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
- Estructura del proyecto Python
- La bala trazadora: hito 0, la línea base que ya funciona
- Mejoras por capas: hitos 1 a 3 con medición intermedia
- Experimentos reproducibles: semillas, configuración y resultados
- Medición seria: predecir el coste y comprobarlo
- Pruebas mínimas que valen su peso
- 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.pyReglas 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.pyconcentra 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.pyrecibe 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:
- Semillas explícitas y separadas. Una semilla para los datos, otra para los algoritmos estocásticos (k-means). Ambas en
config.py, jamásnp.random.seeddisperso por los módulos. - Configuración fuera del código. Cambiar
N_PEDIDOSoKno debe tocar ningún algoritmo. - 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 | Sí |
| Húngaro | O(k³) | 125 operaciones: nada | Sí |
| 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.mdcon 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.
Algoritmos Avanzados
Módulo 1: Introducción a los Algoritmos Avanzados
- Conceptos Básicos y Notación
- Análisis de Complejidad
- Recursión y Programación Dinámica
- Estructuras de Datos Avanzadas
Módulo 2: Algoritmos de Optimización
- Programación Lineal
- Algoritmos de Optimización Combinatoria
- Backtracking y Branch and Bound
- Algoritmos Genéticos
- Optimización de Colonia de Hormigas
Módulo 3: Algoritmos en Grafos
- Representación de Grafos
- Búsqueda en Grafos: BFS y DFS
- Algoritmos de Caminos Mínimos
- Árboles de Expansión Mínima
- Algoritmos de Flujo Máximo
- Algoritmos de Emparejamiento en Grafos
Módulo 4: Algoritmos de Búsqueda y Ordenación
Módulo 5: Algoritmos de Aprendizaje Automático
- Introducción al Aprendizaje Automático
- Algoritmos de Clasificación
- Algoritmos de Regresión
- Redes Neuronales y Deep Learning
- Algoritmos de Clustering
Módulo 6: Casos de Estudio y Aplicaciones
- Optimización en la Industria
- Aplicaciones de Grafos en Redes Sociales
- Búsqueda y Ordenación en Grandes Volúmenes de Datos
- Aplicaciones de Aprendizaje Automático en la Vida Real
