
1. 从一次调参翻车说起TolX到底管什么大概半年前有个做信号处理的朋友发了个MATLAB脚本给我看说用fminsearch拟合一个三参数指数衰减模型迭代日志显示TolX已经到1e-12了可拟合出来的曲线和原始数据还是对不上他怀疑是算法不行想换ga遗传算法。我扫了一眼他的options结构体TolX设了1e-12TolFun设了1e-12MaxIter默认MaxFunEvals默认函数本身倒没什么问题。我让他把TolX调回1e-6TolFun也调回1e-6重新跑一次五分钟之后他发消息说好了曲线贴上了但为什么容差设得松反而更准这个问题其实特别典型。很多人把fminsearch当成一个黑箱TolX、TolFun、MaxIter这些选项随便填以为容差设得越小结果越精确。但Nelder-Mead算法根本不吃这一套——它是一个基于单纯形几何操作的直接搜索算法没有用到任何导数信息它的收敛判据和基于梯度的算法完全不一样。你用错判据、选错尺度算法就会在错误的地方停下来甚至停在一个根本不是极小值的位置。所以这篇东西我想把TolX和Nelder-Mead算法放在一起讲透。包括算法是怎么迭代的TolX和TolFun在停止判据中各扮演什么角色STEP1那类常见误配置为什么会翻车以及在MATLAB里到底怎么排查这类问题。2. Nelder-Mead迭代过程的几何直觉2.1 单纯形在N维空间里的爬行Nelder-Mead算法维护的不是一个点而是一整个单纯形——在N维空间里就是N1个顶点构成的几何体。二维问题时是三角形三维问题时是四面体。每次迭代做的事情就是对这个单纯形做反射、扩展、收缩、缩边这四种几何操作让整个单纯形自己爬向函数值更小的区域。我先用一个二维Rosenbrock函数来演示因为它的函数值在谷底呈香蕉形弯曲最适合观察单纯形的行为。初始化时fminsearch会基于初始点x0自动生成三角形具体规则是每个维度偏移0.05倍的点作为第二个顶点偏移0.05倍且加上0.00025的点作为第三个顶点。这个初始单纯形如果落在函数值非常平坦的区域算法的前几步会走得很慢。代码可以这样写fun (x) 100*(x(2)-x(1)^2)^2 (1-x(1))^2; x0 [-1.2, 1]; options optimset(Display,iter, TolX, 1e-6, TolFun, 1e-6); [x, fval, exitflag, output] fminsearch(fun, x0, options);你可以看到迭代日志里x(1)和x(2)一开始变化非常剧烈那是因为反射操作经常把单纯形弹到远处等接近谷底后收缩操作开始占主导每步的变化量越来越小。这时候如果你把TolX设成1e-12算法不会收敛反而会在谷底来回震荡因为数值舍入误差已经开始影响单纯形的几何判定。2.2 四种几何操作的含义和代价反射操作是最激进的——沿着最差顶点与形心连线的反方向找一个新点如果反射点比当前最好点还要好就尝试扩展让单纯形大步跳向更优区域。收缩操作则是在反射失败后把单纯形往形心方向缩回来如果收缩也失败就把整个单纯形向当前最好顶点缩边。这四种操作的本质差异在于计算代价和探索幅度反射只算1个新点的函数值最便宜扩展在反射基础上多算1个新点收缩同样算1个新点但几何幅度小缩边最贵要重新计算N个顶点的函数值所以一个迭代次数特别多但函数评估次数也特别多的运行通常意味着缩边操作频繁发生——这表明单纯形在某个方向上反复被压扁算法实际上已经接近能到达的区域边界了。这种情况下你继续收紧TolX单纯形只会被压成一条线或一个点完全失去几何意义。3. TolX和TolFun在停止判据里是怎么分工的3.1 停止判据的官方逻辑和实际行为fminsearch默认的停止判据是同时满足以下两个条件才停止TolX当前单纯形各顶点的坐标差异小于设定值TolFun当前单纯形各顶点的函数值差异小于设定值注意这个同时满足非常关键。单纯形各顶点在空间上已经挤成一团满足TolX但函数值差异很大——比如在一个陡峭的斜坡上——算法不会停。反过来各顶点的函数值很接近满足TolFun但顶点在空间上分布很开——比如在一个极平坦的高原上——算法也不会停。这就解释了为什么你同时设TolX1e-12和TolFun1e-12时经常会出现迭代早就应该收敛了却还在跑的现象。以TolFun1e-12为例要求所有顶点的函数值差异小到万亿分之一这在很多工程目标函数上根本不现实。3.2 TolX作为收敛判据和作为几何门槛的双重身份我倾向于把TolX理解为两个身份这样很多问题就清晰了第一个身份是收敛判据——当所有顶点的坐标都挤在TolX范围内时说明单纯形在空间上已经缩小到足够小继续迭代找到更好点的概率很低。这个角度下TolX的设定要和你对参数精度的实际需求匹配。第二个身份是几何门槛——单纯形各顶点之间的最小间距就是TolX级别的。如果TolX太小单纯形会被压缩到数值精度的极限导致下一步操作中的几何计算出现精度问题。MATLAB的double浮点数精度是eps大约2.2204e-16你设TolX1e-12时单纯形的边长可能收缩到1e-8量级这没问题但如果你的参数本身量级在1e-3单纯形边长收缩到1e-9就已经接近浮点精度能可靠分辨的极限了。一个实用的判断标准是TolX不应该比sqrt(eps) * max(abs(x0))小太多。sqrt(eps)大约是1.49e-8对于量级为1的初始值TolX设在1e-7到1e-8之间已经是极限了再往下设没什么实际意义。4. 常见误配置的完整排查链路4.1 现象拟合曲线系统性偏移我朋友的案例里拟合结果和原始数据的偏差不是随机噪声而是系统性偏移——曲线的上升段拟合上了衰减段慢半拍参数和真实值的偏差大概有2%到3%。他给的配置是options optimset(Display,iter, TolX, 1e-12, TolFun, 1e-12, MaxFunEvals, 1e6);这个配置有两个问题TolX和TolFun都设成1e-12MaxFunEvals设成1e6。照理说容差设得这么严算法应该跑得非常久才对。但实际上由于目标函数对某些参数方向不敏感单纯形在某个方向上被压得极扁顶点的坐标差很快就小于1e-12了——也就是说TolX条件实际上在迭代早期就满足了算法被迫在参数空间的一个峡谷里停止了探索。4.2 逐步排查过程我建议他用以下步骤排查每步都能看到具体现象第一步打印output结构体看实际迭代了多少次、函数评估了多少次、退出标志是什么disp(output);结果显示iterations只有区区的47次funcCount是185次exitflag是1。对于一个三参数拟合问题185次函数评估其实不算多但关键是——47次迭代就满足了TolX和TolFun说明算法速度很快但这个快很可疑。第二步手动把TolFun改回1e-6只保留TolX1e-6重新运行options optimset(Display,iter, TolX, 1e-6, TolFun, 1e-6);结果拟合误差直接下降了一个数量级曲线贴合了。这是一个很典型的信号——当算法在平坦区域运行时TolFun设得越严越容易被数值噪声卡住而普通的1e-6反而让算法有空间继续探索。第三步把TolX和TolFun一起放宽到1e-4结果拟合误差没有进一步变化。这说明问题不是容差太宽导致的而是容差太严导致的——单纯形因为提前满足了几何判据停止迭代然后返回了一个还没完全收敛的结果。4.3 根因数值梯度和几何形态的错位这个案例的根因在于目标函数对三个参数的敏感度差异很大。比如指数衰减模型A*exp(-t/tau)C中C这个基线参数对结果的影响非常小单纯形在这个方向上的伸展基本不会改变函数值——于是TolFun条件很难满足。但同时由于C方向几乎是一个平面单纯形顶点在这个方向上的坐标差很容易小于TolX。结果就是TolX条件秒满足TolFun条件迟迟不满足算法状态很纠结。再加上TolFun1e-12让函数值差异条件形同虚设不可能达到所以算法真正依赖的其实只有TolX。等到TolX在某个方向上满足之后单纯形停止收缩的同时另一个仍然敏感的参数方向也没来得及探索到最佳位置拟合结果就偏了。这类问题用一句话概括就是你把容差设到了一个远超你实际需求的精度反而让算法在一个不合适的几何形态下提前终止了。5. 参数尺度和收敛之间的隐藏关系5.1 参数量级差异对单纯形的影响Nelder-Mead算法对参数尺度极其敏感。如果你的三个参数分别是1e-3、1e2、1e4量级单纯形初始形状会非常离谱——它本质上是在原始参数空间中做几何操作各维度之间的尺度差异直接决定了反射和收缩的方向是否合理。举个例子拟合一个形如a*x^b c的函数b如果接近2a接近1c接近1000那么初始单纯形在c方向上的偏移远大于在a方向上的偏移算法前几步几乎只在调整ca和b的变化很小。如果你把TolX设成一个适合a和b的尺度比如1e-6它在c方向上根本不可能达到这个精度反过来如果你把TolX设成适合c的尺度比如1e-2a和b方向又太粗犷了。正确的做法是先对参数做归一化或缩放。把c直接除以1000、把b除以2让所有参数的量级都落在0.1到10之间再跑fminsearch收敛速度和稳定性都会提升一个台阶。5.2 函数值尺度对TolFun的影响还有一个很容易被忽略的问题目标函数的函数值尺度。如果你的目标函数算出来是1e8量级比如某些残差平方和而TolFun设的是1e-6那么单纯形各个顶点的函数值差异几乎不可能小于1e-6——因为浮点舍入误差本身就有个几百的量级。也就是说函数值的绝对尺度必须和TolFun匹配。实操中我倾向于提前算一下目标函数在初始点附近的取值波动范围然后把TolFun设成这个波动范围的千分之一到万分之一。如果目标函数在初始点附近的波动是1e3量级TolFun设成0.1就足够了设成1e-6纯属自欺欺人。6. 从模拟信号到真实数据带噪声目标函数下的收敛陷阱6.1 噪声项对单纯形判定的干扰真实工程数据几乎总是带噪声的。如果你直接在原始测量数据上计算残差平方和那么目标函数在参数空间中是有噪声基底的——函数值不是一个光滑曲面而是叠加了随机起伏。在这种目标函数上TolFun1e-12和TolX1e-12会带来一个非常恶心的行为单纯形在搜索过程中经常会因为噪声的影响把噪声当成了真实的函数变化做出错误的反射或收缩决策。具体表现就是迭代日志里函数评估次数暴增但参数几乎不变化整个搜索过程变成在噪声里打转。这种情况下正确做法不是去调整TolX和TolFun而是先对数据进行平滑或降噪处理或者改用对噪声更鲁棒的目标函数形式。在MATLAB里可以先对原始数据做movmean或者lowess平滑再用平滑后的数据做拟合如果你研究过lsqnonlin和fminsearch的配合你会发现lsqnonlin在带噪声问题上更稳定因为它用了梯度信息而fminsearch是完全盲目的几何搜索。6.2 多起点策略规避局部极值的实用配置Nelder-Mead算法是一个局部优化算法没有任何全局搜索能力。你对初始点x0的选择几乎决定了最终收敛到哪个极值附近。解决这个问题最朴素也最有效的办法就是多起点在参数空间里随机生成几十个初始点每个点跑一遍fminsearch然后取结果中最小的函数值作为最终答案。MATLAB里可以用GlobalSearch或MultiStart来系统化地做这件事但说实话对大多数问题手写几个随机起点就够了best_x []; best_fval inf; for i 1:20 x0_random lb (ub - lb) .* rand(1, numel(lb)); [x_i, fval_i] fminsearch(fun, x0_random, options); if fval_i best_fval best_fval fval_i; best_x x_i; end end关键是lb下界和ub上界的范围不能太宽不然随机采的点大部分落在无意义的区域纯粹浪费时间。可以根据实际问题的物理意义确定合理边界比如衰减时间常数tau不可能小于采样间隔也不可能大于总采样时长。7. 验证收敛的方法跳出MATLAB的黑箱7.1 绘制单纯形的迭代轨迹fminsearch默认不返回迭代过程的中间状态。想直观看到单纯形在参数空间里的爬行过程可以用自定义输出函数在每次迭代时把当前单纯形的顶点坐标抓出来画成二维散点图或三维路径。function stop outfun(x, optimValues, state) stop false; if strcmp(state, iter) plot(optimValues.iteration, x(1), b.); hold on; plot(optimValues.iteration, x(2), r.); drawnow; end end这个图如果画出来是一条平稳收敛的曲线说明算法行为正常如果曲线在某个区域反复振荡或者迭代到后期变化仍然杂乱说明参数空间中可能存在不可导点、平坦区域或是凸性很差。这时候再看TolX、TolFun的设置才有意义——假如曲线本身还在振荡你再调容差也没有用问题出在目标函数形态上。7.2 容差扫描实验一条成本最低的验证路径一个我在实践中屡试不爽的办法是对TolX做一个扫描分别设为1e-3、1e-4、1e-5、1e-6、1e-7、1e-8各跑一遍记录最终的目标函数值。以我朋友的指数衰减拟合问题为例扫描结果通常是这样的TolX最终目标函数值迭代次数函数评估次数1e-34.32e-221841e-43.85e-2341311e-51.98e-2522101e-61.87e-2471851e-71.87e-21747031e-84.15e-23221370注意这个有意思的现象TolX1e-6和1e-7得到同样的1.87e-2但1e-7多花了几百次函数评估更反直觉的是1e-8的结果反而变差了——4.15e-2和1e-3的表现差不多。这就说明这个问题的实际收敛点已经在TolX1e-6左右达到了再收紧TolX只会让算法进入数值噪声区域纯属帮倒忙。如果扫描结果中所有TolX的最终目标函数值都是单调下降且变化大那说明容量收敛还是太松继续收紧有意义但如果出现了收紧后结果反而变差的现象就说明你已经越过了算法适用精度。所以我最终的实操建议非常简单做一次容差扫描实验画出目标函数值对TolX的曲线找到那个临界点——目标函数值不再明显变化、且继续收紧容差结果就开始恶化的位置——然后把TolX和TolFun都设在这个临界点附近。不要设得比这个临界点更严。8. 一个可以直接抄的fminsearch配置模板因为内容涉及具体参数选择我直接给出一份我常用的fminsearch配置方式你根据实际问题改目标函数和初始点就行% 目标函数残差平方和 fun (p) sum((ydata - model(p, xdata)).^2); % 初始点尽量接近真实值 x0 [A_init, tau_init, C_init]; % 先做一次快速粗跑确定大致量级 options_fast optimset(Display,off, TolX, 1e-3, TolFun, 1e-3); [x_coarse, fval_coarse] fminsearch(fun, x0, options_fast); % 基于粗跑结果做容差扫描 tol_list [1e-3, 1e-4, 1e-5, 1e-6, 1e-7, 1e-8]; results zeros(length(tol_list), 2); for i 1:length(tol_list) options_i optimset(Display,off, TolX, tol_list(i), TolFun, tol_list(i)); [x_i, fval_i] fminsearch(fun, x_coarse, options_i); results(i, :) [tol_list(i), fval_i]; end % 打印扫描表人工判断临界点 disp(array2table(results, VariableNames, {TolX, FinalFval})); % 选一个临界点稍宽的容差做最终运行 tol_final 1e-5; options_final optimset(Display,final, TolX, tol_final, TolFun, tol_final, ... MaxIter, 500, MaxFunEvals, 2000); [x_final, fval_final, exitflag, output] fminsearch(fun, x_final, options_final);这套流程的核心逻辑是先用粗容差快速找一个大概的区域然后做容差扫描确定合理的收敛精度最后基于扫描结果选择TolX和TolFun。这样既不会因为容差太松过早停止也不会因为容差太严陷入数值噪声。9. 再分享一个减少单纯形缩放效应的技巧最后补充一个很多教程不会提到的经验fminsearch在MATLAB中还有一个默认行为——初始单纯形是基于初始点x0的各维度幅值生成的。如果你的x0某个维度的值特别大比如1e5初始单纯形在这个维度上的步长可能大到把目标函数算出一个Inf。这样算法一上来就崩了和TolX都没什么关系。解决办法是在调用fminsearch前把参数空间做一次线性缩放让x0落在一个以1为中心的区间内。具体做法% 假设原始参数 p 有三条量级分别是 1e-3, 1e2, 1e4 % 定义缩放因子 scale [1e-3, 1e2, 1e4]; % 内部优化时用归一化参数 fun_scaled (z) fun(z .* scale); z0 x0 ./ scale; % 跑 fminsearch [z_opt, fval_opt] fminsearch(fun_scaled, z0, options); % 还原为真实参数 x_opt z_opt .* scale;缩放之后TolX的设定就和参数的绝对值解耦了TolX1e-6意味着归一化参数的变化不超过百万分之一这个语义对任何实际问题都是直观的。这也是我在处理参数量级差异大的拟合问题时效果最稳定的一个改动。回到我朋友那个案例——他后来用容差扫描加参数归一化的方式重新跑了所有拟合不仅指数衰减模型一次性成功后面做双指数拟合和三参数高斯拟合时也没再翻车。TolX不是随便填的一个坑它是你在告诉算法参数空间里的探索精度到哪里就够了这个值设得合理算法才能真正帮上忙。