{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Que alguien más le pueda preguntar\n",
    "\n",
    "Guardar el pipeline, cargarlo en otro proceso, validar lo que llega antes de predecir y montar la API que lo sirve.\n",
    "\n",
    "Cuaderno de práctica del capítulo 25 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/servir-el-modelo/\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"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import urllib.request\n",
    "\n",
    "# Este capitulo abre el archivo por su nombre, sin URL, que es lo que se hace\n",
    "# cuando lo tienes al lado. Aqui se baja primero para que exista.\n",
    "for nombre in ['ventas-miss-yera.csv']:\n",
    "    urllib.request.urlretrieve(f\"https://missyera.com/static/datasets/{nombre}\", nombre)\n",
    "    print(nombre, \"lista\")"
   ]
  },
  {
   "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: \"ZWwgbW9kZWxvIHN1ZWx0byBlc3BlcmEgMjAgY29sdW1uYXMKdHUgdmVudGEgdGllbmUgICAgICAgICAgNiBjYW1wb3M=\",\n",
    "    2: \"JzQ4MC4zNycgLT4gbW9udG8gcXVlZGEgNDgwLjM3IHwgcHJvYmFiaWxpZGFkIDAuNjc3OQonNDgwLDM3JyAtPiBtb250byBxdWVkYSBuYW4gfCBwcm9iYWJpbGlkYWQgMC42Nzky\",\n",
    "    3: \"YW50ZXMgOiA5IFsnQXJlcXVpcGEnLCAnQ2hpY2xheW8nLCAnQ3VzY28nLCAnTElNQSAnLCAnTGltYScsICdMw61tYScsICdQaXVyYScsICdUcnVqaWxsbycsICdsaW1hJ10KZGVzcHVlczogNiBbJ2FyZXF1aXBhJywgJ2NoaWNsYXlvJywgJ2N1c2NvJywgJ2xpbWEnLCAncGl1cmEnLCAndHJ1amlsbG8nXQ==\",\n",
    "    4: \"dW1icmFsIDAuMyAtPiBkZWNpc2lvbiAxCnVtYnJhbCAwLjUgLT4gZGVjaXNpb24gMQp1bWJyYWwgMC43IC0+IGRlY2lzaW9uIDAKbGEgcHJvYmFiaWxpZGFkIGVzIGxhIG1pc21hOiAwLjY3Nzk=\",\n",
    "    5: \"cHJvYmFiaWxpZGFkZXM6IFswLjY3NzkgMC40NjE1IDAuNzk1N10KdW5hIGxsYW1hZGEgcGFyYSAzIHZlbnRhcw==\",\n",
    "    6: \"b3JkZW4gcXVlIG1hbmRvOiBbJ2NhbmFsJywgJ3NlZ21lbnRvJywgJ2NpdWRhZCcsICdzYXRpc2ZhY2Npb24nLCAnbW9udG8nLCAndW5pZGFkZXMnXQptaXNtYSBwcm9iYWJpbGlkYWQ6IDAuNjc3OQ==\",\n",
    "}, lenguaje=\"python\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Hasta acá el modelo vive en tu cuaderno. Lo entrenas, lo mides, lo explicas\n",
    "y ahí se queda 🐣\n",
    "\n",
    "Este capítulo es el tramo que casi nadie enseña: entre \"tengo un modelo que\n",
    "funciona\" y \"el sistema de la empresa le pide predicciones\" hay un trabajo, y no\n",
    "es el que uno se imagina. La API son veinte líneas. Lo difícil es lo que llega\n",
    "por ella."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Se guarda el pipeline, no el modelo"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esto es lo primero y es donde se equivoca casi todo el mundo. Lo que hay que\n",
    "guardar no es el clasificador: es el **pipeline entero** del\n",
    "capítulo 11, con su imputación, su escalado y sus dummies\n",
    "dentro."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import os\n",
    "import tempfile\n",
    "import pandas as pd\n",
    "import joblib\n",
    "from sklearn.pipeline import Pipeline\n",
    "from sklearn.compose import ColumnTransformer\n",
    "from sklearn.preprocessing import OneHotEncoder, StandardScaler\n",
    "from sklearn.impute import SimpleImputer\n",
    "from sklearn.linear_model import LogisticRegression\n",
    "\n",
    "NUM = ['unidades', 'monto', 'satisfaccion']\n",
    "CAT = ['ciudad', 'segmento', 'canal']\n",
    "\n",
    "df = pd.read_csv('ventas-miss-yera.csv')\n",
    "for c in NUM:\n",
    "    df[c] = pd.to_numeric(df[c], errors='coerce')\n",
    "d = df[NUM + CAT + ['compro']].dropna(subset=['compro'])\n",
    "X, y = d[NUM + CAT], d['compro'].astype(int)\n",
    "\n",
    "pre = ColumnTransformer([\n",
    "    ('num', Pipeline([('imp', SimpleImputer(strategy='median')),\n",
    "                      ('esc', StandardScaler())]), NUM),\n",
    "    ('cat', OneHotEncoder(handle_unknown='ignore'), CAT)])\n",
    "pipe = Pipeline([('pre', pre), ('modelo', LogisticRegression(max_iter=1000))])\n",
    "pipe.fit(X, y)\n",
    "\n",
    "RUTA = os.path.join(tempfile.mkdtemp(), 'modelo.joblib')\n",
    "joblib.dump(pipe, RUTA)\n",
    "print('entrenado con', len(X), 'filas')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Si guardaras solo la regresión, quien la cargue del otro lado tendría que\n",
    "reproducir a mano el escalado y las dummies, exactamente iguales. Y no van a ser\n",
    "exactamente iguales. Ese es el bug clásico de este tema y tiene nombre:\n",
    "**training-serving skew**, o sea que entrenas con una preparación y\n",
    "predices con otra."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Cargarlo y pedirle algo"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "modelo = joblib.load(RUTA)\n",
    "\n",
    "venta = {'unidades': 10, 'monto': 480.37, 'satisfaccion': 4.0,\n",
    "         'ciudad': 'Lima', 'segmento': 'Minimarket', 'canal': 'Web'}\n",
    "\n",
    "p = float(modelo.predict_proba(pd.DataFrame([venta]))[0, 1])\n",
    "print('probabilidad de que compre:', round(p, 4))\n",
    "print('decision con umbral 0.5   :', int(p >= 0.5))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ya está: eso es servir un modelo. Todo lo demás es envolverlo.\n",
    "\n",
    "Fíjate en el `pd.DataFrame([venta])`: el pipeline espera una tabla\n",
    "con nombres de columna, no una lista de números. Y en el umbral 0.5, que no es\n",
    "sagrado: cuál te conviene lo decidiste en el capítulo 15."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los tres fallos, y solo uno hace ruido"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Acá está el capítulo de verdad. Le voy a mandar tres cosas mal y quiero que\n",
    "mires cuál protesta.\n",
    "\n",
    "**Uno: falta una columna.**"
   ]
  },
  {
   "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",
    "    sin_canal = {k: v for k, v in venta.items() if k != 'canal'}\n",
    "    modelo.predict_proba(pd.DataFrame([sin_canal]))\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: {'canal'}\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Perfecto. Revienta, dice qué falta, y quien mire los registros lo va a\n",
    "encontrar en dos minutos. **Este es el fallo bueno.**\n",
    "\n",
    "**Dos: una ciudad que el modelo nunca vio.**"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "otra = dict(venta, ciudad='Tarapoto')\n",
    "print('probabilidad:', round(float(modelo.predict_proba(pd.DataFrame([otra]))[0, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ni un aviso. Devolvió 0.6369, que es un número perfectamente creíble.\n",
    "\n",
    "Lo que pasó por dentro: `handle_unknown='ignore'` dejó todas las\n",
    "columnas de ciudad en cero. O sea que el modelo predijo como si esa venta no\n",
    "tuviera ciudad. No es una respuesta mala del todo, y tampoco es la que tú creías\n",
    "estar dando.\n",
    "\n",
    "**Tres: el número llega como texto.**"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "texto = dict(venta, monto='480.37')\n",
    "print('probabilidad:', round(float(modelo.predict_proba(pd.DataFrame([texto]))[0, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Salió bien. Y aquí es donde quiero que desconfíes: salió bien\n",
    "**de casualidad**, porque pandas convirtió esa cadena a número él\n",
    "solo. Si en vez de `'480.37'` llega `'480,37'` con coma,\n",
    "que es como lo manda medio Perú, la conversión falla en silencio y ese monto\n",
    "entra como nulo, y el imputador del pipeline lo rellena con la mediana.\n",
    "\n",
    "Tu API contestaría una probabilidad calculada sobre un monto que nunca\n",
    "existió 🙃"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Entonces se valida antes, y a mano"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "ESPERADO = {'unidades': 'numero', 'monto': 'numero', 'satisfaccion': 'numero',\n",
    "            'ciudad': 'texto', 'segmento': 'texto', 'canal': 'texto'}\n",
    "\n",
    "def revisa(venta, conocidas):\n",
    "    faltan = [c for c in ESPERADO if c not in venta]\n",
    "    if faltan:\n",
    "        return 'faltan columnas: ' + str(faltan)\n",
    "    for c, tipo in ESPERADO.items():\n",
    "        if tipo == 'numero' and not isinstance(venta[c], (int, float)):\n",
    "            return c + ' llego como ' + type(venta[c]).__name__ + ', se esperaba numero'\n",
    "    if venta['ciudad'] not in conocidas:\n",
    "        return 'ciudad desconocida: ' + venta['ciudad']\n",
    "    return None\n",
    "\n",
    "conocidas = set(df['ciudad'].dropna().unique())\n",
    "casos = [('completa y sana', venta),\n",
    "         ('sin canal', sin_canal),\n",
    "         ('monto como texto', dict(venta, monto='480.37')),\n",
    "         ('ciudad nueva', dict(venta, ciudad='Tarapoto'))]\n",
    "for etiqueta, caso in casos:\n",
    "    print(etiqueta.ljust(18), '->', revisa(caso, conocidas) or 'OK')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Los tres se cazan antes de llegar al modelo. Eso es un **contrato de\n",
    "entrada**, y es el hermano del contrato de datos del capítulo\n",
    "2: aquel era con quien te da los datos para entrenar,\n",
    "este es con quien te los manda para predecir."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Y ahora mira lo que pasa con las ciudades conocidas"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(sorted(conocidas))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cuatro maneras de escribir Lima 😅\n",
    "\n",
    "Así que mi validación, tal como está, deja pasar `'LIMA '` con\n",
    "espacio y rechaza `'LIMA'` sin espacio, sin ningún motivo. La lista de\n",
    "categorías válidas **hereda la suciedad del entrenamiento**, que es\n",
    "justo lo que trabajamos en el capítulo 5.\n",
    "\n",
    "La forma correcta es normalizar en los dos sitios con la misma función:\n",
    "minúsculas, sin espacios y sin tildes, antes de entrenar y antes de predecir. Y\n",
    "que sea **la misma función**, importada del mismo archivo, no dos\n",
    "copias que se parecen."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La API, que es la parte fácil"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Con eso resuelto, exponerlo es un archivo. Esto va en `api.py`:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "from fastapi import FastAPI, HTTPException\n",
    "from pydantic import BaseModel\n",
    "import pandas as pd, joblib\n",
    "\n",
    "app = FastAPI()\n",
    "modelo = joblib.load('modelo.joblib')      # una vez, al arrancar\n",
    "\n",
    "class Venta(BaseModel):\n",
    "    unidades: int\n",
    "    monto: float\n",
    "    satisfaccion: float\n",
    "    ciudad: str\n",
    "    segmento: str\n",
    "    canal: str\n",
    "\n",
    "@app.get('/salud')\n",
    "def salud():\n",
    "    return {'estado': 'vivo'}\n",
    "\n",
    "@app.post('/predecir')\n",
    "def predecir(venta: Venta):\n",
    "    fila = pd.DataFrame([venta.model_dump()])\n",
    "    p = float(modelo.predict_proba(fila)[0, 1])\n",
    "    return {'probabilidad': round(p, 4), 'compra': int(p >= 0.5)}\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y se levanta así:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "uvicorn api:app --reload\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cuatro cosas que quiero señalarte de ese archivo, porque son las que\n",
    "importan:\n",
    "\n",
    "- **El `joblib.load` está fuera de la función.** Se\n",
    "carga una vez al arrancar, no en cada petición. Cargarlo dentro es el error de\n",
    "rendimiento número uno de este tema.\n",
    "\n",
    "- **La clase `Venta` es media validación gratis.**\n",
    "Pydantic ya rechaza lo que no sea del tipo que dice. Lo que no sabe es qué\n",
    "ciudades conoce tu modelo: eso lo pones tú.\n",
    "\n",
    "- **`/salud`** no sirve para nada y hace falta\n",
    "siempre: es lo que consulta quien vigila para saber si el servicio está vivo.\n",
    "\n",
    "- **Devuelve la probabilidad, no solo el sí o el no.** Quien\n",
    "consuma la API puede querer otro umbral, y con el número crudo puede; con el\n",
    "cero o uno, ya no.\n",
    "\n",
    "Esa API está lista para recibir peticiones, y sigue estando en tu máquina. Que\n",
    "corra en la de otra persona es el capítulo 26."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- Se guarda el pipeline entero, no el modelo suelto.\n",
    "\n",
    "- Entrenar con una preparación y predecir con otra tiene nombre y te va a\n",
    "pasar.\n",
    "\n",
    "- Falta una columna: revienta, y eso es bueno.\n",
    "\n",
    "- Categoría nueva y número como texto: no revientan, y eso es lo peligroso.\n",
    "\n",
    "- Se valida a mano antes de predecir, con un contrato de entrada.\n",
    "\n",
    "- La lista de categorías válidas hereda la suciedad del entrenamiento.\n",
    "\n",
    "- Carga el modelo al arrancar, no en cada petición.\n",
    "\n",
    "- Devuelve la probabilidad, no la decisión."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "Tu API recibe una venta con la ciudad \"Tarapoto\", que el modelo nunca vio porque no estaba en el entrenamiento. ¿Qué devuelve?\n",
    "\n",
    "a) Una probabilidad normal, sin avisar de nada\n",
    "\n",
    "b) Un error, porque la categoría no existe\n",
    "\n",
    "c) La probabilidad media de todas las ciudades\n",
    "\n",
    "d) Un nulo, que después revienta más adelante"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. Guarda solo el modelo y mira el desastre\n",
    "\n",
    "Para que veas por qué se guarda el pipeline entero."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%revisa 1\n",
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. La coma decimal, que es la de verdad\n",
    "\n",
    "El caso peruano que rompe en silencio."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%revisa 2\n",
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. Normaliza en los dos lados con la misma función\n",
    "\n",
    "Arregla las cuatro Limas."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%revisa 3\n",
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. El umbral se decide fuera del modelo\n",
    "\n",
    "Por eso la API devuelve la probabilidad y no el sí o el no."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%revisa 4\n",
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. Un lote en vez de una fila\n",
    "\n",
    "La misma llamada sirve para muchas ventas."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%revisa 5\n",
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. Manda las columnas al revés\n",
    "\n",
    "Comprueba si el pipeline se guía por el nombre o por la\n",
    "posición."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%revisa 6\n",
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 25 de **Machine learning desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/machine-learning-desde-cero/servir-el-modelo/\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
}
