Capítulo 5 de 15 7 secciones 19 min

Cómo sabe la red hacia dónde corregir

La pérdida, la pendiente y el paso. Y por qué sin escalar no existe ningún paso que funcione, que era lo que quedó pendiente.

Entrenar es repetir tres cosas: calcular cuánto te equivocas (la pérdida), calcular hacia dónde crece ese error (el gradiente) y dar un paso en dirección contraria. Y el escalado no es opcional: con columnas de escalas distintas, cualquier paso desde 0,0001 hace que la pérdida explote, y uno más pequeño deja la pérdida en 0,6916 después de 20.000 vueltas, o sea sin moverse del punto de partida.

En el capítulo 4 la red aprendió y yo te dije que no miraras las líneas del "hacia atrás". Aquí las miramos 🔍

Y de paso pago lo que quedé debiendo en el capítulo 1, cuando dije que la razón de escalar estaba en el gradiente. Está, y se ve clarísima.

Primero: cuánto te equivocas

L=1ni=1n(yiy^i)2

el error medio al cuadrado, que castiga mucho más un fallo grande que dos medianos

Para corregir hace falta un número que diga cómo de mal vas. Ese número es la pérdida, y para un sí o no se usa esta:

import numpy as np

def sigmoide(z):
    return 1 / (1 + np.exp(-z))

def perdida(p, y):
    p = np.clip(p, 1e-12, 1 - 1e-12)      # para que log(0) no reviente
    return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))

y = np.array([1., 1., 0., 0.])
print('acertando de lleno  :', round(perdida(np.array([.99, .99, .01, .01]), y), 4))
print('sin idea (0,5 a todo):', round(perdida(np.array([.5, .5, .5, .5]), y), 4))
print('equivocada de lleno :', round(perdida(np.array([.01, .01, .99, .99]), y), 4))
acertando de lleno  : 0.0101
sin idea (0,5 a todo): 0.6931
equivocada de lleno : 4.6052

Se llama entropía cruzada y funciona así: castiga estar seguro y equivocado mucho más que estar dudando.

Fíjate en los números. Dudar cuesta 0,6931, que ya salió en el capítulo 4 y es ln(2). Y estar seguro y equivocado cuesta 4,6052, o sea casi siete veces más.

Esa asimetría es a propósito. Un modelo que dice "70%" y falla es un modelo que se puede arreglar; uno que dice "99%" y falla está roto 😬

Curvas de nivel de la función de pérdida con la trayectoria del descenso de gradiente desde el punto de inicio hasta el mínimo. Los pasos son largos al principio, donde la pendiente es fuerte, y se acortan al acercarse al fondo.
La montaña con niebla, dibujada. Cada punto es una actualización de los pesos, y los pasos se acortan solos al llegar al valle porque la pendiente es menor, no porque nadie los frene. Con una tasa de aprendizaje más grande, esa trayectoria salta por encima del mínimo y rebota sin llegar nunca.

Segundo: hacia dónde crece el error

El gradiente es una lista de números, uno por peso, que dice cuánto sube la pérdida si subes ese peso un poquito.

Para una neurona con sigmoide y entropía cruzada, sale una fórmula sorprendentemente corta:

rng = np.random.default_rng(0)
X = rng.normal(0, 1, (200, 3))
y = (rng.random(200) < sigmoide(X @ np.array([1., -0.5, 0.3]))).astype(float)

w = np.array([0.2, -0.1, 0.4])
b = 0.05

error = sigmoide(X @ w + b) - y
gradiente = X.T @ error / len(y)
print('gradiente:', np.round(gradiente, 6))
gradiente: [-0.139486  0.142932  0.048427]

Esa línea, X.T @ (predicho - real) / n, es todo el cálculo. Sale de derivar la pérdida y se simplifica muchísimo porque la sigmoide y la entropía cruzada están hechas la una para la otra.

Ahora, ¿cómo sabemos que esa fórmula está bien? Se comprueba moviendo el peso a mano y midiendo cuánto cambia la pérdida:

def perdida_de(w, b):
    return perdida(sigmoide(X @ w + b), y)

h = 1e-5
numerico = np.zeros(3)
for i in range(3):
    arriba = w.copy(); arriba[i] += h
    abajo = w.copy(); abajo[i] -= h
    numerico[i] = (perdida_de(arriba, b) - perdida_de(abajo, b)) / (2 * h)

print('por fórmula:', np.round(gradiente, 8))
print('moviendo    :', np.round(numerico, 8))
print('diferencia  :', float(np.abs(gradiente - numerico).max()))
por fórmula: [-0.1394857   0.14293227  0.0484271 ]
moviendo    : [-0.1394857   0.14293227  0.0484271 ]
diferencia  : 6.669387264679472e-12

6,7e-12 de diferencia, o sea que la fórmula está bien 🎯

Esto tiene nombre, gradient checking, y es lo que se hace cuando escribes una capa nueva a mano y no estás segura de la derivada. Es lento (hay que evaluar la pérdida dos veces por peso) así que solo se usa para comprobar, nunca para entrenar.

Te lo enseño porque es la herramienta que convierte "creo que la derivada está bien" en "está bien", y esa diferencia vale mucho.

Una parábola que representa el error según el valor de un peso, con una cadena de puntos que baja por la ladera dando pasos cada vez más cortos hasta quedarse en el fondo.
Cada punto es una vuelta de entrenamiento. Los pasos se acortan solos porque la pendiente se aplana cerca del mínimo, y esa es la razón de que el descenso frene en vez de pasarse.
El ciclo de entrenamiento en cuatro pasos que se repiten miles de veces: paso hacia adelante que produce una prediccion, calculo de la perdida, paso hacia atras que reparte la culpa entre los pesos, y actualizacion de cada peso en la direccion que reduce el error.
Retropropagacion es solo el paso 3: repartir la culpa del error entre los pesos, yendo de atras hacia adelante. Su merito no es matematico sino de eficiencia, porque reutiliza lo ya calculado en vez de medir cada peso por separado.

Tercero: dar el paso

wwηLw

mueves cada peso un poquito en la dirección contraria a la pendiente, y ese poquito es la tasa de aprendizaje

El gradiente apunta hacia donde el error crece. Así que para mejorar hay que ir al revés:

print('pérdida ahora     :', round(perdida_de(w, b), 6))
print('un paso a favor   :', round(perdida_de(w - 0.5 * gradiente, b), 6))
print('un paso al revés  :', round(perdida_de(w + 0.5 * gradiente, b), 6))
pérdida ahora     : 0.648283
un paso a favor   : 0.628465
un paso al revés  : 0.67071

Restando baja de 0,6483 a 0,6285 y sumando sube a 0,6707. Por eso todas las líneas de corrección del libro llevan un menos 🔽

Y ese 0,5 que multiplica es el paso, o tasa de aprendizaje, que en el capítulo 4 ya vimos que puede arruinarlo todo por los dos lados.

Entrenar es repetir esas tres cosas hasta que deje de mejorar. Nada más.

Lo que quedó pendiente en el capítulo 1

Ahora sí. Armo un problema con dos columnas de escalas muy distintas, que es lo que pasa siempre en datos de verdad: un monto en cientos de soles y una satisfacción del 1 al 5.

n = 500
monto = rng.normal(800, 300, n)
satisfaccion = rng.normal(3.5, 1.0, n)
z = 0.002 * (monto - 800) + 0.8 * (satisfaccion - 3.5)
objetivo = (rng.random(n) < sigmoide(z)).astype(float)

crudo = np.column_stack([monto, satisfaccion])
escalado = (crudo - crudo.mean(axis=0)) / crudo.std(axis=0)

def baja(datos, paso, vueltas=200):
    w = np.zeros(datos.shape[1])
    b = 0.0
    inicial = perdida(sigmoide(datos @ w + b), objetivo)
    for _ in range(vueltas):
        e = sigmoide(datos @ w + b) - objetivo
        w -= paso * (datos.T @ e) / len(objetivo)
        b -= paso * e.mean()
    return inicial, perdida(sigmoide(datos @ w + b), objetivo)

for nombre, datos in [('sin escalar', crudo), ('escalado', escalado)]:
    for paso in [0.00001, 0.0001, 0.01, 0.1]:
        ini, fin = baja(datos, paso)
        estado = 'DIVERGE' if fin > ini else f'{fin:.4f}'
        print(f'{nombre:12} paso {paso:<8} {estado}')
sin escalar  paso 1e-05    0.6806
sin escalar  paso 0.0001   DIVERGE
sin escalar  paso 0.01     DIVERGE
sin escalar  paso 0.1      DIVERGE
escalado     paso 1e-05    0.6930
escalado     paso 0.0001   0.6920
escalado     paso 0.01     0.6175
escalado     paso 0.1      0.5544

Ahí está la respuesta 😳

Sin escalar, desde 0,0001 la pérdida explota. El único paso que sobrevive es 0,00001, y con ese la pérdida baja de 0,6931 a 0,6806 en 200 vueltas.

Con las columnas escaladas, ese mismo paso de 0,00001 apenas mueve nada (0,6930), pero como ahí sí puedes usar 0,1, la pérdida llega a 0,5544.

Compara el avance: sin escalar ganó 0,0125 de pérdida y escalado ganó 0,1387, o sea once veces más, en las mismas 200 vueltas.

El mecanismo es este. El gradiente lleva la escala de la columna dentro: la del monto sale unas trescientas veces más grande que la de satisfacción. Un paso suficientemente chico para no hacer explotar el peso del monto es trescientas veces demasiado chico para mover el de satisfacción. Y no existe un número que sirva para los dos.

Escalar es poner las dos columnas en la misma escala para que un solo paso les valga a las dos. Por eso no es una buena práctica: es la condición para que el descenso de gradiente funcione ⚖️

Y por eso también el StandardScaler va dentro del pipeline desde el capítulo 1.

Y ahora sobre las ventas de verdad

Todo lo anterior fue con datos que fabriqué yo, para poder comparar contra la respuesta verdadera. Vamos a soltar el mismo bucle sobre el CSV de la distribuidora 🐔

import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'

def carga_limpia(url):
    v = pd.read_csv(url).drop_duplicates()
    v['ciudad'] = (v['ciudad'].str.strip().str.lower()
                   .str.normalize('NFKD')
                   .str.encode('ascii', 'ignore').str.decode('utf-8'))
    v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
    for col in ['fecha', 'fecha_ultima_compra']:
        f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce')
        falta = f.isna() & v[col].notna()
        f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y', errors='coerce')
        v[col] = f
    return v

def prepara(v):
    v = v.sort_values(['cliente_id', 'fecha']).copy()
    v['sin_compra_previa'] = v['fecha_ultima_compra'].isna().astype(int)
    v['sin_descuento'] = v['descuento'].isna().astype(int)
    v['sin_satisfaccion'] = v['satisfaccion'].isna().astype(int)
    v['precio_unitario'] = v['monto'] / v['unidades']
    v['visita_numero'] = v.groupby('cliente_id').cumcount() + 1
    return v

NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion', 'precio_unitario',
             'sin_compra_previa', 'sin_descuento', 'sin_satisfaccion', 'visita_numero']
CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria']

ventas = prepara(carga_limpia(URL))
Xv = ventas[NUMERICAS + CATEGORICAS]
yv = ventas['compro']
Xv_tr, Xv_te, yv_tr, yv_te = train_test_split(Xv, yv, test_size=0.25,
                                              random_state=42, stratify=yv)

sklearn_modelo = Pipeline([
    ('pre', ColumnTransformer([
        ('num', Pipeline([('r', SimpleImputer(strategy='median')),
                          ('e', StandardScaler())]), NUMERICAS),
        ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
                          ('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
    ])),
    ('mod', LogisticRegression(max_iter=1000, random_state=42)),
]).fit(Xv_tr, yv_tr)

T_tr = sklearn_modelo.named_steps['pre'].transform(Xv_tr)
T_te = sklearn_modelo.named_steps['pre'].transform(Xv_te)
objetivo_v = yv_tr.values.astype(float)

w_mano = np.zeros(T_tr.shape[1])
b_mano = 0.0
for _ in range(3000):
    e = sigmoide(T_tr @ w_mano + b_mano) - objetivo_v
    w_mano -= 0.5 * (T_tr.T @ e) / len(objetivo_v)
    b_mano -= 0.5 * e.mean()

print('nuestro bucle:', round(roc_auc_score(yv_te, sigmoide(T_te @ w_mano + b_mano)), 4))
print('scikit-learn :', round(roc_auc_score(
    yv_te, sklearn_modelo.predict_proba(Xv_te)[:, 1]), 4))
nuestro bucle: 0.7216
scikit-learn : 0.7214

0,7216 el bucle que acabamos de escribir, contra 0,7214 la librería. Con veintiocho columnas de ventas peruanas de verdad, con su suciedad, sus nulos y su OneHotEncoder 🎉

Y no es que hayamos empatado por poco: las dos están resolviendo exactamente el mismo problema con el mismo método. La diferencia de dos diezmilésimas es que scikit-learn usa un optimizador más listo que el nuestro y llega a un sitio ligerísimamente distinto.

Ese es el punto de haber escrito todo esto a mano. Ya no hay ninguna parte de una regresión logística que no sepas hacer tú, y lo que viene en el capítulo 6 es la misma idea con capas 💪

Ejercicios

1. Ver la pérdida bajar vuelta a vuelta

Guarda la pérdida cada 20 vueltas y mírala.

w = np.zeros(2); b = 0.0
for i in range(201):
    p = sigmoide(escalado @ w + b)
    if i % 40 == 0:
        print(f'vuelta {i:3d}  pérdida {perdida(p, objetivo):.4f}')
    e = p - objetivo
    w -= 0.1 * (escalado.T @ e) / len(objetivo)
    b -= 0.1 * e.mean()
vuelta   0  pérdida 0.6931
vuelta  40  pérdida 0.5858
vuelta  80  pérdida 0.5639
vuelta 120  pérdida 0.5574
vuelta 160  pérdida 0.5552
vuelta 200  pérdida 0.5544

Baja rápido al principio y luego se aplana, que es la forma normal de una curva de entrenamiento.

Cuando veas una que baja y se queda plana pronto, no está rota: llegó. Y si baja a saltos o sube, el paso es demasiado grande 📉

2. Cuánto vale cada peso al final

Compara los pesos que encontró con los que usé para fabricar los datos.

print('pesos encontrados:', np.round(w, 4))
print('sesgo            :', round(float(b), 4))
print()
print('yo generé con 0.002 por sol de monto y 0.8 por punto de satisfacción')
print('en unidades escaladas eso es:',
      np.round([0.002 * crudo[:, 0].std(), 0.8 * crudo[:, 1].std()], 4))
pesos encontrados: [0.6997 1.0115]
sesgo            : 0.171

yo generé con 0.002 por sol de monto y 0.8 por punto de satisfacción
en unidades escaladas eso es: [0.5728 0.8234]

Encontró 0,6997 y 1,0115 donde la respuesta verdadera era 0,5728 y 0,8234. O sea que se pasó, y por bastante: un 22% y un 23% por encima.

Este ejercicio solo se puede hacer con datos fabricados, y por eso lo puse: es la única vez en todo el libro que sabemos la respuesta verdadera y podemos comprobar si el método la encuentra 🔬

Que se pase no significa que el método esté mal. Significa que 500 filas son pocas, y lo comprobamos en el ejercicio 3.

3. Cuánto se tarda en llegar de verdad

Dale muchas más vueltas y mira si alcanza los pesos reales.

for vueltas in [200, 1000, 5000, 20000]:
    ww = np.zeros(2); bb = 0.0
    for _ in range(vueltas):
        e = sigmoide(escalado @ ww + bb) - objetivo
        ww -= 0.1 * (escalado.T @ e) / len(objetivo)
        bb -= 0.1 * e.mean()
    print(f'{vueltas:6d} vueltas: pesos {np.round(ww, 4)}  '
          f'pérdida {perdida(sigmoide(escalado @ ww + bb), objetivo):.4f}')
   200 vueltas: pesos [0.699  1.0106]  pérdida 0.5544
  1000 vueltas: pesos [0.763  1.0924]  pérdida 0.5538
  5000 vueltas: pesos [0.763  1.0925]  pérdida 0.5538
 20000 vueltas: pesos [0.763  1.0925]  pérdida 0.5538

Con 1.000 vueltas ya llegó a 0,7630 y 1,0925, y de ahí no se mueve ni un decimal aunque le des veinte mil. La pérdida se queda clavada en 0,5538.

El 99% del trabajo se hace al principio, y ahí está la justificación de la parada temprana del capítulo 1 ⏱️

Y fíjate que converge a 0,7630 y 1,0925, que no son los 0,5728 y 0,8234 verdaderos. No le faltan vueltas: es que con 500 filas esos son los pesos que mejor explican los datos que tocaron, y los datos que tocaron no son el mundo. Eso es ruido de muestreo, no un fallo del entrenamiento.

4. Con más filas, ¿se acerca a la verdad?

Fabrica el mismo problema con más datos y mira si los pesos encontrados se pegan a los verdaderos.

for filas in [500, 5000, 50000]:
    g = np.random.default_rng(0)
    m = g.normal(800, 300, filas)
    s = g.normal(3.5, 1.0, filas)
    obj = (g.random(filas) < sigmoide(0.002 * (m - 800) + 0.8 * (s - 3.5))).astype(float)
    c = np.column_stack([m, s])
    esc = (c - c.mean(axis=0)) / c.std(axis=0)

    ww = np.zeros(2); bb = 0.0
    for _ in range(20000):
        e = sigmoide(esc @ ww + bb) - obj
        ww -= 0.1 * (esc.T @ e) / filas
        bb -= 0.1 * e.mean()

    verdad = np.array([0.002 * c[:, 0].std(), 0.8 * c[:, 1].std()])
    print(f'{filas:6d} filas: encontrado {np.round(ww, 4)}  '
          f'verdadero {np.round(verdad, 4)}')
   500 filas: encontrado [0.5796 0.6495]  verdadero [0.6082 0.7504]
  5000 filas: encontrado [0.635  0.8078]  verdadero [0.5972 0.8005]
 50000 filas: encontrado [0.594  0.8179]  verdadero [0.6008 0.7991]

Con 500 filas se queda en 0,5796 y 0,6495 contra 0,6082 y 0,7504. Con 50.000 llega a 0,5940 y 0,8179 contra 0,6008 y 0,7991: prácticamente clavados.

Confirmado, entonces: el desvío del ejercicio anterior era falta de datos, no un fallo del método. Y esto es lo que hay debajo de todo el libro de machine learning: con pocas filas, lo que encuentras es una mezcla de la verdad y de la suerte que te tocó 🎲

5. Cuántas vueltas hacen falta con datos de verdad

Mira el AUC sobre las ventas según las vueltas que le des.

for vueltas in [100, 500, 1500, 3000]:
    ww = np.zeros(T_tr.shape[1]); bb = 0.0
    for _ in range(vueltas):
        e = sigmoide(T_tr @ ww + bb) - objetivo_v
        ww -= 0.5 * (T_tr.T @ e) / len(objetivo_v)
        bb -= 0.5 * e.mean()
    print(f'{vueltas:5d} vueltas: AUC {roc_auc_score(yv_te, sigmoide(T_te @ ww + bb)):.4f}')
  100 vueltas: AUC 0.7189
  500 vueltas: AUC 0.7213
 1500 vueltas: AUC 0.7216
 3000 vueltas: AUC 0.7216

Con 100 vueltas ya saca 0,7189 y con 500 llega a 0,7213, que es prácticamente el final. Las 2.500 vueltas siguientes ganan tres diezmilésimas.

Es lo mismo que vimos con los datos fabricados y ahora con las ventas reales: casi todo el aprendizaje pasa al principio. Si estás esperando a que un entrenamiento largo mejore mucho, casi nunca pasa ⏳

6. El gradiente cuando ya acertaste

Mira cuánto vale el gradiente en un modelo que acierta y en uno que no.

perfecto = np.array([10., 0., 0.])
malo = np.array([-10., 0., 0.])
y_facil = (X[:, 0] > 0).astype(float)

for nombre, pesos in [('acierta', perfecto), ('se equivoca', malo)]:
    e = sigmoide(X @ pesos) - y_facil
    g = X.T @ e / len(y_facil)
    print(f'{nombre:12} pérdida {perdida(sigmoide(X @ pesos), y_facil):.4f}  '
          f'gradiente {np.round(g, 4)}')
acierta      pérdida 0.0628  gradiente [-0.0067 -0.0028  0.0041]
se equivoca  pérdida 8.0274  gradiente [-0.7963  0.0155  0.0704]

Cuando acierta, el gradiente es prácticamente cero: no hay hacia dónde corregir y el entrenamiento se para solo.

Cuando se equivoca de lleno, el gradiente es enorme y empuja fuerte. El método tiene esa gracia: se autorregula, corrige mucho cuando va mal y poco cuando va bien 🎚️

7. Por lotes, que es como se hace de verdad

En vez de usar las 500 filas cada vuelta, usa 32 al azar.

lote_rng = np.random.default_rng(7)
w_lote = np.zeros(2); b_lote = 0.0
for i in range(201):
    idx = lote_rng.choice(len(objetivo), 32, replace=False)
    e = sigmoide(escalado[idx] @ w_lote + b_lote) - objetivo[idx]
    w_lote -= 0.1 * (escalado[idx].T @ e) / len(idx)
    b_lote -= 0.1 * e.mean()

print('con lotes de 32 :', np.round(w_lote, 4),
      f'pérdida {perdida(sigmoide(escalado @ w_lote + b_lote), objetivo):.4f}')
print('con las 500     :', np.round(w, 4),
      f'pérdida {perdida(sigmoide(escalado @ w + b), objetivo):.4f}')
con lotes de 32 : [0.7054 1.0285] pérdida 0.5547
con las 500     : [0.6997 1.0115] pérdida 0.5543

Casi lo mismo, y mirando una quinceava parte de los datos en cada vuelta.

Eso es el descenso de gradiente por lotes, y es como se entrena todo hoy. Con millones de filas no puedes calcular el gradiente de todas: usas un puñado, que apunta en una dirección parecida y cuesta muchísimo menos 🚀

El ruido que mete el lote hasta ayuda, porque a veces empuja fuera de un mínimo local como el que atascó la semilla 0 del capítulo 4.

8. El error de pedir un lote más grande que los datos

Pide 600 filas de un conjunto de 500.

lote_rng.choice(len(objetivo), 600, replace=False)
ValueError: Cannot take a larger sample than population when replace is False

"Cannot take a larger sample than population when replace is False", o sea que no puedes sacar 600 bolas distintas de una bolsa con 500.

Parece una tontería y pasa constantemente, porque el tamaño de lote se escribe una vez arriba del cuaderno y luego alguien filtra los datos y se queda con menos filas de las que había 🎒

Con replace=True sí te deja, y ahí no hay error pero hay filas repetidas dentro del lote. Que a veces está bien (se llama bootstrap) y a veces no es lo que querías.

9. Comprobar el gradiente del sesgo

Haz el gradient checking para b, que arriba lo salté.

formula = float((sigmoide(X @ w[:3] if len(w) == 3 else X @ np.array([0.2, -0.1, 0.4]) + b) - y).mean())
numerico_b = (perdida_de(np.array([0.2, -0.1, 0.4]), b + h)
              - perdida_de(np.array([0.2, -0.1, 0.4]), b - h)) / (2 * h)
print('por fórmula:', round(formula, 8))
print('moviendo   :', round(numerico_b, 8))
print('diferencia :', round(abs(formula - numerico_b), 12))
por fórmula: 0.03743041
moviendo   : 0.03743041
diferencia : 0.0

El gradiente del sesgo es sencillamente la media del error, sin multiplicar por ninguna columna. Y sale igual que moviéndolo a mano.

Tiene sentido si lo piensas: el sesgo entra en la cuenta multiplicado por 1 siempre, así que su derivada no lleva ninguna columna dentro 🎈

10. El paso que se va de largo, visto por dentro

Imprime la pérdida vuelta a vuelta con un paso enorme.

w_mal = np.zeros(2); b_mal = 0.0
for i in range(8):
    p = sigmoide(escalado @ w_mal + b_mal)
    print(f'vuelta {i}: pérdida {perdida(p, objetivo):10.4f}  pesos {np.round(w_mal, 3)}')
    e = p - objetivo
    w_mal -= 60.0 * (escalado.T @ e) / len(objetivo)
    b_mal -= 60.0 * e.mean()
vuelta 0: pérdida     0.6931  pesos [0. 0.]
vuelta 1: pérdida     2.1362  pesos [ 7.897 11.991]
vuelta 2: pérdida     1.0199  pesos [4.257 4.276]
vuelta 3: pérdida     1.6548  pesos [-2.816  0.363]
vuelta 4: pérdida     4.7848  pesos [24.973  8.635]
vuelta 5: pérdida     2.6199  pesos [10.694 14.776]
vuelta 6: pérdida     1.5089  pesos [6.047 7.431]
vuelta 7: pérdida     0.6832  pesos [0.707 1.659]

Mira los pesos: 0 y 0, luego 7,9 y 12, luego 4,3 y 4,3, luego -2,8 y 0,4, luego 25 y 8,6. Van dando tumbos sin asentarse, y la pérdida hace lo mismo: 0,69, 2,14, 1,02, 1,65, 4,78.

Se pasó del fondo, rebotó en la pared de enfrente, se volvió a pasar. Con este problema tan fácil acaba cayendo cerca por accidente en la vuelta 7, pero eso es suerte y no convergencia.

Esa alternancia de signo es la firma de un paso demasiado grande, y se reconoce a simple vista. Si ves la pérdida haciendo eso, divide el paso entre diez y vuelve a correr, no toques nada más 🪃

11. El nan silencioso del log de cero

Quita el clip de la función de pérdida y dale una predicción perfecta.

def perdida_sin_clip(p, y):
    return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))

print(perdida_sin_clip(np.array([1.0, 0.0]), np.array([1.0, 0.0])))
nan

Un modelo que acierta con seguridad absoluta hace que log(1 - 1) sea log(0), que es menos infinito. Y cero por menos infinito es nan.

Y fíjate en lo que no pasó: no reventó. Numpy avisa con un RuntimeWarning y devuelve nan tan tranquilo, que es mucho peor que un error. El entrenamiento sigue, ese nan entra en el gradiente, y a la vuelta siguiente todos los pesos son nan.

Si alguna vez ves que de golpe toda tu red se vuelve nan, este es el primer sitio donde mirar 🔎

Por eso está el np.clip(p, 1e-12, 1 - 1e-12) de la primera función del capítulo. Todas las librerías lo hacen y ninguna lo cuenta 🧯

Comprueba que lo tienes

El descenso de gradiente da pasos cada vez más cortos según se acerca al mínimo. ¿Por qué?

  • Porque el paso es proporcional a la pendiente, y la pendiente se aplana
  • Porque la tasa de aprendizaje va bajando sola
  • Porque el modelo detecta que está cerca
  • Porque se va quedando sin datos

Lo que te llevas

  • 📏 La pérdida de entropía cruzada castiga estar seguro y equivocado (4,6052) mucho más que dudar (0,6931).
  • 🧮 El gradiente de una neurona cabe en una línea: X.T @ (predicho - real) / n.
  • 🔬 Se comprueba moviendo cada peso a mano, y coincide con 6,7e-12 de diferencia.
  • 🔽 El gradiente apunta hacia donde el error crece, así que se resta.
  • ⚖️ Sin escalar no hay ningún paso que sirva: desde 0,0001 explota, y con 0,00001 la pérdida se mueve una diezmilésima en 200 vueltas.
  • ⏱️ El 99% del trabajo se hace al principio: entre 5.000 y 20.000 vueltas la pérdida solo baja 0,0005.
  • 🚀 Con lotes de 32 filas se llega casi al mismo sitio mirando una quinceava parte de los datos.
  • 🪃 Pesos que saltan de signo son la firma de un paso demasiado grande.

En el capítulo 6 llevamos esto a una red con capas, que es donde el cálculo se complica y aparece la retropropagación.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?