如果让我给“从0开始学机器学习”画一个最容易劝退的点,我不会选线性代数,也不会选概率统计,而是导数代码。机器学习里要用的“导数 + 代码”,和考卷上要你手算的导数,完全是两种东西。所以这次我专门把“导数代码”这个系列作为笔记,记录怎么用代码理解导数、梯度、自动微分,以及怎么让它们真正驱动一次模型训练。这篇算是系列的开篇,适合数学基础一般、但想知道梯度下降为什么真的能把损失函数压低的人。
1. 别急着刷高数:机器学习需要哪种导数能力
1.1 训练模型的本质是下山
我习惯把训练模型看成“下山”:模型的损失函数就是一座山的表面,参数决定了你站在哪个位置,损失值就是当前海拔。你希望找到海拔最低的谷底,但山的形状通常复杂到没法一眼看穿,只能靠“试着往下坡走一步,再看新的海拔”来逼近。
这时候问题就变成:站在当前位置,怎么知道哪个方向是下坡?如果山只有一条坐标轴,坡度就是导数;如果山是一大片地形,坡度就是梯度。机器学习里的训练循环,本质上就是在反复做这件事:算当前位置的梯度,沿梯度的反方向走一小步,更新参数,再算新的梯度。
所以导数不是数学课遗留的抽象概念,而是“方向”和“步长”这两个信号最直接的来源。这也是为什么几乎所有机器学习框架里都有自动求导模块——因为整个训练过程都要靠它。
1.2 代码导数和考场导数的区别
以前学高数,求导是“对着公式手算”,核心能力是熟练链式法则、记牢常见导数表。但在机器学习里,真正重要的不是你能不能手算出某个复杂函数的导数,而是:
- 你能不能把“变化率”变成程序里的数值;
- 你能不能对一个大向量(比如几百万个参数)一次性求出所有偏导;
- 你能不能理解框架在你调用
.backward()时到底做了什么。
代码导数和考场导数的差别,就像用计算器做复杂运算和用心算做复杂运算。心算能力强是好事,但在一个大模型里心算完全不可行。你需要的是“让程序替你算导数”的能力,以及“知道程序凭什么能算出来”的判断力。
1.3 这篇笔记覆盖的范围
我按自己实际学习的顺序,把导数代码拆成四块:数值导数、多元梯度、自动微分、梯度下降实战。前两块帮你看清导数和梯度的几何含义,第三块是 PyTorch/TensorFlow 等框架背后的核心机制,最后一块把它真正接到一次线性回归训练上,让你看到一个完整的训练循环跑起来。
这套顺序是我踩过坑之后反推出来的。如果一上来就教你调用loss.backward(),你只会得到一个“反正能跑”的魔法操作;如果先从手写的简陋自动微分开始,后面看到框架里的各种优化器,就会觉得很自然。
2. 数值导数:用极限的暴力美学建立直觉
2.1 导数定义就是一段可以抄的代码
导数就是变化率。教科书定义是:
[ f'(x) = \lim_{h \to 0} \frac{f(x+h) - f(x)}{h} ]
其实写成代码,这就是“暴力逼近”:给自变量一个很小的变化 h,看函数值变化多少,二者相除。我最开始写的就是最朴素的版本:
def forward_diff(f, x, h=1e-5): """前向差分:从 x 往右挪一点点""" return (f(x + h) - f(x)) / h还会有后向差分:
[ f'(x) \approx \frac{f(x) - f(x-h)}{h} ]
def backward_diff(f, x, h=1e-5): """后向差分:从 x 往左挪一点点""" return (f(x) - f(x - h)) / h这两个写法在函数变化比较平缓时还好,但一旦 h 取得不合适,误差会很明显。原因下面细说。
2.2 中心差分:为什么它比前向差分稳得多
真正更可靠的是中心差分。它的思想不是只看一边,而是左右各取一个点,看对称位置的变化:
[ f'(x) \approx \frac{f(x+h) - f(x-h)}{2h} ]
def central_diff(f, x, h=1e-5): """中心差分:左右对称采点,误差更小""" return (f(x + h) - f(x - h)) / (2 * h)我刚开始对这些差分种类很无所谓,觉得反正都是近似。直到对比测试才发现差距很大。以 ( f(x) = x^2 ) 在 ( x = 2 ) 处为例,真实导数是 4。把 h 从 1e-2 一路缩小到 1e-8,对比结果:
f = lambda x: x ** 2 for h in [1e-2, 1e-4, 1e-6, 1e-8]: fd = forward_diff(f, 2.0, h) bc = backward_diff(f, 2.0, h) cd = central_diff(f, 2.0, h) print(f"h={h:>8} 前向差={fd:.12f} 后向差={bc:.12f} 中心差={cd:.12f}")实际跑出来,前向差分和后向差分在小 h 时精度会掉得比较厉害,中心差分则稳得多。原因用泰勒展开就能看明白。对前向差分:
[ f(x+h) = f(x) + h f'(x) + \frac{h^2}{2} f''(x) + \cdots ]
所以:
[ \frac{f(x+h) - f(x)}{h} = f'(x) + \frac{h}{2} f''(x) + O(h^2) ]
误差大约是 h 的一阶项。而中心差分左右一减,二阶项正好消掉:
[ \frac{f(x+h) - f(x-h)}{2h} = f'(x) + O(h^2) ]
误差是 h 的平方阶。所以同样一个 h,中心差分通常比前向差分精确一个量级。
提示:数值导数里 h 并不是越小越好。h 太小以后,
f(x+h)和f(x-h)在浮点数里会不可分辨,减法带来的舍入误差会被放大。一般实践里 h 取 1e-5 到 1e-7 之间比较合适。
2.3 数值导数真正的用途
你可能想:既然有微积分公式,为什么还要用这种笨办法算导数?我至少遇到两个场景:
一是梯度检查。手写反向传播或者自定义了一个算子之后,你不敢保证公式推导错了没有。这时用数值导数当“裁判”,把解析梯度和数值梯度对比,偏差大了就有问题。它精度不高,但作为校验工具非常可靠。
二是不可导或黑盒函数。有些业务场景里的损失函数带离散逻辑,比如排序指标、不可微的阈值判断,没法直接求解析导数。数值导数能拿来近似估计梯度方向,在很多调参场景下还能用。
缺点也很明显:每求一个参数的偏导,都要重新算一遍函数,代价是 O(参数个数) 次前向计算。对几百万参数的模型,这完全不可行。所以数值导数适合“小规模验证”,不适合大规模训练。
3. 从一元导数到多元梯度:等高线给出的方向答案
3.1 偏导就是一次只动一个变量
进入机器学习之后,你面对的基本都是多元函数。最简单的例子是:
[ f(x, y) = x^2 + 3y^2 ]
对 x 求偏导时,把 y 当常数;对 y 求偏导时,把 x 当常数。所以:
[ \frac{\partial f}{\partial x} = 2x, \quad \frac{\partial f}{\partial y} = 6y ]
这种手算很简单,但我要展示的是如何用数值法推广到任意多元函数。核心思路:对每个维度分别做中心差分,其余维度保持不变。
import numpy as np def numerical_gradient(f, x, h=1e-5): """对任意函数 f:R^n -> R 求数值梯度""" grad = np.zeros_like(x, dtype=float) for i in range(len(x)): xp = x.copy() xm = x.copy() xp[i] += h xm[i] -= h grad[i] = (f(xp) - f(xm)) / (2 * h) return grad def f(v): x, y = v return x ** 2 + 3 * y ** 2 print(numerical_gradient(f, np.array([1.0, 1.0])))输出结果应该约等于[2.0, 6.0],和解析求导结果一致。
多变量场景里,“梯度”不是单个数字,而是一个向量,每个分量就是对应自变量方向的偏导。上面 numpy 实现里,循环遍历每个维度做中心差分,就是最直观的“偏导”理解。
3.2 方向导数:为什么偏偏沿梯度方向最陡
知道梯度还不够,还得知道梯度为什么是“最陡上升”的方向。引入方向导数的概念:单位方向向量 ( \mathbf{u} ) 上的变化率可以写成:
[ D_{\mathbf{u}} f(x) = \nabla f(x) \cdot \mathbf{u} ]
如果 ( \mathbf{u} ) 是单位向量,这个内积最大发生在 ( \mathbf{u} ) 和 ( \nabla f(x) ) 方向一致时,最大值就是 ( |\nabla f(x)| )。换句话说,梯度指向的就是上升最快的方向。
我用代码验证过这一点。上面例子里在 (1, 1) 点的梯度是 (2, 6),模长约为 6.32。单位梯度方向是 ( (2, 6)/6.32 \approx (0.316, 0.949) )。沿着这个方向算方向导数,结果应该就是梯度模长;换个方向比如 ( (0.949, -0.316) ),值就小得多。
这就是“梯度下降”名字的来历:要找最小值,不能往梯度方向走,要往梯度的反方向走。沿着等高线看,梯度箭头总是垂直穿过等高线,指向海拔上升最快的那一侧;反方向就是下山最陡的路径。
3.3 机器学习里的梯度,经常是对一堆参数求偏导
一个实际的模型里,输入通常是数据,参数才是我们要优化的变量。比如最简单的线性回归:预测值 ( \hat{y} = wx + b ),损失函数 ( L = \frac{1}{n} \sum_{i=1}^n (\hat{y}_i - y_i)^2 )。
当你对 w 和 b 求梯度时,x 和 y 都是已知数据,只有 w 和 b 是未知数。理解这一点很重要,因为很多新手会对“对谁求导”感到困惑。框架里loss.backward()算出来的梯度,默认是 loss 对“叶子参数”的梯度,而不是对输入的梯度。这条细节在调试自己的自定义 layer 时能省不少时间。
4. 自动微分:机器学习真正在用的导数代码
4.1 三种求导流派,各自的应用场景
我刚开始学的时候,一直想不通一个问题:既然框架能自动算梯度,它到底用的是哪种方案?简单梳理一下:
| 流派 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 符号微分 | 用符号表达式推导导数公式 | 精确,适合公式化简 | 表达式会指数膨胀,不适合深层网络 |
| 数值微分 | 用极限/差分近似 | 实现简单,零推导成本 | 计算量大,有截断误差和舍入误差 |
| 自动微分 | 把函数拆成计算图,反向传递链式法则 | 兼顾精度和效率,能处理百万级参数 | 需要理解计算图,调试有一定门槛 |
机器学习框架里,PyTorch 的autograd、TensorFlow 的GradientTape、JAX 的grad底层都是自动微分。它不是真的“自动微积分”,而是把整个函数拆成一系列基础运算,然后利用链式法则反向传播梯度。
4.2 计算图与链式法则
任何函数都能画成一张计算图。比如:
[ f = (x \times y) + \text{relu}(x + y) ]
这个函数可以先算 ( x \times y ),再算 ( x + y ),然后对 ( x+y ) 做 relu,最后把两部分加起来。前向传播就是按图从左往右算值,反向传播就是按图从右往左传递梯度。
反向传播的依据是链式法则:如果 ( z ) 依赖 ( y ),而 ( y ) 依赖 ( x ),那么:
[ \frac{\partial z}{\partial x} = \frac{\partial z}{\partial y} \cdot \frac{\partial y}{\partial x} ]
问题在于,手写链式法则在复杂网络里会越来越繁琐。计算图的价值就是把这个过程结构化:每个节点只需要知道自己怎么求“对父节点的梯度”,子节点把自己的梯度乘上去传给父节点即可。
4.3 手写一个极简自动微分引擎
为了真正理解链式法则的代码实现,我建议你也手写一个极简版本。不用像 PyTorch 那样完整,只需要支持加法、乘法、relu 就够了。我的实现是:
class Value: def __init__(self, data, children=(), op=''): self.data = data self.grad = 0.0 self.children = children self.op = op self._backward = lambda: None 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 relu(self): out = Value(max(0.0, self.data), (self,), 'relu') def _backward(): self.grad += (out.data > 0) * 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.children: build(c) topo.append(v) build(self) self.grad = 1.0 for v in reversed(topo): v._backward()这个类只有一个标量节点,不涉及矩阵,但核心逻辑足够清晰。试试用它计算上面那个函数在 ( x=2, y=3 ) 时的梯度:
x = Value(2.0) y = Value(3.0) f = (x * y) + (x + y).relu() f.backward() print(f.data, x.grad, y.grad)手算验证一下:( f = 2 \times 3 + \text{relu}(5 + 3) = 6 + 5 = 11 )。对 x 的梯度,来自乘法项贡献 ( y=3 ),来自 relu 项经过 ( x+y ) 链式贡献 ( 1 ),所以 x.grad = 4;对 y 的梯度,乘法项贡献 ( x=2 ),relu 链贡献 1,所以 y.grad = 3。程序输出应该也是 4 和 3。
4.4 为什么要用反向模式,而不是前向模式
你可能注意到上面代码里的backward()是从输出往输入反向传播,这叫反向模式自动微分。还有一个选择是“前向模式”,把导数直接跟着计算图从输入传到输出。为什么框架都选反向模式?
关键差异在于:如果你的函数有 d 个输入、一个标量输出,前向模式需要做 d 趟才能把所有输入方向的导数算完;反向模式只需要 1 趟前向计算加 1 趟反向传播,就能把 d 个输入的梯度全算出来。
机器学习里,输入参数的维度动辄百万,输出往往是一个标量损失值。这种“一个海量输入、一个标量输出”的结构,反向模式几乎是唯一解。理解这一点,你就会明白为什么深度学习都在讲“反向传播”而不是“前向传播”。
5. 用导数驱动一次线性回归:从梯度到下降实操
5.1 从手写微积分开始,而不是一开始就套框架
上面手写的自动微分有点抽象,还是得接到真实问题上才有实感。最经典的自然是线性回归。我先造一组带噪声的线性数据:
np.random.seed(0) X = np.array([1., 2., 3., 4., 5.]) y = 3.0 * X + 1.0 + np.random.normal(0, 0.2, size=X.shape)目标是让模型y_pred = w * x + b尽量贴合数据。损失函数用均方误差:
def mse_loss(X, y, w, b): pred = w * X + b return np.mean((pred - y) ** 2)对 w 和 b 的梯度,可以用中学的链式法则直接推:
[ \frac{\partial L}{\partial w} = \frac{2}{n} \sum_{i=1}^n (w x_i + b - y_i) x_i ]
[ \frac{\partial L}{\partial b} = \frac{2}{n} \sum_{i=1}^n (w x_i + b - y_i) ]
5.2 梯度下降主循环
梯度下降的核心代码非常短。初始化两个参数,然后反复算梯度、更新参数:
w, b = 0.0, 0.0 lr = 0.05 for step in range(101): pred = w * X + b diff = pred - y dw = 2 * np.mean(diff * X) db = 2 * np.mean(diff) w -= lr * dw b -= lr * db if step % 20 == 0: print(f"step {step:>3}: loss = {mse_loss(X, y, w, b):.6f}, w = {w:.4f}, b = {b:.4f}")跑一下就会发现,loss 在最初几步下降很快,后面逐渐放缓,w 收敛到接近 3,b 收敛到接近 1。这就完成了第一次“用导数更新参数”的闭环。
为了更直观,你可以把 loss 曲线画出来:横轴是迭代次数,纵轴是对数坐标下的 loss,曲线先陡峭后平缓,最后贴近水平线。这张图和“下山”类比完全吻合——越靠近谷底,坡面越平缓,每次移动带来的变化越小。
5.3 学习率:最容易搞炸的旋钮
梯度只告诉方向,更新幅度由学习率控制。所谓学习率,就是“这一步走多大”。我把同样的循环分别用 lr = 0.01、0.05、0.5 跑一遍,结果很鲜明:
- lr = 0.01:收敛很慢,到 100 步还没完全贴合;
- lr = 0.05:50 步左右基本稳定,体验最舒服;
- lr = 0.5:loss 会在一个区间震荡,偶尔直接变成 nan。
原因不复杂。更新公式是:
[ \theta_{new} = \theta_{old} - \eta \nabla L ]
如果 η 太大,一次更新就越过了谷底,甚至跳到山坡另一侧更高处;再下一次更新又跳回来,反复震荡。如果 η 太大到爆炸,参数会指数膨胀,loss 直接溢出成 inf 或 nan。
注意:训练曲线出现 loss 中间突然跳到 nan,大概率不是优化器坏了,而是学习率太大,或者特征没有归一化导致梯度量级差异悬殊。先检查这两项,再考虑代码 bug。
5.4 从线性回归到神经网络的迁移
线性回归其实可以看成“单层、无激活函数、一个输出”的神经网络。一旦你把线性层换成一堆乘法和加法,把 relu 激活插在中间,把多个层叠起来,反向传播推导会变复杂,但代码逻辑和上面手写 Value 引擎完全一致。
这也是为什么我不建议跳过手写自动微分这一节。线性回归可以直接用公式推出梯度,但一旦模型复杂,公式推导就不可行了。自动微分把“损失对中间层权重的梯度”用计算图统一算出来,解决的就是这个问题。
6. 写代码时最容易踩的几个坑
6.1 梯度累加,忘了清零
如果你用 PyTorch 等框架,同一个 batch 连续调用几次backward(),梯度默认会累加而不是覆盖。原因和上面的手写引擎一样:每个节点的grad初始值大多是 0,反向传播时用+=累加。
所以训练循环里会有标准的:
optimizer.zero_grad() loss.backward() optimizer.step()zero_grad()本质上就是把所有参数的 grad 属性清零。我一开始总忘记这行,结果 loss 越来越小,梯度却越来越大,最终损失直接爆炸。查找这种 bug 时,千万别忽略“梯度没清零”这个位置。
6.2 Relu 在 0 点不可导,代码不会报错
严格来说,relu 在 ( x=0 ) 时不可导。但在代码里,框架要么把梯度记为 0,要么记为 1,不会抛出异常。你选择哪种行为都可能影响训练结果。比如自定义算子时不注意这一点,梯度方向在零点附近可能会有微小偏差。
6.3 解析梯度与数值梯度不一致时,先看相对误差
梯度检查是调试反向传播的重要手段。别直接比较绝对数值,因为量纲不同绝对值没有参考意义。一般用相对误差:
[ \text{rel_err} = \frac{|g_{num} - g_{ana}|}{|g_{num}| + |g_{ana}|} ]
如果相对误差在 1e-5 以下,基本可以认为反向传播公式没问题。如果误差在 1e-2 量级,通常就是某个节点_backward写错了,比如加减号搞反、乘法时两个输入的值没有交换。
6.4 参数的初始化和特征缩放对梯度影响很大
我在线性回归里故意把 X 设为[1, 2, 3, 4, 5],这种量纲还好。但如果 X 是几千几万的量级,模型对 w 的梯度和对 b 的梯度会差好几个数量级,一不小心学习率就很难同时兼顾两个维度。实际处理中,把特征缩放到 0 附近(例如标准化)能让梯度更均匀,训练也更稳定。
6.5 记住你求的是“损失对参数的梯度”
我早期写自定义层时经常绕晕:一个输入 x、参数 W,到底该对谁求梯度?如果最终目标是优化参数,那就要把 W 视为变量,x 视为常量。框架里区分叶子节点正是为了干这件事。调试时可以打印x.grad和W.grad,看看是不是只有 W 的梯度非零、x 的梯度在你的预期范围。
7. 把这次笔记串成一条学习路径
如果让我给后来者一条建议,我会说:先手写一个 20 行的自动微分,再去调现成框架。我的亲身感受是,虽然 Value 类很简单,但写完之后,再看 PyTorch 里loss.backward(),就不再觉得它是魔法了。反向传播的本质就是沿着计算图,把链式法则一层层传回去。
后面的笔记里,我会继续沿这条路走下去:从线性回归换成多层感知机,从标量 Value 扩展成 Tensor,从普通梯度下降扩展成带动量、自适应学习率的优化器。但不管走多远,核心还是这篇笔记里围绕“导数代码”搭起来的几个概念:数值导数是校验工具,梯度指明方向,自动微分负责高效传递,梯度下降负责更新参数。
你可以先跑一遍上面所有代码,然后回到最开始的问题:损失函数是一座山,参数是你的位置,梯度是脚下最陡的方向,而学习率决定你要迈多大步。理解这四个意象之后,再去看任何机器学习框架的训练代码,都会感觉亲切很多。