En el capítulo 2 la neurona aplastaba con una sigmoide y no dije por qué. Es que hace falta explicarlo con dos capas delante, y ahora ya podemos 🔧
La demostración de que sin activación no sirve de nada
Una red es capas apiladas: la salida de una entra en la siguiente. Si quitamos la activación, cada capa es solo una multiplicación de matrices.
import numpy as np
rng = np.random.default_rng(0)
X = rng.normal(0, 1, (5, 3)) # 5 filas con 3 columnas
W1 = rng.normal(0, 1, (3, 4)) # primera capa: 3 entradas, 4 neuronas
W2 = rng.normal(0, 1, (4, 2)) # segunda capa: 4 entradas, 2 salidas
dos_capas = (X @ W1) @ W2
una_capa = X @ (W1 @ W2) # las dos matrices multiplicadas de antemano
print('¿dan lo mismo?', np.allclose(dos_capas, una_capa))
print('mayor diferencia:', float(np.abs(dos_capas - una_capa).max()))
¿dan lo mismo? True mayor diferencia: 4.440892098500626e-16
Ahí está, y es toda la razón de ser de este capítulo 💡
Sin activación, dos capas son exactamente una. Y no es que se parezcan: la diferencia es 4,4e-16, o sea el redondeo de la máquina. Puedes apilar cincuenta capas y seguirás teniendo un modelo lineal, con más gasto de electricidad y ninguna capacidad nueva.
Lo que rompe esa cadena es meter una función no lineal entre capa y capa. Eso es una activación, y su único trabajo es doblar 🌀
Las tres que hay que conocer
aplasta cualquier número entre 0 y 1, y su derivada nunca pasa de 0,25, que es exactamente por lo que el gradiente se desvanece al apilar capas
def sigmoide(z):
return 1 / (1 + np.exp(-z))
def tanh(z):
return np.tanh(z)
def relu(z):
return np.maximum(0, z) # ojo: np.maximum, no max
z = np.array([-6.0, -2.0, -0.5, 0.0, 0.5, 2.0, 6.0])
print('z ', z)
print('sigmoide', np.round(sigmoide(z), 4))
print('tanh ', np.round(tanh(z), 4))
print('relu ', np.round(relu(z), 4))
z [-6. -2. -0.5 0. 0.5 2. 6. ] sigmoide [0.0025 0.1192 0.3775 0.5 0.6225 0.8808 0.9975] tanh [-1. -0.964 -0.4621 0. 0.4621 0.964 1. ] relu [0. 0. 0. 0. 0.5 2. 6. ]
Léelas por lo que hacen con los números:
- 🫓 Sigmoide aplasta todo entre 0 y 1. Sirve para una salida que quieras leer como probabilidad.
- ⚖️ Tanh aplasta entre -1 y 1, o sea que queda centrada en cero. Es la sigmoide estirada.
- ✂️ ReLU es lo más tonto que te puedas imaginar: si es negativo, cero; si no, tal cual. Y es la que se usa en casi todo.
Que la más simple sea la que ganó no es casualidad, y se ve mirando otra cosa 👀
Lo que de verdad decide: la pendiente
deja pasar lo positivo y corta lo negativo, y su derivada vale uno o cero, que es lo que impide que el gradiente se encoja al bajar de capa
Del capítulo 2 nos quedó pendiente que una neurona saturada deja de aprender. Aquí está el porqué, con las tres al lado:
print('pendiente de la sigmoide', np.round(sigmoide(z) * (1 - sigmoide(z)), 4))
print('pendiente de tanh ', np.round(1 - tanh(z) ** 2, 4))
print('pendiente de relu ', np.round((z > 0).astype(float), 4))
pendiente de la sigmoide [0.0025 0.105 0.235 0.25 0.235 0.105 0.0025] pendiente de tanh [0. 0.0707 0.7864 1. 0.7864 0.0707 0. ] pendiente de relu [0. 0. 0. 0. 1. 1. 1.]
Esta tabla explica veinte años de historia del deep learning, así que vale la pena pararse 🛑
La sigmoide no pasa nunca de 0,25, y eso es en su mejor momento, justo en el centro. En z=6 vale 0,0025.
La de ReLU vale 1 en toda la mitad positiva. Ni encoge ni crece.
Y ahora la parte que importa: en el capítulo 5 vamos a ver que para entrenar una red, las pendientes de todas las capas se multiplican entre sí. Con sigmoides, diez capas te dan como mucho 0,25 elevado a diez, que es 0,00000095. El aviso de corrección que llega a la primera capa es prácticamente cero y esa capa no aprende nunca.
Eso tiene nombre, gradiente desvanecido, y fue lo que tuvo parado el campo durante años. Con ReLU se multiplica por 1 diez veces y no pasa nada 🎉
Lo que ReLU rompe a cambio
Nada es gratis. Mira su pendiente en la mitad negativa: cero, y no cero pequeño sino cero exacto.
Una neurona cuya suma sale siempre negativa devuelve siempre cero, y como su pendiente también es cero, no recibe ninguna corrección. Está muerta y no revive:
X_g = rng.normal(0, 1, (200, 4))
W = rng.normal(0, 1, (4, 8))
for sesgo in [0.0, -5.0]:
H = relu(X_g @ W + np.full(8, sesgo))
muertas = int((H.sum(axis=0) == 0).sum())
print(f'sesgo {sesgo:5}: {muertas} de 8 neuronas nunca se activan, '
f'{(H == 0).mean():.4f} de la capa son ceros')
sesgo 0.0: 0 de 8 neuronas nunca se activan, 0.4994 de la capa son ceros sesgo -5.0: 6 de 8 neuronas nunca se activan, 0.9969 de la capa son ceros
Con el sesgo en cero, ninguna muere y el 49,94% de la capa son ceros, que es lo normal y hasta conveniente. Con el sesgo en -5, mueren 6 de 8 y el 99,69% de la capa es cero. Esa capa ya no hace nada 💀
Por eso importa cómo se arrancan los pesos, que es un tema entero, y por eso
existen variantes como LeakyReLU, que en vez de cero devuelve
0,01 * z en el lado negativo para dejar un hilito de pendiente.
La de la salida es otra cosa
convierte una lista de números en probabilidades que suman uno, exagerando las diferencias porque la exponencial crece rapidísimo
Aquí se confunde mucha gente. La activación de las capas del medio y la de la salida se eligen por razones distintas:
| Dónde | Qué se usa | Por qué |
|---|---|---|
| Capas del medio | ReLU | Para que la pendiente no se apague al multiplicarse |
| Salida, sí o no | Sigmoide | Para leer el número como probabilidad |
| Salida, varias clases | Softmax | Para que las probabilidades sumen 1 |
| Salida, un número | Ninguna | Un precio o una cantidad no se aplasta |
La softmax es la sigmoide cuando hay más de dos opciones, y tiene una trampa que vas a encontrarte:
def softmax_ingenua(z):
e = np.exp(z)
return e / e.sum()
def softmax(z):
z = z - z.max() # esta línea es toda la diferencia
e = np.exp(z)
return e / e.sum()
grandes = np.array([1000.0, 1.0, 2.0])
print('ingenua:', softmax_ingenua(grandes))
print('estable:', np.round(softmax(grandes), 4))
ingenua: [nan 0. 0.] estable: [1. 0. 0.]
La ingenua devuelve nan, porque np.exp(1000) se sale
del rango de un número de computadora y luego infinito dividido infinito no es
nada.
Restar el máximo antes no cambia el resultado matemático (compruébalo con
lápiz: se cancela arriba y abajo) y hace que el mayor exponente sea siempre
exp(0) = 1. Todas las librerías lo hacen por dentro, y si alguna vez
escribes la tuya, acuérdate 🧯
Y en nuestros datos, ¿cuál gana?
Ya sabes que aquí las redes pierden, pero la comparación entre activaciones sigue siendo interesante:
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier
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']
def arma(modelo):
return 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', modelo),
])
datos = prepara(carga_limpia(URL))
X = datos[NUMERICAS + CATEGORICAS]
y = datos['compro']
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
random_state=42, stratify=y)
for activacion in ['logistic', 'tanh', 'relu', 'identity']:
m = arma(MLPClassifier(hidden_layer_sizes=(16,), activation=activacion,
max_iter=300, random_state=42)).fit(X_tr, y_tr)
print(f'{activacion:10} {roc_auc_score(y_te, m.predict_proba(X_te)[:, 1]):.4f}')
logistic 0.7195 tanh 0.6560 relu 0.6584 identity 0.7230
Y aquí está el remate del capítulo, que no me lo esperaba cuando lo corrí 😳
identity significa sin activación ninguna, o sea
justo lo que acabamos de demostrar que colapsa en un modelo lineal. Y gana:
0,7230, por encima de las tres que doblan.
No se contradice con nada. Lo que dice es que en estos datos la relación es lineal, así que doblar la frontera no ayuda y solo añade formas de equivocarse. Y 0,7230 es prácticamente el 0,7214 de la regresión logística, que es exactamente lo que tiene que pasar: son el mismo modelo.
Doblar es una capacidad, no una mejora 💛
Ejercicios
1. Comprobar el colapso con más capas
Apila cinco capas sin activación y mira si sigue siendo una.
M = rng.normal(0, 1, (5, 3)) # cinco filas de tres columnas
capas = [rng.normal(0, 1, (3, 6)), rng.normal(0, 1, (6, 6)),
rng.normal(0, 1, (6, 6)), rng.normal(0, 1, (6, 4)),
rng.normal(0, 1, (4, 2))]
salida = M
for W in capas:
salida = salida @ W
junta = capas[0]
for W in capas[1:]:
junta = junta @ W
print('cinco capas =', salida.shape, ' una sola matriz =', junta.shape)
print('¿dan lo mismo?', np.allclose(salida, M @ junta))
cinco capas = (5, 2) una sola matriz = (3, 2) ¿dan lo mismo? True
Cinco capas, 3 por 6 por 6 por 6 por 4 por 2, y todo eso se reduce a una matriz de 3 por 2.
Si alguna vez alguien te enseña una arquitectura con muchas capas y ninguna activación, ya sabes lo que estás mirando: una regresión lineal cara 💸
2. Cuánto se apaga la pendiente al apilar
Multiplica la mejor pendiente posible de cada activación tantas veces como capas.
for capas_n in [1, 2, 5, 10, 20]:
print(f'{capas_n:2d} capas sigmoide {0.25 ** capas_n:.3e} '
f'tanh {1.0 ** capas_n:.4f} relu {1.0 ** capas_n:.4f}')
1 capas sigmoide 2.500e-01 tanh 1.0000 relu 1.0000 2 capas sigmoide 6.250e-02 tanh 1.0000 relu 1.0000 5 capas sigmoide 9.766e-04 tanh 1.0000 relu 1.0000 10 capas sigmoide 9.537e-07 tanh 1.0000 relu 1.0000 20 capas sigmoide 9.095e-13 tanh 1.0000 relu 1.0000
Con veinte capas de sigmoide, el aviso que llega a la primera se multiplicó por 9,095e-13, o sea nueve billonésimas. Y eso siendo generosas, porque 0,25 es su máximo y casi nunca está ahí.
Con tanh y ReLU el mejor caso es 1, así que no se apaga. Por eso las redes profundas de verdad no existieron hasta que se dejó de usar sigmoide en el medio 📉
3. Dibujar la frontera que dobla
Vuelve al problema del círculo del capítulo 1 y compara con activación y sin ella.
P = rng.normal(0, 1, (3000, 2))
etiqueta = ((P[:, 0] ** 2 + P[:, 1] ** 2) > 2).astype(int)
A_tr, A_te, b_tr, b_te = train_test_split(P, etiqueta, test_size=0.25,
random_state=42, stratify=etiqueta)
for activacion in ['identity', 'tanh', 'relu']:
m = MLPClassifier(hidden_layer_sizes=(16,), activation=activacion,
max_iter=500, random_state=42).fit(A_tr, b_tr)
print(f'{activacion:10} {roc_auc_score(b_te, m.predict_proba(A_te)[:, 1]):.4f}')
identity 0.5063 tanh 0.9998 relu 0.9995
Aquí sí se separan de verdad: identity se queda en 0,5063, que
es azar, y las dos que doblan pasan de 0,999.
Este ejercicio y el de arriba son el mismo experimento con datos distintos, y por eso los puse los dos. La activación no mejora modelos: los hace capaces de otra cosa. Si esa otra cosa no está en tus datos, no gana 🎯
4. ReLU deja medio cerebro apagado y está bien
Cuenta qué porcentaje de la capa son ceros en una red entrenada de verdad.
red = arma(MLPClassifier(hidden_layer_sizes=(16,), activation='relu',
max_iter=300, random_state=42)).fit(X_tr, y_tr)
T = red.named_steps['pre'].transform(X_te)
oculta = relu(T @ red.named_steps['mod'].coefs_[0] + red.named_steps['mod'].intercepts_[0])
print('ceros en la capa oculta:', round(float((oculta == 0).mean()), 4))
print('neuronas muertas del todo:', int((oculta.sum(axis=0) == 0).sum()), 'de 16')
ceros en la capa oculta: 0.4933 neuronas muertas del todo: 0 de 16
El 49,33% de la capa son ceros y no hay ninguna neurona muerta del todo.
Esos ceros no son desperdicio: significan que cada neurona se especializó en una parte de los datos y se calla en el resto. A esa propiedad se le llama activación dispersa y es parte de por qué ReLU funciona tan bien 🧩
5. La softmax de verdad, con tres opciones
Úsala sobre un caso con la forma de un problema real.
puntajes = np.array([2.1, 0.4, -1.3]) # Mayorista, Horeca, Bodega
p = softmax(puntajes)
for nombre, valor in zip(['Mayorista', 'Horeca', 'Bodega'], p):
print(f'{nombre:10} {valor:.4f}')
print('suman:', round(float(p.sum()), 10))
Mayorista 0.8223 Horeca 0.1502 Bodega 0.0274 suman: 1.0
Suman 1 exactamente, que es lo que la hace útil: son probabilidades de verdad y compiten entre ellas.
Fíjate en que la diferencia entre 2,1 y 0,4 (1,7 puntos) se convierte en 0,8223 contra 0,1502, o sea cinco veces y media. La softmax exagera las diferencias, y eso es justo lo que la temperatura de un modelo de lenguaje ajusta, que lo vemos en el capítulo 12 🌡️
6. Escribir LeakyReLU en una línea
Hazla tú y compárala con la ReLU normal.
def leaky_relu(z, fuga=0.01):
return np.where(z > 0, z, fuga * z)
print('z ', z)
print('relu ', np.round(relu(z), 4))
print('leaky ', np.round(leaky_relu(z), 4))
print('pendiente ', np.round(np.where(z > 0, 1.0, 0.01), 4))
z [-6. -2. -0.5 0. 0.5 2. 6. ] relu [0. 0. 0. 0. 0.5 2. 6. ] leaky [-6.e-02 -2.e-02 -5.e-03 0.e+00 5.e-01 2.e+00 6.e+00] pendiente [0.01 0.01 0.01 0.01 1. 1. 1. ]
La diferencia son unos decimalitos en el lado negativo, y esos decimalitos son la diferencia entre una neurona muerta y una neurona que puede volver.
Que una idea tan pequeña tenga nombre propio y papers te dice algo bonito del campo: casi todo el deep learning son ideas simples puestas en el sitio correcto 💛
7. El error de escribir ReLU con el max de Python
Prueba a usar max en vez de
np.maximum.
max(0, z)
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
El max de Python compara dos cosas y devuelve la mayor, así que
necesita saber si z es mayor que 0. Y z son siete
números: unos lo son y otros no.
El mensaje es de los mejores que da numpy porque te dice hasta la salida: "Use a.any() or a.all()". Aunque aquí ninguna de las dos es lo que quieres 🙃
np.maximum con "n" al final es otra función: compara elemento por
elemento y devuelve un array. La confusión entre max y
np.maximum es un clásico y ahora ya no te va a pasar.
Comprueba que lo tienes
En el capítulo 3 gana identity, o sea ninguna activación, con 0,7230. ¿Qué significa eso?
- Que en esta tabla no hay nada no lineal que aprender
- Que identity es la mejor activación
- Que la prueba está mal hecha
- Que hay que probar más activaciones
Lo que te llevas
- 🌀 Sin activación, dos capas son exactamente una: la diferencia es 4,4e-16.
- 📉 La pendiente de la sigmoide no pasa de 0,25 y se multiplica entre capas. Veinte capas la dejan en 9,095e-13.
- ✂️ ReLU tiene pendiente 1 en todo el lado positivo, y por eso las redes profundas se pudieron entrenar.
- 💀 Y a cambio mata neuronas: con un sesgo de -5 murieron 6 de 8.
- 🎚️ La activación del medio se elige por el gradiente; la de la salida, por lo que quieres leer.
- 🧯 La softmax se escribe restando el máximo, o devuelve
nancon números grandes. - 😳 En nuestros datos gana
identity, o sea ninguna activación, con 0,7230. Doblar es una capacidad y no una mejora.
En el capítulo 4 apilamos la primera red de verdad y le damos el problema que una sola neurona no puede resolver.
Que tengas lindo día! 🌸