优化(scipy.optimize)#

scipy.optimize 包提供了几种常用的优化算法。可获得详细列表:scipy.optimize(也可通过 help(scipy.optimize) 找到)。

多变量标量函数的无约束最小化 (minimize)#

minimize 函数为 scipy.optimize 中的多变量标量函数的无约束和约束最小化算法提供了一个通用接口。为了演示最小化函数,考虑最小化 \(N\) 个变量的 Rosenbrock 函数的问题

\[f\left(\mathbf{x}\right)=\sum_{i=1}^{N-1}100\left(x_{i+1}-x_{i}^{2}\right)^{2}+\left(1-x_{i}\right)^{2}.\]

此函数的最小值为 0,当 \(x_{i}=1.\) 时达到。

请注意,Rosenbrock 函数及其导数包含在 scipy.optimize 中。以下部分中所示的实现提供了如何定义目标函数及其雅可比和海森函数的示例。scipy.optimize 中的目标函数期望一个 numpy 数组作为其第一个参数,该参数要进行优化,并且必须返回一个浮点值。确切的调用签名必须是 f(x, *args),其中 x 表示一个 numpy 数组,args 表示提供给目标函数的附加参数的元组。

Nelder-Mead 单纯形算法 (method='Nelder-Mead')#

在下面的示例中,minimize 例程与 Nelder-Mead 单纯形算法一起使用(通过 method 参数选择)

>>> import numpy as np
>>> from scipy.optimize import minimize
>>> def rosen(x):
...     """The Rosenbrock function"""
...     return sum(100.0*(x[1:]-x[:-1]**2.0)**2.0 + (1-x[:-1])**2.0)
>>> x0 = np.array([1.3, 0.7, 0.8, 1.9, 1.2])
>>> res = minimize(rosen, x0, method='nelder-mead',
...                options={'xatol': 1e-8, 'disp': True})
Optimization terminated successfully.
         Current function value: 0.000000
         Iterations: 339
         Function evaluations: 571
>>> print(res.x)
[1. 1. 1. 1. 1.]

单纯形算法可能是最小化行为良好的函数的最简单方法。它只需要函数评估,并且是简单最小化问题的不错选择。但是,因为它不使用任何梯度评估,所以可能需要更长的时间来找到最小值。

另一种只需要函数调用即可找到最小值的优化算法是 Powell 方法,可通过在 minimize 中设置 method='powell' 来使用。

为了演示如何向目标函数提供附加参数,让我们用附加比例因子 a 和偏移 b 最小化 Rosenbrock 函数

\[f\left(\mathbf{x}, a, b\right)=\sum_{i=1}^{N-1}a\left(x_{i+1}-x_{i}^{2}\right)^{2}+\left(1-x_{i}\right)^{2} + b.\]

再次使用 minimize 例程,可以通过以下代码块针对示例参数 a=0.5b=1 来解决此问题。

>>> def rosen_with_args(x, a, b):
...     """The Rosenbrock function with additional arguments"""
...     return sum(a*(x[1:]-x[:-1]**2.0)**2.0 + (1-x[:-1])**2.0) + b
>>> x0 = np.array([1.3, 0.7, 0.8, 1.9, 1.2])
>>> res = minimize(rosen_with_args, x0, method='nelder-mead',
...                args=(0.5, 1.), options={'xatol': 1e-8, 'disp': True})
Optimization terminated successfully.
         Current function value: 1.000000
         Iterations: 319 # may vary
         Function evaluations: 525 # may vary
>>> print(res.x)
[1.         1.         1.         1.         0.99999999]

作为使用 minimizeargs 参数的替代方案,只需将目标函数包装在一个仅接受 x 的新函数中。当需要将附加参数作为关键字参数传递给目标函数时,此方法也很有用。

>>> def rosen_with_args(x, a, *, b):  # b is a keyword-only argument
...     return sum(a*(x[1:]-x[:-1]**2.0)**2.0 + (1-x[:-1])**2.0) + b
>>> def wrapped_rosen_without_args(x):
...     return rosen_with_args(x, 0.5, b=1.)  # pass in `a` and `b`
>>> x0 = np.array([1.3, 0.7, 0.8, 1.9, 1.2])
>>> res = minimize(wrapped_rosen_without_args, x0, method='nelder-mead',
...                options={'xatol': 1e-8,})
>>> print(res.x)
[1.         1.         1.         1.         0.99999999]

另一种替代方案是使用 functools.partial

>>> from functools import partial
>>> partial_rosen = partial(rosen_with_args, a=0.5, b=1.)
>>> res = minimize(partial_rosen, x0, method='nelder-mead',
...                options={'xatol': 1e-8,})
>>> print(res.x)
[1.         1.         1.         1.         0.99999999]

布罗伊登-弗莱彻-戈德法布-沙诺算法 (method='BFGS')#

为了更快地收敛到解决方案,此例程使用目标函数的梯度。如果用户未给出梯度,则使用一阶差分进行估计。即使必须估计梯度,布罗伊登-弗莱彻-戈德法布-沙诺 (BFGS) 方法通常比单纯形算法需要更少的函数调用。

为了演示此算法,再次使用 Rosenbrock 函数。Rosenbrock 函数的梯度是向量

\begin{eqnarray*} \frac{\partial f}{\partial x_{j}} & = & \sum_{i=1}^{N}200\left(x_{i}-x_{i-1}^{2}\right)\left(\delta_{i,j}-2x_{i-1}\delta_{i-1,j}\right)-2\left(1-x_{i-1}\right)\delta_{i-1,j}.\\ & = & 200\left(x_{j}-x_{j-1}^{2}\right)-400x_{j}\left(x_{j+1}-x_{j}^{2}\right)-2\left(1-x_{j}\right).\end{eqnarray*}

此表达式对内部导数有效。特殊情况是

\begin{eqnarray*} \frac{\partial f}{\partial x_{0}} & = & -400x_{0}\left(x_{1}-x_{0}^{2}\right)-2\left(1-x_{0}\right),\\ \frac{\partial f}{\partial x_{N-1}} & = & 200\left(x_{N-1}-x_{N-2}^{2}\right).\end{eqnarray*}

通过代码段构造一个计算此梯度的 Python 函数

>>> def rosen_der(x):
...     xm = x[1:-1]
...     xm_m1 = x[:-2]
...     xm_p1 = x[2:]
...     der = np.zeros_like(x)
...     der[1:-1] = 200*(xm-xm_m1**2) - 400*(xm_p1 - xm**2)*xm - 2*(1-xm)
...     der[0] = -400*x[0]*(x[1]-x[0]**2) - 2*(1-x[0])
...     der[-1] = 200*(x[-1]-x[-2]**2)
...     return der

此梯度信息在 minimize 函数中通过 jac 参数指定,如下所示。

>>> res = minimize(rosen, x0, method='BFGS', jac=rosen_der,
...                options={'disp': True})
Optimization terminated successfully.
         Current function value: 0.000000
         Iterations: 25                     # may vary
         Function evaluations: 30
         Gradient evaluations: 30
>>> res.x
array([1., 1., 1., 1., 1.])

避免冗余计算#

目标函数及其梯度共享计算部分是很常见的。例如,考虑以下问题。

>>> def f(x):
...    return -expensive(x[0])**2
>>>
>>> def df(x):
...     return -2 * expensive(x[0]) * dexpensive(x[0])
>>>
>>> def expensive(x):
...     # this function is computationally expensive!
...     expensive.count += 1  # let's keep track of how many times it runs
...     return np.sin(x)
>>> expensive.count = 0
>>>
>>> def dexpensive(x):
...     return np.cos(x)
>>>
>>> res = minimize(f, 0.5, jac=df)
>>> res.fun
-0.9999999999999174
>>> res.nfev, res.njev
6, 6
>>> expensive.count
12

在此,expensive 被调用了 12 次:在目标函数中调用了 6 次,在梯度中调用了 6 次。减少冗余计算的一种方法是创建一个同时返回目标函数和梯度的函数。

>>> def f_and_df(x):
...     expensive_value = expensive(x[0])
...     return (-expensive_value**2,  # objective function
...             -2*expensive_value*dexpensive(x[0]))  # gradient
>>>
>>> expensive.count = 0  # reset the counter
>>> res = minimize(f_and_df, 0.5, jac=True)
>>> res.fun
-0.9999999999999174
>>> expensive.count
6

当我们调用 minimize 时,我们指定 jac==True 以指示提供的函数同时返回目标函数及其梯度。虽然方便,但并非所有 scipy.optimize 函数都支持此功能,而且,它仅用于在函数及其梯度之间共享计算,而在某些问题中,我们希望与 Hessian(目标函数的二阶导数)和约束共享计算。一种更通用的方法是记忆计算的昂贵部分。在简单的情况下,可以使用 functools.lru_cache 包装器来实现此目的。

>>> from functools import lru_cache
>>> expensive.count = 0  # reset the counter
>>> expensive = lru_cache(expensive)
>>> res = minimize(f, 0.5, jac=df)
>>> res.fun
-0.9999999999999174
>>> expensive.count
6

牛顿共轭梯度算法 (method='Newton-CG')#

牛顿共轭梯度算法是改进的牛顿法,它使用共轭梯度算法(近似)求解局部 Hessian [NW] 的逆。牛顿法基于将函数局部拟合为二次形式

\[f\left(\mathbf{x}\right)\approx f\left(\mathbf{x}_{0}\right)+\nabla f\left(\mathbf{x}_{0}\right)\cdot\left(\mathbf{x}-\mathbf{x}_{0}\right)+\frac{1}{2}\left(\mathbf{x}-\mathbf{x}_{0}\right)^{T}\mathbf{H}\left(\mathbf{x}_{0}\right)\left(\mathbf{x}-\mathbf{x}_{0}\right).\]

其中 \(\mathbf{H}\left(\mathbf{x}_{0}\right)\) 是二阶导数的矩阵(Hessian)。如果 Hessian 是正定的,则可以通过将二次形式的梯度设为零来找到此函数的局部最小值,得到

\[\mathbf{x}_{\textrm{opt}}=\mathbf{x}_{0}-\mathbf{H}^{-1}\nabla f.\]

使用共轭梯度法计算 Hessian 的逆。下面给出了使用此方法最小化 Rosenbrock 函数的示例。为了充分利用牛顿共轭梯度法,必须提供计算 Hessian 的函数。不需要构造 Hessian 矩阵本身,只需将 Hessian 与任意向量的乘积作为向量提供给最小化例程即可。因此,用户可以提供一个计算 Hessian 矩阵的函数,或一个计算 Hessian 与任意向量的乘积的函数。

完整的黑塞矩阵示例:#

Rosenbrock 函数的黑塞矩阵为

\begin{eqnarray*} H_{ij}=\frac{\partial^{2}f}{\partial x_{i}\partial x_{j}} & = & 200\left(\delta_{i,j}-2x_{i-1}\delta_{i-1,j}\right)-400x_{i}\left(\delta_{i+1,j}-2x_{i}\delta_{i,j}\right)-400\delta_{i,j}\left(x_{i+1}-x_{i}^{2}\right)+2\delta_{i,j},\\ & = & \left(202+1200x_{i}^{2}-400x_{i+1}\right)\delta_{i,j}-400x_{i}\delta_{i+1,j}-400x_{i-1}\delta_{i-1,j},\end{eqnarray*}

如果 \(i,j\in\left[1,N-2\right]\),其中 \(i,j\in\left[0,N-1\right]\) 定义 \(N\times N\) 矩阵。矩阵的其他非零项为

\begin{eqnarray*} \frac{\partial^{2}f}{\partial x_{0}^{2}} & = & 1200x_{0}^{2}-400x_{1}+2,\\ \frac{\partial^{2}f}{\partial x_{0}\partial x_{1}}=\frac{\partial^{2}f}{\partial x_{1}\partial x_{0}} & = & -400x_{0},\\ \frac{\partial^{2}f}{\partial x_{N-1}\partial x_{N-2}}=\frac{\partial^{2}f}{\partial x_{N-2}\partial x_{N-1}} & = & -400x_{N-2},\\ \frac{\partial^{2}f}{\partial x_{N-1}^{2}} & = & 200.\end{eqnarray*}

例如,当 \(N=5\) 时,黑塞矩阵为

\[\begin{split}\mathbf{H}=\begin{bmatrix} 1200x_{0}^{2}+2\mkern-2em\\&1200x_{1}^{2}+202\mkern-2em\\&&1200x_{1}^{2}+202\mkern-2em\\&&&1200x_{3}^{2}+202\mkern-1em\\&&&&200\end{bmatrix}-400\begin{bmatrix} x_1 & x_0 \\ x_0 & x_2 & x_1 \\ & x_1 & x_3 & x_2\\ & & x_2 & x_4 & x_3 \\ & & & x_3 & 0\end{bmatrix}.\end{split}\]

以下示例中显示了计算此黑塞矩阵的代码以及使用 Newton-CG 方法最小化函数的代码

>>> def rosen_hess(x):
...     x = np.asarray(x)
...     H = np.diag(-400*x[:-1],1) - np.diag(400*x[:-1],-1)
...     diagonal = np.zeros_like(x)
...     diagonal[0] = 1200*x[0]**2-400*x[1]+2
...     diagonal[-1] = 200
...     diagonal[1:-1] = 202 + 1200*x[1:-1]**2 - 400*x[2:]
...     H = H + np.diag(diagonal)
...     return H
>>> res = minimize(rosen, x0, method='Newton-CG',
...                jac=rosen_der, hess=rosen_hess,
...                options={'xtol': 1e-8, 'disp': True})
Optimization terminated successfully.
         Current function value: 0.000000
         Iterations: 19                       # may vary
         Function evaluations: 22
         Gradient evaluations: 19
         Hessian evaluations: 19
>>> res.x
array([1.,  1.,  1.,  1.,  1.])

黑塞矩阵乘积示例:#

对于较大的最小化问题,存储整个黑塞矩阵会消耗大量时间和内存。Newton-CG 算法仅需要黑塞矩阵与任意向量的乘积。因此,用户可以通过提供一个 hess 函数来计算此乘积,而不是整个黑塞矩阵,该函数将最小化向量作为第一个参数,将任意向量作为第二个参数(以及传递给要最小化的函数的额外参数)。如果可能,使用带有黑塞矩阵乘积选项的 Newton-CG 可能是最小化函数的最快速方法。

在这种情况下,用任意向量计算 Rosenbrock Hessian 乘积并不困难。如果 \(\mathbf{p}\) 是任意向量,则 \(\mathbf{H}\left(\mathbf{x}\right)\mathbf{p}\) 的元素为

\[\begin{split}\mathbf{H}\left(\mathbf{x}\right)\mathbf{p}=\begin{bmatrix} \left(1200x_{0}^{2}-400x_{1}+2\right)p_{0}-400x_{0}p_{1}\\ \vdots\\ -400x_{i-1}p_{i-1}+\left(202+1200x_{i}^{2}-400x_{i+1}\right)p_{i}-400x_{i}p_{i+1}\\ \vdots\\ -400x_{N-2}p_{N-2}+200p_{N-1}\end{bmatrix}.\end{split}\]

利用此 Hessian 乘积最小化 Rosenbrock 函数的代码使用 minimize 如下

>>> def rosen_hess_p(x, p):
...     x = np.asarray(x)
...     Hp = np.zeros_like(x)
...     Hp[0] = (1200*x[0]**2 - 400*x[1] + 2)*p[0] - 400*x[0]*p[1]
...     Hp[1:-1] = -400*x[:-2]*p[:-2]+(202+1200*x[1:-1]**2-400*x[2:])*p[1:-1] \
...                -400*x[1:-1]*p[2:]
...     Hp[-1] = -400*x[-2]*p[-2] + 200*p[-1]
...     return Hp
>>> res = minimize(rosen, x0, method='Newton-CG',
...                jac=rosen_der, hessp=rosen_hess_p,
...                options={'xtol': 1e-8, 'disp': True})
Optimization terminated successfully.
         Current function value: 0.000000
         Iterations: 20                    # may vary
         Function evaluations: 23
         Gradient evaluations: 20
         Hessian evaluations: 44
>>> res.x
array([1., 1., 1., 1., 1.])

根据 [NW] 第 170 页,当 Hessian 病态时,Newton-CG 算法可能效率低下,因为在这种情况下,该方法提供的搜索方向质量较差。作者认为,trust-ncg 方法能更有效地处理这种情况,接下来将介绍该方法。

信任域牛顿共轭梯度算法 (method='trust-ncg')#

Newton-CG 方法是一种线搜索方法:它找到一个搜索方向,最小化函数的二次近似,然后使用线搜索算法在该方向上找到(近似)最优步长。另一种方法是,首先固定步长限制 \(\Delta\),然后通过求解以下二次子问题,在给定的信任半径内找到最优步长 \(\mathbf{p}\)

\begin{eqnarray*} \min_{\mathbf{p}} f\left(\mathbf{x}_{k}\right)+\nabla f\left(\mathbf{x}_{k}\right)\cdot\mathbf{p}+\frac{1}{2}\mathbf{p}^{T}\mathbf{H}\left(\mathbf{x}_{k}\right)\mathbf{p};&\\ \text{subject to: } \|\mathbf{p}\|\le \Delta.& \end{eqnarray*}

然后更新解 \(\mathbf{x}_{k+1} = \mathbf{x}_{k} + \mathbf{p}\),并根据二次模型与实际函数的一致性程度调整信任半径 \(\Delta\)。这类方法称为信任域方法。trust-ncg 算法是一种信任域方法,它使用共轭梯度算法来求解信任域子问题 [NW]

完整 Hessian 示例:#

>>> res = minimize(rosen, x0, method='trust-ncg',
...                jac=rosen_der, hess=rosen_hess,
...                options={'gtol': 1e-8, 'disp': True})
Optimization terminated successfully.
         Current function value: 0.000000
         Iterations: 20                    # may vary
         Function evaluations: 21
         Gradient evaluations: 20
         Hessian evaluations: 19
>>> res.x
array([1., 1., 1., 1., 1.])

Hessian 乘积示例:#

>>> res = minimize(rosen, x0, method='trust-ncg',
...                jac=rosen_der, hessp=rosen_hess_p,
...                options={'gtol': 1e-8, 'disp': True})
Optimization terminated successfully.
         Current function value: 0.000000
         Iterations: 20                    # may vary
         Function evaluations: 21
         Gradient evaluations: 20
         Hessian evaluations: 0
>>> res.x
array([1., 1., 1., 1., 1.])

信赖域截断广义 Lanczos/共轭梯度算法 (method='trust-krylov')#

trust-ncg 方法类似,trust-krylov 方法适用于大规模问题,因为它仅通过矩阵-向量乘积将 Hessian 作为线性算子使用。它比 trust-ncg 方法更准确地求解二次子问题。

\begin{eqnarray*} \min_{\mathbf{p}} f\left(\mathbf{x}_{k}\right)+\nabla f\left(\mathbf{x}_{k}\right)\cdot\mathbf{p}+\frac{1}{2}\mathbf{p}^{T}\mathbf{H}\left(\mathbf{x}_{k}\right)\mathbf{p};&\\ \text{subject to: } \|\mathbf{p}\|\le \Delta.& \end{eqnarray*}

此方法封装了 [TRLIB][GLTR] 方法的实现,该方法精确求解限制在截断 Krylov 子空间中的信赖域子问题。对于不定问题,通常最好使用此方法,因为它会以每个子问题求解中更多矩阵-向量乘积为代价,减少非线性迭代次数,与 trust-ncg 方法相比。

完整 Hessian 示例:#

>>> res = minimize(rosen, x0, method='trust-krylov',
...                jac=rosen_der, hess=rosen_hess,
...                options={'gtol': 1e-8, 'disp': True})
Optimization terminated successfully.
         Current function value: 0.000000
         Iterations: 19                    # may vary
         Function evaluations: 20
         Gradient evaluations: 20
         Hessian evaluations: 18
>>> res.x
array([1., 1., 1., 1., 1.])

Hessian 乘积示例:#

>>> res = minimize(rosen, x0, method='trust-krylov',
...                jac=rosen_der, hessp=rosen_hess_p,
...                options={'gtol': 1e-8, 'disp': True})
Optimization terminated successfully.
         Current function value: 0.000000
         Iterations: 19                    # may vary
         Function evaluations: 20
         Gradient evaluations: 20
         Hessian evaluations: 0
>>> res.x
array([1., 1., 1., 1., 1.])
[TRLIB]

F. Lenders、C. Kirches、A. Potschka:“trlib:GLTR 方法的无向量实现,用于迭代求解信赖域问题”,arXiv:1611.04718

[GLTR]

N. Gould、S. Lucidi、M. Roma、P. Toint:“使用 Lanczos 方法求解信赖域子问题”,SIAM J. Optim.,9(2),504–525,(1999)。DOI:10.1137/S1052623497322735

信赖域近似精确算法 (method='trust-exact')#

所有方法 Newton-CGtrust-ncgtrust-krylov 都适用于处理大规模问题(具有数千个变量的问题)。这是因为共轭梯度算法通过迭代近似求解信赖域子问题(或对 Hessian 求逆),而无需显式 Hessian 分解。由于只需要 Hessian 与任意向量的乘积,因此该算法特别适用于处理稀疏 Hessian,从而降低了稀疏问题的存储要求并节省了大量时间。

对于中等规模的问题,其中 Hessian 的存储和分解成本并不关键,可以通过几乎精确地解决信任区域子问题来在更少的迭代中获得解决方案。为实现此目的,针对每个二次子问题迭代求解某个非线性方程 [CGT]。此解决方案通常需要 Hessian 矩阵的 3 或 4 个 Cholesky 分解。因此,该方法在较少的迭代次数中收敛,并且比其他已实现的信任区域方法需要更少的目标函数评估。此算法不支持 Hessian 乘积选项。下面是一个使用 Rosenbrock 函数的示例

>>> res = minimize(rosen, x0, method='trust-exact',
...                jac=rosen_der, hess=rosen_hess,
...                options={'gtol': 1e-8, 'disp': True})
Optimization terminated successfully.
         Current function value: 0.000000
         Iterations: 13                    # may vary
         Function evaluations: 14
         Gradient evaluations: 13
         Hessian evaluations: 14
>>> res.x
array([1., 1., 1., 1., 1.])
[NW] (1,2,3)

J. Nocedal, S.J. Wright “Numerical optimization.” 2nd edition. Springer Science (2006).

[CGT]

Conn, A. R., Gould, N. I., & Toint, P. L. “Trust region methods”. Siam. (2000). pp. 169-200.

多变量标量函数的约束最小化 (minimize)#

minimize 函数提供了约束最小化算法,即 'trust-constr''SLSQP''COBYLA'。它们要求使用略有不同的结构来定义约束。方法 'trust-constr' 要求将约束定义为对象序列 LinearConstraintNonlinearConstraint。另一方面,方法 'SLSQP''COBYLA' 要求将约束定义为字典序列,其中键为 typefunjac

作为一个示例,让我们考虑 Rosenbrock 函数的约束最小化

\begin{eqnarray*} \min_{x_0, x_1} & ~~100\left(x_{1}-x_{0}^{2}\right)^{2}+\left(1-x_{0}\right)^{2} &\\ \text{subject to: } & x_0 + 2 x_1 \leq 1 & \\ & x_0^2 + x_1 \leq 1 & \\ & x_0^2 - x_1 \leq 1 & \\ & 2 x_0 + x_1 = 1 & \\ & 0 \leq x_0 \leq 1 & \\ & -0.5 \leq x_1 \leq 2.0. & \end{eqnarray*}

此优化问题的唯一解为 \([x_0, x_1] = [0.4149,~ 0.1701]\),其中仅第一个和第四个约束为活动约束。

信赖域约束算法 (method='trust-constr')#

信赖域约束方法处理形式如下所示的约束最小化问题

\begin{eqnarray*} \min_x & f(x) & \\ \text{subject to: } & ~~~ c^l \leq c(x) \leq c^u, &\\ & x^l \leq x \leq x^u. & \end{eqnarray*}

\(c^l_j = c^u_j\) 时,该方法将 \(j\) -th 约束视为等式约束并相应地处理它。除此之外,可以通过将上限或下限设置为带适当符号的 np.inf 来指定单边约束。

该实现基于 [EQSQP](用于等式约束问题)和 [TRIP](用于具有不等式约束的问题)。两者都是适用于大规模问题的信赖域类型算法。

定义边界约束:#

边界约束 \(0 \leq x_0 \leq 1\)\(-0.5 \leq x_1 \leq 2.0\) 使用 Bounds 对象进行定义。

>>> from scipy.optimize import Bounds
>>> bounds = Bounds([0, -0.5], [1.0, 2.0])

定义线性约束:#

约束 \(x_0 + 2 x_1 \leq 1\)\(2 x_0 + x_1 = 1\) 可以用线性约束标准格式编写

\begin{equation*} \begin{bmatrix}-\infty \\1\end{bmatrix} \leq \begin{bmatrix} 1& 2 \\ 2& 1\end{bmatrix} \begin{bmatrix} x_0 \\x_1\end{bmatrix} \leq \begin{bmatrix} 1 \\ 1\end{bmatrix},\end{equation*}

并使用 LinearConstraint 对象进行定义。

>>> from scipy.optimize import LinearConstraint
>>> linear_constraint = LinearConstraint([[1, 2], [2, 1]], [-np.inf, 1], [1, 1])

定义非线性约束:#

非线性约束

\begin{equation*} c(x) = \begin{bmatrix} x_0^2 + x_1 \\ x_0^2 - x_1\end{bmatrix} \leq \begin{bmatrix} 1 \\ 1\end{bmatrix}, \end{equation*}

雅可比矩阵

\begin{equation*} J(x) = \begin{bmatrix} 2x_0 & 1 \\ 2x_0 & -1\end{bmatrix},\end{equation*}

海森矩阵的线性组合

\begin{equation*} H(x, v) = \sum_{i=0}^1 v_i \nabla^2 c_i(x) = v_0\begin{bmatrix} 2 & 0 \\ 0 & 0\end{bmatrix} + v_1\begin{bmatrix} 2 & 0 \\ 0 & 0\end{bmatrix}, \end{equation*}

使用 NonlinearConstraint 对象定义。

>>> def cons_f(x):
...     return [x[0]**2 + x[1], x[0]**2 - x[1]]
>>> def cons_J(x):
...     return [[2*x[0], 1], [2*x[0], -1]]
>>> def cons_H(x, v):
...     return v[0]*np.array([[2, 0], [0, 0]]) + v[1]*np.array([[2, 0], [0, 0]])
>>> from scipy.optimize import NonlinearConstraint
>>> nonlinear_constraint = NonlinearConstraint(cons_f, -np.inf, 1, jac=cons_J, hess=cons_H)

或者,也可以将海森矩阵 \(H(x, v)\) 定义为稀疏矩阵,

>>> from scipy.sparse import csc_matrix
>>> def cons_H_sparse(x, v):
...     return v[0]*csc_matrix([[2, 0], [0, 0]]) + v[1]*csc_matrix([[2, 0], [0, 0]])
>>> nonlinear_constraint = NonlinearConstraint(cons_f, -np.inf, 1,
...                                            jac=cons_J, hess=cons_H_sparse)

LinearOperator 对象。

>>> from scipy.sparse.linalg import LinearOperator
>>> def cons_H_linear_operator(x, v):
...     def matvec(p):
...         return np.array([p[0]*2*(v[0]+v[1]), 0])
...     return LinearOperator((2, 2), matvec=matvec)
>>> nonlinear_constraint = NonlinearConstraint(cons_f, -np.inf, 1,
...                                           jac=cons_J, hess=cons_H_linear_operator)

如果海森矩阵 \(H(x, v)\) 的评估难以实现或计算不可行,可以使用 HessianUpdateStrategy。当前可用的策略是 BFGSSR1

>>> from scipy.optimize import BFGS
>>> nonlinear_constraint = NonlinearConstraint(cons_f, -np.inf, 1, jac=cons_J, hess=BFGS())

或者,可以使用有限差分近似海森矩阵。

>>> nonlinear_constraint = NonlinearConstraint(cons_f, -np.inf, 1, jac=cons_J, hess='2-point')

约束的雅可比矩阵也可以用有限差分近似。然而,在这种情况下,无法使用有限差分计算海森矩阵,需要用户提供或使用 HessianUpdateStrategy 定义。

>>> nonlinear_constraint = NonlinearConstraint(cons_f, -np.inf, 1, jac='2-point', hess=BFGS())

求解优化问题:#

使用以下方法求解优化问题

>>> x0 = np.array([0.5, 0])
>>> res = minimize(rosen, x0, method='trust-constr', jac=rosen_der, hess=rosen_hess,
...                constraints=[linear_constraint, nonlinear_constraint],
...                options={'verbose': 1}, bounds=bounds)
# may vary
`gtol` termination condition is satisfied.
Number of iterations: 12, function evaluations: 8, CG iterations: 7, optimality: 2.99e-09, constraint violation: 1.11e-16, execution time: 0.016 s.
>>> print(res.x)
[0.41494531 0.17010937]

需要时,可以使用 LinearOperator 对象定义目标函数海森矩阵,

>>> def rosen_hess_linop(x):
...     def matvec(p):
...         return rosen_hess_p(x, p)
...     return LinearOperator((2, 2), matvec=matvec)
>>> res = minimize(rosen, x0, method='trust-constr', jac=rosen_der, hess=rosen_hess_linop,
...                constraints=[linear_constraint, nonlinear_constraint],
...                options={'verbose': 1}, bounds=bounds)
# may vary
`gtol` termination condition is satisfied.
Number of iterations: 12, function evaluations: 8, CG iterations: 7, optimality: 2.99e-09, constraint violation: 1.11e-16, execution time: 0.018 s.
>>> print(res.x)
[0.41494531 0.17010937]

或通过参数 hessp 进行 Hessian 向量乘积。

>>> res = minimize(rosen, x0, method='trust-constr', jac=rosen_der, hessp=rosen_hess_p,
...                constraints=[linear_constraint, nonlinear_constraint],
...                options={'verbose': 1}, bounds=bounds)
# may vary
`gtol` termination condition is satisfied.
Number of iterations: 12, function evaluations: 8, CG iterations: 7, optimality: 2.99e-09, constraint violation: 1.11e-16, execution time: 0.018 s.
>>> print(res.x)
[0.41494531 0.17010937]

或者,可以近似目标函数的一阶和二阶导数。例如,Hessian 可以用 SR1 拟牛顿近似来近似,梯度可以用有限差分来近似。

>>> from scipy.optimize import SR1
>>> res = minimize(rosen, x0, method='trust-constr',  jac="2-point", hess=SR1(),
...                constraints=[linear_constraint, nonlinear_constraint],
...                options={'verbose': 1}, bounds=bounds)
# may vary
`gtol` termination condition is satisfied.
Number of iterations: 12, function evaluations: 24, CG iterations: 7, optimality: 4.48e-09, constraint violation: 0.00e+00, execution time: 0.016 s.
>>> print(res.x)
[0.41494531 0.17010937]
[TRIP]

Byrd, Richard H., Mary E. Hribar 和 Jorge Nocedal。1999 年。用于大规模非线性规划的内点算法。SIAM 优化杂志 9.4:877-900。

[EQSQP]

Lalee, Marucha, Jorge Nocedal 和 Todd Plantega。1998 年。关于大规模等式约束优化算法的实现。SIAM 优化杂志 8.3:682-706。

顺序最小二乘规划 (SLSQP) 算法 (method='SLSQP')#

SLSQP 方法处理以下形式的约束最小化问题:

\begin{eqnarray*} \min_x & f(x) \\ \text{subject to: } & c_j(x) = 0 , &j \in \mathcal{E}\\ & c_j(x) \geq 0 , &j \in \mathcal{I}\\ & \text{lb}_i \leq x_i \leq \text{ub}_i , &i = 1,...,N. \end{eqnarray*}

其中 \(\mathcal{E}\)\(\mathcal{I}\) 是包含等式和不等式约束的索引集。

线性约束和非线性约束都定义为具有键 typefunjac 的字典。

>>> ineq_cons = {'type': 'ineq',
...              'fun' : lambda x: np.array([1 - x[0] - 2*x[1],
...                                          1 - x[0]**2 - x[1],
...                                          1 - x[0]**2 + x[1]]),
...              'jac' : lambda x: np.array([[-1.0, -2.0],
...                                          [-2*x[0], -1.0],
...                                          [-2*x[0], 1.0]])}
>>> eq_cons = {'type': 'eq',
...            'fun' : lambda x: np.array([2*x[0] + x[1] - 1]),
...            'jac' : lambda x: np.array([2.0, 1.0])}

优化问题用以下方法解决:

>>> x0 = np.array([0.5, 0])
>>> res = minimize(rosen, x0, method='SLSQP', jac=rosen_der,
...                constraints=[eq_cons, ineq_cons], options={'ftol': 1e-9, 'disp': True},
...                bounds=bounds)
# may vary
Optimization terminated successfully.    (Exit mode 0)
            Current function value: 0.342717574857755
            Iterations: 5
            Function evaluations: 6
            Gradient evaluations: 5
>>> print(res.x)
[0.41494475 0.1701105 ]

方法 'trust-constr' 可用的选项大部分对 'SLSQP' 不可用。

全局优化#

全局优化旨在找到给定边界内函数的全局最小值,即使存在许多局部最小值。通常,全局最小化器会有效地搜索参数空间,同时使用局部最小化器(例如,minimize)。SciPy 包含许多良好的全局优化器。在此,我们将在相同的目标函数上使用它们,即(恰如其名)eggholder 函数

>>> def eggholder(x):
...     return (-(x[1] + 47) * np.sin(np.sqrt(abs(x[0]/2 + (x[1]  + 47))))
...             -x[0] * np.sin(np.sqrt(abs(x[0] - (x[1]  + 47)))))

>>> bounds = [(-512, 512), (-512, 512)]

此函数看起来像一个鸡蛋盒

>>> import matplotlib.pyplot as plt
>>> from mpl_toolkits.mplot3d import Axes3D

>>> x = np.arange(-512, 513)
>>> y = np.arange(-512, 513)
>>> xgrid, ygrid = np.meshgrid(x, y)
>>> xy = np.stack([xgrid, ygrid])

>>> fig = plt.figure()
>>> ax = fig.add_subplot(111, projection='3d')
>>> ax.view_init(45, -45)
>>> ax.plot_surface(xgrid, ygrid, eggholder(xy), cmap='terrain')
>>> ax.set_xlabel('x')
>>> ax.set_ylabel('y')
>>> ax.set_zlabel('eggholder(x, y)')
>>> plt.show()
"A 3-D plot shown from a three-quarter view. The function is very noisy with dozens of valleys and peaks. There is no clear min or max discernible from this view and it's not possible to see all the local peaks and valleys from this view."

我们现在使用全局优化器来获取最小值和最小值处的函数值。我们将把结果存储在字典中,以便稍后比较不同的优化结果。

>>> from scipy import optimize
>>> results = dict()
>>> results['shgo'] = optimize.shgo(eggholder, bounds)
>>> results['shgo']
     fun: -935.3379515604197  # may vary
    funl: array([-935.33795156])
 message: 'Optimization terminated successfully.'
    nfev: 42
     nit: 2
   nlfev: 37
   nlhev: 0
   nljev: 9
 success: True
       x: array([439.48096952, 453.97740589])
      xl: array([[439.48096952, 453.97740589]])
>>> results['DA'] = optimize.dual_annealing(eggholder, bounds)
>>> results['DA']
     fun: -956.9182316237413  # may vary
 message: ['Maximum number of iteration reached']
    nfev: 4091
    nhev: 0
     nit: 1000
    njev: 0
       x: array([482.35324114, 432.87892901])

所有优化器都返回一个 OptimizeResult,除了解决方案之外,它还包含有关函数评估次数、优化是否成功等信息。为简洁起见,我们不会显示其他优化器的完整输出

>>> results['DE'] = optimize.differential_evolution(eggholder, bounds)

shgo 有第二个方法,它返回所有局部最小值,而不是它认为的全局最小值

>>> results['shgo_sobol'] = optimize.shgo(eggholder, bounds, n=200, iters=5,
...                                       sampling_method='sobol')

我们现在将在函数的热图上绘制所有找到的最小值

>>> fig = plt.figure()
>>> ax = fig.add_subplot(111)
>>> im = ax.imshow(eggholder(xy), interpolation='bilinear', origin='lower',
...                cmap='gray')
>>> ax.set_xlabel('x')
>>> ax.set_ylabel('y')
>>>
>>> def plot_point(res, marker='o', color=None):
...     ax.plot(512+res.x[0], 512+res.x[1], marker=marker, color=color, ms=10)

>>> plot_point(results['DE'], color='c')  # differential_evolution - cyan
>>> plot_point(results['DA'], color='w')  # dual_annealing.        - white

>>> # SHGO produces multiple minima, plot them all (with a smaller marker size)
>>> plot_point(results['shgo'], color='r', marker='+')
>>> plot_point(results['shgo_sobol'], color='r', marker='x')
>>> for i in range(results['shgo_sobol'].xl.shape[0]):
...     ax.plot(512 + results['shgo_sobol'].xl[i, 0],
...             512 + results['shgo_sobol'].xl[i, 1],
...             'ro', ms=2)

>>> ax.set_xlim([-4, 514*2])
>>> ax.set_ylim([-4, 514*2])
>>> plt.show()
"This X-Y plot is a heatmap with the Z value denoted with the lowest points as black and the highest values as white. The image resembles a chess board rotated 45 degrees but heavily smoothed. A red dot is located at many of the minima on the grid resulting from the SHGO optimizer. SHGO shows the global minima as a red X in the top right. A local minima found with dual annealing is a white circle marker in the top left. A different local minima found with basinhopping is a yellow marker in the top center. The code is plotting the differential evolution result as a cyan circle, but it is not visible on the plot. At a glance it's not clear which of these valleys is the true global minima."

最小二乘法最小化 (least_squares)#

SciPy 能够解决鲁棒化的有界非线性最小二乘问题

\begin{align} &\min_\mathbf{x} \frac{1}{2} \sum_{i = 1}^m \rho\left(f_i(\mathbf{x})^2\right) \\ &\text{subject to }\mathbf{lb} \leq \mathbf{x} \leq \mathbf{ub} \end{align}

其中 \(f_i(\mathbf{x})\) 是从 \(\mathbb{R}^n\)\(\mathbb{R}\) 的平滑函数,我们称它们为残差。标量值函数 \(\rho(\cdot)\) 的目的是减少异常值残差的影响并有助于解决方案的鲁棒性,我们称之为损失函数。线性损失函数给出了标准最小二乘问题。此外,允许对某些 \(x_j\) 采用下限和上限形式的约束。

所有特定于最小二乘法最小化的方法都利用一个称为雅可比的偏导数的 \(m \times n\) 矩阵,定义为 \(J_{ij} = \partial f_i / \partial x_j\)。强烈建议分析计算此矩阵并将其传递给 least_squares,否则,它将通过有限差分进行估计,这需要大量额外时间,并且在困难的情况下可能非常不准确。

函数 least_squares 可用于拟合函数 \(\varphi(t; \mathbf{x})\) 至经验数据 \(\{(t_i, y_i), i = 0, \ldots, m-1\}\)。为此,只需将残差预先计算为 \(f_i(\mathbf{x}) = w_i (\varphi(t_i; \mathbf{x}) - y_i)\),其中 \(w_i\) 是分配给每个观测的权重。

求解拟合问题的示例#

此处我们考虑酶促反应 [1]。有 11 个残差定义为

\[f_i(x) = \frac{x_0 (u_i^2 + u_i x_1)}{u_i^2 + u_i x_2 + x_3} - y_i, \quad i = 0, \ldots, 10,\]

其中 \(y_i\) 是测量值,\(u_i\) 是自变量的值。未知参数向量为 \(\mathbf{x} = (x_0, x_1, x_2, x_3)^T\)。如前所述,建议以封闭形式计算雅可比矩阵

\begin{align} &J_{i0} = \frac{\partial f_i}{\partial x_0} = \frac{u_i^2 + u_i x_1}{u_i^2 + u_i x_2 + x_3} \\ &J_{i1} = \frac{\partial f_i}{\partial x_1} = \frac{u_i x_0}{u_i^2 + u_i x_2 + x_3} \\ &J_{i2} = \frac{\partial f_i}{\partial x_2} = -\frac{x_0 (u_i^2 + u_i x_1) u_i}{(u_i^2 + u_i x_2 + x_3)^2} \\ &J_{i3} = \frac{\partial f_i}{\partial x_3} = -\frac{x_0 (u_i^2 + u_i x_1)}{(u_i^2 + u_i x_2 + x_3)^2} \end{align}

我们将使用 [2] 中定义的“困难”起始点。为了找到一个有意义的物理解决方案,避免潜在的零除并确保收敛到全局最小值,我们施加约束 \(0 \leq x_j \leq 100, j = 0, 1, 2, 3\)

以下代码实现了 \(\mathbf{x}\) 的最小二乘估计,并最终绘制原始数据和拟合模型函数

>>> from scipy.optimize import least_squares
>>> def model(x, u):
...     return x[0] * (u ** 2 + x[1] * u) / (u ** 2 + x[2] * u + x[3])
>>> def fun(x, u, y):
...     return model(x, u) - y
>>> def jac(x, u, y):
...     J = np.empty((u.size, x.size))
...     den = u ** 2 + x[2] * u + x[3]
...     num = u ** 2 + x[1] * u
...     J[:, 0] = num / den
...     J[:, 1] = x[0] * u / den
...     J[:, 2] = -x[0] * num * u / den ** 2
...     J[:, 3] = -x[0] * num / den ** 2
...     return J
>>> u = np.array([4.0, 2.0, 1.0, 5.0e-1, 2.5e-1, 1.67e-1, 1.25e-1, 1.0e-1,
...               8.33e-2, 7.14e-2, 6.25e-2])
>>> y = np.array([1.957e-1, 1.947e-1, 1.735e-1, 1.6e-1, 8.44e-2, 6.27e-2,
...               4.56e-2, 3.42e-2, 3.23e-2, 2.35e-2, 2.46e-2])
>>> x0 = np.array([2.5, 3.9, 4.15, 3.9])
>>> res = least_squares(fun, x0, jac=jac, bounds=(0, 100), args=(u, y), verbose=1)
# may vary
`ftol` termination condition is satisfied.
Function evaluations 130, initial cost 4.4383e+00, final cost 1.5375e-04, first-order optimality 4.92e-08.
>>> res.x
array([ 0.19280596,  0.19130423,  0.12306063,  0.13607247])
>>> import matplotlib.pyplot as plt
>>> u_test = np.linspace(0, 5)
>>> y_test = model(res.x, u_test)
>>> plt.plot(u, y, 'o', markersize=4, label='data')
>>> plt.plot(u_test, y_test, label='fitted model')
>>> plt.xlabel("u")
>>> plt.ylabel("y")
>>> plt.legend(loc='lower right')
>>> plt.show()
"This code plots an X-Y time-series. The series starts in the lower left at (0, 0) and rapidly trends up to the maximum of 0.2 then flattens out. The fitted model is shown as a smooth orange trace and is well fit to the data."

更多示例#

以下三个交互式示例详细说明了 least_squares 的用法。

  1. scipy 中的大规模束调整演示了 least_squares 的大规模功能以及如何有效地计算稀疏雅可比的有限差分近似。

  2. scipy 中的稳健非线性回归展示了如何在非线性回归中使用稳健损失函数来处理异常值。

  3. 在 scipy 中求解离散边值问题探讨了如何求解大型方程组并使用边界来实现解的所需属性。

有关实现背后的数学算法的详细信息,请参阅 least_squares 的文档。

单变量函数最小化器 (minimize_scalar)#

通常只需要单变量函数(即,将标量作为输入的函数)的最小值。在这些情况下,已经开发出可以更快工作的其他优化技术。这些技术可以通过 minimize_scalar 函数访问,该函数提出了几种算法。

无约束最小化 (method='brent')#

实际上,有两种方法可用于最小化单变量函数:brentgolden,但 golden 仅出于学术目的而包含,很少使用。这些方法可分别通过 minimize_scalar 中的 method 参数进行选择。 brent 方法使用 Brent 算法来查找最小值。最优情况下,应给出包含所需最小值的括号(bracket 参数)。括号是一个三元组 \(\left( a, b, c \right)\),其中 \(f \left( a \right) > f \left( b \right) < f \left( c \right)\)\(a < b < c\) 。如果未给出此项,则可以选择两个起始点,并使用简单的行进算法从这些点中找到括号。如果未提供这两个起始点,则将使用 01(这可能不是函数的正确选择,并导致返回意外的最小值)。

以下是一个示例

>>> from scipy.optimize import minimize_scalar
>>> f = lambda x: (x - 2) * (x + 1)**2
>>> res = minimize_scalar(f, method='brent')
>>> print(res.x)
1.0

有界最小化 (method='bounded')#

通常,在最小化发生之前,可以对解空间施加约束。 minimize_scalar 中的 bounded 方法是受约束最小化过程的一个示例,它为标量函数提供了基本的区间约束。区间约束允许最小化仅在两个固定端点之间发生,使用强制 bounds 参数指定这些端点。

例如,若要找到 \(J_{1}\left( x \right)\) 附近 \(x=5\) 的最小值,minimize_scalar 可使用区间 \(\left[ 4, 7 \right]\) 作为约束条件。结果为 \(x_{\textrm{min}}=5.3314\)

>>> from scipy.special import j1
>>> res = minimize_scalar(j1, bounds=(4, 7), method='bounded')
>>> res.x
5.33144184241

自定义最小化器#

有时,使用自定义方法作为(多元或单变量)最小化器可能很有用,例如,在使用 minimize 的一些库包装器(例如,basinhopping)时。

我们可以通过传递一个可调用对象(函数或实现 __call__ 方法的对象)作为 method 参数,而不是传递一个方法名称来实现这一点。

让我们考虑一个(公认的相当虚拟的)需求,即使用一个简单的自定义多元最小化方法,该方法将仅以固定的步长大小在每个维度中独立搜索邻域

>>> from scipy.optimize import OptimizeResult
>>> def custmin(fun, x0, args=(), maxfev=None, stepsize=0.1,
...         maxiter=100, callback=None, **options):
...     bestx = x0
...     besty = fun(x0)
...     funcalls = 1
...     niter = 0
...     improved = True
...     stop = False
...
...     while improved and not stop and niter < maxiter:
...         improved = False
...         niter += 1
...         for dim in range(np.size(x0)):
...             for s in [bestx[dim] - stepsize, bestx[dim] + stepsize]:
...                 testx = np.copy(bestx)
...                 testx[dim] = s
...                 testy = fun(testx, *args)
...                 funcalls += 1
...                 if testy < besty:
...                     besty = testy
...                     bestx = testx
...                     improved = True
...             if callback is not None:
...                 callback(bestx)
...             if maxfev is not None and funcalls >= maxfev:
...                 stop = True
...                 break
...
...     return OptimizeResult(fun=besty, x=bestx, nit=niter,
...                           nfev=funcalls, success=(niter > 1))
>>> x0 = [1.35, 0.9, 0.8, 1.1, 1.2]
>>> res = minimize(rosen, x0, method=custmin, options=dict(stepsize=0.05))
>>> res.x
array([1., 1., 1., 1., 1.])

在单变量优化的情况下,这同样有效

>>> def custmin(fun, bracket, args=(), maxfev=None, stepsize=0.1,
...         maxiter=100, callback=None, **options):
...     bestx = (bracket[1] + bracket[0]) / 2.0
...     besty = fun(bestx)
...     funcalls = 1
...     niter = 0
...     improved = True
...     stop = False
...
...     while improved and not stop and niter < maxiter:
...         improved = False
...         niter += 1
...         for testx in [bestx - stepsize, bestx + stepsize]:
...             testy = fun(testx, *args)
...             funcalls += 1
...             if testy < besty:
...                 besty = testy
...                 bestx = testx
...                 improved = True
...         if callback is not None:
...             callback(bestx)
...         if maxfev is not None and funcalls >= maxfev:
...             stop = True
...             break
...
...     return OptimizeResult(fun=besty, x=bestx, nit=niter,
...                           nfev=funcalls, success=(niter > 1))
>>> def f(x):
...    return (x - 2)**2 * (x + 2)**2
>>> res = minimize_scalar(f, bracket=(-3.5, 0), method=custmin,
...                       options=dict(stepsize = 0.05))
>>> res.x
-2.0

根寻找#

标量函数#

如果有一个单变量方程,则可以尝试多种不同的根查找算法。其中大多数算法都需要一个预期根所在的区间端点(因为函数会改变符号)。通常,brentq 是最佳选择,但其他方法在某些情况下或出于学术目的可能有用。当没有可用括号,但有一个或多个导数可用时,newton(或halleysecant)可能适用。如果函数在复平面的子集上定义,并且无法使用括号方法,则尤其如此。

定点求解#

与求解函数零点密切相关的一个问题是求解函数定点的问题。函数的定点是函数求值返回该点的点:\(g\left(x\right)=x.\)显然,\(g\) 的定点是\(f\left(x\right)=g\left(x\right)-x.\) 的根。等价地,\(f\) 的根是\(g\left(x\right)=f\left(x\right)+x.\) 的定点。例程fixed_point提供了一种简单的迭代方法,使用 Aitken 序列加速来估计给定起始点的\(g\) 的定点。

方程组#

可以使用 root 函数找到一组非线性方程的根。有几种方法可用,其中包括 hybr(默认)和 lm,它们分别使用 Powell 的混合方法和 MINPACK 的 Levenberg-Marquardt 方法。

以下示例考虑单变量超越方程

\[x+2\cos\left(x\right)=0,\]

其根可以按如下方式找到

>>> import numpy as np
>>> from scipy.optimize import root
>>> def func(x):
...     return x + 2 * np.cos(x)
>>> sol = root(func, 0.3)
>>> sol.x
array([-1.02986653])
>>> sol.fun
array([ -6.66133815e-16])

现在考虑一组非线性方程

\begin{eqnarray*} x_{0}\cos\left(x_{1}\right) & = & 4,\\ x_{0}x_{1}-x_{1} & = & 5. \end{eqnarray*}

我们定义目标函数,以便它也返回雅可比矩阵,并通过将 jac 参数设置为 True 来指示这一点。此外,这里使用了 Levenberg-Marquardt 求解器。

>>> def func2(x):
...     f = [x[0] * np.cos(x[1]) - 4,
...          x[1]*x[0] - x[1] - 5]
...     df = np.array([[np.cos(x[1]), -x[0] * np.sin(x[1])],
...                    [x[1], x[0] - 1]])
...     return f, df
>>> sol = root(func2, [1, 1], jac=True, method='lm')
>>> sol.x
array([ 6.50409711,  0.90841421])

大规模问题的根求解#

root 中的方法 hybrlm 无法处理非常多的变量(N),因为它们需要在每个牛顿步计算并求解一个稠密的 N x N 雅可比矩阵。当 N 增大时,这会变得相当低效。

例如,考虑以下问题:我们需要求解方块 \([0,1]\times[0,1]\) 上的以下积分微分方程

\[(\partial_x^2 + \partial_y^2) P + 5 \left(\int_0^1\int_0^1\cosh(P)\,dx\,dy\right)^2 = 0\]

其中边界条件为上边缘的 \(P(x,1) = 1\) 和方块边界上其他地方的 \(P=0\)。可以通过在其网格上的值 \(P_{n,m}\approx{}P(n h, m h)\) 上逼近连续函数 P 来完成此操作,其中网格间距 h 很小。然后可以逼近导数和积分;例如 \(\partial_x^2 P(x,y)\approx{}(P(x+h,y) - 2 P(x,y) + P(x-h,y))/h^2\)。然后,该问题等效于找到某个函数 residual(P) 的根,其中 P 是长度为 \(N_x N_y\) 的向量。

现在,由于 \(N_x N_y\) 可能很大,因此 root 中的方法 hybrlm 将花费很长时间来解决此问题。但是,可以使用其中一个大规模求解器(例如 krylovbroyden2anderson)来找到解决方案。它们使用所谓的非精确牛顿法,该方法不是精确计算雅可比矩阵,而是对其进行近似。

我们现在可以按如下方式解决遇到的问题

import numpy as np
from scipy.optimize import root
from numpy import cosh, zeros_like, mgrid, zeros

# parameters
nx, ny = 75, 75
hx, hy = 1./(nx-1), 1./(ny-1)

P_left, P_right = 0, 0
P_top, P_bottom = 1, 0

def residual(P):
   d2x = zeros_like(P)
   d2y = zeros_like(P)

   d2x[1:-1] = (P[2:]   - 2*P[1:-1] + P[:-2]) / hx/hx
   d2x[0]    = (P[1]    - 2*P[0]    + P_left)/hx/hx
   d2x[-1]   = (P_right - 2*P[-1]   + P[-2])/hx/hx

   d2y[:,1:-1] = (P[:,2:] - 2*P[:,1:-1] + P[:,:-2])/hy/hy
   d2y[:,0]    = (P[:,1]  - 2*P[:,0]    + P_bottom)/hy/hy
   d2y[:,-1]   = (P_top   - 2*P[:,-1]   + P[:,-2])/hy/hy

   return d2x + d2y + 5*cosh(P).mean()**2

# solve
guess = zeros((nx, ny), float)
sol = root(residual, guess, method='krylov', options={'disp': True})
#sol = root(residual, guess, method='broyden2', options={'disp': True, 'max_rank': 50})
#sol = root(residual, guess, method='anderson', options={'disp': True, 'M': 10})
print('Residual: %g' % abs(residual(sol.x)).max())

# visualize
import matplotlib.pyplot as plt
x, y = mgrid[0:1:(nx*1j), 0:1:(ny*1j)]
plt.pcolormesh(x, y, sol.x, shading='gouraud')
plt.colorbar()
plt.show()
"This code generates a 2-D heatmap with Z values from 0 to 1. The graph resembles a smooth, dark blue-green, U shape, with an open yellow top. The right, bottom, and left edges have a value near zero and the top has a value close to 1. The center of the solution space has a value close to 0.8."

仍然太慢?预处理。#

在寻找函数 \(f_i({\bf x}) = 0\) 的零点时,i = 1, 2, …, Nkrylov 求解器花费大部分时间求解雅可比矩阵的逆矩阵,

\[J_{ij} = \frac{\partial f_i}{\partial x_j} .\]

如果您有逆矩阵 \(M\approx{}J^{-1}\) 的近似值,则可将其用于对线性求逆问题进行预处理。其思想是,求解 \(J{\bf s}={\bf y}\) 时,求解 \(MJ{\bf s}=M{\bf y}\):由于矩阵 \(MJ\)\(J\) 更“接近”单位矩阵,因此 Krylov 方法应该更容易处理该方程。

矩阵 M 可以作为选项 options['jac_options']['inner_M'] 传递给 root,方法为 krylov。它可以是(稀疏)矩阵或 scipy.sparse.linalg.LinearOperator 实例。

对于上一部分中的问题,我们注意到要解决的函数由两部分组成:第一部分是拉普拉斯算子的应用,\([\partial_x^2 + \partial_y^2] P\),第二部分是积分。我们实际上可以轻松计算与拉普拉斯算子部分相对应的雅可比矩阵:我们知道在 1-D 中

\[\begin{split}\partial_x^2 \approx \frac{1}{h_x^2} \begin{pmatrix} -2 & 1 & 0 & 0 \cdots \\ 1 & -2 & 1 & 0 \cdots \\ 0 & 1 & -2 & 1 \cdots \\ \ldots \end{pmatrix} = h_x^{-2} L\end{split}\]

因此,整个 2-D 算子表示为

\[J_1 = \partial_x^2 + \partial_y^2 \simeq h_x^{-2} L \otimes I + h_y^{-2} I \otimes L\]

雅可比对应的积分矩阵 \(J_2\) 比较难计算,而且由于所有条目都非零,因此也很难求逆。另一方面,\(J_1\) 是一个相对简单的矩阵,可以通过 scipy.sparse.linalg.splu 求逆(或可以通过 scipy.sparse.linalg.spilu 近似求逆)。因此,我们满足于取 \(M\approx{}J_1^{-1}\) 并寄希望于最好的结果。

在下面的示例中,我们使用预处理程序 \(M=J_1^{-1}\)

from scipy.optimize import root
from scipy.sparse import spdiags, kron
from scipy.sparse.linalg import spilu, LinearOperator
from numpy import cosh, zeros_like, mgrid, zeros, eye

# parameters
nx, ny = 75, 75
hx, hy = 1./(nx-1), 1./(ny-1)

P_left, P_right = 0, 0
P_top, P_bottom = 1, 0

def get_preconditioner():
    """Compute the preconditioner M"""
    diags_x = zeros((3, nx))
    diags_x[0,:] = 1/hx/hx
    diags_x[1,:] = -2/hx/hx
    diags_x[2,:] = 1/hx/hx
    Lx = spdiags(diags_x, [-1,0,1], nx, nx)

    diags_y = zeros((3, ny))
    diags_y[0,:] = 1/hy/hy
    diags_y[1,:] = -2/hy/hy
    diags_y[2,:] = 1/hy/hy
    Ly = spdiags(diags_y, [-1,0,1], ny, ny)

    J1 = kron(Lx, eye(ny)) + kron(eye(nx), Ly)

    # Now we have the matrix `J_1`. We need to find its inverse `M` --
    # however, since an approximate inverse is enough, we can use
    # the *incomplete LU* decomposition

    J1_ilu = spilu(J1)

    # This returns an object with a method .solve() that evaluates
    # the corresponding matrix-vector product. We need to wrap it into
    # a LinearOperator before it can be passed to the Krylov methods:

    M = LinearOperator(shape=(nx*ny, nx*ny), matvec=J1_ilu.solve)
    return M

def solve(preconditioning=True):
    """Compute the solution"""
    count = [0]

    def residual(P):
        count[0] += 1

        d2x = zeros_like(P)
        d2y = zeros_like(P)

        d2x[1:-1] = (P[2:]   - 2*P[1:-1] + P[:-2])/hx/hx
        d2x[0]    = (P[1]    - 2*P[0]    + P_left)/hx/hx
        d2x[-1]   = (P_right - 2*P[-1]   + P[-2])/hx/hx

        d2y[:,1:-1] = (P[:,2:] - 2*P[:,1:-1] + P[:,:-2])/hy/hy
        d2y[:,0]    = (P[:,1]  - 2*P[:,0]    + P_bottom)/hy/hy
        d2y[:,-1]   = (P_top   - 2*P[:,-1]   + P[:,-2])/hy/hy

        return d2x + d2y + 5*cosh(P).mean()**2

    # preconditioner
    if preconditioning:
        M = get_preconditioner()
    else:
        M = None

    # solve
    guess = zeros((nx, ny), float)

    sol = root(residual, guess, method='krylov',
               options={'disp': True,
                        'jac_options': {'inner_M': M}})
    print('Residual', abs(residual(sol.x)).max())
    print('Evaluations', count[0])

    return sol.x

def main():
    sol = solve(preconditioning=True)

    # visualize
    import matplotlib.pyplot as plt
    x, y = mgrid[0:1:(nx*1j), 0:1:(ny*1j)]
    plt.clf()
    plt.pcolor(x, y, sol)
    plt.clim(0, 1)
    plt.colorbar()
    plt.show()


if __name__ == "__main__":
    main()

运行结果,首先不使用预处理

0:  |F(x)| = 803.614; step 1; tol 0.000257947
1:  |F(x)| = 345.912; step 1; tol 0.166755
2:  |F(x)| = 139.159; step 1; tol 0.145657
3:  |F(x)| = 27.3682; step 1; tol 0.0348109
4:  |F(x)| = 1.03303; step 1; tol 0.00128227
5:  |F(x)| = 0.0406634; step 1; tol 0.00139451
6:  |F(x)| = 0.00344341; step 1; tol 0.00645373
7:  |F(x)| = 0.000153671; step 1; tol 0.00179246
8:  |F(x)| = 6.7424e-06; step 1; tol 0.00173256
Residual 3.57078908664e-07
Evaluations 317

然后使用预处理

0:  |F(x)| = 136.993; step 1; tol 7.49599e-06
1:  |F(x)| = 4.80983; step 1; tol 0.00110945
2:  |F(x)| = 0.195942; step 1; tol 0.00149362
3:  |F(x)| = 0.000563597; step 1; tol 7.44604e-06
4:  |F(x)| = 1.00698e-09; step 1; tol 2.87308e-12
Residual 9.29603061195e-11
Evaluations 77

使用预处理程序将 residual 函数的评估次数减少了4 倍。对于残差计算成本很高的难题,良好的预处理至关重要——甚至可以决定难题在实践中是否可解。

预处理是一门艺术、科学和产业。在这里,我们很幸运地做出了一个简单且效果不错选择,但此主题的深度远不止此处所示。

线性规划 (linprog)#

函数 linprog 可以最小化线性目标函数,但前提是满足线性等式和不等式约束。此类问题被称为线性规划。线性规划解决以下形式的问题

\[\begin{split}\min_x \ & c^T x \\ \mbox{such that} \ & A_{ub} x \leq b_{ub},\\ & A_{eq} x = b_{eq},\\ & l \leq x \leq u ,\end{split}\]

其中 \(x\) 是决策变量的向量;\(c\)\(b_{ub}\)\(b_{eq}\)\(l\)\(u\) 是向量;\(A_{ub}\)\(A_{eq}\) 是矩阵。

在本教程中,我们将尝试使用 linprog 解决一个典型的线性规划问题。

线性规划示例#

考虑以下简单的线性规划问题

\[\begin{split}\max_{x_1, x_2, x_3, x_4} \ & 29x_1 + 45x_2 \\ \mbox{such that} \ & x_1 -x_2 -3x_3 \leq 5\\ & 2x_1 -3x_2 -7x_3 + 3x_4 \geq 10\\ & 2x_1 + 8x_2 + x_3 = 60\\ & 4x_1 + 4x_2 + x_4 = 60\\ & 0 \leq x_0\\ & 0 \leq x_1 \leq 5\\ & x_2 \leq 0.5\\ & -3 \leq x_3\\\end{split}\]

我们需要一些数学运算,将目标问题转换为 linprog 接受的形式。

首先,让我们考虑目标函数。我们希望最大化目标函数,但 linprog 只接受最小化问题。这可以通过将最大化 \(29x_1 + 45x_2\) 转换为最小化 \(-29x_1 -45x_2\) 来轻松解决。此外,\(x_3, x_4\) 未显示在目标函数中。这意味着与 \(x_3, x_4\) 相对应的权重为零。因此,目标函数可以转换为

\[\min_{x_1, x_2, x_3, x_4} \ -29x_1 -45x_2 + 0x_3 + 0x_4\]

如果我们定义决策变量向量 \(x = [x_1, x_2, x_3, x_4]^T\),则 linprog 中的目标权重向量 \(c\) 在此问题中应为

\[c = [-29, -45, 0, 0]^T\]

接下来,让我们考虑两个不等式约束。第一个是不等式“小于”,因此已经处于 linprog 接受的形式。第二个是不等式“大于”,因此我们需要将两边乘以 \(-1\) 以将其转换为不等式“小于”。明确显示零系数,我们有

\[\begin{split}x_1 -x_2 -3x_3 + 0x_4 &\leq 5\\ -2x_1 + 3x_2 + 7x_3 - 3x_4 &\leq -10\\\end{split}\]

这些方程可以转换为矩阵形式

\[\begin{split}A_{ub} x \leq b_{ub}\\\end{split}\]

其中

\begin{equation*} A_{ub} = \begin{bmatrix} 1 & -1 & -3 & 0 \\ -2 & 3 & 7 & -3 \end{bmatrix} \end{equation*}
\begin{equation*} b_{ub} = \begin{bmatrix} 5 \\ -10 \end{bmatrix} \end{equation*}

接下来,让我们考虑两个等式约束。明确显示零权重,这些是

\[\begin{split}2x_1 + 8x_2 + 1x_3 + 0x_4 &= 60\\ 4x_1 + 4x_2 + 0x_3 + 1x_4 &= 60\\\end{split}\]

这些方程可以转换为矩阵形式

\[\begin{split}A_{eq} x = b_{eq}\\\end{split}\]

其中

\begin{equation*} A_{eq} = \begin{bmatrix} 2 & 8 & 1 & 0 \\ 4 & 4 & 0 & 1 \end{bmatrix} \end{equation*}
\begin{equation*} b_{eq} = \begin{bmatrix} 60 \\ 60 \end{bmatrix} \end{equation*}

最后,让我们考虑对各个决策变量的独立不等式约束,这些约束被称为“方框约束”或“简单界限”。可以使用 linprog 的 bounds 参数应用这些约束。正如 linprog 文档中所述,bounds 的默认值为 (0, None),这意味着每个决策变量的下界为 0,每个决策变量的上界为无穷大:所有决策变量都是非负的。我们的界限不同,因此我们需要将每个决策变量的下界和上界指定为一个元组,并将这些元组分组到一个列表中。

最后,我们可以使用 linprog 求解转换后的问题。

>>> import numpy as np
>>> from scipy.optimize import linprog
>>> c = np.array([-29.0, -45.0, 0.0, 0.0])
>>> A_ub = np.array([[1.0, -1.0, -3.0, 0.0],
...                 [-2.0, 3.0, 7.0, -3.0]])
>>> b_ub = np.array([5.0, -10.0])
>>> A_eq = np.array([[2.0, 8.0, 1.0, 0.0],
...                 [4.0, 4.0, 0.0, 1.0]])
>>> b_eq = np.array([60.0, 60.0])
>>> x0_bounds = (0, None)
>>> x1_bounds = (0, 5.0)
>>> x2_bounds = (-np.inf, 0.5)  # +/- np.inf can be used instead of None
>>> x3_bounds = (-3.0, None)
>>> bounds = [x0_bounds, x1_bounds, x2_bounds, x3_bounds]
>>> result = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq, bounds=bounds)
>>> print(result.message)
The problem is infeasible. (HiGHS Status 8: model_status is Infeasible; primal_status is At lower/fixed bound)

结果表明我们的问题不可行,这意味着没有满足所有约束的解向量。这并不一定意味着我们做错了什么;有些问题确实不可行。然而,假设我们决定 \(x_1\) 的界限约束太紧,可以放宽到 \(0 \leq x_1 \leq 6\)。在调整我们的代码 x1_bounds = (0, 6) 以反映更改并再次执行它之后

>>> x1_bounds = (0, 6)
>>> bounds = [x0_bounds, x1_bounds, x2_bounds, x3_bounds]
>>> result = linprog(c, A_ub=A_ub, b_ub=b_ub, A_eq=A_eq, b_eq=b_eq, bounds=bounds)
>>> print(result.message)
Optimization terminated successfully. (HiGHS Status 7: Optimal)

结果显示优化成功。我们可以检查目标值 (result.fun) 与 \(c^Tx\) 相同

>>> x = np.array(result.x)
>>> obj = result.fun
>>> print(c @ x)
-505.97435889013434  # may vary
>>> print(obj)
-505.97435889013434  # may vary

我们还可以检查所有约束是否在合理容差范围内得到满足

>>> print(b_ub - (A_ub @ x).flatten())  # this is equivalent to result.slack
[ 6.52747190e-10, -2.26730279e-09]  # may vary
>>> print(b_eq - (A_eq @ x).flatten())  # this is equivalent to result.con
[ 9.78840831e-09, 1.04662945e-08]]  # may vary
>>> print([0 <= result.x[0], 0 <= result.x[1] <= 6.0, result.x[2] <= 0.5, -3.0 <= result.x[3]])
[True, True, True, True]

分配问题#

线性求和分配问题示例#

考虑为游泳混合接力队选拔学生的问题。我们有一张表格,显示了五名学生的每种游泳方式的时间

学生

仰泳

蛙泳

蝶泳

自由泳

A

43.5

47.1

48.4

38.2

B

45.5

42.1

49.6

36.8

C

43.4

39.1

42.1

43.2

D

46.5

44.1

44.5

41.2

E

46.3

47.8

50.4

37.2

我们需要为四种游泳方式中的每一种选择一名学生,以使接力赛总时间最小化。这是一个典型的线性求和分配问题。我们可以使用 linear_sum_assignment 来解决它。

线性求和分配问题是最著名的组合优化问题之一。给定一个“成本矩阵”\(C\),该问题是要选择

  • 每一行中恰好一个元素

  • 而不从任何一列中选择多个元素

  • 以使所选元素的和最小化

换句话说,我们需要将每一行分配给一列,以使相应项的和最小化。

形式上,令\(X\)为布尔矩阵,其中\(X[i,j] = 1\)当且仅当行\(i\)分配给列\(j\)时。那么最优分配的成本为

\[\min \sum_i \sum_j C_{i,j} X_{i,j}\]

第一步是定义成本矩阵。在此示例中,我们要将每种游泳方式分配给一名学生。linear_sum_assignment能够将成本矩阵的每一行分配给一列。因此,为了形成成本矩阵,需要对上表进行转置,以便行与游泳方式相对应,列与学生相对应

>>> import numpy as np
>>> cost = np.array([[43.5, 45.5, 43.4, 46.5, 46.3],
...                  [47.1, 42.1, 39.1, 44.1, 47.8],
...                  [48.4, 49.6, 42.1, 44.5, 50.4],
...                  [38.2, 36.8, 43.2, 41.2, 37.2]])

我们可以使用linear_sum_assignment解决分配问题

>>> from scipy.optimize import linear_sum_assignment
>>> row_ind, col_ind = linear_sum_assignment(cost)

row_indcol_ind是成本矩阵的最优分配矩阵索引

>>> row_ind
array([0, 1, 2, 3])
>>> col_ind
array([0, 2, 3, 1])

最优分配为

>>> styles = np.array(["backstroke", "breaststroke", "butterfly", "freestyle"])[row_ind]
>>> students = np.array(["A", "B", "C", "D", "E"])[col_ind]
>>> dict(zip(styles, students))
{'backstroke': 'A', 'breaststroke': 'C', 'butterfly': 'D', 'freestyle': 'B'}

最优总混合泳时间为

>>> cost[row_ind, col_ind].sum()
163.89999999999998

请注意,此结果与每种游泳方式的最小时间的总和不同

>>> np.min(cost, axis=1).sum()
161.39999999999998

因为学生“C”是“蛙泳”和“蝶泳”两种泳式的最佳游泳者。我们不能将学生“C”分配给两种泳式,因此我们让学生 C 参加“蛙泳”泳式,让学生 D 参加“蝶泳”泳式,以使总时间最小化。

参考

一些进一步的阅读和相关软件,例如 Newton-Krylov [KK]、PETSc [PP]和 PyAMG [AMG]

[KK]

D.A. Knoll 和 D.E. Keyes,“无雅可比 Newton-Krylov 方法”,J. Comp. Phys. 193, 357 (2004)。DOI:10.1016/j.jcp.2003.08.010

[AMG]

PyAMG(代数多重网格预处理器/求解器)pyamg/pyamg#issues

混合整数线性规划#

背包问题示例#

背包问题是一个众所周知的组合优化问题。给定一组物品,每个物品都有一个尺寸和一个值,问题是要选择那些在总尺寸低于某个阈值条件下使总价值最大化的物品。

形式上,令

  • \(x_i\) 是一个布尔变量,表示物品 \(i\) 是否包含在背包中,

  • \(n\) 是物品总数,

  • \(v_i\) 是物品 \(i\) 的值,

  • \(s_i\) 是物品 \(i\) 的尺寸,

  • \(C\) 是背包的容量。

那么问题就是

\[\max \sum_i^n v_{i} x_{i}\]
\[\text{subject to} \sum_i^n s_{i} x_{i} \leq C, x_{i} \in {0, 1}\]

虽然目标函数和不等式约束在决策变量 \(x_i\) 中是线性的,但这与典型的线性规划问题不同,因为决策变量只能取整数值。具体来说,我们的决策变量只能是 \(0\)\(1\),因此这称为二进制整数线性规划 (BILP)。此类问题属于混合整数线性规划 (MILP) 的更大类别,我们可以使用 milp 来求解。

在我们的示例中,有 8 个物品可供选择,每个物品的尺寸和价值如下所示。

>>> import numpy as np
>>> from scipy import optimize
>>> sizes = np.array([21, 11, 15, 9, 34, 25, 41, 52])
>>> values = np.array([22, 12, 16, 10, 35, 26, 42, 53])

我们需要将我们的八个决策变量限制为二进制。我们通过添加一个 Bounds 约束来确保它们介于 \(0\)\(1\) 之间,并且我们应用“整体性”约束来确保它们要么\(0\)要么\(1\)

>>> bounds = optimize.Bounds(0, 1)  # 0 <= x_i <= 1
>>> integrality = np.full_like(values, True)  # x_i are integers

背包容量约束使用 LinearConstraint 指定。

>>> capacity = 100
>>> constraints = optimize.LinearConstraint(A=sizes, lb=0, ub=capacity)

如果我们遵循线性代数的通常规则,输入 A 应为二维矩阵,下界和上界 lbub 应为一维向量,但 LinearConstraint 只要输入可以广播到一致的形状,就会宽容。

使用上面定义的变量,我们可以使用 milp 求解背包问题。请注意,milp 最小化目标函数,但我们希望最大化总价值,因此我们将 c 设置为值的负数。

>>> from scipy.optimize import milp
>>> res = milp(c=-values, constraints=constraints,
...            integrality=integrality, bounds=bounds)

让我们检查结果

>>> res.success
True
>>> res.x
array([1., 1., 0., 1., 1., 1., 0., 0.])

这意味着我们应选择项目 1、2、4、5、6 以在尺寸约束下优化总价值。请注意,这与我们在求解线性规划松弛(没有整数约束)并尝试舍入决策变量后获得的结果不同。

>>> from scipy.optimize import milp
>>> res = milp(c=-values, constraints=constraints,
...            integrality=False, bounds=bounds)
>>> res.x
array([1.        , 1.        , 1.        , 1.        ,
       0.55882353, 1.        , 0.        , 0.        ])

如果我们将此解四舍五入为 array([1., 1., 1., 1., 1., 1., 0., 0.]),我们的背包将超过容量约束,而如果我们将此解向下舍入为 array([1., 1., 1., 1., 0., 1., 0., 0.]),我们将获得次优解。

有关更多 MILP 教程,请参阅 SciPy Cookbooks 上的 Jupyter 笔记本