第 2 章 配置体系(config.py) 核心思想:所有超参数集中到 dataclass,做到「配置即代码」,命令行可覆盖任意一项。 2.1 为什么配置这么重要 深度学习训练有个残酷事实:同样的代码、不同的超参数,效果天差地别。学习率差一个数量级,loss 可能从 1.5 飙到 NaN。所以工业项目都把「配置管理」当一等公民。 用 Python 标准库的 解决了三个问题: 集中:所有超参一处定义,不散落各处。 类型安全:IDE 自动补全、静态检查。 可序列化:能存进 checkpoint,下次恢复。 2.2 完整源码 整文件不到 100 行,但定义了项目的全部可调旋钮。 2.3 dataclass 入门 传统写法的痛点 问题:参数散落、无类型、无文档、易写错、难复现。
核心思想:所有超参数集中到 dataclass,做到「配置即代码」,命令行可覆盖任意一项。
深度学习训练有个残酷事实:同样的代码、不同的超参数,效果天差地别。学习率差一个数量级,loss 可能从 1.5 飙到 NaN。所以工业项目都把「配置管理」当一等公民。
config.py 用 Python 标准库的 dataclass 解决了三个问题:
""" config.py ========= GPT 模型训练项目的全局配置模块。 本模块使用 Python 标准库 dataclass 定义两组配置: - GPTConfig: 描述 GPT 模型本身的架构超参数(层数、头数、嵌入维度等)。 - TrainConfig: 描述训练过程的超参数(批大小、学习率、最大迭代步数等)。 使用 dataclass 的好处: 1. 代码简洁,自动生成 __init__ / __repr__ 等方法。 2. 类型注解清晰,便于 IDE 提示与静态检查。 3. 易于序列化为 JSON / YAML,方便实验复现。 """ from dataclasses import dataclass from typing import Tuple @dataclass class GPTConfig: """GPT 模型架构配置。 这些超参数会被映射为 HuggingFace transformers 的 GPT2Config, 从而控制一个从零初始化的小型 GPT2 模型的规模。 """ # ---------- 词表与上下文 ---------- # p50k_base (GPT-2 编码) 的词表大小,固定为 50257。 vocab_size: int = 50257 # 上下文窗口长度 (block_size),即模型一次能看到的最大 token 数。 block_size: int = 128 # ---------- Transformer 主体 ---------- # Transformer 层数。 n_layer: int = 6 # 多头注意力的头数。 n_head: int = 6 # 嵌入维度(隐藏层维度),会被均匀拆分到每个注意力头。 n_embd: int = 384 # ---------- Dropout ---------- # 统一的 dropout 概率,分别作用于残差连接、嵌入层与注意力权重。 dropout: float = 0.1 def to_gpt2_kwargs(self) -> dict: """将本配置映射为 HuggingFace transformers GPT2Config 的关键字参数。""" return { "vocab_size": self.vocab_size, "n_positions": self.block_size, "n_ctx": self.block_size, "n_embd": self.n_embd, "n_layer": self.n_layer, "n_head": self.n_head, "resid_pdrop": self.dropout, "embd_pdrop": self.dropout, "attn_pdrop": self.dropout, # GPT-2 词表中 50256 即 <|endoftext|>,用作序列起止符。 "bos_token_id": self.vocab_size - 1, "eos_token_id": self.vocab_size - 1, } @dataclass class TrainConfig: """训练流程配置。""" # ---------- 数据 ---------- batch_size: int = 32 # Windows 下若大于 0,必须保证训练入口位于 if __name__ == "__main__" 中。 num_workers: int = 0 # ---------- 优化器 (AdamW) ---------- learning_rate: float = 3e-4 weight_decay: float = 0.1 betas: Tuple[float, float] = (0.9, 0.95) # ---------- 训练步数 ---------- max_iters: int = 5000 warmup_iters: int = 100 # ---------- 梯度裁剪 ---------- grad_clip: float = 1.0 # ---------- 评估、日志与保存 ---------- log_iter: int = 10 # 每多少步打印一次平均 loss save_iter: int = 500 # 每多少步保存一次 checkpoint # ---------- 学习率调度 ---------- min_lr_ratio: float = 0.1 # 余弦退火终点的学习率比例 # ---------- 设备与随机种子 ---------- device: str = "auto" # "auto" / "cuda" / "cpu" seed: int = 42 # ---------- 目录 ---------- checkpoint_dir: str = "checkpoints"
整文件不到 100 行,但定义了项目的全部可调旋钮。
# 反面教材:散落的魔法数字 def train(): lr = 3e-4 # 这个 3e-4 哪来的?改一处忘改另一处? batch_size = 32 n_layer = 6 # ...100 行后... scheduler = CosineScheduler(lr, n_layer=6) # 又出现一个 6,跟前面那个一致吗?
问题:参数散落、无类型、无文档、易写错、难复现。
from dataclasses import dataclass @dataclass class GPTConfig: vocab_size: int = 50257 n_layer: int = 6 n_embd: int = 384
加了 @dataclass 装饰器后,Python 自动给你生成:
__init__:GPTConfig(n_layer=12) 直接可用,参数都有默认值。__repr__:print(GPTConfig()) 输出 GPTConfig(vocab_size=50257, n_layer=6, n_embd=384),可读。__eq__:两个 config 可直接比较相等。你只写「字段名 + 类型 + 默认值」一行,方法全自动生成。这是「声明式编程」的典型范式。
dataclass 实例的 __dict__ 直接是 JSON 兼容的 dict(只要字段都是基本类型):
cfg = GPTConfig() print(cfg.__dict__) # {'vocab_size': 50257, 'block_size': 128, 'n_layer': 6, ...} # 存进 checkpoint import json with open("config.json", "w") as f: json.dump(cfg.__dict__, f) # 恢复 cfg2 = GPTConfig() cfg2.__dict__.update(json.load(open("config.json")))
本项目 train.py 就是这么把配置存进 checkpoint 的(见第 5 章)。
@dataclass class GPTConfig: vocab_size: int = 50257 block_size: int = 128 n_layer: int = 6 n_head: int = 6 n_embd: int = 384 dropout: float = 0.1
输入 token 序列 (长度 ≤ block_size) │ ▼ ┌─────────────────────────────────────┐ │ Token Embedding (vocab_size × n_embd) │ ├─────────────────────────────────────┤ │ ┌─────────────────────────────┐ │ │ │ Transformer Block × n_layer │ │ ◄── 6 层堆叠 │ │ ├─ Multi-Head Attention │ │ (n_head=6 个头) │ │ │ 每头维度 = n_embd/n_head│ │ 每头 384/6 = 64 维 │ │ ├─ Feed-Forward │ │ │ │ └─ Residual + LayerNorm │ │ │ └─────────────────────────────┘ │ ├─────────────────────────────────────┤ │ LM Head (n_embd × vocab_size) │ └─────────────────────────────────────┘ │ ▼ 输出每个位置下一个 token 的概率分布
vocab_size = 50257分词器的词表大小。本项目用 tiktoken 的 p50k_base 编码(GPT-2 同款),词表恰好 50257。
⚠️ 铁律:这个值由分词器决定,不能乱改。改了会导致 token id 与 Embedding 矩阵的行数对不上。换词表 = 换分词器 = 必须同步换 vocab_size。
block_size = 128模型一次能看到的最大 token 数(上下文窗口)。GPT-2 small 是 1024,本项目缩小到 128 是为了训练快。
影响:
n_positions)。n_layer = 6、n_head = 6、n_embd = 384Transformer 的核心三参数。
n_layer:堆叠几个 Transformer Block。越深越能学复杂模式,但越难训。n_head:多头注意力的头数。每个头独立学一种「关注模式」(如语法、语义、共指……)。n_embd:嵌入维度,所有 token 都被表示成 384 维向量。这是模型的「带宽」。⚠️ 约束:
n_embd必须能被n_head整除。每个头的维度 =n_embd / n_head。本项目 384 / 6 = 64 ✓。改成n_embd=100, n_head=3会报错(100/3 不是整数)。
dropout = 0.1训练时随机把 10% 的神经元置零,防止过拟合。推理时自动关闭。
本项目用同一个 dropout 值作用于三处(见 to_gpt2_kwargs):
resid_pdrop:残差连接后的 dropoutembd_pdrop:嵌入层后的 dropoutattn_pdrop:注意力权重上的 dropout默认配置(6 × 384 × 6)≈ 25M 参数。常见对照:
| 规模 | n_layer | n_embd | n_head | 参数量 |
|---|---|---|---|---|
| 玩具级 | 2 | 64 | 2 | ~0.2M |
| 本项目默认 | 6 | 384 | 6 | ~25M |
| GPT-2 small | 12 | 768 | 12 | 124M |
| GPT-2 XL | 48 | 1600 | 25 | 1.5B |
参数量粗略公式:
参数量 ≈ 12 × n_layer × n_embd²
(精确数字会因是否 tie weights、词表大小略有出入,第 4 章会讲。)
to_gpt2_kwargs():配置的单一来源def to_gpt2_kwargs(self) -> dict: return { "vocab_size": self.vocab_size, "n_positions": self.block_size, "n_ctx": self.block_size, "n_embd": self.n_embd, "n_layer": self.n_layer, "n_head": self.n_head, "resid_pdrop": self.dropout, "embd_pdrop": self.dropout, "attn_pdrop": self.dropout, "bos_token_id": self.vocab_size - 1, "eos_token_id": self.vocab_size - 1, }
项目自己定义 GPTConfig,再用这个方法单向映射到 HuggingFace 的 GPT2Config 字段名。
项目代码 ──只认──► GPTConfig(项目自己的) │ │ to_gpt2_kwargs() 单向映射 ▼ GPT2Config(HuggingFace 的)
好处:
n_positions/n_ctx 两个名字(其实是一回事),项目统一叫 block_size 更好懂。dropout 字段,映射到 HF 的三个独立 dropout 参数,避免不一致。bos_token_id = eos_token_id = 50256GPT-2 词表中第 50256 号 token 是 <|endoftext|>,它身兼三职:
本项目复用这一约定——推理时若没给 prompt,就用它作为起始 token(见 inference.py:64)。
@dataclass class TrainConfig: batch_size: int = 32 num_workers: int = 0 learning_rate: float = 3e-4 weight_decay: float = 0.1 betas: Tuple[float, float] = (0.9, 0.95) max_iters: int = 5000 warmup_iters: int = 100 grad_clip: float = 1.0 log_iter: int = 10 save_iter: int = 500 min_lr_ratio: float = 0.1 device: str = "auto" seed: int = 42 checkpoint_dir: str = "checkpoints"
按职能分组理解:
| 字段 | 默认 | 说明 |
|---|---|---|
batch_size |
32 | 每个梯度步用的样本数 |
num_workers |
0 | DataLoader 子进程数(Windows 建议 0) |
batch_size 影响:
| 字段 | 默认 | 说明 |
|---|---|---|
learning_rate |
3e-4 | 峰值学习率 |
weight_decay |
0.1 | 权重衰减系数 |
betas |
(0.9, 0.95) | AdamW 动量参数 |
💡 最重要的超参:
learning_rate。比模型架构还关键。第 5 章详讲 AdamW。
| 字段 | 默认 | 说明 |
|---|---|---|
max_iters |
5000 | 总训练步数 |
warmup_iters |
100 | 预热步数 |
💡 为什么是步数而不是 epochs?语言模型训练通常按「优化步数」控制,因为数据量远大于一个 epoch 所需。本项目用一个
while step < max_iters循环 + 数据迭代器重置来消费数据(见第 5 章)。
| 字段 | 默认 | 说明 |
|---|---|---|
grad_clip |
1.0 | 梯度裁剪的 L2 范数阈值 |
防梯度爆炸的「保险丝」。
| 字段 | 默认 | 说明 |
|---|---|---|
log_iter |
10 | 每 10 步打印一次 loss |
save_iter |
500 | 每 500 步存一次 checkpoint |
| 字段 | 默认 | 说明 |
|---|---|---|
min_lr_ratio |
0.1 | 余弦退火终点 lr 是峰值的 10% |
| 字段 | 默认 | 说明 |
|---|---|---|
device |
"auto" | 自动检测 CUDA |
seed |
42 | 随机种子 |
checkpoint_dir |
"checkpoints" | 存盘目录 |
本项目用「线性预热 + 余弦退火」,曲线长这样:
学习率 ↑ │ ╭───╮ ← 峰值 learning_rate (3e-4) │ ╱ ╲ │ ╱ ╲ │ ╱ ╲ │ ╱ ╲ │ ╱ ╰───╯ ← 终点 learning_rate × min_lr_ratio (3e-5) │╱ └─────────────────────→ 训练步数 0 warmup max_iters =100 =5000
三段论:
具体实现见 train.py:51-82 的 get_cosine_schedule_with_warmup,第 5 章会逐行讲数学。
train.py 用 argparse 把命令行参数映射到 config 字段:
# train.py 片段(完整见第 5 章) def apply_args_to_configs(args, gpt_config, train_config): if args.n_layer is not None: gpt_config.n_layer = args.n_layer if args.batch_size is not None: train_config.batch_size = args.batch_size # ...
所以你可以:
# 调模型规模 python train.py --n-layer 12 --n-embd 768 --n-head 12 # 调训练 python train.py --learning-rate 1e-4 --max-iters 10000 # 全自定义 python train.py --batch-size 16 --warmup-iters 200 --min-lr-ratio 0.05
没传的参数保持 dataclass 默认值——这是「配置即代码 + 命令行覆盖」的标准范式。
学完 config.py,自己写项目时遵守这些原则:
破坏性测试:把 n_embd 改成 100、n_head 改成 3,观察是否能构建模型(提示:100/3 不是整数)。
画学习率曲线:把 min_lr_ratio 改成 0.0,画一下学习率曲线会是什么样?改成 1.0 呢?
思考题:如果训练中途想换 learning_rate,直接改配置重启会丢失什么?
--resume(见第 5 章)。打印 config:在 Python 里跑:
from config import GPTConfig, TrainConfig print(GPTConfig()) print(TrainConfig()) print(GPTConfig().to_gpt2_kwargs())
观察输出,加深印象。
config.py 用 dataclass 定义 GPTConfig(模型)和 TrainConfig(训练)两组配置。GPTConfig.to_gpt2_kwargs() 把项目配置单向映射到 HuggingFace 命名,实现解耦。搞懂配置后,去 第 3 章 数据流水线 看文本是怎么变成模型能吃的 (x, y) 张量对的。