给你 50 个点,看上去大致排成一条直线,让你画一条”最好”的线。你会凭眼睛画;计算机没有眼睛,它只能做一件事:先随便画一条,算一下画得有多差,然后朝”变好”的方向挪一点,重复几千次。这就是机器学习里”学”的全部含义。从这条直线出发,只需要再加三样东西:一个非线性、一个链式法则、一个自动记账的数据结构,就能得到神经网络和 PyTorch 的 loss.backward()。

前置:线性代数(矩阵乘向量)、概率论(MLE 的直觉)

把”好”变成一个数:损失函数

先造数据:真实关系是 ,再加一点噪声。模型是 ,两个参数 待定。

“这条线画得好不好”必须变成一个可以比较大小的数,否则没法往好的方向挪。最常见的选择是均方误差(MSE):

为什么是平方而不是绝对值?一个理由是它处处可导、导数是线性的,后面算梯度很省事。另一个更深的理由来自概率:如果假设噪声服从高斯分布 ,那么让观测数据出现概率最大的 (最大似然估计)恰好就是让 MSE 最小的那组。选损失函数,等于在选你对噪声的假设。 这个对应关系后面讲分类时还会再出现一次。

线性回归其实有闭式解(正规方程 ,即 线性代数 里的最小二乘投影)。但闭式解只对极少数模型存在,神经网络没有。我们需要一个对任何可导模型都管用的办法。

往下坡走:梯度下降

是一个关于两个参数的曲面。梯度 指向函数上升最快的方向,那么沿它的反方向走一小步,损失就会下降:

叫学习率。对 MSE 手推一下导数:,。直接写进 numpy:

import numpy as np
rng = np.random.default_rng(0)
X = rng.uniform(0, 10, 50)
y = 3 * X + 2 + rng.normal(0, 1, 50)
w, b, lr = 0.0, 0.0, 0.01
for step in range(2001):
    pred = w * X + b
    err = pred - y
    loss = (err ** 2).mean()
    dw = 2 * (err * X).mean(); db = 2 * err.mean()
    w -= lr * dw; b -= lr * db
    if step in (0, 10, 100, 2000):
        print(f"step {step:4d}  loss {loss:8.3f}  w {w:.3f}  b {b:.3f}")
step    0  loss  398.339  w 2.398  b 0.357
step   10  loss    1.346  w 3.231  b 0.537
step  100  loss    1.147  w 3.170  b 0.951
step 2000  loss    0.993  w 3.052  b 1.757

两个现象值得盯住。第一, 十步之内就到了 3 附近, 却两千步还没到 2。原因是 的取值在 0 到 10 之间, 方向的梯度被 放大了,而 的梯度没有;同一个学习率对 正合适,对 就太小。曲面在两个方向上弯曲程度差很多,是一道窄长的峡谷。这就是为什么实践中要先把特征标准化(减均值除标准差),也是 训练深度网络与 CNN 里 Adam 这类”每个参数各自调步长”的优化器要解决的问题。

第二,学习率不能随便调大。把 lr 改成 0.05:

step 0 loss 3.983e+02
step 1 loss 2.900e+03
step 2 loss 2.116e+04
step 3 loss 1.545e+05
step 4 loss 1.128e+06

每一步损失放大约 7 倍。步子太大,一步跨过谷底落到对面更高的地方,再跨回来更高,于是发散。对二次函数可以精确算出界限:步长必须小于 , 是曲面最陡方向的曲率。这里 约为 ,界限约 0.03,0.05 已经越界。

真实数据集有几百万条,每一步都在全部数据上算梯度太贵。随机梯度下降(SGD)每次只取一个 mini-batch(比如 256 条)估计梯度。估计有噪声,但期望是对的,而且一步的代价从 降到 。噪声本身还有点好处:它让参数不容易卡在尖锐的局部极小里。batch size 和学习率通常一起调,batch 变大,梯度更准,学习率可以相应放大。

停一下,检查理解单选

上面的例子里 lr=0.01 时 w 很快收敛而 b 很慢。最直接的修法是?

从回归到分类:sigmoid、softmax 与交叉熵

现在任务变了:给一张图,判断它是 10 类里的哪一类。输出不再是一个实数,而是一个概率分布。

二分类时,把线性输出 塞进 sigmoid ,压到 当作”是正类”的概率,这就是逻辑回归。多分类时推广成 softmax:线性层输出 个分数(logits),

指数保证非负,除以总和保证加起来是 1。 时 softmax 退化成 sigmoid。

损失用什么?还用 MSE 也能训,但效果差:sigmoid 在两端饱和,MSE 的梯度里带一个 因子,预测错得离谱( 很负而标签是 1)时这个因子接近 0,模型反而学不动。回到最大似然:模型给真实类别 的概率是 ,让它最大,等价于最小化

这就是交叉熵损失。它和 softmax 配在一起有个漂亮的结果:对 logits 的梯度是

预测概率减去 one-hot 标签。错得越多梯度越大,没有饱和问题。

实现时有个坑。logits 稍大一点, 就溢出:

import numpy as np
z = np.array([1000., 1001., 1002.])
with np.errstate(over='ignore', invalid='ignore'):
    print(np.exp(z) / np.exp(z).sum())
zs = z - z.max()
p = np.exp(zs) / np.exp(zs).sum()
print(p.round(4))
print("CE if label=0:", -np.log(p[0]).round(4))
# gradient of CE wrt logits is p - onehot
print("grad:", (p - np.array([1, 0, 0])).round(4))
[nan nan nan]
[0.09   0.2447 0.6652]
CE if label=0: 2.4076
grad: [-0.91    0.2447  0.6652]

softmax 对所有 logits 同时加减一个常数不变,所以先减去最大值,最大的指数就是 ,不会溢出。fp32 的 exp 在输入超过约 88 时就变成 inf(见 信息的表示),fp16 只到约 11,这个技巧在半精度下更是必需。PyTorch 的 F.cross_entropy 直接吃 logits,内部用 log-sum-exp 一步算完,所以不要自己先做 softmax 再取 log 传进去。

梯度那一行也对得上公式:真实类 0 的梯度是 ,往上推;其他类是正的,往下压。

停一下,检查理解多选

关于 softmax + 交叉熵,哪些说法正确?

线性模型的天花板:加一层非线性

把 softmax 回归用在 Fashion-MNIST(28×28 的衣服鞋子图片)上,测试准确率大约 83%–85%,再怎么训也上不去。原因不在优化,在模型本身:它只能画直线(超平面)把类别分开。

最小的反例是 XOR:四个点 是一类, 是另一类。没有任何一条直线能把它们分开。那多叠几层线性层呢?,两层线性还是一层线性,白叠。

关键是在层与层之间插一个非线性激活函数:

可以是 tanh、ReLU 等。这就是多层感知机(MLP)。第一层把输入映射到一个新空间,在新空间里原本线性不可分的点可能就可分了,第二层再在新空间里画直线。万能近似定理说,只要隐藏层足够宽,一个隐藏层的 MLP 就能以任意精度逼近任何连续函数。这个定理只保证”存在”,不保证梯度下降找得到、也不保证需要的宽度合理;实践中”更深”往往比”更宽”划算得多。

现在问题来了:参数变成了矩阵 ,损失是层层嵌套的复合函数,再手推导数就很痛苦,每改一次模型结构都要重推一遍。我们需要让计算机自己算导数。

停一下,检查理解单选

一个网络由三层 Linear 直接堆叠、中间没有激活函数。它的表达能力相当于?

链式法则 + 记账:从零写一个 autograd

任何复杂的函数都是由加、乘、tanh 这样的基本运算拼出来的。每个基本运算的局部导数我们都会: 时 ; 时 。链式法则说,最终输出 对某个中间量 的导数,等于 对 的”下游”的导数乘上局部导数,如果 流向了多个下游,就把各条路的贡献加起来。

于是做法很机械:

  1. 前向:计算时把每一步记下来,形成一张有向无环图(计算图),每个节点记住它的输入是谁、怎么算的。
  2. 反向:从 出发,令 ,按拓扑序的逆序访问每个节点,把自己的梯度乘上局部导数,累加到输入节点上。

下面是一个只有标量的 autograd,和 Karpathy 的 micrograd 思路一致:

import math, random
class Value:
    def __init__(self, data, children=(), op=''):
        self.data, self.grad = data, 0.0
        self._prev, self._op = set(children), op
        self._backward = lambda: None
    def __add__(self, o):
        o = o if isinstance(o, Value) else Value(o)
        out = Value(self.data + o.data, (self, o), '+')
        def _b():
            self.grad += out.grad; o.grad += out.grad
        out._backward = _b; return out
    def __mul__(self, o):
        o = o if isinstance(o, Value) else Value(o)
        out = Value(self.data * o.data, (self, o), '*')
        def _b():
            self.grad += o.data * out.grad; o.grad += self.data * out.grad
        out._backward = _b; return out
    def tanh(self):
        t = math.tanh(self.data); out = Value(t, (self,), 'tanh')
        def _b(): self.grad += (1 - t * t) * out.grad
        out._backward = _b; return out
    def __neg__(self): return self * -1
    def __sub__(self, o): return self + (-o)
    __radd__ = __add__; __rmul__ = __mul__
    def backward(self):
        topo, seen = [], set()
        def build(v):
            if v not in seen:
                seen.add(v); [build(c) for c in v._prev]; topo.append(v)
        build(self); self.grad = 1.0
        for v in reversed(topo): v._backward()
 
a, b = Value(2.0), Value(-3.0)
c = a * b
L = (c + a).tanh()
L.backward()
print(f"L={L.data:.4f} a.grad={a.grad:.4f} b.grad={b.grad:.4f}")
 
import torch
ta = torch.tensor(2.0, requires_grad=True); tb = torch.tensor(-3.0, requires_grad=True)
tL = (ta * tb + ta).tanh(); tL.backward()
print(f"torch: a.grad={ta.grad.item():.4f} b.grad={tb.grad.item():.4f}")
L=-0.9993 a.grad=-0.0027 b.grad=0.0027
torch: a.grad=-0.0027 b.grad=0.0027

几个设计细节都有原因:

  • _backward 是一个闭包,在前向时就创建好,捕获了这一步的输入和输出。反向时不需要知道”这是什么运算”,调用它就行。PyTorch 里每个张量的 grad_fn 就是这个角色。
  • 梯度用 += 而不是 =。上例中 既进了乘法又进了加法,两条路径的贡献要加起来:,正好是 。如果写成 =,后执行的那条路径会覆盖前一条。
  • 也正因为是累加,每次反向前要把梯度清零。PyTorch 的 optimizer.zero_grad() 就是干这个的,忘了写,梯度会在多个 step 之间越积越大。
  • 拓扑排序保证一个节点在所有下游都把梯度传给它之后才往上传。

这种”从输出往输入推”的方式叫反向模式自动微分。对 个参数、1 个标量损失,一次反向就拿到全部 个偏导,代价和前向同阶。另一种”正向模式”每次只能求对一个输入的导数,要跑 次。神经网络是”百万输入、一个输出”,反向模式完胜。代价是前向的中间结果都要存着等反向用,这是训练比推理吃显存多得多的根源(见 资源核算与 Scaling Laws)。

还有一点容易混:autograd 既不是符号求导(不会给你一个化简后的导数表达式),也不是数值求导(不用 去近似)。它对这一次具体的计算过程精确求导,所以 if、for 这种控制流也没问题,走了哪条分支就对哪条分支求导。

停一下,检查理解单选

在上面的 Value 实现里,如果把 __add__ 的反向改成 self.grad = out.grad; o.grad = out.grad(赋值而非累加),算 L = tanh(a*b + a) 时会怎样?

用它训练一个网络,再换成 PyTorch

有了 Value,搭神经元和 MLP 只是几行组合代码。在 XOR 上训练一个 2→4→1 的网络:

random.seed(0)
class Neuron:
    def __init__(s, n): s.w = [Value(random.uniform(-1, 1)) for _ in range(n)]; s.b = Value(0.0)
    def __call__(s, x): return sum((wi * xi for wi, xi in zip(s.w, x)), s.b).tanh()
    def params(s): return s.w + [s.b]
class MLP:
    def __init__(s, sizes): s.layers = [[Neuron(sizes[i]) for _ in range(sizes[i+1])] for i in range(len(sizes)-1)]
    def __call__(s, x):
        for layer in s.layers: x = [n(x) for n in layer]
        return x[0]
    def params(s): return [p for l in s.layers for n in l for p in n.params()]
X = [[0,0],[0,1],[1,0],[1,1]]; Y = [-1, 1, 1, -1]
net = MLP([2, 4, 1])
print("params:", len(net.params()))
for step in range(201):
    loss = sum(((net(x) - y) * (net(x) - y) for x, y in zip(X, Y)), Value(0.0))
    for p in net.params(): p.grad = 0.0
    loss.backward()
    for p in net.params(): p.data -= 0.1 * p.grad
    if step % 50 == 0: print(f"step {step:3d} loss {loss.data:.4f}")
print([round(net(x).data, 3) for x in X])
params: 17
step   0 loss 4.1676
step  50 loss 0.3553
step 100 loss 0.0578
step 150 loss 0.0279
step 200 loss 0.0177
[-0.954, 0.933, 0.931, -0.921]

17 个参数是 (隐藏层)加 (输出层)。输出符号和标签 完全一致。去掉隐藏层、直接 MLP([2, 1]) 训练 500 步,损失停在 4.0,也就是每个点都预测 0,线性模型在 XOR 上只能放弃。

训练循环的四步,前向、清零、反向、更新,和 PyTorch 里一模一样。区别只在粒度:Value 一个节点是一个标量,一个 512×512 的矩阵乘会生成几十万个节点,慢到不能用。PyTorch 的节点是张量,一次 matmul 是图里的一个节点,它的反向公式也是矩阵形式:若 ,则

记这类公式有个省力的办法:只看形状。 必须和 同形状,手里只有 和 ,能拼出这个形状的乘法基本只有一种。同样的 XOR,PyTorch 写法如下(示意,不重复贴输出):

import torch, torch.nn as nn
net = nn.Sequential(nn.Linear(2, 4), nn.Tanh(), nn.Linear(4, 1), nn.Tanh())
opt = torch.optim.SGD(net.parameters(), lr=0.1)
X = torch.tensor([[0., 0], [0, 1], [1, 0], [1, 1]]); Y = torch.tensor([[-1.], [1], [1], [-1]])
for step in range(200):
    loss = ((net(X) - Y) ** 2).sum()
    opt.zero_grad(); loss.backward(); opt.step()

nn.Linear 封装了 和初始化,opt.step() 就是那行 p.data -= lr * p.grad。你已经知道它们底下在干什么了。

停一下,检查理解单选

X 形状 (B, d_in),W 形状 (d_in, d_out),Y = X @ W,已知 dY 形状 (B, d_out)。dW 应该怎么算?

训练集上好,不等于真的好:过拟合与正则化

模型越强,越要警惕另一个问题。用 12 个带噪声的点拟合 ,分别用 1、3、11 次多项式(多项式特征 + 线性回归):

import numpy as np
rng = np.random.default_rng(1)
x = np.sort(rng.uniform(-1, 1, 12)); y = np.sin(3 * x) + rng.normal(0, 0.2, 12)
xt = np.linspace(-1, 1, 200); yt = np.sin(3 * xt)
def feats(x, d): return np.vander(x, d + 1, increasing=True)
for d, lam in [(1, 0), (3, 0), (11, 0), (11, 1e-3)]:
    A = feats(x, d)
    w = np.linalg.solve(A.T @ A + lam * np.eye(d + 1), A.T @ y)
    tr = ((A @ w - y) ** 2).mean(); te = ((feats(xt, d) @ w - yt) ** 2).mean()
    print(f"degree {d:2d} lambda {lam:<6} train {tr:.4f}  test {te:.4f}  |w|max {np.abs(w).max():.1f}")
degree  1 lambda 0      train 0.0999  test 0.1696  |w|max 1.0
degree  3 lambda 0      train 0.0054  test 0.0129  |w|max 2.5
degree 11 lambda 0      train 0.0000  test 50688.1548  |w|max 45262.7
degree 11 lambda 0.001  train 0.0029  test 0.0296  |w|max 2.6

1 次多项式两边误差都大,叫欠拟合(高偏差),模型太弱。11 次多项式有 12 个参数、12 个点,能精确穿过每个点,训练误差为 0,测试误差却爆到五万,系数大到四万多。它把噪声也背下来了,在点与点之间剧烈震荡,这叫过拟合(高方差)。

诊断规则很简单:训练误差高,是偏差问题,换更大的模型、训更久;训练误差低而验证误差高,是方差问题,加数据或加正则。为此数据要分成训练集、验证集(调超参数用)、测试集(最后只看一次)。用测试集调超参数,等于把测试集当训练集用了,报出来的数字就不可信。

最后一行展示了L2 正则化:损失里加一项 ,惩罚大系数。同样是 11 次多项式, 就把系数压回到个位数,测试误差降到 0.03,和 3 次多项式相当。梯度下降里这一项的导数是 ,每一步都把权重往 0 拉一点,所以也叫权重衰减(weight decay)。它和 Adam 一起用时有个微妙之处,正则项要不要进自适应缩放,这就是 AdamW 的由来(见 并行训练)。

神经网络里其他常用的正则手段还有 dropout(训练时随机把一部分激活置零,迫使网络不依赖个别神经元)、早停(验证误差开始上升时停下)和数据增强,后两者在 训练深度网络与 CNN 里讲。现代大模型有一个反直觉的现象:参数远多于数据点,却不一定过拟合,模型继续变大测试误差反而再次下降。经典的偏差-方差图景在这里不完全适用,但”看验证集说话”这条原则始终成立。

停一下,检查理解单选

一个模型训练集准确率 99%,验证集 70%。下列哪个做法最不可能有帮助?

记住这三件事

  1. 学习 = 定义一个损失,用梯度下降让它变小;损失函数的选择对应着对数据噪声的概率假设(MSE↔高斯,交叉熵↔类别分布)。
  2. 反向传播就是链式法则加记账:前向记下计算图,反向按逆拓扑序把梯度乘局部导数并累加,所以要 zero_grad。
  3. 线性模型叠多少层都是线性的,非线性激活才让深度有意义;而模型越强,越要用验证集和正则化防过拟合。

延伸阅读