【语音识别】WeNet 工具包 LibriSpeech 语音识别流程梳理(上)
WeNet工具包采用U2++架构统一流式与非流式语音识别,通过同一模型参数支持实时低延迟和高精度两种场景。其处理流程包括:数据下载与准备、特征计算与BPE分词、词表构建、数据格式转换及模型训练。关键点包括:使用LibriSpeech数据集,计算全局CMVN统计量,训练5000个subword的BPE模型,构建包含特殊符号的字典,生成JSON格式的训练数据列表,并支持多GPU分布式训练。该方案显著降
前言
本文通过梳理 WeNet 工具包 LibriSpeech 的语音识别流程,理解 WeNet 是如何采用 U2(Unified Two-pass)架构从算法侧到工程侧实现了流式(Streaming)/ 非流式(Non-streaming)语音识别的统一。基于 two-pass 的架构让同一套模型参数既支持实时低延迟的流式识别(如语音助手、直播字幕),也支持高精度的非流式识别(如语音转写、录音分析),无需为两种场景单独训练、部署模型,大幅降低工业开发成本。
因为内容较多,拆成了上下两篇,上篇主要是数据处理,下篇是 CTC/AED 核心算法。
一、脚本流程
代码流程可参考 run.sh,配置文件采用 train_u2++_conformer.yaml。
| 配置文件 | use_dynamic_chunk | causal | 备注 |
|---|---|---|---|
| train_u2++_conformer.yaml | true | true | 标准 U2++ |
| train_u2++_conformer_lowmem.yaml | true | true | 显存优化版 |
| train_u2++_squeezeformer.yaml | true | true | Squeezeformer 流式 |
| train_u2++_branchformer.yaml | true | true | Branchformer 流式 |
| train_unified_conformer.yaml | true | true | 单 decoder,非 BiTransformer |
| train_u2++_efficonformer_v1.yaml | true | false | 非因果 |
| train_u2++_efficonformer_v2.yaml | true | false | 非因果 |
Stage -1:数据下载
从 www.openslr.org/resources/12 下载 LibriSpeech
子集:dev-clean, test-clean, dev-other, test-other, train-clean-100, train-clean-360, train-other-500
使用 local/download_and_untar.sh 下载并解压
Stage 0:数据准备
对每个子集调用 local/data_prep_torchaudio.sh
生成标准数据目录结构,包括 wav.scp、text,wav.scp 保存音频位置,text 保存转录信息,如
103-1240-0015 IF HE'D RUN OUT OF TURNIP SEED HE WOULDN'T DRESS UP AND TAKE THE BUGGY TO GO FOR MORE
输出目录形如:data/dev_clean, data/test_clean, data/train_clean_100 等
Stage 1:特征与数据组织
合并训练集:train_clean_100 + train_clean_360 + train_other_500 → train_960
合并验证集:dev_clean + dev_other → dev
使用 tools/compute_cmvn_stats.py 计算全局 CMVN 统计量
输出 global_cmvn,供训练和推理时做特征归一化
Stage 2:词表与 BPE
构建字典 data/lang_char/train_960_unigram5000_units.txt
用 SentencePiece 训练 unigram BPE(nbpe=5000)
生成 BPE 模型和词表,用于 subword 表示
这里说明下词表构建。BPE(Byte Pair Encoding)是字节对编码,是当前主流的子词分词算法,介于字符级和单词级之间。核心思想是用数据驱动的方式,自动把高频出现的连续字符组合合并成一个 “子词”,最终词汇表里是 字符 + 高频子词。目标是用最小的词汇表,覆盖最多的文本,同时不产生太长序列。
英文如果单词表建模,词表会很大(轻松 10w+),模型参数巨大。OOV(Out Of Vocabulary)一大堆,导致识别错误。如果字符建模,英文一共 26 个字母加标点,词汇表太小,序列会非常长,一句话可能变成上百字符,模型建模长序列依赖困难;没有语义信息。如果是 BPE 建模,粒度就比较合适,可以学到一些词根和词缀,搭配上字符,可以完美解决 OOV 问题。词汇表小、序列短、训练稳。
比如在 Stage 2 中,cut -f 2- -d" " $wave_data/${train_set}/text > $wave_data/lang_char/input.txt,输出 input.txt,每行一条纯文本,用于 BPE 训练。
示例:
# text 原文
103-1240-0000 CHAPTER ONE MISSUS RACHEL LYNDE...
# input.txt 结果
CHAPTER ONE MISSUS RACHEL LYNDE...
训练 BPE 模型
tools/spm_train --input=$wave_data/lang_char/input.txt \
--vocab_size=${nbpe} \ # 5000 个 subword
--model_type=${bpemode} \ # unigram
--model_prefix=${bpemodel} \ # 输出前缀
--input_sentence_size=100000000 # 使用尽可能多的句子
-
用 SentencePiece 在 input.txt 上训练 unigram BPE。
-
生成:train_960_unigram5000.model、train_960_unigram5000.vocab。
-
作用:把文本切分成 subword,平衡词表大小和建模粒度。
从 BPE 编码结果构建 Wenet 符号表
tools/spm_encode --model=${bpemodel}.model --output_format=piece < $wave_data/lang_char/input.txt \
| tr ' ' '\n' \ # 空格换行,每个 token 一行
| sort | uniq \ # 去重并排序
| awk '{print $0 " " NR+2}' >> ${dict} # 从 3 开始赋 id
- spm_encode:用 .model 把 input.txt 全部转成 piece 序列。
- tr ’ ’ ‘\n’:把空格换成换行,每个 piece 一行。
- sort | uniq:按字母序排序并去重,得到所有出现过的 subword。
- awk ‘{print $0 " " NR+2}’:为每个 token 分配从 3 开始的 id(0、1、2 已被特殊符号占用)。
| 符号 | ID | 用途 |
|---|---|---|
<blank> |
0 | CTC blank,必须为 0,CTC 输出在 blank 和非 blank 间切换 |
<unk> |
1 | 未知 token,OOV 时使用 |
<sos/eos> |
2 | decoder 的开始/结束,统一用 2 |
最后生成的字典 train_960_unigram5000_units.txt 形式如下
<blank> 0
<unk> 1
<sos/eos> 2
' 3
▁ 4
▁A 5
A 6
▁ABANDON 7
ABETH 8
......
它在训练的时候,文本先经过 BPE 切分,用字典(符号表)把 token 映射成 id 送入模型。CTC head 和 decoder 输出维数等于 vocab_size,对应符号表大小。解码的时候,模型输出 id 通过符号表查回 token,拼接成最终文本。
Stage 3:Wenet 数据格式
对每个数据集(dev、test_clean、test_other、train_960 等):
- 调用 tools/make_raw_list.py
- 从 wav.scp 和 text 生成 data.list
data.list 是 Wenet 训练与解码所需格式
make_raw_list.py 会生成 data.list,每行一个 JSON 对象,例如:
{"key": "103-1240-0000", "wav": "/path/to/103-1240-0000.flac", "txt": "CHAPTER ONE..."}
| 字段 | 说明 |
|---|---|
key |
utterance id,便于对齐和调试 |
wav |
音频路径,DataLoader 按此加载 |
txt |
转写文本,用于标签和损失计算 |
Stage 4:模型训练
if [ ${stage} -le 4 ] && [ ${stop_stage} -ge 4 ]; then
mkdir -p $dir # 创建训练输出目录,如 exp/sp_spec_aug
# 统计 CUDA_VISIBLE_DEVICES 中逗号分隔的 GPU 数量(如 0,1,2,3 → 4)
num_gpus=$(echo $CUDA_VISIBLE_DEVICES | awk -F "," '{print NF}')
# PyTorch DDP 用 NCCL 做 GPU 间通信,多卡训练时比 gloo 更快
dist_backend="nccl"
# 训练脚本会把配置文件写到 $dir/train.yaml,并补充模型输入/输出维度
# 解码和模型导出都会读取这个 train.yaml
# 根据 train_engine 打印当前使用的训练引擎(DeepSpeed 或 PyTorch DDP)
if [ ${train_engine} == "deepspeed" ]; then
echo "$0: using deepspeed"
else
echo "$0: using torch ddp"
fi
echo "$0: num_nodes is $num_nodes, proc_per_node is $num_gpus"
# --nnodes 节点数,单机为 1
# --nproc_per_node 每节点进程数(一般等于 GPU 数)
# --rdzv_endpoint rendezvous 地址,如 localhost:0
# --rdzv_id rendezvous ID,用于多任务区分
# --rdzv_backend rendezvous 后端,c10d 为 PyTorch 内置集合通信
torchrun --nnodes=$num_nodes --nproc_per_node=$num_gpus --rdzv_endpoint=$HOST_NODE_ADDR \
--rdzv_id=$job_id --rdzv_backend="c10d" \
wenet/bin/train.py \
--train_engine ${train_engine} \ # 训练引擎:torch_ddp / deepspeed
--config $train_config \ # 配置文件 conf/train_conformer.yaml
--data_type ${data_type} \ # 数据格式 raw(原始音频)
--train_data $wave_data/$train_set/data.list \ # 训练集 data.list
--cv_data $wave_data/$dev_set/data.list \ # 验证集 data.list
${checkpoint:+--checkpoint $checkpoint} \ # 若 checkpoint 有值则续训
--model_dir $dir \ # 输出目录
--tensorboard_dir ${tensorboard_dir} \ # TensorBoard 日志目录
--ddp.dist_backend $dist_backend \ # DDP 后端 nccl
--num_workers ${num_workers} \ # DataLoader worker 数
--pin_memory \ # 使用 pinned memory,加快 CPU→GPU 拷贝
--deepspeed_config ${deepspeed_config} \ # DeepSpeed 配置
--deepspeed.save_states ${deepspeed_save_states} # DeepSpeed 保存模式
fi
流程概览
torchrun 启动 num_gpus 个进程
↓
每个进程执行 wenet/bin/train.py
↓
按 config 构建模型、加载 train/dev data.list
↓
使用 DDP 在各 GPU 上同步训练
↓
checkpoint 和 train.yaml 写入 model_dir
二、训练流程
数据与训练流程简图
data.list
↓
JSON 解析 → 音频加载 → tokenize → fbank → spec_aug → batch(padding)
↓
batch_dict: {feats, target, feats_lengths, target_lengths, ...}
↓
┌─────────────────────────────────────────────────────────────┐
│ ASRModel.forward │
│ feats → Encoder → encoder_out │
│ ├→ CTC(encoder_out, target) → loss_ctc │
│ └→ Decoder(encoder_out, target) → loss_att │
│ │
│ loss = ctc_weight * loss_ctc + (1-ctc_weight) * loss_att │
└─────────────────────────────────────────────────────────────┘
↓
loss.backward() → clip_grad → optimizer.step() → scheduler.step()
Dataset 是一串 map 操作:
data.list (JSONL)
→ parse_json (解析 key/wav/txt)
→ decode_wav (读音频)
→ singal_channel (单声道)
→ tokenize (文本 → token id)
→ filter # 过滤异常样本:过短(噪音)、过长(显存爆炸)、文本为空或过长;
# 控制 token/帧 比例:避免几乎没有语音或文本远长于音频的样本
# 提高训练效率
→ resample (16kHz)
→ compute_fbank (提取 fbank)
→ spec_aug (SpecAugment,训练集)
→ shuffle / sort # 通常 sort_size < shuffle_size(如 500 < 1500)
# 先 shuffle 再局部 sort,既保证随机性,又照顾到长度相近时的 padding
→ batch (padding)
打好的 batch 是一个字典,下面是每个键的含义:
| Key | 类型 | 含义 | 在训练里的作用 |
|---|---|---|---|
| keys | list[str],长度 B |
每条样本的 utt id(如文件名或 “1088-134315-0004”),与 data.list 里一一对应 | 日志、对齐、解码结果写 text 时按 key 区分样本;不参与 loss 计算 |
| feats | Tensor (B, T, F) |
Fbank 特征,按帧、已按 feats_lengths 降序排好,短句用 0 padding 到 T | 模型主输入:speech,进 Encoder,核心训练输入 |
| feats_lengths | Tensor (B,),int32 |
每个样本的有效帧数(未 pad 的长度) | 做 encoder mask、CTC 的 encoder_out_lens 等,必须 |
| target | Tensor (B, U),int64 |
标签序列(token id),padding 用 -1(ignore_id) | 模型里的 text:CTC 目标、Attention decoder 目标,核心训练目标 |
| target_lengths | Tensor (B,),int32 |
每个样本的有效标签长度(不含 padding) | CTC/Attention loss 里做 mask,必须 |
| pcm | Tensor (B, L) |
原始波形(raw wav),按样本长度 pad 到 L | 若用 raw 前端会在别处用;当前 U2/U2++ 用 feats 时训练里一般不用 |
| pcm_length | Tensor (B,) |
每个样本的有效采样点数 | 和 pcm 配套,训练用 feats 时一般不参与 |
| langs | list[str],长度 B |
每条样本的语言标识(如 “en”) | 多语/多任务时传给 decoder(如 _calc_att_loss 的 infos),单语可忽略 |
| tasks | list[str],长度 B |
每条样本的任务标识(如 “transcribe”) | 多任务时区分任务,单任务可忽略 |
参考文献
[1] Yao Z , Wu D , Wang X ,et al.WeNet: Production oriented Streaming and Non-streaming End-to-End Speech Recognition Toolkit[J]. 2021.DOI:10.48550/arXiv.2102.01547.
[2] Zhang B , Wu D , Peng Z ,et al.WeNet 2.0: More Productive End-to-End Speech Recognition Toolkit[J]. 2022.DOI:10.48550/arXiv.2203.15455.
[3] https://github.com/wenet-e2e/wenet.
更多推荐

所有评论(0)