Capítulo 6 de 15 6 secciones 16 min

Repartir la culpa hacia atrás

La regla de la cadena con capas, comprobada peso a peso. Y el gradiente desvanecido, por fin medido.

La retropropagación reparte el error hacia atrás: la última capa sabe directamente cuánto se equivocó, y cada capa anterior recibe la culpa que le llega a través de la siguiente, multiplicada por la pendiente de su activación. Se comprueba peso a peso contra el gradiente numérico y coincide con 1e-10. Con diez capas de sigmoide, el gradiente que llega a la primera es 500.000 veces más pequeño que el de la última.

En el capítulo 5 calculamos el gradiente de una neurona suelta. Aquí está el problema nuevo: en una red con capas, los pesos del medio no tocan la salida directamente, así que no está claro cuánta culpa tienen 🤔

La respuesta se llama retropropagación, y la escribimos entera.

La idea, sin fórmulas

Lw=La·az·zw

la regla de la cadena, que es toda la retropropagación: para saber cuánta culpa tiene un peso, multiplicas las derivadas de todo lo que hay entre ese peso y el error

Imagínate una cadena de tres personas que se pasan un pedido: la primera lo apunta, la segunda lo prepara y la tercera lo entrega. Llega mal.

La tercera sabe exactamente en qué se equivocó, porque el cliente se lo dijo. La segunda no habló con el cliente: se entera por la tercera, y solo de la parte que le tocaba. Y la primera se entera por la segunda 📦

Eso es la retropropagación. El error viaja hacia atrás y cada capa recibe la culpa que le llega de la siguiente, ajustada por cuánto influía ella.

Y "cuánto influía" es justamente la pendiente de su activación, que es por lo que el capítulo 3 pasó tanto rato con las derivadas.

Escrita entera, con nombres

δ(L)=aLf\(z(L)),δ(l)=(W(l+1)δ(l+1))f\(z(l))

la culpa de la última capa se calcula directo, y la de cada capa anterior sale de repartir hacia atrás la de la siguiente con la misma matriz de pesos, pero transpuesta

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)
    return float(-np.mean(y * np.log(p) + (1 - y) * np.log(1 - p)))

rng = np.random.default_rng(0)
X = rng.normal(0, 1, (50, 3))
y = (rng.random(50) < 0.5).astype(float).reshape(-1, 1)

W1 = rng.normal(0, 0.5, (3, 4)); b1 = np.zeros(4)
W2 = rng.normal(0, 0.5, (4, 1)); b2 = np.zeros(1)

# hacia adelante
h = np.tanh(X @ W1 + b1)          # lo que sale de la capa oculta
p = sigmoide(h @ W2 + b2)         # la predicción

# hacia atrás
d2 = (p - y) / len(X)             # la culpa de la capa de salida
dW2 = h.T @ d2                    # cómo cambiar sus pesos
db2 = d2.sum(axis=0)

d1 = (d2 @ W2.T) * (1 - h ** 2)   # la culpa que le llega a la oculta
dW1 = X.T @ d1                    # cómo cambiar los suyos
db1 = d1.sum(axis=0)

print('pérdida:', round(perdida(p, y), 6))
print('formas :', dW1.shape, db1.shape, dW2.shape, db2.shape)
pérdida: 0.69672
formas : (3, 4) (4,) (4, 1) (1,)

Son seis líneas y merecen que las leas una por una 👀

d2 = (p - y) / len(X). La culpa de la última capa, que es el error a secas. Salió del capítulo 5.

dW2 = h.T @ d2. Para saber cuánto cambiar un peso, se multiplica la culpa por lo que entró por ese peso. Un peso que recibió un número grande tiene más responsabilidad.

d1 = (d2 @ W2.T) * (1 - h ** 2). Esta es la línea del capítulo. Tiene dos partes:

  • 📨 d2 @ W2.T reparte la culpa de la salida entre las cuatro neuronas ocultas, en proporción al peso con que cada una contribuyó.
  • 🎚️ * (1 - h ** 2) es la pendiente de la tanh, y ajusta esa culpa por cuánto podía esa neurona haber cambiado algo.

Ahí está todo. Una neurona saturada tiene pendiente casi cero, así que su parte de la culpa se multiplica por casi cero y no aprende. Es literalmente la misma multiplicación de la que hablábamos en el capítulo 3, ahora escrita 🎯

Y dW1 = X.T @ d1 es la misma forma que dW2: la culpa por lo que entró. El patrón se repite igual por cada capa que añadas.

¿Y cómo sabemos que está bien?

Igual que en el capítulo 5, pero ahora para los cuatro grupos de pesos: moviendo cada uno a mano y midiendo cuánto cambia la pérdida.

def adelante(W1, b1, W2, b2):
    hh = np.tanh(X @ W1 + b1)
    return perdida(sigmoide(hh @ W2 + b2), y)

def gradiente_numerico(parametro, cual, eps=1e-6):
    g = np.zeros_like(parametro)
    it = np.nditer(parametro, flags=['multi_index'])
    for _ in it:
        i = it.multi_index
        arriba = parametro.copy(); arriba[i] += eps
        abajo = parametro.copy(); abajo[i] -= eps
        args = {'W1': W1, 'b1': b1, 'W2': W2, 'b2': b2}
        args[cual] = arriba; La = adelante(**args)
        args[cual] = abajo; Lb = adelante(**args)
        g[i] = (La - Lb) / (2 * eps)
    return g

for nombre, analitico, original in [('W2', dW2, W2), ('b2', db2, b2),
                                    ('W1', dW1, W1), ('b1', db1, b1)]:
    n = gradiente_numerico(original, nombre)
    print(f'{nombre}: mayor diferencia {float(np.abs(analitico - n).max()):.3e}')
W2: mayor diferencia 1.047e-10
b2: mayor diferencia 3.225e-11
W1: mayor diferencia 1.140e-10
b1: mayor diferencia 7.217e-11

Los cuatro coinciden hasta 1e-10 🎉

Esto es lo que quería que tuvieras: no hay que creerse la retropropagación, se comprueba. Y cuando escribas una capa rara y no te salgan las cuentas, este es el método que te dice si el fallo está en la derivada o en otro sitio.

Tamaño del gradiente por capa en escala logarítmica, para una red de diez capas con sigmoide. La línea cae en picado desde la última capa hasta la primera, atravesando varios órdenes de magnitud.
El eje vertical es logarítmico, o sea que cada marca es diez veces menos. Por eso la primera capa casi no aprende: le llega un gradiente cientos de miles de veces más chico que a la última.
Magnitud del gradiente por capa en escala logarítmica: alta en las capas cercanas a la salida y cada vez más pequeña hacia las primeras capas, hasta quedar dos órdenes de magnitud por debajo.
El eje es logarítmico, así que la diferencia entre la primera barra y la última es mucho mayor de lo que parece. Con un gradiente así de pequeño las primeras capas siguen ahí y ya no cambian: la red las arrastra sin entrenarlas.

El gradiente desvanecido, por fin medido

En el capítulo 3 dije que las pendientes se multiplican entre capas y que con sigmoides se apagan. Vamos a verlo de verdad, midiendo qué tamaño tiene el gradiente en cada capa según la profundidad:

def gradientes_por_profundidad(n_capas, activacion):
    r = np.random.default_rng(1)
    Xg = np.random.default_rng(0).normal(0, 1, (200, 4))
    yg = (np.random.default_rng(0).random(200) < 0.5).astype(float).reshape(-1, 1)

    Ws = [r.normal(0, 1, (4, 4)) for _ in range(n_capas)] + [r.normal(0, 1, (4, 1))]
    bs = [np.zeros(4) for _ in range(n_capas)] + [np.zeros(1)]

    hs = [Xg]
    for W, b in zip(Ws[:-1], bs[:-1]):
        z = hs[-1] @ W + b
        hs.append(sigmoide(z) if activacion == 'sigmoide' else np.maximum(0, z))

    pg = sigmoide(hs[-1] @ Ws[-1] + bs[-1])
    d = (pg - yg) / len(Xg)

    tamanos = []
    for i in range(len(Ws) - 1, -1, -1):
        tamanos.append(float(np.abs(hs[i].T @ d).mean()))
        if i > 0:
            if activacion == 'sigmoide':
                d = (d @ Ws[i].T) * hs[i] * (1 - hs[i])
            else:
                d = (d @ Ws[i].T) * (hs[i] > 0)
    return tamanos[::-1]

for activacion in ['sigmoide', 'relu']:
    for capas in [2, 5, 10]:
        g = gradientes_por_profundidad(capas, activacion)
        print(f'{activacion:9} {capas:2d} capas: primera {g[0]:.3e}   última {g[-1]:.3e}')
sigmoide   2 capas: primera 2.336e-03   última 1.375e-01
sigmoide   5 capas: primera 1.582e-04   última 1.415e-01
sigmoide  10 capas: primera 7.604e-08   última 3.802e-02
relu       2 capas: primera 7.596e-02   última 2.207e-01
relu       5 capas: primera 2.465e-03   última 1.429e-04
relu      10 capas: primera 4.330e-04   última 1.895e-04

Con sigmoide y diez capas, el gradiente que llega a la primera es 7,604e-08 y el de la última es 3,802e-02. Son quinientas mil veces de diferencia 😱

Traducido: mientras la última capa aprende a paso normal, la primera se mueve quinientas mil veces más despacio. Con cualquier paso razonable, esa primera capa no cambia nunca. Está ahí, ocupa memoria, y sigue con los pesos aleatorios del principio.

Con ReLU y diez capas: 4,330e-04 en la primera y 1,895e-04 en la última. El mismo orden de magnitud, y encima la primera sale mayor. La primera capa aprende igual que la última 🎉

Eso es exactamente lo que destrabó el campo. No hizo falta un algoritmo nuevo: hizo falta cambiar una función por otra más tonta cuya pendiente no encoge.

Y fíjate en un detalle honesto de la tabla: con dos capas la sigmoide da 2,336e-03 en la primera, que comparado con el 1,375e-01 de la última son dos órdenes de magnitud y no cinco. El problema no es la sigmoide, es la sigmoide con profundidad. Por eso funcionaba bien en los años 90, cuando las redes tenían dos capas.

Ejercicios

1. Entrenar la red con esas seis líneas

Mete el bucle alrededor y mira bajar la pérdida.

A1 = rng.normal(0, 0.5, (3, 4)); c1 = np.zeros(4)
A2 = rng.normal(0, 0.5, (4, 1)); c2 = np.zeros(1)

for i in range(2001):
    hh = np.tanh(X @ A1 + c1)
    pp = sigmoide(hh @ A2 + c2)
    if i % 500 == 0:
        print(f'vuelta {i:4d}  pérdida {perdida(pp, y):.4f}')
    e2 = (pp - y) / len(X)
    e1 = (e2 @ A2.T) * (1 - hh ** 2)
    A2 -= 0.5 * (hh.T @ e2); c2 -= 0.5 * e2.sum(axis=0)
    A1 -= 0.5 * (X.T @ e1); c1 -= 0.5 * e1.sum(axis=0)
vuelta    0  pérdida 0.7716
vuelta  500  pérdida 0.4163
vuelta 1000  pérdida 0.3791
vuelta 1500  pérdida 0.3576
vuelta 2000  pérdida 0.3239

Baja de 0,7716 a 0,3239 y sigue bajando. Y ahora fíjate en lo importante: el y de este ejemplo lo generé tirando una moneda. No hay absolutamente nada que aprender.

O sea que esa pérdida que baja tan bonito es memorización pura. Con 50 filas y 21 pesos, la red se aprende el ruido de carrerilla, y una curva de entrenamiento preciosa no significa nada por sí sola.

Ese es el capítulo 8 en un ejercicio, y es la razón de que exista el conjunto de prueba 🎲

2. Ver la culpa que le llega a cada neurona

Imprime d1 y mira su forma y su tamaño.

print('forma de d1:', d1.shape, ' (una fila por dato, una columna por neurona)')
print()
print('culpa media que recibe cada una de las 4 neuronas ocultas:')
print(np.round(np.abs(d1).mean(axis=0), 6))
print()
print('culpa media de la capa de salida:', round(float(np.abs(d2).mean()), 6))
forma de d1: (50, 4)  (una fila por dato, una columna por neurona)

culpa media que recibe cada una de las 4 neuronas ocultas:
[0.003179 0.001939 0.000598 0.001484]

culpa media de la capa de salida: 0.009784

Cada neurona oculta recibe una cantidad de culpa distinta, según con qué peso contribuyó a la salida.

Y fíjate en la comparación con la de salida: ya en una sola capa hacia atrás la culpa se hizo más pequeña. Multiplica eso diez veces y tienes el problema de arriba 📉

3. Qué pasa si te olvidas la pendiente

Quita el * (1 - h ** 2) y compara el gradiente con el numérico.

d1_mal = d2 @ W2.T                 # sin multiplicar por la pendiente
dW1_mal = X.T @ d1_mal

correcto = gradiente_numerico(W1, 'W1')
print('con pendiente, error:', float(np.abs(dW1 - correcto).max()))
print('sin pendiente, error:', float(np.abs(dW1_mal - correcto).max()))
con pendiente, error: 1.1404513444723818e-10
sin pendiente, error: 0.0312959323249657

El correcto se equivoca en 1,1e-10 y el otro en 0,0313, o sea trescientos millones de veces más.

Y esto no da ningún error: la red entrena, la pérdida baja algo, y los pesos van a un sitio que no es. Es el tipo de fallo que solo caza el gradient checking, y por eso existe 🔍

4. Tres capas, el mismo patrón

Añade una capa y comprueba que la retropropagación es la misma línea repetida.

V1 = rng.normal(0, 0.5, (3, 4)); e1b = np.zeros(4)
V2 = rng.normal(0, 0.5, (4, 4)); e2b = np.zeros(4)
V3 = rng.normal(0, 0.5, (4, 1)); e3b = np.zeros(1)

k1 = np.tanh(X @ V1 + e1b)
k2 = np.tanh(k1 @ V2 + e2b)
q = sigmoide(k2 @ V3 + e3b)

g3 = (q - y) / len(X)
g2 = (g3 @ V3.T) * (1 - k2 ** 2)
g1 = (g2 @ V2.T) * (1 - k1 ** 2)

print('culpa media en la capa 3 (salida):', round(float(np.abs(g3).mean()), 8))
print('culpa media en la capa 2         :', round(float(np.abs(g2).mean()), 8))
print('culpa media en la capa 1         :', round(float(np.abs(g1).mean()), 8))
culpa media en la capa 3 (salida): 0.01017376
culpa media en la capa 2         : 0.0026545
culpa media en la capa 1         : 0.0011628

Una línea por capa, siempre igual: reparte con la matriz de la siguiente y multiplica por la pendiente de esta.

Ese patrón se repite tanto que se acabó automatizando, y a eso se le llama diferenciación automática. Es lo que hacen PyTorch y TensorFlow por dentro, y es básicamente esta línea con contabilidad 🧾

5. La sigmoide es peor que tanh incluso con dos capas

Compara las pendientes máximas de las dos.

print('pendiente máxima de la sigmoide:', 0.25)
print('pendiente máxima de tanh       :', 1.0)
print()
for capas in [2, 5, 10, 20]:
    print(f'{capas:2d} capas: sigmoide {0.25 ** capas:.3e}   tanh {1.0 ** capas:.3e}')
pendiente máxima de la sigmoide: 0.25
pendiente máxima de tanh       : 1.0

 2 capas: sigmoide 6.250e-02   tanh 1.000e+00
 5 capas: sigmoide 9.766e-04   tanh 1.000e+00
10 capas: sigmoide 9.537e-07   tanh 1.000e+00
20 capas: sigmoide 9.095e-13   tanh 1.000e+00

Por eso en los años 90, cuando todavía no existía ReLU, la recomendación era usar tanh en vez de sigmoide en las capas del medio.

Con la sigmoide reservada para la salida, que es donde sí la quieres porque devuelve algo entre 0 y 1. Cada función en su sitio 🎚️

6. Cuánto cuesta comprobar el gradiente

Cuenta las evaluaciones que hace el gradient checking.

pesos_totales = W1.size + b1.size + W2.size + b2.size
print('pesos de esta red diminuta:', pesos_totales)
print('evaluaciones para comprobar:', pesos_totales * 2)
print()
print('en una red con un millón de pesos serían:', 1_000_000 * 2, 'evaluaciones')
print('mientras que la retropropagación cuesta como UNA pasada hacia adelante')
pesos de esta red diminuta: 21
evaluaciones para comprobar: 42

en una red con un millón de pesos serían: 2000000 evaluaciones
mientras que la retropropagación cuesta como UNA pasada hacia adelante

Cuarenta y dos evaluaciones para una red de 21 pesos, y dos millones para una de un millón.

Ahí está por qué la retropropagación fue un hallazgo y no una obviedad: calcula todos los gradientes de golpe por el precio de una pasada. Sin ella, entrenar redes grandes sería imposible, y el gradient checking se queda como herramienta de depuración sobre un puñado de pesos 🧮

7. Soltarla sobre las ventas de la distribuidora

Entrena la red de dos capas, escrita por nosotras, sobre el CSV de verdad. Es un adelanto del capítulo 7.

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

ventas = carga_limpia(URL).sort_values(['cliente_id', 'fecha']).copy()
ventas['sin_compra_previa'] = ventas['fecha_ultima_compra'].isna().astype(int)
ventas['sin_descuento'] = ventas['descuento'].isna().astype(int)
ventas['sin_satisfaccion'] = ventas['satisfaccion'].isna().astype(int)
ventas['precio_unitario'] = ventas['monto'] / ventas['unidades']
ventas['visita_numero'] = ventas.groupby('cliente_id').cumcount() + 1

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

Xv_tr, Xv_te, yv_tr, yv_te = train_test_split(
    ventas[NUM + CAT], ventas['compro'], test_size=0.25,
    random_state=42, stratify=ventas['compro'])

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

Tv_tr = logistica.named_steps['pre'].transform(Xv_tr)
Tv_te = logistica.named_steps['pre'].transform(Xv_te)
obj = yv_tr.values.astype(float).reshape(-1, 1)

r = np.random.default_rng(0)
M1 = r.normal(0, 0.1, (Tv_tr.shape[1], 8)); n1 = np.zeros(8)
M2 = r.normal(0, 0.1, (8, 1)); n2 = np.zeros(1)

for _ in range(2000):
    hh = np.tanh(Tv_tr @ M1 + n1)
    pp = sigmoide(hh @ M2 + n2)
    f2 = (pp - obj) / len(obj)
    f1 = (f2 @ M2.T) * (1 - hh ** 2)
    M2 -= 0.5 * (hh.T @ f2); n2 -= 0.5 * f2.sum(axis=0)
    M1 -= 0.5 * (Tv_tr.T @ f1); n1 -= 0.5 * f1.sum(axis=0)

pred = sigmoide(np.tanh(Tv_te @ M1 + n1) @ M2 + n2).ravel()
print('nuestra red de 2 capas:', round(roc_auc_score(yv_te, pred), 4))
print('logística de sklearn  :', round(roc_auc_score(
    yv_te, logistica.predict_proba(Xv_te)[:, 1]), 4))
nuestra red de 2 capas: 0.6651
logística de sklearn  : 0.7214

Funciona: 0,6651 con una red que escribimos nosotras, sobre las ventas de verdad, con sus nulos y su OneHotEncoder 🎉

Y pierde contra la logística, que saca 0,7214. Que es exactamente lo que el capítulo 1 anunció y lo que el capítulo 4 explicó: en estos datos no hay ninguna esquina cruzada, así que la capa oculta no tiene dónde ayudar y solo añade 2.000 vueltas de tropiezos.

Lo importante de este ejercicio no es el número: es que ya puedes entrenar una red sobre datos reales sin importar ninguna librería de deep learning. En el capítulo 7 la ponemos a punto 💪

8. El error de retropropagar en el orden equivocado

Calcula d1 antes que d2, que es lo que sale si escribes el bloque de arriba abajo sin pensar.

del d2
d1_temprano = (d2 @ W2.T) * (1 - h ** 2)
NameError: name 'd2' is not defined

Obvio dicho así, y a la vez es el fallo conceptual más común cuando alguien escribe su primera red: hacia atrás significa hacia atrás. La última capa se calcula primero porque es la única que sabe algo sin depender de nadie.

Si te sale este error, casi siempre es que ordenaste el bloque como el de adelante 🔄

Comprueba que lo tienes

Con diez capas de sigmoide, el gradiente que llega a la primera es 7,6e-08 y el de la última 3,8e-02. ¿Qué consecuencia tiene?

  • Que la primera capa casi no aprende, por muchas vueltas que des
  • Que hay que bajar la tasa de aprendizaje
  • Que la red está mal inicializada
  • Que hacen falta más datos

Lo que te llevas

  • 📦 La última capa sabe su error directamente; cada capa anterior lo recibe a través de la siguiente.
  • 🧮 Son seis líneas, y el patrón se repite igual por cada capa que añadas.
  • 🎚️ d1 = (d2 @ W2.T) * pendiente: repartir y ajustar por cuánto podía esa neurona cambiar algo.
  • 🔬 Los cuatro gradientes coinciden con el numérico a 1e-10. No hay que creérselo, se comprueba.
  • 😱 Con diez capas de sigmoide, el gradiente de la primera es 500.000 veces menor que el de la última.
  • 🎉 Con ReLU y diez capas están en el mismo orden de magnitud.
  • 🕰️ El problema no es la sigmoide: es la sigmoide con profundidad. Con dos capas va bien, y por eso funcionaba en los años 90.
  • 🧾 Comprobar el gradiente cuesta dos evaluaciones por peso; la retropropagación los saca todos por el precio de una pasada.

En el capítulo 7 juntamos todo y entrenamos una red completa sobre las ventas de la distribuidora, escrita por nosotras de principio a fin.

Que tengas lindo día! 🌸

¿Tienes alguna duda o consulta?