自学内容网 自学内容网

【大模型理论篇】大模型生成之解码策略(涉及束搜索、长度及重复惩罚、温度调节、Top-K及Top-P采样、对比解码、解码策略优化等)

1. 背景介绍 

        大模型作为一个复杂系统,涉及的知识点非常广泛。我们之前已经分享了许多相关内容,您可以参考《全方位解读大模型:多样知识点的深度探讨与技术分享小结》。不过,还有一些重要的方面尚未涵盖,因此本文将继续补充这些未被覆盖的内容,也会增加到大模型技术小结中。

        大模型训练完成后,将模型部署到实际场景中,对外提供的工作方式是通过文本生成。在自回归架构中,模型会根据输入内容(即提示文本)逐个单词生成输出,这里涉及到一个重要的知识点,那就是大模型的解码。本文会介绍常见的解码策略及其优化加速算法。

        大模型的生成过程本质上是一种概率采样机制。在这个过程中,模型根据输入信息预测每个词汇出现的概率,并逐步生成文本。因此,选择合适的解码策略至关重要,因为它直接影响到生成内容的质量和连贯性【1】。

        解码策略不仅决定了生成的文本是否符合上下文的逻辑,还影响到文本的多样性和创造性。不同的解码策略,如贪心搜索、束搜索和采样方法(如top-k和核采样),在输出内容时展现出不同的特点。例如,贪心搜索可能会快速找到一个可行的输出,但可能缺乏多样性;而束搜索则能够在生成过程中保持多个候选文本,从而提高输出的质量。

2. 语言模型生成文本过程描述

        语言模型通过根据输入上下文预测序列中的下一个token来生成文本。此过程始于将输入文本分解为可管理的片段或token【2】。

        token可以是单个字符或整个单词或短语,具体取决于模型的设计以及捕捉的语言细节水平。词级tokenizer将文本分割为完整单词,而子词tokenizer则将单词分解为更小的单位,如前缀、词根和后缀。子词tokenizer在词汇大小和处理未知词汇的能力之间取得了平衡。

        每个token由一个整数表示,将其映射到模型词汇中的唯一标识符。该词汇是模型识别的所有唯一token的预定义列表,类似于语言字典。例如,“beyond”可能被分配为整数1234,而子词“be”可能被分配为5678。

        在tokenize之后,token的数值表示将由模型处理,其中包括神经网络层和Transformer中的自注意力机制。这些组件协同工作以预测序列中的下一个token。

        在最后一层中,模型将输入序列转换为logit向量,每个条目表示词汇中每个token的得分【3】。这些logits指示每个token在序列中作为下一个token的可能性,但它们不是概率且总和不等于1。

        softmax函数将logits转换为概率。它将logits缩放到0和1之间,使其值总和为1,从而形成整个词汇中所有可能下一个token的概率分布。例如,如果当前序列为“The basketball team clinched”,模型可能预测下一个token为“the”的概率为0.298,“their”为0.213,“another”为0.125,等等。

        注意Logits是机器学习模型最后一层输出的原始未归一化得分,在应用任何激活函数之前。这些得分表示模型对每个可能结果的信心。然而,由于logits不是概率,它们的总和不等于1,并且不能直接解释。

        在计算概率后,模型根据这些概率使用各种解码策略选择下一个token。这些策略从简单地选择最可能的token到更复杂的方法(如引入随机性或考虑多个顶级候选项)各不相同。所选token会被添加到现有序列中,然后作为新输入反馈给模型。这个过程重复进行:模型处理扩展后的序列,生成一组新的logits和概率,并选择下一个token。这个循环持续,直到满足停止条件,例如达到最大序列长度或生成特殊的结束token。

        以下是自回归解码流程【1】:

3. 解码策略

        解码策略决定了语言模型在预测所有可能token的概率后如何选择下一个token。这些策略对生成文本的质量和多样性有很大影响。解码策略主要分为两类:确定性和随机性。

  • 确定性策略:在相同输入下,确定性策略将始终生成相同的输出,不涉及任何随机性。
  • 随机策略:在选择过程中引入随机性,旨在产生更丰富和创造性的输出,但可预测性较低。

3.1 贪心搜索

        贪心搜索是一种解码方法,在每个步骤中选择最可能的token作为下一个token。这意味着它始终选择每个阶段概率最高的token,忽略所有其他可能的选项。

优点

  • 效率:贪心解码简单且快速,适用于速度敏感的应用。
  • 低计算成本

缺点

  • 重复性:往往生成重复和可预测的文本。
  • 缺乏创造性:总是选择最可能的下一个token,未考虑更广泛的上下文或替代选项,可能降低文本质量和多样性。

        针对贪心搜索的局限性进行改进有以下几种方案:

束搜索(Beam Search)

        该方法通过保留前 n 个最高概率的句子来避免局部最优的问题。当 n = 1 时,束搜索变为贪心搜索。每一步选择联合概率最高的候选句子,最终选出整体生成概率最高的句子。

长度惩罚(Length Penalty)

        通过在生成概率计算中引入长度惩罚,避免生成较短句子的倾向。具体做法是将句子概率除以其长度的指数幂 α,鼓励生成更长的句子。

重复惩罚(Repetition Penalty)

        使用 n-元惩罚来减少重复生成的词元。出现惩罚和频率惩罚是更“温和”的策略,分别通过降低已生成词元的概率来减少重复的可能性。

        接下来我们对其中的部分技术展开讨论。

3.1.1 束搜索(Beam Search)

        束搜索不只是选择下一个概率最高的词元,而是在每个时间步骤维持一个包含 K 个最有可能序列的束,其中 K 被称为束宽度。这个过程会持续进行,直到达到预定义的最大长度或出现结束序列标记。这种方法生成的文本质量更高,具体取决于束的大小,但由于计算量比贪心搜索更多,可能会较慢。

        以上图为例,我们从给定的序列“Once upon a time”开始,对于束宽度 K=2,下一个最可能的两个词元是“a”和“the”。在下一次迭代中,我们有两个序列(“a”, “cat”),其概率为 0.20(0.5×0.4),还有一个序列(“the”, “people”),其概率更高为 0.21(0.3×0.7)。因此,束搜索可以选择概率更高的序列“the people”作为生成序列。如果我们增加束宽度 K,算法可以探索更多的序列,从而生成更高质量的文本,但这会增加计算成本。因此,这两者之间存在权衡。

        上述方法在每一步选择最可能的下一个词元,称为确定性方法。这些方法生成的输出文本保证了可预测性,但往往以多样性为代价。

  • 束搜索概率计算

    对于每个时间步骤 t,选择的概率计算公式为:

    P(s_t) = \prod_{i=1}^{t} P(w_i | s_{1}, s_{2}, \ldots, s_{i-1})

    其中 s_t​ 是当前步骤的序列,w_i 是在序列中生成的词元。

  • 束宽度

    设定束宽度 K 时,选择的序列数量为 K,可以表示为:

    S = \{s_1, s_2, \ldots, s_K\}

    其中 S 是当前保留的序列集合。

        【3】利用networkx给出了可视化的代码及图示,帮助理解:

from transformers import GPT2LMHeadModel, GPT2Tokenizer
import torch
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import time
import matplotlib.pyplot as plt
import networkx as nx
import matplotlib.colors as mcolors
from matplotlib.colors import LinearSegmentedColormap
from tqdm.notebook import tqdm


def get_log_prob(logits, token_id):
    # Compute the softmax of the logits
    probabilities = torch.nn.functional.softmax(logits, dim=-1)
    log_probabilities = torch.log(probabilities)
    
    # Get the log probability of the token
    token_log_probability = log_probabilities[token_id].item()
    return token_log_probability

def greedy_search(input_ids, node, length=5):
    if length == 0:
        return input_ids

    outputs = model(input_ids)
    predictions = outputs.logits

    # Get the predicted next sub-word (here we use top-k search)
    logits = predictions[0, -1, :]
    token_id = torch.argmax(logits).unsqueeze(0)

    # Compute the score of the predicted token
    token_score = get_log_prob(logits, token_id)

    # Add the predicted token to the list of input ids
    new_input_ids = torch.cat([input_ids, token_id.unsqueeze(0)], dim=-1)

    # Add node and edge to graph
    next_token = tokenizer.decode(token_id, skip_special_tokens=True)
    current_node = list(graph.successors(node))[0]
    graph.nodes[current_node]['tokenscore'] = np.exp(token_score) * 100
    graph.nodes[current_node]['token'] = next_token + f"_{length}"

    # Recursive call
    input_ids = greedy_search(new_input_ids, current_node, length-1)
    
    return input_ids

def plot_graph(graph, length, beams, score):
    fig, ax = plt.subplots(figsize=(3+1.2*beams**length, max(5, 2+length)), dpi=300, facecolor='white')

    # Create positions for each node
    pos = nx.nx_agraph.graphviz_layout(graph, prog="dot")

    # Normalize the colors along the range of token scores
    if score == 'token':
        scores = [data['tokenscore'] for _, data in graph.nodes(data=True) if data['token'] is not None]
    elif score == 'sequence':
        scores = [data['sequencescore'] for _, data in graph.nodes(data=True) if data['token'] is not None]
    vmin = min(scores)
    vmax = max(scores)
    norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
    cmap = LinearSegmentedColormap.from_list('rg', ["r", "y", "g"], N=256) 

    # Draw the nodes
    nx.draw_networkx_nodes(graph, pos, node_size=2000, node_shape='o', alpha=1, linewidths=4, 
                          node_color=scores, cmap=cmap)

    # Draw the edges
    nx.draw_networkx_edges(graph, pos)

    # Draw the labels
    if score == 'token':
        labels = {node: data['token'].split('_')[0] + f"\n{data['tokenscore']:.2f}%" for node, data in graph.nodes(data=True) if data['token'] is not None}
    elif score == 'sequence':
        labels = {node: data['token'].split('_')[0] + f"\n{data['sequencescore']:.2f}" for node, data in graph.nodes(data=True) if data['token'] is not None}
    nx.draw_networkx_labels(graph, pos, labels=labels, font_size=10)
    plt.box(False)

    # Add a colorbar
    sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
    sm.set_array([])
    if score == 'token':
        fig.colorbar(sm, ax=ax, orientation='vertical', pad=0, label='Token probability (%)')
    elif score == 'sequence':
        fig.colorbar(sm, ax=ax, orientation='vertical', pad=0, label='Sequence score')
    plt.show()

def greedy_sampling(logits, beams):
    return torch.topk(logits, beams).indices
    
def beam_search(input_ids, node, bar, length, beams, sampling, temperature=0.1):
    if length == 0:
        return None

    outputs = model(input_ids)
    predictions = outputs.logits

    # Get the predicted next sub-word (here we use top-k search)
    logits = predictions[0, -1, :]

    if sampling == 'greedy':
        top_token_ids = greedy_sampling(logits, beams)
    elif sampling == 'top_k':
        top_token_ids = top_k_sampling(logits, temperature, 20, beams)
    elif sampling == 'nucleus':
        top_token_ids = nucleus_sampling(logits, temperature, 0.5, beams)

    for j, token_id in enumerate(top_token_ids):
        bar.update(1)

        # Compute the score of the predicted token
        token_score = get_log_prob(logits, token_id)
        cumulative_score = graph.nodes[node]['cumscore'] + token_score

        # Add the predicted token to the list of input ids
        new_input_ids = torch.cat([input_ids, token_id.unsqueeze(0).unsqueeze(0)], dim=-1)

        # Add node and edge to graph
        token = tokenizer.decode(token_id, skip_special_tokens=True)
        current_node = list(graph.successors(node))[j]
        graph.nodes[current_node]['tokenscore'] = np.exp(token_score) * 100
        graph.nodes[current_node]['cumscore'] = cumulative_score
        graph.nodes[current_node]['sequencescore'] = 1/(len(new_input_ids.squeeze())) * cumulative_score
        graph.nodes[current_node]['token'] = token + f"_{length}_{j}"

        # Recursive call
        beam_search(new_input_ids, current_node, bar, length-1, beams, sampling, 1)

def get_best_sequence(G):
    # Create a list of leaf nodes
    leaf_nodes = [node for node in G.nodes() if G.out_degree(node)==0]

    # Get the leaf node with the highest cumscore
    max_score_node = None
    max_score = float('-inf')
    for node in leaf_nodes:
        if G.nodes[node]['sequencescore'] > max_score:
            max_score = G.nodes[node]['sequencescore']
            max_score_node = node

    # Retrieve the sequence of nodes from this leaf node to the root node in a list
    path = nx.shortest_path(G, source=0, target=max_score_node)

    # Return the string of token attributes of this sequence
    sequence = "".join([G.nodes[node]['token'].split('_')[0] for node in path])
    
    return sequence, max_score


device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = GPT2LMHeadModel.from_pretrained('gpt2').to(device)
tokenizer = GPT2Tokenizer.from_pretrained('gpt2')
model.eval()

text = "I have a dream"
input_ids = tokenizer.encode(text, return_tensors='pt').to(device)

outputs = model.generate(input_ids, max_length=len(input_ids.squeeze())+5)
generated_text = tokenizer.decode(outputs[0], skip_special_tokens=True)
print(f"Generated text: {generated_text}")

# Parameters
length = 5
beams = 1

# Create a balanced tree with height 'length'
graph = nx.balanced_tree(1, length, create_using=nx.DiGraph())

# Add 'tokenscore', 'cumscore', and 'token' attributes to each node
for node in graph.nodes:
    graph.nodes[node]['tokenscore'] = 100
    graph.nodes[node]['token'] = text

# Start generating text
output_ids = greedy_search(input_ids, 0, length=length)
output = tokenizer.decode(output_ids.squeeze().tolist(), skip_special_tokens=True)
print(f"Generated text: {output}")

# Plot graph
plot_graph(graph, length, 1.5, 'token')

# Parameters
length = 5
beams = 2

# Create a balanced tree with height 'length' and branching factor 'k'
graph = nx.balanced_tree(beams, length, create_using=nx.DiGraph())
bar = tqdm(total=len(graph.nodes))

# Add 'tokenscore', 'cumscore', and 'token' attributes to each node
for node in graph.nodes:
    graph.nodes[node]['tokenscore'] = 100
    graph.nodes[node]['cumscore'] = 0
    graph.nodes[node]['sequencescore'] = 0
    graph.nodes[node]['token'] = text

# Start generating text
beam_search(input_ids, 0, bar, length, beams, 'greedy', 1)

sequence, max_score = get_best_sequence(graph)
print(f"Generated text: {sequence}")

# Plot graph
plot_graph(graph, length, beams, 'sequence')

3.1.2 长度惩罚(Length Penalty)

        长度惩罚是为了避免束搜索倾向于生成较短句子的问题。为了实现这一点,可以在计算句子概率时引入长度惩罚。具体公式为:

P_{\text{normalized}}(s) = \frac{P(s)}{L^{\alpha}}

其中:

  • P_{\text{normalized}}(s) 是经过长度惩罚归一化的概率;
  • P(s) 是句子的原始生成概率;
  • L 是句子的长度;
  • α 是惩罚因子,通常设置为 0.6 到 0.7 之间的数值。

3.1.3 重复惩罚(Repetition Penalty)

        重复惩罚是为了降低生成重复词元的概率,可以通过 n-元惩罚或其他机制来实现。对于 n-元惩罚,公式为:

P_{\text{penalized}}(w) = P(w) \times (1 - \text{Penalty}(n))

其中:

  • P_{\text{penalized}}(w) 是经过重复惩罚调整后的词元概率;
  • P(w) 是词元的原始概率;
  • Penalty(n) 是基于当前生成的重复 n-元词的惩罚值。

其他惩罚机制

        出现惩罚(Presence Penalty)

P_{\text{adjusted}}(w) = P(w) - \alpha \times \text{count}(w)

        频率惩罚(Frequency Penalty)

P_{\text{adjusted}}(w) = P(w) - \alpha \times \text{frequency}(w)

        其中 α 的取值范围通常在 0.1 到 1 之间,\text{count}(w)是词元已生成的次数,\text{frequency}(w)是词元在生成过程中出现的频率。

3.2 概率采样

        另一种可选的解码策略是概率采样。 该方法根据模型建模的概率分布采样得到下一个词元,增强生成过程中的随机性和结果的多样性。基于概率采样的方法会在整个词表中选择词元,这可能会导致生成不相干的词元。为了进一步提高生成质量,可以进一 步使用一些改进的采样策略,减少具有极低概率词汇对于生成结果的影响。

3.2.1 温度调节(Temperature Setting)

        在生成过程中,温度可以影响下一个词的概率分布,高温度可能导致更多的随机性,而低温度则使得生成更具确定性。如果接触过任何大模型,应该会注意到有一个温度参数。这个参数被设置成可以影响文本生成。温度是一个超参数(而不是解码策略),它修改softmax函数产生的输出概率,影响下一个token的选择。

        基于采样的方法在整个词汇上进行token采样,这可能会选择错误或无关的token。为了提高生成质量,减轻或防止选择极低概率的词汇,使用温度缩放。

        温度(T)通过在应用softmax之前将每个logit除以T来调整softmax函数:

​​​​​​​df["softmax_temperature"] = np.round(np.exp(df.Logit/temp) / np.sum(np.exp(df.Logit/temp)),4)

        对概率分布的影响

  • 高温度(T > 1):当温度大于1时,使得logits之间的相似度增大,分布“平坦化”,让不太可能的token有更高的被选择的机会,文本更随机。
  • 低温度(T < 1):当温度小于1时,夸大logits之间的差异,分布“尖锐化”,使最可能的token更有可能被选择,结果是文本更可预测。
  • 温度=1:softmax函数正常工作,对logits没有任何变化。

        调整温度允许控制文本生成中的随机性与可预测性之间的平衡,影响生成文本的多样性和新颖性。下图给出了温度的不同设置,对于token的概率影响【5】。

3.2.2 Top-k 采样(Top-k Sampling)

        Top-k 采样通过直接剔除概率较低的词元,限制模型从概率最高的前 k 个词元中进行采样。例如,若使用 top-3 采样,模型将仅从概率最大的三个词元中进行采样。

3.2.3 Top-p 采样(Top-p Sampling)

       由于 top-k 采样策略并不考虑整体概率分布, 因此固定的常数 𝑘 可能无法适应不同的上下文语境。 Top-p 采样(又称核采样)从符合特定概率条件的最小词元集合中进行采样,要求该集合中所有词元的累积概率大于或等于预设阈值 p。其具体实现步骤为:

  1. 按生成概率对词元进行排序;
  2. 不断将词元添加到临时集合,直到集合的累积概率首次超过阈值 p。

3.2.4 对比解码(Contrastive Decoding)

        对比解码利用较大语言模型与较小语言模型之间的对数概率分布差异来提高重要词元的影响力【6】。其背后的思路是大模型比小模型具有更强的生成能力,因而在预测下一个词元时,大模型相较于小模型更倾向于为重要词元分配更高的概率。

        具体过程为:

  1. 计算大型模型(如 GPT-2 XL)和小型模型(如 GPT-2 small)预测的对数概率分布差值;
  2. 基于归一化的差值分布进行下一个词元的采样。

        例如,在预测“李时珍是湖北人,他出生于 __”的下一个词时,若 GPT-2 XL 为“湖北”分配 15% 概率,而 GPT-2 small 为 10%,则对比解码能够有效利用这一现象,提升重要词汇在生成过程中的影响力。

        具体的公式可以表示为:

  1. 设定较大模型的对数概率为 \log P_{\text{large}}(u_j | u_{<i})
  2. 设定较小模型的对数概率为 \log P_{\text{small}}(u_j | u_{<i})

        对比解码的归一化概率可以通过以下方式计算:

P_{\text{contrastive}}(u_j | u_{<i}) = \frac{\exp(\log P_{\text{large}}(u_j | u_{<i}) - \log P_{\text{small}}(u_j | u_{<i}))}{\sum_{j'} \exp(\log P_{\text{large}}(u_{j'}) - \log P_{\text{small}}(u_{j'})}

        公式反映了大模型对每个候选词元的概率与小模型的概率差异,从而提升重要词元的影响力。

        基于上述讨论,我们再来看下大模型的解码策略应用,GPT-3使用了束搜索和长度惩罚、llama在问答任务使用了贪心搜索和代码生成任务中采用了温度设置,此外Open AI也使用了温度采样、top-p采样以及出现惩罚和频率惩罚。后续可以对照着大模型的设置来理解它们的生成策略。

4. 解码策略优化

        自回归生成的解码看起来就很耗时,对于大模型来说计算量和算力消耗就更大了。因此出现了一些解码策略优化。其实我们之前介绍的KV Cache、FlashAttention、PagedAttention等都可以用于解码效率的提升,具体技术分享可以参考《全方位解读大模型:多样知识点的深度探讨与技术分享小结》中的计算加速优化章节的文章。

        接下来我们展开说一下解码策略的优化,其中有一些优化的思路可能会比较容易想到:

推测解码

        推测解码的核心思想是通过先使用小型模型生成初步的词元,再由大型模型验证这些词元的正确性。这个方法有效提升了解码效率,通常能够实现约两倍的加速。推测解码特别适用于生成较难预测的词时,它使用小模型处理初步的生成任务,然后由大模型进行验证和修正。比如,在生成一个句子时,小模型可以先生成部分词元,大模型再检查这些词元是否为概率最高的选择。

级联解码

        级联解码与推测解码相似,利用不同规模的模型来处理不同复杂度的请求,以减少解码时间。FrugalGPT【7】采用多个模型,按效率排序,逐步生成结果,并使用分类模型判断生成内容的有效性。只有在结果符合要求时,才会跳过后续模型的生成,从而提高整体解码效率。

非自回归解码

        非自回归解码通过并行生成所有词元来提高效率,但通常牺牲了一定的生成质量。为了解决这一问题,半自回归解码被提出,它每次生成一组词元,并使用这些词元作为输入继续生成。这种方法在效率上有所提升,但仍需依赖自回归生成的验证,确保质量。

早退机制

        早退机制基于观察到的现象,认为在某些情况下,不必经过模型的所有层就能有效预测下一个词(我们很早的时候就尝试过在对话场景仅用较少的层来推理发现效果也是可以得到保证的)。通过设置早退条件,可以在满足条件时终止后续计算,提升解码速度。常见的做法是使用熵值判断输出概率分布的集中程度,当熵值低于某一阈值时,判断可以早退。此外,混合深度方法通过动态调整每一层的计算量,能够选择性地跳过部分层,从而更有效地利用模型的不同特性。

5. 参考材料

【1】A Survey of Large Language Models

【2】Mastering Text Generation: Unveiling the Secrets of Decoding Strategies in Large Language Models

【3】Decoding Strategies in Large Language Models

【4】All You Need To Know About LLM Text Generation

【5】LLM — Temperature

【6】RUC AI BOX 大预言模型

【7】FrugalGPT: How to Use Large Language Models While Reducing Cost and Improving Performance


原文地址:https://blog.csdn.net/weixin_65514978/article/details/143059617

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!