Curso / fast.ai / Lección 5
fast.ai · 5 de 14

fast.ai · Practical Deep Learning for Coders · Lección 5

Modelo desde cero, otra vez: por qué repetirlo con datos tabulares

Esta lección no enseña conceptos nuevos de SGD — los repite, a propósito, sobre un dominio distinto (datos tabulares del Titanic en vez de imágenes de MNIST) para consolidar que el mecanismo es genuinamente el mismo en cualquier tipo de dato. Y cierra con el argumento más pragmático del curso: por qué, aun sabiendo programarlo a mano, conviene usar un framework.

Antes de leerlección 3, SGD y una red a mano

🎯 Al terminar vas a poder

  • Convertir un dataframe con columnas heterogéneas (categóricas, continuas, faltantes) en un tensor listo para indeps@coeffs, con pandas: fillna, np.log, get_dummies, normalización.
  • Escribir de cero un modelo lineal, una red de dos capas y una de varias capas sobre el Titanic, con calc_preds, calc_loss, one_epoch y train_model, y explicar por qué la sigmoid al final fue la mejora clave.
  • Reproducir el mismo modelo con TabularPandas + tabular_learner en diez líneas, y promediar cinco modelos (ensembling) para ganar estabilidad.
  • Diagnosticar los errores típicos de datos tabulares: categóricas como números arbitrarios, columnas sin normalizar, booleanos como object, evaluar sobre train.

1. El mismo proceso, otro dominio: de MNIST a Titanic

En la Lección 3 armaste, a mano, un modelo lineal y después una red de dos capas para clasificar dígitos. Acá el ejercicio se repite con el dataset del Titanic de Kaggle (predecir quién sobrevivió al hundimiento, a partir de clase de pasaje, sexo, edad, tarifa pagada, cantidad de familiares a bordo) — deliberadamente, para que quede claro que no hay nada específico de imágenes en SGD: los mismos siete pasos (predecir, medir loss, calcular gradiente, dar un step) aplican sin cambios a filas de una tabla.

Lo único que cambia entre MNIST y Titanic es el preprocesamiento de la entrada — cómo se convierte cada fila en un vector de números que un modelo lineal pueda multiplicar. El material de la lección son dos notebooks de Kaggle de Jeremy Howard: Linear model and neural net from scratch (todo a mano, con pandas y PyTorch pelado) y Why you should use a framework (lo mismo con fastai). Esta página sigue los dos, en ese orden. El dataset (train.csv, 891 pasajeros, 12 columnas) se baja de la competencia "Titanic - Machine Learning from Disaster" de Kaggle.

2. Preprocesar datos tabulares para un modelo lineal

A diferencia de una imagen (ya es una grilla de números), una tabla mezcla tipos de dato heterogéneos que hay que normalizar a una representación numérica uniforme antes de poder aplicar xb@weights + bias:

  • Missing values (edad faltante en 177 de los 891 pasajeros, cabina en la mayoría): se rellenan con un valor razonable. El notebook usa la moda de cada columna, la opción más simple que funciona para numéricas y categóricas a la vez; fastai usa la mediana por defecto y, cuando importa no perder esa información, agrega una columna booleana extra marcando qué filas tenían el dato faltante.
  • Variables con distribución muy sesgada (la tarifa: la mayoría pagó poco, unos pocos pagaron muchísimo): se les aplica un logaritmo para que la cola larga no domine. np.log(Fare + 1) — el +1 es para que la tarifa 0 no dé -inf.
  • Variables categóricas (sexo, clase de pasaje, puerto de embarque): se convierten a columnas dummy/one-hot — una columna binaria por categoría posible, en vez de un único número arbitrario (asignar 0/1/2 a categorías sin orden real induciría una relación numérica falsa entre ellas: "Cherbourg es la mitad de Southampton").
  • Variables continuas (edad, tarifa, familiares): se normalizan para que ninguna columna domine el gradiente solo por tener una escala numérica más grande. El notebook divide cada columna por su máximo (queda en 0–1); fastai resta la media y divide por el desvío estándar. Cualquiera de las dos sirve; lo que no sirve es no hacerlo.

El código, tal como está en el notebook (con un ajuste para pandas 2.x, ver el comentario):

import numpy as np, pandas as pd, torch
from torch import tensor

df = pd.read_csv('train.csv')
df.isna().sum()                       # Age 177, Cabin 687, Embarked 2

modes = df.mode().iloc[0]                # la moda de cada columna
df.fillna(modes, inplace=True)

df['LogFare'] = np.log(df['Fare'] + 1)
df = pd.get_dummies(df, columns=['Sex', 'Pclass', 'Embarked'])
added_cols = ['Sex_male', 'Sex_female', 'Pclass_1', 'Pclass_2', 'Pclass_3',
              'Embarked_C', 'Embarked_Q', 'Embarked_S']

indep_cols = ['Age', 'SibSp', 'Parch', 'LogFare'] + added_cols
t_dep = tensor(df.Survived)
# pandas 2.x crea las dummies como bool; el .astype(float) evita un array 'object' que tensor() rechaza
t_indep = tensor(df[indep_cols].astype(float).values, dtype=torch.float)
t_indep.shape                          # torch.Size([891, 12])

vals, indices = t_indep.max(dim=0)
t_indep = t_indep / vals               # cada columna dividida por su máximo → 0..1

Fijate por qué la normalización es obligatoria acá y no en MNIST: en MNIST todas las columnas eran píxeles en 0–1. En el Titanic, Age llega a 80, SibSp a 8 y las dummies son 0 o 1. Sin dividir por el máximo, el producto indeps*coeffs está dominado por la edad y la tarifa, el gradiente respecto de esos coeficientes es enorme comparado con el resto, y un learning rate que funciona para uno diverge para los otros. Es exactamente el mismo problema que "no dividir por 255" en la Lección 3, con otro disfraz.

De la tabla del Titanic al tensor que multiplica el modelo lineal Cada fila del CSV mezcla números, categorías y faltantes. Los faltantes se rellenan con la moda; la tarifa pasa por logaritmo; sexo, clase y puerto se convierten en columnas dummy, ocho en total; y cada columna se divide por su máximo. Quedan doce columnas numéricas entre 0 y 1 por pasajero, un tensor de 891 por 12, que multiplicado por doce coeficientes y pasado por una sigmoid da la probabilidad de sobrevivir. una fila del CSV Age 22 SibSp 1 Parch 0 Fare 7.25 Sex male Pclass 3 Embarked S Age NaN? 177 edades faltan normalizar: ÷ máximo → 0..1 log(Fare + 1): sin cola larga get_dummies: 1 columna por categoría fillna(moda) las 12 columnas del tensor, ya en 0..1 Age SibSp Parch LogFare S_m S_f P_1 P_2 P_3 E_C E_Q E_S 4 continuas normalizadas + 8 dummies (exactamente una de S_m/S_f vale 1: ese par hace de bias) · t_indep.shape = [891, 12] indeps [891, 12] @ coeffs [12] sigmoid p(sobrevive) · acc ≈ 0,83 sin normalizar, Age (hasta 80) y Fare (hasta 512) dominan el gradiente: es el «no dividir por 255» de MNIST, con otra tabla
Lo único que cambia entre MNIST y el Titanic es este preprocesamiento. Una imagen ya es una grilla de números; una tabla hay que convertirla: rellenar, comprimir colas largas, expandir categorías en columnas binarias y llevar todo a la misma escala. Después, el modelo es el mismo de la Lección 3.

3. El modelo lineal a mano: coeficientes, loss, sigmoid

Con la tabla convertida en un tensor [891, 12], el modelo lineal es el de siempre: un coeficiente por columna. El notebook no pone bias explícito porque las dummies ya lo cubren (para cada fila, exactamente una de Sex_male/Sex_female vale 1 — la suma de sus coeficientes actúa como constante). La loss es el error absoluto medio, más simple que MSE y perfectamente diferenciable donde importa:

torch.manual_seed(442)
n_coeff = t_indep.shape[1]
coeffs = torch.rand(n_coeff) - 0.5        # 12 coeficientes entre -0.5 y 0.5

def calc_preds(coeffs, indeps): return (indeps*coeffs).sum(axis=1)
def calc_loss(coeffs, indeps, deps): return torch.abs(calc_preds(coeffs, indeps) - deps).mean()

# un paso de gradiente, a mano
coeffs.requires_grad_()
loss = calc_loss(coeffs, t_indep, t_dep)
loss.backward()
with torch.no_grad():
    coeffs.sub_(coeffs.grad * 0.1)
    coeffs.grad.zero_()
    print(calc_loss(coeffs, t_indep, t_dep))   # bajó

(indeps*coeffs).sum(axis=1) es el producto matriz-vector escrito con broadcasting: multiplica cada fila por los coeficientes elemento a elemento y suma. Es lo mismo que indeps@coeffs, y el notebook lo escribe así primero para que se vea qué hace; después lo cambia por @. Ahora el split y el loop de entrenamiento, que son apply_step de la Lección 3 con otros nombres:

from fastai.data.transforms import RandomSplitter
trn_split, val_split = RandomSplitter(seed=42)(df)        # 80/20 de índices
trn_indep, val_indep = t_indep[trn_split], t_indep[val_split]
trn_dep,   val_dep   = t_dep[trn_split],   t_dep[val_split]

def init_coeffs(): return (torch.rand(n_coeff) - 0.5).requires_grad_()

def update_coeffs(coeffs, lr):
    coeffs.sub_(coeffs.grad * lr)
    coeffs.grad.zero_()

def one_epoch(coeffs, lr):
    loss = calc_loss(coeffs, trn_indep, trn_dep)   # todo el train set de una: 712 filas, no hace falta batch
    loss.backward()
    with torch.no_grad(): update_coeffs(coeffs, lr)
    print(f"{loss:.3f}", end="; ")

def train_model(epochs=30, lr=0.01):
    torch.manual_seed(442)
    coeffs = init_coeffs()
    for i in range(epochs): one_epoch(coeffs, lr=lr)
    return coeffs

def acc(coeffs): return (val_dep.bool() == (calc_preds(coeffs, val_indep) > 0.5)).float().mean()

coeffs = train_model(18, lr=0.2)
acc(coeffs)
→ 0.536; 0.502; 0.477; 0.454; 0.431; … 0.301; acc ≈ 0.79

Alrededor de 79% de accuracy en validación con un modelo lineal — ya mejor que "todos mueren" (~62%) y que "sobreviven las mujeres" (~78%). Pero mirá las predicciones: calc_preds devuelve cualquier número real. Para un pasajero de primera clase, mujer, joven, puede dar 1.4; para otro, -0.3. La loss castiga esos excesos (|1.4 − 1| = 0.4 de error en una predicción que en realidad es correcta), y el modelo gasta capacidad en no pasarse de 1 en vez de en separar mejor a los pasajeros.

Justificación Matemática y Dinámica del Gradiente con Sigmoid

def calc_preds(coeffs, indeps): return torch.sigmoid((indeps*coeffs).sum(axis=1))

coeffs = train_model(lr=100)      # sí, 100: la derivada de la sigmoid aplasta los gradientes
acc(coeffs)
→ acc ≈ 0.83

Cambiar una única línea incrementa la accuracy de 79% a 83%. Pero ¿cuál es el fundamento matemático exacto de este salto cualitativo?

Consideremos el modelo lineal sin sigmoid: produce una combinación no acotada (un logit crudo) z=w⊤x∈(-∞,∞). Supongamos que evaluamos bajo la función de pérdida de error absoluto medio (MAE) L=|z-y|.

Analicemos el caso de un pasajero que efectivamente sobrevivió (y=1):

  • Supongamos que el modelo realiza una predicción correcta y con alta certeza: z=2.5.
  • La pérdida lineal incurre en un error espurio: L=|2.5-1|=1.5.
  • El gradiente respecto a la salida del modelo es:
    ∂L∂z=sign(z-y)=sign(2.5-1)=+1.0
  • La actualización de pesos mediante SGD aplica:
    wj←wj-η∂L∂wj=wj-η(+1.0)xj

La patología del modelo lineal: A pesar de haber clasificado correctamente la instancia como superviviente (z>0), el gradiente penaliza al modelo forzando a los pesos a reducir la predicción de vuelta hacia 1.0. La optimización dilapida grados de libertad intentando encajar una salida continua exactamente en los valores discretos {0,1}, en lugar de concentrarse en separar los casos difíciles y ambiguos.

Al introducir la función logística Sigmoid y^=σ(z)=11+e-z∈(0,1):

∂L∂z=∂|σ(z)-y|∂σ(z)⋅σ′(z)=sign(σ(z)-y)⋅σ(z)(1-σ(z))

Cuando y=1 y la predicción es asertiva (z→∞), σ(z)→1 y la derivada σ′(z)→0, por lo que ∂L∂z→0. El modelo no recibe penalización alguna por emitir logits arbitrariamente altos para casos indudables.

¿Por qué el learning rate salta de 0.2 a 100? La derivada máxima de la función sigmoid se alcanza en z=0 y vale exactamente σ′(0)=0.5×0.5=0.25. Para valores más alejados del umbral, σ′(z) decrece exponencialmente. Para mantener una magnitud de actualización suficiente en los pesos (Δw∼η⋅σ′(z)), la tasa de aprendizaje debe amplificarse drásticamente.

Un último cambio de forma antes de la red: pasar de (indeps*coeffs).sum(axis=1) a indeps@coeffs con coeffs de shape [12, 1], y los targets a [n, 1] con trn_dep[:, None]. Es solo para que el producto matricial escale a varias capas; los números no cambian.

4. De lineal a red de dos capas, y a "deep learning"

La red de dos capas es simple_net de la Lección 3 con otros nombres: una matriz [12, n_hidden], ReLU, una matriz [n_hidden, 1], un bias y la sigmoid final. El notebook mantiene train_model y one_epoch sin tocarlos — solo redefine las tres funciones que saben cómo son los coeficientes:

import torch.nn.functional as F

def init_coeffs(n_hidden=20):
    layer1 = (torch.rand(n_coeff, n_hidden) - 0.5) / n_hidden
    layer2 = torch.rand(n_hidden, 1) - 0.3
    const = torch.rand(1)[0]
    return layer1.requires_grad_(), layer2.requires_grad_(), const.requires_grad_()

def calc_preds(coeffs, indeps):
    l1, l2, const = coeffs
    res = F.relu(indeps@l1)
    res = res@l2 + const
    return torch.sigmoid(res)

def update_coeffs(coeffs, lr):
    for layer in coeffs: layer.sub_(layer.grad * lr)
    for layer in coeffs: layer.grad.zero_()

coeffs = train_model(lr=20)
acc(coeffs)

Las constantes raras de init_coeffs (/n_hidden, -0.3) son inicialización a ojo para que las activaciones arranquen en un rango razonable — el notebook admite que las eligió probando, y ese es justamente uno de los argumentos de §5 para usar un framework: la inicialización correcta (Kaiming/He para ReLU) ya está estudiada y no hace falta adivinarla.

"Deep learning" es lo mismo con una lista de capas en vez de dos. La versión del notebook parametriza cuántas capas ocultas y de qué tamaño:

def init_coeffs():
    hiddens = [10, 10]                      # tamaño de cada capa oculta; probá [20], [10,10,10]…
    sizes = [n_coeff] + hiddens + [1]
    n = len(sizes)
    layers = [(torch.rand(sizes[i], sizes[i+1]) - 0.3) / sizes[i+1] * 4 for i in range(n-1)]
    consts = [(torch.rand(1)[0] - 0.5) * 0.1 for i in range(n-1)]
    for l in layers + consts: l.requires_grad_()
    return layers, consts

def calc_preds(coeffs, indeps):
    layers, consts = coeffs
    n = len(layers)
    res = indeps
    for i, l in enumerate(layers):
        res = res@l + consts[i]
        if i != n-1: res = F.relu(res)       # ReLU entre capas, no después de la última
    return torch.sigmoid(res)

def update_coeffs(coeffs, lr):
    layers, consts = coeffs
    for layer in layers + consts: layer.sub_(layer.grad * lr)
    for layer in layers + consts: layer.grad.zero_()

coeffs = train_model(lr=4)
acc(coeffs)

Y acá viene la parte honesta del notebook: con 891 filas y 12 columnas, la red de dos capas y la "profunda" dan una accuracy de validación prácticamente igual a la del modelo lineal con sigmoid, alrededor de 82–83%. No es que las redes no sirvan — es que un dataset tan chico no tiene información suficiente para que la capacidad extra se traduzca en algo, y la varianza entre corridas (178 filas de validación: cada pasajero es más de medio punto) es mayor que la diferencia entre modelos. Ese es el argumento para el ensembling de §5 y para los árboles de §6.

5. Por qué usar un framework (y qué esconde)

Habiendo ya escrito linear1, el loop de gradientes y simple_net a mano dos veces (MNIST y Titanic), el curso hace explícito el argumento pedagógico completo: entender el mecanismo desde cero te da la capacidad de diagnosticar cuando algo sale mal — pero escribirlo a mano en cada proyecto real es un desperdicio de tiempo y una fuente de bugs sutiles. Un framework como fastai o PyTorch de alto nivel no reemplaza el conocimiento de qué está pasando por dentro — automatiza exactamente eso que ya escribiste, y agrega detalles de estabilidad numérica y performance que no vale la pena reinventar cada vez: inicialización cuidadosa de pesos, manejo de NaN/overflow, batching eficiente en GPU, schedulers de learning rate.

El segundo notebook de la lección, Why you should use a framework, hace exactamente lo de §2–§4 con fastai. Todo el preprocesamiento son tres procs, y la red es una línea:

from fastai.tabular.all import *

df = pd.read_csv('train.csv')
df['LogFare'] = np.log1p(df['Fare'])        # log(x+1), la misma transformación de §2
df['Family'] = df.SibSp + df.Parch         # feature engineering, lo único que el framework no hace

splits = RandomSplitter(seed=42)(df)
dls = TabularPandas(
    df, splits=splits,
    procs=[Categorify, FillMissing, Normalize],   # one-hot/embeddings, imputación + columna _na, (x-media)/std
    cat_names=['Sex', 'Pclass', 'Embarked'],
    cont_names=['Age', 'SibSp', 'Parch', 'LogFare', 'Family'],
    y_names='Survived', y_block=CategoryBlock(),
).dataloaders(path='.')

learn = tabular_learner(dls, metrics=accuracy, layers=[10, 10])   # las mismas dos capas ocultas de §4
learn.lr_find(suggest_funcs=(slide, valley))
learn.fit(16, lr=0.03)

Entity Embeddings para Variables Categóricas (Guo & Berkhahn, 2016)

El preprocesador Categorify y tabular_learner reemplazan la codificación One-Hot por Entity Embeddings.

En la codificación One-Hot, cada categoría de cardinalidad C se representa como un vector ortogonal en la base estándar de RC. La distancia euclidiana entre cualquier par de categorías distintas i≠j es idéntica:

∥ei-ej∥2=12+(-1)2=2≈1.414

Esto impone un sesgo nulo: el modelo asume que primera clase y segunda clase son tan disímiles como primera clase y sexo masculino. Los Entity Embeddings proyectan cada categoría c∈{1,…,C} en un espacio vectorial continuo de dimensión reducida RD (D≪C) mediante una matriz de búsqueda E∈RC×D.

Para determinar automáticamente la dimensión óptima de embedding D, fastai implementa una regla empírica no lineal:

D=min600,round1.6×C0.56

Esta fórmula otorga suficientes grados de libertad para cardinalidades bajas sin caer en la maldición de la dimensionalidad para categorías masivas:

  • Para C=3 (puerto de embarque o clase): D≈1.6×30.56≈3.
  • Para C=100 (categorías comerciales): D≈1.6×1000.56≈21.
  • Para C=10.000 (códigos postales o IDs de usuario): D≈1.6×100000.56≈276.

Durante el entrenamiento, el gradiente propaga actualizaciones hacia E. Categorías con comportamientos predictivos afines convergen hacia cúmulos cercanos en el espacio latente, permitiendo que la red generalice inferencias hacia categorías con pocas observaciones en el conjunto de entrenamiento.

La mejora final: promediar cinco modelos

Como cada entrenamiento arranca de pesos aleatorios distintos y el dataset es chico, dos corridas del mismo modelo pueden diferir en uno o dos puntos de accuracy. El notebook cierra con el truco más barato que existe para bajar esa varianza: entrenar cinco veces y promediar las probabilidades predichas (un ensemble).

def ensemble():
    learn = tabular_learner(dls, metrics=accuracy, layers=[10, 10])
    with learn.no_bar(), learn.no_logging(): learn.fit(16, lr=0.03)
    return learn.get_preds()[0]           # probabilidades sobre validación, shape [178, 2]

learns = [ensemble() for _ in range(5)]
ens_preds = torch.stack(learns).mean(0)   # promedio de las 5 corridas
(ens_preds.argmax(dim=1) == tensor(df.Survived.values)[splits[1]]).float().mean()

Cada modelo individual se equivoca en pasajeros distintos por su inicialización; el promedio cancela parte de ese ruido. En el notebook, el ensemble sube la accuracy de validación y, más importante, la hace estable entre corridas. Es la misma idea que los random forests de §6 (promediar muchos árboles), aplicada a redes.

📜 Una analogía en el espíritu del curso

Saber cómo funciona SGD por dentro y aun así usar Learner.fit() de fastai es como saber cómo funciona un motor de combustión y aun así manejar un auto con caja automática: el conocimiento no se desperdicia — te permite reconocer cuándo algo anda raro (el motor suena distinto, el loss no baja como debería) que alguien que solo sabe "girar la llave" no puede diagnosticar.

🔧 En tu proyecto

Tu gate anti-alucinación para artículos financieros es, en el fondo, un clasificador binario sobre features tabulares (scores, longitudes, conteos de entidades) — y todo lo de esta lección le aplica: normalizar las features antes de cualquier capa lineal, terminar en sigmoid para que el umbral sea una probabilidad, y promediar varias corridas si la decisión de bloquear un artículo no puede depender de la semilla con la que entrenaste.

6. Redes Neuronales Tabulares vs. Gradient Boosted Trees (GBDTs)

Para datos tabulares heterogéneos, existe una tensión arquitectónica constante entre dos familias con sesgos inductivos (inductive biases) opuestos: las Redes Neuronales y los Árboles de Decisión Ensemblados (Random Forests y GBDTs: XGBoost, LightGBM, CatBoost).

PropiedadGradient Boosted Decision Trees (GBDTs)Redes Neuronales Tabulares (MLP / TabNet)
Geometría de Separación Particiones ortogonales alineadas a los ejes: funciones escalonadas constantes a trozos (I[xj>θ]). Combinaciones lineales continuas seguidas de no linealidades suaves: hiperplanos rotados (σ(Wx+b)).
Invarianza a Escalas Completamente invariante: los splits solo dependen del ordenamiento ordinal. No requiere normalización ni log transforms. Extremadamente sensible: variables con magnitudes grandes distorsionan el gradiente y requieren normalización estricta.
Variables Categóricas Soportan target encoding o splits por partición de conjuntos discretos nativamente (CatBoost/LightGBM). Exigen Entity Embeddings densos (RD), que destacan en cardinalidades masivas (C>105).
Fusión Multimodal Imposible de forma end-to-end; requiere extraer embeddings previamente como vectores estáticos. Nativo y diferenciable: permite concatenar features tabulares con representaciones de LLMs (texto) y Vision Transformers (imágenes).

Criterio de Selección de Ingeniería:

  • Elegí GBDTs (XGBoost / LightGBM) cuando: El problema involucra datos tabulares puros, columnas numéricas con distribuciones multimodales o valores atípicos severos, y datasets medianos donde el costo de inferencia en CPU deba ser sub-milisegundo.
  • Elegí Redes Tabulares (fastai / PyTorch) cuando: Existen variables categóricas de cardinalidad extrema (millones de usuarios o productos) donde los embeddings capturen similitud semántica latente, o cuando el sistema deba fusionar datos estructurados con texto no estructurado o embeddings de un LLM en un único forward pass end-to-end.

7. Errores comunes

En datos tabulares, casi todos los errores están en la tabla antes de que exista el modelo — y ninguno lanza una excepción.

SíntomaCausa probableQué hacer
El modelo lineal rinde peor de lo esperado y los coeficientes de una columna categórica no tienen sentido.Codificaste la categórica como un número arbitrario (Embarked → 0/1/2, Pclass como 1/2/3 directo): el modelo asume que "Q" está entre "C" y "S", y que segunda clase es el promedio de primera y tercera.pd.get_dummies (una columna por categoría) o, con fastai, Categorify + embeddings. Ojo con Pclass: parece número pero es categoría.
El loss baja lentísimo con lr chico y explota con lr grande; no hay lr que funcione.Columnas sin normalizar: Age (hasta 80) y Fare (hasta 512) dominan el gradiente, y el lr que les sirve a ellas es demasiado grande para las dummies.Dividir por el máximo (notebook) o Normalize (fastai), siempre con estadísticas de train. Log a las columnas con cola larga.
TypeError: can't convert np.ndarray of type numpy.object_ al hacer tensor(df[cols].values).pandas 2.x crea las dummies como bool; mezcladas con floats, .values devuelve un array object.df[cols].astype(float).values, o pd.get_dummies(..., dtype=float).
Con una red de dos capas escrita a mano, la accuracy queda clavada por debajo del modelo lineal.Olvidaste el bias (la constante) en alguna capa: sin él, la capa oculta no puede desplazar el punto donde ReLU corta, y muchas neuronas quedan siempre en cero.Una constante por capa (el const/consts del notebook). nn.Linear lo trae por defecto.
La red profunda no converge con la lr que funcionaba para la red de dos capas (o al revés).Cada arquitectura tiene su propio rango de lr útil — 0.2 para el lineal sin sigmoid, 100 con sigmoid, 20 para dos capas, 4 para tres: la escala de los gradientes cambia con cada capa que agregás.No reutilices la lr entre modelos. Con fastai, lr_find cada vez que cambiás la arquitectura.
Accuracy de 90% "en validación" que nunca se reproduce en Kaggle.Evaluaste sobre train (acc escrita con trn_indep), o normalizaste con estadísticas del dataset completo, o imputaste la edad con la moda calculada incluyendo validación.Split primero, todo lo demás después, con estadísticas solo de train. TabularPandas lo hace bien si le pasás splits.
Dos corridas del mismo modelo difieren en 2 puntos y no sabés cuál es "mejor".Varianza de inicialización sobre un validation set de 178 filas: cada pasajero vale 0,56 puntos.Ensembling de varias corridas (§5) y, para comparar arquitecturas, validación cruzada o al menos varias semillas.

8. Resumen

  1. SGD no tiene nada específico de imágenes — el mismo mecanismo de la Lección 3 aplica sin cambios a datos tabulares del Titanic; el notebook reutiliza train_model y one_epoch sin tocarlos para el lineal, la red de dos capas y la profunda.
  2. Lo que cambia entre dominios es el preprocesamiento: imputación (moda o mediana), log para colas largas, get_dummies para categóricas, normalización (dividir por el máximo o estandarizar) para que ninguna columna domine el gradiente.
  3. La mejora clave del notebook es una sigmoid al final: acota las predicciones a (0, 1), así la loss deja de castigar aciertos exagerados y el modelo usa su capacidad donde importa. El precio: gradientes más chicos, lr mucho más grande.
  4. Con 891 filas, lineal + sigmoid, red de dos capas y red profunda empatan en ~82–83%: el techo lo pone el dataset. Promediar cinco modelos (ensembling) baja la varianza y estabiliza el resultado.
  5. Escribir SGD a mano una vez te da el diagnóstico; TabularPandas + tabular_learner después te ahorran reinventar imputación, normalización con estadísticas de train, inicialización y lr_find en cada proyecto.
  6. Para datos tabulares, los árboles de decisión y random forests son una alternativa a las redes neuronales — más simples, casi sin preprocesamiento, muy interpretables — y suelen ser el primer modelo a probar.

9. Ponete a prueba

Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.

1. ¿Por qué Pclass (1, 2, 3) se pasa por get_dummies si ya es un número?

Porque es una categoría con apariencia numérica: nada garantiza que el efecto de segunda clase sea el promedio de primera y tercera. Con un coeficiente único, el modelo está obligado a asumir esa relación lineal; con tres dummies, cada clase tiene su propio peso. (§2, §7)

2. ¿Qué pasaría si no dividieras t_indep por el máximo de cada columna?

Age y Fare dominarían indeps*coeffs y sus gradientes; un lr que sirve para esos coeficientes sería demasiado grande para las dummies (o al revés), y el entrenamiento diverge o avanza lentísimo. Es el "no dividir por 255" de MNIST. (§2)

3. ¿Por qué agregar torch.sigmoid al final de calc_preds mejora la accuracy, si la loss no cambió?

Sin sigmoid, una predicción de 1.4 para un sobreviviente cuenta como 0.4 de error aunque sea correcta, y el modelo gasta capacidad en no pasarse. Con sigmoid la salida queda en (0, 1): los casos claros pueden ser "muy claros" sin penalización, y los coeficientes se dedican a la zona ambigua. (§3)

4. El lr pasa de 0.2 (lineal) a 100 (lineal + sigmoid). ¿Qué cambió?

La derivada de la sigmoid es como mucho 0.25 y mucho menor lejos de cero, así que los gradientes que llegan a los coeficientes son mucho más chicos; hace falta un lr proporcionalmente mayor para moverse lo mismo. Cada modelo tiene su propio rango de lr. (§3, §7)

5. ¿Por qué la red profunda no le gana al modelo lineal con sigmoid en este dataset?

Con 891 filas y 12 columnas no hay información suficiente para que la capacidad extra se traduzca en mejor generalización, y la varianza de un validation set de 178 filas (0,56 puntos por pasajero) es mayor que la diferencia entre modelos. El techo lo pone el dataset. (§4)

6. ¿Qué hace exactamente el ensemble de §5 y por qué ayuda?

Entrena cinco veces el mismo modelo desde inicializaciones distintas y promedia las probabilidades predichas. Cada corrida se equivoca en pasajeros distintos por azar de inicialización; el promedio cancela parte de ese ruido y el resultado es más estable entre corridas. (§5)

10. Para practicar

✏️ Ejercicio 1 — Correr las tres versiones y anotar la accuracy

Bajá train.csv de la competencia Titanic de Kaggle (o cualquier copia del mismo archivo con las 12 columnas originales) y corré §2, §3 y §4 de corrido en CPU: tarda segundos. Anotá la accuracy de validación de (a) lineal, (b) lineal + sigmoid, (c) dos capas, (d) profunda con hiddens=[10, 10]. Después cambiá torch.manual_seed(442) por otras dos semillas y repetí.

# después de §2 y del split de §3
for seed in [442, 1, 7]:
    torch.manual_seed(seed)
    coeffs = init_coeffs()
    for i in range(30): one_epoch(coeffs, lr=4)     # versión profunda
    print(f'\nseed {seed}: acc {acc(coeffs):.3f}')
Pista / qué esperar

(a) alrededor de 0.79; (b), (c) y (d) alrededor de 0.82–0.83, con diferencias entre semillas del orden de 1–2 puntos — más grandes que la diferencia entre arquitecturas. Esa es la conclusión del ejercicio.

✏️ Ejercicio 2 — Ver el efecto de no normalizar

Repetí el modelo lineal + sigmoid de §3 pero comentá la línea t_indep = t_indep / vals. Probá con lr=100, lr=1 y lr=0.01, y mirá los coeficientes finales con dict(zip(indep_cols, coeffs.detach())).

Pista / qué esperar

Con lr=100 el loss salta o se queda en 0.5 (sigmoid saturada por Age y LogFare); con lr chico baja despacio y las dummies apenas se mueven. Los coeficientes de Age y Fare terminan minúsculos comparados con el resto — no porque importen poco, sino porque su escala es grande.

✏️ Ejercicio 3 — Lo mismo con fastai, y el ensemble

Corré el código de §5 y compará con tu mejor resultado a mano. Después subí el ensemble de 5 a 10 modelos y mirá si la accuracy cambia o solo se estabiliza. Con pip install fastai corre en CPU en menos de un minuto.

Pista / qué esperar

Accuracy individual en la misma zona (~82–84%) que a mano — el framework no hace magia, hace lo mismo bien. El ensemble de 10 no debería ser claramente mejor que el de 5; lo que gana es que dos corridas completas del ensemble den casi el mismo número.

11. Fuentes y para seguir