首页 / 资讯中心 / 文章详情

Transformer核心原理:从自注意力到多头注意力的代码级拆解

Transformer核心原理:从自注意力到多头注意力的代码级拆解 ★ FEATURED ARTICLE
简介基于论文《Attention is all you need》的Transformer详解课件面向NLP初学者与进阶学习者系统梳理从传统Seq2Seq模型、Attention机制到Transformer的完整演进脉络。课件以神经机器翻译NMT为例对比RNN因顺序计算无法并行、训练效率低下的局限引出Transformer完全基于自注意力机制的创新架构随后从宏观到微观拆解编码器与解码器结构覆盖多头注意力、位置编码、Padding Mask等关键模块并逐步演示Q、K、V向量的生成、点积缩放、softmax加权求和及矩阵化实现过程。训练阶段的损失优化细节与推理阶段的自回归解码策略亦单独成节帮助读者形成从原理到应用的闭环理解。资源为单个pptx演示文稿整包约17.08MB已有3560人学习下载适合用于课程备课、论文导读、组会分享或自学笔记。1. Transformer凭什么取代RNN从一篇论文到NLP标配架构如果你训练过一个基于RNN的神经机器翻译NMT模型一定体会过那种“等它出结果”的煎熬每个词都要等上一个词算完GPU再强也只能按顺序跑训练一个中英翻译模型动辄好几天。2017年那篇《Attention Is All You Need》直接把这条路掀了——Transformer抛弃循环结构全靠自注意力并行处理整个序列训练速度提升一个量级。这份PPT就是那篇论文的导读版从Seq2Seq痛点讲到Transformer的每一层计算细节包含工作流程图、QKV推导和多头注意力拆解适合刚入门NLP、准备面试或是要动手实现Transformer的开发者。它不教你调包而是把黑匣子打开让你看清每一步在算什么。2. 为什么是AttentionSeq2Seq演进与并行化架构选型逻辑2.1 传统Seq2Seq的痛点顺序计算的硬件天花板Seq2Seq模型由编码器和解码器构成编码器读入源序列并压缩成语义向量解码器逐词生成目标序列。在RNN架构下t时刻的隐状态必须依赖t-1时刻的输出这意味着整个序列无法并行计算。PPT里明确点出了这个死穴“RNN及其变体模型无法并行计算模型效率低下”。问题还不止训练慢。RNN在长序列上存在信息衰减越早的输入经过多步传递后对当前输出的影响越弱。机器翻译里源句子和目标句子的对应关系往往跨越很长距离RNN的隐状态很难把这些远程依赖完整带过来。后来的Seq2Seq with Attention做了补救在解码每一步直接去“翻”编码器所有位置的信息相当于给模型开了一条直达通道。但注意那时候的Attention只是辅助组件主干仍然是RNN顺序计算瓶颈没解决。所以在2017年之前主流NMT模型的训练效率基本被RNN的顺序特性锁死了。GPU再快也只能等上一个时间步算完——这是硬件层面的硬限制不是优化能绕过去的。Transformer的思路很直接干脆把循环结构整个去掉让所有位置同时参与计算顺序问题交给位置编码来补充。2.2 Attention机制与Transformer整体架构的引入《Attention Is All You Need》的标题就是它的核心主张只要注意力机制不要循环。这篇论文提出了一套完全基于自注意力Self-Attention的架构PPT里给出了完整的架构图和模块拆解。Transformer整体分两半编码器Encoder和解码器Decoder。编码器由N个完全相同的Block堆叠而成原论文里N6每个Block内部有两层子结构第一层是Multi-Head Self-Attention后接Add残差连接和NormLayerNorm第二层是Feed Forward前馈网络同样后接Add和Norm解码器也是N个Block堆叠但每层有三段结构Masked Multi-Head Self-Attention Add NormMulti-Head Co-Attention也叫Encoder-Decoder Attention Add NormFeed Forward Add Norm编码侧的数据流是输入序列经过Embedding和位置编码后进入第一个BlockBlock1到BlockN-1的输出依次传给下一个Block最后一个Block的输出作为处理后的源序列表示。这条表示在解码器的每一层都会用到——具体地说是喂给解码器的Co-Attention层让解码器能“看”到完整源句子。解码器侧的数据流类似目标序列训练时是真实译文推理时是已生成的词经Embedding和位置编码后进入Block前三段子层依次处理后最后一个Block的输出送进Linear层和Softmax得到每个候选词的概率分布。这里的选型逻辑值得细想为什么要多头注意力为什么每个子层后面都要残差加归一化道理在于多头注意力让模型在不同表示子空间里并行捕捉关联残差连接缓解深层网络梯度消失LayerNorm让每层输入分布稳定。PPT里用三层结构图One-Head / Two-Heads / All-Heads演示了注意力从单头到多头的可视化演变直观说明多头对特征捕捉粒度的提升。2.3 编码器与解码器的Block内部结构残差、归一化与子层顺序把Block拆开看每个子层的计算模式是一致的先做子层函数注意力或前馈网络再做残差相加最后过LayerNorm。写成公式就是LayerNorm(x Sublayer(x))残差连接解决的核心问题是网络堆深之后梯度消失。Transformer动辄6层甚至12层起步如果没有短路连接深层Block的梯度很难传回浅层。Add操作不引入额外参数计算量几乎为零性价比极高。LayerNorm在这里和BatchNorm有个重要区别BatchNorm在batch维度上统计均值和方差受batch大小影响且序列长度不同时统计量不稳定。LayerNorm则对每个样本的每个时间步向量做归一化不依赖batch内其他样本。对NLP任务输入序列长度不固定LayerNorm明显更合适。解码器的Masked Self-Attention是编码器没有的——它的作用是在训练阶段挡住“未来信息”。翻译任务里生成第3个词时不该看到第4、5个词长什么样。这个mask是一个上三角矩阵把未来位置的值置为负无穷经过softmax后权重归零注意力就只落在当前及之前的词上。关于mask的更多细节第4章会具体展开。3. 工作流程逐步骤拆解从嵌入、位置编码到多头注意力的计算路径3.1 Word Embedding与Positional Encoding怎么拼Transformer的输入不是原始词而是词嵌入向量加上位置编码。词嵌入把每个词映射成一个固定维度的稠密向量维度用d_model表示PPT里默认是512。问题在于自注意力对位置不敏感——它计算的是词与词之间的关联权重如果把“我爱你”三个词的嵌入向量交换位置注意力计算出的权重是一样的。机器翻译里词序是关键信息所以必须把位置信息编码进输入。位置编码的生成方式用正弦和余弦函数不同频率对应不同位置的模式import numpy as np def positional_encoding(max_len, d_model): pe np.zeros((max_len, d_model)) for pos in range(max_len): for i in range(0, d_model, 2): pe[pos, i] np.sin(pos / (10000 ** (i / d_model))) pe[pos, i 1] np.cos(pos / (10000 ** (i / d_model))) return pe # 参数说明 # max_len: 支持的最大序列长度, 实际使用时应大于训练语料中最长句子 # d_model: 词嵌入维度, 与后续所有层的特征维度保持一致 pe positional_encoding(100, 512) print(pe.shape) # (100, 512)这里为什么用不同频率的sin/cos组合因为不同频率的函数组合能让模型学到相对位置关系——比如位置5和位置6的编码向量之差是有规律的而不像one-hot位置编码那样各位置完全正交、什么规律都学不到。位置编码和词嵌入是相加而不是拼接。相加的妙处在于不改变输入维度——两个512维向量相加得到512维如果拼接就变成1024维后续的权重矩阵维度要全部翻倍计算量起飞。而且相加后每个位置的特征同时包含语义信息和位置信息Attention内部计算时两者会自然交互。3.2 自注意力六步Q、K、V生成到加权求和PPT里把自注意力的计算拆成了六步这六步是整个Transformer的核心。我先说宏观直觉每个词输出一个新向量这个向量是“全序列所有词按照相关性加权求和”的结果。第一步输入向量X分别乘上三组权重矩阵WQ、WK、WV得到查询向量Q、键向量K、值向量V。Q代表“我想找什么”K代表“我是什么”V代表“我真正携带的信息”。以句子“The animal didnt cross the street because it was too tired”为例词“it”的Q会和所有词的K做匹配匹配结果决定“it”要从哪些词身上取信息——直觉上应该聚焦在“animal”上。具体到代码六步可以一次矩阵化实现import numpy as np def self_attention(X, W_Q, W_K, W_V): # X 形状: (seq_len, d_model) # W_Q, W_K: (d_model, d_k) W_V: (d_model, d_v) # 第一步: 生成 Q、K、V Q np.dot(X, W_Q) # (seq_len, d_k) K np.dot(X, W_K) # (seq_len, d_k) V np.dot(X, W_V) # (seq_len, d_v) # 第二步: Q和K点积, 计算每对位置的相关性分数 scores np.dot(Q, K.T) # (seq_len, seq_len), scores[i][j] 表示 i 位置对 j 位置的关注分数 # 第三步: 除以 d_k 的平方根, 防止点积过大导致 softmax 饱和 d_k Q.shape[-1] scores scores / np.sqrt(d_k) # 第四步: softmax 归一化成权重 exp_scores np.exp(scores - np.max(scores, axis-1, keepdimsTrue)) # 减最大值防溢出 weights exp_scores / np.sum(exp_scores, axis-1, keepdimsTrue) # 第五步第六步: 值向量加权求和, 得到输出 output np.dot(weights, V) # (seq_len, d_v) return output, weights为什么除以维度的平方根这是论文里明确给出的缩放技巧。当d_k比较大时Q和K的点积数值会变得很大把softmax推到梯度极小的饱和区——所有权重都接近0或1反向传播基本失效。除以sqrt(d_k)相当于把点积的方差拉回1附近让softmax梯度保持敏感。这个小细节直接影响训练稳定性很多人自己实现时漏了这一步结果模型怎么训都不收敛。第六步的本质是“按权重取信息”weights矩阵的每一行描述了一个词应该从哪些词那里获取多少信息用它去加权V矩阵的每一行得到该位置新表示。3.3 Multi-Head Attention多头分解、拼接与维度还原单个注意力头只能捕捉一种关联模式。但语言里的关系是多种多样的——语法关系、指代关系、语义相似性都同时存在。多头注意力的思路是把Q、K、V切到多个低维子空间里让每个头学不同维度的关联。PPT里的流程很清晰X分别乘WQ、WK、WV得到完整的Q、K、V然后按头数h切分。假设d_model512h8那么每个头的维度就是512/864。每个头独立做缩放点积注意力产出64维向量最后把所有头的结果拼接成512维再乘一个输出权重矩阵WO做线性变换import numpy as np def multi_head_attention(X, W_Q, W_K, W_V, W_O, h): # X: (seq_len, d_model) d_model 必须能被 h 整除 d_model X.shape[-1] d_k d_v d_model // h # 1. 整体投影, 再按头切分 Q np.dot(X, W_Q) # (seq_len, d_model) K np.dot(X, W_K) V np.dot(X, W_V) # 2. 切成 h 个头: (h, seq_len, d_k) Q Q.reshape(Q.shape[0], h, d_k).transpose(1, 0, 2) K K.reshape(K.shape[0], h, d_k).transpose(1, 0, 2) V V.reshape(V.shape[0], h, d_v).transpose(1, 0, 2) # 3. 每个头独立计算注意力 scores np.matmul(Q, K.transpose(0, 2, 1)) / np.sqrt(d_k) weights np.exp(scores - np.max(scores, axis-1, keepdimsTrue)) weights weights / np.sum(weights, axis-1, keepdimsTrue) head_outputs np.matmul(weights, V) # (h, seq_len, d_v) # 4. 拼接所有头, 还原到 d_model 维度 concat head_outputs.transpose(1, 0, 2).reshape(X.shape[0], d_model) output np.dot(concat, W_O) # (seq_len, d_model) return output, weights几个维度细节容易绕晕对照着看切分前Q的维度是(seq_len, d_model)reshape成(seq_len, h, d_k)后transpose变成(h, seq_len, d_k)——目的是让头维度在最外层matmul时一次性计算所有头的矩阵乘法。每个头独立维护自己的注意力分布。PPT里用One-Head、Two-Heads、All-Heads三张热力图演示了这个效果单头只能看到一种对齐模式多头叠加后覆盖了句法、指代、语义等多种关联。最后拼接时注意顺序不能乱要把头的维度重新挪回内层再做reshape否则词和向量维度会错位。3.4 Padding Mask-inf遮罩的实现与边界实际训练时一个batch里的句子长度不一样要统一padding成max_sequence_length。但padding的位置是无效信息注意力不该去关注它们。如果不做处理模型会白白计算这些位置的权重还可能引入噪声。PPT里给出了具体的mask示例——“0011100000”之类的0/1序列以及“-inf -inf -inf”的填充方式。做法是在softmax之前把padding位置对应的分数改成负无穷这样softmax计算出的权重就趋近于0import numpy as np def padding_mask(seq, pad_token_id0): # seq: (batch_size, seq_len) 的 token id 序列 # 返回: (batch_size, 1, 1, seq_len) 便于广播 mask (seq pad_token_id) # padding 位置为 True return mask.astype(np.float32) * -1e9 # 转成很大的负数 # 使用方法: 在 scores 上直接加 mask # scores: (batch_size, heads, query_len, key_len) # mask padding_mask(input_ids).unsqueeze(1) # 广播到所有头和 query 位置 # scores scores mask # padding 位置变成很大的负数, softmax 后接近 0这段逻辑的核心是“加负数”而不是“乘0”。原因在softmax的运算机制如果直接把padding位置乘0那这些位置的分数变成0经过softmax后仍然会分到一部分权重——因为0也是一个有效值。只有把分数压到负无穷softmax指数运算后才会趋近于0。这里有几个边界值得注意。padding mask应加在Q和K的点积结果上也就是softmax之前而不是作用在最终输出上。如果加错了位置padding位置的权重并不会真正归零。另外mask加的是-1e9或更小的负值不是负无穷——因为实际浮点运算里直接用-inf会带来NaN风险。4. 解码器与训练/推理细节Masked Attention、Co-Attention与解码策略4.1 Masked Self-Attention如何遮住未来的词解码器的目标和编码器不同。编码器可以看完整句因为它要理解整个源句子解码器要逐词生成译文生成第t个词时只能依赖已经生成的前t-1个词不允许“偷看”未来的词。但在训练阶段为了利用GPU并行能力通常把整句目标序列一次喂给解码器。这就产生了矛盾并行处理时所有位置同时计算位置3的注意力会自然地看到位置4、5的信息——信息泄露。解决方法是Masked Self-Attention在自注意力计算的scores矩阵上加一个上三角掩码把“未来”方向的所有值都遮掉import numpy as np def causal_mask(seq_len): # 生成因果掩码: 下三角为0, 上三角为很大的负数 mask np.triu(np.ones((seq_len, seq_len)), k1) return mask * -1e9 def masked_self_attention(Q, K, V, mask): d_k Q.shape[-1] scores np.matmul(Q, K.transpose(0, 2, 1)) / np.sqrt(d_k) # mask 形状: (seq_len, seq_len), 与 scores 广播相加 scores scores mask weights np.exp(scores - np.max(scores, axis-1, keepdimsTrue)) weights weights / np.sum(weights, axis-1, keepdimsTrue) return np.matmul(weights, V)掩码矩阵的对角线是保留的——位置t可以看自己。k1参数让主对角线以上的元素全部置为负值。这个mask只针对解码器的第一个自注意力层编码器不需要因为编码器没有“生成顺序”的概念。实际工程里因果mask很少单独存在。更常见的是把padding mask和causal mask合在一起用因为目标序列里也可能有padding token。合并方式很简单两个mask都是“很大的负数”形式直接相加就行。但要注意广播维度——padding mask通常是(batch, 1, 1, seq_len)causal mask是(seq_len, seq_len)需要先统一形状再相加。4.2 Co-Attention解码器如何读取编码器全局信息解码器中间的这层Multi-Head Attention是最容易理解错的地方。它和第一层的区别不在计算方式而在Q、K、V的来源第一层Masked Self-AttentionQ、K、V全部来自解码器自身上一层的输出中间这层Co-AttentionQ来自解码器上一层的输出也就是第一层处理后的结果但K和V来自编码器最后一层的输出这就是编码器-解码器之间的信息桥梁。翻译“The animal didnt cross the street”时解码器在生成“animal”对应的译文词时需要回到编码器输出里寻找源语言里哪个位置的语义最相关。Co-Attention就是做这件事的——从编码器输出的所有位置中检索、聚合信息供解码器参考。这一层没有mask因为解码器必须能看到完整源句子才能翻译。这和第一层的因果约束不冲突——“不能看未来词”约束的是目标序列内部的信息流而看向源序列的完整信息是合理且必要的。维度对应关系值得记一下Co-Attention的Q维度是(batch, target_seq_len, d_model)K/V维度是(batch, source_seq_len, d_model)点积之后得到的scores形状是(batch, target_seq_len, source_seq_len)——每个目标位置对每个源位置的注意力分数。4.3 训练阶段与推理阶段Teacher Forcing与解码策略训练和推理在策略上有一个重要区别。训练时使用Teacher Forcing不管模型当前位置预测得对不对都强制把真实译文词作为下一位置的输入。好处是训练速度快、收敛稳定模型不用承担“一步错步步错”的风险。推理时没有真实译文可用只能自回归生成把已生成的结果拼接到输入末尾再继续预测下一个词直到输出终止符。PPT里提到“逐词生成输出序列每次生成一个词后将其加入到已生成序列的末尾”说的就是这种策略。最直观的解码策略是贪心搜索——每步取概率最大的词def greedy_decode(model, encoder_output, start_token, end_token, max_len): # 初始化解码器输入: 只有起始符 decoder_input [start_token] for step in range(max_len): # 预测下一个词的概率分布 logits model.decode(encoder_output, decoder_input) # 取当前步骤概率最大的词 next_token np.argmax(logits[step], axis-1) decoder_input.append(next_token) if next_token end_token: break return decoder_input贪心的缺陷是局部最优如果第1步选了个次优词后续再怎么调也回不到全局最优。束搜索Beam Search是更常用的折中——每步保留top-k个候选序列k通常取3到8搜完再按累计概率打分。对于Transformer推理阶段还有个涉及mask的实操细节。训练时用长度固定的序列和mask并行计算推理时每次只新增一个词mask矩阵也要动态扩展。工程实现里通常先分配好max_gen_len的缓存再用mask把未生成的位置遮掉而不是每次重新创建矩阵——这个优化能显著减少内存分配和拷贝开销。5. 避坑与常见问题维度错乱、mask失效与注意力发散的五条记录5.1 现象矩阵乘法报错QKV维度怎么都对不上一开始照着PPT公式实现Self-Attention最常见的翻车现场是维度对不上。X的维度明明是(batch, seq_len, d_model)乘完WQ之后想要(batch, heads, seq_len, d_k)reshape和transpose搞错一个顺序整个矩阵就变成了(batch, seq_len, heads, d_k)后面matmul直接报错。原因是对多头切分的维度顺序没有想清楚。解决方法是统一约定所有中间张量都按(batch, heads, seq_len, d_head)组织。reshape时先拆出heads维度transpose把heads挪到第1维这样matmul才能一次性处理所有头的计算。我一般会在每个关键步骤后打印shape对照预期值检查不用等到报错再排查。5.2 现象padding位置权重不为0模型总在padding上浪费注意力训练loss一直降不下去检查注意力热力图发现句子末尾的padding位置有高权重。原因是把mask乘在了softmax的输出上而不是加在softmax前的scores上。乘法无法真正清零——softmax输出里padding位置本来就有一个小概率乘0之后还是会残留数值。正确做法是把mask转成很大的负数-1e9或-1e4直接加到scores矩阵上在softmax之前完成遮罩。验证方法也简单把mask矩阵单独打印出来检查padding位置的值是否远大于普通位置的分数。5.3 现象训练和推理效果差距巨大翻译结果颠三倒四模型在训练集上loss很好看一到推理就崩。最常见的原因是训练时没用Masked Self-Attention或者mask加错了位置。我排查的时候先检查解码器第一层注意力矩阵打印一个随机样本的注意力权重如果右上角未来位置出现明显的大权重说明mask没生效。解决办法是在训练阶段就严格加上因果mask。另一个容易被忽略的点是Teacher Forcing期间模型看到的是真实译文推理时看到的是自己生成的词分布天然有差异。这属于正常现象可以通过推理时适当降低采样温度、增大beam size来缓解。5.4 现象位置编码和词向量拼在一起模型死活不收敛有人想“既然位置信息重要拼接总比相加保留更多信息吧”结果模型训练震荡很久都不收敛。原因是拼接让每个位置的输入维度翻了倍后续所有线性层的参数量翻倍梯度更新模式完全改变且位置编码和语义信息被强行隔离在各自维度里注意力很难同时利用它们。PPT里的设计是相加。相加意味着位置编码作为偏移量叠加在词向量上注意力计算时两者自然融合而且不引入额外参数量。想保留更多位置信息的话可以尝试可学习的相对位置编码或旋转位置编码而不是简单拼接。5.5 现象padding mask和causal mask相加时维度不匹配实现解码器时要把两个mask合起来用直接相加报错或者算出来结果全错。原因是两个mask的形状不同padding mask是(batch, 1, 1, seq_len)causal mask是(1, seq_len, seq_len)广播规则不同直接相加维度冲突。解决方法是先把两个mask都扩展成(batch, heads, seq_len, seq_len)的完整形状再相加。实际项目里我习惯直接把两个mask分别生成后相加再检查结果矩阵——目标位置上应当是只保留了当前词和过去词的权重padding位置全部为负值。6. 进阶验证技巧用注意力热力图检验编码质量把模型跑通只是第一步——模型学到的注意力模式是否合理直接决定你是否敢把它接进下游任务。PPT的多头注意力部分用One-Head、Two-Heads、All-Heads三张图直观展示了不同数量头的注意力分布差异这个可视化思路完全可以自己复现用来验证你实现的Transformer有没有“学对”。具体做法是拿一个英文句子比如“The animal didnt cross the street because it was too tired”走一遍编码器的某层Self-Attention把每对词的注意力权重画成热力图import matplotlib.pyplot as plt import numpy as np def visualize_attention(tokens, attention_weights, head_idx, save_path): # tokens: 输入句子分词后的token列表 # attention_weights: (heads, seq_len, seq_len) 的注意力矩阵, 需先从模型里取出 weight attention_weights[head_idx] # 选中第 head_idx 个头 plt.figure(figsize(12, 10)) plt.imshow(weight, cmaphot, aspectauto) plt.colorbar(labelAttention Weight) plt.xticks(range(len(tokens)), tokens, rotation45, haright) plt.yticks(range(len(tokens)), tokens) plt.title(fHead {head_idx} Self-Attention Visualization) plt.tight_layout() plt.savefig(save_path, dpi150)拿到热力图后怎么判断好坏第一看对角线附近正常的自注意力总会在自己和紧邻词上有较高权重这是基础的语言局部性。第二看是否有明显的“注意力发散”模式——某些位置的权重几乎均匀分布在所有词上说明这个头没学到有效特征可能需要调整头数或检查Embedding是否训练充分。第三检查“it”这类代词位置的注意力理想的模式是高权重落在它指代的名词上就像原文里“it”应该聚焦在“animal”上。第四验证mask生效在解码器层热力图右上角应该是一片黑色——权重确实被遮成了0。我还习惯把多头的热力图拼成一张大图按头序号排列快速对比不同头学的模式是否重复。如果8个头里一大半长得一模一样说明多头没有真正分化——常见原因是d_model太小每个头分到的维度太窄表达力不足。这种情况下我会先增大d_model再考虑调整头数。从那以后我每次实现完一个Transformer变体都会强制自己跑一遍这个可视化流程。不光是图个安心它能帮你在训练初期就发现mask错位、维度错配、学习率异常这些问题不用等几个小时的训练跑完才发现模型根本没在学习。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站