Skip to content

Commit 0b8de73

Browse files
authored
Merge branch 'master' into normal_cdf
2 parents 12360c5 + e6df2dd commit 0b8de73

File tree

98 files changed

+2024
-480
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

98 files changed

+2024
-480
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ jobs:
159159
CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux*"
160160
CIBW_ARCHS_MACOS: x86_64 universal2
161161
CIBW_ARCHS_LINUX: auto aarch64
162-
uses: pypa/cibuildwheel@v3.3.0
162+
uses: pypa/cibuildwheel@v3.4.0
163163

164164
- name: Build source
165165
if: ${{github.event_name == 'push' && env.SINGLE_ACTION_CONFIG == 'True'}}

cvxpy/atoms/affine/cumsum.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
See the License for the specific language governing permissions and
1414
limitations under the License.
1515
"""
16-
from typing import Optional, Tuple
16+
from typing import Tuple
1717

1818
import numpy as np
1919
import scipy.sparse as sp
@@ -51,7 +51,10 @@ class cumsum(AffAtom, AxisAtom):
5151
The axis to sum across. If None, the array is flattened before cumsum.
5252
Note: NumPy's default is axis=None, while CVXPY defaults to axis=0.
5353
"""
54-
def __init__(self, expr: Expression, axis: Optional[int] = 0) -> None:
54+
55+
_reduce_all_axes_to_none = False
56+
57+
def __init__(self, expr: Expression, axis: None | int = 0) -> None:
5558
super(cumsum, self).__init__(expr, axis)
5659

5760
def validate_arguments(self) -> None:

cvxpy/atoms/affine/diag.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def is_symmetric(self) -> bool:
101101
def is_hermitian(self) -> bool:
102102
"""Is the expression hermitian?
103103
"""
104-
return self.k == 0
104+
return self.k == 0 and self.args[0].is_real()
105105

106106
def is_psd(self) -> bool:
107107
"""Is the expression a positive semidefinite matrix?

cvxpy/atoms/affine/imag.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,4 @@ def is_complex(self) -> bool:
4949
def is_symmetric(self) -> bool:
5050
"""Is the expression symmetric?
5151
"""
52-
return self.args[0].is_hermitian()
52+
return self.args[0].is_symmetric()

cvxpy/atoms/affine/real.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,4 @@ def is_complex(self) -> bool:
5050
def is_symmetric(self) -> bool:
5151
"""Is the expression symmetric?
5252
"""
53-
return self.args[0].is_hermitian()
53+
return self.args[0].is_symmetric() or self.args[0].is_hermitian()

cvxpy/atoms/affine/sum.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import builtins
1717
from functools import wraps
1818
from types import GeneratorType
19-
from typing import Optional, Tuple
19+
from typing import Tuple
2020

2121
import numpy as np
2222
from numpy.exceptions import AxisError
@@ -25,7 +25,7 @@
2525
import cvxpy.lin_ops.lin_op as lo
2626
import cvxpy.lin_ops.lin_utils as lu
2727
from cvxpy.atoms.affine.affine_atom import AffAtom
28-
from cvxpy.atoms.axis_atom import AxisAtom
28+
from cvxpy.atoms.axis_atom import AxisAtom, normalize_axis
2929
from cvxpy.constraints.constraint import Constraint
3030
from cvxpy.utilities import bounds as bounds_utils
3131

@@ -123,6 +123,10 @@ def graph_implementation(self,
123123
The axis and keepdims parameters of the sum expression.
124124
"""
125125
axis, keepdims = data
126+
# Normalize tuple axes so they use the fast path when possible.
127+
if isinstance(axis, tuple):
128+
ndim = len(arg_objs[0].shape)
129+
axis = normalize_axis(axis, ndim)
126130
# Note: added new case for summing with n-dimensional shapes and
127131
# multiple axes. Previous behavior is kept in the else statement.
128132
if len(arg_objs[0].shape) > 2 or axis not in {None, 0, 1}:
@@ -148,7 +152,7 @@ def graph_implementation(self,
148152

149153

150154
@wraps(Sum)
151-
def sum(expr, axis: Optional[int] = None, keepdims: bool = False):
155+
def sum(expr, axis: None | int | tuple[int, ...] = None, keepdims: bool = False):
152156
"""
153157
Wrapper for Sum class.
154158
"""

cvxpy/atoms/affine/trace.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ def trace(expr):
5757
class Trace(AffAtom):
5858
"""The sum of the diagonal entries of a matrix.
5959
60+
Follows ``np.linalg.trace`` conventions: for an input with shape
61+
``(*batch, n, n)``, returns an expression with shape ``(*batch,)``.
62+
6063
Parameters
6164
----------
6265
expr : Expression
@@ -79,19 +82,21 @@ def sign_from_args(self) -> Tuple[bool, bool]:
7982
def numeric(self, values):
8083
"""Sums the diagonal entries.
8184
"""
82-
return np.trace(values[0])
85+
return np.linalg.trace(values[0])
8386

8487
def validate_arguments(self) -> None:
85-
"""Checks that the argument is a square matrix.
88+
"""Checks that the argument is a square matrix (possibly batched).
8689
"""
8790
shape = self.args[0].shape
88-
if self.args[0].ndim != 2 or shape[0] != shape[1]:
89-
raise ValueError("Argument to trace must be a 2-d square array.")
91+
if self.args[0].ndim < 2 or shape[-2] != shape[-1]:
92+
raise ValueError(
93+
"Argument to trace must have ndim >= 2 with equal last two dimensions."
94+
)
9095

9196
def shape_from_args(self) -> Tuple[int, ...]:
92-
"""Always scalar.
97+
"""Scalar for 2D input, batch shape for ND input.
9398
"""
94-
return tuple()
99+
return self.args[0].shape[:-2]
95100

96101
def is_real(self) -> bool:
97102
return self.args[0].is_real() or self.args[0].is_hermitian()
@@ -128,4 +133,4 @@ def graph_implementation(
128133
tuple
129134
(LinOp for objective, list of constraints)
130135
"""
131-
return (lu.trace(arg_objs[0]), [])
136+
return (lu.trace(arg_objs[0], shape), [])

cvxpy/atoms/affine/upper_tri.py

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,22 +57,27 @@ def numeric(self, values):
5757
"""
5858
Vectorize the strictly upper triangular entries.
5959
"""
60-
upper_idx = np.triu_indices(n=values[0].shape[0], k=1, m=values[0].shape[1])
61-
return values[0][upper_idx]
60+
n = values[0].shape[-1]
61+
rows, cols = np.triu_indices(n, k=1)
62+
return values[0][..., rows, cols]
6263

6364
def validate_arguments(self) -> None:
64-
"""Checks that the argument is a square matrix.
65+
"""Checks that the argument is a square matrix with ndim >= 2.
6566
"""
66-
if not self.args[0].ndim == 2 or self.args[0].shape[0] != self.args[0].shape[1]:
67+
shape = self.args[0].shape
68+
if len(shape) < 2 or shape[-2] != shape[-1]:
6769
raise ValueError(
68-
"Argument to upper_tri must be a 2-d square array."
70+
"Argument to upper_tri must have ndim >= 2 with equal last two dimensions."
6971
)
7072

71-
def shape_from_args(self) -> Tuple[int, int]:
72-
"""A vector.
73+
def shape_from_args(self) -> Tuple[int, ...]:
74+
"""Batch shape + vector of upper triangular entries.
7375
"""
74-
rows, cols = self.args[0].shape
75-
return (rows*(cols-1)//2, 1)
76+
shape = self.args[0].shape
77+
n = shape[-1]
78+
batch_shape = shape[:-2]
79+
tri = n * (n - 1) // 2
80+
return batch_shape + (tri, 1)
7681

7782
def is_atom_log_log_convex(self) -> bool:
7883
"""Is the atom log-log convex?
@@ -174,3 +179,30 @@ def upper_tri_to_full(n: int) -> sp.csc_array:
174179

175180
# Construct and return the sparse matrix
176181
return sp.csc_array((values, (row_idx, col_idx)), shape=(n * n, entries))
182+
183+
184+
def batched_upper_tri_to_full(batch_size: int, n: int) -> sp.csc_array:
185+
"""
186+
Returns a coefficient matrix that maps a vector of batch_size * tri entries
187+
(F-order layout of (batch_size, tri)) to batch_size * n*n entries
188+
(F-order layout of (batch_size, n, n)).
189+
190+
Uses Kronecker product kron(upper_tri_to_full(n), eye(batch_size)) because
191+
F-order reshape interleaves batch elements.
192+
193+
Parameters
194+
----------
195+
batch_size : int
196+
The number of batch elements.
197+
n : int
198+
The dimension of the square matrix.
199+
200+
Returns
201+
-------
202+
sp.csc_array
203+
The coefficient matrix.
204+
"""
205+
single = upper_tri_to_full(n)
206+
if batch_size == 1:
207+
return single
208+
return sp.csc_array(sp.kron(single, sp.eye(batch_size), format='csc'))

cvxpy/atoms/axis_atom.py

Lines changed: 105 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
limitations under the License.
1515
"""
1616

17-
from typing import List, Optional, Tuple
17+
from typing import Tuple
1818

1919
import numpy as np
2020
import scipy.sparse as sp
@@ -23,15 +23,47 @@
2323
from cvxpy.atoms.atom import Atom
2424

2525

26+
def normalize_axis(
27+
axis: int | tuple[int, ...], ndim: int, reduce_all_to_none: bool = True
28+
) -> None | int | tuple[int, ...]:
29+
"""Normalize an axis argument to a canonical form.
30+
31+
- Negative indices become positive.
32+
- Single-element tuples become an int.
33+
- If all axes are listed and *reduce_all_to_none* is True, returns None.
34+
"""
35+
axes = normalize_axis_tuple(axis, ndim)
36+
if reduce_all_to_none and len(axes) == ndim:
37+
return None
38+
elif len(axes) == 1:
39+
return axes[0]
40+
else:
41+
return axes
42+
43+
2644
class AxisAtom(Atom):
2745
"""
2846
An abstract base class for atoms that can be applied along an axis.
2947
"""
3048

31-
def __init__(self, expr, axis: Optional[int] = None, keepdims: bool = False) -> None:
49+
# Whether reducing over all axes is equivalent to axis=None.
50+
# True for reduction atoms (sum, max, min, etc.).
51+
# False for cumulative atoms (cumsum, cummax, cumprod) that preserve shape.
52+
_reduce_all_axes_to_none = True
53+
54+
def __init__(
55+
self, expr, axis: None | int | tuple[int, ...] = None, keepdims: bool = False
56+
) -> None:
3257
self.axis = axis
3358
self.keepdims = keepdims
3459
super(AxisAtom, self).__init__(expr)
60+
# Normalize axis after init so self.args is available.
61+
if self.axis is not None:
62+
ndim = len(self.args[0].shape)
63+
if ndim > 0:
64+
self.axis = normalize_axis(
65+
self.axis, ndim, self._reduce_all_axes_to_none
66+
)
3567

3668
def shape_from_args(self) -> Tuple[int, ...]:
3769
"""
@@ -75,12 +107,14 @@ def validate_arguments(self) -> None:
75107
_ = normalize_axis_tuple(axes, dim)
76108
super(AxisAtom, self).validate_arguments()
77109

78-
def _axis_grad(self, values) -> Optional[List[sp.csc_array]]:
110+
def _axis_grad(self, values) -> list[sp.csc_array] | None:
79111
"""
80112
Gives the (sub/super)gradient of the atom w.r.t. each argument.
81113
82114
Matrix expressions are vectorized, so the gradient is a matrix.
83-
Takes axis into account.
115+
Takes axis into account. Works for any number of dimensions.
116+
117+
CVXPY convention: grad[i, j] = d(output_flat_F[j]) / d(input_flat_F[i])
84118
85119
Args:
86120
values: A list of numeric values for the arguments.
@@ -93,33 +127,74 @@ def _axis_grad(self, values) -> Optional[List[sp.csc_array]]:
93127
D = self._column_grad(value)
94128
if D is not None:
95129
D = sp.csc_array(D)
130+
return [D]
131+
132+
input_shape = self.args[0].shape
133+
ndim = len(input_shape)
134+
135+
# Normalize axis to tuple
136+
axis = self.axis
137+
axes = (axis,) if isinstance(axis, int) else tuple(axis)
138+
keep = [i for i in range(ndim) if i not in axes]
139+
140+
reduce_dims = [input_shape[a] for a in axes]
141+
reduce_size = int(np.prod(reduce_dims))
142+
output_shape = tuple(input_shape[i] for i in keep)
143+
input_size = int(np.prod(input_shape))
144+
output_size = max(1, int(np.prod(output_shape)))
145+
146+
# F-order strides: stride[k] = prod(input_shape[:k])
147+
f_strides = np.ones(ndim, dtype=int)
148+
for k in range(1, ndim):
149+
f_strides[k] = f_strides[k-1] * input_shape[k-1]
150+
151+
# Flat input in F-order
152+
flat_input = values[0].ravel(order='F')
153+
154+
# All output multi-indices in F-order
155+
if len(output_shape) == 0:
156+
out_multis = np.zeros((0, 1), dtype=int)
96157
else:
97-
m, n = self.args[0].shape
98-
if self.axis == 0: # function apply to each column
99-
D = sp.csc_array((m*n, n), dtype=float)
100-
for i in range(n):
101-
value = values[0][:, i]
102-
d = self._column_grad(value).T
103-
if d is None:
104-
return [None]
105-
else:
106-
d = np.array(d).flatten()
107-
row = np.linspace(i*m, i*m+m-1, m) # [i*m, i*m+1, ..., i*m+m-1]
108-
col = np.ones((m))*i
109-
D = D + sp.csc_array((d, (row, col)),
110-
shape=(m*n, n)) # d must be 1-D
111-
else: # function apply to each row
112-
values = np.transpose(values[0])
113-
D = sp.csc_array((m*n, m), dtype=float)
114-
for i in range(m):
115-
value = values[:, i]
116-
d = self._column_grad(value).T
117-
if d is None:
118-
return [None]
119-
row = np.linspace(i, i+(n-1)*m, n) # [0+i, m+i, ..., m(n-1)+i]
120-
col = np.ones((n))*i
121-
D = D + sp.csc_array((np.array(d)[0], (row, col)),
122-
shape=(m*n, m)) # d must be 1-D
158+
out_multis = np.array(
159+
np.unravel_index(np.arange(output_size), output_shape, order='F')
160+
) # shape: (len(keep), output_size)
161+
162+
# All reduce-axis multi-indices
163+
reduce_multis = np.array(
164+
np.unravel_index(np.arange(reduce_size), reduce_dims)
165+
).T # shape: (reduce_size, len(axes))
166+
167+
all_rows = []
168+
all_cols = []
169+
all_data = []
170+
171+
for j in range(output_size):
172+
om = out_multis[:, j]
173+
174+
# Build input multi-indices: fix keep axes, vary reduce axes
175+
in_multis = np.zeros((reduce_size, ndim), dtype=int)
176+
for idx, k in enumerate(keep):
177+
in_multis[:, k] = om[idx]
178+
for idx, a in enumerate(axes):
179+
in_multis[:, a] = reduce_multis[:, idx]
180+
181+
# Compute flat F-order indices for this fiber
182+
fiber_indices = in_multis @ f_strides
183+
fiber_values = flat_input[fiber_indices]
184+
185+
d = self._column_grad(fiber_values.reshape(-1, 1))
186+
if d is None:
187+
return [None]
188+
d = np.asarray(d).flatten()
189+
190+
all_rows.append(fiber_indices)
191+
all_cols.append(np.full(reduce_size, j, dtype=int))
192+
all_data.append(d)
193+
194+
rows = np.concatenate(all_rows)
195+
cols = np.concatenate(all_cols)
196+
data = np.concatenate(all_data)
197+
D = sp.csc_array((data, (rows, cols)), shape=(input_size, output_size))
123198
return [D]
124199

125200
def _column_grad(self, value):

cvxpy/atoms/cummax.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ class cummax(AxisAtom):
2525
"""Cumulative maximum.
2626
"""
2727

28+
_reduce_all_axes_to_none = False
29+
2830
def __init__(self, x, axis: int = 0) -> None:
2931
super(cummax, self).__init__(x, axis=axis)
3032

0 commit comments

Comments
 (0)