原理理解
SLP第10.3 依赖于上下文的声学模型:三音子
协同发音:发音器官为了预期下一个发音动作或保持上一个发音动作而进行的一种运动。
和之前单音子的区别:
单因子建立一个HMM模型,相应于音子的开头、中间和结尾的次因子,我们使用了三个发射状态。
但事实类似“[eh]的开头”这种次因子的问题在于,一个音子在很大程度上会依赖于相邻的音子而发生变化。
所以,多数LVCSR系统使用依赖于上下文音子的HMM来代替独立于上下文音子的HMM
最常见的依赖于上下文的模型就是三音子的隐马尔可夫模型。
三音子的表示方法:[y-ch+1]
用y-表示前面为y,用+1的方法表示后面为1的音子[ch]
这种三音素模型也它的缺点:我们试图训练的模型越复杂,从训练中获得的对于每一个音子的观察数据就越少。举个例子,对于有50个音子的音子集来说,原则需要$ 50^3$
或125 000个音子。但是事实上并不是每一个三音子序列都是可能存在的。
解决的办法:
最普遍的方法是对于某些上下文进行聚类,把上下文落入同样聚类中的次音子捆绑起来。
就是一个聚类里面的音子捆绑起来,他们共享一个高斯模型。
聚类的方法:决策树
对于每一个音子的每一个状态(次音子)都分别建立一棵树
训练方法:
通过迭代的方式,决策树从根部开始自定向下逐渐生长。在每一轮迭代时,算法要考虑树中每一个可能的问题q和每一个可能的结点n。
对于每一个问题算法要考虑新的分离对于训练数据的声学似然度的影响。如果对于问题q而引起了被捆绑模型基础的分离,那么,算法还要计算出训练数据的当前的声学似然度与新的似然度之间的差别。算法选区给出最大似然度的结点n和问题q。这个过程不断的迭代,直到每一个叶子结点的实例数都达到最大的阈值。
为了训练依赖于上下文的模型,我们首先使用标准的嵌人式算法来训练独立于上下文的模型,多次使用EM,对于单调音子/aa/和/ae/中的每个次音子,最后各自形成不同的高斯模型。
然后,克隆每一个单调音子模型,也就是说,我们等同地复制有三个次状态的高斯模型,对于每一个潜在的三音子都进行一次克隆。转移矩阵A不克隆,但是它们要把一个单调音子的所有的三音子克隆捆绑在一起。然后,我们再次运行EM迭代,再次训练三音子高斯模型。现在,对于所有的单调音子,我们使用前面描述的聚类算法,把所有的依赖于上下文的三音子都进行了聚类,得到捆绑状态聚类的-一个集合。选出一个典型的状态作为这种聚类的例子,其他的状态与这个状态进行捆绑。
我们使用同样的克隆过程来训练高斯混合模型。首先使用具有多次EM迭代的嵌人式训练,对于上述的每一个捆绑的三音子状态,训练- . 个单独的高斯混合模型。然后,把每一个状态克隆(分离)到两个等同的高斯模型中,使用某个ε来干扰并调整每一个值, 再次运行EM,并对这些值再次进行训练。继续这个过程,直到我们对于每一一个状态中的观察,都得到-一个恰当的高斯混合模型为止。
我们顺次使用克隆和再训练这两个过程,得到了一个完全的依赖于上下文的GMM三音子模型
论文《Tree-Based State Tying For High Accuracy Acoustic Modelling》S.J.Young
主要比较了决策树与传统的数据驱动方法的效率。以及比较了绑点状态和绑定模型的方法之间的区别。
之后又主要讲解了如何去建立一棵决策树。
详细请参考我的博客,我对这篇论文进行了一个翻译总结。
传送门
(正文)1.如何累积相关统计量?
首先介绍与决策树相关的几个数据结构和统计量,然后介绍累积统计量的主程序和主要函数。
EventMap之EventType
了解EventMap相关的EventType这一数据结构,需要阅读官方文档《Decision tree internals》。
我们知道决策树用于对三音素GMM声学模型进行状态绑定,那么问题来了:“用什么数据结构表示三音素呢?”
一种方法是用一对数表示三音素的位置和该位置上的音素,也就是(三音素位置,该位置上的音素),用C++表示就是pair<int, int>,第一个int取0,1,2,分别代表三音素的三个位置,第二个int取音素的编号,把这三对数放在一起就可以描述三音素了,用C++表示就是由pair<int, int>组成的vector。
此外,除了知道三音素三个位置上的音素各是什么,我们还想知道一个HMM状态是三音素的第几个HMM状态,前面使用(位置,音素)对来表示三音素,能不能也用一对数表示HMM状态信息?当然可以,此时只要把位置置为-1即可,也就是用pair<int, int>表示HMM状态信息,第一个int取-1,表示这一对数表示的是HMM状态信息;第二个int取HMM状态编号,表示这是该三音素的第几个HMM状态(对于三状态HMM,第二个int取0,1,2)。
表示三音素的三对数和表示HMM状态的一对数放在一起,这四对数就可以描述三音素及HMM状态。在kaldi中用EventType表示这一数据结构:
typedef std::vector<std::pair<EventKeyType,EventValueType> > EventType;
这里的EventKeyType和EventValueType都是int32的别名,这样命名的话代码更容易被理解。
举个例子,假设我们当前的三音素是a/b/c;假设音素a,b,c的编号分别是10,11,12;假设我们用的是标准的3状态HMM拓扑结构;那么该三音素的第二个HMM状态可表示为:
EventType e = { (-1, 1), (0, 10), (1, 11), (2,12) };
这里的-1我们通常用常量kPdfClass=-1来表示。上面这行可能不是合法的C++代码,只是为了说明问题。
EventMap是从EventType到EventAnswerType的映射,您可以将其大致看作是从上下文中的音子到整数索引的映射。
EventAnswerType ans;
bool ret = emap.Map(e, &ans); // emap is of type EventMap; e is EventType
KALDI_ASSERT(ret == true && ans == 1000);
实现EventMap接口的具体类有三个:
ConstantEventMap: 把它看作一个决策树叶节点。此类存储EventAnswerType类型的整数,其映射函数始终返回该值。
SplitEventMap: 将其视为一个决策树非叶节点,它查询某个键并根据答案转到“是”或“否”子节点。它的map函数调用相应子节点的map函数。它存储了一组kanswertype类型的整数,这些整数对应于“yes”子项(其他都指向“no”)。
TableEventMap: 这将对特定键执行完全拆分。一个典型的例子是:您可能首先在中间的音素上完全拆分,然后对该音子的每个值都有一个单独的决策树。它在内部存储eventmap*指针的向量。它查找与正在拆分的键对应的值,并在向量的相应位置调用eventmap的map函数。
Clusterable和GuassClusterable
这一块建议阅读官方文档《Clustering mechanisms in Kaldi》《GaussClusterable Class Reference》。(聚类机制)
Clusterable是一个纯虚类,作为kaldi聚类机制的统一接口。在三音素决策树状态绑定这一块,我们主要用到的是继承自该基类的GuassClusterable。
Clusteralbe对象的主要作用是把统计量累加在一起,和计算目标函数。下面对这句话进行说明。
在forced alignment之后,从左到右扫描对齐数据,我们能从中得到(三音素及HMM状态)和其对应的特征向量,也就是得到一个EventType和其对应的特征向量。在扫描过所有训练数据后,出现的每个EventType会对应多个特征向量。
目标函数,就是论文中所提到的状态集S的似然L(S),根据L(S)的计算公式,我们需要知道状态集S产生的所有观测(也就是特征向量集)的协方差,对角协方差的对角线上是特征向量集每一维的方差,要想知道每一维的方差就需要知道特征向量集的和以及特征向量集的平方和
(
D
(
X
)
=
E
(
X
2
)
−
(
E
X
)
2
)
(D(X)=E(X^2)-(EX)^2)
(D(X)=E(X2)−(EX)2);计算L(S)除了要知道协方差,还需要知道状态集S产生的特征向量的个数,也就是状态集S出现的次数,因为kaldi中使用的是Viterbi训练,得到对齐后,我们就不需要计算posterior概率,可以用状态集S对应的特征向量的个数代替posterior概率。
(时刻记得一个EventType表示一个三音素和hmm状态id)
于是,我们就可以发现,与一个EventType相关的统计量包括该EventType对应的特征向量的个数、这些特征向量的累加、这些特征向量的平方的累加。这三个值,就是GuassClusterable中需要保存的统计量,并且根据这三个统计量可以计算该EventType的似然。如果把多个EventType的统计量累加在一起,就可以计算这些EventType组成的状态集的似然,因为一个EventType实际就是一个状态state。
在扫描对齐数据累积统计量时,一个EventType对应一个Clusterable对象(确切来说是GaussClusterable对象)。在这个GaussCluterable对象中,成员count_保存着该EventType出现的次数,成员stats_矩阵的第一行保存着该EventType对应的所有特征向量的和,stats_矩阵的第二行保存着该EventType对应的所有特征向量的平方之和。
BuildTreeStatsType
在构建决策树时,我们需要知道的所有信息就是从训练数据的对齐中得到的所有EventType(三音素+HMM状态id),和每个EventType对应的Clusterable对象(EventType对应的特征向量的个数、这些特征向量的累加、这些特征向量的平方的累加)。
很自然的,我们可以把这两者的对应关系保存成一个对pair<EventType, Clusterable*>,然后把所有的这些对保存成一个vector,所以构建决策树所用到的统计量可以表示成:
typedef std::vector<std::pair<EventType, Clusterable*> > BuildTreeStatsType;
acc-tree-stats
- 作用:Accumulate statistics for phonetic-context tree building. 该程序为决策树的构建累积相关的统计量。
程序读取一个特征存档和相应的对齐,并为决策树的创建生成足够的统计信息。上下文宽度和中心音素的位置用于识别上下文,转换模型作为识别pdf(密度函数)和phone(音子)的输入。 - 输入:声学模型、特征、对齐
- 输出:统计量
- 示例:
acc-tree-stats $context_opts \
--ci-phones=$ciphonelist $alidir/final.mdl "$feats" \
"ark:gunzip -c $alidir/ali.JOB.gz|" $dir/JOB.treeacc
- 过程:
输入的声学模型一般为单音素训练得到的GMM模型。
1.打开声学模型并从中读取TransitionModel,打开特征文件、打开对齐文件
2.对每一句话的特征和对应的对齐,调用程序AccumulateTreeStats()累积统计量tree_stats
3.将tree_stats转移到BuildTreeStatsType类型的变量stats中,将stats写到文件JOB.treeacc。
AccumulateTreeStats()
void AccumulateTreeStats(const TransitionModel &trans_model,
const AccumulateTreeStatsInfo &info,
const std::vector<int32> &alignment,
const Matrix<BaseFloat> &features,
std::map<EventType, GaussClusterable*> *stats)
主要功能:这个函数拿到一句话的特征序列(39维MFCC特征)和对齐序列后,从对齐序列(transition-id序列)能够得到对应的音素序列(SplitToPhones())。
如下图所示,第一行为特征序列,,第二行为对齐序列,第三行为音素序列。
然后从左到右扫描该音素序列,对每一个三音素,又由transition-id得到这个三音素的HMM状态id,这样就得到了一个个EventType。对齐序列中的一个transition-id对应一个EventType,这个transition-id对应的特征向量就是属于该EventType的数据,用该Event Type对应的GaussClusterable对象累积相关的统计量。
对所有的特征数据、对齐数据执行这个函数后,我们就从训练数据中得到了所有的EventType(注意并不是所有的三音素都会在训练数据中出现)和该Event Type对应的统计量。这些统计量可以被用于自动产生问题集、构建决策树。
acc-tree-stats.cc文件的过程:
2.如何自动生成问题集?
Kaldi决策树中使用的问题集并不是手工设计的,而是通过之前得到的统计量自动生成的。
cluster-phones
- 作用:Cluster phones (or sets of phones) into sets for various purpose. 对多个音素或多个音素集进行聚类。
- 输入:决策树相关统计量treeacc,多个音素集sets.int
- 输出:自动生成的问题集(每个问题由多个音素组成)
- 示例:
cluster-phones $context_opts $dir/treeacc $lang/phones/sets.int \
$dir/questions.int
- 过程:
- 从treeacc中读取统计量到BuildTreeStatsType stats;读取vector pdf_class_list,该变量指定所考虑的HMM状态,默认为1,也就是只考虑三状态HMM的中间状态;从sets.int读取vector > phone_sets;默认的三音素参数N=3,P=1。
- 若指定的mode为questions,调用AutomaticallyObtainQuestions()自动生成问题集vector > phone_sets_out;若指定的model为k-means,调用KMeansClusterPhones()。此笔记只涉及questions模式。
- 将上述函数自动生成的phone_sets_out写到questions.int。
- 文件说明:
下面以我们实验室所用的sets.int和sets.txt为例,来对sets.int文件有一个直观的感受:(左边是sets.txt,右边是sets.int,两图第一列均为行号)
AutomaticallyObtainQuestions()
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
)
- 主要功能:AutomaticallyObtainQuestions()通过对音素自动进行聚类,从而获取问题集;它把音素聚类成一棵树,并且对树中的每一个结点,把从该结点可以到达的所有叶子结点合在一起构成一个问题(该树的一个叶子结点保存着一些音素,一个问题就是一个音素的集合)。(官方文档《Decision tree internals》的Classes and functions involved in tree-building中的Top-level tree-building functions部分如是说)。
代码分析
- 读取sets.int中的所有音素,保存在phones中。phone_sets_in由sets.int得到。
std::vector<std::vector<int32> > phone_sets(phone_sets_in);
std::vector<int32> phones;
for (size_t i = 0; i < phone_sets.size() ;i++) {
std::sort(phone_sets[i].begin(), phone_sets[i].end());
for (size_t j = 0; j < phone_sets[i].size(); j++)
phones.push_back(phone_sets[i][j]);
}
std::sort(phones.begin(), phones.end());
也就是将sets.int中的所有代表各个音素的整数放入phones中,并从小到大排序。
- 调用FilterStatsByKey()把stats中只属于三音素第二个HMM状态的统计量留下。通过累积统计量部分我们知道,三音素的三个HMM状态可能都会有对应的统计量,但是这里只把与第二个HMM状态相关的统计量留下进行聚类,其他的都暂时扔掉不用。为什么是第二个?这是由向量all_pdf_class指定的,all_pdf_class就是程序cluster-phones中的参数pdf_class_list,该向量指定所考虑的HMM状态,默认为1,也就是只考虑三状态HMM的中间状态。至于为什么是第二个,暂时我也不是很清楚。kPdfClass=-1,指明过滤统计量的Key,也就是根据EventType的HMM状态进行过滤,只留下all_pdf_classes指定的HMM状态对应的stats。
BuildTreeStatsType retained_stats;
FilterStatsByKey(stats, kPdfClass, all_pdf_classes,
true, // retain only the listed positions
&retained_stats);
- 调用SplitStatsByKey(),根据三音素的中间音素对retained_stats进行划分,把属于每个音素的统计量放在一个BuildTreeStatsType中。由参数P指定根据三音素的第几个音素进行划分,因为此处P是1,所以是三音素的中间音素。举个例子,我们实验室的所用的音素一共有215个,假设每个音素都出现在三音素的中间位置,对retained_stats进行划分之后,split_stats的元素个数是215,每一个元素保存着(中间音素都是x的所有三音素对应的所有统计量)。
举个例子:就是根据中间的音素,中间音素相同的三音素分为一类。
第一类:
c+a-b;d+a-f;f+a-h;....
第二类:
a+b-a;s+b-d;d+b-t;s+b+r......
std::vector<BuildTreeStatsType> split_stats; // split by phone.
SplitStatsByKey(retained_stats, P, &split_stats);
- 调用SumStatsVec()把split_stats每个元素中的所有统计量加起来,得到每个中间音素的统计量,也就是summed_stats,其维数为音素个数。
从上一步我们知道,split_stats的每一个元素保存着中间音素都是x的所有三音素对应的所有统计量,因为音素x左右音素的不同,所以split_stats这个元素中保存的统计量有很多,现在把中间音素都是x的所有三音素对应的所有统计量累加起来(就是把这些GaussClusterable的count_相加、stats_相加);对split_stats的每个元素都执行这样的操作后,就得到了summed_stats。
举个例子,我们实验室的所用的音素一共有215个,最终的summed_stats就只有215个元素,每个元素保存着某音素作为三音素中间音素、其HMM状态为第二个状态对应的所有统计量的累积。(这里注意一下这个条件,我们知道EventType的结构是这样的((-1,1),(0,10),(1,11),(2,12)),我们控制的两个条件就是-1之后的那个值为1,也就是其HMM状态为第二个状态,还有就是1之后那个就是代表三音素的中间音素)
std::vector<Clusterable*> summed_stats; // summed up by phone.
SumStatsVec(split_stats, &summed_stats);
- 根据sets.int指定的集合,累加同一个集合中音素的统计量。从上面sets.int文件的图片可以看出,该文件的一行就是一个音素的集合,这块代码的作用就是把属于sets.int文件同一行的音素的统计量累加在一起,所以最后summed_stats的维数就是sets.int的行数,一行对应一个统计量。
就比如说第三行,有3,4,5,6,7,那么我们将中间音素为3,4,5,6,7的统计量累加在一起。
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]]));
}
- 调用TreeCluster(),对summed_stats_per_set进行聚类,生成相关信息。TreeCluster()是AutomaticallyObtainQuestions()最核心的部分,该函数的具体解释独立出来放在后面,建议先阅读此部分的详细解释。
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);
-
调用ObtainSetsOfPhones(),由上一步得到的信息,生成问题集。先解释下该函数的几个参数:
- phone_sets:由sets.int生成,每个元素代表sets.int中一行上的音素集
- assignments:phone_sets中每个元素所属的cluster。上一步生成了树,每个phone_sets的元素(一个点)都属于该树的一个叶子结点(或者称为一个cluster)
- clust_assignments:上一步生成的树的每个结点的父结点
- num_leaves:上一步生成的树的叶子个数
- question_out:生成的问题集
函数内容:
a) 得到每个cluster(叶子结点)中的音素集;
b) 将子结点的音素集加入到其父结点的音素集中(实现了“把从该结点可以到达的所有叶子结点合在一起构成一个问题”);
c) 把原始的phone_set插入到问题集;
d) 过滤问题集的重复项、空项,生成最终的问题集。
个人理解:感觉是先通过聚类算法分成若干个类,然后依次将最相邻的两个类合并在一起,看做是这两个类的父类,然后这两个的音素都放入到父节点中,形成一个问题集,不断向上迭代,是一个自底向上的一个过程。如下图所示。
// process the information obtained by TreeCluster into the
// form we want at output.
ObtainSetsOfPhones(phone_sets,
assignments,
clust_assignments,
num_leaves,
questions_out);
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)
在AutomaticallyObtainQuestions()中被调用时获得的参数:
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(points, max_clust, cfg);
BaseFloat ans = tc.Cluster(clusters_out, assignments_out, clust_assignments_out, num_leaves_out);
这里很重要的点是面对对象的思想。设计一个对象,完成具体的工作。把函数的实现变简单,把工作的细节都放在对象的实现中。
该函数首先初始化一个TreeClusterer对象,把统计量points传给该对象;然后调用该对象的Cluster方法获取关于聚类结果的相关信息。ObtainSetsOfPhones()根据这些信息就可以生成问题集。下面就分三部分来解释TreeCluster()和TreeClusterer对象:
1-TreeClusterer对象和Node数据结构
TreeClusterer是使用自顶向下的树进行聚类的一个对象。有树的地方就有结点Node,我们先来看下Node数据结构中保存了什么信息。
- Node保存着指向其双亲结点和孩子结点的指针。注意到children是一个Node指针的vector,vector的大小由TreeClusterOptions中的branch_factor参数指定,这个值默认为2,所以我们这里使用的树是二叉树,每个结点最多只有两个孩子结点。
- 保存着属于该结点的所有统计量之和node_total(统计量就是该结点中的音素对应的所有特征向量的出现次数count_、特征向量之和stats_(0)和特征向量的平方和stats_(1),统计量用来计算该结点的似然L(s))。
- 还保存着该结点是否是叶子结点(之后会对似然度增长最多的叶子结点进行分裂,然后原来的叶子就不在是是叶子结点),以及是叶子结点时在leaf_nodes_中的索引和不是叶子结点时在nonleaf_nodes_中的索引。
- 如果是叶子结点,保存着属于该叶子的那些点的统计量points,以及该叶子上拥有的那些点在所有点组成的vector中的索引(也就是在TreeClusterer对象points_成员中的索引)。用best_split保存着对该叶子结点进行最优划分时,获得的最大的似然提升。对该叶子结点划分意味着生成两个新的簇(或者说两个新的孩子结点,也就是按照问题集将这个结点的音素进行划分),assignments中就保存着对该叶子结点进行最优划分后,该叶子结点中的点分别被划分到哪个簇(或者说分别被划分到哪个孩子结点),其元素值一般为0、1。
struct Node {
bool is_leaf;
int32 index; // index into leaf_nodes or nonleaf_nodes as applicable.
Node *parent;
Clusterable *node_total; // sum of all data with this node.
struct {
std::vector<Clusterable*> points;
std::vector<int32> point_indices;
BaseFloat best_split;
std::vector<Clusterable*> clusters; // [branch_factor]... if we do split.
std::vector<int32> assignments; // assignments of points to clusters.
} leaf;
std::vector<Node*> children; // vector of size branch_factor. if non-leaf.
// pointers not owned here but in vectors leaf_nodes_, nonleaf_nodes_.
};
下面看一下TreeClusterer的数据成员都有哪些。
- TreeClusterer中构造的树的结点分为两类:叶子结点和非叶子结点。叶子结点放在leaf_nodes_中,非叶子结点放在nonleaf_nodes_中,每个结点Node的数据结构中保存着该Node是否为叶子结点以及在这两个向量中的索引。
- points_中保存着初始化TreeClusterer对象时传递进来的每个点的统计量,该对象的聚类过程,就是为了把这些点分成一簇簇(cluster)。
- queue_是一个优先队列,队列中的每个元素是一个对,这个对的第二个数据保存着结点信息,这个对的第一个数据是对该结点进行划分时所获得的似然的最大提升。使用优先队列则说明,对似然提升最大的结点优先进行划分。(至于为什么要根据这种策略做,以及似然这些名词对应的公式,请参考论文《Tree-Based State Tying For High Accuracy Acoustic Modelling》S.J.Young的第三部分Tree-BasedClustering)
std::vector<Node*> leaf_nodes_;
std::vector<Node*> nonleaf_nodes_;
const std::vector<Clusterable*> &points_;
int32 max_clust_;
BaseFloat ans_; // objf improvement.
std::priority_queue<std::pair<BaseFloat, Node*> > queue_; // contains leaves.
TreeClusterOptions cfg_;
2-TreeClusterer对象初始化
这里我们使用下面这个构造函数对TreeClusterer对象进行初始化。首先初始化TreeClusterer对象的一些数据成员,然后调用Init()完成剩余的初始化工作。
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()生成树的根结点top_node,根结点包含传递给该对象的所有点points_,初始化根top_node的信息,包括is_leaf=true、index=0、parent=NULL、node_total和leaf中的成员,并把该根top_node放进leaf_nodes_中。(末尾带下划线的是TreeClusterer的数据成员,若末尾不带下划线,则一般为Node的数据成员,可以使用这种命名规范进行区分,防止混乱)
每当创建新结点的时候(一般为叶子结点),应该总是调用FindBestSplit(Node* node)函数。该函数的作用是找到对参数node的最优划分,即采取该划分时,获得的似然提升最大;并把最优划分时获得的似然提升记录在该node的leaf.best_split中。若该best_split超过cfg_指定的似然阈值thresh,则把对(best_split, node)放进优先队列queue_中。该函数调用ClusterKMeans()找到对属于该node的点的最优的划分和对应的似然提升,ClusterKMeans()的细节我们在后面再提及,这里可以先略过。
因为top_node是新建的且唯一的叶子结点,所以在Init()的末尾,调用FindBestSplit(top_node),找到对top_node的最优划分,将属于top_node的点划分成两簇,每一簇对应一个孩子结点,同时将该划分的最大似然提升记录在top_node->leaf.best_split中,并把对pairleaf.best_split, top_node>放进优先队列queue_中(此时queue_只包含这一个对)。
3-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对象的初始化工作之后,对传递给该对象的所有点,该成员方法根据统计量构造树,实现对点的聚类,将点分成一簇簇,该树的一个叶子结点代表一个簇,每一簇都包含几个点。
回到AutomaticallyObtainQuestions()对TreeCluster()的调用,这些点就是sets.int中的音素集的集合(为什么这么说,因为我们之前已经把sets.int中的每一行的音素的统计量都已经求和了,现在这个求和之后的这个点可以代表这个音素集的集合),一个点就是sets.int中的一行,也就是说一个点就是一个音素集。以我们实验室的sets.int为例,sets.int一共有63行,所以就有63个点。用树对这63个点进行聚类后,树的每个叶子结点都有几个点,其中一个点是一个音素集。
回忆之前官方文档所说的:“AutomaticallyObtainQuestions()把音素聚类成一棵树,并且对树中的每一个结点,把从该结点可以到达的所有叶子结点合在一起构成一个问题”。对于构建好的树,从每个结点都可以访问到一个(该结点就是叶子结点)或多个叶子结点,把这些叶子结点中的点合在一起,也就是把多个音素集合合在一起,形成一个更大的音素的集合,这个更大的音素集合就构成了一个问题(什么是问题?简单点来看就是一个音素的集合,然后将需要判断的音素与这个音素集合进行比较,判断他是否在这个音素集合中)。
下面看一下该成员方法的具体代码:
- 构建树。queue_是一个优先队列,每个元素是pair<BaseFloat, Node*>,该pair中, 第一个数据是对第二个数据Node进行最优化分所得的最大似然提升。对当前树的每个叶子结点都可以进行划分(注意刚开始时该树只有一个top_node),每个叶子结点都有一个最优划分,也就能得到对该叶子结点进行最优划分后的最大似然提升。queue_的队首就是对当前所有叶子结点划分后似然提升最大的一个叶子结点,调用TreeClusterer.DoSplit(Node *node)对该叶子结点进行划分(所有叶子中似然提升最大的叶子结点,和每个叶子结点的最优划分得到最大似然提升,这两个最大不一样,可以看做是局部最优和全局最优的区别,我们的做法可以看做一个全局最优,每次选择当前队列之中最大似然提升最大的叶子结点,虽然可能不是全局最优的,这样做效果差别不会太大,但是速度会快上很多),重复对队首的划分,直到queue_为空。
DoSplit(Node *node)对node执行具体的划分,生成两个新的孩子结点,把node的index给第一个孩子,并把leaf_nodes_该index对应的Node替换成第一个孩子,为第二个孩子生成新的index并加入leaf_nodes_,根据划分时计算的assignments,把node的点划分到两个子孩子中;把node加入到nonleaf_nodes_;最后对两个孩子结点(两个新的叶子结点)分别调用FindBestSplit(),得到两个孩子结点的最优划分,更新queue_。
我个人感觉这是一种用广搜思想建树的过程, 每次将叶子结点和每个叶子结点对最大似然的提升度放入优先队列中,然后选择最优的将其取出来,对齐分裂,然后将它的两个孩子再放进去。
while (static_cast<int32>(leaf_nodes_.size()) < max_clust_ && !queue_.empty()) {
std::pair<BaseFloat, Node*> pr = queue_.top();
queue_.pop();
ans_ += pr.first;
DoSplit(pr.second);
}
- 生成ObtainSetsOfPhones()要用到的信息。由上一步完成了对点的划分,构建好了树。
a) 调用CreateAssignmentsOutput(assignments_out),计算传递给TreeClusterer的每个点分别属于哪个叶子结点。在对树完成划分后,每个叶子结点都包含几个点,把这些点属于的叶子结点编号写到assignments_out中。
b) 调用CreateClustAssignmentsOutput(clust_assignments_out),计算树的每个结点的双亲结点的编号。因为对叶子结点和非叶子结点分开编号,所以为了避免重复,对非叶子结点进行重新编号,接着叶子结点的号进行编号。
c) 调用CreateClustersOutput(clusters_out),因为TreeCluster()不使用这个输出,所以略过。
最好能够自己举一个具体的例子来理解生成输出信息的过程,后面我会附加自己学习笔记的草稿图片来给出一个例子。请参考下面图中,我在左下角画了一个划分的例子,并且把生成输出的过程大致也画在这个图上了,希望其能有一些借鉴意义。ObtainSetsOfPhones()就是要用a)、b)生成的信息来得到问题集。
CreateOutput(clusters_out, assignments_out, clust_assignments_out,
num_leaves_out)
ClusterKMeans()
在TreeClusterer.FindBestSplit(Node *node)中,实际调用ClusterKMeans()找到对node的最优划分。关于这个函数这里我就不具体写了,只把自己手写的笔记放在这里,希望能讲明白这一块做了什么。请参考下图。
个人理解这是一个K-Means K均值聚类算法。
可以参考我之前的博客:传送门
compile-questions
右上角的那个框里面的就是指问题集,每一行代表了一个问题,也判断某个元素是否在这个问题集合之中。
问题不止是针对三音素的三个位置的,同时也会有针对状态的问题。(这边后面的博客会详细解释,kaldi是以每个状态(0,1,2)都创建一棵决策树)
参考
相关的文献和论文,以及开拓师兄的博客。
https://blog.csdn.net/u010731824/article/details/69668647
https://blog.csdn.net/u010731824/article/details/69667017