Módulo 2 · Datos
Particiones y fuga de datos
En esta páginaEl problema
El problema
Una planta guarda una lectura diaria de sensores de 60 máquinas durante ocho días. Mantenimiento etiquetó cada máquina con falla = 1 si falló en los 30 días siguientes. Así, las ocho lecturas de una máquina comparten la misma etiqueta. La columna horas_reparacion registra cuánto duró la reparación posterior a una falla. Estas seis filas vienen de dos máquinas:
| maquina_id | temperatura_c | vibracion_mm_s | presion_kpa | horas_reparacion | falla |
|---|---|---|---|---|---|
| M-01 | 72.2 | 1.72 | 277 | 0.0 | 0 |
| M-01 | 71.7 | 1.51 | 270 | 0.0 | 0 |
| M-01 | 74.0 | 1.89 | 279 | 0.0 | 0 |
| M-08 | 59.6 | 3.89 | 304 | 11.8 | 1 |
| M-08 | 58.7 | 3.67 | 304 | 11.8 | 1 |
| M-08 | 56.4 | 3.94 | 310 | 11.8 | 1 |
El próximo mes llegarán máquinas que no están en esta tabla. Necesitas estimar la exactitud de un modelo simple con las lecturas de esas máquinas nuevas: la fracción de lecturas que clasifica correctamente.
El registro es sintético y se genera con semilla. Cada máquina tiene su propio nivel base de temperatura, vibración y presión, y la etiqueta depende sobre todo de ese nivel base. La llamada a pd.concat simula un error del sistema de registro: 40 lecturas se guardaron dos veces.
Mostrar códigoOcultar código
import numpy as np
import pandas as pd
rng = np.random.default_rng(3)
n_maquinas, n_lecturas = 60, 8
maquina = np.repeat(np.arange(n_maquinas), n_lecturas) # máquina de cada lectura
temperatura_base = rng.normal(60, 6, n_maquinas)
vibracion_base = rng.normal(3, 0.8, n_maquinas)
presion_base = rng.normal(300, 30, n_maquinas)
riesgo = (
(vibracion_base - 3) / 0.8
+ 0.6 * (temperatura_base - 60) / 6
+ rng.normal(0, 0.4, n_maquinas)
)
falla = (riesgo[maquina] > 0.8).astype(int) # cada lectura hereda la etiqueta de su máquina
temperatura = temperatura_base[maquina] + rng.normal(0, 1.0, maquina.size)
vibracion = vibracion_base[maquina] + rng.normal(0, 0.15, maquina.size)
presion = presion_base[maquina] + rng.normal(0, 5, maquina.size)
horas_base = rng.uniform(2, 12, n_maquinas)
horas = np.where(falla == 1, horas_base[maquina], 0.0)
registros = pd.DataFrame(
{
"maquina_id": [f"M-{i + 1:02d}" for i in maquina],
"temperatura_c": temperatura.round(1),
"vibracion_mm_s": vibracion.round(2),
"presion_kpa": presion.round(0),
"horas_reparacion": horas.round(1),
"falla": falla,
}
)
registros = pd.concat(
[registros, registros.sample(40, random_state=0)], ignore_index=True
)
La tabla tiene 520 filas y 6 columnas: 480 lecturas de 60 máquinas y 40 copias. La proporción de filas con falla es 0.233.
Particiones estratificadas
Una partición separa las filas en conjuntos con funciones distintas. Entrenamiento ajusta el modelo y cualquier paso de preprocesamiento. Validación compara decisiones: columnas, modelo o parámetros. Prueba da la estimación final y se usa una sola vez, al terminar.
train_test_split separa una tabla en dos partes. Para obtener tres, se llama dos veces: la primera llamada aparta 20 % para prueba y la segunda aparta 25 % del resto para validación. El argumento stratify recibe la columna objetivo y reparte cada clase en la misma proporción entre las partes. random_state fija el sorteo para que la partición se repita igual.
Pregunta. Con 520 filas, escribe cuántas filas tendrá cada partición y qué proporción de fallas esperas en cada una.
from sklearn.model_selection import train_test_split
temporal, prueba = train_test_split(
registros, test_size=0.2, stratify=registros["falla"], random_state=0
)
entrenamiento, validacion = train_test_split(
temporal, test_size=0.25, stratify=temporal["falla"], random_state=0
) # 25 % del 80 % restante = 20 % del total
for nombre, parte in [
("entrenamiento", entrenamiento),
("validación", validacion),
("prueba", prueba),
]:
print(f"{nombre:<13} {len(parte):>3} filas fallas {parte['falla'].mean():.3f}")
La salida esperada está oculta.
Pregunta. Sin stratify, la proporción de fallas en prueba depende de qué filas caigan ahí. Estima entre qué valores variará con cinco semillas distintas.
Ver resultado
La tabla repite la primera división, 20 % de registros para prueba, sin stratify y con random_state de 0 a 4. Cada fila da la semilla, la proporción de fallas en prueba y cuántas filas de prueba son fallas. Por ejemplo, la fila con 1, 0.192 y 20 de 104 dice que con random_state=1 la prueba recibe solo 20 fallas.
| semilla | proporción de fallas en prueba | fallas en prueba |
|---|---|---|
| 0 | 0.231 | 24 de 104 |
| 1 | 0.192 | 20 de 104 |
| 2 | 0.269 | 28 de 104 |
| 3 | 0.240 | 25 de 104 |
| 4 | 0.231 | 24 de 104 |
stratify elimina esa variación, que no depende del modelo.Preprocesamiento antes de dividir
StandardScaler resta a cada columna su media y divide entre su desviación estándar. fit calcula esas dos cifras con unas filas; transform las aplica a cualquier tabla. El modelo de esta lección mide distancias entre filas, así que las columnas necesitan escalas comparables: sin escalar, las diferencias en presion_kpa son de decenas de kPa y las de vibracion_mm_s rondan 1 mm/s, así que la presión dominaría la distancia. Tras fit, el atributo mean_ guarda las medias calculadas.
Pregunta. Un escalador se ajusta con las 520 filas y otro solo con entrenamiento. ¿Sus medias serán idénticas?
from sklearn.preprocessing import StandardScaler
columnas = ["temperatura_c", "vibracion_mm_s", "presion_kpa"]
escalador_todo = StandardScaler().fit(registros[columnas])
escalador_ent = StandardScaler().fit(entrenamiento[columnas])
print(escalador_todo.mean_.round(2))
print(escalador_ent.mean_.round(2))
La salida esperada está oculta.
Un paso que aprende más de los datos amplifica el efecto. El caso siguiente es extremo: 100 filas, 1000 columnas de ruido y un objetivo asignado al azar. Ninguna columna contiene información real sobre el objetivo.
Mostrar códigoOcultar código
rng_ruido = np.random.default_rng(0)
X_ruido = rng_ruido.normal(size=(100, 1000))
y_ruido = rng_ruido.integers(0, 2, size=100)
Con matrices, train_test_split recibe X y y por separado y devuelve cuatro partes: las dos mitades de X y después las dos de y.
X_a, X_b, y_a, y_b = train_test_split(
X_ruido, y_ruido, test_size=0.5, stratify=y_ruido, random_state=0
)
print(X_a.shape, X_b.shape)
print(y_a.mean(), y_b.mean())
La salida esperada está oculta.
Cada mitad tiene 50 filas y 1000 columnas de ruido normal. En las dos, 52 % de las etiquetas son 1.
SelectKBest conserva las k columnas más asociadas con el objetivo. KNeighborsClassifier(n_neighbors=1) es el vecino más cercano: busca la fila de entrenamiento más parecida y copia su etiqueta. score predice las filas que recibe y devuelve la exactitud.
Pregunta. La mitad de las filas entrena y la otra mitad se reserva. ¿Qué exactitud esperas en la mitad reservada si el selector se ajusta con las 100 filas? ¿Y si se ajusta solo con la mitad de entrenamiento?
from sklearn.feature_selection import SelectKBest
from sklearn.neighbors import KNeighborsClassifier
for nombre, X_ajuste, y_ajuste in [
("selector con todas las filas", X_ruido, y_ruido),
("selector solo con entrenamiento", X_a, y_a),
]:
selector = SelectKBest(k=10).fit(X_ajuste, y_ajuste) # elige columnas con estas filas
vecino = KNeighborsClassifier(n_neighbors=1)
vecino.fit(selector.transform(X_a), y_a)
print(f"{nombre}: {vecino.score(selector.transform(X_b), y_b):.2f}")
La salida esperada está oculta.
Duplicados y grupos
De vuelta a las máquinas. La función evaluar aplica la regla anterior: ajusta el escalador y el vecino más cercano solo con entrenamiento, y mide la exactitud en validación.
Pregunta. Con la partición por filas de la primera sección, ¿qué exactitud en validación esperas?
def evaluar(entrenamiento, validacion, columnas):
escalador = StandardScaler().fit(entrenamiento[columnas])
vecino = KNeighborsClassifier(n_neighbors=1)
vecino.fit(escalador.transform(entrenamiento[columnas]), entrenamiento["falla"])
return vecino.score(
escalador.transform(validacion[columnas]), validacion["falla"]
)
print(f"{evaluar(entrenamiento, validacion, columnas):.3f}")
La salida esperada está oculta.
merge sin el argumento on une por todas las columnas comunes, así que solo empareja filas idénticas. drop_duplicates quita las filas repetidas para no contar dos veces la misma copia. isin marca si cada valor aparece en otra columna.
Pregunta. ¿Cuántas filas de validación tienen una copia exacta en entrenamiento? ¿Qué fracción de las filas de validación pertenece a máquinas que también aparecen en entrenamiento?
copias = validacion.merge(entrenamiento.drop_duplicates())
maquina_vista = validacion["maquina_id"].isin(entrenamiento["maquina_id"])
print(f"Filas con copia exacta en entrenamiento: {len(copias)}")
print(f"Fracción de filas de máquinas vistas: {maquina_vista.mean():.3f}")
La salida esperada está oculta.
GroupShuffleSplit reparte grupos completos. El argumento groups indica a qué máquina pertenece cada fila, y todas las filas de una máquina quedan en la misma partición. Aquí test_size cuenta máquinas, no filas. split devuelve las posiciones de las filas de cada parte y next toma la única partición generada.
from sklearn.model_selection import GroupShuffleSplit
def dividir_por_maquina(tabla, fraccion):
divisor = GroupShuffleSplit(n_splits=1, test_size=fraccion, random_state=0)
posiciones_a, posiciones_b = next(divisor.split(tabla, groups=tabla["maquina_id"]))
return tabla.iloc[posiciones_a], tabla.iloc[posiciones_b] # posiciones, no etiquetas
Antes de dividir, drop_duplicates elimina las copias exactas de registros. Las filas de la nueva prueba ya estuvieron en la partición por filas, pero el cambio a grupos se decide por la estructura de los registros —varias lecturas por máquina y copias exactas—, no por el puntaje en esas máquinas. Como en la primera sección, dos llamadas producen las tres particiones.
Pregunta. Con 60 máquinas, escribe cuántas máquinas tendrá cada partición.
unicos = registros.drop_duplicates()
temporal_g, prueba_g = dividir_por_maquina(unicos, 0.2)
entrenamiento_g, validacion_g = dividir_por_maquina(temporal_g, 0.25)
for nombre, parte in [
("entrenamiento", entrenamiento_g),
("validación", validacion_g),
("prueba", prueba_g),
]:
print(f"{nombre:<13} {parte['maquina_id'].nunique():>2} máquinas {len(parte):>3} filas")
compartidas = set(validacion_g["maquina_id"]) & set(entrenamiento_g["maquina_id"]) # intersección
print(f"Máquinas compartidas: {len(compartidas)}")
La salida esperada está oculta.
Pregunta. Con la partición por máquina, ¿qué exactitud esperas en validación?
print(f"Exactitud en validación: {evaluar(entrenamiento_g, validacion_g, columnas):.3f}")
print(f"Filas sin falla en validación: {1 - validacion_g['falla'].mean():.3f}")
La salida esperada está oculta.
Columnas con fuga
Una columna puede contener el objetivo de forma indirecta. Pasa cuando su valor se registra después del evento que se quiere predecir. groupby("falla") separa las filas por clase y agg(["min", "max"]) resume cada grupo con esas dos funciones.
Pregunta. En entrenamiento, ¿qué valores mínimo y máximo tendrá horas_reparacion en las máquinas sin falla? ¿Y en las máquinas con falla?
print(entrenamiento_g.groupby("falla")["horas_reparacion"].agg(["min", "max"]))
La salida esperada está oculta.
Pregunta. Con la partición por máquina, ¿qué exactitud en validación esperas si agregas horas_reparacion a las tres columnas de sensores?
con_horas = columnas + ["horas_reparacion"]
print(f"Sensores: {evaluar(entrenamiento_g, validacion_g, columnas):.3f}")
print(f"Sensores y horas: {evaluar(entrenamiento_g, validacion_g, con_horas):.3f}")
print(f"Solo horas: {evaluar(entrenamiento_g, validacion_g, ['horas_reparacion']):.3f}")
La salida esperada está oculta.
Prueba y tabla pública
En muchas competencias, las predicciones sobre el conjunto de prueba se califican en dos partes. La tabla pública muestra durante la competencia el puntaje sobre una parte. La tabla privada usa la otra parte y define el resultado final.
La simulación siguiente tiene 200 filas ocultas: 100 públicas y 100 privadas. Hay 300 entregas y cada una predice al azar.
Cada entrega recibe dos exactitudes: la pública, sobre las primeras 100 filas, y la privada, sobre las otras 100. Se conserva la entrega con mejor exactitud pública, la que encabezaría la tabla pública.
Pregunta. ¿La exactitud privada de la entrega elegida será parecida a su exactitud pública?
Ver resultado
La tabla da la exactitud pública y privada de la entrega elegida y, como referencia, la exactitud privada promedio de las 300 entregas. La columna medida dice qué se midió y exactitud, la fracción de aciertos. Por ejemplo, la fila «pública de la elegida» con 0.62 dice que la entrega elegida acierta 62 de las 100 filas públicas.
| medida | exactitud |
|---|---|
| pública de la elegida | 0.62 |
| privada de la elegida | 0.46 |
| privada promedio de las 300 entregas | 0.502 |
Las decisiones de esta lección ya están tomadas: partición por máquina, sin duplicados, escalador ajustado en entrenamiento y solo las tres columnas de sensores. El modelo final se entrena con entrenamiento y validación juntos (temporal_g) y se mide una sola vez en prueba.
Pregunta. ¿La exactitud en prueba estará más cerca del valor por máquina de validación o del valor por filas?
print(f"Máquinas en prueba: {prueba_g['maquina_id'].nunique()}")
print(f"Exactitud en prueba: {evaluar(temporal_g, prueba_g, columnas):.3f}")
print(f"Filas sin falla en prueba: {1 - prueba_g['falla'].mean():.3f}")
La salida esperada está oculta.
Práctica
Traza guiada
Esta tabla tiene cuatro máquinas con distinto número de lecturas:
mini = pd.DataFrame(
{
"maquina_id": ["M-A", "M-A", "M-A", "M-B", "M-B", "M-C", "M-D", "M-D"],
"vibracion_mm_s": [2.1, 2.3, 2.2, 3.9, 4.1, 3.0, 2.8, 2.7],
"falla": [0, 0, 0, 1, 1, 0, 1, 1],
}
)
Antes de ejecutar dividir_por_maquina(mini, 0.25), predice cuántas máquinas quedarán en la segunda parte y qué números de filas son posibles. Decide también si las lecturas de M-A pueden quedar separadas. Comprueba con nunique() y len(). Resultado esperado: la segunda parte tiene una máquina, porque 0.25 de cuatro máquinas es una. Según la máquina elegida, tendría 1, 2 o 3 filas; con random_state=0 queda M-C, con una fila. Si tu predicción contaba filas, revisa qué mide test_size en GroupShuffleSplit.
Semillas de partición
Agrega un parámetro semilla a dividir_por_maquina y úsalo como random_state. Repite las dos divisiones por máquina con la misma semilla, de 0 a 4. Para cada semilla, imprime la exactitud en validación y la fracción de filas sin falla. Antes de ejecutar, predice si las cinco exactitudes quedarán a menos de 0.02 de 0.823. Resultado esperado: van de 0.823 a 1.000, y la fracción sin falla va de 0.667 a 0.917. Explica qué implica esa variación al comparar dos decisiones que difieren en 0.02 de exactitud.
Revisión de particiones
Escribe revisar_particion(entrenamiento, validacion). Debe devolver un diccionario con cuatro valores: filas de validación con copia exacta en entrenamiento, máquinas compartidas y proporción de fallas en cada parte. Aplícala a la partición por filas y a la partición por máquina. Resultado esperado: por filas, 8 copias y 50 máquinas compartidas; por máquina, 0 y 0. Agrega un assert que falle si la partición por máquina tiene copias o máquinas compartidas.
Reto opcional
Agrega a unicos dos columnas nuevas. costo_reparacion vale 450 por horas_reparacion más ruido con semilla. antiguedad_anios asigna a cada máquina un entero entre 1 y 15 sorteado con semilla. Para cada columna, decide primero si su valor se conoce el día de la lectura. Después vuelve a dividir unicos con dividir_por_maquina, porque las particiones anteriores no tienen las columnas nuevas, y compara evaluar con y sin cada columna. Explica por qué la comparación numérica sola no basta para decidir si una columna tiene fuga.
Errores que revisar
- Si la exactitud en validación es casi perfecta con un modelo simple, busca copias exactas entre particiones, grupos compartidos y columnas registradas después del evento.
- Si
fitofit_transformrecibe la tabla completa, validación o prueba, mueve el ajuste a entrenamiento y aplica solotransforma las demás partes. - Si la proporción de clases cambia mucho entre las partes de
train_test_split, revisa questratifyreciba la columna objetivo de la misma tabla que estás dividiendo. - Si una parte de
GroupShuffleSplittiene más o menos filas de las que esperabas, cuenta grupos:test_sizereparte máquinas y cada máquina aporta su propio número de filas. - Si mediste en prueba, cambiaste algo y volviste a medir, la segunda cifra ya es de validación, no una estimación final.
Cierre
Explica qué midió cada una de las cifras 1.000, 0.823 y 0.875, y cuál estima la exactitud con máquinas nuevas.
Lista de comprobación
- ¿Con qué filas se ajusta cada
fitde tu código? - ¿Alguna máquina o fila copiada aparece en más de una partición?
- ¿Cada columna existe en el momento de la predicción?
- ¿Por qué la mejor entrega en la tabla pública suele bajar en la privada?
- ¿Cuántas veces se evaluó el modelo final en prueba?
Documentación oficial
train_test_split: proporciones de partición,stratifyyrandom_state.GroupShuffleSplit: particiones aleatorias que mantienen juntos los grupos ytest_sizecomo fracción de grupos.StandardScaler:fit,transformy los atributosmean_yscale_.- Errores comunes en scikit-learn: fuga de datos por preprocesamiento y cómo evitarla.
- Puntuación en Nitro: tablas pública y privada en tareas de competencia.