{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# El umbral\n",
    "\n",
    "El 0,5 que viene de fábrica, cuánto cuesta cada error, y por qué un modelo casi nunca decide: ordena.\n",
    "\n",
    "Cuaderno de soluciones del capítulo 15 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/umbral-y-costo/\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": [
    "Todo lo que hemos hecho hasta ahora tenía un 0,5 escondido dentro 🎚️\n",
    "\n",
    "Y va una pregunta: **¿alguna vez has puesto un número redondo en un sitio importante solo porque venía por defecto?** Yo sí, muchas veces, y este capítulo va de una de esas 🫣\n",
    "\n",
    "Cuando llamas a `predict()`, el modelo calcula una probabilidad y\n",
    "la compara contra 0,5. Si pasa, dice que sí. Ese número no lo eligió nadie: es\n",
    "el que viene de fábrica.\n",
    "\n",
    "Y en este capítulo vamos a ver que **elegirlo bien vale S/65.750**\n",
    "sobre 750 oportunidades, sin tocar el modelo."
   ]
  },
  {
   "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 confusion_matrix\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",
    "probabilidad = modelo.predict_proba(X_te)[:, 1]\n",
    "print('750 oportunidades, probabilidades entre',\n",
    "      round(probabilidad.min(), 3), 'y', round(probabilidad.max(), 3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los dos precios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Del capítulo 2, y esto no lo decide quien programa:\n",
    "\n",
    "- 🔔 **Falso positivo:** llamamos a alguien que no iba a comprar.\n",
    "Cuesta quince minutos del vendedor, digamos **S/15**.\n",
    "\n",
    "- 💸 **Falso negativo:** no llamamos a alguien que sí iba a\n",
    "comprar. Se pierde la venta entera, digamos **S/800**.\n",
    "\n",
    "Cincuenta y tres veces más caro uno que el otro. Y ahora la pregunta:\n",
    "*¿por qué íbamos a cortar en el medio?*"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El costo de cada umbral"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Costo(u)=cFPFP(u)+cFNFN(u)\n",
    "\n",
    "el umbral bueno no es 0,5, es el que hace más chica la suma de lo que cuestan los dos errores en tu negocio"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "COSTO_FP, COSTO_FN = 15, 800\n",
    "\n",
    "def costo(umbral):\n",
    "    prediccion = (probabilidad >= umbral).astype(int)\n",
    "    tn, fp, fn, tp = confusion_matrix(y_te, prediccion, labels=[0, 1]).ravel()\n",
    "    return fp, fn, fp * COSTO_FP + fn * COSTO_FN\n",
    "\n",
    "for u in (0.15, 0.3, 0.5, 0.7, 0.9):\n",
    "    fp, fn, total = costo(u)\n",
    "    print(f'umbral {u:.2f}   falsas alarmas {fp:3d}   ventas perdidas {fn:3d}   S/{total:,}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Léelo de arriba abajo, porque es de las tablas más útiles de todo el libro.\n",
    "\n",
    "Con el 0,5 de fábrica perdemos **S/70.400**. Con 0,15,\n",
    "**S/4.650**.\n",
    "\n",
    "Quince veces menos. Mismo modelo, mismos datos, mismo día. Solo movimos un\n",
    "número 😳"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Buscarlo bien"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "candidatos = [(costo(u)[2], round(u, 2)) for u in np.arange(0.01, 0.99, 0.01)]\n",
    "mejor_costo, mejor_umbral = min(candidatos)\n",
    "\n",
    "print('umbral óptimo:', mejor_umbral)\n",
    "print('costo ahí    :', f'S/{mejor_costo:,}')\n",
    "print('costo en 0,5 :', f'S/{costo(0.5)[2]:,}')\n",
    "print('ahorro       :', f'S/{costo(0.5)[2] - mejor_costo:,}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "0,14 y S/65.750 de ahorro sobre 750 oportunidades. Anualizado sobre el archivo\n",
    "entero, es una cifra que hace que valga la pena el proyecto entero 💰\n",
    "\n",
    "**Y no costó ni una línea de modelo.** Costó preguntar cuánto\n",
    "vale cada error, que es la pregunta del capítulo 2."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que sale de ahí, y que incomoda un poco"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('a cuántos llama con umbral 0,14:', int((probabilidad >= 0.14).sum()), 'de 750')\n",
    "print('a cuántos llama con umbral 0,50:', int((probabilidad >= 0.50).sum()), 'de 750')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "743 de 750. O sea que, con esos precios, **la respuesta correcta es\n",
    "llamar a casi todo el mundo**.\n",
    "\n",
    "Y tiene toda la lógica: si una llamada cuesta S/15 y una venta vale S/800, no\n",
    "llamar es una apuesta malísima. El modelo no hacía falta para decidir a quién\n",
    "llamar.\n",
    "\n",
    "Esto pasa de verdad y hay que decirlo en la reunión en vez de esconderlo. Pero\n",
    "no significa que el modelo no sirva, y la razón es la del capítulo 2:\n",
    "**nadie puede hacer 743 llamadas** 📞"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Y ahí es donde el modelo sí vale"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cuando la capacidad es la que manda, la decisión ya no es un umbral: es un\n",
    "orden."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "orden = np.argsort(probabilidad)[::-1]\n",
    "\n",
    "print('capacidad  con modelo  al azar  ventas de más')\n",
    "for k in (100, 200, 300, 500):\n",
    "    con_modelo = int(y_te.iloc[orden[:k]].sum())\n",
    "    al_azar = round(k * y_te.mean())\n",
    "    print(f'{k:9d}  {con_modelo:10d}  {al_azar:7.0f}  {con_modelo - al_azar:+3d}'\n",
    "          f'   S/{(con_modelo - al_azar) * 800:,}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Con 200 llamadas: 159 ventas usando el modelo contra 115 llamando al azar.\n",
    "**Cuarenta y cuatro ventas más por el mismo trabajo**, o sea\n",
    "S/35.200 🎯\n",
    "\n",
    "Y fíjate en la última fila: con 500 llamadas la ganancia baja a 54. Cuanto más\n",
    "llamas, menos aporta ordenar, porque al final llamas a todos igual. La ganancia\n",
    "de un modelo de ranking **es más grande cuanto más apretada esté la\n",
    "capacidad**."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Cuándo el 0,5 sí es correcto"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Vamos a mover los precios y ver qué le pasa al umbral óptimo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def optimo(costo_fp, costo_fn):\n",
    "    mejor = None\n",
    "    for u in np.arange(0.01, 0.99, 0.01):\n",
    "        p = (probabilidad >= u).astype(int)\n",
    "        tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()\n",
    "        total = fp * costo_fp + fn * costo_fn\n",
    "        if mejor is None or total < mejor[0]:\n",
    "            mejor = (total, round(u, 2))\n",
    "    return mejor\n",
    "\n",
    "for cfp in (15, 100, 400, 800):\n",
    "    total, u = optimo(cfp, 800)\n",
    "    print(f'falso positivo S/{cfp:3d} contra falso negativo S/800 -> umbral {u}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí está la respuesta completa: el umbral sube según el falso positivo se\n",
    "acerca al falso negativo, y **cuando los dos cuestan igual, el óptimo es\n",
    "0,49**, o sea el 0,5 de fábrica.\n",
    "\n",
    "Así que ese 0,5 no está mal: **está bien para un caso concreto, el de\n",
    "los errores que cuestan lo mismo**. Lo que está mal es usarlo sin\n",
    "preguntarse si es tu caso 🎚️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Cómo se hace bien, que es lo que se olvida"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un aviso importante: **el umbral se elige en validación, no en el\n",
    "examen**. Si lo buscas sobre los mismos datos donde lo mides, estás\n",
    "haciendo lo mismo que el capítulo 12 y el número sale optimista.\n",
    "\n",
    "La forma correcta es partir en tres: entrenar, elegir umbral, y medir. O usar\n",
    "validación cruzada para elegirlo, que es el capítulo 16.\n",
    "\n",
    "Aquí lo hice sobre el examen para que se vea la idea con menos código, y te lo\n",
    "digo en vez de tapártelo 💛\n",
    "\n",
    "Ahora hagámoslo bien, que son cuatro líneas más, y de paso medimos cuánto nos\n",
    "estaba mintiendo el atajo:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "X_ent, X_val, y_ent, y_val = train_test_split(X_tr, y_tr, test_size=0.3,\n",
    "                                              random_state=42, stratify=y_tr)\n",
    "bien = Pipeline([\n",
    "    ('pre', ColumnTransformer([\n",
    "        ('num', Pipeline([('r', SimpleImputer(strategy='median')),\n",
    "                          ('e', StandardScaler())]), NUMERICAS),\n",
    "        ('cat', Pipeline([('r', SimpleImputer(strategy='most_frequent')),\n",
    "                          ('c', OneHotEncoder(handle_unknown='ignore'))]), CATEGORICAS),\n",
    "    ])),\n",
    "    ('mod', LogisticRegression(max_iter=1000, random_state=42)),\n",
    "]).fit(X_ent, y_ent)\n",
    "p_val = bien.predict_proba(X_val)[:, 1]\n",
    "p_test = bien.predict_proba(X_te)[:, 1]\n",
    "\n",
    "def costo_de(prob, objetivo, umbral):\n",
    "    tn, fp, fn, tp = confusion_matrix(objetivo, (prob >= umbral).astype(int),\n",
    "                                      labels=[0, 1]).ravel()\n",
    "    return fp * COSTO_FP + fn * COSTO_FN\n",
    "\n",
    "umbrales = np.arange(0.05, 0.96, 0.01)\n",
    "elegido = umbrales[np.argmin([costo_de(p_val, y_val, u) for u in umbrales])]\n",
    "tramposo = umbrales[np.argmin([costo_de(p_test, y_te, u) for u in umbrales])]\n",
    "\n",
    "print('umbral elegido en validación:', round(elegido, 2))\n",
    "print('  lo que costó en validación: S/', costo_de(p_val, y_val, elegido))\n",
    "print('  lo que cuesta en el examen: S/', costo_de(p_test, y_te, elegido))\n",
    "print('umbral elegido haciendo trampa:', round(tramposo, 2),\n",
    "      ' cuesta S/', costo_de(p_test, y_te, tramposo))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El umbral honesto sale 0,12 y el tramposo 0,14. Y la diferencia de costo entre\n",
    "los dos, medida en el examen, es de **S/30 sobre S/4.650**.\n",
    "\n",
    "O sea que aquí el atajo casi no mintió, y lo digo aunque me deje sin\n",
    "moraleja dramática 😅\n",
    "\n",
    "Pero fíjate en la otra línea, que es la que sí importa: en validación el\n",
    "umbral parecía costar S/4.230 y en el examen cuesta S/4.680. **Ese es el\n",
    "optimismo de verdad**, y no está en el umbral elegido sino en la cifra\n",
    "que ibas a reportar.\n",
    "\n",
    "La regla se queda igual: el umbral se elige en validación y el número que\n",
    "prometes sale del examen. Que el atajo salga barato en un dataset no significa\n",
    "que salga barato en el tuyo 🎚️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Siete. Intenta antes de abrir 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. El umbral elegido como se debe\n",
    "\n",
    "Parte en tres, elige el umbral en el trozo del medio y\n",
    "mídelo en el último."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "X_ent, X_resto, y_ent, y_resto = train_test_split(X, y, test_size=0.4,\n",
    "                                                  random_state=42, stratify=y)\n",
    "X_val, X_fin, y_val, y_fin = train_test_split(X_resto, y_resto, test_size=0.5,\n",
    "                                              random_state=42, stratify=y_resto)\n",
    "print(len(X_ent), len(X_val), len(X_fin))\n",
    "\n",
    "m3 = Pipeline([('pre', pre),\n",
    "               ('mod', LogisticRegression(max_iter=1000, random_state=42))]).fit(X_ent, y_ent)\n",
    "\n",
    "def costo_en(prob, y_v, u):\n",
    "    p = (prob >= u).astype(int)\n",
    "    tn, fp, fn, tp = confusion_matrix(y_v, p, labels=[0, 1]).ravel()\n",
    "    return fp * 15 + fn * 800\n",
    "\n",
    "p_val = m3.predict_proba(X_val)[:, 1]\n",
    "u_elegido = min(np.arange(0.01, 0.99, 0.01), key=lambda u: costo_en(p_val, y_val, u))\n",
    "\n",
    "p_fin = m3.predict_proba(X_fin)[:, 1]\n",
    "print('umbral elegido en validación:', round(u_elegido, 2))\n",
    "print('costo con ese umbral:', f'S/{costo_en(p_fin, y_fin, u_elegido):,}')\n",
    "print('costo con 0,5       :', f'S/{costo_en(p_fin, y_fin, 0.5):,}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "1800 600 600\n",
    "umbral elegido en validación: 0.18\n",
    "costo con ese umbral: S/4,430\n",
    "costo con 0,5       : S/57,105\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El umbral elegido en un trozo funciona igual de bien en otro que no vio nadie.\n",
    "Eso es lo que quieres comprobar: que la decisión no estaba pegada a esas 750\n",
    "filas concretas ✅"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. El costo por oportunidad\n",
    "\n",
    "Pasa el costo total a soles por oportunidad, que es como se\n",
    "presenta."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for u in (0.14, 0.5):\n",
    "    fp, fn, total = costo(u)\n",
    "    print(f'umbral {u}: S/{total:,} en {len(y_te)} oportunidades'\n",
    "          f' = S/{total / len(y_te):.2f} por oportunidad')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "umbral 0.14: S/4,650 en 750 oportunidades = S/6.20 por oportunidad\n",
    "umbral 0.5: S/70,400 en 750 oportunidades = S/93.87 por oportunidad\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "S/6,20 contra S/93,87 por oportunidad. Ese es el número que va en la\n",
    "propuesta, porque se multiplica solo por el volumen del cliente 💼"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. Cuando el falso positivo es el caro\n",
    "\n",
    "Al revés: enviar un cupón de S/200 a quien iba a comprar\n",
    "igual, contra perder una venta de S/300."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "total, u = optimo(200, 300)\n",
    "print('umbral óptimo:', u)\n",
    "print('a cuántos llega:', int((probabilidad >= u).sum()), 'de 750')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "umbral óptimo: 0.39\n",
    "a cuántos llega: 609 de 750\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El umbral sube de 0,14 a 0,39 y el modelo pasa de avisar 743 veces a 609.\n",
    "Tiene todo el sentido: si regalar el cupón cuesta dos tercios de lo que vale la\n",
    "venta, hay que ser bastante más selectiva.\n",
    "\n",
    "Es el mismo modelo, los mismos datos y el mismo día, contestando dos preguntas\n",
    "de negocio distintas. **Lo único que cambió fueron dos números que da el\n",
    "cliente** 🎁"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. La ganancia, no el costo\n",
    "\n",
    "Dale la vuelta: en vez de contar lo que se pierde, cuenta lo\n",
    "que se gana."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "MARGEN = 250\n",
    "\n",
    "def ganancia(u):\n",
    "    p = (probabilidad >= u).astype(int)\n",
    "    tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()\n",
    "    return tp * MARGEN - (tp + fp) * 15\n",
    "\n",
    "for u in (0.14, 0.3, 0.5, 0.7):\n",
    "    print(f'umbral {u}: S/{ganancia(u):,}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "umbral 0.14: S/97,105\n",
    "umbral 0.3: S/94,710\n",
    "umbral 0.5: S/79,380\n",
    "umbral 0.7: S/40,215\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Contar la ganancia en vez del costo da el mismo orden y se presenta mucho\n",
    "mejor: nadie se emociona con \"perdimos menos\", pero \"ganamos S/97.105\" sí se\n",
    "entiende 💰\n",
    "\n",
    "El truco está en que cada venta cerrada deja margen y cada llamada cuesta, la\n",
    "haya cerrado o no."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. El error de comparar probabilidades con etiquetas\n",
    "\n",
    "Calcula la matriz de confusión pasándole las probabilidades\n",
    "sin cortar."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "confusion_matrix(y_te, probabilidad)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "ValueError: Classification metrics can't handle a mix of binary and continuous targets\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*can't handle a mix of binary and continuous targets*, el mismo del\n",
    "capítulo 14. Y aquí duele más, porque es justo el paso que se olvida: la\n",
    "probabilidad hay que **cortarla** antes de compararla.\n",
    "\n",
    "Esa comparación `(probabilidad >= umbral).astype(int)` es todo lo\n",
    "que hay dentro de `predict()`. Nada más 🎚️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. La tabla que se lleva a la reunión\n",
    "\n",
    "Cinco umbrales con todo lo que pregunta un gerente."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('umbral  llama a  cierra  precisión  costo')\n",
    "for u in (0.14, 0.25, 0.4, 0.5, 0.65):\n",
    "    p = (probabilidad >= u).astype(int)\n",
    "    tn, fp, fn, tp = confusion_matrix(y_te, p, labels=[0, 1]).ravel()\n",
    "    prec = tp / (tp + fp) if (tp + fp) else 0\n",
    "    print(f'{u:6.2f}  {tp + fp:7d}  {tp:6d}  {prec:9.3f}  S/{fp * 15 + fn * 800:,}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "umbral  llama a  cierra  precisión  costo\n",
    "  0.14      743     433      0.583  S/4,650\n",
    "  0.25      718     428      0.596  S/8,350\n",
    "  0.40      600     391      0.652  S/36,735\n",
    "  0.50      508     348      0.685  S/70,400\n",
    "  0.65      299     231      0.773  S/162,620\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cada fila es una política comercial distinta, y la elige el negocio, no tú.\n",
    "Tu trabajo es poner la tabla encima de la mesa con los soles al lado 📋"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 7. El modelo no decide, el modelo ordena\n",
    "\n",
    "Junta las dos ideas del capítulo: con capacidad de 200,\n",
    "cuál es el corte de probabilidad que corresponde."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "corte = np.sort(probabilidad)[::-1][199]\n",
    "print('probabilidad del puesto 200:', round(corte, 4))\n",
    "print('llamando por umbral 0,14:', int((probabilidad >= 0.14).sum()))\n",
    "print('llamando por capacidad  :', 200)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "probabilidad del puesto 200: 0.7109\n",
    "llamando por umbral 0,14: 743\n",
    "llamando por capacidad  : 200\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Con capacidad de 200, el umbral efectivo es 0,7109. No lo elegiste tú: lo\n",
    "eligió el tamaño del equipo comercial.\n",
    "\n",
    "Y esa es la conclusión del capítulo, que quiero que se te quede:\n",
    "**un modelo de clasificación en la vida real casi nunca decide sí o no,\n",
    "casi siempre ordena una lista que alguien va a recorrer hasta donde le\n",
    "alcance** 📞"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "¿De dónde sale el 0,5 que usa el modelo para decidir?\n",
    "\n",
    "a) De la librería, y casi nunca es el que le conviene a tu negocio\n",
    "\n",
    "b) De la teoría estadística\n",
    "\n",
    "c) De los datos de entrenamiento\n",
    "\n",
    "d) Es el valor que maximiza la exactitud\n",
    "\n",
    "---\n",
    "\n",
    "**La correcta es la a.**\n",
    "\n",
    "*b)* Es una convención cómoda, no un resultado. Piensa qué pasa si un error cuesta cinco veces más que el otro.\n",
    "\n",
    "*c)* El modelo no lo aprende: viene puesto de fábrica.\n",
    "\n",
    "*d)* A veces coincide, y solo cuando los dos errores cuestan lo mismo.\n",
    "\n",
    "Elegir el umbral es una decisión de costos, y es de las pocas partes del proyecto que decide el negocio."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Y el umbral que se queda clavado"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "Encontraste el umbral óptimo, 0,14, y lo dejas fijo en el código que se va a producción. Con eso ahorras S/65.750 y el proyecto se cierra.\n",
    "\n",
    "```\n",
    "UMBRAL = 0.14\n",
    "\n",
    "def decidir(cliente):\n",
    "    p = modelo.predict_proba(cliente)[0, 1]\n",
    "    return p > UMBRAL\n",
    "```\n",
    "\n",
    "**Qué está mal**\n",
    "\n",
    "Ese 0,14 no salió del modelo: salió de **tres cosas a la vez**. Del costo de llamar, del costo de perder una venta, y de qué porcentaje de ventas se cerraba cuando lo calculaste 🎚️\n",
    "\n",
    "Las tres se mueven. Sube el sueldo del equipo comercial y la llamada deja de costar S/15. Llega temporada baja y el 57,7% de cierre se convierte en otro número. El umbral sigue clavado en 0,14 y nadie se entera, porque el código no falla: sigue devolviendo verdadero y falso como el primer día.\n",
    "\n",
    "Un umbral es una decisión de negocio con fecha de caducidad, así que se guarda **con los dos precios al lado** y se recalcula cuando alguno cambia. Vigilar eso es el capítulo 27."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- 🎚️ `predict()` lleva un 0,5 escondido que no eligió nadie.\n",
    "\n",
    "- 💰 Con falso positivo a S/15 y falso negativo a S/800, el óptimo es 0,14 y\n",
    "ahorra S/65.750 sobre 750 oportunidades. Sin tocar el modelo.\n",
    "\n",
    "- ⚖️ El 0,5 solo es correcto cuando los dos errores cuestan igual: con costos\n",
    "iguales el óptimo sale 0,49.\n",
    "\n",
    "- 😐 Con esos precios el óptimo llama a 743 de 750, o sea a casi todos. Hay que\n",
    "decirlo, no esconderlo.\n",
    "\n",
    "- 📞 Y ahí entra la capacidad: con 200 llamadas, el modelo trae 159 ventas\n",
    "contra 115 al azar. Cuarenta y cuatro más por el mismo trabajo.\n",
    "\n",
    "- 🔒 El umbral se elige en validación, nunca en el examen.\n",
    "\n",
    "- 📋 Tu trabajo es poner la tabla de umbrales con soles al lado. La política la\n",
    "elige el negocio.\n",
    "\n",
    "Y si de todo el capítulo te llevas una sola frase, que sea esta:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El 0,5 no lo eligió nadie. Y aun así decide a quién llamas."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Si te toca defender estos números delante de alguien que decide, el [artículo de cómo ser analista de datos](https://missyera.com/blog/ser-analista-de-datos/) habla justo de esa parte del trabajo 💼\n",
    "\n",
    "En el capítulo 16 dejamos de fiarnos de una sola partición: validación cruzada,\n",
    "búsqueda de hiperparámetros y cómo no engañarse a una misma.\n",
    "\n",
    "Que tengas lindo día! 🌸"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 15 de **Machine learning desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/machine-learning-desde-cero/umbral-y-costo/\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
}
