01_lora_demo.ipynb 说明文档这份文档是给第一次接触 LoRA 微调的同学准备的。
如果说 00_check_env.ipynb 的任务是:
那么 01_lora_demo.ipynb 的任务就是:
注意,这里的关键词是:
它不是为了追求效果,不是为了训练一个很强的模型,而是为了让你第一次真正看懂:
Trainer 是怎么启动训练的只要你把这份 notebook 跑通并看懂,后面再换更大的模型,思路就不会乱。
它做的事情可以概括成 6 步:
instruction / input / output 拼成一段真正拿去训练的文本Trainer这其实就是第 11 章课堂上讲的最小闭环:
# 01_lora_demo
这是一个最小 LoRA 微调演示 notebook。
为了降低第一次实验的门槛,这里默认使用 `sshleifer/tiny-gpt2` 做快速验证。真正上课时,可以把模型替换成课程指定模型。这一格没有代码,只是在明确这个 notebook 的定位:
你可以把它理解成:
代码:
import json
from pathlib import Path
train_path = Path("data/sample_train.json")
val_path = Path("data/sample_val.json")
print("train exists:", train_path.exists())
print("val exists:", val_path.exists())
print("sample train records:")
print(json.loads(train_path.read_text(encoding="utf-8"))[:2])它做了 3 件事:
这是一个很好的实验习惯:
很多人一开始就跑训练,结果根本不知道数据格式有没有错。
import json导入 Python 标准库 json。
它的作用是:
from pathlib import Path导入 Path。
Path 是 Python 里处理路径时很方便的工具。
相比直接写字符串路径,它更清晰,也更稳。
train_path = Path("data/sample_train.json")定义训练集路径对象。
这里不是普通字符串,而是一个 Path 对象。
它表示:
data/sample_train.jsonval_path = Path("data/sample_val.json")定义验证集路径对象。
print("train exists:", train_path.exists())exists() 的意思是:
如果输出是:
train exists: True
说明训练集文件在。
print("val exists:", val_path.exists())同理,检查验证集文件是否存在。
print("sample train records:")只是输出一个提示文字。
print(json.loads(train_path.read_text(encoding="utf-8"))[:2])这一行稍微长一点,我们拆开看。
train_path.read_text(encoding="utf-8")意思是:
这里指定 encoding="utf-8",是为了避免中文数据读取出错。
json.loads(...)loads 的意思是:
如果文件内容是一个 JSON 列表,解析后就会得到 Python 列表。
[:2]这是 Python 的切片写法,表示:
所以整行代码的意思是:
你应该通过它确认:
从课程目录看,这里的样例格式是:
instructioninputoutput这正是后面要拼成训练文本的三部分。
代码:
from datasets import Dataset
def load_records(path):
return json.loads(Path(path).read_text(encoding="utf-8"))
def build_text(example):
parts = []
if example.get("instruction"):
parts.append(f"Instruction: {example['instruction']}")
if example.get("input"):
parts.append(f"Input: {example['input']}")
parts.append(f"Response: {example['output']}")
return "\n".join(parts)
train_ds = Dataset.from_list([{"text": build_text(x)} for x in load_records(train_path)])
val_ds = Dataset.from_list([{"text": build_text(x)} for x in load_records(val_path)])
train_ds这是整个 notebook 第一个真正关键的步骤。
它在做两件事:
Dataset这一步一定要理解,因为它直接回答了一个核心问题:
from datasets import Dataset导入 Hugging Face datasets 里的 Dataset 类。
它的作用是:
.map()、tokenize、切分等操作def load_records(path):定义一个函数,名字叫 load_records。
函数的作用是:
return json.loads(Path(path).read_text(encoding="utf-8"))这一行就是函数主体。
意思是:
Path(path) 把输入路径变成路径对象read_text() 读出文件内容json.loads() 解析成 Python 对象所以 load_records(train_path) 的结果就是:
def build_text(example):再定义一个函数,叫 build_text。
它的任务是:
这一步特别重要,因为它决定了模型看到的数据格式。
parts = []先创建一个空列表 parts。
后面我们会把:
instructioninputoutput分别按顺序加进去。
if example.get("instruction"):这里的 example 是一条字典,比如:
{
"instruction": "...",
"input": "...",
"output": "..."
}get("instruction") 的意思是:
instruction这样写比 example["instruction"] 更稳,因为有些样例可能没有某个字段。
parts.append(f"Instruction: {example['instruction']}")如果 instruction 存在,就把它拼成一行文本,加进 parts。
例如:
Instruction: 将下面这句话改写得更适合大学生阅读。
if example.get("input"):如果这条样例有 input 字段,就继续加。
parts.append(f"Input: {example['input']}")把输入内容也写成一行文本。
parts.append(f"Response: {example['output']}")这一行没有 if 判断,说明这里假设:
output 是必须有的这也符合监督微调的基本逻辑:
return "\n".join(parts)把列表 parts 用换行符连接起来,变成一整段文本。
比如最终得到的样子可能是:
Instruction: 请用更清楚的方式表达下面这句话。
Input: LoRA 通过只训练一小部分参数来降低微调成本。
Response: LoRA 的核心思想是:只更新少量新增参数,从而降低微调所需的算力和存储成本。
这正是本实验最重要的一步之一:
train_ds = Dataset.from_list([{"text": build_text(x)} for x in load_records(train_path)])这一行稍微密,我们拆开看。
load_records(train_path)先把训练集 JSON 读出来。
for x in load_records(train_path)遍历训练集中的每条样例。
{"text": build_text(x)}把每条样例变成一个只有 text 字段的新字典。
也就是说,原来一条结构化样例会变成:
{"text": "Instruction: ...\nInput: ...\nResponse: ..."}[ ... for x in ... ]这是 Python 列表推导式,意思是:
Dataset.from_list(...)把这个 Python 列表转换成 Hugging Face 的 Dataset 对象。
val_ds = Dataset.from_list([...])同样方式处理验证集。
train_ds这一行单独把 train_ds 写出来,是 notebook 里常见的做法。
这样 Jupyter 会自动显示这个数据集对象的摘要。
这格其实就是在回答:
答案不是原始 JSON 字段本身,而是:
代码:
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import LoraConfig, get_peft_model
model_name = "/root/course_lora/models/tiny-gpt2"
tokenizer = AutoTokenizer.from_pretrained(model_name)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = AutoModelForCausalLM.from_pretrained(model_name)
lora_config = LoraConfig(r=8, lora_alpha=16, lora_dropout=0.05, bias="none", task_type="CAUSAL_LM")
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()这一格做了 4 件关键事:
这是整个 notebook 的核心。
from transformers import AutoTokenizer, AutoModelForCausalLM导入两个 Hugging Face 常用类:
AutoTokenizer
AutoModelForCausalLM
CausalLM 的意思可以简单理解成:
这和 GPT 系列的工作方式一致。
from peft import LoraConfig, get_peft_model导入 PEFT 包里的两个核心工具:
LoraConfig
get_peft_model
model_name = "/root/course_lora/models/tiny-gpt2"定义模型路径。
这里不再写 Hugging Face 站点上的模型名,而是直接写本地路径,说明:
00_check_env.ipynb 已经把模型下载到本地了这是一种很好的实验习惯:
tokenizer = AutoTokenizer.from_pretrained(model_name)从本地模型目录加载 tokenizer。
if tokenizer.pad_token is None:这一句很重要。
它在检查:
pad_token为什么要检查?
因为很多 GPT 类 tokenizer 默认没有单独的 pad token。
但后面我们会用:
padding="max_length"如果没有 pad token,很多批处理操作会出问题。
tokenizer.pad_token = tokenizer.eos_token这句的意思是:
eos_token 当成 pad token 来用这里的:
eos = end of sequence这是在很多最小实验里常见的简化处理。
它不是唯一正确方法,但对这个最小演示足够了。
model = AutoModelForCausalLM.from_pretrained(model_name)从本地路径加载 base model。
注意:
lora_config = LoraConfig(...)这里开始定义 LoRA 配置。
这是学生第一次真正接触 LoRA 参数的地方。
我们逐个看:
r=8r 表示 LoRA 的 rank,也就是低秩分解的秩。
可以先简单理解成:
r 越大,LoRA 可表达的更新通常越强,但参数也会更多。
这里设成 8,是一个很常见的入门值。
lora_alpha=16这是 LoRA 的缩放系数。
可以先理解成:
课堂上你可以先记住:
r 决定容量alpha 决定缩放强度lora_dropout=0.05这是 LoRA 路径上的 dropout 概率。
作用是:
这里设成 0.05,表示 5%。
bias="none"表示:
这在最小 LoRA 实验里是常见设置。
task_type="CAUSAL_LM"告诉 PEFT:
因为不同任务类型接 LoRA 的方式和默认逻辑可能不同,所以这里显式指定比较稳。
model = get_peft_model(model, lora_config)这是这一格最关键的一行。
它的作用是:
也就是说,模型从这一步开始,变成:
model.print_trainable_parameters()这一行非常值得看。
它会打印:
这是让学生真正看见 LoRA 价值的最好地方之一:
代码:
def tokenize_fn(batch):
return tokenizer(batch["text"], truncation=True, padding="max_length", max_length=128)
train_tok = train_ds.map(tokenize_fn, batched=True, remove_columns=["text"])
val_tok = val_ds.map(tokenize_fn, batched=True, remove_columns=["text"])
train_tok这里开始从“自然语言文本”进入“模型可计算的数字表示”。
也就是说:
def tokenize_fn(batch):定义一个 tokenization 函数。
注意参数叫 batch,这说明它准备接收:
不是一条样本。
tokenizer(batch["text"], truncation=True, padding="max_length", max_length=128)这一行是 tokenizer 的实际调用。
batch["text"] 表示:
三个重要参数:
truncation=True如果文本太长,就截断。
为什么要这样做?
因为模型输入长度不能无限长。
padding="max_length"表示:
这样一批样本才能组成整齐的张量。
max_length=128把输入长度统一设为最多 128 个 token。
这个值越大:
对最小实验来说,128 是一个很合理的保守值。
train_tok = train_ds.map(tokenize_fn, batched=True, remove_columns=["text"])这一行在训练集上应用 tokenization。
map(...)Hugging Face Dataset 常用方法。
意思是:
batched=True说明 tokenize_fn 每次拿到的是一批数据,而不是一条。
这通常更高效。
remove_columns=["text"]表示:
text 列删掉因为后面训练真正需要的是:
input_idsattention_mask而不是原始文本列。
val_tok = val_ds.map(...)同样方法处理验证集。
train_tok让 Jupyter 显示 token 化后的数据集摘要。
你要理解的是:
Trainer代码:
from transformers import DataCollatorForLanguageModeling, Trainer, TrainingArguments
import torch
output_dir = "outputs/notebook_demo"
args = TrainingArguments(
output_dir=output_dir,
num_train_epochs=1,
per_device_train_batch_size=2,
per_device_eval_batch_size=2,
learning_rate=5e-4,
logging_steps=1,
eval_strategy="steps",
eval_steps=10,
save_strategy="steps",
save_steps=10,
report_to="none",
fp16=torch.cuda.is_available(),
remove_unused_columns=False,
)
trainer = Trainer(
model=model,
args=args,
train_dataset=train_tok,
eval_dataset=val_tok,
data_collator=DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False),
)
trainer.train()这是训练正式启动的地方。
它做了 3 件事:
Trainerfrom transformers import DataCollatorForLanguageModeling, Trainer, TrainingArguments导入 3 个工具:
TrainingArguments
Trainer
DataCollatorForLanguageModeling
import torch这里重新导入 torch,是为了后面用:
torch.cuda.is_available()output_dir = "outputs/notebook_demo"定义输出目录。
训练产物会存到这里。
args = TrainingArguments(...)这里开始构造训练参数对象。
这是初学者第一次接触一大串训练参数的地方,我们逐个解释。
output_dir=output_dir训练输出保存到哪个目录。
num_train_epochs=1训练轮数设为 1。
为什么只设 1?
因为这是最小实验,先跑通,不追求效果。
per_device_train_batch_size=2每张设备卡上的训练 batch size 是 2。
“per device” 的意思是:
per_device_eval_batch_size=2验证时每张卡一次处理 2 条样本。
learning_rate=5e-4学习率是 0.0005。
这是优化器每一步更新幅度的核心参数之一。
对这个最小实验来说,它的作用不是让你调到最好,而是让训练能正常跑起来。
logging_steps=1每 1 步就打一次日志。
这样你几乎每一步都能看到训练输出。
对教学很友好,但对大训练任务不一定高效。
eval_strategy="steps"表示:
不是按 epoch 评估。
eval_steps=10每 10 步做一次评估。
save_strategy="steps"表示:
save_steps=10每 10 步保存一次。
report_to="none"表示:
对于教学实验,这样最省事。
fp16=torch.cuda.is_available()这句很巧妙。
意思是:
fp16为什么要这样写?
因为 fp16 通常是 GPU 训练才会用的优化。
remove_unused_columns=False这项对 Hugging Face 训练管道挺重要。
简单理解是:
对于很多自定义训练流程,这是更稳妥的设置。
trainer = Trainer(...)开始构造训练器。
model=model训练的是当前这个已经接上 LoRA 的模型。
args=args使用刚才定义好的训练参数。
train_dataset=train_tok训练集是 token 化后的训练数据。
eval_dataset=val_tok验证集是 token 化后的验证数据。
data_collator=DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)这是很多初学者容易忽略的一点。
DataCollator 的作用是:
这里的:
mlm=False表示:
因为我们现在用的是 GPT 路线,所以这里必须是 False。
trainer.train()这一行就是正式启动训练。
如果前面都准备好了,这一行会开始:
这一格是“训练系统真正动起来”的地方。
如果学生能看懂这一格,就已经跨过了“我只会调 API”的门槛,开始真正理解训练流程了。
代码:
trainer.save_model(output_dir)
tokenizer.save_pretrained(output_dir)
print("saved to", output_dir)它在把训练结果保存下来,供下一份 notebook 使用。
trainer.save_model(output_dir)把当前训练后的模型保存到输出目录。
在 LoRA 场景里,这里保存的重点不是“一个全新的完整模型”,而是:
这也是第 11 章最重要的概念之一:
tokenizer.save_pretrained(output_dir)把 tokenizer 也保存到同一个目录。
这样后续推理 notebook 可以直接从输出目录恢复完整推理环境。
print("saved to", output_dir)打印保存位置,方便你确认。
因为训练不是“跑完就结束”,而是:
后面的 02_eval_and_infer.ipynb 就会专门验证这件事。
如果你已经顺利运行到这里,说明最小 LoRA 微调流程已经跑通。这句话其实比看起来更重要。
它真正的意思是:
Trainer 能跑这就叫:
也就是:
instructioninputoutput最后会被拼成一条完整字符串,而不是分别独立喂进模型。
这点非常关键。
你还在做语言模型训练,只是:
模型不吃字符串,模型吃:
input_idsattention_mask所以 tokenization 是从“人类语言”走向“模型计算”的桥梁。
Trainer 并不神秘Trainer 的本质只是把这些东西接起来:
看懂这一点,你以后换 SFTTrainer、换更大模型时就不会太慌。
为了避免误解,也要讲清楚它没做什么:
所以它不是“完整大模型工程”,而是:
如果你真的理解了这份 notebook,应该能回答下面这些问题:
LoraConfig 里的 r、alpha、dropout 大致在控制什么?Trainer 需要 data_collator?如果这些问题你都能用自己的话说清楚,那就说明这份最小 LoRA notebook 你已经真的看懂了。