(這個系列的第一部分介紹了貝葉斯定理,第二部分介紹瞭如何過濾垃圾郵件,今天是第三部分。)
使用谷歌的時候,如果你拼錯一個單詞,它會提醒你正確的拼法。
比如,你不小心輸入了 seperate。
谷歌告訴你,這個詞是不存在的,正確的拼法是 separate。
這就叫做"拼寫檢查"(spelling corrector)。有好幾種方法可以實現這個功能,谷歌使用的是基於貝葉斯推斷的統計學方法。這種方法的特點就是快,很短的時間內處理大量文字,並且有很高的精確度(90%以上)。谷歌的研發總監 Peter Norvig,寫過一篇著名的文章,解釋這種方法的原理。
下面我們就來看看,怎麼利用貝葉斯推斷,實現"拼寫檢查"。其實很簡單,一小段程式碼就夠了。
一、原理
使用者輸入了一個單詞。這時分成兩種情況:拼寫正確,或者拼寫不正確。我們把拼寫正確的情況記做 c(代表correct),拼寫錯誤的情況記做 w(代表wrong)。
所謂"拼寫檢查",就是在發生 w 的情況下,試圖推斷出 c。從機率論的角度看,就是已知 w,然後在若干個備選方案中,找出可能性最大的那個 c,也就是求下面這個式子的最大值。
P(c|w)
根據貝葉斯定理:
P(c|w) = P(w|c) * P(c) / P(w)
對於所有備選的 c 來說,對應的都是同一個 w,所以它們的 P(w) 是相同的,因此我們求的其實是
P(w|c) * P(c)
的最大值。
P(c) 的含義是,某個正確的詞的出現"機率",它可以用"頻率"代替。如果我們有一個足夠大的文字庫,那麼這個文字庫中每個單詞的出現頻率,就相當於它的發生機率。某個詞的出現頻率越高,P(c) 就越大。
P(w|c) 的含義是,在試圖拼寫 c 的情況下,出現拼寫錯誤 w 的機率。這需要統計資料的支援,但是為了簡化問題,我們假設兩個單詞在字形上越接近,就有越可能拼錯,P(w|C) 就越大。舉例來說,相差一個字母的拼法,就比相差兩個字母的拼法,發生機率更高。你想拼寫單詞 hello,那麼錯誤拼成 hallo(相差一個字母)的可能性,就比拼成 haallo 高(相差兩個字母)。
所以,我們只要找到與輸入單詞在字形上最相近的那些詞,再在其中挑出出現頻率最高的一個,就能實現 P(w|c) * P(c) 的最大值。
二、演算法
最簡單的演算法,只需要四步就夠了。
第一步,建立一個足夠大的文字庫。
網上有一些免費來源,比如古登堡計劃、Wiktionary、英國國家語料庫等等。
第二步,取出文字庫的每一個單詞,統計它們的出現頻率。
第三步,根據使用者輸入的單詞,得到其所有可能的拼寫相近的形式。
所謂"拼寫相近",指的是兩個單詞之間的"編輯距離"(edit distance)不超過2。也就是說,兩個詞只相差1到2個字母,只透過----刪除、交換、更改和插入----這四種操作中的一種,就可以讓一個詞變成另一個詞。
第四步,比較所有拼寫相近的詞在文字庫的出現頻率。頻率最高的那個詞,就是正確的拼法。
根據 Peter Norvig 的驗證,這種演算法的精確度大約為60%-70%(10個拼寫錯誤能夠檢查出6個。)雖然不令人滿意,但是能夠接受。畢竟它足夠簡單,計算速度極快。(本文的最後部分,將詳細討論這種演算法的缺陷在哪裡。)
三、程式碼
我們使用 Python 語言,實現上一節的演算法。
第一步,把網上下載的文字庫儲存為 big.txt 檔案。這步不需要程式設計。
第二步,載入 Python 的正則語言模組(re)和 collections 模組,後面要用到。
import re, collections
第三步,定義 words() 函式,用來取出文字庫的每一個詞。
def words(text): return re.findall('[a-z]+', text.lower())
lower() 將所有詞都轉成小寫,避免因為大小寫不同,而被算作兩個詞。
第四步,定義一個 train() 函式,用來建立一個"字典"結構。文字庫的每一個詞,都是這個"字典"的鍵;它們所對應的值,就是這個詞在文字庫的出現頻率。
def train(features):
model = collections.defaultdict(lambda: 1)
for f in features:
model[f] += 1
return model
collections.defaultdict(lambda: 1)的意思是,每一個詞的預設出現頻率為1。這是針對那些沒有出現在文字庫的詞。如果一個詞沒有在文字庫出現,我們並不能認定它就是一個不存在的詞,因此將每個詞出現的預設頻率設為1。以後每出現一次,頻率就增加1。
第五步,使用words()和train()函式,生成上一步的"詞頻字典",放入變數NWORDS。
NWORDS = train(words(file('big.txt').read()))
第六步,定義edits1()函式,用來生成所有與輸入引數word的"編輯距離"為1的詞。
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)
edit1()函式中的幾個變數的含義如下:
(1)splits:將word依次按照每一位分割成前後兩半。比如,'abc'會被分割成 [('', 'abc'), ('a', 'bc'), ('ab', 'c'), ('abc', '')] 。
(2)beletes:依次刪除word的每一位後、所形成的所有新詞。比如,'abc'對應的deletes就是 ['bc', 'ac', 'ab'] 。
(3)transposes:依次交換word的鄰近兩位,所形成的所有新詞。比如,'abc'對應的transposes就是 ['bac', 'acb'] 。
(4)replaces:將word的每一位依次替換成其他25個字母,所形成的所有新詞。比如,'abc'對應的replaces就是 ['abc', 'bbc', 'cbc', ... , 'abx', ' aby', 'abz' ] ,一共包含78個詞(26 × 3)。
(5)inserts:在word的鄰近兩位之間依次插入一個字母,所形成的所有新詞。比如,'abc' 對應的inserts就是['aabc', 'babc', 'cabc', ..., 'abcx', 'abcy', 'abcz'],一共包含104個詞(26 × 4)。
最後,edit1()返回deletes、transposes、replaces、inserts的合集,這就是與word"編輯距離"等於1的所有詞。對於一個n位的詞,會返回54n+25個詞。
第七步,定義edit2()函式,用來生成所有與word的"編輯距離"為2的詞語。
def edits2(word):
return set(e2 for e1 in edits1(word) for e2 in edits1(e1))
但是這樣的話,會返回一個 (54n+25) * (54n+25) 的陣列,實在是太大了。因此,我們將edit2()改為known_edits2()函式,將返回的詞限定為在文字庫中出現過的詞。
def known_edits2(word):
return set(e2 for e1 in edits1(word) for e2 in edits1(e1) if e2 in NWORDS)
第八步,定義correct()函式,用來從所有備選的詞中,選出使用者最可能想要拼寫的詞。
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)
我們採用的規則為:
(1)如果word是文字庫現有的詞,說明該詞拼寫正確,直接返回這個詞;
(2)如果word不是現有的詞,則返回"編輯距離"為1的詞之中,在文字庫出現頻率最高的那個詞;
(3)如果"編輯距離"為1的詞,都不是文字庫現有的詞,則返回"編輯距離"為2的詞中,出現頻率最高的那個詞;
(4)如果上述三條規則,都無法得到結果,則直接返回word。
至此,程式碼全部完成,合起來一共21行。
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('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('speling')
'spelling'
>>> correct('korrecter')
'corrector'
四、缺陷
我們使用的這種演算法,有一些缺陷,如果投入生產環境,必須在這些方面加入改進。
(1)文字庫必須有很高的精確性,不能包含拼寫錯誤的詞。
如果使用者輸入一個錯誤的拼法,文字庫恰好包含了這種拼法,它就會被當成正確的拼法。
(2)對於不包含在文字庫中的新詞,沒有提出解決辦法。
如果使用者輸入一個新詞,這個詞不在文字庫之中,就會被當作錯誤的拼寫進行糾正。
(3)程式返回的是"編輯距離"為1的詞,但某些情況下,正確的詞的"編輯距離"為2。
比如,使用者輸入reciet,會被糾正為recite(編輯距離為1),但使用者真正想要輸入的詞是receipt(編輯距離為2)。也就是說,"編輯距離"越短越正確的規則,並非所有情況下都成立。
(4)有些常見拼寫錯誤的"編輯距離"大於2。
這樣的錯誤,程式無法發現。下面就是一些例子,每一行前面那個詞是正確的拼法,後面那個則是常見的錯誤拼法。
purple perpul
curtains courtens
minutes muinets
successful sucssuful
inefficient ineffiect
availability avaiblity
dissension desention
unnecessarily unessasarily
necessary nessasary
unnecessary unessessay
night nite
assessing accesing
necessitates nessisitates
(5)使用者輸入的詞的拼寫正確,但是其實想輸入的是另一個詞。
比如,使用者輸入是where,這個詞拼寫正確,程式不會糾正。但是,使用者真正想輸入的其實是were,不小心多打了一個h。
(6)程式返回的是出現頻率最高的詞,但使用者真正想輸入的是另一個詞。
比如,使用者輸入ther,程式會返回the,因為它的出現頻率最高。但是,使用者真正想輸入的其實是their,少打了一個i。也就是說,出現頻率最高的詞,不一定就是使用者想輸入的詞。
(7)某些詞有不同的拼法,程式無法辨別。
比如,英國英語和美國英語的拼法不一致。英國使用者輸入'humur',應該被糾正為'humour';美國使用者輸入'humur',應該被糾正為'humor'。但是,我們的程式會統一糾正為'humor'。
(完)