做语音识别的朋友应该都有同感:搞到一份干净、可用、带标准转写的中文普通话语料,比调模型参数还费劲。我两年前第一次给团队搭端到端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.txt,UTF-8编码,每行是一条音频的转写。但这里有一个非常坑的点:官方文本是分词后的结果,词和词之间用空格分隔。比如某一行实际内容长这样:
BAC009S0002W0122 而 对 于 如 何 解 决 这个 问题如果直接把整行读进来当标签,模型会把这些空格也学进去,导致词表里出现一堆莫名其妙的带空格内容。正确做法是去掉所有空格,转成连续汉字文本,也就是而对于如何解决这个问题,再用字符级token去训练。
另外,AISHELL-1的转写里偶尔会出现数字、英文单位或者中英混排的情况,这些都属于中文文本规范化要处理的范围。入门阶段可以先简单处理,把所有非汉字内容按常见规则替换掉,等后面需要精调再上完整的中文文本正则化工具。
1.3 和其他中文开源数据集的横向对比
很多初学者会问:市面上中文语音数据集那么多,为什么偏偏推荐AISHELL-1?我用一张表把常见的几个开源普通话数据集放在一起对比。
| 数据集 | 时长 | 说话人数 | 采样率 | 主要场景 | 转写粒度 |
|---|---|---|---|---|---|
| AISHELL-1 | 约178h | 400 | 16kHz | 智能语音助手 | 词级 |
| THCHS-30 | 约30h | 50+ | 16kHz | 新闻朗读 | 字级 |
| Primewords | 约100h | 296 | 16kHz | 手机语音输入 | 句级 |
| AISHELL-2 | 约1000h | 1991 | 16kHz | 多场景采集 | 词级 |
| WenetSpeech | 10000h+ | 多来源 | 16kHz | 网络音频混合 | 部分标注 |
AISHELL-1最大的优势在于“规模适中”。只有几百小时数据,单张V100显卡就能在一天内完成完整训练,规模又足够看出不同模型架构之间的效果差异,不会像THCHS-30那样因为数据量太少导致过拟合严重。加上转写质量高、口音相对标准,测试集字错误率(CER)做到个位数是完全可复现的。
2. 技术路线选型:先别急着训练,选对框架省一半时间
2.1 三条主流路线的优缺点对比
在动手指之前,我先梳理一下当前训练中文ASR模型的几条主流路线,你可以根据自己的基础选。
第一是WeNet。这是国内开源社区非常活跃的语音识别工具,核心是Conformer-Transducer的U2/U2++架构,训练、解码、导出部署模型一体化,对中文场景支持很好,默认支持Kaldi风格的数据目录。
第二是ESPnet。研究圈用得多,recipe非常丰富,模块化程度高,几乎把业界主流模型都实现了,但依赖库多、安装较重,新手第一次跑起来往往要折腾一晚上。
第三是HuggingFace Transformers。用Wav2Vec2或者Whisper做微调,代码量最少,几行就能跑起来,但中文ASR的最终效果和工程落地能力不一定有前两者好,更适合做快速原型验证。
三者对比如下:
| 维度 | WeNet | ESPnet | HuggingFace |
|---|---|---|---|
| 安装复杂度 | 中等 | 较高 | 低 |
| 中文效果 | 很好 | 很好 | 看预训练模型 |
| 官方AISHELL脚本 | 自带 | 自带 | 需自己写 |
| 部署支持 | TorchScript/ONNX | 一般 | 一般 |
| 上手难度 | 适中 | 偏难 | 最容易 |
2.2 为什么这次选WeNet
我最终选择WeNet作为这篇教程的主线,原因有三个。
第一,WeNet官方仓库里本身就带了AISHELL-1的完整recipe,路径改一改就能跑通,这意味着你遇到的大部分问题社区里都已经有人踩过并解决了。第二,WeNet默认使用的Conformer-Transducer架构在中文识别上比早期纯CTC模型效果更好,同时训练速度又比纯Attention模型快不少。第三,它导出TorchScript和ONNX非常方便,你跑通后想继续做服务端部署,几乎不需要迁移代码。
多说一句,如果你只是临时想验证某个想法,HuggingFace路线也不错;但只要是认真想做出一个能用的中文ASR基线,WeNet会是性价比最高的选择。
2.3 环境配置与依赖安装
训练环境我建议用Linux,Windows不是不行,但折腾起来麻烦。下面是一套我验证过的环境配置流程。
conda create -n wenet python=3.9 -y conda activate wenet # 注意:PyTorch版本要和你机器的CUDA版本匹配 # 以CUDA 11.8为例 pip install torch==2.1.0 torchaudio==2.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", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue parts = line.split(maxsplit=1) 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_ok=True) 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(f"warning: {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", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") # 由utt2spk生成spk2utt spk2utt = defaultdict(list) with open(os.path.join(out_dir, "utt2spk"), "r", encoding="utf-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", encoding="utf-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(f"transcript 共 {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", recursive=True) 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 size=16相同的效果,四卡训练时每卡就设成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 --char=1 \ exp/conformer/text \ data/test/text--char=1表示按字符计算,得到的就是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-1,batch 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生成词表,如果词表里混入了空格token,CER一定受影响,重新生成词表时要仔细看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 memory | batch 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下载下来,按上面的代码一步步跑,遇到问题再回头对照速查表,比反复看教程有用得多。