Capítulo 1 de 15 8 secciones 14 min

Cuándo una red neuronal ayuda y cuándo es un error caro

Empezamos midiendo: sobre los datos de este libro la red pierde contra una regresión logística, y pierde más cuanto más grande es.

El deep learning gana cuando los datos tienen estructura que las columnas no capturan: píxeles vecinos, palabras en orden, sonido. Sobre datos en tabla y con pocas filas suele perder. Aquí, con 3.000 filas, la regresión logística saca 0,7214 de AUC en dos décimas de segundo y una red de dos capas saca 0,5834 en tres segundos, con 1,0000 en entrenamiento: memorizó.

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 datoLa estructura que tieneQué usar
Tabla (clientes, ventas)Ninguna. Cambiar el orden de las columnas no cambia nadaLogística, bosques, boosting
ImágenesLos píxeles vecinos significan algo juntosConvolucionales, capítulo 9
TextoEl orden importa y hay dependencias largasTransformers, capítulo 11
Audio y seriesEl tiempo es una dimensión de verdadConvolucionales 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 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! 🌸

¿Tienes alguna duda o consulta?