BERT PyTorch 复现实战:从预训练到微调的完整指南
简介:面向自然语言处理学习者与 PyTorch 实践者的 BERT 复现资源,对应 Google AI 2018 年提出的深度双向 Transformer 预训练模型,帮助读者在本地理解并跑通语言模型训练流程,适合具备一定深度学习基础、希望从代码层面吃透 BERT 结构的中级开发者。压缩包共 33 个文件,以 27 个 Python 脚本为主体,覆盖模型、数据集与训练器等模块,另含 Makefile、requirements.txt、LICENSE、README 及 CI 配置等辅助文件,整体约 28KB,体量轻便、便于通读。已有 879 人学习下载,说明其在入门复现场景中具备一定参考价值。代码附带简单注释,结构清晰,读者可据此梳理 Transformer 编码器、预训练语料组织与训练入口的实现脉络,并借助安装与快速开始说明完成环境搭建和语料准备,为后续迁移到具体 NLP 任务打下基础。
1. 从一份 BERT 的 PyTorch 复现说起:它到底能解决什么问题
如果你手头有一批中文工单、合同条款或者客服对话,想做一个能真正跑起来的语义分类或实体抽取模型,大概率绕不开 BERT 这个底座。而当你打开官方仓库,看到的往往是 TensorFlow 时代的 checkpoint 转换脚本和一堆依赖版本说明,直接拿来在 PyTorch 里训练并不顺手。这份 BERT-pytorch 实现就是冲着这个落差来的:它把 Google 2018 年那版 BERT 的预训练与微调流程,用 PyTorch 重新组织成一套可读、可改、可复现的代码,覆盖了从词表构建、数据管道、Transformer 编码器堆叠,到 MLM/NSP 双任务预训练和下游分类微调的完整链路。适合谁?适合已经懂一点 Transformer、但被官方实现劝退过的 NLP 工程师,也适合想拿一份干净代码去改结构、做消融实验的研究型开发者。它不是开箱即用的 SaaS,而是一份能让你看清每个张量怎么流动的工程底稿。
2. 拆开代码包:Transformer 编码器与预训练任务的落地结构
2.1 目录与模块划分:先认清哪块负责什么
拿到这类复现包,第一件事不是急着跑训练,而是把模块边界摸清楚。常见做法是按职责切成四层:数据层(词表、Dataset、动态 padding 的 collate)、模型层(Embedding、多头注意力、前馈网络、编码器堆叠)、任务头(MLM 头、NSP 头、下游分类头)、训练层(优化器、学习率 warmup、checkpoint 存取)。这份实现基本遵循这个划分,模型主体集中在一个model目录下,预训练入口和微调入口分开,配置文件用 JSON 或 YAML 描述超参。这样切的好处是,你想换掉 NSP 任务、只保留 MLM,改动面被限制在任务头和数据管道两处,不用动编码器。
我一般会先看三个文件:定义BertModel的那个、定义BertForPreTraining的那个、以及train.py里构造 optimizer 和 scheduler 的那段。前两个决定模型能不能对上论文,第三个决定训练会不会在 warmup 阶段就发散。下面是一个典型的编码器层结构,参数名按常见实现习惯写,你对照自己包里的命名微调即可:
import torch import torch.nn as nn class TransformerBlock(nn.Module): def __init__(self, hidden=768, heads=12, ff_dim=3072, dropout=0.1): super().__init__() # 多头自注意力:hidden 必须能被 heads 整除 self.attn = nn.MultiheadAttention(hidden, heads, dropout=dropout, batch_first=True) self.norm1 = nn.LayerNorm(hidden) self.norm2 = nn.LayerNorm(hidden) # 前馈网络:中间维度通常是 hidden 的 4 倍 self.ff = nn.Sequential( nn.Linear(hidden, ff_dim), nn.GELU(), # BERT 原文用 GELU,不是 ReLU nn.Linear(ff_dim, hidden), ) self.dropout = nn.Dropout(dropout) def forward(self, x, attn_mask=None): # Pre-LN 还是 Post-LN 要和权重来源保持一致,这里演示 Post-LN attn_out, _ = self.attn(x, x, x, attn_mask=attn_mask, need_weights=False) x = self.norm1(x + self.dropout(attn_out)) ff_out = self.ff(x) x = self.norm2(x + self.dropout(ff_out)) return x这段代码的关键不在写法,而在三个参数:hidden决定整个模型的容量,base 版是 768,large 版是 1024;heads是注意力头数,base 版 12 头、每头 64 维;ff_dim是前馈中间层,固定为 hidden 的 4 倍。GELU这个激活函数是 BERT 和早期 Transformer 用 ReLU 的一个明显区别,如果你把它换成 ReLU,加载预训练权重后效果会掉,这是血泪经验。另外batch_first=True这个参数要留意,PyTorch 原生MultiheadAttention默认是序列维在前,很多复现包在这里做过转置处理,混用会导致维度对不上。
2.2 词表与数据管道:WordPiece 和动态 padding 怎么接
BERT 用的是 WordPiece 分词,词表大小中文常见 21128,英文 base 是 30522。这份实现通常自带一个vocab.txt或者一个构建脚本,你需要确认词表里的特殊 token 顺序:[PAD]、[UNK]、[CLS]、[SEP]、[MASK]的 id 必须和模型 Embedding 的初始化对齐,否则预训练阶段 mask 的位置全是错的。数据管道这块,预训练需要把一段文本切成句子对,按 0.5 的概率决定第二个句子是不是真实的下一句,这就是 NSP 标签的来源。
from torch.utils.data import Dataset import random class PretrainDataset(Dataset): def __init__(self, docs, tokenizer, max_len=128): self.docs = docs self.tok = tokenizer self.max_len = max_len def __len__(self): return len(self.docs) def __getitem__(self, idx): doc = self.docs[idx] # 简化:按句号切句,真实场景用分句器 sents = [s for s in doc.split("。") if s.strip()] if len(sents) < 2: return self.__getitem__((idx + 1) % len(self.docs)) i = random.randint(0, len(sents) - 2) sent_a = sents[i] # 50% 概率取真实下一句,50% 随机取一句 if random.random() < 0.5: sent_b, is_next = sents[i + 1], 1 else: sent_b, is_next = random.choice(sents), 0 enc = self.tok(sent_a, sent_b, max_length=self.max_len, truncation=True, padding="max_length", return_tensors=None) input_ids = enc["input_ids"] # MLM:15% 的 token 被选中,其中 80% 换 [MASK],10% 换随机词,10% 不变 labels = [-100] * len(input_ids) for pos, tid in enumerate(input_ids): if tid in (self.tok.cls_token_id, self.tok.sep_token_id, self.tok.pad_token_id): continue if random.random() < 0.15: labels[pos] = tid r = random.random() if r < 0.8: input_ids[pos] = self.tok.mask_token_id elif r < 0.9: input_ids[pos] = random.randint(0, self.tok.vocab_size - 1) return {"input_ids": input_ids, "labels": labels, "next_label": is_next}这段逻辑里有两个容易翻车的点。第一,labels用-100填充,是因为 PyTorch 的CrossEntropyLoss默认ignore_index=-100,如果你用别的值,loss 会被大量无效位置拉偏。第二,MLM 的 80/10/10 替换策略必须严格按论文来,很多复现为了省事直接全换[MASK],预训练出来的模型在微调时会有明显的预训练-微调不一致问题,因为微调阶段根本没有[MASK]。动态 padding 这块,训练时按 batch 内最长序列 padding 能省显存,但要注意 attention mask 同步生成,否则 padding 位置会参与注意力计算。
2.3 预训练与微调的衔接:权重怎么迁过去
预训练跑完,你拿到的是一个带 MLM 头和 NSP 头的模型。做下游分类时,标准做法是丢掉这两个任务头,保留编码器,接一个Linear(hidden, num_labels),用[CLS]位置的输出做分类。这里有个实操细节:[CLS]的输出在预训练阶段是被 NSP 任务训练过的,所以它天然带一点句子级语义,这也是 BERT 用[CLS]做分类的合理性来源。迁移时不要重新初始化编码器,只初始化分类头,学习率上编码器用小学习率(常见 2e-5 到 5e-5),分类头可以用大一点(1e-3 量级),这就是常说的分层学习率。
from transformers import BertForSequenceClassification, AdamW, get_linear_schedule_with_warmup model = BertForSequenceClassification.from_pretrained("./pretrained_ckpt", num_labels=8) # 分层学习率:编码器小、分类头大 no_decay = ["bias", "LayerNorm.weight"] optimizer_grouped = [ {"params": [p for n, p in model.bert.named_parameters() if not any(nd in n for nd in no_decay)], "lr": 3e-5, "weight_decay": 0.01}, {"params": [p for n, p in model.bert.named_parameters() if any(nd in n for nd in no_decay)], "lr": 3e-5, "weight_decay": 0.0}, {"params": model.classifier.parameters(), "lr": 1e-3, "weight_decay": 0.01}, ] optimizer = AdamW(optimizer_grouped) scheduler = get_linear_schedule_with_warmup(optimizer, num_warmup_steps=100, num_training_steps=2000)weight_decay对 bias 和 LayerNorm 参数置零是常规操作,因为这些参数做衰减没有意义还会拖慢收敛。warmup 步数一般取总步数的 10% 左右,太少容易在初期震荡,太多则浪费训练预算。如果你是从头预训练而不是加载现成权重,那 warmup 要更保守,常见做法是前 10000 步线性升温。
3. 训练参数怎么设:从 batch size 到学习率的取舍
3.1 显存与 batch size 的换算关系
BERT base 在 fp32 下,序列长度 128、batch size 32 大概要 10GB 出头显存;序列长度 512 时,batch size 8 就可能逼近 16GB。这不是玄学,是注意力矩阵的平方复杂度决定的。实操里我一般先固定序列长度,从 batch size 8 往上试,OOM 就减半,同时用梯度累积把等效 batch size 补回来。比如目标等效 batch 是 64,实际只能跑 8,那就累积 8 步再optimizer.step()。注意梯度累积时 loss 要除以累积步数,否则梯度会被放大。
accum_steps = 8 for step, batch in enumerate(loader): outputs = model(**batch) loss = outputs.loss / accum_steps # 关键:归一化 loss.backward() if (step + 1) % accum_steps == 0: torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad()clip_grad_norm_这个梯度裁剪在 BERT 训练里几乎是标配,阈值 1.0 是论文里的常用值。不加的话,预训练阶段偶尔会出现 loss 突然飙到 nan,尤其是学习率偏大的时候。
3.2 学习率、序列长度与训练轮次的组合
微调阶段,序列长度 128 的分类任务,3 到 5 个 epoch 通常就收敛了,再多容易过拟合,尤其样本量小于一万的时候。学习率 2e-5 到 5e-5 是安全区间,超过 1e-4 很容易把预训练权重冲坏。预训练阶段则完全不同,学习率常见 1e-4 量级,配合 warmup 和线性衰减,训练步数动辄几十万步,个人设备基本跑不动全量,所以这份实现更现实的价值是让你在小语料上做继续预训练(continue pretraining),而不是从零训一个 base。
| 场景 | 序列长度 | batch size | 学习率 | epoch/步数 |
|---|---|---|---|---|
| 下游分类微调 | 128 | 32 | 3e-5 | 3~5 epoch |
| 下游分类微调(长文本) | 512 | 8 | 2e-5 | 3~4 epoch |
| 小语料继续预训练 | 128 | 16 | 5e-5 | 1~2 万步 |
| 从零预训练(参考) | 128 | 256 | 1e-4 | 数十万步 |
这张表里的数字不是死的,但量级关系要守住:序列越长,学习率和 batch 都要往下压;从零预训练和微调是两个完全不同的预算级别,别拿微调的经验去套预训练。
3.3 评估指标与早停策略
分类任务看 accuracy 和 macro-F1,样本不均衡时以 macro-F1 为准。预训练阶段看 MLM 的 loss 和 mask 位置的预测准确率,NSP 看二分类准确率,但这两个指标只能说明模型在学,不能说明它学得好,真正的验证还是下游任务。早停我一般按验证集 F1 连续 2 个 epoch 不涨就停,同时保存 F1 最高的那个 checkpoint,而不是最后一个。这份实现里如果 checkpoint 保存逻辑只存 last,建议自己补一个 best 保存,不然训练完回头找最优权重会很痛苦。
4. 避坑与排查:五个真实会卡住你的问题
4.1 加载权重后 loss 不降,甚至比随机初始化还差
现象是微调第一个 epoch 的 loss 在 2.3 附近横盘,准确率贴着随机猜。原因多半是权重加载时参数名对不上,load_state_dict默认strict=True会报错,但有些人为了跑通改成strict=False,结果编码器一半参数是随机初始化的,表面不报错,实际模型是残的。解决办法是先打印model.state_dict().keys()和 checkpoint 的 keys 做集合差,确认缺失和多余的参数名,再决定是改映射还是改模型定义。另一个可能是词表 id 错位,[CLS]的 id 在词表里是 101,如果你的 tokenizer 配置里写成了别的值,[CLS]位置拿到的就是普通词向量。
4.2 训练到一半 loss 变 nan
现象是前几百步正常,突然 loss 变成 nan 且不再恢复。原因通常是学习率过大导致梯度爆炸,或者 fp16 混合精度下 loss scale 溢出。解决顺序是:先加梯度裁剪(阈值 1.0),再把学习率降一半,如果用了 fp16 就检查GradScaler是否正常unscale_后再裁剪。还有一个隐蔽原因是输入里有全 padding 的样本,attention 全被 mask 掉后 softmax 出现 0/0,排查时在 collate 里断言每个样本至少有一个有效 token。
4.3 显存够但训练速度异常慢
现象是 GPU 利用率长期低于 30%,一个 epoch 跑得比预期慢好几倍。原因常见于数据加载成了瓶颈:DataLoader的num_workers设成 0,所有分词和 padding 都在主进程做,GPU 一直在等数据。解决办法是把num_workers设成 4 到 8,pin_memory=True,并且把能提前做的分词尽量放到 Dataset 初始化阶段做缓存,而不是__getitem__里每次重算。另外动态 padding 如果实现成每个样本单独 padding 到 max_len,也会白白浪费算力。
4.4 微调效果不如直接拿现成预训练模型
现象是自己预训练或继续预训练的模型,在下游任务上反而不如直接加载公开权重的效果好。原因往往是小语料继续预训练把原有知识冲掉了,也就是灾难性遗忘。解决办法是降低继续预训练的学习率(比如 1e-5),减少步数,或者在 loss 里混入一部分原始语料的 MLM 任务做正则。还有一种情况是预训练语料和下游领域差太远,这时候继续预训练的意义本来就不大,不如直接微调。
4.5 多卡训练后单卡推理结果不一致
现象是单卡训练正常,换成多卡 DDP 后,保存的权重在单卡加载时输出对不上。原因是 BatchNorm 类层在多卡下的统计量同步问题,不过 BERT 用的是 LayerNorm,理论上不受影响,真正的问题多半出在保存时只存了model.module.state_dict()而加载时按单卡结构读,key 前缀多了module.。解决办法是保存时统一剥掉前缀,或者加载时用strict=False并核对缺失项。这个坑不常遇到,但一旦遇到很难从 loss 曲线上看出来。
5. 进阶用法:用 mask 预测做领域适配的验证
预训练模型到底有没有学到你领域的东西,光看下游 F1 不够直观。我习惯用一个小的验证技巧:拿几十条领域句子,随机 mask 掉关键实体词,让模型做 MLM 预测,看 top-5 里有没有合理的候选。这个方法能快速判断继续预训练是否有效,比等下游任务跑完再回头排查省时间。
import torch def probe_mlm(model, tokenizer, text, topk=5): enc = tokenizer(text, return_tensors="pt") ids = enc["input_ids"] # 手动把某个位置换成 [MASK],这里演示取第 3 个 token pos = 3 masked = ids.clone() masked[0, pos] = tokenizer.mask_token_id with torch.no_grad(): logits = model(masked).logits probs = torch.softmax(logits[0, pos], dim=-1) top = torch.topk(probs, topk) return [tokenizer.decode([i]) for i in top.indices] # 用法:对比继续预训练前后的预测合理性 print(probe_mlm(model, tokenizer, "本合同自双方签字之日起生效"))这段探测代码的价值在于,它把「模型懂不懂领域词」变成一个可观察的输出。如果继续预训练后,被 mask 的领域词能稳定进入 top-5,说明词表和语义空间确实适配了;如果预测全是高频通用词,那多半是训练步数不够或者学习率把原知识冲没了。参数上topk取 5 到 10 比较合适,太少看不出分布,太多噪声大。
还有一个值得试的进阶方向是冻结层数实验:把编码器底部 6 层冻结,只训顶部 6 层加分类头,在小样本场景下往往比全量微调更稳,过拟合更轻。冻结的实现就是在 optimizer 构造时排除那些层的参数,或者训练前把requires_grad置 False。这个技巧在标注数据只有几千条时特别管用,我一般会把它和全量微调各跑一遍做对比,选验证集更稳的那个。
从那以后我每次拿到一份预训练复现包,都会先跑一遍 MLM 探测再决定要不要继续预训练,而不是上来就堆步数。这个习惯帮我省掉过好几次白跑一整天的训练。希望帮到你。
本文还有配套的精品资源,点击获取