Módulo 3 · Aprendizaje supervisado
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.
| columna | unidad | contenido |
|---|---|---|
id | — | identificador de la bomba |
presion_kpa | kPa | presión de salida |
vibracion_mm_s | mm/s | velocidad de vibración |
temperatura_c | °C | temperatura del motor |
caudal_l_min | L/min | caudal bombeado |
corriente_a | A | corriente eléctrica |
ruido_db | dB | nivel de ruido |
falla | — | solo en entrenamiento: 0 o 1 |
Las reglas del concurso:
| regla | valor |
|---|---|
| métrica | F1 de la clase falla = 1 |
| archivo | CSV con las columnas id,falla, en ese orden |
| filas | una por bomba de prueba, en el orden de prueba |
| valores | falla es 0 o 1, sin celdas vacías |
| envíos | uno |
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ódigo
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.
| tabla | shape |
|---|---|
entrenamiento | (800, 8) |
prueba | (200, 7) |
La única columna de entrenamiento que no está en prueba es falla.
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.
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)
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.
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.
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}")
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:
LogisticRegressionsepara las clases con un hiperplano. La auditoría obliga a agregarSimpleImputerpara los faltantes de temperatura yStandardScalerpara las escalas distintas, ambos dentro de unPipelinepara que se ajusten solo con los folds de entrenamiento. - Árboles:
HistGradientBoostingClassifiersuma á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.
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.
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")
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.
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))
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 falla | bombas | errores |
|---|---|---|
| ≤ 0.1 | 139 | 6 |
| 0.1 a 0.3 | 13 | 2 |
| 0.3 a 0.7 | 4 | 1 |
| 0.7 a 0.9 | 9 | 3 |
| > 0.9 | 35 | 2 |
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.
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.
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.
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))
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ón | entrenado con | F1 |
|---|---|---|
| validación cruzada, media de 5 folds | 480 | 0.879 |
| holdout | 600 | 0.857 |
| prueba del organizador | 800 | 0.897 |
La prueba tiene 55 fallas.
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.
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:
- la auditoría de cuatro revisiones
- un holdout estratificado de 150 filas y cinco folds estratificados en el desarrollo
- el F1 macro de un baseline
most_frequent - dos familias con su justificación y sus cinco puntajes
- 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
- el archivo revisado con una versión de
revisar_enviopara 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
LogisticRegressionlanzaValueErrorpor valoresNaN, faltaSimpleImputerdentro delPipeline; 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: 0al leerlo, se escribió con el índice; usato_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
SimpleImputeren la familia lineal y por quéHistGradientBoostingClassifierno lo necesita? - ¿Por qué
most_frequentacierta 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
assertderevisar_envio?
Documentación oficial
- Validación cruzada en scikit-learn: holdout, K-fold,
StratifiedKFoldycross_val_score. HistGradientBoostingClassifier: parámetros y manejo de valores faltantes en los cortes.- Pipelines y estimadores compuestos:
Pipeliney preprocesamiento ajustado dentro de cada fold. make_classification: significado den_clusters_per_class,weightsyflip_y.DataFrame.to_csv: escritura de CSV, separador eindex.