ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Bert/ERNIE中文短文本分类实战:从微调到部署避坑指南

Bert/ERNIE中文短文本分类实战:从微调到部署避坑指南 简介面向自然语言处理入门者与开发者的中文短文本分类实战压缩包聚焦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_!_, headerNone, 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 transformers4.36.0 tokenizers0.15.0 datasets2.16.1 pip install torch2.1.0 --index-url https://download.pytorch.org/whl/cu118PyTorch 的版本要对比自己的 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_labels12) class ShortTextDataset(Dataset): def __init__(self, texts, labels, max_len32): 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_lengthself.max_len, truncationTrue, paddingmax_length, return_tensorspt) 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其中truncationTrue表示超长截断paddingmax_length把每条样本统一补齐到 32 个 token。return_tensorspt让输出直接是 PyTorch 张量省去手动转换。对短文本来说max_len 32 基本覆盖了 95% 的样本设太短损失信息、设太长浪费显存且引入大量 padding 噪声。训练循环本身用 Trainer 还是手工写取决于你后续要做什么。如果只是跑通流程并拿到一个 baseline直接用Trainer最省事from transformers import Trainer, TrainingArguments args TrainingArguments( output_dir./output, learning_rate2e-5, per_device_train_batch_size32, num_train_epochs3, weight_decay0.01, logging_steps100, ) trainer Trainer(modelmodel, argsargs, train_datasettrain_ds, eval_datasetdev_ds) trainer.train()参数说明learning_rate2e-5是全量微调的标准起步值新人最容易犯的错是沿用预训练阶段的 1e-4 级学习率结果模型权重被冲毁loss 直接发散。per_device_train_batch_size32适用于 12GB 显存的卡短文本 max_len 32 的情况下这个批次不会爆显存。weight_decay0.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, averagemacro))这里用的是 Trainer 自带的predict方法返回的对象里.predictions是模型 logits 输出再套一层argmax得到预测类别。averagemacro是先对每个类别算 F1 再取均值等价于给每个类别相同的权重不关心样本量差异。第一版分数出来后建议把这个值记录成 baseline后续每调一个参数都拿它做对比而不是凭感觉判断“好像变好了”。4. 必调参数与调优经验学习率、MaxLen、批次与权重衰减4.1 三个直接影响收敛的参数学习率、MaxLen、Batch Size短文本分类的微调实验里我用过的参数组合里影响最大的是学习率其次是max_len最后才是 batch size。这个排序和很多人的直觉相反但原因很实际预训练模型已经收敛到一个较优的局部最优点微调的本质是在这个点附近做小幅迁移学习率过大直接飞出最优邻域过小又难以适配下游任务。Bert 类模型在短文本任务上的安全区间是 2e-5 到 5e-5ERNIE 可以放宽到 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 类模型上通常不明显但它直接决定了学习率的上限。小 batch16配合大学习率5e-5容易震荡大 batch64配合小学习率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, patience2): 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, ...], dtypetorch.float) class_weights class_counts.median() / class_counts loss_fct nn.CrossEntropyLoss(weightclass_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_size0.1)默认不 shuffle导致验证集里全是同一批类别的样本。解决划分前必须显式shuffleTrue同时固定随机种子保证每次实验的划分一致。另一个排查方向是模型的随机初始化——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 的词表里不包含全角英文字母但包含全角标点所以“HelloWorld”和“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_length32, truncationTrue, paddingTrue, return_tensorspt) 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_asciiFalse)这里的paddingTrue表示按批次内最大长度动态补齐和训练时的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从此把数据划分固定成了每一次实验的第一步。如果你也正卡在某个诡异的效果问题上先停手回到数据分布和推理模式上排查八成能找到原因。希望帮到你。本文还有配套的精品资源点击获取
返回列表