简介:这份资源是Packt于2021年出版的《Transformers for Natural Language Processing》电子书,面向具备Python基础、希望系统掌握Transformer架构与预训练模型应用的NLP学习者与开发者。全书按三阶段展开:先剖析自注意力机制与原始Transformer的训练方法,再过渡到BERT、RoBERTa、DistilBERT、GPT-2、T5等模型在自然语言理解与生成任务中的用法,最后进入社交网络数据分析、假新闻识别等高级语言理解场景,并结合Hugging Face、Trax、AllenNLP等平台与TensorFlow、Keras实践情感分析、文本摘要、语音识别和机器翻译,同时讨论模型性能度量与适用范围界定。压缩包内为1个PDF文件,约4.72MB,完整保留原书章节结构与代码示例说明,便于离线阅读和按主题检索。该资源已有1969人学习下载,适合想从原理走到落地、建立完整Transformer知识链条的读者查阅参考,也可作为课程学习与技术选型的辅助材料。
1. 参数量不是答案:小模型为什么能在特定任务上反超大模型
把 Transformers 用在自然语言处理(NLP)里,最常见的直觉是参数越多效果越好。但同一批测试集跑下来会发现,一个几千万参数的小模型,在若干细分任务上的分数能压过参数量高出两个数量级的对手。这不是玄学,也不是评测集漏题:差距几乎都落在两处——分词器与语料的匹配程度,以及注意力层把多长的依赖压进定长向量。前者决定模型看到的“词”和语料里的词是不是同一个东西,后者决定信息在层与层之间传递时被打折多少。看懂这两处,才能明白为什么微调一个中等模型常常比直接调用超大模型更划算,也才能看懂后面那些从零预训练、微调、评估的步骤到底在调哪一部分。
2. 自注意力:为什么它能替掉循环结构还抓得住长距离依赖
循环网络读一句话是一个词一个词往前推的,第 100 个词的信息要影响第 1 个词,中间必须穿过 99 次状态更新,梯度沿着这条链反复相乘,衰减几乎是结构性的。自注意力把这条链压成了一步:序列里任意两个位置直接算一次相似度,路径长度恒为 1。代价是它天生不知道谁在前谁在后,而且计算量随序列长度平方增长。这一章把 Q/K/V 投影、缩放因子、多头拆分和位置编码四件事拆开看,它们合起来才构成“能替代循环结构”的完整理由。
2.1 Q/K/V 三组投影:为什么不是一个矩阵
对每个 token 的隐向量,用三个独立的线性层分别投影出 query、key、value。query 表达“我这个位置想找什么样的信息”,key 表达“我这个位置能提供什么线索”,value 表达“如果被选中,我实际交出去的内容”。三者分开的目的是解耦“匹配”和“传递”:如果 query 和 key 共用一个投影,相似度就被绑死在内容本身,模型没法学出“用 A 特征去匹配 B 特征、但取回 C 内容”这种模式,而这恰恰是代词消解、主谓搭配这类任务最需要的能力。
import math import torch import torch.nn.functional as F def scaled_dot_product_attention(Q, K, V, mask=None): """Q/K/V 形状均为 [batch, heads, seq_len, d_k]""" d_k = Q.size(-1) # 1. 相似度:最后一维做点积,得到 [batch, heads, seq_len, seq_len] scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k) # 2. mask 中 0 表示"不允许看",填 -inf 后 softmax 权重恰好为 0 if mask is not None: scores = scores.masked_fill(mask == 0, float("-inf")) # 3. 在 key 维度归一化:每个 query 对所有 key 的权重和为 1 weights = F.softmax(scores, dim=-1) # 4. 用权重对 value 加权求和 return torch.matmul(weights, V), weights这段实现的四个步骤对应注意力的四层语义。K.transpose(-2, -1)把 key 的序列维换到最后一维,点积后得到的矩阵第 i 行第 j 列就是“第 i 个位置对第 j 个位置的关注分数”。dim=-1不能写错成dim=-2,写成 -2 就变成对 query 归一化,每个 key 收到的权重和为 1,语义完全变了,但代码照样能跑,损失照样会降一点,这是最难查的一类错误。masked_fill用-inf而不是 0 或很小的负数,是因为 softmax 里exp(-inf)=0,权重被精确清零;用-1e9这类近似值在 fp16 混合精度下容易溢出成 nan。
mask的张量形状通常是[batch, 1, 1, seq_len],中间两个 1 靠广播扩展到[batch, heads, seq_len, seq_len]。第一个 1 让所有头共享同一份掩码,第二个 1 让所有 query 位置共享同一份“哪些 key 不可见”的规则。理解这一点,后面调 padding 掩码和因果掩码时就不会反复出错。
2.2 缩放因子:为什么除以 √d_k 而不是 d_k
假设 query 和 key 的每个分量独立、均值 0、方差 1,那么它们的点积是 d_k 个独立随机变量之和,方差为 d_k,标准差为 √d_k。d_k 取 64 时,logits 的量级能到 8 左右;取 512 时能到 20 以上。softmax 的输入幅度一大,输出就逼近 one-hot:最大值那一位接近 1,其他接近 0,反向传播时雅可比矩阵几乎全是 0,梯度直接消失。除以 √d_k 正好把方差从 d_k 拉回 1,让 softmax 工作在梯度最健康的区间。
可以用几行代码验证这个判断,不必凭感觉相信:
import torch for d_k in [16, 64, 512]: q = torch.randn(1, 1, 1, d_k) k = torch.randn(1, 1, 8, d_k) # 8 个候选位置 raw = (q @ k.transpose(-2, -1)).squeeze() scaled = raw / d_k ** 0.5 # 熵越低说明分布越接近 one-hot,梯度越容易消失 p_raw = torch.softmax(raw, dim=-1) p_scaled = torch.softmax(scaled, dim=-1) entropy = lambda p: -(p * p.clamp_min(1e-12).log()).sum().item() print(d_k, round(entropy(p_raw), 3), round(entropy(p_scaled), 3))三个 d_k 下未缩放分布的熵会明显低于缩放后,且 d_k 越大差距越夸张。这里的clamp_min是为了避免 log(0) 产生 nan,属于数值细节而非算法的一部分。有人会问为什么不是除以 d_k:除以 d_k 会把方差压到 1/d_k,logits 全部挤在 0 附近,softmax 退化成均匀分布,注意力就等于没学。
2.3 多头:把一次注意力拆成多个子空间
单头注意力的输出是 value 的加权平均,它只能表达一种“关注模式”。多头把 d_model 切成 h 份,每份单独做一次注意力,最后拼接再过一个输出投影。8 头、d_model=512 时,每头 d_k=64。参数量并没有因为多头而暴涨:Q/K/V 三个投影矩阵各自是d_model × d_model,与头数无关,多头只是在同一批参数的结果上换了切分方式。
class MultiHeadAttention(torch.nn.Module): def __init__(self, d_model=512, num_heads=8): super().__init__() assert d_model % num_heads == 0, "d_model 必须能被头数整除" self.num_heads = num_heads self.d_k = d_model // num_heads self.w_q = torch.nn.Linear(d_model, d_model) self.w_k = torch.nn.Linear(d_model, d_model) self.w_v = torch.nn.Linear(d_model, d_model) self.w_o = torch.nn.Linear(d_model, d_model) # 拼接后的输出投影 def _split(self, x): b, s, _ = x.shape # [b, s, d_model] -> [b, heads, s, d_k] return x.view(b, s, self.num_heads, self.d_k).transpose(1, 2) def forward(self, x, mask=None): q, k, v = self._split(self.w_q(x)), self._split(self.w_k(x)), self._split(self.w_v(x)) out, _ = scaled_dot_product_attention(q, k, v, mask) b, h, s, d = out.shape # 先转回 [b, s, heads, d_k],再 view 成 [b, s, d_model] out = out.transpose(1, 2).contiguous().view(b, s, h * d) return self.w_o(out)transpose之后必须调用contiguous()才能view,因为这一步交换的是内存步长,张量在内存里不再连续,直接view会报错。这是实际写代码时的常见问题,换成reshape虽然会自动拷贝,但会掩盖“你到底有没有多复制一份”的事实。
为什么头数不能无限增加?每头分到的 d_k 变小,单个子空间的表达能力下降;头数过多时 d_k 会小到 8 甚至 4,注意力分数变得极不稳定。常见的工程经验是让 d_k 落在 64 附近,头数随 d_model 线性增长。另外要清楚:头之间的分工不是设计出来的,是训练出来的,事后用可视化工具看到的某些头专盯句法结构、某些头专盯指代,都是训练涌现的结果,不是先验约束。
2.4 位置编码:注意力本身无序,顺序信息从哪来
注意力对输入是置换等变的:把输入序列打乱,输出只是同样被打乱,内容一模一样。这意味着不加位置信息,模型读“狗咬人”和“人咬狗”得到的表示无法区分。原始方案用正弦余弦函数给每个位置生成一个固定向量,与词向量逐元素相加。
import numpy as np def positional_encoding(max_len, d_model): pe = np.zeros((max_len, d_model)) pos = np.arange(max_len)[:, None] # [max_len, 1] i = np.arange(0, d_model, 2) # 偶数维下标 # 波长按几何级数从 2π 拉长到 10000·2π,低频维区隔远距离,高频维区隔近距离 div = np.exp(-np.log(10000.0) * i / d_model) pe[:, 0::2] = np.sin(pos * div) # 偶数维用 sin pe[:, 1::2] = np.cos(pos * div) # 奇数维用 cos return pe这个设计的巧妙之处在波长:div是几何级数,前几个维度波长很短,能在相邻位置间产生明显差异;后几个维度波长极长,几百个位置的距离上才有可辨变化。模型因此能同时感知“隔一个词”和“隔一百个词”两种尺度。更关键的是,任意偏移 k 的位置编码都能写成当前位置编码的线性变换,模型只要学一个与 k 相关的线性映射,就能推断相对距离,这让外推到训练时没见过的长度成为可能(虽然效果有限)。
正弦编码在实践里已经被可学习的位置嵌入和相对位置编码大量取代,原因是绝对编码在超过训练长度后衰减得很快。但理解它仍是必要的,因为后面所有变体——无论是给 BERT 的位置嵌入表,还是 RoBERTa 把最大长度设成 514——都建立在这套“位置必须被显式注入”的前提上。
3. 预训练加微调:BERT 的两阶段到底省下了什么
从头训练一个能理解语言的模型,需要几千亿 token 和上百张卡。预训练加微调把这件事拆成两段:第一段在海量无标注文本上学习通用的语言表示,第二段用几千条标注数据把表示对准具体任务。省下来的不只是算力,更是标注成本。理解这个范式,关键要看清楚两件事——预训练目标决定了模型能学到什么,微调阶段决定了这些知识能不能被有效调用。
3.1 掩码语言模型:遮住 15% 的 token 之后模型学到了什么
BERT 的预训练任务是完形填空:随机选 15% 的 token,其中 80% 替换成[MASK],10% 替换成随机词,10% 保持不变。三个比例不是随手定的。如果 100% 都换成[MASK],这个符号只在预训练阶段出现,微调时根本见不到,模型会学到一个只对[MASK]有效的表示,与下游任务脱节。保留 10% 随机词是为了逼模型对每个位置都保持警惕,不能只依赖“看到[MASK]才思考”。保留 10% 原词是为了让模型知道“即使这个词没被遮住,也要认真编码它”。
这个目标天然是双向的:预测第 i 个位置时,左边和右边的上下文都能看到。这一点是它和自回归语言模型的根本分岔——自回归只能看左边,适合生成;双向能看到全句,适合理解。所以 BERT 类模型在分类、抽取式问答、序列标注上强,直接拿来做长文本生成就很勉强。另一个常被忽略的细节是,预训练时一次前向只预测那 15% 的位置,损失只在这些位置计算,梯度信号天然稀疏,这也解释了为什么预训练需要那么大的 batch。
后来的 RoBERTa 把下一句预测任务整个去掉了,只保留掩码语言模型,并把静态掩码改成动态掩码:每个 epoch 重新采样被遮的位置。静态掩码下同一个句子在多个 epoch 里遮的是同一批词,模型容易记住位置模式;动态掩码让模型每次看到不同的残缺版本,等价于扩充了数据。这个改动看起来很小,实际收益相当可观。
3.2 优化器参数组:为什么 bias 和 LayerNorm 不能加权重衰减
微调时的标准配置是 AdamW 加线性预热加线性衰减,学习率在 2e-5 到 5e-5 量级。这里有一个很容易被忽略的细节:权重衰减不能一视同仁地加到所有参数上。
no_decay = ["bias", "LayerNorm.weight", "LayerNorm.bias"] optimizer_grouped_parameters = [ {"params": [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)], "weight_decay": 0.01}, {"params": [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)], "weight_decay": 0.0}, ] optimizer = torch.optim.AdamW(optimizer_grouped_parameters, lr=2e-5, eps=1e-8) scheduler = torch.optim.lr_scheduler.LambdaLR( optimizer, lambda step: min(step / warmup_steps, 1.0) * max(0.0, 1 - step / total_steps) )偏置项的作用是平移激活值,把它往 0 拉等于强行改变激活的中心;LayerNorm 的缩放因子本身就是在做归一化,再施加衰减会让归一化失效。这两类参数在总参数量里占比很小,但衰减错了会让训练后期损失突然抖动,而且这种抖动很难归因,通常被误认为学习率太大。预热的作用是在训练初期把学习率从 0 线性拉到目标值,避免第一批梯度把预训练好的权重冲垮。
3.3 注意力掩码与张量形状:一次前向传播里发生了什么
一条文本进入 BERT 的路径是这样的:分词器把字符串切成 subword,映射成 id,得到[batch, seq_len]的整型张量;嵌入层查表得到[batch, seq_len, hidden],叠加位置嵌入和 token 类型嵌入,过一层 LayerNorm 和 dropout;进入编码器层,多头注意力里把张量拆成[batch, heads, seq_len, d_k],算分数矩阵[batch, heads, seq_len, seq_len],加权求和后拼回[batch, seq_len, hidden];过残差和 LayerNorm,进前馈网络,维度从 hidden 升到 4×hidden 再降回来,再残差再 LayerNorm。堆 12 层之后,取[CLS]位置的向量接一个线性分类头。
注意力掩码在这个流程里承担两件事:一是屏蔽 padding,让模型不要把补齐的无效位置当成真实内容;二是保证同一 batch 内不同长度的样本互不干扰。掩码的值是 1 表示可见、0 表示不可见,扩展成[batch, 1, 1, seq_len]后靠广播生效。这里有一个必须时刻警惕的点:不同库对掩码的语义约定不一样,有的用 1 表示要屏蔽、0 表示保留,如果混用,模型不会报错,只是注意力全被屏蔽,输出全是 nan,或者反过来完全没屏蔽,padding 参与计算,分数虚高但看不出原因。
3.4 评估指标:为什么准确率在类别不均衡时会骗人
一个二分类数据集里正样本只占 5%,模型全部预测为负,准确率 95%。这个数字看起来漂亮,实际毫无信息量。类别不均衡的场景下要看马修斯相关系数(MCC),它同时考虑混淆矩阵的四个格子,取值在 -1 到 1 之间,0 表示与随机猜测等价。
import math def matthews_corrcoef(tp, tn, fp, fn): num = tp * tn - fp * fn den = math.sqrt((tp + fp) * (tp + fn) * (tn + fp) * (tn + fn)) if den == 0: # 某一类完全没被预测出来,分母退化为 0 return 0.0 return num / den分子的tp*tn - fp*fn是“预测正确且同类”与“预测错误且异类”的差值,本质上是两个二元变量的皮尔逊相关系数。把它用在评估上,全预测为负的模型会得到 0 分而不是 95 分,因为 tn 很大但 tp 为 0,分子直接被 fp*fn 拉成负数或 0。分母为 0 的退化情况必须显式处理,否则在验证集早期模型还没学会预测正类时,会直接抛 ZeroDivisionError 中断训练。
除了指标选择,评估还有两个必须做对的细节:一是要在没参与训练的留出集上算,二是标签 id 和类别名的映射顺序要与训练时一致。后者错位不会报错,只会让指标凭空掉十几个点,是排查时最应该先检查的一行代码。
4. 从零预训练一个 RoBERTa:分词器、词表和显存的三角关系
用别人训好的模型是常态,但真正理解 Transformer 的时刻,往往发生在自己从零搭一个的时候:你会亲眼看到词表大小如何吃掉显存,看到配置里每个数字如何决定参数量,看到数据整理器如何悄悄改变训练的稳定性。这一章沿着“训分词器、写配置、组数据、存模型”的顺序走一遍,重点讲每一步背后的取舍。
4.1 先训分词器:词表大小是一次容量与显存的交换
分词器不是训练模型的前置杂务,它决定了模型能看见什么。byte-level BPE 从字节开始,反复合并出现频率最高的相邻对,直到达到目标词表大小。词表越大,越多的完整词能作为一个 token 存在,序列变短,注意力计算量下降;但嵌入矩阵是vocab_size × hidden_size,词表翻倍,这部分参数就翻倍。
from tokenizers import ByteLevelBPETokenizer tokenizer = ByteLevelBPETokenizer() tokenizer.train( files=["corpus.txt"], vocab_size=52000, # 词表容量 min_frequency=2, # 出现不足 2 次的子词直接丢弃,防止词表被噪声撑满 special_tokens=["<s>", "<pad>", "</s>", "<unk>", "<mask>"], ) tokenizer.save_model("tokenizer")min_frequency这个参数在小语料上尤其关键。语料只有几十兆时,把阈值设成 1 会让大量只出现一次的生僻串进入词表,这些 token 在训练中几乎学不到有效表示,却白白占掉嵌入矩阵的空间。反过来,阈值设太高会让词表被迫用短子词拼长词,序列变长,注意力开销上升。经验做法是先把阈值设成 2 到 5,训完统计一下实际词表大小和平均序列长度,再决定要不要调。
<mask>这个特殊符号必须显式声明。RoBERTa 的掩码语言模型在替换 token 时用的就是它,如果词表里没有,训练脚本要么报错,要么退化成用<unk>代替——后者不会崩,但模型永远学不到“这里被遮住了,需要根据上下文填回来”的信号。
4.2 配置里每个数字的真实含义
配置对象决定模型结构,写错一个数字,训练照样能跑完,只是模型容量和你的预期不符。
from transformers import RobertaConfig config = RobertaConfig( vocab_size=52000, # 必须与分词器实际词表大小完全一致 max_position_embeddings=514, # 512 个 token + 2 个特殊位 num_hidden_layers=6, # 编码器层数 num_attention_heads=12, # 头数 hidden_size=768, # 隐层维度 intermediate_size=3072, # 前馈网络中间层,常规取 hidden 的 4 倍 type_vocab_size=1, # 只有一种 segment,等价于放弃下一句预测 hidden_dropout_prob=0.1, )估算参数量有个粗略公式:嵌入层约vocab_size × hidden_size,每层约12 × hidden_size²(注意力四个投影 4h²,前馈两个矩阵 8h²,忽略偏置和 LayerNorm)。这套配置下嵌入层约 4000 万,6 层约 4250 万,总共 8000 万量级。可以立刻看出问题:词表占了将近一半参数。这就是为什么小语料上要从零训模型时,宁可选 32000 的词表或者干脆冻结嵌入层,而不是无脑照抄大模型的 50000。
intermediate_size取 4 倍是经验值,不是硬约束。它在表达能力和显存之间做权衡:倍率越大,前馈网络的非线性拟合能力越强,激活值显存占用也越大。显存吃紧时先降这个,再降层数,最后才动 hidden_size,因为 hidden_size 一改,所有矩阵形状全变,从零训倒无所谓,但想复用预训练权重就完全对不上了。
type_vocab_size=1是 RoBERTa 相对 BERT 的一个明确改动:去掉下一句预测任务后,输入里不再需要区分句子 A 和句子 B,这个维度塌缩成 1。如果照抄 BERT 的配置设成 2,模型能训,但多出来的那一行嵌入永远只收到零梯度。
4.3 数据整理器与动态掩码
模型要的是定长张量,而语料里的句子长短不一,所以每个 batch 都要做填充。数据整理器负责这件事,同时顺手完成掩码采样。
from transformers import DataCollatorForLanguageModeling collator = DataCollatorForLanguageModeling( tokenizer=tokenizer, mlm=True, # 走掩码语言模型分支 mlm_probability=0.15, # 每个 batch 重新采样 15% 的位置 )关键在于这个采样是发生在“每次取 batch”时,而不是“预处理数据”时。同一句话在第 1 个 epoch 被遮的是这几个词,第 2 个 epoch 换一批,这就是动态掩码。如果偷懒在预处理阶段一次性遮好并写进文件,就退化成了静态掩码,效果会明显变差,而且这种差距不会在训练损失上体现得很夸张,只有在下游任务上才看得出来。
填充策略也值得注意。按 batch 内最长序列填充(动态填充)比按全局最大长度填充能省下大量算力,尤其是长度分布长尾的时候。相应地,估显存要按 token 总数算而不是按样本数算:batch_size × max_len 才是真正决定激活值大小的量。显存不够时标准的做法是缩小 batch 再开梯度累积,让累积步数乘上 batch 等于目标等效批量,这样优化器的更新频率和梯度噪声都保持不变。
4.4 保存与加载:为什么 tokenizer 必须跟着模型走
从零训完之后,要保存的至少有三样:模型权重、配置、分词器词表。少了配置,加载时得手工重建结构;少了分词器,模型拿到的是另一套 id 映射,输出会静默错乱。用save_pretrained一次性写进同一个目录,加载时也从同一个目录读,是最省心的做法。
这里有一个代价很高的错误:训练时用 A 分词器,推理时用了 B 分词器,两者的 id 编号完全不同。程序不会报错,因为 id 都是合法整数,只是含义全变了,模型输出一段语法通顺但语义无关的文字。排查这类问题的唯一办法是把一段已知文本分别过两个分词器,打印 token 列表对比,通常第一行就能看出问题。
5. 避坑与排查:五个能让训练静默失败的细节
深度学习最麻烦的失败不是报错,是跑完了、损失曲线看着还行、指标也有一点,但结果就是不对。下面五条都是实际踩过的,按现象、原因、解决三段记录。
5.1 形状与掩码类
坑一:掩码方向反了,损失变成 nan。
现象是第一个 step 之后 loss 直接变成 nan,或者注意力权重打印出来全是均匀分布。原因是不同实现对掩码的语义约定相反,有的用 1 表示保留、0 表示屏蔽,有的反过来;把 0 当成“可见”会让整行 logits 全被填成-inf,softmax 分母为 0。解决方式是固定一个约定并在代码里写死注释:本项目的掩码 1 为可见、0 为屏蔽,所有转换集中在一个函数里做,不要在各个模块里各写一遍。
坑二:忘了切换训练和评估模式。
现象是评估时指标忽高忽低,同一份验证集跑两次结果不一样,或者显存占用比预期高一大截。原因是模型停在 train 模式,dropout 还在随机丢激活,同时对验证集算了梯度、建了计算图。解决办法是在评估循环外显式调用model.eval()并用torch.no_grad()包裹,循环结束再切回model.train()。这个错误之所以常见,是因为它只在指标层面制造噪声,永远不报错。
5.2 训练与指标类
坑三:微调学习率用了从头训练的量级。
现象是训练损失先降后猛涨,或者几步之内就崩到 nan,验证集指标还不如不训练。原因是预训练权重已经在最优点附近,从头训练常用的 1e-4 到 5e-4 对微调来说太大,一步就能把权重推出有效区域。解决办法是把微调学习率压到 2e-5 到 5e-5,并加上几十到几百步的线性预热;如果显存允许,把 batch 调大一些,梯度噪声小了,对学习率的容忍度也会提高。
坑四:分词器和模型词表对不上。
现象是加载时报 index out of range,或者不报错但输出全是<unk>、生成结果毫无意义。原因是模型配置里的vocab_size和分词器实际词表大小不一致,嵌入矩阵的行数不够,超出部分查到的是未初始化的向量。解决办法是在加载后立刻断言len(tokenizer) == model.config.vocab_size,并且在调整词表后必须重新初始化(或至少 resize)嵌入矩阵,扩出来的新行是随机值,需要在语料上继续训练一段时间才能收敛。
坑五:指标虚高。
现象是验证集准确率 95%,上线后效果一塌糊涂。原因通常有三个:一是类别不均衡下用了准确率,全预测多数类就很高;二是不小心在训练集上评估,或者数据划分时同一样本的变体同时进了训练集和验证集,造成泄漏;三是标签 id 与类别名的映射顺序在训练和评估两处不一致。解决办法是换成 MCC 或 F1 这类对不均衡敏感的指标,划分数据时按样本来源去重而不是按行去重,并把标签映射表做成一个常量模块,训练和推理都从同一处引用。
6. 文本到文本:统一范式省下的工程量和它付出的代价
把分类、抽取、翻译、摘要全部塞进“输入一段文本、输出一段文本”这一个模板里,工程上的收益非常直接:任务头消失了,每个任务不再需要单独设计输出层和损失函数,只需要在输入前面加一个前缀。
6.1 前缀代替任务头,换来了什么
原来做摘要要接一个序列到序列的解码器加交叉熵,做分类要在[CLS]上接一个线性层,做序列标注要接一个逐 token 的分类头。统一成文本到文本之后,这些差异全部被前缀吸收:摘要任务在输入前加summarize:,翻译加translate English to German:,模型看到前缀就知道该走哪条生成路径。代价是输出被约束成了文本形式,分类任务必须把类别名当字符串生成出来再解析回去,推理成本比一个线性头高出一到两个数量级;而且生成式解码是自回归的,逐 token 采样,延迟随输出长度线性增长。
编码器-解码器结构和纯解码器结构在这里也分道扬镳。编码器-解码器把“理解输入”和“生成输出”分成两个栈,输入只编码一次,解码时可以反复对它做交叉注意力,长输入短输出的任务(摘要、翻译)效率更高。纯解码器结构把两者压进同一个栈,用因果掩码保证只能看左边,训练目标统一成预测下一个 token,扩展性更好但计算上更浪费——输入部分也要走一遍完整的前向。选择哪种结构,本质是在“任务形态是否集中在生成”和“训练目标是否统一”之间做取舍。
6.2 用 n-gram 匹配衡量生成质量,边界在哪
翻译和摘要这类任务没法用准确率衡量,因为正确答案不止一个。主流做法是 BLEU:统计生成文本和参考译文在 1 到 4 元组上的匹配率,取几何平均,再乘一个简短惩罚项防止模型靠输出短句刷分。几何平均的作用是让四个阶数的匹配率必须同时高,任何一阶接近 0 都会把总分拉垮,这比算术平均严格得多。
问题在于,n 元组匹配对语义完全不敏感。把“猫追老鼠”改成“老鼠追猫”,词全都对上了,分数照样很高。再加上模型可能生成长度远短于参考的句子,匹配率的分母变小,分数虚高,所以还需要长度惩罚和平滑处理——当某个高阶 n 元组匹配数为 0 时,几何平均会直接变成 0,平滑就是给这些零项加一个很小的计数,避免整个分数塌陷。这套指标适合做版本间的相对比较,不适合当作绝对质量判断。
从零预训练、微调、评估这条链走下来,最深的体会是:所有让人头疼的问题都不在模型结构上,而在数据流经的每一处形状、每一处语义约定、每一处 id 映射。我现在的习惯是,任何一次训练启动前先跑一个最小可复现检查——用三条样本过一遍完整流程,打印分词结果、张量形状、掩码统计和损失值,确认这四样都对得上再开跑。这个检查花不到五分钟,省下的往往是半天。希望帮到你。
本文还有配套的精品资源,点击获取