ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python实现语音文本多模态情感识别与LLM微调全攻略

Python实现语音文本多模态情感识别与LLM微调全攻略 简介基于Python实现的多模态情感识别项目融合语音wav2vec2与文本BERT特征并支持大模型finetune面向希望入门情感识别或完成课程设计、毕业设计的Python学习者。资源围绕IEMOCAP数据集搭建完整流程包含数据预处理、双编码器训练与微调的关键代码。压缩包共8个文件以4个py脚本为核心分别承担模型结构、训练逻辑、工具函数等角色另有txt格式环境配置单与md说明文档便于快速复现环境。整个资源包仅14KB轻量紧凑适合对照学习。已有949人浏览学习。通过训练脚本与数据预处理工具可快速理解语音文本多模态对齐方式并借助给出的环境配置单减少依赖安装问题。目录结构清晰适合作为项目立项、工程实训的起步参考帮助学习者从数据准备到模型微调形成整体认识。1. 语音加文本的复合情感识别为什么这个方向值得动手在客服质检场景里我遇到过很典型的情况用户语气已经明显不耐烦情绪几乎要爆发但 ASR 转写出来的文本还是礼貌用语反过来一段文本明明在抱怨朗读语气却平淡得像在念说明书。单模态不管怎么调参都会在这两类 case 上翻车而把语音和文本一起送进模型之后错误率明显下降。这就是多模态情感识别存在的理由语音提供韵律和声学线索文本提供语义和语境两者互补。这篇笔记围绕用 Python 实现多模态语音文本情感识别的完整链路展开先解决语音与文本特征怎么提、怎么对齐再搭双塔融合基线最后用大模型 finetune 让模型不仅能分类还能输出解释。适合正在做客服质检、心理健康筛查、人机交互情感判断的工程师也适合想把大模型微调落到实际业务的人。2. 语音与文本两条特征链路先解决数据和 embedding 的三大问题任何多模态项目里数据准备阶段花的力气通常比模型阶段更多。这里要解决三个问题语音特征从哪来、文本特征怎么与语音对齐、变长输入怎么批次化。常见做法是分别用预训练模型抽 embedding把结果缓存成多模态特征文件再进训练管线。下面按链路一步步说。2.1 语音特征别从零造轮子直接取预训练 encoder 的 embedding早期做情感识别常见做法是手工提 MFCC、基频、能量这些 acoustic features再拼给分类器。这条路不是不能跑但泛化很差换一个录音环境、换一种口音特征分布就漂了。预训练语音模型已经在大规模数据上学到了声学底层表示。对情感任务来说用 Wav2Vec 2.0 或 WavLM 这类模型的中间层输出通常比手工特征更稳。import torch import torchaudio from transformers import Wav2Vec2Processor, Wav2Vec2Model processor Wav2Vec2Processor.from_pretrained(facebook/wav2vec2-base) model Wav2Vec2Model.from_pretrained(facebook/wav2vec2-base) def extract_speech_embeddings(wav_path: str, target_sr: int 16000): waveform, sample_rate torchaudio.load(wav_path) # 统一采样率wav2vec2 系列默认按 16k 训练 if sample_rate ! target_sr: resampler torchaudio.transforms.Resample(sample_rate, target_sr) waveform resampler(waveform) # 多声道取平均避免声道数不一致导致序列长度不一致 if waveform.shape[0] 1: waveform torch.mean(waveform, dim0, keepdimTrue) inputs processor( waveform.squeeze(0).numpy(), sampling_ratetarget_sr, return_tensorspt, ) with torch.no_grad(): outputs model(**inputs) frame_embeddings outputs.last_hidden_state # (1, seq_len, hidden) # 全局池化只用来做快速验证训练时建议保留帧级特征 pooled torch.mean(frame_embeddings, dim1) return pooled, frame_embeddings这里有一个关键选择返回的 frame_embeddings 我会保留因为情感判断相当依赖时序变化。比如“高兴”在语调和节奏上的起伏在全局平均池化之后会被抹平很多。全局池化只适合快速验证链路通不通真正训练时应该让模型自己决定哪些帧重要。另一个容易忽略的参数是 target_sr。wav2vec2 系列预训练时普遍用 16kHz你手里的录音可能是 44.1kHz 或 8kHz不重采样直接喂进去特征质量会明显下降。重采样这一步看似多余实际是影响结果的第一道坎。2.2 文本特征BERT embedding 加情绪标签上下文文本分支相对简单但同样不建议自己训练词向量。中文场景我常用 hfl/chinese-roberta-wwm-ext它对中文语义和情感词的理解比通用 BERT 好一些英文场景可以用 bert-base-uncased。这个分支需要输出的也是 frame-level 的 token embedding而不是只取句向量因为后面要和语音特征做注意力融合。from transformers import AutoTokenizer, AutoModel tokenizer AutoTokenizer.from_pretrained(hfl/chinese-roberta-wwm-ext) text_model AutoModel.from_pretrained(hfl/chinese-roberta-wwm-ext) def extract_text_embeddings(text: str, max_len: int 128): encoded tokenizer( text, max_lengthmax_len, paddingmax_length, truncationTrue, return_tensorspt, ) with torch.no_grad(): output text_model(**encoded) # token 级 embedding[CLS] 在 0 位 return output.last_hidden_state, encoded[attention_mask]max_len 的选择要结合你实际数据的长度分布。客服录音转写文本一般不超过 128 token但如果是会议长对话就需要调大或者做滑窗切分。padding 用 max_length 而不是动态 padding是为了和语音分支的固定长度 tensor 对齐省去大量逻辑判断。这里有个很多人踩过的坑直接把整段长文本 truncate会把情绪转折部分截掉模型大概率学到的只是开头情绪。我的建议是先统计语料长度中位数再把 max_len 设为中位数的 1.5 倍左右而不是拍脑袋定 128。2.3 对齐与数据集构建把语音帧和文本 token 装进同一条流水线语音帧和文本 token 并不是天然对齐的一段 10 秒的音频wav2vec2 会产出接近 500 帧而文本可能只有 30 个 token。工程上常见做法是统一截断到固定长度并在 batch 维度上补齐。这样能直接丢给 PyTorch DataLoader避免每次都要处理变长张量。import torch from torch.utils.data import Dataset class EmotionDataset(Dataset): def __init__(self, samples, max_frames320, max_text_len128): self.samples samples self.max_frames max_frames self.max_text_len max_text_len def __len__(self): return len(self.samples) def __getitem__(self, idx): wav_path, text, label self.samples[idx] _, audio_emb extract_speech_embeddings(wav_path) text_emb, attn_mask extract_text_embeddings(text, self.max_text_len) # 固定语音帧数320 帧对应约 6.4 秒音频每帧约 20ms if audio_emb.shape[1] self.max_frames: audio_emb audio_emb[:, : self.max_frames, :] else: pad_len self.max_frames - audio_emb.shape[1] audio_emb torch.cat( [audio_emb, torch.zeros(1, pad_len, audio_emb.shape[-1])], dim1 ) return audio_emb.squeeze(0), text_emb.squeeze(0), attn_mask.squeeze(0), labelmax_frames 这个参数值得多说一句。320 帧对应约 6.4 秒语音这是客服单轮对话常见时长。如果你的业务音频有 30 秒硬截断到 320 帧会丢掉后半段情绪爆发点。另一种更稳的做法是不固定帧数batch 内动态 padding再把 attention mask 传给融合模块。为了快速跑通先用固定长度等换到长音频场景时再改动态 padding改动成本不大。数据准备阶段我还会把抽取好的 embedding 缓存成 .npy 或 .pt 文件而不是每次训练都重新过一遍预训练模型。语音和文本 encoder 在训练中通常是冻结的重复前向计算纯属浪费 GPU。缓存后训练迭代速度能快 5 到 10 倍这也是很多多模态比赛队伍普遍采用的做法。3. 模型融合双塔加交叉注意力先跑通一个能用的基线数据准备好了接下来是模型结构。多模态融合的选型会直接影响模型能不能收敛这里先讲清楚三种融合时机的差别再给一个可以跑的基线最后升级成交叉注意力版本。3.1 融合时机early、intermediate、late fusion 的取舍按融合发生的层级大致分三类融合方式做法优点风险early fusion在输入端拼接原始特征结构简单两种模态统计特性差异大模型很难学intermediate fusion在各自编码后、分类前融合平衡效果好信息损失小需要设计融合模块late fusion各自分类后再融合得分实现最快适合独立单模态模型模态间交互信息被丢弃我的选择是 intermediate fusion。语音 embedding 和文本 embedding 经过各自大模型编码后语义空间已经相对对齐在这个层级做交叉注意力模型能捕捉到“文本说没事但语音在抖”这类跨模态线索。late fusion 虽然简单但两个分类器已经独立决策后期再融合很难弥补单模态的错误。3.2 基线双塔拼接入分类头先做一个最简单的拼接双塔。两个 encoder 各自输出 embedding全局池化后拼接进全连接层。这个基线虽然不高级但能快速验证数据链路和标签质量。import torch.nn as nn class EmotionFusionBaseline(nn.Module): def __init__(self, audio_dim768, text_dim768, hidden_dim256, num_classes4): super().__init__() self.classifier nn.Sequential( nn.Linear(audio_dim text_dim, hidden_dim), nn.ReLU(), nn.Dropout(0.2), nn.Linear(hidden_dim, num_classes), ) def forward(self, audio_emb, text_emb, text_mask): # audio_emb: (B, T_a, D_a)text_emb: (B, T_t, D_t) audio_pooled audio_emb.mean(dim1) # 取 [CLS] 位比 mean pooling 更贴合 BERT 语义 text_pooled text_emb[:, 0, :] combined torch.cat([audio_pooled, text_pooled], dim-1) return self.classifier(combined)代码逻辑很简单但有三处值得注意。第一text_pooled 用 [CLS] token 而不是 mean pooling因为 BERT 系列模型的 [CLS] 位置经过预训练后专门用来聚合句子语义。第二音频和文本的隐藏维度最好保持一致这里都是 768如果遇到维度不一致的 encoder需要先各自过一个线性层做维度统一否则拼接后的向量会偏向维度更大的模态。第三拼接后的向量直接进全连接层相当于默认两个模态贡献相等这在情感任务里并不成立下一小节会改进。3.3 交叉注意力与门控融合让模型自己决定听谁的拼接解决了“特征都有了”但没解决“谁更重要”。音频噪声大、ASR 转写错误、说话人习惯性冷笑话都会让某个模态变不可靠。交叉注意力能让模型根据不同输入动态加权而门控机制可以进一步输出一个 0 到 1 之间的模态权重。import torch.nn.functional as F class CrossAttentionFusion(nn.Module): def __init__(self, d_model768, num_heads8): super().__init__() self.attn nn.MultiheadAttention(d_model, num_heads, batch_firstTrue) self.norm nn.LayerNorm(d_model) def forward(self, audio_emb, text_emb, text_mask): # text 作为 query去 audio 序列里检索相关信息 fused, weights self.attn( querytext_emb, keyaudio_emb, valueaudio_emb, key_padding_maskNone, ) return self.norm(fused text_emb), weights class GatedFusion(nn.Module): def __init__(self, d_model768): super().__init__() self.gate_proj nn.Linear(d_model * 2, 1) def forward(self, audio_pooled, text_pooled): gate_input torch.cat([audio_pooled, text_pooled], dim-1) gate torch.sigmoid(self.gate_proj(gate_input)) fused gate * audio_pooled (1 - gate) * text_pooled return fused这里让 text 做 query 而不是 audio原因是语义信息通常比韵律信息更稳定让模型先去文本里确认“说了什么”再去音频里找“怎么说的”更符合人类理解情感的次序。交叉注意力的输出序列再池化后接一个门控层能显式控制两条模态的贡献比例。复杂场景下多模态情感预测比如嘈杂环境、多人对话、语音转写错误率高时门控权重会明显偏向文本反之当文本简短且缺少情绪词时模型会自动转向语音特征。这个“自动取舍”就是门控融合的核心价值。从工程角度看先跑通拼接基线确认 loss 能下降再替换成交叉注意力去对比指标提升是风险最小的路径。如果一上来就上复杂的 MAC 注意力或 Transformer 层出了问题很难分清是融合模块的问题还是数据问题。4. 大模型 finetune让模型从分类器变成会解释的助手双塔分类器能给出情感标签但业务方常常还要一句理由“这个用户为什么被判定为愤怒是语气问题还是用词问题”传统分类器做不到。这时候可以引入大模型 finetune用一个生成式底座把多模态特征和文本一起喂进去让它输出标签、置信度和解释。4.1 为什么选生成式大模型以及音频特征怎么进语言模型大模型不能直接读音频。常见做法是把语音 embedding 压缩成固定数量的“软 token”拼在文本 prompt 之前。这个压缩投影层必须跟着模型一起训练否则模型看到一堆没见过的数字向量只会输出胡话。另一条路是把 ASR 文本作为大模型输入语音只做辅助特征通过一个可学习的 prefix 注入。我实际用的方案是不管走 Qwen2.5 还是 Llama 3.2 的底座都会把语音帧池化成固定长度比如 64 帧然后过一层线性投影到 LLM 的 hidden size。训练时冻结底座只训练投影层和 LoRA 参数显存占用可以控制在单张 24G 显卡以内。4.2 LoRA / QLoRA 选型与关键参数大模型全参数微调对显存和数据量要求都很高情感识别这种任务通常只需要适配底层能力LoRA 是更实际的选择。LoRA 只训练 attention 层的低秩增量矩阵参数量通常不到整体的 1%效果在中小数据集上与全参数微调差距不大。from peft import LoraConfig, get_peft_model, TaskType from transformers import AutoModelForCausalLM, AutoTokenizer import torch base_model AutoModelForCausalLM.from_pretrained( Qwen/Qwen2.5-7B-Instruct, torch_dtypetorch.bfloat16, device_mapauto, ) tokenizer AutoTokenizer.from_pretrained(Qwen/Qwen2.5-7B-Instruct) lora_config LoraConfig( task_typeTaskType.CAUSAL_LM, r8, lora_alpha32, target_modules[q_proj, k_proj, v_proj, o_proj], lora_dropout0.1, ) model get_peft_model(base_model, lora_config) model.print_trainable_parameters()三个参数值得重点说明。r 是低秩矩阵的秩r 越大模型能学到的变换越复杂但也更容易过拟合情感识别这种任务 r8 通常够了lora_alpha 是缩放系数实际生效的缩放比例是 alpha / r所以把 r 从 8 改成 16 时alpha 最好也从 32 同步放大到 64否则更新量会变小target_modules 要根据底座模型结构调整Qwen2.5 是 q_proj、k_proj、v_proj、o_proj如果换成 Llama 3.2命名基本一致但还是要先打印模型结构确认一遍省得配了不生效。4.3 微调数据构造与训练脚本大模型微调最关键的其实是数据格式。情感识别任务要把指令、文本、语音特征、标签组织成对话模板每轮都保持一致模型才会学到稳定的输出格式。这里给出一个训练样本构造函数和训练脚本。def build_prompt(text, audio_feature_vec, labelNone): instruction ( 你是一名情感识别助手。根据用户文本和语音特征判断情绪 输出格式为情绪类别|置信度|理由。\n ) text_part f文本内容{text}\n audio_part f语音特征{audio_feature_vec}\n if label is not None: answer f标签{label} return instruction text_part audio_part answer return instruction text_part audio_part # 训练样本示例 sample_text 我觉得你们这个服务真的很让人失望 sample_audio_feat [0.123, -0.456, ...] # 投影后的语音特征 prompt build_prompt(sample_text, sample_audio_feat, labelangry)from trl import SFTTrainer from transformers import TrainingArguments training_args TrainingArguments( output_dir./emotion_lora, per_device_train_batch_size2, gradient_accumulation_steps4, learning_rate2e-4, logging_steps10, num_train_epochs3, save_strategysteps, save_steps200, bf16True, ) trainer SFTTrainer( modelmodel, train_datasetdataset, argstraining_args, max_seq_length2048, ) trainer.train()注意 per_device_train_batch_size 和 gradient_accumulation_steps 的乘积才是真正生效的 batch size这里 2 × 4 8。情感数据集通常只有几千条batch size 过大反而容易欠拟合。bf16 只在支持 Ampere 及以上架构的显卡上可用老显卡换成 fp16True。max_seq_length 必须覆盖“音频特征 token 文本 token 输出标签”的总长度语音特征压到 64 帧后加上文本一般不会超过 2048如果发现训练时被截断优先减小语音特征帧数而不是无限调大 max_seq_length。微调完成后要单独切出一部分 training 里没见过的 speaker 或录音环境验证泛化能力。情感识别数据集的说话人重合问题很常见训练集和测试集来自同一个人指标虚高换了一组人立刻掉点。这属于老生常谈但必须提前规避的问题。5. 训练评估与常见坑别让准确率指标骗了你模型收敛之后评估环节设计得好不好直接决定这个系统能不能上线。情感识别里准确率是最不诚实的指标类别不均衡、标注主观性、说话人重合都会让单点指标失真。这一章讲清楚评估指标怎么选、数据集怎么用再列几条我真实踩过的坑。5.1 评估指标weighted-F1 比 accuracy 诚实常见情感数据集里“中性”样本通常占一半以上“愤怒”“厌恶”这类负向情绪样本很少。模型只要全部预测成中性准确率也能有 50% 以上这个数字没有任何上线参考价值。正确做法是看加权 F1 和每类别的召回率。from sklearn.metrics import classification_report, confusion_matrix y_true [neutral, angry, sad, happy] y_pred [neutral, angry, neutral, happy] report classification_report( y_true, y_pred, target_names[angry, sad, happy, neutral], zero_division0, ) print(report) conf confusion_matrix(y_true, y_pred) print(conf)classification_report 会同时输出每类的 precision、recall、f1-score 和加权平均一眼就能看出哪个类别被牺牲了。confusion_matrix 则用来观察类别间的混淆模式比如“愤怒”经常被预测成“中性”说明模型对负向情绪不敏感可能需要更多负向样本或调整阈值。上线时我会额外设置一个风险优先级对“愤怒”和“厌恶”的召回率要求更高即使牺牲一部分准确率也要保证不漏报。5.2 数据集怎么选公开多模态数据集与自建数据的边界情感识别领域可用的多模态数据集并不算多做实验前要先把数据分布和版权看清楚。数据集模态语言适合场景注意点IEMOCAP音视频 文本英文对话情感说话人少容易过拟合CMU-MOSI音视频 文本英文情感回归标签细粒度偏向 opinionMELD音视频 文本英文多轮对话类别不均衡严重自建客服数据语音 ASR 文本中文业务落地需要标注规范和清洗IEMOCAP 只有 10 个说话人如果不按说话人划分模型会把说话人身份学进去测试时换一批人直接崩。自建数据时标注规范里要区分“文本情绪”和“语音情绪”比如文本中性但语音愤怒标签应该怎么定这必须在标注前敲定否则后期清洗成本极高。另外ASR 转写错误是自建数据避不开的问题口音重、噪声大的录音尤其严重我会做一层人工抽检按句统计 ASR 错误率超过 20% 的样本直接从训练集剔除。5.3 五个真实踩坑记录现象、原因、解决这里写几条我自己在训练和微调中反复遇到的坑每条都按现象、原因、解决的顺序来说。坑一训练 loss 下不去语音分支完全没生效。现象是拼接双塔训练到第 10 个 epoch验证 F1 还在 0.3 附近比只用文本的单模态还差。原因是语音 embedding 没有做归一化输入分布不稳定模型全被数值更大的维度带偏。解决方法是先对音频 embedding 做 L2 normalize 或 LayerNorm再把学习率降到 5e-5重训后 F1 回升了十几个点。这也是多模态项目里最玄学但最常见的问题两个模态的数值尺度不一样模型会忽略小尺度模态。坑二融合模型退化成单模态模型。现象是语音分支对结果的影响几乎为零把音频特征换成随机噪声预测结果不变。原因是交叉注意力里 query 全部来自文本语音信息经过注意力加权后被稀释而门控初始权重偏向文本。解决方法是把 gate 的 bias 初始化为 0.5让两个模态起点平等同时给语音分支多加一个 [CLS] 式的全局 token避免帧级信息被平均掉。检查方式是做一个控制变量实验分别只输入语音或只输入文本看两个单模态的预测对最终结果是否有可感知的影响。坑三ASR 文本错误导致标签和内容错位。现象是测试集里一段明明很愤怒的语音转写文本变成“好的好的没问题”模型预测成中性。原因不是模型问题是 ASR 在噪声环境下把否定词听丢了。解决方法是把 ASR 置信度作为特征传给模型低置信度的句子让模型更依赖语音分支。如果项目允许还可以把 ASR 候选文本 top-k 都送进文本分支让模型自己判断哪条转写更可信。坑四大模型微调后复读指令输出一堆模板话术。现象是模型对着输入重复“你是情感识别助手”或者只输出逗号不断句。原因通常是 LoRA 学习率太高、训练轮数太多导致模型记住了数据里的指令模板而不是情感判断逻辑。解决方法是把学习率降到 1e-4 左右训练轮数控制在 3 轮以内另外检查数据里指令和回答之间是否有固定分隔符模板设置得越统一模型越容易陷入复读。坑五显存突然爆掉换小 batch 也挡不住。现象是语音特征压到 64 帧后训练到中途 CUDA OOM。原因是 attention 机制里 key 和 value 都包含完整语音序列主干长度虽然是 64但 Qwen 的 attention 本身还有 prompt cache。解决方法是用 gradient checkpointing 开启重计算牺牲一点速度换显存同时把语音帧从 64 降到 32对比 F1 变化很多时候 32 帧已经够用显存却省了一大半。6. 部署与验证把微调模型包装成一条可调用的推理链路模型训练完最终要变成一个能被打着语音文本调用的服务。这里分享一条我常用的推理封装思路和验证技巧。6.1 一条最小可用推理链路把语音抽取、文本编码、LoRA 模型推理串成一个函数。部署时先加载一次模型不要在请求里反复加载否则延迟和显存开销都不可控。def predict_emotion(wav_path: str, asr_text: str, model, tokenizer, devicecuda): audio_pooled, _ extract_speech_embeddings(wav_path) audio_proj projector(audio_pooled) # 投影到 LLM hidden size prompt build_prompt(asr_text, audio_proj.detach().tolist()) inputs tokenizer(prompt, return_tensorspt).to(device) with torch.no_grad(): outputs model.generate(**inputs, max_new_tokens64) return tokenizer.decode(outputs[0], skip_special_tokensTrue)实际部署时我会先把语音 embedding 缓存成预计算特征推理阶段只做一次查表避免每条请求都重新跑一遍 wav2vec2。如果直接把大模型部署在 CPU 上响应延迟会很难看常见做法是把 LoRA 合并回底座后导出成量化版本或者保留双塔分类器作为高并发入口只对低置信度样本调用大模型做解释生成。两条路没有绝对优劣取决于你的并发量和预算。6.2 上线前的验证技巧最后一步我会回到最初让人翻车的那两类 case文本中性但语音愤怒、文本抱怨但语音平淡。把这些 case 专门搜集成一个挑战集每次迭代后先跑一遍挑战集再看总体指标。这是最容易发现“模型是不是又退化成单模态”的检测方式。另一个习惯是拿 100 条真实录音找三个人独立标注标注意见不一致的样本直接丢进难例集而不是强行统一标签。情感本身有主观性标注一致性不高的样本模型学不进去还容易带偏整个决策边界。微调大模型时我会把这类分歧样本单独保留不在训练阶段使用只在测试阶段观察模型输出的解释是否符合多数人判断。这些做法保证了每次迭代都不只是让指标涨一点而是让系统在真实场景里更可靠。希望这些血泪经验能帮你少走弯路也希望这个方向在你手里能跑出更好的效果。本文还有配套的精品资源点击获取
返回列表