{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "2fab8f6c",
   "metadata": {},
   "source": [
    "# 03 · Grandes volúmenes de datos con poca RAM\n",
    "\n",
    "Tarde o temprano un conjunto de datos no cabrá cómodamente en memoria: un portátil tiene de 8 a 16 GB, un\n",
    "servidor pequeño en la nube de 2 a 4 GB, y pandas suele necesitar varias veces el tamaño del archivo mientras\n",
    "carga. Este módulo muestra cómo medir la memoria con honestidad y las técnicas, y las bibliotecas\n",
    "alternativas, que la mantienen bajo control.\n",
    "\n",
    "**Aprenderás**\n",
    "1. Cómo medir la memoria: el tamaño de los objetos y el **pico** de memoria de un proceso completo\n",
    "2. Reducir un DataFrame: tipos numéricos más pequeños y categorías\n",
    "3. Procesamiento por flujo: trabajar por bloques o con generadores en lugar de cargarlo todo\n",
    "4. Archivos columnares: leer solo las columnas y filas necesarias\n",
    "5. Alternativas a pandas: **Polars**, **DuckDB**, **PyArrow** (y cuándo tienen sentido Dask o Spark)\n",
    "6. Una comparativa: la misma consulta de seis maneras, con tiempos y pico de memoria medidos\n",
    "7. **PySpark** para datos que no caben en una máquina: las mismas consultas con la API de DataFrame y con SQL"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e92eb5f8",
   "metadata": {},
   "source": [
    "## 1. Medir la memoria\n",
    "`sys.getsizeof` da el tamaño de un objeto de Python (no de lo que contiene); `DataFrame.memory_usage(deep=True)`\n",
    "da el tamaño real de una tabla, cadenas incluidas; `psutil` informa de lo que usa el proceso entero."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "8fb0f237",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:07:57.047062Z",
     "iopub.status.busy": "2026-10-18T10:07:57.046638Z",
     "iopub.status.idle": "2026-10-18T10:07:57.789160Z",
     "shell.execute_reply": "2026-10-18T10:07:57.787137Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "este kernel usa ahora 132 MB\n",
      "28 bytes for an int, 24 for a float, 42 for a 1-character str\n",
      "a NumPy array of 1M float64: 8 MB; a list of 1M floats: ~32 MB\n"
     ]
    }
   ],
   "source": [
    "import sys, os, psutil\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "\n",
    "proc = psutil.Process(os.getpid())\n",
    "print(f\"este kernel usa ahora {proc.memory_info().rss / 1e6:.0f} MB\")\n",
    "\n",
    "print(sys.getsizeof(1), \"bytes for an int,\", sys.getsizeof(1.0), \"for a float,\", sys.getsizeof(\"a\"), \"for a 1-character str\")\n",
    "print(f\"a NumPy array of 1M float64: {np.zeros(1_000_000).nbytes / 1e6:.0f} MB; a list of 1M floats: \"\n",
    "      f\"~{(sys.getsizeof([0.0] * 1_000_000) + 1_000_000 * sys.getsizeof(0.0)) / 1e6:.0f} MB\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "247abe9e",
   "metadata": {},
   "source": [
    "## 2. Reducir un DataFrame\n",
    "pandas usa por defecto números de 64 bits y cadenas de Python. La mayoría de las columnas necesitan mucho menos."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "e5803b73",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:07:57.793894Z",
     "iopub.status.busy": "2026-10-18T10:07:57.793270Z",
     "iopub.status.idle": "2026-10-18T10:07:59.246153Z",
     "shell.execute_reply": "2026-10-18T10:07:59.244336Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "{'sensor_id': dtype('int64'), 'temperature': dtype('float64'), 'humidity': dtype('int64'), 'status': dtype('O'), 'site': dtype('O')}\n",
      "{'sensor_id': dtype('int16'), 'temperature': dtype('float32'), 'humidity': dtype('uint8'), 'status': CategoricalDtype(categories=['fault', 'ok', 'warning'], ordered=False, categories_dtype=object), 'site': CategoricalDtype(categories=['site_0', 'site_1', 'site_10', 'site_11', 'site_12',\n",
      "                  'site_13', 'site_14', 'site_15', 'site_16', 'site_17',\n",
      "                  'site_18', 'site_19', 'site_2', 'site_20', 'site_21',\n",
      "                  'site_22', 'site_23', 'site_24', 'site_25', 'site_26',\n",
      "                  'site_27', 'site_28', 'site_29', 'site_3', 'site_30',\n",
      "                  'site_31', 'site_32', 'site_33', 'site_34', 'site_35',\n",
      "                  'site_36', 'site_37', 'site_38', 'site_39', 'site_4',\n",
      "                  'site_5', 'site_6', 'site_7', 'site_8', 'site_9'],\n",
      ", ordered=False, categories_dtype=object)}\n",
      "antes 131 MB -> después 9 MB (14.6 veces menos)\n"
     ]
    }
   ],
   "source": [
    "rng = np.random.default_rng(0)\n",
    "n = 1_000_000\n",
    "df = pd.DataFrame({\n",
    "    \"sensor_id\": rng.integers(1, 500, n),                    # fits in int16\n",
    "    \"temperature\": rng.normal(21, 4, n),                     # float32 is plenty for a sensor\n",
    "    \"humidity\": rng.integers(0, 101, n),                     # fits in uint8\n",
    "    \"status\": rng.choice([\"ok\", \"warning\", \"fault\"], n, p=[0.9, 0.08, 0.02]),  # 3 distinct values\n",
    "    \"site\": rng.choice([f\"site_{i}\" for i in range(40)], n),\n",
    "})\n",
    "\n",
    "def mb(frame):\n",
    "    return frame.memory_usage(deep=True).sum() / 1e6\n",
    "\n",
    "before = mb(df)\n",
    "small = df.assign(\n",
    "    sensor_id=pd.to_numeric(df[\"sensor_id\"], downcast=\"integer\"),\n",
    "    temperature=df[\"temperature\"].astype(\"float32\"),\n",
    "    humidity=pd.to_numeric(df[\"humidity\"], downcast=\"unsigned\"),\n",
    "    status=df[\"status\"].astype(\"category\"),\n",
    "    site=df[\"site\"].astype(\"category\"),\n",
    ")\n",
    "print(df.dtypes.to_dict()); print(small.dtypes.to_dict())\n",
    "print(f\"antes {before:.0f} MB -> después {mb(small):.0f} MB ({before / mb(small):.1f} veces menos)\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "af3426eb",
   "metadata": {},
   "source": [
    "Una **categoría** guarda cada cadena distinta una sola vez y almacena por fila un código entero pequeño. Es la\n",
    "mayor mejora individual para columnas de texto con pocos valores distintos, y agrupar por ella también es\n",
    "más rápido:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "47473716",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:07:59.250648Z",
     "iopub.status.busy": "2026-10-18T10:07:59.250285Z",
     "iopub.status.idle": "2026-10-18T10:07:59.842590Z",
     "shell.execute_reply": "2026-10-18T10:07:59.840782Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "groupby sobre texto: 88 ms, sobre categoría: 29 ms\n"
     ]
    }
   ],
   "source": [
    "import timeit\n",
    "t_obj = timeit.timeit(lambda: df.groupby(\"site\")[\"temperature\"].mean(), number=5) / 5\n",
    "t_cat = timeit.timeit(lambda: small.groupby(\"site\", observed=True)[\"temperature\"].mean(), number=5) / 5\n",
    "print(f\"groupby sobre texto: {t_obj * 1000:.0f} ms, sobre categoría: {t_cat * 1000:.0f} ms\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d2403db3",
   "metadata": {},
   "source": [
    "## 3. Procesamiento por flujo: bloques y generadores\n",
    "Si el resultado es pequeño (una suma, un recuento, una media), no hace falta tener todas las filas a la vez."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "e5417c04",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:07:59.846248Z",
     "iopub.status.busy": "2026-10-18T10:07:59.845898Z",
     "iopub.status.idle": "2026-10-18T10:08:14.909082Z",
     "shell.execute_reply": "2026-10-18T10:08:14.907429Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "CSV 104 MB, Parquet 25 MB\n"
     ]
    }
   ],
   "source": [
    "import tempfile\n",
    "tmp = tempfile.mkdtemp()\n",
    "\n",
    "# a 3-million-row dataset, written once as CSV and as Parquet, used for the rest of the module\n",
    "N = 3_000_000\n",
    "big = pd.DataFrame({\n",
    "    \"sensor_id\": rng.integers(1, 500, N).astype(\"int16\"),\n",
    "    \"temperature\": rng.normal(21, 4, N).astype(\"float32\"),\n",
    "    \"status\": pd.Categorical(rng.choice([\"ok\", \"warning\", \"fault\"], N, p=[0.9, 0.08, 0.02])),\n",
    "    \"site\": pd.Categorical(rng.choice([f\"site_{i}\" for i in range(40)], N)),\n",
    "    \"note\": rng.choice([\"\", \"manual check\", \"recalibrated\", \"battery low\"], N),\n",
    "})\n",
    "CSV, PARQUET = os.path.join(tmp, \"big.csv\"), os.path.join(tmp, \"big.parquet\")\n",
    "big.to_csv(CSV, index=False)\n",
    "big.to_parquet(PARQUET, index=False, row_group_size=250_000)\n",
    "print(f\"CSV {os.path.getsize(CSV) / 1e6:.0f} MB, Parquet {os.path.getsize(PARQUET) / 1e6:.0f} MB\")\n",
    "del big"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "eb3fa62c",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:08:14.914419Z",
     "iopub.status.busy": "2026-10-18T10:08:14.913998Z",
     "iopub.status.idle": "2026-10-18T10:08:17.153573Z",
     "shell.execute_reply": "2026-10-18T10:08:17.151265Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "site_0     21.057986\n",
      "site_1     21.014257\n",
      "site_10    20.881563\n",
      "site_11    20.891889\n",
      "site_12    21.071960\n",
      "dtype: float64\n"
     ]
    }
   ],
   "source": [
    "# temperatura media de las lecturas 'fault' por sitio, leyendo el CSV de 250.000 en 250.000 filas\n",
    "sums, counts = {}, {}\n",
    "for chunk in pd.read_csv(CSV, chunksize=250_000, usecols=[\"site\", \"status\", \"temperature\"]):\n",
    "    faults = chunk[chunk[\"status\"] == \"fault\"]\n",
    "    g = faults.groupby(\"site\")[\"temperature\"]\n",
    "    for site, s in g.sum().items():\n",
    "        sums[site] = sums.get(site, 0.0) + s\n",
    "    for site, c in g.count().items():\n",
    "        counts[site] = counts.get(site, 0) + c\n",
    "streamed = pd.Series({k: sums[k] / counts[k] for k in sums}).sort_index()\n",
    "print(streamed.head())"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "f87c43c2",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:08:17.157795Z",
     "iopub.status.busy": "2026-10-18T10:08:17.157252Z",
     "iopub.status.idle": "2026-10-18T10:08:19.539040Z",
     "shell.execute_reply": "2026-10-18T10:08:19.537323Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "filas fault: 59,835\n"
     ]
    }
   ],
   "source": [
    "# la misma idea con un generador simple: un flujo que nunca tiene el archivo entero en memoria\n",
    "def read_lines(path):\n",
    "    with open(path) as f:\n",
    "        next(f)                                   # skip the header\n",
    "        for line in f:\n",
    "            yield line.rstrip(\"\\n\").split(\",\")\n",
    "\n",
    "def faults_only(rows):\n",
    "    return (r for r in rows if r[2] == \"fault\")   # columns: sensor_id, temperature, status, site, note\n",
    "\n",
    "count = sum(1 for _ in faults_only(read_lines(CSV)))\n",
    "print(f\"filas fault: {count:,}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1ba95abe",
   "metadata": {},
   "source": [
    "## 4. Archivos columnares: leer solo lo necesario\n",
    "Parquet guarda cada columna por separado y conserva estadísticas de mínimo y máximo por bloque de filas (un\n",
    "*row group*). Por eso un lector puede saltarse las columnas que no necesita (**poda de columnas**) y bloques\n",
    "enteros de filas que no pueden cumplir un filtro (**predicate pushdown**, empuje de predicados)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "15d3fc84",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:08:19.544530Z",
     "iopub.status.busy": "2026-10-18T10:08:19.544040Z",
     "iopub.status.idle": "2026-10-18T10:08:19.652431Z",
     "shell.execute_reply": "2026-10-18T10:08:19.650853Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "3,000,000 rows in 12 row groups, 5 columns\n",
      "(59835, 2) 0.3 MB cargados en lugar de la tabla entera\n"
     ]
    }
   ],
   "source": [
    "import pyarrow.parquet as pq\n",
    "meta = pq.ParquetFile(PARQUET).metadata\n",
    "print(f\"{meta.num_rows:,} rows in {meta.num_row_groups} row groups, {meta.num_columns} columns\")\n",
    "\n",
    "subset = pd.read_parquet(PARQUET, columns=[\"site\", \"temperature\"], filters=[(\"status\", \"==\", \"fault\")])\n",
    "print(subset.shape, f\"{mb(subset):.1f} MB cargados en lugar de la tabla entera\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2179bbc4",
   "metadata": {},
   "source": [
    "## 5. Alternativas a pandas\n",
    "\n",
    "| Biblioteca | Qué es | Puntos fuertes |\n",
    "|---|---|---|\n",
    "| **Polars** | biblioteca de DataFrames escrita en Rust, construida sobre Apache Arrow | multihilo, consultas diferidas con optimizador, procesamiento por flujo para datos mayores que la RAM |\n",
    "| **DuckDB** | una base de datos SQL analítica que se ejecuta dentro del proceso | SQL directamente sobre archivos Parquet/CSV, ejecución fuera de memoria, consumo de memoria muy bajo |\n",
    "| **PyArrow** | la biblioteca de Python para Apache Arrow, el formato columnar en memoria que comparten las demás | intercambio sin copias entre herramientas, datasets, funciones de cálculo |\n",
    "| **Dask** | API parecida a pandas, repartida en particiones | escala código pandas a muchos núcleos o a un clúster |\n",
    "| **Spark (PySpark)** | motor distribuido | datos que necesitan un clúster (cientos de GB y más) |\n",
    "\n",
    "Regla práctica: hasta unos pocos GB, pandas con buenos tipos basta; por encima, o con poca memoria, Polars o\n",
    "DuckDB sobre Parquet suelen resolverlo en una sola máquina; recurre a Dask o Spark solo cuando una máquina de\n",
    "verdad no alcance."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "398a7b9b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:08:19.656363Z",
     "iopub.status.busy": "2026-10-18T10:08:19.655947Z",
     "iopub.status.idle": "2026-10-18T10:08:20.606393Z",
     "shell.execute_reply": "2026-10-18T10:08:20.603751Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "shape: (3, 2)\n",
      "┌─────────┬─────────────┐\n",
      "│ site    ┆ temperature │\n",
      "│ ---     ┆ ---         │\n",
      "│ cat     ┆ f32         │\n",
      "╞═════════╪═════════════╡\n",
      "│ site_0  ┆ 21.057985   │\n",
      "│ site_1  ┆ 21.014257   │\n",
      "│ site_10 ┆ 20.881563   │\n",
      "└─────────┴─────────────┘\n",
      "      site          t\n",
      "0   site_0  21.057986\n",
      "1   site_1  21.014257\n",
      "2  site_10  20.881563\n"
     ]
    }
   ],
   "source": [
    "import polars as pl, duckdb\n",
    "\n",
    "print(pl.scan_parquet(PARQUET)                 # lazy: nothing is read yet\n",
    "        .filter(pl.col(\"status\") == \"fault\")\n",
    "        .group_by(\"site\").agg(pl.col(\"temperature\").mean())\n",
    "        .sort(\"site\").head(3).collect())\n",
    "\n",
    "print(duckdb.sql(f\"select site, avg(temperature) as t from '{PARQUET}' where status = 'fault' \"\n",
    "                 \"group by site order by site limit 3\").df())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6e3e1899",
   "metadata": {},
   "source": [
    "## 6. Comparativa: una consulta, seis maneras\n",
    "Cada enfoque se ejecuta en un **proceso de Python nuevo** para medir su pico de memoria (el máximo de memoria\n",
    "residente según el sistema operativo) por separado, sin nada de lo que ya tiene este notebook. Las bibliotecas\n",
    "nativas reservan memoria fuera de Python, así que es la única forma justa de compararlas."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "7fb533dc",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:08:20.610882Z",
     "iopub.status.busy": "2026-10-18T10:08:20.610364Z",
     "iopub.status.idle": "2026-10-18T10:08:35.149489Z",
     "shell.execute_reply": "2026-10-18T10:08:35.147910Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "mean temperature of fault readings per site; peak memory above a bare 'import pandas' process (99 MB)\n"
     ]
    },
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>seconds</th>\n",
       "      <th>peak_mb</th>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>approach</th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>pandas read_csv (all columns)</th>\n",
       "      <td>2.85</td>\n",
       "      <td>229.20</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>pandas read_csv (3 columns, typed)</th>\n",
       "      <td>1.63</td>\n",
       "      <td>37.60</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>pandas read_csv in chunks</th>\n",
       "      <td>2.40</td>\n",
       "      <td>42.86</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>pandas read_parquet (pruned + filtered)</th>\n",
       "      <td>0.14</td>\n",
       "      <td>126.16</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>Polars lazy scan</th>\n",
       "      <td>0.45</td>\n",
       "      <td>95.05</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>DuckDB SQL</th>\n",
       "      <td>0.15</td>\n",
       "      <td>66.78</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "                                         seconds  peak_mb\n",
       "approach                                                 \n",
       "pandas read_csv (all columns)               2.85   229.20\n",
       "pandas read_csv (3 columns, typed)          1.63    37.60\n",
       "pandas read_csv in chunks                   2.40    42.86\n",
       "pandas read_parquet (pruned + filtered)     0.14   126.16\n",
       "Polars lazy scan                            0.45    95.05\n",
       "DuckDB SQL                                  0.15    66.78"
      ]
     },
     "execution_count": 9,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "import subprocess, json, textwrap\n",
    "\n",
    "QUERY = \"mean temperature of fault readings per site\"\n",
    "approaches = {\n",
    "    \"pandas read_csv (all columns)\": f\"\"\"\n",
    "df = pd.read_csv({CSV!r})\n",
    "r = df[df.status == 'fault'].groupby('site').temperature.mean()\"\"\",\n",
    "    \"pandas read_csv (3 columns, typed)\": f\"\"\"\n",
    "df = pd.read_csv({CSV!r}, usecols=['site', 'status', 'temperature'], dtype={{'site': 'category', 'status': 'category', 'temperature': 'float32'}})\n",
    "r = df[df.status == 'fault'].groupby('site', observed=True).temperature.mean()\"\"\",\n",
    "    \"pandas read_csv in chunks\": f\"\"\"\n",
    "parts = [c[c.status == 'fault'] for c in pd.read_csv({CSV!r}, usecols=['site', 'status', 'temperature'], chunksize=250_000)]\n",
    "r = pd.concat(parts).groupby('site').temperature.mean()\"\"\",\n",
    "    \"pandas read_parquet (pruned + filtered)\": f\"\"\"\n",
    "df = pd.read_parquet({PARQUET!r}, columns=['site', 'temperature'], filters=[('status', '==', 'fault')])\n",
    "r = df.groupby('site', observed=True).temperature.mean()\"\"\",\n",
    "    \"Polars lazy scan\": f\"\"\"\n",
    "import polars as pl\n",
    "r = pl.scan_parquet({PARQUET!r}).filter(pl.col('status') == 'fault').group_by('site').agg(pl.col('temperature').mean()).collect()\"\"\",\n",
    "    \"DuckDB SQL\": f\"\"\"\n",
    "import duckdb\n",
    "r = duckdb.sql(\"select site, avg(temperature) from '{PARQUET}' where status = 'fault' group by site\").df()\"\"\",\n",
    "}\n",
    "\n",
    "def run(snippet):\n",
    "    # Peak memory = VmHWM (\"high water mark\") from /proc/self/status, in kB. Unlike getrusage's\n",
    "    # ru_maxrss it belongs to the new program only: Linux carries ru_maxrss over from the parent\n",
    "    # (this notebook) through fork and exec, which would hide every difference. (Linux only.)\n",
    "    prog = (\"import time, pandas as pd\\nt0 = time.perf_counter()\\n\" + textwrap.dedent(snippet) +\n",
    "            \"\\nhwm = next(l for l in open('/proc/self/status') if l.startswith('VmHWM')).split()[1]\"\n",
    "            \"\\nprint(time.perf_counter() - t0, hwm)\")\n",
    "    out = subprocess.run([sys.executable, \"-c\", prog], capture_output=True, text=True, check=True).stdout.split()\n",
    "    return float(out[-2]), int(out[-1]) / 1024          # seconds, MB\n",
    "\n",
    "baseline_mb = run(\"pass\")[1]                            # what an empty python + pandas import costs\n",
    "bench = pd.DataFrame([dict(approach=k, seconds=s, peak_mb=m - baseline_mb) for k, (s, m) in\n",
    "                      ((k, run(v)) for k, v in approaches.items())]).set_index(\"approach\").round(2)\n",
    "print(f\"{QUERY}; peak memory above a bare 'import pandas' process ({baseline_mb:.0f} MB)\")\n",
    "bench"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "8aa05821",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:08:35.153520Z",
     "iopub.status.busy": "2026-10-18T10:08:35.152955Z",
     "iopub.status.idle": "2026-10-18T10:08:36.356665Z",
     "shell.execute_reply": "2026-10-18T10:08:36.354904Z"
    }
   },
   "outputs": [
    {
     "data": {
      "image/png": "iVBORw0KGgoAAAANSUhEUgAABEIAAAFeCAYAAACB7ro+AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjIsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvgI3uAAAAAAlwSFlzAAAPYQAAD2EBqD+naQAAgLhJREFUeJzt3XdYFcf7NvCbLr1XUUCxIGBFsYsVWxR778RuLDF2o0aNX40lJiZ2jcaCLXZjr2DvFUssNBWQqnSY9w9e9udKOyAIeO7PdXElZ3d25pk5ZT3PmZ1VEUIIEBEREREREREpAdWiDoCIiIiIiIiI6EthIoSIiIiIiIiIlAYTIURERERERESkNJgIISIiIiIiIiKlwUQIERERERERESkNJkKIiIiIiIiISGkwEUJERERERERESoOJECIiIiIiIiJSGupFHQARUXGTlpaGkJAQ6OvrQ0VFpajDISKiXAghEBsbCxsbG6iqfv7vfDwPEBGVPHk5FzARQkT0iZCQEJQpU6aowyAiojwKDAyEra3tZ9fD8wARUcmlyLmAiRAiok/o6+sDSP8QNTAwKOJoiIgoNzExMShTpoz0+f25eB4gIip58nIuYCKEiOgTGdOgDQwM+A9gIqISpKAuY+F5gIio5FLkXMDFUomIiIiIiIhIaTARQkRERERERERKg4kQIiIiIiIiIlIaTIQQERERERERkdJgIoSIiIiIiIiIlAYTIURERERERESkNJgIISIiIiIiIiKlwUQIERERERERESkNJkKIiIiIiIiISGmoF3UARETFle3OMKjoJBR1GEREX73o3hZFHUKWenV6DQ3190UdBhGRUth3zOaLtcUZIURERERERESkNJgIISIiIiIiIiKlwUQIERERERERESkNJkKIiIiIiIiISGkwEUJERERERERESoOJEMrRzZs3MXLkyKIOo0CtW7cO69atK+owspSWloZx48YhICBA4WP++OMPbNq0KdvHheFLtFFQli9fjtOnTxd1GEREREREVEwwEUI5CggIwLZt24o6jALl6+sLX1/fog4jS+vXr8fNmzdRtmxZhY85c+YMLl26lO3jwvAl2igoVapUwdChQ5GYmFjUoRARERERUTHARAhRMZGamor58+dj/PjxRR3KV6Vly5bQ0NDA9u3bizoUIiIiIiIqBtSLOgDK2ZkzZ3D06FEMGTIE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ERERERGR0mAihIiIiIiIiIiUBhMhRERERERERKQ0mAghIiIiIiIiIqXBRAgRERERERERKQ0mQoiIiIiIiIhIaTARQkRERERERERKg4kQIiIiIiIiIlIaTIQQERERERERkdJgIoSIiIiIiIiIlAYTIURERERERESkNNSLOgAiouLKdmcYVHQSijoMomIlurdFUYdA9MX06vQaGurvizqMQrXvmE1Rh0BE9MVxRggRERERERERKQ0mQoiIiIiIiIhIaTARQkRERERERERKg4kQIiIiIiIiIlIaTIQQERERERERkdJgIqSIHT9+HFWrVi3qMArUxIkTMXHixKIOI0uxsbGoVasWXr16VaRxjBw5EtOnTy/SGL5Wn77+vv/+e/z+++9FGBERERERERUnTIQUsbi4OAQEBBR1GAUqPDwc4eHhRR1GlubPnw9nZ2fY2dkBABISErB8+XI0bdoULi4u6NSpE86cOVPocYSGhuLdu3eF3s6XMHToUMyaNauow5B8+vr79ttvMXPmTISFhRVhVEREREREVFwwEUJKIzY2FitXrsSIESOkbTNnzkRoaCjmzp0LHx8fuLq6olWrVvD19S3CSEuW4p7UqVy5MqpXr441a9YUdShERERERFQMKH0iZOvWrfDw8MD27dvRokULODk5Yfjw4YiOjpbK+Pn5wd7eHvb29nByckLHjh1x+fLlLOvZs2cPPD094eLigiFDhmT6gnj8+HE0adIEVatWxcCBA/H69WvZfkXaio6Oxvjx41GzZk3Url0bM2bMQHx8fI7927lzJxo0aIBy5cohMjISALBv3z60bNkSlStXRps2bXDs2DHZsbNmzZJiqVmzJkaMGIHQ0FBZGSEEFixYgKpVq6J+/fqYO3cuUlJSFBj5/7N//354enrCyckJ3bp1w5MnT6R9d+7cQdeuXeHs7IyWLVvCx8dH6pebmxvS0tJkdU2ZMgV9+/bNth1dXV3Uq1dP2va///0P8+fPR8OGDeHi4oKffvoJtra2OH78eL5jBoCVK1eiXr16qFSpErp06YJ79+7lWF+HDh2wcuVK2bbx48fLLvEYMGAApk2bhokTJ6Ju3bqoXr06li9fjtjYWEyYMAEuLi6oX78+9uzZI6tnwIABmDVrFmbNmoV69eqhdu3aWLx4MYQQUpnsxjk3U6ZMwfHjx7F582bptfLrr7+iVq1a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wsa7xhARERERERGR0mAihIiIiIiIiIiUhtJfGkNElJ2g7uYFcvcBIiIqmbbvteZ5gIjoK8QZIURERERERESkNJgIISIiIiIiIiKlwUQIERERERERESkNJkKIiIiIiIiISGkwEUJERERERERESoOJECIiIiIiIiJSGkyEEBEREREREZHSYCKEiIiIiIiIiJQGEyFEREREREREpDSYCCEiIiIiIiIipcFECBEREREREREpDSZCiIiIiIiIiEhpMBFCREREREREREqDiRAiIiIiIiIiUhpMhBARERERERGR0mAihIiIiIiIiIiUBhMhRERERERERKQ0mAghIiIiIiIiIqXBRAgRERERERERKQ0mQoiIiIiIiIhIaTARQkRERERERERKg4kQIiIiIiIiIlIa6kUdABFRcWW7MwwqOglFHQYREQAgurdFUYegdHp1eg0N9fdFHUaR2nfMpqhDICIqcJwRQkRERERERERKg4kQIiIiIiIiIlIa+b405v3793j48CEiIiIy7WvduvVnBUVEREREREREVBjylQg5evQoevfujcjISKipqWXan5KS8tmBEREREREREREVtHxdGjN+/HiMHDkSMTExSElJyfRHRERERERERFQc5SsREhgYiClTpkBfX7+g4yElExcXh4CAgKIOo0CFh4cjPDy8qMPIJCwsDO/evSvUNt6+fYvIyMhCbaMo2iIiIiIioq9HvhIhTk5OePnyZQGHQsro+PHjqFq1alGHUaAmTpyIiRMnFnUYmYwYMQLTp08v1DYGDBiA+fPnF2obRdEWERERERF9PRReI+TjxMfQoUPRr18/LFq0CI6OjlBRUZGVtbe3L6j4iKiAWFhYwNjYuKjDICIiIiIiKlIKJ0IcHBwybWvVqlWWZYUQ+Y+IvqjY2FjExMSgdOnSSE1NRXh4OCwtLWVlEhIS8ObNGwCAtrZ2pv2f1pOWloaIiAiYmZll2WZ8fDzi4+NhYmKSaZ8ibWWIiYmBqqoq9PT0FOpffHw8wsLCYGtrC1XV9MlQSUlJiIiIgKWlZaaEXkREBGJiYgAAJiYmMDAwyLadiIgIaGtrQ1tbO9syOUlJScG7d+9gYWGRKQ4hBMLDw2FsbAx19fS3bGRkJBISEmBtbS0rGx0djffv36N06dKZ2pgzZ47UbyD90hJNTU0YGxsjLi4OaWlpOY6lovFmyK7O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HqJ7WxR1CMVWxud2dHR0gazvkVFf22b+0FBX7vPAvmM2RR0CEZFC8nIuyNeMECD9l/YRI0bk93Cir15KSgqCgoKy3V+6dOk83eGFiIiIiIiIPl++EyH0dcju0hH6fG/evIGHh0e2+0+dOoXy5ct/uYCIiIiIiIgof4mQlJQU/P7779i1axcCAgIy3Snm4ztCUPHWp0+fQl9kU1nZ2tri5cuXRR0GERERERERfSRfd42ZO3cu/vjjD/Tu3RvBwcGYN28eOnTogIiICC5GSERERERERETFVr4SIZs3b4aPjw9Gjx4NABgyZAjWrFmD1atX48aNGwUaIBERERERERFRQcnXXWPU1NSQmJgIdXV16OrqIiQkBIaGhoiNjYWVlRU+fPhQGLESEX0RBX33ASIiKlyFddcYngeIiEqOvHx252tGSFpaGtTV05cXcXBwwIULFwAAjx49go6OTn6qJCIiIiIiIiIqdJ9915iRI0eiV69ecHd3x/Xr1zFo0KCCiIuIiIiIiIiIqMDlKxHy8dU0I0eOhJ2dHS5duoSBAweid+/eBRYcEREREREREVFB+uwZIQDQrl07tGvXriCqIiIiIiIiIiIqNJ+VCPH398ejR48ghECVKlVQuXLlgoqLiIiIiIiIiKjA5SsREhkZiYEDB+LAgQNQU1ODiooKUlJS0KFDB2zatAlGRkYFHCYRERERERER0efL111jxo0bh8DAQFy6dAmJiYlISEjApUuXEBgYiLFjxxZ0jEREREREREREBSJfM0IOHDiAy5cvo1KlStK2unXrYtu2bahfv36BBUdEREREREREVJDyNSMkISEBJiYmmbabmJggPj7+s4MiIiIiIiIiIioM+UqE1K9fH1OnTkVCQoK0LSEhAVOmTEGDBg0KLDgiIiIiIiIiooKUr0tjfv31V3h6esLW1hZVq1YFANy9exeampo4duxYgQZIRERERERERFRQ8pUIcXV1xdOnT/H333/jwYMHUFFRQffu3dGvXz/o6uoWdIxERERERERERAUiX4mQnj17wsfHB8OHDy/oeIiIiIiIiIiICk2+1gg5ePAgF0UlIiIiIiIiohInX4kQDw8PHDp0qKBjISIiIiIiIiIqVPm6NKZy5cro168fDh8+jCpVqkBTU1O2f9y4cQURGxERERERERFRgVIRQoi8HuTi4pLj/vv37+c7ICKiohYTEwNDQ0NER0fDwMCgqMMhIqJcFPTnNs8DREQlT14+u/M1I4SJDiIiIiIiIiIqifK1RggRERERERERUUmU70TIli1bUKtWLejq6kJXVxdubm7Ytm1bQcZGRERERERERFSg8nVpzPz587FgwQIMHz4ckyZNAgBcu3YNQ4cORUBAAKZMmVKgQRIRERERERERFYR8LZZqYWGBDRs2oH379rLtBw8ehLe3N96+fVtgARIRfWlcJI+IqGThYqlERJSXz+58XRqTkpKCxo0bZ9repEkTJCcn56dKIiIiIiIiIqJCl69LY+rXr4+dO3fC29tbtn3nzp2oX79+gQRGRFTUbHeGQUUnId/HR/e2KMBoiIjoS+vV6TU01N8XdRhERWLfMZuiDoGo0OQrEVKxYkUMGzYMBw4cQO3atSGEwPXr13H48GGMHTsWv/76q1R23LhxBRQqEREREREREdHnyVci5Pjx43BycsLz58/x/PlzabuTkxOOHz8uK8tECBEREREREREVF/lKhNy/f7+g4yAiIiIiIiIiKnT5WiyViIiIiIiIiKgkynciZMuWLahVqxZ0dXWhq6sLNzc3bNu2rSBjIyIiIiIiIiIqUPlKhMyfPx/Dhw9H06ZNsWHDBmzYsAEeHh4YOnQo/ve//xV0jIXm6NGjuH37dlGHUWCSkpLw119/ISIioqhDKbZu3LiBq1evFnUYny0gIKDQE49paWk4c+YMNm/ejBs3buDhw4fYv3+/tP/58+fYsWNHocaQH76+vvD19ZUe//vvv3jx4kURRkRERERERMVJvhIhy5cvh4+PDxYvXowePXqgR48eWLx4MbZv345ly5YVdIyF5n//+x/27dtX1GEUmLi4OAwaNAgBAQFFHcoX9ezZM+zatSvXch8+fICXlxdUVFS+QFSF6+bNmxg5cmShttG9e3eMGDECp06dwvPnz3H8+HFMnz5d2n/x4kWMHTtWeqzo81DY1q1bh3Xr1kmPAwICMGTIkCKMiIiIiIiIipN8JUJSUlLQuHHjTNubNGmC5OTkzw6KKC98fX0xfvz4XMutWLECTk5OqF279heIqnh59uwZ/v33X4XLR0dHY8+ePfDx8cGmTZvQrVs3VKlSBV5eXtkeo+jz8KUNHjwY9+/fx4kTJ4o6FCIiIiIiKgbyddeY+vXrY+fOnfD29pZt37lzJ+rXr69wPXv37oWTkxP09PRw8+ZNaGpqolmzZtDU1JTKnDp1CoGBgVBRUYGFhQXc3Nxgbm6eZT2Ghoa4fv06tLS00KhRI2hra8vKvX37FufPn4eZmVmWX4YVaQsArl27hhcvXsDBwQFubm7ZzjDIiEtHRwdXrlyBlZUVGjVqBAAIDw+Hr68vVFVVUatWLZQuXVo6TgiBTZs2AQDU1dVhZ2cHd3d32bhkuHPnDh4/fgxHR0eUK1cuu6HOJDo6Gnv37kX37t3x5MkTPHnyBI6OjqhZs2ae4vi4nvv37+Pp06do2LAh7OzskJaWhnPnziEyMhLVq1eHuro6fH190bt3bwDA3bt3ERgYiHbt2kn1vXr1Cn5+flKZDE+fPsXNmzdhYmICd3d3GBgYAACCgoLg6+uLuLg4/PXXXwCAmjVromrVqrLjhRD4888/sWDBAmnb48eP8eDBA7Rr1w7Xrl1DcHAw6tWrh7Jly2Yq0759e5w7dw6vX79Gjx498Pjx41xjzzj2m2++wfXr1xEcHIyaNWtm+Txl178Mn45lXvn6+mLdunVo06ZNrmVfvHghzew4efKk7PKx7JJIuT0POfUvuzHW0tJCWloaLl26hMDAQDg4OKB27dpQVZXnb6Ojo3Hq1Cno6ellGZ+GhgZ69uyJlStXomXLlrn2n4iIiIiIvm75SoRUrFgRw4YNw4EDB1C7dm0IIXD9+nUcPnwYY8eOxa+//iqVHTduXLb1TJ06Febm5nj58iVq166NGzduwMTEBGfOnIGRkREA4N69e9IXsYCAANy4cQObNm2S/TI9depUWFpaIiQkBNWrV8etW7egpaWFq1evQldXFwBw5swZdOjQAa6urjA0NMR///2H1NRUWTy5tZWSkoIOHTrg/v37qFevHoKCgqCpqYkjR45kSrpkxGVhYYGgoCDUrl0bzZs3R6NGjfDXX39h3LhxqFevHrS0tNC/f3/MmzcPo0ePBpD+pf3s2bMAgOTkZNy6dQtCCJw8eVKWMJk+fTqWLVuG5s2bIygoCCYmJrk8c//n9evXGDRoEP7++2+EhYXB3t4ep0+fhre3t/T8KRJHRj1bt27Fu3fv4OLigkqVKsHKygpt2rTBw4cPUb9+fUyYMAEODg64deuWlCg4cOAADh06JEsmXLt2DSNHjpTKCCEwatQo7Nq1C40bN0ZERAQePXqE3bt3o2HDhnj37h38/f2RmJgoxWpqapopEXLv3j0EBATAw8ND2nbmzBnMnDkTpUuXhqmpKQBgwIAB2LRpE3r06CErU7ZsWZiYmKB06dLo3LmzQrFnHPu///0PxsbGUFVVRZ8+ffD333+je/fuCvUPABITE7Mcy8ISGhqKK1euAAAuX74MPT09AMDDhw8RFxeHjh07Zjomu+fB1dU11/5lN8YRERFo37494uPj4erqitu3b8PMzAxHjhyBoaEhAODBgwdo1qwZrKysYGdnhzt37sDIyAg1atSQxde0aVP069cPSUlJWSYUiYiIiIhIeeQrEXL8+HE4OTnh+fPneP78ubTdyckJx48fl5XNKRECpP8afPfuXVhZWSEmJgZ16tTBggULsHDhwiyP37hxI0aMGIEOHTrIfhkODw/H7du3oauri7i4OJQrVw5btmzBsGHDkJqaiuHDh8Pb21taw2Tbtm3o06dPjrF+2tb169dx8uRJhIaGSokaPz8/JCcnZ5kIAYDg4GDcunVL+uL28OFDjBo1CufPn0etWrUAANevX0fDhg3RunVrODo6QlVVVfpVHUj/sty5c2fMmTMHa9asAZA+E+R///sfTp8+jSZNmkAIgV69euU41lnR19fHiRMnoKqqiitXrqBevXro0aMH6tWrp1AcGezs7GSXHvz22294+PAh7t69CwsLC0RHR8PNzS3P8a1btw7Hjx/HkydPYGxsDABYtmwZ+vfvj//++w/VqlWDt7c3ZsyYIYv1UxnPgY2NjWx7eHg4pk6digkTJgAAFi1ahNGjR6Nt27bQ19eXyixduhT9+vXLc/zh4eFYu3atlEz78ccfMWvWLCkRklv/VFRUsHr16gIZS0W5u7tj4cKF+Oeff7B48WLY29sDAH799VfZ2hsfy+55WLt2ba79A7Ie4x49eqBmzZpYs2YNVFRUkJKSAk9PT8yePVt6H48ZMwaNGjXCzp07oaqqCj8/PzRs2DBTIsTFxQUfPnzA48eP4erqmin+xMREJCYmSo9jYmLyPnBERFRi8TxARKRc8pUIuX//foEF0KtXL1hZWQEADAwM8O2332LlypVSIgRIv+Tg7t27iIiIQGxsLN68eYPAwEDY2dnJ6smY/aGjo4MaNWrgyZMnANIvwXjy5AlOnjwpKz9x4sRM8eTUlp6eHlJTU3HlyhV4enoCABo0aJBj/3r27CklQYD0BIyVlRUePXqEhw8fQggBID0h4efnB0dHR6ns9evX8eLFC3z48AGGhoa4du2atO+ff/5B9erV0aRJEwCAiooKxo8fn+e7eIwdO1ZKKLm7u6NBgwbYs2cP6tWrp1AcGYYNGyZ7vGfPHvTp0wcWFhYAAENDQ3h7e8suTVHEpk2bULlyZRw+fBhCCOnvxYsXCAgIkL0GcvLu3TspefUxDQ0NjBo1Snr83XffYcaMGTh37hzat28PANDT00Pfvn3zFHcGU1NT2eylBg0aYMGCBRBCQEVFRaH+5WcsIyIicODAAemxr68vQkNDZUkKJycnuLu756tfilL0+ft0jMPDw3HkyBHMmjULW7dulY6zsrKSZpxERkbizJkzOH/+vPQabtCgAerWrZspjoznPjw8PMs4FyxYgDlz5hRgz4mIqCTheYCISLnkKxFSkD5ejwFIn1nw8V1PJk+ejD///BP16tWDhYUF0tLSAKR/ofn4S/CnX3K1tLSkzH5AQABUVVVha2sr7VdRUcnUdm5tubi4YMmSJRg0aBBUVFTQtGlTeHt7yy63+FRGkidDQEAAkpOTZUkZAGjXrh0sLS0BALGxsWjRogWCgoJQp04dGBgY4L///pN9iQsICMhy7PIqp/FXJI6c+tm5c+fPji8gIABJSUmZxmvAgAFSEkkR+vr6eP/+fabtlpaW0NLSkh6XKlUKFhYWsteghYVFvu80k9XrMiUlBampqVBXV1eof/kZy/fv30sJAyB9sdTo6GjZNnV19UJPhCj6/H06xhnjf//+fbx8+VLarqGhgaZNm8rKKPI+yHjuM2b5fOrjWUFA+i+BZcqUybV/RET0deB5gIhIuXxWIiQ0NBQBAQFISUmRbc/qF9nsREREZHqcsUDpy5cvsWjRIty8eVOa6v706VNs3749T1+Czc3NkZaWhqioKGl6/qdtK9rWuHHjMHbsWDx8+BC7d+9GixYtcOrUKWlmxqc+/QJtaGgIExOTHC/jWL9+PaKiovD8+XPpS/ovv/yC5cuXy/r08WVJn/ZHUREREShfvrzsccb6E4rEkeHTfpqbm2f53H5MTU1NSjZliI+Plz02NDRE/fr1ZevO5EelSpUQERGB6Oho2QydyMhIWTkhBCIjI2WL5GaVBFEkdkUo0j9FxvJTZcuWlb3G/vrrL6xbty7H111hUPT5y+p9AgCjR4/ONtGY8RxFRETIkh8RERGZLoF68eIF1NTUUKFChSzr0tLSkiXEiIhIufA8QESkXPJ1+9zg4GA0adIElpaWqF27NurVqyf7y4t9+/bJFi3NWFQRAMLCwgBAtjCkj49PnuN1dXWFkZER9uzZI227ffs2nj59Kj1WpK03b94gMTERKioqcHZ2xqxZs1CuXDncvHlT4Vjatm2LO3fu4MKFC7Lt7969Q2xsrBSLtbW1dEJOS0uT7uKRoXHjxrh8+TKCg4OlbTt37lQ4jgwfj0loaCjOnTsnG//c4shO48aNs3xuP1a6dGm8ePFCdsvlT2cOtG3bFtu2bcv0xf/jWQL6+vq5JiHc3d2hq6sLPz8/2fYPHz7g6NGj0uPDhw8jOTk512SeIrErQpH+KTKWxUFWz4Mi/ctK+fLlUalSJaxYsSLTvoxjra2t4ejoKHsNv3nzJtN7C0i/NKhOnTqyJBgRERERESmnfM0IGTduHKytrREYGIgyZcogLCwMV69exXfffYfx48fnqa53796hZcuW8PLywtmzZ3HlyhVcvXoVAFC1alVUqFABXl5e6N69O+7evYt//vknz/Hq6+tj9uzZGDNmDJ4/fw4jIyP8+eefstkhirT1+PFjDB06FF5eXihXrhyuXr2Kt2/fyu4ckps2bdpg6NChaNOmDYYPHw4HBwc8evQIx44dw7lz56Cvrw8vLy8sXLgQo0ePRqVKlbBnzx48f/4cpUqVkupp3749GjRogKZNm2LYsGEIDAzE7t278zw2mzdvRmxsLBwdHbF27Vq4urqiW7duAKBQHNmZNGkStmzZAk9PT3To0AGnT5+Gv7+/rEyHDh3w/fffo0uXLmjdujUuXbqE06dPy8pMnz4dp0+fRq1atTBkyBDo6uri6tWrCA4Oxvnz5wGk36Y1JiYGU6dORaVKlbK8fW7G3Xm2b9+Otm3bStt1dXXx7bffYtiwYRBCYMmSJRg3blyu02EViV0RivRPkbEsDrJ6HhTpX3Y2btyINm3aoGXLlmjfvj2io6Px77//wsvLC5MnT4aKigp++eUXdOvWDdHR0ShXrhzWrl2b6dbDQHpSM6s1gYiIiIiISPnka0bIuXPnsHjxYmnNDWNjY7Rt2xZbt27F77//nqe6ZsyYgeHDh+PFixeoUKECbty4gcqVKwNI//Lq5+eH5s2b4/bt27Czs8OVK1cwYMAAmJmZSXV06tQJTk5OsnqbNWsm+1V/7Nix2LVrF6KiovD+/Xvs3bsXkyZNQvXq1RVuq0mTJjh58iRMTU1x+/ZtODo64v79+6hYsWKWfcsqLgBYvXo19u/fDxUVFTx8+BDOzs64ceOGNJ2/du3auHTpEkqVKoVHjx5h0KBB2L17t3SnkQxHjhzBqFGj8OzZM5QpUwaXL1/GgAED8nQb3ZMnT6JChQp4+vQphgwZglOnTkkLTyoSh6GhIQYMGCAtVJvBwsICN27cQL169fDs2TN07Ngx06/7RkZGuHHjBqpXr44nT56gTZs22Lt3r3T7WSB9AV0/Pz/MmzcPb968QVBQEDp37owzZ85IZRwcHHDq1CkkJSXh3LlzePXqVZZ9nTRpEg4fPiybRWNlZYVTp05BCIGQkJBMC/VWqlQJXbp0yVSXIrFndayVlRUGDBggjbEi/ctqLA8cOCBrKzeOjo6yBFBuDAwMMGDAAOnWuQBQpUoV2cKv5cuXl24zDGT9PCjSv+zGuF69evD394enpycePnyItLQ0/Pbbb5g8ebJUxsvLS6orNDQUK1euxPz586Vb8wLpr/GkpKRMd4kiIiIiIiLlpCLysthGxkEqKtK6Gaamprh//z6sra0RFxcHY2Nj2e3HclK5cmWMHj0ao0ePzmsI9Jn8/f3h5OSEwMBA2SKyhWnfvn0YOHAgoqKivkh7Wdm4cSOMjY3h5eWFVatWYfHixXj27FmRxUOFb/ny5XB1dUWzZs0UPiYmJgaGhobQX/sMKjpZL7CqiOjeFvk+loiIFJfxuR0dHZ3lzMD81te2mT801PN/HiAqyfYds8m9EFExkpdzwWffNaZ69epYuXIlJk2ahLVr12a6gwMVjatXr+Lhw4dZ7itdurTSroQ+aNCgog6BvrCxY8cWdQhERERERFSM5CsR0rFjR+n/582bh3bt2mHu3LkoVaoU/v77b4Xrye7SEfp8z549k90q9WOurq5wcXHJ8pKWwlS2bNk8Xc5R2LK7JIOIiIiIiIi+Xvm6NOZT79+/h7+/P+zt7WVrdxARlUS8NIaIqGThpTFEBY+XxlBJ80UvjQEAPT09uLm5FURVRERERERERESFJl93jSEiIiIiIiIiKokKZEYIEdHXKKi7eYFMsSYiopJp+15rngeIiL5CnBFCREREREREREqDiRAiIiIiIiIiUhpMhBARERERERGR0mAihIiIiIiIiIiUBhMhRERERERERKQ0mAghIiIiIiIiIqXBRAgRERERERERKQ0mQoiIiIiIiIhIaTARQkRERERERERKg4kQIiIiIiIiIlIaTIQQERERERERkdJgIoSIiIiIiIiIlAYTIURERERERESkNJgIISIiIiIiIiKlwUQIERERERERESkNJkKIiIiIiIiISGkwEUJERERERERESoOJECIiIiIiIiJSGkyEEBEREREREZHSYCKEiIiIiIiIiJQGEyFEREREREREpDSYCCEiIiIiIiIipcFECBEREREREREpDfWiDoCIqLiy3RkGFZ2Eog6jwEX3tijqEIiISoRenV5DQ/19UYdBJdS+YzZFHQIRZYMzQoiIiIiIiIhIaTARQkRERERERERKg4kQIiIiIiIiIlIaTIQQERERERERkdJgIoSIiIiIiIiIlAYTIURF5I8//sCmTZuUpl0iIiIiIqLigLfPJcqHyMhIjB8/XnpsbGwMV1dX9OnTB1paWgrVcebMGZiZmWHAgAGFFWaxapeIiIiIiKg44IwQonz48OEDNm3aBAsLC3h4eMDGxgZz585FvXr1kJiYWNThERERERERUTY4I4ToM7Rq1QotWrQAALRp0waurq44ePAgunbtirt372L79u2IiIiAi4sLBg8eDF1d3Wzr2rJlC06ePAkAMDExgbu7O7p37w4VFRWpzJIlS2BrawsjIyMcOXIElpaWmDZtGmJjY7Fp0yY8evQINjY26NWrF8qVK6dQH3Jr97///sPcuXMzHVexYkW4ubnh2LFjWLJkiWzf1q1b8fLlS0yfPj3TcbnFGhQUhL///hsBAQFwcXHBkCFDUKpUqTyPkbm5OU6cOIGUlBR07doV7u7uCo0HERERERF93TgjhKiAVK5cGSoqKggKCsLRo0fh5uaGd+/ewcnJCRs2bEDdunVznC1SoUIFeHh4wMPDA1ZWVpg+fToGDx4sK3PixAmMGzcOc+fOhaOjI2rUqAEhBDw8PLBr1y44OzsjOTkZnTt3xpMnTxSKO7d2DQwMpP0eHh5o2LAhDh48iCtXrqBChQpYtmwZbt68KZUXQmD69OnQ1NTM1FZusfr5+aFKlSq4fPkynJyc4O/vjy5duuR5jMaPH4+5c+fCxsYG8fHxaNiwIa5cuaLQeBARERER0deNM0KICsjRo0chhICrqytGjBiB0aNHY+nSpQCAIUOGwMHBAX/++adsbZGPubu7y2Yt9OjRAw4ODpg1axbs7e2l7dra2jhz5gw0NDQAAK9evcLNmzcRHBwMGxsbAMCkSZOQlJSkUNy5tWtubo6BAwdK+8eMGQNNTU38+eefKF26NJo3b44NGzagZs2aAICTJ08iODgY/fv3z9RWQEBAjrEOHjwYXbt2xYYNG6RjQkJC8jxGJiYmOHnyJNTU1AAAT58+xebNm7OdFZKYmChLUsXExOQ6bkRE9PXgeYCISLkwEUL0GX755Rds2bIF4eHhOHHiBIYOHYoqVarg6dOn6Nmzp1ROX18f7du3x7lz57JNhKSlpWH//v24fPkywsLCkJaWBnV1dTx+/Fj2Jb9Zs2ZSEgQALC0tYWZmhpkzZ2Ls2LFwcXGBjo4OdHR0FOqDou0CwPr167F27VqcOXMGpUuXBgB4e3tjxIgRWLJkCbS0tLBx40a0a9cOlpaWmdrKKdbnz5/jyZMn2Lhxo+yYjIRJXmL18PCQkiAAUKlSJbx69SrbMViwYAHmzJmj0HgREdHXh+cBIiLlwktjiD5DtWrV4OHhAW9vb9y/fx+rV69GWFgYAMDU1FRW1szMDKGhodnW1a9fP3z//ffQ1tZG3bp1pS/zsbGxsnJGRkayx6VKlcL58+chhJASEOPGjUN8fLxCfVC03UuXLmHkyJFYtWoV6tWrJ23v1KkT1NTUsG/fPkRFRWHv3r2ZLldRJNbIyEgAgIWFxWfHmrGmSAZVVVWkpqZmW+/UqVMRHR0t/QUGBmZbloiIvj48DxARKRfOCCH6DB8vlprBzs4OAPDy5UuUL19e2v7ixQtp36diYmKwfft2XLlyBbVr1waQfoveQYMGKRRHxjokAHD79m20b98eFhYWmDZtWo7HKdpucHAwOnfujJEjR8oukwEATU1N9OvXDxs2bMC7d+9gaGiItm3b5jnWIUOGAACePXsGR0fHfMeaH1paWgrf9piIiL4+PA8QESkXzgghKmCGhoZo0aIFfv31V2kWwqNHj3Do0CF07do1y2My7nqSMSsCAObPn69Qe69evYKfn5/0uHr16rC1tUVUVFSuxyrSbkJCAry8vODi4oLFixdnWY+3tzdOnjyJpUuXon///lBXzzrHmlOslpaWaNGiBX7++WfExcVJZY4dO6ZwrERERERERLnhjBCiQvDHH3+gZcuWqFq1KipUqIAzZ86gd+/esjugfExfXx9jx45F165d0apVK7x69QrJycnQ1tbOtS0tLS1MmTIFsbGxqFy5Mp4+fYqIiAiMHDky12MVaXf37t24fv06OnToIM3aANJvn5sx46RKlSqoW7cuLl68mO1lMYrEunHjRrRv3x4VK1aEu7s7nj59iubNm8PT0/OzxoiIiIiIiCiDihBCFHUQRCVNXFwcdu7cCU9PT1hbW2dZJiEhAefOnUNERARcXFzg6uoq23/27FloaWnJ1tu4ceMGnjx5AmtrazRs2BA7d+5EgwYNpEtqTpw4AWNjY7i5uWVq786dO3j8+DEsLCzQsGHDbGdl5LXdZ8+ewdfXN1M9lpaWaNOmjfR45MiRuHPnjmzGR3ZyijUtLQ0XL15EcHAwqlatCicnJ9mx+Rmjq1evIjY2Fs2bN881NiD9MhxDQ0Por30GFR19hY4pSaJ7Z78OCxFRSZTxuR0dHQ0DA4MCq69tM39oqH995wH6MvYds8m9EBEVmLycC5gIIaLPFhsbCzs7O/z222/o27dvUYfz2ZgIISIqWZgIoeKIiRCiLysv5wKuEUJEn2XixIlo2LAhHBwcZLcMJiIiIiIiKo64RggRfZa6deuifv368PT0zPZyHCIiIiIiouKC31qI6LNkdyccIiIiIiKi4oiXxhARERERERGR0mAihIiIiIiIiIiUBi+NISLKRlB38wK5+wAREZVM2/da8zxARPQV4owQIiIiIiIiIlIaTIQQERERERERkdJgIoSIiIiIiIiIlAYTIURERERERESkNJgIISIiIiIiIiKlwUQIERERERERESkNJkKIiIiIiIiISGkwEUJERERERERESoOJECIiIiIiIiJSGkyEEBEREREREZHSYCKEiIiIiIiIiJQGEyFEREREREREpDSYCCEiIiIiIiIipcFECBEREREREREpDSZCiIiIiIiIiEhpMBFCREREREREREqDiRAiIiIiIiIiUhpMhBARERERERGR0mAihIiIiIiIiIiUBhMhRERERERERKQ0mAghIiIiIiIiIqXBRAgRERERERERKQ0mQoiIiIiIiIhIaagXdQBERMWV7c4wqOgkfFYd0b0tCigaIiL60np1eg0N9fdFHQZ9ZN8xm6IOgYi+ApwRQkRERERERERKg4kQIiIiIiIiIlIaTIQQERER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5844d+6cbHHAvXv3YvLkybh58ybevHmD3bt344cffsixX506dYKBgYH0j+EFCxZgypQpCAkJweXLl9G1a1ecO3dOWuTw999/x/jx42VfOIhIufz5559o3Lgxrl+/DhcXF1y/fl221oOXl5dsHZFJkybhxIkTSE5OxrVr19C6dWvMnTtX2v/zzz9j48aNePnyJe7du4eff/4ZmzdvzjGGpUuX4pdffsGDBw8QFRWFo0eP4ttvv5WtWzF27FhcvHgR+vr6uHDhAtTU1LB///4cExNdu3aVzZgAgB49esj6p6amhh49esDc3DxPbeW3bkXGJ6u6AflzYW5ujitXrsDOzg7nzp2Do6Mjzpw5k+kc9Knc+nbkyBEsXboUTZo0AZB+Xtu2bRuCg4Px9u1bqKur48SJEzAxMcGhQ4dw5coVmJiYoEePHrIEA5A+A+X48eOIiorClStX0LdvX2zZsgXt2rXLNj4A6NevH+Li4nDq1CkAkM7RFhYWWZbX0dGR7c8o/+LFC+zbtw///vsvUlJScOjQIfz000+yY3///XdMnjxZdv6lL09FZHdBFxEREX0Vnj17hgoVKuDFixeZFiksCH5+fjh58iRmzZqVY7l3795h4sSJWLlyZba/5hHR101PTw9btmyBl5dXUYdCJHPixAncvn071x8APkdQUBB++uknrFy5Mss1b+jLYSKEiIjoK1fYiRAiIkUxEUJExQEvjSEiIvrKZXVJDRFRUcjuEgwioi+JM0KIiIiIiIiISGlwRggRERERERERKQ0mQoiIiIiIiIhIaTARQkRERERERERKg4kQIiIiIiIiIlIaTIQQERERERERkdJgIoSIiIiIiIiIlAYTIURERERERESkNJgIISIiIiIiIiKlwUQIERERERERESmN/wfEaYTBjmcIpwAAAABJRU5ErkJggg==",
      "text/plain": [
       "<Figure size 1100x360 with 2 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "import matplotlib.pyplot as plt\n",
    "fig, ax = plt.subplots(1, 2, figsize=(11, 3.6), sharey=True)\n",
    "bench[\"seconds\"].plot.barh(ax=ax[0], color=\"#0ea5e9\"); ax[0].set_xlabel(\"tiempo (s)\")\n",
    "bench[\"peak_mb\"].plot.barh(ax=ax[1], color=\"#4f46e5\"); ax[1].set_xlabel(\"pico de memoria extra (MB)\")\n",
    "ax[0].invert_yaxis(); plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0d2a0126",
   "metadata": {},
   "source": [
    "**Qué dicen los números.** Dos objetivos distintos, dos ganadores distintos:\n",
    "- **La velocidad** viene del formato del archivo y del motor. Leer un Parquet columnar con poda de columnas y\n",
    "  un filtro, o dejar que DuckDB o Polars ejecuten la consulta, es entre 6 y 20 veces más rápido que analizar el\n",
    "  CSV, porque nunca se lee ni se convierte desde texto nada irrelevante.\n",
    "- **La memoria** se ahorra guardando menos. Cargar el CSV entero con los tipos por defecto es, con diferencia,\n",
    "  la opción que más memoria consume. Pedir a pandas solo tres columnas con tipos compactos, o procesar por\n",
    "  bloques, reduce el pico entre cinco y seis veces, pero analizar texto sigue siendo lento.\n",
    "- La memoria extra que aparece para Parquet, Polars y DuckDB es sobre todo la carga de las propias\n",
    "  bibliotecas (los motores de Arrow y DuckDB ocupan decenas de MB cada uno); con este tamaño de datos ese coste\n",
    "  fijo domina. Se mantiene casi constante al crecer los datos, mientras que una carga completa con pandas\n",
    "  crece con el archivo.\n",
    "\n",
    "En la práctica: guarda los datos en Parquet, selecciona columnas y tipos de entrada, y deja que un motor de\n",
    "consultas (DuckDB o Polars) filtre y agregue; procesa por flujo cuando ni siquiera los datos seleccionados quepan."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d8e7634e",
   "metadata": {},
   "source": [
    "## 7. Más allá de una máquina: PySpark\n",
    "Cuando los datos ya no caben en una máquina (cientos de GB, muchos TB), o llegan continuamente desde muchas\n",
    "fuentes, la herramienta estándar es **Apache Spark**, que se usa desde Python con **PySpark**. Spark divide un\n",
    "conjunto de datos en **particiones** y las procesa en paralelo en muchos **ejecutores** (procesos de trabajo,\n",
    "a menudo en muchos servidores), coordinados por un **driver** (tu programa de Python). Como el modo diferido de\n",
    "Polars, nada se ejecuta hasta que pides un resultado: Spark primero construye un plan, y su optimizador\n",
    "(*Catalyst*) lo reescribe empujando los filtros y la selección de columnas hasta la lectura del archivo.\n",
    "\n",
    "| Concepto | Significado |\n",
    "|---|---|\n",
    "| `SparkSession` | el punto de entrada; se conecta a un clúster o lo ejecuta todo dentro de una máquina con `local[*]` |\n",
    "| DataFrame | una tabla distribuida con esquema, dividida en particiones |\n",
    "| transformación | `filter`, `select`, `groupBy`, `join`: diferida, solo añade pasos al plan |\n",
    "| acción | `show`, `count`, `collect`, `write`: ejecuta el plan |\n",
    "| gestor de clúster | de dónde salen los ejecutores: Kubernetes, YARN, Databricks, AWS EMR, Google Dataproc |\n",
    "\n",
    "El mismo código se ejecuta en un portátil en modo local y en un clúster de 100 nodos; solo cambia la\n",
    "configuración de la sesión. Spark se ejecuta en la máquina virtual de Java, así que necesita un entorno de\n",
    "ejecución de Java (versión 17 o posterior)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "fd54ba00",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:08:36.362880Z",
     "iopub.status.busy": "2026-10-18T10:08:36.362367Z",
     "iopub.status.idle": "2026-10-18T10:09:07.117240Z",
     "shell.execute_reply": "2026-10-18T10:09:07.113249Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "WARNING: Using incubator modules: jdk.incubator.vector\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "Using Spark's default log4j profile: org/apache/spark/log4j2-defaults.properties\n",
      "26/10/18 12:08:40 WARN Utils: Your hostname, patito-HP-Laptop-15-db0xxx, resolves to a loopback address: 127.0.1.1; using 192.168.0.16 instead (on interface wlo1)\n",
      "26/10/18 12:08:40 WARN Utils: Set SPARK_LOCAL_IP if you need to bind to another address\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "Using Spark's default log4j profile: org/apache/spark/log4j2-defaults.properties\n",
      "Setting default log level to \"WARN\".\n",
      "To adjust logging level use sc.setLogLevel(newLevel). For SparkR, use setLogLevel(newLevel).\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "26/10/18 12:08:51 WARN NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Spark 4.2.0\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 0:>                                                          (0 + 1) / 1]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "                                                                                \r"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "root\n",
      " |-- sensor_id: short (nullable = true)\n",
      " |-- temperature: float (nullable = true)\n",
      " |-- status: string (nullable = true)\n",
      " |-- site: string (nullable = true)\n",
      " |-- note: string (nullable = true)\n",
      "\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 1:>                                                          (0 + 4) / 4]\r"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "partitions: 4 | rows: 3,000,000\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "                                                                                \r"
     ]
    }
   ],
   "source": [
    "from pyspark.sql import SparkSession, functions as F, Window\n",
    "\n",
    "spark = (SparkSession.builder\n",
    "         .master(\"local[4]\")                                   # 4 worker threads on this machine\n",
    "         .appName(\"python1001-large-data\")\n",
    "         .config(\"spark.driver.memory\", \"2g\")\n",
    "         .config(\"spark.sql.shuffle.partitions\", \"8\")         # small data: few partitions after a shuffle\n",
    "         .config(\"spark.ui.enabled\", \"false\")\n",
    "         .getOrCreate())\n",
    "spark.sparkContext.setLogLevel(\"ERROR\")\n",
    "print(\"Spark\", spark.version)\n",
    "\n",
    "readings = spark.read.parquet(PARQUET)                          # lazy: only the schema is read\n",
    "readings.printSchema()\n",
    "print(\"partitions:\", readings.rdd.getNumPartitions(), \"| rows:\", f\"{readings.count():,}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1ccff34f",
   "metadata": {},
   "source": [
    "### Dos maneras de escribir la misma consulta\n",
    "Spark ofrece dos interfaces equivalentes, y los equipos las combinan libremente:\n",
    "\n",
    "1. **La API de DataFrame**: llamadas a métodos sobre el DataFrame, con expresiones de columna de\n",
    "   `pyspark.sql.functions` (`F.col`, `F.avg`, `F.when`, ...). Componible, comprobada por tu editor y fácil de\n",
    "   construir desde código.\n",
    "2. **SQL**: registra el DataFrame como una vista y ejecuta una consulta en texto con `spark.sql(\"...\")`.\n",
    "   Familiar para cualquiera que sepa SQL y cómodo para consultas analíticas largas.\n",
    "\n",
    "**Consulta 1:** temperatura media y número de lecturas `fault` por sitio."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "id": "8858e97b",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:09:07.124905Z",
     "iopub.status.busy": "2026-10-18T10:09:07.124323Z",
     "iopub.status.idle": "2026-10-18T10:09:10.552216Z",
     "shell.execute_reply": "2026-10-18T10:09:10.550048Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 4:>                                                          (0 + 4) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 4:==============>                                            (1 + 3) / 4]\r"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "+-------+---------+--------+\n",
      "|   site|mean_temp|n_faults|\n",
      "+-------+---------+--------+\n",
      "| site_0|   21.058|    1510|\n",
      "| site_1|   21.014|    1453|\n",
      "|site_10|   20.882|    1456|\n",
      "|site_11|   20.892|    1484|\n",
      "|site_12|   21.072|    1512|\n",
      "+-------+---------+--------+\n",
      "only showing top 5 rows\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "                                                                                \r"
     ]
    }
   ],
   "source": [
    "# 1) DataFrame API\n",
    "api_result = (readings\n",
    "              .filter(F.col(\"status\") == \"fault\")\n",
    "              .groupBy(\"site\")\n",
    "              .agg(F.round(F.avg(\"temperature\"), 3).alias(\"mean_temp\"), F.count(\"*\").alias(\"n_faults\"))\n",
    "              .orderBy(\"site\"))\n",
    "api_result.show(5)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "id": "930c515a",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:09:10.556636Z",
     "iopub.status.busy": "2026-10-18T10:09:10.556170Z",
     "iopub.status.idle": "2026-10-18T10:09:14.224580Z",
     "shell.execute_reply": "2026-10-18T10:09:14.222685Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 7:============================================>              (3 + 1) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "                                                                                \r"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "+-------+---------+--------+\n",
      "|   site|mean_temp|n_faults|\n",
      "+-------+---------+--------+\n",
      "| site_0|   21.058|    1510|\n",
      "| site_1|   21.014|    1453|\n",
      "|site_10|   20.882|    1456|\n",
      "|site_11|   20.892|    1484|\n",
      "|site_12|   21.072|    1512|\n",
      "+-------+---------+--------+\n",
      "only showing top 5 rows\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "resultados idénticos: True\n"
     ]
    }
   ],
   "source": [
    "# 2) SQL: the DataFrame becomes a temporary view that queries can refer to by name\n",
    "readings.createOrReplaceTempView(\"readings\")\n",
    "sql_result = spark.sql('''\n",
    "    SELECT site,\n",
    "           ROUND(AVG(temperature), 3) AS mean_temp,\n",
    "           COUNT(*)                   AS n_faults\n",
    "    FROM readings\n",
    "    WHERE status = 'fault'\n",
    "    GROUP BY site\n",
    "    ORDER BY site\n",
    "''')\n",
    "sql_result.show(5)\n",
    "print(\"resultados idénticos:\", api_result.collect() == sql_result.collect())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "528215cf",
   "metadata": {},
   "source": [
    "Las dos las compila el mismo optimizador en el mismo tipo de plan físico, así que ninguna es más rápida. El\n",
    "plan muestra la optimización en acción: el filtro sobre `status` y las tres columnas necesarias se empujan\n",
    "hasta la lectura del Parquet (`PushedFilters`, `ReadSchema`), de modo que las demás columnas nunca se leen."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "id": "4f2f25b4",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:09:14.229898Z",
     "iopub.status.busy": "2026-10-18T10:09:14.229390Z",
     "iopub.status.idle": "2026-10-18T10:09:14.254781Z",
     "shell.execute_reply": "2026-10-18T10:09:14.252769Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "== Physical Plan ==\n",
      "AdaptiveSparkPlan isFinalPlan=true\n",
      "+- == Final Plan ==\n",
      "   ResultQueryStage 2\n",
      "   +- *(3) Sort [site#3 ASC NULLS FIRST], true, 0\n",
      "      +- AQEShuffleRead coalesced\n",
      "         +- ShuffleQueryStage 1\n",
      "            +- Exchange rangepartitioning(site#3 ASC NULLS FIRST, 8), ENSURE_REQUIREMENTS, [plan_id=260]\n",
      "               +- *(2) HashAggregate(keys=[site#3], functions=[avg(temperature#1), count(1)])\n",
      "                  +- AQEShuffleRead coalesced\n",
      "                     +- ShuffleQueryStage 0\n",
      "                        +- Exchange hashpartitioning(site#3, 8), ENSURE_REQUIREMENTS, [plan_id=228]\n",
      "                           +- *(1) HashAggregate(keys=[site#3], functions=[partial_avg(temperature#1), partial_count(1)])\n",
      "                              +- *(1) Project [temperature#1, site#3]\n",
      "                                 +- *(1) Filter (isnotnull(status#2) AND (status#2 = fault))\n",
      "                                    +- *(1) ColumnarToRow\n",
      "                                       +- FileScan parquet [temperature#1,status#2,site#3] Batched: true, DataFilters: [isnotnull(status#2), (status#2 = fault)], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:/tmp/tmpnwrt3gxo/big.parquet], PartitionFilters: [], PushedFilters: [IsNotNull(status), EqualTo(status,fault)], ReadSchema: struct<temperature:float,status:string,site:string>\n",
      "+- == Initial Plan ==\n",
      "   Sort [site#3 ASC NULLS FIRST], true, 0\n",
      "   +- Exchange rangepartitioning(site#3 ASC NULLS FIRST, 8), ENSURE_REQUIREMENTS, [plan_id=202]\n",
      "      +- HashAggregate(keys=[site#3], functions=[avg(temperature#1), count(1)])\n",
      "         +- Exchange hashpartitioning(site#3, 8), ENSURE_REQUIREMENTS, [plan_id=199]\n",
      "            +- HashAggregate(keys=[site#3], functions=[partial_avg(temperature#1), partial_count(1)])\n",
      "               +- Project [temperature#1, site#3]\n",
      "                  +- Filter (isnotnull(status#2) AND (status#2 = fault))\n",
      "                     +- FileScan parquet [temperature#1,status#2,site#3] Batched: true, DataFilters: [isnotnull(status#2), (status#2 = fault)], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:/tmp/tmpnwrt3gxo/big.parquet], PartitionFilters: [], PushedFilters: [IsNotNull(status), EqualTo(status,fault)], ReadSchema: struct<temperature:float,status:string,site:string>\n",
      "\n",
      "\n",
      "== Physical Plan ==\n",
      "AdaptiveSparkPlan isFinalPlan=true\n",
      "+- == Final Plan ==\n",
      "   ResultQueryStage 2\n",
      "   +- *(3) Sort [site#3 ASC NULLS FIRST], true, 0\n",
      "      +- AQEShuffleRead coalesced\n",
      "         +- ShuffleQueryStage 1\n",
      "            +- Exchange rangepartitioning(site#3 ASC NULLS FIRST, 8), ENSURE_REQUIREMENTS, [plan_id=360]\n",
      "               +- *(2) HashAggregate(keys=[site#3], functions=[avg(temperature#1), count(1)])\n",
      "                  +- AQEShuffleRead coalesced\n",
      "                     +- ShuffleQueryStage 0\n",
      "                        +- Exchange hashpartitioning(site#3, 8), ENSURE_REQUIREMENTS, [plan_id=328]\n",
      "                           +- *(1) HashAggregate(keys=[site#3], functions=[partial_avg(temperature#1), partial_count(1)])\n",
      "                              +- *(1) Project [temperature#1, site#3]\n",
      "                                 +- *(1) Filter (isnotnull(status#2) AND (status#2 = fault))\n",
      "                                    +- *(1) ColumnarToRow\n",
      "                                       +- FileScan parquet [temperature#1,status#2,site#3] Batched: true, DataFilters: [isnotnull(status#2), (status#2 = fault)], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:/tmp/tmpnwrt3gxo/big.parquet], PartitionFilters: [], PushedFilters: [IsNotNull(status), EqualTo(status,fault)], ReadSchema: struct<temperature:float,status:string,site:string>\n",
      "+- == Initial Plan ==\n",
      "   Sort [site#3 ASC NULLS FIRST], true, 0\n",
      "   +- Exchange rangepartitioning(site#3 ASC NULLS FIRST, 8), ENSURE_REQUIREMENTS, [plan_id=302]\n",
      "      +- HashAggregate(keys=[site#3], functions=[avg(temperature#1), count(1)])\n",
      "         +- Exchange hashpartitioning(site#3, 8), ENSURE_REQUIREMENTS, [plan_id=299]\n",
      "            +- HashAggregate(keys=[site#3], functions=[partial_avg(temperature#1), partial_count(1)])\n",
      "               +- Project [temperature#1, site#3]\n",
      "                  +- Filter (isnotnull(status#2) AND (status#2 = fault))\n",
      "                     +- FileScan parquet [temperature#1,status#2,site#3] Batched: true, DataFilters: [isnotnull(status#2), (status#2 = fault)], Format: Parquet, Location: InMemoryFileIndex(1 paths)[file:/tmp/tmpnwrt3gxo/big.parquet], PartitionFilters: [], PushedFilters: [IsNotNull(status), EqualTo(status,fault)], ReadSchema: struct<temperature:float,status:string,site:string>\n",
      "\n",
      "\n"
     ]
    }
   ],
   "source": [
    "api_result.explain()          # the DataFrame API version\n",
    "sql_result.explain()          # the SQL version: the same operators"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "43959b1f",
   "metadata": {},
   "source": [
    "**Consulta 2:** algo más completo. Por sitio, el porcentaje de lecturas en cada estado y los tres sensores con\n",
    "la temperatura media más alta, usando una columna derivada, una tabla dinámica (pivot), una unión (join) y\n",
    "una **función de ventana**."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 15,
   "id": "862559a2",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:09:14.263923Z",
     "iopub.status.busy": "2026-10-18T10:09:14.262784Z",
     "iopub.status.idle": "2026-10-18T10:09:21.403714Z",
     "shell.execute_reply": "2026-10-18T10:09:21.401754Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 26:>                 (0 + 4) / 4][Stage 27:>                 (0 + 0) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 26:====>             (1 + 3) / 4][Stage 27:>                 (0 + 1) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 27:>                                                         (0 + 4) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 27:>                 (0 + 4) / 4][Stage 29:>                 (0 + 0) / 1]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 27:====>             (1 + 3) / 4][Stage 29:>                 (0 + 1) / 1]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 27:===========================================>              (3 + 1) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "                                                                                \r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 34:>                                                         (0 + 1) / 1]\r"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "+------+---------+------------------+------------+----+------+-----------+---------+\n",
      "|  site|sensor_id|             avg_t|hot_readings|rank|pct_ok|pct_warning|pct_fault|\n",
      "+------+---------+------------------+------------+----+------+-----------+---------+\n",
      "|site_0|      195|22.334422131518384|          14|   1| 89.92|       8.06|     2.02|\n",
      "|site_0|      191|22.060088488459588|          11|   2| 89.92|       8.06|     2.02|\n",
      "|site_0|      217|21.775642860226515|           8|   3| 89.92|       8.06|     2.02|\n",
      "|site_1|      313|   22.034557895806|          12|   1| 90.11|       7.96|     1.94|\n",
      "|site_1|      228|21.965595207214356|           8|   2| 90.11|       7.96|     1.94|\n",
      "|site_1|      107|21.913695235925218|          12|   3| 90.11|       7.96|     1.94|\n",
      "+------+---------+------------------+------------+----+------+-----------+---------+\n",
      "only showing top 6 rows\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "                                                                                \r"
     ]
    }
   ],
   "source": [
    "# DataFrame API\n",
    "flagged = readings.withColumn(\"is_hot\", F.when(F.col(\"temperature\") > 28, 1).otherwise(0))\n",
    "status_share = (flagged.groupBy(\"site\").pivot(\"status\", [\"ok\", \"warning\", \"fault\"]).count()\n",
    "                .withColumn(\"total\", F.col(\"ok\") + F.col(\"warning\") + F.col(\"fault\"))\n",
    "                .select(\"site\", *[F.round(F.col(s) / F.col(\"total\") * 100, 2).alias(f\"pct_{s}\") for s in [\"ok\", \"warning\", \"fault\"]]))\n",
    "by_sensor = flagged.groupBy(\"site\", \"sensor_id\").agg(F.avg(\"temperature\").alias(\"avg_t\"), F.sum(\"is_hot\").alias(\"hot_readings\"))\n",
    "rank_w = Window.partitionBy(\"site\").orderBy(F.col(\"avg_t\").desc())\n",
    "top3_api = (by_sensor.withColumn(\"rank\", F.row_number().over(rank_w)).filter(F.col(\"rank\") <= 3)\n",
    "            .join(F.broadcast(status_share), \"site\")                     # small table: send it to every executor\n",
    "            .orderBy(\"site\", \"rank\"))\n",
    "top3_api.show(6)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 16,
   "id": "ddc24d24",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:09:21.407982Z",
     "iopub.status.busy": "2026-10-18T10:09:21.407536Z",
     "iopub.status.idle": "2026-10-18T10:09:31.919170Z",
     "shell.execute_reply": "2026-10-18T10:09:31.916620Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 38:>                                                         (0 + 4) / 4]\r",
      "\r",
      "[Stage 38:>                 (0 + 4) / 4][Stage 39:>                 (0 + 0) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 38:====>             (1 + 3) / 4][Stage 39:>                 (0 + 1) / 4]\r",
      "\r",
      "[Stage 38:=============>    (3 + 1) / 4][Stage 39:>                 (0 + 3) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 39:>                                                         (0 + 4) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "                                                                                \r"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "+------+---------+------------------+------------+----+------+-----------+---------+\n",
      "|  site|sensor_id|             avg_t|hot_readings|rank|pct_ok|pct_warning|pct_fault|\n",
      "+------+---------+------------------+------------+----+------+-----------+---------+\n",
      "|site_0|      195|22.334422131518384|          14|   1| 89.92|       8.06|     2.02|\n",
      "|site_0|      191|22.060088488459588|          11|   2| 89.92|       8.06|     2.02|\n",
      "|site_0|      217|21.775642860226515|           8|   3| 89.92|       8.06|     2.02|\n",
      "|site_1|      313|   22.034557895806|          12|   1| 90.11|       7.96|     1.94|\n",
      "|site_1|      228|21.965595207214356|           8|   2| 90.11|       7.96|     1.94|\n",
      "|site_1|      107|21.913695235925218|          12|   3| 90.11|       7.96|     1.94|\n",
      "+------+---------+------------------+------------+----+------+-----------+---------+\n",
      "only showing top 6 rows\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 47:=============>    (3 + 1) / 4][Stage 48:>                 (0 + 3) / 4]\r",
      "\r",
      "[Stage 48:>                                                         (0 + 4) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 48:>                 (0 + 4) / 4][Stage 50:>                 (0 + 0) / 1]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 48:==============>                                           (1 + 3) / 4]\r",
      "\r",
      "[Stage 48:=============================>                            (2 + 2) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "                                                                                \r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 66:>                                                         (0 + 4) / 4]\r",
      "\r",
      "[Stage 66:====>             (1 + 3) / 4][Stage 67:>                 (0 + 1) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 66:=============>    (3 + 1) / 4][Stage 67:>                 (0 + 3) / 4]\r",
      "\r",
      "[Stage 67:>                                                         (0 + 4) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 67:=============================>                            (2 + 2) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "                                                                                \r"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "mismas filas: True\n"
     ]
    }
   ],
   "source": [
    "# SQL: the same logic, with CTEs, CASE WHEN, conditional aggregation and ROW_NUMBER() OVER (...)\n",
    "top3_sql = spark.sql('''\n",
    "    WITH flagged AS (\n",
    "        SELECT *, CASE WHEN temperature > 28 THEN 1 ELSE 0 END AS is_hot FROM readings\n",
    "    ),\n",
    "    status_share AS (\n",
    "        SELECT site,\n",
    "               ROUND(100 * AVG(CASE WHEN status = 'ok'      THEN 1 ELSE 0 END), 2) AS pct_ok,\n",
    "               ROUND(100 * AVG(CASE WHEN status = 'warning' THEN 1 ELSE 0 END), 2) AS pct_warning,\n",
    "               ROUND(100 * AVG(CASE WHEN status = 'fault'   THEN 1 ELSE 0 END), 2) AS pct_fault\n",
    "        FROM flagged GROUP BY site\n",
    "    ),\n",
    "    ranked AS (\n",
    "        SELECT site, sensor_id, AVG(temperature) AS avg_t, SUM(is_hot) AS hot_readings,\n",
    "               ROW_NUMBER() OVER (PARTITION BY site ORDER BY AVG(temperature) DESC) AS rank\n",
    "        FROM flagged GROUP BY site, sensor_id\n",
    "    )\n",
    "    SELECT r.*, s.pct_ok, s.pct_warning, s.pct_fault\n",
    "    FROM ranked r JOIN status_share s USING (site)\n",
    "    WHERE rank <= 3\n",
    "    ORDER BY site, rank\n",
    "''')\n",
    "top3_sql.show(6)\n",
    "cols = [\"site\", \"sensor_id\", \"rank\", \"pct_fault\"]\n",
    "print(\"mismas filas:\", top3_api.select(cols).collect() == top3_sql.select(cols).collect())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3502e2c0",
   "metadata": {},
   "source": [
    "### Sacar resultados y escribir datos\n",
    "`toPandas()` trae un resultado al driver como un DataFrame de pandas: hazlo solo con resultados **pequeños**,\n",
    "nunca con los datos en bruto. Las salidas grandes se escriben de vuelta al almacenamiento, normalmente en\n",
    "Parquet particionado por una columna, para que las consultas posteriores puedan saltarse carpetas enteras."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 17,
   "id": "26c538f7",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:09:31.928069Z",
     "iopub.status.busy": "2026-10-18T10:09:31.926933Z",
     "iopub.status.idle": "2026-10-18T10:09:38.522952Z",
     "shell.execute_reply": "2026-10-18T10:09:38.520713Z"
    }
   },
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 82:>                 (0 + 4) / 4][Stage 83:>                 (0 + 0) / 4]\r",
      "\r",
      "[Stage 82:====>             (1 + 3) / 4][Stage 83:>                 (0 + 1) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 82:=============>    (3 + 1) / 4][Stage 83:====>             (1 + 3) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "                                                                                \r"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "DataFrame (120, 8)\n"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 98:>                                                         (0 + 4) / 4]\r"
     ]
    },
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "\r",
      "[Stage 98:===========================================>              (3 + 1) / 4]\r",
      "\r",
      "                                                                                \r"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "['status=fault', 'status=warning']\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "+-------+------+\n",
      "| status|     n|\n",
      "+-------+------+\n",
      "|  fault| 59835|\n",
      "|warning|240089|\n",
      "+-------+------+\n",
      "\n"
     ]
    }
   ],
   "source": [
    "small = top3_sql.toPandas()                       # fine: a few hundred rows\n",
    "print(type(small).__name__, small.shape)\n",
    "\n",
    "out = os.path.join(tmp, \"spark_out\")\n",
    "(readings.filter(F.col(\"status\") != \"ok\")\n",
    "         .write.mode(\"overwrite\").partitionBy(\"status\").parquet(out))\n",
    "print(sorted(d for d in os.listdir(out) if not d.startswith((\"_\", \".\"))))\n",
    "# SQL can also query files directly, without registering a view first\n",
    "spark.sql(f\"SELECT status, COUNT(*) AS n FROM parquet.`{out}` GROUP BY status ORDER BY status\").show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "91f8f093",
   "metadata": {},
   "source": [
    "### ¿Es Spark más rápido aquí?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 18,
   "id": "5cf7000f",
   "metadata": {
    "execution": {
     "iopub.execute_input": "2026-10-18T10:09:38.534158Z",
     "iopub.status.busy": "2026-10-18T10:09:38.533255Z",
     "iopub.status.idle": "2026-10-18T10:09:39.177768Z",
     "shell.execute_reply": "2026-10-18T10:09:39.175924Z"
    }
   },
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "consulta 1 con ambos motores ya en marcha: Spark 0.10 s, DuckDB 0.05 s\n"
     ]
    }
   ],
   "source": [
    "import time\n",
    "t0 = time.perf_counter(); api_result.collect(); t_spark = time.perf_counter() - t0\n",
    "t0 = time.perf_counter()\n",
    "duckdb.sql(f\"select site, avg(temperature), count(*) from '{PARQUET}' where status = 'fault' group by site\").df()\n",
    "t_duck = time.perf_counter() - t0\n",
    "print(f\"consulta 1 con ambos motores ya en marcha: Spark {t_spark:.2f} s, DuckDB {t_duck:.2f} s\")\n",
    "spark.stop()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7f314736",
   "metadata": {},
   "source": [
    "En una sola máquina y con unos pocos millones de filas, no: Spark planifica la consulta, reparte tareas y mueve\n",
    "datos entre ellas en un proceso de Java aparte, costes que evitan los motores de un solo nodo como DuckDB y\n",
    "Polars. Spark compensa cuando los datos o el trabajo son demasiado grandes para una máquina, cuando los datos ya\n",
    "viven en un data lake o un almacén de datos que Spark lee de forma nativa, o en flujos de datos en tiempo real.\n",
    "Un camino sensato: pandas, después Polars o DuckDB, después Spark.\n",
    "\n",
    "| Herramienta | API de funciones | Estilo SQL |\n",
    "|---|---|---|\n",
    "| **PySpark** | `df.filter(F.col(\"x\") > 1).groupBy(\"k\").agg(F.avg(\"v\"))` | `df.createOrReplaceTempView(\"t\")` y después `spark.sql(\"SELECT k, AVG(v) FROM t WHERE x > 1 GROUP BY k\")` |\n",
    "| **Polars** | `df.filter(pl.col(\"x\") > 1).group_by(\"k\").agg(pl.col(\"v\").mean())` | `df.sql(\"SELECT k, AVG(v) FROM self WHERE x > 1 GROUP BY k\")` |\n",
    "| **DuckDB** | API relacional: `duckdb.table(\"t\").filter(\"x > 1\").aggregate(\"k, avg(v)\")` | `duckdb.sql(\"SELECT ...\")`, que también puede consultar DataFrames de pandas por su nombre |\n",
    "| **pandas** | `df[df.x > 1].groupby(\"k\")[\"v\"].mean()` | `df.query(\"x > 1\")` recibe una expresión de filtro, no SQL completo |\n",
    "\n",
    "Spark también ofrece una API parecida a pandas (`import pyspark.pandas as ps`) para equipos que llevan código\n",
    "pandas a un clúster."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "db24f840",
   "metadata": {},
   "source": [
    "## Lista de comprobación para procesar con memoria limitada\n",
    "1. **Mide primero**: `memory_usage(deep=True)`, `psutil` o el pico de una ejecución de prueba.\n",
    "2. **Guarda en Parquet**, no en CSV, y lee solo las columnas y filas necesarias.\n",
    "3. **Usa los tipos adecuados**: `category` para cadenas repetidas, `float32`/enteros pequeños donde la precisión lo permita.\n",
    "4. **Procesa por flujo** cuando el resultado sea pequeño: bloques, generadores o un motor diferido.\n",
    "5. **Borra lo que ya no necesites** (`del df`) y evita copias accidentales.\n",
    "6. **Deja el trabajo a un motor de consultas** (DuckDB, Polars) cuando pandas lo cargaría todo.\n",
    "7. Solo entonces piensa en más RAM o en un clúster (Spark, sección 7).\n",
    "\n",
    "## Ejercicios\n",
    "1. Repite la comparativa agrupando por `sensor_id`. ¿Cambia la clasificación?\n",
    "2. Añade un enfoque con `pyarrow.dataset` y una expresión de filtro, y compáralo.\n",
    "3. Usa `pl.scan_csv` en lugar de `scan_parquet`: ¿cuánta ventaja de Polars viene del formato del archivo?\n",
    "4. En PySpark, encuentra la lectura más caliente de cada sitio dos veces: con `Window` y `F.max`, y en SQL con `MAX(...) OVER (PARTITION BY site)`. Comprueba que ambas dan las mismas filas."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.12.3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
