想学机器学习,最绕不开的一座山就是导数。我见过太多人卡在反向传播上,卡在梯度下降里,根子其实都在于没把导数这一关真正过掉。这个系列是我自己从0开始学机器学习的笔记整理,这一篇专门讲导数——而且是带着代码讲的导数。教材里那一堆极限定义和求导公式,看到第三页就容易犯困;我自己的做法是把每个概念都写成代码,跑一遍,观察数值变化,再回头看公式,很多疑惑就自己解开了。这个"导数代码系列"就是干这个的:不堆推导,只讲怎么把导数在代码里落地。适合跟我一样习惯先动手再补理论的人,也适合刚入门想找个清晰提纲的新手。
1. 为什么学机器学习要先啃导数
1.1 导数在机器学习里的真实位置
很多初学者以为机器学习就是调库、跑模型。真到自己动手写训练循环时,第一个卡住的地方是:参数到底该往哪个方向更新?答案就藏在导数里。
举个最直观的例子。假设模型输出是 f(w),我们希望它和真实值的差距尽量小,于是定义损失函数 L(w)。训练的本质就是找一组 w,让 L(w) 最小。问题在于 L 可能极其复杂,几乎不可能直接解方程求出最小值点,只能一步步试探着走。每走一步都需要一个关键信息:当前位置往哪个方向动,L 才会下降得最快?这个信息就是导数,严格来说是负梯度。
在只有一个参数量 w 的情况下,L'(w) 告诉我们 w 增大时 L 是涨还是跌。如果 L'(w) > 0,说明增大 w 会让损失变大,那就应该减小 w;反过来,L'(w) < 0 就增大 w。于是就有了最基本的更新规则:
w ← w - η · L'(w)
这个 η 就是学习率。你每天都听人说"梯度下降",拆开来看其实就是这么一步:用导数指方向,用学习率定步长,反复迭代直到收敛。
到了神经网络里,参数不止一个,而是几百万个。这时 L 对每个参数的偏导数组成的向量就叫梯度,更新规则变成 θ ← θ - η·∇L(θ)。看着复杂,本质还是同一个东西。再往后,反向传播也不过是用链式法则,把损失对每一层参数的导数高效地算出来。
所以你看,导数不是数学课上的抽象玩具,而是训练过程的"方向盘"。方向盘没学好,后面调参全凭感觉,模型不收敛都不知道是哪里出了问题。
1.2 三种求导方式,先建立全局观
学习导数有个很反直觉的点:数学上求导是"推公式"的过程,但代码里求导有完全不同的几条路线。我整理成三类,初学者最好一开始就把这个框架搭起来。
| 实现方式 | 基本思路 | 典型用途 | 局限 |
|---|---|---|---|
| 数值求导 | 用极限定义近似计算 | 验证其他方法是否正确 | 慢、有精度误差 |
| 符号求导 | 像人一样推公式,得到导函数表达式 | 小规模公式推导 | 表达式会膨胀 |
| 自动求导 | 拆成基本运算,用链式法则逐层回传 | 深度学习框架的核心 | 需要构建计算图 |
这三条路线我后面都会用代码演示一遍。先记住结论:框架训练时用的是自动求导;但你手动排查梯度问题时,数值求导反而是最可靠的参考标准。
2. 从极限到梯度:入门必须搞懂的三个概念
2.1 导数的直觉:斜率、切线、瞬时变化率
先说导数的定义。f 在 x 处的导数定义为:
f'(x) = lim(h→0) (f(x+h) - f(x)) / h
第一次看到这个式子的人通常会问:h 趋近于 0 又不等于 0,到底怎么算?我的理解方法是把它当成开车看码表。跑一段路,总路程除以总时间,得到的是平均速度;但码表上显示的是某一瞬间的瞬时速度。要得到瞬时速度,就得让时间间隔趋于 0。导数就是这个"间隔无限小"时的变化率。
几何上,导数就是曲线在某个点的切线斜率。f'(x) 大,说明曲线在那个点很陡;f'(x) = 0,说明那里是平缓的极值点。机器学习里的损失函数,我们找的恰恰就是这种"平缓点",即极值附近的位置。
我给自己留了一个加深印象的小练习:画出 y = x² 的曲线,在 x = 2 处放一把尺子当切线,尺子斜率应该是 4,刚好等于 2x 在 x = 2 时的值。这个概念一旦跟图像挂上钩,后面的偏导数和梯度就都好懂多了。
2.2 偏导数:从一元到多元的跳跃
损失函数几乎不会只有一个变量。比如线性回归里有斜率 w 和截距 b 两个参数,损失 L(w, b) 就是二元函数。这时候"导数"要升级成"偏导数":固定其他变量不动,只看其中一个变量变化时函数怎么变,记作 ∂L/∂w。
你可以把偏导数理解为"单独拧一个旋钮时的灵敏度"。调 w 时,假设 b 暂时锁死,看损失变化多少;调 b 时,假设 w 锁死,再看损失变化多少。两个偏导数各管各的,加在一起就是完整的方向信息。
我自己踩过的坑是:一开始总把偏导数当成普通函数求导,忽略了"固定其他变量"这个动作。其实只要记住一句口诀——偏导数是对某个变量单独求导,其他变量在求导时一律当常数——就不会错。比如 f(x, y) = x² + 3xy + y²,∂f/∂x 时把 y 当常数:2x + 3y;∂f/∂y 时把 x 当常数:3x + 2y。
2.3 梯度:指向最陡上升方向的那个向量
把函数对每个变量的偏导数按顺序排成一个向量,就是梯度,记作 ∇f。梯度有一个非常漂亮的性质:它指向函数值上升最快的方向,梯度向量的模长就是上升的速率。
举个例子,f(x, y) = x² + y²。在点 (1, 2) 处,∂f/∂x = 2x = 2,∂f/∂y = 2y = 4,所以梯度是 (2, 4)。从 (1, 2) 出发,往 (2, 4) 这个方向走,f 的数值增长最快。要最小化 f,就往相反方向 (-2, -4) 走,这就是"负梯度方向",也是梯度下降名字的由来。
为什么梯度这么重要?因为机器学习的所有优化问题,本质上都在玩同一个游戏:在参数空间里找一个点,让损失函数最小。找不到解析解,就只能在每个点问一句"往哪走下降最快",然后迈一小步。这句话问一千遍一万遍,就是训练的全过程。
3. 导数代码实战:数值、符号、自动三种实现
3.1 数值微分:用中心差分理解导数本质
数值微分是最笨但最直观的方法——直接照着定义算。我用中心差分公式,比简单的前向差分精度更高:
def numerical_derivative(f, x, h=1e-6): return (f(x + h) - f(x - h)) / (2 * h) # 验证:f(x) = x^3 + 2x,理论导数 3x^2 + 2 f = lambda x: x**3 + 2*x for x in [1.0, 2.0, 3.0]: approx = numerical_derivative(f, x) exact = 3 * x**2 + 2 print(f"x={x}: 数值={approx:.10f}, 理论={exact:.10f}, 误差={abs(approx-exact):.2e}")运行输出大致如下:
x=1.0: 数值=5.0000000000, 理论=5.0000000000, 误差=0.00e+00 x=2.0: 数值=14.0000000000, 理论=14.0000000000, 误差=0.00e+00 x=3.0: 数值=28.9999999936, 理论=29.0000000000, 误差=6.39e-09中心差分之所以比前向差分 (f(x+h) - f(x))/h 好,是因为它的截断误差是 O(h²),而前向差分只有 O(h)。用 h=1e-6 时,中心差分的理论误差在 1e-12 量级,前向差分在 1e-6 量级,差了好几个数量级。
但数值微分有个致命缺点:每算一个点的导数,就要多算两次函数值;参数有 n 个,梯度就要算 2n 次函数值。神经网络动辄上百万参数,根本不可能用这种方式训练。所以它真正的用武之地是"梯度检查"——验证手写或者框架算出来的梯度是否正确,第5章我会专门讲这个。
注意一点:h 不是越小越好。h 太小,f(x+h) 和 f(x-h) 几乎相等,计算机浮点精度有限,相减时有效位数被消掉,反而放大误差。我实测过:h=1e-8 时误差比 h=1e-6 更大。经验值取 1e-6 到 1e-5 比较稳。
3.2 符号求导:让程序帮你推导函数
符号求导就是"让计算机像人一样推公式"。数学上 d/dx (x³ + 2x) = 3x² + 2,符号求导库做的事情就是输出这个导函数表达式,而不是某个点的数值。Python 里可以用 sympy 来演示:
from sympy import symbols, diff, sin, exp x = symbols('x') expr = x**3 + 2*x print(diff(expr, x)) # 3*x**2 + 2 expr2 = sin(x) * exp(x) print(diff(expr2, x)) # exp(x)*sin(x) + exp(x)*cos(x)看起来很方便,但符号求导有一个工程上的大坑:表达式膨胀。想象一个很深的神经网络,每一层都套着一个复杂函数,用链式法则逐层展开,最后得到的导函数表达式会指数级变长。层数一多,光是存储这个表达式就够呛,更别说计算了。
所以符号求导适合小规模公式推导,不适合深度学习。但作为学习工具,它有一个隐藏价值:可以帮你验证自己手推的导数对不对,尤其是比较绕的复合函数。我平时推完一版公式,会先用符号求导快速核对一遍,再写进训练代码,能省不少调试时间。
3.3 自动求导:深度学习框架的看家本领
现在的深度学习框架里,既不用数值微分,也不用符号微分,而是用自动求导。它结合了两者的优点:计算的是具体数值,但借助链式法则,把整个计算过程拆成一步步基本运算,再从后往前回传。
我写过一个极简演示,帮助自己理解反向模式(reverse mode)的执行过程。假设要求 y = x·sin(x) 在 x=3 处的导数:
import math x = 3.0 # 前向:记录中间变量 t1 = math.sin(x) # t1 = sin(x) t2 = x * t1 # t2 = x * sin(x) = y # 反向:从最终输出往回推 dy_dt2 = 1.0 dt2_dx = t1 # t2 = x * t1,对 x 求导得到 t1 dt2_dt1 = x # t2 = x * t1,对 t1 求导得到 x dt1_dx = math.cos(x) # t1 = sin(x) # 链式法则合并两条路径 dy_dx = dt2_dx + dt2_dt1 * dt1_dx print(dy_dx) # 约 -2.789,理论值是 sin(3) + 3*cos(3)这段代码虽然只处理了一个非常简单的表达式,但核心逻辑跟框架里做的事情一模一样:前向过程把每个中间结果存下来,反向过程从输出端开始,沿着计算图把导数一步步传回来。神经网络动辄几百层,训练之所以可行,靠的就是这种反向模式自动求导——一次前向加一次反向,就能拿到所有参数的梯度。
实际用框架时不需要自己实现这些。定义好计算图,前向传播跑一遍,调用一下 backward()(不同框架方法名略有差异),梯度就自动填到每个变量上。理解自动求导机制,对排查"为什么梯度是 None"这类问题极有帮助。我之前就遇到过计算图在中间被重建导致反向传播断链的情况,不理解原理的话,光看报错信息很难想到是这个原因。
4. 用导数驱动梯度下降:线性回归完整实战
4.1 手写梯度下降,亲手感受导数作用
理论说了一堆,不如来一个完整实验。我从头手写一个线性回归的梯度下降,不借助任何高级框架,看导数在训练里到底扮演什么角色。
先生成一份带噪声的数据,真实的隐藏规律是 y ≈ 2x + 1:
import numpy as np np.random.seed(42) x = np.linspace(0, 2, 100) y_true = 2 * x + 1 + 0.3 * np.random.randn(100)接着定义损失函数 MSE,以及它关于参数 w 和 b 的偏导数。这里的推导值得亲手做一遍:y_pred = w·x + b,损失 L = (1/n) Σ(y_pred - y_true)²。对 w 求偏导时把 b 当常数,得到 ∂L/∂w = (2/n) Σ x(y_pred - y_true);对 b 求偏导时把 w 当常数,得到 ∂L/∂b = (2/n) Σ(y_pred - y_true)。
def mse(y_pred, y_true): return np.mean((y_pred - y_true) ** 2) w, b = 0.0, 0.0 lr = 0.5 epochs = 50 for epoch in range(epochs): y_pred = w * x + b loss = mse(y_pred, y_true) gw = 2 * np.mean(x * (y_pred - y_true)) gb = 2 * np.mean(y_pred - y_true) w -= lr * gw b -= lr * gb if epoch % 10 == 0: print(f"epoch={epoch:3d} loss={loss:.4f} w={w:.4f} b={b:.4f}")运行结果大概是 loss 从十几一路降到零点几,w 收敛到 2 附近,b 收敛到 1 附近。整个过程最值得观察的现象就是:每次迭代都用梯度告诉模型"该往哪个方向调",模型就真的往那边走一步,损失稳步下降。
我建议你改一个地方做对比实验:把更新方向从w -= lr * gw改成w += lr * gw。你会立刻看到 loss 一路暴涨,完全无法收敛。这个反例比任何讲解都能让你记住一件事:训练用的是负梯度方向,因为导数是上升方向,要最小化损失就必须反着走。
4.2 学习率的坑:太小不收敛,太大直接炸
学习率是整个梯度下降里最敏感的超参数。同一份数据、同一个初始点,学习率不同,结果天差地别。我用 lr=0.01、0.5、2.0 分别跑同一份代码,记录 50 轮后的现象:
| 学习率 | 实际表现 | 原因 |
|---|---|---|
| 0.01 | 收敛但很慢,50 轮后 w 只到 1.2 左右 | 步子太小,参数挪得慢 |
| 0.5 | 快速收敛,约 20 轮就基本到位 | 步长与损失曲面匹配 |
| 2.0 | loss 震荡甚至发散,数值变成 NaN | 步子太大,越过谷底来回弹 |
为什么学习率大了会发散?用一维情形解释:当前位置导数为 5,学习率取 2,一次更新就前进 10。如果谷底就在前方 6 个单位处,这一步跨过谷底跳到另一边。下一个位置的导数方向还是指向谷底,但一步又过头,于是来回震荡,甚至越震越大。
经验上,调学习率有几个土办法:一个是看 loss 曲线,收敛平滑就试着加大,震荡就减小;另一个是先用 0.1 量级起步,再按 3 倍、10 倍上下试探。更高级的做法是学习率衰减或者用自适应优化器,核心思想都是"一开始走快点,接近极值时自动放慢"。但作为理解导数的练习,我强烈建议先用最原始的梯度下降跑一遍,别急着上优化器,这样你才能真切感受到导数、学习率、损失曲面三者的相互作用。
5. 常见问题与排查技巧实录
5.1 数值微分里的精度陷阱
数值微分看起来简单,实际用起来全是坑。第一个坑是 h 的选择。前面提到中心差分一般取 1e-6 到 1e-5,但具体函数不一样。你可以写个循环测试 h 从 1e-2 到 1e-12,观察误差变化:一开始误差随 h 减小而减小,到达某个最低点后,误差反而增大。那个拐点附近就是最合适的 h。
第二个坑是函数值域跨度大。如果 f(x) 本身数值很大,比如上万,那么 f(x+h) 和 f(x-h) 的差值在浮点数表示下可能只有最后几位不同,有效位数严重不足。解决办法是先对函数做归一化,或者改用更高精度的浮点类型再计算。
第三个坑更隐蔽:数值微分对不连续点毫无抵抗力。如果函数在某点有跳跃或者尖角,中心差分在两个方向取的斜率不一致,结果会偏离任何一侧的导数。所以在验证梯度时,尽量挑光滑区域,别在突变点附近做检查。
5.2 梯度消失与梯度爆炸的根源
深度网络训练里最常见的两种"梯度事故"是梯度消失和梯度爆炸。它们的根源都在链式法则:损失对浅层参数的导数,是沿途每一层导数的连乘。
连乘的后果很直接。如果每一层的导数都略大于 1,比如都是 1.5,十层下来是 1.5 的十次方,约等于 57,梯度被指数放大,参数一次更新就飞了,这就是梯度爆炸;如果每一层的导数都略小于 1,比如 0.5,十层下来是 0.5 的十次方,约等于千分之一,浅层几乎收不到梯度信号,前面几层根本学不动,这就是梯度消失。
缓解手段现在已经很成熟:激活函数别用 sigmoid 这种容易饱和的,多用 ReLU 族;初始化方式要匹配网络结构;加上批归一化层;梯度裁剪对付爆炸也很有效,直接在更新前把梯度模长限制到一个上限。这些都是工程手段,但理解背后的"连乘"逻辑才是关键。我是在手推了一个两层小网络的梯度之后才彻底明白的,建议你也亲手试一次。
5.3 梯度排查:四招定位问题
最后分享几个我在实际排查梯度问题时用的方法。
第一招:梯度检查。不管是你自己写的梯度函数,还是怀疑框架算错梯度,都用数值微分算一个参考值,跟目标梯度比较,相对误差在 1e-6 以下基本可以判定正确。注意检查时要把随机种子固定,关闭网络的随机性,否则前后两次前向结果不一致,数值参考也没意义。
第二招:用最小数据集过拟合单批。训练前,先只拿一条或几条样本,反复训练几十轮,看 loss 能不能降到接近 0。如果连单样本都过拟合不了,说明梯度或者模型结构有 bug。这时候别去调学习率,先把代码逻辑检查一遍。
第三招:把梯度打出来看数值分布。训练几轮后,打印各层梯度的均值、方差、最大值。如果某一层梯度全是 0,大概率是激活函数把输入压到饱和区了;如果梯度差异跨了好几个数量级,说明各层学习速度失衡,需要检查初始化或加归一化。
第四招:loss 曲线是最好的告警器。收敛正常时曲线平滑下降;出现锯齿状震荡,八成是学习率偏大;曲线在某一步陡然变成 NaN,先怀疑梯度爆炸或者数据里有异常值。我每次训练新模型,第一件事永远是画 loss 曲线,它比任何日志都能更快告诉你问题出在哪。
这篇关于导数与代码的笔记就先聊到这儿。最后说点个人体会:导数这个知识点,理论看着容易,真正理解难。我学的时候试过背公式、抄推导,效果都不如亲手写一个梯度下降来得深刻。建议你把文中的代码自己敲一遍,改一改学习率和更新方向,看看 loss 曲线的变化,机器学习里最核心的直觉就建立起来了。尤其是那个"反向更新导致 loss 暴涨"的对比实验,跑过一次之后,你对梯度下降、对导数在训练里的作用,会有完全不一样的体会。