先把机器装起来转动,聪明是后面几篇的事
这一篇不求聪明,只求循环转起来
第一篇说过,训练是个循环:喂真实文字、看模型猜错在哪、按错误方向拧旋钮,反复上万遍。前两篇我们把数据准备好了。这一篇要做的,是把这个循环第一次真正搭起来、让它转动。
为了把注意力集中在"循环"本身,这一篇的模型会故意做得非常笨——它只看前一个字来猜下一个字。这种模型叫 Bigram(二元)模型。它学不到什么真本事,但没关系。这一篇的目标不是聪明,是让"喂数据、算损失、拧旋钮"这套机器先转起来。聪明,是第五篇之后的事。
一次喂多少:批次和上下文长度
训练时,我们不会把整份语料一次性塞给模型,那既装不下也没必要。每一步只喂一小块。这就牵出两个数字。
一个是上下文长度 block_size:模型一次最多看多长的文字。我们先设 8,表示模型每次根据最多 8 个字来预测。
另一个是批次大小 batch_size:为了效率,每一步同时喂好几小块,让模型并行处理。设 32,表示一次喂 32 小块。
关键在于,每一小块要配一个"正确答案"。我们从语料里随机抠出一段长 block_size 的文字作为输入 x,再把这段文字整体往后挪一个字作为答案 y。这样一来,x 的每一个位置,它对应的"下一个字"正好就是 y 同一位置上的字。
import torch
torch.manual_seed(1337) # 固定随机种子,让结果可复现
block_size = 8
batch_size = 32
def get_batch(split: str):
"""随机抠出 batch_size 个小块,返回输入 x 和答案 y"""
data = train_data if split == "train" else val_data
# 随机选 batch_size 个起始位置
ix = torch.randint(len(data) - block_size, (batch_size,))
x = torch.stack([data[i:i + block_size] for i in ix])
y = torch.stack([data[i + 1:i + block_size + 1] for i in ix]) # 整体后挪一位
return x, y
xb, yb = get_batch("train")
print(f"输入 x 的形状:{xb.shape}") # (32, 8)
print(f"答案 y 的形状:{yb.shape}") # (32, 8)
print(f"第一小块 x:{xb[0].tolist()}")
print(f"第一小块 y:{yb[0].tolist()}")
对照打印出来的第一小块,你会发现 y 就是 x 错开一位的结果。x 的第 0 个号码后面应该接什么?答案是 y 的第 0 个号码。x 的第 1 个号码后面接什么?看 y 的第 1 个。一小块长度 8,里面其实藏了 8 道"预测下一个字"的题。
最笨的模型:Bigram
现在搭模型。在 PyTorch 里,模型是一个继承 nn.Module 的类。Bigram 模型只需要一样东西:一张表。
import torch.nn as nn
from torch.nn import functional as F
class BigramLanguageModel(nn.Module):
def __init__(self, vocab_size: int):
super().__init__()
# 一张 vocab_size × vocab_size 的表,这就是模型的全部"旋钮"
self.token_embedding_table = nn.Embedding(vocab_size, vocab_size)
def forward(self, idx, targets=None):
# idx 形状 (B, T),每个元素是一个字的号码
logits = self.token_embedding_table(idx) # 查表,得到 (B, T, vocab_size)
if targets is None:
loss = None
else:
B, T, C = logits.shape
logits = logits.view(B * T, C)
targets = targets.view(B * T)
loss = F.cross_entropy(logits, targets)
return logits, loss
nn.Embedding(vocab_size, vocab_size) 是一张 vocab_size 行、vocab_size 列的表。每个字的号码对应其中一行,这一行有 vocab_size 个数,代表"看到这个字之后,下一个字是词表里各个字的可能性打分"。
这就是它笨的地方:预测只查了当前这一个字对应的那一行,完全没看再往前的内容。"今天天气真"这五个字,它只拿最后一个"真"去查表,前面四个字直接忽略。这台机器只有一个字的记忆。
代码里 B, T, C 是三个维度:B 是批次大小(batch,32),T 是时间步也就是序列长度(time,8),C 是通道数也就是每个位置的打分个数(channel,等于 vocab_size)。这套 (B, T, C) 记法后面每一篇都会用,先混个眼熟。
模型怎么"打分":logits、softmax 与损失
forward 里出现了三个新词,得讲清楚,它们是训练的核心。
第一个是 logits。模型对"下一个字"的预测,不是直接报一个字,而是给词表里每个字打一个分。这一串原始分数就叫 logits。分数有高有低,可正可负。
第二个是 softmax。logits 是原始分数,不直观。softmax 是一个函数,它把这一串分数换算成一组概率——全都在 0 到 1 之间、加起来正好等于 1。这样我们就能说"下一个字是'好'的概率是 0.42"。
第三个是损失(loss)。我们需要一个数字来衡量"模型这次猜得有多差"。这个数字就是损失。这里用的损失函数叫交叉熵(cross entropy),它的逻辑很朴素:看模型给"正确答案那个字"打出的概率有多高。概率给得高,损失就低;概率给得低,损失就高。模型把正确答案的概率压到接近 0,损失会冲到很大。
整个训练,就是想方设法让这个损失数字变小。损失小,等于模型给正确答案的概率高,等于它猜得准。
这里有个能拿来对账的细节。训练刚开始,旋钮是随机的,模型对词表里每个字几乎一视同仁地瞎猜,给正确答案的概率大约是 1/vocab_size。此时交叉熵损失约等于 ln(vocab_size)。假设你的 vocab_size 是 3000,那一开始的损失应该在 8 上下(ln(3000) ≈ 8)。等会儿跑起来,如果初始损失和这个数对得上,说明一切正常。
让它生成文字
模型还要能写字。给 Bigram 加一个 generate 方法——就是第一篇说的"接龙":预测下一个字,接上,再拿去预测,循环。
def generate(self, idx, max_new_tokens: int):
# idx 是当前已有的文字(号码形式),形状 (B, T)
for _ in range(max_new_tokens):
logits, _ = self(idx) # 前向,拿到打分
logits = logits[:, -1, :] # 只要最后一个位置的打分
probs = F.softmax(logits, dim=-1) # 换算成概率
idx_next = torch.multinomial(probs, num_samples=1) # 按概率抽一个字
idx = torch.cat((idx, idx_next), dim=1) # 接到后面
return idx
注意 torch.multinomial 这一步:它不是死板地选概率最高的字,而是按概率随机抽。概率 0.42 的字有 42% 的机会被抽中。这点随机性让模型每次生成的内容不完全一样,也更自然。
训练循环:核心就五行
万事俱备。训练循环本身,核心其实只有五行:
model = BigramLanguageModel(vocab_size)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
for step in range(5000):
xb, yb = get_batch("train") # 1. 取一批数据
logits, loss = model(xb, yb) # 2. 前向:算预测、算损失
optimizer.zero_grad(set_to_none=True) # 3. 清空上一轮的梯度
loss.backward() # 4. 反向:算出每个旋钮该往哪拧
optimizer.step() # 5. 真正拧动旋钮
if step % 500 == 0:
print(f"step {step}: loss {loss.item():.4f}")
逐行说。第 1 行取一批数据。第 2 行把数据喂进模型,得到打分和损失——这一步叫前向传播。第 3 行先把上一轮残留的梯度清零(为什么要清,下一篇讲)。第 4 行 loss.backward() 是关键,它会算出"每一个旋钮,应该往哪个方向、拧多少",这个过程叫反向传播。第 5 行 optimizer.step() 按算出来的方向,真正把所有旋钮拧动一次。
optimizer 是优化器,负责"拧"这个动作。lr=1e-3 是学习率,控制每次拧多大幅度。loss.backward() 和优化器内部到底怎么工作的,正是下一篇——反向传播——的全部内容。这一篇你先把它当成"按错误方向拧旋钮"的黑盒,让循环转起来。
跑起来看看
把前两篇的数据准备代码、加上这一篇的全部代码,存成一个文件跑起来。你会看到损失打印出来,从一开始的 8 左右,一步步往下掉,降到 3 到 4 之间稳住。
损失在下降,就证明那套机器真的转起来了——模型在被一步步拧得"猜得更准"。
训练完,让它生成一段看看:
context = torch.zeros((1, 1), dtype=torch.long) # 从号码 0 这个字起头
generated = model.generate(context, max_new_tokens=300)[0].tolist()
print(decode(generated))
别期待太高。Bigram 生成的东西,还是基本读不通的。但仔细看,它和训练前纯粹的乱码已经不同了——常见的字出现得多了,标点的位置不再完全离谱,单看相邻两个字偶尔还算搭。这正是它的能力上限:它只有一个字的记忆,能学到的也就是"哪个字后面常跟哪个字"这种最表层的规律。
但这不重要。重要的是:喂数据、算损失、反向传播、拧旋钮——这套循环,已经完整地、正确地转起来了。后面几篇要做的,全部是把模型从"只看一个字"升级成"看得见整段上下文",而这套训练循环的骨架,几乎一行都不用再改。
本篇要点
- 训练时数据分小块喂入,
block_size是一次看多长,batch_size是一次喂几块。 - 输入
x和答案y的关系是"整体错开一位",一小块里藏着多道"预测下一个字"的题。 - Bigram 模型只用一张
vocab_size × vocab_size的表,只看前一个字,所以很笨。 - logits 是模型对每个字的原始打分,softmax 把它换算成概率,交叉熵损失衡量"猜得有多差"。
- 训练循环核心五行:取数据、前向算损失、清梯度、反向传播、拧旋钮。
- Bigram 生成的文字仍读不通,但训练循环已完整转起来,这就是这一篇的目标。
下一篇
这一篇里,loss.backward() 和 optimizer.step() 是当黑盒用的。但它们恰恰是"模型怎么知道该往哪改"的答案所在。下一篇专门拆开这个黑盒,讲反向传播——整个训练的心脏。
参考资料
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:最笨的模型:先让训练循环转起来
本文链接:https://www.sshipanoo.com/blog/ai/mini-gpt/03-最笨的模型先让循环转起来/
本文最后一次更新为 天前,文章中的某些内容可能已过时!