Módulo 3 · Aprendizaje supervisado

Lección 9 de 9

Minirreto supervisado

En esta páginaEl problema

El problema

Un minirreto publica dos tablas sintéticas de bombas de agua. Cada fila es una bomba con un id y seis lecturas de sensores. entrenamiento tiene 800 bombas y la etiqueta falla, que vale 1 si la bomba falló durante el mes siguiente. prueba tiene 200 bombas sin etiqueta. El organizador guarda esas 200 etiquetas y califica el envío con ellas.

columnaunidadcontenido
id—identificador de la bomba
presion_kpakPapresión de salida
vibracion_mm_smm/svelocidad de vibración
temperatura_c°Ctemperatura del motor
caudal_l_minL/mincaudal bombeado
corriente_aAcorriente eléctrica
ruido_dbdBnivel de ruido
falla—solo en entrenamiento: 0 o 1

Las reglas del concurso:

reglavalor
métricaF1 de la clase falla = 1
archivoCSV con las columnas id,falla, en ese orden
filasuna por bomba de prueba, en el orden de prueba
valoresfalla es 0 o 1, sin celdas vacías
envíosuno

Auditoría de los datos

La celda plegada simula las dos tablas del organizador con make_classification. También guarda sus respuestas en respuestas_organizador. Ninguna decisión usa esa variable; solo sirve al final para calificar el envío, como haría el organizador.

Mostrar códigoOcultar códigoPython
Python 3 · ejemplo reproducible
import numpy as np
import pandas as pd
from sklearn.datasets import make_classification

X, y = make_classification(
    n_samples=1000,
    n_features=6,
    n_informative=4,
    n_redundant=1,
    n_clusters_per_class=2,
    weights=[0.75],
    class_sep=1.0,
    flip_y=0.02,
    random_state=3,
)
sensores = [
    "presion_kpa",
    "vibracion_mm_s",
    "temperatura_c",
    "caudal_l_min",
    "corriente_a",
    "ruido_db",
]
centro = np.array([300, 6, 60, 120, 15, 70])
escala = np.array([30, 1, 6, 20, 2, 4])
# lleva cada columna a unidades plausibles del sensor
tabla = pd.DataFrame(np.round(X * escala + centro, 1), columns=sensores)
tabla.insert(0, "id", [f"B-{i:04d}" for i in range(1, 1001)])
rng = np.random.default_rng(3)
# vacía 40 temperaturas elegidas al azar
tabla.loc[rng.choice(1000, size=40, replace=False), "temperatura_c"] = np.nan

entrenamiento = tabla.iloc[:800].assign(falla=y[:800])
prueba = tabla.iloc[800:].reset_index(drop=True)
respuestas_organizador = y[800:]

entrenamiento y prueba son tablas de pandas con las columnas descritas en el problema. sensores guarda los nombres de las seis lecturas y selecciona esas columnas en el resto de la lección. La forma de una tabla es el par (filas, columnas) que devuelve shape.

Pregunta. Con la tabla de columnas, escribe la forma de entrenamiento y la de prueba.

Ver resultado

Cada fila es una tabla del concurso y shape da su forma, el par (filas, columnas). Por ejemplo, la fila de prueba con (200, 7) dice que prueba tiene 200 bombas y 7 columnas.

tablashape
entrenamiento(800, 8)
prueba(200, 7)

La única columna de entrenamiento que no está en prueba es falla.

Lectura. entrenamiento tiene las seis lecturas, el id y falla. prueba tiene el id y las mismas seis lecturas. El modelo solo puede usar las seis columnas de sensores, porque son las únicas que existen en ambas tablas con el mismo significado. id es una etiqueta, no una medición.

La auditoría breve revisa cuatro cosas: faltantes por tabla, conteo de clases, identificadores repetidos o compartidos entre tablas y rango de cada sensor. isna().sum() cuenta los faltantes de cada columna y value_counts() cuenta cada clase. duplicated() marca los id que ya aparecieron más arriba, y & entre dos conjuntos deja los id presentes en ambas tablas. agg(["min", "max"]) calcula el mínimo y el máximo de cada sensor; .T transpone para ver un sensor por fila.

Pregunta. Para cada revisión, decide qué resultado te obligaría a cambiar el modelo o la partición. Por ejemplo, qué harías si una columna tuviera faltantes o si un id apareciera en las dos tablas.

Python 3 · ejemplo reproducible
faltantes = entrenamiento.isna().sum()
print(faltantes[faltantes > 0])
print("faltantes en prueba:", prueba.isna().sum().sum())
print(entrenamiento["falla"].value_counts().sort_index())

repetidos = entrenamiento["id"].duplicated().sum()
compartidos = len(set(entrenamiento["id"]) & set(prueba["id"]))
print("id repetidos:", repetidos, "| id compartidos:", compartidos)
print(entrenamiento[sensores].agg(["min", "max"]).T)
Salida esperada

La salida esperada está oculta.

Protocolo y baseline

Con un solo envío, la prueba no puede guiar ninguna decisión. El protocolo sale de entrenamiento: un holdout estratificado de 200 bombas que se evalúa una sola vez y un conjunto de desarrollo con las 600 restantes. En el desarrollo, StratifiedKFold de cinco folds compara candidatos con scoring="f1".

train_test_split aparta el holdout con test_size=200, y stratify=y_todo conserva la proporción de clases en las dos partes. En StratifiedKFold, shuffle=True baraja las filas antes de formar los folds y random_state fija ese barajado, así que todos los candidatos se evalúan en los mismos cinco folds.

Python 3 · ejemplo reproducible
from sklearn.model_selection import StratifiedKFold, train_test_split

X_todo = entrenamiento[sensores]
y_todo = entrenamiento["falla"]
X_dev, X_hold, y_dev, y_hold = train_test_split(
    X_todo, y_todo, test_size=200, stratify=y_todo, random_state=0
)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)

El primer candidato es un baseline trivial. most_frequent predice siempre la clase mayoritaria. stratified predice al azar con las proporciones de entrenamiento. cross_val_score entrena y evalúa el candidato en cada fold y devuelve un puntaje por fold. np.bincount cuenta cuántas etiquetas 0 y 1 hay.

Pregunta. Escribe cuántas fallas tiene el holdout. Predice el F1 medio de cada baseline.

Python 3 · ejemplo reproducible
from sklearn.dummy import DummyClassifier
from sklearn.model_selection import cross_val_score

print(X_dev.shape, np.bincount(y_dev))
print(X_hold.shape, np.bincount(y_hold))
for estrategia in ["most_frequent", "stratified"]:
    # random_state fija los aciertos al azar de "stratified"
    baseline = DummyClassifier(strategy=estrategia, random_state=0)
    f1_folds = cross_val_score(baseline, X_dev, y_dev, cv=cv, scoring="f1")
    print(f"{estrategia:13}  F1 por fold={f1_folds.round(3)}  media={f1_folds.mean():.3f}")
Salida esperada

La salida esperada está oculta.

Dos familias de modelos

Dos familias distintas prueban dos hipótesis distintas sobre la frontera entre bombas sanas y bombas que fallan.

  • Lineal: LogisticRegression separa las clases con un hiperplano. La auditoría obliga a agregar SimpleImputer para los faltantes de temperatura y StandardScaler para las escalas distintas, ambos dentro de un Pipeline para que se ajusten solo con los folds de entrenamiento.
  • Árboles: HistGradientBoostingClassifier suma árboles pequeños que cortan un sensor a la vez. Los cortes no dependen de la escala. En cada corte, el modelo aprende hacia qué rama enviar las filas con faltantes, así que no necesita imputador.

make_pipeline encadena los pasos en orden: cada fit ajusta el imputador, el escalador y el modelo solo con las filas que recibe. strategy="median" rellena cada faltante con la mediana de su columna.

Python 3 · ejemplo reproducible
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

lineal = make_pipeline(
    SimpleImputer(strategy="median"), StandardScaler(), LogisticRegression()
)
arboles = HistGradientBoostingClassifier(random_state=0)

Ambas familias usan los mismos cinco folds, así que el puntaje de cada fold es comparable entre ellas. std() mide cuánto varía el F1 de un fold a otro, y f1_arboles > f1_lineal compara las dos familias fold por fold.

Pregunta. Anota el F1 medio que esperas de cada familia. Decide cuántos folds debería ganar una familia para que su ventaja no sea ruido de la partición.

Python 3 · ejemplo reproducible
f1_lineal = cross_val_score(lineal, X_dev, y_dev, cv=cv, scoring="f1")
f1_arboles = cross_val_score(arboles, X_dev, y_dev, cv=cv, scoring="f1")

for nombre, f1_folds in [("lineal", f1_lineal), ("árboles", f1_arboles)]:
    media, desv = f1_folds.mean(), f1_folds.std()
    print(f"{nombre:8}", f1_folds.round(3), f"media={media:.3f} desv={desv:.3f}")
# sum() cuenta cada True como 1
print("folds que ganan los árboles:", int((f1_arboles > f1_lineal).sum()), "de 5")
Salida esperada

La salida esperada está oculta.

Holdout y errores

El modelo elegido se entrena con las 600 filas de desarrollo y se evalúa una sola vez en las 200 del holdout. f1_score recibe las etiquetas reales y las predichas. confusion_matrix devuelve una tabla de 2 × 2 con la clase real por fila y la clase predicha por columna.

Pregunta. Decide si el F1 del holdout caerá dentro del rango de los cinco folds de los árboles. Con 50 fallas en el holdout, predice si habrá más falsos negativos o más falsos positivos.

Python 3 · ejemplo reproducible
from sklearn.metrics import confusion_matrix, f1_score

arboles.fit(X_dev, y_dev)
pred_hold = arboles.predict(X_hold)

print(f"F1 holdout: {f1_score(y_hold, pred_hold):.3f}")
print(confusion_matrix(y_hold, pred_hold))
Salida esperada

La salida esperada está oculta.

Una matriz dice cuántos errores hay. Para decidir si se pueden corregir, hay que ver con qué probabilidad los cometió el modelo. Un error con probabilidad cercana a 0.5 cambia si se mueve el umbral. Un error con probabilidad cercana a 0 o a 1 no cambia con ningún umbral razonable.

predict_proba devuelve una columna por clase; [:, 1] toma la de falla. Así, arboles.predict_proba(X_hold)[:, 1] da la probabilidad de falla de cada bomba del holdout. El modelo predice falla cuando esa probabilidad pasa de 0.5. Las probabilidades se agrupan en cinco bandas: ≤ 0.1, 0.1 a 0.3, 0.3 a 0.7, 0.7 a 0.9 y > 0.9.

Pregunta. Decide en qué banda de probabilidad se concentran los 14 errores.

Ver resultado

Cada fila es una banda de la probabilidad de falla que el modelo dio a las 200 bombas del holdout. Cada banda incluye su límite superior. bombas cuenta las bombas de la banda y errores cuántas de ellas clasificó mal el modelo. Por ejemplo, la fila con > 0.9, 35 y 2 dice que 35 bombas recibieron más de 0.9 y que 2 de ellas eran sanas.

probabilidad de fallabombaserrores
≤ 0.11396
0.1 a 0.3132
0.3 a 0.741
0.7 a 0.993
> 0.9352
Lectura. Solo 6 de los 14 errores caen entre 0.1 y 0.9. Los otros 8 son errores seguros: 6 fallas reales con probabilidad de 0.1 o menos y 2 bombas sanas con más de 0.9. Mover el umbral solo cambiaría filas de las bandas intermedias. Los errores seguros son filas cuyos seis sensores se parecen a los de la otra clase. Parte de ellos puede venir de flip_y=0.02, que asigna una etiqueta al azar a 2 % de las filas; el resto cae donde las nubes de las dos clases se enciman.

Archivo de predicciones

Con la familia elegida, el modelo final se reentrena con las 800 filas etiquetadas, sin cambiar ningún hiperparámetro. Después predice prueba, y envio junta cada id con su predicción.

Python 3 · ejemplo reproducible
final = HistGradientBoostingClassifier(random_state=0).fit(X_todo, y_todo)
envio = pd.DataFrame({"id": prueba["id"], "falla": final.predict(prueba[sensores])})

revisar_envio convierte cada regla del concurso en un assert. columns.tolist() da los nombres de columna en orden. Si la condición es falsa, assert detiene la celda con un AssertionError que lleva el mensaje escrito después de la coma. Entre dos conjuntos, <= pregunta si el primero está contenido en el segundo.

Python 3 · ejemplo reproducible
def revisar_envio(envio, prueba):
    assert envio.columns.tolist() == ["id", "falla"], "columnas"
    assert len(envio) == len(prueba), "número de filas"
    assert envio["id"].tolist() == prueba["id"].tolist(), "orden de id"
    assert envio["falla"].notna().all(), "celdas vacías"
    assert set(envio["falla"].unique()) <= {0, 1}, "valores permitidos"
    return "formato correcto"

Sin ruta, to_csv devuelve el texto del CSV en lugar de escribir un archivo. io.StringIO envuelve ese texto para que pd.read_csv lo lea como si fuera un archivo.

Pregunta. Escribe las tres primeras líneas del CSV. Decide qué columna extra aparecería si omitieras index=False.

Python 3 · ejemplo reproducible
import io

texto = envio.to_csv(index=False)
leido = pd.read_csv(io.StringIO(texto))

print("\n".join(texto.splitlines()[:3]))
print("líneas:", len(texto.splitlines()))
print(revisar_envio(leido, prueba))
Salida esperada

La salida esperada está oculta.

El organizador califica el envío con las etiquetas que guardó.

Pregunta. Escribe el F1 que esperas en la prueba como un rango, con los números de la validación cruzada y del holdout.

Ver resultado

Cada fila es una evaluación del modelo de árboles. entrenado con da cuántas bombas etiquetadas vio el modelo evaluado y F1 es el F1 de la clase falla = 1. Por ejemplo, la fila de la prueba con 800 y 0.897 dice que el modelo final, entrenado con las 800 bombas de entrenamiento, obtuvo F1 = 0.897 con las respuestas del organizador.

evaluaciónentrenado conF1
validación cruzada, media de 5 folds4800.879
holdout6000.857
prueba del organizador8000.897

La prueba tiene 55 fallas.

Lectura. Los tres números caen dentro del rango de los cinco folds. La prueba sale algo por encima del holdout: el modelo final vio 800 filas en lugar de 600, y con 55 fallas en la prueba cada falla detectada o perdida mueve el F1 en torno a 0.01. Una diferencia de pocas centésimas entre holdout y prueba es ruido de la muestra, no una señal de que el protocolo falló.

Práctica

Traza guiada

Antes de procesar, lista todos los problemas de este envío frente a las tres primeras bombas de prueba. Predice cuál es el primer assert de revisar_envio que falla y con qué mensaje.

Python 3 · ejemplo reproducible
envio_mal = pd.DataFrame(
    {
        "id": ["B-0802", "B-0801", "B-0803"],
        "falla": [1, 0.62, 0],
    }
)

Comprueba con revisar_envio(envio_mal, prueba.iloc[:3]). Corrige el primer problema, vuelve a revisar y repite hasta obtener formato correcto.

Formato con probabilidades

Supón que el organizador cambia la métrica a ROC-AUC y pide la columna prob_falla con valores entre 0 y 1 en lugar de falla. Modifica revisar_envio y la construcción de envio. Antes de procesar, predice si el orden de las dos familias cambia con scoring="roc_auc" en la validación cruzada. Comprueba con los mismos cinco folds.

Concurso de tres clases

Genera un concurso nuevo con make_classification(n_samples=900, n_features=8, n_informative=5, n_classes=3, random_state=11). Las últimas 150 filas forman prueba; guarda sus etiquetas aparte. La métrica es F1 macro (scoring="f1_macro") y el archivo tiene las columnas id,clase. Produce:

  1. la auditoría de cuatro revisiones
  2. un holdout estratificado de 150 filas y cinco folds estratificados en el desarrollo
  3. el F1 macro de un baseline most_frequent
  4. dos familias con su justificación y sus cinco puntajes
  5. una sola evaluación en el holdout con la matriz de confusión de 3 × 3 y el par de clases que más se confunde
  6. el archivo revisado con una versión de revisar_envio para tres clases

Reto opcional

Con cross_val_predict(arboles, X_dev, y_dev, cv=cv, method="predict_proba"), obtén una probabilidad fuera de fold para cada fila de desarrollo. Elige el umbral que maximiza el F1 en esas probabilidades. Evalúa ese umbral una sola vez en el holdout y compáralo con el umbral 0.5.

Errores que revisar

  • Si LogisticRegression lanza ValueError por valores NaN, falta SimpleImputer dentro del Pipeline; imputar antes de dividir calcularía las medianas con todas las filas, incluidas las del holdout.
  • Si eliges la familia con el puntaje del holdout, ese número ya no estima el rendimiento en la prueba; la elección se hace con los folds del desarrollo.
  • Si dos familias difieren por menos que la desviación entre folds, compara fold por fold antes de declarar una ganadora.
  • Si el archivo tiene una columna Unnamed: 0 al leerlo, se escribió con el índice; usa to_csv(..., index=False).
  • Si el F1 de la prueba queda muy por debajo del rango de los folds, busca diferencias entre las dos tablas: faltantes en otra columna, otra unidad o rangos que no aparecen en entrenamiento.

Cierre

Escribe el reporte de cuatro líneas que acompañaría el envío: modelo elegido, evidencia que lo eligió, F1 esperado con un rango y un tipo de error que el modelo no puede corregir.

Lista de comprobación

  • ¿Puedes explicar por qué la familia se eligió con validación cruzada y el holdout se evaluó una sola vez?
  • ¿Qué hallazgo de la auditoría exige SimpleImputer en la familia lineal y por qué HistGradientBoostingClassifier no lo necesita?
  • ¿Por qué most_frequent acierta 75 % de las bombas y tiene F1 igual a 0?
  • ¿Distingues un error que un cambio de umbral puede corregir de uno que no?
  • ¿Qué error de envío detecta cada assert de revisar_envio?

Documentación oficial

Cargando progreso…