{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# XGBoost, LightGBM y Optuna\n",
    "\n",
    "Los tres boosting contra la regresión logística sobre las mismas 3.000 ventas, y sesenta pruebas de Optuna para ver si alcanzan.\n",
    "\n",
    "Cuaderno de soluciones del capítulo 18 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/boosting-y-tuning/\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": [
    "XGBoost, LightGBM y CatBoost. Los tres nombres que aparecen en todas las\n",
    "ofertas de trabajo y en todos los hilos de LinkedIn 🌟\n",
    "\n",
    "Este capítulo los pone a competir contra la regresión logística del capítulo\n",
    "13 sobre las mismas 3.000 ventas. Y te adelanto el final porque es el motivo por\n",
    "el que vale la pena leerlo: **los tres pierden**.\n",
    "\n",
    "Tranqui, que no es un capítulo para decirte que no los uses. Es para que\n",
    "sepas cuándo ganan, por qué aquí no, y cómo afinarlos cuando toque, que es la\n",
    "parte que casi nunca se cuenta 💜"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Qué hace un boosting por dentro"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un bosque aleatorio entrena cien árboles a la vez, cada uno con un trozo\n",
    "distinto de los datos, y después vota. Un boosting hace lo contrario: entrena\n",
    "los árboles **en fila**, y cada uno se dedica a arreglar lo que el\n",
    "anterior hizo mal."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Fm(x)=Fm−1(x)+νhm(x)\n",
    "\n",
    "el modelo de esta ronda es el de la ronda anterior más un árbol nuevo, y la letra nu es el freno que decide qué porción de ese árbol se suma"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esa letra griega, la nu, es la `learning_rate`. Es un freno: con\n",
    "0,1 solo se suma la décima parte de lo que ese árbol propone.\n",
    "\n",
    "Y lo que aprende cada árbol nuevo no es la respuesta, es el error que\n",
    "queda:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "hm≈−∂L∂Fm−1(xi)=yi−pi(con log-loss)\n",
    "\n",
    "cada árbol nuevo no aprende la respuesta sino el error que queda, y con log-loss ese error es la resta entre lo que pasó y lo que el modelo creía"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí está la idea entera. El primer árbol dice \"los mayoristas compran\". El\n",
    "segundo mira dónde se equivocó el primero y dice \"sí, pero los mayoristas de\n",
    "provincia que compran poco, no\". El tercero afina el resto. Y así trescientas\n",
    "veces 🎯\n",
    "\n",
    "Por eso un boosting es tan potente y por eso se pasa de rosca tan fácil: si\n",
    "lo dejas seguir, acaba memorizando las filas raras del entrenamiento una por\n",
    "una."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El punto de partida"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Misma preparación que en todo el libro, para que la comparación sea justa."
   ]
  },
  {
   "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, RandomForestClassifier\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 StratifiedKFold, cross_val_score, 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",
    "\n",
    "def arma(modelo):\n",
    "    \"\"\"El mismo preprocesado de siempre, con el modelo que le pases dentro.\"\"\"\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",
    "    return Pipeline([('pre', pre), ('mod', modelo)])\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",
    "print('train', X_tr.shape, 'test', X_te.shape)\n",
    "print('tasa de compra train', round(y_tr.mean(), 4), 'test', round(y_te.mean(), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los tres, de fábrica, contra la logística"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Sin tocar ni un parámetro. Tal como salen de la caja."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import xgboost as xgb\n",
    "import lightgbm as lgb\n",
    "\n",
    "def auc(modelo, nombre):\n",
    "    m = arma(modelo).fit(X_tr, y_tr)\n",
    "    a = roc_auc_score(y_te, m.predict_proba(X_te)[:, 1])\n",
    "    print(f'{nombre:24s} AUC test {a:.4f}')\n",
    "\n",
    "auc(LogisticRegression(max_iter=1000, random_state=42), 'logistica')\n",
    "auc(RandomForestClassifier(n_estimators=300, random_state=42), 'bosque')\n",
    "auc(HistGradientBoostingClassifier(random_state=42), 'HistGradientBoosting')\n",
    "auc(xgb.XGBClassifier(random_state=42, eval_metric='logloss'), 'xgboost')\n",
    "auc(lgb.LGBMClassifier(random_state=42, verbose=-1), 'lightgbm')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí lo tienes 😅\n",
    "\n",
    "La regresión logística, que es de 1958 y cabe en una fórmula de una línea, le\n",
    "saca puntos a las tres librerías que ganan competencias.\n",
    "\n",
    "Esto no es un accidente ni una casualidad de esta partición. Es lo que pasa\n",
    "casi siempre en un archivo de este tamaño, y tiene una explicación que no es\n",
    "\"los datos son malos\"."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Por qué pierde el boosting aquí"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Dos razones, y las dos se pueden medir.\n",
    "\n",
    "**La primera es el tamaño.** Son 2.250 filas de entrenamiento.\n",
    "Un boosting con trescientos árboles tiene sitio de sobra para aprenderse esas\n",
    "2.250 filas de memoria, y memorizar no es aprender.\n",
    "\n",
    "**La segunda es la forma de la señal.** En el libro de\n",
    "estadística salió que lo que separa a quien compra de quien no es sobre todo el\n",
    "segmento y el canal, y esos empujan en línea recta: ser Mayorista sube la\n",
    "probabilidad y ya está. Un boosting está hecho para encontrar cosas del tipo\n",
    "\"Mayorista sí, pero solo en la sierra, en el canal digital y con más de tres\n",
    "unidades\". Si esa clase de reglas no está en los datos, el boosting no tiene\n",
    "nada que buscar y lo único que le queda es memorizar 🔍\n",
    "\n",
    "La regresión logística no puede memorizar aunque quiera. Su límite es su\n",
    "defensa."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Afinar: qué es Optuna y por qué no es una rejilla"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Antes de rendirse hay que afinar, que es lo justo. Un LightGBM de fábrica es\n",
    "un LightGBM sin configurar.\n",
    "\n",
    "La forma antigua era `GridSearchCV`: le das cinco valores de cada\n",
    "parámetro y prueba todas las combinaciones. Con siete parámetros y cinco valores\n",
    "cada uno, eso son 78.125 entrenamientos, y encima probando valores que ya se\n",
    "sabía que iban mal.\n",
    "\n",
    "Optuna hace otra cosa. Prueba una combinación, mira el resultado, y la\n",
    "siguiente la elige **en función de lo que ya aprendió**. Si las\n",
    "tasas de aprendizaje bajas van bien, insiste por ahí y deja de gastar pruebas en\n",
    "las altas."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import optuna\n",
    "optuna.logging.set_verbosity(optuna.logging.WARNING)\n",
    "\n",
    "cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)\n",
    "\n",
    "def objetivo(t):\n",
    "    modelo = lgb.LGBMClassifier(\n",
    "        random_state=42, verbose=-1,\n",
    "        n_estimators=t.suggest_int('n_estimators', 50, 600),\n",
    "        learning_rate=t.suggest_float('learning_rate', 0.01, 0.3, log=True),\n",
    "        num_leaves=t.suggest_int('num_leaves', 4, 64),\n",
    "        min_child_samples=t.suggest_int('min_child_samples', 5, 200),\n",
    "        subsample=t.suggest_float('subsample', 0.5, 1.0),\n",
    "        subsample_freq=1,\n",
    "        colsample_bytree=t.suggest_float('colsample_bytree', 0.5, 1.0),\n",
    "        reg_lambda=t.suggest_float('reg_lambda', 1e-3, 30.0, log=True))\n",
    "    return cross_val_score(arma(modelo), X_tr, y_tr, cv=cv, scoring='roc_auc').mean()\n",
    "\n",
    "estudio = optuna.create_study(direction='maximize',\n",
    "                              sampler=optuna.samplers.TPESampler(seed=42))\n",
    "estudio.optimize(objetivo, n_trials=60)\n",
    "\n",
    "print('mejor CV', round(estudio.best_value, 4))\n",
    "for k, v in estudio.best_params.items():\n",
    "    print(' ', k, round(v, 4) if isinstance(v, float) else v)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Fíjate en dos cosas de ese código, porque las dos son la diferencia entre\n",
    "afinar bien y engañarse.\n",
    "\n",
    "**Uno: la función objetivo devuelve validación cruzada sobre\n",
    "train.** No toca el test ni una vez. Si el objetivo fuera el AUC de test,\n",
    "sesenta pruebas buscando el máximo lo encontrarían, y ese número ya no mediría\n",
    "nada.\n",
    "\n",
    "**Dos: `arma(modelo)` mete el preprocesado dentro.**\n",
    "Es lo del capítulo 11, y aquí importa el doble: cada una de las sesenta pruebas\n",
    "hace cinco particiones, o sea 300 entrenamientos, y en los 300 el imputador\n",
    "aprende su mediana solo del trozo que le toca."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Cuánto sube, y qué eligió"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "base = cross_val_score(arma(LogisticRegression(max_iter=1000, random_state=42)),\n",
    "                       X_tr, y_tr, cv=cv, scoring='roc_auc')\n",
    "bruto = cross_val_score(arma(lgb.LGBMClassifier(random_state=42, verbose=-1)),\n",
    "                        X_tr, y_tr, cv=cv, scoring='roc_auc')\n",
    "print(f'logistica        CV {base.mean():.4f} +/- {base.std():.4f}')\n",
    "print(f'lightgbm defecto CV {bruto.mean():.4f} +/- {bruto.std():.4f}')\n",
    "print(f'lightgbm afinado CV {estudio.best_value:.4f}')\n",
    "\n",
    "afinado = arma(lgb.LGBMClassifier(random_state=42, verbose=-1, subsample_freq=1,\n",
    "                                  **estudio.best_params)).fit(X_tr, y_tr)\n",
    "print('lightgbm afinado AUC test',\n",
    "      round(roc_auc_score(y_te, afinado.predict_proba(X_te)[:, 1]), 4))\n",
    "\n",
    "valores = [t.value for t in estudio.trials if t.value is not None]\n",
    "print('peor prueba', round(min(valores), 4), 'mejor', round(max(valores), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Afinar sirvió, y mucho: el LightGBM pasa de 0,66 a 0,70 en validación\n",
    "cruzada. Casi cuatro centésimas de AUC, que es muchísimo más de lo que sube\n",
    "cambiar de modelo.\n",
    "\n",
    "Pero mira lo que **eligió** la búsqueda, que es la parte\n",
    "interesante:\n",
    "\n",
    "- 🍃 `num_leaves` en 9, con 64 disponibles. Árboles diminutos.\n",
    "\n",
    "- 🧱 `min_child_samples` en 200, que era el tope. O sea: ninguna\n",
    "hoja con menos de 200 filas.\n",
    "\n",
    "- 🐌 `learning_rate` en 0,0134, casi el mínimo del rango.\n",
    "\n",
    "- 🎲 `colsample_bytree` en 0,57. Cada árbol ve poco más de la mitad\n",
    "de las columnas.\n",
    "\n",
    "Traducido: la búsqueda pasó sesenta pruebas descubriendo que la mejor forma\n",
    "de usar LightGBM en este archivo es **apagarlo casi del todo** 😂\n",
    "\n",
    "Eso no es un fracaso de Optuna. Es Optuna diciéndote algo del problema, y hay\n",
    "que saber escucharlo: cuando la búsqueda te pide simplificar sin parar, lo que\n",
    "te está diciendo es que no tienes datos suficientes para lo que estás pidiendo."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El parámetro que aterriza en el borde"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`min_child_samples` salió 200 y 200 era el tope que yo puse. Eso\n",
    "es una bandera roja de manual: la búsqueda quería seguir subiendo y se chocó\n",
    "contra una pared que puse yo, no el problema.\n",
    "\n",
    "Así que se amplía el rango y se vuelve a buscar. Siempre."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def objetivo_ancho(t):\n",
    "    modelo = lgb.LGBMClassifier(\n",
    "        random_state=42, verbose=-1,\n",
    "        n_estimators=t.suggest_int('n_estimators', 50, 600),\n",
    "        learning_rate=t.suggest_float('learning_rate', 0.01, 0.3, log=True),\n",
    "        num_leaves=t.suggest_int('num_leaves', 4, 64),\n",
    "        min_child_samples=t.suggest_int('min_child_samples', 5, 600),\n",
    "        subsample=t.suggest_float('subsample', 0.5, 1.0),\n",
    "        subsample_freq=1,\n",
    "        colsample_bytree=t.suggest_float('colsample_bytree', 0.5, 1.0),\n",
    "        reg_lambda=t.suggest_float('reg_lambda', 1e-3, 30.0, log=True))\n",
    "    return cross_val_score(arma(modelo), X_tr, y_tr, cv=cv, scoring='roc_auc').mean()\n",
    "\n",
    "ancho = optuna.create_study(direction='maximize',\n",
    "                            sampler=optuna.samplers.TPESampler(seed=42))\n",
    "ancho.optimize(objetivo_ancho, n_trials=60)\n",
    "print('mejor CV', round(ancho.best_value, 4))\n",
    "print('min_child_samples', ancho.best_params['min_child_samples'])\n",
    "print('num_leaves', ancho.best_params['num_leaves'])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Con sitio hasta 600, la búsqueda elige 276 y el CV se queda igual: 0,7013\n",
    "contra 0,7012. La pared existía pero no apretaba, porque de 200 para arriba la\n",
    "cosa está plana.\n",
    "\n",
    "Y esto también hay que decirlo tal cual: comprobar el borde costó treinta\n",
    "segundos y no cambió nada. Se comprueba igual, porque la vez que sí cambie algo\n",
    "no lleva un cartel avisando 🚩"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El freno y los árboles van juntos"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`learning_rate` y `n_estimators` no son dos parámetros\n",
    "independientes: son el mismo parámetro visto de dos formas."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "M·ν≈constante\n",
    "\n",
    "la tasa de aprendizaje y el número de árboles se compensan, así que bajar el freno a la mitad obliga a poner el doble de árboles para llegar al mismo sitio"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Si bajas el freno a la mitad, necesitas el doble de árboles para llegar al\n",
    "mismo sitio. Por eso afinar los dos por separado es perder pruebas.\n",
    "\n",
    "Lo que se hace en la práctica: fijar un `learning_rate` bajo, poner\n",
    "muchísimos árboles, y dejar que el modelo pare solo cuando deje de mejorar en un\n",
    "trozo de validación. Se llama *early stopping*."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "X_aj, X_val, y_aj, y_val = train_test_split(X_tr, y_tr, test_size=0.2,\n",
    "                                            random_state=42, stratify=y_tr)\n",
    "\n",
    "pre = arma(LogisticRegression()).named_steps['pre']\n",
    "A = pre.fit_transform(X_aj)\n",
    "V = pre.transform(X_val)\n",
    "T = pre.transform(X_te)\n",
    "\n",
    "parada = lgb.LGBMClassifier(random_state=42, verbose=-1,\n",
    "                            n_estimators=2000, learning_rate=0.05)\n",
    "parada.fit(A, y_aj, eval_X=V, eval_y=y_val, eval_metric='auc',\n",
    "           callbacks=[lgb.early_stopping(50, verbose=False)])\n",
    "print('pedidos 2000, usados', parada.best_iteration_)\n",
    "print('con parada  AUC test', round(roc_auc_score(y_te, parada.predict_proba(T)[:, 1]), 4))\n",
    "\n",
    "entero = lgb.LGBMClassifier(random_state=42, verbose=-1,\n",
    "                            n_estimators=2000, learning_rate=0.05).fit(A, y_aj)\n",
    "print('los 2000    AUC test', round(roc_auc_score(y_te, entero.predict_proba(T)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "De dos mil árboles pedidos, usa 68. Y los otros 1.932 no es que sobren: es\n",
    "que **hacen daño**, cinco centésimas y media de AUC.\n",
    "\n",
    "Ahí ves el boosting memorizando en directo. Cada árbol a partir del 68 está\n",
    "aprendiéndose filas concretas del entrenamiento que no se repiten en ningún otro\n",
    "sitio 📉\n",
    "\n",
    "Ojo con el preprocesado de este bloque, que va fuera del pipeline a\n",
    "propósito: `eval_X` necesita la matriz ya transformada. Es la única\n",
    "vez en todo el libro que preparo los datos por fuera, y por eso el imputador\n",
    "aprende solo de `X_aj` y nunca ve `X_val`.\n",
    "\n",
    "Si en tu versión de LightGBM eso da error, el nombre antiguo del parámetro es\n",
    "`eval_set=[(V, y_val)]`, con las dos matrices en una tupla."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Cuánta gente hace falta para que el boosting alcance"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Si la hipótesis es que faltan datos, se mide. Se entrena con trozos cada vez\n",
    "más grandes y se mira si la brecha se cierra."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "MEJOR = dict(n_estimators=439, learning_rate=0.0134, num_leaves=9,\n",
    "             min_child_samples=200, subsample=0.6737, subsample_freq=1,\n",
    "             colsample_bytree=0.5664, reg_lambda=0.0123)\n",
    "\n",
    "print(f\"{'filas':>6} {'logistica':>11} {'lightgbm':>10} {'brecha':>9}\")\n",
    "for n in (300, 600, 900, 1200, 1800, 2250):\n",
    "    la, lb = [], []\n",
    "    for semilla in range(6):\n",
    "        if n < len(X_tr):\n",
    "            Xs, _, ys, _ = train_test_split(X_tr, y_tr, train_size=n,\n",
    "                                            random_state=semilla, stratify=y_tr)\n",
    "        else:\n",
    "            Xs, ys = X_tr, y_tr\n",
    "        a = arma(LogisticRegression(max_iter=1000, random_state=42)).fit(Xs, ys)\n",
    "        b = arma(lgb.LGBMClassifier(random_state=42, verbose=-1, **MEJOR)).fit(Xs, ys)\n",
    "        la.append(roc_auc_score(y_te, a.predict_proba(X_te)[:, 1]))\n",
    "        lb.append(roc_auc_score(y_te, b.predict_proba(X_te)[:, 1]))\n",
    "        if n >= len(X_tr):\n",
    "            break\n",
    "    print(f'{n:6d} {np.mean(la):11.4f} {np.mean(lb):10.4f} {np.mean(la)-np.mean(lb):+9.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esta tabla es el capítulo entero en seis líneas 📊\n",
    "\n",
    "Con 300 filas el LightGBM da 0,5000 clavado, que es lo mismo que tirar una\n",
    "moneda. Y tiene sentido: le pedimos hojas de mínimo 200 filas, así que con 300\n",
    "apenas puede partir una vez. Los parámetros están afinados para 2.250.\n",
    "\n",
    "A partir de ahí la brecha se cierra sola y sin excepciones: 0,1887 → 0,0844 →\n",
    "0,0515 → 0,0283 → 0,0091. En 1.800 filas ya casi se tocan.\n",
    "\n",
    "La última fila sube un poco, a 0,0130, y no hay que taparlo: las cinco\n",
    "primeras son el promedio de seis submuestras distintas y la última es una sola\n",
    "medición, porque con 2.250 no hay nada que submuestrear. Se está comparando un\n",
    "promedio con un dato suelto.\n",
    "\n",
    "La tendencia es lo que importa, y la tendencia dice algo muy concreto: **a\n",
    "este archivo no le falta modelo, le faltan filas**. Con 10.000 ventas\n",
    "esta conversación sería otra."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Un error que vas a ver el primer 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",
    "    xgb.XGBClassifier(random_state=42, eval_metric='logloss').fit(\n",
    "        X_tr[['unidades', 'ciudad']], y_tr)\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: DataFrame.dtypes for data must be int, float, bool or category. When categorical type is supplied, the experimental DMatrix parameter`enable_categorical` must be set to `True`.  Invalid columns:ciudad: str\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "XGBoost no sabe qué hacer con la palabra \"lima\". Necesita números, y por eso\n",
    "todo el capítulo pasa por `arma()`, que trae el\n",
    "`OneHotEncoder` dentro.\n",
    "\n",
    "Es el error más común de quien viene de scikit-learn y prueba XGBoost por\n",
    "primera vez, y la solución no es convertir la columna a mano: es meter el modelo\n",
    "en el pipeline que ya tienes."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Entonces, ¿cuándo sí?"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El boosting gana, y gana claro, cuando se cumplen estas cosas:\n",
    "\n",
    "- 📈 **Hay filas.** Decenas de miles para arriba. La tabla de\n",
    "antes lo enseña: la brecha se cierra con datos, no con parámetros.\n",
    "\n",
    "- 🔀 **La señal tiene esquinas.** Interacciones de verdad, del\n",
    "tipo \"esto solo pasa cuando se juntan estas tres cosas\".\n",
    "\n",
    "- 🧮 **Hay muchas columnas.** Cuando pasas de cincuenta o cien,\n",
    "el boosting elige solo cuáles mirar y la logística se ahoga.\n",
    "\n",
    "- ⏱️ **Los datos son tabulares.** Si son imágenes, audio o texto,\n",
    "esto no es la herramienta; eso es el libro de deep learning.\n",
    "\n",
    "Y mi regla práctica, la que uso de verdad en proyectos: **la logística\n",
    "es el baseline y el boosting tiene que ganárselo**. Si no le gana por un\n",
    "margen que aguante la validación cruzada, se va la logística a producción, que\n",
    "se explica sola, se entrena en un segundo y no tiene siete parámetros que\n",
    "alguien tendrá que mantener 😌"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. XGBoost afinado, a ver si mejora al LightGBM\n",
    "\n",
    "Repite la búsqueda de Optuna con XGBoost en vez de LightGBM\n",
    "y compara. Los parámetros no se llaman igual."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def objetivo_xgb(t):\n",
    "    modelo = xgb.XGBClassifier(\n",
    "        random_state=42, eval_metric='logloss',\n",
    "        n_estimators=t.suggest_int('n_estimators', 50, 600),\n",
    "        learning_rate=t.suggest_float('learning_rate', 0.01, 0.3, log=True),\n",
    "        max_depth=t.suggest_int('max_depth', 2, 8),\n",
    "        min_child_weight=t.suggest_float('min_child_weight', 1, 50, log=True),\n",
    "        subsample=t.suggest_float('subsample', 0.5, 1.0),\n",
    "        colsample_bytree=t.suggest_float('colsample_bytree', 0.5, 1.0),\n",
    "        reg_lambda=t.suggest_float('reg_lambda', 1e-3, 30.0, log=True))\n",
    "    return cross_val_score(arma(modelo), X_tr, y_tr, cv=cv, scoring='roc_auc').mean()\n",
    "\n",
    "ex = optuna.create_study(direction='maximize',\n",
    "                         sampler=optuna.samplers.TPESampler(seed=42))\n",
    "ex.optimize(objetivo_xgb, n_trials=60)\n",
    "print('xgboost afinado CV', round(ex.best_value, 4))\n",
    "print('max_depth', ex.best_params['max_depth'])\n",
    "mx = arma(xgb.XGBClassifier(random_state=42, eval_metric='logloss',\n",
    "                            **ex.best_params)).fit(X_tr, y_tr)\n",
    "print('xgboost afinado AUC test',\n",
    "      round(roc_auc_score(y_te, mx.predict_proba(X_te)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "xgboost afinado CV 0.7023\n",
    "max_depth 2\n",
    "xgboost afinado AUC test 0.7061\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Misma película con otro actor. `max_depth` sale 2, o sea árboles de\n",
    "un solo corte: la búsqueda vuelve a pedir simplificar.\n",
    "\n",
    "Y aquí hay un matiz que merece la pena mirar despacio. En validación cruzada\n",
    "el XGBoost afinado saca 0,7023, que está **por encima** del 0,7012\n",
    "del LightGBM y también del 0,6997 de la logística. En test saca 0,7061 y vuelve\n",
    "a quedarse debajo del 0,7214.\n",
    "\n",
    "O sea que ganar en CV por 0,0026 no garantizó ganar en test. Esa distancia es\n",
    "más pequeña que la desviación de la propia CV, que era 0,0165: se está celebrando\n",
    "ruido 🎲\n",
    "\n",
    "Que las dos librerías, buscando por su cuenta y con parámetros que ni se\n",
    "llaman igual, terminen las dos pidiendo árboles diminutos es la mejor prueba de\n",
    "que el techo no es del algoritmo 🧱"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. Cuántas pruebas hacen falta de verdad\n",
    "\n",
    "Sesenta pruebas fue un número que puse yo. Mira en cuál\n",
    "apareció el mejor resultado y cuánto aportaron las siguientes."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "mejor = -1\n",
    "historia = []\n",
    "for i, t in enumerate(estudio.trials, 1):\n",
    "    if t.value is not None and t.value > mejor:\n",
    "        mejor = t.value\n",
    "        historia.append((i, round(mejor, 4)))\n",
    "print('cada vez que se batio el record:')\n",
    "for i, v in historia:\n",
    "    print(f'  prueba {i:2d}  CV {v}')\n",
    "print('records totales:', len(historia))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "cada vez que se batio el record:\n",
    "  prueba  1  CV 0.6696\n",
    "  prueba  3  CV 0.6974\n",
    "  prueba 12  CV 0.6984\n",
    "  prueba 16  CV 0.7003\n",
    "  prueba 17  CV 0.7006\n",
    "  prueba 28  CV 0.7012\n",
    "records totales: 6\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Seis récords en sesenta pruebas, y el último en la 28. Las 32 siguientes no\n",
    "mejoraron nada: la mitad del presupuesto sobró.\n",
    "\n",
    "Y fíjate en la 3, que ya llegó a 0,6974. Las 57 pruebas restantes sirvieron\n",
    "para arañar 0,0038 más. Casi todo lo que da una búsqueda lo da al principio 📉\n",
    "\n",
    "Con Optuna esto se automatiza con un *pruner*, que corta una prueba a\n",
    "medias cuando ya se ve que va peor que las anteriores. En archivos como este no\n",
    "hace falta, pero cuando cada entrenamiento tarda diez minutos, cambia el día ⏳"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. Afinar mirando el test, para ver cuánto engaña\n",
    "\n",
    "Haz justo lo que no hay que hacer: que la función objetivo\n",
    "devuelva el AUC de test. Después mide cuánto se infló."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def objetivo_trampa(t):\n",
    "    modelo = lgb.LGBMClassifier(\n",
    "        random_state=42, verbose=-1,\n",
    "        n_estimators=t.suggest_int('n_estimators', 50, 600),\n",
    "        learning_rate=t.suggest_float('learning_rate', 0.01, 0.3, log=True),\n",
    "        num_leaves=t.suggest_int('num_leaves', 4, 64),\n",
    "        min_child_samples=t.suggest_int('min_child_samples', 5, 200),\n",
    "        subsample=t.suggest_float('subsample', 0.5, 1.0),\n",
    "        subsample_freq=1,\n",
    "        colsample_bytree=t.suggest_float('colsample_bytree', 0.5, 1.0),\n",
    "        reg_lambda=t.suggest_float('reg_lambda', 1e-3, 30.0, log=True))\n",
    "    m = arma(modelo).fit(X_tr, y_tr)\n",
    "    return roc_auc_score(y_te, m.predict_proba(X_te)[:, 1])\n",
    "\n",
    "trampa = optuna.create_study(direction='maximize',\n",
    "                             sampler=optuna.samplers.TPESampler(seed=42))\n",
    "trampa.optimize(objetivo_trampa, n_trials=60)\n",
    "ganador = arma(lgb.LGBMClassifier(random_state=42, verbose=-1, subsample_freq=1,\n",
    "                                  **trampa.best_params))\n",
    "print('AUC test del ganador', round(trampa.best_value, 4))\n",
    "print('su CV honesta       ',\n",
    "      round(cross_val_score(ganador, X_tr, y_tr, cv=cv, scoring='roc_auc').mean(), 4))\n",
    "\n",
    "X_elige, X_final, y_elige, y_final = train_test_split(\n",
    "    X_te, y_te, test_size=0.5, random_state=7, stratify=y_te)\n",
    "ganador.fit(X_tr, y_tr)\n",
    "print('en la mitad que use para elegir',\n",
    "      round(roc_auc_score(y_elige, ganador.predict_proba(X_elige)[:, 1]), 4))\n",
    "print('en la mitad que nunca vio     ',\n",
    "      round(roc_auc_score(y_final, ganador.predict_proba(X_final)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "AUC test del ganador 0.7136\n",
    "su CV honesta        0.6991\n",
    "en la mitad que use para elegir 0.7142\n",
    "en la mitad que nunca vio      0.7115\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y aquí toca contar el resultado tal como salió, aunque no sea el que\n",
    "esperaba: la trampa apenas infló nada. 0,7142 en la mitad que usé para elegir\n",
    "contra 0,7115 en la que nunca vio. Veintisiete diezmilésimas.\n",
    "\n",
    "La lección no es \"entonces se puede afinar contra el test\". Es esta: con\n",
    "sesenta pruebas el sobreajuste fue pequeño, con seiscientas sería grande, y\n",
    "**desde dentro no hay forma de saber en cuál de los dos casos estás**.\n",
    "La única defensa es no mirar 🙈\n",
    "\n",
    "Fíjate además en la CV honesta del ganador: 0,6991, por debajo del 0,7012 que\n",
    "salió afinando bien. Optimizar el test le costó puntos en todo lo demás."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. Los mismos parámetros, otras cinco particiones\n",
    "\n",
    "El capítulo 16 dijo que una sola partición es una sola\n",
    "tirada de dados. Comprueba si la ventaja de la logística aguanta cambiando el\n",
    "sorteo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "filas = []\n",
    "for semilla in (0, 1, 7, 42, 2024):\n",
    "    Xa, Xb, ya, yb = train_test_split(X, y, test_size=0.25,\n",
    "                                      random_state=semilla, stratify=y)\n",
    "    log = arma(LogisticRegression(max_iter=1000, random_state=42)).fit(Xa, ya)\n",
    "    gbm = arma(lgb.LGBMClassifier(random_state=42, verbose=-1, **MEJOR)).fit(Xa, ya)\n",
    "    a = roc_auc_score(yb, log.predict_proba(Xb)[:, 1])\n",
    "    b = roc_auc_score(yb, gbm.predict_proba(Xb)[:, 1])\n",
    "    filas.append({'semilla': semilla, 'logistica': round(a, 4),\n",
    "                  'lightgbm': round(b, 4), 'gana': 'log' if a > b else 'gbm'})\n",
    "tabla = pd.DataFrame(filas)\n",
    "print(tabla.to_string(index=False))\n",
    "print('\\ngana la logistica en', (tabla['gana'] == 'log').sum(), 'de 5')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    " semilla  logistica  lightgbm gana\n",
    "       0     0.7105    0.7060  log\n",
    "       1     0.7091    0.7127  gbm\n",
    "       7     0.7124    0.7003  log\n",
    "      42     0.7214    0.7084  log\n",
    "    2024     0.6900    0.6899  log\n",
    "\n",
    "gana la logistica en 4 de 5\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un resultado que solo aparece con `random_state=42` no es un\n",
    "resultado. Este aguanta cuatro de cinco, y por eso me atrevo a escribirlo en un\n",
    "libro 💪\n",
    "\n",
    "Pero mira las dos que casi se caen. Con la semilla 1 gana el LightGBM, y con\n",
    "la 2024 la logística gana por 0,0001, que es empatar. Así que la frase correcta\n",
    "no es \"la logística gana\", es **\"la logística no pierde, y cuesta la\n",
    "centésima parte\"**.\n",
    "\n",
    "Esa diferencia de matiz es la que separa un informe que aguanta preguntas de\n",
    "uno que no 🎯"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. Qué mira cada uno\n",
    "\n",
    "La logística y el boosting sacan notas parecidas. Mira si\n",
    "además miran lo mismo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.inspection import permutation_importance\n",
    "\n",
    "log = arma(LogisticRegression(max_iter=1000, random_state=42)).fit(X_tr, y_tr)\n",
    "gbm = arma(lgb.LGBMClassifier(random_state=42, verbose=-1, **MEJOR)).fit(X_tr, y_tr)\n",
    "\n",
    "def top(modelo, nombre):\n",
    "    r = permutation_importance(modelo, X_te, y_te, n_repeats=10,\n",
    "                               random_state=42, scoring='roc_auc')\n",
    "    s = pd.Series(r.importances_mean, index=X_te.columns).sort_values(ascending=False)\n",
    "    print(f'--- {nombre} ---')\n",
    "    print(s.head(5).round(4).to_string())\n",
    "\n",
    "top(log, 'logistica')\n",
    "top(gbm, 'lightgbm afinado')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "--- logistica ---\n",
    "segmento             0.0909\n",
    "satisfaccion         0.0464\n",
    "sin_compra_previa    0.0361\n",
    "canal                0.0334\n",
    "monto                0.0067\n",
    "--- lightgbm afinado ---\n",
    "monto                0.0394\n",
    "satisfaccion         0.0360\n",
    "sin_compra_previa    0.0322\n",
    "canal                0.0234\n",
    "segmento             0.0232\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Aquí sale algo que yo no esperaba, y es de lo mejor del capítulo 👀\n",
    "\n",
    "Las dos notas se parecen (0,7214 contra 0,7084) pero **las dos listas\n",
    "no**. Para la logística lo primero es el segmento, con 0,0909, que es el\n",
    "doble que lo siguiente. Para el LightGBM lo primero es `monto`, con\n",
    "0,0394, y el segmento se le cae hasta el último puesto de los cinco.\n",
    "\n",
    "Y `monto`, que para el boosting es la columna número uno, para la\n",
    "logística vale 0,0067. Prácticamente nada.\n",
    "\n",
    "La explicación es la misma que salió en el capítulo 5 con la columna de\n",
    "ruido. `monto` es continua, así que un árbol puede trocearla donde\n",
    "quiera y sacarle escalones. La logística solo puede multiplicarla por un número,\n",
    "y en línea recta esa columna casi no dice nada 📐\n",
    "\n",
    "Mira además la forma de las dos listas. La logística tiene una columna que\n",
    "manda y las demás detrás; el LightGBM las tiene todas apretadas entre 0,0394 y\n",
    "0,0232, repartiendo la apuesta. Eso es exactamente lo que hace un modelo al que\n",
    "le falta señal clara: se agarra a todo un poquito.\n",
    "\n",
    "Dos modelos con notas parecidas apoyados en columnas distintas es un dato\n",
    "operativo, no una curiosidad: el día que `monto` llegue mal de origen,\n",
    "uno de los dos se cae y el otro ni se entera 🔧"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. Los dos juntos, a ver si suman\n",
    "\n",
    "Si miran cosas distintas, promediar sus probabilidades\n",
    "debería mejorar a los dos. Compruébalo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "p_log = log.predict_proba(X_te)[:, 1]\n",
    "p_gbm = gbm.predict_proba(X_te)[:, 1]\n",
    "\n",
    "print('correlacion entre las dos probabilidades',\n",
    "      round(np.corrcoef(p_log, p_gbm)[0, 1], 4))\n",
    "print('logistica ', round(roc_auc_score(y_te, p_log), 4))\n",
    "print('lightgbm  ', round(roc_auc_score(y_te, p_gbm), 4))\n",
    "for peso in (0.25, 0.5, 0.75):\n",
    "    mezcla = peso * p_log + (1 - peso) * p_gbm\n",
    "    print(f'mezcla {peso:.2f} log', round(roc_auc_score(y_te, mezcla), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "correlacion entre las dos probabilidades 0.9383\n",
    "logistica  0.7214\n",
    "lightgbm   0.7084\n",
    "mezcla 0.25 log 0.7147\n",
    "mezcla 0.50 log 0.7188\n",
    "mezcla 0.75 log 0.7207\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esto es un *ensemble*, y es lo que gana las competiciones de Kaggle:\n",
    "juntar modelos que se equivocan en sitios distintos.\n",
    "\n",
    "Aquí no funciona, y el motivo está en la primera línea: las dos\n",
    "probabilidades van correlacionadas al 0,9383. Miran columnas distintas, como\n",
    "acabamos de ver, pero acaban ordenando a la gente casi igual 🤝\n",
    "\n",
    "Ninguna mezcla llega al 0,7214 de la logística sola. La mejor es la que le da\n",
    "el 75% del peso a la logística y saca 0,7207, o sea que cuanto menos LightGBM\n",
    "lleva la mezcla, mejor va. Traducido: aquí el ensemble es una forma cara de\n",
    "diluir el mejor modelo.\n",
    "\n",
    "Y esa es la comprobación que hay que hacer siempre antes de montar uno.\n",
    "Correlación por debajo de 0,8 y la mezcla suele aportar; por encima de 0,9, casi\n",
    "nunca 📏"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 7. Lo que pesa cada modelo\n",
    "\n",
    "Todo el capítulo habla de puntos de AUC. Mide también lo\n",
    "que hay que cargar en el servidor cada vez que arranca."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import os\n",
    "import tempfile\n",
    "import joblib\n",
    "\n",
    "carpeta = tempfile.mkdtemp()\n",
    "\n",
    "def pesa(modelo, nombre):\n",
    "    m = arma(modelo).fit(X_tr, y_tr)\n",
    "    ruta = os.path.join(carpeta, nombre.replace(' ', '_') + '.joblib')\n",
    "    joblib.dump(m, ruta)\n",
    "    tam = os.path.getsize(ruta)\n",
    "    print(f'{nombre:20s} {tam:>8,d} bytes')\n",
    "    return tam\n",
    "\n",
    "chico = pesa(LogisticRegression(max_iter=1000, random_state=42), 'logistica')\n",
    "grande = pesa(lgb.LGBMClassifier(random_state=42, verbose=-1, **MEJOR), 'lightgbm afinado')\n",
    "print(f'\\nel boosting pesa {grande / chico:.0f} veces mas')\n",
    "print('arboles que lleva dentro:', MEJOR['n_estimators'])\n",
    "print('y la busqueda fueron 60 pruebas x 5 particiones =', 60 * 5, 'entrenamientos')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "logistica               5,642 bytes\n",
    "lightgbm afinado      349,119 bytes\n",
    "\n",
    "el boosting pesa 62 veces mas\n",
    "arboles que lleva dentro: 439\n",
    "y la busqueda fueron 60 pruebas x 5 particiones = 300 entrenamientos\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "439 árboles ocupan 349 KB. La regresión logística son unos cuantos números y\n",
    "un intercepto, y ocupa 5 KB.\n",
    "\n",
    "Sesenta y dos veces más peso para quedarse por debajo en AUC. Y esto no es\n",
    "una curiosidad de informática: ese archivo hay que versionarlo, guardarlo,\n",
    "cargarlo en cada arranque del servidor y volver a entrenarlo cada vez que los\n",
    "datos se muevan 💸\n",
    "\n",
    "Medí el tamaño y no el tiempo a propósito. Los segundos dependen de tu\n",
    "máquina y del resto de cosas que tengas abiertas, así que no se pueden publicar\n",
    "en un libro donde cada salida se comprueba; los bytes salen iguales siempre 📏"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "Afinaste LightGBM con Optuna y el mejor valor de min_child_samples salió 200, que era justo el tope del rango que le diste. ¿Qué haces?\n",
    "\n",
    "a) Ampliar el rango y volver a buscar, porque el tope lo pusiste tú\n",
    "\n",
    "b) Nada: 200 es el mejor valor, la búsqueda ya lo encontró\n",
    "\n",
    "c) Bajarlo a 100 para que el modelo tenga más libertad\n",
    "\n",
    "d) Cambiar de modelo, porque ese parámetro no debería importar tanto\n",
    "\n",
    "---\n",
    "\n",
    "**La correcta es la a.**\n",
    "\n",
    "*b)* La búsqueda encontró el mejor valor de los que le dejaste probar, que no es lo mismo.\n",
    "\n",
    "*c)* Vas en la dirección contraria a la que pidió la búsqueda.\n",
    "\n",
    "*d)* Antes de cambiar de modelo conviene saber qué te estaba diciendo esa búsqueda.\n",
    "\n",
    "Un parámetro que aterriza en el borde del rango es la búsqueda pidiéndote más sitio."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Doscientas veces mirando el examen"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "Optuna con doscientas pruebas para exprimir el LightGBM. Cada prueba se evalúa contra el conjunto de examen, que es el que dice la verdad.\n",
    "\n",
    "```\n",
    "def objetivo(trial):\n",
    "    p = sugiere(trial)\n",
    "    m = LGBMClassifier(**p).fit(X_tr, y_tr)\n",
    "    return evalua(m, X_te, y_te)\n",
    "\n",
    "estudio.optimize(objetivo, n_trials=200)\n",
    "```\n",
    "\n",
    "**Qué está mal**\n",
    "\n",
    "Doscientas veces mirando el examen para decidir. Con eso el examen ya no estima nada: encuentra la combinación que mejor le va **a esas filas concretas**, y buena parte de lo que sube es suerte de ese reparto 🎯\n",
    "\n",
    "Es la misma trampa del capítulo de los ocho modelos, pero multiplicada por doscientas. Y es peor porque aquí nadie la ve: no hay una tabla de ocho filas donde contar cuántas veces miraste, hay un bucle que lo hace solo mientras te tomas un café.\n",
    "\n",
    "Lo correcto es que el objetivo de Optuna sea **validación cruzada sobre el entrenamiento**, y que el examen se abra una sola vez, al final, con la combinación ya elegida. Si al abrirlo el número baja mucho respecto a lo que prometía la búsqueda, esa caída es la medida de cuánto te estabas engañando."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- 🧱 Un boosting entrena árboles en fila y cada uno arregla el error del\n",
    "anterior. Un bosque los entrena a la vez y vota. No es el mismo animal.\n",
    "\n",
    "- 🥊 De fábrica, los tres boosting pierden contra la regresión logística en\n",
    "este archivo: 0,6556, 0,6683 y 0,6799 contra 0,7214.\n",
    "\n",
    "- 🔧 Afinar con Optuna sube el LightGBM de 0,6613 a 0,7012 en validación\n",
    "cruzada. Casi cuatro centésimas, mucho más de lo que da cambiar de modelo.\n",
    "\n",
    "- 🔇 Y lo que eligió la búsqueda fue apagar el modelo: hojas de 200 filas\n",
    "mínimo, nueve hojas por árbol y la tasa de aprendizaje casi en el mínimo.\n",
    "\n",
    "- 🚩 `min_child_samples` aterrizó en el tope del rango, así que se\n",
    "amplió a 600. Eligió 276 y el CV no se movió: la pared no apretaba, pero eso no\n",
    "se sabe hasta comprobarlo.\n",
    "\n",
    "- 🛑 Early stopping usó 68 árboles de los 2.000 pedidos, y los 1.932 restantes\n",
    "no sobraban: costaban cinco centésimas y media de AUC.\n",
    "\n",
    "- 📉 La brecha se cierra sola con filas: 0,1887 con 300, 0,0091 con 1.800. A\n",
    "este archivo no le falta modelo, le faltan datos.\n",
    "\n",
    "- ⚖️ La logística es el baseline y el boosting tiene que ganárselo. Si no le\n",
    "gana por un margen que aguante la validación cruzada, va la logística.\n",
    "\n",
    "Y si de todo el capítulo te llevas una sola frase, que sea esta:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El modelo de moda no inventa señal. Solo tarda más en decirte que no la hay."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y si lo que quieres es ver dónde sí ganan los modelos grandes, con imágenes y texto en vez de tablas, eso es el [libro de deep learning desde cero](https://missyera.com/guias/deep-learning-desde-cero/) 🧠"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Qué viene ahora"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ya tenemos varios modelos y ya sabemos cuál va a producción. Falta la\n",
    "pregunta que va a hacer la primera persona que reciba la lista: **¿por qué\n",
    "este cliente y no el otro?** 🙋‍♀️\n",
    "\n",
    "El capítulo 20 abre la caja con lo que trae scikit-learn: coeficientes, odds\n",
    "e importancia por permutación. Y ahí aparece una columna que medimos en doce\n",
    "puntos en el capítulo 4 y que al modelo no le aporta nada, lo cual tiene una\n",
    "explicación bonita.\n",
    "\n",
    "Lo que verás allí:\n",
    "\n",
    "- 📖 Leer los coeficientes de una logística y decirlos en voz alta con\n",
    "`exp()`.\n",
    "\n",
    "- 🔀 Importancia por permutación, que funciona con cualquier modelo, también\n",
    "con los tres de este capítulo.\n",
    "\n",
    "- 🧩 Explicar una predicción concreta sumando aportes.\n",
    "\n",
    "- 👯 Y por qué dos columnas que dicen lo mismo salen las dos sin importancia,\n",
    "que es la trampa que hace que se descarten columnas buenas."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 18 de **Machine learning desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/machine-learning-desde-cero/boosting-y-tuning/\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
}
