Transformer 前的文本生成 本节摘要:一个词如果总是让人意外,模型就是坏的;困惑度把「意外」变成一个数,平滑让它保持有限。在 Transformer、RNN、词嵌入出现之前,语言模型靠「数前 n-1 个词之后这个字出现过几次」来预测下一个词:数到「the cat」之后「sat」47 次、「jumped」12 次、「refrigerator」0 次,归一化即得概率分布。这就是 n-gram 语言模型,1980 到 2015 年间每个语音识别器、拼写检查器、基于短语的机器翻译系统都跑它,今天需要廉价端侧语言建模时仍在用。真正有趣的问题是:没见过的 n-gram 怎么办?原始计数模型给一切未见事件零概率,而句子很长、几乎每句都含至少一个未见序列,这灾难性的。
本节摘要:一个词如果总是让人意外,模型就是坏的;困惑度把「意外」变成一个数,平滑让它保持有限。在 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 节(文本处理)。
阅读完本节,你应当能够:
在 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 比特每字母。英语大约有四分之三是「被迫的走法」。一个模型必须学的结构,在任何模型能学它之前就被测出来了。
自此之后的每个语言模型都是这个游戏的机械玩家,本节的每个评估数字都是这个游戏的计分:
2^bits(或 e^nats):猜完后模型仍面对的分支因子。27 个符号上均匀猜的困惑度是 27;每字母 1 比特的玩家困惑度是 2。💡 单位切换:游戏按字母、以比特(
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% 的留出四元组在训练里未见。没有平滑,你在任何真实文本上都评估不了。
平滑方法,按精巧程度递进:
D,重分配给未见。Kneser-Ney 的洞见很深。「San Francisco」是常见二元组,「Francisco」几乎只在「San」后出现。朴素绝对折损给「Francisco」高的一元概率(因为计数高)。Kneser-Ney 注意到「Francisco」只出现在一种上下文里,于是相应降低它的延续概率。结果:以「Francisco」结尾的新二元组得到恰当的低概率。
评估:困惑度。 在留出测试集上,每词平均负对数似然的指数。越低越好。困惑度 100 意味着模型和在 100 个词里均匀选一样困惑。
perplexity = exp(- (1/N) * Σ log P(w_i | context_i))
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> 是句界标记。
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。能平滑,但给未见事件分配过多质量,也伤了「罕见但已知」的事件。
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 是折损释放出的、用来加权回退的质量;最终概率是折损后的主项加上加权延续项。
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(贪心),再加一个小随机旋钮(温度)。
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。差距约十倍——这个差距就是本领域继续前进的原因。
保存为 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.
P(w|context) = count(context,w) / count(context),1980~2015 主宰语音、拼写、机器翻译。下一节,我们把这些生成能力装进一个交互壳里——进入「聊天机器人」,看从基于规则的有限状态机到神经生成,对话系统如何一步步演化。