做情感分析这件事,我前后折腾过好几轮。最早是用词向量加 LSTM 硬怼,后来又试过 TextCNN,效果能看但很脆——换个业务场景、换批线上数据,指标准掉一截。真正让我把整条链路重做一遍的,是换成 BERT 做中文情感分析之后:同一个标注集,Macro-F1 从 0.82 提到 0.90 附近,而且模型对否定句、反讽式表达的容忍度明显好了。但代价也很实在,显存、训练时间、数据质量要求全都上来了,坑一个没少踩。这篇就把我从数据准备到训练调参、再到推理收尾的完整过程摊开讲,包括那些文档里不会写、只有自己跑挂了才会明白的细节。如果你手上正好有一批中文评论、弹幕、客服对话要做极性判断,或者你已经跑通了官方 Demo 但一换自己的数据就掉点,下面这些应该能省你几天时间。
1. 先把任务边界划清楚,别急着 import transformers
我见过太多人拿到需求就直接开写训练脚本,跑到第三天发现标签定义和业务方理解的根本不是一回事。情感分析看着简单,实际上"正/负"这两个字背后的口径差异能大到让整个模型白训。
1.1 二分类、三分类还是极性加强度分开打
第一件要拍板的事是类别数量。常见的三种口径:二分类(正向/负向)、三分类(再加中性)、以及极性加强度(正向强、正向弱、中性、负向弱、负向强)。我的建议很直接——除非业务方明确要求,别碰中性类。
原因在于中性样本的标注一致性极差。同一句"手机到了,包装完好",有人标中性(只是描述事实),有人标正向(没有抱怨就是好评)。我做过一次标注一致性抽查,让三个人独立标 500 条中性样本,两两一致率只有 0.61,而正负样本的一致率是 0.93。中性类的标签噪声会直接污染整个决策边界,最后表现为模型对什么都给 0.5 附近的概率,看着"很谨慎",实际是学不到东西。
如果你的原始数据是五星评分,别直接 1:1 映射成五分类。我的做法是先看分布:
| 原始评分 | 样本占比(某电商评论集实测) | 建议映射 |
|---|---|---|
| 1 星 | 12% | 负向 |
| 2 星 | 6% | 负向(并入) |
| 3 星 | 9% | 单独观察,多数可丢弃或并入中性 |
| 4 星 | 21% | 正向 |
| 5 星 | 52% | 正向 |
2 星并进负向、3 星单独拎出来看,这个处理不是拍脑袋。2 星评论的内容绝大多数是"东西有问题但客服态度还行",情感极性上仍然是负的;3 星才是真正的模糊地带。把 3 星丢掉之后,二分类的标签纯度会有明显提升,代价是丢 9% 的数据,这个交换我认为值得。
1.2 标签体系一旦定了,后面所有工作都围着它转
标签定义要写成一句话能说清、且能覆盖边界情况的规则。比如"负向"的定义不是"用户不满意",而是"文本中表达了对商品或服务本身的负面评价,包括质量、物流、客服响应"。后者能让你在遇到"物流慢但东西好"这种混合情感时有一致的判断依据。
混合情感的样本必须提前定规则。我的处理是按句子切分后取主导情感,主导的判断标准是"哪个情感被更多的修饰词和具体描述支撑"。这条规则听起来主观,但比"凭感觉"强太多,至少标注员之间能对齐。
还有一个容易忽略的点:训练时的类别顺序要和线上服务返回的顺序严格一致。我踩过一次,训练脚本里 label 是 0=负、1=正,线上服务的映射表写反了,灰度了两小时才发现负向评论全被标成正向推给了运营。这种事故不涉及任何高深技术,纯粹是约定没写死。后来我的做法是标签映射表放进配置文件,训练和推理读同一份,禁止硬编码。
1.3 数据集的三个隐蔽陷阱
陷阱一:重复样本跨集合泄漏。电商评论里"好评""此用户未填写评价"这类文本会大量重复,如果随机切分,同一条文本可能同时出现在训练集和验证集,验证指标会虚高好几个点。我的做法是先按文本内容做 MD5 去重,去掉完全相同的,再做切分。更进一步,对于高度相似的短文本(编辑距离很小的),可以用 SimHash 做近似去重。
陷阱二:同一用户的多条评论跨集合。这个更隐蔽。一个用户连发五条差评,如果这五条分散在训练和验证集,模型实际上是在"记住这个用户的说话风格"。有用户 ID 的话就按用户分组切分,没有的话至少检查一下文本相似度。
陷阱三:时间穿越。如果是按时间积累的真实业务数据,随机切分会让模型"看到未来"。正确做法是按时间切:用较早的数据训练,较晚的数据验证。这两者的指标差距往往很大,我实测过一次,随机切分 Macro-F1 0.91,按时间切分掉到 0.86,后者才是上线后的真实水平。
提示:切分完之后,务必统计三个集合的标签分布。如果验证集的负向占比和训练集差了 5 个百分点以上,说明切分方式有问题,优先怀疑上面三个陷阱。
2. BERT 在这件事上到底强在哪,值不值得这个成本
很多人对 BERT 的理解停留在"效果好的预训练模型",但具体好在哪、什么情况下会失效,说不清楚。这直接影响你后面调参时的判断方向,所以我花点篇幅拆开讲。
2.1 词向量加 LSTM 方案的三个天花板
早期方案的基本套路是:分词 → 查预训练词向量 → 送进 LSTM/GRU 或 CNN → 池化 → 全连接分类。它在情感分析上有三个绕不过去的天花板。
第一是一词多义的静态表示。"这个价格真香"和"这香水真香","香"在两个语境里的语义完全不同,但静态词向量给的是同一个向量。模型只能靠上下文窗口内的其他词去猜,而 LSTM 的隐状态在长句里会衰减,猜得并不准。
第二是长距离依赖衰减。中文情感句里经常出现"虽然说……但是……"这种结构,情感极性由后半句决定。LSTM 理论上能记住,实际上超过 30 个 token 之后,前面信息的权重就明显下降了。我测过一个句子:"东西不便宜,包装也一般,物流还慢了两天,但是用起来是真的舒服,值。"这种句子 LSTM 很容易判负,因为它被前三个负面分句主导了。
第三是标注数据依赖。纯监督训练,几千条数据往往不够,模型泛化很差。
2.2 自注意力实际在做的事情
BERT 的核心是 Transformer 编码器,里面的自注意力机制做的事情可以这么理解:对于序列中的每一个位置,让它自己去"看"其他所有位置,然后决定从谁那里取多少信息。
用生活化的说法,LSTM 像一个人从头到尾读句子,读到后面时对前面的记忆已经模糊了;自注意力像一个人把整句话摊在桌面上,同时看所有词,然后针对每个词问一遍"你要跟哪几个词建立联系"。上面那个"但是用起来是真的舒服"的例子,自注意力在编码"舒服"的时候,可以直接和"但是"以及整个后半句对齐,权重给得很高,前面的负面词权重自然压低。
多头机制则是同时从多个角度建立联系——一个头可能关注情感词,一个头可能关注否定词,一个头可能关注程度副词。这也是为什么 BERT 对"不是很满意"这种否定结构比传统模型稳得多。
2.3 预训练加微调为什么小样本也能打
预训练阶段模型在大量无标注文本上做了两件事:掩码语言建模(遮住一部分词让模型猜)和下一句预测。这个过程让模型学到了中文的语法结构、常见搭配、语义关联。
微调阶段你只需要告诉它"在这个具体任务上,什么样的输出是对的"。因为底层的语言理解能力已经有了,微调本质上是在一个很好的起点上做小幅调整,而不是从零学起。这就是为什么几千条标注数据在 BERT 上能出效果,在 LSTM 上基本没戏。
但这里有个反直觉的点:数据量太少的时候(比如每类不到 500 条),BERT 反而不如精心调过的小模型。原因是微调阶段参数太多,容易在极小的数据集上过拟合,验证集指标抖动非常大。这种情况下我的建议是先扩数据,或者用更小的模型版本(4 层、6 层),别硬上 12 层。
3. 环境和权重的准备,别在这上面浪费两天
环境问题看着无聊,但它是我见过最多人卡住的地方。版本不匹配、路径没配对、tokenizer 和模型对不上,每一个都能让你怀疑人生。
3.1 依赖版本锁定
我的习惯是永远锁版本。不锁版本的代价是:今天跑通的脚本,下周换台机器就报错,而错误信息往往指向一个和你代码毫无关系的地方。
python -m venv venv_sentiment source venv_sentiment/bin/activate pip install torch==2.1.2 --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.36.2 pip install scikit-learn==1.3.2 pandas==2.1.4 numpy==1.26.3 tqdm pip freeze > requirements.lock.txt几个版本坑要提前知道:
| 问题现象 | 根因 | 处理方式 |
|---|---|---|
加载权重报unexpected key一堆 | transformers 大版本跨越,权重格式变了 | 降 transformers 到权重对应的版本区间 |
tokenizer.pad返回格式不对 | 不同版本返回类型有差异 | 显式指定return_tensors="pt" |
混合精度训练报autocast弃用警告 | torch 2.x 移到了torch.amp | 统一改用torch.amp.autocast("cuda", ...) |
| 训练完保存再加载,指标掉了 | 只存了state_dict没存 config | 用save_pretrained保存整个目录 |
3.2 权重目录的本地化组织
线上环境往往不能直连外网拉权重,所以从一开始就按本地路径来组织,别养成在线加载的习惯。目录结构我会这样安排:
project/ ├── data/ │ ├── raw/ │ ├── processed/train.jsonl │ └── processed/val.jsonl ├── weights/ │ └── bert_base_zh/ # 解压后的权重目录 │ ├── config.json │ ├── vocab.txt │ └── pytorch_model.bin # 或 model.safetensors ├── src/ │ ├── dataset.py │ ├── train.py │ └── predict.py └── outputs/ └── exp_001/代码里路径全部走配置:
import os PRETRAINED_DIR = os.environ.get("BERT_DIR", "./weights/bert_base_zh") MAX_LEN = 256 NUM_LABELS = 2 LABEL2ID = {"negative": 0, "positive": 1} ID2LABEL = {v: k for k, v in LABEL2ID.items()}用环境变量兜底,本地开发直接指本地目录,容器里挂载不同的卷,不用改代码。LABEL2ID单独抽出来,训练和推理都 import 它,从根上断掉映射写反的可能。
3.3 tokenizer 的几个参数必须明确
中文 BERT 的 tokenizer 是字级别的,一个字一个 token。这点很重要,因为它决定了你的max_len该怎么设。
from transformers import BertTokenizer tokenizer = BertTokenizer.from_pretrained( PRETRAINED_DIR, do_lower_case=True, tokenize_chinese_chars=True, ) sample = "这个手机的续航是真的顶,就是有点重。" print(tokenizer.tokenize(sample))输出大概是这样:
['这', '个', '手', '机', '的', '续', '航', '是', '真', '的', '顶', ',', '就', '是', '有', '点', '重', '。']一句 20 来个字的话,tokenize 完是 18-20 个 token,加上[CLS]和[SEP]就是 22 个。所以max_len=128大概能装下 120 字左右的中文,max_len=256能装 250 字左右。如果你处理的是客服对话那种动辄几百字的长文本,128 肯定不够。
do_lower_case对纯中文没影响,但如果文本里混了英文品牌名(比如型号带字母),设成 True 能减少词表外的情况。tokenize_chinese_chars保持默认 True,别去关它,关了会把整个中文词当成一个 token,词表直接对不上。
4. 从原始文本到可训练张量,数据管道怎么写才不出错
这一段是整条链路里最容易出静默错误的地方——代码不报错,但喂进去的数据是错的,最后表现为"模型怎么训都上不去"。
4.1 文本清洗要克制,别把信号洗掉了
刚开始我清洗得很激进:去标点、去表情、去重复字符、去 URL,结果指标掉了两个点。后来才发现,中文情感分析里标点和表情是高价值信号。
"好。"和"好???"情感强度完全不同。"太好了!!!"和"太好了"也不是一回事。表情符号就更直接了,一个"🙂"在中文互联网语境里经常是反讽。
所以我的清洗规则收得很窄,只做这几件事:
import re import unicodedata URL_PATTERN = re.compile(r"https?://\S+|www\.\S+") AT_PATTERN = re.compile(r"@[\w\u4e00-\u9fa5\-]{1,20}") SPACE_PATTERN = re.compile(r"\s+") def clean_text(text: str) -> str: if not isinstance(text, str): return "" # 全角转半角,统一字符形态 text = unicodedata.normalize("NFKC", text) # 去掉链接,但保留前后文 text = URL_PATTERN.sub(" ", text) # 去掉 @提及,保留后面的正文 text = AT_PATTERN.sub(" ", text) # 连续空白压成一个空格 text = SPACE_PATTERN.sub(" ", text).strip() return text注意NFKC归一化会把全角标点转成半角,!变!。这一步有利有弊:好处是减少词表外的字符,坏处是丢失了一点点形态信息。我实测对指标影响在 0.3 个点以内,可以接受。
重复字符要压但不能全压。"好好好好好"这种,压成"好"会丢强度信息,压成"好好好"比较合适。我的规则是连续重复超过 3 次的,压到 3 次。
4.2 Dataset 写成动态 padding
不要在一开始就把所有样本 pad 到max_len。中文评论长度分布很不均匀,大部分在 30 字以内,少部分上百字。全部 pad 到 256 会让绝大部分计算浪费在 padding token 上,训练速度直接腰斩。
正确的做法是 Dataset 里只做 tokenize 和截断,padding 留到 collate 阶段按 batch 内最长的那条来 pad:
import torch from torch.utils.data import Dataset class SentimentDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=256): assert len(texts) == len(labels) 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=False, return_attention_mask=True, ) item = {k: v for k, v in enc.items()} item["labels"] = int(self.labels[idx]) return itemtruncation=True配max_length,超长的直接截断。但截断是有代价的,第 6 节会讲一个因为截断导致情感反转的真实案例。
4.3 collate_fn 里做 padding 和类型转换
def make_collate_fn(tokenizer): def collate_fn(features): pad_keys = ["input_ids", "attention_mask", "token_type_ids"] batch_inputs = [{k: f[k] for k in pad_keys if k in f} for f in features] batch = tokenizer.pad(batch_inputs, padding=True, return_tensors="pt") batch["labels"] = torch.tensor( [f["labels"] for f in features], dtype=torch.long ) return batch return collate_fn这里有个细节:token_type_ids不是所有预训练权重都有,用if k in f过滤一下,避免 KeyError。另外labels必须是torch.long,如果是 float 会在算交叉熵时直接报错,错误信息还很不直观。
DataLoader 配置:
from torch.utils.data import DataLoader train_loader = DataLoader( train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True, collate_fn=make_collate_fn(tokenizer), drop_last=True, )drop_last=True只对训练集开,验证集要保留最后一批,否则评估会漏样本。num_workers建议设为 CPU 核数的 1/4 左右,设太大反而因为进程间通信变慢。pin_memory=True配合 GPU 训练能加快数据搬运。
5. 训练循环里的关键决策,参数不是随便填的
模型加载本身三行代码,真正决定效果的是优化器怎么配、学习率怎么走、什么时候停。
5.1 优化器参数分组,这个细节影响不小
BERT 微调时,bias 和 LayerNorm 的参数不应该做权重衰减。原因很直白:这两类参数本身就是为了稳定数值,对它们施加 L2 惩罚等于在跟模型的设计意图对着干,实测会带来轻微但稳定的指标下降。
from transformers import BertForSequenceClassification, AdamW model = BertForSequenceClassification.from_pretrained( PRETRAINED_DIR, num_labels=NUM_LABELS, id2label=ID2LABEL, label2id=LABEL2ID, ) no_decay = ["bias", "LayerNorm.weight", "LayerNorm.bias"] optimizer_grouped_parameters = [ { "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(optimizer_grouped_parameters, lr=2e-5, eps=1e-8)学习率取值:微调 BERT 的常用区间是 1e-5 到 5e-5。我的经验是小数据集取小值,大数据集取大值。三千条左右的标注集,2e-5 是稳的;上万条可以用到 3e-5 甚至 4e-5。超过 5e-5 很容易训崩,表现为 loss 先降后剧烈震荡。
5.2 warmup 不能省
BERT 微调必须带 warmup。原因在于预训练权重已经很好了,如果一上来就给满学习率,前几个 batch 的大梯度会把这套权重冲乱,后面很难救回来。
from transformers import get_linear_schedule_with_warmup EPOCHS = 4 total_steps = len(train_loader) * EPOCHS warmup_steps = int(total_steps * 0.1) scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=warmup_steps, num_training_steps=total_steps, )warmup 比例我一般设 10%。数据量特别小(低于 2000 条)的时候可以提到 15%-20%,让小数据集上的前期更稳。
5.3 显存不够的时候,按这个顺序降级
显存是最现实的问题。给一组我实测过的经验值(12 层基础版权重,单卡,含优化器状态和激活值):
| max_len | batch_size | 混合精度 | 显存占用(经验值) | 训练速度 |
|---|---|---|---|---|
| 128 | 32 | 否 | 6-7 GB | 基准 |
| 128 | 32 | 是 | 4-5 GB | 快约 1.5 倍 |
| 256 | 32 | 否 | 9-11 GB | 慢约 1.4 倍 |
| 256 | 32 | 是 | 6-7 GB | 接近基准 |
| 512 | 16 | 是 | 9-10 GB | 慢约 2 倍 |
显存不够时按这个优先级降级:先开混合精度(几乎不掉点,速度快),再降 max_len(但要看 4.1 提到的截断风险),最后才降 batch_size(降到 8 以下梯度噪声太大,收敛变差)。如果 batch 必须很小,用梯度累积补回来:
GRAD_ACCUM_STEPS = 4 # 等效 batch_size = 32 for step, batch in enumerate(train_loader): loss = loss / GRAD_ACCUM_STEPS loss.backward() if (step + 1) % GRAD_ACCUM_STEPS == 0: optimizer.step() scheduler.step() optimizer.zero_grad()注意scheduler.step()要跟着优化器的步走,不能每个 batch 都调,否则学习率曲线会提前走完。
完整训练循环:
import torch from torch.amp import autocast, GradScaler def train_one_epoch(model, loader, optimizer, scheduler, scaler, device): model.train() total_loss = 0.0 for batch in loader: input_ids = batch["input_ids"].to(device, non_blocking=True) attention_mask = batch["attention_mask"].to(device, non_blocking=True) labels = batch["labels"].to(device, non_blocking=True) token_type_ids = batch.get("token_type_ids") if token_type_ids is not None: token_type_ids = token_type_ids.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) with autocast("cuda", enabled=scaler is not None): outputs = model( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids, labels=labels, ) loss = outputs.loss if scaler is not None: scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) scaler.step(optimizer) scaler.update() else: loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() total_loss += loss.item() return total_loss / max(len(loader), 1)梯度裁剪max_norm=1.0建议保留。中文情感数据里经常有超短文本("差")和超长文本混在一个 batch,梯度尺度差异大,裁剪能防止偶发的梯度爆炸。
5.4 评估指标别只看准确率
类别不均衡时准确率会骗人。正向样本占 70% 的数据集,模型全判正向就能拿 70% 准确率,但业务上完全没用。
我的评估组合是Macro-F1 为主、各类别召回率为辅、混淆矩阵看具体错在哪:
import numpy as np from sklearn.metrics import f1_score, classification_report, confusion_matrix @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) labels = batch["labels"].to(device) token_type_ids = batch.get("token_type_ids") if token_type_ids is not None: token_type_ids = token_type_ids.to(device) logits = model( input_ids=input_ids, attention_mask=attention_mask, token_type_ids=token_type_ids, ).logits preds = torch.argmax(logits, dim=-1) all_preds.extend(preds.cpu().tolist()) all_labels.extend(labels.cpu().tolist()) macro_f1 = f1_score(all_labels, all_preds, average="macro") report = classification_report( all_labels, all_preds, target_names=[ID2LABEL[i] for i in range(NUM_LABELS)], digits=4, ) cm = confusion_matrix(all_labels, all_preds) return macro_f1, report, cm早停策略:patience 设 2,监控 Macro-F1,取验证集最好的那个 epoch 保存。不要用最后一个 epoch,也不要按训练 loss 选——训练 loss 一直在降,但验证指标早就到顶了。
6. 实测中撞上的几类故障,以及我的排查顺序
下面这些是我和身边同学真踩过的,按"出现频率 × 排查成本"排序,建议先看一遍再开跑。
6.1 训练 loss 不降或者降得很慢
排查顺序我固定成四步:
第一步,确认标签和输入是配对的。写个脚本随机抽 20 条,把原文、tokenize 结果、label 一起打印出来人眼看。我遇到过一次,DataFrame 读 CSV 时索引错位,文本和标签整体偏移一行,loss 卡在 0.69(二分类的随机水平)死活不动,查了大半天。
第二步,确认model.train()调了。这个听着蠢,但我确实见过。没切训练模式,dropout 不生效,指标一直很平。
第三步,看学习率。打印前 100 步的 lr,确认 warmup 曲线是上升的。有一次 scheduler 写错,学习率一直是 0,loss 完全不动。
第四步,看过拟合信号。如果训练 loss 在降但验证不涨,那不是训不动,是过拟合,方向完全不同。
6.2 验证集指标虚高,上线就掉
这个最有欺骗性。验证集 0.91,线上一跑 0.83。除了第 1.3 节的切分陷阱,还有一个高频原因是验证集的文本分布太"干净"。
我做过一个对比,同一批数据,验证集随机抽 vs 按长度分层抽,指标差了 4 个点。随机抽的时候,验证集里短文本占比高,模型在短文本上本来就准,自然虚高。
排查方法:统计训练集和验证集的文本长度分布、类别分布、高频词重合度。如果验证集的平均长度比训练集低 30% 以上,基本可以确定是分布不匹配。
6.3 同样的输入,每次推理结果都不一样
这个问题的答案只有一个:没调model.eval()。BERT 里有多层 dropout,训练模式下每次前向都会随机丢一部分神经元,概率输出自然抖动。
@torch.no_grad() def predict(texts, model, tokenizer, device, max_len=256, batch_size=64): model.eval() # 这一行不能漏 all_preds, all_probs = [], [] for i in range(0, len(texts), batch_size): chunk = texts[i:i + batch_size] enc = tokenizer( chunk, max_length=max_len, truncation=True, padding=True, return_tensors="pt", ).to(device) logits = model(**enc).logits probs = torch.softmax(logits.float(), dim=-1) all_preds.extend(probs.argmax(dim=-1).cpu().tolist()) all_probs.extend(probs.cpu().tolist()) return all_preds, all_probs顺带一提,即使调了eval(),也不能用torch.argmax之外的方式去"凑"结果。我见过有人为了避免抖动,对同一输入重复推理 5 次取多数票,这纯属掩盖问题。
6.4 长文本截断导致情感反转
中文评论里有个经典结构:前 200 字吐槽,最后一句"不过整体还是可以的"。max_len=128直接截断在后面那句之前,模型只能看到吐槽部分,判成负向。
处理方法有三种,按复杂度和效果排:
| 方案 | 做法 | 效果 | 成本 |
|---|---|---|---|
| 提高 max_len | 128 提到 256 或 512 | 直接有效 | 显存和速度代价大 |
| 头部加尾部拼接 | 取前 60% + 后 40% 拼起来 | 效果好,成本低 | 可能丢中间信息 |
| 分段推理后融合 | 切段分别推理,概率加权平均 | 效果最好 | 推理耗时翻倍 |
我一般用第二种,max_len=256,取前 150 个 token 和后 100 个 token 拼起来,中间用...占位。这个方案在实测中比纯截断提升了大约 2 个点的 Macro-F1,显存没变,性价比最高。
6.5 其他几个高频小坑
其他值得记一笔的还有:FP16 下 softmax 数值溢出,表现为概率出现nan或者全是 0/1,解决方法是 softmax 前把 logits 转float();多卡训练后保存的权重 key 带module.前缀,加载时用state_dict手动去掉前缀再load_state_dict;早停保存的逻辑写在评估函数外层,导致最后一个 epoch 把最好的权重覆盖掉了,这种错误特别难发现,因为文件确实存在。
提示:每次改完训练脚本,先用 200 条小样本跑 2 个 epoch,确认 loss 在降、验证能跑通、权重能保存能加载,再上全量。这个"冒烟测试"习惯帮我省过无数次一整夜的等待。
7. 上线前的收尾工作,这几件事不做等于白训
模型训完不等于能用。从 notebook 里的 checkpoint 到线上稳定服务,还有一段路。
7.1 推理效率的两个优化点
第一是批量推理加长度排序。线上请求通常是流式的,一条一条推会浪费大量 GPU 并行能力。做法是攒一小批(比如 32 条或 50ms 超时),按文本长度排序后再 pad,这样 padding 浪费最少。
第二是导出成静态图格式。BERT 这种固定结构的模型,导出后推理速度能提升 30%-50%,尤其是在 CPU 上部署时差距明显。导出时注意把max_len固定下来,动态 shape 的图优化效果会打折扣。
第三是模型量化。INT8 量化能进一步压缩体积、提升速度,但要重新评估精度。我实测的结果是在情感分析任务上量化后 Macro-F1 掉 0.5 个点左右,多数业务能接受。不过要注意,量化后的模型要单独跑一遍留存测试集,不能想当然认为掉点幅度一致。
7.2 概率阈值比想象中重要
argmax是默认做法,但业务上不一定合适。比如做舆情预警,你宁可误报也不想漏报,那就应该把负向的判定阈值从 0.5 降到 0.35,牺牲一点精确率换召回率。
阈值要基于留存测试集的概率分布来定,不能拍脑袋。做法是把验证集的预测概率按类别导出,画出分布直方图,看两类概率的重叠区域在哪,阈值就落在能接受误判比例的那个位置。
多分类的时候还可能遇到"拒判"需求:最大概率低于某个值时直接返回"无法判断",交给人工。这个兜底逻辑在真实业务里非常必要,因为线上文本的分布永远比训练集更野。
7.3 上线前必须做的三件回归
第一件:留存集回归。在切分时单独留一份完全没参与过训练和调参的测试集,只在最终验证时用一次。我见过太多人反复在验证集上调阈值、选模型,最后验证集指标已经严重乐观了。
第二件:badcase 回归。把训练过程中积累的所有错判样本整理成一个固定集合,每次模型迭代都跑一遍,确认旧的错误没有被重新引入。这个集合会越滚越大,是最有价值的资产之一。
第三件:分布漂移监控。上线后统计线上请求的文本长度分布、类别预测分布,和训练集对比。如果负向预测占比突然从 20% 涨到 60%,可能是数据源变了,也可能是模型出问题了,两种情况处理方式完全不同,但都必须第一时间知道。
实际用下来,我最深的一点体会是:这套链路里真正决定效果的,从来不是模型结构或者调参技巧,而是数据质量和你对任务口径的定义清晰度。同一个基础版权重,在干净、口径统一的标注集上能跑到 0.91,在口径混乱的数据上只能到 0.85,中间这 6 个点靠换模型、调学习率是补不回来的。所以如果只能优化一个环节,我会选标注规范和去重切分,而不是去折腾更复杂的网络。
另外一个小技巧分享给还在调显存的人:训练脚本里加一行torch.cuda.reset_peak_memory_stats(),在每个 epoch 结束时打印torch.cuda.max_memory_allocated() / 1024**3,你就能精确知道自己的峰值显存到底是多少,不用靠任务管理器估。我第一次这么干的时候发现实际峰值比估算低了将近 2GB,直接把 batch_size 从 16 提到了 24。