Cerramos el módulo con el problema que lo abrió. En 03-01 calculamos que el orden de las entregas de una furgoneta tiene n! posibilidades y que la fuerza bruta muere a partir de una docena de paradas; en 03-02 aprendimos a encontrar el mejor camino entre dos puntos, y en 03-03 a decidir frente a un rival. Pero ninguna de esas técnicas resuelve el problema completo de Diego: dado el mapa y las entregas del día, ¿en qué orden debe visitarlas la furgoneta para recorrer los menos kilómetros posibles? Es el célebre problema del viajante, y es el ejemplo perfecto de un problema de optimización: no buscamos un camino en un grafo, sino la mejor de entre un número astronómico de soluciones completas. En esta lección aprenderemos a formularlo (variables, función objetivo, restricciones), distinguiremos métodos exactos y aproximados, y construiremos tres algoritmos de búsqueda local y metaheurísticas que resuelven el viajante de NovaMarket en segundos: ascenso de colina, recocido simulado y algoritmos genéticos, validándolos contra la fuerza bruta en tamaño pequeño. Después aplicaremos la misma idea a la asignación de pedidos a los almacenes de Zaragoza y Getafe con una restricción de capacidad, y veremos cómo una restricción se convierte en una penalización. Terminaremos con la conexión que abre el módulo 4: entrenar un modelo de aprendizaje automático es, por dentro, también un problema de optimización.
Contenido
- Qué es un problema de optimización: variables, función objetivo, restricciones
- Optimización exacta frente a aproximada; búsqueda local
- Preparar el problema del viajante de NovaMarket: la matriz de distancias con Dijkstra
- Fuerza bruta como referencia (7 entregas)
- Ascenso de colina por intercambio de paradas: óptimos locales y reinicios aleatorios
- Un problema mayor: 15 direcciones de entrega
- Recocido simulado: la intuición de la temperatura
- Algoritmos genéticos: población, torneo, cruce, mutación y elitismo
- Segundo ejemplo: asignación de pedidos a dos almacenes con capacidad (restricciones como penalización)
- Tabla comparativa de métodos y cuándo usar cada uno
- Enlace con el módulo 4: aprender es optimizar
- Qué es un problema de optimización
Un problema de optimización tiene tres componentes:
| Componente | Qué es | En el viajante de la furgoneta | En la asignación a almacenes |
|---|---|---|---|
| Variables de decisión | Lo que podemos elegir | El orden de las paradas (una permutación de las entregas) | Para cada pedido, si sale de Getafe o de Zaragoza |
| Función objetivo | El número que queremos minimizar (o maximizar) | Kilómetros totales de la ruta cerrada desde el almacén | Coste total de envío |
| Restricciones | Condiciones que toda solución debe cumplir | Visitar cada entrega exactamente una vez, salir y volver al almacén | No superar la capacidad de preparación de cada almacén |
Cada asignación concreta de valores a las variables es una solución candidata; las que cumplen las restricciones son factibles; la factible con mejor objetivo es la óptima. La diferencia con la búsqueda de 03-02 es de perspectiva: allí el objeto era un camino que se construía paso a paso desde el estado inicial, y el coste se acumulaba tramo a tramo; aquí trabajamos con soluciones completas que evaluamos de golpe con la función objetivo, y el "camino" que nos importa no está en el mapa, sino en el espacio de soluciones. En 02-01 dijimos que un agente basado en utilidad elige la acción que maximiza una función; los algoritmos de esta lección son la maquinaria para hacer esa elección cuando las opciones son demasiadas para listarlas.
- Optimización exacta frente a aproximada; búsqueda local
| Enfoque | Qué garantiza | Ejemplos | Coste | Cuándo |
|---|---|---|---|---|
| Exacto | Encuentra el óptimo demostrable | Fuerza bruta, ramificación y poda, programación dinámica, programación lineal (para problemas con estructura lineal) | Exponencial en el peor caso para el viajante y sus parientes | Tamaño pequeño, o problemas con estructura especial |
| Heurístico constructivo | Una solución razonable, rápida | "Ir siempre a la parada más cercana" (vecino más próximo) | Muy bajo | Punto de partida; cuando el tiempo es crítico |
| Búsqueda local / metaheurístico | Una solución buena, sin garantía de óptimo, con posibilidad de mejorarla dando más tiempo | Ascenso de colina, recocido simulado, algoritmos genéticos, búsqueda tabú | Ajustable | La opción por defecto en problemas grandes del mundo real |
La búsqueda local parte de una solución completa (buena o mala) y la mejora paso a paso aplicando pequeños cambios, llamados movimientos; el conjunto de soluciones alcanzables con un movimiento desde la actual es su vecindario. La metáfora habitual es un paisaje: cada solución es un punto, su objetivo es la altura, y el algoritmo es un excursionista que quiere llegar al punto más alto (o más bajo, si minimizamos) moviéndose solo a puntos vecinos. Con esa imagen se entienden de inmediato los tres algoritmos de la lección: el ascenso de colina sube siempre y se queda atascado en la primera cima; el recocido simulado se permite bajar a veces para escapar; el algoritmo genético manda a muchos excursionistas a la vez y cruza sus rutas.
Un apunte de vocabulario: aunque en el viajante minimizamos kilómetros, la literatura habla de "ascenso" de colina por costumbre; minimizar f es maximizar −f, así que la idea es la misma.
- Preparar el problema: la matriz de distancias con Dijkstra
En 03-01, la fuerza bruta solo aceptaba tramos entre barrios conectados por una carretera directa. Ahora podemos hacerlo bien: la distancia entre dos paradas cualesquiera es la del camino más corto entre ellas, que calculamos con la búsqueda de coste uniforme (Dijkstra) de 03-02, ejecutada desde cada nodo. El resultado es una matriz de distancias con la que el viajante ya no depende de la topología del grafo. Esta separación en dos capas (Dijkstra/A* para "cómo ir de A a B", optimización para "en qué orden visitar A, B, C…") es exactamente como funcionan los planificadores de reparto reales.
import heapq
import math
import random
import itertools
GRAFO_CIUDAD = {
"Almacen_Getafe": [("Leganes", 4.5), ("Villaverde", 5.0)],
"Leganes": [("Almacen_Getafe", 4.5), ("Carabanchel", 4.5), ("Villaverde", 6.5)],
"Villaverde": [("Almacen_Getafe", 5.0), ("Leganes", 6.5), ("Usera", 4.5), ("Vallecas", 7.5)],
"Carabanchel": [("Leganes", 4.5), ("Usera", 4.5), ("Arganzuela", 5.0)],
"Usera": [("Villaverde", 4.5), ("Carabanchel", 4.5), ("Arganzuela", 3.5), ("Vallecas", 5.5)],
"Vallecas": [("Villaverde", 7.5), ("Usera", 5.5), ("Retiro", 6.0)],
"Arganzuela": [("Carabanchel", 5.0), ("Usera", 3.5), ("Retiro", 4.0)],
"Retiro": [("Arganzuela", 4.0), ("Vallecas", 6.0)],
}
def distancias_desde(grafo, origen):
"""Dijkstra compacto: distancia mínima desde origen a TODOS los nodos (03-02)."""
dist = {origen: 0.0}
frontera = [(0.0, origen)]
while frontera:
g, nodo = heapq.heappop(frontera)
if g > dist[nodo]: # entrada obsoleta del montículo
continue
for vecino, d in grafo[nodo]:
nuevo = g + d
if vecino not in dist or nuevo < dist[vecino]:
dist[vecino] = nuevo
heapq.heappush(frontera, (nuevo, vecino))
return dist
MATRIZ = {n: distancias_desde(GRAFO_CIUDAD, n) for n in GRAFO_CIUDAD}
NODOS = list(GRAFO_CIUDAD)
ENTREGAS = [n for n in NODOS if n != "Almacen_Getafe"] # las 7 entregas de hoy
def longitud_ruta(ruta, dist, origen="Almacen_Getafe"):
"""Km de la ruta cerrada: origen -> ruta[0] -> ... -> ruta[-1] -> origen."""
total = dist[origen][ruta[0]] + dist[ruta[-1]][origen]
for a, b in zip(ruta, ruta[1:]):
total += dist[a][b]
return totalMATRIZ[a][b] es la distancia mínima por carretera de a a b; por ejemplo, MATRIZ["Almacen_Getafe"]["Retiro"] vale 17,0 (el resultado de A* en 03-02) y MATRIZ["Leganes"]["Vallecas"] vale 14,0 aunque no exista carretera directa. La matriz completa, en km:
| Getafe | Leganes | Villaverde | Carabanchel | Usera | Vallecas | Arganzuela | Retiro | |
|---|---|---|---|---|---|---|---|---|
| Almacen_Getafe | 0 | 4,5 | 5,0 | 9,0 | 9,5 | 12,5 | 13,0 | 17,0 |
| Leganes | 4,5 | 0 | 6,5 | 4,5 | 9,0 | 14,0 | 9,5 | 13,5 |
| Villaverde | 5,0 | 6,5 | 0 | 9,0 | 4,5 | 7,5 | 8,0 | 12,0 |
| Carabanchel | 9,0 | 4,5 | 9,0 | 0 | 4,5 | 10,0 | 5,0 | 9,0 |
| Usera | 9,5 | 9,0 | 4,5 | 4,5 | 0 | 5,5 | 3,5 | 7,5 |
| Vallecas | 12,5 | 14,0 | 7,5 | 10,0 | 5,5 | 0 | 9,0 | 6,0 |
| Arganzuela | 13,0 | 9,5 | 8,0 | 5,0 | 3,5 | 9,0 | 0 | 4,0 |
| Retiro | 17,0 | 13,5 | 12,0 | 9,0 | 7,5 | 6,0 | 4,0 | 0 |
La función longitud_ruta es nuestra función objetivo: recibe una permutación de las entregas (la variable de decisión) y devuelve los kilómetros de la ruta cerrada. La restricción "cada entrega exactamente una vez" queda garantizada por construcción, porque solo manejaremos permutaciones.
- Fuerza bruta como referencia (7 entregas)
Con 7 entregas hay 7! = 5.040 permutaciones: la fuerza bruta de 03-01, ahora sobre la matriz, nos da el óptimo exacto que servirá de vara de medir para los métodos aproximados.
def fuerza_bruta(entregas, dist):
mejor, mejor_km = None, math.inf
for orden in itertools.permutations(entregas):
km = longitud_ruta(orden, dist)
if km < mejor_km:
mejor, mejor_km = list(orden), km
return mejor, mejor_km
optimo, optimo_km = fuerza_bruta(ENTREGAS, MATRIZ)
print(optimo_km, " -> ".join(optimo))El óptimo son 39,0 km (curiosamente coincide con el que encontró la versión restringida de 03-01: en este mapa la mejor ruta ya usaba solo carreteras directas). Recuerda que este número es inalcanzable por fuerza bruta a partir de unas 12-13 entregas.
- Ascenso de colina por intercambio de paradas
Movimiento: intercambiar dos paradas de la ruta. Con 7 paradas hay 21 intercambios posibles: ese es el vecindario. Algoritmo: mirar todos los vecinos, moverse al mejor si mejora la ruta actual, y parar cuando ningún vecino mejora (versión de "mejor mejora"; la alternativa de "primera mejora" se mueve al primer vecino que mejore).
def vecinos_intercambio(ruta):
"""Genera todas las rutas que resultan de intercambiar dos paradas."""
for i in range(len(ruta)):
for j in range(i + 1, len(ruta)):
v = ruta[:]
v[i], v[j] = v[j], v[i]
yield v
def ascenso_colina(ruta_inicial, dist, verbose=False):
actual = ruta_inicial[:]
km_actual = longitud_ruta(actual, dist)
pasos = 0
while True:
mejor_vecino, mejor_km = None, km_actual
for v in vecinos_intercambio(actual):
km = longitud_ruta(v, dist)
if km < mejor_km:
mejor_vecino, mejor_km = v, km
if mejor_vecino is None: # ningún vecino mejora: óptimo local
return actual, km_actual, pasos
actual, km_actual = mejor_vecino, mejor_km
pasos += 1
if verbose:
print(f" paso {pasos}: {km_actual:.1f} km {' -> '.join(actual)}")
random.seed(7)
inicial = ENTREGAS[:]
random.shuffle(inicial)
print("Inicial:", longitud_ruta(inicial, MATRIZ), "km", " -> ".join(inicial))
ruta, km, pasos = ascenso_colina(inicial, MATRIZ, verbose=True)
print("Final:", km, "km en", pasos, "pasos")Inicial: 68.5 km Arganzuela -> Retiro -> Vallecas -> Leganes -> Usera -> Villaverde -> Carabanchel paso 1: 53.0 km Arganzuela -> Retiro -> Vallecas -> Carabanchel -> Usera -> Villaverde -> Leganes paso 2: 47.5 km Vallecas -> Retiro -> Arganzuela -> Carabanchel -> Usera -> Villaverde -> Leganes Final: 47.5 km en 2 pasos
En dos pasos ha pasado de 68,5 a 47,5 km, y ahí se ha parado: ninguno de los 21 intercambios mejora esa ruta, pero el óptimo es 39,0. Hemos llegado a un óptimo local: una cima desde la que todo lo que se ve alrededor es cuesta abajo, aunque haya montañas más altas más allá. Es el defecto estructural del ascenso de colina, y tiene tres remedios clásicos:
- Vecindarios más ricos (más movimientos posibles: por ejemplo, invertir un segmento de la ruta, el famoso 2-opt, que usaremos en la sección 7).
- Reinicios aleatorios: repetir el ascenso desde muchas soluciones iniciales distintas y quedarse con la mejor. Es simple, se paraleliza sin esfuerzo y funciona sorprendentemente bien.
- Aceptar a veces movimientos que empeoran, que es la idea del recocido simulado.
def ascenso_con_reinicios(entregas, dist, n_reinicios, semilla=0):
rng = random.Random(semilla)
mejor, mejor_km = None, math.inf
for _ in range(n_reinicios):
inicial = entregas[:]
rng.shuffle(inicial)
ruta, km, _ = ascenso_colina(inicial, dist)
if km < mejor_km:
mejor, mejor_km = ruta, km
return mejor, mejor_km
ruta, km = ascenso_con_reinicios(ENTREGAS, MATRIZ, n_reinicios=10)
print(km, " -> ".join(ruta))Con 10 reinicios alcanza el óptimo de la fuerza bruta. Si repites el experimento con 20 inicios distintos verás que unos 8 de cada 20 terminan en 39,0, otros tantos en 39,5 y el resto en óptimos locales peores (46-47,5 km): la probabilidad de acertar con un solo intento es de un 40 %, pero con 10 intentos independientes es de más del 99 %. Y el coste total ha sido 10 × (unos pocos pasos × 21 evaluaciones): unas 500 evaluaciones frente a las 5.040 de la fuerza bruta. Con 7 paradas la diferencia es anecdótica; con 30 es la diferencia entre segundos y siglos.
- Un problema mayor: 15 direcciones de entrega
Para que las metaheurísticas tengan algo que morder, generamos una instancia mayor: 15 direcciones concretas de clientes repartidas alrededor de los barrios de COORDENADAS (03-02), con la distancia en línea recta como aproximación de la distancia real (en producción usaríamos la matriz Dijkstra sobre el callejero, exactamente como en la sección 3; el algoritmo de optimización no nota la diferencia porque solo consulta dist[a][b]).
COORDENADAS = {
"Almacen_Getafe": (0, 0), "Leganes": (-3, 3), "Villaverde": (3, 3), "Carabanchel": (-2, 7),
"Usera": (2, 7), "Vallecas": (7, 8), "Arganzuela": (1, 10), "Retiro": (4, 12),
}
def generar_direcciones(n, semilla=42):
"""n direcciones ficticias de entrega: cada una cerca de un barrio elegido al azar."""
rng = random.Random(semilla)
barrios = [b for b in COORDENADAS if b != "Almacen_Getafe"]
direcciones = {}
for i in range(1, n + 1):
barrio = rng.choice(barrios)
x, y = COORDENADAS[barrio]
direcciones[f"E{i:02d}_{barrio[:4]}"] = (x + rng.uniform(-1.5, 1.5), y + rng.uniform(-1.5, 1.5))
return direcciones
DIRECCIONES = generar_direcciones(15)
PUNTOS = {"Almacen_Getafe": (0, 0), **DIRECCIONES}
def euclidea(a, b):
(x1, y1), (x2, y2) = PUNTOS[a], PUNTOS[b]
return math.hypot(x2 - x1, y2 - y1)
DIST15 = {a: {b: euclidea(a, b) for b in PUNTOS} for a in PUNTOS}
ENTREGAS15 = list(DIRECCIONES)
rng = random.Random(1)
inicial15 = ENTREGAS15[:]
rng.shuffle(inicial15)
print("Inicial:", round(longitud_ruta(inicial15, DIST15), 1), "km")
ruta, km, pasos = ascenso_colina(inicial15, DIST15)
print("Ascenso de colina:", round(km, 1), "km en", pasos, "pasos")
for n in [1, 10, 50]:
ruta, km = ascenso_con_reinicios(ENTREGAS15, DIST15, n_reinicios=n)
print(f"Con {n:2d} reinicios: {km:.2f} km")Inicial: 97.6 km Ascenso de colina: 51.8 km en 7 pasos Con 1 reinicios: 43.79 km Con 10 reinicios: 36.45 km Con 50 reinicios: 36.45 km
Los nombres de las direcciones llevan el prefijo del barrio (E04_Vall es la cuarta entrega, cerca de Vallecas). Con 15 paradas la fuerza bruta necesitaría 15! ≈ 1,3 billones de evaluaciones; el ascenso de colina simple se atasca en 51,8 km, y con reinicios llega a 36,45 km, que es (como confirmarán los otros dos métodos) el mejor valor conocido para esta instancia. Guarda esa cifra como referencia.
- Recocido simulado: la intuición de la temperatura
El recocido simulado (simulated annealing) toma su nombre de la metalurgia: un metal calentado y enfriado lentamente cristaliza en una estructura de mínima energía, mientras que enfriado de golpe queda lleno de defectos. Traducido a búsqueda local:
- En cada iteración se genera un vecino al azar (no todos).
- Si mejora, se acepta siempre.
- Si empeora en una cantidad Δ, se acepta con probabilidad e^(−Δ/T), donde T es la temperatura. Con T alta casi todo se acepta (exploración libre, se salta entre valles); con T baja casi nada que empeore se acepta (el algoritmo se comporta como ascenso de colina y afina la solución).
- T empieza alta y se reduce poco a poco (enfriamiento), por ejemplo multiplicándola por 0,995 en cada iteración.
Unos números para fijar la intuición (probabilidad de aceptar un empeoramiento de Δ km a temperatura T):
| Δ (km peor) | T = 10 | T = 1 | T = 0,1 |
|---|---|---|---|
| 0,5 | 0,95 | 0,61 | 0,007 |
| 2 | 0,82 | 0,14 | ≈ 0 |
| 5 | 0,61 | 0,007 | ≈ 0 |
Al principio acepta casi cualquier cosa; al final, prácticamente solo mejoras. Como movimiento usaremos el 2-opt: elegir dos posiciones e invertir el segmento entre ellas, que en rutas es más eficaz que el intercambio simple porque deshace cruces.
def recocido_simulado(ruta_inicial, dist, T0=10.0, enfriamiento=0.995, T_min=0.01, semilla=0):
rng = random.Random(semilla)
actual = ruta_inicial[:]
km_actual = longitud_ruta(actual, dist)
mejor, mejor_km = actual[:], km_actual
T = T0
iteraciones = aceptados_peores = 0
while T > T_min:
i, j = sorted(rng.sample(range(len(actual)), 2))
vecino = actual[:]
vecino[i:j + 1] = reversed(vecino[i:j + 1]) # movimiento 2-opt: invertir un segmento
km_vecino = longitud_ruta(vecino, dist)
delta = km_vecino - km_actual
if delta < 0 or rng.random() < math.exp(-delta / T): # mejora: siempre; empeora: a veces
if delta > 0:
aceptados_peores += 1
actual, km_actual = vecino, km_vecino
if km_actual < mejor_km: # recordamos la mejor vista
mejor, mejor_km = actual[:], km_actual
T *= enfriamiento
iteraciones += 1
return mejor, mejor_km, iteraciones, aceptados_peores
ruta, km, iteraciones, peores = recocido_simulado(inicial15, DIST15)
print(f"Recocido: {km:.2f} km en {iteraciones} iteraciones ({peores} movimientos a peor aceptados)")
for semilla in range(5):
ruta, km, _, peores = recocido_simulado(inicial15, DIST15, semilla=semilla)
print(f" semilla {semilla}: {km:.2f} km, {peores} empeoramientos aceptados")Recocido: 36.45 km en 1379 iteraciones (117 movimientos a peor aceptados) semilla 0: 36.45 km, 117 empeoramientos aceptados semilla 1: 36.84 km, 101 empeoramientos aceptados semilla 2: 36.45 km, 101 empeoramientos aceptados semilla 3: 36.84 km, 105 empeoramientos aceptados semilla 4: 36.45 km, 88 empeoramientos aceptados
Desde la misma ruta inicial de 97,6 km en la que el ascenso de colina se quedó en 51,8, el recocido llega a 36,45 km (o a 36,84, un 1 % peor) en unas 1.400 evaluaciones, aceptando por el camino un centenar de movimientos que empeoraban: esos "pasos atrás" son los que le permiten salir de los valles. Tres detalles del código merecen atención: se guarda aparte la mejor solución vista (la actual puede empeorar al final de la fase caliente); la temperatura se multiplica en cada iteración (enfriamiento geométrico, el más habitual); y el número de iteraciones lo fija el ritmo de enfriamiento (de 10 a 0,01 a razón de 0,995 son unas 1.380 iteraciones). Enfriar más despacio (0,999) mejora la calidad a cambio de más tiempo; es el mando que Marta ajustará según los segundos disponibles antes de las 8 de la mañana.
- Algoritmos genéticos
Los algoritmos genéticos se inspiran en la evolución: en lugar de una solución que se mueve, mantienen una población de soluciones que se reproduce; las mejores tienen más descendencia, los hijos combinan trozos de sus padres y de vez en cuando sufren mutaciones. Vocabulario y ciclo:
flowchart TD
A[Población inicial aleatoria<br/>N rutas] --> B[Evaluación: km de cada ruta<br/>aptitud = -km]
B --> C{¿Fin?<br/>generaciones agotadas}
C -- No --> D[Selección por torneo:<br/>elegir k al azar, gana el mejor]
D --> E[Cruce OX:<br/>copiar un segmento del padre 1<br/>y rellenar en el orden del padre 2]
E --> F[Mutación:<br/>con prob. p, intercambiar dos paradas]
F --> G[Elitismo:<br/>los e mejores pasan intactos]
G --> B
C -- Sí --> H[Devolver la mejor ruta]
- Individuo / cromosoma: una solución (una ruta); sus genes son las paradas.
- Aptitud (fitness): el objetivo (menos km, más apto).
- Selección por torneo: se toman k individuos al azar y gana el mejor; los buenos se reproducen más, pero los malos también tienen alguna opción, lo que preserva diversidad.
- Cruce: combinar dos padres. En permutaciones no vale copiar mitades (se repetirían paradas); el cruce de orden (OX) copia un segmento del padre 1 y rellena las posiciones restantes con las paradas que faltan en el orden en que aparecen en el padre 2.
- Mutación: cambio aleatorio pequeño (intercambiar dos paradas), que introduce novedad e impide que la población se vuelva idéntica.
- Elitismo: los mejores individuos pasan sin cambios a la siguiente generación, para no perder nunca la mejor solución encontrada.
def torneo(poblacion, dist, rng, k=3):
candidatos = rng.sample(poblacion, k)
return min(candidatos, key=lambda r: longitud_ruta(r, dist))
def cruce_ox(padre1, padre2, rng):
n = len(padre1)
i, j = sorted(rng.sample(range(n), 2))
hijo = [None] * n
hijo[i:j + 1] = padre1[i:j + 1] # segmento heredado del padre 1
restantes = [g for g in padre2 if g not in hijo] # lo que falta, en el orden del padre 2
huecos = [k for k in range(n) if hijo[k] is None]
for k, g in zip(huecos, restantes):
hijo[k] = g
return hijo
def mutacion_intercambio(ruta, rng, prob):
if rng.random() < prob:
i, j = rng.sample(range(len(ruta)), 2)
ruta[i], ruta[j] = ruta[j], ruta[i]
return ruta
def algoritmo_genetico(entregas, dist, tam_poblacion=60, generaciones=200,
elitismo=2, prob_mutacion=0.2, semilla=0, verbose=False):
rng = random.Random(semilla)
poblacion = []
for _ in range(tam_poblacion):
r = entregas[:]
rng.shuffle(r)
poblacion.append(r)
historial = []
for gen in range(generaciones):
poblacion.sort(key=lambda r: longitud_ruta(r, dist)) # mejor primero
mejor_km = longitud_ruta(poblacion[0], dist)
historial.append(mejor_km)
if verbose and gen % 25 == 0:
media = sum(longitud_ruta(r, dist) for r in poblacion) / len(poblacion)
print(f" gen {gen:3d}: mejor {mejor_km:.2f} km, media {media:.2f} km")
nueva = [r[:] for r in poblacion[:elitismo]] # elitismo
while len(nueva) < tam_poblacion:
p1, p2 = torneo(poblacion, dist, rng), torneo(poblacion, dist, rng)
hijo = cruce_ox(p1, p2, rng)
hijo = mutacion_intercambio(hijo, rng, prob_mutacion)
nueva.append(hijo)
poblacion = nueva
poblacion.sort(key=lambda r: longitud_ruta(r, dist))
return poblacion[0], longitud_ruta(poblacion[0], dist), historial
ruta, km, historial = algoritmo_genetico(ENTREGAS15, DIST15, verbose=True)
print(f"Genético: {km:.2f} km (mejor alcanzado en la generación {historial.index(min(historial))})")
print(" -> ".join(ruta))gen 0: mejor 79.70 km, media 99.49 km gen 25: mejor 36.45 km, media 39.24 km gen 50: mejor 36.45 km, media 39.69 km gen 75: mejor 36.45 km, media 40.86 km gen 100: mejor 36.45 km, media 39.18 km gen 125: mejor 36.45 km, media 41.31 km gen 150: mejor 36.45 km, media 39.64 km gen 175: mejor 36.45 km, media 37.70 km Genético: 36.45 km (mejor alcanzado en la generación 18) E05_Vill -> E02_Vill -> E04_Vall -> E07_Vall -> E15_Vall -> E06_Vall -> E09_Reti -> E13_Reti -> E03_Arga -> E01_Arga -> E10_Cara -> E11_Cara -> E12_Cara -> E08_Cara -> E14_Lega
La población empieza con una media de casi 100 km y en 18 generaciones (unas 1.000 evaluaciones) su mejor individuo ya está en 36,45 km. La ruta final tiene todo el sentido geográfico: sale hacia Villaverde, recorre Vallecas, sube a Retiro y Arganzuela, baja por Carabanchel y vuelve por Leganés. Un ejemplo del cruce OX para que veas la mecánica: con padres A B C D E F G y G F E D C B A, si el segmento elegido es las posiciones 1-2, el hijo hereda _ B C _ _ _ _ del primero y rellena con G F E D A (el orden del segundo, saltando B y C): G B C F E D A. Ninguna parada se repite ni se pierde.
Dos advertencias honestas: los algoritmos genéticos son sensibles a sus parámetros y estocásticos. Con otras semillas, la misma configuración termina en 41,3, 43,4, 43,8 o 46,1 km: la población converge prematuramente (todos los individuos se parecen y el cruce ya no aporta nada). Subir la probabilidad de mutación a 0,5 y la población a 150 hace que 4 de cada 5 ejecuciones alcancen 36,45. Comparado con el recocido, aquí el genético necesita más evaluaciones para el mismo resultado; su ventaja aparece en problemas donde no hay un movimiento local natural, donde la evaluación es paralelizable, o donde interesa obtener varias soluciones buenas y distintas.
- Segundo ejemplo: asignación de pedidos a dos almacenes con capacidad
Cambiemos de problema pero no de método. Cada mañana Diego debe decidir qué pedidos se preparan en Getafe y cuáles en Zaragoza (caso 6). Cada pedido tiene un coste de envío distinto según el almacén (por la zona de destino) y un volumen en cajas; cada almacén tiene una capacidad diaria de preparación en cajas. Formulación:
- Variables: para cada pedido,
"Getafe"o"Zaragoza"(una lista de 20 etiquetas; 2²⁰ ≈ 1 millón de soluciones). - Objetivo: minimizar el coste total de envío.
- Restricción: la suma de cajas asignadas a cada almacén no puede superar su capacidad.
La forma más habitual de tratar una restricción en búsqueda local es convertirla en penalización: sumar al objetivo un coste artificial proporcional a la violación (aquí, 20 € por cada caja de exceso). Así todas las soluciones son "evaluables", las infactibles simplemente salen muy caras, y el algoritmo aprende a evitarlas.
def generar_pedidos(n, semilla=11):
rng = random.Random(semilla)
pedidos = []
tarifa = {"centro": (4, 7), "sur": (3, 9), "noreste": (9, 4), "levante": (8, 5)} # €/caja (Getafe, Zaragoza)
for i in range(1, n + 1):
zona = rng.choice(list(tarifa))
cajas = rng.choice([1, 1, 2, 3, 5])
pedidos.append({"id": f"P{i:03d}", "zona": zona, "cajas": cajas,
"coste_getafe": tarifa[zona][0] * cajas,
"coste_zaragoza": tarifa[zona][1] * cajas})
return pedidos
PEDIDOS = generar_pedidos(20) # 60 cajas en total
CAPACIDAD = {"Getafe": 32, "Zaragoza": 30}
PENALIZACION = 20 # € por caja de exceso
def coste_asignacion(asignacion, pedidos, capacidad, penalizacion=PENALIZACION):
"""Devuelve (coste penalizado, coste real, carga por almacén, cajas de exceso)."""
coste = 0
carga = {"Getafe": 0, "Zaragoza": 0}
for p, almacen in zip(pedidos, asignacion):
coste += p["coste_getafe"] if almacen == "Getafe" else p["coste_zaragoza"]
carga[almacen] += p["cajas"]
exceso = sum(max(0, carga[a] - capacidad[a]) for a in carga)
return coste + penalizacion * exceso, coste, carga, exceso
# Solución ingenua: cada pedido al almacén más barato, sin mirar la capacidad
ingenua = ["Getafe" if p["coste_getafe"] <= p["coste_zaragoza"] else "Zaragoza" for p in PEDIDOS]
print("Ingenua:", coste_asignacion(ingenua, PEDIDOS, CAPACIDAD))
def vecinos_asignacion(asignacion):
"""Vecinos: cambiar UN pedido de almacén."""
for i in range(len(asignacion)):
v = asignacion[:]
v[i] = "Zaragoza" if v[i] == "Getafe" else "Getafe"
yield v
def ascenso_asignacion(asignacion, pedidos, capacidad):
actual = asignacion[:]
f_actual = coste_asignacion(actual, pedidos, capacidad)[0]
while True:
mejor, f_mejor = None, f_actual
for v in vecinos_asignacion(actual):
f = coste_asignacion(v, pedidos, capacidad)[0]
if f < f_mejor:
mejor, f_mejor = v, f
if mejor is None:
return actual, f_actual
cambiado = [i for i in range(len(actual)) if actual[i] != mejor[i]][0]
actual, f_actual = mejor, f_mejor
print(f" muevo {pedidos[cambiado]['id']} ({pedidos[cambiado]['zona']}, "
f"{pedidos[cambiado]['cajas']} cajas) a {actual[cambiado]} -> "
f"{coste_asignacion(actual, pedidos, capacidad)}")
final, f = ascenso_asignacion(ingenua, PEDIDOS, CAPACIDAD)
print("Final:", coste_asignacion(final, PEDIDOS, CAPACIDAD))Ingenua: (359, 239, {'Getafe': 38, 'Zaragoza': 22}, 6)
muevo P008 (centro, 5 cajas) a Zaragoza -> (274, 254, {'Getafe': 33, 'Zaragoza': 27}, 1)
muevo P012 (centro, 1 cajas) a Zaragoza -> (257, 257, {'Getafe': 32, 'Zaragoza': 28}, 0)
Final: (257, 257, {'Getafe': 32, 'Zaragoza': 28}, 0)La asignación ingenua es la más barata sobre el papel (239 €), pero carga 38 cajas en Getafe, 6 por encima de su capacidad: penalizada, cuesta 359. El ascenso de colina mueve a Zaragoza dos pedidos de la zona centro (los que menos pierden al cambiar de almacén: 15 € y 3 € más) y llega a una asignación factible de 257 €. Como aquí el espacio tiene "solo" 2²⁰ soluciones, podemos comprobar por enumeración completa que 257 € es el óptimo exacto; con 200 pedidos (2²⁰⁰) esa comprobación sería imposible y la búsqueda local sería la única opción.
Dos lecciones de este ejemplo: la penalización debe ser lo bastante alta para que ninguna solución infactible salga a cuenta (con 20 €/caja funciona; con 2 €/caja el algoritmo preferiría pagar la multa), pero no tan alta que aplaste las diferencias de coste real y convierta el paisaje en una meseta; y el mismo esqueleto de búsqueda local sirve para problemas de naturaleza distinta cambiando solo la representación, el vecindario y la función objetivo.
- Tabla comparativa de métodos
| Método | Tipo | Garantía de óptimo | Coste computacional | Parámetros | Cuándo usarlo |
|---|---|---|---|---|---|
| Fuerza bruta | Exacto | Sí | n! o 2ⁿ: solo tamaños minúsculos | Ninguno | Validar otros métodos; problemas de juguete |
| Ramificación y poda, programación dinámica, programación lineal | Exacto | Sí (si el problema tiene la estructura adecuada) | Exponencial en el peor caso; muy eficaz en muchos casos reales | Pocos | Cuando existe un solver adecuado (asignación, flujo, planificación lineal) |
| Vecino más próximo y otros constructivos | Heurístico | No; típicamente 20-25 % peor que el óptimo en el viajante | Muy bajo (n²) | Ninguno | Solución de arranque; respuesta instantánea |
| Ascenso de colina (+ reinicios) | Búsqueda local | No; óptimo local | Bajo por reinicio, escalable | Vecindario, nº de reinicios | Primera opción: simple y a menudo suficiente |
| Recocido simulado | Metaheurístico | No, pero converge al óptimo con enfriamiento infinitamente lento | Medio, controlable | T₀, ritmo de enfriamiento | Espacios con muchos óptimos locales; una sola solución buena |
| Algoritmo genético | Metaheurístico | No | Medio-alto (población × generaciones), paralelizable | Población, torneo, cruce, mutación, elitismo | Sin movimiento local natural; varias soluciones diversas; evaluación paralela |
La regla de decisión de Marta: si el problema es pequeño o tiene estructura conocida, exacto (y hay solvers de programación lineal excelentes, aunque quedan fuera de este curso); si no, ascenso de colina con reinicios como base, recocido simulado cuando se atasca, y genético cuando la representación lo pide. Y siempre validar en pequeño contra la fuerza bruta, como hemos hecho.
- Enlace con el módulo 4: aprender es optimizar
Cerramos con la conexión más importante de la lección. Recuerda aprender_umbral de 01-02: buscaba el umbral que mejor separaba dos clases probando valores. Eso ya era optimización: variable (el umbral), objetivo (aciertos), método (fuerza bruta sobre una rejilla). Todo entrenamiento de un modelo de aprendizaje automático es un problema de optimización: las variables son los parámetros del modelo (unos pocos en una regresión, miles de millones en un gran modelo de lenguaje), la función objetivo es una medida de error sobre los datos de entrenamiento (la "función de pérdida"), y las restricciones o penalizaciones son las técnicas de regularización de 04-06. La diferencia con esta lección es que, cuando los parámetros son números continuos y la función de pérdida es suave, no hace falta probar vecinos al azar: se puede calcular en qué dirección desciende la pérdida (su gradiente) y dar un paso en ella, una y otra vez. Ese método, el descenso del gradiente, es el "ascenso de colina con brújula" que hace funcionar las redes neuronales, y lo estudiaremos en 05-03. Todo lo que has aprendido hoy sobre óptimos locales, tamaño de paso (aquí, temperatura y vecindario) y validación te servirá tal cual allí.
Errores Comunes y Consejos
- Confundir el óptimo local con el global: un ascenso de colina que "no mejora más" no ha terminado, se ha atascado. Reinicia, cambia el vecindario o usa recocido antes de dar la solución por buena.
- No validar en pequeño: antes de confiar en una metaheurística para 40 paradas, comprueba que en 7 u 8 encuentra el mismo óptimo que la fuerza bruta. Un error en la función objetivo (por ejemplo, olvidar el tramo de vuelta al almacén) pasa desapercibido sin esa comprobación.
- Semillas y reproducibilidad: los métodos de esta lección son estocásticos. Fija la semilla (
random.Random(semilla)) para poder reproducir resultados y depurar, y evalúa siempre varias semillas antes de sacar conclusiones sobre un parámetro. - Enfriar demasiado rápido en el recocido convierte el algoritmo en un ascenso de colina caro; enfriar demasiado lento desperdicia tiempo. Empieza con T₀ del orden de un empeoramiento "grande" típico y ajusta mirando cuántos movimientos a peor se aceptan.
- Cruce que rompe la representación: en permutaciones, un cruce ingenuo produce rutas con paradas repetidas o ausentes. Usa OX u otro operador que preserve permutaciones y comprueba tras cada cruce que
sorted(hijo) == sorted(padre1). - Penalización mal calibrada: demasiado baja y la solución "óptima" viola la restricción; demasiado alta y el algoritmo no distingue entre soluciones factibles. Comprueba siempre
exceso == 0en la solución final. - Olvidar guardar la mejor solución vista en el recocido: la solución actual puede empeorar al aceptar movimientos a peor; devuelve siempre
mejor, noactual. - Optimizar el objetivo equivocado: como avisó 02-01, si el coste solo mide kilómetros, la ruta óptima puede incumplir todas las franjas horarias. Añade al objetivo (o como penalización) todo lo que importe.
Ejercicios
Ejercicio 1: vecino más próximo como punto de partida
Implementa la heurística constructiva del vecino más próximo: partiendo del almacén, ir siempre a la entrega pendiente más cercana. Aplícala a ENTREGAS con MATRIZ y a ENTREGAS15 con DIST15, compara con los óptimos conocidos (39,0 y 36,45 km) y úsala como ruta inicial del ascenso de colina. ¿Mejora el resultado respecto a un inicio aleatorio?
Ejercicio 2: 2-opt en el ascenso de colina
Escribe vecinos_2opt(ruta) que genere todas las rutas obtenidas al invertir un segmento ruta[i:j+1] y úsalo en lugar de vecinos_intercambio dentro de ascenso_colina (parametriza la función de vecindario). Compara, para 20 inicios aleatorios sobre ENTREGAS15, cuántas veces alcanza 36,45 km cada vecindario.
Ejercicio 3: capacidad más ajustada en la asignación
Cambia CAPACIDAD a {"Getafe": 30, "Zaragoza": 32} y vuelve a ejecutar ascenso_asignacion desde la solución ingenua. ¿Qué pedidos se mueven ahora, cuál es el coste final y es factible? Después baja PENALIZACION a 2 y observa qué ocurre con el exceso de la solución final. Explica el resultado.
Soluciones
Solución 1.
def vecino_mas_proximo(entregas, dist, origen="Almacen_Getafe"):
pendientes = set(entregas)
ruta, actual = [], origen
while pendientes:
siguiente = min(pendientes, key=lambda e: dist[actual][e])
ruta.append(siguiente)
pendientes.remove(siguiente)
actual = siguiente
return ruta
for entregas, dist, nombre in [(ENTREGAS, MATRIZ, "7 barrios"), (ENTREGAS15, DIST15, "15 direcciones")]:
r = vecino_mas_proximo(entregas, dist)
km = longitud_ruta(r, dist)
r2, km2, pasos = ascenso_colina(r, dist)
print(f"{nombre}: vecino más próximo {km:.2f} km -> tras ascenso {km2:.2f} km ({pasos} pasos)")Con los 7 barrios el vecino más próximo da 39,5 km (Leganés, Carabanchel, Usera, Arganzuela, Retiro, Vallecas, Villaverde: recorre el mapa en sentido horario y vuelve por Villaverde), a solo 0,5 km del óptimo, y el ascenso por intercambio no consigue mejorarlo (0 pasos): es un óptimo local muy cercano al global. Con las 15 direcciones da 37,82 km y un solo paso de ascenso lo lleva a 36,45. Una buena solución inicial ahorra mucho trabajo (compara con los 51,8 km del inicio aleatorio), pero por sí sola no garantiza el óptimo: sigue haciendo falta la búsqueda local, y en general los reinicios o el recocido.
Solución 2.
def vecinos_2opt(ruta):
for i in range(len(ruta)):
for j in range(i + 1, len(ruta)):
v = ruta[:]
v[i:j + 1] = reversed(v[i:j + 1])
yield v
def ascenso_colina_generico(ruta_inicial, dist, vecindario):
actual, km_actual = ruta_inicial[:], longitud_ruta(ruta_inicial, dist)
while True:
mejor, mejor_km = None, km_actual
for v in vecindario(actual):
km = longitud_ruta(v, dist)
if km < mejor_km:
mejor, mejor_km = v, km
if mejor is None:
return actual, km_actual
actual, km_actual = mejor, mejor_km
for nombre, vecindario in [("intercambio", vecinos_intercambio), ("2-opt", vecinos_2opt)]:
rng = random.Random(0)
aciertos = 0
for _ in range(20):
ini = ENTREGAS15[:]
rng.shuffle(ini)
_, km = ascenso_colina_generico(ini, DIST15, vecindario)
aciertos += round(km, 2) == 36.45
print(f"{nombre}: {aciertos} de 20 inicios alcanzan 36.45 km")Salida: intercambio: 1 de 20 inicios alcanzan 36.45 km frente a 2-opt: 15 de 20 inicios alcanzan 36.45 km. Invertir segmentos deshace cruces de la ruta, que es la fuente típica de óptimos locales malos en el viajante, y por eso 2-opt es el vecindario estándar en este problema.
Solución 3. Con {"Getafe": 30, "Zaragoza": 32} la ingenua tiene 8 cajas de exceso (399 penalizado). El ascenso mueve P008 (centro, 5 cajas) y después P015 (centro, 3 cajas) a Zaragoza, dejando 30 y 30 cajas: 263 €, factible, y de nuevo es el óptimo exacto (compruébalo con itertools.product). Con PENALIZACION = 2, mover P008 a Zaragoza cuesta 15 € más de envío pero solo ahorra 10 € de multa (5 cajas × 2 €), así que el algoritmo no mueve nada y devuelve la asignación ingenua (255 € penalizados, 239 € reales) con 8 cajas de exceso: la penalización es demasiado barata para representar una restricción que en la realidad es dura (el almacén simplemente no puede preparar más). Moraleja: la penalización debe superar el mayor ahorro que se pueda obtener violando la restricción.
Conclusión
Con esta lección hemos completado la caja de herramientas algorítmica del módulo. Hemos formulado los problemas de optimización con sus tres componentes (variables, objetivo, restricciones), hemos distinguido los métodos exactos de los aproximados, y hemos construido tres algoritmos de búsqueda local y metaheurísticas: el ascenso de colina, que sube hasta el primer óptimo local y se rescata con reinicios aleatorios; el recocido simulado, que acepta empeoramientos con una probabilidad que decrece con la temperatura para escapar de los valles; y el algoritmo genético, que hace evolucionar una población mediante torneo, cruce OX, mutación y elitismo. Los tres han resuelto el problema del viajante de la furgoneta de NovaMarket (39,0 km en 7 barrios, validado contra la fuerza bruta; 36,45 km en 15 direcciones, donde la fuerza bruta es imposible) sobre la matriz de distancias que Dijkstra nos dio en 03-02, y el mismo esqueleto ha resuelto la asignación de pedidos a Getafe y Zaragoza convirtiendo la capacidad en una penalización. Por último hemos visto que entrenar un modelo es optimizar, la idea que enlaza este módulo con los dos siguientes.
Con esto cerramos el módulo 3. Has recorrido el camino que va de la definición de algoritmo y la explosión combinatoria (03-01) a la búsqueda de caminos con BFS, DFS, coste uniforme y A* sobre GRAFO_CIUDAD (03-02), la decisión frente a un adversario con minimax y poda alfa-beta (03-03) y la optimización de soluciones completas con búsqueda local y metaheurísticas (03-04). El planificador de rutas y la asignación de pedidos de NovaMarket, que en 02-01 eran solo una formulación, ahora tienen código que funciona; en 09-01 volverás a ellos con más ejercicios. En el módulo 4, Aprendizaje Automático, cambiaremos de paradigma: en lugar de programar nosotros el algoritmo que resuelve el problema, daremos al sistema datos históricos (pedidos.csv, resenas.csv, incidencias.csv) para que aprenda por sí mismo la regla, y veremos que, por dentro, ese aprendizaje es una búsqueda del mejor modelo en un espacio enorme, con las mismas ideas de objetivo, óptimo local y validación que acabas de dominar. Marta y Diego pasarán de optimizar rutas a predecir la demanda y detectar el fraude.
Fundamentos de Inteligencia Artificial (IA)
Módulo 1: Introducción a la Inteligencia Artificial
Módulo 2: Principios Básicos de la IA
- Conceptos Fundamentales: Agentes, Entornos y Racionalidad
- Tipos de Inteligencia Artificial
- Los Datos como Materia Prima de la IA
- Ética y Consideraciones en IA
Módulo 3: Algoritmos en IA
- Introducción a los Algoritmos
- Algoritmos de Búsqueda
- Búsqueda con Adversario: Juegos y Minimax
- Algoritmos de Optimización
Módulo 4: Aprendizaje Automático (Machine Learning)
- Conceptos Básicos de Machine Learning
- Tipos de Aprendizaje Automático
- Preparación de Datos y Características
- Algoritmos de Machine Learning
- Evaluación y Validación de Modelos
- Sobreajuste, Regularización y Ajuste de Hiperparámetros
Módulo 5: Redes Neuronales y Deep Learning
- Introducción a las Redes Neuronales
- Arquitectura de Redes Neuronales
- Cómo Aprende una Red: Descenso del Gradiente y Retropropagación
- Deep Learning y sus Aplicaciones
- Transformers, Grandes Modelos de Lenguaje e IA Generativa
Módulo 6: Lógica y Sistemas Expertos
- Lógica en IA
- Sistemas Expertos
- Razonamiento con Incertidumbre: Probabilidad y Redes Bayesianas
- Aplicaciones de Sistemas Expertos
Módulo 7: Herramientas y Lenguajes de Programación en IA
- Lenguajes de Programación para IA
- Python Científico: NumPy, pandas y Matplotlib
- Herramientas y Librerías Populares
- Entornos de Desarrollo
Módulo 8: Proyectos y Casos de Estudio
Módulo 9: Ejercicios y Prácticas
- Ejercicios de Algoritmos
- Prácticas de Machine Learning
- Proyectos de Redes Neuronales
- Proyecto Integrador: de la Idea al Prototipo
