书接上回,分词并ID化之后,是统计每个三元组出现的次数:
$ make m3_idngram
./ids2ngram -n 3 -s ../swap/swap -o ../swap/lm_sc.id3gram -p 5000000 ../swap/lm_sc.ids
ids2ngram.cpp:
ProcessingRead():
读取上一步生成的ID文件。首先读取N-1个ID(也就是2个词),保存到ngram对象中的ids成员数组中。然后读取一个词,保存为ids数组的第三个(也就是ids[N-1])元素。使用std::map的operator [],以ids[0..2]为key,取出该三元组对应的出现次数(如果是第一次出现,[]操作符会将这个<key,0>对儿加入到map中),并加1。将ids数组向左移动一个单位,继续迭代上述过程。
在迭代过程中,还会判断是否map的大小超过了预先设置的段的最大值(paraMax)。这是为了防止map在内存占用了过多的空间。如果超过了,则将map写入到交换文件中,记录下该段在交换文件的偏移,然后将map清空,继续统计。值得提醒的是,在使用iterator迭代std::map时是有序的,因为其内部是一个平衡的有序二叉树(通常为红黑树,即rb-tree)。后面会对这些段进行一个归并排序。
ProcessingIdngramMerge():
归并排序。对多个已经有序的数据段(或队列)进行归并排序,就是找出最小(或)最大的队头元素并输出,然后将该元素所在队列的队头后移一个单元,重复上面的过程,直至所有队列为空。如果归并的路数比较多时,对队头元素的排序就成为一个重要的步骤。在SunPinyin中,有一个模板类类处理文件的多路归并,即slm/sim_fmerge.h;它使用了heap(堆)算法,对队头元素进行排序。getBest()从heap中得到具有最小队头元素的分段,next()将该队列的后续元素重新插入到heap中。
最后,我们得到了一个没有进行任何平滑的原始trigram,所有出现的三元组以及其出现的次数。
接下来是构建一个支持back-off的n-gram模型:
$ make m3_slm
./slmbuild -n 3 -o ../swap/lm_sc.3gram -w 120000 -c 0,2,2 -d ABS,0.0005 -d ABS -d ABS,0.6 -b 10 -e 9 ../swap/lm_sc.id3gram
首先让我们了解一下何为n-gram。
对基于概率统计的自然语言处理来说,计算一个句子(S=W1,W2,...Wn)的概率,根据链式规则,为:
P(S) = P(W1).P(W2|W1).P(W3|W1,W2).P(W4|W1,W2,W3)...P(Wn|W1,W2,...Wn-1)
= P(W1).prod_i^n (P(Wi|W1,W2,...Wi-1))
为了在字符模式下表示数学公式,这里使用类Latex的公式语言。用Latex表示上面的公式,P(S) = P(W_1)\prod_i^nP(W_i|W_1,W_2,...W_{i-1}),其图像的表示为:

而在现实中,因为数据稀疏性的问题,是不可能按照这个式子去计算的。一种可行的方案是,假定P(Wi|W1,W2,...Wi-1)只依赖于前面的N个词,即Wi-N+1,Wi-N+2,...Wi-1。具体来说,我们有unigram(N=0,context-free grammer,上下文无关文法),bigram(N=1),trigram(N=2),以及fourgram(N=3)。通常实际使用的多为trigram。
我们再了解几个常用的术语:
- types(型):单词表(或词典)的大小
- tokens(例):语料库的大小
- vector/solution space(解空间):对于N-gram模型来说,V = types^N
- data sparseness(数据稀疏性):tokens << V,也就是说语料库的大小远远小于解空间的大小。
- Maximum likelihood(最大似然估计):Pml(Wi|Wi-2,Wi-1) = C(Wi-2,Wi-1,Wi)/C(Wi-2,Wi-1),C表示出现的次数。这个式子的意思是,P(Wi|Wi-2,Wi-1)的最大似然估计为,(Wi-2,Wi-1,Wi)出现的次数除以(Wi-2,Wi-1)出现的次数。
许多可能的词序列,也许并没有被收集到训练语料库中。如果Pml(Wi|h)为0,那么整个句子的概率也就化为乌有了。例如,如果在语料库中只出现过"Tom read a book",而没有出现过"Yong read xxx"的序列,那么Pml(Yong read a book) = 0。因此我们必须要对语言模型进行平滑处理(smoothing)。所谓平滑,就是将已知(已见到)事件的概率分出一小部分,匀给未知(或未见的)的事件,也就是出现次数为0的事件。
平滑的方法有许多种:
- 简单有加1(或加delta)平滑,单独使用效果很差。
- discounting(打折)平滑:例如Absolute Discounting,线性平滑,Witten-Bell方法,Good-Turing方法等等
- 复合平滑,包括back-off平滑,插值平滑等
一般的back-off模型的表示如下:
Ps(Wi|h) = Pd(Wi|h) -- C(h,Wi) > 0
bow(h).Ps(Wi|h') -- C(h,Wi) == 0
- h'是h去掉其第一个词,对trigram (A,B,Wi)来说,h为(A,B),h'为B。
- Pd(Wi|h) < Pml(Wi|h),对最大似然估计进行打折(discount),可使用多种不同的打折方法。
- bow(h)被称为back-off weight(回退权重),对于给定的h,bow(h)是一个常数,并且可以通过下面的方法来确定Ps(W1|h)+Ps(W2|h)+...+Ps(Wn|h) = sum_i (Ps(Wi|h)) = 1
- 上面这个式子是递归的,如果(h',Wi)出现次数为0,还会继续回退,也许会回退到Wi,甚至回退到平均分配(如果Wi也未出现)
- 如果h在训练中没有见到,则P(Wi|h) = P(Wi|h')
Katz的back-off模型使用的是Good-Turing方法。Kneser-Ney是另一种back-off模型,其平滑效果要好于Katz方法。Stanley Chen和Joshua Goodman专门写过一篇论文,详细比较了各种平滑方法的优劣,有兴趣可以去读一读。
下面来看代码。
sim_slmbuilder.[h|cpp]:
CSlmBuilder::Create(n):
根据n初始化level数组,level[0]为pseudo root,其意义为平均分布。level[1]为unigram,level[2]为bigram,level[3]为trigram...对trigram来说,level[3]上的节点为叶子节点。叶子节点是没有bow信息的,因为它属于C(h,Wi)>0的情况。
CSlmBuilder::AddNGram(ngram, fr):
调用isExcludeId()来判断ngram的第一个词是否为排除在外的词(我们在参数中指定为9,也就是上一回所说的AMBI-ID)。对数组level[1..n]的每个成员,检查其预留(reserve)的空间是否已经用尽,如果用尽,则分配更多的内存。如果第一个词不是排除在外的词,则将这个三元组出现的次数,累加到level[0][0](也就是pseudo root)上。将ngram中的每个id加入到level[i] (0<i<=n) 的数组中,并累加前继节点的出现次数。对于level[n](即leaf level),只有ngram的出现次数大于指定的cut number,才会加入;这样做是为了避免叶节点的数目过于庞大(可能达到数千万个)而无法保存在内存中。
其间还要判断,如果ngram[i] (0<i<=n-1) 为排除在外的词,则只统计ngram[0..i-1]的出现次数;如果ngram[i] (0<=i<=n-1) 为句子分割词的ID(我们在参数中指定为10),则只统计ngram[0..i]的出现次数。举例来说,如果某个trigram为(9, x, y),则直接就跳过了;如果是(x, 9, y),则只统计unigram (x);如果是(10, x, y),则统计unigram (10);如果是(x, 10, y),则统计unigram(x)和bigram (x, 10)。
CSlmBuilder::Build():
构建back-off的n-gram语言模型。
CSlmBuilder::CountNr():
统计出现次数小于SLM_MAX_R(即16)的n-gram的个数。例如nr[3][0]表示trigram的总数,nr[3][10]表示出现次数为10的trigram的总数(只可能是10的倍数,例如500)。这些数据是在进行打折时需要的。
CSlmBuilder::AppendTails():
为每个level添加一个tail,方便后面迭代条件的判断。

CSlmBuilder::Cut():
将每个level中出现次数小于指定值的n元组删除。我们指定的cut数组为(-c 0,2,2),这表示我们将忽略出现次数小于等于2的bigram和trigram,但是并不对unigram进行裁剪。
CSlmBuilder::Discount():
初始化每个level所使用的discounter。由高的level到低的level,调用DiscountOneLevel()来为每个level执行打折。将level[0](pseudo root)设为平均分布,其概率为1除以词的总数(由参数-w指定,本例为120000)。
CSlmBuilder::DiscountOneLevel(v, ch, disc ...):
参数v是上一个level,例如,如果为level[3]执行打折,那么传入的第一个参数是level[2]。要为level[m]打折,遍历level[m-1]的每个节点,对每个节点的后续节点([it->child, itnext->child))进行打折。这里折扣掉的是出现的次数(frenquency),而非概率值。然后除以前继节点上记录的次数(root_freq),得到条件概率,并保存在原节点上。
CSlmBuilder::CalcBOW():
计算back-off weight,是从低的level向高的level进行的。base[i]指向的是level[i]的第一个元素,idx[i]表示在每个level上的游标,(base[i])[idx[i]]就是当前遍历的节点(这里利用了这样一个事实,那就是std::vector中分配的内存空间是连续的,可以通过指针偏移或数组下标来直接访问)。
我们以计算level[2]上的BOW为例。此时lvl为2,迭代base[2]的每个词。首先用一个for循环,找到这个词的前继,例如idx[2]指向上图的level[2][1](C),则其前继为level[1][0](A),将它们放到words数组中,words[4]={0, A, C, .}。然后调用CalcNodeBow()来计算这个节点的BOW值,这个函数的最后两个参数,表示该节点的后续节点范围,[ch+node.child, ch+nodenext.child)。
CalcNodeBow(...):
遍历[chh, cht),将每个节点的概率值累加起来,放到sumnext变量中;同时调用builder->getPr(lvl, words+2)得到一个概率值,累加到sum变量中。words+2实际的作用是,去掉h最前面的那个词。沿用上面的例子,在第一次迭代时,words[4]={0, A, C, D},那么words+2为{C, D},getPr()得到的概率值为Ps(D|C)(注意,getPr()本身是一个递归函数,且当lvl为0时,返回level[0]的平均分布概率)。BOW值为(1.0-sumnext)/(1.0-sum)。
我们前面介绍过,BOW的值是通过公式sum_i (Ps(Wi|h)) = 1来求解的。通过下面的转换,你就可以看出sumnext和sum各自的含义了。
