Skip to content

Commit 3ef2be1

Browse files
authored
Merge pull request #3892 from Robbybp/cyipopt-hessian
Use Hessian approximation in CyIpopt when grey-box without Hessian is present
2 parents e8a1bea + beb1dde commit 3ef2be1

4 files changed

Lines changed: 138 additions & 21 deletions

File tree

pyomo/contrib/pynumero/algorithms/solvers/cyipopt_solver.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
#
33
# Pyomo: Python Optimization Modeling Objects
44
# Copyright (c) 2008-2026 National Technology and Engineering Solutions of Sandia, LLC
5-
# Under the terms of Contract DE-NA0003525 with National Technology and Engineering
5+
# Under the terms of Contract DE-NA0003525 with National Technology and Engineerinkg
66
# Solutions of Sandia, LLC, the U.S. Government retains certain rights in this
77
# software. This software is distributed under the 3-clause BSD License.
88
# ____________________________________________________________________________________
@@ -350,6 +350,7 @@ def solve(self, model, **kwds):
350350
intermediate_callback=config.intermediate_callback,
351351
halt_on_evaluation_error=config.halt_on_evaluation_error,
352352
)
353+
353354
ng = len(problem.g_lb())
354355
nx = len(problem.x_lb())
355356
cyipopt_solver = problem
@@ -371,6 +372,21 @@ def solve(self, model, **kwds):
371372
set_scaling = cyipopt_solver.setProblemScaling
372373
set_scaling(obj_scaling, x_scaling, g_scaling)
373374

375+
# has_hessian_support only exists for PyomoNLPWithGreyBoxBlocks, but if
376+
# we have grey box blocks, we know our NLP is of this class.
377+
if grey_box_blocks and not nlp.has_hessian_support():
378+
# Note that `config` is a copy of the instance-level self.config so
379+
# we don't have to reset the option after the solve.
380+
hessian_approx = config.options.get("hessian_approximation", None)
381+
if hessian_approx is not None and hessian_approx.value() == "exact":
382+
logger.warning(
383+
"'hessian_approximation' option is set to 'exact', but at"
384+
" least one grey box model does not support Hessians."
385+
" Overriding this option with"
386+
" hessian_approximation='limited-memory'."
387+
)
388+
config.options["hessian_approximation"] = "limited-memory"
389+
374390
# add options
375391
try:
376392
add_option = cyipopt_solver.add_option

pyomo/contrib/pynumero/algorithms/solvers/tests/test_cyipopt_solver.py

Lines changed: 96 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
# software. This software is distributed under the 3-clause BSD License.
88
# ____________________________________________________________________________________
99

10+
import io
11+
import logging
1012
import pyomo.common.unittest as unittest
13+
from pyomo.common.log import LoggingIntercept
1114
import pyomo.environ as pyo
1215
from pyomo.common.tempfiles import TempfileManager
1316

@@ -39,6 +42,11 @@
3942
)
4043

4144
from pyomo.contrib.pynumero.algorithms.solvers.cyipopt_solver import CyIpoptSolver
45+
from pyomo.contrib.pynumero.interfaces.external_grey_box import ExternalGreyBoxBlock
46+
import pyomo.contrib.pynumero.interfaces.tests.external_grey_box_models as ex_models
47+
from pyomo.contrib.pynumero.examples.external_grey_box.react_example.reactor_model_outputs import (
48+
ReactorConcentrationsOutputModel,
49+
)
4250

4351
if cyipopt_available:
4452
# We don't raise unittest.SkipTest if not cyipopt_available as there is a
@@ -207,7 +215,7 @@ def test_model1(self):
207215
nlp.set_primals(x)
208216
nlp.set_duals(y_sol)
209217
self.assertAlmostEqual(nlp.evaluate_objective(), -428.6362455416348, places=5)
210-
self.assertTrue(np.allclose(info['mult_g'], y_sol, rtol=1e-4))
218+
self.assertTrue(np.allclose(info["mult_g"], y_sol, rtol=1e-4))
211219

212220
def test_model1_with_scaling(self):
213221
m = create_model1()
@@ -219,35 +227,35 @@ def test_model1_with_scaling(self):
219227

220228
with TempfileManager.new_context() as temp:
221229
cynlp = CyIpoptNLP(PyomoNLP(m))
222-
logfile = temp.create_tempfile('_cyipopt-scaling.log')
230+
logfile = temp.create_tempfile("_cyipopt-scaling.log")
223231
options = {
224-
'nlp_scaling_method': 'user-scaling',
225-
'output_file': logfile,
226-
'file_print_level': 10,
227-
'max_iter': 0,
232+
"nlp_scaling_method": "user-scaling",
233+
"output_file": logfile,
234+
"file_print_level": 10,
235+
"max_iter": 0,
228236
}
229237
solver = CyIpoptSolver(cynlp, options=options)
230238
x, info = solver.solve()
231239
cynlp.close()
232240

233-
with open(logfile, 'r') as fd:
241+
with open(logfile, "r") as fd:
234242
solver_trace = fd.read()
235243

236244
# check for the following strings in the log
237-
self.assertIn('nlp_scaling_method = user-scaling', solver_trace)
245+
self.assertIn("nlp_scaling_method = user-scaling", solver_trace)
238246
self.assertIn(f"output_file = {logfile}", solver_trace)
239-
self.assertIn('objective scaling factor = 1e-06', solver_trace)
240-
self.assertIn('x scaling provided', solver_trace)
241-
self.assertIn('c scaling provided', solver_trace)
242-
self.assertIn('d scaling provided', solver_trace)
247+
self.assertIn("objective scaling factor = 1e-06", solver_trace)
248+
self.assertIn("x scaling provided", solver_trace)
249+
self.assertIn("c scaling provided", solver_trace)
250+
self.assertIn("d scaling provided", solver_trace)
243251
self.assertIn('DenseVector "x scaling vector" with 3 elements:', solver_trace)
244-
self.assertIn('x scaling vector[ 1]= 1.0000000000000000e+00', solver_trace)
245-
self.assertIn('x scaling vector[ 2]= 1.0000000000000000e+00', solver_trace)
246-
self.assertIn('x scaling vector[ 3]= 4.0000000000000000e+00', solver_trace)
252+
self.assertIn("x scaling vector[ 1]= 1.0000000000000000e+00", solver_trace)
253+
self.assertIn("x scaling vector[ 2]= 1.0000000000000000e+00", solver_trace)
254+
self.assertIn("x scaling vector[ 3]= 4.0000000000000000e+00", solver_trace)
247255
self.assertIn('DenseVector "c scaling vector" with 1 elements:', solver_trace)
248-
self.assertIn('c scaling vector[ 1]= 2.0000000000000000e+00', solver_trace)
256+
self.assertIn("c scaling vector[ 1]= 2.0000000000000000e+00", solver_trace)
249257
self.assertIn('DenseVector "d scaling vector" with 1 elements:', solver_trace)
250-
self.assertIn('d scaling vector[ 1]= 3.0000000000000000e+00', solver_trace)
258+
self.assertIn("d scaling vector[ 1]= 3.0000000000000000e+00", solver_trace)
251259

252260
def test_model2(self):
253261
model = create_model2()
@@ -260,7 +268,7 @@ def test_model2(self):
260268
nlp.set_primals(x)
261269
nlp.set_duals(y_sol)
262270
self.assertAlmostEqual(nlp.evaluate_objective(), -31.000000057167462, places=5)
263-
self.assertTrue(np.allclose(info['mult_g'], y_sol, rtol=1e-4))
271+
self.assertTrue(np.allclose(info["mult_g"], y_sol, rtol=1e-4))
264272

265273
def test_model3(self):
266274
G = np.array([[6, 2, 1], [2, 5, 2], [1, 2, 4]])
@@ -278,12 +286,12 @@ def test_model3(self):
278286
nlp.set_primals(x)
279287
nlp.set_duals(y_sol)
280288
self.assertAlmostEqual(nlp.evaluate_objective(), -3.5, places=5)
281-
self.assertTrue(np.allclose(info['mult_g'], y_sol, rtol=1e-4))
289+
self.assertTrue(np.allclose(info["mult_g"], y_sol, rtol=1e-4))
282290

283291
def test_options(self):
284292
model = create_model1()
285293
nlp = PyomoNLP(model)
286-
solver = CyIpoptSolver(CyIpoptNLP(nlp), options={'max_iter': 1})
294+
solver = CyIpoptSolver(CyIpoptNLP(nlp), options={"max_iter": 1})
287295
x, info = solver.solve(tee=False)
288296
nlp.set_primals(x)
289297
self.assertAlmostEqual(nlp.evaluate_objective(), -5.0879028e02, places=5)
@@ -414,3 +422,71 @@ def intermediate(
414422
x, y = iterate_data[-1]
415423
self.assertTrue(np.allclose(x_sol, x))
416424
self.assertTrue(np.allclose(y_sol, y))
425+
426+
427+
@unittest.skipUnless(cyipopt_available, "cyipopt is not available")
428+
class TestCyIpoptGreyBox(unittest.TestCase):
429+
"""Most of the grey-box functionality is tested elsewhere. Here we just
430+
need to test that we correctly override the hessian_approximation option
431+
when Hessians aren't supported.
432+
"""
433+
434+
def test_solve_hessian_not_supported(self):
435+
m = pyo.ConcreteModel()
436+
m.reactor = ExternalGreyBoxBlock(
437+
external_model=ReactorConcentrationsOutputModel()
438+
)
439+
m.k1con = pyo.Constraint(expr=m.reactor.inputs["k1"] == 5 / 6)
440+
m.k2con = pyo.Constraint(expr=m.reactor.inputs["k2"] == 5 / 3)
441+
m.k3con = pyo.Constraint(expr=m.reactor.inputs["k3"] == 1 / 6000)
442+
m.cafcon = pyo.Constraint(expr=m.reactor.inputs["caf"] == 10000)
443+
m.obj = pyo.Objective(expr=m.reactor.outputs["cb"], sense=pyo.maximize)
444+
solver = pyo.SolverFactory("cyipopt")
445+
results = solver.solve(m, tee=True)
446+
pyo.assert_optimal_termination(results)
447+
self.assertNotIn("hessian_approximation", solver.config.options)
448+
self.assertAlmostEqual(pyo.value(m.reactor.inputs["sv"]), 1.34381, places=3)
449+
self.assertAlmostEqual(pyo.value(m.reactor.outputs["cb"]), 1072.4372, places=2)
450+
451+
def test_solve_hessian_not_supported_override_user_option(self):
452+
"""Make sure we do this even when the user says to use an exact Hessian"""
453+
m = pyo.ConcreteModel()
454+
m.reactor = ExternalGreyBoxBlock(
455+
external_model=ReactorConcentrationsOutputModel()
456+
)
457+
m.k1con = pyo.Constraint(expr=m.reactor.inputs["k1"] == 5 / 6)
458+
m.k2con = pyo.Constraint(expr=m.reactor.inputs["k2"] == 5 / 3)
459+
m.k3con = pyo.Constraint(expr=m.reactor.inputs["k3"] == 1 / 6000)
460+
m.cafcon = pyo.Constraint(expr=m.reactor.inputs["caf"] == 10000)
461+
m.obj = pyo.Objective(expr=m.reactor.outputs["cb"], sense=pyo.maximize)
462+
solver = pyo.SolverFactory("cyipopt")
463+
solver.config.options["hessian_approximation"] = "exact"
464+
buf = io.StringIO()
465+
with LoggingIntercept(buf, "pyomo.contrib.pynumero", logging.WARNING):
466+
results = solver.solve(m, tee=True)
467+
msg = "at least one grey box model does not support Hessians"
468+
self.assertIn(msg, buf.getvalue())
469+
pyo.assert_optimal_termination(results)
470+
self.assertEqual(solver.config.options["hessian_approximation"], "exact")
471+
self.assertAlmostEqual(pyo.value(m.reactor.inputs["sv"]), 1.34381, places=3)
472+
self.assertAlmostEqual(pyo.value(m.reactor.outputs["cb"]), 1072.4372, places=2)
473+
474+
def test_hessian_supported_no_override(self):
475+
"""Make sure we didn't accidentally override the hessian_approximation
476+
option when Hessian *is* supported.
477+
"""
478+
m = pyo.ConcreteModel()
479+
external_model = ex_models.PressureDropSingleOutputWithHessian()
480+
m.x = pyo.Var(range(external_model.n_inputs()))
481+
m.eq = pyo.Constraint(expr=sum(m.x.values()) == 1)
482+
solver = pyo.SolverFactory("cyipopt")
483+
with TempfileManager.new_context() as temp:
484+
logfile = temp.create_tempfile("hessian-no-override.log")
485+
options = dict(output_file=logfile, max_iter=0, print_user_options="yes")
486+
solver.config.options.update(options)
487+
_, cynlp = solver.solve(m, tee=True, return_nlp=True)
488+
# IIRC this may be necessary to avoid file IO race condition
489+
# cynlp.close()
490+
with open(logfile, "r") as fd:
491+
solver_trace = fd.read()
492+
self.assertNotIn("hessian_approximation", solver_trace)

pyomo/contrib/pynumero/interfaces/pyomo_grey_box_nlp.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,9 @@ def load_state_into_pyomo(self, bound_multipliers=None):
503503
zip(self._pyomo_model_var_datas, -obj_sign * bound_multipliers[1])
504504
)
505505

506+
def has_hessian_support(self):
507+
return self._has_hessian_support
508+
506509

507510
def _default_if_none(value, default):
508511
if value is None:

pyomo/contrib/pynumero/interfaces/tests/test_pyomo_grey_box_nlp.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2586,6 +2586,28 @@ def test_duals_after_solve(self):
25862586
)
25872587
self.assertAlmostEqual(m.dual[m.egb]['egb.u2_con'], 62.5, places=3)
25882588

2589+
def test_has_hessian_support_false(self):
2590+
external_model = ex_models.PressureDropSingleOutput()
2591+
m = pyo.ConcreteModel()
2592+
m.x = pyo.Var(range(external_model.n_inputs()))
2593+
m.gb = ExternalGreyBoxBlock()
2594+
m.gb.set_external_model(external_model, inputs=list(m.x.values()))
2595+
# Some random constraint to let us construct the NLP...
2596+
m.eq = pyo.Constraint(expr=sum(m.x.values()) == 1)
2597+
nlp = PyomoNLPWithGreyBoxBlocks(m)
2598+
self.assertFalse(nlp.has_hessian_support())
2599+
2600+
def test_has_hessian_support_true(self):
2601+
external_model = ex_models.PressureDropSingleOutputWithHessian()
2602+
m = pyo.ConcreteModel()
2603+
m.x = pyo.Var(range(external_model.n_inputs()))
2604+
m.gb = ExternalGreyBoxBlock()
2605+
m.gb.set_external_model(external_model, inputs=list(m.x.values()))
2606+
# Some random constraint to let us construct the NLP...
2607+
m.eq = pyo.Constraint(expr=sum(m.x.values()) == 1)
2608+
nlp = PyomoNLPWithGreyBoxBlocks(m)
2609+
self.assertTrue(nlp.has_hessian_support())
2610+
25892611

25902612
class TestGreyBoxObjectives(unittest.TestCase):
25912613
@unittest.skipIf(not cyipopt_available, "CyIpopt needed to run tests with solve")

0 commit comments

Comments
 (0)