Back to writing

/ What Is Series

What Is an N-Gram Model?

A practical explanation of N-Gram language models: chain rule, Markov assumption, count-based estimation, sparsity, smoothing, backoff, interpolation, and how they differ from Transformers.

3 minNLP · Language Model · N-Gram · Transformer

**N-Gram Model(N 元语法模型)**是一类经典的统计语言模型。它的核心思想很直接:

用前面最近的 N - 1 个词或 token,去预测下一个词或 token 出现的概率。

例如一句话:

我 喜欢 吃 苹果

不同的 N 对应不同的上下文长度:

  • Unigram,1-gram:不看上下文,只看每个词自身出现的频率;
  • Bigram,2-gram:用前 1 个词预测下一个词;
  • Trigram,3-gram:用前 2 个词预测下一个词;
  • 4-gram:用前 3 个词预测下一个词。

一句话概括:N-Gram 是一种非参数化、固定窗口、基于离散频次查表的自回归语言模型。

它和现代大语言模型一样,都在做“预测下一个 token”。区别在于:N-Gram 靠短窗口频次统计,Transformer 靠长上下文神经网络计算。

1. 从语言模型说起#

语言模型想回答的问题是:

一句话出现的概率是多少?

比如:

我 喜欢 吃 苹果

可以写成:

P(我, 喜欢, 吃, 苹果)

根据概率链式法则,整句话的概率可以拆成一连串条件概率:

P(w1, w2, ..., wT)
= P(w1) * P(w2 | w1) * P(w3 | w1, w2) * ... * P(wT | w1, ..., wT-1)

也就是:每个词的概率都依赖它前面已经出现的所有词。

如果真的完整保留所有历史,统计空间会非常大。对于稍长一点的句子,几乎不可能在训练语料中找到完全相同的历史上下文。N-Gram 的做法是引入一个近似假设:

P(wt | w1, ..., wt-1)
≈ P(wt | wt-N+1, ..., wt-1)

也就是说,下一个词只依赖最近的 N - 1 个词。

这个假设通常叫做 N - 1 阶马尔可夫假设。它牺牲了长距离上下文,换来了可统计、可存储、可推理的模型。

2. 不同 N 到底看什么#

假设句子是:

今天 天气 很 好

2.1 Unigram#

Unigram 完全不考虑词序:

P(今天 天气 很 好)
= P(今天) * P(天气) * P(很) * P(好)

因此在 Unigram 看来,下面两句话可能拥有相同概率:

今天 天气 很 好
好 很 天气 今天

只要词的集合和出现次数一样,它就无法区分顺序是否合理。

2.2 Bigram#

Bigram 每一步只看前一个词:

P(今天 天气 很 好)
= P(今天) * P(天气 | 今天) * P(很 | 天气) * P(好 | 很)

它已经能够学习局部搭配,比如:

天气 很
很 好

但它仍然看不到更远的上下文。

2.3 Trigram#

Trigram 每一步看前两个词:

P(今天 天气 很 好)
= P(今天) * P(天气 | 今天) * P(很 | 今天, 天气) * P(好 | 天气, 很)

相比 Bigram,Trigram 能利用更长一点的局部结构。例如它不仅知道 很 -> 好 常见,也可以知道 天气 很 -> 好 更合理。

3. N-Gram 概率怎么估计#

N-Gram 的训练本质上是计数。

对于一个上下文 h 和下一个词 w,最大似然估计可以写成:

P(w | h) = Count(h, w) / Count(h)

如果是 Bigram:

P(wt | wt-1) = Count(wt-1, wt) / Count(wt-1)

如果是 Trigram:

P(wt | wt-2, wt-1) = Count(wt-2, wt-1, wt) / Count(wt-2, wt-1)

这里的 Count(...) 表示某个 token 序列在训练语料中出现的次数。

举个 Bigram 的例子。假设训练语料是:

我 喜欢 吃 苹果
我 喜欢 吃 香蕉
我 喜欢 打 篮球

我们想估计:

P(吃 | 喜欢)

统计 喜欢 后面出现过什么:

喜欢 吃
喜欢 吃
喜欢 打

所以:

P(吃 | 喜欢) = Count(喜欢, 吃) / Count(喜欢) = 2 / 3
P(打 | 喜欢) = Count(喜欢, 打) / Count(喜欢) = 1 / 3

因此 Bigram 模型看到 我 喜欢 之后,更倾向于继续生成

4. 句子开始和结束也要建模#

真实实现里通常会给句子加上特殊 token:

<BOS> 我 喜欢 吃 苹果 <EOS>

其中:

  • <BOS> 表示句子开始;
  • <EOS> 表示句子结束。

这样模型不仅能学到句子内部的转移概率,也能学到:

P(我 | <BOS>)
P(<EOS> | 苹果)

对于 Bigram,整句话概率可以写成:

P(我 喜欢 吃 苹果)
= P(我 | <BOS>)
* P(喜欢 | 我)
* P(吃 | 喜欢)
* P(苹果 | 吃)
* P(<EOS> | 苹果)

这也解释了为什么 N-Gram 可以生成文本:只要从 <BOS> 开始,不断根据当前上下文采样下一个 token,直到采到 <EOS> 即可。

5. 为什么 N 不能无限增大#

直觉上,N 越大,模型看到的上下文越多,预测应该越准。但在统计语言模型里,N 变大很快会遇到两个问题。

5.1 N 太小:上下文不足#

Bigram 只看前一个词。比如看到:

银行 利率

它无法利用更长的上下文:

中央银行决定降低利率
商业银行决定提高存款利率

如果只靠最近一个词,模型很难区分不同语义场景。

5.2 N 太大:数据稀疏严重#

如果使用 10-gram,模型需要统计很多很长的连续片段:

我 昨天 晚上 和 朋友 一起 去了 上海 外滩

这样的完整序列在训练语料中可能一次都没有出现。于是模型无法直接估计它的概率。

组合数量也会快速膨胀。假设词表大小是 V,理论上的 N-Gram 组合数是:

V^N

如果词表有 50,000 个词:

Bigram:  50,000^2 = 2,500,000,000
Trigram: 50,000^3 = 125,000,000,000,000

真实语料只能覆盖其中很小一部分。所以传统统计语言模型常见的是 Bigram、Trigram、4-gram、5-gram,很少直接把 N 设得特别大。

6. 未见过的 N-Gram 怎么办#

最大似然估计有一个致命问题:未见过的组合概率为 0。

假设训练语料里从未出现过:

喜欢 榴莲

那么 Bigram 会得到:

P(榴莲 | 喜欢) = 0

一旦句子里有一个 N-Gram 概率为 0,整句话概率就会变成 0:

P(sentence) = p1 * p2 * ... * 0 * ... * pT = 0

这显然不合理。未见过不代表不可能,只代表训练语料没有覆盖到。因此 N-Gram 模型通常必须使用 smoothing(平滑)

6.1 加一平滑#

最直观的方法是给每个候选词都加 1 次伪计数:

P(w | h) = (Count(h, w) + 1) / (Count(h) + V)

其中:

  • h 是历史上下文;
  • w 是下一个 token;
  • V 是词表大小。

这样所有未出现过的组合都会得到一个非零概率。

不过加一平滑通常过于粗糙:它会从高频事件里拿走太多概率,分给大量低概率甚至不合理的事件。

6.2 更常见的平滑方法#

经典统计语言模型里更成熟的方法包括:

  • Good-Turing smoothing;
  • Katz backoff;
  • Jelinek-Mercer interpolation;
  • Kneser-Ney smoothing。

其中 Kneser-Ney smoothing 通常被认为是 N-Gram 语言模型里非常强的平滑方法。它不只看一个词出现了多少次,还会考虑这个词出现在多少种不同上下文后面。

例如 of San Francisco 可能很常见,但 Francisco 作为任意上下文后的泛化预测并不一定应该很高;Kneser-Ney 会更谨慎地分配这类概率。

7. Backoff 和 Interpolation#

当高阶 N-Gram 没见过时,有两种常见思路:回退和插值。

7.1 Backoff:高阶没有就退到低阶#

假设我们想计算:

P(苹果 | 我, 喜欢, 吃)

如果 4-gram:

我 喜欢 吃 苹果

没有出现过,可以退回 Trigram:

P(苹果 | 喜欢, 吃)

如果 Trigram 也没有出现,就继续退回 Bigram:

P(苹果 | 吃)

再没有,就退回 Unigram:

P(苹果)

这叫 backoff(回退)。它的直觉是:优先相信更具体的上下文;如果没有统计证据,就使用更粗粒度但更稳定的低阶模型。

7.2 Interpolation:不同阶一起投票#

插值不是只在高阶缺失时才退回低阶,而是始终把不同阶模型加权融合:

P(wt)
= lambda3 * P(wt | wt-2, wt-1)
+ lambda2 * P(wt | wt-1)
+ lambda1 * P(wt)

其中:

lambda1 + lambda2 + lambda3 = 1

它的好处是更稳:即使高阶统计存在,也不会完全忽略低阶统计带来的全局信息。

8. N-Gram 如何生成文本#

以 Bigram 为例,当前词是:

模型查表得到下一个词分布:

P(喜欢 | 我) = 0.5
P(想   | 我) = 0.3
P(今天 | 我) = 0.2

然后从这个分布中采样。假设采到 喜欢,模型继续查看:

P(w | 喜欢)

如果采到 ,再继续查看:

P(w | 吃)

直到生成:

我 喜欢 吃 苹果

这个过程和现代大模型的自回归生成形式很像:

prefix -> next token distribution -> sample/select token -> new prefix -> next token distribution

区别在于分布从哪里来:

  • N-Gram:来自离散频次表;
  • Transformer:来自神经网络前向计算。

9. 一个简单 Python 实现#

下面是一个极简 Bigram 模型:

from collections import Counter, defaultdict
 
sentences = [
    ["<BOS>", "我", "喜欢", "吃", "苹果", "<EOS>"],
    ["<BOS>", "我", "喜欢", "吃", "香蕉", "<EOS>"],
    ["<BOS>", "我", "喜欢", "打", "篮球", "<EOS>"],
]
 
bigram_counts = defaultdict(Counter)
 
for sentence in sentences:
    for current_word, next_word in zip(sentence[:-1], sentence[1:]):
        bigram_counts[current_word][next_word] += 1
 
 
def next_word_probability(current_word: str) -> dict[str, float]:
    counts = bigram_counts[current_word]
    total = sum(counts.values())
 
    if total == 0:
        return {}
 
    return {
        word: count / total
        for word, count in counts.items()
    }
 
 
print(next_word_probability("喜欢"))

输出大致是:

{"吃": 0.6666666666666666, "打": 0.3333333333333333}

这段代码没有平滑,也没有回退,只展示最核心的计数和归一化过程。

10. 和 Transformer 大语言模型的关系#

N-Gram 和 Transformer 都可以写成自回归形式:

P(x1, ..., xT) = product over t of P(xt | x<t)

但二者计算条件概率的方式完全不同。

模型条件概率怎么来上下文泛化能力推理形式
N-Gram频次统计表固定 N - 1 个 token弱,几乎只能记忆离散片段查表
Transformer神经网络参数化计算上下文窗口内的 token强,可通过 embedding 和 attention 泛化前向计算

N-Gram 可以写成:

P(xt | x<t) ≈ P(xt | xt-N+1, ..., xt-1)

Transformer 更接近:

P(xt | x<t) = Softmax(f_theta(x<t))

其中 f_theta 是一个带参数的神经网络。

这带来几个关键差异:

  • N-Gram 的上下文是硬截断的,超过 N - 1 个 token 的历史会被直接丢弃;
  • Transformer 可以在上下文窗口内对很多历史 token 分配不同注意力权重;
  • N-Gram 对未见过的组合几乎没有语义泛化能力;
  • Transformer 可以把相似词、相似短语、相似结构映射到相近表示空间;
  • N-Gram 训练主要是计数,Transformer 训练是大规模梯度优化。

所以不能简单把 N-Gram 的 N 等同于大模型的 context length。4-gram 只看最近 3 个 token;而 8K context 的 Transformer 理论上可以让当前 token 使用前面 8K 范围内的信息。

11. 今天 N-Gram 还有什么用#

虽然 N-Gram 已经不是主流大语言模型架构,但它仍然很实用。

11.1 文本去重#

可以用 word n-gram 或 character n-gram 判断两个文档是否相似。

例如:

大模型推理性能优化
大语言模型推理性能优化

二者可以通过字符 3-gram、MinHash、Jaccard 相似度等方法判断是否近似重复。

11.2 训练数据污染检测#

在评测大模型时,可以检查 benchmark 题目是否以相同或近似 N-Gram 的形式出现在训练集中。

这类方法不能证明模型一定“背过答案”,但可以作为污染风险分析的基础特征。

11.3 生成重复检测#

很多解码器支持禁止重复 N-Gram。例如 Hugging Face 里常见的参数:

no_repeat_ngram_size=3

表示已经生成过的连续 3-token 片段不能再次生成。这对减少循环复读很有帮助。

11.4 轻量候选模型#

在一些 speculative decoding 或输出验证场景中,可以使用非常轻量的统计模型快速给出候选 token,再交给主模型验证。

现代系统更常使用小型神经网络作为 draft model,但 N-Gram 作为极轻量候选模型仍然有工程价值。

11.5 语音识别和资源受限场景#

传统 ASR 系统里,N-Gram 语言模型长期扮演重要角色:

声学模型 + 发音词典 + N-Gram 语言模型

在资源受限、低延迟、固定领域的场景中,N-Gram 依然可能是一个足够简单、可解释、可控的选择。

12. 优点和缺点#

维度优点缺点
训练主要是计数,速度快高阶统计需要大量语料
推理查表即可,延迟低表可能很大
可解释性每个概率都有频次来源很难表达语义抽象
上下文局部搭配建模直接长距离依赖能力弱
泛化固定领域短语效果可能很好未见过组合需要平滑和回退

如果任务依赖固定短语、局部搭配、低延迟推理,N-Gram 仍然有价值。如果任务需要语义理解、长上下文推理、跨表达泛化,Transformer 会更合适。

13. 一句话理解#

N-Gram Model 的核心可以概括为:

根据最近 N - 1 个 token 的历史出现频率,预测下一个 token。

例如 Trigram 就是:

看最近两个词,然后统计语料中这两个词后面最常跟什么词。

它和现代大模型都属于自回归语言建模,但 N-Gram 靠的是 短窗口频次查表,Transformer 靠的是 长上下文神经网络计算