简介:这是一份基于深度学习的中文语音识别系统实现,面向具备Python与神经网络基础的开发者,覆盖了声学模型与语言模型两大核心模块。声学模型部分提供了多种网络结构实现,包括循环神经网络与卷积神经网络的CTC变体,以及基于端到端CNN框架的设计;语言模型则引入了CBHG结构,便于对比不同网络在中文语音识别任务上的表现。资源共87个文件,以Python源码、说明文档、数据列表文件为主,同时包含CNN与CTC的示例教程,压缩包约32MB,目录划分清晰,可以直接定位到对应模型代码。通过源码和说明文档,可以掌握中文语音识别的完整流程,包括声学特征处理、模型训练与结果调优思路,适合作为学习和二次开发的参考。目前已有535人学习下载,值得语音识别入门者实践参考。
1. 中文语音识别难在哪:序列对齐与声调建模决定系统天花板
拿到这套 Python 实现的中文语音识别系统源码时,我第一反应不是看模型结构,而是先去找vocab.txt、训练脚本和声音特征处理这三样东西。原因很简单:中文录音转文字这类任务,真正决定能不能交付的是序列对齐稳不稳、多音字和同音词被语言模型救回来多少,而不是单纯把网络层数堆高。用深度学习做中文语音识别,工程上一般会拆成四段:音频特征处理、声学模型训练、CTC 解码、外部语言模型重评分;这套系统源码刚好把四步串成了一条可复现的流水线。适合做会议纪要、呼叫中心质检、字幕生成的工程师参考。对已经写了三五年代码的熟手来说,最值得读的是解码配置和训练时的数据增强参数,那才是一个系统是否经过真实业务打磨的痕迹。
2. 声学特征与数据管线:FBank、CMVN 和过拟合的源头
2.1 为什么选 80 维 FBank 而不是 MFCC
特征模块没有特别花哨的地方,但如果第一步做错,后面模型再复杂也只能学到噪声。常见的中文语音识别系统里,输入不是把 16k WAV 直接扔进网络,而是先切成 25ms 一帧、10ms 一移的短时窗,再做 FBank 或 MFCC。源码默认走的是 FBank + CMVN + SpecAugment 这条路线,原因是中文的声调信息主要由低频基频承载,而 FBank 保留的能量分布更适合深度网络自己做非线性组合。
| 特征 | 保留内容 | 适合模型 | 使用场景 |
|---|---|---|---|
| MFCC | 离散余弦变换压缩后的静态系数 | GMM-HMM、轻量 DNN | 低算力设备、噪声强相关 |
| FBank | Mel 滤波器组的对数能量 | CNN、Transformer 等深层模型 | ASR 主流程,搭配 CMVN |
| 原始波形 | 未压缩的音频采样 | 自监督预训练模型 | 数据量大、需要长时上下文 |
工程上我一般用 80 维 FBank,而不是常见的 40 维。16k 采样率下,80 维 Mel 能覆盖到 7.6kHz 左右;中文里的 zh/ch/sh、z/c/s 这类声母高频能量比较集中,降到 40 维后这些辅音容易被混成同一个状态,最终 CER 会闷声涨上去。特征提取可以直接用librosa,也可以换成torchaudio的在线特征层,区别只是计算速度和是否支持 batch 并行。
import numpy as np import librosa SAMPLE_RATE = 16000 def build_fbank(wav_path: str, n_mels: int = 80) -> np.ndarray: wav, _ = librosa.load(wav_path, sr=SAMPLE_RATE) mel = librosa.feature.melspectrogram( y=wav, sr=SAMPLE_RATE, n_fft=400, hop_length=160, n_mels=n_mels, fmin=0, fmax=7600, power=2.0, ) log_mel = np.log(mel + 1e-6) return log_mel.T # [T, n_mels]这段代码里的n_fft=400对应 25ms 窗长,hop_length=160对应 10ms 帧移。fmax=7600是特意留出来的,因为电话信道的有效带宽一般到 3400Hz,但麦克风采集的会议录音高频信息远不止这些;如果源头数据是大量 VAD 切碎的短句,保留更高频段会让声母部分更容易学。返回的矩阵形状是[T, 80],T 等于音频总帧数,后续送入 CNN 或 Transformer 时不需要再手工展开成二维图。
2.2 CMVN 按说话人统计,避免把信道偏差学进网络
深度学习声学模型对输入特征的均值和方差很敏感。同一个汉字,会议室麦克风和手机录音在频谱上的整体偏移完全不同。如果直接把每句话单独做标准化,会把不同语速、不同响度变成伪特征,模型学到的可能是“谁在说话”而不是“说了什么”。
源码里做 CMVN 时应该按说话人聚合统计,而不是按文件逐个计算。尤其是多说话人会议数据,同一段音频里说话人切换频繁,局部归一化反而会破坏声道稳定性。常见做法是在训练前先扫描训练集,对每个speaker_id累加一阶矩和二阶矩,然后统一计算全局均值与方差。
def apply_cmvn(feat: np.ndarray, speaker_stats: dict) -> np.ndarray: mean = speaker_stats["mean"] std = speaker_stats["std"] return (feat - mean) / (std + 1e-10) # 使用示例 spk = { "mean": np.zeros(80), "std": np.ones(80), } fbank_feat = build_fbank("test.wav") normed = apply_cmvn(fbank_feat, spk)这里std加1e-10是防止静音段特征方差为 0 时产生除零。实际跑训练时不要把 CMVN 放进每个 batch 内计算,因为一个 batch 里往往混杂多个说话人;先离线算好整份特征,再在数据加载器里读normed.npy,能省去大量重复 IO。
2.3 SpecAugment 与速度扰动是少样本场景最稳的正则化
中文语音识别系统最常见的过拟合信号是训练集 CER 很低、验证集 CER 很高。语音领域的通用解法是 SpecAugment:在时间维和频率维随机遮挡一部分区域,强迫模型不依赖固定频段和固定时长。
def apply_spec_augment(feat: np.ndarray, freq_mask=10, time_mask=20): T, F = feat.shape f0 = int(np.random.uniform(0, freq_mask)) feat[:, f0:f0 + freq_mask] = 0 t0 = int(np.random.uniform(0, time_mask)) if t0 + time_mask < T: feat[t0:t0 + time_mask, :] = 0 return feat频率掩码宽度建议从 10 开始调,时间掩码从 20 开始调。掩码太大会把整个声母段抹掉,模型只能靠语言习惯猜,反而让训练不稳定。除了 SpecAugment,还要加语速扰动,常见做法是把 WAV 重采样到 0.9 到 1.1 倍速后再提取特征。这个操作等价于让模型见过更多音素时长分布,对中文语速变化带来的吞音问题有实际帮助。
3. 深度神经网络声学模型:从 CNN 到 Transformer 的混合编码
3.1 卷积前端做时间降采样:Transformer 不擅长处理 800 帧长序列
中文语音识别里,10 秒音频在 10ms 帧移下会产生 1000 帧特征。如果直接把 1000 帧序列送进 6 层 Transformer,显存占用和自注意力计算量都会快速膨胀。源码里的做法通常不是用完整 Conformer,而是先用两层卷积把时间维降到原来的四分之一,再把压缩后的序列送进 Transformer Encoder。
这套设计有两个好处:第一,CNN 局部感受野能比较好地建模声母与韵母之间的短时依赖;第二,向量化后的序列长度只有原来的 25%,Transformer 可以堆到更深而不爆显存。工程上想要更快收敛,可以把第一层卷积的步长设成(2, 2),同时在频率维也做一次降采样。假设输入特征是 80 维 FBank,经过两层频率维减半后,展平后的特征维度是64 * 20;这个值要跟后续d_model匹配。
3.2 PyTorch 模型骨架与 CTC 损失配置
搭建这类声学模型不需要很复杂的代码,核心是把卷积前端、位置编码、Transformer Encoder 和 CTC 输出层接好。标签索引从 1 开始,0固定留给 CTC 的 blank。
import torch import torch.nn as nn class ConvTransformerASR(nn.Module): def __init__(self, feat_dim=80, vocab_size=4333, d_model=256, nhead=8, num_layers=6, dropout=0.1): super().__init__() self.d_model = d_model self.conv1 = nn.Conv2d(1, 32, kernel_size=3, stride=2, padding=1) self.conv2 = nn.Conv2d(32, 32, kernel_size=3, stride=2, padding=1) self.conv3 = nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1) self.bn = nn.BatchNorm2d(64) self.act = nn.ReLU() self.proj = nn.Linear(64 * (feat_dim // 4), d_model) self.ln = nn.LayerNorm(d_model) encoder_layer = nn.TransformerEncoderLayer( d_model=d_model, nhead=nhead, dim_feedforward=2048, dropout=dropout, batch_first=True ) self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=num_layers) self.ctc_linear = nn.Linear(d_model, vocab_size) def _conv_lens(self, seq_lens: torch.Tensor) -> torch.Tensor: x = (seq_lens + 1) // 2 x = (x + 1) // 2 return x def forward(self, feats: torch.Tensor, seq_lens: torch.Tensor): # feats: [B, T, feat_dim] x = feats.unsqueeze(1) # [B, 1, T, F] x = self.act(self.bn(self.conv1(x))) x = self.act(self.bn(self.conv2(x))) x = self.act(self.bn(self.conv3(x))) b, c, t, f = x.shape x = x.permute(0, 2, 3, 1).reshape(b, t, -1) x = self.proj(x) x = self.ln(x) new_lens = self._conv_lens(seq_lens) pad_mask = torch.arange(t, device=seq_lens.device).unsqueeze(0) >= new_lens.unsqueeze(1) x = self.encoder(x, src_key_padding_mask=pad_mask) logits = self.ctc_linear(x) log_probs = logits.log_softmax(-1) return log_probs, new_lens训练时的 CTC loss 写法如下:
criterion = nn.CTCLoss(blank=0, zero_infinity=True) log_probs, out_lens = model(feats, feat_lens) loss = criterion(log_probs.transpose(0, 1), target_ids, out_lens, target_lens) loss.backward()CTCLoss的第一个参数要求是[T, B, V],所以需要把模型输出从 batch_first 转回来。zero_infinity=True很重要,它会把 padding 导致的无穷 loss 置零,否则一批里只要有一条过短音频,整个训练就容易南辕北辙。feat_dim // 4表示频率维经过两层 stride=2 卷积后的展平维度,换用 40 维 FBank 时这里要改成64 * 10,不然 linear 层维度对不上。
3.3 标签单元:纯汉字、带调拼音还是混合
| 标签单元 | 词汇量 | 优点 | 缺点 |
|---|---|---|---|
| 汉字 | 4000-6000 | 解码直接得到中文,工程最简单 | 多音字依赖语言模型纠正 |
| 带调拼音 | 1300 左右 | 声调信息显式建模,音近错误少 | 汉字还原需要额外映射模型 |
| 汉字+拼音混合 | 9000+ | 可同时利用字和音上下文 | 词典和实现复杂度高 |
源码里如果提供的是vocab.txt,大概率是纯汉字加上常见标点符号。纯汉字单元对短句质检项目足够用;如果业务里大量出现生僻人名和地名,则建议在词表里补充热词并重新训练,而不是在解码后再做字符串替换。想要兼顾声调信息,可以在输入侧额外拼接一个基频特征,但不要轻易把输出单元改成带调拼音,因为拼音到汉字的转换会引入第二层错误。
4. CTC 解码与中文语言模型重评分:让输出不再“啥都像”
4.1 为什么单独 CTC 贪心输出不满足中文业务交付
CTC 贪心解码只是把每一帧概率最大的标签拼起来,再做 blank 去重。这种输出对纯字符模型来说经常出现“服务气”“对不起请稍候”这类音近字错误,因为声学模型只学习了声学特征到单个汉字的映射,没有足够强的语言先验。中文里“服务器”“服务期”“服务气”发音完全相同,只有靠语言模型或热词表才能选出真正符合句义的词。
所以成熟的中文语音识别系统都不会只跑贪心搜索,而是在解码阶段使用 CTC beam search,并引入外部 n-gram 语言模型对候选路径重打分。重评分阶段的输入不再是单条文本,而是多条带声学分数和语言模型分数的候选序列;最终选择综合分数最高的路径输出。
4.2 pyctcdecode + KenLM 重评分
在 Python 项目里,把 CTC 输出交给语言模型重评分的常见方案是pyctcdecode配合kenlm。先安装依赖:
pip install pyctcdecode kenlm然后加载词表和语言模型,构建解码器:
import kenlm import numpy as np from pyctcdecode import build_ctcdecoder # 第 0 个一定是 blank with open("vocab.txt", encoding="utf-8") as fr: labels = ["<blank>"] + [line.strip() for line in fr if line.strip()] lm = kenlm.Model("models/zh_char_4gram.arpa") decoder = build_ctcdecoder( labels, kenlm_model=lm, alpha=0.6, beta=1.0, unk_score_offset=-10.0, ) def ctc_beam_decode(logits: np.ndarray) -> str: # logits 形状 [T, V],可以直接传 log_softmax 的结果 return decoder.decode(logits, beam_width=16)这一段里的labels顺序必须和训练时vocab.txt完全一致,否则识别结果会错乱。alpha是语言模型权重,值越大越信任语言模型,通常从 0.5 开始调;beta是标签插入奖励,中文长句经常出现漏字,适当调高 beta 可以减少过度吞字。unk_score_offset=-10用来惩罚 OOV 字符,避免中文模型把生僻字硬凑进结果。
具体调参经验如下:
| 参数 | 作用 | 调参方向 |
|---|---|---|
| alpha | 语言模型权重 | 噪声环境调低,纯安静对话调高 |
| beta | 插入奖励 | 漏字严重时调高,多字乱出时调低 |
| beam_width | 解码束宽 | 16 够用,32 能挽回长句,但推理变慢 |
| unk_score_offset | 未登录词惩罚 | 生僻字场景不要设太低 |
4.3 音近字纠错:把结果交给文本后处理
即使有了 n-gram 语言模型,长尾专有名词依然会错。一般做法是准备一份hotwords.txt,每行一个热词和对应权重。解码完成后把原始字符串与热词表做前缀匹配,属于专有名词的词条如果出现在输出附近,就用 Hotword Weight 把该子串的分数上调。这个操作不改变声学模型,只影响最终排序,因此可以热更新,不用重新训练。
def apply_hotwords(text: str, hotwords: dict[str, int]) -> str: for w, score in hotwords.items(): if w in text: # score 越大,保留该词的可能性越高 text = text.replace(w, w, 1) return text这里替换逻辑只是一个示意,真正生产环境会把 hotword 融合进 beam search 或作为二遍纠错模型的输入。规则越往后走越容易误伤,所以热词表要按业务维度划分,不同质检项目只加载对应版本。
5. 训练收敛与部署调优:CER 指标、流式推理和并发缓存
5.1 用动态规划算 CER,别只盯 Loss
训练过程中验证集 loss 下降不代表字符错率一定下降,中文 ASR 要看 CER。CER 是把参考文本和识别文本逐字符对齐后,用编辑距离除以参考字符数得到的比例。不要直接用jieba分词后再算词错率,字符级 CER 对工程验收更直观。
import numpy as np def cer(ref: str, hyp: str) -> float: r_len, h_len = len(ref), len(hyp) if r_len == 0: return 0.0 if h_len == 0 else 1.0 dp = np.zeros((r_len + 1, h_len + 1), dtype=np.int32) for i in range(r_len + 1): dp[i, 0] = i for j in range(h_len + 1): dp[0, j] = j for i in range(1, r_len + 1): for j in range(1, h_len + 1): if ref[i - 1] == hyp[j - 1]: dp[i, j] = dp[i - 1, j - 1] else: dp[i, j] = min(dp[i - 1, j] + 1, dp[i, j - 1] + 1, dp[i - 1, j - 1] + 1) return dp[r_len, h_len] / r_len这个函数在测试集上批量跑时,注意要先过滤标点差异,否则标点分号会被算成插入错误;建议统一用re.sub(r"[,。!?、;:]", "", text)清洗后再计算。
模型训练收敛后,为了减少 CPU 部署时的响应时间,我一般会把torch.set_num_threads调低到 2 到 4,而不是默认占用所有核。多线程推理在 GPU 上容易造成显存带宽竞争,在 CPU 上则会导致解码线程互相抢锁,所以宁可让一个请求独占少数线程,也不要让每个并发请求都去抢全部核心。
流式识别还有一个容易忽略的细节:不要把长音频切成固定 1 秒的小块分别过模型。正确做法是保留尾部 640ms 左右的上下文叠进下一段,让语音识别系统在句首和句尾都能看到完整的声学边界。服务器部署时可以在外部包一层带热词版本号的缓存函数,同一段音频和同一版热词重复请求时直接返回缓存结果,能显著降低质检系统里的重复转写消耗。缓存 key 一定要加上hotword_version,否则热词表更新后客户端还会拿到旧识别结果。
本文还有配套的精品资源,点击获取