跳轉到

9.2 語言模型從N-gram到神經網路#

概念說明#

語言模型(language model, LM)回答一個基本問題:一個詞序列出現的機率是多少?P(w₁w₂…wₙ)。它不只是學術玩具——輸入法選字、語音辨識、機器翻譯、拼字檢查(4.3 節的模糊比對配上 LM 才知道「哪個候選像人話」),全是 LM 的應用;而今天的 ChatGPT 本質就是一個超大 LM。

鏈式法則把序列機率分解:P(w₁…wₙ) = Π P(wᵢ | w₁…wᵢ₋₁)。問題是「前面所有歷史」的組合空間爆炸,無法直接統計。

N-gram 模型的經典近似:馬可夫假設——下一個詞只依賴前 N−1 個詞。bigram:P(wᵢ|wᵢ₋₁);trigram:P(wᵢ|wᵢ₋₂wᵢ₋₁)。機率從語料直接統計:P(w|h) = count(h,w)/count(h)。評估用困惑度(perplexity):PP = 2^{平均負 log 機率},直觀上是「模型平均在幾個等可能選項中猶豫」——越低越好,好模型的英文困惑度可從 200+(unigram)降到 100 以下(trigram)。

N-gram 的死穴是稀疏性:語料再大,大多數 trigram 也沒出現過,機率為 0 → 困惑度無窮大。解法是平滑:

  • Laplace/add-k:一律加 k(粗)。
  • Good-Turing:用「出現 r 次的物種數」重新分配機率。
  • 回退(backoff)/插值:trigram 沒見過就退回 bigram、unigram,按可靠度加權(Kneser-Ney 平滑是冠軍,至今仍是 n-gram 時代最佳)。
  • 未登錄詞處理:全轉 <UNK>。

神經語言模型(Bengio 2003 開端)換了思路:把詞變成向量(7.3 節),相似詞共享統計強度——「貓」的資訊自動幫助「狗」的預測,稀疏問題從根本緩解。架構演化:RNN(隱狀態遞迴傳遞全部歷史)→ LSTM/GRU(門控解決梯度消失,能記住長距離依賴)→ Transformer(9.3 節,注意力直接看全部歷史,可完全平行訓練)。

從 2017 年 Transformer 到 LLM,語言模型的目標從「估機率」升級為「通用能力湧現」:GPT 系列在網路級語料上自監督預訓練「預測下一個詞」,再經指令微調與人類回饋(RLHF)對齊。但核心公式沒變——輸入前文、輸出下一詞的分布;改變的是參數量(10⁶ → 10¹¹+)與資料量。

演算法步驟(N-gram 建模與評估)#

  1. 語料分詞、句首尾加 <s> </s>、低頻詞轉 <UNK>。
  2. 統計各階 n-gram 計數表(unigram、bigram、…)。
  3. 預測:P(w|h) 用插值:λ₃P₃ + λ₂P₂ + λ₁P₁(λ 可按 count(h) 自適應,或 Kneser-Ney)。
  4. 評估:對測試集逐句算 log 機率,困惑度 = exp(−總 log 機率 / 總詞數)。
  5. 生成:從 <s> 起,每步按模型分布採樣下一詞,直到 </s>。

Python 程式碼範例#

import math
from collections import Counter, defaultdict

corpus = ("演算法 是 處理 資料 的 方法 。 資料結構 支撐 演算法 。 "
          "深度學習 是 演算法 的 一種 。 演算法 處理 資料 的 效率 很 重要 。 "
          "資料 的 結構 決定 演算法 的 效率 。").split()
sents = [s.split("。") for s in " ".join(corpus).split("。 ")]

class NgramLM:
    def __init__(self, n=2):
        self.n = n
        self.counts = [Counter() for _ in range(n)]   # 1..n gram 計數

    def fit(self, tokens):
        toks = ["<s>"] * (self.n - 1) + tokens + ["</s>"]
        for i, w in enumerate(toks):
            for k in range(1, self.n + 1):
                if i + k <= len(toks):
                    self.counts[k - 1][tuple(toks[i:i + k])] += 1

    def prob(self, words):
        """插值平滑的機率"""
        toks = ["<s>"] * (self.n - 1) + list(words)
        p = 0.0
        for k in range(self.n, 0, -1):
            ctx = tuple(toks[-k + 1:]) if k > 1 else ()
            num = self.counts[k - 1][ctx + (words[-1],)]
            den = self.counts[k - 2][ctx] if k > 1 else sum(
                c for g, c in self.counts[0].items())
            if den:
                p += (num + 1) / (den + len(self.counts[0]))
        return p / self.n

    def perplexity(self, tokens):
        toks = ["<s>"] * (self.n - 1) + tokens + ["</s>"]
        ll = sum(math.log(self.prob(toks[i - self.n + 1:i + 1]))
                 for i in range(self.n - 1, len(toks)))
        return math.exp(-ll / (len(toks) - self.n + 1))

    def generate(self, max_len=12):
        import random
        out = ["<s>"] * (self.n - 1)
        for _ in range(max_len):
            ctx = tuple(out[-(self.n - 1):]) if self.n > 1 else ()
            cands = [(g[-1], c) for g, c in self.counts[self.n - 1].items()
                     if g[:-1] == ctx]
            if not cands:
                break
            tot = sum(c for _, c in cands)
            r, acc = random.random() * tot, 0
            for w, c in cands:
                acc += c
                if acc >= r:
                    break
            if w == "</s>":
                break
            out.append(w)
        return " ".join(out[self.n - 1:]) or "(請再生成一次)"

lm2, lm3 = NgramLM(2), NgramLM(3)
lm2.fit(corpus); lm3.fit(corpus)
print("bigram 生成:", lm2.generate())
print("trigram 生成:", lm3.generate())
test = "演算法 處理 資料".split()
print(f"困惑度 bigram={lm2.perplexity(test):.2f} "
      f"trigram={lm3.perplexity(test):.2f}")
print("P(。|演算法) =", round(lm2.prob(["演算法", "。"]), 4))

複雜度分析#

  • 訓練:O(總詞數×n) 統計;空間 O(不同 n-gram 數)——trigram 表常達數十 GB(1 億詞語料),需壓縮 trie/量化。
  • 評估:每詞 O(n) 查表,困惑度計算 O(總詞數)。
  • 生成:每步 O(候選數) 採樣;神經模型每步 O(參數量) 次運算(GPU 平行化)。
  • 量級對比:5-gram Kneser-Ney 困惑度約 80–100;LSTM 約 50–60;現代 Transformer LM 在同一基準可到 20 以下——模型表達力的直接量尺。

練習#

  1. 把插值權重 λ 改為依「上下文 count(h)」自適應(count 高→多信 trigram),與固定 λ₁=λ₂=λ₃ 比困惑度。
  2. 實作 Kneser-Ney 平滑的入門版:unigram 機率改用「詞作為不同上下文後繼的次數」,在稀疏測試句上比較與 add-k 的困惑度差異。
  3. 比較「以字為單位」與「以詞為單位」的 bigram 中文模型:字級詞表小、稀疏少;詞級單位大、語義強。用同一測試集算困惑度,寫 200 字分析中文該用哪個單位(這正是 7.2 節分詞難題的另一視角)。