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
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
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! 🌸