SunPinyin代码导读-SLM部分

0. 综述

 首先check out输入法项目的代码:
 $ hg clone ssh://anon//@//hg.opensolaris.org/hg/nv-g11n/inputmethod
 或者到www.opensolaris.org上的input-method项目,下载最新的代码仓库快照。(但是其中并不包括ime/data目录下那些较大的数据文件,你可以到这里下载它们)
 inputmethod/sunpinyin目录下是SunPinyin的代码,其中包括两部分,slm目录下是统计语言模型的代码(slm: statistical language model),ime目录下是和输入法相关的接口(ime: input method engine)。
 slm中的代码用来构建一个支持back-off(回退)的n-gram(n元语法)静态统计语言模型。
 首先执行下面的步骤来编译各个工具:
 $ ./autogen.sh --prefix=/usr
 $ make
 build:这个目录下保存编译好的各个工具(以及它们的object文件),例如ids2ngram、slmbuild等。通过查看这个目录下的Makefile.am,你可以了解如何一步一步地构建词库和统计语言模型的二进制文件。
 $ export LC_ALL=zh_CN.UTF-8
 因为语料库和词表都使用UTF-8的编码,因此在进行后续步骤之前,需要将当前的语言环境设置为任意一个UTF-8的locale。
 $ make test_corpus (或real_corpus)
 这一步在raw目录中,建立一个corpus.utf8的符号链接。你完全可以手工建立这个链接。
 $ make trigram
 这一步建立一个trigram语言模型。

  1. 首先使用mmseg(最大正向分词),根据词表,对语料库进行分词,并将词的ID序列输出到一个文件中(../swap/lm_sc.ids)。
  2. 然后使用ids2ngram,对所有3元组出现的次数进行统计,并输出到一个文件中。例如下面的句子:ABCEBCA。得到的3元序列包括:(<S> A B), (A B C), (B C E) ... ( B C A), (C A '。'), (A '。' </S>)。<S>和</S>分别表示句首和句尾。
  3. 使用slmbuild来构造一个back-off的trigram语言模型,但是未经裁剪。
  4. 使用slmprune对刚输出的trigram语言模型进行剪裁,使用的算法是基于熵的剪裁算法。
  5. 使用slmthread对剪裁后的语言模型进行线索化,加快back-off(回退)查找的速度。将最终的结果输出到../data/lm_sc.t3g。

 $ make bs_trigram
 这个目标和上面的类似,不同的是使用slmseg进行分词,它借助刚刚生成的基于最大正向分词得到的语言模型,对语料库进行重新分词。这样可以进一步提高语言模型的精确程度。
 $ make lexicon
 根据生成语言模型中的unigram数据,对词表进行处理,得到一个支持不完全拼音的词库文件。
 代码的细节我们随后再详述。

1. 词典与分词

 如前所说,在SunPinyin中,第一遍分词使用的正向最大匹配方法(FMM,forward maximum matching)。辞典加载之后,保存在trie树的数据结构中,其key是以USC-4编码(32位)的字符。其中某些节点是有效的词,例如在下图中最左侧的一条路径中,“中国”是一个有效的词。而有些节点,则是不成词的。例如“中华人民共和国”这个路径中的,“共”节点,就不成为一个有效的词。(和参考3中的数据结构相似)
 需要注意的是,root的所有子节点都是有效词,因为所有的“字”都被被视为“单字词”。
http://blogs.sun.com/yongsun/resource/dict_trie.png 
sim_dict.[h|cpp]:

parseText ()   : 加载text格式的辞典,并以trie树的形式保存在内存中。

matchLongest (): 进行最长匹配,并返回匹配的长度。
step ()        : 从当前节点转移到匹配给定key的一个孩子节点,并返回。

mmseg/mmseg.cpp:

  processSingleFile()中,逐句读取语料库,例如,"为人民办实事的精神",然后进行调用SIMDict::matchLongest()进行最长匹配。

 因为辞典中有“为人民”这个词条,因此分割到的第一个词是“为人民”,其长度为3,然后调用getAmbiLen(),来分析是否有交集歧义,并返回最大交集歧义的长度。下面是每次迭代的情况:

  1. 人民, 办实事的精神 -> i=1, len=2, word_len=3, 对“人民办实事”进行最长匹配,分割到的词为“人民”,其长度为2,
  2. 民办, 实事的精神 -> i=2, len=2, word_len=4, 对“民办实事”进行最长匹配,分割到的词为“民办”,其长度为2,因为i与len的合超过了最初传入的word_len,则设置word_len为4,继续迭代。可以看出,此时歧义已经检测到了。
  3. 办实事, 的精神 -> i=3, len=3, word_len=6
  4. 实事, 的精神 -> i=4, len=2, word_len=6
  5. 事, 的精神 -> i=5, len=1, word_len=6, 最后歧义的长度为6
  6. 退出循环,返回得到的长度,即6 如果指定了ambiguous-id,则会将这6个字符作为一个AMBI-ID(由参数-a指定),输出到分词的结果中。然后跳过这6个字符,继续进行分词。当句子结束时,如果使用二进制格式输出,则会输出一个句子结束的ID(由参数-s指定)。最后得到的结果是:
     $ echo "为人民办实事的精神" | ./mmseg -d ../raw/dict.utf8 -f text -s 10  -a 9
     <ambi>为人民办实事</ambi> 的 精神

 那么,我们最终得到的分词结果中,所有的交集歧义都作为作为一个词AMBI-ID,相当于我们忽略了这部分信息(这个比例并不低)。这样在我们后面的统计中,绝大部分的3元组都可以保证是有意义和有价值的。进而训练得到的统计语言模型,能够排除交集歧义的影响。然后,可以这个模型,使用slmseg,重新对语料库进行分词,并计算新的语言模型。这一次,原来忽略的带有歧义的信息,我们也加以利用了。
 $ echo "为人民办实事的精神" | ./slmseg -d ../raw/dict.utf8 -f text -s 10 -m ../data/lm_sc.t3g
 为人民 办实事 的 精神
 (注,因为版权的原因,我们无法公开内部使用的生语料库。不过,要使用slmseg,您可以在slm/data目录中,建立一个指向ime/data/lm_sc.t3g.<arch>的符号链接。)
 References:

  1. 数学之美 系列二  谈谈中文分词
  2. 中文搜索引擎技术揭密:中文分词
  3. “天堂的阶梯”设计的中文分词算法

2. 统计三元组

 分词并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,即所有出现的三元组以及其出现的次数。

3. 构造back-off的n-gram语言模型

 接下来是构建一个支持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}),其图像的表示为:

http://www.sitmo.com/latex/latex2png.php?eq=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 cancel;如果是(10, x, y),则统计unigram (10);如果是(x, 10, y),则统计unigramcancel和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,方便后面迭代条件的判断。

http://blogs.sun.com/yongsun/resource/backoff-trigram-data-structure.png
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各自的含义了。


4. 基于熵对back-off模型进行剪枝

 这一回我们要介绍的是,如何基于熵对back-off模型进行剪枝(pruning)。
$ make m3_prune
./slmprune ../swap/lm_sc.3gram../swap/lm_sc.3gmR 100000 1250000 1000000

 首先来看熵的定义,设pcancel是随即变量X的概率密度函数,则X的熵为:
http://blogs.sun.com/yongsun/resource/entropy-equation.png 
 注:log均是以2为底的对数,且假定0log 0 = 0。
 熵用来描述随机事件的不确定性的大小。熵值越大,不确定性就越高。等式的后半部分表明,熵实际上是log(1/p(X))的期望。假如p(X=xi)的概率为1/256,那么log(1/p(xi))=log(256)=8,也就是说要传输xi所表示的随机事件,需要用8个bit。而H(p)是对这个值的期望(加权平均),即平均信息长度。
 再来看看相对熵(即Kullback-Leibler距离):
http://blogs.sun.com/yongsun/resource/relative-entropy.png 
 它表示的含义是,如果一个随机变量X的分布为p,我们却用概率分布q为其编码,所要多使用的比特位。
 条件相对熵的计算公式为:
http://blogs.sun.com/yongsun/resource/conditional-relative-entropy.png 
 注:这个公式的证明,请参见 "Elements of Information Theory" (《信息论基础》,机械工业出版社)。
 下面来看看如何应用相对熵来对back-off语言模型进行剪枝。下面的分析源自 "Entropy-based Pruning of Backoff Language Models" 一文。
 回忆一下back-off模型的一般形式
 Ps(Wi|h)= Pd(Wi|h)        ~-- C(h,Wi) > 0
           bow(h).Ps(Wi|h')~-- C(h,Wi) == 0

 剪枝的目标,就是要从模型中删除某些有明确估计(explicit estimation,即C(h,Wi) > 0)的n-gram,以降低模型的参数规模,同时尽可能减少准确度的损失。(注意,在剪枝之后,剩余的那些有明确估计的n-gram,其概率并不改变。)
 剪枝的步骤是:

  1. 选取阈值,
  2. 分别计算剪去每个n-gram所引起的相对熵的增长
  3. 将增长幅度偏低的那些n-gram从模型中删除,然后重新计算back-off weights。

 假设从模型中删除(h, w)后,再计算p(w|h)时,就需要进行回退估计(back-off or implicit estimation)了。要保证概率模型的规整性,即sum_wi p(wi|h) = 1,bow(h)必须重新计算,记为bow'(h),则p'(w|h) = bow'(h).p(w|h')。同时,原模型中所有涉及h的回退估计都会受到影响,将这种情况记为BO(wi,h)(实际上就是C(h,wi) == 0的情况)。我们可以得到:
http://blogs.sun.com/yongsun/resource/relative-entropy-for-pruning-1.png 
 注意,h和w都是给定的、具体的。且除了(h,w)之外的,原先有明确估计的n-gram,其概率不变,因此相互抵消掉了。
 最后(推导过程见上面引用的那篇论文),我们可以得到:
http://blogs.sun.com/yongsun/resource/relative-entropy-for-pruning-2.png 
 其中,p(h) = p(h1)p(h2|h1)...,并且,我们知道:
http://www.sitmo.com/latex/latex2png.php?eq=\sum_{w_i:BO(w_i,h)} p(w_i|h) = 1 - \sum_{w_i:\neg BO(w_i,h)} p(w_i|h) 
 那么接下来的问题就是,如何计算bow'(h)了。
http://blogs.sun.com/yongsun/resource/bow(h) and bow'(h).png 
 计算bow'(h),就是将计算bow(h)公式分子分母中的求合式中,去掉已经剪枝的w。因为bow(h)是已知的,我们求得分子,也就可以得到分母,然后在分子和分母上分别加上p(w|h)和p(w|h')。这样就可以得到bow'(h)了。
 下面来看代码,
slmprune.cpp

CSlmPruner::Prune()

  从level[N]到level[1],调用PrunLevel(lvl)进行剪枝。完成后调用CalcBOW()重新计算level[0..N-1]上各节点的back-off weight。

CSlmPruner::PruneLevel(lvl)

  sz[lvl]是level[lvl]中节点的数量,其中包括一个尾节点(pseudo tail),减1为实际的节点数,赋值给n。cut[lvl]中是要剪去的n-gram的数量。分配TNodeInfo[n]数组,赋给pbuf。迭代level[lvl]中的节点,用一个for循环找出该节点的前继节点,然后将这个n-gram放到hw[0..lvl]中,并将各个level上的下标保存在idx[0..lvl]中。判断这个节点是否有子节点((pn+1)->child > pn->child),如果有子节点,则这个节点不可被剪掉。如果没有子节点,调用CalcDistance(lvl, idx, hw)计算剪掉这个节点所引起的相对熵的增量。将上述信息保存到pbuf中。继续迭代level[lvl]的下一个节点。

 迭代完成后,对TNodeInfo数组进行一个堆排序。然后迭代TNodeInfo数组的前cut[lvl]个元素,将level[lvl][pinfo->idx]上节点的概率值标为1.0。然后执行CutLevel(),将标记概率有1.0的节点从level[lvl]中删除,并修改level[lvl-1]前继节点的child下标。将level[lvl]新的大小赋值给sz[lvl]。清理分配的内存空间和成员变量,然后退出。

CSlmPruner::CalcDistance(lvl, idx[], hw[0..lvl])

  首先从parent节点上得到bow(h),保存在BOW变量中。然后计算p(h),p(h) = p(hw1).p(hw2|hw1)...p(hwlvl-1|hw1hw2...hwlvl-2),保存在PH变量中。然后level[lvl][idx[lvl]]节点上概率值,即为p(w|h),保存在PHW变量中。调用getPr(lvl-1, hw+2),得到p(w|h')的概率指,保存在PH_W变量中。

 如果cache_level不是lvl-1(那么只可能是-1),或者cache_idx不等于idx[lvl-1](那么只可能是-1),则将cache_level和cache_idx分别赋值,并且将cache_PA和cache_PB初始化有1.0。迭代父节点的所有子节点[parent->child, (parent+1)->child),得到每个子节点上的概率值pr(即p(wi|h)),并从cache_PA中减去;将hw[lvl]赋值为当前子节点的词id,调用getPr(lvl-1, hw+2),得到概率值p_r(即p(wi|h')),并从cache_PB中减去。迭代结束之后,PA = sum_{w_i:BO(w_i,h)} P(w_i|h),而PB = sum_{w_i:BO(w_i,h)} P(w_i|h')。然后分别加上p(w|h)和p(w|h'),相除得到bow'(h),保存在_BOW中。
 最后套用公式,得到D(p||p'),并返回。

CSlmPruner::CalcBOW(),CalcNodeBow(...)

  和上一节中的CSlmBuilder::CalcBOW()CalcNodeBow(...)基本雷同。就不再赘述了。

5. 语言模型线索化

 让我们来看看最后一个步骤,将剪枝后的语言模型线索化(threading)。
$ make m3_thread
./slmthread ../swap/lm_sc.3gm../data/lm_sc.t3g

 线索化的目的是为了加快语言模型的查找速度。以trigram为例,如果我们要计算句子“ABCDE。”的概率,P(S) = P(A).P(B|A).P(C|AB).P(D|BC).P(E|CD).P(<EOS>|DE),并假定这些条件概率的求解无需回退。
 首先我们从level[1](unigram中),可以通过二分查找,找到P(A);然后从A的孩子节点中,二分找到B,得到P(B|A);然后从B的孩子节点二分找到C,得到P(C|AB)。再接下来呢,我们要计算的是P(D|BC)。这时,我们只有重新在unigram中二分找到B,然后再在B的孩子节点中二分找到C,才能计算P(D|BC)的值。这无疑是很低效的。如果我们在计算P(C|AB)时,能够从它的节点上,直接定位到(B,C),就可以大大加快查找的速度。
 我们用(level, idx)来标识一个节点,节点上的信息用(W, h', p, b, c)来表示,其中:

  • W:跟在history后面的词ID。这个节点所在位置,还隐含了一个信息,就是它的历史由来。
  • h':指向下一步要回退到的节点,用(level', idx')来表示。
  • p:p(W|h)
  • b:bow(h)
  • c:其下一个level中后续节点的开始位置

 当然,叶子节点上是没有b和c的。现在,回退模型的结构,就从一棵树变成了一个网。它的基本操作是:在当前历史状态hi(level,idx)下,输入一个词W,则转移到一个新的历史状态hj(level', idx'),并返回P(W|h)。新节点hj的level',并不总等于level-1。例如,C(ABC)>0,但是(B,C)在训练时没有见到,则这个节点的h'就是level[1]上的C节点。如果C也没有统计到,则h'就是pseudo root节点(level[0][0])了。
 让我们考察下面一个片段。
                                 level+1
 h(level,idx)   ~----------->  W1,p1,h'1,[b,c]
    h',bow      \             ... ...
                  \            Wi,pi,h'i,[b,c]
                   \           ... ...
                    ~-------->  Wk,pk,h'k,[b,c]

 输入一个词W。如果W出现在h的孩子节点中,记为(level+1,idx'),其p值就是P(W|h)。
     如果该节点有子节点,那么(level+1,idx')就是新的历史节点;
     如果该节点没有子节点,则它的h',即为新的历史节点。
 如果W没有出现在h的孩子节点中,则从(level,idx)节点中的h'开始,继续进行上面相同的操作,找到新的历史节点,并将得到的概率值乘上bow(h),作为P(W|h)返回。注意,这是一个递归的过程。
 下面来看看代码:
slmthread.cpp
CSIMSlmWithIteration::getIdString(it, history)

  成员变量m_history中保存的是各个level中的index,将节点上的词ID保存到传入的history参数中。next()方法将m_history最后面的下标(即当前level的下标)加1,然后调用adjustIterator(),找出新节点各前继节点的下标,保存在m_history中。

CSIMSlmWithIteration::findBackOffState(n, hw, bol, bon)

  寻找某个n-gram(长度为n,保存在hw[1..n]中)的回退节点(h'),返回它的level和idx。调用findState()去查找hw[2..n]在level[n-1]上的下标idx,如果下标>=0且该节点(n-1, idx)有子节点,则返回h'的位置。否则调用findState()查找hw[3..n]在level[n-2]上的下标,...,如果循环到hw[n]还是找不到,则返回pseudo root节点。

 举例来说,查找trigram (A,B,C)的回退节点。查找(B,C)是否存在,如果存在则返回(2, idx_BC)。否则,查找(C)是否存在,如果存在则返回(1, idx_C)。否则返回(0, 0)。

CSIMSlmWithIteration::findState(n, hw)

  寻找n-gram(长度为n,保存在hw[1..n]中)在level[n]上状态节点的下标。如果该n-gram没有找到,则返回-1。

 在threading的过程中,还对32位浮点数进行了压缩。其中bow被压缩到14个bits,pr被压缩到16个bits。大致思想是,对在bow(或pr)中出现的所有浮点数进行统计,将距离比较近的浮点数进行“合并”,将浮点数的总数控制在1<<BITS_BOW(或1<<BITS_PR)内;在生成的语言模型二进制文件中,会有两个浮点数的表,通过查表来得到原来的浮点数值。我对这个算法的细节还不是很清楚,有机会请原作者张磊来讲解一下。
 现在,我们就得到了最终的语言模型--lm_sc.t3g。你可以用tslminfo来查看它的信息:
$ make m3_tslminfo
./tslminfo -p -v -l ../raw/dict.utf8../data/lm_sc.t3g>../swap/lm_sc.t3g.arpa
 $ tail ../swap/lm_sc.t3g.arpa
 点击 率 也  0.036363638937 (2,839922)
 点击 率 最高  0.081818044186 (2,840054)
 点击 率 的  0.096969485283 (2,840080)
 点击 率 达到  0.036363638937 (2,840122)
 点击 量 达  0.485714286566 (2,1132198)
 点击 鼠标 ,  0.400000005960 (1,1)
 点击 鼠标 左  0.309090882540 (2,1186378)
 率队 取得 <unknown>  0.479999989271 (2,366031)
 率队 在 <unknown>  0.130769222975 (2,431213)
 率队 打 了  0.479999989271 (2,642183)

 我们可以通过CThreadSlm类来访问或使用它,下一节将以slmseg为例,看看如何利用最终生成的语言模型来进行搜索。

6. 如何使用生成的语言模型

 我们在上面的步骤中,已经得到了线索化(threaded)的语言模型。那么如何来访问或使用这个模型呢?让我们看看slm.h文件,CThreadSlm类的public方法,只有下面几个:
  1. load():将语言模型加载到内存中
  1. free():释放语言模型所占据的内存
  1. isUseLogPr():是否使用log值作为概率值
  1. transfer(history, wid, result):从历史状态history,给定输入wid,转移到一个新的状态result,并返回P(wid|history)的概率值。
  1. transferNegLog(history, wid, result):和上面的方法一样,只不过返回的是-log(p(wid|history)。
  1. history_state_of(st):求得状态st的h',并返回。
  1. historify(st):将状态st设置为它的h'。
  1. lastWordId(st):返回状态st的最后一个词的id。设st为(lvl, idx),如果lvl>=N,则返回m_Levels[N][idx];如果0<lvl<N,则返回m_level[lvl][idx];如果lvl==0,且idx==0,则返回pseudo root的wid(即0),但如果idx>0,就返回idx(我们将来在介绍输入法的history cache时,会涉及到这种情况)。
 让我们看看slmseg如何使用CThreadSlm类来进行基本的搜索。
$ make slmids3
./slmseg -d ../raw/dict.utf8-f bin -s 10 -m ../data/lm_sc.t3g../raw/corpus.utf8>../swap/lm_sc.ids
 来看slmseg/slmseg.cpp的代码:
struct TLatticeWord {
   int m_left;
   intm_right;   //[m_left, m_right)为这个词在lattice上的位置范围
   intm_wordId;  //词的ID
 };
 struct TLatticeStateValue{
  double            m_pr;      //该状态节点上的概率值。
   TLatticeWord*     mp_btword;  //回溯的(back-trace)词,即由哪个词跳转到当前状态的
   CThreadSlm::TStatem_btstate;  //回溯的(back-trace)状态节点,即由哪个状态跳转过来的
 };
 typedef std::map<CThreadSlm::TState,TLatticeStateValue>TLatticeColumnStates;
 struct TLatticeColumn {
   TLatticeWordVecm_wordstarting;  //起始于该列上的词。其实就是在扩展本列状态节点时,将要输入的词。                                 //转移得到的新状态节点,位于后面的lattice[word.m_right]上。
   TLatticeColumnStatesm_states;   //该列上的状态节点及其对应的状态信息
 };
   TLatticeColumnStatesm_states;   //该列上的状态节点及其对应的状态信息
 }; 
processSingleFile():
  逐句读取文件。对每个句子,调用buildLattice()构建搜索需要的lattice(栅格),然后调用searchBest()进行搜索,通过getBestPath()得到最佳的切分,最后调用output()将其输出。
buildLattice(sentence, lattice)
  将lattice[0]上的状态,设置为pseudo root。将i初始化为0,对sntnc[i..n]进行最大正向匹配,得到词长len。然后调用getAmbiLen()得到最大交集歧义长度ambilen。如果ambilen<=len,则无交集歧义,调用insertLatticeWord(),将分词得到的词([i,i+len),Wid),加入到lattice[i]的词列表中;令i=i+len,继续循环。如果有歧义,则调用fullSegBuildLattice(),对sntnc的[i..i+ambilen)子句进行全切分,将所有可能的词划分(包括所有的单字词),假设其位置为[left, right),加入到lattice[left]中;然后i=i+ambilen,继续循环。遍历完整个句子之后,在最后面加入一个句子结束的标识(下面的例子中,我们用“。”来表示)。
 我们还借用第二回中的例子:
http://blogs.sun.com/yongsun/resource/slmseg_build_lattice.png
  对例句进行最大正向匹配,得到的词为“发扬”,且无歧义,则将“发扬”加入到lattice[0]上的词列表中。然后i=2,继续循环。FMM得到的词是“为人民”,但是歧义长度为6,则需要对sntnc[2..7]进行全切分。将idx由2向7移动,将idx上所有可能的词(包括单字词),加入到lattice[idx]中。然后i=2+6=8,继续循环。FMM得到的词为“的”,且无歧义。然后i=9,最大正向匹配得到的词为“精神”,且无歧义。循环结束,最后在末尾加上一个句子结束的ID。可以看到lattice[1]上没有词,而lattice[2]中的词有:“为”、“为人”和“为人民”。依次类推...
searchBest(lattice)
  从0开始,遍历lattice。对lattice[i]上的所有状态节点,用该列上的各个词([left, right), Wid),来扩展它(调用CThreadSlm::transferNegLog()),将新状态节点的h'(CThreadSlm::historify(his)),保存在lattice[word.m_right].m_states中。注意,两个不同的状态节点,例如(A, C)和(B, C),给定一个输入词D,可能得到相同的h'(即(C, D)),那么这两条路径中只保留概率大者就可以了。
http://blogs.sun.com/yongsun/resource/slmseg_search_best.png
  首先从lattice[0]的states开始,我们在buildLattice()时,已经将它设置为pseudo root节点(0, 0)了。lattice[0]上只有一个词,“发扬”;调用CThreadSlm::transferNegLog((0,0), wid_of("发扬")),得到新的状态节点和概率值(P("发扬")),将该节点的h'(lvl=1, idx),及其状态信息(概率值和回溯信息),放到lattice[2]上。然后查看lattice[1],它上面没有要扩展的状态节点。进而判断lattice[2],有一个状态节点,并有三个词(“为”,“为人”,“为人民”),将三个新的h'状态节点,分别保存在lattice[3],lattice[4]和lattice[5]上。依次类推...
 这是一个动态规划(dynamic programming)问题,这里使用的是Viterbi网格(Viterbi Lattice)算法。
getBestPath(lattice, segResult):
  从后向前,回溯lattice,将回溯的词id放到segResult中。最后再
将segResult翻转一下,就得到了最佳的切分。对本例来说,就是上图中的蓝色路径。

7. 生成拼音词表

 slm模块的最后一步是,生成拼音词表(lexicon):
$ make lexicon
./genpyt ../raw/dict.utf8../data/pydict_sc.bin../swap/pydict_sc.log.utf8../data/lm_sc.t3g
 这一步操作包括,建立一个支持不完全拼音的键树(Key Tree);将节点上的候选词,按语言模型中的unigram进行排序。
 我 们这里还要说明一下,在训练语言模型时对多音字词的处理。在处理语料库时,理想的情况下,应该将不同的读音视为不同的词ID。但是要准确地进行拼音标注, 本身就十分困难,特别第一遍用FMM分词训练语言模型时。我们使用了下面的折衷方法:在词典文件dict.utf8中,我们对读音的常见程度进行了标记。 例如“长”,它的两个读音“zhang”和“chang”都比较常见,分别被标为1;而“她”(ta),另一个读音“chi”则十分罕见,因此将其标为- 1。在训练时,我们将所有遇到的“她”,都认为是“ta”,然后为“chi”的读音分配一定的概率,并为其分配一个新的词ID。
 下面来看代码:
lexicon/pytrie_gen.cpp:
CPinyinTrieMaker::constructFromLexicon(fileName):
  将 词典文件的每一行读取到buf中,然后调用parseLine()进行解析,返回词的buf(word_buf,UTF-8编码)、ID和发音(可能有多 个,每个发音有一个cost值,缺省为0)的集合--pyset。判断这个词的编码类型,0x0为GB2312,0x1为GBK,0x2为 GB18030。将m_Lexicon[id]赋值为word_buf。遍历这个词的所有读音,调用insertFullPinyinPair(t, wid),将拼音串为t的词wid,插入到trie树中。在处理完词典文件中的词之后,调用threadNonCompletePinyin(),扩展不 完全拼音的节点。
CPinyinTrieMaker::insertFullPinyinPair(pinyin, wid):
  将pnode 设为trie树的根节点。遍历pinyin串,如果*p=='\''(即音节分割符),则将pnode节点的 m_bFullSyllableTransfer设置为true。调用insertTransfer(pnode, *p),在pnode的子转移节点(m_Trans )中查找是否已经存在*p,如果不存在,则创建一个新节点,累计到m_AllNodes,并赋值给m_Trans[c];最后返回*p对应的节点(依旧赋 值给pnode)。将pnode的m_bExpanded设为true,并插入到其自身的m_PrimitiveNodes中。遍历结束后,如果 pinyin串不是以'\''结尾,则插入一个音节分割符的节点。最后,调用insertWordId(),将wid插入到pnode的 m_WordIdSet中。
 在将所有完全拼音节点插入完毕之后,trie树上的所有节点(累计在m_AllNodes),都被标记为已扩展,且其m_PrimitiveNodes中,只有其本身一个节点。
CPinyinTrieMaker::threadNonCompletePinyin():
  遍 历m_AllNodes中的节点,如果这个节点还没有扩展过(也就是后来加入的不完全拼音的节点),则调用expandNode(pnode),扩展该节 点。如果这个节点的syllable prefix(音节前缀)长度>0,且这个前缀不是一个完整音节(即没有被注册到m_FullSyllables中),并且pnode的转发子节点 中没有'\'',则调用addNonCompleteSyllableTransfer()为pnode节点添加不完整拼音的转发节点。
CPinyinTrieMaker::addNonCompleteSyllableTransfer(pnode):
  调用findSyllableChildren(),在pnode->m_PrimitiveNodes个节点的子树(m_Trans)中,递归查找同级的音节边界节点。如果pnode的音节前缀是"c"、"z"或"s",则查找时忽略'h'的子树。
 例如我们有:
[[image:http://blogs.sun.com/yongsun/resource/lexicon_example.png]] N0节点的primitive node就是自身,syChildren为N1和N2两个节点。
 在m_StateMap 中查找syChildren(本例中是N1和N2两个节点的集合)对应的节点。如果不存在,则创建一个新的TNode节点pChildNoe,将它加入到m_AllNodes的尾部,其 m_PrimitiveNodes为syChildren,且其m_bExpanded为false。将syChildren中各节点的候选词,合并到新节点中。最后将pChildNode作为pnode节点中的'\''转移。即有:
[[image:http://blogs.sun.com/yongsun/resource/lexicon_example1.png]] 再 如,/--c--a--'--h--a--n--'--(擦汗..)在这条路径上,当遍历到节点N3时,找到的音节边界节点为N5,它可以在m_StateMap中找到(当 初在insertFullPinyinPair()时,所有节点的primitive node都登记在m_StateMap中了)。因此N3节点上新加入的'\''转移,直接指向了尾节点。
CPinyinTrieMaker::expandNode(pnode):
  遍 历pnode节点的 primitive nodes,将每个节点的转移节点(除'\''外),都并入到combTrans中。例如,要扩展上图中的N7节点(即c')节点,其primitive nodes为N1和N2,遍历它们各自的转移节点,并入到combTrans中。combTrans['h']中有两个节点,分别为上图的N3和 N4,这两个节点作为一个集合在以前没有遇到过,因此创建一个新TNode节点pChildNode,将它的primitive nodes设置为[N3, N4],且其m_bExpanded为false,然后加到m_AllNodes的尾部,并作为N7对'h'的转移节点,但它自己的转移节点尚为空,这个新节点以后也将被扩展。 combTrans['d']中只有一个节点--N6,且已经见过,将它直接作为N7对'd'的转移节点。
http://blogs.sun.com/yongsun/resource/lexicon_example2.png
 让我们看看最后扩展的结果:
 c' 采 擦 才 嚓
 c'd' 菜单
 c'da' 菜单
 c'dan' 菜单
 c'h' 才华 擦汗
 c'ha' 擦汗
 c'han' 擦汗
 c'hu' 才华
 c'hua' 才华
 ca' 擦 嚓
 ca'h' 擦汗
 ca'ha' 擦汗
 ca'han' 擦汗
 cai' 采 才
 cai'd' 菜单
 cai'da' 菜单
 cai'dan' 菜单
 cai'h' 才华
 cai'hu' 才华
 cai'hua' 才华
 ch' 查 察
 cha' 查 察
CPinyinTrieMaker::write(fp, psrt):
  遍 历所有m_AllNodes中 的所有,计算每个节点保存到文件中的偏移量,记录在nodeOffsetMap中。遍历m_Lexicon中的词,记录要保存这些词所需要的空间。再次遍 历m_AllNodes,如果节点上的m_WordIdSet不为空,则用传入的psrt(CWordEvaluator,实际使用的是 CUnigramSorter),得到每个词的cost(即unigram的概率);然后对该节点上的候选词表进行排序。将节点输出到文件中。在遍历完 m_AllNodes之后,把词表m_Lexicon也输出到文件中。
 最终生成的词表文件,保存到data/pydict_sc.bin中,我们可以通过CPinyinTrie类来访问它。CPinyinTrie中包括加载词表文件,释放内存空间,以及若干的transfer()方法:即输入一个拼音字符(或字符串),从当前状态(亦可能是root),转移新的状态节点。在后面介绍ime部分的代码时,会涉及到对这个类的使用。
 至此,slm部分的代码就基本介绍完了,希望能对大家了解n-gram语言模型、和SunPinyin训练语言模型的代码细节,有稍许帮助。

原文出自:


  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
以下是在Ubuntu上安装和配置Emacs的步骤: 1. 打开终端并输入以下命令以更新软件包列表: ```shell sudo apt update ``` 2. 输入以下命令以安装Emacs: ```shell sudo apt install emacs ``` 3. 安装完成后,您可以通过输入以下命令来启动Emacs: ```shell emacs ``` 4. 如果您想要在Emacs中使用中文输入法,请安装fcitx输入法框架和fcitx-emacs插件。输入以下命令以安装它们: ```shell sudo apt install fcitx fcitx-config-gtk fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-ui-classic fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx-sunpinyin fcitx-table-all fcitx-frontend-all fcitx-pinyin fcitx-module-x11 fcitx-config-gtk2 fcitx-module-dbus fcitx-module-kimpanel fcitx-module-x11 fcitx-ui-classic fcitx-module-cloudpinyin fcitx

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值