跳轉到

7.3 詞向量與文字表示入門#

概念說明#

演算法要計算,詞就要先變成數。文字表示法的演進,正是 NLP 半世紀的縮影:

  1. One-hot 編碼:詞表大小 N 的向量,只有自己那維是 1。問題:任何兩詞的內積都是 0——「貓」與「狗」的相似度和「貓」與「飛機」完全相同,語義資訊全無;且維度爆炸。
  2. 詞袋(Bag of Words, BoW):文件 = 詞頻向量。能表達「哪些詞出現」,仍無法表達「詞與詞相似」,且向量維度 = 詞表大小。TF-IDF(8.2 節)是它的加權改良。
  3. 分散式表示(distributional hypothesis):「看一個詞的伴,知這個詞的義」——Firth 的名言。若「貓」與「狗」的上下文分布相似,它們的向量就應該相近。詞向量(word embedding)把每詞映射成固定維度(如 100~300 維)的稠密實數向量,語義近的詞在向量空間中相鄰。
  4. word2vec(Mikolov et al., 2013):兩種架構——CBOW(用上下文詞預測中心詞)與 Skip-gram(用中心詞預測上下文詞)。訓練目標極簡:一個小型神經網路(兩層線性層)在真實語料上做好「預測上下文」這件事,訓練完的權重矩陣的每一列就是詞向量。為了能在數十億詞上訓練,用負採樣(negative sampling):把多分類問題簡化為「真實上下文 vs 隨機抽的干擾詞」的二元分類,計算量驟降。GloVe 則從「共現矩陣分解」出發得到類似效果。

詞向量最迷人的性質是語義算術:king − man + woman ≈ queen、Paris − France + Taiwan ≈ Taipei。線性關係存在的原因:模型為了預測上下文,被迫把「性別」「首都關係」這些系統性差異編碼成向量方向。實務上,詞向量是分類、聚類、推薦、搜尋語義擴展的通用特徵。

現代的延伸:FastText 用子詞(字元 n-gram)組合詞向量,能處理拼寫變體與未登錄詞;contextual embeddings(ELMo、BERT,9.3 節)更進一步——同一個詞在不同句子裡向量不同,解決了 word2vec「一詞一向量」無法處理多義詞(bank 銀行/河岸)的問題。

演算法步驟(Skip-gram + 負採樣)#

  1. 語料分詞、建詞表,高頻詞按 P_discard(w) = 1 − √(t/f(w)) 隨機降採樣(the 這種詞少進訓練)。
  2. 對每個中心詞 w,取視窗 ±c 內的上下文詞 u:
  3. 正例 (w, u):標籤 1。
  4. 負例 (w, uᵢ):k 個,uᵢ 按單詞頻率的 3/4 次方抽,標籤 0。
  5. 目標函數:maximize Σ log σ(vᵤ·v_w) + Σ log σ(−v_{uᵢ}·v_w),σ 為 sigmoid。
  6. 以隨機梯度下降更新兩個矩陣(輸入向量 v_w、輸出向量 v_u),訓練完取輸入矩陣為詞向量。
  7. 評估:相似詞最近鄰、詞類比(king−man+woman)、或下游任務表現。

Python 程式碼範例#

import numpy as np
from collections import Counter

corpus = ("演算法 是 處理 資料 的 方法 深度學習 是 演算法 的 一種 "
          "資料 結構 支撐 演算法 演算法 與 資料 結構 是 電腦 科學 "
          "的 核心 深度學習 需要 大量 資料 演算法 處理 資料 的 效率 "
          "決定 程式 的 效率 電腦 科學 研究演算法 與 資料 結構").split()
vocab = {w: i for i, (w, _) in enumerate(Counter(corpus).most_common())}
V, D = len(vocab), 8

rng = np.random.default_rng(0)
W_in = rng.normal(0, 0.1, (V, D))     # 輸入向量(詞向量)
W_out = rng.normal(0, 0.1, (V, D))
freq = np.array([Counter(corpus)[w] for w in vocab])
neg_pool = np.repeat(np.arange(V), (freq ** 0.75).astype(int))

def sigmoid(x): return 1 / (1 + np.exp(-x))

ids = [vocab[w] for w in corpus]
lr, window, k = 0.05, 2, 3
for epoch in range(30):
    loss = 0
    for i, c in enumerate(ids):
        ctx = ids[max(0, i - window):i] + ids[i + 1:i + 1 + window]
        for u in ctx:
            for target, label in [(u, 1)] + [
                    (neg_pool[rng.integers(len(neg_pool))], 0)] * k:
                v, t = W_in[c], W_out[target]
                score = sigmoid(v @ t)
                g = lr * (label - score)
                loss -= np.log(max(score if label else 1 - score, 1e-9))
                W_out[target] += g * v
                W_in[c]      += g * t
    if epoch == 29:
        print(f"最終 loss={loss:.2f}")

def nearest(word, top=4):
    v = W_in[vocab[word]]
    sims = W_in @ v / (np.linalg.norm(W_in, axis=1) * np.linalg.norm(v))
    order = np.argsort(-sims)
    return [(list(vocab)[i], round(sims[i], 3))
            for i in order[1:top + 1] if list(vocab)[i] != word]

for w in ["演算法", "資料", "深度學習"]:
    print(w, "->", nearest(w))

複雜度分析#

  • 訓練:每個 (中心詞, 上下文) 對 O(k+1) 次向量內積與更新,每次 O(D);總 O(語料詞數 × 2c × k × D)。D=300、k=5、c=5 時,10 億詞約 10¹³ 次浮點運算——必須用 GPU/分散式或更省的技巧。
  • 查詢最近鄰:暴力 O(V×D);向量維度大時用 ANN 索引(HNSW、IVF,將於 8 章向量檢索思想再遇)壓到次線性。
  • 空間:兩個 V×D 矩陣;V=10 萬、D=300 時每矩陣約 120 MB(float32)。
  • 表示法量尺:one-hot 內積恆 0(無相似度);BoW 同上;詞向量則餘弦相似度有意義——這是質的飛躍。

練習#

  1. 把範例的 D 提到 32、window 提到 5,重新訓練並觀察最近鄰品質變化;再用「詞類比」測試(如 核心−電腦+科學)驗證向量算術是否成立。
  2. 實作 降採樣:按 P_discard 隨機丟棄高頻詞後再訓練,比較訓練時間與相似詞結果,解釋為何丟掉 "的" 反而提升品質。
  3. 用 gensim.models.Word2Vec(若可安裝)在同一語料訓練,與手作版比較最近鄰;再思考:中文詞向量需要先分詞(7.2 節)——如果不分詞直接以字為單位訓練,會得到什麼?