在研究了一段时间中科院计算所张华平、刘群所开发的ICTCLAS分词系统(Free版)代码后,阅读了大量的相关资料,我开始着手将C++的ICTCLAS分词系统移植到.net平台下,并取得了较好的实验结果。这种移植并不容易,在研究了ICTCLAS分词理论的同时还要阅读C++代码实现,其中遇到了很多困惑、迷茫,也不得不重写了一小部分代码,我将在随后的文章中介绍具体实现。
目前除了最后的词性标注部分还没有完全完工外,其它部分已经接近尾声(包括初始切分、N最短路径、人名、地名的识别以及最终优化等),我们先来看看程序对以下句子的分词结果:
SharpICTCLAS程序分词结果
==== 原始句子: 王晓平在滦南大会上说的确实在理 ==== 粗切分后的结果(N个结果): 始##始, 王, 晓, 平, 在, 滦, 南, 大, 会上, 说, 的, 确实, 在, 理, 末##末, 始##始, 王, 晓, 平, 在, 滦, 南, 大会, 上, 说, 的, 确实, 在, 理, 末##末, 始##始, 王, 晓, 平, 在, 滦, 南, 大, 会上, 说, 的, 确实, 在理, 末##末, 始##始, 王, 晓, 平, 在, 滦, 南, 大会, 上, 说, 的, 确实, 在理, 末##末, 始##始, 王, 晓, 平, 在, 滦, 南, 大, 会, 上, 说, 的, 确实, 在, 理, 末##末, ==== 加入对姓名、翻译人名以及地名的识别: row: 0, col: 1, eWeight: 329805.00, nPOS: 1, sWord:始##始 row: 1, col: 2, eWeight: 218.00, nPOS: 0, sWord:王 row: 1, col: 4, eWeight: 10.86, nPOS: -28274, sWord:未##人 row: 2, col: 3, eWeight: 9.00, nPOS: 0, sWord:晓 row: 2, col: 4, eWeight: 13.27, nPOS: -28274, sWord:未##人 row: 3, col: 4, eWeight: 271.00, nPOS: 0, sWord:平 row: 4, col: 5, eWeight: 78484.00, nPOS: 0, sWord:在 row: 5, col: 6, eWeight: 1.00, nPOS: 27136, sWord:滦 row: 5, col: 7, eWeight: 20.37, nPOS: -28275, sWord:未##地 row: 6, col: 7, eWeight: 813.00, nPOS: 0, sWord:南 row: 7, col: 8, eWeight: 14536.00, nPOS: 0, sWord:大 row: 7, col: 9, eWeight: 1333.00, nPOS: 28160, sWord:大会 row: 8, col: 9, eWeight: 6136.00, nPOS: 0, sWord:会 row: 8, col: 10, eWeight: 469.00, nPOS: 0, sWord:会上 row: 9, col: 10, eWeight: 23706.00, nPOS: -27904, sWord:上 row: 10, col: 11, eWeight: 17649.00, nPOS: 0, sWord:说 row: 11, col: 12, eWeight: 358156.00, nPOS: 0, sWord:的 row: 12, col: 14, eWeight: 361.00, nPOS: 0, sWord:确实 row: 14, col: 15, eWeight: 78484.00, nPOS: 0, sWord:在 row: 14, col: 16, eWeight: 3.00, nPOS: 24832, sWord:在理 row: 15, col: 16, eWeight: 129.00, nPOS: 0, sWord:理 row: 16, col: 17, eWeight:2079997.00, nPOS: 4, sWord:末##末 ==== 最终识别结果: 始##始, 王晓平, 在, 滦南, 大会, 上, 说, 的, 确实, 在, 理, 末##末, --------------------------------------------------- ==== 原始句子: 馆内陈列周恩来和邓颖超生前使用过的物品 ==== 粗切分后的结果(N个结果): 始##始, 馆内, 陈列, 周恩来, 和, 邓, 颖, 超, 生前, 使用, 过, 的, 物品, 末##末, 始##始, 馆内, 陈列, 周恩来, 和, 邓, 颖, 超生, 前, 使用, 过, 的, 物品, 末##末, 始##始, 馆内, 陈列, 周恩来, 和, 邓, 颖, 超, 生前, 使用, 过, 的, 物, 品, 末##末, 始##始, 馆内, 陈列, 周恩来, 和, 邓, 颖, 超生, 前, 使, 用, 过, 的, 物品, 末##末, 始##始, 馆内, 陈列, 周恩来, 和, 邓, 颖, 超, 生, 前, 使用, 过, 的, 物品, 末##末, ==== 加入对姓名、翻译人名以及地名的识别: row: 0, col: 1, eWeight: 329805.00, nPOS: 1, sWord:始##始 row: 1, col: 3, eWeight: 24.00, nPOS: 0, sWord:馆内 row: 3, col: 5, eWeight: 70.00, nPOS: 0, sWord:陈列 row: 5, col: 8, eWeight: 1990.00, nPOS: 28274, sWord:周恩来 row: 8, col: 9, eWeight: 72562.00, nPOS: 0, sWord:和 row: 9, col: 10, eWeight: 90.00, nPOS: 28274, sWord:邓 row: 9, col: 12, eWeight: 15.93, nPOS: -28274, sWord:未##人 row: 10, col: 11, eWeight: 2.00, nPOS: 28274, sWord:颖 row: 11, col: 12, eWeight: 200.00, nPOS: 0, sWord:超 row: 11, col: 13, eWeight: 4.00, nPOS: 0, sWord:超生 row: 12, col: 13, eWeight: 532.00, nPOS: 0, sWord:生 row: 12, col: 14, eWeight: 175.00, nPOS: 29696, sWord:生前 row: 13, col: 14, eWeight: 5107.00, nPOS: 0, sWord:前 row: 14, col: 15, eWeight: 8224.00, nPOS: 0, sWord:使 row: 14, col: 16, eWeight: 1876.00, nPOS: 0, sWord:使用 row: 15, col: 16, eWeight: 5300.00, nPOS: 0, sWord:用 row: 16, col: 17, eWeight: 5090.00, nPOS: 0, sWord:过 row: 17, col: 18, eWeight: 358156.00, nPOS: 0, sWord:的 row: 18, col: 19, eWeight: 200.00, nPOS: 0, sWord:物 row: 18, col: 20, eWeight: 189.00, nPOS: 28160, sWord:物品 row: 19, col: 20, eWeight: 75.00, nPOS: 0, sWord:品 row: 20, col: 21, eWeight:2079997.00, nPOS: 4, sWord:末##末 ==== 最终识别结果: 始##始, 馆内, 陈列, 周恩来, 和, 邓颖超, 生前, 使用, 过, 的, 物品, 末##末,
从上面结果可以看出,切分效果还是令人满意的(当然这完全是由原有ICTCLAS的良好设计理论所决定)。
在移植的过程中,比较突出的问题包括:
1、C#支持Unicode,而原有设计是基于单字节表示
在原有设计中使用了大量的字符数组,而且一个汉字在字符数组中占两个字符位置。为了取出一个字符,必须考虑是半角字符还是全角汉字。所以随处可见类似代码:
C++代码实现取一个字符
char tchar[3]; tchar[2] = 0; tchar[0] = sWord[k]; tchar[1] = 0; if (sWord[k] < 0) { tchar[1] = sWord[k + 1]; k += 1; } k += 1;
为了判断是否是汉字,使用了“if (sWord[k] < 0) ”等手段异常繁琐。
而C#本身对Unicode有很好的支持,所以只需要string.ToCharArray()方法就可以将一个一个字符拆分开来。但需要注意的是,在C#中一个汉字的长度是1,而C++实现中一个汉字的长度是2,这要求在移植过程中要仔细对待。
2、使用正则表达式简化了部分设计
原有设计中为了判断一个字符串是否是数字需要很长的代码(例如Utility类中的IsAllNum方法),代码行数将近7~80行,而改用正则表达式后,一行代码就解决问题了。 移植后的代码使用了很多正则表达式简化类似代码。
3、字符串比较问题
由于原有设计中,对汉字大小的比较是基于CCID的(尤其是对词典库进行检索时),一个汉字的CCID计算方式如下:
CCID计算方法(C#)
//==================================================================== // 根据汉字返回对应的CC_ID //==================================================================== public static int CC_ID(char c) { byte[] b = Encoding.GetEncoding("gb2312").GetBytes(c.ToString()); if (b.Length != 2) return -1; else return (Convert.ToInt32(b[0]) - 176) * 94 + (Convert.ToInt32(b[1]) - 161); }
而C#的字符串比较没有一个适合CCID方式的字符串比较,例如在原有设计中,“声”、“生”、“现”的大小关系是:“声” < “生” < “现”,而C#中string.Compare方法不管设置为StringComparison.Ordinal、StringComparison.CurrentCulture还是StringComparison.InvariantCulture都无法达到这个结果,因此不得已设计了自己的字符串比较函数。
4、重写了部分代码
由于原有ICTCLAS系统代码的繁琐和不易理解(可以参考《天书般的ICTCLAS分词系统代码(一)》、《天书般的ICTCLAS分词系统代码(二)》) ,我重写了部分代码,主要包括:
1)重写了DynamicArray代码。新代码使用了三个类实现了原有代码,将不同功能分离开,使得代码简单易读。
2)重写了NShortPath代码。到现在我也没有完全弄明白原作者在实现NShortPath时的思路,干脆自己写吧。重写后的新代码比原有代码简化了不少,而且比较容易理解(至少我是这么认为的)。
3)Segment类中重写了GenerateWord方法,使用了链表而不是数组记录结果,并采用了管道式的处理流程,这简化了后续的合并逻辑。
4)对原有代码中部分属性、变量、字段的命名进行了调整,让其更具有实际意义。例如原有代码中nHandle和nPOS据我理解应当是一会事,所以新程序中全部使用nPOS这个命名。
5、保留了相当一部分原有代码
对于某些逻辑结构异常复杂的情况,在新代码中保留了原有的设计内容。
例如Segment类中对日期、年份、时间等的合并策略,其if条件嵌套有5层之多,为了保留原有逻辑,在移植过程中仅做了微小的调整。
另外CSpan、Unknown等类中的代码几乎没有做任何调整(其中包含了大量的计算逻辑),保留了原汁原味的内容。
我会在后续的文章中,分多次内容介绍SharpICTCLAS的实现手段以及对原有ICTCLAS代码改造的地方。