☰
BERT中文文本分类实战:从代码跑通到避坑进阶
2026/10/16 3:20:05 网站建设 项目流程

简介:这份资源面向希望快速上手中文文本分类的深度学习开发者与NLP学习者,基于pytorch-transformers实现BERT中文新闻标题分类,覆盖从数据预处理、模型加载到训练与评估的完整流程,适合具备一定PyTorch基础、想掌握预训练模型微调的中级读者。压缩包共29个文件,约732.57MB,包含7个txt数据与标签文件、5个py训练与预处理脚本、5个xml工程配置、3个pyc缓存、2个png结果图,以及pytorch_model.bin预训练权重、config.json与vocab.txt词表等,结构清晰便于按模块查阅。数据取自THUCNews随机抽取的20万条新闻标题,涵盖财经、房产、股票、教育、科技、社会、时政、体育、游戏、娱乐十类,每类2万条,并按18万训练、1万验证、1万测试划分,可直接复现实验。已有3700人学习下载,读者能获得可运行的分类代码、预训练模型与结果可视化,快速理解BERT微调在中文分类任务中的落地方式。

1. 一份能直接跑通的 BERT 中文分类代码,到底省掉了哪些事

如果你做过中文文本分类,大概率经历过这个循环:找开源代码、配环境、改数据读取、调 tokenizer、发现标签对不上、loss 不降、显存爆了、换个小模型再试一遍。一圈下来,模型还没开始训,人已经累了。这份基于 pytorch-transformers 的 BERT 中文文本分类代码,解决的就是这个循环里的重复劳动——它把数据加载、tokenize、模型构建、训练循环、评估指标、预测推理这几块串成了一条能直接跑的链路,适合手里有标注数据、想快速拿到 baseline 的从业者,也适合刚接触 transformers 生态、想看清一个完整分类流程长什么样的新手。下面按「它怎么组织、怎么跑起来、参数怎么调、坑在哪」的顺序拆开讲。

2. 代码结构拆解:从原始文本到 DataLoader 的完整链路

拿到一份分类代码,第一件事不是急着python train.py,而是先看清楚它把哪些环节封装了、哪些留给你改。这份代码的组织方式比较典型,核心文件通常包括配置、数据处理、模型定义、训练入口和预测脚本几块。理解这条链路,后面调参和排错才有方向。

2.1 配置文件与超参入口

多数这类代码会把超参集中在一个 config 类或 yaml/json 里,常见字段包括max_seq_len、batch_size、learning_rate、num_epochs、num_labels、pretrained_model_path。集中管理的好处是训练和预测共用同一套参数,避免推理时 tokenizer 的max_length和训练时不一致导致结果漂移。

class Config: def __init__(self): self.pretrained_path = "./bert-base-chinese" # 本地权重目录,避免每次联网拉取 self.max_seq_len = 128 # 中文短文本 128 通常够用,长文档再往上加 self.batch_size = 32 # 视显存调整,8G 显存建议 16 起步 self.learning_rate = 2e-5 # BERT 微调经典区间 1e-5 ~ 5e-5 self.num_epochs = 5 # 小数据集 3~5 轮即可,多了容易过拟合 self.num_labels = 10 # 必须和标签映射表长度一致 self.dropout = 0.1

这里几个参数值得单独说。max_seq_len直接决定显存占用和速度,中文分类任务里绝大多数样本在 128 以内,盲目设成 512 只会让训练慢好几倍。learning_rate是 BERT 微调最敏感的参数,2e-5 是个稳妥起点,太大 loss 会震荡,太小收敛慢到怀疑人生。num_labels必须和你的标签体系严格对齐,这是后面很多「维度不匹配」报错的根源。

2.2 数据读取与标签映射

分类代码里最容易被忽视、又最容易翻车的就是标签映射。原始数据里的标签可能是中文、可能是字符串、可能是从 0 开始也可能从 1 开始,模型只认0 ~ num_labels-1的整数。所以一份靠谱的代码一定会有一个显式的 label2id 构建过程。

import pandas as pd from sklearn.model_selection import train_test_split def load_data(path): df = pd.read_csv(path, sep="\t", header=None, names=["text", "label"]) # 构建标签映射,排序保证每次运行 id 一致 labels = sorted(df["label"].unique().tolist()) label2id = {lab: i for i, lab in enumerate(labels)} df["label_id"] = df["label"].map(label2id) return df, label2id def split_data(df, test_size=0.2, seed=42): train_df, val_df = train_test_split( df, test_size=test_size, random_state=seed, stratify=df["label_id"] # 分层抽样,防止某类样本在验证集里消失 ) return train_df.reset_index(drop=True), val_df.reset_index(drop=True)

stratify这个参数在小类别样本少的时候尤其关键,不加的话验证集里可能某个类一个样本都没有,评估指标直接失真。random_state固定住,是为了让每次实验可复现——调参阶段这点很重要,否则你分不清是参数起作用还是数据划分变了。

2.3 Dataset 与 tokenizer 的对接

transformers 生态里,数据要包装成Dataset,再由DataLoader按 batch 取。tokenizer 负责把中文文本切成 token id、生成 attention mask。

import torch from torch.utils.data import Dataset from transformers import BertTokenizer class TextDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): enc = self.tokenizer( str(self.texts[idx]), max_length=self.max_len, padding="max_length", # 统一补齐到 max_len,方便 batch 堆叠 truncation=True, # 超长截断,别让它悄悄报错 return_tensors="pt" ) return { "input_ids": enc["input_ids"].squeeze(0), "attention_mask": enc["attention_mask"].squeeze(0), "labels": torch.tensor(self.labels[idx], dtype=torch.long) }

padding="max_length"和padding=True的区别值得注意:前者每条都补到固定长度,显存占用稳定但浪费;后者按 batch 内最长样本补齐,省显存但每个 batch 形状不同。分类任务里如果显存够,用max_length更省心。truncation=True必须开,否则遇到超长文本 tokenizer 会直接抛错中断训练。

2.4 模型定义与训练循环

模型部分通常是在BertForSequenceClassification基础上包一层,或者自己接一个分类头。训练循环则是标准的 forward、loss、backward、step,加上验证和保存最优模型。

from transformers import BertForSequenceClassification, AdamW from torch.utils.data import DataLoader def build_model(config): model = BertForSequenceClassification.from_pretrained( config.pretrained_path, num_labels=config.num_labels ) return model def train_one_epoch(model, loader, optimizer, device): model.train() total_loss = 0 for batch in loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].to(device) optimizer.zero_grad() outputs = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels) loss = outputs.loss loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader)

attention_mask一定要传进去,否则 padding 的位置也会参与注意力计算,短文本任务上这个疏忽能让准确率掉好几个点。优化器用AdamW而不是Adam,是因为 BERT 微调需要正确的权重衰减处理,这是 transformers 官方推荐的搭配。

3. 把代码跑起来:环境、命令与参数调整

链路看懂了,接下来是实际跑通。这一步的坑大多集中在环境和显存上,按顺序来基本能避开。

3.1 环境依赖与版本对齐

transformers 这个库版本迭代快,不同版本 API 有差异。常见做法是固定一组经过验证的版本,而不是无脑装最新。

pip install torch==1.13.1 pip install transformers==4.26.1 pip install scikit-learn pandas numpy

pytorch-transformers这个旧包名现在已经并入transformers,如果你看到代码里 import 的是pytorch_transformers,说明它基于很老的版本,建议统一换成transformers的新 API,否则BertForSequenceClassification的返回结构对不上。torch 和 transformers 的版本要匹配,torch 太新而 transformers 太旧时,加载权重可能报unexpected key之类的错。

3.2 训练与验证的完整命令

把数据准备好之后,训练入口一般长这样:

python train.py \ --data_path ./data/train.tsv \ --pretrained_path ./bert-base-chinese \ --max_seq_len 128 \ --batch_size 32 \ --learning_rate 2e-5 \ --num_epochs 5 \ --save_dir ./output

参数含义逐条对照:data_path指向标注数据,格式要和load_data里的解析逻辑一致;pretrained_path指向本地权重目录,提前下载好能避免训练中途卡在下载上;batch_size和max_seq_len共同决定显存峰值,跑不动就先降这两个;save_dir用来存最优 checkpoint,验证集指标不再提升时保存,避免存下过拟合的最后一轮。

3.3 显存不够时的降级策略

显存爆了是最高频的问题,报错通常是CUDA out of memory。降级顺序建议是:先把batch_size减半,再把max_seq_len从 128 降到 64,还不行就换更小的预训练模型(比如 4 层的轻量版本)。另外可以开梯度累积,用小 batch 模拟大 batch 的效果:

accum_steps = 4 # 等效 batch = batch_size * accum_steps for i, batch in enumerate(loader): loss = model(**batch).loss / accum_steps loss.backward() if (i + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()

梯度累积的代价是训练变慢,但能在有限显存下用上更大的等效 batch,对小数据集稳定训练有帮助。

3.4 评估指标与预测推理

训练完要看的指标不能只有 accuracy。类别不均衡时,accuracy 会被多数类带偏,常见做法是同时输出 macro-F1 和每个类的 precision/recall。

from sklearn.metrics import classification_report def evaluate(model, loader, device, label_names): model.eval() preds, trues = [], [] with torch.no_grad(): for batch in loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) logits = model(input_ids=input_ids, attention_mask=attention_mask).logits preds.extend(torch.argmax(logits, dim=-1).cpu().tolist()) trues.extend(batch["labels"].tolist()) print(classification_report(trues, preds, target_names=label_names))

torch.no_grad()在评估阶段必须加,否则会白白占用显存并拖慢速度。argmax取 logits 最大值的下标作为预测类别,这是单标签分类的标准做法;如果是多标签,就要换成 sigmoid 加阈值判断。

4. 避坑与排查:那些让 loss 不降的细节

代码能跑起来只是第一步,跑出合理结果才算数。下面几条是这类项目里反复出现的翻车点,按「现象 → 原因 → 解决」整理。

4.1 loss 一直不降或震荡

现象:训练几轮后 loss 在某个值附近来回跳,准确率不涨。原因通常是学习率过大,或者标签映射错乱导致模型在学噪声。解决:先把learning_rate降到 1e-5 试一轮,同时打印几个 batch 的labels确认取值范围在0 ~ num_labels-1之间。如果标签里有 -1 或超出范围的值,CrossEntropyLoss会静默算错。

4.2 验证集准确率远高于训练集

现象:验证集指标比训练集还高,看着很美好。原因多半是数据泄漏——训练集和验证集在划分前就重复了,或者用了stratify但原始数据本身有重复样本。解决:划分前先drop_duplicates,并检查两个集合的文本是否有交集。

4.3 推理结果和训练时对不上

现象:训练时评估正常,单独写预测脚本时结果全错。原因通常是预测时的 tokenizer 参数和训练时不一致,比如训练用max_length=128,预测用了默认的 512,或者 padding 策略不同。解决:把 tokenizer 的配置和训练时完全对齐,最好复用同一个 Config 对象。

4.4 中文被切成了奇怪的字

现象:tokenizer 把中文按单字切,或者出现大量[UNK]。原因是用错了预训练模型——用了英文的bert-base-uncased而不是中文权重。解决:确认pretrained_path指向的是中文预训练模型,词表里应该包含大量中文字符。这个错误在新手手里特别常见,因为英文模型的下载和加载流程完全一样,不报错但效果差。

4.5 保存的模型加载后维度不匹配

现象:load_state_dict报 size mismatch。原因一般是保存时num_labels和加载时不一致,或者分类头的名字对不上。解决:加载时用同一个 Config 构建模型结构,再load_state_dict,不要直接from_pretrained一个已经改过分类头的目录。

提示:每次改完参数,先拿几十条样本跑一个 epoch,确认链路通了再上全量数据,能省下大量等待时间。

5. 进阶技巧:让这份代码在你的数据上真正好用

baseline 跑通之后,真正决定效果的是几个容易被忽略的细节。这里挑三个最实用的讲。

第一个是分层学习率。BERT 主体和分类头对学习率的敏感度不同,主体用小的(2e-5),分类头用大的(1e-3),常见做法是给不同参数组设置不同 lr:

bert_params = list(model.bert.named_parameters()) head_params = list(model.classifier.named_parameters()) optimizer = AdamW([ {"params": [p for _, p in bert_params], "lr": 2e-5}, {"params": [p for _, p in head_params], "lr": 1e-3}, ])

这样分类头能更快适应你的标签体系,主体则保持预训练学到的语言知识不被冲垮。

第二个是冻结层数。数据量小的时候(比如每类只有几百条),解冻全部 BERT 层容易过拟合。可以只训练最后两三层加分类头,前面的层冻结:

for name, param in model.bert.named_parameters(): if "layer.10" not in name and "layer.11" not in name and "pooler" not in name: param.requires_grad = False

冻结后训练速度快很多,小数据上效果往往比全量微调更稳。

第三个是对抗训练。在 embedding 上加一点扰动(FGM 或 PGD),能提升模型鲁棒性,对中文分类这种容易受措辞影响的场景有帮助。实现上就是在 backward 之前对 embedding 做一次扰动再算一次梯度,代码量不大但收益明显。

验证方法上,我一般会留一个「线上抽样集」——从真实业务里随机抽几百条没参与训练的数据,每次改完参数都在这上面看一遍。训练集和验证集的指标再好看,最终还是要看这批数据上的表现。有一次我调了一下午参数,验证集 F1 涨了两个点,结果抽样集上反而掉了,后来发现是验证集和训练集分布太接近,过拟合被掩盖了。从那以后我每次改完参数,都强制在抽样集上走一遍再决定要不要保留这次改动。希望这份代码和这些经验,能帮你少走几段弯路。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询