{
 "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 soluciones 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",
    "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": [
    "## 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": [
    "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\n",
    "\n",
    "---\n",
    "\n",
    "**La correcta es la a.**\n",
    "\n",
    "*b)* Daría error solo si el OneHotEncoder llevara handle_unknown en su valor por defecto. Con ignore, que es lo que casi todo el mundo pone para que no reviente, la fila pasa.\n",
    "\n",
    "*c)* Eso sería si algo calculara un promedio, y no lo hace nadie. La columna de esa ciudad simplemente queda en ceros.\n",
    "\n",
    "*d)* Ojalá. Un nulo se ve; una probabilidad razonable calculada sobre una ciudad que el modelo no conoce no se ve.\n",
    "\n",
    "El fallo ruidoso es el barato. El caro es este: te contesta 0.6369 con toda la seguridad del mundo 🧾"
   ]
  },
  {
   "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": [
    "solo_modelo = pipe.named_steps['modelo']\n",
    "print('el modelo suelto espera', solo_modelo.n_features_in_, 'columnas')\n",
    "print('tu venta tiene         ', len(venta), 'campos')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "el modelo suelto espera 20 columnas\n",
    "tu venta tiene          6 campos\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "20 contra 6. Esas 20 son las 3 numéricas escaladas más las 17 dummies que\n",
    "sacó el OneHotEncoder: 9 ciudades, 4 segmentos y 4 canales. Para usar el modelo\n",
    "suelto tendrías que fabricar esas 20 columnas en el mismo orden, y el día que el\n",
    "entrenamiento vea una ciudad más pasan a ser 21 y tu código de servicio ni se\n",
    "entera."
   ]
  },
  {
   "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": [
    "for valor in ('480.37', '480,37'):\n",
    "    fila = pd.DataFrame([dict(venta, monto=valor)])\n",
    "    fila['monto'] = pd.to_numeric(fila['monto'], errors='coerce')\n",
    "    p = float(modelo.predict_proba(fila)[0, 1])\n",
    "    print(repr(valor), '-> monto queda', fila['monto'].iloc[0],\n",
    "          '| probabilidad', round(p, 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "'480.37' -> monto queda 480.37 | probabilidad 0.6779\n",
    "'480,37' -> monto queda nan | probabilidad 0.6792\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Mira la diferencia: 0.6779 contra 0.6792. Trece diezmilésimas.\n",
    "\n",
    "La segunda está calculada sobre un monto que se convirtió en nulo y que el\n",
    "imputador rellenó con la mediana. Ese cliente recibió una predicción que no tiene\n",
    "nada que ver con lo que facturó, y la respuesta se parece tanto a la correcta que\n",
    "no hay forma de verlo mirando la salida.\n",
    "\n",
    "Si el fallo hubiera devuelto 0.02 lo cazabas el primer día. Cambiando la\n",
    "tercera cifra, puede estar meses corriendo. Por eso la validación va antes y no\n",
    "después 🧾"
   ]
  },
  {
   "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": [
    "import unicodedata\n",
    "\n",
    "def normaliza(texto):\n",
    "    t = unicodedata.normalize('NFD', str(texto).strip().lower())\n",
    "    return ''.join(c for c in t if unicodedata.category(c) != 'Mn')\n",
    "\n",
    "crudas = sorted(df['ciudad'].dropna().unique())\n",
    "limpias = sorted(set(normaliza(c) for c in crudas))\n",
    "print('antes :', len(crudas), crudas)\n",
    "print('despues:', len(limpias), limpias)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "antes : 9 ['Arequipa', 'Chiclayo', 'Cusco', 'LIMA ', 'Lima', 'Líma', 'Piura', 'Trujillo', 'lima']\n",
    "despues: 6 ['arequipa', 'chiclayo', 'cusco', 'lima', 'piura', 'trujillo']\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "De 9 a 6. Y lo importante no es el número: es que esa función tiene que\n",
    "correr antes de entrenar y antes de predecir, importada del mismo archivo. Dos\n",
    "copias parecidas en dos repositorios distintos es exactamente cómo aparece el\n",
    "training-serving skew del que hablábamos arriba."
   ]
  },
  {
   "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": [
    "p = float(modelo.predict_proba(pd.DataFrame([venta]))[0, 1])\n",
    "for u in (0.3, 0.5, 0.7):\n",
    "    print('umbral', u, '-> decision', int(p >= u))\n",
    "print('la probabilidad es la misma:', round(p, 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "umbral 0.3 -> decision 1\n",
    "umbral 0.5 -> decision 1\n",
    "umbral 0.7 -> decision 0\n",
    "la probabilidad es la misma: 0.6779\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "La misma venta es un sí o un no según quién la mire, y el modelo no cambió\n",
    "nada. Si tu API devolviera solo el cero o el uno, cambiar de umbral obligaría a\n",
    "redesplegar; devolviendo el número, lo cambia quien consume con una línea suya.\n",
    "Cuál conviene es el capítulo 15."
   ]
  },
  {
   "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": [
    "import numpy as np\n",
    "\n",
    "lote = pd.DataFrame([venta,\n",
    "                     dict(venta, unidades=1, monto=40.0, satisfaccion=1.0),\n",
    "                     dict(venta, unidades=50, monto=3000.0, satisfaccion=5.0)])\n",
    "pr = modelo.predict_proba(lote)[:, 1]\n",
    "print('probabilidades:', np.round(pr, 4))\n",
    "print('una llamada para', len(lote), 'ventas')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "probabilidades: [0.6779 0.4615 0.7957]\n",
    "una llamada para 3 ventas\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Si tu sistema va a puntuar diez mil ventas de madrugada, hacerlo en un lote y\n",
    "no en diez mil peticiones cambia el tiempo por completo. Conviene que la API\n",
    "acepte las dos formas: una venta y una lista."
   ]
  },
  {
   "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": [
    "al_reves = {k: venta[k] for k in reversed(list(venta))}\n",
    "print('orden que mando:', list(al_reves))\n",
    "p2 = float(modelo.predict_proba(pd.DataFrame([al_reves]))[0, 1])\n",
    "print('misma probabilidad:', round(p2, 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "orden que mando: ['canal', 'segmento', 'ciudad', 'satisfaccion', 'monto', 'unidades']\n",
    "misma probabilidad: 0.6779\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Idéntica. El `ColumnTransformer` busca por nombre, así que el orden\n",
    "no importa. Y esto vale justo porque guardaste el pipeline entero: si hubieras\n",
    "guardado el modelo suelto y armado las columnas a mano, mandarlas al revés te\n",
    "daría un número distinto sin avisar."
   ]
  },
  {
   "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
}
