开始前,会这些就够了
- 知道 softmax 把分数转成概率
- 知道导数描述微小变化
- 能读列表、函数与有限差分
- 需要补基础时:先补 Softmax 与分类损失
- 需要补基础时:再补导数与链式法则
三个选项的代价分别是 1、3、5。模型这次只选第一个,实际付出的代价就是 1。如果把第一项分数微调一点,选择仍然不变,代价也不变。按普通微积分,这一小段里的导数是零。
训练时又可能希望分数得到调整信号。直通估计会保留前向的硬选择,同时为反向另定一条可用的计算规则。理解它的关键,是把实际输出与用于更新参数的规则分开记录,而不是把硬选择误认为突然变得光滑。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- ste_surrogate.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B ste_surrogate.py解压到独立练习文件夹,在含 ste_surrogate.py 的目录打开终端运行。固定分数与固定噪声的标准库替代梯度、非线性上游导数和有限差分;未随机估计期望、运行框架接口或训练模型。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先算清楚这次到底选了谁
给三个选项分数 z=[ln2,0,0],暂时不加入随机变化。温度 τ=1 时,指数比例为 2:1:1,softmax 得到 p=[0.5,0.25,0.25]。取最大分数只选第一项,写成 one-hot 向量 h=[1,0,0];one-hot 就是只有一处为 1 的选择记录。
把代价 c=[1,3,5] 与向量相乘后相加:硬选择的损失 L(h)=1;软向量的损失 L(p)=0.5×1+0.25×3+0.25×5=2.5。这是两个不同的前向结果,不能因为反向借用了 p,就声称本次实际代价是 2.5。
程序对分数分别加减 0.000001,再计算硬选择损失。所有选择仍是第一项,因此三项中心有限差分都为 0。这里分数最大值是唯一的,扰动没有跨过选择边界。
反向借的是软函数的斜率
把 softmax 当作反向近似时,线性代价对应的替代梯度为 gᵢ=pᵢ(cᵢ−Σⱼpⱼcⱼ)/τ。先算平均代价 2.5,再分别计算 0.5×(1−2.5)、0.25×(3−2.5)、0.25×(5−2.5),得到 [−0.75,0.125,0.625]。
普通梯度下降会减去学习率乘梯度,因此负的第一项会把第一项分数往上推。这个方向来自借用的软函数,不是硬 argmax 的真实局部导数。原始直通方法可以直接假装阈值是恒等函数;本文采用的是 softmax 斜率这一种规则,二者不能混为同一条导数公式。
框架常把这个约定写成 y=h−stopgrad(p)+p。stopgrad 保留 p 的数值,却停止沿这一项回传梯度;前向数值仍是 h,反向只经过最后的 p。PyTorch 文档使用 detach 表达这个操作。标准库程序不实现自动微分,而是明确写出这条人为规定的反向算式。
损失不是线性时,不能整条换成软前向
把代价再平方:L(y)=(c·y)²。硬前向给 c·h=1,所以损失对这个标量的导数是 2。直通反向先取这个真实前向位置的上游导数,再乘 softmax 斜率,得到 [−1.5,0.25,1.25]。
如果连前向也改成 p,c·p=2.5,上游导数会变成 5,整个软损失的梯度则是 [−3.75,0.625,3.125]。这两组数字不相同。因此“硬前向、软反向”不等于偷偷把整个目标函数换成一个纯软目标。
程序用有限差分核对线性软损失的解析梯度,最大绝对差小于 1e−8;另外计算平方软损失的有限差分,和平方损失的直通结果作对照。硬线性损失的有限差分为零。这里不同检查各自回答不同问题:软算式是否求导正确,以及硬输出对小扰动是否真的改变。直通值与硬有限差分不同,在这个有意指定的规则里是预期现象。
随机选择与温度,是另外两项约定
Gumbel-Max 在每个 logit 上加入独立噪声 g,再取最大项;一种采样方式是先取 u∈(0,1),再算 g=−ln(−lnu)。Gumbel-Softmax 用 softmax((z+g)/τ) 作连续近似,直通版本再把前向离散化。这里 z 是未归一化的对数分数,整体加一个常数不改变选择。
本例固定 g=[0,0,0],只隔离反向规则,没有进行随机采样,也没有估计随机目标的期望梯度。温度取 0.5、1、2 时,软向量与替代梯度改变,但对同一组 z+g 和正温度,硬选择保持不变。温度调低不保证每一项梯度单调增大,因为概率也随之变化。
直通类方法通常是有偏的梯度估计:不能保证反复采样后平均值恰好等于真实期望目标的导数。本例更直接展示固定噪声下的局部导数不一致;它没有测量统计偏差的数值,也没有证明这条更新总能降低硬目标。
同时保留硬输出、替代梯度和参照差分
保存为 ste_surrogate.py,运行 python -B ste_surrogate.py。程序打印两种前向损失、线性与平方损失的替代梯度、软硬有限差分和三个温度的结果,并写入 report.json。
额外的分数并列例子会得到量级为一百万的中心差分:极小扰动使选择从一项跳到另一项。它说明边界处不该把有限差分当成稳定导数参照。程序采用并列时选最前项的规则,并拒绝零、负数和 NaN 温度。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Fixed-score straight-through arithmetic. Python standard library only."""
import json
import math
from pathlib import Path
def softmax(scores, temperature):
if not scores or not math.isfinite(temperature) or temperature <= 0:
raise ValueError('positive finite temperature and nonempty scores required')
if not all(math.isfinite(x) for x in scores):
raise ValueError('scores must be finite')
maximum = max(scores)
weights = [math.exp((x - maximum) / temperature) for x in scores]
total = sum(weights)
return [x / total for x in weights]
def dot(a, b):
if len(a) != len(b):
raise ValueError('different lengths')
return sum(x * y for x, y in zip(a, b))
def one_hot(scores):
chosen = max(range(len(scores)), key=scores.__getitem__)
return [float(i == chosen) for i in range(len(scores))]
def surrogate_gradient(scores, costs, temperature, squared=False):
probabilities = softmax(scores, temperature)
average = dot(probabilities, costs)
# This is an intentionally prescribed backward rule, not d(argmax)/dz.
multiplier = 2 * dot(one_hot(scores), costs) if squared else 1.0
return [multiplier * p * (cost - average) / temperature
for p, cost in zip(probabilities, costs)]
def finite_difference(function, values, step=1e-6):
result = []
for index in range(len(values)):
plus, minus = list(values), list(values)
plus[index] += step
minus[index] -= step
result.append((function(plus) - function(minus)) / (2 * step))
return result
def main():
# Fixed noise g=(0,0,0): this isolates the backward rule, not sampling.
logits, noise, costs = [math.log(2), 0.0, 0.0], [0.0] * 3, [1.0, 3.0, 5.0]
scores = [z + g for z, g in zip(logits, noise)]
probabilities = softmax(scores, 1.0)
hard = one_hot(scores)
linear_grad = surrogate_gradient(scores, costs, 1.0)
hard_fd = finite_difference(lambda z: dot(one_hot(z), costs), scores)
soft_fd = finite_difference(lambda z: dot(softmax(z, 1.0), costs), scores)
squared_st = surrogate_gradient(scores, costs, 1.0, squared=True)
squared_soft_fd = finite_difference(lambda z: dot(softmax(z, 1.0), costs) ** 2, scores)
assert probabilities == [0.5, 0.25, 0.25]
assert hard == [1.0, 0.0, 0.0] and hard_fd == [0.0] * 3
assert max(abs(a-b) for a,b in zip(linear_grad, soft_fd)) < 1e-8
assert linear_grad == [-0.75, 0.125, 0.625]
assert squared_st == [-1.5, 0.25, 1.25]
assert max(abs(a-b) for a,b in zip(squared_st, squared_soft_fd)) > 1
temperatures = []
for temperature in [0.5, 1.0, 2.0]:
p = softmax(scores, temperature)
gradient = surrogate_gradient(scores, costs, temperature)
assert abs(sum(p) - 1) < 1e-12 and abs(sum(gradient)) < 1e-12
assert one_hot(p) == hard
temperatures.append({'temperature':temperature, 'soft':p, 'gradient':gradient})
# A numerical derivative at a decision boundary is not a useful reference.
tie = [0.0, 0.0, -2.0]
tie_fd = finite_difference(lambda z: dot(one_hot(z), costs), tie)
assert abs(tie_fd[0]) > 100000
rejected = []
for value in [0, -1, float('nan')]:
try:
softmax(scores, value)
except ValueError:
rejected.append(str(value))
assert len(rejected) == 3
report = {'scope':'fixed-noise arithmetic; no sampling expectation or model training',
'logits':logits,'fixed_noise':noise,'costs':costs,'soft':probabilities,'hard':hard,
'hard_linear_loss':dot(hard,costs),'soft_linear_loss':dot(probabilities,costs),
'surrogate_linear_gradient':linear_grad,'hard_finite_difference':hard_fd,
'soft_finite_difference':soft_fd,'squared_st_gradient':squared_st,
'squared_soft_finite_difference':squared_soft_fd,'temperatures':temperatures,
'tie_finite_difference':tie_fd,'rejected_temperatures':rejected,'checks_passed':True}
Path('report.json').write_text(json.dumps(report, indent=2)+'\n',encoding='utf-8')
print('soft =', probabilities, 'hard =', hard)
print('linear loss: hard=1.000000 soft=2.500000')
print('ST linear gradient =', linear_grad)
print('hard finite difference =', hard_fd)
print('soft finite difference =', [round(x, 6) for x in soft_fd])
print('squared loss ST gradient =', squared_st)
print('entirely soft squared gradient =', [round(x, 6) for x in squared_soft_fd])
for row in temperatures:
print('tau={:.1f} soft={} gradient={}'.format(row['temperature'],
[round(x, 6) for x in row['soft']], [round(x, 6) for x in row['gradient']]))
print('decision-boundary finite difference =', [round(x, 6) for x in tie_fd])
print('checks=PASS; report.json written')
if __name__ == '__main__':
main()
怎样运行:在独立练习文件夹中执行 python -B ste_surrogate.py。2026-10-17 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。固定分数与固定噪声的标准库替代梯度、非线性上游导数和有限差分;未随机估计期望、运行框架接口或训练模型。
运行后对照结果
soft = [0.5, 0.25, 0.25] hard = [1.0, 0.0, 0.0]
linear loss: hard=1.000000 soft=2.500000
ST linear gradient = [-0.75, 0.125, 0.625]
hard finite difference = [0.0, 0.0, 0.0]
soft finite difference = [-0.75, 0.125, 0.625]
squared loss ST gradient = [-1.5, 0.25, 1.25]
entirely soft squared gradient = [-3.75, 0.625, 3.125]
tau=0.5 soft=[0.666667, 0.166667, 0.166667] gradient=[-1.333333, 0.333333, 1.0]
tau=1.0 soft=[0.5, 0.25, 0.25] gradient=[-0.75, 0.125, 0.625]
tau=2.0 soft=[0.414214, 0.292893, 0.292893] gradient=[-0.363961, 0.035534, 0.328427]
decision-boundary finite difference = [-1000000.0, 1000000.0, 0.0]
checks=PASS; report.json written接入训练前,写清前向与反向各算什么
检查表应包含硬选择规则、噪声分布、温度、替代梯度形式及损失在哪个前向值上求导。只看到 loss.backward 能运行,并不足以说明它在求硬操作的普通导数。参数更新后还应另外观察真实任务损失和离散决策是否改善。
本次只运行标准库的固定分数算术与有限差分,没有训练门控网络、抽样估计期望、调用 Gumbel-Softmax 框架接口或测试任务质量。论文与框架文档用于核对定义;它们的训练结果不是本文实验结果。
记住这些要点
- 硬选择在不跨边界的小区间内可以完全不变。
- 直通估计保留硬前向,为反向另外规定可用斜率。
- 非线性损失的上游导数仍取决于实际硬前向。
- 固定噪声的算术检查不等于证明随机梯度估计无偏。
别把例子用过头
- 把替代梯度写成 argmax 的真实导数。
- 只核对纯软损失,就宣称整个直通反向完全相同。
- 每次有限差分都重新抽噪声,混入另一种变化。
- 在并列决策边界用极小步长解释普通导数。
自己试一小步
把代价换为 [4,1,2],直通更新多大才会真的改变硬选择?
- 另存副本并保留原 main 的基准检查,为 [4,1,2] 另外增加一组实验;保留固定分数与固定噪声,先手算新代价的线性替代梯度,不直接沿用原来的固定断言和打印值。
- 对多种学习率执行一次更新,分别记录分数、硬选择与硬损失。
- 把损失改为平方,再比较直通更新和完全软前向的更新。
- 在不跨选择边界与跨边界两种情况下分别做有限差分。
做到这些,就算完成
- 分别报告更新信号和实际硬目标变化。
- 有限差分两侧使用同一份固定噪声。
- 解释不能从一个样本推断随机估计的无偏性。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Estimating or Propagating Gradients Through Stochastic Neurons for Conditional Computation (新标签页)↗
直通规则与有偏估计的定义;恒等近似和本文 softmax 近似需区分。
- Categorical Reparameterization with Gumbel-Softmax (新标签页)↗
Gumbel 噪声、温度和离散前向搭配连续反向的定义。
- PyTorch 2.9:gumbel_softmax (新标签页)↗
hard 参数以及
y_hard−y_soft.detach()+y_soft的前后向约定。