因果语言模型的掩码、LoRA与断点
前置为数据章、上一节梯度,以及模型 API 总览中的消息与上下文实验。这里构造一个真正的GPT-2因果解码器,只有一层、32维,随机初始化;它能演示训练和存储,不是可用助手,也不代表预训练模型微调的质量。
teacher forcing 把正确前缀喂给模型:位置 t 的 logits 预测位置 t+1 的标签。训练输入仍含用户问题,但 assistant-only loss 将用户和分隔符标签设为 -100;模型读取这些上下文,梯度目标却只监督回答和EOS。attention_mask 控制PAD不可见,labels=-100控制PAD不计损失,两者不能互相替代。
LoRA冻结原矩阵,只优化低秩增量,增量形状由 target module 决定。GPT-2 c_attn 是组合QKV投影并使用Conv1D转置约定,所以这里明确 fan_in_fan_out=True;不能把别的模型q_proj名字直接复制过来。
首次完整实验
Python 3.12隔离环境安装 torch==2.8.0 transformers==4.57.3 peft==0.18.0;保存为 train_lora.py,在空工作目录运行 python train_lora.py,写出tiny-base、adapter和resume.pt。不下载权重、不需要GPU。
from pathlib import Path
import torch
from transformers import GPT2Config, GPT2LMHeadModel
from peft import LoraConfig, get_peft_model, PeftModel
torch.manual_seed(11); torch.set_num_threads(1)
# A tiny random causal decoder, not a useful pretrained assistant.
config = GPT2Config(vocab_size=16, n_positions=16, n_embd=32, n_layer=1, n_head=2,
resid_pdrop=0, embd_pdrop=0, attn_pdrop=0, eos_token_id=2, pad_token_id=0)
base = GPT2LMHeadModel(config)
base.save_pretrained('tiny-base')
model = get_peft_model(base, LoraConfig(r=4, lora_alpha=8, target_modules=['c_attn'],
fan_in_fan_out=True, task_type='CAUSAL_LM'))
# 1=user delimiter, 3=question, 4=assistant delimiter, 5=answer, 2=eos, 0=pad.
ids = torch.tensor([[1, 3, 4, 5, 2, 0], [1, 6, 4, 7, 2, 0]])
mask = ids.ne(0).long()
labels = ids.clone(); labels[:, :3] = -100; labels[mask == 0] = -100
hold = torch.tensor([[1, 8, 4, 9, 2, 0]])
hlabels = hold.clone(); hlabels[:, :3] = -100; hlabels[hold == 0] = -100
optimizer = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=.03)
def measure():
model.eval()
with torch.no_grad():
return (model(input_ids=ids, attention_mask=mask, labels=labels).loss.item(),
model(input_ids=hold, attention_mask=hold.ne(0).long(), labels=hlabels).loss.item())
before = measure()
for step in range(30):
model.train(); optimizer.zero_grad()
result = model(input_ids=ids, attention_mask=mask, labels=labels)
# Transformers shifts internally: position 2 predicts answer token at position 3.
manual = torch.nn.functional.cross_entropy(result.logits[:, :-1].reshape(-1,16), labels[:,1:].reshape(-1))
assert torch.allclose(result.loss, manual)
result.loss.backward()
assert all(p.grad is None for n,p in model.named_parameters() if 'lora_' not in n)
optimizer.step()
model.eval(); expected = model(input_ids=ids, attention_mask=mask).logits.detach()
model.save_pretrained('adapter')
torch.save({'optimizer':optimizer.state_dict(),'step':30,'rng':torch.get_rng_state()}, 'resume.pt')
reloaded = PeftModel.from_pretrained(GPT2LMHeadModel.from_pretrained('tiny-base'), 'adapter', is_trainable=True)
reloaded.eval()
assert torch.allclose(expected, reloaded(input_ids=ids,attention_mask=mask).logits, atol=1e-6)
resume = torch.load('resume.pt', weights_only=True)
opt2 = torch.optim.AdamW([p for p in reloaded.parameters() if p.requires_grad],lr=.03)
opt2.load_state_dict(resume['optimizer']); torch.set_rng_state(resume['rng'])
opt2.zero_grad(); reloaded(input_ids=ids,attention_mask=mask,labels=labels).loss.backward(); opt2.step()
# Compare merge before changing the original trained model.
merged = model.merge_and_unload(); merged.eval()
assert torch.allclose(expected, merged(input_ids=ids,attention_mask=mask).logits,atol=1e-5)
print('labels', labels.tolist(), 'train/holdout', before, measure() if hasattr(model,'peft_config') else 'merged')
print('saved reloaded resumed merged', resume['step'])打印 labels 后应看到每行前三位和PAD为-100,回答与EOS保留;手工移位损失与框架返回相同。若再把传入labels提前移一位,框架会二次移位,训练目标错开。每次反向断言非LoRA参数无梯度,表明这是实际冻结,不只是统计参数数量。
先保存 base,再保存 adapter;只拿adapter目录无法重建模型。重新载入同一base并加载adapter后logits一致,optimizer的动量与随机数状态也保存,恢复后执行下一步。正式训练还应保存调度器、数据采样位置、梯度缩放器与分布式状态;本例固定两条样本、无dropout、无混合精度,明确省略了这些状态。merge_and_unload把增量写入基座,之后不能再把同一个adapter叠加一次。
样本packing把多个短对话装进长序列,必须重新计算每段assistant掩码;EOS只标记分界,不自动阻断不同样本间注意力。若要求严格独立,使用分块注意力mask或不packing。右padding使批次对齐,截断时必须保证至少一个受监督token,否则全-100损失没有有效目标。
全量微调还更新基座、需保存全部权重和优化器状态;QLoRA将冻结基座量化并保留高精度adapter,节省基座内存但不免除激活和优化器开销。分布式数据并行复制模型、划分样本;参数分片减少每卡状态但增加通信,不能根据“多卡”假定一定更快。CPU例子不伪造量化或多卡吞吐。
DPO不是给SFT增加一个好坏标签。它比较同一prompt的chosen/rejected序列对数概率相对冻结reference的差值:margin=β[(logπc−logπr)−(logrefc−logrefr)],loss=−logσ(margin)。因此要只累计回答token、保留相同prompt,并固定reference;把长答案总logprob与短答案粗暴比较会带来长度偏差。先确认SFT格式能力,再用偏好对与独立保留集检测退化。
练习
把第二条样本所有标签改成-100,手工核对其不贡献损失;然后故意加载不同随机base,证明adapter重载logits不再相同。不要据训练集损失下降断言泛化改进。
完成后查看本章答案。
下一章:泛化、遗忘、量化与发布。