Python, de la zero Lecția 29 / 60

Agregare: groupBy, agg, transform, alegerea potrivita

GroupBy la scara, agregari denumite, diferenta dintre agg/apply/transform si pattern-urile pe care le vei folosi saptamanal.

Dacă selecția înseamnă „alege rânduri”, agregarea înseamnă „sumarizează rânduri”. Odată ce ai filtrat până la datele care te interesează, aproape orice analiză se termină cu o întrebare de forma „media per țară”, „maximul per client”, „primul eveniment per sesiune”. Pandas are o operație de bază pentru asta, groupby, și patru metode pe care le poți apela pe rezultat: agg, transform, apply, filter. Arată asemănător și fac lucruri foarte diferite. Astăzi obținem distincția corect, fiindcă alegerea celei greșite e diferența dintre o interogare care rulează în 30 de milisecunde și una care rulează în 30 de secunde.

Vom lucra cu asta pe parcurs:

import pandas as pd

pd.set_option("mode.copy_on_write", True)

df = pd.DataFrame(
    {
        "country": ["IT", "DE", "FR", "IT", "DE", "FR", "IT", "DE"],
        "year":    [2024, 2024, 2024, 2025, 2025, 2025, 2025, 2024],
        "channel": ["web", "web", "web", "app", "app", "web", "web", "app"],
        "revenue": [120.0, 340.0, 210.0, 145.0, 380.0, 225.0, 95.0, 290.0],
        "orders":  [12, 25, 18, 15, 28, 19, 9, 22],
    }
)

Forma de bază

df.groupby("col") întoarce un obiect DataFrameGroupBy: nu un rezultat, un intermediar. Pandas nu iterează efectiv datele până nu apelezi o metodă de agregare pe el:

df.groupby("country").sum(numeric_only=True)
df.groupby("country").mean(numeric_only=True)
df.groupby("country").size()                   # numarul de randuri per grup

numeric_only=True e o practică bună: fără el, pandas va încerca să sumeze string-uri (ceea ce „funcționează” prin concatenare) sau va da eroare pe tipuri mixte. În pandas 2.x implicit e să dea eroare pe coloane non-numerice pentru sum/mean, ceea ce e implicitul corect dar înseamnă că setezi flag-ul explicit când îl vrei.

Output-ul e un DataFrame indexat după cheia de grup. Cheia de grup s-a mutat de la coloană la index: utilă pentru graficare și pentru join-uri ulterioare, dar o sursă comună de „stai, unde s-a dus coloana mea country?”. Dacă o vrei ca pe o coloană, fie apelează .reset_index(), fie pasează as_index=False:

df.groupby("country", as_index=False).sum(numeric_only=True)

Groupby pe mai multe chei: grupează după un tuplu de coloane:

df.groupby(["country", "year"]).sum(numeric_only=True)

Rezultatul are un MultiIndex pe rânduri. Poți muta ambele chei înapoi la coloane cu .reset_index() sau folosi as_index=False.

.agg: agregări diferite per coloană

sum, mean, min, max, count, nunique, median, std, var, first, last: toate disponibile ca metode pe groupby. Când vrei agregări diferite pentru coloane diferite, asta e .agg:

df.groupby("country").agg(
    {
        "revenue": "sum",
        "orders": "mean",
    }
)

Poți pasa o listă pe coloană ca să obții mai multe agregări:

df.groupby("country").agg(
    {
        "revenue": ["sum", "mean", "max"],
        "orders": ["sum", "min"],
    }
)

Rezultatul are un MultiIndex pe coloane: ("revenue", "sum"), ("revenue", "mean"), etc. E incomod de manevrat în aval: df["revenue"]["sum"] în loc de df["revenue_sum"]. Ceea ce ne aduce la modul modern.

Agregările denumite: așa se face

Pandas 0.25 a adăugat agregările denumite și odată ce le vezi nu te mai întorci. Forma:

df.groupby("country").agg(
    total_revenue=("revenue", "sum"),
    avg_revenue=("revenue", "mean"),
    total_orders=("orders", "sum"),
    n_rows=("revenue", "size"),
)

Fiecare argument cu nume e un tuplu (coloana_sursa, functie_agregare), iar numele argumentului devine numele coloanei din rezultat. Fără MultiIndex. Coloane plate. Auto-documentate. Asta e forma pe care ar trebui s-o scrii în 2026.

Agregări custom? Pasează un callable:

df.groupby("country").agg(
    revenue_range=("revenue", lambda s: s.max() - s.min()),
    p95_revenue=("revenue", lambda s: s.quantile(0.95)),
)

lambda s: ... primește un Series: valorile acelei coloane pentru un grup. Valoarea de retur poate fi un scalar (devine o celulă) sau un tuplu/listă (rar, avansat).

Pentru „numără rândurile per grup”, preferă ("any_column", "size") în loc de ("any_column", "count"): vezi secțiunea următoare pentru de ce.

size vs count, pe scurt

Două funcții care sună la fel și nu sunt:

  • .size() întoarce numărul de rânduri din fiecare grup, inclusiv rândurile în care valoarea e NaN. E o proprietate a grupului, nu a vreunei coloane.
  • .count() întoarce numărul de valori non-null din fiecare coloană a fiecărui grup. Variază pe coloană.
df.groupby("country").size()                       # randuri per tara
df.groupby("country").count()                      # non-null per coloana per tara
df.groupby("country").agg(n=("revenue", "size"))   # versiunea cu nume

Dacă vrei „câte rânduri sunt în acest grup”, folosește size. Dacă vrei „câte dintre rânduri au un revenue non-null”, folosește count. Confundarea uneia cu cealaltă produce tăcut dashboard-uri greșite.

.transform: difuzează înapoi la forma originală

agg reduce fiecare grup la un rând. transform face o agregare dar difuzează rezultatul înapoi la fiecare rând din grup, deci output-ul are aceeași lungime ca input-ul. Asta e funcționalitatea-cheie de care nu știai că ai nevoie.

Utilizarea clasică: calculează o deviație per rând de la media grupului.

df["country_avg_revenue"] = df.groupby("country")["revenue"].transform("mean")
df["revenue_vs_avg"] = df["revenue"] - df["country_avg_revenue"]

Sau, normalizează în interiorul grupului:

df["revenue_share_of_country"] = (
    df["revenue"] / df.groupby("country")["revenue"].transform("sum")
)

E o singură expresie, vectorizată, rapidă. Alternativa, să construiești un DataFrame agregat separat și apoi să-l aduci înapoi cu merge pe cheia de grup, înseamnă trei rânduri, două obiecte în plus și un ordin de mărime mai lent. Ori de câte ori te trezești că vrei o valoare per rând care depinde de grupul lui, întinde-te după transform.

transform acceptă aceleași agregări string ca agg ("mean", "sum", "max", "min", "count", "std", "rank"), plus orice callable care primește un Series și întoarce un Series de aceeași lungime:

df["revenue_z"] = df.groupby("country")["revenue"].transform(
    lambda s: (s - s.mean()) / s.std()
)

.apply: portița de scăpare (folosește cu măsură)

apply e cea mai flexibilă metodă groupby și cea mai lentă. Apelează o funcție o dată per grup, cu DataFrame-ul complet al grupului, și coase rezultatele la un loc:

def top_n(group: pd.DataFrame, n: int = 2) -> pd.DataFrame:
    return group.nlargest(n, "revenue")

df.groupby("country").apply(top_n, n=2, include_groups=False)

Pandas 2.2+ cere include_groups=False pe apply când cheile groupby sunt și ele pasate înăuntru: altfel îți va arunca un avertisment de deprecare. Setează-l.

Când e apply răspunsul corect? Când operația are nevoie sincer de DataFrame-ul întreg al grupului și nu poate fi exprimată ca o reducere pe coloană sau o difuzare per rând. „Top N rânduri per grup”, „potrivește o regresie per grup”, „logică complexă pe mai multe coloane care nu se mapează la agg”. Pentru orice altceva, agregare coloană cu coloană, statistici de grup per rând, folosește agg sau transform. Sunt vectorizate; apply e o buclă Python deghizată.

O regulă aproximativă: dacă o poți exprima fără apply, ar trebui s-o faci.

.filter: păstrează doar unele grupuri

filter ia o funcție care întoarce True/False per grup și păstrează rândurile grupurilor în care funcția întoarce True:

df.groupby("country").filter(lambda g: len(g) >= 3)

Asta întoarce DataFrame-ul de formă originală, dar doar cu rândurile din țările care au cel puțin trei înregistrări. Util pentru „elimină categoriile rare înainte de antrenarea unui model” sau „ignoră clienții ocazionali”. Reține că filter aici e metoda groupby, nu metoda DataFrame .filter (care e ceva complet diferit, un selector pe nume de coloană). Numirea pandas, of.

Pivot table-uri: groupby plus reshape

pivot_table e groupby cu un reshape încorporat. Acolo unde groupby([a, b]).agg(...) produce un DataFrame în formă lungă cu un MultiIndex, pivot_table produce unul în formă lată: una dintre chei devine coloane:

df.pivot_table(
    values="revenue",
    index="country",
    columns="year",
    aggfunc="sum",
    fill_value=0,
)

Obții un DataFrame cu țările ca rânduri, anii ca coloane, venitul însumat în celule. fill_value=0 zice „dacă o combinație (country, year) nu are date, scrie zero în loc de NaN”, de obicei ce vrei pentru un tabel de prezentare.

Mai multe agregări și coloane de valori funcționează și ele:

df.pivot_table(
    values=["revenue", "orders"],
    index="country",
    columns="year",
    aggfunc={"revenue": "sum", "orders": "mean"},
)

Rezultatul e lat și prezentabil. Pentru consum în aval prelucrabil de mașini, preferă forma lungă (groupby + named agg) și fă reshape doar la final.

Combinând: filtrare, grupare, agregare, sortare

Analiza reală înlănțuie astea. Pandas se citește de sus în jos când împachetezi un lanț de metode în paranteze:

result = (
    df
    .loc[df["revenue"].notna()]
    .groupby(["country", "year"], as_index=False)
    .agg(
        total_revenue=("revenue", "sum"),
        total_orders=("orders", "sum"),
        avg_revenue=("revenue", "mean"),
        n_rows=("revenue", "size"),
    )
    .assign(revenue_per_order=lambda d: d["total_revenue"] / d["total_orders"])
    .sort_values(["country", "year"])
    .reset_index(drop=True)
)

.assign creează o coloană nouă dintr-un lambda care primește DataFrame-ul în lucru: util în lanțuri fiindcă nu poți scrie df["x"] = ... la mijlocul lanțului. .sort_values sortează rândurile; .reset_index(drop=True) reconstruiește un RangeIndex curat la final, ca rezultatul să fie prezentabil.

Asta e forma a 90% din codul analitic pandas pe care-l scriu: filtrare, groupby cu agregări denumite, coloane derivate prin assign, sortare, reset. Memorează scheletul.

Un sac amestecat de agregări care merită știute

Câteva agregări specifice apar suficient de des încât să le numesc:

  • first / last: întorc prima/ultima valoare din fiecare grup. Ordinea contează, deci de obicei precedate de df.sort_values(...). Utile pentru „prima sumă a comenzii clientului” sau „cel mai recent status per ticket”.
  • nunique: numărul de valori distincte. df.groupby("country")["customer_id"].nunique() îți dă numărul de clienți distincți per țară.
  • idxmax / idxmin: eticheta de index a rândului cu valoarea max/min. Combinat cu .loc, așa alegi rândul complet care conține maximul per grup: df.loc[df.groupby("country")["revenue"].idxmax()].
  • quantile: df.groupby("country")["revenue"].quantile(0.95). Percentila 95 per grup, etc. Cu agregări denumite: p95=("revenue", lambda s: s.quantile(0.95)).
  • agg(list): colectează toate valorile din grup într-o listă Python, o celulă per grup. Util pentru „ce articole erau în această comandă?”. Lent pe date mari; în regulă pentru output-uri la scară umană.

Note de performanță

  • Agregările vectorizate sunt de 10-100x mai rapide decât .apply cu o funcție Python. „sum”, „mean”, „max” etc. dispatch-uiesc la C; lambdas nu. Dacă te trezești că te întinzi după .apply într-o cale fierbinte, întreabă mai întâi dacă agg plus o utilizare creativă a lui transform poate face aceeași treabă: de obicei poate.
  • as_index=False e mai ieftin decât .reset_index() fiindcă pandas nu construiește MultiIndex-ul doar ca să-l arunce. Pentru cod de pipeline care se termină mereu într-un DataFrame plat, implicit folosește as_index=False.
  • observed=True contează cu chei de grup categorice. Implicit, groupby pe o coloană category produce un rând pentru fiecare valoare a categoriei, chiar și pentru cele fără date. Cu observed=True, apar doar valorile prezente, ceea ce e aproape mereu ce vrei și e notabil mai rapid pentru categorii cu cardinalitate mare. Pandas 3.0 va schimba implicitul; între timp, setează-l.
  • sort=False sare peste sortarea alfabetică a cheilor de grup. Implicitul sort=True e în regulă pentru rezultate mici dar adaugă overhead notabil pe milioane de grupuri, iar oricum probabil vrei o sortare specifică domeniului la finalul pipeline-ului.
  • Pentru date cu adevărat mari, pattern-ul groupby-then-agg e ce fac DuckDB și Polars nativ și mai rapid. Dacă DataFrame-ul tău are mai mult de câțiva GB, ia în considerare să faci agregarea în DuckDB (duckdb.query("SELECT country, SUM(revenue) FROM df GROUP BY country").to_df()) și să citești rezultatul mic înapoi în pandas.

Ce urmează

Lecția 30 e despre join-uri: merge, concat și comportamentul de aliniere a indexului care a salvat sau ruinat multe analize. Lecția 32 va acoperi funcțiile de fereastră (rolling, expanding, ranking): verii cu aromă de serii temporale ai celor făcute azi cu transform.

Lectură suplimentară

Ne vedem vineri.

Caută