{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Para quién funciona peor y cuánto puedes fiarte\n",
    "\n",
    "El modelo tiene 0,72 de AUC en general y menos de 0,69 dentro de cada segmento. Cómo se mide eso y cómo se dice \"no sé\".\n",
    "\n",
    "Cuaderno de soluciones del capítulo 24 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/sesgo-y-confianza/\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": [
    "Hasta aquí el modelo tiene un número: 0,7214 de AUC. Y un número solo se\n",
    "parece mucho a una nota del colegio, que te dice cómo te fue en promedio y no\n",
    "te dice en qué te equivocaste 📋\n",
    "\n",
    "Antes de seguir, piensa en tu caso: **¿a quién le puede estar fallando tu modelo sin que tú te enteres?** Casi siempre es al grupo del que menos datos tienes ⚖️\n",
    "\n",
    "Este capítulo son dos preguntas incómodas. La primera es **para quién\n",
    "funciona peor**. La segunda es **cuándo el modelo no sabe**,\n",
    "que es distinto de cuándo se equivoca.\n",
    "\n",
    "Y la primera nos va a dar una sorpresa que a mí me tomó un rato entender."
   ]
  },
  {
   "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 accuracy_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['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",
    "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",
    "\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",
    "\n",
    "modelo = Pipeline([('pre', pre),\n",
    "                   ('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit(X_tr, y_tr)\n",
    "prob = modelo.predict_proba(X_te)[:, 1]\n",
    "pred = (prob >= 0.5).astype(int)\n",
    "\n",
    "print('filas de prueba:', len(y_te))\n",
    "print('acierto:', round(accuracy_score(y_te, pred), 4))\n",
    "print('AUC    :', round(roc_auc_score(y_te, prob), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ese es el modelo del capítulo 11, tal cual. Ahora lo vamos a abrir por grupos."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo mismo, pero dentro de cada segmento"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Auditar un modelo es aburrido de explicar y fácil de hacer: mides lo mismo que\n",
    "ya mediste, pero una vez por grupo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def por_grupo(columna):\n",
    "    filas = []\n",
    "    for g in sorted(X_te[columna].unique()):\n",
    "        m = (X_te[columna] == g).values\n",
    "        filas.append({\n",
    "            columna: g,\n",
    "            'n': m.sum(),\n",
    "            'compra_real': round(y_te[m].mean(), 4),\n",
    "            'le_dice_si': round(pred[m].mean(), 4),\n",
    "            'acierto': round(accuracy_score(y_te[m], pred[m]), 4),\n",
    "            'encuentra': round(recall_score(y_te[m], pred[m]), 4),\n",
    "            'AUC': round(roc_auc_score(y_te[m], prob[m]), 4),\n",
    "        })\n",
    "    return pd.DataFrame(filas)\n",
    "\n",
    "print(por_grupo('segmento').to_string(index=False))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Léela con calma que hay tres cosas aquí dentro 👀\n",
    "\n",
    "**Uno.** El segmento donde el modelo tiene el mejor acierto es\n",
    "Mayorista. Y es el segmento donde tiene el peor AUC. Las dos cosas a la vez.\n",
    "\n",
    "Eso pasa porque el 76,96% de los Mayoristas de la prueba compran, y el modelo\n",
    "le dice que sí al 91,10% de ellos. Con esa mezcla acierta mucho *sin\n",
    "distinguir nada*. El acierto se lo regala la tasa base, no el modelo.\n",
    "\n",
    "**Dos.** Mira la columna `encuentra`. De las Bodegas\n",
    "que sí compraron, el modelo encuentra el 40,68%. De los Mayoristas que sí\n",
    "compraron, encuentra el 92,52%. **El mismo modelo, el mismo umbral, y una\n",
    "diferencia de 51 puntos** según de qué segmento seas.\n",
    "\n",
    "**Tres.** Ningún AUC por segmento llega a 0,7214, que es el AUC\n",
    "global. Ninguno. Y eso al principio me pareció un error mío."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La sorpresa: el modelo es peor por dentro que por fuera"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "No es un error. Es que el AUC global mide si el modelo ordena bien\n",
    "**todas** las filas juntas, y ahí hay un atajo enorme: los\n",
    "Mayoristas compran el 72,40% de las veces y las Bodegas el 37,48%."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(datos.groupby('segmento')['compro'].agg(['size', 'mean']).round(4).to_string())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "O sea que buena parte de lo que el modelo hace bien es **poner a los\n",
    "Mayoristas arriba y a las Bodegas abajo**. Eso es útil y es real. Pero\n",
    "cuando el gerente de la zona te pide a quién llamar *entre sus Bodegas*,\n",
    "ese atajo ya no sirve: todas son Bodegas.\n",
    "\n",
    "Y ahí el modelo vale lo que valga por dentro del grupo, que es menos.\n",
    "\n",
    "Antes de contarlo por ahí conviene medir cuánto de esto es real y cuánto es\n",
    "la lotería de la partición, que es la lección del capítulo 16 aplicada a los\n",
    "grupos:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "por_semilla = {}\n",
    "for s in range(10):\n",
    "    A_tr, A_te, b_tr, b_te = train_test_split(X, y, test_size=0.25,\n",
    "                                              random_state=s, stratify=y)\n",
    "    m = Pipeline([('pre', pre),\n",
    "                  ('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit(A_tr, b_tr)\n",
    "    p = m.predict_proba(A_te)[:, 1]\n",
    "    por_semilla.setdefault('TODO', []).append(roc_auc_score(b_te, p))\n",
    "    for g in sorted(A_te['segmento'].unique()):\n",
    "        msk = (A_te['segmento'] == g).values\n",
    "        por_semilla.setdefault(g, []).append(roc_auc_score(b_te[msk], p[msk]))\n",
    "\n",
    "for g, v in por_semilla.items():\n",
    "    v = np.array(v)\n",
    "    print(f'{g:11} media={v.mean():.4f}  min={v.min():.4f}  max={v.max():.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Aquí hay dos noticias.\n",
    "\n",
    "La buena: el hallazgo aguanta. En diez particiones distintas el AUC global\n",
    "promedia 0,7087 y la mejor media por segmento es 0,6835. **El modelo es\n",
    "peor dentro de cada grupo que en el total**, y no era cosa de la semilla\n",
    "42 💡\n",
    "\n",
    "La mala: el 0,5625 de Mayorista que salió arriba era una partición con mala\n",
    "suerte. En diez semillas ese segmento promedia 0,6479 y su peor caso es 0,5901.\n",
    "Sigue siendo el peor de los cuatro, pero no es el desastre que sugería el número\n",
    "suelto.\n",
    "\n",
    "Por eso una tabla por grupos **nunca** se entrega con una sola\n",
    "partición. Cada grupo tiene ahí unas 190 filas y con 190 filas los números\n",
    "bailan 🎲"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El mismo umbral no trata igual a todos"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Volvamos a los 51 puntos de diferencia en `encuentra`. Eso no viene\n",
    "de que el modelo odie a las bodegas: viene de que **0,5 es un corte y las\n",
    "bodegas tienen las probabilidades más bajas**, así que quedan casi todas\n",
    "del lado del no.\n",
    "\n",
    "Si lo que el negocio quiere es encontrar al 80% de los que van a comprar en\n",
    "cada segmento, el corte tiene que ser distinto en cada uno:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for g in sorted(X_te['segmento'].unique()):\n",
    "    m = (X_te['segmento'] == g).values\n",
    "    elegido = 0.5\n",
    "    for u in np.arange(0.05, 0.96, 0.01):\n",
    "        if recall_score(y_te[m], (prob[m] >= u).astype(int)) >= 0.80:\n",
    "            elegido = u\n",
    "    r = recall_score(y_te[m], (prob[m] >= elegido).astype(int))\n",
    "    print(f'{g:11} umbral={elegido:.2f}  encuentra={r:.4f}  '\n",
    "          f'con 0.5 encontraba={recall_score(y_te[m], pred[m]):.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Para las Bodegas hay que bajar el corte a 0,34 y para los Mayoristas subirlo a\n",
    "0,60. Con eso los cuatro segmentos quedan parejos en el 80%.\n",
    "\n",
    "Ahora la parte que no es técnica. **Emparejar tiene un precio**:\n",
    "bajar el corte de las bodegas significa visitar bodegas que no van a comprar, y\n",
    "subir el de los mayoristas significa dejar de visitar mayoristas que sí habrían\n",
    "comprado. Si lo único que te importa es el total de ventas, el umbral único gana.\n",
    "\n",
    "Si te importa que tu fuerza de ventas no abandone un segmento entero porque el\n",
    "modelo casi nunca lo nombra, entonces emparejar es lo correcto aunque cueste.\n",
    "\n",
    "Eso lo decide la persona que responde por el negocio, no el modelo. Lo tuyo es\n",
    "poner la tabla sobre la mesa para que la decisión se tome sabiendo 🤝"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## ¿Un 0,7 es de verdad un 70%?"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Una probabilidad está bien calibrada si de cada 100 filas a las que les pones\n",
    "0,7, compran unas 70. Suena obvio y casi ningún modelo lo cumple.\n",
    "\n",
    "Se comprueba partiendo las predicciones en diez montones y comparando lo que\n",
    "prometió con lo que pasó:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "tabla = pd.DataFrame({'prob': prob, 'real': y_te.values})\n",
    "tabla['monton'] = pd.qcut(tabla['prob'], 10, labels=False)\n",
    "print(tabla.groupby('monton').agg(n=('real', 'size'),\n",
    "                                  prometio=('prob', 'mean'),\n",
    "                                  paso=('real', 'mean')).round(4).to_string())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Las dos puntas casi clavan: el montón más bajo prometía 0,2442 y compró el\n",
    "21,33%, y el más alto prometía 0,8451 y compró el 81,33%. La regresión logística\n",
    "sale bastante calibrada de fábrica porque es lo que optimiza mientras entrena 🎯\n",
    "\n",
    "En el medio se mueve más. El montón 7 prometía 0,7184 y compró el 80,00%, ocho\n",
    "puntos por encima. Tampoco te alarmes: son 75 filas por montón, y con 75 filas\n",
    "ocho puntos son cuatro personas que compraron de más 🤏\n",
    "\n",
    "Pero por grupos vuelve a pasar lo de siempre:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for g in sorted(X_te['segmento'].unique()):\n",
    "    m = (X_te['segmento'] == g).values\n",
    "    print(f'{g:11} prometio={prob[m].mean():.4f}  paso={y_te[m].mean():.4f}  '\n",
    "          f'brecha={prob[m].mean() - y_te[m].mean():+.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "A los Mayoristas les promete 7 puntos menos de lo que pasa y a las Bodegas les\n",
    "promete 5 puntos de más. Está bien calibrado en promedio porque los errores se\n",
    "cancelan entre grupos, que es exactamente la trampa que tiene mirar solo el\n",
    "promedio 🙃"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Que el modelo diga cuándo no sabe"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "q=cuantil1−α({si}i∈calibración)\n",
    "\n",
    "guardas un trozo de datos aparte, mides ahí lo mal que se porta el modelo y usas ese cuantil como frontera, que es lo que hace que la cobertura prometida se cumpla de verdad"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un modelo siempre contesta. Le des la fila que le des, escupe un número entre\n",
    "0 y 1 y se queda tan tranquilo. No tiene forma de decir \"esta no la sé\".\n",
    "\n",
    "Sí la tiene, y es una idea preciosa que se llama **predicción\n",
    "conforme**. La versión más simple cabe en cinco líneas y funciona así:\n",
    "\n",
    "- 🧪 Apartas un trozo de datos que el modelo *no* vio, la calibración.\n",
    "\n",
    "- 📏 En ese trozo mides, para cada fila, cuánta probabilidad le dio a la\n",
    "respuesta que resultó ser la correcta.\n",
    "\n",
    "- ✂️ De esos errores te quedas con el percentil que corresponde a la confianza\n",
    "que quieres.\n",
    "\n",
    "- 📦 Para una fila nueva devuelves *todas* las respuestas que superan ese\n",
    "listón, que pueden ser una, las dos, o ninguna.\n",
    "\n",
    "Cuando devuelve las dos, el modelo te está diciendo que no sabe."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "X_ent, X_cal, y_ent, y_cal = train_test_split(X_tr, y_tr, test_size=0.3,\n",
    "                                              random_state=42, stratify=y_tr)\n",
    "conf = Pipeline([('pre', pre),\n",
    "                 ('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit(X_ent, y_ent)\n",
    "\n",
    "p_cal = conf.predict_proba(X_cal)\n",
    "fallo = 1 - p_cal[np.arange(len(y_cal)), y_cal.values]\n",
    "\n",
    "alpha = 0.20\n",
    "n = len(fallo)\n",
    "k = int(np.ceil((n + 1) * (1 - alpha)))\n",
    "listero = np.sort(fallo)[k - 1]\n",
    "print('filas de calibracion:', n)\n",
    "print('listón:', round(1 - listero, 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ese `1 - listero` es el listón: cualquier clase a la que el modelo\n",
    "le dé al menos esa probabilidad entra en la respuesta.\n",
    "\n",
    "El `(n + 1)` y el `ceil` no son adorno. Son la corrección\n",
    "que hace que la garantía valga con las filas que tienes y no solo en el infinito."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "p_te = conf.predict_proba(X_te)\n",
    "conjunto = p_te >= 1 - listero\n",
    "tam = conjunto.sum(axis=1)\n",
    "cubre = conjunto[np.arange(len(y_te)), y_te.values]\n",
    "\n",
    "print('prometimos cubrir:', 1 - alpha)\n",
    "print('cubrimos         :', round(cubre.mean(), 4))\n",
    "print()\n",
    "print('una sola respuesta:', (tam == 1).sum())\n",
    "print('las dos (no sé)   :', (tam == 2).sum())\n",
    "print('ninguna           :', (tam == 0).sum())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Prometimos cubrir el 80% de los casos y cubrimos el 82,40%. Eso no es suerte:\n",
    "es lo que la predicción conforme garantiza, sin pedirle nada al modelo ni a los\n",
    "datos más que haber apartado bien la calibración 🪄\n",
    "\n",
    "Y ahora lo que hace que esto valga la pena de verdad:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for t in [1, 2]:\n",
    "    m = tam == t\n",
    "    print(f'conjunto de {t}: n={m.sum():3d}  '\n",
    "          f'acierta={accuracy_score(y_te[m], pred[m]):.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "En las 500 filas donde el modelo se moja acierta el 73,60%. En las 250 donde\n",
    "dice \"no sé\" acierta el 54,80%, que es tirar una moneda.\n",
    "\n",
    "**El modelo sabe cuándo no sabe.** Solo había que dejarlo\n",
    "decirlo.\n",
    "\n",
    "Y esto cambia cómo se usa: esas 250 filas no se le mandan al vendedor con una\n",
    "probabilidad al lado como si fuera información. Se le mandan sin recomendación,\n",
    "o no se le mandan."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La garantía es del total, no de cada grupo"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un detalle honesto que casi nadie cuenta. La cobertura del 80% está garantizada\n",
    "**en promedio sobre todas las filas**. Por grupo, no."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for g in sorted(X_te['segmento'].unique()):\n",
    "    m = (X_te['segmento'] == g).values\n",
    "    print(f'{g:11} cubre={cubre[m].mean():.4f}  no_sé={(tam[m] == 2).mean():.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "A las Bodegas les cubre 87,36% y a los Mayoristas 79,58%. El promedio cuadra y\n",
    "los grupos se mueven, otra vez.\n",
    "\n",
    "Mira también la columna `no_sé`: con los Mayoristas el modelo duda\n",
    "el 17,80% de las veces y con las Bodegas el 40,80%. O sea que **es el grupo\n",
    "donde menos duda y el grupo donde peor ordena**. Seguridad y acierto no\n",
    "son lo mismo, ni en los modelos ni en las personas 😅\n",
    "\n",
    "Intentar arreglarlo se llama cobertura condicional por grupo y se hace con un\n",
    "listón por grupo, calculado con las filas de calibración de ese grupo. Es el\n",
    "ejercicio 5, y ahí vas a ver que con estos datos todavía no alcanza."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que no se puede pedir menos de lo que se tiene"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Una última cosa antes de los ejercicios. Prueba a pedir una confianza altísima:"
   ]
  },
  {
   "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",
    "    alpha = 0.001\n",
    "    k = int(np.ceil((n + 1) * (1 - alpha)))\n",
    "    print('me hace falta la posición', k, 'de', n)\n",
    "    np.sort(fallo)[k - 1]\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",
    "IndexError: index 675 is out of bounds for axis 0 with size 675\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Con 675 filas de calibración no se puede prometer 99,9% de cobertura, y el\n",
    "error lo dice sin rodeos: hace falta la posición 676 de una lista de 675.\n",
    "\n",
    "La regla sale de despejar: para prometer `1 - alpha` necesitas al\n",
    "menos `1/alpha - 1` filas de calibración. Para el 90% te bastan 9;\n",
    "para el 99% necesitas 99; para el 99,9% necesitas 999.\n",
    "\n",
    "Me encanta este error, sinceramente. **Es una librería negándose a\n",
    "prometer algo que no puede cumplir**, que es más de lo que hace mucha\n",
    "gente 🙂"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. La misma auditoría por ciudad\n",
    "\n",
    "Corre `por_grupo` sobre la ciudad y mira si hay\n",
    "alguna que salga maltratada."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(por_grupo('ciudad').to_string(index=False))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "  ciudad   n  compra_real  le_dice_si  acierto  encuentra    AUC\n",
    "arequipa 137       0.5839      0.6350   0.7153     0.8000 0.7680\n",
    "chiclayo 131       0.5802      0.6870   0.6641     0.8026 0.7136\n",
    "   cusco 106       0.5472      0.6887   0.6321     0.7931 0.6537\n",
    "    lima 136       0.6029      0.6544   0.6691     0.7683 0.7448\n",
    "   piura 138       0.5652      0.7101   0.6522     0.8205 0.6897\n",
    "trujillo 102       0.5784      0.6961   0.7059     0.8475 0.7442\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Aquí las diferencias son bastante más chicas que por segmento y las tasas\n",
    "reales de compra están todas entre 0,54 y 0,61.\n",
    "\n",
    "El AUC va de 0,6537 en Cusco a 0,7680 en Arequipa, y visto lo que aprendimos\n",
    "con las diez semillas, esa diferencia de 11 puntos sobre 106 y 137 filas puede\n",
    "ser perfectamente ruido. Antes de escribir \"el modelo funciona peor en Cusco\"\n",
    "hay que repetirlo con varias particiones 🔍"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. Auditar por canal\n",
    "\n",
    "Lo mismo con el canal de venta."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(por_grupo('canal').to_string(index=False))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "      canal   n  compra_real  le_dice_si  acierto  encuentra    AUC\n",
    "Marketplace 185       0.4541      0.4216   0.6541     0.5833 0.6952\n",
    "     Tienda 173       0.5838      0.8035   0.6416     0.8812 0.7028\n",
    "        Web 209       0.5837      0.6842   0.6794     0.8115 0.6930\n",
    "   WhatsApp 183       0.6885      0.8087   0.7158     0.8810 0.7544\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Marketplace es el único canal donde el modelo le dice que sí a menos de la\n",
    "mitad (42,16%), y coincide con que es el canal con menos compras reales (45,41%).\n",
    "Ahí el modelo está siguiendo a los datos, no inventando.\n",
    "\n",
    "Y fíjate que `encuentra` vuelve a partirse: 58,33% en Marketplace\n",
    "contra 88,12% en Tienda. El mismo 0,5 tratando distinto otra vez."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. El modelo sin la columna del segmento\n",
    "\n",
    "Si el segmento es el atajo, quítalo y mira qué queda."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "SIN_SEG = ['ciudad', 'canal', 'categoria']\n",
    "pre_ss = 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'))]), SIN_SEG),\n",
    "])\n",
    "cols = NUMERICAS + SIN_SEG\n",
    "ss = Pipeline([('pre', pre_ss),\n",
    "               ('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit(\n",
    "    X_tr[cols], y_tr)\n",
    "p_ss = ss.predict_proba(X_te[cols])[:, 1]\n",
    "\n",
    "print('con segmento:', round(roc_auc_score(y_te, prob), 4))\n",
    "print('sin segmento:', round(roc_auc_score(y_te, p_ss), 4))\n",
    "for g in sorted(X_te['segmento'].unique()):\n",
    "    m = (X_te['segmento'] == g).values\n",
    "    print(f'  dentro de {g:11} {roc_auc_score(y_te[m], p_ss[m]):.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "con segmento: 0.7214\n",
    "sin segmento: 0.7038\n",
    "  dentro de Bodega      0.7076\n",
    "  dentro de Horeca      0.6849\n",
    "  dentro de Mayorista   0.5733\n",
    "  dentro de Minimarket  0.6663\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Quitar el segmento baja el AUC global, como era de esperar. Pero mira los AUC\n",
    "por dentro de cada segmento: se mueven poquísimo respecto a los del modelo\n",
    "completo.\n",
    "\n",
    "Eso confirma lo del capítulo: **el segmento aporta al ranking entre\n",
    "grupos y casi nada al ranking dentro de un grupo** 🧩"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. Un modelo por segmento, a ver si mejora\n",
    "\n",
    "Entrena cuatro modelos separados, uno por segmento, y\n",
    "compáralos con el modelo único medido dentro de ese mismo segmento."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "DENTRO = ['ciudad', 'canal', 'categoria']       # el segmento aquí es constante\n",
    "\n",
    "for g in sorted(X_te['segmento'].unique()):\n",
    "    m_tr = (X_tr['segmento'] == g).values\n",
    "    m_te = (X_te['segmento'] == g).values\n",
    "    propio = Pipeline([\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'))]), DENTRO),\n",
    "        ])),\n",
    "        ('mod', LogisticRegression(max_iter=1000, random_state=42)),\n",
    "    ]).fit(X_tr[m_tr][NUMERICAS + DENTRO], y_tr[m_tr])\n",
    "    p_propio = propio.predict_proba(X_te[m_te][NUMERICAS + DENTRO])[:, 1]\n",
    "    print(f'{g:11} propio={roc_auc_score(y_te[m_te], p_propio):.4f}  '\n",
    "          f'unico={roc_auc_score(y_te[m_te], prob[m_te]):.4f}  '\n",
    "          f'entreno con {m_tr.sum()} filas')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "Bodega      propio=0.7117  unico=0.7066  entreno con 533 filas\n",
    "Horeca      propio=0.6167  unico=0.6894  entreno con 555 filas\n",
    "Mayorista   propio=0.5710  unico=0.5625  entreno con 559 filas\n",
    "Minimarket  propio=0.6280  unico=0.6695  entreno con 603 filas\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Empate técnico y por un mal camino. En Bodega y en Mayorista el modelo propio\n",
    "gana por 0,0051 y 0,0085, que es nada. En Horeca y en Minimarket pierde por\n",
    "0,0727 y 0,0415, que ya es algo. Sumando, el modelo único va mejor.\n",
    "\n",
    "Y tiene sentido: cada modelo propio entrena con unas 550 filas en vez de 2.250,\n",
    "así que lo que gana en especialización lo pierde en datos. Con cuatro veces más\n",
    "filas por segmento la respuesta podría cambiar, y por eso la pregunta se vuelve a\n",
    "hacer cuando el dataset crece.\n",
    "\n",
    "Es la respuesta a una pregunta que siempre sale en reuniones (\"¿y si hacemos\n",
    "uno para cada tipo de cliente?\"). La respuesta es: mídelo, casi nunca gana con\n",
    "pocos datos 📊\n",
    "\n",
    "Un detalle del código que me costó un rato: aquí armo un\n",
    "`ColumnTransformer` nuevo dentro del bucle en vez de reusar\n",
    "`pre`. Y no es por ordenado.\n",
    "\n",
    "**Un `Pipeline` no clona sus pasos: los usa tal cual y los\n",
    "deja entrenados**. Si le paso el `pre` de arriba, al terminar\n",
    "el bucle ese objeto queda ajustado al último segmento, y el `conf` del\n",
    "apartado anterior, que comparte el mismo objeto, se rompe con un\n",
    "`ValueError` de features que no cuadran. Objeto compartido, estado\n",
    "compartido 🔁"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. Un listón por grupo\n",
    "\n",
    "Calcula el listón conforme por separado dentro de cada\n",
    "segmento y mira si la cobertura se empareja."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "alpha_g = 0.20\n",
    "for g in sorted(X_cal['segmento'].unique()):\n",
    "    c = (X_cal['segmento'] == g).values\n",
    "    t = (X_te['segmento'] == g).values\n",
    "    f_g = fallo[c]\n",
    "    kg = int(np.ceil((c.sum() + 1) * (1 - alpha_g)))\n",
    "    lg = np.sort(f_g)[kg - 1]\n",
    "    cj = p_te[t] >= 1 - lg\n",
    "    cb = cj[np.arange(t.sum()), y_te[t].values]\n",
    "    print(f'{g:11} n_cal={c.sum():3d}  cubre={cb.mean():.4f}  '\n",
    "          f'no_sé={(cj.sum(axis=1) == 2).mean():.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "Bodega      n_cal=161  cubre=0.8851  no_sé=0.4368\n",
    "Horeca      n_cal=170  cubre=0.8503  no_sé=0.4064\n",
    "Mayorista   n_cal=158  cubre=0.8325  no_sé=0.2565\n",
    "Minimarket  n_cal=186  cubre=0.7727  no_sé=0.3333\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y aquí es donde el libro te tiene que decir la verdad en vez de la teoría:\n",
    "**no funcionó**. Con el listón único las coberturas iban de 79,58% a\n",
    "87,36%, y con un listón por grupo van de 77,27% a 88,51%. Quedaron más separadas,\n",
    "no menos.\n",
    "\n",
    "La idea es correcta y lo que falla es el tamaño. Cada listón se calcula ahora\n",
    "con unas 160 filas en vez de con las 675 de antes, así que cada uno trae su propio\n",
    "temblor y el remedio mete tanto ruido como el que quita 🌡️\n",
    "\n",
    "Fíjate además en Minimarket: 77,27% cuando le prometimos 80%. Con 186 filas de\n",
    "calibración eso entra dentro de lo normal, y esa es justamente la razón por la que\n",
    "el remedio no alcanza todavía.\n",
    "\n",
    "Lo que yo haría con estos datos es quedarme con el listón único, reportar la\n",
    "tabla de cobertura por grupo tal cual está, y volver a intentar el listón por\n",
    "grupo cuando haya unas mil filas de calibración en cada uno. Un método que\n",
    "necesita más datos de los que tienes no es un método malo: es un método para más\n",
    "adelante 🎁"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. ¿Y si el grupo es diminuto?\n",
    "\n",
    "Arma un grupo de pocas filas y mira qué le pasa a las\n",
    "métricas."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "chico = X_te[(X_te['segmento'] == 'Bodega') &\n",
    "             (X_te['ciudad'] == 'cusco')].index\n",
    "m = X_te.index.isin(chico)\n",
    "print('filas:', m.sum())\n",
    "print('compraron:', int(y_te[m].sum()))\n",
    "print('acierto:', round(accuracy_score(y_te[m], pred[m]), 4))\n",
    "print('AUC:', round(roc_auc_score(y_te[m], prob[m]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "filas: 27\n",
    "compraron: 12\n",
    "acierto: 0.6667\n",
    "AUC: 0.6056\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Con un puñado de filas el AUC es un número, sí, pero no significa nada: una\n",
    "sola fila que cambie de lado lo mueve entero.\n",
    "\n",
    "Mi regla de trabajo es no reportar métricas de grupos con menos de 100 filas\n",
    "sin poner al lado un rango, y para eso está el bootstrap que sale en el libro de\n",
    "estadística. Un número solo, en un grupo chico, es una opinión disfrazada 📉"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 7. El error de calibrar con lo que el modelo ya vio\n",
    "\n",
    "Calcula el listón conforme con las mismas filas con las que\n",
    "entrenaste y mira qué promete."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "p_mal = conf.predict_proba(X_ent)\n",
    "fallo_mal = 1 - p_mal[np.arange(len(y_ent)), y_ent.values]\n",
    "k_mal = int(np.ceil((len(fallo_mal) + 1) * (1 - 0.20)))\n",
    "liston_mal = np.sort(fallo_mal)[k_mal - 1]\n",
    "\n",
    "conj_mal = p_te >= 1 - liston_mal\n",
    "cubre_mal = conj_mal[np.arange(len(y_te)), y_te.values]\n",
    "print('listón bueno :', round(1 - listero, 4))\n",
    "print('listón malo  :', round(1 - liston_mal, 4))\n",
    "print('prometió cubrir 0.8 y cubrió:', round(cubre_mal.mean(), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "listón bueno : 0.3888\n",
    "listón malo  : 0.4047\n",
    "prometió cubrir 0.8 y cubrió: 0.804\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Este no revienta, que es lo peligroso. Sale un listón parecido y una cobertura\n",
    "parecida, y te vas a casa tan feliz.\n",
    "\n",
    "Con este modelo casi no se nota porque una regresión logística con trece\n",
    "columnas apenas se aprende de memoria las filas de entrenamiento. Prueba lo\n",
    "mismo con el bosque sin podar del capítulo 13, que en entrenamiento acertaba 1,0:\n",
    "ahí el listón sale ridículamente bajo y la garantía se cae entera.\n",
    "\n",
    "**La calibración tiene que ser de filas que el modelo no vio.**\n",
    "Sin eso, la predicción conforme deja de ser una garantía y pasa a ser un\n",
    "adorno 🚫"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "El modelo está bien calibrado en total y mal por segmento. ¿Cómo puede ser?\n",
    "\n",
    "a) Porque los errores de unos grupos cancelan los de otros\n",
    "\n",
    "b) Es imposible: si está bien en total está bien en todos\n",
    "\n",
    "c) Porque la muestra total es más grande\n",
    "\n",
    "d) Porque la calibración solo funciona con muchos datos\n",
    "\n",
    "---\n",
    "\n",
    "**La correcta es la a.**\n",
    "\n",
    "*b)* Es lo que uno esperaría, y aquí un segmento se pasa 7 puntos y otro se queda 5 corto.\n",
    "\n",
    "*c)* El tamaño hace el promedio más estable, no lo hace representar a cada grupo.\n",
    "\n",
    "*d)* Funciona con pocos también, y lo que falla aquí es otra cosa.\n",
    "\n",
    "El promedio disimula: hay que mirar por grupo o no se ve."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La auditoría que sale limpia por el motivo equivocado"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "Auditas por grupo como toca: sacas el tamaño de cada ciudad en el trozo de examen antes de medir nada. Y ahí ya hay dos cosas mal.\n",
    "\n",
    "```\n",
    "print(prueba['ciudad'].value_counts())\n",
    "\n",
    "# Piura     135\n",
    "# Cusco     134\n",
    "# Arequipa  126\n",
    "# Trujillo  125\n",
    "# Chiclayo  122\n",
    "# Lima       93\n",
    "# líma       25\n",
    "```\n",
    "\n",
    "**Qué está mal**\n",
    "\n",
    "Lima aparece dos veces 🫠 Son las mismas 118 ventas partidas en dos grupos porque alguien escribió la ciudad con tilde, que es justo la suciedad que limpiamos en el capítulo 4 y que aquí volvió a entrar sin avisar.\n",
    "\n",
    "Y de ahí sale la segunda, que es la grave. Ese grupo de **25 filas** va a dar un número, y ese número no distingue entre \"aquí el modelo funciona mejor\" y \"aquí el modelo es una moneda al aire\": con 25 casos, el intervalo es tan ancho que caben las dos cosas.\n",
    "\n",
    "El sentido del error va al revés de lo que conviene. Los grupos pequeños suelen ser los peor atendidos, y son exactamente en los que tu auditoría tiene menos capacidad de detectar que algo va mal: sale limpia *porque* el grupo es pequeño. Cada métrica por grupo se reporta con su tamaño al lado, y cuando el grupo no da, se escribe con esas palabras: **no hay datos suficientes para auditar este grupo**."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- 🔎 Un número global es un promedio. Auditar es repetir la misma medición\n",
    "dentro de cada grupo.\n",
    "\n",
    "- 🪞 El mejor acierto y el peor AUC pueden estar en el mismo grupo. Mayorista\n",
    "acierta 0,7435 y ordena a 0,5625.\n",
    "\n",
    "- 🧩 El AUC global sale más alto que el de cualquier segmento, porque parte de\n",
    "lo que el modelo sabe es distinguir segmentos.\n",
    "\n",
    "- ⚖️ Un umbral único trata distinto a cada grupo: encuentra el 40,68% de las\n",
    "Bodegas que compran y el 92,52% de los Mayoristas.\n",
    "\n",
    "- 🎯 Calibrado en promedio no es calibrado por grupo: aquí los errores de\n",
    "Mayorista y Bodega se cancelan entre sí.\n",
    "\n",
    "- 📦 La predicción conforme deja que el modelo diga \"no sé\", y donde lo dice\n",
    "acierta 54,80% contra 73,60% donde se moja.\n",
    "\n",
    "- 📐 La cobertura conforme está garantizada en total, no por grupo, y para\n",
    "prometer 1-alpha necesitas al menos 1/alpha - 1 filas de calibración.\n",
    "\n",
    "- 🚫 Calibrar con filas que el modelo vio no da error y arruina la garantía en\n",
    "silencio.\n",
    "\n",
    "Y si de todo el capítulo te llevas una sola frase, que sea esta:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un promedio no le falla a nadie. La gente a la que le falla el modelo está siempre dentro del promedio."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Si esto lo tienes que llevar a una empresa con reglas encima, en [consultoría en IA](https://missyera.com/consultoria-ia/) cuento cómo se monta la parte de gobernanza 🏛️\n",
    "\n",
    "En el capítulo 27 sacamos el modelo del cuaderno: guardarlo, servirlo y\n",
    "vigilar que el mundo no se mueva debajo de él.\n",
    "\n",
    "Que tengas lindo día! 🌸"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 24 de **Machine learning desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/machine-learning-desde-cero/sesgo-y-confianza/\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
}
