{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Abrir la caja negra\n",
    "\n",
    "Coeficientes, odds e importancia por permutación, y la columna que valía doce puntos y no aporta nada.\n",
    "\n",
    "Cuaderno de soluciones del capítulo 20 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/explicar-el-modelo/\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": [
    "Un modelo que nadie entiende no se usa. Y esto no es filosofía: es lo que\n",
    "pasa cuando el equipo comercial recibe una lista de 200 nombres y nadie sabe\n",
    "decir por qué están esos y no otros 🤷‍♀️\n",
    "\n",
    "Una pregunta para ti: **¿te ha tocado defender un resultado que no sabías explicar del todo?** Es de las sensaciones más incómodas de este trabajo, y se arregla antes de la reunión, no durante 😬\n",
    "\n",
    "En este capítulo abrimos la caja. Y de paso vamos a encontrar una columna que\n",
    "medimos en doce puntos en el capítulo 4 y que al modelo **no le aporta\n",
    "nada**, lo cual tiene una explicación bonita."
   ]
  },
  {
   "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.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",
    "print('AUC:', round(modelo.score(X_te, y_te), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los coeficientes, que es lo que la logística regala"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Una regresión logística le pone un número a cada columna. Ese número\n",
    "**es** la explicación, no una aproximación de la explicación."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "nombres = modelo.named_steps['pre'].get_feature_names_out()\n",
    "pesos = modelo.named_steps['mod'].coef_[0]\n",
    "\n",
    "for i in np.argsort(np.abs(pesos))[::-1][:8]:\n",
    "    print(f'{nombres[i]:32} {pesos[i]:+.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Se leen así: **positivo empuja hacia el sí, negativo hacia el no, y el\n",
    "tamaño dice cuánto**.\n",
    "\n",
    "Ser Bodega es lo que más resta (-0,68) y ser Mayorista lo que más suma\n",
    "(+0,47). Marketplace resta, WhatsApp suma. Todo eso ya lo habíamos visto a mano\n",
    "en el capítulo 1, y ahora sale solo y con todas las columnas a la vez.\n",
    "\n",
    "Eso último es la gracia: en el capítulo 1 miramos el segmento\n",
    "*ignorando* el resto. El coeficiente dice cuánto aporta el segmento\n",
    "**una vez descontado todo lo demás** 🎯"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Convertirlos a algo que se pueda decir en voz alta"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "log(p1−p)=b+∑jwjxj\n",
    "\n",
    "la misma fórmula al revés: cada peso dice cuánto mueve el logaritmo de la ventaja, y por eso un peso de 0,7 se lee como multiplicar la ventaja por dos"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Los coeficientes están en log-odds, que no se explican en una reunión. Se\n",
    "arregla con una exponencial:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for i in np.argsort(np.abs(pesos))[::-1][:6]:\n",
    "    print(f'{nombres[i]:32} multiplica las probabilidades por {np.exp(pesos[i]):.2f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahora sí: **ser Mayorista multiplica por 1,6 las probabilidades a favor\n",
    "de cerrar; ser Bodega las multiplica por 0,5, o sea las corta a la mitad**.\n",
    "\n",
    "Ojo con una trampa de vocabulario que se comete siempre: eso son\n",
    "*odds*, no probabilidades. \"Multiplica por 1,6 las probabilidades\" es\n",
    "incorrecto en sentido estricto; lo correcto es \"la razón de probabilidades\". En\n",
    "una reunión yo digo lo primero y lo aclaro si alguien pregunta, y en un informe\n",
    "escrito lo pongo bien 📝\n",
    "\n",
    "Y un aviso más importante: los coeficientes de las numéricas están\n",
    "**escalados** por el `StandardScaler`, así que +0,4152 en\n",
    "satisfacción significa \"por cada desviación típica de satisfacción\", no \"por cada\n",
    "punto\". Se puede desescalar, y casi siempre no hace falta: para comparar\n",
    "importancia entre columnas, escalado es justo lo que quieres."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La forma que funciona con cualquier modelo"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Los coeficientes solo existen en los modelos lineales. Para un bosque o un\n",
    "boosting hace falta otra cosa, y la buena se llama\n",
    "**importancia por permutación**.\n",
    "\n",
    "La idea es preciosa de tan simple: *revuelve una columna al azar y mira\n",
    "cuánto empeora el modelo*. Si empeora mucho, esa columna importaba. Si no\n",
    "cambia nada, no servía."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.inspection import permutation_importance\n",
    "\n",
    "r = permutation_importance(modelo, X_te, y_te, n_repeats=10,\n",
    "                           random_state=42, scoring='roc_auc', n_jobs=-1)\n",
    "\n",
    "for i in r.importances_mean.argsort()[::-1]:\n",
    "    print(f'{X_te.columns[i]:20} {r.importances_mean[i]:+.4f} ± {r.importances_std[i]:.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí está el ranking honesto, medido en **puntos de AUC perdidos**:\n",
    "\n",
    "- 🥇 **segmento**: 0,0909. Si lo revuelves, el modelo pierde nueve\n",
    "puntos. Es la columna del libro.\n",
    "\n",
    "- 🥈 **satisfacción**: 0,0464.\n",
    "\n",
    "- 🥉 **sin_compra_previa**: 0,0361, la bandera que construimos en\n",
    "el capítulo 9 con una línea.\n",
    "\n",
    "- 4️⃣ **canal**: 0,0334.\n",
    "\n",
    "Y de ahí para abajo, todo por debajo de 0,007. **Cuatro columnas hacen\n",
    "el modelo** y las otras nueve están de adorno.\n",
    "\n",
    "Fíjate en tres ventajas enormes de esta técnica: funciona con cualquier\n",
    "modelo, se mide en la unidad que te importa (aquí AUC) y viene con\n",
    "**desviación**, así que sabes cuándo dos columnas están\n",
    "empatadas 📐"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La columna que valía doce puntos y no aporta nada"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Mira `sin_descuento` en esa lista: -0,0002. Cero.\n",
    "\n",
    "Y en el capítulo 4 medimos que sin descuento se cierra el 48,1% y con\n",
    "descuento el 60,2%. **Doce puntos de diferencia.** ¿Cómo puede no\n",
    "aportar nada?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('la bandera sola:')\n",
    "print(datos.groupby('sin_descuento')['compro'].mean().round(4))\n",
    "print()\n",
    "print('el descuento imputado, por bandera:')\n",
    "imputado = datos['descuento'].fillna(datos['descuento'].median())\n",
    "print(pd.DataFrame({'bandera': datos['sin_descuento'],\n",
    "                    'descuento': imputado}).groupby('bandera')['descuento'].describe()[['min', 'max']].round(3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí está la explicación: cuando la bandera vale 1, el imputador rellenó el\n",
    "descuento con la mediana, y ese valor exacto (0,127) prácticamente solo aparece\n",
    "en esas filas.\n",
    "\n",
    "O sea que **la columna `descuento` ya lleva dentro la\n",
    "información de la bandera**. Son redundantes, y al revolver una la otra\n",
    "sigue diciendo lo mismo.\n",
    "\n",
    "Esa es la trampa grande de la importancia por permutación y hay que saberla:\n",
    "**con dos columnas que dicen lo mismo, las dos salen sin importancia**,\n",
    "porque el modelo se apoya en la que quede. No significa que ninguna importe:\n",
    "significa que sobra una 🔍\n",
    "\n",
    "La forma de comprobarlo es quitarlas de verdad, que es el ejercicio 4."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Explicar UNA predicción"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Lo de arriba explica el modelo. Pero en la reunión te van a preguntar por una\n",
    "fila: *¿por qué a este cliente le pusiste 0,85?*"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fila = X_te.iloc[[3]]\n",
    "prob = modelo.predict_proba(fila)[0, 1]\n",
    "transformada = modelo.named_steps['pre'].transform(fila)[0]\n",
    "aporte = transformada * pesos\n",
    "\n",
    "print('probabilidad:', round(prob, 4))\n",
    "print()\n",
    "for i in np.argsort(np.abs(aporte))[::-1][:6]:\n",
    "    print(f'{nombres[i]:32} {aporte[i]:+.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Eso es una explicación completa de esa fila: cada número dice cuánto empujó\n",
    "cada característica de *este* cliente.\n",
    "\n",
    "Y sumado da exactamente el resultado, se puede comprobar:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "total = aporte.sum() + modelo.named_steps['mod'].intercept_[0]\n",
    "print('suma de aportes + intercepto:', round(total, 4))\n",
    "print('probabilidad reconstruida   :', round(1 / (1 + np.exp(-total)), 4))\n",
    "print('la que dio el modelo        :', round(prob, 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Idénticas 🎯 Eso es lo que hace que un modelo lineal sea auditable de verdad:\n",
    "no hay nada escondido, la predicción **es** la suma."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El coeficiente que cambia de signo"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Todo lo de arriba tiene una letra pequeña que hay que leer antes de contarle\n",
    "a nadie qué columna importa 🔬\n",
    "\n",
    "En estos datos hay dos columnas que dicen casi lo mismo:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('correlación monto / monto_final_facturado:',\n",
    "      round(datos['monto'].corr(datos['monto_final_facturado']), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "0,74. No son la misma columna, pero se parecen mucho. Ahora mira qué le pasa\n",
    "al coeficiente del monto según esté sola o acompañada:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.compose import ColumnTransformer\n",
    "from sklearn.impute import SimpleImputer\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "\n",
    "\n",
    "def coef_de(cols):\n",
    "    p = Pipeline([\n",
    "        ('p', ColumnTransformer([('n', Pipeline([\n",
    "            ('i', SimpleImputer(strategy='median')),\n",
    "            ('s', StandardScaler()),\n",
    "        ]), cols)])),\n",
    "        ('m', LogisticRegression(max_iter=1000, random_state=42)),\n",
    "    ])\n",
    "    p.fit(datos[cols], datos['compro'])\n",
    "    return dict(zip(cols, p.named_steps['m'].coef_[0].round(3)))\n",
    "\n",
    "\n",
    "print('sola      :', coef_de(['monto', 'unidades', 'satisfaccion']))\n",
    "print('acompañada:', coef_de(['monto', 'monto_final_facturado',\n",
    "                              'unidades', 'satisfaccion']))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El coeficiente del monto pasa de **+0,461 a −2,029** 😱\n",
    "\n",
    "Los mismos datos, la misma columna, el mismo modelo. Lo único que cambió es\n",
    "que al lado hay otra columna parecida. Y no cambió de tamaño: **cambió de\n",
    "signo**.\n",
    "\n",
    "Con el primer modelo dirías \"cuanto más grande la venta, más probable que\n",
    "compre\". Con el segundo dirías exactamente lo contrario. Las dos frases salen de\n",
    "mirar un coeficiente, y las dos serían igual de defendibles delante de alguien\n",
    "que no sepa esto.\n",
    "\n",
    "### Por qué pasa\n",
    "\n",
    "Un coeficiente no significa \"el efecto de esta columna\". Significa\n",
    "**\"el efecto de esta columna manteniendo las demás quietas\"**, y esa\n",
    "diferencia lo es todo 🔑\n",
    "\n",
    "Cuando dos columnas se mueven juntas, \"mantener la otra quieta\" es una\n",
    "situación que casi no existe en los datos. El modelo tiene que inventarse cómo\n",
    "repartir el crédito entre las dos, y le vale cualquier reparto que dé el mismo\n",
    "resultado: `+15,3` a una y `−2,0` a la otra suma lo mismo\n",
    "que un reparto más razonable.\n",
    "\n",
    "Se llama multicolinealidad, y aquí tienes su síntoma en pantalla: coeficientes\n",
    "enormes, de signos opuestos, en columnas que se parecen.\n",
    "\n",
    "### Qué hacer con esto\n",
    "\n",
    "- 🔎 **Mira las correlaciones antes de leer coeficientes.** Si dos\n",
    "columnas pasan de 0,7, no leas sus coeficientes por separado.\n",
    "\n",
    "- ✂️ **Quita una de las dos.** Si dicen casi lo mismo, la segunda\n",
    "no está aportando información nueva, solo confusión.\n",
    "\n",
    "- 📊 **Usa permutación en vez de coeficientes.** Lo de arriba mide\n",
    "cuánto empeora el modelo al estropear una columna, y eso no se reparte de forma\n",
    "arbitraria.\n",
    "\n",
    "Y un aviso sobre esta columna en concreto, para que no la copies:\n",
    "`monto_final_facturado` es además **una fuga**. Es lo que\n",
    "se facturó, o sea que existe después de que la venta pasara, y un modelo que la\n",
    "usa está mirando el futuro. Lo desarrollo en el capítulo\n",
    "12.\n",
    "\n",
    "La he usado aquí porque es el ejemplo más limpio que tengo de dos columnas\n",
    "correlacionadas, y porque las dos cosas van juntas más veces de lo que parece:\n",
    "una columna que se parece demasiado a la respuesta suele ser sospechosa por los\n",
    "dos motivos a la vez 🕵️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Sobre SHAP, que es lo que te van a preguntar"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "SHAP es la técnica estándar para esto con modelos complicados. Reparte la\n",
    "predicción entre las columnas de una forma que viene de teoría de juegos y tiene\n",
    "propiedades matemáticas bonitas.\n",
    "\n",
    "Vive en una librería aparte (`pip install shap`) y no la uso en\n",
    "este libro por dos razones honestas:\n",
    "\n",
    "- 📦 No está instalada en el entorno donde se ejecutan estos capítulos, y este\n",
    "libro no publica código que no corre.\n",
    "\n",
    "- ➕ Para un modelo lineal, **los aportes de SHAP son exactamente lo que\n",
    "acabamos de calcular a mano**. Con tres líneas y sin dependencias.\n",
    "\n",
    "Cuándo sí vale la pena instalarlo: cuando el modelo que ganó es un bosque o un\n",
    "boosting y necesitas explicar filas concretas. Para eso no hay atajo y SHAP es lo\n",
    "mejor que hay 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Siete. Intenta antes de abrir 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. Los coeficientes del segmento, ordenados\n",
    "\n",
    "Saca solo los cuatro segmentos y ordénalos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "seg = [(nombres[i], pesos[i]) for i in range(len(nombres))\n",
    "       if nombres[i].startswith('cat__segmento')]\n",
    "for n, p in sorted(seg, key=lambda x: -x[1]):\n",
    "    print(f'{n:32} {p:+.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "cat__segmento_Mayorista          +0.4725\n",
    "cat__segmento_Horeca             +0.3248\n",
    "cat__segmento_Minimarket         +0.0666\n",
    "cat__segmento_Bodega             -0.6832\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Mayorista, Horeca, Minimarket, Bodega, de más a menos. **El mismo orden\n",
    "que las tasas del capítulo 1** (72,3%, 63,2%, 56,9%, 37,6%), y eso es una\n",
    "comprobación de sanidad muy útil.\n",
    "\n",
    "Si el orden del modelo no coincidiera con el de los datos crudos, habría algo\n",
    "raro y valdría la pena mirarlo 🔍"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. Desescalar un coeficiente\n",
    "\n",
    "Traduce el peso de la satisfacción a \"por cada punto de\n",
    "satisfacción\"."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "escalador = modelo.named_steps['pre'].named_transformers_['num'].named_steps['e']\n",
    "i_sat = NUMERICAS.index('satisfaccion')\n",
    "desviacion = escalador.scale_[i_sat]\n",
    "\n",
    "peso_escalado = pesos[list(nombres).index('num__satisfaccion')]\n",
    "print('desviación típica de satisfacción:', round(desviacion, 4))\n",
    "print('por desviación:', round(peso_escalado, 4))\n",
    "print('por punto     :', round(peso_escalado / desviacion, 4))\n",
    "print('odds por punto:', round(np.exp(peso_escalado / desviacion), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "desviación típica de satisfacción: 1.3641\n",
    "por desviación: 0.4152\n",
    "por punto     : 0.3043\n",
    "odds por punto: 1.3557\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cada punto de satisfacción multiplica las odds por 1,34. **Eso sí se\n",
    "puede decir en una reunión**, y encima es accionable: si el equipo de\n",
    "atención sube un punto la satisfacción media, esto es lo que pasa 📈"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. La importancia según el bosque, comparada\n",
    "\n",
    "Pon al lado la importancia del bosque y la de permutación, y\n",
    "mira si coinciden."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.ensemble import RandomForestClassifier\n",
    "\n",
    "bosque = Pipeline([('pre', pre),\n",
    "                   ('mod', RandomForestClassifier(n_estimators=300, max_depth=5,\n",
    "                                                  random_state=42, n_jobs=-1))]).fit(X_tr, y_tr)\n",
    "rb = permutation_importance(bosque, X_te, y_te, n_repeats=10,\n",
    "                            random_state=42, scoring='roc_auc', n_jobs=-1)\n",
    "for i in rb.importances_mean.argsort()[::-1][:5]:\n",
    "    print(f'{X_te.columns[i]:20} {rb.importances_mean[i]:+.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "segmento             +0.0365\n",
    "satisfaccion         +0.0270\n",
    "monto                +0.0257\n",
    "sin_compra_previa    +0.0213\n",
    "canal                +0.0206\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El bosque, medido por permutación, da un ranking parecido al de la logística:\n",
    "segmento arriba del todo.\n",
    "\n",
    "Y compáralo con lo que decía `feature_importances_` en el capítulo\n",
    "13, donde el monto salía primero. **Dos técnicas sobre el mismo modelo\n",
    "entrenado dan rankings distintos**, y la de permutación es la que hay que\n",
    "creer, porque mide lo que te importa (AUC perdido) en vez de contar cortes 🔍"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. Quitar de verdad las columnas que no aportan\n",
    "\n",
    "Entrena solo con las cuatro columnas que salieron arriba y\n",
    "compara."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "CUATRO_NUM = ['satisfaccion', 'sin_compra_previa']\n",
    "CUATRO_CAT = ['segmento', 'canal']\n",
    "\n",
    "pre4 = ColumnTransformer([\n",
    "    ('num', Pipeline([('r', SimpleImputer(strategy='median')),\n",
    "                      ('e', StandardScaler())]), CUATRO_NUM),\n",
    "    ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),\n",
    "                      ('c', OneHotEncoder(handle_unknown='ignore'))]), CUATRO_CAT),\n",
    "])\n",
    "m4 = Pipeline([('pre', pre4),\n",
    "               ('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit(\n",
    "    X_tr[CUATRO_NUM + CUATRO_CAT], y_tr)\n",
    "\n",
    "from sklearn.metrics import roc_auc_score\n",
    "print('con 4 columnas :', round(roc_auc_score(y_te,\n",
    "      m4.predict_proba(X_te[CUATRO_NUM + CUATRO_CAT])[:, 1]), 4))\n",
    "print('con 13 columnas:', round(roc_auc_score(y_te,\n",
    "      modelo.predict_proba(X_te)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "con 4 columnas : 0.7193\n",
    "con 13 columnas: 0.7214\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "0,7193 con cuatro columnas contra 0,7214 con trece. **Dos milésimas de\n",
    "diferencia**, menos que la desviación de la validación cruzada del\n",
    "capítulo 16.\n",
    "\n",
    "O sea que las otras nueve columnas no hacen nada. Y un modelo de cuatro\n",
    "columnas es más fácil de explicar, más barato de mantener y menos frágil, así\n",
    "que en producción yo me quedaría con este 🧹"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. La fila que el modelo se equivoca\n",
    "\n",
    "Busca un caso donde el modelo estaba muy seguro y falló, y\n",
    "explícalo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "prob_te = modelo.predict_proba(X_te)[:, 1]\n",
    "error = np.abs(prob_te - y_te.values)\n",
    "peor = error.argmax()\n",
    "\n",
    "print('el modelo dijo:', round(prob_te[peor], 4))\n",
    "print('la verdad era :', int(y_te.iloc[peor]))\n",
    "print()\n",
    "print(X_te.iloc[peor][['segmento', 'canal', 'satisfaccion', 'monto']])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "el modelo dijo: 0.9032\n",
    "la verdad era : 0\n",
    "\n",
    "segmento        Mayorista\n",
    "canal              Tienda\n",
    "satisfaccion          5.0\n",
    "monto             1796.06\n",
    "Name: 135, dtype: object\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un caso con todo a favor que no cerró. Y no hay nada que arreglar: pasa.\n",
    "\n",
    "Mirar los peores errores uno por uno es de las cosas más útiles que existen y\n",
    "casi nadie las hace. A veces encuentras un patrón (todos son de la misma\n",
    "sucursal, todos del mismo mes) y ahí sí hay algo que corregir 🕵️‍♀️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. El error de leer los nombres al revés\n",
    "\n",
    "Intenta emparejar coeficientes con las columnas originales\n",
    "en vez de con las transformadas."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pd.Series(pesos, index=X.columns)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "ValueError: Length of values (28) does not match length of index (13)\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Trece nombres para veintiocho pesos. Y menos mal que da error, porque\n",
    "**si hubiera coincidido el número, habrías publicado una tabla de\n",
    "importancias completamente equivocada** y nada te habría avisado 😳\n",
    "\n",
    "Después de un `OneHotEncoder` los nombres se piden siempre con\n",
    "`get_feature_names_out()`, nunca se asumen."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 7. La tabla que entregas con el modelo\n",
    "\n",
    "Junta coeficiente, odds e importancia en una sola tabla,\n",
    "lista para el informe."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "filas = []\n",
    "for i, col in enumerate(X_te.columns):\n",
    "    filas.append({'columna': col,\n",
    "                  'importancia': round(r.importances_mean[i], 4),\n",
    "                  'desviacion': round(r.importances_std[i], 4)})\n",
    "\n",
    "tabla = pd.DataFrame(filas).sort_values('importancia', ascending=False)\n",
    "print(tabla.head(6).to_string(index=False))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "          columna  importancia  desviacion\n",
    "         segmento       0.0909      0.0117\n",
    "     satisfaccion       0.0464      0.0076\n",
    "sin_compra_previa       0.0361      0.0082\n",
    "            canal       0.0334      0.0064\n",
    "            monto       0.0067      0.0019\n",
    "  precio_unitario       0.0060      0.0031\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Seis filas y una columna de desviación al lado, que es lo que dice cuáles\n",
    "están empatadas.\n",
    "\n",
    "Con esa tabla, cualquiera puede discutir tu modelo sin creerte, y eso es\n",
    "justamente lo que hace que te crean 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "Una columna tenía doce puntos de diferencia entre grupos y el modelo no la usa. ¿Qué pasó?\n",
    "\n",
    "a) Que otra columna ya traía esa información\n",
    "\n",
    "b) Que el modelo está mal entrenado\n",
    "\n",
    "c) Que la diferencia de doce puntos era mentira\n",
    "\n",
    "d) Que hay que forzar al modelo a usarla\n",
    "\n",
    "---\n",
    "\n",
    "**La correcta es la a.**\n",
    "\n",
    "*b)* El modelo entrena bien. Piensa en qué pasa cuando dos columnas dicen lo mismo.\n",
    "\n",
    "*c)* Era real y está medida. Lo que cambia es qué aporta cuando ya están las demás.\n",
    "\n",
    "*d)* Forzarlo a usar información repetida no añade nada.\n",
    "\n",
    "Una variable importante por separado puede no aportar nada dentro del conjunto."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El ranking que ordena otra cosa"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "Ya tienes el modelo entrenado y te piden qué variable pesa más. Ordenas los coeficientes por tamaño y sale la respuesta.\n",
    "\n",
    "```\n",
    "coef = pd.Series(modelo.coef_[0],\n",
    "                 index=X.columns)\n",
    "print(coef.abs().sort_values(\n",
    "    ascending=False).head(3))\n",
    "\n",
    "# satisfaccion  0.2745\n",
    "# unidades      0.0049\n",
    "# monto         0.0006\n",
    "```\n",
    "\n",
    "**Qué está mal**\n",
    "\n",
    "Ese ranking no ordena importancia: ordena **unidades de medida** 📏 La satisfacción va de 1 a 5 y el monto llega a 4.236, así que el coeficiente del monto es pequeño porque su columna es grande, no porque no importe. Multiplica el monto por mil y su coeficiente se divide por mil sin que el modelo cambie en nada.\n",
    "\n",
    "Los coeficientes solo se pueden comparar entre ellos si las columnas están en la misma escala, y por eso este capítulo los lee sobre datos escalados. Si no lo están, lo que se compara es `coeficiente x desviación de la columna`, que sí es comparable.\n",
    "\n",
    "Y hay una segunda capa: un coeficiente grande con un intervalo que cruza el cero no es grande, es desconocido. Eso es el capítulo 22."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- 📊 En una logística, los coeficientes *son* la explicación. Positivo\n",
    "empuja al sí, negativo al no.\n",
    "\n",
    "- 🗣️ Se dicen en voz alta con `exp()`: ser Mayorista multiplica las\n",
    "odds por 1,6 y ser Bodega las corta a la mitad.\n",
    "\n",
    "- 🔀 La importancia por permutación funciona con cualquier modelo, se mide en\n",
    "la unidad que te importa y trae desviación.\n",
    "\n",
    "- 🥇 Cuatro columnas hacen el modelo: segmento, satisfacción, sin_compra_previa\n",
    "y canal. Las otras nueve están de adorno.\n",
    "\n",
    "- 🔍 Dos columnas que dicen lo mismo salen las dos sin importancia. No sobra\n",
    "información: sobra una columna.\n",
    "\n",
    "- 🧾 Una predicción concreta se explica sumando aportes, y la suma reconstruye\n",
    "la probabilidad exacta.\n",
    "\n",
    "- 📦 Para un modelo lineal, SHAP da lo mismo que esas tres líneas. Instálalo\n",
    "cuando el modelo sea un bosque o un boosting.\n",
    "\n",
    "Y si de todo el capítulo te llevas una sola frase, que sea esta:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un modelo que nadie sabe explicar no se usa. Y uno que no se usa no existe."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Explicar un modelo a quien no es técnico es media profesión. De eso va el [artículo de cómo ser analista de datos](https://missyera.com/blog/ser-analista-de-datos/) 🗣️\n",
    "\n",
    "En el capítulo 24 vamos a lo que el modelo aprendió sin que nadie se lo\n",
    "pidiera: sesgo, grupos que salen peor parados y cuánta confianza merece cada\n",
    "predicción.\n",
    "\n",
    "Que tengas lindo día! 🌸"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 20 de **Machine learning desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/machine-learning-desde-cero/explicar-el-modelo/\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
}
