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