{
 "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 práctica 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",
    "Los ejercicios están al final y traen una celda vacía debajo de cada uno. Las\n",
    "respuestas viven en el cuaderno de soluciones, y merece la pena pelearse un\n",
    "rato antes de abrirlo 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Antes de empezar\n",
    "\n",
    "Esta celda baja el ayudante que corrige tus ejercicios. Después, en cada\n",
    "ejercicio que se pueda corregir solo, vas a ver `%%revisa` arriba de la celda:\n",
    "escribe tu respuesta debajo, ejecuta, y te digo si te salió 💛"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import urllib.request\n",
    "\n",
    "# El ayudante de los cuadernos. Trae la corrección de los ejercicios y, en los\n",
    "# capítulos de consola, la celda mágica que ejecuta los comandos. Se baja en\n",
    "# vez de venir pegado aquí para que siempre sea el último.\n",
    "urllib.request.urlretrieve(\n",
    "    \"https://missyera.com/static/cuadernos/revisa.py\", \"revisa.py\")\n",
    "import revisa\n",
    "revisa.carga({\n",
    "    1: \"MjAyNS0wNy0wMSAgZW50cmVubz0gOTkyICBmdXR1cm89MjAwOCAgQVVDPTAuNjk5NAoyMDI1LTEwLTAxICBlbnRyZW5vPTE0OTEgIGZ1dHVybz0xNTA5ICBBVUM9MC43MTgwCjIwMjYtMDEtMDEgIGVudHJlbm89MjAwOCAgZnV0dXJvPSA5OTIgIEFVQz0wLjcyMjcKMjAyNi0wNC0wMSAgZW50cmVubz0yNTI1ICBmdXR1cm89IDQ3NSAgQVVDPTAuNzA0NA==\",\n",
    "    2: \"MjAyNi0wMSAgY29uZ2VsYWRvPTAuNzU4NSAgZnJlc2NvPTAuNzU4NSAgKGVudHJlbsOzIGNvbiAyMDA4KQoyMDI2LTAyICBjb25nZWxhZG89MC44MDI5ICBmcmVzY289MC44MDA1ICAoZW50cmVuw7MgY29uIDIxODQpCjIwMjYtMDMgIGNvbmdlbGFkbz0wLjY4MDAgIGZyZXNjbz0wLjY4ODUgIChlbnRyZW7DsyBjb24gMjMzMykKMjAyNi0wNCAgY29uZ2VsYWRvPTAuNzI5NiAgZnJlc2NvPTAuNzM1NyAgKGVudHJlbsOzIGNvbiAyNTI1KQoyMDI2LTA1ICBjb25nZWxhZG89MC43MjEyICBmcmVzY289MC43MjIzICAoZW50cmVuw7MgY29uIDI2OTYpCjIwMjYtMDYgIGNvbmdlbGFkbz0wLjYyMzEgIGZyZXNjbz0wLjYxODUgIChlbnRyZW7DsyBjb24gMjg3OCk=\",\n",
    "    3: \"MjAyNi0wMSAgZGljZSBzw60gYWwgMC42NjQ4ICBwcm9iIG1lZGlhIDAuNTc3MyAgcmVhbCAwLjU3MzkKMjAyNi0wMiAgZGljZSBzw60gYWwgMC42MjQyICBwcm9iIG1lZGlhIDAuNTU2OCAgcmVhbCAwLjU5MDYKMjAyNi0wMyAgZGljZSBzw60gYWwgMC42OTI3ICBwcm9iIG1lZGlhIDAuNTgxMyAgcmVhbCAwLjU2NzcKMjAyNi0wNCAgZGljZSBzw60gYWwgMC42NDkxICBwcm9iIG1lZGlhIDAuNTY4NyAgcmVhbCAwLjU2MTQKMjAyNi0wNSAgZGljZSBzw60gYWwgMC42NDI5ICBwcm9iIG1lZGlhIDAuNTYxMCAgcmVhbCAwLjUyMjAKMjAyNi0wNiAgZGljZSBzw60gYWwgMC42MjMwICBwcm9iIG1lZGlhIDAuNTcyNiAgcmVhbCAwLjYzMTE=\",\n",
    "    4: \"Y2l1ZGFkICAgICAgIHJlYWw9MC4wMDQzICBjb24gdGFjbmE9MC4xMTQ0CnNlZ21lbnRvICAgICByZWFsPTAuMDAyMSAgY29uIHRhY25hPTAuMDAyMQpjYW5hbCAgICAgICAgcmVhbD0wLjAwNTUgIGNvbiB0YWNuYT0wLjAwNTUKY2F0ZWdvcmlhICAgIHJlYWw9MC4wMTQyICBjb24gdGFjbmE9MC4wMTQy\",\n",
    "    5: \"eydvayc6IFRydWUsICduJzogOTkyLCAncHJvYl9tZWRpYSc6IDAuNTY5OSwgJ3BzaSc6IDAuMDE1fQp7J29rJzogRmFsc2UsICdwcm9ibGVtYXMnOiBbImNpdWRhZDogdmFsb3JlcyBudW5jYSB2aXN0b3MgWyd0YWNuYSddIl19Cnsnb2snOiBGYWxzZSwgJ3Byb2JsZW1hcyc6IFsiZmFsdGFuIGNvbHVtbmFzOiBbJ21vbnRvJ10iXX0=\",\n",
    "    6: \"bj0gIDUwICBlbnRyZSAwLjYwNjMgeSAwLjgzMjcgIGFuY2hvPTAuMjI2NApuPSAxNTAgIGVudHJlIDAuNjUwMSB5IDAuNzg0NSAgYW5jaG89MC4xMzQ0Cm49IDUwMCAgZW50cmUgMC42OTY2IHkgMC43NDk3ICBhbmNobz0wLjA1MzEKbj0gOTkyICBlbnRyZSAwLjcyMjcgeSAwLjcyMjcgIGFuY2hvPTAuMDAwMA==\",\n",
    "    7: \"dmllam86IDAuNzA0NgpudWV2bzogMC43MDQ0CmNvaW5jaWRlbiBlbiBlbCBzw60vbm86IDAuOTcyNg==\",\n",
    "}, lenguaje=\"python\")"
   ]
  },
  {
   "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": [
    "%%revisa 1\n",
    "# tu turno"
   ]
  },
  {
   "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": [
    "%%revisa 2\n",
    "# tu turno"
   ]
  },
  {
   "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": [
    "%%revisa 3\n",
    "# tu turno"
   ]
  },
  {
   "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": [
    "%%revisa 4\n",
    "# tu turno"
   ]
  },
  {
   "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": [
    "%%revisa 5\n",
    "# tu turno"
   ]
  },
  {
   "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": [
    "%%revisa 6\n",
    "# tu turno"
   ]
  },
  {
   "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": [
    "%%revisa 7\n",
    "# tu turno"
   ]
  },
  {
   "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"
   ]
  },
  {
   "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?** La respuesta está en el cuaderno de soluciones. Míralo tú primero."
   ]
  },
  {
   "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
}
