Skip to content

Commit a87c4f1

Browse files
Transurgeonclaude
andauthored
PR review medium effort fixes: deduplicate canonicalizers and optimize bounds (#144)
- Add _common.py with canonicalize_unary_smooth() helper to reduce code duplication across hyperbolic, trig, exp, and prod canonicalizers - Refactor canonicalizer files to use the common helper - Preallocate numpy arrays in Bounds class instead of list extend + convert - Fix hessian() to return typed empty array (dtype=np.float64) - Cache empty hessian structure in hessianstructure() for quasi-Newton Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent eed52e2 commit a87c4f1

File tree

6 files changed

+130
-87
lines changed

6 files changed

+130
-87
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""
2+
Copyright 2025 CVXPY developers
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
"""
16+
17+
import numpy as np
18+
19+
from cvxpy.expressions.variable import Variable
20+
21+
22+
def canonicalize_unary_smooth(expr, args, bounds=None, default_value=None):
23+
"""
24+
Generic canonicalization for smooth unary functions.
25+
26+
Ensures the argument is a Variable. If already a Variable (with no bounds
27+
constraint), returns unchanged. Otherwise, creates a new Variable with
28+
an equality constraint to the original argument.
29+
30+
Parameters
31+
----------
32+
expr : Atom
33+
The expression being canonicalized.
34+
args : list
35+
The canonicalized arguments of expr (expects single argument).
36+
bounds : tuple or None
37+
Optional (lower, upper) bounds for the new Variable.
38+
default_value : array-like or None
39+
Default value to use if arg has no value. If None, uses arg.value.
40+
41+
Returns
42+
-------
43+
tuple
44+
(canonicalized_expr, list_of_constraints)
45+
"""
46+
arg = args[0]
47+
48+
# If already a Variable with no bounds requirement, return unchanged
49+
if isinstance(arg, Variable) and bounds is None:
50+
return expr.copy([arg]), []
51+
52+
# Create new Variable, optionally with bounds
53+
if bounds is not None:
54+
t = Variable(arg.shape, bounds=bounds)
55+
else:
56+
t = Variable(arg.shape)
57+
58+
# Set initial value
59+
if arg.value is not None:
60+
t.value = arg.value
61+
elif default_value is not None:
62+
if callable(default_value):
63+
t.value = default_value(arg.shape)
64+
else:
65+
t.value = np.broadcast_to(default_value, arg.shape)
66+
67+
return expr.copy([t]), [t == arg]

cvxpy/reductions/dnlp2smooth/canonicalizers/exp_canon.py

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,9 @@
1313
See the License for the specific language governing permissions and
1414
limitations under the License.
1515
"""
16-
from cvxpy.expressions.variable import Variable
16+
17+
from cvxpy.reductions.dnlp2smooth.canonicalizers._common import canonicalize_unary_smooth
1718

1819

1920
def exp_canon(expr, args):
20-
if isinstance(args[0], Variable):
21-
return expr.copy([args[0]]), []
22-
else:
23-
t = Variable(args[0].shape)
24-
if args[0].value is not None:
25-
t.value = args[0].value
26-
return expr.copy([t]), [t == args[0]]
21+
return canonicalize_unary_smooth(expr, args)

cvxpy/reductions/dnlp2smooth/canonicalizers/hyperbolic_canon.py

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,38 +13,21 @@
1313
See the License for the specific language governing permissions and
1414
limitations under the License.
1515
"""
16-
from cvxpy.expressions.variable import Variable
16+
17+
from cvxpy.reductions.dnlp2smooth.canonicalizers._common import canonicalize_unary_smooth
1718

1819

1920
def sinh_canon(expr, args):
20-
if isinstance(args[0], Variable):
21-
return expr, []
22-
else:
23-
t = Variable(args[0].shape)
24-
if args[0].value is not None:
25-
t.value = args[0].value
26-
return expr.copy([t]), [t == args[0]]
21+
return canonicalize_unary_smooth(expr, args)
22+
2723

2824
def tanh_canon(expr, args):
29-
if isinstance(args[0], Variable):
30-
return expr, []
31-
else:
32-
t = Variable(args[0].shape)
33-
if args[0].value is not None:
34-
t.value = args[0].value
35-
return expr.copy([t]), [t == args[0]]
36-
25+
return canonicalize_unary_smooth(expr, args)
26+
27+
3728
def asinh_canon(expr, args):
38-
if isinstance(args[0], Variable):
39-
return expr, []
40-
else:
41-
t = Variable(args[0].shape)
42-
if args[0].value is not None:
43-
t.value = args[0].value
44-
return expr.copy([t]), [t == args[0]]
29+
return canonicalize_unary_smooth(expr, args)
30+
4531

4632
def atanh_canon(expr, args):
47-
t = Variable(args[0].shape, bounds=[-1, 1])
48-
if args[0].value is not None:
49-
t.value = args[0].value
50-
return expr.copy([t]), [t == args[0]]
33+
return canonicalize_unary_smooth(expr, args, bounds=(-1, 1))

cvxpy/reductions/dnlp2smooth/canonicalizers/prod_canon.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import numpy as np
1818

19-
from cvxpy.expressions.variable import Variable
19+
from cvxpy.reductions.dnlp2smooth.canonicalizers._common import canonicalize_unary_smooth
2020

2121

2222
def prod_canon(expr, args):
@@ -26,14 +26,4 @@ def prod_canon(expr, args):
2626
Since prod is a smooth function with implemented gradients,
2727
we simply ensure the argument is a Variable.
2828
"""
29-
if isinstance(args[0], Variable):
30-
return expr.copy([args[0]]), []
31-
32-
t = Variable(args[0].shape)
33-
34-
if args[0].value is not None:
35-
t.value = args[0].value
36-
else:
37-
t.value = np.ones(args[0].shape)
38-
39-
return expr.copy([t]), [t == args[0]]
29+
return canonicalize_unary_smooth(expr, args, default_value=np.ones)

cvxpy/reductions/dnlp2smooth/canonicalizers/trig_canon.py

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,19 @@
1313
See the License for the specific language governing permissions and
1414
limitations under the License.
1515
"""
16-
from cvxpy.expressions.variable import Variable
16+
17+
import numpy as np
18+
19+
from cvxpy.reductions.dnlp2smooth.canonicalizers._common import canonicalize_unary_smooth
1720

1821

1922
def sin_canon(expr, args):
20-
if isinstance(args[0], Variable):
21-
return expr, []
22-
else:
23-
t = Variable(args[0].shape)
24-
if args[0].value is not None:
25-
t.value = args[0].value
26-
return expr.copy([t]), [t == args[0]]
23+
return canonicalize_unary_smooth(expr, args)
24+
2725

2826
def cos_canon(expr, args):
29-
if isinstance(args[0], Variable):
30-
return expr, []
31-
else:
32-
t = Variable(args[0].shape)
33-
if args[0].value is not None:
34-
t.value = args[0].value
35-
return expr.copy([t]), [t == args[0]]
27+
return canonicalize_unary_smooth(expr, args)
28+
3629

3730
def tan_canon(expr, args):
38-
t = Variable(args[0].shape, bounds=[-3.14159/2, 3.14159/2])
39-
if args[0].value is not None:
40-
t.value = args[0].value
41-
return expr.copy([t]), [t == args[0]]
31+
return canonicalize_unary_smooth(expr, args, bounds=(-np.pi / 2, np.pi / 2))

cvxpy/reductions/solvers/nlp_solvers/nlp_solver.py

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -84,45 +84,59 @@ def get_constraint_bounds(self):
8484
as well as equalities to zero constraints and forms
8585
a new problem from the canonicalized problem.
8686
"""
87-
lower, upper = [], []
87+
# Preallocate arrays for constraint bounds
88+
total_constr_size = sum(c.size for c in self.problem.constraints)
89+
lower = np.zeros(total_constr_size)
90+
upper = np.zeros(total_constr_size)
91+
8892
new_constr = []
93+
offset = 0
8994
for constraint in self.problem.constraints:
95+
size = constraint.size
9096
if isinstance(constraint, Equality):
91-
lower.extend([0.0] * constraint.size)
92-
upper.extend([0.0] * constraint.size)
97+
# lower[offset:offset+size] already 0.0
98+
# upper[offset:offset+size] already 0.0
9399
new_constr.append(lower_equality(constraint))
94100
elif isinstance(constraint, Inequality):
95-
lower.extend([0.0] * constraint.size)
96-
upper.extend([np.inf] * constraint.size)
101+
# lower[offset:offset+size] already 0.0
102+
upper[offset:offset + size] = np.inf
97103
new_constr.append(lower_ineq_to_nonneg(constraint))
98104
elif isinstance(constraint, NonPos):
99-
lower.extend([0.0] * constraint.size)
100-
upper.extend([np.inf] * constraint.size)
105+
# lower[offset:offset+size] already 0.0
106+
upper[offset:offset + size] = np.inf
101107
new_constr.append(nonpos2nonneg(constraint))
108+
offset += size
109+
102110
canonicalized_prob = self.problem.copy([self.problem.objective, new_constr])
103111
self.new_problem = canonicalized_prob
104-
self.cl = np.array(lower)
105-
self.cu = np.array(upper)
112+
self.cl = lower
113+
self.cu = upper
106114

107115
def get_variable_bounds(self):
108116
"""
109117
Get variable bounds for all variables.
110118
Uses the variable's get_bounds() method which handles bounds attributes,
111119
nonneg/nonpos attributes, and properly broadcasts scalar bounds.
112120
"""
113-
var_lower, var_upper = [], []
121+
# Preallocate arrays for variable bounds
122+
total_var_size = sum(v.size for v in self.main_var)
123+
var_lower = np.full(total_var_size, -np.inf)
124+
var_upper = np.full(total_var_size, np.inf)
125+
126+
offset = 0
114127
for var in self.main_var:
128+
size = var.size
115129
# get_bounds() returns arrays broadcastable to var.shape
116130
# and handles all edge cases (scalar bounds, sign attributes, etc.)
117131
lb, ub = var.get_bounds()
118132
# Flatten in column-major (Fortran) order and convert to contiguous array
119133
# (broadcast_to creates read-only views that need to be copied)
120-
lb_flat = np.asarray(lb).flatten(order='F')
121-
ub_flat = np.asarray(ub).flatten(order='F')
122-
var_lower.extend(lb_flat)
123-
var_upper.extend(ub_flat)
124-
self.lb = np.array(var_lower)
125-
self.ub = np.array(var_upper)
134+
var_lower[offset:offset + size] = np.asarray(lb).flatten(order='F')
135+
var_upper[offset:offset + size] = np.asarray(ub).flatten(order='F')
136+
offset += size
137+
138+
self.lb = var_lower
139+
self.ub = var_upper
126140

127141

128142
def construct_initial_point(self):
@@ -213,7 +227,7 @@ def hessian(self, x, duals, obj_factor):
213227
"""Returns the lower triangular Hessian values in COO format. """
214228
if not self.use_hessian:
215229
# Shouldn't be called when using quasi-Newton, but return empty array
216-
return np.array([])
230+
return np.array([], dtype=np.float64)
217231

218232
if not self.objective_forward_passed:
219233
self.objective(x)
@@ -230,13 +244,17 @@ def hessian(self, x, duals, obj_factor):
230244

231245
def hessianstructure(self):
232246
"""Returns the sparsity structure of the lower triangular Hessian."""
233-
if not self.use_hessian:
234-
# Return empty structure when using quasi-Newton approximation
235-
return (np.array([], dtype=np.int32), np.array([], dtype=np.int32))
236-
237247
if self._hess_structure is not None:
238248
return self._hess_structure
239249

250+
if not self.use_hessian:
251+
# Cache and return empty structure when using quasi-Newton approximation
252+
self._hess_structure = (
253+
np.array([], dtype=np.int32),
254+
np.array([], dtype=np.int32)
255+
)
256+
return self._hess_structure
257+
240258
hess_csr = self.c_problem.get_hessian()
241259
hess_coo = hess_csr.tocoo()
242260

0 commit comments

Comments
 (0)