ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

深度学习中文语音识别:PyTorch从特征提取到CTC解码

深度学习中文语音识别:PyTorch从特征提取到CTC解码 简介一套基于深度学习的中文语音识别系统源码与说明文档面向希望从声学模型和语言模型两条主线入手学习语音识别的Python开发者。系统包含GRU-CTC、CNN-CTC以及基于DFCNN框架改进的inception卷积结构等多种声学模型实现并移植了CBHG结构作为神经网络语言模型覆盖从特征提取、数据准备到模型训练与调参的主要环节适合高校课程设计、毕业设计或入门级项目参考。压缩包共88个文件以30个py源码、30个txt文本说明和22个lst列表文件为主另有tsv、pkl等辅助数据整体大小约34.55MB目录按声学模型、语言模型、数据处理等模块清晰组织。已有188人学习下载可帮助读者快速搭建实验环境理解端到端中文语音识别的网络结构与代码细节。1. 中文语音识别系统自己搭一套更值得GitHub 上标着高 Star 的中文语音识别仓库很多但真正 clone 下来跑一遍能在自己业务数据上保持精度的少。基于深度学习的系统听起来唬人拆开看其实只有三条主线原始音频怎么变成特征特征怎么映射成文字概率以及概率序列怎么解码出通顺的汉字。用 Python 实现这套流程时绝大多数项目的源码结构都逃不出这三个模块。与其拿到 zip 包就跑 demo不如按我平时搭系统的顺序把源码理顺先弄懂特征和模型设计的取舍再把训练、推理、评估拆开验证最后落到长音频处理这种常见场景。适合做语音产品开发的工程师也适合拿语音识别做毕业设计、想把开源模型改造成自用系统的同学。2. 中文语音识别原理声学模型、文本单元与特征流2.1 中文 ASR 的任务划分从音频到汉字序列中文语音识别本质上是一个序列到序列的映射问题一端是 16kHz 采样的音频波形另一端是汉字字符串。和英文不同中文没有天然的词边界歧义切分很常见而且多音字多、口音变化大所以任务通常被拆成两层声学层面解决“这段音对应哪些声学单元”语言层面解决“这些声学单元拼接成哪些字更合理”。传统方案 GMM-HMM 需要为每个状态单独建模流程长、调参重。深度学习方案把这个流程压缩成“声学模型直接输出字或拼音的概率”语言模型单独做或者干脆也交给神经网络。这种端到端设计减少了中间错误传递也让源码结构更清晰数据处理、模型定义、训练循环、解码验证各自独立。2.2 深度学习模型如何做时序对齐CTC 与 Attention音频帧数和汉字数量天然不对等。一个“好”字在语速慢时可能横跨 50 帧语速快时十几帧就结束了。训练时拿不到“第几帧对应哪个字”的精确标注模型就得自己学会对齐。这里有两种主流机制CTCConnectionist Temporal Classification的思路是让模型每个时刻输出一个字符或空符允许连续重复出现。训练时用动态规划把所有可能对齐方式的概率都加起来因此不需要逐帧标注。推理时把连续重复的字符合并、删掉空符就能得到识别结果。这套机制在中文识别里依然常见原因是实现简单、收敛稳定对标注没有额外要求。Attention 机制则不同。解码器每生成一个字符通过注意力权重到编码器输出的不同帧上“取数”直接建模了输出字符和输入帧之间的对齐关系。理论上比 CTC 更灵活但数据量不够时容易对齐不稳定实际项目里常用 CTC 和 Attention 联合训练用 CTC 损失帮助注意力快速找到合适的位置。源码里如果看到损失函数是两个交叉熵相加多半就是这种结构。2.3 特征提取16kHz 采样、Fbank 与 MFCC 的取舍语音识别输入端几乎不会直接用原始波形而是用短时傅里叶变换把每一帧转成频谱再压缩到人耳感知范围。参数选择直接影响后面的模型结构。参数常用值为什么是这个值采样率16 kHz中文语音的基频和大部分共振峰集中在 8kHz 以下16k 已能覆盖主要信息帧长25 ms短于 20ms 频率分辨率不足长于 30ms 帧内信号平稳性变差帧移10 ms相邻帧有 60% 重叠能捕捉音节间的过渡变化窗函数Hamming 窗主瓣宽度和旁瓣衰减均衡是最通用的选择Mel 滤波组数80端到端模型的常用设置兼顾分辨率和计算量特征类型log-Mel Fbank保留更多高频细节MFCC 的 DCT 反而损失了相关性信息MFCC 是传统 GMM 时代的标配它把 Mel 谱再压缩成倒谱系数去掉了相关性。到了深度学习时代神经网络自己能学会特征之间的关系Fbank 这种保留更多信息的特征反而更好用。源码里如果看到计算流程是waveform - stft - mel - log而不是- dct说明作者选择了更适合端到端模型的方案。2.4 中文文本单元字符还是子词中文识别模型的输出层要么是汉字字符要么是拼音要么是 BPE 子词。字符集最直接覆盖常用字约 4000 到 6000 个拼音集只有 400 多个声韵母组合大大降低输出维度但后续要多一步把拼音转汉字这一步引入的歧义需要语言模型处理。BPE 适合大语料场景能把低频字拆成更小的片段避免稀疏问题。构造字符映射表时要注意 CTC 损失约定blank0也就是空符必须占索引 0。字符串的长度也需要单独记录因为同一批次的音频长度不一样送入 CTC 损失时要分别告诉它每条样本的有效帧数。构造中文字符映射表的示例def build_tokenizer(texts, add_blankTrue): chars set() for line in texts: chars.update(line) token2idx {ch: i 1 for i, ch in enumerate(sorted(chars))} if add_blank: token2idx[blk] 0 return token2idx # 用法texts 是标注文件中的每一行已去掉空格和标点 # token2idx build_tokenizer(open(labels.txt, encodingutf-8).read().splitlines())这段代码把所有出现过的汉字去重后排序从 1 开始编号把 0 留给空符。排序保证了多次运行结果一致不会因为集合遍历顺序不同导致映射变化。写入模型配置时还要单独保存一个idx2token的反向表推理时把模型输出的索引还原成汉字。3. 用 PyTorch 搭建最小可运行的识别管线3.1 项目结构先读 README 和配置文件再动手拿到一个语音识别项目的 zip 包先不要急着执行python train.py。我一般会先看两样东西README 里写的 Python 版本和依赖清单以及配置文件里的音频参数和语料路径。深度学习项目跑不起来八成是依赖冲突或路径写死而不是模型代码本身的问题。常见的项目目录一般长这样asr_project/ ├── configs/ │ └── base.yaml # 训练参数、特征参数、路径配置 ├── data/ │ ├── train/ │ │ ├── wav/ # 原始音频 │ │ └── transcript.txt # 标注文本一行对应一个音频 │ └── dev/ ├── models/ │ ├── encoder.py # 编码器CNN RNN 或 Transformer │ └── decoder.py # 解码器CTC 头或注意力解码器 ├── trainer.py # 训练循环 ├── inference.py # 推理脚本 └── utils/ ├── audio.py # 音频加载和特征提取 └── text.py # 文本编码和转换配置文件里需要关心的参数主要是采样率、帧长、帧移、Mel 维度和输出类别数。看到frame_length400时要能算出它对应 25ms400 / 16000因为很多项目注释不写物理单位只写采样点数值。3.2 用 torchaudio 把音频变成特征张量音频加载统一用torchaudio实现它会自动处理解码格式差异。关键点是保证重采样到目标采样率否则特征提取的参数含义会全部跑偏。import torchaudio def load_audio(path, target_sr16000): waveform, sr torchaudio.load(path) if sr ! target_sr: resampler torchaudio.transforms.Resample(sr, target_sr) waveform resampler(waveform) return waveform.squeeze(0) # [1, T] - [T] def make_fbank(waveform, n_mels80): fbank torchaudio.compliance.kaldi.fbank( waveform.unsqueeze(0), num_mel_binsn_mels, frame_length400, frame_shift160, ) return fbank # [frames, n_mels]frame_length400对应 25msframe_shift160对应 10ms这两个值和第 2 章的参数表一一对应。load_audio返回的是 1D 张量所以传入fbank时需要先unsqueeze补上通道维。torchaudio 的fbank底层调用的是 Kaldi 兼容实现和 Kaldi 的特征计算方式几乎一致后续如果换成其他特征提取器结果差异也会很小。3.3 模型主体CNN 加双向 LSTM 的常见组合轻量级中文识别模型最常见的骨干结构是前几层用 CNN 做局部特征抽取和降采样后面接双向 LSTM 建模时序依赖最后接一个线性层把隐状态映射到字符空间。CNN 降采样能压缩序列长度减少 LSTM 的计算量。import torch import torch.nn as nn class ConvLSTMEncoder(nn.Module): def __init__(self, n_mels80, hidden_size256, num_layers3, num_classes4334): super().__init__() self.conv nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.ReLU(), nn.BatchNorm2d(32), nn.Conv2d(32, 32, kernel_size3, padding1), nn.ReLU(), nn.BatchNorm2d(32), nn.MaxPool2d((2, 2)), ) self.rnn nn.LSTM( input_sizen_mels // 2 * 32, hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, ) self.linear nn.Linear(hidden_size * 2, num_classes) def forward(self, x): # x: [B, T, n_mels] x x.unsqueeze(1) x self.conv(x) b, c, t, f x.shape x x.permute(0, 2, 3, 1).reshape(b, t, c * f) x, _ self.rnn(x) return self.linear(x) # [B, T, num_classes]输入是[B, T, n_mels]的特征序列先补一个通道维变成[B, 1, T, n_mels]经过两层卷积加一个MaxPool2d((2,2))后时间维和频率维都减半。permute和reshape的目的是把卷积输出从[B, C, T, F]转成[B, T, C*F]这样才能送入 LSTM。双向 LSTM 输出的hidden_size * 2是因为正反两个方向的隐状态拼在了一起。3.4 训练循环与 CTC 损失训练循环里最容易被忽略的是长度信息的传递。CTC 损失需要三条对齐信息模型输出的时间长度input_lengths、每个标注的真实长度target_lengths以及空符索引blank。少了任何一条都会直接报错或训练发散。import torch import torch.nn.functional as F def train_one_epoch(model, loader, optimizer, device): model.train() total_loss 0.0 for feats, texts in loader: feats feats.to(device) logits model(feats) # [B, T, V] T logits.size(1) log_probs logits.log_softmax(dim-1).transpose(0, 1) # [T, B, V] input_lengths torch.full( (feats.size(0),), T, dtypetorch.long ) target_lengths torch.tensor( [len(t) for t in texts], dtypetorch.long ) loss F.ctc_loss( log_probs, texts, input_lengths, target_lengths, blank0, ) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(loader)transpose(0, 1)把输出从[B, T, V]转成[T, B, V]这是 PyTorch CTC 的输入格式要求。input_lengths全部取T适用于没有做额外截断的批处理。如果将来加入打包或是动态批处理这个值必须改成每条样本的真实帧数否则损失会算错。blank0与第 2 章构造映射表时的约定保持一致。3.5 推理贪心解码与拼音转汉字训练完成后推理阶段只需要取每个时间步概率最大的索引再做合并去重。这叫贪心解码速度最快但没有利用上下文信息适合先验证模型是否训练起来了。def greedy_decode(logits): blank 0 pred torch.argmax(logits, dim-1).tolist() result [] prev blank for token in pred: if token ! prev: if token ! blank: result.append(token) prev token return result # 输出的是索引序列 # 用 idx2token 映射成汉字 # chars .join(idx2token[i] for i in result)合并规则是跳过连续重复的索引同时把空符丢弃。注意这里的重复合并只在相邻索引之间判断因为 CTC 认为同一个字连续输出多次才是重复中间插了空符就不合并。如果模型的输出单元是拼音还需要再接一层字符到拼音的映射或一个短文本纠错模型。这一步在源码项目中通常单独放在language_model/目录里不在声学模型的推理链路中。4. 训练与调优CER 指标、学习率与数据增强4.1 用字符错误率衡量中文识别质量英文语音识别常用 WER词错误率中文没有天然分词边界用词错误率会引入分词器的额外误差。更稳妥的指标是 CER字符错误率直接以字为单位计算编辑距离。CER 低于 10% 属于可用水平低于 5% 基本接近商用场景注意这里是针对清晰朗读语料嘈杂环境下要求会放宽。import Levenshtein def cer(hyp, ref): if len(ref) 0: return 1.0 return Levenshtein.distance(hyp, ref) / len(ref) # 示例hyp我今天吃饭 ref我今天去吃饭 # distance1插入一个去CER1/6≈0.167Levenshtein 库计算的是编辑距离也就是把一个字符串变成另一个字符串需要的最少插入、删除、替换次数。除以len(ref)是为了归一化让不同长度句子之间的误差率可比。计算前要把标点和空格全部去除不然标点变动会直接拉高 CER掩盖真实的识别质量。4.2 学习率与优化器选择深度学习语音识别的训练曲线对学习率很敏感。学习率太大CTC 损失会在前几个 epoch 直接发散太小模型要跑很久才能收敛。常见的做法是配合热身warmup策略先用小学习率跑几百步让 BatchNorm 统计量稳定下来再逐步提升。阶段学习率设置说明热身前 5% 步数1e-6 线性升至 1e-3避免初始阶段梯度震荡主训练阶段1e-3搭配 AdamW权重衰减设 1e-4后期衰减每 3 个 epoch 乘以 0.8让模型在后半程更精细地收敛from torch.optim.lr_scheduler import LambdaLR warmup_steps 1000 def lr_lambda(step): if step warmup_steps: return (step 1) / warmup_steps return 0.8 ** (step // 3000) scheduler LambdaLR(optimizer, lr_lambdalr_lambda)lr_lambda返回的是乘法因子最终学习率等于初始学习率乘上这个因子。前 1000 步从 0 线性爬到 1.0之后每 3000 步打八折。曲线的含义是刚开始模型权重是随机的卷积层在热身阶段学到的梯度方向不稳定这时候用高学习率容易被冲出起点附近等损失掉到 1 以下再逐步降低学习率模型才有机会进入更平坦的局部最优。4.3 SpecAugment 数据增强中文语音识别最直接有效的数据增强是 SpecAugment直接在特征图上做时间掩蔽和频率掩蔽。它的动机是让模型在缺失部分时间或频率信息时依然能识别从而提升泛化能力。实现简单训练时随机执行即可。import random class SpecAugment: def __init__(self, freq_mask15, time_mask40, p0.5): self.freq_mask freq_mask self.time_mask time_mask self.p p def __call__(self, feats): # feats: [T, F] if random.random() self.p: return feats feats feats.clone() f random.randint(0, self.freq_mask) f0 random.randint(0, feats.size(1) - f - 1) feats[:, f0:f0 f] 0 t random.randint(0, self.time_mask) t0 random.randint(0, feats.size(0) - t - 1) feats[t0:t0 t, :] 0 return feats频率掩蔽参数设为 15表示最多掩盖 15 个 Mel 频带时间掩蔽设为 40表示最多掩盖 40 帧也就是 400ms。掩蔽值置为零等价于将这段信息置为静音模型需要从周围上下文补齐。这里的p0.5表示一半概率执行增强实际项目中数据量大时可以调高到 0.8。4.4 从源码角度最容易被忽略的几个坑第一数据加载时的音频长度对齐。PyTorch 的DataLoader默认按 batch 内最长那条来 pad但 pad 的部分会被模型计算成不存在的输出帧。要么用torch.nn.utils.rnn.pack_padded_sequence打包要么手动记录真实帧数在计算 CTC 损失时传input_lengths。很多项目源码里明明写了input_lengths却在数据加载阶段传了 pad 后的长度导致损失偏低识别结果却很差。第二空白符索引冲突。有些开源项目把sos、eos等特殊符号直接放在 0 号位CTC 的blank又默认是 0两个符号共用同一个索引训练过程不会报错但推理时解码结果里会出现无法映射的符号。检查方法是看tokenizer的构建代码里特殊符号是否被单独处理。第三训练集和验证集的数据泄漏。如果一个项目的数据划分脚本里没有按说话人 ID 去重同一说话人的不同音频可能会同时出现在训练集和验证集里CER 指标看起来很低上线后面对新说话人效果立刻下滑。验证代码时要检查划分逻辑是不是按目录名或 ID 字段分组而不是随机切割。5. 把系统用起来端点检测、流式识别与长音频切割实际场景中用户上传的音频往往不是干净的“一句一文件”而是一段几分钟的录音中间夹杂大量静音和停顿。直接把整段音频送进模型不仅计算浪费模型还会在静音片段时产生幻觉输出。最常见的做法是先做端点检测VAD把长音频切成多个句子再逐句识别。import webrtcvad vad webrtcvad.Vad(2) def segment_audio(waveform, sr16000, frame_ms30): frame_len int(sr * frame_ms / 1000) frames [] for start in range(0, len(waveform) - frame_len, frame_len): frame waveform[start:start frame_len] frames.append(frame) speech_flags [ vad.is_speech(frame.numpy().tobytes(), sr) for frame in frames ] segments [] in_speech False for i, flag in enumerate(speech_flags): if flag and not in_speech: start i * frame_len in_speech True elif not flag and in_speech: end i * frame_len if end - start sr: # 过滤短于 1 秒的噪声片段 segments.append((start, end)) in_speech False return segmentswebrtcvad的is_speech只接受 10ms、20ms 或 30ms 的帧这里统一取 30ms。Vad 的聚合模式设为 2表示灵敏度适中既不会把轻微呼吸声当成语音也不会把轻声说话漏掉。识别前再统计一下每个切片的时长长于 10 秒的片段需要再次细分因为模型训练时很少见到超长序列直接推理容易丢失注意力位置。流式识别的思路也建立在切分之上。不需要一次性等到整段录音结束而是每次取 320ms 的音频做特征提取模型在已累积的特征序列上继续推理。对这种增量推理模型结构上要小心双向 LSTM 会看到未来信息不适合流式场景我一般会要求项目源码里使用单向 LSTM或者干脆用因果卷积。如果手里的项目是双向 LSTM那流式化时只能妥协为“整句切分后一次性识别”先确认 VAD 的切分质量再决定要不要重构模型。长音频切割后不要忽略前后重叠。相邻两个片段之间留 300ms 的重叠区识别完成后用后处理脚本去掉重复字符。计算 CER 时也要把所有片段的识别结果拼接后再和参考文本比对按片段分别计算再平均会损失跨句边界信息导致指标偏高。按这套流程把“长音频识别”拆成“VAD 切割 短句识别 结果拼接”整个系统上线的难度会低很多出问题时也能精确排查是切错了还是认错了。本文还有配套的精品资源点击获取
返回列表