Módulo 9 · Problemas integrados

Lección 2 de 4

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.

columnaunidad o rangocontenido
distancia_kmkmdistancia entre la tienda y el cliente
peso_kgkgpeso del paquete
lluvia_mmmmlluvia registrada durante la entrega
trafico0 a 1índice de tráfico en la ruta
experiencia_mesesmesesexperiencia del repartidor
calificacion_tienda3 a 5calificació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:

ideacambiocosto estimado
AC de 1.0 a 0.15 min
Bclass_weight="balanced"5 min
Ccolumna nueva distancia_km * trafico15 min
Dquitar calificacion_tienda5 min
Eproductos de todos los pares de columnas20 min
FHistGradientBoostingClassifier en lugar de regresión logística60 min
Gcolumna nueva lluvia_mm * trafico15 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ódigoPython
Python 3 · ejemplo reproducible
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.

Python 3 · ejemplo reproducible
print(pedidos.head(3).to_string())
print(pedidos.shape, f"proporción tarde={tarde.mean():.3f}")
Salida esperada

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.

Python 3 · ejemplo reproducible
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.

Python 3 · ejemplo reproducible
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.

Python 3 · ejemplo reproducible
print(f"base, semilla 0: ROC-AUC={medir(BASE, 0):.4f}")
print("misma semilla, mismo resultado:", medir(BASE, 0) == medir(BASE, 0))
Salida esperada

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.

Python 3 · ejemplo reproducible
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}")
Salida esperada

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.

Python 3 · ejemplo reproducible
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}")
Salida esperada

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.

Python 3 · ejemplo reproducible
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.

Python 3 · ejemplo reproducible
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.

Python 3 · ejemplo reproducible
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))
Salida esperada

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.

Python 3 · ejemplo reproducible
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óndiferencia media
base nueva − base inicial+0.0142
suma de C y D por separado+0.0145
Lectura. La base nueva mejora a la inicial en +0.0142, 0.0003 menos que la suma de las dos ablations (+0.0145). Aquí los efectos casi se suman, pero eso se comprueba con la medición; no se supone.

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.

Python 3 · ejemplo reproducible
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.

Python 3 · ejemplo reproducible
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))
Salida esperada

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.

Python 3 · ejemplo reproducible
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))
Salida esperada

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.

Python 3 · ejemplo reproducible
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))
Salida esperada

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:

Python 3 · ejemplo reproducible
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:

  1. una función medir(config, semilla) con todo lo fijo dentro
  2. la exactitud de la base con las semillas 0 a 4 y su rango
  3. tres ablations de un factor: n_estimators=300, max_depth=5 y max_features=None
  4. el registro como DataFrame con diferencia media, mínima, máxima, victorias y veredicto
  5. dos ideas pendientes que aún no hayas medido, por ejemplo min_samples_leaf=5 y class_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 ablation lanza AssertionError, 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_media además de gana.
  • 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; fija random_state en 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

Cargando progreso…