{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Machine learning: el pipeline completo\n",
    "\n",
    "Cuaderno de practica de la guia **Machine learning desde cero** de Miss Yera.\n",
    "Corre de arriba abajo. No necesitas instalar nada si lo abres en Google Colab.\n",
    "\n",
    "Los datos son ventas de una distribuidora peruana, con los defectos que traen\n",
    "los datos reales: fechas en dos formatos, la ciudad escrita de cuatro maneras,\n",
    "montos como texto, duplicados y tres tipos distintos de dato faltante.\n",
    "\n",
    "Guia completa: https://missyera.com/guias/machine-learning-desde-cero/"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Aqui se entrena un modelo para predecir si una visita comercial termina en pedido. Son los mismos bloques de la guia, en el mismo orden y con los mismos numeros, para que puedas cambiar uno y ver que se mueve.\n",
    "\n",
    "Lo importante no es el modelo: es el **orden**, y sobre todo la fuga de informacion, que aqui se demuestra en vez de explicarse."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "import numpy as np\n",
    "\n",
    "URL = \"https://missyera.com/static/datasets/ventas-miss-yera.csv\"\n",
    "ventas = pd.read_csv(URL)\n",
    "\n",
    "print(ventas.shape)\n",
    "print(ventas[\"compro\"].value_counts())\n",
    "ventas.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 1. El EDA, que es donde se decide casi todo\n",
    "\n",
    "Cuatro preguntas antes de cualquier modelo: de que tipo es cada columna, donde faltan datos, hay duplicados y como esta escrita cada categoria."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(ventas.dtypes)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(ventas.isna().sum().sort_values(ascending=False).head(4))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(\"id_venta repetidos:\", ventas[\"id_venta\"].duplicated().sum())\n",
    "print(ventas[\"ciudad\"].value_counts())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`monto` llego como texto y Lima esta escrita de cuatro formas. Si entrenas asi, el modelo ve cuatro ciudades chiquitas donde hay una sola."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2. La limpieza\n",
    "\n",
    "Los cuatro problemas de arriba, arreglados. Es la parte del proyecto que mas cambia el resultado final."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "ventas = ventas.drop_duplicates(subset=\"id_venta\")\n",
    "\n",
    "ventas[\"ciudad\"] = (ventas[\"ciudad\"].str.strip().str.lower()\n",
    "                    .str.normalize(\"NFKD\")\n",
    "                    .str.encode(\"ascii\", \"ignore\").str.decode(\"utf-8\"))\n",
    "\n",
    "ventas[\"monto\"] = pd.to_numeric(ventas[\"monto\"].str.replace(\",\", \".\"),\n",
    "                                errors=\"coerce\")\n",
    "\n",
    "print(ventas[\"ciudad\"].value_counts())\n",
    "print(\"montos que no se pudieron convertir:\", ventas[\"monto\"].isna().sum())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Las fechas vienen en dos formatos mezclados. El atajo de `format=\"mixed\"` da vuelta las que ya estaban bien sin avisar, asi que se parsea cada formato por separado."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "fecha = pd.to_datetime(ventas[\"fecha\"], format=\"%Y-%m-%d\", errors=\"coerce\")\n",
    "faltan = fecha.isna()\n",
    "fecha[faltan] = pd.to_datetime(ventas.loc[faltan, \"fecha\"],\n",
    "                               format=\"%d/%m/%Y\", errors=\"coerce\")\n",
    "ventas[\"fecha\"] = fecha\n",
    "\n",
    "ultima = pd.to_datetime(ventas[\"fecha_ultima_compra\"],\n",
    "                        format=\"%Y-%m-%d\", errors=\"coerce\")\n",
    "print(\"fechas sin parsear:\", int(fecha.isna().sum()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 3. El hueco que es informacion\n",
    "\n",
    "`fecha_ultima_compra` falta justo en los clientes nuevos. Ese hueco es MNAR: no es un dato que falta, es un dato en si mismo, asi que se marca con una bandera en vez de rellenarlo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "ventas[\"dias_desde_ultima\"] = (ventas[\"fecha\"] - ultima).dt.days\n",
    "ventas[\"sin_compra_previa\"] = ultima.isna().astype(int)\n",
    "\n",
    "print(ventas.groupby(\"sin_compra_previa\")[\"compro\"].mean().round(4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Veinte puntos de diferencia en la tasa de compra. Rellenar esa fecha con un promedio habria borrado lo mas predictivo del archivo.\n",
    "\n",
    "**Pruebalo tu:** haz lo mismo con `descuento` y mira si su hueco tambien dice algo."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 4. El pipeline\n",
    "\n",
    "Cuatro instrucciones, y el orden es la mitad de la leccion: primero se aparta la prueba, despues se declara el preprocesamiento, y recien entonces se junta todo en un solo objeto."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.compose import ColumnTransformer\n",
    "from sklearn.impute import SimpleImputer\n",
    "from sklearn.linear_model import LogisticRegression\n",
    "from sklearn.model_selection import train_test_split\n",
    "from sklearn.pipeline import Pipeline\n",
    "from sklearn.preprocessing import OneHotEncoder, StandardScaler\n",
    "\n",
    "NUMERICAS = [\"unidades\", \"monto\", \"descuento\", \"satisfaccion\",\n",
    "             \"dias_desde_ultima\", \"sin_compra_previa\"]\n",
    "CATEGORICAS = [\"ciudad\", \"segmento\", \"canal\", \"categoria\"]\n",
    "\n",
    "X = ventas[NUMERICAS + CATEGORICAS]\n",
    "y = ventas[\"compro\"]\n",
    "\n",
    "X_train, X_test, y_train, y_test = train_test_split(\n",
    "    X, y, test_size=0.2, random_state=42, stratify=y)\n",
    "\n",
    "preparacion = ColumnTransformer([\n",
    "    (\"num\", Pipeline([(\"faltantes\", SimpleImputer(strategy=\"median\")),\n",
    "                      (\"escala\", StandardScaler())]), NUMERICAS),\n",
    "    (\"cat\", OneHotEncoder(handle_unknown=\"ignore\"), CATEGORICAS)])\n",
    "\n",
    "modelo = Pipeline([(\"prep\", preparacion),\n",
    "                   (\"clasificador\", LogisticRegression(max_iter=1000))])\n",
    "\n",
    "print(X.shape, \"->\", len(X_train), \"para entrenar y\", len(X_test), \"apartadas\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Por que el preprocesamiento va DENTRO.** Si escalas o imputas antes de partir, el promedio que usaste incluye datos de la prueba: el modelo se examina con informacion que ya vio. Dentro del pipeline eso es imposible por construccion."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 5. El baseline tonto\n",
    "\n",
    "Antes de celebrar nada hay que ganarle al modelo mas bobo posible. Toma dos lineas y descarta bastantes proyectos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.dummy import DummyClassifier\n",
    "\n",
    "tonto = DummyClassifier(strategy=\"most_frequent\").fit(X_train, y_train)\n",
    "print(\"la vara a superar:\", round(tonto.score(X_test, y_test), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 6. El primer modelo honesto\n",
    "\n",
    "Una linea para entrenar y la mirada seria a lo que salio. La matriz de confusion dice mucho mas que la exactitud."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score\n",
    "\n",
    "modelo.fit(X_train, y_train)\n",
    "probabilidades = modelo.predict_proba(X_test)[:, 1]\n",
    "\n",
    "print(\"exactitud:\", round(modelo.score(X_test, y_test), 4))\n",
    "print(\"AUC      :\", round(roc_auc_score(y_test, probabilidades), 4))\n",
    "print(confusion_matrix(y_test, modelo.predict(X_test)))\n",
    "print(classification_report(y_test, modelo.predict(X_test), digits=2))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Siete puntos y medio sobre el baseline. Es poco y es real: asi se ve un primer modelo honesto sobre datos que no fueron fabricados para que salga bonito."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 7. La fuga de informacion, demostrada\n",
    "\n",
    "`monto_final_facturado` suena inocente. Vamos a meterla a proposito y a ver que pasa."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "CON_FUGA = NUMERICAS + [\"monto_final_facturado\"]\n",
    "\n",
    "preparacion_con_fuga = ColumnTransformer([\n",
    "    (\"num\", Pipeline([(\"faltantes\", SimpleImputer(strategy=\"median\")),\n",
    "                      (\"escala\", StandardScaler())]), CON_FUGA),\n",
    "    (\"cat\", OneHotEncoder(handle_unknown=\"ignore\"), CATEGORICAS)])\n",
    "\n",
    "Xf = ventas[CON_FUGA + CATEGORICAS]\n",
    "Xf_train, Xf_test, yf_train, yf_test = train_test_split(\n",
    "    Xf, y, test_size=0.2, random_state=42, stratify=y)\n",
    "\n",
    "trampa = Pipeline([(\"prep\", preparacion_con_fuga),\n",
    "                   (\"clasificador\", LogisticRegression(max_iter=1000))])\n",
    "trampa.fit(Xf_train, yf_train)\n",
    "\n",
    "print(\"exactitud:\", round(trampa.score(Xf_test, yf_test), 4))\n",
    "print(\"AUC      :\", round(roc_auc_score(yf_test, trampa.predict_proba(Xf_test)[:, 1]), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Un salto asi no es un hallazgo, es un sintoma.** La comprobacion es una linea:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(ventas.groupby(\"compro\")[\"monto_final_facturado\"].describe()[[\"count\", \"mean\", \"max\"]].round(2))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cero para todo el que no compro, porque solo se factura despues de comprar. La regla que se lleva: **por cada columna, preguntate en que momento del tiempo se llena**."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 8. Cambiar de algoritmo casi nunca es la respuesta\n",
    "\n",
    "Los tres candidatos, con validacion cruzada sobre el entrenamiento. La prueba apartada sigue sin tocarse."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier\n",
    "from sklearn.model_selection import cross_val_score\n",
    "\n",
    "candidatos = [\n",
    "    (\"logistica\", LogisticRegression(max_iter=1000)),\n",
    "    (\"bosque\", RandomForestClassifier(n_estimators=300, min_samples_leaf=5,\n",
    "                                      n_jobs=-1, random_state=42)),\n",
    "    (\"boosting\", HistGradientBoostingClassifier(learning_rate=0.05, max_iter=400,\n",
    "                                                random_state=42)),\n",
    "]\n",
    "\n",
    "for nombre, clasificador in candidatos:\n",
    "    tubo = Pipeline([(\"prep\", preparacion), (\"clasificador\", clasificador)])\n",
    "    s = cross_val_score(tubo, X_train, y_train, cv=5, scoring=\"roc_auc\", n_jobs=-1)\n",
    "    print(f\"{nombre:10} AUC {s.mean():.4f} +/- {s.std():.4f}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Gana la regresion logistica. Cuando el modelo mas simple gana, el modelo mas simple es el que se lleva a produccion."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 9. El umbral sale de los costos, no del 0,5\n",
    "\n",
    "El 0,5 que trae puesto el modelo viene de la libreria, no de tu negocio. Pon aqui lo que cuesta cada error."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "COSTO_FN, COSTO_FP = 2000, 50    # los pone el negocio, no el modelo\n",
    "\n",
    "for u in np.arange(0.20, 0.85, 0.10):\n",
    "    tn, fp, fn, tp = confusion_matrix(\n",
    "        y_test, (probabilidades >= u).astype(int)).ravel()\n",
    "    print(f\"umbral {u:.2f}  FP {fp:4}  FN {fn:4}  \"\n",
    "          f\"costo S/{fp*COSTO_FP + fn*COSTO_FN:,}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Cambia los dos costos de arriba y vuelve a correr la celda.** El umbral optimo se mueve, y esa es la parte del proyecto donde manda quien conoce el negocio y no quien programa. Con estos costos, el 0,5 de fabrica cuesta siete veces mas que el 0,20."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 10. Explicar el modelo\n",
    "\n",
    "Un modelo que no puede explicarse no se aprueba en banca ni en salud. Con los coeficientes basta para una regresion logistica."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "nombres = modelo.named_steps[\"prep\"].get_feature_names_out()\n",
    "pesos = pd.Series(modelo.named_steps[\"clasificador\"].coef_[0], index=nombres)\n",
    "\n",
    "print(pesos.sort_values(ascending=False).head(4).round(3))\n",
    "print(pesos.sort_values().head(3).round(3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 11. Desempeno por grupo\n",
    "\n",
    "Un modelo con buena metrica global puede estar fallando sistematicamente en un grupo. Si afecta a personas, esto no es opcional."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "resultado = X_test.copy()\n",
    "resultado[\"acerto\"] = (modelo.predict(X_test) == y_test).astype(int)\n",
    "\n",
    "print(resultado.groupby(\"segmento\")[\"acerto\"].agg([\"size\", \"mean\"]).round(3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas\n",
    "\n",
    "El algoritmo fue lo de menos: una regresion logistica de una linea, y le gano al bosque y al boosting. Lo que decidio el resultado fue el orden, crear la bandera del hueco, quitar la variable con fuga, tener un baseline contra el que comparar y elegir el umbral con los costos reales.\n",
    "\n",
    "Y la advertencia que cierra cualquier proyecto serio: **este modelo predice, no explica**. Que el segmento pese mucho no significa que cambiar de segmento haga comprar a nadie."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.11"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}