{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# SHAP y PDP\n",
    "\n",
    "El reparto que suma exacto, dos clientes con la misma probabilidad por razones opuestas, y el PDP que sale plano sin avisar.\n",
    "\n",
    "Cuaderno de práctica del capítulo 21 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/shap-y-pdp/\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: \"cHJvYmFiaWxpZGFkIDg5JS4gUGVzYTogc2F0aXNmYWNjaW9uIChhIGZhdm9yLCAwLjU2KTsgbW9udG8gKGEgZmF2b3IsIDAuMzApOyBwcmVjaW8gdW5pdGFyaW8gKGEgZmF2b3IsIDAuMjApCnByb2JhYmlsaWRhZCA4OSUuIFBlc2E6IHNhdGlzZmFjY2lvbiAoYSBmYXZvciwgMC41Nyk7IG1vbnRvIChhIGZhdm9yLCAwLjMyKTsgc2luIGNvbXByYSBwcmV2aWEgKGEgZmF2b3IsIDAuMTgpCnByb2JhYmlsaWRhZCA4OSUuIFBlc2E6IHNhdGlzZmFjY2lvbiAoYSBmYXZvciwgMC41Nik7IG1vbnRvIChhIGZhdm9yLCAwLjI5KTsgcHJlY2lvIHVuaXRhcmlvIChhIGZhdm9yLCAwLjIwKQ==\",\n",
    "    2: \"dGFzYSBkZSBjb21wcmEgb3JpZ2luYWw6IDAuNTc3OCAtPiBiYXNlIDAuMzYxMgp0YXNhIGRlIGNvbXByYSBzZXNnYWRhIDogMC4xNzM5IC0+IGJhc2UgLTEuNjk5OQ==\",\n",
    "    3: \"c2VnbWVudG8gICAgICBCb2RlZ2EgIEhvcmVjYSAgTWF5b3Jpc3RhICBNaW5pbWFya2V0CnNhdGlzZmFjY2lvbiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAoxLjAgICAgICAgICAgIC0wLjQ0OCAgLTAuNDc0ICAgICAtMC40NzkgICAgICAtMC40NzYKMi4wICAgICAgICAgICAtMC4xOTQgIC0wLjIwNyAgICAgLTAuMjA2ICAgICAgLTAuMTg4CjMuMCAgICAgICAgICAgLTAuMTMwICAtMC4xMzQgICAgIC0wLjEzMyAgICAgIC0wLjEzMAo0LjAgICAgICAgICAgICAwLjE5NiAgIDAuMjQxICAgICAgMC4yNDQgICAgICAgMC4yMzAKNS4wICAgICAgICAgICAgMC41OTAgICAwLjU4OSAgICAgIDAuNTc1ICAgICAgIDAuNTkw\",\n",
    "    4: \"YWNpZXJ0b3MgcmVhbGVzIGVuIGxhcyAyMDA6IDE1NyBkZSAyMDAKCm1vdGl2bwpzYXRpc2ZhY2Npb24gICAgICAgICAxMDIKbW9udG8gICAgICAgICAgICAgICAgIDg3CmNhbmFsX01hcmtldHBsYWNlICAgICAgNgpwcmVjaW9fdW5pdGFyaW8gICAgICAgIDIKZGVzY3VlbnRvICAgICAgICAgICAgICAxCnVuaWRhZGVzICAgICAgICAgICAgICAgMQpzaW5fY29tcHJhX3ByZXZpYSAgICAgIDE=\",\n",
    "    5: \"ICA1MCBmaWxhczogY29ycmVsYWNpb24gZGUgcHVlc3RvcyAwLjk5NzMsIGNvaW5jaWRlbiAzLzUgZW4gZWwgdG9wIDUKIDE1MCBmaWxhczogY29ycmVsYWNpb24gZGUgcHVlc3RvcyAwLjk5ODksIGNvaW5jaWRlbiA1LzUgZW4gZWwgdG9wIDUKIDQwMCBmaWxhczogY29ycmVsYWNpb24gZGUgcHVlc3RvcyAwLjk5ODksIGNvaW5jaWRlbiAzLzUgZW4gZWwgdG9wIDUKIDc1MCBmaWxhczogY29ycmVsYWNpb24gZGUgcHVlc3RvcyAxLjAwMDAsIGNvaW5jaWRlbiA1LzUgZW4gZWwgdG9wIDU=\",\n",
    "    6: \"cHJlY2lvX3VuaXRhcmlvICBudWxvcyAgIDAgIHJlamlsbGEgWyAgOS43MSAxNzAuMjYgMzMwLjgxIDQ5MS4zN10KICAgICAgICAgICAgICAgICBjdXJ2YSAgWzAuNTQ2OCAwLjYwODggMC42MTM5IDAuNjEzOV0Kc2F0aXNmYWNjaW9uICAgICBudWxvcyAgNTYgIHJlamlsbGEgW25hbiBuYW4gbmFuIG5hbl0KICAgICAgICAgICAgICAgICBjdXJ2YSAgWzAuNTU4IDAuNTU4IDAuNTU4IDAuNTU4XQ==\",\n",
    "    7: \"cmVhbGlkYWQgMCwgZWwgbW9kZWxvIGRpam8gMC44OTE4CgogIHNhdGlzZmFjY2lvbiAgICAgICAgICAgKzAuNTU3CiAgbW9udG8gICAgICAgICAgICAgICAgICArMC4zMDAKICBwcmVjaW9fdW5pdGFyaW8gICAgICAgICswLjIwNAogIHNpbl9jb21wcmFfcHJldmlhICAgICAgKzAuMTgwCiAgY2FuYWxfTWFya2V0cGxhY2UgICAgICArMC4xMjEKCnNlZ21lbnRvICAgICAgICAgICAgIE1heW9yaXN0YQpjYW5hbCAgICAgICAgICAgICAgICAgICBUaWVuZGEKc2F0aXNmYWNjaW9uICAgICAgICAgICAgICAgNS4wCm1vbnRvICAgICAgICAgICAgICAgICAgMTc5Ni4wNgpzaW5fY29tcHJhX3ByZXZpYSAgICAgICAgICAgIDA=\",\n",
    "}, lenguaje=\"python\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El capítulo anterior contestó qué mira el modelo *en general*. Este\n",
    "contesta la pregunta que de verdad hacen en la reunión: **¿y por qué este\n",
    "cliente?** 🙋‍♀️\n",
    "\n",
    "Son preguntas distintas y se responden con herramientas distintas. La\n",
    "importancia por permutación te dice que el segmento pesa mucho en las 750 filas.\n",
    "No te dice nada sobre la señora de la bodega de Villa El Salvador que sale\n",
    "séptima en tu lista.\n",
    "\n",
    "Para eso está SHAP, que ya lo instalamos y hasta ahora solo lo habíamos\n",
    "nombrado 💜"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Qué es un valor SHAP"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "La idea viene de teoría de juegos y es de 1953, mucho antes de que existiera\n",
    "nada de esto. La pregunta original era cómo repartir un premio entre los\n",
    "jugadores de un equipo de forma justa.\n",
    "\n",
    "Aquí el equipo son las columnas y el premio es la predicción. Un valor SHAP\n",
    "es **cuánto aportó esa columna a ESTA fila**, medido en la moneda\n",
    "del modelo."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "f(x)=ϕ0+∑j=1pϕj\n",
    "\n",
    "la predicción de una fila es lo que el modelo creía antes de mirarla más lo que aportó cada columna, y esa suma cuadra exacta, no aproximada"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esa igualdad es lo que separa a SHAP de todo lo demás: no es una\n",
    "aproximación ni un ranking, es un reparto que **suma exactamente**.\n",
    "Y lo vamos a comprobar en las 750 filas, no de palabra.\n",
    "\n",
    "La forma de calcular cuánto aporta cada columna es quitarla y ver cuánto\n",
    "cambia la predicción, pero en todos los órdenes posibles y promediando:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "ϕj=∑S⊆N⧵{j}|S|!(p−|S|−1)!p![f(S∪{j})−f(S)]\n",
    "\n",
    "el aporte de una columna es cuánto cambia la predicción al añadirla, medido en todos los grupos de columnas posibles y promediado"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Con trece columnas eso serían muchísimas combinaciones. La gracia de\n",
    "`TreeExplainer` es que en un modelo de árboles esa cuenta tiene\n",
    "atajo, y por eso pasa de imposible a instantánea 🌳"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El punto de partida"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El LightGBM afinado del capítulo 18, que es donde SHAP\n",
    "se gana el sueldo: la logística ya se explica con sus coeficientes."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import pandas as pd\n",
    "import shap\n",
    "from scipy.special import expit\n",
    "from sklearn.compose import ColumnTransformer\n",
    "from sklearn.impute import SimpleImputer\n",
    "from sklearn.model_selection import train_test_split\n",
    "from sklearn.pipeline import Pipeline\n",
    "from sklearn.preprocessing import OneHotEncoder, StandardScaler\n",
    "import lightgbm as lgb\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",
    "MEJOR = dict(n_estimators=439, learning_rate=0.0134, num_leaves=9,\n",
    "             min_child_samples=200, subsample=0.6737, subsample_freq=1,\n",
    "             colsample_bytree=0.5664, reg_lambda=0.0123)\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",
    "modelo = Pipeline([('pre', pre),\n",
    "                   ('mod', lgb.LGBMClassifier(random_state=42, verbose=-1, **MEJOR))])\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",
    "modelo.fit(X_tr, y_tr)\n",
    "print('shap', shap.__version__)\n",
    "print('columnas despues del one-hot:', len(modelo.named_steps['pre'].get_feature_names_out()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El error que vas a cometer en el primer intento"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Lo natural es pasarle el modelo entero. No funciona."
   ]
  },
  {
   "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",
    "    shap.TreeExplainer(modelo)\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",
    "InvalidModelError: Model type not yet supported by TreeExplainer: <class 'sklearn.pipeline.Pipeline'>\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "SHAP quiere **el árbol**, no el pipeline. Y tiene sentido: el\n",
    "atajo que hace que esto sea rápido es específico de los árboles, y un\n",
    "`ColumnTransformer` no es un árbol.\n",
    "\n",
    "Así que hay que partirlo en dos: el preprocesado transforma, y el explicador\n",
    "mira solo el modelo. El precio de partirlo es que a partir de aquí trabajas con\n",
    "las 28 columnas de después del one-hot y no con las 13 de antes 🔧"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "preparador = modelo.named_steps['pre']\n",
    "arbol = modelo.named_steps['mod']\n",
    "nombres = list(preparador.get_feature_names_out())\n",
    "X_te_t = preparador.transform(X_te)\n",
    "\n",
    "explicador = shap.TreeExplainer(arbol)\n",
    "valores = np.array(explicador.shap_values(X_te_t))\n",
    "if valores.ndim == 3:            # segun la version, sale una capa por clase\n",
    "    valores = valores[:, :, 1]\n",
    "base = float(np.ravel(explicador.expected_value)[-1])\n",
    "\n",
    "print('forma:', valores.shape)\n",
    "print('valor base (log-odds):', round(base, 4))\n",
    "print('convertido a probabilidad:', round(float(expit(base)), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ese valor base es lo que el modelo predice **sin saber nada** de\n",
    "la fila. Fíjate en que se parece muchísimo a la tasa de compra del archivo, que\n",
    "era 0,5773. Tiene que ser así: es el punto de partida antes de que ninguna\n",
    "columna empuje 📍"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo primero: comprobar que la suma cuadra"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Antes de contarle nada a nadie, se comprueba la propiedad que hace que esto\n",
    "valga: base más aportes tiene que dar la predicción. Exacta."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "probabilidad = modelo.predict_proba(X_te)[:, 1]\n",
    "reconstruida = expit(base + valores.sum(axis=1))\n",
    "\n",
    "print('diferencia maxima en las 750 filas:', float(np.abs(reconstruida - probabilidad).max()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ese número es cero con dieciséis ceros detrás. No es \"casi\": es el error de\n",
    "redondeo de los decimales de la máquina 🎯\n",
    "\n",
    "Y no es un detalle de purista. Significa que cuando le digas a alguien \"este\n",
    "cliente sale arriba por la satisfacción y por no ser nuevo\", esos dos números\n",
    "son **literalmente** de dónde salió la predicción, no una historia\n",
    "razonable construida encima."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo global, que es lo que ya sabíamos hacer"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "importancia = pd.Series(np.abs(valores).mean(axis=0), index=nombres)\n",
    "print(importancia.sort_values(ascending=False).head(10).round(4).to_string())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Promediar el valor absoluto de los aportes da una importancia global, y sale\n",
    "parecida a la del capítulo 20 pero no igual.\n",
    "\n",
    "Vale la pena mirar por qué, porque es la confusión más común con estas dos\n",
    "herramientas."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## SHAP y permutación no miden lo mismo"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Las dos sobre el mismo modelo entrenado, agrupando el one-hot para poder\n",
    "compararlas columna con columna."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.inspection import permutation_importance\n",
    "\n",
    "def agrupa(serie):\n",
    "    \"\"\"Suma las columnas del one-hot para volver a la columna original.\"\"\"\n",
    "    fuera = {}\n",
    "    for clave, valor in serie.items():\n",
    "        base_col = clave.split('__', 1)[1]\n",
    "        if clave.startswith('cat__'):\n",
    "            base_col = base_col.rsplit('_', 1)[0]\n",
    "        fuera[base_col] = fuera.get(base_col, 0) + valor\n",
    "    return pd.Series(fuera)\n",
    "\n",
    "r = permutation_importance(modelo, X_te, y_te, n_repeats=10,\n",
    "                           random_state=42, scoring='roc_auc')\n",
    "tabla = pd.DataFrame({\n",
    "    'shap': agrupa(importancia),\n",
    "    'permutacion': pd.Series(r.importances_mean, index=X_te.columns),\n",
    "}).sort_values('shap', ascending=False)\n",
    "tabla['puesto_shap'] = tabla['shap'].rank(ascending=False).astype(int)\n",
    "tabla['puesto_perm'] = tabla['permutacion'].rank(ascending=False).astype(int)\n",
    "print(tabla.round(4).to_string())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Mira los tres primeros puestos, que no coinciden 👀\n",
    "\n",
    "Para SHAP manda `satisfaccion` y `monto` es tercera.\n",
    "Para la permutación manda `monto` y `satisfaccion` es\n",
    "segunda. Y `canal` pasa del segundo puesto al cuarto.\n",
    "\n",
    "No es que una esté mal. Es que contestan preguntas distintas:\n",
    "\n",
    "- 🔍 **SHAP** mide cuánto *usa* el modelo esa columna para\n",
    "mover sus predicciones. Es una propiedad del modelo.\n",
    "\n",
    "- 💥 **La permutación** mide cuánto *acierto pierde* si le\n",
    "rompes esa columna. Es una propiedad del modelo y de los datos juntos.\n",
    "\n",
    "Una columna que el modelo usa mucho pero que está correlacionada con otra\n",
    "puede tener SHAP alto y permutación baja: la rompes y el modelo se apoya en la\n",
    "gemela sin despeinarse. Es el mismo fenómeno que en el capítulo\n",
    "20 dejó a `sin_descuento` en cero.\n",
    "\n",
    "Regla práctica: **si vas a explicar, SHAP. Si vas a decidir qué columna\n",
    "puedes dejar de recoger, permutación** 📋"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Y ahora sí: por qué ESTE cliente"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Aquí es donde SHAP deja atrás a todo lo demás. Dos clientes con\n",
    "prácticamente la misma probabilidad, buscados a propósito."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "orden = np.argsort(probabilidad)\n",
    "mejor = None\n",
    "for a in range(len(orden)):\n",
    "    for b in range(a + 1, min(a + 40, len(orden))):\n",
    "        i, j = orden[a], orden[b]\n",
    "        if abs(probabilidad[i] - probabilidad[j]) > 0.002:\n",
    "            continue\n",
    "        distancia = np.abs(valores[i] - valores[j]).sum()\n",
    "        if mejor is None or distancia > mejor[0]:\n",
    "            mejor = (distancia, i, j)\n",
    "\n",
    "_d, i, j = mejor\n",
    "print(f'cliente A: {probabilidad[i]:.4f}   cliente B: {probabilidad[j]:.4f}')\n",
    "for fila, quien in ((i, 'A'), (j, 'B')):\n",
    "    top = pd.Series(valores[fila], index=nombres).sort_values(key=abs, ascending=False)\n",
    "    print(f'\\n  cliente {quien}')\n",
    "    for nombre, valor in top.head(4).items():\n",
    "        print(f'    {nombre.split(\"__\")[1]:22s} {valor:+.3f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Dos clientes con 0,46 de probabilidad y **razones opuestas** 🤯\n",
    "\n",
    "Al cliente A la satisfacción lo empuja fuerte hacia arriba (+0,641) y el\n",
    "monto lo tira hacia abajo. Al cliente B lo hunde ser nuevo (−0,692) y encima la\n",
    "satisfacción también le juega en contra.\n",
    "\n",
    "El número que ve el comercial es el mismo. La conversación no puede serlo.\n",
    "\n",
    "A uno hay que llamarlo hablando del producto que ya le gustó. Al otro hay que\n",
    "tratarlo como lo que es, un cliente nuevo, y eso en la práctica es otro guion,\n",
    "otra oferta y hasta otra persona del equipo llamando 📞\n",
    "\n",
    "Esto es literalmente imposible de sacar de una tabla de importancias\n",
    "globales, y es la razón por la que este capítulo existe."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Qué aprendió el modelo sobre una columna"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Agrupando el aporte de una columna por su valor sale lo que el modelo\n",
    "entendió de ella, sin que nadie se lo dijera."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "columna = nombres.index('num__satisfaccion')\n",
    "d = pd.DataFrame({'satisfaccion': X_te['satisfaccion'].values,\n",
    "                  'aporte': valores[:, columna]})\n",
    "\n",
    "print(d.dropna().groupby(d.dropna()['satisfaccion'].round())['aporte']\n",
    "      .agg(['count', 'mean']).round(4).to_string())\n",
    "\n",
    "hueco = d['satisfaccion'].isna()\n",
    "print('\\nfilas sin satisfaccion:', int(hueco.sum()))\n",
    "print('aporte medio de esas   :', round(float(d.loc[hueco, 'aporte'].mean()), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esto es de lo más bonito del libro y no lo programó nadie 💛\n",
    "\n",
    "El modelo aprendió el **orden** de la satisfacción él solo:\n",
    "−0,4692 con un 1, subiendo sin saltarse ningún escalón hasta +0,5866 con un 5.\n",
    "Nunca le dijimos que 5 fuera más que 1; para él eran números y punto.\n",
    "\n",
    "Y mira dónde cae el hueco: −0,1285, entre el 2 y el 3. O sea que **no\n",
    "saber la satisfacción de un cliente es mala señal**, aunque no tan mala\n",
    "como saber que es un 1.\n",
    "\n",
    "Tiene sentido si te acuerdas de que el imputador rellena con la mediana, que\n",
    "en esta columna es 3. Pero el aporte no es el del 3 exacto, así que algo más\n",
    "está pasando ahí, y ese algo es `sin_satisfaccion`, la bandera que\n",
    "construimos en el capítulo 9 justo para esto 🕳️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## PDP: la forma de la relación, y una trampa silenciosa"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "SHAP explica filas. El PDP (gráfico de dependencia parcial) hace otra cosa:\n",
    "mueve una columna por todo su rango y mira qué le pasa a la predicción media."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.inspection import partial_dependence\n",
    "\n",
    "p = partial_dependence(modelo, X_te, ['satisfaccion'], kind='average',\n",
    "                       grid_resolution=5)\n",
    "print('valores probados:', p['grid_values'][0])\n",
    "print('probabilidad media:', np.round(p['average'][0], 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí tienes la trampa, y da escalofrío: **la rejilla es `nan`\n",
    "y la curva es una línea plana**. Sin error, sin aviso, sin nada.\n",
    "\n",
    "Lo que pasó es que `satisfaccion` tiene 56 nulos en el examen, y\n",
    "scikit-learn arma la rejilla con percentiles. Un percentil con nulos dentro es\n",
    "`nan`, así que probó cinco veces el valor `nan` y le salió\n",
    "cinco veces lo mismo.\n",
    "\n",
    "Quien no mire la rejilla se lleva a la reunión un gráfico plano y concluye\n",
    "que la satisfacción no importa. Justo al revés de lo que acabamos de medir con\n",
    "SHAP 😨\n",
    "\n",
    "La solución es decirle a mano qué valores probar:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "p = partial_dependence(modelo, X_te, ['satisfaccion'], kind='both',\n",
    "                       custom_values={'satisfaccion': [1., 2., 3., 4., 5.]})\n",
    "print('valores probados:', p['grid_values'][0])\n",
    "print('probabilidad media:', np.round(p['average'][0], 4))\n",
    "\n",
    "curvas = p['individual'][0]\n",
    "cambio = curvas[:, -1] - curvas[:, 0]\n",
    "print('\\nfilas donde sube:', int((cambio > 0).sum()),\n",
    "      '| donde baja:', int((cambio < 0).sum()))\n",
    "print('subida minima', round(float(cambio.min()), 4),\n",
    "      'maxima', round(float(cambio.max()), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahora sí: de 0,4817 a 0,7079. Veintitrés puntos de probabilidad entre un\n",
    "cliente de satisfacción 1 y uno de 5, con todo lo demás igual.\n",
    "\n",
    "Y esa segunda parte es el ICE, que dibuja una curva por fila en vez de la\n",
    "media. Sirve para cazar el caso en que la media miente: si la mitad sube y la\n",
    "otra mitad baja, el promedio sale plano y no hay efecto ninguno que reportar.\n",
    "\n",
    "Aquí sube en las 750, así que la media no tapaba nada. Lo digo porque es el\n",
    "resultado que salió, no el que hacía más bonita la explicación 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. La ficha de un cliente, lista para el comercial\n",
    "\n",
    "Convierte los aportes de una fila en tres frases en\n",
    "castellano. Eso es lo que se pega en el CRM, no una tabla de números."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%revisa 1\n",
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. Contra quién se compara este cliente\n",
    "\n",
    "Un aporte se lee contra el valor base. Comprueba que el\n",
    "mismo cliente en un modelo entrenado con otra tasa de compra tendría los mismos\n",
    "aportes relativos pero otra base."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%revisa 2\n",
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. ¿La satisfacción pesa igual en todos los segmentos?\n",
    "\n",
    "Aquí es donde SHAP encuentra interacciones sin que nadie\n",
    "las busque: se agrupa el aporte de una columna por otra."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%revisa 3\n",
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. Las cuentas del comercial, en soles\n",
    "\n",
    "Junta SHAP con el umbral del capítulo\n",
    "15: quién entra en las 200 llamadas y por qué."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%revisa 4\n",
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. Cuando SHAP tarda: la muestra\n",
    "\n",
    "Con 750 filas es instantáneo. Con dos millones no. Mide\n",
    "cuánto cambia la importancia global si la calculas sobre una muestra."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%revisa 5\n",
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. El PDP de una columna sin huecos, para ver la diferencia\n",
    "\n",
    "La trampa del `nan` solo aparece con columnas\n",
    "que tienen nulos. Compruébalo con una que no los tiene."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%revisa 6\n",
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 7. El que más se equivocó, explicado\n",
    "\n",
    "Los aportes también sirven para depurar. Busca la fila\n",
    "donde el modelo estuvo más seguro y falló, y mira quién lo engañó."
   ]
  },
  {
   "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",
    "Dos clientes de tu lista tienen 0,46 de probabilidad. ¿Qué te dice eso de por qué salieron ahí?\n",
    "\n",
    "a) Nada: la misma probabilidad puede venir de razones opuestas\n",
    "\n",
    "b) Que se parecen, porque el modelo los puntuó igual\n",
    "\n",
    "c) Que pesó la columna más importante del modelo en los dos\n",
    "\n",
    "d) Que están en el mismo segmento"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La recomendación que SHAP no puede sostener"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "El gráfico de SHAP deja clarísimo que la satisfacción es lo que más empuja a que la venta se cierre. Y de ahí sale la recomendación, que es lo que te pidieron.\n",
    "\n",
    "```\n",
    "# SHAP: satisfaccion es la que mas aporta\n",
    "\n",
    "# recomendacion al cliente:\n",
    "#   \"subir la satisfaccion 1 punto\n",
    "#    sube el cierre un 7%\"\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 valor SHAP es cuánto aportó una columna **a esa fila**,\n",
    "no al modelo. Es la diferencia entre \"el segmento importa\" y \"a esta señora la\n",
    "sube su segmento\".\n",
    "\n",
    "- ✅ Base más aportes da la predicción exacta. En las 750 filas la diferencia\n",
    "máxima fue del orden de 10⁻¹⁶, que es el redondeo de la máquina.\n",
    "\n",
    "- 🔌 `TreeExplainer` quiere el modelo, no el pipeline. Hay que\n",
    "partirlo: el preparador transforma y el explicador mira solo el árbol.\n",
    "\n",
    "- 🎭 Dos clientes con 0,46 de probabilidad por razones opuestas: a uno lo sube\n",
    "la satisfacción, al otro lo hunde ser nuevo. Mismo número, otra llamada.\n",
    "\n",
    "- 🔀 SHAP y permutación no coinciden ni en el podio, y no tienen por qué: una\n",
    "mide cuánto usa el modelo la columna y la otra cuánto acierto pierde si se la\n",
    "rompes.\n",
    "\n",
    "- 📈 El modelo aprendió solo el orden de la satisfacción, de −0,4692 con un 1\n",
    "a +0,5866 con un 5, sin que nadie le dijera que 5 es más que 1.\n",
    "\n",
    "- 🕳️ Y colocó el hueco en −0,1285, entre el 2 y el 3: no saber la\n",
    "satisfacción de alguien es mala señal, pero menos que saber que es un 1.\n",
    "\n",
    "- 🚨 El PDP de una columna con nulos sale plano y con la rejilla en\n",
    "`nan`, sin error ninguno. Mira la rejilla antes que la curva, o pasa\n",
    "los valores a mano con `custom_values`.\n",
    "\n",
    "- 🧊 Con la rejilla arreglada, de 0,4817 a 0,7079 según la satisfacción. Y el\n",
    "ICE dice que sube en las 750 filas, así que la media no tapaba nada.\n",
    "\n",
    "Y si de todo el capítulo te llevas una sola frase, que sea esta:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "SHAP explica al modelo, no al mundo. Y el mundo es lo que tu cliente quiere cambiar."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "La diferencia entre predecir y explicar una causa da para un libro entero, y la parte estadística está en el [libro de estadística desde cero](https://missyera.com/guias/estadistica-desde-cero/) 📈"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Qué viene ahora"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ya sabemos por qué el modelo dice lo que dice. Falta lo que aprendió sin que\n",
    "nadie se lo pidiera 😬\n",
    "\n",
    "El capítulo 24 abre el modelo por grupos y hace la\n",
    "pregunta incómoda: ¿acierta igual con las bodegas que con los mayoristas? Y\n",
    "después, cuánta confianza merece cada predicción por separado.\n",
    "\n",
    "Lo que verás allí:\n",
    "\n",
    "- ⚖️ Las métricas del capítulo 14 calculadas dentro de cada\n",
    "segmento, que es donde salen las diferencias que el promedio esconde.\n",
    "\n",
    "- 🎲 Cuánto de esa diferencia es real y cuánto es la lotería de la partición,\n",
    "medido con diez sorteos.\n",
    "\n",
    "- 📏 Calibración: si el modelo dice 70%, ¿de cada diez cierran siete?\n",
    "\n",
    "- 🛡️ Y la predicción conforme, que en vez de un número da un conjunto y\n",
    "promete cubrir la respuesta el 90% de las veces."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 21 de **Machine learning desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/machine-learning-desde-cero/shap-y-pdp/\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
}
