决策树在Kaldi中如何使用
说明:本文是kaldi主页相关内容的翻译(http://kaldi-asr.org/doc/tree_externals.html )。目前网上已经有一个翻译的版本,但翻译的不是很清楚,导致我在刚学这部分内容的时候产生了一些误解,所以我希望结合我目前所知道的一些东西,尽量把这部分内容翻译地比较容易理解,但由于也是初学者,一些错误也是不可避免,希望大家发现后一起交流,以便我后期修正。好了,还是废话少说吧。
介绍(Introduction)
本页将对声学决策树在kaldi中如何被创建和使用,以及如何在训练和解码图构建过程进行运用给出一个概述性的解释。对于构建决策树代码的内部描述,请参见Decision tree internals;对于构建解码图方法的详细信息,可以参见Decoding graph construction in Kaldi。
实现的基本算法就是自顶向下的贪婪分裂,通过问一些问题,比如说左边的音素,右边的音素,中心音素以及当前的状态等等,我们会得到很多可以把数据进行分裂的路径。我们实现的算法与标准算法非常相似,请参见Young,Odell和Woodland的这篇论文"Tree-based State Tying for High Accuracy Acoustic Modeling" 。假设我们对数据建模时采用单高斯将它们分成两部分,在这个算法中,我们通过选择局部最优的问题进行数据分裂,也就是使得似然值增加最大的那个问题。与标准算法实现不同的地方包括可以自由配置树的根节点;对HMM状态和中心音素相关问题提问的能力;以及实际上在Kaldi脚本中默认情况下,问题集是通过对数据自顶向下的二分聚类自动生成的,这就意味着不需要手动去创建问题集。关于树的根节点的配置:可能是把一个共享的群组里面所有音素分裂的统计量,或者独立的音素,或者每个音素的HMM状态,作为树的根节点来进行分裂,或者把音素组作为树的根节点(注:多个音素作为一棵树的根节点)。对于如何用标准的脚本配置根节点,请参见Data preparation。实际上,我们一般让每棵树的根节点都对应一个真实的音素(real phone),意思就是说我们把每个音素的词位置相关、发音相关或者音调相关的所有变种都放进一个音素组,作为决策树的根节点。
本页下面主要给出相关代码层面的一些详细信息。
音素上下文窗(Phonetic context windows)
这里我们解释一下在代码中我们怎样描述一个音素的上下文。一棵特殊的决策树将有两个整型值,分别描述的是上下文窗的宽度和中心位置。下表简单说明了这两个值: N是上下文窗的宽度,P是设计的中心音素的标记。一般P就是窗的中心(因此叫中心位置);举例说,当N=3时我们一般设P=1,但是我们也可以从0到N-1自由选择;比如,P=2和N=3意味着有左上下文有两个音素,并且没有右上下文。在代码中,当我们讨论中心音素时,我们总是认为讨论的是第P个音素,可能是也可能不是上下文窗中心的那个音素。
一个用来表示典型的triphone上下文窗的整型向量可能是: 1
2//probably not valid C++
vector<int32> ctx_window = { 12, 15, 21 };1
vector<int32> ctx_window = { 12, 15, 0};
1
vector<int32> ctx_window = { 15 };
树的构建过程(The tree building process)
在这部分我们给出Kaldi中树构建过程的一个概述。
即使是单音素系统也有一个决策树,但是是一个无用的树。参见返回这样一个无用树的函数MonophoneContextDependency() 和 MonophoneContextDependencyShared()。这两个函数被命令行程序gmm-init-mono调用;它主要的输入参数是HmmTopology对象,并且输出一棵树,这棵树通常会被以ContextDependency类型的对象写到一个叫做“tree”的文件中,以及模型文件(模型文件包含一个TransitionModel对象和一个AmDiagGmm对象)。如果程序gmm-init-mono接受一个叫-shared-phones的可选参数,它将会在指定的音素序列间共享pdfs(注:输出概率密度函数,比如高斯),否则它会使得所有的音素都是独立的。
从一个扁平的初始(注:除了sil,所有的单音素模型都是一样的)开始训练一个单音素系统后,我们拿单音素对齐的结果和使用函数AccumulateTreeStats()(被acc-tree-stats调用)来累积训练决策树的统计量。这个程序不限于读取单音素的对齐结果;它也能读取上下文相关的对齐结果,因此我们也可以基于triphone对齐结果来构建树。构建树的统计量以BuildTreeStatsType类型(参见Statistics for building the tree)被写到磁盘。函数AccumulateTreeStats()输入N和P的值,N和P就是上文解释过的上下文窗的大小和中心音素位置。命令行程序会默认地将N和P设为3和1,但是也可以使用–context-width和–central-position可选参数进行覆盖。程序acc-tree-stats输入一个上下文无关的音素列表(比如,silence),但是即使存在上下文无关的音素,这个也不是必需的;它只是减少统计量大小的一个机制。对于上下文无关的音素,程序将会累积一个没有定义keys的相关的统计量,keys是跟左右音素对应的(注:在代码中会把一个音素不同的上下文和pdf-class分别作为不同的key,然后累积每个key的统计量)(c.f. Event maps)。
当统计量被积累后,我们使用程序build-tree来构建树。这个程序输出一棵树。程序build-tree需要三样东西:
- 统计量(BuildTreeStatsType类型)
- 问题集配置(Questions类型)
- roots文件(参见下面)
统计量一般从程序acc-tree-stats得到;问题集配置类可以用程序compile-questions输出,compile-questions输入一个声学问题集的拓扑列表(在我们的脚本中,这些都是自动地从构建树的统计量通过程序cluster-phones得到)(注:cluster-phones输入构建树的统计量可以得到一个声学问题集)。roots文件指定了将要在决策树聚类过程中共享根节点的音素集,并且对每个音素集指出下面两个东西:
- “shared”或者“not-shared”指出是每个pdf-class(也就是一般情况下的HMM状态)都有不同的根节点,还是所有pdf-class共享一个根节点。如果是“shared”,对于所有的HMM状态(比如在正常的HMM拓扑下所有的三个状态)将只会有一个树根节点;如果是“not-shared”,将会有三个树根节点,每个pdf-class有一个。
- “split”或者“not-split”指出对于根节点要不要根据问题进行决策树分裂(对于silence,我们一般不分裂)。如果该行指定“split”(正常情况),那么我们进行决策树分裂。如果指定“not-split”,那么就不会进行分裂,因此根节点就被无分裂地保留。
下面将对这个怎样使用方面做一些阐述:
- 如果我们指定“shared split”,即使所有的三个HMM状态有一个根节点,不同的HMM状态仍然可以到达不同的叶子节点,因为树可以像对声学上下文的问题提问一样对pdf-class的问题提问。
- 对于roots文件中同一行出现的所有音素,我们总是让它们共享根节点。如果你不想共享音素的根节点,你只要把它们放在不同的行。
下面是roots文件的一个例子;假设音素1是silence,并且其他的音素都有不同的根节点。
1 | not-shared not-split 1 |
当我们有比如位置和声调相关的音素时,将多个音素放在同一行会非常有用;这样每个“真实的“音素将关联到一个整数的音素ID集合。在这种情况下我们将particular
underlying(注:这个不知道怎么翻译)音素的所有变种版本共享一个根节点。下面是来自egs/wsj/s5脚本中Wall
Street
Journal的roots文件的一个例子(这个例子中音素是用文本表示的,而不是整数形式;但在被Kaldi读取之前会被转换成整数形式(注:就是会把音素映射成整数的ID)):
1
2
3
4
5
6
7
8
9
10not-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
当创建这个roots文件时,你应该确保在每一行至少有一个音素是可见的(注:有对应的训练样本)。比如上面的情况,如果音素AY至少在声调和词位置的某些连接中可见,那就没问题。
在这个例子中,对于slience等音素我们有很多的词位置相关的变种。它们将共享它们的pdf's,因为它们都在同一行,并且是“not-split”,但是它们可能会有不同的状态转移参数。实际上,silence的大多数变种都不可能用到,因为silence不可能出现在词与词之间;这只是为了防止以后有人做一些奇怪的事而不会过时。
我们用从之前创建的模型(比如,单音素模型)得到的对齐结果来对混合高斯参数进行初始化;对齐的结果会被程序convert-ali从一棵树转换到另一棵(注:应该就是说对齐的transition不变,但状态绑定的参数可能因为决策树的不同而变化)。
PDF标号(PDF identifiers)
PDF标号(pdf-id)是一个从0开始的数字,用做概率密度函数(p.d.f.)的序号。系统中每一个p.d.f.都有自己的pdf-id,并且是连续的(在一个LVCSR系统中一般会有几千个)。在树首先被构建时,它们就会被赋值。对于每一个pdf-id对应的是哪个音素,可能知道也可能不知道,这取决于树是怎样被构建的。
上下文相关对象(Context dependency objects)
ContextDependencyInterface对象是树的一个虚基类,指定了如何与构建解码图代码进行交互。这个接口只包含四个函数:
- ContextWidth()返回树需要的N(上下文窗的大小)的值。
- CentralPosition()返回树需要的P(窗中心位置)的值
- NumPdfs()返回树定义的pdfs的数量;pdfs的编号从0到NumPdfs()-1。
- Compute()是对某个特殊的上下文计算它对应的pdf-id的函数
ContextDependencyInterface::Compute()函数的声明如下: 1
2
3
4
5class ContextDependencyInterface {
...
virtual bool Compute(const std::vector<int32> &phoneseq, int32 pdf_class,
int32 *pdf_id) const;
}1
2
3
4
5
6
7
8ContextDependencyInterface *ctx_dep = ... ;
vector<int32> ctx_window = { 12, 15, 21 }; // not valid C++
int32 pdf_class = 1; // probably central state of 3-state HMM.
int32 pdf_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;
ContextDependency对象实际上是对EventMap对象的简单组合封装;请参见Decision tree internals。我们希望尽可能地隐藏树的真正实现,使得以后需要重构代码时变得非常简单。
决策树的一个例子(An example of a decision tree)
决策树文件的格式不是以人们的可读性为首要目标而创建的,但由于大家需要我们在这里试着解释如何去解读这个文件。请看下面的例子,这个是一个来自Wall
Street
Journal脚本中triphone的决策树。它以这个对象的名字ContextDependency开始(注:在代码中整个树是一个ContextDependency对象);然后是N(上下文窗的大小),这里是3;接着是P(上下文窗的中心位置),这里是1。文件剩下的部分包含单个EventMap对象。EventMap是一个可能包含指向其他EventMap指针的多态类型。更多详细信息,请参见Event
maps。这个文件表示一棵决策树或多棵决策树的集合,并将一个键值对集合(比如,left-phone=5,
central-phone=10, right-phone=11,
pdf-class=2(注:注意这里是四个键值对,表示一个中心音素是10,上文是音素5,下文是音素11的triphone的第2个状态))映射到一个pdf-id(比如,158)。简单来说,一个决策树包含三种基本类型:一个是SplitEventMap(就像决策树中的分支判断),一个是ConstantEventMap(就像决策树的叶子节点,只包含一个表示pdf-id的数字),和一个是TableEventMap(就像是一个包含其他EventMaps的一个查找表)。SplitEventMap和TableEventMap都有一个需要它们判断的key,这个值可能是0,1或者2,分别表示左上下文音素,中心音素和右上下文音素,也可能是-1,表示pdf-class的标号(注:如果HMM的每个状态都有对应的pdf,则pdf-class可理解为HMM的第几个状态)。一般情况,pdf-class的值与HMM状态的序号是相同的,比如0,1或2。请尝试不要因此而感到困惑:key是-1,value是0,1或2,但它们与上下文窗中音素的keys
0,1或2是没有任何关系的(注:上下文窗中0,1和2表示的是窗中音素的位置)。SplitEventMap有一系列值可以触发决策树的yes分支。下面是一种quasi-BNF符号表示的决策树文件格式。
1
2
3
4 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 ")"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
27s3# 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 9\
3 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 1\
20 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 14\
5 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170\
171 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 6\
8 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 10\
1 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 2\
58 259 260 261 262 263 268 269 270 271 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 30\
3 ]1
2
3
4
5
6s3# 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 )1
std::vector<std::vector<int32> > ilabel_info;
1
2// not valid C++
ilabel_info[1500] == { 4, 30, 12 };1
ilabel_info[30] == { 28 }
1
ilabel_info[5] == { -42 }
1
2
3ilabel_info[0] == { }; // epsilon
ilabel_info[1] == { 0 }; // disambig symbol #-1;
// we use symbol 1, but don't consider this hardwired.
程序fstmakecontextsyms可以创建一个与ilabel_info对象打印形式对应的符号表;这个主要用于调试和诊断错误。