Skip to content

Commit ae6b87f

Browse files
Transurgeonclaude
andauthored
Extract NLP solving logic from problem.py into nlp_solving_chain.py (#156)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * Remove debug print and expand comment in best_of loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Gate best_of print on verbose flag Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * adds changes to test_problem with parametrize * Revert verbose unpack comment to original single-line version Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5f0d38a commit ae6b87f

7 files changed

Lines changed: 272 additions & 275 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ prob.solve(nlp=True, solver=cp.IPOPT, best_of=5)
6565

6666
| Solver | License | Installation |
6767
|--------|---------|--------------|
68-
| [IPOPT](https://github.com/coin-or/Ipopt) | EPL-2.0 | `conda install -c conda-forge cyipopt` |
68+
| [IPOPT](https://github.com/coin-or/Ipopt) | EPL-2.0 | `conda install -c conda-forge cyipopt` (conda only — pip install is unreliable) |
6969
| [Knitro](https://www.artelys.com/solvers/knitro/) | Commercial | `pip install knitro` (requires license) |
7070
| [COPT](https://www.copt.de/) | Commercial | Requires license |
7171
| [Uno](https://github.com/cuter-testing/uno) | Open source | See Uno documentation |
@@ -129,9 +129,9 @@ Key reduction classes in `cvxpy/reductions/`:
129129
- `Chain` composes multiple reductions
130130
- `SolvingChain` orchestrates the full solve process
131131

132-
For DNLP: `CvxAttr2Constr``Dnlp2Smooth``NLPSolver`
132+
For DNLP: `FlipObjective` (if Maximize) → `CvxAttr2Constr``Dnlp2Smooth``NLPSolver`
133133

134-
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.
134+
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`.
135135

136136
### Solver Categories
137137

@@ -242,6 +242,10 @@ class TestMyNLPFeature:
242242

243243
A common pattern is to solve with both a DCP solver (CLARABEL) and an NLP solver (IPOPT) and verify the results match.
244244

245+
## Build System
246+
247+
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`).
248+
245249
## Benchmarks
246250

247251
Benchmarks use [Airspeed Velocity](https://asv.readthedocs.io/) and live in the `benchmarks/` directory. To run locally:

cvxpy/problems/problem.py

Lines changed: 4 additions & 185 deletions
Original file line numberDiff line numberDiff line change
@@ -39,18 +39,12 @@
3939
from cvxpy.problems.objective import Maximize, Minimize
4040
from cvxpy.reductions import InverseData
4141
from cvxpy.reductions.chain import Chain
42-
from cvxpy.reductions.cvx_attr2constr import CvxAttr2Constr
43-
from cvxpy.reductions.dnlp2smooth.dnlp2smooth import Dnlp2Smooth
4442
from cvxpy.reductions.dqcp2dcp import dqcp2dcp
4543
from cvxpy.reductions.eval_params import EvalParams
4644
from cvxpy.reductions.flip_objective import FlipObjective
4745
from cvxpy.reductions.solution import INF_OR_UNB_MESSAGE
4846
from cvxpy.reductions.solvers import bisection
4947
from cvxpy.reductions.solvers import defines as slv_def
50-
from cvxpy.reductions.solvers.nlp_solvers.copt_nlpif import COPT as COPT_nlp
51-
from cvxpy.reductions.solvers.nlp_solvers.ipopt_nlpif import IPOPT as IPOPT_nlp
52-
from cvxpy.reductions.solvers.nlp_solvers.knitro_nlpif import KNITRO as KNITRO_nlp
53-
from cvxpy.reductions.solvers.nlp_solvers.uno_nlpif import UNO as UNO_nlp
5448
from cvxpy.reductions.solvers.solver_inverse_data import SolverInverseData
5549
from cvxpy.reductions.solvers.solving_chain import (
5650
SolvingChain,
@@ -1080,89 +1074,10 @@ def _solve(self,
10801074
return self.value
10811075

10821076
if nlp and self.is_dnlp():
1083-
if type(self.objective) == Maximize:
1084-
reductions = [FlipObjective()]
1085-
else:
1086-
reductions = []
1087-
reductions = reductions + [CvxAttr2Constr(reduce_bounds=False), Dnlp2Smooth()]
1088-
# instantiate based on user provided solver
1089-
# (default to Ipopt)
1090-
if solver is s.IPOPT or solver is None:
1091-
nlp_reductions = reductions + [IPOPT_nlp()]
1092-
elif "knitro" in solver.lower():
1093-
if solver == "knitro_ipm":
1094-
kwargs["algorithm"] = 1
1095-
elif solver == "knitro_sqp":
1096-
kwargs["algorithm"] = 4
1097-
elif solver == "knitro_alm":
1098-
kwargs["algorithm"] = 6
1099-
nlp_reductions = reductions + [KNITRO_nlp()]
1100-
elif solver is s.COPT:
1101-
nlp_reductions = reductions + [COPT_nlp()]
1102-
elif "uno" in solver.lower():
1103-
if solver.lower() == "uno_ipm":
1104-
# Interior-point method (requires MUMPS linear solver)
1105-
kwargs["preset"] = "ipopt"
1106-
kwargs["linear_solver"] = "MUMPS"
1107-
elif solver.lower() == "uno_sqp":
1108-
# SQP method (default)
1109-
kwargs["preset"] = "filtersqp"
1110-
nlp_reductions = reductions + [UNO_nlp()]
1111-
else:
1112-
raise error.SolverError(
1113-
"Solver %s is not supported for NLP problems." % solver
1114-
)
1115-
# canonicalize disciplined nlp problems to smooth form
1116-
nlp_chain = SolvingChain(reductions=nlp_reductions)
1117-
best_of = kwargs.pop("best_of", 1)
1118-
1119-
# standard solve
1120-
if best_of == 1:
1121-
self.set_NLP_initial_point()
1122-
canon_problem, inverse_data = nlp_chain.apply(problem=self)
1123-
solution = nlp_chain.solver.solve_via_data(canon_problem, warm_start,
1124-
verbose, solver_opts=kwargs)
1125-
self.unpack_results(solution, nlp_chain, inverse_data)
1126-
return self.value
1127-
# best-of-N solve
1128-
else:
1129-
if (not isinstance(best_of, int)) or best_of < 1:
1130-
raise ValueError("best_of must be a positive integer.")
1131-
1132-
best_obj, best_solution = float("inf"), None
1133-
all_objs = np.zeros(shape=(best_of,))
1134-
1135-
for run in range(best_of):
1136-
print("Starting NLP solve %d of %d" % (run + 1, best_of))
1137-
self.set_random_NLP_initial_point(run)
1138-
canon_problem, inverse_data = nlp_chain.apply(problem=self)
1139-
solution = nlp_chain.solver.solve_via_data(canon_problem, warm_start,
1140-
verbose, solver_opts=kwargs)
1141-
1142-
# This gives the objective value of the C problem
1143-
# which can be slightly different from the original NLP
1144-
# so we use the below approach with unpacking. Preferably
1145-
# we would have a way to do this without unpacking.
1146-
#obj_value = canon_problem['objective'](solution['x'])
1147-
1148-
# set cvxpy variable
1149-
self.unpack_results(solution, nlp_chain, inverse_data)
1150-
obj_value = self.objective.value
1151-
1152-
all_objs[run] = obj_value
1153-
if obj_value < best_obj:
1154-
best_obj = obj_value
1155-
print("best_obj: ", best_obj)
1156-
best_solution = solution
1157-
1158-
# unpack best solution
1159-
if type(self.objective) == Maximize:
1160-
all_objs = -all_objs
1161-
1162-
# propagate all objective values to the user
1163-
best_solution['all_objs_from_best_of'] = all_objs
1164-
self.unpack_results(best_solution, nlp_chain, inverse_data)
1165-
return self.value
1077+
# Deferred import to avoid circular import:
1078+
# nlp_solving_chain → dnlp2smooth → cvxpy → problem
1079+
from cvxpy.reductions.solvers.nlp_solving_chain import solve_nlp
1080+
return solve_nlp(self, solver, warm_start, verbose, **kwargs)
11661081
elif nlp and not self.is_dnlp():
11671082
raise error.DNLPError("The problem you specified is not DNLP.")
11681083

@@ -1517,103 +1432,7 @@ def unpack_results(self, solution, chain: SolvingChain, inverse_data) -> None:
15171432
self._solver_stats = SolverStats.from_dict(self._solution.attr,
15181433
chain.solver.name())
15191434

1520-
1521-
def set_NLP_initial_point(self) -> dict:
1522-
""" Constructs an initial point for the optimization problem. If no
1523-
initial value is specified, look at the bounds. If both lb and ub are
1524-
specified, we initialize the variables to be their midpoints. If only
1525-
one of them is specified, we initialize the variable one unit from
1526-
the bound. If none of them is specified, we initialize it to zero.
1527-
"""
1528-
for var in self.variables():
1529-
if var.value is not None:
1530-
continue
1531-
1532-
bounds = var.bounds
1533-
is_nonneg = var.is_nonneg()
1534-
is_nonpos = var.is_nonpos()
1535-
1536-
if bounds is None:
1537-
if is_nonneg:
1538-
x0 = np.ones(var.shape)
1539-
elif is_nonpos:
1540-
x0 = -np.ones(var.shape)
1541-
else:
1542-
x0 = np.zeros(var.shape)
1543-
1544-
var.save_value(x0)
1545-
else:
1546-
lb, ub = bounds
1547-
1548-
if is_nonneg:
1549-
lb = np.maximum(lb, 0)
1550-
elif is_nonpos:
1551-
ub = np.maximum(ub, 0)
1552-
1553-
lb_finite = np.isfinite(lb)
1554-
ub_finite = np.isfinite(ub)
1555-
# Replace infs with zero for arithmetic
1556-
lb0 = np.where(lb_finite, lb, 0.0)
1557-
ub0 = np.where(ub_finite, ub, 0.0)
1558-
# Midpoint if both finite, one from bound if only one finite, zero if none
1559-
init = (lb_finite * ub_finite * 0.5 * (lb0 + ub0) +
1560-
lb_finite * (~ub_finite) * (lb0 + 1.0) +
1561-
(~lb_finite) * ub_finite * (ub0 - 1.0))
1562-
# Broadcast to variable shape (handles scalar bounds)
1563-
init = np.broadcast_to(init, var.shape).copy()
1564-
var.save_value(init)
1565-
1566-
1567-
def set_random_NLP_initial_point(self, run) -> dict:
1568-
""" Generates a random initial point for DNLP problems.
1569-
A variable is initialized randomly in the following cases:
1570-
1. 'sample_bounds' is set for that variable.
1571-
2. the initial value specified by the user is None,
1572-
'sample_bounds' is not set for that variable, but the
1573-
variable has both finite lower and upper bounds.
1574-
"""
15751435

1576-
# store user-specified initial values for variables that do
1577-
# not have sample bounds assigned
1578-
if run == 0:
1579-
self._user_initials = {}
1580-
for var in self.variables():
1581-
if var.sample_bounds is not None:
1582-
self._user_initials[var.id] = None
1583-
else:
1584-
self._user_initials[var.id] = var.value
1585-
1586-
for var in self.variables():
1587-
1588-
# skip variables with user-specified initial value
1589-
# (note that any variable with sample bounds set will have
1590-
# _user_initials[var.id] == None)
1591-
if self._user_initials[var.id] is not None:
1592-
# reset to user-specified initial value from last solve
1593-
var.value = self._user_initials[var.id]
1594-
continue
1595-
else:
1596-
# reset to None from last solve
1597-
var.value = None
1598-
1599-
# set sample_bounds to variable bounds if sample_bounds is None
1600-
# and variable bounds (possibly infinite) are set
1601-
if var.sample_bounds is None and var.bounds is not None:
1602-
var.sample_bounds = var.bounds
1603-
1604-
# sample initial value if sample_bounds is set
1605-
if var.sample_bounds is not None:
1606-
low, high = var.sample_bounds
1607-
if not np.all(np.isfinite(low)) or not np.all(np.isfinite(high)):
1608-
raise ValueError(
1609-
"Variable %s has non-finite sample_bounds %s. Cannot generate"
1610-
" random initial point. Either add sample bounds or set the value. "
1611-
% (var.name(), var.sample_bounds)
1612-
)
1613-
1614-
initial_val = np.random.uniform(low=low, high=high, size=var.shape)
1615-
var.save_value(initial_val)
1616-
16171436
def __str__(self) -> str:
16181437
if len(self.constraints) == 0:
16191438
return str(self.objective)

cvxpy/reductions/solvers/defines.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
# NLP interfaces
5050
from cvxpy.reductions.solvers.nlp_solvers.copt_nlpif import COPT as COPT_nlp
5151
from cvxpy.reductions.solvers.nlp_solvers.ipopt_nlpif import IPOPT as IPOPT_nlp
52+
from cvxpy.reductions.solvers.nlp_solvers.knitro_nlpif import KNITRO as KNITRO_nlp
5253
from cvxpy.reductions.solvers.nlp_solvers.uno_nlpif import UNO as UNO_nlp
5354

5455
# QP interfaces
@@ -83,14 +84,23 @@
8384
]}
8485

8586
SOLVER_MAP_NLP = {inst.name(): inst for inst in [
86-
IPOPT_nlp(), UNO_nlp(), COPT_nlp(),
87+
IPOPT_nlp(), KNITRO_nlp(), UNO_nlp(), COPT_nlp(),
8788
]}
8889

8990
# Preference-ordered solver name lists, derived from the maps above.
9091
CONIC_SOLVERS = list(SOLVER_MAP_CONIC)
9192
QP_SOLVERS = list(SOLVER_MAP_QP)
9293
NLP_SOLVERS = list(SOLVER_MAP_NLP)
9394

95+
# Solver variants: maps variant name → (base solver name, extra kwargs).
96+
NLP_SOLVER_VARIANTS = {
97+
"knitro_ipm": ("KNITRO", {"algorithm": 1}),
98+
"knitro_sqp": ("KNITRO", {"algorithm": 4}),
99+
"knitro_alm": ("KNITRO", {"algorithm": 6}),
100+
"uno_ipm": ("UNO", {"preset": "ipopt", "linear_solver": "MUMPS"}),
101+
"uno_sqp": ("UNO", {"preset": "filtersqp"}),
102+
}
103+
94104
# Mixed-integer solver lists, derived from solver class attributes.
95105
MI_SOLVERS = [
96106
name for name, slv in SOLVER_MAP_CONIC.items() if slv.MIP_CAPABLE

cvxpy/reductions/solvers/nlp_solvers/knitro_nlpif.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ class KNITRO(NLPsolver):
2727
NLP interface for the KNITRO solver
2828
"""
2929

30-
BOUNDED_VARIABLES = True
3130
# Keys:
3231
CONTEXT_KEY = "context"
3332
X_INIT_KEY = "x_init"

cvxpy/reductions/solvers/nlp_solvers/nlp_solver.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class NLPsolver(Solver):
4343
"""
4444
REQUIRES_CONSTR = False
4545
MIP_CAPABLE = False
46+
BOUNDED_VARIABLES = True
4647

4748
def accepts(self, problem: Problem) -> bool:
4849
"""

0 commit comments

Comments
 (0)