给你 50 个点,看上去大致排成一条直线,让你画一条”最好”的线。你会凭眼睛画;计算机没有眼睛,它只能做一件事:先随便画一条,算一下画得有多差,然后朝”变好”的方向挪一点,重复几千次。这就是机器学习里”学”的全部含义。从这条直线出发,只需要再加三样东西:一个非线性、一个链式法则、一个自动记账的数据结构,就能得到神经网络和 PyTorch 的 loss.backward()。
把”好”变成一个数:损失函数
先造数据:真实关系是 ,再加一点噪声。模型是 ,两个参数 待定。
“这条线画得好不好”必须变成一个可以比较大小的数,否则没法往好的方向挪。最常见的选择是均方误差(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 很慢。最直接的修法是?
w 的梯度被 x 的尺度放大,b 没有,两方向曲率差得多。标准化后两个方向曲率接近,同一个学习率对两者都合适。
调大到 0.05 会让 w 方向先发散,正文已经跑出来了。换损失函数不改变尺度失衡。加数据只让梯度估计更准,不改变曲面形状。
从回归到分类: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 分子分母同乘 e 的 100 次方,抵消掉,所以平移不变;这也是减最大值技巧成立的原因。梯度公式正文已推出并验证。
F.cross_entropy 期望的是原始 logits,先 softmax 等于做了两次,结果错误且数值不稳。MSE 配 sigmoid 在预测错得厉害时梯度会饱和,不是等价的。
线性模型的天花板:加一层非线性
把 softmax 回归用在 Fashion-MNIST(28×28 的衣服鞋子图片)上,测试准确率大约 83%–85%,再怎么训也上不去。原因不在优化,在模型本身:它只能画直线(超平面)把类别分开。
最小的反例是 XOR:四个点 是一类, 是另一类。没有任何一条直线能把它们分开。那多叠几层线性层呢?,两层线性还是一层线性,白叠。
关键是在层与层之间插一个非线性激活函数:
可以是 tanh、ReLU 等。这就是多层感知机(MLP)。第一层把输入映射到一个新空间,在新空间里原本线性不可分的点可能就可分了,第二层再在新空间里画直线。万能近似定理说,只要隐藏层足够宽,一个隐藏层的 MLP 就能以任意精度逼近任何连续函数。这个定理只保证”存在”,不保证梯度下降找得到、也不保证需要的宽度合理;实践中”更深”往往比”更宽”划算得多。
现在问题来了:参数变成了矩阵 ,损失是层层嵌套的复合函数,再手推导数就很痛苦,每改一次模型结构都要重推一遍。我们需要让计算机自己算导数。
一个网络由三层 Linear 直接堆叠、中间没有激活函数。它的表达能力相当于?
线性映射的复合仍是线性映射,三个矩阵乘起来等于一个矩阵(偏置也合并成一个),所以只能表示线性函数。
能拟合 XOR 需要非线性。宽度再大也改变不了"复合后仍线性"这件事,不存在介于两者之间的情况。
链式法则 + 记账:从零写一个 autograd
任何复杂的函数都是由加、乘、tanh 这样的基本运算拼出来的。每个基本运算的局部导数我们都会: 时 ; 时 。链式法则说,最终输出 对某个中间量 的导数,等于 对 的”下游”的导数乘上局部导数,如果 流向了多个下游,就把各条路的贡献加起来。
于是做法很机械:
- 前向:计算时把每一步记下来,形成一张有向无环图(计算图),每个节点记住它的输入是谁、怎么算的。
- 反向:从 出发,令 ,按拓扑序的逆序访问每个节点,把自己的梯度乘上局部导数,累加到输入节点上。
下面是一个只有标量的 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) 时会怎样?
a 同时作为乘法和加法的输入,链式法则要求把两条路径的梯度相加;赋值会让后执行的 _backward 覆盖先写入的值。
b 只有一条路径,不受影响;程序也不会报错,只会静默地算错,这正是这类 bug 难查的原因。
用它训练一个网络,再换成 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 应该怎么算?
dW 必须是 (d_in, d_out)。X.T 是 (d_in, B),乘 (B, d_out) 正好得到它,并且对 batch 维求和,对应每个样本贡献累加。
dY @ X.T 形状对不上无法相乘;dY.T @ X 得到 (d_out, d_in),是转置后的结果;X @ dY.T 得到 (B, B),含义完全不对。
训练集上好,不等于真的好:过拟合与正则化
模型越强,越要警惕另一个问题。用 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.61 次多项式两边误差都大,叫欠拟合(高偏差),模型太弱。11 次多项式有 12 个参数、12 个点,能精确穿过每个点,训练误差为 0,测试误差却爆到五万,系数大到四万多。它把噪声也背下来了,在点与点之间剧烈震荡,这叫过拟合(高方差)。
诊断规则很简单:训练误差高,是偏差问题,换更大的模型、训更久;训练误差低而验证误差高,是方差问题,加数据或加正则。为此数据要分成训练集、验证集(调超参数用)、测试集(最后只看一次)。用测试集调超参数,等于把测试集当训练集用了,报出来的数字就不可信。
最后一行展示了L2 正则化:损失里加一项 ,惩罚大系数。同样是 11 次多项式, 就把系数压回到个位数,测试误差降到 0.03,和 3 次多项式相当。梯度下降里这一项的导数是 ,每一步都把权重往 0 拉一点,所以也叫权重衰减(weight decay)。它和 Adam 一起用时有个微妙之处,正则项要不要进自适应缩放,这就是 AdamW 的由来(见 并行训练)。
神经网络里其他常用的正则手段还有 dropout(训练时随机把一部分激活置零,迫使网络不依赖个别神经元)、早停(验证误差开始上升时停下)和数据增强,后两者在 训练深度网络与 CNN 里讲。现代大模型有一个反直觉的现象:参数远多于数据点,却不一定过拟合,模型继续变大测试误差反而再次下降。经典的偏差-方差图景在这里不完全适用,但”看验证集说话”这条原则始终成立。
一个模型训练集准确率 99%,验证集 70%。下列哪个做法最不可能有帮助?
训练好、验证差是高方差(过拟合),更大的模型通常让过拟合更严重。
更多数据、正则化、数据增强都在缩小训练和验证之间的差距,是对症的做法。
记住这三件事
- 学习 = 定义一个损失,用梯度下降让它变小;损失函数的选择对应着对数据噪声的概率假设(MSE↔高斯,交叉熵↔类别分布)。
- 反向传播就是链式法则加记账:前向记下计算图,反向按逆拓扑序把梯度乘局部导数并累加,所以要
zero_grad。 - 线性模型叠多少层都是线性的,非线性激活才让深度有意义;而模型越强,越要用验证集和正则化防过拟合。