{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Validación cruzada\n",
    "\n",
    "Diez semillas dan cinco puntos y medio de diferencia, y ajustar hiperparámetros movió once diezmilésimas.\n",
    "\n",
    "Cuaderno de soluciones del capítulo 16 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/validacion-cruzada/\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": [
    "Llevamos nueve capítulos diciendo \"el AUC es 0,7214\" 📐\n",
    "\n",
    "Antes de mirar nada te pregunto: **¿cuánto crees que cambiaría ese número si hubiéramos partido los datos de otra manera?** Apuesta un número mentalmente, que en un minuto lo comprobamos 🎲\n",
    "\n",
    "Ese número salió de partir los datos una vez, con\n",
    "`random_state=42`. Hoy vamos a ver qué habría pasado con otra semilla,\n",
    "y la respuesta incomoda un poquito."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El punto de partida"
   ]
  },
  {
   "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.impute import SimpleImputer\n",
    "from sklearn.linear_model import LogisticRegression\n",
    "from sklearn.metrics import roc_auc_score\n",
    "from sklearn.model_selection import 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(clasificador=None):\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",
    "    if clasificador is None:\n",
    "        clasificador = LogisticRegression(max_iter=1000, random_state=42)\n",
    "    return Pipeline([('pre', pre), ('mod', clasificador)])\n",
    "\n",
    "datos = prepara(carga_limpia(URL))\n",
    "X = datos[NUMERICAS + CATEGORICAS]\n",
    "y = datos['compro']\n",
    "print(X.shape)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El mismo modelo, diez veces"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "aucs = []\n",
    "for semilla in range(10):\n",
    "    X_a, X_b, y_a, y_b = train_test_split(X, y, test_size=0.25,\n",
    "                                          random_state=semilla, stratify=y)\n",
    "    m = arma().fit(X_a, y_a)\n",
    "    aucs.append(roc_auc_score(y_b, m.predict_proba(X_b)[:, 1]))\n",
    "\n",
    "print(np.round(aucs, 4))\n",
    "print('mínimo', round(min(aucs), 4), '| máximo', round(max(aucs), 4),\n",
    "      '| media', round(np.mean(aucs), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "De **0,6870 a 0,7419**. Cinco puntos y medio de diferencia, y lo\n",
    "único que cambió fue qué filas cayeron en cada lado 😳\n",
    "\n",
    "Y aquí va la parte incómoda: nuestro 0,7214 de siempre está por encima de la\n",
    "media (0,7087). O sea que **el 42 nos tocó bueno**.\n",
    "\n",
    "Esto es lo que pasa cuando alguien prueba cinco semillas y reporta la mejor.\n",
    "No hace falta mala intención: basta con parar de probar cuando sale un número\n",
    "bonito 🎲"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Validación cruzada, que es la respuesta"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "err^=1K∑k=1Kerrk\n",
    "\n",
    "entrenas K veces dejando fuera un trozo distinto cada vez y promedias, para que la nota no dependa de qué filas cayeron en el test"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "En vez de partir una vez, se parte cinco: cada trozo hace de examen una vez y\n",
    "de entrenamiento cuatro. Así todas las filas se usan para las dos cosas y el\n",
    "resultado no depende de la suerte."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.model_selection import StratifiedKFold, cross_val_score\n",
    "\n",
    "particion = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)\n",
    "notas = cross_val_score(arma(), X, y, cv=particion, scoring='roc_auc')\n",
    "\n",
    "print(np.round(notas, 4))\n",
    "print('media:', round(notas.mean(), 4), '± desviación:', round(notas.std(), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**0,7056 ± 0,0133.** Ese es el número que hay que reportar, y con\n",
    "la desviación al lado.\n",
    "\n",
    "Y la desviación no es decoración: dice que **una diferencia de menos de\n",
    "un punto entre dos modelos no significa nada**. Si un modelo saca 0,706 y\n",
    "otro 0,712, están empatados 📐\n",
    "\n",
    "El `Stratified` importa: reparte manteniendo la proporción de la\n",
    "clase positiva en cada trozo. Sin él, con clases desbalanceadas, algún trozo se\n",
    "puede quedar casi sin positivos."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Los porqués del reparto\n",
    "\n",
    "La validación cruzada tiene cuatro decisiones y ninguna viene puesta por defecto de forma inocente:\n",
    "\n",
    "- 🍰 **Cinco trozos y no diez.** Más trozos dan una estimación algo mejor y cuestan el doble de tiempo. Con 3.000 filas, cinco llega de sobra; con 300 filas la respuesta sería otra.\n",
    "\n",
    "- ⚖️ **Estratificado.** Cada trozo mantiene el porcentaje de cada clase. Sin esto, con una clase rara, unos trozos se quedan casi sin positivos y la desviación que sale mide el reparto en vez del modelo.\n",
    "\n",
    "- 🔒 **El pipeline entero dentro.** Imputar, codificar y escalar aprenden de los datos, así que tienen que reaprenderse en cada vuelta con solo el trozo de entrenamiento de esa vuelta.\n",
    "\n",
    "- 🎲 **La semilla fija.** No para que salga mejor, sino para que mañana salga lo mismo y puedas comparar el modelo de hoy con el de la semana que viene."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La curva de aprendizaje"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Antes de tocar hiperparámetros, hay una pregunta que sale más barata:\n",
    "*¿me faltan datos o me falta modelo?*"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.model_selection import learning_curve\n",
    "\n",
    "tam, train, validacion = learning_curve(\n",
    "    arma(), X, y, train_sizes=[0.1, 0.3, 0.5, 0.75, 1.0],\n",
    "    cv=particion, scoring='roc_auc', n_jobs=-1)\n",
    "\n",
    "for n, a, b in zip(tam, train.mean(axis=1), validacion.mean(axis=1)):\n",
    "    print(f'{n:5d} filas   train {a:.4f}   validación {b:.4f}   hueco {a - b:.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esa tabla se lee así, y es una de las herramientas más infravaloradas que\n",
    "existen:\n",
    "\n",
    "- 📉 **El hueco se cierra**, de 0,1378 con 240 filas a 0,0167 con\n",
    "2.400. O sea que el sobreajuste desapareció al tener datos.\n",
    "\n",
    "- 📈 **La validación sigue subiendo**, aunque despacito: de 0,6672\n",
    "a 0,7056. Todavía no se aplanó del todo.\n",
    "\n",
    "La conclusión práctica: **más datos ayudarían un poquito, y cambiar de\n",
    "modelo casi nada**. Si la curva de validación se hubiera aplanado, más\n",
    "datos no servirían de nada y habría que buscar columnas nuevas.\n",
    "\n",
    "Es la diferencia entre pedir presupuesto para \"más datos\" y pedirlo para \"más\n",
    "tiempo de análisis\", y contestarla con una tabla en vez de con una opinión 💼"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Buscar hiperparámetros sin engañarte"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un hiperparámetro es una decisión tuya, no algo que el modelo aprenda. El\n",
    "`max_depth` del árbol, el `k` de los vecinos, el\n",
    "`C` de la logística.\n",
    "\n",
    "Probarlos a mano es lento y peligroso: si pruebas veinte y te quedas con el\n",
    "mejor *en el examen*, el examen dejó de ser examen."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.model_selection import GridSearchCV\n",
    "\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",
    "\n",
    "rejilla = GridSearchCV(arma(), {'mod__C': [0.01, 0.1, 1, 10, 100]},\n",
    "                       cv=particion, scoring='roc_auc', n_jobs=-1)\n",
    "rejilla.fit(X_tr, y_tr)\n",
    "\n",
    "print('mejor:', rejilla.best_params_)\n",
    "print('CV   :', round(rejilla.best_score_, 4))\n",
    "print('test :', round(roc_auc_score(y_te, rejilla.predict_proba(X_te)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Fíjate en el `mod__C` con doble guion bajo: es la forma de decirle\n",
    "\"el parámetro `C` del paso que se llama `mod`\". Con esa\n",
    "sintaxis puedes ajustar también la preparación, por ejemplo la estrategia del\n",
    "imputador.\n",
    "\n",
    "Y ahora mira los cinco resultados:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for p, s in zip(rejilla.cv_results_['params'], rejilla.cv_results_['mean_test_score']):\n",
    "    print(p, round(s, 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "De 0,6989 a 0,7000. **Once diezmilésimas entre el mejor y el peor**,\n",
    "cuando la desviación de la validación cruzada era 0,0133.\n",
    "\n",
    "Traducido: **ajustar la logística en este problema no sirvió de\n",
    "nada**. Y eso también es un resultado, y de los que ahorran semanas 😅\n",
    "\n",
    "Es lo contrario de lo que se suele contar. El orden de rentabilidad casi\n",
    "siempre es: primero limpiar (capítulo 4), después construir columnas (capítulo 9), después el umbral (capítulo 15), y solo al final los hiperparámetros."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Qué es ese C, que es Ridge y que es Lasso"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ltotal=L+λ∑jwj2(L2),Ltotal=L+λ∑j|wj|(L1)\n",
    "\n",
    "le sumas al error un castigo por tener pesos grandes, y la versión con valor absoluto además los empuja hasta cero, o sea que selecciona columnas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Llevamos dos capítulos ajustando `C` sin decir qué es. Es el freno\n",
    "de la regresión logística, y merece su propia sección porque es el\n",
    "hiperparámetro que más te vas a encontrar.\n",
    "\n",
    "Al entrenar, el modelo busca los pesos que mejor separan. Si lo dejas suelto,\n",
    "los estira todo lo que haga falta para acertar en entrenamiento, que es\n",
    "justamente memorizar. La **regularización** le añade a la cuenta un\n",
    "castigo por tener pesos grandes, y ahí hay dos formas de castigar:\n",
    "\n",
    "- 📏 **Ridge**, o penalización L2, castiga la suma de los pesos\n",
    "al cuadrado. Los encoge a todos y no anula ninguno. Es la que trae\n",
    "`LogisticRegression` puesta de fábrica.\n",
    "\n",
    "- ✂️ **Lasso**, o penalización L1, castiga la suma de los valores\n",
    "absolutos. Esta sí **pone pesos en cero**, o sea que elige columnas\n",
    "mientras entrena.\n",
    "\n",
    "Y `C` es el freno al revés: **C chico castiga más**.\n",
    "Es de las convenciones más incómodas de scikit-learn y no hay que darle vueltas,\n",
    "solo recordarla."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.linear_model import LogisticRegression\n",
    "\n",
    "print(f'{\"C\":<8}{\"Ridge\":<9}{\"Lasso\":<9}pesos en cero')\n",
    "for C in [0.001, 0.01, 0.1, 1.0, 10.0]:\n",
    "    ridge = arma(LogisticRegression(max_iter=1000, C=C, random_state=42)).fit(X_tr, y_tr)\n",
    "    lasso = arma(LogisticRegression(max_iter=1000, C=C, penalty='l1',\n",
    "                                    solver='liblinear', random_state=42)).fit(X_tr, y_tr)\n",
    "    ceros = (lasso.named_steps['mod'].coef_[0] == 0).sum()\n",
    "    print(f'{C:<8}'\n",
    "          f'{roc_auc_score(y_te, ridge.predict_proba(X_te)[:, 1]):<9.4f}'\n",
    "          f'{roc_auc_score(y_te, lasso.predict_proba(X_te)[:, 1]):<9.4f}'\n",
    "          f'{ceros} de 28')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Aquí hay tres cosas y las tres valen 👀\n",
    "\n",
    "**Ridge apenas se mueve**, de 0,7145 a 0,7217 en un rango de C\n",
    "que va de mil a una diezmilésima. Con trece columnas hay poco que frenar.\n",
    "\n",
    "**Lasso con C=0,001 pone los 28 pesos en cero** y saca 0,5000\n",
    "exacto, que es tirar una moneda. Es la demostración más limpia que conozco de\n",
    "qué hace el castigo: apretado del todo, el modelo se queda sin nada que decir.\n",
    "\n",
    "**Y con C=0,1, Lasso anula 14 de 28 pesos y saca 0,7229**, que\n",
    "es mejor que el modelo completo. La mitad de las columnas sobraba, y esto lo\n",
    "descubrió solo mientras entrenaba 🎉\n",
    "\n",
    "Cuando C sube, los ceros bajan: 24, luego 14, luego 6, luego 4. El freno se\n",
    "suelta y el modelo se permite usar más columnas.\n",
    "\n",
    "Miremos cuáles sobreviven cuando apretamos fuerte:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fuerte = arma(LogisticRegression(max_iter=1000, C=0.05, penalty='l1',\n",
    "                                 solver='liblinear', random_state=42)).fit(X_tr, y_tr)\n",
    "nombres = fuerte.named_steps['pre'].get_feature_names_out()\n",
    "pesos = fuerte.named_steps['mod'].coef_[0]\n",
    "\n",
    "for i in np.argsort(-np.abs(pesos))[:6]:\n",
    "    print(f'{nombres[i]:32} {pesos[i]:+.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Segmento, satisfacción, sin_compra_previa y canal están las cuatro ahí, que\n",
    "son exactamente **las cuatro que la importancia por permutación va a\n",
    "encontrar en el capítulo 20**, por un camino completamente distinto.\n",
    "\n",
    "Cuando dos métodos que no se parecen en nada te señalan las mismas columnas,\n",
    "eso ya no es casualidad 💪\n",
    "\n",
    "Se cuela el monto en cuarto lugar, que allá salía quinto y flojito. Y tiene\n",
    "explicación: Lasso mira el *tamaño del peso* y la permutación mide\n",
    "*cuánto AUC se pierde* al revolver la columna. No son lo mismo, y el\n",
    "monto es el caso donde se separan.\n",
    "\n",
    "Y para elegir entre las dos: **Ridge por defecto**, porque casi\n",
    "siempre va igual o mejor y no te quita nada. **Lasso cuando tienes muchas\n",
    "columnas y quieres que el modelo elija**, que es su gracia de verdad."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El bosque, con su rejilla"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.ensemble import RandomForestClassifier\n",
    "\n",
    "rejilla_bosque = GridSearchCV(\n",
    "    arma(RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1)),\n",
    "    {'mod__max_depth': [3, 5, 8, None], 'mod__min_samples_leaf': [1, 10, 30]},\n",
    "    cv=particion, scoring='roc_auc', n_jobs=-1)\n",
    "rejilla_bosque.fit(X_tr, y_tr)\n",
    "\n",
    "print('mejor:', rejilla_bosque.best_params_)\n",
    "print('CV   :', round(rejilla_bosque.best_score_, 4))\n",
    "print('test :', round(roc_auc_score(y_te, rejilla_bosque.predict_proba(X_te)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Doce combinaciones, y el mejor bosque saca 0,693 en validación cruzada contra\n",
    "el 0,7056 de la logística sin ajustar.\n",
    "\n",
    "**El modelo complicado, ajustado, sigue perdiendo contra el simple sin\n",
    "ajustar.** Es el capítulo 13 confirmado con el método correcto esta vez 🥇"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Siete. Intenta antes de abrir 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. Validación cruzada por cliente\n",
    "\n",
    "Del capítulo 12: repite la validación cruzada sin que un\n",
    "cliente caiga en los dos lados."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.model_selection import GroupKFold\n",
    "\n",
    "por_cliente = cross_val_score(arma(), X, y, groups=datos['cliente_id'],\n",
    "                              cv=GroupKFold(n_splits=5), scoring='roc_auc')\n",
    "print('por cliente:', np.round(por_cliente, 4), 'media', round(por_cliente.mean(), 4))\n",
    "print('normal    :', round(notas.mean(), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "por cliente: [0.7319 0.6732 0.7241 0.7283 0.6919] media 0.7099\n",
    "normal    : 0.7056\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "0,7099 contra 0,7056: **prácticamente lo mismo**, e incluso un\n",
    "poquito más alto.\n",
    "\n",
    "Y esto matiza el capítulo 12, donde con una sola partición la diferencia salía\n",
    "de casi cuatro puntos. Con cinco particiones, esa diferencia se disuelve:\n",
    "**era ruido de una partición concreta, no un efecto**.\n",
    "\n",
    "Me parece importante dejarlo escrito así, porque es exactamente el error que\n",
    "este capítulo enseña a no cometer 🎲"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. Cuántos trozos\n",
    "\n",
    "Compara 3, 5 y 10 particiones."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for k in (3, 5, 10):\n",
    "    n = cross_val_score(arma(), X, y, scoring='roc_auc',\n",
    "                        cv=StratifiedKFold(k, shuffle=True, random_state=42))\n",
    "    print(f'{k:2d} trozos  media {n.mean():.4f}  desviación {n.std():.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    " 3 trozos  media 0.7056  desviación 0.0085\n",
    " 5 trozos  media 0.7056  desviación 0.0133\n",
    "10 trozos  media 0.7081  desviación 0.0188\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Las medias se parecen y la desviación sube con más trozos, porque cada examen\n",
    "tiene menos filas y es más variable.\n",
    "\n",
    "Cinco es el estándar y está bien. Diez cuando tienes pocos datos y quieres\n",
    "entrenar con más en cada vuelta; tres cuando el modelo tarda mucho ⏱️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. Ajustar también la preparación\n",
    "\n",
    "Deja que la búsqueda elija cómo rellenar los huecos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "rej3 = GridSearchCV(\n",
    "    arma(),\n",
    "    {'pre__num__r__strategy': ['median', 'mean', 'most_frequent'],\n",
    "     'mod__C': [0.1, 1]},\n",
    "    cv=particion, scoring='roc_auc', n_jobs=-1)\n",
    "rej3.fit(X_tr, y_tr)\n",
    "print('mejor:', rej3.best_params_)\n",
    "print('CV   :', round(rej3.best_score_, 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "mejor: {'mod__C': 0.1, 'pre__num__r__strategy': 'most_frequent'}\n",
    "CV   : 0.7001\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ese `pre__num__r__strategy` se lee de fuera hacia dentro: el paso\n",
    "`pre`, dentro el bloque `num`, dentro el paso\n",
    "`r`, y su parámetro `strategy`.\n",
    "\n",
    "**Esto es lo que el pipeline del capítulo 11 hace posible.** Sin\n",
    "él, probar tres formas de imputar dentro de una validación cruzada honesta es\n",
    "imposible de escribir 🔧"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. Búsqueda al azar cuando la rejilla es grande\n",
    "\n",
    "Con muchos parámetros, probarlos todos no cabe. Prueba 10\n",
    "combinaciones al azar."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.model_selection import RandomizedSearchCV\n",
    "\n",
    "rej4 = RandomizedSearchCV(\n",
    "    arma(RandomForestClassifier(n_estimators=200, random_state=42, n_jobs=-1)),\n",
    "    {'mod__max_depth': [3, 5, 8, 12, None],\n",
    "     'mod__min_samples_leaf': [1, 5, 10, 20, 30],\n",
    "     'mod__max_features': ['sqrt', 'log2', 0.5]},\n",
    "    n_iter=10, cv=particion, scoring='roc_auc', random_state=42, n_jobs=-1)\n",
    "rej4.fit(X_tr, y_tr)\n",
    "print('probó 10 de', 5 * 5 * 3, 'combinaciones')\n",
    "print('mejor CV:', round(rej4.best_score_, 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "probó 10 de 75 combinaciones\n",
    "mejor CV: 0.692\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Diez de setenta y cinco, y llega casi al mismo sitio que la rejilla completa.\n",
    "Con muchos parámetros la búsqueda al azar es **mejor idea que la\n",
    "exhaustiva**, porque casi siempre solo unos pocos parámetros importan y al\n",
    "azar los explora más 🎲"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. El error de buscar y medir en el mismo sitio\n",
    "\n",
    "Compara el mejor resultado de la búsqueda con el resultado\n",
    "en el examen de verdad."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('lo que dice la búsqueda:', round(rejilla_bosque.best_score_, 4))\n",
    "print('lo que da en el examen :', round(roc_auc_score(y_te,\n",
    "      rejilla_bosque.predict_proba(X_te)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "lo que dice la búsqueda: 0.693\n",
    "lo que da en el examen : 0.711\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Aquí el examen sale *mejor* que la búsqueda (0,711 contra 0,693), y eso\n",
    "es normal: la búsqueda entrena con cuatro quintos de los datos y el modelo final\n",
    "con todos.\n",
    "\n",
    "Lo que hay que vigilar es el caso contrario. **Si el examen sale bastante\n",
    "peor que la búsqueda, probaste demasiadas combinaciones** y una de ellas\n",
    "acertó por casualidad. Con doce como aquí no pasa; con quinientas, sí 🎯"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. El error del doble guion bajo\n",
    "\n",
    "Pásale a la rejilla un parámetro escrito sin el prefijo del\n",
    "paso."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "GridSearchCV(arma(), {'C': [0.1, 1]}, cv=3).fit(X_tr, y_tr)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "ValueError: Invalid parameter 'C' for estimator Pipeline(steps=[('pre',\n",
    "                 ColumnTransformer(transformers=[('num',\n",
    "                                                  Pipeline(steps=[('r',\n",
    "                                                                   SimpleImputer(strategy='median')),\n",
    "                                                                  ('e',\n",
    "                                                                   StandardScaler())]),\n",
    "                                                  ['unidades', 'monto',\n",
    "                                                   'descuento', 'satisfaccion',\n",
    "                                                   'precio_unitario',\n",
    "                                                   'sin_compra_previa',\n",
    "                                                   'sin_descuento',\n",
    "                                                   'sin_satisfaccion',\n",
    "                                                   'visita_numero']),\n",
    "                                                 ('cat',\n",
    "                                                  Pipeline(steps=[('r',\n",
    "                                                                   SimpleImputer(strategy='most_frequent')),\n",
    "                                                                  ('c',\n",
    "                                                                   OneHotEncoder(handle_unknown='ignore'))]),\n",
    "                                                  ['ciudad', 'segmento',\n",
    "                                                   'canal', 'categoria'])])),\n",
    "                ('mod', LogisticRegression(max_iter=1000, random_state=42))]). Valid parameters are: ['memory', 'steps', 'transform_input', 'verbose'].\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y el mensaje es de los buenos: te dice que `C` no es un parámetro\n",
    "del pipeline y encima te lista los que sí valen.\n",
    "\n",
    "Cuando no te acuerdes de cómo se llama algo, `arma().get_params()`\n",
    "te devuelve la lista completa 🔑"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 7. El resultado final, escrito como se debe\n",
    "\n",
    "Junta todo lo del capítulo en el párrafo que iría en el\n",
    "informe."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "final = cross_val_score(arma(), X, y, cv=particion, scoring='roc_auc')\n",
    "print(f'AUC {final.mean():.4f} ± {final.std():.4f} '\n",
    "      f'(validación cruzada de 5, {len(X)} filas)')\n",
    "print(f'rango de las 5: {final.min():.4f} a {final.max():.4f}')\n",
    "print(f'una sola partición habría dado entre {min(aucs):.4f} y {max(aucs):.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "AUC 0.7056 ± 0.0133 (validación cruzada de 5, 3000 filas)\n",
    "rango de las 5: 0.6920 a 0.7291\n",
    "una sola partición habría dado entre 0.6870 y 0.7419\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esas tres líneas son lo que separa un número de un resultado.\n",
    "\n",
    "Cualquiera que lea eso sabe cuánto puede confiar, y sobre todo sabe que\n",
    "**una mejora de dos décimas no es una mejora**. Es la frase que más\n",
    "discusiones ahorra en una reunión de datos 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "El mismo modelo da AUC de 0,66 a 0,74 según cómo partas los datos. ¿Cuál reportas?\n",
    "\n",
    "a) El promedio de varias particiones, con su variación al lado\n",
    "\n",
    "b) El mejor, porque demuestra de lo que es capaz\n",
    "\n",
    "c) El peor, para ser conservador\n",
    "\n",
    "d) El de la partición con random_state=42\n",
    "\n",
    "---\n",
    "\n",
    "**La correcta es la a.**\n",
    "\n",
    "*b)* Ese es el que te va a dejar mal cuando alguien repita la medición.\n",
    "\n",
    "*c)* Es más honesto que el mejor, pero sigue siendo un solo sorteo.\n",
    "\n",
    "*d)* Ese número no tiene nada de especial: es otro sorteo más.\n",
    "\n",
    "Una sola partición es una sola tirada de dados."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El reparto que mide otra cosa"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "Validación cruzada de cinco trozos, la de siempre, sobre el problema de las ventas grandes que solo pasan el 7,6% de las veces.\n",
    "\n",
    "```\n",
    "kf = KFold(n_splits=5)\n",
    "scores = cross_val_score(modelo, X, y,\n",
    "                         cv=kf,\n",
    "                         scoring='roc_auc')\n",
    "```\n",
    "\n",
    "**Qué está mal**\n",
    "\n",
    "`KFold` reparte a ciegas, sin mirar la etiqueta. Con una clase que solo aparece el 7,6% de las veces, unos trozos se quedan con muchos positivos y otros con casi ninguno, así que cada nota mide un problema distinto 🎰\n",
    "\n",
    "El resultado es una desviación enorme que parece decir \"este modelo es inestable\", cuando lo inestable es el reparto. Y como la desviación es justo lo que usas para decidir si una diferencia importa, ahora todas las comparaciones te salen empatadas.\n",
    "\n",
    "Se arregla con `StratifiedKFold`, que reparte manteniendo el porcentaje de cada clase en cada trozo. Y ojo, que scikit-learn lo pone solo si le pasas `cv=5` a secas: en cuanto construyes el objeto a mano, la responsabilidad pasa a ser tuya."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- 🎲 Diez semillas dan de 0,6870 a 0,7419. Un solo `train_test_split`\n",
    "no es un resultado.\n",
    "\n",
    "- 📐 La validación cruzada da 0,7056 ± 0,0133, y la desviación se reporta\n",
    "siempre.\n",
    "\n",
    "- 📊 Una diferencia menor que la desviación no es una diferencia.\n",
    "\n",
    "- 📈 La curva de aprendizaje contesta si faltan datos o falta modelo. Aquí el\n",
    "hueco se cierra de 0,1377 a 0,0167 y la validación todavía sube.\n",
    "\n",
    "- 🔧 Ajustar la logística movió once diezmilésimas. Limpiar, construir columnas\n",
    "y elegir el umbral rinden mucho más.\n",
    "\n",
    "- 🥇 El bosque ajustado (0,693) sigue perdiendo contra la logística sin ajustar\n",
    "(0,7056).\n",
    "\n",
    "- 🔍 Con cinco particiones, la diferencia entre partir por fila y por cliente se\n",
    "disuelve: era ruido de una partición.\n",
    "\n",
    "Y si de todo el capítulo te llevas una sola frase, que sea esta:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un número sin su desviación al lado no es un resultado. Es una anécdota."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Desviación, intervalos y qué significa que dos números no se distingan: todo eso está en el [libro de estadística desde cero](https://missyera.com/guias/estadistica-desde-cero/) 📊\n",
    "\n",
    "En el capítulo 19 vamos a la clase rara: qué hacer cuando lo que te importa\n",
    "pasa el 5% de las veces.\n",
    "\n",
    "Que tengas lindo día! 🌸"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Preguntas frecuentes"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "¿Qué es la validación cruzada?Partir los datos en varios pedazos, entrenar con todos menos uno y medir con el que quedó fuera, rotando. Da una idea más honesta de cuánto acierta el modelo que una sola partición.\n",
    "\n",
    "¿Qué es una curva de aprendizaje?Un gráfico de cuánto acierta el modelo según cuántos datos le das. Sirve para contestar la pregunta que siempre llega: si conseguimos más datos, ¿mejora?\n",
    "\n",
    "¿Cuántos pliegues uso en la validación cruzada?Cinco o diez es lo habitual y la diferencia entre ambos es poca. Con pocos datos, más pliegues; con muchos, menos, porque cada uno cuesta un entrenamiento.\n",
    "\n",
    "¿Cuándo NO se puede usar validación cruzada normal?Cuando los datos tienen tiempo o grupos. Si mezclas fechas, el modelo aprende del futuro; si partes un mismo cliente entre entrenamiento y prueba, se copia a sí mismo.\n",
    "\n",
    "¿Qué es el overfitting o sobreajuste?Que el modelo se aprendió de memoria los datos de entrenamiento, ruido incluido, y por eso acierta muchísimo ahí y falla con datos nuevos. Las dos palabras son la misma cosa: overfitting en inglés, sobreajuste en castellano.\n",
    "\n",
    "¿Cómo se detecta el overfitting?Comparando el acierto en entrenamiento contra el de prueba. Si el primero es mucho mejor que el segundo, se memorizó. La validación cruzada existe para que esa comparación no dependa de una sola partición con suerte."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 16 de **Machine learning desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/machine-learning-desde-cero/validacion-cruzada/\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
}
