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