01_lora_demo.ipynb 说明文档

这份文档是给第一次接触 LoRA 微调的同学准备的。

如果说 00_check_env.ipynb 的任务是:

那么 01_lora_demo.ipynb 的任务就是:

注意,这里的关键词是:

它不是为了追求效果,不是为了训练一个很强的模型,而是为了让你第一次真正看懂:

  1. 数据是怎么进入模型的
  2. 文本是怎么变成 token 的
  3. LoRA 是怎么接到模型上的
  4. Trainer 是怎么启动训练的
  5. 训练结果是怎么保存的

只要你把这份 notebook 跑通并看懂,后面再换更大的模型,思路就不会乱。


一、这个 notebook 整体在做什么

它做的事情可以概括成 6 步:

  1. 读取样例训练集和验证集
  2. instruction / input / output 拼成一段真正拿去训练的文本
  3. 加载 tokenizer 和 base model
  4. 给模型接上 LoRA adapter
  5. 把文本 token 化,并交给 Trainer
  6. 训练结束后保存 adapter 和 tokenizer

这其实就是第 11 章课堂上讲的最小闭环:


二、Cell 0:开场说明

# 01_lora_demo

这是一个最小 LoRA 微调演示 notebook。

为了降低第一次实验的门槛,这里默认使用 `sshleifer/tiny-gpt2` 做快速验证。真正上课时,可以把模型替换成课程指定模型。

这一格没有代码,只是在明确这个 notebook 的定位:

你可以把它理解成:


三、Cell 1:确认数据文件存在,并预览样例

代码:

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 件事:

  1. 找到训练集和验证集文件
  2. 检查它们是否真的存在
  3. 打印前 2 条样例,看看数据长什么样

这是一个很好的实验习惯:

很多人一开始就跑训练,结果根本不知道数据格式有没有错。

逐行解释

import json

导入 Python 标准库 json

它的作用是:

from pathlib import Path

导入 Path

Path 是 Python 里处理路径时很方便的工具。
相比直接写字符串路径,它更清晰,也更稳。

train_path = Path("data/sample_train.json")

定义训练集路径对象。

这里不是普通字符串,而是一个 Path 对象。
它表示:

val_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 的切片写法,表示:

所以整行代码的意思是:

这一格的意义

你应该通过它确认:

  1. 数据文件路径没有错
  2. JSON 格式没有坏
  3. 每条样例里大概有哪些字段

从课程目录看,这里的样例格式是:

这正是后面要拼成训练文本的三部分。


四、Cell 2:把结构化样例变成训练文本,并封装成数据集

代码:

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 第一个真正关键的步骤。

它在做两件事:

  1. 定义“怎样把一条样例变成训练文本”
  2. 把所有样例装进 Hugging Face 的 Dataset

这一步一定要理解,因为它直接回答了一个核心问题:

逐行解释

from datasets import Dataset

导入 Hugging Face datasets 里的 Dataset 类。

它的作用是:

def load_records(path):

定义一个函数,名字叫 load_records

函数的作用是:

return json.loads(Path(path).read_text(encoding="utf-8"))

这一行就是函数主体。

意思是:

  1. Path(path) 把输入路径变成路径对象
  2. read_text() 读出文件内容
  3. json.loads() 解析成 Python 对象
  4. 把结果返回

所以 load_records(train_path) 的结果就是:

def build_text(example):

再定义一个函数,叫 build_text

它的任务是:

这一步特别重要,因为它决定了模型看到的数据格式。

parts = []

先创建一个空列表 parts

后面我们会把:

分别按顺序加进去。

if example.get("instruction"):

这里的 example 是一条字典,比如:

{
  "instruction": "...",
  "input": "...",
  "output": "..."
}

get("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 判断,说明这里假设:

这也符合监督微调的基本逻辑:

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 字段本身,而是:


五、Cell 3:加载 tokenizer 和模型,并接上 LoRA

代码:

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 件关键事:

  1. 加载 tokenizer
  2. 处理 pad token
  3. 加载 base model
  4. 在 base model 上接上 LoRA adapter

这是整个 notebook 的核心。

逐行解释

from transformers import AutoTokenizer, AutoModelForCausalLM

导入两个 Hugging Face 常用类:

CausalLM 的意思可以简单理解成:

这和 GPT 系列的工作方式一致。

from peft import LoraConfig, get_peft_model

导入 PEFT 包里的两个核心工具:

model_name = "/root/course_lora/models/tiny-gpt2"

定义模型路径。

这里不再写 Hugging Face 站点上的模型名,而是直接写本地路径,说明:

这是一种很好的实验习惯:

tokenizer = AutoTokenizer.from_pretrained(model_name)

从本地模型目录加载 tokenizer。

if tokenizer.pad_token is None:

这一句很重要。

它在检查:

为什么要检查?

因为很多 GPT 类 tokenizer 默认没有单独的 pad token。

但后面我们会用:

如果没有 pad token,很多批处理操作会出问题。

tokenizer.pad_token = tokenizer.eos_token

这句的意思是:

这里的:

这是在很多最小实验里常见的简化处理。

它不是唯一正确方法,但对这个最小演示足够了。

model = AutoModelForCausalLM.from_pretrained(model_name)

从本地路径加载 base model。

注意:

lora_config = LoraConfig(...)

这里开始定义 LoRA 配置。

这是学生第一次真正接触 LoRA 参数的地方。

我们逐个看:

r=8

r 表示 LoRA 的 rank,也就是低秩分解的秩。

可以先简单理解成:

r 越大,LoRA 可表达的更新通常越强,但参数也会更多。

这里设成 8,是一个很常见的入门值。

lora_alpha=16

这是 LoRA 的缩放系数。

可以先理解成:

课堂上你可以先记住:

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 价值的最好地方之一:


六、Cell 4:把文本变成 token

代码:

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"]

表示:

因为后面训练真正需要的是:

而不是原始文本列。

val_tok = val_ds.map(...)

同样方法处理验证集。

train_tok

让 Jupyter 显示 token 化后的数据集摘要。

这一格的意义

你要理解的是:


七、Cell 5:配置训练参数,并启动 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 件事:

  1. 定义训练参数
  2. 构造 Trainer
  3. 启动训练

逐行解释

from transformers import DataCollatorForLanguageModeling, Trainer, TrainingArguments

导入 3 个工具:

import torch

这里重新导入 torch,是为了后面用:

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 通常是 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 的作用是:

这里的:

表示:

因为我们现在用的是 GPT 路线,所以这里必须是 False

trainer.train()

这一行就是正式启动训练。

如果前面都准备好了,这一行会开始:

这一格的意义

这一格是“训练系统真正动起来”的地方。
如果学生能看懂这一格,就已经跨过了“我只会调 API”的门槛,开始真正理解训练流程了。


八、Cell 6:保存训练结果

代码:

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 就会专门验证这件事。


九、Cell 7:结束说明

如果你已经顺利运行到这里,说明最小 LoRA 微调流程已经跑通。

这句话其实比看起来更重要。
它真正的意思是:

这就叫:


十、这一份 notebook 最核心的 4 个学习点

1. 大模型微调前,数据通常要先变成一段统一文本

也就是:

最后会被拼成一条完整字符串,而不是分别独立喂进模型。

2. LoRA 不是改训练目标,而是改“哪些参数参与训练”

这点非常关键。
你还在做语言模型训练,只是:

3. tokenizer 是训练真正开始前的必经步骤

模型不吃字符串,模型吃:

所以 tokenization 是从“人类语言”走向“模型计算”的桥梁。

4. Trainer 并不神秘

Trainer 的本质只是把这些东西接起来:

看懂这一点,你以后换 SFTTrainer、换更大模型时就不会太慌。


十一、这份 notebook 没有做什么

为了避免误解,也要讲清楚它没做什么:

所以它不是“完整大模型工程”,而是:


十二、你跑完以后应该问自己什么

如果你真的理解了这份 notebook,应该能回答下面这些问题:

  1. 为什么要先把 JSON 样例拼成文本?
  2. 为什么 tokenizer 需要 pad token?
  3. LoraConfig 里的 ralphadropout 大致在控制什么?
  4. 为什么 Trainer 需要 data_collator
  5. 为什么保存的不只是模型,还包括 tokenizer?

如果这些问题你都能用自己的话说清楚,那就说明这份最小 LoRA notebook 你已经真的看懂了。