What’s New in SunPinyin2 — CIMIContext

原先SunPinyin是使用CBone/CSkeleton来组织search lattice的,参见sunpinyin代码导读(九)。每一个Bone对应一个syllable(或者严格的说,是一个segment),而CSkeleton是CBone的双向链表(std::list)。这个结构不太适合对模糊切分的支持。因此在SunPinyin2中,lattice不再以syllable为单位了,而是以单个的拼音字符为单位。如下图所示:

sunpinyin2_lattice_search

我们定义了一个CLattice的类,表示整个的search lattice,其对应于原来的CSkeleton。将每个column称为一个CLatticeFrame,对应于原来的CBone/CBoneInnerData。而TLexiconStateTLatticeState和以前的差别不大,只是把位置信息从iterator换成了index。另外为了支持用户词典,在TLexiconState加入了一些相应的字段,例如m_wordsm_syls

CIMIContext类的主要入口是buildLattice (segments, rebuildFrom, doSearch)。rebuildFrom这个参数值其实就是IPySegmentor::updatedFrom() + 1,因为在lattice上,拼音字符串是从index 1开始的,而在切分器中是从0开始的。buildLattice遍历segments(由IPySegmentor::getSegments所返回的),跳过rebuildFrom之前的那些segments,然后调用_forwardXXX方法,build每个目标frame上的lexiconStates。直到segments的结束或者超过一定长度,调用_forwardTail处理结尾。然后,在doSearch为true的情况下,调用searchFrom进行搜索。

CIMIContext类中的_forwardXXX方法,和原来的forwardXXX方法大同小异。_forwardSingleSyllable中对用户词典做了一些额外的处理。因为我们现在的用户词典是基于sqlite实现的,不是一棵trie树,所以和基于trie树的系统词典处理起来有所不同。例如,用户词典中有“测试拼音”这个词,当用户输入到ceshipin的时候,从数据库中得到的词的列表是空的,且对系统词典(CPinyinTrie)的transfer也会返回一个空节点。但这种情况下,我们不能drop掉ceshipin这个音节序列,还是要把它加入到目标latticeFrame的lexiconStates中

CIMIContext::searchFrom也和前作基本相同,都是通过frame上的lexiconStates,调用_transferBetween方法来构建latticeStates,同时调用语言模型进行句子的评分。最后调用_backTraceBestPath,把最佳路径标识出来。稍有不同的是,searchFrom对fuzzyForwarding进行了一些处理,收窄了易混淆音节的搜索范围并降低了其初始的评分

其他的方法,例如_transferBetween, getBestSentence, getCandidates都和之前的实现大体相同。cancelSelectionmakeSelection得到了简化。memorize方法加入了将<=6个字符的短句(其中含有用户选择的),作为用户词条保存的功能。

另外CIMIContext有两个助手类,CGetFullPunctOpCGetFullSymbolOp,用来处理标点符号和字符的全角转换。这两个类也是可配置的,并且CIMIContext的所有实例都share同一份拷贝。因此,也属于shared & global的配置选项。

新版本的CIMIContext,在将拼音切分剥离之后,其职责更加明确和清晰;采用新的数据结构之后,逻辑也更简单和直观。代码行数也由原来的1.5k下降到0.6k;同时可配置性也得到了提升。

下一篇我会介绍一下用户词典的实现。

What’s New in SunPinyin2 — Quanpin Segmentor

如在上篇介绍的那样,SunPinyin原先是借助CPinyinTrie来进行拼音切分的,因为CPinyinTrie是一棵前缀树,因此切分是按最大前向匹配的方式进行的。还有一些额外的处理,例如fangan会被切分为fan'gan,但是dier是被切分为die'r的(我们也因此收到了很多用户的反馈)。在SunPinyin2中,我们依然借助一个trie树来进行音节切分。不过这个tire树是由所有有效音节组成的一棵后缀树。因此,我们的切分原则是以最大后向匹配为基础的。

简单的后向匹配还是有一些问题,例如zhonge(中俄),会被切分成zh'o'n'ge,如果你留意FIT或者ibus-pinyin,就会发现他们都有上述的问题。那这个问题在SunPinyin2中的全拼切分器是如何解决的呢,用户在输入g的当下,切分结果是zh'o'n,加入g之后,segments发生了向前的合并,那么我们就把'g'换成'G'(实际的处理是高位置1)放到内部匹配的buffer中。那么下次用户在输入e的时候,最大后向匹配zhonGe的结果,就会产生正确的结果了。还有一种可能性要处理,例如feng,fe不是一个合法的音节,所以在输入n的当下,切分的结果是f'e,在输入n之后,音节发生了向前的合并,因此N被加入到内部匹配的buffer中,变为feN,但是再输入g时,最大后向匹配却无法返回正确的切分结果了。所以,所以我们还要尝试在某些情况下,将N临时改为n再match一次,如果得到的segment长度是原先的长度+1,就接受这个新的结果,否则再把n改回到N。

我们前面提到了,SunPinyin2使用一棵后缀trie树进行拼音切分的。trie树可以通过逐级二分来实现(如果是内存中的,可以是hash或map)。为了尽可能地保障切分器的效率和性能,我们使用了Double Array Trie(DAT)的方式。DAT的优势在于,可以直接load/mmap到内存中,并且查询子节点的复杂度是O(1)的,缺点是后续的插入和删除比较复杂。因此十分适合作为构造静态词典的方法。在SunPinyin2中,DAT的构造是用python实现的。我们定义了一个C++的模板类CDATrie来访问DAT,其中match_longest (first, last, length)是最核心的方法。读者有兴趣可以去查看相关的代码和资料,这里就不赘述了。

接下来我们看看CQuanpinSegmentor的实现细节。CQuanpinSegmentor有两个助手类,CGetFuzzySyllablesOpCGetCorrectionPairOp。这两个助手类分别用来得到易混淆音(例如zhan -> zan/zang/zhang),和纠错结果(例如ign -> ing)。这两个类都是可配置的。并且所有的CQuanpinSegmentor实例,应该共享相同的助手类的实例。这种可配置项,我们称为shared & global的,在用户修改用户配置的时候,不会影响已经创建的那些会话(sessions)。我们后面还会看到,有些配置项是non-shared  but global的,对这种配置项的更改,需要重新创建已存的那些会话。这是后话,暂且不表。

这两个助手类比较直观,这里也就不赘述了,读者可以参考相关的代码。

CQuanpinSegmentor的核心是_push方法。目前的实现中,deleteAt/insertAt都是用_push方法来实现的,也就是把修改位置后面的字符串重新push了一遍。这个实在是比较粗笨,应该还有一定的改进空间。不过因为用户输入的拼音字符串长度有限(缺省的设置是512个字符),性能目前还是可以接受的。

_push方法首先将传入的字符ch,push到m_pystr中,然后调用m_pytrie的match_longest (m_pystr.rbegin(), m_pystr.rend(), l)方法进行后向匹配,匹配的结果主要有以下几种情况:

  1. l == 0,表明ch不是一个有效的音节,根据字符的特征选定一个seg_type,创建一个长度为1的segment并push到m_segs中。
  2. l == 1,可能是一个新的segment,但是如果是上面类似feNg这样的情况,就修改上一个segment的属性。否则,创建一个新的长度为1的segment,并push到m_segs中。
  3. l == m_segs.back().m_len + 1,也就是说ch可以扩展上一个segment,那么修改上一个segment的属性。
  4. 其他情况,一种是 l > m_segs.back().m_len + 1,例如zhon+g,l = 5,但是上一个segment(n)的长度是1。另一种情况是 l < m_segs.back().m_len + 1,例如die+r,l=2,但是上一个segment(die)的长度是3。这两种情况都会涉及合并或拆分现有的segments。并且在此过程中,可能会引起进一步的合并和拆分动作。因此我们用一个while循环来追赶,直到达到一个既有的音节边界为止。

最后,所有的分支都会跳到RETURN,进行post-editing的操作,这里我们只有一个post-editing的action,就是处理易混淆音。

SunPinyin2中的双拼切分器是由William来实现的,期待他会写一篇文档来总结一下他的实现细节。我将在下一篇介绍对CIMIContext类的改造。

What’s New in SunPinyin2 — Pinyin Segmentor

在原先SunPinyin的ime部分中,拼音切分是借助CPinyinTrie来完成的,同时紧密地耦合在CIMIContext类中(注:CIMIContext类是输入引擎的核心,主要负责根据拼音字符串建立一个search lattice并进行搜索,并处理用户的user selection,以及获取某个位置开始的candidates,参见sunpinyin代码导读(九))。

这为我们添加新的拼音方案(例如双拼)带来了极大的不便,所以在2.0中,我们将拼音切分从CIMIContext中剥离了出来,定义了一个统一的接口——IPySegmentor。要为SunPinyin2添加一个新的拼音方案,就是要实现一个IPySegmentor的接口(当然,如果该方案无法和全拼一一对应,例如粤拼,还需要自行定义自己的lexicon,参见上篇)。

让我们来看看这个IPySegmentor接口:

struct IPySegmentor
{
    enum ESegmentType
        {SYLLABLE, SYLLABLE_SEP, INVALID, STRING,};
    struct TSegment {
        std::vector<unsigned>   m_syllables;
        unsigned                m_start        : 16;
        unsigned                m_len          : 8;
        ESegmentType            m_type         : 8;
    };
    typedef std::vector<TSegment>  TSegmentVec;
    virtual TSegmentVec& getSegments () = 0;
    virtual wstring& getInputBuffer () = 0;
    virtual const char* getSylSeps () = 0;
    virtual unsigned push (unsigned ch) = 0;
    virtual unsigned pop () = 0;
    virtual unsigned insertAt (unsigned idx, unsigned ch) = 0;
    virtual unsigned deleteAt (unsigned idx, bool backward=true) = 0;
    virtual unsigned clear (unsigned from=0) = 0;
    virtual unsigned updatedFrom () = 0;
    virtual void locateSegment (unsigned idx, unsigned &strIdx, unsigned &segIdx) = 0;
};

拼音切分器的任务是,将用户的输入切分成TSegment(s),例如用户的输入是“xi'anshiAi”,那么得到的segments是,[(xi), s:0, l:2, SYLLABLE], [(), s:2, l:1, SYLLABLE_SEP], [(an), s:3, l:2, SYLLABLE], [(shi), s:5, l:3, SYLLABLE], [('A'), s:8, l:1, STRING], [('i'), s:9, l:1, INVALID]。之所以区分INVALID和STRING,是为了preedit可以考虑用不同的颜色来显示它们。

如果切分器支持易混淆音(例如z-zh, c-ch, s-sh),一个segment可能会有多个syllables。例如,对上例中的shi,对应的segment为[(shi, si), s:3, l:3, SYLLABLE]。注意,CIMIContext会将m_syllables的第二个及之后的音节视为是易混淆音,而在搜索的宽度和句子评分方面有所降低。

细心的读者可能发现了,在上面的例子中,start的信息好像是冗余的。但如果切分器支持模糊切分,例如fangan -> fang'an, fan'gan,那么TSegment的m_start就有用武之地了。并且隐含要求,TSegmentVec中的segments是按照m_start从小到大排序的。不过,现有的CIMIContext还需要一些改进,需要根据搜索的最优路径来返回正确的切分路径。

下面我们来看一看IPySegmentor的主要接口。因为用户输入、删除或编辑,是以单个字符为单位进行的,我们的接口也是按照这种风格设计的。例如push,是指用户在尾部添加一个字符,这个方法的返回值是,指示segments从拼音字符串的什么位置发生了更新。这个更新值非常重要,考虑下面的示例,

Input | Segments | UpdatedFrom
------+----------+------------
z     | z        | 0
zh    | zh       | 0
zho   | zh, o    | 2
zhon  | zh, o, n | 3
zhong | zhong    | 0

可以看到,在用户输入g时,原本3个segments被合并成了一个,且返回值为0,表示segments从拼音字符串0这个位置发生了更新。这个返回值,和updatedFrom方法返回的是一致的,只是为了client调用方便而添加的。之所以需要这个updatedFrom值,是出于性能的要求:CIMIContext希望在用户输入/编辑之后,只重新build产生更新部分拼音字符串对应的lattice,而不需要从新build整个lattice。

其他的几个online editing方法,也是类似的。pop指从尾部删除一个字符,insertAt/deleteAt分别表示从中间位置插入/删除一个字符。之所以区分push/insertAt,或pop/deleteAt,是因为push/pop可能有很多优化的空间。locateSegment返回拼音字符串的某个位置(idx),其所在音节的起始位置(strIdx),和segments vector中的下标(segIdx)。getSegments返回一个TSegmentVec的引用,即切分的结果。getInputBuffer返回用户输入的原始数据,getSylSeps返回这个切分器支持的音节切分符。

目前SunPinyin2中实现了两个切分器,分别为CQuanpinSegmentorCShuangpinSegmentor。其中CQuanpinSegmentor支持易混淆音、自动纠错等功能,且这些功能都是可配置的,但尚不支持模糊切分。我们将在下一篇介绍CQuanpinSegmentor的实现。

What's New in SunPinyin2 -- Lexicon

为了在SunPinyin2中支持不同的拼音方案,甚至包括注音、粤拼等不同的系统,我们改变了以前使用拼音字符作为trie树索引key的方式,而使用音节编码作为trie树的key。

我们定义了一个TSyllable的数据结构,来表示一个音节:

struct TSyllable {
    unsigned other    : 12;
    unsigned initial  : 8;
    unsigned final    : 8;
    unsigned tone     : 4;
}

这里只显示了big-endian时的结构布局,other是padding用的,initial表示的声母,final表示的是韵母,tone自然就是声调了。

相应地,我们修改了pytrie_gen.{h|cpp},主要集中在insertFullPinyinPair, threadNonCompletePinyin, 并增加了parseFullPinyin, combineInitialTrans, addCombinedTransfers, expandCombinedNode等辅助方法;CPinyinTrieMaker类中其他的方法基本没有什么修改。

让我们看看代码:

parseFullPinyin (pinyin, ret):

解析传入的拼音字符串(pinyin),调用CPinyinDataencodeSyllable静态方法,将得到的音节编码(TSyllable),并顺序保存到ret中,最后返回。

CPinyinTrieMaker::insertFullPinyinPair (pinyin, wid):

调用parseFullPinyin,将拼音字符串转换为音节编码(TSyllable)的序列,然后调用insertTransfer将这个音节的分支加入到trie树中,并调用insertWordId更新叶子节点上的word ID列表。

CPinyinTrieMaker::combineInitialTrans (pnode):

遍历trie树节点pnode上所有的自节点(m_Trans),将具有相同声母(initial)的子节点,收集到一起。然后调用addCombinedTransfers方法,将某一个声母对应的若干子节点,作为一个特殊的节点(尚未扩展的复合节点),加入到原来的trie树中。例如,

(N0)--->shang--->(N1)
  | \
  |  +->sheng--->(N2)
  |
  +---->sh------>(N3_{N1,N2})

CPinyinTrieMaker::addCombinedTransfers (pnode, s, nodes):

如果节点集(nodes)的size为1,则直接在pnode上将s的转发分支指向那个节点。举例来说,N0只有一个shang的子路径,但是我们依然希望为N0加入一个sh的转发路径。不过我们不需要创建新的符合节点,直接把这个转发指向N1就好了。

如果集合size>1,就创建一个新的节点(m_bExpanded缺省是false),其m_cmbNodes成员赋值为nodes,然后在m_StateMap中记录一下,已经为这个节点集创建过新节点了(即p),最后把nodes上所有的候选词IDs,merge到新的子节点上。最后,将pnode上的s转发分支指向新的复合节点。

CPinyinTrieMaker::expandCombinedNode (pnode):

遍历pnode的复合节点,整合它们的子转发路径,并创建新的符合节点,插入到trie树中。

例如,扩展N3节点,将N1和N2的diao转发合并,创建一个N7_{N4,N6}新的复合节点,作为N3节点“diao”的转发路径。因为只有N1有“hai”的转发路径,直接将N3节点上“hai”的转发路径指向N5节点。

(N0)---->shang--->(N1)---diao--->(N4)
  |\               \
  | \               +----hai---->(N5)<--------------+
  |  \                                              ^
  |   +->sheng--->(N2)---diao--->(N6)               |
  |                                                 |
  +----->sh------>(N3_{N1,N2})                      |
                   |  \                             |
                   |   +----diao--->(N7_{N4,N6})    |
                   |                                |
                   +--------------hai-------------->+

CPinyinTrieMaker::threadNonCompletePinyin:

遍历m_AllNodes,如果该节点的m_bExpanded为true,则调用combineInitialTrans,对该节点进行声母合并的操作,其间创建的新节点会append到m_AllNodes。如果m_bExpanded为false,则是一个复合节点,调用expandCombinedNode,将复合节点的子节点进行合并操作,其间创建的新节点也会append到m_AllNodes。最后,这棵支持不完整拼音的trie树就构建完成了。

另外,对CPinyinTrie这个类也进行了相应的修改,使其按照TSyllable来进行索引。

香屯游

周五和Sun的同事一起去延庆的香屯村爬野长城,由于当天是儿子的生日,要回沧州接儿子返京,所以只参加了上午的活动。现在真是体力不济,爬得很吃力。中午在村民家里吃的午餐,大家狼吞虎咽,柴鸡很美味,可惜菜量小了些,呵呵 ...

I'm leaving Sun ...

It's the hardest decision I ever made, that I'm leaving Sun, tomorrow is my last working day. It's the best ~6 years in my life and career, I really enjoyed working in such a great company, and with you guys, the most talented people. Thanks for your great supports and help for the years.

I will continue to contribute to oso-inputmethod and SunPinyin project as much as I can, as I can not stop my love of SunPinyin. :)

As you may have seen, I'd setup a new blog at http://yongsun.me,  keep in touch ...

EMAIL: mail@yongsun.me
GTALK: findsun@gmail.com
MSN: findsun@hotmail.com
FACEBOOK: http://facebook.com/sunyong
TWITTER: http://twitter.com/yongsun

Released the developing source code of SunPinyin-2.0 project

We just released the developing source code of SunPinyin-2.0 project!

We re-wrote the engine part, to support more pinyin schemes and user dictionary. Thanks to William for his contribution. So far, we are about finishing the core logics and functionalities, and you could play with the test application (gtk_standalone) to have a try. :)

You could update your local repository, or clone ssh://anon@hg.opensolaris.org/hg/nv-g11n/inputmethod, if you don't have one on your disk. Then follow the steps as described,

$ cd sunpinyin2
$ ln -s ../../sunpinyin/ime/data/lm_sc.t3g.le data/lm_sc.t3g.le
$ ./autogen.sh --enable-debug --disable-cle
$ cd build
$ make genpyt
$ make lexicon
$ cd ../wrapper/gtk_standalone/
$ make
$ ./sunpinyin

sunpinyin-2.0-demo

You are welcome to review the code, join the development, or port it to various input method frameworks or operating systems!

西藏游小记

老婆上周有时间休假,准备出游。突发奇想,决定去西藏转转,一尝夙愿。咨询了上个月刚去过西藏的Paul同学,在携程订了往返的机票和当日的酒店,怀揣着几篇攻略就出发了。基本的规划是要去林芝和纳木措,但具体的行程要到拉萨之后找旅行社联系,才能最终敲定。去林芝准备采用租车的方式,中途的住宿也要由司机指引安排。我还真是不太习惯这种无既定行程的旅游方式,觉得如果没有把行程都确定好,特别是没有把住宿都安排好,就心里发毛。

刚下飞机还没有什么高原反映,不过坐机场巴士到拉萨之后,弯腰去提行李,起得猛了些,头马上就晕了,坐在台阶上休息了一下才好一些。然后去东措院里的红山旅行社,谈包车。临行之前在网上搜到了好几个拉萨/西藏红山旅行社,打电话过去报价都蛮高的,后来从Paul同学那里找到联系人的电话。当时头只是有些隐约的疼。听旅行社老板的劝,决定先在拉萨休整一天,然后第三天去林芝。下午3点多才到旅馆,小睡了一会儿,高反反而严重起来了。头痛欲裂,呼吸不畅,嘴唇发紫。还好老婆没什么反应。晚饭我也没出去,老婆帮我买了点水果。

第二天起来,头还是很痛,硬着头皮跟散客团拉萨一日游。去了布达拉宫和大昭寺。中途还配合导游进了四个店,也买了些东西。主要是浪费了许多时间。布达拉宫西侧有一家酸奶坊,里面的酸奶很好喝。当晚,有个女学生联系我们拼车,后来她又联系到一位女士同行。旅行社也把原来4000元的包车费提高到了4500元,由那位大姐重新签了合同。晚上在拉百外面的小吃摊吃饭时,碰到两位教师夫妇,他们已经是第三次进藏了,两人自驾几乎去到了祖国所有的高原,真是令人羡慕啊。

第三天去林芝,一路上都在下雨。沿途尼洋河的景色本应该是很美的,不过因为下雨,河水都是混混的。川藏线的路况也非常糟糕和凶险。右侧的悬崖近在咫尺,在车道很狭窄的地方还经常堵车。这一天只去了天佛山瀑布,虽然海拔3000多米的瀑布十分罕见,不过这个景区实在是没什么意思,门票还死贵。晚上到八一镇,因为是旺季,酒店不是很好找,价钱也较平时高了很多,而且司机也不给安排住宿。无奈我们住了三个标间。

第四天继续赶路,中间去藏民家里坐了一会儿。一对老夫妇很淳朴、和善,接待了我们,还带我们到各个屋子参观。老天爷在傍晚终于开了眼,让我们可以一睹米堆冰川的容颜,还开了块蓝天给我们。晚上住在然乌,各个旅馆都住满了,好不容易找到一间,条件还比较差,只好凑合一晚。

第五天去附近的然乌湖,天公不作美,下雨。往日漂亮的然乌湖,现在灰蒙蒙一片。连司机师傅都很郁闷。无奈,返程。回程路过鲁朗时,在刘氏石锅鸡吃的当地名吃——石锅鸡,非常美味,大家最后把汤都喝光了。晚上回到八一镇,休息一晚。这一觉睡得最踏实,早上起来却找不到司机师傅了,电话也打不通,原来他和其他师傅昨天夜里打牌到4点多,手机也出了点故障。

第六天继续返程,路过一个不知名的小景点,大家从一处狭小的山洞中穿过,爬到顶端,拍了些照片。还觉得蛮有意思的。然后是游览来时因为下雨没有去的巴松措。中午在景区外面的一处小餐馆吃的闻名已久的藏香猪,可能是店家的手艺一般,味道有些令人失望。进入景区,湖面不是很辽阔,碧绿的湖水倒是很养眼。晚上在一处藏民茶馆,吃的牛肉汤和薄饼,汤十分鲜美。然后大家还留了个合影以示纪念。吃完饭继续赶路,可是车子却发动不起来了,这时天已经全黑了,师傅忙了半天才继续上路。快到拉萨时,又有一个胎漏气,又花了十几分钟换胎。我们穿着抓绒冲锋衣,站在外面都觉得比较凉,师傅居然只穿了个汗衫,真是令人钦佩啊。最后,终于在凌晨1点多回到拉萨。

第七天早起6点要到东措门口集合,去参观纳木措,所以晚上只睡了3个小时左右,十分倦怠。原本是想住一晚的,不过担心海拔较高、自己身体吃不消,所以跟了个一日团。上午10点多到了目的地,纳木措果然是非常漂亮,湛蓝的湖水一眼都望不到边际。中午不到一点,就开始往回赶。路上的风景也非常美,路过念青塘沽拉山的主峰时,师傅问我们要不要下车拍照,估计大家都没睡好,居然没有人应声。

第八天睡到自然醒,然后去罗布林卡(达赖的夏宫)和西藏历史博物馆参观了一下。

第九天睡到自然醒,下午坐飞机返京。中间在重庆停留了一个多小时,晚上9点多才到首都机场。

拍了一些照片,不过由于身体非常疲乏,沿途很多景色都错过了。因为要屏息稳定机身的关系,每拍完一张照片,都要匀上好几口气。自己也感觉很遗憾,期待以后能自驾去西藏,再好好地拍些照片。

虽然林芝路线上的风景并没有令我十分激动,但是一路上所见所闻,留给我的回忆确是令人难忘的 ...