Ai scris un script pandas. Face ce trebuie pe eșantionul de test. Apoi îl îndrepți spre fișierul real și pleci să-ți faci o cafea, iar când te întorci încă rulează. Sau, mai rău, rulează pe laptopul unui coleg și el te întreabă politicos cât ar trebui să dureze.
Lecția asta e bucla de diagnostic pentru performanța pandas: cum să-ți dai seama de ce e lent, cele cinci lucruri care sunt aproape întotdeauna cauza și plafonul pragmatic la care răspunsul corect încetează să mai fie „optimizează codul pandas” și începe să fie „rescrie în Polars sau DuckDB”. Am văzut partea cu dtype-uri în lecția 33 (categories, string[pyarrow], int-uri nullable); astăzi o punem în cadrul mai larg.
Lentoarea pandas are cinci arome
Din experiența mea, există într-adevăr doar cinci motive pentru care pandas e lent și, odată ce le cunoști, fiecare „scriptul ăsta durează 40 de minute” se transformă în „aha, e aroma 2.” Sunt, în ordinea aproximativă a frecvenței cu care le văd:
- Dtype-uri proaste. O coloană care ar trebui să fie
categoryeobject. O coloană de întregi a fost împinsă lafloat64din cauza unui NaN. O coloană de string-uri de 5 milioane de rânduri trăiește ca string-uri Pythonobjectîn loc destring[pyarrow]. Memoria explodează, fiecare operație e mai lentă decât ar trebui și GC-ul începe să se zbată. apply()cu o lambda Python. Asta e cea mare.df["col"].apply(lambda x: ...)e unforPython deghizat: fiecare rând traversează granița C/Python, GIL-ul e ținut, iar tu ai aruncat fiecare avantaj al folosirii unui DataFrame.- Lucrul pe întregul DataFrame când ai nevoie doar de o felie. Încărcarea a 200 de coloane ca să folosești 6. Filtrarea după un groupby în loc de înainte. Citirea a 10 milioane de rânduri în memorie ca să calculezi un singur număr.
- Operații single-threaded pe date care s-ar paraleliza. Pandas, prin design, rulează pe un singur core. Dacă laptopul tău are 16 core-uri și macini o oră, cincisprezece dintre ele se plictisesc.
- Prea multe date pentru o singură mașină. Ăsta e zidul. Pandas are nevoie de aproximativ 5-10x dimensiunea datelor în RAM în timpul operațiilor (copii intermediare, join-uri, buffer-e de groupby). Pentru 1 GB de date pe o mașină de 32 GB ești ok. Pentru 50 GB de date pe o mașină de 64 GB nu ești și niciun fel de tuning nu te va salva.
Bucla de diagnostic se mapează unu la unu pe acestea. Hai s-o rulăm.
Bucla de diagnostic
Într-un notebook Jupyter (sau ipython), workflow-ul e:
%%timeit
result = df.groupby("country")["revenue"].transform(lambda s: s - s.mean())
%%timeit rulează celula de mai multe ori și-ți dă un număr stabil de wall-clock. Folosește-l pe oricare linie e suspectă. Dacă tot scriptul e lent, comentează lucruri până găsești infractorul; de nouă ori din zece e o singură linie.
Odată ce ai linia lentă, verifică trei lucruri în ordine:
df.info(memory_usage="deep")
Asta îți arată fiecare coloană, dtype-ul ei și amprenta de memorie. Caută:
- Dtype-uri
objectcare ar trebui să fiecategorysaustring[pyarrow]. - Coloane
float64care sunt conceptual întregi (probabil au căpătat un NaN undeva mai sus). - Orice coloană unde memoria deep e nebunește disproporționată: o coloană de string-uri de 5 milioane de rânduri la 1.5 GB e bottleneck-ul tău.
Apoi uită-te la operația în sine:
- E
.applycu o lambda Python? Aproape sigur poate fi vectorizată. - E un
groupbypeste o coloană care e încă dtypeobject? Cast lacategorymai întâi; groupby pe category e de mai multe ori mai rapid. - Copiezi frame-ul de mai mult de o dată? Fiecare
.copy()și multe apeluri.assignînlănțuite alocă.
Acel triaj identifică de obicei pârghia. Acum, cele cinci pârghii.
Pârghia 1: dtype-uri mai bune
Ăsta e cel mai ieftin câștig. Cast-ează coloanele de string-uri object cu cardinalitate scăzută la category; cast-ează tot restul la string[pyarrow]; transformă coloanele de întregi care au alunecat la float înapoi la Int64 nullable. Un script tipic aplicat pe 5 milioane de rânduri de date despre clienți scade de la 4 GB memorie rezidentă la 600 MB, iar groupby-urile merg de 5-10x mai rapid, totul dintr-o singură trecere în partea de sus a scriptului:
df = df.astype({
"country": "category",
"currency": "category",
"tier": "category",
"customer_id": "string[pyarrow]",
"name": "string[pyarrow]",
"order_id": "Int64",
})
Sau, mult mai bine, declar-o la citire ca să nu plătești niciodată taxa de dtype prost (dtype= la read_csv, sau pur și simplu folosește Parquet, care poartă dtype-urile nativ).
Pârghia 2: vectorizează
Dacă ai o singură optimizare de învățat din lecția asta, învaț-o pe asta. apply cu o lambda Python e cel mai lent lucru din pandas și aproape întotdeauna poate fi înlocuit cu ceva de douăzeci până la o sută de ori mai rapid.
Pattern-ul e: orice face lambda ta, găsește operația echivalentă la nivel de coloană și apeleaz-o. Exemple concrete.
Coloană condițională. Lumea scrie adesea:
df["band"] = df["age"].apply(lambda x: "young" if x < 30 else "old")
Înlocuiește cu np.where sau pd.cut:
import numpy as np
df["band"] = np.where(df["age"] < 30, "young", "old")
Pentru mai multe benzi, pd.cut sau np.select:
df["band"] = pd.cut(df["age"], bins=[0, 18, 30, 60, 200], labels=["minor", "young", "adult", "senior"])
Ambele sunt vectorizate, ambele sunt rapide, ambele rulează în C.
Operații pe string-uri. Lumea scrie:
df["domain"] = df["email"].apply(lambda s: s.split("@")[1].lower())
Pandas are un accesor de string-uri exact pentru asta:
df["domain"] = df["email"].str.split("@").str[1].str.lower()
Accesorul .str se mapează pe kernel-uri vectorizate de string-uri. Cu dtype-ul string[pyarrow], acele kernel-uri rulează în codul de string-uri compilat al Arrow și sunt cam de zece ori mai rapide decât echivalentul Python.
Matematică pe mai multe coloane. Lumea scrie:
df["score"] = df.apply(lambda r: r["a"] * 0.5 + r["b"] * 0.3 + r["c"] * 0.2, axis=1)
Asta e axis=1, cea mai rea formă de apply fiindcă iterează rând cu rând prin Python. Vectorizează:
df["score"] = df["a"] * 0.5 + df["b"] * 0.3 + df["c"] * 0.2
De o sută de ori mai rapid. În 2026 nu există în esență scuză pentru df.apply(..., axis=1); dacă te trezești că dai mâna spre el, oprește-te și gândește-te ce operație la nivel de coloană ar face același lucru.
Singurul loc legitim pentru apply e atunci când calculul per-rând e cu adevărat complex (apel la un API, rularea unui model, ceva ce lumea vectorizată nu poate exprima). Și chiar și atunci, fă batching, rulează-l o singură dată pe valorile unice și fă join înapoi.
Pârghia 3: citiri pe chunk-uri
Dacă datele tale nu intră în memorie dar operația e per-rând sau agregabilă, fă streaming. Fiecare pd.read_* acceptă chunksize=:
totals: dict[str, float] = {}
for chunk in pd.read_csv("huge.csv", chunksize=500_000, dtype_backend="pyarrow"):
for country, sub in chunk.groupby("country"):
totals[country] = totals.get(country, 0) + sub["revenue"].sum()
Fiecare chunk e un DataFrame normal; îl procesezi și mergi mai departe. Memoria rămâne mărginită la dimensiunea unui chunk, indiferent de dimensiunea fișierului. Asta funcționează pentru sume, count-uri, top-K, orice agregabil. Nu funcționează pentru operații care trebuie să vadă tot frame-ul (sortarea întregului fișier, calcularea unei percentile globale); pentru acelea, pârghia 5.
Pârghia 4: query() și eval()
Pentru lanțuri lungi de aritmetică, df.eval și df.query folosesc numexpr în culise, care evaluează întreaga expresie în C, în paralel, fără alocări intermediare:
# In loc de
df["score"] = (df["a"] + df["b"]) * df["c"] - df["d"] / df["e"]
# Foloseste
df = df.eval("score = (a + b) * c - d / e")
# In loc de
result = df[(df["age"] > 30) & (df["country"] == "IT") & (df["revenue"] > 1000)]
# Foloseste
result = df.query("age > 30 and country == 'IT' and revenue > 1000")
Speedup-ul e modest (tipic 2-3x) și pierzi puțin ajutor de la IDE, deci nu apela la astea pentru expresii scurte. Pentru lanțuri de cinci-plus operații pe frame-uri mari, merită.
Pârghia 5: părăsește pandas
Uneori răspunsul corect nu e să optimizezi codul pandas, ci să admiți că problema a depășit unealta. Cele două ieșiri principale în 2026 sunt:
- Polars pentru aceeași muncă pe DataFrame in-memory dar cu un core în Rust, multithreading și un query optimizer. API-ul lazy poate rula query-uri pe date care nu intră în memorie via streaming. Asta e subiectul lecției 35.
- DuckDB pentru query-uri analitice de tip SQL pe fișiere Parquet sau frame-uri pandas.
duckdb.query("SELECT country, SUM(revenue) FROM 'data/*.parquet' GROUP BY 1").to_df()rulează cam la viteza unei baze de date column-store, pe laptopul tău, cu zero setup.
O euristică aproximativă: dacă datele tale intră confortabil în RAM (sub, să zicem, 5 GB pe o mașină de 32 GB) și scriptul rulează în mai puțin de un minut, rămâi cu pandas. Dacă scriptul durează 10+ minute și ai tras deja pârghiile 1-4, schimbă. Dacă datele nu intră deloc, nu ai de ales: Polars în mod streaming sau DuckDB.
Despre asistenții AI și optimizarea pandas
Ăsta e unul dintre cazurile în care asistenții AI sunt fiabil utili. Lipește o funcție lentă în Claude sau ChatGPT cu prompt-ul „asta e lent, găsește bottleneck-ul și rescrie-o” și sugestiile sunt de obicei corecte: schimbă apply în np.where, cast-ează coloana asta la category, înlocuiește merge-ul cu un join pe index. Modelul a văzut zece mii de răspunsuri pandas pe Stack Overflow, iar pattern-urile sunt mecanice.
Unde asistenții sunt mai puțin fiabili e întrebarea arhitecturală: când să abandonezi complet pandas. Vor optimiza fericiți un script care ar trebui să fie un query Polars sau un apel DuckDB, câștigând 3x când o rescriere ar câștiga 30x. Acea judecată rămâne a ta, iar euristica de mai sus e începutul ei.
Memoria: zidul
Regula de cinci-până-la-zece-ori merită repetată fiindcă îi surprinde pe oameni. Un CSV de 1 GB devine un DataFrame de aproximativ 1.5-3 GB în memorie (dtype-urile pandas sunt mai late decât bytes-urile CSV, dar string-urile susținute de Arrow restrâng asta). Un groupby pe el alocă buffer-e intermediare care dublează sau triplează asta. Un merge cu un alt frame poate aloca produsul cartezian. Deci:
- 1 GB date, 32 GB RAM: confortabil.
- 5 GB date, 32 GB RAM: strâmt, mai ales cu merge-uri.
- 10 GB date, 32 GB RAM: vei lovi swap-ul; performanța cade de pe stâncă.
- 10 GB date, 64 GB RAM: lucrabil.
- 50 GB date, orice laptop: nu pandas.
Zidul e real și nu e o problemă de configurare; e costul ținerii a tot în RAM deodată. Ieșirea e fie un motor de streaming (Polars scan_* cu collect(streaming=True), DuckDB), fie un motor distribuit (Spark, Dask), iar pe primul îl vom vedea în lecția următoare.
Ce urmează
Lecția 35 e capitolul Polars: ce face altfel, de ce API-ul lazy e punctul real și cum să-l citești dacă deja gândești în pandas. După aceea, lecția de proiect, iar apoi se închide Modulul 6.
Lecturi suplimentare
- pandas: Enhancing performance ghidul oficial pentru
eval,queryși escape hatches Cython. Consultat 2026-05-01. - pandas: Scaling to large datasets chunking, trucuri de dtype, când să pleci. Consultat 2026-05-01.
Ne vedem marți.