Transformer 的 Encoder 部分,很多人第一次看论文的时候觉得“不就是多头注意力和前馈网络堆一堆嘛”,但真到动手复现或者读源码的时候,往往会在几个地方卡住:位置编码到底怎么加、多头注意力的维度怎么切、残差连接和 LayerNorm 的先后顺序为什么和原始论文不一样、padding mask 和 causal mask 到底该在哪一层起作用。我自己在带团队做文本分类和语义匹配项目时,前前后后把 Encoder 重写过好几版,踩过的坑足够写一篇长文。这篇就把 Transformer Encoder 从结构到实现彻底拆开讲一遍,重点放在那些文档里不会写、但实际写代码一定会遇到的地方。
1. 为什么 Encoder 是理解整个 Transformer 的钥匙
1.1 Encoder 到底在做什么事
先把定位说清楚。Transformer 最初是为机器翻译设计的,整体是 Encoder-Decoder 结构。Encoder 负责把输入序列(比如一句中文)编码成一堆向量表示,Decoder 再基于这些向量逐词生成目标序列(比如对应的英文)。后来大家发现,很多任务其实只需要 Encoder:文本分类、语义匹配、序列标注、抽取式问答,这些任务不需要生成,只要把输入理解透就够了。BERT 就是典型的“只用 Encoder”的模型,这也是为什么搞 NLP 的人几乎绕不开 Encoder。
那 Encoder 具体在做什么?一句话概括:它把每个 token 的初始向量(词嵌入 + 位置信息)经过多层堆叠,不断和其他 token 交换信息,最终输出一组“融合了上下文”的向量。关键在于“融合上下文”这四个字。传统的 RNN 是一个词一个词往后传,信息传递路径长,长距离依赖容易丢;而 Encoder 里的自注意力机制让任意两个位置之间都是一步直达,路径长度是 O(1)。这就是 Transformer 最核心的卖点。
我经常用一个类比跟新人解释:RNN 像是传话游戏,一句话从队首传到队尾,传到最后往往变味了;而自注意力像是把所有人拉到一个会议室里,每个人都能直接听到其他所有人说的话,然后各自决定该重点关注谁。Encoder 就是这个会议室,而且开了好几层,每层都在重新讨论一遍。
1.2 一个 Encoder Block 的四大件
一个标准的 Encoder Block 由四个部分组成,缺一不可:
- 多头自注意力(Multi-Head Self-Attention):让每个位置和所有位置交互,捕捉依赖关系。
- 前馈网络(Feed-Forward Network, FFN):对每个位置独立做非线性变换,增强表达能力。
- 残差连接(Residual Connection):缓解深层网络的梯度消失,让梯度能直接回传。
- 层归一化(Layer Normalization):稳定训练,加速收敛。
原始论文《Attention Is All You Need》里,每个子层(自注意力、前馈网络)外面都包了一层LayerNorm(x + Sublayer(x)),也就是“先残差再加,最后归一化”,这叫 Post-LN。但后来大量实践发现 Post-LN 在深层模型里训练不稳定,需要 warmup 才能收敛,于是 GPT-2、BERT 之后的模型普遍改成了 Pre-LN,即x + Sublayer(LayerNorm(x))。这个顺序差异看起来很小,但对训练稳定性的影响是巨大的,后面我会专门用一节来讲。
整个 Encoder 就是把这个 Block 重复 N 层(原始论文 N=6),每一层的输出维度保持不变(原始论文 d_model=512),这样才能一层层叠上去。输入是(batch_size, seq_len, d_model),输出也是(batch_size, seq_len, d_model),形状不变,变的是每个向量里蕴含的信息。
1.3 为什么值得单独把 Encoder 拎出来研究
有人会问,直接调transformers库不就行了,为什么要自己实现 Encoder?我的理由有三个。
第一,调试和排错的需要。线上模型效果不对,你得知道是哪一层出了问题。是 mask 加错了?是位置编码越界了?还是 LayerNorm 的 eps 设得太小导致数值不稳定?不懂内部结构,你只能盲猜。
第二,定制化的需要。实际项目里经常要改结构:换位置编码方案、改注意力计算方式、加相对位置偏置、做稀疏注意力。这些改动都建立在你能手写一个干净 Encoder 的基础上。
第三,面试和知识体系的需要。Encoder 是 Transformer 的地基,理解了它,Decoder、Cross-Attention、各种变体都好说。我见过太多人背得出“多头注意力就是把 QKV 切成 h 份”,但问他“为什么要切”“切完怎么拼”“d_k 和 d_model 什么关系”就答不上来。
2. 自注意力机制的数学拆解与维度陷阱
2.1 从 Q、K、V 的直觉说起
自注意力的核心公式就一行:
Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V但这一行里藏着很多细节。先说 Q、K、V 是什么。输入序列每个 token 的向量 x(维度 d_model),分别乘以三个可学习矩阵 W_Q、W_K、W_V,得到 Query、Key、Value 三个向量。用图书馆检索来类比:Query 是你要查的问题,Key 是每本书的标签,Value 是书的内容。你先拿 Query 和每个 Key 算相似度(点积),相似度高的书权重就大,最后按权重把各本书的 Value 加权求和,得到的就是这个 Query 的检索结果。
自注意力里 Q、K、V 都来自同一个输入序列,所以叫“自”注意力。如果是 Decoder 里的 Cross-Attention,Q 来自 Decoder,K、V 来自 Encoder,那就是跨序列的注意力了。
2.2 为什么要除以 sqrt(d_k)
这是最常被问到的问题之一。原因很简单:当 d_k 比较大时,Q 和 K 的点积结果方差会随 d_k 线性增长。假设 Q、K 的每个分量都是均值 0、方差 1 的独立随机变量,那么点积q·k = sum(q_i * k_i)的方差就是 d_k。方差一大,softmax 的输入就会落在饱和区,梯度趋近于 0,训练就废了。
除以 sqrt(d_k) 相当于把方差拉回到 1 附近,让 softmax 的输入分布保持在一个梯度健康的范围。我实测过,d_k=64 时不除 sqrt(d_k),训练 loss 几乎不下降;除完之后正常收敛。这不是玄学,是实打实的数值问题。
2.3 多头到底“多”在哪里
多头注意力的做法是:把 d_model 维的 Q、K、V 分别切成 h 份,每份维度 d_k = d_model / h,每一份独立做一次注意力计算,最后把 h 个结果拼起来,再过一个线性层。
为什么要这么做?单个注意力头只能学到一种“关注模式”,比如“关注前一个词”或者“关注句子的主语”。多头让模型可以同时从不同子空间、不同角度去关注信息,就像开会时不同的人负责记录不同的重点。原始论文 h=8,d_model=512,所以 d_k=d_v=64。
这里有个维度陷阱:切分不是简单地把向量按顺序切成 h 段,而是通过线性层投影后,在最后一维 reshape 成(h, d_k)。实现上通常是先算完整的 Q、K、V(维度 d_model),再 reshape 成(batch, seq_len, h, d_k),然后 transpose 成(batch, h, seq_len, d_k),这样每个头就能独立做批量矩阵乘法。很多人第一次写会在这里把维度搞混,导致结果对不上。
2.4 缩放点积注意力的完整实现
下面是我常用的一个干净实现,PyTorch 版本:
import torch import torch.nn as nn import math def scaled_dot_product_attention(q, k, v, mask=None): # q, k, v: (batch, h, seq_len, d_k) d_k = q.size(-1) scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_k) # scores: (batch, h, seq_len, seq_len) if mask is not None: scores = scores.masked_fill(mask == 0, float('-inf')) attn = torch.softmax(scores, dim=-1) output = torch.matmul(attn, v) return output, attn几个要点。第一,masked_fill用的是-inf而不是 0,因为 softmax 之后-inf会变成 0,而直接填 0 会分走一部分概率质量。第二,mask 的形状要能广播到(batch, h, seq_len, seq_len),通常 padding mask 是(batch, 1, 1, seq_len),表示哪些位置是 padding。第三,transpose(-2, -1)只交换最后两维,别写成transpose(1, 2),那样在 batch 维度上会出错。
注意:如果你用的是混合精度训练(fp16),
-inf在某些实现里会变成 NaN。稳妥的做法是用一个很大的负数,比如-1e9,或者用 PyTorch 的torch.finfo(dtype).min。
3. 位置编码:Transformer 的“空间感”从哪来
3.1 自注意力本身是“无序”的
这是 Transformer 一个反直觉的地方:自注意力对输入顺序完全不敏感。你把一句话的词打乱,只要每个词的向量不变,注意力算出来的结果只是跟着位置换了一下,模型本身感知不到顺序。但语言是有顺序的,“狗咬人”和“人咬狗”意思完全相反。
所以必须显式地把位置信息注入进去。原始论文用的是正弦位置编码(Sinusoidal Positional Encoding),公式是:
PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))其中 pos 是位置,i 是维度索引。偶数维用 sin,奇数维用 cos。
3.2 为什么用这种奇怪的三角函数
这个设计不是随便拍的,它有两个巧妙之处。
第一,不同维度对应不同频率。低维(i 小)频率高,变化快,能区分相邻位置;高维(i 大)频率低,变化慢,能编码长距离的位置关系。这有点像二进制编码,低位变化快、高位变化慢,组合起来能表示任意整数。
第二,相对位置可以通过线性变换得到。因为三角恒等式sin(a+b) = sin(a)cos(b) + cos(a)sin(b),位置pos+k的编码可以表示成位置pos编码的线性函数。这让模型有能力学到“相对位置”的概念,而不只是绝对位置。
我实测下来,正弦编码在短文本任务上和可学习位置编码效果差不多,但在长文本外推(训练时见短句、推理时见长句)上,正弦编码泛化更好,因为它不依赖具体的最大长度参数。
3.3 位置编码到底怎么加
实现上,位置编码是一个(max_len, d_model)的矩阵,直接和词嵌入相加:
class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len=5000, dropout=0.1): super().__init__() pe = torch.zeros(max_len, d_model) position = torch.arange(0, max_len).unsqueeze(1).float() 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) pe = pe.unsqueeze(0) # (1, max_len, d_model) self.register_buffer('pe', pe) self.dropout = nn.Dropout(dropout) def forward(self, x): # x: (batch, seq_len, d_model) x = x + self.pe[:, :x.size(1)] return self.dropout(x)几个实操细节。第一,register_buffer很关键,它让 pe 成为模型状态的一部分(会跟着.to(device)走),但不会被当作可学习参数更新。第二,div_term用exp(log(...))的写法比直接10000 ** (2i/d_model)数值更稳定。第三,加法之后通常接一个 dropout,原始论文里 dropout 率是 0.1。
提示:位置编码加在词嵌入上,而不是拼接到后面。加法不增加维度,拼接会增加 d_model,后续所有矩阵都要跟着改,没必要。
3.4 可学习位置编码与正弦编码的取舍
BERT 用的是可学习位置编码,就是一个(max_len, d_model)的nn.Embedding,跟着训练一起更新。它的好处是灵活,能学到数据里特有的位置模式;坏处是受限于 max_len,推理时超过这个长度就没法处理(除非做插值)。
我的经验是:如果你的任务序列长度固定且不长(比如分类任务,max_len=128),可学习编码够用;如果序列长度变化大、或者有外推需求,正弦编码更稳。还有一种折中方案是相对位置编码(如 T5 的 bucket 方案),但那是另一个话题了。
4. 前馈网络、残差与 LayerNorm 的工程细节
4.1 前馈网络为什么是两层而不是一层
前馈网络的结构是Linear(d_model, d_ff) -> ReLU -> Linear(d_ff, d_model),中间维度 d_ff 通常是 d_model 的 4 倍(原始论文 d_model=512,d_ff=2048)。为什么是两层?因为单层线性变换等价于一个线性映射,无论怎么堆都还是线性的,表达能力有限。加一个非线性激活(ReLU 或 GELU)之后,FFN 就能拟合更复杂的函数。
为什么中间要放大 4 倍?这是经验值。放大给模型更多的“临时工作空间”,让它能在高维空间里做非线性组合,再压回原维度。我试过 2 倍和 8 倍,2 倍效果略差,8 倍参数涨太多收益不明显,4 倍是个甜点。
FFN 是逐位置独立作用的,也就是说每个 token 的向量单独过这个网络,token 之间的信息交换全靠注意力层。这个分工很清晰:注意力负责“横向”交互,FFN 负责“纵向”加工。
4.2 残差连接解决的是什么问题
残差连接就是output = x + Sublayer(x)。它的作用有两个层面。
从梯度角度,反向传播时梯度可以通过这条“捷径”直接回传到浅层,不用穿过每一层的非线性变换,极大缓解了梯度消失。这让训练几十层的网络成为可能。
从信息角度,残差让每一层只需要学习“增量”,而不是从头学一个完整映射。如果某一层没学到东西,输出至少等于输入,不会比原来更差。这种“保底”机制让深层网络更容易训练。
在 Encoder 里,每个子层(注意力和 FFN)外面都包了残差。注意残差要求输入输出维度一致,这也是为什么 d_model 在整层里保持不变。
4.3 Post-LN 和 Pre-LN 的实战差异
这是我最想强调的一节,因为踩过坑。
原始论文是 Post-LN:LayerNorm(x + Sublayer(x))。归一化在残差相加之后。这种结构在浅层(6 层)配合 warmup 能训,但层数一深(比如 12 层以上)就非常不稳定,loss 容易炸,必须小心翼翼地调学习率和 warmup 步数。
Pre-LN 是x + Sublayer(LayerNorm(x)),归一化在子层输入之前。这样残差路径上没有任何归一化操作,梯度可以无阻碍地回传,训练稳定性大幅提升。GPT-2 之后基本都用 Pre-LN。代价是最终输出前需要额外加一个 LayerNorm,因为最后一层的输出没被归一化过。
我做过对比实验:同样 12 层 Encoder,Post-LN 需要 warmup 4000 步才能稳住,Pre-LN 几乎不需要 warmup 就能正常收敛,而且对学习率没那么敏感。所以现在自己写 Encoder,我默认用 Pre-LN。
4.4 LayerNorm 为什么不用 BatchNorm
BatchNorm 是在 batch 维度上做归一化,统计每个特征在 batch 内的均值和方差。它在 CV 里很成功,但在 NLP 里问题很大:序列长度不一,padding 多,batch 统计量不稳定;而且推理时如果 batch 很小,统计量偏差大。
LayerNorm 是在特征维度上做归一化,对每个样本、每个位置独立计算均值和方差。它不依赖 batch 大小,天然适合变长序列。这就是为什么 Transformer 全线用 LayerNorm。
实现上,LayerNorm 有可学习的缩放参数 gamma 和平移参数 beta,让模型在归一化之后还能恢复一定的表达能力。eps 一般设 1e-5 或 1e-6,太小数值不稳,太大归一化效果打折。
5. 从零搭一个可用的 Encoder:完整代码与逐行注释
5.1 整体结构设计
我习惯把 Encoder 拆成三个类:MultiHeadAttention、FeedForward、EncoderLayer,最后用Encoder把它们堆起来。这样职责清晰,改哪块都不影响其他部分。
先定义超参:d_model=512,n_heads=8,d_ff=2048,n_layers=6,dropout=0.1。这些是原始论文的配置,可以直接用。
5.2 多头注意力的完整实现
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(dropout) def forward(self, x, mask=None): batch, seq_len, _ = x.size() # 投影并切分多头 q = self.w_q(x).view(batch, seq_len, self.n_heads, self.d_k).transpose(1, 2) k = self.w_k(x).view(batch, seq_len, self.n_heads, self.d_k).transpose(1, 2) v = self.w_v(x).view(batch, seq_len, self.n_heads, self.d_k).transpose(1, 2) # q,k,v: (batch, n_heads, seq_len, d_k) scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores = scores.masked_fill(mask == 0, torch.finfo(scores.dtype).min) attn = self.dropout(torch.softmax(scores, dim=-1)) out = torch.matmul(attn, v) # (batch, n_heads, seq_len, d_k) # 拼回多头 out = out.transpose(1, 2).contiguous().view(batch, seq_len, self.d_model) return self.w_o(out)这里contiguous()不能省,因为transpose之后内存不连续,直接view会报错。这是新手最常遇到的报错之一。
5.3 前馈网络与 EncoderLayer
class FeedForward(nn.Module): def __init__(self, d_model, d_ff, dropout=0.1): super().__init__() self.net = nn.Sequential( nn.Linear(d_model, d_ff), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_ff, d_model), ) def forward(self, x): return self.net(x) class EncoderLayer(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout=0.1): super().__init__() self.attn = MultiHeadAttention(d_model, n_heads, dropout) self.ffn = FeedForward(d_model, d_ff, dropout) self.norm1 = nn.LayerNorm(d_model, eps=1e-6) self.norm2 = nn.LayerNorm(d_model, eps=1e-6) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): # Pre-LN x = x + self.dropout(self.attn(self.norm1(x), mask)) x = x + self.dropout(self.ffn(self.norm2(x))) return x注意激活函数我用了 GELU 而不是 ReLU。原始论文用 ReLU,但 BERT 之后 GELU 成了主流,它在 0 附近更平滑,梯度性质更好,实测在多数任务上略优。
5.4 堆叠成完整 Encoder
class Encoder(nn.Module): def __init__(self, vocab_size, d_model=512, n_heads=8, d_ff=2048, n_layers=6, max_len=5000, dropout=0.1): super().__init__() self.embedding = nn.Embedding(vocab_size, d_model) self.pos_enc = 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, eps=1e-6) def forward(self, x, mask=None): x = self.pos_enc(self.embedding(x)) for layer in self.layers: x = layer(x, mask) return self.norm(x)最后的self.norm是 Pre-LN 的标配,因为最后一层输出没被归一化。词嵌入通常还会乘一个sqrt(d_model),让嵌入和位置编码的量级匹配,这个细节原始论文提到了但很多人会漏。
6. Mask 机制:padding mask 与 causal mask 的正确用法
6.1 padding mask 解决什么问题
实际训练时一个 batch 里句子长度不一,短的要用 0 补齐到最长。这些 padding 位置本身没有意义,但如果不处理,注意力会去关注它们,污染结果。padding mask 就是告诉模型:这些位置别管。
实现上,mask 是一个(batch, 1, 1, seq_len)的 0/1 矩阵,1 表示有效 token,0 表示 padding。在 softmax 之前把 0 位置填成负无穷,softmax 后这些位置的概率就是 0。
def make_padding_mask(seq, pad_idx=0): # seq: (batch, seq_len) mask = (seq != pad_idx).unsqueeze(1).unsqueeze(2) return mask # (batch, 1, 1, seq_len)6.2 Encoder 需要 causal mask 吗
不需要。这是 Encoder 和 Decoder 的关键区别。Encoder 是双向的,每个位置可以看到整个序列(包括后面),因为它是在做“理解”,不是“生成”。Decoder 才需要 causal mask(也叫 look-ahead mask),保证生成第 t 个词时只能看到前 t-1 个词,不能偷看未来。
我见过有人把 causal mask 误加到 Encoder 上,结果模型效果大打折扣,还找不到原因。记住:Encoder 只用 padding mask。
6.3 mask 形状与广播的坑
mask 的形状必须能广播到注意力分数(batch, n_heads, seq_len, seq_len)。padding mask 是(batch, 1, 1, seq_len),在 seq_len 那一维广播,表示“所有 query 位置都不关注这些 key 位置”。如果你要做更复杂的 mask(比如某些 token 之间不能互相看),形状要相应调整。
一个常见 bug 是 mask 的 dtype 不对,或者用了masked_fill(mask, -inf)但 mask 是布尔值反了。建议统一用布尔 mask,True表示保留,False表示屏蔽,然后masked_fill(~mask, -inf)。写单元测试验证:全 padding 的行,注意力输出应该全是 0。
7. 训练稳定性与性能优化的实战经验
7.1 初始化与 warmup 的配合
Transformer 对初始化敏感。原始论文用 Xavier 初始化,但实践中我更喜欢用较小的标准差(比如 0.02)初始化线性层权重,LayerNorm 的 gamma 初始化为 1、beta 为 0。这样初始输出量级可控。
即使上了 Pre-LN,前几百步用 warmup 把学习率从 0 线性升到峰值,仍然是个好习惯。warmup 步数一般是总步数的 5%~10%。我试过完全不用 warmup,Pre-LN 也能训,但前期 loss 抖动会大一些。
7.2 注意力计算的显存优化
标准注意力的显存是 O(seq_len^2),序列一长就爆。几个优化方向:
- 梯度检查点(gradient checkpointing):用时间换显存,把中间激活不保存,反向时重算。适合显存紧张但能接受训练变慢的场景。
- FlashAttention:通过分块计算避免显式存储完整的注意力矩阵,既省显存又快。PyTorch 2.0 之后
F.scaled_dot_product_attention已经内置支持,直接调用即可。 - 稀疏注意力:只计算部分位置的注意力,适合超长序列,但实现复杂,效果因任务而异。
我的建议是:序列长度 512 以内,标准实现够用;超过 1024,优先考虑 FlashAttention;再长再考虑稀疏方案。
7.3 常见报错与排查清单
| 报错/现象 | 可能原因 | 排查方向 |
|---|---|---|
| loss 变 NaN | mask 用了 -inf 且混合精度 | 换成 finfo.min |
| 训练不收敛 | Post-LN 没加 warmup | 改 Pre-LN 或加 warmup |
| 输出全是同一个值 | 注意力被 mask 全屏蔽 | 检查 mask 逻辑 |
| view 报错 | transpose 后没 contiguous | 加 .contiguous() |
| 效果远差于预期 | 位置编码没加或加错 | 打印 pos_enc 输出检查 |
| 显存爆 | 序列太长,注意力矩阵太大 | 用 FlashAttention 或减 batch |
这张表是我自己排错时总结的,基本覆盖了 90% 的常见问题。
8. 一些容易被忽略但很关键的细节
8.1 词嵌入缩放
原始论文里,词嵌入会乘以sqrt(d_model)。原因是位置编码的值域大致在 [-1, 1],而随机初始化的词嵌入量级偏小,两者相加时位置信息会占主导。乘上sqrt(d_model)让两者量级接近,信息更均衡。这个细节很多开源实现会漏,但对小模型影响明显。
8.2 dropout 的位置
dropout 在 Encoder 里有三处:位置编码之后、每个子层输出之后(残差相加前)、注意力权重上。原始论文的 dropout 率统一是 0.1。注意力权重上的 dropout 容易被忽略,但它对防止过拟合有帮助,尤其是小数据集。
8.3 权重共享
Encoder 和 Decoder 的词嵌入矩阵、以及输出层的投影矩阵,在很多实现里是共享的。这能减少参数量,还能让词向量和输出空间对齐。不过纯 Encoder 模型(如 BERT)没有输出投影层,只共享嵌入。
8.4 层数、头数、维度的取舍
给几个我实测的经验值:
- 小数据集(几万条):4 层、4 头、d_model=256 就够,再大容易过拟合。
- 中等数据集(几十万条):6 层、8 头、d_model=512,原始配置。
- 大数据集(百万级以上):12 层起步,d_model=768 或更大。
头数不是越多越好。d_model=512 时,8 头(d_k=64)是甜点;16 头(d_k=32)每个头容量太小,效果反而下降。经验法则是 d_k 不低于 32。
9. 从 Encoder 出发的延伸方向
把 Encoder 吃透之后,往下走有几个自然的方向。一是掩码语言模型(MLM),BERT 的核心预训练任务,随机 mask 掉一些 token 让模型预测,这需要理解 Encoder 的双向特性。二是对比学习,像 SimCSE 那样用 Encoder 编码句子做语义匹配,重点在 pooling 策略和损失函数设计。三是长文本建模,把标准注意力换成稀疏或线性注意力,处理文档级输入。四是多模态,把文本 Encoder 和图像 Encoder 对齐,做跨模态检索或生成。
每个方向都值得单独展开,但它们的共同地基都是这篇讲的 Encoder。我自己在从分类任务转向语义匹配时,最大的体会就是:Encoder 的输出怎么用(取 [CLS]、平均池化还是最大池化)对最终效果的影响,有时候比模型结构本身还大。这个坑我踩过,后面有机会再细说。
最后分享一个我调试 Encoder 的小习惯:写完之后先拿一个极小的数据集(比如 100 条)过拟合,如果 loss 能降到接近 0,说明结构和前向传播没问题;如果降不下去,那一定是实现有 bug,别急着上大数据集。这个“先过拟合小数据”的检查,帮我省下了无数次盲目调参的时间。