☰
Transformer情绪识别与情感分析实战:从数据管线到模型部署
2026/10/7 3:10:18 网站建设 项目流程

简介:本资源面向希望入门情绪识别与情感分析的开发者与研究人员,提供一套基于Transformer的完整项目实战方案,覆盖从数据预处理、模型训练到评估优化的全流程,帮助读者快速掌握自注意力机制在多模态情感任务中的应用。压缩包共19个文件,以14个Python源码为主,辅以3个pkl数据文件和2个md说明文档,整体约506KB,源码涵盖模型定义、训练脚本、数据集加载与结果可视化等模块,结构清晰便于按需查阅。目前已有154人学习下载。项目围绕MOSEI_UMONS多模态数据集展开,涉及文本、语音、视频等复合情感信号的编码与解码,并给出准确率、精确率、召回率、F1分数等评估思路,读者可据此理解过拟合与欠拟合的调优方向。配套流程教程指导环境搭建、代码运行与结果分析,兼顾情感极性判断等分支任务,适合初学者与有一定基础的研究者动手实践,具备较高的实用与教学价值。

1. 情绪识别遇上 Transformer:一条文本分类链路为什么值得重做

电商评论、客服会话、社交媒体帖子,这些文本里藏着的情绪信号,用传统 TF-IDF 加 SVM 也能跑出个及格线,但一旦遇到反讽、多重否定、长距离依赖,准确率就断崖式下跌。Transformer 的出现让情绪识别和情感分析这条链路有了重做的价值——自注意力机制能同时捕捉「虽然但是」这种转折结构里的两端信号,这是 RNN 和 CNN 做不到的。这个项目标题指向的是一套完整的实战方案:用 Transformer 做情绪识别与情感分析,附带源码和流程教程。适合已经跑通过基础文本分类、想升级到 Transformer 架构的 NLP 工程师,也适合手里有标注数据、想快速验证 Transformer 在自己业务场景下效果如何的算法同学。核心问题只有一个:怎么用最小的工程代价,把 Transformer 从论文里的架构图变成能跑在你自己数据上的分类器。

2. 从原始文本到模型输入:数据管线的四个关键决策

2.1 情绪标签体系怎么定:三类还是五类

情绪识别和情感分析经常被混着说,但落到标签体系上是两件事。情感分析通常是二分类(正面/负面)或三分类(正面/中性/负面),而情绪识别更细,常见的是 Ekman 六类(高兴、悲伤、愤怒、恐惧、惊讶、厌恶)或者中文场景下简化的四到五类。我一般建议从三分类起步,原因是标注一致性容易保证,模型收敛快,后面要扩类也有基线可对比。

标签体系定下来之后,要做一个关键决策:单标签还是多标签。一条评论可能同时包含「愤怒」和「失望」,如果强行单标签,标注员会纠结,模型学到的边界也模糊。多标签方案用 Sigmoid 输出加 BCE Loss,单标签用 Softmax 加 CrossEntropy Loss,这是两条完全不同的路,在数据预处理阶段就要定死。

# 标签体系定义示例:三分类情感 + 多标签情绪 sentiment_labels = {"negative": 0, "neutral": 1, "positive": 2} emotion_labels = {"anger": 0, "sadness": 1, "joy": 2, "fear": 3} # 多标签场景下,一条样本的标签向量是 [0,1,0,1] 这种形式 # 单标签场景下,标签是单个整数

上面这段代码看起来简单,但它决定了后面模型输出层的激活函数和损失函数选择。多标签用BCEWithLogitsLoss,单标签用CrossEntropyLoss,这两个不能混。参数上,多标签的标签向量维度等于情绪类别数,每个位置独立做二分类。

2.2 中文分词与 Tokenizer 选型:BERT 还是自己训

Transformer 做文本分类,Tokenizer 的选择直接影响输入序列的质量。中文场景下有三条路:直接用 BERT 系列的中文 Tokenizer(WordPiece)、用 SentencePiece 在领域数据上重新训练、或者先分词再按词切分。我的经验是,如果你的数据是通用领域(电商评论、微博),直接用bert-base-chinese的 Tokenizer 就够了,它的词表覆盖了大部分常见汉字和词组。但如果是垂直领域(医疗、法律),领域术语会被切碎,这时候用 SentencePiece 在领域语料上训一个 32k 词表的 Tokenizer,效果提升明显。

from transformers import BertTokenizer # 通用领域直接用预训练 Tokenizer tokenizer = BertTokenizer.from_pretrained("bert-base-chinese") # 查看分词结果 text = "这个产品的质量虽然不错,但是物流太慢了" tokens = tokenizer.tokenize(text) print(tokens) # ['这', '个', '产', '品', '的', '质', '量', '虽', '然', '不', '错', ',', '但', '是', '物', '流', '太', '慢', '了'] # 转成模型输入 encoding = tokenizer(text, max_length=128, padding="max_length", truncation=True, return_tensors="pt")

这段代码的关键参数是max_length。情绪识别任务里,大部分文本在 64 到 128 个 token 之间,设太大浪费显存,设太小截断关键信息。我一般会先统计训练集的分位数,取 95 分位作为max_length。padding="max_length"在训练时用,推理时用padding=True动态补齐更高效。truncation=True必须开,否则超长文本会直接报错。

2.3 数据清洗:去噪比调参重要

情绪识别模型效果不好,八成问题出在数据上,不是模型上。原始文本里的 HTML 标签、URL、重复字符、表情符号,这些噪声不处理,Transformer 的注意力会被分散。我一般会做这几步:去掉 HTML 标签和 URL、把连续重复字符压缩(「好好好好好」变成「好」)、表情符号转成文字描述(「😊」转成「微笑」)、统一全角半角。

import re def clean_text(text): # 去 HTML 标签 text = re.sub(r"<[^>]+>", "", text) # 去 URL text = re.sub(r"http\S+|www\.\S+", "", text) # 压缩连续重复字符(超过 2 次的压缩为 2 次) text = re.sub(r"(.)\1{2,}", r"\1\1", text) # 去多余空白 text = re.sub(r"\s+", " ", text).strip() return text # 表情符号映射表(部分) emoji_map = {"😊": "微笑", "😡": "愤怒", "😢": "悲伤", "😱": "恐惧"} def replace_emoji(text): for emoji, desc in emoji_map.items(): text = text.replace(emoji, desc) return text

清洗逻辑要按业务场景调整。比如电商评论里,「差评」和「差 评」要统一,但「不差」不能动。表情符号映射表不用贪多,覆盖 Top 20 高频表情就能拿到大部分收益。注意清洗顺序:先去 HTML 和 URL,再处理重复字符,最后做表情替换,否则表情替换后的文字可能被后续正则误伤。

2.4 类别不均衡:过采样还是 Focal Loss

情绪识别数据集里,「中性」样本往往占 60% 以上,「恐惧」「厌恶」可能不到 5%。这种不均衡不处理,模型会倾向于全预测成多数类,准确率看着高,但少数类召回率惨不忍睹。两条路:数据层面用过采样(对少数类复制或增强),损失层面用 Focal Loss 或 Class Weight。

import torch import torch.nn as nn # 方案一:Class Weight class_counts = [1200, 300, 150, 80] # 四个类别的样本数 weights = 1.0 / torch.tensor(class_counts, dtype=torch.float) weights = weights / weights.sum() criterion = nn.CrossEntropyLoss(weight=weights) # 方案二:Focal Loss class FocalLoss(nn.Module): def __init__(self, alpha=1, gamma=2): super().__init__() self.alpha = alpha self.gamma = gamma def forward(self, inputs, targets): ce_loss = nn.functional.cross_entropy(inputs, targets, reduction="none") pt = torch.exp(-ce_loss) focal_loss = self.alpha * (1 - pt) ** self.gamma * ce_loss return focal_loss.mean()

Class Weight 实现简单,适合不均衡程度中等(最多类:最少类 < 20:1)的场景。Focal Loss 通过gamma参数降低易分类样本的权重,让模型聚焦难样本,适合极端不均衡。gamma=2是原论文的推荐值,alpha一般设 1 或者按类别频率的倒数来设。我一般先用 Class Weight 跑一版基线,如果少数类 F1 还是低于 0.3,再换 Focal Loss。

3. Transformer 分类模型:从架构选型到训练循环

3.1 用 BERT 做基座还是从头训一个 Transformer

这是最容易被纠结的问题。我的建议很直接:数据量小于 1 万条,直接用预训练 BERT 微调;数据量在 1 万到 10 万之间,用 BERT 微调加领域适配(在领域语料上继续做 MLM);数据量超过 10 万,可以考虑从头训一个轻量 Transformer 或者用领域预训练模型。

从头训 Transformer 不是不行,但需要的数据量和算力远超大多数人预期。原论文在 8 块 GPU 上训了 12 小时才出 base 版本,你的数据量如果只有几千条,从头训出来的模型连词向量都没学好。预训练模型已经在大规模语料上学到了语法和语义表示,微调只需要调整分类头附近的参数,收敛快、效果好。

from transformers import BertForSequenceClassification, AdamW # 加载预训练 BERT,指定分类数 model = BertForSequenceClassification.from_pretrained( "bert-base-chinese", num_labels=3, # 三分类情感 hidden_dropout_prob=0.3, # 分类头 dropout 调高防过拟合 attention_probs_dropout_prob=0.1 ) # 优化器:分类头用大学习率,BERT 层用小学习率 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 = AdamW(optimizer_grouped_parameters, lr=2e-5)

num_labels必须和你的标签体系一致,改这个参数会重新初始化分类头。hidden_dropout_prob默认 0.1,小数据集上调到 0.3 能明显缓解过拟合。优化器用 AdamW,weight_decay对权重矩阵生效,对 bias 和 LayerNorm 不生效,这是 BERT 微调的标准做法。学习率2e-5是 BERT 微调的经典值,太大容易灾难性遗忘,太小收敛慢。

3.2 训练循环里必须监控的三个指标

训练 Transformer 分类模型,不能只看 loss。我一般会同时盯三个指标:验证集 F1(macro)、验证集 loss、学习率。F1 是最终目标,loss 反映模型是否还在学习,学习率反映调度器是否正常工作。

from torch.utils.data import DataLoader from transformers import get_linear_schedule_with_warmup from sklearn.metrics import f1_score train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False) epochs = 5 total_steps = len(train_loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), # 10% 步数做 warmup num_training_steps=total_steps ) for epoch in range(epochs): model.train() for batch in train_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_mask=attention_mask, labels=labels) loss = outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad() # 验证 model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for batch in val_loader: outputs = model( batch["input_ids"].to(device), attention_mask=batch["attention_mask"].to(device) ) preds = torch.argmax(outputs.logits, dim=-1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(batch["labels"].numpy()) f1 = f1_score(all_labels, all_preds, average="macro") print(f"Epoch {epoch+1} | Val F1: {f1:.4f}")

batch_size=32是 8G 显存下的安全值,显存够可以调到 64。warmup设 10% 的总步数,防止训练初期梯度爆炸。clip_grad_norm_的max_norm=1.0是标配,不加的话偶尔会出现 loss 突然变 NaN。验证时用model.eval()关掉 dropout,torch.no_grad()省显存。F1 用 macro 平均,因为类别不均衡时 macro 更能反映少数类表现。

3.3 学习率调度:线性衰减还是余弦退火

BERT 微调最常用的调度器是线性衰减加 warmup,就是上面代码里的get_linear_schedule_with_warmup。但如果你发现模型在训练后期 F1 还在涨,只是涨得慢,可以试试余弦退火,它能让学习率在后期降得更慢,给模型更多时间精细调整。

from transformers import get_cosine_schedule_with_warmup scheduler = get_cosine_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps, num_cycles=0.5 # 半个余弦周期 )

num_cycles=0.5表示学习率从峰值降到 0 走半个余弦周期,这是最常用的设置。如果设成 1.0,学习率会先降后升再降,适合训练轮数很多的情况。我一般先用线性衰减跑基线,如果验证集 F1 曲线在最后两个 epoch 还有明显上升趋势,再换余弦退火。

4. 避坑与排查:情绪识别项目里最容易翻车的五个地方

4.1 现象:训练 loss 正常下降,验证 F1 始终在 0.4 左右

原因通常是标签噪声。情绪识别标注的主观性很强,不同标注员对同一条文本可能给出不同标签。如果没做标注一致性校验,训练集里会混入大量错标样本,模型学到的决策边界是模糊的。

解决:先算标注一致性(Cohen's Kappa),低于 0.6 的类别要么重新标注,要么合并。然后用交叉验证找出模型预测和标注不一致的样本,人工复核。我一般会挑出验证集里 loss 最高的 50 条样本,逐条看,通常能发现一批系统性错标。

4.2 现象:模型在测试集上表现很好,上线后效果暴跌

原因是数据分布偏移。训练集可能是半年前的评论数据,上线时用户表达方式已经变了,新词、新梗、新的反讽方式模型没见过。

解决:建立在线监控,定期采样线上数据做人工标注,和训练集分布做对比。如果发现明显偏移,用新数据做增量微调。另外,在预处理阶段加一层新词发现,把高频未登录词加入 Tokenizer 词表。

4.3 现象:推理时单条文本耗时超过 200ms

原因是max_length设得太大,或者 batch size 为 1 时没有做动态 padding。BERT base 在 CPU 上推理 128 长度大约 50ms,如果设到 512 长度,耗时直接翻四倍。

解决:统计线上文本长度分布,把max_length压到 95 分位。推理时用padding=True动态补齐,不要用padding="max_length"。如果延迟还是高,考虑蒸馏到 4 层的小模型,或者用 ONNX Runtime 做推理加速。

4.4 现象:多标签模型所有标签都预测为 0

原因是正样本太少,BCE Loss 被负样本主导,模型学到「全预测 0」就能拿到很低的 loss。这是多标签分类的经典问题。

解决:对正样本做加权,pos_weight设成负正样本比例。或者用 Focal Loss 的变体。另外,评估指标不能用准确率,要用每个标签的 F1 和 mAP。

# 多标签 pos_weight 设置 pos_counts = torch.tensor([50, 30, 20, 10], dtype=torch.float) # 每个标签的正样本数 total = 1000 neg_counts = total - pos_counts pos_weight = neg_counts / pos_counts criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)

4.5 现象:换了随机种子,F1 波动超过 5 个点

原因是数据集太小,模型对初始化敏感。几千条数据训 BERT,不同随机种子下的方差确实会很大。

解决:用 5 折交叉验证代替单次划分,报告平均 F1 和标准差。如果标准差超过 3 个点,说明数据量不够,要么加数据,要么用更强的正则化(调高 dropout、加 weight decay)。另外,固定所有随机种子(Python、NumPy、PyTorch、CUDA),保证实验可复现。

5. 让模型真正可用的三个进阶技巧

5.1 用对抗训练提升鲁棒性

情绪识别模型最怕输入里有错别字或同音字,比如「质量很好」打成「质量很号」,模型可能就懵了。对抗训练(FGM、PGD)通过在词嵌入层加扰动,让模型学会忽略这些小噪声。

class FGM: def __init__(self, model, epsilon=1.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 = {}

用法是在正常 forward/backward 之后,调用fgm.attack(),再做一次 forward/backward,然后fgm.restore()。epsilon=1.0是常用值,太大反而伤效果。对抗训练一般能带来 1 到 2 个点的 F1 提升,但训练时间增加约 50%。

5.2 用置信度校准决定什么时候不预测

线上场景里,模型对某些输入就是不确定的,强行预测不如转人工。温度缩放(Temperature Scaling)是最简单的校准方法:在验证集上学一个温度参数 T,推理时把 logits 除以 T 再 softmax,让置信度更可靠。

class TemperatureScaler(nn.Module): def __init__(self): super().__init__() self.temperature = nn.Parameter(torch.ones(1) * 1.5) def forward(self, logits): return logits / self.temperature # 在验证集上优化 temperature 参数 scaler = TemperatureScaler() optimizer = torch.optim.LBFGS([scaler.temperature], lr=0.01, max_iter=50) # ... 用 NLL loss 优化

校准后,如果模型输出的最大概率低于 0.6,就把这条样本转人工审核。这个阈值要在验证集上按业务需求调,宁可多转人工,也别让错判流出去。

5.3 用错误分析驱动迭代

模型上线不是终点。我习惯每周抽 100 条线上预测错误的样本,分门别类:是标注错了、还是文本太短信息不够、还是出现了新的表达方式。分类统计之后,优先级最高的那类问题就是下一轮迭代的方向。

这个习惯让我避免了很多无效调参。有一次发现 30% 的错误来自「反讽」,而训练集里几乎没有反讽样本,补了 500 条反讽数据后,整体 F1 直接涨了 4 个点。模型效果不好,先看数据,再看模型,这个顺序别搞反。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询