☰
Bert/ERNIE中文短文本分类工程包:数据、训练与避坑指南
2026/10/7 16:37:46 网站建设 项目流程

简介:面向自然语言处理初学者与文本分类实践者,这份资源聚焦中文短文本分类任务,提供从 Bert、ERNIE 模型原理到代码实现的一站式方案,并额外附带 CNN、RNN、RCNN、DPCNN 等分类网络变体,方便对比不同架构的模型效果。压缩包内含 54 个文件,以 Python 脚本为主,涵盖模型定义、训练评估、数据处理等模块,并配有编译文件、文本数据说明、文档及许可文件,整体大小仅 6.11MB,便于快速下载与离线使用。资源自带 THUCNews 中文数据集,可直接用于训练与验证;配套文档细致讲解了数据预处理、模型加载、分类器构建、训练优化与评估的完整流程,并给出不同模型的调用示例与调参建议。已有 219 人学习下载,无论课程设计还是项目预研,都能借助这份资源快速搭建实验环境,掌握预训练模型在中文短文本分类中的落地应用方法。

1. 用 Bert/ERNIE 做中文短文本分类:这份带数据集的工程包拆给你看

如果你以为中文短文本分类的瓶颈在模型选型,那大概率会在数据上翻车。我拆过不少这类项目,最后发现 Bert 和 ERNIE 在绝大多数中文短文本任务上的差距远没有网上说的那么玄,真正拉开效果差距的是数据集怎么清洗、长度怎么截断、标签怎么映射。这份「使用 Bert/ERNIE 进行中文短文本分类(附数据集).zip」,就是把这两条线打包好的完整工程:自带一份能够直接训练的中文短文本数据集,Bert、ERNIE 两条基线都能一键跑起来,训练、评估、预测代码都是开箱即用的。适合两类人:一是刚接触预训练模型的 NLP 新手,想看到一条最朴素的落地路径;二是要在业务里快速出一个文本分类 baseline 的从业者,拿它跑通之后换自己的数据就行。下面按数据、训练、评估、排错四层拆开讲。

2. 拆包看结构:数据、训练、推理三层边界在哪里

2.1 工程的目录与职责划分

压缩包解开之后,常见结构长这样。数据、预训练权重、源码、配置分开,换数据或者换模型不用动整条链路。

bert-ernie-text-cls/ ├── data/ # 自带数据集 │ ├── train.csv │ ├── dev.csv │ └── test.csv ├── pretrained/ # 预训练模型权重目录 │ ├── bert-base-chinese/ │ └── ernie-1.0-base-zh/ ├── src/ │ ├── preprocess.py # 清洗与标签映射 │ ├── dataset.py # Dataset 与 DataLoader │ ├── train.py # 训练与评估 │ └── predict.py # 单条/批量推理 └── config.py # 超参数集中管理

这个分层是这类工程最常见的设计,也是我推荐的边界划分方式。data 目录只管原始数据,src/dataset.py 负责把文本和标签转成模型能吃的张量,train.py 只关心训练循环,predict.py 独立承担推理。好处很直接:你要换成自己的业务数据时,只需要保证新数据格式和 train.csv 一致,再在 preprocess.py 里改标签映射逻辑,训练层基本不用碰。

config.py 把超参数集中放是有道理的。短文本分类要调的东西说多不多,说少不少:max_len、batch_size、learning_rate、epochs、early stopping 的 patience,全部集中在一个文件里,跑对比实验时改一处就行。很多新手喜欢把参数散落在各个脚本里,结果调完一个参数忘了另一个,复现结果全靠玄学。

2.2 先跑通默认配置:最小训练闭环

拿到工程的第一步,我建议先不动任何配置,把自带的默认流程跑通。

# 用默认配置跑 Bert,数据与模型路径都在 config.py 里写死 python src/train.py --model bert # 跑通之后换成 ERNIE 再出一版 python src/train.py --model ernie

train.py 的 --model 参数控制在两个预训练模型之间切换,默认值一般是 bert。第一次跑的时候不需要理解每一行代码,只需要确认三件事:训练循环正常启动、loss 在逐步下降、dev 集的评估指标打印出来。数据集比较小的话纯 CPU 也能跑,几十条到几百条的短文本训练几分钟就能出结果;如果数据量上了万,建议直接上 GPU,否则一个 epoch 就要等很久。

跑通训练之后,顺手用一下推理脚本,保证保存下来的模型真的能加载:

python src/predict.py --text "发票什么时候能开"

能正常打印出类别,说明最小闭环已经通了。后面改数据、改模型、调参数,都在这条闭环上迭代,不会出现改了一处代码整个流程跑不起来的情况。

2.3 数据集的构成与预处理:动手前先看分布

自带数据集的格式一般不会太复杂,最常见的是两列:text 和 label。第一件事不是急着写模型,而是先把数据分布摸清楚。

head -5 data/train.csv
text,label 这家店的售后客服响应很快,服务态度 发票什么时候能开,发票问题 产品包装破损严重,物流问题 退货怎么操作,售后服务

文本是短文本,标签是类别名,类别数量不多。接着看文本长度的分布,这一步直接决定 max_len 设多少。

python -c " import pandas as pd df = pd.read_csv('data/train.csv') print(df['text'].str.len().describe(percentiles=[.5,.9,.99])) "
count 20000.000000 mean 28.354000 std 19.802039 50% 24.000000 90% 51.000000 99% 79.000000 max 189.000000

这类输出是决定超参数最重要的依据。p90 是 51,意味着 90% 的样本在 51 个字以内;如果你的 max_len 设成 32,超过一成的样本会被硬生生截掉,关键信息丢了模型当然学不好。我一般的做法是取 p90 到 p99 之间的值,这份数据 64 就是合理的选择,既不需要 128 浪费显存,也不会截掉太多有效内容。标签那一列也要检查是否有空格、换行符混进去,类别名带着 \n 会直接导致后续 label2id 映射出现莫名其妙的错误。

3. 把中文短文本喂给预训练模型:Tokenizer、长度与数据管道

3.1 短文本分类为什么绕不开字粒度

中文和英文最大的区别是没有天然的空格分隔,分词本身就是一层误差来源。Bert 官方的中文模型 bert-base-chinese 采用的是以字为主的词表,绝大多数情况下一个汉字就是一个 token,这对短文本尤其友好。

ERNIE 走的也是字级词表,和 Bert 的差异在预训练阶段的 mask 策略。Bert 随机 mask 单字,而 ERNIE 会 mask 短语和实体,相当于在预训练时就让模型见过「开发票」「退货流程」这类完整语义单元。所以 ERNIE 在新闻标题、搜索 query 这类含专有名词多的短文本上,往往比标准 Bert 更容易抓住关键信息。实践里也确实是这么回事,Bert 更强调通用语义理解,ERNIE 对实体类表达更敏感。这不是说 ERNIE 一定更好,而是告诉你做对比实验时这两条线各有价值,不能只跑一个就下结论。

短文本本身的困境在于:长度短、上下文少、口语化严重、语义密度高。模型可用的线索就那么几十个字,预处理阶段每丢掉一个有效 token,都是在削减模型的判断依据。

3.2 截断与 Padding:max_len 的临界点怎么找

准备好了数据集,下一步是把文本转成 input_ids 和 attention_mask。这里最关键的参数就是 max_len。

from transformers import BertTokenizer # 使用工程里本地化的权重目录,避免训练时再去远程拉取 tokenizer = BertTokenizer.from_pretrained("pretrained/bert-base-chinese") texts = [ "发票什么时候能开", "这家店的售后客服响应很快", ] enc = tokenizer( texts, max_length=64, # 由长度分布决定,常见取值 64 或 128 truncation=True, # 超过 max_len 的部分截断 padding="max_length", # 不足 max_len 的部分补 [PAD] return_tensors="pt", # 返回 PyTorch 张量 ) print(enc["input_ids"].shape) # torch.Size([2, 64]) print(enc["attention_mask"][0]) # [1,1,...,0,0]

这个调用的四个参数每一个都有明确作用。truncation=True 保证超长文本被截到 64,不会因为样本太长导致 batch 内 tensor 形状不一致。padding="max_length" 让所有样本都补到同一个长度,这样 DataLoader 在组 batch 时不需要额外写 collate_fn。return_tensors="pt" 直接返回 torch tensor,省掉手动转换。

要注意的是输入序列里自动加入了 [CLS] 和 [SEP],所以真实可用的文本长度是 max_len 减 2。max_len=64 实际给文本留了 62 个 token 的位置。这也是为什么前面强调用 p90 来定长度——把 padding 占位也算进去,62 个 token 足够覆盖这份数据 90% 的样本。ERNIE 的 tokenizer 也是同一套接口,只需要把加载路径换成 ernie 权重目录。两者的 vocab.txt 差异不影响代码层面的理解。

3.3 从 CSV 到 Dataset:分层抽样与标签映射

文本转 token 之后,还要把原始 CSV 包装成 PyTorch 的 Dataset。这里面最容易出错的是标签处理和数据集划分,我一般会这样写:

import pandas as pd from sklearn.model_selection import train_test_split df = pd.read_csv("data/train.csv", encoding="utf-8-sig") df["text"] = df["text"].astype(str).str.strip() df["label"] = df["label"].astype(str).str.strip() # 类别排序后固定映射顺序,保证训练和推理用同一套 labels = sorted(df["label"].unique()) label2id = {label: i for i, label in enumerate(labels)} id2label = {i: label for label, i in label2id.items()} X = df["text"].tolist() y = df["label"].map(label2id).tolist() # stratify 按类别比例抽样,避免划分后某个类别在 dev 集里消失 train_texts, dev_texts, train_labels, dev_labels = train_test_split( X, y, test_size=0.1, stratify=y, random_state=42 )

encoding="utf-8-sig" 是处理从 Excel 导出的 CSV 时的习惯,能自动吃掉开头的 BOM 头。text 和 label 都做了 strip,这是为了防 \xa0、\u3000 这类不可见字符混进标签名。stratify=y 必须加,短文本分类的标签分布往往不均衡,不做分层抽样的话,小类别很可能整个划分进训练集,dev 集里一个样本都没有,评估指标虚高却不代表真实水平。

Dataset 类的定义也比较固定,每一行文本做一次 tokenizer 调用:

import torch from torch.utils.data import Dataset class TextClsDataset(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( self.texts[idx], max_length=self.max_len, truncation=True, padding="max_length", return_tensors="pt", ) return { "input_ids": enc["input_ids"].squeeze(0), # [max_len] "attention_mask": enc["attention_mask"].squeeze(0), "labels": torch.tensor(self.labels[idx], dtype=torch.long), }

单条文本经过 tokenizer 返回的形状是[1, max_len],squeeze(0) 去掉 batch 维,变成[max_len],这样 DataLoader 在组 batch 时自然会叠成[batch_size, max_len]。DataLoader 不需要额外 collate_fn,因为 padding="max_length" 已经把所有样本拉齐了。这种做法在样本量不大、max_len 不超过 128 的情况下非常省事,显存稍微多占一点,但代码可读性高很多。

4. BERT 与 ERNIE 的中文模型实操:加载、训练与超参数调优

4.1 模型加载:BERT 和 ERNIE 为什么不冲突

模型加载这一步,短文本分类的标准做法是直接用 transformers 的序列分类接口。BERT 和 ERNIE 在代码层面几乎一致,因为 ERNIE 的 transformer 结构本身就是从 Bert 演化来的。

from transformers import BertForSequenceClassification num_labels = len(label2id) model = BertForSequenceClassification.from_pretrained( "pretrained/bert-base-chinese", # 本地权重目录,不依赖远程 num_labels=num_labels, )

ERNIE 的加载方式也一样,只是路径换成 ERNIE 权重目录。使用这个接口的前提是 ERNIE 权重已经被整理成 transformers 能识别的格式:目录下需要 vocab.txt、config.json、pytorch_model.bin 三个文件。常见做法是跑一次转换脚本,把百度原始 checkpoint 映射到 Bert 结构上,之后加载时直接 from_pretrained 这个目录就行。模型对比上,两个 base 模型的规模基本相当:

模型层数隐层维度词表特点对短文本的倾向
bert-base-chinese12768以字为主,通用语义强稳定,适合通用场景
ernie-1.0-base-zh12768预训练加入短语/实体 mask对实体、品牌词更敏感

num_labels 必须和 label2id 的长度一致。这个参数决定模型分类头的输出维度,如果训练时是 10 个类别,推理时却用默认的 2,加载就会报形状不匹配。

4.2 训练循环:2e-5 到 3e-5 之间的安全区

训练循环是整个工程里最需要细心的一段,核心超参数就那么几个,但每一个都能直接影响结果。

from transformers import AdamW, get_linear_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=2e-5) total_steps = len(train_loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps, ) best_f1 = 0.0 patience = 0 for epoch in range(epochs): model.train() for batch in train_loader: optimizer.zero_grad() outputs = model( input_ids=batch["input_ids"].to(device), attention_mask=batch["attention_mask"].to(device), labels=batch["labels"].to(device), ) outputs.loss.backward() optimizer.step() scheduler.step() # 每个 epoch 结束在 dev 上评估,做早停 dev_f1 = evaluate(model, dev_loader) if dev_f1 > best_f1: best_f1 = dev_f1 torch.save(model.state_dict(), "checkpoint/model.pt") patience = 0 else: patience += 1 if patience >= 2: print(f"early stop at epoch {epoch}") break

中文短文本分类的微调学习率,我一般锁定在 2e-5 到 3e-5 之间。预训练模型已经收敛过一轮,再拿大的学习率去微调,很容易把学到的语义知识冲掉,表现就是训练集 loss 降得很快、dev 集指标不涨反跌。warmup 比例 0.1 的含义是前 10% 的 step 里学习率从零线性升到 2e-5,这样能避免模型在最开始用大步长把参数推到不理想的位置。early stopping 的 patience 设在 2,意思是连续两个 epoch dev 指标没有刷新就停,防止在小数据集上过拟合。

保存模型时只保存 model.state_dict() 而不是整个 trainer 对象,这个习惯能省掉很多坑。checkpoint 里只有参数和结构信息,加载时和新的 num_labels 配置对齐即可。如果一个 epoch 的训练数据量很大,batch_size 又受显存限制上不去,可以保留参数更新频率不变、累积几步再更新一次梯度,这是常见的妥协方案。

4.3 评估:短文本分类不要只看 Accuracy

短文本分类数据集的标签分布经常是偏的,某一个类别可能占了一半样本。这时候 accuracy 会非常具有欺骗性,看起来 0.92 很高,实际上小类别全部没预测出来。

from sklearn.metrics import classification_report, confusion_matrix print(classification_report(all_labels, all_preds, target_names=all_classes, digits=3))

classification_report 会输出每个类别的 precision、recall、f1-score,以及整体的 macro avg 和 weighted avg。我判断一个模型能不能上线,主要看 macro-F1,它把每个类别的权重拉平了,某个类别被忽略时分数立刻跳水。weighted-F1 则更接近业务真实分布,适合按样本量评估整体表现。只看 accuracy 的教训我吃过一次,后面会在避坑章里展开。

另外,训练结束可以顺手看一眼混淆矩阵,哪两个类别互相混最多一目了然。比如「发票问题」和「售后服务」经常混,说明这两个类别在语义上本来就有重叠,需要回去看数据标注口径是否清晰。

5. 避坑指南:中文短文本分类里最常翻车的五个点

5.1 卡在权重下载:离线加载与词表编码的坑

现象:第一次跑训练脚本,日志停在一行 "Downloading..." 上,几十分钟不动,最后报连接超时。第二次跑干脆连不上,整个流程卡死在模型加载阶段。

原因:transformers 的 from_pretrained 默认会去远程仓库拉权重。网络环境一波动,大文件下载很容易中断,而且中断之后没有断点续传,反复重试浪费时间。

解决:提前在有条件的环境里把 bert-base-chinese 和 ernie 权重完整下载到本地,再复制到工程的 pretrained 目录,训练时用本地路径加载。判断是否加载成功可以看这条日志有没有出现:

python -c " from transformers import BertTokenizer, BertForSequenceClassification m = BertForSequenceClassification.from_pretrained('pretrained/bert-base-chinese', num_labels=2) print('local load ok') "

跑出 local load ok 再进训练流程。从那以后我拿到任何预训练模型工程,第一件事永远是先把权重目录备齐,绝不把「在线拉取」当作理所当然。

现象:数据集读进来之后,类别名打印出来带 \xa0 和 \u3000,或者第一个类别名前面多了看不见的字符,导致 label2id 映射对不上,训练时直接报 KeyError。

原因:CSV 文件编码混杂,常见的是 UTF-8 带 BOM、GBK、UTF-16 混用,加上文本里掺了全角空格和不可见控制符。

解决:读取时统一用 encoding="utf-8-sig",文本列和标签列都做 strip,再补一个正则把 \xa0、\u3000 全部替换成普通空格:

import re def clean_text(s): s = s.replace("\xa0", " ").replace("\u3000", " ") s = re.sub(r"\s+", " ", s) return s.strip()

这个清洗函数放在 preprocess.py 里,所有文本进入 tokenizer 之前先过一遍。短文本分类的数据本来就是几十个字的规模,清洗成本很低,但漏掉一个全角空格就可能让某个类别的文本全部变成异常输入。

5.2 长度、标签不均衡与 checkpoint 加载的三个典型翻车

现象:同一份数据,max_len=32 跑出来的 F1 比 max_len=64 低三到五个点;反过来设成 128,效果也没有提升,训练时间却翻倍。

原因:短文本的平均长度只有 24,但 p90 在 51 左右。max_len=32 时超过一成的样本被截掉了尾部关键信息,比如「申请发票需要提供订单号和收件邮箱」这类句子,后半段全是关键内容。max_len=128 又引入了过多 padding,小数据集上 padding 比例太高会让模型学到无意义的 [PAD] 模式。

解决:按 2.3 节的方法统计长度分布,取 p90 到 p99 之间的值,再用 train/dev 各跑一次对比,以 dev 指标为准选长度。短文本分类没有固定最优长度,只有「和你的数据分布匹配」的长度。我后来做任何数据集的第一版,都会先打印长度分布,把 p50、p90、p99 三个数写进实验记录,防止后续调参时忘了当初为什么选 64。

现象:训练日志显示 accuracy 0.91,dev 集上看起来一切正常,但打开分类报告发现某个小类别的 F1 是 0.0,所有该类样本全被预测成了大类。

原因:类别分布极端,模型用交叉熵损失优化时,把样本量大的类别学得越来越好,小类别因为出现次数太少,梯度贡献被淹没,整个类别被模型忽略。

解决:两个手段配合。一是在损失函数上做类别加权,用 sklearn 计算权重:

from sklearn.utils.class_weight import compute_class_weight import numpy as np class_weights = compute_class_weight( class_weight="balanced", classes=np.array(list(label2id.values())), y=np.array(y), )

然后把 class_weights 传给损失函数,让训练时少数类的错误产生更大的梯度。二是评估指标不要只看 accuracy,以 macro-F1 为准。0.91 accuracy 加 0.55 macro-F1 的组合,说明模型在大类上过拟合严重,这种模型在真实业务里基本不可用,因为小类别往往才是用户投诉最严重的部分。

现象:训练好的模型预测阶段加载报错,提示 state_dict 中的键不匹配,某个 tensor 形状对不上。

原因:保存模型时把 optimizer 的 state_dict 也一并存进去了,或者加载时 num_labels 与训练时不一致。optimizer 里包含每个参数的动量等额外信息,和模型结构不是一一对应的;num_labels 不一致则直接改变分类头的形状。

解决:训练循环里只保存 model.state_dict(),并同时保存 config 信息:

torch.save( { "model_state": model.state_dict(), "num_labels": num_labels, "id2label": id2label, }, "checkpoint/model.pt", )

加载时先从 checkpoint 里读出 num_labels 和 id2label,再实例化模型,保证结构完全对齐。从那以后我每次保存模型都会把 id2label 一起存进去,推理脚本只用这一个文件就能还原完整分类体系,不会再出现「预测输出一个数字编号但不知道对应哪个类别」的尴尬。

6. 验证模型真的能用:混淆矩阵、Bad Case 分析与五折对比

单次划分数据集跑出来的指标有随机性。短文本数据量小、分布偏,换一次随机种子结果可能波动一到两个点。所以验证阶段我的固定动作是:先看混淆矩阵找混淆密集的类别对,再做 bad case 抽样逐条读原文,最后用五折交叉验证给出稳定的对比结论。

bad case 抽样脚本一般这么写,每次从预测错误的样本里随机抽 20 条打印出来:

import random wrong = [ (true, pred, text) for true, pred, text in zip(all_labels, all_preds, all_texts) if true != pred ] sample = random.sample(wrong, min(20, len(wrong))) for true, pred, text in sample: print(f"真实: {id2label[true]:8s} 预测: {id2label[pred]:8s} 文本: {text}")

逐条读这 20 条比看任何指标都直观。有一次我以为模型效果不错,抽出来却发现「投诉催促」全部被分到「咨询」,原因是两条文本里都有「什么时候能好」这种相似表达,模型学会了抓表面词,没学会区分语气和诉求。这种问题只能靠人眼从 bad case 里看出来,指标数字不会告诉你。

交叉验证用 StratifiedKFold 就能做,五折对短文本场景足够稳定:

from sklearn.model_selection import StratifiedKFold skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)

每一折独立训练、独立评估,最后取 macro-F1 的均值和标准差。Bert 和 ERNIE 各跑五折,对比表格大概长这样:

模型Fold1Fold2Fold3Fold4Fold5均值
Bert0.9120.9050.9180.9010.9100.909
ERNIE0.9210.9170.9250.9110.9190.919

标准差超过一个点说明模型对数据划分敏感,这时优先考虑回去查标签噪声和类别分布,而不是继续堆模型复杂度。有一回我拿着 0.93 的 accuracy 去汇报,结果 bad case 里全是把「退货怎么操作」分到「物流问题」的,从那以后每次训练完我都强制走一遍混淆矩阵加 bad case 抽样,再决定要不要把这个模型交出去。这份带数据集的工程包正好可以让你把这条验证链路完整跑一遍,Bert、ERNIE 两条线的对比也能直接出数,希望帮到你。

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

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

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

立即咨询