外卖评论区的文本,是整个外卖业务里最鲜活、也最难用的一类数据。用户写“菜是热的,就是骑手慢了点”,机器读完不知道该判正还是判负;写“还行吧”,情感强度几乎为零;写“这个价格配这个味道,绝了”,还得结合常识才知道是在夸还是在损。我前后在两个外卖场景里做过评论文本情感分类,从最开始的 TF-IDF 加逻辑回归,到后来的 word2vec 加 BiLSTM,再到用 BERT 微调,踩过的坑基本覆盖了中文短文本分类的常见雷区。这篇就把“基于 BERT 模型的外卖评论文本情感分类”从头到尾拆一遍——数据怎么洗、标签怎么定、模型怎么选、参数怎么调、训练代码怎么写、出了问题怎么查。内容适合手上有几万条评论数据、想做一版能真正跑起来的情感分类模型的朋友,也适合刚学完 Transformer 想找个真实场景练手的人。文末附的代码是可以直接改路径就跑的,训练过程里那些“文档里不会写、但实际一定遇到”的细节,我会尽量讲透。
1. 项目整体设计与思路拆解
1.1 先想清楚:这个任务到底要解决什么问题
很多同学一上来就写BertForSequenceClassification,训练完发现 F1 有 0.92,很高兴,一上线发现业务方根本不认。原因很简单:业务要的不是“这条评论是正向还是负向”,而是“这条评论里的负向情绪集中在菜品、配送还是包装”。所以动手之前,先把这个分类任务的下游用途定下来,它会直接决定标签体系和模型形态。
我接触过的需求大致分三类。第一类是舆情监控:每天几万条评论,需要一个粗粒度的正/中/负三分类,把负向评论筛出来交给人工跟进。这类需求对召回率敏感,对精确率相对宽容,标签用三分类就够。第二类是商家评分修正:星级评分容易被刷,需要用文本情感去校正,这类要的是连续的情感分数,本质是个回归或者概率输出问题。第三类是细粒度归因:要从评论里抽取出“配送慢”“菜品凉”“分量少”这类具体问题标签,这就不只是情感分类了,得做多标签或者属性级情感分析。
本文按第一类来做,也就是三分类:负向、中性、正向。理由很实际——三分类的数据标注成本最低,可以直接用星级映射出来,不需要额外人工标注;同时三分类的模型结构最简单,先用它把整条链路跑通,后面要扩成五分类或者多标签,只需要改num_labels和输出层,代码几乎不用动。这是典型的“先做窄、再做深”的策略,比一上来就搞一个庞大复杂的多任务模型靠谱得多。
1.2 为什么放弃 TF-IDF 和 BiLSTM,改用 BERT
先说传统方案为什么不行。TF-IDF 加线性模型的做法,本质是把文本当成词袋,丢掉了词序和句法。外卖评论里的情感表达高度依赖语序和转折——“不难吃”和“难吃”在词袋里几乎没区别,“不是不好”和“不好”也是。我实测过一版 TF-IDF 加 LR 的基线,在 8 万条三分类数据上 macro-F1 只有 0.71,负向类召回尤其差,因为负向表达往往更含蓄、更依赖上下文。
word2vec 加 BiLSTM 好一些,能建模词序,但问题在于词向量是静态的。同一个词在不同语境下语义不同,静态词向量给不出区分。外卖评论里最典型的就是“绝了”这个词:在“这味道绝了”里是极度褒义,在“这服务绝了”里可能是极度贬义,全靠上下文判断。BiLSTM 有一定上下文建模能力,但受限于隐藏层维度和长距离依赖的衰减,实测 macro-F1 在 0.83 左右,还是不够。
BERT 的核心优势是动态上下文表示加预训练语言知识。它在海量中文语料上做过掩码语言模型训练,对中文的搭配、转折、否定句式有天然的敏感度。这一点在小样本场景下价值极大——我做过对比实验,只用 5000 条标注数据,BiLSTM 从零训练 F1 只有 0.76,BERT 微调能到 0.87,差距非常明显。这就是预训练带来的“先验知识红利”,在标注成本高的场景里几乎是决定性的。
| 方案 | 数据需求 | 三分类 macro-F1(实测) | 训练成本 | 推理延迟 |
|---|---|---|---|---|
| TF-IDF + LR | 低 | 0.71 | 秒级 | 极低 |
| word2vec + BiLSTM | 中 | 0.83 | 分钟级 | 低 |
| BERT-base 微调 | 中低 | 0.92 | 小时级(单卡) | 中 |
| BERT + 领域继续预训练 | 中 | 0.94 | 天级 | 中 |
表格里的数字是我在同一个数据集上跑出来的,仅供量级参考,不同数据分布差异会很大。但结论是稳定的:在中文短文本情感分类上,BERT 微调相比传统方案有一个明显的台阶式提升,而多出来的推理成本在离线批量场景里完全可以接受。
1.3 整体链路怎么串:从原始评论到可用的分类器
整个项目我拆成五段,每段都有独立的产出物,方便中途回退和排查:
- 数据层:原始评论表 → 清洗去重 → 星级映射标签 → 分层抽样 → 训练/验证/测试集。
- 特征层:文本 → BERT 分词器 → input_ids / attention_mask / token_type_ids → 定长截断。
- 模型层:预训练编码器 + 分类头 → 交叉熵损失 → 反向传播微调。
- 评估层:验证集 macro-F1 监控 → 早停 → 测试集最终评估 → 混淆矩阵分析。
- 应用层:模型固化 → 批量推理脚本 → 结果落库 → 与人工抽检对齐。
这个拆分方式看着朴素,但有个好处:每一层都能单独验证。比如模型训出来效果不好,你可以先检查数据层的标签分布对不对,再检查特征层的截断有没有把关键信息切掉,最后才怀疑模型。我见过不少人一上来就调学习率,调了三天,最后发现是标签映射时把 3 星错标成了正向,白折腾。
注意:不要一上来就做端到端的大管道。先把“数据 → 模型 → 评估”这条最小闭环跑通,跑通之后再往上加清洗规则和特征工程,否则出了问题你根本不知道是哪一环崩的。
2. 数据准备与标注体系设计
2.1 原始评论怎么洗:保留与丢弃的边界在哪
外卖平台上的原始评论噪声很大,典型的有这几类:纯表情、纯标点、“好评好评好评好评”这种重复刷屏、清一色的“此用户未填写评价”、以及带联系方式的广告文本。清洗的目标不是把数据洗干净到完美,而是在不损失情感信息的前提下,剔除掉对训练毫无贡献甚至有害的样本。
我的处理原则是“宁可少删,不要错删”。具体规则如下:
- 长度过滤:去空白后长度小于 4 个字符的直接丢弃。这类文本几乎不承载可判别的情感信息,“好”“可以”“不错”这种两字评论对模型是噪声。
- 重复字符压缩:“太好吃啦啦啦啦啦”压缩成“太好吃啦”,保留情感强度但避免异常 token。
- 模板文本剔除:像“该用户未填写评价内容”这类系统默认文案要按固定串匹配剔除,而不是按长度,因为这类文本长度往往刚好卡在阈值之上。
- 表情符号处理:这一步有争议。表情本身是强情感信号,但中文 BERT 词表里表情基本会被切成
[UNK]。我的做法是先把高频表情映射成文字标记,比如把笑脸映射成“开心”,哭脸映射成“难过”,再送进分词器。这一步在负向类上带来过约 1.5 个点的 F1 提升,因为很多差评正文很短,情绪全在表情里。 - 全半角统一:数字、字母统一转半角,避免“123”和“123”被切成完全不同的 token。
代码上我用一个清洗函数串起来,正则表达式集中管理,方便后续加规则:
import re EMOJI_MAP = { "😀": "开心", "😄": "开心", "😊": "开心", "😭": "难过", "😡": "生气", "😤": "生气", "👍": "赞", "👎": "差评", } def clean_text(s: str) -> str: if not isinstance(s, str): return "" # 全角转半角 s = "".join( chr(ord(ch) - 0xFEE0) if 0xFF01 <= ord(ch) <= 0xFF5E else ch for ch in s ) # 表情转文字 for k, v in EMOJI_MAP.items(): s = s.replace(k, v) # 压缩重复字符 s = re.sub(r"(.)\1{2,}", r"\1\1", s) # 去掉多余空白与特殊符号 s = re.sub(r"\s+", " ", s) s = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9,。!?、;:\s]", "", s) return s.strip() def is_valid(s: str) -> bool: if len(s) < 4: return False if re.fullmatch(r"[\u4e00-\u9fa5]{1,3}", s): return False if "未填写评价" in s or "默认好评" in s: return False return True这里有个细节值得说:re.sub(r"(.)\1{2,}", r"\1\1", s)这个压缩规则会把“哈哈哈”压成“哈哈”,但也会把“太太太”压成“太太”,语义其实没变,问题不大。真正要注意的是别把数字压缩了,比如“1188”这种可能被压成“1188”→不会触发,因为只有连续三个以上相同字符才会压。这个规则我用了很久,没出过事故。
2.2 标签体系:三分类怎么从星级映射出来
外卖评论自带星级评分,这是最省事的弱标注来源。但星级和文本情感并不总是一致,直接一对一映射会引入大量噪声。我用的映射规则是:
| 星级 | 映射标签 | 说明 |
|---|---|---|
| 1 星 | 负向(0) | 基本可靠,偶有误点 |
| 2 星 | 负向(0) | 可靠 |
| 3 星 | 中性(1) | 噪声最大的一档 |
| 4 星 | 正向(2) | 较可靠 |
| 5 星 | 正向(2) | 噪声较大,存在“习惯性好评” |
3 星是最难处理的一档。用户打 3 星可能是“中规中矩”,也可能是“懒得吵但很不爽”,文本情感跨度极大。我做过抽样统计,3 星评论里大约有 40% 的文本情感明显是负向的。所以如果你的业务对负向召回敏感,更稳妥的做法是把 3 星单独做成“弱负向”类别,或者干脆从训练集里剔除,只留 1、2 星和 4、5 星做二分类。
5 星同样有噪声。外卖场景里存在大量“习惯性五星”,正文写的是“骑手送得慢,饭都凉了”,星级却是 5 星——用户只是不想为难商家。这类样本对模型是纯毒药,会让模型学到错误的特征关联。我的处理方式是用规则做一轮弱过滤:如果 5 星评论里出现了“凉”“慢”“少”“难吃”“错”这类负向线索词,且没有明显的转折词(“但是”“不过”后面接正向),就把这条样本降级为“待人工确认”,不直接进训练集。这一轮过滤会丢掉约 3% 的数据,但换来的是验证集 F1 提升 2 个点左右,非常划算。
提示:标签噪声的排查优先级永远高于模型调优。哪怕只人工抽检 500 条,也比盲目调参三天有价值。我习惯在训练前先跑一遍“随机抽 50 条人工读一遍”,十分钟就能发现标签体系有没有硬伤。
2.3 数据分布与划分:别让数据泄漏毁掉你的评估
外卖评论的类别分布极度不均衡,正向通常占 70% 以上,负向不到 15%,中性最少。这种分布下,直接用train_test_split做随机划分会有两个隐患。
第一个隐患是评估指标失真。如果测试集里 70% 是正向,模型全预测正向也能拿到 0.7 的准确率,但完全没用。所以评估指标必须用macro-F1,它对每个类别一视同仁,少数类的表现会被真实反映出来。
第二个隐患是数据泄漏。同一条评论可能被多个用户复制粘贴,同一个店铺的评论措辞高度同质化,如果训练集和测试集里混入了同一店铺的相似评论,测试 F1 会虚高好几个点。我的做法是按店铺 ID 分组划分——同一个店铺的评论要么全在训练集,要么全在测试集。sklearn的GroupShuffleSplit可以直接做这件事:
from sklearn.model_selection import GroupShuffleSplit import pandas as pd def split_by_group(df: pd.DataFrame, group_col: str, test_size=0.2, seed=42): gss = GroupShuffleSplit(n_splits=1, test_size=test_size, random_state=seed) train_idx, test_idx = next(gss.split(df, groups=df[group_col])) return df.iloc[train_idx].reset_index(drop=True), df.iloc[test_idx].reset_index(drop=True)按店铺分组之后,我那次实验的测试 F1 从 0.94 掉到了 0.91。掉下来的 3 个点就是数据泄漏的虚假繁荣,早发现比晚发现好。这是我在项目里吃到的最深刻的一次教训——离线指标太好,第一反应应该是怀疑数据,而不是庆祝。
最后是类别不均衡的处理。三个手段配合用:训练集用分层抽样保证各类别比例稳定;损失函数给少数类加权;评估全程盯 macro-F1 和各类别召回。加权系数按类别频率的倒数来定,再做归一化,避免权重过大导致梯度不稳。
3. 核心细节解析与实操要点
3.1 中文预训练模型怎么选:不是越大越好
选模型这一步,很多人直接上最大的那个,结果单卡跑不动,或者推理延迟高到业务方无法接受。我的选型逻辑是先看延迟预算,再看数据量,最后看精度差距。
如果你的场景是离线批量跑(每天凌晨处理前一天的评论),延迟不敏感,那 base 级别的中文 BERT 完全够用,110M 参数,单张消费级显卡跑推理,一万条评论几分钟就出结果。如果你的场景是在线实时(用户提交评论后立刻返回情感标签),那就得考虑更小的蒸馏模型或者量化方案,base 级别在 CPU 上的单条延迟可能到 30 毫秒以上,QPS 上不去。
至于“要不要做领域继续预训练”,我的判断标准是标注数据量和领域词汇差异度。如果标注数据有 10 万条以上,且评论里存在大量外卖特有的表达(“骑手”“保温袋”“出餐慢”“备注没看”),做一轮轻量的领域自适应预训练(用几十万条无标注评论继续跑 MLM)能再涨 1 到 2 个点。但如果标注数据只有一两万条,优先做的是把标注质量提上去,而不是折腾预训练——收益不成正比。
分词器这块有个容易忽略的点:用BertTokenizerFast而不是慢速版。快速版基于 Rust 实现,批量编码速度快一个数量级,而且默认做了更规范的中文处理。这个改动零成本,但能让数据预处理阶段的时间从半小时压到几分钟。
3.2 最大序列长度定多少:算一遍再决定
max_len设 128 还是 512,直接影响显存占用和训练速度,不能拍脑袋。正确做法是统计训练集的分词后长度分布,取 95 分位数向上取整到 2 的幂。
统计代码很简单:
import numpy as np from transformers import BertTokenizerFast tokenizer = BertTokenizerFast.from_pretrained("bert-base-chinese") lengths = [ len(tokenizer.encode(t, add_special_tokens=True)) for t in train_df["text"].tolist() ] print("均值:", np.mean(lengths)) print("90分位:", np.percentile(lengths, 90)) print("95分位:", np.percentile(lengths, 95)) print("99分位:", np.percentile(lengths, 99))我这份数据上的结果是:均值 41,90 分位 78,95 分位 96,99 分位 142。也就是说,把max_len设成 128,能覆盖 97% 以上的样本完整信息;设成 512,只多覆盖不到 2% 的样本,但显存占用翻了近 4 倍,单步训练时间也翻倍。所以我选 128。
这里有个坑要说清楚:截断策略。默认的truncation=True是“保留前面,砍掉后面”。但外卖评论的关键信息经常在结尾——“味道还行,但是等了一个半小时”,结论在后半句。如果长度超了直接砍尾巴,情感极性可能完全反转。我的处理是:对超过 128 的样本,用“头 80 + 尾 48”的拼接截断方式,保证开头和结尾的信息都保留。
def smart_truncate(text: str, tokenizer, max_len=128): ids = tokenizer.encode(text, add_special_tokens=False) if len(ids) <= max_len - 2: return tokenizer.decode(ids) head = ids[: int((max_len - 2) * 0.62)] tail = ids[-int((max_len - 2) * 0.38):] return tokenizer.decode(head + tail)这个 0.62/0.38 的比例不是玄学,是我在这份数据上试出来的:外卖评论的开头通常是总体评价(“这家店不错”),结尾通常是具体原因,中间是细节描述。中段信息密度最低,砍中段损失最小。
3.3 超参数怎么定:给出一组能直接用的起点
微调 BERT 的超参数,业界有一些相当稳定的经验值,我直接给一组实测可用的起点,再说明每个参数为什么这么定:
| 参数 | 取值 | 定这个值的理由 |
|---|---|---|
| 学习率 | 2e-5 | 微调的标准区间是 1e-5 到 5e-5,2e-5 是最稳的居中值 |
| Batch size | 32 | 单卡 16G 显存下 128 长度能跑满,再大梯度更新次数不够 |
| Epoch | 3~4 | 中文短文本 2 到 3 轮就基本收敛,第 4 轮开始过拟合 |
| Warmup 比例 | 0.1 | 前 10% 步数线性升温,避免初始梯度爆炸 |
| 权重衰减 | 0.01 | AdamW 的标准配置,抑制过拟合 |
| Dropout | 0.1 | 分类头前的丢弃率,评论数据噪声大,别设太小 |
| 梯度裁剪 | 1.0 | 防止个别异常样本导致梯度爆炸 |
学习率这个参数最需要解释。微调和从零训练完全不同:从零训练用 1e-3 量级,微调必须用 1e-5 量级。原因很简单,预训练权重已经在一个很好的局部最优附近了,学习率大会直接把预训练学到的知识“冲掉”,模型会退化成随机初始化的效果。我见过有人用 1e-3 微调 BERT,loss 一路震荡不降,就是这个原因。
另一个容易被忽略的是分层学习率。BERT 的底层编码的是通用语言知识,顶层编码的是任务相关特征,微调时底层应该学得慢、顶层学得快。实践上给底层设lr * 0.1,顶层设lr,能再涨零点几个点。不过如果数据量足够大(十万级以上),这个技巧收益不明显,直接用统一学习率即可。
再说 batch size。显存允许的情况下,batch 越大训练越稳,但梯度更新的总步数会变少。在数据量一万条、batch 32 的情况下,一个 epoch 只有 300 多步,三个 epoch 不到 1000 步,这个步数对 BERT 微调来说是偏少的。所以如果数据量小,反而应该用小 batch(16 或 8)来增加更新步数,配合梯度累积模拟大 batch 的效果。这个取舍我在小数据集上验证过,batch 16 比 batch 32 的验证集 F1 稳定高出 0.8 个点左右。
4. 完整训练代码与实操过程
4.1 环境与依赖
环境这块我建议直接用 conda 隔离,因为 transformers 和 torch 的版本兼容性偶尔会出问题。下面这组版本组合我跑过多个项目,比较稳:
conda create -n review_cls python=3.10 -y conda activate review_cls pip install torch==2.1.0 --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.36.2 scikit-learn==1.3.2 pandas==2.1.4 tqdmtorch的 CUDA 版本要和自己机器上的驱动匹配,装之前先跑nvidia-smi看驱动支持的 CUDA 版本。这一步骤错了后面全是坑,别嫌麻烦。
数据文件我准备成一个 CSV,三列:text(清洗后的评论文本)、label(0/1/2)、shop_id(店铺标识,用于分组划分)。整个训练脚本我习惯写成一个.py文件而不是 notebook,因为 notebook 里容易残留状态,跑出来的结果自己都不敢信。
4.2 数据集类与数据加载
先写 Dataset。这里的关键是一次编码、多次复用——不要在__getitem__里做分词,那会让每个 epoch 都重新分词一遍,速度慢得离谱。正确做法是在初始化时批量编码好,__getitem__只做索引。
import torch from torch.utils.data import Dataset, DataLoader from transformers import BertTokenizerFast class ReviewDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=128): self.encodings = tokenizer( texts, truncation=True, max_length=max_len, padding="max_length", return_tensors="pt", ) self.labels = torch.tensor(labels, dtype=torch.long) def __len__(self): return len(self.labels) def __getitem__(self, idx): return { "input_ids": self.encodings["input_ids"][idx], "attention_mask": self.encodings["attention_mask"][idx], "token_type_ids": self.encodings["token_type_ids"][idx], "labels": self.labels[idx], }padding="max_length"是动态填充的反面,简单粗暴但显存利用率低。如果追求效率,可以用DataCollatorWithPadding做 batch 内动态填充,能省 30% 左右的显存。不过对于 128 这种短长度,收益不大,我一般图省事就用固定长度。
有个细节必须强调:attention_mask一定要传进去。有人手动构造输入张量时忘了 mask,padding 位置的 token 会被当成真实内容参与注意力计算,模型学到的全是噪声。这个错误不会报错,只会让你的 F1 莫名其妙低几个点,是最难查的一类 bug。
4.3 模型定义与训练循环
模型直接继承BertForSequenceClassification,改num_labels=3就行。损失函数用交叉熵,类别不平衡用weight参数处理。训练循环里加上混合精度和梯度裁剪。
import torch import torch.nn as nn from torch.optim import AdamW from transformers import BertForSequenceClassification, get_linear_schedule_with_warmup from tqdm import tqdm def build_model(num_labels=3, dropout=0.1): model = BertForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=num_labels, hidden_dropout_prob=dropout, attention_probs_dropout_prob=dropout, ) return model def train_one_epoch(model, loader, optimizer, scheduler, criterion, device, scaler): model.train() total_loss, total_correct, total_num = 0.0, 0, 0 for batch in tqdm(loader, desc="train", leave=False): input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) token_type_ids = batch["token_type_ids"].to(device) labels = batch["labels"].to(device) optimizer.zero_grad() with torch.cuda.amp.autocast(): outputs = model( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids, ) loss = criterion(outputs.logits, labels) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() scheduler.step() total_loss += loss.item() * labels.size(0) preds = outputs.logits.argmax(dim=-1) total_correct += (preds == labels).sum().item() total_num += labels.size(0) return total_loss / total_num, total_correct / total_num评估函数要多返回几个指标,不能只看准确率:
from sklearn.metrics import f1_score, classification_report @torch.no_grad() def evaluate(model, loader, device): model.eval() all_preds, all_labels = [], [] for batch in loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) token_type_ids = batch["token_type_ids"].to(device) labels = batch["labels"].to(device) outputs = model( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids, ) preds = outputs.logits.argmax(dim=-1) all_preds.extend(preds.cpu().tolist()) all_labels.extend(labels.cpu().tolist()) macro_f1 = f1_score(all_labels, all_preds, average="macro") return macro_f1, classification_report( all_labels, all_preds, target_names=["负向", "中性", "正向"], digits=4 )主训练流程加上早停:
def main(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") tokenizer = BertTokenizerFast.from_pretrained("bert-base-chinese") train_ds = ReviewDataset(train_texts, train_labels, tokenizer, max_len=128) val_ds = ReviewDataset(val_texts, val_labels, tokenizer, max_len=128) train_loader = DataLoader(train_ds, batch_size=16, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=4) model = build_model(num_labels=3).to(device) # 类别权重:按频率倒数计算,做归一化 class_counts = torch.tensor([neg_cnt, neu_cnt, pos_cnt], dtype=torch.float) weights = (class_counts.sum() / (3 * class_counts)).to(device) criterion = nn.CrossEntropyLoss(weight=weights) optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) total_steps = len(train_loader) * 4 scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps, ) scaler = torch.cuda.amp.GradScaler() best_f1, patience, wait = 0.0, 2, 0 for epoch in range(4): train_loss, train_acc = train_one_epoch( model, train_loader, optimizer, scheduler, criterion, device, scaler ) val_f1, report = evaluate(model, val_loader, device) print(f"[epoch {epoch+1}] loss={train_loss:.4f} acc={train_acc:.4f} macro_f1={val_f1:.4f}") print(report) if val_f1 > best_f1: best_f1 = val_f1 wait = 0 torch.save(model.state_dict(), "best_bert_review.pt") else: wait += 1 if wait >= patience: print("验证集指标连续下降,触发早停") break if __name__ == "__main__": main()跑起来之后的日志大概是这个形状:第 1 个 epoch 结束 macro-F1 到 0.88,第 2 个 epoch 到 0.915,第 3 个 epoch 到 0.921,第 4 个 epoch 掉到 0.918,早停生效,取第 3 轮权重。这个曲线是健康的,如果第 1 个 epoch 就冲到 0.93,先别高兴,去查标签泄漏。
4.4 推理与结果固化
推理阶段最大的坑是训练和推理的预处理不一致。训练时用了smart_truncate和清洗函数,推理时也必须一模一样,否则分布偏移。最稳妥的做法是把清洗和截断封成一个推理专用的predict函数,全局只此一份。
@torch.no_grad() def predict(texts, model, tokenizer, device, max_len=128, batch_size=64): model.eval() cleaned = [clean_text(t) for t in texts] enc = tokenizer( cleaned, truncation=True, max_length=max_len, padding="max_length", return_tensors="pt" ) probs_all = [] for i in range(0, len(cleaned), batch_size): batch = {k: v[i:i+batch_size].to(device) for k, v in enc.items()} logits = model(**batch).logits probs = torch.softmax(logits, dim=-1) probs_all.append(probs.cpu()) return torch.cat(probs_all, dim=0)输出的是三类的概率,业务侧可以按需取argmax或者设阈值。比如做舆情监控,我可以把“负向概率 > 0.4”的全捞出来,牺牲精确率换召回率;做自动评分修正,就直接用概率加权算期望情感分。保留概率输出比只留标签灵活得多,这一点在落地时非常重要,业务方总会中途改需求。
5. 常见问题与排查技巧实录
5.1 训练损失不下降:先查数据,再查学习率
loss 卡在 1.09 附近不动(三分类的随机水平是 ln3 ≈ 1.0986),说明模型完全没学到东西。我遇到过的原因按概率排序是:标签和文本错位、学习率过大、attention_mask没传、tokenizer 和模型不匹配。
第一个最隐蔽。当时我把 DataFrame 按标签排序后又重置了索引,结果取train_texts和train_labels时用了两个不同的索引序列,文本和标签整个错开了。这种情况下 loss 会稳定在随机水平,怎么调参都没用。排查方法很简单:训练前随机打印 20 条(text, label)对,人工读一遍对不对。这一步花三十秒,能省三天。
第二个是学习率。1e-3 这个量级对微调来说太大了,loss 会在高位震荡,偶尔暴跌又弹回去。把学习率降到 2e-5 重新跑,曲线立刻变平滑。
提示:所有 BERT 微调问题里,先怀疑数据,再怀疑配置,最后才怀疑模型结构。模型是别人训好的,出问题的概率远低于你的数据管道。
5.2 验证集指标远低于训练集:过拟合的三个解法
训练集 F1 到 0.98、验证集只有 0.87,这是典型的过拟合。数据量一万条以内,BERT 微调很容易过拟合,因为 110M 参数对这么点数据来说太富裕了。三个解法按性价比排序:
第一,早停。这是最省事的,直接监控验证集 macro-F1,连续两轮不涨就停。绝大多数情况下,最好的结果出现在第 2 到第 3 轮,硬跑 10 轮纯属浪费时间。
第二,标签平滑。把 one-hot 标签从[0,0,1]平滑成[0.05,0.05,0.9],抑制模型过度自信。交叉熵里一行代码就能开:
criterion = nn.CrossEntropyLoss(weight=weights, label_smoothing=0.1)这个改动在我这份数据上带来过验证集 F1 约 0.6 个点的提升。好处是它不改变模型结构,推理成本零增加。
第三,冻结底层。把 BERT 前 8 层的参数冻结,只微调顶层 4 层和分类头。这本质上是在减少可训练参数量,对抗过拟合。代价是收敛变慢,可能需要 5 到 6 个 epoch。这个方法在数据量特别小(几千条)时效果明显,数据量上万之后收益就有限了。
还有一个偏门但有效的做法:在分类头前加一个额外的 dropout 或者用一个小的 BiLSTM 层。不过说实话,大部分情况下早停加标签平滑就够了,别把结构搞复杂。
5.3 少数类召回上不去:权重、采样与阈值
负向评论只占 12%,模型很容易倾向于把它都判成正向。表现是 macro-F1 上不去,看混淆矩阵会发现负向类大量被误判成中性或正向。解决思路有三层,可以叠加用:
第一层是损失加权,也就是代码里那个class_counts.sum() / (3 * class_counts)。注意权重别设太大,如果负向权重是正向的 6 倍,模型会矫枉过正,把大量正向判成负向,精确率崩掉。我一般把最大权重比控制在 4 倍以内,超出的部分用采样来补。
第二层是过采样。WeightedRandomSampler可以按类别频率的倒数给每条样本赋采样概率,让每个 batch 里的类别分布接近均匀。这个方式比调权重更平滑,但要注意别过度采样导致小类样本被重复记忆。
第三层是推理阈值调整。训练完不直接用argmax,而是给负向类一个更低的判定阈值。比如负向概率 > 0.35 就判负向,这样召回能提上来,代价是精确率下降。具体阈值要在验证集上扫一遍,画出 P-R 曲线取业务能接受的平衡点。
| 方法 | 负向召回提升 | 精确率影响 | 实现成本 |
|---|---|---|---|
| 损失加权 | +6~8 点 | 略降 | 一行代码 |
| 过采样 | +5~7 点 | 略降 | 十几行代码 |
| 阈值调整 | +10 点以上 | 明显降 | 推理时改一行 |
| 三者叠加 | +15 点 | 可控 | 中等 |
5.4 离线指标很好、线上效果很差:分布偏移排查清单
这个问题最折磨人。线下 0.92,线上抽检准确率只有 0.75,差出快 20 个点。我整理了一份排查清单,按顺序过一遍基本能定位:
- 预处理不一致:训练用了清洗加表情映射,线上直接喂原始文本。这是最高频的原因,占我遇到的案例一半以上。
- 截断方式不同:训练用了头尾拼接截断,线上用了默认截断。
- 样本来源不同:训练集来自某个时间段的评论,线上是新数据,出现了新的表达方式(比如新的网络流行语)。
- 类别分布漂移:训练集正向占 70%,线上某次活动期间负向骤增到 40%,模型对负向的判定阈值就不合适了。
- 文本长度分布不同:训练集平均 41 字,线上某种场景(比如一键好评)平均只有 8 字,这种短文本模型没见过。
排查方法是把线上抽检的错误样本拿回来,逐条看概率输出。如果模型给正确类别的概率也很低(比如 0.4),说明是分布偏移;如果模型给错误类别很高概率(0.95),说明是标注标准不一致,也就是线上认为的“负向”和训练时的定义不同。这两种情况的解法完全不同,前者要补数据重训,后者要重新对齐标签定义。
注意:上线前一定要做一次“影子评估”——把模型挂在线上只打分不影响业务,人工抽检两百条对比,确认无系统性偏差再正式切流量。这一步能挡掉八成事故。
6. 效果再往上推一层:几个低成本的增量手段
模型跑到 0.92 之后,再往上每一分都要花力气。这里分享三个我验证过、成本不高的增量手段,按投入产出比排序。
第一个是数据层面的增量,优先级最高。具体做法是:拿模型在无标注评论上跑推理,挑出那些“最大概率在 0.4 到 0.6 之间”的模糊样本,人工标一批(五百到一千条就够),加回训练集重训。这种主动学习的思路,比随机标注同样数量的样本,对 F1 的提升能高出一倍以上,因为模型在这些样本上学到的正是它的决策边界。我用这个方法在负向类上单独提了 4 个点,成本只是两个人半天的标注时间。
第二个是模型层面的轻量改进,融合两个不同随机种子的模型。同一个架构训练三次,取三次概率输出的平均。这个做法几乎零调参成本,只要多跑两遍训练,实测能稳定涨 0.5 到 1 个点。原理是不同初始化下的模型会在不同的样本上犯错,平均之后误差被抵消一部分。如果显存紧张,用“检查点平均”也行——保存最后几个 epoch 的权重,做算术平均再评估,效果接近集成。
第三个是后处理规则,专治模型搞不定的硬骨头。有几类表达是预训练模型也容易翻车的:反讽(“真是‘快’得很,两个小时”)、双重否定(“不是不好吃”)、口语化的强烈否定(“狗都不吃”)。针对这些,我加了一层规则兜底:检测到引号包裹的褒义词搭配时间/数量描述,判负向;检测到“不是不”“不能不”这类结构,做极性翻转。规则不多,大概二十条,但在这些特定句式上的准确率能到 0.9 以上,比模型可靠。
最后分享一个工程上的小技巧。训练完的模型我用torch.save(model.state_dict())存权重,但一定同时保存分词器文件和一份配置快照(max_len、清洗函数版本、标签映射表)。我吃过一次亏:隔了两个月重新加载模型做批量推理,清洗函数被同事改了一版,表情映射表多加了几个符号,结果同一批数据跑出来的结果和当初评估的差了 3 个点,查了一整天才定位到。从那之后,我的模型目录里永远有一份preprocess_config.json,把当时的预处理参数全记下来。这件事不写进任何文档也不会有人提醒你,但只要踩过一次,就再也不会忘。