中文语音识别系统全链路:Conformer+CTC训练、解码与部署避坑

发布时间:2026/10/30 13:06:00
中文语音识别系统全链路:Conformer+CTC训练、解码与部署避坑 简介面向希望系统掌握中文语音识别工程实现的学习者与开发者这个深度学习实战项目以Keras和TensorFlow为基础串联卷积网络特征提取、长短时记忆网络序列建模、注意力机制与连接时序分类覆盖语音采样、梅尔频率倒谱系数处理、模型搭建、训练评估及语音命令实时识别等场景。压缩包共47个文件约6.5MB以24个Python脚本承载模型定义、数据处理、训练测试与服务端逻辑辅以14个txt词典与语料说明、3个md文档、3个lst数据列表及license、yml等配置目录按数据、模型、语言模型和测试模块组织便于按环节查阅复现。目前已有1363人学习下载适合具备Python与深度学习基础、希望梳理中文语音识别工程结构的人群。读者可理解卷积网络、循环网络、注意力与连接时序分类的组合思路参考数据预处理、语言模型、多模型版本和测试客户端等模块搭建实验基线并排查训练流程中的常见问题。1. 三秒录音变一行汉字:这套中文语音识别系统的真实定位客服中心每天沉下来几千段录音,人工抽检只能覆盖百分之二,剩下的躺在磁盘里没人听。中文语音识别系统要干的活,就是把这些音频批量转成可检索的文本,顺带解决会议纪要、字幕生成、语音指令这几类需求。这套基于深度学习的中文语音识别系统不是去调一个云端接口,而是把声学模型、解码器、语言模型的整条链路摊开在本地,训练和推理都握在自己手里。适合两类人:一类是想把语音识别吃透、手上已经有几十到上千小时标注数据要自己训模型的算法同学;一类是产品已经跑起来、想离线部署或者做领域微调的后端和端侧工程师。硬件门槛不低,训练阶段一张 24G 显存的卡是起步价,推理反而轻,十几秒的音频在普通 CPU 上也能做到准实时。2. 系统拆解:FBank 到汉字之间的四段流水线整套系统如果只用一句话概括,就是「波形 → 特征 → 声学模型 → 解码」这四段。每一段都有独立的失败模式,分开看才排得清问题。我在拆这类项目时习惯先看目录,再看模型主干,最后才看解码器,因为解码器出问题往往表现为「模型明明收敛了但输出一坨乱码」,而目录和主干决定了这个模型能不能被你改动。2.1 目录结构:训练、解码、部署拆成三块一个能长期维护的语音识别工程,不应该把训练脚本和推理脚本混在一个目录里。下面这套结构是我改动最多的版本,收敛到一个仓库里,结构清晰到新人第一天就能找到入口文件:asr-cn/ ├── conf/ │ ├── train_conformer_ctc.yaml # 训练超参、数据路径、日志目录 │ └── decode.yaml # 解码参数:beam、lm_weight、blank_penalty ├── data/ │ ├── manifest/ │ │ ├── train.jsonl # 训练清单,一行一条音频 │ │ ├── dev.jsonl │ │ └── test.jsonl │ └── vocab/ │ └── chars.txt # 字表,第 0 行必须是 blank ├── src/ │ ├── dataset.py # Dataset 动态 batching 的 collate_fn │ ├── features.py # FBank 提取与 CMVN │ ├── model/ │ │ ├── subsampling.py # 卷积下采样 │ │ ├── conformer.py # Conformer Block │ │ └── asr_model.py # 主干拼装,输出 logits │ ├── decoder/ │ │ ├── greedy.py # 贪心解码,调试用 │ │ └── beam.py # 前缀束搜索 可选语言模型 │ ├── metrics.py # CER 计算与文本归一化 │ ├── train.py # 训练入口 │ └── infer.py # 单文件/批量推理入口 ├── scripts/ │ ├── make_manifest.py # 从原始音频目录生成 jsonl │ └── build_vocab.py # 统计字频,生成字表 └── deploy/ ├── export_onnx.py └── server.py # 一个最小的 HTTP 推理服务conf/和src/分开的意义在于:调参不动代码,改代码不动配置。decoder/单独成目录的意义更大,因为解码策略的迭代频率远高于模型结构,贪心解码用来验证模型本身有没有学坏,束搜索用来榨最后那两三个点的准确率,两者必须能随时切换。data/manifest/用 jsonl 而不是 Kaldi 那套wav.scp text utt2spk三件套,是个取舍。Kaldi 风格对老工程更友好,但 jsonl 一条记录能塞下音频路径、转写文本、说话人、时长,读的时候一行json.loads,写 collate 函数时按duration排序做动态 batching 也方便,新手不容易在路径拼接上翻车。2.2 声学主干选型:卷积下采样加 Conformer特征这一层基本没有争议,80 维 FBank,25ms 窗长,10ms 帧移,16kHz 采样。这一帧移意味着 1 秒音频出 100 帧,一段 10 秒的音频就是 1000 帧,直接喂给自注意力,显存和计算量都吃不消。所以第一层必须是下采样,常见的做法是两层 stride2 的二维卷积,把时间维压 4 倍,10 秒音频变成 250 帧,后面的 Conformer 才好跑。主干的选型上,纯 Transformer 的自注意力擅长建模长距离依赖,但语音的局部性很强,相邻几十毫秒的频谱变化才是音素判别的主力;纯 CNN 反过来,局部抓得牢,长距离上下文弱。Conformer 把两者串在一个 Block 里,前面是前馈、中间是自注意力、再跟一个深度可分离卷积模块,最后再来一层前馈,这套结构在中文识别上比纯 Transformer 收敛更稳,尤其是数据量在几百小时这个量级的时候。为什么是 CTC 而不是注意力解码器?因为 CTC 不需要帧级强制对齐,标注成本最低,训练也最稳。代价是 CTC 假设各帧输出条件独立,输出的字之间没有语言层面的依赖,所以 CER 会比对注意力解码器高一截,这部分差距要靠解码阶段的语言模型补回来。这个取舍在后面第 6 章做领域迁移时还会再遇到一次。模型规模上,d_model256、n_head4、num_layers12、卷积核 15,是单卡 24G 能跑起来的配置。想再大就得上梯度累积或者 ZeRO,那是另一个话题了。2.3 解码这一侧:CTC 前缀束搜索与语言模型的位置CTC 的输出是一个(T, V)的矩阵,T 是下采样后的帧数,V 是字表大小(中文常用字 5000 左右足够)。贪心解码就是每帧取 argmax,然后合并重复、去掉 blank,三行代码搞定:def greedy_decode(log_probs, blank0): # log_probs: (T, V),已做 log_softmax ids log_probs.argmax(dim-1).tolist() out, prev [], None for i in ids: if i ! prev and i ! blank: # 合并连续重复 剔除 blank out.append(i) prev i return out这段代码短,但有两处容易写错。第一处是prev的更新位置:必须先判断再更新,如果把prev i写在if里面,连续重复的帧会被重复输出。第二处是 blank 的处理顺序,CTC 的合并规则是「先合并重复,再删 blank」,顺序反了会把a blank a这种本该输出两个a的情况错并成一个。贪心只用于验证模型有没有学会,真实上线走前缀束搜索。束搜索的核心是在每个时间步维护 top-k 个候选前缀,对每个前缀考虑「扩展一个新字」和「保持不动」两种转移,最后按log_p_ctc lm_weight * log_p_lm blank_penalty * len打分排序。中文场景下lm_weight一般给 0.5 到 1.0,blank_penalty给负数用来抑制 blank 过多导致的漏字。语言模型可以是字级 n-gram,也可以是训练好的 Transformer LM,前者加载快、内存小,后者效果好但推理慢,离线批处理选后者,在线服务选前者。一个常被忽略的点:束搜索的宽度不是越大越好。beam10 到 16 是收益拐点,再往上 CER 只降 0.1 个点,延迟却翻倍。这一点在部署时尤其明显。3. 跑通第一步:环境、数据准备与词表构建从零跑到第一个 CER 数字,中间有三个必过的坎:环境依赖版本对不上、数据清单格式写错、特征提取参数训练和推理不一致。第三个坑最隐蔽,往往要等到上线才爆。3.1 环境与最小可跑配置依赖不多,但版本要卡住。PyTorch 2.x 配 torchaudio 是最省事的组合,FBank 提取可以直接调torchaudio.compliance.kaldi,不用额外装 Kaldi:# 建议 Python 3.9 / 3.10,torch 与 torchaudio 版本必须对齐 pip install torch2.1.0 torchaudio2.1.0 --index-url https://download.pytorch.org/whl/cu118 pip install numpy pyyaml tqdm tensorboard # 可选:语言模型用 KenLM 时再装,不影响训练 pip install kenlmtorch和torchaudio版本不对齐是新手最常翻的车,表现是import torchaudio时报undefined symbol,这种报错跟你的代码一点关系都没有,纯粹是两个包的 ABI 不匹配。装的时候一定用同一条命令一次装完,别分开装。单卡 24G 显存,上面这套d_model256 / 12 层的配置,batch_size按帧数算控制在 16000 帧左右(约 160 秒音频),能跑满显存又不至于 OOM。没有卡的话,先用 8 小时数据、4 层模型跑通流程,验证代码没问题再上规模,这个顺序别反。3.2 数据清单:三行文本撑起一次训练每条训练样本在 jsonl 里是这样一行:{utt: spk001_0001, wav: /data/wav/spk001/spk001_0001.wav, text: 今天天气不错, spk: spk001, duration: 3.42}utt是全局唯一的样本 ID,wav用绝对路径最省事,text是已经归一化过的转写,duration用来做动态 batching。文本归一化这一步千万别省:全角转半角、去掉标点、阿拉伯数字转中文读法、英文单词按字母拆,这些处理如果在训练和测试两侧不一致,CER 会莫名其妙地高。我一般会把这套规则固定成一个normalize_text()函数,训练、验证、推理三处都调同一个,不给自己留后悔药。scripts/make_manifest.py的职责就是遍历音频目录、读取同名标注文件、过滤掉时长超过 20 秒和短于 0.3 秒的样本,输出三个 jsonl。长音频会让一个 batch 里 padding 到爆炸,短音频信噪比太低,两头都该砍。3.3 词表构建与 FBank 特征提取中文的字表比英文简单得多,不需要 BPE,直接按字统计词频就行。唯一的硬性要求是第 0 位必须是blank,因为 CTC 的 blank id 默认取 0:# scripts/build_vocab.py import json, collections counter collections.Counter() with open(data/manifest/train.jsonl, encodingutf-8) as f: for line in f: text json.loads(line)[text] counter.update(list(text)) # 中文按字切,不做分词 # blank 固定占 0 号位,后面按字频降序;低频字可以合并成 unk vocab [blank, unk] [c for c, n in counter.most_common() if n 3] with open(data/vocab/chars.txt, w, encodingutf-8) as f: f.write(\n.join(vocab)) print(fvocab size {len(vocab)})字频阈值 3 是个经验值,低于这个数的字在整个语料里基本只出现一两次,留在表里只会稀释 softmax,不如统一映射到unk。中文常用字 3000 到 5000 就能覆盖日常口语 99% 以上,词表开到 8000 以上收益极小,反而拖慢解码。特征提取这一层,训练侧和推理侧必须是同一个函数:# src/features.py import torch, torchaudio.compliance.kaldi as kaldi def extract_fbank(waveform, sample_rate16000, num_mel_bins80): # waveform: (1, N) 的 float32 张量,取值范围 [-1, 1] if sample_rate ! 16000: waveform torchaudio.functional.resample(waveform, sample_rate, 16000) fbank kaldi.fbank( waveform, num_mel_binsnum_mel_bins, frame_length25, # 窗长 25ms frame_shift10, # 帧移 10ms dither0.0, # 推理时必须关掉抖动,否则同一段音频两次结果不同 sample_frequency16000, energy_floor0.0, ) return fbank # (T, 80)dither这个参数是血泪经验:训练时设成 1.0 加一点随机噪声能提升鲁棒性,推理时必须设 0.0,否则同一段音频每次识别结果都会有细微差异,排查问题时会被这个「玄学抖动」折磨很久。另外energy_floor0.0也要显式给,默认值在不同 torchaudio 版本里变过,不写死就会出现「训练和推理特征分布不一致」这种查三天都查不出来的问题。拿到 FBank 之后还要做 CMVN(倒谱均值方差归一化),训练集统计出全局均值和方差存成.npy,训练和推理都减同一个值。有些实现用每条样本自己做 CMVN,线上遇到短音频会非常不稳定,别这么干。Dataset 侧再加上 SpecAugment:随机遮两段频带、随机遮两段时间,这是小数据量下防止过拟合最划算的手段,代价几乎为零。4. 训练核心代码:一个 batch 从波形到 CTC Loss 的完整链路这一章是整套系统的心脏。模型结构、损失函数、解码评估三块代码串起来,才算真正跑通一次训练。4.1 模型定义:下采样层与 Conformer Block下采样层用两层二维卷积,频率维和时间维同时压,输出维度要显式算出来,不能拍脑袋写死:# src/model/subsampling.py import torch.nn as nn class ConvSubsampling(nn.Module): def __init__(self, in_dim80, out_dim256): super().__init__() self.conv nn.Sequential( nn.Conv2d(1, out_dim, kernel_size3, stride2, padding1), nn.ReLU(), nn.Conv2d(out_dim, out_dim, kernel_size3, stride2, padding1), nn.ReLU(), ) # 频率维经过两次 stride2 后的尺寸:(n 2p - k)//s 1 freq in_dim for _ in range(2): freq (freq 2 * 1 - 3) // 2 1 # 80 - 40 - 20 self.out nn.Linear(out_dim * freq, out_dim) def forward(self, x, lengths): # x: (B, T, 80) - (B, 1, T, 80) x x.unsqueeze(1) x self.conv(x) # (B, C, T//4, 20) b, c, t, f x.shape x x.permute(0, 2, 1, 3).reshape(b, t, c * f) # 输出长度是输入长度除以 4 再向下取整,解码时要用这个新长度 out_lengths (lengths 1) // 2 out_lengths (out_lengths 1) // 2 return self.out(x), out_lengths这里最容易错的是out_lengths。卷积的 stride2 加 padding1,长度变换严格来说是(L 2*1 - 3)//2 1,不是简单的L//2。这个长度如果算错,CTC Loss 里传入的input_lengths就跟真实帧数对不上,loss 会算得莫名其妙,而且不报错,只是不收敛。我第一次写这段的时候就是被这个差值坑了两天。Conformer Block 给一个可跑的最小实现,重点是残差缩放系数:前馈是 0.5,注意力和卷积是 1.0,这个半残差的写法是原论文的设计,照抄就行:# src/model/conformer.py import torch, torch.nn as nn class ConvModule(nn.Module): 深度可分离卷积模块,负责抓局部上下文 def __init__(self, d_model256, kernel15, dropout0.1): super().__init__() self.norm nn.LayerNorm(d_model) self.pw1 nn.Conv1d(d_model, 2 * d_model, 1) self.dw nn.Conv1d(d_model, d_model, kernel, paddingkernel // 2, groupsd_model) self.bn nn.BatchNorm1d(d_model) self.pw2 nn.Conv1d(d_model, d_model, 1) self.act nn.GLU(dim1) def forward(self, x): # x: (B, T, D) y self.norm(x).transpose(1, 2) # (B, D, T) y self.act(self.pw1(y)) # GLU 把通道砍回 D y self.dw(y) y self.bn(y) y self.pw2(nn.functional.silu(y)) return x y.transpose(1, 2) class ConformerBlock(nn.Module): def __init__(self, d_model256, n_head4, kernel15, dropout0.1): super().__init__() self.ff1 nn.Sequential(nn.LayerNorm(d_model), nn.Linear(d_model, 4 * d_model), nn.SiLU(), nn.Dropout(dropout), nn.Linear(4 * d_model, d_model)) self.attn nn.MultiheadAttention(d_model, n_head, dropoutdropout, batch_firstTrue) self.conv ConvModule(d_model, kernel, dropout) self.ff2 nn.Sequential(nn.LayerNorm(d_model), nn.Linear(d_model, 4 * d_model), nn.SiLU(), nn.Dropout(dropout), nn.Linear(4 * d_model, d_model)) self.norm nn.LayerNorm(d_model) def forward(self, x, key_padding_maskNone): x x 0.5 * self.ff1(x) # 半残差前馈 h x x, _ self.attn(x, x, x, key_padding_maskkey_padding_mask, need_weightsFalse) x h x # 自注意力,全残差 x self.conv(x) # 卷积模块自带残差 x x 0.5 * self.ff2(x) # 半残差前馈 return self.norm(x)key_padding_mask必须传,而且要跟 batch 里的真实长度对上,padding 出来的帧不能被注意力看到,否则一条样本的输出会污染另一条。这个参数在训练时忘了传是最常见的静默 bug——loss 照常下降,只是 CER 卡在一个不上不下的数字上。主干就是把下采样、12 个 Block、最后一层 Linear 映射到词表大小拼起来。这里不需要额外的位置编码层,Conformer 通常用相对位置编码嵌在注意力里,上面这版最小实现为了可读性省掉了,换成绝对位置编码也能跑,只是长音频上会掉一点。4.2 训练循环:CTC Loss 的参数一个都不能错CTC Loss 的四个参数必须一次给对:log_probs的形状是(T, B, V),targets是(B, S),input_lengths是下采样后的帧数,target_lengths是真实标签长度:# src/train.py(核心片段) import torch, torch.nn as nn criterion nn.CTCLoss(blank0, reductionsum, zero_infinityTrue) optimizer torch.optim.AdamW(model.parameters(), lr2e-3, weight_decay1e-2) for epoch in range(max_epoch): model.train() for batch in loader: feats, feat_lens, targets, tgt_lens [x.cuda() for x in batch] logits, out_lens model(feats, feat_lens) # (B, T, V) log_probs logits.log_softmax(dim-1).transpose(0, 1) # (T, B, V) loss criterion(log_probs, targets, out_lens, tgt_lens) loss loss / out_lens.sum() # reductionsum 时手动按总帧数归一化 optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) # CTC 梯度容易爆 optimizer.step() # warmup 分段衰减,warmup 阶段学习率从 lr*0.01 线性涨到 lr step 1 if step warmup_steps: cur_lr base_lr * step / warmup_steps else: cur_lr base_lr * (0.5 ** (step // decay_steps)) for g in optimizer.param_groups: g[lr] cur_lr几个参数为什么要这么设,值得说明白。reductionsum加手动除以out_lens.sum(),是为了让 loss 与 batch 内的总帧数挂钩,而不是与 batch 大小挂钩。如果用reductionmean,PyTorch 是按target_lengths平均的,同样一个 batch,长音频和短音频给出的梯度尺度差别很大,学习率就得跟着调,很麻烦。按帧平均之后,梯度尺度稳定,学习率可以固定住。zero_infinityTrue是保底的。当某条样本的target_length大于input_length(比如标签里有一长串没对齐的字,或者下采样长度算错了),CTC 会算出inf的 loss,一个inf反传下去整组参数全变 NaN,训练直接报废,而且日志上只能看到一个loss: inf,什么都没说。开了这个参数之后,这条样本的 loss 变成 0,至少训练不会崩,能让你有时间去查数据。clip_grad_norm_也是同理,CTC 的梯度在训练早期非常不稳,不裁剪经常出现 loss 突然跳到几千。4.3 推理与 CER:判断模型到底行不行的唯一标准训练集 loss 降下去不算数,唯一可信的指标是 CER(字错误率):# src/metrics.py def char_error_rate(ref: str, hyp: str) - float: # 编辑距离:插入/删除/替换各算一个错误,除以参考序列长度 n, m len(ref), len(hyp) if n 0: return 0.0 if m 0 else 1.0 dp list(range(m 1)) for i in range(1, n 1): prev, dp[0] dp[0], i for j in range(1, m 1): cur dp[j] dp[j] min(dp[j] 1, # 删除 dp[j - 1] 1, # 插入 prev (ref[i - 1] ! hyp[j - 1])) # 替换或匹配 prev cur return dp[m] / n计算之前必须做文本归一化,把参考文本和识别结果都过一遍去标点、去空格、数字转写,否则 CER 里会凭空多出 5 到 10 个百分点,纯粹是标点差异贡献的。我见过有人用原始标注直接算 CER,算出来 28%,归一化之后变成 9%,中间差的这些全是他自己吓自己。推理入口要做三件事:model.eval()、torch.no_grad()、dither0.0。缺任何一个都会让结果飘。批量推理时还要注意按长度排序,避免一个 batch 里全是最长样本的 padding,白白浪费算力。验证集 CER 在开源中文语料(百来小时量级的普通话朗读)上,这套配置跑 60 到 80 轮,贪心解码大概能到 12% 到 15%,加上束搜索和语言模型能压到 8% 到 10%。如果跑出来是 30% 以上,别急着调模型,先把第 5 章那几个坑挨个查一遍。5. 避坑与排查:五个把我按在地上摩擦的真实记录下面这五条,基本覆盖了这套系统从训练到上线 80% 的异常表现。每一条都按「现象 → 原因 → 解决」写,遇到问题时可以直接对号入座。5.1 loss 秒降到 0.1,CER 却卡在 95%现象:训练日志里 loss 第一个 epoch 就掉到 0.1 以下,看起来收敛得非常好,但验证集 CER 一直在 95% 以上,解码输出要么是空,要么是一两个字反复重复。原因:真实标签里混进了 blank id 0。常见于词表构建时把blank放到了 0 号位,但文本转 id 的函数没有把空白字符过滤干净,或者语料里有全角空格、零宽字符,被映射成了 0。CTC 看到的目标全是 blank,当然立刻收敛到「全部输出 blank」这个平凡解,loss 自然低得离谱。解决:在build_vocab.py之后加一段校验,遍历训练集,断言所有 label id 都不等于 0。同时把文本归一化函数里的空格、制表符、零宽字符显式清掉。这个检查我后来直接写进了训练脚本开头,不通过就raise,比训两天再发现问题便宜太多。5.2 训练十轮 loss 纹丝不动现象:loss 从 300 多缓慢降到 280 就停住了,十轮过去几乎没变化,梯度范数一直很小。原因:八成是学习率没 warmup。CTC 训练早期梯度方向非常乱,直接上 2e-3 的学习率,模型会被推向一个很差的局部区域,之后就出不来了。另一种可能是 FBank 特征没有做 CMVN,输入数值范围在 -20 到 20 之间波动,和 LayerNorm 的预期不匹配。解决:先确认 warmup 生效,warmup 步数取总步数 * 0.05左右,起始学习率从base_lr * 0.01线性爬上去。再把 CMVN 加上:统计训练集所有 FBank 帧的均值和方差,存成cmvn.npy,训练和推理都减均值除标准差。这两件事做完,loss 通常在 3 到 5 轮之后开始明显下降。如果还是不降,把 batch 打印出来肉眼看一下标签对不对,别在梯度上继续猜。5.3 显存 OOM 与 batch 组织方式现象:按batch_size32训练,几轮之后突然 OOM,而且越到后面越容易爆。原因:按样本条数切 batch 是错的。中文语音里一条 2 秒、一条 20 秒,差十倍,一个 batch 的显存占用取决于最长那条,padding 出来的帧全都实打实占显存。数据打乱之后,随机碰上几条长音频就爆了。解决:改成按帧数动态 batching。先按duration排序,再按「累计帧数不超过 16000」切组,组内再打乱。这样每个 batch 的计算量稳定,显存峰值可控。代价是每个 epoch 内的样本顺序相对固定,所以要在 epoch 之间重新排序。这个改动一般能把显存利用率提升 30% 以上,相当于白捡三分之一的吞吐。5.4 线下验证 8%,上线变 30%现象:验证集 CER 8%,用真实业务录音跑一遍,直接变成 30%,而且错误集中在某些特定口音或特定场景。原因:三个可能同时存在。一是采样率没对齐,业务音频有 8kHz 的电话录音,代码里默认按 16kHz 读,频谱直接错位;二是dither没关,推理时还在加随机噪声;三是模型还在train()模式,Dropout 和 BatchNorm 都在按训练行为跑。解决:推理入口统一写一个prepare()函数,里面依次做model.eval()、torch.no_grad()、重采样到 16kHz、dither0.0,所有推理路径都只能走这个函数,不允许绕过。另外把验证集和业务数据的音频参数(采样率、位深、声道数)打成一张表对比一遍,这个表我很长时间里每周都要看一次。5.5 CER 算出来比实际低一半现象:自己算的 CER 是 6%,换一个评测脚本算出来是 12%,两边差一倍。原因:文本归一化的规则不一致。自己那份把标点全删了,参考文本也跟着变短,CER 的分母变小;换成别家的脚本,标点算作错误,分母又变长。还有一种情况是参考文本里有多音字或数字,一边转了一边没转。解决:归一化函数只留一份,放进metrics.py,训练、验证、对外评测全调它。函数里做四件事:全角转半角、去所有标点与空白、阿拉伯数字转中文读法、英文统一小写。评测脚本不提供参数化的选项,想改就改源码,这样至少所有人的口径是一致的。6. 二次开发:换领域词表、加标点、模型量化上线的三条路拿到这套代码之后,大部分人的需求不是「重训一个通用模型」,而是「在我这个场景里用起来」。这时候有三条改造路径,投入产出比从高到低排。6.1 领域迁移:冻结哪几层,学习率怎么给通用模型在你的领域上 CER 高,九成是因为领域词表不匹配——医疗、金融、客服工单里的专有名词,通用模型根本没听过。最省事的做法是先把领域词表加进解码器的语言模型里,不动声学模型,很多场景下这一招就能把 CER 拉低三到五个点。声学模型要动的话,别全量微调。前 6 层学的是低频的声学特征,基本和领域无关,冻结掉;后 6 层和最后的输出层放开,学习率给预训练时的十分之一:# 冻结前 6 层编码器,只微调后半部分和输出层 for name, p in model.named_parameters(): if name.startswith(encoder.layers.): idx int(name.split(.)[2]) p.requires_grad idx 6 elif name.startswith(subsampling): p.requires_grad False optimizer torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr2e-4, # 预训练的 1/10 weight_decay1e-2, )分层学习率在语音任务上收益很明显,底层用小学习率、顶层用大学习率,比全局一个值稳得多。冻结层数不是固定值,数据量少(几小时)就冻 8 层,数据量到几十小时可以只冻 4 层。判断标准很简单:微调后的验证集 CER 比微调前低,而且没有在训练集上快速过拟合,就继续放开。6.2 流式与端侧:ONNX 导出和分块推理离线批处理和在线服务的模型不一样。在线要求低延迟,不能等整段音频说完再出结果,得改成分块推理,每 320ms 出一个增量结果。做法是把自注意力限制在当前的 chunk 内,卷积模块改成因果卷积,这些改动在 Conformer 里都有标准做法,核心是给注意力加一个 chunk mask。端侧部署的话,导出 ONNX 是最快落地的路径:# deploy/export_onnx.py import torch model.eval() dummy_feat torch.randn(1, 300, 80) # 3 秒音频的 FBank dummy_len torch.tensor([300]) torch.onnx.export( model, (dummy_feat, dummy_len), asr_conformer.onnx, input_names[features, lengths], output_names[logits, out_lengths], dynamic_axes{ # 时间和 batch 都必须动态 features: {0: batch, 1: time}, logits: {0: batch, 1: time}, }, opset_version14, )dynamic_axes里如果不写time,导出来的模型就只能接受固定长度的输入,线上每一段音频都得 padding 到同一个长度,白白浪费算力。导出之后一定要用同一段音频对比 PyTorch 和 ONNX 的输出,torch.allclose过一遍,差值超过 1e-3 就说明某个算子导出有问题,常见的是 LayerNorm 和 GLU。再往下一步是量化。INT8 量化在 Conformer 上要小心,注意力的 softmax 对精度敏感,整网量化 CER 会掉两三个点。稳妥的做法是只量化卷积和线性层,注意力保持 FP16。这个组合在端侧能把模型压到原来四分之一大小,延迟降一半左右,精度损失控制在 0.5 个点以内。我在这个项目上踩得最深的一次,是微调完之后只看了训练集的 loss 就发版,结果业务侧的真实录音 CER 比验证集高了二十多个点——训练集和验证集来自同一批录音设备,而业务侧是手机外放录的,完全是两套声学条件。从那以后我每次改完模型,都强制先拿两百条真实业务音频跑一遍端到端 CER,再谈上线。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询