简介基于PyTorch实现的多模态情感分析项目提供完整可运行源码面向深度学习研究者与工程师聚焦语音与文本联合情感识别。项目采用预训练多语言BERT作为文本编码器、Wav2Vec2作为语音特征提取器利用注意力机制融合两类模态特征数据集为EATD_Corpus涵盖negative、neutral、positive三类情感样本。压缩包共31个文件包含2个Python核心脚本、10个txt说明文档、9个wav音频与9个mp4视频样本及1个inscode配置文件整体约183KB结构紧凑便于直接运行与二次开发。目前已有183人学习下载。压缩包内含create_sample_videos.py、multimodal_emotion_analysis.py等代码配套样本数据和注释说明完整覆盖环境搭建、数据预处理、模型构建、训练评估与部署流程可帮助快速复现实验并在此基础上扩展更多情感类别、优化模型鲁棒性。1. 多模态情感分析PyTorch源码包它到底省掉了哪几步脏活多模态情感分析是近两年深度学习里最常被问“能不能直接跑”的方向之一视频里有语音、画面和字幕单一模态都只给出一部分情绪信号而 PyTorch 生态下要做多模态学习第一步往往不是建模而是处理数据。拿到这套可运行源码包之前我帮某开发者排查过一版按论文复现的情感分析项目——模型结构是对的可数据始终喂不进去卡在文本、音频、视频三路输入的时间对齐上足足三天。这份源码包解决的不是“情感分析论文复现”这一件事而是把五类脏活提前做完了三模态数据对齐、特征抽取管线、五种融合结构、完整训练脚本、推理回归测试。它适合三类人需要快速出结果的毕设生、刚转多模态方向想先跑通一条链路的深度学习新手、以及需要拿一套干净 baseline 去做对比实验的算法预研。你能把时间花在调结构和看指标而不是花在修数据维度上。2. 数据对齐是第一个拦路虎文本、音频、视频的时间步如何捏成同一个矩阵2.1 三个模态的采样粒度天然不一致文本按词切分一个词在视频里可能占用 200 到 600 毫秒不等音频按帧提取特征时通常每帧 20 到 40 毫秒有的开源特征包直接按每 100 毫秒出一个窗口视频则固定 30 fps 左右。三者天然不是一个粒度直接拼接等于把三种尺度的数据当成同一把尺子来量模型只会学到噪声。常见做法是以“词”作为统一时间单位用每个词在视频中的起止时间戳去切音频特征和视频特征区间再把区间内的帧做 mean 或 max 池化得到一个向量。这样每个词都对应一个文本向量、一个音频向量、一个视频向量三个数组在第一个维度上长度一致后续才能进入融合层。公开评测集里一般会提供词级时间戳自己采集的数据则需要先用对齐工具粗标一遍这一步别指望完全自动至少抽检 5% 样本看时间戳是否落在人声区间内。对齐策略时间粒度对齐后维度适用场景词级对齐按词切(词数, 各模态特征维度)最常用语义单位明确句级对齐按句切(句数, 各模态特征维度)句子较短、词级标注缺失时帧级对齐统一到最低帧率(帧数, 各模态特征维度)需要逐帧预测情绪的细粒度任务句级对齐省事但会损失词级情绪变化比如“这部电影前面很无聊但结局让我哭了”这种转折在句级特征里会被平均掉。帧级对齐保真度最高但计算量和显存占用至少乘三倍小数据集上容易过拟合。源码包默认走词级对齐这也是多数多模态情感分析论文的预处理口径。2.2 对齐函数把词时间戳映射到音视频特征源码包里 preprocess/align.py 的核心逻辑可以压缩成下面这个函数。输入是词级起止时间戳、已经抽好的音频帧特征和视频帧特征输出是聚合后的词级音频、视频特征。import numpy as np def align_to_word(word_intervals, audio_feats, video_feats, audio_fps, video_fps, aggregatemean): 把帧级音频/视频特征聚合到词级。 word_intervals: list of (start_sec, end_sec)一个词对应一段区间 audio_feats: [T_a, D_a]T_a 是音频总帧数D_a 是音频特征维度 video_feats: [T_v, D_v]T_v 是视频总帧数D_v 是视频特征维度 audio_fps: 音频特征帧率比如每 100ms 一帧就是 10 video_fps: 视频帧率通常 30 aggregate: mean 或 max词内多帧的聚合方式 aligned_audio, aligned_video [], [] for start, end in word_intervals: a_s int(start * audio_fps) a_e min(int(end * audio_fps) 1, audio_feats.shape[0]) v_s int(start * video_fps) v_e min(int(end * video_fps) 1, video_feats.shape[0]) if a_s a_e or v_s v_e: continue # 极短词或时间戳越界跳过该词 seg_a audio_feats[a_s:a_e] seg_v video_feats[v_s:v_e] if aggregate mean: aligned_audio.append(np.mean(seg_a, axis0)) aligned_video.append(np.mean(seg_v, axis0)) else: aligned_audio.append(np.max(seg_a, axis0)) aligned_video.append(np.max(seg_v, axis0)) return np.stack(aligned_audio), np.stack(aligned_video)逻辑说明每个词的时间区间先乘以对应帧率换算成帧索引。视频取 start 到 end 对应的整数帧音频里给 end 帧加一是为了把闭区间 [start, end] 包含完整否则最后一个特征帧会漏掉。加min(..., shape[0])是防止最后一个词的结束时间超过总帧数导致越界。continue分支处理的是不足一帧的极短词这类词在带口播的评测集里不少见直接保留反而会让特征变成空向量。参数说明audio_fps不是音频采样率。如果项目里用 16kHz 采样率但只按每 100 毫秒抽一帧那audio_fps应该传 10传 16000 会对齐出一个可怕的越界索引。我吃过这个亏当时维度对不上还以为是特征抽取出了问题。aggregate选mean更稳定适合大多数情绪分类任务max适合强调“某个瞬间爆发情绪”的场景但对短音频段噪声敏感建议先两个都试再选。2.3 对齐完怎么验收建立一份维度断言清单对齐完成后不要急着送入模型先跑一个断言脚本确认维度全对。常见的词级特征维度大约是文本向量 768预训练编码器输出、音频韵律特征 74、视频动作单元特征 35。不同特征包输出维度不同但规律一致三个特征第一个维度必须等于当前句子的词数。def check_alignment(sample, seq_len, feature_names[text, audio, video]): 验收对齐后的单条样本。sample: {name: np.ndarray} for name in feature_names: feat sample[name] assert feat.shape[0] seq_len, \ f{name} 长度 {feat.shape[0]} 不等于词数 {seq_len} assert np.all(np.isfinite(feat)), \ f{name} 含 NaN 或 Inf检查特征抽取与对齐 assert np.abs(feat).sum() 0, \ f{name} 全零大概率是时间戳错位导致切片空窗 print(aligned, all checks passed)逻辑说明seq_len从文本分词结果直接获取sample里的音频和视频特征必须和它严格一致。三个断言分别抓三类问题长度不一致说明帧率参数或时间戳有问题出现 NaN/Inf 说明切片索引越界后在聚合时产生了空计算全零向量通常是因为时间戳偏移导致取到了空白静音段或画面切换段。这套检查在源码包里被写成了preprocess/verify.py每次预处理完批量数据都建议跑一遍。参数说明feature_names可以按实际模态增减如果项目只做文本加音频就删掉 video。断言脚本的成本很低却能在进入训练前拦住八成的数据问题比在 loss 下降异常时回头查数据要省时间得多。3. 深度融合结构怎么选拼接、加权、跨模态注意力的适用边界与参数3.1 融合分层早期、中期、晚期各自卡在哪融合结构决定的是“三个模态在哪个阶段发生交互”。早期融合把对齐后的特征直接拼接成一个向量再进分类器实现最简单但每个模态独立的高维特征被强行缝合冗余信息会让模型在小数据集上过拟合。晚期融合让三个模态各自走完编码器最后再拼接或加权做分类模态独立性好但交互信息完全丢失情绪判断往往“各说各话”。中期融合是在编码过程中让模态互相交换信息多模态学习的大部分效果提升都来自这个阶段。表格法看更直观结构类型交互时机参数规模3000 样本量级标注数据上的表现调参要点简单加权求和晚期极小可作为下限 baseline权重可固定为相等或学一个 softmax 权重特征拼接 MLP早期小稳定但不突出hidden 32-64dropout 0.3-0.5张量外积融合中期极大易过拟合效果两极分化不推荐在万级以下数据用词级跨模态注意力中期中等多数评测集上最优hidden 64-128heads 2-4门控选择融合中期中等对缺模态样本友好需要额外设计门控向量来源我自己的排序是小数据先跑拼接 MLP 做 baseline再切跨模态注意力加权求和作为缺失模态场景的备选。张量外积这类结构在公开评测集上曾有过亮眼结果但它的参数量随模态维度指数增长换到自己采集的数据上几乎必然翻车血泪经验。3.2 融合模块的 PyTorch 实现对比源码包里的models/fusion.py实现了五种结构其中两个核心模块可以压缩成下面这段对比代码一个是最基础的拼接融合一个是跨模态注意力融合。注意力版本把三个模态各压缩成一个 token放在一起做自注意力让模态之间直接交换信息。import torch import torch.nn as nn class ConcatFusion(nn.Module): 早期拼接融合对齐后直接 concat 过 MLP def __init__(self, d_in, d_hidden, n_class): super().__init__() self.fc nn.Sequential( nn.Linear(d_in, d_hidden), nn.ReLU(), nn.Dropout(0.3), nn.Linear(d_hidden, n_class), ) def forward(self, text_feat, audio_feat, video_feat): # 三个特征首维都是词数先做句级池化再拼接 def pool(x): return x.mean(dim1) cat torch.cat([pool(text_feat), pool(audio_feat), pool(video_feat)], dim-1) return self.fc(cat) class CrossModalFusion(nn.Module): 句级跨模态注意力三个模态作为三个 token 互相关注 def __init__(self, d_text, d_audio, d_video, d_hidden, n_class): super().__init__() self.proj_t nn.Linear(d_text, d_hidden) self.proj_a nn.Linear(d_audio, d_hidden) self.proj_v nn.Linear(d_video, d_hidden) self.attn nn.MultiheadAttention(d_hidden, num_heads4, batch_firstTrue) self.classifier nn.Sequential( nn.Linear(d_hidden, 64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, n_class) ) def forward(self, text_feat, audio_feat, video_feat): t self.proj_t(text_feat.mean(dim1)) a self.proj_a(audio_feat.mean(dim1)) v self.proj_v(video_feat.mean(dim1)) m torch.stack([t, a, v], dim1) # [B, 3, H] attn_out, attn_weight self.attn(m, m, m) # 三个模态互做注意力 fused attn_out.mean(dim1) return self.classifier(fused), attn_weight逻辑说明ConcatFusion 先对每个模态做句级平均池化再拼接三维向量过两层 MLP。它简单但有个问题平均池化会把词级情绪波动抹掉所以适合作为 baseline 而不是最终方案。CrossModalFusion 里三个模态先各投影到同一隐藏维度堆成[B, 3, H]的序列送入MultiheadAttention等价于让文本、音频、视频两两计算相关度再由注意力输出汇总。返回的attn_weight可以用来观察每个模态对最终判断的贡献比例这在多模态学习中是个很有用的诊断信号。参数说明d_hidden我一般设在 64 到 128 之间太大在小数据上会更快过拟合。num_heads4对 3 个 token 的序列来说已经足够别用 8 头头数超过 token 数量没有意义。Dropout(0.3)是 3000 样本量级的安全区如果换到 1 万以上数据可以降到 0.1。注意这个版本是句级简化实现对时间步展开的完整词级注意力在源码包的models/sequence_fusion.py里结构更复杂但思路一致。3.3 实验对比怎么安排先把 baseline 跑死拿到源码包先别急着上跨模态注意力。我的建议是三步走先用 ConcatFusion 跑一组完整训练记录准确率和 F1作为整个实验的地板再用 CrossModalFusion 跑同一组数据对比提升幅度最后把attn_weight画出来看哪些样本上音频主导、哪些文本主导。如果跨模态注意力没有显著超过拼接先检查数据对齐质量而不是怀疑模型——融合结构能放大的只有信号不能凭空创造信号。源码包experiments/目录下已经写好这两种结构的训练入口切换模型只需改一行配置。做对比实验时保持 batch size、学习率、训练轮数完全一致否则指标差距说不清是结构带来的还是调参带来的。我当时就把三种结构的训练轮数设成不同值结果跑出来以为组间差异很大最后发现只是收敛速度不同翻车翻得很冤枉。4. PyTorch 训练管线落地从 Dataset 到混合精度的完整细节4.1 Dataset 类三模态样本如何组织数据组织方式直接影响后续 collate 和训练的省心程度。源码包里的data/dataset.py把每个样本组织成一个 dict包含对齐后的文本特征、音频特征、视频特征和标签__getitem__返回的是 you 可以直接转成 Tensor 的 numpy 数组。from torch.utils.data import Dataset import numpy as np class MultimodalEmoDataset(Dataset): def __init__(self, records, label_map): self.data records # list of dict见下方说明 self.label_map label_map # {positive: 0, negative: 1, ...} def __len__(self): return len(self.data) def __getitem__(self, idx): item self.data[idx] return { text: item[text].astype(float32), # [seq_len, 768] audio: item[audio].astype(float32), # [seq_len, 74] video: item[video].astype(float32), # [seq_len, 35] label: self.label_map[item[label]], }逻辑说明records是预处理阶段导出的标准化字典列表每个字典里三个模态特征已经在对齐阶段统一成相同的seq_len。__getitem__里做astype(float32)是为了避免默认的 float64 在混入模型时引发类型不匹配的报错PyTorch 对 dtype 的检查比 numpy 严格得多。标签在初始化的时候通过label_map转成整数索引后面CrossEntropyLoss可以直接吃。参数说明如果数据量很大不要在__getitem__里做特征读取或归一化这种耗时操作训练时每个 epoch 都会重新执行一次IO 会成为隐形瓶颈。正确做法是在预处理阶段把所有特征一次性读进内存并序列化训练只管取数。4.2 collate_fn 与 attention_maskpadding 不能只 pad 数值对齐阶段保证的是单个样本内三个模态长度一致但一个 batch 内不同句子的词数仍然不同所以需要 padding 到该 batch 的最大长度。这里最容易踩的坑是只 pad 特征矩阵忘了生成对应的attention_maskTransformer 结构会把 padding 位置当成真实信号来计算注意力。import torch import numpy as np def collate_fn(batch): texts, audios, videos, labels [], [], [], [] for item in batch: texts.append(item[text]) audios.append(item[audio]) videos.append(item[video]) labels.append(item[label]) max_len max(t.shape[0] for t in texts) pad_t, pad_a, pad_v, masks [], [], [], [] for t, a, v in zip(texts, audios, videos): seq_len t.shape[0] pad_t.append(np.pad(t, ((0, max_len - seq_len), (0, 0)))) pad_a.append(np.pad(a, ((0, max_len - seq_len), (0, 0)))) pad_v.append(np.pad(v, ((0, max_len - seq_len), (0, 0)))) masks.append([1] * seq_len [0] * (max_len - seq_len)) return { text: torch.tensor(np.stack(pad_t)), audio: torch.tensor(np.stack(pad_a)), video: torch.tensor(np.stack(pad_v)), mask: torch.tensor(masks, dtypetorch.long), label: torch.tensor(labels), }逻辑说明np.pad第二维(0, 0)表示不 pad 特征维度只 pad 序列维度。masks里真实词位置填 1padding 位置填 0这个掩码要同时传给文本编码器和跨模态注意力模块否则模型无法区分真实数据和补齐数据。返回统一字典的好处是模型取参方便不用照看多个位置参数。参数说明max_len按 batch 内最大长度动态计算比固定到数据集最大长度要省算力但每个 batch 长度不同batch 之间的训练时间会波动。如果显存吃紧可以把max_len固定成 64 或 128超出部分截断短样本仍然 pad 到这个固定长度。4.3 训练循环梯度裁剪与混合精度训练多模态模型有一个特点三个模态的 loss 量级常常不一致某一路特征分布偏大就会主导梯度所以梯度裁剪几乎是必须项。优化器用 AdamW学习率不适合太大我用 5e-4 在公开评测集上表现稳定。import torch import torch.nn.functional as F from torch.cuda.amp import GradScaler def train_one_epoch(model, loader, optimizer, scaler, device, max_norm2.0): model.train() total_loss, total_correct, total_num 0.0, 0, 0 for batch in loader: text batch[text].to(device) audio batch[audio].to(device) video batch[video].to(device) mask batch[mask].to(device) labels batch[label].to(device) optimizer.zero_grad() with torch.autocast(cuda, dtypetorch.float16): logits, _ model(text, audio, video, mask) loss F.cross_entropy(logits, labels) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm) scaler.step(optimizer) scaler.update() total_loss loss.item() * len(labels) total_correct (logits.argmax(dim-1) labels).sum().item() total_num len(labels) return total_loss / total_num, total_correct / total_num逻辑说明model(text, audio, video, mask)里传了mask确保注意力层屏蔽 padding 位置。autocast和GradScaler是混合精度的标准搭配scaler.unscale_(optimizer)必须先于clip_grad_norm_调用否则梯度仍是缩放后的状态clip 的阈值就失效了。clip_grad_norm_的max_norm2.0是一个在多数多模态任务上不会过于激进的值如果训练初期 loss 出现振荡降到 1.0 观察。参数说明max_norm不是越大越好设成 10 基本等于没裁。混合精度在 30 系及以上显卡上提速明显老显卡如果不支持可以直接去掉autocast和scaler训练逻辑不变。学习率 5e-4 适合 AdamW 加 CosineAnnealingLR 的搭配如果不加 scheduler建议降到 2e-4。4.4 可复现性设置随机种子一次锁住多模态特征抽取通常是离线完成的训练阶段的随机性主要来自 dropout、数据打乱顺序和 CUDA 非确定性算子。源码包utils/seed.py里提供了一个标准的全局种子设置函数训练入口第一行就调用它。import random, numpy as np, torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False逻辑说明torch.manual_seed同时覆盖 CPU 和当前 CUDA 设备cuda.manual_seed_all覆盖所有显卡。cudnn.deterministic True让卷积和某些算子走确定性算法benchmark False防止运行时根据输入尺寸自动选择最快的非确定性内核。参数说明开启确定性后训练速度会有 10% 到 30% 的下降这是复现性必须付出的代价。做正式实验时保持开启日常调参阶段可以临时关掉deterministic只保留随机种子等确认实验方案后再开回来跑最终版。源码包在configs/default.yaml里提供了seed和cudnn_deterministic两个开关不必每次改代码。5. 多模态情感分析避坑指南五个会浪费一整天的问题5.1 对齐相关三个让人抓狂的报错坑 1词数少的样本切音频后维度不等于词数。 现象训练到某个 batch 直接抛RuntimeError: size mismatch打印出来的 shape 是(17, 74)和(18, 74)这种差一个的错位。 原因对齐函数里音频帧率传成了原始采样率词时间区间换算出来的帧索引跨度过大切出的帧数远多于词数。 解决先打印单个样本的word_intervals和audio_feats.shape[0]核对audio_fps是不是特征文件实际的帧率。特征提取工具每 100ms 出一帧就传 10每 20ms 出一帧就传 50不是你录音设备上的 16000 或 44100。坑 2padding 之后没传 attention_mask。 现象loss 能降但准确率始终在随机水平附近或者验证集上模型预测几乎全落在数量最多的类别上。 原因Transformer 的注意力机制把 padding 位置的零向量也当成有效信号计算模型学会了从“这是 padding”里偷懒没有真正建模词义。 解决检查collate_fn是否生成了mask再检查模型forward里是否真把mask传给了MultiheadAttention。只生成不传等于没生成这是我复制旧代码时最容易犯的错。坑 3shuffle 之后三路模态的数据互相错位。 现象验证集指标看起来很正常但随机抽几条预测结果发现预测的文本和音频根本不是同一个人说的。 原因Dataset 的records列表在预处理时被某种外部排序打乱过而__getitem__里按索引分别去不同文件目录读三路数据索引对不上。 解决在预处理阶段把三路特征按同一个sample_id打包进同一个字典并序列化训练时只从这个字典里取数禁止__getitem__里临时读文件。宁可内存占用高一点也不要引入隐性的对齐错位。5.2 训练与数据策略两个容易被忽略的细节坑 4测试时重新 fit 了归一化器导致结果“虚高”。 现象验证集 F1 比训练时记录的高出 5 个点但心里清楚模型不可能在没见过的数据上变这么好。 原因测试管线里用了新的MinMaxScaler对特征重新做了 fit测试集分布信息被泄漏进了特征数值里。多模态特征中音频和视频特征的数值范围差异巨大不做归一化根本训不动但归一化器的 fit 只能在训练集上做一次。 解决训练阶段把 fit 好的归一化器用 pickle 保存到artifacts/scaler.pkl测试和推理阶段只调用transform禁止再次fit。源码包的inference/run.py里已经实现了这个逻辑直接从训练脚本同一条路径读取即可。坑 5类别不平衡只加加权 loss小类 recall 还是很差。 现象confusion matrix 显示大类准确率 90%小类 recall 只有 10% 左右。 原因加权 loss 只是让梯度里小类的信号变大但每个 batch 的采样分布仍然偏向大类小类样本每 epoch 只出现几次学不动。 解决用WeightedRandomSampler让每个类别在同一个 epoch 内被抽到的次数接近再叠加focal loss。采样器的权重按类别样本数的倒数计算实现很简单但效果比只加 loss 权重明显得多。注意采样器会改变每个 epoch 的实际样本数训练日志里的 step 总数要按新采样数重新计算否则早停的 patience 判断会失真。6. 把训练好的模型搬到新数据归一化参数回放与推理回归测试训练完只代表这版数据和模型是匹配的换一批新视频之后特征分布会变。最典型的翻车点是新数据的音频响度整体比训练集大视频人物离镜头更远特征数值范围偏移后模型预测精度直接下一个台阶。解决办法不是重新训练而是把训练阶段保存的归一化器“回放”到新数据上。import pickle, numpy as np def load_scaler(pathartifacts/scaler.pkl): with open(path, rb) as f: return pickle.load(f) def normalize_new_sample(sample, scaler): # 只调用 transform禁止再次 fit out {} for name in [audio, video]: feat sample[name] # [seq_len, dim] out[name] scaler[name].transform(feat) out[text] sample[text] # 文本特征不做这个归一化 return out逻辑说明scaler是训练集 fit 好的字典每个模态各存一个独立的归一化器。文本特征来自预训练编码器数值分布本身稳定不需要也不应该套用音视频的归一化参数。新样本的音频和视频特征只做transform数值范围才和训练时一致。推理回归测试是这套管线里我最后加上去的一个环节取训练集前 5 条样本用当前推理脚本跑一遍和训练时记录下来的 logits 做对比差异超过阈值就说明推理管线被改坏了。这个习惯救过我一次当时重构了预处理代码自认为逻辑没变结果回归测试发现最大绝对差到了 0.8一查是特征帧率参数被误改成了原始采样率。从那以后我每次把模型搬到新一批视频上都会强制走一遍这个回归脚本先保证新旧结果一致再谈阈值微调和效果优化。多模态项目里“数据出问题”的概率远高于“模型出问题”所有能在数据侧设立的检查点都值得加。希望帮到你。本文还有配套的精品资源点击获取