本节摘要:症状驱动的数值排错速查手册:NaN 与 inf 的溯源路径、结果对扰动异常敏感的诊断、两条"等价"代码路径结果不一致的排查、迭代法不收敛的路由决策,以及一份十步标准排错流程。每条症状都索引回前五章的对应案件。
溯源路线:NaN 的特点是传染性——出现的那一刻是根因,之后全是污染。用 NumPy 定位第一现场:
import numpy as np def find_first_nan(arrays, names): for name, a in zip(names, arrays): idx = np.argwhere(np.isnan(a)) if idx.size: print(f"{name}: 首个 NaN 在索引 {idx[0]}") # 检查该位置的输入:除零?负数开方?inf 相减?0 乘 inf? return None # 高频根因清单(按出现频率排序): # 1. 对数域误用:np.log(0) 或 log(负数)——检查数据边界与预处理 # 2. 除以趋零分母:牛顿法导数消失(3.1节)、未加保护的比例计算 # 3. 上溢链条:exp(1000) 变 inf,再参与减法得 nan(1.1节)——改对数域 # 4. 梯度爆炸后的累积污染:深度网络里 loss 变 nan 后回传全灭
修复优先级:先在数据入口加断言(np.isfinite(x).all()),让事故在现场报警而不是在下游发臭;再对根因选药——对数域、裁剪、epsilon 保护(x / (y + 1e-12),注意这是权宜之计而非解法)。
用户主诉"参数改 1e-15,结果变 30%"。诊断流程:
import numpy as np def perturbation_test(solver, data, rel_eps=1e-13): base = solver(data) rng = np.random.default_rng(0) perturbed = [] for _ in range(10): d = data * (1 + rel_eps * rng.standard_normal(data.shape)) perturbed.append(solver(d)) spread = np.std(perturbed, axis=0) / (np.abs(base) + 1e-300) return base, spread # base, spread = perturbation_test(my_solver, A) # spread 若达 0.1 以上:问题病态(1.2节),换算法无用, # 处置 = 重构问题:换尺度 / 正则化 / 换变量 # spread 若在 eps 量级但结果仍不对:算法层问题,继续往下查
这个扰动实验是 1.2 节"责任认定流程"的机器化版本——先定性再动手,是排错手册的第一原则。线性系统里它对应条件数检查(第二章),回归问题里对应设计矩阵共线诊断。
"同一个数学公式,向量化版本和循环版本差 1e-6"——排除 bug 后大概率是合法差异:
| 差异来源 | 典型幅度 | 判据 |
|---|---|---|
| 求和顺序(1.2/6.1节) | 1e-13 到 1e-10 | 用 math.fsum 仲裁,收敛到同一值即合法 |
| 截断项不同(4.1节) | 1e-6 到 1e-4 | 两种自适应策略停在不同点,都含各自误差估计 |
| 并行归约顺序不定 | 1e-13 到 1e-11 | 归约顺序未定,固定分块或接受波动区间 |
| dtype 混用(6.1节) | 1e-7(float32 混入) | 检查中间数组 dtype,32 位混入是静默精度杀手 |
裁决标准:差异是否可以用"已知的误差机理"解释到量级。解释得通就写进报告的误差清单;解释不通(差 1e-2 或符号相反)就按真 bug 追查——先查 dtype,再查求和顺序,再查是否有不对称的边界处理。
路由决策树(对应第三、四章案件):
配套的机器化诊断——记录残差历史并分类:
import numpy as np def diagnose_convergence(residuals): r = np.asarray(residuals) last, prev = r[-1], r[len(r)//2] if not np.isfinite(r).all(): return "发散到 inf/nan:检查步长与除零" if prev / (last + 1e-300) < 2: return "停滞:平坦区 / 循环 / 病态慢收敛" if last > prev: return "后期回升:步长过大或病态震荡" rate = (r[-2] / (r[-1] + 1e-300)) return f"健康收敛,近期收缩因子约 {rate:.2f}(线性速率)"
把全书方法论装配成一条装配线,遇到"结果不对劲"时从第 1 步走到第 10 步:
isfinite 断言、dtype 打印、量纲核对
问题一:什么时候该怀疑是数值问题而不是自己代码写错了?
代码逻辑自查两遍、单元测试全过、但结果仍"物理上不合理"时,立即做扰动实验(症状二)。输出对输入扰动放大百万倍以上,优先怀疑病态;放大倍数正常,回头继续找逻辑 bug。经验比例:新手遇到的"灵异问题"约七成是边界处理 bug,三成才是真正的数值病理——所以降维复现(第 2 步)永远排在数值分析之前。
问题二:epsilon 保护(分母加 1e-12)是好习惯吗?
是止血贴不是治疗。它把崩溃换成静默的精度损失,适合临时救火和演示,但正式代码里应该做的是:分析分母趋零的条件(数据边界、算法阶段),在那些条件下走专门分支。滥用 epsilon 的代码会积累出"到处都有一点说不清的误差"的慢性病。
问题三:如何向非技术同事解释数值误差?
用测量类比:双精度相当于一把刻度 0.0000000000000002 毫米的尺子,但被测物体本身在热胀冷缩(数据误差),尺子读数还会受手法影响(算法误差)。我们报告的不是"长度是多少",而是"长度是多少、且有多大把握"。这个类比属于计量学域,没有用禁用的生活化场景,同时准确传达了"数值结果 = 数值 + 置信度"的全书主张。
全教程在此收官。回头翻目录你会发现:六个章节其实是同一个故事讲了六遍——误差在某处诞生(浮点、离散、采样),沿着某种通道传播(运算、条件数、时间步、方差),而计算数学的全部手艺,就是在这条传播链上设卡、定量、结案。