BERT 在做什么
BERT 原论文使用多层 Transformer encoder 给输入文本编码。encoder 的自注意力可以同时看当前位置左右两边的 token;这与自回归生成时只许看左侧历史的因果注意力不同。BERT 常用于分类、序列标注、问答等理解任务。它不是一个逐字生成后续文本的 decoder。
输入不只是 token ID。原版 BERT 把 token embedding、segment embedding、position embedding 相加,送入 encoder。句对输入常写作 [CLS] A [SEP] B [SEP]:[CLS] 的最终表示用于句级任务,[SEP] 标出边界,segment ID 区分 A、B。实际还要提供 padding mask,防止注意力把补齐位置当成有效文本。经过 L 层后,若批大小为 B、序列长为 T、隐藏维度为 D,输出 hidden 的形状是 [B, T, D]。
这里的“可以看两边”指同一输入序列内部不使用因果上三角掩码,而不是无条件读取训练集之外的文本。若输入是一对句子,A 的 token 可以关注 B 的 token,反之亦然;若输入只是一句,segment ID 仍可以统一填 A。位置 embedding 在原版 BERT 中是可学习参数,和前一篇介绍的固定正弦编码并不相同。输入超过位置表的长度时,不能仅靠增加 padding mask 解决:还涉及位置表大小与模型训练过的上下文范围。
原版预训练:MLM 与 NSP
Masked Language Model(MLM) 随机选中约 15% 的 token 作为预测目标。原论文对这些选中位置,80% 换成 [MASK],10% 换成随机 token,10% 保留原 token;无论输入如何替换,标签始终是原 token。损失只计算被选中的位置,不是对整句每个 token 都做词表分类。这样模型必须利用双侧上下文,但 [MASK] 主要出现在预训练阶段,微调时未必出现;80/10/10 的安排也有助于减小这种输入差异。
Next Sentence Prediction(NSP) 是原版 BERT 的预训练设计:给定 A、B,约一半的 B 是原文中紧接 A 的片段,另一半是随机抽取的片段;使用 [CLS] 表示做二分类。它训练的是原论文定义的句对关系,不等同于一般的语义相似度判断。后来的模型可以修改或舍弃 NSP,所以看到“BERT 风格模型”时不要默认它仍在训练这个目标。
拿“今天下雨了。/我带了伞。”举例,MLM 可能遮住“伞”,让模型利用前后文猜回原词;NSP 则检查第二个片段是否来自第一段后面的原文位置。一个 batch 中可以同时有 MLM 标签 [B, T] 和 NSP 标签 [B]:前者逐 token 监督少数位置,后者逐句对监督一个结果。两种损失在预训练时一起优化,但各自解决的问题和标签粒度不同。尤其不要把随机替换的 token 当成训练答案;答案始终来自原文本。
下方代码只画出结构和张量流,不包含数据采样、训练循环或可复现原版权重的全部细节。nn.TransformerEncoderLayer 是教学用近似组件;真实 BERT 还涉及具体层数、初始化、词表、归一化与池化实现。
import torch
from torch import nn
class TinyBertSketch(nn.Module):
def __init__(self, vocab_size, max_len, dim=256, heads=4, layers=4):
super().__init__()
self.token = nn.Embedding(vocab_size, dim)
self.segment = nn.Embedding(2, dim)
self.position = nn.Embedding(max_len, dim)
block = nn.TransformerEncoderLayer(
d_model=dim, nhead=heads, batch_first=True,
activation="gelu")
self.encoder = nn.TransformerEncoder(block, num_layers=layers)
self.mlm = nn.Linear(dim, vocab_size)
self.pool = nn.Sequential(nn.Linear(dim, dim), nn.Tanh())
self.nsp = nn.Linear(dim, 2)
def forward(self, input_ids, segment_ids, attention_mask):
B, T = input_ids.shape
pos = torch.arange(T, device=input_ids.device)[None, :]
x = self.token(input_ids) + self.segment(segment_ids)
x = x + self.position(pos) # [B, T, D]
hidden = self.encoder(x, src_key_padding_mask=~attention_mask.bool())
mlm_logits = self.mlm(hidden) # [B, T, vocab_size]
nsp_logits = self.nsp(self.pool(hidden[:, 0])) # [B, 2]
return hidden, mlm_logits, nsp_logits
注意 attention_mask=True 表示有效 token,而 PyTorch 的 src_key_padding_mask=True 表示要忽略,所以代码里取反。MLM 的标签可把未选中位置设为 -100,再用 cross_entropy(..., ignore_index=-100);NSP 标签是每个句对一个二分类值。代码没有处理预训练原文中的全部工程细节,例如输出层权重共享,因此不应将它当成原版 BERT 的逐项复刻。
若要把这个示意变成训练代码,还需在数据准备阶段完成分词、特殊符号插入、选中位置抽样以及 MLM 标签生成。先保留原 token 作为标签,再按 80/10/10 改写输入;未被选中的位置设为忽略值。[PAD] 既要被注意力掩码排除,也不该产生 MLM 损失。做句级任务时,hidden[:, 0] 的形状是 [B, D];做逐 token 任务时通常保留 [B, T, D],并用标签掩码跳过 padding 与不参与标注的特殊符号。把这些掩码混成一个变量,往往会得到能运行却目标错误的训练。
微调与预训练的分工
预训练先用大量未标注文本学习通用表示;微调再用任务标注更新 encoder 和小型任务头。句级分类可取 [CLS] 表示接线性分类头;序列标注可对 hidden[:, t, :] 的每个位置分类;抽取式问答可预测答案片段的起止位置。微调时应按下游任务构造标签和损失,不能继续把 MLM/NSP 的损失直接当作分类任务目标。
三个常见误会值得分开:双向指编码时能利用两侧上下文,不表示 BERT 可以直接因果生成;MLM 只监督抽中的位置,不是整句填空;NSP 特指原版 BERT 的一个预训练目标,不是所有 encoder 模型的必要部件。
原始论文: Devlin et al., BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding。