Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pysat wsum #114

Draft
wants to merge 56 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
29777d5
First implmenetation of pysat weighted sum with a psuedo boolean solver
sourdough-bread Mar 17, 2022
00e3799
adding the x - y or -x + y case
sourdough-bread Mar 18, 2022
a6b5b74
handling the x - 3*y
sourdough-bread Mar 18, 2022
b009795
handling the x - 3*y
sourdough-bread Mar 18, 2022
0ed2677
adding test case
sourdough-bread Mar 18, 2022
404d2e2
adding test case
sourdough-bread Mar 18, 2022
f816000
adding trivial test case
sourdough-bread Mar 18, 2022
f808883
Hybrid Cardenc in case of sum and PBEnc in case of wsum
sourdough-bread Apr 1, 2022
267f7b1
Hybrid Cardenc in case of sum and PBEnc in case of wsum
sourdough-bread Apr 1, 2022
8ab3f5f
removing adaptations to core, such that the model stays true to the u…
sourdough-bread Apr 1, 2022
67774ba
Improved test cases
sourdough-bread Apr 1, 2022
d1683b1
added rewrite rules for flattening model that distributes - to muls a…
sourdough-bread Apr 1, 2022
3db2a51
Removed space in rumul
sourdough-bread Jun 1, 2022
fa2deb2
removing extra flattening+added cases for wsum_make
sourdough-bread Jun 1, 2022
3acc3b7
removing printing
sourdough-bread Jun 1, 2022
fac595c
ADding comments
sourdough-bread Jun 1, 2022
e07a10b
simplyfying the wsum_make calls
sourdough-bread Jun 1, 2022
7e9be25
seperate _wsum_should of flatten to _wsum_should_flatten hadnling the…
sourdough-bread Jun 1, 2022
bf16cbe
seperate _wsum_should of flatten to _wsum_should_flatten hadnling the…
sourdough-bread Jun 1, 2022
4d3dbd1
Adding wsum_make for special cases into the flattening
sourdough-bread Jun 1, 2022
d2885e1
Adding wsum_make for special cases into the flattening
sourdough-bread Jun 1, 2022
2da89ec
Adding wsum_make for special cases into the flattening
sourdough-bread Jun 1, 2022
ea10231
Adding wsum_make for special cases into the flattening
sourdough-bread Jun 1, 2022
5f12a4a
minor cleanups
Jun 8, 2022
0149425
Merging flattening with wsum
sourdough-bread Jul 13, 2022
d96d642
removing version checking with pysat
sourdough-bread Jul 13, 2022
3a19d68
-abs(exp) expressions should not be transformed into wsum
sourdough-bread Jul 13, 2022
c5ebdcc
Merge branch 'master' into pysat_wsum
sourdough-bread Nov 24, 2022
bca039b
rolling back transformations
sourdough-bread Nov 24, 2022
325cb49
running all tests
sourdough-bread Nov 24, 2022
bfd11de
Merge branch 'master' into pysat_wsum
sourdough-bread Dec 6, 2022
e5fd82a
adding tests for pysat
sourdough-bread Dec 6, 2022
f90c836
Adding model check if Bool-based
sourdough-bread Dec 6, 2022
51af5d5
Updated tests for wsum:int and wsum:bool to test pysat
IgnaceBleukx Dec 8, 2022
897ab6e
fixed condition in tests
IgnaceBleukx Dec 19, 2022
e3ed926
special case for a * b > 1
IgnaceBleukx Dec 19, 2022
11240f9
Merge branch 'master' into pysat_wsum
IgnaceBleukx Jan 31, 2023
eca6cbd
merged master into pysat_wsum
IgnaceBleukx Jan 31, 2023
aeb9021
pysat: accept reified cardinality constraints
tias May 24, 2023
82840ce
clean up commit before merging latest master
tias May 24, 2023
96d7dc2
Merge branch 'master' into pysat_wsum
tias May 24, 2023
eb765c2
Merge branch '296-pysat-reified-cardinality' into pysat_wsum
tias May 24, 2023
b043915
pysat: support reified wsums
tias May 24, 2023
ad33826
pysat: disable 'mul' tests, we dont support/rewrite a mul of two vars
tias May 24, 2023
624719c
enable all tests; z3 mul of two (bool?)vars is failing, should it?
tias May 24, 2023
ae059e4
merge master into pysat_wsum branch
IgnaceBleukx Jul 13, 2023
07f8b26
Merge branch 'master' into pysat_wsum
tias Nov 29, 2023
d937513
add wsum([c,bvs]) 'comp' const case
tias Nov 29, 2023
94c3d8d
extra tests for non-const RHS. Use existing transformation?
tias Nov 29, 2023
d0ad014
add linearize to pysat?
tias Nov 29, 2023
586fddf
remove two edge-cases on !=, make comment on rewrite
tias Nov 29, 2023
74803bb
tests:pb actually test what happens with too large bounds
tias Nov 29, 2023
b313c69
add more (failing) tests
tias Nov 29, 2023
c2daf46
allow for "and" and "or" to be supported
IgnaceBleukx Nov 30, 2023
09d03c4
allow for "and" and "or" to be supported
IgnaceBleukx Nov 30, 2023
d9df156
fix test
IgnaceBleukx Nov 30, 2023
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
79 changes: 66 additions & 13 deletions cpmpy/solvers/pysat.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
from ..expressions.core import Expression, Comparison, Operator
from ..expressions.variables import _BoolVarImpl, NegBoolView, boolvar
from ..expressions.utils import is_any_list, is_int
from ..transformations.get_variables import get_variables
from ..transformations.get_variables import get_variables, get_variables_model
from ..transformations.to_cnf import to_cnf

class CPM_pysat(SolverInterface):
Expand Down Expand Up @@ -64,6 +64,15 @@ def supported():
except ImportError as e:
return False

@staticmethod
def pb_supported():
try:
from pypblib import pblib
from pysat.pb import PBEnc
import pysat
return True
except ImportError as e:
return False

@staticmethod
def solvernames():
Expand Down Expand Up @@ -114,6 +123,7 @@ def __init__(self, cpm_model=None, subsolver=None):
self.pysat_solver = Solver(use_timer=True, name=subsolver)

# initialise everything else and post the constraints/objective
assert all(v.is_bool() for v in get_variables_model(cpm_model)), "Only support boolean variables in model"
tias marked this conversation as resolved.
Show resolved Hide resolved
super().__init__(name="pysat:"+subsolver, cpm_model=cpm_model)


Expand Down Expand Up @@ -243,40 +253,41 @@ def _post_constraint(self, cpm_expr):
raise NotImplementedError(
f"Automatic conversion of Operator {cpm_expr} to CNF not yet supported, please report on github.")
elif isinstance(cpm_expr, Comparison):
left = cpm_expr.args[0] # left-hand side, sum/wsum/mul
bound = cpm_expr.args[1] # right-hand side, constant

# only handle cardinality encodings (for now)
if isinstance(cpm_expr.args[0], Operator) and cpm_expr.args[0].name == "sum" and all(
isinstance(v, _BoolVarImpl) for v in cpm_expr.args[0].args):
lits = self.solver_vars(cpm_expr.args[0].args)
bound = cpm_expr.args[1]
if isinstance(left, Operator) and left.name == "sum" and all(isinstance(v, _BoolVarImpl) for v in left.args):
lits = self.solver_vars(left.args)
if not is_int(bound):
raise NotImplementedError(f"PySAT sum: rhs `{bound}` type {type(bound)} not supported")
tias marked this conversation as resolved.
Show resolved Hide resolved

clauses = []
if cpm_expr.name == "<":
clauses += CardEnc.atmost(lits=lits, bound=bound - 1, vpool=self.pysat_vpool).clauses
clauses += CardEnc.atmost(lits=lits, bound=bound-1, vpool=self.pysat_vpool).clauses
elif cpm_expr.name == "<=":
clauses += CardEnc.atmost(lits=lits, bound=bound, vpool=self.pysat_vpool).clauses
elif cpm_expr.name == ">=":
clauses += CardEnc.atleast(lits=lits, bound=bound, vpool=self.pysat_vpool).clauses
elif cpm_expr.name == ">":
clauses += CardEnc.atleast(lits=lits, bound=bound + 1, vpool=self.pysat_vpool).clauses
clauses += CardEnc.atleast(lits=lits, bound=bound+1, vpool=self.pysat_vpool).clauses
elif cpm_expr.name == "==":
clauses += CardEnc.equals(lits=lits, bound=bound, vpool=self.pysat_vpool).clauses
elif cpm_expr.name == "!=":
# special cases with bounding 'hardcoded' for clarity
if bound <= 0:
clauses += CardEnc.atleast(lits=lits, bound=bound + 1, vpool=self.pysat_vpool).clauses
clauses += CardEnc.atleast(lits=lits, bound=bound+1, vpool=self.pysat_vpool).clauses
elif bound >= len(lits):
clauses += CardEnc.atmost(lits=lits, bound=bound - 1, vpool=self.pysat_vpool).clauses
clauses += CardEnc.atmost(lits=lits, bound=bound-1, vpool=self.pysat_vpool).clauses
else:
## add implication literal to atleast/atmost
is_atleast = self.solver_var(boolvar())
clauses += [atl + [-is_atleast] for atl in
CardEnc.atleast(lits=lits, bound=bound + 1, vpool=self.pysat_vpool).clauses]
CardEnc.atleast(lits=lits, bound=bound+1, vpool=self.pysat_vpool).clauses]

is_atmost = self.solver_var(boolvar())
clauses += [atm + [-is_atmost] for atm in
CardEnc.atmost(lits=lits, bound=bound - 1, vpool=self.pysat_vpool).clauses]
CardEnc.atmost(lits=lits, bound=bound-1, vpool=self.pysat_vpool).clauses]

## add is_atleast or is_atmost
clauses.append([is_atleast, is_atmost])
Expand All @@ -285,9 +296,51 @@ def _post_constraint(self, cpm_expr):

# post the clauses
self.pysat_solver.append_formula(clauses)
else:
raise NotImplementedError(f"Non-operator constraint {cpm_expr} not supported by CPM_pysat")

# WEIGHTED !
elif isinstance(left, Operator) and (left.name in ["wsum", "mul"]) and is_int(bound):

if not CPM_pysat.pb_supported():
raise ImportError("Please install PyPBLib: pip install pypblib")
from pysat.pb import PBEnc

if left.name == "mul":
# single weight,value pair, in list
weights = [left.args[0]]
tias marked this conversation as resolved.
Show resolved Hide resolved
lits = [self.solver_var(left.args[1])]
else: # wsum
weights = left.args[0]
lits = self.solver_vars(left.args[1])

clauses = []
if cpm_expr.name == "<" or (cpm_expr.name == "!=" and bound >= len(lits)):
Copy link
Collaborator

Choose a reason for hiding this comment

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

You are missing the weights in the condition.
Now 10 * a + 5 * b != 5 is converted to 10 * a + 5 * b <= 4

Copy link
Collaborator

Choose a reason for hiding this comment

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

current version generates '[(BV0) -> (sum([10, 5] * [a, b]) <= 4), (~BV0) -> (sum([10, 5] * [a, b]) >= 6)]'
which I think is correct? @IgnaceBleukx can you resolve this thread if you agree?

Copy link
Collaborator

Choose a reason for hiding this comment

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

OK, I was confused here... it is the 'linearize' transfo that I added today for testing that did that rewriting.
You mean that it should be bound >= sum(weights) I think... but then indeed what about negatives (your comment lower). We could do bound >= sum(max(0,weights)) but what are we doing here then...

Either the bound is that max, and you can just post it as <= bound-1, or the bound is higher then the max and then the constraint is always true...

Maybe we should just drop this 'optimisation'?
I'm hoping that the PB encoding itself will check the 'always true' edge cases and so cover this appropriately?

clauses += PBEnc.leq(lits=lits, weights=weights, bound=bound-1, vpool=self.pysat_vpool).clauses
elif cpm_expr.name == "<=":
clauses += PBEnc.leq(lits=lits, weights=weights, bound=bound,vpool=self.pysat_vpool).clauses
elif cpm_expr.name == ">" or (cpm_expr.name == "!=" and bound <= 0):
Copy link
Collaborator

Choose a reason for hiding this comment

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

What about negative weights? I think you need the weights in the condition here as well

Copy link
Collaborator

Choose a reason for hiding this comment

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

removed special case in to-be commited version; resolve if OK (ignace)

clauses += PBEnc.geq(lits=lits, weights=weights, bound=bound+1, vpool=self.pysat_vpool).clauses
elif cpm_expr.name == ">=":
clauses += PBEnc.geq(lits=lits, weights=weights, bound=bound ,vpool=self.pysat_vpool).clauses
elif cpm_expr.name == "==":
clauses +=PBEnc.equals(lits=lits, weights=weights, bound=bound, vpool=self.pysat_vpool)

elif cpm_expr.name == "!=":
# BUG with pblib solved in Pysat dev 0.1.7.dev12
is_atleast = self.solver_var(boolvar())
clauses += [atl + [-is_atleast] for atl in
PBEnc.geq(lits=lits, weights=weights, bound=bound+1, vpool=self.pysat_vpool).clauses]
is_atmost = self.solver_var(boolvar())
clauses += [atm + [-is_atmost] for atm in
PBEnc.leq(lits=lits, weights=weights, bound=bound-1, vpool=self.pysat_vpool).clauses]

## add is_atleast or is_atmost
clauses.append([is_atleast, is_atmost])
else:
raise NotImplementedError(f"Comparison: {cpm_expr} not supported by CPM_pysat")

self.pysat_solver.append_formula(clauses)
else:
raise NotImplementedError(f"Comparison: {cpm_expr} not supported by CPM_pysat")
else:
raise NotImplementedError(f"Non-operator constraint {cpm_expr} not supported by CPM_pysat")

Expand Down
57 changes: 56 additions & 1 deletion cpmpy/transformations/flatten_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
import math
import numpy as np
from ..expressions.core import *
from ..expressions.core import _wsum_should
from ..expressions.core import _wsum_make
from ..expressions.variables import _NumVarImpl, _IntVarImpl, _BoolVarImpl, NegBoolView
from ..expressions.utils import is_num, is_any_list

Expand Down Expand Up @@ -185,6 +187,17 @@ def flatten_constraint(expr):
- Numeric inequality (>=,>,<,<=,): Numexpr >=< Var (CPMpy class 'Comparison')
- Reification (double implication): Boolexpr == Var (CPMpy class 'Comparison')
"""

left, right = expr.args[0], expr.args[1]

# Flatten a complex weighted sum where any of the sub expressions is a sum, mul, neg, ...
# e.g. bv0 - 3 * (bv2 + 2 * bv1)
if isinstance(left, Operator) and \
(left.name == "wsum" or
any(_wsum_should_flatten(sub_expr) for sub_expr in left.args) ):
w_new, x_new = _wsum_make_flatten(left)
return [Comparison(expr.name, Operator("wsum",[w_new, x_new] ),right)]

if all(__is_flat_var(arg) for arg in expr.args):
return [expr]

Expand Down Expand Up @@ -393,7 +406,6 @@ def normalized_boolexpr(expr):
"""
assert(not __is_flat_var(expr))
assert(expr.is_bool())

if isinstance(expr, Operator):
# and, or, ->

Expand Down Expand Up @@ -621,3 +633,46 @@ def negated_normal(expr):
return negated_normal(Operator('and', expr.decompose()))
else:
raise NotImplementedError("negate_normal {}".format(expr))


def _wsum_should_flatten(arg):
tias marked this conversation as resolved.
Show resolved Hide resolved
# in flatten we also turn negations (in sums) into weighted sums,
# for stronger expression simplification
if isinstance(arg, Operator) and arg.name == "-":
# TODO: HANDLE the ABS operator
if any(isinstance(sub_arg, Operator) and sub_arg.name == "abs" for sub_arg in arg.args):
return False
return True
return _wsum_should(arg)

def _wsum_make_flatten(sub_expr):
# Mixed operator weighted sums that can be flattened into one,
# where any of the sub expressions is a sum, mul, neg, ...
# e.g. bv0 - 3 * (bv2 + 2 * bv1)
if sub_expr.name == 'wsum':
w_new, x_new = [], []
for wi, xi in zip(sub_expr.args[0], sub_expr.args[1]):
wni, xni = _wsum_make_flatten(xi)
wni = [wnij * wi for wnij in wni]
w_new += wni
x_new += xni
return w_new, x_new
elif sub_expr.name == 'mul':
w = sub_expr.args[0]
wi, x = _wsum_make_flatten(sub_expr.args[1])
wi_new = [wij*w for wij in wi]
return wi_new, x
elif sub_expr.name == "sum":
w_new, x_new = [], []
for xi in sub_expr.args:
wni, xni = _wsum_make_flatten(xi)
w_new += wni
x_new += xni
return w_new, x_new
elif sub_expr.name == "-" and isinstance(sub_expr.args[0], Operator):
# - (3 * y)
w, x = _wsum_make_flatten(sub_expr.args[0])
return [-i for i in w], x

## base case
return _wsum_make(sub_expr)
2 changes: 1 addition & 1 deletion tests/test_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# Exclude certain operators for solvers.
# Not all solvers support all operators in CPMpy
EXCLUDE_OPERATORS = {"gurobi": {"mod"},
"pysat": {"sum", "wsum", "sub", "mod", "div", "pow", "abs", "mul","-"}}
tias marked this conversation as resolved.
Show resolved Hide resolved
"pysat": {"sub", "mod", "div", "pow", "abs", "mul","-"}}

# Some solvers only support a subset of operators in imply-constraints
# This subset can differ between left and right hand side of the implication
Expand Down
78 changes: 78 additions & 0 deletions tests/test_pysat_wsum.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import unittest
import cpmpy as cp
from cpmpy import *
from cpmpy.solvers.pysat import CPM_pysat
from cpmpy.transformations.to_cnf import to_cnf

class TestEncodeLinearConstraint(unittest.TestCase):
def setUp(self):
self.bv = boolvar(shape=3)

def test_pysat_simple_atmost(self):

atmost = cp.Model(
## < This does not work
- 2 * self.bv[0] < 3,
## <=
- 3 * self.bv[1] <= 3,
## >
2 * self.bv[2] > 1,
## >=
4 * self.bv[2] >= 3,
)
ps = CPM_pysat(atmost)
ps.solve()

def test_pysat_boolean_linear_sum(self):
ls = cp.Model(
2 * self.bv[0] + 3 * self.bv[1] <= 3,
self.bv[1] == 1,
)

ps = CPM_pysat(ls)
ps.solve()

def test_pysat_complex_expressions(self):
ls = cp.Model(
2 * self.bv[0] + 3 * self.bv[1] <= 3,
self.bv[1] == 1,
self.bv[2] == 0
)

ps = CPM_pysat(ls)
ps.solve()


def test_pysat_unsat(self):
ls = cp.Model(
2 * self.bv[0] + 3 * self.bv[1] <= 3,
self.bv[0] == 1,
self.bv[1] == 1
)

ps = CPM_pysat(ls)
solved = ps.solve()
self.assertFalse(solved)

def test_encode_linear_expressions(self):
expressions = [
- self.bv[2] == -1,
- 2 * self.bv[2] == -2,
self.bv[0] - self.bv[2] > 0,
-self.bv[0] + self.bv[2] > 0,
2 * self.bv[0] + 3 * self.bv[2] > 0,
2 * self.bv[0] - 3 * self.bv[2] + 2 * self.bv[1] > 0,
self.bv[0] - 3 * self.bv[2] > 0,
self.bv[0] - 3 * (self.bv[2] + 2 * self.bv[1])> 0,
]

## check all types of linear constraints are handled
for expression in expressions:
ps = CPM_pysat(Model(
expression
))
ps.solve()

if __name__ == '__main__':
unittest.main()