简介:面向深度学习研究者和算法工程师的多模态情感分析可运行源码,基于PyTorch实现语音与文本双模态融合,通过注意力机制对预训练多语言BERT与Wav2Vec2提取的特征进行动态加权,并在EATD_Corpus数据集上完成negative、neutral、positive三分类任务,覆盖从环境搭建到模型部署的完整开发链路。资源打包为ZIP格式,共31个文件,包括2个Python源码脚本、10个TXT文本说明、9个WAV语音样本、9个MP4视频样本及1个inscode配置文件,整体仅183KB,下载与使用都很轻量。已有183人学习下载,适合需要快速上手多模态情感分析的读者,也适合用作课程设计、毕业设计或科研课题的参考项目。源码中附带了示例数据生成脚本(create_sample_videos.py)和负类/中性/正类范例样本,便于理解数据组织方式;主程序清晰地呈现了多模态特征融合、注意力加权及模型评估等关键实现,可在此基础上进一步扩展情感类别或优化模型鲁棒性,为研究和工程实践提供起步基础。
1. 多模态情感分析解决什么问题
多模态情感分析这几年在客服质检、短视频舆情、人机交互里越来越绕不开。只给一行文本“你做得真好”,模型会判成正面,可配上用户拖长的语调、背景里的叹气声、画面上一闪而过的嫌弃表情,这句就是彻头彻尾的反讽。单模态文本模型在这里必然翻车,而多模态情感分析就是想用语音、图像给文本“纠偏”,把文本说反话的场景救回来。这个方向适合三类人:一类是做客服质检和舆情系统的后端工程师,一类是入门多模态研究的算法同学,还有一类是手里有视频或录音数据、想低成本验证多模态效果的开发者。下面按“为什么有效 → 数据怎么准备 → 模型怎么搭 → 坑在哪 → 怎么验证”的顺序把整条路走通。
2. 情感分析为什么要“多模态”:对齐、互补与融合的逻辑
2.1 三个模态各擅长什么:文本给语义,语音给语气,图像给表情
文本模态给出的是显性语义,情感词、否定词、程度副词都集中在词面上,但它天然丢失了语气。同一句“没事,你先忙”,用平稳语速说是理解,用急促高音说是埋怨,文本特征看不出差别。语音模态的强项是韵律:基频(F0)的起伏、语速快慢、短时能量高低,这些参数直接关联人的 arousal(唤醒度),也就是情绪的激烈程度。图像模态则提供面部动作单元(AU,Action Unit)和表情强度,比如嘴角下压、眉毛上挑,这些是判断厌恶、惊讶这类细粒度情感的关键线索。
三个模态的信息是互补的,不是重复的。文本说“太好了”,语音平静,图像无表情,多数情况下是真高兴;文本说“太好了”,语音低沉,图像嘴角下压,大概率是讽刺或无奈。多模态模型的核心收益就来自让视觉和语音去修正文本的极性误判。常见做法是把三个模态的特征分别抽出来,再在某个层级做融合,让模型自己学出“哪些模态在什么情况下更可信”。如果一个项目里只有文本语料,强行加语音图像反而会拖慢训练,这是选型时就要想清楚的。
2.2 时间对齐是第一道技术门槛:词级、帧级、句子级怎么选
多模态数据天然是不同采样率的:文本按“词”或“字符”存在,语音按毫秒级帧(常见 10ms~30ms 帧移)存在,视频按帧率(常见 25fps)存在。融合前必须把三个模态拉到同一个时间轴上,否则模型拿到的就是错位的特征对。最常用的对齐锚点是文本词的时间戳,也就是每个词从哪一秒开始、到哪一秒结束,然后按这个词的时间段去切音频特征和视频特征。
对齐粒度有三个选择。词级对齐最细,每个词对应一段语音特征和一段图像特征,适合做细粒度情感变化分析,但实现成本高,要求数据有词级时间戳。句子级对齐最省事,把整句话的语音和图像特征做均值池化,拼到句子向量上,适合只做分类不做细粒度分析的项目。帧级对齐介于两者之间,通常把音频和视频按同一采样频率重采样,再用注意力机制去学帧和词之间的对应关系。
实操里我一般这样处理一段 15 秒、5 个词的视频:语音按 30ms 帧移得到约 500 帧,视频按 25fps 抽帧得到 375 帧,5 个词的边界分别为 0-2s、2-5s、5-9s、9-12s、12-15s。每个词窗口内的语音帧和视频帧分别池化成固定维度的向量,这样每个词就拿到了一组对齐好的三模态特征。对齐这一步出错,后面模型训练基本是玄学,损失降得下去但指标上不来,还大概率是数据错位被模型死记硬背了。
2.3 融合策略怎么选:早期拼接、中期交互、晚期决策的适用边界
融合策略决定模型结构的上限。早期融合(Early Fusion)是把文本、语音、图像的特征向量直接拼接,后面接全连接层或 Transformer。实现最简单、训练最快,但三个模态的原始特征域差异大,拼接后模型要自己学模态间交互,数据量不够时容易过拟合。晚期融合(Late Fusion)先让每个模态各训练一个分类器,最后对三个分类概率做加权平均或投票。好处是每个模态的模型可以独立迭代、单独替换,坏处是完全丢失了模态间的细粒度交互,比如“语音低沉的讽刺”这种跨模态模式学不到。
中期融合(Intermediate Fusion)是折中方案,也是最常用的做法:每个模态先通过各自的编码器抽到中间表示,然后在中间层做交互,常见手段是跨模态注意力、张量融合或门控机制。项目里如果数据量只有几千条,我一般先跑早期拼接基线,用它的准确率做参照;如果数据上了万,再试中期融合,看指标提升是否值得增加的那部分训练成本。选型的判断依据是数据量、标注粒度、以及是否需要解释模态贡献度,这些直接决定了调试成本和上线后的维护难度。
| 融合方式 | 实现成本 | 数据量要求 | 模态交互能力 | 适用场景 |
|---|---|---|---|---|
| 早期拼接 | 低 | 中 | 弱,得靠网络自己学 | 基线、快速验证 |
| 中期交互 | 高 | 高 | 强,显式建模 | 数据充足、细粒度分析 |
| 晚期决策 | 低 | 低 | 无跨模态交互 | 多系统集成、快速上线 |
3. 三路特征管线:文本、语音、图像变成模型能吃的张量
3.1 文本通道:预训练模型与情感统计特征怎么搭
文本特征的主流做法是用预训练语言模型取句向量或词向量,代替传统的 TF-IDF 加情感词典拼接,因为预训练向量自带上下文语义,能处理“不用了谢谢”这类按字面难判定的表达。工程上要考虑的是取哪一层输出、是否微调。取倒数第二层输出是常见默认,因为最后一层过于贴近语言建模任务;微调则要看数据量,数据少于一万条时我一般不微调,直接冻结特征,省显存也稳。
from transformers import AutoTokenizer, AutoModel import torch def extract_text_features(texts, model_name="bert-base-chinese"): tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModel.from_pretrained(model_name) features = [] for text in texts: encoded = tokenizer( text, max_length=64, padding="max_length", truncation=True, return_tensors="pt" ) with torch.no_grad(): output = model(**encoded) # 取倒数第二层,做mean pooling得到句向量 hidden = output.hidden_states[-2].squeeze(0) mask = encoded["attention_mask"].squeeze(0).unsqueeze(-1).float() sent_vec = (hidden * mask).sum(0) / mask.sum(0) features.append(sent_vec.numpy()) return featuresmax_length按语料的 95% 分位长度设,64 或 128 都常见;hidden_states[-2]是倒数第二层;attention_mask参与池化是为了把 padding 位剔除,否则零位会被算进均值里拉偏向量。这个函数输出的是句级特征,如果做词级对齐,就把每句话按词切开分别编码,并保留每个词的时间戳。实际项目中文本通道很少单独调参,瓶颈往往在语音和图像的预处理上。
3.2 语音通道:语谱图与韵律特征提取的取舍
语音特征有两条路线:一是手工特征,用 openSMILE 或 librosa 提取 MFCC、基频、过零率、能量等韵律特征;二是端到端特征,直接把语谱图送入预训练音频模型取嵌入。手工特征可解释性强、维度低、算得快,但表达力有限;端到端特征更准,但要额外引入一个音频预训练模型,工程链变长。做情感分析,我倾向于两者折中:韵律特征打底,MFCC 作为补充,维度控制在几十维以内,避免和文本特征拼接后维度失衡。
import librosa import numpy as np def extract_audio_features(audio_path, sr=16000): # 统一重采样到16kHz,与ASR模型常见采样率对齐 y, _ = librosa.load(audio_path, sr=sr) # 帧长25ms,帧移10ms,是语音识别的常用配置 mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=13, n_fft=400, hop_length=160) # 基频和过零率、短时能量作为韵律补充 f0, voiced_flag, _ = librosa.pyin( y, fmin=70, fmax=400, sr=sr, frame_length=400, hop_length=160 ) f0 = np.nan_to_num(f0, nan=0.0) zcr = librosa.feature.zero_crossing_rate(y, frame_length=400, hop_length=160) mfcc_mean = mfcc.T.mean(axis=0) f0_mean = f0.mean() zcr_mean = zcr.mean() # 拼接成一段音频的整体特征向量 return np.concatenate([mfcc_mean, [f0_mean, zcr_mean]])注意:pyin 返回的静音段基频为 nan,这里一律填 0,对应“无音高”的状态。fmin=70、fmax=400覆盖人类基频的大多数范围;hop_length=160在 16kHz 下等于 10ms 帧移。按词对齐时,不能把整段音频一次性池化,而是要按词的起止时间在音频波形上切片,再对每个切片单独调用这个函数,得到每个词的韵律特征。静音段和无人声段在这里都会被映射成低能量和零基频向量,后面模型可以自动学到“这段没声音”的含义。
3.3 视觉通道:抽帧、人脸区域与 AU 特征
图像特征的关键问题是“看哪里”。一整帧画面里背景占大头,直接整帧编码会引入大量与情感无关的信息。常见做法是先做人脸检测,把人脸区域裁出来,再送入表情特征提取。工程上要控制抽帧率,25fps 的视频每秒抽 25 帧不现实也没必要,按词边界每个词取 3 到 5 帧就够。帧数太少会漏掉关键表情变化,帧数太多会让训练集膨胀好几倍。
import cv2 def extract_frames_by_word(video_path, word_timestamps, max_frames=5): cap = cv2.VideoCapture(video_path) fps = cap.get(cv2.CAP_PROP_FPS) word_frames = [] for start, end in word_timestamps: start_frame = int(start * fps) end_frame = int(end * fps) candidate_frames = list(range( start_frame, end_frame, max(1, (end_frame - start_frame) // max_frames) )) frames = [] for idx in candidate_frames: cap.set(cv2.CAP_PROP_POS_FRAMES, idx) ret, frame = cap.read() if ret: frames.append(frame) word_frames.append(frames) cap.release() return word_framescandidate_frames用整数除法均匀采样,避免某一帧卡顿导致连续抽到同一帧;max_frames=5在大多数据集里能覆盖一个词的表情变化。拿到人脸区域后,可以用现成的表情模型或直接提取人脸关键点坐标,但要注意头部姿态和遮挡会污染特征。多目标、侧脸、口罩遮挡这些情况,后续避坑章节会单独讨论。
4. 模型融合:从特征拼接跨到注意力,给出一份可运行的训练骨架
4.1 基线模型:三特征拼接加 MLP,先跑通再谈提升
一段可运行的代码比任何架构分析都有说服力。先用早期融合把三路特征拼接,送进一个两层的 MLP,这是最快验证数据管线对不对的方法。特征维度分别是文本 768、语音 15、图像 128(这里以实际提取为准),拼接后输入网络的维度就是 911。
import torch import torch.nn as nn class EarlyFusionModel(nn.Module): def __init__(self, text_dim=768, audio_dim=15, vision_dim=128, hidden_dim=256, num_classes=3, dropout=0.3): super().__init__() # 拼接后维度是text+audio+vision self.fusion_dim = text_dim + audio_dim + vision_dim self.classifier = nn.Sequential( nn.Linear(self.fusion_dim, hidden_dim), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_dim, hidden_dim // 2), nn.ReLU(), nn.Dropout(dropout), nn.Linear(hidden_dim // 2, num_classes) ) def forward(self, text_feat, audio_feat, vision_feat): # 在特征维度上拼接 fused = torch.cat([text_feat, audio_feat, vision_feat], dim=-1) return self.classifier(fused)cat是在最后一维拼接,要求三个特征已经对齐到同一批词上;Dropout(0.3)是防过拟合的关键,只拼特征不加 dropout,几千条数据基本必过拟合。这个模型跑通后再加上训练循环、早停和 F1 评估,就可以作为一个基线分数。基线的作用是给后续中期融合一个对比锚点,如果加了复杂结构收益不到 1 个点,是不值得上线的。
4.2 中期融合:用跨模态注意力让视觉语音帮文本“纠偏”
中期融合的实现思路是:文本特征作为查询,语音和图像特征作为键值,计算注意力权重。这样模型可以学会在听到低沉语调时提高视觉特征的权重,去修正文本的正面判断。下面是最简的跨模态注意力层。
import torch.nn.functional as F class CrossModalAttention(nn.Module): def __init__(self, text_dim=768, other_dim=128, hidden_dim=128): super().__init__() # query来自文本,key/value来自另一模态 self.query_proj = nn.Linear(text_dim, hidden_dim) self.key_proj = nn.Linear(other_dim, hidden_dim) self.value_proj = nn.Linear(other_dim, hidden_dim) def forward(self, text_feat, other_feat): q = self.query_proj(text_feat).unsqueeze(1) # (B,1,H) k = self.key_proj(other_feat).unsqueeze(1) # (B,1,H) v = self.value_proj(other_feat).unsqueeze(1) # (B,1,H) scores = torch.matmul(q, k.transpose(-2, -1)) / (k.size(-1) ** 0.5) attn_weights = F.softmax(scores, dim=-1) attended = torch.matmul(attn_weights, v).squeeze(1) return attendedtext_feat作为 query,让文本决定“我要不要参考视觉信息”;除以 sqrt(H)是 scaling 操作,防止点积过大把 softmax 推到饱和区。这个层分别处理语音和图像,得到两个 attended 向量,再和原始文本向量拼接进分类头。相比简单的cat,注意力融合的可解释性也更好:权重高说明模型确实在依赖那个模态。训练时建议先固定这个层的学习率到主网络的十分之一,因为它收敛快,学习率太高会打乱文本预训练特征。
4.3 训练参数与稳定训练技巧:学习率、早停、梯度裁剪
多模态训练最容易犯的错是所有参数用同一个学习率。文本来自预训练模型,视觉和语音编码器也可能带预训练权重,这些参数承受不起 1e-4 以上的更新幅度,很容易把学到的语义冲坏。我常见的做法是:融合网络和分类头用 1e-4,预训练文本模型用 1e-5,如果有预训练视觉模型同样压到 1e-5。batch size 在显存允许下取 16 到 32 之间,太小则梯度噪声大,太大则情感类别的稀疏样本很难被学到。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 融合网络学习率 | 1e-4 | AdamW 默认即可 |
| 预训练文本模型学习率 | 1e-5 | 防止冲坏语义表示 |
| batch size | 16~32 | 兼顾稳定性和显存 |
| 早停 patience | 5~8 epoch | 按验证集 F1 判断 |
| 梯度裁剪阈值 | 1.0 | 防止 loss 爆炸 |
| Dropout | 0.3~0.5 | 越低越容易过拟合 |
早停按验证集 F1 而不是 loss 来监控,因为情感分类的多类别不平衡时,loss 下降不代表少数类变准。哪怕训练还没收敛,只要验证 F1 连续 5 个 epoch 不涨就停下来,省时间也避免过拟合。梯度裁剪加在loss.backward()之后的 optimizer step 前,设到 1.0 基本不会影响正常收敛,但能拦住偶发的梯度尖峰。
5. 多模态情感分析开发避坑清单:对齐失败、模态缺失与过拟合
5.1 时间戳错位:特征对不上,模型学不到任何东西
现象:训练 loss 正常下降,验证集 F1 一直在 0.3 徘徊,和随机猜测差不多;检查预测结果发现模型把所有样本都判成多数类。
原因:语音特征和视觉特征没有按同一个时间戳切,比如音频切片按毫秒切、视频抽帧按帧号切,两者之间差了整整一段偏移。模型学到的是“第 2 秒的文本配第 5 秒的表情”,完全没对齐。
解决:先单独检查对齐结果。写一段脚本把每个词的文本、对应音频波形段、对应视频帧并排输出,人眼抽查 20 条。我习惯在预处理阶段输出一份对齐日志,记录每个词的起止时间、音频帧数、视频帧数,任何一项为 0 就单独标记,而不是直接丢弃。
5.2 模态缺失:画面没人、音频静音,融合层直接 NaN
现象:训练到中途 loss 变成 nan,或者推理时某条样本分数全为 0;进一步排查发现这类样本的图像检测不到人脸,整段特征向量全是 0。
原因:视觉通道在无人脸时返回空数组,进入 batch 后形状不一致,或者拼接层把空张量送进去导致维度崩溃。这是多模态情感分析里最常见的数据问题,不是模型问题。
解决:给每个模态加一个有效标记(mask),无人脸、静音、文本为空都分别记录。模型输入里附带 mask 向量,融合时对该模态特征做乘零处理而不是直接省略。此外,训练集里可以刻意保留一部分单模态样本,让模型见到缺失输入时也不会崩,这样部署时面对真实录像的“没人露脸”情况才抗得住。
5.3 文本模态“一口吃掉”其他模态,模型退化成单模态
现象:消融实验发现去掉图像和语音后 F1 几乎不变,说明视觉语音通道没有提供增量信息;注意力权重也显示文本 query 对其他模态的注意力接近均匀分布,学成了一个摆设。
原因:文本预训练特征表达力太强,拼接后 MLP 直接用文本特征就能拟合训练集,梯度回传时语音图像分支拿不到有效信号,慢慢就成了死分支。
解决:一是训练中随机遮蔽部分文本词,强制模型从语音和图像找补充信息;二是对多模态融合层使用更高的 dropout;三是给每个模态的特征做独立的 LayerNorm,防止某个模态的数值范围压过其他模态。这个坑几乎人人会踩,早做消融实验就能早点发现。
5.4 标注噪声与类别不平衡:多数类主导训练,F1 虚高
现象:准确率 0.8,但一看混淆矩阵,正样本几乎全被分到负类;F1 和准确率差距很大,少数类的召回率只有 0.1。
原因:多模态情感数据集里“中性”或“正面”占比往往超过一半,模型把所有样本判成多数类就能拿到高准确率,但任务要求的是对负面和讽刺这类难样本敏感。
解决:评估时统一用 macro F1 而不是准确率。训练时给少数类加权,或者用 focal loss 调整难样本权重。还要留意标注一致性:同一个视频片段请两个人标,容易出现一人标“轻微负面”、另一人标“中性”,这属于标注边界模糊,不该让模型硬学。必要时把争议样本单独拎出来做人工复核。
5.5 loss 震荡不收敛:预训练参数被大步长冲坏
现象:前两个 epoch 指标缓慢上升后开始剧烈震荡,loss 曲线像锯齿;继续训练发现验证 F1 甚至比第 2 个 epoch 还低。
原因:预训练文本模型和随机初始化的分类头共用同一个学习率。分类头需要较大学习率快速收敛,但同一个学习率作用在预训练权重上,一步就把语义表示冲得面目全非。
解决:分层设置学习率。分类头和融合层 1e-4,预训练文本层 1e-5,并配合线性 warmup,前几百步从 0 逐步升到目标学习率。加了梯度裁剪之后,loss 震荡基本能被控制住。如果震荡仍然存在,把 batch size 翻倍,通常能直接稳住。
6. 让结果可信:消融实验、模态缺失测试与一个随手可用的评估脚本
项目做完模型不是终点,别人问“你这个多模态到底比单模态好在哪”,得用数字回答。消融实验是标准做法:跑四组对比,完整的三个模态、去掉语音、去掉图像、纯文本。每组用相同的随机种子和训练轮数,记录 macro F1。如果完整模型比纯文本高不到一个点,就该回到第 4 章的融合策略重新选型,而不是署一个“多模态系统”的名字交差。
from sklearn.metrics import f1_score def evaluate_model(model, loader, device): model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for batch in loader: text, audio, vision, labels = [x.to(device) for x in batch] logits = model(text, audio, vision) preds = logits.argmax(dim=-1) all_preds.extend(preds.cpu().tolist()) all_labels.extend(labels.cpu().tolist()) return f1_score(all_labels, all_preds, average="macro")这条评估函数的要点是average="macro",它把每个类别的 F1 等权平均,避免多数类掩盖少数类的糟糕表现。另外还要测一下模态缺失的场景:推理时随机抹掉图像或语音通道,观察分数下降幅度是否合理。如果抹掉语音后结果几乎不变,说明语音分支没被模型真正用到,这个情况在真实线上环境里很致命,因为线上视频时常没人声。我自己的习惯是训练结束后第一件事并不是看测试集指标,而是跑这组模态缺失测试,再决定要不要带着全部模态上线。这个习惯帮我筛掉过至少两个看似分数不错、实际单模态就能复现结果的模型。希望帮到你。
本文还有配套的精品资源,点击获取