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"\n Python 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