卷0 · 基础篇
0.3 神经网络与反向传播:从零实现(micrograd 路线)
展开本章目录与同卷章节
1. 一个神经元:线性 + 非线性2. 计算图:反向传播的舞台3. 必须亲手推一遍的梯度4. 从零造引擎:micrograd 式实现4.1 标量自动求导引擎(约 60 行,核心中的核心)4.2 用它搭 MLP 并训练(完整可运行)4.3 验证正确性:数值梯度5. 初始化:为什么一开始就”该差不多”6. Normalization:从 BatchNorm 到 RMSNorm7. NumPy ↔ 引擎 ↔ PyTorch 概念映射表8. 自测题9. 动手作业10. 延伸资源同卷章节0.1 数学基础:线性代数 · 概率 · 优化(深度学习视角三合一)0.2 Python 与 PyTorch 实战:从装环境到手写训练循环0.3 神经网络与反向传播:从零实现(micrograd 路线)0.4 训练工程常识:混合精度、断点续训、调试与监控0.5 Git / Linux / 远程开发生存包本章目标:不借助 PyTorch 的 autograd,亲手造一个能自动求导、能训练的最小神经网络引擎。学完标准:别人问你”反向传播到底在干嘛”,你能用代码+一行数学回答,而不是背口诀。 本章跟着 Karpathy《The spelled-out intro to neural networks and backpropagation: building micrograd》食用效果最佳——本笔记补足其中的数学直觉和中文讲解。
1. 一个神经元:线性 + 非线性
神经元 = 加权求和 + 偏置 + 激活函数:
激活函数 必不可少:如果不用非线性激活,多层线性变换的复合还是线性变换(),100 层等价于 1 层。非线性是深度的来源。
常用激活:ReLU(,CNN 时代默认)、GELU(Transformer 默认)、tanh(教学常用,光滑好求导)。
2. 计算图:反向传播的舞台
任何计算(比如 loss)都能画成一张有向无环图:输入和参数是叶子,运算符是中间节点,loss 是根。反向传播 = 把链式法则自动化在这张图上:
- 前向:从叶子到根,算出每个节点的值;
- 反向:从根到叶子,用链式法则把”loss 对每个节点的梯度”逐层乘回去。
关键直觉:每个节点只需知道两件事——(a) 它怎么由输入算出(局部前向公式);(b) 它的输出梯度怎么分摊给各输入(局部梯度公式)。局部规则 + 全局调度 = 自动求导。PyTorch 的 autograd 干的就是这件事,只是规模大、优化多。
3. 必须亲手推一遍的梯度
以两层网络 + softmax + 交叉熵为例(符号与 0.1 章 §2.5 一致):
前向:,,,。
反向(从后往前,每步只用链式法则):
- (0.1 章推导过;tanh 的导数是 )
- ,
- (注意转置:误差从上层”倒着流回来”)
- ( 是逐元素乘,激活导数逐点作用)
- 的梯度同第 2 步模式。
观察结构:每层都是”上游梯度 × 本层局部导数,再往上传”。这就是为什么框架只需要每个算子定义 backward(),任意深度的网络都能自动求导。
4. 从零造引擎:micrograd 式实现
4.1 标量自动求导引擎(约 60 行,核心中的核心)
import math
class Value:
"""一个数 + 它的计算历史 + 自动求导能力"""
def __init__(self, data, _children=(), _op=""):
self.data = data
self.grad = 0.0 # d(loss)/d(self)
self._backward = lambda: None
self._prev = set(_children) # 计算图的前驱
self._op = _op
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward(): # 加法:梯度原样传给两边
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), "*")
def _backward(): # 乘法:交换律式分摊
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def tanh(self):
x = self.data
t = (math.exp(2*x) - 1) / (math.exp(2*x) + 1)
out = Value(t, (self,), "tanh")
def _backward(): # d(tanh)/dx = 1 - tanh²
self.grad += (1 - t**2) * out.grad
out._backward = _backward
return out
def backward(self):
# 拓扑排序:保证先算完所有下游,再算自己
topo, visited = [], set()
def build(v):
if v not in visited:
visited.add(v)
for c in v._prev: build(c)
topo.append(v)
build(self)
self.grad = 1.0 # d(loss)/d(loss) = 1
for node in reversed(topo): # 从根往叶子走
node._backward()
# 语法糖
__rmul__ = __mul__
def __neg__(self): return self * -1
def __sub__(self, other): return self + (-other)
def __truediv__(self, other): return self * other**-1
def __pow__(self, k):
out = Value(self.data ** k, (self,), f"**{k}")
def _backward(): self.grad += k * self.data ** (k-1) * out.grad
out._backward = _backward
return out
def __repr__(self): return f"Value({self.data:.4f}, grad={self.grad:.4f})"
读代码时盯住三个设计:
+=而不是=:一个变量被多次使用时(比如c = a + a),梯度要累加()。漏掉累加是最经典的自动求导 bug;_backward存在节点上而不是立刻执行:因为要等所有下游节点贡献完梯度才能跑;backward()里拓扑排序 + 逆序执行:这就是”反向”二字的实现。
4.2 用它搭 MLP 并训练(完整可运行)
import random
random.seed(0)
class Neuron:
def __init__(self, nin):
self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
self.b = Value(0.0)
def __call__(self, x):
act = sum((wi*xi for wi, xi in zip(self.w, x)), self.b)
return act.tanh()
class Layer:
def __init__(self, nin, nout):
self.neurons = [Neuron(nin) for _ in range(nout)]
def __call__(self, x):
out = [n(x) for n in self.neurons]
return out[0] if len(out) == 1 else out
class MLP:
def __init__(self, nin, nouts): # 如 [2, 8, 8, 1]
sizes = [nin] + nouts
self.layers = [Layer(sizes[i], sizes[i+1]) for i in range(len(nouts))]
def __call__(self, x):
for layer in self.layers: x = layer(x)
return x
def train_step(model, x, y_target, lr=0.05):
y_pred = model(x) # 前向
loss = (y_pred - y_target) ** 2 # MSE(用我们的引擎算!)
for p in model.parameters(): p.grad = 0.0 # 手动 zero_grad(和 PyTorch 纪律一致)
loss.backward() # 自动求导
for p in model.parameters(): # 梯度下降
p.data -= lr * p.grad
return loss.data
model = MLP(2, [8, 8, 1])
model.parameters = lambda: [p for l in model.layers for n in l.neurons
for p in n.w + [n.b]]
# 玩具任务:判断点在圆内还是圆外
data = []
for _ in range(200):
x, y = random.uniform(-1, 1), random.uniform(-1, 1)
data.append(([x, y], 1.0 if x*x + y*y < 0.5 else -1.0))
for step in range(200):
losses = [train_step(model, x, y) for x, y in random.sample(data, 20)]
if step % 20 == 0:
acc = sum((model(x).data > 0) == (y > 0) for x, y in data) / len(data)
print(f"step {step:3d} loss {sum(losses)/len(losses):.4f} acc {acc:.2%}")
跑起来你会看到 loss 下降、acc 爬升——这是你亲手从数学造出来的学习,里面没有一行 PyTorch。
4.3 验证正确性:数值梯度
自动求导写错了不报错,只会”学得慢”。验证方法是对拍:用定义 数值算梯度,和引擎的 grad 对比。PyTorch 里对应 torch.autograd.gradcheck。养成对新写的梯度代码做数值对拍的习惯——这是你区别于”纯 vibe coder”的第一个标志。
5. 初始化:为什么一开始就”该差不多”
若权重全零或方差过大,信号会逐层衰减/爆炸。经验规则:
- He 初始化(ReLU 配套):
- Xavier(tanh/sigmoid 配套):
现代大模型初始化的要点其实就一句话:让每一层输出的方差和输入大致相同。残差连接的层常乘一个 0.02~0.1 的小系数(GPT 做法),让训练初期网络接近恒等映射。
6. Normalization:从 BatchNorm 到 RMSNorm
问题:深层网络里,前面层的参数一变,后面层看到的输入分布就漂移,训练不稳。
- BatchNorm:对每个通道,用 batch 内统计量做标准化 + 可学习的缩放平移。CNN 时代标配。缺点:依赖 batch 统计(推理时要用训练期累计的滑动平均),batch 小时不稳。
- LayerNorm:对每个样本自己的所有特征做标准化,与 batch 无关。Transformer 标配。
- RMSNorm:LayerNorm 的简化——只除以均方根、不减均值,少一半计算。LLaMA/Qwen 系全部用它。省掉的均值中心化被证明基本无害,这是”简单方法赢了”的又一例。
卷2 拆 GPT 架构时会再见到 RMSNorm;现在只需要知道它的位置(每个子层前,Pre-Norm)和动机(稳定深层训练)。
7. NumPy ↔ 引擎 ↔ PyTorch 概念映射表
| 概念 | 本章引擎 | PyTorch |
|---|---|---|
| 带梯度的数 | Value | tensor(requires_grad=True) |
| 前向计算 | 重载运算符 | 一切 tensor 运算 |
| 反向传播 | backward() 拓扑逆序 | loss.backward() |
| 清梯度 | p.grad = 0 | optimizer.zero_grad() |
| 参数集合 | model.parameters() | model.parameters() |
| 梯度下降 | p.data -= lr*p.grad | optimizer.step() |
你会发现第 0.2 章的训练循环和 4.2 节的训练循环逐行同构。从此 PyTorch 对你不再是黑盒。
8. 自测题
- 为什么
__add__的_backward里用+=?举一个会出错的场景。(c = a + a;共享节点梯度叠加) - 加法的反向传播是什么形式?乘法呢?(恒等分摊给两边;交叉乘对方的值)
- 为什么拓扑排序必须”逆序”执行
_backward?(梯度依赖下游已算完,loss 是根,逆拓扑 = 从根到叶) - tanh 导数在 |x| 很大时趋近多少?这对梯度意味着什么?(0;饱和区梯度消失,GELU/ReLU 缓解此问题)
- 为什么全零初始化会让”对称神经元”永远一样?(同层神经元收到相同梯度,永远同步更新,网络等效宽度 1)
- BatchNorm 和 LayerNorm 的统计维度差在哪?为什么 LLM 用后者?(BN 沿 batch 维统计、LN 沿特征维;LLM 序列长度可变且 batch 小,且推理不依赖滑动统计)
- RMSNorm 砍掉了什么?为什么基本无害?(减均值;特征均值信息弱,且残差流本身提供中心化)
- 数值梯度的公式是什么? 太大会怎样、太小会怎样?(中心差分;太大截断误差、太小浮点舍入,一般 1e-5 左右)
9. 动手作业
- 给
Value加exp、log方法,并推导它们的_backward。(提示:d(e^x)/dx = e^x,前向已算出的out.data可直接复用) - 用数值梯度对拍验证第 1 题和整个 MLP 的梯度(构造 20 个随机输入,最大相对误差应 < 1e-6 量级)。
- 把玩具任务换成 XOR 和”同心圆环”( 或 为正类),观察不同宽度的网络谁学得动——体会”容量”。
- 挑战题:把引擎升级成支持向量化的版本(参考 Karpathy 后续的
micrograd→ tinygrad 思路),为卷1手写 attention 做准备。
10. 延伸资源
- Karpathy《The spelled-out intro to neural networks and backpropagation》(YouTube/B站)——与本章一一对应
- micrograd 官方 repo(约 100 行)——读完本章后对照
- 《Neural Networks: Zero to Hero》系列 playlist——通往卷2 的 GPT 章节
