Módulo 9 · Problemas integrados
Ablations y priorización
En esta páginaEl problema
El problema
Un concurso inventado pide predecir si un pedido de una tienda en línea llegará tarde. La tabla de entrenamiento tiene 1200 pedidos, seis columnas y la etiqueta tarde, que vale 1 si el pedido llegó tarde. La métrica es ROC-AUC. El modelo actual es una regresión logística con las seis columnas estandarizadas, y ya produce un archivo de envío válido.
| columna | unidad o rango | contenido |
|---|---|---|
distancia_km | km | distancia entre la tienda y el cliente |
peso_kg | kg | peso del paquete |
lluvia_mm | mm | lluvia registrada durante la entrega |
trafico | 0 a 1 | índice de tráfico en la ruta |
experiencia_meses | meses | experiencia del repartidor |
calificacion_tienda | 3 a 5 | calificación pública de la tienda |
tarde | — | 0 o 1 |
Quedan 90 minutos antes del cierre. La lista de ideas tiene siete cambios, cada uno con el tiempo estimado para programarlo y medirlo:
| idea | cambio | costo estimado |
|---|---|---|
| A | C de 1.0 a 0.1 | 5 min |
| B | class_weight="balanced" | 5 min |
| C | columna nueva distancia_km * trafico | 15 min |
| D | quitar calificacion_tienda | 5 min |
| E | productos de todos los pares de columnas | 20 min |
| F | HistGradientBoostingClassifier en lugar de regresión logística | 60 min |
| G | columna nueva lluvia_mm * trafico | 15 min |
Una compañera de equipo ya probó B. Obtuvo ROC-AUC 0.7599 con la semilla 1, lo comparó con el 0.7522 del modelo actual con la semilla 0 y concluyó que B mejora el modelo en 0.0077.
Protocolo de medición
Una ablation cambia una sola cosa del modelo y mide el resultado con la misma validación. Para garantizarlo, toda la medición vive en una función.
La celda plegada simula la tabla del organizador con la semilla 7. La fórmula de z es la regla con la que se generaron los retrasos. En un concurso real no la conocerías.
Mostrar códigoOcultar código
from itertools import combinations
import numpy as np
import pandas as pd
rng = np.random.default_rng(7)
n = 1200
pedidos = pd.DataFrame(
{
"distancia_km": rng.uniform(1, 30, n).round(1),
"peso_kg": rng.gamma(2, 2, n).round(1),
# 70 % de los pedidos sin lluvia
"lluvia_mm": np.where(rng.random(n) < 0.7, 0, rng.exponential(8, n)).round(1),
"trafico": rng.uniform(0, 1, n).round(2),
"experiencia_meses": rng.integers(1, 61, n),
"calificacion_tienda": rng.uniform(3, 5, n).round(1),
}
)
z = (
-2.0
+ 0.18 * pedidos["distancia_km"] * (pedidos["trafico"] - 0.3)
+ 0.05 * pedidos["lluvia_mm"]
+ 0.08 * pedidos["peso_kg"]
- 0.025 * pedidos["experiencia_meses"]
)
# tarde = 1 con probabilidad sigmoide(z)
tarde = (rng.random(n) < 1 / (1 + np.exp(-z))).astype(int)
pedidos guarda las seis columnas de la tabla, una fila por pedido. La etiqueta queda aparte, en el arreglo tarde de ceros y unos; tarde.mean() es la proporción de pedidos que llegaron tarde.
print(pedidos.head(3).to_string())
print(pedidos.shape, f"proporción tarde={tarde.mean():.3f}")
La salida esperada está oculta.
pedidos tiene 1200 filas y seis columnas; alrededor de 19 % de los pedidos llegó tarde.
La configuración de un experimento es un diccionario con cuatro claves: C, class_weight, las interacciones que se agregan como columnas y las columnas que se van a quitar. BASE describe el modelo actual. preparar construye la tabla de entrada de una configuración: drop quita columnas y cada par (a, b) agrega su producto.
BASE = {"C": 1.0, "class_weight": None, "interacciones": (), "quitar": ()}
def preparar(config):
X = pedidos.drop(columns=list(config["quitar"]))
for a, b in config["interacciones"]:
X[f"{a}*{b}"] = X[a] * X[b] # la columna nueva se llama "a*b"
return X
medir recibe una configuración y una semilla. Lo demás queda fijo dentro de la función. make_pipeline encadena StandardScaler con LogisticRegression, así la estandarización se ajusta solo con los folds de entrenamiento. StratifiedKFold con shuffle=True reparte las filas al azar en cinco folds con la misma proporción de pedidos tarde, y random_state fija ese reparto. cross_val_score con scoring="roc_auc" devuelve el ROC-AUC de cada fold y medir devuelve su promedio. max_iter=1000 deja al optimizador iteraciones de sobra para converger.
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
def medir(config, semilla):
modelo = make_pipeline(
StandardScaler(),
LogisticRegression(
C=config["C"], class_weight=config["class_weight"], max_iter=1000
),
)
folds = StratifiedKFold(n_splits=5, shuffle=True, random_state=semilla)
auc = cross_val_score(modelo, preparar(config), tarde, cv=folds, scoring="roc_auc")
return float(auc.mean())
Pregunta. Decide si dos llamadas a medir(BASE, 0) devuelven exactamente el mismo número.
print(f"base, semilla 0: ROC-AUC={medir(BASE, 0):.4f}")
print("misma semilla, mismo resultado:", medir(BASE, 0) == medir(BASE, 0))
La salida esperada está oculta.
Ruido entre semillas
Cambiar la semilla cambia solo la partición en folds. El modelo y los datos son los mismos. Cualquier variación del ROC-AUC entre semillas es, entonces, ruido de la medición, y sirve como referencia: un cambio de ese tamaño puede aparecer sin tocar el modelo.
auc_base es una pd.Series con el ROC-AUC de la base para cada semilla, indexada por la semilla.
Pregunta. Decide si el ROC-AUC de la base se mueve entre semillas en la tercera o en la cuarta cifra decimal.
SEMILLAS = [0, 1, 2, 3, 4]
auc_base = pd.Series([medir(BASE, s) for s in SEMILLAS], index=SEMILLAS)
print(auc_base.round(4).to_string())
print(f"rango entre semillas: {auc_base.max() - auc_base.min():.4f}")
La salida esperada está oculta.
Ahora B se mide con la semilla 1 y se compara con la base en las semillas 0 y 1. {**BASE, "class_weight": "balanced"} copia BASE y reemplaza solo class_weight: es la idea B.
balanceado = {**BASE, "class_weight": "balanced"}
auc_b1 = medir(balanceado, 1)
print(f"B semilla 1 - base semilla 0: {auc_b1 - auc_base[0]:+.4f}")
print(f"B semilla 1 - base semilla 1: {auc_b1 - auc_base[1]:+.4f}")
La salida esperada está oculta.
Ablations de un factor
El veredicto de un experimento usa el signo de las cinco diferencias variante − base, una por semilla: «mejora» si la variante gana en las cinco, «empeora» si pierde en las cinco y «ruido» en cualquier otro caso. Si un cambio no tuviera efecto y cada signo se comportara como una moneda, los cinco signos coincidirían 2 de cada 32 veces. (dif > 0).all() es True solo si todas las diferencias son positivas.
def veredicto(dif):
if (dif > 0).all():
return "mejora"
if (dif < 0).all():
return "empeora"
return "ruido"
ablation compara una variante contra su base con las cinco semillas. Para cada semilla calcula la diferencia variante − base con los mismos folds en las dos. **cambio recoge en un diccionario los argumentos con nombre, como C=0.1, y el assert impide cambiar dos claves a la vez. bases guarda las configuraciones contra las que se compara; sobre elige una por su nombre. El resultado es un diccionario con la diferencia media, mínima y máxima, las victorias y el veredicto.
bases = {"inicial": BASE}
def ablation(idea, sobre, **cambio):
assert len(cambio) == 1, "una ablation cambia una sola cosa"
base = bases[sobre]
variante = {**base, **cambio} # la clave de cambio reemplaza a la de base
dif = pd.Series([medir(variante, s) - medir(base, s) for s in SEMILLAS])
return {
"idea": idea,
"sobre": sobre,
"dif_media": dif.mean(),
"dif_min": dif.min(),
"dif_max": dif.max(),
"gana": f"{(dif > 0).sum()}/5",
"veredicto": veredicto(dif),
}
par_c es el par de columnas de la idea C. Cada resultado se agrega a la lista registro. pd.DataFrame(registro) convierte esa lista de diccionarios en la tabla de experimentos, con una columna por clave.
Pregunta. Escribe el veredicto que esperas para A, B, C y D. Para A, decide si ganar en la mayoría de las semillas bastaría para adoptarla.
par_c = ("distancia_km", "trafico")
registro = [
ablation("A: C=0.1", "inicial", C=0.1),
ablation("B: balanced", "inicial", class_weight="balanced"),
ablation("C: distancia*trafico", "inicial", interacciones=(par_c,)),
ablation("D: sin calificacion", "inicial", quitar=("calificacion_tienda",)),
]
print(pd.DataFrame(registro).round(4).to_string(index=False))
La salida esperada está oculta.
D también gana en las cinco, pero solo por +0.0010: es consistente y pequeña. A gana en cuatro semillas y su diferencia media redondea a 0.0000. Contar victorias no mide el tamaño del efecto. B gana en una semilla con +0.0015 y pierde en las otras cuatro. Si esa semilla hubiera sido la única medición, B habría parecido una mejora.
Priorización por costo
C y D se combinan en una base nueva. Una combinación es otra configuración, así que se mide de nuevo con las mismas semillas, igual que la base inicial en auc_base. bases["nueva"] es la configuración con C y D.
bases["nueva"] = {**BASE, "interacciones": (par_c,), "quitar": ("calificacion_tienda",)}
Pregunta. Decide si la base nueva gana más o menos que la suma de las ganancias de C y D medidas por separado.
Ver resultado
La tabla compara dos diferencias medias de ROC-AUC sobre las semillas 0 a 4. La columna «comparación» dice qué se calcula y la columna «diferencia media» da el resultado. La primera fila mide la base nueva contra la inicial con las mismas semillas. La segunda suma los valores de dif_media de las filas C y D del registro. Por ejemplo, la fila de la suma con +0.0145 dice que C ganó 0.0135 y D 0.0010 cuando se midieron por separado.
| comparación | diferencia media |
|---|---|
| base nueva − base inicial | +0.0142 |
| suma de C y D por separado | +0.0145 |
A–D costaron 30 minutos y quedan 60. Para las ideas pendientes, la ganancia esperada es una estimación escrita antes de medir:
- E: los productos de todos los pares incluyen el de C, que ya está en la base, y agregan nueve sin una razón del problema. Estimación: 0.002.
- F: un modelo de árboles puede captar interacciones y curvas que nadie especificó. Estimación: 0.010.
- G: tiene la forma de C, pero la relación entre lluvia y tráfico es menos directa. Estimación: la mitad de lo que ganó C, 0.007.
La prioridad es la ganancia esperada por hora de trabajo. pendientes guarda el costo y la ganancia esperada de E, F y G. restante guarda los 60 minutos que quedan.
restante = 90 - (5 + 5 + 15 + 5)
pendientes = pd.DataFrame(
{
"idea": ["E: todos los pares", "F: HistGradientBoosting", "G: lluvia*trafico"],
"costo_min": [20, 60, 15],
"ganancia_esperada": [0.002, 0.010, 0.007],
}
)
sort_values con ascending=False ordena pendientes de mayor a menor ganancia por hora.
Pregunta. Ordena E, F y G por ganancia por hora.
print("minutos restantes:", restante)
# ganancia por minuto × 60 minutos
pendientes["por_hora"] = pendientes["ganancia_esperada"] / pendientes["costo_min"] * 60
pendientes = pendientes.sort_values("por_hora", ascending=False)
print(pendientes.round(3).to_string(index=False))
La salida esperada está oculta.
Registro de experimentos
G se mide contra la base nueva, no contra la inicial. La pregunta es si agrega algo a lo que ya se aceptó. La fila entra al mismo registro; la columna sobre indica contra qué base se comparó. con_lluvia repite el par de C y agrega el de G, porque el cambio reemplaza la tupla interacciones completa.
Pregunta. Escribe el veredicto que esperas para G y decide si su diferencia media quedará cerca de la estimación de 0.007.
con_lluvia = (par_c, ("lluvia_mm", "trafico"))
registro.append(ablation("G: lluvia*trafico", "nueva", interacciones=con_lluvia))
print(pd.DataFrame(registro).round(4).to_string(index=False))
La salida esperada está oculta.
G costó 15 minutos. Se descuentan de restante, G sale de pendientes y quedan solo las ideas que caben en el tiempo restante.
Pregunta. Decide qué idea sigue después de G.
restante -= 15
pendientes = pendientes[pendientes["idea"] != "G: lluvia*trafico"]
print("minutos restantes:", restante)
print(pendientes[pendientes["costo_min"] <= restante].round(3).to_string(index=False))
La salida esperada está oculta.
Práctica
Traza guiada
Estas son las diferencias variante − base de tres ideas, una por semilla de 0 a 4:
difs = pd.DataFrame(
{
"H1": [0.004, 0.006, 0.003, 0.005, 0.004],
"H2": [0.009, -0.002, 0.001, -0.003, 0.000],
"H3": [-0.001, -0.002, -0.0005, -0.001, -0.003],
}
)
Antes de procesar, escribe para cada idea la diferencia media, cuántas semillas gana y el veredicto con la regla de ablation. Decide cuál habría parecido la mejor si solo se hubiera medido la semilla 0. Comprueba con difs.mean() y (difs > 0).sum().
Ablation de todos los pares
Mide E sobre la base nueva. Construye los pares con combinations sobre las cinco columnas de preparar(bases["nueva"]) sin productos: distancia_km, peso_kg, lluvia_mm, trafico y experiencia_meses. Pasa esos pares como el único cambio de ablation y agrega la fila al registro. Antes de procesar, predice el veredicto y compara la diferencia media con la estimación de 0.002. Actualiza restante y decide si sobra tiempo para otra idea de la lista.
Registro de un problema nuevo
Genera X, y = make_classification(n_samples=800, n_features=8, n_informative=4, random_state=5). El modelo base es RandomForestClassifier(n_estimators=100, random_state=semilla) y la métrica es exactitud con cinco folds estratificados. La semilla fija los folds y el azar del bosque. Produce:
- una función
medir(config, semilla)con todo lo fijo dentro - la exactitud de la base con las semillas 0 a 4 y su rango
- tres ablations de un factor:
n_estimators=300,max_depth=5ymax_features=None - el registro como
DataFramecon diferencia media, mínima, máxima, victorias y veredicto - dos ideas pendientes que aún no hayas medido, por ejemplo
min_samples_leaf=5yclass_weight="balanced", cada una con una ganancia esperada escrita antes de medir y un costo estimado en minutos, y la siguiente idea elegida por ganancia por hora
Reto opcional
Mide F sobre la base nueva. Agrega a la configuración una clave modelo y haz que medir construya HistGradientBoostingClassifier(random_state=0) cuando modelo lo indique, sin cambiar los folds ni la métrica. Compara la diferencia media con la estimación de 0.010 y decide si los 60 minutos habrían valido la pena.
Errores que revisar
- Si comparas dos configuraciones medidas con semillas distintas, la diferencia incluye el cambio de partición; repite ambas con las mismas semillas.
- Si
ablationlanzaAssertionError, la variante cambia dos claves a la vez y la diferencia no se podría atribuir a ninguna; separa los cambios en dos ablations. - Si una idea gana en casi todas las semillas pero su diferencia media redondea a cero, no hay un efecto que valga la pena adoptar; revisa
dif_mediaademás degana. - Si adoptas dos mejoras y no mides la combinación, no sabes si sus efectos se suman; mide la base nueva con las mismas semillas.
- Si
medir(config, 0)da dos resultados distintos en dos llamadas, algo usa azar sin semilla; fijarandom_stateen los folds y en el modelo.
Cierre
Escribe la fila de registro y la decisión que siguen después de medir E: la idea, la base contra la que se midió, el veredicto, los minutos restantes y la configuración que usarías para el envío final.
Lista de comprobación
- ¿Por qué la diferencia de +0.0077 de la compañera no es evidencia de que B mejore el modelo?
- ¿Qué parte del ruido se cancela cuando dos configuraciones usan las mismas semillas?
- ¿Por qué A gana en cuatro de cinco semillas y aun así no se adopta?
- ¿Por qué F, con la ganancia esperada más alta, no fue la primera idea medida?
- ¿Por qué G se compara contra la base nueva y no contra la inicial?
Documentación oficial
- Validación cruzada en scikit-learn: K-fold estratificado,
cross_val_scorey comparación de modelos con los mismos folds. StratifiedKFold: parámetrosshuffleyrandom_state.- Errores comunes y prácticas recomendadas: control del azar con
random_stateen particiones y estimadores. LogisticRegression: significado deCyclass_weight.DataFrame.sort_values: ordenar una tabla por una o varias columnas.