Polars : L’alternative ultra-rapide à Pandas pour la manipulation de données en Python !

Si vous entamez un projet contenant de la manipulation tabulaire de données avec Python, il y a fort à parier que vous allez vous tourner vers Pandas car c’est à ce jour une des librairies de manipulation de Dataframe les plus populaires. Cependant, si vous avez déjà travaillé avec Pandas sur plusieurs dizaines (ou même des centaines) de milliers de lignes … Il est fort probable que les performances n'étaient pas au rendez-vous … Face aux limitations de performance de Pandas sur de grands volumes de données, Spark est souvent envisagé pour sa capacité à traiter des volumes massifs en parallèle. Mais savez-vous qu’il existe une alternative plus légère que Spark et plus rapide que Pandas ?

Asseyez-vous confortablement et laissez-moi vous présenter Polars !

Polars, c’est quoi ?

Commençons par les bases. Polars (de son vrai nom Pola.rs) est une librairie de manipulation de données open-source écrite en Rust. Le projet, né en 2020, vise avant tout à contourner les limitations de performance des Dataframes, notamment Pandas, qui ne sont pas toujours adaptés aux besoins modernes en matière de traitement des données volumineuses et complexes. Et le projet semble tenir parole car, pour ses performances, il a rapidement été adopté par la communauté.

Pourquoi Polars est-il performant ?

Rust

Si on devait expliquer les performances de Polars, cela tiendrait en un mot : Rust. Rust est un langage de programmation bas niveau compilé avec des performances proches du C/C++. De plus, le langage possède des mécanismes bien à lui :

Gestion de la mémoire

Rust est un langage assez unique, notamment dans sa gestion de la mémoire sans garbage collector, mais avec un mécanisme de possession. Cela signifie que Rust sait (de manière déterministe) où et quand libérer de la mémoire dès la compilation. Ce mécanisme permet également d’éviter les erreurs de mémoire (pointeur invalide, débordement de mémoire, double libération de mémoire, etc). En résumé, Rust maximise l'utilisation des ressources tout en minimisant les surcoûts liés à la gestion automatique de la mémoire.

Parallélisation de l’exécution

Comme expliqué dans leur blog, Rust prône la “fearless concurrency“, c’est-à-dire qu'il garantit la sécurité des threads à la compilation, éliminant les erreurs classiques de concurrence. En pratique, cela permet d’utiliser toutes les capacités de parallélisation de nos processeurs multicoeurs.

Interopérabilité

“Mais je ne souhaite pas apprendre le Rust pour manipuler mes Dataframes …” Pas de soucis puisque l’API Polars est aussi disponible en Python (mais également en R et NodeJs). Polars utilise PyO3 (c’est une librairie qui permet d’encapsuler le code Rust dans Python), on a donc les avantages de performance de Rust avec la facilité d’utilisation de Python.

Optimisation des requêtes

Polars possède deux types d'exécution : les “Eager executions” et les “Lazy executions”.

Usuellement, on s’attend à ce que le programme exécute immédiatement les requêtes au moment où il les rencontre pendant l’exécution. Les calculs sont faits de manière séquentielle. C’est ce que l’on appelle une “Eager execution”.

De l’autre côté, on retrouve les “Lazy execution” (ou “Lazy evaluation”) qui permettent d’optimiser les requêtes avant de les exécuter. Au moment où le code va rencontrer une requête, celle-ci ne va pas s’exécuter directement mais elle va être ajoutée au plan d’exécution. Le déclenchement de ce plan d'exécution se fait uniquement au dernier moment. Avec ce fonctionnement, on est en capacité d’optimiser le code en évitant des opérations inutiles pour gagner en performance.

Prenons un petit exemple :

import polars as pl

local_file_path = '../data/match_data_version1.csv'

q1 = (
    pl.scan_csv(local_file_path)
    .with_columns(pl.col("gameCreation").str.to_datetime())
    .filter((pl.col("seasonId") == 13))
)

Dans ce code, on scanne le fichier match_data_version1.csv puis on applique une transformation à la colonne gameCreation. Enfin, on filtre les lignes pour ne garder que la seasonId égale à 13.

q1.show_graph(optimized=False)

Image avec Titre Centré
Figure 1 : Plan d’exécution sans optimisation de la requête q1

En affichant le plan sans optimisation (à lire de bas en haut), on peut voir qu’il s’effectue séquentiellement. Cela signifie qu’il va, en premier, charger le fichier en mémoire (tout le fichier) puis effectuer la transformation sur gameCreation (sur toutes les lignes donc). Ce n’est qu’à la toute fin qu’il va filtrer sur seasonId. Vous me voyez venir ?

q1.show_graph(optimized=True)

Image avec Titre Centré
Figure 2 : Plan d’exécution avec optimisation de la requête q1

Or, si on accède au plan optimisé, Polars évite les opérations inutiles en filtrant d'abord les données avant d'appliquer les transformations. Il va commencer par charger en mémoire les lignes nécessaires du fichier (en ne gardant que la seasonId égale à 13) - c’est un Predicate Pushdown - puis il va effectuer la transformation sur la colonne gameCreation. Gain de mémoire et gain de temps →Gagnant-Gagnant

Apache Arrow natif

Contrairement à Pandas v1.0, Polars intègre nativement l’utilisation de Apache Arrow comme unique format de données en mémoire. Apache Arrow pourrait avoir un article dédié … et ça tombe bien : je vous invite à lire cet article qui en parle très bien.

Sans rentrer dans les détails, Apache Arrow est une technologie de stockage de données en mémoire qui repose sur un format orienté colonne. Cela se traduit par une accélération notable des opérations analytiques, particulièrement pour des tâches comme le filtrage, l'agrégation ou les calculs vectorisés. C’est ce qui permet à Polars d’être hautement performant, à la fois rapide et efficace sur le plan mémoire.

Remarque : depuis Pandas v2.0, Pandas propose une meilleure intégration avec Apache Arrow qui remplace de plus en plus Numpy comme le format de donnée en mémoire.

Autres fonctionnalités

Il y a d’autres features proposées par Polars qu’on ne va pas détailler dans cet article mais qui sont intéressantes à connaître :

  • SQL Contexte : on peut faire des requêtes SQL sur le dataset. Plutôt pratique !
  • Streaming API (BETA) : au lieu de charger toutes les données, Polars va exécuter les requêtes en Batch (Idéal si ça ne rentre pas dans la mémoire).
  • Accélération avec GPUs (BETA) : le CPU c’est trop lent ?
  • Polar CLI.

Et en pratique, ça donne quoi ?

On s’intéresse aux performances obtenues en utilisant Pandas (Dataframe) et Polars (Dataframe + Lazyframe). La comparaison des performances portera sur le temps d’exécution (fourni par AWS) ainsi que la mémoire maximale utilisée (en utilisant la mémoire “Unique Set Size” (alias USS) de la fonction memory_full_info() de la librairie psutil).

Remarque : USS représente le calcul de la mémoire qui serait libérée si le processus était tué immédiatement (lors de l’appel à memory_full_info() donc).

Les données portent sur 100 000 parties classées de “League Of Legend” du serveur Coréen sur la période 2019. Pour plus d'informations sur les données, elles sont disponibles sur Kaggle. C’est un fichier CSV de 3,5 Go. À partir de ce fichier, on peut créer un échantillon en ne prenant que les 250 premières lignes pour tester les performances sur un petit fichier. Il y a également un fichier JSON fourni par l’API Riot Game qui contient les constantes utilisées dans les réponses (cf. API Riot Game). Ce sera l’occasion de tester les jointures avec ce fichier JSON. Les deux fichiers CSV ainsi que le fichier JSON seront stockés dans un bucket S3.

Le calcul se fera dans une fonction AWS Lambda avec 10240 Mo de mémoire. Les transformations (ce sont des opérations basiques de manipulation de données) seront les suivantes :

  1. Lecture du fichier CSV
  2. Filtre
  3. Déduplication des lignes
  4. Lecture d’un fichier JSON
  5. Jointure entre les données CSV et le fichier JSON
  6. Agrégation (Comptage)
  7. Tri
  8. Affichage du Top 3

Code

Pandas Dataframe (NumPy)

import pandas as pd
import s3fs
import os
import psutil

PROCESS = psutil.Process(os.getpid())

def compute_pd_dataframe(data_file_path, json_file_path):
    fs = s3fs.S3FileSystem()

    with fs.open(data_file_path, mode='rb') as f:
        games_df = pd.read_csv(f, header=0, sep=",")

    # Memory Peak
    print(f"Pandas memory usage: {PROCESS.memory_full_info().uss / 1e6:.2f} Mo")

    games_df = games_df[
        (games_df["gameMode"] == "CLASSIC")
        & (games_df["seasonId"] == 13)
    ]

    games_deduplicated_df = games_df.drop_duplicates(subset=["gameId"], inplace=False)

    queues_df = pd.read_json(
        json_file_path,
        dtype={
            "queueId": "float64",
            "map": "string",
            "description": "string",
            "notes": "string",
        },
    )

    games_joined_df = pd.merge(
        games_deduplicated_df, queues_df, on="queueId", how="left"
    )

    count_by_map_df = (
games_joined_df.groupby("map")["gameId"].count().sort_values(ascending=False)
    )

    print(count_by_map_df.head(3))

Polars Dataframe

import polars as pl
import s3fs
import os
import psutil

PROCESS = psutil.Process(os.getpid())

def compute_pl_dataframe(data_file_path, json_file_path):
    fs = s3fs.S3FileSystem()

    with fs.open(data_file_path, mode='rb') as f:
        games_df = pl.read_csv(f, has_header=True, separator=",").filter(
            (pl.col("gameMode") == "CLASSIC")
            & (pl.col("seasonId") == 13)
        )

    # Memory Peak
    print(f"Polars memory usage: {PROCESS.memory_full_info().uss / 1e6:.2f} Mo")

    games_deduplicated_df = games_df.unique(subset=["gameId"])

    with fs.open(json_file_path, mode='rb') as f:
        queues_df = pl.read_json(
            f,
            schema={
                "queueId": pl.Float64,
                "map": pl.String,
                "description": pl.String,
                "notes": pl.String,
            },
        )

    games_joined_df = (
games_deduplicated_df.join(queues_df, on="queueId", how="left")
    )

    count_by_map_df = (
        games_joined_df.group_by("map")
        .agg(pl.count("gameId").alias("count"))
        .sort("count", descending=True)
    )

    print(count_by_map_df.limit(3))

Polars Lazyframe

import polars as pl
import s3fs
import os
import psutil

PROCESS = psutil.Process(os.getpid())

def compute_pl_lazyframe(data_file_path, json_file_path):
    fs = s3fs.S3FileSystem()

    with fs.open(data_file_path, mode='rb') as f:
        games_lf = pl.scan_csv(f, has_header=True, separator=",").filter(
            (pl.col("gameMode") == "CLASSIC")
            & (pl.col("seasonId") == 13)
        )

    games_deduplicated_lf = games_lf.unique(subset=["gameId"])

    with fs.open(json_file_path, mode='rb') as f:
        queues_lf = pl.read_json(
            f,
            schema={
                "queueId": pl.Float64,
                "map": pl.String,
                "description": pl.String,
                "notes": pl.String,
            },
        ).lazy()

    games_joined_lf = (
games_deduplicated_lf.join(queues_lf, on="queueId", how="left")
    )

    count_by_map_lf = (
        games_joined_lf.group_by("map")
        .agg(pl.count("gameId").alias("count"))
        .sort("count", descending=True)
    )

    result = count_by_map_lf.limit(3).collect()

    # Memory Peak
    print(f"Polars memory usage: {PROCESS.memory_full_info().uss / 1e6:.2f} Mo")

    print(result)

Remarques :

  • Il est difficile d’avoir exactement les mêmes transformations en Pandas et en Polars car les deux APIs sont très différentes dans leur conception. Néanmoins, j’ai essayé de m’en approcher le plus possible. C’est pour cette raison que j’utilise aussi s3fs pour lire un fichier CSV sur le bucket S3 alors qu’il suffit d’utiliser la fonction pd.read_csv(“s3://…”) avec Pandas. Cette fonctionnalité n’est pas dispo
  • nible dans Polars. Je reviendrai sur ce sujet.
  • Pour les utilisateurs très avancés de Pandas, je rajouterai également que je n’ai pas optimisé le code Pandas. Il existe sûrement des mécanismes pour accélérer le code Pandas (je pense notamment à pyarrow et l’utilisation des indexes). Le but ici est de faire une petite comparaison entre les deux librairies sans rentrer de la paramétrisation complexe.
  • Le pic mémoire est estimé en fonction du comportement de Polars et Pandas. Cela se vérifie si on utilise memory_full_info() ligne par ligne.

Mesures de performances

J’ai lancé une quinzaine de fois le code pour lisser les résultats obtenus. Les données présentées ci-dessous correspondent à la moyenne des 15 lancés ainsi que la marge d’erreur à 95%.

Tableau HTML
Tableau 1 : Temps d’exécution
Pandas Dataframe Polars Dataframe Polars LazyFrame
Échantillon (250 lignes) 234ms ± 8ms 179ms ± 8ms 154ms ± 9ms
Dataset Complet (100 000+ Lignes) 72sec ± 3sec 51sec ± 0sec 46sec ± 1sec
Tableau 2 : Mémoire maximale utilisée
Pandas Dataframe Polars Dataframe Polars LazyFrame
Échantillon (250 lignes) 77 Mo ± 0 Mo 80 Mo ± 0 Mo 71 Mo ± 0 Mo
Dataset Complet (100 000+ Lignes) 3981 Mo ± 0 Mo 3553 Mo ± 0 Mo 80 Mo ± 0 Mo

Concernant la comparaison, les temps d’exécution sont meilleurs sur Polars que sur Pandas et l’écart se creuse encore plus en utilisant les Lazyframes (environ 35% plus rapide par rapport à Pandas) plutôt que le Dataframe (“seulement” 25% plus rapide par rapport à Pandas).

Si on regarde de plus près la mémoire maximale utilisée, Polars et Pandas sont similaires pour un volume de données modeste (même si les Lazyframes sont légèrement moins lourds). Par contre, on peut observer une (très) nette différence avec le dataset complet. Le Polars Dataframe est 11% plus léger que le Pandas Dataframe et le Polars LazyFrame est quant à lui … 98% plus léger. (Oui oui je vous assure !)

Remarque : ce résultat assez étonnant s’explique car le fichier utilisé contient un colonne participantIdentities particulièrement lourde (avec une liste des informations exhaustives sur les 10 joueurs !). Or, cette colonne n’est pas utilisée dans le calcul, elle n’est donc pas récupérée par Polars (c’est le mécanisme de Projection Pushdown). Si on désactive cette optimisation, on retombe sur une mémoire maximale utilisée de 3467Mo ± 0 Mo (13% plus léger que Pandas). Moins impressionnant certes … mais plus proche de la réalité.

Limites de Polars

Si vous êtes arrivé jusqu’ici, vous devriez déjà avoir partagé l’article avec votre Tech Lead pour l’intégrer dans votre projet ou, a minima, avoir envie de tester la librairie sur vos propres données. Mais si Polars n’est pas déjà dans tous les projets en production, ce n’est pas pour rien …

Librairie encore jeune

Les premières versions de Pandas sont arrivées au début des années 2010, l’outil a eu le temps de maturer avant d’en arriver là. Polars c’est un petit nouveau à côté, il semble très performant mais il manque encore quelques fonctionnalités pour que la communauté l’adopte complètement.

Lors de mon cas pratique, j’ai eu l’occasion de rencontrer quelques-unes de ces limitations puisqu’il n’est pas encore possible de lire des fichiers JSON sur un Cloud Provider (rassurez-vous, pas de soucis avec CSV et Parquet). Il n’est pas non plus possible de scanner (Lazyframe) une autre format que Parquet sur un Cloud Provider. Alors effectivement, il existe des alternatives (en utilisant s3fs par exemple) et il est quand même possible de le faire, mais ces fonctionnalités ne sont pas intégrées nativement dans Polars (pas encore du moins).

Interopérabilité avec d'autres outils

Dans le même registre que le point précédent, si on souhaite faire du Machine Learning (avec PyTorch ou TensorFlow par exemple), ou même faire de la Visualisation (avec Plotly, Dash ou Matplotlib par exemple). Il est possible que cela ne fonctionnera pas du premier coup avec un DataFrame Polars (malgré des grosses améliorations qui ont pu être faites de ce côté là durant les derniers mois) car contrairement au DataFrame Pandas qui est devenu un incontournable dans l’écosystème, Polars reste une librairie relativement jeune et il manque un peu d'interopérabilités entre Polars et les autres librairies.

Il sera quand même possible d’utiliser les fonctions d’export comme par exemple to_pandas() pour transformer un Dataframe Polars en un Dataframe Pandas (sans réallocation de mémoire) pour pallier ce souci d'interopérabilité.

Courbe d'apprentissage

Passer de Panda à Polars n’est pas non plus une chose extrêmement facile tant la différence de syntaxe est importante. On compare souvent Polars à Pandas car ils sont conçus pour fonctionner sur une machine unique mais d’un point de vue syntaxique, Polars se rapproche plus d’un Apache Spark que de Pandas. On ne manipule pas des lignes comme dans Pandas mais des colonnes !

Personnellement, je trouve l’approche de Polars plus intuitive car elle repose sur une manipulation des données centrée sur les colonnes. On traite la colonne comme un ensemble, ce qui permet de travailler de manière plus structurée et explicite. Néanmoins, pour les personnes qui sont adeptes de l’utilisation de Pandas (approche par les lignes), il faudra quand même s’y habituer.

Conclusion : Polars, l’avenir de la manipulation de données en Python ?

Il est indéniable que Polars est performant (et même très performant) dans son domaine tant sur la vitesse d’exécution que sur la gestion de la mémoire. Cette performance, Polars le doit en grande partie à Rust et ainsi qu’à ses choix d’implémentation impliquant Apache Arrow. Pour tirer toute la puissance de l'outil, c'est vers les “Lazy executions” et les optimisations de requêtes qu’il faudra se tourner.

Néanmoins, l’outil est encore jeune et il manque encore quelques fonctionnalités clé pour une adoption massive dans l’écosystème de manipulation de données en Python. Je pense notamment à étoffer l’API pour l’utiliser avec les différents Cloud Providers de manière plus transparente mais également d’ajouter de l'interopérabilité avec les autres librairies en Python (Visualisation et Machine Learning). Il n’y a rien d’insurmontable pour ceux qui ont un peu de temps mais, à mon sens, cela freine l’adoption de la librairie par le grand public.

Actuellement, Pandas domine ce marché, mais à l’avenir, Polars pourrait s’avérer être un choix pertinent dans certains projets. À mon avis, Pandas et Polars répondent à des besoins différents. Pandas, de par son approche par les lignes, sera utilisé pour de l’exploration de données, des POCs ou des projets à faible volumétrie. De l’autre côté, Polars lui, trouvera une place dans des projets de transformation de données à moyenne/forte volumétrie (dans des petites Data Platform par exemple) afin de limiter les coûts tout en offrant une bonne performance. Pour des volumes de données encore plus importants, il reste préférable d’opter pour un environnement multi-cluster, tel que Spark, offrant une meilleure scalabilité.

Si vous voulez aller plus loin dans la comparaison entre Polars et Pandas, je vous conseille le blog Modern Polars.