把前六篇的部件结合起来,完成一次完整的训练
把所有部件组合起来
进行到这里,所有部件都已齐备。第二篇把数据转换成了数字,第三篇实现了训练循环,第四篇讲解了循环的核心机制,第五、六篇把模型从结构简单的 Bigram 升级为完整的 GPT。
本篇要做的,是把它们全部组合起来,完整地训练这个迷你 GPT,然后观察它如何从输出随机字符,逐步变得能够写出通顺的句子。
超参数:模型与训练的全部设定
训练之前,需要把所有超参数hyperparameterHyperparameters are the settings you choose yourself before training, as opposed to the parameters that the training process learns. Model size, number of training steps, learning rate and batch size are all hyperparameters. They are not right or wrong in an absolute sense; they are tuned by experiment, by watching how the loss responds. 确定下来。超参数指的是那些不通过训练学习、需要我们自行设定的数值——模型的规模、训练的时长、学习率的大小等。把它们集中放在脚本的开头:
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 这个部件,在训练时与评估时的行为并不相同,因此测量损失之前要切换到评估模式,测量完毕再切换回训练模式。
完整的训练脚本
下面是训练的主体。把前几篇的代码(数据准备、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,验证损失已经明显高于训练损失。
这就是第二篇预告过的过拟合。两者差距拉大,说明模型开始倾向于记诵训练集中的内容,而非学习可迁移的语言规律——它在记诵过的文字上表现良好,一旦遇到验证集中未曾记诵的内容便表现欠佳。差距越大,记诵的程度越严重。
有几种常用的办法可以抑制过拟合。其一是 dropoutdropoutdropout 是一种抑制过拟合的手段。它在每一次训练时,随机让一部分神经元的输出临时置零、不参与本轮计算。这样一来,模型就无法依赖某几条固定的运算通路,被迫把同一种规律用多种方式都学会,从而变得更稳健、更不容易死记训练数据。评估与真正使用时 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-完整训练看它从胡言乱语到像模像样/
本文最后一次更新为 天前,文章中的某些内容可能已过时!