写一个阻抗谱等效电路拟合器,撞到八个问题。没有一个会抛异常,没有一个会打印警告。它们全都产出了看起来完全合理的输出——最严重的那个,是拟合函数安静地把你的初始猜测原样还给你,而界面上一切正常。这篇讲的不是拟合,是数值代码怎么在不报错的情况下骗你。
一、最贵的那个:符号写反,函数返回初始猜测
Levenberg–Marquardt 的一步,在残差定义为 r = y − f(x)、雅可比为 J = ∂r/∂x 时,下降方向是:
Δx = −(JᵀJ + λD)⁻¹ Jᵀr
我写成了:
x += (JᵀJ + λD)⁻¹ Jᵀr // 少了一个负号
少一个负号意味着每一步都在往上坡走。LM 的内层循环会试探性地增大阻尼 λ 再重算,只要新残差不比旧的小就拒绝这一步。往上坡走的结果是:12 次 λ 尝试全部被拒,循环退出,iter = 0,函数返回它进来时拿到的那个 x。
而 x 进来时是初始猜测。
于是界面上你会看到:一条拟合曲线(用初始猜测算的,形状大致对)、一组参数(就是你自己填的初值)、一个 chi² 值(有限、不离谱)。没有任何一处提示出错。如果初始猜测给得还行,这个”结果”甚至看着挺不错。
发现它的唯一途径是拿合成数据去打:用已知的一组参数正演出一条曲线,喂进拟合器,看它能不能把那组参数还原出来。还不出来,就是坏的。
任何拟合器、优化器、求解器都必须有一个”合成数据 → 已知答案”的自检,而且要在每次改动数值核心之后跑。这不是测试覆盖率的问题——是这类代码失败时不会告诉你,你只能主动去问。
二、参数沿山谷跑到 1e154,而残差纹丝不动
ZARC 元件(一个电阻并联一个常相位元件)的阻抗是:
Z = R / (1 + R·Q·(jω)ⁿ)
把 R 推向无穷,同时保持乘积 R·Q 不变,这个表达式退化成纯 CPE:Z → 1/(Q(jω)ⁿ)。
意思是:在 R·Q 为常数的那条曲线上,R 和 Q 可以一起跑向无穷而阻抗几乎不变。这是一条平坦的山谷,残差在谷底任何位置都一样。优化器没有理由停在某一点,它会一直滑。
不加边界的时候,40 组噪声实现里出现过 R1 ≈ 1e154——而它的 chi² 是正常的。拟合”成功”了,参数是垃圾。
处理办法不是加大迭代次数或换优化器(山谷是模型本身的性质,换谁都一样),而是两步:
- 给每个参数物理边界(电阻不可能是 1e154 欧姆);
- 撞到边界时标记为”未识别”,而不是把那个数打印出来。
第二步是关键。撞边界的意思是”数据不足以确定这个参数”,此时边界值只是优化器停下的地方,没有物理含义。打印它等于把一个任意数字包装成测量结果。
三、守卫误报会耗尽它自己的可信度
加完边界之后我又加了一个可靠性守卫,判据是”最后一次迭代的相对改善 < 1e-12 才算收敛”。听起来很严谨。
结果它把 60 组里的 4 组完全正常的拟合标成了”不可靠”——那 4 组的 chi²_red 和成功的那些是同一个量级。
原因是我误解了 LM 的终止条件。LM 的正常终止方式就是”再也找不到能让残差下降的步长”——阻尼加到某个程度还是没有改善,于是退出。这时”最后一次相对改善”可能根本就没有,或者是上一次成功步的值。拿它当收敛判据,会把正常收敛判成失败。
这个错误的代价不在那 4 组本身,而在于:一个经常误报的警告,用户很快就会学会忽略它。而一旦养成忽略的习惯,真正该报警的那次也会被忽略掉。守卫的价值完全建立在它很少说谎上,误报是在消耗这个存量。
宁可少报,也不要多报——漏掉的那次至少不会训练用户无视你。
四、权重不是设置,是结果的一部分
拟合阻抗谱时要选残差权重。常见的两种:单位权重(每个点等权)和模值权重(按 |Z| 归一)。阻抗的模值在一条谱里能跨好几个数量级,所以这个选择不是细节。
拿同一套合成数据跑 60 组独立噪声实现,看 R0 的还原误差:
| 权重 | R0 相对误差 | 撞边界的次数 |
|---|---|---|
| 模值权重 | −0.04% ± 0.19% | 0 / 60 |
| 单位权重 | −10.03% ± 30.37% | 6 / 60(R1) |
系统偏差差 250 倍,离散度差 160 倍。换一个下拉框的值,同一份数据能给出两个结论。
所以这个工具里权重是一个显式控件,并且跟结果一起打印出来。把它藏在默认值后面,等于让读者拿到一个没法复现的数字。
五、剩下四个:都在显示层,都不报错
Nyquist 图必须两轴同比例
看 Nyquist 图的目的之一,就是判断那段弧是标准半圆还是被压扁的。而在自由长宽比下,压扁的弧和标准半圆看起来一模一样——你正好看不出你要看的那件事。两轴必须锁同一个标度。
刻度不能用累加生成
// 坏:浮点误差累积
for (let v = lo; v <= hi; v += step) ticks.push(v);
// 好:整数倍
for (let k = 0; k <= n; k++) ticks.push(lo + k * step);
累加的后果是本该为 0 的刻度变成 1.7e-18。单看这个值无所谓,但接下来 SI 前缀格式化会把它渲染成 0.000001735 p——一个长到把整个坐标轴撑出 viewBox 的字符串。一个 1e-18 的数值误差,表现为图裂了。
无量纲量不能套 SI 前缀
CPE 指数 n 是无量纲的,典型值 0.86。格式化函数不加区分地套 SI 前缀,显示成 "859.6 m"——毫,作为 0.8596 的前缀,在数学上完全正确,在物理上毫无意义。格式化函数需要知道哪些量有单位。
测试环境自己会骗你
调试时有两次我确信"改动没生效",两次都是环境问题:
- headless Chrome 复用
--user-data-dir时,会从磁盘缓存拿旧页面; - 页面 URL 不带缓存绕过参数时拿到的是 CDN 边缘缓存——而且要注意参数名本身可能被缓存规则忽略,我用过一个不起作用的参数名,白白误判了一次。
验证"改动是否生效"之前,先验证"你看到的是不是新版本"。
六、这八个的共同点
把它们排在一起,模式很清楚:
| 问题 | 它表现成什么 |
|---|---|
| LM 符号写反 | 一次成功的拟合 |
| R–Q 退化 | 一个正常的 chi² |
| 收敛判据错 | 一条可靠性警告 |
| 权重选错 | 一组精确到小数点后两位的参数 |
| 长宽比自由 | 一张漂亮的图 |
| 刻度累加 | 一个被裁掉的坐标轴 |
| SI 前缀滥用 | "859.6 m" |
| 缓存没绕过 | "我的修改没生效" |
没有一个抛异常。数值代码的失败模式几乎都是这样:它不会崩,它会给你一个数。而一个数在屏幕上的样子,跟它是对是错无关。
能对抗这个的只有一件事——准备一份你知道正确答案的输入,并且每次改动数值核心之后都跑一遍。本文里前四个问题全部是靠合成数据发现的,没有一个是靠读代码发现的。
References
- Marquardt (1963), An Algorithm for Least-Squares Estimation of Nonlinear Parameters
- Boukamp (1986), A nonlinear least squares fit procedure for analysis of immittance data
- impedance.py 文档:等效电路拟合与权重选择
相关阅读:EIS 等效电路拟合器(本文说的那个工具)、参数拟合与可辨识性("拟合收敛了"为什么不等于"参数是对的")、HPPC 脉冲内阻分析器 与 dQ/dV 增量容量分析器(另外两种表征手段)。
