Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add get_current_iterate and get_current_violations methods to Problem class #182

Merged
merged 47 commits into from
Apr 28, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
81971d0
start function to get iterate
Robbybp Feb 10, 2023
c224638
implement get_current_iterate and get_current_violations methods
Robbybp Feb 10, 2023
f356322
fix typo in definition instance fixture
Robbybp Feb 10, 2023
7c82b25
tests for get_current_iterate and get_current_violations
Robbybp Feb 10, 2023
bf7de5c
TODO comments
Robbybp Feb 10, 2023
dfa93c4
update tolerance in primal infeas test
Robbybp Feb 10, 2023
60fcb04
Merge branch 'master' of https://github.com/mechmotum/cyipopt into ge…
Robbybp Feb 12, 2023
444f0bc
check ipopt version before calling GetIpoptCurrentIterate
Robbybp Feb 13, 2023
5b32906
check Ipopt version at build time
Robbybp Feb 14, 2023
f2a0acf
update pre/post-solve tests so they work with latest Ipopt branch
Robbybp Feb 14, 2023
77efa0d
add flag to degect whether we are in a call to IpoptSolve
Robbybp Feb 14, 2023
0ad62e8
remove outdated comment
Robbybp Feb 14, 2023
fd4356f
update return types from get_current_* to dicts
Robbybp Feb 14, 2023
d4c7a7c
turn off bound relaxation and set atol for (final) intermediate prima…
Robbybp Feb 15, 2023
9752fb9
Merge branch 'get-iterate' of https://github.com/robbybp/cyipopt into…
Robbybp Feb 15, 2023
7cf21e1
install libarchive
Robbybp Feb 16, 2023
4c0f4f0
Merge branch 'master' into get-iterate
Robbybp Feb 18, 2023
02bd357
get_current_iterate docstring
Robbybp Feb 18, 2023
e91ceff
docstring for get_current_violations
Robbybp Feb 18, 2023
e74299b
return none from get_current_* if values are not loaded
Robbybp Feb 19, 2023
5101bbd
Merge branch 'get-iterate' of https://github.com/robbybp/cyipopt into…
Robbybp Feb 19, 2023
5d08fed
update key in violation dict
Robbybp Feb 19, 2023
f0dba5e
update docstrings
Robbybp Feb 19, 2023
2aa23ab
update docstring
Robbybp Feb 19, 2023
29393b2
prototype support for 12-arg intermediate callback
Robbybp Feb 19, 2023
18d3812
Merge pull request #1 from Robbybp/cb-signature
Robbybp Feb 27, 2023
55d833d
update get_current_violations test to not rely on special attribute o…
Robbybp Feb 27, 2023
f502cee
add test using subclass of Problem
Robbybp Feb 27, 2023
ad973f5
document optional problem argument in intermediate callback
Robbybp Mar 17, 2023
7e5923f
raise error if intermediate call signature is not something we expect
Robbybp Mar 17, 2023
8573c67
add test callback with variable number of arguments
Robbybp Mar 17, 2023
c1f1b63
clarify logic and change name of flag when deciding which callback to…
Robbybp Mar 17, 2023
f93733a
add tests for exceptions with different invalid intermediate call sig…
Robbybp Mar 17, 2023
ff88aea
tutorial section using get_current_* by subclassing cyipopt.Problem
Robbybp Apr 9, 2023
a19b114
remove unnecessary punctuation
Robbybp Apr 10, 2023
54bd031
Merge branch 'master' of https://github.com/mechmotum/cyipopt into ge…
Robbybp Apr 25, 2023
abef99c
Merge branch 'get-iterate' of https://github.com/robbybp/cyipopt into…
Robbybp Apr 25, 2023
01a776b
remove code that was necessary to support and test a 12-argument call…
Robbybp Apr 25, 2023
b7046e7
update get_current_* section of docs
Robbybp Apr 25, 2023
2a24f09
pin scipy to 1.10.0
Robbybp Apr 27, 2023
de52b8e
Update .github/workflows/tests.yml
moorepants Apr 28, 2023
cc4efc0
Update .github/workflows/tests.yml
moorepants Apr 28, 2023
27ebaf4
Update .github/workflows/tests.yml
moorepants Apr 28, 2023
6614eaf
Update .github/workflows/tests.yml
moorepants Apr 28, 2023
aba6c97
Update .github/workflows/tests.yml
moorepants Apr 28, 2023
0a1af02
Update .github/workflows/tests.yml
moorepants Apr 28, 2023
f78615d
Drop Python 3.7 tests in CI.
moorepants Apr 28, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions cyipopt/cython/ipopt.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,29 @@ cdef extern from "IpStdCInterface.h":
Number* mult_x_U,
UserDataPtr user_data
)

Bool GetIpoptCurrentIterate(
IpoptProblem ipopt_problem,
Bool scaled,
Index n,
Number* x,
Number* z_L,
Number* z_U,
Index m,
Number* g,
Number* lambd
)

Bool GetIpoptCurrentViolations(
IpoptProblem ipopt_problem,
Bool scaled,
Index n,
Number* x_L_violation,
Number* x_U_violation,
Number* compl_x_L,
Number* compl_x_U,
Number* grad_lag_x,
Index m,
Number* nlp_constraint_violation,
Number* compl_g
)
96 changes: 96 additions & 0 deletions cyipopt/cython/ipopt_wrapper.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,102 @@ cdef class Problem:

return np_x, info

def get_current_iterate(self, scaled=False):
# Allocate arrays to hold the current iterate
cdef np.ndarray[DTYPEd_t, ndim=1] np_x
cdef np.ndarray[DTYPEd_t, ndim=1] np_mult_x_L
cdef np.ndarray[DTYPEd_t, ndim=1] np_mult_x_U
cdef np.ndarray[DTYPEd_t, ndim=1] np_g
cdef np.ndarray[DTYPEd_t, ndim=1] np_mult_g
np_x = np.zeros((self.__n,), dtype=DTYPEd).flatten()
np_mult_x_L = np.zeros((self.__n,), dtype=DTYPEd).flatten()
np_mult_x_U = np.zeros((self.__n,), dtype=DTYPEd).flatten()
np_g = np.zeros((self.__m,), dtype=DTYPEd).flatten()
np_mult_g = np.zeros((self.__m,), dtype=DTYPEd).flatten()

# Cast to C data types
x = <Number*>np_x.data
mult_x_L = <Number*>np_mult_x_L.data
mult_x_U = <Number*>np_mult_x_U.data
g = <Number*>np_g.data
mult_g = <Number*>np_mult_g.data

# NOTE: GetIpoptCurrentIterate can *only* be called during an
# intermediate callback (otherwise __nlp->tnlp->ip_data_ is NULL)
# TODO: Either catch error or avoid calling if we are not in an
# intermediate callback
ret = GetIpoptCurrentIterate(
self.__nlp,
scaled,
self.__n,
x,
mult_x_L,
mult_x_U,
self.__m,
g,
mult_g,
)

# Return values to user
# - Is another data type (e.g. dict, namedtuple) more appropriate than
# simply a tuple?
Copy link
Collaborator

Choose a reason for hiding this comment

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

In .solve() we return a tuple of an ndarray (optimal solution) and a dict mapping to other arrays including the optimal solution again. I can't say that is the cleanest API. But maybe we should mimic it here, since it is similar information?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We could mimic this for get_current_iterate, but I don't see what the first ndarray should be for get_current_violations, and I think it's more important for these to be consistent with each other than with solve. I'm currently returning dicts for both methods. In get_current_iterate, the keys are a subset of those in solve's returned dict. In get_current_violations, they match the GetIpoptCurrentIterate documentation, although I'm thinking about changing "nlp_constraint_violation" -> "g_violation".

# - Should `ret` be returned here?
Copy link
Collaborator

Choose a reason for hiding this comment

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

If ret is 0, then we should return nan for the values. That is probably easiest to interpret for a Python users.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've raised a RuntimeError in this case. I don't like nan as it's unclear whether it came from Ipopt or us. Return None, raise an error, or include ret in the returned object are all things I'd be okay with.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I was imagining that there could be cases where a problem could complete, but the values weren't returned from these new methods, so you could complete the solve. But maybe that doesn't occur and ret=0 means we should stop the solve.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The only case I know of where this returns 0 is if it is called outside of a callback. There are other branches that return false in the Ipopt code, but I don't know how to reach them. I've updated to return None in this case so that it's a little easier for a user to handle (i.e. decide whether to complete solve or not).

Copy link
Collaborator

Choose a reason for hiding this comment

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

Ok.

return (np_x, np_mult_x_L, np_mult_x_U, np_g, np_mult_g)

def get_current_violations(self, scaled=False):
# Allocate arrays to hold current violations
cdef np.ndarray[DTYPEd_t, ndim=1] np_x_L_viol
cdef np.ndarray[DTYPEd_t, ndim=1] np_x_U_viol
cdef np.ndarray[DTYPEd_t, ndim=1] np_compl_x_L
cdef np.ndarray[DTYPEd_t, ndim=1] np_compl_x_U
cdef np.ndarray[DTYPEd_t, ndim=1] np_grad_lag_x
cdef np.ndarray[DTYPEd_t, ndim=1] np_g_viol
cdef np.ndarray[DTYPEd_t, ndim=1] np_compl_g
np_x_L_viol = np.zeros((self.__n,), dtype=DTYPEd).flatten()
np_x_U_viol = np.zeros((self.__n,), dtype=DTYPEd).flatten()
np_compl_x_L = np.zeros((self.__n,), dtype=DTYPEd).flatten()
np_compl_x_U = np.zeros((self.__n,), dtype=DTYPEd).flatten()
np_grad_lag_x = np.zeros((self.__n,), dtype=DTYPEd).flatten()
np_g_viol = np.zeros((self.__m,), dtype=DTYPEd).flatten()
np_compl_g = np.zeros((self.__m,), dtype=DTYPEd).flatten()

# Cast to C data types
x_L_viol = <Number*>np_x_L_viol.data
x_U_viol = <Number*>np_x_U_viol.data
compl_x_L = <Number*>np_compl_x_L.data
compl_x_U = <Number*>np_compl_x_U.data
grad_lag_x = <Number*>np_grad_lag_x.data
g_viol = <Number*>np_g_viol.data
compl_g = <Number*>np_compl_g.data

# NOTE: GetIpoptCurrentViolations can *only* be called during an
# intermediate callback (otherwise __nlp->tnlp->ip_data_ is NULL)
# TODO: Either catch error or avoid calling if we are not in an
# intermediate callback
ret = GetIpoptCurrentViolations(
self.__nlp,
scaled,
self.__n,
x_L_viol,
x_U_viol,
compl_x_L,
compl_x_U,
grad_lag_x,
self.__m,
g_viol,
compl_g,
)

return (
np_x_L_viol,
np_x_U_viol,
np_compl_x_L,
np_compl_x_U,
np_grad_lag_x,
np_g_viol,
np_compl_g,
)


#
# Callback functions
Expand Down
6 changes: 3 additions & 3 deletions cyipopt/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def intermediate(*args):


@pytest.fixture()
def hs071_defintion_instance_fixture(hs071_objective_fixture,
def hs071_definition_instance_fixture(hs071_objective_fixture,
hs071_gradient_fixture,
hs071_constraints_fixture,
hs071_jacobian_fixture,
Expand Down Expand Up @@ -175,15 +175,15 @@ def hs071_constraint_upper_bounds_fixture():


@pytest.fixture()
def hs071_problem_instance_fixture(hs071_defintion_instance_fixture,
def hs071_problem_instance_fixture(hs071_definition_instance_fixture,
hs071_initial_guess_fixture,
hs071_variable_lower_bounds_fixture,
hs071_variable_upper_bounds_fixture,
hs071_constraint_lower_bounds_fixture,
hs071_constraint_upper_bounds_fixture,
):
"""Return a default cyipopt.Problem instance of the hs071 test problem."""
problem_definition = hs071_defintion_instance_fixture
problem_definition = hs071_definition_instance_fixture
x0 = hs071_initial_guess_fixture
lb = hs071_variable_lower_bounds_fixture
ub = hs071_variable_upper_bounds_fixture
Expand Down
4 changes: 2 additions & 2 deletions cyipopt/tests/unit/test_deprecations.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def test_ipopt_import_deprecation():
import ipopt


def test_non_pep8_class_name_deprecation(hs071_defintion_instance_fixture,
def test_non_pep8_class_name_deprecation(hs071_definition_instance_fixture,
hs071_initial_guess_fixture,
hs071_variable_lower_bounds_fixture,
hs071_variable_upper_bounds_fixture,
Expand All @@ -36,7 +36,7 @@ def test_non_pep8_class_name_deprecation(hs071_defintion_instance_fixture,
with pytest.warns(FutureWarning, match=expected_warning_msg):
_ = cyipopt.problem(n=len(hs071_initial_guess_fixture),
m=len(hs071_constraint_lower_bounds_fixture),
problem_obj=hs071_defintion_instance_fixture,
problem_obj=hs071_definition_instance_fixture,
lb=hs071_variable_lower_bounds_fixture,
ub=hs071_variable_upper_bounds_fixture,
cl=hs071_constraint_lower_bounds_fixture,
Expand Down
213 changes: 213 additions & 0 deletions cyipopt/tests/unit/test_ipopt_funcs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import numpy as np
import pytest

import cyipopt

@pytest.mark.skipif(True, reason="This segfaults. Ideally, it fails gracefully")
def test_get_iterate_uninit(hs071_problem_instance_fixture):
"""Test that we can call get_current_iterate on an uninitialized problem
"""
nlp = hs071_problem_instance_fixture
x, zL, zU, g, lam = nlp.get_current_iterate()


@pytest.mark.skipif(True, reason="This also segfaults")
def test_get_iterate_postsolve(
hs071_initial_guess_fixture,
hs071_problem_instance_fixture,
):
x0 = hs071_initial_guess_fixture
nlp = hs071_problem_instance_fixture
x, info = nlp.solve(x0)

x, zL, zU, g, lam = nlp.get_current_iterate()
expected_x = np.array([1.0, 4.74299964, 3.82114998, 1.37940829])
np.testing.assert_allclose(x, expected_x)


@pytest.mark.skipif(True, reason="Segfaults")
def test_get_violations_uninit(hs071_problem_instance_fixture):
nlp = hs071_problem_instance_fixture
x, zL, zU, g, lam = nlp.get_current_violations()


@pytest.mark.skipif(True, reason="Segfaults")
def test_get_violations_postsolve(
hs071_initial_guess_fixture,
hs071_problem_instance_fixture,
):
x0 = hs071_initial_guess_fixture
nlp = hs071_problem_instance_fixture
x, info = nlp.solve(x0)

x, zL, zU, g, lam = nlp.get_current_violations()
expected_x = np.array([1.0, 4.74299964, 3.82114998, 1.37940829])
np.testing.assert_allclose(x, expected_x)


def test_get_iterate_hs071(
hs071_initial_guess_fixture,
hs071_definition_instance_fixture,
hs071_variable_lower_bounds_fixture,
hs071_variable_upper_bounds_fixture,
hs071_constraint_lower_bounds_fixture,
hs071_constraint_upper_bounds_fixture,
):
x0 = hs071_initial_guess_fixture
lb = hs071_variable_lower_bounds_fixture
ub = hs071_variable_upper_bounds_fixture
cl = hs071_constraint_lower_bounds_fixture
cu = hs071_constraint_upper_bounds_fixture
n = len(x0)
m = len(cl)

problem_definition = hs071_definition_instance_fixture

#
# Define a callback that uses some "global" information to call
# get_current_iterate and store the result
#
x_iterates = []
def intermediate(
alg_mod,
iter_count,
obj_value,
inf_pr,
inf_du,
mu,
d_norm,
regularization_size,
alpha_du,
alpha_pr,
ls_trials,
):
# CyIpopt's C wapper expects a callback with this signature. If we
# implemented this as a method on problem_definition, we could store
# and access global information on self.

# This callback must be defined before constructing the Problem, but can
# be defined after (or as part of) problem_definition. If we attach the
# Problem to the "definition", then we can call get_current_iterate
# from this callback.
iterate = problem_definition.nlp.get_current_iterate(scaled=False)
x, zL, zU, g, lam = iterate
x_iterates.append(x)

# Hack so we may get the number of iterations after the solve
problem_definition.iter_count = iter_count

# Replace "intermediate" attribute with our callback, which knows
# about the "Problem", and therefore can call get_current_iterate.
problem_definition.intermediate = intermediate

nlp = cyipopt.Problem(
n=n,
m=m,
problem_obj=problem_definition,
lb=lb,
ub=ub,
cl=cl,
cu=cu,
)
# Add nlp (the "Problem") as an attribute on our "problem definition".
# This way we can call methods on the Problem, like get_current_iterate,
# during the solve.
problem_definition.nlp = nlp

# Disable bound push to make testing easier
nlp.add_option("bound_push", 1e-9)
x, info = nlp.solve(x0)

# Assert correct solution
expected_x = np.array([1.0, 4.74299964, 3.82114998, 1.37940829])
np.testing.assert_allclose(x, expected_x)

#
# Assert some very basic information about the collected primal iterates
#
assert len(x_iterates) == (1 + problem_definition.iter_count)

# These could be different due to bound_push (and scaling)
np.testing.assert_allclose(x_iterates[0], x0)

# These could be different due to honor_original_bounds (and scaling)
np.testing.assert_allclose(x_iterates[-1], x)


def test_get_violations_hs071(
hs071_initial_guess_fixture,
hs071_definition_instance_fixture,
hs071_variable_lower_bounds_fixture,
hs071_variable_upper_bounds_fixture,
hs071_constraint_lower_bounds_fixture,
hs071_constraint_upper_bounds_fixture,
):
x0 = hs071_initial_guess_fixture
lb = hs071_variable_lower_bounds_fixture
ub = hs071_variable_upper_bounds_fixture
cl = hs071_constraint_lower_bounds_fixture
cu = hs071_constraint_upper_bounds_fixture
n = len(x0)
m = len(cl)

problem_definition = hs071_definition_instance_fixture

pr_violations = []
du_violations = []
def intermediate(
alg_mod,
iter_count,
obj_value,
inf_pr,
inf_du,
mu,
d_norm,
regularization_size,
alpha_du,
alpha_pr,
ls_trials,
):
violations = problem_definition.nlp.get_current_violations(scaled=True)
(
xL_viol, xU_viol, xL_compl, xU_compl, grad_lag, g_viol, g_compl
) = violations
pr_violations.append(g_viol)
du_violations.append(grad_lag)

# Hack so we may get the number of iterations after the solve
problem_definition.iter_count = iter_count

problem_definition.intermediate = intermediate
nlp = cyipopt.Problem(
n=n,
m=m,
problem_obj=problem_definition,
lb=lb,
ub=ub,
cl=cl,
cu=cu,
)
problem_definition.nlp = nlp

nlp.add_option("tol", 1e-8)
x, info = nlp.solve(x0)

# Assert correct solution
expected_x = np.array([1.0, 4.74299964, 3.82114998, 1.37940829])
np.testing.assert_allclose(x, expected_x)

#
# Assert some very basic information about the collected violations
#
assert len(pr_violations) == (1 + problem_definition.iter_count)
assert len(du_violations) == (1 + problem_definition.iter_count)

#
# With atol=1e-8, this check fails. This differs from what I see in the
# Ipopt log, where inf_pr is 1.77e-11 at the final iteration. I see
# final primal violations: [2.455637e-07, 1.770672e-11]
# Not sure if a bug or not...
#
np.testing.assert_allclose(pr_violations[-1], np.zeros(m), atol=1e-6)

np.testing.assert_allclose(du_violations[-1], np.zeros(n), atol=1e-8)