ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深度学习注意力机制:从QKV到多头掩码的工程实践

深度学习注意力机制:从QKV到多头掩码的工程实践

简介:一份面向深度学习初学者与研究者的注意力机制专题PPT,围绕Attention的前因后果展开:先通过RNN输入输出非等长问题引出Encoder-Decoder(Seq2Seq)架构,再指出固定长度上下文向量对长序列信息压缩不足、关键信息被稀释的缺陷,从而自然过渡到2014年Bahdanau提出的Attention机制。内容系统覆盖Bahdanau Attention(加性注意力)与Luong Attention(dot、general、concat三种打分方式),并对比Soft Attention、Hard Attention与Local Attention的特点,解释Hard Attention不可微需借助强化学习训练等细节;随后重点讲解Self-Attention如何捕获句子内部句法语义关联,及其在Transformer中提升长序列处理效率的原理。应用层面结合机器翻译、文本摘要、语音识别、图像描述等场景说明注意力如何帮助模型动态聚焦关键信息。资源包内共1个文件,采用PPT幻灯片格式,压缩包大小2.7MB,页面图文排版展示核心公式与网络结构,适合直接用于组会分享、课堂讲授或自学笔记。目前已有1183人学习下载,对希望系统理解注意力机制并快速建立知识框架的读者很有帮助。

1. 当组里让你讲透“深度学习中的注意力机制”,别照着 PPT 念公式

一个常见场景:某公司内部技术分享轮到你,主题就是“深度学习中attention注意力机制”。你下载一堆资料,结果发现要么上来就是 Q、K、V 的公式推导,要么对着 Transformer 结构图反复指指点点,台下的人听完代码照样写不出来。注意力机制这几年几乎统治了 NLP 和 CV 的顶层模型,但大家最缺的其实不是公式,而是“这东西本质是什么、参数怎么设、哪些位置容易翻车”。这篇笔记按我平时做技术分享和带人复现 Attention 的路径整理:先立住直觉,再给可跑的最小代码,最后把踩过的坑一次说清。适合要讲这门课的人、正在调 attention 相关模型的人,以及准备面试的从业者。

2. 注意力机制的底层逻辑:从软寻址到 QKV 的建模过程

2.1 注意力不是一种“机制”,是一种加权求和范式

很多新手在第一步就理解偏了:以为注意力机制是一个类似卷积核那样的“层”。其实它首先是一种计算范式。给定一个查询(Query)和一组键值对(Key、Value),注意力做的事情就是计算查询和每个键的相似度,得到一组权重,再把 Value 按权重求和。整个过程最后输出的还是一个向量,形状和 Value 一致。换句话说,它用“加权求和”替代了“硬选择”:不是只取某一个位置的向量,而是把多个位置的信息按相关程度混合起来。

这个视角能解释很多现象。RNN/LSTM 传递隐状态时要一路按顺序走,长距离信息会被逐步稀释,这就是所谓的信息瓶颈。注意力机制有一个特点:查询可以直接和任意位置的键算相似度,路径长度为 1。所以它不是用来替代某个层,而是为了解决长程依赖问题。你在课件里要强调一点:注意力不是“让模型变聪明的魔法”,而是“给模型一条直连通道”。

为了讲清楚这一点,我习惯先给一个表格,对比三种序列建模方式:

方式信息传递路径最长依赖瓶颈
RNN/LSTM按时间步链式传递O(序列长度)梯度衰减、信息稀释
CNN局部窗口逐层堆叠依赖卷积层数需要大量层才能覆盖长距离
注意力任意两位置直接连接O(1)计算量随长度平方增长

用这张表去组织课件的第一部分,听众能快速建立坐标系。不需要展开梯度消失的数学推导,先让听众接受“路径长短”这个关键视角。后面的 QKV 都是在这条思路上长出来的工具。

2.2 从 Softmax 打分到 QKV:为什么需要三个矩阵

要理解 QKV,先看最朴素版本的注意力:假设我们只有一套向量 x1, x2, ..., xn,想找“x1 和谁最相关”。最直接的办法是让 x1 和所有 xi 做点积,得到相似度分数,再经过 softmax 变成权重,最后用这个权重对 xi 本身做加权求和。这个版本叫作自注意力,但有一个问题:某个位置和自己的点积天然最大,导致每个位置过分关注自己,其他位置的信息很难进来。

解决办法就是把“要拿去比较的向量”和“要被加权汇总的向量”分开,这就是 Q、K、V 的来源。Query 是主动发问的一方,Key 是被比较的标签,Value 是真正要取的内容。三份向量都由同一个输入 x 通过不同的线性变换得到,这样模型可以学出“用哪份内容去比较、用哪份内容去输出”的不同子空间。常见做法是分别乘上 Wq、Wk、Wv 三个权重矩阵,输出维度可以按需设计。

这个部分的最小实现不需要动用什么框架,用 NumPy 就能说明白。我一般会现场拆这么一段:

import numpy as np def scaled_dot_product_attention(Q, K, V, mask=None): # Q/K/V: [seq_len, dim] dim = Q.shape[-1] # 1. 点积计算相似度 scores = Q @ K.T # [seq_len, seq_len] # 2. 除以 sqrt(dim),防止点积过大导致 softmax 进入饱和区 scores = scores / np.sqrt(dim) # 3. 掩码位置用负无穷填充,softmax 之后权重为 0 if mask is not None: scores = np.where(mask, scores, -1e9) # 4. 按行做 softmax,得到每行权重和为 1 weights = np.exp(scores - scores.max(axis=-1, keepdims=True)) weights = weights / weights.sum(axis=-1, keepdims=True) # 5. 用权重对 V 做加权求和 out = weights @ V return out, weights

这段代码里有几个参数值得讲。dim 是每个头的维度,不是整个隐藏维度;除以根号 d 是为了让点积结果的方差保持在 1 左右,否则 softmax 的梯度会非常小。掩码用 -1e9 而不是 0,是因为 softmax 指数函数会把 0 变成 1,达不到屏蔽效果。返回的 weights 是 [seq_len, seq_len] 的矩阵,第 i 行含义是:第 i 个 query 分别对各个 key 的关注程度,这也是后面做热力图的原始数据。

很多讲述者会急着把 multi-head 抛出来,我建议先在这一步停住,让听众亲手算一次上面五个步骤。注意力机制的数学部分其实就这么点内容:点积、缩放、掩码、softmax、加权求和。后面的多头、位置编码、mask 都只是在这五步上做工程化修改。

2.3 打分函数与掩码:加性注意力、点积注意力与 Decoder 掩码

你去看经典论文会看到两种打分函数。早期主流是加性注意力,用一个带 tanh 的单层网络去拟合“查询和键的匹配程度”;后来的 Transformer 系列几乎全部改用点积注意力,因为矩阵乘在 GPU 上极高效,而且配合根号 d 缩放后效果不输加性注意力。做技术分享时不要回避这层差异:点积注意力快的本质是“把匹配打分变成了一次矩阵乘法”,而加性注意力是逐元素在线层里算,没法大规模并行。

掩码是初学者最容易漏掉的一环,也是课件里必须单列的一页。Transformer 里有两种掩码。第一种是 padding mask,因为 batch 内句子长度不一,短句后面补了 0,这些位置不能参与注意力计算;第二种是 causal mask,也叫 look-ahead mask,用在 decoder 里,保证第 t 个位置只能看到前 t-1 个位置,防止信息泄露。

两种掩码都是在 softmax 之前加到 scores 上,而不是在 softmax 之后乘权重。原因很简单:softmax 会把所有输入变成正数,且指数函数对数值敏感。如果在 softmax 之后把权重置 0,虽然权重是 0,但分母已经被污染过,其他位置的权重也会被压缩。用 PyTorch 写就是:

import torch import torch.nn.functional as F def attention_with_mask(Q, K, V, mask): # Q/K/V: [batch, heads, seq_len, dim] scores = Q @ K.transpose(-2, -1) / (Q.size(-1) ** 0.5) # mask 里 True 表示保留,False 表示屏蔽 scores = scores.masked_fill(~mask, float('-inf')) weights = F.softmax(scores, dim=-1) return weights @ V

masked_fill 传入的是布尔掩码,False 的位置会被替换成负无穷。负无穷经过 softmax 后指数为 0,权重精确为 0,不会污染分母。如果代码里把 mask 的 True/False 逻辑写反,症状非常典型:训练时 loss 不降,或者 decoder 每个位置输出几乎一样。后面避坑章节会专门展开这类症状。

3. 把公式讲成工程:多头注意力、位置编码与复杂度控制

3.1 多头注意力:为什么要把特征切成头

单头注意力有一个限制:一组 QKV 只能建模一种匹配模式。比如“主语找谓语”和“代词找指代对象”是两种不同的关系,一个头的注意力权重里这些关系会互相叠加,最后糊成一张平均图。多头注意力的做法是把 Q、K、V 在特征维度上切成 h 份,每一份单独做注意力,然后把 h 份输出拼接,再过一次线性投影。这样每个头相当于在各自的子空间里专注学一类关系。

参数上需要理解两层关系。head_dim = d_model / h,Transformer 基础配置里 d_model=512、h=8,每个头维度就是 64。头的数量不是越多越好:头数增加会显著增加参数量和计算量,而每个头负责什么关系是学出来的,不是人为指定的。实际调参时,如果发现某些头的权重分布特别平,趋近均匀分布,可以先降低头数试试。常见做法是把 h 设为 8 或 16,d_model 越大,头数可以越多。

多头注意力的实现有一个容易写错的位置:reshape 的顺序。PyTorch 里把 [batch, seq_len, d_model] 拆成多头时,要先用 view 变成 [batch, seq_len, h, head_dim],再 transpose 成 [batch, h, seq_len, head_dim]。如果直接用 view 变成 [batch, h, seq_len, head_dim],会把相邻的维度切碎,每个头拿到的是混杂的特征,而不是按特征段切分的。

import torch d_model, h = 512, 8 head_dim = d_model // h x = torch.randn(2, 128, d_model) # [batch, seq_len, d_model] # 正确拆分:先切成 h 段,再交换维度 x_multi = x.view(2, 128, h, head_dim).transpose(1, 2) print(x_multi.shape) # [2, 8, 128, 64] # 错误拆分:直接从 d_model 维度解成多头 x_wrong = x.view(2, h, 128, head_dim) print(x_wrong.shape) # 形状一样,但特征排列完全错了

两种写法最后形状相同,如果不做语义检查,很难发现。区别在于:正确版本是先把 d_model 切成 8 段,每段 64 维;错误版本是每隔 8 个维取一个头,每个头拿到的特征交错混杂。这类维度问题在初始化阶段往往不报错,等到训练时 loss 异常才暴露,是需要警惕的。

3.2 位置编码:给序列模型补上顺序感

把上一节的 self-attention 单独拿出来看,把任意两个 token 交换位置,输出只是对应行也跟着换,权重矩阵完全不变。这意味着纯注意力本身感受不到“顺序”,而语言顺序是携带语义的:“我打你”和“你打我”的词完全一样,含义完全相反。

常见做法是加位置编码。Transformer 原始方案用的是正弦函数,位置 pos 的第 i 维编码为 sin(pos/10000^(2i/d)) 或 cos 的同频率版本。选择正弦函数的理由是:不同位置的编码可以被表示为其他位置的线性函数,模型容易学到“相对位置”的模式。后来也有用可学习位置编码的,效果往往更好,但序列长度变化时不够灵活。

生成正弦位置编码的代码很简短,但有两个细节容易被忽略:一是把位置维度和特征维度分开计算频率,二是偶数维用 sin、奇数维用 cos。课件这一页可以贴上完整生成函数:

import numpy as np def sinusoidal_embedding(seq_len, d_model): pe = np.zeros((seq_len, d_model)) pos = np.arange(seq_len)[:, None] # [seq_len, 1] dim_idx = np.arange(d_model)[None, :] # [1, d_model] # 偶数维用 sin,奇数维用 cos angle = pos / np.power(10000, (2 * (dim_idx // 2)) / d_model) pe[:, 0::2] = np.sin(angle[:, 0::2]) pe[:, 1::2] = np.cos(angle[:, 1::2]) return pe

注意 dim_idx // 2 这一步:同一个频率段里包含一对 sin/cos,所以 10000 的指数用的是 dim_idx//2,而不是 dim_idx。写错这个,位置编码相邻两维的频率会不一致。加法方式上,位置编码是加到输入 embedding 上的,不是拼接。如果你想验证编码是否生效,可以直接检查相邻位置的编码向量余弦相似度:相近位置的相似度高,远距离位置的相似度低,这是注意力要恢复顺序信息的基础。

3.3 复杂度分析与模型选型:O(n²) 的代价

注意力机制最受诟病的一点是计算复杂度。Q 和 K 的点积矩阵尺寸是 [seq_len, seq_len],也就是说时间和内存开销都随序列长度平方增长。这是 attention 的命门,任何做长文本或长序列的人都绕不开。

组件时间复杂度主要内存消耗
自注意力O(n²·d)n² 的权重矩阵
前馈网络O(n·d²)参数矩阵
RNN 按步计算O(n·d²)需要保存每步隐状态

所以你会看到现代模型都在解决同一个问题:如何让注意力不随 n 平方增长。做法大致分三类:稀疏注意力,让每个位置只和局部或固定间隔的位置做注意力;线性注意力,把核函数拆开,把 n² 变成 n·d;以及先做长度压缩再注意力的跨层方案。选型时我一般按序列长度拍板:n 在 512 以下,标准全量注意力完全够用,没必要增加复杂度;n 到 2048 以上,就要考虑窗口限制或稀疏模式;n 到几万甚至百万,基本只能选择线性注意力。

这一节在课件里的价值是给工程选型做判断依据。听众不需要背下常数,但要理解:注意力不是免费的,它用平方复杂度换取了长程依赖的直接路径。这也是“为什么现在大家都在提高效注意力”的背景。

4. 用可视化把注意力“讲活”:课件制作与代码落地

4.1 注意力热力图:从 PyTorch 钩子到权重提取

讲注意力只靠公式很难让人信服。最直观的验证手法是把 attention weights 画成热力图,让听众看到:代词“it”确实关注了前文的名词,decoder 在生成第 5 个词时确实主要在看源句的第 3、4 个位置。这是整份课件里最容易出彩的部分。

提取权重有两种常见做法。一种是在模型代码里修改 forward,直接把 attn_weights 返回出来,简单直接但会改模型结构;另一种是用 PyTorch 的 register_forward_hook 在 Forward 结束后截获模块的输出,不需要改动原模型。后一种我更喜欢,因为做课件时往往用的是别人预训练好的模型,不该为了可视化去动别人代码。

import torch # 假设 model 里有一个名为 encoder_attn 的 MultiheadAttention 模块 attention_outputs = [] def hook_fn(module, input, output): # output[1] 就是 attention weights,形状 [batch, heads, tgt_len, src_len] attention_outputs.append(output[1]) handle = model.encoder_attn.register_forward_hook(hook_fn) with torch.no_grad(): out = model(src_tokens, tgt_tokens) handle.remove() # 取第 0 个样本、第 0 个头,画成热力图 weights = attention_outputs[0][0, 0].cpu().numpy() print(weights.shape) # [tgt_len, src_len]

这段代码有几个值得课堂强调的点。钩子函数拿到的是模块输出元组,MultiheadAttention 输出的第二项才是权重,别取错下标。可视化前最好检查一下每行权重是否和为 1,注意个别位置被 mask 后可能出现 0 行。绘制热力图时,把横轴设成源句 token,纵轴设成目标 token 或当前位置。如果用的是封装良好的第三方模型,要留意它的 forward 是否把 attn_weights 透传了,有些实现为了省内存默认不返回权重,需要在配置里打开输出注意力开关。

4.2 课件页的组织顺序与代码示例:先直觉、再公式、再工程

做一份能讲的讲义的排布顺序比内容本身更重要。我见过不少课件前 10 页全是数学符号,后排听众直接放弃。按我讲下来的经验,顺序应该是:

第一页放一个“注意力长什么样”的成品图,首选机器翻译的注意力对齐矩阵图,不用解释,先让听众看到“斜对角线”这种直观结构。第二页抛出问题:为什么需要注意力,用什么替代 RNN 的链式传递。第三页才进入 QKV 和打分函数的数学,控制在 5 分钟以内。第四页讲多头和位置编码,给出 PyTorch 代码片段。第五页讲两种掩码,这是最容易引出工程问题的部分。第六页放可视化代码和热力图。第七页放训练技巧和避坑清单。

每一页代码不要超过 15 行,超过就拆成两步。课件里出现的代码必须是在本地跑通过的。有一个最常见的翻车点:演示时直接从 notebook 里复制代码,结果输入输出尺寸对不上。建议每段代码固定一个形状:batch=2、seq=128、d_model=512,涉及多头时统一 h=8。这样听众读完代码能自己说出每个维度变化的含义。

4.3 一张图讲清自注意力 vs 交叉注意力

Transformer 里 attention 有两种使用场景,新手容易混。自注意力是 Transformer 原文里更关键的创新:Q、K、V 都来自同一序列,目的是建模句子内部的依赖关系。交叉注意力是 decoder 里每个位置去 attend encoder 的输出:Q 来自 decoder,K、V 来自 encoder 编码后的序列,目的是把源信息“搬”到生成过程中。

类型Query 来源Key/Value 来源作用
自注意力本层输入本层输入建模内部依赖
交叉注意力解码器输入编码器输出建模源与目标的对齐

这个对比表值得单独做成一页。问听众一个问题:为什么交叉注意力的 Key/Value 要用编码器输出,而不是解码器自己的隐状态?答案是编码器输出已经包含了源句的全局上下文,decoder 每生成一个词,都需要重新去源句里“查询”相关信息,而这个查询的向量来自 decoder 当前的状态。回答上来的听众,基本就真正理解了 QKV 的角色分工。

5. 注意力机制避坑:维度、掩码与可视化的五个常见问题

5.1 维度对不上:多头拆分的顺序错位

现象:代码在 Forward 阶段不报错,但训练几轮后 loss 居高不下,甚至完全不降。排查时发现单头注意力正常,换成多头就不对。原因:把 [batch, seq, d_model] 拆成多头时,view/reshape 的顺序写错,导致每个头拿到的不是连续特征段,而是交错拆散的特征。解决:按“先切段、再换轴”的顺序,先 view 成 [batch, seq, h, head_dim],再 transpose(1, 2)。建议在拆分后加一行断言,检查 x_multi.shape 和预期一致。实际维度对了还不够,还要检查第 2 维和第 3 维的语义是否对应 seq 和 head。有一种快速验证法:把所有头的输出拼接后过投影,应该能还原出和单头注意力一致的形状;如果投影后维度错乱,八成是这里出了问题。

5.2 掩码位置放错:应该 mask 的是 softmax 之前的 logits

现象:加了 padding mask 之后,被 padding 的位置仍然有非零权重,或者注意力输出里出现异常大值。原因:掩码被乘在了 softmax 之后的权重上,而不是 softmax 之前的 scores 上。softmax 的输出都是正数,乘法只会缩放权重,无法让屏蔽位置真正归零,并且所有位置的分母已经被污染。解决:先算 scores,再用 masked_fill 把屏蔽位置替换为负无穷,然后才做 softmax。如果用 NumPy 手写,用 np.where 把屏蔽位置设成 -1e9,不要用 0。还有一个容易忽略的点:causal mask 和 padding mask 是两种掩码,要先合并再去 mask。合并方式一般用与运算或者直接相加后裁剪,别用或运算,否则会把本来有效的位置也屏蔽掉。

5.3 可视化热力图全是一个颜色:权重没有归一化或取错了层

现象:画出的热力图颜色非常均匀,看不出注意力集中的位置,或者全是深色一块。原因有三个可能:一是取了 attention 模块的输出而不是权重,输出是经过加权求和后的向量值,范围不受 [0,1] 约束;二是权重矩阵里存在 mask 导致的整行 0,imshow 的默认色标把所有非零值压到了很窄的区间;三是多头的权重被平均后,注意力集中的模式被互相抵消,变成均匀分布。解决:先检查权重取值是否在 0 到 1 之间、每行求和是否为 1,对 mask 位置单独处理,不要直接 imshow。可视化时不要把所有 head 平均,先挑一个 head,或者观察多个 head 的并排图,找权重方差大的那个头。行归一化可以用 weights / weights.sum(axis=-1, keepdims=True),但前提是确认没有整行全 0。

5.4 训练不收敛:Scale 因子、Dropout 位置与初始化

现象:同样的数据、同样的优化器,别人的 attention 模型能收敛,自己实现的不行。原因:缩放因子忘记除以 sqrt(dim),点积结果过大,softmax 进入饱和区,梯度极小;或者 dropout 被加在了 scores 而不是权重上,训练和推理行为不一致。解决:点积后必须除以根号下 head_dim。Dropout 的标准做法是加在 softmax 之后的权重上,也就是权重 dropout,不是对 scores 做 dropout;这两者在推理关闭 dropout 时行为差异很大。初始化方面,QKV 投影矩阵的方差建议按 Xavier 或正态分布小方差初始化,避免初始注意力分布过于尖锐。还有一个小技巧:如果训练初期 loss 下降非常慢,可以先输出 attention weights 的熵,熵值过低说明某个头把全部注意力集中到单一位置,可以考虑调高温度系数,或者检查位置编码是否生效。

5.5 把注意力权重当“特征重要性”:解释性的边界

现象:有人拿着 attention 热力图下结论:权重高的位置就是模型做判断的最重要特征。原因:注意力权重反映的是信息聚合的强度,而不是因果归因。同一个词在不同头里权重差异很大,而且权重受掩码、序列长度、训练状态影响,不代表最终预测的贡献度。解决:不要把热力图当成严谨的解释工具,课件里要明确标注“这是相关性,不是因果性”。如果想做更可靠的解释,使用梯度归因方法,计算输出对输入 embedding 的梯度,再乘以输入,得到 saliency map,至少比直接看权重可靠。做技术分享时主动澄清这一点,反而比一味吹 attention 的可解释性更能让人信服。

6. 从课件到落地:用一个小型复现验证你对注意力机制的理解

讲完这么多,最后给一个能在一小时内从零跑通的验证方法:用纯 NumPy 实现 10 行以内的注意力,然后构造一组词向量来验证“相近的词之间注意力权重高”。

import numpy as np x = np.array([ [1.0, 0.0], [0.0, 1.0], [1.0, 0.0], ]) # 第 0 个和第 2 个向量相同 Q = K = V = x scores = Q @ K.T / np.sqrt(2) weights = np.exp(scores - scores.max(axis=1, keepdims=True)) weights = weights / weights.sum(axis=1, keepdims=True) print(np.round(weights, 3))

这个数组里第 0 个向量和第 2 个向量完全相同,所以理论上第 0 行的权重应该主要落在第 0 和第 2 列上,第 1 列权重很小。如果你打印出来发现三列权重几乎平均,说明实现里可能漏了缩放,或者 softmax 写错了。在写大模型代码前先跑通这个最小验证,能省下大量排查时间;讲课时现场演示,听众也能立刻把公式和数字对应上。

我自己的教训是:有一年做技术分享,为了炫技直接跑一个大模型的可视化,结果现场因为显存不足翻车。后来我把所有演示代码都换成这种十几行的最小复现,听众反而更容易跟上,因为每一步都能对应到刚才讲的公式。做技术分享也好、面试准备也好,能用最小代码讲清楚,才是真的理解。希望这份整理能帮你在做注意力机制课件时少走弯路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进