简介这份资源面向希望快速上手中文文本分类的深度学习开发者与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, headerNone, 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_size0.2, seed42): train_df, val_df train_test_split( df, test_sizetest_size, random_stateseed, stratifydf[label_id] # 分层抽样防止某类样本在验证集里消失 ) return train_df.reset_index(dropTrue), val_df.reset_index(dropTrue)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_lengthself.max_len, paddingmax_length, # 统一补齐到 max_len方便 batch 堆叠 truncationTrue, # 超长截断别让它悄悄报错 return_tensorspt ) return { input_ids: enc[input_ids].squeeze(0), attention_mask: enc[attention_mask].squeeze(0), labels: torch.tensor(self.labels[idx], dtypetorch.long) }paddingmax_length和paddingTrue的区别值得注意前者每条都补到固定长度显存占用稳定但浪费后者按 batch 内最长样本补齐省显存但每个 batch 形状不同。分类任务里如果显存够用max_length更省心。truncationTrue必须开否则遇到超长文本 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_labelsconfig.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_idsinput_ids, attention_maskattention_mask, labelslabels) 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 torch1.13.1 pip install transformers4.26.1 pip install scikit-learn pandas numpypytorch-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_idsinput_ids, attention_maskattention_mask).logits preds.extend(torch.argmax(logits, dim-1).cpu().tolist()) trues.extend(batch[labels].tolist()) print(classification_report(trues, preds, target_nameslabel_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_length128预测用了默认的 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常见做法是给不同参数组设置不同 lrbert_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 涨了两个点结果抽样集上反而掉了后来发现是验证集和训练集分布太接近过拟合被掩盖了。从那以后我每次改完参数都强制在抽样集上走一遍再决定要不要保留这次改动。希望这份代码和这些经验能帮你少走几段弯路。本文还有配套的精品资源点击获取