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

Conversation

Robbybp
Copy link
Contributor

@Robbybp Robbybp commented Feb 10, 2023

Fixes #180

Being able to access primal/dual variable values as well as Ipopt's computed infeasibilities during a callback would be very useful for debugging NLPs. This PR contains a basic implementation of methods that call GetIpoptCurrentIterate and GetIpoptCurrentViolations. These methods can be called from an intermediate callback if the callback is somehow aware of the Problem.

This PR is marked as WIP as the methods currently segfault if not called from a callback. Ideally this would raise a Python exception, but I'm not sure what is the best way to detect whether we are "in a callback". Maybe a global flag in ipopt_wrapper.pyx?
Edit: The problem is calling intermediate_cb when we are not in an IpoptSolve. We use a flag to detect this and raise an error.

Edit: Alternatives

Admittedly, the callback needing to be aware of the Problem object is somewhat clunky, but this is the best solution I could think of that doesn't involve changing the callback API. If we were willing to change the callback API, we could:

  • Pass the Problem object from intermediate_cb to the user-facing callback
  • Call GetIpoptCurrentIterate and GetIpoptCurrentViolations from intermediate_cb, then return the results to the user's callback somehow. This would require accessing __nlp from intermediate_cb, however, which I don't know how to do.
  • Edit 2: We can also just subclass Problem and implement intermediate as a method, which allows us to access the new methods from the callback without altering its call signature.
  • Edit 3: I now use inspect.signature to detect how many positional arguments are accepted by the user's callback, and send the Problem object to the callback if the right number (12 or *args) are accepted. For anything other than 11 or 12 positional arguments or *args, an exception is raised.

@moorepants
Copy link
Collaborator

Thanks for the submission. We need to get #178 fixed, so these tests can run here. It is almost there. Then we can check this out.

@moorepants
Copy link
Collaborator

Can you merge in master here, so the update CI files will be used?

# Return values to user
# - Is another data type (e.g. dict, namedtuple) more appropriate than
# simply a tuple?
# - 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 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".

@moorepants
Copy link
Collaborator

There are two ways a user can create problems. The first should work fine with your new methods:

class MyProblem(Problem):

    def __init__(self):

        self.iterations = []
        self.pr_violations = []
        self.du_violations = []

    def intermediate(self, *args):

        x, _, _, _, _ = self.get_current_iterate()

        self.iterations.append(x)

        _, _, _, _, grad_lag, g_viol, _ = self.get_current_violations()

        self.pr_violations.append(g_viol)
        self.du_violations.append(grad_lag)

mp = MyProblem()
mp.solve()

We can decide to only support this way if you want to get the intermediate values. But I think this may work:

class MyProblem:

    def __init__(self):

        self.iterations = []
        self.pr_violations = []
        self.du_violations = []

    def intermediate(self, *args):

        x, _, _, _, _ = self.get_current_iterate()

        self.iterations.append(x)

        _, _, _, _, grad_lag, g_viol, _ = self.get_current_violations()

        self.pr_violations.append(g_viol)
        self.du_violations.append(grad_lag)

mp = MyProblem()
p = Problem(problem_obj=mp)
mp.get_current_iterate = p.get_current_iterate
mp.get_current_violations = p.get_current_violations

If we made get_current_iterate a standalone function instead of a method. Can you do this:

class MyProblem:

    def __init__(self):

        self.iterations = []
        self.pr_violations = []
        self.du_violations = []

    def intermediate(self, *args):

        x, _, _, _, _ = get_current_iterate(self.prob)

        self.iterations.append(x)

        _, _, _, _, grad_lag, g_viol, _ = get_current_violations(self.prob)

        self.pr_violations.append(g_viol)
        self.du_violations.append(grad_lag)

mp = MyProblem()
p = Problem(problem_obj=mp)
mp.prob = p
p.solve()

The last two options are not ideal, but may work. I'm also fine just not supporting that setup.

@Robbybp
Copy link
Contributor Author

Robbybp commented Feb 12, 2023

Regarding the three options you present:

  1. I did not consider subclassing Problem. This should work nicely
  2. This works, and is approximately what I have done in the tests. This is also basically how I intend to use this functionality in the Pyomo CyIpopt interface
  3. This runs into the issue of accessing self.prob.__nlp in get_current_violations, which I don't think is currently possible when __nlp is a C-struct. This should be possible somehow, but I don't know the right way to do it.

@moorepants
Copy link
Collaborator

Since these new methods are only present in ipopt >= 3.14, we need to disable them for prior versions. We technically support back to 3.11, I believe. Certainly 3.12.

@moorepants
Copy link
Collaborator

We probably have to check at build time for < 3.14.

@Robbybp
Copy link
Contributor Author

Robbybp commented Feb 14, 2023

We probably have to check at build time for < 3.14.

I have this working locally using macros, following the example in the Cython documentation. Not sure if you had something else in mind that would be preferred.

@moorepants
Copy link
Collaborator

mac is giving this conda error:

PluginError: Error while loading conda plugins from entrypoints: dlopen(/usr/local/miniconda/lib/python3.10/site-packages/libmambapy/bindings.cpython-310-darwin.so, 0x0002): Library not loaded: '@rpath/libarchive.19.dylib'
  Referenced from: '/usr/local/miniconda/lib/libmamba.2.0.0.dylib'
  Reason: tried: '/usr/local/miniconda/lib/libarchive.19.dylib' (no such file), '/usr/local/miniconda/lib/python3.10/site-packages/libmambapy/../../../libarchive.19.dylib' (no such file), '/usr/local/miniconda/lib/python3.10/site-packages/libmambapy/../../../libarchive.19.dylib' (no such file), '/usr/local/miniconda/bin/../lib/libarchive.19.dylib' (no such file), '/usr/local/miniconda/bin/../lib/libarchive.19.dylib' (no such file), '/usr/local/lib/libarchive.19.dylib' (no such file), '/usr/lib/libarchive.19.dylib' (no such file)

@Robbybp
Copy link
Contributor Author

Robbybp commented Feb 15, 2023

mac is giving this conda error:

PluginError: Error while loading conda plugins from entrypoints: dlopen(/usr/local/miniconda/lib/python3.10/site-packages/libmambapy/bindings.cpython-310-darwin.so, 0x0002): Library not loaded: '@rpath/libarchive.19.dylib'
  Referenced from: '/usr/local/miniconda/lib/libmamba.2.0.0.dylib'
  Reason: tried: '/usr/local/miniconda/lib/libarchive.19.dylib' (no such file), '/usr/local/miniconda/lib/python3.10/site-packages/libmambapy/../../../libarchive.19.dylib' (no such file), '/usr/local/miniconda/lib/python3.10/site-packages/libmambapy/../../../libarchive.19.dylib' (no such file), '/usr/local/miniconda/bin/../lib/libarchive.19.dylib' (no such file), '/usr/local/miniconda/bin/../lib/libarchive.19.dylib' (no such file), '/usr/local/lib/libarchive.19.dylib' (no such file), '/usr/lib/libarchive.19.dylib' (no such file)

Any idea what could be causing this? I see no pattern to which mac builds succeed or fail.

@moorepants
Copy link
Collaborator

Any idea what could be causing this? I see no pattern to which mac builds succeed or fail.

I just switched to libmamba as a work around for another conda bug, but maybe this just exposes a new one. I'm not sure either.

@moorepants
Copy link
Collaborator

This may be the mac issue: mamba-org/mamba#1826

@Robbybp
Copy link
Contributor Author

Robbybp commented Apr 25, 2023

Pyomo/pyomo#2760 has been merged, so I removed all the code that was necessary to support a 12-argument callback (to access get_current_* without subclassing Problem). This functionality still exists on my https://github.com/Robbybp/cyipopt/tree/get-iterate-12arg-cb branch. If this becomes necessary in the future for some reason, hopefully it is not too difficult to dust off. 01a776b is the commit where the code for support an augment callback was removed, for reference.

@moorepants this is ready for re-review/merge when you have a chance.

@moorepants
Copy link
Collaborator

There are some errors, e.g.:

E   ImportError: /usr/share/miniconda3/envs/test-environment/lib/python3.8/site-packages/scipy/sparse/linalg/_isolve/_iterative.cpython-38-x86_64-linux-gnu.so: undefined symbol: cblas_cdotc_sub
=========================== short test summary info ============================
ERROR  - ImportError: /usr/share/miniconda3/envs/test-environment/lib/python3.8/site-packages/scipy/sparse/linalg/_isolve/_iterative.cpython-38-x86_64-linux-gnu.so: undefined symbol: cblas_cdotc_sub
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.24s ===============================

@Robbybp
Copy link
Contributor Author

Robbybp commented Apr 27, 2023

Looks like this is a known issue. See conda-forge/scipy-feedstock#224 and scipy/scipy#18371.

@moorepants
Copy link
Collaborator

I changed it to scipy<1.10.0 which allowed the prior failing ones to build, but the others fail with:

Run mamba install -q -y -c conda-forge cython>=0.26 "ipopt=3.14" numpy>=1.15 pkg-config>=0.29.2 setuptools>=39.0 pytest>=3.3.2 "scipy<1.10.0"
bash: line 1: 1.10.0: No such file or directory
Error: Process completed with exit code 1.

which seems like the last < is being picked up as a bash command even though I have it in quotes.

@moorepants
Copy link
Collaborator

This is stated in the conda docs:

"When using the command line, put double quotes around any package version specification that contains the space character or any of the following characters: <, >, *, or |."

@moorepants
Copy link
Collaborator

Sorry, I added several commits. Seems like this may be a mamba bug on the version specification.

@moorepants
Copy link
Collaborator

This gives a clearer error message if scipy is pinned to 1.10.1, I guess there are no versions for Python 3.7:

warning  libmamba Added empty dependency for problem type SOLVER_RULE_JOB
Could not solve for environment specs
The following packages are incompatible
└─ scipy 1.10.0**  is uninstallable because there are no viable options
   ├─ scipy 1.10.0 would require
   │  └─ python >=3.10,<3.11.0a0 , which conflicts with any installable versions previously reported;
   ├─ scipy 1.10.0 would require
   │  └─ python >=3.11,<3.12.0a0 , which conflicts with any installable versions previously reported;
   ├─ scipy 1.10.0 would require
   │  └─ python >=3.8,<3.9.0a0 , which conflicts with any installable versions previously reported;
   └─ scipy 1.10.0 would require
      └─ python >=3.9,<3.10.0a0 , which conflicts with any installable versions previously reported.

If I use scipy<=1.10.0 I get an error with mamba.

@moorepants
Copy link
Collaborator

It really needs to be scipy<=1.10.0 to work across versions. For Python 3.7 we could use a different version or I can also disable for Python 3.7 because checking scipy on at least one or two versions should be sufficient.

@moorepants
Copy link
Collaborator

Python 3.7 is end of life in 2 months. So we can drop it for the next release. Maybe the simplest solution is to drop Python 3.7 here.

@moorepants
Copy link
Collaborator

This looks good to me. Thanks for the large effort getting this. It is a nice new addition!

@moorepants moorepants merged commit f2a3158 into mechmotum:master Apr 28, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Optimization variables in callback function
2 participants