El modelo hasta aquí vive en un cuaderno. Si cierras el cuaderno, se muere 💀
Este capítulo es sacarlo de ahí: guardarlo, validarlo como si el futuro fuera futuro, y montarle una vigilancia que avise cuando deje de servir.
Te adelanto el final, porque es lo más útil que aprendí escribiendo esto: busqué deriva en 18 meses de datos y no hay. Lo que sí hay son alarmas que suenan solas. Así que la mitad del capítulo es aprender a no asustarse.
El punto de partida, ahora partiendo por fecha
Todo lo anterior partía al azar. Eso está bien para comparar modelos, pero miente sobre producción: en producción el modelo siempre predice hacia adelante, con datos de meses que no vio.
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'
def carga_limpia(url):
v = pd.read_csv(url).drop_duplicates()
v['ciudad'] = (v['ciudad'].str.strip().str.lower()
.str.normalize('NFKD')
.str.encode('ascii', 'ignore').str.decode('utf-8'))
v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))
for col in ['fecha', 'fecha_ultima_compra']:
f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce')
falta = f.isna() & v[col].notna()
f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y', errors='coerce')
v[col] = f
return v
def prepara(v):
v = v.sort_values(['cliente_id', 'fecha']).copy()
v['sin_compra_previa'] = v['fecha_ultima_compra'].isna().astype(int)
v['sin_descuento'] = v['descuento'].isna().astype(int)
v['sin_satisfaccion'] = v['satisfaccion'].isna().astype(int)
v['precio_unitario'] = v['monto'] / v['unidades']
v['visita_numero'] = v.groupby('cliente_id').cumcount() + 1
return v
NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion', 'precio_unitario',
'sin_compra_previa', 'sin_descuento', 'sin_satisfaccion', 'visita_numero']
CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria']
COLS = NUMERICAS + CATEGORICAS
def arma_modelo():
return Pipeline([
('pre', ColumnTransformer([
('num', Pipeline([('r', SimpleImputer(strategy='median')),
('e', StandardScaler())]), NUMERICAS),
('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),
('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),
])),
('mod', LogisticRegression(max_iter=1000, random_state=42)),
])
datos = prepara(carga_limpia(URL))
print('desde', datos['fecha'].min().date(), 'hasta', datos['fecha'].max().date())
CORTE = pd.Timestamp('2026-01-01')
pasado = datos[datos['fecha'] < CORTE]
futuro = datos[datos['fecha'] >= CORTE]
print('entreno con', len(pasado), 'filas y predigo', len(futuro))
desde 2025-01-01 hasta 2026-06-24 entreno con 2008 filas y predigo 992
Dieciocho meses de datos: doce para entrenar y seis para hacer de futuro. Ese corte es el más parecido a la vida real que puedes montar sin esperar seis meses.
El susto que no fue
modelo = arma_modelo().fit(pasado[COLS], pasado['compro'])
p_futuro = modelo.predict_proba(futuro[COLS])[:, 1]
X_tr, X_te, y_tr, y_te = train_test_split(datos[COLS], datos['compro'],
test_size=0.25, random_state=42,
stratify=datos['compro'])
azar = arma_modelo().fit(X_tr, y_tr)
print('partiendo por fecha:', round(roc_auc_score(futuro['compro'], p_futuro), 4))
print('partiendo al azar :', round(roc_auc_score(y_te, azar.predict_proba(X_te)[:, 1]), 4))
partiendo por fecha: 0.7227 partiendo al azar : 0.7214
0,7227 contra 0,7214. Trece diezmilésimas, y encima a favor del futuro 😄
Esto quiere decir dos cosas buenas. Que no hay fuga temporal escondida (si la hubiera, la partición al azar saldría mucho mejor que la temporal). Y que en estos 18 meses el mundo no se movió: lo que el modelo aprendió del 2025 sirve igual en el 2026.
Que no siempre pasa. Pero cuando pasa, hay que decirlo y no inventar un problema para tener algo que arreglar.
Guardarlo
Un modelo entrenado se guarda entero, con el preprocesamiento dentro. Por eso
insistí tanto en el Pipeline del capítulo 11: lo que se guarda es una
sola cosa 📦
import joblib
import os
import tempfile
RUTA = os.path.join(tempfile.gettempdir(), 'modelo-miss-yera.joblib')
joblib.dump(modelo, RUTA)
print('pesa', round(os.path.getsize(RUTA) / 1024, 1), 'KB')
cargado = joblib.load(RUTA)
print('predice igual:', np.allclose(cargado.predict_proba(futuro[COLS])[:, 1], p_futuro))
pesa 5.5 KB predice igual: True
Cinco kilobytes y medio. Un modelo lineal es una lista de números y cabe en un correo.
Lo guardo en la carpeta temporal para no ensuciarte el proyecto mientras pruebas. En serio va a un almacenamiento con versiones, porque vas a querer volver al modelo del mes pasado el día que el nuevo salga peor.
Y guarda al lado, en un archivo de texto, tres cosas: con qué fechas entrenaste, qué AUC sacó y la lista de columnas. Sin eso, dentro de seis meses tienes un archivo binario y ninguna forma de saber si sirve 📝
Vigilar cuando todavía no sabes si acertaste
Aquí está el problema de verdad de producción. El modelo predice hoy quién va a comprar y la respuesta llega en semanas. Mientras tanto no puedes calcular ninguna métrica, porque no tienes con qué compararte.
Lo que sí puedes mirar es si lo que entra se parece a lo que entraba antes. Eso se mide con el PSI, que compara dos repartos:
def psi(antes, ahora, cajones=10):
cortes = np.unique(np.quantile(antes, np.linspace(0, 1, cajones + 1)))
a = np.histogram(antes, bins=cortes)[0] / len(antes)
b = np.histogram(ahora, bins=cortes)[0] / len(ahora)
a = np.clip(a, 1e-4, None)
b = np.clip(b, 1e-4, None)
return float(((b - a) * np.log(b / a)).sum())
for col in ['monto', 'satisfaccion', 'unidades', 'precio_unitario']:
print(f'{col:16} {psi(pasado[col].dropna(), futuro[col].dropna()):.4f}')
p_pasado = modelo.predict_proba(pasado[COLS])[:, 1]
print(f'{"la probabilidad":16} {psi(p_pasado, p_futuro):.4f}')
monto 0.0277 satisfaccion 0.0006 unidades 0.0045 precio_unitario 0.0065 la probabilidad 0.0150
La convención que usa medio mundo es: por debajo de 0,1 no pasa nada, entre 0,1 y 0,25 mira qué pasó, por encima de 0,25 algo se rompió. Es una convención, no una ley de la física, pero funciona.
Todo está por debajo de 0,03. Ninguna columna se movió y la probabilidad que sale del modelo tampoco 🟢
La alarma mensual y por qué te va a volver loca
Lo primero que pide todo el mundo es un tablero con el AUC del mes. Vamos a verlo, y luego vamos a ver por qué es mala idea sin un acompañante.
f = futuro.copy()
f['prob'] = p_futuro
f['mes'] = f['fecha'].dt.to_period('M')
for mes, g in f.groupby('mes'):
print(f'{mes} n={len(g):3d} AUC={roc_auc_score(g["compro"], g["prob"]):.4f}')
2026-01 n=176 AUC=0.7585 2026-02 n=149 AUC=0.8029 2026-03 n=192 AUC=0.6800 2026-04 n=171 AUC=0.7296 2026-05 n=182 AUC=0.7212 2026-06 n=122 AUC=0.6231
Marzo 0,6800 y junio 0,6231. Si tuvieras una alarma en 0,65, en junio te levantas con un correo rojo y una reunión.
Antes de correr, la pregunta correcta: ¿cuánto baila este número cuando no pasa absolutamente nada? Se responde sacando muestras del mismo periodo, sin cambiar nada:
rng = np.random.default_rng(0)
muestras = []
for _ in range(200):
idx = rng.choice(len(f), 150, replace=False)
muestras.append(roc_auc_score(f['compro'].values[idx], p_futuro[idx]))
muestras = np.array(muestras)
print('con 150 filas y sin que cambie nada, el AUC cae entre:')
print(' ', round(np.quantile(muestras, 0.05), 4), 'y', round(np.quantile(muestras, 0.95), 4))
con 150 filas y sin que cambie nada, el AUC cae entre: 0.6501 y 0.7845
Ahí está. Con 150 filas, el AUC se mueve entre 0,6501 y 0,7845 el 90% de las veces sin que nada haya cambiado. Los meses malos y los meses buenos que viste arriba caben enteros dentro de ese rango.
O sea que la alarma en 0,65 no detecta problemas: detecta meses. Y te va a sonar una de cada veinte veces para nada 🔔
La regla que uso: ninguna alarma sin haber medido antes cuánto baila sola. Y si el mes trae 150 filas, se vigila el trimestre.
Romperlo a propósito
Como los datos reales no traen deriva, la fabrico yo. Tres averías que pasan de verdad, y vamos a ver cuál se nota y cuál no.
Avería 1: se abre una ciudad nueva
c1 = futuro[COLS].copy()
c1.iloc[:300, c1.columns.get_loc('ciudad')] = 'tacna'
p1 = modelo.predict_proba(c1)[:, 1]
print('AUC :', round(roc_auc_score(futuro['compro'], p1), 4))
print('PSI de prob:', round(psi(p_pasado, p1), 4))
print('cuánto se movió la predicción de esas 300:',
round(np.abs(p1[:300] - p_futuro[:300]).mean(), 4))
AUC : 0.7239 PSI de prob: 0.0176 cuánto se movió la predicción de esas 300: 0.0108
No pasa nada. Y eso es lo malo 😨
El handle_unknown='ignore' del capítulo 11, que pusimos para que no
reventara, hace exactamente lo que le pedimos: ante una ciudad que nunca vio, pone
ceros en todas las casillas de ciudad y sigue como si nada. La predicción se mueve
una centésima, el AUC no se entera y el PSI tampoco.
Trescientas filas atendidas por un modelo que no está usando su ciudad, en silencio absoluto. Ninguna alarma estadística te salva de esto.
Avería 2: suben los precios un 30%
c2 = futuro[COLS].copy()
c2['monto'] = c2['monto'] * 1.30
c2['precio_unitario'] = c2['monto'] / c2['unidades']
p2 = modelo.predict_proba(c2)[:, 1]
print('AUC :', round(roc_auc_score(futuro['compro'], p2), 4))
print('PSI de monto:', round(psi(pasado['monto'].dropna(), c2['monto']), 4))
print('PSI de prob :', round(psi(p_pasado, p2), 4))
AUC : 0.7229 PSI de monto: 0.0904 PSI de prob : 0.0116
Aquí pasa lo contrario y también es interesante. El PSI del monto se dispara a 0,0904, casi tocando la alarma, y al modelo le da igual: el AUC se mueve dos diezmilésimas.
La explicación está en el capítulo 19. El monto tenía una importancia de 0,0067, o sea casi ninguna. Vigilar todas las columnas por igual te llena de alarmas sobre cosas que al modelo no le importan 🤷♀️
Avería 3: el sistema de origen cambia y manda el segmento en mayúsculas
c3 = futuro[COLS].copy()
c3['segmento'] = c3['segmento'].str.upper()
p3 = modelo.predict_proba(c3)[:, 1]
print('AUC :', round(roc_auc_score(futuro['compro'], p3), 4))
print('PSI de prob:', round(psi(p_pasado, p3), 4))
AUC : 0.6861 PSI de prob: 0.2187
Esta sí. El AUC cae de 0,7227 a 0,6861 y el PSI de la probabilidad salta a 0,2187, que ya es alarma clara.
Y tiene todo el sentido: el segmento era la columna más importante del modelo, y
'MAYORISTA' no es 'Mayorista' para un
OneHotEncoder. Todas las filas se quedaron sin segmento a la vez.
Fíjate en la moraleja que sale de las tres juntas: el PSI de la probabilidad predicha se enteró justo de la avería que importaba y se calló en las dos que no. Si vas a vigilar un solo número, vigila ese 🎯
Lo que de verdad te salva: revisar el contrato
Ninguna estadística iba a avisarte de la ciudad nueva. Lo que avisa es una comprobación tonta, de las que se escriben en diez minutos y no se escriben nunca:
def revisa(nuevo, entreno, categoricas):
problemas = []
faltan = set(entreno.columns) - set(nuevo.columns)
if faltan:
problemas.append(f'faltan columnas: {sorted(faltan)}')
for col in categoricas:
if col in nuevo.columns:
nuevas = set(nuevo[col].dropna().unique()) - set(entreno[col].dropna().unique())
if nuevas:
problemas.append(f'{col}: valores nunca vistos {sorted(nuevas)}')
for col in entreno.columns:
if col in nuevo.columns and entreno[col].dtype != nuevo[col].dtype:
problemas.append(f'{col}: llega como {nuevo[col].dtype} '
f'y se entrenó con {entreno[col].dtype}')
return problemas
entreno = pasado[COLS]
print('normal :', revisa(futuro[COLS], entreno, CATEGORICAS))
print('ciudad nueva:', revisa(c1, entreno, CATEGORICAS))
print('mayúsculas :', revisa(c3, entreno, CATEGORICAS))
normal : [] ciudad nueva: ["ciudad: valores nunca vistos ['tacna']"] mayúsculas : ["segmento: valores nunca vistos ['BODEGA', 'HORECA', 'MAYORISTA', 'MINIMARKET']"]
Las dos averías silenciosas, cazadas por nombre y con el valor exacto que las causó. Eso no lo hace ningún PSI.
Esta función es el contrato del capítulo 2 convertido en código y puesto a la entrada del modelo. Si devuelve algo, no se predice: se avisa 🚦
Las que sí revientan
Hay averías que dan error, y son las buenas, porque te enteras el mismo día.
modelo.predict_proba(futuro[COLS].drop(columns=['satisfaccion']))
ValueError: columns are missing: {'satisfaccion'}
Clarísimo. Falta una columna y lo dice por su nombre.
Y la que más me ha pasado en la vida real, que es que el monto llegue como texto con coma decimal, tal como viene en el CSV original antes de limpiarlo:
c4 = futuro[COLS].copy()
c4['monto'] = c4['monto'].map(lambda v: str(v).replace('.', ','))
modelo.predict_proba(c4)
ValueError: Cannot use median strategy with non-numeric data: could not convert string to float: '480,52'
El error viene de dentro del SimpleImputer y menciona el valor
exacto que lo rompió, '480,52'. Con eso, en dos minutos sabes que
alguien cambió el formato de exportación 🔧
Y una buena noticia, para terminar con las averías: el orden de las columnas
no importa. El ColumnTransformer las busca por
nombre, así que un sistema que las mande barajadas no rompe nada.
Cuándo reentrenar
La respuesta honesta con estos datos es: no por calendario.
Reentrenar cada mes porque sí gasta tiempo y mete un riesgo nuevo cada vez, y aquí ya vimos que doce meses de antigüedad no le hicieron nada al modelo. Lo que yo dejaría montado es esto, por orden de importancia:
- 🚦 El
revisaen cada tanda de predicciones. Si habla, se para. - 📈 El PSI de la probabilidad predicha, semanal. Alarma en 0,1.
- 📅 El AUC del trimestre, no del mes, y comparado contra el rango de bailoteo que mediste, no contra un número redondo.
- 🔁 Reentrenar cuando alguna de esas tres hable, cuando entre una ciudad o un segmento nuevo, o cuando el negocio cambie algo que tú sepas (una promoción grande, un canal nuevo).
Ejercicios
1. Mover el corte y ver si aguanta
Prueba a entrenar con menos meses y predecir más futuro.
for corte in ['2025-07-01', '2025-10-01', '2026-01-01', '2026-04-01']:
c = pd.Timestamp(corte)
a, b = datos[datos['fecha'] < c], datos[datos['fecha'] >= c]
m = arma_modelo().fit(a[COLS], a['compro'])
print(f'{corte} entreno={len(a):4d} futuro={len(b):4d} '
f'AUC={roc_auc_score(b["compro"], m.predict_proba(b[COLS])[:, 1]):.4f}')
2025-07-01 entreno= 992 futuro=2008 AUC=0.6994 2025-10-01 entreno=1491 futuro=1509 AUC=0.7180 2026-01-01 entreno=2008 futuro= 992 AUC=0.7227 2026-04-01 entreno=2525 futuro= 475 AUC=0.7044
Con seis meses de entrenamiento ya saca 0,6994, y de ahí en adelante se mueve poco: 0,7180, 0,7227 y 0,7044. Fíjate que el último baja, y no es que más datos empeoren el modelo: es que ese corte deja solo 475 filas de prueba y ya sabemos lo que bailan.
Eso te dice algo práctico: no hace falta esperar dos años de datos para poner esto a funcionar. Con seis meses ya tienes un modelo que sirve, y los meses siguientes lo mejoran poquito 🌱
2. Reentrenar cada mes contra no reentrenar nunca
Compara el modelo congelado de enero con uno que se reentrena con todo lo que va llegando.
meses = sorted(futuro['fecha'].dt.to_period('M').unique())
for mes in meses:
m_actual = futuro['fecha'].dt.to_period('M') == mes
b = futuro[m_actual]
hasta = datos[datos['fecha'] < b['fecha'].min()]
fresco = arma_modelo().fit(hasta[COLS], hasta['compro'])
print(f'{mes} congelado={roc_auc_score(b["compro"], p_futuro[m_actual.values]):.4f} '
f'fresco={roc_auc_score(b["compro"], fresco.predict_proba(b[COLS])[:, 1]):.4f} '
f'(entrenó con {len(hasta)})')
2026-01 congelado=0.7585 fresco=0.7585 (entrenó con 2008) 2026-02 congelado=0.8029 fresco=0.8005 (entrenó con 2184) 2026-03 congelado=0.6800 fresco=0.6885 (entrenó con 2333) 2026-04 congelado=0.7296 fresco=0.7357 (entrenó con 2525) 2026-05 congelado=0.7212 fresco=0.7223 (entrenó con 2696) 2026-06 congelado=0.6231 fresco=0.6185 (entrenó con 2878)
Reentrenar cada mes gana en tres meses, pierde en dos y empata en uno. Lo mejor que consigue es 0,0085 en marzo y lo peor que pierde es 0,0046 en junio, con todo eso muy por debajo del bailoteo que medimos arriba.
Esto es exactamente lo que hay que llevar a la reunión donde alguien propone un reentrenamiento automático semanal: no es que esté mal, es que en estos datos no compra nada y sí trae riesgo 🧊
3. Vigilar la tasa de sí, que es gratis
Sin etiquetas todavía, mira qué porcentaje de síes está dando el modelo cada mes.
for mes, g in f.groupby('mes'):
print(f'{mes} dice sí al {(g["prob"] >= 0.5).mean():.4f} '
f'prob media {g["prob"].mean():.4f} real {g["compro"].mean():.4f}')
2026-01 dice sí al 0.6648 prob media 0.5773 real 0.5739 2026-02 dice sí al 0.6242 prob media 0.5568 real 0.5906 2026-03 dice sí al 0.6927 prob media 0.5813 real 0.5677 2026-04 dice sí al 0.6491 prob media 0.5687 real 0.5614 2026-05 dice sí al 0.6429 prob media 0.5610 real 0.5220 2026-06 dice sí al 0.6230 prob media 0.5726 real 0.6311
La tasa de síes se mantiene entre 0,6230 y 0,6927 todos los meses. Estable.
Este es el indicador más barato que existe: no necesita etiquetas, no necesita esperar, y si un lunes te sale 0,20 sabes que algo pasó antes de que nadie te lo cuente 💰
4. PSI sobre las categóricas
El PSI de arriba era para números. Hazlo para las columnas de texto.
def psi_cat(antes, ahora):
a = antes.value_counts(normalize=True)
b = ahora.value_counts(normalize=True).reindex(a.index).fillna(1e-4)
return float(((b - a) * np.log(b / a)).sum())
for col in CATEGORICAS:
print(f'{col:12} real={psi_cat(pasado[col], futuro[col]):.4f} '
f'con tacna={psi_cat(pasado[col], c1[col]):.4f}')
ciudad real=0.0043 con tacna=0.1144 segmento real=0.0021 con tacna=0.0021 canal real=0.0055 con tacna=0.0055 categoria real=0.0142 con tacna=0.0142
La ciudad pasa de 0,0043 a 0,1144 cuando entran las 300 filas de tacna. Cruza el 0,1, o sea que entra en la banda de "anda a mirar", pero no llega ni de lejos al 0,25 de alarma seria.
Y eso que un tercio de las filas cambió de ciudad, que es una avería enorme. Con 50 filas de tacna en vez de 300, el PSI ni se habría inmutado.
O sea que la ciudad nueva era detectable, apenas, y solo con este
detector concreto. Cada avería tiene el suyo, y por eso el revisa del
contrato, que la caza por nombre y sin ambigüedad, sigue siendo lo primero que yo
montaría 🧰
5. La función que usarías de verdad
Junta cargar, revisar y predecir en una sola función que devuelva algo con lo que se pueda trabajar.
def predice(nuevo):
problemas = revisa(nuevo, entreno, CATEGORICAS)
if problemas:
return {'ok': False, 'problemas': problemas}
prob = joblib.load(RUTA).predict_proba(nuevo[COLS])[:, 1]
return {'ok': True, 'n': len(nuevo),
'prob_media': round(float(prob.mean()), 4),
'psi': round(psi(p_pasado, prob), 4)}
print(predice(futuro[COLS]))
print(predice(c1))
print(predice(futuro[COLS].drop(columns=['monto'])))
{'ok': True, 'n': 992, 'prob_media': 0.5699, 'psi': 0.015}
{'ok': False, 'problemas': ["ciudad: valores nunca vistos ['tacna']"]}
{'ok': False, 'problemas': ["faltan columnas: ['monto']"]}
Tres llamadas y tres respuestas útiles: una que funciona y trae su PSI al lado, y dos que se niegan diciendo exactamente por qué.
Que la función se niegue en vez de predecir es la decisión de diseño más importante de todo el capítulo. Un modelo que predice sobre datos rotos es peor que un modelo caído, porque el caído se nota 🛑
6. Cuántas filas hacen falta para que la alarma sirva
Repite el bailoteo del AUC con distintos tamaños de muestra.
for n in [50, 150, 500, 992]:
ms = []
rng = np.random.default_rng(0)
for _ in range(200):
idx = rng.choice(len(f), n, replace=False)
ms.append(roc_auc_score(f['compro'].values[idx], p_futuro[idx]))
ms = np.array(ms)
print(f'n={n:4d} entre {np.quantile(ms, .05):.4f} y {np.quantile(ms, .95):.4f} '
f'ancho={np.quantile(ms, .95) - np.quantile(ms, .05):.4f}')
n= 50 entre 0.6063 y 0.8327 ancho=0.2264 n= 150 entre 0.6501 y 0.7845 ancho=0.1344 n= 500 entre 0.6966 y 0.7497 ancho=0.0531 n= 992 entre 0.7227 y 0.7227 ancho=0.0000
Con 50 filas el rango mide 0,2264 de ancho, o sea que el número no dice nada. Con 500 baja a 0,0531 y ya se puede hablar.
La última fila sale 0,0000 y no es que con 992 filas no haya incertidumbre: es que estoy sacando 992 de 992 sin reemplazo, así que siempre me toca la misma muestra. El bootstrap de verdad, con reemplazo, está en el libro de estadística 🎯
Esta tabla es la que decide cada cuánto miras el tablero. Si tu negocio hace 150 operaciones al mes, tu tablero es trimestral y punto 📏
7. El modelo viejo contra el modelo nuevo, bien comparados
Antes de reemplazar un modelo en producción, compáralos sobre las mismas filas.
nuevo = arma_modelo().fit(datos[datos['fecha'] < pd.Timestamp('2026-04-01')][COLS],
datos[datos['fecha'] < pd.Timestamp('2026-04-01')]['compro'])
ultimos = datos[datos['fecha'] >= pd.Timestamp('2026-04-01')]
viejo_p = modelo.predict_proba(ultimos[COLS])[:, 1]
nuevo_p = nuevo.predict_proba(ultimos[COLS])[:, 1]
print('viejo:', round(roc_auc_score(ultimos['compro'], viejo_p), 4))
print('nuevo:', round(roc_auc_score(ultimos['compro'], nuevo_p), 4))
print('coinciden en el sí/no:',
round(((viejo_p >= 0.5) == (nuevo_p >= 0.5)).mean(), 4))
viejo: 0.7046 nuevo: 0.7044 coinciden en el sí/no: 0.9726
Empate perfecto: el viejo saca 0,7046 y el nuevo 0,7044 sobre las mismas 475 filas, y coinciden en el 97,26% de las decisiones.
El modelo nuevo tiene tres meses más de datos y no aprendió nada nuevo. Esa es la respuesta y hay que darla tal cual, aunque el trabajo de reentrenar ya esté hecho y dé pena tirarlo.
Y esa última línea es la que hay que llevar a la reunión. Saber que el 2,74% de las decisiones cambiarían te dice el tamaño real de lo que estás proponiendo, que aquí es mover 13 clientes de lado a cambio de dos diezmilésimas ⚖️
Comprueba que lo tienes
Llevas el modelo a producción. ¿Qué mides todos los meses?
- Si las columnas de entrada se parecen a las del entrenamiento
- Solo el AUC, cuando lleguen las etiquetas
- Nada, mientras nadie se queje
- El tiempo de respuesta
Lo que te llevas
- 📅 Se valida partiendo por fecha, no al azar. Aquí da 0,7227 contra 0,7214, o sea que no hay fuga temporal ni deriva.
- 📦 Se guarda con
jobliby sale 5,5 KB, con el preprocesamiento dentro. Al lado, un texto con fechas, AUC y columnas. - 🔔 Ninguna alarma sin medir antes cuánto baila sola: con 150 filas el AUC se mueve entre 0,6501 y 0,7845 sin que pase nada.
- 🤫 Una ciudad nueva no rompe nada, no la ve el PSI de la probabilidad y el PSI de la ciudad apenas la roza (0,1144 con un tercio de las filas cambiadas). Es la avería peligrosa.
- 🎯 El PSI de la probabilidad predicha se enteró de la avería que importaba (0,2187) y se calló en las dos que no.
- 🚦 Lo que de verdad salva es revisar el contrato de entrada: columnas que faltan, valores nunca vistos y tipos cambiados.
- 🛑 Una función que se niega a predecir sobre datos rotos vale más que una que predice igual.
- 🧊 Reentrenar por calendario no ganó nada en estos seis meses. Se reentrena cuando algo habla.
En el capítulo 24 juntamos todo en un proyecto de punta a punta, del CSV crudo a la lista de clientes con su probabilidad al lado.
Que tengas lindo día! 🌸