从0开始的Kaldi决策树绑定+三音素

这个博客主要介绍了三音素GMM的原理以及Kaldi对其的实现,由于没有分篇幅所以全篇较长

三音素GMM

在单音素GMM中,我们对每一个音素建立一个HMM模型,并相应于音素开头、中间和结尾使用了3个发射状态(emitting state),每个发射状态对应一个GMM模型,然而这种建模方式只是对一个音素单独建模而没有考虑到发音是个连续的过程。
由于协同发音情况的存在,一个音素在很大程度上与前一个或者后一个音素关联,所以需要上下文音素一起建模,因此有了三音素GMM。

什么是三音素GMM

三音素的HMM模型属于CD phone(依赖于上下文音素),表示在特定左侧上下文和特定右侧上下文中的音素。

[a-b+c]前[a]后[c]
[a-b]前[a]
[b+c]后[c]

但是由于排列组合导致的建模单元大量增加,对于50个音素的音素集来说。需要 5 0 3 50^3 503个三音素,但是一些组合事实上是不会存在的,因此有必要减少需要训练的三音素数量。
而最普通的办法是对于某些上下文进行聚类,把上下文在同一类别的状态捆绑起来。例如:左侧以[n]开头的音素与左侧以[m]开头的音素看起来很相近,因此可以将[m-eh+d]和[n-eh+d]中开头的第一个次音素绑定起来,从而共享同一个高斯模型。

那么怎样决定究竟要聚类什么样的上下文呢?最普通的方法就是使用决策树。对于每一个音素的每一个状态都分别建立一棵树。首先把相同中心的三音素模型放到同一个根节点上。然后根据对上下文的提问,把当前的聚类分离成两个较小的聚类。在决策树中所提的问题是关于音素的左侧或者右侧是否具有某种语音特征。

决策树的如何训练出来呢?通过迭代的方式,决策树从根部开始自顶向下。在每一轮迭代时,算法要考虑树中每一个可能的问题q和每一个可能的结点n。对于每一个问题,算法要考虑新的分离对于训练数据的声学似然度的影响。如果由于问题q引起了被捆绑模型基础的分离,那么,算法要计算出训练数据的当前声学似然度与新的似然度之间的差别。算法选取给出最大似然度的结点n和问题q。不断迭代过程直到每一个叶子结点的实例数都达到最大的阈值为止。

为了训练依赖于上下文的模型,首先使用标准的嵌入式算法来训练独立于上下文的模型,多次使用EM算法,对于单音素的每一个次音素都形成不同的高斯模型,然后克隆每一个单音素模型到三音素模型。但是转移矩阵A不克隆,但是要把所有的三音素绑定在一起。再次运行EM迭代,再次训练三音素高斯模型。对于所有的单音素,使用决策树的聚类算法,把依赖于上下文的三音素都聚类,选择一个典型的状态作为类别的例子,其他状态与这个状态进行捆绑。

接下来训练高斯混合模型,首先使用嵌入式训练对上述的每一个捆绑的三音素状态训练一个单独的高斯混合模型,然后把每一个状态分离到两个等同的高斯模型中,使用某个 ϵ \epsilon ϵ来干扰并调整每一个值,再次运行EM,对这些值再次进行训练,直到对于每一个状态中的观察,都得到一个恰当的高斯混合模型为止。

kaldi决策树绑定

EventMap之EventType

决策树用于对三音素的状态进行绑定,在kaldi中使用什么数据表示三音素呢?
一种方法是使用一对数表示三音素的位置和该位置上的音素,C++表示就是pair<int,int>。另外除了知道三音素三个位置上的音素各是什么,我们还要知道一个HMM状态是三音素的第几个HMM状态,在表示三音素时使用一对数表示,在表示HMM状态的时候使用同样的方法,第一个int取-1则表示这对数是HMM状态信息,第二个数取HMM状态编号,表示这是该三音素第几个HMM状态。

表示三音素的三对数和表示HMM状态的一对数放在一起,可以描述特定三音素模型的某一HMM状态

typedef std::vector<std::pair<EventKeyType, EventValueType>> EventType

在这里EventKeyType和EventValueType只是对int32的重定义,当前音素是a(10)/b(11)/c(12)时,那么该三音素的第二个HMM状态为:

EventType e = {(-1,1),(0,10),(1,11),(2,12)}

什么是EventMap呢?EventMap是EventType到EventAnswerType的一个映射,其中EventAnswerType是一个int32的值表示pdf-id。我们知道每一个HMM状态都会关联一个概率密度函数,这个关联就是EventMap。EventMap::Map(EventType e, &EventAnswerType ans)返回bool并将pdf-id存到ans中,如果e没有对应任何pdf则返回false。

EventMap是一个纯虚类,它有三个具体的实现:

  1. ConstantEventMap:可以看做决策树叶节点。此类存储EventAnswerType类型的整数命名为answer_,它的Map()函数始终是返回该值
  2. SplitEventMap:可以看做决策树非叶节点,它查询某个键的值并根据答案转到True或False子节点。但是一个节点只有左或者右一个问题所以保存一个key_变量来判断是左边音素还是右边音素的问题,另外我们还可以对HMM状态问问题,既key_可以取-1它的map函数调用相应子节点map函数。该节点会存储的是一组答案为True的kAnswerType类型的整数命名为yes_set_。(除了存储的整数以外,剩余的是False)另外还包含指向孩子的指针yes_和no_
  3. TableEventMap:一般来说,对每个中间音素的每个状态都要建立一棵决策树进行状态绑定,Kaldi对所有HMM状态想要建立一棵大树,这个树林的每个节点分别是每个决策树的起点,用一个EventMap*指针的向量存储。它查找与拆分的键相对应的值,并在向量的相应位置调用eventmap的Map()函数

使用ConstantEventMap和SplitEventMap构成决策树,而TableEventMap去选择决策树

roots文件

Kaldi在构建决策树的时候,除了需要累积的统计量和问题集,还需要roots文件。它指明在决策树的聚类过程中,哪些音素应该共享树根。对放在roots文件同一行的音素共享一个树根。

shared or not-shared

shared——对一个音素的HMM状态共享一个树根
not-shared——对一个音素的HMM状态建立分开的决策树根

split or not-split

split——对当前决策树树根进行划分
not-split——不对当前决策树树根划分

简单看一下roots文件,上为roots.txt,下为roots.int,图片来自于开拓师兄博客

Clusterable和GuassClusterable

Clusterable是一个纯虚类,作为kaldi聚类机制的统一接口。在三音素决策树状态绑定这一块,我们主要用到的是继承自该类的GuassClusterable。

Clusterable对象的主要作用是把统计量累加在一起,和计算目标函数。两个Clusterable的距离用分别计算两个Clusterable的目标函数,然后再把两个Clusterable加起来计算目标函数,目标函数下降的负值就是两个Clusterable的距离。

在forced alignment之后,从左到右扫描对齐数据,我们从中得到(三音素及HMM状态)和其对应的特征向量,也就是得到一个EventType和其对应的特征向量。在扫描过所有训练数据后,出现每个EventType会对应多个特征向量。

在计算目标函数时,也就是状态集的似然L(S),这里S是一组HMM状态,根据L(S)的公式
L ( S ) = ∑ f ∈ F ∑ s ∈ S l o g ( P r ( o f ; μ ( S ) , Σ ( S ) ) ) γ s ( o f ) L(S)=\sum_{f\in F} \sum_{s\in S}log(Pr(o_f;\mu(S),\Sigma(S)))\gamma_s(o_f) L(S)=f∈F∑​s∈S∑​log(Pr(of​;μ(S),Σ(S)))γs​(of​)
带入高斯混合模型
L ( S ) = − 1 2 ( l o g [ ( 2 π ) n ∣ Σ ( S ) ∣ ] + n ) ∑ s ∈ S ∑ f ∈ F γ s ( o f ) L(S)=-\frac{1}{2}(log[(2\pi)^n|\Sigma(S)|]+n)\sum_{s\in S}\sum_{f\in F}\gamma_s(o_f) L(S)=−21​(log[(2π)n∣Σ(S)∣]+n)s∈S∑​f∈F∑​γs​(of​)
γ s ( o f ) \gamma_s(of) γs​(of)是状态s得到观测 o f o_f of​的后验概率,想要详细了解该式的原理请参考这篇博客

根据上式我们需要知道状态机S产生的所有观测的协方差,对角协方差的对角线上是特征向量集每一维的方差,每一维方差就需要知道特征向量集的和以及特征向量集的平方和(D(X)=E(X2)-(EX)2);计算L(S)除了知道协方差还需要知道状态集S产生的特征向量的个数,也就是状态集S出现的次数,因为kaldi使用Viterbi训练,得到对齐后,我们就不需要计算posterior概率,可以用状态集S对应的特征向量的个数代替posterior概率。

于是,与一个EventType相关的统计量包括EventType对应的特征向量的个数、这些特征向量的累加、这些特征向量的平方的累加,这三个值,就是GuassClusterable中需要保存的统计量,根据这三个统计量可以计算该EventType的似然。如果把多个EventType的统计量累加到一起,可以计算这些EventType组成的状态集的似然,如果把多个EventType统计量累加在一起就可以计算这些EventType组成的状态集的似然。

在扫描对齐数据累积统计量时,一个EventType对应一个GaussClusterable对象。在这个GaussClusterable对象中,成员count_保存EventType出现的次数,stats_矩阵的第一行保存该EventType对应的所有特征向量的和,stats_矩阵的第二行保存着该EventType对应的所有特征向量的平方和

聚类算法
  1. RefineClustersOptions
  2. ClusterKMeansOptions
  3. TreeClusterOptions
BuildTreeStatsType

构建决策树时,我们需要知道的所有信息就是从训练数据的对齐中得到的所有EventType,和每个EventType对应的Clusterable对象,我们可以把这两者的对应关系保存在一对数据中pair<EventType, Clusterable*>,然后把所有的这些对保存成一个vector,所以构建决策树所用到的统计量可以表示成:

    typedef std::vector<std::pair<EventType, Clusterable*>> BuildTreeStatsType
acc-tree-stats

作用:Accumulate statistic for phonetic-context tree building 该程序为决策树的构建累积相关统计量。
代码位置:kaldi-master/src/bin/acc-tree-stats
输入:声学模型、特征、对齐
输出:统计量(也就是BuildTreStatsType)
过程:输入的声学模型一般为单音素训练得到的GMM模型

  1. 打开声学模型并从中读取TransitionModel,打开特征文件、打开对齐文件,这里使用一个代码块来读取mdl文件,binary携带是否为二进制文件。使用SequentialBaseFloatMatrixReader来打开特征文件,使用RandomAccessInt32VectorReader来打开对齐文件
TransitionModel trans_model;
{bool binary;Input ki(model_filename, &binary);trans_model.Read(ki.Stream(), binary);
}
SequentialBaseFloatMatrixReader feature_reader(feature_rspecifier);
RandomAccessInt32VectorReader alignment_reader(alignment_rspecifier);
  1. 对每一句话的特征和对应的对齐,调用程序AccumulateTreeStats()累积统计量tree_stats,tree_stats变量使用std::map<EventType,GaussClusterable*>用来构建决策树。逐条读入特征数据,检查该特征数据对应键值是否在对齐中(值得注意的是这里如果键值不再对齐中的话将继续读取下一个特征),如果在的话获取特征和对齐的状态,检查特征和对齐的维度是否相同,相同则调用AccumulateTreeStats计算累积量并将结果输出到tree_stats中
std::map<EventType, GuassClusterable*> tree_stats
AccumulateTreeStats(trans_model,acc_tree_stats_info,alignment,mat,&tree_stats)
  1. 将tree_stats转移到BuildTreeeStatsType类型的变量stats中,将stats写到文件JOB.treeacc,使用std::map生成的迭代器循环将tree_stats复制到BuildTreeStatsType中,并保存在std::pair<>数据类型保存。使用代码块输出到文件中,这里使用的binary是定义在函数的binary为true
{Output ko(accs_out_wxfilename,binary);WriteBuildTreeStats(ko.Stream, binary, stats);
}

工具使用方法:

acc-tree-stats $context_opts --ci-phones=$ciphonelist $alidir/final.mdl "$feats" \
"ark:gunzip -c $alidir/ali.JOB.gz|" $dir/JOB.treeaacc
AccumulateTreeStats()功能详解

该函数的实现是在hmm/tree-accu.cc中,先说它的函数定义

void AccumulateTreeStats(const TransitionModel &trans_model,  //单因素模型const AccumulateTreeStatsInfo &info,  //参数const std::vector<int32> &alignment,  //一条特征序列对应的对齐状态const Matrix<BaseFloat> &features,  //一条特征序列std::map<EventType, GaussClusterable*> *stats

在讲解这个函数前首先要介绍几个AccumulateTreeStatsInfo的参数

  • phone_map 这是旧音素id到新音素id的映射
  • context_width 上下文相关音素窗口大小
  • central_position 窗口的中间位置
  • ci_phone 上下文无关音素列表
    主要功能:
  • 首先这个函数拿到单因素模型,对齐序列(transition_id序列),注意transition_id可以一对一的对应到一个HMM状态。使用(SplitToPhones())能够从transition_id得到对应的音素序列split_alignment
  • 然后使用一个context_width大小的窗在split_alignment上滑动,并找出中间位置在对齐序列中的情况
  • 从phone_map中获取新的中间音素的phone_id,并在ci_phone中查找,若未找到则证明该音素是上下文相关的
  • 查找窗内所有音素的id号(超出对齐列表的使用0作为代替),并将id号和音素对应位置的二元组记录下来存入EventType
  • 之后对于每一组三音素需要得到HMM状态id号,通过transition_id得到三音素的HMM状态id,从而得到一个个EventType。
  • 经过排序后,使用AddStats()函数计算累计统计量,这个函数计算了每个EventType对应出现的次数,观测累和、观测平方累和该函数定义如下
void AddStats(const VectorBase<BaseFloat>& vec,BaseFloat weight=1.0
)

具体例子参照开拓师兄博客中的例子:

如何自动形成问题集

cluster_phone
  • 作用:kaldi使用cluster_phone作为驱动形成问题集,对多个音素或多个音素集进行聚类
  • 输入:决策树相关统计量treeacc,多个音素集文件sets.int
  • 输出:自动生成的问题集questions.int
  • 使用方法:
cluster-phones $context_opts $dir/treeacc $lang/phones/sets.int \$dir/questions.int
  • 过程:

    1. 从treeacc中读取统计量到BuildTreeStatsType stats;从pdf_class_list_str中读取pdf_class_list这是一个vector<int32>类型的变量用于指示考虑的HMM状态id号,默认只有1,也就是只考虑三个状态HMM的中间状态。从sets.int中读取phone_sets,默认三音素的参数为N=3(有3个音素组成),P=1(中间音素编号为1)
    2. 若指定的mode为questions调用AutomaticallyObtainQuestions()自动生成问题集 phone_sets_out;若指定的mode为k-means,调用KMeansClusterPhones(),具体详见论文《Tree-Based State Tying For High Accuracy Acoustic Modelling》
    3. 将上述函数自动生成的phone_sets_out写道questions.int中
  • 输入文件输出文件:以下是sets.txt和 sets.int的截图,看到这个文件的形式我梦能对其有一个直观的印象,并且输出文件是同样的形式,只是每一行的内容有所改变

AutomaticallyObtainQuestion()

该函数是cluster_phone的核心函数,其主要功能是通过对音素自动聚类获得问题集;他把音素聚集为一个树,对树中的每个节点,把从该节点可以到达的所有叶子节点结合走一起构成一个音素集。该函数的定义如下:

void AutomaticallyObtainQuestions(BuildTreeStatsType &stats,const std::vector<std::vector<int32> > &phone_sets_in,const std::vector<int32> &all_pdf_classes_in,int32 P,std::vector<std::vector<int32> > *questions_out
)

代码解读:

  1. 首先读取phone_sets_in中每一个音素集中的音素并保存在vector<int32> phones,phone_sets_in在驱动程序中由sets.int得到
  2. 使用FilterStatsByKey()函数把stats中只属于三音素第二个HMM状态的统计量留下。该函数的定义如下
void FilterStatsByKey (const BuildTreeStatsType & stats_in, EventKeyType key, std::vector< EventValueType > & values, bool include_if_present, BuildTreeStatsType * stats_out
)

该函数通过特定键的值过滤统计量,当include_if_present为true的时候,输出特定键的值在values中的统计量,否则输出不在values中的统计量,那么为什么说是第二个HMM状态的值呢?这个就是由values决定的,在程序中是这样调用的

  FilterStatsByKey(stats, kPdfClass, all_pdf_classes,true,  // retain only the listed positions&retained_stats);

kPdfClass为-1指示的是HMM状态id,all_pdf_classes就是cluster-phones中的参数pdf_class_list,我们先前提到了它在默认情况下只有1,所以是第二个状态。输出为retained_stats

  1. 使用SplitStatsByKey(),根据三音素的中间音素对retained_stats进行划分,把属于每个音素的统计量放在一个BuildTreeStatsType中。该函数的定义如下:
void SplitStatsByKey(const BuildTreeStatsType& stats_inEventKeyType key,std::vector<BuildTreeStatsType>* stats_out)

这个函数的主要作用将按key对应的值的不同对统计量进行划分,在程序中,这个函数是这样调用的:

  SplitStatsByKey(retained_stats, P, &split_stats);

我们前面提到P为1,所以例如我们音素集中共有215个音素,且这些些音素都出现在三音素的中间位置,则split_stats元素个数是215

  1. 使用SumStatsVec()把刚刚按中间音素进行划分的统计量加起来,得到每个中间音素的统计量,也就是输出summed_stats,该函数在函数内部通过循环调用SumStats将每个音素的统计量累加起来,该函数在程序中是这样调用的:
SumStatsVec(split_stats, &summed_stats);
  1. 到上一步为止,我们已经获得了每一个音素的统计量,为了计算聚类,我们还需要得到原本音素集的累积统计量,根据sets.int指定的集合,累加同一个集合中的音素的统计量,我们之前提到了sets.int中的数据形式,sets.int文件同一行的音素表示在一个音素集合中,我们最后得到的输出是summed_stats_per_set,它的维数就是sets.int的行数
  std::vector<Clusterable*> summed_stats_per_set(phone_sets.size(), NULL);for (size_t i = 0; i < phone_sets.size(); i++) {const std::vector<int32> &this_set = phone_sets[i];summed_stats_per_set[i] = summed_stats[this_set[0]]->Copy();for (size_t j = 1; j < this_set.size(); j++)summed_stats_per_set[i]->Add(*(summed_stats[this_set[j]]));}
  1. 准备工作完成,到了真正开始聚类的时候,调用TreeCluster()对summed_stats_per_set进行聚类,生成一系列信息,对于这部分的讲解我们先往后搁搁,这个函数是这样调用的
  TreeClusterOptions topts;topts.kmeans_cfg.num_tries = 10;  // This is a slow-but-accurate setting,// we do it this way since there are typically few phones.std::vector<int32> assignments; //assignment of phones to clusters. dim == summed_stats.size().std::vector<int32> clust_assignments;  // Parent of each cluster.  Dim == #clusters.int32 num_leaves;  // number of leaf-level clusters.TreeCluster(summed_stats_per_set,summed_stats_per_set.size(),  // max-#clust is all of the points.NULL,  // don't need the clusters out.&assignments,&clust_assignments,&num_leaves,topts);
  1. 最后的最后我们从上一步的结果中获取信息生成问题集。使用的函数是ObtainSetsOfPhones(),该函数定义方法如下:
static void kaldi::ObtainSetsOfPhones(const std::vector<std::vector<int32>>& phone_sets//由sets.int生成const std::vector<int32>& assignments//phone_sets中每个元素所属的cluster,上一步生成了树,每个phone_sets的元素都属于该树的一个叶子节点const std::vector<int32>& clust_assignments//上一步生成的树的每个节点的父节点int32 num_leaves//上一步生成树的叶子个数std::vector<std::vector<int32>>* sets_out//生成的问题集
)

该函数执行过程为:首先得到每个cluster(叶子节点)中的音素集;将子节点的音素集加入到其父节点的音素集中;把原始的phone_set插入到问题集;过滤问题集的重复项、空项,生成最终的问题集。

TreeCluster()

该函数的定义如下:

BaseFloat TreeCluster(const std::vector<Clusterable*> &points,int32 max_clust,  // this is a max only.std::vector<Clusterable*> *clusters_out,std::vector<int32> *assignments_out,std::vector<int32> *clust_assignments_out,int32 *num_leaves_out,TreeClusterOptions cfg)

程序中调用方法:

  TreeClusterOptions topts;topts.kmeans_cfg.num_tries = 10;  // This is a slow-but-accurate setting,// we do it this way since there are typically few phones.std::vector<int32> assignments; //assignment of phones to clusters. dim == summed_stats.size().std::vector<int32> clust_assignments;  // Parent of each cluster.  Dim == #clusters.int32 num_leaves;  // number of leaf-level clusters.TreeCluster(summed_stats_per_set,summed_stats_per_set.size(),  // max-#clust is all of the points.NULL,  // don't need the clusters out.&assignments,&clust_assignments,&num_leaves,topts);

该函数其实只包含两行代码:

TreeClusterer tc(point, max_clust, cfg);
BaseFloat ans = tc.Cluster(clusters_out, assignments_out, clust_assignments_out, num_leaves_out)

kaldi将所有的聚类操作全部继承到一个类中,实现了程序之间的解耦合。首先初始化一个TreeCluster对象,把统计量points传给对象;然后调用该对象的Cluster方法获取关于聚类结果的相关信息。ObtainSetsOfPhones()根据这些信息就可以生成问题集。

TreeClusterer对象和Node数据结构

TreeClusterer是使用自顶向下的树进行聚类的一个对象。有树的地方就有节点Node,Node数据结构中保存了以下信息

  1. Node保存着指向其父节点的指针parent。以及指向其儿子节点的指针children,注意到children是一个Node指针的vector,vector的大小由TreeClusterOptions中的branch_factor参数指定,这个值默认为2,所以我们这里使用的树是二叉树,每个节点最多只有两个孩子节点。

  2. 保存着属于该节点的所有统计量之和node_total(统计量就是该节点中的音素对应的所有特征向量的出现次数count_、特征向量之和stats_(0)、特征向量的平方和stats_(1),统计量用来计算该节点的似然L(s))

  3. 还保存着该节点是否是叶子节点,以及是叶子节点时在leaf_nodes中的索引和不是叶子节点时在nonleaf_nodes节点中

  4. 如果是叶子节点,保存着属于该叶子的那些点的统计量points,以及该叶子节点上拥有的那些点在所有点组成的vector上的索引point_indices(也就是在TreeClusterer对象point_成员中的索引)。用best_split保存着对该叶子节点进行最优划分时,获得的最大的似然提升。对该叶子节点划分意味着生成两个新的簇(或者说两个新的孩子节点),assignment中就保存着对该叶子节点进行最优划分后,该叶子节点中的点分别被划分到哪个簇,其元素值为0、1。
    从这里我们就注意到了一件事,那就是统计量全部存储在叶子几点中,那么之前的一句话

    他把音素聚集为一个树,对树中的每个节点,把从该节点可以到达的所有叶子节点结合在一起构成一个音素集。
    

接着看一下TreeClusterer的数据成员有哪些:

  1. TreeClusterer中构造树的节点分为两类:叶子节点和非叶子节点。叶子节点放在leaf_nodes_中,非叶子节点放在nonleaf_nodes_中,每个节点Node的数据结构中保存着该Node是否为叶子节点以及在这两个向量中的索引。
  2. points_中保存着初始化TreeClusterer对象时传递来的每个点的统计量,该对象的聚类过程,就是为了把这些点分成一簇簇(cluster)。
  3. queue_是一个优先队列,队列中的每个元素是std::pair<BaseFloat,Node*>类型,首先说Node*指向的是一个个节点,而第一个BaseFloat则是对Node进行划分时所获得的似然的最大提升。使用优先队列以为着是对似然提升最大的节点优先划分。
    事实上,Node是一个定义在TreeClusterer中的私有struct,因此只能在TreeClusterer创建使用
TreeClusterer对象初始化

我们注意TreeCluster首先是初始化了一个TreeClusterer对象,我们首先来看TreeClusterer的构造函数

TreeClusterer(const std::vector<Clusterable*> &points,int32 max_clust,TreeClusterOptions cfg):points_(points), max_clust_(max_clust), ans_(0.0), cfg_(cfg){KALDI_ASSERT(cfg_.branch_factor > 1);Init();}

其中调用了Init()函数,Init()函数的作用是初始化根节点

  void Init() {  // Initializes top node.Node *top_node = new Node;top_node->index = leaf_nodes_.size();  // == 0 currently.top_node->parent = NULL;  // no parent since root of tree.top_node->is_leaf = true;leaf_nodes_.push_back(top_node);top_node->leaf.points = points_;top_node->node_total = SumClusterable(points_);top_node->leaf.point_indices.resize(points_.size());for (size_t i = 0;i<points_.size();i++) top_node->leaf.point_indices[i] = i;FindBestSplit(top_node);  // this should always be called when new node is created.}

该函数将根节点初始化为叶子节点并将index置为0后,在leaf_nodes_中添加了根结点,这样根节点在leaf_nodes_中的位置变成了0,之后是初始化Node中是叶子节点的数据结构,根节点包括传递给TreeClusterer所有点的统计数据point_,并使用vector.resize以point_的大小构造一个vector给point_indices并循环赋值。最后调用FindBestSplit(Node * node)对根节点进行最优划分。

每当创建新节点的时候(一般是叶子节点),应该总是调用FindBestSplit(Node * node),在该函数中主要是求节点的最优划分,调用ClusterKmeans()计算最大似然的提升并将结果保存在best_split中如果这个提升大于cfg_中指定的似然阈值thresh,则将这个结果和当前节点存放到queue_优先队列中。

TreeClusterer.Cluster()
  BaseFloat Cluster(std::vector<Clusterable*> *clusters_out,std::vector<int32> *assignments_out,std::vector<int32> *clust_assignments_out,int32 *num_leaves_out)

在完成对TreeCluster对象的初始化工作后,对传递给该对象的所有点,该函数根据统计量构造树,实现对点的聚类,将点分为一簇簇,该树的一个叶子节点代表一个簇,每一簇都包含几个点。
回到AutomaticallyObtainQuestion()对TreeCluster()的调用,所谓树聚类的点就是sets.int中的音素集的集合,一个点就是sets.int中的一行,也就是说一个点就是一个音素集。以实验室set.int为例,sets.int一共63行,所以有63个点。用树聚类将这些点聚类后树的每个叶子节点都有几个点,
该函数是循环构建树,直到叶子节点的数量大于max_clust_或者queue_为空
queue_是一个优先队列,每个元素是pair<BaseFloat, Node*>,该pair中,后一个指向节点的指针,前一个是对第二个数据Node进行最优划分所得的最大似然提升。在循环中不断将队首节点取出对其进行DoSplit(),并在ans_中加上最大似然的提升。
DoSplit(Node *node)是对node属性上值性具体的划分,首先生成两个孩子节点设置为叶子节点并将之前在初始化时分好的累计统计量分别分到各个子节点上,另外也要更新leaf_nodes_将父节点的位置给第一个子节点后面的以此类推,并更新子节点的index变量。之后将父节点中所有的point分到不同的子节点,转移的数据有三个,一个是每个点的类别向量,一个是子节点对应的points向量以及子节点对应的point_indices。最后将父节点变为非叶子节点,加入nonleaf_nodes_,并对子节点调用FindBestSplit对字节点继续划分子节点。在FindBestSplit中对节点做KMeans聚类如果提升大于阈值则加入优先队列之后就可以继续进行Cluster()中的循环。
最后生成ObtainSetsOfPhones()所要用到的信息。

  void CreateOutput(std::vector<Clusterable*> *clusters_out,std::vector<int32> *assignments_out,std::vector<int32> *clust_assignments_out,int32 *num_leaves_out)
  1. 调用CreateAssignmentsOutput(assignments_out),计算传递给TreeCluster的每个点分别属于哪个叶子节点。在对树完成划分后,每个叶子节点都包含几个点,把这些点属于叶子节点编号写到assignments_out中
  2. 调用CreateClustAssignmentsOutput(clust_assignments_out),计算树的每个节点的父亲节点的编号。
  3. 调用CreateClustersOutput(clusters_out),TreeCluster()并不使用这个输出
ClusterKMeans

ClusterKMeans的函数定义如下

BaseFloat ClusterKMeans(const std::vector<Clusterable*> &points,int32 num_clust,std::vector<Clusterable*> *clusters_out,std::vector<int32> *assignments_out,ClusterKMeansOptions cfg)

其中num_clust表示划分类的个数,这里被设置为branch_factor(默认为2),在这里用到了两个参数

  1. num_tries 调用ClusterKMeansOnce()的次数,并选择最好一次的结果
  2. num_iters 迭代次数
    之后主要是调用了ClusterKMeansOnce()来进行聚类,以num_tries为1为例
ClusterKMeansOnce(points, num_clust, clusters_out, (assignments_out != NULL?assignments_out:&assignments), cfg);

现在研究ClusterKMeansOnce()的代码,该函数的参数定义和ClusterKMeans如出一辙,这里就不再继续详述了。
ClusterKMeansOnce()运行过程如下:

  1. 首先将传给该函数的points伪随机分给两个簇使用Rand()(所以我们说多次调用会产生不同的答案),随机分簇的方法是随机产生一个skip,这个skip要和points_的总数互质,找到互质数的方法比较暴力,从随机起点开始搜索与总数互质的点,到达num_points-1的位置就从头开始,为什么要选质数呢,因为质数可以将points_全部遍历,看下面代码是将points_集随机分为两类的方法,注意这里第一次将assignment_out改变了。
    int32 i, j, count = 0;for (i = 0, j = 0; count != num_points;i = (i+skip)%num_points, j = (j+1)%num_clust, count++) {// i cycles pseudo-randomly through all points; j skips ahead by 1 each time// modulo num_points.// assign point i to cluster j.if ((*clusters_out)[j] == NULL) (*clusters_out)[j] = points[i]->Copy();else (*clusters_out)[j]->Add(*(points[i]));(*assignments_out)[i] = j;}
  1. 现在计算初始的impr,这里的方法是将两个簇的目标函数结果相加减去,将这两个Clusterable相加后再去计算目标函数的结果。
    ans = SumClusterableObjf(*clusters_out) - all_stats->Objf();  // improvement just from the random
  1. 循环迭代num_iters次,每次调用RefineClusters()函数对聚类簇进行重新分类,如果提升为0则跳出循环。在这个函数中初始化了RefineClusterer类的对象rc,并调用了该类的Refine()函数
    BaseFloat impr = RefineClusters(points, clusters_out, assignments_out, cfg.refine_cfg);
RefineClusterer

该类中的变量有

  typedef struct {LocalInt clust;LocalInt time;BaseFloat objf;  // Objf of this cluster plus this point (or minus, if own cluster).} point_info;const std::vector<Clusterable*> &points_;std::vector<Clusterable*> *clusters_;std::vector<int32> *assignments_;std::vector<point_info> info_;  // size is [num_points_ * cfg_.top_n].std::vector<ClustIndexInt> my_clust_index_;  // says for each point, which index 0...cfg_.top_n-1 currently// corresponds to its own cluster.std::vector<LocalInt> clust_time_;  // Modification time of cluster.std::vector<BaseFloat> clust_objf_;  // [clust], objf for cluster.BaseFloat ans_;  // objf improvement.int32 num_clust_;int32 num_points_;int32 t_;RefineClustersOptions cfg_;  // note, we change top_n in config; don't make this member a reference member.

前三个是输入值,point_info是类内定义的数据结构存储点的信息,info_存储了每个点与各个簇之间的关系,ClusterIndexInt是uint_smaller类型的,my_clust_index这个向量表示每个点对应的簇,LocalInt是int32类型的,clust_time_表示簇更改时间,clust_objf表示簇的目标函数值
首先被调用的是该类的构造函数,所以我们从构造函数开始看起。

  RefineClusterer(const std::vector<Clusterable*> &points,std::vector<Clusterable*> *clusters,std::vector<int32> *assignments,RefineClustersOptions cfg): points_(points), clusters_(clusters), assignments_(assignments),cfg_(cfg) {
  1. 首先将cfg_.top_n设置为2(默认为5)意味着我们只需要距离最近的两个点,将my_clust_index_初始化为num_points_大小,clust_time_和clust_objf都初始化大小为2,其中clust_time_全部设置为0,clust_objf_设置为每个簇的目标函数值,info则设置为为num_points_*top_n大小,最后调用InitPoints()
  2. 在InitPoints中对每个point调用InitPoint(int32 point),在InitPoint(int32 point)中首先计算了当前点到除自己所在簇以外其他簇的距离并保存在一个distance的向量中,这里的distance使用的是每个簇的目标函数减去加入这个point的目标函数得到的。之后对distance进行了一次nth_element从而取到了距离最近的top_n-1个distance,之后是在这个点对应的两个info中填入相应信息,最后将my_clust_index_中对应值设置为top_n-1(意味着每个点目前还是自己所属的那个类)
RefineClusterer.Refine()

Refine()函数非常简单,它里面调用的是Iterate()函数。Iterate()函数也非常简单它包含两层循环,最外层是迭代次数的循环,内层是point点的循环,每个点调用ProcessPoint(int32 point)函数,来处理每个点,首先先说它里面用到的另一个函数UpdateInfo(int32 point, int32 idx)它的作用是如果输入的点对应的簇是自己所在的簇则从簇里去掉并将时间记录下来,反之如果不是自己所在的簇就加入该点,那么现在来说说ProcessPoint做了什么事吧
3. 首先将输入点从自己的簇中去掉并得到了去掉了自己的簇的目标函数值以及从clust_objf_中获得了之前没有去掉自己的簇的目标函数值
4. 对每个除了自己的簇意外的其他簇(这里只有1个),将自己加入到这个簇中并获得了加上自己的簇的目标函数值以及从clust_objf_中获得的之前没加自己的目标函数值
5. 两个改变后的最大似然和-两个没有改变时的最大似然和得到impr
6. 如果impr大于0则在ans_中加impr并调用MovePoint(point,index)修改相关的变量值从而将一个点完全地加入到另一个簇中

compile-questions

我们之前使用cluster-phones得到的是questions.txt输出,想要用到构建树中我们还需要将questions.txt变为questions.qst
compile-questions 输入topo和questions.txt,输出question.qst
过程:

  1. 读取topo,questions
  2. 调用ProcessTopo(topo,questions)。该函数的作用是检查所有再topo中的因素至少一次出现在一个问题中(一组音素集合),返回topo中因素具有的最大pdfclasses数
  3. 对三音素的每一个位置n=(0,1,2)分别设置问题,对Question对象qo,调用方法qo.SetQuestionOf(n, phone_opts),这里用到的phone_opts是一个QuestionForKey对象,该函数的作用是用输入的phone_opts创建或者更新该位置的QuestionsForKey。在这里对音素设置问题时携带的问题集来自于questions.txt的全部内容
  4. 对三音素对应的3状态的hmm的每一状态设置问题,与对因素设置的操作类似,只不过这里携带的问题集是如下形式
    1. 若max_num_pdfclasses=3,则[[0],[0,1]]
    2. 若max_num_pdfclasses=5,则[[0],[0,1],[0,1,2],[0,1,2,3]]

Kaldi构建树

build-tree
  1. 作用:构建决策树
  2. 输入:累计的统计量treeacc、问题集question.qst、roots.int、HMM拓扑topo
  3. 输出:决策树tree
  4. 过程:
    1. 读取roots文件得到phone_sets、is_shared_root、is_split_root这些变量的维度相同都是roots.int 中的一行
    2. 读取topo文件,构建HMM拓扑结构对象HmmTopology topo
    3. 读取treeacc,得到累计统计量BuildTreeStatsType stats。
    4. 读取questions.qst,得到Questions qo
    5. 调用topo.GetPhoneToNumPdfClasses(&phone2num_pdf_classes)得到phone2num_pdf_classes,其元素保存每个音素对应的HMM状态数
    6. 调用BuildTree(),返回保存整个大决策树的EventMap *to_pdf
    7. 用to_pdf 初始化对象ContextDependency ctx_dep(N,P,to_pdf),并将ctx_dep写到文件tree中。
BuildTree()
  1. 调用GetStubMap()得到初始的决策树,也就是扩展前的决策树。扩展前的决策树的一个叶子节点对应roots.int中的一行的决策树的树根。
  2. 对roots.int中的一行构建一个决策树d,用决策树D的叶子结点保存决策树d的树根。决策树D的作用只是把63个不同的决策树放在一起。随后对D的每个叶子结点进行扩展构建属于每个音素自己的决策树
  3. (GetStubMap()函数过程)之后是递归建树过程,对非叶子节点生成SE并循环对子节点进行递归调用,对叶子节点(只剩一行音素)生成CE,并再answer_中存入num_leaves_out,
  4. 调用FliterStatsKey(),把再roots.int中指定为split的音素统计量留下,把指定为not-split的音素的统计量丢弃。
  5. 调用SplitDecisionTree(),对tree_stub进行扩展:把tree_stub的每个叶子结点扩展为决策树。
  6. (SplitDecisionTree()函数过程)

从0开始学习kaldi决策树绑定+三音素相关推荐

  1. 从0开始学习自动化测试框架cypress(三)特性

    下面再来一个简单的例子 实现效果是访问百度,输入java经典教程,搜索 describe('DOM访问操作实例', () => {it('百度搜索java经典教程', () => {cy. ...

  2. Kaldi三音素GMM学习笔记

    建议在csdn资源页中免费下载该学习笔记的PDF版进行阅读:)点击进入下载页面 Kaldi三音素GMM学习笔记 三音素GMM与单音素GMM的主要差别在于决策树状态绑定,与GMM参数更新相关的原理.程序 ...

  3. Kaldi决策树状态绑定学习笔记(一)

    建议在csdn资源页中免费下载该学习笔记的PDF版进行阅读:)点击进入下载页面 Kaldi决策树状态绑定学习笔记(一) --如何累积相关统计量? 目录 Kaldi决策树状态绑定学习笔记一 如何累积相关 ...

  4. 从0开始的Kaldi学习

    Kaldi 教程 Kaldi的开始 Kaldi下载 使用github得到最新的kaldi工具包 git clone https://github.com/kaldi-asr/kaldi.git git ...

  5. kaldi 学习笔记-三音素训练1(Decision Tree)

    开始介绍kaldi三音素训练大致流程.本文主要介绍决策树(Decision Tree)部分. 1. acc-tree-stats Usage: acc-tree-stats [options] < ...

  6. Microsoft .NET Pet Shop 4.0 学习之旅(三) - 项目的引用关系2

    Microsoft .NET Pet Shop 4.0 学习之旅(三) 项目的引用关系2 <?xml:namespace prefix = o ns = "urn:schemas-mi ...

  7. kaldi学习笔记-三音素训练2

    本文介绍三音素训练部分. 上篇文章已经提到了如何对三音素聚类,构建决策树,接下来进行对三音素中各个GMM进行训练.三音素训练部分和单音素大致相同,都是运用EM算法进行参数的更新,具体部分可以看单音素训 ...

  8. gentos 执行sh文件_学习kaldi跑thchs30记录(run.sh代码过程)

    cmd.sh:运行配置目录,并行执行命令,通常分 run.pl, queue.pl 两种 path.sh:环境变量相关脚本(kaldi公用的全局PATH变量的设置) run.sh :整体流程控制脚本, ...

  9. 《kaldi语音识别实战》阅读笔记:三音素模型训练—train_deltas.sh解析

    一.使用说明 1.1 描述 训练三音素模型.与单音素模型训练相比,因为建模单元变为三音素,因此多了决策树状态绑定. steps/deltas.sh Usage: steps/train_deltas. ...

最新文章

  1. ArcGIS JavaScript API 实现基本的地图功能
  2. Python基础教程:list和tuple
  3. TabBarController创建及使用方法简介
  4. 静态代码块,构造代码块,局部代码块演示
  5. OpenShift 4 - 通过 secret 访问受保护的镜像
  6. luna16标签数据里的xyz,以及CT的dicom.ImagePositionPatient里的三个值分别代表哪些轴的初始点
  7. flask实现后台java实现前端页面_java实现telnet功能,待实现windows下远程多机自动化发布软件后台代码...
  8. StrangeIoC —— Unity MVC 专属框架
  9. 用vue实现echarts条形图官方实例
  10. SQL中日期转换函数
  11. Qt 简单的视频播放器
  12. lpx寒假作业案例1
  13. 几个Python小案例, 爱上Python编程!
  14. 率土之滨服务器维修,率土之滨征服赛季合服与转服功能详解
  15. phpmyadmin 4.8.1 Remote File Inclusion Vulnerability (CVE-2018-12613)漏洞复现
  16. 基于UMa和RMa传播模型的5G覆盖性能研究
  17. 头条面试居然跟我扯了半小时的Semaphore
  18. 淘宝IFashion风格馆日常如何运营?
  19. 生命早期肠道微生物群与儿童呼吸道疾病之间的关联
  20. 音视频技术开发周刊 | 295

热门文章

  1. 如何免费下载网站模板|资源分享
  2. 亚信电子最新工业以太网控制芯片解决方案介绍
  3. echarts横向柱状图单个柱子设置警戒线,超过警戒线为渐变蓝色,没超过为渐变红色
  4. ChatGPT写的语音播报程序
  5. 仿团购app连接mysql_美团App(仿) - iOS开发
  6. 基于JSP的珠宝商城的设计与实现
  7. log-pilot日志收集到es7.x
  8. 全面盘点:稳定数字加密货币的由来与现状 |区块链捕手
  9. 云转码开源源码(非授权版)
  10. 正则匹配json字符串中的key,将kebab-case转换为camelCase