評論裡有人說「燕麥拿鐵很棒」,有人說「燕麥奶酸了」,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 執行時,可能需要幾秒鐘。
練習本課
12 道題,作答後立即看到解釋。