From 0c6166a4676f44e891d4c9723c95510db9b94323 Mon Sep 17 00:00:00 2001 From: William Zijie Zhang Date: Sun, 22 Feb 2026 08:17:08 -0800 Subject: [PATCH 1/7] Extract NLP solving logic from problem.py into nlp_solving_chain.py Move the ~85-line NLP block from Problem._solve() and the two initial point methods into a dedicated module. This addresses PR review feedback: - NLP chain building, initial point logic, and solve orchestration now live in cvxpy/reductions/solvers/nlp_solving_chain.py - Use var.get_bounds() instead of var.bounds so sign attributes (nonneg, nonpos) are incorporated into bounds automatically - Initial point helpers are now private module-level functions instead of public Problem methods Co-Authored-By: Claude Opus 4.6 --- cvxpy/problems/problem.py | 187 +-------------- cvxpy/reductions/solvers/nlp_solving_chain.py | 218 ++++++++++++++++++ 2 files changed, 220 insertions(+), 185 deletions(-) create mode 100644 cvxpy/reductions/solvers/nlp_solving_chain.py diff --git a/cvxpy/problems/problem.py b/cvxpy/problems/problem.py index d04ffbcc04..5f071ef2fe 100644 --- a/cvxpy/problems/problem.py +++ b/cvxpy/problems/problem.py @@ -39,18 +39,12 @@ from cvxpy.problems.objective import Maximize, Minimize from cvxpy.reductions import InverseData from cvxpy.reductions.chain import Chain -from cvxpy.reductions.cvx_attr2constr import CvxAttr2Constr -from cvxpy.reductions.dnlp2smooth.dnlp2smooth import Dnlp2Smooth from cvxpy.reductions.dqcp2dcp import dqcp2dcp from cvxpy.reductions.eval_params import EvalParams from cvxpy.reductions.flip_objective import FlipObjective from cvxpy.reductions.solution import INF_OR_UNB_MESSAGE from cvxpy.reductions.solvers import bisection from cvxpy.reductions.solvers import defines as slv_def -from cvxpy.reductions.solvers.nlp_solvers.copt_nlpif import COPT as COPT_nlp -from cvxpy.reductions.solvers.nlp_solvers.ipopt_nlpif import IPOPT as IPOPT_nlp -from cvxpy.reductions.solvers.nlp_solvers.knitro_nlpif import KNITRO as KNITRO_nlp -from cvxpy.reductions.solvers.nlp_solvers.uno_nlpif import UNO as UNO_nlp from cvxpy.reductions.solvers.solver_inverse_data import SolverInverseData from cvxpy.reductions.solvers.solving_chain import ( SolvingChain, @@ -1080,89 +1074,8 @@ def _solve(self, return self.value if nlp and self.is_dnlp(): - if type(self.objective) == Maximize: - reductions = [FlipObjective()] - else: - reductions = [] - reductions = reductions + [CvxAttr2Constr(reduce_bounds=False), Dnlp2Smooth()] - # instantiate based on user provided solver - # (default to Ipopt) - if solver is s.IPOPT or solver is None: - nlp_reductions = reductions + [IPOPT_nlp()] - elif "knitro" in solver.lower(): - if solver == "knitro_ipm": - kwargs["algorithm"] = 1 - elif solver == "knitro_sqp": - kwargs["algorithm"] = 4 - elif solver == "knitro_alm": - kwargs["algorithm"] = 6 - nlp_reductions = reductions + [KNITRO_nlp()] - elif solver is s.COPT: - nlp_reductions = reductions + [COPT_nlp()] - elif "uno" in solver.lower(): - if solver.lower() == "uno_ipm": - # Interior-point method (requires MUMPS linear solver) - kwargs["preset"] = "ipopt" - kwargs["linear_solver"] = "MUMPS" - elif solver.lower() == "uno_sqp": - # SQP method (default) - kwargs["preset"] = "filtersqp" - nlp_reductions = reductions + [UNO_nlp()] - else: - raise error.SolverError( - "Solver %s is not supported for NLP problems." % solver - ) - # canonicalize disciplined nlp problems to smooth form - nlp_chain = SolvingChain(reductions=nlp_reductions) - best_of = kwargs.pop("best_of", 1) - - # standard solve - if best_of == 1: - self.set_NLP_initial_point() - canon_problem, inverse_data = nlp_chain.apply(problem=self) - solution = nlp_chain.solver.solve_via_data(canon_problem, warm_start, - verbose, solver_opts=kwargs) - self.unpack_results(solution, nlp_chain, inverse_data) - return self.value - # best-of-N solve - else: - if (not isinstance(best_of, int)) or best_of < 1: - raise ValueError("best_of must be a positive integer.") - - best_obj, best_solution = float("inf"), None - all_objs = np.zeros(shape=(best_of,)) - - for run in range(best_of): - print("Starting NLP solve %d of %d" % (run + 1, best_of)) - self.set_random_NLP_initial_point(run) - canon_problem, inverse_data = nlp_chain.apply(problem=self) - solution = nlp_chain.solver.solve_via_data(canon_problem, warm_start, - verbose, solver_opts=kwargs) - - # This gives the objective value of the C problem - # which can be slightly different from the original NLP - # so we use the below approach with unpacking. Preferably - # we would have a way to do this without unpacking. - #obj_value = canon_problem['objective'](solution['x']) - - # set cvxpy variable - self.unpack_results(solution, nlp_chain, inverse_data) - obj_value = self.objective.value - - all_objs[run] = obj_value - if obj_value < best_obj: - best_obj = obj_value - print("best_obj: ", best_obj) - best_solution = solution - - # unpack best solution - if type(self.objective) == Maximize: - all_objs = -all_objs - - # propagate all objective values to the user - best_solution['all_objs_from_best_of'] = all_objs - self.unpack_results(best_solution, nlp_chain, inverse_data) - return self.value + from cvxpy.reductions.solvers.nlp_solving_chain import solve_nlp + return solve_nlp(self, solver, warm_start, verbose, **kwargs) elif nlp and not self.is_dnlp(): raise error.DNLPError("The problem you specified is not DNLP.") @@ -1517,103 +1430,7 @@ def unpack_results(self, solution, chain: SolvingChain, inverse_data) -> None: self._solver_stats = SolverStats.from_dict(self._solution.attr, chain.solver.name()) - - def set_NLP_initial_point(self) -> dict: - """ Constructs an initial point for the optimization problem. If no - initial value is specified, look at the bounds. If both lb and ub are - specified, we initialize the variables to be their midpoints. If only - one of them is specified, we initialize the variable one unit from - the bound. If none of them is specified, we initialize it to zero. - """ - for var in self.variables(): - if var.value is not None: - continue - - bounds = var.bounds - is_nonneg = var.is_nonneg() - is_nonpos = var.is_nonpos() - - if bounds is None: - if is_nonneg: - x0 = np.ones(var.shape) - elif is_nonpos: - x0 = -np.ones(var.shape) - else: - x0 = np.zeros(var.shape) - - var.save_value(x0) - else: - lb, ub = bounds - - if is_nonneg: - lb = np.maximum(lb, 0) - elif is_nonpos: - ub = np.maximum(ub, 0) - - lb_finite = np.isfinite(lb) - ub_finite = np.isfinite(ub) - # Replace infs with zero for arithmetic - lb0 = np.where(lb_finite, lb, 0.0) - ub0 = np.where(ub_finite, ub, 0.0) - # Midpoint if both finite, one from bound if only one finite, zero if none - init = (lb_finite * ub_finite * 0.5 * (lb0 + ub0) + - lb_finite * (~ub_finite) * (lb0 + 1.0) + - (~lb_finite) * ub_finite * (ub0 - 1.0)) - # Broadcast to variable shape (handles scalar bounds) - init = np.broadcast_to(init, var.shape).copy() - var.save_value(init) - - - def set_random_NLP_initial_point(self, run) -> dict: - """ Generates a random initial point for DNLP problems. - A variable is initialized randomly in the following cases: - 1. 'sample_bounds' is set for that variable. - 2. the initial value specified by the user is None, - 'sample_bounds' is not set for that variable, but the - variable has both finite lower and upper bounds. - """ - # store user-specified initial values for variables that do - # not have sample bounds assigned - if run == 0: - self._user_initials = {} - for var in self.variables(): - if var.sample_bounds is not None: - self._user_initials[var.id] = None - else: - self._user_initials[var.id] = var.value - - for var in self.variables(): - - # skip variables with user-specified initial value - # (note that any variable with sample bounds set will have - # _user_initials[var.id] == None) - if self._user_initials[var.id] is not None: - # reset to user-specified initial value from last solve - var.value = self._user_initials[var.id] - continue - else: - # reset to None from last solve - var.value = None - - # set sample_bounds to variable bounds if sample_bounds is None - # and variable bounds (possibly infinite) are set - if var.sample_bounds is None and var.bounds is not None: - var.sample_bounds = var.bounds - - # sample initial value if sample_bounds is set - if var.sample_bounds is not None: - low, high = var.sample_bounds - if not np.all(np.isfinite(low)) or not np.all(np.isfinite(high)): - raise ValueError( - "Variable %s has non-finite sample_bounds %s. Cannot generate" - " random initial point. Either add sample bounds or set the value. " - % (var.name(), var.sample_bounds) - ) - - initial_val = np.random.uniform(low=low, high=high, size=var.shape) - var.save_value(initial_val) - def __str__(self) -> str: if len(self.constraints) == 0: return str(self.objective) diff --git a/cvxpy/reductions/solvers/nlp_solving_chain.py b/cvxpy/reductions/solvers/nlp_solving_chain.py new file mode 100644 index 0000000000..10ad13a8c5 --- /dev/null +++ b/cvxpy/reductions/solvers/nlp_solving_chain.py @@ -0,0 +1,218 @@ +""" +Copyright, the CVXPY authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +import numpy as np + +from cvxpy import error +from cvxpy import settings as s +from cvxpy.problems.objective import Maximize +from cvxpy.reductions.cvx_attr2constr import CvxAttr2Constr +from cvxpy.reductions.dnlp2smooth.dnlp2smooth import Dnlp2Smooth +from cvxpy.reductions.flip_objective import FlipObjective +from cvxpy.reductions.solvers.nlp_solvers.copt_nlpif import COPT as COPT_nlp +from cvxpy.reductions.solvers.nlp_solvers.ipopt_nlpif import IPOPT as IPOPT_nlp +from cvxpy.reductions.solvers.nlp_solvers.knitro_nlpif import KNITRO as KNITRO_nlp +from cvxpy.reductions.solvers.nlp_solvers.uno_nlpif import UNO as UNO_nlp +from cvxpy.reductions.solvers.solving_chain import SolvingChain + + +def _build_nlp_chain(problem, solver, kwargs): + """Build the NLP reduction chain and return (SolvingChain, kwargs). + + Solver selection may mutate kwargs (e.g., Knitro algorithm, Uno preset). + """ + if type(problem.objective) == Maximize: + reductions = [FlipObjective()] + else: + reductions = [] + reductions = reductions + [CvxAttr2Constr(reduce_bounds=False), Dnlp2Smooth()] + + if solver is s.IPOPT or solver is None: + reductions.append(IPOPT_nlp()) + elif "knitro" in solver.lower(): + if solver == "knitro_ipm": + kwargs["algorithm"] = 1 + elif solver == "knitro_sqp": + kwargs["algorithm"] = 4 + elif solver == "knitro_alm": + kwargs["algorithm"] = 6 + reductions.append(KNITRO_nlp()) + elif solver is s.COPT: + reductions.append(COPT_nlp()) + elif "uno" in solver.lower(): + if solver.lower() == "uno_ipm": + kwargs["preset"] = "ipopt" + kwargs["linear_solver"] = "MUMPS" + elif solver.lower() == "uno_sqp": + kwargs["preset"] = "filtersqp" + reductions.append(UNO_nlp()) + else: + raise error.SolverError( + "Solver %s is not supported for NLP problems." % solver + ) + + return SolvingChain(reductions=reductions), kwargs + + +def _set_nlp_initial_point(problem): + """Construct an initial point for variables without a user-specified value. + + Uses get_bounds() which incorporates sign attributes (nonneg, nonpos, etc.). + If both lb and ub are finite, initialize to their midpoint. If only one is + finite, initialize one unit from the bound. Otherwise, initialize to zero. + """ + for var in problem.variables(): + if var.value is not None: + continue + + lb, ub = var.get_bounds() + + lb_finite = np.isfinite(lb) + ub_finite = np.isfinite(ub) + # Replace infs with zero for arithmetic + lb0 = np.where(lb_finite, lb, 0.0) + ub0 = np.where(ub_finite, ub, 0.0) + # Midpoint if both finite, one from bound if only one finite, zero if none + init = (lb_finite * ub_finite * 0.5 * (lb0 + ub0) + + lb_finite * (~ub_finite) * (lb0 + 1.0) + + (~lb_finite) * ub_finite * (ub0 - 1.0)) + # Broadcast to variable shape (handles scalar bounds) + init = np.broadcast_to(init, var.shape).copy() + var.save_value(init) + + +def _set_random_nlp_initial_point(problem, run, user_initials): + """Generate a random initial point for DNLP problems. + + A variable is initialized randomly if: + 1. 'sample_bounds' is set for that variable. + 2. The initial value specified by the user is None, 'sample_bounds' is not + set, but the variable has both finite lower and upper bounds. + + Parameters + ---------- + problem : Problem + run : int + Current run index (0-based). + user_initials : dict + On run 0, will be populated with user-specified initial values. + On subsequent runs, used to restore user-specified values. + """ + # Store user-specified initial values on the first run + if run == 0: + user_initials.clear() + for var in problem.variables(): + if var.sample_bounds is not None: + user_initials[var.id] = None + else: + user_initials[var.id] = var.value + + for var in problem.variables(): + # Skip variables with user-specified initial value + # (note that any variable with sample bounds set will have + # user_initials[var.id] == None) + if user_initials[var.id] is not None: + # Reset to user-specified initial value from last solve + var.value = user_initials[var.id] + continue + else: + # Reset to None from last solve + var.value = None + + # Set sample_bounds to variable bounds if sample_bounds is None + # and variable bounds (possibly infinite) are set + if var.sample_bounds is None and var.bounds is not None: + var.sample_bounds = var.bounds + + # Sample initial value if sample_bounds is set + if var.sample_bounds is not None: + low, high = var.sample_bounds + if not np.all(np.isfinite(low)) or not np.all(np.isfinite(high)): + raise ValueError( + "Variable %s has non-finite sample_bounds %s. Cannot generate" + " random initial point. Either add sample bounds or set the value. " + % (var.name(), var.sample_bounds) + ) + + initial_val = np.random.uniform(low=low, high=high, size=var.shape) + var.save_value(initial_val) + + +def solve_nlp(problem, solver, warm_start, verbose, **kwargs): + """Solve an NLP problem using the DNLP reduction chain. + + Parameters + ---------- + problem : Problem + A DNLP-valid problem. + solver : str or None + Solver name (e.g., 'IPOPT', 'knitro_sqp'). + warm_start : bool + Whether to warm-start the solver. + verbose : bool + Whether to print solver output. + **kwargs + Additional solver options, including 'best_of'. + + Returns + ------- + float + The optimal problem value. + """ + nlp_chain, kwargs = _build_nlp_chain(problem, solver, kwargs) + best_of = kwargs.pop("best_of", 1) + + # Standard single solve + if best_of == 1: + _set_nlp_initial_point(problem) + canon_problem, inverse_data = nlp_chain.apply(problem=problem) + solution = nlp_chain.solver.solve_via_data(canon_problem, warm_start, + verbose, solver_opts=kwargs) + problem.unpack_results(solution, nlp_chain, inverse_data) + return problem.value + + # Best-of-N solve + if (not isinstance(best_of, int)) or best_of < 1: + raise ValueError("best_of must be a positive integer.") + + best_obj, best_solution = float("inf"), None + all_objs = np.zeros(shape=(best_of,)) + user_initials = {} + + for run in range(best_of): + print("Starting NLP solve %d of %d" % (run + 1, best_of)) + _set_random_nlp_initial_point(problem, run, user_initials) + canon_problem, inverse_data = nlp_chain.apply(problem=problem) + solution = nlp_chain.solver.solve_via_data(canon_problem, warm_start, + verbose, solver_opts=kwargs) + + # Unpack to get the objective value in the original problem space + problem.unpack_results(solution, nlp_chain, inverse_data) + obj_value = problem.objective.value + + all_objs[run] = obj_value + if obj_value < best_obj: + best_obj = obj_value + print("best_obj: ", best_obj) + best_solution = solution + + # Unpack best solution + if type(problem.objective) == Maximize: + all_objs = -all_objs + + # Propagate all objective values to the user + best_solution['all_objs_from_best_of'] = all_objs + problem.unpack_results(best_solution, nlp_chain, inverse_data) + return problem.value From 5b3eb317c506e31bbfedc2df676facfdeeca7129 Mon Sep 17 00:00:00 2001 From: William Zijie Zhang Date: Sun, 22 Feb 2026 08:21:16 -0800 Subject: [PATCH 2/7] Update NLP initial point tests to use _set_nlp_initial_point Tests now import and call the module-level helper directly instead of the removed Problem.set_NLP_initial_point() method. Co-Authored-By: Claude Opus 4.6 --- cvxpy/tests/nlp_tests/test_problem.py | 39 ++++++++++++++------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/cvxpy/tests/nlp_tests/test_problem.py b/cvxpy/tests/nlp_tests/test_problem.py index b9ec4641d6..c7850c92fd 100644 --- a/cvxpy/tests/nlp_tests/test_problem.py +++ b/cvxpy/tests/nlp_tests/test_problem.py @@ -2,48 +2,49 @@ import numpy as np import cvxpy as cp +from cvxpy.reductions.solvers.nlp_solving_chain import _set_nlp_initial_point class TestProblem(): """ - This class can be used to test internal function for Problem that have been added + This class can be used to test internal function for Problem that have been added in the DNLP extension. """ def test_set_initial_point_both_bounds_infinity(self): # when both bounds are infinity, the initial point should be zero vector - + # test 1 x = cp.Variable((3, )) prob = cp.Problem(cp.Minimize(cp.sum(x))) - prob.set_NLP_initial_point() + _set_nlp_initial_point(prob) assert (x.value == np.zeros((3, ))).all() # test 2 x = cp.Variable((3, ), bounds=[None, None]) prob = cp.Problem(cp.Minimize(cp.sum(x))) - prob.set_NLP_initial_point() + _set_nlp_initial_point(prob) assert (x.value == np.zeros((3, ))).all() # test 3 x = cp.Variable((3, ), bounds=[-np.inf, np.inf]) prob = cp.Problem(cp.Minimize(cp.sum(x))) - prob.set_NLP_initial_point() + _set_nlp_initial_point(prob) assert (x.value == np.zeros((3, ))).all() # test 4 x = cp.Variable((3, ), bounds=[None, np.inf]) prob = cp.Problem(cp.Minimize(cp.sum(x))) - prob.set_NLP_initial_point() + _set_nlp_initial_point(prob) assert (x.value == np.zeros((3, ))).all() # test 5 x = cp.Variable((3, ), bounds=[-np.inf, None]) prob = cp.Problem(cp.Minimize(cp.sum(x))) - prob.set_NLP_initial_point() + _set_nlp_initial_point(prob) assert (x.value == np.zeros((3, ))).all() - + def test_set_initial_point_lower_bound_infinity(self): # when one bound is infinity, the initial point should be one unit # away from the finite bound @@ -51,15 +52,15 @@ def test_set_initial_point_lower_bound_infinity(self): # test 1 x = cp.Variable((3, ), bounds=[None, 3.5]) prob = cp.Problem(cp.Minimize(cp.sum(x))) - prob.set_NLP_initial_point() + _set_nlp_initial_point(prob) assert (x.value == 2.5 * np.ones((3, ))).all() # test 2 x = cp.Variable((3, ), bounds=[-np.inf, 3.5]) prob = cp.Problem(cp.Minimize(cp.sum(x))) - prob.set_NLP_initial_point() + _set_nlp_initial_point(prob) assert (x.value == 2.5 * np.ones((3, ))).all() - + def test_set_initial_point_upper_bound_infinity(self): # when one bound is infinity, the initial point should be one unit # away from the finite bound @@ -67,13 +68,13 @@ def test_set_initial_point_upper_bound_infinity(self): # test 1 x = cp.Variable((3, ), bounds=[3.5, None]) prob = cp.Problem(cp.Minimize(cp.sum(x))) - prob.set_NLP_initial_point() + _set_nlp_initial_point(prob) assert (x.value == 4.5 * np.ones((3, ))).all() # test 2 x = cp.Variable((3, ), bounds=[3.5, np.inf]) prob = cp.Problem(cp.Minimize(cp.sum(x))) - prob.set_NLP_initial_point() + _set_nlp_initial_point(prob) assert (x.value == 4.5 * np.ones((3, ))).all() def test_set_initial_point_both_bounds_finite(self): @@ -83,7 +84,7 @@ def test_set_initial_point_both_bounds_finite(self): # test 1 x = cp.Variable((3, ), bounds=[3.5, 4.5]) prob = cp.Problem(cp.Minimize(cp.sum(x))) - prob.set_NLP_initial_point() + _set_nlp_initial_point(prob) assert (x.value == 4.0 * np.ones((3, ))).all() def test_set_initial_point_mixed_inf_and_finite(self): @@ -91,7 +92,7 @@ def test_set_initial_point_mixed_inf_and_finite(self): ub = np.array([-4, 4.5, np.inf, 4.5, np.inf, 4.5]) x = cp.Variable((6, ), bounds=[lb, ub]) prob = cp.Problem(cp.Minimize(cp.sum(x))) - prob.set_NLP_initial_point() + _set_nlp_initial_point(prob) expected = np.array([-5, 4.0, 0.0, 1.5, 3, 3.5]) assert (x.value == expected).all() @@ -99,18 +100,18 @@ def test_set_initial_point_two_variables(self): x = cp.Variable((2, ), bounds=[-np.inf, np.inf]) y = cp.Variable((2, ), bounds=[-3, np.inf]) prob = cp.Problem(cp.Minimize(cp.sum(x) + cp.sum(y))) - prob.set_NLP_initial_point() + _set_nlp_initial_point(prob) assert (x.value == np.zeros((2, ))).all() assert (y.value == -2 * np.ones((2, ))).all() def test_set_initial_point_nonnegative_attributes(self): x = cp.Variable((2, ), nonneg=True) prob = cp.Problem(cp.Minimize(cp.sum(x))) - prob.set_NLP_initial_point() + _set_nlp_initial_point(prob) assert (x.value == np.ones((2, ))).all() def test_set_initial_point_nonnegative_attributes_and_bounds(self): x = cp.Variable((2, ), nonneg=True, bounds=[1, None]) prob = cp.Problem(cp.Minimize(cp.sum(x))) - prob.set_NLP_initial_point() - assert (x.value == 2 * np.ones((2, ))).all() \ No newline at end of file + _set_nlp_initial_point(prob) + assert (x.value == 2 * np.ones((2, ))).all() From 4d377755556d7ce30a02f98b20ba5ff99c416b34 Mon Sep 17 00:00:00 2001 From: William Zijie Zhang Date: Sun, 22 Feb 2026 09:26:09 -0800 Subject: [PATCH 3/7] Remove debug print and expand comment in best_of loop Co-Authored-By: Claude Opus 4.6 --- cvxpy/reductions/solvers/nlp_solving_chain.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/cvxpy/reductions/solvers/nlp_solving_chain.py b/cvxpy/reductions/solvers/nlp_solving_chain.py index 10ad13a8c5..641cf7a95f 100644 --- a/cvxpy/reductions/solvers/nlp_solving_chain.py +++ b/cvxpy/reductions/solvers/nlp_solving_chain.py @@ -198,14 +198,17 @@ def solve_nlp(problem, solver, warm_start, verbose, **kwargs): solution = nlp_chain.solver.solve_via_data(canon_problem, warm_start, verbose, solver_opts=kwargs) - # Unpack to get the objective value in the original problem space + # Unpack to get the objective value in the original problem space. + # If we do obj_value = canon_problem['objective'] we get the objective + # value of the canonicalized problem which can be slightly different + # from that of the original NLP. We therefore implement this approach + # based on unpacking. problem.unpack_results(solution, nlp_chain, inverse_data) obj_value = problem.objective.value all_objs[run] = obj_value if obj_value < best_obj: best_obj = obj_value - print("best_obj: ", best_obj) best_solution = solution # Unpack best solution From 423f4d87edf15bcf441c45a39e39c4c61a783801 Mon Sep 17 00:00:00 2001 From: William Zijie Zhang Date: Mon, 23 Feb 2026 23:58:54 -0800 Subject: [PATCH 4/7] Gate best_of print on verbose flag Co-Authored-By: Claude Opus 4.6 --- cvxpy/reductions/solvers/nlp_solving_chain.py | 101 ++++++++++-------- 1 file changed, 55 insertions(+), 46 deletions(-) diff --git a/cvxpy/reductions/solvers/nlp_solving_chain.py b/cvxpy/reductions/solvers/nlp_solving_chain.py index 641cf7a95f..470519a6da 100644 --- a/cvxpy/reductions/solvers/nlp_solving_chain.py +++ b/cvxpy/reductions/solvers/nlp_solving_chain.py @@ -16,17 +16,22 @@ import numpy as np from cvxpy import error -from cvxpy import settings as s from cvxpy.problems.objective import Maximize from cvxpy.reductions.cvx_attr2constr import CvxAttr2Constr from cvxpy.reductions.dnlp2smooth.dnlp2smooth import Dnlp2Smooth from cvxpy.reductions.flip_objective import FlipObjective -from cvxpy.reductions.solvers.nlp_solvers.copt_nlpif import COPT as COPT_nlp -from cvxpy.reductions.solvers.nlp_solvers.ipopt_nlpif import IPOPT as IPOPT_nlp -from cvxpy.reductions.solvers.nlp_solvers.knitro_nlpif import KNITRO as KNITRO_nlp -from cvxpy.reductions.solvers.nlp_solvers.uno_nlpif import UNO as UNO_nlp +from cvxpy.reductions.solvers.defines import INSTALLED_SOLVERS, SOLVER_MAP_NLP from cvxpy.reductions.solvers.solving_chain import SolvingChain +# Solver variants: maps variant name → (base solver name, extra kwargs). +NLP_SOLVER_VARIANTS = { + "knitro_ipm": ("KNITRO", {"algorithm": 1}), + "knitro_sqp": ("KNITRO", {"algorithm": 4}), + "knitro_alm": ("KNITRO", {"algorithm": 6}), + "uno_ipm": ("UNO", {"preset": "ipopt", "linear_solver": "MUMPS"}), + "uno_sqp": ("UNO", {"preset": "filtersqp"}), +} + def _build_nlp_chain(problem, solver, kwargs): """Build the NLP reduction chain and return (SolvingChain, kwargs). @@ -39,25 +44,23 @@ def _build_nlp_chain(problem, solver, kwargs): reductions = [] reductions = reductions + [CvxAttr2Constr(reduce_bounds=False), Dnlp2Smooth()] - if solver is s.IPOPT or solver is None: - reductions.append(IPOPT_nlp()) - elif "knitro" in solver.lower(): - if solver == "knitro_ipm": - kwargs["algorithm"] = 1 - elif solver == "knitro_sqp": - kwargs["algorithm"] = 4 - elif solver == "knitro_alm": - kwargs["algorithm"] = 6 - reductions.append(KNITRO_nlp()) - elif solver is s.COPT: - reductions.append(COPT_nlp()) - elif "uno" in solver.lower(): - if solver.lower() == "uno_ipm": - kwargs["preset"] = "ipopt" - kwargs["linear_solver"] = "MUMPS" - elif solver.lower() == "uno_sqp": - kwargs["preset"] = "filtersqp" - reductions.append(UNO_nlp()) + if solver is None: + # Pick first installed NLP solver in preference order. + for name, inst in SOLVER_MAP_NLP.items(): + if name in INSTALLED_SOLVERS: + reductions.append(inst) + break + else: + raise error.SolverError( + "No NLP solver is installed. Install one of: %s" + % ", ".join(SOLVER_MAP_NLP) + ) + elif solver in SOLVER_MAP_NLP: + reductions.append(SOLVER_MAP_NLP[solver]) + elif solver.lower() in NLP_SOLVER_VARIANTS: + base_name, variant_kwargs = NLP_SOLVER_VARIANTS[solver.lower()] + kwargs.update(variant_kwargs) + reductions.append(SOLVER_MAP_NLP[base_name]) else: raise error.SolverError( "Solver %s is not supported for NLP problems." % solver @@ -81,15 +84,15 @@ def _set_nlp_initial_point(problem): lb_finite = np.isfinite(lb) ub_finite = np.isfinite(ub) - # Replace infs with zero for arithmetic - lb0 = np.where(lb_finite, lb, 0.0) - ub0 = np.where(ub_finite, ub, 0.0) - # Midpoint if both finite, one from bound if only one finite, zero if none - init = (lb_finite * ub_finite * 0.5 * (lb0 + ub0) + - lb_finite * (~ub_finite) * (lb0 + 1.0) + - (~lb_finite) * ub_finite * (ub0 - 1.0)) - # Broadcast to variable shape (handles scalar bounds) - init = np.broadcast_to(init, var.shape).copy() + + init = np.zeros(var.shape) + both = lb_finite & ub_finite + lb_only = lb_finite & ~ub_finite + ub_only = ~lb_finite & ub_finite + init[both] = 0.5 * (lb[both] + ub[both]) + init[lb_only] = lb[lb_only] + 1.0 + init[ub_only] = ub[ub_only] - 1.0 + var.save_value(init) @@ -131,19 +134,22 @@ def _set_random_nlp_initial_point(problem, run, user_initials): # Reset to None from last solve var.value = None - # Set sample_bounds to variable bounds if sample_bounds is None - # and variable bounds (possibly infinite) are set - if var.sample_bounds is None and var.bounds is not None: - var.sample_bounds = var.bounds - - # Sample initial value if sample_bounds is set - if var.sample_bounds is not None: - low, high = var.sample_bounds + # Determine effective sample bounds: use explicit sample_bounds if set, + # otherwise fall back to variable bounds if both are finite. + sb = var.sample_bounds + if sb is None: + lb, ub = var.get_bounds() + if np.all(np.isfinite(lb)) and np.all(np.isfinite(ub)): + sb = (lb, ub) + + # Sample initial value if effective sample bounds are available + if sb is not None: + low, high = sb if not np.all(np.isfinite(low)) or not np.all(np.isfinite(high)): raise ValueError( "Variable %s has non-finite sample_bounds %s. Cannot generate" " random initial point. Either add sample bounds or set the value. " - % (var.name(), var.sample_bounds) + % (var.name(), sb) ) initial_val = np.random.uniform(low=low, high=high, size=var.shape) @@ -174,6 +180,9 @@ def solve_nlp(problem, solver, warm_start, verbose, **kwargs): nlp_chain, kwargs = _build_nlp_chain(problem, solver, kwargs) best_of = kwargs.pop("best_of", 1) + if not isinstance(best_of, int) or best_of < 1: + raise ValueError("best_of must be a positive integer.") + # Standard single solve if best_of == 1: _set_nlp_initial_point(problem) @@ -184,15 +193,11 @@ def solve_nlp(problem, solver, warm_start, verbose, **kwargs): return problem.value # Best-of-N solve - if (not isinstance(best_of, int)) or best_of < 1: - raise ValueError("best_of must be a positive integer.") - best_obj, best_solution = float("inf"), None all_objs = np.zeros(shape=(best_of,)) user_initials = {} for run in range(best_of): - print("Starting NLP solve %d of %d" % (run + 1, best_of)) _set_random_nlp_initial_point(problem, run, user_initials) canon_problem, inverse_data = nlp_chain.apply(problem=problem) solution = nlp_chain.solver.solve_via_data(canon_problem, warm_start, @@ -211,6 +216,10 @@ def solve_nlp(problem, solver, warm_start, verbose, **kwargs): best_obj = obj_value best_solution = solution + if verbose: + print("Run %d/%d: obj = %.6e | best so far = %.6e" + % (run + 1, best_of, obj_value, best_obj)) + # Unpack best solution if type(problem.objective) == Maximize: all_objs = -all_objs From 25c01c4912e4fee68875c450c0da44f4cd62fc77 Mon Sep 17 00:00:00 2001 From: William Zijie Zhang Date: Tue, 24 Feb 2026 09:34:05 -0800 Subject: [PATCH 5/7] Address PR review comments on nlp_solving_chain extraction - Add circular import comment in problem.py explaining the deferred import - Move NLP_SOLVER_VARIANTS from nlp_solving_chain.py to defines.py - Set BOUNDED_VARIABLES = True on NLPsolver base class and use it in _build_nlp_chain (matching the conic solver pattern in solving_chain.py) Co-Authored-By: Claude Opus 4.6 --- cvxpy/problems/problem.py | 2 ++ cvxpy/reductions/solvers/defines.py | 12 ++++++- .../solvers/nlp_solvers/knitro_nlpif.py | 1 - .../solvers/nlp_solvers/nlp_solver.py | 1 + cvxpy/reductions/solvers/nlp_solving_chain.py | 35 +++++++++---------- 5 files changed, 30 insertions(+), 21 deletions(-) diff --git a/cvxpy/problems/problem.py b/cvxpy/problems/problem.py index 5f071ef2fe..a1d95da67d 100644 --- a/cvxpy/problems/problem.py +++ b/cvxpy/problems/problem.py @@ -1074,6 +1074,8 @@ def _solve(self, return self.value if nlp and self.is_dnlp(): + # Deferred import to avoid circular import: + # nlp_solving_chain → dnlp2smooth → cvxpy → problem from cvxpy.reductions.solvers.nlp_solving_chain import solve_nlp return solve_nlp(self, solver, warm_start, verbose, **kwargs) elif nlp and not self.is_dnlp(): diff --git a/cvxpy/reductions/solvers/defines.py b/cvxpy/reductions/solvers/defines.py index edfd1c7aa2..a4e3f66586 100644 --- a/cvxpy/reductions/solvers/defines.py +++ b/cvxpy/reductions/solvers/defines.py @@ -49,6 +49,7 @@ # NLP interfaces from cvxpy.reductions.solvers.nlp_solvers.copt_nlpif import COPT as COPT_nlp from cvxpy.reductions.solvers.nlp_solvers.ipopt_nlpif import IPOPT as IPOPT_nlp +from cvxpy.reductions.solvers.nlp_solvers.knitro_nlpif import KNITRO as KNITRO_nlp from cvxpy.reductions.solvers.nlp_solvers.uno_nlpif import UNO as UNO_nlp # QP interfaces @@ -83,7 +84,7 @@ ]} SOLVER_MAP_NLP = {inst.name(): inst for inst in [ - IPOPT_nlp(), UNO_nlp(), COPT_nlp(), + IPOPT_nlp(), KNITRO_nlp(), UNO_nlp(), COPT_nlp(), ]} # Preference-ordered solver name lists, derived from the maps above. @@ -91,6 +92,15 @@ QP_SOLVERS = list(SOLVER_MAP_QP) NLP_SOLVERS = list(SOLVER_MAP_NLP) +# Solver variants: maps variant name → (base solver name, extra kwargs). +NLP_SOLVER_VARIANTS = { + "knitro_ipm": ("KNITRO", {"algorithm": 1}), + "knitro_sqp": ("KNITRO", {"algorithm": 4}), + "knitro_alm": ("KNITRO", {"algorithm": 6}), + "uno_ipm": ("UNO", {"preset": "ipopt", "linear_solver": "MUMPS"}), + "uno_sqp": ("UNO", {"preset": "filtersqp"}), +} + # Mixed-integer solver lists, derived from solver class attributes. MI_SOLVERS = [ name for name, slv in SOLVER_MAP_CONIC.items() if slv.MIP_CAPABLE diff --git a/cvxpy/reductions/solvers/nlp_solvers/knitro_nlpif.py b/cvxpy/reductions/solvers/nlp_solvers/knitro_nlpif.py index d10a1e762c..45eb35dce9 100644 --- a/cvxpy/reductions/solvers/nlp_solvers/knitro_nlpif.py +++ b/cvxpy/reductions/solvers/nlp_solvers/knitro_nlpif.py @@ -27,7 +27,6 @@ class KNITRO(NLPsolver): NLP interface for the KNITRO solver """ - BOUNDED_VARIABLES = True # Keys: CONTEXT_KEY = "context" X_INIT_KEY = "x_init" diff --git a/cvxpy/reductions/solvers/nlp_solvers/nlp_solver.py b/cvxpy/reductions/solvers/nlp_solvers/nlp_solver.py index 1211e6f374..e877093b88 100644 --- a/cvxpy/reductions/solvers/nlp_solvers/nlp_solver.py +++ b/cvxpy/reductions/solvers/nlp_solvers/nlp_solver.py @@ -36,6 +36,7 @@ class NLPsolver(Solver): """ REQUIRES_CONSTR = False MIP_CAPABLE = False + BOUNDED_VARIABLES = True def accepts(self, problem): """ diff --git a/cvxpy/reductions/solvers/nlp_solving_chain.py b/cvxpy/reductions/solvers/nlp_solving_chain.py index 470519a6da..ed7ba694bf 100644 --- a/cvxpy/reductions/solvers/nlp_solving_chain.py +++ b/cvxpy/reductions/solvers/nlp_solving_chain.py @@ -20,35 +20,21 @@ from cvxpy.reductions.cvx_attr2constr import CvxAttr2Constr from cvxpy.reductions.dnlp2smooth.dnlp2smooth import Dnlp2Smooth from cvxpy.reductions.flip_objective import FlipObjective -from cvxpy.reductions.solvers.defines import INSTALLED_SOLVERS, SOLVER_MAP_NLP +from cvxpy.reductions.solvers.defines import INSTALLED_SOLVERS, NLP_SOLVER_VARIANTS, SOLVER_MAP_NLP from cvxpy.reductions.solvers.solving_chain import SolvingChain -# Solver variants: maps variant name → (base solver name, extra kwargs). -NLP_SOLVER_VARIANTS = { - "knitro_ipm": ("KNITRO", {"algorithm": 1}), - "knitro_sqp": ("KNITRO", {"algorithm": 4}), - "knitro_alm": ("KNITRO", {"algorithm": 6}), - "uno_ipm": ("UNO", {"preset": "ipopt", "linear_solver": "MUMPS"}), - "uno_sqp": ("UNO", {"preset": "filtersqp"}), -} - def _build_nlp_chain(problem, solver, kwargs): """Build the NLP reduction chain and return (SolvingChain, kwargs). Solver selection may mutate kwargs (e.g., Knitro algorithm, Uno preset). """ - if type(problem.objective) == Maximize: - reductions = [FlipObjective()] - else: - reductions = [] - reductions = reductions + [CvxAttr2Constr(reduce_bounds=False), Dnlp2Smooth()] - + # Resolve the solver instance. if solver is None: # Pick first installed NLP solver in preference order. for name, inst in SOLVER_MAP_NLP.items(): if name in INSTALLED_SOLVERS: - reductions.append(inst) + solver_instance = inst break else: raise error.SolverError( @@ -56,16 +42,27 @@ def _build_nlp_chain(problem, solver, kwargs): % ", ".join(SOLVER_MAP_NLP) ) elif solver in SOLVER_MAP_NLP: - reductions.append(SOLVER_MAP_NLP[solver]) + solver_instance = SOLVER_MAP_NLP[solver] elif solver.lower() in NLP_SOLVER_VARIANTS: base_name, variant_kwargs = NLP_SOLVER_VARIANTS[solver.lower()] kwargs.update(variant_kwargs) - reductions.append(SOLVER_MAP_NLP[base_name]) + solver_instance = SOLVER_MAP_NLP[base_name] else: raise error.SolverError( "Solver %s is not supported for NLP problems." % solver ) + # Build the reduction chain. + if type(problem.objective) == Maximize: + reductions = [FlipObjective()] + else: + reductions = [] + reductions += [ + CvxAttr2Constr(reduce_bounds=not solver_instance.BOUNDED_VARIABLES), + Dnlp2Smooth(), + solver_instance, + ] + return SolvingChain(reductions=reductions), kwargs From aa9630efda9f99991e2c569bc5edb8a964505368 Mon Sep 17 00:00:00 2001 From: William Zijie Zhang Date: Tue, 24 Feb 2026 11:17:48 -0800 Subject: [PATCH 6/7] adds changes to test_problem with parametrize --- CLAUDE.md | 10 ++- cvxpy/tests/nlp_tests/test_problem.py | 100 ++++++-------------------- 2 files changed, 27 insertions(+), 83 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 81e9c66372..71c453a6a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ prob.solve(nlp=True, solver=cp.IPOPT, best_of=5) | Solver | License | Installation | |--------|---------|--------------| -| [IPOPT](https://github.com/coin-or/Ipopt) | EPL-2.0 | `conda install -c conda-forge cyipopt` | +| [IPOPT](https://github.com/coin-or/Ipopt) | EPL-2.0 | `conda install -c conda-forge cyipopt` (conda only — pip install is unreliable) | | [Knitro](https://www.artelys.com/solvers/knitro/) | Commercial | `pip install knitro` (requires license) | | [COPT](https://www.copt.de/) | Commercial | Requires license | | [Uno](https://github.com/cuter-testing/uno) | Open source | See Uno documentation | @@ -129,9 +129,9 @@ Key reduction classes in `cvxpy/reductions/`: - `Chain` composes multiple reductions - `SolvingChain` orchestrates the full solve process -For DNLP: `CvxAttr2Constr` → `Dnlp2Smooth` → `NLPSolver` +For DNLP: `FlipObjective` (if Maximize) → `CvxAttr2Constr` → `Dnlp2Smooth` → `NLPSolver` -Note: The standard `SolvingChain` in `solving_chain.py` handles DCP/DGP/DQCP solver selection automatically. NLP solving is triggered explicitly via `prob.solve(nlp=True)` and bypasses the standard chain. +Note: The standard `SolvingChain` in `solving_chain.py` handles DCP/DGP/DQCP solver selection automatically. NLP solving is triggered explicitly via `prob.solve(nlp=True)` and bypasses the standard chain — `problem.py` delegates to `solve_nlp()` in `cvxpy/reductions/solvers/nlp_solving_chain.py`, which builds the chain, handles initial point construction (via `_set_nlp_initial_point`), and orchestrates the `best_of` multi-start logic. Solver algorithm variants (e.g., `"knitro_sqp"`) are mapped via `NLP_SOLVER_VARIANTS` in `nlp_solving_chain.py`. ### Solver Categories @@ -215,6 +215,10 @@ class TestMyFeature(BaseTest): NLP tests are in `cvxpy/tests/nlp_tests/` with Jacobian and Hessian verification tests. +## Build System + +Uses a custom build backend (`setup/build_meta.py`) that re-exports `setuptools.build_meta`. The `setup/` directory handles C extensions (cvxcore, sparsecholesky) and version management. Solver registration is in `cvxpy/reductions/solvers/defines.py` (`SOLVER_MAP_CONIC`, `SOLVER_MAP_QP`, `SOLVER_MAP_NLP`). + ## Benchmarks Benchmarks use [Airspeed Velocity](https://asv.readthedocs.io/) and live in the `benchmarks/` directory. To run locally: diff --git a/cvxpy/tests/nlp_tests/test_problem.py b/cvxpy/tests/nlp_tests/test_problem.py index c7850c92fd..ff111fb428 100644 --- a/cvxpy/tests/nlp_tests/test_problem.py +++ b/cvxpy/tests/nlp_tests/test_problem.py @@ -1,91 +1,31 @@ import numpy as np +import pytest import cvxpy as cp from cvxpy.reductions.solvers.nlp_solving_chain import _set_nlp_initial_point class TestProblem(): - """ - This class can be used to test internal function for Problem that have been added - in the DNLP extension. - """ - - def test_set_initial_point_both_bounds_infinity(self): - # when both bounds are infinity, the initial point should be zero vector - - # test 1 - x = cp.Variable((3, )) - prob = cp.Problem(cp.Minimize(cp.sum(x))) - _set_nlp_initial_point(prob) - assert (x.value == np.zeros((3, ))).all() - - # test 2 - x = cp.Variable((3, ), bounds=[None, None]) - prob = cp.Problem(cp.Minimize(cp.sum(x))) - _set_nlp_initial_point(prob) - assert (x.value == np.zeros((3, ))).all() - - # test 3 - x = cp.Variable((3, ), bounds=[-np.inf, np.inf]) - prob = cp.Problem(cp.Minimize(cp.sum(x))) - _set_nlp_initial_point(prob) - assert (x.value == np.zeros((3, ))).all() - - # test 4 - x = cp.Variable((3, ), bounds=[None, np.inf]) - prob = cp.Problem(cp.Minimize(cp.sum(x))) - _set_nlp_initial_point(prob) - assert (x.value == np.zeros((3, ))).all() - - # test 5 - x = cp.Variable((3, ), bounds=[-np.inf, None]) - prob = cp.Problem(cp.Minimize(cp.sum(x))) - _set_nlp_initial_point(prob) - assert (x.value == np.zeros((3, ))).all() - - - def test_set_initial_point_lower_bound_infinity(self): - # when one bound is infinity, the initial point should be one unit - # away from the finite bound - - # test 1 - x = cp.Variable((3, ), bounds=[None, 3.5]) - prob = cp.Problem(cp.Minimize(cp.sum(x))) - _set_nlp_initial_point(prob) - assert (x.value == 2.5 * np.ones((3, ))).all() - - # test 2 - x = cp.Variable((3, ), bounds=[-np.inf, 3.5]) - prob = cp.Problem(cp.Minimize(cp.sum(x))) - _set_nlp_initial_point(prob) - assert (x.value == 2.5 * np.ones((3, ))).all() - - def test_set_initial_point_upper_bound_infinity(self): - # when one bound is infinity, the initial point should be one unit - # away from the finite bound - - # test 1 - x = cp.Variable((3, ), bounds=[3.5, None]) - prob = cp.Problem(cp.Minimize(cp.sum(x))) - _set_nlp_initial_point(prob) - assert (x.value == 4.5 * np.ones((3, ))).all() - - # test 2 - x = cp.Variable((3, ), bounds=[3.5, np.inf]) - prob = cp.Problem(cp.Minimize(cp.sum(x))) - _set_nlp_initial_point(prob) - assert (x.value == 4.5 * np.ones((3, ))).all() - - def test_set_initial_point_both_bounds_finite(self): - # when both bounds are finite, the initial point should be the midpoint - # between the two bounds - - # test 1 - x = cp.Variable((3, ), bounds=[3.5, 4.5]) - prob = cp.Problem(cp.Minimize(cp.sum(x))) - _set_nlp_initial_point(prob) - assert (x.value == 4.0 * np.ones((3, ))).all() + """Tests for internal NLP functions in the DNLP extension.""" + + @pytest.mark.parametrize("bounds, expected", [ + (None, 0.0), + ([None, None], 0.0), + ([-np.inf, np.inf], 0.0), + ([None, np.inf], 0.0), + ([-np.inf, None], 0.0), + ([None, 3.5], 2.5), + ([-np.inf, 3.5], 2.5), + ([3.5, None], 4.5), + ([3.5, np.inf], 4.5), + ([3.5, 4.5], 4.0), + ]) + def test_set_initial_point_scalar_bounds(self, bounds, expected): + x = cp.Variable((3, ), bounds=bounds) + prob = cp.Problem(cp.Minimize(cp.sum(x))) + _set_nlp_initial_point(prob) + assert (x.value == expected * np.ones((3, ))).all() def test_set_initial_point_mixed_inf_and_finite(self): lb = np.array([-np.inf, 3.5, -np.inf, -1.5, 2, 2.5]) From 3209547d038a9387cbd4f8bf1e914b4df0e81c21 Mon Sep 17 00:00:00 2001 From: William Zijie Zhang Date: Tue, 24 Feb 2026 12:07:50 -0800 Subject: [PATCH 7/7] Revert verbose unpack comment to original single-line version Co-Authored-By: Claude Opus 4.6 --- cvxpy/reductions/solvers/nlp_solving_chain.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/cvxpy/reductions/solvers/nlp_solving_chain.py b/cvxpy/reductions/solvers/nlp_solving_chain.py index ed7ba694bf..10f00cd418 100644 --- a/cvxpy/reductions/solvers/nlp_solving_chain.py +++ b/cvxpy/reductions/solvers/nlp_solving_chain.py @@ -200,11 +200,7 @@ def solve_nlp(problem, solver, warm_start, verbose, **kwargs): solution = nlp_chain.solver.solve_via_data(canon_problem, warm_start, verbose, solver_opts=kwargs) - # Unpack to get the objective value in the original problem space. - # If we do obj_value = canon_problem['objective'] we get the objective - # value of the canonicalized problem which can be slightly different - # from that of the original NLP. We therefore implement this approach - # based on unpacking. + # Unpack to get the objective value in the original problem space problem.unpack_results(solution, nlp_chain, inverse_data) obj_value = problem.objective.value