It's our wits that make us men.

HTK解码代码分析2

Posted on By CQR

HTK解码总体流程:

首先在HVite.C的main函数中调用相应库的函数。

HVite_main()
{
  解析HVite命令行;
  Initialise();
  net = ExpandWordNet(&netHeap,wdNet,&vocab,&hset);
  for(所有需要识别的MFCC文件)
{
  ProcessFile(datFN,net,n++,genBeam,FALSE);
  }
  释放内存资源;
  
}

Initialise();

功能说明:从字典文件ditionary中解析字典信息并初始化相应的字典结构体Vocab。从网格文件net中解析网络信息并
初始化相应的网格结构体Lattice。从HMM文件中解析HMM模型信息并初始化相应的模型结构体HMMSet。 

net = ExpandWordNet(&netHeap,wdNet,&vocab,&hset);

功能说明:整合字典,HMM模型和网格信息,将它们扩展为可用于识别的网络结构net。

ProcessFile(datFN,net,n++,genBeam,FALSE);

功能说明:解码MFCC文件,并保存解码的结果。
输入说明:
datFN是需要解码的MFCC文件指针,这个文件包含一帧一帧MFCC参数的观察序列。
net是解码网络,这个网络包含了所有可能的识别路径。
n表示要解码第几个MFCC文件(这说明HTK可以解很多MFCC文件)。
genBeam是裁剪值。

ProcessFile()的主要代码分析:

ProcessFile()
{
   
StartRecognitio(vri,net,lmScale,wordPen,prScale);

SetPruningLevels(vri,maxActive,currGenBeam,wordBeam,nBeam,tmBeam);

while(最后一帧观察序列!=ture)
{   

ReadAsBuffer(pbuf,&obs);
   
ProcessObservation(vri,&obs,-1,xfInfo.inXForm);

}


lat=CompleteRecognition(vri,pbinfo.tgtSampRate/10000000.0,&ansHeap);

trans=TranscriptionFromLattice(&ansHeap,lat,nTrans);

LSave(lfn,trans,ofmt);
 
}

StartRecognitio(vri,net,lmScale,wordPen,prScale);

功能说明:初始化解码环境。

SetPruningLevels(vri,maxActive,currGenBeam,wordBeam,nBeam,tmBeam);

功能说明:设置裁剪值。

ReadAsBuffer(pbuf,&obs);

功能说明:从MFCC文件中读取一帧观察序列。

ProcessObservation(vri,&obs,-1,xfInfo.inXForm);

功能说明:解码每一帧MFCC参数的观察序列。这个函数会被循环调用,直至到最后一帧观察序列。
输入说明:
vri是识别过程中保存信息的结构体。
&obs是顺序读取的每一帧MFCC参数的观察序列。

lat=CompleteRecognition(vri,pbinfo.tgtSampRate/10000000.0,&ansHeap);

功能说明:通过追溯令牌中保存的path信息,生成识别结果网格。

trans=TranscriptionFromLattice(&ansHeap,lat,nTrans);

功能说明:把识别结果网格转换成脚本。

LSave(lfn,trans,ofmt);

功能说明:把识别结果脚本保存到MLF文件中。

ProcessObservation()的主要代码分析:

这个函数是HTK解码的核心。

首先解释两个重要的概念。HTK的节点可分为HMM模型节点和单词节点,每个HMM模型节点都有一个后续的相应单词节点。比如bit=b ih t,bit是单词节点,b,ih和t都是HMM模型节点,b,ih和t都有后续的单词节点bit。HTK定义了两个特殊状态,就是entry state和exit state。这两个state并没有观察序列,但起着承上启下的作用。节点的entry state能够接收了最佳节点的最佳令牌,这样可以保证每个HMM模型节点计算概率的初始条件是一样的。节点的exit state的令牌概率表示整个节点的最佳输出概率。

HTK采用了token pass令牌传递算法来实现语音识别。这个算法的根本思想还是基于viterbi维特比算法。它分成了两个步骤,第一步是内部令牌传递,用viterbi算法计算出每个HMM模型节点在当前时刻t每个状态的局部最佳概率,并传递给exit state。第二步是外部令牌传递。首先每个单词节点会把entry state的token令牌传递给exit state,同时创建当前时刻的path路径信息并保存在exit state的path链表中。接着节点会把exit state的令牌传递给概率比它小的后续节点entry state。注意,以后通过回溯path链表的成员项prev可以得出最佳单词节点序列,即识别结果。所以,这个path链表的成员项prev是不容易被更新的,只有新的单词出现时才会被更新。

对于每个时刻的观察序列,ProcessObservation()都会计算每个HMM模型节点的exit state概率,并把取得最大概率值的HMM模型节点的token传递给后续节点。但这个传递不能立即反映新单词的出现。因为新出现单词的令牌概率不会立刻超越当前单词的令牌概率。随着多帧观察序列被计算,新出现单词的令牌概率会大于旧单词的令牌概率,这时新单词的信息会被插入到path链表的成员项prev中。

ProcessObservation()
{   

/* 内部令牌传递 StepInst1(inst->node);*/

for (识别路径上的所有节点Node[i]实例)
{
if(节点==HMM模型节点)
{
计算当前时刻观察序列obs对于HMM模型节点Node[i]的最大概率。
计算HMM模型节点Node[i]实例的exit state概率。
}
else if(节点==单词节点) 
{清除单词节点的entry state和exit state的概率值。}
 }
 

 /* 外部令牌传递 StepInst2(inst->node);*/

for (识别路径上的所有节点Node[i]实例)
  if (节点概率值小于裁剪值) {
     删除节点Node[i]实例。
  }
  else {
     if(节点==非空的单词节点) 
  {
 
 节点Node[i]实例的exit state的token=节点Node[i]实例的entry state的token(即将单词节点Node[i]实例的entry state的token令牌传递给exit state。)
 创建当前时刻的路径信息path。
 节点Node[i]实例->exit->token.path=path。
 Node[i]实例->exit->token.path->prev=Node[i]实例->state->token.path。(这条指令将新单词的path信息插入到path链表的成员项prev中。)
}
 for(节点Node[i]实例的后继节点Node[j])
{
  if(节点Node[j]实例还没生成)
   创建节点Node[j]实例
  if(节点Node[j]实例的entry state概率值<节点Node[i]实例的exit state概率值)
    节点Node[j]实例的entry state的token=节点Node[i]实例的exit state的token。
 }
  }
 }

一个简单的yes-no识别例子:

任务语法限制如下:

 $WORD = YES | NO;   
( { SIL } < $WORD > { SIL } ) 

一共有三个HMM模型,分别是sil,yes和no。字典信息如下:

YES [yes] yes 
NO [no] no   
SIL [sil] sil

用HParse命令,转换成网格信息如下(7个节点,12个连接):

N=7    L=12   
I=0    W=SIL                 
I=1    W=NO                  
I=2    W=!NULL               
I=3    W=YES                 
I=4    W=SIL                 
I=5    W=!NULL               
I=6    W=!NULL               
J=0     S=2    E=0    
J=1     S=2    E=1    
J=2     S=4    E=1    
J=3     S=6    E=1    
J=4     S=1    E=2    
J=5     S=3    E=2    
J=6     S=2    E=3    
J=7     S=4    E=3    
J=8     S=6    E=3    
J=9     S=6    E=4    
J=10    S=0    E=5    
J=11    S=2    E=5  

ProcessObservation()生成的节点实例网络如下:

小写的sil,yes和no是HMM模型节点,大写的SIL,YES,NO和NULL是单词节点。令牌传递过程如下:

sil的令牌从entry state一直传递给exit state,接着sil的exit state令牌传递给SIL的entry state。SIL的令牌会传递给yes和no的entry state。接着yes的exit state令牌会传递给单词节点YES的entry state,no的exit state令牌会传递给单词节点NO的entry state。YES和NO会将entry state令牌传递给自身的exit state,接着YES和NO会将各自exit state令牌传递给NULL1,但NULL1只会接受概率值最大的令牌。注意,单词节点NULL不会创建路径信息path,它仅仅起过渡作用,把前继节点令牌传递给后继节点。最后,NULL1会将exit state令牌分别传给yes,no和sil。从这里可以看出,yes会接受SIL和NULL的令牌,no会接受SIL和NULL的令牌,然后看SIL和NULL哪个令牌大就会接受哪个。

假设现在要识别的MFCC文件内容是SIL-NO-YES-SIL,识别结果如下:

"C:/HTK/htk/data/test/mfcc/yes_no_yes_0.rec"
 0 100000 s3 -156.115662 sil -3760.114014 SIL
 100000 4500000 s5 -3601.917969
 4500000 4600000 s2 -76.337799 sil -1485.466675 SIL
 4600000 6200000 s4 -1074.701172
 6200000 6600000 s5 -334.294281
 6600000 7400000 s2 -737.986572 no -2600.320801 NO
 7400000 8200000 s3 -684.429138
 8200000 8600000 s4 -340.492188
 8600000 9700000 s5 -837.412903
 9700000 11000000 s2 -924.753235 yes -7088.080566 YES
 11000000 12500000 s3 -1245.929077
 12500000 13500000 s4 -570.135193
 13500000 20000000 s5 -4347.263184
 20000000 21100000 s2 -877.634888 sil -2479.738770 SIL
 21100000 21900000 s3 -585.977783
 21900000 22200000 s4 -244.961655
 22200000 23300000 s5 -771.164551

现在看看HTK解码是如何工作的。

一开始,HTK会用viterbi算法分别计算sil,yes和no的HMM模型的令牌输出概率,这时sil的概率会最大。

随着时间的转移,新单词NO出现了。这时sil的令牌概率会慢慢 变小,no的令牌概率会慢慢变大。当no的令牌概率超越sil时,no的令牌会传递给NULL1。新单词NO(从第66帧开始)的path信息会被插入到path链表的成员项prev中。在单词YES出现之前,根据令牌传递的顺序,可以得出:

节点no实例->exit->token.path.frame=66
节点NO实例->entry->token.path.frame=66
节点NO实例->exit->token.path.frame=当前帧数
节点NO实例->exit->token.path->prev.frame=66
节点NULL实例->exit->token.path.frame=当前帧数
节点NULL实例->exit->token.path->prev.frame=66
节点no实例->entry->token.path.frame=当前帧数
节点no实例->entry->token.path->prev.frame=66
节点yes实例->entry->token.path.frame=当前帧数
节点yes实例->entry->token.path->prev.frame=66

当新单词YES(从第97帧开始)出现后,上面的节点yes实例->entry->token会从entry state一直传递到exit state。一旦 yes令牌概率大于no令牌概率,则有:

节点yes实例->exit->token.path.frame=97
节点yes实例->exit->token.path->prev.frame=66
节点YES实例->entry->token.path.frame=97
节点YES实例->entry->token.path->prev.frame=66
节点YES实例->exit->token.path.frame=当前帧数
节点YES实例->exit->token.path->prev.frame=97
节点YES实例->exit->token.path->prev->prev.frame=66
节点NULL实例->exit->token.path.frame=当前帧数
节点NULL实例->exit->token.path->prev.frame=97
节点NULL实例->exit->token.path->prev->prev.frame=66

看到了吗?prev增加了一个表项,保存了新单词YES的path信息。

现在让我们看下令牌是如何从HMM模型节点的entry state传递到exit state:

上图的横轴表示时间帧,纵轴表示HMM模型的每个状态。这个图很形象地表明,每个状态在每个时间帧都有一个最大令牌概率(把这些概率排列,从数学上看就是一个矩阵)。viterbi算法只会取出同一时间帧上得出概率最大的状态令牌往后传递。所以,entry state的令牌是不能轻易地传到exit state的。只有待识别序列找到了合适的HMM模型,entry state令牌会自然地传递到exit state。

现附上HTK BOOK识别部分的中文翻译,这能让我们更好地理解代码。其实HTK BOOK已经把token pass思想大概说出来了,但如果不配合代码一起阅读,肯定是云里雾里。

网络上关于HTK BOOK识别部分的中文翻译(转载):

第一章 HTK基础

1.5 识别和Viterbi解码

这里介绍Viterbi算法。 识别,基于最大似然的状态序列,这种方法可以很好地用于连续语音,如果使用总概率就很难做到。这个概率的计算,本质上和前向概率的计算一样,只不过,前向概率在每一步都是求和操作,这里每一步都是一次最大化操作。 对于给定的模型M,假定表示给定模型M,观察到向量o1到ot,并且在时刻t处于状态j的概率最大,就是这个最大概率。这个概率可以使用下面的迭代公式计算。

其中

那么,对于模型M,观察的数据O的最大似然概率则是

这种形式的迭代就是Viterbi算法的基础。如下图所示,这个算法可以看做是寻找最佳路径的过程。矩阵中的Y轴表示模型的各状态,X轴表示语音帧(即时间)。图中的每个原点表示在时间t处观察到该语音帧的概率,点之间的弧表示一个转移概率。

那么,任何路径的概率都可以通过简单地将路径上的各点和弧的概率相乘(log运算则为相加)得到。路径从左向右,逐列发展。在时间点t,每个路径的对所有的状态i都是已知的,那么就可以使用上面的公式来向右扩展一个时间帧,路径增长一步。

“路径”的概念非常重要,下面会将它通用化,用于连续语音识别。 没有HTK工具直接实现了上面的Viterbi算法。但有一个HVite工具,以及它的支撑库HRec和HNet,用于连续语音识别。

1.6 连续词语音识别

现在回到图1.1所示意的语音识别模型,可以清楚地知道,连续语音识别仅仅需要将多个HMM连接起来,而这个连接而成的HMM模型序列中的每个模型,都对应了其隐藏的符号,这个符号可能是一个单词,那么这称为“连接词语音识别”,这个符号也可能是一个音素,那么这称为“连续语音识别”。另外,在每个模型中包括首尾两个不可观察的状态的原因,现在应该也清楚了,这是多个HMM模型连接在一起的粘合剂。

然而,依然有一些难点。由孤立词过渡到连续词,对于模型训练算法Baum-Welch算法来说,所作的修改很小,只需要把所有模型连接成一个大模型,然后使用HERest的所谓“嵌入式”训练即可,原理和过程和HRest中类似。

然而,对于Viterbi识别算法来说,需要进行重大的扩展,这也是HVite中所做的。在HTK中,使用了Viterbi算法的一个变种,叫做“令牌传送模型”,Token Passing Model。简单地说,令牌传送模型采用了状态路径对齐的概念。

想象一下,一个HMM中的每个状态j在时间t处,都拥有一个可移动的令牌,这个令牌中的信息,包含最大似然概率ψj(t),那么这个令牌就可以表示从o1到ot这个部分观察向量序列,和模型的匹配程度,限制条件是在时刻t必须处于状态j。

这样,上面的路径增长算法就可以使用新的“令牌传送”算法替代,这个算法也是在每个时间点t处执行,其中的关键步骤是:

 1.将状态i处的令牌,传送到和状态i相连的每个状态j,然后递增上面的最大似然概率,使用对数运算时,递增的数值是log[aij ] + log[bj(o(t)]。
 2.在每个状态处,都检查令牌的值,只保留具有最大似然概率的那个令牌,丢弃其它的。

使用令牌传送模型的好处是,它可以非常容易地扩展到连续语音识别的情况中。假设允许出现的HMM序列由一个有限状态网络定义,这个网络由识别任务的语法生成。即HMM的序列是一个有限的集合。例如,下图1.7中是一个简单的网络,其中每个单词都是一个音素的序列,每个音素有自己的HMM,并且所有单词处于一个环路中。

图1.7 连续语音识别的网络

在这个网路中,椭圆形表示HMM的实例,而长方形表示“单词末尾(word-end)节点”。这个组合网络实际上是一个大的HMM,因此可以利用上面的令牌传送算法。唯一的区别是,除了最佳令牌的最大似然概率之外,还需要更多的信息。当最佳的令牌到达语音的终点时,它所经过的网络中的路径信息需要保留下来,以得到所识别出来的模型序列。

令牌通过网络的历史路径,可以使用如下方法进行记录。每个令牌携带一个指针,叫做“尾部单词链接”(word end link)。当令牌从一个单词的退出状态(通过“单词末尾节点”时,即表明已经从退出状态转移了)转移到另外一个单词的入口状态时,这种转移表明刚刚经过一个单词边界。这时生成一个名为“单词链接记录(word link record)”的记录,其中保存了刚刚经过(emerge?)的单词,以及当前令牌中“尾部单词链接”的值。然后,将令牌的“尾部单词链接”指向这个新生成的WLR(单词链接记录)。图1.18演示了这个过程。

图1.18 记录单词边界的转移决策

说明:图中的Before和After表示令牌在从单词one中转移出来之前,和之后的状态,令牌包含两部分信息,一是令牌当前的最大似然概率,以及一个指针,即“尾部单词链接”,指向当前最后一个识别出来的单词。在Before时间点,这个指针指向下方左边第一个节点,即单词two。

令牌刚刚从单词“one”中转移出来,经过了图中的单词末尾节点,这时就生成一个WLR,即下方最右边的一个节点,其中有四个字段,令牌的当前最大似然概率,时间点t,刚经过的单词“one”,以及一个指针。这个指针指向令牌中的尾部单词链接,即最左边的单词two的WLR,然后修改令牌的指针,指向单词one,说明one是最后一个识别出来的单词。

问题:不知道中间两个WLR,分别标以时间点t-2和t-1是什么含义?

一旦处理完了所有的语音数据,那么最佳令牌所指向的WLR链表,就是识别的单词序列结果。可以从尾部的节点进行回朔,得到单词的最佳匹配序列。同时,如果需要的话,可以将语音数据中的单词边界提取出来。

上面描述的令牌传送算法,记录了传送过程中的单词序列。如果需要,可以记录更详细的音素序列,甚至状态序列。

另外,除了可以在每个单词边界处,记录最佳令牌的信息,还可以记录更多的路径信息,例如次优令牌,次次优令牌等。这样,不仅可以生成一个最佳的识别单词序列,还能生成一个可能的单词网络。基于这种思想的算法称为lattice N-Best。

它们是次优的,因为每个状态使用一个令牌,限制了可以保存的不同令牌的历史路径的数目。可以通过让每个状态持有多个令牌,并且认为来自不同的前一个单词的令牌是不同的,来克服这个数目限制。这是另一类算法,称为word N-Best。这种算法已经通过实验验证,其性能可以和其它任何最优N-Best算法相比。

上面大致描述了令牌传送算法的轮廓,它是HTK中实现的识别算法。该算法在模块HRec和HNet中实现,通过工具HVite调用识别功能。其中提供了单令牌和多令牌传送识别的算法、单个最佳令牌输出、网格(lattice)输出、N-Best列表等算法,并且支持跨单词的上下文依赖性,网格评分,以及强制对齐等特性。