第四章:数据集准备——从原始数据到可训练样本
做LoRA微调,说白了就是让模型学会你给它的新知识。那新知识从哪来?当然是从数据里来。我见过太多同学一上来就调参数、改配置,结果跑起来才发现数据格式不对,白白浪费半天时间。嗯,这一章我们就来把数据准备这块彻底讲透。
4.1 数据集格式要求:别让模型“看不懂”
LoRA微调的数据格式,其实没那么玄乎。主流框架(比如Hugging Face的Transformers)通常支持两种格式:
- JSON格式:每条数据一个字典,字段名自己定
- CSV格式:表格形式,列名对应字段
我个人习惯用JSON,因为结构清晰,嵌套起来也方便。举个例子,如果你在做对话任务,数据大概长这样:
{
"instruction": "请解释什么是LoRA",
"input": "",
"output": "LoRA(Low-Rank Adaptation)是一种高效的模型微调方法..."
}
这里要注意一个坑:字段名必须和你的训练脚本里定义的一致。我曾经有个项目,数据里写的是“question”和“answer”,但脚本里读的是“instruction”和“output”,结果跑了一轮才发现——嗯,白跑了。
4.2 加载本地数据集:从硬盘到内存
数据准备好了,怎么喂给模型?最常用的工具是Hugging Face的datasets库。你想想看,它就像一个数据管家,帮你处理各种格式。
加载本地JSON文件的代码很简单:
from datasets import load_dataset
dataset = load_dataset("json", data_files="my_data.json")
print(dataset)
# 输出:DatasetDict({"train": Dataset({...})})
如果你有多个文件,可以这样:
dataset = load_dataset("json", data_files={
"train": "train.json",
"test": "test.json"
})
我在项目中遇到过一个问题:数据文件太大,内存直接爆了。这时候可以用streaming=True参数,让数据按需加载,不一次性全读进内存。
streaming=True。虽然训练时会慢一点,但至少不会OOM。
4.3 数据预处理Pipeline:把“生肉”变成“熟饭”
原始数据通常不能直接喂给模型。你需要做一系列预处理,比如分词、截断、填充。我习惯把这套流程叫做“数据预处理Pipeline”。
一个典型的Pipeline包含这几步:
- 分词:把文本转成token ID
- 截断:超过最大长度的部分切掉
- 填充:不足长度的补上padding token
- 添加特殊标记:比如[CLS]、[SEP]等
代码实现大概是这样的:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-chinese")
def preprocess_function(examples):
# 拼接指令和输入
inputs = [f"{inst} {inp}" for inst, inp in zip(examples["instruction"], examples["input"])]
# 分词
model_inputs = tokenizer(
inputs,
max_length=512,
truncation=True,
padding="max_length"
)
# 标签就是输出
model_inputs["labels"] = tokenizer(
examples["output"],
max_length=512,
truncation=True,
padding="max_length"
)["input_ids"]
return model_inputs
# 应用到整个数据集
tokenized_dataset = dataset.map(preprocess_function, batched=True)
这里有个细节:batched=True表示批量处理,速度会快很多。我第一次写的时候没加这个参数,处理10万条数据跑了快半小时——加了之后,3分钟搞定。
4.4 Train/Test拆分:别让模型“作弊”
数据预处理完了,接下来要划分训练集和测试集。为什么?因为你需要一个“考官”来检验模型学得怎么样。如果训练和测试用同一批数据,那模型就是“开卷考试”,分数再高也没意义。
我常用的拆分比例是8:2或9:1。代码很简单:
# 方法一:直接用datasets的split方法
split_dataset = tokenized_dataset["train"].train_test_split(test_size=0.2)
train_dataset = split_dataset["train"]
test_dataset = split_dataset["test"]
# 方法二:手动指定随机种子,保证可复现
split_dataset = tokenized_dataset["train"].train_test_split(
test_size=0.2,
seed=42
)
你可能会问:为什么要固定随机种子?因为有一次我调试代码,每次跑出来的结果都不一样,排查了半天才发现是数据拆分不一致导致的。嗯,从那以后我每次都会设seed=42。
stratify_by_column参数做分层抽样,保证训练集和测试集的类别分布一致。
4.5 数据加载器构建:让训练“流水线”跑起来
最后一步,把处理好的数据包装成PyTorch的DataLoader。说白了,就是给模型建一个“传送带”,一批一批地送数据。
from torch.utils.data import DataLoader
from transformers import DataCollatorForSeq2Seq
# 数据整理器:自动处理padding和batch
data_collator = DataCollatorForSeq2Seq(
tokenizer=tokenizer,
model=model,
padding=True
)
# 构建DataLoader
train_dataloader = DataLoader(
train_dataset,
batch_size=8, # 每批8条
shuffle=True, # 打乱顺序
collate_fn=data_collator
)
test_dataloader = DataLoader(
test_dataset,
batch_size=8,
shuffle=False # 测试集不需要打乱
)
这里batch_size的选择很有讲究。我刚开始做的时候,觉得越大越好,直接设了64,结果显存爆了。后来慢慢试,发现8~16对于大多数LoRA任务来说是个比较稳妥的范围。
知识体系总览
说了这么多,我们来画个图,把整个数据准备的流程串起来:
你看,从原始数据到可训练样本,一共就五步。每一步都不难,但每一步都有坑。我刚开始做LoRA微调的时候,就是在数据准备这块栽了好几次跟头。后来总结出一句话:数据准备花的时间,会在训练阶段加倍还给你。
- 数据格式:JSON最灵活,字段名要统一
- 加载数据:用datasets库,大文件用streaming
- 预处理:分词+截断+填充,三步走
- 拆分:8:2比例,固定随机种子
- DataLoader:batch_size从8开始试