构建你自己的迷你框架

Build~120 minutesPython先修要求: All of Phase 03 (Lessons 01-09)
你已经分别构建了神经元、层、网络、反向传播、激活函数、损失函数、优化器、正则化、初始化和学习率调度。现在把它们连成一个框架。不是 PyTorch,不是 TensorFlow,而是你自己的。
学习目标
  • 用约 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 有三个职责:

  1. forward() —— 给定输入计算输出
  2. parameters() —— 返回所有可训练权重
  3. 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
    end

Module 层级

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
gradient-clipping静态版本未包含此交互图

构建它

步骤 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

练习

  1. 添加一个 SoftmaxCrossEntropyLoss 类用于多分类。对预测做 softmax,计算交叉熵损失,并处理合并后的反向传播。在 3 类 spiral 数据集上测试。
  1. 在优化器中实现学习率调度:添加 set_lr() 方法,并接入第 09 课的余弦调度。用 warmup + 余弦训练圆环分类器,并与恒定学习率比较。
  1. 给 Sequential 添加 save() 和 load() 方法,把所有权重序列化到 JSON 文件并加载回来。验证加载后的模型与原始模型预测一致。
  1. 在 Adam 优化器中实现权重衰减(L2 正则化)。添加 weight_decay 参数,每步把权重向零收缩。比较 decay=0 与 decay=0.01 的训练效果。
  1. 把逐样本训练循环替换为真正的小批量梯度累积:在一个 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++ 深度学习框架,用于理解框架内部

“Stay hungry, stay foolish.”

— Steve Jobs

esc
↑↓切换↵打开esc关闭