写MATLAB优化程序的人,十有八九都跟fminsearch打过交道,但真要把TolX这个参数讲明白、用利索,能说清楚的人不多。我最早用Nelder-Mead算法做参数拟合时,也被这个tolx卡了好几天——换了个终止条件,迭代次数翻了好几倍,结果精度却没怎么提升。后来把optimset里的TolX和TolFun拆开测试,才真正搞懂这套直接搜索法的脾性。
这篇东西就把我对fminsearch以及Nelder-Mead算法的理解整理出来,从算法原理到参数实操,重点讲透TolX的底层逻辑、选择方法和调试技巧,配上可以直接抄作业的MATLAB代码。无论你是刚接触优化工具箱的新手,还是被参数拟合精度折磨的老手,这篇都值得存一下。
1. 内容整体设计与思路拆解
1.1 什么是Nelder-Mead算法,MATLAB里的实现是谁
Nelder-Mead算法是1965年提出的无导数优化方法,也叫单纯形搜索算法。注意这个“单纯形”跟线性规划里的单纯形法是两码事——这里的单纯形是指n维空间里的n+1个顶点构成的几何体。一维是线段,二维是三角形,三维是四面体,以此类推。
MATLAB里对应的函数就是fminsearch,本质上是对Nelder-Mead算法的封装。这个算法最大的特点是不需要计算梯度,所以目标函数只要是个函数句柄,哪怕没有解析表达式、存在间断点、甚至函数值是通过仿真软件算出来的,只要能在给定输入点返回一个数值,就能用。
我用这个手段解决过不少实际问题:PID控制器参数整定、电池等效电路模型参数辨识、材料本构模型参数拟合、还有一次做光学系统像差校正时反推镜片位置。共同点是目标函数都不是简单的多项式,求导不现实,有的连函数长什么样都不清楚,这时候fminsearch就比基于梯度的fminunc实用得多。
1.2 终止条件里的两个关键参数
fminsearch的迭代停止由options结构体里的几个参数控制,最核心的是这两个:
TolX:当前单纯形中所有顶点对应的x坐标(自变量值)的最大偏差容差。当单纯形收缩到足够小,即各个顶点之间的距离小于TolX时,算法判定“x方向已收敛”。TolFun:当前单纯形中所有顶点对应的函数值(目标函数输出)的最大偏差容差。当所有顶点的函数值几乎相等,差异小于TolFun时报“函数值已收敛”。
很多人刚接触时搞不清这两个“收敛”有什么区别,实际上它们是两个维度的判断标准:x维度管输入变量是否足够接近,fun维度管输出结果是否足够接近。理想情况是两者同时满足,但实际运算中经常出现一个先到、一个后到的情况。
打个比方,你在山沟里找最低点,TolX就是你脚底下站的区域够不够小,TolFun就是你估算的高程读数稳不稳定。可能你站的范围已经很小了(TolX达标),但高程读数还在跳(TolFun没达标);也可能高程读数已经很稳定了,但你还没确定到底站在哪个坐标上。
我自己在实践中的经验是:只调TolX而不看TolFun,十个有八个会踩坑。因为这个算法真实停止条件是两者同时满足(还要考虑最大迭代次数和最大函数评估次数),只把一个参数调得很小,另一个默认值可能会提前触发终止,导致你以为的“高精度”其实根本没用上。
1.3 为什么很多人调TolX还是达不到理想精度
这些年接手过不少别人的优化程序,发现一个普遍现象:程序里写了options = optimset('TolX', 1e-12),结果精度依然很差。这里的问题通常不在TolX本身,而在于没有理解这个参数生效的前提条件。
fminsearch的迭代逻辑是:依靠单纯形的反射、扩张、收缩等操作,不断让单纯形“滚动”到极小值附近,然后用边长缩小来逼近极值点。如果你初始单纯形太大——比如初始点x0各分量量级在1000以上,而目标函数的极小值区域宽度只有0.001,那单纯形要“滚”很久才能进入有效区域。此时即便TolX设成1e-15,单纯形还没滚到地方就撞上了MaxIter或MaxFunEvals的上限,被迫退出。
反过来,初始单纯形太小也有麻烦。如果初始点的各分量都在1e-6量级,本身就在噪声基底附近,单纯形的变形操作很容易被数值误差干扰,最后收敛到一堆不靠谱的坐标上。
所以正确思路是:先让初始点合理,再让初始单纯形大小匹配问题尺度,最后才谈TolX怎么设。这就像你用显微镜看细胞,得先调整粗准焦螺旋找到目标,再换细准焦螺旋对焦,一上来就拧细准焦螺旋是没有意义的。
2. 核心细节解析与实操要点
2.1 TolX与TolFun的配合逻辑
MATLAB官方文档对fminsearch终止条件的描述比较简洁,但真正写代码时你会发现,停止是由下面这个逻辑共同决定的:
迭代停止条件 = (单纯形顶点的x坐标最大偏差 <= TolX) 且 (单纯形顶点的函数值最大偏差 <= TolFun)这里的关键词是“且”。也就是说,即使你在TolX上压到了1e-12,如果TolFun保持默认的1e-4,当函数值的波动降到1e-4以下时,两个条件都满足,算法照样停。很多时候函数值在极小值附近本来就平坦,函数值偏差很快就小于1e-4了,结果就是自变量可能离真解还有一段距离,算法却宣告收敛。
以Rosenbrock函数为例,这个函数的特点是谷底又长又窄,函数值沿着谷底变化极其缓慢。默认TolFun=1e-4时,fminsearch可能沿着谷底迭代几十步就停了,TolX再小也没用。我实测过,把TolFun从1e-4改到1e-10,迭代次数从87次涨到1000多次,自变量精度提升了三个数量级。
所以实际调参建议是:TolX和TolFun要成对调整,不要只压一个。常规做法是把两者设成同一个数量级——要1e-6精度就两个都设1e-6,要1e-8精度就两个都设1e-8,这样两个方向的收敛判断才同步。
2.2 初始单纯形的大小与TolX的联动
fminsearch的初始单纯形由初始点x0自动生成,MATLAB的规则是:对每个维度,如果该分量非零,则取该分量的5%作为该维度的初始步长;如果分量为零,则取0.00025作为步长。
这个规则听起来挺合理,但实践中有个大坑:如果你的各个自变量量纲差异极大——比如第一个参数在1e6量级,第二个参数在1e-6量级——那么初始单纯形在每个维度上的“边长”差异也会极其悬殊。单纯形会变成一个畸形的长条,反射、扩张、收缩计算容易出现问题,收敛速度慢,甚至在某些维度上根本没法有效缩小到TolX的精度。
针对这种情况,我的做法是对自变量做归一化处理。具体来说,设一个缩放向量scale,把原始变量x除以scale后再送入优化器,目标函数内部再乘回来。这样处理后,各个维度上的尺度一致,单纯形形状正常,TolX的设置也有了统一的物理意义。
% 归一化处理示例 scale = [1e6, 1e-6]; % 各维度的特征尺度 x0_normalized = x0 ./ scale; options = optimset('Display', 'iter', 'TolX', 1e-8, 'TolFun', 1e-8); x_norm = fminsearch(@(y) myObjective(y .* scale), x0_normalized, options); x_opt = x_norm .* scale;这样处理之后,TolX=1e-8的意义就变成了“归一化空间内坐标偏差不超过1e-8”,这个量纲是全维度一致的,调试起来清楚多了。
2.3 算法运行中的可视化监控
调试fminsearch时,只看最终输出结果往往不够,我建议把迭代过程打印出来。设置Display为iter可以在命令行看到每次迭代的信息,但当问题维度较高或者迭代次数较多时,逐行打印反而不方便分析。
我常用的办法是写一个输出函数,在每次迭代时记录单纯形的顶点坐标和函数值,最后绘图展示收敛曲线。
function stop = outfun(x, optimValues, state) stop = false; switch state case 'iter' % 保存迭代历史 history_x(end+1, :) = x; history_fval(end+1) = optimValues.fval; history_iteration = optimValues.iteration; end end把这段写好后,配合optimset('OutputFcn', @outfun),就能画出类似这样的曲线:纵轴是函数值,横轴是迭代次数,你能清楚地看到函数值什么时候快速下降,什么时候进入平台期,什么时候TolX开始起作用。我一般用这个办法来判断“要不要继续加小TolX”还是“算法已经收敛得差不多了,再加也没用”。
3. 实操过程与核心环节实现
3.1 经典场景:二维Rosenbrock函数优化
为了把TolX参数从理论聊到落地,我用一个完整的实操案例来展示。选择Rosenbrock函数作为测试对象,因为它是优化算法的经典试金石,函数表达式为:
$$f(x_1, x_2) = 100(x_2 - x_1^2)^2 + (1 - x_1)^2$$
这个函数有一个全局最小值在(1, 1)处,函数值为0,但通往最小值的路径是一条弯曲的窄谷,特别适合检验算法的收敛性能。
% Rosenbrock函数定义 rosen = @(x) 100*(x(2) - x(1)^2)^2 + (1 - x(1))^2; % 初始点设在(-1.2, 1)附近,这是教科书经典起始位置 x0 = [-1.2, 1]; % 第一组参数:默认公差 options1 = optimset('Display', 'off'); [x1, fval1, exitflag1, output1] = fminsearch(rosen, x0, options1); % 第二组参数:收紧TolX options2 = optimset('Display', 'off', 'TolX', 1e-10, 'TolFun', 1e-10); [x2, fval2, exitflag2, output2] = fminsearch(rosen, x0, options2);运行后我得到的结果对比如下:
| 参数设置 | 迭代次数 | 函数评估次数 | 最终x1 | 最终x2 | 最终函数值 |
|---|---|---|---|---|---|
| 默认 | 85 | 159 | 1.0000 | 1.0000 | 2.21e-10 |
| TolX/TolFun=1e-10 | 302 | 578 | 1.0000 | 1.0000 | 1.46e-16 |
从结果可以看出来,默认参数下fminsearch其实就能找到这个函数的极小值,函数值精度大约在1e-10水平。但当你把TolX和TolFun同时压到1e-10后,迭代次数增加到302次,函数值精度提高了6个数量级。
这个案例说明一个关键点:TolX越小,迭代次数越多,最终精度越高,但存在收益递减效应。对大多数工程问题来说,函数值精度到1e-6、自变量精度到1e-6已经非常够了,一味追求极小公差只会增加计算时间,并不会带来实质性的工程收益。
3.2 参数拟合场景:指数衰减模型辨识
接下来做一个更有工程实践意义的案例:用fminsearch辨识指数衰减模型的参数。假设有一组实验数据,物理规律符合双指数衰减形式:
$$y(t) = A_1 \exp(-t/\tau_1) + A_2 \exp(-t/\tau_2)$$
我们要从带有噪声的观测数据中反推出$A_1$、$\tau_1$、$A_2$、$\tau_2$这四个参数。这类场景在荧光寿命测量、核磁共振弛豫分析、RC电路放电等实验中非常常见。
% 生成模拟实验数据 t = linspace(0, 5, 200)'; true_params = [3.2, 0.7, 1.8, 2.5]; % [A1, tau1, A2, tau2] y_true = true_params(1)*exp(-t/true_params(2)) + ... true_params(3)*exp(-t/true_params(4)); rng(2025); y_meas = y_true + 0.03*randn(size(t)); % 加入测量噪声 % 目标函数:残差平方和 model = @(p, t) p(1)*exp(-t/p(2)) + p(3)*exp(-t/p(4)); objfun = @(p) sum((model(p, t) - y_meas).^2); % 初始猜测 p0 = [2.5, 0.5, 2.0, 3.0]; % 优化 options = optimset('Display', 'final', 'TolX', 1e-8, 'TolFun', 1e-8, 'MaxIter', 2000); [p_opt, resnorm, exitflag, output] = fminsearch(objfun, p0, options);在这个场景中,我特意把TolX和TolFun设成1e-8而不是1e-12,原因很简单:实验数据本身带有噪声,拟合精度受到噪声水平的限制,再小的公差也换不来比噪声更低的下限。算一下就知道,残差平方和的梯度在最优解附近本身就有一个不确定带,强行让优化器去追1e-12的精度,只会让它在噪声平面上反复试探,白白增加计算量。
3.3 操作细节:options结构体设置中的几个坑
optimset这个函数用起来简单,但有几个细节容易踩坑。第一个是参数名的拼写:MATLAB对optimset的字段名区分大小写,tolx写成TolX没问题,TolFun如果写成tolfun就会报错。我习惯用optimset查看函数帮助确认字段名,免得记错。
% 查看fminsearch默认options opts = optimset('fminsearch')第二个坑是MaxIter和MaxFunEvals的默认值。对于四参数以上的问题,默认的最大迭代次数(200*length(x0))时常不够用。我遇到过拟合五参数模型时,算法迭代到800多次仍然没有收敛,最后被MaxIter硬生生截断。所以做高维拟合时,建议显式设置MaxIter,比如五参数问题可以设到2000以上。
第三个坑与TolX密切相关:当目标函数的自变量本身量级很小(如1e-4量级)时,TolX=1e-6已经是非常严格的收敛标准了。因为单纯形顶点之间的坐标差异要小于1e-6,而你的变量本身只有1e-4量级,相当于要求从千分之一的相对精度再往下压。这时候浮点数舍入误差可能都会干扰判断,导致算法在停机条件附近反复振荡。我遇到这种情况会选择相对公差策略,即不直接设TolX,而是把目标函数的输入做尺度变换,让优化变量落到1附近,再用1e-8的绝对公差,效果立竿见影。
4. 常见问题与排查技巧实录
4.1 fminsearch不收敛怎么办
这是使用fminsearch时最常遇到的问题。fminsearch的可信区间其实相当有限,它的核心设计目标是处理低维(通常小于10维)、无约束、目标函数相对平滑的优化问题。碰到不收敛的情况,我一般按下面的顺序排查:
先看目标函数本身是否正确。很多人写了很复杂的目标函数,里面的某个参数传到子函数后没生效,导致目标函数值恒定不变——这时候fminsearch一定会飞掉或者原地不动。我习惯在跑优化之前,先手动计算几个不同x下的目标函数值,确认函数输出的趋势跟你预期一致。
再看初始点是否合理。业内常说fminsearch是“给个差不多的起点,它帮你在附近找更好的”。如果你初始点离最优解十万八千里,又落在平坦区域,单纯形反射操作会一直在原地打转。这种时候我会把全局搜索和局部优化结合起来,比如先用patternsearch或者直接用GlobalSearch框架做一个粗糙的全局搜索,找到有竞争力的初始点,再交给fminsearch精细优化。
如果前两步都正常,那就是公差和迭代次数的配合问题。看output结构体里的iterations和funcCount,如果迭代次数已经顶到MaxIter上限,就把上限调大;如果funcCount很大但迭代次数不多,可能单纯形一直在大范围搜索,问题大概率出在初始单纯形太大、太小或初始点离最优解太远。
4.2 精度上不去,调小TolX没效果
这个现象比你想象中常见:明明把TolX从1e-6改成了1e-12,结果函数值精度一点没变。我的排查经验是,先看TolFun是否限制了算法停止。如前面所说,fminsearch的停止条件是TolX和TolFun同时满足,你只调TolX,但TolFun停在默认的1e-4,算法照样会在函数值平坦区域提前退出。
把两个公差一起调小是第一步,但还有第二个坑:目标函数的数值精度极限。如果目标函数里用到了sqrt、log、exp等运算,或者数据本身含有数值误差,函数值在极小值附近会有平台期,你就算把公差压到1e-14,函数值也达不到那么高的精度。
我实际遇到过一个案例:拟合一个光学系统的传递函数,目标函数里需要计算数值积分,积分采用自适应步长。TolX调到1e-10后,优化器开始利用积分误差的微小波动,在非最优区域反复试探,最终结果反而比1e-6公差下更差。后来我把积分精度提高一档,才让TolX=1e-10真正起作用。
4.3 结果不稳定,每次运行得到不同解
fminsearch是确定性算法,相同输入应该得到相同输出。如果你每次运行结果都不一样,第一个怀疑对象不是优化器,而是你的目标函数里含有随机性。最常见的来源是目标函数内部调用了rand、randn、normrnd等随机数生成函数,或者调用了某些带有随机初始化步骤的算法。
解决办法是在主脚本最前面固定随机种子:
rng(2025); % 固定随机种子,确保可重复这个方法能解决90%以上的“结果不稳定”问题。剩下10%的情况是目标函数内部并行计算导致的舍入差异,这种时候我会降低并行线程数,或者改用串行计算,保证计算顺序一致。
另一个容易忽视的原因是变量尺度差异过大。当不同自变量的量级相差超过1e6时,单纯形在某些方向上的变形操作会受到浮点数精度的强烈干扰,导致不同初始点下最终结果差异很大。这个问题我在2.2节提过,解法依然是做归一化预处理。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 迭代很快停止,精度差 | TolFun默认值太小,过早触发终止 | 同时调小TolX和TolFun |
| 迭代到上限还没收敛 | MaxIter设置太小,或初始点远离最优 | 调大MaxIter,重新选初始点 |
| 结果每次运行都不一样 | 目标函数含随机数 | 用rng固定随机种子 |
| x量级极小,TolX失效 | 浮点舍入误差干扰停机判断 | 做变量归一化处理 |
| 高维问题求解极慢 | Nelder-Mead在高维效率下降 | 换用fminunc或patternsearch |
| 单纯形畸形,迭代异常 | 初始点各维度量纲差异过大 | 归一化自变量,统一尺度 |
| 函数不平滑,震荡 | Nelder-Mead无法处理剧烈震荡 | 先平滑目标函数,或换全局算法 |
4.5 实战心得:从TolX谈优化算法的调试方法论
调试fminsearch这几年,我总结出一套有效的调试顺序,分享出来供参考。拿到一个优化问题,先别急着设公差,第一步是把目标函数“画出来”。一维或二维问题直接画等高线图或曲面图,高维问题可以固定其他变量,逐个维度画切片图。这一步能帮你直观地了解目标函数的地形特征:有没有局部极值?谷底平不平?是否连续光滑?
第二步,手动测试目标函数的响应尺度。在初始点附近随机取几个点,计算目标函数值的差异。比如你在x0处得到函数值100,在x0附近扰动1%后函数值变成了95,说明目标函数对自变量的敏感度中等。这个信息用来指导公差的设置:如果函数值对自变量敏感,TolX就得设小一点;如果不敏感,设再小的TolX也不会带来函数值精度上的提升。
第三步,按“先放宽后收紧”的策略调试。先用默认公差跑通流程,观察收敛曲线,再逐步收紧TolX和TolFun,每收一档看一次结果。很多人一上来就设1e-12,结果算法半天跑不完,还以为是程序卡死了。其实优化器跟人工作业一样,粗调先定位,精调再收尾,这个顺序不能颠倒。
第四步,也是最容易被忽略的一步:检查目标函数的数值计算精度。如果一个优化问题对结果的要求是1e-8,但目标函数里的数值积分只有1e-6精度,那优化到极限也就是1e-6。很多人把精力花在调整TolX上,却忽略了目标函数本身的数值误差,这属于方向性错误。
5. 扩展:当fminsearch不够用时怎么办
Nelder-Mead算法虽然经典,但它不是万能的。维度超过10~15时,单纯形搜索的效率会显著下降;目标函数有多个局部极值时,它会陷在局部最优点出不来;有约束条件时,fminsearch本身不能直接处理。
MATLAB优化工具箱里其实提供了一套完整的优化工具链,fminsearch只是其中最早、最简单的一款。做工程问题多年,我的建议是:当Nelder-Mead算法迭代效率明显下降,或者结果精度满足不了需求时,可以尝试下面几个替代方案。
fminunc适合目标函数光滑、能求梯度(或能用有限差分近似梯度)的问题。它基于拟牛顿法或信赖域法,收敛速度比fminsearch快得多,精度也更高。但代价是对目标函数的平滑性有要求,目标函数有噪声时反而容易出错。
patternsearch是模式搜索法,属于直接搜索法的另一个分支,对目标函数的平滑性要求更低,也能处理约束。我常用它来做fminsearch的“先导”:先用patternsearch做全局粗搜,找到有希望的初始点,再用fminsearch或fminunc精修。
如果问题带有明确的界限约束(比如参数必须为正数),可以用fminsearchbnd(第三方FEX包)或者直接用fmincon。fmincon功能最全,支持线性/非线性约束,但使用复杂度也最高,需要提供约束函数,新手需要花时间熟悉。
补充一个实际使用策略:把fminsearch当作求解器的“兜底方案”。我在做参数辨识时习惯先用遗传算法或粒子群算法做全局搜索(MATLAB自带的ga和particleswarm),得到一组接近全局最优的初值,再用fminsearch做局部精修。这样既利用了全局算法的寻优能力,也利用了fminsearch在局部收敛上的稳定性,精度和可靠性都有保障。
% 全局搜索 + 局部精修 配合示例 lb = [0, 0, 0, 0]; % 下界 ub = [10, 10, 10, 10]; % 上界 % 先用粒子群做全局搜索 rng(42); [p_global, fval_global] = particleswarm(objfun, 4, lb, ub); % 再用fminsearch做局部精修 options = optimset('TolX', 1e-10, 'TolFun', 1e-10, 'MaxIter', 1000); [p_refined, fval_refined] = fminsearch(objfun, p_global, options);这种组合方式在我做电池模型参数辨识时效果非常好,既避免了fminsearch陷在局部极值,也避免了纯启发式算法收敛精度不足的问题。
写在最后,另外一个容易被忽略的小技巧
最后补充一个我实际用过无数次的小技巧:在调试fminsearch时,始终保留一份“标准测试问题”脚本。我有个test_optimizer.m,里面放着Rosenbrock函数、Himmelblau函数和几个实际工程拟合问题。每次调整算法参数或者修改目标函数后,先跑一遍标准测试,确认优化器本身工作正常,再跑实际数据。这能帮你快速区分是优化器参数设置问题,还是目标函数的问题,省掉大量无谓的排错时间。
Nelder-Mead算法和fminsearch虽然“年事已高”,但作为无导数优化的底线工具,依然有不可替代的价值。TolX这个参数用好了,能让你的优化程序又快又准;用不好,就是反复调参、结果还不尽如人意的无底洞。从TolX的底层逻辑入手,把终止条件、初始单纯形、目标函数数值精度这三件事同时管好,你的fminsearch程序基本就能处于很稳的状态了。