Capítulo 23 de 25 11 secciones 18 min

Sacarlo del cuaderno y vigilar que siga sirviendo

Guardar el modelo, partir por fecha, medir deriva con PSI y romperlo a propósito para ver qué alarma suena y cuál no.

Un modelo en producción se guarda con joblib, se valida partiendo por fecha y se vigila con dos cosas distintas: una revisión del contrato de los datos que llegan y el PSI de la probabilidad que sale. Aquí, con 18 meses de datos, no hay deriva ninguna: la partición temporal da 0,7227 y la aleatoria 0,7214. Los sustos vienen del tamaño de muestra, no del mundo.

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 revisa en 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 joblib y 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! 🌸

¿Tienes alguna duda o consulta?