资讯动态

Transformer NLP实战:BERT微调、RoBERTa预训练与工程避坑

发布时间:2026/10/30 5:08:30 来源:尧图企业网站定制
简介《Transformers for Natural Language Processing》是Packt于2021年推出的NLP进阶电子书面向已具备Python基础、希望系统掌握Transformer架构的开发者与算法学习者。全书按三个阶段展开先剖析自注意力机制与原始Transformer的训练方法说明小型模型在特定任务上可胜过GPT-3的缘由再过渡到BERT、RoBERTa、DistilBERT、GPT-2、T5等预训练模型在自然语言理解与生成任务中的应用最后延伸到社交网络数据优化、假新闻识别等高级场景并演示Hugging Face、Trax、AllenNLP平台以及TensorFlow、Keras在情感分析、文本摘要、语音识别、机器翻译中的实践同时讨论如何衡量关键模型的性能边界。压缩包内为1个PDF文件约4.72MB排版完整便于离线阅读与检索。目前已有1969人学习适合作为从原理到落地的系统参考。1. 从一份 300 页 PDF 说起它到底能帮你解决什么同事把一份三百多页的 PDF 甩给我说里面有一章讲 BERT 微调的完整代码让我把项目里的情感分类从 TF-IDF 换掉。翻开目录才发现这份《Transformers for Natural Language Processing》并不是只讲注意力公式的教材它从原始 Transformer 的编码器堆栈一路写到 BERT、RoBERTa、T5、GPT-2每一章都落到能跑的 Python 代码上连 WMT 机器翻译的 BLEU 平滑、SuperGLUE 的评测口径都写进去了。适合谁手上会点 PyTorch 或 TensorFlow、被具体 NLP 任务卡住、想直接拿预训练模型改自己数据集的开发者。不适合谁完全没写过训练循环、指望复制粘贴就出活的人。中间那些张量维度对齐、显存不够、分词器和数据集对不上的坑书里给了提示但真得自己踩一遍才知道疼。2. 三阶段路线与环境搭建从目录结构到本地能跑的训练环境这份资源的组织方式很清晰前四章打地基中间四章做任务最后四章攻高级理解。读之前先想清楚自己落在哪一段比从头翻到尾高效得多。2.1 十二个章节怎么分成三段第一段是架构认知含原始 Transformer 的编码器堆栈、输入嵌入、位置编码、多头注意力、前馈子层、解码器堆栈和 Post-LN 的顺序接着落到 BERT 的微调流程和从零预训练一个小 RoBERTa 的十五个步骤。这一段的目标不是背公式是让你能对着张量形状说清楚每一步在干什么。第二段是 NLU 与 NLG 的实战下游任务评测体系从 GLUE 到 SuperGLUE含 BoolQ、CB、MultiRC、ReCoRD、RTE、WiC、WSC 这些子任务、WMT 数据集预处理与 BLEU 评测、GPT-2 的文本补全与语言模型训练、T5 的文本摘要。这一段最像工程活数据清洗占的时间比模型多。第三段是高级语言理解分词器与数据集的匹配Word2Vec 分词的六种边界情况、基于 BERT 的语义角色标注、用 NER 和 SRL 反推问答对、情感分析模型横评RoBERTa-large、DistilBERT、MiniLM、mnli 版本、多语种版本、假新闻识别的分析路线图。这一段开始讲“怎么判断模型结论靠不靠谱”含金量比前面高。我的建议是如果你只想把预训练模型接到自己的业务上2、3、4 章加第 11 章的情感分析就够开工想做检索问答或者文档摘要再补第 7、9、10 章。2.2 环境两套后端共存别硬来书里前后用了 PyTorch 和 TensorFlow 两套后端还夹着 Trax 和 AllenNLP。最省事的做法不是装一个大杂烩环境而是按章节拆环境变量、拆虚拟环境。我一般会建一个主环境跑 PyTorch 部分另一个单独跑 Trax 那章的翻译实验因为 Trax 对 jax 版本很挑混装之后 torch 那边的 CUDA 又玄学失效排查起来很难受。# 1) 独立环境Python 3.9 是这套库兼容性比较稳的区间 conda create -n nlp-tf python3.9 -y conda activate nlp-tf # 2) 先看驱动支持到哪一档 CUDA再决定装哪个 torch 轮子 nvidia-smi # 右上角 CUDA Version 是上限不是你必须装的版本 # 3) torch 从官方 whl 索引装别直接 pip install torch 拿到 CPU 版 pip install torch torchvision # 4) 这三件套必须锁版本transformers / datasets / tokenizers 错配是报错重灾区 pip install transformers4.10,4.20 datasets2.0 tokenizers0.11 pip install scikit-learn pandas matplotlib装完先跑一段自检确认 CUDA 真的可用别等到训练脚本跑了两小时才发现全程在 CPU 上磨。import torch, transformers # 三个值要一起看torch 版本、CUDA 是否可用、torch 编译时的 CUDA 版本 print(torch:, torch.__version__) print(cuda available:, torch.cuda.is_available()) print(cuda build:, torch.version.cuda) print(transformers:, transformers.__version__) # 能打印出卡型号才算真通 if torch.cuda.is_available(): print(torch.cuda.get_device_name(0)) print(显存 GB:, torch.cuda.get_device_properties(0).total_memory / 1024**3)这里三个输出要一起看。torch.cuda.is_available()为 True 但torch.version.cuda是 None说明装的是 CPU 轮子两个都不为空但训练时报 CUDA error多半是驱动版本低于 torch 编译时的 CUDA 版本降 torch 比升级驱动省事。显存那行决定你后面 batch size 能开多大8G 卡上 BERT-base 微调 batch 开 8 就打满32G 卡上同样的脚本能开到 32 甚至更多。2.3 目录约定别让各章互相污染书里每个案例都是独立脚本直接堆在一个目录里跑到第三章就会出现vocab.json被覆盖、checkpoint 找不到的情况。我按章拆目录原始语料只读产物统一进 runs。# 一本书一个工作区按章隔离输入输出 nlp-transformers/ ├── ch02_bert_finetune/ # 微调 BERT数据脚本 训练脚本 ├── ch03_roberta_scratch/ # 从零训练分词器与 RoBERTa ├── ch05_translation/ # WMT 预处理 BLEU 评测 ├── ch07_t5_summary/ # T5 文本摘要 ├── data/ # 原始语料只读任何脚本都不许往里写 └── runs/ # checkpoint、日志、预测结果按时间戳分目录这么拆的好处是回滚成本低某一章的依赖炸了删掉那个目录重来即可不会牵连已经跑通的部分。原始语料只读这一条尤其重要书里好几个预处理脚本默认是原地覆盖的一旦跑错参数原始数据就被截断了补救代价很大。3. 核心模块拆解位置编码、多头注意力与残差连接的逐行实现第一章讲架构时给的是图和公式真正要动手得把这两块写成能跑通的nn.Module。这一章我把书里拆开讲的三个部件按代码重新组织一遍顺手补上几个书里没强调但会直接导致训练失败的细节。3.1 输入嵌入与位置编码为什么不能只靠词向量Transformer 本身是置换不变的同一句话打乱词序自注意力的输出集合是一样的。位置编码就是把顺序信息显式塞进去。原始论文用的是正弦余弦的固定编码好处是不需要训练、可以外推到比训练集更长的序列BERT 这类模型改成了可学习的位置嵌入简单但受max_position_embeddings硬限制。import math import torch import torch.nn as nn class PositionalEncoding(nn.Module): def __init__(self, d_model: int, max_len: int 5000, dropout: float 0.1): super().__init__() self.dropout nn.Dropout(dropout) # pe 形状 [max_len, d_model]偶数维用 sin奇数维用 cos pe torch.zeros(max_len, d_model) pos torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) # [max_len, 1] div torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(pos * div) pe[:, 1::2] torch.cos(pos * div) # register_buffer不算参数但会跟着 .to(device) 一起搬也不会进 optimizer self.register_buffer(pe, pe.unsqueeze(0)) # [1, max_len, d_model] def forward(self, x): # x: [batch, seq_len, d_model]直接相加不改变形状 x x self.pe[:, : x.size(1)] return self.dropout(x)register_buffer这行是重点。用普通属性存self.pe pe模型to(cuda)之后这个张量还在 CPU 上前向立刻报 device mismatch用nn.Parameter又会被优化器当成可训练参数白白多一份梯度。d_model必须是偶数否则0::2和1::2切出来的列数对不上直接抛 shape 异常。max_len要给足输入超过它就会静默截断到缓冲区长度序列尾部的词直接丢失顺序信息。3.2 多头注意力一次线性变换切出 Q/K/V多头注意力的本质是把d_model维空间切成 h 份每份独立算一次缩放点积注意力再拼回来过一层输出投影。书里给的实现是三个独立Linear我在实际项目里会合并成一个Linear(d_model, 3 * d_model)再chunk少两次 kernel launch小模型上能省 5% 左右的训练时间。class MultiHeadAttention(nn.Module): def __init__(self, d_model: int 512, n_heads: int 8, dropout: float 0.1): super().__init__() assert d_model % n_heads 0, d_model 必须能被 head 数整除 self.d_k d_model // n_heads self.n_heads n_heads self.w_qkv nn.Linear(d_model, d_model * 3) # 一次算出 Q、K、V self.w_o nn.Linear(d_model, d_model) # 多头拼接后的输出投影 self.dropout nn.Dropout(dropout) def forward(self, x, maskNone): b, s, _ x.shape q, k, v self.w_qkv(x).chunk(3, dim-1) def split_heads(t): # [b, s, h*d_k] - [b, h, s, d_k]把 head 维提到前面 return t.view(b, s, self.n_heads, self.d_k).transpose(1, 2) q, k, v split_heads(q), split_heads(k), split_heads(v) # 缩放点积除以 sqrt(d_k) 防止维度变大时点积方差爆炸 scores q k.transpose(-2, -1) / math.sqrt(self.d_k) # [b, h, s, s] if mask is not None: # mask 里 1 表示可见0 表示屏蔽用 -1e9 而不是 -inf避免 softmax 出 nan scores scores.masked_fill(mask 0, -1e9) attn self.dropout(torch.softmax(scores, dim-1)) out (attn v).transpose(1, 2).contiguous().view(b, s, -1) return self.w_o(out), attn几个容易翻车的参数mask的语义必须全局统一书里编码器用 0/1 的 padding mask解码器还要叠一个上三角的 causal mask两处语义搞反了模型照样能训只是效果差一截很难定位。-1e9这个常量比float(-inf)安全整行全被 mask 时 softmax 不会产生 NaN。transpose之后必须contiguous()再view不然 PyTorch 会直接报内存不连续的错误。3.3 残差、LayerNorm 与 Post-LN 的顺序问题子层之后的Add Norm顺序看着是小事实际决定了训练稳定性。原始论文用的 Post-LN先残差相加再过 LayerNorm后来的很多大模型改成了 Pre-LN先 LayerNorm 再进子层残差直连到底。两者的差别在深层网络上非常明显Post-LN 需要 learning rate warmup 才能训得动Pre-LN 可以省掉 warmup 但收敛点略有不同。class Sublayer(nn.Module): 一个可复用的子层Pre-LN 还是 Post-LN 由 post_ln 开关控制 def __init__(self, d_model, layer, dropout0.1, post_lnTrue): super().__init__() self.layer layer self.norm nn.LayerNorm(d_model) self.dropout nn.Dropout(dropout) self.post_ln post_ln def forward(self, x, *args, **kwargs): if self.post_ln: # 先残差再归一化需要 warmup但论文原始设定 return self.norm(x self.dropout(self.layer(x, *args, **kwargs))) # 先归一化再进子层深层更稳省掉 warmup 也能跑 return x self.dropout(self.layer(self.norm(x), *args, **kwargs))我一般默认用 Post-LN 复现书里的实验保证结果能和书中对照真正要堆到 12 层以上、又不想调 warmup 的时候切 Pre-LN。nn.LayerNorm的eps默认 1e-5BERT 用的是 1e-12做逐层对照实验时这个值要跟着改不然数值差异会被误判成实现错误。4. BERT 微调与 RoBERTa 从零预训练两条链路的对照这一章是全书最值得抄的两段代码。一段是拿预训练权重做下游微调另一段是从零训练分词器再预训练一个小模型。两段的输入输出完全不同但骨架高度相似准备张量、配优化器、写循环、评估、存盘。4.1 从原始句子到张量分词与 attention maskBERT 的输入不是一串 token而是三件套input_ids、attention_mask、labels。第一件是词表索引第二件告诉模型哪些位置是真实 token、哪些是补齐的 pad第三件是任务的监督信号。书里处理的是一个二分类数据集句子和标签分别存两个列表再统一转张量。import torch from transformers import BertTokenizer, BertForSequenceClassification tokenizer BertTokenizer.from_pretrained(bert-base-uncased) sentences [the plot was flat but the acting saved it, a complete waste of two hours] labels [1, 0] # padding 到固定长度超长截断return_tensorspt 直接给张量省一步转换 enc tokenizer(sentences, paddingmax_length, truncationTrue, max_length64, return_tensorspt) input_ids enc[input_ids] # [batch, 64] attention_mask enc[attention_mask] # 1真实 token0pad labels torch.tensor(labels) # 分类标签二分类用 0/1max_length要根据数据分布定。书里用的是短句数据集64 足够换成评论或合同条款64 会把大半句子截掉模型只看前半段当然学不到东西。判断方法很简单对全量数据跑一遍 tokenizer 的encode拿长度看 95 分位在哪取那个值向上取整到 8 的倍数兼顾显存和覆盖率。attention_mask交给模型之后pad 位置的注意力权重会被压掉这一列千万别自己手动造直接取 tokenizer 的输出。4.2 优化器分组与训练循环bias 不做 weight decay这一步是整段微调里最容易被抄漏的。BERT 微调的标准做法是分层设置 weight decay权重矩阵做衰减bias 和 LayerNorm 的参数不做。这样在中小数据集上泛化会稳一些尤其是样本量几百条的冷启动场景。from transformers import AdamW model BertForSequenceClassification.from_pretrained( bert-base-uncased, num_labels2) # 分组bias 和 LayerNorm 不衰减其余权重衰减 no_decay [bias, LayerNorm.weight] 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}, ] # 微调学习率通常在 2e-5 到 5e-5比从零训练小两个数量级 optimizer AdamW(optimizer_grouped_parameters, lr2e-5, eps1e-8)训练循环本身没什么特别的但有两行值得单独说梯度裁剪和model.zero_grad()的位置。梯度裁剪能救回那些因为单条异常样本导致 loss 尖峰、直接训崩的批次zero_grad放在反向传播之前还是之后都行但要在同一次迭代内保持固定混着写就会出现梯度累积。from torch.utils.data import TensorDataset, DataLoader, RandomSampler train_data TensorDataset(input_ids, attention_mask, labels) train_loader DataLoader(train_data, samplerRandomSampler(train_data), batch_size8) device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) for epoch in range(3): model.train() for batch in train_loader: batch [t.to(device) for t in batch] model.zero_grad() outputs model(input_idsbatch[0], attention_maskbatch[1], labelsbatch[2]) loss, logits outputs[0], outputs[1] loss.backward() # 裁剪到 1.0防止个别长句把梯度顶飞 torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step()batch_size8是 8G 显存上的保守值卡好的话开到 16 甚至 32 收敛更稳但如果同时把max_length拉到 256显存占用是平方级上升的先降 batch 再降长度。三个 epoch 对微调通常够再多容易过拟合尤其训练集只有几千条的时候。4.3 评估为什么书里主推 MCC 而不是准确率书里在第 2 章和第 4 章反复用 Matthews 相关系数MCC原因在类别不均衡的数据上非常直观。假设正样本只占 5%模型全预测为负准确率也有 95%看着很美实际一个正样本都没抓到MCC 会直接掉到 0 附近因为它是混淆矩阵四个格的综合指标正类和负类任何一边塌了都会被惩罚。from sklearn.metrics import matthews_corrcoef, accuracy_score, f1_score # preds 是 argmax 后的 0/1 列表labels 是真实标签 print(accuracy:, accuracy_score(labels, preds)) # 不均衡时容易虚高 print(f1:, f1_score(labels, preds, averagebinary)) # 只看正类 print(mcc:, matthews_corrcoef(labels, preds)) # 两边都看-1 到 1评估集要单独切一份 holdout别拿验证集反复调到最优再报那个分数。书里的流程是先按批次记录分数最后在全量 holdout 上算一次总分这个顺序值得照做批次分数波动大只看最后一个批次很容易误判收敛情况。4.4 从零预训练一个小 RoBERTa分词器先行第 3 章那十五步是全书最完整的一条流水线我按依赖关系重新排一遍核心就三段训分词器、配模型、扔进 Trainer。分词器必须先在目标语料上训练否则通用词表切你的领域文本会碎成单字符。这一步产出两个文件vocab.json和merges.txt。from tokenizers import ByteLevelBPETokenizer # 语料一行一句纯文本即可 tokenizer ByteLevelBPETokenizer() tokenizer.train(files[data/corpus.txt], vocab_size52000, # 小语料 3 万到 5 万足够太大反而稀疏 min_frequency2, special_tokens[s, pad, /s, unk, mask]) tokenizer.save_model(./roberta-small) # 落盘 vocab.json merges.txt词表大小是个权衡5 万多是 RoBERTa 的量级语料只有几 MB 时开这么大很多 token 只出现一两次嵌入层学不动我一般在百万字符级语料上开 3 万千万级再上 5 万。min_frequency设成 2 能过滤掉大量只出现一次的词减少无效词表。模型配置里vocab_size必须和分词器完全一致max_position_embeddings要比实际最大长度多留几个位置RoBERTa 系惯例是max_len 2少一个都会在训练中途抛索引越界。from transformers import (RobertaConfig, RobertaTokenizer, RobertaForMaskedLM, DataCollatorForLanguageModeling, Trainer, TrainingArguments) config RobertaConfig( vocab_size52000, # 必须等于分词器的词表大小 max_position_embeddings514, # 最大长度 512 2 个特殊位 num_attention_heads12, num_hidden_layers6, # 从零训练用小层数先跑通再加 type_vocab_size1, # RoBERTa 没有句子对任务就是 1 ) tokenizer RobertaTokenizer.from_pretrained(./roberta-small, max_len512) model RobertaForMaskedLM(configconfig) # MLM 的 collator 会自动做 15% 的随机遮盖不用自己写 collator DataCollatorForLanguageModeling(tokenizertokenizer, mlmTrue, mlm_probability0.15) args TrainingArguments( output_dir./runs/roberta-small, overwrite_output_dirTrue, num_train_epochs5, per_device_train_batch_size16, save_steps500, save_total_limit2, # 只留最近两个 checkpoint不然磁盘很快满 ) trainer Trainer(modelmodel, argsargs, data_collatorcollator, train_datasetdataset) trainer.train() trainer.save_model(./runs/roberta-small/final) # 权重 配置 分词器一起存训完用FillMaskPipeline验证是最快的手段扔一句带mask的话进去看补出来的词是否在语义上合理。如果补出来全是高频虚词说明训练轮数不够或者语料太单一跟代码 bug 关系不大。5. 避坑与排查显存、分词器、版本这五类翻车现场这一章全是真踩过的坑按“现象 → 原因 → 解决”写方便你直接拿去对照日志。5.1 训练到一半 CUDA out of memory现象脚本刚开始跑正常跑到第几十个 batch 突然 OOM重跑位置还不固定。原因多数是变长输入没有统一 padding某个批次恰好碰上一批长句显存峰值被顶穿也可能是评估阶段没加torch.no_grad()中间激活值全留着。解决数据侧统一paddingmax_length别用longest动态补齐评估和推理一律套with torch.no_grad():训练前先torch.cuda.empty_cache()但别指望它解决根本问题。真要压显存开梯度累积把等效 batch 拉上去比硬降 batch 温和。5.2 分词器加载成功但输出 id 全在词表外现象model(input_ids...)抛 index out of range或者补出来的 token 全是unk。原因分词器和模型权重不是同一套。从零训练的 RoBERTa 用自建分词器如果配置文件里的vocab_size和分词器的实际词表对不上或者加载时把路径指到了默认的通用分词器就会错位。解决训练前打印len(tokenizer)和config.vocab_size对比两个数必须相等save_pretrained时把 tokenizer 一起存进同一个目录加载时也从同一个目录取不要一个从本地取、一个从远端拉。5.3 装完就是跑不了报 CUDA driver version is insufficient现象torch.cuda.is_available()返回 True但一执行前向就崩报驱动版本不足。原因torch 编译时用的 CUDA 版本高于驱动支持的版本。nvidia-smi显示的是驱动上限torch.version.cuda显示的是 torch 自带的运行时两者不匹配就会在第一次分配显存时炸。解决优先降 torch 版本去匹配驱动而不是动驱动尤其在共用服务器上升级驱动会影响别人的环境。装之前先看nvidia-smi右上角那一行再决定装哪个索引下的 whl。5.4 loss 不降或者降到某个值就不动了现象前几十步 loss 从 2.3 掉到 0.8然后一条直线验证集准确率和瞎猜差不多。原因微调学习率开太大比如直接用了 1e-3预训练权重被冲掉或者标签编码错了labels里混进了 -1 或者字符串还有一种情况是数据里正负样本几乎全是一类模型学到的就是常数输出。解决微调学习率从 2e-5 起试配合 10% 的 warmup训练前labels.unique()打一下确认只有预期内的取值labels.float().mean()看下正样本比例低于 5% 就换 MCC 或 F1 做监控指标别盯 accuracy。5.5 保存的模型换台机器就加载不了现象本地save_pretrained之后拷到另一台机器from_pretrained报缺文件或配置不匹配。原因只存了pytorch_model.bin没存config.json和分词器文件或者存的时候用了自定义的 head 结构加载时用的是默认BertModel键名对不上。解决统一用trainer.save_model(dir)加tokenizer.save_pretrained(dir)一个目录里放全套自定义 head 的话加载时先用AutoConfig读出配置再用你自己的类实例化别指望from_pretrained猜出你的结构。加载报 missing keys 时先看是不是classifier.*前缀那通常是正常的新初始化的分类头unexpected keys 才要警惕。6. 二次开发把书里的示例改成自己的任务流水线照着书跑通只是第一步真正省时间的是把那些脚本压成一条能复用的流水线。我的做法是固定四层数据层、模型层、训练层、验证层换任务时只动前两层。层固定下来的东西换任务时要改的数据层读取、切分、张量化、DataLoader分词器、max_length、标签映射模型层AutoConfigAutoModelFor*模型名、num_labels、是否冻结底层训练层优化器分组、warmup、梯度裁剪、checkpoint 策略学习率、epoch、batch验证层指标函数、holdout 评估、错误样本导出指标MCC / F1 / BLEU / ROUGE具体改造时有两个技巧。第一个是先冻结再解冻拿到一个新数据集头一版只训分类头把主干requires_gradFalse几个 epoch 就能看出数据本身有没有问题避免一上来全量微调、跑两小时才发现标签是错的。第二个是把pipeline当回归测试用每次改完数据处理逻辑扔十条人工标注过的样本进去看输出是否还符合预期比盯着 loss 曲线直观得多。from transformers import pipeline # 换任务后先拿它做冒烟测试别急着上完整训练 clf pipeline(text-classification, model./runs/my-task/final, tokenizer./runs/my-task/final, device0) samples [这句话是正面的, 完全不行浪费钱] for s in samples: print(clf(s, truncationTrue, max_length128))这里truncationTrue一定要显式给pipeline默认不截断遇到超长输入会直接抛异常max_length要和训练时保持一致不然分词边界不同预测分布会漂。从那以后我每次接新数据集都强制先跑一遍“冻结主干 只训头”的基线再决定要不要放开全量微调。这个习惯帮我省掉的返工时间比多调几组超参加起来还多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑