This article takes solving the integer programming model as an example, and provides the Python code framework of the branch and bound algorithm, looking forward to perfection, correction and communication.
file structure
specific code
problem.py
Define the format of the question:
from typing import List
class Problem(object):
"""
problem
"""
def __init__(self, problem_type: str,
num_var: int, mat_cons: List[List[int or float]], obj_coef: List[int or float]) -> None:
"""
initialise
:param problem_type: problem type
:param num_var: number of variables
:param mat_cons: constraint coefficients matrix
:param obj_coef: objective coefficients list
"""
self.problem_type = problem_type
self.num_var = num_var
self.mat_cons = mat_cons
self.obj_coef = obj_coef
solver.py
Define the base class for problem-solving modules:
from algorithm.problem import Problem
class Solver(object):
"""
problem solver
"""
def __init__(self, problem: Problem) -> None:
"""
initialise
:param problem: problem to solve
"""
self.num_var = problem.num_var
self.mat_cons = problem.mat_cons
self.obj_coef = problem.obj_coef
# solution status
self.status = 'empty' # {'empty', 'invalid', 'optimal', 'feasible', 'infeasible', 'fail'}
self.obj = None
self.solution = []
def _is_valid(self) -> bool:
return True
def solve(self):
pass
solver_lp.py
Inherit the solver module base class, and write a solver module subclass that calls the OR-Tools GLOP solver for linear programming problems:
from datetime import datetime
import rich
from ortools.linear_solver import pywraplp
from config import ACCURACY
from algorithm.solver import Solver
from algorithm.problem import Problem
class SolverLP(Solver):
"""
problem solver, LP model
"""
def __init__(self, problem: Problem) -> None:
"""
initialise
:param problem: problem to solve
"""
super().__init__(problem)
def _is_valid(self) -> bool:
"""
check model format
:return: if model valid
"""
# constraint coefficient lists
for i in range(len(self.mat_cons)):
cons = self.mat_cons[i]
# coefficients list length
if len(cons) != self.num_var + 2:
rich.print(f"Constraint {
i} length {
len(cons)} invalid, need to be {
self.num_var} + 2")
return False
# objective coefficients list length
if len(self.obj_coef) != self.num_var + 1:
rich.print(
f"Objective coefficients list length {
len(self.obj_coef)} invalid, need to be {
self.num_var} + 1")
return False
return True
def solve(self):
"""
solving
"""
if not self._is_valid():
self.status = 'invalid'
return
solver = pywraplp.Solver("lp", problem_type=pywraplp.Solver.GLOP_LINEAR_PROGRAMMING)
x = [solver.NumVar(0, solver.infinity(), 'x_{}'.format(i)) for i in range(self.num_var)]
# constraints
for cons in self.mat_cons:
exp = 0
for j in range(len(cons[: -2])):
exp += cons[j] * x[j]
sign, b = cons[-2], cons[-1]
# index -2 coefficient: negative for <=; 0 for ==; positive for >=
if sign < 0:
solver.Add(exp <= b)
elif sign == 0:
solver.Add(exp == b)
else:
solver.Add(exp >= b)
# objective
obj = 0
for j in range(len(self.obj_coef[: -1])):
obj += self.obj_coef[j] * x[j]
# index -1 coefficient: positive for maximise; otherwise minimise
solver.Minimize(obj) if self.obj_coef[-1] <= 0 else solver.Maximize(obj)
# solve
dts = datetime.now()
status = solver.Solve()
dte = datetime.now()
tm = round((dte - dts).seconds + (dte - dts).microseconds / (10 ** 6), 3)
rich.print(f"LP model solving time: {
tm} s")
# result
if status == pywraplp.Solver.OPTIMAL:
self.status = 'optimal'
self.obj = solver.Objective().Value()
self.solution = [x[i].solution_value() for i in range(self.num_var)]
rich.print(f"objective value: {
self.obj}")
rich.print(f"solution: {
[round(x, ACCURACY) for x in self.solution]}")
elif status == pywraplp.Solver.FEASIBLE:
self.status = 'feasible'
rich.print(f"Didn't get an optimal solution, but a feasible one.")
elif status == pywraplp.Solver.INFEASIBLE:
self.status = 'infeasible'
rich.print(f"The problem does not have a feasible solution.")
else:
self.status = 'fail'
rich.print(f"Failed to solving problem for an unknown reason.")
node.py
Node modules for branch and bound algorithms:
from typing import List
import copy
from config import PROBLEM_TYPES, INSTANCE, ACCURACY
from algorithm.problem import Problem
from algorithm.solver_lp import SolverLP
class Node(object):
"""
branch and bound node
"""
def __init__(self, id: int, father: int or None, new_cons: List[int or float]) -> None:
"""
initialise
:param id: node ID
:param father: father node ID, None for root
:param id: new constraits other than father node's constraints
"""
self.problem: Problem = copy.deepcopy(INSTANCE)
self.problem_type = self.problem.problem_type
self.num_var, self.mat_cons, self.obj_coef = self.problem.num_var, self.problem.mat_cons, self.problem.obj_coef
# check problem type
if self.problem_type not in PROBLEM_TYPES:
raise Exception(f"Problem type {
self.problem_type} invalid!")
self.id = id
self.father = father
self.new_cons = new_cons
self.depth = None
# solution
self.status = 'empty' # {'empty', 'feasible', 'relax', 'infeasible'}
self.obj = None
self.solution = []
self.infeasible_list = []
def add_ancestor_cons(self, ancestor_cons: List[List[int or float]]):
"""
initialise
:param ancestor_cons: constraints from ancestors
"""
self.mat_cons += ancestor_cons
def solve(self):
"""
solve problem
"""
# add new constraint
if self.new_cons:
self.mat_cons.append(self.new_cons)
solver = SolverLP(problem=self.problem)
solver.solve()
status, self.obj, self.solution = solver.status, solver.obj, solver.solution
if status in {
'optimal', 'feasible'}:
self._check_solution()
self.status = 'feasible' if not self.infeasible_list else 'relax'
else:
self.status = 'infeasible'
def _check_solution(self):
"""
check if solution valid
"""
if self.problem_type == 'IP':
self._check_integer()
else:
pass
def _check_integer(self):
"""
check if all solutions are integers
"""
for i in range(len(self.solution)):
x = self.solution[i]
if round(x, ACCURACY) != round(x):
self.infeasible_list.append((i, x))
algorithm.py
The algorithm module of the branch and bound algorithm, the main points:
- Every time a feasible solution is obtained, update the optimal feasible solution (incumbent)
- Every time a branch is completed (all child nodes are solved), update the global bound (bound)
import datetime
from typing import List, Dict
import math
import rich
from config import PROBLEM_TYPES, INSTANCE, ACCURACY
from algorithm.problem import Problem
from algorithm.node import Node
class BranchAndBound(object):
"""
branch and bound algorithm
"""
def __init__(self, time_limit: int or float = 3600, gap_upper: float = 0) -> None:
"""
initialise
:param time_limit: time limit of solving
:param gap_upper: upper bound of gap of solving
"""
self.problem: Problem = INSTANCE
self.problem_type = self.problem.problem_type
self.num_var, self.mat_cons, self.obj_coef = self.problem.num_var, self.problem.mat_cons, self.problem.obj_coef
# check problem type
if self.problem_type not in PROBLEM_TYPES:
raise Exception(f"Problem type {
self.problem_type} invalid!")
# optimising direction
self.opt_dir = 'min' if self.obj_coef[-1] <= 0 else 'max'
# solving params
self.time_limit = time_limit
self.gap_upper = gap_upper
self.dt_start, self.dt_end = datetime.datetime.now(), None
self.final_time_cost = None
self.final_gap = None
# nodes
self.idx_acc = 0
self.nodes: Dict[int, Node] = {
} # all nodes, key: ID
self.leaf_nodes: List[int] = [] # node ID
# solution status
self.status = 'empty' # {'empty', 'optimal', 'feasible', 'infeasible', 'fail'}
self.solution = None
self.incumbent, self.incumbent_node = None, None # update once feasible solution found
self.bound, self.bound_node = None, None # update once leaf nodes updated
@property
def time_cost(self) -> float:
"""
get time cost
"""
dt_end = datetime.datetime.now()
tm_delta: datetime.timedelta = dt_end - self.dt_start
return round(tm_delta.seconds + tm_delta.microseconds / 1e6, 6)
@property
def reach_time_limit(self) -> bool:
"""
check if reach time limit
"""
return self.time_cost >= self.time_limit
@property
def gap(self) -> bool:
"""
calculate current gap
"""
# case 1: searched all nodes
if self.bound_node not in self.leaf_nodes:
return 0
# case 2: catched both incumbent and bound
elif self.bound is not None and self.incumbent is not None:
return abs(self.bound - self.incumbent) / abs(self.incumbent)
else:
return None
@property
def reach_accuracy(self) -> bool:
"""
check if gap reach accuracy
"""
return True if self.gap is not None and self.gap <= self.gap_upper else False
def run(self):
"""
algorithm main process
"""
self.dt_start = datetime.datetime.now()
# root node and solve
node_index = self.idx_acc
self.idx_acc += 1
node = Node(id=node_index, father=None, new_cons=[])
node.depth = 0
self.nodes[node_index] = node
rich.print(f"Solving node {
node_index}")
node.solve()
# special case 1: root node feasible
if node.status == 'feasible':
self.solution = node.solution
self.status = 'optimal'
return
# special case 2: root node infeasible
elif node.status == 'infeasible':
self.status = 'infeasible'
return
# update leaf nodes and bound
self.leaf_nodes.append(node.id)
self._update_bound()
rich.print()
# main circulation
node_index = self._select_node()
while node_index is not None: # attention: don't use "while node_index" because the index can be 0
# branch and get new leaf nodes
is_terminated = self._branch(node_index=node_index)
# check termination
if is_terminated:
self.final_time_cost = self.time_cost
self.final_gap = self.gap
rich.print(f"final time cost: {
self.final_time_cost}")
rich.print(f"final gap: {
self.final_gap}")
return
# select node to branch
node_index = self._select_node()
rich.print()
# all nodes searched
self.final_gap = self.gap
if self.incumbent_node is not None:
self.solution = self.nodes[self.incumbent_node].solution
self.status = 'optimal' if self.final_gap == 0 else 'feasible'
else:
self.status = 'ineasible'
self.final_time_cost = self.time_cost
rich.print(f"final time cost: {
round(self.final_time_cost, 3)} s")
rich.print(f"final gap: {
self.final_gap}")
rich.print(f"total nodes: {
len(self.nodes)}")
rich.print(f"maximal node depth: {
max(self.nodes[i].depth for i in self.nodes)}")
rich.print()
def _select_node(self) -> int or None: # TODO: node selection strategy: bound node
"""
node selection
:return: selected node index
"""
# have updated bound before
return self.bound_node if self.bound_node in self.leaf_nodes else None
def _branch(self, node_index: int) -> bool:
"""
branching
:param node_index: father node index
:return: if algorithm terminates
"""
node = self.nodes[node_index]
list_new_cons = self._get_new_constraints(node_index=node_index)
for cons in list_new_cons:
# new node
node_index_new = self.idx_acc
self.idx_acc += 1
node_new = Node(id=node_index_new, father=node.id, new_cons=cons)
node_new.depth = node.depth + 1
self.nodes[node_index_new] = node_new
# inherit constraints from ancestors
ancestor_cons = []
node_new_ = node_new
while node_new_.father:
ancestor_cons.append(self.nodes[node_new_.father].new_cons)
node_new_ = self.nodes[node_new_.father]
node_new.add_ancestor_cons(ancestor_cons=ancestor_cons)
# solve node and check incumbent
rich.print(f"Solving node {
node_index_new}")
rich.print(f"new constraint coefficients: {
cons}")
node_new.solve()
rich.print(f"node status: {
node_new.status}")
if node_new.status == 'feasible':
incumbent_updated = self._update_incumbent(feasible_node=node_index_new)
# update incumbent and check accuracy
if incumbent_updated:
if self.reach_accuracy:
rich.print(
f"Gap {
self.gap} reach accuracy {
self.gap_upper}, bound {
round(self.bound, ACCURACY)}")
self.solution = self.nodes[self.incumbent_node].solution
self.status = 'optimal' if self.gap == 0 else 'feasible'
return True
# check time limit
if self.reach_time_limit:
rich.print(f"Time limit reached!")
if self.incumbent_node is not None:
self.solution = self.nodes[self.incumbent_node].solution
self.status = 'optimal' if self.gap == 0 else 'feasible'
else:
rich.print(f"Didn't find a feasible solution within time limit!")
self.status = 'fail'
return True
# add new leaf node
if node_new.status == 'relax':
self.leaf_nodes.append(node_index_new)
rich.print()
# remove old leaf node
self.leaf_nodes.remove(node_index)
# update bound and check accuracy
bound_updated = self._update_bound()
if bound_updated:
if self.reach_accuracy:
rich.print(
f"Gap {
self.gap} reach accuracy {
self.gap_upper}, incumbent {
round(self.incumbent, ACCURACY)}")
rich.print()
self.solution = self.nodes[self.incumbent_node].solution
self.status = 'optimal' if self.gap == 0 else 'feasible'
return True
return False
def _get_new_constraints(self, node_index: int) -> List[List[float or int]]: # TODO: branching strategy: index-based
"""
get new constraints for node branching
:param node_index: branching node
:return: new constraints list
"""
node = self.nodes[node_index]
if self.problem_type == 'IP':
idx_infeasible, infeasible_solution = node.infeasible_list[0]
new_cons_1 = [1 if i == idx_infeasible else 0 for i in range(self.num_var)] + [
-1, math.floor(infeasible_solution)]
new_cons_2 = [1 if i == idx_infeasible else 0 for i in range(self.num_var)] + [
1, math.ceil(infeasible_solution)]
return [new_cons_1, new_cons_2]
else:
pass
def _update_incumbent(self, feasible_node: int) -> bool:
"""
update global feasible solution
:param feasible_node: index of new feasible node
:return: if incumbent updated
"""
node = self.nodes[feasible_node]
# get a feasible solution firstly
if self.incumbent is None:
self.incumbent, self.incumbent_node = node.obj, feasible_node
rich.print(f"Get new incumbent {
round(self.incumbent, ACCURACY)} from node {
self.incumbent_node}")
return True
# get a better feasible solution
elif (self.opt_dir == 'min' and node.obj < self.incumbent) or (
self.opt_dir == 'max' and node.obj > self.incumbent):
self.incumbent, self.incumbent_node = node.obj, feasible_node
rich.print(f"Get new incumbent {
round(self.incumbent, ACCURACY)} from node {
self.incumbent_node}")
return True
return False
def _update_bound(self) -> bool:
"""
update global bound
:return: if bound updated
"""
bound, bound_node = None, None
for node_idx in self.leaf_nodes:
node = self.nodes[node_idx]
if node.status == 'relax':
if bound is None:
bound, bound_node = node.obj, node_idx
elif (self.opt_dir == 'min' and node.obj < bound) or (
self.opt_dir == 'max' and node.obj > bound):
bound, bound_node = node.obj, node_idx
if bound is not None:
self.bound, self.bound_node = bound, bound_node
rich.print(f"Get new bound {
round(self.bound, ACCURACY)} from node {
self.bound_node}")
return True
return False
cases.py
Based on the problem format defined above, two integer programming examples are provided:
A simple example:
An NP example:
NP example of the branch and bound algorithm
from algorithm.problem import Problem
CASES = {
}
"""
IP: simple
"""
num_var = 2
mat_cons = [
[1, 1, -1, 5],
[10, 6, -1, 45]
]
var_range = [
[1, 0, 1, 0],
[0, 1, 1, 0]
]
mat_cons += var_range
obj_coef = [5, 4, 1]
CASES['IP-simple'] = Problem(problem_type='IP', num_var=num_var, mat_cons=mat_cons, obj_coef=obj_coef)
"""
IP: NP-hard
"""
num_var = 16
mat_cons = [[2 for _ in range(num_var - 1)] + [1] + [0] + [15]]
var_range = [
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [1] + [0],
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [1] + [0],
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [1] + [0],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [1] + [0],
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [1] + [0],
[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [1] + [0],
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [1] + [0],
[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0] + [1] + [0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0] + [1] + [0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0] + [1] + [0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0] + [1] + [0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0] + [1] + [0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0] + [1] + [0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0] + [1] + [0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0] + [1] + [0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1] + [1] + [0],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [-1] + [1],
[0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [-1] + [1],
[0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [-1] + [1],
[0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [-1] + [1],
[0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [-1] + [1],
[0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [-1] + [1],
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0] + [-1] + [1],
[0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0] + [-1] + [1],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0] + [-1] + [1],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0] + [-1] + [1],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0] + [-1] + [1],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0] + [-1] + [1],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0] + [-1] + [1],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0] + [-1] + [1],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0] + [-1] + [1],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1] + [-1] + [1],
]
mat_cons += var_range
obj_coef = [0 for _ in range(num_var - 1)] + [1] + [-1]
CASES['IP-NP-hard'] = Problem(problem_type='IP', num_var=num_var, mat_cons=mat_cons, obj_coef=obj_coef)
config.py
The configuration file in the root directory, including global variables such as calculation example selection and algorithm accuracy:
from algorithm.cases import CASES
PROBLEM_TYPES = {
'IP'} # TODO: only support IP model so far
# INSTANCE = CASES['IP-simple']
INSTANCE = CASES['IP-NP-hard']
ACCURACY = 3 # 0.001
main.py
Main program:
import rich
from config import ACCURACY
from algorithm.algorithm import BranchAndBound
rich.print()
branch_and_bound = BranchAndBound(time_limit=300, gap_upper=0)
branch_and_bound.run()
status = branch_and_bound.status
solution = [round(x, ACCURACY) for x in branch_and_bound.solution]
optimal_obj = branch_and_bound.incumbent
rich.print(f"algorithm final status: {
status}")
rich.print(f"algorithm final solution: {
solution}")
rich.print(f"algorithm final objective value: {
optimal_obj}")
rich.print()
Calculation running effect
simple example
NP calculation example
Outlook
This article was originally dedicated to writing a simple branch-and-bound algorithm framework applicable to various problems. In fact, it is just for fun. However, due to limited energy, I only completed the part of solving integer programming problems, and the specific strategy inside the algorithm is relatively simple. Therefore, I have a little prospect for more function realization, expecting improvement, correction and communication:
- Richer branch strategy: the code in this article selects the smallest sequence number among all infeasible constraints to branch
- Richer node selection strategy: the code in this article selects the bound node in the leaf node for the next deep search
- Richer problem types: such as using branch-and-bound algorithms to solve problems such as TSP, CBS (conflict-based search), branch-and-price, etc. Of course, the current framework may not be feasible or reasonable, and we look forward to better implementation methods
- Introducing Effective Cut: Improving Algorithm Performance