Skip to content

Commit 7195075

Browse files
authored
Merge pull request #1104 from scipopt/hybrid-node-selection
Add the hybrid estimate in C-SCIP as an example
2 parents d4c6490 + a38c8ea commit 7195075

File tree

2 files changed

+272
-0
lines changed

2 files changed

+272
-0
lines changed

docs/tutorials/nodeselector.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ To include the node selector in your SCIP Model one would use the following code
8787
dfs_node_sel = DFS(scip)
8888
scip.includeNodesel(dfs_node_sel, "DFS", "Depth First Search Nodesel.", 1000000, 1000000)
8989
90+
For a more complex example, see the `Hybrid Estimate Node Selector <https://github.com/scipopt/PySCIPOpt/blob/master/examples/finished/nodesel_hybridestim.py>`_ on GitHub.
91+
9092

9193

9294

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
from pyscipopt import Model, SCIP_PARAMSETTING, Nodesel, SCIP_NODETYPE
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, tighten the cutoff bound to 20% of the range between it and the lowerbound.
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 each node type in priority order
120+
node_getters = [
121+
self.scip.getPrioChild,
122+
self.scip.getBestChild,
123+
self.scip.getPrioSibling,
124+
self.scip.getBestSibling,
125+
]
126+
127+
for get_node in node_getters:
128+
node = get_node()
129+
if node is not None and node.getEstimate() < maxbound:
130+
selnode = node
131+
break
132+
133+
# If no suitable child or sibling found, select from leaves
134+
if selnode is None:
135+
if self.scip.getNNodes() % self.bestnodefreq == 0:
136+
selnode = self.scip.getBestboundNode()
137+
else:
138+
selnode = self.scip.getBestNode()
139+
140+
return {"selnode": selnode}
141+
142+
def nodecomp(self, node1, node2):
143+
"""
144+
Compare two nodes.
145+
146+
Parameters
147+
----------
148+
node1 : Node
149+
First node to compare
150+
node2 : Node
151+
Second node to compare
152+
153+
Returns
154+
-------
155+
int
156+
-1 if node1 is better than node2
157+
0 if both nodes are equally good
158+
1 if node1 is worse than node2
159+
"""
160+
score1 = self._get_nodesel_score(node1)
161+
score2 = self._get_nodesel_score(node2)
162+
163+
# Check if scores are equal or both infinite
164+
any_infinite = self.scip.isInfinity(abs(score1)) or self.scip.isInfinity(abs(score2))
165+
if ( (not any_infinite and self.scip.isEQ(score1, score2)) or
166+
(self.scip.isInfinity(score1) and self.scip.isInfinity(score2)) or
167+
(self.scip.isInfinity(-score1) and self.scip.isInfinity(-score2))):
168+
169+
# Prefer children over siblings over leaves
170+
nodetype1 = node1.getType()
171+
nodetype2 = node2.getType()
172+
173+
# SCIP node types: CHILD = 0, SIBLING = 1, LEAF = 2
174+
if nodetype1 == SCIP_NODETYPE.CHILD and nodetype2 != SCIP_NODETYPE.CHILD: # node1 is child, node2 is not
175+
return -1
176+
elif nodetype1 != SCIP_NODETYPE.CHILD and nodetype2 == SCIP_NODETYPE.CHILD: # node2 is child, node1 is not
177+
return 1
178+
elif nodetype1 == SCIP_NODETYPE.SIBLING and nodetype2 != SCIP_NODETYPE.SIBLING: # node1 is sibling, node2 is not
179+
return -1
180+
elif nodetype1 != SCIP_NODETYPE.SIBLING and nodetype2 == SCIP_NODETYPE.SIBLING: # node2 is sibling, node1 is not
181+
return 1
182+
else:
183+
# Same node type, compare depths (prefer shallower nodes)
184+
depth1 = node1.getDepth()
185+
depth2 = node2.getDepth()
186+
if depth1 < depth2:
187+
return -1
188+
elif depth1 > depth2:
189+
return 1
190+
else:
191+
return 0
192+
193+
# Compare scores
194+
if score1 < score2:
195+
return -1
196+
else:
197+
return 1
198+
199+
def test_hybridestim_vs_default():
200+
"""
201+
Test that the Python hybrid estimate node selector performs similarly
202+
to the default SCIP C implementation.
203+
"""
204+
import os
205+
206+
# Get the path to the 10teams instance
207+
instance_path = os.path.join(os.path.dirname(__file__), "..", "..", "tests", "data", "10teams.mps")
208+
209+
# Test with default SCIP hybrid estimate node selector
210+
m_default = Model()
211+
m_default.readProblem(instance_path)
212+
213+
# Disable presolving, heuristics, and separation to focus on node selection
214+
m_default.setPresolve(SCIP_PARAMSETTING.OFF)
215+
m_default.setHeuristics(SCIP_PARAMSETTING.OFF)
216+
m_default.setSeparating(SCIP_PARAMSETTING.OFF)
217+
m_default.setParam("limits/nodes", 2000)
218+
m_default.setParam("nodeselection/hybridestim/stdpriority", 1_000_000)
219+
220+
m_default.optimize()
221+
222+
default_lp_iterations = m_default.getNLPIterations()
223+
default_nodes = m_default.getNNodes()
224+
default_obj = m_default.getObjVal() if m_default.getNSols() > 0 else None
225+
226+
print(f"Default SCIP hybrid estimate node selector (C implementation):")
227+
print(f" Nodes: {default_nodes}")
228+
print(f" LP iterations: {default_lp_iterations}")
229+
print(f" Objective: {default_obj}")
230+
231+
# Test with Python implementation
232+
m_python = Model()
233+
m_python.readProblem(instance_path)
234+
235+
# Disable presolving, heuristics, and separation to focus on node selection
236+
m_python.setPresolve(SCIP_PARAMSETTING.OFF)
237+
m_python.setHeuristics(SCIP_PARAMSETTING.OFF)
238+
m_python.setSeparating(SCIP_PARAMSETTING.OFF)
239+
m_python.setParam("limits/nodes", 2000)
240+
241+
# Include our Python hybrid estimate node selector
242+
hybridestim_nodesel = HybridEstim(
243+
m_python,
244+
)
245+
m_python.includeNodesel(
246+
hybridestim_nodesel,
247+
"pyhybridestim",
248+
"Python hybrid best estimate / best bound search",
249+
stdpriority=1_000_000,
250+
memsavepriority=50
251+
)
252+
253+
m_python.optimize()
254+
255+
python_lp_iterations = m_python.getNLPIterations()
256+
python_nodes = m_python.getNNodes()
257+
python_obj = m_python.getObjVal() if m_python.getNSols() > 0 else None
258+
259+
print(f"\nPython hybrid estimate node selector:")
260+
print(f" Nodes: {python_nodes}")
261+
print(f" LP iterations: {python_lp_iterations}")
262+
print(f" Objective: {python_obj}")
263+
264+
# Check if LP iterations are the same
265+
assert default_lp_iterations == python_lp_iterations, \
266+
"LP iterations differ between default and Python implementations!"
267+
268+
269+
if __name__ == "__main__":
270+
test_hybridestim_vs_default()

0 commit comments

Comments
 (0)