{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Cuando lo que importa pasa el 7% de las veces\n",
    "\n",
    "Pesos, umbral y remuestreo dan el mismo resultado, y ninguno crea información.\n",
    "\n",
    "Cuaderno de soluciones del capítulo 19 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/clases-raras/\n",
    "\n",
    "Este es el cuaderno de **soluciones**. Trae el código de cada ejercicio, la\n",
    "explicación de la trampa y la respuesta del quiz. Si vienes del cuaderno de\n",
    "práctica sin haberlo intentado, vuelve 🙂"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Antes de empezar: **¿cuántas veces has visto presumir de un porcentaje de acierto alto sin decir contra qué se comparaba?** Este capítulo va del caso donde esa cifra alta es directamente una mentira 🎭\n",
    "\n",
    "En el capítulo 14 dejamos un modelo tirado: el que predice si una venta va a\n",
    "cerrar por más de S/2.000. Tenía 92,27% de exactitud, un listón de 92,40% y\n",
    "encontraba **cero de las 57** ventas grandes 😱\n",
    "\n",
    "Hoy lo arreglamos. Y de paso vamos a ver que las tres técnicas que todo el\n",
    "mundo recomienda para esto hacen exactamente lo mismo, y que ninguna crea\n",
    "información."
   ]
  },
  {
   "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 (average_precision_score, confusion_matrix,\n",
    "                             precision_score, recall_score, 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['visita_numero'] = v.groupby('cliente_id').cumcount() + 1\n",
    "    return v\n",
    "\n",
    "# Sin monto ni precio_unitario: el objetivo se define con el monto, así que\n",
    "# dárselas sería copiarle la respuesta.\n",
    "NUMERICAS = ['unidades', 'descuento', 'satisfaccion', 'sin_compra_previa',\n",
    "             '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",
    "y = ((datos['compro'] == 1) & (datos['monto'] > 2000)).astype(int)\n",
    "X = datos[NUMERICAS + CATEGORICAS]\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",
    "print('positivos:', round(y.mean(), 4), '| en train:', int(y_tr.sum()),\n",
    "      '| en test:', int(y_te.sum()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "7,6% de positivos. 171 casos para entrenar y 57 para examinar. Eso ya es\n",
    "desbalance de verdad, y en fraude o en fallas de máquina te vas a encontrar 1%\n",
    "o menos."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El problema, una vez más"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def informe(m, nombre):\n",
    "    p = m.predict(X_te)\n",
    "    prob = m.predict_proba(X_te)[:, 1]\n",
    "    tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()\n",
    "    print(f'{nombre:24} encontró {tp:2d}/57   falsas alarmas {fp:3d}   '\n",
    "          f'recall {recall_score(y_te, p, zero_division=0):.4f}   '\n",
    "          f'precisión {precision_score(y_te, p, zero_division=0):.4f}   '\n",
    "          f'AUC {roc_auc_score(y_te, prob):.4f}')\n",
    "\n",
    "crudo = arma().fit(X_tr, y_tr)\n",
    "informe(crudo, 'sin hacer nada')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cero de 57. El modelo aprendió que decir \"no\" acierta el 92% de las veces y\n",
    "hace eso.\n",
    "\n",
    "No es que sea tonto: **es que le pedimos que maximice los aciertos y eso\n",
    "es lo que maximiza los aciertos**. El problema no está en el modelo,\n",
    "está en lo que le pedimos 🎯"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Arreglo 1: pesar las clases"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "La forma más limpia. Le decimos que equivocarse con un positivo cuesta más."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pesado = arma(LogisticRegression(max_iter=1000, random_state=42,\n",
    "                                 class_weight='balanced')).fit(X_tr, y_tr)\n",
    "informe(pesado, 'class_weight balanced')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**57 de 57** 🎉 Y 128 falsas alarmas, con una precisión de 0,308.\n",
    "\n",
    "O sea: encuentra absolutamente todas las ventas grandes, a cambio de que de\n",
    "cada tres avisos solo uno sea bueno.\n",
    "\n",
    "¿Eso es bueno? **Depende de lo que cueste cada cosa**, que es el\n",
    "capítulo 15. Si perseguir una venta grande cuesta poco y ganarla vale S/2.000,\n",
    "está regalado. Si cuesta una visita presencial a Chiclayo, no.\n",
    "\n",
    "`class_weight='balanced'` pone el peso de cada clase inversamente\n",
    "proporcional a cuántos casos tiene. Está en casi todos los modelos de\n",
    "scikit-learn y es una sola palabra 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Arreglo 2: mover el umbral"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y ahora lo mismo, sin tocar el modelo, con lo del capítulo 15."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "prob = crudo.predict_proba(X_te)[:, 1]\n",
    "for u in (0.5, 0.2, 0.1, 0.076):\n",
    "    p = (prob >= u).astype(int)\n",
    "    tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()\n",
    "    print(f'umbral {u:<6} encontró {tp:2d}/57   falsas alarmas {fp:3d}   '\n",
    "          f'precisión {precision_score(y_te, p, zero_division=0):.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Mira la última fila: umbral 0,076 (que es justo la tasa de positivos)\n",
    "encuentra las 57 con 128 falsas alarmas y precisión 0,3081.\n",
    "\n",
    "**Exactamente el mismo resultado que `class_weight`.**\n",
    "Hasta el último decimal.\n",
    "\n",
    "No es casualidad: pesar las clases y mover el umbral son **dos formas de\n",
    "escribir la misma decisión**. Cambian dónde está la raya, no lo que el\n",
    "modelo sabe 🎚️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Arreglo 3: remuestrear"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El tercero es el que más se recomienda en internet y el que menos me gusta:\n",
    "cambiar los datos para que haya la misma cantidad de cada clase."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "entrenamiento = X_tr.copy()\n",
    "entrenamiento['objetivo'] = y_tr.values\n",
    "\n",
    "positivos = entrenamiento[entrenamiento['objetivo'] == 1]\n",
    "negativos = entrenamiento[entrenamiento['objetivo'] == 0].sample(\n",
    "    n=len(positivos), random_state=42)\n",
    "balanceado = pd.concat([positivos, negativos]).sample(frac=1, random_state=42)\n",
    "\n",
    "print('de', len(entrenamiento), 'filas a', len(balanceado))\n",
    "\n",
    "submuestreo = arma().fit(balanceado[NUMERICAS + CATEGORICAS], balanceado['objetivo'])\n",
    "informe(submuestreo, 'submuestreo')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Otra vez 57 de 57 y 128 falsas alarmas. Lo mismo.\n",
    "\n",
    "Y fíjate en el precio que pagó: pasó de entrenar con 2.250 filas a hacerlo con\n",
    "342. **Tiró 1.908 filas de datos buenos**, y el AUC bajó de 0,92 a\n",
    "0,9052.\n",
    "\n",
    "El sobremuestreo (repetir los positivos hasta igualar) es lo contrario y\n",
    "tampoco regala nada:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "repetidos = pd.concat([positivos] * (len(entrenamiento[entrenamiento['objetivo'] == 0]) // len(positivos)))\n",
    "sobre = pd.concat([entrenamiento[entrenamiento['objetivo'] == 0], repetidos]).sample(frac=1, random_state=42)\n",
    "\n",
    "sobremuestreo = arma().fit(sobre[NUMERICAS + CATEGORICAS], sobre['objetivo'])\n",
    "informe(sobremuestreo, 'sobremuestreo')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "AUC 0,92, igual que el original. Lo único que hizo fue tardar más en\n",
    "entrenar."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La métrica que no se entera de que la clase es rara"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Antes de la conclusión hay que mirar una cosa más, y es la que explica por qué\n",
    "todos los arreglos de arriba parecían funcionar 📐\n",
    "\n",
    "Todo este capítulo ha ido reportando AUC, que es el ROC-AUC de siempre. Ahora\n",
    "mírala al lado de su prima, la que casi nadie usa:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('positivos en prueba:', int(y_te.sum()), 'de', len(y_te),\n",
    "      '=', f'{y_te.mean():.1%}')\n",
    "print()\n",
    "\n",
    "for nombre, mod in [('sin hacer nada', crudo), ('class_weight balanced', pesado)]:\n",
    "    pr = mod.predict_proba(X_te)[:, 1]\n",
    "    print(f'{nombre:<22} '\n",
    "          f'ROC-AUC {roc_auc_score(y_te, pr):.4f}   '\n",
    "          f'PR-AUC {average_precision_score(y_te, pr):.4f}')\n",
    "\n",
    "print()\n",
    "print('el listón del ROC-AUC:', 0.5)\n",
    "print('el listón del PR-AUC :', round(y_te.mean(), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "0,92 contra 0,37. El mismo modelo, la misma prueba, dos maneras de contar 😐\n",
    "\n",
    "Y el listón cambia igual de fuerte. Un ROC-AUC de 0,5 es tirar una moneda,\n",
    "así que 0,92 parece casi perfecto. Un PR-AUC de 0,076 es tirar una moneda,\n",
    "porque ese es el porcentaje de la clase rara, así que 0,37 es\n",
    "**cinco veces mejor que el azar**, que ya es otra frase.\n",
    "\n",
    "### Por qué se separan tanto\n",
    "\n",
    "El ROC-AUC mira las dos clases y aquí una de ellas tiene 693 casos. Acertar\n",
    "en esos 693 es fácil, y eso pesa en el número 🪶\n",
    "\n",
    "El PR-AUC solo mira la clase rara: de los que señalaste, cuántos eran de\n",
    "verdad, y de los que había, cuántos encontraste. Los 693 negativos que acertaste\n",
    "sin esfuerzo no le suman nada.\n",
    "\n",
    "Por eso, cuando la clase rara es el 7%, el ROC-AUC te va a dar siempre un\n",
    "número bonito y el PR-AUC te va a dar el número que se puede defender.\n",
    "\n",
    "### Y lo que confirma la conclusión del capítulo\n",
    "\n",
    "Fíjate en las dos filas: `class_weight='balanced'` deja el ROC-AUC\n",
    "**exactamente igual** y baja un poco el PR-AUC 🤨\n",
    "\n",
    "O sea que el arreglo más famoso para clases raras, medido con la métrica que\n",
    "sabe mirar clases raras, no mejora nada. Cambia dónde cae el corte, que es lo\n",
    "que ya vimos, pero no ordena mejor las probabilidades.\n",
    "\n",
    "Lo que hay que retener, y sirve para el resto del libro: **si la clase\n",
    "positiva es rara, se reporta PR-AUC, y se reporta con su listón al lado**.\n",
    "Un PR-AUC suelto no significa nada, porque su listón cambia con cada conjunto de\n",
    "datos 🎯"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La conclusión, que es incómoda y liberadora"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Mira los AUC de los cuatro: 0,9200 sin hacer nada, 0,9200 con pesos, 0,9052\n",
    "con submuestreo, 0,9200 con sobremuestreo.\n",
    "\n",
    "**Ninguna técnica de balanceo mejoró la capacidad del modelo para\n",
    "ordenar.** Lo único que hicieron fue mover el punto de corte, y eso ya\n",
    "sabíamos hacerlo desde el capítulo 15 con una línea.\n",
    "\n",
    "Lo digo claro porque se enseña al revés en muchos sitios:\n",
    "**balancear no crea información**. Si el modelo no sabe distinguir,\n",
    "duplicar filas no le va a enseñar nada nuevo.\n",
    "\n",
    "Lo que sí funciona con clases raras, en orden de rentabilidad:\n",
    "\n",
    "- 1️⃣ **Medir con la métrica correcta.** AUC PR y recall, nunca\n",
    "exactitud.\n",
    "\n",
    "- 2️⃣ **Elegir el umbral por costo.** Capítulo 15.\n",
    "\n",
    "- 3️⃣ **Conseguir más casos positivos**, aunque sea de otro\n",
    "periodo. Esto sí crea información.\n",
    "\n",
    "- 4️⃣ **Construir columnas mejores.** Capítulo 9.\n",
    "\n",
    "- 5️⃣ Y si acaso, `class_weight='balanced'`, que es gratis y no\n",
    "estorba.\n",
    "\n",
    "El SMOTE, que es la técnica que genera positivos sintéticos interpolando entre\n",
    "los que hay, vive en una librería aparte (`imbalanced-learn`) y no\n",
    "está instalada aquí. Merece un aviso: **inventa filas que no existieron**,\n",
    "y en datos de negocio con muchas categóricas suele generar combinaciones\n",
    "imposibles, tipo un mayorista de Cusco que compra por un canal que Cusco no\n",
    "tiene 🤨"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Siete. Intenta antes de abrir 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. El AUC PR, que es el que hay que mirar\n",
    "\n",
    "Compara AUC ROC y AUC PR de los cuatro modelos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for nombre, m in [('sin nada', crudo), ('pesado', pesado),\n",
    "                  ('submuestreo', submuestreo), ('sobremuestreo', sobremuestreo)]:\n",
    "    pr = m.predict_proba(X_te)[:, 1]\n",
    "    print(f'{nombre:14} ROC {roc_auc_score(y_te, pr):.4f}   '\n",
    "          f'PR {average_precision_score(y_te, pr):.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "sin nada       ROC 0.9200   PR 0.3741\n",
    "pesado         ROC 0.9200   PR 0.3685\n",
    "submuestreo    ROC 0.9052   PR 0.3711\n",
    "sobremuestreo  ROC 0.9200   PR 0.3704\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Los ROC casi idénticos y los PR también, entre 0,34 y 0,38.\n",
    "\n",
    "Y acuérdate de contra qué se compara el PR: la tasa base es 0,076, así que\n",
    "0,37 es **casi cinco veces mejor que el azar**. El ROC de 0,92 suena\n",
    "a casi perfecto y el PR pone el logro en su tamaño real 📐"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. El umbral por costo, aquí\n",
    "\n",
    "Una venta grande deja S/600 de margen y perseguirla cuesta\n",
    "S/40. ¿Dónde está el corte?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def ganancia(u):\n",
    "    p = (prob >= u).astype(int)\n",
    "    tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()\n",
    "    return tp * 600 - (tp + fp) * 40\n",
    "\n",
    "for u in (0.076, 0.15, 0.25, 0.4, 0.5):\n",
    "    print(f'umbral {u:<6} S/{ganancia(u):,}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "umbral 0.076  S/26,800\n",
    "umbral 0.15   S/23,320\n",
    "umbral 0.25   S/19,960\n",
    "umbral 0.4    S/5,080\n",
    "umbral 0.5    S/-40\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Con esos precios el máximo está abajo del todo: **perseguirlas todas\n",
    "es lo mejor**, porque cada acierto deja S/600 y cada intento cuesta S/40.\n",
    "El 0,5 de fábrica deja S/-40, o sea que se pierde plata.\n",
    "\n",
    "Y ahora súbele el costo de perseguir, que es lo que pasa si en vez de una\n",
    "llamada hay que ir a visitar:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def ganancia_con(costo, u):\n",
    "    p = (prob >= u).astype(int)\n",
    "    tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()\n",
    "    return tp * 600 - (tp + fp) * costo\n",
    "\n",
    "for costo in (40, 150, 250):\n",
    "    mejor = max((ganancia_con(costo, u), round(u, 2))\n",
    "                for u in np.arange(0.05, 0.95, 0.01))\n",
    "    print(f'perseguir cuesta S/{costo:3d}  ->  umbral {mejor[1]}   S/{mejor[0]:,}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "perseguir cuesta S/ 40  ->  umbral 0.09   S/26,840\n",
    "perseguir cuesta S/150  ->  umbral 0.25   S/7,200\n",
    "perseguir cuesta S/250  ->  umbral 0.36   S/1,150\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El umbral óptimo se mueve de 0,09 a 0,36 según lo que cueste perseguir.\n",
    "\n",
    "Ahí está la lección: **el balanceo perfecto (0,076) no es el óptimo de\n",
    "negocio**, y que coincidan en el primer caso es casualidad de esos\n",
    "números. El óptimo lo dan los soles, no la proporción de las clases 💰"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. El peso, a mano\n",
    "\n",
    "En vez de `'balanced'`, pon tú el peso y mira qué\n",
    "pasa."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for peso in (1, 3, 12, 50):\n",
    "    m = arma(LogisticRegression(max_iter=1000, random_state=42,\n",
    "                                class_weight={0: 1, 1: peso})).fit(X_tr, y_tr)\n",
    "    p = m.predict(X_te)\n",
    "    tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()\n",
    "    print(f'peso {peso:3d}  encontró {tp:2d}/57  falsas alarmas {fp:3d}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "peso   1  encontró  0/57  falsas alarmas   1\n",
    "peso   3  encontró 41/57  falsas alarmas  74\n",
    "peso  12  encontró 57/57  falsas alarmas 127\n",
    "peso  50  encontró 57/57  falsas alarmas 128\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El peso es otro dial exactamente igual que el umbral. Con 12 (que es más o\n",
    "menos 92/8, la proporción de las clases) se llega al mismo sitio que\n",
    "`'balanced'`.\n",
    "\n",
    "Tres formas de girar la misma perilla, y por eso conviene quedarse con la más\n",
    "simple: **el umbral**, porque se explica en una frase y se cambia sin\n",
    "reentrenar 🎚️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. Cuántos positivos hacen falta\n",
    "\n",
    "Entrena con 25, 50, 100 y los 171 positivos y mira el AUC."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "negs = entrenamiento[entrenamiento['objetivo'] == 0]\n",
    "for n in (25, 50, 100, 171):\n",
    "    trozo = pd.concat([positivos.head(n), negs])\n",
    "    m = arma(LogisticRegression(max_iter=1000, random_state=42,\n",
    "                                class_weight='balanced')).fit(\n",
    "        trozo[NUMERICAS + CATEGORICAS], trozo['objetivo'])\n",
    "    pr = m.predict_proba(X_te)[:, 1]\n",
    "    print(f'{n:3d} positivos  AUC {roc_auc_score(y_te, pr):.4f}  '\n",
    "          f'PR {average_precision_score(y_te, pr):.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    " 25 positivos  AUC 0.9113  PR 0.3313\n",
    " 50 positivos  AUC 0.9145  PR 0.3551\n",
    "100 positivos  AUC 0.9162  PR 0.3640\n",
    "171 positivos  AUC 0.9200  PR 0.3685\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "De 25 a 171 positivos el AUC sube y se estabiliza. **Eso sí es\n",
    "información nueva**, a diferencia de duplicar los que ya tienes.\n",
    "\n",
    "Es el argumento con el que se pide presupuesto: \"con el doble de casos\n",
    "positivos etiquetados, el modelo mejora esto\". Con una tabla 📈"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. Cuando la clase rara es rarísima\n",
    "\n",
    "Parte sin `stratify` con una clase de solo dos\n",
    "casos y mira qué te devuelve."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "y_rarisima = ((datos['compro'] == 1) & (datos['monto'] > 4000)).astype(int)\n",
    "print('positivos en total:', int(y_rarisima.sum()))\n",
    "\n",
    "a, b, c, d = train_test_split(X, y_rarisima, test_size=0.25, random_state=0)\n",
    "print('positivos en train:', int(c.sum()), '| en test:', int(d.sum()))\n",
    "print(roc_auc_score(d, arma().fit(a, c).predict_proba(b)[:, 1]))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "positivos en total: 2\n",
    "positivos en train: 2 | en test: 0\n",
    "nan\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`nan`. Los dos positivos cayeron en entrenamiento, el examen se\n",
    "quedó con una sola clase, y el AUC **no da error: devuelve nada**\n",
    "😐\n",
    "\n",
    "Otra vez el patrón del libro. Si ese `nan` se cuela en un promedio\n",
    "de validación cruzada, se lleva el resultado entero por delante y nadie se\n",
    "entera.\n",
    "\n",
    "Con estratificar tampoco se arregla si la clase es demasiado rara:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "y_unica = ((datos['compro'] == 1) & (datos['monto'] > 4236)).astype(int)\n",
    "print('positivos:', int(y_unica.sum()))\n",
    "train_test_split(X, y_unica, test_size=0.25, random_state=0, stratify=y_unica)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "ValueError: The least populated classes in y have only 1 member, which is too few. The minimum number of groups for any class cannot be less than 2. Classes with too few members are: [1]\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ese sí avisa, y avisa bien: con un solo caso positivo no se puede ni partir.\n",
    "**Con clases raras, `stratify=y` no es opcional**, y si\n",
    "ni con eso alcanza, es que no hay casos suficientes para modelar y eso hay que\n",
    "decirlo en vez de entregar un número 🚩"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. La matriz de las cuatro versiones\n",
    "\n",
    "Las cuatro matrices de confusión juntas, para verlo de un\n",
    "vistazo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for nombre, m in [('sin nada', crudo), ('pesado', pesado),\n",
    "                  ('submuestreo', submuestreo), ('sobremuestreo', sobremuestreo)]:\n",
    "    tn, fp, fn, tp = confusion_matrix(y_te, m.predict(X_te), labels=[0, 1]).ravel()\n",
    "    print(f'{nombre:14} tn {tn:3d}  fp {fp:3d}  fn {fn:2d}  tp {tp:2d}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "sin nada       tn 692  fp   1  fn 57  tp  0\n",
    "pesado         tn 565  fp 128  fn  0  tp 57\n",
    "submuestreo    tn 565  fp 128  fn  0  tp 57\n",
    "sobremuestreo  tn 566  fp 127  fn  0  tp 57\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Tres de las cuatro filas son idénticas. La primera es la del modelo que no\n",
    "encuentra nada.\n",
    "\n",
    "Esa tabla es la que yo llevaría a la reunión: **no hay que elegir entre\n",
    "tres técnicas, hay que elegir un punto de corte** 🎯"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 7. La receta para clases raras\n",
    "\n",
    "Escribe el flujo completo, del que puedas copiar en tu\n",
    "próximo proyecto."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "1. MIDE con AUC PR y recall. Nunca con exactitud.\n",
    "   El listón del PR es la tasa de positivos, no 0,5.\n",
    "\n",
    "2. ENTRENA normal, sin tocar los datos.\n",
    "   class_weight='balanced' si quieres, es gratis.\n",
    "\n",
    "3. ELIGE el umbral con los costos del negocio (capítulo 15),\n",
    "   en validación, no en el examen.\n",
    "\n",
    "4. REPORTA cuántos casos positivos había.\n",
    "   Con menos de 50 en el examen, cualquier métrica se mueve muchísimo.\n",
    "\n",
    "5. SI NO ALCANZA, pide más casos positivos.\n",
    "   Es lo único de esta lista que crea información.\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Fíjate en lo que no está: SMOTE, submuestreo y sobremuestreo. No es que estén\n",
    "prohibidos, es que en este problema no aportaron nada y añaden una pieza más que\n",
    "puede fallar.\n",
    "\n",
    "Si en el tuyo sí aportan, la forma de saberlo es medir con validación cruzada\n",
    "(capítulo 16) y comparar contra no hacer nada 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "Tu clase positiva es el 3% de los datos. ¿Qué haces primero?\n",
    "\n",
    "a) Cambio la métrica antes de tocar los datos\n",
    "\n",
    "b) Remuestreo hasta equilibrar las clases\n",
    "\n",
    "c) Genero datos sintéticos de la clase rara\n",
    "\n",
    "d) Uso un modelo más potente\n",
    "\n",
    "---\n",
    "\n",
    "**La correcta es la a.**\n",
    "\n",
    "*b)* Es lo primero que todo el mundo hace, y cambia las probabilidades que devuelve el modelo.\n",
    "\n",
    "*c)* Antes de inventar filas, comprueba si el problema es el modelo o la forma de medirlo.\n",
    "\n",
    "*d)* Un modelo más potente con la métrica equivocada sigue pareciendo bueno sin serlo.\n",
    "\n",
    "Con clases raras, PR-AUC dice mucho más que ROC-AUC."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Y el arreglo que se pasa de listo"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "Tienes 7,6% de positivos, así que remuestreas para equilibrar antes de entrenar. Es lo primero que sale al buscar \"clases desbalanceadas\" y sube el resultado una barbaridad.\n",
    "\n",
    "```\n",
    "X_bal, y_bal = SMOTE().fit_resample(X, y)\n",
    "\n",
    "X_tr, X_te, y_tr, y_te = train_test_split(\n",
    "    X_bal, y_bal, test_size=0.25)\n",
    "```\n",
    "\n",
    "**Qué está mal**\n",
    "\n",
    "SMOTE no copia filas: **se las inventa**, interpolando entre positivos que estaban cerca. Y como aquí se ejecuta antes de partir, las filas inventadas se reparten entre entrenamiento y examen 🧬\n",
    "\n",
    "O sea que en el examen hay filas fabricadas a partir de filas que el modelo vio al entrenar. No son las mismas, pero son sus vecinas, y el modelo las reconoce. El número sube muchísimo y no significa nada.\n",
    "\n",
    "El remuestreo se hace **solo sobre el trozo de entrenamiento** y siempre dentro del pipeline, para que la validación cruzada lo repita bien en cada vuelta. Y aun haciéndolo bien, en este capítulo se comprueba que no arregla gran cosa: mueve dónde cae el corte, que es lo que ya hacía el umbral."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- 😱 Con 7,6% de positivos, el modelo sin tocar encuentra 0 de 57.\n",
    "\n",
    "- ⚖️ `class_weight='balanced'` encuentra las 57, con 128 falsas\n",
    "alarmas y precisión 0,308.\n",
    "\n",
    "- 🎚️ Mover el umbral a 0,076 da *exactamente* el mismo resultado. Son la\n",
    "misma decisión escrita de dos formas.\n",
    "\n",
    "- 🚫 Balancear no crea información: los AUC son 0,9200, 0,9200, 0,9052 y\n",
    "0,9200.\n",
    "\n",
    "- ✂️ El submuestreo tiró 1.908 filas buenas y bajó el AUC.\n",
    "\n",
    "- 💰 El óptimo de negocio (umbral 0,15) no es el balanceo perfecto (0,076) ni el\n",
    "de fábrica (0,5).\n",
    "\n",
    "- 📈 Lo único que crea información es tener más casos positivos de verdad.\n",
    "\n",
    "Y si de todo el capítulo te llevas una sola frase, que sea esta:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Si la clase positiva es rara, el problema casi nunca es el modelo. Es la vara con la que lo mides."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El fraude, la morosidad y las averías son casi siempre clases raras. Si tu caso es de empresa, en [soluciones de IA](https://missyera.com/consultoria-ia/) cuento cómo se aterrizan 🏭\n",
    "\n",
    "En el capítulo 20 abrimos la caja: qué mira el modelo, cómo se explica un\n",
    "coeficiente y por qué la importancia que trae el bosque no es de fiar.\n",
    "\n",
    "Que tengas lindo día! 🌸"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 19 de **Machine learning desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/machine-learning-desde-cero/clases-raras/\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
}
