2-Dataset#
到这里我们便完成了对于 MiniMind Tokenizer 和 Model 部分的全部了解,我们所熟悉的大语言模型正是由这个组件构成的,接下来,我们需要对大模型训练所使用的数据集结构有个基本的认识。
想要训练一个能够正常对话,并且符合人类对话偏好的大模型一般需要经过以下几个训练阶段:
预训练(Pre-training)
有监督微调(Supervised Fine-tuning,SFT)
人类反馈强化学习(Reinforcement Learning from Human Feedback,RLHF)
在不同训练阶段使用的数据集有所不同,下面会从 MiniMind 代码出发进行介绍和解读。
# 导入 json 库,用于处理 JSON 格式的数据文件(比如 .jsonl)
import json
# 导入 random 库,用于生成随机数,可能在数据增强或打乱数据时用到
import random
# 导入 re 库,用于正则表达式操作,方便进行复杂的文本匹配和处理
import re
# 导入 pandas 库,一个强大的数据分析工具,常用于读取和操作表格数据(如 CSV 文件)
import pandas as pd
# 导入 numpy 库,Python 科学计算的基础包,用于高效处理数组和矩阵
import numpy as np
# 从 PyTorch 的工具库中导入 Dataset 和 DataLoader
# Dataset 是所有自定义数据集的父类,我们需要继承它来创建自己的数据集
# DataLoader 是一个数据加载器,它可以自动地从 Dataset 中提取数据,并组织成一批一批(batch)的形式,方便模型训练
from torch.utils.data import Dataset, DataLoader
# 导入 PyTorch 核心库
import torch
# 从 scikit-learn 库中导入一个函数,用于方便地将数据集划分为训练集和测试集
from sklearn.model_selection import train_test_split
# 导入 os 库,用于与操作系统交互,比如处理文件路径
import os
# 导入 ast 库,用于处理 Python 的抽象语法树,可以安全地解析字符串形式的 Python 字面量
import ast
# 从 Hugging Face 的 transformers 库中导入 AutoTokenizer,它可以根据指定的路径或名称自动加载合适的分词器
from transformers import AutoTokenizer
# 这行代码是 Hugging Face tokenizers 的一个常见设置
# 作用是禁用分词器的并行处理功能
# 在某些环境(特别是 Jupyter Notebook)中,开启并行可能会导致程序卡死或报错,所以这里显式地关闭它以保证稳定性
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# 这行代码的作用是加载我们之前在第一部分中保存的分词器
# AutoTokenizer.from_pretrained 是一个非常方便的函数
# 它会自动识别 './model/minimind_tokenizer' 目录下的配置文件
# 然后加载与该配置相匹配的分词器类型,并读取词汇表等信息
tokenizer = AutoTokenizer.from_pretrained('../raw/data/Minimind/model/minimind_tokenizer')
# 打印分词器的词汇表大小(vocab_size)
# 这个数字表示分词器总共认识多少个不同的词元(token)
# 模型的输出层维度通常会和这个词汇表大小一致
print(tokenizer.vocab_size)
6400
预训练数据集#
预训练是模型在大规模语料上进行无监督学习的训练阶段,在该阶段,模型主要学习下一词预测的能力,简单的来说就是学会说话,而不是胡言乱语。因此,该阶段训练的模型不会具有问答能力,而是根据用户输入进行简单的词语接龙。
我们可以看一看预训练的数据集格式:
{"text": "如何才能摆脱拖延症? 治愈拖延症并不容易,但以下建议可能有所帮助..."}
为了降低该 demo 的运行门槛,在 ./demo
文件夹下提供了包含两条训练数据的 pretrain_data.jsonl
文件作为熟悉训练流程的数据集 demo。
# 这段代码的目的是读取并展示预训练数据文件的内容
# 定义预训练数据文件的路径
path_pretrain = '../raw/data/Minimind/toydata/pretrain_data.jsonl'
# `with open(...)` 是 Python 中推荐的文件操作方式,它可以确保文件在使用后被自动关闭
# 'r' 表示以只读模式打开文件
# 'encoding='utf-8'' 指定文件编码,以正确处理中文字符
with open(path_pretrain, 'r', encoding='utf-8') as f:
# `enumerate(f, 1)` 会遍历文件中的每一行,并同时提供行号(从 1 开始)和行内容
for line_num, line in enumerate(f, 1):
# 这个文件是 .jsonl 格式(JSON Lines),意味着每一行都是一个独立的、完整的 JSON 对象
# 1. line.strip(): 去除行首和行尾的空白字符(如换行符)
# 2. json.loads(...): 将这一行纯文本的 JSON 字符串解析成 Python 中的字典对象
data = json.loads(line.strip())
# 打印行号和解析后的数据内容
print(f'Row {line_num}: {data}\n')
Row 1: {'text': 'LLM首先要学习的并非直接与人交流,而是让网络参数中充满知识的墨水,“墨水” 理论上喝的越饱越好,产生大量的对世界的知识积累。 预训练就是让Model先埋头苦学大量基本的知识,例如从Wiki百科、新闻、书籍整理大规模的高质量训练数据。 这个过程是“无监督”的,即人类不需要在过程中做任何“有监督”的校正,而是由模型自己从大量文本中总结规律学习知识点。 模型此阶段目的只有一个:学会词语接龙。例如我们输入“秦始皇”四个字,它可以接龙“是中国的第一位皇帝”。'}
Row 2: {'text': '经过预训练,LLM此时已经掌握了大量知识,然而此时它只会无脑地词语接龙,还不会与人聊天。 SFT阶段就需要把半成品LLM施加一个自定义的聊天模板进行微调。 例如模型遇到这样的模板【问题->回答,问题->回答】后不再无脑接龙,而是意识到这是一段完整的对话结束。 称这个过程为指令微调,就如同让已经学富五车的「牛顿」先生适应21世纪智能手机的聊天习惯,学习屏幕左侧是对方消息,右侧是本人消息这个规律。 在训练时,MiniMind的指令和回答长度被截断在512,是为了节省显存空间。就像我们学习时,会先从短的文章开始,当学会写作200字作文后,800字文章也可以手到擒来。 在需要长度拓展时,只需要准备少量的2k/4k/8k长度对话数据进行进一步微调即可(此时最好配合RoPE-NTK的基准差值)。'}
我们知道,构建一个深度学习数据集需要继承 torch.utils.data.dataset
,并构建 DataLoader 数据迭代器进行迭代访问。下面,我们来看看 MiniMind 是如何抽象一个预训练数据集的。
# 定义一个用于预训练的数据集类,它必须继承自 PyTorch 的 torch.utils.data.Dataset
class PretrainDataset(Dataset):
# 构造函数 __init__:在创建数据集对象时被调用
def __init__(self, data_path, tokenizer, max_length=512):
super().__init__() # 调用父类的构造函数
self.tokenizer = tokenizer # 保存分词器
self.max_length = max_length # 保存句子的最大长度
self.samples = self.load_data(data_path) # 调用 load_data 方法加载所有数据到内存
# load_data 方法:读取 .jsonl 文件
def load_data(self, path):
samples = [] # 创建一个空列表来存放所有数据
with open(path, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
data = json.loads(line.strip())
samples.append(data)
return samples
# __len__ 方法:返回数据集的总样本数,这是 Dataset 类的必须方法
def __len__(self):
return len(self.samples)
# __getitem__ 方法:根据索引 `index` 获取单个样本,这是 Dataset 类的核心
def __getitem__(self, index):
# 1. 根据索引从加载的数据中获取一个样本(一个字典)
sample = self.samples[index]
# 2. 格式化文本:在原始文本前后分别加上开始符(BOS)和结束符(EOS)
# 这能帮助模型明确地知道一段文本的开始和结束
text = f"{self.tokenizer.bos_token}{str(sample['text'])}{self.tokenizer.eos_token}"
# 3. 使用分词器将文本转换为 token ID
encoding = self.tokenizer(
text,
max_length=self.max_length, # 限制最大长度
padding='max_length', # 如果文本不够长,就用 padding token 填充到 max_length
truncation=True, # 如果文本太长,就截断
return_tensors='pt' # 返回 PyTorch 张量 (Tensor)
)
# 4. 准备模型的输入(X)、标签(Y)和损失掩码(loss_mask)
# .squeeze() 去掉多余的维度,例如 (1, 512) -> (512)
input_ids = encoding.input_ids.squeeze()
# 创建一个损失掩码,标记哪些位置需要计算损失
# 我们不希望在填充(padding)的部分计算损失,所以把 padding token 的位置设为 False (0)
loss_mask = (input_ids != self.tokenizer.pad_token_id)
# 5. 构造训练对 (X, Y) 用于“下一个词预测”任务
# 例如,如果 input_ids 是 [BOS, "我", "爱", "你", EOS]
# X (输入) 就是 [BOS, "我", "爱", "你"]
# Y (标签) 就是 [ "我", "爱", "你", EOS]
# 模型的目标是:看到 X 的每个词,都能预测出 Y 中对应位置的下一个词
X = input_ids[:-1] # X 是从第一个到倒数第二个 token
Y = input_ids[1:] # Y 是从第二个到最后一个 token
# 损失掩码也需要对齐,因为 Y 是我们的预测目标
loss_mask = loss_mask[1:]
# 返回处理好的输入、标签和损失掩码
return X.long(), Y.long(), loss_mask.long()
# 使用预训练数据路径和分词器,创建一个 PretrainDataset 对象
pretrain_dataset = PretrainDataset(path_pretrain, tokenizer)
# 调用 len() 函数,这会自动调用 pretrain_dataset 的 __len__ 方法
print(f'预训练数据集长度{len(pretrain_dataset)}')
# 下面的代码被注释掉了,但我们可以解释它的作用:
# `pretrain_dataset[0]` 会调用 __getitem__(0) 方法,获取并处理第一个样本
# x, y, lm 会分别接收到模型的输入、标签和损失掩码
# `print(x.shape, y.shape, lm.shape)` 会打印它们的形状,
# 因为 max_length=512,所以形状都应该是 (511,)
预训练数据集长度2
有监督微调数据集#
有监督微调(Supervised Fine Tuning,SFT)对预训练后得到的基座 LLM 施加一个自定义聊天模板进行微调,由于在这一阶段,模型训练的目标是根据用户指令生成响应(构建问答体系),故又称为指令微调。
我们可以看一看有监督微调的数据集格式:
{
"conversations": [
{"role": "user", "content": "你好"},
{"role": "assistant", "content": "你好!"},
{"role": "user", "content": "再见"},
{"role": "assistant", "content": "再见!"}
]
}
为了降低该 demo 的运行门槛,在 ./demo
文件夹下提供了包含两条 conversation 问答数据的 sft_data.jsonl
文件作为熟悉训练流程的数据集 demo。
# 这段代码和查看预训练数据非常类似,只是文件路径不同
# 定义 SFT (有监督微调) 数据文件的路径
path_sft = '../raw/data/Minimind/toydata/sft_data.jsonl'
# 同样使用 `with open` 来安全地读取文件
with open(path_sft, 'r', encoding='utf-8') as f:
# 遍历文件的每一行
for line_num, line in enumerate(f, 1):
# 解析每一行的 JSON 字符串
data = json.loads(line.strip())
# 打印出来,可以看到 SFT 数据的格式是一个包含多轮对话的列表
print(f'Row {line_num}: {data}\n')
Row 1: {'conversations': [{'role': 'user', 'content': '你好吗?'}, {'role': 'assistant', 'content': '我很好,谢谢!你呢?'}, {'role': 'user', 'content': '我也很好,谢谢!'}, {'role': 'assistant', 'content': '太好了!祝你今天愉快!'}]}
Row 2: {'conversations': [{'role': 'user', 'content': '你喜欢什么运动?'}, {'role': 'assistant', 'content': '我喜欢跑步和游泳。你呢?'}, {'role': 'user', 'content': '我喜欢打篮球!'}, {'role': 'assistant', 'content': '篮球很棒!是一个很好的团队运动。'}]}
接下来,我们尝试构造一个数据集对象,实现对 sft 格式数据的读取与处理。
# 定义一个用于 SFT 的数据集类
class SFTDataset(Dataset):
def __init__(self, jsonl_path, tokenizer, max_length=512):
super().__init__()
self.tokenizer = tokenizer
self.max_length = max_length
self.samples = self.load_data(jsonl_path)
# 预先将 assistant 回复的开始和结束标记转换为 token ID
# 这是一种优化,避免在每次生成 loss_mask 时都重新分词
# `add_special_tokens=False` 确保不会额外添加 BOS/EOS 符
self.bos_id = tokenizer('<s>assistant\n', add_special_tokens=False).input_ids
self.eos_id = tokenizer('</s>\n', add_special_tokens=False).input_ids
def load_data(self, path): # 和 PretrainDataset 中的一样
samples = []
with open(path, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
data = json.loads(line.strip())
samples.append(data)
return samples
def __len__(self): # 和 PretrainDataset 中的一样
return len(self.samples)
# 辅助方法:创建符合聊天格式的提示词
def _create_chat_prompt(self, conversations):
# Hugging Face 的分词器提供了一个 `apply_chat_template` 方法
# 它可以根据预设的模板,将多轮对话(包含角色和内容)自动格式化成一个单一的字符串
# 例如,格式化成:<s>user\n你好</s>\n<s>assistant\n我很好</s>\n
messages = conversations # 原始数据格式已经是 `apply_chat_template` 要求的格式
return self.tokenizer.apply_chat_template(
messages,
tokenize=False, # 表示只返回格式化后的字符串,而不是 token ID
add_generation_prompt=False # 表示不在末尾添加 "<s>assistant\n" 提示模型生成
)
# 辅助方法:生成损失掩码,这是 SFT 的关键
def _generate_loss_mask(self, input_ids):
# SFT 的目标是让模型学会“回答”,而不是学会“提问”
# 所以,我们只在模型生成 assistant 回答的部分计算损失
loss_mask = [0] * len(input_ids) # 初始化一个全为 0 的掩码
i = 0
while i < len(input_ids):
# 搜索 assistant 回答的开始标记
if input_ids[i:i + len(self.bos_id)] == self.bos_id:
# 找到了开始标记,现在从这里开始搜索结束标记
start = i + len(self.bos_id)
end = start
while end < len(input_ids):
if input_ids[end:end + len(self.eos_id)] == self.eos_id:
break # 找到了结束标记
end += 1
# 将从开始标记之后到结束标记(包括结束标记)之间的所有位置的掩码设为 1
for j in range(start, end + len(self.eos_id)):
if j < len(loss_mask): # 防止越界
loss_mask[j] = 1
i = end + len(self.eos_id) # 更新搜索位置,跳过这段已处理的回答
else:
i += 1
return loss_mask
def __getitem__(self, index):
sample = self.samples[index]
# 1. 将对话数据格式化为单个字符串
prompt = self._create_chat_prompt(sample['conversations'])
# 2. 分词,并手动进行截断和填充
input_ids = self.tokenizer(prompt).input_ids[:self.max_length]
input_ids += [self.tokenizer.pad_token_id] * (self.max_length - len(input_ids))
# 3. 生成特殊的损失掩码
loss_mask = self._generate_loss_mask(input_ids)
# 4. 创建 X, Y 和对齐后的 loss_mask,逻辑同 PretrainDataset
X = torch.tensor(input_ids[:-1], dtype=torch.long)
Y = torch.tensor(input_ids[1:], dtype=torch.long)
loss_mask = torch.tensor(loss_mask[1:], dtype=torch.long)
return X, Y, loss_mask
# 使用 SFT 数据路径和分词器,创建一个 SFTDataset 对象
sft_dataset = SFTDataset(path_sft, tokenizer)
# 打印数据集长度
print(len(sft_dataset))
# 获取第一个样本来检查
x, y, lm = sft_dataset[0]
# 打印返回的张量形状
print(f'样本 shape = {x.shape}, 标签 shape = {y.shape}, loss_mask shape {lm.shape}')
# 如果取消最后一行代码的注释并运行,你会看到 loss_mask (lm) 的内容。
# 它大部分是 0,只在 assistant 回答的位置是 1,这证明我们的 _generate_loss_mask 方法工作正常。
2
样本 shape = torch.Size([511]), 标签 shape = torch.Size([511]), loss_mask shape torch.Size([511])
人类反馈强化学习数据集#
在 MiniMind 项目中,采用直接偏好优化(Direct Parameter Optimization,DPO)训练大模型对齐人类偏好。在这一训练阶段,模型将会根据提供的问答正反例进行偏好优化,从而降低让人类不满意的答案出现的几率。
与PPO(Proximal Policy Optimization)这种需要奖励模型、价值模型的RL算法不同; DPO通过推导PPO奖励模型的显式解,把在线奖励模型换成离线数据,Ref模型输出可以提前保存。 DPO性能几乎不变,只用跑 actor_model 和 ref_model 两个模型,大大节省显存开销和增加训练稳定性。
我们可以看一看有监督微调的数据集格式:
{
"chosen": [
{"content": "Query", "role": "user"},
{"content": "good answer", "role": "assistant"}
],
"rejected": [
{"content": "Query", "role": "user"},
{"content": "bad answer", "role": "assistant"}
]
}
为了降低该 demo 的运行门槛,在 ./demo 文件夹下提供了包含两条 conversation 问答数据的 sft_data.jsonl 文件作为熟悉训练流程的数据集 demo。
# 这段代码的目的是展示 DPO (直接偏好优化) 数据的格式
# 定义 DPO 数据文件的路径
path_dpo = './toydata/dpo_data.jsonl'
# 注意!这里的代码有一个小笔误,它打开的是 `path_sft` 而不是 `path_dpo`。
# 所以,它实际上会再次打印出 SFT 文件的内容。
# 如果要正确查看 DPO 数据,应该使用 `with open(path_dpo, ...)`
with open(path_dpo, 'r', encoding='utf-8') as f: # 假设这里已修正为 path_dpo
for line_num, line in enumerate(f, 1):
data = json.loads(line.strip())
# DPO 数据每条都包含 "chosen" (好的回答) 和 "rejected" (不好的回答) 两部分
print(f'Row {line_num}: {data}\n')
Row 1: {'conversations': [{'role': 'user', 'content': '你好吗?'}, {'role': 'assistant', 'content': '我很好,谢谢!你呢?'}, {'role': 'user', 'content': '我也很好,谢谢!'}, {'role': 'assistant', 'content': '太好了!祝你今天愉快!'}]}
Row 2: {'conversations': [{'role': 'user', 'content': '你喜欢什么运动?'}, {'role': 'assistant', 'content': '我喜欢跑步和游泳。你呢?'}, {'role': 'user', 'content': '我喜欢打篮球!'}, {'role': 'assistant', 'content': '篮球很棒!是一个很好的团队运动。'}]}
接下来,我们尝试构造 json 对象,实现对 dpo 格式数据的读取和处理
# 定义一个用于 DPO 的数据集类
class DPODataset(Dataset):
def __init__(self, file_path, tokenizer, max_length=512):
super().__init__()
self.tokenizer = tokenizer
self.max_length = max_length
# 和 SFTDataset 一样,预先处理好 assistant 的标记
self.bos_id = tokenizer('<s>assistant\n', add_special_tokens=False).input_ids
self.eos_id = tokenizer('</s>\n', add_special_tokens=False).input_ids
# 直接在构造函数中加载数据
with open(file_path, 'r', encoding='utf-8') as f:
self.data = [json.loads(line.strip()) for line in f]
def __len__(self):
return len(self.data)
def _generate_loss_mask(self, input_ids):
# 这个方法和 SFTDataset 中的完全一样,因为目标相同:只在 assistant 回答上计算损失
loss_mask = [0] * len(input_ids)
i = 0
while i < len(input_ids):
if input_ids[i:i + len(self.bos_id)] == self.bos_id:
start = i + len(self.bos_id)
end = start
while end < len(input_ids):
if input_ids[end:end + len(self.eos_id)] == self.eos_id:
break
end += 1
for j in range(start, end + len(self.eos_id)):
if j < self.max_length:
loss_mask[j] = 1
i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids)
else:
i += 1
return loss_mask
def __getitem__(self, index):
# DPO 的核心是对比 "chosen" 和 "rejected"
item = self.data[index]
chosen_conv = item['chosen'] # 获取好的对话
rejected_conv = item['rejected'] # 获取不好的对话
# 1. 分别为 chosen 和 rejected 对话创建提示词字符串
chosen_prompt = self.tokenizer.apply_chat_template(chosen_conv, tokenize=False, add_generation_prompt=False)
rejected_prompt = self.tokenizer.apply_chat_template(rejected_conv, tokenize=False, add_generation_prompt=False)
# 2. 分别对它们进行分词、填充和截断
chosen_encoding = self.tokenizer(chosen_prompt, truncation=True, max_length=self.max_length, padding='max_length')
rejected_encoding = self.tokenizer(rejected_prompt, truncation=True, max_length=self.max_length, padding='max_length')
# 3. 分别为它们生成损失掩码
chosen_loss_mask = self._generate_loss_mask(chosen_encoding['input_ids'])
rejected_loss_mask = self._generate_loss_mask(rejected_encoding['input_ids'])
# 4. 分别为它们创建 (X, Y) 训练对和对齐后的掩码
chosen_input_ids = chosen_encoding['input_ids']
x_chosen = torch.tensor(chosen_input_ids[:-1], dtype=torch.long)
y_chosen = torch.tensor(chosen_input_ids[1:], dtype=torch.long)
mask_chosen = torch.tensor(chosen_loss_mask[1:], dtype=torch.long)
rejected_input_ids = rejected_encoding['input_ids']
x_rejected = torch.tensor(rejected_input_ids[:-1], dtype=torch.long)
y_rejected = torch.tensor(rejected_input_ids[1:], dtype=torch.long)
mask_rejected = torch.tensor(rejected_loss_mask[1:], dtype=torch.long)
# 5. 将所有处理好的张量打包成一个字典返回
# DPO 的损失函数会同时用到 chosen 和 rejected 的这些信息来进行优化
return {
'x_chosen': x_chosen, 'y_chosen': y_chosen, 'mask_chosen': mask_chosen,
'x_rejected': x_rejected, 'y_rejected': y_rejected, 'mask_rejected': mask_rejected
}
# 1. 使用 DPO 数据路径和分词器,创建一个 DPODataset 对象
dpo_dataset = DPODataset(path_dpo, tokenizer)
# 2. 打印数据集长度
print(f'DPO 数据集长度:{len(dpo_dataset)}')
# 3. 获取第一个样本
# 注意:在 `DPODataset` 的 `__getitem__` 方法中,我们留下了 `print(chosen_prompt)` 和 `print(rejected_prompt)`
# 所以当执行 `dpo_dataset[0]` 这行代码时,会自动打印出第一个样本的 chosen 和 rejected 对话的格式化字符串
res = dpo_dataset[0]
# 4. `res` 现在是一个字典,包含了 'x_chosen', 'y_chosen', 'mask_chosen' 等6个张量
# 可以通过 `res.keys()` 查看所有键,或通过 `res['x_chosen'].shape` 查看具体张量的形状
DPO 数据集长度:2
<s>system
你是 MiniMind,是一个有用的人工智能助手。</s>
<s>user
你好吗?</s>
<s>assistant
我很好,谢谢!你呢?</s>
<s>user
今天过得怎么样?</s>
<s>assistant
挺好的,去跑步了,心情不错。</s>
<s>system
你是 MiniMind,是一个有用的人工智能助手。</s>
<s>user
你好吗?</s>
<s>assistant
不好,我很累。</s>
<s>user
你喜欢什么运动?</s>
<s>assistant
我不喜欢运动,没兴趣。</s>