带着问题看原理
本文将基于llama进行讲解,探讨在LLM使用过程中的一些细节内容:
- 大模型中是loss如何计算?
- 训练是如何并行的?
- 推理是如何预测下一个词的?
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 :表示序列的结束。
- 关键点
- 模板一致性
- 格式一致性:确保模板中的各个部分(如“系统提示”、“用户输入”和“助手答案”)在 训练时和推理时 保持一致。这是非常重要的,因为如果训练时模板格式和推理时不一致,模型就无法理解各个部分的含义,最终可能导致生成的结果不符合预期。
- 模板拼接的作用:通过模板拼接,我们明确了输入和输出的结构。例如,
<im_start>用户:
和助手:
这两个标记帮助模型分清楚 谁在提问,谁在回答。这种清晰的结构不仅有助于模型理解任务,还能够提高训练的效果。
- 拼接后的序列包含输入和目标
- 拼接后的序列不仅仅是 用户输入,还包含了 目标输出(即模型需要生成的答案)。模型会通过计算 输入和输出之间的关系 来学习任务。例如:
- 输入:
系统提示:请根据以下问题回答。 用户:你能介绍一下这张图片的内容吗?
- 目标输出(标签):
助手:这张图片展示了……
- 在这种情况下,模型 需要根据输入(用户问题)生成输出(助手答案)。这为后续计算损失(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
有几种组织形式:
- 直接拼接:
Q1 A1 Q2 A2 Q3 A3
这种方式没有区分多轮结构,只是将所有对话拼接起来;缺点是没有充分利用多轮对话的层次信息。
- 拆分为多次训练:
将数据拆分为三个样本:
- 样本1:`Q1 A1`
- 样本2:`Q1 A1 Q2 A2`
- 样本3:`Q1 A1 Q2 A2 Q3 A3`
这种方式每个样本都单独反向传播,但训练效率低,因为同一数据重复训练了多次。
-
一次 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>
- 这里每轮的提问(
Q1
、Q2
、Q3
)依然被填充为-100
,而答案部分(A1
、A2
、A3
)作为模型的目标。
这种格式更适合模型 在对话中动态更新上下文,并根据当前对话生成合理的回复。
关键区别总结
特点 | 单轮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>
后面,就等于告诉模型:“图像是助手给出的内容”
而不是“图像是用户提供的输入”。这就完全改变了对话结构!
函数设定
这个例子基于 transformers
和 datasets
的用法:
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>助手:
)
- Prompt 模板格式必须匹配模型训练时使用的样式(比如
- 图像格式统一处理:
AutoProcessor
会自动做图像 resize、normalize,不需要你手动处理。
- label 处理:
- 要使用 tokenizer 对答案做编码,并手动拼上
<eos>
; - 要填充为固定长度,并在填充部分标记为
-100
(忽略 loss)。
- 要使用 tokenizer 对答案做编码,并手动拼上
训练流程:
上图展示的是预训练流程,而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
接收四个参数:
attention_mask
:由0 1组成的掩码张量。它是一个形状为[batch_size, seq_len]的二进制张量,当某个位置需要被掩盖,则对应位置为 0。提供解码器的允许和阻止注意力的地方,常用于遮盖序列中的PAD标记;input_shape
:一个表示输入张量的形状的元祖,通常是(batch_size, seq_len)inputs_embeds
:输入标记的嵌入表示,通常是一个形状(batch_size, seq_len, embedding_dim)的张量,其中embedding_dim表示输入数据的嵌入维度,它的数据类型和设备信息将用于创建新的注意力掩码张量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