Bugfix: IPOPT log parser and no objective case#3738
Conversation
| # If the NL instance has no objectives, report zeros | ||
| if not getattr(self._nl_info, "objectives", None): | ||
| vars_ = ( | ||
| vars_to_load if vars_to_load is not None else self._nl_info.variable_map | ||
| ) | ||
| return {v: 0.0 for v in vars_} |
There was a problem hiding this comment.
This fix is suitable if IPOPT converges to a feasible point, but if it converges to a point of local infeasibility, the values of the dual variables convey useful information about which bounds may be active at a solution.
There was a problem hiding this comment.
Per your suggestion, though, if there are no objectives, these should be set to 0. So I don't think we change this. We capture the raw logs anyway, so folks can inspect those if they want more details.
There was a problem hiding this comment.
Would it be better / more appropriate to return {} here? Unspecified duals / reduced costs are assumed to be 0, right?
There was a problem hiding this comment.
@mrmundt IPOPT switches objectives when it enters into Restoration mode, using the L1 norm of the constraint violation as an objective. If it converges to an infeasible point with the exit message EXIT: Converged to a point of local infeasibility. then it is at a local minimum of the constraint violation, and we expect nonzero dual variables and reduced costs for this objective. I don't know if IPOPT makes these values available to AMPL, though.
There was a problem hiding this comment.
I am not sure what to do with this information because I only understand what half of it means.
There was a problem hiding this comment.
If IPOPT converges to an infeasible point, the dual variables and reduced costs are nonzero and contain useful information. I'm not sure whether IPOPT makes that information available via the interface you're using.
blnicho
left a comment
There was a problem hiding this comment.
It might be worth adding some additional tests for the log parser with different Ipopt print_level. We might just want to detect that a non-default print_level was used and add logic to not try to parse it. This doesn't have to hold up this PR but I was thinking about it in the context of making the parser more robust.
| # If the NL instance has no objectives, report zeros | ||
| if not getattr(self._nl_info, "objectives", None): | ||
| vars_ = ( | ||
| vars_to_load if vars_to_load is not None else self._nl_info.variable_map | ||
| ) | ||
| return {v: 0.0 for v in vars_} |
There was a problem hiding this comment.
Would it be better / more appropriate to return {} here? Unspecified duals / reduced costs are assumed to be 0, right?
| vars_ = ( | ||
| vars_to_load if vars_to_load is not None else self._nl_info.variables | ||
| ) | ||
| return ComponentMap((v, 0.0) for v in vars_) |
There was a problem hiding this comment.
Is it more correct to just return an empty ComponentMap? Aren't missing entries assumed to be 0?
There was a problem hiding this comment.
So I did this (both here and for get_duals), and it actually goes against what we say is a norm, surprisingly enough:
________________________________________ TestSolvers.test_no_objective_3_ipopt _________________________________________
a = (<pyomo.contrib.solver.tests.solvers.test_solvers.TestSolvers testMethod=test_no_objective_3_ipopt>,), kw = {}
@wraps(func)
def standalone_func(*a, **kw):
> return func(*(a + p.args), **p.kwargs, **kw)
../venv-pyomo/lib/python3.12/site-packages/parameterized/parameterized.py:620:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
self = <pyomo.contrib.solver.tests.solvers.test_solvers.TestSolvers testMethod=test_no_objective_3_ipopt>
name = 'ipopt', opt_class = <class 'pyomo.contrib.solver.solvers.ipopt.Ipopt'>, use_presolve = False
@parameterized.expand(input=_load_tests(all_solvers))
def test_no_objective(
self, name: str, opt_class: Type[SolverBase], use_presolve: bool
):
opt: SolverBase = opt_class()
if not opt.available():
raise unittest.SkipTest(f'Solver {opt.name} not available.')
check_duals = True
if any(name.startswith(i) for i in nl_solvers_set):
if use_presolve:
check_duals = False
opt.config.writer_config.linear_presolve = True
else:
opt.config.writer_config.linear_presolve = False
m = pyo.ConcreteModel()
m.x = pyo.Var()
m.y = pyo.Var()
m.a1 = pyo.Param(mutable=True)
m.a2 = pyo.Param(mutable=True)
m.b1 = pyo.Param(mutable=True)
m.b2 = pyo.Param(mutable=True)
m.c1 = pyo.Constraint(expr=m.y == m.a1 * m.x + m.b1)
m.c2 = pyo.Constraint(expr=m.y == m.a2 * m.x + m.b2)
params_to_test = [(1, -1, 2, 1), (1, -2, 2, 1), (1, -1, 3, 1)]
for a1, a2, b1, b2 in params_to_test:
m.a1.value = a1
m.a2.value = a2
m.b1.value = b1
m.b2.value = b2
res: Results = opt.solve(m)
self.assertEqual(res.solution_status, SolutionStatus.optimal)
self.assertAlmostEqual(m.x.value, (b2 - b1) / (a1 - a2))
self.assertAlmostEqual(m.y.value, a1 * (b2 - b1) / (a1 - a2) + b1)
self.assertEqual(res.incumbent_objective, None)
self.assertEqual(res.objective_bound, None)
if check_duals:
duals = res.solution_loader.get_duals()
> self.assertAlmostEqual(duals[m.c1], 0)
E KeyError: <pyomo.core.base.constraint.ScalarConstraint object at 0x11b9effc0>
pyomo/contrib/solver/tests/solvers/test_solvers.py:994: KeyError
If I return an empty ComponentMap or an empty dict, then you can't actually access that item anymore.
There was a problem hiding this comment.
Okay, empty ComponentMap is fine; it's empty dict for get_duals that causes these failures.
| cons = ( | ||
| cons_to_load if cons_to_load is not None else self._nl_info.constraints | ||
| ) | ||
| return {c: 0.0 for c in cons} |
There was a problem hiding this comment.
As with RC, is it more correct to return {}?
There was a problem hiding this comment.
(I am happy to defer sorting out the duals / rc until the next PR (which will be a more substantive rework of the SOL parser)
Co-authored-by: John Siirola <[email protected]>
Co-authored-by: John Siirola <[email protected]>
|
@AlexGisi - you pointed out a different missed thing in the IPOPT parser, though! We did only have lowercase |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3738 +/- ##
==========================================
- Coverage 89.31% 89.31% -0.01%
==========================================
Files 896 896
Lines 103697 103728 +31
==========================================
+ Hits 92619 92645 +26
- Misses 11078 11083 +5
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
jsiirola
left a comment
There was a problem hiding this comment.
Two minor nits, but I'm not going to hold up the PR for them.
One question: is there a test that exercises the "token split" logic in the iteration log parser?
| try: | ||
| idx = tokens[0].index('-') | ||
| head = tokens[0][:idx] | ||
| if head and head.rstrip('r').isdigit(): |
There was a problem hiding this comment.
Why is this test needed? The only way for a - to be in tokens[0] is if the first two columns get merged...
| continue | ||
| # IPOPT sometimes mashes the first two column values together | ||
| # (e.g., "2r-4.93e-03"). We need to split them. | ||
| try: |
There was a problem hiding this comment.
I'd still to prefer to not rely on the exception for the "normal" case where the columns are properly separated. That is, look for the -, and if it was found, then split the tokens.
| cons = ( | ||
| cons_to_load if cons_to_load is not None else self._nl_info.constraints | ||
| ) | ||
| return {c: 0.0 for c in cons} |
There was a problem hiding this comment.
(I am happy to defer sorting out the duals / rc until the next PR (which will be a more substantive rework of the SOL parser)
|
Jenkins failures were related to #3750 , unrelated to anything else. So this is technically good to go. |
Fixes #3736
Summary/Motivation:
The issue above mentions two bugs that are related to IPOPT/parsing both the output and the
solfiles. This PR fixes both of those bugs and adds tests.Changes proposed in this PR:
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: