Tu Primer Modelo de Machine Learning con Scikit-learn
Guía paso a paso para construir tu primer modelo de ML: exploración de datos, preprocesamiento, validación cruzada, ajuste de hiperparámetros y evaluación con scikit-learn.
Todos empezamos en algún punto. Este artículo te lleva por el flujo completo de construir un modelo de machine learning desde cero — sin asumir experiencia previa en ML, solo Python y ganas de experimentar.
Al terminar habrás entrenado un clasificador, lo habrás validado correctamente, ajustado sus hiperparámetros, y entendido qué significan realmente las métricas. También verás por qué el enfoque clásico tiene límites, lo que prepara el terreno para las técnicas más avanzadas en los otros artículos de este sitio.
El Dataset
Usaremos el clásico dataset Titanic: predecir si un pasajero sobrevivió en base a características como edad, sexo, clase del ticket y tarifa. Es lo suficientemente pequeño para correr localmente en segundos y lo suficientemente grande para exponer desafíos reales de preprocesamiento.
import pandas as pd
from sklearn.datasets import fetch_openml
# Cargar Titanic desde OpenML
titanic = fetch_openml("titanic", version=1, as_frame=True)
df = titanic.frame
print(df.shape) # (1309, 14)
print(df.dtypes)
Paso 1: Análisis Exploratorio
Antes de escribir una sola línea de código de modelo, entiende tus datos. Los valores faltantes, distribuciones sesgadas y el desbalance de clases afectan al modelo.
# Revisar valores faltantes
print(df.isnull().sum().sort_values(ascending=False))
# body 1188
# cabin 1014
# boat 823
# age 263
# fare 1
# Distribución del target
print(df['survived'].value_counts(normalize=True))
# 0 0.618 (no sobrevivió)
# 1 0.382 (sobrevivió)
# Resumen numérico
print(df[['age', 'fare', 'pclass']].describe())
Dos observaciones inmediatas: cabin tiene demasiados valores faltantes (mejor descartarla), y el dataset está desbalanceado (62% no sobrevivió — necesitamos la métrica correcta). Nota además que boat y body filtran el target: solo se conocen después del desastre, así que usarlas haría que el modelo se viera excelente y fuera inútil.
Paso 2: Preprocesamiento
Los datos crudos rara vez van directamente a un modelo. Necesitamos manejar valores faltantes, codificar variables categóricas y escalar las numéricas.
import numpy as np
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OrdinalEncoder
from sklearn.impute import SimpleImputer
from sklearn.compose import ColumnTransformer
# Seleccionar y definir tipos de features
NUMERIC_FEATURES = ['age', 'fare', 'sibsp', 'parch']
CATEGORICAL_FEATURES = ['sex', 'embarked', 'pclass']
# Sub-pipelines por tipo
numeric_pipeline = Pipeline([
('imputer', SimpleImputer(strategy='median')),
('scaler', StandardScaler()),
])
categorical_pipeline = Pipeline([
('imputer', SimpleImputer(strategy='most_frequent')),
('encoder', OrdinalEncoder(handle_unknown='use_encoded_value', unknown_value=-1)),
])
# Combinar en un solo transformer
preprocessor = ColumnTransformer([
('num', numeric_pipeline, NUMERIC_FEATURES),
('cat', categorical_pipeline, CATEGORICAL_FEATURES),
])
Usar Pipeline y ColumnTransformer no es solo código limpio — previene data leakage al asegurar que el imputer y el scaler solo aprenden de los datos de entrenamiento, nunca del conjunto de test.
Paso 3: División de Datos
from sklearn.model_selection import train_test_split
X = df[NUMERIC_FEATURES + CATEGORICAL_FEATURES]
y = df['survived'].astype(int)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
print(f"Train: {X_train.shape[0]} muestras")
print(f"Test: {X_test.shape[0]} muestras")
stratify=y asegura que ambos conjuntos tengan la misma proporción de clases — esencial con datasets desbalanceados.
Paso 4: Tu Primer Modelo
Un Árbol de Decisión es un buen punto de partida: es interpretable, no hace suposiciones distribucionales, y expone el sobreajuste claramente.
from sklearn.tree import DecisionTreeClassifier
# Pipeline completo: preprocesamiento + modelo
model = Pipeline([
('prep', preprocessor),
('clf', DecisionTreeClassifier(random_state=42)),
])
# Entrenar solo con datos de training
model.fit(X_train, y_train)
# Verificación rápida
train_acc = model.score(X_train, y_train)
test_acc = model.score(X_test, y_test)
print(f"Accuracy en train: {train_acc:.4f}")
print(f"Accuracy en test: {test_acc:.4f}")
# Accuracy en train: 0.9809 ← sospechosamente alto
# Accuracy en test: 0.7710 ← mucho menor → sobreajuste
La gran brecha entre train y test es la señal clásica de sobreajuste. Un árbol de decisión sin restricciones memoriza los datos de entrenamiento.
Paso 5: Validación Cruzada
Una sola división train/test es frágil — podrías haber tenido buena (o mala) suerte con esa división. La validación cruzada da una estimación más confiable de la generalización.
from sklearn.model_selection import cross_val_score, StratifiedKFold
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
cv_scores = cross_val_score(
model, X_train, y_train,
cv=cv,
scoring='f1', # Mejor que accuracy para datos desbalanceados
)
print(f"F1 por fold: {cv_scores.round(3)}")
print(f"F1 medio: {cv_scores.mean():.4f} ± {cv_scores.std():.4f}")
# F1 por fold: [0.682 0.701 0.694 0.671 0.689]
# F1 medio: 0.6874 ± 0.0106
Usamos F1 (media armónica de precisión y recall) en vez de accuracy porque la accuracy en un dataset desbalanceado es engañosa — un modelo que siempre predice "no sobrevivió" tendría 61.8%.
Paso 6: Ajuste de Hiperparámetros con GridSearchCV
Ahora restringimos el árbol para combatir el sobreajuste. max_depth es el parámetro más importante.
from sklearn.model_selection import GridSearchCV
param_grid = {
'clf__max_depth': [3, 5, 7, 10, None],
'clf__min_samples_leaf': [1, 5, 10, 20],
'clf__criterion': ['gini', 'entropy'],
}
grid_search = GridSearchCV(
estimator=model,
param_grid=param_grid,
cv=cv,
scoring='f1',
n_jobs=-1,
verbose=1,
)
grid_search.fit(X_train, y_train)
print(f"Mejores params: {grid_search.best_params_}")
print(f"Mejor CV F1: {grid_search.best_score_:.4f}")
# Mejores params: {'clf__criterion': 'entropy', 'clf__max_depth': 5, 'clf__min_samples_leaf': 10}
# Mejor CV F1: 0.7231
El grid probó 5 × 4 × 2 = 40 combinaciones, cada una evaluada con 5-fold CV — 200 ajustes de modelo en total. Con n_jobs=-1 esto corre en paralelo en todos los núcleos.
Paso 7: Evaluar en el Conjunto de Test
El conjunto de test es tu estimación final y honesta del rendimiento. Lo miras una vez, después de tomar todas las decisiones de ajuste.
from sklearn.metrics import classification_report, confusion_matrix, ConfusionMatrixDisplay
import matplotlib.pyplot as plt
best_model = grid_search.best_estimator_
y_pred = best_model.predict(X_test)
print(classification_report(y_test, y_pred, target_names=['No sobrevivió', 'Sobrevivió']))
precision recall f1-score support
No sobrevivió 0.82 0.87 0.84 161
Sobrevivió 0.76 0.68 0.72 101
accuracy 0.80 262
macro avg 0.79 0.78 0.78 262
weighted avg 0.80 0.80 0.80 262
# Matriz de confusión
cm = confusion_matrix(y_test, y_pred)
disp = ConfusionMatrixDisplay(cm, display_labels=['No sobrevivió', 'Sobrevivió'])
disp.plot(cmap='Blues')
plt.tight_layout()
plt.savefig('matriz_confusion.png', dpi=150)
El modelo identifica correctamente el 68% de los sobrevivientes reales (recall=0.68). Para un caso de uso médico querrías mayor recall, incluso a costa de precisión.
Paso 8: Importancia de Features
Entender qué features impulsan las predicciones genera confianza en el modelo y guía la ingeniería de features futura.
import pandas as pd
# Acceder al árbol dentro del pipeline
tree = best_model.named_steps['clf']
feature_names = NUMERIC_FEATURES + CATEGORICAL_FEATURES
importance_df = (
pd.DataFrame({'feature': feature_names, 'importance': tree.feature_importances_})
.sort_values('importance', ascending=False)
)
print(importance_df)
# feature importance
# 1 sex 0.3812
# 0 age 0.2134
# 5 pclass 0.1876
# 2 sibsp 0.0943
# ...
Sexo, edad y clase dominan — consistente con los relatos históricos del desastre ("mujeres y niños primero").
Hacia Dónde Ir Desde Aquí
Ahora tienes un modelo funcional y correctamente validado. Pero GridSearchCV tiene una limitación fundamental: agota una grilla predefinida, lo que no escala bien cuando crece el número de parámetros. Una grilla de 5 parámetros con 5 valores cada uno son 5⁵ = 3125 ajustes.
Un enfoque más inteligente es usar algoritmos evolutivos para explorar el espacio de hiperparámetros — la misma idea detrás de sklearn-genetic-opt. Si quieres ver cómo se compara en la práctica, el artículo ¿Todavía Usas Grid Search para Optimización de Hiperparámetros? muestra ambos métodos lado a lado en el mismo dataset.
Sobre el autor
Rodrigo Arenas es arquitecto de software e ingeniero de machine learning en Medellín, Colombia. Construye productos, plataformas y software open source para IA, Data Engineering y Machine Learning — es el creador de Ciaren, sklearn-genetic-opt y PyWorkforce.
Lectura recomendada
Rodrigo Arenas — ML Engineer & Open Source Maintainer
¿Estás trabajando en algo similar? Puedo ayudarte a llevarlo a producción.