Skip to content

Commit

Permalink
Fix abs for 1d scalar case (#30)
Browse files Browse the repository at this point in the history
Variables with shape (1,) are not treated as scalars during translation,
even though they are equivalent in cvxpy. In gurobipy however, using
them as optimization objective generates warnings from numpy.

Also, merge all `abs` tests in the same function. Otherwise, the scalar,
vector and matrix containers will grow very large as more genexprs are
added.
  • Loading branch information
jonathanberthias authored Aug 21, 2024
1 parent 9983b5f commit e1e8cac
Show file tree
Hide file tree
Showing 23 changed files with 174 additions and 31 deletions.
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ build.hooks.vcs.version-file = "src/cvxpy_gurobi/_version.py"
dependencies = [
"cvxpy-base==1.5.*",
"gurobipy==11.*",
"numpy",
"numpy!=2.1.0", # broken with cvxpy<=1.5.3
"pytest",
"pytest-insta",
]
Expand All @@ -72,7 +72,7 @@ versions = [
dependencies = [
"cvxpy-base=={matrix:cvxpy}.*",
"gurobipy=={matrix:gurobipy}.*",
"numpy",
"numpy!=2.1.0", # broken with cvxpy<=1.5.3
"scipy{matrix:scipy:}",
"coverage[toml]",
"pytest",
Expand Down
24 changes: 21 additions & 3 deletions src/cvxpy_gurobi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,14 @@ def _matrix_to_gurobi_names(
yield idx, f"{base_name}[{formatted_idx}]"


def _shape(expr: Any) -> tuple[int, ...]:
return getattr(expr, "shape", ())


def _is_scalar_shape(shape: tuple[int, ...]) -> bool:
return prod(shape) == 1


def iter_subexpressions(expr: Any, shape: tuple[int, ...]) -> Iterator[Any]:
for idx in np.ndindex(shape):
yield expr[idx]
Expand Down Expand Up @@ -374,6 +382,16 @@ def visit(self, node: Canonical) -> Any:
raise UnsupportedExpressionError(node)
raise UnsupportedError(node)

def translate_into_scalar(self, node: cp.Expression) -> Any:
expr = self.visit(node)
shape = _shape(expr)
if shape == ():
return expr
assert _is_scalar_shape(shape), f"Expected scalar, got shape {shape}"
# expr can be many things: an ndarray, MVar, MLinExpr, etc.
# but let's assume it always has an `item` method
return expr.item()

def translate_into_variable(
self,
node: cp.Expression,
Expand Down Expand Up @@ -408,7 +426,7 @@ def make_auxilliary_variable_for(
ub: float = gp.GRB.INFINITY,
) -> gp.Var:
"""Add a variable constrained to the value of the given gurobipy expression."""
assert prod(getattr(expr, "shape", ())) == 1, expr.shape
assert _is_scalar_shape(_shape(expr)), expr.shape
self._aux_id += 1
var = add_variable(
self.model,
Expand Down Expand Up @@ -471,11 +489,11 @@ def visit_Inequality(self, ineq: Inequality) -> Any:
)

def visit_Maximize(self, objective: cp.Maximize) -> None:
obj = self.visit(objective.expr)
obj = self.translate_into_scalar(objective.expr)
self.model.setObjective(obj, sense=gp.GRB.MAXIMIZE)

def visit_Minimize(self, objective: cp.Minimize) -> None:
obj = self.visit(objective.expr)
obj = self.translate_into_scalar(objective.expr)
self.model.setObjective(obj, sense=gp.GRB.MINIMIZE)

def visit_MulExpression(self, node: MulExpression) -> Any:
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
28 changes: 28 additions & 0 deletions tests/snapshots/all__lp_genexpr_abs4__0.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
CVXPY
Minimize
abs(X)
Subject To
Bounds
X free
End
----------------------------------------
AFTER COMPILATION
Minimize
C0
Subject To
R0: - C0 + C1 <= 0
R1: - C0 - C1 <= 0
Bounds
C0 free
C1 free
End
----------------------------------------
GUROBI
Minimize
0 X[0] + abs_1
Subject To
Bounds
X[0] free
General Constraints
GC0: abs_1 = ABS ( X[0] )
End
29 changes: 29 additions & 0 deletions tests/snapshots/all__lp_genexpr_abs5__0.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
CVXPY
Minimize
abs(X) + 1.0
Subject To
Bounds
X free
End
----------------------------------------
AFTER COMPILATION
Minimize
C0
Subject To
R0: - C0 + C1 <= 0
R1: - C0 - C1 <= 0
Bounds
C0 free
C1 free
End
----------------------------------------
GUROBI
Minimize
0 X[0] + abs_1 + Constant
Subject To
Bounds
X[0] free
Constant = 1
General Constraints
GC0: abs_1 = ABS ( X[0] )
End
35 changes: 35 additions & 0 deletions tests/snapshots/all__lp_genexpr_abs6__0.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
CVXPY
Minimize
abs(X) + abs(Y)
Subject To
Bounds
X free
Y free
End
----------------------------------------
AFTER COMPILATION
Minimize
C0 + C1
Subject To
R0: - C0 + C2 <= 0
R1: - C0 - C2 <= 0
R2: - C1 + C3 <= 0
R3: - C1 - C3 <= 0
Bounds
C0 free
C1 free
C2 free
C3 free
End
----------------------------------------
GUROBI
Minimize
0 X[0] + abs_1 + 0 Y + abs_2
Subject To
Bounds
X[0] free
Y free
General Constraints
GC0: abs_1 = ABS ( X[0] )
GC1: abs_2 = ABS ( Y )
End
33 changes: 33 additions & 0 deletions tests/snapshots/all__lp_genexpr_abs7__0.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
CVXPY
Minimize
abs(X + Y)
Subject To
Bounds
X free
Y free
End
----------------------------------------
AFTER COMPILATION
Minimize
C0
Subject To
R0: - C0 + C1 + C2 <= 0
R1: - C0 - C1 - C2 <= 0
Bounds
C0 free
C1 free
C2 free
End
----------------------------------------
GUROBI
Minimize
abs_2
Subject To
R0: - X[0] - Y + index_1 = 0
Bounds
X[0] free
Y free
index_1 free
General Constraints
GC0: abs_2 = ABS ( index_1 )
End
File renamed without changes.
File renamed without changes.
52 changes: 26 additions & 26 deletions tests/test_problems.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,7 @@ def all_problems() -> Iterator[ProblemTestCase]:
quadratic_expressions,
matrix_constraints,
matrix_quadratic_expressions,
generalized_scalar_expressions,
generalized_vector_expressions,
generalized_matrix_expressions,
genexpr_abs,
indexing,
attributes,
invalid_expressions,
Expand Down Expand Up @@ -169,8 +167,8 @@ def matrix_quadratic_expressions() -> Iterator[cp.Problem]:
yield cp.Problem(cp.Minimize(cp.sum_squares(S @ x)))


@group_cases("genexpr_scalar")
def generalized_scalar_expressions() -> Iterator[cp.Problem]:
@group_cases("genexpr_abs")
def genexpr_abs() -> Iterator[cp.Problem]:
x = cp.Variable(name="x")
y = cp.Variable(name="y")

Expand All @@ -179,33 +177,35 @@ def generalized_scalar_expressions() -> Iterator[cp.Problem]:
yield cp.Problem(cp.Minimize(cp.abs(x) + cp.abs(y)))
yield cp.Problem(cp.Minimize(cp.abs(x + y)))

x = cp.Variable(1, name="X")
y = cp.Variable(name="Y")

@group_cases("genexpr_vector")
def generalized_vector_expressions() -> Iterator[cp.Problem]:
X = cp.Variable(2, name="X", nonneg=True)
Y = cp.Variable(2, name="Y", nonneg=True)
A = np.array([1, -2])
yield cp.Problem(cp.Minimize(cp.abs(x)))
yield cp.Problem(cp.Minimize(cp.abs(x) + 1))
yield cp.Problem(cp.Minimize(cp.abs(x) + cp.abs(y)))
yield cp.Problem(cp.Minimize(cp.abs(x + y)))

yield cp.Problem(cp.Minimize(cp.sum(cp.abs(X))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(X + Y))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(X) + 1)))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(X) + A)))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(X) + cp.abs(Y))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(X) + cp.abs(A))))
x = cp.Variable(2, name="X", nonneg=True)
y = cp.Variable(2, name="Y", nonneg=True)
A = np.array([1, -2])

yield cp.Problem(cp.Minimize(cp.sum(cp.abs(x))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(x + y))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(x) + 1)))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(x) + A)))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(x) + cp.abs(y))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(x) + cp.abs(A))))

@group_cases("genexpr_matrix")
def generalized_matrix_expressions() -> Iterator[cp.Problem]:
X = cp.Variable((2, 2), name="X", nonneg=True)
Y = cp.Variable((2, 2), name="Y", nonneg=True)
x = cp.Variable((2, 2), name="X", nonneg=True)
y = cp.Variable((2, 2), name="Y", nonneg=True)
A = np.array([[1, -2], [3, 4]])

yield cp.Problem(cp.Minimize(cp.sum(cp.abs(X))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(X + Y))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(X + 1))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(X) + A)))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(X) + cp.abs(Y))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(X) + cp.abs(A))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(x))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(x + y))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(x + 1))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(x) + A)))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(x) + cp.abs(y))))
yield cp.Problem(cp.Minimize(cp.sum(cp.abs(x) + cp.abs(A))))


@group_cases("indexing")
Expand Down

0 comments on commit e1e8cac

Please sign in to comment.