PyTorch从零实现BERT:预训练与微调实战指南

发布时间:2026/10/24 13:58:25
PyTorch从零实现BERT:预训练与微调实战指南

简介:这份资源是 Google AI 于 2018 年提出的 BERT 模型的 PyTorch 实现,面向已具备 Transformer 与自然语言处理基础、希望从代码层面理解预训练语言模型的中高级学习者。项目以简洁注释还原 BERT 的核心结构,涵盖模型定义、数据集处理与训练器三大模块,可用于复现论文思路、搭建自己的预训练实验或迁移到下游 NLP 任务。压缩包共 33 个文件,以 27 个 Python 脚本为主体,另含 Makefile、配置文件、依赖清单与说明文档,整体约 28KB,体量轻巧、便于通读源码。目前已有 879 人学习下载,说明其在 BERT 入门与源码研读场景中具有一定参考价值。读者可借此理清自注意力编码器、语料预处理与训练流程的组织方式,并对照论文理解深度双向 Transformer 的实现细节,适合作为研读 BERT 源码的起点。

1. 从一份“能跑起来的 BERT”说起

2018 年 Google AI 放出 BERT 之后,NLP 圈几乎被重写了一遍。但真到动手时,很多人卡在第一步:论文看得懂,Transformer 结构也画得出来,可官方实现是 TensorFlow 的,想在自己熟悉的 PyTorch 里复现一遍,从 tokenizer 到预训练任务再到微调,处处是坑。这份 BERT-pytorch 项目就是干这件事的——用 PyTorch 从零实现 BERT 的完整链路,包括 WordPiece 分词、Masked LM 与 NSP 两个预训练任务、Transformer 编码器堆叠,以及下游分类任务的微调接口。它适合两类人:一类是想把 Transformer 内部结构吃透、愿意逐行读代码的 NLP 学习者;另一类是手里有语料、想自己跑一遍预训练或拿预训练权重做文本分类的工程开发者。下面我按“这项目怎么组织、怎么跑、怎么改”的顺序拆一遍。

2. 项目结构与技术选型:从目录看 BERT 的模块边界

2.1 目录布局与各模块职责

这类从零实现的 BERT 项目,目录通常不会太深,核心逻辑集中在几个文件里。我按常见组织方式梳理一遍,你拿到源码后可以对照着看:

BERT-pytorch/ ├── models/ │ ├── bert.py # BERT 主体:Embedding + Encoder 堆叠 + Pooler │ ├── transformer.py # 单层 Transformer Block:多头注意力 + FFN + 残差 │ ├── embedding.py # Token / Position / Segment 三种 Embedding 相加 │ └── attention.py # 缩放点积注意力与多头拆分 ├── dataset/ │ ├── vocab.py # 词表加载与 WordPiece 分词 │ └── pretrain_dataset.py# 预训练数据:生成 [MASK] 样本与 NSP 正负样本对 ├── tasks/ │ ├── pretrain.py # 预训练入口:MLM + NSP 联合损失 │ └── classify.py # 下游文本分类微调入口 ├── config.py # 超参配置:层数、头数、隐藏维度、词表大小 └── utils/ ├── optimizer.py # AdamW + 学习率 warmup 调度 └── checkpoint.py # 权重保存与加载

这个划分的好处是边界清晰:models/只管网络结构,dataset/只管数据怎么变成张量,tasks/是训练脚本,config.py集中所有超参。二次开发时你改结构只动models/,换数据只动dataset/,不会牵一发动全身。常见做法是把config.py写成一个 dataclass,字段名和论文里的符号对应,比如hidden_size、num_hidden_layers、num_attention_heads、intermediate_size,这样读代码时能直接和论文表格对上。

2.2 技术栈与依赖

技术栈很纯粹:PyTorch 做张量计算和自动求导,Python 标准库处理文件读写,分词部分要么自己实现 WordPiece,要么依赖tokenizers这类库。没有花哨的框架封装,这正是它的价值——每一层都能看到。依赖大致是:

pip install torch numpy tqdm # 如果分词用现成库 pip install tokenizers

我一般会额外装tensorboard看 loss 曲线,预训练这种长任务,光看终端输出很难判断模型是不是在学。注意 PyTorch 版本别太老,多头注意力的scaled_dot_product_attention在较新版本里才有,老版本得手写矩阵乘法,性能差一截。

2.3 配置对象:超参集中管理

BERT-base 的经典配置是 12 层、768 隐藏维度、12 个注意力头、词表 30522。项目里通常用一个配置类承载这些:

from dataclasses import dataclass @dataclass class BertConfig: vocab_size: int = 30522 # 词表大小,WordPiece 词表决定 hidden_size: int = 768 # 每层输出维度,也是 embedding 维度 num_hidden_layers: int = 12 # Transformer Block 堆叠层数 num_attention_heads: int = 12# 多头注意力的头数,需整除 hidden_size intermediate_size: int = 3072# FFN 中间层维度,通常是 hidden 的 4 倍 max_position_embeddings: int = 512 # 最大序列长度 hidden_dropout_prob: float = 0.1 attention_probs_dropout_prob: float = 0.1

参数说明:hidden_size必须能被num_attention_heads整除,否则拆头时维度对不上,这是新手最容易翻车的地方;intermediate_size取 4 倍是论文默认,小数据集上可以调小省显存;max_position_embeddings决定了位置编码表的大小,超过这个长度的输入会被截断。我一般会把这个配置存成 JSON 一起进 checkpoint,加载权重时先读配置再建模型,避免结构对不上。

3. 核心模块拆解:一个句子如何走完 BERT 的前向

3.1 Embedding 层:三种信息的叠加

BERT 的输入表示是 Token Embedding、Position Embedding、Segment Embedding 三者相加。Token 表示词本身,Position 表示位置(因为 Transformer 本身没有顺序感),Segment 区分句子 A 和句子 B(为 NSP 任务服务)。

import torch import torch.nn as nn class BertEmbedding(nn.Module): def __init__(self, config): super().__init__() # 三个独立的 embedding 表 self.token_emb = nn.Embedding(config.vocab_size, config.hidden_size, padding_idx=0) self.pos_emb = nn.Embedding(config.max_position_embeddings, config.hidden_size) self.seg_emb = nn.Embedding(2, config.hidden_size) # 只有 A/B 两种 self.layer_norm = nn.LayerNorm(config.hidden_size, eps=1e-12) self.dropout = nn.Dropout(config.hidden_dropout_prob) def forward(self, input_ids, segment_ids): seq_len = input_ids.size(1) # 位置 id 从 0 到 seq_len-1,每次前向动态生成 position_ids = torch.arange(seq_len, device=input_ids.device).unsqueeze(0) x = self.token_emb(input_ids) + self.pos_emb(position_ids) + self.seg_emb(segment_ids) return self.dropout(self.layer_norm(x))

逻辑说明:三个 embedding 维度都是hidden_size,直接逐元素相加;相加后过 LayerNorm 再 dropout,这是论文里的顺序。padding_idx=0让 padding 位置的 token embedding 恒为零且不更新梯度,省得 padding 干扰注意力。参数上要注意segment_ids的取值范围只能是 0 或 1,构造数据时别越界,否则nn.Embedding会直接报索引错误。

3.2 多头注意力:拆头、缩放、拼接

多头注意力是 BERT 的计算核心。把hidden_size拆成num_heads份,每份独立算注意力,最后拼回来。缩放因子是1/sqrt(head_dim),防止点积过大导致 softmax 梯度消失。

class MultiHeadAttention(nn.Module): def __init__(self, config): super().__init__() assert config.hidden_size % config.num_attention_heads == 0 self.num_heads = config.num_attention_heads self.head_dim = config.hidden_size // config.num_attention_heads # Q/K/V 三个线性投影,一次算完再拆头 self.qkv = nn.Linear(config.hidden_size, config.hidden_size * 3) self.out = nn.Linear(config.hidden_size, config.hidden_size) self.dropout = nn.Dropout(config.attention_probs_dropout_prob) def forward(self, x, attention_mask): B, L, _ = x.shape qkv = self.qkv(x).reshape(B, L, 3, self.num_heads, self.head_dim) q, k, v = qkv.permute(2, 0, 3, 1, 4) # [3, B, heads, L, head_dim] # 缩放点积注意力 scores = torch.matmul(q, k.transpose(-1, -2)) / (self.head_dim ** 0.5) # mask 掉 padding 位置,用极小值填充 scores = scores.masked_fill(attention_mask == 0, -1e9) probs = self.dropout(torch.softmax(scores, dim=-1)) ctx = torch.matmul(probs, v) # [B, heads, L, head_dim] ctx = ctx.transpose(1, 2).reshape(B, L, -1) # 拼回 hidden_size return self.out(ctx)

逻辑说明:qkv一次线性变换出三份,比三个独立 Linear 少一次 kernel 调用;permute把 heads 维度提到前面,方便批量矩阵乘;masked_fill用-1e9而不是-inf,避免 softmax 出现 NaN,这是血泪经验。参数上attention_mask的形状要和scores的[B, heads, L, L]广播兼容,通常传[B, 1, 1, L]或[B, L]再 unsqueeze。改头数时只动num_attention_heads,head_dim自动跟着变,但记得hidden_size得整除。

3.3 Transformer Block 与整体堆叠

单层 Block 是“注意力 + 残差 + LayerNorm + FFN + 残差 + LayerNorm”的标准结构。BERT 用的是 Post-LN(先残差后归一化),这点和后来的 GPT 系列 Pre-LN 不同,训练时对 warmup 更敏感。

class TransformerBlock(nn.Module): def __init__(self, config): super().__init__() self.attn = MultiHeadAttention(config) self.norm1 = nn.LayerNorm(config.hidden_size, eps=1e-12) self.norm2 = nn.LayerNorm(config.hidden_size, eps=1e-12) self.ffn = nn.Sequential( nn.Linear(config.hidden_size, config.intermediate_size), nn.GELU(), # BERT 用 GELU 而非 ReLU nn.Linear(config.intermediate_size, config.hidden_size), ) self.dropout = nn.Dropout(config.hidden_dropout_prob) def forward(self, x, attention_mask): x = self.norm1(x + self.dropout(self.attn(x, attention_mask))) x = self.norm2(x + self.dropout(self.ffn(x))) return x

逻辑说明:残差连接保证深层梯度能回传,LayerNorm 稳定分布。GELU 是 BERT 的默认激活,换成 ReLU 也能跑但和预训练权重不匹配。整个 BERT 就是把TransformerBlock堆num_hidden_layers层,最后接一个 Pooler(取[CLS]位置过一层 tanh 线性变换)供分类任务用。堆叠时我一般用nn.ModuleList而不是nn.Sequential,方便以后做层间分析或梯度检查点。

3.4 预训练任务:MLM 与 NSP 的损失构造

预训练有两个目标:Masked LM 随机遮词让模型预测,Next Sentence Prediction 判断两句是否连续。数据构造是关键,80% 换[MASK]、10% 换随机词、10% 保持原词的经典比例要写对。

def create_mlm_labels(input_ids, vocab_size, mask_id, pad_id, mask_prob=0.15): labels = input_ids.clone() prob = torch.full(input_ids.shape, mask_prob) prob[input_ids == pad_id] = 0.0 # padding 不参与预测 masked = torch.bernoulli(prob).bool() labels[~masked] = -100 # -100 是 CrossEntropy 的忽略索引 rand = torch.rand(input_ids.shape) # 80% 换 [MASK],10% 换随机词,10% 不变 input_ids[masked & (rand < 0.8)] = mask_id random_words = torch.randint(vocab_size, input_ids.shape) input_ids[masked & (rand >= 0.8) & (rand < 0.9)] = random_words[masked & (rand >= 0.8) & (rand < 0.9)] return input_ids, labels

逻辑说明:labels只在被遮位置有真实值,其余设-100让损失函数忽略;-100是 PyTorchCrossEntropyLoss的默认ignore_index,别改成别的值。参数mask_prob=0.15是论文默认,小语料上可以适当调高让模型多见监督信号。NSP 部分则是构造 50% 连续句对、50% 随机句对,标签 0/1,接一个二分类头。两个损失相加联合优化,权重一般各取 1。

4. 跑起来:从零训练与加载权重两条路

4.1 环境准备与数据格式

先确认 PyTorch 能用到 GPU:

python -c "import torch; print(torch.__version__, torch.cuda.is_available())"

预训练数据一般是纯文本,一行一句或一段。项目里的dataset/pretrain_dataset.py会负责分词、截断到max_position_embeddings、构造句对。我一般先把语料清洗成每行一句,去掉空行和超长行,再跑分词。词表要么用项目自带的 WordPiece 词表,要么用自己的语料训一个——后者更贴合领域,但词表大小别超过配置里的vocab_size,否则 embedding 索引越界。

4.2 预训练启动与关键参数

启动脚本通常长这样:

python -m tasks.pretrain \ --data_path ./corpus.txt \ --vocab_path ./vocab.txt \ --batch_size 32 \ --lr 1e-4 \ --epochs 10 \ --warmup_steps 10000 \ --save_dir ./checkpoints

参数说明:batch_size受显存限制,BERT-base 在 16G 卡上大概能到 32;lr预训练一般 1e-4 量级,配合 warmup;warmup_steps是学习率从 0 线性升到峰值再衰减的步数,Post-LN 结构对 warmup 很敏感,省了它 loss 容易炸。训练时盯两个数:MLM 的 loss 应该稳步下降,NSP 的 accuracy 应该从 0.5 往上走,如果 NSP 一直卡在 0.5,多半是负样本构造有问题。

4.3 下游分类微调

有了预训练权重,分类任务只需在[CLS]上接一个线性层:

class BertForClassification(nn.Module): def __init__(self, bert, num_labels): super().__init__() self.bert = bert self.classifier = nn.Linear(bert.config.hidden_size, num_labels) def forward(self, input_ids, segment_ids, attention_mask): # 取 [CLS] 位置(索引 0)的池化输出 pooled = self.bert(input_ids, segment_ids, attention_mask)[:, 0] return self.classifier(pooled)

微调时学习率要比预训练小一个量级,常见 2e-5 到 5e-5,训练轮数 3 到 5 轮就够,多了容易过拟合小数据集。加载权重时注意键名匹配,预训练模型和分类模型的 BERT 部分参数名要一致,不一致就用load_state_dict(strict=False)再手动核对缺失的键。

5. 避坑与排查:预训练路上最容易翻车的几处

5.1 loss 变成 NaN 或突然爆炸

现象:训练几百步后 loss 变nan,或者突然从 2 点几跳到几十。原因通常是学习率过高、没做 warmup,或者注意力 mask 用了-inf导致 softmax 出 NaN。解决:把warmup_steps加上,峰值学习率降到 5e-5 试;mask 填充值统一用-1e9;再加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)。我一般训练前几百步会打印梯度范数,异常增大就是前兆。

5.2 显存不够,batch 上不去

现象:CUDA out of memory,调小 batch 后训练又太慢。原因:BERT-base 参数量约 1.1 亿,加上优化器状态和激活值,显存吃得很凶。解决:开混合精度torch.cuda.amp,激活值显存能省近一半;或者用梯度累积,小 batch 多步累加等效大 batch;再不行就减层数或隐藏维度做个小号 BERT 先验证流程。

5.3 分词结果和预期对不上

现象:中文语料分词后全是[UNK],或者词表里查不到常见词。原因:WordPiece 词表是按特定语料训的,直接拿英文词表处理中文必然大面积未知。解决:用自己的语料重新训词表,或者换字符级分词;同时检查vocab_size配置和实际词表行数是否一致,不一致会导致 embedding 越界或浪费。

5.4 微调后效果不如直接训一个小模型

现象:拿预训练权重微调分类,准确率还不如从头训的 TextCNN。原因:预训练语料和下游任务领域差太远,或者预训练本身没训够(loss 还没降下来就停了)。解决:确认预训练 loss 已收敛再拿去微调;领域差异大时,先在目标领域语料上继续预训练一段再微调;小数据集上冻结底层、只训顶层几层也能缓解过拟合。

6. 二次开发:改结构、换任务、验证改动的具体手法

拿到这份代码后,最常见的改造方向有三个:换下游任务、改模型规模、接自己的数据管道。换任务最直接——分类头换成序列标注头(每个 token 接一个线性层输出标签),或者换成句对匹配(两个[CLS]拼接再过分类层)。改规模就是动config.py里的层数和维度,做个小号 BERT 在单卡上快速验证想法,验证通了再放大。接数据管道则是重写dataset/里的__getitem__,保证返回的input_ids、segment_ids、attention_mask三个张量形状对齐。

改完怎么验证没改坏?我的习惯是写一个极小的过拟合测试:拿 8 条样本,关掉 dropout,训 200 步,看 loss 能不能降到接近 0。能过拟合说明前向反向链路是通的,再去跑全量数据。这个测试帮我省过很多次“训练半天发现 mask 传错维度”的时间。

还有一个具体技巧:改注意力或 FFN 结构时,先保持参数量不变做对照实验。比如把 GELU 换 ReLU,其他全不动,跑同样的步数比 loss 曲线,这样能判断改动本身的影响,而不是被参数量变化干扰。我一般会在config.py里加个variant字段,不同变体存不同 checkpoint 目录,方便回溯。

最后说个教训。早期我做预训练时图省事,checkpoint 只存模型权重不存配置和优化器状态,结果中断后想续训,学习率调度器状态丢了,续上的曲线和原来完全对不上,白跑两天。从那以后我每次存 checkpoint 都强制把 config、optimizer state、scheduler state、当前 step 一起打包,续训前先assert一遍字段齐全。希望这些能帮到你,少走点我踩过的弯路。

本文还有配套的精品资源,点击获取