☰
PyTorch从零手写Transformer:位置编码、多头注意力与mask详解
2026/10/20 4:26:29 网站建设 项目流程

先说个个人感受:Transformer 这种架构,看论文、看别人写的博客总觉得挺简单,真正自己用 PyTorch 一行行从零实现一遍,才意识到里面有大量容易出问题的细节。维度、mask、残差和归一化的顺序,任何一个地方没想明白,运行时报错能让人原地懵十分钟。所以这次我把实现过程完整梳理出来,不调现成的封装,从位置编码、多头注意力到 Encoder-Decoder 组装一步不落,代码都是可以直接跑通的那种,并且会专门把踩过的坑和排查思路写清楚。如果你想彻底弄懂 Transformer 内部机制,或者正在准备面试、写课程实验,这篇应该能帮上忙。

1. 先把 Transformer 全家桶拆一遍再动手

1.1 编码器和解码器分别干什么

标准 Transformer 是典型的 Encoder-Decoder 架构。如果拿翻译场景来打比方,Encoder 负责读完整句原文,把每个词从“孤立词”变成“结合上下文理解后的语义向量”,这个过程也叫上下文编码。而 Decoder 负责一个词一个词往外蹦译文,每蹦一个词的时候,它不光要回头看自己已经生成的那些词,还要去参考 Encoder 给出的整句语义信息,所以 Decoder 内部有两种注意力:自注意力和交叉注意力。

很多人一开始搞混一点:Encoder 输出的不是一个固定长度的向量,而是和输入序列长度一致的向量序列。比如输入句子有 10 个词,Encoder 就输出 10 个向量,每个向量都蕴含了全句信息。Decoder 在生成第 t 个词时,会通过交叉注意力从这 10 个向量里“选重点”,而不是一把抓。

“标准”这个词在这篇里指的是 2017 年原始 Transformer 论文那个形态:6 层 Encoder + 6 层 Decoder、d_model = 512、8 个头、前馈网络隐藏层 2048。当然,实际代码里我会把这些参数做成可配置,方便在本地 CPU 上跑通,不必一上来就是 512 这种偏重的规模。

1.2 六大子模块逐一说明

拆开看,整个 Transformer 由几个基础模块反复堆叠而成。

模块作用PyTorch 对应
输入嵌入把离散的 token id 变成稠密向量,并乘以 sqrt(d_model)nn.Embedding
位置编码给序列注入“谁是第几个词”的信息自定义PositionalEncoding
多头注意力让每个位置按相关性加权聚合其他位置的信息自定义MultiHeadAttention
前馈网络对每个位置做两次非线性变换,增强表达力nn.Linear+ 激活函数
残差连接让梯度能顺畅回传,缓解深层网络退化x + sublayer(x)
层归一化对每个样本的每一层输出做标准化,稳定训练nn.LayerNorm

这些模块里,位置编码和注意力是最需要亲手实现的部分。尤其是多头注意力,涉及 reshape、转置、缩放、mask 填充,每一步的 shape 都必须心里有数,否则一个view写错就会出现维度错位,而且这种错位不到 backward 阶段往往还不容易发现。

1.3 为什么不用现成封装而要手写

PyTorch 官方确实提供nn.Transformer,一行就能创建模型。但那个封装把所有细节都藏起来了,mask 怎么传、里面是怎么 reshape 的、为什么这么设计,对新手来说就是一个黑盒。

手写的核心价值在于:训练时一旦遇到 loss 不下降、NaN、维度报错,你能快速定位到具体是哪个子模块出了问题。比如梯度爆炸,如果不知道自注意力里除以 sqrt(d_k) 的作用,可能就从没想过问题会出在这里。另外,Transformer 后续衍生出了大量变体,很多改动的落点正是注意力内部的 mask 处理或残差位置,如果你不了解原版结构,后面看其他论文也会吃力。

2. 搭建前的准备工作和全局骨架

2.1 环境与依赖

手写 Transformer 需要的依赖非常少,pytorch和numpy就够了,我用的是 Python 3.10 + PyTorch 2.x。纯 CPU 环境就能完成前向传播和训练 demo,只是速度慢一些。如果想快速看到训练效果,建议把模型参数调小一点。

pip install torch numpy

没有 GPU 也没关系,下面 demo 会把 d_model 设成 128、层数设成 2,跑几十个 step 完全没问题。如果后续要跑大规模任务,再按需调大。

2.2 全局参数配置

我会把模型参数集中放到一个配置里,这样调试起来方便。

import copy import math import torch import torch.nn as nn import torch.nn.functional as F class ModelConfig: src_vocab_size = 128 # 源语言词表大小 tgt_vocab_size = 128 # 目标语言词表大小 d_model = 128 # 模型维度 n_heads = 8 # 注意力头数 d_ff = 512 # 前馈网络中间层维度 n_layers = 2 # Encoder/Decoder 层数 dropout = 0.1 # Dropout 比例 max_len = 512 # 最大序列长度 pad_idx = 1 # padding 对应的 token id

这里有几个参数需要强调一下。d_model % n_heads == 0是硬性要求,因为模型维度需要被切成 n_heads 份。max_len决定了位置编码表的长度,这个值设置过小,输入超长序列时就会直接越界。pad_idx在训练时必须和真实数据的 padding token id 保持一致,否则 mask 形同虚设。

3. 核心模块逐个实现

3.1 位置编码代码与实现要点

自注意力机制本身没有顺序概念,即使把序列随机打乱,算出来的结果也完全一样。这显然不符合语言规律,“我爱你”和“你爱我”意思差远了。所以需要把位置信息显式注入到每个 token 的向量里。

原始论文用的是固定正弦余弦公式:

PE(pos, 2i) = sin(pos / 10000^(2i / d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i / d_model))

其中pos是 token 在序列中的位置,i是维度索引。这个公式设计得很巧妙:不同维度的正弦波频率不同,模型可以通过线性变换学到相对位置关系,而且无需训练就能直接用于任意长度序列。

class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len, dropout=0.1): super().__init__() self.dropout = nn.Dropout(p=dropout) pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) div_term = torch.exp( torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model) ) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) self.register_buffer("pe", pe) def forward(self, x): x = x + self.pe[: x.size(1)] return self.dropout(x)

这里有两个实现细节需要特别注意。

第一,为什么用register_buffer而不是普通赋值?因为self.pe不需要梯度,但又希望它跟随模型一起在设备和训练状态之间切换。注册成 buffer 后,模型调用.to(device)时它会被自动移动,model.eval()或model.train()也不会影响它的值。

第二,x + self.pe[:x.size(1)]的切片操作。如果训练时序列长度变短,位置编码表长度仍按 max_len 预留,切片保证只取当前序列长度对应的编码,不会因为序列短而报错。

3.2 多头注意力完整实现

先看注意力公式:

Attention(Q, K, V) = softmax(Q * K^T / sqrt(d_k)) * V

这里 Q、K、V 分别是 query、key、value。可以把它们理解成一个检索系统:query 是“我要找什么”,key 是“我有哪些标签”,value 是“实际内容”。通过 query 和 key 的点积算相似度,再经过 softmax 变成权重,最后按权重对 value 加权求和。

除以sqrt(d_k)是防止点积数值过大导致 softmax 进入饱和区。d_k 越大,Q 和 K 点积的方差越大,除以根号 d_k 可以重新归一化到合适范围。

多头版本则是把 Q、K、V 拆成多个子空间分别做注意力,最后拼接。头数一般设 8 或 16。多头的好处是模型能在不同子空间捕获不同类型的关系,比如一个头关注句法依赖,另一个头关注语义指代。

class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_heads, dropout=0.1): super().__init__() assert d_model % n_heads == 0 self.d_model = d_model self.n_heads = n_heads self.d_k = d_model // n_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.dropout = nn.Dropout(p=dropout) def forward(self, query, key, value, mask=None): batch_size = query.size(0) Q = self.w_q(query).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) K = self.w_k(key).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) V = self.w_v(value).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) scores = Q @ K.transpose(-2, -1) / math.sqrt(self.d_k) if mask is not None: if mask.dim() == 3: mask = mask.unsqueeze(1) scores = scores.masked_fill(mask, -1e9) attn = torch.softmax(scores, dim=-1) attn = self.dropout(attn) context = attn @ V context = context.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) return self.w_o(context)

这段代码有几个公认的易错点,我拆开解释。

线性层w_q、w_k、w_v的输入输出维度都是 d_model。因为我们把所有头的投影合并到了一个线性层里,所以线性层输出仍然是 d_model,后续再view成(batch_size, seq_len, n_heads, d_k),然后transpose成(batch_size, n_heads, seq_len, d_k),这样每个头独立做注意力。

transpose之后紧接着用contiguous()很重要。transpose本身只是交换了张量的 stride,数据在内存中并没有重排,后续view需要保证张量是连续的,否则会直接报错或者得到语义完全错误的结果。这是 90% 以上新手会遇到的问题。

mask 部分,我采用的是“True 表示忽略该位置”的统一语义。如果传入的是三维 mask(N, L_q, L_k),会被自动扩展成(N, 1, L_q, L_k)以便广播到所有头上。填充值用-1e9而不是float("-inf"),主要是为了防止后续 softmax 出现 NaN,实际效果差异不大,但更稳妥。

实现里我偷了个懒,没有拆成nn.Linear(d_model, d_k)x n_heads 再循环的写法,因为那种写法不仅慢,代码还很冗余。合并线性层是工程上的标准做法。

3.3 前馈网络和子层连接方式

每个 Encoder/Decoder 层除了注意力,还带一个两层前馈网络。公式很简单:

FFN(x) = max(0, xW1 + b1)W2 + b2

也就是先升维再降维。d_model = 512 时,中间层一般是 2048,相当于先扩展到 4 倍宽度,用 ReLU 激活,再压缩回来。这个设计的直觉是:注意力负责在不同位置之间交换信息,但每个位置自身的特征变换还需要非线性能力,单个线性投影不够用。

class FeedForward(nn.Module): def __init__(self, d_model, d_ff, dropout=0.1): super().__init__() self.linear1 = nn.Linear(d_model, d_ff) self.linear2 = nn.Linear(d_ff, d_model) self.dropout = nn.Dropout(p=dropout) def forward(self, x): x = F.relu(self.linear1(x)) x = self.dropout(x) x = self.linear2(x) return x

前馈网络内部不涉及跨位置交互,所以每个 token 是独立计算的,这正好和注意力互补。

接下来是子层连接方式。原版 Transformer 用的是 Post-LN 结构,顺序是:残差连接后做层归一化。用公式表示就是:

x = LayerNorm(x + Dropout(Sublayer(x)))
class EncoderLayer(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout): super().__init__() self.self_attn = MultiHeadAttention(d_model, n_heads, dropout) self.feed_forward = FeedForward(d_model, d_ff, dropout) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.dropout1 = nn.Dropout(dropout) self.dropout2 = nn.Dropout(dropout) def forward(self, x, mask=None): x = self.norm1(x + self.dropout1(self.self_attn(x, x, x, mask))) x = self.norm2(x + self.dropout2(self.feed_forward(x))) return x

Post-LN和后来很多变体用的Pre-LN不一样:Pre-LN 是先归一化再进入子层,训练更稳定,但表达能力有所损失。标准实现遵循原版,用 Post-LN。

这里的LayerNorm需要注意:它是对每个 token 的 d_model 维做标准化,不受序列长度影响。它和 BatchNorm 的区别是 BatchNorm 对 batch 维和序列维一起统计,而 LayerNorm 只对特征维统计,这让它在变长序列场景下更适用。

4. Encoder 和 Decoder 组装时的 mask 细节

4.1 Encoder 层组装

Encoder 由输入嵌入、位置编码和多层 EncoderLayer 堆叠而成,最后再接一层 LayerNorm。

class Encoder(nn.Module): def __init__(self, vocab_size, d_model, n_heads, d_ff, n_layers, dropout, max_len): super().__init__() self.embedding = nn.Embedding(vocab_size, d_model) self.pos_encoding = PositionalEncoding(d_model, max_len, dropout) self.layers = nn.ModuleList( [EncoderLayer(d_model, n_heads, d_ff, dropout) for _ in range(n_layers)] ) self.norm = nn.LayerNorm(d_model) def forward(self, src, mask=None): x = self.embedding(src) * math.sqrt(self.d_model) x = self.pos_encoding(x) for layer in self.layers: x = layer(x, mask) return self.norm(x)

嵌入层后面乘sqrt(d_model)是原版论文里的细节。因为嵌入向量本身的学习方式和位置编码的数值范围不太一致,乘上这个系数能让两者在加法时大致处于同一尺度。简单说就是“把嵌入拉大一点,避免位置编码被吃掉”。

Encoder 的 mask 就是源序列的 padding mask。比如中文句子用 0 补齐到固定长度,padding 位置也应该被忽略。

4.2 Decoder 层组装

Decoder 比 Encoder 多一层交叉注意力,同时自注意力部分要加 look-ahead mask。

class DecoderLayer(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout): super().__init__() self.self_attn = MultiHeadAttention(d_model, n_heads, dropout) self.cross_attn = MultiHeadAttention(d_model, n_heads, dropout) self.feed_forward = FeedForward(d_model, d_ff, dropout) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.norm3 = nn.LayerNorm(d_model) self.dropout1 = nn.Dropout(dropout) self.dropout2 = nn.Dropout(dropout) self.dropout3 = nn.Dropout(dropout) def forward(self, x, memory, tgt_mask=None, memory_mask=None): x = self.norm1(x + self.dropout1(self.self_attn(x, x, x, tgt_mask))) x = self.norm2(x + self.dropout2(self.cross_attn(x, memory, memory, memory_mask))) x = self.norm3(x + self.dropout3(self.feed_forward(x))) return x

交叉注意力里,query来自 Decoder 内部,key和value来自 Encoder 的输出memory。这一步对应“预测下一个词时,去原文里找依据”。两边的序列长度可以不同,所以交叉注意力的分数矩阵形状是(N, H, L_tgt, L_src),这和自注意力明显不同,写代码时心里要时刻清楚每个张量的含义。

Decoder 在推理时是逐词生成的,每次只出一个词,然后拼到输入序列末尾继续预测下一个。但训练时为了并行加速,会一次性把整个目标序列喂进去,同时用 look-ahead mask 把“未来的词”遮住。第 t 个位置只能看到 1 到 t 的位置,这正是为什么要额外传一个 mask。

4.3 mask 的正确构造方式

mask 这部分是我见过出错率最高的地方,单独拿出来讲。先说两种 mask 的语义区别:

  • padding mask:将句子中补齐的无效词位屏蔽。通常在 Encoder 自注意力和 Decoder 交叉注意力中使用。
  • look-ahead mask:将当前时间步之后的未来词位屏蔽。只在 Decoder 自注意力中使用。

我实现的惯例是:mask 为 True 的位置表示“不要看”,会被填充成 -1e9。代码里统一按这个语义来。

def create_padding_mask(seq, pad_idx): return (seq == pad_idx).unsqueeze(1).expand(-1, seq.size(1), -1) def create_lookahead_mask(size, device): mask = torch.triu(torch.ones(size, size, dtype=torch.bool, device=device), diagonal=1) return mask

create_padding_mask输入(N, L),输出(N, L, L)。例如源序列在位置 3 是 padding,那么 mask 的第 3 列整个为 True,表示“所有 query 都不要关注这个 key”。

create_lookahead_mask利用torch.triu生成上三角矩阵,对角线上方的位置为 True,对应未来位置。在 Decoder 自注意力中,我一般会把 padding mask 和 look-ahead mask 做按位或合并。

def combine_masks(tgt, pad_idx, device): batch_size, seq_len = tgt.shape lookahead = create_lookahead_mask(seq_len, device) padding = (tgt == pad_idx).unsqueeze(1).expand(-1, seq_len, -1) return padding | lookahead

这里用|是因为两个 mask 都是 bool 型,取或后,只要任一条件为 True,就表示该位置被忽略。

有个特别容易搞迷糊的坑:在 Decoder 自注意力里,Q 和 K 都是当前序列自己,所以它的 mask 是(L, L)的方形。在 Decoder 交叉注意力里,Q 来自目标语言,K 来自源语言,mask 的形状是(L_tgt, L_src),而且只用 padding mask,不用 look-ahead。一旦把两者搞混,维度就会对不上,运行时报错只是时间问题。

5. 完整 Transformer 模型整合与跑通验证

5.1 组装完整模型

把 Encoder 和 Decoder 拼起来,形成最终的标准 Transformer 结构。

class Transformer(nn.Module): def __init__(self, config): super().__init__() self.d_model = config.d_model self.encoder = Encoder( vocab_size=config.src_vocab_size, d_model=config.d_model, n_heads=config.n_heads, d_ff=config.d_ff, n_layers=config.n_layers, dropout=config.dropout, max_len=config.max_len, ) self.decoder = Decoder( vocab_size=config.tgt_vocab_size, d_model=config.d_model, n_heads=config.n_heads, d_ff=config.d_ff, n_layers=config.n_layers, dropout=config.dropout, max_len=config.max_len, ) self.init_parameters() def init_parameters(self): for p in self.parameters(): if p.dim() > 1: nn.init.xavier_uniform_(p) def forward(self, src, tgt, src_mask=None, tgt_mask=None): memory = self.encoder(src, src_mask) output = self.decoder(tgt, memory, tgt_mask, src_mask) return output

初始化时用了 Xavier 均匀初始化,并且只对维度大于 1 的参数做。因为 LayerNorm 里的 weight 初始化为 1、bias 初始化为 0,如果强行 xavier,反而会破坏归一化的初始状态。

tgt_mask和src_mask由外部生成后传入。src_mask同时会被 Decoder 的交叉注意力使用,因为它代表的是“encoder 输出里哪些位置是 padding”,所以它可以用于 memory 的 key padding mask。这个复用逻辑很重要但容易忽略。

Decoder 的最终输出维度是(N, L_tgt, vocab_size),后面接交叉熵损失计算时,需要在时间维上摊平。

5.2 最小可运行 Demo 与前向输出形状检查

我把超参调小,保证 CPU 上能快速跑通:

config = ModelConfig() model = Transformer(config) # 模拟一批数据:batch=4, src_len=8, tgt_len=8 src = torch.randint(2, config.src_vocab_size, (4, 8)) tgt = torch.randint(2, config.tgt_vocab_size, (4, 8)) src_mask = create_padding_mask(src, config.pad_idx) tgt_mask = combine_masks(tgt, config.pad_idx, tgt.device) logits = model(src, tgt, src_mask, tgt_mask) print(logits.shape) # expect torch.Size([4, 8, 128])

如果能输出[4, 8, 128],说明前向传播的维度是正确的。接下来可以简单跑个训练循环:

optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss(ignore_index=config.pad_idx) model.train() for step in range(50): optimizer.zero_grad() src = torch.randint(2, config.src_vocab_size, (4, 8)) tgt = torch.randint(2, config.tgt_vocab_size, (4, 8)) src_mask = create_padding_mask(src, config.pad_idx) tgt_mask = combine_masks(tgt, config.pad_idx, tgt.device) logits = model(src, tgt, src_mask, tgt_mask) loss = criterion(logits.view(-1, config.tgt_vocab_size), tgt.view(-1)) loss.backward() optimizer.step() if step % 10 == 0: print(f"step {step}, loss {loss.item():.4f}")

这里ignore_index=config.pad_idx让 padding 位置的 token 不参与 loss 计算,这是处理变长序列的常规操作。

实际跑的时候,loss 从刚开始略高会逐渐下降。如果 loss 卡在初始值附近不动,基本可以确定是 mask 或模型结构出了问题,后面我会专门讲怎么排查。

6. 常见问题与排查技巧实录

6.1 view 和 transpose 相关的维度陷阱

我在手写多头注意力时踩过最典型的坑是view报错:

RuntimeError: view size is not compatible with input tensor's size

原因基本离不开两点:一是 Q、K、V 在transpose之后没调用contiguous()就直接view,二是batch_size获取方式不对,在序列批量大小不一致时取错了维度。我第一次是在计算 scores 之后想把 attention 输出转回(N, L, d_model)时忽略的contiguous(),报错位置在view,但问题根源在transpose。

排查思路很简单:在每个关键张量后面临时print(tensor.shape),逐段确认。从线性层输出开始,依次检查 view 之后的形状、transpose 之后的形状、矩阵乘法之后的形状。一旦某一步形状和预期不符,前面的逻辑就要跟着修正。

6.2 mask 最常见的三种错误

mask 的坑比维度更隐蔽,因为维度对了但语义错了,模型也能跑,但效果极差。常见问题有这几类:

第一种是 padding mask 忘记 join 到训练 batch 里。很多人只在 demo 里构造了 mask,真正写 DataLoader 时把它漏了,导致所有 padding 位置都参与注意力计算,整个模型的表现会明显变差。

第二种是把 mask 用在交叉注意力里时不扩展形状。Decoder 交叉注意力的 scores 是(N, H, L_tgt, L_src),mask 必须是(N, L_tgt, L_src)或(N, 1, L_tgt, L_src),如果直接传(N, L_src),广播会失败或者语义出错。

第三种是 look-ahead mask 和 padding mask 没有合并。只在 Decoder 自注意力里用了三角形 mask,忘记把 padding mask 加进去,padding 位置照样能看到。

我在 MHA 代码里用if mask.dim() == 3: mask = mask.unsqueeze(1)做了自动扩展,但这只是省掉显式 unsqueeze 的步骤,不代表 mask 的语义正确。最终检查还得靠数据流逻辑。

6.3 训练不收敛或者 loss 不下降

手写 Transformer 训练不收敛的原因通常不是单个,而是几个问题叠加。高频出现的组合是:学习率没设置合适、mask 没有正确使用、参数初始化方法不对。

Transformer 训练对学习率比较敏感。常见做法是使用带 warmup 的学习率调度器:前若干步学习率线性上升,之后按步数平方根衰减。下面是一个简单实现:

class WarmupScheduler(torch.optim.lr_scheduler.LambdaLR): def __init__(self, optimizer, d_model, warmup_steps): self.d_model = d_model self.warmup_steps = warmup_steps super().__init__(optimizer, self.lr_lambda) def lr_lambda(self, step): step = max(step, 1) return self.d_model ** (-0.5) * min(step ** (-0.5), step * self.warmup_steps ** (-1.5))

如果 loss 直接变 NaN,优先检查两个地方:一是梯度中是否有 NaN,通常加nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)可以规避;二是 attention 的分数是否出现极端值,此时检查有没有漏掉sqrt(d_k)缩放。

另外一个很少人注意的点是,如果你用 Post-LN 结构并且只堆了很多层而不加残差缩放,深层模型叠加数值可能越来越大。标准实现不额外处理这个问题,但如果层数设到 12 以上,建议先确认模型真的稳,再追求更大规模。

6.4 Post-LN 和 Pre-LN 怎么选

前面实现用的 Post-LN 是原始论文的原版结构。但如果你拿这个模型去预训练,用较大学习率或深层叠加,Post-LN 容易在早期出现震荡甚至发散。这是它的一个已知弱点。

Pre-LN 把归一化放在子层之前:

def forward(self, x, mask=None): x = x + self.dropout1(self.self_attn(self.norm1(x), self.norm1(x), self.norm1(x), mask)) x = x + self.dropout2(self.feed_forward(self.norm2(x))) return x

这样改的好处是训练更稳定,可以容忍较大学习率,梯度流也更好,很多后续变体都用了这个结构。坏处是表达能力上稍稍不如 Post-LN,在机器翻译等任务上收敛到的最终指标可能略差一点。

如果你是在学习标准 Transformer,建议先照原版 Post-LN 实现,因为它是后续论文的基础,并且能和原始论文逐模块对上。如果是在做自己的实验或者要快速跑到理想效果,可以直接用 Pre-LN,省去很多调参时间。

我个人在实际项目里的体会是:至少完整手写一次标准 Transformer 是非常划算的投入。虽然现在框架里现成封装很多,但只有亲手处理过 QKV 维度变换和 mask 语义的人,才能在看懂各种衍生模型的时候第一时间理解它们在改什么。尤其是碰到线上模型效果异常时,能快速判断是不是数据处理时 mask 没传对,这种能力只能来自对底层实现细节的熟悉。如果你刚开始接触,建议先照本文代码跑通一遍,然后把 d_model、n_heads 改成别的值再观察维度变化,很快就能建立直觉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询