☰
Bert/ERNIE中文短文本分类实战:从微调到部署避坑指南
2026/10/7 10:13:54 网站建设 项目流程

简介:面向自然语言处理入门者与开发者的中文短文本分类实战压缩包,聚焦Bert与ERNIE在短文本分类场景中的完整应用流程。包内提供可运行的Python训练脚本、预训练模型目录、THUCNews数据集以及说明文档,涵盖数据预处理、模型微调、分类器构建、评估验证等关键环节,适合希望结合预训练模型快速上手中文自然语言处理任务的读者。资源共54个文件,包括27个py脚本、18个pyc编译文件、4个txt数据文件、4个md说明文档和1个license文件,压缩包整体6.11MB。py脚本覆盖模型实现与训练逻辑,md文档提供使用指引,txt便于查看样本或标签格式,整体结构清晰,便于按需取用。目前已有219人学习/下载,内含多种网络结构变体及ERNIE实现,可对照THUCNews数据集进行效果对比,适合实践教学、课程设计或算法调参参考。

1. 把 Bert/ERNIE 用在中文短文本分类:预训练模型为什么成了默认起点

短文本分类这个任务,放在五年前还得靠 TF-IDF 加 SVM 硬扛,如今只要数据量不是小到离谱,直接把 Bert 或 ERNIE 拉过来微调几乎是所有人的第一选择。原因不复杂:短文本本身信息密度低,关键词命中率有限,传统方法很难从十几二十个字里挤出足够特征,而预训练模型在预训练阶段见过海量中文语料,对同义表达、省略句式有天然的泛化能力。标题里那套附数据集的工程包,做的正是这件事——用一套可复现的脚本,把 Bert/ERNIE 在中文短文本分类上的效果跑出来。这篇笔记适合两类人:一类是刚接触预训练模型、想用一个短文本项目练手的开发者,另一类是已经跑通基础流程、但卡在调参和部署细节上的从业者。前者能照着走完全流程,后者能直接跳到参数和避坑章节找答案。

2. 选型与数据处理:Bert 和 ERNIE 差在哪儿,中文短文本怎么准备

2.1 ERNIE 与 Bert 的差异:知识增强对短文本真的有效吗

先说结论:对绝大多数短文本分类任务,ERNIE 相对 Bert 的提升幅度在 0.5 到 2 个百分点的 F1 之间,而且这个差距主要集中在对实体、成语、专有名词敏感的场景下。Bert 的 MLM 预训练任务基于字级别的掩码,模型学会的是“被遮住的这个字最可能是什么字”;而 ERNIE 的预训练把掩码单位从字提升到了词、实体和短语级别,强迫模型在学习时对齐外部知识。这就导致一个很直观的结果:如果你的文本里全是“苹果发布了新手机”这种含实体或品牌名的句子,ERNIE 通常赢;如果文本是“东西不错,物流很快”这种泛化评价,两者差别不大。

实际选择时,我一般会遵循三个判断条件。第一看数据规模,少于 1 万条训练样本时,ERNIE 的收敛速度快过 Bert,因为知识增强相当于给模型加了一层先验;第二看类别体系,如果类别定义本身依赖行业黑话(比如“理财”和“保险”在产品评论里经常纠缠不清),ERNIE 能少走一些弯路;第三看部署环境,ERNIE 的模型体积和 Bert-base 相当,推理延迟几乎没有差别,所以不存在“效果更好但用不起”的情况。

提示:ERNIE 1.0 和 Bert-base-chinese 在相同的微调配置下,训练时间相差不超过 10%,选型不需要过多考虑算力成本。

2.2 用公开数据快速搭一个短文本分类集:字段、标签与划分

标题里附带的数据集,常见内容是今日头条新闻标题分类数据或 THUCNews 的子集。前者有 12 个类别、约 38 万条样本,字段包括新闻标题和对应标签;后者是清华新闻分类的子集,10 个类别,每个类别 6500 条左右。如果没有特殊要求,我建议直接用今日头条那份,因为它本身就是短文本——标题平均长度在 20 个字左右,非常贴近实际业务中的弹幕、评价、query 场景。

拿到数据后的第一个动作不是炼丹,而是做一次彻底的分布摸底。你需要回答三个问题:各类别样本量是否均衡、文本长度分布集中在哪个区间、有没有脏数据(空标题、全符号、乱码)。常常有包里的数据本身是干净的,但自己换了一批数据后直接套用脚本,结果验证集分数一路暴跌,问题就是出在没做这步摸底上。

import pandas as pd df = pd.read_csv("toutiao_cat_data.txt", sep="_!_", header=None, names=["id", "cid", "cname", "title", "keywords"]) print(df["cname"].value_counts()) # 类别分布 df["title_len"] = df["title"].apply(len) print(df["title_len"].describe()) # 长度分布 df = df[df["title"].notna() & (df["title"].str.strip() != "")]

这里读的是带_!_分隔符的原始文件,列名按常见数据格式做了重命名。value_counts()能直接看出类别是不是均衡,describe()输出长度分布的均值、分位数,方便后续决定max_len设多少。清洗逻辑只有一行,但非常重要:空标题会把 tokenize 后的序列变成纯[CLS][SEP],这类样本等于给模型喂噪声。

2.3 一份可直接落地的预处理方案:为什么要保留原始句子

短文本分类里最常见的预处理翻车点,是做分词后存储、再喂给 Bert。Bert 和 ERNIE 的中文 tokenizer 用的是字级切分,自己先用 jieba 分词再拼回带空格的字符串,不仅多此一举,还可能把模型能识别的词边界打乱。正确做法是只做最低限度的清洗:统一全角半角、去除不可见字符、保留原有标点。尤其不要把“你不应该这样”这种句子里的“不”去掉,Bert 的注意力机制需要看到完整的上下文才能判断情感极性。

import re def clean_text(s: str) -> str: s = s.replace("\u3000", " ").replace("\xa0", " ") s = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", s) # 全角转半角 s = "".join( chr(ord(c) - 0xFEE0) if 0xFF01 <= ord(c) <= 0xFF5E else c for c in s ) return s.strip()

这段清洗函数的参数说明:\u3000是全角空格,常见于爬虫抓取的网页文本;\x00-\x08这类控制字符在 JSON 传输中偶尔混入,不清理会触发 tokenizer 的异常;全角转半角的规则是把全角字符的 Unicode 码位减去0xFEE0,落在0xFF01到0xFF5E区间的字符正好对应英文标点和数字。这一套做完,直接传入tokenizer.encode_plus即可,不需要额外分词。

3. 基于 transformers 跑通最小训练链路:从加载到微调

3.1 环境与依赖:能跑起来的版本组合

微调 Bert/ERNIE 的常见基座是 Hugging Face 的 transformers 库。版本选择上有一个血泪经验:不要盲目追新。transformers 4.x 的 API 变动不大,但 4.30 之后 Trainer 的默认行为有调整,网上大量教程基于旧版写的model.bert.embeddings访问方式可能失效。我一般固定使用 4.28.0 或 4.36.0 两个版本,前者兼容大多数老项目代码,后者支持较新的模型结构。

pip install transformers==4.36.0 tokenizers==0.15.0 datasets==2.16.1 pip install torch==2.1.0 --index-url https://download.pytorch.org/whl/cu118

PyTorch 的版本要对比自己的 CUDA 环境来定。cu118是 CUDA 11.8 的预编译版本,如果你的显卡驱动只支持 CUDA 12,就把索引地址改成cu121。这里不建议使用 CPU 版本跑哪怕一万条数据的微调——不是跑不动,是慢到让人怀疑人生,一个 epoch 可能要几小时,完全没法做参数实验。

3.2 最小训练脚本:数据加载、tokenize 与训练主循环

核心训练流程可以拆成四步:加载 tokenizer 和模型、把文本转成 input_ids、构建 DataLoader、跑微调循环。下面是一份我多次复用后精简出的最小脚本,剔除了早停、混合精度等进阶项,只保留跑通链路需要的部分。

from transformers import BertTokenizer, BertForSequenceClassification from torch.utils.data import Dataset, DataLoader import torch tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") model = BertForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=12) class ShortTextDataset(Dataset): def __init__(self, texts, labels, max_len=32): self.texts, self.labels = texts, labels self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, i): enc = tokenizer.encode_plus( self.texts[i], max_length=self.max_len, truncation=True, padding="max_length", return_tensors="pt") return { "input_ids": enc["input_ids"].squeeze(0), "attention_mask": enc["attention_mask"].squeeze(0), "labels": torch.tensor(self.labels[i]) }

这段代码做了什么:encode_plus一次返回 input_ids、attention_mask 和 token_type_ids,其中truncation=True表示超长截断,padding="max_length"把每条样本统一补齐到 32 个 token。return_tensors="pt"让输出直接是 PyTorch 张量,省去手动转换。对短文本来说,max_len 32 基本覆盖了 95% 的样本,设太短损失信息、设太长浪费显存且引入大量 padding 噪声。

训练循环本身用 Trainer 还是手工写,取决于你后续要做什么。如果只是跑通流程并拿到一个 baseline,直接用Trainer最省事:

from transformers import Trainer, TrainingArguments args = TrainingArguments( output_dir="./output", learning_rate=2e-5, per_device_train_batch_size=32, num_train_epochs=3, weight_decay=0.01, logging_steps=100, ) trainer = Trainer(model=model, args=args, train_dataset=train_ds, eval_dataset=dev_ds) trainer.train()

参数说明:learning_rate=2e-5是全量微调的标准起步值,新人最容易犯的错是沿用预训练阶段的 1e-4 级学习率,结果模型权重被冲毁,loss 直接发散。per_device_train_batch_size=32适用于 12GB 显存的卡,短文本 + max_len 32 的情况下这个批次不会爆显存。weight_decay=0.01只对非 bias 和非 LayerNorm 参数生效是 transformers 的默认行为,能压住一部分过拟合。

3.3 在测试集上拿到第一版分数:评估脚本与指标口径

训练完第一版后,最常被忽略的一个步骤是建立评估脚本的统一口径。分类任务最常见的指标是 accuracy 和 macro F1,但对短文本场景,我强烈建议至少同时报告这两个值。原因是:如果类别不均衡,accuracy 可能虚高,而 macro F1 能暴露模型对少数类几乎不识别的问题。下面这段评估代码,输出结果里需要同时体现这两个指标。

from sklearn.metrics import accuracy_score, f1_score import numpy as np preds = np.argmax(trainer.predict(test_ds).predictions, axis=-1) y_true = [x["labels"].item() for x in test_ds] print("acc:", accuracy_score(y_true, preds)) print("macro f1:", f1_score(y_true, preds, average="macro"))

这里用的是 Trainer 自带的predict方法,返回的对象里.predictions是模型 logits 输出,再套一层argmax得到预测类别。average="macro"是先对每个类别算 F1 再取均值,等价于给每个类别相同的权重,不关心样本量差异。第一版分数出来后,建议把这个值记录成 baseline,后续每调一个参数都拿它做对比,而不是凭感觉判断“好像变好了”。

4. 必调参数与调优经验:学习率、MaxLen、批次与权重衰减

4.1 三个直接影响收敛的参数:学习率、MaxLen、Batch Size

短文本分类的微调实验里,我用过的参数组合里影响最大的是学习率,其次是max_len,最后才是 batch size。这个排序和很多人的直觉相反,但原因很实际:预训练模型已经收敛到一个较优的局部最优点,微调的本质是在这个点附近做小幅迁移,学习率过大直接飞出最优邻域,过小又难以适配下游任务。Bert 类模型在短文本任务上的安全区间是 2e-5 到 5e-5,ERNIE 可以放宽到 3e-5 到 5e-5。

参数推荐区间典型初值判断依据
learning_rate1e-5 ~ 5e-52e-5训练 loss 前 200 步是否平稳下降
max_len16 ~ 6432文本长度 98 分位数
batch_size16 ~ 6432显存占用不超过 80%
weight_decay0.0 ~ 0.10.01训练集和验证集 acc 差距是否 >5 个点

批量大小对最终效果的影响在 Bert 类模型上通常不明显,但它直接决定了学习率的上限。小 batch(16)配合大学习率(5e-5)容易震荡,大 batch(64)配合小学习率(1e-5)收敛稳定但训练慢。如果你只有一块 8GB 显存的卡,宁可把 max_len 调到 24 也不要把 batch 降到 8——梯度噪声过大会让模型在类别边缘上反复横跳。

4.2 防止过拟合的工程手段:早停、warmup 与对抗训练

短文本分类的过拟合表现很隐蔽:训练 loss 一路下降,验证 loss 在第 2 个 epoch 后开始反弹,但 accuracy 还在缓慢上升。这时如果只看准确率,你会误以为模型还在变好,实际它已经开始死记训练集中的高频词汇组合,换一批数据立刻失效。我处理这个问题的方式是三重保险,每层都有明确的触发条件。

from transformers import get_linear_schedule_with_warmup from transformers import TrainerCallback class EarlyStopCallback(TrainerCallback): def __init__(self, patience=2): self.patience = patience self.best = float("-inf") self.wait = 0 def on_evaluate(self, args, state, control, **kwargs): current = state.log_history[-1]["eval_f1"] if "eval_f1" in state.log_history[-1] else 0 if current - self.best > 0.003: self.best = current self.wait = 0 else: self.wait += 1 if self.wait >= self.patience: control.should_training_stop = True

早停回调的核心逻辑是:连续两个评估周期 F1 提升不足 0.003,就终止训练。0.003 这个阈值按经验来定,太大容易过早停止,太小等于没设。warmup 的常见做法是让学习率在前 10% 的步数从 0 线性升到目标值,再用线性衰减收尾,这样可以避免开头几步的大梯度把预训练权重冲偏。

对抗训练(FGM/PGD)在短文本分类上的收益因人而异。我测试过的项目里,大约一半场景能拿到 0.5 到 1 个点的 F1 提升,另一半完全没有变化。如果项目紧急,建议先跳过对抗训练,只在数据量少于 5000 条时优先考虑。

4.3 标签不均衡时怎么做:类别权重与 F1 口径

短文本分类的真实业务数据里,标签不均衡是常态而不是异常。电商评论的“好评”可能占 80%,新闻分类的“体育”和“星座”可能相差 10 倍。直接硬训,模型会把多数类学到极致,少数类全部误判成多数类。两个常见解决方向是改损失函数的样本权重,或改评估指标观察重点。这两个方向互不排斥,但先做哪个通常取决于你的业务目标。

import torch.nn as nn class_counts = torch.tensor([12000, 500, 300, ...], dtype=torch.float) class_weights = class_counts.median() / class_counts loss_fct = nn.CrossEntropyLoss(weight=class_weights.cuda())

这段代码的思路是用类别样本量的中位数除以每个类别的数量,少数类获得更大权重。相比1 / count的逆频率方案,中位数归一化不会让极少数类的权重爆炸,训练稳定性更好。注意weight参数最终是通过 loss 层的 softmax 之后的 logit 加权生效的,它对阈值选择没有直接影响——如果你希望少数类的实际召回率更高,还需要在推理时把少数类的预测阈值往低调,这属于决策边界的后处理,和训练权重是两回事。

5. 避坑与排查:中文短文本分类的五个血泪教训

5.1 显存溢出:文本长度剪不剪是两回事

现象:batch size 明明只设了 16,训练刚开始就提示CUDA out of memory,而同一份代码在别人的机器上正常。原因:短文本数据集中混入了几条异常长文本,比如把整篇文章塞进了标题字段。我的排查顺序是这样:第一先看数据集里title_len的最大值,如果超过 500 就说明混入了长文本;第二看自己的max_len设置,如果设了 512,那 tokenizer 会把每条样本都补齐到 512,即便实际内容只有 20 个字,attention 矩阵仍然按 512 计算,显存占用直接翻数倍。解决:把max_len从 512 降到 32,或在Dataset.__getitem__里对超长文本做截断。这也是短文本任务里最容易被忽略的显存杀手,跟 batch size 大是一样的问题。

5.2 训练 loss 正常但验证集不涨:seed 和数据的打乱顺序

现象:训练 loss 从 2.1 降到 0.3,验证集 accuracy 死活停在 0.82 左右。原因排查了很久,最后发现问题出在数据划分上——原本的数据集是按类别顺序排列的,直接train_test_split(test_size=0.1)默认不 shuffle,导致验证集里全是同一批类别的样本。解决:划分前必须显式shuffle=True,同时固定随机种子,保证每次实验的划分一致。另一个排查方向是模型的随机初始化——Bert 类模型的 dropout 在训练模式下会随机丢神经元,如果固定 seed 会让每次实验可比,不固定则验证集分数出现 0.5 个点左右的随机波动。

5.3 同一条数据预测结果不稳定:dropout 与推理模式

现象:训练结束后,对同一条测试数据跑多次预测,结果时对时错。原因:模型默认处于训练模式,dropout 仍然生效,每次前向传播丢掉的神经元不同,输出自然波动。解决:在推理前调用model.eval(),同时用torch.no_grad()包裹推理代码。这个问题在部署时尤其容易出现——加载 checkpoint 后直接预测,没有切到 eval 模式,每 10 条数据出 1 条差异,新手往往误以为是模型权重损坏。

5.4 类别少但模型“迷信”某一类:阈值与后处理

现象:二分类任务,正负样本比例 1:9,模型预测结果里 95% 都是负类。原因:模型把概率阈值贴近了训练集的先验分布,0.5 的默认阈值实际上对应的是“正类概率大于负类”的决策面,而不是后验概率的最大化。解决:在验证集上重新计算最佳阈值,比如用sklearn.metrics.precision_recall_curve找到 F1 最大值对应的阈值,推理时用这个阈值替代默认的 0.5。在短文本场景下,这个操作带来的提升往往比换模型还大——我在一个舆情分类项目里,只调整阈值就把正类 F1 从 0.61 拉到了 0.74。

5.5 字符型文本被错误切分:全角半角与小写化

现象:预测结果里,同一句话的两种写法(全角冒号“:”和半角冒号“:”)得到不同分类。原因:tokenizer 对全角半角字符的处理不是完全等价的。Bert 的词表里不包含全角英文字母,但包含全角标点,所以“Hello:World”和“Hello: World”的 tokenize 结果不同。解决:在clean_text里统一做全角转半角(代码见 2.3 节),不要依赖 tokenizer 的 normalization。这个坑在中文分类里出现频率低,但一旦出现就是系统性错误,会让线上效果无规律波动。

6. 验证与上线:从测试集分数到真实推理的最后一公里

6.1 保存 checkpoint 与推理脚本:不要每次重新训练

训练完成后,第一件事是保存模型和 tokenizer,而不是记录一组指标就关掉终端。transfromers 的save_pretrained会把模型权重和配置一起存到目录,后续加载不需要再指定类别数等参数。

model.save_pretrained("./best_model") tokenizer.save_pretrained("./best_model") # 推理侧加载 from transformers import AutoTokenizer, AutoModelForSequenceClassification tok = AutoTokenizer.from_pretrained("./best_model") model = AutoModelForSequenceClassification.from_pretrained("./best_model") model.eval()

推理侧加载后记得补一句model.eval(),这一点前面避坑章节已经强调过。如果项目上线需要对外提供服务,常见做法是把模型封装成 ONNX 或 TensorRT 格式做加速,但短文本分类性能瓶颈通常在 tokenizer 的 Python 调用开销上,先优化输入预处理比优化模型推理更有效。

6.2 用一个小样本校验集做回归:模型版本迭代的后悔药

模型迭代最怕的不是效果变差,而是变了不知道。我习惯在每次训练后在固定的小样本集上跑一遍预测并保存结果,这个样本集可以只有 200 条,但必须覆盖每个类别以及历史翻车样本。效果分析就按这三步走:先看新模型相对旧模型在哪些类别的预测结果发生变化,再看这些变化里是修正还是误伤,最后决定是否回滚。这一步操作的成本很低,却能避免上线后被线上数据打脸后只能靠回忆排查的窘境,相当于是给自己的模型准备了后悔药。

import json def predict(texts, model, tokenizer, labels): encs = tokenizer(texts, max_length=32, truncation=True, padding=True, return_tensors="pt") with torch.no_grad(): probs = torch.softmax(model(**encs).logits, dim=-1) return [labels[i] for i in probs.argmax(dim=-1)] with open("dev_200_pred.json", "w") as f: json.dump({ "version": "v2", "preds": predict(dev_texts, model, tokenizer, labels), "dates": "2024-06" }, f, ensure_ascii=False)

这里的padding=True表示按批次内最大长度动态补齐,和训练时的max_length策略不同——推理场景下每个 batch 的实际长度不同,动态 padding 可以减少无效计算。

6.3 显存与延迟取舍:短文本场景下的量化与蒸馏

上线环节的最后一个问题是:模型能不能扛住线上流量。Bert-base 有 1.1 亿参数,单条短文本推理在 GPU 上大约 5 到 10 毫秒,在 CPU 上则可能到 50 毫秒以上。如果 QPS 只有几十,CPU 推理加动态 padding 问题不大;如果 QPS 上百,首选方案是把模型转成 ONNX 并开启 int8 量化,在精度损失可控的前提下把延迟降到三分一以内。

python -m transformers.onnx --model ./best_model --feature sequence-classification onnx/model.onnx

这个命令把 transformers 模型转成 ONNX 格式,--feature sequence-classification指定任务类型以保证输出节点正确。转成功后建议用onnxruntime加载做一次推理对比,确认输出与 PyTorch 原模型基本一致再切换。短文本分类模型量化后常见的精度损失在 0.5 到 1 个点之间,如果超过这个范围,先检查是否做了错误的动态轴配置。至于蒸馏,我个人的经验是:先量化再蒸馏,蒸馏收益在短文本上不如长文本明显——短文本本身信息量小,小模型的拟合上限很容易触顶。

说句心里话,这个标题背后做的事并不复杂,但把 Bert/ERNIE 中文短文本分类整套流程走完、踩完坑,基本就摸清了预训练模型落地的大部分套路。我自己第一次做时在 5.2 那条上白折腾了一整天,最后发现是train_test_split没开shuffle,从此把数据划分固定成了每一次实验的第一步。如果你也正卡在某个诡异的效果问题上,先停手,回到数据分布和推理模式上排查,八成能找到原因。希望帮到你。

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

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

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

立即咨询