在5年前,我曾写过一篇基于动态规划的整句输入法的文章,文章末尾提到了拼音拆分的问题,由于当时落格输入法主要针对双拼,实际上并不需要进行拆分,只要两两拆开就好了。(这是我推崇双拼的另一个原因,毕竟少了一个技术难点)
后来落格输入法支持了全拼,并且开始给全拼进行优化,才发现原来拼音分词,甚至比中文字分词还要难。
不少人一提到拼音分词,首先就想到了去和英文分词类比,其实不太准确,它们虽然从形式上很像,比如都是英文字母,都用空格区分,单个字符无意义等等,但英文词汇数量很庞大,而汉字拼音只有几百个……所以其实真正能对应的,是汉字分词——至少都是语言,重码没那么多,上下文才有意义。
网上流行的方案
如果在网上搜“拼音拆分算法”绝大多数需求都是搜索引擎类的,没有人是为了做输入法而研究拼音拆分……这些拆分方案,都有一个致命的问题——拆分是有损的,不能处理歧义拆分。
比如:
问题所在
所有这些方案,都是“在拼音字符串中插入空格”,即切分一种可能。但实际上拼音的拆分是有歧义的,比如 zhanan , 可以是 zha'nan (渣男),但也可以是 zhan'an (站按),两者都是合理合法的拼音,如果你觉得后者不常用(那根本不是个词好吧),那我们换一个例子 fangan ,它可以是 fan'gan (反感) 也可以是 fang'an (方案),这类拼音组合实在是太多了……对中文输入法来说简直就是灾难。
“在拼音字符串中插入空格”是指最终的目的形式,并非算法本身。
还有一种是变长拆分歧义,如典型的 xian 也可以是 xi'an , lian 也可以是 li'an ……
简单办法
其实如果你仔细看的话,会发现这个问题很有规律,拿 fangan 来举例子,如果我们用最长匹配原则进行拆分,那正向就是 fang'an ,反向则是 fan'gan ,完美。但反向拆分容易出岔子,比如遇到拼音 susongan 这个词,正向拆分是 su'song'an , 然后反向拆分变成 su's'o'n'gan ,完全失败了。
转移矩阵
好吧,后来上述方案不行之后,我想到了另一个办法——既然中文我用转移矩阵记录转移概率然后求解,那为什么不能在拼音拆分这里也这么做呢?虽然代价就是两次求解有点慢。
于是我就统计了所有语料的拼音转移……正如上文所述,拼音毕竟不是英语,它单位总数太少了,于是转移一阶和二阶其实没什么区别,但总体上要好于最长匹配,配合上一点拼音词频,偶尔还是能出正确结果的。这个方案我持续用了两年多,中间有过多次改进,但大多歧义拆分我还是使用人工处理的形式硬编码进去。
回归本质
其实不知道你有没有发现,我举的例子中,都是有另一个规律的,那就是正向拆分后,末尾的拼音必然是一个韵母!不论是 fang'an 还是 zhan'an 甚至是 gang'a (理想的拆分应该是“ gan'ga ”),末尾都是韵母(严格来讲应该是“都没有声母”),因为这种情况必然就是声母被前面的拼音给带走了,它和前面拼音的韵母组成了另一个尝一点的韵母。所以,我们完全可以在最长匹配的时候,判断这个拼音是不是拥有声母,如果没有,就把前面的一个拼音取出来,取它最后一个字母进行组合,如果这样组合的结果是合法拼音,前面的拼音去掉这个字母也是合法拼音,那我们就找到了一个歧义拆分组合,把它们都添加到列表就好了!
根本不需要什么统计语言模型(其实我也试过了,效果并没有比单纯的转移概率好多少,还更慢了),基于规则进行判断就好了。这下就彻底解决了这种定长歧义拆分问题,后续整句查询中,我们就可以直接把这些拼音送入模型,让它自己去寻找最合适上下文的候选即可。
这里给出我引擎中实际使用的 Swfit 代码,直接复制粘贴不能运行,因为缺少相关对象声明,但可以作为伪代码理解思路:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
class LESegment { class private func addPinyin2List(pinyin:String, py_list: inout [[String]]) { var handled = false if let p = PyString.pyCode(from: pinyin), PyString.isYunMu(p), let last_list = py_list.last { /* 由于是前缀查询,这里如果拼音是单纯的韵母,则有可能和前面拼音的最后一个字母结合成新的拼音,比如 fang'an,其中的 g 也可能分给后边的韵母形成 fan'gan 我们单独取前面拼音的末尾字母尝试和后面的韵母结合,看看前后是否还能是合法拼音,如果是,则这两种拆分都加入列表 这样后续茶叙的时候就可以进行 4 种组合的混合查询(虽然还有两种 fang'gan 和 fan'an, 但考虑到转移和词频,在整句中应该影响不大) (另外对于词语查询来说,影响应该也能接受) */ for py1 in last_list { let newPy = "\(py1.last!)\(pinyin)" guard PyString.isValidPinyin(for: newPy) else {continue} let oldPy = py1.subString(to: -1) guard PyString.isValidPinyin(for: oldPy) else {continue} py_list[py_list.count-1].append(oldPy) py_list.append([newPy, pinyin]) handled = true break } } if !handled { py_list.append([pinyin]) } } class private func segment(_ py: String, smartCorrection: Bool) -> [[String]] { guard !py.isEmpty else { return [] } var py_list: [[String]] = [] var last_index = 0 while true { for i in (last_index...min(py.count, last_index+6)).reversed() { let sub = py.subString(from: last_index, to: i + 1) if PyString.isValidPinyin(for: sub) { addPinyin2List(pinyin: sub, py_list: &py_list) last_index = i+1 if i == py.count { last_index = i } break } if i == last_index { // 如果走到了这里说明接下来的内容都不是合法拼音了,就直接加入列表并返回即可 py_list.append([py.subString(from: last_index)]) return py_list } } if last_index == py.count { break } } return py_list } } |
变长歧义拆分问题
上文中的讨论主要集中在定长歧义拆分的问题上,这是由于落格输入法引擎本身条件限制所在——不能混合处理变长拼音串,这也是早年基于双拼开发的一点功能限制。所以在处理变长歧义上,我采用的方法是在处理词库时就将这些可能会发生起义的词合并在一起,比如查询 xian ,就会有“仙”这类字,但也会有“西安”这类的词,他们共用同一个编码,但现在实际测试似乎效果并不理想,比如用户输入 xinlianwei ,就一定会被拆分为 xin'lian'wei ,这里本应是“心理安慰”的“理安”,但它无论如何都不会高于“连”的……很遗憾落格输入法不能支持同时查询 xin'lian'wei 和 xin'li'an'wei ,因为一个长度是3,另一个长度是4。这里我也还没有找到更好的解决方案,将来有了,我会回来将内容补充上。
本文由 落格博客 原创撰写:落格博客 » 落格输入法是如何进行全拼拼音拆分的
转载请保留出处和原文链接:https://www.logcg.com/archives/3556.html