ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从导数代码到梯度下降:机器学习自动微分与反向传播实战

从导数代码到梯度下降:机器学习自动微分与反向传播实战 如果让我给“从0开始学机器学习”画一个最容易劝退的点我不会选线性代数也不会选概率统计而是导数代码。机器学习里要用的“导数 代码”和考卷上要你手算的导数完全是两种东西。所以这次我专门把“导数代码”这个系列作为笔记记录怎么用代码理解导数、梯度、自动微分以及怎么让它们真正驱动一次模型训练。这篇算是系列的开篇适合数学基础一般、但想知道梯度下降为什么真的能把损失函数压低的人。1. 别急着刷高数机器学习需要哪种导数能力1.1 训练模型的本质是下山我习惯把训练模型看成“下山”模型的损失函数就是一座山的表面参数决定了你站在哪个位置损失值就是当前海拔。你希望找到海拔最低的谷底但山的形状通常复杂到没法一眼看穿只能靠“试着往下坡走一步再看新的海拔”来逼近。这时候问题就变成站在当前位置怎么知道哪个方向是下坡如果山只有一条坐标轴坡度就是导数如果山是一大片地形坡度就是梯度。机器学习里的训练循环本质上就是在反复做这件事算当前位置的梯度沿梯度的反方向走一小步更新参数再算新的梯度。所以导数不是数学课遗留的抽象概念而是“方向”和“步长”这两个信号最直接的来源。这也是为什么几乎所有机器学习框架里都有自动求导模块——因为整个训练过程都要靠它。1.2 代码导数和考场导数的区别以前学高数求导是“对着公式手算”核心能力是熟练链式法则、记牢常见导数表。但在机器学习里真正重要的不是你能不能手算出某个复杂函数的导数而是你能不能把“变化率”变成程序里的数值你能不能对一个大向量比如几百万个参数一次性求出所有偏导你能不能理解框架在你调用.backward()时到底做了什么。代码导数和考场导数的差别就像用计算器做复杂运算和用心算做复杂运算。心算能力强是好事但在一个大模型里心算完全不可行。你需要的是“让程序替你算导数”的能力以及“知道程序凭什么能算出来”的判断力。1.3 这篇笔记覆盖的范围我按自己实际学习的顺序把导数代码拆成四块数值导数、多元梯度、自动微分、梯度下降实战。前两块帮你看清导数和梯度的几何含义第三块是 PyTorch/TensorFlow 等框架背后的核心机制最后一块把它真正接到一次线性回归训练上让你看到一个完整的训练循环跑起来。这套顺序是我踩过坑之后反推出来的。如果一上来就教你调用loss.backward()你只会得到一个“反正能跑”的魔法操作如果先从手写的简陋自动微分开始后面看到框架里的各种优化器就会觉得很自然。2. 数值导数用极限的暴力美学建立直觉2.1 导数定义就是一段可以抄的代码导数就是变化率。教科书定义是[ f(x) \lim_{h \to 0} \frac{f(xh) - f(x)}{h} ]其实写成代码这就是“暴力逼近”给自变量一个很小的变化 h看函数值变化多少二者相除。我最开始写的就是最朴素的版本def forward_diff(f, x, h1e-5): 前向差分从 x 往右挪一点点 return (f(x h) - f(x)) / h还会有后向差分[ f(x) \approx \frac{f(x) - f(x-h)}{h} ]def backward_diff(f, x, h1e-5): 后向差分从 x 往左挪一点点 return (f(x) - f(x - h)) / h这两个写法在函数变化比较平缓时还好但一旦 h 取得不合适误差会很明显。原因下面细说。2.2 中心差分为什么它比前向差分稳得多真正更可靠的是中心差分。它的思想不是只看一边而是左右各取一个点看对称位置的变化[ f(x) \approx \frac{f(xh) - f(x-h)}{2h} ]def central_diff(f, x, h1e-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(fh{h:8} 前向差{fd:.12f} 后向差{bc:.12f} 中心差{cd:.12f})实际跑出来前向差分和后向差分在小 h 时精度会掉得比较厉害中心差分则稳得多。原因用泰勒展开就能看明白。对前向差分[ f(xh) f(x) h f(x) \frac{h^2}{2} f(x) \cdots ]所以[ \frac{f(xh) - f(x)}{h} f(x) \frac{h}{2} f(x) O(h^2) ]误差大约是 h 的一阶项。而中心差分左右一减二阶项正好消掉[ \frac{f(xh) - f(x-h)}{2h} f(x) O(h^2) ]误差是 h 的平方阶。所以同样一个 h中心差分通常比前向差分精确一个量级。提示数值导数里 h 并不是越小越好。h 太小以后f(xh)和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, h1e-5): 对任意函数 f:R^n - R 求数值梯度 grad np.zeros_like(x, dtypefloat) 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_{i1}^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 )然后对 ( xy ) 做 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()这个类只有一个标量节点不涉及矩阵但核心逻辑足够清晰。试试用它计算上面那个函数在 ( x2, y3 ) 时的梯度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 的梯度来自乘法项贡献 ( y3 )来自 relu 项经过 ( xy ) 链式贡献 ( 1 )所以 x.grad 4对 y 的梯度乘法项贡献 ( x2 )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, sizeX.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_{i1}^n (w x_i b - y_i) x_i ][ \frac{\partial L}{\partial b} \frac{2}{n} \sum_{i1}^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(fstep {step:3}: loss {mse_loss(X, y, w, b):.6f}, w {w:.4f}, b {b:.4f})跑一下就会发现loss 在最初几步下降很快后面逐渐放缓w 收敛到接近 3b 收敛到接近 1。这就完成了第一次“用导数更新参数”的闭环。为了更直观你可以把 loss 曲线画出来横轴是迭代次数纵轴是对数坐标下的 loss曲线先陡峭后平缓最后贴近水平线。这张图和“下山”类比完全吻合——越靠近谷底坡面越平缓每次移动带来的变化越小。5.3 学习率最容易搞炸的旋钮梯度只告诉方向更新幅度由学习率控制。所谓学习率就是“这一步走多大”。我把同样的循环分别用 lr 0.01、0.05、0.5 跑一遍结果很鲜明lr 0.01收敛很慢到 100 步还没完全贴合lr 0.0550 步左右基本稳定体验最舒服lr 0.5loss 会在一个区间震荡偶尔直接变成 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 在 ( x0 ) 时不可导。但在代码里框架要么把梯度记为 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从普通梯度下降扩展成带动量、自适应学习率的优化器。但不管走多远核心还是这篇笔记里围绕“导数代码”搭起来的几个概念数值导数是校验工具梯度指明方向自动微分负责高效传递梯度下降负责更新参数。你可以先跑一遍上面所有代码然后回到最开始的问题损失函数是一座山参数是你的位置梯度是脚下最陡的方向而学习率决定你要迈多大步。理解这四个意象之后再去看任何机器学习框架的训练代码都会感觉亲切很多。
返回列表