第 9 章 进阶拓展方向 把本项目当起点,往这些方向深入。每节给出最小可行实现思路和参考资料。 9.1 手写 Transformer 用 HF 现成的 GPT2 之外,自己实现一遍最能加深理解。 从最小可行版本开始 不要一上来就写完整 Transformer。分 5 步: Step 1:单头注意力 Step 2:多头注意力 把 N 个单头并行算,结果拼接: Step 3:加 LayerNorm + 残差 Step 4:堆叠 N 层 + 嵌入 Step 5:测试 替换本项目的 ,用自己写的 GPT 跑训练,对比 loss 下降是否一致。 参考资料 nanoGPT(Karpathy):GitHub 上 karpathy/nanoGPT 仓库 — 本项目的精神来源,300 行实现 GPT。
把本项目当起点,往这些方向深入。每节给出最小可行实现思路和参考资料。
用 HF 现成的 GPT2 之外,自己实现一遍最能加深理解。
不要一上来就写完整 Transformer。分 5 步:
import torch import torch.nn as nn import torch.nn.functional as F class SingleHeadAttention(nn.Module): def __init__(self, n_embd, head_dim): super().__init__() self.q = nn.Linear(n_embd, head_dim) self.k = nn.Linear(n_embd, head_dim) self.v = nn.Linear(n_embd, head_dim) self.head_dim = head_dim def forward(self, x): # x: (B, T, n_embd) q = self.q(x) # (B, T, head_dim) k = self.k(x) v = self.v(x) # 注意力分数 scores = q @ k.transpose(-2, -1) / (self.head_dim ** 0.5) # (B, T, T) # 因果掩码 mask = torch.tril(torch.ones(T, T)) scores = scores.masked_fill(mask == 0, float("-inf")) attn = F.softmax(scores, dim=-1) return attn @ v # (B, T, head_dim)
把 N 个单头并行算,结果拼接:
class MultiHeadAttention(nn.Module): def __init__(self, n_embd, n_head): super().__init__() self.heads = nn.ModuleList([ SingleHeadAttention(n_embd, n_embd // n_head) for _ in range(n_head) ]) self.proj = nn.Linear(n_embd, n_embd) def forward(self, x): out = torch.cat([h(x) for h in self.heads], dim=-1) return self.proj(out)
class Block(nn.Module): def __init__(self, n_embd, n_head): super().__init__() self.ln1 = nn.LayerNorm(n_embd) self.attn = MultiHeadAttention(n_embd, n_head) self.ln2 = nn.LayerNorm(n_embd) self.ffn = nn.Sequential( nn.Linear(n_embd, 4 * n_embd), nn.GELU(), nn.Linear(4 * n_embd, n_embd), ) def forward(self, x): x = x + self.attn(self.ln1(x)) # 残差 + 注意力 x = x + self.ffn(self.ln2(x)) # 残差 + FFN return x
class GPT(nn.Module): def __init__(self, vocab_size, n_embd, n_head, n_layer, block_size): super().__init__() self.wte = nn.Embedding(vocab_size, n_embd) self.wpe = nn.Embedding(block_size, n_embd) self.blocks = nn.ModuleList([Block(n_embd, n_head) for _ in range(n_layer)]) self.ln_f = nn.LayerNorm(n_embd) self.lm_head = nn.Linear(n_embd, vocab_size, bias=False) def forward(self, idx): B, T = idx.shape pos = torch.arange(T).unsqueeze(0) x = self.wte(idx) + self.wpe(pos) for block in self.blocks: x = block(x) x = self.ln_f(x) return self.lm_head(x) # (B, T, vocab_size)
替换本项目的 model.py,用自己写的 GPT 跑训练,对比 loss 下降是否一致。
本项目只有训练集,看不到泛化性能。改进:
# 在 dataset.py 里加 def split_text(text, val_ratio=0.1): split = int(len(text) * (1 - val_ratio)) return text[:split], text[split:]
或更合理的:按 token 切分。
@torch.no_grad() def evaluate(model, val_loader, device): model.eval() total_loss = 0 n = 0 for x, y in val_loader: x, y = x.to(device), y.to(device) loss = model(input_ids=x, labels=y).loss total_loss += loss.item() * len(x) n += len(x) model.train() # 切回训练模式 return total_loss / n
best_val_loss = float("inf") patience = 5 # 验证 loss 连续 5 次不降就停 no_improve = 0 for step in range(max_iters): # 训练一步... if step % eval_iter == 0: val_loss = evaluate(model, val_loader, device) if val_loss < best_val_loss: best_val_loss = val_loss no_improve = 0 save_checkpoint(..., path="checkpoints/best.pt") else: no_improve += 1 if no_improve >= patience: print("Early stopping!") break
evaluate 后必须 model.train(),否则后续训练 dropout 关闭。@torch.no_grad():验证不建图,省显存。朴素生成的复杂度是 O(seq²)——每生成一个 token,把整段历史重新前向一遍。可以优化到 O(seq)。
注意力计算里,每个历史位置的 K 和 V 是不变的(只是越来越多)。把它们缓存起来,下一步只算新 token 的 Q 与所有历史 K/V 做注意力。
朴素:每步重算 T 个位置的 K, V step 1: 算 K, V for [t0] → 1 次前向 step 2: 算 K, V for [t0, t1] → 2 次前向 step 3: 算 K, V for [t0, t1, t2] → 3 次前向 ... 总复杂度 O(T²) KV Cache:每步只算新位置的 K, V step 1: 算 K, V for t0, 缓存 → 1 次前向 step 2: 算 K, V for t1, 追加缓存 → 1 次前向 step 3: 算 K, V for t2, 追加缓存 → 1 次前向 ... 总复杂度 O(T)
out = model.generate( input_ids=..., max_new_tokens=100, use_cache=True, # 默认就是 True )
或在手写循环里:
past_key_values = None for _ in range(max_new_tokens): out = model(input_ids=next_token, past_key_values=past_key_values) past_key_values = out.past_key_values # 缓存 next_token = sample(out.logits[:, -1])
显存占用增加:要存所有层的 K/V(shape (B, n_head, T, head_dim) × 2 × n_layer)。长序列时这是大头。
Top-K 固定数量,Top-p 固定概率累加上限:
def sample_top_p(logits, p=0.9): probs = F.softmax(logits, dim=-1) sorted_probs, sorted_idx = torch.sort(probs, descending=True) cumsum = torch.cumsum(sorted_probs, dim=-1) # 找累加到 p 的位置 sorted_indices_to_remove = cumsum > p sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone() sorted_indices_to_remove[..., 0] = False indices_to_remove = sorted_idx[sorted_indices_to_remove] probs[indices_to_remove] = 0 probs = probs / probs.sum() return torch.multinomial(probs, num_samples=1)
Top-p 的优势:自适应。
比固定 K=40 更聪明。
选「信息熵接近平均」的 token,避免极端高概率(重复)和极端低概率(乱来)。论文 Hierarchical Neural Story Generation 提出。
新方法(2022),用一个罚项平衡连贯性和多样性。HF 已支持:
out = model.generate( input_ids=..., do_sample=False, penalty_alpha=0.6, top_k=4, )
维护 K 条候选路径,每步扩展,选整体概率最大的。适合翻译/摘要(有标准答案),不适合开放生成(容易平庸重复)。
out = model.generate( input_ids=..., num_beams=4, max_new_tokens=100, )
fp32 训练显存占用大。AMP 自动在 fp16/bf16 和 fp32 间切换,省显存、提速度。
scaler = torch.cuda.amp.GradScaler() for step in range(max_iters): x, y = next(...) optimizer.zero_grad(set_to_none=True) # 前向用 autocast(自动混合精度) with torch.cuda.amp.autocast(): loss = model(input_ids=x, labels=y).loss # 反向用 scaler(防 fp16 梯度下溢) scaler.scale(loss).backward() # 梯度裁剪前先 unscale(否则裁剪的是放大后的梯度) scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 步进 scaler.step(optimizer) scaler.update() scheduler.step()
| fp16 | bf16 | |
|---|---|---|
| 范围 | ±65504 | ±3e38(与 fp32 同) |
| 精度 | 高 | 低 |
| 溢出风险 | 高 | 几乎无 |
| 硬件支持 | Volta+(2017) | Ampere+(2020) |
新卡(A100/RTX 30+)优先 bf16,不需要 scaler:
with torch.autocast(device_type="cuda", dtype=torch.bfloat16): loss = model(...) loss.backward() # 不需要 scaler
| 数据集 | 大小 | 特点 |
|---|---|---|
| tiny_shakespeare(本项目) | 1MB | 教学 |
| WikiText-2 / 103 | 10MB / 500MB | 维基百科 |
| OpenWebText | 40GB | GPT-2 原版数据复刻 |
| The Pile | 825GB | 多源混合 |
| RedPajama | 1T+ | 开源 LLaMA 数据复刻 |
大数据集一次性载入内存不现实,用流式:
from datasets import load_dataset ds = load_dataset("openwebtext", split="train", streaming=True) for example in ds: text = example["text"] # 流式处理,不全载入
import torch.distributed as dist from torch.nn.parallel import DistributedDataParallel as DDP dist.init_process_group("nccl") local_rank = int(os.environ["LOCAL_RANK"]) torch.cuda.set_device(local_rank) model = model.to(local_rank) model = DDP(model, device_ids=[local_rank]) sampler = DistributedSampler(dataset) dataloader = DataLoader(dataset, sampler=sampler, ...) for x, y in dataloader: loss = model(x, labels=y).loss loss.backward() # DDP 自动同步梯度
启动:
torchrun --nproc_per_node=4 train.py # 4 卡并行,每卡处理 1/4 数据,梯度自动平均
DDP 每卡存完整模型副本,显存吃不消。ZeRO 把权重/优化器/梯度切到多卡:
| 阶段 | 切分内容 | 显存节省 |
|---|---|---|
| ZeRO-1 | 优化器状态 | 4x |
| ZeRO-2 | 优化器 + 梯度 | 8x |
| ZeRO-3 | 优化器 + 梯度 + 权重 | 16x+ |
import deepspeed model, optimizer, _, _ = deepspeed.initialize( model=model, optimizer=optimizer, config=ds_config )
不从头训练,加载 gpt2 预训练权重,只训练插入的低秩矩阵。
预训练权重 W 不动,旁边加一个低秩更新 BA:
原:y = Wx LoRA:y = Wx + BAx (B 是 d×r,A 是 r×d,r << d)
只训练 A 和 B,参数量从 d² 降到 2dr。
from peft import LoraConfig, get_peft_model from transformers import GPT2LMHeadModel model = GPT2LMHeadModel.from_pretrained("gpt2") lora_config = LoraConfig( r=8, # 低秩维度 lora_alpha=16, # 缩放系数 target_modules=["c_attn", "c_proj"], # 对哪些层加 LoRA lora_dropout=0.05, ) model = get_peft_model(model, lora_config) model.print_trainable_parameters() # trainable params: 294,912 || all params: 124,734,720 || trainable%: 0.24%
只训 0.24% 的参数,效果接近全参数微调,显存骤降。
想用中文或专门领域词表:
from tokenizers import Tokenizer from tokenizers.models import BPE from tokenizers.trainers import BpeTrainer from tokenizers.pre_tokenizers import Whitespace tokenizer = Tokenizer(BPE(unk_token="<unk>")) tokenizer.pre_tokenizer = Whitespace() trainer = BpeTrainer( vocab_size=30000, special_tokens=["<unk>", "<pad>", "<bos>", "<eos>"], ) tokenizer.train(files=["corpus.txt"], trainer=trainer)
换了 tokenizer 就必须重新训练 embedding(vocab_size 变了):
# 旧:vocab_size=50257 (p50k_base) # 新:vocab_size=30000 (自定义) # 模型的 wte 和 lm_head 都要重建
预训练权重不能直接复用(词表对不上)。如果想微调预训练模型 + 自定义词表,需要 resize:
model = GPT2LMHeadModel.from_pretrained("gpt2") model.resize_token_embeddings(new_vocab_size)
# Loss 越低越好 # Perplexity = exp(loss),越低越好 import math ppl = math.exp(loss)
perplexity 直觉:「模型平均在每个位置纠结于多少个候选」。PPL=1 表示完全确定,PPL=50 表示在 50 个候选间纠结。
gpt2,在自己的数据上微调。accelerate、trl、transformers 新功能。| 论文 | 贡献 |
|---|---|
| Attention Is All You Need (2017) | Transformer 架构 |
| GPT-2 (2019) | 自回归 LM + zero-shot |
| GPT-3 (2020) | few-shot learning, scaling law |
| Chinchilla (2022) | 计算最优训练配方 |
| InstructGPT (2022) | RLHF |
| LLaMA (2023) | 开源大模型 + 数据 |
| LoRA (2021) | 参数高效微调 |
SingleHeadAttention,喂一个 (1, 4, 8) 的随机张量,看输出 shape 是否正确。dataset.py 和 train.py,画 train_loss vs val_loss 曲线,观察是否过拟合。generate 里加一个 top_p 参数。train.py 加混合精度,对比显存和速度。peft 加载 gpt2,在莎士比亚上微调 100 步,看 loss 是否比从零训降得快。进阶方向 10 个:
每条都是独立可深入的领域,选感兴趣的钻。
恭喜你读完整个教程!🎉
记住:理解一个小项目的每一行,比囫囵跑通一个大项目价值更高。本项目就是为前者设计的。
现在去:
Trainer、Accelerate、DeepSpeed。祝玩得开心!🚀