7

Kaldi中的decoder(一)- 基础和viterbi解码

 2 years ago
source link: http://placebokkk.github.io/kaldi/2019/07/31/asr-kaldi-decoder1.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Kaldi中的decoder(一)- 基础和viterbi解码

Jul 31, 2019

Kaldi文档里的内容非常有价值,本文作为辅助理解的材料。 Kaldi代码实现也非常简单清晰。

需要掌握的代码文件

  • decoder/simple-decoder.cc
  • decoder/simple-decoder.h
  • gmmbin/gmm-decode-simple.cc
  • itf/decodable-itf.h

Kaldi的decoder的实现在decoder/目录下,根据下面功能需要实现了不同的类型的解码器。

  • 是否nbest(simple or lattice)
  • CPU还是GPU
  • 普通版还是加速版(faster)
  • 离线批处理还是online。

各个不同的解码器对应的文件都以decoder结尾。

  • simple-decoder
  • faster-decoder
  • lattice-decoder
  • lattice-faster-decoder

解码器对象的设计

Kaldi中抽象出DecodableInterface的概念,将解码器算法和声学模型计算模块解耦合,解码器算法和声学模型类型无关,从而可以被不同类型声学模型复用。

DecodableInterface接口

  • LogLikelihood
  • IsLastFrame
  • NumFramesReady
  • NumIndices

Decoder对象上只绑定了一个解码图Fst对象(HCLG).DecodableInterface对象在调用Decode时传入。

DecodableInterface用于计算声学模型的输出,对于Decoder相当于一个Data Provider,其主要接口为LogLikelihood,该接口返回t时刻,transition-id对应的声学模型得分。声学模型的输出是特征在上下文相关音素绑定后的状态(pdf-id)上的概率值,但在kaldi的hclg解码图FST里,边上输入是transition-id,这个数据结构包含了比pdf-id更多的信息。在LogLikelihood内部会把需要计算的transition-id转为pdf-id,然后计算pdf-id对应的得分。

Decoder

在kaldi中,DecodableInterface的输出一般是各transition-id的声学模型后验概率值,transition-id是HCLG的输入,Decoder要根据transition-id的得分选择一条(或者多条)最优路径。

Kaldi中没有为Decoder设计基类接口,但是每个Decoder在实现时都遵循了相同的接口约定。

解码可执行程序

根据使用的DecodableInterface,也就是声学模型(GMM / NNet),最终的解码可执行程序在 gmmbin和nnet3bin目录下面

  • 1-best的解码叫 *-decode-*
  • lattice解码叫 *-latgen-*

解码可执行程序中主要做的事情是

  • 读取FST解码图模型
  • 非online模式读取特征feature,online模式则是配置特征提取模块FeaturePipeline
  • 配置DecodableInterface(feature)或者DecodableInterface(featurepipeline)
  • 配置Decoder(fst)
  • 调用decoder.Decode(decodable)开始解码

可以阅读gmm-decode-simple学习怎么使用一个decoder.

解码器算法

解码器的核心算法在decoder/下面的文件里。本文简单介绍最简单的SimpleDecoder,其仅在gmm解码里使用(gmm-decode-simple),nnet3并没有使用这个decoder.

SimpleDecoder里实现的token-pass算法是viterbi算法的一种实现方法,这个是其他解码算法的基础,必须要完全理解,深入掌握,代码非常简洁,可以多看几遍。

Token

  • token可以理解为在state上
  • prev_ 指向前一个token(从哪个token扩展来的)
  • ref_count_ 引用计数
  • cost_ 累计cost,t时刻,token A经过边 arc(trans-id, word-id, graph_weight) 扩展为token B。 B的cost等于 A的cost + (-am_score) + graph_weight。其中am_score为t时刻特征Ft的声学模型上对应的pdf-id(trans-id对应的pdf-id)的得分。
  • arc_ token构建时传入的是grpah上对应的StdArc类型的arc,而token内部实际存储的arc类型是LatticeArc,hclg用的StdArc类型的arc里weight只能存一个值,而LatticeArc里weight把acoustic和graph cost分开存储,因此在回溯时,可以分别输出acoustic得分和lm得分.
  • TokenDelete() 利用用引用计数的方式对token进行释放管理。
  • 重载了 < 符号,比较两个token的得分, 若 a.cost > b.cost, 则a < b.

SimpleDecoder类

  • prev_toks_ 保存前一帧(t-1时刻)的所有tokens
  • cur_toks_ 保存当前帧(t时刻)扩展出的tokens
  • prev_toks_和cur_toks_是从stateID到token的map类型对象,类型为unordered_map<StateId, Token*>, 因为只需要1-best路径,每个状态上最多只有1个token
  • 对于t-1之前时刻的tokens,不专门维护,他们只在到达终止时刻T时,cur里的token做backtrace时用到。
  • beam_ 剪枝阈值
  • num_frames_decoded_ 当前完成解码(token pass)的帧数

Decode()

算法非常简单

ProcessEmitting

每一时刻t,对prev_toks_(t-1时刻)里每个状态上的token(SimpleDecoder只找最优路径,每个state只要保留一个token即可,这就是viterbi算法。若想保留全局的Nbest路径,每个状态上需要记录N个cost最小的t时刻token),根据该状态的arc进行转移。若arc的输入不是epsilon,则创建一个新的token,放进cur_toks_(t时刻)里。若新的token对应的state上在t时刻已经有了token,则比较两者的cost,保留cost小的.

因为解码图包含大量state,如果每个时刻都保留所有扩展出的tokens,想象一下我们的HCLG有那么多state,每个state上0到T时刻的token都要保存,带来的计算和存储都非常大。所以需要剪枝删除一些token,一个简单的方法为:在每一个时刻t,记录t时刻扩展出的新token中的最小的cost,在扩展t时刻token时,只有保留cost和该最小cost差值小于某个阈值(beam)的新token。这种根据阈值进行剪枝的搜索方法叫beam search,其可以减少计算和存储开销,但可能丢失全局cost最小的那条路径。

ProcessNonemitting

对于输入是epsilon的边,跳转是不消耗时间的,因此需要单独处理。 在ProcessEmitting完成后,若cur_toks_中的token所在状态存在输入为eps边,则将这些token继续扩展并放入cur_toks_里。(所以自跳转上的边的输入一定不是epsilon,不然这一步无法结束。)代码中,fst里保留0作为epsilon的index,所以代码中根据arc.ilabel是否为0判断输入是否为epsilon。

在ProcessEmitting中,每一个时间点,每个token只能沿着输入非epsilon的arc扩展一步,而对于ProcessNonemitting,如果某个state后面有连续多个epsilon的arc,是需要向后连续扩展token的,因此在代码实现上用了一个先入后出的栈结构来进行深度优先搜索

ProcessNonemitting中的剪枝和ProcessEmitting有些区别,使用的ProcessEmitting中best token的cost + beam作为cutoff阈值。

PruneToks

在t时刻全部扩展完成后,对所有的新token再扫描一遍,只保留那些cost和最小cost差值小于某个阈值(beam)的token。将prune后不包含token的state从map中去除。

GetBestPath

当到达T时刻,只要找出cur_toks_里cost最小的state(如果要求final,则必须使用final状态中的token),然后根据其backtrace指针不断往前回溯找到所有的token,把这些token记录的arc上的output输出即可。GetBestPath里实际是用token里的arc信息构建了一个Lattice,不过这个Lattice其实是一条直线。每个状态都只有一个边。由于有输入是epsilon的边,所以这条路径的边个数可能大于语音的帧数。

以上就是全部算法。谢谢阅读。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK