Tienes una especificación validada; ahora hay que convertirla en código sin morir en el intento. La trampa clásica del autodidacta es empezar por la parte "interesante" (el algoritmo sofisticado) y dejar para el final el pegamento: la carga de datos, la evaluación, la comparación. Resultado habitual: a las 25 horas hay cuatro piezas brillantes que no encajan y ningún número que enseñar. En esta lección haremos lo contrario, siguiendo la técnica del tracer bullet (bala trazadora): primero un pipeline completo de punta a punta con la solución más tonta posible, y después mejoras por capas, midiendo tras cada una. Lo veremos con código real sobre el proyecto de referencia de Rutalia especificado en 07-01; si tu proyecto es otro, la estructura, los hitos y la disciplina de medición se trasladan tal cual.

Contenido

  1. Estructura del proyecto Python
  2. La bala trazadora: hito 0, la línea base que ya funciona
  3. Mejoras por capas: hitos 1 a 3 con medición intermedia
  4. Experimentos reproducibles: semillas, configuración y resultados
  5. Medición seria: predecir el coste y comprobarlo
  6. Pruebas mínimas que valen su peso
  7. Cuándo parar de optimizar

Estructura del proyecto Python

Antes de la primera función, crea esta estructura. Separa lo que cambia a ritmos distintos: los datos se generan una vez, los algoritmos evolucionan por capas, la evaluación no debe tocarse nunca (es el árbitro) y los experimentos son scripts que combinan lo anterior.

planificador-rutalia/
├── README.md               # se completa en 07-03
├── requirements.txt        # numpy, scipy, matplotlib
├── config.py               # parámetros y semillas, NADA de lógica
├── datos/
│   ├── generador.py        # crea histórico e instancias sintéticas
│   └── instancias/         # .npz generados (no se editan a mano)
├── algoritmos/
│   ├── regresion.py        # modelo de tiempos (05-03)
│   ├── clustering.py       # k-means (05-05)
│   ├── asignacion.py       # húngaro (03-06)
│   └── rutas.py            # vecino más cercano + 2-opt (06-01)
├── evaluacion/
│   └── metricas.py         # coste real de un plan; el árbitro único
├── experimentos/
│   ├── exp_baseline.py     # hito 0
│   ├── exp_capas.py        # compara variantes con varias semillas
│   └── resultados/         # .csv con parámetros + resultados
└── tests/
    └── test_basicos.py

Reglas que evitan dolores después:

  • evaluacion/ solo evalúa con los datos verdaderos del generador, nunca con los tiempos predichos por tu modelo. Si el plan se puntúa con las mismas predicciones que lo construyeron, un modelo malo se autofelicita — es el leakage de 06-04 disfrazado de optimización.
  • config.py concentra todos los números mágicos (nº de pedidos, k, semillas, iteraciones de 2-opt). Un experimento debe poder cambiarse sin editar algoritmos.
  • Cada módulo se puede probar solo: rutas.py recibe una matriz de tiempos y devuelve un orden; no sabe de dónde salió la matriz.

Hito 0: la bala trazadora

El primer día de trabajo termina con el sistema entero funcionando mal. Generador de datos, plan ingenuo, evaluación y número final: todo conectado.

# datos/generador.py
import numpy as np

def generar_historico(n=3000, seed=42):
    """Trayectos históricos: [x1,y1,x2,y2,hora] -> minutos reales."""
    rng = np.random.default_rng(seed)
    X = rng.uniform(0, 10, size=(n, 4))          # coords en km
    hora = rng.integers(7, 22, size=n)
    dist = np.hypot(X[:, 2] - X[:, 0], X[:, 3] - X[:, 1])
    punta = np.isin(hora, [8, 9, 18, 19]) * 1.6  # penalización hora punta
    y = dist / 0.35 * (1 + punta * 0.4) + rng.normal(0, 2.0, n)
    return np.column_stack([X, hora]), np.maximum(y, 1.0)

def generar_jornada(n_pedidos=80, n_repartidores=5, seed=0):
    rng = np.random.default_rng(seed)
    pedidos = rng.uniform(0, 10, size=(n_pedidos, 2))
    deposito = np.array([5.0, 5.0])
    return pedidos, deposito, n_repartidores
# experimentos/exp_baseline.py
from datos.generador import generar_jornada
from evaluacion.metricas import coste_plan   # usa los tiempos VERDADEROS

def plan_baseline(pedidos, n_rep):
    """Bloques consecutivos por orden de llegada, ruta sin ordenar."""
    tam = len(pedidos) // n_rep
    return [list(range(i * tam, (i + 1) * tam)) for i in range(n_rep)]

pedidos, deposito, n_rep = generar_jornada(seed=0)
plan = plan_baseline(pedidos, n_rep)
print(f"Coste total línea base: {coste_plan(plan, pedidos, deposito):.0f} min")

Salida del ejemplo: Coste total línea base: 612 min. Es un número horrible y es exactamente lo que queríamos: ya existe la vara de medir, el formato del plan (lista de rutas, cada ruta una lista de índices de pedido) y el árbitro. Todo lo que hagas a partir de aquí, o baja de 612, o sobra.

flowchart LR
    G[generador de datos] --> P[plan: baseline]
    P --> E[evaluacion con tiempos reales]
    E --> R[numero final]
    P -. "hitos 1-3: sustituir<br/>por capas mejores" .-> P

Hitos 1 a 3: mejorar por capas

Cada hito sustituye una pieza del pipeline por su versión algorítmica, se mide contra la tabla acumulada y se guarda el resultado. Nunca dos capas a la vez: si el número empeora, quieres saber qué capa fue.

Hito 1: regresión de tiempos (05-03)

Entrenamos la regresión lineal con descenso de gradiente sobre el histórico (rasgos: distancia, indicador de hora punta) y construimos la matriz de tiempos entre todos los puntos de la jornada. Con ella, cada repartidor ordena su bloque con vecino más cercano — la asignación sigue siendo la ingenua.

# algoritmos/regresion.py (esqueleto de integración)
import numpy as np

def rasgos(X):
    dist = np.hypot(X[:, 2] - X[:, 0], X[:, 3] - X[:, 1])
    punta = np.isin(X[:, 4], [8, 9, 18, 19]).astype(float)
    return np.column_stack([np.ones(len(X)), dist, dist * punta])

def entrenar(X, y, lr=0.01, epocas=500):
    A = rasgos(X)
    w = np.zeros(A.shape[1])
    for _ in range(epocas):
        w -= lr * A.T @ (A @ w - y) / len(y)   # gradiente del ECM
    return w

def matriz_tiempos(puntos, w, hora=9):
    n = len(puntos)
    pares = np.array([[*puntos[i], *puntos[j], hora]
                      for i in range(n) for j in range(n)])
    return (rasgos(pares) @ w).reshape(n, n)

Medimos dos cosas: la calidad del modelo (MAE en el 20 % de test: 3.1 min, dentro del objetivo < 4 de la especificación) y el efecto en el plan.

Hito 2: clustering + asignación (05-05, 03-06)

k-means agrupa los 80 pedidos en 5 zonas compactas; el algoritmo húngaro asigna cada grupo al repartidor cuyo coste de cubrirlo (tiempo predicho depósito → centroide, como aproximación) es menor. En nuestra versión los repartidores parten todos del depósito, así que el húngaro apenas cambia el coste aquí — lo dejamos porque generaliza a repartidores con puntos de inicio distintos, y lo anotamos con honestidad en los resultados.

# fragmento de experimentos/exp_capas.py
from scipy.optimize import linear_sum_assignment
from algoritmos.clustering import kmeans           # 05-05, propio
grupos, centroides = kmeans(pedidos, k=n_rep, seed=0)
coste = matriz_coste_repartidor_grupo(centroides, deposito, w)
filas, cols = linear_sum_assignment(coste)          # húngaro (03-06)
plan = [grupos[c] for c in cols]

Hito 3: 2-opt (06-01)

Sobre cada ruta, vecino más cercano da el orden inicial y 2-opt lo refina invirtiendo segmentos mientras haya mejora, igual que en el día operativo de 06-01 — pero ahora sobre la matriz de tiempos predichos, no de distancias.

Tabla de resultados intermedios del ejemplo (instancia seed=0, evaluada siempre con tiempos verdaderos):

Hito Variante Coste total (min) Mejora vs. base Tiempo de cómputo
H0 Bloques + orden de llegada 612 < 0.01 s
H1 Bloques + NN sobre tiempos predichos 471 −23 % 0.4 s
H2 k-means + húngaro + NN 388 −37 % 0.6 s
H3 k-means + húngaro + NN + 2-opt 342 −44 % 2.1 s

Con una sola instancia esto es una anécdota; el objetivo del ≥ 30 % de la especificación solo se declara cumplido tras la sección siguiente.

Experimentos reproducibles

Tres reglas convierten "me salió 342" en un experimento:

  1. Semillas explícitas y separadas. Una semilla para los datos, otra para los algoritmos estocásticos (k-means). Ambas en config.py, jamás np.random.seed disperso por los módulos.
  2. Configuración fuera del código. Cambiar N_PEDIDOS o K no debe tocar ningún algoritmo.
  3. Cada ejecución guarda parámetros + resultado juntos. Un CSV al que se añade una fila por ejecución; sin esto, en el hito 4 no recordarás qué configuración produjo qué número.
# experimentos/exp_capas.py (bucle principal)
import csv, time
from config import SEEDS_DATOS, SEEDS_ALGO, VARIANTES

with open("experimentos/resultados/capas.csv", "a", newline="") as f:
    w = csv.writer(f)
    for sd in SEEDS_DATOS:                 # 10 instancias de jornada
        for sa in SEEDS_ALGO:              # 5 semillas de algoritmo
            for nombre, planificar in VARIANTES.items():
                t0 = time.perf_counter()
                plan = planificar(sd, sa)
                w.writerow([nombre, sd, sa,
                            coste_plan_desde(plan, sd),
                            time.perf_counter() - t0])

Agregando las 10 instancias × 5 semillas del ejemplo: mejora media del H3 sobre la base −41 % (mínima −33 %, máxima −47 %). Ahora sí: objetivo cumplido, con variabilidad conocida en lugar de un número suelto.

Medición seria: predecir el coste, luego medirlo

El módulo 1 te dio un superpoder que la mayoría de proyectos desperdicia: saber cuánto va a costar algo antes de ejecutarlo. Úsalo como prueba cruzada de tu propia implementación:

Pieza Coste teórico (01-01/01-02) Predicción para n=80, k=5 ¿Medido coherente?
Matriz de tiempos O(n²) evaluaciones del modelo 6 400 filas vectorizadas: ms Sí (~0.3 s con construcción de pares)
k-means O(iter · n · k) trivial
Húngaro O(k³) 125 operaciones: nada
2-opt por ruta O(pasadas · m²), m≈16 ~256 comprobaciones/pasada Sí (~1.5 s total)

Si lo medido se desvía órdenes de magnitud de lo predicho, casi siempre has encontrado un bug (un bucle O(n³) accidental, una matriz que se reconstruye dentro de un bucle). Verifica también la escalabilidad empírica: duplica n y comprueba que el tiempo de la matriz se multiplica por ~4, como manda O(n²) — la misma técnica de duplicación que usaste en 01-02.

Pruebas mínimas

No necesitas una suite industrial; necesitas no engañarte. Dos tipos de prueba bastan:

Casos pequeños verificables a mano. Una jornada de 4 pedidos y 2 repartidores donde el óptimo se calcula con lápiz; tu pipeline debe encontrarlo o quedarse muy cerca.

Propiedades invariantes que deben cumplirse para cualquier entrada:

# tests/test_basicos.py
def test_plan_valido():
    plan = planificar_h3(seed_datos=0, seed_algo=0)
    visitados = [p for ruta in plan for p in ruta]
    assert sorted(visitados) == list(range(80))   # cada pedido, una vez
    assert len(plan) == 5                          # una ruta por repartidor

def test_2opt_nunca_empeora():
    ruta, M = ruta_ejemplo()
    assert coste_ruta(dos_opt(ruta, M), M) <= coste_ruta(ruta, M) + 1e-9

def test_matriz_tiempos_positiva_y_diagonal_cero():
    M = matriz_tiempos(pedidos_ejemplo(), w_entrenado)
    assert (M[~np.eye(len(M), dtype=bool)] > 0).all()

Adapta las propiedades a tu dominio: en un proyecto de flujo, "el flujo se conserva en cada nodo"; en uno de horarios, "ningún recurso está en dos sitios a la vez"; en uno de ordenación externa, "la salida está ordenada y es permutación de la entrada".

Cuándo parar de optimizar

La señal de parada no es "ya no se me ocurre nada", sino cualquiera de estas tres:

  • Objetivo de la especificación cumplido con variabilidad medida. El nuestro era ≥ 30 %; llevamos −41 % medio con peor caso −33 %. Cumplido.
  • Rendimientos decrecientes. H1 aportó 23 puntos, H2 otros 14, H3 otros 7. Una hipotética capa H4 (3-opt, u or-opt) costaría horas para arañar 2-3 puntos: se anota como trabajo futuro, no se implementa.
  • Presupuesto de horas agotado según el plan de hitos. El plan de 07-01 reservaba 6 horas para el hito 4 (experimentos e informe); robárselas a una micro-mejora es un mal cambio, porque la medición y la comunicación valen más nota que el 2 % extra.

Regla práctica final: cada mejora adicional debe justificar por adelantado qué métrica moverá y cuánto estima moverla. Si no sabes responder, no la empieces.

Errores Comunes y Consejos

  • Error: evaluar con los tiempos predichos. El más peligroso de todo el proyecto: el plan optimiza predicciones y se puntúa con ellas, así que mejorar el modelo "mejora" el resultado aunque las rutas reales empeoren. El árbitro usa siempre los datos verdaderos del generador (o un conjunto de evaluación separado).
  • Error: dos capas a la vez. Si integras regresión y clustering en la misma sesión y el coste sube, no sabes a quién culpar. Una capa, una medición, un commit.
  • Error: resultados en pantalla y a otra cosa. El número que no se guarda con sus parámetros no existe. El CSV de resultados es tan parte del proyecto como el código.
  • Error: optimizar el código antes que el algoritmo. Vectorizar una línea base O(n³) es pulir el camarote del Titanic; primero la mejora asintótica (01-02), luego las constantes, y solo si el tiempo de cómputo es realmente un problema.
  • Consejo: commits por hito con el número en el mensaje. git commit -m "H2: kmeans+hungaro, 388 min (-37%) seed 0" te da un historial que en 07-03 se convierte casi solo en la sección de resultados.
  • Consejo: cuaderno de decisiones. Un DECISIONES.md con una línea por elección ("2-opt y no 3-opt porque O(m²) vs O(m³) y m=16") — en la próxima lección será oro para la tabla de justificación de métodos.

Ejercicios

Como en 07-01, son hitos guiados de tu propio proyecto.

Ejercicio 1: bala trazadora

Implementa tu hito 0 completo: generador de datos con semilla, línea base tonta, evaluador y número final impreso. Prohibido implementar ningún algoritmo "bueno" todavía.

Ejercicio 2: capas con tabla

Desarrolla tus hitos intermedios de uno en uno y construye tu tabla de resultados acumulada (variante, métrica, mejora vs. base, tiempo de cómputo), midiendo cada capa por separado.

Ejercicio 3: reproducibilidad y pruebas

Ejecuta tu comparación final con al menos 5 instancias × 3 semillas guardando un CSV, y escribe un mínimo de 3 pruebas: un caso pequeño a mano y dos propiedades invariantes de tu dominio.

Soluciones

Ejercicio 1 — criterios de autoevaluación. Prueba definitiva: borra todo excepto el repositorio, clónalo y ejecuta python experimentos/exp_baseline.py; debe imprimir el mismo número (semilla fija). Además: el evaluador vive en su propio módulo y no importa nada de algoritmos/; el formato de "plan/solución" ya es el definitivo. Si tu línea base tardó más de una tarde, tu especificación de 07-01 tenía la línea base demasiado ambiciosa: simplifícala y anótalo.

Ejercicio 2 — solución orientativa. Una tabla sana muestra mejoras decrecientes por capa (como 23/14/7 en Rutalia) y tiempos de cómputo que crecen de forma coherente con el análisis teórico. Dos situaciones legítimas que no debes ocultar: una capa que no mejora (como nuestro húngaro con depósito único — se documenta el porqué) y una capa que mejora la métrica pero dispara el tiempo (decisión de compromiso: registra ambas columnas). Señal de alarma: mejoras crecientes por capa suelen indicar que la línea base era artificialmente mala o que hay leakage en la evaluación.

Ejercicio 3 — criterios de autoevaluación. El CSV debe permitir reconstruir cualquier número de tu tabla con un groupby (variante → media, mínimo, máximo); si necesitas "recordar" algo que no está en el fichero, faltan columnas de parámetros. Sobre las pruebas: la de caso pequeño debe tener el resultado esperado calculado a mano en un comentario, y las propiedades deben fallar si introduces un bug deliberado (pruébalo: rompe el 2-opt invirtiendo mal los índices y verifica que el test lo caza). Un test que no puede fallar no protege nada.

Conclusión

El proyecto ya existe: estructura limpia con datos, algoritmos, evaluación y experimentos separados; una línea base que funcionó de punta a punta desde el primer día; tres capas algorítmicas integradas una a una — regresión de tiempos (05-03), clustering y asignación (05-05, 03-06), vecino más cercano con 2-opt (06-01) — cada una con su medición; experimentos con semillas múltiples que convierten un número suelto en un resultado con variabilidad (−41 % medio, peor caso −33 %); pruebas que impiden el autoengaño y un criterio claro de parada. Pero un proyecto que solo tú entiendes está a medio terminar: en la última lección del curso (07-03) escribiremos el informe final, prepararemos el repositorio para que cualquiera lo reproduzca, evaluaremos el trabajo con una rúbrica honesta — y cerraremos el viaje que empezó, hace siete módulos, con una notación asintótica y una empresa de reparto llamada Rutalia.

© Copyright 2026. Todos los derechos reservados