Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
### Fixed
- Fixed the type of @ matrix operation result from MatrixVariable to MatrixExpr.
### Changed
- Speed up MatrixVariable.sum(axis=None) via quicksum
### Removed

## v5.6.0 - 2025.08.26
Expand Down
11 changes: 7 additions & 4 deletions src/pyscipopt/matrix.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,14 @@ def _is_number(e):
class MatrixExpr(np.ndarray):
def sum(self, **kwargs):
"""
Based on `numpy.ndarray.sum`, but returns a scalar if the result is a single value.
This is useful for matrix expressions where the sum might reduce to a single value.
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.
Comment on lines +21 to +22
Copy link
Contributor Author

@Zeroto521 Zeroto521 Oct 9, 2025

Choose a reason for hiding this comment

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

An example to show this change. This behavior is similar to numpy.ndarray.sum.

model = Model()
x = model.addMatrixVar((3, 1))
y = model.addMatrixVar(1)

# Now it can directly compare.
# Before, it would raise an error because of different shapes.
x.sum(axis=0) == y

Copy link
Member

Choose a reason for hiding this comment

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

Sorry, can you please explain what axis=None means? I don't understand how either the LHS or the RHS of the expression are a scalar

Copy link
Contributor Author

@Zeroto521 Zeroto521 Oct 18, 2025

Choose a reason for hiding this comment

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

y isn't a scaler, it is a matrix.
matrix.sum(axis=None)(= matrix.sum()) follows the pyscipopt style to get a scalar. So there won't be any damage to the user.
The rest follows np.ndarray.sum style. np.ndarray.sum(axis=1) will return a new np.ndarray, not a scalar. To pyscipopt, the return of MatrixVariable.sum(axis=1) is always a matrix. We don't need to check that the return is a matrix or a scalar. And the result could continuously compare with or calculate against other matrices.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  • before: model.addMatrixVar((3, 1)).sum(axis=0) return a scalar. Because its size is 1.
  • now: model.addMatrixVar((3, 1)).sum(axis=0) return a matrix.

"""
res = super().sum(**kwargs)
return res if res.size > 1 else res.item()

if kwargs.get("axis") is None:
# Speed up `.sum()` #1070
return quicksum(self.flat)
return super().sum(**kwargs)

def __le__(self, other: Union[float, int, Variable, np.ndarray, 'MatrixExpr']) -> np.ndarray:

Expand Down
48 changes: 42 additions & 6 deletions tests/test_matrix_variable.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import pdb
import pprint
Comment on lines -1 to -2
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Remove unused importing

import pytest
from pyscipopt import Model, Variable, log, exp, cos, sin, sqrt
from pyscipopt import Expr, MatrixExpr, MatrixVariable, MatrixExprCons, MatrixConstraint, ExprCons
from time import time

import numpy as np
import pytest

from pyscipopt import (
Expr,
ExprCons,
MatrixConstraint,
MatrixExpr,
MatrixExprCons,
MatrixVariable,
Model,
Variable,
cos,
exp,
log,
quicksum,
sin,
sqrt,
)
Comment on lines +6 to +21
Copy link
Contributor Author

Choose a reason for hiding this comment

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

lint via ruff



def test_catching_errors():
Expand Down Expand Up @@ -170,6 +183,10 @@ def test_expr_from_matrix_vars():
def test_matrix_sum_argument():
m = Model()

# Return a array when axis isn't None
res = m.addMatrixVar((3, 1)).sum(axis=0)
assert isinstance(res, MatrixExpr) and res.shape == (1,)

# 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)
Expand All @@ -192,6 +209,25 @@ def test_matrix_sum_argument():
assert (m.getVal(x) == np.full((2, 3), 4)).all().all()
assert (m.getVal(y) == np.full((2, 4), 3)).all().all()


def test_sum_performance():
n = 1000
model = Model()
x = model.addMatrixVar((n, n))

# Original sum via `np.sum`
start_orig = time()
np.sum(x)
Copy link
Member

Choose a reason for hiding this comment

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

Did you want np.sum, or the previous behavior of x.sum?

Copy link
Contributor Author

@Zeroto521 Zeroto521 Oct 18, 2025

Choose a reason for hiding this comment

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

np.sum is equal to x.sum before this pr.
MatrixVariable.sum calls np.sum method in inner before.
But it now only calls np.sum when axis isn't None. To get the speed up time. It needs to use np.sum to get the original time costing.

end_orig = time()

# Optimized sum via `quicksum`
start_matrix = time()
x.sum()
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 @@ -339,7 +375,7 @@ def test_MatrixVariable_attributes():
assert x.varMayRound().tolist() == [[True, True], [True, True]]

@pytest.mark.skip(reason="Performance test")
def test_performance():
def test_add_cons_performance():
start_orig = time()
m = Model()
x = {}
Expand Down
Loading