做中文语音识别第一步最劝退的往往不是模型而是数据。手里没有干净的语音文本对再好的Conformer、再精巧的解码方案都是空的。AISHELL-1这个开源中文普通话语料库是我过去一年多里反复用的一块数据。它由希尔贝壳发布一共约178小时、400位说话人的带转写普通话语音比THCHS-30大一个量级又没有AISHELL-2上千小时那么重。对一台单卡GPU机器来说用它从零训练第一个ASR模型跑一轮训练、出一版可上手的识别结果时间是刚好够的。这篇文章的完整链路包括四个部分数据集下载与目录解析、特征提取与数据清单制作、基于PyTorch的Conformer-CTC模型训练、解码与字错误率CER评测。全程不依赖任何黑盒开源ASR框架核心代码都贴出来硬件要求控制在一张消费级显卡可跑的范围。无论你是刚开始接触语音识别的学生还是想给业务侧快速验证中文语音方案的技术人员照着走完一遍你就能拥有从wav文件到输出汉字的底层能力。1. 为什么偏偏是AISHELL-1中文ASR入门的数据集选择逻辑1.1 中文开源语音数据集横向对比很多初学者一上来就搜中文语音识别数据集结果看到一堆名字直接懵了。这里我把几个常见的直接摊开对比你就知道AISHELL-1的位置在哪。数据集时长规模说话人数采样/格式标注情况获取门槛THCHS-30约30小时3016kHz WAV字级音节文本直接下载AISHELL-1约178小时40016kHz单声道WAV句级汉字拼音直接下载AISHELL-2约1000小时2000多设备采集句级汉字拼音授权申请Common Voice 中文数量波动众包多设备码流格式社区众包转录直接下载THCHS-30更适合做代码自测和流程验证规模太小正经训练一个端到端模型很容易过拟合你甚至能在几轮之内把训练集给背下来。AISHELL-2数据量大效果上限高但官方走授权申请流程入门阶段没必要掺和。Common Voice的中文语料来自众包环境噪声和转录错误都比较杂做鲁棒性测试还行作为第一个ASR模型的训练集反而会让问题排查变得很痛苦。相比之下AISHELL-1的优点非常具体资源是OpenSLR上公开打包的下载没有任何门槛。1.2 AISHELL-1的核心指标与入门优势AISHELL-1的采集规格在语音社区里算是标准的教科书配置16kHz采样率、单声道、16bit编码、WAV格式几乎不需要重采样直接喂给特征提取模块就行。内容上它覆盖了智能家居、无人驾驶、工业控制等35个场景下的口语句子不是机器念稿那种死板发音比较接近实际产品里会遇到的口语节奏。说话人来自中国不同方言区但录制时统一说标准普通话所以模型学到的声学特征不会局限在单一区域口音里。划分也很良心数据发布时就按说话人维度切好了train/dev/test三个集合大体规模是训练集约12万句、开发集约1.4万句、测试集约0.7万句。这意味着你不需要自己设计复杂的交叉验证策略直接拿官方切分去跑后面和其他开源方案对比基线时也方便。另外resource_aishell压缩包里还带了发音词典和语言模型文件这局后续做拼音建模或语言模型融合时可以直接拆出来用。1.3 这套流程跑通后能拿到什么能力我强调一下这篇文章不是带你去刷新AISHELL-1的SOTA而是让你建立一个从原始wav到汉字的完整闭环。跑完这套流程你会亲手实践到这些环节数据清单怎么生成、log-Mel特征怎么提、模型输入输出怎么对齐、CTC训练时序列长度怎么处理、解码时重复字符怎么合并、最后的CER要怎么算才算对。这些能力不会因为换了数据集就失效。以后你换到AISHELL-2、换到某个方言数据集或者换到包含数千小时的业务数据底层的处理逻辑还是一模一样的。2. 环境准备与数据下载从OpenSLR取回AISHELL-1全流程2.1 硬件选型与软件依赖先说硬件底线。我跑这套实验用的是单张RTX 309024G显存训练非常宽裕。如果你手里是RTX 3060 12G这种卡也没问题把批大小调小一档模型维度从256降到192效果还是能看。完全没有GPU的话CPU也能跑通小规模验证但全量178小时数据就别想了训练时间会从小时级膨胀到天级。软件依赖方面建议用conda隔离出一个干净的Python 3.9环境。PyTorch版本选用2.x配一套和CUDA版本匹配的torchaudio。这里有个容易踩的坑torchaudio和torch的版本必须对应否则import torchaudio的时候会直接报底层符号错误。conda create -n asr python3.9 -y conda activate asr pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118 pip install editdistance librosa tqdmeditdistance用于后面计算CERlibrosa可以做备选的特征增强/分析tqdm就是在长训练循环里看进度用的。这些工具不需要最新版本稳定就好。2.2 下载与校验AISHELL-1的具体操作AISHELL-1在OpenSLR上的编号是33直接通过wget拉就可以。这里要提醒一句data_aishell.tgz大概有15GB左右下载时间取决于你的带宽如果网络中途断了wget默认不自动续传建议加-c参数让它断点续传。# 建议先建个专门目录后面所有文件都往里放 mkdir -p aishell1 cd aishell1 wget -c https://www.openslr.org/resources/33/data_aishell.tgz wget -c https://www.openslr.org/resources/33/resource_aishell.tgz下载完成后别急着解压先做一层完整性校验。OpenSLR页面上有对应的压缩包大小本地用ls -lh看一遍如果和实际大小差太多多半是下载不完整。解压命令我习惯用tar加-j参数直接解.tar.gz格式tar -xzvf data_aishell.tgz tar -xzvf resource_aishell.tgz解压后data_aishell目录大约会占到40GB以上磁盘空间临时文件预留50GB比较稳妥。如果你机器磁盘紧张可以先把tar包删掉只保留解压结果。2.3 目录结构逐层拆解与重点文件说明解压完的目录长这样data_aishell/ ├── transcript/ │ ├── aishell_transcript_train.txt │ ├── aishell_transcript_dev.txt │ └── aishell_transcript_test.txt └── wav/ ├── train/ │ ├── S0002/ │ │ ├── BAC009S0002W0121.wav │ │ └── ... ├── dev/ │ ├── S0123/ │ │ └── ... └── test/ └── ...transcript目录下是三个文本文件每行格式是音频ID 对应中文转录文本。比如BAC009S0002W0121 今天天气怎么样我们下午开会讨论方案注意原始的转录文本里其实保留了中文标点例如顿号、逗号、句号有些句子里还有语气词。这些在后面做文本清洗时都要处理掉否则CER的计算会被标点污染。wav目录按train/dev/test又分成三个子目录里面再按说话人ID分文件夹。这个结构对数据加工非常友好因为你可以直接用glob递归扫描所有wav文件然后通过文件名和transcript里的音频ID做关联不需要手工记录路径。3. 预处理链路把原始WAV文本变成模型能吃的样本3.1 数据清单制作train/dev/test切分与data.list格式不管是自己写训练循环还是用成熟ASR框架最终都需要把音频路径和文本内容放到一个清单文件里。ESPnet和WeNet里管这个叫data.list我就沿用这个叫法。核心逻辑很简单用glob扫出所有wav路径根据文件名基名去transcript里查文本。AISHELL的train/dev/test转录文件是分开的所以生成清单时直接对应关系写清楚就行。import glob import os data_root data_aishell/wav trans_root data_aishell/transcript def load_transcript(txt_path): trans {} with open(txt_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue parts line.split(maxsplit1) if len(parts) 2: trans[parts[0]] parts[1] return trans def make_data_list(split): wav_paths glob.glob(os.path.join(data_root, split, **, *.wav), recursiveTrue) trans load_transcript(os.path.join(trans_root, faishell_transcript_{split}.txt)) out_path f{split}.list with open(out_path, w, encodingutf-8) as out: for wav_path in sorted(wav_paths): uid os.path.basename(wav_path).replace(.wav, ) if uid in trans: out.write(f{wav_path}\t{trans[uid]}\n) print(f{split}: {len(wav_paths)} 条音频匹配转录 {len(trans)} 条) for split in [train, dev, test]: make_data_list(split)如果你的音频文件里存在转写缺失上面的代码会自动跳过。但为了保险生成完之后最好看一眼train.list的行数是否和官方说明一致一旦差异过大多半是解压不全或glob路径写错了。3.2 80维log-Mel特征提取的参数依据与实现语音文件本身不能直接塞给Transformer。第一步是把波形变成一组按帧排列的向量也就是声学特征。现在端到端ASR的标配是80维log-Mel滤波器组特征帧长25毫秒、帧移10毫秒。这个配置被ESPnet和WeNet用得最广泛参数抗过拟合能力和训练稳定性都经过了大量验证。AISHELL-1本身是16kHz单声道所以不需要重采样但代码里我仍然加了判断方便你以后换数据集。MFCC在传统GMM-HMM时代是主流因为它丢掉了说话人相关的倒谱信息只保留声道包络的信息。但端到端模型需要更完整的声学细节MFCC反而不够用log-Mel这种相对原始的特征更合适。import torch import torchaudio import torch.nn.functional as F SAMPLE_RATE 16000 WINDOW_MS 25 HOP_MS 10 N_MELS 80 F_MAX 8000.0 def extract_logmel(wav_path, target_sr16000): waveform, sr torchaudio.load(wav_path) if sr ! target_sr: waveform torchaudio.functional.resample(waveform, sr, target_sr) n_fft int(target_sr * WINDOW_MS / 1000) hop_length int(target_sr * HOP_MS / 1000) mel_spec torchaudio.transforms.MelSpectrogram( sample_ratetarget_sr, n_fftn_fft, hop_lengthhop_length, win_lengthn_fft, n_melsN_MELS, f_min0.0, f_maxF_MAX, )(waveform) logmel torch.log(mel_spec 1e-6) return logmel.squeeze(0).transpose(0, 1) # (帧数T, 特征维度80)代码里torch.log(mel_spec 1e-6)的1e-6是为了防止某个频带能量为0时出现log(0)的负无穷。这个数值不能太大太大会把低能量帧的区分度抹掉。3.3 中文文本清洗与字符级建模的字典生成中文ASR的建模单元选择通常可以在字符级和词级之间权衡。入门阶段我强烈建议用字符级。汉字本身就是表意文字常用字几千个词表爆炸的问题完全不存在。词级建模需要分词工具还容易遇到未登录词对端到端模型来说并没有额外收益。AISHELL-1原始转录里带标点例如。、等等训练前需要统一去掉。有些数据集里还会有括号、书名号这些也一并清洗掉。import re PUNCT 。、《》〈〉「」『』·…— def clean_text(text): text text.strip() for ch in PUNCT: text text.replace(ch, ) text re.sub(r\s, , text) return text清洗完之后统计训练集里所有出现过的字构建两个映射表char2id和id2char。CTC模型需要一个blank符号索引通常放到0。训练阶段一些生僻字或未登录字可以用unk兜底但AISHELL-1训练集覆盖较全这个兜底更多是为了代码通用性。def build_vocab(data_list_paths): vocab {blank: 0, unk: 1} for path in data_list_paths: with open(path, r, encodingutf-8) as f: for line in f: text line.strip().split(\t, 1)[1] text clean_text(text) for ch in text: if ch not in vocab: vocab[ch] len(vocab) return vocab vocab build_vocab([train.list]) id2char {i: ch for ch, i in vocab.items()} blank_id vocab[blank]构建完字典后把训练文本转成ID序列时直接对每个字符查表就行。有一点要记住你的验证集和测试集如果出现了训练集里没有的字最好统一映射到unk否则模型在推理时遇到未知字符会直接输出一个从未见过的词表下标导致解码阶段崩溃。4. 模型实现Conformer编码器 CTC的完整PyTorch代码4.1 为什么入门推荐Conformer-CTC而不是直接上端到端Attention中文ASR在端到端时代有两个主流范式一个是纯Attention的LAS一个是CTC还有一个是把两者接起来的hybrid。我的建议是入门先做Conformer编码器CTC损失。原因很直接纯Attention模型在训练时对数据和调参的敏感度更高解码阶段还要处理自回归生成BERT式的位置信息、强制教学、计划采样这些细节一多新手很容易被各种玄学现象劝退。CTC训练则像一个对齐友好的分类器模型输出和输入帧长度是压缩对齐后的不需要逐帧指导训练稳定解码也可以用简单的贪心搜索。Conformer比起最早的Transformer编码器多了两个设计前置FFNhalf-step残差和卷积模块。它们在语音特征上能更好地捕捉局部连续结构音素和音节在相邻帧之间的关联性也因此没被注意力机制全部打散。中文普通话音节结构本身是声母韵母相邻帧局部信息非常关键。4.2 核心模块代码卷积子采样、Conformer Block、CTC输出层先做卷积子采样。原始特征大约是每秒100帧直接扔给Transformer会让序列太长自注意力的计算复杂度是O(T^2)非常不划算。我用两层stride2的卷积把时间维度压缩到原来的1/4同时把输入的80维特征升维到d_model。import torch import torch.nn as nn class ConvSubsampling(nn.Module): def __init__(self, input_dim80, d_model256): super().__init__() self.conv nn.Sequential( nn.Conv2d(1, d_model, kernel_size3, stride2, padding1), nn.ReLU(), nn.Conv2d(d_model, d_model, kernel_size3, stride2, padding1), nn.ReLU(), ) feat_dim_after_subsample (input_dim - 1) // 2 feat_dim_after_subsample (feat_dim_after_subsample - 1) // 2 self.out_dim d_model * feat_dim_after_subsample self.linear nn.Linear(self.out_dim, d_model) def forward(self, feats): # feats: (B, T, D) x feats.unsqueeze(1) # (B, 1, T, D) x self.conv(x) # (B, d_model, T/4, D/4) B, C, T, D x.size() x x.permute(0, 2, 1, 3).reshape(B, T, -1) return self.linear(x)Conformer Block是整个模型的重头戏。我按论文里的结构实现一个FFN的前半部分残差、一个多头注意力、一个卷积模块、再一个FFN的后半部分残差最后LayerNorm收尾。class PositionwiseFeedForward(nn.Module): def __init__(self, d_model, d_ff, dropout0.1): super().__init__() self.linear1 nn.Linear(d_model, d_ff) self.linear2 nn.Linear(d_ff, d_model) self.activation nn.SiLU() self.dropout nn.Dropout(dropout) def forward(self, x): return self.linear2(self.dropout(self.activation(self.linear1(x)))) class ConformerBlock(nn.Module): def __init__(self, d_model256, d_ff1024, num_heads4, kernel_size7, dropout0.1): super().__init__() self.ffn1 nn.Sequential( nn.LayerNorm(d_model), PositionwiseFeedForward(d_model, d_ff, dropout), nn.Dropout(dropout), ) self.self_attn nn.MultiheadAttention(d_model, num_heads, dropoutdropout, batch_firstTrue) self.attn_norm nn.LayerNorm(d_model) self.conv_module nn.Sequential( nn.LayerNorm(d_model), nn.Conv1d(d_model, d_model * 2, kernel_size1), nn.GLU(dim1), nn.Conv1d(d_model, d_model, kernel_sizekernel_size, paddingkernel_size // 2, groupsd_model), nn.BatchNorm1d(d_model), nn.SiLU(), nn.Conv1d(d_model, d_model, kernel_size1), nn.Dropout(dropout), ) self.ffn2 nn.Sequential( nn.LayerNorm(d_model), PositionwiseFeedForward(d_model, d_ff, dropout), nn.Dropout(dropout), ) self.final_norm nn.LayerNorm(d_model) def forward(self, x, maskNone): # mask: BoolTensor (B, T)True 表示有效帧 x x 0.5 * self.ffn1(x) key_padding_mask ~mask if mask is not None else None attn_out, _ self.self_attn(x, x, x, key_padding_maskkey_padding_mask, need_weightsFalse) x x self.attn_norm(attn_out) conv_in x.transpose(1, 2) conv_out self.conv_module(conv_in).transpose(1, 2) x x conv_out x x 0.5 * self.ffn2(x) return self.final_norm(x)有两点需要解释第一nn.MultiheadAttention的key_padding_mask语义是True的位置会被忽略所以我在forward里把外部传入的有效帧mask取反了。第二卷积模块中间的groupsd_model是深度可分离卷积。它的参数量远小于标准卷积但能有效建模相邻帧的局部依赖。对语音来说局部平滑性比大范围的全局卷积更重要这是Conformer在语音任务上表现好的核心原因之一。最后把编码器和CTC输出层拼起来。class ConformerASR(nn.Module): def __init__(self, input_dim80, vocab_size5000, d_model256, num_blocks12, num_heads4, expansion_factor4, dropout0.1): super().__init__() self.subsampling ConvSubsampling(input_dim, d_model) self.encoder_blocks nn.ModuleList([ ConformerBlock( d_modeld_model, d_ffd_model * expansion_factor, num_headsnum_heads, dropoutdropout, ) for _ in range(num_blocks) ]) self.output_layer nn.Sequential( nn.LayerNorm(d_model), nn.Linear(d_model, vocab_size), ) def forward(self, feats, feat_lens): # feat_lens: LongTensor (B,)表示每条样本的原始帧数 x self.subsampling(feats) subsampled_lens ((feat_lens - 1) // 2 - 1) // 2 mask make_pad_mask(subsampled_lens, max_lenx.size(1)) for block in self.encoder_blocks: x block(x, mask) logits self.output_layer(x) return logits, subsampled_lens这里需要一个make_pad_mask函数作用是把batch里的有效帧区域标记出来让注意力掩码只关注到有效位置。def make_pad_mask(lengths, max_lenNone): if max_len is None: max_len lengths.max().item() batch_size lengths.size(0) seq torch.arange(max_len, devicelengths.device).unsqueeze(0).expand(batch_size, -1) return seq lengths.unsqueeze(1)4.3 模型实例化与参数量计算用以下参数实例化一个入门级模型model ConformerASR( input_dim80, vocab_sizelen(vocab), d_model256, num_blocks12, num_heads4, expansion_factor4, dropout0.1, ) total_params sum(p.numel() for p in model.parameters()) trainable_params sum(p.numel() for p in model.parameters() if p.requires_grad) print(f总参数量: {total_params / 1e6:.2f}M) print(f可训练参数量: {trainable_params / 1e6:.2f}M)这套配置的总参数量大概在40M到50M之间具体取决于你的词表大小。对一张消费级显卡来说这个规模的模型既能体现Conformer结构的效果又不会让训练时间突破心理防线。如果你只有8G显存建议把d_model降到192num_blocks降到8参数量能控制到20M以内。5. 训练循环与实测表现从0走到第一个可用的中文ASR5.1 训练配置、优化器与学习率调度策略训练时我用了AdamW优化器权重衰减0.01。学习率调度选择的是Noam式预热方案前4000步线性增长到一个峰值然后按步数的平方根衰减。这个调度在Transformer类模型里几乎是最稳的能有效避免训练初期梯度爆炸。def noam_schedule(step, d_model256, warmup_steps4000): step max(step, 1) return (d_model ** -0.5) * min(step ** -0.5, step * (warmup_steps ** -1.5)) warmup_steps 4000 peak_lr noam_schedule(warmup_steps, d_model256) # 可自行缩放实际训练时我一般把峰值学习率设成0.001左右然后每个step更新一次优化器参数组里的学习率optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay0.01) for step in range(total_steps): new_lr noam_schedule(step 1, d_model256) for g in optimizer.param_groups: g[lr] new_lrCTC损失函数直接调用PyTorch自带的torch.nn.functional.ctc_loss。注意它的输入形状要求是(T, B, C)而模型输出的logits形状是(B, T, C)所以训练时要把时间维换到最前面。5.2 完整训练脚本与断点续训下面是一个精简但完整的训练循环。我假设你已经把wav文件离线提取成了log-Mel特征并保存成npy或者内存里预加载这样训练循环里不再反复调用torchaudio加载音频速度会快很多。import random import numpy as np import torch from tqdm import tqdm def collate_fn(batch): feats, token_ids zip(*batch) feat_lens torch.tensor([f.size(0) for f in feats]) max_feat_len feat_lens.max().item() padded_feats torch.zeros(len(batch), max_feat_len, feats[0].size(1)) for i, f in enumerate(feats): padded_feats[i, : f.size(0)] f token_lens torch.tensor([len(t) for t in token_ids]) max_token_len token_lens.max().item() padded_tokens torch.full((len(batch), max_token_len), blank_id, dtypetorch.long) for i, t in enumerate(token_ids): padded_tokens[i, : len(t)] torch.tensor(t) return padded_feats, feat_lens, padded_tokens, token_lens def train_one_epoch(model, train_loader, optimizer, device): model.train() total_loss 0 num_steps 0 pbar tqdm(train_loader, desctrain) for feats, feat_lens, tokens, token_lens in pbar: feats feats.to(device) tokens tokens.to(device) logits, enc_lens model(feats, feat_lens.to(device)) log_probs torch.log_softmax(logits, dim-1).transpose(0, 1) # (T, B, C) loss torch.nn.functional.ctc_loss( log_probs, tokens, enc_lens.to(device), token_lens.to(device), blankblank_id, reductionmean, ) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step() total_loss loss.item() num_steps 1 pbar.set_postfix(lossloss.item()) return total_loss / max(num_steps, 1)断点续训是一个很实用的细节。训练到一半断电、显存OOM退出都是很常见的事。我习惯每隔一个epoch保存一次checkpoint里面同时包含模型权重、优化器状态和当前的epoch数这样恢复时能把训练状态完整还原def save_checkpoint(model, optimizer, epoch, path): torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), }, path) def load_checkpoint(model, optimizer, path): ckpt torch.load(path, map_locationcpu) model.load_state_dict(ckpt[model_state_dict]) if optimizer is not None: optimizer.load_state_dict(ckpt[optimizer_state_dict]) return ckpt[epoch]5.3 实测损失曲线与样本识别效果训练过程中我建议每隔一定步数就在dev集上做一次贪心解码打印几条样本的人工转录和模型转录盯着看而不是只盯loss。loss下降不代表模型输出的文字合理有些时候过拟合特征会导致loss很低但CER很高。在我自己的实验里用12层Conformer、d_model256单张RTX 3090跑AISHELL-1全量训练集一个epoch大约需要30到40分钟。到第10到15个epoch时开发集上的CER通常在12%到15%之间。这个数字对入门来说已经可以接受继续往下压需要加SpecAugment数据增强、加深模型或者融合语言模型。如果你只是想验证链路通不通千万别直接全量开跑。先取训练集前5000条样本、dev集前500条样本用小模型跑两个epoch确认代码没有bug再放开来跑全量。我第一次调通整个流程时就是直接上全量结果到了第3个epoch才发现DataLoader里一个排序bug白白浪费了大半天。6. 解码与评测中的坑CER计算、贪心解码与常见问题排查6.1 贪心解码与模型输出转文本的完整实现CTC序列输出里会有大量重复帧解码时要把连续重复的token合并并删掉blank。torch.no_grad() def ctc_greedy_decode(logits, enc_lens): logits: (B, T, C) enc_lens: (B,) probs logits.log_softmax(dim-1) top_ids probs.argmax(dim-1) # (B, T) results [] for b in range(logits.size(0)): prev_token blank_id decoded_tokens [] for t in range(enc_lens[b].item()): token top_ids[b, t].item() if token ! prev_token and token ! blank_id: decoded_tokens.append(token) prev_token token text .join([id2char.get(t, unk) for t in decoded_tokens]) results.append(text) return results这段代码里有个容易犯错的地方CTC合并连续重复时prev_token必须是上一步的原始token而不是上一步加入结果的token。否则像你好和你你你这类连续相同字符会无法正确区分。6.2 CER指标计算与结果解读CER的英文全称是Character Error Rate字错误率中文拼音识别里没人看词错误率因为中文语序和分词本身就没有唯一标准。计算CER时把参考文本和预测文本都转成字符序列用编辑距离除以参考文本的字符数。import editdistance def compute_cer(reference, hypothesis): ref_chars [c for c in reference if c ! ] hyp_chars [c for c in hypothesis if c ! ] if len(ref_chars) 0: return 0.0 return editdistance.distance(ref_chars, hyp_chars) / len(ref_chars)比如参考文本是大家好模型输出大大家好编辑距离是1插入了一个大CER就是1/3约33.3%。这个例子说明一个很基础但很重要的点中文ASR里少一个字符、多一个字符CER都会显著上升所以预处理阶段的文本清洗必须严格对齐。计算整个测试集的CER时先把所有样本的CER算出来再求平均这是最常用的句子级CER平均。也可以把全部参考字符和全部预测字符堆在一起算总编辑距离得出来的是语料级CER。两种算法结果略有差异写论文或做汇报时一定要说清楚你用哪一种否则别人复现对比时会一头雾水。6.3 训练阶段最容易踩的6个坑及处理办法我把自己在AISHELL-1上反复踩过的坑浓缩成下面这6条每一条都是真实会咬人的问题。第一显存OOM。处理方法有两个方向调小batch size或者调小模型维度。如果不想牺牲模型效果可以加梯度累积每4个step统一更新一次参数等效于把batch size放大了4倍。accum_steps 4 loss loss / accum_steps loss.backward() if (step 1) % accum_steps 0: optimizer.step() optimizer.zero_grad()第二CTC loss出现NaN。最常见原因是学习率过大尤其是Noam调度初始步数设得太小。如果确认学习率没问题再检查log_probs是不是真的做了log_softmax以及输入里有没有空音频或长度为0的特征。第三模型输出的文本全是重复字。这往往是因为CTC的blank设置不对或者合并逻辑写反了。记得blank_id要对应字典里的blank且通常放到索引0。第四dev集CER比训练集CER低这看起来反常但实际上经常发生。原因可能是训练集里包含了一些生僻字而dev集文本恰好比较规范。另一种可能是文本清洗不一致导致训练标签里还残留标点dev集却清洗干净了。第五特征和模型维度不匹配。这种情况通常表现为前向传播时报某个matmul维度错误。解决办法是打印一下extract_logmel后特征的最后一维和模型input_dim80对齐。如果你用了librosa替代torchaudio注意librosa返回的特征可能默认是(D, T)需要转置成(T, D)。第六data.list生成时路径分隔符问题。Windows上用反斜杠Linux上用正斜杠跨环境跑时最好统一用os.path.abspath转成绝对路径否则换个机器可能就找不到文件。盘中日程另有一部分是训练数据没有做排序。如果你发现每个batch内音频长度差异很大可以按特征帧数降序排序后重新构建batch既能减少padding浪费加快训练速度还能让CTC梯度的均一性更好。这一步对全量训练尤其值得做能直接省下不少训练时间。6.4 基于这套流程可以继续做的三件事跑通AISHELL-1只是起点。接下来你可以按自己的兴趣往这几个方向深入。第一件是加SpecAugment。这是一种针对语音特征的时频掩码增强策略随机把某几个频率带或时间片整段mask掉让模型对局部缺失更鲁棒。实现不复杂而且对中文ASR的CER降低非常明显。第二件是换解码方式。贪心解码只是基线可以换成beam search或者把AISHELL-1自带的语言模型接进来做CTC/attention rescoring。效果会有一个肉眼可见的提升但代码复杂度也会上涨不少。第三件是尝试流式识别。AISHELL-1的句子都很短你可以把Conformer的注意力改成chunk-based attention配合cache机制做成流式模型。这个方向在真实产品里价值很大也是我从这个项目之后一直在做的方向。这些扩展都会用到这篇文章里搭好的数据预处理和评估基线所以我习惯把第一步做得尽量干净可靠。数据没洗干净之前的优化都是空中楼阁这是我在AISHELL-1上跑过十几轮实验之后最真实的体会。
阅读完成 · 觉得有帮助?