Tipps & Tricks

Wie Polars Pandas Übertrifft: Ein Vergleich der Leistungsfähigkeit

10 min Lesezeit
Wie Polars Pandas Übertrifft: Ein Vergleich der Leistungsfähigkeit

Einleitung

In den letzten zehn Jahren hat sich Pandas als grundlegendes Werkzeug für Datenanalysen in Python etabliert. Bei Datensätzen, die in den Arbeitsspeicher passen, ist es schnell und vertraut genug, dass ein Wechsel der Bibliothek selten in Betracht gezogen wird.

Allerdings zeigen sich bei der Arbeit mit Millionen von Zeilen die Schwächen von Pandas: Groupby-Operationen, die mehrere Sekunden in Anspruch nehmen, Zwischenkopien, die RAM verbrauchen, und Fensterfunktionen, die als Python-Schleifen anstelle von vektorisiertem C- oder Rust-Code ausgeführt werden.

Polars ist eine DataFrame-Bibliothek, die in Rust auf der Grundlage von Apache Arrow entwickelt wurde. Sie wurde mit dem Fokus auf Parallelität und Lazy Evaluation konzipiert. Während Pandas jede Operation im Voraus und sequenziell ausführt, kann Polars einen Abfrageplan erstellen und optimieren, bevor die Ausführung erfolgt, wobei die meisten Operationen automatisch parallel über alle verfügbaren CPU-Kerne ausgeführt werden.

In diesem Artikel werden drei reale Datenprobleme untersucht, die auf echten Fragen von der StrataScratch-Coding-Plattform basieren. Für jedes Problem vergleichen wir die Lösungen beider Bibliotheken und heben hervor, wo die Leistungsunterschiede am bedeutendsten sind.

Aktivitätsrang: Verwendung von rank() vs. with_row_count()

In dieser Frage geht es darum, den Aktivitätsrang der E-Mail-Nutzer basierend auf der Gesamtzahl der gesendeten E-Mails zu ermitteln. Der Nutzer mit den meisten E-Mails erhält den Rang 1. Die Ergebnisse müssen nach der Gesamtzahl der E-Mails in absteigender Reihenfolge sortiert werden, wobei das alphabetische Sortieren als Tiebreaker dient, und jeder Rang muss eindeutig sein, selbst wenn zwei Nutzer die gleiche E-Mail-Anzahl haben.

Datenansicht

Die Tabelle google_gmail_emails speichert eine Zeile pro gesendeter E-Mail, mit einer Sender-ID (from_user), einer Empfänger-ID (to_user) und dem Tag, an dem die E-Mail gesendet wurde. Hier ist eine Vorschau der Tabelle:

  • id
  • from_user
  • to_user
  • day

Grain (was eine Ausgabereihe bedeutet): ein Nutzer, mit seiner Gesamtanzahl an E-Mails und dem einzigartigen Aktivitätsrang.

Häufiger Fehler

Die Frage verlangt einen einzigartigen Rang, selbst wenn zwei Nutzer die gleiche E-Mail-Anzahl haben. Ein häufiger Fehler besteht darin, die Methode rank(method=’dense‘) in Pandas zu verwenden, die den gleichen Rang an gebundene Nutzer vergibt. Die korrekte Methode ist ‚first‘, die Bindungen nach der Position im sortierten Rahmen auflöst. Da wir alphabetisch nach user_id sortieren, sind die resultierenden Ränge einzigartig und deterministisch.

Die optimale Lösung in Polars umgeht die Rangfunktion vollständig. Nach dem Sortieren nach [„total_emails“, „user_id“] in absteigender und aufsteigender Reihenfolge weist die Klausel .with_row_count(„activity_rank“, offset=1) sequentielle Ganzzahlen ab 1 zu. Eine Tiebreaking-Logik ist nicht erforderlich, da die Sortierung dies bereits geregelt hat.

Lösungen

  • Pandas-Lösung
    import pandas as pd
    import numpy as np
    google_gmail_emails = google_gmail_emails.rename(columns={"from_user": "user_id"})
    result = google_gmail_emails.groupby(
     ['user_id']).size().to_frame('total_emails').reset_index()
    result['activity_rank'] = result['total_emails'].rank(method='first', ascending=False)
    result = result.sort_values(by=['total_emails', 'user_id'], ascending=[False, True])
    
  • Polars-Lösung
    import polars as pl
    google_gmail_emails = google_gmail_emails.rename({"from_user": "user_id"})
    result = (
     google_gmail_emails.lazy()
     .group_by("user_id")
     .agg(total_emails = pl.count())
     .sort(
     by=["total_emails", "user_id"],
     descending=[True, False]
     )
     .with_row_count("activity_rank", offset=1)
     .select([
     pl.col("user_id"),
     "total_emails",
     "activity_rank"
     ])
     .collect()
    )
    

Leistungsvergleich

Die Pandas-Lösung durchläuft die Daten nach dem Gruppieren zweimal: einmal zur Berechnung der Größen und einmal zur Zuweisung der Ränge. Intern allokiert rank(method=’first‘) ein Rang-Array, löst Bindungen über argsort auf und schreibt zurück – was erheblich teurer ist, als es aussieht, selbst für eine einzelne Spalte. Die group_by-Funktion in Polars verteilt die Arbeitslast über alle verfügbaren CPU-Kerne, was zu einer erheblich schnelleren Aggregation für große Tabellen führt. Da die Klausel .with_row_count() einen einzigen O(n)-sequentiellen Durchgang nach dem Sortieren darstellt, ersetzt sie die Rangfunktion durch die kostengünstigste Operation. Bei einer Tabelle mit Millionen von E-Mail-Datensätzen kann die Verwendung der parallelen Aggregation ohne Rangfunktion zu einer Verbesserung der Wandzeit um das 5- bis 10-fache im Vergleich zur Pandas-Methode führen.

Rückkehrende Nutzer: Verwendung von cumcount() + pivot() vs. over()

In dieser Frage sollen aktive Rückkehrnutzer identifiziert werden – konkret solche, die innerhalb von 1 bis 7 Tagen nach ihrem ersten Kauf einen zweiten Kauf getätigt haben. Käufe, die am selben Tag getätigt wurden, sollten nicht einbezogen werden. Das Ergebnis ist einfach eine Liste von qualifizierenden user_id-Werten.

Datenansicht

Die Tabelle amazon_transactions hat eine Zeile pro Kauf, mit user_id, item, created_at-Datum und revenue.

Hier ist eine Vorschau der Tabelle:

  • id
  • user_id
  • item
  • created_at
  • revenue

Grain (was eine Ausgabereihe bedeutet): eine user_id, die innerhalb von 7 Tagen nach ihrem ersten Kauf einen qualifizierenden Rückkauf getätigt hat.

Randfall

Käufe am selben Tag sollten ignoriert werden, was bedeutet, dass der Abstand zwischen dem ersten und dem zweiten Kauf mehr als 0 Tage und höchstens 7 Tage betragen muss. Ein Kunde, der am selben Tag zweimal kauft, qualifiziert sich nicht.

Lösungen

Beide Lösungen finden das früheste Kaufdatum jedes Nutzers und filtern dann nach nachfolgenden Käufen innerhalb des 1- bis 7-Tage-Zeitraums. Ein Punkt, auf den man achten sollte: Wenn created_at Zeitstempel anstelle von reinen Daten enthält, müssen diese vor dem Vergleich auf das Datum gekürzt werden. Andernfalls würden zwei Käufe, die zu unterschiedlichen Zeiten am selben Tag getätigt wurden, fälschlicherweise die strenge Ungleichheit bestehen.

  • Pandas-Lösung
    import pandas as pd
    amazon_transactions["purchase_date"] = pd.to_datetime(amazon_transactions["created_at"]).dt.date
    daily = amazon_transactions[["user_id", "purchase_date"]].drop_duplicates()
    ranked = daily.sort_values(["user_id", "purchase_date"])
    ranked["rn"] = ranked.groupby("user_id").cumcount() + 1
    first_two = (ranked[ranked["rn"] = 1) & (first_two["diff"]
    
  • Polars-Lösung
    import polars as pl
    # Rückkehrende aktive Nutzer: 2. Kauf 1–7 Tage nach dem ersten (ignorieren Sie denselben Tag)
    returning_users = (
     amazon_transactions
     .lazy()
     # erstes Kaufdatum pro Nutzer (Fenster, damit wir .groupby auf LazyFrame vermeiden)
     .with_columns(
     pl.col("created_at").min().over("user_id").alias("first_purchase_date")
     )
     # Transaktionen, die strikt 1-7 Tage nach diesem ersten Kauf liegen
     .filter(
     (pl.col("created_at") > pl.col("first_purchase_date")) &
     (pl.col("created_at") < (pl.col("first_purchase_date") + pl.duration(days=7)))
     )
     .unique("user_id")
     .collect()
    )
    

Leistungsvergleich

Beachten Sie die Anzahl der unterschiedlichen DataFrame-Zuweisungen in der Pandas-Lösung: die deduplizierte tägliche Tabelle, die sortierte Rangtabelle, der pivotierte Rahmen, das Ergebnis von dropna und die gefilterte Ausgabe. Diese bestehen aus fünf separaten Objekten, von denen jedes Daten in einen neuen Speicherblock kopiert. Bei einer großen Transaktionstabelle kann der Pivot-Schritt allein den Speicherverbrauch erheblich erhöhen, da er den gesamten Datensatz in ein breites Format umformt.

Die Polars-Lazy-Kette allokiert keinen Speicher, bis .collect() aufgerufen wird. Der Fenster-Ausdruck .over("user_id") berechnet das früheste Kaufdatum jedes Nutzers in einem Durchgang, die .filter()-Anweisung wird sofort im selben Schritt angewendet, und .unique() läuft parallel über die CPU-Kerne. Es gibt keinen Pivot, keine zwischenzeitliche sortierte Kopie und keinen separaten Datumsumwandlungsschritt – Polars behandelt die Datumsarithmetik nativ innerhalb der Ausdrucksengine. Dieser Ansatz verbraucht weniger Speicher und läuft schneller, selbst bei moderat großen Datensätzen.

Monatlicher Verkaufsdurchschnitt: Verwendung von expanding().mean() vs. cum_mean()

In dieser Frage sollen wir einen kumulierten Durchschnitt der monatlichen Buchverkäufe im Jahr 2022 ermitteln. Der Durchschnitt wächst jeden Monat unter Verwendung aller vorhergehenden Monate: Der Februar durchschnittet Januar und Februar, der März durchschnittet alle drei und so weiter. Die Ausgabe sollte den Monat, die Gesamtverkäufe dieses Monats und den kumulierten Durchschnitt, gerundet auf die nächste ganze Zahl, umfassen.

Datenansicht

Die Tabelle amazon_books hat eine Zeile pro Buch und dessen Stückpreis. Die Tabelle book_orders hat eine Zeile pro Bestellung, die eine Buch-ID mit einer Menge und einem Bestelldatum verknüpft. Hier ist eine Vorschau der Tabellen:

  • book_id
  • book_title
  • unit_price
  • order_id
  • order_date
  • book_id
  • quantity

Grain (was eine Ausgabereihe bedeutet): ein Monat im Jahr 2022, mit den Gesamtverkäufen für diesen Monat und einem kumulierten Durchschnitt aller monatlichen Verkäufe bis einschließlich dieses Monats.

Trade-Offs

Die Verwendung von Pandas mit der Klausel .expanding().mean() ist praktisch, funktioniert jedoch intern mit einer Python-Schleife über wachsende Fensterabschnitte. Für eine 12-Zeilen-Monatszusammenfassung ist dieser Aufwand vernachlässigbar. Bei täglichen oder stündlichen Daten im großen Maßstab (zum Beispiel drei Jahre stündlicher Transaktionen) erhöht jeder wachsende Fensterabschnitt den Overhead, der sich zeilenweise summiert.

Polars' cum_mean() führt einen einzigen Durchgang in Rust aus und ist bei großen Datenmengen von Natur aus schneller. Es gibt jedoch einen Haken: Die Frage verlangt eine Rundung auf die nächste ganze Zahl, und Pandas verwendet standardmäßig die Banker-Rundung (runden zur nächsten geraden Zahl). Die Polars-Lösung verwendet NumPys cumsum mit einer expliziten floor(x + 0.5)-Formel, um das Verhalten „runden zur nächsten Zahl“ zu gewährleisten. Wenn Sie eine exakte Übereinstimmung mit der erwarteten Ausgabe benötigen, ist die NumPy-Methode zuverlässiger als die integrierte Rundung in beiden Bibliotheken.

Lösungen

  • Pandas-Lösung
    import pandas as pd
    import numpy as np
    import datetime as dt
    merged = pd.merge(book_orders, amazon_books, on="book_id", how="inner")
    merged["order_date"] = pd.to_datetime(merged["order_date"])
    merged["order_month"] = merged["order_date"].dt.month
    merged["year"] = merged["order_date"].dt.year
    merged["sales"] = merged["unit_price"] * merged["quantity"]
    merged = merged.loc[(merged["year"] == 2022), :]
    result = (
     merged.groupby("order_month")["sales"]
     .sum()
     .to_frame("monthly_sales")
     .sort_values(by="order_month")
     .reset_index()
    )
    result["rolling_average"] = result["monthly_sales"].expanding().mean().round(0)
    result
    
  • Polars-Lösung
    import polars as pl
    import numpy as np
    # Schritt 1: Vorbereiten der monatlichen Verkäufe (LazyFrame)
    monthly_sales_lazy = (
     book_orders.lazy()
     .join(amazon_books.lazy(), on="book_id", how="inner")
     .with_columns([
     (pl.col("unit_price") * pl.col("quantity")).alias("sales"),
     pl.col("order_date").cast(pl.Datetime),
     pl.col("order_date").dt.year().alias("year"),
     pl.col("order_date").dt.month().alias("order_month")
     ])
     .filter(pl.col("year") == 2022)
     .group_by("order_month")
     .agg(pl.col("sales").sum().alias("monthly_sales"))
     .sort("order_month")
    )
    # Schritt 2: Wechseln in den eager-Modus für die rollende Berechnung
    monthly_sales = monthly_sales_lazy.collect()
    

Berechnung des rollierenden Durchschnitts und Abschluss

Mit den monatlichen Verkäufen als NumPy-Array wenden wir die Rundung „runden zur nächsten Zahl“ an, fügen das Ergebnis zurück zum Polars DataFrame hinzu und wählen die Ausgabespalten aus.

# Schritt 3: Rollierender Durchschnitt mit runden zur nächsten Zahl
sales_np = monthly_sales["monthly_sales"].to_numpy()
cumsum = np.cumsum(sales_np)
rolling_avg = np.floor(cumsum / np.arange(1, len(cumsum)+1) + 0.5).astype(int)
# Schritt 4: Zurück zum Polars DataFrame hinzufügen
monthly_sales = monthly_sales.with_columns([
 pl.Series("rolling_average", rolling_avg)
])
# Schritt 5: Endergebnis mit korrekten Spaltennamen
result = monthly_sales.select(["order_month", "monthly_sales", "rolling_average"])

Leistungsvergleich

Diese Frage hat zwei Operationen, die die Leistung am meisten beeinflussen: den Join und das kumulative Fenster. In Pandas führt pd.merge den Join aller Zeilen aus beiden Tabellen durch, bevor für 2022 gefiltert wird. Das bedeutet, dass die Bestellungen eines jeden Jahres verarbeitet werden, bevor Zeilen außerhalb des Zielzeitraums verworfen werden. Polars hingegen erstellt einen Lazy-Abfrageplan und schiebt die Bedingung (year == 2022) vor der Ausführung des Joins, sodass von Anfang an ein kleinerer Datensatz gejoined wird. Diese Prädikatsverschiebung erfolgt automatisch, ohne dass zusätzliches Schreiben erforderlich ist.

Der auffälligste Unterschied ist die Lücke beim rollierenden Durchschnitt. Pandas' .expanding().mean() vergrößert sein Fenster zeilenweise und ruft für jedes Segment in C auf, während es von einer Python-Schleife gesteuert wird. Polars' cum_mean() berechnet die gesamte Spalte in einer einzigen Rust-Schleife ohne Python-Overhead. Während der Unterschied bei monatlichen Daten möglicherweise nicht wahrnehmbar ist, kann die Verwendung von Python-Dekoratoren für leistungsstarke Datenpipelines helfen, die Effizienz zu steigern.

Mehr zum Thema