🏠 总目录📚 本教程 挑战A · 手搓 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()

然后你会彻底明白

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


🏗️ 目标架构

   minitorch/
   ├── Tensor          ← 核心:数据 + 梯度 + 计算图节点
   │     .data         numpy 数组
   │     .grad         梯度
   │     ._backward    这个节点怎么把梯度传给父节点
   │     ._prev        父节点集合
   │
   ├── 运算符重载       __add__ __mul__ __matmul__ ...
   │     每个运算都:① 算结果 ② 记录怎么反传
   │
   ├── backward()      拓扑排序 + 从后往前调用 _backward
   │
   ├── nn.Module       Linear / ReLU / Sequential / Dropout
   ├── 损失函数         MSE / BCEWithLogits / CrossEntropy
   └── optim           SGD / Momentum / Adam

🧠 ADHD 任务切分

第一阶段:自动微分内核(Day 1–2)⭐ 最难也最关键

第二阶段:网络层与损失(Day 3)

第三阶段:优化器与训练(Day 4)

第四阶段:对拍与报告(Day 5–7)


🔨 核心骨架(起步代码)

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 能说清「计算图是什么时候建的、什么时候被用的」

🏆 加分


✅ 检查点

  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 源码、调试梯度问题、实现自定义算子,都不再有心理门槛。

🛑 可以停在这里

走神救援

造 mini-torch(5–7 天,前置第 7–11、15 章):⭐Tensor = 数据 + 梯度 + 计算图节点(_prev 前驱、_backward 怎么把梯度传给父节点)每个运算都做两件事:①算结果 ②记录怎么反传backward()先对整张图拓扑排序,再从输出往输入依次调 _backward,⚠️不排序就会在某个节点梯度还没累加齐时往上传,得到错的结果四阶段内核(Tensor → 三个运算的前向反向 → 拓扑排序 → ⭐逐算子梯度检验,误差 <1e-6 才算过)→ 层与损失(BCE 用 logsumexp 保数值稳定)→ 优化器(⭐实现 zero_grad() 那一刻你就懂了"梯度为什么默认累加"——因为梯度累积需要这个行为,清零是你的责任,忘了训练就废)→ 和 PyTorch 对拍。⚠️专属坑:⭐广播的反向最常错——(32,64)+(64,) 的反向必须把梯度 sum 回 (64,),不处理会形状报错或更糟:静默算错梯度必须用 += 而不是 =(一个节点可能被多条路径用到,这就是 PyTorch 梯度累加的由来);⚠️只测网络不测算子:能训不代表每个算子都对;💀原地改 numpy 数组会破坏计算图,调试到怀疑人生两个硬标准:⭐①逐算子梯度检验 <1e-6 ②和 PyTorch 对拍,前 10 步的 loss 和梯度逐元素一致 <1e-5;⚠️不能互相替代——对拍只说"整体对不对",不说"哪个算子错了",而且两个算子的错可能互相抵消。做完之后,框架从黑箱变成"我写的这些东西的工程化版本"

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

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