{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Cada columna, mirada de una en una\n",
    "\n",
    "La ficha de cuatro números que decide qué escalar y qué transformar, y los tres métodos que cuentan 203, 32 o 222 atípicos en la misma columna.\n",
    "\n",
    "Cuaderno de soluciones del capítulo 6 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-univariado/\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": [
    "Ya está limpio y ya sabemos qué es cada columna. Toca mirarlas\n",
    "**una por una**, sin cruzarlas con nada 🔎\n",
    "\n",
    "Y esto no es un trámite. Lo que salga aquí decide cosas muy concretas más\n",
    "adelante: qué se escala, qué se transforma, qué se parte en tramos, qué imputar\n",
    "con la media y qué con la mediana. Saltarse este capítulo es tomar todas esas\n",
    "decisiones a ojo.\n",
    "\n",
    "Tranqui, que son cuatro números por columna y una función que los saca\n",
    "todos 💜"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## La ficha de una columna"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El resumen que yo saco de cada columna numérica antes de decidir nada."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import pandas as pd\n",
    "from scipy import stats\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",
    "ventas = carga_limpia(URL)\n",
    "ventas['precio_unitario'] = ventas['monto'] / ventas['unidades']\n",
    "\n",
    "NUMERICAS = ['unidades', 'monto', 'descuento', 'satisfaccion',\n",
    "             'precio_unitario', 'monto_final_facturado']\n",
    "\n",
    "def ficha(v, columnas):\n",
    "    filas = []\n",
    "    for c in columnas:\n",
    "        s = v[c].dropna()\n",
    "        filas.append({\n",
    "            'columna': c,\n",
    "            'n': len(s),\n",
    "            'nulos_%': round(100 * v[c].isna().mean(), 2),\n",
    "            'media': round(s.mean(), 2),\n",
    "            'mediana': round(s.median(), 2),\n",
    "            'ds': round(s.std(), 2),\n",
    "            'cv': round(s.std() / s.mean(), 3) if s.mean() else np.nan,\n",
    "            'asimetria': round(stats.skew(s), 3),\n",
    "            'curtosis': round(stats.kurtosis(s), 3),\n",
    "            'min': round(s.min(), 2),\n",
    "            'max': round(s.max(), 2),\n",
    "        })\n",
    "    return pd.DataFrame(filas)\n",
    "\n",
    "print(ficha(ventas, NUMERICAS).to_string(index=False))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esa tabla ya cuenta media docena de cosas. Vamos por partes 🧵"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los cuatro números que de verdad se miran"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Media contra mediana.** Si se parecen, la columna es\n",
    "simétrica. Si la media es mucho mayor, hay cola por la derecha:\n",
    "`monto` tiene media 803,76 y mediana 533,63, o sea que la mitad de\n",
    "las ventas está por debajo de 534 y unas pocas grandes tiran del promedio.\n",
    "\n",
    "**El coeficiente de variación**, que es la desviación dividida\n",
    "entre la media."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "CV=sx¯\n",
    "\n",
    "la desviación dividida entre la media, que sirve para comparar cuánto varían dos columnas medidas en unidades distintas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Sirve para lo que la desviación sola no puede: comparar columnas medidas en\n",
    "unidades distintas. `unidades` tiene CV 0,498 y\n",
    "`precio_unitario` tiene 2,104, o sea que el precio por unidad varía\n",
    "**cuatro veces más** en términos relativos, aunque los dos números\n",
    "crudos no se puedan comparar.\n",
    "\n",
    "**Asimetría y curtosis**, que en una campana perfecta valen\n",
    "cero las dos."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "g1=1n∑(xi−x¯)3s3,g2=1n∑(xi−x¯)4s4−3\n",
    "\n",
    "la primera dice hacia qué lado se estira la cola y la segunda cuánto pesan los extremos, y las dos valen cero en una campana perfecta"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y aquí salta la que se lleva el premio: `precio_unitario`, con\n",
    "asimetría 6,734 y curtosis 60,015. Eso no es una cola larga, eso es un cohete\n",
    "🚀\n",
    "\n",
    "Una columna así rompe cualquier modelo lineal si entra tal cual, y encima el\n",
    "escalado estándar no la arregla: restarle la media y dividir por la desviación\n",
    "no cambia la forma, solo la mueve de sitio."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El error que se comete recorriendo columnas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Lo natural cuando quieres la ficha de todo es meter un bucle sobre\n",
    "`ventas.columns`. No acaba bien."
   ]
  },
  {
   "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",
    "    stats.shapiro(ventas['segmento'])\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: 'Horeca'\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Por eso la función de arriba recibe una lista de columnas en vez de\n",
    "recorrerlas todas. La lista sale de la auditoría de tipos del capítulo\n",
    "5, que es exactamente para lo que servía 🗂️\n",
    "\n",
    "Y ojo con la otra versión del mismo problema, que es peor porque no da error:\n",
    "si le pasas una columna con nulos, `shapiro` devuelve\n",
    "`nan` tan tranquilo. De ahí el `dropna()` dentro de la\n",
    "ficha."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Normalidad: la prueba contesta otra pregunta"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Todo el mundo aprende a preguntar si una columna es normal. Preguntémoslo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for c in NUMERICAS:\n",
    "    s = ventas[c].dropna()\n",
    "    w, p = stats.shapiro(s.sample(min(4000, len(s)), random_state=7))\n",
    "    ad = stats.anderson(s, dist='norm')\n",
    "    print(f'{c:22s} shapiro W {w:.4f} p {p:.3e}   anderson {ad.statistic:8.2f} '\n",
    "          f'(critico al 5% {ad.critical_values[2]:.3f})')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Las seis rechazadas, y no por poco: la más suave sale con p de 5,18e-16 😅\n",
    "\n",
    "Aquí es donde casi todo el mundo concluye \"es que con muchos datos la prueba\n",
    "rechaza siempre\". Yo también lo pensaba, así que lo medí."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "rng = np.random.default_rng(7)\n",
    "\n",
    "print('datos normales de verdad:')\n",
    "for n in (30, 100, 1000, 3000, 20000):\n",
    "    p = stats.shapiro(rng.normal(0, 1, n)).pvalue\n",
    "    print(f'  n {n:6d}  p {p:.4f}  {\"pasa\" if p > 0.05 else \"RECHAZA\"}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Pues no. Los datos normales pasan la prueba con 30 y con 20.000 🤨\n",
    "\n",
    "Así que la conclusión honesta es la incómoda: **estas seis columnas\n",
    "sencillamente no son normales**. No es un artefacto del tamaño.\n",
    "\n",
    "Donde sí manda el tamaño es en las desviaciones chicas. Aquí una mezcla que\n",
    "es 95% normal y 5% de algo con más dispersión, veinte veces por cada tamaño:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print('95% normal y 5% de otra cosa, 20 intentos por tamano:')\n",
    "for n in (50, 200, 1000, 3000, 10000):\n",
    "    ps = []\n",
    "    for semilla in range(20):\n",
    "        g = np.random.default_rng(semilla)\n",
    "        mezcla = np.where(g.random(n) < 0.95, g.normal(0, 1, n), g.normal(0, 3, n))\n",
    "        ps.append(stats.shapiro(mezcla).pvalue)\n",
    "    ps = np.array(ps)\n",
    "    print(f'  n {n:6d}  p mediana {np.median(ps):.2e}  rechaza en '\n",
    "          f'{int((ps < 0.05).sum())}/20')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí está el efecto del tamaño, dicho bien: con 50 filas esa desviación se\n",
    "cuela la mitad de las veces y con 1.000 no se le escapa ninguna.\n",
    "\n",
    "O sea que la prueba no rechaza de más con muchos datos: **rechaza de\n",
    "menos con pocos**. Es lo mismo visto al revés y cambia la conclusión\n",
    "entera.\n",
    "\n",
    "Y aun así yo casi nunca uso la prueba, por una razón práctica: contesta\n",
    "\"¿es exactamente normal?\" cuando la pregunta útil es \"¿cuánto se desvía?\". Eso\n",
    "lo contestan la asimetría y la curtosis, que además te dicen hacia dónde 📐"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Cuántos atípicos hay, según a quién preguntes"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Tres formas de contarlos, las tres estándar."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for c in NUMERICAS:\n",
    "    s = ventas[c].dropna()\n",
    "    q1, q3 = s.quantile([0.25, 0.75])\n",
    "    ri = q3 - q1\n",
    "    tukey = ((s < q1 - 1.5 * ri) | (s > q3 + 1.5 * ri)).sum()\n",
    "    z = (np.abs(stats.zscore(s)) > 3).sum()\n",
    "    mad = stats.median_abs_deviation(s)\n",
    "    robusto = (np.abs(0.6745 * (s - s.median()) / mad) > 3.5).sum() if mad else 0\n",
    "    print(f'{c:22s} tukey {tukey:5d}   z>3 {z:5d}   z robusto {robusto:5d}   '\n",
    "          f'de {len(s)}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Mira `monto`: 203, 32 y 222. Siete veces más según el método 😬\n",
    "\n",
    "Y no es que uno esté mal, es que miden cosas distintas. El z clásico usa la\n",
    "media y la desviación, que **los propios atípicos inflan**, así que\n",
    "se le esconden. El z robusto usa la mediana y la MAD, que no se dejan mover, y\n",
    "por eso encuentra más.\n",
    "\n",
    "La consecuencia práctica es dura de aceptar: **no existe \"cuántos\n",
    "atípicos tiene esta columna\"**. Existe cuántos encuentra el método que\n",
    "elegiste, y ese método hay que decirlo cuando se reporta el número.\n",
    "\n",
    "Fíjate además en las dos que salen en cero por los tres caminos:\n",
    "`descuento` y `satisfaccion`. Cuando los tres métodos\n",
    "coinciden, ahí sí se puede afirmar algo 👍"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que ninguna ficha resume"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for c in ('monto', 'precio_unitario', 'monto_final_facturado'):\n",
    "    s = ventas[c]\n",
    "    print(f'{c:22s} negativos {int((s < 0).sum()):4d}   '\n",
    "          f'ceros {int((s == 0).sum()):5d}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Veintiún montos negativos. Son las devoluciones del capítulo\n",
    "4, y aparecen aquí otra vez porque el mínimo de la ficha ya\n",
    "lo cantaba: −2497,72.\n",
    "\n",
    "Y `monto_final_facturado` con 1.267 ceros, que es el 42,23% de las\n",
    "ventas. Esa columna se delata sola sin cruzarla con nada:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(ventas['monto_final_facturado'].describe().round(2).to_string())\n",
    "print()\n",
    "print('valen exactamente 0:', int((ventas['monto_final_facturado'] == 0).sum()),\n",
    "      f\"({100 * (ventas['monto_final_facturado'] == 0).mean():.2f}%)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El primer cuartil vale 0 y la mediana 191,89. Una columna de dinero donde el\n",
    "25% inferior es exactamente cero no es una columna de dinero: es dos cosas\n",
    "metidas en una, y una de ellas es \"no pasó nada\" 🚩\n",
    "\n",
    "Todavía no sabemos que es una fuga (eso es el capítulo\n",
    "12), pero ya sabemos que hay que preguntar por ella. Y\n",
    "eso lo dio el análisis de una sola columna."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Las categóricas, que aquí no dan guerra"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for c in ('ciudad', 'segmento', 'canal', 'categoria'):\n",
    "    cuenta = ventas[c].value_counts(dropna=False)\n",
    "    print(f'{c:12s} niveles {len(cuenta):3d}   '\n",
    "          f'el mas comun {cuenta.index[0]!r} {100 * cuenta.iloc[0] / len(ventas):5.2f}%   '\n",
    "          f'el mas raro {cuenta.index[-1]!r} {100 * cuenta.iloc[-1] / len(ventas):5.2f}%')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Todas repartidas entre el 15,70% y el 26,70%, y ninguna pasa de seis\n",
    "niveles. Aburrido, y lo digo como un elogio 😌\n",
    "\n",
    "Lo aburrido aquí significa que no hay que hacer nada: sin categorías raras\n",
    "no hay que agrupar niveles, y con seis niveles el one-hot no infla la tabla.\n",
    "\n",
    "Cuando esta tabla sale fea (una categoría con el 95%, o cuarenta niveles con\n",
    "tres filas cada uno) es cuando empiezan las decisiones difíciles, y esas están\n",
    "en el capítulo 5."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. La ficha completa, con recomendación\n",
    "\n",
    "Amplía la ficha para que además diga qué hacer con cada\n",
    "columna según su forma."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def recomienda(fila):\n",
    "    if abs(fila['asimetria']) > 2:\n",
    "        return 'muy sesgada: log o tramos'\n",
    "    if abs(fila['asimetria']) > 0.8:\n",
    "        return 'sesgada: probar log'\n",
    "    if fila['cv'] > 1:\n",
    "        return 'varia mucho: escalar seguro'\n",
    "    return 'escalar y listo'\n",
    "\n",
    "tabla = ficha(ventas, NUMERICAS)\n",
    "tabla['que_hacer'] = tabla.apply(recomienda, axis=1)\n",
    "print(tabla[['columna', 'asimetria', 'cv', 'que_hacer']].to_string(index=False))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "              columna  asimetria    cv                 que_hacer\n",
    "             unidades      0.184 0.498           escalar y listo\n",
    "                monto      1.340 0.936       sesgada: probar log\n",
    "            descuento     -0.016 0.573           escalar y listo\n",
    "         satisfaccion     -0.015 0.472           escalar y listo\n",
    "      precio_unitario      6.734 2.104 muy sesgada: log o tramos\n",
    "monto_final_facturado      1.764 1.411       sesgada: probar log\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esos cortes en 0,8 y en 2 son convenciones, no leyes de la naturaleza. Sirven\n",
    "para ordenar la conversación, no para decidir solas 📋\n",
    "\n",
    "Lo valioso de la tabla es lo mismo que en el capítulo\n",
    "5: **cada fila lleva una decisión al lado**,\n",
    "y esa decisión queda escrita antes de que nadie la pregunte en una reunión."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. El logaritmo, a ver si arregla el cohete\n",
    "\n",
    "La receta clásica para una columna sesgada es el\n",
    "logaritmo. Compruébalo con `precio_unitario`, que tenía asimetría\n",
    "6,734."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "s = ventas['precio_unitario']\n",
    "positivos = s[s > 0]\n",
    "\n",
    "print('original        asimetria', round(stats.skew(positivos), 3),\n",
    "      ' curtosis', round(stats.kurtosis(positivos), 3))\n",
    "print('log             asimetria', round(stats.skew(np.log(positivos)), 3),\n",
    "      ' curtosis', round(stats.kurtosis(np.log(positivos)), 3))\n",
    "print('raiz cuadrada   asimetria', round(stats.skew(np.sqrt(positivos)), 3),\n",
    "      ' curtosis', round(stats.kurtosis(np.sqrt(positivos)), 3))\n",
    "print()\n",
    "print('filas que el log deja fuera por no ser positivas:', int((s <= 0).sum()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "original        asimetria 6.766  curtosis 60.161\n",
    "log             asimetria 0.271  curtosis 0.272\n",
    "raiz cuadrada   asimetria 2.807  curtosis 12.177\n",
    "\n",
    "filas que el log deja fuera por no ser positivas: 21\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El logaritmo hace su trabajo y de sobra. Pero mira la última línea: deja\n",
    "fuera 21 filas, las de los montos negativos.\n",
    "\n",
    "Ese es el precio que nadie menciona. Si transformas con log tienes que\n",
    "decidir qué haces con los no positivos, y \"no hacer nada\" significa perderlos\n",
    "🕳️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. Cuánto cambia la ficha si quitas los atípicos\n",
    "\n",
    "Antes de borrar un atípico conviene ver cuánto de la\n",
    "columna se lleva por delante."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "s = ventas['monto']\n",
    "q1, q3 = s.quantile([0.25, 0.75])\n",
    "ri = q3 - q1\n",
    "dentro = s[(s >= q1 - 1.5 * ri) & (s <= q3 + 1.5 * ri)]\n",
    "\n",
    "print(f'{\"\":12s} {\"n\":>6s} {\"media\":>10s} {\"mediana\":>10s} {\"ds\":>10s} {\"asim\":>8s}')\n",
    "for nombre, datos in (('con todo', s), ('sin atipicos', dentro)):\n",
    "    print(f'{nombre:12s} {len(datos):6d} {datos.mean():10.2f} '\n",
    "          f'{datos.median():10.2f} {datos.std():10.2f} {stats.skew(datos):8.3f}')\n",
    "print()\n",
    "print('soles que se van:', round(float(s.sum() - dentro.sum()), 2))\n",
    "print('porcentaje del dinero total:',\n",
    "      round(100 * (s.sum() - dentro.sum()) / s.sum(), 2), '%')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "                  n      media    mediana         ds     asim\n",
    "con todo       3000     803.76     533.63     751.99    1.340\n",
    "sin atipicos   2797     670.93     494.47     558.71    1.214\n",
    "\n",
    "soles que se van: 534679.47\n",
    "porcentaje del dinero total: 22.17 %\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí está el argumento de verdad contra borrar atípicos por sistema 💸\n",
    "\n",
    "Son 203 filas de 3.000, o sea el 6,77%. Y se llevan **534.679\n",
    "soles**, que es el 22,17% de todo el dinero del archivo.\n",
    "\n",
    "Borrar el 6,77% de las filas para perder el 22,17% de la facturación deja una\n",
    "columna más bonita y un negocio que ya no es el tuyo. Y fíjate en que la\n",
    "asimetría apenas baja, de 1,340 a 1,214: ni siquiera arregla lo que se supone\n",
    "que venía a arreglar."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. La misma columna, mirada por trozos\n",
    "\n",
    "Una ficha global puede esconder dos poblaciones. Saca la\n",
    "ficha de `monto` dentro de cada segmento."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "por_segmento = ventas.groupby('segmento')['monto'].agg(\n",
    "    n='count', media='mean', mediana='median', ds='std',\n",
    "    asimetria=lambda s: stats.skew(s))\n",
    "por_segmento['cv'] = por_segmento['ds'] / por_segmento['media']\n",
    "print(por_segmento.round(3).to_string())\n",
    "print()\n",
    "print('global: media', round(ventas['monto'].mean(), 2),\n",
    "      'asimetria', round(stats.skew(ventas['monto']), 3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "              n     media   mediana       ds  asimetria     cv\n",
    "segmento                                                      \n",
    "Bodega      707   178.698   183.260   69.587     -0.774  0.389\n",
    "Horeca      742   755.179   742.995  277.276     -0.427  0.367\n",
    "Mayorista   750  1856.337  1869.255  719.551     -0.455  0.388\n",
    "Minimarket  801   414.917   413.740  148.680     -0.678  0.358\n",
    "\n",
    "global: media 803.76 asimetria 1.34\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esto es lo mismo que salió en el libro de estadística con la media y la\n",
    "mediana, y aquí sale todavía más fuerte: **la forma global no es la forma\n",
    "de nadie** 🎭\n",
    "\n",
    "La asimetría global es +1,340 y dentro de los cuatro segmentos es\n",
    "**negativa** en los cuatro: −0,774, −0,427, −0,455 y −0,678. No es\n",
    "que se reduzca, es que **cambia de signo**.\n",
    "\n",
    "Traducido: no tienes una columna con cola a la derecha. Tienes cuatro\n",
    "poblaciones muy distintas (178 soles de media en bodega, 1.856 en mayorista) y\n",
    "la cola de la derecha son los mayoristas enteros.\n",
    "\n",
    "Eso no se arregla con un logaritmo. Se arregla con la columna que las\n",
    "distingue, que ya la tienes y se llama `segmento` 🏷️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. Los valores repetidos, que delatan cosas\n",
    "\n",
    "Cuando un valor concreto se repite mucho en una columna\n",
    "continua, casi siempre es un relleno o un tope."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for c in NUMERICAS:\n",
    "    cuenta = ventas[c].value_counts(dropna=True)\n",
    "    if cuenta.empty:\n",
    "        continue\n",
    "    valor, veces = cuenta.index[0], cuenta.iloc[0]\n",
    "    print(f'{c:22s} el valor mas repetido es {valor:10.2f} y sale {veces:5d} veces '\n",
    "          f'({100 * veces / ventas[c].notna().sum():5.2f}%)')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "unidades               el valor mas repetido es      13.00 y sale   208 veces ( 6.93%)\n",
    "monto                  el valor mas repetido es     204.47 y sale     3 veces ( 0.10%)\n",
    "descuento              el valor mas repetido es       0.05 y sale    20 veces ( 0.83%)\n",
    "satisfaccion           el valor mas repetido es       5.00 y sale   563 veces (20.33%)\n",
    "precio_unitario        el valor mas repetido es     185.03 y sale     2 veces ( 0.07%)\n",
    "monto_final_facturado  el valor mas repetido es       0.00 y sale  1267 veces (42.23%)\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Una columna continua de verdad casi no repite valores. Cuando uno se lleva un\n",
    "porcentaje gordo, la pregunta es de dónde sale ese número 🔍\n",
    "\n",
    "Aquí lo canta `monto_final_facturado` con su cero. Y ojo con\n",
    "`unidades` y `satisfaccion`, que repiten mucho por otra\n",
    "razón perfectamente sana: son discretas y tienen pocos valores posibles."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. Cuánto pesa lo que falta\n",
    "\n",
    "Los nulos ya salieron en el capítulo\n",
    "4. Aquí la pregunta es otra: ¿faltan juntos?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "banderas = ventas[['descuento', 'satisfaccion']].isna()\n",
    "banderas.columns = ['falta_descuento', 'falta_satisfaccion']\n",
    "\n",
    "print(pd.crosstab(banderas['falta_descuento'],\n",
    "                  banderas['falta_satisfaccion']).to_string())\n",
    "print()\n",
    "esperado = banderas['falta_descuento'].mean() * banderas['falta_satisfaccion'].mean()\n",
    "juntos = (banderas['falta_descuento'] & banderas['falta_satisfaccion']).mean()\n",
    "print('si faltaran independientes, coincidirian el', round(100 * esperado, 2), '%')\n",
    "print('coinciden de verdad el                     ', round(100 * juntos, 2), '%')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "falta_satisfaccion  False  True \n",
    "falta_descuento                 \n",
    "False                2209    196\n",
    "True                  560     35\n",
    "\n",
    "si faltaran independientes, coincidirian el 1.53 %\n",
    "coinciden de verdad el                      1.17 %\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Aquí la respuesta es que no: si faltaran independientes coincidirían el\n",
    "1,53% de las filas y coinciden el 1,17%. Prácticamente lo mismo, y si acaso un\n",
    "poco menos 🤷‍♀️\n",
    "\n",
    "O sea que son dos huecos con dos causas distintas, y hay que tratarlos por\n",
    "separado. Aburrido otra vez, y otra vez es la respuesta que quieres.\n",
    "\n",
    "La comprobación importa por el caso contrario. Si dos columnas faltaran\n",
    "juntas mucho más de lo que toca por azar, no faltarían por descuido: faltarían\n",
    "por una razón, normalmente un proceso del negocio que no se ejecutó, y entonces\n",
    "son un solo problema disfrazado de dos 🕳️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 7. La ficha como control de calidad automático\n",
    "\n",
    "Convierte todo esto en una función que devuelve avisos, no\n",
    "números. Es lo que se corre cada vez que llega un archivo nuevo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "def avisos(v, columnas):\n",
    "    fuera = []\n",
    "    for c in columnas:\n",
    "        s = v[c].dropna()\n",
    "        if v[c].isna().mean() > 0.15:\n",
    "            fuera.append(f'{c}: falta el {100 * v[c].isna().mean():.1f}%')\n",
    "        if abs(stats.skew(s)) > 2:\n",
    "            fuera.append(f'{c}: asimetria {stats.skew(s):.2f}, muy sesgada')\n",
    "        if (s < 0).any():\n",
    "            fuera.append(f'{c}: {int((s < 0).sum())} valores negativos')\n",
    "        repetido = s.value_counts()\n",
    "        if not repetido.empty and repetido.iloc[0] / len(s) > 0.3:\n",
    "            fuera.append(f'{c}: el valor {repetido.index[0]} se lleva el '\n",
    "                         f'{100 * repetido.iloc[0] / len(s):.1f}%')\n",
    "    return fuera\n",
    "\n",
    "for aviso in avisos(ventas, NUMERICAS):\n",
    "    print(' ', aviso)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "  monto: 21 valores negativos\n",
    "  descuento: falta el 19.8%\n",
    "  precio_unitario: asimetria 6.73, muy sesgada\n",
    "  precio_unitario: 21 valores negativos\n",
    "  monto_final_facturado: el valor 0.0 se lleva el 42.2%\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Seis avisos y ninguno es un error: son seis preguntas que hay que hacerle a\n",
    "quien manda los datos antes de modelar 📋\n",
    "\n",
    "Esta función es la hermana pequeña de la `revisa` del capítulo\n",
    "27. Aquella vigila que el archivo de mañana se parezca\n",
    "al de hoy; esta vigila que el de hoy tenga sentido."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "La prueba de normalidad rechaza tus seis columnas numéricas con p por debajo de 1e-15. ¿Qué haces?\n",
    "\n",
    "a) Mirar la asimetría y la curtosis, que dicen cuánto se desvía\n",
    "\n",
    "b) Transformar las seis con logaritmo para normalizarlas\n",
    "\n",
    "c) Nada: casi ningún modelo del libro pide normalidad\n",
    "\n",
    "d) Quitar los atípicos hasta que la prueba pase\n",
    "\n",
    "---\n",
    "\n",
    "**La correcta es la a.**\n",
    "\n",
    "*b)* Antes de transformar conviene saber cuánto se desvía cada una, porque no todas se desvían igual.\n",
    "\n",
    "*c)* Es verdad que casi ninguno la pide, pero la forma de la columna decide otras cosas.\n",
    "\n",
    "*d)* Eso es tocar los datos para que la prueba diga lo que quieres.\n",
    "\n",
    "La prueba dice si es exactamente normal. La asimetría dice cuánto le falta, que es la pregunta útil."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El número que acaba en una diapositiva"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "Te piden el ticket medio para una presentación. Una línea, sale un número redondo, y ese número acaba en una diapositiva.\n",
    "\n",
    "```\n",
    "print(ventas['monto_final_facturado'].mean())\n",
    "\n",
    "# 480.23\n",
    "```\n",
    "\n",
    "**Qué está mal**\n",
    "\n",
    "S/480,23 no es el ticket medio de nadie. El **42%** de las filas de esa columna valen cero, porque son ventas que no se cerraron, y la media las cuenta como si fueran compras pequeñas 📉\n",
    "\n",
    "Entre quienes de verdad compraron el ticket medio es de **S/831,97**. Son S/351 de diferencia sobre el mismo archivo, y la pregunta que decide cuál va en la diapositiva no es estadística: es si te preguntaron cuánto factura una venta o cuánto factura una venta **cerrada**. Por eso una media se mira siempre al lado de su mediana y de cuántos ceros hay debajo."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- 📋 Cuatro números por columna: media contra mediana, coeficiente de\n",
    "variación, asimetría y curtosis. Con eso ya se decide qué escalar y qué\n",
    "transformar.\n",
    "\n",
    "- 🚀 `precio_unitario` tiene asimetría 6,734 y curtosis 60,015. El\n",
    "escalado estándar no arregla eso: mueve la columna de sitio pero no le cambia la\n",
    "forma.\n",
    "\n",
    "- 📏 El CV compara columnas con unidades distintas: 0,498 en\n",
    "`unidades` contra 2,104 en `precio_unitario`.\n",
    "\n",
    "- 🧪 Las seis columnas fallan la prueba de normalidad, y no es cosa del\n",
    "tamaño: los datos normales de verdad la pasan con 30 filas y con 20.000.\n",
    "\n",
    "- 🔬 Donde sí manda el tamaño es al revés: una desviación chica se cuela la\n",
    "mitad de las veces con 50 filas y no se le escapa ninguna con 1.000. La prueba\n",
    "no rechaza de más con muchos datos, rechaza de menos con pocos.\n",
    "\n",
    "- 😬 Los atípicos de `monto` son 203, 32 o 222 según el método. No\n",
    "existe \"cuántos atípicos tiene\", existe cuántos encuentra el que elegiste.\n",
    "\n",
    "- 🚩 `monto_final_facturado` se delata sin cruzarla con nada: su\n",
    "primer cuartil vale 0 y el 42,23% de las filas también.\n",
    "\n",
    "- 💸 Borrar los atípicos de `monto` quita el 6,77% de las filas y\n",
    "mucho más del dinero, porque son justo las ventas grandes.\n",
    "\n",
    "- 😌 Las cuatro categóricas están repartidas entre el 15,70% y el 26,70%, sin\n",
    "niveles raros. Aburrido, y aquí eso es una buena noticia.\n",
    "\n",
    "Y si de todo el capítulo te llevas una sola frase, que sea esta:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "No existe cuántos atípicos tiene una columna. Existe cuántos encuentra el método que elegiste."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "La asimetría, la curtosis y las pruebas de normalidad que salieron aquí tienen su capítulo propio 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": [
    "Cada columna por separado ya no tiene secretos. Falta la pregunta que de\n",
    "verdad importa: **¿cuál de ellas tiene que ver con lo que queremos\n",
    "predecir?** 🎯\n",
    "\n",
    "El capítulo 7 cruza cada columna con el objetivo, y no a\n",
    "ojo: con la prueba que corresponde a cada tipo y con el tamaño del efecto al\n",
    "lado, que es lo que separa \"hay diferencia\" de \"la diferencia importa\".\n",
    "\n",
    "Lo que verás allí:\n",
    "\n",
    "- 🧮 Qué prueba toca según el tipo de columna, en una tabla que se puede\n",
    "pegar en la pared.\n",
    "\n",
    "- 📐 Por qué el valor p solo no sirve, y qué se pone al lado.\n",
    "\n",
    "- 🎣 Las columnas que parecen decir algo y no dicen nada, con el ajuste por\n",
    "comparaciones múltiples.\n",
    "\n",
    "- 🪤 Y una columna con doce puntos de diferencia que al modelo no le va a\n",
    "aportar nada, que es la trampa del capítulo 20 vista\n",
    "desde el otro lado."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 6 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-univariado/\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
}
