开始前,会这些就够了
- 知道预测误差等于预测减真实值
- 先读 导数与链式法则
- Python 3.10+;只使用标准库
有人说 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 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
误差导数被限住了,参数梯度还会变大吗?
正在准备实验控件…
单参数预测 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,没有下载数据。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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 的上界写成所有参数梯度的上界。
- 混用求和与均值,或者把裁剪挪过加法却不重新核对。
- 把公式实验称为已经验证真实训练稳定性。
自己试一小步
把输入放大后,怎样判断是哪一层放大了梯度?
- 把单样本的 x 改为 −40、y 改为 3、w 保持 0,分别记录残差导数和参数梯度。
- 将 δ 改为 0.5 与 2,先预测两种导数的变化,再运行验证。
- 对两条样本比较先平均后裁剪与先裁剪后平均,并写清每条表达式。
做到这些,就算完成
- 有限差分与解析导数在指定容差内一致。
- 报告明确求导变量、输入单位和 sum
/mean约定。 - 说明你的实验没有证明哪些真实训练性质。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- PyTorch 2.14:HuberLoss (新标签页)↗
分段损失公式、正阈值和求和/均值约定;参数梯度反例为本文自行推导。
- Optax:huber_loss (新标签页)↗
预测输入上的 Huber 损失及与 L2 导数裁剪的关系;继续传播到参数需链式法则。