大语言模型之生成/推理:参数与解码策略原理及其代码实现
LLM 大语言模型 Generate/Inference 生成或者说推理时,有很多的参数和解码策略,比如 OpenAI 在提供 GPT 系列的模型时,就提供了很多的参数 [1],那这些参数的原理以及代码上怎么实现的呢?本文将尽力进行一一的解释。全文阅读和实现可能需要 40 分钟。
所有代码都放在了 github 上,更方便实现:
https://github.com/Glanvery/LLM-Travel
原始生成
假如都没有使用这些参数和策略做后处理,模型是怎么生成的呢?以 llama 模型举例(其他生成式模型是一样):
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
model_name = "llama-2-7b-hf" # 用你的模型的地址
model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(model_name)
text = "say"
inputs = tokenizer(text, return_tensors="pt")
print(f"inputs:{inputs}")
# 结果
inputs:{'input_ids': tensor([[ 1, 1827]]), 'attention_mask': tensor([[1, 1]])}
我们输入的模型的就一个词:say(该词分词后就是 1 个 token,在词表中的位置是 1827),然后给到模型预测下一个 token,如果我们不做任何参数控制,就是直接走模型的 forward:
logits = model.forward(input_ids)
print("Logits Shape:", logits.logits.shape)
print(f"logits:{logits.logits}")
# 结果
Logits Shape: torch.Size([1, 2, 32000])
logits:tensor([[[-12.9696, -7.4491, -0.4354, ..., -6.8250, -8.0804, -7.5782],
[-11.3775, -10.1338, -2.3563, ..., -6.7709, -6.5252, -8.9753]]],
device='cuda:0', grad_fn=<UnsafeViewBackward0>)
logits 就是模型的最后输出,是一个张量(tensor),它的维度是 [batch_size, sequence_length, vocab_size],在我们这里,batch_size是1,sequence_length 是 2,因为我们输入是一个 “say”,而模型在生成下一个 token,因此生成的序列长度为 2(“say” + 1 个生成的 token),vocab_size 是词典的大小,llama 是 32000,也就是说咱们接下来要在第 2 个 sequence 里找到在 32000 词表中哪个 token 的概率最大:
# 在最后一个维度上(32000)进行求最大值操作,返回具有最高分数的词汇索引
next_token = torch.argmax(logits.logits, dim=-1).reshape(-1)[1]
print(f"next_token:{next_token}")
# 结果
next_token:tensor([22172], device='cuda:0')
在最后一个维度上(32000)进行求最大值操作,并返回具有最高分数的词汇索引,在词表中的位置是 22172,接下来就是解码该 token:
next_word = tokenizer.decode(next_token)
print(f"next_word:{next_word}")
# 结果
next_word:hello
这就将 next_word 预测了出来,后面的流程就是将 “hello” 加到 “say” 后面变成 “say hello”,迭代上述流程直到生成 eos_token(终止词),整个预测也就完成了。上述就是不加任何参数和后处理的生成式模型的 generate/inference 全过程,这个过程也叫做 greedy decoding 贪心解码策略,下文会介绍。
常见参数(Huggingface中的常用参数[2])
2.1 temperature
该参数用于控制生成文本的随机性和多样性,其实是调整了模型输出的 logits 概率分布,实现原理很简单,我们举一个简单的例子,假设我们有一个大小为 [1, 4] 的 logits 张量,在上述原始生成例子中其实是 [1, 32000],然后将 logits 输入到 softmax 函数中,分别计算没有 temperature,以及 temperature 为 0.5 和 temperature 为 2 的情况下的概率分布:
import torch
logits = torch.tensor([[0.5, 1.2, -1.0, 0.1]])
# 无temperature
probs = torch.softmax(logits, dim=-1)
# temperature low 0.5
probs_low = torch.softmax(logits / 0.5, dim=-1)
# temperature high 2
probs_high = torch.softmax(logits / 2, dim=-1)
print(f"probs:{probs}")
print(f"probs_low:{probs_low}")
print(f"probs_high:{probs_high}")
# 结果
probs: tensor([[0.2559, 0.5154, 0.0571, 0.1716]])
probs_low: tensor([[0.1800, 0.7301, 0.0090, 0.0809]])
probs_high: tensor([[0.2695, 0.3825, 0.1273, 0.2207]])
从上述结果中可以看到,当 temperature 较高时,会更平均地分配概率给各个 token,这导致生成的文本更具随机性和多样性;temperature 较低接近 0 时,会倾向于选择概率最高的 token,从而使生成的文本更加确定和集中。注:temperature=1 时表示不使用此方式。
2.2 top_p
top-p 也是一个用于控制生成文本多样性的参数,也被称为 “nucleus sampling”。这个参数的全名是 “top probability”,通常用一个介于 0 到 1 之间的值来表示生成下一个 token 时,在概率分布中选择的最高概率的累积阈值,我们看一下是怎么实现的,还以上述的例子为例:
import torch
# 样例:probs: tensor([[0.2559, 0.5154, 0.0571, 0.1716]])
probs = torch.tensor([[0.2559, 0.5154, 0.0571, 0.1716]])
# 第一步进行排序
probs_sort, probs_idx = torch.sort(probs, dim=-1, descending=True)
# 结果
probs_sort: tensor([[0.5154, 0.2559, 0.1716, 0.0571]])
probs_idx: tensor([[1, 0, 3, 2]])
# 第二步概率的累积和
probs_sum = torch.cumsum(probs_sort, dim=-1)
# 结果
probs_sum: tensor([[0.5154, 0.7713, 0.9429, 1.0000]])
# 第三步找到第一个大于阈值p的位置,假设p=0.9,并将后面的概率值置为0:
mask = probs_sum - probs_sort > p
probs_sort[mask] = 0.0
# 结果
probs_sort: tensor([[0.5154, 0.2559, 0.1716, 0.0000]])
# 第四步复原原序列
new_probs = probs_sort.scatter(1, probs_idx, probs_sort)
# 结果
new_probs: tensor([[0.2559, 0.5154, 0.0000, 0.1716]])
# 注:在真实实现中一般会把舍弃的概率置为-inf,即
zero_indices = (new_probs == 0)
new_probs[zero_indices] = float('-inf')
# 结果
new_probs: tensor([[0.2559, 0.5154, -inf, 0.1716]])
# 完整代码
def sample_top_p(probs, p):
probs_sort, probs_idx = torch.sort(probs, dim=-1, descending=True)
probs_sum = torch.cumsum(probs_sort, dim=-1)
mask = probs_sum - probs_sort > p
probs_sort[mask] = 0.0
new_probs = probs_sort.scatter(1, probs_idx, probs_sort)
zero_indices = (new_probs == 0)
new_probs[zero_indices] = float('-inf')
return new_probs
从上述的实现中可以看到,当 top_p 较高时比如 0.9,这意味着前 90% 的概率的 token 会被考虑在抽样中,这样会允许更多的 token 参与抽样,增加生成文本的多样性;当 top_p 较低时比如比如 0.1,这意味着只有前 10% 最高概率的 token 会被考虑在抽样中,这样会限制生成文本的可能性,使生成的文本更加确定和集中。这里可能会有一点疑问,当 top-p 设置的很小,累加的概率没超过怎么办?一般代码中都会强制至少选出一个 token 的。注:top_p=1 时表示不使用此方式。
2.3 top_k
import torch
filter_value = -float("Inf")
top_k = 2
probs = torch.tensor([[0.2559, 0.5154, 0.0571, 0.1716]])
indices_to_remove = probs < torch.topk(probs, top_k)[0][..., -1, None]
new_probs = probs.masked_fill(indices_to_remove, filter_value)
print("new_probs:", new_probs)
# 结果
new_probs: tensor([[0.2559, 0.5154, -inf, -inf]])
2.4 repetition_penalty
这个重复惩罚参数也比较容易理解,通过修改生成文本时的概率分布来实现的, repetition_penalty 的目标是在这个概率分布中对先前生成过的 token,又重复的生成了该 token 进行惩罚(降低概率),以减少生成文本中的重复性,简单实现如下:
import numpy as np
def apply_repetition_penalty(probs, repetition_penalty, prev_tokens):
adjusted_probs = np.copy(probs)
for token in set(prev_tokens):
adjusted_probs[token] *= repetition_penalty
adjusted_probs /= np.sum(adjusted_probs)
return adjusted_probs
# 示例概率分布,索引对应不同词语
original_probs = np.array([0.3, 0.1, 0.3, 0.1, 0.2])
# 示例先前生成的词语
previous_tokens = [2, 4, 2]
# 重复惩罚系数
repetition_penalty = 0.8
# 应用重复惩罚,得到调整后的概率分布
adjusted_probs = apply_repetition_penalty(original_probs, repetition_penalty, previous_tokens)
print("原始概率分布:", original_probs)
print("调整后的概率分布:", adjusted_probs)
# 结果
原始概率分布: [0.3 0.1 0.3 0.1 0.2]
调整后的概率分布: [0.33333333 0.11111111 0.26666667 0.11111111 0.17777778]
上述结果很明显看的出来,出现过的的 token 概率变低了,未出现过的 token 的概率变高了。注:repetition_penalty=1 时表示不进行惩罚。
2.5 no_repeat_ngram_size
这个参数,当设为大于 0 的整数时,生成的文本中不会出现指定大小的重复 n-gram(n 个连续的 token),可以使生成的文本更加多样化,避免出现重复的短语或句子结构。实现原理和上述 repetition_penalty 的是大致差不多的,只不过这里是 n 个连续的 token。注默认不设置
2.6 do_sample
这个参数是对模型计算出来的概率要不要进行多项式采样,多项式采样(Multinomial Sampling)是一种用于从一个具有多个可能结果的离散概率分布中进行随机抽样的方法,多项式采样的步骤如下:
首先,根据概率分布对应的概率,为每个可能结果分配一个抽样概率。这些抽样概率之和必须为 1。
然后,在进行一次抽样时,会根据这些抽样概率来选择一个结果。具体地,会生成一个随机数,然后根据抽样概率选择结果。抽样概率越高的结果,被选中的概率也就越大。
最终,被选中的结果就是这次抽样的输出。
在多项式采样中,概率高的结果更有可能被选中,但不同于确定性的选择,每个结果仍然有一定的概率被选中。这使得模型在生成文本时具有一定的随机性,但又受到概率的控制,以便生成更加多样且符合概率分布的文本。实现如下:
import torch
probs = torch.tensor([[0.2559, 0.5154, 0.0571, 0.1716]])
next_token = torch.multinomial(probs, num_samples=1)
print("next_token:", next_token)
# 结果
next_token: tensor([[1]])
这个 do_sample 参数通过多样式采样会有一定的随机性,这种随机性导致了生成的文本更加多样化,因为模型有机会选择概率较低但仍然可能的词,这种方法可以产生丰富、有趣、创新的文本,但可能会牺牲一些文本的准确性。注 do_sample=False,不进行采样。
在 Huggingface 中,do_sample 这个参数有更高的含义即做不做多样化采样,当 do_sample=False 时,temperature,top_k,top_p 这些参数是不能够被设置的,只有 do_sample=True 时才能够被设置。比如:
UserWarning: `do_sample` is set to `False`. However, `temperature` is set to `0.5` -- this flag is only used in sample-based generation modes. You should set `do_sample=True` or unset `temperature`.
UserWarning: `do_sample` is set to `False`. However, `top_p` is set to `0.1` -- this flag is only used in sample-based generation modes. You should set `do_sample=True` or unset `top_p`.
UserWarning: `do_sample` is set to `False`. However, `top_k` is set to `20` -- this flag is only used in sample-based generation modes. You should set `do_sample=True` or unset `top_k`.
2.7 num_beams
num_beams 参数是用于束搜索(beam search)算法的,其用途是控制生成的多个候选句子的数量,该参数控制的是每个生成步要保留的生成结果的数量,用于在生成过程中增加多样性或生成多个可能的结果。主要步骤如下:
在每个生成步,对于前一个生成中的所有生成结果,分别基于概率保留前 k 个最可能的结果(k 即 num_beams 参数的值)。
将所有扩展后的生成结果,按照其对应的概率分数重新计算分数并进行排序,并保留前 k 个最可能的结果。
如果已经生成了结束符,则将其对应的结果保留下来。
重复上述过程直到生成所有的结果或达到最大长度。
以下是简单代码实现,结合代码会更容易理解:
首先定义一个 BeamSearchNode 类
class BeamSearchNode:
def __init__(self, sequence, score):
self.sequence = sequence # 生成的序列
self.score = score # 分数(概率)
然后给出接下来生成 token 的概率,简单起见给一个固定的概率
# 示例:下一个token的概率函数,简单使用固定概率
def simple_next_word_probs(sequence):
if sequence[-1] == "<end>":
return {}
return {"apple": 0.3, "like": 0.35, "peach": 0.2, "banana": 0.15}
下面是 beam_search 算法的非常简单的实现,工程上实现的话参考 Huggingface 的官方实现 [2],
def beam_search(initial_sequence, next_word_probs_func, num_beams, max_sequence_length):
# 初始化初始节点,且分数为1
initial_node = BeamSearchNode(sequence=initial_sequence, score=1.0)
candidates = [initial_node]
final_candidates = [] # 最终的候选序列
# 只要候选节点列表不为空,且 final_candidates 中的候选节点数量还没有达到指定的束宽度,就继续进行搜索
while candidates and len(final_candidates) < num_beams:
# 候选节点排序
candidates.sort(key=lambda x: -x.score)
current_node = candidates.pop(0)
# 当节点序列末尾生成结束符号(如"<end>"),或者当生成的序列长度达到最大限制时终止节点的扩展
if current_node.sequence[-1] == "<end>" or len(current_node.sequence) >= max_sequence_length:
final_candidates.append(current_node)
else:
# 获取下一个token的概率,我们的例子返回的是固定的概率
next_words_probs = next_word_probs_func(current_node.sequence)
# 生成新的候选序列,并计算分数
for next_word, next_word_prob in next_words_probs.items():
new_sequence = current_node.sequence + [next_word]
new_score = current_node.score * next_word_prob
new_node = BeamSearchNode(sequence=new_sequence, score=new_score)
candidates.append(new_node)
return [candidate.sequence for candidate in final_candidates]
开始使用:
initial_sequence = ["<start>", "I"]
num_beams = 3
max_sequence_length = 3
result = beam_search(initial_sequence, simple_next_word_probs, num_beams, max_sequence_length)
for idx, sequence in enumerate(result):
print(f"Sentence {idx + 1}: {' '.join(sequence)}")
# 结果,符合我们的概率分布
Sentence 1: <start> I like
Sentence 2: <start> I apple
Sentence 3: <start> I peach
# 当长度为4时呢?
Sentence 1: <start> I like like
Sentence 2: <start> I like apple
Sentence 3: <start> I apple like
# 为什么结果变了呢?我们来看一下最终每个序列的分数
current_node: ['<start>', 'I', 'like', 'like']
current_score: 0.12249999999999998
current_node: ['<start>', 'I', 'like', 'apple']
current_score: 0.105
current_node: ['<start>', 'I', 'apple', 'like']
current_score: 0.105
# 再看一下其他序列的分数,apple的
new_node: ['<start>', 'I', 'apple', 'apple']
new_score: 0.09
new_node: ['<start>', 'I', 'apple', 'like']
new_score: 0.105
new_node: ['<start>', 'I', 'apple', 'peach']
new_score: 0.06
new_node: ['<start>', 'I', 'apple', 'banana']
new_score: 0.045
# 再看一下其他序列的分数,peach的
new_node: ['<start>', 'I', 'peach', 'apple']
new_score: 0.06
new_node: ['<start>', 'I', 'peach', 'like']
new_score: 0.06999999999999999
new_node: ['<start>', 'I', 'peach', 'peach']
new_score: 0.04000000000000001
new_node: ['<start>', 'I', 'peach', 'banana']
new_score: 0.03
上述就是 beam search 的简单代码实现,有优点也有相应的缺点
优点:
生成多样性:通过增加 num_beams 束宽,束搜索可以保留更多的候选序列,从而生成更多样化的结果。
找到较优解:增加 num_beams 束宽有助于保留更多可能的候选序列,从而更有可能找到更优的解码结果,这在生成任务中有助于避免陷入局部最优解。
控制输出数量:通过调整 num_beams 束宽,可以精确控制生成的候选序列数量,从而平衡生成结果的多样性和数量。
缺点:
计算复杂度:随着 num_beams 束宽的增加,计算复杂度呈指数级增长,较大的束宽会导致解码过程变得更加耗时,尤其是在资源有限的设备上。
忽略概率较低的序列:增加 num_beams 束宽可能会导致一些低概率的候选序列被忽略,因为搜索过程倾向于集中在概率较高的路径上,从而可能错过一些潜在的优质解。
缺乏多样性:尽管增加 num_beams 束宽可以增加生成结果的多样性,但束搜索仍然可能导致生成的结果过于相似,因为它倾向于选择概率较高的路径。
2.8 num_beam_groups
Huggingface 中的生成参数中也是有该参数的,这个参数背后其实是一种 beam search 算法的改进,叫做 Diverse Beam Search(DBS)[3],上述我们已经讨论了 beam search 生成的结果还是会过于相似的,这个 DBS 做了一些改进,核心就是分组机制。
举个例子来说如果我的 num_beams=2,num_beam_groups=2,那就是说分成 2 个组,每个组里的 beam 可以相似,但组和组之间要有足够的多样性,引入了多样性分数,具体实现细节可以看一下论文,不过以下一张图就容易理解了:
2.9 diversity_penalty
这个多样性惩罚参数只有在启用了 “num_beam_groups”(组束搜索)时才有效,在这些组之间应用多样性惩罚,以确保每个组生成的内容尽可能不同。
2.10 length_penalty
这个长度惩罚参数也是用于束搜索过程中,在束搜索的生成中,候选序列的得分通过对数似然估计计算得到,即得分是负对数似然。length_penalty 的作用是将生成序列的长度应用于得分的分母,从而影响候选序列的得分,当 length_penalty > 1.0 时,较长的序列得到更大的惩罚,鼓励生成较短的序列;当length_penalty< 1.0 时,较短的序列得到更大的惩罚,鼓励生成较长的序列,默认为 1,不受惩罚。
2.11 use_cache
该参数如何设置为 True 时,则模型会利用之前计算得到的注意力权重(key/values attentions)的缓存,这些注意力权重是在模型生成文本的过程中,根据输入上下文和已生成部分文本,计算出来的,当下一个 token 需要被生成时,模型可以通过缓存的注意力权重来重用之前计算的信息,而不需要重新计算一次,有效地跳过重复计算的步骤,从而减少计算负担,提高生成速度和效率。
其他简单、少见参数
接下来对比较简单和少见的参数做一下简单阐述
3.1 num_return_sequences
该参数是模型返回不同的文本序列的数量,要和 beam search 中的 num_beams 一致,在贪心解码策略中(下述会讲到),num_return_sequences 只能为 1,默认也为1。
3.2 max_length
生成的 token 的最大长度。它是输入 prompt 的长度加上 max_new_tokens 的值。如果同时设置了 max_new_tokens,则会覆盖此参数,默认为 20。
3.3 max_new_tokens
生成的最大 token 的数量,不考虑输入 prompt 中的 token 数,默认无设置
3.4 min_length
生成的 token 的最小长度。它是输入 prompt 的长度加上 min_new_tokens 的值。如果同时设置了 min_new_tokens,则会覆盖此参数,默认为 0。
3.5 min_new_tokens
生成的最小 token 的数量,不考虑输入 prompt 中的 token 数,默认无设置
3.6 early_stopping
控制基于束搜索(beam search)等方法的停止条件,接受以下值:
True:生成会在出现 num_beams 个完整候选项时停止。
False:应用启发式方法,当很不可能找到更好的候选项时停止生成。
never:只有当不能找到更好的候选项时,束搜索过程才会停止(经典的束搜索算法)。
默认为False
3.7 bad_words_ids
包含词汇 id 的列表,这个参数用于指定不允许在生成文本中出现的词汇,如果生成的文本包含任何在这个列表中的词汇,它们将被被替换或排除在最终生成的文本之外。
3.8 force_words_ids
包含词汇 id 的列表,用于指定必须包含在生成文本中的词汇,如果给定一个列表,生成的文本将包含这些词汇。
3.9 constraints
自定义约束条件,可以指定约束条件,这些约束条件可以是必须出现的关键词、短语、特定术语或其他文本元素,其实和 force_words_ids 是差不多的意思,在代码实现也是一样的。
常见解码策略(Huggingface 中实现的解码策略[4])
Huggingface 如何判断采用那种解码策略呢?如果直接使用 model.generate(),就是上述参数的组合,会用来一一判断使用那种解码策略,如果出现冲突会抛出异常。目前 Huggingface 总共有 8 种解码策略,我们从 Huggingface 的判断顺序来说起:
4.1 constrained beam-search decoding
受限束搜索解码,使用model.generate() 当 constraints 不为 None 或 force_words_ids 不为 None 时进入该模式,而且要求 num_beams 要大于 1(本质还是束搜索),do_sample 为 False,num_beam_groups 为 1,否则就会抛出:
"`num_beams` needs to be greater than 1 for constrained generation."
"`do_sample` needs to be false for constrained generation."
"`num_beam_groups` not supported yet for constrained generation."
在这个解码策略中,核心还是上述实现的 beam search,只不过在 search 中加入了我们提供的词表,强制其生成我们提供的词表,我们来看一下怎么使用,先来看一下传统的 beam search 使用:
from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig
import torch
model_name = "llama-2-7b-hf" # 你模型的位置
model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(model_name)
text = "say hello to"
inputs = tokenizer(text, return_tensors="pt")
print(f"inputs:{inputs}")
input_ids = inputs["input_ids"].to("cuda")
# generate实现
generation_output = model.generate(
input_ids=input_ids,
num_beams = 3,
num_return_sequences=3,
return_dict_in_generate=True,
max_new_tokens=3,
)
print("query:", text)
for i, output_sequence in enumerate(generation_output.sequences):
output_text = tokenizer.decode(output_sequence, skip_special_tokens=True)
print(f"Generated sequence {i+1}: {output_text}")
# 结果
inputs:{'input_ids': tensor([[ 1, 1827, 22172, 304]]), 'attention_mask': tensor([[1, 1, 1, 1]])}
query: say hello to
Generated sequence 1: say hello to your new favorite
Generated sequence 2: say hello to your new best
Generated sequence 3: say hello to our newest
加上了约束之后,即我们给定词表 ["my"]:
from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig
import torch
model_name = "llama-2-7b-hf" # 你模型的位置
model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(model_name)
text = "say hello to"
inputs = tokenizer(text, return_tensors="pt")
print(f"inputs:{inputs}")
input_ids = inputs["input_ids"].to("cuda")
force_words = ["my"]
force_words_ids = tokenizer(force_words, add_special_tokens=False).input_ids
generation_output = model.generate(
input_ids=input_ids,
force_words_ids = force_words_ids,
num_beams = 3,
num_return_sequences=3,
return_dict_in_generate=True,
max_new_tokens=3,
)
print("query:", text)
for i, output_sequence in enumerate(generation_output.sequences):
output_text = tokenizer.decode(output_sequence, skip_special_tokens=True)
print(f"Generated sequence {i+1}: {output_text}")
# 结果
inputs:{'input_ids': tensor([[ 1, 1827, 22172, 304]]), 'attention_mask': tensor([[1, 1, 1, 1]])}
query: say hello to
Generated sequence 1: say hello to my little friend
Generated sequence 2: say hello to your new favorite
Generated sequence 3: say hello to your new my
结果很明显,生成中出现了我们的限制词表 “my”。
4.2 contrastive search
对比搜索策略,在 model.generate() 中,当 penalty_alpha 大于 0 且 top_k>1 大于 1 时使用,这是一种引入对比度惩罚的搜索方法,我们之前没有介绍过 penalty_alpha 这个惩罚因子参数,因为只有在 contrastive search 是才会用到。
这种解码策略是在 2022 年 A Contrastive Framework for Neural Text Generation [5] 论文中提出来的方法,具体细节可以看论文,Huggingface 已经实现,我们来看一下简单的原理:生成的 token 应该是从模型预测的最佳候选(top k)中而来;在生成 token 时,当前 token 应该能与前面生成的内容保持对比性(或差异性),其实现就是若当前生成的 token 与之前的序列 token 相似度很大,就减少其整体概率值,进而减少它被解码出来的可能性,避免重复解码的问题。
核心公式如下:
核心实现代码如下:
def ranking(context_hidden, next_hidden, next_top_k_ids, next_top_k_probs, alpha):
'''
该函数是实现Contrastive Search中next token预测中候选token的排序分数,分数最大对应token为输出结果
context_hidden: beam_width x context_len x embed_dim ,用于计算相似度,是公式中x_j集合表征向量
next_hidden: beam_width x 1 x embed_dim,用于计算相似度,是公式中候选token v 的表征向量
next_top_k_ids: beam_width x 1,记录候选token的编码
next_top_k_probs,候选token的模型预测概率
alpha,惩罚参数
'''
beam_width, context_len, embed_dim = context_hidden.size()
assert next_hidden.size() == torch.Size([beam_width, 1, embed_dim])
norm_context_hidden = context_hidden / context_hidden.norm(dim=2, keepdim=True)
norm_next_hidden = next_hidden / next_hidden.norm(dim=2, keepdim=True)
cosine_matrix = torch.matmul(norm_context_hidden, norm_next_hidden.transpose(1,2)).squeeze(-1) #计算相似度矩阵
assert cosine_matrix.size() == torch.Size([beam_width, context_len])
scores, _ = torch.max(cosine_matrix, dim = -1) #输出公式第二项值
assert scores.size() == torch.Size([beam_width])
next_top_k_probs = next_top_k_probs.view(-1) #输出公式第一项值
scores = (1.0 - alpha) * next_top_k_probs - alpha * scores #对应公式整体计算
_, selected_idx = torch.topk(scores, k = 1)
assert selected_idx.size() == torch.Size([1])
selected_idx = selected_idx.unsqueeze(0)
assert selected_idx.size() == torch.Size([1,1])
next_id = torch.gather(next_top_k_ids, dim = 0, index=selected_idx)
assert next_id.size() == torch.Size([1,1])
return next_id
如何使用:
from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig
import torch
model_name = "llama-2-7b-hf" # 你模型的位置
model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(model_name)
text = "say hello to"
inputs = tokenizer(text, return_tensors="pt")
print(f"inputs:{inputs}")
input_ids = inputs["input_ids"].to("cuda")
generation_output = model.generate(
input_ids=input_ids,
penalty_alpha = 0.5,
top_k = 30,
return_dict_in_generate=True,
max_new_tokens=3,
)
# 直接使用其函数
# generation_output = model.contrastive_search(
# input_ids=input_ids,
# penalty_alpha = 0.5,
# top_k = 30,
# return_dict_in_generate=True,
# max_new_tokens=3,
# )
print("query:", text)
for i, output_sequence in enumerate(generation_output.sequences):
output_text = tokenizer.decode(output_sequence, skip_special_tokens=True)
print(f"Generated sequence {i+1}: {output_text}")
# 结果
inputs:{'input_ids': tensor([[ 1, 1827, 22172, 304]]), 'attention_mask': tensor([[1, 1, 1, 1]])}
query: say hello to
Generated sequence 1: say hello to 20
4.3 greedy decoding
最经典最原始的贪心解码策略,在 model.generate() 中,当 num_beams 等于 1 且 do_sample 等于 False 时进入此模式,也可以直接使用 model.greedy_search(),这个解码策略很简单,就是在每一步中选择预测概率最高的 token 作为下一个 token,从而生成文本,和之前的 forword 是一样的,这种方法通常会导致生成的文本比较单一和局部最优。注意此策略不能使用 temperature,top_k,top_p 等改变 logits 的参数。
from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig
import torch
model_name = "llama-2-7b-hf" # 你模型的位置
model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(model_name)
text = "say hello to"
inputs = tokenizer(text, return_tensors="pt")
print(f"inputs:{inputs}")
input_ids = inputs["input_ids"].to("cuda")
generation_output = model.generate(
input_ids=input_ids,
num_beams = 1,
do_sample = False,
return_dict_in_generate=True,
max_new_tokens=3,
)
# 直接指定使用其函数
# generation_output = model.greedy_search(
# input_ids=input_ids,
# num_beams = 1,
# do_sample = False,
# return_dict_in_generate=True,
# max_length = 7
# )
print("query:", text)
for i, output_sequence in enumerate(generation_output.sequences):
output_text = tokenizer.decode(output_sequence, skip_special_tokens=True)
print(f"Generated sequence {i+1}: {output_text}")
# 结果
inputs:{'input_ids': tensor([[ 1, 1827, 22172, 304]]), 'attention_mask': tensor([[1, 1, 1, 1]])}
query: say hello to
Generated sequence 1: say hello to the newest
4.4 multinomial sampling
多项式采样解码策略,在 model.generate() 中,当 num_beams 等于 1 且 do_sample 等于 True 时进入此模式,也可以使用model.sample(),该策略通过各种改变 logits 的参数(multinomial sampling,temperature,top_k,top_p等)从而实现生成文本的多样性。
from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig
from transformers import (
LogitsProcessorList,
TopKLogitsWarper,
TopPLogitsWarper,
TemperatureLogitsWarper,
)
import torch
model_name = "llama-2-7b-hf" # 你模型的位置
model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(model_name)
text = "say hello to"
inputs = tokenizer(text, return_tensors="pt")
print(f"inputs:{inputs}")
input_ids = inputs["input_ids"].to("cuda")
generation_output = model.generate(
input_ids=input_ids,
num_beams = 1,
do_sample = True,
temperature = 1.2,
top_k = 100,
top_p = 0.6,
return_dict_in_generate=True,
max_length=7,
)
# sample实现
# logits_warper = LogitsProcessorList(
# [
# TopKLogitsWarper(100),
# TemperatureLogitsWarper(1.2),
# TopPLogitsWarper(0.6)
# ]
# )
# generation_output = model.sample(
# input_ids=input_ids,
# logits_warper=logits_warper,
# return_dict_in_generate=True,
# max_length=7,
# )
print("query:", text)
for i, output_sequence in enumerate(generation_output.sequences):
output_text = tokenizer.decode(output_sequence, skip_special_tokens=True)
print(f"Generated sequence {i+1}: {output_text}")
# 注意这种方式每次结果都可能不一样
inputs:{'input_ids': tensor([[ 1, 1827, 22172, 304]]), 'attention_mask': tensor([[1, 1, 1, 1]])}
query: say hello to
Generated sequence 1: say hello to our new intern
4.5 beam-search decoding
beam search 的解码策略,上述已经讲解过实现过程,在 model.generate() 中是当 num_beams 大于 1 且 do_sample 等于 False 时使用,也可以调用 model.beam_search() 来实现,在此就不过多的赘述。
4.6 beam-search multinomial sampling
beam-search 中在实现采样的方式,其实就是在 model.generate() 中,当 num_beams 大于 1 且 do_sample 等于 True 时使用,其实就是在 beam search 中加入了多样化的采样方式,在此就不过多的赘述。
4.7 diverse beam-search decoding
分组的 beam-search 解码方式,上述在解释 num_beam_groups,已经进行过介绍,在 model.generate() 中,当 num_beams 大于 1 ,num_beam_groups 大于 1 ,diversity_penalty 大于 0,do_sample 等于 False 时进入此模式,在此就不过多的赘述。
4.8 assisted decoding
这一种解码方式比较有意思,叫做辅助解码,意思是使用另一个模型(称为辅助模型)的输出来辅助生成文本,一般是借助较小的模型来加速生成候选 token,辅助模型必须具有与目标模型完全相同的分词器(tokenizer),我们来简单实现一下,通过 llama7B 辅助生成 llama13B,一般来说辅助模型要很小,这里只是简单实验:
from transformers import AutoModelForCausalLM, AutoTokenizer, GenerationConfig
import torch
model_name = "llama-2-13b-hf" # 你自己模型的位置
assistant_model_name = "llama-2-7b-hf" # 你自己模型的位置
model = AutoModelForCausalLM.from_pretrained(model_name, device_map="auto")
assistant_model = AutoModelForCausalLM.from_pretrained(assistant_model_name, device_map="auto")
tokenizer = AutoTokenizer.from_pretrained(model_name)
text = "say hello to"
inputs = tokenizer(text, return_tensors="pt")
print(f"inputs:{inputs}")
input_ids = inputs["input_ids"].to("cuda")
generation_output = model.generate(
assistant_model=assistant_model,
input_ids=input_ids,
num_beams = 1,
do_sample = False,
return_dict_in_generate=True,
max_length=7,
)
print("query:", text)
for i, output_sequence in enumerate(generation_output.sequences):
output_text = tokenizer.decode(output_sequence, skip_special_tokens=True)
print(f"Generated sequence {i+1}: {output_text}")
# 结果
inputs:{'input_ids': tensor([[ 1, 1827, 22172, 304]]), 'attention_mask': tensor([[1, 1, 1, 1]])}
query: say hello to
Generated sequence 1: say hello to the newest
上述就是 Huggingface 中常用的参数,以及目前实现的解码策略的简单原理介绍和一些代码实现,目前关于解码策略方向的研究工作还是比较热的,因为并不是每个实验室都用 8 张以上 A100 ,后续有什么新的解码策略也会进行更新。
参考文献
更多阅读
#投 稿 通 道#
让你的文字被更多人看到
如何才能让更多的优质内容以更短路径到达读者群体,缩短读者寻找优质内容的成本呢?答案就是:你不认识的人。
总有一些你不认识的人,知道你想知道的东西。PaperWeekly 或许可以成为一座桥梁,促使不同背景、不同方向的学者和学术灵感相互碰撞,迸发出更多的可能性。
PaperWeekly 鼓励高校实验室或个人,在我们的平台上分享各类优质内容,可以是最新论文解读,也可以是学术热点剖析、科研心得或竞赛经验讲解等。我们的目的只有一个,让知识真正流动起来。
📝 稿件基本要求:
• 文章确系个人原创作品,未曾在公开渠道发表,如为其他平台已发表或待发表的文章,请明确标注
• 稿件建议以 markdown 格式撰写,文中配图以附件形式发送,要求图片清晰,无版权问题
• PaperWeekly 尊重原作者署名权,并将为每篇被采纳的原创首发稿件,提供业内具有竞争力稿酬,具体依据文章阅读量和文章质量阶梯制结算
📬 投稿通道:
• 投稿邮箱:hr@paperweekly.site
• 来稿请备注即时联系方式(微信),以便我们在稿件选用的第一时间联系作者
• 您也可以直接添加小编微信(pwbot02)快速投稿,备注:姓名-投稿
△长按添加PaperWeekly小编
🔍
现在,在「知乎」也能找到我们了
进入知乎首页搜索「PaperWeekly」
点击「关注」订阅我们的专栏吧