Skip to content

Commit 3ce7654

Browse files
committed
Add the hybrid estimate in C-SCIP as an example
1 parent 6bf045e commit 3ce7654

File tree

1 file changed

+307
-0
lines changed

1 file changed

+307
-0
lines changed
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
from pyscipopt import Model, SCIP_PARAMSETTING, Nodesel, SCIP_NODETYPE, quicksum
2+
from pyscipopt.scip import Node
3+
4+
5+
class HybridEstim(Nodesel):
6+
"""
7+
Hybrid best estimate / best bound node selection plugin.
8+
9+
This implements the hybrid node selection strategy from SCIP, which combines
10+
best estimate and best bound search with a plunging heuristic.
11+
"""
12+
13+
def __init__(self, model, minplungedepth=-1, maxplungedepth=-1, maxplungequot=0.25,
14+
bestnodefreq=1000, estimweight=0.10):
15+
"""
16+
Initialize the hybrid estimate node selector.
17+
18+
Parameters
19+
----------
20+
model : Model
21+
The SCIP model
22+
minplungedepth : int
23+
Minimal plunging depth before new best node may be selected
24+
(-1 for dynamic setting)
25+
maxplungedepth : int
26+
Maximal plunging depth before new best node is forced to be selected
27+
(-1 for dynamic setting)
28+
maxplungequot : float
29+
Maximal quotient (curlowerbound - lowerbound)/(cutoffbound - lowerbound)
30+
where plunging is performed
31+
bestnodefreq : int
32+
Frequency at which the best node instead of the hybrid best estimate/best bound
33+
is selected (0: never)
34+
estimweight : float
35+
Weight of estimate value in node selection score
36+
(0: pure best bound search, 1: pure best estimate search)
37+
"""
38+
super().__init__()
39+
self.scip = model
40+
self.minplungedepth = minplungedepth
41+
self.maxplungedepth = maxplungedepth
42+
self.maxplungequot = maxplungequot
43+
self.bestnodefreq = bestnodefreq if bestnodefreq > 0 else float('inf')
44+
self.estimweight = estimweight
45+
46+
def _get_nodesel_score(self, node: Node) -> float:
47+
"""
48+
Returns a weighted sum of the node's lower bound and estimate value.
49+
50+
Parameters
51+
----------
52+
node : Node
53+
The node to evaluate
54+
55+
Returns
56+
-------
57+
float
58+
The node selection score
59+
"""
60+
return ((1.0 - self.estimweight) * node.getLowerbound() +
61+
self.estimweight * node.getEstimate())
62+
63+
def nodeselect(self):
64+
"""
65+
Select the next node to process.
66+
67+
Returns
68+
-------
69+
dict
70+
Dictionary with 'selnode' key containing the selected node
71+
"""
72+
# Calculate minimal and maximal plunging depth
73+
minplungedepth = self.minplungedepth
74+
maxplungedepth = self.maxplungedepth
75+
76+
if minplungedepth == -1:
77+
minplungedepth = self.scip.getMaxDepth() // 10
78+
# Adjust based on strong branching iterations
79+
if (self.scip.getNStrongbranchLPIterations() >
80+
2 * self.scip.getNNodeLPIterations()):
81+
minplungedepth += 10
82+
if maxplungedepth >= 0:
83+
minplungedepth = min(minplungedepth, maxplungedepth)
84+
85+
if maxplungedepth == -1:
86+
maxplungedepth = self.scip.getMaxDepth() // 2
87+
88+
maxplungedepth = max(maxplungedepth, minplungedepth)
89+
90+
# Check if we exceeded the maximal plunging depth
91+
plungedepth = self.scip.getPlungeDepth()
92+
93+
if plungedepth > maxplungedepth:
94+
# We don't want to plunge again: select best node from the tree
95+
if self.scip.getNNodes() % self.bestnodefreq == 0:
96+
selnode = self.scip.getBestboundNode()
97+
else:
98+
selnode = self.scip.getBestNode()
99+
else:
100+
# Get global lower and cutoff bound
101+
lowerbound = self.scip.getLowerbound()
102+
cutoffbound = self.scip.getCutoffbound()
103+
104+
# If we didn't find a solution yet, use only 20% of the gap as cutoff bound
105+
if self.scip.getNSols() == 0:
106+
cutoffbound = lowerbound + 0.2 * (cutoffbound - lowerbound)
107+
108+
# Check if plunging is forced at the current depth
109+
if plungedepth < minplungedepth:
110+
maxbound = float('inf')
111+
else:
112+
# Calculate maximal plunging bound
113+
maxbound = lowerbound + self.maxplungequot * (cutoffbound - lowerbound)
114+
115+
# We want to plunge again: prefer children over siblings, and siblings over leaves
116+
# but only select a child or sibling if its estimate is small enough
117+
selnode = None
118+
119+
# Try priority child first
120+
node = self.scip.getPrioChild()
121+
if node is not None and node.getEstimate() < maxbound:
122+
selnode = node
123+
else:
124+
# Try best child
125+
node = self.scip.getBestChild()
126+
if node is not None and node.getEstimate() < maxbound:
127+
selnode = node
128+
else:
129+
# Try priority sibling
130+
node = self.scip.getPrioSibling()
131+
if node is not None and node.getEstimate() < maxbound:
132+
selnode = node
133+
else:
134+
# Try best sibling
135+
node = self.scip.getBestSibling()
136+
if node is not None and node.getEstimate() < maxbound:
137+
selnode = node
138+
else:
139+
# Select from leaves
140+
if self.scip.getNNodes() % self.bestnodefreq == 0:
141+
selnode = self.scip.getBestboundNode()
142+
else:
143+
selnode = self.scip.getBestNode()
144+
145+
return {"selnode": selnode}
146+
147+
def nodecomp(self, node1, node2):
148+
"""
149+
Compare two nodes.
150+
151+
Parameters
152+
----------
153+
node1 : Node
154+
First node to compare
155+
node2 : Node
156+
Second node to compare
157+
158+
Returns
159+
-------
160+
int
161+
-1 if node1 is better than node2
162+
0 if both nodes are equally good
163+
1 if node1 is worse than node2
164+
"""
165+
score1 = self._get_nodesel_score(node1)
166+
score2 = self._get_nodesel_score(node2)
167+
168+
# Check if scores are equal or both infinite
169+
if (self.scip.isEQ(score1, score2) or
170+
(self.scip.isInfinity(score1) and self.scip.isInfinity(score2)) or
171+
(self.scip.isInfinity(-score1) and self.scip.isInfinity(-score2))):
172+
173+
# Prefer children over siblings over leaves
174+
nodetype1 = node1.getType()
175+
nodetype2 = node2.getType()
176+
177+
# SCIP node types: CHILD = 0, SIBLING = 1, LEAF = 2
178+
if nodetype1 == SCIP_NODETYPE.CHILD and nodetype2 != SCIP_NODETYPE.CHILD: # node1 is child, node2 is not
179+
return -1
180+
elif nodetype1 != SCIP_NODETYPE.CHILD and nodetype2 == SCIP_NODETYPE.CHILD: # node2 is child, node1 is not
181+
return 1
182+
elif nodetype1 == SCIP_NODETYPE.SIBLING and nodetype2 != SCIP_NODETYPE.SIBLING: # node1 is sibling, node2 is not
183+
return -1
184+
elif nodetype1 != SCIP_NODETYPE.SIBLING and nodetype2 == SCIP_NODETYPE.SIBLING: # node2 is sibling, node1 is not
185+
return 1
186+
else:
187+
# Same node type, compare depths (prefer shallower nodes)
188+
depth1 = node1.getDepth()
189+
depth2 = node2.getDepth()
190+
if depth1 < depth2:
191+
return -1
192+
elif depth1 > depth2:
193+
return 1
194+
else:
195+
return 0
196+
197+
# Compare scores
198+
if score1 < score2:
199+
return -1
200+
else:
201+
return 1
202+
203+
def random_mip_1(disable_sepa=True, disable_heur=True, disable_presolve=True, node_lim=2000, small=False):
204+
model = Model()
205+
206+
x0 = model.addVar(lb=-2, ub=4)
207+
r1 = model.addVar()
208+
r2 = model.addVar()
209+
y0 = model.addVar(lb=3)
210+
t = model.addVar(lb=None)
211+
l = model.addVar(vtype="I", lb=-9, ub=18)
212+
u = model.addVar(vtype="I", lb=-3, ub=99)
213+
214+
more_vars = []
215+
if small:
216+
n = 100
217+
else:
218+
n = 500
219+
for i in range(n):
220+
more_vars.append(model.addVar(vtype="I", lb=-12, ub=40))
221+
model.addCons(quicksum(v for v in more_vars) <= (40 - i) * quicksum(v for v in more_vars[::2]))
222+
223+
for i in range(100):
224+
more_vars.append(model.addVar(vtype="I", lb=-52, ub=10))
225+
if small:
226+
model.addCons(quicksum(v for v in more_vars[50::2]) <= (40 - i) * quicksum(v for v in more_vars[65::2]))
227+
else:
228+
model.addCons(quicksum(v for v in more_vars[50::2]) <= (40 - i) * quicksum(v for v in more_vars[405::2]))
229+
230+
model.addCons(r1 >= x0)
231+
model.addCons(r2 >= -x0)
232+
model.addCons(y0 == r1 + r2)
233+
model.addCons(t + l + 7 * u <= 300)
234+
model.addCons(t >= quicksum(v for v in more_vars[::3]) - 10 * more_vars[5] + 5 * more_vars[9])
235+
model.addCons(more_vars[3] >= l + 2)
236+
model.addCons(7 <= quicksum(v for v in more_vars[::4]) - x0)
237+
model.addCons(quicksum(v for v in more_vars[::2]) + l <= quicksum(v for v in more_vars[::4]))
238+
239+
model.setObjective(t - quicksum(j * v for j, v in enumerate(more_vars[20:-40])))
240+
241+
if disable_sepa:
242+
model.setSeparating(SCIP_PARAMSETTING.OFF)
243+
if disable_heur:
244+
model.setHeuristics(SCIP_PARAMSETTING.OFF)
245+
if disable_presolve:
246+
model.setPresolve(SCIP_PARAMSETTING.OFF)
247+
model.setParam("limits/nodes", node_lim)
248+
249+
return model
250+
251+
def test_hybridestim_vs_default():
252+
"""
253+
Test that the Python hybrid estimate node selector performs similarly
254+
to the default SCIP C implementation.
255+
"""
256+
import random
257+
random.seed(42)
258+
259+
# Test with default SCIP hybrid estimate node selector
260+
m_default = random_mip_1(node_lim=2000, small=True)
261+
262+
m_default.setParam("nodeselection/hybridestim/stdpriority", 1_000_000)
263+
264+
m_default.optimize()
265+
266+
default_lp_iterations = m_default.getNLPIterations()
267+
default_nodes = m_default.getNNodes()
268+
default_obj = m_default.getObjVal()
269+
270+
print(f"Default SCIP hybrid estimate node selector (C implementation):")
271+
print(f" Nodes: {default_nodes}")
272+
print(f" LP iterations: {default_lp_iterations}")
273+
print(f" Objective: {default_obj}")
274+
275+
# Test with Python implementation
276+
m_python = random_mip_1(node_lim=2000, small=True)
277+
278+
# Include our Python hybrid estimate node selector
279+
hybridestim_nodesel = HybridEstim(
280+
m_python,
281+
)
282+
m_python.includeNodesel(
283+
hybridestim_nodesel,
284+
"pyhybridestim",
285+
"Python hybrid best estimate / best bound search",
286+
stdpriority=1_000_000,
287+
memsavepriority=50
288+
)
289+
290+
m_python.optimize()
291+
292+
python_lp_iterations = m_python.getNLPIterations()
293+
python_nodes = m_python.getNNodes()
294+
python_obj = m_python.getObjVal() if m_python.getNSols() > 0 else None
295+
296+
print(f"\nPython hybrid estimate node selector:")
297+
print(f" Nodes: {python_nodes}")
298+
print(f" LP iterations: {python_lp_iterations}")
299+
print(f" Objective: {python_obj}")
300+
301+
# Check if LP iterations are the same
302+
assert default_lp_iterations == python_lp_iterations, \
303+
"LP iterations differ between default and Python implementations!"
304+
305+
306+
if __name__ == "__main__":
307+
test_hybridestim_vs_default()

0 commit comments

Comments
 (0)