Ir al contenido
wannacode

Descenso del gradiente

En resumen: El descenso del gradiente encuentra los pesos de un modelo paso a paso: calcula hacia dónde crece el error y mueve los pesos un poco en la dirección contraria, tanto como diga la tasa de aprendizaje.

Problema

La mejor recta, sin adivinar

Los compañeros de la lección sobre regresión lineal eligieron una recta a ojo. Ahora el almacén quiere que el programa encuentre la mejor por sí solo. Solo hay una fórmula exacta para la recta, y los siguientes modelos (las redes neuronales) no tienen ninguna. Necesitamos un camino que funcione siempre.

Por ahora la tarea es más pequeña: el almacén es conocido (6 horas), y solo buscamos cuántas horas añade cada 100 km, es decir, w. La distancia está en cientos de km para que los números sean pequeños:

# distancia en cientos de km (0.3 son 30 km) y horas hasta la entrega
DIST = [0.3, 0.5, 0.8, 1.0, 1.3, 1.6, 2.0, 2.4, 2.7, 3.0]
HOURS = [10, 11, 17, 17, 23, 24, 31, 33, 40, 44]
B = 6 # el almacén: 6 horas, se sabe
NUDGE = 0.0001

La pérdida es el mismo MSE de la regresión lineal, ahora solo como función de w:

# el MSE de la recta horas = w · distancia + 6
def loss(w):
total = 0
for x, hours in zip(DIST, HOURS):
error = w * x + B - hours
total += error * error
return total / len(DIST)
zip(DIST, HOURS) recorre las dos listas a la vez: en cada paso da una distancia y sus horas.

Podríamos probar w = 0, 1, 2, … hasta 100 y quedarnos con la pérdida más pequeña. Con un solo w funciona. Con 10 pesos son 100¹⁰ intentos, y una red neuronal tiene millones de pesos.

La idea

Empujar y mirar

En lugar de probarlo todo, te pones en un w y preguntas: si me muevo un poco a la derecha, ¿la pérdida baja o sube?

before = loss(0)
after = loss(0.01)
print(f"w = 0: pérdida {before:.2f}")
print(f"w = 0.01: pérdida {after:.2f}")
print(f"pendiente: {(after - before) / 0.01:.2f}")
Salida
w = 0: pérdida 485.00
w = 0.01: pérdida 484.21
pendiente: -78.97

El empujón de 0,01 bajó la pérdida en 0,79. Dividido entre el empujón, esa es la pendiente: −78,97, cuánto cambia la pérdida por unidad de w. El menos significa «a la derecha es cuesta abajo».

Con un empujón menor (0,0001) la pendiente es más exacta. Aquí en cuatro puntos:

# la pendiente: empuja w un poco y mira cuánto cambia la pérdida
def slope(w):
return (loss(w + NUDGE) - loss(w)) / NUDGE
for w in [0, 6, 12, 18]:
print(f"w = {w}: pérdida {loss(w):.2f}, pendiente {slope(w):.2f}")
Salida
w = 0: pérdida 485.00, pendiente -79.00
w = 6: pérdida 127.21, pendiente -40.26
w = 12: pérdida 1.83, pendiente -1.53
w = 18: pérdida 108.87, pendiente 37.21

La pendiente dice dos cosas. El signo es la dirección: menos significa que w debe crecer, más que debe disminuir (en w = 18 ya hemos pasado el fondo). El tamaño dice lo lejos que estamos: −79 está lejos del fondo, −1,53 está casi en el fondo.

La idea

Un paso contra la pendiente

La regla es una línea: w -= tasa · pendiente. Contra la pendiente, porque la pendiente apunta cuesta arriba. Con pendiente negativa, w aumenta; con positiva, disminuye. Cuanto más empinado, mayor el paso.

Una pasada (calcular la pendiente, dar un paso) es una época. Repetimos:

w = 0
rate = 0.1
for epoch in range(1, 11):
w -= rate * slope(w)
print(f"época {epoch}: w = {w:.2f}, pérdida {loss(w):.2f}")
Salida
época 1: w = 7.90, pérdida 62.36
época 2: w = 10.70, pérdida 9.28
época 3: w = 11.69, pérdida 2.61
época 4: w = 12.04, pérdida 1.77
época 5: w = 12.17, pérdida 1.67
época 6: w = 12.21, pérdida 1.65
época 7: w = 12.23, pérdida 1.65
época 8: w = 12.23, pérdida 1.65
época 9: w = 12.24, pérdida 1.65
época 10: w = 12.24, pérdida 1.65

El primer paso es grande (de 0 a 7,90) porque la curva es empinada. Cerca del fondo la pendiente se hace menor, y con ella los pasos: a partir de la época 7, w casi no se mueve. La pérdida de 1,65 es menor que la 1,83 de la recta que los compañeros eligieron a ojo.

Eso es el descenso del gradiente: paso a paso por la pendiente de la pérdida hasta llegar al fondo. «Gradiente» es la pendiente cuando hay más de un parámetro.

Míralo

La bola en la curva

def slope(w):
return (loss(w + NUDGE) - loss(w)) / NUDGE
w = 0
rate = 0.1
for epoch in range(1, 11):
w -= rate * slope(w)
Paso a paso, en palabras
  1. Abajo: la pérdida para cada w; la bola parte de w = 0. Arriba: la recta con este w.
  2. Empuja w un poco: la pérdida baja. La pendiente es -79,00: cuesta abajo es hacia la derecha.
  3. Un paso: w -= 0,1 · (-79,00), así que w pasa a ser 7,90. Contra la pendiente, es decir, cuesta abajo.
  4. Cada pasada es una época. Cerca del fondo la pendiente se hace menor, y con ella los pasos. Tras 10: w = 12,24.
  5. Tasa 0,01: la bola gris se arrastra. Tras 10 épocas está solo en w = 5,96.
  6. Tasa 0,35: la bola roja salta por encima del fondo, cada vez más arriba. La pérdida crece: diverge.
  7. Con 0,1 la bola se queda en el fondo: w = 12,24, pérdida 1,65. Nadie adivinó w: se encontró.

Abajo está la pérdida para cada w, y la bola es el w actual. Arriba está la recta con ese w sobre los pedidos: cuando la bola llega al fondo, la recta pasa por los puntos. La bola gris y la roja son la misma regla con otra tasa.

La idea

La tasa de aprendizaje

El número por el que se multiplica la pendiente es la tasa de aprendizaje. Decide lo largo que es el paso. Tres tasas, 20 épocas cada una (w tras las tres primeras y tras la vigésima):

for rate in [0.01, 0.1, 0.35]:
w = 0
seen = []
for epoch in range(1, 21):
w -= rate * slope(w)
if epoch <= 3 or epoch == 20:
seen.append(f"{w:.2f}")
print(f"tasa {rate:.2f}: w = {', '.join(seen)}, pérdida {loss(w):.2f}")
Salida
tasa 0.01: w = 0.79, 1.53, 2.22, 9.02, pérdida 35.14
tasa 0.10: w = 7.90, 10.70, 11.69, 12.24, pérdida 1.65
tasa 0.35: w = 27.65, -7.18, 36.69, -1224.61, pérdida 4938139.70
  • 0,01 es muy pequeña. La dirección es correcta, pero tras 20 épocas w es solo 9,02. Llega al fondo, pero tras cientos de épocas.
  • 0,1 es la justa. Tres pasos y w está cerca, luego se asienta en 12,24.
  • 0,35 es muy grande. El primer paso se pasa del fondo hasta 27,65. Allí la pendiente es aún mayor, el siguiente paso es aún más largo y w vuela a −7,18, luego a 36,69. La pérdida llega a millones: el descenso diverge.

Una buena tasa no se calcula, se prueba. Lo habitual es empezar con 0,1 o 0,01 y mirar la pérdida por época: si crece o salta, la tasa es muy grande; si apenas baja, es muy pequeña.

La idea

w y b juntos

Hasta ahora b era conocido. Ahora lo buscamos también: cada parámetro tiene su propia pendiente (empujas solo ese), y cada uno da su propio paso en la misma época.

# el MSE de la recta horas = w · distancia + b
def loss(w, b):
total = 0
for x, hours in zip(DIST, HOURS):
error = w * x + b - hours
total += error * error
return total / len(DIST)
w, b, rate = 0, 0, 0.1
for epoch in range(1, 1001):
slope_w = (loss(w + NUDGE, b) - loss(w, b)) / NUDGE
slope_b = (loss(w, b + NUDGE) - loss(w, b)) / NUDGE
w -= rate * slope_w
b -= rate * slope_b
if epoch in (1, 10, 100, 1000):
print(f"época {epoch}: w = {w:.2f}, b = {b:.2f}, pérdida {loss(w, b):.2f}")
Salida
época 1: w = 9.77, b = 5.00, pérdida 29.77
época 10: w = 12.21, b = 6.03, pérdida 1.66
época 100: w = 12.41, b = 5.65, pérdida 1.62
época 1000: w = 12.41, b = 5.64, pérdida 1.62

Tras 1000 épocas, w = 12,41 horas por 100 km y b = 5,64 horas. La fórmula de la mejor recta de la regresión lineal da 0,1241 horas por km y 5,64 horas: la misma recta. El descenso no conoce la fórmula; solo va cuesta abajo.

La idea

La escala de las características

¿Por qué estaba la distancia en cientos de km? Aquí están los mismos pedidos en km:

# los mismos pedidos, pero la distancia está en km
KM = [30, 50, 80, 100, 130, 160, 200, 240, 270, 300]
HOURS = [10, 11, 17, 17, 23, 24, 31, 33, 40, 44]
NUDGE = 0.0001

Con una tasa de 0,1, el descenso diverge desde la primera época:

w, b, rate = 0, 0, 0.1
for epoch in range(1, 4):
slope_w = (loss(w + NUDGE, b) - loss(w, b)) / NUDGE
slope_b = (loss(w, b + NUDGE) - loss(w, b)) / NUDGE
w -= rate * slope_w
b -= rate * slope_b
print(f"tasa 0.1, época {epoch}: w = {w:.0f}")
Salida
tasa 0.1, época 1: w = 977
tasa 0.1, época 2: w = -6304921
tasa 0.1, época 3: w = 40699231079

La pendiente de w crece con el tamaño de la característica: cada w se multiplica por 300 en vez de por 3, y con w = 0 la pendiente es casi −10 000. Así que bajamos la tasa hasta que deje de divergir:

w, b, rate = 0, 0, 0.00002
for epoch in range(1, 1001):
slope_w = (loss(w + NUDGE, b) - loss(w, b)) / NUDGE
slope_b = (loss(w, b + NUDGE) - loss(w, b)) / NUDGE
w -= rate * slope_w
b -= rate * slope_b
if epoch in (10, 100, 1000):
print(f"época {epoch}: w = {w:.4f}, b = {b:.4f}, pérdida {loss(w, b):.2f}")
Salida
época 10: w = 0.1513, b = 0.0013, pérdida 9.44
época 100: w = 0.1513, b = 0.0063, pérdida 9.42
época 1000: w = 0.1510, b = 0.0563, pérdida 9.28

Ahora no diverge, pero b apenas se mueve: tras 1000 épocas vale 0,06 en vez de 5,64, y w está en 0,151 en vez de 0,124. Para b el paso es demasiado pequeño. Una sola tasa no sirve para los dos. La solución es la escala: las características se llevan a un rango parecido antes de entrenar (aquí dividiendo entre 100; a menudo se resta la media y se divide entre la dispersión). Sin eso ninguna tasa resuelve el problema.

Pruébalo

Predice la salida

w vale 2, la pendiente allí es −20 y la tasa es 0,1. ¿Qué imprimirá un paso?

w = 2
slope = -20
rate = 0.1
w -= rate * slope
print(f"{w:.1f}")
Respuesta

Pruébalo

Completa

¿Qué operación da el paso contra la pendiente?

for epoch in range(1, 11):
    w  rate * slope(w)

Pruébalo

Encuentra el error

Un compañero escribe un descenso, pero la pérdida por época crece cada vez más deprisa. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Cuando el descenso duele

  • La tasa equivocada. Si es muy grande, diverge; si es muy pequeña, se arrastra. Si la pérdida por época crece o salta, baja primero la tasa.
  • Características en escalas distintas. Km y número de paquetes en una misma línea: las pendientes difieren cientos de veces y ninguna tasa sirve. Escala las características antes de entrenar.
  • No solo un valle. La pérdida de una recta es un cuenco liso con un fondo. En las redes neuronales la curva tiene muchos valles y mesetas: el descenso llega a algún fondo, no necesariamente al más bajo, y el resultado depende del w inicial.
  • Cuándo parar. Aquí las épocas son fijas. En la práctica paras cuando la pérdida deja de bajar, y si el modelo es bueno para pedidos nuevos se mide aparte (evaluar el modelo).
  • El coste. Cada época recorre todos los datos. Con millones de ejemplos y millones de pesos, el entrenamiento dura horas en servidores especiales; esa infraestructura (máquinas para entrenar, poner el modelo en producción) se enseña en el curso de DevOps ZeroToOps.
Más precisamente, para curiosos

La pendiente con fórmula. Empujar es una aproximación. Para el MSE la pendiente tiene una fórmula exacta (la derivada): 2 · la media de error · distancia. Aquí están las dos formas lado a lado:

# la pendiente con la fórmula: 2 · la media de error · distancia
def slope_by_formula(w):
total = 0
for x, hours in zip(DIST, HOURS):
error = w * x + B - hours
total += error * x
return 2 * total / len(DIST)
for w in [0, 12]:
print(f"con w = {w}: con números {slope(w):.2f}, con fórmula {slope_by_formula(w):.2f}")
Salida
con w = 0: con números -79.00, con fórmula -79.00
con w = 12: con números -1.53, con fórmula -1.53

Coinciden hasta el segundo decimal. La fórmula es más rápida (una pasada en lugar de dos por parámetro), por eso las bibliotecas calculan las derivadas automáticamente a partir del código del modelo. Eso es la retropropagación, de la lección sobre entrenar una red.

El gradiente. Con muchos parámetros, sus pendientes juntas son el gradiente (un vector). El paso es el mismo para cada uno: parámetro -= tasa · su pendiente.

Partes de los datos. Con millones de ejemplos una época es lenta. Por eso la pendiente se calcula en una parte pequeña al azar (un mini-batch) y hay muchos más pasos. Es el descenso del gradiente estocástico (SGD). Sus versiones mejoradas (Adam, por ejemplo) guardan además un «impulso» de los pasos anteriores.

Ejercicio

El mensajero, aprendido solo

Resuelve la tarea en tu editor.

En la lección sobre regresión lineal los compañeros compararon tres rectas para el precio del mensajero. Ahora haz que el programa encuentre la mejor por sí solo:

# paquetes: peso en kg y precio del mensajero en €
KG = [0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0, 6.0]
EUR = [4.10, 4.60, 5.00, 5.60, 6.40, 7.50, 8.30, 9.40]
NUDGE = 0.0001

Escribe la función de pérdida (MSE) para precio = w · kg + b, y luego 1000 épocas de descenso del gradiente para w y b juntos, desde w = 0 y b = 0, con una tasa de 0,05 y un empujón de 0,0001. Imprime w, b y la pérdida con 4 decimales cada uno.

Salida
w = 0.9538, b = 3.6204, MSE 0.0038

Más tareas

  • La tasa. Ejecútalo con una tasa de 0,1. ¿Qué pasa? Encuentra la mayor tasa con la que la pérdida todavía baja.
  • Parar. En lugar de 1000 épocas, para cuando las dos pendientes sean menores que 0,001 en valor absoluto. ¿Después de cuántas épocas?
  • Comparación. ¿Cuánto menor es la pérdida que la de la mejor recta de los compañeros (A, MSE 0,0144)?

Test

Ponte a prueba

1. La pendiente de la pérdida en el w actual es −40. ¿Hacia dónde debe moverse w?
2. Con una tasa de 0,35, w salta a 27,65, −7,18, 36,69, y la pérdida crece. ¿Qué haces?
3. Con una tasa de 0,01, w vale 5,96 tras 10 épocas, y el fondo está cerca de 12. ¿Está mal la tasa?
4. ¿Por qué el descenso se dispara con la distancia en km (30 a 300) con una tasa que funciona con cientos de km (0,3 a 3)?
5. El descenso para la recta de las entregas llega a w = 12,41 y b = 5,64. La fórmula de la regresión lineal da 0,1241 h/km y 5,64 h. ¿Para qué sirve entonces el descenso?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·