LLM原理训练推理详解

带着问题看原理

本文将基于llama进行讲解,探讨在LLM使用过程中的一些细节内容:

  1. 大模型中是loss如何计算?
  2. 训练是如何并行的?
  3. 推理是如何预测下一个词的?

LLM的算法本质:

LLM的算法本质就是基于下一个tokens的预测。得益于next token predict的训练/推理方式。

在训练时,可以阅读大量文本,捕捉到文本背后隐藏的规律,预测下一个token也越来越准,获得智能,同时也达到了压缩文本的效果,所以人们常说,压缩即智能。

在推理时,可以随心所欲的生成下一个token,直到生成出停止符号,这也让LLM可以应用于更广阔更多元化的任务,毕竟所有的文本任务,不管是NLI(自然语言推理),填空,阅读理解,还是文本生成,都可以统一为输入输出的形式。

不同形式的数据集构造:

预训练数据集构造

根据prompt模板拼接构造数据:
  • 目的与思路
    • 格式标准化:大模型(例如对话型模型或指令模型)通常要求输入数据具备一定的结构,这样模型才能清楚地“理解”指令和对应的回答。
    • 模板拼接: 是指将任务的不同部分(例如 系统提示用户输入模型输出)按照预定义的结构(模板)组合起来,形成一个完整的输入序列。这样做的目的是:让模型在训练时可以清楚地分辨 哪个部分是系统的提示(指令)哪个部分是用户的输入,以及哪个部分是模型的答案
    • 举个例子:假设你在训练一个 对话模型,模型的任务是根据用户的提问生成回答。为了确保模型理解不同部分的作用,你会为模型设计一个特定的输入格式(即 prompt 模板。模板中的各个部分会有 固定的结构,并且模型训练和推理时使用相同的格式。
<bos> 系统提示:请根据以下问题回答。 <im_end>
<im_start> 用户:你能介绍一下这张图片的内容吗? <im_end>
<im_start> 助手:这张图片展示了…… <eos>
  • 解释
    • bos:表示序列的开始。
    • prompt请根据以下问题回答。系统对模型的提示(promt),告诉模型接下来的输入是一个问题。
    • <im_end>`:表示某一部分的结束。
    • <im_start>`:表示接下来的部分是模型的输入或输出部分,通常用于标记模型需要处理的输入或输出。
    • 用户输入用户:你能介绍一下这张图片的内容吗?用户提问,是模型需要处理的内容。
    • 助手输出助手:这张图片展示了……模型的目标输出,即模型需要生成的答案。
    • eos :表示序列的结束。
  • 关键点
    1. 模板一致性
    • 格式一致性:确保模板中的各个部分(如“系统提示”、“用户输入”和“助手答案”)在 训练时和推理时 保持一致。这是非常重要的,因为如果训练时模板格式和推理时不一致,模型就无法理解各个部分的含义,最终可能导致生成的结果不符合预期。
    • 模板拼接的作用:通过模板拼接,我们明确了输入和输出的结构。例如,<im_start>用户:助手: 这两个标记帮助模型分清楚 谁在提问,谁在回答。这种清晰的结构不仅有助于模型理解任务,还能够提高训练的效果。
    1. 拼接后的序列包含输入和目标
    • 拼接后的序列不仅仅是 用户输入,还包含了 目标输出(即模型需要生成的答案)。模型会通过计算 输入和输出之间的关系 来学习任务。例如:
    • 输入系统提示:请根据以下问题回答。 用户:你能介绍一下这张图片的内容吗?
    • 目标输出(标签)助手:这张图片展示了……
    • 在这种情况下,模型 需要根据输入(用户问题)生成输出(助手答案)。这为后续计算损失(loss)提供了参考,模型的目标是 最小化实际生成输出与目标输出(标签)之间的差异
  • 代码示例
import random

# 示例问题和答案
qa_pairs = [
    ("你能介绍一下这张图片的内容吗?", "这张图片展示了一个美丽的花园,里面有很多五彩斑斓的花朵。"),
    ("食材有哪些?", "这张图片中的食材包括鸡蛋、黄瓜和番茄。"),
    ("这是什么动物?", "这是一只橙色的虎斑猫。")
]

# 系统提示
system_prompt = "请根据以下问题回答。"

# 用于拼接的函数
def create_prompt(qa_pairs, system_prompt):
    prompts = []
    
    for question, answer in qa_pairs:
        # 构建每个样本的格式
        prompt = f"<bos> 系统提示:{system_prompt} <im_end>\n"
        prompt += f"<im_start> 用户:{question} <im_end>\n"
        prompt += f"<im_start> 助手:{answer} <eos>\n"
        
        prompts.append(prompt)
    
    return prompts

# 生成训练数据
training_data = create_prompt(qa_pairs, system_prompt)

# 打印训练数据
for data in training_data:
    print(data)
<bos> 系统提示:请根据以下问题回答。 <im_end>
<im_start> 用户:你能介绍一下这张图片的内容吗? <im_end>
<im_start> 助手:这张图片展示了一个美丽的花园,里面有很多五彩斑斓的花朵。 <eos>

<bos> 系统提示:请根据以下问题回答。 <im_end>
<im_start> 用户:食材有哪些? <im_end>
<im_start> 助手:这张图片中的食材包括鸡蛋、黄瓜和番茄。 <eos>

<bos> 系统提示:请根据以下问题回答。 <im_end>
<im_start> 用户:这是什么动物? <im_end>
<im_start> 助手:这是一只橙色的虎斑猫。 <eos>
分词:将文本转换为 Token IDs 和 添加特殊符号
  • 目的与思路
    • 数值化输入:大模型无法直接处理纯文本,因此需要将文本转换为数字形式。这一步称为分词(tokenization),利用模型对应的 Tokenizer 将每个词或子词映射为唯一的整数 ID。
    • 构造模型输入:通过分词后得到的 token ID 序列,就是模型的直接输入。同时,Token IDs 保留了文本的语义和结构信息。
  • 关键点
    • 分词时使用的词汇表必须与预训练时保持一致,否则词的编码不匹配会导致模型无法正确理解输入。
    • 得到的 token 序列长度可能不同,后续通常还需要对序列进行填充(padding)处理,以便批量训练。
  • 特殊符号添加
    • (Beginning of Sequence):标识序列开始。训练时告知模型从何处开始生成内容。
    • 或 (End of Sequence):标识序列结束,帮助模型理解何时停止生成。
    • (Padding):用于填充不同长度的序列,使得批次内的所有样本长度一致,以便并行计算。
  • 示例

下面以 Hugging Face 的 Transformers 库为例,展示如何将文本转换为 Token IDs。假设我们使用 GPT-2 模型的分词器(Tokenizer),代码示例如下:

from transformers import AutoTokenizer

# 加载预训练模型对应的 tokenizer,注意必须与模型保持一致
tokenizer = AutoTokenizer.from_pretrained("gpt2")

# 待处理的文本
text = "你好,世界!"

# 将文本转换为 Token IDs
# add_special_tokens=True 表示在文本前后自动添加特殊符号(如起始符<bos>和结束符</s>)
encoded_ids = tokenizer.encode(text, add_special_tokens=True)

# 输出 token ids
print("文本对应的 Token IDs:", encoded_ids)

# 如果需要查看每个 token 对应的文本,可以调用:
tokens = tokenizer.convert_ids_to_tokens(encoded_ids)
print("Token IDs 对应的 Token:", tokens)
Shift-Labels

让我们先不看答案,自己想一下会如何构造训练数据(给定原始数据:token1, token2, token3 )

不就是预测下一个token嘛。那最容易想到的就是token1预测token2,token1 token2预测token3 。模型运行两次forward,反向传播两次。

但是效率太低了,别忘了transformer架构的优势之一就是可以并行,所以我们采用一种数据处理方式,此方式可以在单次传递中处理和学习多个预测。

  • 作用
    • 并行训练需求:语言模型训练时通常采用自回归的方式,即当前时刻的输入用来预测下一个 token。为了实现这一点,模型的输入序列和目标(labels)序列会有一个“位移”关系。
    • 标签位移:例如,假设拼接后得到的 token 序列为
[<bos>, token1, token2, token3, </s>]
	 在计算损失时,模型需要利用前面的 token 来预测后续 token,因此目标 labels 应该为  
[token1, token2, token3, </s>, <PAD>]
这里将原始序列向左平移一个位置,使得模型在位置 i 的输出对应着位置 i+1 的真实 token。<font style="color:rgb(25, 27, 31);">这样训练模型时,仅需一次forward,模型会给出 <bos>时去预测 token1;在给出<bos>和 token1 时去预测 token2;在给出 <bos>、token1 和 token2 时去预测 token3,以此类推,loss是这几个预测损失的和。</font>

我们需要确保所有的序列长度相等,以用于模型训练。这里 用于在训练批次中对齐序列到同样的长度,而token 不会用于损失计算

  • 关键点
    • 保持一致性:训练时在输入、特殊符号添加和标签位移的格式必须与推理时一致。否则,模型可能会因为格式不匹配而输出错误。
    • 并行计算:标签位移使得整个序列内所有位置的预测可以同时计算,从而大大提高训练效率,这在大模型微调时尤为重要。
对应代码
目的:

是构建可用于自回归语言模型训练的数据格式,也就是:

- **模型输入(input_ids)**:从 `<bos>`(开始符)开始,后面跟上输入文本;
- **标签(labels)**:对应的训练目标,是模型需要逐个预测的内容,从输入文本的第一个 token 开始,结尾加 `<eos>`(结束符);
- 这样构建的 input 和 labels 结构允许 Transformer 并行处理每个位置的预测任务。
pt的处理函数:
def preprocess_unsupervised_dataset(examples: Dict[str, List[Any]]) -> Dict[str, Any]:
# build inputs with format `<bos> X` and labels with format `Y <eos>`

举个例子:

原始文本 = “你好,世界”

转换成训练对:

input_ids: 你 好 , 世 界

labels: 你 好 , 世 界

模型会学习:看到 <bos> 应该输出“你”;看到“你”应输出“好”……直到输出 <eos> 为止;

训练过程中每个 token 预测的是下一个 token,属于典型的 auto-regressive 自回归方式

函数原理
from transformers import AutoTokenizer
from typing import Dict, List, Any

tokenizer = AutoTokenizer.from_pretrained("gpt2")  # 替换成你自己的模型Tokenizer

def preprocess_unsupervised_dataset(examples: Dict[str, List[Any]]) -> Dict[str, Any]:
    inputs = []
    labels = []
    
    for text in examples["text"]:
        # 编码文本
        input_tokens = tokenizer.encode(text, add_special_tokens=False)
        bos_token = tokenizer.bos_token_id or tokenizer.convert_tokens_to_ids("<bos>")
        eos_token = tokenizer.eos_token_id or tokenizer.convert_tokens_to_ids("<eos>")

        # 构建 input_ids: [<bos>, X1, X2, ..., Xn]
        input_ids = [bos_token] + input_tokens

        # 构建 labels:     [X1, X2, ..., Xn, <eos>]
        label_ids = input_tokens + [eos_token]

        # 对齐长度(可选)
        max_length = 128
        input_ids = input_ids[:max_length]
        label_ids = label_ids[:max_length]

        # 填充(注意:labels 的 padding 用 -100 以跳过loss计算)
        padding_length = max_length - len(input_ids)
        input_ids += [tokenizer.pad_token_id] * padding_length
        label_ids += [-100] * padding_length

        inputs.append(input_ids)
        labels.append(label_ids)

    return {
        "input_ids": inputs,
        "labels": labels,
        "attention_mask": [[1 if id != tokenizer.pad_token_id else 0 for id in seq] for seq in inputs]
    }
示例输入输出

假设:

examples = {"text": ["你好世界"]}

如果 tokenizer 的结果是:

"你好世界" -> [101, 102, 103]

并且 <bos> 是 1,<eos> 是 2,<pad> 是 0,那么你会得到:

input_ids:  	[1, 101, 102, 103, 0, 0, ..., 0]   # 长度 = 128,所以有124个0
labels:     	[101, 102, 103, 2, -100, ..., -100]
attention_mask: [1, 1, 1, 1, 0, ..., 0]
角色含义内容
<bos>序列开始input_ids 的开头
<eos>序列结束labels 的结尾
input_ids输入序列<bos> + 原始 token
labels训练目标原始 token + <eos>
attention_mask哪些位置有效padding 是 0,其它是 1
-100忽略计算损失的位置避免对 padding 区域计算损失

SFT数据集构造示例

SFT(Supervised Fine-Tuning,SFT):称为监督式微调,在SFT任务重( 例如 QA 对 ), 数据预处理通常要求构造这样的格式:

单轮数据集构建
  • 输入(input_ids):由问题(Q)和答案(A)的拼接组成,格式为

这是单轮问答的QA,Q1 Q2可能时一个复合问题,A1,A2,A3 对应的问题的答案部分,可能包含多个相关的回答。

<bos> Q1 Q2 Q3 A1 A2 <eos>
  • 标签(labels):用来计算损失的目标。在标签序列中,问题部分不参与损失计算,通常用 <pad>(或其它忽略符号,例如 -100,在 PyTorch 中常用 -100)进行填充;只有答案部分及结束符参与损失计算,格式为:
<pad> <pad> <pad> A1 A2 <eos>

原因

  • 在 SFT 任务中,输入中的问题部分通常是重复且固定的,而损失计算主要关注答案部分。
  • 如果计算问题部分的损失,会使模型过多学习“重复”的提示信息,影响回答部分的效果。因此,标签中问题部分被 mask 掉(填充 <pad> 或 -100),使得计算损失时只考虑答案部分。
示例数据:

在实际应用中,问题和答案的数量往往并不总是一样的。有时我们可能有多个问题对应一个答案,或者有一个问题对应多个答案。处理这种 QA 数量不等 的情况,我们需要确保数据在输入时对齐,并且标签部分的填充和 mask 正确地反映每个问题和答案的结构。

假设我们有以下数据:

问题答案
Q1:请描述这张图片中的食材。A1:这张图片中的食材包括鸡蛋、黄瓜和番茄。
Q2:食材的种类有哪些?A2:这些食材可以分为蔬菜类和蛋白质类。
Q3:哪些食材是蛋白质类?

在这种情况下,问题的数量(3 个)大于答案的数量(2 个),我们需要确保输入和标签的一致性,并适当填充。

构造输入和标签的具体步骤
  • **输入格式, 我们要将问题和答案组合起来,形成一个完整的输入序列,格式为: **
#形式:
<bos> Q1 Q2 Q3 A1 A2 <eos>
#结果:
<bos> 请描述这张图片中的食材。 食材的种类有哪些? 哪些食材是蛋白质类? 这张图片中的食材包括鸡蛋、黄瓜和番茄。 这些食材可以分为蔬菜类和蛋白质类。 <eos>

我们通过 tokenizer 将其转换为 token IDs。例如,假设对应的 token IDs 为:

input_ids = [101, 1234, 5678, 910, 11, 12, 13, 14, 15, 16, 102]
标签(labels)格式
对于标签(用于计算损失的目标),我们只需要关注答案部分。问题部分的 token 会被 **mask 掉**(使用 `-100`),表示在计算损失时不参与计算。标签格式为:  
<pad> <pad> <pad> A1 A2 <eos>

对应的标签为:

<pad> <pad> <pad> 这张图片中的食材包括鸡蛋、黄瓜和番茄。 这些食材可以分为蔬菜类和蛋白质类。 <eos>

在实际处理时,问题部分会用 -100 填充,答案部分正常:

labels = [-100, -100, -100, 210, 500, 600, 700, 800, 1024, 102]
  • 注意:

在处理这种 QA 数据 时,<pad> 的数量通常与 问题部分的 token 数量 有关,而不是问题文本的字数。 如果一个问题包含 n 个 token,那么对应的标签中的填充部分将有 **n + 1**** 个 ****-100**(因为还包括 <bos><eos> 的影响)。

多轮对话(Packed SFT)的数据处理方式
数据形式:

假设多轮对话数据的原始形式为:

Q1 A1 Q2 A2 Q3 A3

有几种组织形式:

  1. 直接拼接:
Q1 A1 Q2 A2 Q3 A3

这种方式没有区分多轮结构,只是将所有对话拼接起来;缺点是没有充分利用多轮对话的层次信息。

  1. 拆分为多次训练:

将数据拆分为三个样本:

- 样本1:`Q1 A1`
- 样本2:`Q1 A1 Q2 A2`
- 样本3:`Q1 A1 Q2 A2 Q3 A3`

这种方式每个样本都单独反向传播,但训练效率低,因为同一数据重复训练了多次

  1. 一次 forward 计算多个 loss

    在一次前向传播中统计三个轮次的 loss,然后累加反向传播。个人推荐这种方式,它可以充分利用数据,同时减少重复计算。

例如,对于输入:

<bos> Q1 A1 Q2 A2 Q3 A3 <eos>

对应的标签:

<pad> ... <pad> A1 <pad> ... A2 <pad> ... A3 <eos>

这里可以通过事先标记好答案所在的区域,然后在计算 loss 时只对这些区域求和。

  • 注意: 这里也可以出现有问题但是没有答案的情况,这种情况就是在计算损失函数是问题会被 -100 替代,而没有问题的部分因为没有位置不会造成损失值。
  • 问题1:请描述这张图片中的食材?
  • 答案1:这张图片中的食材包括鸡蛋、黄瓜和番茄。
  • 问题2:食材的种类有哪些?
  • 答案2:这些食材可以分为蔬菜类和蛋白质类。
  • 问题3:能推荐一个菜谱吗?
    (这个问题没有答案)

输入格式:
请描述这张图片中的食材? 这张图片中的食材包括鸡蛋、黄瓜和番茄。 食材的种类有哪些? 这些食材可以分为蔬菜类和蛋白质类。 能推荐一个菜谱吗?

标签格式(填充问题部分,答案部分正常):

这张图片中的食材包括鸡蛋、黄瓜和番茄。 这些食材可以分为蔬菜类和蛋白质类。

轮次3没有答案,所以它的标签部分填充为 -100<pad>,也就是 没有答案的部分不会参与损失计算

单轮和多轮数据的区别
基本的结构和含义
单轮 QA 格式(<bos> Q1 Q2 A1 A2 A3 <eos>

这种格式通常用于处理 单轮问答(Single-turn QA)。具体含义是:

  • **<bos>**:表示序列的开始。
  • **Q1 Q2**:表示两个问题,Q1 和 Q2(可能是一个复合问题)。
  • **A1 A2 A3**:对应的问题的答案部分,可能包含多个相关的回答。
  • **<eos>**:表示序列的结束。
多轮对话格式(<bos> Q1 A1 Q2 A2 Q3 A3 <eos>

这种格式则用于处理 多轮对话(Multi-turn Dialogue)。具体含义是:

  • **<bos>**:表示序列的开始。
  • **Q1 A1 Q2 A2 Q3 A3**:表示多轮对话中的问题和回答交替出现。每一轮的提问和回答(Q1-A1,Q2-A2,Q3-A3)都被依次列出。
  • **<eos>**:表示序列的结束。
区别分析
单轮 QA(<bos> Q1 Q2 A1 A2 A3 <eos>

在这种格式中,模型被训练来 理解问题和答案的关系,并且在回答时不需要考虑上下文的变化(即,没有连续的对话流)。

  • 输入:包括问题和答案。模型的目标是 理解问题并生成答案
  • 标签:问题和答案的部分会同时存在,在标签中对问题部分进行填充(通常用 -100<pad>),使得模型训练时只关注答案部分
输入: <bos> 请描述这张图片中的食材。 食材的种类有哪些? 这张图片中的食材包括鸡蛋、黄瓜和番茄。 这些食材可以分为蔬菜类和蛋白质类。 <eos>
标签: <pad> <pad> 这张图片中的食材包括鸡蛋、黄瓜和番茄。 这些食材可以分为蔬菜类和蛋白质类。 <eos>
此时,问题部分的 token 被 `-100` 填充,答案部分则正常参与计算。
多轮对话(<bos> Q1 A1 Q2 A2 Q3 A3 <eos>

这种格式通常用于处理 对话系统多轮交互,即模型需要在连续对话中根据当前对话上下文生成回答。每轮的提问和回答之间的关系更为复杂,需要模型保持上下文的连续性。

  • 输入:每轮对话的**提问和回答都会依次拼接在一起**。每轮问题和答案之间是紧密联系的,模型需要理解不同轮次之间的关系。
  • 标签:每轮的答案部分会作为目标输出,问题部分依然使用 -100(或者 <pad>)填充。标签中,答案部分和 <eos> 会正常参与损失计算。

例如:

输入: <bos> 请描述这张图片中的食材。 鸡蛋、黄瓜和番茄。 食材的种类有哪些? 这些食材可以分为蔬菜类和蛋白质类。 你能推荐一个菜谱吗? <eos>
标签: <pad> 鸡蛋、黄瓜和番茄。 <pad> 这些食材可以分为蔬菜类和蛋白质类。 <pad> <eos>
  • 这里每轮的提问(Q1Q2Q3)依然被填充为 -100,而答案部分(A1A2A3)作为模型的目标。

这种格式更适合模型 在对话中动态更新上下文,并根据当前对话生成合理的回复。

关键区别总结
特点单轮QA Q1 Q2 A1 A2 A3 多轮QA Q1 A1 Q2 A2 Q3 A3
目的模型仅需理解单个问题的答案模型需要在多轮对话中保持上下文
问题与答案结构问题和答案交替存在每一轮的提问和回答交替存在
上下文没有上下文依赖,每个问题独立每一轮的对话都依赖前面的内容,必须保持上下文
示例 Q1 Q2 A1 A2 A3 Q1 A1 Q2 A2 Q3 A3
模型任务生成单个问题的答案生成多轮对话中的每轮答案

MLLM的数据构造

图像 + 文本 的多模态输入格式的 preprocess_multimodal_dataset 函数,它可以用在微调像 Qwen-VL 或 BLIP2 这样的视觉语言大模型时。

目标场景:图像 + 文本(食材识别)
  • 图像:一道菜的照片
  • 文本 prompt:提示模型描述图中有哪些食材
  • 标签(labels):真实的食材列表(可以是文本形式)
格式设定

假设我们构造的输入格式如下:

<bos>请识别这张图片中的食材:<image><im_end><im_start>助手:

对应的 label 是:

鸡蛋、青椒、洋葱</s>

为什么是<im_end><im_start>的形式,而不是<im_start><im_end>的形式 :

这个问题的关键在于你要 准确还原大模型预训练时使用的 prompt 模板格式,否则模型在微调或推理时就会“理解错”输入。

Qwen2.5-VL-7B 为例,它的预训练** prompt** 是:

用户:请识别图中的内容。<im_end>

<im_start>助手:

这意味着模型被训练时看到的格式是:

和它的 <im_end> 出现在 prompt 的“用户提问”部分(User),

<im_start> 是“回答(Assistant)”的开始标志

为什么不是<im_start><image><im_end>?

如果你把 <image> 放在 <im_start> 后面,就等于告诉模型:

“图像是助手给出的内容

而不是“图像是用户提供的输入”。这就完全改变了对话结构!

函数设定

这个例子基于 transformersdatasets 的用法:

from typing import Dict, List, Any
from PIL import Image
from transformers import AutoProcessor

# 加载视觉-语言模型的 Processor
processor = AutoProcessor.from_pretrained("Qwen/Qwen-VL-7B")

def preprocess_multimodal_dataset(examples: Dict[str, List[Any]]) -> Dict[str, Any]:
    inputs = []
    labels = []

    for image, label_text in zip(examples["image"], examples["label"]):
        # 构造 prompt 文本(注意和模型预训练格式保持一致)
        prompt = "<bos>请识别这张图片中的食材:<image><im_end><im_start>助手:"

        # 使用 processor 编码(包括图像 + 文本)
        model_inputs = processor(
            text=prompt,
            images=image,
            return_tensors="pt",   # 或 "np"
            padding="max_length",
            truncation=True,
            max_length=512
        )

        # 构造 labels:真实的食材列表 + 结束符
        label_ids = processor.tokenizer.encode(
            label_text + processor.tokenizer.eos_token,
            add_special_tokens=False,
            max_length=128,
            truncation=True,
        )

        # 设置 -100 mask,padding位置不计算loss
        labels_padded = label_ids + [-100] * (512 - len(label_ids))

        model_inputs["labels"] = labels_padded
        inputs.append(model_inputs)
    # 合并 batch(注意格式兼容)
    batch = {
        k: [item[k] for item in inputs] for k in inputs[0]
    }

    return batch
示例数据:
{
  "image": [PIL.Image.open("img1.jpg"), PIL.Image.open("img2.jpg")],
  "label": ["鸡蛋、青椒、洋葱", "土豆、番茄"]
}
from datasets import load_dataset

dataset = load_dataset("your_custom_dataset")
processed = dataset.map(preprocess_multimodal_dataset, batched=True)
注意点
  • 格式要和模型保持一致
    • Prompt 模板格式必须匹配模型训练时使用的样式(比如 <image>, <im_start>助手:
  • 图像格式统一处理
    • AutoProcessor 会自动做图像 resize、normalize,不需要你手动处理。
  • label 处理
    • 要使用 tokenizer 对答案做编码,并手动拼上 <eos>
    • 要填充为固定长度,并在填充部分标记为 -100(忽略 loss)。

训练流程:

上图展示的是预训练流程,而sft也和他非常相似,区别只在于前面的数据组成形式

橘黄色框框是模型(LlamaForCausalLM)模型由三个部分组成,包括:主体(由好多block堆叠起来) ,embeding层(self.embed_tokens),分类头(self.lm_head)。训练的时候同时更新这三部分的参数。

虚线粗箭头代表数据准备过程,细箭头代表模型训练时的一次前向传播forward,实线粗箭头是根据产生的loss进行反向传播,更新模型梯度。

数据准备

tokenizer按照一定算法把文本切分成更细粒度的文本(具体算法就不展开了,可以是word/char/sub-word级别的分词)然后转换为数字ids。

分词这一步可以在训练的时候在文本数据加载之后进行,把文本变成数字;也可以提前分词,训练时直接加载以及转换好的数字ids,省去了分词的时间消耗,加快训练。

# 他的max_lenth 设置的是 9.
比如:今天天气很好 ---》今 | 天 | 天 | 气 | 很 | 好 ---》 10 5 5 7 9 1 0 0 0

词表大小是分词器训练之后得到的,一般中文词表大小在2w左右

同时加载数据,构造inputs和labels用来在后面计算loss。数据组成形式和前面讲的一样或者我们也可以不在这里处理,在计算loss时进行位移,原理是一样的,这部分代码可以见【【计算loss】】

embeding(ids-embeding)

单纯一个数字ids,模型很难理解,所以要经过一个embeding,映射到高维空间。这个向量也作为一个token进行后续的计算。

self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size, self.padding_idx)

nn.embedding就是一个简单的查找表,存储固定字典和大小的嵌入。

该模块通常用于存储词嵌入并使用索引检索它们。模块的输入是索引列表,输出是相应的词嵌入。

输入的一句话中的ids,会被并行映射为对应的语义向量

模型blocks(单向注意力)

我们可以把模型blocks视作一个黑箱,多少个token进入,就会有多少token输出,且张量维度不变。

模型blocks里面除了注意力(MSHA)的其他部分,比如位置编码,残差链接,归一化部分可以先不考虑。这里着重看一下因果注意力是怎么实现的。因果注意力在自回归解码中是必须的,以确保在生成当前token时,解码器仅利用之前的输出,不会“看到”未来的token。

这个特性是由自注意力掩码 (attention_mask)实现的。causal attention mask矩阵是一个右上角均为−∞的矩阵,表示任意位置token,都无法接收来自下文的信息,只能根据前面的token进行解码。

看看llama代码里是如何使用的:

if attention_mask is None:
    attention_mask = torch.ones(
        (batch_size, seq_length_with_past), dtype=torch.bool, device=inputs_embeds.device
    )
attention_mask = self._prepare_decoder_attention_mask(
    attention_mask, (batch_size, seq_length), inputs_embeds, past_key_values_length
)

主要调用了_prepare_decoder_attention_mask函数,他是从transformers.models.bart.modeling_bart.BartDecoder类中的一个方法_prepare_decoder_attention_mask复制的,这个方法用于准备解码器(decoder)的注意力掩码(attention mask)

# Copied from transformers.models.bart.modeling_bart.BartDecoder._prepare_decoder_attention_mask
def _prepare_decoder_attention_mask(self, attention_mask, input_shape, inputs_embeds, past_key_values_length):
    # create causal mask
    # [bsz, seq_len] -> [bsz, 1, tgt_seq_len, src_seq_len]
    combined_attention_mask = None
    if input_shape[-1] > 1:#检查sequence长度是否大于1,只有在序列长度大于1时,才需要创建因果掩码。
        combined_attention_mask = _make_causal_mask(
            input_shape,
            inputs_embeds.dtype,
            device=inputs_embeds.device,
            past_key_values_length=past_key_values_length,
        )#生成一个`[batch_size, 1, target_sequence_length, source_sequence_length]`形状的张量
        #这个张量会遮蔽掉未来的位置,其dtype和设备与`inputs_embeds`相同。

    if attention_mask is not None:#整合额外的注意力掩码(如果提供)
        # [bsz, seq_len] -> [bsz, 1, tgt_seq_len, src_seq_len]
        #将这个掩码和我们之前创建的因果掩码结合起来。
        expanded_attn_mask = _expand_mask(attention_mask, inputs_embeds.dtype, tgt_len=input_shape[-1]).to(
            inputs_embeds.device
        )#通过_expand_mask 将 attention_mask 扩展到形状 [bsz, 1, tgt_seq_len, src_seq_len],记为expanded_attn_mask
        combined_attention_mask = (
            expanded_attn_mask if combined_attention_mask is None else expanded_attn_mask + combined_attention_mask
        )#加法操作在布尔掩码上实现了逻辑"或"操作,确保了两个掩码的限制都被强制执行

    return combined_attention_mask

prepare_decoder_attention_mask接收四个参数:

  1. attention_mask:由0 1组成的掩码张量。它是一个形状为[batch_size, seq_len]的二进制张量,当某个位置需要被掩盖,则对应位置为 0。提供解码器的允许和阻止注意力的地方,常用于遮盖序列中的PAD标记;
  2. input_shape:一个表示输入张量的形状的元祖,通常是(batch_size, seq_len)
  3. inputs_embeds:输入标记的嵌入表示,通常是一个形状(batch_size, seq_len, embedding_dim)的张量,其中embedding_dim表示输入数据的嵌入维度,它的数据类型和设备信息将用于创建新的注意力掩码张量
  4. past_key_values_length:过去缓存的键值对(past key-values)的长度,用于计算掩码。

这个函数的生成的combined_attention_mask在解码器中的自注意力层使用,主要目的是保证解码器的自注意力操作不会违反因果关系,并且还能够考虑到额外的注意力掩码,比如需要忽略PAD tokens的位置。

embeding逆映射

其实就是个矩阵充当分类头

self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)

定义这样一个线性变换,对transformer的输出logits做一个映射,映射到跟词表一样大。

分类任务,预测下一个token是词表中的哪一个(词表中的每一个词当作一个类别)

后续可以接一个softmax,表达该token在词表上的概率分布,用来解码:

说是embeding逆映射,其实只是为了叫着方便,以前有些模型lm_head和embeding共享权重,相当于一个转置,但是效果不好,所以现在都是独立的了.

llama计算Loss

对每个token都是一个分类任务,看看能不能正确分类到token

每一个token输出都接入一个分类头,从embeding维度到词表长度([1,embed_size]->[1,vocab_size]),得到logits

outputs = self.model(
            input_ids=input_ids,
            attention_mask=attention_mask,
            position_ids=position_ids,
            past_key_values=past_key_values,
            inputs_embeds=inputs_embeds,
            use_cache=use_cache,
            output_attentions=output_attentions,
            output_hidden_states=output_hidden_states,
            return_dict=return_dict,
        )
hidden_states = outputs[0]
logits = self.lm_head(hidden_states)##

训练时根据logits和labels计算ce_loss。具体来说就是计算logits(模型预测)与targets(真实标签)之间的交叉熵损失,同时忽略了填充值对应的损失。直接调用pytorch的celoss函数即可

交叉熵损失是训练分类问题中常用的损失函数,它测量预测的概率分布和真实分布之间的差异。

计算交叉熵损失时,模型输出的每个词汇的概率分布会与期望的概率分布(通常是一个独热编码表示,指向正确的下一个词汇)进行比较。损失函数计算这两个分布之间的差异。

下面是llama对loss的计算,需要注意的是,llama前面数据处理时没有对labels进行位移,而是在这里进行了shiftlabels,然后再计算celoss的:

loss = None
if labels is not None:#如果没有标签提供,不需要计算损失
    # Shift so that tokens < n predict n
    shift_logits = logits[..., :-1, :].contiguous()
    shift_labels = labels[..., 1:].contiguous()
    # Flatten the tokens
    loss_fct = CrossEntropyLoss()#交叉熵损失函数
    shift_logits = shift_logits.view(-1, self.config.vocab_size)
    shift_labels = shift_labels.view(-1)
    # Enable model parallelism
    shift_labels = shift_labels.to(shift_logits.device)#确保标签tensor和logits的tensor在相同的设备上
    loss = loss_fct(shift_logits, shift_labels)

经过softmax得到概率分布,对应着该token取不同字的概率:

shift_logits = logits[..., :-1, :].contiguous()

对于模型输出/预测的原始得分 logits,我们执行一个位移操作,去掉了序列的最后一个元素(因为最后一个元素没有可预测的下一个标签)。

shift_labels = labels[..., 1:].contiguous()

对应于 logits 的位移,labels向右位移一个位置,这样就把每个token的标签设置为它之后的token,为“预测下一个token”任务做准备。

contiguous()`确保tensor是内存连续的,有时在进行位移操作后,需要调用 contiguous()来确保tensor适合用于后续操作。

loss_fct = CrossEntropyLoss()实例化交叉熵损失函数。交叉熵损失是训练分类问题中常用的损失函数,它测量预测的概率分布和真实分布之间的差异。

shift_logits = shift_logits.view(-1, self.config.vocab_size)

把shift_logits重塑为二维tensor,其行数是所有序列长度乘以批量大小(也就是所有token),列数是词汇表的大小(每个token的预测分布)。

shift_labels = shift_labels.view(-1)

将shift_labels扁平化为一维tensor,包含了批量中的每个预测标签。

loss = loss_fct(shift_logits, shift_labels)

计算扁平化后的shift_logits和shift_labels之间的交叉熵损失。

参考文章

Easy-to-use LLM fine-tuning framework
图解LLM训练和推理的秘密
大模型基础|预训练|有监督微调SFT

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值