qinghuan
← 回到知识探索

AI 直觉 / 动手实验

Huber 已经限制了梯度,为什么一个大输入仍能把更新放大?

把对误差的导数与对参数的梯度分开;用一条线性公式和两条样本,比较三个看似相同的裁剪位置。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 先说清楚:损失对谁求导?
  4. 经过模型这一层,仍要乘上输入
  5. 先限制误差贡献,与最后裁剪总梯度不是同一步
  6. 预测相同,参数坐标改变也会改变梯度数字
  7. 完整程序:核对公式、反例和拒绝输入
  8. 能保住什么,还需要哪些条件?
  9. 读文档时,把“梯度”后面的变量补出来
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
残差导数
改变预测误差一点时,损失随之变化的速度。
参数梯度
改变模型参数一点时,损失随之变化的方向与速度。
打开完整术语小抄 ↗

有人说 Huber 损失把梯度限制在正负 1 内,于是你放心地把学习率保持不变。但一条输入是 100 的样本,仍然给参数带来 100 的梯度。公式没有失效:被限制和被更新的,其实是两个不同的变量。

这次只研究一个参数 w、一个输入 x,预测写成 wx。我们手工求导并用有限差分复核,既不训练神经网络,也不把损失层的性质扩展成任意优化器的步长保证。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • demo.py:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B demo.py

解压后,在含 demo.py 的目录打开终端。命令分别回车执行。有限标量 Huber、链式法则、运算顺序与有限差分;没有训练模型或执行框架 API。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

误差导数被限住了,参数梯度还会变大吗?

正在准备实验控件…

先比较两个预设。它们预测和残差都相同,只有输入 x 不同;观察最后一项参数梯度。

预测 wx
0.00
残差 r
2.00
Huber 损失
1.50
残差导数 ∂L/∂r
1.00
参数梯度 ∂L/∂w
100.00

参数梯度 = 残差导数 × 输入 = 1.00 × 100 = 100.00。残差导数的绝对值不超过 1,当前输入下参数梯度的上界是 100.00。

同一个阈值,放到三个不同位置

固定 x=[100,1]、r=[2,−3],使用求和。下面只跟随阈值 δ 变化,不使用上面的单样本 x、w、y。

Huber:先限制残差导数
99.00
半平方损失原始总梯度
197.00
再裁剪总参数梯度
1.00
各样本参数贡献先裁剪再相加
0.00

单参数预测 wx,残差 wx−y;只计算解析公式,没有训练或执行优化器。两样本对照固定使用求和损失。δ 限制残差导数,参数梯度还要乘输入 x;浏览器未执行 PyTorch/Optax。

先说清楚:损失对谁求导?

记真实值为 y,预测为 wx,残差 r=wx−y。残差为正表示预测偏大,为负表示预测偏小。Huber 的阈值 δ 必须大于零,它把靠近零的误差用半平方惩罚,把远离零的误差用直线延伸。

精确定义是:|r|≤δ 时 L=0.5r²;|r|>δ 时 L=δ(|r|−0.5δ)。两段在 ±δ 处的值与一阶导数都接得上。对残差求导得到 ψ(r)=max(−δ,min(δ,r)),所以 |∂L/∂r|≤δ。这一句的变量是残差 r。

经过模型这一层,仍要乘上输入

要更新的是 w,因此还要沿着 r=wx−y 向前求导。链式法则给出 ∂L/∂w=ψ(r)×x。δ 限制了 ψ,却没有限制 x;固定 x 时上界是 δ|x|,让输入任意变大便没有统一的参数梯度上界。

设 w=0、y=−2、δ=1。输入 x=1 与 x=100 时,预测都为 0,残差都为 2,损失都为 1.5,残差导数都为 1;但参数梯度分别为 1 与 100。两条样本的损失一样大,不表示改变参数时影响一样大。

即使残差只有 0.5,输入为 100 时参数梯度仍为 50。大参数梯度不必来自大残差。这里展示的是输入通过模型导数放大的作用,并没有据此判断这条样本应被删除。

先限制误差贡献,与最后裁剪总梯度不是同一步

固定两个样本 x=[100,1]、r=[2,−3],使用求和损失。Huber 先把残差导数限制为 [1,−1],再乘输入并相加,参数总梯度为 100−1=99。

若先使用半平方损失,参数贡献为 [200,−3],合计 197;最后把这个一维总梯度裁剪到 [−1,1],得到 1。若改成先分别裁剪参数贡献再相加,则得到 1−1=0。99、1、0 对应三个不同的运算,不能因为都出现了 clip 就相互替换。

这些值固定采用 sum。若改为均值,应明确在哪一步除以样本数;尤其不能移动除法越过非线性的裁剪,再声称含义没变。本文不模拟动量、Adam 或权重衰减。

预测相同,参数坐标改变也会改变梯度数字

把输入乘 100,同时把参数除以 100,预测 wx 保持相同。例如 x=1,w=2 和 x′=100,w′=0.02,都预测 2,面对 y=0 的 Huber 损失也相同。

但导数的分母换成了不同参数坐标,∂L/∂w′ 是 ∂L/∂w 的 100 倍。使用相同数值学习率更新这两套参数,并不代表在函数空间走相同一步。因此看到梯度数值更大,先检查输入单位与参数化,而不是直接判定模型更不稳定。

完整程序:核对公式、反例和拒绝输入

下方是可单独保存为 demo.py 的全部代码。它打印四个单样本、三个运算顺序和坐标变化;每个单样本用中心有限差分复核参数导数。检查边界时直接核对连续的一阶导数,不把二阶导数也宣称连续。

运行结果来自本次 Python 3.14.0 真实执行;Python 3.10 仅通过 AST 语法兼容检查,没有运行该版本。代码没有导入 PyTorch 或 Optax,没有下载数据。

展开收起代码 · 小实验python · 90 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Finite scalar Huber examples; standard library only; no model training."""
import math

CHECKS = []

def finite(value):
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError("finite real number required")
    try:
        result = float(value)
    except OverflowError as exc:
        raise ValueError("number outside float range") from exc
    if not math.isfinite(result):
        raise ValueError("finite real number required")
    return result

def huber(residual, delta):
    r, d = finite(residual), finite(delta)
    if d <= 0:
        raise ValueError("delta must be positive")
    loss = 0.5 * r * r if abs(r) <= d else d * (abs(r) - 0.5 * d)
    derivative = max(-d, min(d, r))
    if not math.isfinite(loss):
        raise ValueError("loss outside finite range")
    return loss, derivative

def sample(x, y, w, delta):
    x, y, w = finite(x), finite(y), finite(w)
    residual = w * x - y
    loss, derivative = huber(residual, delta)
    gradient = x * derivative
    if not math.isfinite(gradient):
        raise ValueError("gradient outside finite range")
    return residual, loss, derivative, gradient

def check(name, ok):
    if not ok:
        raise AssertionError(name)
    CHECKS.append(name)

def close(a, b):
    return math.isclose(a, b, rel_tol=1e-7, abs_tol=1e-7)

def main():
    rows = [('small_input', 1., -2., 0., 1.),
            ('large_input', 100., -2., 0., 1.),
            ('small_residual_large_input', 100., -0.5, 0., 1.),
            ('negative_input', -100., -2., 0., 1.)]
    for name, x, y, w, delta in rows:
        r, loss, derivative, gradient = sample(x, y, w, delta)
        h = 1e-6
        numeric = (sample(x, y, w+h, delta)[1] - sample(x, y, w-h, delta)[1]) / (2*h)
        check(name + ' finite difference', close(gradient, numeric))
        check(name + ' residual derivative bound', abs(derivative) <= delta)
        print(f'{name}: r={r:.2f}; loss={loss:.2f}; dL_dr={derivative:.2f}; dL_dw={gradient:.2f}')

    # Same two samples and sum reduction, but three different operation orders.
    xs, residuals, delta = [100., 1.], [2., -3.], 1.
    huber_sum = sum(x*huber(r, delta)[1] for x, r in zip(xs, residuals))
    l2_sum = sum(x*r for x, r in zip(xs, residuals))
    clip_after_sum = max(-delta, min(delta, l2_sum))
    clip_each_parameter = sum(max(-delta, min(delta, x*r)) for x, r in zip(xs, residuals))
    check('three operations differ', (huber_sum, clip_after_sum, clip_each_parameter) == (99., 1., 0.))
    print(f'two_samples_sum: huber={huber_sum:.2f}; half_squared={l2_sum:.2f}; clip_total={clip_after_sum:.2f}; clip_each_parameter={clip_each_parameter:.2f}')

    for d in [0.5, 1., 2.]:
        for r in [-4., -2., -0.5, 0., 0.5, 2., 4.]:
            loss, derivative = huber(r, d)
            check('even loss', close(loss, huber(-r, d)[0]))
            check('odd derivative', close(derivative, -huber(-r, d)[1]))
            check('residual derivative bounded', abs(derivative) <= d)
        check('boundary loss', close(huber(d, d)[0], 0.5*d*d))
        check('boundary derivative', close(huber(-d, d)[1], -d))
    # Coordinate change: x2=100*x, w2=w/100 gives the same prediction.
    first, second = sample(1., 0., 2., 1.), sample(100., 0., 0.02, 1.)
    check('same prediction and loss', first[:3] == second[:3])
    check('parameter-coordinate derivative changes', close(second[3], 100*first[3]))
    print(f'same_prediction_new_coordinate: loss={first[1]:.2f}; gradient_before={first[3]:.2f}; gradient_after={second[3]:.2f}')
    invalid = [(0., 0.), (0., -1.), (float('nan'), 1.), (1., float('inf')), (True, 1.), ('2', 1.), (10**1000, 1.)]
    for args in invalid:
        try:
            huber(*args)
        except ValueError:
            check('reject invalid Huber input', True)
        else:
            raise AssertionError('invalid input accepted')
    print(f'PASS checks={len(CHECKS)}; reduction=sum; reference=chain_rule+finite_difference')

if __name__ == '__main__':
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-24 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。有限标量 Huber、链式法则、运算顺序与有限差分;没有训练模型或执行框架 API。

运行后对照结果
small_input: r=2.00; loss=1.50; dL_dr=1.00; dL_dw=1.00
large_input: r=2.00; loss=1.50; dL_dr=1.00; dL_dw=100.00
small_residual_large_input: r=0.50; loss=0.12; dL_dr=0.50; dL_dw=50.00
negative_input: r=2.00; loss=1.50; dL_dr=1.00; dL_dw=-100.00
two_samples_sum: huber=99.00; half_squared=197.00; clip_total=1.00; clip_each_parameter=0.00
same_prediction_new_coordinate: loss=1.50; gradient_before=1.00; gradient_after=100.00
PASS checks=87; reduction=sum; reference=chain_rule+finite_difference

能保住什么,还需要哪些条件?

对一般标量预测 fθ(x),链式法则变成 ∇θL=ψ(r)∇θfθ(x)。Huber 限制第一项;要给参数梯度上界,还得限制第二项。向量预测还涉及各输出的导数汇总,不能直接照搬一维阈值。

Huber 可以减轻大残差相对于平方损失的影响,但这个机制不等于抵抗所有异常输入。若关注最终参数步长,应检查完整模型导数、梯度聚合、优化器状态和学习率。真实训练该用多大 δ,需要按目标单位、数据分布和验证方案选择,本算例没有给出通用推荐值。

读文档时,把“梯度”后面的变量补出来

PyTorch HuberLoss 文档支持分段定义、δ>0 和 reduction 约定;本篇从这个损失公式自行推导链式法则反例。Optax 页面讨论 Huber 与裁剪 L2 梯度的关系时,输入接口是 predictions。沿预测继续传播到模型参数,还要经过模型导数。

我们实际阅读了上述两个官方页面的函数定义与说明,未执行其框架 API,也未复现真实训练。站内已有 梯度裁剪课程 讨论参数梯度的整体范数;本篇新增的是“损失层残差导数受限,为什么仍不能替代参数梯度裁剪”的具体反例。

记住这三点

  • Huber 限制的是对残差的导数;参数梯度还要乘模型对参数的导数。
  • 逐残差限制、逐样本参数贡献裁剪、总参数梯度裁剪是不同操作。
  • 相同预测和损失,不保证不同参数坐标下的梯度数值或更新效果相同。

别把例子用过头

  • 把 ∂L/∂r 的上界写成所有参数梯度的上界。
  • 混用求和与均值,或者把裁剪挪过加法却不重新核对。
  • 把公式实验称为已经验证真实训练稳定性。

自己试一小步

把输入放大后,怎样判断是哪一层放大了梯度?

  1. 把单样本的 x 改为 −40、y 改为 3、w 保持 0,分别记录残差导数和参数梯度。
  2. 将 δ 改为 0.5 与 2,先预测两种导数的变化,再运行验证。
  3. 对两条样本比较先平均后裁剪与先裁剪后平均,并写清每条表达式。

做到这些,就算完成

  • 有限差分与解析导数在指定容差内一致。
  • 报告明确求导变量、输入单位和 sum/mean 约定。
  • 说明你的实验没有证明哪些真实训练性质。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • PyTorch 2.14:HuberLoss (新标签页)↗PyTorch · 英文

    分段损失公式、正阈值和求和/均值约定;参数梯度反例为本文自行推导。

    本轮打开并阅读函数定义、公式与参数说明;未运行该框架。

  • Optax:huber_loss (新标签页)↗Google DeepMind / Optax · 英文

    预测输入上的 Huber 损失及与 L2 导数裁剪的关系;继续传播到参数需链式法则。

    本轮读取函数签名及关于梯度的说明;未运行 JAX/Optax。