Kaldi中如何使用决策树

介绍

这部分将介绍音素决策树在kaldi中是如何建立和使用的,以及是如何将训练和图建立交互的。对于决策树的构建的代码,可以看Decision tree internals; 对于建立图解码的更多细节,可以看Decoding graph construction in Kaldi

最基本的实现方法就是自顶向下贪婪的分裂,这里我们有很多的方法来分裂我们的数据,比如我们可以用左音素、右音素、中间音素,和我们所在的状态等等。我们实现的方法与标准的方法很相似,具体的你可以去看Young,Odell 和Woodland的论文"Tree-based State Tying for High Accuracy Acoustic Modeling"。在这个方法里,我们通过局部最优问题来分裂数据,比如假设我们通过一个单高斯来分裂我们需要建模的数据,通常我们会用最大似然函数的增加来作为分裂的依据。与标准实现方法不同的有:对怎么计算树的根节点来添加flexibility;对hmm状态和中间音素决策的能力;但是在kaldi脚本中默认的是,决策是通过对数据自顶向下的自动实现二叉树聚类,意思就是我们不需要手工产生的这个决策。不考虑树的根节点的构造:它可能用在一个单共享组中的所有音素的所有统计量来进行来分裂(包括中心音素和HMM状态的问题), 或者用一个单一的音素,或者HMM状态里的音素,来作为分裂树的根节点,或者音素组作为树的根节点。对于在标准的脚本里如何构建,你可以看Data preparation。在实际中,我们让每一个树的根节点对应一个真正的音素,意思就是我们重组了所有word-position-dependent, 每一个音素的tone-dependent 或者stress-dependent组成一个组,成为树的根节点。 这页的剩下部分大多数介绍代码层的一些细节。

上下文相关音素窗(Phonetic context windows)

这里我们将解释在我们的代码里是如何描述上下文相关音素。一个特定的树含有两个值,他们分别描述上下文音素窗的宽度和中心位置。下表是总结这些值:

Name in code Name in command-line arguments Value (triphone) Value (monophone)
N –context-width=? 3 1
P –central-position=? 1 0

N代表上下文相关音素窗的宽度,P表示指定中心音素。正常的P是窗的中心(因此名字叫中心位置);举个例子,当N=3,中心位置就是P=1,但是你可以自由的选择0到N-1中的任何值;比如,P=2和N=3表示有2个音素在左边的上下文,而右边没有上下文。(译者注:因为是从0开始数,所以0,1在2的左边,即没有右边的上下文)。在代码中,当我们讨论中心音素时,我们的意思是第P个音素,不一定是上下文相关音素窗的中心音素。

一个整型的vector表示一个典型的三音素上下文窗也许就是:

// probably not valid C++ 
vector<int32> ctx_window = { 12, 15, 21 };

假设N=3和P=1,这个就代表音素15有一个右边的上下文21和左边的上下文12。我们用这种方式处理句子的结尾时就用0(表示不是一个有效的音素,因为在OpenFst里的epsilon表示没有符号),所以举个例子:

vector<int32> ctx_window = { 12, 15, 0 };

表示音素15有一个左上下文和没有右上下文,因为是一句话的结尾处。尤其在一个句子的结尾处,这种用0的方式也许有一点出乎意料,因为最后一个音素事实上是后续符号"$" (看Making the context transducer),但是在决策树代码里为了方便,我们不把后续符号放在这些上下文窗中,我们直接给其赋0。注意如果我们有N=3和P=2,上述的上下文窗将无效,因为第P个元素是0,它不是一个真正的音素;当然,如果我们用一个树的N=1, 所有的窗都将无效,因为他们是错误的大小。在单因素的情况下,如果的窗就像如下表示:

vector<int32> ctx_window = { 15 };

所以单音素系统是上下文相关系统的一个特殊情况,窗的大小N=1和一个不做任何事情的树。

树建立的过程(The tree building process)

在这部分我们将介绍在kaldi中的树建立的过程。

如果我们的一个单音素系统有一个决策树,那也应该是比较简单。看函数MonophoneContextDependency()和MonophoneContextDependencyShared(),他们将返回这个简单的树。这个在命令行里叫gmm-init-mono; 他们主要的输入是HmmTopology类和输出是树,通常把作为类ContextDependency写到一个名字叫树的文件(tree),和它们的模型文件(模型文件含有一个TransitionModel类和一个AmDiagGmm类)。如果程序gmm-init-mono接受一个叫–shared-phones的选项,它将在音素特定集中共享概率密度函数;否则就把所有的音素分离。

在训练一个以flat start开始的单音素系统时,我们训练树是采用单音素对齐和使用函数AccumulateTreeStats() (称为acc-tree-stats)来累积统计量。这个程序不仅仅在单音素对齐中使用;也可以对上下文相关音素来对齐,所以我们可以根据三音素对齐来建立树。为树建立的统计量将写到磁盘里,作为类型 BuildTreeStatsType (看Statistics for building the tree)。函数AccumulateTreeStats() 采用值N和P,像我们在之前的那部分解释那样;命令行将默认的设定它们为3和1,但是这个可以用–context-width和–central-position选项来覆盖。程序acc-tree-stats采用上下文音素 (e.g. silence)的列表,但是如果是上下文音素,他们是不需要的。它仅仅是一个减少统计量的一个机制。对于上下文相关音素,程序将积累相对应的统计量without the keys corresponding to the left and right phones defined (c.f. Event maps)。

当统计量已经积累,我们将使用程序build-tree 来建立树。输出是一棵树。程序build-tree需要三个东西:

  • 统计量( BuildTreeStatsType类的)
  • 问题的构建(Questions 类)
  • 根文件(看下面)

统计量是通过程序acc-tree-stats得到的;问题构建类是通过问题构建程序得到的,这需要在音素问题集的一个topology表中 (在我们的脚本里,他们通常从程序cluster-phones的树建立统计量自动获得)。根文件指定音素集,这些音素集是在决策树聚类处理中共享根节点,对于每一个音素需要设定2个东西:

  • "shared" or "not-shared" 意思是对于每一个pdf-classes是否有单独的根节点(例如:典型的情况,HMM的状态), 或者根节点是否共享。如果我们打算去分裂(下面有个"split" 选项),我们执行时,根节点是共享的。
  • "split"或者"not-split"意思决策树分裂是否应该通过根节点的问题来决定(对于silence, 我们一般不split)。

要小心点,因为这些符号有些棘手。在根文件里的行"shared"是表示我们是否在一个单一的树的根节点上共享的所有的三个HMM状态。但是我们经常共享所有音素的根节点,这些音素在一个根文件的一行上。这个不是通过这些字符串来构建的,因为如果你不想共享它们,你会把它们分布在根文件的不同的行上。

下面是根文件的一个例子;这里假设音素1是silence和所有的其他有分离的根节点。

not-shared not-split 1
shared split 2
shared split 3
...
shared split 28

当我们有像position and stress-dependent音素时,在同一行上有许多音素是非常有用的。这种情况下,每一个"real"音素将对应整数音素ids的一个集合。在这种情况下,我们对一个特定标注的音素的所有情况将共享根节点(译者注:意思应该就是一个音素可能有不同的标注版本,我们用的时候就相当于一个,具体的可以看下面的根文件)。下面是wsj数据库的一个根文件,在egs/wsj/s5脚本里(这个在text里,不是整数形式;它将经过kaldi转为整数形式):

not-shared not-split SIL SIL_B SIL_E SIL_I SIL_S SPN SPN_B SPN_E SPN_I SPN_S NSN NSN_B NSN_E NSN_I NSN_S 
shared split AA_B AA_E AA_I AA_S AA0_B AA0_E AA0_I AA0_S AA1_B AA1_E AA1_I AA1_S AA2_B AA2_E AA2_I AA2_S
shared split AE_B AE_E AE_I AE_S AE0_B AE0_E AE0_I AE0_S AE1_B AE1_E AE1_I AE1_S AE2_B AE2_E AE2_I AE2_S
shared split AH_B AH_E AH_I AH_S AH0_B AH0_E AH0_I AH0_S AH1_B AH1_E AH1_I AH1_S AH2_B AH2_E AH2_I AH2_S
shared split AO_B AO_E AO_I AO_S AO0_B AO0_E AO0_I AO0_S AO1_B AO1_E AO1_I AO1_S AO2_B AO2_E AO2_I AO2_S
shared split AW_B AW_E AW_I AW_S AW0_B AW0_E AW0_I AW0_S AW1_B AW1_E AW1_I AW1_S AW2_B AW2_E AW2_I AW2_S
shared split AY_B AY_E AY_I AY_S AY0_B AY0_E AY0_I AY0_S AY1_B AY1_E AY1_I AY1_S AY2_B AY2_E AY2_I AY2_S
shared split B_B B_E B_I B_S
shared split CH_B CH_E CH_I CH_S
shared split D_B D_E D_I D_S

当创建根文件时,你应该确保每一行至少有一个音素。举例来说,在这种情况下,如果音素AY应该至少在stress和word-position有一些组成,我们就认为是可以的。

在这里例子里,我们有许多word-position-dependent静音的变异体等等。在这里例子里,他们将共享他们的pdf,因为他们在同一行,而且是"not-split",但是他们有不同的转移参数。事实上,静音的大多数变异体从来不被用作词间出现的静音;这些都为了未来做准备,以防止在未来有人做了一些奇怪的变化。

我们初始阶段的高斯混合是用之前(比如:单音素)的建立来做对齐;对齐是通过程序convert-ali来把一个树转换成其他的。 PDF标识符 (identifiers) PDF标识符(pdf-id)是一个数字,从0开始,被用作概率分布函数(p.d.f.)的一个索引。在系统中的每一个p.d.f.有自己的pdf-id,和他们是邻近的(典型的就是在一个LVCSR有上千上万个)。当树一开始建立的时候,他们就被初始化了。这有可能取决于树是如何建立的,对于每一个pdf-id,都有一个音素与之对应。

上下文相关类(Context dependency objects)

类ContextDependencyInterface对于一个与图建立相交互的特定树来说,是一个虚拟的基类。这种交互仅仅需要4个函数:

  • ContextWidth()返回树所需要的N(context-width)的值。
  • CentralPosition()返回树所需要的P (central-position)值。
  • NumPdfs()返回由树定义的pdfs的数量,他们的值是从0到NumPdfs()-1。
  • Compute()是计算一个特定的上下文 (和pdf-class)的pdf-id的函数。

函数ContextDependencyInterface::Compute() Compute() 如下声明:

class ContextDependencyInterface {
...
virtualboolCompute(const std::vector<int32> &phoneseq, int32 pdf_class,
int32*pdf_id) const;
}

如果可以计算这个上下文和pdf-class的pdf-id就返回true。如果是false,就说明许多种类是错误的或者不匹配的。用这个函数的一个例子是:

ContextDependencyInterface *ctx_dep = ... ;
vector<int32> ctx_window = { 12, 15, 21 }; // not valid C++
int32pdf_class = 1; // probably central state of 3-state HMM.
int32pdf_id;
if(!ctx_dep->Compute(ctx_window, pdf_class, &pdf_id))
KALDI_ERR<< "Something went wrong!"
else
KALDI_LOG<< "Got pdf-id, it is " << pdf_id;

从类ContextDependencyInterface继承的唯一一个类就是类ContextDependency,将轻微的丰富这种交互;唯一一个重要的添加就是函数GetPdfInfo,它将在类TransitionModel中计算一个特定的pdf对应某一个音素(通过枚举所有的上下文,这个函数可以模拟特定的ContextDependencyInterface的交互)。

对象ContextDependency事实上是对象EventMap的一点点的改动;可以看Decision tree internals。我们想要尽最大可能地隐藏树的真正的实现来使以后我们需要重组代码的时候更加的简单。

决策树的一个例子(An example of a decision tree)

决策树文件的格式不是以可读性为第一的标准创建的,而是由于大家的需要,我们将尝试解释如何来翻译这个文件。可以看下面的例子,这是在wsj数据库下的一个三音素的决策树的例子。它由一个名字叫ContextDependency对象开始的,然后N (上下文窗的宽度)等于3,P (上下文窗的中心位置)等于1,比如这个音素上下文位置的中心,我们是从0开始编号的。文件的剩下部分包含一个单一的对象EventMap 。EventMap是一个多态类型,它包含指向其他EventMap对象的指针。更多的细节可以看Event maps;它是一个决策树或者决策树集合的表示,它把key-value对的集合(比如:left-phone=5, central-phone=10, right-phone=11, pdf-class=2)映射成一个pdf-id (比如158)。简单的说,它有三个类型:SplitEventMap (像决策树里的一个分裂),ConstantEventMap (像决策树里的叶子,只包含一个数字,代表一个pdf-id),和TableEventMap(像含有其他的EventMaps表中查找)。SplitEventMap和TableEventMap都有一个他们查询的"key",在这种情况下可以为0,1或者2,与之对应的是left,central或者right context,或者-1代表"pdf-class"的标识。通常pdf-class的值与HMM状态的索引值一样,比如0,1或者2。尝试不要因为这样而迷惑:key的值是-1,但是value是0,1或者 2,和他们在上下文窗里的音素的keys的 0,1或者2 没有任何联系。SplitEventMap 有一组值,这组值将触发树的"yes"分支。下面是解释树文件格式的一种quasi-BNF标识。

EventMap := ConstantEventMap | SplitEventMap | TableEventMap | "NULL"
ConstantEventMap := "CE" <numeric pdf-id> 
SplitEventMap := "SE" <key-to-split-on> "[" yes-value-list "]" "{" EventMap EventMap "}"
TableEventMap := "TE" <key-to-split-on> <table-size> "(" EventMapList ")"

在下面的这个例子里,树的顶层EventMap 是一个分裂的key为1,表示中心音素SplitEventMap (SE)。在方括号里的是phone-ids的邻近值。就像上面说的那样,他们不是代表一个问题,而仅仅是在音素上的分裂的方式,以至于我们可以得到真正的每个音素的决策树。关键就是这个树是通过"shared roots"来建立的,所以这里有许多的phone-ids,对应相同音素的不同版本的word-position-and-stress-marked,他们共享树节点。我们不可能在树的顶层使用 TableEventMap (TE),或者我们不得不重复决策树很多次 (因为EventMap是一个纯树,不是一个普通的图,它没有任何的机制被指向"shared")。"SE" label 的接下来的几个参数也是这个"quasi-tree"的一部分,它一开始就用中心音素来分类 (当我们继续往下看这个文件,我们将更加对这个树深入;注意到括号"{" 是打开的而不是闭合的)。然后我们的字符串"TE -1 5 ( CE 0 CE 1 CE 2 CE 3 CE 4 )",表示在pdf-class "-1"(effectively, the HMM-position)用TableEventMap来分裂,和通过4返回值0。这个值代表对静音和噪声音素SIL, NSN and SPN的5个pdf-ids;在我们的建立中,这个pdfs是在这些三个非语音音素(对于每一个非语音音素只有转移矩阵是特定的)是共享的。注意:对于这些音素,我们有5个状态而不是三个状态的HMM,因此有5个不同的pdf-ids。接下来是"SE -1 [ 0 ]"; 和这些可以被认为在树中的第一个"real" 问题。当中心音素的值是5到19时,我们可以从上面提供的看出SE问题,这个是音素AA的不同版本;问题就是pdf-class (key -1)的值是否为0 (i.e. the leftmost HMM-state)。假如这个答案是"yes",接下来的问题就是"SE 2 [ 220 221 222 223 ]",这就问音素的右边是不是音素"M"的各种形式(一个相对不直观的问题被问,因为他们是leftmost HMM-state);如果是yes,我们将问"SE 0 [ 104 105 106 107... 286 287 ]" 音素的右边的一个问题,如果是yes,然后pdf-id就是5 ("CE 5") 和如果是no,就是696 ("CE 696")。

s3# copy-tree --binary=false exp/tri1/tree - 2>/dev/null | head -100
ContextDependency 3 1 ToPdf SE 1 [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 ]
{ SE 1 [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 ]
{ SE 1 [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 ]
{ SE 1 [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ]
{ SE 1 [ 1 2 3 ]
{ TE -1 5 ( CE 0 CE 1 CE 2 CE 3 CE 4 )
SE -1 [ 0 ]
{ SE 2 [ 220 221 222 223 ]
{ SE 0 [ 104 105 106 107 112 113 114 115 172 173 174 175 208 209 210 211 212 213 214 215 264 265 266 267 280 281 282 283 284 285 286 287 ]
{ CE 5 CE 696 }
SE 2 [ 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 268 269 270 271 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 ]

下面是一个简单的例子:从rm数据库的一个单音素tree。顶层EventMap是一个TableEventMap ("TE 0 49 ...")。key "0"表示的就是中心音素,因为context width (N)为1。在这个表中项的数量是 49 (这种情况下,音素的数量加1)。在表(索引为0)中的第一个EventMap是NULL,因为这里没有索引为0的音素。接下来是一个有三个元素的TableEventMap,与第一个音素的三个HMM状态(technically, pdf-classes)相对应:"TE -1 3 ( CE 0 CE 1 CE 2 )"。

s3# copy-tree --binary=false exp/mono/tree - 2>/dev/null| head -5
ContextDependency 1 0 ToPdf TE 0 49 ( NULL TE -1 3 ( CE 0 CE 1 CE 2 ) 
TE -1 3 ( CE 3 CE 4 CE 5 ) 
TE -1 3 ( CE 6 CE 7 CE 8 ) 
TE -1 3 ( CE 9 CE 10 CE 11 ) 
TE -1 3 ( CE 12 CE 13 CE 14 )

The i label_info object

CLG图(看Decoding graph construction in Kaldi) 在输入边有代表上下文音素的符号(也可能是消歧符号或者可能是静音符号)。在这中,这些会被表示成整数型的标签。我们在代码中用一个对象,在文件名我们通常称为ilabel_info。ilabel_info对象跟ContextFst对象有很强的联系,可以看The ContextFst object。像kaldi中许多其他的类型一样,ilabel_info是一个通用(STL)的类型,但是我们可以使用一致的变量名,以至于可以被识别出来。就是下面的类型:

std::vector<std::vector<int32> > ilabel_info;

它是一个vector,是通过FST 输入标签来索引的,它可以给每一个输入标签相对应的音素上下文窗(看上面的Phonetic context windows)。举例来说,假设符号1500是音素30,有一个12的右边的上下文和4的左边的上下文,我们将有:

// not valid C++
ilabel_info[1500] == { 4, 30, 12 };
In the monophone case, we would have things like:
ilabel_info[30] == { 28 };

这是对处理消歧符号的一个特殊处理(看Disambiguation symbols或者Springer Handbook paper referenced above for an explanation of what these are)。如果一个ilabel_info entry对应一个消歧符号,我们把它放在消歧符号的符号表的负部分(note that this is not the same as the number of the printed form of the disambiguation symbol as in #0, #1, #2 etc,这些数字对应符号表文件的消歧符号,这个在我们现在的脚本中叫phones_disambig.txt)。举例来说,

ilabel_info[5] == { -42 };

意思就是在HCLG中的符号数字5对应一个整数id为42的消歧符号。我们是为了脚本的方便,所以对于给定的消歧符号列表解释ilabel_info对象的程序不是很需要,在单音素的情况下,是为了可以从真正的音素中区分它们。这里是二个其他的特殊的例子,我们有:

ilabel_info[0] == { }; // epsilon
ilabel_info[1] == { 0 }; // disambig symbol #-1;
// we use symbol 1, but don't consider this hardwired.

第一个表示正常的静音符号,我们将给它一个空的vector作为它的ilabel_info entry。这个符号不会出现CLG的左边。第二个是一个特殊的消歧符号,它的印刷形式为"#-1"。在正常的脚本里,我们使用它,这里的静音被用作C转换器的输入;它是确保在空音素表示的词里对CLG确定性。

程序fstmakecontextsyms是可以创建一个与ilabel_info对象的打印形式相对应的符号表;这主要用来调试和诊断。

就像你看到的那样,ilabel_info对象不是很方便,因为它涉及到消歧符号,但是与它密切交互的代码不是很多:仅仅fst::ContextFst 类(一些相关的东西可以看"Classes and functions related to context expansion"),程序fstmakecontextsyms.cc,和一些在Classes and functions for creating FSTs from HMMs列出来的函数)。特别地,ContextDependency对象,仅仅可以看到代表音素上下文窗的长度N的有效的序列。