可以毫不夸张的说,没有聚类的成功应用,就不会有今天的连续语音识别率(虽说不是很高)。由于语流中语音的变体十分丰富,为了能够足够精确的描述这些变体,人们往往必须设计一个较为复杂的语音单元(比如上下文音素单元)。
可是这样,问题就出现了,实际上,我们可以用于训练的语音数据总是有限的,往往不能够满足复杂语音单元训练的要求,这就形成了模型复杂度(模型描述的准确度)和训练数据规模之间的矛盾。一味的增加训练数据是不现实的,一方面这会极大的增加存储空间,人工劳动和训练费用等等;还有一个困难是致命的,就是不管你如何增大训练数据的规模,总是存在训练数据集以外的语音变体,所以仅仅依靠增加数据集来解决这个矛盾是不可行的。而聚类就可以有效的缓解这个矛盾。
用于基于HMM的语音识别的模型训练的聚类方法大致可以分为两类:一是基于数据驱动的聚类方法,而另一个就是现今正广泛使用的基于决策树的聚类方法。决策树聚类的好处是:第一,它可以得到与基于数据驱动聚类方法相同的聚类性能,另一个好处更为重要,就是它可以为训练集未包含的但实际语流中又可能会出现的语音单元(称为不可见语音单元)提供一个较为可信的参数估计。
这里主要讨论的决策树聚类是:上下文语音单元状态的聚类。如上图所示。
基于决策树的状态捆绑(Decision tree based state tying)是一种自顶向下的聚类过程。假设三音素(triphones)模型是上下文相关的模型(context dependent model),起初位于根节点的triphones中的状态模型从单音素模型中获得。从根节点开始像二叉树一样分裂成两个相继的节点(称为yes和no节点),然后再以相继的节点为根节点,继续向下分裂,直到满足给定的条件为止,最后,所有叶节点上的triphones集合就是tied triphones。如下所示
音素决策树
这里介绍两种决策树聚类方法。
以下讨论的聚类方法都假设:模型的输出概率分布为单高斯分布,协方差矩阵为对角阵
基于最大似然准则的决策树的构造如下
1. 一组一维高斯分布的上下文相关的HMM已训练好
2. 预备聚类的状态集放在决策树的根节点上并计算其对数似然值
3. 对每个节点、每个问题:计算在这个问题下分裂成的yes和no子节点的对数似然值比父节点的对数似然值增加值。记似然值增加最多的节点为及其对应问题为,使用问题分裂节点
4. 重复步骤3,直到对数似然值的增加值低于设定的域值
|
是节点在q提问下分裂前后的对数似然值(Log likelihood)之差,即:
|
节点的对数似然值是通过训练数据观察向量的均值、方差以及节点的期望占有数(expected number of occupation)近似计算所得。
|
|
其中、、分别是节点中某元素的第i个状态的均值向量、方差矩阵(对角协方差矩阵)、占有数向量。上标k表示向量的第k个分量。给定训练数据中的一观察序列,那么节点的对数似然值可以通过下式求得:
|
、分别指节点在t时刻的期望占有数和序列的期望占有数。聚类后决策树的叶节点就是求得的捆绑的triphones,对应的均值向量为,协方差矩阵为。这里必须指出的是,不知道为什么大多数文献直接给出,实际上节点的对数似然值为,以后笔者会证明它与式(3-5)之间并不能划上等号,事实上,应该是的辅助函数(这里的表示状态序列)与式(3-5)相等。
下面推导节点的对数似然值公式:
由Baum-Welch参数估计公式:
|
|
令:,
|
|
其中
式(3-9)重写为:
|
重写式(3-4)如下:
|
将式(3-11)代入式(3-10),得:
下面证明为0。
所以:式(3-8)
基于最大似然(ML)准则的决策树聚类已广泛使用,但基于这种方法的聚类有一个缺点,就是需要外部提供一个域值来控制其聚类的程度。这个域值的选取是经验性的,没有可靠的理论来保证选取的最优性,且不同的训练数据需要不同的域值。较好的域值,只能通过反复凑试获得,这通常是很费时。
这里将要讨论的基于MDL的决策树聚类,可以避免外部选择域值的麻烦,算法可以根据模型复杂度和数据规模自适应的产生一个域值。有实验证明这个域值比较可靠。
MDL准则
假设有一组概率模型和一组数据,模型的描述长度计算公式如下
|
其中,是模型的维数(自由参数的个数),是模型对应最大似然估计的参数。
式(1)中的第一项称为模型关于数据的编码长度。这项与ML中的对数似然值的负数等同。
式(1)中的第二项称为模型的编码长度,该项代表了模型的复杂度。
式(1)中的第三项称为选择模型的编码长度,这个假设为常数。
先定性讨论一下式(1)的含义:当模型的复杂度增加时,第一项的值变小,第二项的值变大,第二项可以看成对选择复杂模型的一种惩罚。具有最小描述长度的模型可以认为其复杂度是适中的。从中可以看出,MDL准则不需要外部提供参数来帮助判断模型的最优性。
用于语音识别的MDL的计算
如上图,假设节点分裂成个叶节点,则模型的描述长度的计算公式如下:
|
式(2)中的变量的意义与ML中相同。
记
模型的维数是(个均值向量,个协方差矩阵对角元素向量)
假设节点在问题的提问下分裂成和分支
记
当节点分裂,否则节点停止分裂。
这里的相当于ML中外部提供的域值。
HTK中HHED模块的功能非常强大,它主要负责对HMM定义文件进行修改,比如将单音素模型拷贝生成上下文三音素模型(通过CL命令完成)。这里要主要讨论HHED的决策树聚类的功能(HHED也提供了基于数据驱动的聚类方法)。HHED的工作模式和Shell非常相识,用户提供命令脚本文件(文件扩展名通常为:.hed),脚本文件中一行只能写一个命令,HHED模块每次读入一行命令,解析后就执行该命令,执行完后,接着再读入下一行命令,解析后并执行,如此重复,直到执行完所有命令行。
决策树聚类主要通过QS和TB两个命令完成,每执行一条QS命令,HHED就向聚类问题集中增加一个问题,每执行一条TB命令,HHED便会构造一棵决策树并利用聚类问题集进行分裂聚类,聚类结束时,HHED便会将每个叶节点中的所有元素聚为一类(捆绑状态)。
下面先给出HHED模块程序主流程图
格式:QS 问题名称 {匹配字符串}
例子:QS "L-Vowel" {aa^*,ae^*,ah^*,ao^*,...}
上面例子中QS命令将所有左音素为元音的音素模型归为一类(yes节点),其他的则归为另一类(no节点)。
匹配字符串中的字符*表示匹配0个或多个任意字符,字符?表示匹配任意单个字符。
正如前面所述,执行一条QS命令,HHED便会向聚类问题集中增加一个问题。比如执行前面例子中的QS命令,HHED就会向聚类问题集增加一个名称为"L-Vowel"的问题。
HHED模块中主要由QuestionCommand ()函数负责将问题加入到聚类问题集中。
关键代码:
ChkedAlpha("QS question name", qName);
pattern = PItemList(&ilist, &type, hset, &source, NULL, trace&T_ITM);
LoadQuestion(qName, ilist, pattern);
下面的分析以命令QS "L_p" {p-*}为例
执行ChkedAlpha()后,(char*)qName存储问题名称字符串"L_p"。
PItemList()主要负责将匹配字符串所匹配的模型形成一个模型链表,由(ILink)ilist指向链表头。(char*)pattern存储的就是匹配字符串"{p-*}"。
匹配的模型链表
装载问题函数LoadQuestion(),将问题信息记录到QLink数据结构中,并加入到聚类问题集链表中,(QLink)qHead和(QLink)qTail分别指向这个聚类问题集链表的头和尾。QLink所描述的问题有以下三个部分组成
1. 问题名称 (LabId)QLink->qName
2. 匹配字符串链表 (IPat*)QLink->patList
3. 匹配的模型链表 (ILink)QLink->ilist
格式:TB 域值 宏名 {项列表}
例子:TB 000 mcep_s2_ {*.state[2].stream[1]}
上面例子中TB命令将所有音素模型的状态号2的数据流号1的所有项归为一个聚类集。这个聚类集对应于决策树的根节点。字符串"mcep_s2_"作为聚类完成后取名的前缀。
注意:HHED中的TB命令只支持单高斯连续输出分布的HMM模型的聚类。
在HHED中由TreeBuildCommand()函数完成TB命令。
下面先给出TreeBuildCommand()函数的程序流程图
1. 读入项列表 PItemList()
聚类可以是状态的聚类也可以是状态下某条数据流的聚类等等,总之模型参数的任何一项或多项都可以进行聚类。这里主要讨论状态的聚类或者状态下某条数据流的聚类。
PItemList()前面已经遇到过,这里的功能是将匹配项列表的所有HMM模型组成一个模型参数链表,(ILink)ilist指向该链表头。
以项列表"*.state[2].stream[1]"为例,执行PItemList()函数后(ILink)ilist指向HMMSet集中所有HMM模型在状态号2下的数据流号1的参数组成的模型
参数链表头。
具体到代码上,链表中每一项都记录
模型的状态2参数 ILink->item = (HLink)hmm->svec+2
模型参数 ILink->owner = (HLink)hmm
由于这里是数据流聚类,所以HHED还使用一集合变量(IntSet)Streams来记录聚类的数据流号。在上面的例子中,Streams->set[1] = TRUE,其它元素都为FALSE,表示只对数据流号1聚类。
2. 初始化累加器,创建决策树
BuildTree()函数的第一步就是初始化全局yes和no分支累加器(AccSum)yes、(AccSum)no,接着就是创建决策树及其根节点。
首先要说明的是关于对节点提出的问题的回答。
聚类集由一个链表组成,(CLink)clHead指向该链表头。HHED通过模型参数链表、聚类集和聚类问题集的指针交换实现对节点提问的回答(yes和no)。
下面先给出关键代码片断
//聚类集
i = 1; clHead = NULL;
for (p=ilist;p!=NULL;p=p->next,i++)
{
. . . . . .
cl = (CLink) New(&tmpHeap,sizeof(CRec));
p->owner->hook = cl; //这是关键
cl->item = p; cl->ans = FALSE;
cl->idx = i; //模型参数链表中项的索引号
cl->next = clHead;
clHead = cl;
}
for (q=qHead; q!=NULL; q=q->next)
{
for (p=q->ilist; p!=NULL; p=p->next)
{
//问题的模型链表项 = 聚类集中相应的项
//也就是聚类集中该项对这个问题的回答是yes
p->item = p->owner->hook;
}
}
for (p=ilist;p!=NULL;p=p->next,i++)
p->owner->hook = NULL;
是不是对上面的代码所起的作用还不清楚,有些迷糊,没关系,下面的函数会帮助你更好的理解如何完成对节点提出问题的回答。
回答问题函数AnswerQuestion()的代码片断
void AnswerQuestion(CLink clist, QLink q)
{
CLink p;
ILink i;
//先设置聚类集关于问题(QLink)q的回答为no
for (p=clist;p!=NULL;p=p->next)
p->ans = FALSE;
if (q!=NULL)
{
for (i=q->ilist;i!=NULL;i=i->next)
{
if ((p=(CLink) i->item) != NULL)
p->ans = TRUE; //这是关键
}
}
}
通过关于聚类集问题的回答将聚类集分为yes (CLink->ans = TRUE)和no(CLink->ans = FALSE)两个子聚类集。
决策树由(Tree*)tree表示,开始的时候只有一个根节点,它也是叶节点,根节点对应于聚类集。
(Node*)node = tree->leaf = tree->root = CreateTreeNode(clHead, NULL);
3. 分析分裂节点的最优问题 ValidProbNode()
关键代码:ValidProbNode(node, threshold);
计算节点(Node*)node在问题聚类集中所有问题的提问下的yes和no分支的似然值之和(ClusterLogL())的最大值((double)node->sProb)并记录对应的问题((QLink)node->quest)。
如果这个最大似然值比节点node的似然值还小,则node->sProb=节点似然值,node->quest = NULL。
下面给出ValidProbNode()函数的的代码片断
qbest = NULL;
best = node->tProb; //节点似然值
for (q=qHead;q!=NULL;q=q->next)
{
AnswerQuestion(node->clist, q);
sProb = ClusterLogL(node->clist, &no, &yes, occs);
. . . . . .
if (sProb > best)
{
best = sProb;
qbest = q;
}
}
. . . . . .
node->sProb=best; node->quest=qbest;
4. 分析叶节点中最优的分裂节点 FindBestSplit()
关键代码:node = FindBestSplit(tree->leaf, threshold);
计算所有叶节点中分裂前后后的似然值之差(node->sProb-node->tProb)中的最大值并返回其节点指针。如果这个最大值小于域值(double)threshold,则返回NULL。
下面给出FindBestSplit()函数的代码片断
Node *FindBestSplit(Node *first, double threshold)
{
Node *best, *node;
double sProb, imp;
best = NULL;imp = 0.0;
for (node = first;node != NULL;node = node->next)
{
sProb = node->sProb - node->tProb;
if (sProb>imp && sProb>threshold)
best = node;imp = sProb;
}
return(best);
}
5. 分裂节点 SplitTreeNode()
关键代码:SplitTreeNode(tree, node);
节点(Node*)node在问题(QLink) node->quest提问下分裂成(Node*)yes和(Node*)no两分支。
首先将node在问题(QLink) node->quest提问下回答no的子聚类集赋予子节点(Node*)no,将node在问题(QLink) node->quest提问下回答no的子聚类集赋予子节点(Node*)yes。
接着调整(Node*)tree->leaf仍指向叶节点链表头,并调整yes和no节点的相关指针。
根节点示意图
第一次分裂后示意图
第二次分裂后示意图
下面给出SplitTreeNode()函数的代码片断
void SplitTreeNode(Tree *tree, Node *node)
{
CLink cl,nextcl;
if (node->quest == NULL) return;
cprob += node->sProb - node->tProb;
AnswerQuestion(node->clist, node->quest);
node->yes = CreateTreeNode(NULL,node);
node->yes->ans= TRUE;
node->no = CreateTreeNode(NULL,node);
node->no->ans=TRUE;
//分裂聚类集
for(cl=node->clist;cl!=NULL;cl=nextcl)
{
nextcl=cl->next;
switch(cl->ans)
{
case FALSE:
cl->next=node->no->clist;
node->no->clist=cl;
break;
case TRUE:
cl->next=node->yes->clist;
node->yes->clist=cl;
break;
default:
HError(2694,"SplitTreeNode: Unspecified question result");
}
}// for(cl=node->clist;cl!=NULL;cl=nextcl)
node->clist = NULL;
//调整yes和no分支指针,以及叶节点链表
node->yes->next = node->no;
node->no->prev = node->yes;
node->no->next = node->next;
node->yes->prev = node->prev;
if (node->next!=NULL)
node->next->prev=node->no;
if (node->prev==NULL)
tree->leaf = node->yes;
else
node->prev->next=node->yes;
numTreeClust++; //叶节点数++
}
6. 对所有叶节点进行聚类 TieLeafNodes()
关键代码:TieLeafNodes(tree, macRoot);
决策树分裂结束后,执行TieLeafNodes()将每个叶节点中所有元素聚为一类(新构造一个宏),聚类宏名以(char*)macRoot为前缀,后接序号。聚类的输出概率的均值向量和协方差矩阵取其对应节点的均值向量和协方差矩阵(具体公式见前)。
以前的讨论都没有涉及到聚类模型,所以在这里有必要说明一下。
在HTK中主要通过使用计数(->nUse)标志聚类(捆绑)。如果对模型的状态参数或者状态下某些数据流参数捆绑,那么这些参数都指向同一个同类型的参数,这个参数中的使用计数(>1)记录捆绑参数的个数。
明白了吗?是不是太抽象了,不用担心,下面的代码片断会帮助你进一步弄清是这么回事的。
TieLeafStream()函数实现状态下某条数据流的捆绑
void TieLeafStream(ILink ilist, LabId macId, int stream)
{
ink i;
StreamInfo *sti;
. . . . . .
sti = ((StateElem *)ilist->item)->info->pdf[stream].info;
sti->nUse = 1; //使用计数为1
NewMacro(hset, fidx, 'p', macId, sti);//构造一个新聚类的宏
//捆绑集中所有数据流参数
for (i=ilist->next; i!=NULL; i=i->next)
{
((StateElem *)i->item)->info->pdf[stream].info = sti;
++sti->nUse;
}
}
TieState ()函数实现状态的捆绑
void TieState(ILink ilist, LabId macId)
{
ILink i,ti;
StateInfo *si,*tsi;
StateElem *se;
. . . . . .
if (hset->hsKind==TIEDHS || hset->hsKind==DISCRETEHS)
ti = ilist;
else
ti = TypicalState(ilist, macId); //选择较好的状态参数来代表这个聚类
se = (StateElem *) ti->item;
tsi = se->info;
tsi->nUse = 1; //使用计数为1
NewMacro(hset, fidx, 's', macId, tsi); /构造一个新聚类的宏
//捆绑集中所有状态参数
for (i=ilist; i!=NULL; i=i->next)
{
se = (StateElem *)i->item; si = se->info;
if (si != tsi)
{
se->info = tsi;
++tsi->nUse;
}
}
}
聚类完成后,ST命令可以将内存中的决策树列表输出到文件中。其中包括聚类时使用的问题集,一组构建好的决策树。
文件的存储方式如下:
问题集
决策树列表
问题集中的问题的存储格式和前面的QS命令相同。
下面给出决策树的存储的典型格式
m[3].stream[1]
{
0 C-Vowel -1 -3
-1 C-silences -2 "s3_4"
-2 C-Unvoiced_Stop "s3_6" "s3_5"
-3 C-l -4 "s3_1"
-4 C-Low_Vowel "s3_3" "s3_2"
}
其中,第一行,指明树的名称(m)和聚类的类型(状态号3下的数据流号1的聚类)。
以第三行为例,"C-Vowel"是用来分裂决策树节点号为0(根节点)的问题名称,后面分别是yes子节点(序号为1)和no子节点(序号为3),如果子节点是非叶节点则用序号表示,否则用聚类宏名显示。
将由ST命令输出的决策树列表文件装入。