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 建模與評估)#
- 語料分詞、句首尾加
<s></s>、低頻詞轉<UNK>。 - 統計各階 n-gram 計數表(unigram、bigram、…)。
- 預測:P(w|h) 用插值:λ₃P₃ + λ₂P₂ + λ₁P₁(λ 可按 count(h) 自適應,或 Kneser-Ney)。
- 評估:對測試集逐句算 log 機率,困惑度 = exp(−總 log 機率 / 總詞數)。
- 生成:從
<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 以下——模型表達力的直接量尺。
練習#
- 把插值權重 λ 改為依「上下文 count(h)」自適應(count 高→多信 trigram),與固定 λ₁=λ₂=λ₃ 比困惑度。
- 實作 Kneser-Ney 平滑的入門版:unigram 機率改用「詞作為不同上下文後繼的次數」,在稀疏測試句上比較與 add-k 的困惑度差異。
- 比較「以字為單位」與「以詞為單位」的 bigram 中文模型:字級詞表小、稀疏少;詞級單位大、語義強。用同一測試集算困惑度,寫 200 字分析中文該用哪個單位(這正是 7.2 節分詞難題的另一視角)。