Skip to content

Commit 112f8b2

Browse files
Adds bipartite graph function (#874)
* Add strong branching wrappers * Update CHANGELOG * Add new wrappers and unfinished tests * Update strong branching test * Fix typo * Update CHANGELOG * Add test for other features in branching rule * Fix spelling errors * Remove commented out line * Add bipartite graph generation function * Update CHANGELOG * Add changes from joao * Add assert to ensure safe branching * Add changes from Joao * Change names to copy those of SCIp interface --------- Co-authored-by: João Dionísio <[email protected]>
1 parent 1932131 commit 112f8b2

File tree

3 files changed

+371
-2
lines changed

3 files changed

+371
-2
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Added Python definitions and wrappers for SCIPstartStrongbranch, SCIPendStrongbranch SCIPgetBranchScoreMultiple,
88
SCIPgetVarStrongbranchInt, SCIPupdateVarPseudocost, SCIPgetVarStrongbranchFrac, SCIPcolGetAge,
99
SCIPgetVarStrongbranchLast, SCIPgetVarStrongbranchNode, SCIPallColsInLP, SCIPcolGetAge
10+
- Added getBipartiteGraphRepresentation
1011
### Fixed
1112
- Fixed too strict getObjVal, getVal check
1213
### Changed

src/pyscipopt/scip.pxi

Lines changed: 252 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,8 +2066,9 @@ cdef class Model:
20662066
return SCIPgetRowObjParallelism(self._scip, row.scip_row)
20672067

20682068
def getRowParallelism(self, Row row1, Row row2, orthofunc=101):
2069-
"""Returns the degree of parallelism between hyplerplanes. 1 if perfectly parallel, 0 if orthognal.
2070-
101 in this case is an 'e' (euclidean) in ASCII. The other accpetable input is 100 (d for discrete)."""
2069+
"""Returns the degree of parallelism between hyplerplanes. 1 if perfectly parallel, 0 if orthogonal.
2070+
For two row vectors v, w the parallelism is calculated as: |v*w|/(|v|*|w|).
2071+
101 in this case is an 'e' (euclidean) in ASCII. The other acceptable input is 100 (d for discrete)."""
20712072
return SCIProwGetParallelism(row1.scip_row, row2.scip_row, orthofunc)
20722073

20732074
def getRowDualSol(self, Row row):
@@ -5697,6 +5698,255 @@ cdef class Model:
56975698
"""Get an estimation of the final tree size """
56985699
return SCIPgetTreesizeEstimation(self._scip)
56995700

5701+
5702+
def getBipartiteGraphRepresentation(self, prev_col_features=None, prev_edge_features=None, prev_row_features=None,
5703+
static_only=False, suppress_warnings=False):
5704+
"""This function generates the bipartite graph representation of an LP, which was first used in
5705+
the following paper:
5706+
@inproceedings{conf/nips/GasseCFCL19,
5707+
title={Exact Combinatorial Optimization with Graph Convolutional Neural Networks},
5708+
author={Gasse, Maxime and Chételat, Didier and Ferroni, Nicola and Charlin, Laurent and Lodi, Andrea},
5709+
booktitle={Advances in Neural Information Processing Systems 32},
5710+
year={2019}
5711+
}
5712+
The exact features have been modified compared to the original implementation.
5713+
This function is used mainly in the machine learning community for MIP.
5714+
A user can only call it during the solving process, when there is an LP object. This means calling it
5715+
from some user defined plugin on the Python side.
5716+
An example plugin is a branching rule or an event handler, which is exclusively created to call this function.
5717+
The user must then make certain to return the appropriate SCIP_RESULT (e.g. DIDNOTRUN)
5718+
5719+
:param prev_col_features: The list of column features previously returned by this function
5720+
:param prev_edge_features: The list of edge features previously returned by this function
5721+
:param prev_row_features: The list of row features previously returned by this function
5722+
:param static_only: Whether exclusively static features should be generated
5723+
:param suppress_warnings: Whether warnings should be suppressed
5724+
"""
5725+
5726+
cdef SCIP* scip = self._scip
5727+
cdef int i, j, k, col_i
5728+
cdef SCIP_VARTYPE vtype
5729+
cdef SCIP_Real sim, prod
5730+
5731+
# Check if SCIP is in the correct stage
5732+
if SCIPgetStage(scip) != SCIP_STAGE_SOLVING:
5733+
raise Warning("This functionality can only been called in SCIP_STAGE SOLVING. The row and column"
5734+
"information is then accessible")
5735+
5736+
# Generate column features
5737+
cdef SCIP_COL** cols = SCIPgetLPCols(scip)
5738+
cdef int ncols = SCIPgetNLPCols(scip)
5739+
5740+
if static_only:
5741+
n_col_features = 5
5742+
col_feature_map = {"continuous": 0, "binary": 1, "integer": 2, "implicit_integer": 3, "obj_coef": 4}
5743+
else:
5744+
n_col_features = 19
5745+
col_feature_map = {"continuous": 0, "binary": 1, "integer": 2, "implicit_integer": 3, "obj_coef": 4,
5746+
"has_lb": 5, "has_ub": 6, "sol_at_lb": 7, "sol_at_ub": 8, "sol_val": 9, "sol_frac": 10,
5747+
"red_cost": 11, "basis_lower": 12, "basis_basic": 13, "basis_upper": 14,
5748+
"basis_zero": 15, "best_incumbent_val": 16, "avg_incumbent_val": 17, "age": 18}
5749+
5750+
if prev_col_features is None:
5751+
col_features = [[0 for _ in range(n_col_features)] for _ in range(ncols)]
5752+
else:
5753+
assert len(prev_col_features) > 0, "Previous column features is empty"
5754+
col_features = prev_col_features
5755+
if len(prev_col_features) != ncols:
5756+
if not suppress_warnings:
5757+
raise Warning(f"The number of columns has changed. Previous column data being ignored")
5758+
else:
5759+
col_features = [[0 for _ in range(n_col_features)] for _ in range(ncols)]
5760+
prev_col_features = None
5761+
if len(prev_col_features[0]) != n_col_features:
5762+
raise Warning(f"Dimension mismatch in provided previous features and new features:"
5763+
f"{len(prev_col_features[0])} != {n_col_features}")
5764+
5765+
cdef SCIP_SOL* sol = SCIPgetBestSol(scip)
5766+
cdef SCIP_VAR* var
5767+
cdef SCIP_Real lb, ub, solval
5768+
cdef SCIP_BASESTAT basis_status
5769+
for i in range(ncols):
5770+
col_i = SCIPcolGetLPPos(cols[i])
5771+
var = SCIPcolGetVar(cols[i])
5772+
5773+
lb = SCIPcolGetLb(cols[i])
5774+
ub = SCIPcolGetUb(cols[i])
5775+
solval = SCIPcolGetPrimsol(cols[i])
5776+
5777+
# Collect the static features first (don't need to changed if previous features are passed)
5778+
if prev_col_features is None:
5779+
# Variable types
5780+
vtype = SCIPvarGetType(var)
5781+
if vtype == SCIP_VARTYPE_BINARY:
5782+
col_features[col_i][col_feature_map["binary"]] = 1
5783+
elif vtype == SCIP_VARTYPE_INTEGER:
5784+
col_features[col_i][col_feature_map["integer"]] = 1
5785+
elif vtype == SCIP_VARTYPE_CONTINUOUS:
5786+
col_features[col_i][col_feature_map["continuous"]] = 1
5787+
elif vtype == SCIP_VARTYPE_IMPLINT:
5788+
col_features[col_i][col_feature_map["implicit_integer"]] = 1
5789+
# Objective coefficient
5790+
col_features[col_i][col_feature_map["obj_coef"]] = SCIPcolGetObj(cols[i])
5791+
5792+
# Collect the dynamic features
5793+
if not static_only:
5794+
# Lower bound
5795+
if not SCIPisInfinity(scip, abs(lb)):
5796+
col_features[col_i][col_feature_map["has_lb"]] = 1
5797+
5798+
# Upper bound
5799+
if not SCIPisInfinity(scip, abs(ub)):
5800+
col_features[col_i][col_feature_map["has_ub"]] = 1
5801+
5802+
# Basis status
5803+
basis_status = SCIPcolGetBasisStatus(cols[i])
5804+
if basis_status == SCIP_BASESTAT_LOWER:
5805+
col_features[col_i][col_feature_map["basis_lower"]] = 1
5806+
elif basis_status == SCIP_BASESTAT_BASIC:
5807+
col_features[col_i][col_feature_map["basis_basic"]] = 1
5808+
elif basis_status == SCIP_BASESTAT_UPPER:
5809+
col_features[col_i][col_feature_map["basis_upper"]] = 1
5810+
elif basis_status == SCIP_BASESTAT_ZERO:
5811+
col_features[col_i][col_feature_map["basis_zero"]] = 1
5812+
5813+
# Reduced cost
5814+
col_features[col_i][col_feature_map["red_cost"]] = SCIPgetColRedcost(scip, cols[i])
5815+
5816+
# Age
5817+
col_features[col_i][col_feature_map["age"]] = SCIPcolGetAge(cols[i])
5818+
5819+
# LP solution value
5820+
col_features[col_i][col_feature_map["sol_val"]] = solval
5821+
col_features[col_i][col_feature_map["sol_frac"]] = SCIPfeasFrac(scip, solval)
5822+
col_features[col_i][col_feature_map["sol_at_lb"]] = int(SCIPisEQ(scip, solval, lb))
5823+
col_features[col_i][col_feature_map["sol_at_ub"]] = int(SCIPisEQ(scip, solval, ub))
5824+
5825+
# Incumbent solution value
5826+
if sol is NULL:
5827+
col_features[col_i][col_feature_map["best_incumbent_val"]] = None
5828+
col_features[col_i][col_feature_map["avg_incumbent_val"]] = None
5829+
else:
5830+
col_features[col_i][col_feature_map["best_incumbent_val"]] = SCIPgetSolVal(scip, sol, var)
5831+
col_features[col_i][col_feature_map["avg_incumbent_val"]] = SCIPvarGetAvgSol(var)
5832+
5833+
# Generate row features
5834+
cdef int nrows = SCIPgetNLPRows(scip)
5835+
cdef SCIP_ROW** rows = SCIPgetLPRows(scip)
5836+
5837+
if static_only:
5838+
n_row_features = 6
5839+
row_feature_map = {"has_lhs": 0, "has_rhs": 1, "n_non_zeros": 2, "obj_cosine": 3, "bias": 4, "norm": 5}
5840+
else:
5841+
n_row_features = 14
5842+
row_feature_map = {"has_lhs": 0, "has_rhs": 1, "n_non_zeros": 2, "obj_cosine": 3, "bias": 4, "norm": 5,
5843+
"sol_at_lhs": 6, "sol_at_rhs": 7, "dual_sol": 8, "age": 9,
5844+
"basis_lower": 10, "basis_basic": 11, "basis_upper": 12, "basis_zero": 13}
5845+
5846+
if prev_row_features is None:
5847+
row_features = [[0 for _ in range(n_row_features)] for _ in range(nrows)]
5848+
else:
5849+
assert len(prev_row_features) > 0, "Previous row features is empty"
5850+
row_features = prev_row_features
5851+
if len(prev_row_features) != nrows:
5852+
if not suppress_warnings:
5853+
raise Warning(f"The number of rows has changed. Previous row data being ignored")
5854+
else:
5855+
row_features = [[0 for _ in range(n_row_features)] for _ in range(nrows)]
5856+
prev_row_features = None
5857+
if len(prev_row_features[0]) != n_row_features:
5858+
raise Warning(f"Dimension mismatch in provided previous features and new features:"
5859+
f"{len(prev_row_features[0])} != {n_row_features}")
5860+
5861+
cdef int nnzrs = 0
5862+
cdef SCIP_Real lhs, rhs, cst
5863+
for i in range(nrows):
5864+
5865+
# lhs <= activity + cst <= rhs
5866+
lhs = SCIProwGetLhs(rows[i])
5867+
rhs = SCIProwGetRhs(rows[i])
5868+
cst = SCIProwGetConstant(rows[i])
5869+
activity = SCIPgetRowLPActivity(scip, rows[i])
5870+
5871+
if prev_row_features is None:
5872+
# number of coefficients
5873+
row_features[i][row_feature_map["n_non_zeros"]] = SCIProwGetNLPNonz(rows[i])
5874+
nnzrs += row_features[i][row_feature_map["n_non_zeros"]]
5875+
5876+
# left-hand-side
5877+
if not SCIPisInfinity(scip, abs(lhs)):
5878+
row_features[i][row_feature_map["has_lhs"]] = 1
5879+
5880+
# right-hand-side
5881+
if not SCIPisInfinity(scip, abs(rhs)):
5882+
row_features[i][row_feature_map["has_rhs"]] = 1
5883+
5884+
# bias
5885+
row_features[i][row_feature_map["bias"]] = cst
5886+
5887+
# Objective cosine similarity
5888+
row_features[i][row_feature_map["obj_cosine"]] = SCIPgetRowObjParallelism(scip, rows[i])
5889+
5890+
# L2 norm
5891+
row_features[i][row_feature_map["norm"]] = SCIProwGetNorm(rows[i])
5892+
5893+
if not static_only:
5894+
5895+
# Dual solution
5896+
row_features[i][row_feature_map["dual_sol"]] = SCIProwGetDualsol(rows[i])
5897+
5898+
# Basis status
5899+
basis_status = SCIProwGetBasisStatus(rows[i])
5900+
if basis_status == SCIP_BASESTAT_LOWER:
5901+
row_features[i][row_feature_map["basis_lower"]] = 1
5902+
elif basis_status == SCIP_BASESTAT_BASIC:
5903+
row_features[i][row_feature_map["basis_basic"]] = 1
5904+
elif basis_status == SCIP_BASESTAT_UPPER:
5905+
row_features[i][row_feature_map["basis_upper"]] = 1
5906+
elif basis_status == SCIP_BASESTAT_ZERO:
5907+
row_features[i][row_feature_map["basis_zero"]] = 1
5908+
5909+
# Age
5910+
row_features[i][row_feature_map["age"]] = SCIProwGetAge(rows[i])
5911+
5912+
# Is tight
5913+
row_features[i][row_feature_map["sol_at_lhs"]] = int(SCIPisEQ(scip, activity, lhs))
5914+
row_features[i][row_feature_map["sol_at_rhs"]] = int(SCIPisEQ(scip, activity, rhs))
5915+
5916+
# Generate edge (coefficient) features
5917+
cdef SCIP_COL** row_cols
5918+
cdef SCIP_Real * row_vals
5919+
n_edge_features = 3
5920+
edge_feature_map = {"col_idx": 0, "row_idx": 1, "coef": 2}
5921+
if prev_edge_features is None:
5922+
edge_features = [[0 for _ in range(n_edge_features)] for _ in range(nnzrs)]
5923+
j = 0
5924+
for i in range(nrows):
5925+
# coefficient indexes and values
5926+
row_cols = SCIProwGetCols(rows[i])
5927+
row_vals = SCIProwGetVals(rows[i])
5928+
for k in range(row_features[i][row_feature_map["n_non_zeros"]]):
5929+
edge_features[j][edge_feature_map["col_idx"]] = SCIPcolGetLPPos(row_cols[k])
5930+
edge_features[j][edge_feature_map["row_idx"]] = i
5931+
edge_features[j][edge_feature_map["coef"]] = row_vals[k]
5932+
j += 1
5933+
else:
5934+
assert len(prev_edge_features) > 0, "Previous edge features is empty"
5935+
edge_features = prev_edge_features
5936+
if len(prev_edge_features) != nnzrs:
5937+
if not suppress_warnings:
5938+
raise Warning(f"The number of coefficients in the LP has changed. Previous edge data being ignored")
5939+
else:
5940+
edge_features = [[0 for _ in range(3)] for _ in range(nnzrs)]
5941+
prev_edge_features = None
5942+
if len(prev_edge_features[0]) != 3:
5943+
raise Warning(f"Dimension mismatch in provided previous features and new features:"
5944+
f"{len(prev_edge_features[0])} != 3")
5945+
5946+
5947+
return (col_features, edge_features, row_features,
5948+
{"col": col_feature_map, "edge": edge_feature_map, "row": row_feature_map})
5949+
57005950
@dataclass
57015951
class Statistics:
57025952
"""

tests/test_bipartite.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from pyscipopt import Model, Branchrule, SCIP_RESULT, quicksum, SCIP_PARAMSETTING
2+
3+
"""
4+
This is a test for the bipartite graph generation functionality.
5+
To make the test more practical, we embed the function in a dummy branching rule. Such functionality would allow
6+
users to then extract the feature set before any branching decision. This can be used to gather data for training etc
7+
and to to deploy actual branching rules trained on data from the graph representation.
8+
"""
9+
10+
11+
class DummyFeatureExtractingBranchRule(Branchrule):
12+
13+
def __init__(self, scip, static=False, use_prev_states=True):
14+
self.scip = scip
15+
self.static = static
16+
self.use_prev_states = use_prev_states
17+
self.prev_col_features = None
18+
self.prev_row_features = None
19+
self.prev_edge_features = None
20+
21+
def branchexeclp(self, allowaddcons):
22+
23+
# Get the bipartite graph data
24+
if self.use_prev_states:
25+
prev_col_features = self.prev_col_features
26+
prev_edge_features = self.prev_edge_features
27+
prev_row_features = self.prev_row_features
28+
else:
29+
prev_col_features = None
30+
prev_edge_features = None
31+
prev_row_features = None
32+
col_features, edge_features, row_features, feature_maps = self.scip.getBipartiteGraphRepresentation(
33+
prev_col_features=prev_col_features, prev_edge_features=prev_edge_features,
34+
prev_row_features=prev_row_features, static_only=self.static
35+
)
36+
37+
# Here is now where a decision could be based off the features. If no decision is made just return DIDNOTRUN
38+
39+
return {"result": SCIP_RESULT.DIDNOTRUN}
40+
41+
42+
43+
44+
45+
def create_model():
46+
scip = Model()
47+
scip.setHeuristics(SCIP_PARAMSETTING.OFF)
48+
scip.setSeparating(SCIP_PARAMSETTING.OFF)
49+
scip.setLongintParam("limits/nodes", 250)
50+
scip.setParam("presolving/maxrestarts", 0)
51+
52+
x0 = scip.addVar(lb=-2, ub=4)
53+
r1 = scip.addVar()
54+
r2 = scip.addVar()
55+
y0 = scip.addVar(lb=3)
56+
t = scip.addVar(lb=None)
57+
l = scip.addVar(vtype="I", lb=-9, ub=18)
58+
u = scip.addVar(vtype="I", lb=-3, ub=99)
59+
60+
more_vars = []
61+
for i in range(100):
62+
more_vars.append(scip.addVar(vtype="I", lb=-12, ub=40))
63+
scip.addCons(quicksum(v for v in more_vars) <= (40 - i) * quicksum(v for v in more_vars[::2]))
64+
65+
for i in range(100):
66+
more_vars.append(scip.addVar(vtype="I", lb=-52, ub=10))
67+
scip.addCons(quicksum(v for v in more_vars[50::2]) <= (40 - i) * quicksum(v for v in more_vars[200::2]))
68+
69+
scip.addCons(r1 >= x0)
70+
scip.addCons(r2 >= -x0)
71+
scip.addCons(y0 == r1 + r2)
72+
scip.addCons(t + l + 7 * u <= 300)
73+
scip.addCons(t >= quicksum(v for v in more_vars[::3]) - 10 * more_vars[5] + 5 * more_vars[9])
74+
scip.addCons(more_vars[3] >= l + 2)
75+
scip.addCons(7 <= quicksum(v for v in more_vars[::4]) - x0)
76+
scip.addCons(quicksum(v for v in more_vars[::2]) + l <= quicksum(v for v in more_vars[::4]))
77+
78+
scip.setObjective(t - quicksum(j * v for j, v in enumerate(more_vars[20:-40])))
79+
80+
return scip
81+
82+
83+
def test_bipartite_graph():
84+
scip = create_model()
85+
86+
dummy_branch_rule = DummyFeatureExtractingBranchRule(scip)
87+
scip.includeBranchrule(dummy_branch_rule, "dummy branch rule", "custom feature extraction branching rule",
88+
priority=10000000, maxdepth=-1, maxbounddist=1)
89+
90+
scip.optimize()
91+
92+
93+
def test_bipartite_graph_static():
94+
scip = create_model()
95+
96+
dummy_branch_rule = DummyFeatureExtractingBranchRule(scip, static=True)
97+
scip.includeBranchrule(dummy_branch_rule, "dummy branch rule", "custom feature extraction branching rule",
98+
priority=10000000, maxdepth=-1, maxbounddist=1)
99+
100+
scip.optimize()
101+
102+
def test_bipartite_graph_use_prev():
103+
scip = create_model()
104+
105+
dummy_branch_rule = DummyFeatureExtractingBranchRule(scip, use_prev_states=True)
106+
scip.includeBranchrule(dummy_branch_rule, "dummy branch rule", "custom feature extraction branching rule",
107+
priority=10000000, maxdepth=-1, maxbounddist=1)
108+
109+
scip.optimize()
110+
111+
def test_bipartite_graph_static_use_prev():
112+
scip = create_model()
113+
114+
dummy_branch_rule = DummyFeatureExtractingBranchRule(scip, static=True, use_prev_states=True)
115+
scip.includeBranchrule(dummy_branch_rule, "dummy branch rule", "custom feature extraction branching rule",
116+
priority=10000000, maxdepth=-1, maxbounddist=1)
117+
118+
scip.optimize()

0 commit comments

Comments
 (0)