Пример #1
0
 def test_solve_past_node_limit(self):
     bb = BranchAndBound(unbounded, node_limit=10)
     # check we quit even if node_queue nonempty
     with patch.object(bb, '_evaluate_node') as en:
         bb.evaluated_nodes = 10
         bb.solve()
         self.assertFalse(en.called, 'were past the node limit')
Пример #2
0
    def _optimize_cut(self: G,
                      pi: np.ndarray,
                      cut_optimization_node_limit: int = 10,
                      **kwargs: Any) -> float:
        """ Given the valid inequality pi >= pi0, try to find a smaller RHS such
        that pi >= smaller RHS is still a valid inequality

        :param pi: coefficients of the vector to optimize
        :param cut_optimization_node_limit: maximimum number of nodes to evaluate
        before terminating
        :param kwargs: catch all for unused passed kwargs
        :return: the objective value of the best milp feasible solution found
        """
        A = self.lp.coefMatrix.toarray()
        assert A.shape[1] == pi.shape[
            0], 'number of columns of A and length of c should match'

        # make new model where we minimize the cut
        model = MILPInstance(A=A,
                             b=self.lp.constraintsLower.copy(),
                             c=pi,
                             sense=['Min', '>='],
                             integerIndices=self._integer_indices,
                             numVars=pi.shape[0])

        # find a tighter bound with branch and bound
        bb = BranchAndBound(model=model,
                            Node=BaseNode,
                            node_limit=cut_optimization_node_limit,
                            pseudo_costs={})
        bb.solve()
        return bb.objective_value if bb.status == 'optimal' else bb.global_lower_bound
 def test_solve_infeasible(self):
     # check and make sure we're good with both nodes
     for Node in [BaseNode, PCBDFSNode]:
         bb = BranchAndBound(infeasible, Node=Node)
         bb.solve()
         self.assertTrue(bb.status == 'infeasible')
         self.assertFalse(bb.solution)
         self.assertTrue(bb.objective_value == float('inf'))
 def test_solve_optimal(self):
     # check and make sure we're good with both nodes
     for Node in [BaseNode, PCBDFSNode]:
         bb = BranchAndBound(small_branch, Node=Node)
         bb.solve()
         self.assertTrue(bb.status == 'optimal')
         self.assertTrue(all(s.is_integer for s in bb.solution))
         self.assertTrue(bb.objective_value == -2)
Пример #5
0
 def test_get_leaves(self):
     bb = BranchAndBound(small_branch)
     bb.solve()
     leaves = bb.tree.get_leaves(0)
     for node_id, node in bb.tree.nodes.items():
         if node_id in [n.idx for n in leaves]:
             self.assertFalse(bb.tree.get_children(node_id))
         else:
             self.assertTrue(len(bb.tree.get_children(node_id)) == 2)
Пример #6
0
 def test_solve_stopped_on_iterations(self):
     # check and make sure we're good with both nodes
     for Node in [BaseNode, PCBDFSNode]:
         bb = BranchAndBound(small_branch,
                             Node=Node,
                             node_limit=1,
                             pseudo_costs={})
         bb.solve()
         self.assertTrue(bb.status == 'stopped on iterations')
         self.assertTrue(bb.solve_time)
Пример #7
0
 def test_get_node_instances_fails_asserts(self):
     bb = BranchAndBound(small_branch)
     bb.solve()
     self.assertRaisesRegex(AssertionError,
                            'must be an integer or iterable',
                            bb.tree.get_node_instances, '1')
     self.assertRaisesRegex(AssertionError, 'node_ids are not in the tree',
                            bb.tree.get_node_instances, [20])
     del bb.tree.nodes[0].attr['node']
     self.assertRaisesRegex(AssertionError,
                            'must have an attribute for a node instance',
                            bb.tree.get_node_instances, [0])
    def test_solve_unbounded(self):
        # check and make sure we're good with both nodes
        for Node in [BaseNode, PCBDFSNode]:
            bb = BranchAndBound(unbounded, Node=Node)
            bb.solve()
            self.assertTrue(bb.status == 'unbounded')

        # check we quit even if node_queue nonempty
        with patch.object(bb, '_evaluate_node') as en:
            bb = BranchAndBound(unbounded)
            bb._unbounded = True
            bb.solve()
            self.assertFalse(en.called)
Пример #9
0
 def test_bound_dual_fails_asserts(self):
     bb = BranchAndBound(infeasible)
     bb.solve()
     terminal_nodes = bb.tree.get_leaves(0)
     infeasible_nodes = [
         n for n in terminal_nodes if n.lp_feasible is False
     ]
     n = infeasible_nodes[0]
     n.lp.addVariable('s_0', 1)
     self.assertRaisesRegex(AssertionError,
                            "variable 's_0' is a reserved name",
                            bb._bound_dual, n.lp)
     self.assertRaisesRegex(AssertionError,
                            "must give CyClpSimplex instance",
                            bb._bound_dual, n)
Пример #10
0
    def test_get_node_instances(self):
        bb = BranchAndBound(small_branch)
        bb.solve()

        # test list
        node1, node2 = bb.tree.get_node_instances([1, 2])
        self.assertTrue(node1.idx == 1, 'we should get node with matching id')
        self.assertTrue(isinstance(node1, BaseNode), 'we should get a node')
        self.assertTrue(node2.idx == 2, 'we should get node with matching id')
        self.assertTrue(isinstance(node2, BaseNode), 'we should get a node')

        # test singleton
        node1 = bb.tree.get_node_instances(1)
        self.assertTrue(node1.idx == 1, 'we should get node with matching id')
        self.assertTrue(isinstance(node1, BaseNode), 'we should get a node')
Пример #11
0
 def base_test_models(self):
     self.assertTrue(gu, 'gurobipy needed for this test')
     fldr = os.path.join(
         os.path.dirname(
             os.path.abspath(inspect.getfile(generate_random_variety))),
         'example_models')
     for i, file in enumerate(os.listdir(fldr)):
         print(f'running test {i + 1}')
         pth = os.path.join(fldr, file)
         model = MILPInstance(file_name=pth)
         bb = BranchAndBound(model, self.Node)
         bb.solve()
         gu_mdl = gu.read(pth)
         gu_mdl.optimize()
         self.assertTrue(
             isclose(bb.objective_value, gu_mdl.objVal, abs_tol=.01),
             f'different for {file}')
Пример #12
0
    def test_find_strong_disjunctive_cut_fails_asserts(self):
        bb = BranchAndBound(small_branch)
        bb.solve()

        self.assertRaisesRegex(AssertionError,
                               'parent must already exist in tree',
                               bb.find_strong_disjunctive_cut, 50)

        terminal_nodes = bb.tree.get_leaves(0)
        disjunctive_nodes = [
            n for n in terminal_nodes if n.lp_feasible is not False
        ]
        n = disjunctive_nodes[0]
        n.lp.addVariable('d', 3)

        self.assertRaisesRegex(
            AssertionError,
            'Each disjunctive term should have the same variables',
            bb.find_strong_disjunctive_cut, 0)
Пример #13
0
 def base_test_models(self, standardize_model=False):
     self.assertTrue(gu, 'gurobipy needed for this test')
     fldr = os.path.join(
         os.path.dirname(os.path.abspath(inspect.getfile(generate_random_variety))),
         'example_models'
     )
     for i, file in enumerate(os.listdir(fldr)):
         print(f'running test {i + 1}')
         pth = os.path.join(fldr, file)
         model = MILPInstance(file_name=pth)
         bb = BranchAndBound(model, self.Node, pseudo_costs={})
         bb.solve()
         gu_mdl = gu.read(pth)
         gu_mdl.setParam(gu.GRB.Param.LogToConsole, 0)
         gu_mdl.optimize()
         if not isclose(bb.objective_value, gu_mdl.objVal, abs_tol=.01):
             print(f'different for {file}')
             print(f'mine: {bb.objective_value}')
             print(f'gurobi: {gu_mdl.objVal}')
         self.assertTrue(isclose(bb.objective_value, gu_mdl.objVal, abs_tol=.01),
                         f'different for {file}')
Пример #14
0
    def test_dual_bound_fails_asserts(self):
        bb = BranchAndBound(small_branch)
        self.assertRaisesRegex(AssertionError,
                               'must solve this instance before',
                               bb.dual_bound, CyLPArray([2.5, 4.5]))
        bb.solve()
        self.assertRaisesRegex(AssertionError, 'only works with CyLP arrays',
                               bb.dual_bound, np.array([2.5, 4.5]))
        self.assertRaisesRegex(AssertionError,
                               'shape of the RHS being added should match',
                               bb.dual_bound, CyLPArray([4.5]))

        bb = BranchAndBound(infeasible2)
        bb.root_node.lp += np.matrix(
            [[0, -1, -1]]) * bb.root_node.lp.getVarByName('x') >= CyLPArray(
                [-2.5])
        bb.solve()
        self.assertRaisesRegex(
            AssertionError,
            'feature expects the root node to have a single constraint object',
            bb.dual_bound, CyLPArray([2.5, 4.5]))
Пример #15
0
    def test_find_strong_disjunctive_cut_many_times(self):
        fldr = os.path.join(
            os.path.dirname(
                os.path.abspath(inspect.getfile(generate_random_variety))),
            'example_models')
        for i, file in enumerate(os.listdir(fldr)):
            if i >= 10:
                break
            print(f'running test {i + 1}')
            pth = os.path.join(fldr, file)
            model = MILPInstance(file_name=pth)
            bb = BranchAndBound(model)
            bb.solve()
            pi, pi0 = bb.find_strong_disjunctive_cut(0)

            # ensure we cut off the root solution
            self.assertTrue(sum(pi * bb.root_node.solution) <= pi0)

            # ensure we don't cut off disjunctive mins
            for n in bb.tree.get_leaves(0):
                if n.lp_feasible:
                    self.assertTrue(sum(pi * n.solution) >= pi0 - .01)
Пример #16
0
    def test_find_strong_disjunctive_cut(self):
        bb = BranchAndBound(square)
        bb.solve()
        pi, pi0 = bb.find_strong_disjunctive_cut(0)

        # check cut is what we expect, i.e. x1 <= 1
        assert_allclose(pi / pi0, np.array([1, 0]), atol=.01)
        self.assertTrue((pi - .01 < 0).all())
        self.assertTrue(pi0 - .01 < 0)

        # check we get same bound
        A = np.append(bb.root_node.lp.coefMatrix.toarray(), [pi], axis=0)
        b = np.append(bb.root_node.lp.constraintsLower, pi0, axis=0)
        warm_model = MILPInstance(A=A,
                                  b=b,
                                  c=bb.root_node.lp.objective,
                                  l=bb.root_node.lp.variablesLower.copy(),
                                  u=bb.root_node.lp.variablesUpper.copy(),
                                  sense=['Min', '>='],
                                  integerIndices=bb.root_node._integer_indices,
                                  numVars=bb.root_node.lp.nVariables)
        warm_bb = BranchAndBound(warm_model)
        warm_bb.solve()
        # self.assertTrue(bb.global_lower_bound == warm_bb.root_node.objective_value)

        # try another problem
        bb = BranchAndBound(small_branch, node_limit=10)
        bb.solve()
        pi, pi0 = bb.find_strong_disjunctive_cut(0)

        # check cut is what we expect, i.e. x3 <= 1
        assert_allclose(pi / pi0, np.array([0, 0, 1]), atol=.01)
        self.assertTrue((pi - .01 < 0).all())
        self.assertTrue(pi0 - .01 < 0)

        # check we get same bound
        A = np.append(bb.root_node.lp.coefMatrix.toarray(), [pi], axis=0)
        b = np.append(bb.root_node.lp.constraintsLower, pi0, axis=0)
        warm_model = MILPInstance(A=A,
                                  b=b,
                                  c=bb.root_node.lp.objective,
                                  l=bb.root_node.lp.variablesLower.copy(),
                                  u=bb.root_node.lp.variablesUpper.copy(),
                                  sense=['Min', '>='],
                                  integerIndices=bb.root_node._integer_indices,
                                  numVars=bb.root_node.lp.nVariables)
        warm_bb = BranchAndBound(warm_model)
        warm_bb.solve()
Пример #17
0
 def test_dual_bound_many_times(self):
     pattern = re.compile('evaluation_(\d+).mps')
     fldr_pth = os.path.join(
         os.path.dirname(os.path.abspath(inspect.getfile(example_models))),
         'example_value_functions')
     for count, sub_fldr in enumerate(os.listdir(fldr_pth)):
         print(f'dual bound {count}')
         sub_fldr_pth = os.path.join(fldr_pth, sub_fldr)
         evals = {}
         for file in os.listdir(sub_fldr_pth):
             eval_num = int(pattern.search(file).group(1))
             instance = MILPInstance(
                 file_name=os.path.join(sub_fldr_pth, file))
             bb = BranchAndBound(instance,
                                 PseudoCostBranchNode,
                                 pseudo_costs={})
             bb.solve()
             evals[eval_num] = bb
         instance_0 = evals[0]
         for bb in evals.values():
             # all problems were given as <=, so their constraints were flipped by default
             self.assertTrue(
                 instance_0.dual_bound(CyLPArray(-bb.model.b)) <=
                 bb.objective_value + .01, 'dual_bound should be less')
Пример #18
0
 def test_current_gap(self):
     bb = BranchAndBound(small_branch, node_limit=1)
     bb.solve()
     self.assertTrue(bb.current_gap is None)
     bb.node_limit = 10
     bb.solve()
     self.assertTrue(bb.current_gap == .125)
     bb.node_limit = float('inf')
     bb.solve()
     self.assertTrue(bb.current_gap == 0)
     print()
Пример #19
0
    def test_dual_bound(self):

        # Ensure that BranchAndBound.dual_bound generates the dual function
        # that we saw in ISE 418 HW 3 problem 1
        bb = BranchAndBound(h3p1)
        bb.solve()
        bound = bb.dual_bound(CyLPArray([3.5, -3.5]))
        self.assertTrue(bb.objective_value == bound,
                        'dual should be strong at original rhs')

        prob = {
            0: h3p1_0,
            1: h3p1_1,
            2: h3p1_2,
            3: h3p1_3,
            4: h3p1_4,
            5: h3p1_5
        }
        sol_new = {0: 0, 1: 1, 2: 1, 3: 2, 4: 2, 5: 3}
        sol_bound = {0: 0, 1: .5, 2: 1, 3: 2, 4: 2, 5: 2.5}
        for beta in range(6):
            new_bb = BranchAndBound(prob[beta])
            new_bb.solve()
            bound = bb.dual_bound(CyLPArray(np.array([beta, -beta])))
            self.assertTrue(
                isclose(sol_new[beta], new_bb.objective_value, abs_tol=.01),
                'new branch and bound objective should match expected')
            self.assertTrue(isclose(sol_bound[beta], bound),
                            'new dual bound value should match expected')
            self.assertTrue(
                bound <= new_bb.objective_value + .01,
                'dual bound value should be at most the value function for this rhs'
            )

        bb = BranchAndBound(small_branch)
        bb.solve()
        bound = bb.dual_bound(CyLPArray([2.5, 4.5]))

        # just make sure the dual bound works here too
        self.assertTrue(
            bound <= -5.99,
            'dual bound value should be at most the value function for this rhs'
        )

        # check function calls
        bb = BranchAndBound(small_branch)
        bb.solve()
        bound_duals = [
            bb._bound_dual(n.lp)
            for n in bb.tree.get_node_instances([6, 12, 10, 8, 2])
        ]
        with patch.object(bb, '_bound_dual') as bd:
            bd.side_effect = bound_duals
            bound = bb.dual_bound(CyLPArray([3, 3]))
            self.assertTrue(bd.call_count == 5)

        bb = BranchAndBound(small_branch)
        bb.solve()
        bound = bb.dual_bound(CyLPArray([3, 3]))
        with patch.object(bb, '_bound_dual') as bd:
            bound = bb.dual_bound(CyLPArray([1, 1]))
            self.assertFalse(bd.called)
Пример #20
0
def main(cut_offs, in_fldr, out_file='warm_start_comparison.csv'):
    assert ((np.array([0] + cut_offs)) < (np.array(cut_offs + [float('inf')]))).all(), \
        'please put cut off sizes in increasing order'
    Path(out_file).unlink(missing_ok=True)  # delete output file if it exists

    for i, file in enumerate(os.listdir(in_fldr)):
        print(f'running test {i + 1}')
        warm_bb = {}
        data = {}
        pth = os.path.join(in_fldr, file)
        model = MILPInstance(file_name=pth)
        # cold started branch and bound
        cold_bb = BranchAndBound(model, PseudoCostBranchNode, pseudo_costs={})

        for c in cut_offs:
            cold_bb.node_limit = c
            cold_bb.solve()
            start = time.process_time()
            pi, pi0 = cold_bb.find_strong_disjunctive_cut(0)
            cglp_time = time.process_time() - start

            # warm start branch and bound with disjunctive cut after <c> nodes
            A = np.append(cold_bb.root_node.lp.coefMatrix.toarray(), [pi], axis=0)
            b = np.append(cold_bb.root_node.lp.constraintsLower, pi0, axis=0)
            warm_model = MILPInstance(
                A=A, b=b, c=cold_bb.root_node.lp.objective,
                l=cold_bb.root_node.lp.variablesLower.copy(),
                u=cold_bb.root_node.lp.variablesUpper.copy(), sense=['Min', '>='],
                integerIndices=cold_bb.root_node._integer_indices,
                numVars=cold_bb.root_node.lp.nVariables
            )

            # get data to compare starts and progress after <c> node evaluations
            # for both warm and cold starts
            warm_bb[c] = BranchAndBound(warm_model, PseudoCostBranchNode, node_limit=c, pseudo_costs={})
            warm_bb[c].solve()
            data[c] = {
                'cold initial lower bound': cold_bb.root_node.objective_value,
                'warm initial lower bound': warm_bb[c].root_node.objective_value,
                'cold cut off lower bound': cold_bb.global_lower_bound,
                'warm cut off lower bound': warm_bb[c].global_lower_bound,
                'cut off time': cold_bb.solve_time,
                'cglp time': cglp_time
            }

            # get data on warm start termination
            warm_bb[c].node_limit = float('inf')
            warm_bb[c].solve()
            data[c]['warm evaluated nodes'] = warm_bb[c].evaluated_nodes
            data[c]['warm solve time'] = warm_bb[c].solve_time
            data[c]['total restart solve time'] = data[c]['cut off time'] + \
                data[c]['cglp time'] + warm_bb[c].solve_time
            data[c]['total restart evaluated nodes'] = cold_bb.evaluated_nodes + \
                warm_bb[c].evaluated_nodes
            data[c]['warm initial gap'] = \
                abs(warm_bb[c].objective_value - data[c]['warm initial lower bound']) / \
                abs(warm_bb[c].objective_value)
            data[c]['warm cut off gap'] = \
                abs(warm_bb[c].objective_value - data[c]['warm cut off lower bound']) / \
                abs(warm_bb[c].objective_value)
            data[c]['warm objective value'] = warm_bb[c].objective_value

        # get data on cold start termination
        cold_bb.node_limit = float('inf')
        cold_bb.solve()
        for c in cut_offs:
            assert cold_bb.global_lower_bound <= warm_bb[c].global_upper_bound + .01 and \
                   cold_bb.global_upper_bound + .01 >= warm_bb[c].global_lower_bound, \
                   'gaps should overlap'
            data[c]['cold initial gap'] = \
                abs(cold_bb.objective_value - data[c]['cold initial lower bound']) / \
                abs(cold_bb.objective_value)
            data[c]['cold cut off gap'] = \
                abs(cold_bb.objective_value - data[c]['cold cut off lower bound']) / \
                abs(cold_bb.objective_value)
            data[c]['cold evaluated nodes'] = cold_bb.evaluated_nodes
            data[c]['cold solve time'] = cold_bb.solve_time
            data[c]['cold objective value'] = cold_bb.objective_value
            data[c]['initial gap improvement ratio'] = \
                (data[c]['cold initial gap'] - data[c]['warm initial gap']) / \
                data[c]['cold initial gap']
            data[c]['cut off gap improvement ratio'] = \
                (data[c]['cold cut off gap'] - data[c]['warm cut off gap']) / \
                data[c]['cold cut off gap']
            data[c]['warm evaluated nodes ratio'] = \
                (data[c]['cold evaluated nodes'] - data[c]['warm evaluated nodes']) / \
                data[c]['cold evaluated nodes']
            data[c]['warm solve time ratio'] = \
                (data[c]['cold solve time'] - data[c]['warm solve time']) / \
                data[c]['cold solve time']
            data[c]['total restart evaluated nodes ratio'] = \
                (data[c]['cold evaluated nodes'] - data[c]['total restart evaluated nodes']) / \
                data[c]['cold evaluated nodes']
            data[c]['total restart solve time ratio'] = \
                (data[c]['cold solve time'] - data[c]['total restart solve time']) / \
                data[c]['cold solve time']

        # append this test to our file
        df = pd.DataFrame.from_dict(data, orient='index')
        df.index.names = ['cut off']
        df.reset_index(inplace=True)
        df['test number'] = [i]*len(cut_offs)

        # rearrange columns
        cols = [
            'test number', 'cut off',

            'initial gap improvement ratio', 'cut off gap improvement ratio',
            'warm evaluated nodes ratio', 'total restart evaluated nodes ratio',
            'warm solve time ratio', 'total restart solve time ratio',

            'cold objective value', 'cold initial lower bound', 'cold initial gap',
            'cold cut off lower bound', 'cold cut off gap',
            'warm objective value', 'warm initial lower bound', 'warm initial gap',
            'warm cut off lower bound', 'warm cut off gap',

            'cold evaluated nodes', 'warm evaluated nodes', 'total restart evaluated nodes',
            
            'cold solve time', 'cut off time', 'cglp time', 'warm solve time',
            'total restart solve time'
        ]
        df = df[cols]
        with open(out_file, 'a') as f:
            df.to_csv(f, mode='a', header=f.tell() == 0, index=False)
Пример #21
0
    def test_bound_dual(self):
        bb = BranchAndBound(infeasible2)
        bb.root_node.lp += np.matrix(
            [[0, -1, -1]]) * bb.root_node.lp.getVarByName('x') >= CyLPArray(
                [-2.5])
        bb.solve()
        terminal_nodes = bb.tree.get_leaves(0)
        infeasible_nodes = [
            n for n in terminal_nodes if n.lp_feasible is False
        ]
        n = infeasible_nodes[0]
        lp = bb._bound_dual(n.lp)

        # test that we get a CyClpSimplex object back
        self.assertTrue(isinstance(lp, CyClpSimplex),
                        'should return CyClpSimplex instance')

        # same variables plus extra 's'
        self.assertTrue(
            {v.name
             for v in lp.variables} == {'x', 's_0', 's_1'},
            'x should already exist and s_1 and s_2 should be added')
        old_x = n.lp.getVarByName('x')
        new_x, s_0, s_1 = lp.getVarByName('x'), lp.getVarByName(
            's_0'), lp.getVarByName('s_1')

        # same variable bounds, plus s >= 0
        self.assertTrue(
            all(new_x.lower == old_x.lower)
            and all(new_x.upper == old_x.upper),
            'x should have the same bounds')
        self.assertTrue(
            all(s_0.lower == [0, 0]) and all(s_0.upper > [1e300, 1e300]),
            's_0 >= 0')
        self.assertTrue(
            all(s_1.lower == [0]) and all(s_1.upper > 1e300), 's_1 >= 0')

        # same constraints, plus slack s
        self.assertTrue(lp.nConstraints == 3,
                        'should have same number of constraints')
        self.assertTrue(
            (lp.constraints[0].varCoefs[new_x] == np.array([[-1, -1, 0],
                                                            [0, 0,
                                                             -1]])).all(),
            'x coefs should stay same')
        self.assertTrue((lp.constraints[0].varCoefs[s_0] == np.matrix(
            np.identity(2))).all(), 's_0 should have coef of 2-D identity')
        self.assertTrue(
            all(lp.constraints[1].varCoefs[new_x] == np.array([0, -1, -1])),
            'x coefs should stay same')
        self.assertTrue(
            lp.constraints[1].varCoefs[s_1] == np.matrix(np.identity(1)),
            's_0 should have coef of 1-D identity')
        self.assertTrue(
            all(lp.constraints[0].lower == np.array([1, -1]))
            and all(lp.constraints[0].upper >= np.array([1e300])),
            'constraint bounds should remain same')
        self.assertTrue(
            lp.constraints[1].lower == np.array([-2.5])
            and lp.constraints[1].upper >= np.array([1e300]),
            'constraint bounds should remain same')

        # same objective, plus large s coefficient
        self.assertTrue(
            all(lp.objective == np.array([-1, -1, 0, bb._M, bb._M, bb._M])))

        # problem is now feasible
        self.assertTrue(lp.getStatusCode() == 0, 'lp should now be optimal')
Пример #22
0
 def test_get_leaves_fails_asserts(self):
     bb = BranchAndBound(small_branch)
     bb.solve()
     self.assertRaisesRegex(AssertionError,
                            "subtree_root_id must belong to the tree",
                            bb.tree.get_leaves, 20)