本人初入语音识别一个月, 最近开始学习kaldi源码。本文介绍kaldi语音识别对单音素训练的大致流程。欢迎指正纠错,谢谢。
0. 预备知识
单音素的训练在一个名为train-mono.sh的shell脚本里。解释之前大家需要一些储备知识。需要了解HMM,GMM以及Viterbi算法。由于代码中频繁使用Transition_Model
, AmDiagGmm
等,故先做一些总结如下。
kaldi对每个音素建立一个HMM模型,叫做HMMEntry,由三个state组成,定义在hmm-topology.cc 中。 如上图所示,transition state是一个整数,对应于三个变量:phone的ID号,三个hmm-state(0,1,2),global unique的pdf-id(P.d.f 为GMM模型的概率密度函数)。transition-index 对应于一个pair,也就是下一个state的ID和到下一个state的transmission/emitting probability. 而transition-ID 也对应一个pair,为transition state和transition index。
这样说可能不是很明白。再做一点解释,这些id号都是为了表达抽象的HMM转移模型,如果你需要在数学上表达一个transition,你需要知道在哪个phone上,而每个phone由多个state组成,每个state有多条边(Arc),这些边可能是self-loop,可能transmit 到下一个state,也可能emit 等等。这些边中的第几个就是transition-index,而在哪个phone,phone中哪个state,这个state的emit后的GMM概率密度表达, 这三个共同决定了transition state,因此,为了精确表达一个transition-id,需要transition state和transition-index 共同决定。
接下来一一讲解train-mono.sh 中的关键部分。
1.gmm-init-mono
Usage: gmm-init-mono <topology-in> <dim> <model-out> <tree-out>
输入:HMM的拓扑结构,特征维度
输出:模型文件 决策树文件
该程序首先初始化全局特征的均值和方差glob_inv_var,glob_mean
均为1,大小等于特征维度。如果有输入特征,另外再计算更新。然后,读入拓扑结构HmmTopology,建立里面所有phone到该phone的Pdf class数目的映射,对于通常情况为3(在kaldi的官方英文文档中有说明In the normal case the pdf-class is the same as the HMM state index (e.g. 0, 1 or 2)but pdf classes provide a way for the user to enforce sharing)。 接着就是构建决策树了,对于单音素,不需要进行KMeans聚类,所以只需要根据set.int文件(该文件描述共享共享根结点的phone)递归的构造树就好了。
构建决策树的主要函数GetStubMap
。 讲解该函数首先必须大致清楚kaldi里对树节点的分类,以后会在以后三音素训练中决策树构建中仔细讲解:
CE(Constant EventMap):叶子节点,直接保存该节点存放的transition ID;
SE(Split EventMap): 非叶子节点,保存左右子节点yes,no等等
TE(Table EventMap): 非叶子节点,保存节点数以及各个子节点的指针(与SE的区别在于TE的分裂更快,不只是两个分支,可以多个)
现在开始讲解GetStubMap:首先该函数输入phone_sets,所有共享的音素在一个vector中,所有的这些vector组成了phone_sets, 如果phone_sets的大小是1且其中phone都共享,返回CE,否则返回TE;如果phone_sets中每个vector的大小最大是1,并且phone_sets中vetor的数目小于最大phoneId的两倍,也返回TE;如果不是上述两种情况,进行split,phonesets分为两部分分别递归call GetStubMap,并构建SE。
GetStubMap讲的比较含糊,这个会在三音素训练中详细讲解。
接下来gmm-init-mono 构建GMM。 每个pdf初始化只有一个gmm,也就是单高斯,均值方差来自之前的glob_inv_var,glob_mean
, weight为1,均值方差也都为1,计算常数部分gconsts 即高斯分布x为0时的概率,最后将所有gmm模型导入声学模型am_gmm中。
最后输出transition和声学模型的文件model,决策树tree。
2. compile-train-graphs
Usage: compile-train-graphs [options] <tree-in> <model-in> <lexicon-fst-in> <transcriptions-rspecifier> <graphs-wspecifier>
输入:tree , transition_model ,L.fst(Lexicon), 训练音频文件的字幕, 输出为图 HCLG.fst
该函数比较简单,图的构建部分理论来自大佬Mohri的论文SPEECH RECOGNITION WITH WEIGHTED FINITE-STATE TRANSDUCERS
这部分在以后构图的地方仔细讲解。 只需知道由模型,决策树,词典对每个训练的音频文件说的话构建HCLG的图就好了。所用的函数是CompileGraphsFromText
3. align-equal-compiled 和gmm-align-compiled
这两个一起将的原因是功能比较类似,区别是前者在真正的训练前执行一次即可,后者在训练时调用。
他们的输入都是上一步得到的HCLG.fst的图,还有对音频文件进行特征提取(Feature extraction, MFCC, CMVN, etc)。而输出都是alignment,简单来说就是语音每帧对应的HMM state,在kaldi采用transition ID表示,因为之前说过transion ID是可以映射到唯一的HMM state.
对于align-equal-compiled,执行在真正训练之前,因为开始我们输入只有一个图,要得到alignment,需要对图进行viterbi 解码,找到最优路径,根据前面提到的论文中的WFST,输入即为alignment。 但是这里由于我们刚刚初始化,图里面各个转移概率都是初始化的值,没有进行更新,解码没有任何意义,因此我们直接随机产生一条路径,该路径符合以下要求:路径sequence的长度等于语音帧数,为此可能需要一定数目的self-loop作为补充,这样才能使NumOfFrames = non-selfloop’s ilabels + self-loops’s iabels。 该过程的具体代码见函数EqualAlign()
。 最后,我们取该路径的所有ilabels作为Alignment输出。
对于gmm-align-compiled, 没有太大差别,但是这里我们使用WFST图中各个转移概率进行解码(采用faster-decoder)得到最佳路径而不是随机生成的路径。这是因为gmm-align-compiled 在训练中执行,而训练中我们不断更新WFST中各个概率参数,使得解码更加准确。 具体函数为AlignUtteranceWrapper()
最后,我们取最佳路径的所有ilabels作为Alignment输出。
4. EM Algorithm for GMM training
接下来会讲解训练单音素的最关键部分,kaldi对transition 概率采用Viterbi training,而GMM 模型训练采用EM算法。所以这里先介绍下EM算法在GMM训练中如何使用。
大名鼎鼎的EM算法分为两部,E步和M步。也就是Expectation 和 Maximization。E步中由观测结果计算参数的估计值,M步中重新估计参数,使得Q函数最大。 Q函数为完全数据的对数似然函数的期望,也就是说M步重新估计参数,使得观测数据和未观测数据即完全数据的期望最大,在E步的基础上,我们预估了一个参数,因此得到未观测数据的分布,依此我们重新计算参数,如此迭代直到收敛。具体可看李航《统计学习方法》的EM算法部分,讲的不错。
GMM的训练参数更新如下:
kaldi就是用以上方法对GMM进行更新,E步稍有不同,接下来马上会提到。
5. gmm-acc-stats-ali
Usage: gmm-acc-stats-ali [options] <model-in> <feature-rspecifier> <alignments-rspecifier> <stats-out>
输入:模型model,特征,对齐序列alignment
输出:用于训练的统计量
首先读取transition模型 和 am-gmm模型信息。
对于每个音频文件和对应的对齐序列,我们进行如下操作:遍历每一帧,因为每一帧都可以从对齐序列里找到所对应的transition-id, 用一个vector记录transitio-id出现的次数,之后用于更新转移概率。同时由transition-id找到所对应的pdf-id,建立一个vector,大小为pdf-id的数目,函数ComponentPosteriors()
计算之前图片中EM算法中E步的响应度,kaldi里调用函数LogLikelihoods()
计算数据data也就是每帧语音特征在GMM中每个单高斯出现的概率,存在loglikes里面
Vector<BaseFloat> loglikes;
LogLikelihoods(data, &loglikes);
BaseFloat log_sum = loglikes.ApplySoftMax();
和之前图片里E步公式稍有不同,这里不是简单取平均值,而是用SoftMax计算在这个GMM模型中,数据data分别对应每个单高斯的比例。这里也就是EM算法中的E步。
接下来还调用了函数AccumulateFromPosteriors()
这里对M步进行了一个预计算,也就是计算了上述图片中M步三个公式的分子部分,在代码里,三个分子的数据存在occupancy_,mean_accumulator_,covariance_accumulator_
三个vector中。
上述所有计算后的统计量都放在AccumAmDiagGmm gmm_accs;
中,作为*.acc 输出。
6. gmm-est
Usage: gmm-est [options] <model-in> <stats-in> <model-out>
这是训练的最后一步,输入是gmm-init-mono初始化的模型或者训练时上一步训练后的模型,和gmm-acc-stat-ali计算的统计量,输出也是模型。
这步分为两个部分讲解,一个时EM算法的M步,另一个时Split,因为我们初始化中GMM只有一个单高斯,在训练的时候我们会split出多个,最后使得每个GMM模型中高斯数目等于我们想要的。
M步之前还要更新模型的转移概率,还记得我们在gmm-acc-stat-ali统计好了每个transition-id出现的次数吗,我们就用它来更新,很简单,我们直接用出现的次数除以总数就得到了转移概率,具体代码见函数:
void TransitionModel::MleUpdate(const Vector<double> &stats,const MleTransitionUpdateConfig &cfg,BaseFloat *objf_impr_out,BaseFloat *count_out)
正式进入M步。用刚才计算好的的分子除以相关度之和 ,也就是用每帧数据的在每个单高斯上的概率相加。具体见函数:MleDiagGmmUpdate()
。 这样就完成了EM算法。
接下来讲kaldi如何在每次split出新的高斯模型,达到我们想要的gauss数目。具体看函数:
void AmDiagGmm::SplitByCount(const Vector<BaseFloat> &state_occs,int32 target_components,float perturb_factor, BaseFloat power,BaseFloat min_count);
该函数首先用GetSplitTargets
获取了每个GMM在该轮训练结束的时候需要达到的gauss数目,具体算法如下:
struct CountStats {
CountStats(int32 p, int32 n, BaseFloat occ)
: pdf_index(p), num_components(n), occupancy(occ) {}
int32 pdf_index;
int32 num_components;
BaseFloat occupancy;
bool operator < (const CountStats &other) const {
return occupancy/(num_components+1.0e-10) <
other.occupancy/(other.num_components+1.0e-10);
}
};
kaldi为每个GMM构建以上结构体,pdf_index也就是pdf-id, num_components就是该pdf中的gauss数目,occupancy就是在该轮训练M步结束每个GMMmodel所有gauss的weight之和,再用如下公式
BaseFloat occ = pow(state_occs(pdf_index), power);
重新计算作为该结构体的occupancy。 kaldi用一个priority_queue保存了很多个这样的结构体,occupancy/NumofGauss越大越在queue前面。 在GetSplitTarget函数中,首先构建所有的结构体放入queue中,然后进行一个循环,每次循环对列表最前面的结构体中的num_components加1(num_components之后表示这个GMM的在该轮训练结束时要达到的目标高斯数目),由于加1后priority减小,可能该结构会放到queue中其他位置,下次循环对其他的GMM的gauss数加一(),如此循环,直到所有GMM中的gauss数目总数达到最终的要求。
这个算法基本思想就是优先给权重最多的GMM增加gauss数,使得所有GMM尽可能得到平均。
好了,确定了每个GMM在该轮训练要增加的Gauss数目了,就要用split进行分裂了。 kaldi分裂算法如下, 具体可看函数:
void FullGmm::Split(int32 target_components, float perturb_factor,vector<int32> *history)
我做个简单介绍,我们在之前获取了每个GMM需要增加到的目标gauus数,也就是target_components ,也知道当前GMM的gauss数current_components.
我们同样进行一个循环,每个iteration里,我们先得到所占weigh最大的那个gauss,然后将那个gauss的weight变为原来的一半,增加新的gauss,新gauss的均值方差为之前加上一个perturb_factor随机扰动, weight为原gauss的一半。不断迭代直到 current_components == target_components,完成一轮训练,输出新的model。
7.Summary
至此,做一个小小的回顾。
训练开始调用gmm-init-mono得到最初的模型和决策树,由于是对单音素的训练决策树并没有发挥真正的作用,这里的模型中均值方差都是初始化值,并没有经过训练,不能用于解码,因此,在用compile-train-graphs得到每个语音的HCLG.fst后, 我们训练前只能随机产生一条符合规定条件的路径进行对齐,对齐(align-equal-compiled)的目的是找到该语音每帧对应的transition-id。之后用对齐的transition-id和提取的特征统计信息量(gmm-acc-stats-ali),该步完成了EM算法里的E部,也计算好了M步中所需的一些数据,用于之后训练。训练时用gmm-align-compiled进行对齐,这里使用faster-decoder解码得到最佳对齐路径。用gmm-est进行模型更新,gmm-est主要是用Viterbi training,统计transitio出现的次数除以总数目更新转移概率,用EM算法完成对GMM中参数的更新,结束之前用设置power和perturb_factor进行分裂,到达指定的gauss数目。如此循环迭代,完成对mono-phone的训练。
完。