
简介本资源面向希望入门或进阶情绪识别与情感分析的开发者与研究人员提供一套基于Transformer的完整项目实战方案覆盖文本、语音、视频等多模态情感信号处理场景。压缩包共19个文件以14个Python源码文件为核心辅以3个pkl数据文件与2个Markdown说明文档整体约506KB源码涵盖数据加载、模型定义、训练评估与集成等模块pkl文件则承载训练、验证与测试数据。项目围绕MOSEI_UMONS多模态情感数据集展开涉及数据清洗、分词向量化、自注意力建模、指标评估与调优等关键环节并配有流程教程指导环境搭建与结果分析。目前已有154人学习适合作为课程设计、科研入门或技术选型的参考案例帮助读者快速理解Transformer在情感计算中的落地路径。1. 情绪识别与Transformer一条被低估的落地路径做客服质检的朋友跟我吐槽过一件事他们用传统 CNNBiLSTM 跑情绪识别准确率卡在 78% 上不去badcase 集中在「反讽」和「长句转折」上。比如「你们这服务真是好得让我想投诉」模型判成正向。后来换成 Transformer 做情感分析同样的数据准确率直接拉到 86%反讽类样本的召回率翻了近一倍。这不是个例Transformer 在情绪识别任务上的优势核心在于自注意力机制能同时捕捉「好」和「投诉」之间的长距离依赖而 RNN 系模型在长句上会衰减。这篇要聊的就是基于 Transformer 实现情绪识别和情感分析的完整落地路径。从数据清洗、标签体系设计、模型选型到训练调参、推理部署再到实际项目里那些让人翻车的坑。适合两类人一是手里有标注数据、想从传统模型迁移到 Transformer 的算法工程师二是拿到一份项目源码但跑不通、不知道参数怎么改的开发者。不聊虚的直接上可复现的步骤和参数。2. 情绪识别任务拆解与Transformer选型为什么不是BERT一上来就微调2.1 情绪识别和情感分析到底是不是一回事很多人把这两个词混着用但在工程落地时它们的目标不同。情感分析通常指二分类或三分类正向、负向、中性输出的是一个极性判断。情绪识别则更细比如 Ekman 六分类高兴、悲伤、愤怒、恐惧、惊讶、厌恶输出的是具体情绪类别。项目标题里把两者并列实际落地时通常是「先做情感极性再做情绪细分」的两阶段或者直接用一个多标签模型同时输出。选型前先确认你的标签体系。如果是客服场景建议用「极性强度」的二维标注比如正向-强、正向-弱、负向-强、负向-弱。这样比单纯六分类更贴近业务因为「有点生气」和「非常愤怒」的处理优先级完全不同。我一般会先用规则或小模型做极性预筛再用 Transformer 做细分类这样能省不少标注成本。2.2 Transformer 做情绪识别的三个关键优势第一个是长距离依赖。情绪表达经常是「前面铺垫、后面反转」比如「本来挺期待的结果打开一看呵呵」。RNN 系模型到句尾时前面的信息已经衰减了而自注意力可以直接把「期待」和「呵呵」关联起来。第二个是并行计算训练速度比 LSTM 快 3 到 5 倍尤其在你做超参搜索时优势明显。第三个是预训练权重BERT、RoBERTa 这些模型已经在海量文本上见过各种情绪表达微调时收敛快小样本下也能有不错的效果。但要注意不是所有场景都值得上 Transformer。如果你的数据量小于 5000 条标签噪声又大传统 TF-IDF SVM 可能更稳。Transformer 的优势在数据量过万、句子长度超过 30 个 token 时才会明显拉开差距。2.3 模型选型BERT、RoBERTa 还是蒸馏版选型看三个维度精度、推理延迟、显存。BERT-base 中文版是基线精度够用但推理延迟在 CPU 上大概 80-120ms 一条。RoBERTa-wwm-ext 在中文情绪识别上通常比 BERT 高 1-2 个点因为去掉了 NSP 任务训练更充分。如果要做线上实时推理建议用蒸馏版比如 TinyBERT 或 ALBERT精度掉 2-3 个点但延迟能降到 20ms 以内。我一般会先跑一个 BERT-base 的基线看验证集 F1 能不能到 0.85 以上。如果能到再试 RoBERTa 看有没有提升空间。如果基线就低于 0.75先别换模型回去查数据质量和标签一致性。from transformers import AutoTokenizer, AutoModelForSequenceClassification import torch # 加载中文预训练模型和分词器 model_name bert-base-chinese # 基线选型可替换为 hfl/chinese-roberta-wwm-ext tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForSequenceClassification.from_pretrained( model_name, num_labels6, # 情绪六分类按你的标签体系改 hidden_dropout_prob0.3, # 情绪识别容易过拟合dropout 调高 attention_probs_dropout_prob0.2 ) # 编码示例 text 你们这服务真是好得让我想投诉 inputs tokenizer( text, max_length128, # 情绪识别句子通常不长128 够用 paddingmax_length, truncationTrue, return_tensorspt ) outputs model(**inputs) print(outputs.logits.argmax(dim-1))这段代码做了三件事加载预训练权重、按你的标签数改分类头、对输入做编码。num_labels必须和你的标签体系一致改错了训练时 loss 会异常。hidden_dropout_prob设 0.3 是因为情绪识别数据集通常不大过拟合风险高dropout 比默认的 0.1 更稳。max_length设 128 是经验值中文情绪表达很少超过这个长度设太大浪费显存。3. 数据准备与标签体系从原始文本到模型可吃的格式3.1 数据清洗的四个必做步骤原始数据里通常有大量噪声直接喂给模型会拉低效果。第一步去重重复样本会让模型记住而不是学习。第二步去特殊字符但保留表情符号因为表情是情绪的重要信号。第三步处理长度异常样本超过 512 个 token 的截断少于 3 个字的直接丢弃。第四步检查标签一致性同一个句子被标了不同标签的要么修正要么剔除。我一般会写一个清洗脚本把每一步的过滤数量打出来方便判断数据质量。如果清洗后数据量掉了 30% 以上说明原始数据质量问题严重需要回去和标注团队对齐标准。import re import pandas as pd def clean_text(text): # 去除 URL 和 提及但保留表情符号 text re.sub(rhttp\S|\w, , text) # 去除多余空白 text re.sub(r\s, , text).strip() return text df pd.read_csv(raw_data.csv) # 假设有 text 和 label 两列 print(f原始数据量: {len(df)}) # 去重 df df.drop_duplicates(subset[text]) print(f去重后: {len(df)}) # 清洗文本 df[text] df[text].apply(clean_text) # 过滤长度异常 df df[df[text].str.len().between(3, 500)] print(f长度过滤后: {len(df)}) # 检查标签一致性 label_conflict df.groupby(text)[label].nunique() conflict_texts label_conflict[label_conflict 1].index df df[~df[text].isin(conflict_texts)] print(f标签一致性过滤后: {len(df)})清洗逻辑的核心是「保留情绪信号去掉无关噪声」。URL 和 提及对情绪判断没帮助去掉。表情符号要保留因为「」和「」直接对应情绪。长度过滤的下限设 3 是因为太短的文本没有上下文模型学不到东西。标签一致性检查是很多人忽略的一步但它是数据质量的关键指标。3.2 标签体系设计六分类还是多标签如果你的业务只需要判断「满意/不满意」二分类就够了。但如果要做精细化运营比如把负向情绪拆成「愤怒」「失望」「焦虑」就需要多分类。我建议先用层级标签第一层是极性正/负/中第二层是具体情绪。这样模型可以先学极性再学细分训练更稳定。多标签场景下一个句子可能同时有「愤怒」和「失望」这时候用 BCEWithLogitsLoss 而不是 CrossEntropyLoss。标签格式用 multi-hot 向量比如 [1, 0, 1, 0, 0, 0] 表示同时有第一和第三种情绪。3.3 数据集划分与类别不平衡处理划分比例一般是 8:1:1但情绪识别数据往往不平衡负向样本远多于正向。这时候不能随机划分要用分层采样保证每个类别在训练集和验证集里的比例一致。如果某个类别样本少于 500 条考虑过采样或数据增强。from sklearn.model_selection import train_test_split from torch.utils.data import Dataset, DataLoader import torch class EmotionDataset(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): encoding self.tokenizer( self.texts[idx], max_lengthself.max_len, paddingmax_length, truncationTrue, return_tensorspt ) return { input_ids: encoding[input_ids].squeeze(), attention_mask: encoding[attention_mask].squeeze(), labels: torch.tensor(self.labels[idx], dtypetorch.long) } # 分层划分 train_texts, val_texts, train_labels, val_labels train_test_split( df[text].tolist(), df[label].tolist(), test_size0.2, stratifydf[label], random_state42 ) train_dataset EmotionDataset(train_texts, train_labels, tokenizer) val_dataset EmotionDataset(val_texts, val_labels, tokenizer) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse)stratify参数是关键它保证划分后每个类别的比例和原始数据一致。batch_size训练时设 32 是显存和收敛速度的平衡点验证时可以设 64 因为不需要反向传播。max_len和模型选型时的设置保持一致改了就前功尽弃。4. 训练调参与推理部署让模型真正跑起来4.1 训练脚本的核心参数怎么设学习率是第一个要调的。BERT 系模型微调时学习率一般设 2e-5 到 5e-5太大容易震荡太小收敛慢。我一般从 3e-5 开始看 loss 曲线如果前 100 步 loss 不降调大到 5e-5如果 loss 震荡厉害降到 1e-5。warmup 比例设 0.1让模型先慢慢适应。批次大小受显存限制24G 显存下 BERT-base 最大能跑 batch_size64。如果显存不够用梯度累积比如 batch_size16 累积 4 步等效于 64。训练轮数一般 3 到 5 轮情绪识别数据量不大超过 5 轮基本就过拟合了。from transformers import AdamW, get_linear_schedule_with_warmup from torch.optim import AdamW import torch.nn as nn device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) # 优化器权重衰减防止过拟合 optimizer AdamW(model.parameters(), lr3e-5, weight_decay0.01) # 学习率调度warmup 线性衰减 total_steps len(train_loader) * 5 # 5 轮 scheduler get_linear_schedule_with_warmup( optimizer, num_warmup_stepsint(0.1 * total_steps), num_training_stepstotal_steps ) # 损失函数多分类用 CrossEntropy多标签用 BCEWithLogitsLoss criterion nn.CrossEntropyLoss() # 训练循环 for epoch in range(5): model.train() total_loss 0 for batch in train_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) optimizer.zero_grad() outputs model(input_ids, attention_maskattention_mask) loss criterion(outputs.logits, labels) loss.backward() # 梯度裁剪防止梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() total_loss loss.item() print(fEpoch {epoch1}, Loss: {total_loss/len(train_loader):.4f})weight_decay0.01是 BERT 微调的常用值能抑制过拟合。clip_grad_norm_设 1.0 是防止梯度爆炸情绪识别数据里偶尔有超长样本不加裁剪容易训练崩掉。warmup_steps设总步数的 10%让模型前期稳定。4.2 验证集评估准确率不够要看 F1 和混淆矩阵情绪识别不能只看准确率因为类别不平衡时准确率会骗人。比如负向样本占 80%模型全猜负向也有 80% 准确率但正向样本全错。要看宏平均 F1它给每个类别同等权重。还要看混淆矩阵找出哪些类别容易混比如「愤怒」和「厌恶」经常分不清这时候要考虑合并标签或加更多区分性特征。from sklearn.metrics import classification_report, confusion_matrix import numpy as np model.eval() all_preds [] all_labels [] with torch.no_grad(): for batch in val_loader: input_ids batch[input_ids].to(device) attention_mask batch[attention_mask].to(device) labels batch[labels].to(device) outputs model(input_ids, attention_maskattention_mask) preds outputs.logits.argmax(dim-1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) print(classification_report(all_labels, all_preds, digits4)) print(confusion_matrix(all_labels, all_preds))classification_report会输出每个类别的精确率、召回率和 F1。如果某个类别的召回率低于 0.6说明模型漏检严重需要检查该类别的训练样本是否太少。混淆矩阵能直观看到哪两个类别容易混比如「惊讶」和「恐惧」在文本上确实难分可以考虑合并。4.3 推理部署ONNX 导出和批量推理训练完的模型要部署到线上直接用 PyTorch 推理延迟高。常见做法是导出 ONNX用 ONNX Runtime 推理速度能快 2 到 3 倍。导出时注意 opset 版本中文 BERT 一般用 opset 11 或 12。import torch from transformers import AutoTokenizer, AutoModelForSequenceClassification # 加载微调后的模型 model AutoModelForSequenceClassification.from_pretrained(./finetuned_model) tokenizer AutoTokenizer.from_pretrained(./finetuned_model) model.eval() # 导出 ONNX dummy_input tokenizer(测试文本, return_tensorspt) torch.onnx.export( model, (dummy_input[input_ids], dummy_input[attention_mask]), emotion_model.onnx, input_names[input_ids, attention_mask], output_names[logits], dynamic_axes{ input_ids: {0: batch, 1: sequence}, attention_mask: {0: batch, 1: sequence}, logits: {0: batch} }, opset_version11 )dynamic_axes是关键它让导出的模型支持变长输入和动态批次。不加的话推理时只能跑固定长度线上会报错。导出后用 ONNX Runtime 加载推理代码大概长这样import onnxruntime as ort import numpy as np session ort.InferenceSession(emotion_model.onnx) inputs tokenizer(你们这服务真是好得让我想投诉, return_tensorsnp) outputs session.run(None, { input_ids: inputs[input_ids].astype(np.int64), attention_mask: inputs[attention_mask].astype(np.int64) }) pred_label np.argmax(outputs[0], axis-1) print(f预测情绪: {pred_label})ONNX Runtime 默认用 CPU如果有 GPU 可以装 onnxruntime-gpu推理延迟能降到 10ms 以内。批量推理时把多条文本一起编码吞吐量能提升 5 到 10 倍。5. 避坑与排查那些让模型翻车的细节5.1 现象训练 loss 正常下降但验证集 F1 始终不涨原因通常是数据泄露或标签错误。检查训练集和验证集有没有重复样本尤其是清洗时去重不彻底的情况。另一个可能是标签映射错了比如训练时 label 从 0 开始验证时从 1 开始模型学到的和评估的对不上。解决方法是打印训练集和验证集的前几条样本人工核对文本和标签是否匹配。5.2 现象模型在短句上表现好长句上崩了原因是max_length设太小长句被截断关键情绪词丢了。比如「虽然你们发货慢但是客服态度很好最后还是满意的」如果截断到 32 个 token可能只看到「发货慢」判成负向。解决方法是把max_length调到 256 或 512或者用滑动窗口取多段预测再融合。5.3 现象推理时显存溢出batch_size 降到 1 还是报错原因是 ONNX 导出时没设dynamic_axes模型固定了输入长度。线上推理时输入长度和导出时不一致就会报错。解决方法是重新导出加上dynamic_axes参数。另一个可能是 tokenizer 的padding设成了max_length每条都补到 512显存直接爆掉。改成paddingTrue按批次内最长样本补齐。5.4 现象模型对反讽样本几乎全错反讽是情绪识别的老大难因为字面意思和真实情绪相反。纯文本模型很难学到这种模式需要额外特征。常见做法是加入标点特征比如问号和感叹号的组合、表情符号特征或者在训练数据里专门加一批反讽样本做数据增强。如果业务场景反讽多建议单独训一个反讽检测的二分类模型先过一遍再进情绪分类。5.5 现象线上推理延迟波动大P99 超过 500ms原因是输入长度不一致长样本拖慢了整个批次。解决方法是做长度分桶把相近长度的样本放在一个批次里减少 padding 浪费。另一个可能是 tokenizer 成了瓶颈可以换成 fast tokenizer速度能快 3 到 5 倍。如果还不行考虑模型量化用 INT8 推理精度掉 1 个点左右延迟能降一半。6. 进阶技巧用对抗训练和置信度校准把 F1 再拉两个点模型训完之后如果 F1 卡在 0.85 上不去可以试两个进阶技巧。第一个是对抗训练在 embedding 层加扰动让模型对输入噪声更鲁棒。实现上用 FGM 或 PGD代码大概十几行但效果明显尤其在小样本场景下 F1 能涨 1 到 2 个点。class FGM: def __init__(self, model, epsilon1.0): self.model model self.epsilon epsilon self.backup {} def attack(self): for name, param in self.model.named_parameters(): if param.requires_grad and embedding in name: self.backup[name] param.data.clone() norm torch.norm(param.grad) if norm ! 0: r_at self.epsilon * param.grad / norm param.data.add_(r_at) def restore(self): for name, param in self.model.named_parameters(): if name in self.backup: param.data self.backup[name] self.backup {} # 训练时在 loss.backward() 后加一步 fgm FGM(model) loss.backward() fgm.attack() outputs_adv model(input_ids, attention_maskattention_mask) loss_adv criterion(outputs_adv.logits, labels) loss_adv.backward() fgm.restore() optimizer.step()FGM 的逻辑是在 embedding 上加一个沿梯度方向的扰动让模型在「最坏情况」下也能分对。epsilon控制扰动大小一般设 0.5 到 1.0太大反而掉点。注意对抗训练会让训练时间增加 30% 左右但推理时没有额外开销。第二个技巧是置信度校准。模型输出的 softmax 概率往往过于自信比如把 0.51 和 0.99 都当成确定预测。用温度缩放Temperature Scaling校准后可以设一个阈值低于阈值的样本转人工审核这样能显著降低线上误判率。具体做法是在验证集上拟合一个温度参数 T推理时把 logits 除以 T 再 softmax。from scipy.optimize import minimize import torch.nn.functional as F def calibrate_temperature(logits, labels): 在验证集上拟合温度参数 logits torch.tensor(logits) labels torch.tensor(labels) def loss_fn(T): scaled logits / T return F.cross_entropy(scaled, labels).item() result minimize(loss_fn, x01.0, bounds[(0.5, 3.0)]) return result.x[0] # 推理时应用 T calibrate_temperature(val_logits, val_labels) calibrated_probs F.softmax(torch.tensor(test_logits) / T, dim-1)温度参数 T 大于 1 会让概率分布更平滑小于 1 会更尖锐。情绪识别场景下 T 通常在 1.2 到 1.8 之间。校准后你可以设一个置信度阈值比如 0.7低于这个值的样本不直接输出转人工或走规则兜底。这一步在客服质检场景里特别有用能把误判率压到业务可接受的范围内。我自己的习惯是每次训完模型先不急着上线拿 200 条验证集样本人工过一遍看模型错在哪、置信度分布什么样。这个动作花不了半小时但能避免很多线上翻车。希望帮到你。本文还有配套的精品资源点击获取