{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Agrupar clientes sin decirle cómo\n",
    "\n",
    "K-means sobre los clientes de verdad, la silueta que dice que no hay grupos naturales, y detección de anomalías.\n",
    "\n",
    "Cuaderno de práctica del capítulo 23 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/agrupar-sin-etiqueta/\n",
    "\n",
    "Los ejercicios están al final y traen una celda vacía debajo de cada uno. Las\n",
    "respuestas viven en el cuaderno de soluciones, y merece la pena pelearse un\n",
    "rato antes de abrirlo 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Veintiún capítulos prediciendo, y todos necesitaban lo mismo: una columna con\n",
    "la respuesta correcta. Este va de cuando no la hay 🧩\n",
    "\n",
    "Y es lo que más me piden después de predecir, con una frase que ya conoces:\n",
    "\"queremos segmentar a los clientes\". Antes de empezar, la pregunta:\n",
    "**¿cómo sabrías si los grupos que encontraste existen de verdad o los\n",
    "inventó el algoritmo?** De eso va casi todo el capítulo 🔍"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Una fila por cliente, que es donde empieza todo"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import pandas as pd\n",
    "from sklearn.cluster import KMeans\n",
    "from sklearn.metrics import silhouette_score\n",
    "from sklearn.preprocessing import StandardScaler\n",
    "\n",
    "URL = 'https://missyera.com/static/datasets/ventas-miss-yera.csv'\n",
    "\n",
    "v = pd.read_csv(URL).drop_duplicates()\n",
    "v['monto'] = pd.to_numeric(v['monto'].str.replace(',', '.'))\n",
    "v = v[v['monto'] > 0]\n",
    "v['fecha'] = pd.to_datetime(v['fecha'], format='mixed', dayfirst=True, errors='coerce')\n",
    "\n",
    "corte = v['fecha'].max()\n",
    "c = v.groupby('cliente_id').agg(\n",
    "    total=('monto', 'sum'), compras=('monto', 'count'),\n",
    "    ticket=('monto', 'mean'), ultima=('fecha', 'max')).reset_index()\n",
    "c['dias_sin_comprar'] = (corte - c['ultima']).dt.days\n",
    "c = c.dropna(subset=['dias_sin_comprar'])\n",
    "\n",
    "COLS = ['total', 'compras', 'ticket', 'dias_sin_comprar']\n",
    "print('clientes:', len(c))\n",
    "print(c[COLS].describe().round(1).loc[['mean', 'min', 'max']].to_string())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esas cuatro columnas son las de siempre en segmentación: cuánto compra, cuántas\n",
    "veces, de a cuánto, y hace cuánto que no vuelve. Si te suena a las tres letras de\n",
    "RFM, es exactamente eso 💡"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Escalar no es opcional aquí"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El total llega a 19.929 y las compras a 15. Si le das eso a k-means tal cual,\n",
    "la distancia entre dos clientes la decide el total y las otras tres columnas no\n",
    "existen."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "X = c[COLS].to_numpy(float)\n",
    "Xs = StandardScaler().fit_transform(X)\n",
    "\n",
    "sin_escalar = KMeans(n_clusters=4, n_init=10, random_state=42).fit(X)\n",
    "escalado = KMeans(n_clusters=4, n_init=10, random_state=42).fit(Xs)\n",
    "print('tamanos sin escalar:', np.bincount(sin_escalar.labels_))\n",
    "print('tamanos escalado   :', np.bincount(escalado.labels_))\n",
    "print('silueta sin escalar:', round(silhouette_score(Xs, sin_escalar.labels_), 4))\n",
    "print('silueta escalado   :', round(silhouette_score(Xs, escalado.labels_), 4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Sin escalar sale un grupo de 48 y otro de 227, y la silueta es casi la mitad.\n",
    "Es el mismo escalado del capítulo 10, y aquí no\n",
    "es una buena práctica: **es la diferencia entre agrupar por cuatro cosas o\n",
    "por una** ⚖️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Cuántos grupos, y por qué el codo no contesta"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahora la pregunta de siempre. El método que te van a enseñar se llama el codo:\n",
    "mira cómo baja la inercia (lo apretados que están los grupos) y quédate donde la\n",
    "curva hace una esquina."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for k in range(2, 9):\n",
    "    m = KMeans(n_clusters=k, n_init=10, random_state=42).fit(Xs)\n",
    "    print(f'k={k}  inercia {m.inertia_:9.1f}  silueta {silhouette_score(Xs, m.labels_):.4f}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Mira la columna de la inercia: baja, baja y sigue bajando. **No hay\n",
    "codo**, hay una cuesta 🫠\n",
    "\n",
    "Y no es mala suerte: la inercia *siempre* baja al subir k, por\n",
    "construcción. Con un grupo por cliente sería cero. El codo solo aparece cuando\n",
    "los datos de verdad vienen en montoncitos separados, que es lo que pasa en los\n",
    "ejemplos de los tutoriales y casi nunca en una cartera de clientes.\n",
    "\n",
    "La otra columna es más honesta. La **silueta** mide, para cada\n",
    "punto, cuánto más cerca está de su grupo que del grupo vecino, en una escala de\n",
    "-1 a 1. Y aquí dice dos cosas incómodas:\n",
    "\n",
    "- 📉 El máximo está en **k=2** y de ahí para abajo. Cuantos más\n",
    "grupos pides, peor separados están.\n",
    "\n",
    "- 🤏 Y el máximo es **0,2950**, que es bajo. Por encima de 0,5 se\n",
    "habla de grupos razonables. Esto no llega ni a la mitad."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Estos clientes no vienen en grupos. Segmentarlos es una decisión de negocio, no un descubrimiento del algoritmo."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y eso está **bien**, siempre que se diga. Cortar una cartera en\n",
    "cuatro sirve para organizar al equipo comercial aunque la naturaleza no la haya\n",
    "cortado. Lo que no se puede es presentarlo como \"descubrimos cuatro tipos de\n",
    "cliente\", porque no se descubrió nada: se decidió 🧭"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los grupos, que es donde está el trabajo de verdad"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "El algoritmo es una línea. Lo que cuesta es mirarlos y ponerles nombre."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "c['grupo'] = escalado.labels_\n",
    "print(c.groupby('grupo')[COLS].mean().round(1).to_string())\n",
    "print()\n",
    "print(c['grupo'].value_counts().sort_index().to_string())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahora sí se lee, y encima se lee bonito 💰\n",
    "\n",
    "- 👑 **Grupo 0**: compran mucho, seguido y caro, y volvieron hace\n",
    "poco. Son 133 y hay que cuidarlos.\n",
    "\n",
    "- 🥶 **Grupo 1**: poco, poquísimas veces y llevan 367 días sin\n",
    "aparecer. Están perdidos y hay que decidir si se recuperan o se sueltan.\n",
    "\n",
    "- 🌱 **Grupo 2**: el más numeroso, compra seguido pero barato, y\n",
    "está activo. Aquí es donde se sube el ticket.\n",
    "\n",
    "- 💎 **Grupo 3**: ticket de 1.253, el más alto de los cuatro, pero\n",
    "solo 3,6 compras y 301 días sin volver. Compran poco y grande.\n",
    "\n",
    "Fíjate en el 3, que es el que justifica el capítulo entero: **no lo\n",
    "habrías encontrado ordenando por total**, porque en total queda en medio.\n",
    "Solo aparece cuando miras las cuatro columnas a la vez 🔍\n",
    "\n",
    "Y cada uno de esos cuatro párrafos es una acción distinta del equipo\n",
    "comercial. Ese es el entregable, no la tabla."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los raros, que es la otra mitad del capítulo"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Segmentar parte a todos en grupos. Detectar anomalías busca **los que no\n",
    "son de ninguno**, que es una pregunta distinta y muy útil: fraude, errores\n",
    "de carga, el cliente que se comporta raro antes de irse."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.ensemble import IsolationForest\n",
    "\n",
    "iso = IsolationForest(contamination=0.03, random_state=42).fit(Xs)\n",
    "c['raro'] = iso.predict(Xs) == -1\n",
    "print('marcados como raros:', int(c['raro'].sum()))\n",
    "print()\n",
    "print('los raros :', c[c['raro']][COLS].mean().round(1).to_dict())\n",
    "print('el resto  :', c[~c['raro']][COLS].mean().round(1).to_dict())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Diecinueve clientes con casi el doble de ticket que el resto. Y ojo con el\n",
    "`contamination=0.03`, que no es un detalle técnico:\n",
    "**eres tú quien decide qué porcentaje va a salir raro**. El\n",
    "algoritmo no lo descubre, lo obedece.\n",
    "\n",
    "Compáralo con la herramienta más simple de todas, la regla de las tres\n",
    "desviaciones del libro de estadística:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "z = np.abs((c['total'] - c['total'].mean()) / c['total'].std()) > 3\n",
    "print('la regla de la z sobre el total marca:', int(z.sum()))\n",
    "print('de esos, cuantos marca tambien el bosque:', int((z & c['raro']).sum()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Los cuatro de la z están dentro de los diecinueve del bosque 🎯\n",
    "\n",
    "Y eso dice exactamente para qué sirve cada uno. La z mira **una\n",
    "columna** y encuentra al que gastó una barbaridad. El bosque mira\n",
    "**las cuatro a la vez** y encuentra además al que tiene una\n",
    "combinación rara sin que ninguna cifra suelta destaque: mucho ticket con pocas\n",
    "compras, por ejemplo.\n",
    "\n",
    "Si te sirve una regla para elegir: empieza siempre por la z, que es una línea\n",
    "y se explica en una reunión. Sube al bosque cuando lo raro sea la combinación y\n",
    "no el número."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## De segmentar a recomendar, que es el mismo truco"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Antes de seguir, una parada corta, porque de acá sale una familia entera de\n",
    "sistemas que seguro usas todos los días.\n",
    "\n",
    "Todo lo de arriba se apoya en una sola idea: **poner cada cliente como\n",
    "una fila de números y medir qué tan cerca están**. Segmentar es agrupar a\n",
    "los que están cerca. Y si en vez de agruparlos preguntas quién está más cerca de\n",
    "uno en concreto, ya tienes un recomendador.\n",
    "\n",
    "Eso es lo que hay detrás de \"clientes como tú también compraron\". No hay\n",
    "magia: se busca a los vecinos y se mira qué compraron ellos que este todavía no.\n",
    "En los libros le dicen **filtrado colaborativo**, y la versión\n",
    "que acabas de aprender a montar es la mitad del trabajo.\n",
    "\n",
    "Las dos advertencias, que son las que hunden estos proyectos:\n",
    "\n",
    "- **El cliente nuevo no tiene vecinos.** Si acaba de llegar y no\n",
    "compró nada, no hay con qué medir su distancia. Es el problema del arranque en\n",
    "frío y se resuelve por fuera: lo más vendido, lo de su zona, preguntarle.\n",
    "\n",
    "- **Recomendar lo parecido encierra.** Si a quien compra arroz\n",
    "siempre le ofreces arroz, nunca descubre el aceite. Un recomendador que solo\n",
    "acierta es un recomendador que no vende nada nuevo, y eso no lo mide el\n",
    "acierto."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Y el que no te pregunta cuántos grupos quieres"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Todo lo anterior lo hizo k-means, y k-means tiene una manía: **le pides\n",
    "tres grupos y te da tres**, haya tres o no haya ninguno. Es obediente, no\n",
    "sincero.\n",
    "\n",
    "DBSCAN funciona al revés. En vez del número de grupos le dices qué tan cerca\n",
    "tienen que estar los clientes para considerarse vecinos, y él decide cuántos\n",
    "grupos salen. Y tiene un premio: **lo que no entra en ninguno lo deja\n",
    "suelto**, o sea que agrupa y detecta raros de una vez."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from sklearn.cluster import DBSCAN\n",
    "\n",
    "d = DBSCAN(eps=0.9, min_samples=8).fit(Xs)\n",
    "c['suelto'] = d.labels_ == -1\n",
    "\n",
    "print('grupos que encuentra:', len(set(d.labels_)) - (1 if -1 in d.labels_ else 0))\n",
    "print('sueltos:', int(c['suelto'].sum()))\n",
    "print('coinciden con los raros del IsolationForest:',\n",
    "      int((c['suelto'] & c['raro']).sum()), 'de', int(c['raro'].sum()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Un grupo.** Uno solo, con 23 clientes colgando por fuera.\n",
    "\n",
    "Y eso no es que DBSCAN haya fallado: es la respuesta honesta a la pregunta que\n",
    "k-means no contesta. Estos clientes **no forman islas separadas**,\n",
    "forman una nube continua con una cola de gente que gasta mucho más. Cuando le\n",
    "pediste cuatro grupos a k-means, cortó esa nube en cuatro pedazos porque se lo\n",
    "pediste, no porque estuvieran ahí 🔀\n",
    "\n",
    "Lo cual no invalida la segmentación de arriba. Cortar una nube continua en\n",
    "cuatro tramos sirve igual para trabajar: son cuatro cajones útiles para decidir a\n",
    "quién llamas. Lo que cambia es cómo lo cuentas. \"Descubrimos cuatro tipos de\n",
    "cliente\" no es lo que pasó. \"Partimos a los clientes en cuatro tramos según\n",
    "cuánto y cada cuánto compran\" sí, y encima se defiende mejor en una reunión.\n",
    "\n",
    "Y mira la última línea: de los 19 raros que marcó el IsolationForest, DBSCAN\n",
    "coincide en 14. Dos métodos distintos señalando a la misma gente es la mejor\n",
    "señal que vas a tener de que esos clientes son de verdad distintos. Los cinco en\n",
    "los que no coinciden son los que vale la pena mirar a mano 🔍\n",
    "\n",
    "Lo que se paga: el `eps`. Ese 0,9 lo elegiste tú igual que elegiste\n",
    "el número de grupos en k-means, así que la decisión no desapareció, se mudó de\n",
    "sitio. La diferencia es que ahora estás decidiendo qué tan cerca es cerca, que se\n",
    "puede razonar mirando las distancias, en vez de cuántas islas hay, que era\n",
    "adivinar."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El error que sale al pedir un solo grupo"
   ]
  },
  {
   "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",
    "    silhouette_score(Xs, np.zeros(len(Xs), dtype=int))\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: Number of labels is 1. Valid values are 2 to n_samples - 1 (inclusive)\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Tiene sentido si piensas qué mide la silueta: cuánto más cerca está cada punto\n",
    "de su grupo que *del vecino*. Sin vecino no hay nada que comparar.\n",
    "\n",
    "Y por eso no existe la silueta de k=1, que sería la forma natural de preguntar\n",
    "\"¿de verdad hacen falta grupos?\". Esa pregunta hay que contestarla mirando si el\n",
    "máximo es alto, y aquí no lo era 🧐"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "Un equipo segmenta la cartera y presenta cuatro perfiles de cliente. Al mes siguiente vuelven a correrlo con los datos nuevos para actualizar los grupos y presentan el resultado igual.\n",
    "\n",
    "```\n",
    "# mes 1\n",
    "grupos_enero = KMeans(n_clusters=4, random_state=42).fit_predict(escalar(enero))\n",
    "# -> grupo 0 = \"los premium\", grupo 1 = \"los perdidos\", ...\n",
    "\n",
    "# mes 2, con los datos nuevos\n",
    "grupos_febrero = KMeans(n_clusters=4, random_state=42).fit_predict(escalar(febrero))\n",
    "\n",
    "# y se comparan los dos meses grupo por grupo\n",
    "crecimiento = grupos_febrero.mean() - grupos_enero.mean()\n",
    "```\n",
    "\n",
    "**¿Qué está mal?** La respuesta está en el cuaderno de soluciones. Míralo tú primero."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Seis, y el 4 es el que separa un análisis de un adorno 💛"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. La semilla, y si los grupos aguantan\n",
    "\n",
    "Corre k-means con cinco semillas distintas y mira si salen\n",
    "los mismos grupos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. Qué columna manda\n",
    "\n",
    "Agrupa usando solo `total` y `compras`,\n",
    "y compara la silueta contra la de las cuatro columnas."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. El logaritmo antes de agrupar\n",
    "\n",
    "Aplica logaritmo a `total` y `ticket`\n",
    "antes de escalar, y vuelve a mirar la silueta."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. Ponles nombre y una acción\n",
    "\n",
    "Sin código. Escribe para cada uno de los cuatro grupos una\n",
    "frase con el nombre y una acción concreta del equipo comercial."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. Sube la contaminación\n",
    "\n",
    "Prueba `contamination` en 0,01, 0,05 y 0,10 y\n",
    "cuenta cuántos raros salen."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. Agrupar y después predecir\n",
    "\n",
    "Mete el grupo como columna en el modelo del capítulo\n",
    "11 y mira si el AUC mejora."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# tu turno"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "Corres k-means sobre tus clientes, la silueta sale 0,21 y el codo no aparece por ningún lado. ¿Qué haces?\n",
    "\n",
    "a) Segmento igual si sirve al negocio, diciendo que es una decisión y no un hallazgo\n",
    "\n",
    "b) Pruebo más valores de k hasta que la silueta suba\n",
    "\n",
    "c) Concluyo que no se puede segmentar y paro\n",
    "\n",
    "d) Cambio a otro algoritmo hasta que salgan grupos claros"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- ⚖️ Sin escalar, k-means agrupa por la columna más grande y nada más.\n",
    "\n",
    "- 📉 El codo casi nunca aparece, porque la inercia siempre baja. La silueta sí\n",
    "contesta.\n",
    "\n",
    "- 🧭 Aquí la silueta máxima fue 0,2950 en k=2 y baja de ahí. No hay grupos\n",
    "naturales, y decirlo es parte del entregable.\n",
    "\n",
    "- 🔍 El trabajo no es correr el algoritmo: es mirar los grupos, ponerles nombre\n",
    "y sacar una acción de cada uno.\n",
    "\n",
    "- 🚨 En anomalías, tú fijas cuántas salen. La z mira una columna y el bosque\n",
    "mira la combinación.\n",
    "\n",
    "Y si lo que quieres es medir cuánto pesa cada variable en vez de agrupar, eso\n",
    "es el capítulo 22, y con todos sus intervalos está\n",
    "en el [libro de estadística desde\n",
    "cero](https://missyera.com/guias/estadistica-desde-cero/) 📐\n",
    "\n",
    "Que tengas lindo día! 🌸"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 23 de **Machine learning desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/machine-learning-desde-cero/agrupar-sin-etiqueta/\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
}
