Dacă lucrați cu date tabulare în Python, probabil ați ajuns deja la momentul ăla în care aveți un DataFrame imens și trebuie să răspundeți la întrebări de genul: „Care e media vânzărilor pe regiune?" sau „Câți clienți avem în fiecare categorie?" Exact aici intră în joc GroupBy și tabelele pivot din Pandas — două instrumente pe care, sincer, le folosesc aproape zilnic.
În acest ghid vom trece prin tot ce trebuie să știți despre groupby(), pivot_table(), crosstab() și funcțiile conexe din Pandas 3.0. Dacă ați citit deja ghidul nostru despre merge, join și concat sau cel despre curățarea datelor, articolul de față e pasul următor firesc — odată ce aveți datele curate și combinate, trebuie să le agregați și să le analizați.
Ce Înseamnă GroupBy? Paradigma Split-Apply-Combine
Înainte de a scrie cod, merită să înțelegeți conceptul din spate. Operația groupby() urmează paradigma Split-Apply-Combine (Împarte-Aplică-Combină):
- Split (Împarte) — datele sunt împărțite în grupuri pe baza unei sau mai multor coloane
- Apply (Aplică) — o funcție (medie, sumă, numărare, funcție personalizată) se aplică fiecărui grup
- Combine (Combină) — rezultatele sunt reunite într-un singur DataFrame
E un model similar cu GROUP BY din SQL, dar mult mai flexibil. Hai să vedem cum funcționează în practică.
Configurare și Setul de Date Demonstrativ
Vom lucra cu un set de date simulat care reprezintă vânzările unui magazin online. Nimic spectaculos, dar e suficient ca să ilustreze toate conceptele. Asigurați-vă că aveți Pandas 3.0+ instalat:
pip install pandas>=3.0
import pandas as pd
import numpy as np
# Creăm un DataFrame demonstrativ
np.random.seed(42)
n = 500
df = pd.DataFrame({
"Data": pd.date_range("2025-01-01", periods=n, freq="D"),
"Regiune": np.random.choice(["Nord", "Sud", "Est", "Vest"], n),
"Categorie": np.random.choice(["Electronice", "Îmbrăcăminte", "Alimente", "Cărți"], n),
"Vânzări": np.random.randint(50, 500, n),
"Cantitate": np.random.randint(1, 20, n),
"Client": np.random.choice(["Nou", "Recurent"], n, p=[0.3, 0.7])
})
print(df.head())
print(f"\nDimensiune: {df.shape}")
print(f"Pandas versiune: {pd.__version__}")
GroupBy: Operații Fundamentale
Grupare Simplă cu o Singură Coloană
Cea mai directă utilizare — grupăm după regiune și calculăm media vânzărilor:
# Media vânzărilor pe regiune
df.groupby("Regiune")["Vânzări"].mean()
Rezultatul este un Series indexat după valorile unice din coloana „Regiune". Dacă preferați un DataFrame standard (și de cele mai multe ori asta vreți), folosiți as_index=False:
# Rezultat ca DataFrame (nu Series)
df.groupby("Regiune", as_index=False)["Vânzări"].mean()
Grupare pe Coloane Multiple
Puteți grupa după mai multe coloane simultan, transmițând o listă:
# Suma vânzărilor pe regiune și categorie
df.groupby(["Regiune", "Categorie"])["Vânzări"].sum()
Rezultatul va avea un MultiIndex. Dacă vi se pare incomod (și mie mi se pare, de cele mai multe ori), adăugați .reset_index() la final.
Funcții de Agregare Încorporate
Pandas oferă o gamă largă de funcții de agregare pe care le puteți apela direct. Iată cele mai folosite:
# Funcții comune
df.groupby("Regiune")["Vânzări"].sum() # Suma
df.groupby("Regiune")["Vânzări"].mean() # Media
df.groupby("Regiune")["Vânzări"].median() # Mediana
df.groupby("Regiune")["Vânzări"].std() # Deviația standard
df.groupby("Regiune")["Vânzări"].min() # Minimum
df.groupby("Regiune")["Vânzări"].max() # Maximum
df.groupby("Regiune")["Cantitate"].count() # Numărare
df.groupby("Regiune")["Vânzări"].nunique() # Valori unice
# Descriere statistică completă pe grup
df.groupby("Regiune")["Vânzări"].describe()
Agregări Avansate cu agg()
Agregări Multiple pe Aceeași Coloană
Metoda agg() vă permite să aplicați mai multe funcții simultan pe aceeași coloană:
# Mai multe funcții pe aceeași coloană
df.groupby("Regiune")["Vânzări"].agg(["sum", "mean", "std", "count"])
Agregări Diferite pe Coloane Diferite
Puteți specifica funcții diferite pentru coloane diferite folosind un dicționar:
# Funcții diferite pe coloane diferite
df.groupby("Regiune").agg({
"Vânzări": ["sum", "mean"],
"Cantitate": ["sum", "max"],
})
Atenție: rezultatul va avea coloane MultiIndex. Știu, nu e foarte prietenos. Iată cum le aplatizați:
rezultat = df.groupby("Regiune").agg({
"Vânzări": ["sum", "mean"],
"Cantitate": ["sum", "max"],
})
# Aplatizare coloane MultiIndex
rezultat.columns = ["_".join(col) for col in rezultat.columns]
rezultat = rezultat.reset_index()
print(rezultat)
Agregări cu Nume (Named Aggregations) — Metoda Recomandată
Sincer, asta e abordarea pe care o recomand în orice proiect nou. Începând cu Pandas 0.25 și consolidată în Pandas 3.0, agregarea cu nume produce cod mult mai lizibil și elimină complet necesitatea aplatizării coloanelor:
# Agregări cu nume — curat și lizibil
rezultat = df.groupby("Regiune", as_index=False).agg(
Total_Vanzari=("Vânzări", "sum"),
Media_Vanzari=("Vânzări", "mean"),
Total_Cantitate=("Cantitate", "sum"),
Nr_Tranzactii=("Vânzări", "count"),
Produse_Unice=("Categorie", "nunique")
)
print(rezultat)
Observați cum fiecare agregare primește un nume explicit — rezultatul e un DataFrame cu coloane clare, fără MultiIndex. Stilul e similar cu SQL și e mult mai ușor de citit. Odată ce începeți să-l folosiți, n-o să mai vreți să reveniți la varianta cu dicționar.
Funcții Personalizate în agg()
Puteți folosi funcții lambda sau funcții definite de voi:
# Funcție personalizată: intervalul (max - min)
df.groupby("Regiune").agg(
Interval_Vanzari=("Vânzări", lambda x: x.max() - x.min()),
Coef_Variatie=("Vânzări", lambda x: x.std() / x.mean() * 100)
)
Sfat de performanță: funcțiile lambda sunt mai lente decât funcțiile încorporate Pandas, deoarece nu pot fi optimizate intern. Folosiți-le doar când chiar nu există o alternativă vectorizată.
transform() — Păstrarea Dimensiunii Originale
Metodele agg() și sum() reduc datele (un rând per grup). Dar ce faceți dacă vreți să adăugați o statistică de grup la fiecare rând original? Aici intervine transform() — și e probabil funcția pe care am subestimat-o cel mai mult când am început cu Pandas.
transform() aplică funcția pe fiecare grup, dar returnează un rezultat cu aceeași dimensiune ca DataFrame-ul original:
# Adăugăm media pe regiune la fiecare rând
df["Media_Regiune"] = df.groupby("Regiune")["Vânzări"].transform("mean")
# Calculăm abaterea față de medie
df["Abatere_Media"] = df["Vânzări"] - df["Media_Regiune"]
# Normalizare Z-score pe grup
df["Z_Score"] = df.groupby("Regiune")["Vânzări"].transform(
lambda x: (x - x.mean()) / x.std()
)
print(df[["Regiune", "Vânzări", "Media_Regiune", "Abatere_Media", "Z_Score"]].head(10))
Acest pattern e extrem de util pentru:
- Normalizarea datelor pe grup
- Calculul procentului din totalul grupului
- Identificarea valorilor aberante într-un grup
- Completarea valorilor lipsă cu media grupului
# Procentul fiecărei tranzacții din totalul regiunii
df["Procent_Regiune"] = (
df["Vânzări"] / df.groupby("Regiune")["Vânzări"].transform("sum") * 100
)
# Completarea valorilor lipsă cu media grupului
df["Vânzări"] = df.groupby("Regiune")["Vânzări"].transform(
lambda x: x.fillna(x.mean())
)
filter() — Selectarea Grupurilor Întregi
Metoda filter() vă permite să păstrați sau să eliminați grupuri întregi pe baza unei condiții. E diferită de filtrarea obișnuită, și asta contează:
# Păstrăm doar regiunile cu vânzări totale > 15000
df_filtrat = df.groupby("Regiune").filter(
lambda x: x["Vânzări"].sum() > 15000
)
print(f"Rânduri originale: {len(df)}")
print(f"Rânduri după filtrare: {len(df_filtrat)}")
# Păstrăm doar categoriile cu cel puțin 100 de tranzacții
df_frecvent = df.groupby("Categorie").filter(
lambda x: len(x) >= 100
)
Diferența față de un df[df["col"] > valoare] obișnuit? filter() evaluează condiția la nivel de grup, nu la nivel de rând. Adică fie păstrezi tot grupul, fie îl elimini complet.
apply() — Flexibilitate Maximă
Când agg(), transform() și filter() nu sunt suficiente, apply() oferă flexibilitate totală. Primește ca argument întregul DataFrame al grupului:
# Top 3 vânzări din fiecare regiune
def top_3_vanzari(grup):
return grup.nlargest(3, "Vânzări")
df.groupby("Regiune").apply(top_3_vanzari, include_groups=False)
Atenție: apply() este cea mai lentă dintre cele patru metode, deoarece nu poate fi optimizată vectorizat. Folosiți-o doar când alternativele chiar nu acoperă ce aveți nevoie.
Comparație Rapidă: agg vs transform vs filter vs apply
| Metodă | Rezultat | Acționează pe | Când o folosiți |
|---|---|---|---|
agg() | Un rând per grup | Coloane individuale | Statistici sumare |
transform() | Aceeași dimensiune | Câte o coloană (Series) | Broadcast statistici de grup înapoi |
filter() | Subset de rânduri | Grupuri (True/False) | Eliminare grupuri întregi |
apply() | Flexibil | Întregul DataFrame al grupului | Operații complexe, personalizate |
Regula mea e simplă: încep întotdeauna cu agg() sau transform(), și recurg la apply() doar dacă chiar nu am alternativă.
Grupare pe Intervale de Timp cu pd.Grouper
Dacă aveți date temporale, pd.Grouper vă permite să grupați pe intervale de timp fără a crea manual coloane suplimentare. E o funcționalitate care salvează mult cod:
# Grupare pe lună
df["Data"] = pd.to_datetime(df["Data"])
lunar = df.groupby(pd.Grouper(key="Data", freq="ME")).agg(
Total_Vanzari=("Vânzări", "sum"),
Media_Vanzari=("Vânzări", "mean"),
Nr_Tranzactii=("Vânzări", "count")
)
print(lunar.head())
# Combinat cu alte coloane — vânzări lunare pe regiune
lunar_regiune = df.groupby(
[pd.Grouper(key="Data", freq="ME"), "Regiune"]
).agg(
Total_Vanzari=("Vânzări", "sum")
).reset_index()
print(lunar_regiune.head(8))
Frecvențe utile: "D" (zilnic), "W" (săptămânal), "ME" (lunar — capăt de lună), "QE" (trimestrial), "YE" (anual).
Tabele Pivot: pivot_table()
Dacă groupby() produce rezultate în format lung, pivot_table() le produce în format lat — exact ca tabelele pivot din Excel. Și da, intern, pivot_table() folosește groupby() — deci nu e ceva magic, doar o prezentare diferită a acelorași operații.
Tabela Pivot de Bază
# Tabela pivot: regiuni pe rânduri, categorii pe coloane
pivot = pd.pivot_table(
df,
values="Vânzări",
index="Regiune",
columns="Categorie",
aggfunc="sum"
)
print(pivot)
Funcții de Agregare Multiple
# Sumă și medie simultan
pivot_multi = pd.pivot_table(
df,
values="Vânzări",
index="Regiune",
columns="Categorie",
aggfunc=["sum", "mean"]
)
print(pivot_multi)
Margini (Subtotaluri) și Valori Lipsă
Parametrul margins=True adaugă rânduri și coloane cu subtotaluri — echivalentul „Grand Total" din Excel. Foarte util pentru rapoarte:
# Cu subtotaluri și completarea valorilor lipsă
pivot_complet = pd.pivot_table(
df,
values="Vânzări",
index="Regiune",
columns="Categorie",
aggfunc="sum",
margins=True,
margins_name="Total",
fill_value=0
)
print(pivot_complet)
Index Multi-nivel în Tabele Pivot
# Index pe mai multe niveluri
pivot_detaliat = pd.pivot_table(
df,
values=["Vânzări", "Cantitate"],
index=["Regiune", "Client"],
columns="Categorie",
aggfunc={"Vânzări": "sum", "Cantitate": "mean"},
fill_value=0
)
print(pivot_detaliat)
crosstab() — Tabele de Frecvență
Funcția pd.crosstab() e specializată pentru tabelele de frecvență — contorizează câte observații se află în fiecare combinație de categorii. E ca un pivot_table() simplificat, dar cu o sintaxă ușor diferită:
# Tabel de frecvență: Regiune × Categorie
frecventa = pd.crosstab(df["Regiune"], df["Categorie"])
print(frecventa)
# Cu procente (normalizare pe rânduri)
procente = pd.crosstab(
df["Regiune"],
df["Categorie"],
normalize="index" # "index", "columns", sau "all"
) * 100
print(procente.round(1))
# crosstab cu agregare personalizată
vanzari_medii = pd.crosstab(
df["Regiune"],
df["Categorie"],
values=df["Vânzări"],
aggfunc="mean"
)
print(vanzari_medii.round(0))
melt() — De la Format Lat la Format Lung
Funcția melt() face operația inversă față de pivot_table() — transformă datele din format lat în format lung. O să aveți nevoie de ea mai des decât credeți:
# Pornim de la o tabelă pivot
pivot = pd.pivot_table(df, values="Vânzări", index="Regiune", columns="Categorie", aggfunc="sum")
pivot = pivot.reset_index()
# Transformăm înapoi în format lung
lung = pd.melt(
pivot,
id_vars="Regiune",
var_name="Categorie",
value_name="Total_Vanzari"
)
print(lung)
melt() e utilă mai ales când primiți date în format spreadsheet (o coloană per variabilă) și trebuie să le transformați în format tidy — o observație per rând — pentru analiză sau vizualizare cu Seaborn.
Noutăți Pandas 3.0 Relevante pentru GroupBy
Pandas 3.0, lansat pe 21 ianuarie 2026, vine cu câteva modificări importante care afectează direct operațiile GroupBy. Dacă faceți upgrade de la 2.x, citiți cu atenție:
1. observed=True Este Acum Implicit
În versiunile anterioare, dacă grupați după o coloană categorică, Pandas includea implicit toate categoriile — inclusiv cele fără date. Acum, comportamentul implicit e observed=True, ceea ce înseamnă că doar categoriile prezente efectiv în date apar în rezultat:
# Pandas 3.0: observed=True este implicit
df["Regiune_Cat"] = df["Regiune"].astype("category")
# Doar categoriile prezente apar (comportament implicit în 3.0)
df.groupby("Regiune_Cat")["Vânzări"].sum()
Dacă aveți cod vechi care depindea de comportamentul anterior, adăugați explicit observed=False. Am pățit-o și eu cu un pipeline de producție — testele treceau local dar eșuau pe server după upgrade.
2. Copy-on-Write Este Acum Implicit
Copy-on-Write (CoW) e activat implicit în Pandas 3.0. Asta înseamnă că operațiile de indexare returnează întotdeauna copii logice, nu vederi (views). Pentru GroupBy, impactul principal e că modificarea unui subset nu va mai afecta DataFrame-ul original:
# În Pandas 3.0, aceasta NU modifică df-ul original
subset = df.groupby("Regiune").get_group("Nord")
subset["Vânzări"] = 0 # Safe — nu afectează df
Sincer, e o schimbare binevenită. Eliminarea acelor SettingWithCopyWarning-uri a fost mult așteptată.
3. Tip String Dedicat Implicit
Pandas 3.0 folosește un tip de date string dedicat (cu backend PyArrow, dacă e instalat). Gruparea pe coloane string e acum semnificativ mai rapidă — se estimează îmbunătățiri de 2-3x în performanță și 50% reducere a memoriei.
Sfaturi de Performanță pentru GroupBy la Scară Mare
Când lucrați cu DataFrame-uri de milioane de rânduri, performanța chiar contează. Iată câteva sfaturi pe care le-am învățat din experiență:
# 1. Folosiți tipuri categorice pentru coloanele de grupare
df["Regiune"] = df["Regiune"].astype("category")
df["Categorie"] = df["Categorie"].astype("category")
# 2. Evitați apply() — folosiți agg() sau transform()
# LENT:
df.groupby("Regiune").apply(lambda x: x["Vânzări"].sum())
# RAPID:
df.groupby("Regiune")["Vânzări"].sum()
# 3. Folosiți sort=False dacă ordinea nu contează
df.groupby("Regiune", sort=False)["Vânzări"].mean()
# 4. Folosiți funcții încorporate, nu lambda
# LENT:
df.groupby("Regiune")["Vânzări"].agg(lambda x: x.sum())
# RAPID:
df.groupby("Regiune")["Vânzări"].agg("sum")
# 5. Pentru seturi de date foarte mari, considerați Polars
# import polars as pl
# df_polars = pl.from_pandas(df)
# df_polars.group_by("Regiune").agg(pl.col("Vânzări").sum())
Exemplu Complet: Analiză de Vânzări End-to-End
Hai să punem totul cap la cap într-un exemplu realist. Asta e genul de analiză pe care l-ați face efectiv într-un proiect real — de la date brute la insight-uri:
import pandas as pd
import numpy as np
# Încărcăm datele (sau le creăm)
np.random.seed(42)
n = 1000
df = pd.DataFrame({
"Data": pd.date_range("2025-01-01", periods=n, freq="D"),
"Regiune": np.random.choice(["Nord", "Sud", "Est", "Vest"], n),
"Categorie": np.random.choice(["Electronice", "Îmbrăcăminte", "Alimente"], n),
"Produs": np.random.choice(["Laptop", "Tricou", "Pâine", "Telefon", "Pantaloni"], n),
"Vânzări": np.random.randint(10, 1000, n),
"Cantitate": np.random.randint(1, 50, n),
})
# 1. Rezumat pe regiune și categorie
rezumat = df.groupby(["Regiune", "Categorie"], as_index=False).agg(
Total_Vanzari=("Vânzări", "sum"),
Media_Vanzari=("Vânzări", "mean"),
Nr_Tranzactii=("Vânzări", "count"),
Cantitate_Totala=("Cantitate", "sum")
)
print("=== Rezumat pe Regiune și Categorie ===")
print(rezumat)
# 2. Tabelă pivot cu subtotaluri
pivot = pd.pivot_table(
df,
values="Vânzări",
index="Regiune",
columns="Categorie",
aggfunc="sum",
margins=True,
margins_name="Total"
)
print("\n=== Tabelă Pivot cu Subtotaluri ===")
print(pivot)
# 3. Tendință lunară pe categorie
df["Luna"] = df["Data"].dt.to_period("M")
tendinta = df.groupby(["Luna", "Categorie"], as_index=False).agg(
Total_Vanzari=("Vânzări", "sum")
)
print("\n=== Tendință Lunară ===")
print(tendinta.head(12))
# 4. Top 3 produse pe regiune
top_produse = (
df.groupby(["Regiune", "Produs"], as_index=False)
.agg(Total=("Vânzări", "sum"))
.sort_values(["Regiune", "Total"], ascending=[True, False])
.groupby("Regiune")
.head(3)
)
print("\n=== Top 3 Produse pe Regiune ===")
print(top_produse)
# 5. Procentul fiecărei categorii din totalul regiunii
df["Procent"] = (
df["Vânzări"]
/ df.groupby("Regiune")["Vânzări"].transform("sum")
* 100
)
print("\n=== Ponderea procentuală per rând ===")
print(df[["Regiune", "Categorie", "Vânzări", "Procent"]].head(10))
Întrebări Frecvente (FAQ)
Care este diferența dintre groupby() și pivot_table() în Pandas?
groupby() produce rezultate în format lung (o coloană cu valorile agregate), în timp ce pivot_table() produce rezultate în format lat (cu valorile categoriilor distribuite pe coloane). Intern, pivot_table() folosește groupby(). Alegeți groupby() pentru analiză programatică și pivot_table() pentru rapoarte vizuale de tip spreadsheet.
Cum resetez indexul după o operație groupby?
Aveți două opțiuni: fie folosiți as_index=False direct în apelul groupby(), fie apelați .reset_index() pe rezultat. Prima variantă e mai eficientă deoarece evită crearea unui index intermediar.
De ce este apply() mai lent decât agg() și transform()?
apply() nu poate fi optimizată vectorizat — Pandas trebuie să apeleze funcția Python pentru fiecare grup individual. agg() și transform() cu funcții încorporate (precum "sum", "mean") folosesc cod C optimizat intern. Diferența de performanță poate fi de 10-100x pe seturi de date mari.
Cum grupez datele pe intervale de timp (luni, trimestre) în Pandas 3.0?
Folosiți pd.Grouper(key="coloana_data", freq="ME") pentru grupare lunară, freq="QE" pentru trimestrială sau freq="YE" pentru anuală. Alternativ, puteți extrage componente cu df["Data"].dt.month sau df["Data"].dt.to_period("M").
Ce s-a schimbat la groupby() în Pandas 3.0?
Principala modificare e că parametrul observed are acum valoarea implicită True (anterior era False). Asta înseamnă că gruparea pe coloane categorice va afișa doar categoriile prezente în date, nu toate categoriile posibile. De asemenea, Copy-on-Write implicit și tipul string dedicat aduc îmbunătățiri de performanță semnificative.