{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "04cc22ce",
   "metadata": {},
   "source": [
    "\n",
    "<a id='chap-tpvisualisationdonnees'></a>"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fd8ed6db",
   "metadata": {},
   "source": [
    "# Travaux pratiques - Visualisation de données volumineuses\n",
    "\n",
    "Références externes utiles :\n",
    "\n",
    "- [Documentation Spark](https://spark.apache.org/docs/latest/)  \n",
    "- [Démarrage avec les DataFrames Spark en Python](https://spark.apache.org/docs/latest/api/python/getting_started/quickstart_df.html)  \n",
    "- [Documentation API Spark en Python](https://spark.apache.org/docs/latest/api/python/index.html)  \n",
    "- [Tutoriel Spark avec exemples](https://sparkbyexamples.com/pyspark-tutorial/)  \n",
    "- [Site langage Python](https://www.python.org/)  "
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4cfaac59",
   "metadata": {},
   "source": [
    "## Objectif du TP\n",
    "\n",
    "Ce TP aborde un problème concret rencontré dès que l’on travaille sur des\n",
    "données massives : comment visualiser plusieurs **millions d’observations ?**\n",
    "Un *scatterplot* naïf sur 3 millions de points produit une image illisible ;\n",
    "calculer un histogramme ou une *heatmap* avec Pandas sur un tel volume est lent et\n",
    "consommateur de mémoire.\n",
    "\n",
    "Nous mettons en place une chaîne de traitement améliorée pour ce contexte :\n",
    "\n",
    "1. **PySpark** : chargement et préparation des données distribuées ;  \n",
    "1. **sparkpl** : conversion de Spark vers Polars avec Apache Arrow, sans passer par\n",
    "  Pandas ;  \n",
    "1. **Polars** : filtrage, agrégation et transformations rapides en mémoire ;  \n",
    "1. **Altair** : visualisations déclaratives (avec *backend* natif Polars) ;  \n",
    "1. **hvPlot + Datashader** : rendu de millions de points sans *overplotting*.  \n",
    "\n",
    "\n",
    "Les données utilisées sont les trajets de taxis jaunes new-yorkais (NYC TLC,\n",
    "janvier 2024) : un fichier Parquet public d’environ 50 Mo contenant\n",
    "~2,9 millions de courses.\n",
    "\n",
    ">**Note**\n",
    ">\n",
    ">**Choix des outils : pourquoi cette chaîne ?**\n",
    "\n",
    "La pratique courante dans les *notebooks* Spark consiste à appeler\n",
    "`.toPandas()` pour passer à la visualisation. Cette approche a deux\n",
    "inconvénients : elle introduit une dépendance à Pandas (lourd, mémoire\n",
    "moins bien gérée) et peut être lente sur de grands volumes. **sparkpl**\n",
    "utilise la conversion Arrow native de Spark (format colonnes en mémoire)\n",
    "pour produire directement un DataFrame Polars, plus rapide et sans\n",
    "surcoût mémoire intermédiaire. Polars est lui-même écrit en Rust avec un\n",
    "moteur d’exécution (locale) parallèle : les agrégations et filtres sont\n",
    "plus rapides que leurs équivalents Pandas sur des volumes similaires.\n",
    "\n",
    "**Altair** (v5+) supporte Polars nativement sans conversion. C’est\n",
    "le backend de visualisation par défaut de Polars depuis la v 1.6.\n",
    "**hvPlot** accepte également Polars (conversion Arrow\n",
    "interne) et, couplé à **Datashader**, résout le problème de l”**overplotting**\n",
    "en agrégeant les points en pixels avant le rendu, ce qui permet\n",
    "d’afficher des millions de points en quelques secondes dans un *notebook*."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "aa86df58",
   "metadata": {},
   "source": [
    "## Partie 1 : Mise en place et chargement des données"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "370202df",
   "metadata": {},
   "source": [
    "### Installation des bibliothèques\n",
    "\n",
    "(vérifie d’abord si elles sont déjà présentes)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f0b61208",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "import sys\n",
    "!{sys.executable} -m pip install sparkpl==2.0.1 polars altair hvplot datashader"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7effc1bf",
   "metadata": {},
   "source": [
    "### Initialisation de Spark"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f22f6afb",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "import os\n",
    "os.environ['PYSPARK_PYTHON'] = sys.executable\n",
    "os.environ['PYSPARK_DRIVER_PYTHON'] = sys.executable"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6ab85cb9",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "from pyspark.sql import SparkSession\n",
    "\n",
    "spark = (SparkSession.builder\n",
    "         .appName(\"tpVisualisationTaxisNYC\")\n",
    "         .config(\"spark.sql.execution.arrow.pyspark.enabled\", \"true\")\n",
    "         .getOrCreate())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "017af595",
   "metadata": {},
   "source": [
    ">**Note**\n",
    ">\n",
    ">L’option `spark.sql.execution.arrow.pyspark.enabled` active le transfert\n",
    "Arrow entre Spark et Python. C’est ce qui permet à sparkpl d’éviter la\n",
    "sérialisation ligne par ligne et de produire un DataFrame Polars en quelques\n",
    "secondes plutôt qu’en plusieurs minutes."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5b24efa0",
   "metadata": {},
   "source": [
    "### Téléchargement du fichier Parquet\n",
    "\n",
    "Le site NYC TLC publie les données mensuellement. Nous utilisons d’abord les courses\n",
    "de janvier 2024, soit ~2,9 millions de courses."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "57cdfee0",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "import os\n",
    "\n",
    "dossier    = \"tpVisualisationDonnees/nyc_taxi/\"\n",
    "fichier_pq = dossier + \"yellow_tripdata_2024-01.parquet\"\n",
    "url        = \"https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_2024-01.parquet\"\n",
    "\n",
    "os.makedirs(dossier, exist_ok=True)\n",
    "if not os.path.exists(fichier_pq):\n",
    "    print(\"Téléchargement en cours...\")\n",
    "    rc = os.system(f\"wget -q -nc {url} -P {dossier}\")\n",
    "    print(\"OK\" if rc == 0 else \"Erreur de téléchargement\")\n",
    "else:\n",
    "    print(\"Fichier déjà présent.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "895474e3",
   "metadata": {},
   "source": [
    "### Chargement dans Spark"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "96ceecf1",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "sdf = spark.read.parquet(fichier_pq)\n",
    "print(f\"Lignes   : {sdf.count():,}\")\n",
    "print(f\"Colonnes : {len(sdf.columns)}\")\n",
    "print(f\"Schéma DataFrame :\")\n",
    "sdf.printSchema()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7534aabb",
   "metadata": {},
   "source": [
    "Parmi les colonnes on peut mentionner :\n",
    "\n",
    "- `tpep_pickup_datetime`, `tpep_dropoff_datetime` : horodatages de début\n",
    "  et fin de course ;  \n",
    "- `passenger_count` : nombre de passagers ;  \n",
    "- `trip_distance` : distance en miles ;  \n",
    "- `PULocationID`, `DOLocationID` : identifiants de zone TLC de départ et\n",
    "  d’arrivée (265 zones couvrant les 5 *boroughs* de NYC) ;  \n",
    "- `payment_type` : mode de paiement (1 = carte, 2 = espèces, …) ;  \n",
    "- `fare_amount`, `tip_amount`, `total_amount` : montants en dollars.  "
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dd12d34f",
   "metadata": {},
   "source": [
    "### Question\n",
    "\n",
    "Affichez les 25 premières lignes avec `sdf.show(25, truncate=False)`.\n",
    "Repérez les colonnes contenant des valeurs manquantes ou nulles."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5ea7c777",
   "metadata": {},
   "source": [
    "### Préparation dans Spark\n",
    "\n",
    "Avant de transférer les données vers Polars, on effectue le nettoyage et les\n",
    "transformations coûteuses côté Spark, là où le calcul est distribué."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "316e8182",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "from pyspark.sql import functions as F\n",
    "\n",
    "sdf_clean = (sdf\n",
    "    # Garder uniquement les courses valides\n",
    "    .filter(F.col(\"trip_distance\")  >  0)\n",
    "    .filter(F.col(\"trip_distance\")  <= 100)          # éliminer les outliers\n",
    "    .filter(F.col(\"fare_amount\")    >  0)\n",
    "    .filter(F.col(\"fare_amount\")    <= 500)\n",
    "    .filter(F.col(\"passenger_count\").between(1, 6))\n",
    "    .filter(F.col(\"tpep_pickup_datetime\").isNotNull())\n",
    "    # Dériver quelques colonnes utiles\n",
    "    .withColumn(\"pickup_hour\",\n",
    "                F.hour(\"tpep_pickup_datetime\"))\n",
    "    .withColumn(\"pickup_dow\",                         # jour de semaine (1 = Dimanche ?)\n",
    "                F.dayofweek(\"tpep_pickup_datetime\"))\n",
    "    .withColumn(\"duration_min\",\n",
    "                (F.unix_timestamp(\"tpep_dropoff_datetime\") -\n",
    "                 F.unix_timestamp(\"tpep_pickup_datetime\")) / 60)\n",
    "    .withColumn(\"speed_mph\",\n",
    "                F.col(\"trip_distance\") / (F.col(\"duration_min\") / 60))\n",
    "    .filter(F.col(\"duration_min\") > 0)\n",
    "    .filter(F.col(\"speed_mph\") <= 80)                # vitesse physiquement plausible\n",
    "    .select(\"pickup_hour\", \"pickup_dow\", \"passenger_count\",\n",
    "            \"trip_distance\", \"fare_amount\", \"tip_amount\", \"total_amount\",\n",
    "            \"duration_min\", \"speed_mph\",\n",
    "            \"PULocationID\", \"DOLocationID\", \"payment_type\")\n",
    ")\n",
    "\n",
    "print(f\"Lignes après nettoyage : {sdf_clean.count():,}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3f517fc0",
   "metadata": {},
   "source": [
    "### Question\n",
    "\n",
    "Quel pourcentage de lignes ont été éliminées par le nettoyage ?\n",
    "Ces suppressions vous semblent-elles justifiées ?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b22fa089",
   "metadata": {},
   "source": [
    "### Conversion de Spark vers Polars avec `sparkpl`"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f30e7fb9",
   "metadata": {},
   "source": [
    "### Note technique\n",
    "\n",
    "A partir de `sparkpl` v2.0.0 (qui exige `pyspark` v > 4.0.0…),\n",
    "la conversion de Spark à Polars est directe :\n",
    "`spark_to_polars` appelle `sdf_clean.toArrow()` pour\n",
    "obtenir un `pyarrow.Table` en mémoire partagée, puis\n",
    "`polars.from_arrow()` pour construire le DataFrame Polars. Aucune copie\n",
    "de données n’a lieu, les deux représentations pointent vers le **même buffer\n",
    "mémoire**. C’est ce qu’on appelle une conversion *zero-copy*.\n",
    "\n",
    "Mesurons le temps d’exécution de la conversion vers Polars et ensuite le temps de la conversion de Spark vers Pandas :"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ab7f42ec",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "from sparkpl.converter import spark_to_polars\n",
    "import time\n",
    "\n",
    "sdf_clean.cache()\n",
    "sdf_clean.count()   # déclenche le calcul et remplit le cache\n",
    "\n",
    "t0 = time.time()\n",
    "pdf2 = sdf_clean.toPandas()\n",
    "print(f\"Spark to Pandas : {time.time()-t0:.2f} s\")\n",
    "\n",
    "t0 = time.time()\n",
    "pdf = spark_to_polars(sdf_clean)\n",
    "print(f\"Spark to Polars : {time.time()-t0:.2f} s\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "554617e5",
   "metadata": {},
   "source": [
    "`sdf_clean.count()` sert à charger le DataFrame Spark en mémoire avant de faire la conversion, sinon le temps de lecture depuis le disque risque d’être dominant dans les résultats."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "19c5b4a3",
   "metadata": {},
   "source": [
    "## Partie 2 : Le problème **overplotting**\n",
    "\n",
    "Avant d’utiliser des outils spécialisés, illustrons d’abord le problème\n",
    "que pose la visualisation directe de millions de points."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2fe7419b",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "import matplotlib.pyplot as plt\n",
    "\n",
    "# Scatterplot direct : fare_amount vs trip_distance\n",
    "# ATTENTION : cette cellule peut être lente et produire une image illisible\n",
    "sample = pdf.sample(fraction=0.05, seed=42)   # 5 % = ~135 000 points\n",
    "plt.figure(figsize=(7, 5))\n",
    "plt.scatter(sample[\"trip_distance\"].to_numpy(),\n",
    "            sample[\"fare_amount\"].to_numpy(),\n",
    "            alpha=0.05, s=1, color=\"steelblue\")\n",
    "plt.xlabel(\"Distance (miles)\")\n",
    "plt.ylabel(\"Tarif (USD)\")\n",
    "plt.title(f\"Scatterplot Matplotlib — {len(sample):,} points (5 % du corpus)\")\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5bf5ae4c",
   "metadata": {},
   "source": [
    "## Question\n",
    "\n",
    "Que constatez-vous ? L’image vous semble-t-elle lisible ? Que se\n",
    "passerait-il si l’on utilisait la totalité des 2,7 millions de points ?\n",
    "Réessayez avec `fraction=1.0` et observez le temps de rendu."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "021ee3d3",
   "metadata": {},
   "source": [
    "## Partie 3 : Visualisation avec Altair\n",
    "\n",
    "Altair adopte une approche **déclarative** : on décrit **ce** qu’on souhaite\n",
    "visualiser (quelles colonnes, quels encodages visuels) sans décrire *comment* le\n",
    "le dessiner. La bibliothèque génère une spécification Vega-Lite (JSON) que\n",
    "le navigateur interprète pour produire un graphique SVG interactif."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d47aed28",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "import altair as alt\n",
    "\n",
    "# Altair accepte directement un DataFrame Polars (sans conversion)\n",
    "alt.data_transformers.enable(\"default\", max_rows=None)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b80f91cf",
   "metadata": {},
   "source": [
    ">**Note**\n",
    ">\n",
    ">Altair a une limite en nombre de lignes, c’est pourquoi nous utilisons `sample`\n",
    "(les données sont embarquées en JSON dans le notebook). Pour les agrégations\n",
    "(histogrammes, heatmaps), on ne dépasse jamais cette limite car on travaille sur des\n",
    "résumés, pas sur les données brutes. Pour les **scatterplots** sur données\n",
    "brutes on doit échantillonner explicitement."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "23a224ca",
   "metadata": {},
   "source": [
    "### Distribution horaire des courses"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e565c017",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "chart_heures = (\n",
    "    alt.Chart(sample)\n",
    "    .mark_bar()\n",
    "    .encode(\n",
    "        x=alt.X(\"pickup_hour:O\",\n",
    "                title=\"Heure de départ\",\n",
    "                axis=alt.Axis(labelAngle=0)),\n",
    "        y=alt.Y(\"count():Q\",\n",
    "                title=\"Nombre de courses\"),\n",
    "        color=alt.Color(\"pickup_hour:O\",\n",
    "                        scale=alt.Scale(scheme=\"blues\"),\n",
    "                        legend=None),\n",
    "        tooltip=[\"pickup_hour:O\", \"count():Q\"]\n",
    "    )\n",
    "    .properties(\n",
    "        title=\"Distribution horaire des courses (janvier 2024)\",\n",
    "        width=600, height=300\n",
    "    )\n",
    "    .interactive()\n",
    ")\n",
    "chart_heures"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "67c2ea1e",
   "metadata": {},
   "source": [
    "### Question\n",
    "\n",
    "Identifiez les heures creuses et les heures de pointe. Y a-t-il un\n",
    "creux nocturne marqué ? Ces résultats correspondent-ils à votre\n",
    "intuition ?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "92f64c85",
   "metadata": {},
   "source": [
    "### Distribution par jour de la semaine"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cc488961",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "import polars as pl\n",
    "\n",
    "jours = {1: \"Dim\", 2: \"Lun\", 3: \"Mar\", 4: \"Mer\",\n",
    "         5: \"Jeu\", 6: \"Ven\", 7: \"Sam\"}\n",
    "\n",
    "pdf_dow = sample.with_columns(\n",
    "    pl.col(\"pickup_dow\")\n",
    "      .replace_strict(jours, return_dtype=pl.Utf8)\n",
    "      .alias(\"jour\")\n",
    ")\n",
    "\n",
    "chart_dow = (\n",
    "    alt.Chart(pdf_dow)\n",
    "    .mark_bar()\n",
    "    .encode(\n",
    "        x=alt.X(\"jour:O\",\n",
    "                sort=list(jours.values()),\n",
    "                title=\"Jour de la semaine\"),\n",
    "        y=alt.Y(\"count():Q\", title=\"Nombre de courses\"),\n",
    "        color=alt.Color(\"jour:O\",\n",
    "                        scale=alt.Scale(scheme=\"tableau10\"),\n",
    "                        legend=None),\n",
    "        tooltip=[\"jour:O\", \"count():Q\"]\n",
    "    )\n",
    "    .properties(title=\"Courses par jour de la semaine\", width=500, height=280)\n",
    ")\n",
    "chart_dow"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a7ee2af6",
   "metadata": {},
   "source": [
    "### Question\n",
    "\n",
    "Le volume de courses est-il uniforme sur la semaine ? Que constatez-vous\n",
    "pour le week-end ?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "839ee839",
   "metadata": {},
   "source": [
    "### Heatmap heure × jour"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d781b086",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "import polars as pl\n",
    "\n",
    "# Agrégation Polars : nombre de courses par (heure, jour)\n",
    "agg_heure_jour = (\n",
    "    pdf_dow\n",
    "    .group_by([\"pickup_hour\", \"jour\"])\n",
    "    .agg(pl.len().alias(\"nb_courses\"))\n",
    ")\n",
    "\n",
    "chart_heatmap = (\n",
    "    alt.Chart(agg_heure_jour)\n",
    "    .mark_rect()\n",
    "    .encode(\n",
    "        x=alt.X(\"pickup_hour:O\",\n",
    "                title=\"Heure\",\n",
    "                axis=alt.Axis(labelAngle=0)),\n",
    "        y=alt.Y(\"jour:O\",\n",
    "                sort=list(jours.values()),\n",
    "                title=\"Jour\"),\n",
    "        color=alt.Color(\"nb_courses:Q\",\n",
    "                        scale=alt.Scale(scheme=\"orangered\"),\n",
    "                        title=\"Nb courses\"),\n",
    "        tooltip=[\"jour:O\", \"pickup_hour:O\",\n",
    "                 alt.Tooltip(\"nb_courses:Q\", format=\",\")]\n",
    "    )\n",
    "    .properties(\n",
    "        title=\"Activité par heure et jour de la semaine\",\n",
    "        width=600, height=250\n",
    "    )\n",
    ")\n",
    "chart_heatmap"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "59f8c7db",
   "metadata": {},
   "source": [
    "### Question\n",
    "\n",
    "Quelles sont les plages horaires les plus actives selon le jour de la\n",
    "semaine ? Les vendredis et samedis soir se distinguent-ils ?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "e5e92ce2",
   "metadata": {},
   "source": [
    "### Distribution des tarifs"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d1203dbf",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "# Histogramme de fare_amount (sur les données brutes, Altair calcule les bins)\n",
    "chart_tarif = (\n",
    "    alt.Chart(sample)\n",
    "    .mark_bar(opacity=0.8)\n",
    "    .encode(\n",
    "        x=alt.X(\"fare_amount:Q\",\n",
    "                bin=alt.Bin(maxbins=60),\n",
    "                title=\"Tarif de base (USD)\"),\n",
    "        y=alt.Y(\"count():Q\", title=\"Nombre de courses\"),\n",
    "        tooltip=[\"count():Q\"]\n",
    "    )\n",
    "    .properties(\n",
    "        title=\"Distribution des tarifs — janvier 2024\",\n",
    "        width=600, height=280\n",
    "    )\n",
    ")\n",
    "chart_tarif"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b6f08f5e",
   "metadata": {},
   "source": [
    "### Question\n",
    "\n",
    "La distribution est-elle symétrique ? Que remarquez-vous autour des\n",
    "valeurs 3-4 USD, 18-20 USD et 65-70 USD ? Pouvez-vous expliquer ces pics ?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5cf444b8",
   "metadata": {},
   "source": [
    "### Tarif moyen selon le mode de paiement"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2192daef",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "import polars as pl\n",
    "\n",
    "# Données initiales\n",
    "df = pl.DataFrame({\"statut\": [1, 2, 3, 4]})\n",
    "\n",
    "# Dictionnaire de correspondance\n",
    "mapping = {1: \"Actif\", 2: \"Inactif\"}\n",
    "\n",
    "# Remplacement des valeurs\n",
    "df_res = df.with_columns(\n",
    "    pl.col(\"statut\")\n",
    "      .replace_strict(mapping, default=\"Inconnu\")\n",
    "      .alias(\"statut_texte\")\n",
    ")\n",
    "\n",
    "print(df_res)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f2a84946",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "modes_paiement = {1: \"Carte\", 2: \"Espèces\", 3: \"Sans frais\",\n",
    "                  4: \"Litige\", 5: \"Inconnu\", 6: \"Voyage annulé\"}\n",
    "pdf_pay = pdf.with_columns(\n",
    "    pl.col(\"payment_type\")\n",
    "      .cast(pl.Int64)\n",
    "      .replace_strict(modes_paiement)\n",
    "      .alias(\"mode_paiement\")\n",
    ")\n",
    "\n",
    "agg_pay = (\n",
    "    pdf_pay\n",
    "    .group_by(\"mode_paiement\")\n",
    "    .agg([\n",
    "        pl.len().alias(\"nb_courses\"),\n",
    "        pl.col(\"fare_amount\").mean().alias(\"tarif_moyen\"),\n",
    "        pl.col(\"tip_amount\").mean().alias(\"pourboire_moyen\"),\n",
    "        pl.col(\"total_amount\").mean().alias(\"total_moyen\"),\n",
    "    ])\n",
    "    .sort(\"nb_courses\", descending=True)\n",
    ")\n",
    "\n",
    "# pour voir les différentes valeurs passez la souris sur les barres\n",
    "chart_pay = (\n",
    "    alt.Chart(agg_pay)\n",
    "    .mark_bar()\n",
    "    .encode(\n",
    "        x=alt.X(\"mode_paiement:N\",\n",
    "                sort=\"-y\",\n",
    "                title=\"Mode de paiement\"),\n",
    "        y=alt.Y(\"nb_courses:Q\", title=\"Nombre de courses\"),\n",
    "        color=alt.Color(\"tarif_moyen:Q\",\n",
    "                        scale=alt.Scale(scheme=\"viridis\"),\n",
    "                        title=\"Tarif moyen (USD)\"),\n",
    "        tooltip=[\n",
    "            \"mode_paiement:N\",\n",
    "            alt.Tooltip(\"nb_courses:Q\",    format=\",\"),\n",
    "            alt.Tooltip(\"tarif_moyen:Q\",   format=\".2f\"),\n",
    "            alt.Tooltip(\"pourboire_moyen:Q\", format=\".2f\"),\n",
    "        ]\n",
    "    )\n",
    "    .properties(\n",
    "        title=\"Courses et tarif moyen par mode de paiement\",\n",
    "        width=500, height=300\n",
    "    )\n",
    ")\n",
    "chart_pay"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "948e2c86",
   "metadata": {},
   "source": [
    "### Question\n",
    "\n",
    "Comparez le pourboire moyen pour les paiements par carte vs espèces.\n",
    "Qu’observez-vous ? Quelle en est l’explication probable ?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3a6c60bc",
   "metadata": {},
   "source": [
    "### Graphique composé : tarif et pourboire par heure\n",
    "\n",
    "Altair permet de **combiner** plusieurs graphiques avec des axes partagés\n",
    "(attention toutefois aux problèmes de lisibilité)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9c431177",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "agg_heure = (\n",
    "    pdf\n",
    "    .group_by(\"pickup_hour\")\n",
    "    .agg([\n",
    "        pl.col(\"fare_amount\").mean().alias(\"tarif_moyen\"),\n",
    "        pl.col(\"tip_amount\").mean().alias(\"pourboire_moyen\"),\n",
    "        pl.len().alias(\"nb_courses\"),\n",
    "    ])\n",
    "    .sort(\"pickup_hour\")\n",
    ")\n",
    "\n",
    "base = alt.Chart(agg_heure).encode(\n",
    "    x=alt.X(\"pickup_hour:O\",\n",
    "            title=\"Heure de départ\",\n",
    "            axis=alt.Axis(labelAngle=0))\n",
    ")\n",
    "\n",
    "ligne_tarif = (base\n",
    "    .mark_line(color=\"steelblue\", strokeWidth=2)\n",
    "    .encode(y=alt.Y(\"tarif_moyen:Q\", title=\"USD (moyen)\"),\n",
    "            tooltip=[\"pickup_hour:O\",\n",
    "                     alt.Tooltip(\"tarif_moyen:Q\", format=\".2f\")])\n",
    ")\n",
    "ligne_tip = (base\n",
    "    .mark_line(color=\"darkorange\", strokeDash=[4, 2], strokeWidth=2)\n",
    "    .encode(y=alt.Y(\"pourboire_moyen:Q\"),\n",
    "            tooltip=[\"pickup_hour:O\",\n",
    "                     alt.Tooltip(\"pourboire_moyen:Q\", format=\".2f\")])\n",
    ")\n",
    "barres_vol = (base\n",
    "    .mark_bar(opacity=0.2, color=\"grey\")\n",
    "    .encode(y=alt.Y(\"nb_courses:Q\", title=\"Nb courses\"),\n",
    "            tooltip=[alt.Tooltip(\"nb_courses:Q\", format=\",\")])\n",
    ")\n",
    "\n",
    "chart_compose = (\n",
    "    alt.layer(barres_vol, ligne_tarif, ligne_tip)\n",
    "    .resolve_scale(y=\"independent\")\n",
    "    .properties(\n",
    "        title=\"Tarif moyen (bleu), pourboire moyen (orange) \"\n",
    "              \"et volume (gris) par heure\",\n",
    "        width=650, height=320\n",
    "    )\n",
    ")\n",
    "chart_compose"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bc2b7d87",
   "metadata": {},
   "source": [
    "### Question\n",
    "\n",
    "Le tarif moyen augmente-t-il aux heures de pointe ou aux heures creuses ?\n",
    "Comment l’expliquer (courses plus longues ? supplément nuit ? destinations\n",
    "plus lointaines ?) ?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4b95c346",
   "metadata": {},
   "source": [
    "## Partie 4 : affichage de millions de points avec hvPlot et Datashader\n",
    "\n",
    "Dans la section précédente nous avons visualisé des **agrégations** de données,\n",
    "Altair est parfaitement adaptée. Pour visualiser des données brutes à l’échelle\n",
    "de millions de points nous pouvons employer Datashader."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a762f97c",
   "metadata": {},
   "source": [
    "### Principe de Datashader\n",
    "\n",
    "Datashader ne dessine pas chaque point individuellement mais **rasterise**\n",
    "les données : il divise le plan en une grille de pixels, compte le nombre de\n",
    "points dans chaque pixel et applique une palette de couleurs à partir de\n",
    "ces valeurs.\n",
    "Le résultat est une image de résolution fixe (par ex. 800 × 600 pixels)\n",
    "quel que soit le nombre de points. Le rendu est alors :\n",
    "\n",
    "- rapide, car proportionnel à la résolution de l’image et non au nombre de\n",
    "  points ;  \n",
    "- plus interprétable, car la couleur de chaque pixel reflète la densité\n",
    "  réelle dans cette zone.  "
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1a5cf31c",
   "metadata": {},
   "source": [
    "### Installation et imports"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3faaa9b1",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "import hvplot.polars       # enregistre l'accesseur .hvplot sur Polars\n",
    "import holoviews as hv\n",
    "import datashader as ds\n",
    "from holoviews.operation.datashader import datashade, dynspread\n",
    "import panel as pn\n",
    "\n",
    "hv.extension(\"bokeh\")      # backend interactif dans Jupyter"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "95bfa7d7",
   "metadata": {},
   "source": [
    ">**Note**\n",
    ">\n",
    ">`import hvplot.polars` enregistre un « accesseur » `.hvplot` sur tous les\n",
    "DataFrames Polars de la session. En interne, hvPlot convertit les colonnes\n",
    "nécessaires en NumPy/Arrow avant de les passer à Bokeh ou Datashader. La\n",
    "conversion est paresseuse et ne porte que sur les colonnes du graphique."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f57b7713",
   "metadata": {},
   "source": [
    "### Scatterplot avec Datashader : `fare_amount` vs `trip_distance`"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2c309e41",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "# Datashader « rasterise » les 2,7 millions de points en une image 700 × 500\n",
    "plot_scatter_ds = pdf.hvplot.scatter(\n",
    "    x=\"trip_distance\",\n",
    "    y=\"fare_amount\",\n",
    "    datashade=True,               # active Datashader\n",
    "    dynspread=True,               # élargit les points isolés pour la lisibilité\n",
    "    cmap=\"fire\",                  # palette (*fire* = du noir au blanc avec rouge/jaune)\n",
    "    width=700, height=500,\n",
    "    xlabel=\"Distance (miles)\",\n",
    "    ylabel=\"Tarif (USD)\",\n",
    "    title=\"Tarif vs distance pour 2,7 M courses (Datashader)\"\n",
    ")\n",
    "plot_scatter_ds"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2d7a642b",
   "metadata": {},
   "source": [
    "### Question\n",
    "\n",
    "Comparez cette image au scatterplot Matplotlib de la partie 2. Que\n",
    "révèle Datashader que Matplotlib ne montrait pas ? Identifiez :\n",
    "\n",
    "- la relation linéaire principale (tarif ~ distance) ;  \n",
    "- les bandes horizontales correspondant aux tarifs forfaitaires\n",
    "  (JFK, Newark) ;  \n",
    "- les concentrations de courses courtes et chères (Manhattan).  "
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b0d01517",
   "metadata": {},
   "source": [
    "### Densité par heure avec Datashader"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "2e550730",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "# Distribution de la vitesse suivant l'heure de la journée\n",
    "plot_speed = pdf.hvplot.scatter(\n",
    "    x=\"pickup_hour\",\n",
    "    y=\"speed_mph\",\n",
    "    datashade=True,\n",
    "    cmap=\"fire\",\n",
    "    width=700, height=400,\n",
    "    xlabel=\"Heure de départ\",\n",
    "    ylabel=\"Vitesse moyenne (mph)\",\n",
    "    title=\"Vitesse des courses par heure de départ\"\n",
    ")\n",
    "plot_speed"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0912370f",
   "metadata": {},
   "source": [
    "### Question\n",
    "\n",
    "À quelles heures les courses sont-elles les plus rapides ? Les plus lentes ?\n",
    "Cela correspond à ce que vous attendriez des embouteillages new-yorkais ?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "91517e4d",
   "metadata": {},
   "source": [
    "### Distribution 2D : durée vs distance"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4261fd46",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "plot_duree_distance = pdf.hvplot.scatter(\n",
    "    x=\"trip_distance\",\n",
    "    y=\"duration_min\", ylim=(0, 1500),\n",
    "    datashade=True,\n",
    "    cmap=\"fire\",\n",
    "    width=700, height=500,\n",
    "    xlabel=\"Distance (miles)\",\n",
    "    ylabel=\"Durée (minutes)\",\n",
    "    title=\"Durée vs distance pour toutes les courses\"\n",
    ")\n",
    "plot_duree_distance"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "108f0258",
   "metadata": {},
   "source": [
    "### Histogramme 2D (*heatmap* de densité)\n",
    "\n",
    "Pour des données continues, une heatmap de densité est souvent plus lisible\n",
    "qu’un scatterplot même avec Datashader."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "de986c78",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "plot_hexbin = pdf.hvplot.hexbin(\n",
    "    x=\"trip_distance\",\n",
    "    y=\"fare_amount\",\n",
    "    gridsize=60,\n",
    "    cmap=\"viridis\",\n",
    "    width=700, height=500,\n",
    "    xlabel=\"Distance (miles)\",\n",
    "    ylabel=\"Tarif (USD)\",\n",
    "    title=\"Densité (hexbin) pour tarif vs distance\"\n",
    ")\n",
    "plot_hexbin"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2a054ee4",
   "metadata": {},
   "source": [
    "### Question\n",
    "\n",
    "Comparez la *heatmap* hexagonale au *scatterplot* Datashader précédent.\n",
    "Quels sont les avantages et inconvénients de chaque représentation ?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c308de60",
   "metadata": {},
   "source": [
    "## Partie 5 : Agrégation de plusieurs mois dans Spark\n",
    "\n",
    "La puissance de la chaîne Spark → sparkpl → Polars apparaît quand on\n",
    "travaille sur plusieurs mois de données (volume total de plusieurs dizaines\n",
    "de millions de lignes, ou plusieurs centaines de Mo)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ec0da9fa",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "# Téléchargement de 3 mois supplémentaires\n",
    "mois = [\"2024-02\", \"2024-03\", \"2024-04\"]\n",
    "for m in mois:\n",
    "    f = f\"{dossier}yellow_tripdata_{m}.parquet\"\n",
    "    if not os.path.exists(f):\n",
    "        url_m = f\"https://d37ci6vzurychx.cloudfront.net/trip-data/yellow_tripdata_{m}.parquet\"\n",
    "        print(f\"Téléchargement {m}...\")\n",
    "        os.system(f\"wget -q -nc {url_m} -P {dossier}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "26f039b4",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "# Chargement de tous les fichiers disponibles en un seul DataFrame Spark\n",
    "sdf_multi = spark.read.parquet(dossier + \"yellow_tripdata_2024-*.parquet\")\n",
    "print(f\"Total lignes : {sdf_multi.count():,}\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f25b15db",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "# Agrégation distribuée avec Spark : tarif moyen et volume par heure et mois\n",
    "from pyspark.sql import functions as F\n",
    "\n",
    "agg_spark = (\n",
    "    sdf_multi\n",
    "    .filter(F.col(\"fare_amount\").between(0, 200))\n",
    "    .filter(F.col(\"trip_distance\").between(0, 50))\n",
    "    .withColumn(\"mois\",         F.month(\"tpep_pickup_datetime\"))\n",
    "    .withColumn(\"pickup_hour\",  F.hour(\"tpep_pickup_datetime\"))\n",
    "    .groupBy(\"mois\", \"pickup_hour\")\n",
    "    .agg(\n",
    "        F.count(\"*\").alias(\"nb_courses\"),\n",
    "        F.mean(\"fare_amount\").alias(\"tarif_moyen\"),\n",
    "        F.mean(\"trip_distance\").alias(\"dist_moy\"),\n",
    "    )\n",
    "    .orderBy(\"mois\", \"pickup_hour\")\n",
    ")\n",
    "\n",
    "# Conversion du résumé (quelques centaines de lignes) vers Polars\n",
    "pdf_multi = spark_to_polars(agg_spark)\n",
    "print(pdf_multi)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "46a627d5",
   "metadata": {},
   "source": [
    ">**Note**\n",
    ">\n",
    ">C’est ici qu’on peut voir l’importance de chaîne : Spark agrège des\n",
    "dizaines de millions de lignes distribuées en parallèle sur le *cluster*,\n",
    "et on ne transfère vers Polars que le résumé agrégé (quelques centaines\n",
    "de lignes ici). La visualisation avec Altair est alors instantanée."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "af4ab051",
   "metadata": {
    "hide-output": false
   },
   "outputs": [],
   "source": [
    "pdf_multi_str = pdf_multi.with_columns(\n",
    "    pl.col(\"mois\").cast(pl.Utf8).alias(\"mois_str\")\n",
    ")\n",
    "\n",
    "chart_multi = (\n",
    "    alt.Chart(pdf_multi_str)\n",
    "    .mark_line(point=True)\n",
    "    .encode(\n",
    "        x=alt.X(\"pickup_hour:O\",\n",
    "                title=\"Heure de départ\",\n",
    "                axis=alt.Axis(labelAngle=0)),\n",
    "        y=alt.Y(\"nb_courses:Q\", title=\"Nombre de courses\"),\n",
    "        color=alt.Color(\"mois_str:N\",\n",
    "                        title=\"Mois\",\n",
    "                        scale=alt.Scale(scheme=\"category10\")),\n",
    "        tooltip=[\"mois_str:N\", \"pickup_hour:O\",\n",
    "                 alt.Tooltip(\"nb_courses:Q\", format=\",\"),\n",
    "                 alt.Tooltip(\"tarif_moyen:Q\", format=\".2f\")]\n",
    "    )\n",
    "    .properties(\n",
    "        title=\"Volume de courses par heure — comparaison mensuelle\",\n",
    "        width=650, height=320\n",
    "    )\n",
    "    .interactive()\n",
    ")\n",
    "chart_multi"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "840412c7",
   "metadata": {},
   "source": [
    "## Question\n",
    "\n",
    "Les profils horaires sont-ils stables d’un mois à l’autre ? Observe-t-on\n",
    "des différences entre janvier (hiver) et avril (printemps) ?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4ff6ba28",
   "metadata": {},
   "source": [
    "## Exercice\n",
    "\n",
    "Construisez une *heatmap* Altair (axes : mois × heure, couleur : tarif_moyen)\n",
    "à partir de `pdf_multi_str` et discutez l’évolution du tarif moyen\n",
    "suivant l’heure et la période de l’année."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c4aab375",
   "metadata": {},
   "source": [
    "## Synthèse\n",
    "\n",
    "Dans ce TP vous avez évalué une chaîne complète pour la visualisation\n",
    "de données volumineuses dans un environnement Jupyter/Spark :\n",
    "\n",
    "**PySpark** pour le chargement, le nettoyage et l’agrégation distribuée\n",
    "→ **sparkpl** pour la conversion Arrow zero-copy vers **Polars** → **Altair**\n",
    "pour les visualisations déclaratives **sur agrégats** ou **hvPlot + Datashader**\n",
    "pour le rendu de **données brutes** à grande échelle sans **overplotting**.\n",
    "\n",
    "Les points clés à retenir :\n",
    "\n",
    "- Le problème de l”*overplotting* est inévitable dès que l’on dépasse\n",
    "  quelques milliers de points dans un *scatterplot*. Datashader le résout par\n",
    "  rasterisation.  \n",
    "- Effectuer les agrégations dans Spark avant de transférer les données vers\n",
    "  Polars est la clé du passage à l’échelle car on ne transfère que ce qui\n",
    "  est nécessaire à la visualisation.  \n",
    "- `sparkpl` évite le détour par Pandas grâce à la conversion Arrow, plus\n",
    "  rapide et sans copie de données.  \n",
    "- Altair et `hvPlot` acceptent nativement les DataFrames Polars.  "
   ]
  },
  {
   "cell_type": "markdown",
   "id": "44dc5f8c",
   "metadata": {},
   "source": [
    "## Références\n",
    "\n",
    "NYC Taxi & Limousine Commission. *TLC Trip Record Data*.\n",
    "[https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page](https://www.nyc.gov/site/tlc/about/tlc-trip-record-data.page)\n",
    "\n",
    "Boadzie, D. *sparkpl: A lightweight, pandas-free Python package for seamless\n",
    "conversion between PySpark and Polars DataFrames*.\n",
    "[https://pypi.org/project/sparkpl/](https://pypi.org/project/sparkpl/)\n",
    "\n",
    "HoloViz team. *hvPlot: A high-level plotting API for data exploration*.\n",
    "[https://hvplot.holoviz.org/](https://hvplot.holoviz.org/)\n",
    "\n",
    "Bednar, J. et al. *Datashader: Accurately representing big data*.\n",
    "[https://datashader.org/](https://datashader.org/)\n",
    "\n",
    "VanderPlas, J. and Granger, B. *Altair: Interactive Statistical Visualizations\n",
    "for Python*. Journal of Open Source Software, 3(32), 1057 (2018)."
   ]
  }
 ],
 "metadata": {
  "date": 1790953173.5757034,
  "filename": "tpVisualisationDonnees.rst",
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python"
  },
  "title": "Travaux pratiques - Visualisation de données volumineuses"
 },
 "nbformat": 4,
 "nbformat_minor": 5
}