ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Jupyter的多模态情感分析从零实现:文本图像音频融合

基于Jupyter的多模态情感分析从零实现:文本图像音频融合 简介面向毕业设计、课程设计与项目开发的多模态情感分析模型实现包基于Jupyter与Python构建覆盖从数据准备、模型构建到情感预测的完整流程适合有一定Python基础并希望快速落地相关课题的本科生和开发者。压缩包共含2000个文件其中1997个为文本数据及预测结果文件可用于直接验证模型效果另含2个Markdown项目文档用于说明项目结构与实验报告1个Python脚本封装核心实现整体大小约202.13MB目录层次分明便于按需查阅源码与文档。目前已有523人学习下载。资源内含经过严格测试的源码、项目文档与可用模型并配有大量测试文本与输出示例能够帮助读者快速掌握多模态情感分析的实施思路同时预留扩展空间方便在此框架上做进一步调优与功能增强。1. 多模态情感分析到底在分析什么一个 Jupyter 里就能跑通的项目骨架如果你是带着“毕业设计/课程设计需要一个能跑通的多模态情感分析模型”这个诉求点进来的我先把话说透基于 Jupyter Python 实现多模态情感分析模型不是要你把 Transformer、CLIP、语音识别全部从零造一遍而是把文本、图像、音频三路特征对齐到同一条样本上用一个可解释的融合模型做情感分类。它解决的是“单看文本不够单看图片也不够”的判断场景比如吐槽帖配上表情包、视频评论带语气适合想在 1 到 2 周内完成课程项目、又想真正讲清楚每个模块的人。实际拆开看多模态情感分析的骨架只有四层数据准备、特征提取、模态融合、分类训练。Jupyter Notebook 适合做前三层的快速迭代因为你每一步都能看到张量形状和中间输出不用反复写 print 调试。项目最终交付时再把关键步骤整理成源码、项目文档和模型权重三件套。这篇文章按我自己的实现习惯来写先立任务再给可复现代码最后讲清楚参数和踩坑点。2. 从数据到监督信号先想清楚标签、对齐和切分再碰模型2.1 多模态情感分析的任务定义分类、回归还是多标签网上搜“多模态情感分析”经常混着三种任务情感极性二分类、三分类positive/negative/neutral、多维情绪分类happy/sad/anger/fear/surprise/disgust还有 MOSI 这类数据集的回归任务得分从 -3 到 3。课程设计最常用的是三分类因为可解释性最强画混淆矩阵也直观。我一般拿到需求后会先确认一件事老师要的是“情感极性”还是“情绪类别”。如果题目写的是“情感分析”默认做 positive/negative/neutral 三分类如果写“情绪识别”才考虑七分类或八分类。这两个任务虽然代码结构完全一样但标签体系不同直接决定最后报告里的结论怎么写。别等到训练完才发现标签定义错了那是最冤枉的翻车方式。另外还要区分“多模态情感分析”和“多模态特征拼接”。有些做法是把文本、图像特征拼一起丢给 SVM这也能叫多模态但如果你想在项目和文档里体现建模深度最好做成“模态内编码 模态间融合”的结构。后面第四章会给出可跑的 PyTorch 实现先在这里把任务和监督信号定清楚。2.2 数据从哪来开源数据集、自制数据集和文件目录设计常见做法是优先用公开多模态情感数据集比如 CMU-MOSI、CMU-MOSEI、MELD、IEMOCAP。这类数据已经做好了文本、视频/图像片段、音频和标签的切分适合课程设计直接做训练。但要注意有些数据集是英文对话如果老师要求中文场景你就得自己准备中文舆情数据或者把英文数据换成中文评论加配图。自制数据时目录结构比文件名重要。我建议一个样本一个文件夹里面放 text.txt、image.jpg、audio.wav、label.txt。这样做的好处是对齐逻辑简单缺模态时容易排查后续做滑动窗口扩样也方便。下面这段脚本负责扫描目录并生成训练清单# build_manifest.py # 扫描 data/train 下的样本目录生成 train.csv import os import csv root data/train rows [] for sample in sorted(os.listdir(root)): sp os.path.join(root, sample) text_path os.path.join(sp, text.txt) image_path os.path.join(sp, image.jpg) audio_path os.path.join(sp, audio.wav) label_path os.path.join(sp, label.txt) # 至少存在文本或图像否则跳过音频允许缺失 if not (os.path.exists(text_path) or os.path.exists(image_path)): continue if not os.path.exists(label_path): continue with open(label_path, r, encodingutf-8) as f: label f.read().strip() rows.append([sample, text_path, image_path, audio_path, label]) with open(train.csv, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([id, text, image, audio, label]) writer.writerows(rows) print(f生成 {len(rows)} 条样本)这段脚本的逻辑很直接文件名写死扫描每个样本目录缺失的模态用空路径表示。关键点是只检查文本或图像是否存在因为有些样本本来就没有语音如果把 audio 设为必填训练前就会少掉大量数据。csv 里的路径用相对路径方便项目换机器后不用改代码。2.3 文本、图像、语音三种模态的对齐与标签设计多模态项目最容易翻车的地方不是模型而是对齐。文本是一句评论图像是一张封面语音是一段 5 秒的 wav怎么变成一行训练数据我的习惯是“以文本为主时间轴”一条样本对应一条文本图像是文本发布时的配图语音是文本对应视频片段或对话片段的声音。对不上时宁可直接丢弃也不要硬拼否则模型会学到“跨模态错位”这种假特征。如果做的是视频级数据还需要按句子边界抽帧和切音频。常见做法是先用语音识别工具把视频转成带时间戳的文本然后根据每个句子的起止时间抽中间帧做图像特征截取同一时间段的音频做语音特征。这里有个容易忽略的细节抽帧不要只抽一帧最好取时间段内 3 到 5 帧的平均特征能显著降低表情和光线突变带来的噪声。音频也类似把整段 mel 谱图切成长度一致的片段不够的用 0 补齐。标签设计也要对应模态。文本里“虽然环境差但服务很好”是典型的多情感混合句硬标成 positive 会引入噪声。这时候可以把标签放宽成 coarse-grained 三分类或者做成多标签服务好评、环境差评后者在报告中更容易展示模型能力。数据切分上尽量按样本 id 或视频 id 分组避免同一视频的相邻帧同时出现在训练集和验证集里造成数据泄漏。3. 用 Jupyter Notebook 搭出多模态特征提取管线文本向量、图像向量、音频向量3.1 先修好环境Jupyter kernel 与项目依赖不要直接在 base 环境里 pip install 一堆包后面会踩环境冲突的坑。我习惯用 conda 建独立环境再把这个环境注册成 Jupyter kernel。这样 notebook 左上角可以切换解释器不同项目互不干扰。# 在终端里执行 conda create -n multimodal python3.10 -y conda activate multimodal pip install jupyterlab torch transformers librosa scikit-learn pandas pillow python -m ipykernel install --user --name multimodal --display-name Python (multimodal)这四条命令做的事情分别是创建 Python 3.10 环境激活环境安装 Jupyter、PyTorch、Hugging Face transformers、音频处理库 librosa 和常用数据处理库最后把当前环境注册为名为 “Python (multimodal)” 的 Jupyter kernel。如果你已经在 Jupyter 里打开了 notebook需要刷新页面后才能在 Kernel 菜单里看到新环境。这里顺便说一个让人抓狂的问题在 Windows 上Jupyter 默认保存路径可能是用户目录下的一长串路径导致你在指定项目目录里创建不了 notebook报PermissionError: [Errno 13] Permission denied。解决方法是先jupyter notebook --generate-config然后在生成的jupyter_notebook_config.py里找NotebookApp.notebook_dir改成你自己的项目目录再重启 Jupyter。这个坑不是代码问题是工作路径问题越早改越好。3.2 文本特征用预训练模型把句子变成向量文本模态我直接用 Transformers 库里的中文预训练模型做编码器。短文本用bert-base-chinese就够长文本比如整段评论或者新闻正文用 Longformer 中文模型这类长文本模型。课程设计里不需要自己训练语言模型拿预训练模型做特征提取器把句子转成固定维度向量。# text_encoder.py # 使用预训练模型将文本编码为句向量 from transformers import AutoTokenizer, AutoModel import torch model_name bert-base-chinese # 中短文本长文本可换 Longformer 中文模型 tokenizer AutoTokenizer.from_pretrained(model_name) encoder AutoModel.from_pretrained(model_name) encoder.eval() def embed_text(text: str, max_len: int 128) - torch.Tensor: inputs tokenizer( text, max_lengthmax_len, truncationTrue, paddingmax_length, return_tensorspt ) with torch.no_grad(): outputs encoder(**inputs) # 取 [CLS] 向量作为句向量 cls_vec outputs.last_hidden_state[:, 0, :] return cls_vec这里的关键参数是max_len128。BERT 系列模型对超过 512 的文本效果会明显下降所以要么截断要么按句切分后做平均。paddingmax_length表示短句补 0 到固定长度这么做是为了 batch 训练时矩阵形状一致。最后取last_hidden_state[:, 0, :]是取 [CLS] token 的输出它会被预训练任务训练成一个能代表整句语义的向量。如果你希望句向量更稳定也可以把整句所有 token 输出做平均池化两种都可以但不要混用。还需要注意特征提取阶段要放在训练循环外。也就是先把所有文本一次性转成.npy文件训练时直接加载而不是每个 batch 都重新过一遍 BERT。这样做一方面省时间另一方面防止反向传播误伤预训练参数。默认预训练模型是冻结的如果你后面想微调再把encoder的requires_grad打开但那样显存占用会明显上涨。3.3 图像和音频特征ResNet 编码器与 MFCC 滑动窗口图像特征没必要一上来就上最重的模型。CLIP 的 image encoder 效果确实好但如果你没有 GPUCPU 上跑一张图要几百毫秒整个数据集会非常慢。我做课程设计时常用 ResNet18 或者更轻量的分类网络去掉最后的全连接层把 2048 维特征作为图像向量。# image_encoder.py # 用 ResNet18 提取图像特征输出 512 维向量 from torchvision import transforms, models from PIL import Image import torch img_encoder models.resnet18(pretrainedTrue) img_encoder.fc torch.nn.Identity() # 去掉分类头输出特征 img_encoder.eval() transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) def embed_image(path: str) - torch.Tensor: img Image.open(path).convert(RGB) x transform(img).unsqueeze(0) with torch.no_grad(): feat img_encoder(x) return feat.squeeze(0) # 512 维img_encoder.fc torch.nn.Identity()是这段代码的核心它让模型只输出卷积层提取的特征不再做类别预测。Resize 到 224x224 是为了适配 ImageNet 预训练时输入尺寸normalize 用的均值和标准差也是 ImageNet 统计值切换成别的主干网络时不要改错。音频特征我用 librosa 计算 MFCC。MFCC 是按帧计算的一段音频会得到时间帧维度最后要压成一个固定长度向量。常见手法是把所有帧的 MFCC 做 mean pooling或者用滑动窗口把每段音频切成固定帧数。# audio_encoder.py # 提取音频 MFCC 特征并池化为定长向量 import librosa import numpy as np def embed_audio(path: str, sr: int 16000, n_mfcc: int 40) - np.ndarray: y, _ librosa.load(path, srsr, monoTrue) mfcc librosa.feature.mfcc(yy, srsr, n_mfccn_mfcc) # shape: (40, T) # 沿时间轴做均值池化 feat mfcc.mean(axis1) return feat.astype(np.float32)n_mfcc40是常见取值多取几阶能保留更多音色信息但超过 40 后提升有限。sr16000是语音任务常用采样率如果你的数据集是 44.1kHz 的音乐建议先重采样到 16k否则高频信息对情感判断反而可能是噪声。均值池化会把“惊讶”“愤怒”这类随时间变化的情绪磨平如果你后续想加强时序建模可以改成保留时间帧维度再丢给 LSTM 或 Transformer那是进阶路线了。4. 把三路特征变成情感判断融合结构、损失函数与关键训练参数4.1 早融合还是晚融合先跑通 concatMLP再考虑注意力多模态融合的经典问题只有一个什么时候把三路特征合并。早融合early fusion是把文本、图像、音频向量直接拼接再丢给分类器晚融合late fusion是每个模态先单独分类再把三个概率结果投票或加权。课程设计我更推荐早融合的变体每个模态先过一个全连接层做维度统一再拼接这样既简单又能让模型学到模态间的相关性。# multimodal_model.py # 文本(768) 图像(512) 音频(40) - 融合 - 三分类 import torch import torch.nn as nn class MultimodalFusion(nn.Module): def __init__(self, text_dim768, image_dim512, audio_dim40, num_classes3): super().__init__() self.text_proj nn.Linear(text_dim, 128) self.image_proj nn.Linear(image_dim, 128) self.audio_proj nn.Linear(audio_dim, 64) self.fusion nn.Sequential( nn.Linear(128 128 64, 64), nn.ReLU(), nn.Dropout(0.3), nn.Linear(64, num_classes) ) def forward(self, text_feat, image_feat, audio_feat): t torch.relu(self.text_proj(text_feat)) i torch.relu(self.image_proj(image_feat)) a torch.relu(self.audio_proj(audio_feat)) cat torch.cat([t, i, a], dim1) return self.fusion(cat)这个模型的设计逻辑很直白三路特征维度不同直接从 768/512/40 拼到一起会让全连接层难学所以先各自映射到 128/128/64 维度再做 concat。后面的 MLP 用一层 64 维隐藏层加 Dropout 防止过拟合。别小看这个结构它能跑赢很多花哨的跨模态注意力基线而且答辩时容易解释。如果你追求更高的融合质量可以在此基础上加一个 cross-attention 层让文本特征去 query 图像特征。但那是把双刃剑数据量不够时 attention 学不好反而掉点。我的原则是先把这个基础结构跑通记录指标再决定要不要升级。没有 baseline 就直接上复杂模型后面很难定位问题。4.2 训练循环、损失函数与优化器150 行内完成闭环训练代码只需要一个标准 PyTorch 循环。重点在于三个参数设置数据加载时要把文本、图像、音频特征都转成torch.Tensor损失函数用CrossEntropyLoss如果类别不均衡就传weight优化器用AdamW学习率先给2e-4比直接用 Adam 更稳。# train.py 核心训练循环 import torch from torch.utils.data import DataLoader, TensorDataset # 假设 X_text, X_image, X_audio, y 是已经提取并归一化的 numpy 数组 X_text torch.tensor(X_text, dtypetorch.float32) X_image torch.tensor(X_image, dtypetorch.float32) X_audio torch.tensor(X_audio, dtypetorch.float32) y torch.tensor(y, dtypetorch.long) dataset TensorDataset(X_text, X_image, X_audio, y) loader DataLoader(dataset, batch_size32, shuffleTrue) model MultimodalFusion() # 类别权重按样本数的反比计算 counts torch.bincount(y) class_weight 1.0 / counts.float() class_weight class_weight / class_weight.sum() * len(counts) criterion torch.nn.CrossEntropyLoss(weightclass_weight) optimizer torch.optim.AdamW(model.parameters(), lr2e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max20) for epoch in range(20): model.train() total_loss 0.0 for text_feat, image_feat, audio_feat, label in loader: optimizer.zero_grad() logits model(text_feat, image_feat, audio_feat) loss criterion(logits, label) loss.backward() optimizer.step() total_loss loss.item() scheduler.step() print(fepoch {epoch1}: loss {total_loss / len(loader):.4f})这里两个关键调参点batch_size32适合普通 8GB 显卡如果 OOM 就降到 16 或 8class_weight会放大少数类的损失让模型不至于只预测多数类。CosineAnnealingLR的T_max20要和总 epoch 数一致表示在 20 个 epoch 内把学习率从初始值降到接近 0这个策略比固定学习率好用。训练前还有一个容易忽略的步骤对三路特征分别做标准化。我会用sklearn.preprocessing.StandardScaler分别 fit 文本、图像、音频特征再存成 numpy 数组。这个步骤能防止图像特征值域0~10 左右压过文本特征值域可能几百导致融合层梯度被某一维主导。标准化参数只在训练集上 fit验证集用同一套参数 transform不能混在一起 fit否则又算是小规模数据泄漏。4.3 显存和速度瓶颈先把特征缓存到磁盘如果你直接在每个 epoch 里重新调用 BERT、ResNet 和 librosa训练会非常慢。我第一次就是这么干的一个 5000 条的数据集跑一个 epoch 要 40 分钟后来把特征缓存到磁盘训练时间缩到 30 秒。这才是多模态项目在 Jupyter 里能迭代起来的真正关键。# cache_features.py # 把三个模态特征预计算并保存为 .npz import numpy as np N len(ids) text_feats, image_feats, audio_feats, labels [], [], [], [] for i, sample_id in enumerate(ids): text_feats.append(embed_text(texts[i])) image_feats.append(embed_image(image_paths[i])) audio_feats.append(embed_audio(audio_paths[i])) labels.append(labels[i]) np.savez_compressed(features/train.npz, text_featsnp.array(text_feats), image_featsnp.array(image_feats), audio_featsnp.array(audio_feats), labelsnp.array(labels))缓存之后训练脚本里只需要np.load(features/train.npz)就能拿到全部特征。如果你的图像和文本模态缺失这里需要在循环里填充全零向量。全零向量会在后续融合层被映射到接近零的向量相当于让模型忽略这个模态。这是处理缺失模态最简单的方式比直接删样本要稳。5. 多模态情感分析最常见的 5 个坑现象、原因、解决办法5.1 Jupyter kernel 报 ModuleNotFoundError或者保存文件报 PermissionError现象是在 notebook 里执行import torch报 ModuleNotFoundError但终端里明明已经装好了。另一个更隐蔽的报错是PermissionError: [Errno 13] Permission denied打开或保存 notebook 时突然卡住。原因通常是 kernel 指向的 Python 环境不对。很多人在终端pip install torch但 Jupyter 默认 kernel 用的是 base 或另一个环境install 和环境不一致。PermissionError 则多半是 Windows 上默认 notebook 目录在系统保护路径或者项目目录没有写权限。解决方法是回到终端执行python -m ipykernel install --user --name multimodal然后在 Jupyter 里通过 Kernel 菜单切换解释器。PermissionError 则执行jupyter notebook --generate-config打开生成的配置文件找到c.NotebookApp.notebook_dir改成你有读写权限的项目目录再重启 Jupyter。这个坑看起来低级但几乎每个多模态项目都会遇到一次。5.2 文本和图像数量对不齐dataloader 在中间某一步崩溃现象是训练前 20 个 batch 都很正常第 30 个 batch 报错说 tensor 维度对不上或者list index out of range。检查数据后才发现有的样本缺少图像路径有的音频文件损坏。原因是数据清洗阶段只统计了文件存不存在没有对齐三个模态的索引顺序。尤其是用glob或者os.listdir扫描文件时文件顺序可能不一致直接zip到一起就会错位。解决办法是建立一个统一的 DataFrame 或 CSV 作为唯一数据源行是样本 id列是各模态路径和标签。任何模态缺失时填充空值而不是跳过这一行。在 Dataset 的__getitem__里判断路径是否存在不存在就用全零向量占位。这样 dataloader 的索引顺序完全由 DataFrame 保证不会再出现“某一行比另一行少一个样本”的问题。5.3 三路特征量纲不一致loss 掉到 0.01 就不动了现象是训练时 loss 降得很快但到 0.01 左右再也不降验证集准确率只有 50%和随机猜测差不多。去看融合层权重发现图像那一维的梯度几乎可以忽略。原因是图像特征经过 ResNet 后数值范围可能在 0 到几十而文本 BERT 的 [CLS] 向量可能几千两者 concat 后全连接层会被数值大的维度主导小数值模态的信息被淹没。模型看起来在训练实际上只用了文本这一路。解决办法是在特征缓存之前做标准化每个模态各自用 StandardScaler 归一化到均值为 0、方差为 1。如果做了标准化还不行就给每个模态的投影层输出后接 LayerNorm。我这里通常两件事都做先标准化再在融合层前面加nn.LayerNorm(12812864)。这样能保证每一路特征对梯度的贡献在同一个数量级。5.4 类别不平衡模型把所有样本都预测成 dominant class现象是准确率看着有 70%但看混淆矩阵模型几乎只在预测多数类少数类全错。如果拿这个模型去答辩老师一眼就能看出问题。原因是直接用了默认的CrossEntropyLoss没有设置类别权重。当 negative 样本占 80% 时模型只要全预测 negative 就能拿到很低的 loss根本不需要学习其他类别的特征。解决办法是像第四章那样计算class_weight 1 / class_freq传入损失函数。另一个更直接的做法是评估时用 macro F1 而不是 accuracy因为 macro F1 对少数类的表现更敏感。如果你有精力还可以对少数类做简单过采样复制几份样本进训练集但不要复制太多容易过拟合。一般课程设计里 class weight macro F1 已经足够。5.5 数据泄漏同一视频样本同时出现在训练集和验证集现象是训练集准确率 95%验证集准确率 90%你觉得项目做得很好但到现场演示时随便拿一个新视频预测效果立刻跌到 60%。这通常不是模型过拟合而是数据切分出了问题。原因是你按“文件行数”随机切分但很多多模态数据集里同一条视频的多句话、多个帧都各自成样本。这些样本内容高度相似随机切分后验证集里很可能存在和训练集几乎一样的样本导致验证指标虚高。解决办法是按视频 id 或样本来源 id 做 group split。也就是先把所有样本按视频分组再把整组划入训练、验证或测试保证同一个视频的内容不会同时出现在两边。这里我一般用sklearn.model_selection.GroupShuffleSplit参数里传groupvideo_ids。这个坑隐蔽性很强最可怕的是它会让你的报告完全失真所以数据切分一定要写在项目文档里让评审能看到你做了分组。6. 让模型真的能交差评估指标、模型保存和演示的最后一公里6.1 用 macro F1 和混淆矩阵做最终验证训练完成后别只看准确率。多模态情感分析里类别不均衡几乎是常态macro F1 能更公平地反映每个类别的表现。我会在测试集上计算三个类的 precision、recall、F1然后把混淆矩阵画出来存成confusion_matrix.png。这张图放到课程设计报告里比一段文字说明有力得多。评估代码很简单用 sklearn 的两行函数就能出结果关键是确保评估时用的标准化参数和训练时一致不能重新 fit。6.2 把源码、项目文档和模型三件套整理清楚交付项目时Jupyter Notebook 可以当作过程展示但老师或评审更希望看到一个清晰的项目结构。我的一般做法是src/放特征提取和训练脚本notebooks/放展示用 notebookmodels/放训练好的权重和训练日志docs/放项目文档。README 里写清楚三件事环境安装命令、复现训练步骤、模型指标。不要只丢一个.ipynb那会让评审觉得你没有工程化意识。模型权重保存时至少保存model.state_dict()和当时的num_classes、fusion结构方便以后加载预测。6.3 最后一个技巧在 Jupyter 里做一个交互式演示答辩或演示时与其现场写代码不如在 notebook 里放一个ipywidgets表单输入文本上传图片和音频文件点击按钮输出预测概率。这个演示只要写几十行代码但给老师的印象会非常不一样。我的经验是把预测函数封装成predict(text_path, image_path, audio_path)内部依次调用文本、图像、音频的特征提取和模型 forward最后返回三个类别的概率。输出时用条形图画出概率分布比只打印一个标签直观得多。我自己做多模态项目时最后悔的一件事就是前期没把特征缓存和分组切分的代码固定成独立脚本导致后面为了调参数反复重新提取特征浪费了大量时间。现在我会先把流程跑通再回头补缓存和日志这样每一步都有后悔药可以吃。希望你做完这个项目后也能在 Jupyter 里把每一步看得清清楚楚答辩时不只是“模型跑通了”而是能把每个参数为什么这么设讲明白。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进