多模态融合情感分析:从文本到四类情感的Python分层方案

发布时间:2026/10/25 11:29:36
多模态融合情感分析:从文本到四类情感的Python分层方案

简介:一套基于Python与TensorFlow实现的多模态情感分析项目资源,输入涵盖文本、语音、图片和视频,面向毕业设计、课程设计与项目开发。源码已通过严格测试,可放心参考并二次扩展。项目采用分层融合思路:先提取单模态特征,再逐步构建双模态、三模态组合特征,最后用softmax层输出喜、怒、哀、其他四类情感,比常见的正负中性分类更适合用户分层与目标分析。压缩包共21个文件,主要包括pickle数据文件、py源码、含iemocap数据集的zip、txt说明、pdf文档、md说明及结果示意图,总大小约58.48MB,目录结构清晰,便于按模块查阅。环境要求Python 3.6+和TensorFlow 1.7,支持CPU或GPU。目前已有六百八十二人学习使用,对于想理解多模态情感识别完整流程的开发者,是一套可直接落地的实践资料。

1. 多模态融合情感分析:从文本单模态到四类情感的完整 Python 方案

多模态融合情感分析这个 Python 项目,解决的是纯文本情感分析的盲区。弹幕里一句“这也太厉害了吧”,配上白眼表情包和嘲讽语气,只看文本的模型十有八九判成喜,实际上人家在骂。项目输入同时覆盖文本(含 emojis)、语音、图片和视频,用 TensorFlow 搭了一套从单模态向量逐步融合到双模态、三模态的分层结构,最后通过 softmax 输出喜、怒、哀、其他四类情感。自带源码、项目文档和 MOSI/IEMOCAP 数据集,毕业设计、课程设计都能直接接,跑通就是一份能演示、能写进论文的完整基线。

2. 项目结构与融合原理:单模态向量到三模态向量的分层设计

2.1 为什么先做单模态编码,而不是直接拼特征

多模态数据最麻烦的地方,是三个模态根本不在同一个度量空间里。文本特征来自词向量或 embedding,维度几百;语音特征来自 openSMILE 这类工具抽出的声学参数,维度几十到上千不等;图片和视频特征过完 CNN,动辄上千维。如果直接把三段原始特征 concat 起来喂给全连接层,维度高的模态会主导梯度方向,模型基本学不到弱模态的信息。这是多模态项目里最常见的“单模态近视”,也是很多人复现多模态论文发现准确率反而不如单模态的原因。

项目采用的分层方案是:先给每个模态单独做一层编码,把不同维度的原始特征统一映射到相同的 hidden_dim;再做两两组合的双模态融合;最后做三模态融合接 softmax。每一步都有独立的激活函数和参数,相当于先让模型分别理解“这句话在说什么”“这个语气什么情绪”“这个画面什么氛围”,再学三者之间的互补关系。文本里的 emojis 也被当作语义信号保留在向量里,不需要单独清洗掉,这一点在预处理脚本里处理得很干净。

我一般会拿这种分层结构和直接 concat 做对比实验,后者准确率通常低 5~10 个点。原因是直接拼接时模型要同时学模态内表示和模态间关系,优化难度大;分层结构把任务拆成两段,每段都更容易收敛。它属于 early fusion 和 late fusion 之间的折中——既不是把原始特征生拼,也不是三个模型最后投票,而是在中间层逐级对齐。

2.2 双模态到三模态:融合路径与向量对齐

双模态层有三条路径:文本+语音、文本+视频、语音+视频,三条路径的输出再接三模态层。为什么不把三条并成一条?不同模态对的互补性不一样:文本和语音都携带语言信息,但文本是字面内容、语音是语调情绪,两者结合能纠正“字面客气但语气轻蔑”的样本;语音和视频结合能捕捉说话人表情与语调的一致程度,比如皱着眉说“没事”和笑着说的情感完全不同。

对齐指的是把每个模态的向量统一到相同维度再参与拼接。实现里编码层都输出 hidden_dim(默认 128 维),双模态拼接后是 256 维,经过一层 relu 全连接保持 256 维;三模态把三条双模态路径拼起来是 768 维,再过一层全连接压到 512 维附近。逐层压缩的设计避免了特征维度爆炸,也让最终分类层只面对一个紧凑表示。hidden_dim 不是越大越好,IEMOCAP 这类几千条样本的数据集,128 已经是比较稳的起点。

2.3 四分类标签设计:喜、怒、哀、其他

常见文本情感分析是三分类:正向、负向、中性。项目刻意改成喜、怒、哀、其他四类,项目文档里写得很直接:为了用户分层和目标用户分析。运营场景里把“负向”再拆成“怒”和“哀”非常实用——愤怒用户需要安抚和退款引导,悲伤用户需要共情和陪伴,话术完全两样,只分正负根本不够用。

标签来自数据集自带标注。IEMOCAP 原始是六类情绪,项目给了 6way 和 4way 两份 pickle;MOSI 原始是情感强度回归值,2way pickle 把它二值化成正负。4way 的常见映射关系大致如下:

IEMOCAP 原始情绪映射到四分类
happy、excited喜
angry、frustrated怒
sad哀
neutral 及其他其他

训练时不需要手动做这个映射,pickle 里的 label 数组已经是映射后的整数索引,0~3 对应四类。压缩包里的文件按下面这份清单对照检查:

文件作用
model.py分层融合模型定义
run.py训练、验证、推理入口
data_prep.py数据加载、切分、归一化
create_data.py从原始数据构造特征 pickle
unimodal_mosi_2way.pickleMOSI 单模态特征(二分类标签)
unimodal_iemocap_4way.pickleIEMOCAP 单模态特征(四分类标签)
unimodal_iemocap_6way.pickleIEMOCAP 单模态特征(六分类标签)
iemocap-data.zip原始 IEMOCAP 数据
Canonical code.pdf项目文档与代码说明
README.md环境部署与运行说明
result.png作者跑出的训练结果截图
新建 文本文档.txt作者备注草稿,可忽略

3. 数据准备:MOSI、IEMOCAP 与三份 pickle 文件怎么读

3.1 两份数据集各自的形态和适用场景

MOSI(CMU 多模态观点情感数据集)收录的是 YouTube 影评视频片段,发言者边说边有画面,文本由字幕对齐,是学术圈多模态情感分析最常用的公开集之一。它的情感自带强度,原本是回归任务,范围在 [-3, 3],二值化后转成正负二分类,样本量适中、类别干净,适合用来快速验证模型能不能跑通。

IEMOCAP 是交互式情感动态捕捉数据集,由演员按脚本表演对话,录制时长约 12 小时,情绪标注比 MOSI 细,带 angry、happy、sad、neutral、excited、frustrated 六类。如果你的毕业设计要强调细粒度情感分析,主线用 IEMOCAP 的 4way 那份更有说服力;如果只是做系统演示,MOSI 2way 一个 epoch 跑得飞快,演示过程不冷场。

3.2 pickle 文件解析:读取代码与数据结构

项目把三个模态的特征预先提取好,存成了 pickle。拿到文件第一件事不是跑训练,而是先读进来看结构,确认 key 名和维度,再改 run.py 里的对应参数。下面这段代码可以做快速普查:

import pickle import numpy as np def load_unimodal_pickle(path): # 关键:老 pickle 是 Python 2 生成的,必须指定 latin1 编码 with open(path, 'rb') as f: data = pickle.load(f, encoding='latin1') return data data = load_unimodal_pickle('unimodal_mosi_2way.pickle') print('顶层类型:', type(data)) if isinstance(data, dict): for key, val in data.items(): if hasattr(val, 'shape'): print(key, 'shape:', val.shape, 'dtype:', val.dtype) else: print(key, type(val))

这段代码的逻辑分两块:pickle.load 的 encoding 参数解决 Python 2 序列化文件的兼容问题,很多人卡在这一步;后面的循环按字典逐项打印 key 和 shape,是确认特征维度最快的办法。常见的结构是 {'text': ..., 'audio': ..., 'video': ..., 'label': ...},也可能按 {'train': ..., 'valid': ..., 'test': ...} 分层组织,具体以 print 结果为准。

提示:如果打印出来没有 'label' 这个 key,先列出所有 key 找找,常见命名还有 'labels' 或 'y',改一下脚本里的字段名即可。

注意 label 的取值。2way 的 label 一般是 0/1,4way 是 0~3,6way 是 0~5。如果 run.py 里写死 num_classes=4 却拿 6way 那份去训,会报维度不匹配或准确率异常低,这是我见过最多的用法错误。

3.3 create_data.py 与 data_prep.py:谁负责什么

create_data.py 负责从原始数据构造特征 pickle,输入是 iemocap-data.zip 这类原始录制数据,输出是上面那种特征字典。常见做法是:文本走预训练词向量或句向量;语音用 openSMILE 提声学特征;视频帧过预训练 CNN 取全连接层输出。工程上这一步最耗时,所以作者把特征提前存成 pickle 发出来,省掉重复抽特征的一两个小时。

data_prep.py 负责把 pickle 里的特征变成模型能直接吃的数据:划分训练/验证/测试集、归一化、把各模态维度对齐。切分有个容易踩的坑——三个模态来自同一条样本,必须按下标同步切,不能分别随机打乱。代码写法如下:

from sklearn.model_selection import train_test_split def split_multimodal(text, audio, video, labels, test_ratio=0.1, valid_ratio=0.1): # 用同一个 random_state 和同一组下标,同步切四个数组 idx = np.arange(len(labels)) train_idx, temp_idx = train_test_split( idx, test_size=test_ratio + valid_ratio, stratify=labels, random_state=42) valid_idx, test_idx = train_test_split( temp_idx, test_size=valid_ratio / (test_ratio + valid_ratio), stratify=labels[temp_idx], random_state=42) def pick(arr, ix): return arr[ix] return (pick(text, train_idx), pick(text, valid_idx), pick(text, test_idx), pick(audio, train_idx), pick(audio, valid_idx), pick(audio, test_idx), pick(video, train_idx), pick(video, valid_idx), pick(video, test_idx), pick(labels, train_idx), pick(labels, valid_idx), pick(labels, test_idx))

train_test_split 的 stratify 参数保证训练/验证/测试里每类样本比例一致,情感数据通常类不均衡,这个参数能避免“验证集里全是多数类”的假象。random_state 固定成 42,保证每次跑复现一致,答辩时被问“实验可复现吗”直接报种子。先切出 temp 再二次切分,是为了分别控制验证集和测试集比例,常见配比是 8:1:1 或 6:2:2。

4. 模型与训练:model.py 与 run.py 的代码走读和参数调优

4.1 model.py:分层融合模型的核心实现

model.py 是这套项目的主干,接收三个模态的输入占位符,依次经过单模态编码、双模态融合、三模态融合,输出 logits 和 softmax 概率。一个可用的实现大致如下:

import tensorflow as tf def fused_model(text_feat, audio_feat, video_feat, hidden_dim=128, num_classes=4): # 单模态编码:三个模态统一映射到 hidden_dim with tf.variable_scope('unimodal'): t = tf.layers.dense(text_feat, hidden_dim, activation=tf.nn.relu, name='text_enc') a = tf.layers.dense(audio_feat, hidden_dim, activation=tf.nn.relu, name='audio_enc') v = tf.layers.dense(video_feat, hidden_dim, activation=tf.nn.relu, name='video_enc') # 双模态融合:两两拼接后过一层全连接 with tf.variable_scope('bimodal'): ta = tf.concat([t, a], axis=1) ta = tf.layers.dense(ta, hidden_dim * 2, activation=tf.nn.relu, name='ta_fc') tv = tf.concat([t, v], axis=1) tv = tf.layers.dense(tv, hidden_dim * 2, activation=tf.nn.relu, name='tv_fc') av = tf.concat([a, v], axis=1) av = tf.layers.dense(av, hidden_dim * 2, activation=tf.nn.relu, name='av_fc') # 三模态融合:把三条双模态路径拼起来 with tf.variable_scope('trimodal'): tri = tf.concat([ta, tv, av], axis=1) tri = tf.layers.dense(tri, hidden_dim * 4, activation=tf.nn.relu, name='tri_fc') logits = tf.layers.dense(tri, num_classes, name='logits') return tf.nn.softmax(logits), logits

逻辑说明:单模态层用三个独立的 variable_scope,防止不同模态的 Dense 权重互相覆盖;双模态层输出 hidden_dim2,因为两个 128 维向量拼接后是 256 维,容量不缩水;三模态层输出 hidden_dim4 同理。最后一层不接激活函数,直接出 logits,softmax 交给损失函数内部处理,数值上更稳定,也比手动算 softmax 再算 cross entropy 少一次中间精度损失。

参数说明:hidden_dim 影响模型容量,几千条样本用 128 足够,数据量上十万可以调到 256;num_classes 必须和 pickle 的 label 上界对齐,用 4way 就传 4,用 6way 就传 6。另一个容易忽略的点是 relu 之后可以加 dropout,训练时 drop 0.5、预测时关掉,对抑制过拟合很有效,调参阶段建议加上。

提示:如果你装了 TensorFlow 1.15 而不是 1.7,上面的代码无需改动,两个版本对 tf.layers 和 tf.variable_scope 的支持一致。

4.2 run.py:训练循环与关键超参数

run.py 负责把 model.py 定义的图真正跑起来,核心是占位符、损失函数、优化器和会话循环:

text_ph = tf.placeholder(tf.float32, [None, TEXT_DIM], name='text') audio_ph = tf.placeholder(tf.float32, [None, AUDIO_DIM], name='audio') video_ph = tf.placeholder(tf.float32, [None, VIDEO_DIM], name='video') label_ph = tf.placeholder(tf.int32, [None], name='label') _, logits = fused_model(text_ph, audio_ph, video_ph) loss = tf.reduce_mean( tf.nn.sparse_softmax_cross_entropy_with_logits( logits=logits, labels=label_ph)) train_op = tf.train.AdamOptimizer(learning_rate=1e-3).minimize(loss) correct = tf.equal(tf.argmax(logits, axis=1), tf.cast(label_ph, tf.int64)) accuracy = tf.reduce_mean(tf.cast(correct, tf.float32)) with tf.Session() as sess: sess.run(tf.global_variables_initializer()) for epoch in range(EPOCHS): for batch in make_batches(X_train, y_train, batch_size=32): sess.run(train_op, feed_dict={ text_ph: batch['text'], audio_ph: batch['audio'], video_ph: batch['video'], label_ph: batch['label'], }) val_acc = sess.run(accuracy, feed_dict={ text_ph: X_valid['text'], audio_ph: X_valid['audio'], video_ph: X_valid['video'], label_ph: y_valid, }) print('epoch', epoch, 'val_acc', val_acc)

逻辑说明:sparse_softmax_cross_entropy_with_logits 要求 label 是整数索引而不是 one-hot,正好对上 pickle 里的 0~3 标签,省一步转换;AdamOptimizer 配学习率 1e-3 是通用起点,情感分类这种小数据任务很少需要超过 1e-2。验证集每个 epoch 滚一次,用来判断收敛和过拟合。

参数说明:batch_size 32 在 4G 显存下比较稳,如果用的是视频帧特征且每帧维度大,降到 16 或 8;EPOCHS 先设 50,观察验证准确率不再上升就早停,不用硬跑满。make_batches 可以自己写一个 generator,按 batch_size 切片,注意最后一次不足一个 batch 也要能跑,别用取余把尾巴丢掉。

4.3 怎么判断训练真的有效

训练完别只盯着训练集准确率。第一步,对比训练 loss 和验证 loss:训练 loss 一直降、验证 loss 回升,是过拟合,加 dropout 或减 hidden_dim;两个 loss 都平了,说明模型容量到头。第二步,做消融实验——分别只喂文本、只喂语音、只喂视频,记录各自准确率,再和多模态结果对比。如果融合后没比最好的单模态高,大概率是特征没对齐或者数据量撑不起融合层,回头查第 3 章的 pickle 维度是否真的匹配。

result.png 是作者跑出的效果截图,可以作为你的基准线。把自己训出的准确率和混淆矩阵截图存档,写论文、做答辩 PPT 都用得上,这也是为什么我建议每次实验固定随机种子、固定数据切分,不然两份结果对不上,答辩现场很难解释。

5. 环境部署与常见问题排查:TensorFlow 1.7 时代的五个坑

5.1 环境版本配对:Python 3.6 + TensorFlow 1.7

README 写得很明确:Python 3.6+、TensorFlow 1.7、支持 CPU 或 GPU。这套组合在当年是主流,放到今天问题就来了——TensorFlow 1.7 是 2018 年的版本,和现在的 Python 3.9/3.10、新版 CUDA 完全不兼容。如果机器上已经装了 TF 2.x,直接拿项目代码跑几乎必翻车。

CPU 版安装命令很简单,关键是版本号必须锁死:

conda create -n msa python=3.6 conda activate msa pip install tensorflow==1.7.0 pip install numpy==1.19.5 scikit-learn

GPU 版先确认显卡驱动再装:

pip install tensorflow-gpu==1.7.0

逻辑说明:TF 1.7 的 GPU 版对应 CUDA 9.0 和 cuDNN 7,驱动太新反而可能跑不起来,这是老框架的通病。CPU 版虽然慢,但对情感分析这种几千条样本的小数据集完全够用,一个 epoch 几十秒,毕业设计演示不需要 GPU。装完用下面这行验证环境,能打印版本号就算过了:

python -c "import tensorflow as tf; print(tf.__version__)"

注意:确认机器上没有其他项目依赖 TF 2.x,别在同一个环境里强行降级,新建 conda 环境最省事。

5.2 五个高频踩坑记录

以下五条是这个项目上最常见的真实翻车现场,按出现频率排序。

坑一:TensorFlow 2.x 环境直接报 AttributeError

现象:运行 run.py 报 module 'tensorflow' has no attribute 'placeholder',或 has no attribute 'layers'。

原因:机器装的是 TF 2.x,tf.placeholder、tf.layers 在 2.0 里被移除,项目代码按 1.x API 写的。

解决:新建 conda 环境装 Python 3.6,然后 pip install tensorflow==1.15.0。1.15 对 1.x 代码兼容最好,支持到更晚的 Python 3.7,比 1.7 省心。如果坚持用 2.x,可以在代码头部加 import tensorflow.compat.v1 as tf 并调用 tf.disable_v2_behavior(),但老代码里其他 op 还会陆续报错,不建议。

坑二:加载 pickle 报 UnicodeDecodeError

现象:pickle.load 抛 UnicodeDecodeError: 'ascii' codec can't decode byte。

原因:pickle 是作者用 Python 2 序列化的,Python 3 默认按 ascii 解析字符串,撞上非 ASCII 字符就炸。

解决:open 后加 encoding='latin1',第 3 章代码已经写好了。这是老多模态项目最经典的兼容性坑,踩的人特别多。

坑三:numpy 报 module 'numpy' has no attribute 'float'

现象:import 阶段报 AttributeError: module 'numpy' has no attribute 'float'。

原因:numpy 1.24 起删掉了 np.float、np.int 这些老别名,data_prep.py 或依赖库还在用。

解决:把 numpy 降到 1.19.5。兼容 Python 3.6 的 numpy 最高就到 1.19.5,和 TF 1.7 正好同一代,装的时候务必锁版本。

坑四:GPU 训练 OOM(ResourceExhaustedError)

现象:run.py 跑到某个 epoch 报 ResourceExhaustedError: OOM when allocating tensor with shape [...]。

原因:三个模态特征同时进 feed_dict,视频特征每帧几千维时,一个 batch 占用的显存会暴涨,8G 以下显存尤其明显。

解决:batch_size 从 32 降到 16 或 8;确认 feed_dict 里没有把整个验证集一次性塞进去;再不行用编码层把输入先降维,或者干脆跑 CPU 版。

坑五:模型只学到多数类,准确率虚高

现象:训练准确率很高,比如 80%,但打印每一类的召回率,发现模型几乎把所有样本都判成“其他”。

原因:情感类别天然不均衡,IEMOCAP 里 neutral 和其他类样本多,网络学了个偷懒策略。

解决:切分数据时用 stratify=labels;给损失函数加类别权重,对少数类放大梯度;评估时看混淆矩阵而不是只看总准确率。

6. 落地扩展:把四分类模型改造成自己的数据集

如果你手头有自己的数据,而不是直接用项目自带的 pickle,步骤其实不复杂,核心就是把原始数据换成和 pickle 里一样的特征格式。

特征提取按模态分开处理。文本部分,量小用预训练词向量平均,量大用 BERT 句向量,输出维度按需裁剪;语音部分,用 openSMILE 提取声学特征,拿到 CSV 再转 numpy;图片/视频部分,用预训练 CNN 去掉分类头,把每帧过一遍网络取倒数第二层输出作为该模态特征,多条视频帧可以平均成一个向量。特征到位后,用第 3 章的 split_multimodal 同步切分,再直接跑 run.py。

这里有一个我反复强调的验证方法:先用单模态特征分别训练,拿到三条基线,再打开融合。如果某一模态特征质量太差,比如语音采集噪声大,融合后反而被它拖累,这时候在 data_prep.py 里给该模态特征做归一化,或者干脆先剔除,只做双模态。

进阶技巧是用项目自带的 unimodal_iemocap_4way.pickle 训练出一个预训练权重,再在自己的数据上微调,只重新训练最后两层。做法是在 run.py 里把初始化换成加载 checkpoint,并限定可训练变量:

saver = tf.train.Saver() with tf.Session() as sess: saver.restore(sess, './pretrained/iemocap_4way.ckpt') # 只微调三模态层和分类层,前面层冻结 train_vars = [v for v in tf.trainable_variables() if 'tri_fc' in v.name or 'logits' in v.name] train_op = tf.train.AdamOptimizer(1e-4).minimize(loss, var_list=train_vars)

逻辑说明:saver.restore 加载 IEMOCAP 4way 训练出的权重,作为初始化;var_list 限定只更新 tri_fc 和 logits 两层,前面的模态编码层保持不动,避免小数据量下把预训练表示冲掉。学习率从 1e-3 降到 1e-4,微调阶段步子要小。

验证是否成功的标准很简单:项目自带的 pickle 跑出来的准确率与你自己数据的准确率放在同一张表里,融合模型高于任意单模态基线,说明多模态确实在起作用,这个结果写进论文很有说服力。

说句实在话,我在多模态项目上栽过的跟头,十有七八不在模型,而在数据对齐和版本兼容。从那以后,我每次拿到情感分析数据集,都强制自己先走一遍“读 pickle → 查维度 → 单模态基线 → 融合对比”的流程,十分钟的检查能省掉一晚上的排错。希望帮到你。

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