云镜收藏

稍后阅读

清单保存在当前浏览器,方便下次回来继续阅读。

清单还是空的

在笔记卡片或正文页点击“加入稍后阅读”即可收藏。

卷0 · 基础篇

0.3 神经网络与反向传播:从零实现(micrograd 路线)

约 7 分钟阅读
全书目录

展开本章目录与同卷章节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. 一个神经元:线性 + 非线性

神经元 = 加权求和 + 偏置 + 激活函数:

y=σ(w1x1+w2x2+b)y = \sigma(w_1 x_1 + w_2 x_2 + b)

激活函数 σ\sigma 必不可少:如果不用非线性激活,多层线性变换的复合还是线性变换(W2(W1x)=(W2W1)xW_2(W_1x) = (W_2W_1)x),100 层等价于 1 层。非线性是深度的来源。

常用激活:ReLU(max⁡(0,x)\max(0,x),CNN 时代默认)、GELU(Transformer 默认)、tanh(教学常用,光滑好求导)。

2. 计算图:反向传播的舞台

任何计算(比如 loss)都能画成一张有向无环图:输入和参数是叶子,运算符是中间节点,loss 是根。反向传播 = 把链式法则自动化在这张图上:

  1. 前向:从叶子到根,算出每个节点的值;
  2. 反向:从根到叶子,用链式法则把”loss 对每个节点的梯度”逐层乘回去。

关键直觉:每个节点只需知道两件事——(a) 它怎么由输入算出(局部前向公式);(b) 它的输出梯度怎么分摊给各输入(局部梯度公式)。局部规则 + 全局调度 = 自动求导。PyTorch 的 autograd 干的就是这件事,只是规模大、优化多。

3. 必须亲手推一遍的梯度

以两层网络 + softmax + 交叉熵为例(符号与 0.1 章 §2.5 一致):

前向:h=tanh⁡(W1x+b1)\mathbf{h} = \tanh(W_1 \mathbf{x} + \mathbf{b}_1),z=W2h+b2\mathbf{z} = W_2 \mathbf{h} + \mathbf{b}_2,p=softmax(z)\mathbf{p} = \text{softmax}(\mathbf{z}),L=−log⁡pyL = -\log p_y。

反向(从后往前,每步只用链式法则):

  1. ∂L∂z=p−t\dfrac{\partial L}{\partial \mathbf{z}} = \mathbf{p} - \mathbf{t}(0.1 章推导过;tanh 的导数是 1−tanh⁡21 - \tanh^2)
  2. ∂L∂W2=∂L∂zh⊤\dfrac{\partial L}{\partial W_2} = \dfrac{\partial L}{\partial \mathbf{z}} \mathbf{h}^\top,∂L∂b2=∂L∂z\dfrac{\partial L}{\partial \mathbf{b}_2} = \dfrac{\partial L}{\partial \mathbf{z}}
  3. ∂L∂h=W2⊤∂L∂z\dfrac{\partial L}{\partial \mathbf{h}} = W_2^\top \dfrac{\partial L}{\partial \mathbf{z}}(注意转置:误差从上层”倒着流回来”)
  4. ∂L∂a1=∂L∂h⊙(1−h2)\dfrac{\partial L}{\partial \mathbf{a}_1} = \dfrac{\partial L}{\partial \mathbf{h}} \odot (1 - \mathbf{h}^2)(⊙\odot 是逐元素乘,激活导数逐点作用)
  5. W1,b1W_1, \mathbf{b}_1 的梯度同第 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})"

读代码时盯住三个设计:

  1. += 而不是 =:一个变量被多次使用时(比如 c = a + a),梯度要累加(dc/da=2dc/da = 2)。漏掉累加是最经典的自动求导 bug;
  2. _backward 存在节点上而不是立刻执行:因为要等所有下游节点贡献完梯度才能跑;
  3. 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 验证正确性:数值梯度

自动求导写错了不报错,只会”学得慢”。验证方法是对拍:用定义 (L(θ+ϵ)−L(θ−ϵ))/2ϵ(L(\theta+\epsilon) - L(\theta-\epsilon)) / 2\epsilon 数值算梯度,和引擎的 grad 对比。PyTorch 里对应 torch.autograd.gradcheck。养成对新写的梯度代码做数值对拍的习惯——这是你区别于”纯 vibe coder”的第一个标志。

5. 初始化:为什么一开始就”该差不多”

若权重全零或方差过大,信号会逐层衰减/爆炸。经验规则:

  • He 初始化(ReLU 配套):W∼N(0,2/nin)W \sim \mathcal{N}(0, 2/n_{in})
  • Xavier(tanh/sigmoid 配套):W∼N(0,1/nin)W \sim \mathcal{N}(0, 1/n_{in})

现代大模型初始化的要点其实就一句话:让每一层输出的方差和输入大致相同。残差连接的层常乘一个 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
带梯度的数Valuetensor(requires_grad=True)
前向计算重载运算符一切 tensor 运算
反向传播backward() 拓扑逆序loss.backward()
清梯度p.grad = 0optimizer.zero_grad()
参数集合model.parameters()model.parameters()
梯度下降p.data -= lr*p.gradoptimizer.step()

你会发现第 0.2 章的训练循环和 4.2 节的训练循环逐行同构。从此 PyTorch 对你不再是黑盒。

8. 自测题

  1. 为什么 __add__ 的 _backward 里用 +=?举一个会出错的场景。(c = a + a;共享节点梯度叠加)
  2. 加法的反向传播是什么形式?乘法呢?(恒等分摊给两边;交叉乘对方的值)
  3. 为什么拓扑排序必须”逆序”执行 _backward?(梯度依赖下游已算完,loss 是根,逆拓扑 = 从根到叶)
  4. tanh 导数在 |x| 很大时趋近多少?这对梯度意味着什么?(0;饱和区梯度消失,GELU/ReLU 缓解此问题)
  5. 为什么全零初始化会让”对称神经元”永远一样?(同层神经元收到相同梯度,永远同步更新,网络等效宽度 1)
  6. BatchNorm 和 LayerNorm 的统计维度差在哪?为什么 LLM 用后者?(BN 沿 batch 维统计、LN 沿特征维;LLM 序列长度可变且 batch 小,且推理不依赖滑动统计)
  7. RMSNorm 砍掉了什么?为什么基本无害?(减均值;特征均值信息弱,且残差流本身提供中心化)
  8. 数值梯度的公式是什么?ϵ\epsilon 太大会怎样、太小会怎样?(中心差分;太大截断误差、太小浮点舍入,一般 1e-5 左右)

9. 动手作业

  1. 给 Value 加 exp、log 方法,并推导它们的 _backward。(提示:d(e^x)/dx = e^x,前向已算出的 out.data 可直接复用)
  2. 用数值梯度对拍验证第 1 题和整个 MLP 的梯度(构造 20 个随机输入,最大相对误差应 < 1e-6 量级)。
  3. 把玩具任务换成 XOR 和”同心圆环”(r<0.3r<0.3 或 r>0.6r>0.6 为正类),观察不同宽度的网络谁学得动——体会”容量”。
  4. 挑战题:把引擎升级成支持向量化的版本(参考 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 章节