Fix non-rigorous crossed bound results - #4013
Conversation
Pyomo#3947) Extract the convexity/curvature classification out of GDP_LOA_Solver into a shared pyomo/contrib/gdpopt/nonrigorous_bounds.py module, and drive it from a single _crossed_bounds_are_certified class flag on both the GDPopt and MindtPy algorithm base classes. An algorithm is certified when its relaxation is valid for any model it accepts. The McCormick-based global algorithms (GDPopt GLOA, MindtPy GOA) are certified. The algorithms that linearize at trial points (GDPopt LOA, MindtPy OA and ECP) are only valid on convex models, so they are not certified and the model is checked for convexity before crossed bounds are trusted. This corrects the MindtPy classification, which previously marked GOA (the McCormick-based algorithm) as uncertified while leaving OA and ECP certified, and adds MindtPy the model-structure gate that GDPopt already had.
|
Pushed The inconsistencyThe two toolkits have matching algorithm pairs, and the distinguishing property is how each builds its relaxation:
The flag was on the wrong MindtPy solver. GOA is the McCormick-based algorithm — the direct analogue of GLOA, which this PR deliberately leaves certified — while OA and ECP are the analogues of LOA and were left untouched. What changed
A side benefit of routing through the flag: certified algorithms now skip the convexity scan entirely instead of relying on a base method that returned Effect on reported resultsFor MindtPy this is a behavior change in both directions, and both directions look like the intended fix:
Tests
Still worth a maintainer decision
|
|
Following up on open point 3 from my previous comment, where I flagged that the crossed-bound figures used in the tests (primal They did not. The point is resolved, and no code change is needed. Tracing the provenance:
So there is no evidence that a McCormick-based algorithm ever produced a genuinely crossed bound, and no separate cut-generation bug implied. The reclassification in The figures remain in the tests as a synthetic fixture, which is all they ever were. Open points 1 (module location) and 2 ( |
emma58
left a comment
There was a problem hiding this comment.
Thank you: This is a nice addition to keep users using from shooting themselves in the foot when we can guarantee a problem is convex. I had quite a few comments: I find all the names confusing--I'd vote for names that actually say something about certified or not certified convexity rather than about the crossed bounds. I also would strongly prefer that the implementation not depend on generate_standard_repn and instead use the QuadraticRepnVisitor. Also I'm curious why it is imperative that this work without numpy?
| from pyomo.common.dependencies import numpy as np, numpy_available | ||
| from pyomo.core import Block, Constraint, Objective, minimize, value | ||
| from pyomo.gdp import Disjunct | ||
| from pyomo.repn import generate_standard_repn |
There was a problem hiding this comment.
Please replace use of generate_standard_repn with the LinearRepnVisitor or the QuadraticRepnVisitor. It has known issues and we are hoping to eventually deprecate it. In this case, it looks like you want the QuadraticRepnVisitor.
There was a problem hiding this comment.
Replaced generate_standard_repn with QuadraticRepnVisitor in convexity.py.
| Uses NumPy when it is available. Otherwise falls back to a cyclic Jacobi | ||
| iteration so that curvature classification still works in environments | ||
| without NumPy. Returns None if the Jacobi iteration does not converge. | ||
| """ |
There was a problem hiding this comment.
Is it crucial that this work in an environment without numpy? Implementing a fallback seems like a lot of technical debt, and I'm not sure for what gain. It would not be unprecedented in Pyomo to have a piece of functionality that does truly depend on numpy--that would be fine for GDPopt.
There was a problem hiding this comment.
Removed the custom Jacobi fallback. Quadratic curvature checks now use numpy.linalg.eigvalsh
| if q_matrix is None: | ||
| return None | ||
|
|
||
| eigenvalue_tolerance = 1e-10 |
There was a problem hiding this comment.
This should be configurable in the options for the global algorithms
There was a problem hiding this comment.
Added eigenvalue_tolerance to the GDPopt and MindtPy solver options, with a default of 1e-10.
| return None | ||
|
|
||
|
|
||
| def model_may_have_nonrigorous_dual_bound(model): |
There was a problem hiding this comment.
I'd propose renaming this to model_is_not_certified_convex or something along those lines since that's what you're actually checking.
There was a problem hiding this comment.
Renamed the check to model_is_not_certified_convex.
| approximation dual bound computed for it must not be treated as rigorous. | ||
| """ | ||
| for obj in model.component_data_objects(Objective, active=True, descend_into=True): | ||
| degree = obj.expr.polynomial_degree() |
There was a problem hiding this comment.
polynomial_degree has some known issues also. It may be safer to walk this with the QuadraticRepnVisitor and draw your own conclusion from the result.
There was a problem hiding this comment.
Removed the polynomial_degree() checks. QuadraticRepnVisitor now determines whether each expression is quadratic or contains remaining nonlinear terms.
| self._nonrigorous_crossed_bounds = False | ||
| self._nonrigorous_dual_bound_possible = False |
There was a problem hiding this comment.
I'd advocate renaming these to something about whether or not convexity is certified, but if you prefer this, that's okay.
There was a problem hiding this comment.
Renamed the state to _model_is_not_certified_convex and _bounds_crossed_without_certified_convexity.
| class _GDPoptAlgorithm: | ||
| CONFIG = ConfigBlock("GDPopt") | ||
| _add_common_configs(CONFIG) | ||
| _crossed_bounds_are_certified = True |
There was a problem hiding this comment.
This name is confusing to me. Certified what? Seems like this should be _treat_problem_as_convex or something like that.
There was a problem hiding this comment.
Replaced the ambiguous certification flag with _requires_model_convexity. LOA, OA, and ECP set it to True; GLOA and GOA retain the default False.
| try: | ||
| self.assertFalse( | ||
| GDP_LOA_Solver()._problem_may_have_nonrigorous_dual_bound(convex) | ||
| ) | ||
| self.assertTrue( | ||
| GDP_LOA_Solver()._problem_may_have_nonrigorous_dual_bound(nonconvex) | ||
| ) | ||
| finally: | ||
| nonrigorous_bounds_module.numpy_available = original_numpy_available | ||
|
|
There was a problem hiding this comment.
This is unnecessary: Just import numpy_available here and guard the whole test to only run if it is available.
There was a problem hiding this comment.
Removed the Jacobi-specific test and guarded the NumPy-dependent curvature tests with numpy_available.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #4013 +/- ##
==========================================
+ Coverage 89.93% 89.99% +0.05%
==========================================
Files 917 918 +1
Lines 109135 109866 +731
==========================================
+ Hits 98148 98869 +721
- Misses 10987 10997 +10
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Addressed in 4f51f8e: the naming now describes convexity certification, expression inspection uses QuadraticRepnVisitor, the custom NumPy fallback was removed, the eigenvalue tolerance is configurable, and an end-to-end nonconvex LOA regression test was added. |
emma58
left a comment
There was a problem hiding this comment.
One last comment on import statements, but otherwise this looks good, thank you!
|
|
||
| class TestMindtPyCrossedBoundResults(unittest.TestCase): | ||
| def test_oa_crossed_bounds_are_not_reported_as_global_optimal(self): | ||
| from pyomo.contrib.mindtpy.outer_approximation import MindtPy_OA_Solver |
There was a problem hiding this comment.
Can you please move this import and the others below to the top of the file?
Fixes #3947.
Summary/Motivation:
When GDPopt LOA is applied to a nonconvex problem, its linearizations are not valid relaxations, so the "dual bound" it accumulates is not a rigorous bound. If those bounds cross, GDPopt currently snaps the dual bound to the primal bound and reports
optimalwithLB == UB. That presents a non-rigorous result as a certified global optimum, which is the failure reported in #3947.The fix reports what is actually known in that case: the incumbent is feasible, and no certified dual bound is available.
This is a replacement for #3948, which GitHub would not allow me to reopen after it was closed. The prior review discussion and history remain available there.
Changes proposed in this PR:
_crossed_bounds_are_certifiedclass flag to_GDPoptAlgorithm(defaultTrue), set toFalseonGDP_LOA_Solver. GLOA keeps the existing certified behavior.feasibleinstead ofoptimal, and report the uncertified side of the bound as infinite rather than snapping it to the primal bound._problem_may_have_nonrigorous_dual_bound()inpyomo/contrib/gdpopt/loa.py, which inspects the active objective and constraints: anything non-polynomial or of degree > 2 is treated as possibly non-rigorous, quadratic equalities are treated as possibly non-rigorous, and quadratic inequalities are classified by curvature against their bound sense.x**2 + x*y + y**2 <= 4are still certified.eigvalshwhen available, with a pure-Python Jacobi eigenvalue fallback so the classification also works in Pyomo's NumPy-free test environments.pyomo/contrib/gdpopt/tests/test_gdpopt.pyandpyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.pycovering crossed-bound reporting, non-polynomial detection, quadratic convexity classification (including the no-NumPy path), and preservation of certified GLOA behavior.main:python -m pytest -q pyomo/contrib/gdpopt/tests/test_gdpopt.py pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py96 passed, 19 skipped, 3 deselected in 43.01spython -m black --checkon the six changed files6 files would be left unchangedtypos --config ./.github/workflows/typos.tomlon the six changed filesAI-Use Disclosure
or
AI tools contributed to the development of this PR
Review process (select ONE):
Notes for reviewers (optional): This replacement PR carries over the implementation and tests from #3948 unchanged, then refreshes the branch against current
main. The replacement PR description and branch-refresh workflow were prepared with AI assistance and reviewed before posting.This is the largest of the four GDPopt fixes I have open and it changes reported termination conditions, so it deserves the closest look. Specific points where I would like reviewer judgement:
optimalwithLB == UBwill now returnfeasiblewith one bound infinite. That is the intent of GDPopt LOA reports non-rigorous nonconvex bounds as optimal LB=UB #3947, but it is user-visible and may affect downstream code that keys onoptimal.not _crossed_bounds_are_certifiedand_problem_may_have_nonrigorous_dual_bound(model). The MindtPy GOA path inbounds_converged()gates only onnot _crossed_bounds_are_certified, so any crossed bound there becomesfeasible. I would like a decision on whether MindtPy should carry the same model-structure gate._crossed_bounds_are_certified = Truewhile MindtPy GOA is set toFalse, even though they are the analogous global algorithms. That was deliberate — GLOA's certified behavior is covered by a regression test — but the inconsistency is worth an explicit maintainer decision._problem_may_have_nonrigorous_dual_bound()is intentionally conservative: quadratic equality constraints and anything of degree > 2 or non-polynomial are all treated as possibly non-rigorous. Convex problems expressed in ways the classifier cannot certify will lose theiroptimalstatus.50 * order**2) returningNoneon non-convergence — which is then treated as "not certified" — is a heuristic worth confirming.Legal Acknowledgement
By contributing to this software project, I have read the contribution guide and agree to the following terms and conditions for my contribution: