第 48 課 · 進階 · 19 分鐘

Python 樸素貝葉斯文字分類

用計數、先驗和平滑給短文字分類。

樸素貝葉斯先驗拉普拉斯平滑對數機率

投訴和誇獎用的詞不一樣,數一數就夠做第一個分類器。樸素貝葉斯假設了一個並不存在的獨立。它為什麼還是有用?

樸素貝葉斯是一個由計數構成的快速文字分類基線。它假設在給定類別後各個詞相互獨立,這個假設通常不成立,但模型仍然有用。本課把計數、先驗、平滑和對數計分全部寫清楚。 你還會看到平滑為什麼對未知詞重要,以及長文件為什麼得到更負的分數。

按類別統計詞元

訓練從帶標籤的文件開始。對每個類別,統計每個詞元出現了多少次。這些計數就是分類器使用的原始證據,標籤本身不能作為特徵混進詞表。

docs = [
    ("good movie", "pos"),
    ("great film", "pos"),
    ("bad movie", "neg"),
    ("boring film", "neg"),
]
counts = {}
for text, label in docs:
    counts.setdefault(label, {})
    for word in text.split():
        counts[label][word] = counts[label].get(word, 0) + 1
print(counts["pos"]["movie"])
print(counts["neg"]["bad"])

每個類別各有一張計數表

先驗與平滑似然

先驗是某個類別在訓練文件中的佔比。某個詞在該類別下的似然等於「該詞計數加一」,除以「類別總詞數加詞表大小」。拉普拉斯平滑可以避免沒見過的詞把機率變成零。

docs = [
    ("good movie", "pos"),
    ("great film", "pos"),
    ("bad movie", "neg"),
    ("boring film", "neg"),
]
vocab = sorted({word for text, _ in docs for word in text.split()})
counts = {}
for text, label in docs:
    counts.setdefault(label, {})
    for word in text.split():
        counts[label][word] = counts[label].get(word, 0) + 1
for label in sorted(counts):
    total = sum(counts[label].values())
    probability = (counts[label].get("great", 0) + 1) / (total + len(vocab))
    print(label, round(probability, 3))

先加一,再歸一化

在對數空間計分

很多小機率連乘可能下溢成零。改為把對數相加,數值更安全,排序也不變。對數機率是負數,數值越大表示該類別越可能。

import math

print(round(math.log(0.5 * 0.2), 3))
print(round(math.log(0.5) + math.log(0.2), 3))

排序相同,算術更安全

選出最可能的類別

對新文件,把對數先驗與每個詞的平滑對數似然相加,選擇分數更大的類別。要規定平局規則,並且只在未參與訓練的新文件上評估分類器。

import math

docs = [
    ("good movie", "pos"),
    ("great film", "pos"),
    ("bad movie", "neg"),
    ("boring film", "neg"),
]
vocab = sorted({word for text, _ in docs for word in text.split()})
counts = {}
class_docs = {}
for text, label in docs:
    class_docs[label] = class_docs.get(label, 0) + 1
    counts.setdefault(label, {})
    for word in text.split():
        counts[label][word] = counts[label].get(word, 0) + 1

def predict(text):
    scores = {}
    for label in counts:
        total = sum(counts[label].values())
        score = math.log(class_docs[label] / len(docs))
        for word in text.split():
            score += math.log((counts[label].get(word, 0) + 1) / (total + len(vocab)))
        scores[label] = score
    return max(scores, key=scores.get)

print(predict("great film"))

對數分數更大的類別

平滑讓未知詞的分數保持有限

不做平滑時,未知詞的似然為零,它的對數沒有定義。加 alpha 平滑給詞表中的每個詞一個小的非零計數。alpha 是一種選擇:太小仍然脆弱,太大則衝淡證據。

import math

count = 0
total = 2
vocab_size = 3
for alpha in [0, 1]:
    probability = (count + alpha) / (total + alpha * vocab_size)
    print(alpha, probability, round(math.log(probability), 3) if probability else "undefined")

零計數需要一條策略

越長的文件,總分越負

樸素貝葉斯分數會為每個詞元累加一個對數似然。因此較長文件即使平均證據相似,總分也往往更負。在把分數當成機率之前,應比較每詞元平均對數似然,或在驗證集上校準。

import math

log_prior = math.log(0.5)
log_word = math.log(0.8)

def score(tokens):
    return log_prior + len(tokens) * log_word

for tokens in [["good"], ["good", "good"], ["good", "good", "good"]]:
    print(len(tokens), round(score(tokens), 2), round((score(tokens) - log_prior) / len(tokens), 2))

總分與每詞元分數

本課要點

  • 樸素貝葉斯按類別匯總詞元計數來學習。
  • 先驗反映每個類別在訓練資料中的常見程度。
  • 拉普拉斯平滑防止沒見過的詞把分數歸零。
  • 機率連乘改為對數相加,可以避免下溢。
  • 獨立假設是簡化選擇,不是事實。
  • 平滑處理未知詞;文件長度會改變對數分數的尺度。

常見錯誤

  • 把標籤本身當成詞元特徵。
  • 只對目前文件出現的詞做平滑。
  • 在訓練文件本身上報告準確率。
  • 直接比較長度差異很大的文件的樸素貝葉斯原始分數。
輪到你

使用四篇訓練文件,計算在 neg 類別下出現 'bad' 的平滑機率,保留三位小數。

動手試試

改改變數、先預測輸出,再執行程式碼。第一次點擊「執行程式碼」時會下載 Python 執行時,可能需要幾秒鐘。

瀏覽器內 Python本機執行 · 無需帳號
首次執行會從 CDN 下載 Pyodide。修改後點擊「執行程式碼」。

練習本課

12 道題,作答後立即看到解釋。

查看 12 道題答案開始練習
← 梯度下降k-means 聚類 →