Optimización en Python y PuLP
Este artículo es la versión escrita de un curso corto de Python para optimización y logística al que fui invitado a dictar en varias ocasiones en la Universidad San Francisco Xavier de Chuquisaca (USFX), en Sucre, Bolivia. El curso cubre cuatro problemas clásicos en programación lineal: la ubicación de dos hospitales, el problema de las p-medianas, el problema del vendedor viajero y el ruteo de vehículos.
Las herramientas
Los modelos usan PuLP, una librería de Python para escribir programas lineales, que funciona como un wrapper para GLPK, una excelente herramienta de código abierto para resolver mixed integer linear programming (MILP) problems.
python -m pip install pulp
PuLP también incluye el optimizador CBC. Si GLPK no está instalado, reemplace
GLPK(options=['--mipgap', '0.01']) en el código de abajo por
PULP_CBC_CMD(gapRel=0.01). Ambos se detienen cuando la solución está a menos de un 1 % del punto óptimo.
Un pequeño archivo auxiliar, utils.py, crea las variables en gropo y las ejecuta el optimizador (me parece más sencillo leer el código con esos helpers):
from pulp import *
def create_variable_matrix(i, j, name="x_{i}_{j}", **other_args):
return [[LpVariable(name.format(i=a, j=b), **other_args) for b in range(j)]
for a in range(i)]
def create_variable_array(i, name="y_{i}", **other_args):
return [LpVariable(name.format(i=a), **other_args) for a in range(i)]
def euclidian_distance(point1, point2):
return ((point1[0] - point2[0]) ** 2 + (point1[1] - point2[1]) ** 2) ** 0.5
def create_distance_matrix(points):
return [[euclidian_distance(a, b) for b in points] for a in points]
def resolve_problem(problem, show_variables=True):
GLPK(options=['--mipgap', '0.01']).solve(problem)
if show_variables:
for v in problem.variables():
if v.varValue != 0:
print(v.name, "=", v.varValue)
return value(problem.objective)
LpVariable recibe tres parámetros que son suficientemente relevantes para nosotros: cat, el tipo de variable
('Continuous' o 'Integer'); lowBound, su valor mínimo (\(-\infty\) si se omite); y
upBound, su valor máximo (\(+\infty\) si se omite).
Una variable entera entre 0 y 1 es una decisión de sí o no. Una matriz de \(5 \times 10\) de ellas, y una lista de 17 enteros no negativos se puede describir así:
f = create_variable_matrix(5, 10, name="F_{i}_{j}", cat='Integer', lowBound=0, upBound=1)
l = create_variable_array(17, name="L_{i}", cat='Integer', lowBound=0)
Un modelo es un objeto de tipo LpProblem. Tiene el operador += overloaded, y permite una interesante sintaxis para añadir restricciones: model += constraint:
problem = LpProblem("mi_modelo", LpMinimize) # cambiando LpMaximize
Modelo A: dos hospitales
Siete barrios necesitan dos nuevos hospitales de primer nivel, cada uno construido en uno de los barrios. ¿Dónde deberían ir?
Una buena respuesta deja a cada barrio cerca del hospital que lo atiende, así que el objetivo es la suma de las distancias de cada barrio a su hospital. Para eso necesitamos modelar esas condiciones con dos tipos de decisiones:
- \(x_{ij} = 1\) si el barrio \(i\) es atendido por un hospital en el barrio \(j\), y \(0\) en otro caso, para \(0 \le i, j \le 6\).
- \(y_j = 1\) si se construye un hospital en el barrio \(j\), y \(0\) en otro caso.
problem = LpProblem("two_hospital_problem", LpMinimize)
x = create_variable_matrix(7, 7, cat='Integer', lowBound=0, upBound=1)
y = create_variable_array(7, name="y_{i}", cat='Integer', lowBound=0, upBound=1)
Con \(d_{ij}\) la distancia entre los barrios \(i\) y \(j\), la función objetivo es
Como consecuencia, la función objetivo necesita enlazar todas las \(7 * 7\) combinaciones:
objective = (0.0 * x[0][0] + 20.0 * x[0][1] + 20.0 * x[0][2] + 28.3 * x[0][3]
+ 14.1 * x[0][4] + 22.4 * x[0][5] + 22.4 * x[0][6]
+ 20.0 * x[1][0] + ...) # 49 términos en total
problem += objective
Hay una solución trivial usando solo este objetivo: \(x=0\). Eso quiere decir que la solución que minimiza la distancia entre los hospitales y los vecindarios es no construir ningún hospital. Y claramente, esa no es la solución que quisieramos. Así que necesitamos restricciones adicionales para asegurar que una solución sea válida:
- Cada barrio debe ser atendido por un solo hospital: \(\sum_{j} x_{ij} = 1\) para todo \(i\). Para el barrio 0, eso es \(x_{0,0} + x_{0,1} + \dots + x_{0,6} = 1\), y lo mismo vale para los otros seis.
- Debe haber exactamente dos hospitales: \(\sum_{j} y_j = 2\).
- Un barrio solo puede ser atendido por un hospital que existe: si \(x_{ij} = 1\), entonces \(y_j\) debe ser 1. Podemos escribir esto como una restricción lineal, \(x_{ij} \le y_j\) para todo \(i\) y \(j\).
for i in range(7): # 1. un hospital por barrio
problem += lpSum(x[i][j] for j in range(7)) == 1
problem += lpSum(y) == 2 # 2. dos hospitales
for i in range(7): # 3. solo hospitales que existen
for j in range(7):
problem += x[i][j] <= y[j]
resolve_problem(problem, show_variables=True)
Después de ejecutar el código, GLPK muestra su progreso, con las siguientesvariables que no son cero:
x_0_4 = 1
x_1_5 = 1
x_2_4 = 1
x_3_5 = 1
x_4_4 = 1
x_5_5 = 1
x_6_4 = 1
Los hospitales deberían ser construido en los barrios 4 y 5. Los barrios 0, 2, 4 y 6 usan el hospital del barrio 4, y los barrios 1, 3 y 5 el del barrio 5. La distancia total es 58,2.
Modelo B: el problema de las p-medianas
El modelo de los dos hospitales es un caso particular de un problema clásico de localización. Hay \(n\) clientes que atender y \(m\) lugares donde podría abrirse una agencia. El problema de las p-medianas abre \(p\) de ellas y asigna cada cliente a una agencia abierta, de forma que la suma de las distancias de los clientes a sus agencias sea la menor posible.
Las variables son idénticas a las del modelo A: \(y_j = 1\) si se abre una agencia en \(j\), y \(x_{ij} = 1\) si el cliente \(i\) es atendido desde \(j\). El modelo también es el mismo, con \(p\) en lugar de 2:
Esta vez, podemos escribir la función objetivo con un solo bucle, así que el código sirve para cualquier número de puntos y cualquier \(p\). Para hacer el ejemplo simple, vamos a asumir que cada cliente es también es un lugar candidato, así que \(m = n\):
def p_median_problem(distance_matrix, facilities):
n = len(distance_matrix)
problem = LpProblem("p_median_problem", LpMinimize)
x = create_variable_matrix(n, n, cat='Integer', lowBound=0, upBound=1)
y = create_variable_array(n, name="y_{i}", cat='Integer', lowBound=0, upBound=1)
problem += lpSum(distance_matrix[i][j] * x[i][j] for i in range(n) for j in range(n))
for i in range(n): # cada cliente atendido una vez
problem += lpSum(x[i][j] for j in range(n)) == 1
problem += lpSum(y) == facilities # exactamente p agencias
for i in range(n): # solo desde agencias abiertas
for j in range(n):
problem += x[i][j] <= y[j]
return resolve_problem(problem)
Modelo C: el vendedor viajero
Este es uno de los modelos más clásicos en programación lineal, teoría de grafos y optimización: dadas \(n\) ciudades donde se conoce las distancias entre ellas, el problema del vendedor viajero (en inglés, traveling salesman problem) busca la ruta más corta que parte de una ciudad, visita cada una de las otras exactamente una vez y vuelve al punto de partida.
Sea \(x_{ij} = 1\) si la ruta va directamente de la ciudad \(i\) a la ciudad \(j\), y \(0\) en otro caso. Así qeu la función objetivo es la longitud de la ruta:
A simple vista bastan dos restricciones (que al mismo tiempo resultan intuitivas): a cada ciudad llega un camino, y de cada ciudad sale un camino.
for k in range(n):
problem += lpSum(x[i][k] for i in range(n)) == 1 # un ruta llega
problem += lpSum(x[k][j] for j in range(n)) == 1 # un ruta sale
¿Son suficientes esas dos condiciones? ¡¡No!!. Esto es lo que devuelve el optimizador cuando solamente nos basamos en estas dos restricciones:
La respuesta es correcta para el modelo: a cada ciudad llega exactamente un camino y sale exactamente uno. Nada informa al modelo que los caminos deban formar un solo ciclo, y varios ciclos cortos cuestan menos que uno largo. A estos ciclos se les llamar subtours.
Hay varias formas de eliminarlos. Aquí vamos a usar una formulación usando flujos (Gavish&Graves, 1978) que solucionó el problema de los subtours usando un truco bastante ingenioso. Su idea fue añadir una variable más: \(f_{ij} \ge 0\), una cantidad de flujo enviada por el camino de \(i\) a \(j\). Esa cantidad no se mide ni aparece directamente en nuestra función objetivo, y funciona como una variable que es construida mientras el modelo se ejecuta (como si fuese una variable temporal en un lenguaje de programación). Esto nos permite modelar que si la ciudad 1 envía \(n - 1\) unidades, y cada una de las demás se queda exactamente con una:
El flujo solo puede viajar por los caminos que la ruta usa, así que como ningún camino necesita llevar más de \(n - 1\) unidades,
Un subtour que no pasa por la ciudad 1 no tiene ningún camino que le traiga flujo desde afuera, y aun así cada una de sus ciudades debe quedarse con una unidad. Eso es imposible, así que todas las ciudades tienen que estar en el mismo ciclo que la ciudad 1. Por la misma razón, una ciudad no puede formar un ciclo consigo misma (\(x_{kk} = 1\)), y por eso el código no necesita excluir esas variables.
def travelling_salesman_problem(distance_matrix):
n = len(distance_matrix)
problem = LpProblem("travelling_salesman_problem", LpMinimize)
x = create_variable_matrix(n, n, cat='Integer', lowBound=0, upBound=1)
f = create_variable_matrix(n, n, name="f_{i}_{j}", cat='Integer', lowBound=0)
problem += lpSum(distance_matrix[i][j] * x[i][j] for i in range(n) for j in range(n))
for k in range(n):
problem += lpSum(x[i][k] for i in range(n)) == 1 # un camino llega
problem += lpSum(x[k][j] for j in range(n)) == 1 # un camino sale
for k in range(1, n): # cada ciudad se queda con una unidad
problem += lpSum(f[i][k] for i in range(n)) - lpSum(f[k][j] for j in range(n)) == 1
for i in range(n): # flujo solo por caminos usados
for j in range(n):
if i != j:
problem += f[i][j] <= (n - 1) * x[i][j]
GLPK(options=['--mipgap', '0.01', '--tmlim', '30']).solve(problem)
return value(problem.objective)
La opción --tmlim 30 detiene GLPK a los 30 segundos, así que en instancias más grandes
el resultado es la mejor ruta encontrada en ese tiempo, no necesariamente la más corta.
Modelo D: ruteo de vehículos
El problema de ruteo de vehículos (en inglés, vehicle routing problem) podría pensarse como una expansión del modelo anterior, donde tenemos más viajeros que solamente pueden llevar una carga máxima de productos. Un depósito, el punto 0, atiende a \(n\) clientes, numerados del 1 al \(n\). El cliente \(k\) necesita \(q_k\) unidades de mercancía, y el depósito ninguna (\(q_0 = 0\)). Hay tantos vehículos como haga falta, y cada uno lleva como máximo \(Q\) unidades. El objetivo es el conjunto de rutas más barato tal que:
- toda ruta empieza y termina en el depósito,
- la demanda de cada cliente es atendida, y
- ninguna ruta lleva más de \(Q\) unidades en total.
Podemos añadir algunas definiciones adicionales: \(c_{ij}\) como el costo de ir de \(i\) a \(j\), las variables son \(x_{ij} = 1\) si un vehículo va de \(i\) a \(j\), y \(f_{ij} \ge 0\), la cantidad de mercancía que se lleva de \(i\) a \(j\), así que el modelo sería:
Línea por línea el modelo tiene una interpretación como la que dimos anteriomente:
- Cada cliente sale un vehículo y a cada cliente llega uno
- Al depósito vuelven tantos vehículos como salen
- Cada cliente se queda exactamente con su demanda de la mercancía que pasa por él
- Y, finalmente, ningún camino lleva más de lo que cabe en un vehículo.
Las restricciones de flujo cumplen el mismo papel que en el vendedor viajero: la mercancía solo puede salir del depósito, así que toda ruta tiene que pasar por él.
def vehicle_routing_problem(distance_matrix, demand, vehicle_capacity):
n = len(distance_matrix)
problem = LpProblem("vehicle_routing_problem", LpMinimize)
x = create_variable_matrix(n, n, cat='Integer', lowBound=0, upBound=1)
f = create_variable_matrix(n, n, name="f_{i}_{j}", cat='Integer', lowBound=0)
problem += lpSum(distance_matrix[i][j] * x[i][j] for i in range(n) for j in range(n))
for k in range(1, n):
problem += lpSum(x[k][j] for j in range(n)) == 1 # sale un vehículo
problem += lpSum(x[i][k] for i in range(n)) == 1 # llega un vehículo
problem += (lpSum(x[0][j] for j in range(1, n)) # depósito: salen tantos como llegan
== lpSum(x[i][0] for i in range(1, n)))
for k in range(1, n): # cada cliente se queda con q_k
problem += lpSum(f[i][k] for i in range(n)) - lpSum(f[k][j] for j in range(n)) == demand[k]
for i in range(n): # capacidad del vehículo
for j in range(n):
if i != j:
problem += f[i][j] <= vehicle_capacity * x[i][j]
return resolve_problem(problem)
En el ejemplo cada cliente necesita una unidad y un vehículo lleva cinco, así que los nueve clientes necesitan al menos dos vehículos. El mejor plan usa exactamente dos, que podrían hacer sus entregas al mismo tiempo.