ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于Transformer的情绪识别与情感分析:从零实现到工程落地

基于Transformer的情绪识别与情感分析:从零实现到工程落地 简介基于 Transformer 的情绪识别与情感分析项目面向想快速上手多模态情感计算、需要完整工程参考的开发者或研究人员可应用在人机交互、心理健康监测、市场分析等场景。包内共 19 个文件总大小约 506KB14 个 Python 脚本覆盖数据预处理、模型训练、评估、集成预测与结果可视化等环节3 个 pkl 文件用于直接加载训练/验证/测试集2 个 Markdown 文件提供项目说明与流程指导。项目采用 MOSEI_UMONS 多模态数据集涉及文本、语音与视频中的情感信号处理有助于理解自注意力机制在长序列建模中的优势。当前已有 154 人学习下载代码按功能模块组织可对照教程逐步复现。读者可获得整套可运行源码与流程教程体验从文本清洗、编码、分词、向量化到 Transformer 模型构建、调优和情感倾向判断的完整实践路径既适合课程设计与毕业课题也可作为后续开展情感分析实验的起点。1. 基于Transformer的情绪识别这个标题背后真正要交付的是什么我最近接到的很多咨询都指向同一个需求拿到一份类似“基于Transformer实现的情绪识别情感分析算法”的项目包源码、教程都在却不确定这东西拿起来能干什么。实际上它解决的任务很具体给一段文本、评论或对话算法先判断情感极性是正向还是负向再从愤怒、喜悦、悲伤、恐惧、惊讶、厌恶这些细粒度情绪里给出结论甚至同时输出极性和情绪两个维度。这种能力在舆情监控、电商评论分析、客服质检、社交内容审核里几乎每天都用得上。做这个项目不需要多强的服务器一张普通GPU就能训练门槛比图像检测低得多是理解Transformer架构如何落地NLP最好的实战入口。按标题对应的完整流程走一遍数据怎么定义、Transformer编码器怎么手写、训练调参怎么做、最容易翻车的几个位置最后讲怎么把模型导出成可调用的接口。适合正在复现Transformer源码、想把短文本情绪分析做成项目的开发者。2. 从文本到标签情感分析和情绪识别的边界在哪里2.1 情感极性和细粒度情绪两种任务不能混为一谈很多项目把“情绪识别”和“情感分析”写在一起但落地时的标签粒度完全不同。情感分析通常只把文本分成正向、负向、中性三个桶情绪识别则更像一个细粒度分类问题最常见的标签集是六类或八类。标题把两者并列很合理因为一个完整的情绪识别系统往往需要既输出文本极性又输出具体情绪类别。任务标签粒度常见标签应用场景情感分析粗粒度正向、负向、中性舆情监控、电商评价打分情绪识别细粒度愤怒、喜悦、悲伤、恐惧、惊讶、厌恶客服质检、内容审核、心理辅助情绪识别情感分析多任务/多输出极性 情绪类别用户情绪画像我一般会预置一张情绪到极性的映射表让模型在输出情绪的同时也能推导极性。以六类情绪为例emotion_labels [愤怒, 喜悦, 悲伤, 恐惧, 惊讶, 厌恶] emotion_to_polarity { 愤怒: -1, 喜悦: 1, 悲伤: -1, 恐惧: -1, 惊讶: 1, 厌恶: -1, }这段映射表里最需要留意的是“惊讶”。惊讶既可以是“惊喜”的正向也可以是“惊变”的负向。正因为这种不确定性我不建议用极性倒推情绪而是用情绪概率分布去修正极性预测。真正交付时很多业务方拿到的不是类别标签而是一个概率分布他们在后处理里自己决定阈值。情绪识别和情感分析在数据分布上的差异也很大。情感分析的三分类通常相对平衡但细粒度情绪数据天然不平均喜悦和愤怒往往占比很高恐惧和厌恶数量明显偏少。如果你打开一个情绪识别项目包第一件事不是跑源码而是统计标签分布。标签分布会决定你后面是单纯用CrossEntropy还是需要加类权重或者换成多标签损失。2.2 数据集选择与标签体系先看清标注来源再决定怎么训练标题对应的这类项目通常用的是短文本语料比如微博评论、客服对话、商品评价。样本数量常见在两万到十万条之间对从零训练的Transformer来说这个规模不算大因此数据质量比数据量更关键。情绪标签不像情感极性那么客观不同标注者之间的一致性本来就有限所以用现成数据集时要优先看它是不是多人标注、有没有给出标签置信度。我接手项目时一般会做一轮标签一致性检查同一句话在数据集中出现两次却带着不同标签说明噪声不小。比起直接删样本我更建议把低置信度样本保留但降低权重或者用标签平滑训练。情绪识别本身就带有主观判断强行把数据洗到完全一致反而会让模型失去泛化能力。有一个很常见的误用是只给情感极性标签却在部署时要求模型输出细粒度情绪。这种情况做不出真实效果因为“负向”不能可靠映射到“愤怒”还是“悲伤”。训练前必须明确输出维度要做六分类就准备六类标签要做多标签就允许一条样本同时属于愤怒和悲伤。多标签在情绪识别里很常见一个人对客服发火时往往是“愤怒失望”用一个argmax强制选一类信息就丢了。2.3 预处理和长文本截断策略从句子到token ID基于中文文本时我习惯用jieba分词后建词表再写一个固定最大长度的编码函数。这个函数也会在后面的训练和推理阶段反复使用是非常重要的基础实现import jieba def build_vocab(texts, min_freq2, max_vocab50000): counter {} for text in texts: for word in jieba.cut(text.strip()): counter[word] counter.get(word, 0) 1 vocab {PAD: 0, UNK: 1, CLS: 2} for word, freq in sorted(counter.items(), keylambda x: -x[1]): if freq min_freq or len(vocab) max_vocab: break vocab[word] len(vocab) return vocab def encode_text(text, vocab, max_len128): tokens [CLS] list(jieba.cut(text.strip()))[: max_len - 2] ids [vocab.get(t, vocab[UNK]) for t in tokens] ids [vocab[PAD]] * (max_len - len(ids)) return ids[:max_len]build_vocab的min_freq2过滤掉只出现一次的噪声词对从零训练的Transformer很关键。词表太大不仅占用embedding矩阵还会让模型很难在有限数据中学习到低频词的有效表示。max_len设置为128是短文本情绪识别的常用值中文一句话平均二三十个字128个词足够覆盖。但长评论很容易超过这个长度直接截断会让末尾情绪词丢失我常用的办法是先拼text[:64] text[-64:]再编码既能保留开头语境又覆盖结尾的转折。预处理阶段还要同步生成mask后面会用它遮蔽padding位置。mask逻辑很简单token ID不等于0的位置为True代表在注意力计算中需要被忽略。Transformer和传统RNN不同它不会天然按顺序读句子如果不加mask空白padding也会参与注意力计算导致模型莫名其妙把注意力放到空位上。3. 从零复现Transformer编码器情绪识别模型的落地实现3.1 为什么选择Transformer而不是BiLSTMAttention很多老项目会用BiLSTM加注意力来做情感分类效果也不差但有两个瓶颈串行计算速度慢以及长距离依赖不够直接。Transformer用自注意力让任意两个词直接交互这对转折句特别有帮助。比如“产品外观不错但客服态度让人火大”前半句是正向词后半句是负向情绪BiLSTM需要把信息一步步往后传Transformer在第一步就能让最后一个token注意到“火大”和“客服”。这就是情绪识别场景里Transformer的优势所在。Transformer的并行性也决定了训练效率更高。同样的数据量在一张普通GPU上从零训练的Transformer编码器比BiLSTM更容易跑满算力。再加上PyTorch原生提供了TransformerEncoderLayer手写一个编码器并没有想象中那么复杂。标题里提到的“附项目源码”本质就是把这部分封装好真正需要自己理解的其实只有位置编码、注意力掩码和分类头。3.2 最小可跑通的Transformer手写编码器代码下面是一个适合情绪识别项目的最小实现。它不依赖HuggingFace的预训练模型而是从词表索引出发走一遍完整编码器流程是理解Transformer架构及其工作原理的很好的入手点。import math import torch import torch.nn as nn class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len512): super().__init__() pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) div_term torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) pe[:, 1::2] torch.cos(position * div_term) self.register_buffer(pe, pe) def forward(self, x): return x self.pe[:x.size(1)] class EmotionTransformer(nn.Module): def __init__(self, vocab_size, d_model128, nhead4, num_layers2, num_classes6, dropout0.1): super().__init__() self.embedding nn.Embedding(vocab_size, d_model) self.pos PositionalEncoding(d_model) encoder_layer nn.TransformerEncoderLayer( d_model, nhead, dim_feedforwardd_model * 4, dropoutdropout, batch_firstTrue ) self.encoder nn.TransformerEncoder(encoder_layer, num_layers) self.cls_token nn.Parameter(torch.randn(1, 1, d_model)) self.fc nn.Linear(d_model, num_classes) def forward(self, token_ids, mask): src self.embedding(token_ids) * math.sqrt(self.embedding.embedding_dim) batch_size token_ids.size(0) cls_tok self.cls_token.expand(batch_size, -1, -1) src torch.cat((cls_tok, src), dim1) cls_mask torch.zeros(batch_size, 1, dtypetorch.bool, devicetoken_ids.device) mask torch.cat((cls_mask, mask), dim1) src self.pos(src) out self.encoder(src, src_key_padding_maskmask) return self.fc(out[:, 0, :])这段代码里PositionalEncoding实现了Transformer论文里的正弦位置编码。位置信息的计算方式很简单每个位置用一组不同频率的正弦和余弦函数表示偶数维度用sin奇数维度用cos。这样一来相邻位置编码相近远处位置编码不同模型就能感知词与词之间的距离。div_term是指数递减的系数它让高维位置编码变化更平滑。EmotionTransformer在序列最前面加了一个CLS向量用它的最终表示来分类而不是对整句做平均池化。这个选择和文本分类任务有关情绪往往由整句的组合表达CLS向量在训练中会学习聚合整段信息。使用nn.TransformerEncoderLayer时打开batch_firstTrue能让输入维度写成(batch, seq, d_model)更符合直觉。mask属性和CLS拼接需要注意原始mask对padding位置为TrueCLS位置不应该被掩盖所以用torch.zeros初始化CLS mask再拼接。这里如果写错注意力会把CLS也忽略掉模型就学不到任何区分性信息训练loss会一直不降。来自从零手写Transformer的人最常在这里翻车。3.3 编码器参数怎么设层数、头数、隐藏维度与训练成本从零训练和微调预训练模型是两套参数逻辑。微调BERT时可以用12层、768维因为基础权重已经学会语言结构但从零训练一个小型情绪识别模型照搬大模型参数只会让训练变得极慢而且很容易过拟合。参数推荐取值理由d_model64~128小数据下过大的嵌入维度会让注意力矩阵稀疏nhead4128/432维度每组注意力头足够表达局部语义num_layers2~4编码器堆叠太多层在小数据集上反而过拟合max_len128短文本情绪识别用不到512的长序列dim_feedforwardd_model*4Transformer默认设置小项目可用d_model*2有同学问“Transformer编码部分有多少编码器”这个问题换个说法就是encoder里要堆叠多少层。原版Transformer是6层但情绪识别小项目通常2~4层就够了。注意力头的数量必须能整除d_model如果d_model128、nhead4每个头负责32维子空间如果设置成5PyTorch会在初始化时报维度错误这是最容易发现的问题。训练成本方面参数量主要来自embedding矩阵词表五万、d_model128时这部分就有640万参数。编码器本身反而只占一小半。如果显存紧张仅调小d_model就能成倍减少参数。先把层数和维度降下来跑通流程再逐步增加这是复现这类项目最稳的做法。4. 训练流程与调参让情绪识别项目从“能跑”到“能用”4.1 训练集划分与类权重的设置情绪识别数据通常类别不均衡划分数据集时必须按标签分层抽样。直接用train_test_split而不加stratify容易让验证集里某个低频情绪一个样本都没有模型就完全无法验证它是否学会了“恐惧”和“厌恶”。from sklearn.model_selection import train_test_split from torch.utils.data import Dataset import torch class EmotionDataset(Dataset): def __init__(self, texts, labels, max_len128): self.texts texts self.labels labels self.max_len max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): ids encode_text(self.texts[idx], vocab, self.max_len) mask torch.tensor([t ! 0 for t in ids], dtypetorch.bool) return torch.tensor(ids), mask, torch.tensor(self.labels[idx]) train_texts, val_texts, train_labels, val_labels train_test_split( texts, labels, test_size0.1, stratifylabels, random_state42 )stratifylabels按标签比例抽样保证验证集和训练集的情绪分布大致一致。random_state固定后多次实验结果可复现方便对比调参。针对不均衡我很少直接把稀有类别复制几倍而是用带权重的交叉熵。权重计算方式是用样本数量的倒数归一化低频类拥有更高权重counts torch.bincount(torch.tensor(train_labels)) class_weights 1.0 / counts.float() class_weights class_weights / class_weights.mean() criterion nn.CrossEntropyLoss(weightclass_weights)CrossEntropyLoss的weight参数会在计算每个样本loss时乘以对应类别权重。这样愤怒类哪怕只有10%的比例也能在总loss里占到合理分量。但要注意权重太大会让训练初期震荡建议先把权重归一化到均值1附近再根据验证F1微调。4.2 训练循环、梯度裁剪、学习率与早停从零训练Transformer时我习惯在训练循环里固定做四个动作梯度裁剪、学习率warmup、验证指标换成宏F1、早停恢复最优参数。这四个动作缺一个模型都很难从“拟合训练集”走向“在验证集上可用”。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0.0 for ids, mask, label in loader: ids, mask, label ids.to(device), mask.to(device), label.to(device) optimizer.zero_grad() logits model(ids, mask) loss criterion(logits, label) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() total_loss loss.item() return total_loss / len(loader)clip_grad_norm_把梯度范数限制在1.0以内。从零训练的Transformer很容易因为attention层数值敏感而梯度爆炸加了这一步后训练曲线会稳定很多。学习率上Adam建议从1e-3起手如果loss在第一个epoch就震荡再降到5e-4或1e-4。每轮结束时算验证集宏F1连续三轮不提升就加载上一轮保存的best模型而不是继续跑空轮次。早停要看宏F1而不是准确率。因为情绪不均衡准确率往往看起来很高但稀有类别可能完全没学宏F1把所有类别一视同仁某个类别召回率崩掉时会被直接反映出来。项目源码里如果只打印了accuracy我会建议改掉这是情绪识别项目里投入产出比最高的一行改动。4.3 评估指标准确率、宏F1和混淆矩阵训练完成后用测试集跑一遍完整评估。情绪识别项目不像纯情感分析那样看准确率就够我更习惯看每个类别的精确率、召回率和F1再叠加混淆矩阵判断错误类型。from sklearn.metrics import classification_report, confusion_matrix all_preds [] model.eval() with torch.no_grad(): for ids, mask, _ in val_loader: ids, mask ids.to(device), mask.to(device) logits model(ids, mask) all_preds.extend(logits.argmax(dim1).cpu().tolist()) print(classification_report(val_labels, all_preds, target_namesemotion_labels)) print(confusion_matrix(val_labels, all_preds))classification_report会输出每个情绪类别的F1。遇到“悲伤”类的F1明显低于其他类多数情况是样本不够少部分是标签含混。混淆矩阵里如果“悲伤”经常被预测成“愤怒”这是能理解的因为两种情绪在社交文本里常一起出现但“喜悦”被预测成“悲伤”就要怀疑数据标注错误。部署到真实业务前我会额外设置一条底线任意情绪类别召回率低于30%时模型不能直接交付。召回率低意味着这一类情绪大量漏掉即使整体准确率达到80%业务侧看到的也是“模型完全没识别出用户在生气”。舆情情感倾向分析系统尤其看重这种召回表现宁可精确率低一点也要保证愤怒和悲伤这类高风险情绪不丢。5. 情绪识别项目避坑我踩过的5个关键问题及排查方案5.1 验证集F1比准确率低10个百分点类别不平衡惹的祸现象训练日志里准确率一路涨到80%以上宏F1却始终在65%附近徘徊。仔细看每个类别的报告发现“喜悦”的精确率很高“恐惧”和“惊讶”几乎不预测。原因不是模型学不会而是多数类主导了梯度更新网络发现只要全部预测成“喜悦”就能拿到不错的准确率。解决方法是先加类权重再考虑阈值调整。我一般先跑一轮带class_weights的训练看低频类召回是否上升如果依旧偏低就在验证集上搜索每个类别的判定阈值见后文的校准方法。不要同时上权重和阈值否则两个因素叠加会让验证集好的配置在新数据上失效。5.2 长文本截断把关键情绪词丢在后面现象短文本验证F1不错但一测真实长评论预测结果明显偏离。原因多半是encode_text只在句子前128个词范围内截断而情绪转折发生在后半段。比如“物流很快包装也好但是客服全程不回复”这句话前半段全是正向词情绪落在最后三个字。解决方案是头尾截取法把文本前64个词和后64个词拼在一起再走编码流程。如果还要更细就把长文本切成多个滑窗分别过模型后取平均概率但成本更高。情绪识别的长文本坑比情感分析更致命因为情感极性可能由开头决定而愤怒、失望往往在最后亮出来。5.3 标签噪声让模型训练震荡现象训练loss不断下降但验证F1曲线像锯齿而且同一句话重复预测时输出不稳定。打开训练集一看相似的文本有的标“愤怒”、有的标“厌恶”。原因是情绪标签本身就主观多人标注不一致很正常。解决方法是先做标注一致性清洗训练时给模型加标签平滑label_smoothing0.1。标签平滑不是让模型“变笨”它会把hard label从1.0改成0.9和0.1降低模型对错误标签的绝对信任。情绪识别项目里我反而建议不要把数据洗得太完美保留一部分边界样本因为真实业务里用户情绪本来就是混合的。5.4 Transformer自带padding导致显存OOM现象batch_size设成64后刚开始训练就报CUDA out of memory。原因是在同一个batch里文本长度差异很大如果全部填充到max_len128短句子旁边大量空白位也在参与Transformer计算浪费显存。解决方法是按长度分桶先把训练集按句子长度排序再每个桶内单独padding到该桶最大长度。这样短句子不会因为长句子而白白多算。如果显存仍然不够就用梯度累积把batch_size32拆成两次16的微批次梯度累加后再更新参数。项目源码如果默认固定padding到统一长度记得改掉这是从零训练Transformer最常见的显存杀手。5.5 分词器和词表不一致导致跑通后效果骤降现象本地验证F1不错换一台电脑推理结果完全不对劲。原因很隐蔽训练时用的build_vocab和推理时用的词表不是同一份。可能推理脚本里重新跑了一遍build_vocab但训练语料只加载了一部分导致大量词落进UNK或者分词函数从jieba换成了别的分词器同一个词被切成不同token。解决方案是把词表和分词函数统一导出推理时直接加载固定的vocab文件不要再重新构建。这属于“细节黑匣子”问题表面看代码都能跑实际跑出来的分布却完全不同。我后来定了一条规矩任何情绪识别项目交付时只保留一个encode_text入口训练和推理共用同一个函数杜绝两套代码。除了上面五个问题还有一个系统性陷阱标签定义漂移。项目早期把“惊讶”定义为中性后期业务方希望把它拆成正向惊喜和负向惊吓如果不统一标签定义模型越训练越混乱。做情绪识别项目第一时间把标签说明文档写清楚比调参数更值钱。6. 进阶落地用阈值校准和模型导出让情绪识别结果可交付6.1 先做一次阈值校准不要直接拿argmax当输出六分类情绪识别里argmax默认认为每个句子只能属一类但真实情绪经常是混合的。我会在验证集上为每个情绪类别单独搜索阈值让模型可以同时输出多个标签。import numpy as np from sklearn.metrics import f1_score def best_threshold_for_class(y_true, probs, class_idx): y_bin (y_true class_idx).astype(int) best_th, best_f1 0.5, 0.0 for th in np.arange(0.1, 0.9, 0.05): pred (probs[:, class_idx] th).astype(int) score f1_score(y_bin, pred) if score best_f1: best_f1, best_th score, th return best_th, best_f1这段代码把某个情绪类别的概率向量取出来遍历0.1到0.9的阈值找到让该类F1最高的判定点。例如“愤怒”的阈值可能是0.35“惊讶”的阈值可能需要0.6。这样处理后模型对愤怒更敏感对惊讶更保守整体输出比固定argmax更贴近业务需求。6.2 导出ONNX并统一推理接口真正交付时我不直接发PyTorch权重而是导出ONNX模型包一层统一的推理函数。ONNX的好处是脱离训练框架运行也不要求对方环境里有PyTorch和GPU。导出时把max_len固定成128再配上写死的词表和分词函数接口就变成“输入文本输出情绪概率”。这一步能避免很多部署兼容性问题也是这类项目从demo变成可用系统的关键。6.3 验收抽检模型和人工标注的一致性项目最后我还会做一轮抽检复核每周取200条真实评论让模型先预测再让标注人员复核计算模型预测和人工确认的一致率。情绪识别的主观性很强模型和人工完全一致不现实但我要求Kappa值至少要过0.6。如果某个情绪类别模型和人工分歧特别大先不急着调模型回头核对标签定义是否清晰。很多时候不是模型没学会而是业务方对“惊讶”和“愤怒”的划分标准和训练集不一致。我现在每接到一个情绪识别项目都会把阈值校准、ONNX导出、人工抽检三件事放在上线清单里提前做一遍。这个习惯来自我第一次做情感分析模型时在部署阶段翻车的教训——训练结果还可以上了真实流量之后每两三个小时就跑偏一次。希望这些实操经验能帮到正要往这个方向落地的你。本文还有配套的精品资源点击获取
返回列表