{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Abrir el archivo y pelearse con la suciedad\n",
    "\n",
    "37 filas repetidas, Lima escrita de cuatro formas, medio millón de soles que se evaporan sin dar error, y unos huecos que predicen.\n",
    "\n",
    "Cuaderno de soluciones del capítulo 4 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/eda-y-limpieza/\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": [
    "Se dice que el 80% del trabajo es limpiar datos y suena a queja. No lo es: es\n",
    "la descripción del oficio, y es donde se gana o se pierde el proyecto 🧹\n",
    "\n",
    "Una pregunta antes de abrir nada: **¿cuánto tiempo llevas peleándote con un archivo que alguien más llenó a mano?** Porque eso no es mala suerte tuya, es el estado normal de los datos de cualquier empresa 🫠\n",
    "\n",
    "En este capítulo abrimos el archivo de verdad. Trae 37 filas repetidas, Lima\n",
    "escrita de cuatro maneras, 612 montos que Python no va a leer como números, 773\n",
    "fechas en otro formato y tres columnas con huecos. Nada de eso da error.\n",
    "\n",
    "Y al final del capítulo vas a ver la parte que casi nadie mira: que\n",
    "**los huecos de este archivo predicen mejor que la mitad de las\n",
    "columnas**."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo primero, siempre lo mismo"
   ]
  },
  {
   "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",
    "ventas = pd.read_csv(URL)\n",
    "\n",
    "print(ventas.shape)\n",
    "print(ventas.dtypes)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí ya hay dos avisos gritando, y hay que saber leerlos:\n",
    "\n",
    "- 💸 `monto` dice `str`. Una columna de dinero que llega\n",
    "como texto significa que algo trae un carácter raro.\n",
    "\n",
    "- 📅 `fecha` y `fecha_ultima_compra` también son texto.\n",
    "Normal al leer un CSV, pero hay que convertirlas y ahí es donde muerde.\n",
    "\n",
    "Mi regla: **si una columna que debería ser número sale como\n",
    "texto, hay suciedad, siempre**. Nunca falla."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Las filas repetidas, que se cuelan sin ruido"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('id_venta repetidos:', ventas['id_venta'].duplicated().sum())\n",
    "print('filas idénticas:   ', ventas.duplicated().sum())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "37 ventas cargadas dos veces, enteras. Es lo típico de un proceso que se\n",
    "ejecutó dos veces, y en un reporte de facturación eso son 37 ventas contadas\n",
    "doble."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "ventas = ventas.drop_duplicates()\n",
    "print(len(ventas))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "3.000 filas redonditas. Y ojo con el orden: **quitar duplicados va\n",
    "primero**, antes de cualquier cuenta, porque si no todas las tasas que\n",
    "calcules van a estar contaminadas por esas 37.\n",
    "\n",
    "Cuidado también con lo contrario: si el `id_venta` se repitiera\n",
    "pero las filas fueran distintas, eso *no* es un duplicado, es un problema\n",
    "más feo y hay que preguntar. Aquí las 37 son idénticas, así que fuera."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lima, escrita de cuatro formas"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(ventas['ciudad'].value_counts())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí está: `lima`, `Líma`, `Lima` y\n",
    "`LIMA`. Con tilde, sin tilde, en mayúsculas y en minúsculas 🫠\n",
    "\n",
    "Para pandas son cuatro ciudades distintas, así que Lima aparece cuatro veces\n",
    "en el fondo de la lista en vez de una sola vez arriba. Cualquier\n",
    "`groupby('ciudad')` te da un reporte partido, y cualquier modelo se\n",
    "come cuatro columnas donde debería haber una."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "ventas['ciudad'] = (ventas['ciudad'].str.strip().str.lower()\n",
    "                    .str.normalize('NFKD')\n",
    "                    .str.encode('ascii', 'ignore').str.decode('utf-8'))\n",
    "\n",
    "print(ventas['ciudad'].value_counts())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Seis ciudades y Lima con 471 ventas. Esa línea hace cuatro cosas y las cuatro\n",
    "hacen falta:\n",
    "\n",
    "- ✂️ `strip()` quita espacios de los bordes.\n",
    "\n",
    "- 🔡 `lower()` unifica mayúsculas.\n",
    "\n",
    "- 🔤 `normalize('NFKD')` separa la letra de su tilde.\n",
    "\n",
    "- 🧽 `encode/decode` tira las tildes sueltas.\n",
    "\n",
    "Y una cosa honesta sobre el resultado: Lima pasa de 123 (su versión más\n",
    "grande) a 471, pero **sigue empatada en el último puesto con\n",
    "Trujillo**. Limpiar no siempre destapa un gigante escondido. Lo que sí\n",
    "hace siempre es que el número deje de estar mal 🌟"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El dinero que se evapora sin avisar"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esta es mi favorita del archivo porque no da error y cuesta plata."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(ventas['monto'].head(8).tolist())\n",
    "print('montos con coma:', ventas['monto'].str.contains(',').sum())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "606 de las 3.000 vienen con coma decimal, a la peruana. Y ahora mira la forma\n",
    "que sale sola de convertirlas:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "monto_mal = pd.to_numeric(ventas['monto'], errors='coerce')\n",
    "print('se volvieron nulos:', int(monto_mal.isna().sum()))\n",
    "print('suma:', round(monto_mal.sum(), 2))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "monto_bien = pd.to_numeric(ventas['monto'].str.replace(',', '.'))\n",
    "print('se volvieron nulos:', int(monto_bien.isna().sum()))\n",
    "print('suma:', round(monto_bien.sum(), 2))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "S/1.939.750 contra S/2.411.284. **Se evaporaron S/471.534**, o\n",
    "sea un 19,6% de la facturación, sin un solo error en pantalla 💸\n",
    "\n",
    "El culpable es ese `errors='coerce'`, que significa \"lo que no\n",
    "puedas convertir, ponlo en nulo\". Es cómodo y es peligroso: **siempre que\n",
    "lo uses, cuenta cuántos nulos creó**. Si son más de los que esperabas,\n",
    "ahí está el problema.\n",
    "\n",
    "Sin el `coerce`, pandas te lo dice de frente:"
   ]
  },
  {
   "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",
    "    ventas['monto'].astype(float)\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",
    "ValueError: could not convert string to float: '921,04'\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*could not convert string to float: '921,04'*. Te da hasta el valor\n",
    "que le molestó. Mucho mejor que un silencio que te quita medio millón 🙌\n",
    "\n",
    "Y con eso ya se puede guardar la columna buena:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "ventas['monto'] = monto_bien\n",
    "print(ventas['monto'].dtype, '| suma:', round(ventas['monto'].sum(), 2))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Dos formatos de fecha en la misma columna"
   ]
  },
  {
   "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",
    "    pd.to_datetime(ventas['fecha'], format='%Y-%m-%d')\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",
    "ValueError: time data \"29/06/2025\" doesn't match format \"%Y-%m-%d\". You might want to try:\n",
    "    - passing `format` if your strings have a consistent format;\n",
    "    - passing `format='ISO8601'` if your strings are all ISO8601 but not necessarily in exactly the same format;\n",
    "    - passing `format='mixed'`, and the format will be inferred for each element individually. You might want to use `dayfirst` alongside this.\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "*time data \"29/06/2025\" doesn't match format \"%Y-%m-%d\"*. Hay fechas\n",
    "peruanas mezcladas con fechas ISO.\n",
    "\n",
    "La forma que funciona es en dos pasadas: primero el formato mayoritario, y\n",
    "después el otro solo para las que quedaron sin parsear."
   ]
  },
  {
   "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",
    "print('en formato peruano:', int(faltan.sum()))\n",
    "\n",
    "fecha[faltan] = pd.to_datetime(ventas.loc[faltan, 'fecha'],\n",
    "                               format='%d/%m/%Y', errors='coerce')\n",
    "ventas['fecha'] = fecha\n",
    "\n",
    "print('sin parsear al final:', int(fecha.isna().sum()))\n",
    "print('rango:', fecha.min().date(), 'a', fecha.max().date())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "759 de las 3.000 venían a la peruana y ninguna se quedó fuera.\n",
    "\n",
    "Lo que **no** hay que hacer es dejar que pandas adivine fila por\n",
    "fila. Con `29/06/2025` no hay duda porque no hay mes 29, pero con\n",
    "`07/03/2026` sí: puede ser el 7 de marzo o el 3 de julio, y adivinando\n",
    "te cambia el trimestre sin decírtelo. **El formato se dice, no se\n",
    "adivina** 📅"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los atípicos, y por qué no hay una respuesta"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Este es el punto donde más gente aplica una receta sin mirar lo que hace, así\n",
    "que vamos a mirar 🔬\n",
    "\n",
    "Hay dos formas estándar de marcar un valor como atípico, las dos salen en\n",
    "cualquier tutorial, y las dos son razonables. Aplicadas al mismo monto:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "m = pd.to_numeric(ventas['monto'].astype(str).str.replace(',', '.'),\n",
    "                  errors='coerce').dropna()\n",
    "\n",
    "q1, q3 = m.quantile([.25, .75])\n",
    "ri = q3 - q1\n",
    "por_rango = ((m < q1 - 1.5 * ri) | (m > q3 + 1.5 * ri))\n",
    "\n",
    "z = (m - m.mean()) / m.std()\n",
    "por_desviacion = z.abs() > 3\n",
    "\n",
    "print(f'por el rango intercuartil : {por_rango.sum():4d}  ({por_rango.mean():.2%})')\n",
    "print(f'por tres desviaciones     : {por_desviacion.sum():4d}  ({por_desviacion.mean():.2%})')\n",
    "print(f'coinciden en              : {(por_rango & por_desviacion).sum():4d}')\n",
    "print()\n",
    "print('mínimo:', round(m.min(), 2), ' máximo:', round(m.max(), 2))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "203 contra 32. **Seis veces más**, con los mismos datos y las dos\n",
    "recetas que todo el mundo usa 😳\n",
    "\n",
    "Y fíjate en la tercera línea: los 32 están todos dentro de los 203. No son dos\n",
    "grupos distintos, es que uno es mucho más estricto que el otro.\n",
    "\n",
    "El motivo es que el segundo método usa la media y la desviación, y esas dos\n",
    "las mueven los propios atípicos. Un monto de 4.236 estira la desviación, y al\n",
    "estirarla hace que el umbral de \"tres desviaciones\" se aleje, así que ese mismo\n",
    "monto tiene menos probabilidad de ser marcado. El método se protege solo 🙃\n",
    "\n",
    "El primero usa cuartiles, que no se mueven por lo que pasa en los extremos.\n",
    "Por eso marca más.\n",
    "\n",
    "### Entonces, ¿cuál es el bueno?\n",
    "\n",
    "Ninguno. Y esa es la respuesta, no una evasiva 🤷‍♀️\n",
    "\n",
    "\"Atípico\" no es una propiedad del dato, es una decisión tuya sobre qué\n",
    "consideras normal en este negocio. Los datos no la contestan porque no la\n",
    "saben.\n",
    "\n",
    "Lo que sí se puede contestar mirando:\n",
    "\n",
    "- 💀 **Un monto de −2.497,72 es imposible** si una venta no puede\n",
    "ser negativa. Ese no es atípico, es un error, y hay que preguntar qué pasó.\n",
    "\n",
    "- 💰 **Un monto de 4.236,71 puede ser perfectamente real**: un\n",
    "mayorista haciendo un pedido grande. Tirarlo sería tirar justo al cliente que\n",
    "más importa.\n",
    "\n",
    "- 📊 **Los 203 del rango intercuartil son el 6,8% de la tabla.**\n",
    "Si tiras el 6,8% de tus ventas por una fórmula, más vale que sepas a quién\n",
    "tiraste.\n",
    "\n",
    "Mi orden, que es el único que me ha funcionado: **primero separo lo\n",
    "imposible de lo raro**, y con lo imposible voy a preguntar. Lo raro se\n",
    "queda y se marca, porque un modelo que no ha visto pedidos grandes no sabe qué\n",
    "hacer cuando llegue uno 🏷️\n",
    "\n",
    "Y si al final decides recortar, se recorta comprobando: entrena con y sin\n",
    "ellos y mira si el número cambia. Si no cambia, la decisión daba igual y te\n",
    "ahorras defenderla 🧪"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los huecos, y la pregunta que casi nadie hace"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(ventas.isna().sum().sort_values(ascending=False).head(4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Tres columnas con huecos. Y aquí viene lo importante del capítulo entero.\n",
    "\n",
    "La reacción normal es rellenarlos con la media y seguir. **Casi siempre\n",
    "está mal**, porque antes hay que contestar una pregunta:\n",
    "*¿por qué falta?*\n",
    "\n",
    "Hay tres respuestas posibles y tienen nombre feo pero significan cosas\n",
    "simples:\n",
    "\n",
    "- 🎲 **MCAR**, falta al azar. Se cayó la conexión, se perdió una\n",
    "fila. Rellenar es razonable.\n",
    "\n",
    "- 🔗 **MAR**, falta según otra columna. Por ejemplo, la\n",
    "satisfacción no se pregunta en el canal web. Se puede rellenar mirando esa otra\n",
    "columna.\n",
    "\n",
    "- 🚨 **MNAR**, falta según su propio valor, o directamente el\n",
    "hueco *significa* algo. Aquí rellenar es destruir información.\n",
    "\n",
    "Vamos a averiguar cuál es cada una, y se hace en tres líneas:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for col in ['descuento', 'fecha_ultima_compra', 'satisfaccion']:\n",
    "    hueco = ventas[col].isna()\n",
    "    print(f'{col:22} sin dato: {ventas.loc[hueco, \"compro\"].mean():.4f}'\n",
    "          f'   con dato: {ventas.loc[~hueco, \"compro\"].mean():.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Léelo despacio porque es el hallazgo del capítulo:\n",
    "\n",
    "- 💰 Sin descuento se cierra el 48,1%; con descuento, el 60,2%.\n",
    "**Doce puntos.**\n",
    "\n",
    "- 🆕 Sin fecha de última compra se cierra el 41,4%; con fecha, el 61,4%.\n",
    "**Veinte puntos.**\n",
    "\n",
    "- 😐 La satisfacción vacía casi no cambia nada: 61,9% contra 57,4%.\n",
    "\n",
    "Los dos primeros huecos **no son datos que faltan, son datos en sí\n",
    "mismos**. Que no haya descuento significa que no se ofreció ninguno. Que\n",
    "no haya fecha de última compra significa que es un cliente nuevo, y por eso\n",
    "cierra veinte puntos menos.\n",
    "\n",
    "Si rellenas con la media, borras la señal más fuerte del archivo 😱 Lo que\n",
    "hay que hacer es lo contrario: **convertir el hueco en una columna**,\n",
    "y eso es el capítulo 9."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Siete sobre el archivo ya cargado. Intenta antes de abrir 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. La radiografía de las numéricas\n",
    "\n",
    "Resumen de las columnas numéricas, para cazar rarezas."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(ventas[['unidades', 'monto', 'descuento', 'satisfaccion']].describe().round(3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "       unidades     monto  descuento  satisfaccion\n",
    "count  3000.000  3000.000   2405.000      2769.000\n",
    "mean     11.601   803.761      0.125         3.010\n",
    "std       5.775   751.989      0.072         1.421\n",
    "min       1.000 -2497.720      0.000         1.000\n",
    "25%       7.000   252.690      0.063         2.000\n",
    "50%      12.000   533.630      0.127         3.000\n",
    "75%      15.000  1068.685      0.185         4.000\n",
    "max      30.000  4236.710      0.250         5.000\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Lo que yo miro en esa tabla, en este orden: que `count` sea el\n",
    "mismo en todas (aquí no lo es, y eso son los huecos), que `min` no\n",
    "sea negativo donde no puede serlo, y que `max` no sea absurdo.\n",
    "\n",
    "El descuento va de 0 a 0,25, que suena a política comercial. La satisfacción\n",
    "de 1 a 5, correcto. Las unidades de 1 a 30, correcto.\n",
    "\n",
    "Y el monto tiene un mínimo de **-2.497,72** 😳\n",
    "\n",
    "Una venta negativa. Eso no se puede pasar por alto, y es el ejercicio\n",
    "siguiente 🔍"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. Los montos extremos\n",
    "\n",
    "Las cinco ventas más grandes y las cinco más chicas."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('más grandes:', ventas['monto'].nlargest(5).round(2).tolist())\n",
    "print('más chicas :', ventas['monto'].nsmallest(5).round(2).tolist())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "más grandes: [4236.71, 4175.88, 3948.89, 3751.02, 3609.78]\n",
    "más chicas : [-2497.72, -2399.6, -976.24, -603.83, -494.7]\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Por arriba, hasta S/4.236: grandes pero posibles, y esos **no se\n",
    "tocan**. En ventas, los montos grandes suelen ser los clientes que más\n",
    "importan, y borrarlos \"por ser outliers\" es tirar justo lo que interesa.\n",
    "\n",
    "Por abajo es otra historia: los cinco más chicos son negativos. Vamos a\n",
    "mirarlos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "negativos = ventas['monto'] < 0\n",
    "print('cuántos:', int(negativos.sum()))\n",
    "print('tasa de cierre en esas filas:', round(ventas.loc[negativos, 'compro'].mean(), 4))\n",
    "print('tasa en el resto:           ', round(ventas.loc[~negativos, 'compro'].mean(), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "cuántos: 21\n",
    "tasa de cierre en esas filas: 0.0\n",
    "tasa en el resto:            0.5817\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Veintiuna filas, y **las veintiuna tienen `compro = 0`**.\n",
    "Ni una sola excepción.\n",
    "\n",
    "Eso ya no es un valor raro: es una pista de que el signo negativo lo puso el\n",
    "sistema *después* de que la venta se cayera, probablemente como anulación\n",
    "o devolución. O sea que esa columna, en esas filas, está contando el futuro.\n",
    "\n",
    "Un modelo aprendería \"monto negativo, no compra\" y acertaría el 100% de esas\n",
    "veintiuna, y el día que tenga que predecir de verdad no habrá ningún negativo\n",
    "porque todavía no se anuló nada. Eso es fuga de información y tiene el capítulo\n",
    "12 entero 🚨\n",
    "\n",
    "La lección del ejercicio no es la regla \"quita los negativos\". Es esta:\n",
    "**cuando un valor raro predice perfectamente, no es un outlier, es una\n",
    "fuga**. Y se caza mirando la tasa del objetivo dentro de esas filas, que\n",
    "son dos líneas 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. Cuánto pesa cada categoría\n",
    "\n",
    "Cuántas filas y qué tasa de cierre tiene cada categoría de\n",
    "producto."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(ventas.groupby('categoria')['compro'].agg(['count', 'mean']).round(4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "                  count    mean\n",
    "categoria                      \n",
    "Abarrotes           640  0.5906\n",
    "Bebidas             565  0.5788\n",
    "Cuidado personal    606  0.5594\n",
    "Limpieza            587  0.5877\n",
    "Snacks              602  0.5714\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cinco categorías bien repartidas y todas entre 55% y 59%. **Esta\n",
    "columna casi no separa**, y saberlo ahora te ahorra sorpresas después.\n",
    "\n",
    "No significa que haya que tirarla: significa que no esperes que sea la\n",
    "estrella del modelo 📦"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. El cruce que sí importa\n",
    "\n",
    "Tasa de cierre por segmento y canal a la vez."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "tabla = ventas.pivot_table(index='segmento', columns='canal',\n",
    "                           values='compro', aggfunc='mean').round(3)\n",
    "print(tabla)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "canal       Marketplace  Tienda    Web  WhatsApp\n",
    "segmento                                        \n",
    "Bodega            0.276   0.387  0.385     0.455\n",
    "Horeca            0.505   0.759  0.631     0.658\n",
    "Mayorista         0.637   0.712  0.737     0.819\n",
    "Minimarket        0.416   0.598  0.571     0.692\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí se ve algo que ninguna columna sola te decía: Mayorista por WhatsApp\n",
    "cierra 0,833 y Bodega por Marketplace 0,265. **Más de cincuenta puntos\n",
    "entre las dos esquinas.**\n",
    "\n",
    "Eso se llama interacción: el efecto del canal depende del segmento. Los árboles y los bosques del capítulo 13 las encuentran solos, y la regresión logística no, hay que dárselas hechas 🔀"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. Cuántos clientes son nuevos\n",
    "\n",
    "Qué porcentaje de las ventas son de clientes sin compra\n",
    "anterior."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "nuevos = ventas['fecha_ultima_compra'].isna()\n",
    "print('ventas de clientes nuevos:', int(nuevos.sum()), f'({nuevos.mean():.1%})')\n",
    "print('tasa de cierre:', round(ventas.loc[nuevos, 'compro'].mean(), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "ventas de clientes nuevos: 544 (18.1%)\n",
    "tasa de cierre: 0.4136\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un 18% de las ventas son de clientes nuevos y cierran veinte puntos menos.\n",
    "Es una conclusión de negocio completa sin haber entrenado nada: **lo caro\n",
    "no es vender, es la primera venta** 🆕"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. El error de contar antes de limpiar\n",
    "\n",
    "Suma la columna de montos tal como viene del CSV, sin\n",
    "convertirla."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "crudo = pd.read_csv(URL)\n",
    "print(len(crudo['monto'].sum()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "18972\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "18.972. **Eso no son soles, son caracteres** 😳\n",
    "\n",
    "Sumar una columna de texto en pandas pega los textos uno detrás de otro, y\n",
    "como no hay error, alguien puede meter ese número en una diapositiva. Es la\n",
    "razón por la que `dtypes` se mira antes que nada."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 7. La limpieza completa, en un bloque\n",
    "\n",
    "Junta todo lo del capítulo en algo que puedas copiar al\n",
    "inicio de cualquier cuaderno."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def carga_limpia(url):\n",
    "    \"\"\"Lee el archivo y deja los tipos como deben ser.\"\"\"\n",
    "    v = pd.read_csv(url).drop_duplicates()\n",
    "\n",
    "    v['ciudad'] = (v['ciudad'].str.strip().str.lower()\n",
    "                   .str.normalize('NFKD')\n",
    "                   .str.encode('ascii', 'ignore').str.decode('utf-8'))\n",
    "\n",
    "    v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))\n",
    "\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",
    "\n",
    "    return v\n",
    "\n",
    "limpio = carga_limpia(URL)\n",
    "print(limpio.shape)\n",
    "print(limpio[['monto', 'fecha', 'fecha_ultima_compra']].dtypes)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "(3000, 14)\n",
    "monto                         float64\n",
    "fecha                  datetime64[us]\n",
    "fecha_ultima_compra    datetime64[us]\n",
    "dtype: object\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ese `falta = f.isna() & v[col].notna()` tiene truco y vale la\n",
    "pena mirarlo: solo reintenta las que *tenían texto* y no se pudieron\n",
    "convertir. Sin esa segunda condición, intentaría reparsear también los huecos de\n",
    "verdad, y esos tienen que seguir siendo huecos 🕳️\n",
    "\n",
    "Esta función es la que vamos a usar al principio de todos los capítulos que\n",
    "vienen."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "La columna monto llega como texto y to_numeric con errors=coerce la convierte sin quejarse. ¿Qué revisas?\n",
    "\n",
    "a) Cuántos valores se volvieron nulos en la conversión\n",
    "\n",
    "b) Nada, si no dio error está bien\n",
    "\n",
    "c) El tipo final de la columna\n",
    "\n",
    "d) La media, para ver si es razonable\n",
    "\n",
    "---\n",
    "\n",
    "**La correcta es la a.**\n",
    "\n",
    "*b)* errors=coerce convierte en nulo todo lo que no entiende, y no avisa.\n",
    "\n",
    "*c)* El tipo va a salir bien igual. La pregunta es cuánto se perdió por el camino.\n",
    "\n",
    "*d)* Una media razonable puede salir de la mitad de las filas.\n",
    "\n",
    "Los errores que no dan error son los que llegan a una diapositiva."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Y una que corre sin quejarse"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "El clásico de los viernes. Hay nulos en descuento, se rellenan con cero y a otra cosa. Es una línea, es lo que hace todo el mundo, y cuesta dinero.\n",
    "\n",
    "```\n",
    "ventas['descuento'] = (ventas['descuento']\n",
    "                       .fillna(0))\n",
    "\n",
    "# \"un nulo en descuento es que no hubo\n",
    "#  descuento, obvio\"\n",
    "```\n",
    "\n",
    "**Qué está mal**\n",
    "\n",
    "Puede que sea obvio y aun así te acabas de comer la mejor señal del archivo. Las 603 filas sin descuento cierran el **47,93%** y las que sí lo traen cierran el **60,15%**: doce puntos de diferencia, que es más de lo que consigue el primer modelo entero del capítulo 1 🕳️\n",
    "\n",
    "Con `fillna(0)` esas 603 filas pasan a ser indistinguibles de las que tuvieron descuento cero de verdad, y los doce puntos desaparecen sin dejar rastro. Antes de rellenar un hueco se pregunta **por qué falta**, y si la respuesta importa, el hueco se convierte en columna propia en vez de taparse."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- 🔍 `dtypes` antes que nada. Una columna de dinero que sale como\n",
    "texto siempre esconde suciedad.\n",
    "\n",
    "- 👯 Los duplicados se quitan primero: aquí eran 37 filas enteras.\n",
    "\n",
    "- 🧽 Cuatro formas de escribir Lima son cuatro ciudades para pandas. Se\n",
    "arregla con strip, lower y normalize.\n",
    "\n",
    "- 💸 `errors='coerce'` convierte lo que no entiende en nulos, sin\n",
    "avisar. Aquí se llevaba S/470.000. Cuenta siempre los nulos que crea.\n",
    "\n",
    "- 📅 El formato de fecha se dice, no se adivina, y se pasa en dos pasadas si\n",
    "hay dos formatos.\n",
    "\n",
    "- 🕳️ Antes de rellenar un hueco, pregunta por qué falta. Aquí, \"sin fecha de\n",
    "última compra\" vale veinte puntos de tasa de cierre.\n",
    "\n",
    "- 🔀 Las tablas cruzadas encuentran lo que ninguna columna sola dice: de 0,265\n",
    "a 0,833 según segmento y canal.\n",
    "\n",
    "Y si de todo el capítulo te llevas una sola frase, que sea esta:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Los datos sucios no dan error. Por eso hay que ir a buscarlos."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Casi todo lo de este capítulo es pandas puro, así que si alguna línea te costó, el [libro de Python desde cero](https://missyera.com/guias/python-desde-cero/) la explica desde el principio 🐍\n",
    "\n",
    "En el capítulo 9 convertimos todo lo que acabamos de descubrir en columnas\n",
    "que un modelo pueda usar, y ahí es donde de verdad se gana.\n",
    "\n",
    "Que tengas lindo día! 🌸"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 4 de **Machine learning desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/machine-learning-desde-cero/eda-y-limpieza/\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
}
