{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# pandas\n",
    "\n",
    "El capítulo por el que la mayoría aprende Python, sobre un archivo sucio de verdad y con el aviso que sale en rojo.\n",
    "\n",
    "Cuaderno de soluciones del capítulo 12 de **Python 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/python-desde-cero/pandas/\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": [
    "## Antes de empezar"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import urllib.request\n",
    "\n",
    "# Este capitulo abre el archivo por su nombre, sin URL, que es lo que se hace\n",
    "# cuando lo tienes al lado. Aqui se baja primero para que exista.\n",
    "for nombre in ['ventas-miss-yera.csv']:\n",
    "    urllib.request.urlretrieve(f\"https://missyera.com/static/datasets/{nombre}\", nombre)\n",
    "    print(nombre, \"lista\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Hola! Llegamos al capítulo por el que casi todos aprendemos Python"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Te voy a ser honesta: mucha gente aprende Python solo para llegar aquí 😄\n",
    "\n",
    "pandas es una tabla con superpoderes. Todo lo de los capítulos anteriores\n",
    "sigue valiendo por debajo, y ahora se escribe muchísimo más corto.\n",
    "\n",
    "Y aprovecho para preguntarte algo: **¿cuál es la tabla que abres todas las semanas en tu trabajo?** Esa es con la que tienes que repetir este capítulo cuando lo termines, porque con datos tuyos se aprende el triple 🐼"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Cargar el archivo"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "\n",
    "df = pd.read_csv('ventas-miss-yera.csv')\n",
    "\n",
    "print(df.shape)\n",
    "print(df.columns.tolist())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Una línea. Todo el capítulo 10 (abrir, decodificar, partir por comas, armar\n",
    "diccionarios) resuelto en una línea 🌸\n",
    "\n",
    "Ese `df` es la convención universal para \"DataFrame\". Vas a verlo en\n",
    "todo internet.\n",
    "\n",
    "Y lo primero que se hace, siempre, en este orden:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(df.dtypes)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "**Mira `monto`.** Salió como texto, no como número.\n",
    "\n",
    "Ya sabemos por qué del capítulo 11: son los 612 montos con coma decimal. pandas\n",
    "intentó convertir la columna, encontró comas, y la dejó entera como texto en vez\n",
    "de inventarse nada 👏\n",
    "\n",
    "Si tu pandas es anterior a la versión 3.0 vas a ver `object` donde\n",
    "aquí dice `str`. Es lo mismo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(df.head(3))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Fíjate en la fila 1: la ciudad dice `lima` en minúscula. Ya vamos a\n",
    "llegar a eso 👀"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Series y DataFrame"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Una columna sola es una **Series**; la tabla entera es un\n",
    "**DataFrame**. Es la misma diferencia que entre un arreglo de una\n",
    "dimensión y uno de dos en el capítulo 11."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(type(df['ciudad']))\n",
    "print(type(df[['ciudad', 'monto']]))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Corchetes simples te dan una columna (Series). Corchetes dobles te dan una\n",
    "tabla con esas columnas (DataFrame). Esa confusión es de las más comunes al\n",
    "empezar."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(df['ciudad'].value_counts())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí está el desastre 😅 **Lima escrita de cuatro formas.**\n",
    "\n",
    "Si agrupas así, Lima aparece como cuatro ciudades chiquitas de 115 a 123\n",
    "ventas, en vez de una de 474 que sería la segunda del país. Un informe con ese\n",
    "error se presenta y nadie lo nota."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Limpiar, que es el trabajo de verdad"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# Duplicados primero\n",
    "print('antes  :', len(df))\n",
    "df = df.drop_duplicates(subset='id_venta')\n",
    "print('despues:', len(df))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Treinta y siete ventas duplicadas. Si sumas montos sin quitarlas, tu total\n",
    "está inflado y nadie te va a avisar.\n",
    "\n",
    "Ese `subset='id_venta'` importa: sin él, pandas solo quita filas\n",
    "idénticas en *todas* las columnas, y un duplicado real muchas veces tiene\n",
    "alguna diferencia tonta."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# La ciudad, con los metodos .str\n",
    "df['ciudad'] = (df['ciudad'].str.strip().str.lower()\n",
    "                .str.normalize('NFKD')\n",
    "                .str.encode('ascii', 'ignore').str.decode('utf-8'))\n",
    "\n",
    "print(df['ciudad'].value_counts())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "De nueve ciudades a seis, y Lima dejó de estar partida en cuatro pedazos 🌟\n",
    "\n",
    "Ese `.str` del medio es la clave: **aplica un método de texto\n",
    "a toda la columna de golpe**. Es la vectorización del capítulo 11, aplicada\n",
    "a cadenas. Sin él tendrías que recorrer las tres mil filas.\n",
    "\n",
    "Lo del `normalize('NFKD')` es lo que quita las tildes: separa la\n",
    "letra de su tilde y después bota lo que no es ASCII. Es un truco feo y es el que\n",
    "funciona."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# El monto, con la coma decimal\n",
    "df['monto'] = pd.to_numeric(df['monto'].str.replace(',', '.'), errors='coerce')\n",
    "\n",
    "print(df['monto'].dtype)\n",
    "print('no convertidos:', df['monto'].isna().sum())\n",
    "print(round(df['monto'].sum(), 2))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`errors='coerce'` significa \"lo que no puedas convertir, déjalo\n",
    "como nulo en vez de reventar\". Y esa segunda línea, la que cuenta cuántos no\n",
    "convirtió, es obligatoria: si no da cero, tienes que ir a mirarlos."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Los nulos"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(df.isna().sum().sort_values(ascending=False).head(4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y aquí viene la parte que casi ningún tutorial dice. La pregunta correcta\n",
    "**no es \"con qué relleno\", es \"por qué falta\"**.\n",
    "\n",
    "Mira lo que pasa cuando lo preguntas de verdad:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df['sin_compra_previa'] = df['fecha_ultima_compra'].isna().astype(int)\n",
    "\n",
    "print(df.groupby('sin_compra_previa')['compro'].mean().round(4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Los que sí tenían una compra anterior compran el 61%; los que no, el 41% 😮\n",
    "\n",
    "**Veinte puntos de diferencia.** Si hubiera rellenado esa fecha\n",
    "con un promedio, habría borrado lo más predictivo de todo el archivo.\n",
    "\n",
    "Ese hueco no era un dato que falta, era un dato en sí mismo: el cliente es\n",
    "nuevo. Y la forma correcta de tratarlo es marcarlo con una bandera, no taparlo 💜"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Seleccionar y filtrar"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(df.loc[0, 'ciudad'])\n",
    "print(df.loc[0:2, ['ciudad', 'monto']])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "grandes = df[df['monto'] > 1000]\n",
    "print(len(grandes))\n",
    "\n",
    "lima_grandes = df[(df['ciudad'] == 'lima') & (df['monto'] > 1000)]\n",
    "print(len(lima_grandes))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Es exactamente la máscara del capítulo 11, con `&` y con\n",
    "paréntesis en cada condición. Si usas `and`, pandas te lo dice\n",
    "clarito:"
   ]
  },
  {
   "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",
    "    df[(df['ciudad'] == 'lima') and (df['monto'] > 1000)]\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: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y para filtrar por varios valores, en vez de encadenar `|`:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(len(df[df['ciudad'].isin(['lima', 'arequipa'])]))\n",
    "print(len(df[df['canal'].str.contains('What', na=False)]))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## El aviso rojo que todo el mundo ve y nadie entiende"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Este te va a salir. Vamos a provocarlo para que sepas qué significa."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "solo_lima = df[df['ciudad'] == 'lima']\n",
    "solo_lima['con_igv'] = solo_lima['monto'] * 1.18\n",
    "\n",
    "print(solo_lima['con_igv'].head(2))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Puede que en tu versión ese bloque te saque un\n",
    "`SettingWithCopyWarning` en rojo. Funciona igual, y ese es justo el\n",
    "problema: **pandas no sabe si `solo_lima` es una tabla nueva o\n",
    "una vista de la original**, así que no puede garantizarte a cuál de las\n",
    "dos le estás escribiendo.\n",
    "\n",
    "La solución es una palabra:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "solo_lima = df[df['ciudad'] == 'lima'].copy()\n",
    "solo_lima['con_igv'] = solo_lima['monto'] * 1.18\n",
    "\n",
    "print(len(solo_lima), round(solo_lima['con_igv'].sum(), 2))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "La regla que te ahorra el aviso para siempre: **si filtras y después vas\n",
    "a modificar, pon `.copy()` al filtrar**. Es la misma historia\n",
    "del capítulo 4, la copia que no se copió 🌸"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## groupby: la operación estrella"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(df.groupby('ciudad')['monto'].sum().round(2).sort_values(ascending=False))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "¿Te acuerdas del bucle con `por_ciudad.get(ciudad, 0) + monto` del\n",
    "capítulo 4? Esto es lo mismo, en una línea. Por eso te hice escribir el bucle\n",
    "primero 💜"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "resumen = df.groupby('ciudad').agg(\n",
    "    ventas=('id_venta', 'count'),\n",
    "    total=('monto', 'sum'),\n",
    "    ticket_medio=('monto', 'mean'),\n",
    "    ticket_mediano=('monto', 'median'),\n",
    ").round(2).sort_values('total', ascending=False)\n",
    "\n",
    "print(resumen)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esa es una tabla que puedes mandar tal cual, y mira lo que cuenta: el ticket\n",
    "medio y el mediano se llevan entre doscientos y trescientos soles de diferencia\n",
    "en todas las ciudades. O sea que en todas hay unas pocas ventas grandes que jalan\n",
    "el promedio hacia arriba.\n",
    "\n",
    "Y se puede agrupar por dos cosas:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(df.groupby(['ciudad', 'canal'])['monto'].sum().round(0).head(6))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Y darle la vuelta para que se lea como una tabla de doble entrada, que es lo\n",
    "que en Excel sería una tabla dinámica:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "tabla = df.pivot_table(index='ciudad', columns='canal',\n",
    "                       values='monto', aggfunc='sum').round(0)\n",
    "print(tabla)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Juntar dos tablas"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "metas = pd.DataFrame({\n",
    "    'ciudad': ['lima', 'arequipa', 'cusco', 'piura', 'chiclayo', 'trujillo'],\n",
    "    'meta': [400000, 420000, 380000, 400000, 390000, 400000],\n",
    "})\n",
    "\n",
    "comparativo = resumen.reset_index().merge(metas, on='ciudad')\n",
    "comparativo['cumplimiento'] = (comparativo['total'] / comparativo['meta'] * 100).round(1)\n",
    "\n",
    "print(comparativo[['ciudad', 'total', 'meta', 'cumplimiento']])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Un aviso importante con `merge`: **comprueba siempre cuántas\n",
    "filas te quedaron**. Si una ciudad estaba escrita distinta en las dos\n",
    "tablas, esa fila desaparece sin decir nada."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(len(resumen), len(metas), len(comparativo))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Seis, seis y seis. Si el tercero fuera cinco, faltaría una ciudad y habría que\n",
    "ir a buscar por qué antes de seguir 🔍"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Encadenar, para no llenar el cuaderno de variables"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Fíjate en cómo hemos ido trabajando: `parte = ...`,\n",
    "`resumen = ...`, `comparativo = ...`. Funciona, y a las\n",
    "veinte celdas ya no te acuerdas de cuál era cuál 😅\n",
    "\n",
    "pandas deja escribir todo el camino de una vez, y se lee de arriba abajo como\n",
    "una receta:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "resumen_canal = (df\n",
    "                 .query('monto > 0')\n",
    "                 .assign(con_igv=lambda t: t['monto'] * 1.18,\n",
    "                         grande=lambda t: t['monto'] > 1000)\n",
    "                 .groupby('canal')\n",
    "                 .agg(ventas=('id_venta', 'count'),\n",
    "                      total=('con_igv', 'sum'),\n",
    "                      grandes=('grande', 'sum'))\n",
    "                 .assign(pct_grandes=lambda t: (t['grandes'] / t['ventas'] * 100).round(1))\n",
    "                 .sort_values('total', ascending=False))\n",
    "\n",
    "print(resumen_canal.round(0))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Eso son cinco pasos y ni una variable intermedia. Las piezas nuevas 🧩\n",
    "\n",
    "- 🔎 `query('monto > 0')` es lo mismo que\n",
    "`df[df['monto'] > 0]`, escrito como se dice. Con dos o tres\n",
    "condiciones se nota mucho: `query('monto > 0 and canal == \"Web\"')`,\n",
    "sin paréntesis ni ampersands.\n",
    "\n",
    "- ➕ `assign` crea columnas y devuelve una tabla nueva, así que no\n",
    "toca la original. Ese `lambda t:` es \"la tabla tal como está en este\n",
    "punto de la cadena\", que es lo que hace que funcione en medio del encadenado.\n",
    "\n",
    "- 📊 `agg` con nombres (`ventas=('id_venta', 'count')`)\n",
    "te deja bautizar cada columna del resultado. Sin eso salen apiladas y con nombres\n",
    "raros.\n",
    "\n",
    "Lo que devuelve la tabla, dicho en cristiano: los cuatro canales facturan\n",
    "parecido, y entre el 25% y el 29% de sus ventas pasan de mil soles. Otra vez\n",
    "cuatro canales que se parecen mucho 🤷‍♀️\n",
    "\n",
    "Y el detalle de formato que hace que esto sea legible: los paréntesis\n",
    "alrededor de todo. Sin ellos, Python no deja partir la línea y te queda una fila\n",
    "kilométrica."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que pesa tu tabla, y cómo bajarlo a la mitad"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esto no importa con tres mil filas. Importa muchísimo con tres millones, que\n",
    "es cuando el cuaderno se pone lento y no sabes por qué 🐢"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "texto = df['ciudad'].memory_usage(deep=True)\n",
    "categoria = df['ciudad'].astype('category').memory_usage(deep=True)\n",
    "\n",
    "print('como texto   :', f'{texto:,} bytes')\n",
    "print('como category:', f'{categoria:,} bytes')\n",
    "print('se ahorra    :', round((1 - categoria / texto) * 100, 1), '%')\n",
    "print('valores distintos:', df['ciudad'].nunique(), 'de', len(df), 'filas')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí está el porqué: **seis valores distintos repetidos tres mil\n",
    "veces**.\n",
    "\n",
    "Como texto, pandas guarda las ocho letras de \"arequipa\" quinientas cuarenta y\n",
    "seis veces. Como `category`, guarda las seis palabras una vez y luego\n",
    "tres mil numeritos que apuntan a ellas 🏷️\n",
    "\n",
    "Y ese `deep=True` no es opcional. Sin él, pandas te cuenta lo que\n",
    "pesan los punteros y no el texto al que apuntan, así que te sale un número\n",
    "bonito y falso."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "antes = df.memory_usage(deep=True).sum()\n",
    "\n",
    "compacto = df.copy()\n",
    "for columna in ['ciudad', 'segmento', 'canal', 'categoria']:\n",
    "    compacto[columna] = compacto[columna].astype('category')\n",
    "\n",
    "despues = compacto.memory_usage(deep=True).sum()\n",
    "\n",
    "print('antes  :', f'{antes:,} bytes')\n",
    "print('despues:', f'{despues:,} bytes')\n",
    "print('ahorro :', round((1 - despues / antes) * 100, 1), '%')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Cuatro columnas convertidas y la tabla pesa la mitad. Sin perder un solo\n",
    "dato 💜\n",
    "\n",
    "La regla para saber cuándo aplicarlo: **si los valores distintos son\n",
    "muchos menos que las filas**, va a `category`. Ciudad, canal,\n",
    "segmento, estado, sí. Nombre de cliente o código de venta, no, porque ahí casi\n",
    "cada fila es distinta y encima le añades el diccionario."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Ejercicios"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1. Las tres primeras miradas\n",
    "\n",
    "Carga el archivo y muestra su forma, sus tipos y cuántos\n",
    "nulos tiene la columna satisfaccion."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import pandas as pd\n",
    "\n",
    "d = pd.read_csv('ventas-miss-yera.csv')\n",
    "print(d.shape)\n",
    "print(d['monto'].dtype)\n",
    "print(d['satisfaccion'].isna().sum())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "(3037, 14)\n",
    "str\n",
    "234\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2. Cuántas ventas por canal\n",
    "\n",
    "Cuenta las ventas de cada canal, ordenadas de mayor a\n",
    "menor."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(d['canal'].value_counts())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "canal\n",
    "Web            801\n",
    "Marketplace    776\n",
    "Tienda         735\n",
    "WhatsApp       725\n",
    "Name: count, dtype: int64\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3. Filtrar con dos condiciones\n",
    "\n",
    "Cuenta las ventas del canal WhatsApp con más de 10\n",
    "unidades."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(len(d[(d['canal'] == 'WhatsApp') & (d['unidades'] > 10)]))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "434\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4. El error del and\n",
    "\n",
    "Escribe el filtro anterior con `and` y lee el\n",
    "error."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "d[(d['canal'] == 'WhatsApp') and (d['unidades'] > 10)]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "ValueError: The truth value of a Series is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Se lee así: `and` quiere saber si una Series entera es verdadera o\n",
    "falsa, y una Series de tres mil valores no es ninguna de las dos."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5. Una columna nueva\n",
    "\n",
    "Crea una columna con el monto por unidad y muestra las tres\n",
    "ventas con el precio unitario más alto."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "d = d.drop_duplicates(subset='id_venta').copy()\n",
    "d['monto'] = pd.to_numeric(d['monto'].str.replace(',', '.'), errors='coerce')\n",
    "d['precio_unitario'] = (d['monto'] / d['unidades']).round(2)\n",
    "\n",
    "print(d.nlargest(3, 'precio_unitario')[['ciudad', 'unidades', 'monto', 'precio_unitario']])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "        ciudad  unidades    monto  precio_unitario\n",
    "2398     Piura         1  3586.26          3586.26\n",
    "1905  Trujillo         1  3503.27          3503.27\n",
    "1317  Trujillo         1  3018.31          3018.31\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`nlargest` es más corto que ordenar y cortar, y además es más\n",
    "rápido porque no ordena la tabla entera."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 6. Resumen por segmento\n",
    "\n",
    "Saca ventas, total y ticket mediano por segmento, ordenado\n",
    "por total."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(d.groupby('segmento').agg(\n",
    "    ventas=('id_venta', 'count'),\n",
    "    total=('monto', 'sum'),\n",
    "    mediana=('monto', 'median'),\n",
    ").round(2).sort_values('total', ascending=False))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "            ventas       total  mediana\n",
    "segmento                               \n",
    "Mayorista      750  1392252.83  1869.26\n",
    "Horeca         742   560342.87   743.00\n",
    "Minimarket     801   332348.67   413.74\n",
    "Bodega         707   126339.46   183.26\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 7. El hueco que dice algo\n",
    "\n",
    "Comprueba si el hueco de `descuento` también\n",
    "cambia la tasa de compra."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "d['sin_descuento'] = d['descuento'].isna().astype(int)\n",
    "print(d.groupby('sin_descuento')['compro'].mean().round(4))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "sin_descuento\n",
    "0    0.6017\n",
    "1    0.4807\n",
    "Name: compro, dtype: float64\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Doce puntos. También dice algo, y antes de usarlo hay que preguntarse si ese\n",
    "dato existe en el momento de predecir. Eso se desarrolla en el\n",
    "[libro de machine learning](https://missyera.com/guias/machine-learning-desde-cero/)."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 8. Tabla de doble entrada\n",
    "\n",
    "Arma una tabla con las unidades vendidas por segmento y\n",
    "categoría."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "print(d.pivot_table(index='segmento', columns='categoria',\n",
    "                    values='unidades', aggfunc='sum'))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "categoria   Abarrotes  Bebidas  Cuidado personal  Limpieza  Snacks\n",
    "segmento                                                          \n",
    "Bodega           1564     1659              1862      1344    1595\n",
    "Horeca           1872     1571              1535      1716    1868\n",
    "Mayorista        1944     1796              1425      1718    1912\n",
    "Minimarket       1981     1678              2274      1813    1675\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 9. Provoca el aviso\n",
    "\n",
    "Filtra sin `.copy()`, agrega una columna, y\n",
    "después hazlo bien."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "parte = d[d['ciudad'] == 'Cusco'].copy()\n",
    "parte['doble'] = parte['monto'] * 2\n",
    "\n",
    "print(len(parte), round(parte['doble'].sum(), 2))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "496 774450.76\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Con `.copy()` no hay aviso y sabes exactamente sobre qué estás\n",
    "escribiendo. Es una palabra que se pone siempre y se olvida el problema."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 10. Del bucle a pandas\n",
    "\n",
    "Escribe el total por canal de las dos formas: con el bucle\n",
    "del capítulo 4 y con `groupby`. Comprueba que dan lo mismo."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "a_mano = {}\n",
    "for canal, monto in zip(d['canal'], d['monto']):\n",
    "    a_mano[canal] = a_mano.get(canal, 0) + monto\n",
    "\n",
    "con_pandas = d.groupby('canal')['monto'].sum().to_dict()\n",
    "\n",
    "print(sorted(round(v, 2) for v in a_mano.values()))\n",
    "print(sorted(round(v, 2) for v in con_pandas.values()))\n",
    "print(a_mano.keys() == con_pandas.keys())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "[567392.49, 591519.04, 619760.73, 632611.57]\n",
    "[567392.49, 591519.04, 619760.73, 632611.57]\n",
    "True\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Idénticos. La diferencia es que el de pandas es una línea, no se te olvida\n",
    "inicializar nada, y con un millón de filas tarda una fracción 🌟"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 11. Todo el análisis en una sola cadena\n",
    "\n",
    "Ticket promedio, número de ventas y porcentaje de ventas\n",
    "grandes, por ciudad, sin variables intermedias."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "por_ciudad_cadena = (df\n",
    "                     .query('monto > 0')\n",
    "                     .assign(grande=lambda t: t['monto'] > 1000)\n",
    "                     .groupby('ciudad')\n",
    "                     .agg(ventas=('id_venta', 'count'),\n",
    "                          ticket=('monto', 'mean'),\n",
    "                          grandes=('grande', 'sum'))\n",
    "                     .assign(pct=lambda t: (t['grandes'] / t['ventas'] * 100).round(1))\n",
    "                     .sort_values('ticket', ascending=False))\n",
    "\n",
    "print(por_ciudad_cadena.round(1))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "          ventas  ticket  grandes   pct\n",
    "ciudad                                 \n",
    "trujillo     465   856.7      135  29.0\n",
    "chiclayo     507   820.0      133  26.2\n",
    "arequipa     541   815.0      144  26.6\n",
    "lima         470   799.5      132  28.1\n",
    "piura        503   799.2      133  26.4\n",
    "cusco        493   785.9      134  27.2\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Seis ciudades con tickets entre setecientos y ochocientos y pico. Otra vez la\n",
    "misma historia de este archivo: **casi todo se parece**, y decirlo\n",
    "es el hallazgo 🌸"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 12. Qué columnas vale la pena convertir\n",
    "\n",
    "Mira cuántos valores distintos tiene cada columna de texto y\n",
    "cuánto pesa cada una."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "for columna in ['ciudad', 'segmento', 'canal', 'categoria', 'cliente_id']:\n",
    "    distintos = df[columna].nunique()\n",
    "    pesa = df[columna].memory_usage(deep=True)\n",
    "    cabe = df[columna].astype('category').memory_usage(deep=True)\n",
    "    print(f'{columna:<12} {distintos:>5} distintos  '\n",
    "          f'{pesa:>8,} -> {cabe:>8,}  '\n",
    "          f'ahorra {(1 - cabe / pesa) * 100:.0f}%')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "ciudad           6 distintos   280,198 ->   93,468  ahorra 67%\n",
    "segmento         4 distintos   284,542 ->   93,347  ahorra 67%\n",
    "canal            4 distintos   281,965 ->   93,344  ahorra 67%\n",
    "categoria        5 distintos   288,807 ->   93,419  ahorra 68%\n",
    "cliente_id     617 distintos   276,088 ->  134,342  ahorra 51%\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Mira `cliente_id`, que es el que enseña la regla 👀\n",
    "\n",
    "Las cuatro primeras tienen entre cuatro y seis valores distintos y ahorran dos\n",
    "tercios. La quinta tiene seiscientos diecisiete y ahorra la mitad: sigue mereciendo\n",
    "la pena, pero mucho menos.\n",
    "\n",
    "Y ahí está la regla, dicha con los números delante: **cuantos más valores\n",
    "distintos, menos ahorras**, porque el diccionario que `category`\n",
    "guarda aparte va creciendo. Con una columna donde cada fila es distinta,\n",
    "`category` pesa más que el texto y encima te complica los filtros 🙃"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 13. Los mismos números y aun así distintos\n",
    "\n",
    "Compara el conteo de ciudades antes y después de convertir a\n",
    "`category`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "a = df['ciudad'].value_counts()\n",
    "b = df['ciudad'].astype('category').value_counts()\n",
    "\n",
    "print(a)\n",
    "print(b)\n",
    "print('mismos numeros:', list(a.values) == list(b.values))\n",
    "print('mismo orden   :', list(a.index) == list(b.index))\n",
    "print('equals dice   :', a.equals(b))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "ciudad\n",
    "arequipa    546\n",
    "chiclayo    510\n",
    "piura       506\n",
    "cusco       496\n",
    "trujillo    471\n",
    "lima        471\n",
    "Name: count, dtype: int64\n",
    "ciudad\n",
    "arequipa    546\n",
    "chiclayo    510\n",
    "piura       506\n",
    "cusco       496\n",
    "lima        471\n",
    "trujillo    471\n",
    "Name: count, dtype: int64\n",
    "mismos numeros: True\n",
    "mismo orden   : False\n",
    "equals dice   : False\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Los mismos seis conteos y `equals` dice que no 🤨\n",
    "\n",
    "Hay dos motivos, y los dos son útiles de saber.\n",
    "\n",
    "**El empate.** Lima y Trujillo tienen 471 las dos.\n",
    "`value_counts` ordena por conteo, y cuando hay empate el desempate lo\n",
    "decide el orden interno, que en `category` es alfabético y en texto es\n",
    "el de aparición. Mismos datos, otro orden.\n",
    "\n",
    "**El tipo del índice.** Uno devuelve un `Index` normal\n",
    "y el otro un `CategoricalIndex`, y `equals` compara\n",
    "también eso.\n",
    "\n",
    "La lección práctica: **si vas a comparar dos resultados, ordénalos\n",
    "igual primero**. Con `a.sort_index()` y\n",
    "`b.sort_index()` los valores coinciden. Comparar sin ordenar es la\n",
    "forma más fácil de creer que rompiste algo cuando no rompiste nada 🧘‍♀️"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 14. El merge que multiplica las filas\n",
    "\n",
    "Junta las ventas con una tabla de vendedores por ciudad y\n",
    "mira qué le pasa al total."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "vendedores = pd.DataFrame({\n",
    "    'ciudad': ['lima', 'lima', 'lima', 'arequipa', 'arequipa'],\n",
    "    'vendedor': ['Ana', 'Beto', 'Caro', 'Dani', 'Eli'],\n",
    "})\n",
    "\n",
    "dos_ciudades = df[df['ciudad'].isin(['lima', 'arequipa'])][\n",
    "    ['id_venta', 'ciudad', 'monto']]\n",
    "\n",
    "juntas = dos_ciudades.merge(vendedores, on='ciudad')\n",
    "\n",
    "print('ventas antes     :', len(dos_ciudades))\n",
    "print('vendedores       :', len(vendedores))\n",
    "print('filas despues    :', len(juntas))\n",
    "print('total real       : S/', round(dos_ciudades['monto'].sum(), 2))\n",
    "print('total tras juntar: S/', round(juntas['monto'].sum(), 2))\n",
    "print('multiplicado por :',\n",
    "      round(juntas['monto'].sum() / dos_ciudades['monto'].sum(), 2))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "ventas antes     : 1017\n",
    "vendedores       : 5\n",
    "filas despues    : 2505\n",
    "total real       : S/ 811226.09\n",
    "total tras juntar: S/ 1995831.32\n",
    "multiplicado por : 2.46\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahí lo tienes: el arriba te avisaba de las filas que **desaparecen**\n",
    "en un `merge`. Este es el caso contrario y da más miedo 😱\n",
    "\n",
    "Cada venta de Lima se juntó con los tres vendedores de Lima, así que ahora\n",
    "aparece tres veces. Y al sumar, el total se infló casi dos veces y media.\n",
    "\n",
    "Lo peligroso es que **el número resultante es perfectamente creíble**.\n",
    "No hay error, no hay nulos, la tabla se ve bien. Simplemente estás facturando el\n",
    "doble de lo que vendiste.\n",
    "\n",
    "La forma de que pandas te avise sola:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "try:\n",
    "    dos_ciudades.merge(vendedores, on='ciudad', validate='many_to_one')\n",
    "except Exception as e:\n",
    "    print(type(e).__name__ + ':', str(e).splitlines()[0])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```\n",
    "MergeError: Merge keys are not unique in right dataset; not a many-to-one merge\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ese `validate='many_to_one'` es tu declaración de lo que\n",
    "**esperas**: muchas ventas, una fila por ciudad. Si la tabla de la\n",
    "derecha tiene la clave repetida, revienta en vez de multiplicar 🛡️\n",
    "\n",
    "Las cuatro opciones son `one_to_one`, `one_to_many`,\n",
    "`many_to_one` y `many_to_many`. Ponlo siempre, aunque estés\n",
    "segura. Sobre todo cuando estés segura."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Corregir un dato y que no se corrija nada"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Esta es, con diferencia, la trampa que más veces me han traído en consultoría. Sin error, sin aviso, y con el dato sucio intacto 🫥"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### La trampa\n",
    "\n",
    "Filtras las filas de Lima, corriges la ciudad en ese trozo, y sigues. Sin error y sin aviso.\n",
    "\n",
    "```\n",
    "lima = ventas[ventas['ciudad'] == 'Lima']\n",
    "lima['ciudad'] = 'LIMA'\n",
    "\n",
    "print((ventas['ciudad'] == 'LIMA').sum())\n",
    "# 0\n",
    "```\n",
    "\n",
    "**Qué está mal**\n",
    "\n",
    "Cero. El original no se enteró de nada 🫥 Ese `lima` es una copia, así que corregiste la copia y seguiste trabajando con el original sucio.\n",
    "\n",
    "Y el problema es que a veces sí funciona, según cómo se hizo el filtro. Eso es lo peor de todo: un comportamiento que cambia según el caso no se aprende nunca, solo se sufre. Por eso pandas lleva años avisando de esto y por eso terminó cambiando las reglas para que siempre sea copia.\n",
    "\n",
    "Se corrige sobre el original diciendo dónde: `ventas.loc[ventas['ciudad'] == 'Lima', 'ciudad'] = 'LIMA'`. Con `.loc` y una sola instrucción, no hay copia en medio y no hay duda."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Comprueba que lo tienes\n",
    "\n",
    "Filtras un DataFrame, le cambias un valor al resultado y pandas te avisa con un warning. ¿Qué te está diciendo?\n",
    "\n",
    "a) Que no sabe si estás tocando el original o una copia\n",
    "\n",
    "b) Que el filtro está mal escrito\n",
    "\n",
    "c) Que la columna no existe\n",
    "\n",
    "d) Que hay que usar loc siempre\n",
    "\n",
    "---\n",
    "\n",
    "**La correcta es la a.**\n",
    "\n",
    "*b)* El filtro está bien: el aviso llega después, al asignar.\n",
    "\n",
    "*c)* Eso daría un error, no un aviso.\n",
    "\n",
    "*d)* loc es parte de la solución, pero antes conviene entender el aviso.\n",
    "\n",
    "Filtrar puede devolver una vista o una copia, y no siempre se sabe cuál."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Lo que te llevas"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "- 📥 `read_csv` y después `.dtypes`, siempre. Una columna\n",
    "de dinero que salió como texto tiene algo dentro.\n",
    "\n",
    "- 🧹 Duplicados con `subset`, texto con `.str`, números\n",
    "con `to_numeric(errors='coerce')` y contando cuántos no\n",
    "convirtieron.\n",
    "\n",
    "- ❓ Ante un hueco, la pregunta es por qué falta, no con qué se rellena.\n",
    "\n",
    "- 🔗 Filtros con `&` y `|`, con paréntesis, nunca con\n",
    "`and`.\n",
    "\n",
    "- 📋 Si filtras y vas a modificar, `.copy()`.\n",
    "\n",
    "- 📊 `groupby` con `.agg()` te da la tabla que se manda\n",
    "tal cual.\n",
    "\n",
    "- 🔍 Después de un `merge`, cuenta las filas.\n",
    "\n",
    "Y si de todo el capítulo te llevas una sola frase, que sea esta:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Se corrige sobre el original y diciendo dónde. Todo lo demás es corregir\n",
    "una copia."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "En el capítulo 15 hacemos que todo esto se vea, con matplotlib. Y ahí hay una\n",
    "regla que vale más que todas las opciones de color juntas.\n",
    "\n",
    "Que tengas un hermoso día! 🌟"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "Ese era el capítulo 12 de **Python desde cero**. El texto completo, con las salidas de cada bloque, está en https://missyera.com/guias/python-desde-cero/pandas/\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
}
