- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 为什么要亲手做一个小模型
- 实验环境
- 数据集 — TinyStories
- 模型结构 — 仅解码器 Transformer
- 训练循环
- 训练日志
- 生成结果
- perplexity 8 意味着什么
- 小结
- 🧠 理解度自测
- 参考资料
引言 — 为什么要亲手做一个小模型
讲解如何使用大语言模型的文章很多。但"里面到底发生了什么"却被藏在 API 之后。本系列反其道而行: 我们自己来训练 。
目标不是性能。条件是这样设定的。
- 参数 少于1亿 (实际上远少于此)
- 单张 GPU 上 20分钟以内 完成训练
- 只使用 Hugging Face 上公开的数据
- 完整保留 训练日志与产物
做小之后,整体结构一目了然。注意力为何必要、损失函数在测量什么、训练失败时会出现什么信号 —— 这些都能以分钟为单位观察到。
本篇是第一步, 文本生成模型 。
实验环境
实际使用的设备与版本。
| 项目 | 值 |
|---|---|
| GPU | NVIDIA GeForce RTX 3090 (24GB) |
| CPU / 内存 | 24线程 / 61GB |
| PyTorch | 2.11.0+cu128 |
| 驱动 / CUDA | 570.195.03 / 12.8 |
| 训练时间 | 901.2秒 (15分钟) |
数据集 — TinyStories
roneneldan/TinyStories 是一批只用三四岁孩子能理解的词汇写成的英文短篇童话。它非常适合小模型实验,原因如下。
普通的网络文本词汇量极大,小模型还没学会语法就把容量耗在记单词上了。TinyStories 刻意限制词汇量,从而让 小模型能把容量花在"如何造句"本身 。
分词器没有重新训练,而是直接借用了 GPT-2 的。只取用 Hub 上模型的一部分,也是很实用的做法。
from transformers import AutoTokenizer
from datasets import load_dataset
tok = AutoTokenizer.from_pretrained("openai-community/gpt2")
ds = load_dataset("roneneldan/TinyStories", split="train[:40000]")
ids = []
for ex in ds:
ids.extend(tok(ex["text"]).input_ids + [tok.eos_token_id])
if len(ids) >= 4_000_000:
break
data = torch.tensor(ids[:4_000_000], dtype=torch.long)
我们把 token 连接成一条长流,训练时从中随机切片。会出现跨越文档边界的样本,但 eos_token_id 标记了边界,模型因此学会"故事在这里结束"。
模型结构 — 仅解码器 Transformer
整个结构就这些:4层、嵌入维度 256、8个注意力头。
CTX, DIM, LAYERS, HEADS = 128, 256, 4, 8
class Block(nn.Module):
def __init__(self):
super().__init__()
self.ln1, self.ln2 = nn.LayerNorm(DIM), nn.LayerNorm(DIM)
self.attn = nn.MultiheadAttention(DIM, HEADS, batch_first=True)
self.mlp = nn.Sequential(
nn.Linear(DIM, 4 * DIM), nn.GELU(), nn.Linear(4 * DIM, DIM)
)
def forward(self, x, mask):
h = self.ln1(x)
x = x + self.attn(h, h, h, attn_mask=mask, need_weights=False)[0]
return x + self.mlp(self.ln2(x))
class TinyGPT(nn.Module):
def __init__(self, vocab):
super().__init__()
self.emb = nn.Embedding(vocab, DIM)
self.pos = nn.Embedding(CTX, DIM)
self.blocks = nn.ModuleList(Block() for _ in range(LAYERS))
self.ln = nn.LayerNorm(DIM)
mask = torch.triu(torch.full((CTX, CTX), float("-inf")), 1)
self.register_buffer("mask", mask)
def forward(self, x):
T = x.shape[1]
h = self.emb(x) + self.pos(torch.arange(T, device=x.device))
for b in self.blocks:
h = b(h, self.mask[:T, :T])
return self.ln(h) @ self.emb.weight.T # weight tying
这里有三个值得停下来看的设计决定。
没有因果掩码,训练就不成立
torch.triu(..., 1) 构造的上三角矩阵把对角线以上全部填成负无穷。经过 softmax 后这些位置的权重变为 0。也就是说,它的作用是 让每个位置看不到排在自己后面的 token 。
如果没有它会怎样?模型要解决的是预测下一个 token 的问题,而正确答案已经作为输入被它看到了。损失会掉到接近 0,但完全无法生成。 "损失掉得太快"通常是答案泄漏的信号 。
权重共享 — 省下40%的参数
最后一行的 @ self.emb.weight.T 就是权重共享 (weight tying)。不另设输出层,而是转置并复用输入嵌入矩阵。
GPT-2 的词表有 50,257 个 token。若另设输出层,需要额外 50257 × 256 ≈ 1287万 个参数。整个模型是 1606万个,所以 不共享的话会变成 2893万个 。而且"把 token 变成向量"和"把向量还原成 token"是互为逆向的同一件事,用同一个矩阵本就自然。
Pre-norm 布局
self.ln1(x) 在注意力 之前 应用,残差连接加到未归一化的 x 上。这种顺序 (pre-norm) 在更深的模型中比 post-norm 训练更稳定。4层时差别不大,但我们遵循了惯例。
训练循环
model = TinyGPT(len(tok)).to("cuda")
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01)
step = 0
while not run.over_budget(): # 15分钟预算
ix = torch.randint(0, len(data) - CTX - 1, (64,))
x = torch.stack([data[i:i + CTX] for i in ix]).to("cuda")
y = torch.stack([data[i + 1:i + CTX + 1] for i in ix]).to("cuda")
with torch.autocast("cuda", torch.bfloat16):
loss = F.cross_entropy(model(x).flatten(0, 1), y.flatten())
opt.zero_grad(); loss.backward(); opt.step()
step += 1
关键就是 x 和 y 错开一位。输入是 "Once upon a",目标就是 "upon a time"。所有位置同时预测,因此一个长度 128 的样本能产生 128 个训练信号。
通过 torch.autocast 使用 bfloat16。在 3090 上显存和速度都有收益,而且 bf16 与 float16 不同,指数范围和 float32 一致,无需损失缩放也很稳定。
训练日志
实际记录到的数值。
[ 0.6s] step 0 loss=168.4468 ppl=...
[ 38.2s] step 1000 loss=5.2213 ppl=185.13
[ 114.7s] step 3000 loss=3.6841 ppl=39.81
[ 305.9s] step 6700 loss=3.0350 ppl=20.80
[ 662.6s] step 16100 loss=2.2882 ppl=9.86
[ 901.2s] step 22358 loss=2.0948 ppl=8.12
- 共 22,358 步 ,批量 64、上下文 128,约处理了1.8亿个 token
- 起始损失 168 是初始化刚结束时的值。若为均匀分布应得到
ln(50257) ≈ 10.8,LayerNorm 与权重共享的相互作用使第一步出现较大值,随后立刻回到正常区间 - 最终 perplexity 8.12 意味着 把下一个词的候选平均收窄到了8个左右
生成结果
温度 0.8 采样的真实输出,未做任何修改。
PROMPT: Once upon a time
Once upon a time, there was a little boy named Timmy. Timmy liked to play
with his toy cars instead. One day, Timmy wanted to play with the red cars,
but he couldn't find it.
As Timmy was leaving a fight on the slide, he saw a big tree. Timmy thought
it looked like fun, so he started to tremble. He mixed the leaves and
PROMPT: Lily went to the
Lily went to the park with her mom. They saw a big tree, a hole and a hole.
They were curious and wanted to see what was inside.
They heard a hole in the hole. It was a zipper, but it was in the hole.
让我们诚实地读这些结果。
做得好的地方 — 语法几乎完美。时态一致、冠词、代词指代都正确。人物名字在整段中保持一致。引号开了就会闭。段落划分也自然。
没做好的地方 — 语义崩塌。"a big tree, a hole and a hole" 重复同一个词,"leaving a fight on the slide" 语法成立但讲不通,"he started to tremble" 与前后毫无衔接。
这个对比很关键。 语法是局部模式,小模型也能学会;而连贯性是长程依赖,需要容量和上下文。 上下文只有 128 个 token,模型能回顾的范围本身就很短。把模型做大后最先改善的,正是这一部分。
perplexity 8 意味着什么
perplexity 是 exp(cross_entropy)。直观地说就是 "模型把下一个 token 收窄到了几个候选" 。
- 训练前 (均匀分布):50,257
- 1,000 步后:185
- 最终: 8.12
从 50,257 个里挑,变成从 8 个里挑。不过这个数字不能直接和别的模型比较。perplexity 强烈依赖于分词器和数据分布,像 TinyStories 这样词汇受限的数据很容易得到低值。 它只有在相同数据、相同分词器之内比较时才有意义 。
小结
| 项目 | 值 |
|---|---|
| 参数量 | 16,058,112 (16.1M) |
| 训练时间 | 901.2秒 |
| 步数 | 22,358 |
| 处理 token | 约1.8亿 |
| 最终损失 | 2.0948 |
| 最终 perplexity | 8.12 |
1600万参数、15分钟、电费不过几毛钱。就这样得到了一个能写出合乎语法的英文童话的模型。
把收获压缩成三句话。因果掩码是防止答案泄漏的机制,损失掉得异常快时应当首先怀疑它。权重共享在词表大的模型中能省下近一半参数。语法和语义是难度不同的两个问题,小模型会先学会前者。
下一篇我们来看 一个模型如何同时处理文本和图像 。把像素当作 token,同一个 Transformer 就能既生成图像又描述图像。
🧠 理解度自测
1. 漏掉因果掩码后,训练曲线会出现什么症状?
损失会异常快速地掉到接近 0。因为模型要预测的下一个 token 已经出现在输入里了。训练指标看起来完美,但完全无法生成。当损失掉得比预期快得多时,应该首先怀疑答案泄漏。
2. 权重共享在这里省下了多少参数?
约1287万个。即 GPT-2 词表 50,257 乘以嵌入维度 256。整个模型是 1606万个,所以不共享的话会变成 2893万个,接近减半。
3. 可以把 perplexity 8.12 直接与其他论文的 perplexity 比较吗?
不可以。perplexity 依赖于分词器和数据分布。像 TinyStories 这样词汇受限的数据容易得到低值,换一个分词器同一个模型的数值也会变。只有在相同数据和相同分词器之内比较才有意义。
4. 为什么生成结果语法正确但语义崩塌?
语法是几个词范围内的局部模式,小模型也能通过统计学到。而故事的连贯性需要跨越整个段落的长程依赖。本次实验的上下文是 128 个 token,模型可回溯的范围本身就短,容量也不足。