简介:这套基于 pytorch-transformers 实现的 BERT 中文文本分类代码,面向具备 Python 与深度学习基础的 NLP 学习者,也适合想开展中文短文本分类实验的研究者。作者以 THUCNews 新闻标题为数据源,涵盖财经、房产、股票、教育、科技、社会、时政、体育、游戏、娱乐 10 个类别,每类 2 万条共计 20 万条,并按训练 18 万、验证 1 万、测试 1 万的比例划分,可直接用于模型训练与效果评估。资源包共 29 个文件,包含文本预处理、训练配置、模型推理等 Python 脚本,预训练权重、词表、配置 json,以及结果可视化 png 和说明文档;压缩包整体约 732.57MB,目录结构清晰,便于按模块复现。目前已有 3700 人学习下载。通过该代码,读者能完整了解 BERT 在中文短文本分类上的数据准备、训练与验证流程,并可基于自带数据和预训练权重快速跑通实验、替换数据或调整模型开展进一步研究。
1. BERT 中文文本分类:这份代码能直接跑通的最小可用实现
做文本分类的同行应该都有这种经历:手里攒了一批中文短文本,想从关键词匹配升级成模型分类,打开几个项目一看,要么是老版本的 TensorFlow 代码,要么是包装得过于复杂的训练框架,改起来比从头写还费劲。这份基于 pytorch-transformers 的 BERT 中文文本分类代码,我拆完之后最大的感受是:它把「加载预训练模型、构造数据集、训练、保存、推理」这条链路压缩到了最短,属于真正能落地复现的代码包。
它适合两类人:一类是刚接触预训练模型、想用最小成本跑通 BERT 微调流程的新手,另一类是已经在业务里用词向量或传统机器学习模型做分类、想换 BERT 提升效果但不想重写整套训练逻辑的工程师。这套代码几乎不依赖自定义的复杂网络结构,核心就是 BERT 加一个分类头,数据格式整理好之后,训练脚本一把梭就能跑起来。下面我会把这套代码从环境准备到推理技巧完整讲一遍,也会把你最可能踩的坑提前标出来。
2. 技术底座与运行环境:从 pytorch-transformers 到本地能跑通
2.1 为什么选 pytorch-transformers 而不是其它封装
这份资源的核心依赖是 pytorch-transformers,也就是目前 Hugging Face transformers 库的前身。在我实际用下来的体验里,这个库对 BERT 中文场景最友好的地方是:它把分词、预训练权重下载、模型架构定义全部封装好了,你在代码里只需要指定一个模型名,就能拿到完整的 tokenizer 和模型对象。
对比自己手写 BERT 架构的方案,这个库的好处非常直白。手写版本意味着要自己处理词表、position embedding、attention mask,任何一个细节对不上预训练权重,模型的初始化就等于随机起步,前面预训练学到的语义信息全浪费了。而 pytorch-transformers 提供的BertForSequenceClassification模型类,直接在 BERT 之上挂了一个分类层,你只需要告诉它类别数量,它就会自动处理 hidden state 到 logits 的映射。这种封装在工程上省掉的不只是几十行代码,还省掉了一整类「为什么我的 BERT 效果还不如 word2vec」的玄学问题。
另外这套代码对新手非常友好的一点是,它没有引入复杂的配置文件体系。很多人第一次看某个开源框架,会被一堆 yaml 和注册机制吓退。这份代码的参数基本都集中在训练脚本的入口处,改几个值就能跑,理解成本低很多。
2.2 环境准备与版本匹配
先看安装环节。我建议把 PyTorch 和 transformers 放在同一个虚拟环境里安装,避免和系统里其它 Python 项目的依赖互相污染。用 conda 创建一个干净的环境是最省心的方式:
conda create -n bert-cls python=3.7 conda activate bert-cls pip install torch==1.8.0 pip install pytorch-transformers==1.2.0 pip install scikit-learn pandas tqdm这里的版本组合是我验证过能正常工作的搭配。PyTorch 1.8 和 pytorch-transformers 1.2 之间没有已知的接口冲突,如果你用新版的 transformers 库(比如 4.x),代码里的导入路径需要相应调整。老版本代码最常见的写法是from pytorch_transformers import BertTokenizer, BertForSequenceClassification,新版本则是from transformers import BertTokenizer, BertForSequenceClassification,从资源正文来看它遵循的是前者。
比较隐蔽的一个点是 CUDA 版本。如果训练机器没有 GPU,代码会自动退回 CPU 模式,但你会发现训练速度极其感人——一个几万条样本的二分类任务,在 CPU 上可能要跑十几个小时。我一般会先在 GPU 机器上做验证,也确实遇到过 GPU 驱动和 PyTorch 的 CUDA 版本不匹配导致CUDA error: no kernel image is available的情况。排查方法很直接:先跑一句python -c "import torch; print(torch.cuda.is_available())",输出 False 说明 PyTorch 根本没识别到显卡,基本可以确定是 CUDA 版本问题。环境就绪后,下一步就是准备模型和数据。
3. 数据处理:把原始文本转成 BERT 能吃的输入格式
3.1 数据集格式与拆分策略
拆这套代码时我注意到,它默认接受的是最常见的二分类文本格式:每行一条样本,文本内容和标签用制表符\t分隔。这个设计在工程上很聪明,因为 CSV 格式要处理引号转义和逗号分割问题,而纯文本加分隔符的方案在绝大多数业务数据里都够用了。
拿到原始数据之后,第一步永远是检查数据分布。我通常会先写一个快速统计脚本,看看正负样本比例是否失衡,以及文本长度的大致分布情况:
import pandas as pd df = pd.read_csv("data/train.tsv", sep="\t", header=None, names=["text", "label"]) print(df["label"].value_counts()) df["text_len"] = df["text"].apply(len) print(df["text_len"].describe(percentiles=[0.5, 0.75, 0.9, 0.95]))这段代码做的事情很单纯:第一行用 pandas 读取 TSV 文件并给两列命名,第二行统计每个类别的样本量,第三行计算文本字符长度的分位数。这里的分位数结果直接决定后面设置max_seq_len的方式——如果你的 90% 样本都在 50 个字符以内,那就没必要把序列长度设成 512,那只会白白增加显存占用和训练时间。
关于数据拆分,代码包内部如果没内置的话,我建议用train_test_split固定随机种子来切分:
from sklearn.model_selection import train_test_split train_texts, val_texts, train_labels, val_labels = train_test_split( df["text"].tolist(), df["label"].tolist(), test_size=0.1, random_state=42, stratify=df["label"].tolist() )stratify参数是关键。分类任务最忌讳的就是切分后验证集里某个类别的比例和训练集不一致,尤其是样本量本身不大时,随机切分很容易让验证集丢失少数类样本,导致验证指标虚高或虚低。固定random_state=42是为了复现实验时能拿到完全一致的数据划分。
3.2 tokenizer 处理中文文本的特殊性
BERT 处理中文的方式和处理英文完全不同,这一点在代码里体现得很明显。英文有独立的单词级 tokenizer,需要处理大小写、词形还原等问题;而bert-base-chinese的 tokenizer 是字符级的,它把每个汉字当成一个独立的 token,不需要你再额外引入分词工具。
这段代码里的转换逻辑非常短,但容易出错的地方也在这里——很多人以为自己需要先用 jieba 分词再喂给 BERT,其实是多此一举。字符级 tokenizer 会把「我爱自然语言处理」直接拆成「我」「爱」「自」「然」「语」「言」「处」「理」这八个 token,中间不会插入空格,也不会按词切分。
from pytorch_transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") texts = ["这个商品质量很好,发货速度快", "客服态度差,申请退款"] encodings = tokenizer( texts, max_length=64, padding="max_length", truncation=True, return_tensors="pt" )参数说明:max_length=64是序列截断长度,根据上一步的文本长度分布来设置,能用 64 解决就不用到 128;padding="max_length"表示把所有样本统一填充到固定长度,因为 BERT 内部是矩阵运算,一个 batch 里的序列长度必须完全一致;truncation=True开启超长截断,超过 max_length 的部分直接丢弃;return_tensors="pt"指定返回 PyTorch 张量格式,方便直接喂给模型。
这段代码返回的encodings里包含三个关键字段:input_ids是文本对应 token 在词表中的索引,attention_mask标记哪些位置是真实文本、哪些位置是填充的 padding。token_type_ids在多句任务里用来区分上下句,单文本分类场景下全为 0,可以不用。这三个张量就是 BERT 模型接收的全部输入。
3.3 构建 Dataset 类
有了编码后的张量,还需要把它封装成 PyTorch 的 Dataset 类,方便 DataLoader 按批次取数据:
import torch from torch.utils.data import Dataset class TextClassificationDataset(Dataset): def __init__(self, encodings, labels): self.encodings = encodings self.labels = labels def __getitem__(self, idx): item = {key: val[idx] for key, val in self.encodings.items()} item["labels"] = torch.tensor(self.labels[idx]) return item def __len__(self): return len(self.labels)这个类本身不复杂:__len__返回数据集大小,__getitem__根据索引取出对应样本,整理成字典形式返回,键名和模型 forward 函数的参数名对齐。labels需要手动转换成 LongTensor,因为分类任务的标签本质是整数索引,PyTorch 的交叉熵损失函数只接受 LongTensor 类型的输入,直接用 Python int 会在计算损失时报数据类型错误。这一步做完,数据链路就通了,接下来进入模型和训练部分。
4. 模型训练与调参:分类头、学习率、warmup 一整套走下来
4.1 加载预训练模型
训练的核心代码非常短,这是 pytorch-transformers 库带来的最大便利。预训练模型和分类头的组合只需要一行:
from pytorch_transformers import BertForSequenceClassification model = BertForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=2 )from_pretrained会先去本地缓存目录找模型文件,找不到就自动下载。bert-base-chinese是中文 BERT 的预训练权重标识,对应的是一个 768 维 hidden size、12 层 transformer 的 Base 模型。num_labels=2表示二分类任务,模型内部会自动把最后一层 hidden state 的第一个 token(也就是[CLS]位置的输出)过一层全连接,映射成 2 维的 logits 向量。
如果你是第一次运行,下载模型是个容易卡住的环节。下载需要从外部服务器拉取几个文件,包括config.json、pytorch_model.bin、vocab.txt,其中模型权重文件有大概 400MB。下载过程经常因为网络波动中断,解决方法是提前把文件手动下载好放到本地目录,然后用本地路径加载:
model = BertForSequenceClassification.from_pretrained( "./pretrained/bert-base-chinese", num_labels=2 )本地目录里只需要保持原有的文件命名不变,pytorch_model.bin、config.json、vocab.txt三个文件齐全即可。这个习惯我从那以后一直保持——先把预训练权重下载固定到项目目录里,既保证了训练结果可复现,也避免了每次新环境部署都要重新下载的尴尬。
4.2 优化器与学习率设置
BERT 微调的学习率设置和训练普通神经网络完全不同,这是新手最容易翻车的地方。BERT 预训练模型已经学到了很好的语义表示,微调阶段只需要在原有权重基础上做小幅调整,学习率太高会把学到的知识冲掉,导致模型在训练集上不收敛,甚至 loss 从一开始就爆炸。
from pytorch_transformers import AdamW, WarmupLinearSchedule optimizer = AdamW(model.parameters(), lr=2e-5, correct_bias=False) total_steps = len(train_dataloader) * num_epochs scheduler = WarmupLinearSchedule( optimizer, warmup_steps=int(total_steps * 0.1), t_total=total_steps )这里的lr=2e-5是 BERT 微调任务的常见起点,一般在这个数值附近微调都不会有大问题。如果你发现 loss 波动剧烈,优先把这个值调低到 1e-5,而不是盲目加大。correct_bias=False是为了避免 AdamW 对偏置项做额外的权重衰减修正,保持和 BERT 原始实现一致。
WarmupLinearSchedule是 BERT 训练中一个非常重要的组件:前 10% 的 step 里学习率从 0 线性上升到设定值,后面 90% 的 step 里线性衰减到 0。它解决的核心问题是训练初期模型权重还没稳定,过大的学习率会导致 loss 剧烈震荡;而到训练后期,模型已经接近收敛,线性衰减能帮它找到更平滑的极小值点。
4.3 完整训练循环
代码里最核心的训练循环逻辑如下,为了便于说明,我把关键步骤压缩在一个脚本里:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) for epoch in range(num_epochs): model.train() total_loss = 0 for batch in train_dataloader: batch = {k: v.to(device) for k, v in batch.items()} outputs = model(**batch) loss = outputs[0] loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss += loss.item()这段代码有几个细节值得展开。outputs[0]是模型返回的 loss 值,因为BertForSequenceClassification的 forward 方法在传入labels时会把交叉熵损失计算封装在内部。clip_grad_norm_是梯度裁剪操作,max_norm=1.0表示把整个梯度的 L2 范数限制在 1.0 以内,防止个别 batch 的异常梯度把模型参数推出合理范围。每轮迭代最后先optimizer.step()更新参数,再scheduler.step()更新学习率,最后optimizer.zero_grad()清空梯度,顺序错了会导致梯度累积问题。
4.4 验证函数
训练过程中需要周期性在验证集上评估,判断是否有过拟合迹象。验证阶段不需要计算梯度,用torch.no_grad()包装即可:
from sklearn.metrics import accuracy_score, f1_score def evaluate(model, val_dataloader): model.eval() predictions, true_labels = [], [] for batch in val_dataloader: batch = {k: v.to(device) for k, v in batch.items()} with torch.no_grad(): outputs = model(**batch) logits = outputs[0] preds = torch.argmax(logits, dim=-1).cpu().numpy() predictions.extend(preds) true_labels.extend(batch["labels"].cpu().numpy()) return { "accuracy": accuracy_score(true_labels, predictions), "f1": f1_score(true_labels, predictions, average="binary") }很多初学的人验证集指标和训练集指标差一大截,原因往往不在模型本身,而在数据处理上。比如训练时做了 shuffle,验证时忘了model.eval()切换 BatchNorm 和 Dropout 的行为;或是在验证集上没关梯度计算,导致显存占用翻倍。model.eval()的作用是关闭 Dropout 层和 BatchNorm 层的训练行为,让前向传播结果完全确定。注意model.eval()和torch.no_grad()是两个不同的东西,前者管模型内部层的行为模式,后者管梯度计算图的构建,两者都要用,缺一个都会出问题。验证完模型、调整完参数后,就到了真正展示工程能力的时候。
5. 避坑记录:五个真实翻车现场与排查方法
以下五条都是从实际运行和复现过程中沉淀下来的踩坑记录,每一条都对应具体的现象、原因和解决路径。
第一条:训练 loss 为 NaN,或者从一开始就不下降。
现象:脚本正常启动,但训练到第二个或第三个 batch,loss 直接变成nan,或者全程数值不降。
原因排查:最常见的是学习率设置问题,尤其是在用from_pretrained加载模型后没有把模型切成 train 模式就拉满学习率。另外数据里有空文本也会导致这个问题——BERT 对空序列编码后,attention_mask全为 0,模型拿到的信息是零向量,反向传播计算出的梯度会异常。
解决:先用上面提到的evaluate函数跑一个 batch 的正确率检查数据有效性,把文本预处理里过滤空值和纯空白字符的逻辑加上,学习率先压到 1e-5 试跑一百个 step,确认 loss 在下降再放开。
第二条:验证集指标很高,但线上效果完全不行。
现象:验证集准确率 95% 以上,上线后准确率只有 70% 左右。
原因:验证集和训练集是同一个来源的数据,数据分布高度一致,甚至有些重复样本在切分时同时落到了两边——在没有做去重的数据集上跑随机切分,这个问题几乎必然出现。另一个更隐蔽的原因是训练数据本身的标注质量和线上数据分布不一致,比如训练集里「差评」的文本相对规范化,而线上用户输入的文字噪声很大。
解决:数据切分前强制做一次文本级别的去重,把完全相同或只差一个标点的文本归并;拆成训练集和验证集时用stratify保证类别比例一致;另外做一个简单的「时间切分」,拿业务上时间靠前的数据训练,靠后的数据验证,这样更接近线上真实表现。
第三条:显存直接 OOM,batch size 调到 8 都跑不动。
现象:单张显卡 8GB 显存,batch_size=8、max_length=64时直接报 CUDA out of memory。
原因:显存占用和max_length并不是线性关系,序列长度加长会导致 attention 矩阵按平方级增长。很多人习惯把max_length=128或256当成默认值来设,对短文本分类场景完全是浪费。
解决:短文本分类把max_length压到 64 甚至 32,显存占用直接降一个量级。实在需要长序列,就用gradient_accumulation_steps参数变相增大 batch size:每个小 batch 计算完梯度先累加起来,攒够几个 batch 再执行一次optimizer.step()。另外记得在训练脚本里加上torch.cuda.empty_cache(),在验证阶段切换到 CPU 跑,也能缓解显存压力。
第四条:pytorch-transformers 旧代码在 Python 3.8 以上环境报错。
现象:安装好依赖后,import 阶段或第一次调用from_pretrained时抛出奇怪的错误,比如AttributeError: module 'torch' has no attribute 'set_deterministic'。
原因:pytorch-transformers 1.2.0 是很古老的版本,它内部依赖的 API 在更新的 PyTorch 版本中有变化,导致某些函数找不到入口。
解决:直接用 requirements 里锁定的版本建虚拟环境,我上面给的就是一组确定兼容的组合。如果项目强制要用新版 Python,那就把代码里from pytorch_transformers的导入语句全部替换成from transformers,同时把WarmupLinearSchedule替换成新版对应的调度器类名。这个改动量不大,但需要跑一遍全流程验证,因为新版库的参数名略有差异。
第五条:label 映射错位,训练指标正常但推理结果全反了。
现象:模型训练和验证过程毫无异常,准确率也很好,但拿一条明显是正样本的文本去预测,模型输出的是负样本标签。
原因:训练数据里用「1」表示正样本、「0」表示负样本,代码里某个环节把 label 值和索引顺序搞反了;或者在构建 label 映射时没有固定顺序,比如用了 set 来生成 label 列表,而 set 是无序的。
解决:显式定义 label 映射字典,用固定方式生成label2id和id2label,并在保存模型时把映射文件一并存下来。推理时读取同一个映射文件做 reverse lookup,不要凭记忆在代码里硬编码 0 和 1 的含义。这条坑看似低级,但对生产环境的破坏力最大——训练指标很好恰恰会让人放松警惕。
6. 训完怎么用:推理验证、误差排查与部署小习惯
模型训练完成并不代表交付完成。最后一个环节是推理验证,做法是加载保存的模型权重,把新的文本转成和训练时完全一致的输入格式,然后输出概率分布。这里有一个工程习惯我很早就固化下来了:保存模型时把 tokenizer 和模型权重放在同一个目录,推理时也从同一个目录加载,避免「训练用的 tokenizer 和推理用的 tokenizer 不一致」导致的指标浮动。之前就吃过一次亏:训练时用的是带本地词表调整的 tokenizer,推理时嫌麻烦直接用线上默认路径加载,结果同一条文本在两个场景下被切分成不同的 token 序列,模型输出自然对不上。
推理脚本的核心逻辑很简单:
def predict(texts, model_path="./output"): tokenizer = BertTokenizer.from_pretrained(model_path) model = BertForSequenceClassification.from_pretrained(model_path, num_labels=2) model.eval() model.to(device) encodings = tokenizer( texts, max_length=64, padding="max_length", truncation=True, return_tensors="pt" ).to(device) with torch.no_grad(): logits = model(**encodings)[0] probs = torch.softmax(logits, dim=-1) return probs.cpu().numpy()预测阶段用torch.softmax把 logits 转成概率值,业务系统直接用这个概率做阈值判断。很多场景下直接取最大概率对应的类别并不是最优策略——比如分类置信度只有 0.51 的样本,其实说明模型在两个类别之间非常犹豫,这时候把样本抽出来人工复核或者标记为低置信度,往往比强制归入某一类更合理。这也是一个非常实用的排查思路:把验证集上预测错误的样本全部打印出来,按置信度从高到低排列,你会很快看到模型的系统性偏差——是某一类文本格式识别不了,还是某一类关键词让模型产生了误判。
沿着这个思路再往后走一步,就到了误差分析和持续迭代的环节。我一般会把预测结果落成一个带真实标签、预测标签、置信度三列的表格,每训练完一个版本就看一遍低置信度样本,从中归纳出需要补充的训练数据长什么样。这个动作看起来简单,但对模型效果的持续提升帮助比调参大得多。
从那以后我每次做 BERT 分类项目都强制走一遍完整流程:数据分布统计、分层切分、固定长度的 tokenizer 配置、学习率从 2e-5 起步、训练中盯 loss 和验证指标、推理时用与训练完全一致的预处理管线。这套流程看起来没有什么炫技的地方,但它能把绝大多数翻车点提前拦截掉。这份代码能跑通并不难,难的是跑通之后还能稳定复现、修得动 bug、拍得准问题,希望帮到你。
本文还有配套的精品资源,点击获取