☰
基于BERT的外卖评论情感分类实战:清洗、训练与badcase复盘
2026/11/2 3:26:10 网站建设 项目流程

外卖评论的情感分类看着像个“跑个模型就完事”的小任务,真上手才会发现,脏数据、模糊标签、反讽句式、长短文本混杂这几个问题会轮番教你做人。我这段时间把一套基于 BERT 模型的外卖评论文本情感分类方案从零跑通,从语料清洗、权重选型、训练超参到 badcase 复盘完整走了一遍,踩的坑比想象中多。下面把完整代码和训练过程摊开来写,适合两类人看:一类是想把文本分类真正落地到业务里的开发者,另一类是刚学完 Transformer 想找个真实项目练手的学生。不需要你有多深的深度学习背景,但至少得能看懂 PyTorch 的训练循环,知道交叉熵在算什么。文中涉及的数据全部做了脱敏和泛化处理,代码可以直接替换成自己的语料跑起来。

1. 外卖评论情感分类这件事,为什么值得用 BERT 重做一遍

1.1 从一条真实差评说起:词典法和传统机器学习的翻车现场

先看几条我从语料里随手挑出来的评论,感受一下这个任务的真实难度。“菜是真不错,就是等了五十分钟,凉了”,这条打了 4 星,但里面既有正面评价又有明确的负面抱怨;“呵呵,这个速度,我谢谢你们了”,三星,字面上全是礼貌用语,语义上是纯粹的不满;“味道一般般吧,能吃,不难吃”,两星,没有出现任何传统情感词典里的负向词。这三条如果交给基于情感词典的方案去做,第一条会被“不错”拉成正向,第二条会因为“谢谢”被判成正向,第三条连一个情感词都匹配不上,只能落到中性或者默认极性里。

这就是词典法的根本短板:它只看词,不看词与词之间的关系,更不看语序和上下文。有人会说那就上 TF-IDF 加朴素贝叶斯或者 SVM,效果确实比词典法好一截。我在同一份语料上做过对比,TF-IDF + LinearSVC 在二分类任务上能到 0.86 左右的准确率,听起来还行。但你把它拆到“强负面”这个子类上一看,召回只有 0.6 出头。原因很直接:外卖评论里表达不满的方式太丰富了,“不太行”“跟图片不太一样”“下次不会点了”“客服态度你品品”,这些表达在词袋模型里根本没有共享特征,模型学不到它们之间的共性。

还有一类更隐蔽的问题:外卖评论里有大量口语化缩写、错别字、拼音首字母、表情符号转写。“yyds”“绝绝子”“无语子”“就这?”“蚌埠住了”,n-gram 特征维度爆炸,泛化能力却极差。传统方案要靠人工构造大量特征模板去兜,工程量巨大且很难覆盖长尾。

1.2 BERT 在这个任务上到底带来了什么改变

BERT 的价值不在于“用了预训练模型”这六个字本身,而在于它把这三件事一次性解决了。第一是上下文建模,自注意力机制让“不错”和“等了五十分钟”在同一个句子里互相看见,模型能学到“虽然……但是……”这种转折结构里后半句权重更高。第二是子词切分,中文 BERT 用字级别加上 WordPiece,能缓解错别字和未登录词问题,像“无语子”这种网络造词,即使整体没见过,也能被切成若干常见字,靠组合语义推断出来。第三是预训练阶段已经吸收了大量通用语料里的语言规律,你只需要用几千条标注数据做微调,就能把通用语言能力迁移到外卖评论这个垂直领域上。

我自己实测的对比数据大概是这样:同一份 2 万条标注语料,二分类任务上,TF-IDF + LinearSVC 的宏平均 F1 约 0.84,中文 BERT 微调后能到 0.93 左右;三分类(负向、中性、正向)的差距更大,传统方案 0.72,BERT 到 0.88。这个提升幅度在业务侧就是能不能用的区别。

但这里要说清楚一个前提:数据量少于 2000 条的时候,别急着上 BERT。小样本下微调很容易过拟合,反而不如 SVM 稳。我建议的起步线是每类至少 800 到 1000 条,如果某一类特别少,先做数据增强或者干脆合并类别。

1.3 任务边界的划定:三分类还是二分类,标到什么粒度

动手前必须先把这个想清楚,不然后面所有工作都要返工。我见过不少项目一上来就说“做情感分析”,结果标注的时候一半人按三档标,一半人按五档标,最后标签体系自己打架。

我的经验是先问业务要什么。如果下游是“差评预警”,那二分类就够了,把负向单独拎出来,正负样本比控制在 1 比 3 以内都还能训;如果下游要做“服务质量看板”,那三分类(负向 / 中性 / 正向)更有价值,因为“味道一般般”这种中间态在运营分析里是有意义的;如果要做星级预测,那就是五分类,但五分类的标注一致性会明显下降,相邻星级的边界极其模糊。

我这次选的是三分类,标签定义写死成下面这样,并且标注同学人手一份:

标签判定标准典型例子
负向 (0)明确表达不满、失望、投诉意图,或明确表示不会再消费“等了快一小时,汤都洒了,不会再点”
中性 (1)客观陈述为主,褒贬都有但都不强烈,或仅描述事实“分量还可以,口味偏咸,价格正常”
正向 (2)明确表达满意、推荐意图,或使用较强的褒义词“这家真的可以,送得快,菜还是热的”

提示:标签定义里一定要写“判定标准”而不是只写类别名。只写“正/中/负”三个字,不同标注员的阈值能差出 20% 以上。

另外一个常被忽略的点:星级不能直接当标签用。外卖平台的星级是综合了配送、包装、商家服务等多个维度的结果,很多人菜品不满意但因为骑手态度好给了四星。我做过交叉验证,星级和人工文本情感标注的一致率只有 78% 左右。所以要么纯人工标,要么用星级做初筛加人工复核,绝不能直接映射。

2. 语料从脏到干净:外卖评论的清洗链路与标签构建

2.1 外卖评论语料的真实长相:长度分布与噪声类型

先把数据摸一遍再动手写清洗脚本,这是我吃过亏之后养成的习惯。我手上这批语料大概 3 万多条,长度分布的统计结果如下:

import pandas as pd import numpy as np df = pd.read_csv("reviews_raw.csv") df["char_len"] = df["text"].astype(str).str.len() print(df["char_len"].describe(percentiles=[0.5, 0.8, 0.9, 0.95, 0.99]))

跑出来的结果大致是:中位数 23 个字,80 分位 46 个字,95 分位 108 个字,99 分位 220 个字,最长的一条 800 多字。这个分布很有代表性——外卖评论是典型的长尾短文本任务,绝大多数样本很短,但少数长文本信息密度最高。

长度分布直接决定了后面max_len的选择,也决定了数据清洗的策略。短文本要去掉那些无意义的填充词,长文本要防止截断丢掉关键信息。噪声类型我统计了一下,主要是这几类:

  • 纯符号与表情:只有“😀😀😀”或者“。。。”这种,占比约 3%,直接删。
  • 默认好评:系统自动生成的“此用户未填写评价内容”,占比不小,必须识别并剔除,否则模型会学偏。
  • 重复文本:同一个用户短时间内在多家店铺刷同样的评论,或者商家自己刷单,占比约 5%。
  • 营销内容:包含手机号、二维码描述、拉群信息的,占比 1% 到 2%。
  • 极度口语化的断句:像“快 好 便宜 下次还来”这种以空格分词的,需要重新拼接。

这里有个容易被忽略的细节:默认好评如果不清掉,会让正向类的先验概率严重偏高。我第一批数据没注意这个,训出来的模型对任何输入都倾向于判正向,最后查了半天才发现是数据泄漏。

2.2 标签从哪来:星级映射的陷阱与人工复核策略

前面说过星级不能直接当标签。我实际采用的是“三层过滤”策略。

第一层用星级粗筛,把 1 到 2 星归到候选负向池,3 星归到候选中性池,4 到 5 星归到候选正向池。这一层只做分流,不做最终判定。第二层用规则做冲突检测,比如 5 星但文本里出现“难吃”“拉肚子”“投诉”这类强负向词的,全部打上待复核标记。第三层是人工抽检,负向池和中性池全量复核,正向池按 30% 抽样复核。

三层走完之后,我得到的标签质量大致是这样:正向类一致率 96%,负向类 94%,中性类只有 81%。中性类的标注一致性天生就低,因为它是个“兜底类别”,很多标注员对“不够强烈的负向”和“中性”的边界理解不一样。针对这个问题我做了两件事,一是给中性类补了一份 30 条的对齐样例,标注前先做一轮校准;二是把“褒贬都不强烈”作为唯一判定条件写进规范,把“略微不满”强行归到负向。

类别不平衡也是必须处理的。原始分布大概是正向 60%、负向 30%、中性 10%。中性类样本太少会导致模型几乎不预测它。我的处理方式是先对负向和中性做回译增强,把中性类补到 3000 条左右,然后再训。回译用的就是翻译模型来回翻一遍,虽然会引入一些语序噪声,但实测对提升小类召回有帮助。

注意:增强数据只加进训练集,验证集和测试集必须是纯人工标注的原始数据,否则评估结果会虚高。

2.3 清洗动作清单与不可省略的去重逻辑

清洗脚本我写成了一个可以链式调用的函数集合,核心动作如下:

import re def clean_text(text: str) -> str: if not isinstance(text, str): return "" # 1. 去除 HTML 标签与转义字符 text = re.sub(r"<[^>]+>", "", text) text = text.replace("&nbsp;", " ").replace("&amp;", "&") # 2. 全角转半角,统一标点 text = text.replace(",", ",").replace("!", "!") # 3. 去除手机号、URL、微信号等营销信息 text = re.sub(r"1[3-9]\d{9}", "", text) text = re.sub(r"https?://\S+", "", text) text = re.sub(r"(微信|vx|VX|v信)[::]?\s*\w+", "", text) # 4. emoji 统一替换成占位符,保留情感信息 text = re.sub(r"[\U0001F300-\U0001FAFF]", " [表情] ", text) # 5. 压缩连续重复标点与空白 text = re.sub(r"([!?。,~])\1{2,}", r"\1", text) text = re.sub(r"\s+", " ", text).strip() # 6. 过滤过短与无意义文本 if len(re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9]", "", text)) < 3: return "" return text

去重这块我要单独强调,因为它是很多人会偷懒跳过的一步。我的去重逻辑分三级:完全相同的文本直接去重;去掉标点和空格后相同的做二次去重;长度超过 30 字且编辑距离小于 3 的,视为近似重复,只保留一条。第三级用的是difflib.SequenceMatcher,全量比对太慢,我用文本长度分桶后再在桶内比较,把耗时从几十分钟压到了几分钟。

最后还有一个“隐形污染”需要处理:验证集和训练集之间的近似重复。如果同一条评论在两边都出现,评估结果会严重虚高。我最终的做法是按用户 ID 做分组划分,同一个用户的所有评论要么全在训练集,要么全在测试集,这样能避免用户风格泄漏。

3. 权重选择与环境搭建:中文预训练模型怎么挑

3.1 环境版本锁定,这一步不锁后面全是玄学

深度学习项目最怕的就是“昨天还能跑今天报错”。我把依赖固定成了下面这个组合,实测稳定:

python==3.9 torch==2.0.1 transformers==4.35.2 scikit-learn==1.3.2 pandas==2.0.3 numpy==1.24.3

transformers的版本尤其要锁死。4.30 之前和之后在AutoModelForSequenceClassification的num_labels参数处理上有一点点差异,Trainer的 API 变化更大。我一开始用的是 4.40,后来发现某些权重加载时pooler层会报 shape 不匹配的警告,降回 4.35 之后安静了。

显存方面,我用的是一张 16G 的卡,max_len=128、batch_size=32的情况下,bert-base级别的模型占用大约 6 到 7G,开混合精度之后能压到 4G 左右。如果你只有 8G 卡,把 batch 降到 16,同时把梯度累积步数设成 2,效果基本等价。

还有一个细节是随机种子。文本分类这种小数据集微调,不同种子之间的 F1 波动能到 1 到 2 个点。我固定了torch、numpy、random三处种子,并且开启了torch.backends.cudnn.deterministic,虽然会慢一点,但结果可复现,方便调参对比。

import os, random, numpy as np, torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) os.environ["PYTHONHASHSEED"] = str(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False

3.2 三个候选权重的对比与最终选择理由

中文预训练权重可选的不少,我实际测了三个,对比结果如下:

权重词表大小参数量三分类宏 F1单条推理耗时(ms)
bert-base-chinese21128102M0.8718.2
chinese-roberta-wwm-ext21128102M0.8848.5
chinese-macbert-base21128102M0.8878.4

差距不算大,但 roberta-wwm 和 macbert 在小类召回上明显更稳,尤其是中性类,比原生 bert-base-chinese 高了 3 个点左右。原因我理解是这两个权重在预训练阶段做了全词掩码,对中文的词语边界更敏感,而外卖评论里“不太行”“一般般”这类词恰好依赖词组级别的理解。macbert 用了相似词替换的掩码策略,对错别字和口语化变体的鲁棒性稍好一些。

最终我选了chinese-roberta-wwm-ext,理由很实际:一是效果和 macbert 差距在噪声范围内,二是这个权重在社区里用得更多,遇到问题更容易搜到答案,三是它的推理速度和 bert-base 基本一致,没有额外成本。

加载代码很简单,但有两个参数必须显式指定:

from transformers import AutoTokenizer, AutoModelForSequenceClassification MODEL_NAME = "hfl/chinese-roberta-wwm-ext" NUM_LABELS = 3 tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) model = AutoModelForSequenceClassification.from_pretrained( MODEL_NAME, num_labels=NUM_LABELS, ignore_mismatched_sizes=True, )

ignore_mismatched_sizes=True这个参数很关键,因为预训练权重的分类头是二分类的(NSP 任务),改成三分类必然 shape 不匹配,加上这个参数后分类头会被重新初始化,不会报错中断。

3.3 max_len 怎么定:用分位数而不是拍脑袋

max_len是文本分类里最容易被随手设成 128 或者 512 的参数。设小了丢信息,设大了浪费显存还降低吞吐。正确的做法是看你的长度分位数。

我统计下来的结果是:覆盖 95% 样本需要 108 个 token,覆盖 98% 需要 160 个 token。考虑到 BERT 分词后 token 数通常比字数略少(中文按字切,但标点和数字会被合并),我最终取max_len=128,能覆盖大约 96% 的样本,剩下 4% 的长文本被截断。

被截断的这部分要不要管?我做了一个实验:单独把这批超过 128 个 token 的样本拿出来,看它们的情感极性是否依赖后半段内容。结果是,长文本里确实有一部分是“前面夸后面骂”的结构,截断会丢关键信息。处理办法有两种,一种是简单粗暴地把max_len提到 256,代价是显存翻倍、训练时间增加 60% 左右;另一种是把长文本切块,分别推理后做注意力加权融合。

我选了个折中方案:max_len=160。这个长度覆盖了 98% 的样本,显存只比 128 多了 25%,训练时间增加大约 20%,性价比最高。如果你的业务场景里长文本特别多,那就老老实实上分块方案,别嫌麻烦。

4. 训练代码的骨架:从 Dataset 到 trainer 循环

4.1 数据集封装与动态 padding 的实现

很多人写Dataset的时候喜欢在__getitem__里直接padding='max_length',这样每条样本都会被填到最大长度,短文本后面全是[PAD],计算量浪费非常严重。外卖评论的绝大多数样本只有二三十个字,全量填充到 160 相当于浪费了七八成的算力。

正确的做法是padding='max_length'只在最后 collate 阶段做,__getitem__里只做截断。这样每个 batch 内按真实最大长度填充,一个 batch 里如果全是短文本,实际序列长度可能只有 40。我实测这个改动让训练速度提升了将近一倍。

import torch from torch.utils.data import Dataset class ReviewDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=160): 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): encoding = self.tokenizer( self.texts[idx], truncation=True, max_length=self.max_len, padding=False, # 关键:这里不填充 return_attention_mask=True, ) return { "input_ids": encoding["input_ids"], "attention_mask": encoding["attention_mask"], "labels": int(self.labels[idx]), } def collate_fn(batch): max_len = max(len(item["input_ids"]) for item in batch) input_ids, attention_masks, labels = [], [], [] for item in batch: pad_len = max_len - len(item["input_ids"]) input_ids.append(item["input_ids"] + [0] * pad_len) attention_masks.append(item["attention_mask"] + [0] * pad_len) labels.append(item["labels"]) return { "input_ids": torch.tensor(input_ids, dtype=torch.long), "attention_mask": torch.tensor(attention_masks, dtype=torch.long), "labels": torch.tensor(labels, dtype=torch.long), }

注意[PAD]的 id 是 0,这个在中文 BERT 系列里是一致的,但如果换成别的权重一定要确认一下tokenizer.pad_token_id,写死 0 有风险,稳妥点直接用self.tokenizer.pad_token_id。

4.2 超参设置背后的计算过程

超参不是抄来的,得算。我按下面这个思路定的:

学习率方面,BERT 微调的常规区间是 1e-5 到 5e-5。数据量越小、任务越接近预训练任务,学习率可以越低。我的数据是 3 万条左右的三分类,属于中等规模,取 2e-5。试过 5e-5,训练 loss 下降很快但验证集 F1 在第 2 个 epoch 之后就开始掉,典型过拟合;试过 1e-5,收敛太慢,4 个 epoch 跑完还没到最优。

batch size 的确定要考虑显存和梯度稳定性。32 是个比较稳的值,显存够的话可以上 64,但如果数据里有大量短文本,batch 太大会让一个 batch 内的长度差异变大,动态 padding 的优势就被削弱了。

epoch 数量得配合早停。3 万条数据、batch 32 的话,一个 epoch 大概 900 多步。我设了 5 个 epoch 上限,早停 patience 为 2,评估指标用验证集宏 F1。实际跑下来通常在第 3 个 epoch 达到最优。

warmup 比例设 0.1,也就是前 10% 的步数做线性预热。这个对小数据微调很重要,能让模型在初期不要被随机初始化的分类头带偏。梯度裁剪阈值设 1.0,防止偶尔出现的梯度爆炸。

完整参数表如下:

参数取值依据
learning_rate2e-5中等数据量下的经验最优区间
batch_size3216G 显存下不 OOM 的上限
epochs5(早停 2)配合验证集 F1 监控
warmup_ratio0.1保护随机初始化的分类头
weight_decay0.01抑制过拟合
max_grad_norm1.0防止梯度爆炸
max_len160覆盖 98% 样本长度
num_labels3负向 / 中性 / 正向

4.3 完整的训练与验证循环代码

下面这段是可以直接跑的完整训练脚本,包含优化器配置、学习率调度、混合精度和早停:

import time import numpy as np import torch import torch.nn as nn from torch.utils.data import DataLoader from torch.optim import AdamW from transformers import get_linear_schedule_with_warmup from sklearn.metrics import f1_score, accuracy_score def train_one_epoch(model, loader, optimizer, scheduler, device, scaler): model.train() total_loss = 0.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() with torch.cuda.amp.autocast(): outputs = model(input_ids=input_ids, attention_mask=attention_mask, labels=labels) loss = outputs.loss scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) scaler.update() scheduler.step() total_loss += loss.item() return total_loss / len(loader) @torch.no_grad() def evaluate(model, loader, device): model.eval() preds, golds = [], [] for batch in loader: input_ids = batch["input_ids"].to(device) attention_mask = batch["attention_mask"].to(device) labels = batch["labels"].to(device) with torch.cuda.amp.autocast(): logits = model(input_ids=input_ids, attention_mask=attention_mask).logits preds.extend(torch.argmax(logits, dim=-1).cpu().numpy().tolist()) golds.extend(labels.cpu().numpy().tolist()) return accuracy_score(golds, preds), f1_score(golds, preds, average="macro") def train(train_loader, val_loader, model, device, epochs=5, patience=2): no_decay = ["bias", "LayerNorm.weight"] grouped_params = [ {"params": [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)], "weight_decay": 0.01}, {"params": [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)], "weight_decay": 0.0}, ] optimizer = AdamW(grouped_params, lr=2e-5) total_steps = len(train_loader) * epochs 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, best_state, bad_epochs = 0.0, None, 0 for epoch in range(epochs): t0 = time.time() train_loss = train_one_epoch(model, train_loader, optimizer, scheduler, device, scaler) acc, macro_f1 = evaluate(model, val_loader, device) print(f"epoch {epoch+1} | loss {train_loss:.4f} | acc {acc:.4f} | macro_f1 {macro_f1:.4f} | {time.time()-t0:.1f}s") if macro_f1 > best_f1: best_f1 = macro_f1 best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()} bad_epochs = 0 else: bad_epochs += 1 if bad_epochs >= patience: print(f"early stop at epoch {epoch+1}") break if best_state is not None: model.load_state_dict(best_state) return model, best_f1

这里有几个我自己加的细节值得说一下。优化器参数做了分组,bias和LayerNorm的权重不加权重衰减,这是 BERT 微调的标准做法,能让小数据下的泛化更稳。torch.cuda.amp混合精度是必开的,速度快将近一倍的代价是极小的精度损失,我对比过开与不开的最终 F1,差异在 0.001 以内。早停保存的是“最优验证 F1 对应的那份权重”,不是最后一个 epoch 的权重,这一点很关键,因为最后一个 epoch 往往已经过拟合了。

训练完成的模型保存和加载:

torch.save(model.state_dict(), "best_model.pt") tokenizer.save_pretrained("./tokenizer_out") # 加载时重新构造结构再灌参数 model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME, num_labels=3) model.load_state_dict(torch.load("best_model.pt", map_location="cpu")) model.to(device).eval()

5. 评估不止于 accuracy:混淆矩阵与 badcase 复盘

5.1 指标选择:为什么 F1 和宏平均更可信

外卖评论的类别分布天然不平衡,正向类往往占五六成。这种情况下 accuracy 会骗人,一个把所有样本都判成正向的模型,accuracy 也能到 55%。所以主指标必须用宏平均 F1,它对每个类别一视同仁,小类表现差会立刻反映出来。

除了宏平均 F1,我还会看三个东西:每个类别的 precision / recall 明细、加权平均 F1、以及混淆矩阵。这三个放在一起才能看出问题在哪。比如我某一版模型的宏 F1 是 0.88,看着不错,但拆开看中性类的召回只有 0.71,正向类的 precision 是 0.94。说明模型在“把中性判成正向”这件事上犯了很多错,中性类被正向类吃掉了。

用sklearn出一份完整报告:

from sklearn.metrics import classification_report, confusion_matrix report = classification_report(golds, preds, target_names=["负向", "中性", "正向"], digits=4) print(report) cm = confusion_matrix(golds, preds) print(cm)

输出大概是这个形态:

真实 \ 预测负向中性正向
负向91211841
中性87604159
正向331321574

从这里能直接读出两个结论:一是负向类被判成中性的有 118 条,这是最危险的一类错误,因为业务上差评漏检的代价最高;二是中性类有 159 条被判成正向,占了中性总量的 19%。

5.2 混淆矩阵揭示的三类典型错误

对着混淆矩阵,我把错误样本一条条捞出来看,最后归成了三类。

第一类是**“弱抱怨”被当中性**。像“速度有点慢吧,其他还行”“分量感觉比以前少了”,这类文本里负面表达很克制,用的是“有点”“感觉”“吧”这种模糊限定词。模型从这些词里提取不到足够强的负向信号,就落到了中性。解决思路一是在标注规范里明确“只要出现负面评价词就必须归负向,哪怕只有一处”,二是针对这类句式做定向增强,把一批“有点+负面词”的模板句加进训练集。

第二类是**“先抑后扬”被整体判负**。典型的是“本来以为不行,结果出乎意料的好”,模型抓到了前面的“不行”,但没学明白“结果”“出乎意料”之后才是真正的态度。这类错误 BERT 比词袋模型强很多,但依然有残留。我试过的有效办法是把这类转折句式单独抽出来做了 200 条的小样本增强,负向误判率降了大概 4 个点。

第三类是**“配送与菜品混评”**。外卖评论的一个特殊之处在于,用户经常把骑手服务和菜品质量揉在一起说。“菜好吃,但骑手太慢了”这条整体情绪是混合的,标注时按“以最终表态为准”的原则判了负向,但模型很容易被“好吃”带跑。这类样本在数据里占比不低,我最后的处理是把“以最终表态为准”这条规则写进标注手册,同时保留了一个辅助任务标签——在训练时额外预测“是否提及配送”,但这个方案最后没上,因为收益不足以支撑复杂度。

5.3 badcase 逐条拆解:反讽、混合情感、否定词

除了上面三类结构性错误,还有几个长尾问题值得单独说。

反讽是这个任务里最难的一类。“这速度,我怀疑骑手是走路来的”“味道好到我再也不想吃了”,这类文本的字面意思和真实意图完全相反。BERT 能抓到一部分,因为“怀疑”“再也不想”这些词在预训练语料里和负向语境共现过,但准确率不高。我实测反讽样本的召回只有 0.6 左右。这块的可行解法是单独训练一个反讽识别的小模型做前置分流,或者干脆把反讽样本在标注阶段单独打个标记,用多任务学习的方式带出来。考虑到反讽在整体数据里占比不到 2%,对业务指标影响有限,我暂时选择了接受这个误差。

否定词的范围也是老问题。“不难吃”“不算贵”“服务没有想象中差”,双重否定加模糊限定,模型很容易判反。BERT 比 RNN 类模型好,但依然会错。我做过一个实验,把验证集里所有带“不”字的样本单独拉出来算准确率,是 0.89,比整体低 3 个点。这个差距可以接受,如果想再压一压,可以考虑在输入里显式插入词性或者句法信息,但工程量不小。

超短文本是另一个盲区。“好”“差”“可以”这种两三个字的评论,信息量太少,模型基本靠类别的先验概率猜。这类样本在原始数据里占比约 6%。我的处理是从训练集里剔除了长度小于 4 个有效字符的样本,因为它们对学习没有正贡献,反而会拉低整体指标。

6. 上线前的工程化作答:推理加速与长期维护

6.1 推理侧的性能优化路径

训练完只是第一步,真正上生产要过推理性能这一关。原始 PyTorch 模型在单卡上单条推理大概 8 毫秒,如果 QPS 要求是 100,理论上 8 个并发就够了,但实际因为批处理、GC、内存拷贝这些问题,得留足余量。我走的优化路径大致分三步。

第一步是批处理推理,把请求攒成 batch 再送进模型。这一步收益最大,batch=32 的时候单条平均耗时能从 8 毫秒降到 1.5 毫秒左右。代价是要引入一个攒批的缓冲队列,通常设 10 到 20 毫秒的超时,超出就按当前攒到的数量直接推。

第二步是转 ONNX 或者用torch.jit做图优化。ONNX Runtime 在 CPU 上的推理速度比原生 PyTorch 快 2 到 3 倍,这对没有 GPU 的环境很有价值。导出的时候要注意把attention_mask也作为输入,并且固定输入维度或者用动态轴,具体看你的部署框架。

import torch from torch.onnx import export dummy_input = { "input_ids": torch.ones(1, 160, dtype=torch.long), "attention_mask": torch.ones(1, 160, dtype=torch.long), } export( model, (dummy_input,), "model.onnx", input_names=["input_ids", "attention_mask"], output_names=["logits"], dynamic_axes={ "input_ids": {0: "batch", 1: "seq"}, "attention_mask": {0: "batch", 1: "seq"}, "logits": {0: "batch"}, }, opset_version=14, )

第三步才是考虑模型压缩。如果延迟要求极其苛刻(比如单条必须 1 毫秒以内),可以考虑把 BERT 蒸馏成 4 层甚至 2 层的轻量模型。我用 3 万条数据做过蒸馏实验,学生模型(4 层)的宏 F1 是 0.86,相比老师模型的 0.884 掉了 2 个多点,但推理速度提升了 4 倍。这个取舍要看业务能不能接受。

6.2 数据漂移与模型迭代节奏

这里我想强调一个特别容易被忽略的问题:外卖评论的语言是持续变化的。网络热词两三个月就换一批,商家的话术也在演进。模型训完就锁死不管,半年后指标掉几个点是必然的。

我的做法是建一个轻量的监控闭环。线上推理时把每条请求的输入文本、预测标签、置信度都落库,置信度低于 0.6 的样本自动进人工复核池。每周统计一次各标签的预测占比,如果某个类别的占比和训练集先验偏离超过 5 个百分点,就触发告警。每月从复核池里挑 500 到 1000 条新样本补充进训练集,做一次增量训练。

增量训练有个坑要注意:新数据不能只加不筛。人工复核池里的样本天然偏向“难例”,全是模型判错的,如果直接加进去,会导致训练集分布和线上分布严重不一致。我的做法是按 1 比 3 的比例混入随机采样的普通样本,保持整体分布稳定。

还有一个细节是模型版本管理。每次训练都要记录清楚用的是哪份数据、哪个权重、哪组超参、最终指标多少,我一开始没做这件事,后来发现线上效果变差想回滚,根本不知道上一版是哪份权重,白白浪费了大半天。

6.3 我踩过的几个坑与对应处理

最后把这段经历里印象最深的几个坑列出来,都是那种文档里不会写、只有自己撞过才知道的。

坑一:标签泄漏。早期版本的验证集是从全量数据里随机切的,结果同一家店的评论在训练集和验证集里都出现,模型记住了店铺名,验证 F1 高得离谱,上线后掉到 0.79。改成按店铺和用户分组切分之后,验证 F1 降到 0.88 的真实水平。判断有没有这个问题很简单,看你的验证集 F1 是不是显著高于上线实测值,高 5 个点以上基本就是泄漏。

坑二:tokenizer 截断顺序。BERT 的truncation=True默认是longest_first,也就是从尾部截断。但外卖评论有个特点,用户经常在最后一句给最终结论。“菜一般,等了很久,不会再点了”,结论全在最后。从尾部截断刚好把结论砍掉。后来我改成truncation="only_first"配合更长的max_len,问题缓解了不少。更稳妥的做法是在数据预处理阶段就按句子切分,保留包含结论的句子。

坑三:验证集指标和人工感知不一致。有一版模型报表上宏 F1 是 0.89,但拿真实评论去测,明显感觉不准。查下来是测试集里的样本偏“教科书”,都是表达清晰的长文本,而线上真实流量里短文本和口水话占大头。后来我专门按线上流量的长度分布重新采样了一个贴近真实的测试集,指标掉到 0.85,但这个 0.85 才是有意义的。

坑四:分类头初始化带来的方差。因为分类头是随机初始化的,同一个种子跑十次,最终 F1 能差 1 个点。做法是跑 3 到 5 个不同种子,取验证集 F1 最高的那份,或者做集成。我最后用了 3 个种子的模型做 softmax 概率平均,宏 F1 稳定提升了 0.6 个点,代价是推理耗时翻三倍。如果对延迟敏感,就别搞集成。

坑五:中文标点和全角半角。这个坑很隐蔽,训练数据里全是中文逗号“,”,线上用户输入了一堆英文逗号“,”,tokenizer 切出来完全不同的 token,导致效果波动。统一在全角转半角之外,额外做一次中英文标点归一化,问题就消失了。

这套方案现在跑在每天几十万条评论的量级上,三分类宏 F1 稳定在 0.88 到 0.89 之间,负向类召回 0.92。如果让我重新做一遍,我会在最开始就把“按店铺和用户分组切分”和“贴近真实分布的测试集”这两件事做掉,能省掉后面至少一半的返工时间。数据工程上的功夫,永远比调模型超参的收益更实在。

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

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

立即咨询