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