第 41 課 · 基礎 · 19 分鐘

Python 分詞與文字向量

把文字變成詞元,再用向量比較文件。

分詞詞袋餘弦相似度向量

評論裡有人說「燕麥拿鐵很棒」,有人說「燕麥奶酸了」,Maya 想看出哪些詞把意思拉向兩邊。先分詞,再變向量。餘弦相似度到底在比什麼?

AI 系統不會像人一樣直接閱讀句子。它先把文字切成詞元,再把詞元表示成數字。本課構造一個小型詞袋向量,並用餘弦相似度比較文件,讓背後的算術過程看得見。 你還會用字元 n-gram 處理拼字錯誤,並用 IDF 降低常見詞的權重。

先規範化,再分詞

轉成小寫可以減少無意差異,正規表達式可以提取類似單字的詞元。真實分詞器可能按標點、子詞或 Unicode 採用不同規則。重要的習慣是把分詞規則寫清楚,並在所有地方復用。

import re

def tokenize(text):
    return re.findall(r"[a-z']+", text.lower())

print(tokenize("Python makes text small."))

清楚、可重複的規則

構造共用詞表

向量需要固定順序的維度。收集訓練文件中的唯一詞元,排序以保證確定性,再把每個詞元映射到索引。詞表之外的詞元通常被忽略,或映射到未知詞元槽位。

docs = ["python text", "text models"]
vocab = sorted({word for doc in docs for word in doc.split()})
index = {word: i for i, word in enumerate(vocab)}
print(vocab)
print(index["text"])

先有詞表,再有索引

把詞元計數成向量

詞袋向量記錄每個詞表詞元出現的次數,有意忽略詞序。它速度快、容易檢查,但也意味著 「dog bites man」 和 「man bites dog」 會得到相同向量。

def vector(text, index):
    counts = [0] * len(index)
    for word in text.split():
        if word in index:
            counts[index[word]] += 1
    return counts

index = {"python": 0, "text": 1}
print(vector("python text text", index))

計數,而不是語法

用餘弦相似度比較

點積獎勵共同出現的詞元,向量長度則避免長文件佔據優勢。餘弦相似度等於點積除以兩個長度。除法前要檢查零向量,也要記住詞彙重疊不等於真正理解含義。

import math

def cosine(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = math.sqrt(sum(x * x for x in a))
    norm_b = math.sqrt(sum(y * y for y in b))
    if not norm_a or not norm_b:
        return 0.0
    return dot / (norm_a * norm_b)

print(round(cosine([1, 1], [1, 0]), 3))
print(round(cosine([1, 0], [0, 1]), 3))

共同詞越多,分數越高

字元 n-gram 能容忍拼字錯誤和未知詞

按詞切分的詞表會把拼錯的單字當成未知詞元。字元 n-gram 保留重疊片段,因此查詢仍可能與已知詞近似比對。代價是特徵空間更大、誤比對更多;當拼字和詞形變化重要時,可以使用 n-gram。

def trigrams(text):
    padded = " " + text.lower().replace(" ", "_") + " "
    return {padded[i:i + 3] for i in range(len(padded) - 2)}

query = trigrams("lattee")
known = trigrams("latte")
print(sorted(query & known))
print(round(len(query & known) / len(query | known), 2))

用共用片段代替精確詞元

用 IDF 降低常見詞的權重

原始計數會讓高頻詞佔據主導。逆文件頻率讓出現在許多文件中的詞權重更低。平滑後的 IDF 可避免除以零,加權後的向量仍可用餘弦相似度比較。

import math

docs = ["python text models", "python models", "text models"]
vocab = sorted({word for doc in docs for word in doc.split()})
df = {word: sum(word in doc.split() for doc in docs) for word in vocab}
idf = {word: math.log((1 + len(docs)) / (1 + df[word])) + 1 for word in vocab}

def vector(doc):
    words = doc.split()
    return [words.count(word) * idf[word] for word in vocab]

def cosine(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(y * y for y in b))
    return dot / (na * nb) if na and nb else 0.0

print({word: round(idf[word], 2) for word in vocab})
print([round(x, 2) for x in vector("python text")])
print(round(cosine(vector("python text"), vector("text models")), 2))

稀有詞攜帶更多訊號

本課要點

  • 分詞是明確的設計選擇,不是按空格切開那麼簡單。
  • 詞表為每個詞元提供穩定的向量維度。
  • 詞袋向量只統計詞元,不考慮詞序。
  • 餘弦相似度比較方向,並能處理不同長度的文件。
  • 詞彙相似不等於語意理解。
  • 字元 n-gram 能處理近似比對;IDF 會降低常見詞的拉力。

常見錯誤

  • 每篇文件單獨構造詞表,導致維度無法對齊。
  • 不檢查零向量長度就直接相除。
  • 把詞袋分數當成完整的語意度量。
  • 以為詞級分詞能處理所有拼字錯誤,結果把查詢當作未知詞悄悄丟掉。
輪到你

把兩個短句分詞,構造一個共用詞表,轉成計數向量,並列印保留三位小數的餘弦相似度。

動手試試

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

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

練習本課

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

查看 12 道題答案開始練習
← robots 與禮貌抓取Softmax →