Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
08b95e5
Add test for matrix sum return type
Zeroto521 Nov 24, 2025
0d26107
return `MatrixExpr` type
Zeroto521 Nov 24, 2025
160435a
Update CHANGELOG.md
Zeroto521 Nov 24, 2025
ede6a42
Speed up `MatrixExpr.sum(axis=...)`
Zeroto521 Dec 17, 2025
b8d50c6
Merge branch 'issue/1117' into issue/1133
Zeroto521 Dec 18, 2025
3a757bb
Test `MatrixExpr.sum(axis=...)`
Zeroto521 Dec 18, 2025
a13b7ec
Test `MatrixExpr.sum(axis=tuple(range(ndim))`
Zeroto521 Dec 18, 2025
f9b4a16
Refactor MatrixExpr.sum axis handling with numpy utility
Zeroto521 Dec 18, 2025
fe874d9
Add tests for matrix sum error
Zeroto521 Dec 18, 2025
fa7c88f
Add tests for matrix sum with keepdims parameter
Zeroto521 Dec 18, 2025
a58feae
call `.sum` via positional argument
Zeroto521 Dec 18, 2025
fad458c
Refactor sum method to use np.apply_along_axis
Zeroto521 Dec 18, 2025
363c8b1
Directly test the `.sum` result
Zeroto521 Dec 18, 2025
0e03a3b
Update CHANGELOG.md
Zeroto521 Dec 18, 2025
4c0bb70
Expand docstring for MatrixExpr.sum method
Zeroto521 Dec 18, 2025
8e0e372
Clarify MatrixExpr.sum docstring and note quicksum usage
Zeroto521 Dec 18, 2025
4931f06
Split up two costing time test cases
Zeroto521 Dec 18, 2025
b89a111
Supports numpy 1.x
Zeroto521 Dec 18, 2025
5a4c01f
Supports Python 3.8
Zeroto521 Dec 18, 2025
6300157
Merge branch 'issue/1133' of https://github.com/Zeroto521/PySCIPOpt i…
Zeroto521 Dec 18, 2025
8ac4bf0
Simplify a bit
Zeroto521 Dec 18, 2025
c11743d
suggestion
Joao-Dionisio Dec 25, 2025
464a8ec
Merge branch 'master' into issue/1133
Joao-Dionisio Dec 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

## Unreleased
### Added
- Speed up MatrixExpr.sum(axis=...) via quicksum
### Fixed
- all fundamental callbacks now raise an error if not implemented
- Fixed the type of MatrixExpr.sum(axis=...) result from MatrixVariable to MatrixExpr.
### Changed
- changed default value of enablepricing flag to True
### Removed
Expand Down
69 changes: 60 additions & 9 deletions src/pyscipopt/matrix.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@
# TODO Add tests
"""

from typing import Optional, Tuple, Union
import numpy as np
from typing import Union
try:
# NumPy 2.x location
from numpy.lib.array_utils import normalize_axis_tuple
except ImportError:
# Fallback for NumPy 1.x
from numpy.core.numeric import normalize_axis_tuple


def _is_number(e):
Expand Down Expand Up @@ -44,16 +50,61 @@ def _matrixexpr_richcmp(self, other, op):


class MatrixExpr(np.ndarray):
def sum(self, **kwargs):
"""
Based on `numpy.ndarray.sum`, but returns a scalar if `axis=None`.
This is useful for matrix expressions to compare with a matrix or a scalar.

def sum(
self,
axis: Optional[Union[int, Tuple[int, ...]]] = None,
keepdims: bool = False,
**kwargs,
) -> Union[Expr, MatrixExpr]:
"""
Return the sum of the array elements over the given axis.

Parameters
----------
axis : None or int or tuple of ints, optional
Axis or axes along which a sum is performed. The default, axis=None, will
sum all of the elements of the input array. If axis is negative it counts
from the last to the first axis. If axis is a tuple of ints, a sum is
performed on all of the axes specified in the tuple instead of a single axis
or all the axes as before.

keepdims : bool, optional
If this is set to True, the axes which are reduced are left in the result as
dimensions with size one. With this option, the result will broadcast
correctly against the input array.

**kwargs : ignored
Additional keyword arguments are ignored. They exist for compatibility
with `numpy.ndarray.sum`.

Returns
-------
Expr or MatrixExpr
If the sum is performed over all axes, return an Expr, otherwise return
a MatrixExpr.

if kwargs.get("axis") is None:
# Speed up `.sum()` #1070
return quicksum(self.flat)
return super().sum(**kwargs)
"""
axis: Tuple[int, ...] = normalize_axis_tuple(
range(self.ndim) if axis is None else axis, self.ndim
)
if len(axis) == self.ndim:
res = quicksum(self.flat)
return (
np.array([res], dtype=object).reshape([1] * self.ndim).view(MatrixExpr)
if keepdims
else res
)

keep_axes = tuple(i for i in range(self.ndim) if i not in axis)
shape = (
tuple(1 if i in axis else self.shape[i] for i in range(self.ndim))
if keepdims
else tuple(self.shape[i] for i in keep_axes)
)
return np.apply_along_axis(
quicksum, -1, self.transpose(keep_axes + axis).reshape(shape + (-1,))
).view(MatrixExpr)

def __le__(self, other: Union[float, int, "Expr", np.ndarray, "MatrixExpr"]) -> MatrixExprCons:
return _matrixexpr_richcmp(self, other, 1)
Expand Down
86 changes: 79 additions & 7 deletions tests/test_matrix_variable.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
sin,
sqrt,
)
from pyscipopt.scip import GenExpr
from pyscipopt.scip import CONST, GenExpr


def test_catching_errors():
Expand Down Expand Up @@ -181,7 +181,30 @@ def test_expr_from_matrix_vars():
for term, coeff in expr_list:
assert len(term) == 3

def test_matrix_sum_argument():

def test_matrix_sum_error():
m = Model()
x = m.addMatrixVar((2, 3), "x", "I", ub=4)

# test axis type
with pytest.raises(TypeError):
x.sum("0")

# test axis value (out of range)
with pytest.raises(ValueError):
x.sum(2)

# test axis value (out of range)
with pytest.raises(ValueError):
x.sum((-3,))

# test axis value (duplicate)
with pytest.raises(ValueError):
x.sum((0, 0))


def test_matrix_sum_axis():
# compare the result of summing matrix variable after optimization
m = Model()

# Return a array when axis isn't None
Expand All @@ -190,29 +213,52 @@ def test_matrix_sum_argument():

# compare the result of summing 2d array to a scalar with a scalar
x = m.addMatrixVar((2, 3), "x", "I", ub=4)
m.addMatrixCons(x.sum() == 24)
# `axis=tuple(range(x.ndim))` is `axis=None`
m.addMatrixCons(x.sum(axis=tuple(range(x.ndim))) == 24)

# compare the result of summing 2d array to 1d array
y = m.addMatrixVar((2, 4), "y", "I", ub=4)
m.addMatrixCons(x.sum(axis=1) == y.sum(axis=1))

# compare the result of summing 3d array to a 2d array with a 2d array
z = m.addMatrixVar((2, 3, 4), "z", "I", ub=4)
m.addMatrixCons(z.sum(axis=2) == x)
m.addMatrixCons(z.sum(2) == x)
m.addMatrixCons(z.sum(axis=1) == y)

# to fix the element values
m.addMatrixCons(z == np.ones((2, 3, 4)))

m.setObjective(x.sum() + y.sum() + z.sum(), "maximize")
m.setObjective(x.sum() + y.sum() + z.sum(tuple(range(z.ndim))), "maximize")
m.optimize()

assert (m.getVal(x) == np.full((2, 3), 4)).all().all()
assert (m.getVal(y) == np.full((2, 4), 3)).all().all()


@pytest.mark.parametrize("n", [50, 100, 200])
def test_sum_performance(n):
@pytest.mark.parametrize(
"axis, keepdims",
[
(0, False),
(0, True),
(1, False),
(1, True),
((0, 2), False),
((0, 2), True),
],
)
def test_matrix_sum_result(axis, keepdims):
# directly compare the result of np.sum and MatrixExpr.sum
_getVal = np.vectorize(lambda e: e.terms[CONST])
a = np.arange(6).reshape((1, 2, 3))

np_res = a.sum(axis, keepdims=keepdims)
scip_res = MatrixExpr.sum(a, axis, keepdims=keepdims)
Comment on lines +253 to +255
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test attempts to call MatrixExpr.sum(a, axis, keepdims=keepdims) where a is a plain numpy array of integers (np.arange(6).reshape((1, 2, 3))), not a MatrixExpr containing Expr objects. The test then tries to access e.terms[CONST] on each element, but integers don't have a terms attribute. This test will fail with an AttributeError. Consider creating a proper MatrixExpr with Expr objects or using a MatrixVariable instead.

Suggested change
np_res = a.sum(axis, keepdims=keepdims)
scip_res = MatrixExpr.sum(a, axis, keepdims=keepdims)
# build an array of Expr objects matching the numeric values in `a`
a_expr = np.vectorize(Expr)(a)
np_res = a.sum(axis, keepdims=keepdims)
scip_res = MatrixExpr.sum(a_expr, axis, keepdims=keepdims)

Copilot uses AI. Check for mistakes.
assert (np_res == _getVal(scip_res)).all()
assert np_res.shape == _getVal(scip_res).shape


@pytest.mark.parametrize("n", [50, 100])
def test_matrix_sum_axis_is_none_performance(n):
model = Model()
x = model.addMatrixVar((n, n))

Expand All @@ -229,6 +275,24 @@ def test_sum_performance(n):
assert model.isGT(end_orig - start_orig, end_matrix - start_matrix)


@pytest.mark.parametrize("n", [50, 100])
def test_matrix_sum_axis_not_none_performance(n):
model = Model()
x = model.addMatrixVar((n, n))

# Original sum via `np.ndarray.sum`, `np.sum` will call subclass method
start_orig = time()
np.ndarray.sum(x, axis=0)
end_orig = time()

# Optimized sum via `quicksum`
start_matrix = time()
x.sum(axis=0)
end_matrix = time()

assert model.isGT(end_orig - start_orig, end_matrix - start_matrix)


def test_add_cons_matrixVar():
m = Model()
matrix_variable = m.addMatrixVar(shape=(3, 3), vtype="B", name="A", obj=1)
Expand Down Expand Up @@ -521,6 +585,14 @@ def test_matrix_matmul_return_type():
assert type(y @ z) is MatrixExpr


def test_matrix_sum_return_type():
# test #1117, require returning type is MatrixExpr not MatrixVariable
m = Model()

x = m.addMatrixVar((3, 2))
assert type(x.sum(axis=1)) is MatrixExpr


def test_broadcast():
# test #1065
m = Model()
Expand Down
Loading