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