📑 本页目录(点开跳转)
挑战项目 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()能切断梯度 - 计算图是什么、什么时候被释放
这是理解深度学习框架的分水岭。 之后你用任何框架,都知道皮下是什么。
🏗️ 目标架构
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)⭐ 最难也最关键
- [ ] T1 (90min)
Tensor类:包 numpy 数组,加grad/_backward/_prev三个字段 - [ ] T2 (120min) 实现
+*@三个运算的前向和反向。⭐ 难点:广播的反向(形状不匹配时梯度要 sum 回去) - [ ] T3 (90min)
backward():拓扑排序整张图,从输出往输入依次调_backward - [ ] T4 (60min) ⭐ 梯度检验:对每个运算做数值 vs 解析对比,误差 < 1e-6 才算过
第二阶段:网络层与损失(Day 3)
- [ ] T5 (60min)
Linear(含参数初始化:Xavier / He) - [ ] T6 (45min) 激活:
ReLU/Tanh/Sigmoid - [ ] T7 (60min) 损失:
MSELoss/BCEWithLogitsLoss(⭐ 注意数值稳定:用 logsumexp 技巧) - [ ] T8 (45min)
Sequential+parameters()递归收集参数
第三阶段:优化器与训练(Day 4)
- [ ] T9 (60min)
SGD(含 momentum)和Adam - [ ] T10 (45min)
zero_grad()—— ⭐ 实现它的时候你就懂了「为什么梯度默认累加」 - [ ] T11 (60min) 用你的框架训练异或 +
load_digits手写数字
第四阶段:对拍与报告(Day 5–7)
- [ ] T12 (90min) ⭐ 和 PyTorch 对拍:同样的网络、同样的初始权重、同样的输入,两边的 loss 和梯度必须逐元素一致(误差 < 1e-5)
- [ ] T13 (60min) 加
Dropout、no_grad()上下文管理器 - [ ] T14 (90min) 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 | 同构的「手搓一个框架」项目,阶段切分方式可以直接借 |
✅ 通关标准
- 每个算子单独通过梯度检验(相对误差 < 1e-6)
- 和 PyTorch 对拍:相同初始权重下,前 10 步的 loss 和梯度逐元素一致(< 1e-5)
- 能训练
load_digits到测试精度 > 92% - 实现了
zero_grad()/no_grad()/Dropout - README 能说清「计算图是什么时候建的、什么时候被用的」
🏆 加分
- 实现广播的完整支持(不只是常见情况)
- 加一个计算图可视化(graphviz 画出节点和边)
- 实现
Conv2d(用 im2col 技巧)—— 难度陡增,但做出来极有成就感 - 对比你的框架和 PyTorch 的速度差距,分析瓶颈在哪
✅ 检查点
Tensor需要携带哪几样东西?为什么每个运算都要记录_backward?backward()为什么必须先做拓扑排序?不排会怎样?- 广播的反向传播为什么是这个项目最难的一块?该怎么处理?
- 实现
zero_grad()的那一刻,你会理解 PyTorch 的什么设计? - 两个硬标准分别是什么?为什么"和 PyTorch 对拍"不能替代逐算子梯度检验?
- 做完这个项目,你对框架的认知会发生什么变化?
👀 答案
- 数据 + 梯度 + 计算图节点信息(
_prev前驱、_backward怎么反传)。记录_backward是因为反向传播时需要知道"这个结果是怎么算出来的"——前向时不记,反向时就无从下手。 - 因为一个节点的梯度必须等它所有下游节点都算完才完整。不排序就可能在某个节点的梯度还没累加齐时就往上传,得到错的结果。
- 因为前向广播了,反向就要"收回来":一个
(1, n)的偏置广播成(m, n)参与运算,它的梯度必须沿 batch 维求和才能回到(1, n)。忘了 unbroadcast 会直接形状报错,或者更糟——静默地算错。 - 理解梯度为什么会累加:框架不会自动清零,是因为梯度累积(模拟大 batch)需要这个行为。所以
zero_grad()是你的责任,忘了训练就废。 - ①逐算子梯度检验 < 1e-6 ②和 PyTorch 端到端对拍 < 1e-5。不能互相替代是因为:对拍只告诉你"整体对不对",不告诉你"哪个算子错了";而且两个算子的错误可能互相抵消,端到端看起来正常。
- 从"框架是黑箱"变成"框架就是我写的这些东西的工程化版本"。之后看 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