Este artículo toma como ejemplo la resolución del modelo de programación de enteros y proporciona el marco de código de Python del algoritmo de ramificación y límite, buscando la perfección, la corrección y la comunicación.
estructura de archivos
código específico
problema.py
Defina el formato de la pregunta:
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
Defina la clase base para los módulos de resolución de problemas:
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
Herede la clase base del módulo de resolución y escriba una subclase de módulo de resolución que llame al solucionador GLOP de OR-Tools para problemas de programación lineal:
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.")
nodo.py
Módulos de nodo para algoritmos de ramificación y límite:
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))
algoritmo.py
El módulo de algoritmo del algoritmo de ramificación y límite, los puntos principales:
- Cada vez que se obtenga una solución factible, actualizar la solución factible óptima (incumbente)
- Cada vez que se completa una rama (se resuelven todos los nodos secundarios), actualice el límite global (límite)
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
casos.py
Según el formato del problema definido anteriormente, se proporcionan dos ejemplos de programación de enteros:
Un ejemplo sencillo:
Un ejemplo de NP:
ejemplo de NP del algoritmo de ramificación y límite
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
El archivo de configuración en el directorio raíz, incluidas las variables globales, como la selección de ejemplos de cálculo y la precisión del algoritmo:
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
principal.py
Programa principal:
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()
Efecto de ejecución de cálculo
ejemplo sencillo
Ejemplo de cálculo de NP
panorama
Este artículo se dedicó originalmente a escribir un marco de algoritmo simple de ramificación y acotación adecuado para varios problemas. De hecho, es solo por diversión. Sin embargo, debido a la energía limitada, solo completé la parte de resolver problemas de programación entera, y la parte La estrategia específica dentro del algoritmo es relativamente simple. Por lo tanto, tengo pocas posibilidades de realizar más funciones, esperando mejoras, correcciones y comunicación:
- Estrategia de bifurcación más rica: el código de este artículo selecciona el número de secuencia más pequeño entre todas las restricciones inviables para bifurcar
- Estrategia de selección de nodos más rica: el código de este artículo selecciona el nodo enlazado (enlazado) en el nodo hoja para la siguiente búsqueda profunda
- Tipos de problemas más ricos: como el uso del algoritmo de ramificación y límite para resolver TSP, CBS (búsqueda basada en conflictos), ramificación y precio y otros problemas. Por supuesto, el marco actual puede no ser factible o razonable, y esperamos mejores métodos de implementación
- Presentación de corte efectivo: mejora del rendimiento del algoritmo