☰
SciPy优化器实战:从least_squares到minimize的选型与调试指南
2026/10/14 4:39:12 网站建设 项目流程

做数值优化和参数拟合的朋友,应该都绕不开 SciPy 的 optimize 模块。很多人一开始只会用curve_fit套一条曲线,结果一旦遇到多参数、带约束、有边界、结果总是不收敛这些情况,就不知道从哪里下手了。这篇关于 SciPy 优化器实战的总结,我会从工程里真正会用到的场景出发,把minimize、least_squares、differential_evolution这几个常用入口的选型思路、目标函数设计、约束处理、调试方法完整过一遍。不管是做设备标定、实验数据拟合,还是做简单的设计优化,这篇文章里的思路和代码都可以直接拿来用,不用数学专业背景,有 Python 基础就能跟得上。

1. 先把优化问题看明白:建模才是真正的第一步

1.1 连续优化问题的三个关键成分:变量、目标、约束

在碰任何优化器之前,第一件事不是写代码,而是把问题用数学语言框出来。一个连续优化问题,本质上就是三件事:决策变量是什么、目标函数怎么算、有没有约束条件。

举个例子,我做过一个传感器响应曲线的拟合任务。决策变量就是模型里的时间常数和增益系数,目标函数是模型输出和实测数据之间的残差平方和,约束通常是参数物理意义的上下界,比如时间常数不能为负、增益不能超过某个量级。把这三点写清楚,后面选优化方法、调参数才有方向。

很多人一上来就把数据丢给curve_fit,拿不到好结果就怀疑工具不行。其实八成是问题建模出了问题:目标函数写得有突变,导数不存在;变量之间的尺度差了上千倍,优化器在某个方向死活走不动;约束条件没有显式写进问题,导致优化器跑到物理上不可能的区间。这些不是 SciPy 的锅,是建模的锅。所以我的习惯是,动手写minimize之前,先拿一张纸把优化问题的数学形式写出来,变量用符号表示,目标函数写清楚,约束列出来,再开始写代码。

1.2 为什么我建议优先用 SciPy 而不是自己写优化器

不少朋友在遇到拟合问题时,会倾向于自己写梯度下降或者最小二乘循环。我的观点很直接:除非你是为了学习算法原理,否则不要自己造轮子。SciPy 的 optimize 模块背后是经过多年打磨的数值方法实现,像 BFGS、L-BFGS-B、SLSQP 这类算法,在处理步长选择、Hessian 近似、约束投影等问题上有非常成熟的策略。自己写的话,光是学习率怎么衰减、什么时候停止迭代就够折腾一晚上。

用 SciPy 的另一个好处是接口统一。minimize这个函数可以无缝切换好几种算法,你只需要通过method参数指定即可。这意味着你可以先用简单算法粗跑一遍,再用更快的算法精调,而不需要改目标函数代码。这个特性在实战中非常值钱,因为在没有跑起来之前,你根本不知道问题是光滑的、有约束的还是多峰的,能低成本切换算法给了你很大的容错空间。

我个人的经验是:先用least_squares解决大多数最小二乘类问题,用minimize解决更一般的标量目标函数优化,用differential_evolution处理多峰或者初值不知道怎么给的难题。这三个入口对应了实战中至少九成的场景。

1.3 目标函数怎么写才不容易翻车

目标函数的写法直接决定优化器能不能收敛。最容易犯的错误是目标函数返回的不是标量,而是数组或者矩阵。minimize要求目标函数返回一个标量,least_squares要求返回残差向量。很多人容易把这两个接口搞混,导致代码报错或者结果莫名其妙。

另一个容易踩的坑是目标函数内部出现不连续或者未定义的区域。比如计算对数时参数变成负数、计算除法时分母为零、计算根号时被开方数为负,这些都会让目标函数在某块区域没有定义。优化器在搜索过程中可不会管你物理上合不合理,它会在边界上疯狂试探,一旦撞进未定义区域,轻则返回 NaN,重则直接报错。

解决思路有两个方向。第一个是把变量的取值范围用bounds限制住,让优化器在合法区域内搜索;第二个是在目标函数内部做防御性处理,比如对非法输入返回一个很大的惩罚值。这两种方法我在实践中都会用,但更推荐前者,因为边界约束能有效缩短搜索空间,比惩罚函数的收敛稳定性好很多。

注意:目标函数里尽量少用print,尤其是在迭代次数多的时候。输出会严重拖慢速度,而且刷屏以后你根本看不清有用的日志。调试时可以用callback参数有选择地打印,而不是在目标函数内部打印。

1.4 无量纲化和参数缩放:被忽略但极其重要

这是我觉得最值得强调的一点。优化算法对变量的尺度非常敏感。假设一个优化问题有两个变量,第一个的取值范围是 0.001 和 0.01,第二个的取值范围是 1000 到 10000,那么很多基于梯度的算法会花大量迭代步数在第二个变量上,第一个变量几乎感知不到变化,导致优化速度极慢甚至提前终止。

解决的办法就是对变量做缩放。最常用的做法是把所有变量归一化到接近 1 的数量级,或者在目标函数内部对变量做变换。比如拟合指数衰减模型时,时间常数如果预计在 2 秒左右,可以把它除以一个参考值,让优化器在 1 附近搜索。这样做的效果非常明显,我之前把一组参数做对数变换后再优化,迭代次数直接减少了一个数量级。

在 SciPy 里,缩放既可以在目标函数内部做,也可以通过bounds配合实现。我的通用策略是:任何跨数量级的参数问题,先做变量变换,再用优化器。如果结果不好,第一个怀疑的就是尺度问题,而不是算法选择。

2. 优化方法选型:不同场景下别选错算法

2.1 局部优化算法速查表

SciPy 优化器实战中,method参数的可选算法经常让人眼花缭乱。我先给一张速查表,覆盖我实际用过并且觉得可靠的几个算法。

方法名是否支持边界是否支持约束是否需要梯度适用场景
Nelder-Mead否否否低维、无约束、目标函数不光滑或无法给梯度
BFGS否否是(可数值)中小规模无约束光滑问题
L-BFGS-B是否是(可数值)大规模参数、只带边界约束的问题
SLSQP是是是(可数值)带等式/不等式约束的中小型问题
trust-constr是是是约束复杂、对精度和稳定性要求更高的场景

我个人的使用习惯是:如果没有约束且目标函数相对光滑,优先BFGS;如果变量数量很多,比如几百个参数,优先L-BFGS-B;如果带了非线性约束,基本就是SLSQP或者trust-constr。至于Nelder-Mead,虽然不需要梯度、实现简单,但收敛速度确实偏慢,而且在高维问题上表现一般,适合用来做前期探索或者对精度要求不高的粗调。

2.2 梯度信息:能给解析梯度就不要让程序猜

SciPy 很多算法在你没有提供梯度时,会用有限差分去近似梯度。这个功能很省事,但代价是精度损失和计算开销。有限差分的步长如果选得不好,梯度误差会被放大,严重时会让优化过程在某个区域来回震荡,就是不收敛。

我处理过一个问题,目标函数本身算起来非常快,所以就算用数值梯度也觉得还好。但当我把它放大到需要跑几千次参数扫描时,数值梯度的额外计算量就变得不可接受了。后来我手动推导了目标函数关于参数的偏导表达式,把梯度函数传进minimize,整体优化时间缩短到了原来的三分之一,而且收敛结果更稳定。

如果你不擅长推导梯度,也可以通过 SymPy 自动求导,然后再把梯度函数整理出来。在工程中,花一个小时推导梯度,省下来的是后面几百次运行的时间,这个投入非常划算。

提示:SciPy 提供了scipy.optimize.check_grad函数,可以校验你写的解析梯度是否正确。我第一次用的时候,符号推导有个符号错误,靠这个函数才抓出来,强烈建议传梯度之前都跑一遍校验。

2.3 多峰问题怎么办:全局优化与多起始点

局部优化算法有一个共同的问题,就是它们只能保证找到初始点附近的局部最优解。如果目标函数有很多局部极小值,比如典型的多峰测试函数 Rastrigin,用 BFGS 从不同的点出发会得到完全不同的结果,而且很难判断哪个是全局最优。

面对这种情况,全局优化算法就派上用场了。differential_evolution是差分进化算法,实现稳健,使用简单;dual_annealing是双重退火算法,适合各种类型的连续优化问题;shgo则基于单纯同调方法,适合需要全局搜索的低维问题。我在 SciPy 优化器实战中最常用的是differential_evolution,因为它的超参数少,对目标函数的光滑性要求也不高,只要给变量边界就能跑。

但全局优化算法也有代价:迭代次数多,计算量大。所以实战中我更推荐“两步法”:第一步用differential_evolution在全局范围内找到一个有希望的候选区域,第二步把这个候选点作为初始值,交给BFGS或L-BFGS-B做局部精调。这样既兼顾了全局搜索能力,又利用了局部算法的高精度,效果非常好。

2.4 最小二乘类问题的专用入口:least_squares 与 curve_fit

做数据拟合的时候,其实没必要绕道minimize。SciPy 提供了专门针对最小二乘问题的least_squares,它内部会利用残差的结构信息,比单纯最小化残差平方和更高效、更稳定。curve_fit本质上是least_squares的封装,但它返回的参数协方差估计在实践里非常实用,适合快速做曲线拟合。

least_squares支持通过bounds参数设置上下界,也支持通过loss参数设置鲁棒损失函数。当你的数据里有明显的离群点时,把loss设置为'soft_l1'或者'huber'能有效降低离群点对拟合结果的影响。这一点我特别建议做实验数据拟合的朋友重视,因为实验数据往往不会那么干净,标准最小二乘会被少数几个坏点带偏,而鲁棒损失可以救回来。

3. 实战:一个完整的参数标定任务

3.1 任务描述与数据构造

我拿一个一阶动态系统响应曲线拟合来进行完整演示。这里不涉及具体硬件,只讨论通用方法。假设某个系统在阶跃输入下的响应可以用下面的模型描述:

y(t) = gain * (1 - exp(-t / tau))

其中tau是时间常数,gain是稳态增益。我们的任务是从一组带噪声的观测数据中反推这两个参数。这种模型在很多领域都会出现,比如温度响应、电压充放电、速度阶跃响应等,是一个很适合入门的例子。

先用 NumPy 生成模拟观测数据。为了模拟真实情况,我在真值上叠加了服从正态分布的噪声。

import numpy as np from scipy.optimize import least_squares, minimize, differential_evolution rng = np.random.default_rng(42) def response(t, tau, gain): return gain * (1 - np.exp(-t / tau)) t = np.linspace(0, 10, 100) true_tau, true_gain = 2.4, 3.0 y_obs = response(t, true_tau, true_gain) + 0.02 * rng.normal(size=t.size)

这里的True_tau和true_gain是我们想要找回的真值,实际工程中你是不知道这两个值的,只能靠优化器去估计。

3.2 用 least_squares 完成第一次拟合

定义残差函数,注意least_squares接收的是一个返回残差向量的函数,而不是返回标量。然后把残差函数直接传给least_squares,并给定一个初始猜测值。

def residuals(params): tau, gain = params return response(t, tau, gain) - y_obs res = least_squares(residuals, x0=[1.0, 1.0], bounds=([0.1, 0.1], [10.0, 10.0])) print(res.x)

运行这段代码,输出的结果会非常接近[2.4, 3.0]。这验证了最基本的拟合流程。但你可能会有疑问:为什么初始值[1.0, 1.0]也能收敛?因为这个目标函数相对光滑,只有一个比较明显的极小点,而且边界给得比较合理。

这里有一个值得养成的习惯:查看res.success和res.message。如果success是False,说明优化器没有正常收敛,这个时候直接使用res.x是有风险的。另外,res.cost代表最终的残差平方和,可以用它来对比不同初始值或不同方法的效果。

3.3 给拟合加上边界,并用 minimize 做对照

有些场景下参数有明确的物理边界。比如时间常数不可能小于 0,增益也不可能超过某个阈值。直接在least_squares中用bounds参数指定即可。但如果你的优化目标不是最小二乘形式,而是一个更一般的自定义目标函数,那就需要用到minimize。

为了对照,我把同一个问题改写成标量目标函数的形式:

def objective(params): return np.sum(residuals(params) ** 2) res_min = minimize(objective, x0=[1.0, 1.0], method='L-BFGS-B', bounds=[(0.1, 10.0), (0.1, 10.0)]) print(res_min.x)

这段代码演示了如何把最小二乘问题变成通用优化问题。虽然在这个例子里,least_squares是更合适的选择,但掌握这种写法后,你可以处理任意形式的目标函数,比如带惩罚项、带自定义权重的情况。

注意:minimize的bounds参数是[(min1, max1), (min2, max2), ...]这种嵌套元组格式,不要和least_squares的bounds=([...], [...])搞混。我第一次用的时候就在这里浪费过时间。

3.4 带约束的工程设计优化示例

拟合问题讲完了,再演示一个带约束的优化问题。假设要设计一个圆柱形储水罐,容积至少 1 立方米,目标是让表面积最小。决策变量是底面半径r和高度h,目标函数是表面积,约束是容积不小于给定值。

def area(x): r, h = x return 2 * np.pi * r * (r + h) def volume_constraint(x): r, h = x return np.pi * r**2 * h - 1.0 cons = {'type': 'ineq', 'fun': volume_constraint} res_con = minimize(area, x0=[0.5, 1.0], method='SLSQP', bounds=[(0.01, 10), (0.01, 10)], constraints=cons) print(res_con.x, res_con.fun)

这里的意思是让volume_constraint的结果大于等于 0,也就是容积不小于 1。SLSQP是处理这种约束的常见方法。实际工程中,可能还会同时存在多个约束,比如高度不能超过某个上限、半径不能小于某个工艺最小值,这些都可以通过constraints列表组合起来。

trust-constr也是处理约束的好手,它对于约束条件比较多、目标函数比较光滑的场景,往往比SLSQP更稳定,但相应的计算开销也更大。如果SLSQP收敛困难,我会优先试试trust-constr,而不是急着改目标函数。

3.5 结果检查:不只看收敛,还要看残差

优化跑完之后,我最讨厌的坏习惯就是只打印最优参数,不看其他信息。SciPy 的结果对象里有非常多的信息值得检查。res.success告诉你是否成功收敛,res.fun或者res.cost是最优目标值,res.nit是迭代次数,res.jac是最终梯度,res.optimality是优化度量的数值。

对于拟合问题,我还会额外计算残差的最大值和均方根误差,甚至画一张预测曲线和真实数据的对比图。很多拟合虽然数值上收敛了,但曲线形态在关键区间明显偏离,这种情况说明模型结构本身有问题,参数再准也没用。所以结果检查一定不能跳过。

4. 常见问题与排查思路:这些坑我基本都踩过

4.1 典型问题速查表

为了让实战排查更高效,我先给出一张速查表。遇到问题的时候按图索骥,能省下大量试错时间。

问题现象常见原因优先排查方向
目标函数返回 NaN变量超出定义域,比如取对数负数加边界,目标函数内做防御
优化结果震荡不收敛初值太差、算法不匹配、参数尺度差大换算法,做参数缩放
收敛到明显错误的极值目标函数多峰,初值落入错误谷用全局优化或多起点局部优化
约束没生效约束符号写反、类型用错核对约束定义,打印res.constr_violation
结果对初值极其敏感存在多个局部极小或数值噪声全局优化 + 局部精调两步法
迭代太慢参数多、目标函数重、数值梯度昂贵提供解析梯度,尝试L-BFGS-B

这个表不是标准文档,完全来自我实际调试项目的经验。其中最常出现的是前两行,尤其是尺度问题,几乎每隔一段时间就会遇到一次。

4.2 目标函数返回 NaN 或 Inf 怎么办

目标函数返回 NaN 是优化器最容易崩溃的场景。很多算法在遇到 NaN 时没有统一的行为,有的直接报错,有的会进入死循环。我排查 NaN 的第一个习惯是在目标函数里做一个数值检查,比如:

def objective(x): val = some_computation(x) if not np.isfinite(val): return 1e10 return val

这种做法虽然粗暴,但能把问题暴露出来。不过更推荐的做法是找出 NaN 的根源。有一次我的优化器在参数变成负数时计算了对数,导致目标函数在特定区域变成 NaN。添加边界之后就再也没出现过问题。所以,先看看目标函数里有什么函数对输入有严格限制,比如log、sqrt、1/x,再决定是加边界还是在函数里保护。

4.3 优化结果不收敛或收敛到错误值

不收敛的原因非常多,但我排查的顺序比较固定:先看初始值是否合理,再看目标函数是否光滑,然后看尺度是否统一。初始值不该随手填 0 或 1,而应该根据工程经验给一个接近真实值的估计。比如时间常数明显在 2 秒左右,初始值给 2 比给 0.1 收敛快得多。

收敛到错误值则要怀疑目标函数是不是有多个局部极小。一个很实用的手段是随机生成几十个初始点,用局部优化器分别从这些点出发,把所有结果的最优值挑出来。如果这几十个结果差异很大,说明多峰问题很严重,直接上differential_evolution更省心。

调试时还有一个好工具是callback参数,它可以在每次迭代时打印当前参数和当前目标值,让我直观看到优化的整个轨迹。这个设计对理解算法行为帮助很大,很多问题看一眼迭代轨迹就有眉目了。

4.4 约束失效与边界穿透

我用 SLSQP 的时候遇到过不少次约束看似写上了,但结果根本不在可行域内的情况。后来排查发现,很多是因为约束类型写反了。'ineq'表示不等式约束,约束函数要求返回大于等于零;'eq'表示等式约束,要求返回值为零。如果方向搞反,优化器就会把搜索推向错误的方向,结果自然不符合预期。

另外,即使优化器返回success=True,也不能完全相信约束就一定满足。数值优化算法对约束的满足通常有一定容差。因此检查结果时,要把约束函数的值打印出来实际看一下。SciPy 的结果对象里res.constr_violation字段可以给出约束违反程度,实测下来对排查问题非常有用。

4.5 调试优化器的三个实用习惯

最后一个部分,分享三个我保留下来的习惯,它们帮我解决了很多棘手问题。

第一个习惯是“简单问题先行”。在跑复杂目标函数之前,先用一个最简单的测试函数验证代码链路有没有问题。比如先优化一个球函数,再换成实际目标。这样可以把框架问题和业务问题分开。

第二个习惯是“初值扫描”。固定优化参数和方法,把初始值换成几组不同的值,观察结果是否一致。如果不一致,说明目标函数存在多峰或者数值不稳定,优先做全局搜索。

第三个习惯是“两步法”。先用differential_evolution粗收敛,再用局部算法精调。这个组合几乎适合所有中等难度的连续优化问题。我甚至把这一步写成了一个通用函数,遇到任何新问题都先跑一遍这个流程,再针对结果调整细节。

最后聊一点个人体会。SciPy 优化器实战中,真正的难点往往不在 SciPy 本身,而在于你能不能把实际工程问题变成一个数值上健康、边界清晰、尺度合理的数学优化问题。方法选型只是锦上添花。我自己踩过几次坑之后,现在遇到收敛问题第一反应是检查目标函数和边界,而不是盲目换算法。如果你能把建模、缩放、约束表达这三件事做好,SciPy 的优化器基本可以无脑信任。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询