Al final del módulo anterior dejamos planteada una pregunta: "¿es bueno de verdad mi modelo?". Responderla con rigor es toda una disciplina, y empieza por algo aparentemente sencillo: decidir con qué datos se entrena el modelo y con qué datos se evalúa. En los módulos 4 y 5 usamos train_test_split de forma instrumental, sin justificarlo; en esta lección entenderemos por qué es imprescindible separar los datos, qué papel juega cada conjunto (entrenamiento, validación y prueba), cómo estratificar cuando las clases están desbalanceadas —como el churn de MercaFresh— y, sobre todo, cómo evitar la fuga de datos, ese error silencioso que ya anticipamos en las lecciones 03-02 y 03-05 y que invalida evaluaciones enteras sin dar ningún error de ejecución.

Contenido

  1. Memorizar no es aprender: por qué no se evalúa sobre lo entrenado
  2. La división train/test con train_test_split
  3. El conjunto de validación: la división en tres
  4. Estratificación con clases desbalanceadas
  5. Fuga de datos: el enemigo silencioso
  6. Divisiones temporales: cuando el tiempo importa
  7. El mapa completo de los tres conjuntos

Memorizar no es aprender: por qué no se evalúa sobre lo entrenado

En la lección 01-01 definimos el Machine Learning como la capacidad de generalizar a partir de ejemplos: no queremos un modelo que recuerde los pedidos pasados de MercaFresh, sino uno que acierte con los pedidos que aún no han ocurrido.

La analogía clásica es el examen. Si un profesor entrega a sus alumnos las preguntas exactas del examen una semana antes, todos sacarán un 10 memorizando las respuestas. Ese 10 no mide si han aprendido la materia; mide su memoria. Para saber si de verdad saben, hay que examinarlos con preguntas que no han visto.

Con los modelos ocurre exactamente lo mismo:

  • Evaluar sobre los datos de entrenamiento mide cuánto ha memorizado el modelo. Un árbol de decisión sin límite de profundidad (lo vimos en 04-03) puede alcanzar un 100 % de accuracy en entrenamiento simplemente creando una hoja por cliente.
  • Evaluar sobre datos nunca vistos mide cuánto ha generalizado, que es lo único que importa en producción.

Un ejemplo mínimo lo demuestra:

from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score

# X, y: dataset de churn de MercaFresh construido en el módulo 3
# (features RFM: recencia, frecuencia, gasto medio, etc.)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

arbol = DecisionTreeClassifier(random_state=42)  # sin max_depth: crece libre
arbol.fit(X_train, y_train)

print("Accuracy en train:", accuracy_score(y_train, arbol.predict(X_train)))
print("Accuracy en test: ", accuracy_score(y_test, arbol.predict(X_test)))

Un resultado típico sería 1.00 en entrenamiento y 0.78 en test. El primer número es una ilusión; el segundo es la estimación honesta de cómo se comportará el modelo con el próximo cliente real. A esa brecha entre ambos números le pondremos nombre y remedio en la lección 06-05 (overfitting).

La división train/test con train_test_split

La herramienta estándar es train_test_split, que ya conoces de forma instrumental. Ahora desglosamos sus parámetros:

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,      # proporción reservada para test
    random_state=42,    # semilla: hace la división reproducible
    shuffle=True,       # baraja las filas antes de dividir (por defecto True)
)
  • test_size: los tamaños habituales son 20 %–30 % para test. Con datasets grandes (cientos de miles de filas) basta un porcentaje menor, porque en términos absolutos sigue habiendo muchos ejemplos de test.
  • random_state: la división es aleatoria; fijar la semilla garantiza que tú, tu compañero y tu "yo del futuro" obtengáis exactamente la misma partición. Sin ella, cada ejecución da resultados ligeramente distintos (esto lo explotaremos en 06-03 para motivar la validación cruzada).
  • shuffle: baraja las filas antes de cortar. Es esencial si el CSV viene ordenado (por fecha de alta, por provincia...): sin barajar, el test podría contener solo clientes recientes o de una sola región. La gran excepción es la serie temporal, que veremos más abajo: ahí barajar es precisamente el error.
Escenario test_size orientativo Comentario
Dataset pequeño (< 1.000 filas) 0.2–0.3 Cada fila cuenta; la validación cruzada (06-03) ayudará
Dataset medio (miles–decenas de miles) 0.2 El estándar de facto
Dataset muy grande (> 100.000) 0.1 o menos El 10 % ya son miles de ejemplos de test
Datos temporales corte por fecha Nunca aleatorio; ver sección de divisiones temporales

El conjunto de validación: la división en tres

Con train y test parece suficiente... hasta que empezamos a tomar decisiones mirando el test. Imagina este flujo en MercaFresh:

  1. Entrenas un árbol con max_depth=3 → accuracy en test: 0.81.
  2. Pruebas max_depth=5 → 0.84.
  3. Pruebas max_depth=8 → 0.83.
  4. Te quedas con max_depth=5 y reportas "mi modelo tiene un 84 % de accuracy".

Ese 84 % ya no es una estimación honesta: has usado el test para elegir el hiperparámetro, así que el test ha participado (indirectamente) en la construcción del modelo. Es como dejar que el alumno repita el examen diez veces y quedarse con la mejor nota: el test se ha "gastado".

La solución es dividir en tres conjuntos:

  • Entrenamiento (train): ajusta los parámetros internos del modelo (fit).
  • Validación (validation): compara alternativas — hiperparámetros, algoritmos, conjuntos de features — y elige la mejor. Se consulta muchas veces.
  • Prueba (test): se toca una sola vez, al final, para estimar el rendimiento real del modelo ya elegido.

train_test_split no tiene un modo de tres vías, pero basta encadenar dos llamadas:

# Primera división: separamos el test (20 %) y no lo tocamos más
X_temp, X_test, y_temp, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

# Segunda división: del 80 % restante, un 25 % para validación
# (0.25 x 0.8 = 0.2 → reparto final 60/20/20)
X_train, X_val, y_train, y_val = train_test_split(
    X_temp, y_temp, test_size=0.25, random_state=42, stratify=y_temp
)

print(len(X_train), len(X_val), len(X_test))  # p. ej. 6000, 2000, 2000

Un reparto habitual es 60/20/20 o 70/15/15. Adelantamos dos cosas: la validación cruzada (06-03) permite prescindir de un conjunto de validación fijo reutilizando el train de forma inteligente, y las herramientas que automatizan la búsqueda de hiperparámetros sobre validación (GridSearchCV y compañía) son el tema de 07-05.

Estratificación con clases desbalanceadas

El churn de MercaFresh ronda el 20 %: de cada 100 clientes, unos 20 se dan de baja. Con una división puramente aleatoria, por azar el test podría quedarse con un 14 % o un 26 % de churn, y las métricas calculadas sobre él dejarían de ser comparables con la realidad.

El parámetro stratify=y obliga a que cada conjunto conserve las proporciones de clase del dataset original:

import numpy as np

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

print("Churn global :", np.mean(y))        # p. ej. 0.200
print("Churn en train:", np.mean(y_train)) # ≈ 0.200
print("Churn en test :", np.mean(y_test))  # ≈ 0.200

Reglas prácticas:

  • Con clasificación, usa stratify=y casi siempre; nunca hace daño y es crítico con desbalanceo.
  • Cuanto más pequeño el dataset o más rara la clase minoritaria, más importante es estratificar.
  • En regresión no existe stratify directo (no hay clases), aunque se puede estratificar por tramos de la variable objetivo si hace falta.

Fuga de datos: el enemigo silencioso

La fuga de datos (data leakage) ocurre cuando información que no estaría disponible en el momento de predecir se cuela en el entrenamiento o en la evaluación. El modelo parece excelente en test y fracasa en producción. En 03-02 y 03-05 lo anticipamos con una regla: fit en train, transform en test. Ahora lo vemos en profundidad con tres fugas sutiles.

Fuga 1: escalar (o imputar) antes de dividir

# ❌ INCORRECTO: el escalador "ve" el test
from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_escalado = scaler.fit_transform(X)          # media y desviación de TODO el dataset
X_train, X_test, ... = train_test_split(X_escalado, y, ...)

# ✅ CORRECTO: primero dividir, luego ajustar solo con train
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2,
                                                    random_state=42, stratify=y)
scaler = StandardScaler()
X_train_esc = scaler.fit_transform(X_train)   # aprende SOLO de train
X_test_esc = scaler.transform(X_test)         # aplica lo aprendido

En el caso incorrecto, la media y la desviación usadas para escalar incorporan información de las filas de test. La fuga es pequeña con StandardScaler, pero con imputación por la media, selección de features por correlación o codificaciones basadas en el target puede ser enorme. La forma robusta de blindarse es el Pipeline del módulo 3, que aplica automáticamente fit-en-train/transform-en-test; en 06-03 veremos que dentro de la validación cruzada el Pipeline es directamente obligatorio.

Fuga 2: duplicados repartidos entre train y test

Si el dataset de MercaFresh tiene filas duplicadas (el mismo cliente exportado dos veces, o el mismo cliente con dos cuentas), el barajado puede dejar una copia en train y otra en test. El modelo "acierta" en test porque ya vio esa fila exacta en entrenamiento: memoria disfrazada de generalización.

# Antes de dividir: detectar y eliminar duplicados
print("Duplicados exactos:", df.duplicated().sum())
df = df.drop_duplicates()

# Variante más sutil: varias filas del MISMO cliente (snapshots mensuales).
# La solución es dividir POR CLIENTE, no por fila:
from sklearn.model_selection import GroupShuffleSplit

gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
train_idx, test_idx = next(gss.split(df, groups=df["cliente_id"]))

La regla general: si varias filas comparten una entidad (cliente, tienda, sesión), esa entidad debe caer entera en train o entera en test.

Fuga 3: información temporal futura

Es la más traicionera. Supón que para predecir el churn de marzo construyes la feature "gasto medio del último trimestre"... calculada con datos de abril. O que una columna como motivo_baja solo se rellena después de que el cliente se dé de baja: es una fuga perfecta, porque predice el churn con una consecuencia del churn. Señales de alarma:

  • Una feature con correlación sospechosamente alta con el target (¡un accuracy del 99 % es motivo de desconfianza, no de celebración!).
  • Columnas que en el sistema real se rellenan después del evento que quieres predecir.
  • Agregados (medias, contadores) calculados sobre ventanas que incluyen el futuro.

La pregunta de control es siempre la misma: "¿tendría este dato disponible en el momento exacto de hacer la predicción?". Si la respuesta es no, fuera.

Divisiones temporales: cuando el tiempo importa

Para la predicción de demanda de MercaFresh (¿cuántas unidades de fruta venderemos la próxima semana?) los datos tienen orden temporal, y el modelo se usará siempre así: entrenado con el pasado, prediciendo el futuro. La evaluación debe imitar ese uso:

# ❌ INCORRECTO para series temporales: mezcla pasado y futuro
train_test_split(X, y, test_size=0.2, shuffle=True)

# ✅ CORRECTO: corte cronológico
df = df.sort_values("fecha")
corte = "2025-10-01"
train = df[df["fecha"] < corte]   # p. ej. enero 2023 – septiembre 2025
test  = df[df["fecha"] >= corte]  # octubre – diciembre 2025

Si barajamos, el modelo entrena con ventas de diciembre para "predecir" las de junio anterior: en la práctica está viendo el futuro, y la métrica resultante es pura fantasía (es la fuga 3 a escala de división). Con tres conjuntos, el orden se mantiene: el más antiguo para train, el intermedio para validación y el más reciente para test. Existe además una variante de validación cruzada específica para series temporales (TimeSeriesSplit) que veremos brevemente en 06-03.

El mapa completo de los tres conjuntos

flowchart TD
    D["Dataset completo de MercaFresh<br/>(tras limpieza y sin duplicados)"] -->|"train_test_split<br/>stratify=y"| T["Entrenamiento (60%)"]
    D -->|"train_test_split<br/>stratify=y"| V["Validación (20%)"]
    D -->|"apartado desde el principio"| P["Prueba (20%)"]

    T -->|"fit()"| M["Modelo(s) candidatos"]
    V -->|"comparar y elegir<br/>hiperparámetros / algoritmo"| M
    M -->|"modelo final elegido"| F["Evaluación final"]
    P -->|"una sola vez"| F
    F --> R["Estimación honesta del<br/>rendimiento en producción"]

Tres papeles, tres reglas:

Conjunto Quién lo usa Cuántas veces Pregunta que responde
Entrenamiento fit() del modelo y de los transformadores Muchas ¿Qué patrones hay en los datos?
Validación El humano (o la búsqueda de hiperparámetros) Muchas ¿Qué alternativa elijo?
Prueba La evaluación final Una ¿Cómo funcionará en producción?

Errores Comunes y Consejos

  • Evaluar sobre train y reportar ese número. Es el error número uno del principiante. El accuracy de entrenamiento solo sirve para diagnosticar overfitting (06-05), nunca como métrica de calidad.
  • "Gastar" el test. Cada vez que miras el resultado en test y cambias algo del modelo, el test pierde valor. Decide con validación; reserva el test para el veredicto final.
  • Olvidar random_state. Sin semilla fija, no podrás reproducir tus resultados ni comparar experimentos de forma justa.
  • No estratificar con clases desbalanceadas. Con un churn del 20 % y un dataset pequeño, la proporción en test puede desviarse mucho por azar.
  • Escalar/imputar antes de dividir. La fuga clásica. Usa Pipeline y no tendrás que acordarte.
  • Barajar datos temporales. Si el modelo predecirá el futuro, evalúalo prediciendo el futuro.
  • Consejo: haz la división lo antes posible en tu flujo de trabajo, justo después de la limpieza básica, y trata X_test/y_test como si estuvieran en una caja fuerte.

Ejercicios

Ejercicio 1

Un compañero de MercaFresh te enseña este código y presume de un 97 % de accuracy. Identifica dos problemas metodológicos.

scaler = StandardScaler()
X_esc = scaler.fit_transform(X)
X_train, X_test, y_train, y_test = train_test_split(X_esc, y, test_size=0.2)
modelo.fit(X_train, y_train)
print(accuracy_score(y_train, modelo.predict(X_train)))

Ejercicio 2

Crea una división 70/15/15 (train/validación/test) estratificada para un dataset de churn X, y, con random_state=7, y verifica que la proporción de churn es similar en los tres conjuntos. Pista: la segunda llamada a train_test_split debe repartir el 30 % restante a partes iguales.

Ejercicio 3

MercaFresh quiere predecir la demanda diaria de cada producto con datos de 2023–2025. Propón (sin código) cómo dividirías los datos en train/validación/test y justifica por qué shuffle=True sería un error aquí.

Soluciones

Solución 1. (a) Fuga de datos: el StandardScaler se ajusta con todo el dataset antes de dividir, de modo que la media y la desviación incorporan información del test; lo correcto es dividir primero y hacer fit_transform solo en train y transform en test. (b) Evaluación sobre entrenamiento: el accuracy reportado se calcula con y_train y predicciones sobre X_train — mide memorización, no generalización; debería calcularse sobre el test (y además falta random_state para reproducibilidad y stratify=y si hay desbalanceo, que serían mejoras adicionales).

Solución 2.

# Paso 1: separar el 30 % que luego se repartirá entre val y test
X_train, X_resto, y_train, y_resto = train_test_split(
    X, y, test_size=0.30, random_state=7, stratify=y
)
# Paso 2: dividir ese 30 % por la mitad → 15 % y 15 %
X_val, X_test, y_val, y_test = train_test_split(
    X_resto, y_resto, test_size=0.50, random_state=7, stratify=y_resto
)

import numpy as np
for nombre, vec in [("train", y_train), ("val", y_val), ("test", y_test)]:
    print(f"{nombre}: {len(vec)} filas, churn = {np.mean(vec):.3f}")

Gracias a stratify, los tres porcentajes de churn deben ser prácticamente idénticos al global (~0.20).

Solución 3. División cronológica: por ejemplo, train con 2023 y 2024, validación con el primer semestre de 2025 y test con el segundo semestre de 2025 (los cortes exactos dependen del volumen de datos, pero el orden pasado → validación → test es innegociable). shuffle=True sería un error porque mezclaría filas de fechas futuras en el entrenamiento: el modelo aprendería, por ejemplo, de las ventas de la campaña de Navidad de 2025 para "predecir" días anteriores, una fuga de información temporal que infla las métricas y no refleja el uso real (entrenar con el pasado para predecir el futuro).

Conclusión

En esta lección hemos convertido el train_test_split mecánico de módulos anteriores en una metodología: evaluar sobre datos no vistos porque generalizar no es memorizar; añadir un conjunto de validación para elegir hiperparámetros sin contaminar el test; estratificar cuando las clases están desbalanceadas, como el churn de MercaFresh; blindarse contra las fugas de datos (escalar antes de dividir, duplicados repartidos, información futura); y respetar el orden cronológico cuando los datos son temporales. Ya sabemos con qué datos evaluar; la siguiente pregunta es con qué número: el accuracy que hemos venido usando tiene una trampa seria con clases desbalanceadas, y en la próxima lección desplegaremos el arsenal completo de métricas —matriz de confusión, precision, recall, F1, MAE, RMSE, R²— para medir exactamente lo que le importa al negocio.

Curso de Machine Learning

Módulo 1: Introducción al Machine Learning

Módulo 2: Fundamentos de Estadística y Probabilidad

Módulo 3: Preprocesamiento de Datos

Módulo 4: Algoritmos de Machine Learning Supervisado

Módulo 5: Algoritmos de Machine Learning No Supervisado

Módulo 6: Evaluación y Validación de Modelos

Módulo 7: Técnicas Avanzadas y Optimización

Módulo 8: Implementación y Despliegue de Modelos

Módulo 9: Proyectos Prácticos

Módulo 10: Recursos Adicionales

© Copyright 2026. Todos los derechos reservados