怎樣寫一個拼寫檢查器(Python 版)

發表於2015-07-03

上個星期, 我的兩個朋友 Dean 和 Bill 分別告訴我說他們對 Google 的快速高質量的拼寫檢查工具感到驚奇. 比如說在搜尋的時候鍵入 [speling], 在不到 0.1 秒的時間內, Google 會返回: 你要找的是不是 [spelling]. (Yahoo! 和 微軟也有類似的功能). 讓我感到有點奇怪的是我原想 Dean 和 Bill 這兩個很牛的工程師和數學家應該對於使用統計語言模型構建拼寫檢查器有職業的敏感. 但是他們似乎沒有這個想法. 我後來想了想, 他們的確沒什麼理由很熟悉統計語言模型. 不是他們的知識有問題, 而是我預想的本來就是不對的.

我覺得, 如果對這方面的工作做個解釋, 他們和其他人肯定會受益. 然而像Google 的那樣工業強度的拼寫檢查器的全部細節只會讓人感到迷惑而不是受到啟迪. 前幾天我乘飛機回家的時候, 順便寫了幾十行程式, 作為一個玩具性質的拼寫檢查器. 這個拼寫檢查器大約1秒能處理10多個單詞, 並且達到 80% -90% 的準確率. 下面就是我的程式碼, 用Python 2.5 寫成, 一共21 行, 是一個功能完備的拼寫檢查器.

這段程式碼定義了一個函式叫 correct, 它以一個單詞作為輸入引數, 返回最可能的拼寫建議結果. 比如說:

 

拼寫檢查器的原理, 一些簡單的概率知識

我簡單的介紹一下它的工作原理. 給定一個單詞, 我們的任務是選擇和它最相似的拼寫正確的單詞. (如果這個單詞本身拼寫就是正確的, 那麼最相近的就是它自己啦). 當然, 不可能絕對的找到相近的單詞, 比如說給定 lates 這個單詞, 它應該別更正為 late 呢 還是 latest 呢? 這些困難指示我們, 需要使用概率論, 而不是基於規則的判斷. 我們說, 給定一個詞 w, 在所有正確的拼寫詞中, 我們想要找一個正確的詞 c, 使得對於 w 的條件概率最大, 也就是說:

argmaxc P(c|w)

按照 貝葉斯理論 上面的式子等價於:

argmaxc P(w|c) P(c) / P(w)

因為使用者可以輸錯任何詞, 因此對於任何 c 來講, 出現 w 的概率 P(w) 都是一樣的, 從而我們在上式中忽略它, 寫成:

argmaxc P(w|c) P(c)

這個式子有三個部分, 從右到左, 分別是:

1. P(c), 文章中出現一個正確拼寫詞 c 的概率, 也就是說, 在英語文章中, c 出現的概率有多大呢? 因為這個概率完全由英語這種語言決定, 我們稱之為做語言模型. 好比說, 英語中出現 the 的概率  P(‘the’) 就相對高, 而出現  P(‘zxzxzxzyy’) 的概率接近0(假設後者也是一個詞的話).

2. P(w|c), 在使用者想鍵入 c 的情況下敲成 w 的概率. 因為這個是代表使用者會以多大的概率把 c 敲錯成 w, 因此這個被稱為誤 差模型.

3. argmaxc, 用來列舉所有可能的 c 並且選取概率最大的, 因為我們有理由相信, 一個(正確的)單詞出現的頻率高, 使用者又容易把它敲成另一個錯誤的單詞, 那麼, 那個敲錯的單詞應該被更正為這個正確的.

有人肯定要問, 你笨啊, 為什麼把最簡單的一個 P(c|w) 變成兩項複雜的式子來計算? 答案是本質上 P(c|w) 就是和這兩項同時相關的, 因此拆成兩項反而容易處理. 舉個例子, 比如一個單詞 thew 拼錯了. 看上去 thaw 應該是正確的, 因為就是把 a 打成 e 了. 然而, 也有可能使用者想要的是 the, 因為 the 是英語中常見的一個詞, 並且很有可能打字時候手不小心從 e 滑到 w 了. 因此, 在這種情況下, 我們想要計算  P(c|w), 就必須同時考慮 c 出現的概率和從 c 到 w 的概率. 把一項拆成兩項反而讓這個問題更加容易更加清晰.

現在, 讓我們看看程式究竟是怎麼一回事. 首先是計算 P(c), 我們可以讀入一個巨大的文字檔案, big.txt, 這個裡面大約有幾百萬個詞(相當於是語料庫了). 這個檔案是由Gutenberg 計劃 中可以獲取的一些書, Wiktionary 和 British National Corpus 語料庫構成. (當時在飛機上我只有福爾摩斯全集, 我後來又加入了一些, 直到效果不再顯著提高為止).

然後, 我們利用一個叫 words 的函式把語料中的單詞全部抽取出來, 轉成小寫, 並且去除單詞中間的特殊符號. 這樣, 單詞就會成為字母序列, don’t 就變成 don 和 t 了.1 接著我們訓練一個概率模型, 別被這個術語嚇倒, 實際上就是數一數每個單詞出現幾次. 在 train 函式中, 我們就做這個事情.

實際上, NWORDS[w] 儲存了單詞 w 在語料中出現了多少次. 不過一個問題是要是遇到我們從來沒有過見過的新詞怎麼辦. 假如說一個詞拼寫完全正確, 但是語料庫中沒有包含這個詞, 從而這個詞也永遠不會出現在訓練集中. 於是, 我們就要返回出現這個詞的概率是0. 這個情況不太妙, 因為概率為0這個代表了這個事件絕對不可能發生, 而在我們的概率模型中, 我們期望用一個很小的概率來代表這種情況. 實際上處理這個問題有很多成型的標準方法, 我們選取一個最簡單的方法: 從來沒有過見過的新詞一律假設出現過一次. 這個過程一般成為”平滑化”, 因為我們把概率分佈為0的設定為一個小的概率值. 在語言實現上, 我們可以使用Python collention 包中的 defaultdict 類, 這個類和 python 標準的 dict (其他語言中可能稱之為 hash 表) 一樣, 唯一的不同就是可以給任意的鍵設定一個預設值, 在我們的例子中, 我們使用一個匿名的 lambda:1 函式, 設定預設值為 1.

然後的問題是: 給定一個單詞 w, 怎麼能夠列舉所有可能的正確的拼寫呢? 實際上前人已經研究得很充分了, 這個就是一個編輯距離的概 念. 這兩個詞之間的編輯距離定義為使用了幾次插入(在詞中插入一個單字母), 刪除(刪除一個單字母), 交換(交換相鄰兩個字母), 替換(把一個字母換成另一個)的操作從一個詞變到另一個詞.

下面這個函式可以返回所有與單詞 w 編輯距離為 1 的集合.

顯然, 這個集合很大. 對於一個長度為 n 的單詞, 可能有n種刪除, n-1中對換, 26n 種 (譯註: 實際上是 25n 種)替換 和 26(n+1) 種插入 (譯註: 實際上比這個小, 因為在一個字母前後再插入這個字母構成的詞是等價的). 這樣的話, 一共就是 54n + 25 中情況 (當中還有一點重複). 比如說, 和 something 這個單詞的編輯距離為1 的詞按照這個算來是 511 個, 而實際上是 494 個.

一般講拼寫檢查的文獻宣稱大約80-95%的拼寫錯誤都是介於編譯距離 1 以內. 然而下面我們看到, 當我對於一個有270個拼寫錯誤的語料做實驗的時候, 我發現只有76%的拼寫錯誤是屬於編輯距離為1的集合. 或許是我選取的例子比典型的例子難處理一點吧. 不管怎樣, 我覺得這個結果不夠好, 因此我開始考慮編輯距離為 2 的那些單詞了. 這個事情很簡單, 遞迴的來看, 就是把 edit1 函式再作用在 edit1 函式的返回集合的每一個元素上就行了. 因此, 我們定義函式 edit2:

這個語句寫起來很簡單, 實際上背後是很龐大的計算量: 與 something 編輯距離為2的單詞居然達到了 114,324 個. 不過編輯距離放寬到2以後, 我們基本上就能覆蓋所有的情況了, 在270個樣例中, 只有3個的編輯距離大於2. 當然我們可以做一些小小的優化: 在這些編輯距離小於2的詞中間, 只把那些正確的詞作為候選詞. 我們仍然考慮所有的可能性, 但是不需要構建一個很大的集合, 因此, 我們構建一個函式叫做 known_edits2, 這個函式只返回那些正確的並且與 w 編輯距離小於2 的詞的集合:

現在, 在剛才的 something 例子中, known_edits2(‘something’) 只能返回 3 個單詞: ‘smoothing’, ‘something’ 和 ‘soothing’, 而實際上所有編輯距離為 1 或者  2 的詞一共有 114,324 個. 這個優化大約把速度提高了 10%.

最後剩下的就是誤差模型部分 P(w|c) 了. 這個也是當時難住我的部分. 當時我在飛機上, 沒有網路, 也就沒有資料用來構建一個拼寫錯誤模型. 不過我有一些常識性的知識: 把一個母音拼成另一個的概率要大於子音 (因為人常常把 hello 打成 hallo 這樣); 把單詞的第一個字母拼錯的概率會相對小, 等等. 但是我並沒有具體的數字去支撐這些證據. 因此, 我選擇了一個簡單的方法: 編輯距離為1的正確單詞比編輯距離為2的優先順序高, 而編輯距離為0的正確單詞優先順序比編輯距離為1的高. 因此, 用程式碼寫出來就是:

(譯註: 此處作者使用了Python語言的一個巧妙性質: 短路表示式. 在下面的程式碼中, 如果known(set)非空, candidate 就會選取這個集合, 而不繼續計算後面的; 因此, 通過Python語言的短路表示式, 作者很簡單的實現了優先順序)

correct 函式從一個候選集合中選取最大概率的. 實際上, 就是選取有最大 P(c) 值的那個. 所有的 P(c) 值都儲存在 NWORDS 結構中.

 

效果

現在我們看看演算法效果怎麼樣. 在飛機上我嘗試了好幾個例子, 效果還行. 飛機著陸後, 我從牛津文字檔案庫 (Oxford Text Archive)下載了 Roger Mitton 的 Birkbeck 拼寫錯誤語料庫. 從這個庫中, 我取出了兩個集合, 作為我要做拼寫檢查的目標. 第一個集合用來作為在開發中作為參考, 第二個作為最後的結果測試. 也就是說, 我程式完成之前不參考它, 而把程式在其上的測試結果作為最後的效果. 用兩個集合一個訓練一個對照是一種良好的實踐, 至少這樣可以避免我通過對特定資料集合進行特殊調整從而自欺欺人. 這裡我給出了一個測試的例子和一個執行測試的例子. 實際的完整測試例子和程式可以參見 spell.py.

這個程式給出了下面的輸出:

在270個測試樣本上 270 , 我大約能在13秒內得到 74% 的正確率 (每秒17個正確詞), 在測試集上, 我得到 67% 正確率 (每秒 15 個).

更新: 在這篇文章的原來版本中, 我把結果錯誤的報告高了. 原因是程式中一個小bug. 雖然這個 bug 很不起眼, 但我實際上應該能夠避免. 我為對閱讀我老版本的這篇文章的讀者造成感到抱歉. 在 spelltest 源程式的第四行, 我忽略了if bias:  並且把 bias 預設值賦值為0. 我原來想: 如果 bias 是0 , NWORDS[target] += bias這個語句就不起作用. 而實際上, 雖然這個語句沒有改變 NWORDS[target] 的值, 這個卻讓 (target in NWORDS) 為真. 這樣的話, spelltest 就會把訓練集合中那些不認識的正確拼寫的單詞都當成認識來處理了, 程式就會”作弊”. 我很喜歡 defaultdict 的簡潔, 所以在程式中使用了它, 如果使用 dicts 就不會有這個問題了.2

結論: 我達到了簡潔, 快速開發和執行速度這三個目標, 不過準確率不算太好.

 

將來工作

怎樣才能做到更好結果呢? 讓我們回過頭來看看概率模型中的三個因素:  (1) P(c); (2) P(w|c); and (3) argmaxc. 我們通過程式給出錯誤答案的那些例子入手, 看看這三個因素外, 我們還忽略了什麼.

    1. P(c), 語言模型. 在語言模型中, 有兩種問題會造成最後的錯誤識別. 其中最嚴重的一個因素就是 未知單詞. 在訓練集合中, 一共有15個未知單詞, 它們大約佔了5%; 在測試集合中, 有43個未知詞, 它們佔了11%. 當把 spelltest 的呼叫引數 verbose 設定為 True 的時候: 我們可以看到下面的輸出:

      在這個結果中, 我們可以使用看到 correct 函式作用在那些拼錯的單詞上的結果. (其中 NWORDS 中單詞出現次數在括號中),  然後是我們期望的輸出以及出現的次數. 這個結果告訴我們, 如果程式根本就不知道 ‘econometric’ 是一個單詞, 它也就不可能去把 ‘economtric’ 糾正成 ‘econometric’. 這個問題可以通過往訓練集合中加入更多語料來解決, 不過也有可能引入更多錯誤. 同時注意到最後四行, 實際上我們的訓練集中有正確的單詞, 只是形式略有不同. 因此, 我們可以改進一下程式, 比如在動詞後面加 ‘-ed’ 或者在名詞後面加 ‘-s’ 也是合法的.

      第二個可能導致錯誤的因素是概率: 兩個詞都出現在我們的字典裡面了, 但是恰恰我們選的概率大的那個不是使用者想要的. 不過我要說的是這個問題其實不是最嚴重的, 也不是獨立發生的, 其他原因可能更加嚴重.

      我們可以模擬一下看看如果我們提高語言模型, 最後結果能好多少. 比如說, 我們在訓練集上小”作弊”一下. 我們在 spelltest 函式中有一個引數叫做 bias, 實際上就是代表把正確的拼寫詞多新增幾次, 以便提高語言模型中相應的概率. 比如說, 在語料中, 假設正確的詞出現的頻率多了1次, 或者10次, 或者更多. 如果我們增加 bias 這個引數的值, 可以看到訓練集和測試集上的準確率都顯著提高了.

      Bias 訓練集. 測試集
      0 74% 67%
      1 74% 70%
      10 76% 73%
      100 82% 77%
      1000 89% 80%

      在兩個集合上我們都能做到大約 80-90%. 這個顯示出如果我們有一個好的語言模型, 我們或能達到準確率這個目標. 不過, 這個顯得過於樂觀了, 因為構建一個更大的語言模型會引入新的詞, 從而可能還會引入一些錯誤結果, 儘管這個地方我們沒觀察到這個現象.

      處理未知詞還有另外一種辦法, 比如說, 假如遇到這個詞: “electroencephalographicallz”, 比較好的糾正的方法是把最後的 “z” 變成 “y”, 因為 ‘-cally’ 是英文中很常見的一個字尾. 雖然 “electroencephalographically” 這個詞也不在我們的字典中, 我們也能通過基於音節或者字首字尾等性質給出拼寫建議. 當然, 這種簡單前字尾判斷的方法比基於構詞法的要簡單的多.

    2. P(w|c) 是誤差模型. 到目前為止, 我們都是用的一個很簡陋的模型: 距離越短, 概率越大. 這個也造成了一些問題, 比如下面的例子中, correct 函式返回了編輯距離為 1 的詞作為答案, 而正確答案恰恰編輯距離是 2:

      舉個例子, 程式認為在 ‘adres’ 中把 ‘d’ 變成 ‘c’ 從而得到 ‘acres’ 的優先順序比把 d 寫成 dd 以及 s 寫成 ss 的優先順序高, 從而作出了錯誤的判斷. 還有些時候程式在兩個編輯距離一樣的候選詞中選擇了錯誤的一個, 比如:

      這個例子給我們一個同樣的教訓: 在 ‘thay’ 中, 把 ‘a’ 變成 ‘e’ 的概率比把 ‘y’ 拼成 ‘t’ 大. 為了正確的選擇 ‘they’, 我們至少要在先驗概率上乘以 2.5, 才能使得最後 they 的機率超過 that, 從而選擇 they.

      顯然, 我們可以用一個更好的模型來衡量拼錯單詞的概率. 比如說, 把一個字母順手打成兩個, 或者把一個母音打成另一個的情況都應該比其他打字錯誤更加容易發生. 當然, 更好的辦法還是從資料入手: 比如說, 找一個拼寫錯誤語料, 然後統計插入; 刪除; 交換和變換在給定周圍字母情況下的概率. 為了採集到這些概率, 可能我們需要非常大的資料集. 比如說, 如果我們帶著觀察左右兩個字母作為上下文, 看看一個字母替換成另一個的概率, 就一共有 266 種情況, 也就是大約超過 3 億個情況. 然後每種情況需要平均幾個證據作為支撐, 因此我們知道10億個字母的訓練集. 如果為了保證更好的質量, 可能至少100億個才差不多.

      需要注意的是, 語言模型和誤差模型之間是有聯絡的. 我們的程式中假設了編輯距離為 1 的優先於編輯距離為 2 的. 這種誤差模型或多或少也使得語言模型的優點難以發揮. 我們之所以沒有往語言模型中加入很多不常用的單詞, 是因為我們擔心新增這些單詞後, 他們恰好和我們要更正的詞編輯距離是1, 從而那些出現頻率更高但是編輯距離為 2 的單詞就不可能被選中了. 如果有一個更加好的誤差模型, 或許我們就能夠放心大膽的新增更多的不常用單詞了. 下面就是一個因為新增不常用單詞影響結果的例子:

  1. 列舉所有可能的概率並且選擇最大的: argmaxc. 我們的程式列舉了直到編輯距離為2的所有單詞. 在測試集合中, 270個單詞中, 只有3個編輯距離大於2, 但是在測試集合中, 400箇中卻有23個. 他們是:

    我們可以考慮有限的允許一些編輯距離為3的情況. 比如說, 我們可以只允許在母音旁邊插入一個母音, 或者把母音替換, 或者把 c 寫成 s 等等. 這些基本上就覆蓋了上面所有的情況了.

  2. 第四種, 也是最好的一種改進方法是改進 correct  函式的介面, 讓他可以分析上下文給出決斷. 因為很多情況下, 僅僅根據單詞本身做決斷很難, 這個單詞本身就在字典中, 但是在上下文中, 應該被更正為另一個單詞. 比如說:

    如果單看 ‘where’ 這個單詞本身, 我們無從知曉說什麼情況下該把 correct('where') 返回 ‘were’ , 又在什麼情況下返回 ‘where’. 但是如果我們給 correct 函式的是:'They where going', 這時候 “where” 就應該被更正為 “were”.

    上下文可以幫助程式從多個候選答案中選出最好的, 比如說:

    為什麼 ‘thear’ 要被更正為 ‘there’ 而不是 ‘their’ 呢?  只看單詞本身, 這個問題不好回答, 不過一旦放句子 'There's no there thear' 中, 答案就立即清楚明瞭了.

    要構建一個同時能處理多個詞(詞以及上下文)的系統, 我們需要大量的資料. 所幸的是 Google 已經公開發布了最長 5個單詞的所有序列數 據庫, 這個是從上千億個詞的語料資料中收集得到的. 我相信一個能達到 90% 準確率的拼寫檢查器已經需要考慮上下文以做決定了. 不過, 這個, 我們們改天討論 :)

     

  3. 我們可以通過優化訓練資料和測試資料來提高準確率. 我們抓取了大約100萬個單詞並且假設這些詞都是拼寫正確的. 但是這個事情並不這麼完美, 這些資料集也可能有錯. 我們可以嘗試這找出這些錯並且修正他們. 這個地方, 修正測試集合並不困難. 我留意到至少有三種情況下, 測試集合說我們的程式給出了錯誤的答案, 而我卻認為我們程式的答案比測試集給的答案要好, 比如說: (實際上測試集給的三個答案的拼寫都不正確)

    我們還可以決定英語的變種, 以便訓練我們的程式, 比如說下面的三個錯誤是因為美式英語和英式英語拼發不一樣造成的, (我們的訓練集兩者都有):

  4. 最 後的一個改進是讓程式執行得更加快一點. 比如說, 我們用編譯語言來寫, 而不是用解釋語言. 我們可以使用查詢表, 而不用Python提供的通用的 dict 物件, 我們可以快取計算結果, 從而避免重複計算, 等等. 一個小建議是: 在做任何速度優化之前, 先弄清楚到底程式的時間花在什麼地方了.

延伸閱讀

訂正

我原始的程式一共 20行, 不過 Ivan Peev 指出說我的 string.lowercase, 在某些 locale 和某些版本的 Python中會包含 a-z 以外的更多字母, 因此我加了一個字母表, 我也可以使用 string.ascii_lowercase.

感謝 Jay Liang 指出一共 54n+25 個編輯距離為 1的詞, 而不是 55n+25 個.

感謝Dmitriy Ryaboy 指出 NWORDS[target] += bias bug.

其他程式語言實現

我發表這個文章後, 很多人用其他語言實現了. 我的目的是演算法而不是 Python. 對於那些比較不同語言的人, 這些其他語言實現可能很有意思:

其他自然語言翻譯

譯註:

1. 這個地方顯然作者是為了簡化程式, 實際上don’t 一般都按照 dont 來處理.

2. 如果程式把訓練集合中正確的目標詞存放到 NWORDS 中, 就等價於提前知道答案了, 如果這個錯誤的詞編輯距離為 2 之內沒有其他正確詞, 只有一個這個答案, 程式肯定會選取這個答案. 這個就是所謂的”作弊”.

相關文章