Kaldi决策树状态绑定学习笔记(四)

建议在csdn资源页中免费下载该学习笔记的PDF版进行阅读:)点击进入下载页面

Kaldi决策树状态绑定学习笔记(四)

——如何构建决策树?

  到现在为止,程序acc-tree-stats累积好了构建决策树所需的统计量,程序cluster-phones和compile-questions自动生成好了构建决策树所需的问题集,我们也准备好了roots.int文件,那么我们就可以开始构建决策树,对三音素GMM的状态进行绑定。这次笔记的主要内容是讲解Kaldi如何构建决策树,实现对三音素GMM状态的绑定。
  在这个笔记中,首先我会介绍构建决策树的主程序build-tree和主函数BuildTree(),然后介绍主函数中用到的核心函数GetStubMap()和SplitDecisionTree()。

  建议学习Kaldi官方文档《Decision tree internals》的Classes and functions involved in tree-building部分,官方文档《How decision trees are used in Kaldi》和论文《Tree-Based State Tying For High Accuracy Acoustic Modelling》S.J.Young的第三部分Tree-BasedClustering。

  目录

build-tree

  • 作用:构建决策树
  • 输入:累积的统计量treeacc、问题集questions.qst、roots.int、HMM拓扑topo
  • 输出:决策树tree
  • 示例:
build-tree $context_opts --verbose=1 --max-leaves=$numleaves \
    --cluster-thresh=$cluster_thresh $dir/treeacc $lang/phones/roots.int \
    $dir/questions.qst $lang/topo $dir/tree
  • 过程:
    1. 读取roots.int,得到1)vector<vector<int>> phone_sets,其一个元素包含roots.int的一行上的所有音素;2)vector<bool> is_shared_root,其一个元素指明该行的音素是否共享三个HMM状态的决策树树根;3)vector<bool> is_split_root,其一个元素指明是否对该行音素对应的决策树树根进行划分。
    2. 读取topo文件,得到保存HMM拓扑结构的对象HmmTopology topo,一般音素的HMM状态为三个,我们实验室只有SIL和SPH的HMM状态为五个。
    3. 读取treeacc,得到累积的统计量BuildTreeStatsType stats。
    4. 读取questions.qst,得到Questions qo。
    5. 调用topo.GetPhoneToNumPdfClasses(&phone2num_pdf_classes)得到vector<int> phone2num_pdf_classes,其元素保存每个音素对应的HMM状态数(前面说了,除了音素SIL和SPH的HMM状态数为5,其他均为3)。
    6. 调用BuildTree(),返回保存整个大决策树的EventMap *to_pdf。
    7. 用to_pdf初始化对象ContextDependency ctx_dep(N, P, to_pdf),并将ctx_dep写到文件tree。Kaldi用类ContextDependency对决策树进行封装,其数据成员包含音素宽度N_(=3)、中间音素位置P_(=1)、决策树to_pdf_。

BuildTree()

EventMap *BuildTree(Questions &qopts,
                    const std::vector<std::vector<int32> > &phone_sets,
                    const std::vector<int32> &phone2num_pdf_classes,
                    const std::vector<bool> &share_roots,
                    const std::vector<bool> &do_split,
                    const BuildTreeStatsType &stats,
                    BaseFloat thresh,
                    int32 max_leaves,
                    BaseFloat cluster_thresh,  // typically == thresh.  If negative, use smallest split.
                    int32 P)

建议阅读Kaldi官方文档《Decision tree internals》的 Classes and functions involved in tree-building部分。在build-tree中调用BuildTree()时传递的参数:

    to_pdf = BuildTree(qo,
                       phone_sets,
                       phone2num_pdf_classes,
                       is_shared_root,
                       is_split_root,
                       stats,
                       thresh,
                       max_leaves,
                       cluster_thresh,
                       P);

分块解析:
1. 调用GetStubMap()得到初始的决策树EventMap *tree_stub,也就是扩展前的决策树。扩展前的决策树的一个叶子结点对应roots.int中的一行的决策树的树根。后面给出该函数的进一步解释,其中附带GetStubMap()生成的决策树D的图片。建议先跳到后面看懂此函数。
2. 调用FliterStatsByKey(),只把在roots.int中指定为split的音素的统计量留下,把指定为not-split的音素的统计量丢弃。因为我们实验室都是split,所以这一步什么都没做。
3. 调用SplitDecisionTree(),对tree_stub进行扩展,生成整个大的决策树EventMap *tree_split:把tree_stub的每个叶子结点扩展成决策树,对每一个音素生成实际的决策树。后面给出该函数的进一步解释,其中附带相关图片,尽力使该函数的代码变得直观。建议先跳到后面看懂此函数。
4. 调用ClusterEventMapRestrictedByMap(),对tree_split每个音素的决策树上的某些叶子结点进行合并(请参考一开始提到的论文),得到EventMap *tree_clustered。合并前的CE均有不同的answer_,也就是pdf-id不同,合并后某些CE的answer_相同,相同则说明这些叶子结点合并了。这块代码我并没有像之前那样看得很深,只是明白了其作用是什么。有兴趣的读者可以参考我在最后列出的手写笔记草稿的相关部分。
5. 调用RenumberEventMap()对tree_clustered的叶子结点重新编号(从0开始),得到EventMap *tree_renumbered,这就是我们最终得到的完整的决策树。把tree_renumbered返回到主程序build-tree。

GetStubMap()

EventMap *GetStubMap(int32 P,
                     const std::vector<std::vector<int32> > &phone_sets,
                     const std::vector<int32> &phone2num_pdf_classes,
                     const std::vector<bool> &share_roots,
                     int32 *num_leaves_out)

根据从论文中学习到的决策树状态绑定的理论,我们需要对每个音素的每个HMM状态构建一个决策树,假如有63个音素,每个音素有3个HMM状态的话,我们总共应该构建63x3=189个决策树;但是Kaldi中允许不同HMM状态共享同一个决策树根,并且允许在该决策树上的非叶子结点对HMM状态问问题,假如有63个音素,并且每个音素的HMM状态都共享决策树根的话,则我们只需要构建63个决策树。这63个决策树该怎么保存呢?我们可以一个一个写到文件中。但是Kaldi决定把这63个决策树也用一棵决策树D表示——D有63个叶子结点,每个叶子结点都作为63个决策树的树根。
  GetStubMap()的作用就是构建决策树D,使其每个叶子结点都对应roots.int中的一行的决策树的树根,换句话说,我们本应为roots.int中的一行构建一个决策树d,现在用决策树D的叶子结点保存决策树d的树根。决策树D的作用只是把63个不同的决策树放在一起,随后的代码对决策树D的每个叶子结点进行扩展,在D的叶子结点上构建属于每个音素的实际的决策树。
  有了上面的理解,按照代码注释里的说法,我们对该函数的作用再重复一遍:GetStubMap()对每一个音素集创建一个初始的叶子结点,一个音素集就是roots.int中的一行中的音素的集合。
  上面介绍了GetStubMap()的作用,现在对GetStubMap()的代码进行分析,并给出该函数实际得到的决策树的图片,希望对决策树能有一个直观的认识。在阅读该函数的代码时,内心时刻谨记递归调用。
  以下把ConstantEventMap简称为CE,TableEventMap简称为TE,SplitEventMap简称为SE。
  该函数的核心代码分块三块,最外层if分支里的代码算一块,最外层else if分支里的代码算一块,最外层else分支里的代码算一块,其作用分别是创建叶子结点CE、创建非叶子结点TE、创建非叶子结点SE。
  phone_sets的一个元素是roots.int的一行上的全部音素,以我们实验室为例,roots.int有63行,则递归调用的最外层的GetStubMap()的phone_sets有63个元素。
  我们先来看最外层的else分支的代码。因为一开始phone_sets.size()不为1而是63,并且max_set_size不为1(因为roots.int中有的行的音素数大于1),所以一开始我们进入的是else分支。一开始在else分支中,我们把phone_sets等分成前后两份,前一半phone_sets_1包含roots.int的前31行,后一半phone_sets_2包含roots.int的后32行;然后对这两部分递归调用GetStubMap()得到两个子树map1和map2;递归调用返回之后,我们创建一个非叶子结点SE,令其key_是1(也就是三音素的中间音素,记得我们要为每一个中间音素构建一棵决策树),其yes_set_是phones_sets的前一半phone_sets_1所包含的全部音素,其yes_孩子结点指向由phone_sets_1生成的子树,其no_孩子结点指向由phone_sets_2生成的子树。我们用SE把phone_sets中的音素分为了两份。
  扩展上面的过程,我们对phone_sets_1递归调用GetStubMap()会发生什么?我们依旧会进入else分支,对phone_sets_1进行二等分,然后创建一个新的SE,其key_为1,其yes_set_是phone_sets_1的前一半(也就是phone_sets的前1/4),其yes_孩子指向由phone_sets_1的前一半生成的子树,其no_孩子结点指向由phone_sets_1的后一半生成的子树。对phone_sets_2递归调用GetStubMap(),对phone_sets_1的前一半、后一半递归调用GetStubMap()……
  随着递归调用的深度越深,我们不断生成新的SE对phone_sets进行划分,直到GetStubMap()的参数phone_sets只包含一个元素(也就是只包含roots.int中的一行),这时,我们进入最外层的if分支。(在这里我们假设roots.int中的每一行都指定shared,所以我们只考虑最外层if分支里的if分支,而不考虑最外层if分支里的else分支。)进入最外层的if分支所干的事情就是创建一个叶子结点CE,并设该叶子结点的answer_为num_leaves_out,也就是设该叶子结点的pdf-id为num_leaves_out。假设此时phone_sets只包含的一个元素是roots.int的第一行,我们生成第一个CE,此时num_leaves_out为0,则该CE的answer_为0。
  那么TableEventMap有什么作用呢?从上面的过程我们发现,每次划分我们只能创建两个孩子结点,包含63个元素的phone_sets要划分5次才能到达第一个叶子结点,太慢了。我们的目的是创建63个叶子结点,每个叶子结点保存roots.int中的一行,随后对这63个叶子结点进行扩展,构建真正的决策树。那么当某一次递归调用GetStubMap()时我们发现phone_sets的size()不为一但是其每一个元素只包含一个音素,这时我们进入最外层的else if分支,生成一个TE,令其key_为1。假设此时的phone_sets中保存的是roots.int的第41行到第46行,如下图所示,则生成的TE的table_包含150个元素,第0到144个都为NULL,第145到150个都是CE,我们一下子生成了6个叶子结点,比用SE划分快多了。
这里写图片描述
  调用GetStubMap()生成的最终的决策树D大概如下图所示,这样我们就为roots.int中的63行的每一行生成了一个叶子结点,随后把这63个叶子结点扩展成各自的决策树,我们就把63个决策树放在一个大的决策树中。
这里写图片描述

SplitDecisionTree()

EventMap *SplitDecisionTree(const EventMap &input_map,
                            const BuildTreeStatsType &stats,
                            Questions &q_opts,
                            BaseFloat thresh,
                            int32 max_leaves,  // max_leaves<=0 -> no maximum.
                            int32 *num_leaves,
                            BaseFloat *obj_impr_out,
                            BaseFloat *smallest_split_change_out)

参数input_map就是扩展前的决策树tree_stub,其一个叶子结点表示一个音素(确切来说是roots.int中同一行的音素集合,但因为其共享同一个决策树,我们暂且称该音素集为一个音素,便于理解、陈述)。我们现在要对tree_stub的每个叶子结点、也就是每个音素构建一棵决策树,实现状态绑定,减少GMM参数。我们对决策树持续进行划分,直到总的叶子结点数目到达max_leaves,或者所有叶子结点中的最优划分带来的最大似然提升都小于thresh。

  • 首先调用SplitStatsByMap(),根据tree_stub对stats进行划分得到split_stats,将对应到同一个叶子结点的统计量放在一起。以我们实验室为例,roots.int包含63行,tree_stub包含63个叶子结点,则对stats划分后,split_stats包含63个元素。对tree_stub的每一个叶子结点、也就是每一个音素初始化一个DecisionTreeSplitter对象,使用这个对象构建对应音素的决策树。我们注意到初始化DecisionTreeSplitter对象时传递的参数包括属于该音素的统计量、问题集。有这两者我们就可以构建起决策树。最终的builders包含63个DecisionTreeSplitter且一直包含63个DecisionTreeSplitter。。
      以下将DecisionTreeSplitter简称为DTS。
      在初始化每一个DTS时,都会找到这个DTS对应的叶子结点的最优化分(DTS.FindBestSplit()):首先对于EvenType的每一个key(-1,0,1,2),在该key对应的问题集中(由q_opts给出每个key的问题集)找到一个问题,使得对叶子结点划分后获得的似然提升最大;现在对每个key都找到了一个似然提升,然后比较这几个key的似然提升,找出其中的最大似然提升,将DTS的key_设置为似然提升最大的key,将DTS的yes_set_设置为该key_上取得最大似然提升的问题。也就是说,我们在这个DTS问问题“第key_个位置的音素属于yes_set_吗?”对DTS进行划分。
      因为我们要对决策树进行持续划分,所以DTS还保存着指向两个子树的指针DTS *yes_, *no_。当第key_个位置的音素属于yes_set_时进入yes_子树,否则进入no子树。
      在本笔记中我就不详细介绍DTS了,其学习思路和笔记(二)中对TreeClusterer的介绍一样:先看该类有哪些数据成员,然后看该类的构造函数都完成什么初始化工作,最后看其主要的成员方法完成什么工作。在下面我会附一张DTS的手写笔记草稿,希望其有一定启发。
  std::vector<DecisionTreeSplitter*> builders;
  {  // set up "builders" [one for each current leaf].  This array is never extended.
    // the structures generated during splitting remain as trees at each array location.
    std::vector<BuildTreeStatsType> split_stats;
    SplitStatsByMap(stats, input_map, &split_stats);
    KALDI_ASSERT(split_stats.size() != 0);
    builders.resize(split_stats.size());  // size == #leaves.
    for (size_t i = 0;i < split_stats.size();i++) {
      EventAnswerType leaf = static_cast<EventAnswerType>(i);
      if (split_stats[i].size() == 0) num_empty_leaves++;
      builders[i] = new DecisionTreeSplitter(leaf, split_stats[i], q_opts);
    }
  }

这里写图片描述

  • 把63个决策树(builders)放在一起比较,每次对似然提升最大的叶子结点(此时结点都是DTS)进行划分(builders[i]->DoSplit()),这种策略通过优先队列实现。划分具体就是指,对处于叶子位置的DTS找到第key_个位置和在该位置所问的问题yes_set_,同时将该DTS上的统计量根据划分结果分配到两个孩子DTS yes_和no_。我们对所有的builders持续进行划分,在tree_stub的每个叶子结点就生成了一棵结点全是DTS的树。
  {  // Do the splitting.
    int32 count = 0;
    std::priority_queue<std::pair<BaseFloat, size_t> > queue;  // use size_t because logically these
    // are just indexes into the array, not leaf-ids (after splitting they are no longer leaf id's).
    // Initialize queue.
    for (size_t i = 0; i < builders.size(); i++)
      queue.push(std::make_pair(builders[i]->BestSplit(), i));
    // Note-- queue's size never changes from now.  All the alternatives leaves to split are
    // inside the "DecisionTreeSplitter*" objects, in a tree structure.
    while (queue.top().first > thresh
          && (max_leaves<=0 || *num_leaves < max_leaves)) {
      smallest_split_change = std::min(smallest_split_change, queue.top().first);
      size_t i = queue.top().second;
      like_impr += queue.top().first;
      builders[i]->DoSplit(num_leaves);
      queue.pop();
      queue.push(std::make_pair(builders[i]->BestSplit(), i));
      count++;
    }
     }
  • 对tree_stub的每个叶子结点,我们通过调用builders[i]->GetMap()得到由EventMap表示的属于该音素的决策树。生成好属于每个音素的决策树之后,我们对tree_stub进行扩展,把tree_stub种表示每个音素的叶子结点替换成属于这个音素的决策树EventMap,这就是input_map.Copy(sub_trees)完成的工作。于是,完整的大决策树就生成了。
  EventMap *answer = NULL;
  {  // Create the output EventMap.
    std::vector<EventMap*> sub_trees(builders.size());
    for (size_t i = 0; i < sub_trees.size();i++) sub_trees[i] = builders[i]->GetMap();
    answer = input_map.Copy(sub_trees);
    for (size_t i = 0; i < sub_trees.size();i++) delete sub_trees[i];
}

下面有两幅图片,第一幅表示对tree_stub的第一个叶子结点构建出的由DTS表示的决策树,第二幅表示对DTS表示的决策树的树根(builders[0])调用GetMap()后得到的由EventMap表示的决策树。希望能让大家对每个音素的决策树构建过程有一个直观的认识:
这里写图片描述

这里写图片描述


作者:许开拓
日期:写于2017/04/07~04/08
联系方式:540262601@qq.com

发布了22 篇原创文章 · 获赞 22 · 访问量 3万+
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览