Transformer 前的文本生成


文档摘要

Transformer 前的文本生成 本节摘要:一个词如果总是让人意外,模型就是坏的;困惑度把「意外」变成一个数,平滑让它保持有限。在 Transformer、RNN、词嵌入出现之前,语言模型靠「数前 n-1 个词之后这个字出现过几次」来预测下一个词:数到「the cat」之后「sat」47 次、「jumped」12 次、「refrigerator」0 次,归一化即得概率分布。这就是 n-gram 语言模型,1980 到 2015 年间每个语音识别器、拼写检查器、基于短语的机器翻译系统都跑它,今天需要廉价端侧语言建模时仍在用。真正有趣的问题是:没见过的 n-gram 怎么办?原始计数模型给一切未见事件零概率,而句子很长、几乎每句都含至少一个未见序列,这灾难性的。

Transformer 前的文本生成

本节摘要:一个词如果总是让人意外,模型就是坏的;困惑度把「意外」变成一个数,平滑让它保持有限。在 Transformer、RNN、词嵌入出现之前,语言模型靠「数前 n-1 个词之后这个字出现过几次」来预测下一个词:数到「the cat」之后「sat」47 次、「jumped」12 次、「refrigerator」0 次,归一化即得概率分布。这就是 n-gram 语言模型,1980 到 2015 年间每个语音识别器、拼写检查器、基于短语的机器翻译系统都跑它,今天需要廉价端侧语言建模时仍在用。真正有趣的问题是:没见过的 n-gram 怎么办?原始计数模型给一切未见事件零概率,而句子很长、几乎每句都含至少一个未见序列,这灾难性的。半个世纪的平滑研究修好了它,Kneser-Ney 是成果,现代深度学习继承了它的实证传统。本节从计数、平滑、生成一路搭到困惑度,让你看清语言模型这门游戏的规则其实从未改变——只是玩家越来越强。

对应原课程:Phase 5 · Lesson 16 · text-generation-pre-transformer(原英文 phases/05-nlp-foundations-to-advanced/16-text-generation-pre-transformer/docs/en.md)。前置依赖:第 01 节(文本处理)。

学习目标

阅读完本节,你应当能够:

  1. 复述 n-gram 语言模型的计数与归一化,并说清零计数问题为何致命。
  2. 列出六种平滑方法(Laplace、Good-Turing、插值、回退、绝对折损、Kneser-Ney)的递进逻辑。
  3. 讲透 Kneser-Ney 的延续概率为何能把「San Francisco」式的陷阱修对。
  4. 困惑度评估语言模型,并指出它只在相同分词下可比较。

一、问题与直觉

在 Transformer、RNN、词嵌入之前,语言模型预测下一个词靠的是「数前 n-1 个词之后这个字出现过几次」。数到「the cat」之后「sat」47 次、「jumped」12 次、「refrigerator」0 次,归一化得到概率分布。

这就是 n-gram 语言模型。1980 到 2015 年间每个语音识别器、拼写检查器、基于短语的机器翻译系统都跑它。需要廉价端侧语言建模时,它今天仍在用。

真正有趣的问题是没见过的 n-gram 怎么办。原始计数模型给一切未见事件零概率,这灾难性——句子很长,几乎每句都含至少一个未见序列。半个世纪的平滑研究修好了它,Kneser-Ney 是结果,现代深度学习继承了它的实证传统。

预测游戏

在任何这套机器存在之前,有一个实验定义了「语言模型是什么」。盖住一句英文的下一个字母,让人一次猜一个,猜对为止,记下猜的次数,重复几百个字母。

这些猜的次数不是冷知识,而是文本的无损重编码:把次数序列交给第二个一模一样的猜字者,他能还原每个字母,因为在每个位置他都知道哪些猜测优先。能用更少符号重编码的消息,每个符号携带的信息更少,所以猜次统计给英语的熵设了上限。

Shannon 1951 年跑了这实验,得到一个至今主宰本领域的数。27 个符号的字母表(26 字母加空格)本可携带 log2(27) ≈ 4.75 比特每字母;带 100 字母上下文的人类猜字者落在 0.6~1.3 比特每字母。英语大约有四分之三是「被迫的走法」。一个模型必须学的结构,在任何模型能学它之前就被测出来了。

自此之后的每个语言模型都是这个游戏的机械玩家,本节的每个评估数字都是这个游戏的计分:

  • 交叉熵损失是模型每个符号平均需要的比特数。训练语言模型就是在字面意义上最小化它的猜字得分
  • 困惑度(Perplexity)2^bits(或 e^nats):猜完后模型仍面对的分支因子。27 个符号上均匀猜的困惑度是 27;每字母 1 比特的玩家困惑度是 2。
  • 上下文长度是玩家的记忆。三元组模型用两个 token 的记忆玩;Transformer 用十万个 token 玩同一个游戏。规则从未改变,只是玩家变强了。

💡 单位切换:游戏按字母、以比特(log2)计分,而下面的 n-gram 公式按词 token、以纳特(自然对数)计分。因为纳特下的困惑度 e^H 等于比特下的 2^H,两种视角只是同一测量的不同单位。

N-gram 概率:P(w_i | w_{i-n+1}, ..., w_{i-1})。固定 n(三元组通常 3,四元组 4),从计数算:

P(w | context) = count(context, w) / count(context)

零计数问题:训练里没见过的任何 n-gram 概率为零。2007 年对 Brown 语料的一项研究发现,即便四元组模型也有 30% 的留出四元组在训练里未见。没有平滑,你在任何真实文本上都评估不了。

平滑方法,按精巧程度递进:

  1. Laplace(加一):每个计数加 1。简单,对罕见事件糟糕。
  2. Good-Turing:按「频率的频率」把高频事件的质量重分配给未见事件。
  3. 插值:用可调权重组合 n-gram、(n-1)-gram 等估计。
  4. 回退(Backoff):n-gram 计数为零就退到 (n-1)-gram。Katz 回退做归一化。
  5. 绝对折损:所有计数减去固定折损 D,重分配给未见。
  6. Kneser-Ney:绝对折损,加上对低阶模型的精巧选择——用延续概率(一个词出现在多少种上下文里)而非原始频率。

Kneser-Ney 的洞见很深。「San Francisco」是常见二元组,「Francisco」几乎只在「San」后出现。朴素绝对折损给「Francisco」高的一元概率(因为计数高)。Kneser-Ney 注意到「Francisco」只出现在一种上下文里,于是相应降低它的延续概率。结果:以「Francisco」结尾的新二元组得到恰当的低概率。

评估:困惑度。 在留出测试集上,每词平均负对数似然的指数。越低越好。困惑度 100 意味着模型和在 100 个词里均匀选一样困惑。

perplexity = exp(- (1/N) * Σ log P(w_i | context_i))

二、从零实现

第 1 步:三元组计数

from collections import Counter, defaultdict def train_ngram(corpus_tokens, n=3): ngrams = Counter() contexts = Counter() for sentence in corpus_tokens: padded = ["<s>"] * (n - 1) + sentence + ["</s>"] for i in range(len(padded) - n + 1): ctx = tuple(padded[i:i + n - 1]) word = padded[i + n - 1] ngrams[ctx + (word,)] += 1 contexts[ctx] += 1 return ngrams, contexts def raw_probability(ngrams, contexts, context, word): ctx = tuple(context) if contexts.get(ctx, 0) == 0: return 0.0 return ngrams.get(ctx + (word,), 0) / contexts[ctx]

输入是分好词的句子列表,输出是 n-gram 计数与上下文计数。<s></s> 是句界标记。

第 2 步:Laplace 平滑

def laplace_probability(ngrams, contexts, vocab_size, context, word): ctx = tuple(context) numerator = ngrams.get(ctx + (word,), 0) + 1 denominator = contexts.get(ctx, 0) + vocab_size return numerator / denominator

每个计数加 1。能平滑,但给未见事件分配过多质量,也伤了「罕见但已知」的事件。

第 3 步:Kneser-Ney(二元组,插值式)

def kneser_ney_bigram_model(corpus_tokens, discount=0.75): unigrams = Counter() bigrams = Counter() unigram_contexts = defaultdict(set) for sentence in corpus_tokens: padded = ["<s>"] + sentence + ["</s>"] for i, w in enumerate(padded): unigrams[w] += 1 if i > 0: prev = padded[i - 1] bigrams[(prev, w)] += 1 unigram_contexts[w].add(prev) total_unique_bigrams = sum(len(ctx_set) for ctx_set in unigram_contexts.values()) continuation_prob = { w: len(ctx_set) / total_unique_bigrams for w, ctx_set in unigram_contexts.items() } context_totals = Counter() for (prev, w), count in bigrams.items(): context_totals[prev] += count unique_follow = defaultdict(set) for (prev, w) in bigrams: unique_follow[prev].add(w) def prob(prev, w): count = bigrams.get((prev, w), 0) denom = context_totals.get(prev, 0) if denom == 0: return continuation_prob.get(w, 1e-9) first_term = max(count - discount, 0) / denom lambda_prev = discount * len(unique_follow[prev]) / denom return first_term + lambda_prev * continuation_prob.get(w, 1e-9) return prob

三个活动部件。continuation_prob 捕捉「这个词出现在多少种不同上下文里」(Kneser-Ney 的创新);lambda_prev 是折损释放出的、用来加权回退的质量;最终概率是折损后的主项加上加权延续项。

第 4 步:采样生成文本

import random def generate(prob_fn, vocab, prefix, max_len=30, seed=0): rng = random.Random(seed) tokens = list(prefix) for _ in range(max_len): candidates = [(w, prob_fn(tokens[-1], w)) for w in vocab] total = sum(p for _, p in candidates) r = rng.random() * total acc = 0.0 for w, p in candidates: acc += p if r <= acc: tokens.append(w) break if tokens[-1] == "</s>": break return tokens

按概率比例采样,每个 seed 都给不同输出。要类似 beam search 的输出,每步取 argmax(贪心),再加一个小随机旋钮(温度)。

第 5 步:困惑度

import math def perplexity(prob_fn, sentences): total_log_prob = 0.0 total_tokens = 0 for sentence in sentences: padded = ["<s>"] + sentence + ["</s>"] for i in range(1, len(padded)): p = prob_fn(padded[i - 1], padded[i]) total_log_prob += math.log(max(p, 1e-12)) total_tokens += 1 return math.exp(-total_log_prob / total_tokens)

越低越好。对 Brown 语料,调好的四元组 KN 模型困惑度约 140;Transformer LM 在同一测试集上 15~30。差距约十倍——这个差距就是本领域继续前进的原因。

三、框架对比

  • 经典 NLP 教学:对平滑、MLE、困惑度最清晰的暴露。
  • KenLM:生产级 n-gram 库,在低延迟重要的语音与机器翻译系统里当重排器。
  • 端侧自动补全:键盘里的三元组模型,至今仍在。
  • 基线:声明你的神经 LM 好之前,总先算一个 n-gram LM 困惑度。Transformer 不大幅碾压 KN,就说明有问题。

四、可复用产物

保存为 outputs/prompt-lm-baseline.md:

--- name: lm-baseline description: Build a reproducible n-gram language model baseline before training a neural LM. phase: 5 lesson: 16 --- Given a corpus and target use (next-word prediction, rescoring, perplexity baseline), output: 1. N-gram order. Trigram for general English, 4-gram if corpus is large, 5-gram for speech rescoring. 2. Smoothing. Modified Kneser-Ney is the default; Laplace only for teaching. 3. Library. `kenlm` for production, `nltk.lm` for teaching, roll your own only to learn. 4. Evaluation. Held-out perplexity with consistent tokenization between train and test sets. Refuse to report perplexity computed with different tokenization between systems being compared — perplexity numbers are comparable only under identical tokenization. Flag OOV rate in test set; KN handles OOV poorly unless you reserve a special <UNK> token during training.

五、练习

  1. 基础:在 1000 句莎士比亚语料上训一个三元组 LM,生成 20 句。它们会局部合理而全局不连贯——这是经典演示。
  2. 进阶:在留出莎士比亚上为你的 KN 模型实现困惑度,对比 Laplace。应看到 KN 困惑度低 30~50%。
  3. 挑战:搭一个三元组拼写纠错器:给定拼错的词及其上下文,生成候选并按 LM 下的上下文概率排序,在公开的 Birkbeck 拼错语料上评估。

本节要点回顾

  1. n-gram LM 靠计数预测:P(w|context) = count(context,w) / count(context),1980~2015 主宰语音、拼写、机器翻译。
  2. 零计数问题致命:未见 n-gram 概率为零,几乎每句都含未见序列,必须平滑。
  3. 预测游戏定义语言模型:Shannon 1951,猜次是无损重编码,英语熵约 0.6~1.3 bit/字母。
  4. 交叉熵是平均比特数:训练 LM 就是字面最小化猜字得分。
  5. 困惑度 = 2^bits:猜完后仍面对的分支因子,越低越好。
  6. 六种平滑递进:Laplace→Good-Turing→插值→回退→绝对折损→Kneser-Ney。
  7. Kneser-Ney 的延续概率用「出现在多少种上下文」而非原始频率,修好了「San Francisco」陷阱。
  8. 上下文长度是玩家记忆:三元组两 token,Transformer 十万 token,规则从未变。
  9. 困惑度只在相同分词下可比:不同分词的困惑度数字毫无意义。
  10. 调好的 KN 四元组困惑度约 140,Transformer 15~30:十倍差距就是本领域前进的原因。

下一节,我们把这些生成能力装进一个交互壳里——进入「聊天机器人」,看从基于规则的有限状态机到神经生成,对话系统如何一步步演化。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U