Karpathy-GPT-教程笔记-全-
Karpathy GPT 教程笔记(全)
课程 P1:神经网络与反向传播详解 🧠
在本课程中,我们将从零开始,深入探索神经网络训练的内部机制。我们将从一个空白的 Jupyter 笔记本开始,最终定义并训练一个你自己的神经网络。你将亲眼看到并理解在这个过程中发生的所有事情。
概述:什么是 Micrograd?🤔
Micrograd 是一个自动梯度引擎(Autograd),它实现了反向传播算法。反向传播是一种能够高效计算损失函数相对于神经网络权重梯度的算法,这使得我们能够通过微调权重来最小化损失函数,从而提高网络的准确性。它是 PyTorch 或 Jax 等现代深度学习库的数学核心。
Micrograd 的有趣之处在于,它通过构建数学表达式图来工作。虽然它处理的是标量值(出于教学目的),但其背后的数学原理与处理多维张量的生产级库完全相同。
直观理解导数 📈
在深入代码之前,我们需要对导数有一个坚实的直观理解。导数衡量的是函数在某个点上的瞬时变化率,或者说斜率。
上一节我们介绍了本课程的目标,本节中我们来看看导数的基本概念。
考虑一个简单的标量函数 f(x) = 3x² - 4x + 5。我们可以通过导数的定义来数值近似其在某点 x 的导数:
def f(x):
return 3*x**2 - 4*x + 5
h = 0.001
x = 3.0
numerical_gradient = (f(x+h) - f(x)) / h
这个公式 (f(x+h) - f(x)) / h 在 h 趋近于 0 时的极限,就是函数在 x 点的精确导数。它告诉我们,如果我们将 x 向正方向轻微推动(增加 h),函数值 f(x) 会如何响应。
对于一个多输入函数,例如 d = a*b + c,我们可以分别计算输出 d 相对于每个输入 a, b, c 的导数。这些导数(或梯度)告诉我们每个输入对最终输出的影响有多大。
构建表达式图 🔗
神经网络本质上是复杂的数学表达式。为了处理这些表达式,我们需要一种数据结构来跟踪计算过程。这就是 Value 类的作用。
上一节我们理解了导数的含义,本节中我们开始构建能够表示数学表达式的数据结构。
我们将创建一个 Value 类,它包装一个标量值,并记录该值是如何通过操作从其他值计算而来的。
class Value:
def __init__(self, data, _children=(), _op=''):
self.data = data
self.grad = 0.0 # 初始化梯度为0
self._prev = set(_children) # 子节点(产生此值的输入)
self._op = _op # 产生此值的操作
def __repr__(self):
return f"Value(data={self.data}, grad={self.grad})"
现在,我们需要定义基本的数学运算,如加法和乘法,并让它们返回新的 Value 对象,同时正确设置子节点和操作类型。
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), '+')
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
return out
通过这种方式,我们可以构建如 d = a*b + c 这样的表达式,并形成一个计算图,其中每个 Value 对象都知道自己的来源。
手动反向传播与链式法则 ⛓️
有了表达式图,我们就可以执行反向传播来计算梯度。我们从最终输出开始,逆向遍历整个图。
上一节我们构建了表达式图,本节中我们手动进行反向传播来理解其过程。
以表达式 L = (a*b + c) * f 为例。反向传播的目标是计算损失 L 相对于所有输入值(如 a, b, c, f)的梯度。
- 初始化输出梯度:
L.grad = 1.0(因为dL/dL = 1)。 - 通过乘法节点反向传播:对于
L = d * f,局部导数是:dL/dd = f.datadL/df = d.data
根据链式法则,我们将L.grad乘以上述局部导数,得到d.grad和f.grad。
- 通过加法节点反向传播:对于
d = c + e(其中e = a*b),加法节点的局部导数都是 1。因此,梯度直接传递:c.grad = d.grad * 1,e.grad = d.grad * 1。 - 通过乘法节点反向传播:对于
e = a * b,局部导数是:de/da = b.datade/db = a.data
同样应用链式法则:a.grad += e.grad * b.data,b.grad += e.grad * a.data(注意使用+=,因为一个变量可能被多个节点使用)。
链式法则的核心是:若 z 依赖于 y,y 依赖于 x,则 dz/dx = (dz/dy) * (dy/dx)。在反向传播中,我们将从输出传来的梯度(dz/dy)与局部梯度(dy/dx)相乘,得到对更早输入的梯度。
实现自动反向传播 🤖
手动计算梯度对于复杂网络是不现实的。现在,我们将在 Value 类中实现自动反向传播机制。
上一节我们手动应用了链式法则,本节中我们将这个逻辑编码到每个操作中。
我们为每个 Value 对象添加一个 _backward 方法,它定义了如何将该节点的梯度传播到其子节点。
class Value:
# ... __init__, __add__, __mul__ 等 ...
def _backward(self):
pass # 叶节点的反向传播为空
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), '+')
def _backward():
# 加法:梯度均等分发
self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')
def _backward():
# 乘法:局部导数是另一个因子
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
为了按正确的顺序调用所有节点的 _backward 方法,我们需要对计算图进行拓扑排序。
def backward(self):
# 拓扑排序
topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(self)
# 反向传播
self.grad = 1.0
for node in reversed(topo):
node._backward()
现在,调用 L.backward() 将自动填充图中所有 Value 对象的 .grad 属性。
从神经元到神经网络 🧠➡️🌐
一个神经元是神经网络的基本构建块。它接收多个输入,进行加权求和,加上偏置,然后通过一个非线性激活函数(如 tanh)产生输出。
上一节我们实现了自动求导引擎,本节中我们利用它来构建神经网络组件。
一个神经元的数学模型如下:
output = tanh(x1*w1 + x2*w2 + b)
我们可以用已有的 Value 操作来构建它。首先,我们需要实现 tanh 函数及其反向传播。
def tanh(self):
x = self.data
t = (math.exp(2*x) - 1) / (math.exp(2*x) + 1) # tanh 公式
out = Value(t, (self, ), 'tanh')
def _backward():
# tanh 的导数是 1 - t^2
self.grad += (1 - t**2) * out.grad
out._backward = _backward
return out
Value.tanh = tanh # 将 tanh 方法添加到 Value 类
有了神经元,我们就可以构建层(一组神经元)和最终的多层感知机(MLP),即多个层的堆叠。
训练神经网络:损失函数与梯度下降 🎯⬇️
神经网络的目的是学习一个映射。我们通过定义损失函数来衡量网络预测与真实目标之间的差距,然后使用梯度下降来最小化这个损失。
上一节我们组装出了神经网络,本节中我们让它学习。
以下是训练一个简单神经网络的关键步骤:
- 前向传播:输入数据,计算网络预测和损失值。
# 假设 xs 是输入列表,ys 是目标列表 y_pred = [n(x) for x in xs] # n 是我们的 MLP loss = sum((yout - ygt)**2 for yout, ygt in zip(y_pred, ys)) - 反向传播:计算损失相对于所有网络参数的梯度。
loss.backward() - 梯度下降更新:沿着梯度的反方向微调参数,以减小损失。
learning_rate = 0.01 for p in n.parameters(): # 收集所有权重和偏置 p.data -= learning_rate * p.grad - 迭代:重复步骤 1-3 多次。在每次迭代前,需要将参数的
.grad属性归零(p.grad = 0.0),防止梯度累积。
通过不断迭代,网络的预测会逐渐接近目标,损失值会下降。
与 PyTorch 对比及总结 🆚🏁
我们构建的 Micrograd 在概念上与 PyTorch 这样的工业级库是一致的。PyTorch 的核心 torch.Tensor 就像我们的 Value 对象,它同样有 .data、.grad 和 .backward() 方法。主要区别在于 PyTorch 针对效率进行了大量优化,使用多维张量并行计算,并支持 GPU 加速。


在本课程中,我们一起学习了:
- 导数和梯度的直观意义。
- 如何构建计算图来表示数学表达式。
- 反向传播和链式法则的原理与实现。
- 如何从基本的神经元构建多层感知机。
- 如何使用损失函数和梯度下降来训练神经网络。



虽然 Micrograd 很简单(约 100 行代码),但它包含了训练现代深度神经网络所需的所有核心概念。其他的一切,都是为了规模和效率。希望这次探索能帮助你揭开神经网络训练的神秘面纱!


课程 P10:从零开始重现 GPT-2 (124M) 🚀

在本节课中,我们将学习如何从零开始构建并训练一个 GPT-2 (124M) 模型。我们将涵盖从加载预训练权重、理解模型架构、实现核心模块,到进行高效训练和评估的完整流程。通过本教程,你将能够亲手重现一个功能完整的语言模型。
概述





GPT-2 是 OpenAI 在 2019 年发布的开创性语言模型。它属于一个包含多个尺寸的“迷你系列”,其中 124M 参数版本是一个理想的起点。本节课的目标是理解其架构,并用 PyTorch 从头实现它,最终训练一个性能接近甚至超越原版的开源模型。








1. 目标:加载并理解预训练的 GPT-2 模型


首先,让我们明确目标。我们将使用 Hugging Face transformers 库加载 OpenAI 发布的 GPT-2 (124M) 模型,并检查其权重结构。这为我们后续自己构建模型提供了参考基准。






以下是加载模型并检查其权重的步骤:


- 导入模型:从
transformers库导入GPT2LMHeadModel。 - 加载预训练权重:使用
from_pretrained(‘gpt2’)加载 124M 参数版本(gpt2即对应此版本)。 - 获取状态字典:模型的
state_dict()包含了所有权重张量及其形状。 - 分析关键参数:
- 标记嵌入 (
transformer.wte.weight):形状为(50257, 768)。GPT-2 的词表大小为 50257,每个标记用 768 维向量表示。 - 位置嵌入 (
transformer.wpe.weight):形状为(1024, 768)。模型支持的最大序列长度为 1024,每个位置有独立的嵌入向量。 - Transformer 层权重:包括注意力层、前馈网络层等的权重和偏置。
- 标记嵌入 (

通过分析这些权重,我们可以确认模型结构,并为后续实现提供准确的参数形状。






2. 构建 GPT-2 模型架构 🏗️
上一节我们查看了目标模型的结构,本节中我们来看看如何用 PyTorch 模块搭建我们自己的 GPT-2。

GPT-2 是一个仅解码器的 Transformer 模型。与原始 Transformer 相比,它移除了编码器部分,并调整了层归一化的位置(采用“预归一化”)。
2.1 模型整体框架


我们首先定义 GPT 类作为模型容器。


import torch
import torch.nn as nn
from torch.nn import functional as F
class GPT(nn.Module):
def __init__(self, config):
super().__init__()
self.config = config
# 标记嵌入和位置嵌入
self.transformer = nn.ModuleDict(dict(
wte = nn.Embedding(config.vocab_size, config.n_embd),
wpe = nn.Embedding(config.block_size, config.n_embd),
h = nn.ModuleList([Block(config) for _ in range(config.n_layer)]),
ln_f = nn.LayerNorm(config.n_embd),
))
# 语言模型头(输出层),注意:通常与标记嵌入权重绑定
self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
def forward(self, idx, targets=None):
# idx: (B, T) 批大小 x 序列长度
B, T = idx.shape
device = idx.device
# 生成位置索引
pos = torch.arange(0, T, dtype=torch.long, device=device).unsqueeze(0) # (1, T)
# 前向传播
tok_emb = self.transformer.wte(idx) # (B, T, n_embd)
pos_emb = self.transformer.wpe(pos) # (1, T, n_embd)
x = tok_emb + pos_emb # (B, T, n_embd)
# 通过所有 Transformer 块
for block in self.transformer.h:
x = block(x)
x = self.transformer.ln_f(x) # 最终的层归一化
# 计算 logits
logits = self.lm_head(x) # (B, T, vocab_size)
# 计算损失(如果提供了目标)
loss = None
if targets is not None:
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


2.2 实现 Transformer 块

每个 Block 包含一个带掩码的多头自注意力机制和一个前馈网络(MLP),均采用预归一化。


class Block(nn.Module):
def __init__(self, config):
super().__init__()
self.ln_1 = nn.LayerNorm(config.n_embd)
self.attn = CausalSelfAttention(config) # 我们将实现这个
self.ln_2 = nn.LayerNorm(config.n_embd)
self.mlp = MLP(config)
def forward(self, x):
# 预归一化 -> 注意力 -> 残差连接
x = x + self.attn(self.ln_1(x))
# 预归一化 -> MLP -> 残差连接
x = x + self.mlp(self.ln_2(x))
return x








2.3 实现因果自注意力

这是 Transformer 的核心,允许每个标记关注其左侧的所有标记。



class CausalSelfAttention(nn.Module):
def __init__(self, config):
super().__init__()
assert config.n_embd % config.n_head == 0
# 键、查询、值投影
self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd)
# 输出投影
self.c_proj = nn.Linear(config.n_embd, config.n_embd)
# 正则化
self.attn_dropout = nn.Dropout(config.dropout)
self.resid_dropout = nn.Dropout(config.dropout)
self.n_head = config.n_head
self.n_embd = config.n_embd
self.dropout = config.dropout
# 使用 PyTorch 的优化注意力实现(Flash Attention)
self.flash = hasattr(torch.nn.functional, ‘scaled_dot_product_attention’)
def forward(self, x):
B, T, C = x.size() # 批大小,序列长度,嵌入维度
# 计算查询、键、值
qkv = self.c_attn(x)
q, k, v = qkv.split(self.n_embd, dim=2)
# 重塑为多头格式
k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
# 因果自注意力
if self.flash:
# 使用高效的 Flash Attention
y = torch.nn.functional.scaled_dot_product_attention(q, k, v, attn_mask=None, dropout_p=self.dropout if self.training else 0, is_causal=True)
else:
# 手动实现(用于理解)
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(k.size(-1)))
# 因果掩码:防止关注未来的标记
mask = torch.tril(torch.ones(T, T, device=x.device)).view(1, 1, T, T)
att = att.masked_fill(mask == 0, float(‘-inf’))
att = F.softmax(att, dim=-1)
att = self.attn_dropout(att)
y = att @ v # (B, nh, T, hs)
# 重新组合多头输出
y = y.transpose(1, 2).contiguous().view(B, T, C)
# 输出投影
y = self.resid_dropout(self.c_proj(y))
return y



2.4 实现 MLP(前馈网络)


MLP 包含两个线性层,中间使用 GELU 激活函数。GPT-2 使用了 GELU 的近似版本。
class MLP(nn.Module):
def __init__(self, config):
super().__init__()
self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd)
self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd)
self.dropout = nn.Dropout(config.dropout)
def forward(self, x):
x = self.c_fc(x)
x = F.gelu(x) # GPT-2 使用近似的 gelu: 0.5 * x * (1 + torch.tanh(math.sqrt(2/math.pi) * (x + 0.044715 * torch.pow(x, 3))))
x = self.c_proj(x)
x = self.dropout(x)
return x






3. 加载预训练权重并验证 ✅


现在我们已经实现了自己的 GPT-2 类,接下来需要将 Hugging Face 模型中的权重加载到我们的结构中,并验证生成功能是否正常。


以下是关键步骤:



- 创建配置对象:确保超参数(层数、头数、嵌入维度等)与 GPT-2 (124M) 一致。
- 映射权重名称:遍历 Hugging Face 模型的状态字典,将权重名称映射到我们模型中的对应参数。
- 处理权重绑定:注意标记嵌入 (
wte) 和语言模型头 (lm_head) 的权重是共享的。在我们的实现中,可以通过简单地将lm_head.weight指向wte.weight来实现。 - 验证生成:使用相同的提示词进行文本生成,比较结果以确保模型行为一致。









完成这一步后,我们就有了一个功能上等同于开源 GPT-2 (124M) 的模型,这为我们从头开始训练提供了信心和基准。



4. 准备数据与训练循环 🔄

上一节我们成功加载了预训练模型,本节中我们来看看如何准备数据并构建训练循环。
4.1 数据加载器
我们需要一个能够将文本数据流式转换为模型可接受的批次张量的数据加载器。

class DataLoaderLite:
def __init__(self, B, T, process_rank, num_processes):
self.B = B # 批大小
self.T = T # 序列长度(块大小)
self.process_rank = process_rank
self.num_processes = num_processes
# 假设我们有一个已标记化的 token 数组 `tokens`
self.tokens = tokens # 一个一维的 numpy 数组或 torch 张量
self.current_position = self.B * self.T * self.process_rank # 根据进程排名偏移起始位置
def next_batch(self):
B, T = self.B, self.T
# 获取当前批次所需的 tokens (需要多一个 token 作为目标)
buf = self.tokens[self.current_position : self.current_position + B*T + 1]
# 重塑为输入 (x) 和目标 (y)
x = torch.from_numpy(buf[:-1].reshape(B, T).astype(np.int64))
y = torch.from_numpy(buf[1:].reshape(B, T).astype(np.int64))
# 更新位置,循环回到开头如果到达末尾
self.current_position += B * T * self.num_processes
if self.current_position + (B * T * self.num_processes + 1) > len(self.tokens):
self.current_position = self.B * self.T * self.process_rank
return x, y
4.2 优化器与学习率调度
我们使用 AdamW 优化器,并遵循 GPT-3 论文中的设置:使用余弦衰减学习率调度,并带有预热阶段。
def configure_optimizers(model, weight_decay, learning_rate, device_type):
# 将参数分为需要权重衰减和不需要的两组
decay_params = []
no_decay_params = []
for name, param in model.named_parameters():
if param.requires_grad:
if name.endswith(‘.bias’) or len(param.shape) == 1:
no_decay_params.append(param)
else:
decay_params.append(param)
optim_groups = [
{‘params’: decay_params, ‘weight_decay’: weight_decay},
{‘params’: no_decay_params, ‘weight_decay’: 0.0}
]
# 使用融合的 AdamW 实现(如果可用)以获得更好性能
fused_available = ‘fused’ in inspect.signature(torch.optim.AdamW).parameters
use_fused = fused_available and device_type == ‘cuda’
optimizer = torch.optim.AdamW(optim_groups, lr=learning_rate, betas=(0.9, 0.95), eps=1e-8, fused=use_fused)
return optimizer

def get_lr(it, warmup_iters, learning_rate, lr_decay_iters, min_lr):
# 1) 线性预热
if it < warmup_iters:
return learning_rate * (it+1) / warmup_iters
# 2) 如果超过衰减区间,则返回最小学习率
if it > lr_decay_iters:
return min_lr
# 3) 在预热和衰减区间之间,使用余弦衰减
decay_ratio = (it - warmup_iters) / (lr_decay_iters - warmup_iters)
coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio))
return min_lr + coeff * (learning_rate - min_lr)
4.3 训练步骤

训练循环的核心步骤包括前向传播、损失计算、反向传播和参数更新。
model.train()
optimizer.zero_grad()
loss_accum = 0.0
# 梯度累积循环(用于模拟更大的批大小)
for micro_step in range(gradient_accumulation_steps):
x, y = get_batch(‘train’)
x, y = x.to(device), y.to(device)
with torch.autocast(device_type=device_type, dtype=torch.bfloat16): # 混合精度训练
logits, loss = model(x, y)
# 缩放损失以进行梯度累积
loss = loss / gradient_accumulation_steps
loss_accum += loss.detach()
loss.backward()
# 可选:在最后一步进行梯度裁剪
if (micro_step == gradient_accumulation_steps - 1):
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
optimizer.zero_grad()


5. 高级优化与多 GPU 训练 ⚡
为了充分利用现代硬件并加速训练,我们需要应用一系列优化技术。
5.1 性能优化技巧

以下是提升训练速度的关键方法:

- 降低数值精度:使用
torch.autocast进行混合精度训练(BF16),这能显著减少内存占用并加速计算。 - 使用
torch.compile:PyTorch 2.0 引入的编译器可以融合操作,减少 Python 开销和 GPU 内存读写,带来显著的性能提升。 - 使用 Flash Attention:如果硬件支持,使用 PyTorch 内置的高效注意力实现,它通过算法优化避免了大型注意力矩阵的显式存储和计算。
- 调整“丑陋”的数字:将词表大小等参数调整为 2 的幂(例如从 50257 调整为 50304),可以使 CUDA 内核运行更高效,因为许多内核是为 2 的幂次方块大小优化的。


5.2 多 GPU 分布式训练




为了在多个 GPU 上并行训练,我们使用 PyTorch 的分布式数据并行。


import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
# 初始化进程组
dist.init_process_group(“nccl”)
# 每个进程获取自己的排名和世界大小(总进程数)
rank = dist.get_rank()
world_size = dist.get_world_size()
# 根据排名设置当前进程使用的 GPU
torch.cuda.set_device(rank)
device = f’cuda:{rank}‘

# 包装模型
model = GPT(config)
model.to(device)
model = DDP(model, device_ids=[rank])

# 在训练循环中,确保每个进程处理数据的不同部分
# 梯度会在反向传播时在所有进程间自动同步平均



6. 使用真实数据集训练与评估 📊

我们使用 FineWeb-Edu 数据集(一个高质量的网络文本子集)进行训练,并使用 HellaSwag 基准进行评估。

6.1 数据集准备

- 下载并预处理 FineWeb-Edu 数据集,将其标记化并保存为分片文件。
- 修改数据加载器以支持从多个分片中流式读取数据,并确保在多 GPU 训练中每个进程处理不同的数据块。



6.2 评估:HellaSwag



HellaSwag 是一个句子补全任务,用于评估模型的常识推理能力。评估方法不是直接让模型选择 A/B/C/D,而是让模型为每个选项计算平均对数似然(或损失),然后选择可能性最高的选项。






def evaluate_hellaswag(model, dataset, device):
model.eval()






# P2:语言模型详解:构建MakeMore 🧠


在本节课中,我们将学习如何构建一个名为MakeMore的字符级语言模型。我们将从零开始,逐步实现一个能够根据给定数据集(如名字列表)生成新内容的模型。课程将涵盖从简单的双字母模型到基于神经网络的模型,并介绍如何训练、评估和从模型中采样。





## 概述:什么是MakeMore? 🤔


MakeMore是一个字符级语言模型。如其名所示,它的核心功能是“生成更多”。我们将使用一个包含约32,000个名字的数据集(`names.txt`)来训练它。训练完成后,模型将能够生成听起来像名字但实际上是独特的新名字。

在底层,MakeMore将每个名字视为一个字符序列(例如,“R-E-E-S-E”)。作为字符级语言模型,它的目标是学习序列中字符出现的模式,从而能够预测给定前序字符后,下一个最可能出现的字符。
我们将实现多种类型的字符级语言模型,从简单的双字母模型和词袋模型,到多层感知机、循环神经网络,最终构建一个类似于GPT-2的现代Transformer模型。通过本系列课程,你将深入理解这些模型在字符层面的工作原理。


## 第一步:加载与探索数据 📂

首先,我们需要加载数据集并对其有一个基本的了解。


```python
# 打开并读取数据集
with open('names.txt', 'r') as f:
words = f.read().splitlines()





# 查看前10个单词
print(words[:10])






运行上述代码,我们会得到一个名字列表,例如 ['Emma', 'Olivia', 'Ava', ...]。这个列表可能按频率排序。让我们进一步查看数据集的一些统计信息:

# 统计总词数、最短和最长的单词长度
total_words = len(words)
min_len = min(len(w) for w in words)
max_len = max(len(w) for w in words)
print(f"总词数: {total_words}")
print(f"最短单词长度: {min_len}")
print(f"最长单词长度: {max_len}")

我们预计总词数约为32,000。最短的单词可能只有2个字母,而最长的可能达到15个字符。这些信息对我们构建模型很重要。


第二步:构建双字母模型 🔤




上一节我们介绍了数据集,本节中我们来看看如何构建第一个语言模型——双字母模型。在这个模型中,我们只关注连续的两个字符(即“双字母”),并试图用前一个字符来预测后一个字符。这是一个非常简单且基础的模型,但它是很好的起点。







以下是构建双字母模型的关键步骤:




- 创建字符到索引的映射:我们需要将字符(如‘a’, ‘b’, …)以及特殊的开始/结束标记转换为整数,以便后续处理。
- 统计双字母出现频率:遍历数据集中的所有单词,统计每一个双字母组合出现的次数。
- 将频率转换为概率:对统计结果进行归一化,使得给定前一个字符后,所有可能的下一个字符的概率之和为1。






首先,我们创建字符表。除了26个字母,我们引入一个特殊的开始/结束标记‘.’。




# 创建字符列表,包括特殊标记‘.’
chars = sorted(list(set(''.join(words))))
stoi = {s:i+1 for i,s in enumerate(chars)} # 字符到索引,a从1开始
stoi['.'] = 0 # 特殊标记‘.’的索引为0
itos = {i:s for s,i in stoi.items()} # 索引到字符的反向映射






接下来,我们初始化一个计数矩阵 N,其大小为 (27, 27),用于统计双字母出现次数。行索引代表第一个字符,列索引代表第二个字符。




import torch




N = torch.zeros((27, 27), dtype=torch.int32)

现在,我们遍历所有单词和其中的双字母进行计数。对于每个单词,我们在其开头和结尾分别加上开始标记‘.’和结束标记‘.’。


for w in words:
chs = ['.'] + list(w) + ['.'] # 例如: ['.', 'E', 'm', 'm', 'a', '.']
for ch1, ch2 in zip(chs, chs[1:]):
ix1 = stoi[ch1]
ix2 = stoi[ch2]
N[ix1, ix2] += 1



计数完成后,我们可以将计数矩阵 N 可视化,以直观感受不同双字母组合的出现频率。
为了从计数得到概率,我们需要对矩阵 N 的每一行进行归一化(即让每一行的元素之和为1)。这里我们使用PyTorch的广播功能高效实现。



# 将计数转换为浮点数,并计算概率矩阵P
P = (N.float() + 1) # 加1平滑,防止零概率
P /= P.sum(dim=1, keepdim=True) # 按行归一化
注意:keepdim=True 参数在这里至关重要,它确保了广播除法的方向正确(按行归一化)。如果设置错误,会导致对列进行归一化,得到完全错误的结果。




第三步:从模型中采样 ✨
现在我们已经有了一个训练好的双字母模型(概率矩阵 P)。我们可以利用这个模型来生成新的名字。采样的过程是迭代式的:




- 从开始标记‘.’(索引0)开始。
- 查看
P矩阵中对应当前字符索引的那一行,这是一个概率分布。 - 根据这个概率分布,随机抽取下一个字符的索引。
- 将新抽到的字符作为当前字符,重复步骤2-3。
- 当抽到结束标记‘.’(索引0)时,停止生成。





以下是采样的代码实现:




g = torch.Generator().manual_seed(2147483647) # 设置随机种子以保证结果可复现






for _ in range(5): # 生成5个名字
out = []
ix = 0 # 从开始标记开始
while True:
p = P[ix] # 当前字符对应的下一个字符概率分布
ix = torch.multinomial(p, num_samples=1, replacement=True, generator=g).item()
if ix == 0: # 如果抽到结束标记
break
out.append(itos[ix])
print(''.join(out))



运行上述代码,可能会生成如 “mor.”, “axx.” 等名字。虽然这些名字看起来不太像真实名字,但这正是双字母模型能力有限的表现——它只考虑了前一个字符的信息。

第四步:评估模型质量 📊








为了量化模型的好坏,我们需要一个评估指标。在统计建模中,常用的是似然(Likelihood)和对数似然(Log-Likelihood)。


- 似然:模型为整个训练集中所有真实出现的双字母分配的概率的乘积。值越高,说明模型对训练数据的拟合越好。
- 对数似然:由于概率乘积可能是一个非常小的数字,通常取其对数进行处理,称为对数似然。对数是一个单调函数,因此最大化似然等价于最大化对数似然。




然而,在机器学习中,我们习惯最小化一个损失函数。因此,我们定义负对数似然(Negative Log-Likelihood, NLL)作为损失函数。对于我们的双字母模型,损失计算如下:






log_likelihood = 0.0
n = 0






for w in words[:3]: # 这里先用前3个单词示例
chs = ['.'] + list(w) + ['.']
for ch1, ch2 in zip(chs, chs[1:]):
ix1 = stoi[ch1]
ix2 = stoi[ch2]
prob = P[ix1, ix2]
logprob = torch.log(prob)
log_likelihood += logprob
n += 1




nll = -log_likelihood # 负对数似然
print(f"负对数似然: {nll}")
print(f"平均负对数似然(损失): {nll/n}")






平均负对数似然(即损失)越低,模型质量越好。一个完美的模型(总是预测概率为1)的损失为0。我们还可以在整个训练集上计算这个损失,作为模型的最终评估指标。








模型平滑:在计数时,我们给矩阵 N 的所有元素加了1(N.float() + 1)。这个技巧称为“加1平滑”或“拉普拉斯平滑”。它确保了概率矩阵 P 中没有绝对为零的元素,从而避免了当模型遇到训练集中未出现过的双字母时,对数似然变成负无穷大的情况。




第五步:用神经网络框架重建模型 🧠








上一节我们通过直接计数和归一化的“统计”方法得到了双字母模型。本节我们将使用神经网络和梯度下降的“学习”方法来达到同样的目的。这虽然对于简单的双字母模型显得大材小用,但这种方法具有极强的扩展性,是构建更复杂模型的基础。





我们的目标不变:输入一个字符(索引),输出下一个字符的概率分布。我们将构建一个极简的神经网络:






- 输入层:将字符索引进行独热编码(One-hot Encoding)。例如,索引5变为一个长度为27的向量,只有第5位是1,其余为0。
- 线性层:一个没有偏置项(bias)的全连接层。权重矩阵
W的形状是(27, 27)。这相当于用输入向量的索引去“查找”W矩阵的某一行。 - Softmax层:将线性层的输出(称为logits)通过指数运算和归一化,转换为概率分布。



首先,我们需要准备训练数据(输入 Xs 和标签 Ys)。






xs, ys = [], []
for w in words:
chs = ['.'] + list(w) + ['.']
for ch1, ch2 in zip(chs, chs[1:]):
ix1 = stoi[ch1]
ix2 = stoi[ch2]
xs.append(ix1)
ys.append(ix2)






xs = torch.tensor(xs)
ys = torch.tensor(ys)
num = xs.nelement()
print(f"训练样本数量: {num}")








接下来,我们初始化网络参数 W,并实现前向传播过程。








import torch.nn.functional as F







g = torch.Generator().manual_seed(2147483647)
W = torch.randn((27, 27), generator=g, requires_grad=True) # 初始化权重,并告知PyTorch需要计算梯度




# 前向传播
xenc = F.one_hot(xs, num_classes=27).float() # 独热编码
logits = xenc @ W # 线性层,等价于 W[xs]
counts = logits.exp() # 指数运算,得到“计数”
probs = counts / counts.sum(1, keepdim=True) # 归一化得到概率,即Softmax





现在,我们需要计算损失。损失函数仍然是平均负对数似然。我们需要提取出模型为每个训练样本中真实下一个字符所分配的概率。




# 提取正确标签对应的概率
loss = -probs[torch.arange(num), ys].log().mean()
print(f"初始损失: {loss.item()}")



由于 W 是随机初始化的,初始损失会很高。接下来,我们使用梯度下降来优化 W。




# 梯度下降优化
for k in range(100):
# 前向传播
xenc = F.one_hot(xs, num_classes=27).float()
logits = xenc @ W
counts = logits.exp()
probs = counts / counts.sum(1, keepdim=True)
loss = -probs[torch.arange(num), ys].log().mean()
# 反向传播
W.grad = None # 将梯度置零
loss.backward()
# 更新参数
W.data += -50 * W.grad # 学习率为50


print(f"优化后损失: {loss.item()}")







经过优化,损失会下降到接近我们之前用统计方法得到的值(约2.45)。此时,神经网络的权重矩阵 W 经过 exp() 运算后,就近似等于我们之前通过计数得到的概率矩阵 P(的转置)。两种方法殊途同归。



正则化的联系:在统计方法中,我们通过“加1平滑”来防止过拟合。在神经网络方法中,这等价于一种叫做权重衰减或L2正则化的技术。我们可以在损失函数中添加一项 0.01 * (W**2).mean(),这会鼓励 W 的数值变小,从而使输出概率分布更平滑、更均匀,其效果类似于增加平滑计数。




总结与展望 🚀



本节课中我们一起学习了:







- 字符级语言模型的基本概念:将文本视为字符序列,并预测序列中的下一个字符。
- 双字母模型的构建:通过统计字符共现频率并归一化,得到一个简单的概率查找表模型。
- 模型评估:使用负对数似然作为损失函数来衡量模型质量。
- 神经网络方法:用独热编码、线性层和Softmax构建了等效的模型,并通过梯度下降进行训练。
- 两种方法的统一:统计方法和神经网络方法在双字母模型上是等价的,但后者为构建更复杂的模型提供了框架。





双字母模型的能力非常有限,因为它只考虑了一个字符的上下文。在接下来的课程中,我们将扩展这一框架:


- 考虑更多的前序字符(如3个、5个或整个单词)。
- 用更复杂的神经网络(如MLP、RNN、Transformer)来代替简单的线性层。
- 处理更长的序列和更大的数据集。



神经网络方法的强大之处在于其可扩展性。当上下文变长时,可能的组合呈指数级增长,无法再用一个简单的表格来存储所有概率。而神经网络可以通过学习到的参数,泛化到未见过的字符组合上,从而构建出强大的语言模型。
课程 P3:构建 makemore 第二部分:多层感知器 (MLP) 🧠
在本节课中,我们将学习如何构建一个多层感知器 (MLP) 模型,用于预测序列中的下一个字符。我们将从回顾上一节课的简单模型开始,然后深入探讨如何通过引入嵌入层和隐藏层来构建一个更强大、更灵活的神经网络模型。
概述
在上一节课中,我们实现了一个基于单个前序字符的简单模型(大词袋模型)。这种方法虽然易于理解,但预测效果不佳,因为它只考虑了一个字符的上下文。当我们尝试增加上下文长度时,可能的组合数量会呈指数级增长,导致模型参数过多且数据稀疏。
为了解决这个问题,本节课我们将转向多层感知器模型。我们将遵循一篇有影响力的论文中的方法,通过将字符嵌入到低维空间,并使用神经网络来捕捉字符间的复杂关系,从而实现对更长上下文的建模。
从简单模型到多层感知器
上一节我们介绍了基于计数的简单模型。本节中我们来看看如何构建一个更复杂的神经网络模型。
简单模型的问题在于其有限的上下文窗口。如果我们只考虑一个字符,模型无法捕捉到更长的依赖关系。考虑两个或三个字符的上下文会使状态空间急剧膨胀,导致模型难以训练。
因此,我们需要一种更高效的方法来建模长距离依赖关系。多层感知器通过引入可学习的嵌入和隐藏层来实现这一点。
论文方法解析
我们将参考一篇论文中提出的方法。该方法的核心思想是将每个字符(或单词)表示为一个低维的、可学习的特征向量(嵌入)。
核心概念:
- 嵌入查找表 C:一个矩阵,其行数等于词汇表大小,列数等于嵌入维度。例如,
C[5]返回第5个字符的嵌入向量。 - 前向传播公式:对于上下文中的每个字符索引
i,我们查找其嵌入e_i = C[i]。将多个嵌入拼接后,输入到隐藏层:h = tanh(concat(e_1, e_2, ..., e_n) * W1 + b1)。最后,输出层计算逻辑值:logits = h * W2 + b2。
在训练开始时,这些嵌入向量是随机初始化的。通过反向传播训练神经网络,语义或功能相似的字符的嵌入向量会在空间中彼此靠近,这使得模型能够泛化到未见过的字符组合。
实现数据集构建
以下是构建训练数据集的步骤。我们需要将原始的名称列表转换为神经网络可以处理的输入(x)和标签(y)对。
我们首先定义块大小(block_size),即用于预测下一个字符的上下文长度。例如,如果 block_size = 3,则我们使用前3个字符来预测第4个字符。
block_size = 3 # 上下文长度
X = [] # 输入
Y = [] # 标签
for word in words[:5]: # 先用少量单词示例
context = [0] * block_size # 初始用0填充的上下文
for ch in word + '.':
ix = stoi[ch]
X.append(context) # 当前上下文是输入
Y.append(ix) # 当前字符是标签
# 滚动更新上下文窗口:移除最旧的字符,加入当前字符
context = context[1:] + [ix]
这段代码为每个单词生成多个训练样本。X 中的每个元素是一个包含 block_size 个整数的列表,代表上下文。Y 中的每个元素是一个整数,代表序列中下一个字符的索引。
构建神经网络层
上一节我们准备好了数据。本节中我们来看看如何构建神经网络的各个层。
1. 嵌入层
嵌入层 C 是一个可学习的查找表。它将字符索引映射到一个低维的连续向量空间。
# 假设有27个字符,我们将它们嵌入到2维空间
vocab_size = 27
embedding_dim = 2
C = torch.randn((vocab_size, embedding_dim), requires_grad=True)
对于一个整数索引 5,我们可以通过 C[5] 直接获取其嵌入向量。PyTorch 支持使用张量进行批量索引,因此对于整个输入批次 X(形状为 [batch_size, block_size]),我们可以用一行代码完成所有嵌入查找:embeddings = C[X],结果形状为 [batch_size, block_size, embedding_dim]。
2. 隐藏层
隐藏层是一个全连接层,它对拼接后的嵌入向量进行非线性变换。
# 输入维度:block_size * embedding_dim
# 隐藏层神经元数量:例如 100
input_dim = block_size * embedding_dim
hidden_dim = 100
W1 = torch.randn((input_dim, hidden_dim), requires_grad=True)
b1 = torch.randn(hidden_dim, requires_grad=True)
为了将形状为 [batch_size, block_size, embedding_dim] 的 embeddings 输入到线性层,我们需要将其重塑为 [batch_size, input_dim]。这可以通过 view 方法高效完成:
emb_flat = embeddings.view(-1, input_dim)
3. 前向传播计算
现在我们可以计算隐藏层激活和输出层的逻辑值。
# 计算隐藏层激活 (使用 tanh 非线性函数)
h = torch.tanh(emb_flat @ W1 + b1) # 形状: [batch_size, hidden_dim]
# 输出层(逻辑值层)
W2 = torch.randn((hidden_dim, vocab_size), requires_grad=True)
b2 = torch.randn(vocab_size, requires_grad=True)
logits = h @ W2 + b2 # 形状: [batch_size, vocab_size]
logits 是网络对下一个字符的原始预测分数。为了得到概率分布,我们需要对其应用 softmax 函数。
计算损失与训练
得到逻辑值后,我们需要计算损失以评估模型预测的好坏,并通过反向传播来更新参数。
计算损失
我们使用交叉熵损失函数。它直接接受逻辑值 logits 和真实标签 Y(一个包含正确字符索引的张量)。
loss = F.cross_entropy(logits, Y)
使用 F.cross_entropy 比自己实现 softmax 再计算负对数似然更高效、数值更稳定。它会内部处理可能的数值溢出问题。
训练循环
训练过程包括前向传播、损失计算、反向传播和参数更新。
learning_rate = 0.1
for step in range(10000):
# 前向传播
emb = C[X]
h = torch.tanh(emb.view(-1, block_size*embedding_dim) @ W1 + b1)
logits = h @ W2 + b2
loss = F.cross_entropy(logits, Y)
# 反向传播
for p in [C, W1, b1, W2, b2]:
p.grad = None
loss.backward()
# 参数更新(梯度下降)
for p in [C, W1, b1, W2, b2]:
p.data += -learning_rate * p.grad
在实际训练中,我们不会使用全部数据(22万个样本)计算梯度,而是采用小批量随机梯度下降,每次迭代只使用一小部分数据,这能极大提高训练速度。
模型评估与超参数调优
训练模型后,我们需要评估其性能,并调整超参数以获得更好的结果。
数据集划分
为了避免过拟合,我们将数据划分为三部分:
- 训练集(80%):用于更新模型参数。
- 验证集(10%):用于调整超参数(如网络大小、学习率)。
- 测试集(10%):用于最终评估模型性能,应极少使用。
寻找合适的学习率
学习率是训练中最重要的超参数之一。一个寻找合适学习率范围的方法是进行学习率扫描。
# 在一系列学习率上运行少量迭代,观察损失变化
lrs = torch.logspace(-3, 0, 1000) # 从 0.001 到 1.0
losses = []
for lr in lrs:
# 用该学习率训练几步...
# 记录最终损失
losses.append(loss.item())
# 绘制 lr (x轴) 和 loss (y轴) 的关系图
理想的区域是损失快速下降但尚未剧烈震荡的部分。根据扫描结果,我们可以选择一个合理的学习率(例如 0.1)。
扩大模型容量
如果模型在训练集和验证集上的损失都很高且相近,说明模型可能“欠拟合”,即模型容量不足。我们可以通过以下方式增加容量:
- 增加嵌入维度(例如从 2 维增加到 10 维)。
- 增加隐藏层的神经元数量。
- 增加上下文长度(
block_size)。
每次调整后,都应在验证集上评估性能,选择效果最好的配置。
从模型采样生成名称
训练好的模型可以用来生成新的名称。以下是采样过程:
for _ in range(20):
out = []
context = [0] * block_size # 初始上下文
while True:
# 前向传播
emb = C[torch.tensor([context])]
h = torch.tanh(emb.view(1, -1) @ W1 + b1)
logits = h @ W2 + b2
probs = F.softmax(logits, dim=1)
# 根据概率分布采样下一个字符索引
ix = torch.multinomial(probs, num_samples=1).item()
# 更新上下文
context = context[1:] + [ix]
out.append(ix)
if ix == 0: # 遇到结束符 '.'
break
print(''.join(itos[i] for i in out))
总结


本节课中我们一起学习了如何构建一个用于字符级语言建模的多层感知器模型。
我们首先指出了简单计数模型的局限性,然后引入了通过嵌入层将离散字符映射到连续向量空间的思想。我们详细实现了神经网络的前向传播过程,包括嵌入查找、隐藏层变换和输出层计算。我们使用交叉熵损失函数和梯度下降法来训练模型,并讨论了如何通过划分数据集、寻找合适学习率以及调整模型容量(嵌入维度、隐藏层大小)来优化模型性能。最后,我们展示了如何从训练好的模型中采样生成新的名称。

通过本课的学习,你现在应该能够理解并实现一个基本的神经网络语言模型,并掌握对其进行分析和改进的基本方法。




P4:构建Makemore第三部分:激活值与梯度,BatchNorm 🧠📈


在本节课中,我们将继续构建Makemore项目,深入探讨神经网络训练中的两个核心概念:激活值与梯度。我们将分析它们在训练过程中的表现,并学习如何使用批量归一化(Batch Normalization) 这一关键技术来稳定深度神经网络的训练。理解这些内容对于后续学习更复杂的模型(如循环神经网络)至关重要。





概述:为什么需要关注激活与梯度?🤔




上一节我们实现了一个基于多层感知机(MLP)的字符级语言模型。在向更复杂的架构(如RNN、LSTM)迈进之前,我们必须先深入理解神经网络在训练期间内部发生了什么。核心在于观察激活值(Activations) 和反向传播的梯度(Gradients) 的行为。






如果激活值分布不当(例如,过大或过小),或者梯度流动不畅(例如,消失或爆炸),网络将难以有效学习。这对于深度网络尤为关键。本节课,我们将首先诊断并修复初始化问题,然后引入批量归一化层来系统性地控制激活统计量。



1. 诊断与修复初始化问题 🔍



神经网络训练的第一步是参数的初始化。糟糕的初始化会导致训练初期出现异常,例如损失值异常高或激活值饱和。



1.1 输出层Logits的初始化






在初始化时,我们希望网络对每个输出字符没有先验偏好,即预测概率应大致均匀。对于27个字符的分类问题,期望的初始损失应为 -log(1/27) ≈ 3.29。








然而,在我们的初始代码中,第一次迭代的损失高达27。这是因为输出层的logits值过于极端,导致softmax输出对错误答案“过度自信”。








问题根源:输出logits由公式 logits = h @ w2 + b2 计算得出。初始的 w2 和 b2 值过大。





解决方案:将输出层的权重 w2 和偏置 b2 初始化为较小的值(例如,乘以0.01),使logits接近零,从而让softmax输出接近均匀分布。

# 修复输出层初始化
w2 = torch.randn(n_hidden, vocab_size) * 0.01 # 缩小权重
b2 = torch.randn(vocab_size) * 0 # 偏置置零







修复后,初始损失从27降至接近预期的3.29,训练曲线也不再出现初期陡降的“曲棍球棒”形状,这意味着优化过程更有效率。





1.2 隐藏层激活的初始化



接下来,我们检查隐藏层的激活值 h,它由 tanh 函数产生。理想情况下,我们希望 tanh 的输入(预激活值)不要过大,否则 tanh 会进入饱和区(输出接近±1),导致梯度消失。




问题诊断:绘制隐藏层预激活值 hpreact 的直方图,发现其值域很宽(例如-15到15)。这使得 tanh 的输出大量集中在±1附近,处于函数的平坦区域。在反向传播时,梯度会乘以 1 - tanh(x)^2,当输出接近±1时,这个因子接近零,梯度因此“消失”。








解决方案:同样,通过缩小第一层权重 w1 和偏置 b1 的初始值,来控制预激活值的尺度。


# 修复隐藏层初始化
w1 = torch.randn(block_size * n_embd, n_hidden) * 0.2 # 例如,缩放因子0.2
b1 = torch.randn(n_hidden) * 0.01





调整后,tanh 的输入被控制在合理范围(例如-1.5到1.5),输出直方图显示饱和神经元数量大大减少,梯度得以更有效地流动。













2. 权重初始化的原则性方法 ⚖️


上一节我们通过试错法找到了缩放因子(如0.2)。但对于更深的网络,我们需要一个系统性的初始化方法。





核心思想是:我们希望每一层线性变换的输出(在通过非线性函数之前)保持大致相同的分布,通常希望是均值为0、标准差为1的高斯分布。







对于一个线性层 y = x @ w,假设输入 x 是标准高斯分布(均值为0,标准差为1)。那么,为了使输出 y 的标准差也保持为1,权重 w 的标准差应设置为 1 / sqrt(fan_in),其中 fan_in 是该层的输入维度。




然而,当层与层之间插入非线性激活函数(如 tanh 或 ReLU)时,情况会发生变化。这些函数会压缩或改变输入的分布。因此,我们需要引入一个增益(gain) 因子来补偿。









例如,对于 tanh 激活函数,研究发现增益约为 5/3。因此,初始化权重时,我们使用:
std = gain / sqrt(fan_in)




PyTorch的 kaiming_normal_ 初始化方法就内置了针对不同非线性函数的增益计算。







# 使用PyTorch的Kaiming初始化(针对tanh)
torch.nn.init.kaiming_normal_(w1, nonlinearity='tanh')




通过这种有原则的初始化,我们可以确保深层网络中各层的激活值分布更加稳定,而无需手动调整每个缩放因子。










3. 批量归一化(Batch Normalization)🚀








尽管精心设计的初始化有所帮助,但在训练非常深的神经网络时,维持稳定的激活分布仍然极具挑战。批量归一化(BatchNorm)应运而生,它通过一个可微操作直接对激活值进行标准化,极大地稳定了深度网络的训练。






3.1 BatchNorm的核心思想






BatchNorm层的操作发生在线性层之后、非线性激活函数之前。对于一个批次(Batch)的输入 x(形状为 [batch_size, features]),它执行以下步骤:





- 计算批次统计量:计算该批次数据在每个特征维度上的均值(
mean)和方差(var)。 - 标准化:使用计算出的均值和方差对批次数据进行标准化,使其均值为0,方差为1。
xhat = (x - mean) / sqrt(var + eps)
(eps是一个很小的数,防止除以零) - 缩放与偏移:引入两个可学习参数
gamma(增益)和beta(偏置),对标准化后的数据进行变换。
out = gamma * xhat + beta




为什么需要 gamma 和 beta?
如果只做标准化,网络每一层的表达能力会受到限制(强制为0均值1方差)。gamma 和 beta 允许网络学习最适合当前任务的数据分布形态。



3.2 BatchNorm的训练与推理模式


- 训练模式:使用当前批次的统计量(
mean,var)进行标准化。同时,它会以指数移动平均(EMA)的方式更新两个缓冲区(buffer):running_mean和running_var,用于估计整个训练集的全局统计量。 - 推理/评估模式:不再使用当前批次的统计量,而是使用训练阶段估算好的
running_mean和running_var进行标准化。这使得网络可以对单个样本进行前向传播。




# 一个简化的BatchNorm1d实现示例
class BatchNorm1d:
def __init__(self, dim, eps=1e-5, momentum=0.1):
self.eps = eps
self.momentum = momentum
self.gamma = torch.ones(dim) # 可学习增益,初始为1
self.beta = torch.zeros(dim) # 可学习偏置,初始为0
self.running_mean = torch.zeros(dim)
self.running_var = torch.ones(dim)
def __call__(self, x):
if self.training:
# 训练模式:使用当前批次统计
xmean = x.mean(0, keepdim=True)
xvar = x.var(0, keepdim=True)
else:
# 推理模式:使用运行统计
xmean = self.running_mean
xvar = self.running_var
xhat = (x - xmean) / torch.sqrt(xvar + self.eps)
out = self.gamma * xhat + self.beta
if self.training:
# 更新运行统计
with torch.no_grad():
self.running_mean = (1 - self.momentum) * self.running_mean + self.momentum * xmean
self.running_var = (1 - self.momentum) * self.running_var + self.momentum * xvar
return out




3.3 使用BatchNorm的注意事项






- 与偏置项共存:在线性层(
Linear)或卷积层(Conv2d)后接BatchNorm时,通常应将线性层的bias参数设为False。因为BatchNorm中的beta已经起到了偏置的作用,原线性层的偏置会在标准化时被减去,变得无用。 - 正则化效果:由于标准化时使用的均值和方差来自当前批次,这为每个样本的激活引入了一些噪声(因为批次是随机采样的)。这种噪声起到了轻微的正则化作用,有助于防止过拟合。
- 批次大小:BatchNorm的效果依赖于足够大的批次大小(batch size)来获得有意义的统计量估计。批次过小可能导致运行统计量估计不准确,训练不稳定。














4. 训练诊断工具 📊









为了确保神经网络训练健康,我们需要监控一些关键指标。以下是一些有用的诊断工具:






4.1 激活与梯度统计



- 前向传播激活直方图:绘制每一层激活值(特别是非线性层如
tanh的输出)的直方图。检查是否过度饱和(大量值集中在±1)或过度不活跃(大量值接近0)。 - 梯度直方图:绘制流经每一层的梯度的直方图。检查梯度是否消失(值非常小)或爆炸(值非常大)。








4.2 参数更新比率






更重要的指标是参数更新与其数值本身的比率。在一次优化步骤中,参数的更新量为 update = -learning_rate * gradient。我们关心 update / data 的尺度。






通常,我们希望这个比率在对数尺度上(log10(|update| / |data|))大约在 -3 左右(即更新量大约是参数值的千分之一)。这表示训练以稳定、适中的速度进行。




- 比率远大于-3(如-1):更新过大,可能导致训练不稳定。
- 比率远小于-3(如-5):更新过小,训练可能过于缓慢。








通过绘制不同网络层参数的这个比率随时间变化的曲线,可以直观判断学习率设置是否合适,以及网络各层是否在协调学习。

















总结 🎯









本节课我们一起深入探讨了神经网络训练的核心动态:







- 初始化的重要性:不恰当的初始化会导致输出层过度自信、隐藏层激活饱和,从而损害梯度流动和训练效率。我们学习了如何通过缩放权重来获得合理的初始激活分布。
- 原则性初始化:介绍了基于输入维度(
fan_in)和非线性函数增益(如tanh的5/3)的初始化方法(如Kaiming初始化),这为构建更深网络提供了指导。 - 批量归一化(BatchNorm):作为稳定深度网络训练的关键技术,BatchNorm通过标准化激活、引入可学习的缩放偏移参数、以及区分的训练/推理模式,极大地缓解了梯度消失/爆炸问题,并带来了正则化益处。
- 诊断与监控:我们学习了一套实用的诊断工具,包括检查激活/梯度直方图以及监控参数更新比率,这些工具能帮助我们判断网络训练是否健康,并指导超参数(如学习率)的调整。








通过理解激活、梯度和BatchNorm,我们为接下来探索更强大但也更难以训练的循环神经网络(RNN)架构奠定了坚实的基础。在下一课中,我们将开始构建RNN,并观察这些动态在其中扮演的关键角色。












P5:p5 建造 makemore 第四部分:成为一个反向传播忍者 🥷









在本节课中,我们将深入学习如何手动实现神经网络的反向传播。我们将从当前的多层感知器(MLP)架构出发,逐步替换 PyTorch 的自动微分功能,通过手动计算梯度来加深对神经网络内部工作原理的理解。












概述






到目前为止,我们已经实现并训练了一个包含批量归一化层的两层 MLP。我们的神经网络架构如下:















我们取得了不错的损失,并对架构有了较好的理解。然而,当前的代码依赖于 PyTorch 的 loss.backward() 来自动计算梯度。本节课的目标是移除这个依赖,手动在张量级别实现反向传播。









理解反向传播的内部机制至关重要,因为它是一个“漏水的抽象”。仅仅堆叠可微函数并期望反向传播自动工作是不够的。如果不理解其内部原理,在调试和优化网络时可能会遇到困难。










历史上,手动编写反向传播曾是标准做法。例如,在 2010 年左右,使用 MATLAB 或 NumPy 手动计算梯度非常普遍。虽然如今自动微分已成为标准,但掌握手动反向传播能让你成为更强大的神经网络实践者和调试者。








本节课的练习将分为四个部分:
- 将整个计算图分解为原子操作,并逐一进行反向传播。
- 对交叉熵损失进行数学推导,实现其高效的反向传播。
- 对批量归一化层进行数学推导,实现其高效的反向传播。
- 将所有手动反向传播代码整合,训练完整的 MLP。











现在,让我们开始第一个练习。










练习一:原子操作的反向传播









在第一个练习中,我们将把前向传播过程分解为许多细小的原子操作(如加法、乘法、指数、对数等),并逐一为每个操作手动计算梯度。我们将从损失开始,逐步反向传播到网络的输入。









以下是实现反向传播所需的关键步骤列表:









- 计算损失对 logprobs 的梯度 (
dlogprobs):损失是正确标签对应logprobs的负平均值。因此,梯度在正确标签位置为-1/n,其他位置为 0。dlogprobs = torch.zeros_like(logprobs) dlogprobs[range(n), yb] = -1.0 / n - 计算损失对 probs 的梯度 (
dprobs):logprobs = log(probs)。根据链式法则,dprobs = dlogprobs * (1 / probs)。dprobs = (1.0 / probs) * dlogprobs - 计算损失对计数和计数的梯度:
probs = counts / counts_sum。这里涉及除法和广播,需要小心处理。对于dcounts_sum,梯度需要沿被广播的维度求和。dcounts_sum = (-counts / (counts_sum**2)) * dprobs dcounts_sum = dcounts_sum.sum(dim=0, keepdim=True) dcounts = (1.0 / counts_sum) * dprobs - 计算损失对归一化 logits 的梯度 (
dnormlogits):counts = exp(normlogits)。因此,dnormlogits = counts * dcounts。dnormlogits = counts * dcounts - 计算损失对 logits 和 logit maxes 的梯度:
normlogits = logits - logitmaxes。这里也有广播。dlogits = dnormlogits.clone() dlogitmaxes = -dnormlogits.sum(dim=1, keepdim=True) - 计算损失对 logit maxes 的第二个分支梯度:
logitmaxes来自logits的最大值。梯度需要路由到最大值出现的位置。dlogits[range(n), logitmaxes_idx] += dlogitmaxes.squeeze() - 计算损失对隐藏层、权重和偏置的梯度(线性层):
logits = h @ w2 + b2。通过维度匹配推导,dh = dlogits @ w2.T,dw2 = h.T @ dlogits,db2 = dlogits.sum(dim=0)。dh = dlogits @ w2.T dw2 = h.T @ dlogits db2 = dlogits.sum(dim=0) - 计算损失对 tanh 激活前的梯度 (
dhpreact):h = tanh(hpreact)。导数dhpreact = (1 - h**2) * dh。dhpreact = (1 - h**2) * dh - 计算损失对批量归一化参数和输出的梯度:
hpreact = bngain * bnraw + bnbias。需要处理广播。dbngain = (bnraw * dhpreact).sum(dim=0, keepdim=True) dbnraw = bngain * dhpreact dbnbias = dhpreact.sum(dim=0, keepdim=True) - 继续反向传播通过批量归一化的各个原子步骤:包括对方差、均值、差值等中间变量的梯度计算,过程较为繁琐,需仔细处理广播和求和。
- 计算损失对第一个线性层参数和输入的梯度:与第二个线性层类似。
demcat = dhprebn @ w1.T dw1 = emcat.T @ dhprebn db1 = dhprebn.sum(dim=0) - 计算损失对嵌入层参数的梯度 (
dC):em是通过索引从嵌入表C中查得的。梯度需要根据索引累加回dC。dC = torch.zeros_like(C) for k in range(Xb.shape[0]): for j in range(Xb.shape[1]): ix = Xb[k, j] dC[ix] += dem[k, j, :]










通过逐步实现上述所有步骤,我们完成了对整个计算图的原子级反向传播。每一步的梯度都可以与 PyTorch 自动计算的梯度进行比较验证。

上一节我们通过分解原子操作完成了反向传播,但这种方法效率较低。接下来,我们将看到如何通过数学推导来简化关键部分的反向传播。



练习二:交叉熵损失的高效反向传播










在第二个练习中,我们将交叉熵损失视为一个整体数学表达式,并通过解析方式直接计算损失对 logits 的梯度,而不是通过多个原子操作。










交叉熵损失可以写为:
loss = -log(softmax(logits)[label]) 对于单个样本,或对批次取平均。









通过数学推导(应用微积分链式法则),我们可以得到损失对 logits 的梯度有一个非常简洁的形式:
对于批处理中的每个样本,dlogits = softmax(logits) - one_hot(label)。






直观理解是:梯度会降低错误类别(根据网络当前概率)的 logits,并提高正确类别的 logits,推拉的力量是平衡的。










以下是实现代码:
probs = F.softmax(logits, dim=1)
dlogits = probs.clone()
dlogits[range(n), yb] -= 1
dlogits /= n # 因为损失是平均值
这个简单的几行代码等价于练习一中从 logits 到 loss 之间所有原子操作的反向传播总和,但效率要高得多。





练习三:批量归一化的高效反向传播











类似地,对于批量归一化层,我们也可以推导出一个统一的反向传播公式。给定输入 x(即 hprebn)、输出 y(即 hpreact)、缩放参数 gamma(bngain)、偏移参数 beta(bnbias)、批次均值 mu、批次方差 sigma2(使用贝塞尔校正 n-1)和小常数 eps。










前向传播为:
xhat = (x - mu) / sqrt(sigma2 + eps)
y = gamma * xhat + beta









经过繁琐但直接的微积分推导(考虑 mu 和 sigma2 是 x 的函数,且 xhat 相互依赖),我们可以得到损失对输入 x 的梯度 dx 的解析表达式。










以下是根据推导结果实现的向量化代码:
# 前向传播中计算的中间变量
bnmean = bnraw.mean(dim=0, keepdim=True)
bndiff = bnraw - bnmean
bndiff2 = bndiff**2
bnvar = (bndiff2).sum(dim=0, keepdim=True) / (n - 1) # 贝塞尔校正
bnvar_inv = (bnvar + eps)**-0.5
bnrawhat = bndiff * bnvar_inv







# 反向传播 (dhpreact 是损失对 y 的梯度)
dbnraw = (bngain * bnvar_inv / (n-1)) * ( (n-1) * dhpreact - dhpreact.sum(dim=0, keepdim=True) - bnrawhat * (dhpreact * bnrawhat).sum(dim=0, keepdim=True) )
这个公式直接计算了 dhprebn(即 dx),避免了通过所有中间变量反向传播的复杂性。











练习四:整合与训练







在最后的练习中,我们将把所有手动实现的反向传播代码片段整合到一起,形成一个完整的、不依赖 loss.backward() 的训练循环。





我们将使用练习二和练习三推导出的高效反向传播公式,以及为线性层、激活函数和嵌入层手动编写的梯度计算代码。



关键步骤包括:
- 前向传播计算损失。
- 手动反向传播计算所有参数的梯度。
- 使用计算出的梯度(而非
p.grad)更新参数。 - 关闭 PyTorch 的梯度跟踪以提升效率 (
torch.no_grad)。
完成整合后,我们可以运行训练循环。经过足够迭代,损失会下降,并且从模型中进行采样可以生成看起来合理的“名字”,结果与使用自动微分时相同。这证明了我们手动计算梯度的正确性。

总结


本节课中,我们一起深入探讨了神经网络反向传播的内部机制。我们从最基础的原子操作开始,手动计算了每一个步骤的梯度。然后,我们通过数学推导,找到了交叉熵损失和批量归一化层更高效、更简洁的反向传播公式。最后,我们将所有手动代码整合,成功训练了一个多层感知器。










通过这个过程,我们揭开了自动微分的神秘面纱,强化了对梯度如何在网络中流动的理解。这不仅能帮助我们在未来更好地调试神经网络,也让我们对所使用的工具有了更深刻的认识。现在,我们可以充满信心地称自己为“反向传播忍者”了!











在下一节课中,我们将探索更复杂的架构,如循环神经网络(RNN)及其变体(LSTM),以获取更好的模型性能。






P6:构建 Makemore 第五部分:构建一个 WaveNet 🧠







在本节课中,我们将继续完善我们最喜爱的字符级语言模型。我们将从之前构建的多层感知器(MLP)架构出发,将其复杂化,以处理更长的输入序列,并构建一个更深、能逐步融合信息的模型。最终,我们将实现一个类似于 WaveNet 的层次化架构。







概述









在之前的课程中,我们构建了一个基于三层感知器的字符级语言模型。它接收三个先前的字符,并尝试预测序列中的第四个字符。本节课的目标是扩展这个模型,使其能够处理更长的上下文(例如8个字符),并通过一个更深的、树状层次化的结构来逐步融合信息,而不是一次性将所有信息压缩到一个隐藏层中。这种架构灵感来源于2016年的 WaveNet 论文,它本质上也是一个自回归模型,用于预测序列中的下一个元素。









从现有代码库开始






我们本节课的起点代码与第三部分结束时的代码非常相似。我们有一个处理好的数据集,包含约18.2万个“给定三个字符预测第四个字符”的示例。我们的代码已经模块化,包含了 Linear、BatchNorm 等层,其API设计模仿了PyTorch的 torch.nn 模块。





首先,我们注意到损失曲线因为批次大小太小而波动剧烈。我们通过将损失列表重塑为二维张量并计算行平均来平滑可视化。







# 将损失列表重塑为二维张量以平滑曲线
losses = torch.tensor(losses).view(-1, 1000).mean(1)








重构模型:引入更多模块








上一节我们处理了数据可视化问题。本节中,我们将重构模型的前向传播逻辑,使其更加模块化和简洁。








我们之前将嵌入表(C)和展平(view)操作放在了层列表之外。为了使结构更统一,我们创建了 Embedding 和 Flatten 模块,并将它们加入到层的序列中。








class Embedding:
def __init__(self, num_embeddings, embedding_dim):
self.weight = torch.randn((num_embeddings, embedding_dim))
def __call__(self, idx):
return self.weight[idx]









class Flatten:
def __call__(self, x):
return x.view(x.shape[0], -1)



接着,我们引入 Sequential 容器来管理这些层,这进一步简化了代码。Sequential 接收一个层列表,并在前向传播中依次调用它们。








class Sequential:
def __init__(self, layers):
self.layers = layers
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x






现在,我们的模型定义和前向传播变得非常清晰:








model = Sequential([
Embedding(vocab_size, n_embd),
Flatten(),
Linear(n_embd * block_size, n_hidden), BatchNorm(n_hidden), Tanh(),
Linear(n_hidden, vocab_size)
])
logits = model(xb)




扩展上下文并引入层次化结构


上一节我们重构了模型代码。本节中,我们将扩展模型的上下文长度,并引入WaveNet的核心思想——层次化融合。






首先,我们将输入上下文长度从3增加到8。这立即带来了性能提升(验证损失从 ~2.10 降至 ~2.02),因为模型拥有了更多信息。



但是,简单地将8个字符的嵌入向量展平后送入一个线性层,意味着信息被过早地压缩了。我们想要的是像WaveNet那样,以树状结构逐步融合信息:先融合相邻的两个字符,再融合上一层的两个“组”,以此类推。



为了实现这一点,我们需要修改 Flatten 层。我们创建了一个新的 FlattenConsecutive 层,它可以将连续的 n 个元素拼接在一起,并增加一个“组”的维度。








class FlattenConsecutive:
def __init__(self, n):
self.n = n
def __call__(self, x):
B, T, C = x.shape
x = x.view(B, T // self.n, C * self.n)
if x.shape[1] == 1:
x = x.squeeze(1)
return x






然后,我们重新设计模型架构。第一层 FlattenConsecutive(2) 将8个字符分成4组,每组2个字符的嵌入被拼接。随后的线性层只处理这“2个字符”的信息。之后,我们再次使用 FlattenConsecutive(2) 将4组合并为2组,以此类推,形成一个小型的层次化网络。






model = Sequential([
Embedding(vocab_size, n_embd),
FlattenConsecutive(2), Linear(n_embd * 2, n_hidden), BatchNorm(n_hidden), Tanh(),
FlattenConsecutive(2), Linear(n_hidden * 2, n_hidden), BatchNorm(n_hidden), Tanh(),
FlattenConsecutive(2), Linear(n_hidden * 2, n_hidden), BatchNorm(n_hidden), Tanh(),
Linear(n_hidden, vocab_size)
])







修复批归一化层





上一节我们构建了层次化模型。本节中,我们需要修复一个关键问题:BatchNorm 层对多维输入的处理。



我们原来的 BatchNorm 实现假设输入是二维的 (batch_size, features)。但在我们的新架构中,FlattenConsecutive 会产生三维输入 (batch_size, groups, features)。我们需要让 BatchNorm 在训练时,同时计算 batch 和 groups 维度上的均值和方差。






class BatchNorm:
def __call__(self, x):
if self.training:
dims = (0, 1) if x.ndim == 3 else (0)
xmean = x.mean(dims, keepdim=True)
xvar = x.var(dims, keepdim=True)
# ... 后续标准化和更新运行统计量







修复这个Bug后,模型性能得到了小幅但稳定的提升。








实验结果与未来方向


通过增加模型容量(如嵌入维度和隐藏层大小),我们最终将验证损失降低到了 1.993 左右,成功跨过了2.0的界限。





本节课我们一起实现了一个简化的WaveNet风格架构。我们学习了如何:
- 使用模块化构建块(如
Sequential)来组织复杂网络。 - 通过
FlattenConsecutive和线性层实现信息的层次化融合。 - 调整
BatchNorm以正确处理多维输入。







然而,我们实现的只是WaveNet思想的核心骨架。完整的WaveNet还包括门控激活单元、残差连接和空洞因果卷积(用于高效计算)。此外,我们缺乏一个系统的超参数搜索和实验框架,目前的优化更多是“猜测与检验”。






在未来的课程中,我们可以:
- 实现空洞卷积来高效地计算整个输入序列的输出。
- 添加残差连接以训练更深的网络。
- 建立实验管线,进行大规模的超参数优化。
- 探索循环神经网络(RNN/LSTM)和Transformer架构。








挑战:你可以尝试调整本课的模型(如各层通道数、嵌入维度),或者阅读WaveNet论文实现更复杂的层,看看能否击败 1.993 的验证损失记录。










总结:本节课中,我们从基础的MLP出发,逐步构建了一个层次化的、类似WaveNet的字符级语言模型。我们重构了代码使其更清晰,引入了层次化信息融合的概念,并修复了批归一化层的多维处理问题。虽然性能得到了提升,但这仅仅是探索现代深度神经网络架构的开始。
课程 P7:从零构建 GPT 🧠

在本节课中,我们将学习如何从零开始构建一个类似 GPT 的 Transformer 语言模型。我们将使用一个简单的字符级数据集(Tiny Shakespeare),并逐步实现模型的核心组件,包括自注意力机制、多头注意力、前馈网络以及残差连接等。通过这个过程,你将深入理解现代大型语言模型(如 ChatGPT)背后的基本原理。



概述 📋



Transformer 架构是当今许多先进 AI 系统的核心,它最初在 2017 年的论文《Attention Is All You Need》中被提出。GPT(Generative Pre-trained Transformer)正是基于此架构构建的。在本教程中,我们将专注于构建一个仅解码器的 Transformer,用于字符级语言建模任务。虽然我们无法复现 ChatGPT 那样的复杂系统,但通过构建一个微型版本,我们可以清晰地理解其工作原理。


我们将从处理数据开始,逐步实现模型的关键部分,并在 Tiny Shakespeare 数据集上进行训练,最终生成莎士比亚风格的文本。

1. 数据准备与分词 📚

首先,我们需要准备数据并将其转换为模型可以处理的格式。我们将使用 Tiny Shakespeare 数据集,它包含了莎士比亚的所有作品。
1.1 读取数据
我们从指定 URL 下载数据集,并将其读取为一个长字符串。
import torch
import requests
# 下载数据集
url = "https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt"
text = requests.get(url).text
print(f"数据集长度(字符数): {len(text)}")
print(text[:1000]) # 打印前1000个字符
1.2 创建词汇表
接下来,我们找出数据集中所有独特的字符,构建一个词汇表。每个字符将被映射到一个唯一的整数(标记)。
# 获取所有独特字符并排序
chars = sorted(list(set(text)))
vocab_size = len(chars)
print(f"词汇表大小: {vocab_size}")
print(''.join(chars)) # 打印所有字符
# 创建编码器和解码器
stoi = {ch: i for i, ch in enumerate(chars)} # 字符 -> 整数
itos = {i: ch for i, ch in enumerate(chars)} # 整数 -> 字符
def encode(s):
return [stoi[c] for c in s] # 字符串 -> 整数列表
def decode(l):
return ''.join([itos[i] for i in l]) # 整数列表 -> 字符串
# 测试编码解码
test_str = "hi there"
encoded = encode(test_str)
decoded = decode(encoded)
print(f"原始字符串: {test_str}")
print(f"编码后: {encoded}")
print(f"解码后: {decoded}")
1.3 划分数据集
我们将数据集分为训练集(90%)和验证集(10%)。验证集用于评估模型的泛化能力,防止过拟合。
# 将整个文本编码为整数张量
data = torch.tensor(encode(text), dtype=torch.long)
# 划分训练集和验证集
n = int(0.9 * len(data))
train_data = data[:n]
val_data = data[n:]

2. 数据批处理 🔄


由于我们无法一次性将整个数据集输入模型,因此需要从数据中随机抽取小块(批次)进行训练。每个批次包含多个独立的序列,模型将并行处理它们。
以下是创建数据批次的函数:

def get_batch(split):
# 根据 split 选择训练集或验证集
data = train_data if split == 'train' else val_data
# 生成随机起始索引
ix = torch.randint(len(data) - block_size, (batch_size,))
# 构建输入 x 和目标 y
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
# 设置超参数
batch_size = 4
block_size = 8
# 获取一个批次
xb, yb = get_batch('train')
print('输入 xb 的形状:', xb.shape)
print('目标 yb 的形状:', yb.shape)
print('输入示例:\n', xb)
print('目标示例:\n', yb)
在这个批次中,xb 是模型的输入,yb 是每个位置对应的下一个字符的目标值。模型的任务是根据 xb 的上下文预测 yb。
3. 基础模型:Bigram 语言模型 🔤
在深入 Transformer 之前,我们先实现一个最简单的语言模型——Bigram 模型。它仅根据当前字符的身份来预测下一个字符,不考虑任何上下文信息。
3.1 模型定义
Bigram 模型本质上是一个查找表,其中每个字符都直接预测下一个字符的分布。
import torch.nn as nn

class BigramLanguageModel(nn.Module):
def __init__(self, vocab_size):
super().__init__()
# 每个标记直接映射到下一个标记的 logits
self.token_embedding_table = nn.Embedding(vocab_size, vocab_size)
def forward(self, idx, targets=None):
# idx 和 targets 都是形状为 (B, T) 的整数张量
logits = self.token_embedding_table(idx) # (B, T, C)
if targets is None:
loss = None
else:
# 调整形状以匹配 PyTorch 的交叉熵损失期望
B, T, C = logits.shape
logits = logits.view(B*T, C)
targets = targets.view(B*T)
loss = nn.functional.cross_entropy(logits, targets)
return logits, loss
def generate(self, idx, max_new_tokens):
# idx 是当前上下文,形状为 (B, T)
for _ in range(max_new_tokens):
# 获取预测
logits, loss = self(idx)
# 只关注最后一步
logits = logits[:, -1, :] # 变为 (B, C)
# 应用 softmax 获取概率
probs = nn.functional.softmax(logits, dim=-1) # (B, C)
# 从分布中采样下一个标记
idx_next = torch.multinomial(probs, num_samples=1) # (B, 1)
# 将采样到的标记附加到序列上
idx = torch.cat((idx, idx_next), dim=1) # (B, T+1)
return idx
# 实例化模型
model = BigramLanguageModel(vocab_size)

3.2 训练与生成
我们可以用简单的优化循环来训练这个模型,并观察其生成效果。
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
for steps in range(10000):
# 获取一个数据批次
xb, yb = get_batch('train')
# 前向传播,计算损失
logits, loss = model(xb, yb)
# 反向传播,更新参数
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
print(f"最终损失: {loss.item()}")
# 生成文本
context = torch.zeros((1, 1), dtype=torch.long)
print(decode(model.generate(context, max_new_tokens=500)[0].tolist()))
Bigram 模型的表现非常有限,因为它没有利用上下文信息。接下来,我们将引入自注意力机制,让字符之间能够进行交流。
4. 自注意力机制 🤝
自注意力是 Transformer 的核心组件,它允许序列中的每个元素(标记)根据其与序列中其他元素的关系来聚合信息。

4.1 数学原理
自注意力的关键思想是让每个标记生成三个向量:查询(Query)、键(Key) 和 值(Value)。
- 查询(Q):表示“我正在寻找什么”。
- 键(K):表示“我包含什么信息”。
- 值(V):表示“如果被关注,我将传递什么信息”。
标记之间的亲和力(注意力权重)通过查询和键的点积计算:affinity = Q @ K^T。然后,我们使用这些权重对值进行加权求和,从而聚合信息。


为了实现语言建模中的因果性(即当前标记不能看到未来标记),我们使用一个下三角掩码矩阵,将未来位置的注意力权重设置为负无穷大,这样在 softmax 后它们的权重就变为 0。
4.2 实现单头自注意力
以下是单头自注意力的 PyTorch 实现:


class Head(nn.Module):
""" 单头自注意力 """
def __init__(self, head_size):
super().__init__()
self.key = nn.Linear(n_embd, head_size, bias=False)
self.query = nn.Linear(n_embd, head_size, bias=False)
self.value = nn.Linear(n_embd, head_size, bias=False)
# 下三角掩码,用于实现因果注意力
self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
self.dropout = nn.Dropout(dropout)
def forward(self, x):
B, T, C = x.shape
k = self.key(x) # (B, T, head_size)
q = self.query(x) # (B, T, head_size)
# 计算注意力分数(亲和力)
wei = q @ k.transpose(-2, -1) * C**-0.5 # (B, T, T) 缩放点积
# 应用因果掩码
wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf'))
wei = nn.functional.softmax(wei, dim=-1) # (B, T, T)
wei = self.dropout(wei)
# 加权聚合值
v = self.value(x) # (B, T, head_size)
out = wei @ v # (B, T, head_size)
return out



在这个实现中:
- 我们为键、查询和值定义了线性投影层。
- 计算缩放点积注意力分数,并应用因果掩码。
- 使用 softmax 将分数转换为概率分布(注意力权重)。
- 使用这些权重对值向量进行加权求和,得到输出。




5. 多头注意力与 Transformer 块 🧩


单个注意力头可能只关注特定类型的关系。为了捕捉更丰富的信息,我们并行使用多个注意力头,这就是多头注意力。


5.1 实现多头注意力
我们将多个单头注意力的输出在通道维度上拼接起来。
class MultiHeadAttention(nn.Module):
""" 多头自注意力 """
def __init__(self, num_heads, head_size):
super().__init__()
self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])
self.proj = nn.Linear(n_embd, n_embd) # 投影层
self.dropout = nn.Dropout(dropout)
def forward(self, x):
# 并行运行所有注意力头并拼接结果
out = torch.cat([h(x) for h in self.heads], dim=-1)
out = self.dropout(self.proj(out)) # 投影回残差路径
return out


5.2 前馈网络
在自注意力进行通信之后,每个标记需要独立处理收集到的信息。这是通过一个简单的前馈网络(FFN)实现的,通常是一个两层 MLP。



class FeedForward(nn.Module):
""" 简单的前馈网络 """
def __init__(self, n_embd):
super().__init__()
self.net = nn.Sequential(
nn.Linear(n_embd, 4 * n_embd), # 扩展维度
nn.ReLU(),
nn.Linear(4 * n_embd, n_embd), # 投影回原始维度
nn.Dropout(dropout),
)
def forward(self, x):
return self.net(x)



5.3 构建 Transformer 块
现在,我们将多头注意力和前馈网络组合成一个 Transformer 块。为了优化深度网络,我们引入残差连接和层归一化。

- 残差连接:将块的输入直接加到其输出上。这创建了一条梯度高速公路,有助于缓解深度网络中的梯度消失问题。
- 层归一化:在块内对每个标记的特征进行归一化,稳定训练过程。
class Block(nn.Module):
""" Transformer 块:通信(注意力)后接计算(前馈) """
def __init__(self, n_embd, n_head):
super().__init__()
head_size = n_embd // n_head
self.sa = MultiHeadAttention(n_head, head_size) # 多头自注意力
self.ffwd = FeedForward(n_embd) # 前馈网络
self.ln1 = nn.LayerNorm(n_embd) # 层归一化 1
self.ln2 = nn.LayerNorm(n_embd) # 层归一化 2
def forward(self, x):
# 带残差连接和层归一化的自注意力
x = x + self.sa(self.ln1(x))
# 带残差连接和层归一化的前馈网络
x = x + self.ffwd(self.ln2(x))
return x

6. 构建完整 GPT 模型 🏗️


现在,我们可以将所有组件组合起来,构建完整的 GPT 模型。我们的模型将包括:
- 标记嵌入层:将整数标记转换为向量。
- 位置嵌入层:为序列中的每个位置提供位置信息。
- 多个 Transformer 块(解码器块)。
- 最终的层归一化和线性投影层,用于预测下一个标记。



class GPTLanguageModel(nn.Module):
def __init__(self):
super().__init__()
# 每个标记对应一个嵌入向量
self.token_embedding_table = nn.Embedding(vocab_size, n_embd)
# 每个位置对应一个嵌入向量
self.position_embedding_table = nn.Embedding(block_size, n_embd)
# 堆叠 Transformer 块
self.blocks = nn.Sequential(*[Block(n_embd, n_head=n_head) for _ in range(n_layer)])
# 最终的层归一化
self.ln_f = nn.LayerNorm(n_embd)
# 语言建模头,将特征投影回词汇表
self.lm_head = nn.Linear(n_embd, vocab_size)
def forward(self, idx, targets=None):
B, T = idx.shape
# 获取标记嵌入和位置嵌入
tok_emb = self.token_embedding_table(idx) # (B, T, n_embd)
pos_emb = self.position_embedding_table(torch.arange(T, device=device)) # (T, n_embd)
x = tok_emb + pos_emb # (B, T, n_embd)
# 通过 Transformer 块
x = self.blocks(x)
x = self.ln_f(x)
logits = self.lm_head(x) # (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 = nn.functional.cross_entropy(logits, targets)
return logits, loss
def generate(self, idx, max_new_tokens):
# idx 是当前上下文 (B, T)
for _ in range(max_new_tokens):
# 如果上下文过长,裁剪到块大小
idx_cond = idx if idx.size(1) <= block_size else idx[:, -block_size:]
# 获取预测
logits, loss = self(idx_cond)
# 关注最后一步
logits = logits[:, -1, :] # (B, C)
# 应用 softmax 获取概率
probs = nn.functional.softmax(logits, dim=-1) # (B, C)
# 从分布中采样下一个标记
idx_next = torch.multinomial(probs, num_samples=1) # (B, 1)
# 将采样到的标记附加到序列上
idx = torch.cat((idx, idx_next), dim=1) # (B, T+1)
return idx



7. 模型训练与评估 🚀


现在,我们可以使用更大的超参数来训练我们的 GPT 模型,并观察其性能。


7.1 设置超参数与设备
# 超参数
batch_size = 64 # 每批处理的独立序列数
block_size = 256 # 最大上下文长度
max_iters = 5000 # 训练迭代次数
eval_interval = 500 # 每多少步评估一次
learning_rate = 3e-4 # 学习率
device = 'cuda' if torch.cuda.is_available() else 'cpu' # 使用 GPU 如果可用
eval_iters = 200 # 评估时平均损失的批次数量
n_embd = 384 # 嵌入维度
n_head = 6 # 注意力头数量
n_layer = 6 # Transformer 块层数
dropout = 0.2 # Dropout 比率

# 实例化模型并移至设备
model = GPTLanguageModel()
m = model.to(device)
print(f"模型参数量: {sum(p.numel() for p in m.parameters())/1e6:.2f} M")


# 创建优化器
optimizer =
# 课程 P8:GPT的现状 🧠
在本节课中,我们将学习大型语言模型(如GPT)是如何被训练出来的,以及如何有效地将它们应用于实际任务。课程内容分为两部分:第一部分介绍训练GPT助手的完整流程,第二部分探讨如何在实际应用中最佳地使用这些助手。

## 第一部分:如何训练GPT助手 🏗️

训练一个像GPT这样的助手模型是一个多阶段的过程。整个过程大致可以分为四个主要阶段:预训练、监督微调、奖励建模和强化学习。下面我们将逐一详细介绍。

### 1. 预训练阶段:打造基础模型
预训练是整个过程的核心,消耗了绝大部分的计算资源和时间。这个阶段的目标是让模型学会理解和生成人类语言。

首先,我们需要收集海量的文本数据。这些数据通常来自互联网,混合了多种来源。

以下是构成训练数据混合物的常见来源:
* Common Crawl(网络爬虫数据)
* C4(另一种常见的爬虫数据集)
* 高质量数据集,如:GitHub代码、维基百科、书籍、学术论文、Stack Exchange问答等。
这些数据按照特定比例混合采样,形成神经网络的训练集。在训练之前,文本需要经过一个称为“分词”的预处理步骤。分词将原始文本无损地转换为整数序列,因为这是GPT模型能够理解的“原生”格式。常用的算法包括字节对编码(BPE)。

**分词示例**:`"Hello world!"` 可能被转换为整数序列 `[15496, 995, 0]`。

接下来,我们看看管理这个阶段的一些关键超参数。以GPT-3和LLaMA为例:

* **词汇表大小**:通常在数万级别(例如,50,257个标记)。
* **上下文长度**:决定模型一次能查看的标记数量,早期是2K或4K,现在可达100万。
* **模型参数量**:GPT-3有1750亿参数,LLaMA有650亿参数。
* **训练数据量**:GPT-3训练了约3000亿标记,而LLaMA训练了约1.4万亿标记。

模型的强大程度不仅取决于参数数量,更与训练数据量和训练时长密切相关。用于指定Transformer架构的超参数包括头数、维度、层数等。训练一个650亿参数的模型可能需要约2000个GPU训练21天,成本达数百万美元。

那么,预训练具体是如何进行的呢?我们将分词后的数据组织成批次。每个批次包含多行独立文档,每行长度等于上下文长度。文档之间用特殊的结束标记分隔。

模型的任务是预测序列中的下一个标记。以图中的绿色单元格为例,Transformer神经网络会查看它之前的所有黄色标记(即上下文),然后尝试预测下一个红色标记是什么。模型会为词汇表中的每一个可能的标记输出一个概率。

**训练目标**:通过比较模型的预测概率和实际的下一个标记(监督信号),使用反向传播算法不断调整Transformer的数十亿个参数,使其预测越来越准确。

训练开始时,模型的权重是随机的,输出也是杂乱无章的。随着训练的进行,模型逐渐学会单词、语法和文本结构。我们可以通过观察训练损失(loss)的下降来追踪进展,损失越低,意味着模型预测正确下一个标记的概率越高。
预训练完成后,我们得到了一个“基础模型”。人们发现,这种在庞大语料上训练出的模型,学到了强大的通用语言表示能力,可以高效地适配到各种下游任务。

### 2. 从基础模型到助手模型

基础模型本质上是“文档续写者”,它只想完成它认为的文档。例如,如果你问它“法国的首都是什么?”,它可能会续写成“法国的首都是一个常见的问题,答案是巴黎。”,而不是直接给出答案。

为了让模型成为有用的“助手”,我们需要对它进行进一步的调优。主要有两种路径:

**路径一:提示工程**
我们可以通过精心设计输入文本来“欺骗”基础模型执行任务。例如,使用“少样本提示”,在问题前提供几个问答示例,使模型模仿这种格式来回答问题。甚至可以通过构造“人类与助手对话”的文档格式,诱使基础模型扮演助手角色。但这种方法并不总是可靠。

**路径二:监督微调**
这是创建真正助手模型的更可靠方法。在此阶段,我们需要收集一个小规模但高质量的数据集。
以下是数据集的构建方式:
* 聘请人类标注员,根据详细的指南(要求回答有帮助、真实、无害)来编写“提示”和对应的“理想回答”。
* 通常需要数十万条这样的数据。


然后,我们在这个新数据集上继续执行**语言建模任务**。算法不变,只是训练数据从互联网文档换成了高质量的问答对。训练后得到的模型称为“SFT模型”,它是一个可以直接部署的助手模型。
### 3. 基于人类反馈的强化学习

为了使助手表现更好,我们可以引入基于人类反馈的强化学习。这个阶段分为两步:奖励建模和强化学习。


**第一步:奖励建模**
我们改变数据收集的形式,从“写答案”变为“比较答案”。

以下是数据收集过程:
1. 使用已有的SFT模型为同一个提示生成多个(例如,三个)不同的回答。
2. 让人类标注员对这些回答进行质量排序。

接着,我们训练一个“奖励模型”。该模型的任务是:给定一个提示和回答,预测一个标量奖励值,代表这个回答的质量。训练时,我们让奖励模型的预测尽量与人类标注员的排序一致。

**第二步:强化学习**
现在,我们固定奖励模型,用它来指导SFT模型的进一步优化。
以下是强化学习的流程:
1. 收集一大批提示。
2. 用当前的SFT模型为每个提示生成回答。
3. 用奖励模型为每个回答打分。
4. 调整SFT模型的参数,使其生成的、获得高奖励的回答在未来出现的概率更高,同时降低低奖励回答的出现概率。
这个过程通常使用近端策略优化等强化学习算法。最终得到的模型就是“RLHF模型”。例如,ChatGPT就是一个RLHF模型。

那么,为什么需要RLHF?实验表明,人类通常更喜欢RLHF模型的输出。一个可能的原因是:对于人类来说,“比较两个答案哪个更好”比“凭空写出一个完美答案”要容易得多。RLHF更高效地利用了人类的判断力。
但需要注意的是,RLHF模型并非在所有方面都优于基础模型。它们可能会失去一些“创造性”或“多样性”,输出变得更加确定和保守。在需要生成多样化内容(如构思创意名称)的场景下,基础模型可能更有优势。

目前,能力最强的助手模型(如GPT-4、Claude)大多经过了RLHF训练。而许多开源模型(如Koala)是SFT模型。
## 第二部分:如何有效使用GPT助手 🛠️

了解了模型的训练过程后,我们来看看如何在实际应用中最佳地使用它们。我们将通过一个具体例子来理解人类与LLM在解决问题时的认知差异。

假设你要写一句话:“加利福尼亚的人口是阿拉斯加的53倍。”你的思考过程可能是:
1. **意识**:我需要比较两个州的人口。
2. **知识检索**:我不知道具体数字,需要查维基百科。
3. **工具使用**:查到数字后,需要用计算器做除法。
4. **反思验证**:53倍这个结果合理吗?加州人口最多,似乎合理。
5. **创作与修订**:尝试组织句子,觉得“有53倍于”很拗口,删掉重写,最终定稿。
这个过程涉及丰富的内心独白、工具使用和递归验证。然而,对于GPT来说,它看到的只是一个接一个的标记序列。它对每个标记进行的计算是相同且有限的(例如,一个80层的Transformer对每个标记进行80步“思考”)。它没有持续的内心独白,不会在过程中主动检查错误或使用外部工具,它只是在模仿训练数据中下一个标记出现的概率。

因此,我们可以把提示工程看作是弥补人类与LLM之间认知架构差异的桥梁。以下是一些核心策略:
### 1. 给予模型“思考时间”

LLM需要标记来“思考”。对于复杂问题,不能指望它在一个标记内给出答案。
**关键技术**:
* **思维链**:在提示中要求模型“逐步推理”或“展示你的工作”。这迫使模型将推理过程分散到多个输出标记上,从而更可能得出正确答案。例如,使用“让我们一步一步地思考...”作为提示开头。
* **自我一致性**:不要只采样一次。让模型多次生成回答,然后通过投票或选择最佳答案的方式聚合结果,避免单次采样的随机性。

### 2. 明确要求高质量输出

LLM训练数据中既有高质量答案,也有低质量答案。它默认会模仿所有内容。你需要明确要求它给出专家级答案。
**关键技术**:
* 在提示中指定角色,如“你是一个顶尖的物理学家”或“请确保答案正确”。
* 这有助于模型将概率质量集中在高质量输出上,而不是平均分配给所有可能的续写方式。

### 3. 弥补模型的能力缺陷
LLM可能不擅长精确计算、获取实时信息或处理特定格式。我们需要通过提示或外部工具来弥补。

**关键技术**:
* **工具使用**:明确告诉模型“你不太擅长心算,请使用提供的计算器工具”,并定义工具的使用格式。许多框架(如ReAct)将工具调用集成到模型的思考过程中。
* **检索增强**:将模型庞大的内部记忆与外部检索结合起来。使用向量数据库等技术,将与任务相关的文档片段检索出来,并插入到模型的上下文中,作为其“工作记忆”。这能极大提升模型在特定领域的表现。
* **输出约束**:使用指导采样等技术,强制模型的输出遵循特定格式(如JSON、XML),确保输出易于被下游程序解析。

### 4. 超越单一提示:构建系统
复杂的任务往往不能通过一次问答完成。
**关键技术**:
* **提示链**:将多个提示串联起来,形成工作流。例如,先让模型规划步骤,再分步执行,最后总结。
* **反思与重试**:让模型评估自己生成的答案是否正确,如果不正确,则重新尝试。这模拟了人类的自我修正过程。
* **树状搜索**:像AlphaGo一样,维护多个可能的推理路径(思维树),对它们进行评估和扩展,最终选择最优路径。这需要Python代码来协调多个LLM调用。
### 实践建议与总结

对于初学者和应用开发者,建议遵循以下路径:

1. **优先提示工程**:从最强大的模型(如GPT-4)开始,设计详细、包含示例和背景信息的提示。充分考虑LLM的“心理特点”,使用思维链、检索增强等技术。
2. **考虑系统设计**:不要局限于单一提示。思考如何用代码将多个提示、工具调用和逻辑判断粘合起来,构建一个可靠的系统。
3. **最后考虑微调**:当提示工程潜力用尽时,再考虑微调。监督微调相对直接,但需要高质量数据。RLHF则非常复杂且不稳定,目前不建议初学者尝试。
4. **认识局限性并安全使用**:始终记住LLM存在幻觉、偏见、知识过时、易受攻击等局限。建议在低风险场景中使用,将其作为“副驾驶”提供灵感和建议,并保持人类监督。


---

**本节课总结**:我们一起学习了GPT助手训练的四个核心阶段(预训练、监督微调、奖励建模、强化学习),理解了基础模型与助手模型的区别。更重要的是,我们探讨了如何通过提示工程、工具使用和系统设计来弥合人类与LLM的认知差异,从而在实际应用中有效、可靠地利用这些强大的模型。记住,LLM是惊人的“标记模拟器”,而我们的任务是引导它,为它创造“思考”的条件。


# 课程 P9:构建 GPT 分词器 🧩
在本节课中,我们将要学习大型语言模型(LLM)中一个关键但常被忽视的组件:分词器。我们将了解什么是分词、为什么它如此重要,并动手从零开始实现一个基于字节对编码(BPE)的分词器。通过本教程,你将理解分词如何影响模型的性能,并掌握构建和训练自定义分词器的核心技能。
## 概述:什么是分词?
分词是将文本字符串转换为一系列整数(称为“词元”或“标记”)的过程,这些整数是语言模型能够理解和处理的基本单位。在之前的课程《从头开始构建 GPT》中,我们使用了一个简单的字符级分词器。然而,实际应用中的 LLM(如 GPT 系列)使用更复杂的分词方案,例如字节对编码。
分词是许多 LLM 奇怪行为的根源,例如拼写困难、处理非英语语言效果差、算术能力不佳等。理解分词的工作原理对于深入理解 LLM 至关重要。
## 从字符级分词到子词分词
上一节我们介绍了简单的字符级分词。本节中我们来看看更先进的子词分词方法。
在字符级分词中,每个字符(如 `‘h’`, `‘i’`)被映射为一个独立的整数。虽然简单,但这会导致序列非常长,效率低下。例如,句子 “hello there” 会被编码为一系列代表每个字符的整数。
实际操作中,我们使用子词分词。它将常见的字符组合(如 `‘he’`, `‘ll’`, `‘o’`)合并为单独的标记,从而压缩序列长度。这通过字节对编码等算法实现。
## 字节对编码算法详解
字节对编码是一种数据压缩算法,后来被应用于 NLP 的分词任务。其核心思想是迭代地合并数据中最常见的字节对。
以下是 BPE 算法的基本步骤:
1. 将文本编码为 UTF-8 字节序列,初始词汇表为 256 个字节(0-255)。
2. 统计所有相邻字节对的出现频率。
3. 找到出现频率最高的字节对。
4. 为该字节对创建一个新的标记,并将其加入词汇表。
5. 在数据中,将所有出现的该字节对替换为这个新标记。
6. 重复步骤 2-5,直到达到预设的词汇表大小或没有更多可合并的对。
通过这种方式,我们从基础的字节开始,逐步构建出代表常见字符组合的标记,从而实现对文本的高效压缩。
## 实现 BPE 分词器
现在,让我们动手实现一个基础的 BPE 分词器。我们将编写训练函数来从数据中学习合并规则,并编写编码/解码函数来进行文本和标记之间的转换。
首先,我们需要一个函数来统计字节对的出现频率。
```python
def get_stats(ids):
"""
统计给定整数ID列表中相邻元素对的出现次数。
Args:
ids: 整数列表,代表字节或标记。
Returns:
一个字典,键为(元素1, 元素2)的元组,值为出现次数。
"""
counts = {}
for pair in zip(ids, ids[1:]):
counts[pair] = counts.get(pair, 0) + 1
return counts
接下来,实现合并最高频字节对的函数。
def merge(ids, pair, idx):
"""
在ID序列中,用新ID替换所有出现的指定字节对。
Args:
ids: 整数列表。
pair: 要合并的字节对,例如 (101, 32)。
idx: 用于替换的新标记ID(例如 256)。
Returns:
合并后的新ID列表。
"""
newids = []
i = 0
while i < len(ids):
# 如果找到匹配的对,则进行合并
if i < len(ids) - 1 and (ids[i], ids[i+1]) == pair:
newids.append(idx)
i += 2
else:
newids.append(ids[i])
i += 1
return newids
现在,我们可以编写训练循环,迭代地进行合并,构建词汇表。
def train_bpe(text, vocab_size):
"""
在文本上训练BPE分词器。
Args:
text: 训练文本字符串。
vocab_size: 目标词汇表大小。
Returns:
merges: 记录合并规则的字典,键为合并后的ID,值为被合并的字节对。
vocab: 从标记ID到字节表示的映射。
"""
# 1. 将文本编码为UTF-8字节,并转换为整数列表
tokens = list(text.encode(‘utf-8’))
# 初始词汇表大小是256(0-255)
num_merges = vocab_size - 256
merges = {} # (id1, id2) -> new_id
vocab = {idx: bytes([idx]) for idx in range(256)} # id -> bytes
for i in range(num_merges):
# 2. 统计当前标记序列中字节对的频率
stats = get_stats(tokens)
if not stats:
break
# 3. 找到最常出现的字节对
top_pair = max(stats, key=stats.get)
# 4. 分配新的ID(从256开始)
idx = 256 + i
# 5. 记录合并规则
merges[top_pair] = idx
# 6. 更新词汇表:新标记是子标记字节的拼接
vocab[idx] = vocab[top_pair[0]] + vocab[top_pair[1]]
# 7. 在序列中应用合并
tokens = merge(tokens, top_pair, idx)
return merges, vocab
编码与解码
训练好分词器(获得 merges 和 vocab)后,我们需要实现编码(文本 -> 标记)和解码(标记 -> 文本)功能。
解码相对简单:将每个标记 ID 通过 vocab 映射回其字节表示,然后连接并解码为字符串。
def decode(ids, vocab):
"""
将标记ID序列解码为文本字符串。
Args:
ids: 标记ID列表。
vocab: 从标记ID到字节表示的映射。
Returns:
解码后的字符串。
"""
# 将每个ID转换为其字节表示
tokens_bytes = b’’.join(vocab[idx] for idx in ids)
# 将字节解码为字符串,使用 ‘replace’ 处理无效字节
text = tokens_bytes.decode(‘utf-8’, errors=‘replace’)
return text


编码过程需要模拟训练时的合并过程,将文本转换为字节后,反复应用合并规则。
def encode(text, merges):
"""
将文本字符串编码为标记ID序列。
Args:
text: 输入文本。
merges: 训练得到的合并规则字典。
Returns:
标记ID列表。
"""
# 将文本转换为UTF-8字节,再转为整数列表
tokens = list(text.encode(‘utf-8’))
# 只要还有可合并的对,就持续合并
while True:
stats = get_stats(tokens)
# 找到当前序列中优先级最高(在merges中索引最小)的可合并对
pair_to_merge = None
min_idx = float(‘inf’)
for pair in stats:
idx = merges.get(pair)
if idx is not None and idx < min_idx:
min_idx = idx
pair_to_merge = pair
# 如果没有可合并的对,结束循环
if pair_to_merge is None:
break
# 应用合并
idx = merges[pair_to_merge]
tokens = merge(tokens, pair_to_merge, idx)
return tokens
实际分词器的复杂性
我们上面实现的是一个基础的、纯算法的 BPE 分词器。在实际应用中(如 GPT-2, GPT-4),分词器引入了更多规则来处理复杂情况。
预处理规则:例如,GPT-2 使用一个复杂的正则表达式模式,在 BPE 合并之前先将文本分割成不同的块(如字母、数字、标点符号)。这确保了合并只发生在特定类别内部,防止了像将 “dog.” 和 “dog!” 合并成不同标记的情况,使分词更加一致。
特殊标记:除了从数据中学习到的标记,分词器还会引入特殊标记,如 <|endoftext|> 用于分隔文档,或在聊天模型中用于区分用户、助手和系统消息的标记。这些标记在词汇表中拥有独立的 ID,并在处理时被特殊对待。
词汇表大小的影响:词汇表大小是一个关键超参数。太小的词汇表(如字符级)会导致序列过长,消耗大量计算资源。太大的词汇表则会使每个标记出现的频率降低,可能导致嵌入训练不足,同时也会增加模型输出层的计算负担。目前先进的模型通常在数万到十万左右。


分词器与模型训练的关系
需要明确的是,分词器的训练与语言模型本身的训练是两个独立的阶段。
- 分词器训练:使用一个代表性数据集(可能与模型训练集不同),运行 BPE 算法,确定合并规则和最终词汇表。这个过程产生
merges和vocab两个核心组件。 - 模型训练:使用训练好的分词器,将海量的模型训练文本全部转换为标记序列。这些标记序列被保存下来,语言模型在此标记序列上进行训练,学习预测下一个标记。
这种分离意味着我们可以针对不同的目标(如多语言支持、代码处理)优化分词器,而不必重新训练整个大模型。
总结
本节课中我们一起学习了构建 GPT 分词器的核心知识:
- 分词的重要性:分词是文本进入 LLM 的桥梁,其设计直接影响模型处理各种任务(拼写、多语言、算术、代码)的能力。
- BPE 算法原理:通过迭代合并最常见字节对来构建词汇表,实现从字符到子词的压缩表示。
- 分词器实现:我们实现了
train_bpe、encode和decode等核心函数,构建了一个可工作的基础分词器。 - 实际考量:了解了实际分词器(如 OpenAI 的 tiktoken)引入的预处理规则、特殊标记等复杂性,以及词汇表大小等设计选择。
- 训练流程:明确了分词器训练与语言模型训练是两个独立且先后进行的阶段。



分词虽然是一个预处理步骤,但它深远地影响着语言模型的行为和能力。希望本教程能帮助你揭开分词的神秘面纱,并为深入理解和使用大型语言模型打下坚实基础。


浙公网安备 33010602011771号