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.
Antes, una pregunta: ¿cómo repartirías tú la culpa de un error entre diez personas que trabajaron en cadena? Lo que hace una red es exactamente eso, y por eso se entiende sin fórmulas 🔁
La idea, sin fórmulas
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
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.Treparte 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.
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
Y el gradiente que crece con el lote
La trampa
Sumas los gradientes de todas las filas del lote, que es lo que dice la fórmula, y das el paso. Entrena, la pérdida baja, todo normal.
dW = np.zeros_like(W) for x, objetivo in lote: dW += gradiente(x, objetivo) W -= lr * dW
Qué está mal
Falta dividir entre el tamaño del lote 🧮 Sumando, el gradiente crece con el número de filas, así que tu paso real no es lr: es lr multiplicado por cuántas filas metiste.
Con lotes de 32 funciona, subes a 256 para ir más rápido y de pronto la pérdida explota. Vas a pasarte una tarde buscando el error en la red, y el error está en que acabas de multiplicar por ocho la velocidad de aprendizaje sin tocar lr.
Se arregla con dW / len(lote), y entonces el paso significa lo mismo con cualquier tamaño de lote. Es la diferencia entre sum y mean, y explica una cantidad de horas perdidas que no te imaginas.
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.
Y si de todo el capítulo te llevas una sola frase, que sea esta:
La retropropagación no es magia. Es contabilidad: cada capa cobra la culpa que le llega de la siguiente.
Las derivadas y la regla de la cadena que hay debajo de esto las trato despacio en el libro de estadística desde cero 📐
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! 🌸
Practica este capítulo 📓
Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.
¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.