☰
多模态情感分析实战:从特征提取到融合算法选型指南
2026/10/8 11:16:23 网站建设 项目流程

简介:一套多模态情感分析方向的深度学习模型资源包,面向毕业设计、课程项目与科研入门场景,重点解决多模态表示学习、多模态融合以及多模态情感分析等下游任务中的建模与实验问题。项目包含多种有代表性的模型实现和多个公开多模态数据集,如情感识别、讽刺检测、对话场景样本;支持文本、音频、视觉信号的特征提取与融合,并提供训练、验证与评估脚本。压缩包共185个文件,大小约285.97MB,主要包含41个Python源码文件用于模型构建与实验流程,21个pickle和8个h5用于保存参数与中间特征,18个arff和36个csv用于数据输入与标签,12个markdown文档提供说明。已有1495人学习下载。借助清晰的项目目录和PyTorch、Keras、TensorFlow工程,读者可以快速复现从数据预处理、特征对齐、模型训练到情感分类评估的完整实验闭环;直接修改网络结构或数据路径即可迁移到自有数据集,适合作为课题基线或毕业设计基础。

1. 多模态情感分析:为什么各种融合模型都在抢这个任务

一段视频评论里,博主嘴上说“这片子我真挺喜欢的”,语气却平平的,全程没有笑意。只看文本是好评,只看语音像敷衍,只看表情甚至偏负面——三个模态各说各话。这正是多模态情感分析要解决的问题,也是标题里那套“多模态表示学习 + 多模态融合 + 下游任务模型”组合包的用武之地。

它以CMU-MOSI这类带多维标注的视频语料为基准,先分别抽取文本、语音、面部特征,再把它们按时间轴对齐、融合,最后统一做情感回归或分类评估。适合两类人:刚上手想用现成数据和代码把流程跑通的新手,以及在固定基准上横向对比自家融合算法的熟手。我当年第一次跑通这个流程时最大的感受是:融合模型本身往往不是瓶颈,前面特征提取的粒度才是决定上限的玄学。

2. 多模态表示学习:三种单模态特征提取路线与参数选型

在动手碰融合模型之前,先想清楚一个事实:融合的下限由特征决定,上限才由模型决定。文本特征如果只拿一个句向量,语音特征只做全局统计,视频特征没过滤低质量帧,后面无论用多复杂的融合模块都很难补回来。这一章讲这个方向上的默认特征方案,每一步都能直接落进代码。

2.1 文本模态:词级BERT向量比CLS句向量更适合对齐

常见做法是用bert-base-uncased取last_hidden_state,而不是直接用pooler_output那根向量。pooler_output经过了下游NSP任务设计的Tanh变换,句向量是压缩过的,做句级分类够用,但要做word-level对齐时信息损失明显。更稳妥的是保留每个词的隐状态,等语音和视频特征按词时间戳对齐之后,再做一次注意力池化。

from transformers import BertTokenizer, BertModel text = ["This movie is surprisingly good"] tokenizer = BertTokenizer.from_pretrained("bert-base-uncased") model = BertModel.from_pretrained("bert-base-uncased") inputs = tokenizer(text, padding=True, truncation=True, return_tensors="pt", max_length=32) with torch.no_grad(): out = model(**inputs) word_vec = out.last_hidden_state # [batch, seq_len, 768] cls_vec = word_vec[:, 0, :] # [batch, 768]

逻辑说明:last_hidden_state每个位置对应一个subword的768维隐状态,cls_vec取第0位。max_length设到32是因为MOSI这类utterance大多在20个词以内,给长句留一点余量即可;设太大没有收益,只增加后面融合的计算量。如果复现时发现长句截断导致特征错位,把max_length提到64,同时检查最长样本的长度分布。

参数说明:batch_size一般取32到64,显存不够就降到16。BERT只做特征提取时,务必包在torch.no_grad()里,否则会为无关计算构建计算图,白白吃掉显存。模型权重首次加载需要从外网下载,如果环境受限,先把HF_HOME指向已有的模型缓存目录,能省掉很多麻烦。

2.2 语音模态:openSMILE方便,wav2vec 2.0更强

语音这块有两条路线。一条是openSMILE提eGeMAPS的88维统计量,一条是用wav2vec 2.0提取帧级特征。前者便宜、稳定、跑得快,适合做早期拼接;后者信息量大,保留时序结构,适合做中间融合和跨模态注意力。我一般先跑openSMILE当baseline,确认文本单模态和音频单模态各自的分数后,再决定要不要把音频换成预训练特征。

SMILExtract -C eGeMAPS.conf -I utterance.wav -O utterance_eGeMAPS.csv

这条命令没有特别复杂的参数,核心在配置文件eGeMAPS.conf里,它决定了提取哪些低级描述符(LLD)以及怎么聚合成统计量。默认输出是每段音频一行88维向量,作为utterance-level特征可以直接拼接。缺点是它把整段语音压成一个向量,丢失了时间结构,做word-level对齐时信息不够细。

import torchaudio waveform, sr = torchaudio.load("utterance.wav") bundle = torchaudio.pipelines.WAV2VEC2_BASE model = bundle.get_model().eval() with torch.no_grad(): feats, _ = model(waveform) # [batch, frames, 768]

wav2vec 2.0输出的frames步长约20ms,与词时间戳对齐时,把落在词边界内的帧做平均池化即可。这里有个容易忽略的细节:waveform要求16kHz单声道,而视频里的音轨常是48kHz双声道。别省这一步torchaudio.functional.resample,否则特征的频率语义会偏,后面融合也跟着偏。

2.3 视觉模态:AU强度特征与帧滤波的默认参数

视频模态我习惯用OpenFace先跑一遍,得到每帧的面部特征点坐标、头部姿态和17个面部动作单元(AU)强度。对情感任务来说,AU序列比原始图像帧好用得多:原始帧经过预训练CNN提取的是物体语义,跟情绪相关性太间接;AU是脸部肌肉动作的编码,更接近标注者打分时的依据。

./FaceLandmarkImg -fdir path/to/frames -out_dir path/to/openface_out

参数说明:-fdir输入帧目录,-out_dir输出CSV目录。OpenFace按视频帧顺序逐帧处理,每个视频目录输出一个csv,行数等于帧数。使用前先做一件事:把每帧的confidence过滤掉,低于0.5的帧直接丢掉,因为这些帧大概率是模糊、遮挡或侧脸,AU强度不可信。如果同一段视频连续跑两次,低置信度帧上的AU差异很大,说明输入帧质量本身就不稳定,这时候先别调融合模型,回到抽帧环节解决。

import numpy as np valid = frame_conf >= 0.5 au_mean = au_values[valid].mean(axis=0) au_std = au_values[valid].std(axis=0) video_feat = np.concatenate([au_mean, au_std]) # 34维

这段逻辑是按话语切片后,在时间维度上做均值/标准差聚合。concat均值与标准差是为了把AU的波动也作为一个弱信号保存下来。如果按utterance做回归,这个34维向量可以直接用;如果按word-level做,就不能全局池化,得按词时间戳分段池化。视觉特征维度远低于文本和语音,融合时如果不做投影均衡,很容易被文本模态淹没。

3. 多模态融合算法:从拼接、门控到跨模态注意力

特征都提完之后,融合是决定最后分数的临门一脚。这一章按“早期—晚期—中间—注意力”四类路线过一遍,每类给最小实现,并指出适用边界。明白了这些边界,就不会出现“换了个融合模块反而不如拼接”的翻车。

3.1 早期融合:一个MLP吃掉891维向量的问题

早期融合就是把三个模态的特征直接拼接起来,然后丢给MLP。优点是极简,缺点是问题也很明显:768+768+34这种维度结构里,文本占了绝对主导,语音和视觉的贡献被稀释;且各模态的采样粒度可能不同,一个是句级、一个是词级,强行对齐会引入误差。

import torch import torch.nn as nn class EarlyFusion(nn.Module): def __init__(self, d_text=768, d_audio=768, d_video=34, d_hidden=256, num_classes=2): super().__init__() self.fc = nn.Sequential( nn.Linear(d_text + d_audio + d_video, d_hidden), nn.ReLU(), nn.Dropout(0.3), nn.Linear(d_hidden, num_classes), ) def forward(self, text, audio, video): h = torch.cat([text, audio, video], dim=-1) return self.fc(h)

参数说明:d_hidden=256是常见中间层大小,Dropout用0.3,因为拼接特征维度高、样本量少,不丢一下必然过拟合。训练时batch size取64,学习率1e-3配Adam,配合早停跑30到50轮。早期融合适合当baseline,不适合当最终方案,原因就是维度失衡和模态间相关性完全没有建模。

注意:如果只改EarlyFusion里的dropout和hidden size,性能很难有质变。问题出在拼接本身,不在MLP容量。

3.2 晚期融合:独立预测再加权,先跑通再优化

晚期融合是每个模态先单独训练一个分类器,最后把三个分类器的输出按权重加起来。这种做法不会让单模态的过拟合互相传染,而且每个模态可以各自选择最合适的模型结构,比如文本用BERT微调,语音用openSMILE特征配线性层,视觉用AU序列配LSTM。缺点是它学不到模态间的交互特征,对“文本正向但语气负面”这类冲突型样本,它只能打平均分,而不是理解冲突本身。

def late_fusion_logits(text_logits, audio_logits, video_logits, weight=(0.5, 0.2, 0.3)): return (weight[0] * text_logits + weight[1] * audio_logits + weight[2] * video_logits)

参数说明:权重我一般先在验证集上从0.4/0.2/0.4开始网格搜索,步长0.1。注意相加的是logits而不是softmax后的概率,概率相加会压缩动态范围,logits相加保留差异。晚期融合跑通之后,权重就固定下来,不要再用训练集调它,它本质上是验证集上的超参数,调多了就变成对验证集的过拟合。

3.3 中间融合:门控机制让模型学会“什么时候信谁”

中间融合是多数项目里的主力。做法是先把文本和音频等模态映射到同一维度,再用一个门控网络计算每个模态在当前位置上的可信度,最后做加权组合。门控本质上是连续版本的“软选择”:语音质量差的时候,门的输出自动降低音频权重,这不是手工定的规则,而是模型在训练里自己学到的。

class GatedFusion(nn.Module): def __init__(self, d_model=256): super().__init__() self.text_proj = nn.Linear(768, d_model) self.audio_proj = nn.Linear(768, d_model) self.video_proj = nn.Linear(34, d_model) self.gate = nn.Linear(d_model * 3, 3) self.fc = nn.Linear(d_model, 2) def forward(self, text, audio, video): ht = torch.tanh(self.text_proj(text)) ha = torch.tanh(self.audio_proj(audio)) hv = torch.tanh(self.video_proj(video)) g = torch.softmax(self.gate(torch.cat([ht, ha, hv], dim=-1)), dim=-1) h = g[:, 0:1] * ht + g[:, 1:2] * ha + g[:, 2:3] * hv return self.fc(h)

关键设计:三个模态先投影到256维共享空间,再算门。不能直接用768维文本向量和34维视觉向量拼在一起算门,维度差会让门控网络学到“只看文本维度”的捷径。这里的softmax门比sigmoid门多一个性质:三个模态的权重之和为1,相当于对输入向量做了凸组合;如果想让每个模态独立开关,就改成三组sigmoid,但参数量更大。d_model是256,门控网络只有一层linear,足够学习这个可信度映射,再加深反而容易在小样本上过拟合。

3.4 跨模态注意力:以文本为Query的加权池化

如果数据量大、预算足,跨模态注意力是比门控更强的方案。它让语音和视觉特征按文本每个词的注意力权重做池化,得到“文本正在强调哪个词,就重点看该词位置附近的表情和语气”的效果。很多多模态融合论文的分类也就是早融合、晚融合、中间融合和注意力这几类,注意力是目前公认上限更高的一种。

def cross_modal_attention(query, key, value, d_k=64): scores = torch.matmul(query, key.transpose(-2, -1)) / (d_k ** 0.5) weights = torch.softmax(scores, dim=-1) return torch.matmul(weights, value)

参数说明:query来自文本特征投影,key和value来自音视频拼接特征投影;d_k是query的维度,通常取64或128。softmax在最后一个维度上做归一化,保证每个词位置上音视频的权重和为1。这个模块需要更长的训练时间,学习率要降到5e-5附近,否则很容易在最初几步就陷入局部最优。它和门控不冲突,可以先用门控做baseline,再在门控输出后面接一层跨模态注意力,验证性能增量是否值得多出的计算量。

最后用一个消融表格收住本章,方便对照自己的实验。下面给出的是这类任务在常见配置下的浮动区间,以你实际切分和特征为准:

特征组合acc-2(二分类)F1说明
仅文本60~650.58~0.63容易依赖措辞,忽略语气
仅音频56~610.54~0.59受背景噪声影响大
仅视觉55~600.52~0.57表情与情感相关性较强但信息少
文本+音频66~700.64~0.68双模态交互明显
文本+视觉65~690.62~0.66比音频提升略小
三模态70~750.68~0.72一般是最优配置

如果你把自己的中间融合跑出来后,三模态分数低于任意双模态,那大概率是特征对齐出了问题,不是融合模型的锅。

4. 数据集与对齐策略:CMU-MOSI、MOSEI、IEMOCAP怎么喂给模型

数据集在这类项目里是个双刃剑:选对数据集,评测有说服力;切分策略错了,复现出来的分数会完全没法跟历史工作对比。这一章先从数据集分工说起,再给一份从原始视频到word-level训练样本的操作流程,最后讲特征对齐。

4.1 三个常用数据集的分工:回归、分类与对话

先明确一个概念:多模态情感分析不等同于人脸表情识别或语音情绪识别,它的核心是“多个模态共同表达一个情感标签”。目前业内最常用来跑这类模型的公开基准是:

数据集规模与形态标注类型常用指标适合场景
CMU-MOSI2199段短视频评论情感评分 [-3,3]Acc-2/F1/MAE/Corr快速验证融合模型
CMU-MOSEI23453段视频评论情感倾向 + 强度Acc-2/F1/MAE/Corr大规模稳定性验证
IEMOCAP约12小时双人对话6类离散情绪WAR/Acc、F1对话轮次与上下文建模
DEAP32人观看视频的EEG与生理信号维度情感分类/回归精度生理信号+音视频融合扩展

选型逻辑:想快速迭代算法,用CMU-MOSI,它样本少、跑一轮很快;要做严谨的结论,必须补CMU-MOSEI,规模大10倍,对融合鲁棒性的考验更强。IEMOCAP是对话流,多个说话人轮流发言,如果模型不利用说话人轮次信息,很容易在WAR指标上吃亏,这是它和MOSI/MOSEI差异最大的地方。做生理信号方向的可以关注DEAP,但DEAP的标注来自被试自评,噪声大,先跑MOSI把流程稳下来更稳妥。

4.2 从原始视频到word-level样本:抽帧与切片的固定流程

这段流程我重复过很多次,已经固定成一套标准操作。第一步,把视频切成语语;第二步,用ASR拿到每个词的时间戳;第三步,按词时间戳取对应的视频帧和音频区间。

ffmpeg -i input.mp4 -f segment -segment_time 3 -c copy seg_%03d.mp4

这个按固定3秒切的方案只适合快速看数据,不适合做训练集。正式做法是先跑ASR(whisper是当前最方便的常见选择),拿到每句话的起止时间,再按话语起止切。

ffmpeg -ss 12.3 -to 18.7 -i input.mp4 -c copy utt_001.mp4

参数说明:-ss放在-i之前是快速定位再切,时间戳精度在关键帧附近可能偏差;要更精确就把-ss放在-i之后做重编码,速度慢但帧精确。训练集建议用后一种,因为词时间戳和帧的对应必须准确,否则后面每个word-level的视觉特征都是错位的。

抽帧的默认参数也在这里给出来:

ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate -of default=nokey=1:noprint_wrappers=1 input.mp4

先看原始帧率,再决定抽帧频率。如果原视频是25fps,就直接按25fps抽,不要强行抽到30fps,否则会产生重复帧,后面按时间戳对齐时每帧对应的墙壁时间就乱了。

4.3 特征对齐:ALI索引与统一长度池化

特征对齐是多模态项目里最重要的工程环节。CMU-MOSI和MOSEI官方发布过ALI对齐索引,它把每个单词映射到对应的视频帧区间和音频特征区间,省去自己基于ASR切词的误差。即使不用ALI,也要在自己的流程里保证三点:特征的时间分辨率统一、缺失区间有标记、padding后再融合,否则融合层会学到“对齐误差”这个假信号。

def align_to_fixed(feat, target_len=16, mode="mean"): t = feat.shape[0] if t == 0: return np.zeros((target_len, feat.shape[1]), dtype=np.float32) idx = np.linspace(0, t - 1, target_len).astype(int) if mode == "mean": return feat[idx] if mode == "avg_pool": seg = np.array_split(feat, target_len) return np.stack([s.mean(axis=0) for s in seg])

逻辑说明:第一个分支是均匀索引采样,速度快但会丢信息;第二个分支是把序列等分成target_len段,每段平均,更接近时间池化。target_len的选取要和模态对齐:文本句子平均词数相近最好,16是句级特征的常用值。如果发现某个模态的有效帧数远小于target_len,比如一段音频只有2帧有效,后面的分段池化会产生大量重复统计量,此时应该把这条样本的该模态标记为缺失,做掩码而不是硬凑长度。

注意:无效区间不要用零填充。一旦填零,融合模型会学到“模态缺失=零向量”,而这个零向量本身是一个强特征,会导致训练时看似收敛很快、测试时遇到真缺失就崩。用NaN标记,然后在融合模块里用mask计算。

5. 实验评估避坑:指标选错等于白跑的4个常见问题

多模态情感分析的公开基准已经比较成熟,但不少人在上面跑出来的自测分数很高、横向一比就不可复现。这里把最常见的4个坑按“现象→原因→解决”的顺序写清楚。

5.1 切分不一致:你的0.82和论文0.82不是同一件事

现象:复现某组融合结果,Acc-2差了3个点以上,训练曲线看着很健康但评测分数对不上。原因:多数公开基准要求按说话人划分train/valid/test,而不是随机切分样本。随机切分会把同一个人的不同片段拆进训练集和测试集,模型记住了说话人特征,而不是情感表达特征,分数虚高。解决:加载数据集时打印三件事——训练集说话人数、验证集说话人数、测试集说话人数,并确认三者没有交集。

for split in ["train", "valid", "test"]: speaker_ids = set(df[df.split == split]["speaker_id"]) print(split, len(speaker_ids)) if split == "train": train_ids = speaker_ids else: overlap = train_ids & speaker_ids assert len(overlap) == 0, f"{split} shares speakers with train"

这段代码用speaker_id判交集。如果下载的数据里只有video_id而没有speaker_id,就需要从视频id前缀推导说话人,比如MOSI里很多视频id以yt开头,同一说话人的id前缀相同。动手前先核对数据csv里的字段,别想当然。MOSI的标准划分大约是51位说话人训练、10位验证、29位测试,MOSEI类似但每次下载来源不同标注可能不同,所以要自己验证一遍。

5.2 用Acc-2还是F1:类别不均衡时的两种命运

现象:训练日志里Acc-2在涨,但按分类报告看负类F1只有0.3。原因:情感评分转二分类时,常把评分大于0标为正、小于等于0标为负,MOSI的正负样本天然不均衡,模型只需多猜多数类就能把Acc-2撑上去。解决:两个指标都报,且以加权F1为主要取舍依据。

from sklearn.metrics import classification_report, accuracy_score y_true = [1, 0, 0, 1, 1, 0] y_pred = [1, 0, 0, 0, 1, 0] print(classification_report(y_true, y_pred, digits=3)) print("acc-2:", accuracy_score(y_true, y_pred))

分类报告会给出每个类别的precision/recall/F1以及macro/weighted平均值。重点看两个数字:少数类的F1、weighted avg的F1。如果你的模型在正类上F1很高、负类上F1很低,说明特征还没学到“负面情感”的共性表达,这时候先别调融合结构,回到单模态baseline去看是哪个模态对负类本来就弱。另一个常见做法是保留中性样本做三分类,这时候F1比Acc-2更能反映真实能力,因为它同时对三个类别的recall做了平均。

5.3 回归指标只看MAE:Corr才是衡量排序能力的标尺

现象:MAE降了0.1,但画出来的预测曲线在几个关键点上完全反向,负相关明显。原因:MAE衡量的是误差绝对值,它小只能说明平均偏差小,不能说明预测值的排列顺序和真实值一致;而情感评分本身是相对强度,排序是否一致同样重要。解决:MAE和Corr一起报。

from scipy.stats import pearsonr mae = float(torch.abs(pred - target).mean()) corr, _ = pearsonr(pred.detach().cpu().numpy(), target.detach().cpu().numpy()) print(f"MAE={mae:.3f} Corr={corr:.3f}")

强调一点:MAE改进明显但Corr反而下降,通常说明模型学会了把预测拉向均值区间,牺牲了对极端情感的区分能力。在MOSI/MOSEI的回归设置里这很常见,尤其当损失函数只用了L1时。我的习惯是损失函数里加上0.1倍的排序损失,或者在训练时同时监控验证集Corr并作为早停依据。还可以检查预测分布的方差,如果预测值全部挤在零点附近,说明模型已经退化成了“平均预测器”。

5.4 特征没对齐就拼接:一个经典的翻车案例

现象:三模态融合分数低于文本单模态,且训练集指标也不高。原因:最常见的是文本词特征、音频帧特征、视频帧特征在时间上没有对齐。比如文本是句级向量、音视频是词级向量,拼接时相当于拿整句话的语义去匹配某个词的语气,信息错位。解决:回查特征的时间分辨率,每个模态都打印shape和对应的时间戳范围,确认都是word-level或都是utterance-level后再进融合。

另一个隐蔽问题是全零行:某段音频无声或视频脸丢了,对应特征整行是0,拼接时会把这种“伪信息”当成真实输入。我的处理是保留一个valid_mask,在融合层对无效位置做masked averaging,而不是简单填零。这一步虽然改动很小,但它经常就是融合模型能不能超过单模态的分水岭。可以先写一个小函数检查无效行占比:

def check_valid_ratio(feat, threshold=0.5): valid = ~(np.abs(feat).sum(axis=-1) == 0) ratio = valid.sum() / feat.shape[0] print(f"valid ratio: {ratio:.2f}") assert ratio >= threshold, f"too many empty rows: {1 - ratio:.2%}"

这段逻辑把整行全零的位置认为是无效帧,计算有效比例。阈值0.5是经验值,低于这个比例的特征基本不可用。跑融合前对三个模态都执行一遍,能过滤掉大多数“融合不如单模态”的乌龙。

6. 用模态屏蔽测试验证融合模型学到了什么

融合模型训练完,先别急着报指标。我每次会多花半小时做一组模态屏蔽测试,用来判断模型到底依赖哪个模态。做法很简单:推理时把某个模态的输入全部置零,观察指标下降幅度。如果屏蔽文本后F1几乎不掉,说明模型学到的是文本捷径;如果屏蔽视觉后F1反而上升,说明视觉特征引入的是噪声。

def masked_eval(model, val_loader, mask_key): model.eval() preds, gts = [], [] for batch in val_loader: if mask_key in batch: batch[mask_key] = torch.zeros_like(batch[mask_key]) logits = model(**batch) preds.append(logits.argmax(dim=-1)) gts.append(batch["label"]) return accuracy_score(torch.cat(gts), torch.cat(preds))

这段代码直接复用验证集,哪个模态被屏蔽了性能掉得最多,哪个就是模型真正依赖的线索。与之配套的做法是单模态有效性排序:分别用文本、音频、视觉特征单独训练一个逻辑回归,得到单模态F1排序。把两张表对比一下——如果单模态里文本最强,屏蔽文本时掉分也最多,说明融合模型行为正常;如果单模态排序和屏蔽掉分排序不一致,就要怀疑融合层里存在某种模态捷径,比如投影维度不同导致某个模态梯度占优。

我自己踩过的教训是:有一版门控融合模型,屏蔽文本只掉了1个点,屏蔽音频掉了8个点,翻看特征才发现音频特征里有大量静音段被错误填补成零向量,模型学到的不是语气信息,而是“有没有声音”这个二分类信号。从那以后,我每个融合实验都固定先跑这条屏蔽测试,再决定要不要继续往上加跨模态注意力。别让模型在你不知道的地方抄近道,这个习惯比多调一组超参数更值。希望帮到你。

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

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

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

立即咨询