ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

多模态语音与文本情感识别:wav2vec2+BERT+LoRA实战方法

多模态语音与文本情感识别:wav2vec2+BERT+LoRA实战方法 简介这是一份基于Python的多模态情感识别实现资源核心思路是将语音与文本特征结合并在大模型基础上做微调如BERT与w2v2联合训练可用于学习、课程设计、毕业设计或项目演示适合计算机相关专业学生、教师及科研人员。压缩包共10个文件主要包括4个Python源码脚本、1个Markdown说明、1个文本配置说明及备份文件等整体仅11KB结构精简便于快速查看与二次开发。资源经过严格测试功能完整可正常运行README与源码注释有助于理解模型训练、语音编码与文本分词等关键环节。目前已有80人学习使用。对于想动手实践多模态情感识别、理解预训练模型微调流程的读者这份代码提供了可直接运行的示例与扩展基础基础较好的用户还能在此基础上修改实现更多功能。资源仅用于学习交流请勿商用。1. 多模态语音与文本情感识别为什么双模态比单模态更值得做做过客服质检或舆情分析的人应该都有这种体验一段录音转成文字后情绪信息丢了大半。客户语气已经很不耐烦了文本里却只有一句“好的我知道了”反过来纯听语音能捕捉到怒气但听不出“我要投诉”和“我要表扬”到底指向什么。单靠任何一边都只能拿到一半的信息。这个项目做的就是把这俩捏到一起——语音走 wav2vec2 提声学特征文本走 BERT 提语义特征再把两组特征融合起来在情感分类任务上做大模型微调。它不是玩具 Demo而是一套能从原始音频跑到分类结果的完整流程适合做毕业设计也适合想正式入门多模态微调的在职开发者照着改。2. 技术选型与整体架构wav2vec2 BERT LoRA 这套组合的选型逻辑2.1 语音侧选 wav2vec2 而不是手工特征早年做语音情感识别常规路径是提 MFCC、零交叉率、能量这些手工声学特征喂给 LSTM 或 SVM。MFCC 的问题是它只描述当前一帧的频谱包络没有上下文。而情感恰恰是慢变量——一个人从平静到发火跨度往往在几十毫秒到几百毫秒只看单帧根本看不出来。wav2vec2 是自监督预训练模型它在大量无标注语音上学会了“下一帧大概长什么样”所以它输出的每一帧特征都天然带上了左右上下文的信息。用它的 hidden state 当特征相当于让模型一出生就懂语音的节奏和语调变化这比从零训练一个 LSTM 强太多。这个项目里语音编码器用的是facebook/wav2vec2-base输出维度 768。选 base 而不是 large原因很朴素视频显存不够而且 base 在情感任务上已经能提供足够好的声学表征。large 带来的提升在融合模型里会被文本侧稀释但训练时间翻倍不止。音频输入采样率强制统一到 16000Hz这是 wav2vec2 预训练时的标准采样率。模型内部卷积层会把音频转成 50Hz 的帧序列也就是说每 20 毫秒音频对应一个特征向量。一个 10 秒的音频片段语音侧拿到的是 500 帧 × 768 维的特征序列。2.2 文本侧用 BERT 而不是更大的生成模型文本侧的任务是抓住语义里的情感线索。“我不确定这个方案行不行”这句话关键词不是“不确定”而是“我”的主观视角和整个句子的语气。BERT 这类双向编码器在情感分类任务上依然是性价比最高的选择因为它能看到完整上下文。选用bert-base-chinese处理中文文本输入序列上限 512 token。对一条语音对应的转写文本来说这个长度基本够用——正常人 10 秒说不了 512 个字。两个编码器输出都是 768 维这个维度对齐不是巧合而是选择模型的硬性标准。维度一致意味着后接的融合层不需要做复杂的维度映射直接拼接也不会出现某一侧特征被压扁的问题。BERT 的输出取[CLS]位置的向量wav2vec2 的输出做时间维度的全局平均池化两边都变成 1 × 768。这样融合层拿到的输入是 1 × 1536结构非常干净。2.3 LoRA 与全量微调的取舍传统做法是把整个模型所有参数都放开训练这对 wav2vec2 BERT 这种规模的模型来说不现实显存放不下、数据集太小容易过拟合、训练时间以天为单位。LoRA 的思路是冻结预训练模型权重在注意力层的权重矩阵旁路训练低秩增量。训练时只更新增量部分推理时把增量合并回原矩阵推理速度完全没有损失。对比项全量微调LoRA 微调可训练参数占比100%0.5% ~ 1%单卡 12GB 能否训练很勉强可以还能留余量训练时间十余小时起步几小时过拟合风险高数据少时严重低相当于隐式正则效果上限理论上更高实际差距在 1% 以内这也是这个项目的核心思路编码器用预训练模型的表征能力只微调少量参数把精力放在融合层设计上。融合层是随机初始化的它的参数量虽然不大但决定了两侧特征怎么交互——这部分必须认真训练。3. 环境搭建与数据流水线从原始音频到可训练样本3.1 环境依赖与安装这个项目依赖的核心包就七个torch、transformers、datasets、peft、librosa、soundfile、sklearn。torch 的版本需要注意LoRA 微调依赖 peft 库而 peft 不同版本对 transformers 的最低版本要求不一样建议把 transformers 装到较新版本。pip install torch2.1.2 torchaudio2.1.2 --index-url https://download.pytorch.org/whl/cu118 pip install transformers4.36.2 datasets2.16.1 peft0.7.1 pip install librosa0.10.1 soundfile0.12.1 scikit-learn1.3.2装完之后先跑一个环境自检脚本确认 CUDA 可用、模型能加载别等数据都准备好了才发现基础环境有问题。这里--index-url指定了 CUDA 11.8 版本的 PyTorch如果你的显卡驱动支持 CUDA 12.x可以去掉这个参数直接装默认版本。librosa 装 0.10.x 的原因是这个版本对 Python 3.10/3.11 的适配最稳更老版本在读取 mp3 时容易缺依赖。3.2 语音与文本的对齐不做强制对齐也能用的方案多模态任务第一个要解决的就是对齐问题一段 8 秒的音频对应文本应该从哪里切到哪里严格做法是用 Montreal Forced Aligner 这类工具做到音素级对齐但那需要额外安装工具、训练对齐模型流程长还容易翻车。更务实的方案是用 ASR 模型先把音频转成带时间戳的文本然后按句子边界切分。def align_audio_text(audio_path, asr_model, asr_processor, window_sec8): speech, sr librosa.load(audio_path, sr16000) # ASR 转写返回按句分段的文本和时间戳 outputs asr_model.generate( asr_processor(speech, sampling_ratesr, return_tensorspt).input_values ) result asr_processor.batch_decode(outputs, output_timestampsTrue) segments [] for item in result.timestamps: start, end item[start], item[end] text item[text].strip() if len(text) 2: # 过滤语气词、空转写 continue window_text expand_to_window(text, start, end, window_sec) segments.append({ start: start, end: end, text: window_text, audio: speech[int(start * 16000):int(end * 16000)] }) return segments这段代码的逻辑是先按 ASR 识别出的句子边界拿到自然分段再以每个句子为中心扩展成一个固定长度的窗口。window_sec8是窗口总长度句子在哪一侧多了就往另一侧补。这样做的原因是模型训练时要求输入是等长的音频和文本但实际对话中句子长短不一直接在原始边界上切会导致长短差距过大。expand_to_window函数内部会根据句子中心位置前后补音频和文本短句子用相邻上下文填充长句子则截断尾部。3.3 序列长度统一策略音频和文本在送入模型之前都要做长度归一化。wav2vec2 要求采样率 16000Hzlibrosa 加载时已经做了重采样BERT 要求输入长度不超过 512 token。关键问题在于截断策略——如果直接对超长文本一刀切很可能把句末的情感词切掉。def normalize_audio(audio, target_sr16000, max_frames8 * 16000): if len(audio) max_frames: # 取音频中间段保留情感表达最丰富的部分 start (len(audio) - max_frames) // 2 audio audio[start:start max_frames] else: audio np.pad(audio, (0, max_frames - len(audio))) return audio.astype(np.float32) def normalize_text(text, tokenizer, max_len512): tokens tokenizer( text, truncationTrue, paddingmax_length, max_lengthmax_len, return_tensorspt ) return tokens音频截断取中间段而不是开头是因为一段语音的高潮往往在中后部——比如一句“你怎么回事”的怒气值在句末才达到峰值。文本截断交给 tokenizer 的truncationTrue处理它默认从尾部截但这里有一个坑BERT 的 tokenizer 会把长句从头到尾编码如果某个句子超过 512 token尾部的关键词会被丢掉。常见做法是在数据预处理阶段就把长句按标点先切一遍把超过长度阈值的句子拆成独立样本避免训练时被动截断。4. 模型实现与 LoRA 微调特征融合与训练细节4.1 双塔编码器构建模型主体由两个编码器组成语音和文本各走各的分支。加载预训练权重后立刻冻结这一步不做的话反向传播会更新所有参数LoRA 就没有意义了。import torch from transformers import Wav2Vec2Model, BertModel class DualEncoder(torch.nn.Module): def __init__(self, audio_model_namefacebook/wav2vec2-base, text_model_namebert-base-chinese): super().__init__() self.audio_encoder Wav2Vec2Model.from_pretrained(audio_model_name) self.text_encoder BertModel.from_pretrained(text_model_name) # 冻结编码器参数只留特征提取能力 for param in self.audio_encoder.parameters(): param.requires_grad False for param in self.text_encoder.parameters(): param.requires_grad False self.audio_pool torch.nn.AdaptiveAvgPool1d(1) def forward(self, audio_inputs, text_inputs): # audio_inputs: [batch, time] audio_feat self.audio_encoder(input_valuesaudio_inputs).last_hidden_state audio_feat self.audio_pool(audio_feat.transpose(1, 2)).squeeze(-1) text_feat self.text_encoder(**text_inputs).last_hidden_state[:, 0, :] return audio_feat, text_featAdaptiveAvgPool1d(1)把时间维压成 1无论输入音频多长输出都是 768 维。文本侧取[CLS]位置同样得到 768 维。两个向量拼在一起就是 1536 维的融合特征这是整个模型的主干。这段代码的核心在于冻结操作必须在from_pretrained之后立刻执行否则优化器会把这些参数算进去显存立刻爆掉。4.2 融合层设计从拼接门控到交叉注意力特征融合的方式直接决定多模态的效果。最简单的拼接就是 concat但它假设两模态特征是独立的实际上语音和文本有大量互补信息需要学习它们之间的交互关系。class FusionLayer(torch.nn.Module): def __init__(self, hidden_size768, num_labels6): super().__init__() self.gate torch.nn.Sequential( torch.nn.Linear(hidden_size * 2, hidden_size * 2), torch.nn.Tanh(), torch.nn.Linear(hidden_size * 2, 2), torch.nn.Softmax(dim-1) ) self.classifier torch.nn.Sequential( torch.nn.Linear(hidden_size * 2, hidden_size), torch.nn.Dropout(0.2), torch.nn.ReLU(), torch.nn.Linear(hidden_size, num_labels) ) def forward(self, audio_feat, text_feat): concat_feat torch.cat([audio_feat, text_feat], dim-1) # 门控机制让模型自己决定每个样本该信语音多还是文本多 gate_weight self.gate(concat_feat) gated_feat concat_feat * gate_weight[:, 0:1] concat_feat * gate_weight[:, 1:2] return self.classifier(gated_feat)门控机制的关键价值在于对不同样本两模态的可信度不一样。比如环境嘈杂的录音语音特征噪声大模型该多参考文本反过来如果说话人用了明显的反讽语气文本字面意思不可信模型该多听声学特征。门控网络输入 1536 维拼接特征输出两个权重分别乘到语音和文本特征上再求和进入分类器。Softmax(dim-1)保证两个权重之和为 1避免训练时梯度爆炸。这里 dropout 设 0.2是融合层的惯例取值对防止融合层过拟合有效。4.3 LoRA 微调与训练循环融合层参数是随机初始化的需要训练编码器参数冻结但它们的注意力权重可以用 LoRA 做低秩微调。LoRA 的目标是让预训练模型在不改变全部参数的前提下为当前任务调整注意力机制。from peft import LoraConfig, get_peft_model lora_config LoraConfig( r8, # 低秩矩阵的秩决定增量参数容量 lora_alpha16, # 缩放系数控制更新步幅 target_modules[q_proj, v_proj], # 只微调注意力层里的 Q 和 V lora_dropout0.1 ) model DualEncoder() model.audio_encoder get_peft_model(model.audio_encoder, lora_config) model.text_encoder get_peft_model(model.text_encoder, lora_config) # 融合层和 LoRA 参数一起训练编码器主体参数保持冻结 trainable_params [p for p in model.parameters() if p.requires_grad] optimizer torch.optim.AdamW(trainable_params, lr2e-5)target_modules[q_proj, v_proj]是 LoRA 论文反复验证过的最优配置只微调 Query 和 Value 矩阵就能取得接近全量微调的效果而 Key 矩阵的调整收益很小。r8意味着每个被调矩阵新增一个 8 维的低秩旁路参数参数量很小但对任务适配足够。lora_alpha16是缩放因子通常设成r的两倍梯度更新时稳定性好。训练循环里有一个容易被忽略的点融合层的输出直接进分类器梯度会同时回传到融合层和 LoRA 增量参数上。融合层的学习率可以和 LoRA 分开设融合层用 1e-4LoRA 用 2e-5这样融合层收敛快LoRA 保持稳定微调。from torch.utils.data import DataLoader from transformers import get_linear_schedule_with_warmup def train_one_epoch(model, dataloader, optimizer, scheduler): model.train() total_loss 0 for batch in dataloader: audio_inputs batch[audio].to(device) # [batch, 128000] float32 text_inputs {k: v.to(device) for k, v in batch[text].items()} labels batch[label].to(device) audio_feat, text_feat model.audio_encoder_and_pool(audio_inputs), model.text_encoder(text_inputs) logits model.fusion(audio_feat, text_feat) loss torch.nn.CrossEntropyLoss()(logits, labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss loss.item() return total_loss / len(dataloader)梯度裁剪max_norm1.0是必要的LoRA 增量参数和融合层参数一起训练时融合层梯度可能远大于 LoRA 梯度如果不裁剪几个 step 之后融合层参数就会震荡损失曲线出现锯齿状。学习率调度用 warmup 线性衰减warmup 比例设 0.1前 10% 的 step 学习率从 0 线性升到目标值避免起步阶段梯度剧烈波动。5. 避坑记录多模态语音与文本情感识别的五个典型翻车场景5.1 采样率不一致导致语音特征错位现象模型训练时 loss 能降到 0.2 附近但验证集准确率一直徘徊在 30% 到 40%跟随机猜测差不多。原因训练集音频有一部分是 44.1kHz 录制的另一部分是 16kHz 的座机录音。librosa 加载时如果没显式指定sr16000会用音频原始采样率返回。wav2vec2 的预处理层默认按 16000Hz 把输入切成帧44.1kHz 的音频被它当成 16kHz 处理相当于同一段语音的时间轴被压缩了一半帧序列错位严重。解决所有音频加载路径都强制librosa.load(path, sr16000)并在数据类加载后做一次抽样检查——打印每条样本的audio.shape[1] // 16000确认时长在合理范围残差值刚好是 8 秒的整数倍。5.2 标签不平衡让模型沦为“哑巴分类器”现象训练时 loss 正常下降验证 F1 值却涨不上去观察输出分布后发现模型对所有样本都预测“中性”这个多数类。原因情感数据天然不平衡负面样本不到总数 20%。CrossEntropyLoss默认对所有类别同等对待模型学到最后发现只要全预测多数类loss 最低。解决用sklearn.utils.class_weight.compute_class_weight计算每个类别的权重传给损失函数的weight参数给少数类更高权重让模型“被迫”关注它们。类别权重公式是count_total / (count_class * num_classes)少数类权重是多数类的几倍loss 对少数类预测错误的惩罚也会成倍放大模型才有动力去学它们的特征。5.3 文本截断一刀切导致长句情感误判现象测试集里超过 40 字的句子情感识别准确率显著低于短句尤其是“虽然……但是……”这类转折结构。原因文本最大长度限制 512 token数据预处理阶段对超长句子直接尾部截断。转折句的情感表态在句末被截掉之后只剩前半段的“虽然……”模型自然判成中性或相反情感。解决预处理时对超过长度阈值的句子按逗号、分句处的标点做滑窗拆分每个窗口单独成为一个训练样本窗口之间保留部分重叠。训练代码里不直接改truncationTrue的逻辑而是在 tokenize 之前先用re.split按标点切分短句合并、长句不截断保证语义完整。5.4 融合层随机初始化拖后腿现象训练前期 loss 下降很慢前 20 个 epoch 几乎没有进展之后才开始好转而且融合层参数明显比 LoRA 参数更新幅度大得多。原因融合层是随机初始化的LoRA 的增量参数是在预训练权重的基础上学出来的两者梯度量级相差悬殊。共用 AdamW 时融合层的梯度主导了整个优化方向LoRA 增量几乎没学到什么东西。解决分两阶段训练。第一阶段冻结编码器只训练融合层让分类器先学会在已有特征上做映射第二阶段再插入 LoRA用较小学习率微调注意力层。阶段切换时验证集 F1 会有一个小幅跳升这是 LoRA 开始生效的迹象。5.5 训练与推理的预处理不一致现象训练时验证集表现不错部署到实际场景跑同一段音频结果完全不对。原因把数据加载、重采样、归一化的逻辑放进了训练脚本内部导出模型时没有带上预处理函数。推理时直接用soundfile.read读音频采样率、强度和训练时完全不同。解决把预处理流程封装成独立函数训练和推理共用同一个模块。导出的模型文件里同时保存预处理的配置参数采样率、最大时长、tokenizer 名称加载模型时读取配置强制显卡上的数据流和训练时走完全相同的路径。从那以后我每次部署都先跑一遍“训练集里抽一条样本 → 推理 → 对比训练时输出”的回归测试避免这类低级错误。6. 进阶验证技巧量化模态贡献度才知道该往哪调模型跑通只是第一步真正要回答的问题是语音和文本到底谁对最后的结果贡献大这个问题不搞清楚后续优化就是盲人摸象——加数据不知道加哪边的改模型不知道改哪部分。最直接的工具是消融实验。跑三个模型只用语音、只用文本、双模态融合。对比它们在同一个测试集上的 F1一眼就能看出模态的边际贡献。如果双模态比单模态最好成绩只高不到 2 个百分点说明融合设计有问题——可能是门控权重塌缩到了某一侧如果语音单模态和文本单模态差距悬殊说明弱模态的特征提取质量差需要换编码器或加数据增强而不是调融合层。第二个验证手段是分析门控权重。训练好的模型里把每个样本的门控权重值存下来按情感类别分组统计。如果负面样本的语音权重普遍高于文本权重说明情绪主要靠语气表达如果正面样本的文本权重更高说明语义信息主导。这个分析能给你优化方向的硬证据哪个类识别不好先看它的模态权重分布再针对性地收集对应模态的数据。import numpy as np from sklearn.metrics import confusion_matrix def analyze_gate_weights(model, dataloader, device): model.eval() gate_weights [] true_labels [] pred_labels [] with torch.no_grad(): for batch in dataloader: audio_inputs batch[audio].to(device) text_inputs {k: v.to(device) for k, v in batch[text].items()} labels batch[label].cpu().numpy() audio_feat, text_feat model.encode(audio_inputs, text_inputs) concat_feat torch.cat([audio_feat, text_feat], dim-1) gate model.fusion.gate(concat_feat).cpu().numpy() logits model.fusion(concat_feat).cpu().numpy() gate_weights.extend(gate[:, 0]) # 语音侧权重 true_labels.extend(labels) pred_labels.extend(np.argmax(logits, axis1)) cm confusion_matrix(true_labels, pred_labels) return gate_weights, cm很多情感数据集里“愤怒”这个类最难分因为有人愤怒时语气很冲有人愤怒时反而压低声音。这种情况下你会在门控权重分析里发现被误判为“中性”的愤怒样本文本权重普遍高而语音权重低。这提示你这一类样本的区分度主要藏在语调里应该给语音侧加更细粒度的数据增强——比如变速不变调、音量随机扰动——让模型学会从不同强度的语调里提取愤怒信号。项目做到这个深度就不是“跑通模型”的级别了而是能解释模型为什么对某些样本犯错。这比我第一次跑多模态时只知道看准确率高了不少。从那以后我每换一个数据集都强制先跑一次单模态基线、存一份门控权重分布再谈改进。希望帮到你。本文还有配套的精品资源点击获取
返回列表