Python, de la zero Lecția 35 / 60

Polars: alternativa moderna

Ce face Polars altfel, API-ul lazy care il face rapid si turul de sintaxa pentru utilizatorii pandas.

Am petrecut zece lecții în interiorul pandas, ceea ce e modul în care în 2026 încă se desfășoară majoritatea muncii Python de date în producție: șanțul ecosistemului e enorm, scikit-learn vrea un DataFrame, fiecare notebook de pe Kaggle începe cu import pandas as pd. Dar pandas nu mai e singura bibliotecă de DataFrame, iar pentru anumite tipuri de muncă nu mai e cea mai bună. Astăzi îl întâlnim pe Polars, biblioteca de DataFrame din a doua generație, și învățăm destul cât să-i citim codul, să scriem un pipeline cu el și să judecăm când să apelăm la el.

De unde vine Polars

Polars a fost început de Ritchie Vink, un inginer olandez, în 2020. Era frustrat de performanța pandas pe genul de date medii-mari (10-100 GB) care e comun în industrie dar incomod pentru pandas (prea mari ca să fie confortabile, prea mici ca să justifice Spark). A scris core-ul în Rust, cu Apache Arrow ca format in-memory, și un binding Python deasupra. Primul release 1.0 a fost în 2024; până în 2026 Polars e pe versiunea 1.x, matur, și e biblioteca de DataFrame implicită pentru o porțiune semnificativă din munca Python de date nouă.

Cele patru decizii de design care fac Polars diferit de pandas:

  1. Core în Rust, paralel by default. Fiecare operație care poate folosi mai multe core-uri o face, fără configurare. Pe un laptop cu 16 core-uri, un groupby Polars e cam de 8-15x mai rapid decât același groupby pandas, iar majoritatea acelei vitezi vine din core-uri.
  2. Format de memorie Arrow peste tot. Fără fallback NumPy; totul e Arrow columnar. String-uri, date, valori lipsă, toate native, toate eficiente.
  3. Un API lazy cu un query optimizer. Asta e bucata care îl deosebește pe Polars de „pandas rapid.” Mai multe imediat.
  4. Fără index. Frame-urile Polars sunt doar dreptunghiuri de coloane. Nu există .set_index, fără etichete de rânduri, fără ierarhie de multi-index. Tot ce face pandas cu indexul, Polars face cu coloane explicite. După prima zi de sevraj, asta e o ușurare.

Cele două API-uri: eager și lazy

Polars are două moduri de a exprima același calcul, iar diferența dintre ele e cel mai important lucru de înțeles despre bibliotecă.

Modul eager e ce face pandas: fiecare operație rulează imediat și returnează un nou DataFrame. E natural pentru explorare într-un notebook:

import polars as pl

df = pl.read_csv("sales.csv")
filtered = df.filter(pl.col("amount") > 100)
grouped = filtered.group_by("country").agg(pl.col("amount").sum())

Fiecare linie a rulat. Fiecare linie a alocat. Ordinea de execuție e exact ce ai tastat.

Modul lazy e cel care contează. În loc să rulezi fiecare pas, construiești un plan de query, îl predai lui Polars și lași planificatorul să optimizeze înainte de execuție:

result = (
    pl.scan_csv("sales.csv")               # scan, nu read - inca fara I/O
    .filter(pl.col("amount") > 100)
    .group_by("country")
    .agg(pl.col("amount").sum())
    .collect()                              # ACUM ruleaza
)

Observă scan_csv în loc de read_csv și .collect() la final. Până la collect(), nimic nu se execută; Polars a construit doar un plan. Apoi planificatorul se uită la planul întreg și îl rescrie: împinge filtrul jos în reader-ul de CSV (deci rândurile cu amount <= 100 nici nu sunt parsate), își dă seama ce coloane sunt efectiv folosite și citește doar pe alea, alege cea mai bună strategie paralelă și abia apoi rulează.

Ăsta e același truc pe care îl folosesc Spark și majoritatea motoarelor SQL moderne. Trecerea mentală pentru utilizatorii pandas e reală: în pandas, optimizezi tunând manual ordinea operațiilor. În modul lazy Polars, scrii operațiile în ordinea cea mai clară, iar planificatorul le reordonează.

Regula empirică: codul de producție ar trebui să folosească lazy. Notebook-urile și explorarea folosesc eager. Tranziția de la unul la celălalt e mică (schimbi read_* în scan_* și adaugi .collect() la final) dar speedup-ul pe date reale e adesea de 5-10x peste viteza deja-ridicată a Polars.

API-ul de expresii

Cealaltă schimbare mare e cum sunt referite coloanele. În pandas, df["x"] îți dă un Series; îl poți pasa, faci matematică pe el și îl atribui înapoi. În Polars, folosești pl.col("x") în interiorul expresiilor:

df.with_columns(
    (pl.col("revenue") * 1.22).alias("revenue_with_vat"),
    pl.col("country").str.to_uppercase().alias("country_upper"),
)

pl.col("revenue") nu e o valoare de coloană; e o expresie care spune „coloana numită revenue, în orice frame se aplică asta.” E o descriere, nu un fetch. Asta e ce-i permite planificatorului să raționeze despre query.

Tot de aceea df.with_columns(...) în loc de df["new"] = ... din pandas. Frame-urile Polars sunt imutabile; fiecare operație returnează un frame nou. Nu muți, derivi.

Câteva pattern-uri comune de expresii:

# Filtru
df.filter(pl.col("age") > 30)
df.filter((pl.col("age") > 30) & (pl.col("country") == "IT"))

# Adauga coloane
df.with_columns([
    (pl.col("a") + pl.col("b")).alias("sum"),
    pl.col("name").str.to_lowercase().alias("name_lower"),
])

# Group si agregheaza
df.group_by("country").agg([
    pl.col("revenue").sum().alias("total"),
    pl.col("revenue").mean().alias("avg"),
    pl.col("customer_id").n_unique().alias("customers"),
])

# Sort
df.sort("revenue", descending=True)

# Join (fara index, deci mereu explicit on=)
left.join(right, on="customer_id", how="inner")

Observă că fiecare agregat e numit explicit cu .alias(...). Polars nu auto-numește; spui ce vrei.

O fișă de comparație pandas-Polars

Maparea cea mai des necesară, una lângă alta:

SarcinăpandasPolars
Citește CSVpd.read_csv("f.csv")pl.read_csv("f.csv") / pl.scan_csv(...)
Citește Parquetpd.read_parquet(...)pl.read_parquet(...) / pl.scan_parquet(...)
Selectează coloanedf[["a", "b"]]df.select(["a", "b"])
Filtrează rânduridf[df["a"] > 5]df.filter(pl.col("a") > 5)
Adaugă coloanădf["c"] = df["a"] + df["b"]df.with_columns((pl.col("a") + pl.col("b")).alias("c"))
Elimină coloanădf.drop(columns=["a"])df.drop("a")
Group + sumdf.groupby("k")["x"].sum()df.group_by("k").agg(pl.col("x").sum())
Sortdf.sort_values("x")df.sort("x")
Joindf.merge(other, on="k")df.join(other, on="k")
Redenumeștedf.rename(columns={"a": "b"})df.rename({"a": "b"})
Parte de datădf["d"].dt.yeardf.with_columns(pl.col("d").dt.year())
Umple nulldf["x"].fillna(0)df.with_columns(pl.col("x").fill_null(0))
Apply (evită)df["x"].apply(f)df.with_columns(pl.col("x").map_elements(f))
Către pandas-df.to_pandas()
Din pandas-pl.from_pandas(pdf)

O notă despre apply: în Polars e map_elements, iar biblioteca îți va da un warning de runtime dacă îl folosești fiindcă Python per-rând e și mai evident răspunsul greșit aici decât în pandas; expresiile Polars acoperă aproape tot ce ai vrea.

Streaming pe date mai mari decât memoria

Ăsta e al doilea truc mare al Polars. collect(streaming=True) rulează planul lazy în mod streaming, procesând datele în chunk-uri sub capotă fără să materializeze vreodată tot frame-ul:

result = (
    pl.scan_parquet("data/year=2025/*.parquet")   # 80 GB pe fisiere
    .filter(pl.col("country") == "IT")
    .group_by("month")
    .agg(pl.col("revenue").sum())
    .collect(streaming=True)
)

Asta e forma unui query care ar topi absolut pandas (80 GB pe un laptop), iar Polars îl rulează în câteva minute cu memorie mărginită. Nu fiecare operație suportă încă streaming (join-uri pe frame-uri uriașe, anumite window functions) dar cazul comun (filter, group, aggregate) o face, iar acoperirea s-a tot lărgit cu fiecare release.

Lucrul cu ambele biblioteci

Conversia între Polars și pandas e ieftină, fiindcă ambele vorbesc Arrow:

# Frame Polars catre pandas
pdf = df.to_pandas(use_pyarrow_extension_array=True)

# Frame pandas catre Polars
df = pl.from_pandas(pdf)

Cu use_pyarrow_extension_array=True, conversia e zero-copy: aceeași memorie, vedere diferită. Așa că pattern-ul practic în 2026 e: folosește Polars pentru munca grea de date (load, filter, aggregate, transform), convertește la pandas doar când ai nevoie să alimentezi sklearn sau o bibliotecă de plotare care nu vorbește încă Polars:

features = (
    pl.scan_parquet("training_data.parquet")
    .filter(pl.col("year") >= 2024)
    .group_by("user_id")
    .agg([
        pl.col("revenue").sum().alias("total_rev"),
        pl.col("session_count").mean().alias("avg_sessions"),
    ])
    .collect()
)

# Predam catre scikit-learn
X = features.to_pandas()
model.fit(X.drop(columns=["user_id"]), y)

Asta e calea pragmatică: Polars pentru ingineria datelor, pandas pentru predarea către model.

Când câștigă Polars, când câștigă pandas

Polars câștigă când:

  • Datele sunt medii-mari (1 GB și mai sus). Core-ul Rust și paralelismul domină.
  • Codul e un pipeline (load, filter, group, aggregate, write) unde planificatorul lazy poate face muncă reală.
  • Pornești de la zero și nu trebuie să integrezi cu o bibliotecă pandas-only.
  • Datele nu intră în memorie și poți folosi streaming.

Pandas încă câștigă când:

  • Ai nevoie de scikit-learn / statsmodels / ceva vechi care vrea un DataFrame pandas ca input.
  • Faci explorare de probă într-un notebook pe date mici; API-ul pandas e mai iertător pentru întrebări one-off.
  • Codebase-ul e deja pandas și speedup-ul nu ar justifica rescrierea. Codebase-urile mixte devin confuze.
  • O bibliotecă de care depinzi (un connector de nișă, un SDK de domeniu) returnează un frame pandas și nu vrei să adaugi peste tot fricțiunea conversiei.

În practică, recomandarea mea în 2026 e: pipeline-uri noi, Polars; codebase-uri pandas existente, lasă-le în pace decât dacă lovesc un zid de performanță; explorare în notebook, oricare gândești mai rapid și, tot mai des, și asta e Polars, odată ce memoria musculară prinde.

Câteva lucruri care îi mușcă pe utilizatorii pandas

Trei puncte de fricțiune care merită semnalate, fiindcă acolo își petrece o persoană fluentă în pandas prima sa după-amiază de confuzie cu Polars:

Referințele de coloane nu sunt string-uri, sunt expresii. În pandas poți face df.groupby("country")["revenue"].sum() fiindcă numele de coloane sunt string-uri care indexează un frame. În Polars, aceeași operație e df.group_by("country").agg(pl.col("revenue").sum()); pl.col("revenue") e unitatea pe care operezi, nu string-ul. Odată ce asta prinde, fiecare semnătură de metodă Polars încetează să arate ciudat.

Fără atribuire înlănțuită. df["new_col"] = ... din pandas mută frame-ul. Frame-urile Polars sunt imutabile; trebuie să spui df = df.with_columns(...) și să reatribui. E enervant un minut și o ușurare pentru totdeauna după aceea, fiindcă întreaga clasă de bug-uri „am atribuit la o copie din greșeală” dispare.

Join-urile sunt explicite pe on=. Pandas folosește indexul pentru join-uri by default dacă nu treci on=. Polars nu are index, deci treci mereu on= (sau left_on= / right_on=). Mai multă tastare, mult mai puțină ghicire.

Gestionarea null este consecventă. Fiecare tip e nullable, valorile lipsă sunt null (nu NaN, nu NaT, nu None în funcție de dtype), iar pl.col("x").fill_null(0) funcționează mereu la fel. După ani de mizerie pandas cu NaN-vs-None-vs-NaT, asta e o mică bucurie.

Un mic exemplu lucrat

Forma completă a unui script Polars (read, transform, aggregate, write) ca să-ți dăm ritmul:

import polars as pl

result = (
    pl.scan_parquet("orders/*.parquet")
    .filter(pl.col("status") == "completed")
    .with_columns([
        (pl.col("quantity") * pl.col("unit_price")).alias("revenue"),
        pl.col("created_at").dt.month().alias("month"),
    ])
    .group_by(["country", "month"])
    .agg([
        pl.col("revenue").sum().alias("total_revenue"),
        pl.col("order_id").n_unique().alias("orders"),
        pl.col("customer_id").n_unique().alias("customers"),
    ])
    .sort(["country", "month"])
    .collect(streaming=True)
)

result.write_parquet("output/monthly_summary.parquet")

Citește-l o dată: scan, filter, derivă două coloane, group, agregă trei lucruri, sort, rulează în streaming, scrie. Nu există frame temporar, nicio variabilă intermediară, niciun .copy(). Tot calculul e o singură expresie, planificatorul îl vede ca o singură expresie și rulează la fel de repede pe cât permit discul și CPU-urile tale.

Ce urmează

Lecția 36 închide modulul cu un proiect end-to-end: încarci un dataset real, îl curăți, îl explorezi, răspunzi la o întrebare, scrii rezultatul, folosind pattern-urile din ultimele douăsprezece lecții. După asta, Modulul 7 e ingineria datelor: cum să iei un script de analiză ca cele pe care le-am scris și să-l transformi în ceva care rulează pe un program, gestionează eșecul și pe care nu trebuie să-l pui la pat.

Lecturi suplimentare

Ne vedem vineri pentru proiect.

Caută