
简介面向希望学习多模态情感识别与大模型微调的开发者、在校学生及毕业生这套基于Python实现的项目包整合了语音与文本两条模态的情感识别方案。项目围绕IEMOCAP数据集、BERT-base-uncased与wav2vec2-xls-r-300m模型展开提供数据预处理、模型定义、训练调用等核心Python脚本并附带完整环境配置清单与README说明便于从零复现实验流程。整个压缩包体积仅14KB共8个文件其中以4个Python脚本为主用于数据处理、模型构建与训练同时包含txt环境说明、Markdown文档等虽小巧但结构完整。目前已有949人学习浏览适合作为毕业设计、课程设计或工程实训的参考项目。读者可借助它理解多模态融合中文本编码与语音编码的协同方式熟悉finetune流程并利用提供的环境配置单与README快速排查依赖问题降低入门门槛压缩包虽小但代码层次清晰可在此基础上替换数据集或调整模型参数进行扩展尝试。1. 为什么语音文本的情感识别比单模态更像一个工程问题做客服质检或智能助手时你很快会撞上一个事实光听语音判断情绪准确率做到 70% 就到头了光看文本也差不多。但把语音和文本一起喂给模型情绪识别的 F1 能明显再跳一截。原因很直接——语音里有语气、停顿、语速文本里有语义和关键词它们互补。但这件事真正难的地方在于数据怎么配对、特征在什么层级融合、以及大模型怎么 finetune 才能同时吃下两类输入。这篇文章就是把我自己跑通的这套方案掰开讲依赖装什么、代码怎么写、参数怎么调、哪些坑是白踩过的。适合想动手做多模态情感识别、而不是只看论文的 Python 工程师。2. 多模态数据准备从音频到带时间戳的文本先跑通第一条流水线2.1 依赖安装与离线语音识别的选择whisper 是当前最优解做语音 文本的多模态情感识别第一步不是搭模型而是把音频转成文本还得拿到时间戳。没有时间戳后面语音特征和文本特征根本对不上。先明确依赖项。Python 版本建议 3.9 以上核心包有这四个pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118 pip install openai-whisper numpy pandas librosa soundfile pip install transformers peft datasets accelerate解释一下这几个包各自干什么用torchaudio负责加载音频和处理波形openai-whisper做语音识别并返回时间戳librosa用于音频特征分析和重采样transformers和peft留到第 4 章微调用。先装 openai-whisper 就能跑通转录不必急于全装完。注意whisper 的模型分 tiny / base / small / medium / large 几档中文场景下用small即可获得可接受的中英混合识别效果large-v3资源占用太高训练机上跑容易爆显存。2.2 音频裁剪与文本对齐解决“谁在什么时候说了什么”情感识别面对的音频通常是一整段客服对话或一条语音留言长度 30 秒到几分钟不等。常见做法是先做 VAD语音活动检测或直接按静音切段再用 whisper 对每一段做转录。这里我直接用 whisper 的return_timestampsTrue它会返回每个句子的起始和结束时间省去手动切分的麻烦。import whisper import pandas as pd model whisper.load_model(small, devicecuda) audio_path call_0001.wav result model.transcribe(audio_path, languagezh, return_timestampsTrue) segments [] for seg in result[segments]: segments.append({ start: seg[start], end: seg[end], text: seg[text].strip(), }) df pd.DataFrame(segments) df.to_csv(call_0001_segments.csv, indexFalse) print(df.head())这段代码的逻辑加载 whisper 模型后用transcribe返回带时间戳的分段结果每个分段包含start、end、text三个字段存成 CSV 供后续读取。参数里languagezh强制按中文识别能避免 whisper 在短音频上自动检测语言时误判为英文。fp16False这个参数在 CPU 下必须加否则 half 精度计算会直接报错在 GPU 上则默认保持 fp16 以节省显存。拿到 CSV 后还要再做一次清洗。whisper 的文本里有大量空白、标点残留以及无实际语义的“嗯”“啊”、“那个”这类口头禅。情感识别对停用词很敏感所以清洗规则有三条去头尾空格并统一全角半角删除纯语气词和重复字符如“哈哈哈哈哈”压缩为“哈哈”匹配不到时间戳的空行直接丢弃。清洗后最好把每段音频切到对应时间区间以 16kHz 单声道 WAV 格式保存——这是下一步特征提取的标准输入格式。2.3 数据增强的边界变速、加噪、混响怎么影响标签多模态训练里数据增强是个双刃剑。语音端常见的增强手段包括音调微调、速度扰动、加背景噪声、加混响文本端则是同义词替换、随机 mask。但增强必须控制在不会改变情绪标签的范围内。比如把语速加快 20%原本“愤怒”的语音听起来可能只是“急促”标签就错了。import librosa import soundfile as sf import numpy as np def light_augment(input_path, output_path, speed_rate1.05, noise_scale0.005): y, sr librosa.load(input_path, sr16000, monoTrue) y_aug librosa.effects.time_stretch(y, ratespeed_rate) noise np.random.randn(len(y_aug)) * noise_scale y_aug y_aug noise sf.write(output_path, y_aug, samplerate16000)这段代码做了两种轻量增强time_stretch以 1.05 倍速率拉伸音频noise_scale0.005表示加入幅度为 0.005 的高斯噪声。为什么幅度设这么小因为客服录音本身的信噪比不高噪声加多了会直接改变情感的可辨性。我一般只在训练集的 30% 以内做增强且增强后的样本仍然保留原始标签不做二次标注——人工再标注的成本太高增强过度反而引入噪音不值得。文本端我同样建议轻做只对非情感承载词做同义词替换比如“手机”换成“电话”“退款”不动。情感词“生气”“满意”“失望”一个都不能改。实现时用 jieba 分词后对照一个禁用词表过滤掉情感词再随机替换其余词汇。3. 特征提取与模型选型语音用 wav2vec2、文本用 BERT在哪个层级融合3.1 语音特征wav2vec2 的隐藏层输出比 MFCC 强在哪早期语音情感识别用的是梅尔频率倒谱系数也就是 MFCC配合 CNN 分类器。MFCC 的问题是它只刻画了短时频谱包络丢掉了很多韵律信息——而情感恰恰高度依赖语调和重音。wav2vec2 这类预训练模型输出的隐藏状态内在包含了说话人、韵律和声道特征在情感识别任务上比 MFCC 高 5 到 8 个点的准确率。import torch import torchaudio from transformers import Wav2Vec2Processor, Wav2Vec2Model processor Wav2Vec2Processor.from_pretrained(jonatasgrosman/wav2vec2-large-xlsr-53-chinese-zh-cn) model Wav2Vec2Model.from_pretrained(jonatasgrosman/wav2vec2-large-xlsr-53-chinese-zh-cn) model.eval() waveform, sr torchaudio.load(call_0001_segment_0.wav) if sr ! 16000: waveform torchaudio.transforms.Resample(sr, 16000)(waveform) input_values processor(waveform.squeeze(0), sampling_rate16000, return_tensorspt).input_values with torch.no_grad(): outputs model(input_values) last_hidden outputs.last_hidden_state sentence_embedding last_hidden.mean(dim1)逻辑说明从预训练模型取最后一层隐藏状态对所有时间步做均值池化得到整个句子的语音向量。均值池化是轻量做法但它会把每个音素同等看待说话人停顿和重音的位置会被抹平。更好的做法是用 attention pooling我们后面再改造。参数注意模型加载后一定要切到 eval 模式否则 dropout 层在推理时仍处于激活状态音频采样率必须重采样到 16kHz否则模型拿到的特征错得离谱但不会报错。3.2 文本特征为什么不用 CNN 而是用预训练模型做文本编码文本端同样不用 TF-IDF 或 CNN 文本分类那套。BERT 类模型天然上下文感知能处理“不像话”在不同语境里的情感极性差异。用中文 RoBERTa 或 BERT-base 提取句子向量直接供融合层使用。from transformers import AutoTokenizer, AutoModel tokenizer AutoTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext) text_model AutoModel.from_pretrained(hfl/chinese-roberta-wwm-ext) text_model.eval() text 你们这个退款流程太麻烦了我等了一周都没到账。 inputs tokenizer(text, paddingmax_length, truncationTrue, max_length128, return_tensorspt) with torch.no_grad(): outputs text_model(**inputs) text_embedding outputs.last_hidden_state[:, 0, :]这里用[CLS]位置的向量作为整句表示这是 BERT 分类任务的常规用法。max_length128的控制逻辑客服文本一般不超过 50 个字128 个 token 完全够用长度超过的部分直接截断避免 padding 过多拖慢计算。但[CLS]只是固定位置的输出对长句子的语义聚合并不充分。和第 3.1 节同样的问题我会在后续替换成 mean pooling 或注意力池化。3.3 融合方式对比早融合、晚融合、注意力池化参数与效果差异多模态融合算法大体分三种特征级联的早融合、决策级联的晚融合、以及跨模态注意力。早期实验经验是晚融合能榨出 60% 的增益但剩下 40% 得靠注意力融合。把三种融合方式的代码结构和效果说清楚class EarlyFusion(nn.Module): def __init__(self, audio_dim, text_dim, hidden_dim): super().__init__() self.fc nn.Linear(audio_dim text_dim, hidden_dim) def forward(self, audio_emb, text_emb): return self.fc(torch.cat([audio_emb, text_emb], dim-1)) class LateFusion(nn.Module): def __init__(self, audio_dim, text_dim, num_labels): super().__init__() self.audio_head nn.Linear(audio_dim, num_labels) self.text_head nn.Linear(text_dim, num_labels) self.weight_a nn.Parameter(torch.tensor(0.5)) def forward(self, audio_emb, text_emb): out_a self.audio_head(audio_emb) out_t self.text_head(text_emb) return self.weight_a * out_a (1 - self.weight_a) * out_t早融合是在输入层就把 audio_emb 和 text_emb 拼起来再过一层全连接优点是实现简单缺点是两类特征分布差异大拼接后模型需要额外学习对齐关系。晚融合是让两个模态各自出结果再加权求和这里把weight_a设计成可学习参数模型自动学会更依赖哪一路输入。实际跑下来晚融合一般比早融合高 2 个百分点左右。但真正稳的提升来自跨模态注意力。更常见的做法是用 torch 的nn.MultiheadAttention以语音特征做 Query文本特征做 Key/Value让语音向量去文本里捞语义信息。cross_attn nn.MultiheadAttention(embed_dim768, num_heads4, batch_firstTrue) def cross_modal_fusion(audio_emb, text_emb): audio_emb audio_emb.unsqueeze(1) text_emb text_emb.unsqueeze(1) attn_out, _ cross_attn(queryaudio_emb, keytext_emb, valuetext_emb) return attn_out.squeeze(1)这段代码把语音向量作为 Query、文本作为 Key/Value做一次注意力交互。embed_dim768对应 wav2vec2 的输出维度num_heads4是经验值再大容易过拟合。跨模态注意力的好处是通过注意力权重能看到模型到底在“听”语音的哪一段排查 badcase 的时候非常有帮助。4. 大模型 finetune 的完整流程LoRA 冻结基座只训练融合头4.1 模型结构定义基座、LoRA 注入点与分类头标题里的大模型 finetune落到实践就是两个预训练基座 LoRA 适配器 融合分类头。全参微调一个 BERT-base 加一个 wav2vec2-large参数量加起来超过 6 亿显存根本吃不消而且客服场景下的数据量一般只有几千到几万条全参微调容易灾难性遗忘。常见的正确做法是冻结基座参数用 LoRA 在注意力层注入低秩矩阵。from peft import LoraConfig, get_peft_model audio_model Wav2Vec2Model.from_pretrained(jonatasgrosman/wav2vec2-large-xlsr-53-chinese-zh-cn) text_model AutoModel.from_pretrained(hfl/chinese-roberta-wwm-ext) lora_config LoraConfig( r8, lora_alpha32, lora_dropout0.1, target_modules[q_proj, v_proj], biasnone, ) audio_model get_peft_model(audio_model, lora_config) text_model get_peft_model(text_model, lora_config) for param in audio_model.parameters(): param.requires_grad False for param in text_model.parameters(): param.requires_grad False for name, param in audio_model.named_parameters(): if lora in name: param.requires_grad True for name, param in text_model.named_parameters(): if lora in name: param.requires_grad True逻辑说明LoraConfig里r8表示低秩矩阵的秩lora_alpha32是缩放超参实际等效学习率由alpha / r控制也就是 4 倍。target_modules选了q_proj和v_proj这是注意力矩阵里效果最明显的注入位置K 矩阵也可以注入但收益有限还增加显存占用。冻结顺序要先做一次全量冻结再手动解冻含 “lora” 的层顺序反了会导致 LoRA 参数也被冻结。融合头我通常设计得尽量简单一个 LayerNorm 一个 Dropout 一个 Linear 分类层。复杂的融合头在小数据集上极易过拟合。4.2 损失函数与采样策略标签不平衡时怎么调情感标签天然不平衡。“正常”类样本通常占 70% 以上“愤怒”“失望”类稀少。直接用 CrossEntropyLoss 会让模型学成“永远预测正常”准确率虚高但 F1 崩盘。常见的做法是给损失函数加类别权重或者用 Focal Loss 让模型聚焦难样本。from torch.nn import CrossEntropyLoss class_counts [5321, 1402, 880, 760, 455] total sum(class_counts) weights [total / (len(class_counts) * c) for c in class_counts] weights torch.tensor(weights, dtypetorch.float).cuda() criterion CrossEntropyLoss(weightweights)这段代码按类别样本数的反比计算权重样本越少的类别权重越大。示例里五个数字对应五类情绪正常、开心、愤怒、失望、焦虑。这里强调一点权重别拉太极端否则少数类会过度拟合一般让最大权重不超过最小权重的 3 倍。采样策略上训练集按批次做平衡采样from torch.utils.data import WeightedRandomSampler labels train_dataset.labels sample_weights [1.0 / class_counts[label] for label in labels] sampler WeightedRandomSampler(sample_weights, num_sampleslen(labels), replacementTrue) dataloader DataLoader(train_dataset, batch_size16, samplersampler)WeightedRandomSampler让每个 batch 里稀有类别出现的概率更高。配合 loss 权重双重手段能把少数类 F1 拉升 10 个点以上。但注意验证集不要用任何采样策略必须保持原始分布否则测出来的数全是虚的。4.3 训练参数与调度学习率、epoch 数、梯度累积这个项目的训练参数我踩了很多轮才定下来一个稳定组合。这里直接给默认值并解释逻辑参数推荐值说明batch_size16单卡 24GB 显存下的安全值学习率2e-5LoRA 参数用 2e-4融合头用 1e-3epoch10加早停在验证集 loss 连续 3 轮不降时停止梯度累积4等效 batch 64稳定 BN 统计量warmup10% steps避免开局 loss 震荡优化器AdamW权重衰减 0.01训练循环的骨架from transformers import get_cosine_schedule_with_warmup optimizer torch.optim.AdamW( [ {params: filter(lambda p: p.requires_grad, audio_model.parameters()), lr: 2e-4}, {params: filter(lambda p: p.requires_grad, text_model.parameters()), lr: 2e-4}, {params: fusion_head.parameters(), lr: 1e-3}, ], weight_decay0.01, ) total_steps len(dataloader) // grad_accum_steps * epochs scheduler get_cosine_schedule_with_warmup(optimizer, num_warmup_stepsint(total_steps * 0.1), num_training_stepstotal_steps)这里参数分组是核心LoRA 层用2e-4融合头用1e-3因为融合头是从零训练需要更大步长快速收敛而预训练模型只需要微调学习率高了直接灾难性遗忘。get_cosine_schedule_with_warmup用余弦退火后期学习率变小有助于收敛稳定。每轮训练结束要同时计算验证集的加权 F1以 F1 为模型保存依据而不是准确率。保存时用save_pretrained保存两个 LoRA 适配器和融合头的 state_dictaudio_model.save_pretrained(./checkpoints/audio_lora) text_model.save_pretrained(./checkpoints/text_lora) torch.save(fusion_head.state_dict(), ./checkpoints/fusion_head.pt)5. 避坑与排查多模态情感识别最容易翻车的 5 个现场5.1 现象 1音频全部变调识别率骤降现象训练时准确率正常一到推理就发现预测结果一团糟回听音频发现声音明显变调了。原因whisper 转录时自动把音频按 16kHz 处理但特征提取环节直接加载原文件没有检查采样率。原来 44.1kHz 的音频被当成 16kHz 喂给 wav2vec2语音频谱被整体压扁特征完全错位。解决数据流水线入口统一加一段重采样代码强制torchaudio.load后如果采样率不是 16000 就做Resample。这个坑看起来低级但它是多模态项目里最隐蔽的坑因为代码不会报错只有结果出错。5.2 现象 2文本和语音数量对不上数据加载报错现象训练到一半dataloader 突然抛 index out of range或者某个 batch 里语音特征有 99 条而文本只有 98 条。原因whisper 分段后有些短段落在清洗阶段被删掉了但语音文件是按段落切好的没有同步删除对应的音频文件。同步逻辑不一致。解决切分音频和清洗文本必须在同一循环里做每一条文本生成后才允许写入音频文件不要分两步各跑各的。文本清洗函数返回布尔值决定这一条是否保留。5.3 现象 3训练 loss 下降但验证集准确率纹丝不动现象每轮训练 loss 都在掉验证集 F1 一直在一个区间横跳没有上升趋势。原因这是典型的多模态过拟合早发信号。融合头参数太多或者 LoRA 的 rank 值太大比如设了 64模型在训练集上学到了模态间的“死记硬背”组合没有泛化。解决先把融合头简化成单层 LinearLoRA rank 降到 8同时把 dropout 加到 0.2。如果 F1 还不动检查是否某个模态的特征全被另一个模态压制——单独各跑一个单模态模型看 baseline哪个类别明显低于另一个就优先优化那个模态的特征提取。5.4 现象 4LoRA 注入后在 GPU 上显存爆炸现象两个基座模型加载完显存还剩不少get_peft_model之后直接 batch size 减半仍然 OOM。原因LoRA 在原模型权重上增加了额外参数但真正的显存杀手是 input_values 的序列长度。wav2vec2 对 10 秒音频会产生约 768 个时间步BERT 是 128 个 token两个输入同时放进 batch激活值占用的显存远超预期。解决语音侧做分段截断超过 8 秒的音频先切成 8 秒以内保证max_length可控文本侧max_length128不要放开。另外可以把 batch size 降到 8用梯度累积补回来效果比硬撑显存好得多。5.5 现象 5预测时结果和训练时表现不一致现象模型在验证集上 F1 有 0.81但接到新录音上预测结果全是“正常”。原因验证集和训练集来自同一次数据清洗分布一致新录音的说话人、信道噪声、口音和训练集差异很大模型学到的是数据集特有规律而不是泛化情感特征。解决这属于领域漂移问题。常见做法是收集一小部分新录音哪怕 100 条人工标注后做增量 LoRA 微调把领域信息拉回来。当前项目里最有效的手段是加入说话人无关的音频增强例如随机均衡器和房间混响模拟让模型不过度依赖特定音色。6. 落地技巧把模型导出并做一次跨数据集验证确认它真的能用6.1 导出的正确姿势onnx 导出与动态轴设置模型训练完不能直接拿 PyTorch 模型上线。推理环境不一定有 GPU而且多模态模型里有两套 tokenizer 和 processor接口复杂。常见做法是导出成 ONNX用 ONNX Runtime 加载CPU 环境下也能跑。import torch import onnx def export_onnx(audio_model, text_model, fusion_head, output_dir./export): audio_model.eval() text_model.eval() fusion_head.eval() dummy_audio torch.randn(1, 128, 1024) dummy_text torch.randn(1, 768) combined torch.jit.script(fusion_head) torch.onnx.export( combined, (dummy_audio, dummy_text), f{output_dir}/fusion.onnx, input_names[audio_emb, text_emb], output_names[logits], dynamic_axes{audio_emb: {0: batch}, text_emb: {0: batch}}, opset_version14, )这里dynamic_axes要显式声明 batch 维度是动态的否则导出后每次推理固定 batch size 为 1性能白白打折。opset_version14是一个比较稳妥的版本适配大多数 ONNX Runtime。导出后要检查模型结构是否完整用onnx.checker.check_model验证一下。我之前遇到过融合头的 LayerNorm 导出后数值偏差变大原因是训练时 LayerNorm 的 momentum 参数被错误保存验证时换成了 eval 模式但统计量没有刷新。导出前一定要跑一次 eval 模式的推理和训练时的输出做对比误差超过 1e-4 就说明导出有问题。6.2 跨数据集验证习惯用一份没见过的数据刺探模型漂移最后分享一个我个人的验证习惯。每个多模态情感识别项目我都会从另一个公开数据集或同批次其他渠道收集 300 条左右的数据不参与训练完全不参与验证集调参留到最后一刻当“刺探集”。这个集的作用是测出模型在数据分布偏移下的真实韧性。具体做法很简单训练完成后把刺探集通过完整流水线whisper 转录、音频切段、特征提取、LoRA 推理跑一遍输出每类的置信度分布。如果发现某类别的平均置信度低于 0.5说明模型对这个情感的表达方式还没学会如果各类置信度普遍偏高但准确率很低基本可以判断模型学到了某个模态的偶然特征。这个步骤能帮你避免把“数据集内 F1”误当成“线上真实效果”。我第一次做这个项目时就是跳过了跨数据集验证直接部署上线结果线上准确率比验证集跌了 20 个点。后来每次训练完都强迫自己跑一遍刺探集再谈部署这个习惯救了不少次后续项目的命。多模态情感识别没有想象中那么玄学数据对齐、特征融合、微调策略每一步都有章可循照着这套流程做下来你也能得到一份可靠的模型。希望帮到你。本文还有配套的精品资源点击获取