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

从零手写自注意力机制:单头到多头与工程实践

从零手写自注意力机制:单头到多头与工程实践 ★ FEATURED ARTICLE
自注意力机制这个东西我第一次认真去啃它的时候走了不少弯路。当时我已经能跑通一些大模型推理脚本也能用现成框架微调个小模型但每次看到论文里的 ( Q )、( K )、( V ) 和那一堆矩阵乘法总觉得隔了一层纱。后来我逼着自己从零手写了一遍单头和多头自注意力又拿几个真实场景做了对照实验才算真正把这块骨头啃下来。这篇内容就是把我踩过的坑、想明白的道理、以及实际写代码时总结出来的经验完整地摊开讲一遍。如果你正在学 LLM 相关的东西或者已经会用但说不清楚自注意力到底在干什么又或者你准备自己动手实现一个简化版 Transformer那这篇内容应该能帮到你。我会从设计动机讲到矩阵运算从单头讲到多头再讲到实际工程里那些文档不会写的细节。全程不堆公式吓人能用生活类比的地方就用类比能上代码的地方就上代码。1. 为什么自注意力值得单独拿出来讲1.1 从“词向量查表”到“上下文感知”的跨越早期做 NLP 的时候我们习惯把每个词映射成一个固定向量也就是词嵌入。这个做法有个根本问题同一个词在不同句子里意思可能完全不一样。比如“苹果”在“我吃了一个苹果”和“苹果发布了新手机”里语义指向完全不同但查表得到的向量是一模一样的。这就导致下游任务经常被这种歧义拖累。有人可能会说那我用循环神经网络按顺序读句子理论上后面的隐藏状态能记住前面的信息。方向是对的但 RNN 有两个硬伤一是长距离依赖容易衰减句子一长前面的信息传到后面就剩不下多少了二是它必须串行计算第 ( t ) 步依赖第 ( t-1 ) 步没法并行训练效率上不去。自注意力机制换了个思路既然每个词的含义由上下文决定那我干脆让每个词直接去“看”句子里所有其他词根据相关程度动态地聚合信息。这样一来词向量不再是固定的而是随上下文变化的。更重要的是所有位置的“看”可以同时进行天然适合并行计算。这个设计思路的转变是后面一切大模型能 scale 起来的基础。1.2 自注意力到底解决了什么问题用一句话概括自注意力让序列中每个位置都能直接访问其他所有位置的信息并根据相关性加权聚合。它解决的核心问题是长距离依赖建模和并行计算这两个看似矛盾的需求。传统卷积网络要靠堆层数来扩大感受野RNN 要靠时间步传递信息而自注意力一层就能让任意两个位置直接建立联系。距离为 1 和距离为 100 的词在自注意力里的交互路径长度是一样的都是 1。这个性质非常关键它意味着模型不会因为距离远就“忘记”前面的内容。另外自注意力本身不带位置信息它只看内容相关性。这既是优点也是缺点。优点是灵活缺点是如果不额外注入位置编码模型分不清“猫追狗”和“狗追猫”。所以实际实现里位置编码是标配这个后面会细讲。1.3 适合哪些人深入理解这篇内容适合三类人。第一类是对 LLM 有基本了解但没亲手推导过自注意力计算过程的人。第二类是准备自己实现简化版 Transformer需要搞清楚每个矩阵形状和运算含义的人。第三类是在实际项目中遇到注意力相关问题比如显存爆炸、训练不稳定想从原理层面找原因的人。如果你完全没接触过神经网络建议先补一下全连接层和矩阵乘法的基本概念不然读起来会有点吃力。但只要你有基本的线性代数直觉剩下的部分我会尽量讲得直白。2. 自注意力的核心设计思路拆解2.1 用“查询-键-值”类比理解注意力自注意力最核心的抽象是 Query、Key、Value 三个矩阵。很多人第一次看到这三个词是懵的我用一个检索系统的类比来解释。想象你有一个资料库里面存了很多条信息每条信息有一个“标签”和一个“内容”。现在你拿着一个“查询条件”来找资料。系统会拿你的查询条件去和每条信息的标签做匹配算出一个匹配分数然后根据分数高低把对应的内容加权汇总给你。在这个类比里Query 就是你的查询条件Key 是每条信息的标签Value 是每条信息的内容匹配分数就是注意力权重加权汇总的结果就是注意力输出放到自注意力里每个词自己既产生一个 Query也产生一个 Key 和一个 Value。也就是说每个词都拿着自己的查询条件去和句子里所有词的标签匹配然后聚合所有词的内容。这就是“自”注意力的含义——查询和键值来自同一个序列。2.2 为什么是点积而不是其他相似度度量计算 Query 和 Key 的匹配分数最直接的方式是点积。为什么选点积因为点积在数学上等价于衡量两个向量的方向一致性而且计算效率极高可以直接转化成矩阵乘法在 GPU 上跑得飞快。具体来说两个向量 ( q ) 和 ( k ) 的点积 ( q \cdot k ) 越大说明它们方向越接近相关性越高。如果两个向量正交点积为零表示不相关。如果方向相反点积为负表示负相关。有人会问那用余弦相似度行不行余弦相似度是点积除以模长理论上更纯粹地衡量方向。但在实际实现里因为后面有缩放和 softmax模长的影响会被部分吸收而且除以模长会引入额外的计算和数值不稳定风险。所以主流实现都用缩放点积也就是点积之后除以 ( \sqrt{d_k} )。2.3 缩放因子的数学直觉为什么要除以 ( \sqrt{d_k} )这里的 ( d_k ) 是 Key 向量的维度。假设 ( q ) 和 ( k ) 的每个分量都是独立同分布均值 0方差 1那么它们的点积 ( q \cdot k \sum_{i1}^{d_k} q_i k_i ) 的均值是 0方差是 ( d_k )。也就是说维度越大点积的方差越大数值波动越剧烈。点积数值太大有什么问题后面要接 softmaxsoftmax 对输入数值很敏感。如果输入值很大softmax 的输出会趋近于 one-hot也就是某个位置权重接近 1其他接近 0。这会导致梯度非常小训练时参数几乎不更新也就是梯度消失。除以 ( \sqrt{d_k} ) 之后方差被拉回到 1 附近softmax 的输入分布更温和梯度更健康。这个细节看起来小但实际训练大模型时如果不做缩放收敛会明显变差。我早期自己写实现时偷懒省掉了缩放结果 loss 降得特别慢排查了半天才发现是这个原因。2.4 注意力权重的归一化与加权求和算出缩放点积分数后要对每一行做 softmax把分数转成概率分布。softmax 的作用是让所有位置的权重非负且和为 1这样输出就是所有 Value 的凸组合数值稳定解释性也好。softmax 之后得到注意力权重矩阵形状是“序列长度 × 序列长度”。每一行表示某个位置对所有位置的关注程度。然后用这个权重矩阵去乘 Value 矩阵得到输出。输出的每一行就是对应位置聚合后的新表示。整个流程用公式写就是[ \text{Attention}(Q, K, V) \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V ]这个公式看起来简洁但每一步都有讲究。( QK^T ) 是分数矩阵除以 ( \sqrt{d_k} ) 是缩放softmax 是归一化最后乘 ( V ) 是加权聚合。理解了这四步自注意力的核心就掌握了。3. 从零实现单头自注意力3.1 输入表示与线性投影假设我们有一个长度为 ( n ) 的序列每个词用 ( d_{model} ) 维向量表示那么输入矩阵 ( X ) 的形状是 ( n \times d_{model} )。自注意力不直接拿 ( X ) 去算而是先做三个线性投影分别得到 Query、Key、Value[ Q XW_Q, \quad K XW_K, \quad V XW_V ]其中 ( W_Q )、( W_K )、( W_V ) 都是可学习参数形状为 ( d_{model} \times d_k )。这里 ( d_k ) 是投影后的维度通常取 ( d_{model} / h )( h ) 是头数。单头情况下 ( h1 )( d_k d_{model} )。为什么要做投影因为原始词向量空间不一定适合直接做相关性匹配。通过线性变换模型可以学习到更适合计算注意力的表示子空间。这相当于给模型更大的灵活性让它自己决定用什么特征去匹配、用什么特征去聚合。代码实现上可以用一个线性层同时输出三份也可以分开三个线性层。我习惯分开写调试时更清晰import torch import torch.nn as nn import math class SingleHeadAttention(nn.Module): def __init__(self, d_model): super().__init__() self.d_model d_model self.w_q nn.Linear(d_model, d_model) self.w_k nn.Linear(d_model, d_model) self.w_v nn.Linear(d_model, d_model) self.scale math.sqrt(d_model) def forward(self, x, maskNone): Q self.w_q(x) K self.w_k(x) V self.w_v(x) scores torch.matmul(Q, K.transpose(-2, -1)) / self.scale if mask is not None: scores scores.masked_fill(mask 0, float(-inf)) attn torch.softmax(scores, dim-1) out torch.matmul(attn, V) return out, attn这段代码里mask是可选的用于处理 padding 或者因果掩码。因果掩码在解码器里很关键后面会讲。3.2 分数矩阵的计算与形状变化分数矩阵 ( QK^T ) 的形状是 ( n \times n )。这个矩阵的第 ( i ) 行第 ( j ) 列表示第 ( i ) 个位置对第 ( j ) 个位置的原始关注分数。矩阵乘法的过程是( Q ) 的每一行和 ( K ) 的每一行做点积得到 ( n \times n ) 个分数。这里有个容易混淆的点( Q ) 和 ( K ) 都是 ( n \times d_k )( K^T ) 是 ( d_k \times n )所以 ( QK^T ) 是 ( n \times n )。这个 ( n \times n ) 的矩阵就是注意力权重的来源。当序列很长时这个矩阵会非常大。比如 ( n8192 )单精度浮点数下这个矩阵就要占 ( 8192 \times 8192 \times 4 ) 字节约 256 MB。如果多头、多层叠加显存压力会急剧上升。这也是后面各种高效注意力变体要解决的问题。3.3 softmax 的数值稳定性处理softmax 的公式是[ \text{softmax}(x_i) \frac{e^{x_i}}{\sum_j e^{x_j}} ]直接按这个公式算如果 ( x_i ) 很大( e^{x_i} ) 会溢出如果 ( x_i ) 很小会下溢到 0。实际实现里都会先减去每行的最大值[ \text{softmax}(x_i) \frac{e^{x_i - \max(x)}}{\sum_j e^{x_j - \max(x)}} ]这样指数部分最大是 0不会溢出数学上结果等价。PyTorch 的torch.softmax内部已经做了这个处理所以直接用就行。但如果你自己手写 softmax一定要记得减最大值。还有一个细节当某个位置被 mask 掉时我们把它设为负无穷softmax 之后权重就是 0。但负无穷在计算时可能产生 NaN所以有些实现会用一个大负数代替比如-1e9。不过 PyTorch 的masked_fill配合 softmax 通常没问题因为 softmax 内部会处理。3.4 加权求和与输出投影softmax 之后得到注意力权重矩阵 ( A )形状 ( n \times n )。然后 ( A ) 乘 ( V ) 得到输出形状 ( n \times d_k )。这个输出就是每个位置聚合后的新表示。单头情况下输出维度是 ( d_k )如果 ( d_k d_{model} )那输出可以直接接后续层。但实际 Transformer 里自注意力后面通常还有一个输出投影 ( W_O )形状 ( d_k \times d_{model} )。这个投影的作用是把多头拼接后的结果映射回 ( d_{model} ) 维同时增加参数量提升表达能力。单头实现里如果 ( d_k d_{model} )输出投影可以省略但加上也没坏处。我建议加上这样代码结构更接近真实 Transformer后面扩展多头时改动更小。4. 多头自注意力机制原理与实现4.1 为什么要多头子空间互补单头自注意力只能学习一种相关性模式。但语言里的关系是多种多样的有的位置关注语法依赖有的关注语义相似有的关注位置邻近。一个头很难同时捕捉所有这些模式。多头注意力的思路是把 ( d_{model} ) 维空间切成 ( h ) 个子空间每个子空间独立做一次自注意力最后把结果拼接起来。这样每个头可以专注于不同的关系模式模型表达能力显著增强。打个比方单头就像一个人用一双眼睛看世界多头就像多个人从不同角度看同一个场景最后把各自的观察汇总。每个人关注的点不同合起来信息更全面。4.2 维度切分与并行计算多头实现有两种常见写法。一种是每个头单独定义一组 ( W_Q )、( W_K )、( W_V )分别投影到 ( d_k d_{model} / h ) 维然后各自算注意力最后拼接。另一种是定义一个大的投影矩阵输出 ( d_{model} ) 维然后 reshape 成 ( h ) 个头。第二种写法更高效因为可以用一次矩阵乘法完成所有头的投影然后通过 reshape 和 transpose 把维度拆开。具体来说class MultiHeadAttention(nn.Module): def __init__(self, d_model, num_heads): super().__init__() assert d_model % num_heads 0 self.d_model d_model self.num_heads num_heads self.d_k d_model // num_heads self.w_q nn.Linear(d_model, d_model) self.w_k nn.Linear(d_model, d_model) self.w_v nn.Linear(d_model, d_model) self.w_o nn.Linear(d_model, d_model) self.scale math.sqrt(self.d_k) def forward(self, x, maskNone): batch_size, seq_len, _ x.shape Q self.w_q(x).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2) K self.w_k(x).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2) V self.w_v(x).view(batch_size, seq_len, self.num_heads, self.d_k).transpose(1, 2) scores torch.matmul(Q, K.transpose(-2, -1)) / self.scale if mask is not None: scores scores.masked_fill(mask 0, float(-inf)) attn torch.softmax(scores, dim-1) out torch.matmul(attn, V) out out.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model) out self.w_o(out) return out, attn这里的关键操作是view和transpose。view把最后一维 ( d_{model} ) 拆成 ( h \times d_k )transpose把头维度换到前面变成 ( batch \times h \times n \times d_k )。这样每个头的数据是独立的矩阵乘法会自动在头维度上并行。4.3 头数选择与维度分配的经验头数 ( h ) 怎么选常见配置是 ( d_{model}512, h8 )或者 ( d_{model}768, h12 )。规律是 ( d_k d_{model} / h ) 通常保持在 64 左右。为什么是 64这是实验总结出来的经验值。太小了每个头的表达能力不足太大了头数不够子空间多样性下降。我做过一组对照实验固定 ( d_{model}512 )分别试 ( h4, 8, 16 )。在相同数据上( h8 ) 收敛最快最终效果也最好。( h4 ) 时表达能力偏弱( h16 ) 时每个头只有 32 维训练不稳定。当然这不是绝对规律具体任务还要调但 64 维这个经验值可以作为起点。还有一个细节多头注意力的参数量和单头是一样的。因为总投影维度还是 ( d_{model} )只是拆成了多份。所以多头不会显著增加参数量但计算量会略增因为 softmax 和矩阵乘法的批次变多了。4.4 输出拼接与最终线性变换所有头算完之后每个头输出 ( n \times d_k )拼接起来是 ( n \times d_{model} )。然后经过输出投影 ( W_O ) 得到最终结果。这个投影的作用不只是维度变换它还负责融合不同头的信息。如果没有这个投影各头的输出只是简单拼接头与头之间没有交互。加上投影后模型可以学习如何组合不同头的特征。实际实现里拼接和投影可以合并成一个操作先把多头输出 transpose 回 ( batch \times n \times h \times d_k )然后 view 成 ( batch \times n \times d_{model} )最后过线性层。这个顺序不能乱否则维度对不上。5. 位置编码与掩码机制5.1 为什么自注意力需要位置编码自注意力本身是置换不变的。也就是说如果你把输入序列的顺序打乱输出也会跟着打乱但每个位置的输出值不变。这是因为注意力计算只依赖内容相关性不依赖位置。但语言是有顺序的“猫追狗”和“狗追猫”意思完全不同。所以必须额外注入位置信息。位置编码有两种主流做法。一种是正弦余弦编码用不同频率的三角函数生成固定向量直接加到词向量上。另一种是可学习的位置嵌入把位置当成词一样查表。前者不需要训练参数理论上可以外推到比训练时更长的序列后者更灵活但受限于训练时见过的最大长度。实际大模型里两种都有用。我个人的经验是如果序列长度固定且不太长可学习嵌入简单有效如果需要处理变长序列或者想外推正弦编码更稳。不过现在很多新模型用旋转位置编码那个是另一种思路这里不展开。5.2 因果掩码的实现细节解码器里的自注意力必须是因果的也就是每个位置只能看到自己和前面的位置不能看到后面。这是为了防止训练时偷看答案。实现方式是在分数矩阵上加上一个上三角为负无穷的掩码def causal_mask(seq_len): mask torch.tril(torch.ones(seq_len, seq_len)) return masktorch.tril生成下三角矩阵对角线及以下为 1以上为 0。然后masked_fill把 0 的位置设为负无穷softmax 之后这些位置权重就是 0。这里有个坑掩码的形状要和分数矩阵匹配。分数矩阵是 ( batch \times h \times n \times n )掩码通常是 ( n \times n )需要广播。如果手动实现记得把掩码扩展到正确的维度否则会报错或者静默算错。5.3 padding 掩码的处理除了因果掩码还有 padding 掩码。当一批序列长度不一时短的序列会补零到最长长度。这些补零位置不应该参与注意力计算否则会引入噪声。处理方式类似把 padding 位置对应的 Key 设为负无穷这样所有位置对 padding 的注意力权重都是 0。实际实现里因果掩码和 padding 掩码经常同时使用。可以先把两个掩码合并再一次性应用。合并逻辑是只要任一掩码为 0最终就是 0。用逻辑与操作即可。5.4 掩码对梯度的影响掩码位置被设为负无穷后softmax 输出为 0梯度也为 0。这意味着这些位置不参与参数更新这是符合预期的。但要注意如果整个序列都被 mask 掉softmax 会出现全零分母导致 NaN。所以实现时要确保至少有一个位置是可见的。另外负无穷在反向传播时可能产生 NaN 梯度。PyTorch 的 softmax 对负无穷处理得比较好但如果你自己写 CUDA 核或者用其他框架要特别小心。一个稳妥的做法是用一个很大的负数代替负无穷比如-1e9这样既能让 softmax 输出接近 0又不会产生数值问题。6. 常见问题与排查技巧实录6.1 注意力权重全为均匀分布怎么办如果你发现训练出来的注意力权重几乎均匀每个位置对所有位置的关注度差不多说明模型没学到有效的相关性。可能原因有几个。第一缩放因子没加或者加错了。如果点积数值太大softmax 会饱和如果太小softmax 会趋近均匀。检查 ( \sqrt{d_k} ) 是否正确。第二学习率太大参数还没收敛就发散了。可以试着降低学习率观察注意力权重是否变得尖锐。第三初始化有问题。如果 ( W_Q ) 和 ( W_K ) 初始化方差太大或太小点积分布会异常。建议用 Xavier 或 Kaiming 初始化保持方差稳定。第四数据本身没有明显的相关性模式。这种情况不是 bug是任务太简单或者数据太随机模型学不到东西。6.2 显存爆炸的排查思路自注意力的显存占用主要来自分数矩阵 ( n \times n )。当序列长度增加时显存是平方级增长。如果遇到显存爆炸可以按以下顺序排查排查项可能原因解决方向序列长度输入序列过长截断、分块、滑动窗口批次大小batch 太大减小 batch用梯度累积头数头数过多导致中间张量多减少头数或优化实现精度用 float32 训练混合精度训练中间缓存保存了注意力权重用于可视化训练时关闭权重保存我实际遇到过一次显存爆炸最后发现是注意力权重被保存下来用于画图每个 batch 都存一份累积起来把显存吃光了。关掉可视化保存后显存直接降了一半。这个坑很隐蔽因为代码逻辑上没问题只是资源管理疏忽。6.3 训练不收敛的常见原因自注意力训练不收敛除了学习率和初始化还有几个容易忽略的点。一是掩码用错了。比如因果掩码方向反了模型看到了未来信息训练 loss 会异常低但推理时效果很差。检查掩码是不是下三角。二是位置编码没加或者加错了。没有位置信息模型分不清顺序某些任务上会完全学不动。三是层归一化位置不对。Transformer 里层归一化通常放在自注意力之后如果放错位置梯度流动会受影响。四是残差连接缺失。自注意力层通常配合残差连接否则深层网络梯度传不下去。6.4 推理时注意力权重的可视化技巧推理时把注意力权重画出来能直观看到模型关注哪里。但要注意多头注意力的每个头关注点不同最好分开画。我通常取最后一层的注意力权重因为底层关注局部模式高层关注语义关系。可视化时用热力图横轴是 Key 位置纵轴是 Query 位置。如果看到明显的对角线说明模型主要关注邻近位置如果看到垂直条纹说明某些位置被普遍关注可能是关键信息。不过要提醒一句注意力权重高不代表因果重要。有些研究指出注意力权重和梯度重要性并不总是一致。所以可视化只能作为参考不能作为唯一解释依据。7. 工程实践中的优化与取舍7.1 高效注意力的常见变体思路标准自注意力的 ( O(n^2) ) 复杂度在长序列上是瓶颈。业界提出了不少优化变体核心思路无非几种稀疏化、低秩近似、核方法、分块计算。稀疏化的代表是只计算局部窗口内的注意力或者只计算某些关键位置的注意力。低秩近似是把 ( n \times n ) 矩阵分解成两个低秩矩阵相乘。核方法是用核函数近似 softmax把复杂度降到线性。分块计算是把序列切成块块内算完整注意力块间用近似。这些变体各有取舍有的牺牲精度换速度有的增加实现复杂度。实际选型时先看你的序列长度。如果不超过 2048标准注意力通常够用。如果到 8192 甚至更长再考虑优化变体。7.2 混合精度训练对注意力的影响混合精度训练用 float16 或 bfloat16 加速计算、节省显存。但自注意力对数值精度比较敏感尤其是 softmax 和缩放点积。float16 的动态范围小容易溢出。实际做法是矩阵乘法和 softmax 用 float32其他部分用 float16。PyTorch 的autocast会自动处理这些细节。但如果你手写 CUDA 核要自己控制精度。我实测下来bfloat16 比 float16 更稳因为它的指数位和 float32 一样宽不容易溢出。如果硬件支持 bfloat16优先用它。7.3 批处理与序列长度的权衡批处理能提升 GPU 利用率但序列长度和批次大小是此消彼长的。显存固定时序列越长能放的批次越小。实际调参时我通常先确定最大序列长度再在这个约束下尽量增大批次。如果序列长度差异很大可以用动态批处理把长度相近的样本放在一批减少 padding 浪费。或者用梯度累积小批次多次前向后再更新模拟大批次效果。7.4 注意力 dropout 的使用经验Dropout 在自注意力里通常加在两个地方一是注意力权重上二是输出投影后。注意力 dropout 能防止模型过度依赖某些位置提升泛化。但 dropout 率不能太高。我试过 0.1 到 0.30.1 通常够用0.3 会导致欠拟合。如果数据量很大可以适当提高数据少的时候dropout 要谨慎。还有一个细节推理时 dropout 要关闭否则结果不稳定。这个虽然基础但我在实际项目里见过有人忘了切换模式导致线上结果波动。8. 从自注意力到完整 Transformer 的衔接8.1 残差连接与层归一化的位置自注意力层通常不是单独使用的而是嵌在一个更大的块里。标准结构是输入先过层归一化再进自注意力输出加残差然后再过层归一化进前馈网络再加残差。这个结构叫 Pre-LN比 Post-LN 更稳定深层网络更容易训练。残差连接的作用是让梯度直接流过缓解深层网络的梯度消失。层归一化是稳定每层的输入分布加速收敛。两者配合才能让几十层的 Transformer 训得起来。8.2 前馈网络的角色自注意力负责聚合信息前馈网络负责变换信息。前馈网络通常是两层全连接中间加激活函数隐藏层维度是 ( d_{model} ) 的 4 倍。这个 4 倍是经验值来自原始 Transformer 论文后来大部分模型都沿用。前馈网络是逐位置独立计算的不跨位置交互。所以它和自注意力是互补的自注意力管跨位置前馈网络管单位置的特征变换。8.3 编码器与解码器的注意力差异编码器里的自注意力是双向的每个位置能看到所有位置。解码器里的自注意力是因果的只能看前面。另外解码器还有一个交叉注意力层Query 来自解码器Key 和 Value 来自编码器输出。这个设计让解码器在生成时能参考编码器的全部信息。实际实现时编码器和解码器的自注意力可以复用同一套代码只是掩码不同。交叉注意力则需要额外的投影矩阵因为 Query 和 Key/Value 来源不同。8.4 实际项目中的模块化建议如果你要自己实现 Transformer建议把自注意力、前馈网络、层归一化、残差连接分别封装成独立模块然后用一个 Block 类组合起来。这样调试时容易定位问题替换组件也方便。我自己的习惯是每个模块都写单元测试检查输入输出形状和数值范围。自注意力最容易出错的地方是维度变换单元测试能提前发现大部分问题。9. 我踩过的几个典型坑第一个坑是缩放因子。我早期实现时觉得除以 ( \sqrt{d_k} ) 可有可无结果训练 loss 降得极慢。后来加上缩放收敛速度明显提升。这个细节论文里写了但自己写的时候容易忽略。第二个坑是掩码维度。有一次因果掩码形状不对广播后变成了全零模型完全看不到任何信息loss 一直不降。排查了半天才发现是掩码生成时少了一个维度。第三个坑是 softmax 维度。torch.softmax要指定dim-1如果指错维度归一化方向就反了注意力权重完全错误。这个错误很隐蔽因为形状是对的只是语义错了。第四个坑是显存泄漏。注意力权重被保存用于可视化训练时忘了关导致显存越用越多。后来改成只在验证时保存问题解决。第五个坑是位置编码外推。用可学习位置嵌入时推理序列超过训练长度会报错。后来换成正弦编码支持任意长度虽然效果略有下降但工程上更稳。这些坑看起来都是小问题但每一个都能让你卡半天。写代码时多检查形状、多打印中间结果、多写单元测试能省很多时间。10. 后续可以深入的方向自注意力本身还有很多值得挖的点。比如注意力权重的可解释性到底能不能用来解释模型决策学术界还有争议。再比如高效注意力的各种变体哪种在什么场景下最优需要结合实际任务做 benchmark。如果你想继续深入我建议先从复现一个完整的 Transformer 开始然后尝试替换不同的注意力变体对比效果和速度。再往后可以研究位置编码的改进比如旋转位置编码和相对位置编码。最后可以看看注意力在视觉、语音等其他模态上的应用很多思路是相通的。我自己接下来打算把旋转位置编码的实现细节再抠一遍之前用现成库比较多自己手写时发现有些细节没完全搞明白。等搞清楚了再写一篇分享。最后分享一个小技巧调试自注意力时先把序列长度设为 2 或 3头数设为 1维度设小一点手动算一遍结果和代码输出对比。这样能快速验证实现是否正确。等小规模验证通过后再放大到真实配置。这个方法帮我省了很多排查时间。
阅读完成 · 觉得有帮助?
咨询建站