From 3ce765450a3cd33d011a1e203df73e21fdc2c107 Mon Sep 17 00:00:00 2001 From: Mohammed Ghannam Date: Wed, 5 Nov 2025 18:28:57 +0100 Subject: [PATCH 1/7] Add the hybrid estimate in C-SCIP as an example --- examples/finished/nodesel_hybridestim.py | 307 +++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 examples/finished/nodesel_hybridestim.py diff --git a/examples/finished/nodesel_hybridestim.py b/examples/finished/nodesel_hybridestim.py new file mode 100644 index 000000000..3977727f2 --- /dev/null +++ b/examples/finished/nodesel_hybridestim.py @@ -0,0 +1,307 @@ +from pyscipopt import Model, SCIP_PARAMSETTING, Nodesel, SCIP_NODETYPE, quicksum +from pyscipopt.scip import Node + + +class HybridEstim(Nodesel): + """ + Hybrid best estimate / best bound node selection plugin. + + This implements the hybrid node selection strategy from SCIP, which combines + best estimate and best bound search with a plunging heuristic. + """ + + def __init__(self, model, minplungedepth=-1, maxplungedepth=-1, maxplungequot=0.25, + bestnodefreq=1000, estimweight=0.10): + """ + Initialize the hybrid estimate node selector. + + Parameters + ---------- + model : Model + The SCIP model + minplungedepth : int + Minimal plunging depth before new best node may be selected + (-1 for dynamic setting) + maxplungedepth : int + Maximal plunging depth before new best node is forced to be selected + (-1 for dynamic setting) + maxplungequot : float + Maximal quotient (curlowerbound - lowerbound)/(cutoffbound - lowerbound) + where plunging is performed + bestnodefreq : int + Frequency at which the best node instead of the hybrid best estimate/best bound + is selected (0: never) + estimweight : float + Weight of estimate value in node selection score + (0: pure best bound search, 1: pure best estimate search) + """ + super().__init__() + self.scip = model + self.minplungedepth = minplungedepth + self.maxplungedepth = maxplungedepth + self.maxplungequot = maxplungequot + self.bestnodefreq = bestnodefreq if bestnodefreq > 0 else float('inf') + self.estimweight = estimweight + + def _get_nodesel_score(self, node: Node) -> float: + """ + Returns a weighted sum of the node's lower bound and estimate value. + + Parameters + ---------- + node : Node + The node to evaluate + + Returns + ------- + float + The node selection score + """ + return ((1.0 - self.estimweight) * node.getLowerbound() + + self.estimweight * node.getEstimate()) + + def nodeselect(self): + """ + Select the next node to process. + + Returns + ------- + dict + Dictionary with 'selnode' key containing the selected node + """ + # Calculate minimal and maximal plunging depth + minplungedepth = self.minplungedepth + maxplungedepth = self.maxplungedepth + + if minplungedepth == -1: + minplungedepth = self.scip.getMaxDepth() // 10 + # Adjust based on strong branching iterations + if (self.scip.getNStrongbranchLPIterations() > + 2 * self.scip.getNNodeLPIterations()): + minplungedepth += 10 + if maxplungedepth >= 0: + minplungedepth = min(minplungedepth, maxplungedepth) + + if maxplungedepth == -1: + maxplungedepth = self.scip.getMaxDepth() // 2 + + maxplungedepth = max(maxplungedepth, minplungedepth) + + # Check if we exceeded the maximal plunging depth + plungedepth = self.scip.getPlungeDepth() + + if plungedepth > maxplungedepth: + # We don't want to plunge again: select best node from the tree + if self.scip.getNNodes() % self.bestnodefreq == 0: + selnode = self.scip.getBestboundNode() + else: + selnode = self.scip.getBestNode() + else: + # Get global lower and cutoff bound + lowerbound = self.scip.getLowerbound() + cutoffbound = self.scip.getCutoffbound() + + # If we didn't find a solution yet, use only 20% of the gap as cutoff bound + if self.scip.getNSols() == 0: + cutoffbound = lowerbound + 0.2 * (cutoffbound - lowerbound) + + # Check if plunging is forced at the current depth + if plungedepth < minplungedepth: + maxbound = float('inf') + else: + # Calculate maximal plunging bound + maxbound = lowerbound + self.maxplungequot * (cutoffbound - lowerbound) + + # We want to plunge again: prefer children over siblings, and siblings over leaves + # but only select a child or sibling if its estimate is small enough + selnode = None + + # Try priority child first + node = self.scip.getPrioChild() + if node is not None and node.getEstimate() < maxbound: + selnode = node + else: + # Try best child + node = self.scip.getBestChild() + if node is not None and node.getEstimate() < maxbound: + selnode = node + else: + # Try priority sibling + node = self.scip.getPrioSibling() + if node is not None and node.getEstimate() < maxbound: + selnode = node + else: + # Try best sibling + node = self.scip.getBestSibling() + if node is not None and node.getEstimate() < maxbound: + selnode = node + else: + # Select from leaves + if self.scip.getNNodes() % self.bestnodefreq == 0: + selnode = self.scip.getBestboundNode() + else: + selnode = self.scip.getBestNode() + + return {"selnode": selnode} + + def nodecomp(self, node1, node2): + """ + Compare two nodes. + + Parameters + ---------- + node1 : Node + First node to compare + node2 : Node + Second node to compare + + Returns + ------- + int + -1 if node1 is better than node2 + 0 if both nodes are equally good + 1 if node1 is worse than node2 + """ + score1 = self._get_nodesel_score(node1) + score2 = self._get_nodesel_score(node2) + + # Check if scores are equal or both infinite + if (self.scip.isEQ(score1, score2) or + (self.scip.isInfinity(score1) and self.scip.isInfinity(score2)) or + (self.scip.isInfinity(-score1) and self.scip.isInfinity(-score2))): + + # Prefer children over siblings over leaves + nodetype1 = node1.getType() + nodetype2 = node2.getType() + + # SCIP node types: CHILD = 0, SIBLING = 1, LEAF = 2 + if nodetype1 == SCIP_NODETYPE.CHILD and nodetype2 != SCIP_NODETYPE.CHILD: # node1 is child, node2 is not + return -1 + elif nodetype1 != SCIP_NODETYPE.CHILD and nodetype2 == SCIP_NODETYPE.CHILD: # node2 is child, node1 is not + return 1 + elif nodetype1 == SCIP_NODETYPE.SIBLING and nodetype2 != SCIP_NODETYPE.SIBLING: # node1 is sibling, node2 is not + return -1 + elif nodetype1 != SCIP_NODETYPE.SIBLING and nodetype2 == SCIP_NODETYPE.SIBLING: # node2 is sibling, node1 is not + return 1 + else: + # Same node type, compare depths (prefer shallower nodes) + depth1 = node1.getDepth() + depth2 = node2.getDepth() + if depth1 < depth2: + return -1 + elif depth1 > depth2: + return 1 + else: + return 0 + + # Compare scores + if score1 < score2: + return -1 + else: + return 1 + +def random_mip_1(disable_sepa=True, disable_heur=True, disable_presolve=True, node_lim=2000, small=False): + model = Model() + + x0 = model.addVar(lb=-2, ub=4) + r1 = model.addVar() + r2 = model.addVar() + y0 = model.addVar(lb=3) + t = model.addVar(lb=None) + l = model.addVar(vtype="I", lb=-9, ub=18) + u = model.addVar(vtype="I", lb=-3, ub=99) + + more_vars = [] + if small: + n = 100 + else: + n = 500 + for i in range(n): + more_vars.append(model.addVar(vtype="I", lb=-12, ub=40)) + model.addCons(quicksum(v for v in more_vars) <= (40 - i) * quicksum(v for v in more_vars[::2])) + + for i in range(100): + more_vars.append(model.addVar(vtype="I", lb=-52, ub=10)) + if small: + model.addCons(quicksum(v for v in more_vars[50::2]) <= (40 - i) * quicksum(v for v in more_vars[65::2])) + else: + model.addCons(quicksum(v for v in more_vars[50::2]) <= (40 - i) * quicksum(v for v in more_vars[405::2])) + + model.addCons(r1 >= x0) + model.addCons(r2 >= -x0) + model.addCons(y0 == r1 + r2) + model.addCons(t + l + 7 * u <= 300) + model.addCons(t >= quicksum(v for v in more_vars[::3]) - 10 * more_vars[5] + 5 * more_vars[9]) + model.addCons(more_vars[3] >= l + 2) + model.addCons(7 <= quicksum(v for v in more_vars[::4]) - x0) + model.addCons(quicksum(v for v in more_vars[::2]) + l <= quicksum(v for v in more_vars[::4])) + + model.setObjective(t - quicksum(j * v for j, v in enumerate(more_vars[20:-40]))) + + if disable_sepa: + model.setSeparating(SCIP_PARAMSETTING.OFF) + if disable_heur: + model.setHeuristics(SCIP_PARAMSETTING.OFF) + if disable_presolve: + model.setPresolve(SCIP_PARAMSETTING.OFF) + model.setParam("limits/nodes", node_lim) + + return model + +def test_hybridestim_vs_default(): + """ + Test that the Python hybrid estimate node selector performs similarly + to the default SCIP C implementation. + """ + import random + random.seed(42) + + # Test with default SCIP hybrid estimate node selector + m_default = random_mip_1(node_lim=2000, small=True) + + m_default.setParam("nodeselection/hybridestim/stdpriority", 1_000_000) + + m_default.optimize() + + default_lp_iterations = m_default.getNLPIterations() + default_nodes = m_default.getNNodes() + default_obj = m_default.getObjVal() + + print(f"Default SCIP hybrid estimate node selector (C implementation):") + print(f" Nodes: {default_nodes}") + print(f" LP iterations: {default_lp_iterations}") + print(f" Objective: {default_obj}") + + # Test with Python implementation + m_python = random_mip_1(node_lim=2000, small=True) + + # Include our Python hybrid estimate node selector + hybridestim_nodesel = HybridEstim( + m_python, + ) + m_python.includeNodesel( + hybridestim_nodesel, + "pyhybridestim", + "Python hybrid best estimate / best bound search", + stdpriority=1_000_000, + memsavepriority=50 + ) + + m_python.optimize() + + python_lp_iterations = m_python.getNLPIterations() + python_nodes = m_python.getNNodes() + python_obj = m_python.getObjVal() if m_python.getNSols() > 0 else None + + print(f"\nPython hybrid estimate node selector:") + print(f" Nodes: {python_nodes}") + print(f" LP iterations: {python_lp_iterations}") + print(f" Objective: {python_obj}") + + # Check if LP iterations are the same + assert default_lp_iterations == python_lp_iterations, \ + "LP iterations differ between default and Python implementations!" + + +if __name__ == "__main__": + test_hybridestim_vs_default() \ No newline at end of file From d108fb513053574dec2765a83c23209d71806e93 Mon Sep 17 00:00:00 2001 From: Mohammed Ghannam Date: Thu, 6 Nov 2025 08:15:09 +0100 Subject: [PATCH 2/7] Refactor nested logic --- examples/finished/nodesel_hybridestim.py | 39 +++++++++++------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/examples/finished/nodesel_hybridestim.py b/examples/finished/nodesel_hybridestim.py index 3977727f2..b394223c4 100644 --- a/examples/finished/nodesel_hybridestim.py +++ b/examples/finished/nodesel_hybridestim.py @@ -116,31 +116,26 @@ def nodeselect(self): # but only select a child or sibling if its estimate is small enough selnode = None - # Try priority child first - node = self.scip.getPrioChild() - if node is not None and node.getEstimate() < maxbound: - selnode = node - else: - # Try best child - node = self.scip.getBestChild() + # Try each node type in priority order + node_getters = [ + self.scip.getPrioChild, + self.scip.getBestChild, + self.scip.getPrioSibling, + self.scip.getBestSibling, + ] + + for get_node in node_getters: + node = get_node() if node is not None and node.getEstimate() < maxbound: selnode = node + break + + # If no suitable child or sibling found, select from leaves + if selnode is None: + if self.scip.getNNodes() % self.bestnodefreq == 0: + selnode = self.scip.getBestboundNode() else: - # Try priority sibling - node = self.scip.getPrioSibling() - if node is not None and node.getEstimate() < maxbound: - selnode = node - else: - # Try best sibling - node = self.scip.getBestSibling() - if node is not None and node.getEstimate() < maxbound: - selnode = node - else: - # Select from leaves - if self.scip.getNNodes() % self.bestnodefreq == 0: - selnode = self.scip.getBestboundNode() - else: - selnode = self.scip.getBestNode() + selnode = self.scip.getBestNode() return {"selnode": selnode} From 81a74c068289429bc89ad0ecfa07efd4a2a690e9 Mon Sep 17 00:00:00 2001 From: Mohammed Ghannam Date: Thu, 6 Nov 2025 08:40:55 +0100 Subject: [PATCH 3/7] Make sure scores are not infinity when checking for equality --- examples/finished/nodesel_hybridestim.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/finished/nodesel_hybridestim.py b/examples/finished/nodesel_hybridestim.py index b394223c4..f762382ac 100644 --- a/examples/finished/nodesel_hybridestim.py +++ b/examples/finished/nodesel_hybridestim.py @@ -161,7 +161,9 @@ def nodecomp(self, node1, node2): score2 = self._get_nodesel_score(node2) # Check if scores are equal or both infinite - if (self.scip.isEQ(score1, score2) or + any_infinite = (self.scip.isInfinity(score1) or self.scip.isInfinity(-score1) or + self.scip.isInfinity(score2) or self.scip.isInfinity(-score2)) + if ( (not any_infinite and self.scip.isEQ(score1, score2)) or (self.scip.isInfinity(score1) and self.scip.isInfinity(score2)) or (self.scip.isInfinity(-score1) and self.scip.isInfinity(-score2))): From a5af47ec4249e055aa35d8d6c9602ebc861f90ce Mon Sep 17 00:00:00 2001 From: Mohammed Ghannam Date: Thu, 6 Nov 2025 11:03:30 +0100 Subject: [PATCH 4/7] Use an instance file, instead of duplicating the random mip generator --- examples/finished/nodesel_hybridestim.py | 79 +++++++----------------- 1 file changed, 23 insertions(+), 56 deletions(-) diff --git a/examples/finished/nodesel_hybridestim.py b/examples/finished/nodesel_hybridestim.py index f762382ac..a643df488 100644 --- a/examples/finished/nodesel_hybridestim.py +++ b/examples/finished/nodesel_hybridestim.py @@ -1,4 +1,4 @@ -from pyscipopt import Model, SCIP_PARAMSETTING, Nodesel, SCIP_NODETYPE, quicksum +from pyscipopt import Model, SCIP_PARAMSETTING, Nodesel, SCIP_NODETYPE from pyscipopt.scip import Node @@ -197,80 +197,47 @@ def nodecomp(self, node1, node2): else: return 1 -def random_mip_1(disable_sepa=True, disable_heur=True, disable_presolve=True, node_lim=2000, small=False): - model = Model() - - x0 = model.addVar(lb=-2, ub=4) - r1 = model.addVar() - r2 = model.addVar() - y0 = model.addVar(lb=3) - t = model.addVar(lb=None) - l = model.addVar(vtype="I", lb=-9, ub=18) - u = model.addVar(vtype="I", lb=-3, ub=99) - - more_vars = [] - if small: - n = 100 - else: - n = 500 - for i in range(n): - more_vars.append(model.addVar(vtype="I", lb=-12, ub=40)) - model.addCons(quicksum(v for v in more_vars) <= (40 - i) * quicksum(v for v in more_vars[::2])) - - for i in range(100): - more_vars.append(model.addVar(vtype="I", lb=-52, ub=10)) - if small: - model.addCons(quicksum(v for v in more_vars[50::2]) <= (40 - i) * quicksum(v for v in more_vars[65::2])) - else: - model.addCons(quicksum(v for v in more_vars[50::2]) <= (40 - i) * quicksum(v for v in more_vars[405::2])) - - model.addCons(r1 >= x0) - model.addCons(r2 >= -x0) - model.addCons(y0 == r1 + r2) - model.addCons(t + l + 7 * u <= 300) - model.addCons(t >= quicksum(v for v in more_vars[::3]) - 10 * more_vars[5] + 5 * more_vars[9]) - model.addCons(more_vars[3] >= l + 2) - model.addCons(7 <= quicksum(v for v in more_vars[::4]) - x0) - model.addCons(quicksum(v for v in more_vars[::2]) + l <= quicksum(v for v in more_vars[::4])) - - model.setObjective(t - quicksum(j * v for j, v in enumerate(more_vars[20:-40]))) - - if disable_sepa: - model.setSeparating(SCIP_PARAMSETTING.OFF) - if disable_heur: - model.setHeuristics(SCIP_PARAMSETTING.OFF) - if disable_presolve: - model.setPresolve(SCIP_PARAMSETTING.OFF) - model.setParam("limits/nodes", node_lim) - - return model - def test_hybridestim_vs_default(): """ Test that the Python hybrid estimate node selector performs similarly to the default SCIP C implementation. """ - import random - random.seed(42) + import os - # Test with default SCIP hybrid estimate node selector - m_default = random_mip_1(node_lim=2000, small=True) + # Get the path to the 10teams instance + instance_path = os.path.join(os.path.dirname(__file__), "..", "..", "tests", "data", "10teams.mps") + # Test with default SCIP hybrid estimate node selector + m_default = Model() + m_default.readProblem(instance_path) + + # Disable presolving, heuristics, and separation to focus on node selection + m_default.setPresolve(SCIP_PARAMSETTING.OFF) + m_default.setHeuristics(SCIP_PARAMSETTING.OFF) + m_default.setSeparating(SCIP_PARAMSETTING.OFF) + m_default.setParam("limits/nodes", 2000) m_default.setParam("nodeselection/hybridestim/stdpriority", 1_000_000) m_default.optimize() default_lp_iterations = m_default.getNLPIterations() default_nodes = m_default.getNNodes() - default_obj = m_default.getObjVal() - + default_obj = m_default.getObjVal() if m_default.getNSols() > 0 else None + print(f"Default SCIP hybrid estimate node selector (C implementation):") print(f" Nodes: {default_nodes}") print(f" LP iterations: {default_lp_iterations}") print(f" Objective: {default_obj}") # Test with Python implementation - m_python = random_mip_1(node_lim=2000, small=True) + m_python = Model() + m_python.readProblem(instance_path) + + # Disable presolving, heuristics, and separation to focus on node selection + m_python.setPresolve(SCIP_PARAMSETTING.OFF) + m_python.setHeuristics(SCIP_PARAMSETTING.OFF) + m_python.setSeparating(SCIP_PARAMSETTING.OFF) + m_python.setParam("limits/nodes", 2000) # Include our Python hybrid estimate node selector hybridestim_nodesel = HybridEstim( From 7538707e9853003ceebd1598b25e09dc532f4f9b Mon Sep 17 00:00:00 2001 From: Mohammed Ghannam Date: Thu, 6 Nov 2025 11:51:32 +0100 Subject: [PATCH 5/7] Mention the new example in the tutorial docs --- docs/tutorials/nodeselector.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/tutorials/nodeselector.rst b/docs/tutorials/nodeselector.rst index 6f1038fba..db905b2af 100644 --- a/docs/tutorials/nodeselector.rst +++ b/docs/tutorials/nodeselector.rst @@ -87,6 +87,8 @@ To include the node selector in your SCIP Model one would use the following code dfs_node_sel = DFS(scip) scip.includeNodesel(dfs_node_sel, "DFS", "Depth First Search Nodesel.", 1000000, 1000000) +For a more complex example, see the `Hybrid Estimate Node Selector `_ on GitHub. + From d2aa7430bbe807d3ec20c9b95de3345af6587da6 Mon Sep 17 00:00:00 2001 From: Mohammed Ghannam Date: Thu, 6 Nov 2025 11:54:29 +0100 Subject: [PATCH 6/7] Simplify condition --- examples/finished/nodesel_hybridestim.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/finished/nodesel_hybridestim.py b/examples/finished/nodesel_hybridestim.py index a643df488..6deced3af 100644 --- a/examples/finished/nodesel_hybridestim.py +++ b/examples/finished/nodesel_hybridestim.py @@ -161,8 +161,7 @@ def nodecomp(self, node1, node2): score2 = self._get_nodesel_score(node2) # Check if scores are equal or both infinite - any_infinite = (self.scip.isInfinity(score1) or self.scip.isInfinity(-score1) or - self.scip.isInfinity(score2) or self.scip.isInfinity(-score2)) + any_infinite = self.scip.isInfinity(abs(score1)) or self.scip.isInfinity(abs(score2)) if ( (not any_infinite and self.scip.isEQ(score1, score2)) or (self.scip.isInfinity(score1) and self.scip.isInfinity(score2)) or (self.scip.isInfinity(-score1) and self.scip.isInfinity(-score2))): From 65a3d5eb274ac0184d7e9bed4ba8a4ad540cbe13 Mon Sep 17 00:00:00 2001 From: Mohammed Ghannam Date: Thu, 6 Nov 2025 12:02:43 +0100 Subject: [PATCH 7/7] Update examples/finished/nodesel_hybridestim.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: João Dionísio <57299939+Joao-Dionisio@users.noreply.github.com> --- examples/finished/nodesel_hybridestim.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/finished/nodesel_hybridestim.py b/examples/finished/nodesel_hybridestim.py index 6deced3af..96c579298 100644 --- a/examples/finished/nodesel_hybridestim.py +++ b/examples/finished/nodesel_hybridestim.py @@ -101,7 +101,7 @@ def nodeselect(self): lowerbound = self.scip.getLowerbound() cutoffbound = self.scip.getCutoffbound() - # If we didn't find a solution yet, use only 20% of the gap as cutoff bound + # If we didn't find a solution yet, tighten the cutoff bound to 20% of the range between it and the lowerbound. if self.scip.getNSols() == 0: cutoffbound = lowerbound + 0.2 * (cutoffbound - lowerbound)