抛开其他的不说,我们如果想用MMI训练,可以暂且不管目标函数怎么计算的,但是需要知道导数是怎么计算的。导数无论分子还是分母是在一个FST上进行前向后向计算。所以,有必要了解一下FST的基础知识。这里只是为了更好理解MMI而了解一下FST的操作使用,如果想特别详细地了解WFST在语音中的应用,在网上可以搜到很多资料。
WFST-- Weight Finite State Transducer
WFSA-- Weight Finite State Automaton
我们在kaldi中使用的基本都是有权重的,所以这里面提到的FST、FSA实际上都是WFST、WFSA。
我们最开始可能接触到WFSA更多一些,有限状态自动机,你在其他地方肯定也听过这个名字。它描述了一些有限的状态,这些状态中有开始状态有结束状态,这些状态通过有向边进行链接。如果想从一个状态跳转到另外一个状态,你需要给边一些输入,直到到达最终状态结束。
实际上就是检测你这个输入合不合法,没有输出,或者看成输出就是输入也行,不影响理解。
wfsa,权重为1,仅接受ab输入,不接受其他任何输入,没有输出,或者可以看成输入ab,输出ab。
WFST实际上就是在WFSA的基础上加上了每条边的输出。
再如:
wfst,权重为1,仅接受ab输入,并且返回cd作为输出,不接受其他任何输入。
注意,无论是wfsa还是wfst,节点并没有太多实际意义。
WFST的一些常用类型:
半环,这是一种逻辑结构,可以参考一下其他的博客或者教程里面的介绍。我们这里不介绍太多理论东西,我们这里只是让你知道有这么个东西。
为什么需要知道它?因为可以说WFST就是个semiring,为了更方便地在WFST上进行计算,我们先来了解一下semiring的计算规则。
半环所具有的元素:数域K,自定义加法操作,自定义乘法操作,零元,一元。
懵逼了是不是?直接来看例子你就懂了。
也就是说,对于实数中的任何数字,它的加法操作变成了去两个数的最小值: a ⨁ b = m i n ( a , b ) a\bigoplus b = min(a,b) a⨁b=min(a,b) 。乘法变成了取两个数的和: a ⨂ b = a + b a\bigotimes b = a+b a⨂b=a+b 。零元意思是一个数圈加上零元等于自身: a ⨁ ∞ = m i n ( a , ∞ ) = a a\bigoplus \infty = min(a,\infty) = a a⨁∞=min(a,∞)=a 。一元的意思是一个数圈乘一元得到他自身: a ⨂ 0 = a + 0 = a a\bigotimes 0 = a + 0 = a a⨂0=a+0=a
至于其他的还需要满足分配律、交换律、结合律、反身性的一些东西,有兴趣的自己去查一下。
不难发现,这就是自己定义的一套规则。(我们传统意义上的加减乘除是大家普遍接受的一套规则罢了)
Commutative :支持圈乘的交换律,即 x ⨂ y = y ⨂ x x\bigotimes y = y\bigotimes x x⨂y=y⨂x ,别看上面举的两个例子是支持这一操作的,实际上很多情况下是不支持的。
Idempotent:对于数域K中的所有元素x,满足 x ⨁ x = x x\bigoplus x= x x⨁x=x
k-closed: 1 ^ ⨁ a ⨁ . . . ⨁ a k = 1 ^ ⨁ a ⨁ . . . ⨁ a k ⨁ a k + 1 \hat{1}\bigoplus a \bigoplus ... \bigoplus a^k =\hat{1}\bigoplus a \bigoplus ... \bigoplus a^k\bigoplus a^{k+1} 1^⨁a⨁...⨁ak=1^⨁a⨁...⨁ak⨁ak+1 或者可以写成 ⨁ n = 0 k a n = ⨁ n = 0 k + 1 a n \bigoplus ^k_{n=0} a^n = \bigoplus ^{k+1}_{n=0} a^n ⨁n=0kan=⨁n=0k+1an
如果是0-closed,则 1 ^ = 1 ^ ⨁ a \hat{1}=\hat{1}\bigoplus a 1^=1^⨁a 。
Weakly-left divisible Semiring: ∀ ( x ⨁ y ! = 0 ^ ) , ∃ z ∈ K \forall (x\bigoplus y != \hat{0}), \exist z\in K ∀(x⨁y!=0^),∃z∈K使得 x = ( x ⨁ y ) ⨂ z x=(x\bigoplus y)\bigotimes z x=(x⨁y)⨂z
//不知道为啥,我打出来的不等号\neq很奇怪,所以这里用!=表示不等。
ZeroSum Free Semiring: x ⨁ y = 0 ^ x\bigoplus y = \hat{0} x⨁y=0^当且仅当 x = y = 0 ^ x=y=\hat{0} x=y=0^
一个从别的地方copy过来的总结表,有兴趣的可以看一下。
Tropical Semiring | Log Semiring | String Semiring | Probability Semiring | |
---|---|---|---|---|
Commutative | ✓ \checkmark ✓ | ✓ \checkmark ✓ | ✓ \checkmark ✓ | |
Idempotent | ✓ \checkmark ✓ | ✓ \checkmark ✓ | ✓ \checkmark ✓ | |
K-closed | ✓ \checkmark ✓ | ✓ \checkmark ✓ | ✓ \checkmark ✓ | |
Weakly left Divisible | ✓ \checkmark ✓ | ✓ \checkmark ✓ | ✓ \checkmark ✓ | |
Zero Sum Free | ✓ \checkmark ✓ | ✓ \checkmark ✓ | ✓ \checkmark ✓ |
这里面只是作为了解,了解一下WFST的一些操作即可,后面方面理解kaldi中的一些数据结构,详细的版本我以后会另外写一份。
后面四个都是属于优化操作,所以我写的很简略,关键是需要了解一下composition的作用。
在说Lattice之前,我们来想一下语音识别CE的训练流程中,神经网络的输出是和什么相对应?aliment。也就是训练语料的某一帧所对应的pdf-id,这个是由decode得到的,也称为强制对齐。
那lattice是个什么东西?我们在很多地方看到了这个词,但没有人告诉你这个是什么。原因很简单,lattice在不同地方的定义是不一样的,实现方式当然也是不同的。但如果了解了Lattice的目的,问题就会简单很多了。
最开始的lattice的产生,是为了得到n-best结果而准备的,因为如果是找最优路径aliment,可能在声学上最优,但结果不一定是我们想要的结果。所以出现了n-best,在一次解码中我们不是保留一条最优路径,而是保留多很有可能的条路径。然后把这些路径存储在一个graph中。这个graph也就是wfst,实际上lattice的本质就是wfst。
在kaldi中,lattice有两种存储结构:Lattice 和 CompactLattice
Kaldi中Lattice的定义是,状态级别的,通常用来描述一句话。
自定义的 ⨁ \bigoplus ⨁ 和 ⨂ \bigotimes ⨂ 的操作为:
和Lattice描述的内容是一致的,但是表述的方式是不一样的。
最有趣的就是它把weight重新定义了,由三个部分组成。因此它自己也重新定义了 ⨁ \bigoplus ⨁ 和 ⨂ \bigotimes ⨂ 的操作:
我们来看一下Lattice输出的样子。输出的实际上是CompactLattice。
我们看到会有三个权重。但是有的只有两个,这是因为tid序列要连在一起看才有意义,单看一条边的是没有意义的,个人推测是做了weightpushing的结果导致。
如果想让每条边都有其对应的对齐信息,我们可以使用lattice-align-word或lattice-align-phones。
可以看一下结果:
每条边的输入和输出是word-id,这里我只把olabel映射成了汉字,ilabel没有映射,还是word-id。
phone-align-lattice:
这里我没有对应出来,但是它的ilabel和olabel都是phone-id,就是上一张图“是的”的拓展,它在拓展的时候做了一些其他的操作,所以最后看到的是只有一个结束节点。
两幅图的权重都没有画出来。
注意,生成出来的lattice可以认为是对齐,没有自环,并且每条路径上的tid序列长度和相同
Supervision依然是一个WFST,源于Lattice但又和Lattice有些不同。具体有啥不同当我们看了流程就知道了。我这里讲到的是chain中的supervision,代码是chain-get-supervision
首先在生成supervision之前,需要先用lattice-align-phones,然后把phoneLattice作为输入,输入给supervision。
然后把Lattice转换成ProtoSupervision,函数:PhoneLatticeToProtoSupervision
在主程序执行之前,先要计算每个state_times
int CompactLatticeStateTimes(lat,&state_times)
/*
输入PhoneLattice,和state_times数组
*/
//初始化
num_states = lat.NumStates()
state_times->resize(num_states,-1)
state_times[0]=0
//统计时间
for(state in num_states){
cur_time = state_times[state]
for (arc in state){
state_times[nextstate] = cur_time + ark.num-tids
}
if state is finalstate{
检查到达finalstate的路径长度是否相同
路径长度 = cur_time + sate.weight.num-tids
保留所有路径最长的长度作为utt长度
}
}
return utt_len
总的来说就是算每个state出现的时间点,然后返回句子长度。
PhoneLatticeToProtoSupervision
//初始化
num_state = lat.NumStates()
proto_supervision.ReserveState(num_states)
num_frames = CompactLatticeStateTimes
num_frames_subsampled = num_frames/subsample_factor //这里是指subsample操作后的长度,一般来说subsample的系数是3,也就是假设原来长度是6,现在长度是2,向上取整
proto_supervision->allowed_phones.resize(num_frames_subsampled)
//allowed_phones是一个二维数组
//迭代完成protosupervision
for (state in num_states){
for (arc in state){
phone = arc.ilabel
proto_supervision->fst.AddArc(state,Arc(phone,phone,TropicalWeight::One(),arc.nextstate))
//也就是说,这里只是保留了phone信息,把所有的对齐序列都扔掉了
然后可以根据这条边开始结束节点的state_times找到这条边的subsample后的长度包括开始和结束时间点
for(time in sumbsampledtimes){
proto_supervision->allowed_phones[time].push_back(phone)
}
}
}
总结来说,protosupervision保留了lattice的phone信息,但没有保留lattice的tid对齐信息,然后里面有一个关键的allowed_phones描述的是这个句子的某一帧可能出现的phone,对后面的裁剪有帮助。
我们知道,Supervision实际上就是个分子图,我们计算的时候是要在分子图上跑前向算法和后向算法的,所以我们如果只有phone级别的信息是不够的,我们需要扩展到pdf-id级别。因此,最后,把protosupervision转换成supervision,函数:ProtoSupervisionToSupervision
注意:我们最后得到的是一个WFSA而不是WFST
整个流程比较简单,kaldi代码本身的注释也够看懂,如果想深入了解,还需要对FST有深入的了解才行。这里只简述一下流程即可。
我们想,目前我们得到的图,实际上可以看成一个LG.fst,虽然不严谨,但是如果把一个L.fst和G.fst拼起来实际上就是phone对应到word,word之间的跳转遵循ngram。或者像论文中说的P.fst。那么我们要拼成HCLG.fst则只差CH,那我们依次先compose C,然后compose H即可。的确是这样做的。
但要注意,我们的P.fst和CH都是没有时间信息的,那我们得到的HCLG肯定是一个有很多自环的图,而我们想要的是这句话的每一帧是个什么pdf是明确清楚的。因此,我们最后得到HCLG之后还需要和时间信息compose一下。这里的时间信息,就是我们刚刚提出来的allowed_phones。
所以代码流程就是三个compose和三个project。
注意,这里的project操作实际上是属于WFSA的基本操作,所以在上面没列出来。也比较简单。就是把WFST转成WFSA,Projec(input)就是把output替换为input,Project(output)就是把input替换为output。
这里就不用伪代码简述流程了。直接copy一下kaldi中的代码,里面的注释很详细。同样,只是有关键步骤,很多细节忽略掉了。
//C
VectorFst<StdArc> context_dep_fst;
fst::ComposeContextFst(cfst, phone_fst, &context_dep_fst);
// at this point, context_dep_fst will have indexes into 'ilabels' as its
// input symbol (representing context-dependent phones), and phones on its
// output. We don't need the phones, so we'll project.
fst::Project(&context_dep_fst, fst::PROJECT_INPUT);
//H
VectorFst<StdArc> *h_fst = GetHTransducer(cfst.ILabelInfo(),
ctx_dep,
trans_model,
h_cfg,
&disambig_syms_h);
KALDI_ASSERT(disambig_syms_h.empty());
bool reorder = true; // more efficient in general; won't affect results.
// add self-loops to the FST with transition-ids as its labels.
AddSelfLoops(trans_model, disambig_syms_h, self_loop_scale, reorder,
&transition_id_fst);
// at this point transition_id_fst will have transition-ids as its ilabels and
// context-dependent phones (indexes into ILabelInfo()) as its olabels.
// Discard the context-dependent phones by projecting on the input, keeping
// only the transition-ids.
fst::Project(&transition_id_fst, fst::PROJECT_INPUT);
//这里有个函数是AddSelfLoops,很重要,我会在后面的博客中讲到。
//时间约束
// The last step is to enforce that phones can only appear on the frames they
// are 'allowed' to appear on. This will also convert the FST to have pdf-ids
// plus one as the labels
TimeEnforcerFst enforcer_fst(trans_model, proto_supervision.allowed_phones);
ComposeDeterministicOnDemand(transition_id_fst,
&enforcer_fst,
&(supervision->fst));
fst::Connect(&(supervision->fst));
// at this point supervision->fst will have pdf-ids plus one as the olabels,
// but still transition-ids as the ilabels. Copy olabels to ilabels.
fst::Project(&(supervision->fst), fst::PROJECT_OUTPUT);
这就是分子图构建的整个流程和原理,参杂了一些个人理解在里面。欢迎交流补充。
kaldi文档:http://www.kaldi-asr.org/doc/lattices.html
Speech Recognition With Weighted Finite-State Transducers
Generating Exact Lattice in The WFST Framework