Python, dalle fondamenta Lezione 29 / 60

Aggregazione: groupBy, agg, transform, quale serve davvero

GroupBy su scala, aggregazioni nominate, la differenza tra agg/apply/transform, e gli schemi che userai ogni settimana.

Se la selezione è “scegli le righe”, l’aggregazione è “riassumi le righe”. Una volta che hai filtrato fino ai dati che ti interessano, quasi ogni analisi finisce con una domanda della forma “media per paese”, “massimo per cliente”, “primo evento per sessione”. Pandas ha una sola operazione fondamentale per questo, groupby, e quattro metodi che puoi chiamare sul risultato: agg, transform, apply, filter. Si somigliano e fanno cose molto diverse. Oggi mettiamo a posto la distinzione, perché scegliere quello sbagliato è la differenza tra una query che gira in 30 millisecondi e una che gira in 30 secondi.

Lavoreremo con questo per tutta la lezione:

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],
    }
)

La forma di base

df.groupby("col") restituisce un oggetto DataFrameGroupBy, non un risultato, un intermedio. Pandas non itera davvero i dati finché non gli chiami sopra un metodo di aggregazione:

df.groupby("country").sum(numeric_only=True)
df.groupby("country").mean(numeric_only=True)
df.groupby("country").size()                   # conteggio righe per gruppo

numeric_only=True è buona pratica: senza di lui pandas proverà a sommare stringhe (cosa che “funziona” per concatenazione) o darà errore su tipi misti. In pandas 2.x il default è dare errore sulle colonne non numeriche per sum/mean, che è il default giusto ma significa che imposti il flag esplicitamente quando lo vuoi.

L’output è un DataFrame indicizzato dalla group key. La group key è passata dall’essere una colonna all’essere l’index, comodo per il plotting e per ulteriori join, ma una fonte comune di “aspetta, dov’è finita la mia colonna country?”. Se la vuoi come colonna, o chiami .reset_index() o passi as_index=False:

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

Multi-key groupby, raggruppa per una tupla di colonne:

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

Il risultato ha un MultiIndex sulle righe. Puoi rispostare entrambe le chiavi a colonne con .reset_index() o usare as_index=False.

.agg, aggregazioni diverse per colonna

sum, mean, min, max, count, nunique, median, std, var, first, last, tutti disponibili come metodi sul groupby. Quando vuoi aggregazioni diverse per colonne diverse, è .agg:

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

Puoi passare una lista per colonna per ottenere più aggregazioni:

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

Il risultato ha un MultiIndex sulle colonne: ("revenue", "sum"), ("revenue", "mean"), eccetera. È scomodo da gestire a valle: df["revenue"]["sum"] invece di df["revenue_sum"]. Il che ci porta al modo moderno.

Aggregazioni nominate, il modo di farlo

Pandas 0.25 ha aggiunto le aggregazioni nominate e una volta che le vedi non torni indietro. La forma:

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

Ogni argomento per parola chiave è una tupla (source_column, aggregation_function), e il nome della parola chiave diventa il nome della colonna risultante. Niente MultiIndex. Colonne piatte. Auto-documentanti. Questa è la forma che dovresti scrivere nel 2026.

Aggregazioni custom? Passa 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: ... riceve una Series, i valori di quella colonna per un gruppo. Il valore di ritorno può essere uno scalare (diventa una cella) o una tupla/lista (raro, avanzato).

Per “conta le righe per gruppo”, preferisci ("any_column", "size") a ("any_column", "count"), vedi la sezione successiva per il perché.

size contro count, in breve

Due funzioni che suonano uguali ma non lo sono:

  • .size() restituisce il numero di righe in ogni gruppo, incluse le righe in cui il valore è NaN. È una proprietà del gruppo, non di una qualsiasi colonna.
  • .count() restituisce il numero di valori non null in ogni colonna di ogni gruppo. Varia per colonna.
df.groupby("country").size()                       # righe per country
df.groupby("country").count()                      # non null per colonna per country
df.groupby("country").agg(n=("revenue", "size"))   # versione nominata

Se vuoi “quante righe ci sono in questo gruppo”, usa size. Se vuoi “quante delle righe hanno un revenue non null”, usa count. Confondere uno con l’altro produce silenziosamente dashboard sbagliate.

.transform, trasmette di nuovo alla forma originale

agg riduce ogni gruppo a una riga. transform fa un’aggregazione ma trasmette il risultato a ogni riga del gruppo, quindi l’output ha la stessa lunghezza dell’input. È la feature killer che non sapevi di volere.

Uso classico: calcolare uno scarto per riga rispetto alla media di gruppo.

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

Oppure, normalizzare all’interno del gruppo:

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

Questa è una sola espressione, vettorizzata, veloce. L’alternativa, costruire un DataFrame aggregato a parte e poi farne merge di nuovo sulla group key, è tre righe, due oggetti in più, e un ordine di grandezza più lenta. Ogni volta che ti ritrovi a volere un valore per riga che dipende dal suo gruppo, ricorri a transform.

transform accetta le stesse aggregazioni-stringa di agg ("mean", "sum", "max", "min", "count", "std", "rank"), più qualsiasi callable che prende una Series e restituisce una Series della stessa lunghezza:

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

.apply, l’uscita di emergenza (usalo con parsimonia)

apply è il metodo groupby più flessibile e il più lento. Chiama una funzione una volta per gruppo, con il DataFrame completo del gruppo, e cuce insieme i risultati:

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+ richiede include_groups=False su apply quando le chiavi del groupby vengono anche passate dentro, altrimenti ti sgrida con un deprecation warning. Impostalo.

Quando apply è la risposta giusta? Quando l’operazione richiede davvero il tutto del DataFrame del gruppo e non si può esprimere come riduzione per colonna o broadcast per riga. “Top N righe per gruppo”, “fitta una regressione per gruppo”, “logica multi-colonna complessa che non si mappa in agg”. Per tutto il resto, aggregazione colonna per colonna, statistiche di gruppo per riga, usa agg o transform. Sono vettorizzate; apply è un loop Python travestito.

Una regola approssimativa: se lo puoi esprimere senza apply, dovresti.

.filter, tieni solo alcuni gruppi

filter prende una funzione che restituisce True/False per gruppo e tiene le righe dei gruppi in cui la funzione restituisce True:

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

Quello restituisce il DataFrame della forma originale, ma solo con le righe dei paesi che hanno almeno tre record. Utile per “rimuovi le categorie rare prima di allenare un modello” o “ignora i clienti una tantum”. Nota che filter qui è il metodo del groupby, non il metodo .filter del DataFrame (che è una cosa completamente diversa, un selettore di nomi di colonna). Naming di pandas, sigh.

Pivot table, groupby più reshape

pivot_table è groupby con un reshape integrato. Mentre groupby([a, b]).agg(...) produce un DataFrame in formato lungo con un MultiIndex, pivot_table ne produce uno in formato largo: una delle chiavi diventa colonne:

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

Ottieni un DataFrame con i paesi come righe, gli anni come colonne, il revenue sommato nelle celle. fill_value=0 dice “se una combinazione (country, year) non ha dati, scrivi zero invece di NaN”, di solito quello che vuoi per una tabella da presentazione.

Funzionano anche aggregazioni multiple e colonne valore multiple:

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

Il risultato è largo e presentabile. Per il consumo a valle leggibile da macchina, preferisci la forma lunga (groupby + named agg) e fai il reshape solo alla fine.

Combiniamo: filtra, raggruppa, aggrega, ordina

L’analisi reale concatena questi pezzi. Pandas si legge dall’alto in basso quando avvolgi una catena di metodi tra parentesi:

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 crea una nuova colonna da una lambda che riceve il DataFrame in lavorazione, utile nelle catene perché non puoi scrivere df["x"] = ... a metà catena. .sort_values ordina le righe; .reset_index(drop=True) ricostruisce un RangeIndex pulito alla fine così il risultato è presentabile.

Questa è la forma del 90% del codice pandas analitico che scrivo: filtra, groupby con named agg, colonne derivate via assign, ordina, reset. Memorizza lo scheletro.

Una sacca di aggregazioni che vale la pena conoscere

Alcune aggregazioni specifiche tornano spesso abbastanza da chiamarle per nome:

  • first / last, restituisce il primo/ultimo valore in ogni gruppo. L’ordine conta, quindi di solito è preceduto da df.sort_values(...). Utile per “l’importo del primo ordine del cliente” o “lo stato più recente per ticket”.
  • nunique, conteggio di valori distinti. df.groupby("country")["customer_id"].nunique() ti dà il numero di clienti distinti per paese.
  • idxmax / idxmin, la label dell’index della riga con il valore massimo/minimo. Combinato con .loc, è così che peschi la riga completa che contiene il massimo per gruppo: df.loc[df.groupby("country")["revenue"].idxmax()].
  • quantile, df.groupby("country")["revenue"].quantile(0.95). Il 95-esimo percentile per gruppo, eccetera. Con named agg: p95=("revenue", lambda s: s.quantile(0.95)).
  • agg(list), raccoglie tutti i valori del gruppo in una lista Python, una cella per gruppo. Comodo per “che articoli c’erano in questo ordine?”. Lento sui big data; va bene per output a misura d’uomo.

Note sulle prestazioni

  • Le aggregazioni vettorizzate sono 10-100 volte più veloci di .apply con una funzione Python. “sum”, “mean”, “max” eccetera vengono dispatchate a C; le lambda no. Se ti ritrovi a usare .apply in un percorso caldo, chiediti prima se agg più un uso creativo di transform può fare lo stesso lavoro, di solito può.
  • as_index=False è meno costoso di .reset_index() perché pandas non costruisce il MultiIndex solo per buttarlo via. Per il codice di pipeline che finisce sempre in un DataFrame piatto, mettilo come default a as_index=False.
  • observed=True conta con le group key categoriche. Di default, groupby su una colonna categoria produce una riga per ogni valore di categoria, anche quelli senza dati. Con observed=True, appaiono solo i valori presenti, che è quasi sempre quello che vuoi e notevolmente più veloce per categorie ad alta cardinalità. Pandas 3.0 cambierà il default; nel frattempo, impostalo.
  • sort=False salta l’ordinamento alfabetico delle group key. Il default sort=True va bene per risultati piccoli ma aggiunge overhead notevole su milioni di gruppi, e probabilmente vuoi un ordinamento specifico del dominio alla fine della pipeline comunque.
  • Per dati davvero grandi, lo schema groupby-poi-agg è quello che DuckDB e Polars fanno nativamente e più velocemente. Se il tuo DataFrame è più di qualche GB, considera di fare l’aggregazione in DuckDB (duckdb.query("SELECT country, SUM(revenue) FROM df GROUP BY country").to_df()) e rileggere il risultato piccolo dentro pandas.

Cosa c’è dopo

La lezione 30 è le join: merge, concat, e il comportamento di allineamento dell’index che ha salvato o rovinato molte analisi. La lezione 32 coprirà le window function (rolling, expanding, ranking), le cugine in salsa serie storiche di quello che abbiamo fatto oggi con transform.

Letture di approfondimento

Ci vediamo venerdì.

Cerca