Skip to content

Conversation

nbelakovski
Copy link
Contributor

PRIMA now has a pure python implementation of COBYLA!

I'm pleased to announce that after a lot of effort the PRIMA COBYLA algorithm is now implemented in Python. A considerable amount of effort went into validating this implementation against the Fortran and I'm happy to report that against a battery of over 500 problems from cutest, the Fortran and Python implementations get the same result bit for bit.

Reference issue

Closes gh-18118

Additional information

This commit also

  • Updates COBYLA to accept the new style constraints
  • Adds several options, including ftarget and more granular iprint
  • Updates COBYLA to use actual bounds and linear constraints instead of simply adding them to the nonlinear constraint
  • Updates COBYLA to use the new style callback interface
  • Updates COBYLA to be threadsafe
  • Updates all relevant documentation and tests
  • Removes the old implementation

@github-actions github-actions bot added scipy.optimize scipy._lib Documentation Issues related to the SciPy documentation. Also check https://github.com/scipy/scipy.org Fortran Items related to the internal Fortran code base Meson Items related to the introduction of Meson as the new build system for SciPy enhancement A new feature or improvement labels Jan 17, 2025
@lucascolley
Copy link
Member

looks like just one real CI failure from a brief look:

____________________________ TestBounds.test_basic _____________________________
[gw1] linux -- Python 3.10.12 /usr/bin/python3-dbg

self = <scipy.optimize.tests.test_cobyla.TestBounds object at 0x7f006a4c9f90>

    def test_basic(self):
        def f(x):
            return np.sum(x**2)
    
        lb = [-1, None, 1, None, -0.5]
        ub = [-0.5, -0.5, None, None, -0.5]
        bounds = [(a, b) for a, b in zip(lb, ub)]
        # these are converted to Bounds internally
    
        res = minimize(f, x0=[1, 2, 3, 4, 5], method='cobyla', bounds=bounds)
        ref = [-0.5, -0.5, 1, 0, -0.5]
        assert res.success
>       assert_allclose(res.x, ref, atol=1e-3)
E       AssertionError: 
E       Not equal to tolerance rtol=1e-07, atol=0.001
E       
E       Mismatched elements: 2 / 5 (40%)
E       Max absolute difference among violations: 1.85025255
E       Max relative difference among violations: 0.25694824
E        ACTUAL: array([-0.5     , -0.5     ,  1.256948,  1.850253, -0.5     ])
E        DESIRED: array([-0.5, -0.5,  1. ,  0. , -0.5])

bounds     = [(-1, -0.5), (None, -0.5), (1, None), (None, None), (-0.5, -0.5)]
f          = <function TestBounds.test_basic.<locals>.f at 0x7f0057df9440>
lb         = [-1, None, 1, None, -0.5]
ref        = [-0.5, -0.5, 1, 0, -0.5]
res        =  message: Return from COBYLA because the trust region radius reaches its lower bound.
 success: True
  status: 0
     fun: 5.753353369726385
       x: [-5.000e-01 -5.000e-01  1.257e+00  1.850e+00 -5.000e-01]
    nfev: 38
   maxcv: 0.0
self       = <scipy.optimize.tests.test_cobyla.TestBounds object at 0x7f006a4c9f90>
ub         = [-0.5, -0.5, None, None, -0.5]

@nbelakovski
Copy link
Contributor Author

nbelakovski commented Jan 17, 2025

It appears that the issue with the one you pointed out is that one of the bounds is fixed, and this leads COBYLA to go a little haywire when trying to satisfy it. I'll add some code to detect fixed bounds and eliminate them, which is both a sensible thing to do and also what COBYQA does.

And there's also test_basinhopping. It takes longer now than the 20s allotted for it, taking up to 33.8 seconds on Windows fail slow full. Obviously since the algorithm is now in Python it's not surprising that it's slower, so I've increased the window for that test to 35s.

Besides those two there are some mypy failures which I need to look into.

@nbelakovski
Copy link
Contributor Author

Fixed bounds and test_basinhopping are fixed, last up is the mypy failures.

@lucascolley
Copy link
Member

please just apply this with your next round of changes:

diff --git a/mypy.ini b/mypy.ini
index 95deab150a..3c5dfd9e86 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -751,3 +751,6 @@ ignore_errors = True
 
 [mypy-scipy._lib.array_api_compat.*]
 ignore_errors = True
+
+[mypy-scipy._lib.pyprima.*]
+ignore_errors = True

Copy link
Member

@lucascolley lucascolley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a cursory review - I haven't audited the changes to the tests in detail - but looks very promising, thanks!

.gitmodules Outdated
@@ -32,3 +32,7 @@
path = subprojects/highs
url = https://github.com/scipy/HiGHs
shallow = true
[submodule "scipy/_lib/prima"]
path = scipy/_lib/prima
url = https://github.com/nbelakovski/prima
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose we will want to mirror this repo under the SciPy org?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Either that, or perhaps better have a repo with a script to extract only the relevant parts from upstream. prima is a very heavy repo, with a large majority of the content being benchmarks and code in other languages. So we could probably keep things a lot leaner without too much process overhead if we just copied out pyprima/ and ensured we had a good way to record the exact commit hash and repo the content came from.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be happy to look into this more if this sounds like the way to go, to avoid giving @nbelakovski even more homework. Getting PRIMA into the current great shape has already been a massive lift I think.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a first step to get this merged, could we simply fork this repo under the scipy org?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can take a look at the script approach later today

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nbelakovski nbelakovski#1 passes tests for me locally. Would you mind taking a look and merging? @rgommers, what are the next steps to move that repo under the scipy org?

@nbelakovski
Copy link
Contributor Author

I ran the DFO (Derivative Free Optimization) benchmarks to compare against the existing COBYLA implementation and here are the results

Change Before [2299d44-main] After [7ebb473-pyprima] Ratio Benchmark (Parameter)
+ 1.57221e+12 6.85374e+12 4.36 optimize.BenchDFO.track_all(46, 'COBYLA', 'min_obj')
+ 85822.2 2.91279e+11 3.39398e+06 optimize.BenchDFO.track_all(27, 'COBYLA', 'min_obj')
+ 31 1000 32.26 optimize.BenchDFO.track_all(25, 'COBYLA', 'mean_nfev')
+ 4.47608e+12 1.1284e+13 2.52 optimize.BenchDFO.track_all(50, 'COBYLA', 'min_obj')
+ 7.18191e+06 1.05009e+09 146.21 optimize.BenchDFO.track_all(17, 'COBYLA', 'min_obj')
+ 201 339 1.69 optimize.BenchDFO.track_all(47, 'COBYLA', 'mean_nfev')
+ 109 159 1.46 optimize.BenchDFO.track_all(3, 'COBYLA', 'mean_nfev')
+ 268 381 1.42 optimize.BenchDFO.track_all(48, 'COBYLA', 'mean_nfev')
+ 149 207 1.39 optimize.BenchDFO.track_all(45, 'COBYLA', 'mean_nfev')
+ 698 960 1.38 optimize.BenchDFO.track_all(39, 'COBYLA', 'mean_nfev')
+ 2.21563 2.86989 1.3 optimize.BenchDFO.track_all(35, 'COBYLA', 'min_obj')
+ 573 732 1.28 optimize.BenchDFO.track_all(38, 'COBYLA', 'mean_nfev')
+ 369 469 1.27 optimize.BenchDFO.track_all(49, 'COBYLA', 'mean_nfev')
+ 826 1000 1.21 optimize.BenchDFO.track_all(41, 'COBYLA', 'mean_nfev')
+ 848 1000 1.18 optimize.BenchDFO.track_all(40, 'COBYLA', 'mean_nfev')
+ 551 622 1.13 optimize.BenchDFO.track_all(26, 'COBYLA', 'mean_nfev')
+ 882 1000 1.13 optimize.BenchDFO.track_all(27, 'COBYLA', 'mean_nfev')
+ 413 459 1.11 optimize.BenchDFO.track_all(29, 'COBYLA', 'mean_nfev')
+ 0.0825842 0.0916973 1.11 optimize.BenchDFO.track_all(44, 'COBYLA', 'min_obj')
+ 0.290312 0.316521 1.09 optimize.BenchDFO.track_all(36, 'COBYLA', 'min_obj')
+ 18.1544 19.7105 1.09 optimize.BenchDFO.track_all(7, 'COBYLA', 'min_obj')
+ 1.02022e+06 1.07136e+06 1.05 optimize.BenchDFO.track_all(52, 'COBYLA', 'min_obj')
- 0.0772566 0.0730388 0.95 optimize.BenchDFO.track_all(43, 'COBYLA', 'min_obj')
- 52.6424 49.7236 0.94 optimize.BenchDFO.track_all(13, 'COBYLA', 'min_obj')
- 0.0196931 0.0185623 0.94 optimize.BenchDFO.track_all(20, 'COBYLA', 'min_obj')
- 256.166 241.953 0.94 optimize.BenchDFO.track_all(25, 'COBYLA', 'min_obj')
- 0.00041551 0.000381363 0.92 optimize.BenchDFO.track_all(16, 'COBYLA', 'min_obj')
- 0.0106998 0.00983711 0.92 optimize.BenchDFO.track_all(18, 'COBYLA', 'min_obj')
- 208 186 0.89 optimize.BenchDFO.track_all(1, 'COBYLA', 'mean_nfev')
- 1000 868 0.87 optimize.BenchDFO.track_all(35, 'COBYLA', 'mean_nfev')
- 384 333 0.87 optimize.BenchDFO.track_all(37, 'COBYLA', 'mean_nfev')
- 127 110 0.87 optimize.BenchDFO.track_all(5, 'COBYLA', 'mean_nfev')
- 1.83741e-06 1.56566e-06 0.85 optimize.BenchDFO.track_all(28, 'COBYLA', 'min_obj')
- 0.00169863 0.00140562 0.83 optimize.BenchDFO.track_all(34, 'COBYLA', 'min_obj')
- 330 272 0.82 optimize.BenchDFO.track_all(28, 'COBYLA', 'mean_nfev')
- 0.0807426 0.0658221 0.82 optimize.BenchDFO.track_all(42, 'COBYLA', 'min_obj')
- 67.931 54.5526 0.8 optimize.BenchDFO.track_all(12, 'COBYLA', 'min_obj')
- 1000 797 0.8 optimize.BenchDFO.track_all(18, 'COBYLA', 'mean_nfev')
- 1000 793 0.79 optimize.BenchDFO.track_all(14, 'COBYLA', 'mean_nfev')
- 581 457 0.79 optimize.BenchDFO.track_all(31, 'COBYLA', 'mean_nfev')
- 174 132 0.76 optimize.BenchDFO.track_all(0, 'COBYLA', 'mean_nfev')
- 91 66 0.73 optimize.BenchDFO.track_all(4, 'COBYLA', 'mean_nfev')
- 0.0230739 0.0163344 0.71 optimize.BenchDFO.track_all(48, 'COBYLA', 'min_obj')
- 1000 699 0.7 optimize.BenchDFO.track_all(16, 'COBYLA', 'mean_nfev')
- 1000 693 0.69 optimize.BenchDFO.track_all(19, 'COBYLA', 'mean_nfev')
- 0.0453879 0.0311729 0.69 optimize.BenchDFO.track_all(51, 'COBYLA', 'min_obj')
- 105 69 0.66 optimize.BenchDFO.track_all(2, 'COBYLA', 'mean_nfev')
- 0.0342853 0.0215811 0.63 optimize.BenchDFO.track_all(49, 'COBYLA', 'min_obj')
- 0.155084 0.0939493 0.61 optimize.BenchDFO.track_all(23, 'COBYLA', 'min_obj')
- 0.0138684 0.00717343 0.52 optimize.BenchDFO.track_all(19, 'COBYLA', 'min_obj')
- 1000 462 0.46 optimize.BenchDFO.track_all(8, 'COBYLA', 'mean_nfev')
- 0.102238 0.046054 0.45 optimize.BenchDFO.track_all(22, 'COBYLA', 'min_obj')
- 48 21 0.44 optimize.BenchDFO.track_all(17, 'COBYLA', 'mean_nfev')
- 9.46318e-06 3.81706e-06 0.4 optimize.BenchDFO.track_all(29, 'COBYLA', 'min_obj')
- 0.0202994 0.00759472 0.37 optimize.BenchDFO.track_all(45, 'COBYLA', 'min_obj')
- 0.0635923 0.0203449 0.32 optimize.BenchDFO.track_all(6, 'COBYLA', 'min_obj')
- 0.193012 0.0575186 0.3 optimize.BenchDFO.track_all(21, 'COBYLA', 'min_obj')
- 0.00311681 0.000732976 0.24 optimize.BenchDFO.track_all(10, 'COBYLA', 'min_obj')
- 0.0252814 0.00546931 0.22 optimize.BenchDFO.track_all(47, 'COBYLA', 'min_obj')
- 1.43505 0.281975 0.2 optimize.BenchDFO.track_all(9, 'COBYLA', 'min_obj')
- 1000 186 0.19 optimize.BenchDFO.track_all(24, 'COBYLA', 'mean_nfev')
- 0.00629085 0.00105276 0.17 optimize.BenchDFO.track_all(11, 'COBYLA', 'min_obj')
- 0.00595643 0.000906142 0.15 optimize.BenchDFO.track_all(8, 'COBYLA', 'min_obj')
- 0.0024656 3.83688e-05 0.02 optimize.BenchDFO.track_all(24, 'COBYLA', 'min_obj')
- 8.37489 0.0280712 0 optimize.BenchDFO.track_all(15, 'COBYLA', 'min_obj')

43 problems are improved either in final value or in number of function evaluations. 22 are worse in those metrics. A notable outlier is test #27. I did some digging and found this this is the objective function it is trying to evaluate

def func(x):
    m = 20
    fvec = np.zeros(m)
    for i in range(m):
            temp = (i + 1) / 5
            tmp1 = x[0] + temp * x[1] - np.exp(temp)
            tmp2 = x[2] + np.sin(temp) * x[3] - np.cos(temp)
            fvec[i] = tmp1 * tmp1 + tmp2 * tmp2
    y = np.sum(fvec ** 2)
    if np.isnan(y):
        return np.inf
    return y
x0 = [250.  50. -50. -10.]

If I remove the maxfun limitation on the new implementation it eventually gets to the optimum value, although it takes ~13000 function evaluations on my machine as compared to <1000 for the old implementation. Problem 17 is also somewhat of an outlier. If I tighten the tolerance on it to 1e-6 from the default 1e-4 the result becomes comparable to the old implementation. Different problems will fare better or worse depending on the initial/final trust region radius (which in scipy are called rhobeg and tol respectively) and these parameters are exposed to the user so I don't think this outlier is something to be overly concerned with.

@nbelakovski nbelakovski force-pushed the pyprima branch 2 times, most recently from 6de248e to 75d7ca2 Compare January 20, 2025 02:53
@dschmitz89
Copy link
Contributor

For me the benchmarks look like a net win. Let's include one sentence about the parameters that can be tweaked in the release notes to give users a hint.

@lucascolley lucascolley added the needs-release-note a maintainer should add a release note written by a reviewer/author to the wiki label Jan 22, 2025
@lucascolley lucascolley added this to the 1.16.0 milestone Jan 22, 2025
@ilayn
Copy link
Member

ilayn commented Jan 22, 2025

Not to block, or in fact take away, anything from this great addition, I just want to understand, is this going to live in your fork and not in prima repo? I don't have any background info hence my question.

@nbelakovski
Copy link
Contributor Author

nbelakovski commented Jan 25, 2025

Not to block, or in fact take away, anything from this great addition, I just want to understand, is this going to live in your fork and not in prima repo? I don't have any background info hence my question.

At the moment yes. It's my understanding that @zaikunzhang is occupied and that PRIMA is not particularly high on his list of priorities. As such I haven't had a chance to discuss this with him and see if he has any comments, but given my previous work with him, I think he would take it as a good sign that the Python implementation is bit-for-bit identical* to the Fortran version on all problems in the test set that we used for the Python bindings. Hopefully he is able to get bandwidth sometime in the future and we'll be able to merge the Python implementation back into the prima repo. I've been rebasing my work on the latest stuff from him so at the moment the fork is up to date with the latest. All that said, I don't think we should wait for him to move forward on this.

* This is true when the USE_NAIVE_MATH flag in Python is on, and Fortran is compiled in debug mode. I've chosen to default that flag to off so that numpy routines are used for martix multiplication and so forth instead of the naive implementations. My reasoning is that most practical problems do not need to preserve all floating point calculations, but when the flag is on it sometimes results in more function evaluations and therefore more matrix math, and so the speedup/slowdown is not as significant as you might think. And also Fortran behaves differently when compiled with any optimization so there's a parallel there. I'm open to changing this.

@nbelakovski nbelakovski force-pushed the pyprima branch 2 times, most recently from 4b2bccc to 44a5542 Compare January 29, 2025 17:13
@dschmitz89
Copy link
Contributor

dschmitz89 commented Mar 20, 2025

Test failures are unrelated, this should be mergeable now.

@lucascolley
Copy link
Member

We will want to transfer lucascolley/pyprima to the SciPy org before merge.

@dschmitz89
Copy link
Contributor

We will want to transfer lucascolley/pyprima to the SciPy org before merge.

Argh, missed that, sry. Maintainer should have rights to create repos under scipy org, at least the button works for me. Is there an approval process to follow?

@lucascolley
Copy link
Member

Maintainer should have rights to create repos under scipy org

thanks Daniel, done!

@dschmitz89
Copy link
Contributor

Thanks @lucascolley . If there are no more comments, I will merge this next week.

dschmitz89
dschmitz89 previously approved these changes Mar 21, 2025
@dschmitz89
Copy link
Contributor

@lucascolley @nbelakovski : the new dependency checker tach is complaining now. Would the suggested fix by tach suffice?

@dschmitz89 dschmitz89 dismissed their stale review March 24, 2025 08:22

New linter complaints

@lucascolley
Copy link
Member

lucascolley commented Mar 24, 2025

I would suggest just adding

[[modules]]
path = "scipy._lib.pyprima"
unchecked = true

Like we have for cobyqa

PyPRIMA imports scipy in order to use the constraint objects, which
results in a circular dependency violation. COBYQA does the same thing,
so like with COBYQA we set unchecked to true.
@nbelakovski
Copy link
Contributor Author

Agreed, and done.

@dschmitz89 dschmitz89 merged commit dff7190 into scipy:main Mar 24, 2025
40 of 41 checks passed
@dschmitz89
Copy link
Contributor

Alright, I pulled the trigger here finally.

Thanks for your tremendous work here @nbelakovski ! Hat tip to all reviewers as well.

@j-bowhay
Copy link
Member

We should see if we can close #15527 and #13952

@j-bowhay
Copy link
Member

And of course thanks for your great work @nbelakovski

@nbelakovski
Copy link
Contributor Author

Thanks all, and thanks for the help in getting it across the finish line!

@zaikunzhang
Copy link
Contributor

zaikunzhang commented Mar 25, 2025

(sorry for reposting)

Hello! Author of PRIMA here.

Thanks for working on this. I am sorry for not being able to respond.

For anything derived from PRIMA, may I request that the following is stated clearly?

  1. PRIMA is developed by Zaikun Zhang, with "Zaikun Zhang" linked to my homepage zhangzk.net
  2. PRIMA means "Reference Implementation for Powell's Methods with Modernization and Amelioration", which provides a modernized and improved implementation of M.J.D. Powell's derivative-free optimization methods, with "M.J.D. Powell" linked to zhangzk.net/powell.html

As an example, see lfortran.org/blog/2025/03/lfortran-compiles-prima

Note that PRIMA contains bug fixes and improvements that do not exist in Powell's original implementation of the solvers. Results produced by PRIMA are surely different from those produced by the original solvers by Powell. Therefore, it is simply wrong to pretend that PRIMA is just Powell's solvers.

It took me years to develop PRIMA, which almost cost my career and my life. (This partially explains why I have been unable to respond recently. I have to strive to survive as a young professor).

As a professor, all I need is a citation / acknowledgment when my work is useful, which is not well recognized in academia when it comes to software development. In addition, for obvious reasons, people tend to cite Powell without mentioning me when they use PRIMA instead of Powell's original solvers. (I have no doubt that my contribution is insignificant compared with Powell's, and I acknowledge this whenever I introduce PRIMA to others. See libprima.net for example). This is crucial for my career.

Thank you very much.

@lucascolley lucascolley removed the needs-release-note a maintainer should add a release note written by a reviewer/author to the wiki label Mar 25, 2025
@rgommers
Copy link
Member

@dschmitz89 and @lucascolley thank you for finishing the review/merge here! Sorry for going missing in action for a while.

@nbelakovski awesome work, thank you 🙏🏼

As a professor, all I need is a citation / acknowledgment when my work is useful, which is not well recognized in academia when it comes to software development.

We definitely recognize that problem and the need for citations. I know you saw it, but just for completeness: gh-22738 added references to your paper in two places.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Documentation Issues related to the SciPy documentation. Also check https://github.com/scipy/scipy.org enhancement A new feature or improvement Fortran Items related to the internal Fortran code base Meson Items related to the introduction of Meson as the new build system for SciPy scipy._lib scipy.optimize
Projects
None yet
Development

Successfully merging this pull request may close these issues.

ENH: The Fortran 77 implementation of COBYLA is buggy and challenging to maintain. Switch to the PRIMA implementation?
7 participants