Este es un libro de redes neuronales que empieza diciéndote cuándo no usarlas. Ya sé que es raro 🙃
Lo hago por una razón concreta: en los últimos años me han llegado varios proyectos donde alguien montó una red porque sonaba serio, y la respuesta buena eran tres líneas de otra cosa. Eso cuesta meses y credibilidad.
Así que antes de la primera neurona, vamos a medirlo sobre los datos de la distribuidora, que son los mismos del libro de machine learning 📊
El punto de partida
import numpy as np
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.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)
print('filas:', len(datos), ' columnas que entran:', X.shape[1])
print('compran el:', round(y.mean(), 4))
filas: 3000 columnas que entran: 13 compran el: 0.5777
Ese preprocesamiento es el mismo del capítulo 5 del libro de machine learning. Y el escalado, que allá era una buena práctica, aquí es obligatorio: una red con columnas sin escalar no converge, y lo vamos a ver con nuestros ojos en el capítulo 6 🧮
La comparación, sin adornos
competidores = [
('regresión logística', LogisticRegression(max_iter=1000, random_state=42)),
('red de 16', MLPClassifier(hidden_layer_sizes=(16,), max_iter=300,
random_state=42)),
('red de 64 y 32', MLPClassifier(hidden_layer_sizes=(64, 32), max_iter=300,
random_state=42)),
]
for nombre, modelo in competidores:
m = arma(modelo).fit(X_tr, y_tr)
entrena = roc_auc_score(y_tr, m.predict_proba(X_tr)[:, 1])
prueba = roc_auc_score(y_te, m.predict_proba(X_te)[:, 1])
print(f'{nombre:20} entrena={entrena:.4f} prueba={prueba:.4f}')
regresión logística entrena=0.7186 prueba=0.7214 red de 16 entrena=0.8040 prueba=0.6584 red de 64 y 32 entrena=1.0000 prueba=0.5834
Léelo columna por columna que hay tres cosas 👀
La red pierde. 0,7214 la logística contra 0,6584 y 0,5834. Y no por poco: la de dos capas está a diez puntos, que en AUC es un abismo.
Cuanto más grande, peor. La red de 64 y 32 tiene más de tres mil pesos que ajustar y 2.250 filas para hacerlo. No hay forma.
Y mira la columna de entrenamiento. 1,0000. Perfecto. La red grande memorizó las 2.250 filas y por eso en prueba se hunde 😱
Eso último es lo que hay que entender de este capítulo: una red neuronal es una máquina de memorizar, y lo hace tan bien que hay que trabajar para impedírselo. En el capítulo 8 dedicamos el rato entero a eso.
¿Y si la red está mal configurada?
Es la primera objeción y es justa. Probemos con freno.
con_freno = [
('64 y 32, parada temprana', MLPClassifier(hidden_layer_sizes=(64, 32),
max_iter=300, early_stopping=True,
random_state=42)),
('16, con penalización', MLPClassifier(hidden_layer_sizes=(16,), max_iter=300,
alpha=1.0, random_state=42)),
]
for nombre, modelo in con_freno:
m = arma(modelo).fit(X_tr, y_tr)
print(f'{nombre:26} entrena={roc_auc_score(y_tr, m.predict_proba(X_tr)[:, 1]):.4f} '
f'prueba={roc_auc_score(y_te, m.predict_proba(X_te)[:, 1]):.4f}')
64 y 32, parada temprana entrena=0.6818 prueba=0.7018 16, con penalización entrena=0.7559 prueba=0.7072
Con freno mejoran muchísimo: de 0,5834 a 0,7018 y a 0,7072. La memorización desaparece, porque el entrenamiento ya no se lleva a la red hasta el fondo.
Y aun así siguen perdiendo contra las tres líneas de la regresión logística 🤷♀️
Esto no es un accidente de estos datos. Con datos en tabla y pocas filas, los modelos lineales y los árboles ganan casi siempre, y hay competencias enteras donde el primer puesto es un boosting y las redes quedan atrás.
Entonces, ¿cuándo sí?
Cuando los datos tienen una estructura que las columnas no capturan. Esa es toda la regla y es más útil que cualquier lista.
| Tipo de dato | La estructura que tiene | Qué usar |
|---|---|---|
| Tabla (clientes, ventas) | Ninguna. Cambiar el orden de las columnas no cambia nada | Logística, bosques, boosting |
| Imágenes | Los píxeles vecinos significan algo juntos | Convolucionales, capítulo 9 |
| Texto | El orden importa y hay dependencias largas | Transformers, capítulo 11 |
| Audio y series | El tiempo es una dimensión de verdad | Convolucionales 1D, transformers |
Hay una frase que se repite mucho en el oficio y que conviene mirar de cerca: para datos en tabla el boosting le gana a una red casi siempre. Sobre estos datos, el boosting saca 0,6799 y la red con freno del capítulo 8 saca 0,7207, o sea que aquí no se cumple.
Lo que sí se cumple es lo de fondo: las dos pierden contra la regresión logística, que saca 0,7214. En tabla, los modelos simples mandan, y cuál de los complicados queda segundo cambia de un dataset a otro 🤷♀️
Fíjate en la primera fila de la tabla, que es la que explica todo lo de
arriba. En
nuestra tabla, si intercambias la columna monto con
ciudad, no se pierde información. En una foto, si intercambias dos
píxeles, la rompes.
Ahí es donde una red gana: no en tener más capacidad, sino en que su arquitectura ya sabe algo sobre la forma del dato antes de ver el primer ejemplo 💡
La otra pregunta: cuántos datos hacen falta
for n in [300, 750, 1500, 2250]:
sub = X_tr.sample(n, random_state=42)
obj = y_tr.loc[sub.index]
log = arma(LogisticRegression(max_iter=1000, random_state=42)).fit(sub, obj)
red = arma(MLPClassifier(hidden_layer_sizes=(64, 32), max_iter=300,
early_stopping=True, random_state=42)).fit(sub, obj)
print(f'n={n:5d} logística={roc_auc_score(y_te, log.predict_proba(X_te)[:, 1]):.4f} '
f'red={roc_auc_score(y_te, red.predict_proba(X_te)[:, 1]):.4f}')
n= 300 logística=0.7097 red=0.6306 n= 750 logística=0.7116 red=0.7161 n= 1500 logística=0.7189 red=0.7134 n= 2250 logística=0.7214 red=0.7165
Con 300 filas la red va ocho puntos por debajo (0,6306 contra 0,7097): no tiene con qué. A partir de 750 se empareja, e incluso le gana por poco en esa fila, y de ahí en adelante van las dos casi pegadas.
O sea que la red con freno sí alcanza a la logística en cuanto hay datos suficientes, y lo que no consigue es pasarla. Que es el resumen del capítulo: aquí no hay nada más que sacarle a estos datos, y el modelo simple ya lo estaba sacando 📈
La respuesta honesta a "¿cuántos datos necesito?" sigue siendo: más que el otro modelo. Y los modelos de imágenes que te suenan se entrenaron con millones de ejemplos, no con miles.
Y entonces por qué un libro entero
Por tres razones que van en serio:
- 🖼️ Porque los datos con forma existen en tu trabajo. Facturas escaneadas, fotos de anaquel, comentarios de clientes, audios de call center. Nada de eso entra en una tabla.
- 🤖 Porque los LLM son esto. ChatGPT y Claude son transformers, y entender la atención del capítulo 11 cambia cómo les escribes y qué esperas de ellos.
- 🛡️ Porque te van a vender redes. Y con este libro vas a poder pedir la comparación contra el modelo simple, que es la pregunta que desarma el 80% de las propuestas infladas.
Lo que vamos a hacer aquí es escribir las redes a mano, con numpy, sin librería que las esconda. La neurona, el gradiente, la retropropagación, la convolución y la atención. Todo en veinte líneas cada una y con sus números impresos 🔧
No porque en el trabajo se haga así, sino porque es la única forma de que después, cuando uses una librería, sepas qué está pasando.
Ejercicios
1. La red más pequeña posible
Una sola neurona escondida. Mira contra qué se parece.
una = arma(MLPClassifier(hidden_layer_sizes=(1,), max_iter=500,
random_state=42)).fit(X_tr, y_tr)
log = arma(LogisticRegression(max_iter=1000, random_state=42)).fit(X_tr, y_tr)
print('red de 1 neurona:', round(roc_auc_score(y_te, una.predict_proba(X_te)[:, 1]), 4))
print('logística :', round(roc_auc_score(y_te, log.predict_proba(X_te)[:, 1]), 4))
red de 1 neurona: 0.7019 logística : 0.7214
Se acerca bastante, y tiene sentido: una red de una neurona con salida sigmoide es casi la misma cuenta que una regresión logística. La diferencia está en cómo se entrena y en la activación del medio.
Guárdate esta idea porque es la del capítulo 2: la regresión logística que ya conoces es una neurona. No hay salto conceptual, hay apilamiento 🧱
2. Cuántos pesos tiene cada arquitectura
Cuenta los parámetros sin entrenar nada, que salen de la forma de la red y de nada más.
def cuantos_pesos(entradas, capas, salidas=1):
total, previa = 0, entradas
for c in list(capas) + [salidas]:
total += previa * c + c # los pesos, más el sesgo de cada neurona
previa = c
return total
ENTRADAS = 28 # 9 numéricas + 19 columnas que salen del OneHotEncoder
for capas in [(1,), (16,), (64, 32), (128, 64, 32), (512, 256, 128)]:
p = cuantos_pesos(ENTRADAS, capas)
print(f'{str(capas):18} {p:8d} pesos {p / len(X_tr):6.2f} por fila')
(1,) 31 pesos 0.01 por fila (16,) 481 pesos 0.21 por fila (64, 32) 3969 pesos 1.76 por fila (128, 64, 32) 14081 pesos 6.26 por fila (512, 256, 128) 179201 pesos 79.64 por fila
Esa última columna es la que hay que mirar antes de elegir arquitectura. Con (64, 32) ya hay más pesos que filas de entrenamiento, y con (512, 256, 128) hay más de setenta pesos por cada fila que tienes 😳
Si tienes más parámetros que datos, no estás modelando: estás guardando 🗄️
Y mira la primera fila: 31 pesos para una red de una neurona, que son las 28 entradas más su sesgo, más el peso y el sesgo de la salida. Nada mágico, todo contable ✍️
3. Qué pasa si no escalas
Quita el StandardScaler y entrena igual.
def red():
return MLPClassifier(hidden_layer_sizes=(16,), max_iter=300, random_state=42)
sin_escalar = Pipeline([
('pre', ColumnTransformer([
('num', SimpleImputer(strategy='median'), NUMERICAS),
('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
])),
('mod', red()),
]).fit(X_tr, y_tr)
print('sin escalar:', round(roc_auc_score(
y_te, sin_escalar.predict_proba(X_te)[:, 1]), 4))
print('escalada :', round(roc_auc_score(
y_te, arma(red()).fit(X_tr, y_tr).predict_proba(X_te)[:, 1]), 4))
sin escalar: 0.6925 escalada : 0.6584
Subió. Con las columnas sin escalar saca 0,6925 y escalada 0,6584 😤
Y aquí te tengo que contar lo que me pasó escribiendo esto, porque es más útil que el resultado. Mi primera explicación fue: "sin escalar, el monto llega con valores de miles, la red se satura y acaba usando solo las columnas del OneHotEncoder, que ya son ceros y unos". Sonaba impecable.
Antes de publicarla la comprobé:
solo_num = Pipeline([
('pre', ColumnTransformer([('num', SimpleImputer(strategy='median'), NUMERICAS)])),
('mod', red()),
]).fit(X_tr, y_tr)
print('sin escalar, solo numéricas:', round(roc_auc_score(
y_te, solo_num.predict_proba(X_te)[:, 1]), 4))
sin escalar, solo numéricas: 0.6931
0,6931 solo con las numéricas sin escalar, o sea prácticamente lo mismo que el modelo completo. La red no las estaba ignorando: las estaba usando. Mi explicación bonita era falsa.
Entonces, ¿qué queda? Queda el número tal cual: en este dataset, con esta arquitectura y esta semilla, no escalar no hizo daño. Y queda que no tengo una historia limpia para contarte, porque cuando la fui a comprobar no aguantó.
La regla de escalar sigue en pie, pero no se apoya en este número sino en cómo funciona el descenso de gradiente, que es el capítulo 5 y ahí lo vas a ver con tus ojos. Lo que no voy a hacer es inventarme el mecanismo para que el ejemplo cuadre 💛
Y lo otro sí pasa siempre: no escalar no da error ni advertencia útil. Entrena tan tranquila y devuelve números.
4. La red sobre datos que sí tienen forma
Fabrica un problema con estructura y repite la comparación.
rng = np.random.default_rng(0)
P = rng.normal(0, 1, (3000, 2))
etiqueta = ((P[:, 0] ** 2 + P[:, 1] ** 2) > 2).astype(int) # un círculo
A_tr, A_te, b_tr, b_te = train_test_split(P, etiqueta, test_size=0.25,
random_state=42, stratify=etiqueta)
log = LogisticRegression(max_iter=1000, random_state=42).fit(A_tr, b_tr)
red = MLPClassifier(hidden_layer_sizes=(16,), max_iter=500,
random_state=42).fit(A_tr, b_tr)
print('logística:', round(roc_auc_score(b_te, log.predict_proba(A_te)[:, 1]), 4))
print('red :', round(roc_auc_score(b_te, red.predict_proba(A_te)[:, 1]), 4))
logística: 0.4867 red : 0.9999
Aquí se dan la vuelta las tornas: 0,4867 la logística y 0,9999 la red.
Y fíjate que la logística sale por debajo de 0,5, que es peor que tirar una moneda. No es que lo haga mal a propósito: es que no hay ninguna recta que ayude, así que la que encuentra es ruido 🎲
La logística busca una recta y la frontera es un círculo, así que no hay nada que hacer. La red dobla la frontera sin que nadie le diga cómo 🎯
Y esto es lo que hay que llevarse: la red no es mejor, es distinta. Gana cuando la relación no es una raya, y en nuestros datos de ventas resulta que sí lo es.
5. Lo mismo, pero dándole la pista a la logística
Añade a mano las columnas al cuadrado y vuelve a medir.
P2 = np.column_stack([P, P ** 2])
C_tr, C_te, b_tr2, b_te2 = train_test_split(P2, etiqueta, test_size=0.25,
random_state=42, stratify=etiqueta)
log2 = LogisticRegression(max_iter=1000, random_state=42).fit(C_tr, b_tr2)
print('logística con los cuadrados:', round(roc_auc_score(
b_te2, log2.predict_proba(C_te)[:, 1]), 4))
logística con los cuadrados: 1.0
1,0 clavado, o sea que empata con la red y hasta la pasa 😄
Y ahí está la frase que resume el libro entero: la red aprende sola las columnas que tú tendrías que inventar a mano. Cuando sabes cuáles son, no la necesitas. Cuando son millones y no las puedes escribir (los píxeles de una foto, las relaciones entre palabras), la necesitas.
6. El error de darle texto crudo
Pásale la ciudad tal cual, sin codificar, a ver qué dice.
MLPClassifier(hidden_layer_sizes=(16,), max_iter=100).fit(
datos[['ciudad', 'segmento']], y)
ValueError: could not convert string to float: 'lima'
Una red neuronal es multiplicar matrices, y 'lima' no se
multiplica. Por eso el OneHotEncoder del pipeline no es opcional.
Me gusta este error porque deja claro qué es una red por dentro: no "entiende" nada, hace cuentas. Todo lo que entre tiene que ser un número, y convertirlo bien es la mitad del trabajo 🔢
7. La pregunta para la próxima propuesta que te llegue
Arma la tabla que yo pediría antes de aprobar una red.
filas = []
for nombre, modelo in [
('tonto (siempre sí)', None),
('logística', LogisticRegression(max_iter=1000, random_state=42)),
('red 64-32 con freno', MLPClassifier(hidden_layer_sizes=(64, 32), max_iter=300,
early_stopping=True, random_state=42)),
]:
if modelo is None:
filas.append({'modelo': nombre, 'AUC': 0.5, 'pesos': 0})
continue
m = arma(modelo).fit(X_tr, y_tr)
filas.append({'modelo': nombre,
'AUC': round(roc_auc_score(y_te, m.predict_proba(X_te)[:, 1]), 4),
'pesos': int(sum(c.size for c in m.named_steps['mod'].coefs_))
if hasattr(m.named_steps['mod'], 'coefs_')
else int(m.named_steps['mod'].coef_.size)})
print(pd.DataFrame(filas).to_string(index=False))
modelo AUC pesos
tonto (siempre sí) 0.5000 0
logística 0.7214 28
red 64-32 con freno 0.7018 3872
Tres filas y una decisión. Si la red no le saca ventaja clara a la logística, no entra: cuesta más tiempo, es más difícil de explicar y se rompe de formas más raras.
Pedir esta tabla no es ser negativa, es ser justa. Y si la red gana de verdad, la tabla también lo dice y ahí sí se aprueba con ganas 💛
Comprueba que lo tienes
En las 3.000 ventas la red saca 0,5834 y la regresión logística 0,7214. ¿Qué haces?
- Uso la logística, y guardo la red para cuando haya imágenes o texto
- Entreno la red más vueltas
- Le añado más capas
- Reviso los datos, porque una red no puede perder
Lo que te llevas
- 📉 Sobre estos datos en tabla la red pierde: 0,7214 la logística contra 0,6584 y 0,5834.
- 🧠 Y memoriza: la red de 64 y 32 llega a 1,0000 de AUC en entrenamiento.
- 🛑 Con parada temprana y penalización mejora mucho (0,7018 y 0,7072) y sigue perdiendo.
- 🖼️ La red gana cuando el dato tiene forma: píxeles vecinos, palabras en orden, tiempo.
- ⭕ En un problema circular fabricado, la logística da 0,4867 y la red 0,9999. Y la logística llega a 1,0 si le das los cuadrados a mano.
- 🧱 Una red de una neurona es casi una regresión logística. No hay salto conceptual, hay apilamiento.
- ⚖️ Sin escalar, esta red sacó 0,6925 y escalada 0,6584. La explicación que le iba a poner no aguantó la comprobación, así que va el número y no el cuento.
- 📋 Antes de aprobar una red, la tabla de tres filas: tonto, simple y red.
En el capítulo 2 escribimos la neurona a mano, con numpy y sin librería, para ver que por dentro son cuatro cuentas.
Que tengas lindo día! 🌸