上周,两个朋友(Dean 和 Bill)都跟我提起Google的拼写检查,说它做的如此好、反应迅速,简直让人惊叹。搜索框输入[speling], 然后谷歌在大概0.1秒后回应:“你是要找spelling吗?”(雅虎和微软也有类似的功能)。让我感到吃惊的是,作为熟练的程序员和算法工程师应该会对动态语言处理拼写检查这样的问题有良好的直觉,但是他们没有。
大家都有一个观念,工业级的拼写矫正器的全部细节是相当复杂的 。我这里想做的是用少于一页的代码开发一个每秒至少能处理10个单词并且能纠正80——90%错误的拼写检查器。
下面是用Python2.5写的拼写纠正器,只有21行代码(经过译者修改和加注释):
# -*- coding: utf-8 -*-
import re, collections
def words(text):
return re.findall('[a-z]+', text.lower())#将文本中的单词分离开 返回一个列表
def train(features):
model = collections.defaultdict(lambda: 1)
for f in features:
model[f] += 1
return model
NWORDS = train(words(file('D:\\CODE\\big.txt').read()))
alphabet = 'abcdefghijklmnopqrstuvwxyz'
def edits1(word):
splits = [(word[:i], word[i:]) for i in range(len(word) + 1)]
deletes = [a + b[1:] for a, b in splits if b]
transposes = [a + b[1] + b[0] + b[2:] for a, b in splits if len(b)>1]
replaces = [a + c + b[1:] for a, b in splits for c in alphabet if b]
inserts = [a + c + b for a, b in splits for c in alphabet]
return set(deletes + transposes + replaces + inserts)
#经过两次编辑
def known_edits2(word):
return set(e2 for e1 in edits1(word) for e2 in edits1(e1) if e2 in NWORDS)
def known(words):
return set(w for w in words if w in NWORDS)
def correct(word):
candidates = known([word]) or known(edits1(word)) or known_edits2(word) or [word]
return max(candidates, key=NWORDS.get)
代码定义了一个函数correct,接收一个词作为输入,然后返回可能想输入的正确的单词,例子如下:
>>> correct('speling')
'spelling'
>>> correct('korrecter')
'corrector'
它是怎样实现的:一些概率理论
它是怎样实现的?首先,要了解一点理论知识。给一个词,我们试图选取一个最可能的正确的的拼写建议(建议也可能就是输入的单词)。有时也不清楚(比如lates应该被更正为late或者latest?),我们用概率决定把哪一个作为建议。我们从跟原始词w相关的所有可能的正确拼写中找到可能性最大的那个拼写建议c:
argmaxc P(c|w)
通过贝叶斯定理,上式可以变换为:
argmaxc P(w|c) P(c) / P(w)
由于P(w)对c所有的情况都一样,我们可以忽略它,直接考虑:
argmaxc P(w|c) P(c)
上面的表达式中由三个部分组成,从左到右:
1. P(c),c这个拼写出现的概率。下面将它称之为语言模型:它也可以理解为“c在英文文本中出现的概率“。所以P(“the”)的值很大,而P(”zxxzxzz“)几乎为0.
2. P(w|c),用户想要输入c却输入了w的概率。给出错误模型的定义:当用户想输入c却错误地输入w的可能性。
3. argmaxc 控制机制,负责枚举c所有可能的值,然后选择配对可能性最大的那个。
那么大家可能要问:为什么用涉及到两个概率模型的更复杂的表达式来替换一个像P(c|w)这样简单的表达式?原因是P(c|w)实际上已经包含了这两个概率模型,但是将这两个分离开反而更方便处理。考虑错误的拼写w = “thew”,它的两个候选的拼写建议 c = “the” 和c = “thaw”。哪一个侯选值的P(c|w)更大?好吧,“thaw”看起来很像因为它的不同只是将”e”拼写成”a“。但另一方面,”the”也不错,因为“the“是一个很常用的词,而且可能打字员敲击键盘时手指从”e“滑到了”w“。关键点在于为了估计P(c|w)我们必须考虑到c出现的概率和c错拼为w的概率这两方面。所以形式上将这两个因素分开考虑使问题更清晰。
接下来我们来看程序是怎样实现的。首先是P(c)我们将读取一个包含了一百万个单词的很大的文本文件big.txt【下载地址:http://norvig.com/big.txt】。这个文件由Project Gutenberg中几个公共领域的书串联而成。
我们然后提取单词(利用words函数,这个函数将单词转换为小写,所以“the”和“The”一样定义为同一个单词;然后将一个字母的序列看作一个单词,所以don’t 将被当做两个单词 don和t)并用train函数计算每个单词出现的次数然后训练出一个合适的模型。下面是代码:
def words(text): return re.findall('[a-z]+', text.lower())
def train(features):
model = collections.defaultdict(lambda: 1)
for f in features:
model[f] += 1
return model
NWORDS = train(words(file('big.txt').read()))
处理之后,NWORD[w]保存了单词w出现的次数。 如果有一个单词在我们训练的数据中没有出现怎么办?最简单的方法是把这些单词看作出现了一次。这个处理叫做平滑处理,因为我们将那些概率分布可能为0的地方平滑化,将他们设置为最小的概率值。 这个处理通过collections.defaultdict实现 。 这个类像一个所有键的值都默认为1的Python的字典(在其他语言中,叫做哈希表)。
现在,我们来处理枚举单词w对应的所有可能正确的拼写c的问题。我们来讨论两个单词的编辑距离(edit distance):把一个单词变成另一个单词的编辑次数。一次编辑可以是删除【删去一个字母】、交换【交换两个相邻的字母】、或者是修改【修改一个字母】或者是插入【插入一个字母】。这里是一个将w进行1次编辑然后返回所有可能结果的集合的函数:
def edits1(word):
splits = [(word[:i], word[i:]) for i in range(len(word) + 1)]
deletes = [a + b[1:] for a, b in splits if b]
transposes = [a + b[1] + b[0] + b[2:] for a, b in splits if len(b)>1]
replaces = [a + c + b[1:] for a, b in splits for c in alphabet if b]
inserts = [a + c + b for a, b in splits for c in alphabet]
return set(deletes + transposes + replaces + inserts)
返回的可能是一个很大的集合,对于一个长度为n的单词,将会有n种删除,n-1种交换,26*n种修改,26*(n+1)种插入。这些加起来总共有54*n + 25种结果(结果中可能有一些重复的值)。例如,edits(‘something’)的元素个数—是494:
def edits2(word):
return set(e2 for e1 in edits1(word) for e2 in edits1(e1))
相关论文显示,80-95%的拼写错误跟想要拼写的单词都只有1个编辑距离。可是我很快发现把270个错误拼写组成的语料库进行测试,发现只有76%的编辑距离是1。可能我测试的数据要比典型的错误要难。不管怎样,我觉得只考虑1次编辑距离还不够好。所以我们要考虑两次编辑距离。2次编辑距离变换也很容易,只需要进行两次edit1处理就好了。
这些说起来容易,但是我们又面临了运算问题。len(edits2(‘something’))的值是114,324。但是,我们确实得到了很好的结果:270个测试例子中,只有3个的编辑距离大于2。我们可以做一个优化,我们最后的结果集合中只保留确认为已知单词的。也就是说,我们需要考虑所有的可能性,但是不用建立很大的一个集合。这些通过函数known_edits2来实现:
def known_edits2(word):
return set(e2 for e1 in edits1(word) for e2 in edits1(e1) if e2 in NWORDS)
现在,known_edits2(‘something’)返回的是一个包括四个单词的集合,而不是114,324个通过两次编辑的所有结果。这种优化能将运行速度提升了大概10%。
然后剩下的问题就只剩错误模型P(w|c)了。这个时候我也陷入了困境。坐在飞机上,连不上网,我找不到训练的数据去构建一个拼写错误的模型。我有一些直觉,弄混两个元音的概率要大于弄错两个辅音,拼错单词的第一个字母的可能性比较小等等。但是我没有数据支持这些想法。所以我选择了一个捷径:我定义一个琐碎的模型,里面有所有已知的单词经过一次编辑变换的值的概率设的远大于经过两次编辑变换的,但又远小于经过0次变换的。这种策略我们通过下面代码实现:
def known(words):
return set(w for w in words if w in NWORDS)
def correct(word):
candidates = known([word]) or known(edits1(word)) or known_edits2(word) or [word]
return max(candidates, key=NWORDS.get)
函数 correct 选择已知的单词中存在的编辑距离最小的单词作为候选的单词。确定候选单词后,选择其中P(c)的值最大的那个作为返回值。