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.
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.Una lista de números en el orden correcto. Eso es todo lo que se califica.
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.
Tienes que hacerle lo mismo a dos tablas, pero aprendido de una sola. En cuanto lo haces a mano, se rompe. Mira:
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.
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.
| Verbo | Qué hace | Dónde se usa |
|---|---|---|
fit | Mira los datos y memoriza: los niveles de cada categoría, la media de cada columna, los cortes del árbol. | Sólo en el train |
transform | Aplica lo memorizado. No aprende nada nuevo. | Train y holdout |
predict_proba | La 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
fit sólo toca el train; el holdout
sólo se transforma. Si escribiste fit dos veces, ya te
equivocaste.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ónde | Qué | 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. |
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")
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.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.from sklearn.ensemble import HistGradientBoostingClassifier
HistGradientBoostingClassifier(random_state=0) # regresión: ...Regressor
Tres razones prácticas, no ideológicas:
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.Si el enunciado pide un número continuo en vez de una probabilidad —Andes
Retail, por ejemplo— es el mismo gesto con HistGradientBoostingRegressor.
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.
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.0.770 pasa el baseline tonto,
0.782 es competente, 0.792 es bueno.model.fit(X, y)
antes de predecir el holdout. La validación era para verte, no para entregarla.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.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.
| Lo que ves | Lo 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?». |
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)
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.