🏠 总目录📚 本教程 挑战A · 手搓 mini-torch ← →
📑 本页目录(点开跳转)

18 / 一次完成一个小任务

先让一个数求对梯度,
再造一个小框架。

mini-torch 的第一站是一个能检查答案的计算图,不是完整复刻训练框架。

先抓住一个具体结果

从一个可验算的结果开始

令 x=3,y=x×x,那么 y=9,dy/dx=6。先让你的反向传播得到 6,再加入更多运算,并与数值差分核对。

接下来,按需要选一项

你现在想看什么?

这些入口是选择,不是必须按顺序完成的任务。

可以停在这里

mini-torch 的第一站是一个能检查答案的计算图,不是完整复刻训练框架。

需要更多细节时,继续看完整正文 →
继续探索

原有正文、图解和例子都在下方。按需跳转,不必一次读完。

挑战项目 A · 手搓一个 mini-torch

⏱ 5–7 天 | 难度 ★★★★☆ | 前置:第 7–11、15 章


🎯 为什么选这个项目

第 8 章你手写了一个固定网络的反向传播。这个项目更进一步: 造一个通用的自动微分引擎——任意搭网络,它自动求导。

做完你会得到的东西:

import minitorch as mt

model = mt.Sequential(
    mt.Linear(30, 64), mt.ReLU(),
    mt.Linear(64, 1))
loss = mt.BCEWithLogits(model(x), y)
loss.backward()          # ← 你自己实现的!
optimizer.step()

然后你会彻底明白:

  • PyTorch 的 .backward() 到底在干什么
  • 为什么梯度会默认累加
  • 为什么 .detach() 能切断梯度
  • 计算图是什么、什么时候被释放

这是理解深度学习框架的分水岭。 之后你用任何框架,都知道皮下是什么。


🏗️ 目标架构

先看关系:Tensor 是共同的核心;网络层、损失函数和优化器都围绕它工作。 下面不是执行顺序,也不是目录清单。

mini-torch:Tensor 核心与三个模块的关系 Tensor 内含 data、grad、prev 和 backward 回调。网络层用 Tensor 计算输出,损失函数用 Tensor 计算损失,优化器读取 Tensor 的梯度并更新数据。三个模块并列,都依赖 Tensor。 minitorch Tensor · 共同核心 .data 数值(numpy 数组) .grad 累积的梯度 ._prev 父节点集合 ._backward 这个节点的反传规则 网络层 损失函数 优化器
看连线:三个模块共用 Tensor,不是依次经过三个步骤。

一次运算,做两件事

__add__、__mul__、__matmul__:先算出结果,再记录这个结果如何把梯度传回父节点。

两个 backward,职责不同

  • ._backward:只负责当前节点怎么反传。
  • backward():先拓扑排序整张图,再从输出往输入调用各节点的 ._backward。

三个模块,各管一件事

  • 网络层 nn.Module:Linear / ReLU / Sequential / Dropout,负责搭网络、算输出。
  • 损失函数:MSE / BCEWithLogits / CrossEntropy,负责衡量预测误差。
  • 优化器 optim:SGD / Momentum / Adam,负责用梯度更新参数。

🧠 ADHD 任务切分

一次只选一项。时间是参考,不是倒计时;较长的任务可以分几次完成。勾选表示你已核对该项结果,不代表页面替你运行了测试。

共 14 项任务,可逐项勾选。

第一阶段 · 自动微分内核

Day 1–2 · T1–T4

这一阶段最关键:先让一个小计算图求对梯度,再扩展网络。

参考用时 · 90 分钟

用 Tensor 包住 numpy 数组,加入 grad、_backward、_prev 三个字段。

完成标志:一个对象里能找到数值、梯度和计算图信息。

参考用时 · 120 分钟,可按算子拆开做

实现 +、*、@ 的前向计算和反向传播。

完成标志:三个算子既能给出结果,也能把梯度传回输入。

难点:广播的反向。形状不匹配时,梯度要 sum 回原来的形状。

参考用时 · 90 分钟

实现 backward():拓扑排序整张图,从输出往输入依次调用 _backward。

完成标志:共享节点等所有下游贡献累加齐,再向前传梯度。

参考用时 · 60 分钟

逐个算子对比数值梯度与解析梯度。

完成标志:相对误差小于 1e-6;不能只凭网络能训练就跳过。

停靠点:T1–T4 通过后,你已经有了一个可检验的自动微分内核。

第二阶段 · 网络层与损失

Day 3 · T5–T8

把已验证的算子组合起来,让搭网络不再需要手写每条连接。

参考用时 · 60 分钟

实现线性层,并加入 Xavier / He 参数初始化。

完成标志:线性层能产生正确形状的输出,参数参与计算图。

参考用时 · 45 分钟

实现 ReLU、Tanh、Sigmoid。

完成标志:三种激活都能前向计算,并传回梯度。

参考用时 · 60 分钟

实现 MSELoss 和 BCEWithLogitsLoss。

完成标志:能算出损失,并反传到预测值。

注意数值稳定:BCEWithLogitsLoss 使用 logsumexp 技巧。

参考用时 · 45 分钟

实现 Sequential 和递归收集参数的 parameters()。

完成标志:多层能顺序运行,内部可训练参数都能被收集到。

停靠点:现在能搭网络、算损失;下一阶段才加入参数更新。

第三阶段 · 优化器与训练

Day 4 · T9–T11

让参数真正更新,观察你的框架能不能学会一个小任务。

参考用时 · 60 分钟

实现 SGD(含 momentum)和 Adam。

完成标志:调用更新方法后,参数按照对应规则改变。

参考用时 · 45 分钟

实现 zero_grad(),观察连续反传时梯度为什么默认累加。

完成标志:你能分别演示“累加梯度”和“清零后重新计算”。

参考用时 · 60 分钟

用自己的框架训练异或,再训练 load_digits 手写数字。

完成标志:留下两次训练的损失记录;手写数字的最终验收见下方通关标准。

停靠点:能训练是进展,但还不能替代与参考框架的逐项比较。

第四阶段 · 对拍与报告

Day 5–7 · T12–T14

对拍就是给两个框架相同条件,比较结果,而不只是看最后的准确率。

参考用时 · 90 分钟

使用同样的网络、初始权重和输入,逐元素比较 loss 与梯度。

完成标志:误差小于 1e-5,并核对通关标准要求的前 10 步。

参考用时 · 60 分钟

加入 Dropout 和 no_grad() 上下文管理器。

完成标志:检查 Dropout 的训练与推理行为,以及 no_grad 下不记录求导图的行为。

参考用时 · 90 分钟

README 包含架构图、计算图可视化、对拍结果,以及“我从造轮子里理解了什么”。

完成标志:别人能顺着报告了解结构、复现比较,并看懂你的结论。

停靠点:保留对拍记录和报告,再逐项核对下方通关标准。


🔨 核心骨架(起步代码)

import numpy as np

class Tensor:
    def __init__(self, data, _prev=(), requires_grad=True):
        self.data = np.asarray(data, dtype=np.float64)
        self.grad = np.zeros_like(self.data)
        self._prev = set(_prev)
        self._backward = lambda: None       # 默认叶子节点什么都不做
        self.requires_grad = requires_grad

    def __add__(self, other):
        other = other if isinstance(other, Tensor) else Tensor(other)
        out = Tensor(self.data + other.data, (self, other))
        def _backward():
            # ⭐ 难点:广播的反向 —— 把梯度 sum 回原来的形状
            self.grad += unbroadcast(out.grad, self.data.shape)
            other.grad += unbroadcast(out.grad, other.data.shape)
        out._backward = _backward
        return out

    def __matmul__(self, other):
        out = Tensor(self.data @ other.data, (self, other))
        def _backward():
            self.grad  += out.grad @ other.data.T
            other.grad += self.data.T @ out.grad
        out._backward = _backward
        return out

    def relu(self):
        out = Tensor(np.maximum(0, self.data), (self,))
        def _backward():
            self.grad += out.grad * (self.data > 0)
        out._backward = _backward
        return out

    def backward(self):
        # 拓扑排序:保证调用某节点的 _backward 前,它的所有下游都已算完
        topo, visited = [], set()
        def build(v):
            if v not in visited:
                visited.add(v)
                for p in v._prev: build(p)
                topo.append(v)
        build(self)
        self.grad = np.ones_like(self.data)     # 起点:dloss/dloss = 1
        for node in reversed(topo):
            node._backward()


def unbroadcast(grad, shape):
    """把广播后的梯度还原回原形状 —— 这是最容易写错的地方"""
    while grad.ndim > len(shape):
        grad = grad.sum(axis=0)
    for i, s in enumerate(shape):
        if s == 1:
            grad = grad.sum(axis=i, keepdims=True)
    return grad

🕳️ 专属坑

坑 说明
广播的反向 ⭐ 最常错 (32,64) + (64,) 的反向要把梯度 sum 到 (64,)。不处理会形状报错或静默算错
梯度累加 vs 覆盖 必须用 += 不是 = —— 一个节点可能被多条路径用到(这就是 PyTorch 梯度累加的由来)
拓扑排序顺序 必须保证下游全部算完才算上游,否则梯度不全
数值稳定 softmax 要减 max;log 要加 eps;BCE 要用 logsumexp 形式
只测网络不测算子 网络能训不代表每个算子都对。逐算子做梯度检验
原地修改 numpy 数组 会破坏计算图,调试到怀疑人生

🔗 这一章连到哪里

去哪 为什么
数学原理 12 ⭐ 手搓 autograd 之前先把链式法则的矩阵形式推一遍,否则调试时无从下手
AI基础设施 09 做到「存不存中间值」那一步时:这就是梯度检查点,用时间换显存
智能体 18 同构的「手搓一个框架」项目,阶段切分方式可以直接借

🏁 通关标准

  1. 每个算子单独通过梯度检验(相对误差 < 1e-6)
  2. 和 PyTorch 对拍:相同初始权重下,前 10 步的 loss 和梯度逐元素一致(< 1e-5)
  3. 能训练 load_digits 到测试精度 > 92%
  4. 实现了 zero_grad() / no_grad() / Dropout
  5. README 能说清「计算图是什么时候建的、什么时候被用的」

🏆 加分

  • 实现广播的完整支持(不只是常见情况)
  • 加一个计算图可视化(graphviz 画出节点和边)
  • 实现 Conv2d(用 im2col 技巧)—— 难度陡增,但做出来极有成就感
  • 对比你的框架和 PyTorch 的速度差距,分析瓶颈在哪

✅ 检查点

  1. Tensor 需要携带哪几样东西?为什么每个运算都要记录 _backward?
  2. backward() 为什么必须先做拓扑排序?不排会怎样?
  3. 广播的反向传播为什么是这个项目最难的一块?该怎么处理?
  4. 实现 zero_grad() 的那一刻,你会理解 PyTorch 的什么设计?
  5. 两个硬标准分别是什么?为什么"和 PyTorch 对拍"不能替代逐算子梯度检验?
  6. 做完这个项目,你对框架的认知会发生什么变化?
👀 答案
  1. 数据 + 梯度 + 计算图节点信息(_prev 前驱、_backward 怎么反传)。记录 _backward 是因为反向传播时需要知道"这个结果是怎么算出来的"——前向时不记,反向时就无从下手。
  2. 因为一个节点的梯度必须等它所有下游节点都算完才完整。不排序就可能在某个节点的梯度还没累加齐时就往上传,得到错的结果。
  3. 因为前向广播了,反向就要"收回来":一个 (1, n) 的偏置广播成 (m, n) 参与运算,它的梯度必须沿 batch 维求和才能回到 (1, n)。忘了 unbroadcast 会直接形状报错,或者更糟——静默地算错。
  4. 理解梯度为什么会累加:框架不会自动清零,是因为梯度累积(模拟大 batch)需要这个行为。所以 zero_grad() 是你的责任,忘了训练就废。
  5. ①逐算子梯度检验 < 1e-6 ②和 PyTorch 端到端对拍 < 1e-5。不能互相替代是因为:对拍只告诉你"整体对不对",不告诉你"哪个算子错了";而且两个算子的错误可能互相抵消,端到端看起来正常。
  6. 从"框架是黑箱"变成"框架就是我写的这些东西的工程化版本"。之后看 PyTorch 源码、调试梯度问题、实现自定义算子,都不再有心理门槛。

🛑 可以停在这里

走神救援 · 只记这四点

  • Tensor 保存数值、梯度和计算图信息;每次运算既算结果,也记录反传规则。
  • backward() 排好顺序再反传;共享节点的梯度要先累加齐。
  • 广播的梯度要收回原形状;梯度是否清零,由你显式控制。
  • 两道检验都要过:逐算子梯度检验,以及与 PyTorch 对拍。能训练不等于算对了。

今天可以停下。下次回到四阶段任务区,从尚未完成的一项继续。

下一个挑战 👉 19-挑战项目B-四种解法对决.md

打卡记录保存在你的浏览器里,首页能看到总进度