gpt.py --- 纯 Python 手写的字符级 GPT
不依赖任何第三方库,只用 Python 标准库,从标量级自动求导开始,手写一个字符级 GPT。
核心思想(与 GPT-4 一致):
根据已有上下文,预测下一个字符(token)。
区别只是规模:本项目约 1.3 万参数、一份 names.txt、CPU 上训练;GPT-4 是万亿参数、TB 级数据、数千 GPU。
一、模型在做什么
模型是一个"接龙机器":给它前面若干个字符,它预测下一个字符最可能是什么。
训练数据是一堆中文名字。模型从名字里学习"看到『李』之后,下一个字更可能是哪些字"这样的规律,训练完成后可以按姓氏生成新名字。
二、整体流程
makefile
首次运行:
names.txt → 建词表 → 随机初始化参数 → 训练 1000 步 → 保存 gpt_weights.json → 交互起名
之后运行:
gpt_weights.json → 加载参数 → 交互起名(不再训练)
重新训练:
删除 gpt_weights.json 再运行
训练与推理分离:训练把参数从随机数调成"有知识";推理用固定参数处理新输入。
如何运行和使用本项目
1. 环境准备
本项目只依赖 Python 标准库,无需安装任何第三方包。
bash
# 确认 Python 版本(3.8+ 即可)
python3 --version
2. 准备训练数据
在项目根目录创建 names.txt,每行一个名字(用于学习"字与字"的接龙规律):
erlang
李雷
韩梅梅
李娜
王芳
张伟
...
数据越多、越多样,生成名字越自然。空行会被自动跳过。
3. 运行
bash
python3 gpt.py
- 首次运行 :检测到没有
gpt_weights.json→ 读取names.txt→ 建词表 → 随机初始化参数 → 训练 1000 步(终端实时显示loss下降)→ 保存gpt_weights.json→ 进入交互起名。 - 之后运行 :检测到已有
gpt_weights.json→ 直接加载参数 → 跳过训练 → 进入交互起名。
4. 交互起名(怎么用)
进入交互模式后,按提示输入姓氏即可:
bash
👤 你: 我姓李
🤖 为你起的名字: 李雷明
👤 你: 王
🤖 为你起的名字: 王梓涵
👤 你: exit
再见 👋
支持的输入写法:我姓李 / 姓李 / 李姓 / 李(单字即视为姓氏)。 输入 exit / quit / 退出 / q 结束程序。
5. 重新训练
想用新的 names.txt 重训,删掉权重文件再运行即可:
bash
rm gpt_weights.json
python3 gpt.py
6. 想改模型大小 / 训练步数?
- 训练步数:改
gpt.py里main()中的num_steps = 1000。 - 模型规模:改
gpt.py顶部的N_EMBD/BLOCK_SIZE/N_LAYER/N_HEAD(改后需重新训练,旧权重不兼容)。 - 生成随机性:交互模式默认
temperature=0.7;想更保守改小、更发散改大(位于interactive_mode调用generate_one处)。
三、参数(state_dict)
init_state_dict(vocab_size) 初始化模型的全部参数(即模型所有可学习参数的集合)。每个参数是一个矩阵,用正态分布随机数填充(std=0.08)。
| 参数 | 形状 | 作用 |
|---|---|---|
wte |
[vocab_size × n_embd] |
Token Embedding:每个字符一行向量,作为"字的含义" |
wpe |
[block_size × n_embd] |
Position Embedding:每个位置一行向量,作为"第几位" |
lm_head |
[vocab_size × n_embd] |
输出投影:把最终向量映射回词表,得到每个字作为"下一个字"的分数 |
layer{i}.attn_wq/wk/wv/wo |
[n_embd × n_embd] |
注意力的 Q/K/V 投影与输出投影 |
layer{i}.mlp_fc1/fc2 |
[4·n_embd × n_embd] / [n_embd × 4·n_embd] |
前馈网络两层全连接 |
超参数(gpt.py 顶部):
N_EMBD = 16:每个 token 用 16 维向量表示BLOCK_SIZE = 16:模型最多能"看" 16 个字符N_LAYER = 1:Transformer 堆叠层数N_HEAD = 4:注意力头数
本质上可以理解为:被查的表+被用来后面用来计算的矩阵(所有查表(wte/wpe)+ 所有被用来计算的矩阵(attn_/mlp_/lm_head)) 此参数的设置依据是魔法gpt 设置的,其科学来源是参数的结构是按 Transformer 标准架构设计的(学术界定的骨架):《Attention Is All You Need》[arxiv.org/abs/1706.03...](https://link.juejin.cn?target=https%3A%2F%2Farxiv.org%2Fabs%2F1706.03762 "https://arxiv.org/abs/1706.03762") (重塑AI 的祖师爷),
BLOCK_SIZE = 16
模型一次最多能"看"多少个字(上下文窗口上限),这里只是为了演示设置16的窗口,正式的大模型窗口更大。大模型长会话会出现幻觉或者上下文丢失,主要就是这个原因。对话超过上下文窗口能看的范围,最早的消息就会被丢弃,这是最常被说的"丢失上下文"。
四、单次前向传播(gpt 函数)
输入当前字符 token_id 和位置 pos_id,输出下一个字符的 logits(每个字一个分数)。
vbnet
Step 1 Token Embedding:查 wte 得到字符向量
Step 2 Position Embedding:查 wpe 得到位置向量,加到字符向量上
Step 3 RMSNorm 归一化
Step 4 Transformer Block × N_LAYER:
├─ 注意力块(见下)
└─ MLP 块(见下)
Step 5 LM Head:用 lm_head 把向量投影回词表,得到每个字的 logits
注意力块
把同一输入投影成三个视角:
Q(Query):"我要找什么"K(Key):"我是什么特征"V(Value):"我贡献什么信息"
流程:
- 用
attn_wq/wk/wv把输入投影成 Q/K/V - 把当前字符的 K、V 存入缓存(因为注意力:只能看过去的字符)
- 多头注意力:每个头计算
Q·K / √d得到匹配分数 → softmax 变权重 → 按权重对 V 加权求和("该关注前面哪些字"在此决定) - 用
attn_wo把多头输出投影回去,加残差连接
MLP 块
对注意力收集到的信息做深度加工:
fc1(16 → 64 维)→ ReLU(负数归零,引入非线性)→ fc2(64 → 16 维)→ 加残差
残差连接
注意力块和 MLP 块都先保存输入,处理完后再加回原输入(x = F(x) + x),保证原始信息不丢失、梯度可直达底层。
五、训练循环(train 函数)
目标:把随机参数调成"懂起名"的参数。每步重复三件事:
1. 前向传播(Forward)
- 取一个名字,包成
[BOS, 字1, 字2, ..., 字n, BOS]的 token 序列 - 逐位置让模型根据当前字符预测下一个字符
- 计算交叉熵 loss:
-log(P(正确答案))。正确答案概率越高,loss 越小
2. 反向传播(Backward)
loss.backward()从总 loss 沿计算图反推,算出每个参数对错误的"责任"(梯度 grad)- 梯度为正表示该调小,为负表示该调大,接近 0 表示无关
3. 参数更新(Adam)
- 用 Adam 优化器按梯度调整参数:
θ = θ - lr · m̂ / (√v̂ + ε) m记梯度方向,v记梯度大小,学习率随步数衰减(先大步走、后小步精修)- 更新后清零梯度,进入下一步
训练 num_steps = 1000 步后,loss 从高变低,参数存入 gpt_weights.json。
最后的目的,一个字推到下个字的,链接线的向量,这条线是多条的多维度的,粗细不同,然后把这不同的线存下来,一个字在模型里是多维向量,它通过多层多条线(embedding、attention、mlp、lm_head)加权。(主要用到了:熵(Entropy) H(P) 交叉熵 H(P,Q) KL 散度 ,去计算链接线的粗细或者说"肌肉记忆")。
六、推理 / 生成(generate_one)
训练完成后只做前向传播,不再更新参数:
- 把姓氏作为前缀喂入模型(强制作为开头)
- 自回归逐字生成:每一步取 logits,按 softmax 概率采样下一个字符(概率生成下个字,而非始终一个字,是永远选最大的,而是按概率随机选)
- 遇到 BOS 或达到最大长度则停止
temperature控制随机性:越小越保守,越大越多样
交互模式
interactive_mode循环读取用户输入,提取姓氏后生成多个候选名字去重输出。 本质就是:用训练存下的线(参数),多维度的线,整合多条线,根据实际情况推出下个字(向前推导)
流程图
ini
┌─────────────────────────────────────────────────────────────┐
│ ① 初始化(随机脑子) │
│ init_state_dict → create_matrix │
│ 13824 根线,每根是 随机小数(标准差0.08) │
│ state_dict = {wte, wpe, attn_wq/wk/wv/wo, mlp_fc1/fc2, │
│ lm_head} │
└───────────────────────────────┬─────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ ② 训练(调线 1000 次) │
│ │
│ for step in range(1000): │
│ doc = docs[step % len] # 拿一个名字,如"李雷" │
│ tokens = [BOS, 李, 雷, BOS] │
│ │
│ ┌── 前向:顺着线算分(不存分,只算)──────────┐ │
│ │ for pos: │ │
│ │ logits = gpt(当前字, pos, 缓存, sd) │ │
│ │ probs = softmax(logits) → 每个下个字可能性│ │
│ │ loss += -log(probs[正确答案]) │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ 反向:loss.backward() → 每根线算"责任"grad │
│ 更新:p.data -= lr * ... → 加粗/削细相关线 │
│ ("李→雷"线加粗,"李→梅"线削细) │
│ │
│ ★ 重复1000次 → 线定型 = 肌肉记忆 │
└───────────────────────────────┬─────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ ③ 存线(固化记忆) │
│ save_weights(state_dict) → gpt_weights.json │
│ 只存 13824 个参数数字(线粗细),不存分数/计算图 │
└───────────────────────────────┬─────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ ④ 推理/生成(用线接龙) │
│ generate_one(姓氏="李") │
│ │
│ 阶段1:喂姓氏 │
│ 把"李"走一遍gpt → 存进KV缓存(模型"看到"李,不输出) │
│ │
│ 阶段2:自回归接龙 │
│ token = BOS │
│ loop: │
│ logits = gpt(token, pos, 缓存, sd) # 顺着定型线整合算 │
│ probs = softmax(logits / temperature) │
│ token = sample_weighted(probs) # 按可能性抽一个字 │
│ if token == BOS: break # 名字结束 │
│ seq.append(抽到的字) │
│ │
│ 例:李 →(雷0.4,娜0.3)→ 抽到"雷" → 李雷 → 抽到"明" → 李雷明 │
│ → 抽到BOS → 停止 → 输出"李雷明" │
└─────────────────────────────────────────────────────────────┘
七、本项目与真实大模型的关系
| gpt.py | GPT-4 级别 | |
|---|---|---|
| 参数初始化 | 正态分布随机填表(std=0.08) |
正态分布随机填表(更精细的缩放) |
| 核心结构 | Transformer + Attention + MLP | 同左 |
| 自动求导 | 手写标量级 Value 引擎 | 框架内置张量级自动求导 |
| 参数量 | ~1.3 万 | 万亿级 |
| 维度 | n_embd=16 | 12288+ |
原理完差不多,规模天差地别。理解本项目的 gpt() 函数,就理解了大模型前向传播的主体逻辑。
八、关键心智模型
scss
模型 = 有很多可调数字的函数 → state_dict
训练 = 用数据不断调整这些数字 → train()
loss = 模型当前错得多严重 → 交叉熵
梯度 = 每个参数对 loss 的责任 → backward()
优化器 = 根据梯度执行参数调整 → Adam
推理 = 用固定参数处理新输入 → generate_one()