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