写这篇东西的起因是我前阵子在一个参数标定项目里被Matlab的fmincon折磨得不行:目标函数是仿真系统一跑就是几分钟的黑盒输出,带回声、有噪声,还带着一堆局部极小值。初始点稍微偏一点,结果就稳稳陷在某个盆地不出来。后来把模拟退火算法(Simulated Annealing)和优化工具箱组合起来用,先用全局搜索打底,再用fmincon精修,效果完全不一样。这篇就是我对这套方案的完整复盘,包含工具箱选型、simulannealbnd核心参数、三个能直接跑的案例,以及踩坑后的调参思路。
1. 为什么需要模拟退火这类全局优化算法
1.1 基于梯度的优化器并不万能
先聊点实际的。大部分人在Matlab里接触优化,第一个想到的就是fmincon。它的原理是基于梯度信息,沿着最速下降方向或者拟牛顿方向一步步爬到邻近的极小点。对凸函数、光滑函数、初始点比较靠谱的问题,它又快又准,确实是工程利器。
但工程里的优化问题往往没这么讲武德。目标函数可能是仿真结果直接算出来的,连表达式都拿不到;可能存在噪声,数值微分出来的梯度全是乱跳;更常见的是多峰函数,到处都是局部极小值。这时候fmincon本质上是“站在山腰往山下走”,它只能看到脚下的坡,看不到远处的谷。你换个初值,它就可能爬到另一个完全不同的局部坑里。
模拟退火解决的就是这个痛点。它不依赖梯度,不要求函数光滑,也不会看见局部极小就一头扎进去。核心思路特别朴素:先到处乱逛,逛得差不多了再慢慢收网。实际用下来,它就是给fmincon这类局部优化器打前站的最佳搭档。
1.2 模拟退火的核心机制:允许“坏解”出现
模拟退火这个名字来源于冶金学里的退火工艺:金属加热到高温后慢慢冷却,原子在高温下剧烈运动,冷却过程中逐渐排列成低能量的规则晶格。算法就是把目标函数当成能量函数,把一组参数当成系统状态,通过模拟“加热—冷却”的过程来找能量最低的状态。
关键机制叫Metropolis准则。每次迭代随机扰动当前解,得到一个新解。如果新解比当前解好,直接接受;如果新解更差,也不是直接扔掉,而是以一定概率接受它:
P = exp(-(ΔE) / T)
其中ΔE是新解和旧解的能量差,T是当前温度。温度越高,接受差解的概率越大;温度越低,这个概率越小。也就是说,算法在前期“体温”高的时候允许翻山越岭往坏地方走,借此逃出局部陷阱;后期“体温”降下来,就老老实实做局部精修。
很多初学者不理解为什么要接受差解。你想想登山的场景:你想从A山走到更高的B山,中间隔着一条山谷,如果只允许往上走,你永远到不了B。接受差解,就是允许你先下到山谷,再爬到B。这就是全局搜索和局部搜索的本质区别。
1.3 全局优化工具箱的定位
Matlab里有一个常见的误会:把“优化工具箱”当成所有优化函数的统称。其实官方划分很清晰:
- Optimization Toolbox:主要是基于梯度的局部优化和线性、二次规划,fmincon、fminunc、linprog、quadprog都在这。
- Global Optimization Toolbox:专门做全局优化,模拟退火对应的simulannealbnd、遗传算法ga、粒子群particleswarm都在这里。
这两个工具箱一般是配合使用的。模拟退火负责大范围找好区域,fmincon负责在好区域里精确收敛。别一门心思只盯着一个函数,最实用的方案往往是“全局粗搜 + 局部精修”的混合策略。
2. Matlab优化工具箱全景与模拟退火的选型
2.1 常用求解器横向对比
先给一张表,把几个主流求解器的特性摆出来,方便你选型。
| 求解器 | 搜索机制 | 是否全局 | 约束支持 | 典型适用场景 |
|---|---|---|---|---|
| fmincon | 梯度下降/拟牛顿 | 否,局部 | 边界、线性、非线性约束 | 凸问题、初值靠近最优解、有精确梯度 |
| fminunc | 梯度下降/拟牛顿 | 否,局部 | 无约束 | 光滑无约束问题 |
| simulannealbnd | 模拟退火 | 是 | 只原生支持边界约束 | 黑盒、多峰、不可导、中等维度 |
| ga | 遗传算法 | 是 | 支持约束,但处理非线性约束较麻烦 | 离散变量、混合整数、复杂约束 |
| particleswarm | 粒子群 | 是 | 主要支持边界约束 | 连续变量、计算量可控、多峰搜索 |
这张表最关键的信息是:fmincon求的是“局部最优”,simulannealbnd求的是“尽量靠近全局最优”。如果问题本身是凸的,别杀鸡用牛刀,直接fmincon就行。如果问题肉眼可见地多峰、黑盒、不可导,就别在fmincon的初值上反复赌运气了,直接上模拟退火。
2.2 什么场景优先选模拟退火
我自己的判断标准很直接,满足下面两三条就优先考虑SA:
- 目标函数没有解析表达式,只能通过仿真、实验或者外部程序返回值。
- 目标函数存在多个极值点,而且你不知道哪个是全局。
- 函数不可导、不连续,或者对参数变化很敏感。
- 变量维度不算太高,一般几十维以内,太高的话SA收敛会非常慢。
- 你愿意用几分钟甚至几小时的全局搜索时间,换一个稳定的最终方案。
遇到过很多项目,目标函数跑一次要好几分钟,这种情况下你不可能让SA像无头苍蝇一样乱撞几万次。我的做法是先用低精度的代理模型或者降采样版本跑SA,确定最优参数大概在哪个区域,再用高精度仿真在那个小范围里做局部优化。这个思路比单纯堆迭代次数管用得多。
2.3 一个必须澄清的误区:工具箱归属
去网上搜“Matlab模拟退火”,很多人会直接输入simulannealbnd,结果报错“Undefined function”。原因不是函数名记错了,而是没有安装Global Optimization Toolbox。
检查工具箱是否存在,可以用:
license('test', 'GADS_Toolbox')如果返回1,说明有全局优化工具箱;如果返回0,说明只有基础的Optimization Toolbox。另外还可以在Matlab里运行ver,看输出列表里有没有Global Optimization Toolbox。
这个细节特别容易坑到新手。我见过有人折腾了半天代码,结果发现是工具箱都没装。如果你所在的公司或者学校只有Optimization Toolbox,也别灰心:模拟退火算法本身不复杂,自己写一个核心循环也就几十行,后面第4节我会给你一个能跑的手写版本。
3. simulannealbnd核心参数与实操细节
3.1 目标函数怎么写给SA才不踩坑
simulannealbnd对目标函数的要求比fmincon宽松很多,但有几个基础规则必须遵守。第一,输出必须是标量;第二,尽量别返回NaN或Inf;第三,输入x的维度要和初始点x0保持一致。
一个典型的匿名函数写法:
fun = @(x) (x(1)-2)^2 + (x(2)+1)^2 + 5*sin(3*x(1)); x0 = [0, 0]; lb = [-10, -10]; ub = [10, 10]; [x, fval] = simulannealbnd(fun, x0, lb, ub);这里的x是行向量还是列向量,取决于你的x0。如果你传递的是行向量,SA内部会把x当行向量处理;目标函数里要注意x(1)、x(2)的顺序别写反。
另外一个经验是:如果目标函数内部有报错风险,比如根号下负数、对数里出现0,最好在函数开头加保护逻辑,返回一个大数值而不是让程序崩溃。SA会在搜索过程中频繁试探边界和极端位置,目标函数必须足够健壮。
3.2 边界约束与非线性约束处理
simulannealbnd原生支持的是边界约束lb和ub,这个很简单。没有下界或上界时传[]就行:
[x, fval] = simulannealbnd(fun, x0, [], ub);但实际工程问题经常有非线性约束,比如某个指标不能超过阈值、某种资源总和不能超限。SA本身不直接支持非线性约束,我通常用外点惩罚函数来处理。基本思路是:把约束违反量放大后加到目标函数上,让算法在搜索时自动避开不可行区域。
惩罚函数的标准形式是:
cost_pen = cost_original + lambda * max(0, g(x))^2
其中g(x) <= 0是不等式约束,lambda是惩罚系数。lambda太小,约束会被无视;lambda太大,会扭曲目标函数形状,让SA的收敛变得很困难。我一般从1e3开始试,逐步增大到1e5量级,直到最优解的约束违反量小到可以忽略。
3.3 温度、冷却率、迭代次数怎么调
模拟退火的效果很大程度取决于温度调度。温度高,前期探索范围大;温度低,后期容易陷入局部。降温太快,跟没退火一样;降温太慢,计算时间会爆炸。
在simulannealbnd里,这些因素分散在几个参数中:
| 参数 | 作用 | 经验值 |
|---|---|---|
| InitialTemperature | 初始温度,决定前期接受差解的概率 | 根据目标函数值量级调整,常用10~1000 |
| TemperatureFcn | 温度下降函数,@temperatureexp、@temperaturefast | 默认常用指数降温 |
| ReannealInterval | 多少代后重新升温再降温,防止过早收敛 | 100~1000 |
| MaxIter | 最大迭代次数 | 500~10000 |
| MaxFunEvals | 最大目标函数调用次数 | 通常比MaxIter大几倍 |
| TolFun | 目标函数变化小于该值则停止 | 1e-6~1e-8 |
如果你完全不知道初始温度怎么设,有一个相对靠谱的校准方法:先固定一个初始解,做若干次随机扰动,统计目标函数增量的平均值Δ_mean,然后设定初始接受率P0,比如0.8,用公式反推:
T0 ≈ -Δ_mean / ln(P0)
意思是刚开始有80%的概率接受那些变差的解。这样算法前期才有足够的探索能力。很多默认参数跑不出好结果,问题往往就出在初始温度和目标函数值量级不匹配。
3.4 saoptimset关键选项速查
老版本用saoptimset设置参数,新版本也可以继续用。我给一个常用的配置模板:
options = saoptimset( ... 'Display', 'iter', ... 'MaxIter', 3000, ... 'MaxFunEvals', 30000, ... 'InitialTemperature', 100, ... 'TemperatureFcn', @temperatureexp, ... 'ReannealInterval', 200, ... 'TolFun', 1e-8); [x, fval] = simulannealbnd(fun, x0, lb, ub, options);如果用的是较新的Matlab版本,也可以改成optimoptions方式,参数名基本一致:
options = optimoptions(@simulannealbnd, ... 'Display', 'iter', ... 'MaxIterations', 3000, ... 'InitialTemperature', 100);有一个细节:MaxIter和MaxFunEvals是两套完全不同的终止条件。MaxIter是迭代轮数,每一轮可能调用多次目标函数;MaxFunEvals是目标函数调用次数上限。如果你的目标函数跑一次特别贵,更应该限制MaxFunEvals,而不是MaxIter。
4. 三个可复现案例:从单目标到组合优化
4.1 案例A:多峰函数寻优,和fmincon对比
第一个案例用经典的Rastrigin函数做测试。这个函数的特点是:全局最小值在原点,但周围布满了密密麻麻的局部极小点,专门用来考验全局优化算法。
clear; clc; rng(1); fun = @(x) sum(x.^2 - 10*cos(2*pi*x) + 10); lb = [-5, -5]; ub = [5, 5]; x0 = [3, 4]; % 模拟退火 options = saoptimset('Display', 'off', 'MaxIter', 5000, ... 'InitialTemperature', 50, 'TemperatureFcn', @temperatureexp); [x_sa, f_sa] = simulannealbnd(fun, x0, lb, ub, options); % fmincon做对比 opts_fmin = optimoptions(@fmincon, 'Display', 'off', 'Algorithm', 'sqp'); [x_f, f_f] = fmincon(fun, x0, [], [], [], [], lb, ub, [], opts_fmin); fprintf('SA: x=[%.4f %.4f], fval=%.6f\n', x_sa(1), x_sa(2), f_sa); fprintf('fmincon: x=[%.4f %.4f], fval=%.6f\n', x_f(1), x_f(2), f_f);在我本地的测试里,SA基本能稳定找到接近(0,0)的位置,fmincon从(3,4)出发则容易停在一个局部极小点,函数值明显更大。这个案例想说明的不是fmincon不行,而是它确实缺乏“跨过山谷”的能力。如果换成凸函数,fmincon的表现反而会更好,所以选求解器一定要看问题形状。
4.2 案例B:带非线性约束的工程参数优化
第二个案例更贴近工程:目标函数很简单,但有一条非线性约束。问题定义如下:
min (x1-3)^2 + (x2-4)^2
约束条件:x1^2 + x2^2 <= 16
这个问题的解析最优解大概在(3, sqrt(7))附近,也就是约束边界上。先用惩罚函数把约束带进目标函数,跑SA,再用fmincon在SA的结果上精修。
clear; clc; rng(2); obj = @(x) (x(1)-3)^2 + (x(2)-4)^2; con = @(x) x(1)^2 + x(2)^2 - 16; lambda = 1e4; penObj = @(x) obj(x) + lambda * max(0, con(x))^2; lb = [-10, -10]; ub = [10, 10]; x0 = [0, 0]; options = saoptimset('Display', 'off', 'MaxIter', 4000, ... 'InitialTemperature', 100); [x_sa, f_sa] = simulannealbnd(penObj, x0, lb, ub, options); % 用fmincon做约束精修 nonlcon = @(x) deal(con(x), []); [x_refine, f_refine] = fmincon(obj, x_sa, [], [], [], [], lb, ub, nonlcon); fprintf('SA粗搜索: x=[%.4f %.4f], 约束值=%.4f\n', x_sa(1), x_sa(2), con(x_sa)); fprintf('fmincon精修: x=[%.4f %.4f], 约束值=%.4f\n', x_refine(1), x_refine(2), con(x_refine));这里要注意,fmincon精修时使用的是原始目标函数,不再带惩罚项,而是把约束正式传给nonlcon。这样SA负责找到边界附近的可行区域,fmincon负责把解精确落到边界上,两者分工明确。
我带过一个项目,最初只靠惩罚函数跑SA,结果总是差那么一点,后来改成这种“SA找区域,fmincon找精确解”的两段式,精度瞬间就上去了。
4.3 案例C:手写SA求解TSP,自定义邻域是关键
simulannealbnd并不是为离散组合优化设计的,比如旅行商问题(TSP),它的变量是城市访问顺序,是置换编码,不是连续向量。强行套simulannealbnd会很别扭。这种情况下我更推荐直接用Matlab手写一个SA核心循环,代码量也不大。
TSP的SA思路:
- 用随机排列表示一条路径。
- 邻域操作采用“交换两个城市的位置”或者“反转一段路径”。
- 新路径长度更短就接受,更长则以exp(-Δ/T)概率接受。
- 温度按指数冷却:T = T * alpha。
核心代码框架:
nCity = 30; rng(3); cityPos = rand(nCity, 2) * 100; distMat = pdist2(cityPos, cityPos); path = randperm(nCity); path = [path, path(1)]; % 首尾相连 cost = pathCost(path, distMat); T = 1000; alpha = 0.995; while T > 1e-3 newPath = swapTwo(path); newCost = pathCost(newPath, distMat); delta = newCost - cost; if delta < 0 || exp(-delta / T) > rand path = newPath; cost = newCost; end T = T * alpha; end fprintf('最终路径长度: %.2f\n', cost); function len = pathCost(p, D) len = 0; for i = 1:length(p)-1 len = len + D(p(i), p(i+1)); end end function p2 = swapTwo(p) p2 = p; idx = randperm(length(p)-2, 2) + 1; % 不交换首尾 p2(idx) = p2(fliplr(idx)); end这个案例的价值在于:当你面对的不是标准连续优化问题,而是特殊编码的组合优化问题时,理解算法原理远比赛会背工具箱接口更重要。SA的核心就三件事:解的表达、邻域扰动、温度调度。
5. 常见问题与调优技巧实录
5.1 结果重复性差怎么处理
很多用户第一次跑SA,会惊讶地发现每次结果都不一样。这是正常现象,因为SA本质是随机算法。但如果每次结果天差地别,说明算法没有充分收敛。
我处理重复性问题有一个标准流程:先固定随机种子rng(1),保证每次实验可复现;然后增加MaxIter和MaxFunEvals;最后如果计算资源允许,多次运行SA,取目标函数值最小的解作为最终结果。这个“多跑几次取最优”的策略虽然简单,但在实际工程里非常实用。
5.2 SA收敛太慢,除了等待还能做什么
如果跑了几千次迭代还在原地转,大概率是温度策略不适合当前问题。常用的加速手段:
- 降低InitialTemperature,减少前期无效的乱逛。
- 把TemperatureFcn换成temperaturefast,降温更激进。
- 增大ReannealInterval,减少重新升温的次数,让算法更专心收敛。
- 检查目标函数是否有大量平坦区域,如果有,SA在平坦区里会瞎转,这种问题更适合加一点局部梯度信息。
我踩过的一个坑是:为了追求全局最优,把初始温度设得特别高,结果前一半计算时间全在随机乱跳,真正的局部精修反而不够。
5.3 惩罚函数把结果带偏了
惩罚函数几乎是处理非线性约束的标配,但它有个副作用:惩罚系数太大,目标函数表面会变得特别陡峭,SA在边界附近很难稳定;惩罚系数太小,约束会被无视。
我的建议是不要一次性定死惩罚系数,而是跑一轮SA,检查最终解的约束违反量。如果违反量大,就把lambda乘以10再跑;如果结果已经在可行域内,但精度不够,再用fmincon精修。实际项目中,我经常用SA+惩罚函数找到边界区域,然后靠fmincon去满足严格的约束。
5.4 SA+fmincon混合优化模板
综合前面的经验,我目前最常用的优化流程是这样的:
- 先用模拟退火做全局搜索,得到一个候补解。
- 检查候补解的目标函数值和约束违反量。
- 以候补解为初值,调用fmincon做局部精修。
- 如果fmincon结果不符合预期,回到第1步,换一个随机种子再跑一次。
这段逻辑可以封装成一个函数,核心代码如下:
function [x_best, f_best] = hybrid_optimize(objFun, x0, lb, ub, nonlcon) saOptions = saoptimset('Display', 'off', 'MaxIter', 3000); [x_sa, ~] = simulannealbnd(objFun, x0, lb, ub, saOptions); fminOptions = optimoptions(@fmincon, 'Display', 'off'); if nargin < 5 nonlcon = []; end [x_best, f_best] = fmincon(objFun, x_sa, [], [], [], [], lb, ub, nonlcon, fminOptions); end这种混合策略几乎成了我做中型优化项目的默认起手式。它既保留了SA的全局搜索能力,又利用了fmincon在局部的高精度收敛,两个工具箱才算真正打出了配合。
5.5 问题速查表
| 现象 | 常见原因 | 解决办法 |
|---|---|---|
| 每次运行结果都不同 | 随机算法未充分收敛 | 固定rng、增加迭代次数、多次运行取最优 |
| 收敛到明显很差的解 | 初始温度过低或降温过快 | 提高InitialTemperature,更换temperatureexp |
| 搜索过程极其缓慢 | 初始温度过高、退火区间过长 | 降低初始温度、增大ReannealInterval |
| 约束始终不满足 | 惩罚系数太小 | 增大lambda,或改用fmincon处理约束 |
| 目标函数返回NaN | 搜索到无效区域 | 在目标函数内加保护逻辑,返回大数 |
| 结果停留在初始点附近 | 邻域扰动幅度太小 | 检查x0向量范围,调整初始温度 |
6. 我的实操心得与后续扩展
6.1 踩坑后总结的习惯
我现在做任何一个优化项目,都会先花时间把目标函数的行为摸一遍。能画图就画图,不能画图就随机采样若干组参数,看一眼目标函数的量级和粗糙程度。这个习惯帮我省了无数调参时间。
第二个习惯是永远不要把SA的结果当最终答案。SA擅长的是“找到好区域”,而不是“给出极高精度的解”。除非你的问题对精度要求不高,否则建议在SA结果后面再接一个局部优化器。哪怕是fminunc这种无约束的局部求解器,也够用。
第三个习惯是记录每一次SA运行的轨迹。Matlab里可以通过自定义输出函数或绘图函数,把每次迭代的当前最优值记录下来,后期分析收敛过程非常有用。但自定义绘图函数在新版本里有时兼容性一般,我直接保存变量,画图自己来。
6.2 还可以怎么扩展
模拟退火的灵活性在于温度调度和邻域操作都可以自定义。我在一个项目里给温度函数加入“阶段性升温”策略:每跑固定轮数后,如果检测到连续多轮最优值没有变化,就自动升温重新搜索。效果比固定降温曲线好不少。
如果你对Matlab的全局优化工具箱已经很熟,还可以进一步把SA与其他方法组合:先用SA做变量筛选,再用线性回归或响应面模型精修;或者在SA的目标函数中嵌入仿真模型,实现真正的黑盒优化。只要理解了“全局粗搜+局部精修”这个核心思想,工具箱就只是工具,真正解决问题的是你的调度策略。
模拟退火不是万能的,但在我处理过的多峰、黑盒、不可导问题里,它几乎是最稳的兜底方案。配合Matlab优化工具箱里的局部求解器,这一套组合拳值得成为你优化武器库里的常备选项。