Kaldi 训练声学模型

前言

kaldi 是最为流行的语音识别开发工具,这次我们使用 kaldi 来进行一个唤醒词(keyword spotting)模型的训练。本篇主要涉及声学模型部分。

智能音箱、语音助手往往都需要一个唤醒词,唤醒词部分离线低功耗处理,成功唤醒后的音频数据再交给服务端进行在线识别,例如 “小爱同学” 的唤醒就是通过唤醒词模型实现的。我这里虽然简单将目标描述为“唤醒词模型”,但其实与业界一般意义上的唤醒词还不大一样,用“命令词识别”可能会更准确些。我的需求是要对一系列词汇即时唤醒,而非单个词。

需求描述:

1、在 Android 手机端运行,因此需要保证模型低功耗、内存占用相对较小;
2、支持多个命令词唤醒;
3、当有新的命令词汇列表需求时,能够快速训练适配.

方案对比,Guideboard:

  1. Tensorflow audio recognition 官方入门文档

    需要大量样本数据,每次修改词汇列表需要整个模型重训练。

  2. Snowboy 方案
    本质原理类似指纹识别,与模板进行相似度比较,不适用于词汇很多的情况。

  3. 使用厂商SDK
    例如百度语音唤醒,基本能够满足我们的需求,但自由度不够大。

  4. PocketSphinx
    可以在一定程度上满足需求,之前有过很多尝试(详见另一篇文章),但效果不够理想。

  5. kaldi-egs-mobvoi
    最近刚发现也是最近刚合入kaldi项目的,出门问问在kaldi上开源的 E2E LF-MMI recipes。还没来得及研究尝试。

  6. 本文方案:
    基于 kaldi aishell2 训练得到通用语音识别模型,即 LVCSR(Large Vocabulary Continuous Speech Recognition),然后修改语言模型使之应用于唤醒词。

    思路主要源于这篇文章: 基于kaldi训练唤醒词模型的一种方法,以及 PocketSphinx 的使用经验。

    对语言模型的修改方法源于 kaldi 官方文档 online_decoding 一节

前置条件

  • python2 环境
  • kaldi github 下载下来,并进行了 kaldi 的环境编译
  • 如果使用 aishell2 的模型,并使用 aishell 数据,先把数据手动下载下来

基本配置

将空的 aishell2 复制一份,这样之后,在自己新建的 recipe 下(我本次例子中为 kaldi_root/egs/benz_wakeup2 )应有 s5 文件夹,s5 文件夹下有以下文件:

1
RESULTS  cmd.sh  conf  local  path.sh  run.sh  steps  utils

aishell2 recipe 中,stepsutils 其实都是软链接到 ../../wsj/s5 里的对应文件夹的,也就是复用了这些脚本,local 文件夹中的脚本是针对本 recipe 的一些代码,也是我们将要主要修改的地方。

为了能在单机而不是集群上跑起来,首先需要把 cmd.sh 脚本中的 queue.pl 都改为 run.pl,文件头有如下注释说明:

1
2
3
4
# If you have no queueing system and want to run on a local machine, you
# can change all instances 'queue.pl' to run.pl (but be careful and run
# commands one by one: most recipes will exhaust the memory on your
# machine). queue.pl works with GridEngine (qsub).

接下来,run.sh 可以用来执行训练,但是不建议直接尝试训练,而是应该把里面的每步拎出来,厘清参数分别手动执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# prepare trn/dev/tst data, lexicon, lang etc
if [ $stage -le 1 ]; then
local/prepare_all.sh ${trn_set} ${dev_set} ${tst_set} || exit 1;
fi

# GMM
if [ $stage -le 2 ]; then
local/run_gmm.sh --nj $nj --stage $gmm_stage
fi

# chain
if [ $stage -le 3 ]; then
local/chain/run_tdnn.sh --nj $nj
fi

可以从调用脚本的名字和注释可以了解到,大致分为三步:

  1. 准备数据和环境: prepare_all.sh
  2. run_gmm: 进行 gmm hmm 等训练
  3. run_tdnn: 进行 tdnn 模型训练

下面分别讲解:

数据准备 以及 语言模型的训练

关于训练音频语料的数据准备,有下面三个选择:

  1. 自己录制或搜寻语料,并修改为 aishell2 的组织格式
  2. 下载 aishell recipe 的免费语料,得到 data_aishell.tar.gz,约 15G,然后借助以下脚本将文件组织为 aishell2 的结构: I19tModel/hotword_detection/tools/prepare_aishell2_corpus.ipynb
  3. 使用软链接指向已有的语料目录:
    1
    ln -s /home/benjamin/workspace/I19tModel/hotword_detection/kaldi/egs/aishell2/corpus corpus

语料数据准备好后,在你的 egs recipe 目录下,除了 s5 文件夹外,还会有个 corpus 文件夹,其内的数据组织为如下结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
.
├── dev
│ ├── trans.txt
│ ├── wav
│ └── wav.scp
├── test
│ ├── trans.txt
│ ├── wav
│ └── wav.scp
└── train
├── trans.txt
├── wav
└── wav.scp

wav 文件夹下则是说话人编号的文件夹,而其内即对应的音频样本。

接下来可以直接使用 prepare_all.sh 进行语料预处理和语言模型的训练了。
进入 s5 目录,然后执行以下命令即可:

1
local/prepare_all.sh ../corpus/train ../corpus/dev ../corpus/test

脚本的前半部分(1、2步)会进行语料音素映射,以备后续训练;后半部分是进行语言模型的训练。
这部分脚本都不必做修改,prepare_dict.sh 值得再看一下,因为里面涉及到 aishell2 对中文词汇的处理以及对词汇外音素的选择:

1
2
3
4
5
6
7
8
9
# download the DaCiDian from github
if [ ! -d $download_dir ]; then
git clone https://github.com/aishell-foundation/DaCiDian.git $download_dir
fi

# here we map <UNK> to the phone spn(spoken noise)
mkdir -p $dir
python $download_dir/DaCiDian.py $download_dir/word_to_pinyin.txt $download_dir/pinyin_to_phone.txt > $dir/lexicon.txt
echo -e "<UNK>\tspn" >> $dir/lexicon.txt

aishell2 将中文文本语料使用 DaCiDian.py,先把词汇转为拼音,然后再转为音素。这样可以简化词汇的标注工作。详见该项目 github README。

训练完成后,主要是多了 s5/data 文件夹,其下包括这些文件夹:

1
dev  lang  lang_test  local  test  train

lang 是主要语言模型训练的主要结果,train dev test 里都是些索引文件,方便后续训练的,而 local 文件夹中是刚才训练时的临时文件,里面可以看到有 DaCiDian 文件夹。

GMM-HMM 训练

然后参照run.sh中的步骤,使用run_gmm.sh进行接下来GMM-HMM的训练。不过,为了使训练出的模型能耗尽可能小,这里我们把 mfcc 特征提取中的 pitch 特征去掉,从 make_mfcc_pitch.sh 换用到 make_mfcc.sh

上图中圈出来的第二个部分,也可能需要根据自己数据集的数据量进行相应修改。

另外,由于去掉了 pitch 所带来的变化,还要像下面一样把 mono、tri1、tri2、tri3 训练步骤中的 decoding 部分都给注释掉,这些部分主要是用来进行测试的,对训练不会有影响。

最后,执行以下脚本开始 GMM-HMM 的训练

1
local/run_gmm.sh --nj 30 --stage 1

训练完成后:
提示 local/run_gmm.sh succeeded
在 s5 文件夹下,多出了 exp 和 mfcc 目录,在 exp 下,有如下训练结果:

1
make_mfcc  mono  mono_ali  tri1  tri1_ali  tri2  tri2_ali  tri3  tri3_ali  tri3_ali_dev

进行 chain 神经网络模型的训练

前面提到,为了降低模型能耗,在 mfcc 时,我们省掉了 pitch 特征的提取。在 chain 神经网络训练时,还会重复进行一次 mfcc 特征提取,即 run_tdnn.sh 中的 stage 5 的部分,因此,这个时候也要做适当修改来略过 pitch 特征。

两种方法:

1、我们可以将原脚本中的 make_mfcc_pitch.sh 改为 make_mfcc.sh,随后注释掉 limit_feature_dim.sh 脚本等部分,把后续脚本中 ${datadir}_hires_nopitch 都改为 ${datadir}_hire

2、 也可以维持特征提取的部分不变,在 Stage 10 和 stage 11 中,把相关输入特征改为 ${datadir}_hires_nopitch 即可。

这里我采用后者方法。(因为我打算保留 ivector 特征,而 ivector 特征原本就是用 ${datadir}_hires_nopitch 训练的)这篇博文中还提到了去掉 ivector 特征以减少内存消耗,也值得参考下,原文有附整个脚本,注意在 stage 10 和 stage 11 中要把 ivector 作为输入的相关代码注掉。

stage 10 是将模型结构预先输出到一个 xconfig 配置文件中,我们通过修改该部分来极大缩减神经网络规模,还是主要参考自这篇博文,改后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
if [ $stage -le 10 ]; then
echo "$0: creating neural net configs using the xconfig parser";
#feat_dim=$(feat-to-dim scp:data/${train_set}_hires/feats.scp -)
feat_dim=$(feat-to-dim scp:data/${train_set}_hires_nopitch/feats.scp -)
num_targets=$(tree-info $treedir/tree | grep num-pdfs | awk '{print $2}')
learning_rate_factor=$(echo "print (0.5/$xent_regularize)" | python)
opts="l2-regularize=0.002"
linear_opts="orthonormal-constraint=1.0"
output_opts="l2-regularize=0.0005 bottleneck-dim=64"

mkdir -p $dir/configs
cat <<EOF > $dir/configs/network.xconfig
input dim=100 name=ivector
input dim=$feat_dim name=input

# please note that it is important to have input layer with the name=input
# as the layer immediately preceding the fixed-affine-layer to enable
# the use of short notation for the descriptor
fixed-affine-layer name=lda input=Append(-1,0,1,ReplaceIndex(ivector, t, 0)) affine-transform-file=$dir/configs/lda.mat

# the first splicing is moved before the lda layer, so no splicing here
relu-batchnorm-dropout-layer name=tdnn1 $opts dim=320
linear-component name=tdnn2l dim=64 $linear_opts input=Append(-1,0)
relu-batchnorm-dropout-layer name=tdnn2 $opts input=Append(0,1) dim=320
linear-component name=tdnn3l dim=64 $linear_opts
relu-batchnorm-dropout-layer name=tdnn3 $opts dim=320
linear-component name=tdnn4l dim=64 $linear_opts input=Append(-1,0)
relu-batchnorm-dropout-layer name=tdnn4 $opts input=Append(0,1) dim=320
linear-component name=tdnn5l dim=64 $linear_opts
relu-batchnorm-dropout-layer name=tdnn5 $opts dim=320 input=Append(tdnn5l, tdnn3l)
linear-component name=tdnn6l dim=64 $linear_opts input=Append(-3,0)
relu-batchnorm-dropout-layer name=tdnn6 $opts input=Append(0,3) dim=320
linear-component name=tdnn7l dim=64 $linear_opts input=Append(-3,0)
relu-batchnorm-dropout-layer name=tdnn7 $opts input=Append(0,3,tdnn6l,tdnn4l,tdnn2l) dim=320
# linear-component name=tdnn8l dim=256 $linear_opts input=Append(-3,0)
# relu-batchnorm-dropout-layer name=tdnn8 $opts input=Append(0,3) dim=1280
# linear-component name=tdnn9l dim=256 $linear_opts input=Append(-3,0)
# relu-batchnorm-dropout-layer name=tdnn9 $opts input=Append(0,3,tdnn8l,tdnn6l,tdnn4l) dim=1280
# linear-component name=tdnn10l dim=256 $linear_opts input=Append(-3,0)
# relu-batchnorm-dropout-layer name=tdnn10 $opts input=Append(0,3) dim=1280
# linear-component name=tdnn11l dim=256 $linear_opts input=Append(-3,0)
# relu-batchnorm-dropout-layer name=tdnn11 $opts input=Append(0,3,tdnn10l,tdnn8l,tdnn6l) dim=1280
linear-component name=prefinal-l dim=64 $linear_opts

relu-batchnorm-layer name=prefinal-chain input=prefinal-l $opts dim=320
output-layer name=output include-log-softmax=false dim=$num_targets $output_opts

relu-batchnorm-layer name=prefinal-xent input=prefinal-l $opts dim=320
output-layer name=output-xent dim=$num_targets learning-rate-factor=$learning_rate_factor $output_opts

EOF
steps/nnet3/xconfig_to_configs.py --xconfig-file $dir/configs/network.xconfig --config-dir $dir/configs/
fi

模型结构应该还有很大调优空间的,这里就先拿来主义了。

接下来正式启动训练,local/chain/run_tdnn.sh,运行到 stage 11, 也就是真正进行神经网络训练的时候,可能需要减少工作线程,如果报错请修改 stage 和 nj 参数后再继续跑下去。

到此,我们所要的声学模型部分就训练完成了。事实上,到这里也已经训练得到了一个基准的 LVCSR 模型,此时可以进行一些简单的语音识别服务了。在 s5/exp/chain/ 目录下应有新生成的相关模型文件,其中 s5/exp/chain/tdnn_1b_all_sp/final.mdl 是关键的声学模型文件,s5/exp/chain/tdnn_1b_all_sp/graph 下的 HCLG.fst 和 words.txt 则为语言模型文件和词典,有他们再加一点配置参数,就可以进行解码、识别了。

解码:

这里使用搭建 TCP server 的方式测试下在线解码能力。

官方文档其实已经有很详细的说明,在 online_decodingTCP server for nnet3 online decoding 一节。

从官方文档上看到,部署 tcp server 通过 online2-tcp-nnet3-decode-faster 完成。除了 nnet3-in(声学模型) fst-in(语言模型) word-symbol-table(词典) 这三个必要参数外,还需要先准备一个 online.conf 文件,通过类似下面的命令实现:

1
steps/online/nnet3/prepare_online_decoding.sh --add-pitch false data/lang exp/chain/extractor_all exp/chain/tdnn_1b_all_sp ./online_conf

由于我训练时保留了 ivector 特征,命令中才会有 exp/chain/extractor_all ,这个参数是可选的,如果没用到 ivector 就可以去掉,详见 prepare_online_decoding.sh 的参数说明。

命令结束后,会在 ./online_conf 目录中多出很多文件,从online_conf/conf/online.conf 的内容可以看到,这步骤所做的事情本质是把各个 .conf 配置文件链接绑定起来,以方便后续使用。进行在线解码时就会用到这个配置文件。

不过,由于我们是基于 aishell2 训练的模型,aishell2 训练时的特征提取是以 mfcc_hires.conf 来进行的,与此时配置文件中的 mfcc.conf 中所定义的维度不同,所以需要把配置文件再手动做点修改,否则会报 类似这里 提到的错误。

config/mfcc_hires.conf 拷贝到 online_conf/conf/ 中,将 online_conf/conf/online.conf--mfcc-config 参数的路径改为该 mfcc_hires.conf 文件的绝对路径。

接下来用以下命令启动解码的tcp服务:

1
2
3
4
5
6
7
online2-tcp-nnet3-decode-faster \
--config=./online_conf/conf/online.conf \
--max-active=7000 --frame-subsampling-factor=3 \
--beam=15.0 --lattice-beam=6.0 --acoustic-scale=1.0 \
--samp-freq=16000 --frames-per-chunk=20 --extra-left-context-initial=0 \
--port-num=5050 \
exp/chain/tdnn_1b_all_sp/final.mdl exp/chain/tdnn_1b_all_sp/graph/HCLG.fst exp/chain/tdnn_1b_all_sp/graph/words.txt

--config 参数对应的是刚刚生成的配置文件, --port-num 参数对应 tcp 服务监听的端口号,其它的是解码相关参数,最后一行的三个对应对应训练得到的模型。

命令执行成功后,应有下面的log,提示在监听端口等待数据了。

1
2
LOG (online2-tcp-nnet3-decode-faster[5.5.543~1-7249c]:Listen():online2-tcp-nnet3-decode-faster.cc:385) TcpServer: Listening on port: 5050
LOG (online2-tcp-nnet3-decode-faster[5.5.543~1-7249c]:Accept():online2-tcp-nnet3-decode-faster.cc:399) Waiting for client...

接下来另起一个 terminal,通过 sox 和 netcat 工具,把音频文件转为数据流并传给这个端口供其识别:

1
sox test.wav -t raw -c 1 -b 16 -r 16k -e signed-integer - | nc localhost 5050

按照官方文档说法, nc 之后还应该增加 -N 参数,但我这里会报错没有该命令,所以暂且略过了,仍能正常测试。

结果类似如下:

结语

到此,我们已经完成了一个语音识别模型的训练和解码,就着 TCP server 的路子继续走下去,还可以实现一个通用的实时语音识别服务。不过我们的目标是在 Android 端实现命令词唤醒,所以还需要研究如何让模型在 Android 端跑起来,以及语言模型的修改。