diff --git a/corneto/backend/_base.py b/corneto/backend/_base.py index 0452a69f..736a68e8 100644 --- a/corneto/backend/_base.py +++ b/corneto/backend/_base.py @@ -22,11 +22,11 @@ def _eq_shape(a: np.ndarray, b: np.ndarray) -> bool: def _identical_columns(array): # Get the first column as a reference column ref_column = array[:, 0] - + # Compare all columns to the reference column # np.all will check if all elements in the result are True along axis 0 (down the rows) are_columns_identical = np.all(array == ref_column[:, np.newaxis], axis=0) - + # np.all on the result checks if all columns are identical to the reference column return np.all(are_columns_identical) @@ -108,7 +108,7 @@ def _norm(self, p: int = 2) -> Any: @_delegate def norm(self, p: int = 2) -> "CExpression": return self._norm(p=p) - + @abc.abstractmethod def _sum(self, axis: Optional[int] = None) -> Any: pass @@ -117,6 +117,14 @@ def _sum(self, axis: Optional[int] = None) -> Any: def sum(self, axis: Optional[int] = None) -> "CExpression": return self._sum(axis=axis) + @abc.abstractmethod + def _max(self, axis: Optional[int] = None) -> Any: + pass + + @_delegate + def max(self, axis: Optional[int] = None) -> "CExpression": + return self._max(axis=axis) + @_delegate def __getitem__(self, item) -> "CExpression": # type: ignore pass @@ -722,9 +730,13 @@ def Flow( if shared_bounds and n_flows > 1: # check num dims of lb if len(shape) > 1 and shape[1] > 1 and not _identical_columns(lb): - raise ValueError("shared_bounds=True cannot be used when lower bounds are not identical across flows") + raise ValueError( + "shared_bounds=True cannot be used when lower bounds are not identical across flows" + ) if len(shape) > 1 and shape[1] > 1 and not _identical_columns(ub): - raise ValueError("shared_bounds=True cannot be used when upper bounds are not identical across flows") + raise ValueError( + "shared_bounds=True cannot be used when upper bounds are not identical across flows" + ) S = F.sum(axis=1) P += S <= ub[:, 0] P += S >= lb[:, 0] @@ -970,6 +982,33 @@ def Xor(self, x: CExpression, y: CExpression, varname="_xor"): [xor >= x - y, xor >= y - x, xor <= x + y, xor <= 2 - x - y] ) + def linear_or( + self, x: CSymbol, axis: Optional[int] = None, varname="_linear_or" + ): + # Check if the variable is binary, otherwise throw an error + if x._vartype != VarType.BINARY: + raise ValueError(f"Variable x has type {x._vartype} instead of BINARY") + Z = x.sum(axis=axis) + Z_norm = Z / x.shape[axis] # between 0-1 + # Create a new binary variable to compute linearized or + Or = self.Variable(varname, Z.shape, 0, 1, vartype=VarType.BINARY) + return self.Problem([Or >= Z_norm, Or <= Z]) + + + def linear_and( + self, x: CSymbol, axis: Optional[int] = None, varname="_linear_and" + ): + # Check if the variable is binary, otherwise throw an error + if x._vartype != VarType.BINARY: + raise ValueError(f"Variable x has type {x._vartype} instead of BINARY") + Z = x.sum(axis=axis) + N = x.shape[axis] + Z_norm = Z / N + And = self.Variable(varname, Z.shape, 0, 1, vartype=VarType.BINARY) + return self.Problem([And <= Z_norm, And >= Z - N + 1]) + + + class NoBackend(Backend): def __init__(self) -> None: diff --git a/corneto/backend/_cvxpy_backend.py b/corneto/backend/_cvxpy_backend.py index 6c17eb71..841b0cf8 100644 --- a/corneto/backend/_cvxpy_backend.py +++ b/corneto/backend/_cvxpy_backend.py @@ -24,20 +24,19 @@ def __init__(self, expr: Any, symbols: Optional[Set["CSymbol"]] = None) -> None: def _create_proxy_expr( self, expr: Any, symbols: Optional[Set["CSymbol"]] = None ) -> "CvxpyExpression": - # TODO: Move to upper class - # if symbols is not None: - # return CvxpyExpression(expr, self._proxy_symbols | symbols) - # return CvxpyExpression(expr, self._proxy_symbols) return CvxpyExpression(expr, symbols) def _elementwise_mul(self, other: Any) -> Any: return cp.multiply(self._expr, other) - def _norm(self, p: int = 2) -> CExpression: + def _norm(self, p: int = 2) -> Any: return cp.norm(self._expr, p=p) def _sum(self, axis: Optional[int] = None) -> Any: return cp.sum(self._expr, axis=axis) + + def _max(self, axis: Optional[int] = None) -> Any: + return cp.max(self._expr, axis=axis) @property def value(self) -> np.ndarray: diff --git a/corneto/backend/_picos_backend.py b/corneto/backend/_picos_backend.py index 7a7410b8..2547fdb3 100644 --- a/corneto/backend/_picos_backend.py +++ b/corneto/backend/_picos_backend.py @@ -17,19 +17,19 @@ def __init__(self, expr: Any, symbols: Optional[Set["CSymbol"]] = None) -> None: def _create_proxy_expr( self, expr: Any, symbols: Optional[Set["CSymbol"]] = None ) -> "PicosExpression": - # if symbols is not None: - # return PicosExpression(expr, self._proxy_symbols | symbols) - # return PicosExpression(expr, self._proxy_symbols) return PicosExpression(expr, symbols) def _elementwise_mul(self, other: Any) -> Any: return self._expr ^ other def _norm(self, p: int = 2) -> CExpression: - return pc.expressions.exp_norm.Norm(self._expr, p=p) + return pc.Norm(self._expr, p=p) def _sum(self, axis: Optional[int] = None) -> Any: - return pc.expressions.sum(self._expr, axis=axis) + return pc.sum(self._expr, axis=axis) + + def _max(self, axis: Optional[int] = None) -> Any: + raise NotImplementedError() @property def value(self) -> np.ndarray: @@ -97,7 +97,7 @@ def Variable( v = pc.BinaryVariable(name, shape) else: v = pc.RealVariable(name, shape, lower=lb, upper=ub) - return PicosSymbol(v, name, lb, ub) + return PicosSymbol(v, name, lb, ub, vartype=vartype) def _solve( self, diff --git a/tests/test_backend.py b/tests/test_backend.py index 7efc997c..9c1efcbd 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,7 +1,7 @@ import pytest import pathlib import numpy as np -from corneto.backend import PicosBackend, CvxpyBackend, Backend +from corneto.backend import PicosBackend, CvxpyBackend, Backend, VarType from corneto._graph import Graph import cvxpy as cp @@ -69,6 +69,56 @@ def test_cvxpy_convex_apply(): assert np.all(np.array(x.value) > np.array([-1e-6, 0.62, 0.36, -1e-6, -1e-6])) +def test_delegate_multiply_shape(backend): + V = backend.Variable(shape=(2, 3)) + V = V.multiply(np.ones((2, 3))) + assert V.shape == (2, 3) + + +def test_delegate_sum_axis0_shape(backend): + V = backend.Variable(shape=(2, 3)) + V = V.sum(axis=0) + if len(V.shape) == 1: + # Cvxpy assumes keepdims=False + assert V.shape == (3,) + else: + # Picos assumes keepdims=True + assert V.shape == (1, 3) + + +def test_delegate_sum_axis1_shape(backend): + V = backend.Variable(shape=(2, 3)) + V = V.sum(axis=1) + if len(V.shape) == 1: + # Cvxpy assumes keepdims=False + assert V.shape == (2,) + else: + # Picos assumes keepdims=True + assert V.shape == (2, 1) + + +def test_opt_delegate_sum_axis0(backend): + x = backend.Variable("x", (2, 3)) + e = x.sum(axis=0) + P = backend.Problem() + P += x <= 10 + esum = e[0] + e[1] + e[2] + P.add_objectives(esum, weights=-1) + P.solve() + assert np.isclose(esum.value, 60) + + +def test_opt_delegate_sum_axis1(backend): + x = backend.Variable("x", (2, 3)) + e = x.sum(axis=1) + P = backend.Problem() + P += x <= 10 + esum = e[0] + e[1] + P.add_objectives(esum, weights=-1) + P.solve() + assert np.isclose(esum.value, 60) + + def test_cexpression_name(backend): x = backend.Variable("x") e = x <= 10 @@ -116,7 +166,7 @@ def test_register(backend): P = backend.Problem() x = backend.Variable("x", lb=-10, ub=10) P += x >= 0 - P.register("1-x", 1-x) + P.register("1-x", 1 - x) assert "1-x" in P.expressions @@ -124,17 +174,16 @@ def test_register_merge(backend): P1 = backend.Problem() x = backend.Variable("x", lb=-10, ub=10) P1 += x >= 0 - P1.register("1-x", 1-x) + P1.register("1-x", 1 - x) P2 = backend.Problem() y = backend.Variable("y", lb=-10, ub=10) P2 += y >= 0 - P2.register("1-y", 1-y) + P2.register("1-y", 1 - y) P = P1.merge(P2) assert "1-x" in P.expressions assert "1-y" in P.expressions - def test_symbol_only_in_objective(backend): x = backend.Variable("x", lb=-10, ub=10) P = backend.Problem() @@ -178,6 +227,48 @@ def test_rmatmul_symbols(backend): assert "x" in P.symbols +def test_linearized_or_axis0(backend): + P = backend.Problem() + X = backend.Variable("X", (2, 3), vartype=VarType.BINARY) + P += backend.linear_or(X, axis=0, varname="v_or") + # Force X to have at least a 1 in the first column + P += P.expr.v_or[0] == 1 + P.add_objectives(sum(X[:, 0])) + P.solve() + assert np.isclose(np.sum(X[:, 0].value), 1.0) + + +def test_linearized_or_axis1(backend): + P = backend.Problem() + X = backend.Variable("X", (2, 3), vartype=VarType.BINARY) + P += backend.linear_or(X, axis=1, varname="v_or") + # Force X to have at least a 1 in the first row + P += P.expr.v_or[0] == 1 + P.add_objectives(sum(X[0, :])) + P.solve() + assert np.isclose(np.sum(X[0, :].value), 1.0) + + +def test_linearized_and_axis0(backend): + P = backend.Problem() + X = backend.Variable("X", (2, 3), vartype=VarType.BINARY) + P += backend.linear_and(X, axis=0, varname="v_and") + P += P.expr.v_and[0] == 1 + P.add_objectives(sum(X[:, 0])) + P.solve() + assert np.isclose(np.sum(X[:, 0].value), 2.0) + + +def test_linearized_and_axis1(backend): + P = backend.Problem() + X = backend.Variable("X", (2, 3), vartype=VarType.BINARY) + P += backend.linear_and(X, axis=1, varname="v_and") + P += P.expr.v_and[0] == 1 + P.add_objectives(sum(X[0, :])) + P.solve() + assert np.isclose(np.sum(X[0, :].value), 3.0) + + def test_undirected_flow(backend): g = Graph() g.add_edges([((), "A"), ("A", "B"), ("A", "C"), ("B", "D"), ("C", "D"), ("D", ())])