
做语音识别的朋友应该都有同感搞到一份干净、可用、带标准转写的中文普通话语料比调模型参数还费劲。我两年前第一次给团队搭端到端ASR演示系统翻遍了网上各种数据集最后稳稳落在AISHELL-1上。这个数据集由北京希尔贝壳发布总计178小时普通话语音、400位说话人内容覆盖智能语音助手常见的交互场景更难得的是它自带词级转写文本。拿它来做端到端自动语音识别ASR模型的第一个实战项目几乎已经成了中文语音圈默认的入门路径。这篇文章会从AISHELL-1的下载和解压讲起带你把它整理成标准训练目录再用WeNet框架训练一个真正能识别中文普通话的ASR模型。代码和命令我会直接给全过程中容易踩的坑也会一并说明。无论你是刚接触语音的研究生还是想在业务里快速落地中文语音识别方案的工程师这套流程都能帮你少走好几天的弯路。1. AISHELL-1到底是什么数据集结构、标注规范与适用场景1.1 数据规模与目录结构先看整体规模。AISHELL-1压缩包在OpenSLR官方页面上的体积大约是14GB解压后是标准的data_aishell目录里面分成wav和transcript两部分。wav下按train、dev、test三个子集组织分别对应训练集、开发集和测试集每个子集下再按说话人目录划分命名类似BAC009S0002、BAC009S0417这种格式。具体时长和说话人数官方给的信息大致是训练集约150小时约340位说话人开发集约17小时约40位说话人测试集约10小时约20位说话人。所有音频都是16kHz采样率、16bit、单声道的PCM WAV单条音频时长集中在几秒到十几秒非常适合做语音识别模型的训练样本。这里有个容易被忽略的细节目录和文件名的编号不是随便起的。BAC009S0002W0122这类命名里包含了语料批次、说话人ID和句子ID我们在后面做数据准备时可以直接把文件名当作全局唯一的utt_id既能保证不重名也便于排查问题。1.2 转写文本做了什么手脚transcript目录下只有一个文件aishell_transcript_v0.8.txtUTF-8编码每行是一条音频的转写。但这里有一个非常坑的点官方文本是分词后的结果词和词之间用空格分隔。比如某一行实际内容长这样BAC009S0002W0122 而 对 于 如 何 解 决 这个 问题如果直接把整行读进来当标签模型会把这些空格也学进去导致词表里出现一堆莫名其妙的带空格内容。正确做法是去掉所有空格转成连续汉字文本也就是而对于如何解决这个问题再用字符级token去训练。另外AISHELL-1的转写里偶尔会出现数字、英文单位或者中英混排的情况这些都属于中文文本规范化要处理的范围。入门阶段可以先简单处理把所有非汉字内容按常见规则替换掉等后面需要精调再上完整的中文文本正则化工具。1.3 和其他中文开源数据集的横向对比很多初学者会问市面上中文语音数据集那么多为什么偏偏推荐AISHELL-1我用一张表把常见的几个开源普通话数据集放在一起对比。数据集时长说话人数采样率主要场景转写粒度AISHELL-1约178h40016kHz智能语音助手词级THCHS-30约30h5016kHz新闻朗读字级Primewords约100h29616kHz手机语音输入句级AISHELL-2约1000h199116kHz多场景采集词级WenetSpeech10000h多来源16kHz网络音频混合部分标注AISHELL-1最大的优势在于“规模适中”。只有几百小时数据单张V100显卡就能在一天内完成完整训练规模又足够看出不同模型架构之间的效果差异不会像THCHS-30那样因为数据量太少导致过拟合严重。加上转写质量高、口音相对标准测试集字错误率CER做到个位数是完全可复现的。2. 技术路线选型先别急着训练选对框架省一半时间2.1 三条主流路线的优缺点对比在动手指之前我先梳理一下当前训练中文ASR模型的几条主流路线你可以根据自己的基础选。第一是WeNet。这是国内开源社区非常活跃的语音识别工具核心是Conformer-Transducer的U2/U2架构训练、解码、导出部署模型一体化对中文场景支持很好默认支持Kaldi风格的数据目录。第二是ESPnet。研究圈用得多recipe非常丰富模块化程度高几乎把业界主流模型都实现了但依赖库多、安装较重新手第一次跑起来往往要折腾一晚上。第三是HuggingFace Transformers。用Wav2Vec2或者Whisper做微调代码量最少几行就能跑起来但中文ASR的最终效果和工程落地能力不一定有前两者好更适合做快速原型验证。三者对比如下维度WeNetESPnetHuggingFace安装复杂度中等较高低中文效果很好很好看预训练模型官方AISHELL脚本自带自带需自己写部署支持TorchScript/ONNX一般一般上手难度适中偏难最容易2.2 为什么这次选WeNet我最终选择WeNet作为这篇教程的主线原因有三个。第一WeNet官方仓库里本身就带了AISHELL-1的完整recipe路径改一改就能跑通这意味着你遇到的大部分问题社区里都已经有人踩过并解决了。第二WeNet默认使用的Conformer-Transducer架构在中文识别上比早期纯CTC模型效果更好同时训练速度又比纯Attention模型快不少。第三它导出TorchScript和ONNX非常方便你跑通后想继续做服务端部署几乎不需要迁移代码。多说一句如果你只是临时想验证某个想法HuggingFace路线也不错但只要是认真想做出一个能用的中文ASR基线WeNet会是性价比最高的选择。2.3 环境配置与依赖安装训练环境我建议用LinuxWindows不是不行但折腾起来麻烦。下面是一套我验证过的环境配置流程。conda create -n wenet python3.9 -y conda activate wenet # 注意PyTorch版本要和你机器的CUDA版本匹配 # 以CUDA 11.8为例 pip install torch2.1.0 torchaudio2.1.0 --index-url https://download.pytorch.org/whl/cu118 git clone https://github.com/wenet-e2e/wenet.git cd wenet pip install -r requirements.txt显存方面AISHELL-1完整训练我建议至少准备一张11GB以上显存的显卡像V100、2080Ti、3090、A10都没问题。如果你的显卡只有8GB显存也别灰心把batch size调小一些仍然能跑完只是训练时间会长一点。纯CPU训练理论也可以但一个完整的Conformer模型在CPU上可能要跑一周以上不推荐。3. 数据集下载与离线预处理把WAV整理成标准训练目录3.1 下载AISHELL-1并校验完整性AISHELL-1的官方下载地址在OpenSLR资源编号是33直接使用wget就能下载。wget -c https://www.openslr.org/resources/33/data_aishell.tgz这里加-c参数是为了支持断点续传。这个文件大概14GB如果网络不稳定断点续传能帮你省去重新下载的时间。如果你用浏览器或IDM下载记得也选支持续传的工具。下载完成后建议先做一次MD5校验。OpenSLR页面上会给每个文件提供MD5值使用下面的命令检查md5sum data_aishell.tgz确认无误后再解压tar -zxvf data_aishell.tgz解压后最好检查一下目录结构确保data_aishell/wav和data_aishell/transcript都存在并且aishell_transcript_v0.8.txt不是0字节。3.2 5分钟生成WeNet训练目录完整Python代码原始数据解压出来后还不能直接扔给训练脚本。WeNet这类框架通常要求Kaldi风格的数据目录包含四个核心文件wav.scp每行是“音频ID 音频路径”text每行是“音频ID 转写文本”utt2spk每行是“音频ID 说话人ID”spk2utt每行是“说话人ID 音频ID列表”下面的Python脚本可以从原始AISHELL-1目录直接生成这三个子集的完整Kaldi风格数据目录。这是我认为全过程里最值得保存的一段代码。# prepare_aishell_data.py import os import re from collections import defaultdict RAW_DATA_DIR data_aishell OUT_DATA_DIR data TRANSCRIPT_FILE os.path.join(RAW_DATA_DIR, transcript, aishell_transcript_v0.8.txt) def read_transcript(transcript_file): 读取官方转写文件返回 {utt_id: text} utt2text {} with open(transcript_file, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue parts line.split(maxsplit1) if len(parts) ! 2: continue utt_id, text parts # 去掉官方转写里的分词空格转成连续汉字文本 text re.sub(r\s, , text) utt2text[utt_id] text return utt2text def build_one_set(utt2text, wav_root, out_dir): 根据wav目录下的某个子集生成wav.scp/utt2spk/text/spk2utt os.makedirs(out_dir, exist_okTrue) wav_scp, utt2spk, text [], [], [] speaker_dirs sorted( [d for d in os.listdir(wav_root) if os.path.isdir(os.path.join(wav_root, d))] ) for spk in speaker_dirs: spk_dir os.path.join(wav_root, spk) for wav_name in sorted(os.listdir(spk_dir)): if not wav_name.endswith(.wav): continue utt_id wav_name[:-4] if utt_id not in utt2text: print(fwarning: {utt_id} has no transcript, skip) continue wav_path os.path.abspath(os.path.join(spk_dir, wav_name)) wav_scp.append(f{utt_id} {wav_path}) utt2spk.append(f{utt_id} {spk}) text.append(f{utt_id} {utt2text[utt_id]}) for filename, lines in [ (wav.scp, wav_scp), (utt2spk, utt2spk), (text, text), ]: with open(os.path.join(out_dir, filename), w, encodingutf-8) as f: f.write(\n.join(lines) \n) # 由utt2spk生成spk2utt spk2utt defaultdict(list) with open(os.path.join(out_dir, utt2spk), r, encodingutf-8) as f: for line in f: utt, spk line.strip().split() spk2utt[spk].append(utt) with open(os.path.join(out_dir, spk2utt), w, encodingutf-8) as f: for spk in sorted(spk2utt): f.write(f{spk} { .join(sorted(spk2utt[spk]))}\n) if __name__ __main__: utt2text read_transcript(TRANSCRIPT_FILE) print(ftranscript 共 {len(utt2text)} 条) for subset in [train, dev, test]: build_one_set( utt2text, os.path.join(RAW_DATA_DIR, wav, subset), os.path.join(OUT_DATA_DIR, subset), ) print(f{subset} 完成)运行方式很简单python prepare_aishell_data.py脚本会在data/目录下生成train、dev、test三个子目录每个子目录里都有wav.scp、text、utt2spk、spk2utt四个文件。这里我特别说一下为什么text要去掉空格。如果你保留分词空格那么训练时会生成包含空格的特殊token这些token既不能提升模型能力还会让最终预测结果里出现莫名其妙的断裂感。字符级建模是中文ASR比较稳妥的默认选择后续如果要做分词或者NER可以在语言模型层面处理而不是在声学模型里掺和。3.3 检查音频格式16kHz单声道是硬指标数据目录生成后我强烈建议先做一次音频格式检查。虽然AISHELL-1自身是标准的16kHz单声道但有时候你从网盘下载、解压工具异常或者别处拷来的音频可能会混入非标准格式的样本。用下面这段小脚本快速扫描一部分音频# check_audio.py import wave import glob wav_files glob.glob(data_aishell/wav/train/**/*.wav, recursiveTrue) bad_files [] for wav_path in wav_files[:5000]: try: with wave.open(wav_path, rb) as wf: rate wf.getframerate() channels wf.getnchannels() if rate ! 16000 or channels ! 1: bad_files.append((wav_path, rate, channels)) except Exception as e: bad_files.append((wav_path, str(e))) print(f检查 {min(5000, len(wav_files))} 个文件异常文件数{len(bad_files)}) for item in bad_files[:10]: print(item)如果发现确实有采样率不是16kHz的音频可以用sox批量重采样sox input.wav -r 16000 -c 1 output.wav这一步虽然原始数据里几乎不会用到但等你以后换数据集或者接真实业务音频时这个习惯能帮你省掉很多排查时间。4. 训练第一个中文ASR模型配置解析与完整训练流程4.1 WeNet官方AISHELL recipe怎么改现在进入正式训练。进入WeNet仓库下的官方示例目录cd wenet/examples/aishell/s0这个目录下有个run.sh是官方写好的完整流程脚本。你只需要做两件事第一把data路径变量改成自己刚才生成的数据目录第二确认dict路径能正确生成。如果前面我们已经用prepare_aishell_data.py生成好了data/目录就可以直接从特征提取或者训练阶段开始跑。官方脚本最省心的地方在于它把整个流程拆成了多个stage你可以任意指定从哪一步开始、到哪一步结束。# 查看脚本支持的stage范围 bash run.sh --help # 例如直接跑特征提取到训练完成 bash run.sh --stage 1 --stop_stage 3关于特征提取多说一句。WeNet在训练时会从wav.scp里读取音频并在数据加载阶段实时做80维Fbank特征提取。这种方式的好处是不用提前生成巨量中间文件坏处是对磁盘IO和CPU有一定压力。如果你的机器比较强也可以用官方脚本里的离线特征方式但入门阶段直接用默认的实时特征提取就够了。4.2 模型配置文件逐段解读WeNet的AISHELL-1示例里主流配置是Conformer-Transducer结构。配置文件通常叫conf/train_conformer.yaml核心参数大概是下面这些encoder: conformer encoder_conf: output_size: 256 attention_heads: 4 linear_units: 2048 num_blocks: 12 dropout_rate: 0.1 positional_dropout_rate: 0.1 attention_dropout_rate: 0.1 input_layer: conv2d normalize_before: true cnn_module_kernel: 15 decoder: transformer decoder_conf: attention_heads: 4 linear_units: 2048 num_blocks: 6 dropout_rate: 0.1 positional_dropout_rate: 0.1 self_attention_dropout_rate: 0.1 src_attention_dropout_rate: 0.1 model_conf: ctc_weight: 0.3 lsm_weight: 0.1 length_normalized_loss: false这些参数里对效果影响最大的是num_blocks和output_size。output_size: 256代表编码器隐层维度num_blocks: 12代表编码器Stack了多少层Transformer/Conformer模块。cnn_module_kernel: 15则是Conformer特有的卷积模块卷积核大小它帮助模型捕捉语音信号里的局部时序信息这比标准Transformer在语音任务上更有效率。model_conf里的ctc_weight表示CTC损失和Attention损失的混合权重这里设0.3是让模型同时兼顾CTC的稳定对齐和Attention的上下文建模能力。lsm_weight是标签平滑用来缓解过拟合。4.3 启动训练单卡和多卡命令当data/目录和配置都确认无误后启动训练的命令其实非常简单。官方run.sh里会执行类似这样的训练命令python wenet/bin/train.py \ --config conf/train_conformer.yaml \ --data_type raw \ --train_data data/train \ --cv_data data/dev \ --gpu 0 \ --model_dir exp/conformer \ --num_workers 8如果是多卡训练把--gpu 0改成--gpu 0,1,2,3即可WeNet底层用DistributedDataParallel处理多卡同步。这里需要注意多卡训练时batch size是“单卡数值”总batch size等于单卡数值乘以卡数。所以如果你想获得和单卡batch size16相同的效果四卡训练时每卡就设成4。训练过程会输出每个epoch的loss和CER。这里说的CER是开发集上的字错误率整个过程里我建议你盯住这个数而不是只看loss。有时候loss在降但CER不动说明模型在过拟合训练集这时候需要看看数据增强和正则化。4.4 模型平均与解码CER怎么算WeNet训练完之后通常还会做一步“平均模型”。原因是训练末期模型参数会有波动把最后N个epoch的checkpoint平均一下往往能得到更稳定的结果。官方脚本里类似这样python wenet/bin/average_model.py \ --dst_model exp/conformer/avg_24.pt \ --src_path exp/conformer \ --num 24解码测试阶段可以用下面这条命令python wenet/bin/recognize.py \ --gpu 0 \ --mode ctc_greedy_search \ --config conf/train_conformer.yaml \ --data_type raw \ --test_data data/test \ --checkpoint exp/conformer/avg_24.pt \ --batch_size 32 \ --dict dict/lang_char.txt \ --result_dir exp/conformer这个命令会输出每条测试音频的识别文本放在result_dir/text里。随后用WeNet自带的评估工具去对比预测文本和标准文本python tools/compute-wer.py --char1 \ exp/conformer/text \ data/test/text--char1表示按字符计算得到的就是CER。在AISHELL-1测试集上合理训练出来的Conformer模型CER大约在5%上下。如果你的结果在6%到8%说明参数或训练细节还有优化的空间后面常见问题部分我会展开讲。5. 训练过程中最常见的6个坑和排查思路5.1 下载慢、解压异常、MD5不一致AISHELL-1全网传播渠道很多有些人从非官方渠道拿到压缩包结果解压时报错或者训到一半发现音频文件缺失。解决思路很朴素优先从OpenSLR下载并且下载完一定要校验MD5。如果官方地址实在慢可以找高校镜像或者国内数据集市场但拿到文件后还是要用md5sum对一遍再解压。另外解压后如果发现目录里多出__MACOSX文件夹这是macOS压缩带来的杂项rm -rf __MACOSX删掉即可不影响数据。5.2 显存不足和训练中断训练AISHELL-1时最常遇到的问题就是OOM。你看到CUDA out of memory的时候第一反应应该是调小batch size而不是换更大的卡。具体做法找到train.py调用里的--batch_size参数把16改成8、4。如果batch size调小后梯度更新次数变多训练收敛震荡可以同步把learning rate调低一点或者在训练命令里开启梯度累积。通俗解释就是先按小批量算梯度攒够几次再加权更新参数模拟一个大批量的效果。我自己的习惯是一张32G的V100跑AISHELL-1batch size设16不费劲如果只有11G显存设8更稳。5.3 CER一直不降或居高不下CER不降有很多原因最常见的是数据没准备好。比如text文件里保留了空格分词或者转写文本里混入了繁体字、英文标点都会让模型学到一些奇怪的映射。先用这个命令检查几行数据head -n 5 data/train/text head -n 5 data/train/wav.scp其次是字典问题。WeNet训练前要用data/train/text生成词表如果词表里混入了空格tokenCER一定受影响重新生成词表时要仔细看dict/lang_char.txt里是否出现了空格。另外如果你在AISHELL-1上反复调整几个epoch后发现CER还是很高可以检查一下学习率warmup步数是否合理。WeNet用的是带warmup的Transformer调度器warmup步数太小容易在前期震荡。5.4 多卡训练时效果反而更差多卡训练结果不如单卡通常有三个原因一是总batch size变了但学习率没调二是不同卡之间数据分布不均三是一些依赖随机性的操作在多卡下行为不一致。最简单的排查方式先用单卡跑通一个小的、比如5000条数据的实验记录CER再用多卡跑同样小实验对比两者差异。如果多卡明显更差优先考虑把batch size和学习率按比例调整。5.5 GPU训练可以CPU推理很慢模型训完如果你在CPU上做实时推理会发现Conformer模型延迟有点高。这不一定是模型训得有问题而是模型本身计算量较大。解决办法是铺垫量化或者剪枝也可以用WeNet导出TorchScript后开启int8动态量化绝大多数场景下能把延迟降到可用水平。不过这属于后话第一次训练实验不用急着优化。5.6 数据加载成了训练瓶颈如果你发现GPU利用率起伏很大训练速度上不去多半是数据加载卡住了。检查一下--num_workers参数在多核机器上可以设成8或者更高。同时尽量把data_aishell和训练中间结果放在SSD上机械硬盘的随机读速度会拖慢整个流程。下面用一个速查表总结这些典型问题现象可能原因解决思路CUDA out of memorybatch size太大调小batch size开启梯度累积CER不降text有空格、词表异常检查text文件与dict生成过程训练速度慢num_workers太少、磁盘慢增加worker使用SSD多卡效果差学习率未按batch size调整按总batch size比例调整学习率CPU推理慢模型参数过大导出TorchScript后量化数据下载解压失败文件不完整用MD5校验后重新下载6. 更进一步从基准模型走向实际应用6.1 用更大的中文数据集提升泛化AISHELL-1跑通只是第一步。它的口音和场景比较单一直接部署到真实环境中通常会被各种口音、噪声、专有名词打回原形。这时可以考虑用AISHELL-2或者WenetSpeech作为补充数据。不过这里要提醒一句数据量增大后训练时间会指数级上升显存和内存需求也会变大。稳妥的做法是先保留AISHELL-1作为开发集和测试集用更大的数据集做训练集这样你还能直接对比AISHELL-1官方CER看你有没有跑偏。6.2 蒸馏一个小模型部署到边缘设备如果目标是部署到手机或嵌入式设备AISHELL-1训练出来的完整Conformer模型太大。一个很实用的路径是做模型蒸馏用训练好的大模型当teacher让一个小模型去学它的输出分布。小模型可以用更少的参数量达到接近大模型的CER。WeNet社区里对Squeezeformer、Zipformer这类轻量结构讨论很多它们本质上就是在计算效率和识别精度之间做权衡。你可以在跑通AISHELL-1后用同样的数据把配置文件改成Zipformer再训练一遍很快就能体会到不同架构的差异。6.3 领域适配比想象中重要AISHELL-1覆盖的是智能语音助手类场景如果业务是医疗、法律、教育等垂直领域直接拿通用模型上生产环境效果一定会打折扣。这时候最有效的不是继续堆模型结构而是做领域文本的language model融合或者搜集目标领域的少量音频做增量训练。我个人在项目里的做法是先把AISHELL-1这类公开数据练出基线再用真实的业务数据做一版微调最后对比两者在不同测试集上的差异。这个过程能让你对整个ASR系统有比较完整的体感。回到AISHELL-1本身我到现在还会经常拿它做各种新模型的“试纸”。这次写这篇教程我又把它从OpenSLR下载下来重新跑了一遍数据准备和训练流程很多细节和两年前一样但理解比当初深入了不少。跑通整个流程只是开始真正值钱的是你亲手维护了一条从原始音频到文本标签对齐的数据pipeline并且知道每个环节为什么会出错、该怎么修。后面无论你换更大的数据集、更复杂的模型还是转去研究流式识别、说话人日志这段基本功都会长期派上用场。真要上手的话建议现在就打开终端把AISHELL-1下载下来按上面的代码一步步跑遇到问题再回头对照速查表比反复看教程有用得多。