如何生成文本:使用不同的解码方法
引言
自回归语言生成基于以下假设:单词序列的概率分布可以分解为一系列下一个单词的条件概率分布的乘积: $$ P(w_{1:T} | W_0) = \prod_{t=1}^{T} P(w_t | w_{1:t-1}, W_0) $$
其中,$ w_{1:0} = \emptyset $,$ W_0 $ 是初始上下文单词序列。单词序列的长度 $ T $ 通常是在生成过程中动态确定的,对应于从 $ P(w_t | w_{1:t-1}, W_0) $ 中生成结束符(EOS)的时间步 $ t = T $。
本文将介绍目前最主流的几种解码方法:
- 贪婪搜索(Greedy Search)
- 集束搜索(Beam Search)
- 采样方法(Sampling,包括 Top-K 和 Top-p)
贪婪搜索(Greedy Search)
贪婪搜索是最简单的解码方法。它在每个时间步 $ t $ 选择概率最高的单词作为下一个单词:
$$ w_t = \arg\max_w P(w | w_{1:t-1}) $$ 以下是一个示意图:

从单词 "The" 开始,算法贪婪地选择概率最高的下一个单词 "nice",依此类推,最终生成的单词序列是 ("The", "nice", "woman"),其整体概率为 $ 0.5 \times 0.4 = 0.2 $。
贪婪搜索的例子:
接下来,使用GPT2在上下文 ("I", "enjoy", "walking", "with", "my", "cute", "dog") 上生成单词序列。学习如何在transformers中使用贪婪搜索:
# 编码生成所基于的上下文
model_inputs = tokenizer('I enjoy walking with my cute dog', return_tensors='pt').to(torch_device)
# 生成40个新单词
greedy_output = model.generate(**model_inputs, max_new_tokens=40)
print(tokenizer.decode(greedy_output[0], skip_special_tokens=True))输出:
I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with my dog. I'm not sure if I'll ever be able to walk with my dog.
I'm not sure这种方法快速而高效,但容易陷入重复。例如 GPT-2 在输入 I enjoy walking with my cute dog 后可能生成:
I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with my dog. I'm not sure if...
贪婪搜索的主要缺点是它会错过隐藏在低概率单词后面的高概率单词,如上图所示:
单词 "has" 的条件概率为 0.9,但它隐藏在单词 "dog" 之后,而 "dog" 只有第二高的条件概率,因此贪婪搜索错过了单词序列 ("The", "dog", "has")。
束搜索(Beam Search)
束搜索通过在每个时间步保留最有可能的 $ \text{num_beams} $ (如 5 )个假设,并最终选择整体概率最高的假设。与贪婪搜索不同,这种策略可以“向前看”,即使初始 token 的概率较低,也可以选择整体概率更高的序列,降低了错过隐藏的高概率单词序列的风险。通过设置num_beams 参数(应大于1,否则等同于贪婪搜索)启用束搜索。

在时间步 1,除了最有可能的假设 ("The", "nice") 之外,束搜索还会跟踪第二有可能的假设 ("The", "dog")。
在时间步 2,束搜索发现单词序列 ("The", "dog", "has") 的概率为 0.36,高于 ("The", "nice", "woman") 的 0.2。束搜索找到了demo 示例中最有可能的单词序列!
贪婪选择:“The” → “nice” → “woman”,总概率 0.5 × 0.4 = 0.2
束搜索可找到:“The” → “dog” → “has”,总概率 0.6 × 0.6 = 0.36
束搜索总是能找到比贪婪搜索概率更高的输出序列,但不能保证找到最有可能的输出。
束搜索的例子1:
在transformers中使用束搜索。设置 $ \text{num_beams} > 1 $ 并设置 $ \text{early_stopping} = \text{True} $,以便在所有束假设达到EOS token 时结束生成。
# 激活束搜索和提前停止
beam_output = model.generate(
**model_inputs,
max_new_tokens=40,
num_beams=5,
early_stopping=True
)
print(tokenizer.decode(beam_output[0], skip_special_tokens=True))输出:
I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with him again.
I'm not sure if I'll ever be able to walk with him again. I'm not sure虽然结果更具流畅性,但输出仍然包含重复的单词序列。一个可用的补救措施是引入n-gram(即n个单词的序列)惩罚,最常见的n-gram 惩罚确保每个 n-gram 都只出现一次,方法是如果看到当前候选词与其上文所组成的 n-gram 已经出现过了,就将该候选词的概率设置为 0。
束搜索的例子2:
为避免重复,可设置 no_repeat_ngram_size=2,禁止重复 2-gram,例如:
# 设置no_repeat_ngram_size为2
beam_output = model.generate(
**model_inputs,
max_new_tokens=40,
num_beams=5,
no_repeat_ngram_size=2,
early_stopping=True
)
print(tokenizer.decode(beam_output[0], skip_special_tokens=True))输出:
I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with him again.
I've been thinking about this for a while now, and I think it's time for me to生成的文本更自然连贯,但应小心使用,例如一篇关于“New York” 城市的文章就不应使用 2-gram 惩罚, 否则“New York”在在整个文本中只能出现一次。
束搜索的例子3:
束搜索的另一个重要特性是我们能够比较概率最高的几个束,并选择最符合要求的束作为最终生成文本。
在 transformers 中,只需将参数 num_return_sequences 设置为需返回的概率最高的束的数量,记得确保 num_return_sequences <= num_beams!
# 设置return_num_sequences > 1
beam_outputs = model.generate(
**model_inputs,
max_new_tokens=40,
num_beams=5,
no_repeat_ngram_size=2,
num_return_sequences=5,
early_stopping=True
)
# 现在我们有5个输出序列
for i, beam_output in enumerate(beam_outputs):
print("{}: {}".format(i, tokenizer.decode(beam_output, skip_special_tokens=True)))输出:
0: I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with him again.
I've been thinking about this for a while now, and I think it's time for me to
1: I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with her again.
I've been thinking about this for a while now, and I think it's time for me to
2: I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with him again.
I've been thinking about this for a while now, and I think it's a good idea to
3: I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with him again.
I've been thinking about this for a while now, and I think it's time to take a
4: I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with him again.
I've been thinking about this for a while now, and I think it's a good idea.如上所见,五个束之间只有微小的差异。
在开放性生成中,束搜索可能不是最佳选择:
- 束搜索在目标生成长度相对可预测的任务中表现良好,例如机器翻译或摘要。但在开放性生成中,目标输出长度可能会有很大差异,例如对话和故事生成。
- 束搜索受到重复生成的影响。在故事生成中,使用n-gram或其他惩罚来控制重复非常困难,因为找到抑制重复和避免重复n-gram循环之间的良好平衡需要大量的微调。
- 高质量的人类语言并不遵循高概率下一个单词的分布。换句话说,我们希望生成的文本能够给我们惊喜,而不是枯燥/可预测的。通过绘制模型对人类文本的概率与束搜索所做内容的对比图,很好地展示了这一点。

那么,让我们引入一些随机性。
采样(Sampling)
采样根据整个模型词汇表上的概率分布随机选择 token(而不是像贪婪搜索那样选择最有可能的token)。这意味着任何具有非零概率的token都有机会被选中。采样策略可以减少重复,并生成更具创造性和多样性的输出。
在最基本的形式中,采样根据其条件概率分布随机选择下一个单词 $ w_t $:
$$ w_t \sim P(w | w_{1:t-1}) $$ 以之前的例子为例,下图展示了采样进行语言生成的过程。

很明显,使用采样进行语言生成不再是确定性的。单词 ("car") 是从条件概率分布 $ P(w | The) $ 中采样的,随后从 $ P(w | The, car) $ 中采样 ("drives")。
采样例子1
在transformers中,设置 $ \text{do_sample} = \text{True} $ 并通过设置 $ \text{top_k} = 0 $ 禁用Top-K 采样(稍后详细介绍)。
# 设置种子以重现结果。您可以自由更改种子以获得不同结果
from transformers import set_seed
set_seed(42)
# 激活采样并禁用Top-K采样
sample_output = model.generate(
**model_inputs,
max_new_tokens=40,
do_sample=True,
top_k=0
)
print(tokenizer.decode(sample_output[0], skip_special_tokens=True))输出:
I enjoy walking with my cute dog for the rest of the day, but this had me staying in an unusual room and not going on nights out with friends (which will always be wondered for a mere minute or so at this point).文本看起来还可以,但仔细观察后发现它并不连贯,也不像是人类写的。这就是采样生成单词序列的主要问题:模型往往会生成不连贯的胡言乱语。
一个技巧是通过降低所谓的 softmax 温度来使分布 $ P(w | w_{1:t-1}) $ 更尖锐(增加高概率单词的可能性,降低低概率单词的可能性)。
采样例子2
以下是应用温度到之前例子的示意图。

在时间步 $ t=1 $ 的条件下一个单词的分布变得更加尖锐,几乎不可能选择单词 ("car")。学习如何在库中通过设置 $ \text{temperature} = 0.6 $ 来降低分布的温度。
# 设置种子以重现结果。您可以自由更改种子以获得不同结果
set_seed(42)
# 使用温度来降低对低概率候选词的敏感度
sample_output = model.generate(
**model_inputs,
max_new_tokens=40,
do_sample=True,
top_k=0,
temperature=0.6,
)
print(tokenizer.decode(sample_output[0], skip_special_tokens=True))输出:
I enjoy walking with my cute dog, but I don't like to chew on it. I like to eat it and not chew on it. I like to be able to walk with my dog."
So how did you decide好的,现在生成的文本中奇怪的n-gram少了一些,输出也稍微连贯了一些!虽然降低温度可以使分布不那么随机,但当温度趋近于0时,温度缩放采样将变得与贪婪解码相同,并会遭受与之前相同的问题。
Top-K 采样
Top-K 采样中,概率最大的 K (如 top_k=50)个词会被选出,然后这 K 个词的概率会被重新归一化,最后就在这重新被归一化概率后的 K 个词中采样。
为了更好地说明Top-K采样,我们将上述示例中用于采样步骤的单词范围从3个单词扩展到10个单词。

我们将采样池限制为6个单词。尽管在第一步中,6个最有可能的单词(记为 $ V_{\text{top-K} } $)仅涵盖了大约三分之二的概率,但在第二步中,它几乎包含了所有概率。然而,我们可以看到它成功地消除了第二步中相对奇怪的候选词("not"、"the"、"small"、"told")。
采样例子2
学习如何在transformers库中通过设置 $ \text{top_k} = 50 $ 来使用Top-K采样。
# 设置种子以重现结果。您可以自由更改种子以获得不同结果
set_seed(42)
# 设置top_k为50
sample_output = model.generate(
**model_inputs,
max_new_tokens=40,
do_sample=True,
top_k=50
)
print(tokenizer.decode(sample_output[0], skip_special_tokens=True))输出:
I enjoy walking with my cute dog for the rest of the day, but this time it was hard for me to figure out what to do with it. (One reason I asked this for a few months back is that I had a还不错!这是迄今为止最接近人类书写的文本。然而,Top-K 采样有一个问题,它不会动态调整从下一个单词概率分布 $ P(w | w_{1:t-1}) $ 中过滤掉的单词数量。这可能会导致问题,因为有些单词可能来自一个非常尖锐的分布(图中右侧的分布),而另一些单词则来自一个更平坦的分布(图中左侧的分布)。
在时间步 $ t=1 $ 中,Top-K 消除了采样 ("people"、"big"、"house"、"cat") 的可能性,这些似乎都是合理的候选词。另一方面,在时间步 $ t=2 $ 中,该方法将显然不太合适的单词 ("down"、"a") 包含在单词样本池中。因此,将样本池限制为固定大小 $ K $ 可能会使模型在尖锐分布中产生胡言乱语,并限制模型在平坦分布中的创造力。
Top-p(核)采样
相较 Top-K,Top-p 动态调整候选词集合大小,更加灵活,在实践中表现良好。
Top-p(又称核采样)选择从最小可能的单词集合中采样,这些单词的累积概率超过概率 $ p $。然后将概率质量重新分配给这些单词。这样,单词集合的大小(即集合中的单词数量)可以根据下一个单词的概率分布动态增加和减少。

设置 $ p = 0.92 $,Top-p采样选择最小数量的单词以超过92%的概率质量,记为 $ V_{\text{top-p} } $。在第一个例子中,这包括了9个最有可能的单词,而在第二个例子中,它只需要选择排名前三的单词即可超过92%。
可以看到,在下一个单词不太可预测的情况下(例如 $ P(w | The) $),它保留了更广泛的单词范围;而在下一个单词更可预测的情况下(例如 $ P(w | The, car) $),它只选择少数几个单词。
采样例子3
在transformers中尝试Top-p采样。我们通过设置 $ 0 < \text{top_p} < 1 $ 来激活Top-p采样,同时设置 top_k=0 禁用 top_k 采样。
# 设置种子以重现结果。您可以自由更改种子以获得不同结果
set_seed(42)
# 设置top_k为50
sample_output = model.generate(
**model_inputs,
max_new_tokens=40,
do_sample=True,
top_p=0.92,
top_k=0
)
print(tokenizer.decode(sample_output[0], skip_special_tokens=True))输出:
I enjoy walking with my cute dog for the rest of the day, but this had me staying in an unusual room and not going on nights out with friends (which will always be my yearning for such a spacious screen on my desk这看起来像是人类写的。虽然从理论上讲,Top-p 比Top-K 更优雅,但两种方法在实践中都表现良好。Top-p也可以与Top-K结合使用,以避免选择排名非常低的单词,同时允许一些动态选择。
最后,为了获得多个独立采样的输出,我们再次设置参数 $ \text{num_return_sequences} > 1 $ :
# 设置种子以重现结果。您可以自由更改种子以获得不同结果
set_seed(42)
# 设置top_k = 50,top_p = 0.95,num_return_sequences = 3
sample_outputs = model.generate(
**model_inputs,
max_new_tokens=40,
do_sample=True,
top_k=50,
top_p=0.95,
num_return_sequences=3,
)
for i, sample_output in enumerate(sample_outputs):
print("{}: {}".format(i, tokenizer.decode(sample_output, skip_special_tokens=True)))输出:
0: I enjoy walking with my cute dog for the rest of the day, but this time it was hard for me to figure out what to do with it. When I finally looked at this for a few moments, I immediately thought, "
1: I enjoy walking with my cute dog. The only time I felt like walking was when I was working, so it was awesome for me. I didn't want to walk for days. I am really curious how she can walk with me
2: I enjoy walking with my cute dog (Chama-I-I-I-I-I), and I really enjoy running. I play in a little game I play with my brother in which I take pictures of our houses.总结
| 解码方法 | 优点 | 缺点 |
|---|---|---|
| 贪婪搜索 | 快速,易实现 | 易重复,易错过全局最优 |
| 束搜索 | 考虑多路径,结果更优 | 易重复,调参复杂 |
| Top-K 采样 | 控制词汇范围,结果多样 | 固定 K 可能适应性差 |
| Top-p 采样 | 动态词集,生成更自然 | 仍可能重复,略复杂 |