前两天配好python的环境和IDE之后,今天便想着做点什么来练练手。看来看去,还是觉得Google的拼写检查器比较有意思,而且原理易理解,实现起来也不算难,同时写完这篇博客后对于python的基础语法也有了进一步的巩固和提升。
Google快速高质量的拼写检查器背后其实是一个有千亿级数量词汇的语料库的支撑,庞大的数据集的训练可以使得它的拼写纠错准确度达90%以上。那么,这个拼写检查系统究竟是如何根据用户输入的词给出高正确率的判断和推荐的呢?
话不多说,切入正题。其实,这一套系统背后的“始作俑者”正是Bayes算法。那么Bayes算法是怎么推导出结果的呢?可信的依据是什么呢?别着急,小编为大家慢慢道来~
当用户输入一个不在字典中单词时,我们肯定会推测:“咦,这个家伙到底真正想输入的单词是什么呢?”我们肯定会给他一个最大概率的可能单词吧,也就是其实我们要求的就是 :P(我们猜测他想输入的单词 | 他实际输入的单词)
现在来做一个假设,假设用户实际输入的单词记为D(D代表Data,即观测数据,也就是我们的一个语料库了),这时我们会有多种猜测结果吧:
猜测1:P(h1 | D) , 猜测2:P(h2 | D) ,猜测3:P(h3 | D)......统一为:P(h | D)
那么我们的求解任务就变成当前词汇下,哪种猜测的P(h | D)最大。根据Bayes公式,我们可以做一个转换,于是就变成了:
P(h | D)=P(h) * P(D | h) / P(D)
其中,P(h)相当于是一个先验概率,就好比当我们拿到一个语料库,其中the这个单词出现的次数或说概率我们是可以统计计算的,其他单词亦是如此。P(D | h)就是当你想输一个词结果输错了的概率有多大,比如你想输“the"结果输成"tha"的概率有多大。而P(D)呢就是用户输入的一个单词,用户想输什么样的单词是不是都有可能呀,这个对所有猜测结果P(D)来说都是一个词吧,所以在计算中我们就把它约分掉了。所以化简为:
P(h | D)∝P(h) * P(D | h)
对于给定的观测数据,一个猜测是好是坏,取决于“这个猜测本身独立的可能性大小(先验概率)“和”这个猜测生成我们观测到的数据的可能性大小“。对于后者的求解是我们要考虑的问题,我们需要给它一个指标,本文采用编辑距离来衡量,所谓编辑距离简单来说就是,现在有两个字符串,把其中一个变成另一个需要几次处理。比如把“the"打成"tha"那么编辑距离就是1喽。
不难想到,编辑距离为1有四种可能情况:插入一处,删除一处,交换一处,修改一处。对于一个长度为n的字符串,就会有26(n+1)种插入情况,n种删除情况,n-1种交换情况,26n种修改情况,加起来也就是54*n+25种结果。显而易见,当两个单词的编辑距离相同,比如输入tep,此时top和tip的编辑距离都是1,这个时候按照这个算法系统就会根据二者的先验概率即在语料库中出现的频率或说常见程度给出推荐。
有编辑距离是1的情况,自然也有编辑距离为2,3......的情况,只不过编辑距离越大,出现单词拼写错误的概率就更高,这种情况发生的概率也就越低,因为人们往往不会是故意拼错,错误的拼写往往很接近正确的单词,虽然这种说法也并非绝对,但是一般讲拼写检查的文献宣称大约80-95%的拼写错误都是介于编译距离 1 以内。为谨慎起见,后文我也把求解编辑距离为2的单词写了进来。
了解了原理,下面对一般拼写检查器应用Bayes公式做一下总结:
argmaxc P(c|w) = P(w|c) P(c) / P(w)
因为用户可以输错任何词, 因此对于任何 c 来讲, 出现 w 的概率 P(w) 都是一样的, 从而我们在上式中忽略它, 写成:
argmaxc P(w|c) P(c)
式子从左到右有三个部分:
P(c),文章中出现一个正确拼写词c的概率,也就是说,在语料库中c出现的概率有多大
P(w|c),在用户想键入c的情况下敲成w的概率,代表用户会以多大的概率把c敲错成w
argmaxc,用来枚举所有可能的c并且选取概率最大的
接下来,我们来看一下程序是怎么回事。
首先是计算 P(c), 我们可以读入一个巨大的文本文件, big.txt, 这个里面大约有几百万个词(相当于是语料库了)。然后,写一个函数words,把语料中的单词全部抽取出来, 转成小写, 并且去除单词中间的特殊符号。这样, 单词就会成为字母序列, don't 就变成 don 和 t 了。接着我们训练一个概率模型,别被这个术语吓倒, 实际上就是数一数每个单词出现几次。 在 train 函数中, 我们就做这个事情。
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[w] 存储了单词 w 在语料中出现了多少次。不过一个问题是要是遇到我们从来没有过见过的新词怎么办。假如说一个词拼写完全正确,但是语料库中没有包含这个词,从而这个词也永远不会出现在训练集中。于是, 我们就要返回出现这个词的概率是0。这个情况不太妙, 因为概率为0这个代表了这个事件绝对不可能发生, 而在我们的概率模型中, 我们期望用一个很小的概率来代表这种情况。 实际上处理这个问题有很多成型的标准方法, 我们选取一个最简单的方法: 从来没有过见过的新词一律假设出现过一次。 这个过程一般成为”平滑化”, 因为我们把概率分布为0的设置为一个小的概率值。在语言实现上,我们可以使用Python collention 包中的 defaultdict 类,这个类和 python 标准的 dict (其他语言中可能称之为 hash 表) 一样, 唯一的不同就是可以给任意的键设置一个默认值, 在我们的例子中, 我们使用一个匿名的 lambda:1 函数, 设置默认值为 1。
NWORDS=train(words(file('big.txt').read()))
for key,value in NWORDS.items():
print('{key}:{value}'.format(key=key,value=value))
接着我们来写求编辑距离为1的单词的函数edits1,结果返回一个集合。捎带提一句,这里的语法要用到python的列表解析。
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)
为严谨一点,我们继续考虑编辑距离为 2的那些单词。这个事情很简单, 递归的来看, 就是把 edits1 函数再作用在 edits1 函数的返回集合的每一个元素上就行了。 因此, 我们定义函数 edits2:
def edits2(word):
return set(e2 for e1 in edits1(word) for e2 in edits1(e1))
这个语句写起来很简单, 实际上背后是很庞大的计算量:与 something 编辑距离为2的单词居然达到了 114,324 个。不过编辑距离放宽到2以后, 我们基本上就能覆盖所有的情况了。当然我们可以做一些小小的优化: 在这些编辑距离小于2的词中间, 只把那些正确的词作为候选词。 我们仍然考虑所有的可能性, 但是不需要构建一个很大的集合, 因此, 我们构建一个函数叫做 known_edits2, 这个函数只返回那些正确的并且与 w 编辑距离小于2 的词的集合:
def known_edits2(word): return set(e2 for e1 in edits1(word) for e2 in edits1(e1) if e2 in NWORDS)
现在, 在刚才的 something 例子中, known_edits2('something') 只能返回 3 个单词: 'smoothing', 'something' 和 'soothing', 而实际上所有编辑距离为 1 或者 2 的词一共有 114,324 个,这个优化大约把速度提高了 10%。
接下来就是P(w|c)了,根据上文的分析和常识确定一个简单的规则: 编辑距离为1的正确单词比编辑距离为2的优先级高, 而编辑距离为0的正确单词优先级比编辑距离为1的高。因此我们可以利用Python语言的一个巧妙性质:短路表达式。在下面的代码中, 如果known(set)非空,candidate 就会选取这个集合, 而不继续计算后面的;因此, 通过Python语言的短路表达式,我们很简单的实现了优先级。
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]
print '\n'
for data_key in candidates:
for key in NWORDS.keys():
if data_key==key:
print data_key+"在语料库中出现的次数是: %d"%NWORDS[key]
print "**系统推荐词为**:"+max(candidates, key=lambda w: NWORDS[w])
return max(candidates, key=lambda w: NWORDS[w])
correct 函数从一个候选集合中选取最大概率的。实际上, 就是选取有最大 P(c) 值的那个。 所有的 P(c) 值都存储在 NWORDS 结构中。correct函数作为程序的入口:
correct("cop")
我们来看一下程序的运行结果:
writings:5
yellow:16
four:40
woods:8
hanging:8
increase:6
pardon:3
granting:3
eligible:3
electricity:2
originality:2
lord:53
immature:2
flicking:2
meadows:3
shaving:2
sinking:4
treasuries:2
hacked:2
bile:2
uncongenial:2
foul:7
stabbed:2
bringing:8
wooded:2
uttering:2
prize:2
wooden:18
wednesday:5
piling:2
solid:7
hague:2
succession:4
busybody:2
guardsmen:4
charter:13
nigh:3
tired:3
cordially:2
preface:2
elections:3
second:37
crisply:2
sustaining:2
sailed:3
scraped:3
loathing:2
redemptioners:2
captain:5
ruthless:3
cooking:2
contributed:3
fingers:13
increasing:6
inducement:2
pioneering:2
specialist:2
misjudged:2
avert:4
chins:2
reporter:2
error:6
swag:2
here:149
herd:2
reported:5
china:8
hers:3
shriek:5
chink:3
natured:3
pretensions:2
k:22
climbed:2
reports:3
controversy:2
natures:2
military:7
numerical:3
......
......
co在语料库中出现的次数是: 8
cup在语料库中出现的次数是: 7
top在语料库中出现的次数是: 15
cap在语料库中出现的次数是: 8
crop在语料库中出现的次数是: 12
copy在语料库中出现的次数是: 9
com在语料库中出现的次数是: 3
**系统推荐词为**:top
这样就实现了一个基本的拼写检查器,从代码简洁,快速开发和运行速度方面来看,还算是可以。但是,有些问题也是我们已经发现并且在未来需要解决的。
系统推荐的词一定是语料库中出现过的词,如果一个单词本来就是正确的,但是语料库中没有这个单词,它便会推荐一个其他的正确的单词,而非我们需要的单词。这时就要考虑增大语料库数据量来完善,可能至少100个亿才差不多。
P(w|c) 是误差模型。到目前为止,我们都是用的一个很简陋的模型: 距离越短, 概率越大。这个也造成了一些问题,有的时候correct 函数返回了编辑距离为 1 的词作为答案,而正确答案恰恰编辑距离是 2。需要注意的是,语言模型和误差模型之间是有联系的。 我们的程序中假设了编辑距离为 1 的优先于编辑距离为 2 的。 这种误差模型或多或少也使得语言模型的优点难以发挥。 我们之所以没有往语言模型中加入很多不常用的单词, 是因为我们担心添加这些单词后, 他们恰好和我们要更正的词编辑距离是1, 从而那些出现频率更高但是编辑距离为 2 的单词就不可能被选中了。 如果有一个更加好的误差模型, 或许我们就能够放心大胆的添加更多的不常用单词了。下面就是一个因为添加不常用单词影响结果的例子:
correct('wonted') => 'wonted' (2); expected 'wanted' (214)
correct('planed') => 'planed' (2); expected 'planned' (16)
correct('forth') => 'forth' (83); expected 'fourth' (79)
correct('et') => 'et' (20); expected 'set' (325)
最好的一种改进方法是改进 correct 函数的接口,让他可以分析上下文给出决断。 因为很多情况下,仅仅根据单词本身做决断很难, 这个单词本身就在字典中, 但是在上下文中, 应该被更正为另一个单词。比如说如果单看 'where' 这个单词本身, 我们无从知晓说什么情况下该把 correct('where') 返回 'were' ,又在什么情况下返回 'where'。但是如果我们给 correct 函数的是:'They where going',这时候 "where" 就应该被更正为 "were"。要构建一个同时能处理多个词(词以及上下文)的系统, 我们需要大量的数据。 所幸的是 Google 已经公开发布了最长 5个单词的所有序列数据库, 这个是从上千亿个词的语料数据中收集得到的。 我相信一个能达到 90% 准确率的拼写检查器已经需要考虑上下文以做决定了。
以上就是本文的全部内容,与君共勉。