简介:这份资源面向计算机、人工智能、通信工程等专业的高校学生与科研人员,提供一套基于Python的多模态情感识别完整实现,将语音与文本两种模态结合,并借助大模型微调思路完成情感分类任务,可作为毕业设计、课程设计或项目立项的参考方案。压缩包共6个文件,以4个py脚本为核心,涵盖BERT与wav2vec2特征融合、训练流程及音频编码与文本分词等工具模块,另附1个md说明文档和1个txt环境配置记录,整体约12KB,结构紧凑便于快速理解工程脉络。目前已有58人学习关注。读者可从中获取多模态特征对齐与模型微调的关键代码逻辑、训练脚本组织方式以及环境依赖说明,既能直接运行验证,也可在此基础上替换数据集或调整融合策略以扩展功能,适合具备一定Python与深度学习基础的学习者进阶实践。
1. 多模态情感识别毕设:语音和文本怎么在 Python 里拧成一股绳
很多做毕设的同学一开始都会把「多模态情感识别」想简单了:语音丢给一个模型、文本丢给另一个模型,两个结果一平均,就以为是大模型 finetune。真跑起来才发现,语音里的叹气、停顿、音高变化,和文本里的反讽、否定、语气词,经常给出互相打架的信号,简单加权反而比单模态还差。这个标题要解决的核心问题,就是让语音和文本在同一个 Python 工程里被统一处理、对齐、融合,再用预训练大模型做微调,最终输出一个可解释的情感类别。它适合正在做毕设、需要一套能跑通、能写进论文、还能在答辩时讲清楚融合逻辑的本科生和研究生。下面我按自己带项目的顺序,把数据、模型、训练、避坑和进阶验证一层层拆开。
2. 先定融合架构:早期融合、晚期融合还是交叉注意力
2.1 三种融合方式在毕设里的真实取舍
多模态融合不是越复杂越好,毕设周期通常只有两三个月,选错架构会直接卡在训练不收敛上。常见做法有三类:早期融合把语音特征和文本特征在输入层拼接,实现最简单,但对齐要求极高,语音和文本长度不一致时基本没法直接拼;晚期融合各自过模型,最后在决策层加权或投票,工程上最稳,缺点是学不到跨模态交互;交叉注意力融合让文本 token 去查询语音帧、语音帧去查询文本 token,能捕捉「说到某个词时语气突然上扬」这类线索,效果通常最好,但显存和调参成本也最高。
我一般建议毕设走「文本为主、语音为辅的交叉注意力」路线:文本用预训练语言模型编码,语音用预训练语音模型抽帧级特征,再让文本表示去 attend 语音帧。这样即使语音数据量小,也不会把文本侧已经学好的语义带偏。选型理由很直接:文本情感信号密度高,语音信号稀疏但补充性强,主辅结构符合数据分布。
2.2 用 Python 搭出双塔编码器的最小骨架
下面这段代码只做一件事:把一条样本的文本和语音分别编码成定长向量,并保留语音帧序列供后续交叉注意力使用。它不涉及训练,先确保前向能跑通。
import torch import torch.nn as nn from transformers import AutoModel, AutoConfig class DualEncoder(nn.Module): def __init__(self, text_model_name, speech_input_dim=768, hidden=256): super().__init__() # 文本塔:直接用预训练语言模型,输出 last_hidden_state self.text_encoder = AutoModel.from_pretrained(text_model_name) text_dim = self.text_encoder.config.hidden_size # 语音塔:这里用线性层模拟帧级特征投影,实际可换成 Wav2Vec2/Whisper encoder self.speech_proj = nn.Sequential( nn.Linear(speech_input_dim, hidden), nn.ReLU(), nn.Linear(hidden, text_dim) # 投影到和文本同维度,方便做注意力 ) self.text_proj = nn.Linear(text_dim, text_dim) def forward(self, input_ids, attention_mask, speech_feats, speech_mask): # speech_feats: [B, T_s, D_s],speech_mask: [B, T_s] text_out = self.text_encoder(input_ids=input_ids, attention_mask=attention_mask) text_seq = self.text_proj(text_out.last_hidden_state) # [B, T_t, D] speech_seq = self.speech_proj(speech_feats) # [B, T_s, D] return text_seq, speech_seq, attention_mask, speech_mask逻辑说明:文本塔直接复用 HuggingFace 的预训练权重,省去从零训练;语音塔先用线性投影把帧级特征映射到与文本相同的维度,这是做交叉注意力的前提。参数上,speech_input_dim要和你实际提取的语音特征维度一致,比如 Wav2Vec2 base 是 768,如果用的是 MFCC 拼接统计量则可能是几十维,需要改。hidden只是中间层宽度,显存紧张就降到 128。注意speech_mask必须传进来,否则 padding 帧会参与注意力,训练时 loss 会异常震荡。
2.3 交叉注意力融合层怎么写才不翻车
有了双塔输出,融合层就是让文本序列和语音序列互相看。下面是一个单向的文本查询语音的注意力模块,比双向实现更省显存,毕设够用。
class CrossAttentionFusion(nn.Module): def __init__(self, dim=768, num_heads=8, dropout=0.1): super().__init__() self.attn = nn.MultiheadAttention(dim, num_heads, dropout=dropout, batch_first=True) self.norm = nn.LayerNorm(dim) self.dropout = nn.Dropout(dropout) def forward(self, text_seq, speech_seq, speech_mask): # query=文本, key=value=语音;speech_mask 中 True 表示有效帧 key_padding_mask = ~speech_mask.bool() # MultiheadAttention 要求 True 表示忽略 attn_out, _ = self.attn(text_seq, speech_seq, speech_seq, key_padding_mask=key_padding_mask) out = self.norm(text_seq + self.dropout(attn_out)) # 残差连接 return out逻辑说明:key_padding_mask的布尔含义容易搞反,PyTorch 里 True 表示「屏蔽掉」,所以要对有效 mask 取反。残差连接和 LayerNorm 不能省,否则深层的注意力输出会把文本原始语义冲淡。参数上,num_heads保持 8 或 12,dropout在数据量小于五千条时调到 0.2 到 0.3。如果训练时发现 attention 权重几乎均匀分布,说明语音特征没提供区分度,要回头检查语音提取环节。
3. 数据准备:语音转文本、文本预处理和特征对齐
3.1 语音转文本不是必须,但强烈建议做
很多毕设数据集只给音频和标签,没有转写文本。这时候有两条路:一是用离线语音识别模型先转出文本,再走文本塔;二是直接拿语音帧特征当「语音模态」,文本模态用数据集自带的转写。如果数据集本身没有文本,我建议至少用离线语音包跑一遍转写,因为纯语音情感识别在毕设里很难讲出多模态的故事。转写时注意保留时间戳,后面做帧级对齐会用到。
# 以 transformers 的自动语音识别 pipeline 为例,离线加载本地模型 from transformers import pipeline asr = pipeline("automatic-speech-recognition", model="你的本地ASR模型路径", device=0) result = asr("sample.wav", return_timestamps="word") print(result["text"]) print(result["chunks"][:3]) # 每个词的时间戳逻辑说明:return_timestamps="word"会返回词级时间戳,这是后续把文本 token 和语音帧对齐的关键。参数上,device=0表示用第一块 GPU,没有 GPU 就删掉。注意离线模型路径要提前下载好,不要依赖运行时联网。转写文本里会有标点和大小写不一致,下一步文本预处理要统一。
3.2 文本预处理:别把情感词洗没了
文本预处理常见做法是去停用词、去标点、转小写。但在情感识别里,否定词、程度副词、感叹号本身就是强信号,洗得太狠反而掉点。我一般只做三件事:统一全角半角、把连续重复标点压缩成一个、保留否定词和程度词。分词直接用预训练模型对应的 tokenizer,不要自己用 jieba 切完再喂给 BERT,那样会破坏子词边界。
import re from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese") def clean_text(text): text = text.strip() text = re.sub(r"[!!]{2,}", "!", text) # 连续感叹号压缩 text = re.sub(r"[??]{2,}", "?", text) text = re.sub(r"\s+", " ", text) return text def encode_text(text, max_len=128): text = clean_text(text) enc = tokenizer(text, padding="max_length", truncation=True, max_length=max_len, return_tensors="pt") return enc["input_ids"], enc["attention_mask"]逻辑说明:clean_text只做轻量归一化,不删除任何实词。max_length=128对大多数情感短句够用,如果数据集里长评论多,可以加到 256,但显存会线性增长。注意padding="max_length"在训练时方便批量拼接,推理时可以改成longest。
3.3 语音特征提取与帧级对齐的实操参数
语音侧我一般用预训练语音模型抽帧级特征,而不是手工 MFCC。手工特征在跨说话人场景下方差很大,预训练模型鲁棒性好得多。提取时统一采样率到 16kHz,帧移 20ms,这样一秒有 50 帧。文本侧一个 token 大约对应几百毫秒,所以对齐时通常把语音帧下采样到和文本 token 数量接近,或者直接用注意力让模型自己学对齐,不强行一一对应。
| 参数 | 常用值 | 说明 |
|---|---|---|
| 采样率 | 16000 Hz | 预训练语音模型标准输入 |
| 帧移 | 20 ms | 决定帧率,50 帧/秒 |
| 最大音频时长 | 10 s | 超长截断,避免显存爆炸 |
| 语音特征维度 | 768 | 与文本塔 hidden 对齐 |
| 文本最大长度 | 128 | 覆盖绝大多数情感句 |
提示:如果音频超过 10 秒,不要直接截断,先按静音段切分,取能量最高的片段,否则可能把关键情感句切掉。
4. 大模型 finetune:从冻结到解冻的训练策略
4.1 为什么先冻结再解冻
毕设数据量通常几千条,直接全量微调预训练大模型会过拟合,验证集 loss 先降后升。我一般分两阶段:第一阶段冻结文本塔和语音塔,只训练融合层和分类头,学习率设 1e-3;第二阶段解冻顶部两到四层 Transformer,学习率降到 1e-5 到 2e-5。这样既能让融合层先学会对齐,又不会一上来就把预训练语义破坏掉。
def set_freeze(model, freeze=True): # 冻结文本塔和语音塔主干 for name, param in model.text_encoder.named_parameters(): param.requires_grad = not freeze for param in model.speech_proj.parameters(): param.requires_grad = not freeze # 第一阶段 set_freeze(model, freeze=True) optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3) # 第二阶段:解冻文本塔顶部 4 层 set_freeze(model, freeze=True) for layer in model.text_encoder.encoder.layer[-4:]: for param in layer.parameters(): param.requires_grad = True optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=2e-5)逻辑说明:filter(lambda p: p.requires_grad, ...)确保优化器只更新需要梯度的参数,避免冻结参数被 weight decay 影响。第二阶段学习率必须显著降低,否则预训练权重会被小数据集带偏。如果显存不够,可以只解冻最后两层。
4.2 分类头和损失函数的选择
融合后的文本序列取[CLS]位置或做平均池化,接一个线性分类头。情感类别通常是三分类或四分类,用交叉熵就够。如果类别不平衡,加类别权重。不要用 Focal Loss 当默认,除非你确认难易样本分布极端,否则调参成本很高。
class EmotionClassifier(nn.Module): def __init__(self, dim=768, num_classes=4, dropout=0.3): super().__init__() self.dropout = nn.Dropout(dropout) self.classifier = nn.Linear(dim, num_classes) def forward(self, fused_text_seq, text_mask): # 用 mask 做平均池化,避免 padding 影响 mask = text_mask.unsqueeze(-1).float() pooled = (fused_text_seq * mask).sum(1) / mask.sum(1).clamp(min=1e-6) return self.classifier(self.dropout(pooled))逻辑说明:平均池化比直接取[CLS]更稳,因为融合后的[CLS]可能被语音注意力稀释。dropout=0.3在小数据集上是常规操作。clamp(min=1e-6)防止除零。
4.3 训练循环里必须监控的三个量
训练时不要只看 loss。我一般同时打印训练 loss、验证集加权 F1、以及语音注意力权重的熵。如果注意力熵接近均匀分布的最大值,说明语音模态没被用起来,要检查语音特征是否被正确 mask,或者语音塔学习率是否太低。验证 F1 连续三轮不升就早停,毕设没必要死磕。
from sklearn.metrics import f1_score def evaluate(model, dataloader, device): model.eval() preds, labels = [], [] with torch.no_grad(): for batch in dataloader: batch = {k: v.to(device) for k, v in batch.items()} logits = model(**batch) preds.extend(logits.argmax(-1).cpu().tolist()) labels.extend(batch["labels"].cpu().tolist()) return f1_score(labels, preds, average="weighted")逻辑说明:average="weighted"按类别样本数加权,比 macro 更能反映整体表现。如果类别极不平衡,再补看 macro F1。
5. 避坑与排查:多模态情感识别毕设里最容易翻车的五件事
5.1 语音和文本标签对不上
现象:训练 loss 正常下降,但验证集准确率始终在随机水平附近。原因:音频文件和文本转写来自不同样本,或者数据清洗时打乱了顺序。解决:在 Dataset 里用同一个样本 ID 同时索引音频路径和文本,并在__getitem__里断言两者标签一致,不一致直接抛异常。
5.2 注意力 mask 方向搞反
现象:模型对短音频表现正常,对长音频(padding 多)预测全部偏向某一类。原因:key_padding_mask的 True/False 含义记反,导致有效帧被屏蔽、padding 帧参与注意力。解决:打印一次 mask 的sum(),确认有效帧数量与音频实际帧数一致,再检查~speech_mask.bool()是否写对。
5.3 解冻后学习率没降
现象:第二阶段一开始验证 loss 剧烈上升,几个 step 后模型完全崩溃。原因:解冻预训练层后仍沿用第一阶段 1e-3 的学习率。解决:解冻时新建优化器,学习率设为 1e-5 到 2e-5,并加 warmup,前 10% step 线性升温。
5.4 语音特征提取时采样率不一致
现象:同一句话在不同机器上提取的特征差异很大,模型换环境就掉点。原因:有的音频 44.1kHz,有的 16kHz,重采样库默认参数不同。解决:在 Dataset 初始化时统一用torchaudio或librosa重采样到 16kHz,并固定res_type,把重采样后的音频缓存到磁盘,避免每次训练重复计算。
5.5 验证集参与调参导致过拟合
现象:论文里报告验证集 F1 很高,换一批测试数据掉十几个点。原因:反复用验证集调超参,验证集信息泄漏。解决:从训练集里再切一个小验证集用于调参,原始验证集只在最后评估一次,测试集全程不碰。
6. 进阶验证:用消融和注意力可视化证明融合真的有用
毕设答辩时老师最常问的一句话是:「你怎么证明语音模态起了作用?」光报一个总体 F1 不够,要做消融。我一般跑三组:纯文本、纯语音、多模态融合,在同一个测试集上对比。如果多模态比纯文本高 2 到 5 个点,且纯语音明显低,说明融合有效。如果多模态和纯文本差不多,就要检查语音分支是不是被模型忽略了。
# 消融实验:把语音输入置零,观察性能变化 def ablate_speech(model, batch, device): batch = {k: v.to(device) for k, v in batch.items()} batch["speech_feats"] = torch.zeros_like(batch["speech_feats"]) batch["speech_mask"] = torch.zeros_like(batch["speech_mask"]) with torch.no_grad(): logits = model(**batch) return logits.argmax(-1)逻辑说明:把语音特征和 mask 全部置零,相当于关掉语音分支。对比正常前向和消融后的预测差异,差异越大说明语音贡献越大。注意 mask 也要置零,否则全零特征仍可能被注意力选中。
另一个技巧是导出交叉注意力权重,看文本里哪些词对语音帧的注意力高。如果「但是」「居然」这类转折词对应高注意力,说明模型学到了跨模态线索,这个图放进论文里很有说服力。我自己的习惯是每做完一个多模态项目,先跑消融再写结论,没有消融数据的提升一律不信。希望帮到你。
本文还有配套的精品资源,点击获取