- 用约 500 行代码构建一个完整的深度学习框架,包括 Module、Linear、ReLU、Sigmoid、Dropout、BatchNorm、Sequential、损失函数、优化器和 DataLoader
- 解释 Module 抽象(forward、backward、parameters)以及为什么需要 train/eval 模式切换
- 把所有组件接入一个可用的训练循环,训练一个 4 层网络完成圆环分类
- 将框架中的每个组件映射到对应的 PyTorch 等价物(nn.Module、nn.Sequential、optim.Adam、DataLoader)
问题
你有十节课的构建块散落在不同文件里:一个 Value 类在这里,一个训练循环在那里,权重初始化在另一个文件,学习率调度又在另一个文件。要训练一个网络,你得从五节课里复制粘贴,再手工连线。
这就是框架要解决的问题。PyTorch 给了你 nn.Module、nn.Sequential、optim.Adam、DataLoader,以及把它们串起来的训练循环模式。TensorFlow 给了你 keras.Layer、keras.Sequential、keras.optimizers.Adam。这些不是魔法,而是组织模式,让你不必每次重新定义管道就能定义、训练和评估网络。
你将用约 500 行 Python 构建同样的东西。不用 numpy,不用外部依赖。这个框架可以定义任意前馈网络,用 SGD 或 Adam 训练,对数据分批,应用 dropout 和 batch normalization,使用任意激活函数,并调度学习率。
完成之后,当你写 model = nn.Sequential(...) 时,你会明白每一行到底发生了什么。你会理解为什么 model.train() 和 model.eval() 存在,为什么 optimizer.zero_grad() 是单独的一步。你会理解这一切,因为这都是你亲手构建的。
概念
Module 抽象
PyTorch 中的每一层都继承自 nn.Module。Module 有三个职责:
- forward() —— 给定输入计算输出
- parameters() —— 返回所有可训练权重
- backward() —— 计算梯度(在 PyTorch 中由 autograd 处理,在我们的框架里显式实现)
线性层是 Module,ReLU 激活是 Module,dropout 层是 Module,batch normalization 层也是 Module。它们都有相同的接口。
Sequential 容器
nn.Sequential 把 Module 串成链。前向传播:数据经过 Module 1,再 Module 2,再 Module 3。反向传播:逆序穿过这条链。容器本身也是 Module——它有 forward()、parameters() 和 backward()。这就是组合模式:一组 Module 序列本身也是 Module。
训练模式与评估模式
Dropout 在训练期间随机把神经元置零,在评估期间则全部放行。Batch normalization 在训练期间使用 batch 统计量,在评估期间使用滑动平均。train() 和 eval() 方法切换这种行为。每个 Module 都有一个 training 标志。
优化器
优化器用梯度更新参数。SGD:param -= lr * grad。Adam:维护动量和方差估计,然后更新。优化器不了解网络架构——它只看到扁平的参数列表和对应的梯度。
DataLoader
分批很重要,原因有二。第一,对于大规模问题,你无法把整个数据集放进内存。第二,小批量梯度下降引入的噪声有助于逃离局部最小值。DataLoader 把数据分成 batch,并可选地在每个 epoch 之间打乱。
框架架构
graph TD
subgraph "Modules"
Linear["Linear<br/>W*x + b"]
ReLU["ReLU<br/>max(0, x)"]
Sigmoid["Sigmoid<br/>1/(1+e^-x)"]
Dropout["Dropout<br/>random zero mask"]
BatchNorm["BatchNorm<br/>normalize activations"]
end
subgraph "Containers"
Sequential["Sequential<br/>chains modules"]
end
subgraph "Loss Functions"
MSE["MSELoss<br/>(pred - target)^2"]
BCE["BCELoss<br/>binary cross-entropy"]
end
subgraph "Optimizers"
SGD["SGD<br/>param -= lr * grad"]
Adam["Adam<br/>adaptive moments"]
end
subgraph "Data"
DataLoader["DataLoader<br/>batching + shuffle"]
end
Sequential --> |"contains"| Linear
Sequential --> |"contains"| ReLU
Sequential --> |"forward/backward"| MSE
SGD --> |"updates"| Sequential
DataLoader --> |"feeds"| Sequential训练循环
sequenceDiagram
participant DL as DataLoader
participant M as Model
participant L as Loss
participant O as Optimizer
loop Each Epoch
DL->>M: batch of inputs
M->>M: forward pass (layer by layer)
M->>L: predictions
L->>L: compute loss
L->>M: backward pass (gradients)
M->>O: parameters + gradients
O->>M: updated parameters
O->>O: zero gradients
endModule 层级
classDiagram
class Module {
+forward(x)
+backward(grad)
+parameters()
+train()
+eval()
}
class Linear {
-weights
-biases
+forward(x)
+backward(grad)
}
class ReLU {
+forward(x)
+backward(grad)
}
class Sequential {
-modules[]
+forward(x)
+backward(grad)
+parameters()
}
Module <|-- Linear
Module <|-- ReLU
Module <|-- Sequential
Sequential *-- Module构建它
步骤 1:Module 基类
每个层都要实现的抽象接口。
pythonclass Module:
def __init__(self):
self.training = True
def forward(self, x):
raise NotImplementedError
def backward(self, grad):
raise NotImplementedError
def parameters(self):
return []
def train(self):
self.training = True
def eval(self):
self.training = False步骤 2:线性层
最基本的构建块。存储权重和偏置,前向计算 Wx + b,反向计算权重/输入梯度。
pythonimport math
import random
class Linear(Module):
def __init__(self, fan_in, fan_out):
super().__init__()
std = math.sqrt(2.0 / fan_in)
self.weights = [[random.gauss(0, std) for _ in range(fan_in)] for _ in range(fan_out)]
self.biases = [0.0] * fan_out
self.weight_grads = [[0.0] * fan_in for _ in range(fan_out)]
self.bias_grads = [0.0] * fan_out
self.fan_in = fan_in
self.fan_out = fan_out
self.input = None
def forward(self, x):
self.input = x
output = []
for i in range(self.fan_out):
val = self.biases[i]
for j in range(self.fan_in):
val += self.weights[i][j] * x[j]
output.append(val)
return output
def backward(self, grad):
input_grad = [0.0] * self.fan_in
for i in range(self.fan_out):
self.bias_grads[i] += grad[i]
for j in range(self.fan_in):
self.weight_grads[i][j] += grad[i] * self.input[j]
input_grad[j] += grad[i] * self.weights[i][j]
return input_grad
def parameters(self):
params = []
for i in range(self.fan_out):
for j in range(self.fan_in):
params.append((self.weights, i, j, self.weight_grads))
params.append((self.biases, i, None, self.bias_grads))
return params步骤 3:激活模块
把 ReLU、Sigmoid、Tanh 都实现为 Module。每个都会缓存反向传播需要的信息。
pythonclass ReLU(Module):
def __init__(self):
super().__init__()
self.mask = None
def forward(self, x):
self.mask = [1.0 if v > 0 else 0.0 for v in x]
return [max(0.0, v) for v in x]
def backward(self, grad):
return [g * m for g, m in zip(grad, self.mask)]
class Sigmoid(Module):
def __init__(self):
super().__init__()
self.output = None
def forward(self, x):
self.output = []
for v in x:
v = max(-500, min(500, v))
self.output.append(1.0 / (1.0 + math.exp(-v)))
return self.output
def backward(self, grad):
return [g * o * (1 - o) for g, o in zip(grad, self.output)]
class Tanh(Module):
def __init__(self):
super().__init__()
self.output = None
def forward(self, x):
self.output = [math.tanh(v) for v in x]
return self.output
def backward(self, grad):
return [g * (1 - o * o) for g, o in zip(grad, self.output)]步骤 4:Dropout 模块
训练期间随机把元素置零。把剩余元素缩放 1/(1-p),让期望值保持不变。评估期间什么都不做。
pythonclass Dropout(Module):
def __init__(self, p=0.5):
super().__init__()
self.p = p
self.mask = None
def forward(self, x):
if not self.training:
return x
self.mask = [0.0 if random.random() < self.p else 1.0 / (1 - self.p) for _ in x]
return [v * m for v, m in zip(x, self.mask)]
def backward(self, grad):
if self.mask is None:
return grad
return [g * m for g, m in zip(grad, self.mask)]步骤 5:BatchNorm 模块
把每个特征在 batch 内归一化为零均值、单位方差。为评估模式维护滑动统计量。
pythonclass BatchNorm(Module):
def __init__(self, size, momentum=0.1, eps=1e-5):
super().__init__()
self.size = size
self.gamma = [1.0] * size
self.beta = [0.0] * size
self.gamma_grads = [0.0] * size
self.beta_grads = [0.0] * size
self.running_mean = [0.0] * size
self.running_var = [1.0] * size
self.momentum = momentum
self.eps = eps
self.x_norm = None
self.std_inv = None
self.batch_input = None
def forward_batch(self, batch):
batch_size = len(batch)
output_batch = []
if self.training:
mean = [0.0] * self.size
for sample in batch:
for j in range(self.size):
mean[j] += sample[j]
mean = [m / batch_size for m in mean]
var = [0.0] * self.size
for sample in batch:
for j in range(self.size):
var[j] += (sample[j] - mean[j]) ** 2
var = [v / batch_size for v in var]
self.std_inv = [1.0 / math.sqrt(v + self.eps) for v in var]
self.x_norm = []
self.batch_input = batch
for sample in batch:
normed = [(sample[j] - mean[j]) * self.std_inv[j] for j in range(self.size)]
self.x_norm.append(normed)
output = [self.gamma[j] * normed[j] + self.beta[j] for j in range(self.size)]
output_batch.append(output)
for j in range(self.size):
self.running_mean[j] = (1 - self.momentum) * self.running_mean[j] + self.momentum * mean[j]
self.running_var[j] = (1 - self.momentum) * self.running_var[j] + self.momentum * var[j]
else:
std_inv = [1.0 / math.sqrt(v + self.eps) for v in self.running_var]
for sample in batch:
normed = [(sample[j] - self.running_mean[j]) * std_inv[j] for j in range(self.size)]
output = [self.gamma[j] * normed[j] + self.beta[j] for j in range(self.size)]
output_batch.append(output)
return output_batch
def forward(self, x):
result = self.forward_batch([x])
return result[0]
def backward(self, grad):
if self.x_norm is None:
return grad
for j in range(self.size):
self.gamma_grads[j] += self.x_norm[0][j] * grad[j]
self.beta_grads[j] += grad[j]
return [grad[j] * self.gamma[j] * self.std_inv[j] for j in range(self.size)]
def parameters(self):
params = []
for j in range(self.size):
params.append((self.gamma, j, None, self.gamma_grads))
params.append((self.beta, j, None, self.beta_grads))
return params步骤 6:Sequential 容器
把模块串成链。前向从左到右,反向从右到左。
pythonclass Sequential(Module):
def __init__(self, *modules):
super().__init__()
self.modules = list(modules)
def forward(self, x):
for module in self.modules:
x = module.forward(x)
return x
def backward(self, grad):
for module in reversed(self.modules):
grad = module.backward(grad)
return grad
def parameters(self):
params = []
for module in self.modules:
params.extend(module.parameters())
return params
def train(self):
self.training = True
for module in self.modules:
module.train()
def eval(self):
self.training = False
for module in self.modules:
module.eval()步骤 7:损失函数
MSE 和二分类交叉熵。每个返回损失值,并提供返回梯度的 backward()。
pythonclass MSELoss:
def __call__(self, predicted, target):
self.predicted = predicted
self.target = target
n = len(predicted)
self.loss = sum((p - t) ** 2 for p, t in zip(predicted, target)) / n
return self.loss
def backward(self):
n = len(self.predicted)
return [2 * (p - t) / n for p, t in zip(self.predicted, self.target)]
class BCELoss:
def __call__(self, predicted, target):
self.predicted = predicted
self.target = target
eps = 1e-7
n = len(predicted)
self.loss = 0
for p, t in zip(predicted, target):
p = max(eps, min(1 - eps, p))
self.loss += -(t * math.log(p) + (1 - t) * math.log(1 - p))
self.loss /= n
return self.loss
def backward(self):
eps = 1e-7
n = len(self.predicted)
grads = []
for p, t in zip(self.predicted, self.target):
p = max(eps, min(1 - eps, p))
grads.append((-t / p + (1 - t) / (1 - p)) / n)
return grads步骤 8:SGD 和 Adam 优化器
两者都接收参数列表,用梯度更新权重。
pythonclass SGD:
def __init__(self, parameters, lr=0.01):
self.params = parameters
self.lr = lr
def step(self):
for container, i, j, grad_container in self.params:
if j is not None:
container[i][j] -= self.lr * grad_container[i][j]
else:
container[i] -= self.lr * grad_container[i]
def zero_grad(self):
for container, i, j, grad_container in self.params:
if j is not None:
grad_container[i][j] = 0.0
else:
grad_container[i] = 0.0
class Adam:
def __init__(self, parameters, lr=0.001, beta1=0.9, beta2=0.999, eps=1e-8):
self.params = parameters
self.lr = lr
self.beta1 = beta1
self.beta2 = beta2
self.eps = eps
self.t = 0
self.m = [0.0] * len(parameters)
self.v = [0.0] * len(parameters)
def step(self):
self.t += 1
for idx, (container, i, j, grad_container) in enumerate(self.params):
if j is not None:
g = grad_container[i][j]
else:
g = grad_container[i]
self.m[idx] = self.beta1 * self.m[idx] + (1 - self.beta1) * g
self.v[idx] = self.beta2 * self.v[idx] + (1 - self.beta2) * g * g
m_hat = self.m[idx] / (1 - self.beta1 ** self.t)
v_hat = self.v[idx] / (1 - self.beta2 ** self.t)
update = self.lr * m_hat / (math.sqrt(v_hat) + self.eps)
if j is not None:
container[i][j] -= update
else:
container[i] -= update
def zero_grad(self):
for container, i, j, grad_container in self.params:
if j is not None:
grad_container[i][j] = 0.0
else:
grad_container[i] = 0.0步骤 9:DataLoader
把数据分成 batch,可选每个 epoch 打乱。
pythonclass DataLoader:
def __init__(self, data, batch_size=32, shuffle=True):
self.data = data
self.batch_size = batch_size
self.shuffle = shuffle
def __iter__(self):
indices = list(range(len(self.data)))
if self.shuffle:
random.shuffle(indices)
for start in range(0, len(indices), self.batch_size):
batch_indices = indices[start:start + self.batch_size]
batch = [self.data[i] for i in batch_indices]
inputs = [item[0] for item in batch]
targets = [item[1] for item in batch]
yield inputs, targets
def __len__(self):
return (len(self.data) + self.batch_size - 1) // self.batch_size步骤 10:训练一个 4 层网络完成圆环分类
把所有东西连起来。定义模型、选择损失、选择优化器、运行训练循环。
pythondef make_circle_data(n=500, seed=42):
random.seed(seed)
data = []
for _ in range(n):
x = random.uniform(-2, 2)
y = random.uniform(-2, 2)
label = 1.0 if x * x + y * y < 1.5 else 0.0
data.append(([x, y], [label]))
return data
def train():
random.seed(42)
model = Sequential(
Linear(2, 16),
ReLU(),
Linear(16, 16),
ReLU(),
Linear(16, 8),
ReLU(),
Linear(8, 1),
Sigmoid(),
)
criterion = BCELoss()
optimizer = Adam(model.parameters(), lr=0.01)
data = make_circle_data(500)
split = int(len(data) * 0.8)
train_data = data[:split]
test_data = data[split:]
loader = DataLoader(train_data, batch_size=16, shuffle=True)
model.train()
for epoch in range(100):
total_loss = 0
total_correct = 0
total_samples = 0
for batch_inputs, batch_targets in loader:
batch_loss = 0
for x, t in zip(batch_inputs, batch_targets):
pred = model.forward(x)
loss = criterion(pred, t)
batch_loss += loss
optimizer.zero_grad()
grad = criterion.backward()
model.backward(grad)
optimizer.step()
predicted_class = 1.0 if pred[0] >= 0.5 else 0.0
if predicted_class == t[0]:
total_correct += 1
total_samples += 1
total_loss += batch_loss
avg_loss = total_loss / total_samples
accuracy = total_correct / total_samples * 100
if epoch % 10 == 0 or epoch == 99:
print(f"Epoch {epoch:3d} | Loss: {avg_loss:.6f} | Train Accuracy: {accuracy:.1f}%")
model.eval()
correct = 0
for x, t in test_data:
pred = model.forward(x)
predicted_class = 1.0 if pred[0] >= 0.5 else 0.0
if predicted_class == t[0]:
correct += 1
test_accuracy = correct / len(test_data) * 100
print(f"\nTest Accuracy: {test_accuracy:.1f}% ({correct}/{len(test_data)})")
return model, test_accuracy使用它
以下是你刚才构建的东西在 PyTorch 中的等价版本:
pythonimport torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
model = nn.Sequential(
nn.Linear(2, 16),
nn.ReLU(),
nn.Linear(16, 16),
nn.ReLU(),
nn.Linear(16, 8),
nn.ReLU(),
nn.Linear(8, 1),
nn.Sigmoid(),
)
criterion = nn.BCELoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
for epoch in range(100):
model.train()
for inputs, targets in dataloader:
optimizer.zero_grad()
predictions = model(inputs)
loss = criterion(predictions, targets)
loss.backward()
optimizer.step()
model.eval()
with torch.no_grad():
test_predictions = model(test_inputs)结构完全一致:Sequential、Linear、ReLU、Sigmoid、BCELoss、Adam、zero_grad、backward、step、train、eval。每个概念都一一对应。区别在于 PyTorch 自动处理 autograd(不需要在每个模块里实现 backward())、在 GPU 上运行,并经过多年优化。但骨架是一样的。
现在再看 PyTorch 代码,你就知道每一行背后发生了什么。这才是本节课的全部意义。
交付它
本节课产出:
outputs/prompt-framework-architect.md—— 一个使用框架抽象设计神经网络架构的 prompt
练习
- 添加一个
SoftmaxCrossEntropyLoss类用于多分类。对预测做 softmax,计算交叉熵损失,并处理合并后的反向传播。在 3 类 spiral 数据集上测试。
- 在优化器中实现学习率调度:添加
set_lr()方法,并接入第 09 课的余弦调度。用 warmup + 余弦训练圆环分类器,并与恒定学习率比较。
- 给 Sequential 添加
save()和load()方法,把所有权重序列化到 JSON 文件并加载回来。验证加载后的模型与原始模型预测一致。
- 在 Adam 优化器中实现权重衰减(L2 正则化)。添加
weight_decay参数,每步把权重向零收缩。比较 decay=0 与 decay=0.01 的训练效果。
- 把逐样本训练循环替换为真正的小批量梯度累积:在一个 batch 内所有样本上累积梯度,然后除以 batch size,再走一步优化。测量这是否改变收敛速度。
关键术语
| 术语 | 常见说法 | 实际含义 |
|---|---|---|
| Module | "一层" | 框架中的基础抽象——任何具有 forward()、backward() 和 parameters() 的东西 |
| Sequential | "按顺序堆叠层" | 把模块串成链,前向按顺序应用,反向逆序应用 |
| Forward pass | "运行网络" | 按顺序把输入穿过每个模块以计算输出 |
| Backward pass | "计算梯度" | 把损失梯度反向穿过每个模块以计算参数梯度 |
| Parameters | "可训练权重" | 优化器可以更新的网络中所有值——权重和偏置 |
| Optimizer | "更新权重的东西" | 使用梯度更新参数的算法,实现 SGD、Adam 等规则 |
| DataLoader | "喂数据的东西" | 把数据集拆成 batch 的迭代器,可选每个 epoch 打乱 |
| Training mode | "model.train()" | 启用随机行为的标志,如 dropout 和使用 batch 统计的 batch normalization |
| Evaluation mode | "model.eval()" | 禁用 dropout,并对 batch normalization 使用滑动统计量的标志 |
| Zero grad | "清空梯度" | 在计算下一批梯度之前把所有参数梯度重置为零 |
延伸阅读
- Paszke et al., "PyTorch: An Imperative Style, High-Performance Deep Learning Library" (2019) —— 描述 PyTorch 设计决策的论文
- Chollet, "Deep Learning with Python, Second Edition" (2021) —— 第 3 章介绍了 Keras 内部同样的 module/layer 抽象
- Johnson, "Tiny-DNN" (https://github.com/tiny-dnn/tiny-dnn) —— 一个仅头文件的 C++ 深度学习框架,用于理解框架内部