情感分析这个活儿我在几个项目里反复折腾过好几轮。最早那阵子用的是情感词典加规则匹配正负面词表堆了几千条还得配一堆否定词、程度副词的加权逻辑上线头两周看着还行评论一多、黑话一出来就露馅——这价格我笑死被判定成正面质量好到我怀疑人生直接翻车。后来换成词袋加逻辑回归效果稳了一些但一到短文本、反讽、带错别字的评论上准确率就开始坐滑梯。真正让我把方案定下来的是把BERT 情感分析这套流程完整跑通之后同一个数据集验证集准确率从 0.86 拉到 0.94 以上正负类的召回也不再一边倒。这篇东西讲的就是这套流程的完整落地版本从数据整理、模型选型、训练参数、显存估算到评估口径、推理封装和线上掉点的排查。它解决的问题很具体给你一份中文评论数据让你能在单卡上把 BERT 微调成一个可用的情感二分类或多分类模型并且知道每一步为什么这么做。适合谁看写过一点 PyTorch、知道 Dataset 和 DataLoader 是怎么回事、但没完整跑过预训练模型微调的同学最合适纯新手也能跟我会把关键概念用生活化的说法补上有经验的同学可以直接跳到参数和排查那几节。1. 开工前把任务边界钉死比急着写代码重要1.1 情感分析在业务里到底是个什么任务很多人一上来就问用哪个模型其实更该先问这条评论到底算正面还是负面谁来定义。情感分析表面上是自然语言处理里的经典分类任务落到业务上其实是三件事的混合体极性判定正/负/中性、强度判定几颗星、多生气、对象归属这句话骂的是物流还是商品本身。我踩过最狠的一个坑是早期把一个三分类任务硬做成了二分类。数据里有一批东西收到了还没拆、质量一般般吧能用的评论标注同学按个人理解一半标正一半标负模型学得一脸懵验证集准确率怎么调都在 0.88 上下晃。后来把中性类单独拎出来做三分类同一份数据、同一套参数macro-F1 直接从 0.83 涨到 0.91。所以动手前先拿一张纸写清楚三件事类别有哪几个、每个类别的判定标准是什么最好给标注同学两三条正例和反例、边界情况怎么处理比如纯表情、纯符号、只写了的评论。这件事花你半小时能省后面两天。1.2 数据从哪来标注怎么才不返工数据来源无非三条路业务自有数据、公开数据集、模型生成加人工校验。自有数据的优势是分布真实劣势是往往类别极不平衡——真实评论里正面通常占七成以上负面可能只有一成多。公开数据集可以拿来补充冷启动但要注意它的领域和你的业务是不是一回事影评数据训出来的模型放到电商评论上效果会掉得很明显。标注环节我建议做两件事。第一双人标注加抽样复核抽 10% 的样本让两个人分别标算一下一致性如果一致率低于 85%说明判定标准写得不够细先别急着扩大标注量。第二留一批困难样本单独存就是那种人看了也要犹豫三秒的句子训练时正常用但要单独统计模型在这批样本上的表现它比整体准确率更能反映模型的真实水平。还有个小技巧把标注指南里的规则写成如果……则标为……的短句别写成大段论述。标注同学是流水线作业看大段文字会跳读。1.3 为什么最后选了 BERT而不是更轻的方案方案选型这块我做过对比结论不是BERT 一定好而是在数据量够、算力够的前提下BERT 的性价比最高。下面这张表是我在同一个中文评论数据集约 3 万条正负各半上的实测对比指标都是验证集上的 macro-F1方案macro-F1单条推理耗时CPU训练成本备注情感词典加规则0.711ms无反讽、黑话几乎全错字符级 TF-IDF 逻辑回归0.83约 0.5ms分钟级短文本表现尚可长句掉点轻量预训练模型6 层左右0.90约 8ms单卡十几分钟性价比很高适合线上量大的场景BERT 基础版12 层0.94约 25ms单卡半小时到一小时上限高适合对精度敏感的场景看到这张表你就明白了如果你的业务每秒要处理几千条评论、服务器只有 CPU那轻量模型才是正解如果是做舆情日报、差评预警这种对精度敏感、对延迟不敏感的场景BERT 基础版更划算。选型的关键从来不是模型多新而是你的约束条件是什么。另外解释一下 BERT 为什么比词袋强。词袋模型把句子当成一堆词的集合不和好分开看合起来的意思它抓不到BERT 用的是自注意力机制每个字在编码自己的时候会去看句子里其他所有字所以不会实打实地压低好的表示。打个比方词袋像是把一篇文章剪成一个个词扔进袋子BERT 像是有人一边读一边做笔记前后文的关系都记着。2. 环境准备与模型选型的具体操作2.1 依赖版本别乱升这是我交过学费的地方深度学习环境最怕的就是我本地能跑。我这几年养成的习惯是新项目第一件事不是写代码是把版本号钉死写进 requirements 或者环境文件里。python3.9 torch2.0.1 transformers4.35.2 scikit-learn1.3.2 pandas2.1.3 numpy1.24.4为什么强调这个我有一次在训练机上用的是新版 transformers本地是旧版同一份代码保存下来的模型在另一台机器上加载时报了一堆 key 不匹配的警告排查了两个小时才发现是配置序列化的细节变了。版本钉死不是洁癖是省时间。还有一点显卡驱动和 CUDA 版本要匹配。装完环境先跑一句torch.cuda.is_available()和torch.cuda.get_device_name(0)确认能识别到卡。如果返回 False八成是装成了 CPU 版卸了重装带 CUDA 后缀的版本别在那边调代码方向就错了。2.2 中文预训练模型怎么挑中文可选的预训练模型不少按我的使用经验可以这么分通用中文基础版12 层、隐藏维度 768、约 1.1 亿参数最稳的起点社区资料多遇到问题好搜。整词掩码版本预训练时按词做掩码而不是按字中文语义上更贴合短文本分类通常比基础版高半个到一个点。轻量版层数减半或隐藏维减半线上 CPU 部署的首选精度损失通常在两三个点以内。领域继续预训练版本如果你手上有一批无标注的领域文本比如几十万条商品评论可以在基础版上再做一轮掩码语言模型训练冷启动效果提升明显但这一步比较费机器建议作为二期优化。我给的建议很直接第一版就用通用中文基础版跑通全流程别在选型上纠结。等你把流程跑顺、有了基线指标再去试其他模型做个 A/B 对比哪个好用数据说话。见过太多人在选型阶段耗掉一周模型一个都没训起来。2.3 目录结构让三个月后的自己看得懂sentiment_bert/ ├── configs/ │ └── base.yaml ├── data/ │ ├── raw/ │ ├── processed/ │ └── splits/ ├── src/ │ ├── dataset.py │ ├── train.py │ ├── evaluate.py │ ├── predict.py │ └── utils.py ├── outputs/ │ ├── checkpoints/ │ └── logs/ └── requirements.txt这个结构没什么花哨的好处是把原始数据—处理后数据—划分文件—模型产物—日志分层了。特别说一下splits/这个目录把训练/验证/测试的划分结果固定成文件别每次跑都用随机种子重新切。我吃过亏两次训练用了不同的切分指标差了两个点还以为是改了超参的功劳白高兴一场。3. 数据处理环节真正决定效果上限的地方3.1 文本清洗到什么程度算合适清洗这事儿做少了模型学到噪声做多了把信号也洗掉了。我的经验是只做无损清洗也就是清完之后人读起来意思不变、情感不变。具体做什么去掉首尾空白、连续重复的标点压缩成一个。把全角符号统一成半角数字、字母这个对分词影响挺大。处理 HTML 残留标签评论数据里特别常见。保留表情符号的中文描述比如把常见的笑脸符号替换成开心两个字。别直接删很多短评论的全部信息就在那个表情里。不该做什么别去停用词。BERT 的注意力机制自己会学哪些字不重要你手动删掉反而破坏了句子结构。别做分词再用空格拼回去。中文 BERT 用的是字级切分加子词切分你自己用分词工具切一遍等于把预训练时的输入格式打乱了效果会掉。别把错别字纠正掉。真实线上数据全是错别字你纠正了等于训练和线上分布不一致。提示清洗函数一定要写成纯函数并加单元测试跑之前先抽 20 条打印前后对比肉眼确认意思没变。这一步五分钟能挡掉绝大部分事故。3.2 长度统计和 max_len 怎么定序列长度直接决定显存和速度不能拍脑袋定 512。正确做法是先统计import numpy as np from transformers import BertTokenizerFast tokenizer BertTokenizerFast.from_pretrained(./pretrained/bert-base-chinese) lengths [] for text in train_texts: ids tokenizer.encode(text, add_special_tokensTrue) lengths.append(len(ids)) lengths np.array(lengths) for p in [50, 90, 95, 99, 99.9]: print(fP{p}: {np.percentile(lengths, p):.0f}) print(max:, lengths.max(), mean:, lengths.mean())假设你跑出来 P50 是 28、P95 是 76、P99 是 132那么取96 或 128都是合理的。我的原则是覆盖到 P99 就够了剩下那 1% 的长文本截断带来的损失远小于为了它们把 max_len 拉到 512 造成的显存和速度浪费。真要处理超长文本正确做法是切段推理再融合而不是硬拉长度。算一下显存的账注意力部分的计算量随序列长度是平方增长的128 相比 512注意力计算量大约是 1/16。BERT 基础版在混合精度下、batch size 32、max_len 128 时显存占用大概 6 到 8G一张 12G 的卡能轻松跑如果把长度拉到 512同样的 batch size 直接爆显存只能把 batch 降到 8训练时间翻几倍。3.3 Dataset 和 DataLoader 的写法import torch from torch.utils.data import Dataset class SentimentDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len128): 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_lengthself.max_len, paddingmax_length, truncationTrue, return_attention_maskTrue, return_tensorsNone, ) return { input_ids: torch.tensor(enc[input_ids], dtypetorch.long), attention_mask: torch.tensor(enc[attention_mask], dtypetorch.long), token_type_ids: torch.tensor(enc[token_type_ids], dtypetorch.long), labels: torch.tensor(self.labels[idx], dtypetorch.long), }这里有两个细节值得说。一是attention_mask补零的位置要靠这个掩码告诉模型这些不算漏了它模型会把补的零当成真实内容学指标会莫名掉几个点而且很难查。二是paddingmax_length还是动态补齐训练时用固定长度省事、对显存友好好控制推理时建议用动态补齐一个 batch 内补到该 batch 最长能省不少时间。另外中文基础版模型有token_type_ids做单句分类时它全是 0没什么信息量但传进去不会有坏处如果换成某些只接受两个输入的模型变体记得把这个字段去掉不然会报参数不匹配。4. 模型结构与训练循环把每个参数的理由讲清楚4.1 分类头怎么接dropout 放多少最省事的做法是直接用现成的序列分类封装一行就能拿到带分类头的模型from transformers import BertForSequenceClassification model BertForSequenceClassification.from_pretrained( ./pretrained/bert-base-chinese, num_labels2, )它内部干的事是取[CLS]位置的输出向量768 维过一层 dropout再过一层线性层映射到类别数。为什么用[CLS]因为这个位置在预训练时就承担了汇总整句信息的角色是天然的句向量出口。dropout 默认 0.1我一般不改。只有在小数据集上比如一万条以内过拟合明显时我会调到 0.2 或 0.3同时把学习率再降一点。反过来数据量几十万条时0.1 就够调高反而欠拟合。如果你要做多标签一条评论同时有质量差和物流慢两个标签把损失换成带 sigmoid 的版本别用 softmax。这个坑我见人踩过不止一次。4.2 学习率、batch size、warmup 的取值逻辑微调预训练模型学习率是最敏感的超参没有之一。经验区间是1e-5 到 5e-5超出这个区间要么学不动要么直接把预训练学到的知识冲垮。具体怎么定看 batch sizebatch 16 用 2e-5batch 32 用 3e-5这是我实测比较稳的组合。为什么 batch 大了学习率可以大一点因为梯度是多个样本的平均噪声更小步子可以迈大些。warmup 比例取 10%。前 10% 的步数里学习率从 0 线性爬到设定值之后再线性衰减到 0。这一步不是可选项跳过 warmup 直接上 3e-5我遇到过训练前几百步 loss 剧烈震荡、最后收敛到一个比较差的结果。原因很直观模型刚加载时分类头是随机初始化的梯度很脏一上来就给大学习率会把主干网络的参数也带偏。算一下步数假设训练集 27000 条、batch 32、训练 3 轮每轮步数 27000 / 32 ≈ 844 总步数 844 × 3 2532 warmup 步数 2532 × 0.1 ≈ 253优化器用 AdamW权重衰减 0.01。这里有个细节权重衰减不要加到 bias 和 LayerNorm 参数上这是很多开源实现里的标准做法能带来一点点稳定收益。实现上就是把参数分成两组一组带衰减一组不带。no_decay [bias, LayerNorm.weight] 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 torch.optim.AdamW(optimizer_grouped_parameters, lr3e-5)4.3 完整的训练循环与早停策略import torch from torch.optim.lr_scheduler import LambdaLR from sklearn.metrics import f1_score def train_one_epoch(model, loader, optimizer, scheduler, device, scalerNone): model.train() total_loss 0.0 for batch in loader: batch {k: v.to(device) for k, v in batch.items()} optimizer.zero_grad() if scaler is not None: # 混合精度 with torch.cuda.amp.autocast(): out model(**batch) loss out.loss scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() else: out model(**batch) loss out.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() 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: batch {k: v.to(device) for k, v in batch.items()} logits model(**batch).logits preds.extend(torch.argmax(logits, dim-1).cpu().tolist()) golds.extend(batch[labels].cpu().tolist()) return f1_score(golds, preds, averagemacro), preds, golds几个必须加的东西梯度裁剪 1.0防止个别坏样本导致梯度爆炸混合精度torch.cuda.amp显存能省三四成、速度能快一到两倍几乎没精度损失按 macro-F1 存最优模型而不是按 loss因为 loss 低不代表分类效果好尤其在类别不平衡时。早停我一般设 patience 等于 2连续两轮验证集 macro-F1 没提升就停。多数情况下 3 到 4 轮就收敛了训练超过 5 轮基本都是过拟合验证集指标开始往下走而训练集还在涨——这就是模型开始背答案了。注意每轮结束一定要打印训练集和验证集的指标对照只盯一个数看不出问题。两个都在涨是正常训练涨验证跌是过拟合两个都不动是学习率或数据出了问题。5. 评估口径与推理封装别只看一个准确率5.1 准确率会骗人看这四个指标真实业务数据里正面占八成的时候一个把所有样本都判成正面的模型准确率能到 0.8看着挺美实际一点用没有。所以至少要一起看指标含义什么场景重点看准确率整体判对的比例类别均衡时的粗看指标macro-F1各类 F1 的平均对小类敏感类别不平衡时的主要指标负面类召回真实负面里被找出来的比例差评预警、舆情监控负面类精确率判为负面的里真的负面的比例自动拦截、自动处罚这四项经常互相打架。你要做的是先明确误判代价漏掉一条差评和误报一条差评哪个更贵如果漏报更贵就把判定阈值往下调比如从 0.5 调到 0.35用精确率换召回率如果误报会触发自动处罚、误伤用户那就把阈值往上调。阈值怎么定不要靠感觉拿验证集画出不同阈值下的精确率和召回率曲线找业务能接受的那个点。这一步能带来一两个点的实际收益比调模型结构省事多了。5.2 混淆矩阵和错误样本分析评估脚本跑完别只看数字把错判的样本捞出来看二十条errors [(t, g, p) for t, g, p in zip(test_texts, golds, preds) if g ! p] for t, g, p in errors[:20]: print(f真实{g} 预测{p} | {t})我看过的错判样本八成能归进三类反讽真是太好了收到就是坏的、否定嵌套不能说不好用、信息不足还行吧。前两类可以考虑补充同类样本进训练集第三类属于标注标准问题得回到标注指南去解决不是模型能救的。这个动作我强烈建议做成固定流程每次训练完都跑一遍。凭数字调参是盲调看错误样本才知道往哪使劲。5.3 推理封装让它能被别人用起来class SentimentPredictor: def __init__(self, model_dir, max_len128, deviceNone): self.tokenizer BertTokenizerFast.from_pretrained(model_dir) self.model BertForSequenceClassification.from_pretrained(model_dir) self.device device or (cuda if torch.cuda.is_available() else cpu) self.model.to(self.device).eval() self.max_len max_len torch.no_grad() def predict(self, texts, batch_size64, threshold0.5): results [] for i in range(0, len(texts), batch_size): chunk texts[i:i batch_size] enc self.tokenizer( chunk, max_lengthself.max_len, truncationTrue, paddingTrue, return_tensorspt, ).to(self.device) prob torch.softmax(self.model(**enc).logits, dim-1)[:, 1] results.extend([ {label: int(p threshold), score: float(p)} for p in prob.cpu() ]) return results几个工程细节model.eval()和torch.no_grad()必须加前者影响 dropout后者省显存推理时用paddingTrue动态补齐比固定长度快一到两倍batch size 设 64太小了吃不满显卡太大了容易 OOM这个数可以按机器试分词器和模型一起保存和加载不要分开维护曾经因为分词器版本不一致导致线上效果莫名掉了三个点查了一整天才定位到。上线前还有一件事拿一批线上真实数据不是验证集跑一遍统计一下预测分布的偏移。如果线上正面比例明显高于验证集说明数据分布有漂移得重新采样训练数据。6. 常见问题排查速查表6.1 训练不收敛、指标卡住不动这是最高频的问题我按概率从高到低排一张表现象最可能原因排查动作loss 几乎不变接近 0.69学习率太小或标签全错位打印一批标签和预测值对照学习率翻倍试loss 变 NaN学习率过大或混合精度溢出降到 1e-5关掉半精度先跑通训练集指标涨验证集跌过拟合减轮次、加 dropout、加数据训练验证都不动接近随机数据和标签没对齐检查 DataFrame 的 index 重置指标异常高0.99数据泄漏检查训练验证是否有重复样本数据和标签没对齐这条特别隐蔽常见于你对原始表做过dropna()或筛选后没有reset_index(dropTrue)结果文本取到了错位的标签。表现就是模型怎么调都是五五开很容易误判成学习率问题。6.2 显存爆了、训练太慢显存问题的处理顺序我建议按这个优先级先把max_len从 512 降到 128收益最大、代价最小再开混合精度再降 batch size 并配合梯度累积保持等效 batch最后才考虑换小模型。梯度累积的写法batch size 设 8累积 4 步再更新一次参数等效 batch 是 32显存只占 8 的用量。注意用梯度累积时学习率的调度步数要按更新次数算不是按前向次数算算错了 warmup 会变得很奇怪。训练慢的话先确认卡真的在跑nvidia-smi看利用率如果利用率一直在 20% 上下多半是数据加载成了瓶颈把 DataLoader 的num_workers调到 4 或 8pin_memoryTrue能快不少。我遇到过最离谱的一次是数据在机械硬盘上、每轮都在随机读小文件把数据先加载进内存后训练时间从四十分钟降到十几分钟。6.3 离线指标好线上效果掉这是最让人头疼的因为问题不在模型本身。常见原因有三个一是数据分布偏移。训练数据是三个月前的线上是今天的用词、热点、商品都变了。解决办法是定期用新数据做增量训练或全量重训并且监控线上预测分布的均值变化一旦偏移超过阈值就报警。二是预处理不一致。训练时做了清洗线上忘了做或者两边的清洗函数版本不同。这类问题的典型表现是某些特定格式的输入比如带表情的短评论全判错。解决办法是把预处理和分词打包进模型服务里别让调用方自己处理。三是阈值和标签口径不一致。训练时正类标签是 1线上代码里写成了判断 score 0.5整个意思反了。这种情况通常会在灰度阶段被发现所以上线一定要先小流量灰度拿人工标注的一百条做对照指标对得上再放全量。6.4 几个容易被忽略的实操细节固定随机种子random、numpy、torch、cuda四处都要设还要设torch.backends.cudnn.deterministic True。不设的话两次训练指标能差一个点你会误以为是改参数的功劳。保存最优而不只保存最后一轮早停触发时的模型和最后一代模型指标可能差两三个点。记录每次实验的配置参数、数据版本、指标写在同一个表里。我习惯用 CSV 记一行一次实验后来回看能省很多上次那个参数是多少来着的时间。别在小样本上做大决策验证集只有两百条时指标波动两三个点是正常的别为这点波动去改模型结构多半是噪声。7. 我自己的几条经验模型这块我现在的做法是先跑一个最朴素的基线固定长度 128、学习率 3e-5、batch 32、三轮、warmup 10%别的都不动。跑完拿到一个数再一个一个改参数每次只改一个。之所以这么干是因为一开始就调一堆超参最后效果好你也不知道是谁的功劳效果差更不知道往哪查。基线跑通这个动作看起来笨实际最省时间。还有一点关于数据的心得。我在几个项目里的共同感受是加三百条高质量困难样本带来的提升往往比调一整天超参更大。尤其是反讽和否定句这两类模型的短板非常集中。所以每轮训练完把错判样本捞出来人工挑出一批补进训练集这个循环比什么都有效。最后分享一个部署侧的小技巧。如果你要上 CPU 推理可以在训练完之后做一次动态量化把线性层的权重从 32 位浮点压到 8 位整数模型体积能小到原来的四分之一左右CPU 推理速度能快两倍上下精度损失通常在半个点以内。做之前先在自己的人工评测集上对比一遍确认业务能接受再上别拍脑袋直接换。