From d96a398d56f5845dfe2dfd1fcf750aee48d980f1 Mon Sep 17 00:00:00 2001 From: Shugo Kaneko <49300827+kanekosh@users.noreply.github.com> Date: Fri, 5 Jul 2024 23:21:56 -0400 Subject: [PATCH 1/3] Added linear constraint check and updated documentation (#410) * updated linear constraint guide * Raise error if user is returning linear constraint values * minor edits * modified some tests and added a test * format --------- Co-authored-by: Alasdair Gray Co-authored-by: Marco Mangano <36549388+marcomangano@users.noreply.github.com> --- doc/guide.rst | 31 +++++++++++++++------- pyoptsparse/pyOpt_optimizer.py | 20 +++++++++++++++ tests/test_lincon_error.py | 47 ++++++++++++++++++++++++++++++++++ tests/test_snopt_bugfix.py | 33 ++---------------------- tests/test_user_termination.py | 2 -- 5 files changed, 91 insertions(+), 42 deletions(-) create mode 100644 tests/test_lincon_error.py diff --git a/doc/guide.rst b/doc/guide.rst index 0ed5d761..b7341eed 100644 --- a/doc/guide.rst +++ b/doc/guide.rst @@ -22,13 +22,13 @@ The optimization class is created using the following call: .. code-block:: python - optProb = Optimization("name", objFun) + optProb = Optimization("name", objconFun) -The general template of the objective function is as follows: +The general template of the objective and constraint function is as follows: .. code-block:: python - def obj_fun(xdict): + def objconFun(xdict): funcs = {} funcs["obj_name"] = function(xdict) funcs["con_name"] = function(xdict) @@ -196,17 +196,30 @@ This argument is a dictionary, and the keys must match the design variable sets Essentially what we have done is specified the which blocks of the constraint rows are non-zero, and provided the sparsity structure of ones that are sparse. -For linear constraints the values in ``jac`` are meaningful: -they must be the actual linear constraint Jacobian values (which do not change). -For non-linear constraints, only the sparsity structure (i.e. which entries are nonzero) is significant. -The values themselves will be determined by a call to the ``sens()`` function. - -Also note, that the ``wrt`` and ``jac`` keyword arguments are only supported when user-supplied sensitivity is used. +Note that the ``wrt`` and ``jac`` keyword arguments are only supported when user-supplied sensitivity is used. If automatic gradients from pyOptSparse are used, the constraint Jacobian will necessarily be dense. .. note:: Currently, only the optimizers SNOPT and IPOPT support sparse Jacobians. +Linear Constraints +~~~~~~~~~~~~~~~~~~ +Linear constraints in pyOptSparse are defined exclusively by ``jac``, ``lower``, and ``upper`` entries of the ``addConGroup`` method. +For linear constraint :math:`g_L \leq Ax + b \leq g_U`, the constraint definition would look like: + +.. code-block:: python + + optProb.addConGroup("con", num_cons, linear=True, wrt=["xvars"], jac={"xvars": A}, lower=gL - b, upper=gU - b) + +Users should not provide the linear constraint values (i.e., :math:`g = Ax + b`) in a user-defined objective/constraint function. +pyOptSparse will raise an error if you do so. + +For linear constraints, the values in ``jac`` are meaningful: +they must be the actual linear constraint Jacobian values (which do not change). +For non-linear constraints, only the sparsity structure (i.e. which entries are nonzero) is significant. +The values themselves will be determined by a call to the ``sens()`` function. + + Objectives ++++++++++ diff --git a/pyoptsparse/pyOpt_optimizer.py b/pyoptsparse/pyOpt_optimizer.py index 70ba70c7..2ae6dca0 100644 --- a/pyoptsparse/pyOpt_optimizer.py +++ b/pyoptsparse/pyOpt_optimizer.py @@ -367,6 +367,10 @@ def _masterFunc2(self, x, evaluate, writeHist=True): self.userObjTime += time.time() - timeA self.userObjCalls += 1 + # Make sure the user-defined function does *not* return linear constraint values + if self.callCounter == 0: + self._checkLinearConstraints(funcs) + # Discard zero imaginary components in funcs for key, val in funcs.items(): funcs[key] = np.real(val) @@ -417,6 +421,10 @@ def _masterFunc2(self, x, evaluate, writeHist=True): self.userObjTime += time.time() - timeA self.userObjCalls += 1 + # Make sure the user-defined function does *not* return linear constraint values + if self.callCounter == 0: + self._checkLinearConstraints(funcs) + # Discard zero imaginary components in funcs for key, val in funcs.items(): funcs[key] = np.real(val) @@ -867,6 +875,18 @@ def _on_setOption(self, name, value): """ pass + def _checkLinearConstraints(self, funcs): + """ + Makes sure that the user-defined obj/con function does not compute the linear constraint values + because the linear constraints are exclusively defined by jac and bounds in addConGroup. + """ + for conName in self.optProb.constraints: + if self.optProb.constraints[conName].linear and conName in funcs: + raise Error( + "Value for linear constraint returned from user obj function. Linear constraints " + + "are evaluated internally and should not be returned from the user's function." + ) + def setOption(self, name, value=None): """ Generic routine for all option setting. The routine does diff --git a/tests/test_lincon_error.py b/tests/test_lincon_error.py new file mode 100644 index 00000000..7af5723b --- /dev/null +++ b/tests/test_lincon_error.py @@ -0,0 +1,47 @@ +""" +Tests that pyOptSparse raises an error when a user-defined obj/con function returns a linear constraint value +(which should not because linear constraint is defined exclusively by jac and bounds) +""" + +# Standard Python modules +import unittest + +# First party modules +from pyoptsparse import SLSQP, Optimization +from pyoptsparse.pyOpt_error import Error + + +def objfunc(xdict): + """Evaluates the equation f(x,y) = (x-3)^2 + xy + (y+4)^2 - 3""" + x = xdict["x"] + funcs = {} + + funcs["obj"] = x**2 + funcs["con"] = x - 1 # falsely return a linear constraint value + + fail = False + return funcs, fail + + +class TestLinearConstraintCheck(unittest.TestCase): + def test(self): + # define an optimization problem with a linear constraint + optProb = Optimization("test", objfunc) + optProb.addVarGroup("x", 1, value=1) + optProb.addObj("obj") + optProb.addConGroup("con", 1, lower=1.0, linear=True, wrt=["x"], jac={"x": [1.0]}) + + opt = SLSQP() + with self.assertRaises(Error) as context: + opt(optProb, sens="FD") + + # check if we get the expected error message + err_msg = ( + "Value for linear constraint returned from user obj function. Linear constraints " + + "are evaluated internally and should not be returned from the user's function." + ) + self.assertEqual(err_msg, str(context.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_snopt_bugfix.py b/tests/test_snopt_bugfix.py index d3c8b598..12ffe28a 100644 --- a/tests/test_snopt_bugfix.py +++ b/tests/test_snopt_bugfix.py @@ -21,35 +21,6 @@ def objfunc(xdict): funcs = {} funcs["obj"] = (x - 3.0) ** 2 + x * y + (y + 4.0) ** 2 - 3.0 - conval = -x + y - funcs["con"] = conval - - fail = False - return funcs, fail - - -def objfunc_no_con(xdict): - """Evaluates the equation f(x,y) = (x-3)^2 + xy + (y+4)^2 - 3""" - x = xdict["x"] - y = xdict["y"] - funcs = {} - - funcs["obj"] = (x - 3.0) ** 2 + x * y + (y + 4.0) ** 2 - 3.0 - - fail = False - return funcs, fail - - -def objfunc_2con(xdict): - """Evaluates the equation f(x,y) = (x-3)^2 + xy + (y+4)^2 - 3""" - x = xdict["x"] - y = xdict["y"] - funcs = {} - - funcs["obj"] = (x - 3.0) ** 2 + x * y + (y + 4.0) ** 2 - 3.0 - conval = -x + y - funcs["con"] = conval * np.ones(2) - funcs["con2"] = (conval + 1) * np.ones(3) fail = False return funcs, fail @@ -115,7 +86,7 @@ def test_opt(self): def test_opt_bug1(self): # Due to a new feature, there is a TypeError when you optimize a model without a constraint. - optProb = Optimization("Paraboloid", objfunc_no_con) + optProb = Optimization("Paraboloid", objfunc) # Design Variables optProb.addVarGroup("x", 1, varType="c", lower=-50.0, upper=50.0, value=0.0) @@ -141,7 +112,7 @@ def test_opt_bug1(self): def test_opt_bug_print_2con(self): # Optimization Object - optProb = Optimization("Paraboloid", objfunc_2con) + optProb = Optimization("Paraboloid", objfunc) # Design Variables optProb.addVarGroup("x", 1, varType="c", lower=-50.0, upper=50.0, value=0.0) diff --git a/tests/test_user_termination.py b/tests/test_user_termination.py index 9aa4a980..017255f3 100644 --- a/tests/test_user_termination.py +++ b/tests/test_user_termination.py @@ -30,8 +30,6 @@ def objfunc(self, xdict): funcs = {} funcs["obj"] = (x - 3.0) ** 2 + x * y + (y + 4.0) ** 2 - 3.0 - conval = -x + y - funcs["con"] = conval if self.obj_count > self.max_obj: fail = 2 From 7e862db49bbc8bde2b49dee686cce68c8d2ab9f0 Mon Sep 17 00:00:00 2001 From: Sabet Seraj <48863473+sseraj@users.noreply.github.com> Date: Mon, 8 Jul 2024 19:47:31 -0600 Subject: [PATCH 2/3] Added restartDict to snstop (#404) * save restartDict after every major iteration * added restartDict as snstop callback argument * keep the final restartDict * added example to docs * added test * test major iterations instead of function evaluations * bumped minor version * added snstop arguments option * added work arrays save option * added error for unknown snstop arg * updated docs * addressed Ella's comments * minor version bump * updated comment --------- Co-authored-by: Marco Mangano <36549388+marcomangano@users.noreply.github.com> --- doc/optimizers/SNOPT_options.yaml | 30 +++++++++++ pyoptsparse/__init__.py | 2 +- pyoptsparse/pySNOPT/pySNOPT.py | 35 ++++++++++++- tests/test_hs015.py | 86 +++++++++++++++++++++++++++++++ 4 files changed, 150 insertions(+), 3 deletions(-) diff --git a/doc/optimizers/SNOPT_options.yaml b/doc/optimizers/SNOPT_options.yaml index 0c740234..65240285 100644 --- a/doc/optimizers/SNOPT_options.yaml +++ b/doc/optimizers/SNOPT_options.yaml @@ -100,7 +100,37 @@ Return work arrays: These arrays can be used to hot start a subsequent optimization. The SNOPT option 'Sticky parameters' will also be automatically set to 'Yes' to facilitate the hot start. +Work arrays save file: + desc: > + This option is unique to the Python wrapper. + The SNOPT work arrays will be pickled and saved to this file after each major iteration. + This file is useful if you want to restart an optimization that did not exit cleanly. + If None, the work arrays are not saved. + snSTOP function handle: desc: > This option is unique to the Python wrapper. A function handle can be supplied which is called at the end of each major iteration. + The following is an example of a callback function that saves the restart dictionary + to a different file after each major iteration. + + .. code-block:: python + + def snstopCallback(iterDict, restartDict): + # Get the major iteration number + nMajor = iterDict["nMajor"] + + # Save the restart dictionary + writePickle(f"restart_{nMajor}.pickle", restartDict) + + return 0 + +snSTOP arguments: + desc: | + This option is unique to the Python wrapper. + It specifies a list of arguments that will be passed to the snSTOP function handle. + ``iterDict`` is always passed as an argument. + Additional arguments are passed in the same order as this list. + The possible values are + + - ``restartDict`` diff --git a/pyoptsparse/__init__.py b/pyoptsparse/__init__.py index 5ab4a539..c122965d 100644 --- a/pyoptsparse/__init__.py +++ b/pyoptsparse/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.11.3" +__version__ = "2.12.0" from .pyOpt_history import History from .pyOpt_variable import Variable diff --git a/pyoptsparse/pySNOPT/pySNOPT.py b/pyoptsparse/pySNOPT/pySNOPT.py index a7d7c168..1a39fb2b 100644 --- a/pyoptsparse/pySNOPT/pySNOPT.py +++ b/pyoptsparse/pySNOPT/pySNOPT.py @@ -12,7 +12,7 @@ from typing import Any, Dict, Optional, Tuple # External modules -from baseclasses.utils import CaseInsensitiveSet +from baseclasses.utils import CaseInsensitiveSet, writePickle import numpy as np from numpy import ndarray from pkg_resources import parse_version @@ -60,7 +60,9 @@ def __init__(self, raiseError=True, options: Dict = {}): { "Save major iteration variables", "Return work arrays", + "Work arrays save file", "snSTOP function handle", + "snSTOP arguments", } ) @@ -118,7 +120,9 @@ def _getDefaultOptions() -> Dict[str, Any]: "Total real workspace": [int, None], "Save major iteration variables": [list, []], "Return work arrays": [bool, False], + "Work arrays save file": [(type(None), str), None], "snSTOP function handle": [(type(None), type(lambda: None)), None], + "snSTOP arguments": [list, []], } return defOpts @@ -667,12 +671,39 @@ def _snstop(self, ktcond, mjrprtlvl, minimize, n, nncon, nnobj, ns, itn, nmajor, if "funcs" in self.cache.keys(): iterDict["funcs"].update(self.cache["funcs"]) + # Create the restart dictionary to be passed to snstop_handle + restartDict = { + "cw": cw, + "iw": iw, + "rw": rw, + "xs": x, # x is the same as xs; we call it x here to be consistent with the SNOPT subroutine snSTOP + "hs": hs, + "pi": pi, + } + + workArraysSave = self.getOption("Work arrays save file") + if workArraysSave is not None: + # Save the restart dictionary + writePickle(workArraysSave, restartDict) + # perform callback if requested snstop_handle = self.getOption("snSTOP function handle") if snstop_handle is not None: + + # Get the arguments to pass in to snstop_handle + # iterDict is always included + snstopArgs = [iterDict] + for snstopArg in self.getOption("snSTOP arguments"): + if snstopArg == "restartDict": + snstopArgs.append(restartDict) + else: + raise Error(f"Received unknown snSTOP argument {snstopArg}. " + + "Please see 'snSTOP arguments' option in the pyOptSparse documentation " + + "under 'SNOPT'.") + if not self.storeHistory: raise Error("snSTOP function handle must be used with storeHistory=True") - iabort = snstop_handle(iterDict) + iabort = snstop_handle(*snstopArgs) # write iterDict again if anything was inserted if self.storeHistory and callCounter is not None: self.hist.write(callCounter, iterDict) diff --git a/tests/test_hs015.py b/tests/test_hs015.py index 88f8779c..27ee8fd3 100644 --- a/tests/test_hs015.py +++ b/tests/test_hs015.py @@ -1,9 +1,11 @@ """Test solution of problem HS15 from the Hock & Schittkowski collection""" # Standard Python modules +import os import unittest # External modules +from baseclasses.utils import readPickle, writePickle import numpy as np from parameterized import parameterized @@ -193,6 +195,90 @@ def test_snopt_snstop(self): # we should get 70/74 self.assert_inform_equal(sol, optInform=74) + def test_snopt_snstop_restart(self): + pickleFile = "restart.pickle" + + def my_snstop_restart(iterDict, restartDict): + # Save the restart dictionary + writePickle(pickleFile, restartDict) + + # Exit after 5 major iterations + if iterDict["nMajor"] == 5: + return 1 + + return 0 + + # Run the optimization for 5 major iterations + self.optName = "SNOPT" + self.setup_optProb() + optOptions = { + "snSTOP function handle": my_snstop_restart, + "snSTOP arguments": ["restartDict"], + } + sol = self.optimize(optOptions=optOptions, storeHistory=True) + + # Check that the optimization exited with 74 + self.assert_inform_equal(sol, optInform=74) + + # Read the restart dictionary pickle file saved by snstop + restartDict = readPickle(pickleFile) + + # Now optimize again but using the restart dictionary + self.setup_optProb() + opt = OPT( + self.optName, + options={ + "Start": "Hot", + "Verify level": -1, + "snSTOP function handle": my_snstop_restart, + "snSTOP arguments": ["restartDict"], + }, + ) + histFile = "restart.hst" + sol = opt(self.optProb, sens=self.sens, storeHistory=histFile, restartDict=restartDict) + + # Check that the optimization converged in fewer than 5 more major iterations + self.assert_solution_allclose(sol, 1e-12) + self.assert_inform_equal(sol, optInform=1) + + # Delete the pickle and history files + os.remove(pickleFile) + os.remove(histFile) + + def test_snopt_work_arrays_save(self): + # Run the optimization for 5 major iterations + self.optName = "SNOPT" + self.setup_optProb() + pickleFile = "work_arrays_save.pickle" + optOptions = { + "snSTOP function handle": self.my_snstop, + "Work arrays save file": pickleFile, + } + sol = self.optimize(optOptions=optOptions, storeHistory=True) + + # Read the restart dictionary pickle file saved by snstop + restartDict = readPickle(pickleFile) + + # Now optimize again but using the restart dictionary + self.setup_optProb() + opt = OPT( + self.optName, + options={ + "Start": "Hot", + "Verify level": -1, + }, + ) + histFile = "work_arrays_save.hst" + sol = opt(self.optProb, sens=self.sens, storeHistory=histFile, restartDict=restartDict) + + # Check that the optimization converged + self.assert_solution_allclose(sol, 1e-12) + self.assert_inform_equal(sol, optInform=1) + + # Delete the pickle and history files + os.remove(pickleFile) + os.remove(histFile) + def test_snopt_failed_initial(self): def failed_fun(x_dict): funcs = {"obj": 0.0, "con": [np.nan, np.nan]} From bc021e4b76e2d6ff53c6c8312b39ef180be04b4c Mon Sep 17 00:00:00 2001 From: Ella Wu <602725+ewu63@users.noreply.github.com> Date: Mon, 5 Aug 2024 13:12:58 -0700 Subject: [PATCH 3/3] use packaging instead of pkg_resources (#412) --- .github/environment.yml | 1 + pyoptsparse/pySNOPT/pySNOPT.py | 2 +- setup.py | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/environment.yml b/.github/environment.yml index 65035f83..237cc786 100644 --- a/.github/environment.yml +++ b/.github/environment.yml @@ -10,6 +10,7 @@ dependencies: - pip - setuptools - build + - packaging # testing - parameterized - testflo diff --git a/pyoptsparse/pySNOPT/pySNOPT.py b/pyoptsparse/pySNOPT/pySNOPT.py index 1a39fb2b..5354f78e 100644 --- a/pyoptsparse/pySNOPT/pySNOPT.py +++ b/pyoptsparse/pySNOPT/pySNOPT.py @@ -15,7 +15,7 @@ from baseclasses.utils import CaseInsensitiveSet, writePickle import numpy as np from numpy import ndarray -from pkg_resources import parse_version +from packaging.version import parse as parse_version # Local modules from ..pyOpt_error import Error diff --git a/setup.py b/setup.py index 6866e167..205c1416 100644 --- a/setup.py +++ b/setup.py @@ -102,6 +102,7 @@ def copy_shared_libraries(): platforms=["Linux"], keywords="optimization", install_requires=[ + "packaging", "sqlitedict>=1.6", "numpy>=1.21,<2", "scipy>=1.7",