前六篇的零件,这一篇全部接上电
把所有零件接上电
走到这里,所有零件都齐了。第二篇把数据变成了数字,第三篇做出了训练循环,第四篇讲透了循环的心脏,第五、六篇把模型从笨 Bigram 升级成了完整的 GPT。
这一篇做一件事:把它们全部接上电,完整训练这个迷你 GPT,然后亲眼看着它从输出乱码,变成能写出像模像样的句子。这是整个系列最有成就感的一篇。
超参数:这台机器的全部刻度
训练之前,要把所有超参数定下来。超参数就是那些不靠训练学习、需要我们自己设定的数值——模型多大、训练多久、学习率多少。把它们集中放在脚本开头:
import torch
# ---- 训练相关 ----
batch_size = 32 # 一次喂几小块
block_size = 32 # 上下文长度,一次看多长
max_iters = 5000 # 总共训练多少步
eval_interval = 500 # 每隔多少步检查一次损失
eval_iters = 200 # 每次检查时,平均多少批来估算损失
learning_rate = 3e-4 # 学习率
# ---- 模型相关 ----
n_embd = 64 # 字向量长度
n_head = 4 # 注意力头数量
n_layer = 4 # Transformer 层数
dropout = 0.1 # dropout 比例
# ---- 设备 ----
device = "cuda" if torch.cuda.is_available() else "cpu"
torch.manual_seed(1337)
这些数字现在先照抄,跑通之后你可以自己调着玩。device 那行会自动判断有没有显卡,有就用显卡,没有就用 CPU。
测损失要测准:estimate_loss
第三篇训练时,我们直接打印每一步的 loss。但单步的损失是抖动的——这一步刚好抽到简单的数据损失就低,下一步抽到难的就高,看着忽上忽下,判断不准模型到底有没有进步。
更可靠的办法是:每隔一段时间,专门抽很多批数据、把损失平均一下,得到一个平稳的估计。而且要在训练集和验证集上各测一次——还记得第二篇留的验证集吗?现在它要派上用场,用来识破过拟合。
@torch.no_grad() # 这段不算梯度,省内存也更快
def estimate_loss():
out = {}
model.eval() # 切到评估模式
for split in ["train", "val"]:
losses = torch.zeros(eval_iters)
for k in range(eval_iters):
x, y = get_batch(split)
x, y = x.to(device), y.to(device)
_, loss = model(x, y)
losses[k] = loss.item()
out[split] = losses.mean().item()
model.train() # 切回训练模式
return out
两个细节。@torch.no_grad() 告诉 PyTorch 这段只是看一眼、不训练,不用记录反向传播要用的东西,省内存、更快。model.eval() 和 model.train() 是切换模式——第六篇加进去的 dropout 这个零件,在训练时和评估时行为不同,所以测损失前要切到 eval、测完切回 train。
完整训练脚本
主角登场。把前几篇的代码(数据准备、get_batch、GPTLanguageModel 及它的几个零件类)都放在前面,然后接上训练主循环:
# 创建模型,搬到 device 上
model = GPTLanguageModel().to(device)
print(f"模型参数量:{sum(p.numel() for p in model.parameters()) / 1e6:.2f} M")
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)
for iter in range(max_iters):
# 每隔一段,估算并打印训练/验证损失
if iter % eval_interval == 0 or iter == max_iters - 1:
losses = estimate_loss()
print(f"step {iter}: train loss {losses['train']:.4f}, "
f"val loss {losses['val']:.4f}")
# 训练循环的核心五行,和第三篇一字不差
xb, yb = get_batch("train")
xb, yb = xb.to(device), yb.to(device)
_, loss = model(xb, yb)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
注意训练核心还是第三篇那五行:取数据、前向、清梯度、反向、迈步。从 Bigram 到完整 GPT,模型脱胎换骨,但训练循环的骨架没动过——这正是第四篇说的,"怎么训练"和"模型长什么样"是两条独立的线。.to(device) 那几行是把模型和数据都搬到显卡(或 CPU)上,两者必须在同一个设备上才能运算。
跑之前先看一眼打印出来的参数量,这个迷你 GPT 大约是零点几个 M(百万)。作为对照,GPT-3 是 1750 亿。结构相同,规模差了十万倍。
看着它学会写字
跑起来。在 CPU 上大约几分钟到十几分钟,有显卡会快很多。你会看到损失这样下降:
step 0: train loss 8.21, val loss 8.20
step 500: train loss 5.12, val loss 5.19
step 1000: train loss 4.03, val loss 4.18
step 3000: train loss 3.21, val loss 3.55
step 4999: train loss 2.86, val loss 3.40
光看数字不够过瘾。真正有意思的,是在训练的不同阶段让模型生成一段,看它的变化。可以在主循环里每隔一段就调一次 generate。
刚开始(step 0),模型旋钮是随机的,生成的是纯粹的乱码,字和字之间毫无关系。训练到中途(step 1000 左右),它开始像样:常用字出现得对了,标点位置基本合理,偶尔能蹦出一两个通顺的词。训练到后期(step 5000),如果你的语料是一部小说,它已经能生成断句正常、用词搭调、看着真有那么点小说味儿的段落了——虽然细看逻辑还是不通,但"像那么回事"。
# 训练结束后,让它生成一段
context = torch.zeros((1, 1), dtype=torch.long, device=device)
generated = model.generate(context, max_new_tokens=500)[0].tolist()
print(decode(generated))
停下来体会一下这件事。你没有给它写过任何一条语法规则,没告诉它"句子要有主谓宾"、没教过它一个成语。你只是让它做了五千遍"猜下一个字、按错误方向拧旋钮"。语言的规律,是它自己从那份文字里一点点摸出来的。第一篇说的"训练",到这里你算是亲眼见过了。
用上显卡
如果你有 NVIDIA 显卡,前面的代码其实已经在用了——device 那行自动选了 cuda,模型和数据也都 .to(device) 搬了过去。显卡的优势是能高度并行地做矩阵运算,而 GPT 内部全是矩阵运算,所以训练能快上几倍到几十倍。
没有显卡也完全不耽误这个系列:我们的模型小,CPU 几分钟就训完了。显卡的意义要到模型做大时才真正显现——这也正是为什么训练真正的大模型,需要成千上万张显卡。
过拟合:训练集好、验证集差
回头看那组损失数字,留意 train loss 和 val loss 的差距。前期两者贴得很近,到后期 train loss 2.86、val loss 3.40,验证损失明显比训练损失高了。
这就是第二篇预告过的过拟合。差距拉开,说明模型开始"死记硬背"训练集里的内容,而不是学习通用的语言规律——它在背过的文字上表现好,一遇到验证集里没背过的就差。差距越大,背书越严重。
有几个常用的办法压制过拟合。一是 dropout,就是第六篇加进去、这一篇设成 0.1 的那个零件:它在训练时随机让一部分数值临时失效,逼模型不能依赖某几条固定通路、必须学得更稳健。二是控制模型大小,模型相对语料太大就容易背书,可以减小 n_layer 或 n_embd。三是增加语料,原料越多越难背完。
判断的标准就是盯着这两个损失:验证损失还在跟着降,就继续训;验证损失不降反升、和训练损失越拉越开,就该停了,再训只是在加深背书。
把训练成果存下来
训练好的模型,那套被拧到位的旋钮,得存下来,不然程序一关就白训了。
# 保存:把模型所有参数存成一个文件
torch.save(model.state_dict(), "mini_gpt.pt")
print("模型已保存到 mini_gpt.pt")
model.state_dict() 是模型当前所有参数的集合,torch.save 把它写进文件。这个 mini_gpt.pt,就是第一篇说的"模型权重"——训练的全部成果,凝结成的一个文件。你下载任何一个开源大模型,下回来的也正是这样一个权重文件,只是大得多。
下次要用,不必重新训练,直接加载:
model = GPTLanguageModel().to(device)
model.load_state_dict(torch.load("mini_gpt.pt"))
model.eval() # 加载后切到评估模式,准备生成
先创建一个结构相同的空模型,再把存好的参数灌回去。这一灌,那台机器的几十万个旋钮就回到了训练结束时的位置,模型立刻恢复能力。训练和使用,就此分开:训练做一次,权重存下来,之后随用随加载。
到这里,你已经完整地、从零地训练出了一个能生成文本的 GPT,并且把它存了下来。第一篇那个抽象的问题——"模型到底是怎么训练出来的"——你现在有了一个具体的、自己跑通过的答案。
本篇要点
- 超参数是需要自己设定的数值(模型大小、训练步数、学习率等),集中放在脚本开头便于调整。
- 单步损失抖动大,要用
estimate_loss抽多批求平均,并在训练集和验证集上分别测。 - 训练核心循环和第三篇一字不差,从 Bigram 到完整 GPT,"怎么训练"这条线没变过。
- 训练中分阶段生成文本,能直观看到模型从乱码到通顺的过程,语言规律是它自己摸出来的。
- 训练损失远低于验证损失就是过拟合,可用 dropout、控制模型大小、增加语料来压制。
torch.save(model.state_dict(), ...)保存的文件就是"模型权重",训练和使用就此分开。
下一篇
你训练出的,是一个会"续写文字"的迷你 GPT。但 ChatGPT 不只会续写,它会听话、会对答、有分寸。最后一篇讲清楚:从我们做的这个迷你 GPT,到真正的 ChatGPT,中间还隔着哪些关键步骤。
参考资料
- nanoGPT 项目源码
- PyTorch 模型保存与加载
- 博客内 ml-basics 系列《过拟合与欠拟合》《Dropout 详解》《超参数调优》
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:完整训练:看它从胡言乱语到像模像样
本文链接:https://www.sshipanoo.com/blog/ai/mini-gpt/07-完整训练看它从胡言乱语到像模像样/
本文最后一次更新为 天前,文章中的某些内容可能已过时!