{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Gobernanza de datos\n",
    "\n",
    "Lo que una senior escribe antes de tocar un modelo, y que casi nunca aparece: el perfil de calidad, los sesgos declarados y el número que decide si el proyecto sirve.\n",
    "\n",
    "Cuaderno de soluciones del capítulo 3 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/gobernanza-y-sesgos/\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": [
    "Este capítulo es el que separa a alguien que entrena modelos de alguien que\n",
    "entrega proyectos 🎓\n",
    "\n",
    "Empiezo preguntándote algo: **¿alguna vez te han pedido un modelo sin decirte contra qué se iba a comparar?** A mí muchas veces, y siempre acaba igual: el que decide si sirve es quien lo mira, y decide después de verlo 🎯\n",
    "\n",
    "En el capítulo 2 escribimos el contrato: qué es una fila, qué significa la\n",
    "etiqueta, en qué momento se predice. Ahora falta lo otro, que es igual de\n",
    "aburrido de escribir y salva igual de proyectos:\n",
    "\n",
    "- 📋 **El perfil de calidad.** Columna por columna, qué tan\n",
    "confiable es lo que hay dentro.\n",
    "\n",
    "- ⚖️ **Los sesgos declarados.** Quién está de más y quién está de\n",
    "menos en estos datos.\n",
    "\n",
    "- 🔒 **La política de lo sensible.** Qué columnas no se usan, y\n",
    "por qué.\n",
    "\n",
    "- 🎯 **El umbral de aceptación.** A partir de qué número el\n",
    "proyecto sirve. Escrito ANTES de ver el resultado."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El perfil de calidad, en una tabla"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "\n",
    "URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'\n",
    "\n",
    "\n",
    "def carga_limpia(url):\n",
    "    \"\"\"La del capítulo 4, resumida.\"\"\"\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',\n",
    "                                  errors='coerce')\n",
    "        v[col] = f\n",
    "    return v\n",
    "\n",
    "\n",
    "v = carga_limpia(URL)\n",
    "\n",
    "perfil = pd.DataFrame({\n",
    "    'tipo': v.dtypes.astype(str),\n",
    "    'nulos': v.isna().sum(),\n",
    "    'pct_nulos': (100 * v.isna().mean()).round(2),\n",
    "    'distintos': v.nunique(),\n",
    "})\n",
    "print(perfil.to_string())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esa tabla es la primera página del informe, y se lee de una 👀\n",
    "\n",
    "Tres columnas tienen huecos y ninguna es poca cosa:\n",
    "**descuento** con el 19,83%, **fecha_ultima_compra**\n",
    "con el 18,13% y **satisfaccion** con el 7,70%. Eso son tres\n",
    "decisiones que hay que tomar antes de modelar, no tres detalles.\n",
    "\n",
    "Y la columna de valores distintos ya delata los tipos, que es el capítulo\n",
    "siguiente: 3.000 valores distintos en 3.000 filas es un identificador, y 4 o 5\n",
    "valores es una categoría por mucho que esté escrita con números."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los duplicados, contra la clave de negocio"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`drop_duplicates()` quita filas idénticas. Pero el duplicado que\n",
    "duele es otro: **la misma venta cargada dos veces con distinto\n",
    "identificador**."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('filas:            %d' % len(v))\n",
    "print('id_venta unicos:  %d' % v['id_venta'].nunique())\n",
    "print('duplicados por cliente + fecha + monto: %d'\n",
    "      % v.duplicated(subset=['cliente_id', 'fecha', 'monto']).sum())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Limpio ✅ Pero fíjate en que lo comprobé contra la clave de negocio\n",
    "(*quién, cuándo, cuánto*) y no contra el identificador, que por definición\n",
    "nunca se repite.\n",
    "\n",
    "Si esto hubiera salido con 40 duplicados, todo lo que viene después estaría\n",
    "contando cuarenta ventas dos veces, y el modelo creería tener más evidencia de\n",
    "la que tiene."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los rangos que no pueden ser"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for col in ['unidades', 'monto', 'descuento', 'satisfaccion']:\n",
    "    s = v[col]\n",
    "    print('%-13s de %9.2f a %9.2f | negativos %3d | ceros %3d'\n",
    "          % (col, s.min(), s.max(), (s < 0).sum(), (s == 0).sum()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí están las 21 ventas negativas otra vez 🔴\n",
    "\n",
    "Lo importante de este paso no es encontrarlas: es **escribir qué rango\n",
    "es posible para cada columna antes de mirar**. Un descuento va de 0 a 1,\n",
    "una satisfacción de 1 a 5, unas unidades no pueden ser cero. Cuando eso está\n",
    "escrito, el día que llegue un archivo nuevo la comprobación es automática."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los sesgos, declarados"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "La pregunta es: *¿estos datos representan al negocio, o representan a una\n",
    "parte?*"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for col in ['segmento', 'canal', 'ciudad']:\n",
    "    p = v[col].value_counts(normalize=True)\n",
    "    print('%-9s el mas chico %.4f | el mas grande %.4f | desbalance %.2fx'\n",
    "          % (col, p.min(), p.max(), p.max() / p.min()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un desbalance de 1,13 quiere decir que el grupo más representado tiene un 13%\n",
    "más de filas que el menos representado. Eso es **equilibradísimo**,\n",
    "y me hace levantar una ceja 🤨\n",
    "\n",
    "En datos reales de una distribuidora es rarísimo que los cuatro segmentos\n",
    "tengan casi las mismas ventas. Lo anoto en el informe como lo que es: una\n",
    "sospecha de que este archivo fue muestreado, no volcado entero. Y si fue\n",
    "muestreado, **las proporciones que salgan de aquí no son las del\n",
    "negocio**.\n",
    "\n",
    "Eso no invalida el modelo (el modelo aprende relaciones, no proporciones),\n",
    "pero sí invalida cualquier frase del tipo \"el 25% de nuestras ventas son de\n",
    "mayoristas\"."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo sensible"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Aquí no hay nombres ni documentos de identidad, pero sí hay una columna que\n",
    "merece política: `ciudad`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(v.groupby('ciudad')['compro'].agg(['mean', 'count']).round(4).to_string())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Las seis ciudades cierran prácticamente igual, así que **usar ciudad no\n",
    "va a discriminar a nadie por dónde vive**, sencillamente porque no aporta\n",
    "información.\n",
    "\n",
    "Pero fíjate en el orden de la frase, porque importa: primero se mide, después\n",
    "se decide. Si Cusco cerrara el 20% y Lima el 80%, meter ciudad en el modelo\n",
    "significaría que a un cliente de Cusco se le llama menos *por ser de\n",
    "Cusco*, y eso ya no es una decisión técnica 🔒\n",
    "\n",
    "La política que escribo es esta: *ciudad entra al modelo porque no\n",
    "discrimina; si en una versión futura empieza a pesar, se saca y se documenta*."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Y ahora el que casi nadie escribe"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El umbral de aceptación. **¿A partir de qué número este proyecto vale\n",
    "la pena?**\n",
    "\n",
    "La tentación es decir \"un AUC de 0,80 estaría bien\". Ese número no sale de\n",
    "ningún sitio. Lo que hay que hacer es medir contra qué se compara:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.compose import ColumnTransformer\n",
    "from sklearn.dummy import DummyClassifier\n",
    "from sklearn.linear_model import LogisticRegression\n",
    "from sklearn.metrics import roc_auc_score\n",
    "from sklearn.model_selection import train_test_split\n",
    "from sklearn.pipeline import Pipeline\n",
    "from sklearn.preprocessing import OneHotEncoder, StandardScaler\n",
    "\n",
    "cat = ['segmento', 'canal', 'categoria', 'ciudad']\n",
    "num = ['unidades', 'monto']\n",
    "X, y = v[cat + num], v['compro']\n",
    "Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=7,\n",
    "                                      stratify=y)\n",
    "\n",
    "tonto = DummyClassifier(strategy='stratified', random_state=7).fit(Xtr, ytr)\n",
    "print('1. tirar una moneda:     %.4f'\n",
    "      % roc_auc_score(yte, tonto.predict_proba(Xte)[:, 1]))\n",
    "\n",
    "solo_seg = Pipeline([\n",
    "    ('pre', ColumnTransformer([('c', OneHotEncoder(handle_unknown='ignore'),\n",
    "                                ['segmento'])])),\n",
    "    ('clf', LogisticRegression(max_iter=1000)),\n",
    "]).fit(Xtr[['segmento']], ytr)\n",
    "print('2. solo el segmento:     %.4f'\n",
    "      % roc_auc_score(yte, solo_seg.predict_proba(Xte[['segmento']])[:, 1]))\n",
    "\n",
    "completo = Pipeline([\n",
    "    ('pre', ColumnTransformer([\n",
    "        ('cat', OneHotEncoder(handle_unknown='ignore'), cat),\n",
    "        ('num', StandardScaler(), num)])),\n",
    "    ('clf', LogisticRegression(max_iter=1000)),\n",
    "]).fit(Xtr, ytr)\n",
    "print('3. las seis columnas:    %.4f'\n",
    "      % roc_auc_score(yte, completo.predict_proba(Xte)[:, 1]))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Léelo despacio, porque esto cambia el proyecto entero 😳\n",
    "\n",
    "Una sola columna, el segmento, consigue **0,6346**. Las seis\n",
    "juntas consiguen **0,6554**. Las otras cinco columnas, todas\n",
    "juntas, aportan **dos centésimas**.\n",
    "\n",
    "Y eso hay que escribirlo en el informe ANTES de seguir, porque significa\n",
    "una cosa muy concreta: **si al final el modelo saca 0,66, no hacía falta\n",
    "ningún modelo**. Bastaba con una tabla de cuatro filas que diga la tasa\n",
    "de cierre de cada segmento.\n",
    "\n",
    "El umbral de aceptación que escribo es este:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "| Nivel | AUC | Qué significa |\n",
    "|---|---|---|\n",
    "| Piso | 0,6346 | Lo que da mirar solo el segmento. Por debajo, el modelo estorba |\n",
    "| Mínimo para seguir | 0,70 | Suficiente por encima del piso como para justificar mantenerlo |\n",
    "| Objetivo | 0,75 | Lo que haría falta para cambiar cómo se reparte el equipo comercial |"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Los capítulos 5 y 9 construyen columnas nuevas, y ahí se ve si se llega. Lo\n",
    "que no se puede hacer es fijar el umbral *después* de ver el resultado,\n",
    "que es exactamente lo que pasa cuando nadie lo escribió antes 📝"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El error del capítulo"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Copio el chequeo de duplicados de otro proyecto, donde la columna de dinero\n",
    "se llamaba distinto:"
   ]
  },
  {
   "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",
    "    v.duplicated(subset=['cliente_id', 'fecha', 'monto_total'])\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",
    "KeyError: Index(['monto_total'], dtype='str')\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Te frena, y eso está bien 👍 Copiar un script de gobernanza de un proyecto a\n",
    "otro es lo normal, y este error es lo que te obliga a mirar si las columnas se\n",
    "llaman igual.\n",
    "\n",
    "Ahora el que **no** te frena, que es el que quería enseñarte.\n",
    "Quiero el perfil de calidad de golpe con `describe()`, que es lo que\n",
    "hace todo el mundo:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(v.describe(include='all').loc['mean'].to_string())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Mira lo que devuelve 😳\n",
    "\n",
    "La media de `id_venta` es 11.499,50, que es el número de factura\n",
    "promedio y no existe. La media de `fecha` es el 29 de septiembre de\n",
    "2025, que es la fecha promedio de las ventas y tampoco significa nada. Y las\n",
    "columnas de texto salen como `NaN`, o sea que en una tabla de catorce\n",
    "filas hay cinco que son huecos y dos que son disparates.\n",
    "\n",
    "Y ninguna dio error 😑\n",
    "\n",
    "La lección: **el perfil de calidad no es `describe()`**.\n",
    "describe() te da estadísticos; un perfil te dice cuántos huecos hay, cuántos\n",
    "valores distintos, qué tipo tiene cada columna y si el rango es posible. Son\n",
    "cosas distintas y la primera no sustituye a la segunda."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Practica 💪"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. La cobertura temporal, que decide el split\n",
    "\n",
    "Mira cuánto tiempo cubren los datos y si todos los meses\n",
    "pesan igual. De eso depende cómo hay que partir en entrenamiento y test."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "por_mes = v.groupby(v['fecha'].dt.to_period('M')).size()\n",
    "\n",
    "print('de %s a %s' % (v['fecha'].min().date(), v['fecha'].max().date()))\n",
    "print('meses: %d' % len(por_mes))\n",
    "print('ventas por mes: de %d a %d' % (por_mes.min(), por_mes.max()))\n",
    "print('el ultimo mes: %d' % por_mes.iloc[-1])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "de 2025-01-01 a 2026-06-24\n",
    "meses: 18\n",
    "ventas por mes: de 122 a 192\n",
    "el ultimo mes: 122\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Dieciocho meses y entre 122 y 192 ventas por mes 📅\n",
    "\n",
    "Lo importante es el último: tiene menos que el resto porque los datos cortan\n",
    "el 24 de junio. Eso no es una caída del negocio, es un mes al que le faltan\n",
    "seis días, y si alguien entrena con todo y valida con \"el último mes\" va a\n",
    "validar contra un mes cojo.\n",
    "\n",
    "De aquí sale una decisión del contrato: **si el modelo se va a usar\n",
    "hacia adelante en el tiempo, la validación tiene que ser temporal**, no\n",
    "aleatoria. Lo vemos en el capítulo de validación."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. La tasa base por grupo, que es la línea de fairness\n",
    "\n",
    "Antes de entrenar, deja escrito qué tasa de compra tiene\n",
    "cada segmento. Es contra eso que se va a comparar el modelo después."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "base = v.groupby('segmento')['compro'].agg(['mean', 'count'])\n",
    "base.columns = ['tasa', 'ventas']\n",
    "base['vs_global'] = (base['tasa'] - v['compro'].mean()).round(4)\n",
    "print(base.round(4).to_string())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "              tasa  ventas  vs_global\n",
    "segmento                             \n",
    "Bodega      0.3748     707    -0.2028\n",
    "Horeca      0.6321     742     0.0544\n",
    "Mayorista   0.7240     750     0.1463\n",
    "Minimarket  0.5693     801    -0.0084\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esta tabla es la referencia de todo el capítulo 24 🎯\n",
    "\n",
    "Bodega cierra 20 puntos por debajo del global y Mayorista 15 por encima. Eso\n",
    "NO es un sesgo del modelo: es el negocio.\n",
    "\n",
    "El sesgo del modelo sería que, además de esa diferencia real, el modelo\n",
    "acertara peor en un grupo que en otro. Y para poder afirmar eso hace falta tener\n",
    "esta tabla escrita de antemano, o si no cualquier diferencia posterior se puede\n",
    "explicar como \"es que ese segmento compra menos\" 🛡️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. Qué pasa si el hueco significa cero\n",
    "\n",
    "La columna descuento tiene 19,83% de nulos. Mide cuánto\n",
    "cambia la conclusión según qué supongas."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('media ignorando huecos: %.4f' % v['descuento'].mean())\n",
    "print('media con huecos = 0:   %.4f' % v['descuento'].fillna(0).mean())\n",
    "print()\n",
    "print('tasa de compra con descuento:    %.4f'\n",
    "      % v.loc[v['descuento'].notna(), 'compro'].mean())\n",
    "print('tasa de compra sin descuento:    %.4f'\n",
    "      % v.loc[v['descuento'].isna(), 'compro'].mean())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "media ignorando huecos: 0.1252\n",
    "media con huecos = 0:   0.1004\n",
    "\n",
    "tasa de compra con descuento:    0.6017\n",
    "tasa de compra sin descuento:    0.4807\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Mira las dos últimas líneas, que son lo interesante 👀\n",
    "\n",
    "La tasa de compra es distinta según **si el descuento está o no\n",
    "está**. O sea que el hueco no es aleatorio: significa algo.\n",
    "\n",
    "Eso tiene un nombre en el skill de gobernanza y es la pregunta que decide qué\n",
    "hacer: si los huecos aparecen al azar (MCAR) se pueden rellenar sin drama; si\n",
    "aparecen según otra columna (MAR) hay que rellenar con cuidado; y si aparecen\n",
    "según el propio valor que falta (MNAR) rellenarlos inventa datos.\n",
    "\n",
    "Aquí el hueco predice la compra y por bastante: **60,17% de cierre\n",
    "cuando hay descuento contra 48,07% cuando no lo hay**. Doce puntos que\n",
    "salen de una columna vacía 😮\n",
    "\n",
    "Así que lo correcto es lo del capítulo siguiente: **convertir el hueco\n",
    "en columna** en vez de taparlo. Rellenarlo con la media borraría esos\n",
    "doce puntos."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. Una función de perfil que puedas reutilizar\n",
    "\n",
    "Empaqueta el perfil de calidad para poder correrlo sobre\n",
    "cualquier archivo nuevo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def perfil_de_calidad(df, rangos=None):\n",
    "    rangos = rangos or {}\n",
    "    filas = []\n",
    "    for col in df.columns:\n",
    "        s = df[col]\n",
    "        aviso = ''\n",
    "        if col in rangos:\n",
    "            bajo, alto = rangos[col]\n",
    "            fuera = ((s < bajo) | (s > alto)).sum()\n",
    "            aviso = f'{fuera} fuera de rango' if fuera else ''\n",
    "        if s.nunique() == len(df):\n",
    "            aviso = (aviso + ' identificador').strip()\n",
    "        filas.append((col, str(s.dtype), s.isna().sum(),\n",
    "                      round(100 * s.isna().mean(), 2), s.nunique(), aviso))\n",
    "    return pd.DataFrame(filas, columns=['columna', 'tipo', 'nulos', 'pct',\n",
    "                                        'distintos', 'aviso'])\n",
    "\n",
    "\n",
    "print(perfil_de_calidad(v, rangos={\n",
    "    'descuento': (0, 1),\n",
    "    'satisfaccion': (1, 5),\n",
    "    'unidades': (1, 1000),\n",
    "    'monto': (0, 1e9),\n",
    "}).to_string(index=False))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "              columna           tipo  nulos   pct  distintos             aviso\n",
    "             id_venta          int64      0  0.00       3000     identificador\n",
    "                fecha datetime64[us]      0  0.00        537                  \n",
    "           cliente_id            str      0  0.00        617                  \n",
    "               ciudad            str      0  0.00          6                  \n",
    "             segmento            str      0  0.00          4                  \n",
    "                canal            str      0  0.00          4                  \n",
    "            categoria            str      0  0.00          5                  \n",
    "             unidades          int64      0  0.00         30                  \n",
    "                monto        float64      0  0.00       2964 21 fuera de rango\n",
    "            descuento        float64    595 19.83        251                  \n",
    "  fecha_ultima_compra datetime64[us]    544 18.13        399                  \n",
    "         satisfaccion        float64    231  7.70          5                  \n",
    "               compro          int64      0  0.00          2                  \n",
    "monto_final_facturado        float64      0  0.00       1723                  \n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí lo tienes en una pasada 🙌\n",
    "\n",
    "Lo que hace útil a esta función no es contar nulos, que lo hace\n",
    "`isna()`. Es la columna de avisos: **compara contra lo que tú\n",
    "declaraste que era posible**, y eso es lo que convierte un vistazo en un\n",
    "control.\n",
    "\n",
    "El día que llegue el archivo del mes que viene, esta función corre sola y te\n",
    "dice si algo cambió sin avisar 📟"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. El umbral, traducido a plata\n",
    "\n",
    "Un AUC no le dice nada a nadie. Traduce la diferencia entre\n",
    "0,6346 y 0,75 a algo que se pueda defender."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "ticket = v.loc[v['compro'] == 1, 'monto_final_facturado'].median()\n",
    "llamadas = 200\n",
    "\n",
    "for auc, nombre in [(0.6346, 'solo el segmento'), (0.70, 'minimo'),\n",
    "                    (0.75, 'objetivo')]:\n",
    "    # Aproximacion de campo: la tasa de acierto entre los mejores 200 sube\n",
    "    # aproximadamente con el AUC por encima del azar.\n",
    "    tasa = v['compro'].mean() + (auc - 0.5) * 0.9\n",
    "    print('%-18s AUC %.4f -> %.1f cierres de %d llamadas -> S/ %.0f'\n",
    "          % (nombre, auc, llamadas * tasa, llamadas, llamadas * tasa * ticket))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "solo el segmento   AUC 0.6346 -> 139.8 cierres de 200 llamadas -> S/ 79749\n",
    "minimo             AUC 0.7000 -> 151.5 cierres de 200 llamadas -> S/ 86466\n",
    "objetivo           AUC 0.7500 -> 160.5 cierres de 200 llamadas -> S/ 91602\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahora la conversación es otra 💰\n",
    "\n",
    "Pasar del piso al objetivo son unos cuantos miles de soles al mes sobre las\n",
    "mismas 200 llamadas. Ese número es el que decide si el proyecto sigue, y es el\n",
    "que hay que poner al lado del costo de mantenerlo.\n",
    "\n",
    "Y ojo con la línea del comentario: ese 0,9 es **una aproximación de\n",
    "campo, no una ley**. Sirve para dimensionar antes de tener el modelo. Con\n",
    "el modelo hecho, el número bueno sale de contar los cierres de verdad entre los\n",
    "200 mejores, que es lo que hace el capítulo del proyecto final."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. La ficha de gobernanza, entera\n",
    "\n",
    "Junta todo en el bloque que iría al principio del informe."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('FICHA DE GOBERNANZA')\n",
    "print('-' * 46)\n",
    "print('unidad de analisis: una venta')\n",
    "print('filas: %d | clientes: %d | periodo: %s a %s'\n",
    "      % (len(v), v['cliente_id'].nunique(),\n",
    "         v['fecha'].min().date(), v['fecha'].max().date()))\n",
    "print('etiqueta: compro (1 = se cerro), tasa base %.4f' % v['compro'].mean())\n",
    "print('columnas con huecos: %s'\n",
    "      % ', '.join('%s %.1f%%' % (c, 100 * v[c].isna().mean())\n",
    "                  for c in v.columns if v[c].isna().any()))\n",
    "print('sensibles: ciudad (medida, no discrimina)')\n",
    "print('umbral: piso 0.6346 | minimo 0.70 | objetivo 0.75')\n",
    "print('riesgo declarado: los grupos estan demasiado equilibrados,')\n",
    "print('  probablemente es una muestra y no el volcado entero')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "FICHA DE GOBERNANZA\n",
    "----------------------------------------------\n",
    "unidad de analisis: una venta\n",
    "filas: 3000 | clientes: 617 | periodo: 2025-01-01 a 2026-06-24\n",
    "etiqueta: compro (1 = se cerro), tasa base 0.5777\n",
    "columnas con huecos: descuento 19.8%, fecha_ultima_compra 18.1%, satisfaccion 7.7%\n",
    "sensibles: ciudad (medida, no discrimina)\n",
    "umbral: piso 0.6346 | minimo 0.70 | objetivo 0.75\n",
    "riesgo declarado: los grupos estan demasiado equilibrados,\n",
    "  probablemente es una muestra y no el volcado entero\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esa ficha cabe en media carilla y es lo que hace que un cliente confíe 📄\n",
    "\n",
    "Fíjate en la última línea, que es la que más cuesta escribir: **el\n",
    "riesgo declarado**. Decir \"sospecho que esto es una muestra\" antes de\n",
    "empezar te protege, y sobre todo protege a quien va a decidir con el resultado.\n",
    "\n",
    "Un informe que solo dice lo que salió bien no es un informe, es una\n",
    "presentación de ventas 🙃"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "Antes de entrenar mides que una sola columna, el segmento, da un AUC de 0,6346. ¿Para qué sirve ese número?\n",
    "\n",
    "a) Es el piso: por debajo de ahí el modelo estorba en vez de ayudar\n",
    "\n",
    "b) Para saber que el segmento es la variable más importante\n",
    "\n",
    "c) Para descartar las otras cinco columnas\n",
    "\n",
    "d) Para nada, porque el modelo final va a usar todas\n",
    "\n",
    "---\n",
    "\n",
    "**La correcta es la a.**\n",
    "\n",
    "*b)* Lo es, pero eso lo sabías ya. Piensa contra qué vas a comparar el modelo final.\n",
    "\n",
    "*c)* Todavía no: faltan las columnas que se construyen en el capítulo 9.\n",
    "\n",
    "*d)* Justamente por eso: si usa todas y saca lo mismo que una, no hacía falta.\n",
    "\n",
    "El umbral de aceptación se escribe antes de ver el resultado, o si no se escribe para que el resultado lo cumpla."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La que parece que arregla el sesgo"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "Te piden que el modelo \"no discrimine por ciudad\". Alguien lo resuelve en una línea y cierra el ticket. Léelo despacio.\n",
    "\n",
    "```\n",
    "X = ventas.drop(columns=['ciudad'])\n",
    "\n",
    "# \"listo, ya no puede discriminar\n",
    "#  por ciudad\"\n",
    "```\n",
    "\n",
    "**Qué está mal**\n",
    "\n",
    "Quitar la columna quita el nombre, no la información 🫥 El canal, el segmento y el monto siguen dentro, y entre los tres reconstruyen buena parte de la ciudad sin nombrarla nunca. El modelo sigue tratando distinto a la gente de Lima; lo único que perdiste es la capacidad de **medirlo**.\n",
    "\n",
    "Lo que sí funciona es lo contrario de lo que parece: **dejar la columna dentro para poder auditarla**, y comprobar el rendimiento grupo por grupo. Eso es el capítulo 24. Un modelo que no puedes auditar no es un modelo justo, es uno del que no sabes nada."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- 📋 El perfil de calidad va antes que el modelo. Aquí: tres columnas con\n",
    "huecos, la peor con el 19,83%.\n",
    "\n",
    "- 🔁 Los duplicados se buscan contra la clave de negocio (quién, cuándo,\n",
    "cuánto), no contra el identificador, que nunca se repite.\n",
    "\n",
    "- ⚖️ Los grupos están equilibrados a 1,13x, que es sospechosamente parejo para\n",
    "datos reales. Se declara como riesgo.\n",
    "\n",
    "- 🎯 **El umbral de aceptación se escribe antes de ver el\n",
    "resultado.** Aquí el piso es 0,6346, que es lo que da mirar una sola\n",
    "columna.\n",
    "\n",
    "- 🕳️ El hueco de descuento no es aleatorio: predice la compra. Eso se\n",
    "convierte en columna, no se tapa.\n",
    "\n",
    "- 📄 El riesgo declarado es la línea más difícil de escribir y la que hace que\n",
    "te crean.\n",
    "\n",
    "Y si de todo el capítulo te llevas una sola frase, que sea esta:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El umbral se escribe antes. Después ya no es un umbral, es una excusa."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y si lo que te falta para escribir ese umbral es la estadística de debajo, está entera 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": [
    "Con la ficha escrita, toca mirar los datos de verdad 🕵️\n",
    "\n",
    "El capítulo 4 limpia lo que está sucio: las filas repetidas, las cuatro\n",
    "formas de escribir Lima, el dinero que se evapora al convertirlo y los dos\n",
    "formatos de fecha metidos en la misma columna.\n",
    "\n",
    "Y de ahí en adelante el libro sigue el orden que a mí me costó años aprender,\n",
    "que es el contrario del que parece:\n",
    "\n",
    "- 🔍 **Primero se entiende lo que hay.** Limpiar, auditar tipos,\n",
    "mirar cada columna por separado y luego contra la etiqueta.\n",
    "\n",
    "- 🧱 **Después se construye.** Las columnas que no venían en el\n",
    "archivo y que son las que de verdad mueven la aguja.\n",
    "\n",
    "- 🤖 **Y el modelo llega casi al final**, cuando ya está decidido\n",
    "todo lo que importa.\n",
    "\n",
    "Si el modelado te ocupa más tiempo que entender los datos, lo que estás\n",
    "escribiendo es un script y no un proyecto. Esa proporción es la que separa a una\n",
    "senior de alguien que sabe llamar a `.fit()` 📋"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 3 de **Machine learning desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/machine-learning-desde-cero/gobernanza-y-sesgos/\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
}
