Machine Learning

Comparar modelos de Machine Learning: No siempre gana la red neuronal

Entrena y compara 10 modelos de clasificación sobre el dataset Titanic: una simple Regresión Logística (0.855) superó a una red neuronal (0.793). Aprende a evaluar modelos y descubre por qué lo complejo no siempre es mejor.

por Santi López ·

La intuición y los datos no siempre coinciden

En resumen: Entrené 10 modelos de clasificación sobre el dataset Titanic para comparar su rendimiento en igualdad de condiciones. La Regresión Logística (0.855) superó a la red neuronal MLP (0.793) por más de 6 puntos de accuracy, y el Ensemble Stacking (0.866) fue el ganador absoluto.

  • La red neuronal (MLP 32,16) fue el segundo peor modelo, solo por delante del Decision Tree
  • Una simple Regresión Logística con buen feature engineering quedó segunda
  • El Stacking Classifier combinó LR + Random Forest + XGBoost para llevarse el mejor resultado
  • Con datasets tabulares pequeños, los modelos clásicos suelen ganar a las redes neuronales

Cuando empecé en Machine Learning, tenía una intuición clara: las redes neuronales son la herramienta más potente, así que deberían funcionar mejor en cualquier problema. Era una idea que arrastraba de leer sobre deep learning, transformers y modelos que generan texto, imágenes y código.

El dataset Titanic me bajó de esa nube en menos de una tarde.

Este ejercicio nació de una pregunta sencilla: ¿qué pasa si entreno varios modelos sobre exactamente los mismos datos y los comparo? El resultado fue una lección que ningún tutorial teórico me había enseñado con tanta claridad: el modelo más complejo no es el mejor. Lo es el que mejor se adapta a tus datos.

El dataset: carga y exploración inicial

El dataset Titanic de Kaggle contiene información de 891 pasajeros con 12 columnas. La variable objetivo es Survived (0 = no sobrevivió, 1 = sobrevivió).

import pandas as pd

df = pd.read_csv("data/titanic/train.csv")
df.head()
PassengerIdSurvivedPclassNameSexAgeSibSpParchTicketFareCabinEmbarked
103Braund, Mr. Owen Harrismale22.010A/5 211717.25NaNS
211Cumings, Mrs. John Bradleyfemale38.010PC 1759971.28C85C

Lo primero es entender qué tenemos entre manos:

df.info()
Data columns (total 12 columns):
 #   Column       Non-Null Count  Dtype
 0   PassengerId  891 non-null    int64
 1   Survived     891 non-null    int64
 2   Pclass       891 non-null    int64
 3   Name         891 non-null    object
 4   Sex          891 non-null    object
 5   Age          714 non-null    float64    ← 177 nulos
 6   SibSp        891 non-null    int64
 7   Parch        891 non-null    int64
 8   Ticket       891 non-null    object
 9   Fare         891 non-null    float64
 10  Cabin        204 non-null    object     ← 687 nulos
 11  Embarked     889 non-null    object     ← 2 nulos

Problemas detectados: Age tiene 177 valores nulos, Cabin está vacía en el 77% de los casos, Embarked tiene 2 huecos. Con df.describe() confirmo que no hay outliers sospechosos y que la tasa de supervivencia es del 38%.

Ingeniería de features

Aquí es donde se gana o se pierde el partido. Los datos en bruto rara vez son suficientes. La función preprocess() que escribí hace cinco cosas clave:

def preprocess(df):
    df["Age"] = df["Age"].fillna(df["Age"].median())
    df["Embarked"] = df["Embarked"].fillna(df["Embarked"].mode()[0])
    df["Fare"] = df["Fare"].fillna(df["Fare"].median())

    df["Title"] = df["Name"].str.extract(r' ([A-Za-z]+)\.', expand=False)
    df["Title"] = df["Title"].replace(
        ['Lady','Countess','Capt','Col','Don','Dr','Major','Rev','Sir','Jonkheer','Dona'],
        'Rare'
    )
    df["Title"] = df["Title"].replace(['Mlle','Ms'], 'Miss')
    df["Title"] = df["Title"].replace('Mme', 'Mrs')

    df["FamilySize"] = df["SibSp"] + df["Parch"] + 1
    df["IsAlone"] = (df["FamilySize"] == 1).astype(int)
    df["FarePerPerson"] = df["Fare"] / df["FamilySize"]

    df = df.drop(columns=["Cabin", "Name", "Ticket", "PassengerId"])
    return df

¿Por qué cada decisión?

  • Title desde el nombre: el tratamiento social (Mr, Mrs, Miss, Master) codifica implícitamente sexo, edad aproximada y estatus social. Los títulos raros (Dr, Rev, Col) los agrupo en Rare porque tienen pocas ocurrencias.
  • FamilySize: combina hermanos/cónyuge (SibSp) y padres/hijos (Parch). Familias numerosas tuvieron distinta tasa de supervivencia que pasajeros solos.
  • IsAlone: un flag binario que captura si el pasajero viajaba solo. Los pasajeros solos y las familias grandes tuvieron comportamientos opuestos.
  • FarePerPerson: la tarifa bruta no dice mucho si no sabes para cuántas personas era el billete. Dividir por el tamaño familiar normaliza este valor.

Tras aplicar preprocess(), el dataframe queda con 12 columnas limpias, sin nulos y listas para modelar:

Survived, Pclass, Sex, Age, SibSp, Parch, Fare, Embarked, Title, FamilySize, IsAlone, FarePerPerson

División train/test y preparación final

Divido los datos con estratificación para mantener la proporción de supervivientes en ambos conjuntos:

from sklearn.model_selection import train_test_split

X = df.drop(columns=["Survived"])
y = df["Survived"]

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

Las variables categóricas (Sex, Embarked, Pclass, Title) las convierto con OneHotEncoder, y las numéricas las escalo con StandardScaler para que todas tengan media 0 y desviación 1. Esto es especialmente importante para la Regresión Logística, SVM y la red neuronal, que son sensibles a la escala:

from sklearn.preprocessing import OneHotEncoder, StandardScaler
import pandas as pd

categorical_cols = ["Sex", "Embarked", "Pclass", "Title"]
encoder = OneHotEncoder(sparse_output=False, handle_unknown="ignore")
encoded = encoder.fit_transform(X_train[categorical_cols])
encoded_cols = encoder.get_feature_names_out(categorical_cols)
encoded_df = pd.DataFrame(encoded, columns=encoded_cols, index=X_train.index)

X_train_processed = X_train.drop(columns=categorical_cols)
X_train_processed = pd.concat([X_train_processed, encoded_df], axis=1)

numeric_cols = ["Age", "SibSp", "Parch", "Fare", "FarePerPerson", "FamilySize"]
scaler = StandardScaler()
X_train_processed[numeric_cols] = scaler.fit_transform(X_train_processed[numeric_cols])

# Mismo tratamiento para test
encoded_test = encoder.transform(X_test[categorical_cols])
encoded_test_df = pd.DataFrame(encoded_test, columns=encoded_cols, index=X_test.index)
X_test_processed = X_test.drop(columns=categorical_cols)
X_test_processed = pd.concat([X_test_processed, encoded_test_df], axis=1)
X_test_processed[numeric_cols] = scaler.transform(X_test_processed[numeric_cols])

El dataset procesado final tiene 20 features: 7 numéricas originales más 13 columnas del OneHotEncoder (Sex_female, Sex_male, Embarked_C, Embarked_Q, Embarked_S, Pclass_1, Pclass_2, Pclass_3, Title_Master, Title_Miss, Title_Mr, Title_Mrs, Title_Rare).

Entrenamiento de los 10 modelos

Aquí viene la parte interesante. Entrené 10 modelos con sus configuraciones. Todos reciben exactamente los mismos X_train_processed y se evalúan con los mismos X_test_processed:

from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier, StackingClassifier
from xgboost import XGBClassifier
from sklearn.neural_network import MLPClassifier
from sklearn.naive_bayes import GaussianNB
from sklearn.svm import SVC

# 1. Regresión Logística — el baseline clásico
model = LogisticRegression(max_iter=1000)
model.fit(X_train_processed, y_train)
y_pred = model.predict(X_test_processed)

# 2. Árbol de Decisión — el más simple de los no lineales
tree = DecisionTreeClassifier(random_state=42)
tree.fit(X_train_processed, y_train)
tree_pred = tree.predict(X_test_processed)

# 3. Random Forest — 300 árboles sin límite de profundidad
rf = RandomForestClassifier(n_estimators=300, max_depth=None, random_state=42)
rf.fit(X_train_processed, y_train)
rf_pred = rf.predict(X_test_processed)

# 4. Gradient Boosting — boosting clásico de sklearn
gb = GradientBoostingClassifier(random_state=42)
gb.fit(X_train_processed, y_train)
gb_pred = gb.predict(X_test_processed)

# 5. XGBoost — boosting optimizado con regularización
xgb = XGBClassifier(
    n_estimators=300, learning_rate=0.05, max_depth=4,
    subsample=0.8, colsample_bytree=0.8, random_state=42
)
xgb.fit(X_train_processed, y_train)
xgb_pred = xgb.predict(X_test_processed)

# 6. XGBoost afinado — más árboles, learning rate más bajo
xgb2 = XGBClassifier(
    n_estimators=1200, learning_rate=0.015, max_depth=4,
    subsample=0.85, colsample_bytree=0.85,
    min_child_weight=1, gamma=0.15,
    reg_alpha=0.1, reg_lambda=1.2,
    random_state=42, tree_method="hist"
)
xgb2.fit(X_train_processed, y_train)
xgb_pred2 = xgb2.predict(X_test_processed)

# 7. Red Neuronal — MLP con 2 capas ocultas
mlp = MLPClassifier(
    hidden_layer_sizes=(32, 16), max_iter=500, random_state=42
)
mlp.fit(X_train_processed, y_train)
mlp_pred = mlp.predict(X_test_processed)

# 8. Naive Bayes — rápido y probabilístico
nb = GaussianNB()
nb.fit(X_train_processed, y_train)
nb_pred = nb.predict(X_test_processed)

# 9. SVM — kernel RBF con margen óptimo
svm = SVC(kernel="rbf", C=1, gamma="scale", probability=True)
svm.fit(X_train_processed, y_train)
svm_pred = svm.predict(X_test_processed)

# 10. Ensemble Stacking — la combinación ganadora
base_models = [
    ("lr", LogisticRegression(max_iter=1000)),
    ("rf", RandomForestClassifier(n_estimators=300, max_depth=None, random_state=42)),
    ("xgb", XGBClassifier(
        n_estimators=600, learning_rate=0.03, max_depth=4,
        subsample=0.85, colsample_bytree=0.85,
        random_state=42, tree_method="hist"
    ))
]
ensemble = StackingClassifier(
    estimators=base_models,
    final_estimator=LogisticRegression(max_iter=1000),
    passthrough=False,
    cv=5
)
ensemble.fit(X_train_processed, y_train)
ensemble_pred = ensemble.predict(X_test_processed)

Resultados: la tabla que lo cambió todo

from sklearn.metrics import accuracy_score

models = {
    "Logistic Regression": y_pred,
    "Decision Tree": tree_pred,
    "Random Forest": rf_pred,
    "Gradient Boosting": gb_pred,
    "XGBoost": xgb_pred,
    "XGBoost (tuned)": xgb_pred2,
    "SVM": svm_pred,
    "MLP (Red Neuronal)": mlp_pred,
    "Naive Bayes": nb_pred,
    "Ensemble (Stacking)": ensemble_pred,
}

for name, pred in models.items():
    print(f"{name}: {accuracy_score(y_test, pred):.4f}")
ModeloAccuracyTipo
Ensemble (Stacking)0.8659Meta-modelo
Logistic Regression0.8547Lineal
XGBoost (tuned)0.8324Boosting
SVM0.8324Kernel
XGBoost0.8268Boosting
Random Forest0.8212Bagging
Naive Bayes0.8212Probabilístico
Gradient Boosting0.8045Boosting
MLP (Red Neuronal)0.7933Deep Learning
Decision Tree0.7877Árbol único

La red neuronal, el modelo que yo asumía como el más potente, quedó en penúltimo lugar. La Regresión Logística, que cabe en tres líneas de código, la superó por más de 6 puntos porcentuales.

¿Por qué la red neuronal no brilló?

Hay tres razones principales por las que el MLPClassifier rindió peor que modelos mucho más simples:

1. El tamaño del dataset juega en contra. Una red neuronal con arquitectura (32, 16) tiene miles de parámetros que optimizar. Con solo 712 filas de entrenamiento, el modelo no tiene suficientes ejemplos para aprender patrones generalizables. De hecho, el warning de scikit-learn lo confirma: “Maximum iterations (500) reached and the optimization hasn’t converged yet”. La red ni siquiera terminó de converger.

2. Los datos tabulares tienen patrones casi lineales. La supervivencia en el Titanic sigue reglas bastante directas: ser mujer y viajar en primera clase aumentaba drásticamente las probabilidades. La Regresión Logística captura este tipo de relaciones lineales de forma natural y eficiente. La red neuronal, diseñada para encontrar patrones complejos y no lineales, añade complejidad innecesaria donde no la hay.

3. Las redes neuronales destacan en datos no estructurados. Imágenes, texto, audio. Ahí es donde el deep learning marca la diferencia. Para datos tabulares con pocos miles de filas, los modelos basados en árboles (XGBoost, Random Forest) y los modelos lineales con buen feature engineering siguen siendo el estándar en la industria.

Lección clave: la complejidad del modelo debe ser proporcional a la complejidad del problema y al volumen de datos disponible. Una red neuronal no es una bala de plata.

¿Por qué ganó el Ensemble y la Regresión Logística?

La Regresión Logística (0.8547) fue el segundo mejor modelo individual. No es casualidad: el feature engineering que hice (Title, FarePerPerson, IsAlone) transformó variables crudas en señales con alto poder predictivo. Una regresión logística con buenas features puede ser devastadoramente efectiva.

El Stacking Ensemble (0.8659) se llevó el primer puesto por una razón concreta: combina tres modelos que capturan patrones distintos y complementarios:

  • Logistic Regression captura las relaciones lineales dominantes (Sex, Pclass)
  • Random Forest captura interacciones no lineales entre features sin overfitting grave
  • XGBoost maneja bien los outliers y las relaciones complejas con su boosting regularizado

El meta-modelo (otra Logistic Regression entrenada con validación cruzada de 5 folds) aprende cuándo fiarse más de cada modelo base. Es como tener tres expertos que ven el problema desde ángulos distintos y un cuarto experto que sabe a cuál escuchar en cada caso.

Conclusión: lo que aprendí de este experimento

Este ejercicio me enseñó tres cosas que ningún tutorial de sklearn me había dejado tan claras:

Empieza siempre por un baseline simple. Si una Regresión Logística ya te da 0.85 de accuracy, cualquier modelo más complejo tiene que justificar muy bien la complejidad adicional que introduce.

El feature engineering importa más que el algoritmo. Extraer el título del nombre, crear FamilySize y FarePerPerson tuvo más impacto en el resultado que cambiar de Random Forest a XGBoost. Dedica tiempo a entender tus datos antes de probar modelos.

Compara, mide y deja que los datos decidan. Mi intuición decía que la red neuronal ganaría. Los datos dijeron lo contrario. La única forma de saber qué funciona es entrenar, medir y comparar.

Si estás empezando en Machine Learning, el dataset Titanic es el laboratorio perfecto para interiorizar estas lecciones. No necesitas GPUs ni terabytes de datos. Solo pandas, scikit-learn y la disciplina de dejar que los números hablen.

Preguntas frecuentes

¿Por qué una Regresión Logística superó a una red neuronal en este ejercicio?

Porque el dataset Titanic tiene solo 891 filas y los patrones de supervivencia son casi lineales (mujeres y primera clase sobreviven más). Las redes neuronales brillan con grandes volúmenes de datos no estructurados (imágenes, texto, audio). Con datos tabulares pequeños, los modelos clásicos como la Regresión Logística o los ensembles de árboles suelen generalizar mejor y con menos coste computacional.

¿Cuándo conviene usar una red neuronal en lugar de modelos clásicos?

Las redes neuronales son la herramienta adecuada cuando trabajas con datos no estructurados (imágenes con CNN, texto con transformers, audio), cuando tienes cientos de miles o millones de ejemplos, o cuando las relaciones entre variables son altamente no lineales y complejas. Para datos tabulares con pocos miles de filas, XGBoost, Random Forest o incluso una Regresión Logística con buen feature engineering suelen dar mejores resultados.

¿Qué es un Stacking Classifier y por qué fue el mejor modelo?

El Stacking es una técnica de ensemble que entrena varios modelos base (en este caso Logistic Regression, Random Forest y XGBoost) y luego entrena un meta-modelo (otra Logistic Regression) que aprende a combinar las predicciones de los modelos base. La clave está en que cada modelo base captura patrones distintos: la regresión logística captura relaciones lineales, los árboles capturan interacciones no lineales y XGBoost maneja bien los outliers. El meta-modelo aprende cuándo confiar en cada uno.

¿Cómo se comparan modelos de Machine Learning correctamente?

Para una comparación justa necesitas: (1) mismo split de train/test para todos los modelos, (2) mismas features de entrada, (3) misma métrica de evaluación (accuracy, F1, AUC), (4) misma semilla aleatoria donde aplique y (5) validación cruzada si el dataset es pequeño. En este ejercicio usamos train_test_split estratificado con random_state=42 y exactamente el mismo X_train_processed para los 10 modelos.

¿Qué otros modelos podría haber probado en este dataset?

Podrías probar K-Nearest Neighbors (KNN), LightGBM (alternativa más rápida a XGBoost), CatBoost (maneja variables categóricas nativamente), VotingClassifier (combinación por voto en lugar de stacking) o incluso un modelo de AutoML como AutoGluon. También podrías afinar los hiperparámetros de la red neuronal con GridSearchCV: más capas, dropout, learning rate adaptativo o early stopping para evitar overfitting.