Al final del módulo 5 dijimos que dejábamos de afilar herramientas para salir al mundo. Esta lección es la primera salida: vamos a ver dónde vive la optimización en la industria real —logística, manufactura, energía, planificación de personal— y, sobre todo, vamos a resolver de principio a fin "un día en la operativa de Rutalia" encadenando piezas que ya construiste: el clustering de 05-05, el algoritmo húngaro de 03-06 y el TSP del módulo 2, al que añadiremos una técnica nueva y ligera (la mejora 2-opt). El objetivo ya no es entender un algoritmo, sino algo más difícil y más valioso: modelar — traducir un problema de negocio a los algoritmos correctos, elegir entre alternativas y medir la mejora en cada paso.

Contenido

  1. El mapa de la optimización industrial
  2. El caso central: un día en la operativa de Rutalia
  3. Paso 1 — Agrupar pedidos en zonas de reparto (clustering)
  4. Paso 2 — Asignar repartidores a zonas (algoritmo húngaro)
  5. Paso 3 — Ordenar las paradas dentro de cada zona (TSP + 2-opt)
  6. Medir la mejora: el pipeline completo con métricas
  7. Más allá del caso: turnos como PL, y el VRP como generalización real
  8. La lección de ingeniería: "suficientemente bueno hoy"

El mapa de la optimización industrial

La optimización industrial no es un algoritmo: es una disciplina de modelado. En cada sector el problema de negocio suena distinto, pero debajo casi siempre hay un problema canónico que ya conoces de este curso:

Dominio Problema de negocio Problema canónico Algoritmo del curso
Logística ¿En qué orden visito mis paradas? TSP / VRP PD y B&B (02-02, 02-03), genéticos (02-04), hormigas (02-05)
Logística ¿Cómo lleno furgonetas sin desperdiciar espacio? Bin packing FFD y voraces (02-02)
Manufactura ¿Qué mezcla de productos fabrico con recursos limitados? Programación lineal Símplex con scipy.linprog (02-01)
Manufactura ¿En qué orden paso trabajos por las máquinas? Scheduling (job shop) Voraces, backtracking, B&B (02-02, 02-03)
Energía ¿Qué centrales enciendo cada hora? Unit commitment (PLE) PL entera (02-01) + heurísticas (02-04)
Energía / telecom ¿Qué red mínima conecta todos los nodos? Árbol de expansión mínima Kruskal/Prim (03-04)
Personal ¿Cuántas personas por turno y quién cubre cada tarea? Cobertura / asignación PL/PLE (02-01), húngaro (03-06)
Transporte ¿Cuánto caudal soporta mi red y dónde está el cuello de botella? Flujo máximo / min-cut Edmonds-Karp (03-05)

Fíjate en el patrón: la columna difícil no es la última (los algoritmos ya los tienes), sino la tercera. Reconocer el problema canónico dentro del problema de negocio es el 80 % del trabajo. Un especialista en optimización pasa más tiempo preguntando "¿qué restricciones son de verdad duras y cuáles negociables?" que programando.

flowchart LR
    A[Problema de negocio] --> B[Modelado:<br>variables, restricciones, objetivo]
    B --> C[Problema canónico<br>reconocido]
    C --> D[Algoritmo exacto<br>si el tamaño lo permite]
    C --> E[Heurística / metaheurística<br>si no]
    D --> F[Medir contra la<br>situación actual]
    E --> F
    F -->|no mejora o rompe restricciones| B

Ese bucle de vuelta (medir → remodelar) es lo que distingue la optimización real de los ejercicios de libro: el primer modelo casi nunca captura todas las restricciones que el negocio daba por obvias.

El caso central: un día en la operativa de Rutalia

Situación: son las 7:00 y Rutalia tiene 60 pedidos pendientes para hoy, repartidos por la ciudad, y 4 repartidores disponibles, cada uno con distinto conocimiento de cada parte de la ciudad. La operativa actual —la que queremos batir— es artesanal: un coordinador reparte los pedidos "a ojo" por orden de llegada y cada repartidor improvisa su ruta.

El plan algorítmico encadena tres decisiones, cada una con su algoritmo:

  1. ¿Qué pedidos van juntos? → clustering k-means (05-05) con k = 4 zonas.
  2. ¿Qué repartidor lleva cada zona? → asignación óptima con el algoritmo húngaro (03-06).
  3. ¿En qué orden visita cada repartidor sus paradas? → TSP heurístico: vecino más cercano (02-02) + mejora 2-opt (nueva en esta lección).

Cada paso reduce el coste total y lo mediremos. Primero, los datos ficticios (coordenadas en km sobre la cuadrícula de la ciudad que usamos desde 01-03):

import random
import math

random.seed(42)

# 60 pedidos: id y coordenadas (km) en una ciudad de 10x10.
# Los generamos alrededor de 4 focos de demanda, como los perfiles
# de zona que descubrimos con clustering en 05-05.
focos = [(2, 2), (8, 3), (3, 8), (7.5, 7.5)]
pedidos = []
for i in range(60):
    fx, fy = focos[i % 4]
    pedidos.append({
        "id": f"P-{i+1:03d}",
        "x": fx + random.gauss(0, 1.0),
        "y": fy + random.gauss(0, 1.0),
    })

DEPOSITO = (5.0, 5.0)   # almacén central, el papel del DEP en el TSP de 02-02

def dist(a, b):
    return math.hypot(a[0] - b[0], a[1] - b[1])

La métrica de negocio será kilómetros totales recorridos (todos los repartidores: salida del depósito, todas sus paradas y vuelta). Menos kilómetros = menos combustible, menos horas y más entregas por jornada.

La línea base. Para saber si mejoramos necesitamos un punto de comparación honesto: simulamos la operativa "a ojo" repartiendo los pedidos en 4 bloques por orden de llegada (sin criterio geográfico) y visitándolos en ese mismo orden.

def coste_ruta(paradas):
    """Km de una ruta DEPOSITO -> paradas en orden -> DEPOSITO."""
    if not paradas:
        return 0.0
    puntos = [DEPOSITO] + [(p["x"], p["y"]) for p in paradas] + [DEPOSITO]
    return sum(dist(puntos[i], puntos[i + 1]) for i in range(len(puntos) - 1))

# Línea base: 4 bloques de 15 pedidos por orden de llegada
bloques = [pedidos[i::4] for i in range(4)]
base = sum(coste_ruta(b) for b in bloques)
print(f"Línea base (reparto a ojo): {base:.1f} km")

Con la semilla fija, la línea base ronda los 330 km. Todo lo que hagamos a partir de aquí se compara contra ese número.

Paso 1 — Agrupar pedidos en zonas de reparto (clustering)

En 05-05 implementaste k-means y k-means++ desde cero; aquí lo usamos como pieza, sin re-explicar su mecánica. La decisión de modelado es otra: ¿por qué clustering y no, por ejemplo, un reparto equitativo de 15 pedidos por cabeza?

  • Porque el coste dominante del reparto son los desplazamientos entre pedidos: pedidos cercanos deben ir juntos.
  • Porque k-means minimiza exactamente eso: la dispersión intra-grupo.
  • El precio: los grupos pueden quedar desequilibrados (18 pedidos en una zona, 11 en otra). En Rutalia lo aceptamos hoy; si fuera inaceptable, haría falta un modelo con restricción de capacidad (lo retomamos al hablar del VRP).
def kmeans(puntos, k, iters=100):
    """k-means clásico (visto en 05-05), versión compacta."""
    centroides = random.sample(puntos, k)
    for _ in range(iters):
        grupos = [[] for _ in range(k)]
        for p in puntos:
            j = min(range(k), key=lambda c: dist(p, centroides[c]))
            grupos[j].append(p)
        nuevos = [
            (sum(p[0] for p in g) / len(g), sum(p[1] for p in g) / len(g))
            if g else centroides[j]
            for j, g in enumerate(grupos)
        ]
        if nuevos == centroides:
            break
        centroides = nuevos
    return centroides

coords = [(p["x"], p["y"]) for p in pedidos]
centroides = kmeans(coords, k=4)

# Reconstruimos las zonas como listas de pedidos
zonas = [[] for _ in range(4)]
for p in pedidos:
    j = min(range(4), key=lambda c: dist((p["x"], p["y"]), centroides[c]))
    zonas[j].append(p)

paso1 = sum(coste_ruta(z) for z in zonas)
print(f"Tras clustering (rutas aún sin ordenar): {paso1:.1f} km")
for j, z in enumerate(zonas):
    print(f"  Zona {j}: {len(z)} pedidos, centroide ({centroides[j][0]:.1f}, {centroides[j][1]:.1f})")

Solo con agrupar bien —sin haber ordenado todavía ninguna ruta— el total baja típicamente a unos 210 km: cada repartidor ya no cruza la ciudad entera. Primera lección de modelado: la decisión de nivel superior (qué va con qué) suele mover más la aguja que la optimización fina posterior.

Paso 2 — Asignar repartidores a zonas (algoritmo húngaro)

Tenemos 4 zonas y 4 repartidores, pero no son intercambiables: cada repartidor conoce mejor unas partes de la ciudad (menos tiempo perdido, menos incidencias). Modelamos ese conocimiento como una matriz de coste repartidor × zona (minutos extra estimados por jornada) y resolvemos la asignación óptima con el método húngaro que ya usaste vía scipy en 03-06.

import numpy as np
from scipy.optimize import linear_sum_assignment

repartidores = ["R-01", "R-02", "R-03", "R-04"]
# coste[i][j]: minutos extra estimados si el repartidor i opera la zona j.
# Datos ficticios: en la práctica saldrían del histórico de entregas
# por zona, con los modelos de regresión de 05-03.
coste = np.array([
    [12, 35, 28, 40],
    [30, 10, 33, 25],
    [27, 32, 11, 30],
    [38, 24, 29,  9],
])

filas, cols = linear_sum_assignment(coste)
total = coste[filas, cols].sum()
peor = sum(coste[i].max() for i in range(4))  # asignación pésima, como referencia
for i, j in zip(filas, cols):
    print(f"{repartidores[i]} -> Zona {j} ({coste[i][j]} min extra)")
print(f"Coste de la asignación óptima: {total} min (la peor posible: {peor} min)")

Aquí la matriz es amigable y el óptimo (42 min) se ve a simple vista, pero ese es justo el punto: con 4×4 lo ves a ojo; con 40 repartidores y 40 zonas ya no, y el húngaro sigue dándote el óptimo exacto en O(n³). Fíjate también en de dónde salen los números de la matriz: de estimaciones sobre el histórico — la optimización de este módulo consume las predicciones del módulo 5. Los módulos no eran compartimentos: eran capas.

Paso 3 — Ordenar las paradas dentro de cada zona (TSP + 2-opt)

Cada zona tiene ~15 paradas más el depósito. En 02-02 viste que el TSP exacto por PD (Held-Karp) es O(2ⁿ·n²): con 15 paradas aún es viable pero lento, y con 25 imposible. La receta industrial estándar para tamaños medios es más humilde y muy eficaz: construir una ruta rápida con el vecino más cercano (el voraz de 02-02, contraejemplo incluido) y mejorarla con 2-opt.

2-opt, la técnica nueva de esta lección. La idea cabe en una frase: si la ruta se cruza sobre sí misma, descruzarla siempre la acorta. Formalmente: toma dos aristas de la ruta, (a→b) y (c→d), elimínalas y reconecta como (a→c) y (b→d), invirtiendo el tramo entre b y c. Si la suma de las dos aristas nuevas es menor que la de las viejas, la ruta mejora. Se repite hasta que ningún intercambio mejore (óptimo local).

flowchart LR
    subgraph Antes[Antes: la ruta se cruza]
        A1((a)) --> B1((b))
        B1 -. tramo .-> C1((c))
        C1 --> D1((d))
    end
    subgraph Despues[Después del intercambio 2-opt]
        A2((a)) --> C2((c))
        C2 -. tramo invertido .-> B2((b))
        B2 --> D2((d))
    end
    Antes --> Despues
def vecino_mas_cercano(paradas, origen=DEPOSITO):
    """Construcción voraz: siempre a la parada más cercana (02-02)."""
    pendientes = paradas[:]
    ruta, actual = [], origen
    while pendientes:
        sig = min(pendientes, key=lambda p: dist(actual, (p["x"], p["y"])))
        pendientes.remove(sig)
        ruta.append(sig)
        actual = (sig["x"], sig["y"])
    return ruta

def mejora_2opt(ruta):
    """Descruza la ruta hasta alcanzar un óptimo local 2-opt."""
    def pts(r):
        return [DEPOSITO] + [(p["x"], p["y"]) for p in r] + [DEPOSITO]
    mejorado = True
    while mejorado:
        mejorado = False
        P = pts(ruta)
        for i in range(len(ruta) - 1):
            for j in range(i + 1, len(ruta)):
                # aristas actuales: P[i]->P[i+1] y P[j+1]->P[j+2]
                antes = dist(P[i], P[i + 1]) + dist(P[j + 1], P[j + 2])
                despues = dist(P[i], P[j + 1]) + dist(P[i + 1], P[j + 2])
                if despues < antes - 1e-9:
                    ruta[i:j + 1] = reversed(ruta[i:j + 1])  # invierte el tramo
                    P = pts(ruta)
                    mejorado = True
    return ruta

total_nn, total_2opt = 0.0, 0.0
for j, zona in enumerate(zonas):
    ruta = vecino_mas_cercano(zona)
    km_nn = coste_ruta(ruta)
    ruta = mejora_2opt(ruta)
    km_2opt = coste_ruta(ruta)
    total_nn += km_nn
    total_2opt += km_2opt
    print(f"Zona {j}: vecino más cercano {km_nn:.1f} km -> 2-opt {km_2opt:.1f} km")
print(f"Total NN: {total_nn:.1f} km | Total tras 2-opt: {total_2opt:.1f} km")

Detalles que importan al leer el código:

  • La condición despues < antes - 1e-9 evita bucles infinitos por errores de redondeo en coma flotante: solo aceptamos mejoras estrictas.
  • Cada pasada evalúa O(n²) intercambios; con 15 paradas es instantáneo. Con cientos de paradas, 2-opt sigue sirviendo pero conviene acelerarlo (listas de vecinos cercanos) o escalar a metaheurísticas: los genéticos (02-04) y la colonia de hormigas (02-05) son exactamente la evolución natural cuando 2-opt se estanca en un óptimo local pobre o la instancia crece.
  • 2-opt da un óptimo local, no global — la misma limitación que viste en k-means (05-05) y en el descenso de gradiente (05-03). Es un tema recurrente del curso: casi toda la optimización práctica es "mejora local + saber cuándo conformarse".

Medir la mejora: el pipeline completo con métricas

Recapitulemos el día de Rutalia con números (los tuyos variarán algo con otra semilla; el orden de magnitud es lo estable):

Etapa Algoritmo Lección Km totales Mejora acumulada
Línea base "a ojo" ~330
Zonas coherentes k-means 05-05 ~210 ~36 %
Rutas construidas Vecino más cercano 02-02 ~105 ~68 %
Rutas pulidas 2-opt 06-01 ~92 ~72 %
Repartidor ↔ zona Húngaro 03-06 42 min extra (vs 141 en el peor caso) (métrica en minutos)

Tres observaciones de ingeniería:

  • Cada capa se mide por separado. Si mañana el negocio pregunta "¿qué pasa si quito el 2-opt para simplificar?", la respuesta es un número (~13 km/día), no una opinión.
  • La ganancia marginal decrece. El clustering ahorró ~120 km; el 2-opt, ~13. Esa curva te dice dónde invertir el siguiente esfuerzo.
  • La línea base honesta es sagrada. Compararse contra una línea base artificialmente mala infla la mejora y destruye la credibilidad del proyecto. Es el equivalente operativo de la trampa de la exactitud de 05-02.

Más allá del caso: turnos como PL, y el VRP como generalización real

Planificación de turnos. El otro gran clásico industrial se modela con la programación lineal de 02-01. Ejemplo mínimo: Rutalia necesita cubrir una demanda de repartidores por franja (mañana 6, mediodía 10, tarde 8, noche 3) con turnos de 8 horas que cubren dos franjas consecutivas (el turno de noche enlaza con la mañana). Variables: cuántas personas empiezan en cada franja; objetivo: minimizar plantilla; restricciones: cada franja cubierta.

from scipy.optimize import linprog

# x[i] = personas que empiezan su turno en la franja i (cubren la i y la i+1)
demanda = [6, 10, 8, 3]
# Cobertura de la franja f: x[f-1] + x[f] >= demanda[f]
# linprog minimiza con A_ub @ x <= b_ub, así que negamos ambos lados.
A_ub = [
    [-1,  0,  0, -1],   # franja 0: la cubren x0 y x3 (noche que enlaza)
    [-1, -1,  0,  0],   # franja 1: x0 y x1
    [ 0, -1, -1,  0],   # franja 2: x1 y x2
    [ 0,  0, -1, -1],   # franja 3: x2 y x3
]
b_ub = [-d for d in demanda]
res = linprog(c=[1, 1, 1, 1], A_ub=A_ub, b_ub=b_ub,
              bounds=[(0, None)] * 4, integrality=[1, 1, 1, 1])
print("Inicios por franja:", res.x, "| plantilla mínima:", res.fun)

El parámetro integrality pide soluciones enteras (no puedes contratar 2,5 repartidores): es la PL entera que en 02-01 vimos que dispara la complejidad teórica, pero que los solvers modernos manejan sin despeinarse a estos tamaños.

El VRP. Nuestro pipeline (clustering + TSP por zona) es en realidad una heurística clásica —cluster first, route second— para el Vehicle Routing Problem, la generalización industrial del TSP. El VRP añade lo que el TSP ignora: capacidad de cada vehículo (kilos, volumen), ventanas de tiempo de entrega ("entre 9:00 y 11:00"), flotas heterogéneas, varias pasadas por el depósito. Es NP-duro con agravantes, y en la práctica casi nadie lo resuelve desde cero: se usan solvers especializados como Google OR-Tools (gratuito y omnipresente en logística), que combinan por dentro construcción voraz, búsqueda local tipo 2-opt/3-opt y metaheurísticas — exactamente las familias que has aprendido. Conocer lo que hay dentro de la caja es lo que te permite configurarla bien y detectar cuándo su respuesta no tiene sentido.

La lección de ingeniería: "suficientemente bueno hoy"

En 02-02 calculamos el óptimo exacto del TSP de 9 paradas (35,22 km) porque el tamaño lo permitía. Hoy, con 60 pedidos y las furgonetas saliendo a las 8:00, la pregunta correcta no es "¿cuál es el óptimo?" sino "¿cuánta mejora me cabe antes de las 8:00?". La regla de oro de la optimización industrial:

Una solución un 5 % peor que el óptimo, disponible a tiempo, vale infinitamente más que el óptimo que llega tarde. El óptimo es el patrón de medida, no siempre el objetivo.

Y una advertencia que no es retórica: los resultados de estos modelos son recomendaciones. Antes de aplicarlos a una operativa real (rutas, turnos, asignaciones de personas) deben validarlos quienes conocen el terreno y las obligaciones del negocio —normativa laboral, restricciones de tráfico, compromisos con clientes—, porque el modelo solo optimiza lo que se le puso en la función objetivo, y la realidad siempre tiene restricciones que nadie escribió. La decisión final es humana.

Errores Comunes y Consejos

  • Optimizar sin línea base. Si no mides la situación actual con la misma métrica, no puedes afirmar que mejoraste. Es el error número uno en proyectos reales.
  • Confundir el óptimo del modelo con el óptimo del negocio. El modelo minimiza kilómetros; el negocio quizá valora más la puntualidad. Pregunta qué se optimiza de verdad antes de escribir la función objetivo.
  • Aplicar 2-opt sin tolerancia numérica. Aceptar "mejoras" de tamaño 1e-15 provoca bucles infinitos por redondeo. Usa siempre despues < antes - epsilon.
  • Encadenar etapas sin revisar el acople. Cluster first, route second es una heurística: un clustering óptimo puede inducir rutas mediocres. Si el resultado global decepciona, prueba a variar k o a mover pedidos frontera entre zonas y re-rutar.
  • Reinventar el VRP. Para problemas con capacidades y ventanas de tiempo, evalúa OR-Tools u otro solver antes de escribir el tuyo: tu valor está en el modelado y la validación, no en reimplementar búsqueda local madura.
  • Consejo: fija las semillas aleatorias (random.seed) en los experimentos. Sin reproducibilidad no hay comparación justa entre variantes.

Ejercicios

  1. Sensibilidad al número de zonas. Ejecuta el pipeline completo con k = 3, 4, 5 y 6 zonas (con su correspondiente número de repartidores) y construye la tabla k → km totales tras 2-opt. ¿Sigue bajando el total al subir k? ¿Qué coste oculto tiene subir k que los kilómetros no reflejan?
  2. El voraz engañado, revisitado. Construye una zona de 12 paradas donde el vecino más cercano produzca una ruta al menos un 15 % peor que la que se obtiene tras aplicar 2-opt (pista: recuerda el contraejemplo del voraz de 02-02 — paradas casi en línea con una trampa cerca del origen). Verifícalo con coste_ruta.
  3. Turnos con techo. Amplía el modelo de turnos a 6 franjas de 4 horas con demanda [4, 6, 10, 9, 7, 3], turnos que cubren dos franjas consecutivas (el de la franja 5 enlaza con la 0) y la restricción adicional de que en ninguna franja puede haber más de 12 personas activas. Resuélvelo con linprog e integrality.

Soluciones

  1. Con los focos del ejemplo, k=4 coincide con la estructura real de la demanda y da el mejor equilibrio; k=5 y k=6 apenas reducen kilómetros (a veces incluso los suben, porque más zonas implican más viajes depósito↔zona) y añaden el coste oculto de un repartidor y un vehículo más por zona. La curva km-vs-k tiene forma de codo — exactamente el método del codo que usaste para elegir k en 05-05: el mismo criterio estadístico resulta ser un criterio de negocio.
  2. Un patrón que funciona: depósito en (0,0), una parada "cebo" en (0.5, 0.1) y el resto casi colineales en x = 1..10 (y≈0), con una parada final en (10, 3). El vecino más cercano muerde el cebo, recorre la línea y el retorno cruza toda la ciudad; también suele zigzaguear entre paradas casi equidistantes. 2-opt descruza esos retornos. Midiendo con coste_ruta, la mejora supera con holgura el 15 %. La moraleja de 02-02 se mantiene: el voraz es un buen constructor, no un buen terminador.
  3. Con cobertura circular, la restricción de demanda de la franja f es x[(f-1) % 6] + x[f] >= demanda[f] (filas con -1 en esas dos posiciones y b_ub = -demanda[f]), y la de techo es la misma pareja con signo positivo y b_ub = 12. Son 12 filas en total. Con la demanda dada, el mínimo entero factible ronda las 20 personas; comprueba en la solución que la franja de demanda 10 queda cubierta casi exacta y que ninguna supera 12 — cuando una restricción de techo y una de demanda aprietan a la vez, el solver "desplaza" inicios de turno hacia franjas valle.

Conclusión

Has visto el catálogo de la optimización industrial y has resuelto un día completo de Rutalia encadenando tres algoritmos de tres módulos distintos: clustering (05-05) para decidir qué va junto, el húngaro (03-06) para decidir quién hace qué, y vecino más cercano + 2-opt (02-02 y esta lección) para decidir en qué orden — con una métrica midiendo cada eslabón y una mejora total cercana al 70 % sobre la operativa artesanal. Las ideas que debes llevarte: reconocer el problema canónico es el 80 % del trabajo, las decisiones de nivel alto mueven más la aguja que el pulido fino, y "suficientemente bueno a tiempo" gana al óptimo tardío. En la próxima lección cambiamos de dominio pero no de método: las redes sociales como grafos, donde el BFS, las componentes y el clustering que ya dominas se convierten en detección de comunidades, medición de influencia —con PageRank como caso estrella— y recomendación de amistades.

© Copyright 2026. Todos los derechos reservados