{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Escalar, imputar y codificar\n",
    "\n",
    "Seis escaladores que dan el mismo AUC, uno que vale siete centésimas, y la tabla de decisiones que sale de medirlo todo.\n",
    "\n",
    "Cuaderno de soluciones del capítulo 10 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/decisiones-de-preprocessing/\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": [
    "Ya tenemos las columnas. Falta decidir **cómo entra cada una**:\n",
    "qué se escala, con qué se rellenan los huecos y cómo se convierte el texto en\n",
    "números 🔧\n",
    "\n",
    "Y te hago una pregunta incómoda de entrada: **¿cuántas de las decisiones técnicas que defendiste el año pasado podrías justificar hoy con un número?** Este capítulo va de eso 🤔\n",
    "\n",
    "Estas decisiones se toman a ojo casi siempre, copiando lo que había en el\n",
    "proyecto anterior. Aquí las vamos a medir todas, y el resultado va a ser\n",
    "incómodo: **casi ninguna mueve la aguja**.\n",
    "\n",
    "Lo cual no significa que den igual. Significa que no se eligen por el AUC 💜"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El banco de pruebas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Los porqués de este banco de pruebas\n",
    "\n",
    "Comparar preprocesamientos parece trivial y tiene tres decisiones dentro, y si alguna se cae la comparación deja de medir lo que crees:\n",
    "\n",
    "- 🔁 **Validación cruzada y no un solo reparto.** Con una sola partición, la diferencia entre dos escaladores se mezcla con la suerte de esa partición. Es exactamente lo que hace falta separar aquí.\n",
    "\n",
    "- 🔒 **Todo dentro del pipeline.** Si el escalador se ajusta fuera, se ajusta con las filas del examen dentro, y entonces estarías comparando seis maneras distintas de hacer trampa.\n",
    "\n",
    "- 🎲 **La misma semilla para los seis.** Si cada uno se evalúa con un reparto distinto, la tabla mide repartos y no escaladores. Es el error más fácil de cometer y el más difícil de ver después."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Una función que arma el pipeline con las piezas que le pases y devuelve la\n",
    "validación cruzada. A partir de ahí, todo el capítulo es cambiar una pieza."
   ]
  },
  {
   "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.ensemble import HistGradientBoostingClassifier\n",
    "from sklearn.impute import KNNImputer, SimpleImputer\n",
    "from sklearn.linear_model import LogisticRegression\n",
    "from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split\n",
    "from sklearn.neighbors import KNeighborsClassifier\n",
    "from sklearn.pipeline import Pipeline\n",
    "from sklearn.preprocessing import (KBinsDiscretizer, MinMaxScaler, OneHotEncoder,\n",
    "                                   OrdinalEncoder, PowerTransformer,\n",
    "                                   QuantileTransformer, RobustScaler, StandardScaler)\n",
    "from sklearn.svm import SVC\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",
    "\n",
    "datos = prepara(carga_limpia(URL))\n",
    "X = datos[NUMERICAS + CATEGORICAS]\n",
    "y = datos['compro']\n",
    "X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,\n",
    "                                          random_state=42, stratify=y)\n",
    "cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)\n",
    "\n",
    "ONEHOT = Pipeline([('r', SimpleImputer(strategy='most_frequent')),\n",
    "                   ('c', OneHotEncoder(handle_unknown='ignore'))])\n",
    "\n",
    "def prueba(paso_numerico, paso_categorico=ONEHOT, modelo=None):\n",
    "    pre = ColumnTransformer([('num', paso_numerico, NUMERICAS),\n",
    "                             ('cat', paso_categorico, CATEGORICAS)])\n",
    "    tuberia = Pipeline([('pre', pre),\n",
    "                        ('mod', modelo or LogisticRegression(max_iter=2000,\n",
    "                                                             random_state=42))])\n",
    "    s = cross_val_score(tuberia, X_tr, y_tr, cv=cv, scoring='roc_auc')\n",
    "    return s.mean(), s.std()\n",
    "\n",
    "print('banco listo. filas de entrenamiento:', len(X_tr))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Seis escaladores, un solo resultado"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for nombre, escalador in [\n",
    "        ('sin escalar', 'passthrough'),\n",
    "        ('StandardScaler', StandardScaler()),\n",
    "        ('MinMaxScaler', MinMaxScaler()),\n",
    "        ('RobustScaler', RobustScaler()),\n",
    "        ('Quantile a normal', QuantileTransformer(output_distribution='normal',\n",
    "                                                  random_state=42)),\n",
    "        ('Yeo-Johnson', PowerTransformer(method='yeo-johnson'))]:\n",
    "    pasos = [('r', SimpleImputer(strategy='median'))]\n",
    "    if escalador != 'passthrough':\n",
    "        pasos.append(('e', escalador))\n",
    "    media, desviacion = prueba(Pipeline(pasos))\n",
    "    print(f'{nombre:20s} AUC {media:.4f} +/- {desviacion:.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "De 0,6994 a 0,7007. Trece diezmilésimas entre el mejor y el peor, cuando la\n",
    "desviación de la propia validación cruzada es 0,0165 😐\n",
    "\n",
    "O sea que la diferencia entre no escalar nada y usar la transformación más\n",
    "sofisticada de scikit-learn es **doce veces más chica que el ruido de la\n",
    "medición**. Elegir por ese número es elegir el ruido.\n",
    "\n",
    "Y ojo con `precio_unitario`, que en el capítulo\n",
    "6 tenía asimetría 6,734. Ni siquiera Yeo-Johnson, que está\n",
    "hecho justo para eso, consigue sacarle una décima 🤷‍♀️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Con un boosting, ni eso"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for nombre, escalador in [('sin escalar', 'passthrough'),\n",
    "                          ('StandardScaler', StandardScaler())]:\n",
    "    pasos = [('r', SimpleImputer(strategy='median'))]\n",
    "    if escalador != 'passthrough':\n",
    "        pasos.append(('e', escalador))\n",
    "    media, desviacion = prueba(Pipeline(pasos),\n",
    "                               modelo=HistGradientBoostingClassifier(random_state=42))\n",
    "    print(f'{nombre:20s} AUC {media:.4f} +/- {desviacion:.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El mismo número, hasta la cuarta cifra, y no es casualidad 🌳\n",
    "\n",
    "Un árbol pregunta \"¿esta columna es mayor que X?\", y esa pregunta no cambia\n",
    "si multiplicas la columna por mil. Escalar antes de un árbol es trabajo que no\n",
    "hace nada, ni bueno ni malo."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Y ahora dónde sí decide"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Si con esos dos modelos da igual, la conclusión fácil es \"el escalado es un\n",
    "mito\". Vamos a comprobarlo con dos modelos que miden distancias."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for nombre, modelo in [('k vecinos', KNeighborsClassifier(n_neighbors=25)),\n",
    "                       ('SVM rbf', SVC(probability=True, random_state=42))]:\n",
    "    for etiqueta, escalador in [('sin escalar', 'passthrough'),\n",
    "                                ('escalado', StandardScaler())]:\n",
    "        pasos = [('r', SimpleImputer(strategy='median'))]\n",
    "        if escalador != 'passthrough':\n",
    "            pasos.append(('e', escalador))\n",
    "        media, desviacion = prueba(Pipeline(pasos), modelo=modelo)\n",
    "        print(f'{nombre:12s} {etiqueta:12s} AUC {media:.4f} +/- {desviacion:.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí está 💥\n",
    "\n",
    "El k vecinos pasa de 0,5863 a 0,6611. **Siete centésimas y media**,\n",
    "que es más de lo que da cualquier cambio de modelo en todo el libro. Y la SVM,\n",
    "cinco centésimas.\n",
    "\n",
    "La razón es de sentido común: esos dos modelos miden distancias entre filas.\n",
    "Sin escalar, `monto` va en miles y `satisfaccion` de 1 a\n",
    "5, así que la distancia entre dos clientes es **casi enteramente la\n",
    "diferencia de monto** y las otras ocho columnas no se enteran.\n",
    "\n",
    "Conclusión honesta y con matices: el escalado no es un mito ni un trámite.\n",
    "**Depende del modelo**, y como en un proyecto se prueban varios\n",
    "(capítulo 13), se escala siempre y se acabó 📏"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Cuatro formas de rellenar un hueco"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for nombre, imputador in [\n",
    "        ('mediana', SimpleImputer(strategy='median')),\n",
    "        ('media', SimpleImputer(strategy='mean')),\n",
    "        ('constante -1', SimpleImputer(strategy='constant', fill_value=-1)),\n",
    "        ('KNN con 5 vecinos', KNNImputer(n_neighbors=5))]:\n",
    "    media, desviacion = prueba(Pipeline([('r', imputador), ('e', StandardScaler())]))\n",
    "    print(f'{nombre:20s} AUC {media:.4f} +/- {desviacion:.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Otra vez lo mismo: de 0,6990 a 0,6998, ocho diezmilésimas 😑\n",
    "\n",
    "Y fíjate en que el KNN, que es el caro (mira los cinco vecinos más parecidos\n",
    "de cada fila con hueco), sale **el último**. No por mucho, pero el\n",
    "último.\n",
    "\n",
    "La explicación está en el capítulo 9: la información\n",
    "del hueco ya no está en el hueco, está en las banderas\n",
    "`sin_descuento` y `sin_satisfaccion`. Con la bandera\n",
    "puesta, da igual qué número pongas debajo: el modelo ya sabe que ahí no había\n",
    "nada 🕳️\n",
    "\n",
    "Por eso yo uso la mediana. No porque gane, sino porque es la que menos\n",
    "sorpresas da: no se mueve con un valor raro, no inventa correlaciones y se\n",
    "explica en una frase en la reunión."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El texto, convertido en números"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "NUMERICO = Pipeline([('r', SimpleImputer(strategy='median')),\n",
    "                     ('e', StandardScaler())])\n",
    "ORDINAL = Pipeline([('r', SimpleImputer(strategy='most_frequent')),\n",
    "                    ('c', OrdinalEncoder(handle_unknown='use_encoded_value',\n",
    "                                         unknown_value=-1))])\n",
    "\n",
    "for nombre, codificador in [('one-hot', ONEHOT),\n",
    "                            ('ordinal (numero arbitrario)', ORDINAL)]:\n",
    "    for etiqueta, modelo in [('logistica', LogisticRegression(max_iter=2000,\n",
    "                                                              random_state=42)),\n",
    "                             ('boosting', HistGradientBoostingClassifier(random_state=42))]:\n",
    "        media, desviacion = prueba(NUMERICO, codificador, modelo)\n",
    "        print(f'{nombre:28s} {etiqueta:10s} AUC {media:.4f} +/- {desviacion:.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Aquí sí hay diferencia, aunque sea chica: el ordinal cuesta 0,0091 con la\n",
    "logística y 0,0080 con el boosting.\n",
    "\n",
    "Y el motivo es que el ordinal **se inventa un orden**. Le pone\n",
    "0 a Abarrotes, 1 a Bebidas, 2 a Cuidado personal, y el modelo lineal lee eso\n",
    "como que Cuidado personal es el doble que Bebidas. Que es una tontería, pero el\n",
    "modelo no lo sabe 🔢\n",
    "\n",
    "Con cuatro o cinco niveles como aquí, el one-hot añade pocas columnas y\n",
    "gana. Con los 617 niveles de `cliente_id` del capítulo\n",
    "5, el one-hot es imposible y hay que buscar otra cosa."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El error que te espera en producción"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Fíjate en que arriba puse `handle_unknown` en los dos\n",
    "codificadores. Sin eso:"
   ]
  },
  {
   "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",
    "    codificador = OrdinalEncoder().fit(X_tr[['ciudad']])\n",
    "    codificador.transform([['ciudad-nueva']])\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: Found unknown categories ['ciudad-nueva'] in column 0 during transform\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El día que el equipo comercial abra en Cusco, el modelo revienta 💥\n",
    "\n",
    "Y aquí hay una decisión de verdad, no una convención. Las dos opciones son\n",
    "malas de formas distintas:\n",
    "\n",
    "- 💣 **Reventar** es ruidoso, pero avisa. Alguien lo ve el mismo\n",
    "día.\n",
    "\n",
    "- 🤫 **`handle_unknown='ignore'`** sigue funcionando y\n",
    "pone ceros. En el capítulo 27 se ve lo que eso\n",
    "significa: 300 ventas de una ciudad nueva predichas sin que nadie se entere.\n",
    "\n",
    "Mi respuesta es las dos cosas: `ignore` en el codificador para no\n",
    "tumbar el servicio, y una comprobación antes de predecir que avise por su\n",
    "cuenta. Eso es la función `revisa` del capítulo\n",
    "27 🚦"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Partir en tramos, que aquí no sale a cuenta"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "La idea es buena sobre el papel: si una columna no empuja en línea recta,\n",
    "partirla en tramos deja que un modelo lineal vea escalones."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for nombre, transformador in [\n",
    "        ('sin tramos', 'passthrough'),\n",
    "        ('4 tramos por cuantil', KBinsDiscretizer(n_bins=4, encode='onehot-dense',\n",
    "                                                  strategy='quantile')),\n",
    "        ('10 tramos por cuantil', KBinsDiscretizer(n_bins=10, encode='onehot-dense',\n",
    "                                                   strategy='quantile'))]:\n",
    "    pasos = [('r', SimpleImputer(strategy='median'))]\n",
    "    pasos.append(('e', StandardScaler()) if transformador == 'passthrough'\n",
    "                 else ('t', transformador))\n",
    "    media, desviacion = prueba(Pipeline(pasos))\n",
    "    print(f'{nombre:24s} AUC {media:.4f} +/- {desviacion:.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Pierde, y cuanto más se parte, más pierde: 0,6997 sin tramos, 0,6734 con\n",
    "cuatro y 0,6670 con diez 📉\n",
    "\n",
    "Esto va en contra de lo que yo esperaba al escribir el capítulo, así que\n",
    "merece explicación. Y la explicación está en el capítulo\n",
    "8: la relación entre el monto y la compra **no\n",
    "existe** una vez descuentas el segmento.\n",
    "\n",
    "Partir en tramos sirve para que un modelo lineal capture una relación curva.\n",
    "Si no hay relación, lo único que haces es cambiar una columna con información\n",
    "por cuatro columnas con menos, y encima con las fronteras puestas donde caen los\n",
    "cuantiles y no donde el negocio tiene sentido.\n",
    "\n",
    "La regla que me llevo: **los tramos se justifican con un hallazgo, no\n",
    "con una intuición** 🪜"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La tabla de decisiones"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Todo el capítulo cabe en esto, y esto es lo que se pega en el informe."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "| Decisión | Cuánto mueve el AUC | Entonces se elige por |\n",
    "|---|---|---|\n",
    "| Qué escalador | 0,0013 | Que no se rompa con un valor raro |\n",
    "| Escalar o no, con árboles | 0,0000 | Da igual, se deja por costumbre |\n",
    "| Escalar o no, con distancias | **0,0748** | Se escala, sin discusión |\n",
    "| Qué imputador | 0,0008 | Que se explique en una frase |\n",
    "| One-hot u ordinal | 0,0091 | One-hot si caben las columnas |\n",
    "| Partir en tramos | **−0,0263** | Solo con un hallazgo detrás |"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Dos de las seis importan, y una de las dos importa *en contra* 😅\n",
    "\n",
    "Si esto te parece decepcionante, mira el otro lado: significa que puedes\n",
    "dejar de discutir cuatro de estas seis decisiones y meter ese tiempo en las que\n",
    "sí mueven, que son construir columnas (capítulo 9) y\n",
    "elegir el umbral (capítulo 15)."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. Cada columna con su tratamiento\n",
    "\n",
    "Hasta aquí todas las numéricas recibieron el mismo trato.\n",
    "Dale a cada una lo que su ficha del capítulo 6 pedía."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "SESGADAS = ['monto', 'precio_unitario']\n",
    "NORMALES = ['unidades', 'descuento', 'satisfaccion', 'visita_numero']\n",
    "BANDERAS = ['sin_compra_previa', 'sin_descuento', 'sin_satisfaccion']\n",
    "\n",
    "a_medida = ColumnTransformer([\n",
    "    ('sesgadas', Pipeline([('r', SimpleImputer(strategy='median')),\n",
    "                           ('e', PowerTransformer(method='yeo-johnson'))]), SESGADAS),\n",
    "    ('normales', Pipeline([('r', SimpleImputer(strategy='median')),\n",
    "                           ('e', StandardScaler())]), NORMALES),\n",
    "    ('banderas', 'passthrough', BANDERAS),\n",
    "    ('cat', ONEHOT, CATEGORICAS),\n",
    "])\n",
    "a_medida = Pipeline([('pre', a_medida),\n",
    "                     ('mod', LogisticRegression(max_iter=2000, random_state=42))])\n",
    "s = cross_val_score(a_medida, X_tr, y_tr, cv=cv, scoring='roc_auc')\n",
    "print(f'a medida        AUC {s.mean():.4f} +/- {s.std():.4f}')\n",
    "\n",
    "media, desviacion = prueba(Pipeline([('r', SimpleImputer(strategy='median')),\n",
    "                                     ('e', StandardScaler())]))\n",
    "print(f'todo por igual  AUC {media:.4f} +/- {desviacion:.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "a medida        AUC 0.7010 +/- 0.0170\n",
    "todo por igual  AUC 0.6997 +/- 0.0165\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "0,7010 contra 0,6997. Trece diezmilésimas otra vez, o sea la misma nota, y\n",
    "cuatro veces más código para conseguirla 😂\n",
    "\n",
    "Y aun así lo hago en proyectos de verdad, por una razón que no es el AUC: las\n",
    "banderas pasan por `passthrough` en vez de escalarse, así que sus\n",
    "coeficientes se leen directamente como odds sin deshacer ninguna\n",
    "transformación. Se gana en explicación, no en acierto."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. Cuánto tarda cada decisión\n",
    "\n",
    "Si el AUC no decide, que decida algo. Mide el tamaño del\n",
    "objeto que hay que guardar y cargar en cada arranque."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import os\n",
    "import tempfile\n",
    "import joblib\n",
    "\n",
    "carpeta = tempfile.mkdtemp()\n",
    "for nombre, paso in [('mediana', SimpleImputer(strategy='median')),\n",
    "                     ('KNN con 5 vecinos', KNNImputer(n_neighbors=5))]:\n",
    "    pre = ColumnTransformer([('num', Pipeline([('r', paso), ('e', StandardScaler())]),\n",
    "                              NUMERICAS),\n",
    "                             ('cat', ONEHOT, CATEGORICAS)])\n",
    "    modelo = Pipeline([('pre', pre),\n",
    "                       ('mod', LogisticRegression(max_iter=2000, random_state=42))])\n",
    "    modelo.fit(X_tr, y_tr)\n",
    "    ruta = os.path.join(carpeta, nombre.replace(' ', '_') + '.joblib')\n",
    "    joblib.dump(modelo, ruta)\n",
    "    print(f'{nombre:20s} {os.path.getsize(ruta):>9,d} bytes')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "mediana                  5,642 bytes\n",
    "KNN con 5 vecinos      188,106 bytes\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El KNN se lleva el conjunto de entrenamiento entero dentro, porque para\n",
    "imputar una fila nueva necesita compararla con las que vio 🎒\n",
    "\n",
    "Ese es el tipo de argumento que decide cuando el AUC no lo hace. Un objeto\n",
    "más pesado tarda más en cargar en cada arranque del servicio y hay que\n",
    "versionarlo igual."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. El escalador contra un valor absurdo\n",
    "\n",
    "Mañana llega una venta con un cero de más por un error de\n",
    "tecleo. Mide qué le hace eso a cada escalador."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "rara = X_tr.copy()\n",
    "rara.iloc[0, rara.columns.get_loc('monto')] = 4_000_000\n",
    "\n",
    "for nombre, escalador in [('StandardScaler', StandardScaler()),\n",
    "                          ('MinMaxScaler', MinMaxScaler()),\n",
    "                          ('RobustScaler', RobustScaler())]:\n",
    "    antes = escalador.fit_transform(X_tr[['monto']])\n",
    "    despues = escalador.fit_transform(rara[['monto']])\n",
    "    print(f'{nombre:16s} desviacion de las 2.250 filas: '\n",
    "          f'antes {antes.std():.4f}  despues {despues.std():.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "StandardScaler   desviacion de las 2.250 filas: antes 1.0000  despues 1.0000\n",
    "MinMaxScaler     desviacion de las 2.250 filas: antes 0.1134  despues 0.0211\n",
    "RobustScaler     desviacion de las 2.250 filas: antes 0.9415  despues 103.6063\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Las tres reaccionan distinto y ninguna se queda igual 😱\n",
    "\n",
    "**El MinMax aplasta a todo el mundo.** Su rango pasa a ser de 0\n",
    "a cuatro millones, así que las 2.250 filas de verdad se apelotonan contra el\n",
    "cero: la desviación cae de 0,1134 a 0,0211, una quinta parte. La información\n",
    "sigue ahí pero comprimida hasta casi desaparecer.\n",
    "\n",
    "**El Standard sale en 1,0000 las dos veces**, y eso no es que\n",
    "aguante: es que por construcción siempre devuelve desviación 1. Ese número no\n",
    "mide nada, y lo dejo justamente para que se vea que un indicador puede parecer\n",
    "tranquilizador sin decir nada.\n",
    "\n",
    "**Y el Robust se dispara a 103,6.** Como divide por el rango\n",
    "intercuartílico, que no se mueve, la fila mal tecleada se convierte en un número\n",
    "enorme y arrastra la desviación del conjunto.\n",
    "\n",
    "Suena mal y es justo lo que quieres: las 2.250 filas buenas se quedan donde\n",
    "estaban y **el dato absurdo se queda absurdo**, en vez de\n",
    "disimularse. Se ve en cualquier gráfico y se ve en cualquier alarma 🚩\n",
    "\n",
    "Ahí tienes el criterio de verdad para elegir escalador, que no era el AUC:\n",
    "cuál deja el dato raro a la vista en vez de esconderlo entre los demás."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. Imputar mirando el examen, que es trampa\n",
    "\n",
    "La mediana del imputador tiene que salir solo del\n",
    "entrenamiento. Mide cuánto se infla si sale de todo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.metrics import roc_auc_score\n",
    "\n",
    "# Lo correcto: el pipeline aprende la mediana dentro de cada partición\n",
    "correcto = Pipeline([('pre', ColumnTransformer([\n",
    "    ('num', Pipeline([('r', SimpleImputer(strategy='median')),\n",
    "                      ('e', StandardScaler())]), NUMERICAS),\n",
    "    ('cat', ONEHOT, CATEGORICAS)])),\n",
    "    ('mod', LogisticRegression(max_iter=2000, random_state=42))])\n",
    "correcto.fit(X_tr, y_tr)\n",
    "print('correcto  AUC test', round(roc_auc_score(y_te, correcto.predict_proba(X_te)[:, 1]), 4))\n",
    "\n",
    "# La trampa: la mediana sale de las 3.000 filas, examen incluido\n",
    "trampa = X.copy()\n",
    "trampa[NUMERICAS] = trampa[NUMERICAS].fillna(trampa[NUMERICAS].median())\n",
    "Xt_tr, Xt_te = trampa.loc[X_tr.index], trampa.loc[X_te.index]\n",
    "sucio = Pipeline([('pre', ColumnTransformer([\n",
    "    ('num', StandardScaler(), NUMERICAS), ('cat', ONEHOT, CATEGORICAS)])),\n",
    "    ('mod', LogisticRegression(max_iter=2000, random_state=42))])\n",
    "sucio.fit(Xt_tr, y_tr)\n",
    "print('con trampa AUC test', round(roc_auc_score(y_te, sucio.predict_proba(Xt_te)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "correcto  AUC test 0.7214\n",
    "con trampa AUC test 0.7215\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "La diferencia es minúscula, y lo digo aunque no ayude al argumento 💛\n",
    "\n",
    "Con 3.000 filas y una mediana, filtrar el examen apenas cambia nada. El\n",
    "problema es que **no siempre es minúscula**: con un imputador que\n",
    "aprende relaciones entre columnas, o con selección de variables mirando todo el\n",
    "archivo, la misma trampa vale varios puntos.\n",
    "\n",
    "Por eso la regla del capítulo 11 no admite excepciones,\n",
    "aunque en este caso concreto no habría pasado nada."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. Cuántas columnas salen del otro lado\n",
    "\n",
    "Antes de discutir codificaciones, mira cuántas columnas\n",
    "crea cada una. Es lo que decide si el one-hot es viable."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pre = ColumnTransformer([('num', StandardScaler(), NUMERICAS),\n",
    "                         ('cat', ONEHOT, CATEGORICAS)])\n",
    "pre.fit(X_tr)\n",
    "print('con one-hot :', len(pre.get_feature_names_out()), 'columnas')\n",
    "\n",
    "pre_ord = ColumnTransformer([('num', StandardScaler(), NUMERICAS),\n",
    "                             ('cat', ORDINAL, CATEGORICAS)])\n",
    "pre_ord.fit(X_tr)\n",
    "print('con ordinal :', len(pre_ord.get_feature_names_out()), 'columnas')\n",
    "print()\n",
    "for c in CATEGORICAS:\n",
    "    print(f'  {c:12s} {X_tr[c].nunique():3d} niveles')\n",
    "print()\n",
    "print('si cliente_id entrara con one-hot:', datos['cliente_id'].nunique(), 'columnas mas')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "con one-hot : 28 columnas\n",
    "con ordinal : 13 columnas\n",
    "\n",
    "  ciudad         6 niveles\n",
    "  segmento       4 niveles\n",
    "  canal          4 niveles\n",
    "  categoria      5 niveles\n",
    "\n",
    "si cliente_id entrara con one-hot: 617 columnas mas\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Veintiocho columnas contra trece. Con estos niveles el one-hot es gratis 🧩\n",
    "\n",
    "Y ahí abajo está el límite: `cliente_id` añadiría 617 columnas a\n",
    "2.250 filas de entrenamiento. Eso ya no es codificar, es regalarle al modelo una\n",
    "columna por cliente para que se lo aprenda de memoria."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. El paso que se come columnas sin decir nada\n",
    "\n",
    "Un `ColumnTransformer` tira todo lo que no le\n",
    "nombres. Compruébalo olvidándote de tres columnas a propósito."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "olvidadizo = ColumnTransformer([\n",
    "    ('num', Pipeline([('r', SimpleImputer(strategy='median')),\n",
    "                      ('e', StandardScaler())]), NUMERICAS[:4]),\n",
    "    ('cat', ONEHOT, CATEGORICAS)])\n",
    "modelo = Pipeline([('pre', olvidadizo),\n",
    "                   ('mod', LogisticRegression(max_iter=2000, random_state=42))])\n",
    "s = cross_val_score(modelo, X_tr, y_tr, cv=cv, scoring='roc_auc')\n",
    "\n",
    "print('columnas que le pasé  :', len(NUMERICAS + CATEGORICAS))\n",
    "print('columnas que usó      :', len(olvidadizo.fit(X_tr).get_feature_names_out()))\n",
    "print(f'AUC {s.mean():.4f} +/- {s.std():.4f}')\n",
    "print()\n",
    "print('lo mismo con las nueve numericas:')\n",
    "media, desviacion = prueba(Pipeline([('r', SimpleImputer(strategy='median')),\n",
    "                                     ('e', StandardScaler())]))\n",
    "print(f'AUC {media:.4f} +/- {desviacion:.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "columnas que le pasé  : 13\n",
    "columnas que usó      : 23\n",
    "AUC 0.6793 +/- 0.0184\n",
    "\n",
    "lo mismo con las nueve numericas:\n",
    "AUC 0.6997 +/- 0.0165\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Le pasé trece columnas y solo nombré cuatro de las nueve numéricas. Las\n",
    "cinco que faltan se van sin una sola advertencia, y el AUC se cae de 0,6997 a\n",
    "0,6793 🕳️\n",
    "\n",
    "Las 23 de la segunda línea son las de después del one-hot: cuatro numéricas\n",
    "más diecinueve columnas nuevas de las categóricas. Por eso el número de salida\n",
    "no sirve para detectar la pérdida, y hay que mirar los nombres.\n",
    "\n",
    "Es el fallo silencioso más común de todo scikit-learn y no aparece en ningún\n",
    "sitio: `remainder='drop'` es el valor por defecto. Con\n",
    "`remainder='passthrough'` pasa lo contrario, que se cuelan columnas\n",
    "que no querías, incluidos identificadores.\n",
    "\n",
    "Por eso la comprobación de arriba, `get_feature_names_out()`, va\n",
    "siempre después de armar el pipeline. Una línea 📏"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 7. El resumen, en una función que decide sola\n",
    "\n",
    "Cierra el capítulo con la función que arma el\n",
    "preprocesamiento a partir de la auditoría de tipos, sin decidir nada a mano."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def arma_preprocesado(v, columnas, umbral_asimetria=2.0):\n",
    "    from scipy import stats\n",
    "    sesgadas, normales, banderas, categoricas = [], [], [], []\n",
    "    for c in columnas:\n",
    "        if not pd.api.types.is_numeric_dtype(v[c]):\n",
    "            categoricas.append(c)\n",
    "        elif v[c].dropna().nunique() <= 2:\n",
    "            banderas.append(c)\n",
    "        elif abs(stats.skew(v[c].dropna())) > umbral_asimetria:\n",
    "            sesgadas.append(c)\n",
    "        else:\n",
    "            normales.append(c)\n",
    "\n",
    "    partes = []\n",
    "    if sesgadas:\n",
    "        partes.append(('sesgadas', Pipeline([\n",
    "            ('r', SimpleImputer(strategy='median')),\n",
    "            ('e', PowerTransformer(method='yeo-johnson'))]), sesgadas))\n",
    "    if normales:\n",
    "        partes.append(('normales', Pipeline([\n",
    "            ('r', SimpleImputer(strategy='median')),\n",
    "            ('e', RobustScaler())]), normales))\n",
    "    if banderas:\n",
    "        partes.append(('banderas', 'passthrough', banderas))\n",
    "    if categoricas:\n",
    "        partes.append(('cat', ONEHOT, categoricas))\n",
    "    return ColumnTransformer(partes), {'sesgadas': sesgadas, 'normales': normales,\n",
    "                                       'banderas': banderas, 'categoricas': categoricas}\n",
    "\n",
    "pre, reparto = arma_preprocesado(X_tr, NUMERICAS + CATEGORICAS)\n",
    "for grupo, cols in reparto.items():\n",
    "    print(f'{grupo:12s} {cols}')\n",
    "print()\n",
    "modelo = Pipeline([('pre', pre),\n",
    "                   ('mod', LogisticRegression(max_iter=2000, random_state=42))])\n",
    "s = cross_val_score(modelo, X_tr, y_tr, cv=cv, scoring='roc_auc')\n",
    "print(f'AUC {s.mean():.4f} +/- {s.std():.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "sesgadas     ['precio_unitario']\n",
    "normales     ['unidades', 'monto', 'descuento', 'satisfaccion', 'visita_numero']\n",
    "banderas     ['sin_compra_previa', 'sin_descuento', 'sin_satisfaccion']\n",
    "categoricas  ['ciudad', 'segmento', 'canal', 'categoria']\n",
    "\n",
    "AUC 0.7012 +/- 0.0175\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Mismo resultado que a mano, y con el reparto escrito para que se pueda\n",
    "revisar 📋\n",
    "\n",
    "Fíjate en que devuelve dos cosas: el transformador y **el reparto**.\n",
    "Lo segundo es lo que hace que esto sea auditable en vez de mágico. Cuando\n",
    "alguien pregunte por qué el descuento se escaló con RobustScaler, la respuesta\n",
    "está impresa y no hay que leer el código.\n",
    "\n",
    "Y ese `umbral_asimetria` de 2,0 es una decisión mía que puse en un\n",
    "parámetro a propósito. Las decisiones discutibles se ponen donde se vean, no\n",
    "enterradas en un `if` 💛\n",
    "\n",
    "Un detalle del código que cuesta un rato la primera vez: la comprobación de\n",
    "si una columna es texto va con `pd.api.types.is_numeric_dtype` y no\n",
    "con `dtype == object`. Desde pandas 3, una columna de texto ya no\n",
    "tiene dtype `object`, así que la comparación de toda la vida devuelve\n",
    "`False` y las categóricas se cuelan por la rama de los números 🐍"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "Pruebas seis escaladores distintos y los seis dan el mismo AUC, con las diferencias por debajo de la desviación de la validación cruzada. ¿Qué haces?\n",
    "\n",
    "a) Elijo el que menos se rompa el día que llegue un dato raro\n",
    "\n",
    "b) Me quedo con el que salió más alto, aunque sea por poco\n",
    "\n",
    "c) Pruebo más escaladores hasta que alguno destaque\n",
    "\n",
    "d) Quito el escalado, que total da igual\n",
    "\n",
    "---\n",
    "\n",
    "**La correcta es la a.**\n",
    "\n",
    "*b)* Elegir por una diferencia más chica que el ruido es elegir el ruido.\n",
    "\n",
    "*c)* Si ninguno de los seis destaca, el séptimo tampoco va a ser la respuesta.\n",
    "\n",
    "*d)* Da igual para este modelo. Cambia el modelo y deja de dar igual.\n",
    "\n",
    "Cuando el AUC no decide, decide producción."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El ganador que sale del ruido"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "Comparas seis escaladores como manda el manual, ordenas por AUC y te quedas con el mejor. El código es correcto y la tabla es real.\n",
    "\n",
    "```\n",
    "for nombre, esc in escaladores.items():\n",
    "    auc[nombre] = evalua(esc)\n",
    "\n",
    "mejor = max(auc, key=auc.get)\n",
    "# 'RobustScaler'  (0.7007)\n",
    "```\n",
    "\n",
    "**Qué está mal**\n",
    "\n",
    "El mejor saca 0,7007 y el peor 0,6994. Trece diezmilésimas de diferencia, sobre una validación cruzada cuya desviación es **0,0165**, o sea doce veces más grande que la distancia entre el primero y el último 📉\n",
    "\n",
    "Ese ranking no mide escaladores: mide el azar del reparto. Si cambias la semilla, el ganador cambia. Y el problema no es perder trece diezmilésimas, es que ahora tienes una decisión escrita en el informe, defendida en una reunión y copiada al proyecto siguiente, sostenida por nada.\n",
    "\n",
    "La regla que uso: **si la diferencia no supera la desviación de la validación cruzada, no hay ganador**. Se elige por otra cosa, que aquí es cuál se explica mejor y cuál aguanta un valor raro en producción."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- 😐 Seis escaladores dan de 0,6994 a 0,7007. Trece diezmilésimas, cuando la\n",
    "desviación de la propia validación cruzada es 0,0165.\n",
    "\n",
    "- 🌳 Con un boosting, escalar da exactamente el mismo número hasta la cuarta\n",
    "cifra. Un árbol pregunta si una columna es mayor que un valor, y eso no cambia\n",
    "al multiplicarla por mil.\n",
    "\n",
    "- 💥 Pero con k vecinos el escalado vale 0,0748 de AUC y con SVM 0,0518. Esos\n",
    "miden distancias, y sin escalar la distancia entre dos clientes es casi\n",
    "enteramente la diferencia de monto.\n",
    "\n",
    "- 🕳️ Cuatro imputadores dan ocho diezmilésimas de diferencia, y el KNN, que\n",
    "es el caro, sale el último. La información del hueco ya está en la bandera.\n",
    "\n",
    "- 🔢 El ordinal cuesta 0,0091 contra el one-hot, porque se inventa un orden:\n",
    "le pone 0 a Abarrotes y 2 a Cuidado personal y el modelo lee que uno es el\n",
    "doble.\n",
    "\n",
    "- 💣 Sin `handle_unknown`, el día que abras en Cusco el modelo\n",
    "revienta. Con `ignore` no revienta y predice mal en silencio, así que\n",
    "hacen falta las dos cosas: ignore y una comprobación aparte.\n",
    "\n",
    "- 📉 Partir el monto en tramos PIERDE: 0,6997 sin tramos, 0,6734 con cuatro y\n",
    "0,6670 con diez. Los tramos se justifican con un hallazgo, no con una\n",
    "intuición.\n",
    "\n",
    "- 🧯 El MinMaxScaler con un monto mal tecleado aplasta las 2.250 filas\n",
    "restantes contra el cero. El RobustScaler ni se entera, y ese es el criterio de\n",
    "verdad para elegirlo.\n",
    "\n",
    "- 🚨 `ColumnTransformer` tira sin avisar toda columna que no le\n",
    "nombres, porque `remainder='drop'` es el valor por defecto. Se\n",
    "comprueba con `get_feature_names_out()`, que es una línea.\n",
    "\n",
    "- ⚖️ Dos de seis decisiones importan, y una importa en contra. Eso libera\n",
    "tiempo para las que sí mueven: construir columnas y elegir el umbral.\n",
    "\n",
    "Y si de todo el capítulo te llevas una sola frase, que sea esta:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Si la diferencia cabe dentro del ruido, no hay ganador. Hay una moneda."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "La desviación de la validación cruzada, y por qué una diferencia dentro de ella no significa nada, viene de la estadística: está en el [libro de estadística desde cero](https://missyera.com/guias/estadistica-desde-cero/) 📐"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Qué viene ahora"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ya sabemos qué columnas entran y cómo. Falta que todo eso se ejecute\n",
    "**igual** en entrenamiento, en examen y en producción, sin copiar y\n",
    "pegar 📦\n",
    "\n",
    "El capítulo 11 junta las piezas de este capítulo en un solo\n",
    "objeto que se entrena de una vez, se guarda de una vez y no deja que la\n",
    "preparación se te escape a los datos de examen.\n",
    "\n",
    "Lo que verás allí:\n",
    "\n",
    "- 🔗 El `ColumnTransformer` y el `Pipeline` montados\n",
    "sobre las decisiones que acabamos de tomar aquí.\n",
    "\n",
    "- 🧪 Por qué la preparación tiene que rehacerse **dentro** de\n",
    "cada partición de la validación cruzada, y qué pasa si no.\n",
    "\n",
    "- 📦 Un solo objeto que se guarda con `joblib`, se carga en\n",
    "producción y trae el preprocesamiento dentro.\n",
    "\n",
    "- 🏷️ Y la lista de nombres que salen del otro lado, que es la que vas a\n",
    "necesitar para explicar el modelo.\n",
    "\n",
    "Y ahí sale el número que va a valer para todo el resto del libro: casi diez\n",
    "puntos de AUC por encima del listón, y ninguno de ellos vino del algoritmo."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 10 de **Machine learning desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/machine-learning-desde-cero/decisiones-de-preprocessing/\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
}
