前段时间一直到在使用 kaldi 来做声纹识别,算是可以把整个 ivector 的例程可以跑下来,也可以根据例程来改写脚本,使用自己的数据来训练和测试。接下来可能要去做其他的项目了,所以要趁着还记得的时候赶紧写个总结,也算是对之前的工作也算是归纳一下。
kaldi 的安装
kaldi 在 Linux 下的安装总的来说还是比较简单的,首先是先进入 tools 中运行 extras/check_dependenices.sh 看下还有哪些依赖项没有安装,然后就可以按照他的提示来安装依赖项目。安装完依赖项之后就分别进入 tools 目录和 src 目录下执行命令 make -j8,其中 8 时 cpu 可以同时运行的线程数量。这个过程还是需要一定时间的。在 make 完之后就可以运行一个小的例程来看下有没有成功地安装 kaldi,我们进入到 egs/yesno/s5 目录下然后运行 run.sh 脚本,这是一个判断语音中说的是 yes 还是 no 的程序,他会自动下载数据并训练和测试,最终可以有 0.0% 的 WER,这就代表 kaldi 安装成功啦✌️
运行 aishell 例程
首先我们来看下 kaldi 下的目录:

- egs:保存了各种例程,均使用脚本编写,以使用的数据库的名字命名。在下一级目录中以 s 开头的文件是语音识别,以 v 开头的是声纹识别,一般 v1 就是使用 i-vector 的方法来进行声纹识别。
- src:保存了 kaldi 的 C++ 代码。
- tools:包括了 kaldi 依赖的库和一些实用的脚本。
- windows:包括了在 Windows 下安装需要的一些工具和配置文件
接下来我们就来跑一下 aishell 的声纹识别例程,在 egs/aishell/v1 中的 run.sh 就包括了整个声纹识别的流程,最好将 run.sh 中的命令复制到另外一个脚本中,一句一句地执行,这样就能及时发现错误然后修改。
- data=/export/a05/xna/data
- data_url=www.openslr.org/resources/33
-
- . ./cmd.sh
- . ./path.sh
-
- set -e # exit on error
-
- local/download_and_untar.sh $data $data_url data_aishell
- local/download_and_untar.sh $data $data_url resource_aishell
-
- # Data Preparation
- local/aishell_data_prep.sh $data/data_aishell/wav $data/data_aishell/transcript
首先是数据准备阶段,如果没有下载数据,脚本也可以自动下载和解压;如果下载好了就要把 data 的路径改成自己存放数据的路径。之后的 cmd.sh 和 path.sh 分别是设置执行命令的方式和 kaldi 的路径。如果我们是在自己的电脑上运行,就需要进入到 cmd.sh 中,把 queue.pl 修改成 run.pl。path.sh 就是设置和 kaldi 相关的路径,如果是例程的话就不用修改了。配置好之后就开始下载和解压数据。
之后就是最关键的部分了,准备一些下面环节需要的文档,使用 aishell_data_prep.sh 这个脚本来生成。声纹识别需要用到的分别是 utt2spk spk2utt wav.scp 这三个文件。其中 utt 指的是 utterance 代表一个音频文件的文件名,spk 代表 speaker 是说话人的 ID,这里在下一节做详细的介绍。如果是做语音识别,还需要 text 文件,这里就不做介绍了。
- # Now make MFCC features.
- # mfccdir should be some place with a largish disk where you
- # want to store MFCC features.
- mfccmfccdir=mfcc
- for x in train test; do
- steps/make_mfcc.sh --cmd "$train_cmd" --nj 10 data/$x exp/make_mfcc/$x $mfccdir
- sid/compute_vad_decision.sh --nj 10 --cmd "$train_cmd" data/$x exp/make_mfcc/$x $mfccdir
- utils/fix_data_dir.sh data/$x
- done
在准备好数据之后就要开始提取 mfcc 特征了(make_mfcc 的过程中也包括了分帧加窗),进行端点检测(VAD),以及检查文件符不符合要求对文件进行排序(其实我也没有看太懂 fix_data_dir.sh 这个脚本到底做了什么
- # train diag ubm
- sid/train_diag_ubm.sh --nj 10 --cmd "$train_cmd" --num-threads 16
- data/train 1024 exp/diag_ubm_1024
-
- #train full ubm
- sid/train_full_ubm.sh --nj 10 --cmd "$train_cmd" data/train
- exp/diag_ubm_1024 exp/full_ubm_1024
-
- #train ivector
- sid/train_ivector_extractor.sh --cmd "$train_cmd --mem 10G"
- --num-iters 5 exp/full_ubm_1024/final.ubm data/train
- exp/extractor_1024
再接下来就是训练 UBM 和 ivector extractor 了,这里需要注意的是训练 ivector extractor 的脚本会默认同时执行程序非常多,会占用很高的内存导致内存溢出。我们需要进入 train_ivector_extractor.sh 中修改一下。它默认同时执行的程序数量为 nj*num_thread*num_processes, 在 16G 内存下我把这三个参数都改为 2 才能跑通。这里也还有两个超参数可以修改,分别是 UBM 的维数和 ivector 的维数,UBM 的维数就直接在 run.sh 中修改就行,train_diag_ubm.sh 中 data/train 后面那个参数就是 UBM 的维数,默认为 1024。要修改 ivector 的维数就同样需要进到 train_ivector_extractor.sh 中修改 ivector_dim,默认为 400。
- #extract ivector
- sid/extract_ivectors.sh --cmd "$train_cmd" --nj 10
- exp/extractor_1024 data/train exp/ivector_train_1024
-
- #train plda
- $train_cmd exp/ivector_train_1024/log/plda.log
- ivector-compute-plda ark:data/train/spk2utt
- 'ark:ivector-normalize-length scp:exp/ivector_train_1024/ivector.scp ark:- |'
- exp/ivector_train_1024/plda
训练完 ivector 之后就要开始提取训练集的 ivector 了,然后用训练集的 ivector 来训练 plda 模型用于打分。
- # split the test to enroll and eval
- mkdir -p data/test/enroll data/test/eval
- cp data/test/{spk2utt,feats.scp,vad.scp} data/test/enroll
- cp data/test/{spk2utt,feats.scp,vad.scp} data/test/eval
- local/split_data_enroll_eval.py data/test/utt2spk data/test/enroll/utt2spk
- data/test/eval/utt2spk
- trials=data/test/aishell_speaker_ver.lst
- local/produce_trials.py data/test/eval/utt2spk $trials
- utils/fix_data_dir.sh data/test/enroll
- utils/fix_data_dir.sh data/test/eval
之后就要将测试集分为注册集和验证集,这一步主要通过 loacl/split_data_enroll_eval.py 这个脚本来完成,我们先来看一下这个脚本:
- # split_data_enroll_eval.py
- import sys,random
-
- dictutt = {}
-
- for line in open(sys.argv[1]):
- lineline = line.rstrip(' ')
- utt, spk = line.split(' ')
- if spk not in dictutt:
- dictutt[spk] = []
- dictutt[spk].append(utt)
-
- fenroll = open(sys.argv[2], 'w')
- feval = open(sys.argv[3], 'w')
-
- for key in dictutt:
- utts = dictutt[key]
- random.shuffle(utts)
- for i in range(0, len(utts)):
- line = utts[i] + ' ' + key
- if(i < 3):
- fenroll.write(line + ' ')
- else:
- feval.write(line + ' ')
-
- fenroll.close()
- feval.close()
这个脚本首先先将每个 spk 和与其对应的 utt 存入 dictutt 中,然后再将 spk 的 utt 顺序随机打乱,重新分配到 enroll(注册集)和 eval(评估集)中。可以看到在程序的倒数第六行中,if(i<3): 就将 utt 写入 enroll 中,否则就写入 eval 中。所以我们可以通过改这个值来改变注册集和评估集中的语音数。
在重新生成完 utt2spk 之后,就要生成 trials 了。trials 通过 loacl/product_trials.py 来生成。trials 是指需要进行打分的注册说话人和不同的语音的一个列表,它的格式为 (举个例子):
uttID |
spkID |
target/nontarget |
spkA-utt1 |
spkA |
target |
spkA-utt2 |
spkB |
nontarget |
spkB-utt1 |
spkA |
nontarget |
spkB-utt1 |
spkB |
target |
… |
… |
… |
- #extract enroll ivector
- sid/extract_ivectors.sh --cmd "$train_cmd" --nj 10
- exp/extractor_1024 data/test/enroll exp/ivector_enroll_1024
- #extract eval ivector
- sid/extract_ivectors.sh --cmd "$train_cmd" --nj 10
- exp/extractor_1024 data/test/eval exp/ivector_eval_1024
-
- #compute plda score
- $train_cmd exp/ivector_eval_1024/log/plda_score.log
- ivector-plda-scoring --num-utts=ark:exp/ivector_enroll_1024/num_utts.ark
- exp/ivector_train_1024/plda
- ark:exp/ivector_enroll_1024/spk_ivector.ark
- "ark:ivector-normalize-length scp:exp/ivector_eval_1024/ivector.scp ark:- |"
- "cat '$trials' | awk '{print \$2, \$1}' |" exp/trials_out
-
- #compute eer
- awk '{print $3}' exp/trials_out | paste - $trials | awk '{print $1, $4}' | compute-eer -
在将测试集分成注册集和评估集之后,就开始分别提取注册集和评估集的 ivector,然后按照生成的 trials 打分,最终打分结果输出在 trials_out 中, 最终跑出来的结果为 eer 为 0.183%。
使用 TIMIT 数据库进行声纹识别
在了解了 kaldi 中整个声纹识别的流程后,我们就可以 AISHELL 的例程来改写使用自己数据的声纹识别系统,这里我使用 TIMIT 数据库。
我们首先看下 AISHELL 和 TIMIT 数据库中的数据划分。AISHELL 中一共有 400 人,默认分为 train、dev 和 test 集。其中 train 里面有 340 人;dev 里面有 40 人;test 里面有 20 人。在例程中,使用 train 作为训练集,test 作为测试集,并没有使用 dev。AISHELL 里每个人大概有 300 多段语音,每段语音是一句话,每段语音大概在 2~6s。在 TIMIT 数据库中一共有 630 人,分为 train 和 test。训练集中有 462 人,测试集中有 168 人。每个人分别有 10 段语音,每段语音大概在 2~4s。这里就直接使用 TIMIT 的原本的分配方式,用 462 人作为训练集,168 人作为测试集。
不过使用 TIMIT 数据库还有一个问题就是,TIMIT 数据库中文件存放以及命名的方式和 AISHELL 不太一样。TIMIT 数据库下文件存放的结构是,/TRAIN/DR/SPEARKER_ID/UTTERANCE_ID.wav,train 代表是训练集或者测试集,DR(1~8)代表了说话人的方言类型,然后是说话人的 ID 文件夹,文件夹下存放了 10 段语音。TIMIT 数据库中不同的人会说同一段话,说的话的内容是一样的话文件名就是一样的,我不知道如果有相同的文件名会不会引发错误,稳妥起见还是把每个文件都重新命名了。我写了个程序,将文件都重新命名为说话人的 ID 加上音频的序号,并且将其重新保存在 / TRAIN/SPEAKER_ID 这样的目录下,这样就在下面的程序就可以不用修改太多。
在了解完两个数据库的区别和整个声纹识别的流程之后,我们就可以开始改写我们的程序了。其实整个过程中需要改的地方并不多,主要就是在准备数据阶段和生成 trials 的过程需要修改一下。首先是数据准备阶段,我们就可以根据哈 aishell_data_prepare.sh 这个脚本来改写自己的 timit_data_prepare.sh 了。数据准备阶段就要生成 utt2spk spk2utt 和 wav.scp 这三个文件。这三个文件的格式如下:
文件名 |
格式 |
utt2spk |
[音频文件名] [说话人 ID] |
spk2utt |
[说话人名] [音频文件名] [音频文件名] [音频文件名] |
wav.scp |
[音频文件名] [音频文件的具体路径] |
- . ./path.sh || exit 1;
-
- if [ $# != 2 ]; then
- echo "Usage: $0 <audio-path> <text-path>"
- echo " $0 /export/a05/xna/data/data_aishell/wav /export/a05/xna/data/data_aishell/transcript"
- exit 1;
- fi
-
- aishell_audio_dir=$1
- aishell_text_dir=$2
-
- train_dir=data/local/train
- dev_dir=data/local/dev
- test_dir=data/local/test
-
- mkdir -p $train_dir
- mkdir -p $dev_dir
- mkdir -p $test_dir
-
- # data directory check
- if [ ! -d $aishell_audio_dir ] || [ ! -d $aishell_text_dir ]; then
- echo "Error: $0 requires two directory arguments"
- exit 1;
- fi
-
- # find wav audio file for train, dev and test resp.
- find $aishell_audio_dir -iname "*.wav"
- | grep -i "wav/train" > $train_dir/wav.flist || exit 1;
- find $aishell_audio_dir -iname "*.wav"
- | grep -i "wav/dev" > $dev_dir/wav.flist || exit 1;
- find $aishell_audio_dir -iname "*.wav"
- | grep -i "wav/test" > $test_dir/wav.flist || exit 1;
前面首先是检查路径和创建用来存放文件的路径,由于在 TIMIT 中没有 dev 集,所以要把带有 dev 的都删掉。接下来脚本查找目录下的所有 wav 文件。
- n=`cat $train_dir/wav.flist $dev_dir/wav.flist $test_dir/wav.flist | wc -l`
- [ $n -ne 141925 ] &&
- echo Warning: expected 141925 data data files, found $n
-
- # Transcriptions preparation
- for dir in $train_dir $test_dir; do
- echo Preparing $dir transcriptions
- sed -e 's/.wav//' $dir/wav.flist | awk -F '/' '{print $NF}' > $dir/utt.list
- sed -e 's/.wav//' $dir/wav.flist | awk -F '/' '{i=NF-1;printf("%s %s ",$NF,$i)}' > $dir/utt2spk_all
- paste -d' ' $dir/utt.list $dir/wav.flist > $dir/wav.scp_all
- utils/filter_scp.pl -f 1 $dir/utt.list $aishell_text_dir/*.txt > $dir/transcripts.txt
- awk '{print $1}' $dir/transcripts.txt | sort -u > $dir/utt.list
- utils/filter_scp.pl -f 1 $dir/utt.list $dir/utt2spk_all | sort -u > $dir/utt2spk
- utils/filter_scp.pl -f 1 $dir/utt.list $dir/wav.scp_all | sort -u > $dir/wav.scp
- sort -u $dir/transcripts.txt > $dir/text
- utils/utt2spk_to_spk2utt.pl $dir/utt2spk > $dir/spk2utt
- done
-
- mkdir -p data/train data/test
- for f in spk2utt utt2spk wav.scp text; do
- cp $train_dir/$f data/train/$f || exit 1;
- cp $test_dir/$f data/test/$f || exit 1;
- done
-
- echo "$0: AISHELL data preparation succeeded"
- exit 0;
接下来就检查找到的 wav 文件加起来有没有 141924 个,然后就开始做 wav.scp、utt2spk 和 spk2utt 以及用于语音识别的 transcripts.txt,这里我们就要找到脚本中和 transcripts.txt 相关的,然后删掉就可以了。
再做完准备数据的阶段之后,我们就可以开始按照上面的流程来进行声纹识别了。还需要注意的一点是 trials,如果一个人只有两三段语音的话,就需要修改分配 enroll 集和 eval 集的比例。不过由于 TIMIT 数据库每个人有 10 段语音,所以不用修改也是可以的。这里就用 3 段语音去注册,然后剩下的 7 段语音用于验证。
最终跑出来的等错误率在 4.5% 左右,虽然是一个还可以接受的结果,但是和 AISHELL 的 0.18% 的等错误率相比还是差了很多的。分析一下原因:首先是用于训练的语音较少,虽然人数有 462 人,但是每个人只有 10 段语音,和 AISHELL 中 340 人用于训练,每个人 300 多段语音相比差了很多。同样的,TIMIT 中测试集中一共有 168 人,相比于 AISHELL 中测试集只有 40 人多了很多。而且,AISHELL 默认的训练的 UBM 阶数和 ivector 的维度都非常高,所以这两点可能导致了等错误率比较高。如果想进一步降低等错误率可以尝试降低训练的 UBM 和 ivector 的维度。我把 UBM 和 ivector 的维度都降低后,等错误率最终可以达到 1.53%。
kaldi 中声纹识别的流程
总结一下,kaldi 中声纹的识别(ivector)的流程图如下:

首先,将数据集分为训练集和测试集。然后对先对训练集做处理,先提取训练集的 mfcc 特征,然后训练 UBM 和 ivector extractor,接着提取训练集的 ivector,并使用训练集的 ivector 去训练 plda 模型。之后就开始对测试集进行处理,先把测试集分为注册集和验证集,分别提取 mfcc 然后在提取 ivector,在用 plda 进行打分。这就是整个 kaldi 中 ivector 声纹识别的流程了。
(yutouwd) |