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

发布时间:2026/10/24 13:59:15

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_idx0) 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, eps1e-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, deviceinput_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_idx0让 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_headshead_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, eps1e-12) self.norm2 nn.LayerNorm(config.hidden_size, eps1e-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_prob0.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_prob0.15是论文默认小语料上可以适当调高让模型多见监督信号。NSP 部分则是构造 50% 连续句对、50% 随机句对标签 0/1接一个二分类头。两个损失相加联合优化权重一般各取 1。4. 跑起来从零训练与加载权重两条路4.1 环境准备与数据格式先确认 PyTorch 能用到 GPUpython -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 卡上大概能到 32lr预训练一般 1e-4 量级配合 warmupwarmup_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(strictFalse)再手动核对缺失的键。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一遍字段齐全。希望这些能帮到你少走点我踩过的弯路。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/24 13:54:14

生成式AI监管转向:中国治理路径与产业影响解析

简介:南财智库发布的2023年《生成式人工智能发展与监管白皮书》,聚焦中国AI治理的独立思考,面向AI从业者、法律合规人员及关注数字治理的研究者。报告从“发展、风险、借鉴、实践”四个维度展开,先说明生成式AI的概念、算力基础、…

2026/10/24 13:54:14

text-to-CAD技术解析:从语义理解到制造就绪的工程化实现

1. 什么是text-to-CAD:从一句话指令到可制造模型的完整链路“text-to-CAD”这个词最近在工程设计、智能制造和教育圈里频繁出现,但它绝不是“用文字生成CAD图纸”这么一句轻飘飘的概括就能说清的事。我接触过几十个实际落地的项目,从某高校机…

2026/10/24 14:49:45

FreeSWITCH 呼入呼出路由配置实战:SIP中继与拨号计划详解

简介:这份文档面向使用 FreeSWITCH 搭建 VoIP 通信平台的运维与开发人员,聚焦呼入、呼出路由配置这一关键环节,帮助读者理清拨号计划与 SIP 中继的配合方式。内容围绕 FreeSWITCH 事件驱动架构展开,讲解如何通过 XML 拨号计划定义…

2026/10/24 14:49:45

DeepSeek提示词实战手册:7大场景+50案例+5类模板

简介:这是一份面向AI初学者与实践者的DeepSeek深度应用指南,专为自媒体创作者、商务人士、教师、学生及科研工作者设计,聚焦智能问答、内容生成、数据分析、图像协同与投资策略等高频场景,系统解决日常效率、育儿辅导、职场提效与…

2026/10/24 14:49:45

WiFi不是黑箱:零基础看懂AP、Station、SSID通信原理

简介:本资源是一份面向网络初学者与通信技术从业者的Wi-Fi基础知识系统性整理文档,聚焦无线局域网核心原理与工程实践基础,有效解决入门者对802.11协议体系、关键术语及有线/无线差异理解模糊的问题。文档为单文件Word格式(.doc&a…

2026/10/24 14:49:45

低代码平台六大引擎解析:元数据驱动的黑匣子真相

简介:本资源是头豹研究院发布的《2022年中国低代码无代码平台行业研究报告》,面向企业数字化转型决策者、IT架构师、低代码平台选型人员及技术管理者,系统解答低代码/无代码平台的定义边界、厂商分类、商业模式、部署路径与盈利困局等核心问题…

2026/10/24 14:49:45

SAP固定资产报废出售盘点的ABAVN+F-02合规凭证链

简介:本资源是一份面向SAP财务模块(FI)从业者与初学者的实务操作手册,聚焦固定资产报废、出售及盘点三大高频账务场景,解决企业在SAP系统中准确完成资产处置与账务结转的核心痛点。手册覆盖有收入报废(ABAV…

2026/10/24 14:44:45

DNS 记录查询接口接入教程:参数、请求示例与结构化返回解读

排查域名解析问题或做域名资产盘点时,经常需要查 A、MX、CAA 等各类 DNS 记录。DNS 记录查询接口支持 8 种标准类型和 ANY 模式,并把结果做结构化解析。本文从第一次调用讲起:要准备什么、最小请求怎么发、返回值怎么看。 一、适用场景 解析…

2026/10/23 22:56:35

金融AI落地指南:从调研报告到技术选型与工程实践

简介:这份PDF报告聚焦2025年金融服务业中国AI现状与趋势,面向金融科技从业者、AI战略规划人员及关注行业数字化转型的研究者,帮助读者把握生成式AI在银行、支付、交易等场景中的落地路径与投资方向。资源包内含1个PDF文件,大小约1…

2026/10/24 0:02:40

MATLAB缩进快捷键与批量格式化实战指南

1. 被自动缩进折磨的那些年如果你写过MATLAB代码,大概率经历过这种场景:从某个文档、网页或者聊天记录里复制了一段代码,粘贴进编辑器之后,每一行前面都带着莫名其妙的空格,有的行首是四个空格,有的是一个T…

2026/10/24 0:02:40

用Poco实现Qt风格信号与槽的跨平台C++方案

简介:本资源是一份面向C中级开发者的技术实践工程,聚焦于在非Qt框架环境下模拟实现Qt核心通信机制——信号与槽,借助Poco网络库的事件系统完成解耦式对象通信。适用于需在跨平台C项目中引入松耦合回调机制、但受限于无法集成Qt依赖的开发场景…

2026/10/24 0:02:40

ESXi裸机虚拟化安装指南:从硬件兼容到虚拟机部署

1. 为什么还要折腾ESXi:从一台闲置机器说起手里攒了几台淘汰下来的迷你主机,配置不算差,i5加16G内存,扔了可惜,装个桌面系统又觉得浪费。这种场景下,ESXi就成了一个绕不开的选择。它是一套裸机虚拟化平台&a…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑