chapter 03 / deep-learning · 预计学习时间 150-180 分钟
第 2 章实验室里,逻辑回归在 XOR 数据上无论怎么训练都只有 50% 准确率——它的假设空间里只有直线。当时给了三条出路:手工造特征、核方法、让模型自己学特征。前两条的共同问题是:特征(或核)是人选的,选错全盘皆输。第三条路就是神经网络:
$$\text{逻辑回归:}\ \hat{p} = \sigma(\mathbf{w}^\top \mathbf{x} + b) \qquad\Longrightarrow\qquad \text{神经网络:}\ \hat{p} = \sigma\big(\mathbf{w}_2^\top\, \underbrace{g(W_1 \mathbf{x} + \mathbf{b}_1)}_{\text{学出来的新特征 } \mathbf{a}}\, + b_2\big)$$结构上只是「先做一层线性变换+非线性 $g$,再做逻辑回归」。但意义是革命性的:隐藏层 $\mathbf{a} = g(W_1\mathbf{x}+\mathbf{b}_1)$ 就是模型自己学出来的特征。对 XOR,网络会自己发现类似「$x$ 和 $y$ 是否同侧」的两个中间特征——在这个新空间里,XOR 变成线性可分的。深度学习的全部秘密浓缩成一句话:逐层学习越来越好的表示,直到最后一层用一个线性模型收尾。
把上面的结构推广:$L$ 层网络,第 $\ell$ 层 $\mathbf{a}^{(\ell)} = g\big(W^{(\ell)} \mathbf{a}^{(\ell-1)} + \mathbf{b}^{(\ell)}\big)$。理论保证来自万能逼近定理(Cybenko 1989 / Hornik 1991):单隐藏层网络只要隐藏单元足够多,就能以任意精度逼近紧集上的任意连续函数。
训练还是老三样:模型(上面)、损失(交叉熵)、优化(梯度下降)。唯一的新问题:几百万个参数的梯度怎么算得快?答案是链式法则 + 动态规划,名字叫反向传播。
设前向:$\mathbf{z}_1 = W_1\mathbf{x}+\mathbf{b}_1,\ \mathbf{a}_1 = g(\mathbf{z}_1),\ z_2 = \mathbf{w}_2^\top\mathbf{a}_1 + b_2,\ \hat p = \sigma(z_2)$,损失 $L$ 为交叉熵。从输出端往回,一层层用链式法则:
第一步(第 2 章的礼物,直接搬来):
$$\delta_2 \equiv \frac{\partial L}{\partial z_2} = \hat p - y$$第二步:输出层参数的梯度。$z_2$ 对 $\mathbf{w}_2$ 的偏导就是 $\mathbf{a}_1$:
$$\frac{\partial L}{\partial \mathbf{w}_2} = \delta_2\, \mathbf{a}_1, \qquad \frac{\partial L}{\partial b_2} = \delta_2$$第三步(关键):误差穿过权重和激活函数传回隐藏层。$z_2$ 依赖 $\mathbf{a}_1$,$\mathbf{a}_1$ 依赖 $\mathbf{z}_1$:
$$\delta_1 \equiv \frac{\partial L}{\partial \mathbf{z}_1} = \underbrace{\delta_2\, \mathbf{w}_2}_{\text{误差按权重分配}} \odot \underbrace{g'(\mathbf{z}_1)}_{\text{乘激活函数的导数}}$$第四步:隐藏层参数的梯度,形式和第二步完全一样:
$$\frac{\partial L}{\partial W_1} = \delta_1\, \mathbf{x}^\top, \qquad \frac{\partial L}{\partial \mathbf{b}_1} = \delta_1$$看到模式了吗?每层的梯度 = 该层的误差 $\delta$ × 该层的输入——第 1、2 章「误差×输入」的完全体。而误差 $\delta$ 的传播有一个统一的递推式(任意深的网络都适用):
$$\boxed{\ \delta^{(\ell)} = \big(W^{(\ell+1)\top} \delta^{(\ell+1)}\big) \odot g'(\mathbf{z}^{(\ell)})\ }$$这就是「反向传播」四个字的全部内容:前向算一遍存下各层激活($O(n)$),反向用递推把 $\delta$ 从输出层传回输入层(又一个 $O(n)$)。计算全部参数的梯度只比算一次前向贵约两倍,与参数数量无关——没有这个性质,万亿参数的 GPT 根本不可能训练。PyTorch 的 loss.backward() 内部就是在计算图上跑这套递推。
下面是一个真的在你浏览器里跑反向传播的 2-H-1 网络(代码就是 §3 的四步推导,可读 assets/neural.js 源码对照)。必做实验:① H=1 训练 XOR——参数再多也学不会(一个隐藏单元只能折叠一次空间);② H 调到 4 再训练——看决策边界如何「弯」出两块对角区域;③ 螺旋数据 + H=8 + tanh,见证小网络的极限;④ 把激活换成 sigmoid 再训螺旋,感受梯度消失导致的龟速;⑤ 学习率拉满看损失爆炸。背景色深浅 = 模型置信度,浅色地带是它的「犹豫区」。
单击画布加绿点 · Shift+单击加紫点 · 改隐藏单元数/激活函数会重新初始化
| 函数 | 公式 | 问题/优势 |
|---|---|---|
| sigmoid | $1/(1+e^{-z})$ | 两端饱和,$\sigma' \le 0.25$,深网络中梯度连乘后指数级消失;输出不以零为中心 |
| tanh | $\tanh(z)$ | 零中心改善了 sigmoid,但依然饱和 |
| ReLU | $\max(0, z)$ | 正区间导数恒为 1,梯度高速公路;计算近乎免费。代价:「死亡 ReLU」(神经元落入负区间后梯度为 0,永远醒不来) |
| GELU | $z\cdot\Phi(z)$ | ReLU 的光滑版,GPT/BERT 的选择 |
| SwiGLU | $\text{Swish}(W_1 x)\otimes W_2 x$ | 门控结构,LLaMA 及之后多数 LLM 的 FFN 标配(第 6 章见) |
为什么 ReLU 是深度学习起飞的隐形功臣?看 $\delta$ 递推式:每往回传一层都要乘一次 $g'$。sigmoid 的 $g' \le 0.25$,传 10 层后梯度至少缩小 $4^{10} \approx$ 百万倍——底层根本学不到东西(梯度消失)。ReLU 在激活的路径上 $g' = 1$,误差无损通行。AlexNet 论文专门用一张图说明 ReLU 比 tanh 快 6 倍收敛——2012 年革命的三要素里,「算法」那一项主要就是它。
朴素 SGD 的两个痛点:①损失面像狭长峡谷时,梯度在陡峭方向来回震荡、在平缓方向蠕动;②所有参数共用一个学习率,但不同参数的梯度量级可能差几个数量级。两条改进线索最终汇成 Adam:
把历史梯度做指数滑动平均:震荡方向正负抵消,一致方向越滚越快——像有质量的小球滚下山。$\beta=0.9$ 约等于平均最近 10 步。
每个参数除以自己梯度幅度的滑动均方根:梯度一向很大的参数自动减速,很小的自动加速——每个参数有了私人定制的学习率。
偏差修正解决冷启动问题:$\mathbf{m}_0 = 0$ 导致早期估计向零偏,除以 $(1-\beta^t)$ 校正($t$ 大了该项趋于 1,自动失效)。
同一个公式,区别只在对谁算 $\mu, \sigma$:BatchNorm 沿 batch 维(同一通道跨样本)——视觉标配,但依赖 batch 统计:batch 小了不稳,推理时要用训练期的滑动均值,序列长度可变时很别扭。LayerNorm 沿特征维(单样本内部)——与 batch 无关、对序列天然友好,这就是 Transformer 选它的原因。RMSNorm 再省掉减均值,只除均方根,LLaMA 后成为 LLM 默认(第 6 章会出现在你从零实现的 GPT 里)。它们的共同作用:把每层输入拉回稳定分布,损失面更光滑,可以用大学习率。
左手是 §3 推导的逐行翻译,右手是工业现实——两者必须给出相同的梯度:
python · backprop_from_scratch.pyimport numpy as np
rng = np.random.default_rng(0)
# XOR 数据
X = np.array([[0,0],[0,1],[1,0],[1,1]], dtype=float)
y = np.array([0.,1.,1.,0.])
H = 4
W1 = rng.normal(0, np.sqrt(2/2), (H, 2)); b1 = np.zeros(H) # He 初始化
W2 = rng.normal(0, np.sqrt(2/H), H); b2 = 0.0
sig = lambda z: 1/(1+np.exp(-z))
for epoch in range(5000):
# ---- 前向(存下中间量,反向要用)----
Z1 = X @ W1.T + b1 # (4,H)
A1 = np.maximum(0, Z1) # ReLU
z2 = A1 @ W2 + b2 # (4,)
p = sig(z2)
# ---- 反向:§3 的四步,逐行对应 ----
d2 = (p - y) / len(X) # δ₂ = p̂ - y (第一步)
gW2 = A1.T @ d2; gb2 = d2.sum() # (第二步)
d1 = np.outer(d2, W2) * (Z1 > 0) # δ₁ = δ₂W₂ ⊙ g'(第三步)
gW1 = d1.T @ X; gb1 = d1.sum(0) # (第四步)
# ---- SGD 更新 ----
lr = 0.5
W1 -= lr*gW1; b1 -= lr*gb1; W2 -= lr*gW2; b2 -= lr*gb2
print(np.round(p, 3)) # → 约 [0, 1, 1, 0],XOR 被解开 ✓
python · 同一件事的 PyTorch 版
import torch, torch.nn as nn
X = torch.tensor([[0.,0.],[0.,1.],[1.,0.],[1.,1.]])
y = torch.tensor([[0.],[1.],[1.],[0.]])
model = nn.Sequential(nn.Linear(2, 4), nn.ReLU(), nn.Linear(4, 1))
opt = torch.optim.AdamW(model.parameters(), lr=0.05, weight_decay=0.01)
loss_fn = nn.BCEWithLogitsLoss() # sigmoid+交叉熵融合算子(数值稳定,§2 章伏笔)
for epoch in range(2000):
opt.zero_grad()
loss = loss_fn(model(X), y)
loss.backward() # ← 自动跑 §3 的递推,所谓 autograd
opt.step()
print(torch.sigmoid(model(X)).detach().round().squeeze()) # tensor([0.,1.,1.,0.])
收尾视频是本章的「期末大餐」:Karpathy 从一个空 Python 文件开始,手写整个自动求导引擎 micrograd——看完它,loss.backward() 对你就再无任何神秘感。这是通往第 6 章「从零实现 GPT」的第一块跳板: