Northwind Telecom · T7 · 30 de los 100 puntos

Un pipeline, paso a paso

Qué es, por qué existe y las ocho líneas que resuelven el último punto de Northwind. Cada paso dice primero por qué y después el código. Nada de teoría suelta: todo lo que hay aquí abajo es la solución real del proyecto.

Lo que te piden, en una frase

No es «haz un modelo». Es esto:

def fit_predict(train, holdout) -> np.ndarray:
    ...
  • train — 6 132 filas crudas (que son 6 000 cuentas: hay repetidas), con la columna churn.
  • holdout — 7 154 filas crudas, sin churn.
  • devuelves — 7 154 números entre 0 y 1, la probabilidad de que cada cuenta se vaya, uno por fila y en el orden del archivo.

Una lista de números en el orden correcto. Eso es todo lo que se califica.

La forma del asunto

aprende
customers.csv
→
reparar
→
X, y
→
.fit(X, y)
lo que aprendió del train se guarda dentro del modelo
puntúa
holdout.csv
→
reparar igual
→
X_score
→
.predict_proba()
→
7 154 números

Las dos filas hacen lo mismo salvo en un punto: la de arriba aprende, la de abajo sólo aplica. Confundir esas dos cosas es el 90 % de los errores de esta tarea.


  1. El pipeline existe por un problema concreto, no por elegancia

    Tienes que hacerle lo mismo a dos tablas, pero aprendido de una sola. En cuanto lo haces a mano, se rompe. Mira:

    Así no

    X       = pd.get_dummies(train[features])
    X_score = pd.get_dummies(holdout[features])
    
    model.fit(X, y)
    model.predict_proba(X_score)

    region tiene cinco valores en el train — Lima, Arequipa, Cusco, Trujillo, Iquitos — y basta con que esa noche no aparezca ninguna cuenta de Trujillo. 23 columnas contra 22:

    ValueError: The feature names should match those that were passed during fit.
    Feature names seen at fit time, yet now missing:
    - region_Trujillo

    Con matrices de numpy, sin nombres de columna, el mismo fallo se ve así: X has 22 features, but HistGradientBoostingClassifier is expecting 23 features as input.

    Y lo peor no es ese error. Si por casualidad salen las mismas columnas en distinto orden, no revienta: predice con contract puesto en el lugar de region. Números plausibles, todos mal, cero avisos.

    Así sí

    prepare.fit(X)            # una vez, sobre el train
    
    prepare.transform(X)      # train
    prepare.transform(X_score) # holdout: mismas
                              # columnas, mismo orden

    Un objeto que recuerda lo que vio: qué categorías existían, en qué orden, cuál era la media. Se lo aplica al holdout aunque al holdout le falten valores o le sobren.

    Un Pipeline es exactamente eso, encadenado: una lista de pasos que se fitean juntos y se aplican juntos, para que no se puedan desincronizar.

  2. Tres verbos, y ya sabes sklearn

    VerboQué haceDónde se usa
    fitMira los datos y memoriza: los niveles de cada categoría, la media de cada columna, los cortes del árbol. Sólo en el train
    transformAplica lo memorizado. No aprende nada nuevo. Train y holdout
    predict_probaLa probabilidad de cada clase. Holdout

    Un ejemplo mínimo, para verlo con los ojos:

    from sklearn.preprocessing import OneHotEncoder
    
    enc = OneHotEncoder(handle_unknown="ignore", sparse_output=False)
    enc.fit(train[["contract"]])          # memoriza tres niveles, en este orden
    
    enc.categories_
    # [array(['Month-to-month', 'One year', 'Two year'], dtype=object)]
    
    holdout["contract"].head(3).tolist()
    # ['Two year', 'One year', 'Month-to-month']
    
    enc.transform(holdout[["contract"]])[:3]
    # array([[0., 0., 1.],      <- Two year     (la tercera columna)
    #        [0., 1., 0.],      <- One year
    #        [1., 0., 0.]])     <- Month-to-month
    La regla entera: fit sólo toca el train; el holdout sólo se transforma. Si escribiste fit dos veces, ya te equivocaste.
  3. Qué va fuera del pipeline y qué va dentro

    Esta es la pregunta que nadie responde, y es la que te traba. La respuesta es una sola frase: lo que se lee del enunciado va fuera; lo que se aprende de los datos va dentro.

    DóndeQuéPor qué
    Fuera, con pandas,
    a las dos tablas
    total_charges de texto a número; contract a sus tres niveles; -1 → NaN; el lote en céntimos dividido entre 100. Son reglas del brief. No dependen de los datos: las sabes antes de abrir el archivo.
    Dentro,
    en el Pipeline
    One-hot, imputar por la media, escalar, el modelo. Dependen de qué había en el train, así que tienen que fitearse una sola vez y viajar con el modelo.
    Sólo al train drop_duplicates. Las 6 132 filas del train son 6 000 cuentas: 132 están repetidas y contarlas dos veces le da doble voto a esa gente. Una fila del holdout, en cambio, es una respuesta que te están pidiendo.
    El holdout es sagrado. Te piden una fila por fila del archivo, en su orden. Si de-duplicas el holdout, T7 vale cero aunque el modelo sea perfecto: len(pred) != len(holdout) y no hay crédito parcial que valga.

    Por eso la referencia de Northwind tiene dos funciones donde parecía que bastaba una:

    def _repair(df):
        """Todo lo que se le hace igual al train y al holdout."""
        out = df.copy()
        out["total_charges"] = pd.to_numeric(out["total_charges"], errors="coerce")
        out["contract"] = out["contract"].astype(str).str.strip().str.lower().map(CONTRACTS)
        slipped = out["monthly_charges"] > 1000
        out.loc[slipped, "monthly_charges"] /= 100
        out["satisfaction_score"] = out["satisfaction_score"].replace(-1, np.nan)
        return out.drop(columns=["data_partition"], errors="ignore")
    
    
    def clean(df):
        """_repair + de-duplicar. Esto SÓLO se le hace al train."""
        return _repair(df).drop_duplicates(subset="customer_id", keep="first")
  4. ColumnTransformer: a cada columna lo suyo

    contract es un texto de tres valores. monthly_charges es un número. Al modelo no le puedes dar texto, y hacerle one-hot a un precio sería un disparate. ColumnTransformer es la lista de «a estas columnas, esto; a estas otras, aquello».

    CATEGORICAL = ["contract", "internet_service", "payment_method", "region"]
    NUMERIC = ["tenure_months", "monthly_charges", "total_charges", "support_tickets",
               "senior_citizen", "satisfaction_score", "avg_charge_per_month", "is_new"]
    
    prepare = ColumnTransformer([
        ("categorical", OneHotEncoder(handle_unknown="ignore", sparse_output=False), CATEGORICAL),
        ("numeric", "passthrough", NUMERIC),
    ])

    Cuatro detalles que valen puntos:

    • handle_unknown="ignore" — si el holdout trae un nivel que el train nunca vio (un SEC-90 en Chavín es el ejemplo de manual), lo codifica como ceros en vez de reventar con Found unknown categories [...] during transform. Ponlo siempre: no cuesta nada y te salva la tarea entera.
    • sparse_output=False — devuelve una matriz normal en vez de una dispersa. Menos sorpresas al depurar.
    • "passthrough" — los números pasan tal cual. Un árbol no necesita que los escales; sólo escala si el modelo es lineal o mide distancias.
    • Lo que no listes, no entra. Ahí es donde se quedan fuera customer_id (identifica la fila), data_partition (nunca varía) y retention_offer_accepted (se escribe después de que la cuenta avisó que se va). Eso es literalmente el T6.
  5. El modelo: uno, y casi siempre el mismo

    from sklearn.ensemble import HistGradientBoostingClassifier
    
    HistGradientBoostingClassifier(random_state=0)     # regresión: ...Regressor

    Tres razones prácticas, no ideológicas:

    1. Come NaN sin imputar. En Northwind los NaN son de verdad: «nunca facturado», «nunca encuestado». Rellenarlos con la media es inventar que esa cuenta pagó 64 soles y puso 3,7 en la encuesta. El árbol prefiere saber que falta.
    2. No necesita escalado, ni normalidad, ni que quites outliers.
    3. De fábrica ya está cerca del techo. Bajar el learning rate y triplicar las iteraciones movió el AUC 0,004 medido sobre ocho semillas. Los noventa minutos no se ganan ahí; se ganan limpiando y no filtrando.

    Si el enunciado pide un número continuo en vez de una probabilidad —Andes Retail, por ejemplo— es el mismo gesto con HistGradientBoostingRegressor.

  6. Las ocho líneas

    Todo lo anterior, junto. Esto es el T7 completo:

    def fit_predict(train, holdout):
        fitted = engineer(clean(train))           # reparar + de-duplicar + features
        scored = engineer(_repair(holdout))       # reparar y NADA más: ni una fila menos
    
        features = CATEGORICAL + NUMERIC
        X, y = fitted[features], fitted["churn"].astype(int)
        X_score = scored[features]
    
        model = Pipeline([
            ("prepare", ColumnTransformer([
                ("categorical", OneHotEncoder(handle_unknown="ignore", sparse_output=False),
                 CATEGORICAL),
                ("numeric", "passthrough", NUMERIC),
            ])),
            ("gbm", HistGradientBoostingClassifier(random_state=0)),
        ])
        model.fit(X, y)
        return model.predict_proba(X_score)[:, 1]

    Lo único que hace Pipeline es encadenar: fit baja por los pasos fiteando cada uno, predict_proba vuelve a bajar pero sólo transformando. Un solo objeto, imposible de desincronizar, y las mismas columnas en el mismo orden las dos veces.

  7. Validar antes de entregar

    Esto es lo que separa un 0,77 de un 0,79, y casi nadie lo hace bajo reloj: medir antes de mandar. Cuesta cuatro líneas.

    from sklearn.model_selection import train_test_split
    from sklearn.metrics import roc_auc_score
    
    X_tr, X_va, y_tr, y_va = train_test_split(X, y, test_size=0.25, stratify=y, random_state=0)
    model.fit(X_tr, y_tr)
    print(roc_auc_score(y_va, model.predict_proba(X_va)[:, 1]))
    • stratify=y — el churn es minoría; sin esto la partición puede quedar con otra proporción y el número no significa nada.
    • Qué esperar en Northwind: 0.770 pasa el baseline tonto, 0.782 es competente, 0.792 es bueno.
    • Cuando ya mediste, vuelve a entrenar con todo: model.fit(X, y) antes de predecir el holdout. La validación era para verte, no para entregarla.
    Si te sale 0,95, no celebres: es una fuga. Casi siempre es retention_offer_accepted, que sólo existe cuando el cliente ya avisó que se va. El día que el modelo corra de verdad esa columna está vacía. El corrector trata un AUC por encima de 0,94 como defecto, no como triunfo — y en una entrevista es exactamente la misma respuesta.
  8. Predecir, y tres comprobaciones de diez segundos

    pred = model.predict_proba(X_score)[:, 1]

    predict_proba devuelve dos columnas — [P(se queda), P(se va)] — y tú quieres la segunda: [:, 1]. Con predict tendrías ceros y unos, y el AUC se hunde: te piden un orden para llamar primero a los de arriba, no un sí/no.

    assert len(pred) == len(holdout)          # ni una fila de más ni de menos
    assert np.isfinite(pred).all()            # ni NaN ni inf
    assert 0 <= pred.min() and pred.max() <= 1

    Tres assert que en la prueba real te habrían salvado más veces que cualquier ajuste de hiperparámetros.

  9. Los seis errores, y qué significan de verdad

    Lo que vesLo que pasóEl arreglo
    could not convert string to float: 'Month-to-month' Le diste texto crudo al modelo. Esa columna va en CATEGORICAL, dentro del OneHotEncoder.
    X has 22 features, but ... is expecting 23 Codificaste las dos tablas por separado. Con DataFrames el mensaje es The feature names should match those that were passed during fit, y te dice cuál falta. Un solo encoder, fiteado en el train, aplicado dos veces.
    Found unknown categories [...] in column 0 during transform El holdout trae un nivel que el train no tenía. handle_unknown="ignore".
    Input X contains NaN. El modelo que elegiste no come huecos (casi todos menos HistGB). HistGB, o un SimpleImputer dentro del pipeline.
    len(pred) != len(holdout) De-duplicaste o filtraste el archivo de scoring. Al holdout sólo _repair. Nunca drop_duplicates, nunca dropna.
    AUC de 0,97 y una sonrisa Fuga: una columna que no existiría al momento de puntuar. Sácala. Pregúntate siempre «¿esto ya estaba escrito antes del desenlace?».
  10. La plantilla en blanco

    Esto es lo que conviene tener en los dedos el día de la prueba. Sirve para los cinco proyectos: cambian los nombres de las columnas y, si es regresión, el modelo y la última línea.

    import numpy as np, pandas as pd
    from sklearn.compose import ColumnTransformer
    from sklearn.ensemble import HistGradientBoostingClassifier
    from sklearn.pipeline import Pipeline
    from sklearn.preprocessing import OneHotEncoder
    
    CATEGORICAL = [...]
    NUMERIC = [...]
    
    def fit_predict(train, holdout):
        tr = prepare_rows(train, drop_dupes=True)      # reparaciones del brief
        ho = prepare_rows(holdout, drop_dupes=False)   # las mismas, sin tocar filas
    
        X, y = tr[CATEGORICAL + NUMERIC], tr[TARGET]
        X_score = ho[CATEGORICAL + NUMERIC]
    
        model = Pipeline([
            ("prepare", ColumnTransformer([
                ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), CATEGORICAL),
                ("num", "passthrough", NUMERIC),
            ])),
            ("model", HistGradientBoostingClassifier(random_state=0)),
        ])
        model.fit(X, y)
        return model.predict_proba(X_score)[:, 1]      # regresión: model.predict(X_score)

Ahora hazlo tú

Leerlo no sirve de nada hasta que lo escribes con el reloj corriendo. La pestaña «Práctica» de Northwind tiene el stub, los datos del día y el corrector.