{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# La fuga de información\n",
    "\n",
    "El fallo que en vez de un error te da una felicitación, con los tres sabores medidos sobre el mismo archivo.\n",
    "\n",
    "Cuaderno de soluciones del capítulo 12 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/fuga-de-informacion/\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": [
    "Si un modelo te da 0,99 de entrada, no celebres. Busca el error 🚨\n",
    "\n",
    "La fuga de información es cuando el modelo ve, al entrenar, algo que no va a\n",
    "tener el día que le toque predecir de verdad. Y es el fallo más caro del oficio\n",
    "por una razón muy concreta: **hace que todo se vea mejor**. Un bug\n",
    "normal te da un error; este te da una felicitación.\n",
    "\n",
    "En este capítulo la vamos a provocar tres veces, con tres sabores distintos, y\n",
    "las tres son de este archivo."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El punto de partida"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "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 roc_auc_score\n",
    "from sklearn.model_selection import train_test_split\n",
    "from sklearn.pipeline import Pipeline\n",
    "from sklearn.preprocessing import OneHotEncoder, StandardScaler\n",
    "\n",
    "URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'\n",
    "\n",
    "def carga_limpia(url):\n",
    "    v = pd.read_csv(url).drop_duplicates()\n",
    "    v['ciudad'] = (v['ciudad'].str.strip().str.lower()\n",
    "                   .str.normalize('NFKD')\n",
    "                   .str.encode('ascii', 'ignore').str.decode('utf-8'))\n",
    "    v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))\n",
    "    for col in ['fecha', 'fecha_ultima_compra']:\n",
    "        f = pd.to_datetime(v[col], format='%Y-%m-%d', errors='coerce')\n",
    "        falta = f.isna() & v[col].notna()\n",
    "        f[falta] = pd.to_datetime(v.loc[falta, col], format='%d/%m/%Y', errors='coerce')\n",
    "        v[col] = f\n",
    "    return v\n",
    "\n",
    "def prepara(v):\n",
    "    v = v.sort_values(['cliente_id', 'fecha']).copy()\n",
    "    v['sin_compra_previa'] = v['fecha_ultima_compra'].isna().astype(int)\n",
    "    v['sin_descuento'] = v['descuento'].isna().astype(int)\n",
    "    v['sin_satisfaccion'] = v['satisfaccion'].isna().astype(int)\n",
    "    v['precio_unitario'] = v['monto'] / v['unidades']\n",
    "    v['visita_numero'] = v.groupby('cliente_id').cumcount() + 1\n",
    "    return v\n",
    "\n",
    "NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion', 'precio_unitario',\n",
    "             'sin_compra_previa', 'sin_descuento', 'sin_satisfaccion', 'visita_numero']\n",
    "CATEGORICAS = ['ciudad', 'segmento', 'canal', 'categoria']\n",
    "\n",
    "def arma(numericas, categoricas):\n",
    "    \"\"\"El pipeline del capítulo 11, para poder cambiarle las columnas.\"\"\"\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",
    "    return Pipeline([('pre', pre),\n",
    "                     ('mod', LogisticRegression(max_iter=1000, random_state=42))])\n",
    "\n",
    "datos = prepara(carga_limpia(URL))\n",
    "y = datos['compro']\n",
    "print(datos.shape)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El número honesto, para comparar"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "X = datos[NUMERICAS + CATEGORICAS]\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",
    "honesto = arma(NUMERICAS, CATEGORICAS).fit(X_tr, y_tr)\n",
    "print('exactitud:', round(honesto.score(X_te, y_te), 4))\n",
    "print('AUC      :', round(roc_auc_score(y_te, honesto.predict_proba(X_te)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "0,6733 y 0,7214. Ese es el modelo del capítulo 11 y es el que dice la\n",
    "verdad 📏"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Fuga 1: la columna que es la respuesta"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Del capítulo 2 ya sabemos que `monto_final_facturado` vale 0 en\n",
    "todas las ventas que no se cerraron. Vamos a metérsela al modelo a ver qué\n",
    "pasa."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "CON_FUGA = NUMERICAS + ['monto_final_facturado']\n",
    "\n",
    "X2 = datos[CON_FUGA + CATEGORICAS]\n",
    "X2_tr, X2_te, y2_tr, y2_te = train_test_split(X2, y, test_size=0.25,\n",
    "                                              random_state=42, stratify=y)\n",
    "\n",
    "tramposo = arma(CON_FUGA, CATEGORICAS).fit(X2_tr, y2_tr)\n",
    "print('exactitud:', round(tramposo.score(X2_te, y2_te), 4))\n",
    "print('AUC      :', round(roc_auc_score(y2_te, tramposo.predict_proba(X2_te)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**98,8% de exactitud y 0,9994 de AUC** 🎉🎉\n",
    "\n",
    "Y es completamente inútil.\n",
    "\n",
    "Ese modelo aprendió \"si el monto facturado es mayor que cero, entonces se\n",
    "cerró\", que es verdad y no sirve de nada: el día que tienes que predecir, la\n",
    "venta todavía no se facturó. En producción esa columna llegaría vacía o en cero\n",
    "para todo el mundo, y el modelo diría que nadie compra.\n",
    "\n",
    "Esta es la fuga fácil, la que se ve. Pero fíjate en la trampa emocional que\n",
    "tiene: **ese 0,99 es el número que uno quiere ver**, y por eso\n",
    "cuesta tanto ir a buscarle el problema 😳\n",
    "\n",
    "La señal de alarma es sencilla: **si tu AUC pasa de 0,95 en un problema\n",
    "de negocio, hay fuga hasta que se demuestre lo contrario**. Los problemas\n",
    "de verdad son difíciles."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Fuga 2: el mismo cliente en los dos lados"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esta es más sutil y la comete todo el mundo, incluido yo durante años.\n",
    "\n",
    "Nuestro archivo tiene 3.000 ventas de 617 clientes, o sea unas cinco por\n",
    "cliente. Cuando partimos al azar por filas, ¿qué pasa?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "clientes_tr = set(datos.loc[X_tr.index, 'cliente_id'])\n",
    "clientes_te = set(datos.loc[X_te.index, 'cliente_id'])\n",
    "print('clientes en los dos lados:', len(clientes_tr & clientes_te))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "431 de los 617 están en entrenamiento *y* en examen 😬\n",
    "\n",
    "El modelo vio tres ventas del cliente C0045 al entrenar y le preguntamos por\n",
    "la cuarta. No está prediciendo, está recordando. Y en producción los clientes\n",
    "van a ser nuevos.\n",
    "\n",
    "Se arregla partiendo por cliente, no por fila:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.model_selection import GroupShuffleSplit\n",
    "\n",
    "grupos = datos['cliente_id']\n",
    "gss = GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=42)\n",
    "i_tr, i_te = next(gss.split(X, y, groups=grupos))\n",
    "\n",
    "print('clientes compartidos ahora:', len(set(grupos.iloc[i_tr]) & set(grupos.iloc[i_te])))\n",
    "\n",
    "por_grupo = arma(NUMERICAS, CATEGORICAS).fit(X.iloc[i_tr], y.iloc[i_tr])\n",
    "print('AUC por cliente:', round(roc_auc_score(y.iloc[i_te],\n",
    "      por_grupo.predict_proba(X.iloc[i_te])[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cero clientes compartidos, y el AUC baja de **0,7214 a 0,6843**.\n",
    "\n",
    "Casi cuatro puntos que no eran del modelo: eran de conocer al cliente. Si tu\n",
    "sistema va a predecir sobre clientes que ya existen, 0,72 es el número bueno; si\n",
    "va a predecir sobre clientes nuevos, el bueno es 0,68 y el otro te iba a dejar\n",
    "mal en la reunión de los tres meses 📉\n",
    "\n",
    "**La regla: si una entidad aparece en varias filas, se parte por esa\n",
    "entidad.** Cliente, paciente, tienda, usuario. Y se decide mirando la\n",
    "pregunta del capítulo 2, no el código."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Fuga 3: la que no infla el número"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y ahora la más traicionera de las tres, porque no se detecta mirando el\n",
    "resultado.\n",
    "\n",
    "Acuérdate del capítulo 9: `fecha_ultima_compra` es la última compra\n",
    "del cliente en todo el archivo, así que el 30% de las filas tienen una fecha\n",
    "posterior a la venta. Vamos a usarla igual."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "con_dias = datos.copy()\n",
    "con_dias['dias_desde_ultima'] = (con_dias['fecha'] - con_dias['fecha_ultima_compra']).dt.days\n",
    "\n",
    "CON_DIAS = NUMERICAS + ['dias_desde_ultima']\n",
    "X3 = con_dias[CON_DIAS + CATEGORICAS]\n",
    "X3_tr, X3_te, y3_tr, y3_te = train_test_split(X3, y, test_size=0.25,\n",
    "                                              random_state=42, stratify=y)\n",
    "\n",
    "m3 = arma(CON_DIAS, CATEGORICAS).fit(X3_tr, y3_tr)\n",
    "print('exactitud:', round(m3.score(X3_te, y3_te), 4))\n",
    "print('AUC      :', round(roc_auc_score(y3_te, m3.predict_proba(X3_te)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "0,6787 y 0,7183. Prácticamente lo mismo que el honesto.\n",
    "\n",
    "O sea que **esta fuga no infla nada**, y por eso ninguna alarma\n",
    "de \"el número está muy alto\" la habría cazado. Y sin embargo es una fuga: esa\n",
    "columna, calculada así, no se puede calcular en producción, porque el día de la\n",
    "predicción no conoces las compras futuras del cliente.\n",
    "\n",
    "El modelo funcionaría hasta que alguien lo despliegue, y entonces esa columna\n",
    "saldría distinta y el modelo daría cosas raras sin explicación.\n",
    "\n",
    "Conclusión incómoda: **la fuga no se caza por el resultado, se caza por\n",
    "el origen de cada columna**. La pregunta del capítulo 2, otra vez:\n",
    "¿cuándo se rellena esto? 🕵️‍♀️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Fuga 4: la media de la etiqueta, que es la reina"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esta es la fuga que más veces he visto en modelos que llegaron a producción,\n",
    "y la más difícil de ver leyendo el código 👑\n",
    "\n",
    "La idea es tentadora y suena razonable: si un cliente compró 8 de sus 10\n",
    "visitas, esa tasa del 80% tiene que ser útil para predecir si va a comprar. Se\n",
    "llama codificar con la etiqueta, y se escribe así de fácil:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "datos_mal = datos.copy()\n",
    "datos_mal['tasa_cliente'] = datos.groupby('cliente_id')['compro'].transform('mean')\n",
    "\n",
    "COLS = NUMERICAS + ['tasa_cliente']\n",
    "Xm = datos_mal[COLS + CATEGORICAS]\n",
    "\n",
    "Xm_tr, Xm_te, ym_tr, ym_te = train_test_split(\n",
    "    Xm, y, test_size=0.25, random_state=42, stratify=y)\n",
    "\n",
    "modelo_mal = arma(COLS, CATEGORICAS).fit(Xm_tr, ym_tr)\n",
    "auc_mal = roc_auc_score(ym_te, modelo_mal.predict_proba(Xm_te)[:, 1])\n",
    "\n",
    "print('AUC con la columna nueva:', round(auc_mal, 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "0,8169. Nueve puntos y medio por encima del modelo de antes 🎉\n",
    "\n",
    "Y aquí es donde había que parar y no seguir. Porque esa columna se calculó\n",
    "sobre **los datos enteros**, incluidas las filas que van a acabar\n",
    "en el conjunto de prueba. La tasa de cada cliente ya sabe cómo terminaron sus\n",
    "visitas de prueba.\n",
    "\n",
    "Ahora bien hecho: la tasa se calcula **solo con entrenamiento**,\n",
    "y a los clientes que no aparecen ahí se les pone la media general."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "tasa_tr = datos.loc[X_tr.index].groupby('cliente_id')['compro'].mean()\n",
    "media_tr = datos.loc[X_tr.index, 'compro'].mean()\n",
    "\n",
    "datos_bien = datos.copy()\n",
    "datos_bien['tasa_cliente'] = datos_bien['cliente_id'].map(tasa_tr).fillna(media_tr)\n",
    "Xb = datos_bien[COLS + CATEGORICAS]\n",
    "\n",
    "modelo_bien = arma(COLS, CATEGORICAS).fit(Xb.loc[X_tr.index], y.loc[X_tr.index])\n",
    "auc_bien = roc_auc_score(y.loc[X_te.index],\n",
    "                         modelo_bien.predict_proba(Xb.loc[X_te.index])[:, 1])\n",
    "\n",
    "print('AUC con la columna nueva:', round(auc_mal, 4))\n",
    "print('AUC con la misma, bien  :', round(auc_bien, 4))\n",
    "print('lo que inflaba la fuga  :', round(auc_mal - auc_bien, 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "De 0,8169 a 0,5810. La fuga estaba inflando **veintitrés puntos y\n",
    "medio** de AUC 😱\n",
    "\n",
    "Pero lo de verdad interesante viene ahora. Compáralo con el modelo que ni\n",
    "siquiera tenía esa columna:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('sin la columna     :', round(roc_auc_score(y_te, honesto.predict_proba(X_te)[:, 1]), 4))\n",
    "print('con la columna bien:', round(auc_bien, 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "0,7214 sin ella y 0,5810 con ella 🫠\n",
    "\n",
    "**Bien calculada, la columna no es que aporte menos: es que hace el\n",
    "modelo peor.** Bastante peor.\n",
    "\n",
    "Y tiene sentido cuando lo piensas. La tasa de un cliente calculada con las\n",
    "pocas visitas que cayeron en entrenamiento es un número ruidoso: si un cliente\n",
    "tiene tres visitas en entrenamiento y compró en una, su tasa es 0,33 y eso no\n",
    "dice casi nada. El modelo se agarra a ese número ruidoso y deja de mirar las\n",
    "columnas que sí informaban.\n",
    "\n",
    "O sea que aquí hubo dos errores encadenados, y el primero tapaba el segundo:\n",
    "la fuga hacía que una columna mala pareciera la mejor de todas 🎭"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Fuga 5: la del tiempo, y por qué aquí no aparece"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "La fuga más famosa de todas: entrenar con datos de junio y probar con datos\n",
    "de marzo. En producción el modelo predice el futuro, y si al medirlo le dejaste\n",
    "ver el futuro, el número que te dio no vale 🕰️\n",
    "\n",
    "Se comprueba partiendo por fecha en vez de al azar:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "d = datos.sort_values('fecha').reset_index(drop=True)\n",
    "Xt = d[NUMERICAS + CATEGORICAS]\n",
    "yt = d['compro']\n",
    "\n",
    "corte = int(len(d) * 0.75)\n",
    "\n",
    "m_tiempo = arma(NUMERICAS, CATEGORICAS).fit(Xt.iloc[:corte], yt.iloc[:corte])\n",
    "auc_tiempo = roc_auc_score(yt.iloc[corte:],\n",
    "                           m_tiempo.predict_proba(Xt.iloc[corte:])[:, 1])\n",
    "\n",
    "print('fecha de corte        :', d['fecha'].iloc[corte].date())\n",
    "print('AUC partiendo al azar :', round(roc_auc_score(y_te, honesto.predict_proba(X_te)[:, 1]), 4))\n",
    "print('AUC partiendo por fecha:', round(auc_tiempo, 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y aquí la respuesta no es la que yo esperaba: **partir por fecha da\n",
    "casi lo mismo**. Poco más de un punto de diferencia 🤨\n",
    "\n",
    "O sea que en estos datos **no hay fuga temporal**, o es tan\n",
    "pequeña que se confunde con el ruido de partir de otra forma. Y decirlo vale\n",
    "tanto como haberla encontrado.\n",
    "\n",
    "El motivo es que aquí cada visita es independiente: no hay una tendencia que\n",
    "haga que las ventas de 2026 se parezcan entre sí más que a las de 2025, ni\n",
    "columnas que acumulen historia. Si las hubiera, el número de la derecha se\n",
    "habría hundido.\n",
    "\n",
    "¿Cuándo sí aparece? Cuando hay estacionalidad fuerte, cuando el negocio cambió\n",
    "en medio del periodo, o cuando alguna columna resume el pasado del cliente, que\n",
    "es justo el caso de la fuga 4. Con esas, **partir al azar es mentir**.\n",
    "\n",
    "Mi regla: **si los datos tienen fecha, se prueba de las dos formas**.\n",
    "Cuesta cuatro líneas. Si dan lo mismo, duermes tranquila; si no, acabas de\n",
    "descubrir que tu número real es el otro."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La lista con la que yo reviso"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cinco preguntas, en este orden, antes de entregar cualquier modelo:\n",
    "\n",
    "- 1️⃣ **¿El AUC pasa de 0,95?** Si sí, hay fuga hasta que\n",
    "demuestres lo contrario.\n",
    "\n",
    "- 2️⃣ **Por cada columna: ¿cuándo se rellena?** Si es \"al cerrar\",\n",
    "\"al facturar\", \"al mes siguiente\", fuera.\n",
    "\n",
    "- 3️⃣ **¿Hay una entidad repetida en varias filas?** Si sí, se\n",
    "parte por ahí.\n",
    "\n",
    "- 4️⃣ **¿Alguna columna la calculó otro equipo?** Los\n",
    "`score_` y `segmento_` de otros sistemas suelen traer\n",
    "información del futuro y nadie lo sabe.\n",
    "\n",
    "- 5️⃣ **¿La preparación se hizo antes de partir?** Seleccionar\n",
    "columnas o buscar hiperparámetros mirando todo el archivo también es fuga. Por\n",
    "eso el pipeline del capítulo 11."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Siete. Intenta antes de abrir 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. Qué peso le da el modelo a la columna tramposa\n",
    "\n",
    "Mira el coeficiente de `monto_final_facturado`\n",
    "frente al resto."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "\n",
    "nombres = tramposo.named_steps['pre'].get_feature_names_out()\n",
    "pesos = tramposo.named_steps['mod'].coef_[0]\n",
    "orden = np.argsort(np.abs(pesos))[::-1][:5]\n",
    "for i in orden:\n",
    "    print(f'{nombres[i]:35} {pesos[i]:.3f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "num__monto_final_facturado          13.777\n",
    "num__monto                          -1.499\n",
    "cat__segmento_Mayorista             -0.836\n",
    "cat__segmento_Bodega                0.757\n",
    "cat__categoria_Snacks               -0.321\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El coeficiente de la columna tramposa es enorme comparado con los demás. Ese\n",
    "es otro síntoma: **cuando una sola columna se lleva casi todo el\n",
    "peso**, hay que ir a mirarla 🔍"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. El modelo tramposo en producción\n",
    "\n",
    "Simula el día del despliegue: la columna llega en cero\n",
    "porque todavía no se facturó nada."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "produccion = X2_te.copy()\n",
    "produccion['monto_final_facturado'] = 0.0\n",
    "\n",
    "print('predice que compran:', int(tramposo.predict(produccion).sum()), 'de', len(produccion))\n",
    "print('la realidad era     :', int(y2_te.sum()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "predice que compran: 0 de 750\n",
    "la realidad era     : 433\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cero. **El modelo dice que no compra nadie**, cuando en realidad\n",
    "compraron 433 de 750.\n",
    "\n",
    "Ese es exactamente el día que se descubre la fuga en la vida real: cuando el\n",
    "sistema lleva una semana en producción y el equipo comercial pregunta por qué la\n",
    "lista de llamadas está vacía 😱"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. El cliente_id como columna\n",
    "\n",
    "Mete el identificador del cliente como una categórica más y\n",
    "mira qué pasa."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "CAT_ID = CATEGORICAS + ['cliente_id']\n",
    "X4 = datos[NUMERICAS + CAT_ID]\n",
    "X4_tr, X4_te, y4_tr, y4_te = train_test_split(X4, y, test_size=0.25,\n",
    "                                              random_state=42, stratify=y)\n",
    "m4 = arma(NUMERICAS, CAT_ID).fit(X4_tr, y4_tr)\n",
    "print('AUC con cliente_id:', round(roc_auc_score(y4_te, m4.predict_proba(X4_te)[:, 1]), 4))\n",
    "print('AUC sin cliente_id:', round(roc_auc_score(y_te, honesto.predict_proba(X_te)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "AUC con cliente_id: 0.6748\n",
    "AUC sin cliente_id: 0.7214\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Baja un poco. Y aquí hay una lección que sorprende: **meter un\n",
    "identificador no siempre infla, a veces solo estorba**.\n",
    "\n",
    "Con 617 clientes, el codificador crea 617 columnas de las que casi todas\n",
    "valen cero, el modelo se queda sin datos por columna y empeora. Sea como sea, un\n",
    "identificador nunca es una característica: es un nombre 🏷️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. Partir por fecha, que es lo que pasa de verdad\n",
    "\n",
    "En vez de al azar, entrena con 2025 y examina con 2026."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "es_2025 = datos['fecha'].dt.year == 2025\n",
    "m5 = arma(NUMERICAS, CATEGORICAS).fit(X[es_2025.values], y[es_2025.values])\n",
    "print('entrenamiento:', int(es_2025.sum()), 'filas de 2025')\n",
    "print('examen       :', int((~es_2025).sum()), 'filas de 2026')\n",
    "print('AUC en el futuro:', round(roc_auc_score(y[~es_2025.values],\n",
    "      m5.predict_proba(X[~es_2025.values])[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "entrenamiento: 2008 filas de 2025\n",
    "examen       : 992 filas de 2026\n",
    "AUC en el futuro: 0.7227\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "0,7227, prácticamente igual al 0,7214 del azar. Eso es una buena noticia:\n",
    "**el modelo aguanta el paso del tiempo** en este archivo, y encima\n",
    "entrenando con dos tercios de los datos en vez de con tres cuartos.\n",
    "\n",
    "Y esta partición es la que corresponde cuando vas a predecir el futuro, que es\n",
    "casi siempre. Si al partir por fecha el número se cae, tienes deriva, y eso es el\n",
    "capítulo 27 📅"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. El fallo silencioso de partir por separado\n",
    "\n",
    "Parte las columnas y el objetivo con dos llamadas\n",
    "distintas, cada una con su semilla, y mira si te enteras."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "X_a, X_b = train_test_split(X, test_size=0.25, random_state=1)\n",
    "y_a, y_b = train_test_split(y, test_size=0.25, random_state=2)\n",
    "\n",
    "revuelto = arma(NUMERICAS, CATEGORICAS).fit(X_a, y_a)\n",
    "print('exactitud:', round(revuelto.score(X_b, y_b), 4))\n",
    "print('AUC      :', round(roc_auc_score(y_b, revuelto.predict_proba(X_b)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "exactitud: 0.5747\n",
    "AUC      : 0.5155\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Ni un error.** Los dos trozos miden 2.250 filas, así que\n",
    "scikit-learn los acepta tan contento, y lo que hemos hecho es entrenar cada fila\n",
    "con la etiqueta de otra 😳\n",
    "\n",
    "El único que lo delata es el AUC: **0,5155**. Un modelo que\n",
    "ordena al azar da 0,5, así que ese número dice \"aquí no se aprendió nada\".\n",
    "\n",
    "Y fíjate en la exactitud: 0,5747, que se parece muchísimo al listón de 0,5773.\n",
    "Si solo miraras la exactitud pensarías que el modelo es flojito, no que está\n",
    "roto 📐\n",
    "\n",
    "Solo salta el error si además cambias el tamaño:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "X_c, X_d = train_test_split(X, test_size=0.30, random_state=1)\n",
    "arma(NUMERICAS, CATEGORICAS).fit(X_c, y_a)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "ValueError: Found input variables with inconsistent numbers of samples: [2100, 2250]\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*inconsistent numbers of samples: [2100, 2250]*. Ese sí avisa, y es el\n",
    "error con suerte.\n",
    "\n",
    "Por eso `train_test_split` se llama **una sola vez con\n",
    "todo** y devuelve los cuatro trozos alineados 🔗"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. Cazar columnas sospechosas de una pasada\n",
    "\n",
    "Un bucle que mide cuánto separa cada columna numérica ella\n",
    "sola. Lo que salga demasiado alto, se mira."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "candidatas = NUMERICAS + ['monto_final_facturado']\n",
    "for col in candidatas:\n",
    "    v = datos[col].fillna(datos[col].median())\n",
    "    auc = roc_auc_score(y, v)\n",
    "    if auc < 0.5:\n",
    "        auc = 1 - auc\n",
    "    marca = '  <-- SOSPECHOSA' if auc > 0.9 else ''\n",
    "    print(f'{col:24} {auc:.4f}{marca}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "unidades                 0.5127\n",
    "monto                    0.6374\n",
    "descuento                0.5313\n",
    "satisfaccion             0.5980\n",
    "precio_unitario          0.6086\n",
    "sin_compra_previa        0.5610\n",
    "sin_descuento            0.5394\n",
    "sin_satisfaccion         0.5065\n",
    "visita_numero            0.5110\n",
    "monto_final_facturado    1.0000  <-- SOSPECHOSA\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Una sola columna pasa de 0,9 y es justo la tramposa. Las demás están entre\n",
    "0,50 y 0,62, que es lo razonable.\n",
    "\n",
    "Este bucle son seis líneas y lo corro **siempre**, antes de\n",
    "entrenar nada. Ninguna columna suelta debería predecir casi perfecto: si lo\n",
    "hace, o es la respuesta disfrazada o es algo que en producción no vas a\n",
    "tener 🚨"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 7. Los dos números que se reportan juntos\n",
    "\n",
    "Deja escrito el resultado honesto de este capítulo, con las\n",
    "dos particiones."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('partiendo por fila   :', round(roc_auc_score(y_te,\n",
    "      honesto.predict_proba(X_te)[:, 1]), 4))\n",
    "print('partiendo por cliente:', round(roc_auc_score(y.iloc[i_te],\n",
    "      por_grupo.predict_proba(X.iloc[i_te])[:, 1]), 4))\n",
    "print('con la columna del futuro:', round(roc_auc_score(y2_te,\n",
    "      tramposo.predict_proba(X2_te)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "partiendo por fila   : 0.7214\n",
    "partiendo por cliente: 0.6843\n",
    "con la columna del futuro: 0.9994\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Tres números del mismo archivo y el mismo modelo: 0,7214, 0,6843 y 0,9994.\n",
    "\n",
    "El tercero es mentira. El primero vale si vas a predecir sobre clientes que ya\n",
    "conoces. El segundo vale si vas a predecir sobre clientes nuevos.\n",
    "\n",
    "**Cuál de los tres aparece en la diapositiva depende de quién arma la\n",
    "diapositiva**, y esa es la parte del oficio que no es técnica 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "monto_final_facturado solo se llena si la venta se cerró. ¿Puede entrar en un modelo que predice si se va a cerrar?\n",
    "\n",
    "a) No: el día que hay que predecir esa columna todavía está vacía\n",
    "\n",
    "b) Sí, si se rellenan los nulos con cero\n",
    "\n",
    "c) Sí, porque mejora muchísimo el AUC\n",
    "\n",
    "d) Depende del modelo\n",
    "\n",
    "---\n",
    "\n",
    "**La correcta es la a.**\n",
    "\n",
    "*b)* Rellenarla con cero es contarle al modelo que no se cerró, que es justo lo que tiene que adivinar.\n",
    "\n",
    "*c)* Mejora porque es la respuesta disfrazada de columna.\n",
    "\n",
    "*d)* No depende del modelo: depende de cuándo existe el dato.\n",
    "\n",
    "La pregunta de la fuga es siempre la misma: ¿este dato existe en el momento de predecir?"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Y la que se cuela por donde nadie mira"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "Seleccionas las mejores columnas y luego validas con validación cruzada, que es lo que hay que hacer. En este orden.\n",
    "\n",
    "```\n",
    "X_sel = SelectKBest(k=10).fit_transform(X, y)\n",
    "\n",
    "scores = cross_val_score(modelo, X_sel, y,\n",
    "                         cv=5)\n",
    "```\n",
    "\n",
    "**Qué está mal**\n",
    "\n",
    "La selección mira **la y entera** antes de que exista ningún reparto. Para cuando la validación cruzada aparta su primer trozo, esas diez columnas ya fueron elegidas sabiendo lo que ese trozo contestaba 🎯\n",
    "\n",
    "Y esta es de las peores porque la validación cruzada es justo la herramienta que la gente usa para *demostrar* que no hay trampa. Sale un número con su desviación, con pinta de robusto, y está contaminado desde la primera línea.\n",
    "\n",
    "La regla no tiene excepciones: **todo lo que aprenda algo de los datos va dentro del pipeline**, y el pipeline entero es lo que se valida. Seleccionar columnas aprende. Imputar aprende. Escalar aprende. Mirar el gráfico y decidir a mano también aprende, y esa no la caza ningún código."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- 🚨 AUC por encima de 0,95 en un problema de negocio: hay fuga hasta que\n",
    "demuestres lo contrario.\n",
    "\n",
    "- 💥 La columna que es la respuesta disfrazada llevó el AUC de 0,7214 a 0,9994,\n",
    "y en producción predice que no compra nadie.\n",
    "\n",
    "- 👥 431 de 617 clientes estaban en los dos lados al partir por filas. Partiendo\n",
    "por cliente, el AUC honesto baja de 0,7214 a 0,6843.\n",
    "\n",
    "- 🕵️‍♀️ Hay fugas que *no* inflan el número: la de los días dio 0,7183. No\n",
    "se cazan por el resultado, se cazan por el origen de la columna.\n",
    "\n",
    "- 🏷️ Un identificador nunca es una característica.\n",
    "\n",
    "- 📅 Si vas a predecir el futuro, parte por fecha. Aquí aguanta: 0,7083.\n",
    "\n",
    "- 📏 El bucle de seis líneas que mide el AUC de cada columna sola se corre\n",
    "siempre, antes de entrenar.\n",
    "\n",
    "Y si de todo el capítulo te llevas una sola frase, que sea esta:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "La fuga no rompe nada. Por eso llega hasta producción."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esta lista de comprobaciones también sirve fuera de este libro. Si quieres el vocabulario suelto, lo tengo en dos líneas por término en el [glosario de IA](https://missyera.com/glosario-ia/) 📖\n",
    "\n",
    "En el capítulo 13 dejamos de usar un solo modelo: regresión logística, árbol,\n",
    "bosque, boosting y dos más, todos sobre las mismas columnas, para ver cuál gana\n",
    "de verdad.\n",
    "\n",
    "Que tengas lindo día! 🌸"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 12 de **Machine learning desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/machine-learning-desde-cero/fuga-de-informacion/\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
}
