Salta al contenuto principale

13 - Pandas: tabelle di dati senza sorprese

Come ragiona Pandas sotto la superficie: allineamento per etichetta, valori mancanti, groupby in tre tempi e merge che moltiplica le righe, con uscite eseguite.

Cosa ne pensi di questo articolo?

13 - Pandas: tabelle di dati senza sorprese
Machine Learning Engineerfedericocalo.dev

In Breve

Come ragiona Pandas sotto la superficie: allineamento per etichetta, valori mancanti, groupby in tre tempi e merge che moltiplica le righe, con uscite eseguite.

Corso Machine Learning Engineer · Modulo 01 · Python per i dati · Lezione 6

Hai un registro di ordini e un'anagrafica clienti, e ti basta una riga per ottenere il totale per città. Se non sai che cosa succede sotto, quella riga può darti un totale sbagliato senza alcun messaggio: un cliente assente dall'anagrafica sparisce dal report, una chiave duplicata gonfia i numeri, una data letta al contrario sposta un ordine da aprile a marzo. Questa lezione ti fa leggere, selezionare, pulire, aggregare e unire tabelle con Pandas sapendo che cosa accade sotto: l'indice che allinea per etichetta, i tipi di colonna, i valori mancanti che si propagano, il groupby come procedura in tre tempi e il merge che può moltiplicare le righe.

Il luogo comune dice che Pandas è Excel dentro Python. In un foglio di calcolo ogni cella è libera; in un DataFrame ogni colonna ha un solo tipo e le operazioni si allineano per etichetta, non per posizione. Chi ragiona «a celle» produce errori che non lanciano nessuna eccezione. Pandas costruisce le sue tabelle sopra gli array della lezione su NumPy, aggiungendo nomi, tipi misti e buchi. Gli esempi usano dati sintetici, dichiarati tali, generati nel codice, e sono stati eseguiti con Python 3.14.4, NumPy 2.3.5 e pandas 3.0.3, la versione che ha cambiato alcuni comportamenti importanti (li segnaliamo dove contano).

I listati formano una sola sessione: il primo che crea ordini e clienti va eseguito per primo, quelli successivi lo riusano (i blocchi su pivot_table e su category ricostruiscono i dati da soli). Eseguiti nell'ordine in cui compaiono, due volte, hanno dato le stesse uscite che leggi qui.

Series e DataFrame: array con etichette

L'analogia: il registro con i nomi sulle righe

Un array NumPy è un registro con le righe numerate 0, 1, 2... Una Series è lo stesso registro, ma ogni riga ha un nome (l'indice): «Anna», «Luca», «2026-01-15». Un DataFrame è un armadio di registri che condividono gli stessi nomi di riga: una Series per colonna, tutte con lo stesso indice.

La conseguenza più importante è l'allineamento: quando operi su due Series, Pandas abbina le righe che hanno la stessa etichetta, non quelle nella stessa posizione.

import numpy as np
import pandas as pd

s1 = pd.Series([1, 2, 3], index=["a", "b", "c"])
s2 = pd.Series([10, 20, 30], index=["b", "c", "d"])
print(s1 + s2)

Output ottenuto:

a     NaN
b    12.0
c    23.0
d     NaN
dtype: float64

a esiste solo in s1 e d solo in s2: dove manca uno dei due operandi il risultato è NaN (not a number, il marcatore dei valori mancanti). Su b e c la somma è fatta tra le righe con quel nome, non tra la seconda e la seconda. Questo comportamento è una comodità quando gli indici hanno significato (date, identificativi) e una trappola quando l'indice è un numero progressivo che si è «sporcato» dopo un filtro.

Creare, ispezionare, capire i tipi

Costruiamo due tabelle sintetiche: un registro di ordini e un'anagrafica clienti. Il cliente 5 compare negli ordini ma non nell'anagrafica (dato volutamente incompleto, come nella realtà).

rng = np.random.default_rng(7)

clienti = pd.DataFrame({
    "id_cliente": [1, 2, 3, 4],
    "citta": ["Lecce", "Milano", "Lecce", "Torino"],
    "segmento": ["privato", "azienda", "privato", "azienda"],
})

n = 12
ordini = pd.DataFrame({
    "id_ordine": range(100, 100 + n),
    "id_cliente": rng.choice([1, 2, 3, 4, 5], n),
    "importo": np.round(rng.gamma(shape=4, scale=30, size=n), 2),
    "data": pd.Timestamp("2026-01-01") + pd.to_timedelta(rng.integers(0, 120, n), unit="D"),
})
ordini.loc[[2, 7], "importo"] = np.nan      # due importi mancanti

print(ordini)
print(ordini.dtypes)
print(clienti.dtypes)

Output ottenuto:

    id_ordine  id_cliente  importo       data
0         100           5   113.49 2026-04-27
1         101           4    84.08 2026-03-25
2         102           4      NaN 2026-04-16
3         103           5   116.17 2026-01-25
4         104           3   108.33 2026-03-28
5         105           4    49.44 2026-02-14
6         106           5    34.50 2026-02-28
7         107           2      NaN 2026-01-01
8         108           1   119.25 2026-03-16
9         109           2   107.24 2026-04-10
10        110           2    43.43 2026-03-21
11        111           5   118.65 2026-01-19
id_ordine              int64
id_cliente             int64
importo              float64
data          datetime64[us]
dtype: object
id_cliente    int64
citta           str
segmento        str
dtype: object

Come leggere il codice. clienti è un DataFrame costruito da un dizionario: le chiavi diventano i nomi delle colonne, le liste i valori; tutte le liste hanno la stessa lunghezza (4). ordini si costruisce allo stesso modo ma con colonne generate con NumPy: rng.choice([1, 2, 3, 4, 5], n) estrae per ciascun ordine il cliente (il 5 non esiste nell'anagrafica: è il dato incompleto di cui parlavamo), rng.gamma(shape=4, scale=30, size=n) produce importi positivi con una coda a destra (una distribuzione plausibile per una spesa) e np.round(..., 2) li porta ai centesimi. La colonna data somma a una data di partenza un numero casuale di giorni (pd.to_timedelta(..., unit="D") converte i numeri in durate). Infine ordini.loc[[2, 7], "importo"] = np.nan imposta a mancante l'importo delle righe con etichetta 2 e 7: da qui in avanti la tabella ha due buchi.

Ricorda che loc[righe, colonna] con una sola parentesi è il modo corretto di assegnare (ci torniamo con il Copy-on-Write).

Ispezione veloce, da fare sempre appena si carica una tabella:

  • df.shape: righe e colonne.
  • df.dtypes e df.info(): tipo di ogni colonna e conteggio dei valori non nulli.
  • df.head(), df.sample(5): uno sguardo ai dati veri.
  • df.describe(): statistiche riassuntive delle colonne numeriche.

I tipi contano. Una colonna di interi che contiene un buco diventa float64, perché NaN è un float. Se vuoi mantenere gli interi con valori mancanti esistono i tipi nullable ("Int64" con la I maiuscola):

print(pd.Series([1, 2, None]).dtype)
print(pd.Series([1, 2, None], dtype="Int64").tolist())
print(ordini["importo"].describe().round(2))

Output ottenuto:

float64
[1, 2, <NA>]
count     10.00
mean      89.46
std       34.11
min       34.50
25%       58.10
50%      107.78
75%      115.50
max      119.25
Name: importo, dtype: float64

In Pandas 3 le colonne di testo hanno per impostazione predefinita il tipo str (stringa vera) al posto del vecchio object, più generico e lento, come descrivono le note di rilascio di pandas 3.0: lo vedi nell'output di clienti.dtypes.

Trade-off. I tipi category (poche etichette ripetute, come la città) risparmiano memoria e accelerano i raggruppamenti; i tipi nullable preservano gli interi ma richiedono di abituarsi a pd.NA. Se il dataset è piccolo, i default vanno benissimo.

Selezionare: le quattro porte

Cosa vuoi Come Nota
Una colonna df["importo"] restituisce una Series
Più colonne df[["importo", "data"]] doppie parentesi: restituisce un DataFrame
Righe per etichetta df.loc[etichette, colonne] gli estremi della fetta sono inclusi (guida all'indicizzazione)
Righe per posizione df.iloc[posizioni, colonne] gli estremi della fetta: l'ultimo è escluso
Righe per condizione df[maschera] maschera booleana

La differenza tra loc e iloc è la più importante da tenere a mente. Un'analogia: loc è «dammi la riga intestata a Rossi», iloc è «dammi la terza riga dall'alto». Se l'indice è un progressivo 0, 1, 2, ... i due coincidono, finché non filtri: dopo un filtro l'indice conserva i vecchi numeri.

print(ordini.loc[0:2, ["id_ordine", "importo"]])     # etichette 0, 1, 2: estremo INCLUSO
print(ordini.iloc[0:2, [0, 2]])                      # posizioni 0 e 1: estremo ESCLUSO

grandi = ordini[ordini["importo"] > 100]
print(grandi[["id_ordine", "importo"]])
print("indice dopo il filtro:", grandi.index.tolist())
print("grandi.loc[3]:", grandi.loc[3, "id_ordine"], "| grandi.iloc[3]:", grandi.iloc[3]["id_ordine"])

Output ottenuto:

   id_ordine  importo
0        100   113.49
1        101    84.08
2        102      NaN
   id_ordine  importo
0        100   113.49
1        101    84.08
    id_ordine  importo
0         100   113.49
3         103   116.17
4         104   108.33
8         108   119.25
9         109   107.24
11        111   118.65
indice dopo il filtro: [0, 3, 4, 8, 9, 11]
grandi.loc[3]: 103 | grandi.iloc[3]: 108

Dopo il filtro l'indice conserva le vecchie etichette delle righe sopravvissute, [0, 3, 4, 8, 9, 11]: loc[3] cerca l'etichetta 3 (l'ordine 103), mentre iloc[3] prende la quarta riga rimasta (l'ordine 108). Stessa parentesi, righe diverse: hai appena scoperto perché non conviene mischiarli.

Combinare condizioni

In Python and, or, not ragionano su un valore di verità; su una colonna intera serve l'operatore bit a bit, con le parentesi (per la precedenza degli operatori):

cond = (ordini["importo"] > 50) & (ordini["id_cliente"] != 5)
print(ordini[cond]["id_ordine"].tolist())
print(ordini[~ordini["importo"].isna()].shape[0], "ordini con importo noto")
print(ordini.query("importo > 100 and id_cliente == 5")["id_ordine"].tolist())

Output ottenuto:

[101, 104, 108, 109]
10 ordini con importo noto
[100, 103, 111]

Scrivere ordini["importo"] > 50 and ... solleva ValueError: The truth value of a Series is ambiguous: Pandas rifiuta di decidere per te se una colonna intera è «vera». query accetta la sintassi con and/or perché interpreta una stringa.

Valori mancanti

Un valore mancante non è «zero» e non è «niente»: è una informazione sconosciuta. Tre domande vengono prima di qualunque rimedio.

  1. Quanti sono e dove? df.isna().sum() conta per colonna.
  2. Perché mancano? Rubin (1976) distingue tre casi. Mancanti completamente a caso (il sensore si è spento per un guasto casuale), a caso dato altro che osservo (i clienti giovani lasciano più spesso vuoto il campo reddito), non a caso (chi guadagna molto omette il reddito proprio perché è alto). Nel terzo caso nessuna sostituzione automatica è innocente.
  3. Che effetto ha ignorarli? Dipende dalla domanda.

L'ordine conta: prima si conta, poi si cerca il meccanismo, solo alla fine si sceglie un rimedio. Chi parte dal rimedio (di solito fillna) decide senza sapere se i buchi sono pochi o tanti e se hanno un motivo. Il blocco seguente risponde alla prima e alla terza domanda; la seconda richiede di conoscere come sono stati raccolti i dati, e nessuna riga di codice la risolve.

print(ordini.isna().sum())
print("somma ignorando i NaN :", ordini["importo"].sum())
print("somma NON ignorandoli  :", ordini["importo"].sum(skipna=False))
print("media con NaN ignorati :", round(ordini["importo"].mean(), 2))

riempito_media = ordini["importo"].fillna(ordini["importo"].mean())
print("media dopo fillna(media):", round(riempito_media.mean(), 2), "(identica: la media non può cambiare)")
print("deviazione std prima/dopo:", round(ordini["importo"].std(), 2), round(riempito_media.std(), 2))

Output ottenuto:

id_ordine     0
id_cliente    0
importo       2
data          0
dtype: int64
somma ignorando i NaN : 894.5799999999999
somma NON ignorandoli  : nan
media con NaN ignorati : 89.46
media dopo fillna(media): 89.46 (identica: la media non può cambiare)
deviazione std prima/dopo: 34.11 30.86

L'ultima riga mostra l'effetto collaterale del rimedio più popolare: sostituire con la media lascia invariata la media ma riduce la variabilità (stai inserendo valori «tutti uguali, tutti al centro»). Un modello addestrato su quei dati vedrà meno incertezza di quanta ne esista davvero. In più, se calcoli quella media su tutto il dataset prima di separare train e test, fai trapelare informazione dal test al training: lo stesso difetto che vedremo con il data leakage nella lezione su scikit-learn. La regola: la media (o qualunque statistica di riempimento) si calcola solo sul training e si applica al test.

Alternative: eliminare le righe (dropna, innocuo solo se i mancanti sono pochi e casuali), aggiungere una colonna indicatore «era mancante» (a volte il fatto di mancare è esso stesso informativo), usare un modello che gestisce nativamente i buchi.

groupby: dividere, applicare, combinare

L'analogia: smistare la posta

Hai una pila di lettere e vuoi sapere quanto hai speso per città. Prima smisti le lettere in mucchietti, uno per città (split). Poi per ogni mucchietto fai un conto (apply). Infine raccogli i risultati in un unico foglio (combine). È la strategia split-apply-combine, formalizzata da Wickham (2011), e groupby la esegue in una riga.

Split, apply, combineLa tabella è divisa per città, ogni gruppo viene riassunto e i risultati ricomposti.TabellaordinisplitLeccesomma, mediaMilanosomma, mediaTorinosomma, mediaapplyUna rigaper gruppocombine
Figura: groupby in tre tempi.

Prima abbiamo bisogno di unire gli ordini con l'anagrafica (sezione successiva); anticipiamo il risultato:

m = ordini.merge(clienti, on="id_cliente", how="left", validate="m:1", indicator=True)
print(m[["id_ordine", "id_cliente", "citta", "_merge"]])

Output ottenuto:

    id_ordine  id_cliente   citta     _merge
0         100           5     NaN  left_only
1         101           4  Torino       both
2         102           4  Torino       both
3         103           5     NaN  left_only
4         104           3   Lecce       both
5         105           4  Torino       both
6         106           5     NaN  left_only
7         107           2  Milano       both
8         108           1   Lecce       both
9         109           2  Milano       both
10        110           2  Milano       both
11        111           5     NaN  left_only

Quattro ordini (del cliente 5) non hanno una città: _merge dice left_only, e Pandas mette NaN al posto della città. Il left ha tenuto tutte le righe degli ordini, e validate="m:1" ha garantito che nessuna sia stata duplicata: dodici righe prima, dodici dopo. Ora raggruppiamo per città, contando in due modi diversi le righe e i valori noti e calcolando totale e media degli importi:

tab = m.groupby("citta", dropna=False).agg(
    n_ordini=("id_ordine", "size"),
    n_importi=("importo", "count"),
    totale=("importo", "sum"),
    medio=("importo", "mean"),
)
print(tab.round(2))

Output ottenuto:

        n_ordini  n_importi  totale   medio
citta                                      
Lecce          2          2  227.58  113.79
Milano         3          2  150.67   75.33
Torino         3          2  133.52   66.76
NaN            4          4  382.81   95.70

Osserva due dettagli che fanno sbagliare i report:

  • size contro count. size conta le righe del gruppo; count conta i valori non mancanti. Per Milano gli ordini sono 3 ma gli importi noti sono 2: la media è calcolata su 2, non su 3.
  • dropna=False. Di default groupby scarta (come dice la guida a groupby) silenziosamente il gruppo la cui chiave è NaN: quei 4 ordini sparirebbero dal totale. Con dropna=False compaiono come gruppo a sé, e il totale generale torna.

Entrambi i dettagli hanno la stessa natura: groupby sceglie in silenzio che cosa contare e che cosa scartare, e il report che ne esce sembra ordinato e completo. Il controllo è aritmetico: la somma dei gruppi deve coincidere con il totale (qui 227,58 + 150,67 + 133,52 + 382,81 = 894,58, verificato), e i conteggi con le righe della tabella di partenza. Come per i float della lezione sui numeri in virgola mobile, il confronto fra somme va fatto con una tolleranza.

Una trappola di statistica: la media delle medie

media_delle_medie = tab["medio"].mean()
media_globale = m["importo"].mean()
print("media delle medie per citta:", round(media_delle_medie, 2))
print("media globale degli importi :", round(media_globale, 2))

Output ottenuto:

media delle medie per citta: 87.9
media globale degli importi : 89.46

I due numeri sono diversi: la media delle medie dà lo stesso peso a gruppi di dimensione diversa. È un parente del paradosso di Simpson: aggregare a livelli diversi cambia la risposta. Quale numero sia quello giusto dipende dalla domanda (la spesa media per ordine o la spesa media per città?), ma bisogna saperlo e dirlo.

Esercizio 1

Due medie, due domande diverse

Una biblioteca scolastica tiene il registro dei prestiti. La classe 3A ha restituito due libri, dopo 10 e 20 giorni. La 3B ne ha restituiti sei, tutti dopo 5 giorni. Carta e penna.

  1. Quanto vale la durata media per ciascuna delle due classi?
  2. Quanto vale la media delle due medie? E la media di tutti e otto i prestiti?
  3. I due numeri sono diversi. Quale risponde a «quanto tiene un libro uno studente medio» e quale a «quanto tiene un libro una classe media»?

Le prime due domande sono aritmetica da fare su carta; la terza è la vera domanda, perché chiede di collegare un numero alla frase che lo descrive. Rispondi prima di aprire la soluzione e scrivi, accanto a ogni numero, la domanda a cui risponde.

Mostra la soluzione

1. 3A: (10 + 20) / 2 = 15 giorni. 3B: 30 / 6 = 5 giorni.

2. Media delle medie: (15 + 5) / 2 = 10 giorni. Media globale: (30 + 30) / 8 = 60 / 8 = 7.5 giorni.

3. La media globale, 7.5, risponde alla domanda sullo studente: ogni prestito pesa uno. La media delle medie, 10, risponde alla domanda sulla classe: ogni classe pesa uno, indipendentemente da quanti prestiti abbia fatto. Nessuna delle due è sbagliata; è sbagliato calcolarne una e raccontare l'altra. È la stessa radice del paradosso di Simpson: il livello a cui aggreghi cambia la risposta, e il livello giusto lo decide la domanda. Nota pratica: in Pandas la seconda si ottiene per sbaglio facilissimamente, facendo .mean() su una tabella già raggruppata.

transform: un risultato per ogni riga

agg riduce (un valore per gruppo). transform restituisce una serie della stessa lunghezza dell'originale: ottimo per creare caratteristiche, come «quota dell'ordine sul totale del cliente».

m["tot_cliente"] = m.groupby("id_cliente")["importo"].transform("sum")
m["quota"] = (m["importo"] / m["tot_cliente"]).round(3)
print(m.loc[m["id_cliente"] == 5, ["id_ordine", "importo", "tot_cliente", "quota"]])

Output ottenuto:

    id_ordine  importo  tot_cliente  quota
0         100   113.49       382.81  0.296
3         103   116.17       382.81  0.303
6         106    34.50       382.81  0.090
11        111   118.65       382.81  0.310

merge: unire tabelle e il pericolo dei duplicati

merge fa l'equivalente del JOIN di SQL. I tipi principali:

how Cosa tiene
inner solo le chiavi presenti in entrambe
left tutte le righe di sinistra, con NaN dove a destra manca
right simmetrico
outer tutte le chiavi di entrambe

Il rischio grosso non è il tipo di join, è la cardinalità. Se la chiave a destra non è unica, ogni riga di sinistra viene duplicata per ogni corrispondenza, e i totali si gonfiano senza alcun avviso.

clienti_sporca = pd.concat([clienti, clienti.iloc[[0]]], ignore_index=True)   # il cliente 1 è duplicato
print("righe prima :", len(ordini))

gonfiato = ordini.merge(clienti_sporca, on="id_cliente", how="left")
print("righe dopo (senza validate):", len(gonfiato))
print("totale vero    :", round(ordini["importo"].sum(), 2))
print("totale gonfiato:", round(gonfiato["importo"].sum(), 2))

try:
    ordini.merge(clienti_sporca, on="id_cliente", how="left", validate="m:1")
except Exception as e:
    print("con validate='m:1':", type(e).__name__)

Output ottenuto:

righe prima : 12
righe dopo (senza validate): 13
totale vero    : 894.58
totale gonfiato: 1013.83
con validate='m:1': MergeError

Il parametro validate="m:1" (molti a uno: la chiave a destra deve essere unica; la guida al merge lo descrive come controllo sull'unicità delle chiavi) trasforma un errore silenzioso in un'eccezione. È l'assicurazione che costa una parola e dovrebbe comparire in quasi ogni merge di una pipeline. Anche indicator=True aiuta: la colonna _merge dice da dove viene ogni riga.

Regola del pollice. Prima e dopo un merge, controlla il numero di righe. Se cambia e non te lo aspetti, ti sei imbattuto nei duplicati.

Esercizio 2

Il totale che si gonfia da solo

La tabella dei prestiti ha 10 righe, e il lettore numero 3 compare in 3 di esse, con prestiti da 5 giorni ciascuno. Il totale dei giorni noti è 72. Per un errore di inserimento, l'anagrafica dei lettori contiene due volte la riga del lettore 3.

  1. Quante righe escono da prestiti.merge(lettori, on="id_lettore", how="left")?
  2. A quanto arriva il totale dei giorni dopo quel merge?
  3. Quale parametro del merge avrebbe trasformato l'errore silenzioso in un'eccezione, e di che tipo è l'eccezione?

Anche qui le prime due domande sono conti, la terza chiede il rimedio. Prima di rispondere ricorda la regola del pollice della sezione precedente: contare le righe prima e dopo ogni unione, e confrontare il totale degli importi.

Mostra la soluzione

1. 13. Ogni riga di sinistra viene ripetuta una volta per ogni corrispondenza a destra: i tre prestiti del lettore 3 ne trovano due ciascuno, quindi 10 + 3 = 13. Le altre righe non cambiano.

2. I tre prestiti duplicati valgono 5 giorni ciascuno e vengono contati due volte: 72 + 15 = 87. Un totale cresciuto di un quinto, senza nessun messaggio.

3. validate="m:1" («molti a uno»: la chiave a destra deve essere unica), che solleva MergeError. È un'assicurazione che costa una parola e dovrebbe comparire in quasi ogni merge di una pipeline. Perché un'eccezione e non un avviso: l'avviso finisce in un log, mentre il report sbagliato arriva lo stesso in riunione; l'eccezione ferma il lavoro nel punto esatto in cui il dato si rompe, che è l'unico momento in cui qualcuno lo può ancora riparare. In mancanza di validate, la regola del pollice è contare le righe prima e dopo ogni unione.

Date e tempo

Le date lette da un file sono quasi sempre stringhe. pd.to_datetime le converte in datetime64, e l'accessor .dt espone le parti.

ordini["mese"] = ordini["data"].dt.month
ordini["giorno_sett"] = ordini["data"].dt.day_name()
per_mese = ordini.groupby("mese")["importo"].agg(["count", "sum"]).round(2)
print(per_mese)

print(pd.to_datetime("03/04/2026", dayfirst=True).date(), pd.to_datetime("03/04/2026", dayfirst=False).date())

Output ottenuto:

      count     sum
mese               
1         2  234.82
2         2   83.94
3         4  355.09
4         2  220.73
2026-04-03 2026-03-04

L'ultima riga è un classico: 03/04/2026 è il 3 aprile o il 4 marzo? Dipende dalla convenzione. Dichiara sempre il formato (format="%d/%m/%Y") quando leggi date da fonti umane: l'ambiguità riguarda ogni data con giorno da 1 a 12, e la lettura sbagliata non produce nessun errore.

Riassumere in due dimensioni: pivot_table e crosstab

Quando i raggruppamenti sono due, conviene una tabella a doppia entrata: righe per un criterio, colonne per l'altro. pivot_table la costruisce con un'aggregazione a scelta; crosstab conta le combinazioni. Qui incrociamo il segmento del cliente e il mese dell'ordine, dando il nome «ignoto» agli ordini senza anagrafica.

import numpy as np
import pandas as pd

rng = np.random.default_rng(7)
clienti = pd.DataFrame({
    "id_cliente": [1, 2, 3, 4],
    "citta": ["Lecce", "Milano", "Lecce", "Torino"],
    "segmento": ["privato", "azienda", "privato", "azienda"],
})
n = 12
ordini = pd.DataFrame({
    "id_ordine": range(100, 100 + n),
    "id_cliente": rng.choice([1, 2, 3, 4, 5], n),
    "importo": np.round(rng.gamma(shape=4, scale=30, size=n), 2),
    "data": pd.Timestamp("2026-01-01") + pd.to_timedelta(rng.integers(0, 120, n), unit="D"),
})
ordini.loc[[2, 7], "importo"] = np.nan

m = ordini.merge(clienti, on="id_cliente", how="left", validate="m:1")
m["segmento"] = m["segmento"].fillna("ignoto")
m["mese"] = m["data"].dt.month

print(m.pivot_table(index="segmento", columns="mese", values="importo", aggfunc="sum", fill_value=0).round(1))
print()
print(pd.crosstab(m["segmento"], m["mese"]))
print()
print(m.set_index("data")["importo"].resample("MS").agg(["count", "sum"]).round(2))
print()
m["rango_nel_cliente"] = m.groupby("id_cliente")["importo"].rank(ascending=False, method="min")
print(m.loc[m["id_cliente"] == 5, ["id_ordine", "importo", "rango_nel_cliente"]])

Output ottenuto:

mese          1     2      3      4
segmento                           
azienda     0.0  49.4  127.5  107.2
ignoto    234.8  34.5    0.0  113.5
privato     0.0   0.0  227.6    0.0

mese      1  2  3  4
segmento            
azienda   1  1  2  2
ignoto    2  1  0  1
privato   0  0  2  0

            count     sum
data                     
2026-01-01      2  234.82
2026-02-01      2   83.94
2026-03-01      4  355.09
2026-04-01      2  220.73

    id_ordine  importo  rango_nel_cliente
0         100   113.49                3.0
3         103   116.17                2.0
6         106    34.50                4.0
11        111   118.65                1.0

Come leggere il codice. La pivot_table ha i segmenti sulle righe, i mesi sulle colonne e la somma degli importi nelle celle (fill_value=0 mette zero dove non c'è nessun ordine; attenzione: uno zero qui significa «nessun ordine», non «ordine da zero euro»). La crosstab conta gli ordini per combinazione: confrontandola con la tabella precedente si capisce che gli zeri sono celle vuote, e si vede che il segmento «ignoto» ha ordini in tre mesi su quattro.

Il terzo blocco usa resample("MS"), che raggruppa una colonna indicizzata per data in intervalli regolari (qui Month Start, un gruppo per mese) e applica le aggregazioni count e sum: è il groupby per serie temporali. L'ultimo blocco risolve la richiesta del mini-progetto sul rango: dentro ciascun gruppo (id_cliente) ordina gli importi dal più grande e assegna la posizione (rank); i valori mancanti restano NaN e non ricevono un rango. Il cliente 5 ha quattro ordini e, tra quelli con importo noto, il più grande ha rango 1.

Un secondo esempio, contrastante: il tipo category contro il testo

Le colonne di testo con pochi valori distinti e molte righe (la città, il segmento, il paese) occupano molto spazio se ogni riga conserva la propria stringa. Il tipo category memorizza una volta l'elenco dei valori distinti e, per ogni riga, solo un piccolo codice intero (come i numeri di cassetto di una tabella hash, vista in liste, tuple, dizionari e insiemi).

import numpy as np
import pandas as pd

rng = np.random.default_rng(7)
grande = pd.DataFrame({"citta": rng.choice(["Lecce", "Milano", "Torino", "Roma", "Napoli"], 200_000)})

come_testo = grande.memory_usage(deep=True)["citta"]
come_categoria = grande.astype({"citta": "category"}).memory_usage(deep=True)["citta"]
print("tipo di partenza:", grande["citta"].dtype)
print(f"memoria come testo      : {come_testo:>9,} byte".replace(",", "."))
print(f"memoria come category   : {come_categoria:>9,} byte".replace(",", "."))
print(f"rapporto                : {come_testo / come_categoria:.1f} volte")
print("valori distinti memorizzati una volta sola:", list(grande.astype({"citta": "category"})["citta"].cat.categories))

Output ottenuto:

tipo di partenza: str
memoria come testo      : 2.679.698 byte
memoria come category   :   200.068 byte
rapporto                : 13.4 volte
valori distinti memorizzati una volta sola: ['Lecce', 'Milano', 'Napoli', 'Roma', 'Torino']

Come leggere il codice. Il DataFrame ha 200 000 righe con soli cinque valori possibili. memory_usage(deep=True) misura i byte occupati includendo il contenuto delle stringhe; astype({"citta": "category"}) converte la colonna. Il risparmio è di un ordine di grandezza (circa 13 volte), e il groupby su una colonna category è anche più veloce, perché raggruppa per codici interi. Trade-off: category ha senso quando i valori distinti sono pochi rispetto alle righe; con una colonna dove ogni valore è quasi unico (un identificativo, un indirizzo email) non risparmia nulla e complica le operazioni. Inoltre, nuove categorie vanno dichiarate: assegnare un valore non previsto a una colonna category solleva un errore.

Copy-on-Write e la fine dell'assegnazione a catena

Fino a Pandas 2, scrivere df[df.a > 1]["b"] = 9 poteva modificare df oppure no, a seconda che l'operazione intermedia restituisse una vista o una copia: un comportamento imprevedibile, segnalato dal famigerato SettingWithCopyWarning. Da Pandas 3 vale la regola del Copy-on-Write: qualunque risultato di un'operazione si comporta come una copia indipendente, e modificarlo non tocca mai l'originale. L'assegnazione a catena, quindi, non ha più effetto (e Pandas lo segnala con un avviso).

import warnings

x = pd.DataFrame({"a": [1, 2, 3], "b": [0, 0, 0]})
with warnings.catch_warnings(record=True) as avvisi:
    warnings.simplefilter("always")
    x[x["a"] > 1]["b"] = 9           # assegnazione A CATENA: modifica una copia temporanea
    print("dopo la catena   :", x["b"].tolist(), "| avviso:", [a.category.__name__ for a in avvisi])

x.loc[x["a"] > 1, "b"] = 9           # un solo passaggio, con loc: modifica x
print("dopo loc         :", x["b"].tolist())

Output ottenuto:

dopo la catena   : [0, 0, 0] | avviso: ['ChainedAssignmentError']
dopo loc         : [0, 9, 9]

La regola da ricordare: per modificare un sottoinsieme, usa un solo loc[righe, colonna] = valore. La forma con due parentesi in fila crea una copia temporanea e scrive su quella.

Errori tipici (sintomo, causa, rimedio)

  • Un totale che «non torna» dopo un merge. Causa: chiave non unica che duplica righe. Rimedio: validate="m:1" e controllo del numero di righe prima/dopo.
  • Un gruppo sparisce dal report. Causa: chiave NaN scartata da groupby. Rimedio: dropna=False e controllo che la somma dei gruppi coincida con il totale.
  • NaN che inquina le medie. Causa: confronto o somma con valori mancanti. Rimedio: conoscere skipna (vedi la guida ai valori mancanti), decidere una strategia esplicita, mai fillna(0) per riflesso (uno zero è un valore, non un'assenza).
  • and/or su colonne. Sintomo: ValueError sul valore di verità ambiguo. Rimedio: &, |, ~ con parentesi, oppure query.
  • loc e iloc confusi dopo un filtro. Rimedio: usa reset_index(drop=True) quando l'indice non ha significato, o ragiona sempre in etichette.
  • Date lette al contrario. Rimedio: format= esplicito.
  • Un ciclo for sulle righe. Sintomo: lentezza (stesso problema della lezione su NumPy). Rimedio: operazioni sulle colonne, groupby, merge.

Le prime due righe e la quarta hanno lo stesso aspetto: un report che sembra corretto e ha un numero sbagliato. Per questo la difesa non è nel codice più furbo ma nel controllo aritmetico a valle (somma dei gruppi uguale al totale, righe prima e dopo). Gli ultimi due sono errori di abitudine e si vedono subito: un'eccezione o un tempo di esecuzione che esplode.

Best practice

  • Ispeziona prima di calcolare: shape, dtypes, isna().sum(), head().
  • Controlla le cardinalità di ogni merge e ogni groupby (righe prima e dopo; somma dei gruppi uguale al totale).
  • Un'operazione per riga di codice, con nomi di colonne che si capiscono: la catena di metodi è elegante se rimane leggibile.
  • Non modificare l'originale: crea colonne nuove con assign o su una copia, e conserva i dati grezzi.
  • Documenta come tratti i mancanti: è una decisione di analisi, e chi legge deve poterla contestare.
  • Fissa i tipi all'ingresso (dtype=, parse_dates=) quando leggi i file.

Sono sei abitudini con un'unica ragione: Pandas ti dà una risposta anche quando la domanda era sbagliata. Ispezionare, controllare le cardinalità e fissare i tipi spostano l'errore dal momento in cui il report è già in riunione al momento in cui carichi i dati, che è l'unico in cui correggerlo costa poco.

Mini-progetto

Con le tabelle ordini e clienti di questa lezione:

  1. Calcola il fatturato mensile per segmento (privato/azienda), includendo gli ordini senza anagrafica come segmento «ignoto».
  2. Aggiungi la colonna rango_nel_cliente: la posizione dell'ordine per importo decrescente all'interno di ciascun cliente (suggerimento: groupby + rank).
  3. Verifica con un assert che la somma del fatturato per segmento coincida con il totale degli importi noti.
  4. Scrivi due righe sul motivo per cui, in questo dataset sintetico, non faresti fillna(0) sugli importi.

I punti 1 e 2 sono risolti, in forma diversa, nel blocco su pivot_table e rank più sopra: provaci prima da solo e poi confronta. Il punto 3 è il controllo di cui parla la sezione sugli errori tipici, il punto 4 chiede una motivazione scritta, perché una decisione sui dati mancanti che non si può contestare non è un'analisi.

Challenge

  • Easy. Trova l'ordine con l'importo massimo e il cliente che lo ha fatto, senza cicli (idxmax).
  • Medium. Per ciascun cliente calcola i giorni trascorsi tra due ordini consecutivi (sort_values, groupby, diff).
  • Hard. Costruisci un esempio di merge in cui la chiave a destra duplicata fa raddoppiare il totale, poi correggilo in due modi (deduplicare prima, oppure aggregare a destra) e spiega quando preferisci l'uno o l'altro.
  • Expert. Scrivi una funzione controlla_merge(sinistra, destra, chiave) che restituisce un riepilogo (righe di sinistra, chiavi non trovate, chiavi duplicate a destra, righe dopo il merge) e solleva un'eccezione se la cardinalità non è quella attesa.
  • Enterprise. Prendi un'esportazione reale della tua organizzazione. Elenca per ogni colonna: tipo, percentuale di mancanti, ipotesi plausibile sul perché mancano (completamente a caso, a caso condizionato, non a caso), e la strategia che adotteresti.

I livelli salgono dal comando singolo (Easy) alla funzione di controllo riusabile (Expert) fino a un'analisi su dati tuoi (Enterprise). Per Hard conta il confronto fra le due correzioni: deduplicare perde informazione sul lato destro, aggregare la conserva ma richiede di decidere come, ed è questa scelta, non il codice, ciò che devi motivare.

Che cosa sai fare adesso

Sai prevedere che cosa farà Pandas quando operi su due oggetti con indici diversi, scegliere fra loc e iloc sapendo che dopo un filtro i due divergono, e trattare i valori mancanti come una decisione di analisi e non come un fastidio. Sai costruire un groupby che non perde il gruppo con la chiave mancante, controllare un merge con validate e con il conteggio delle righe, e modificare un sottoinsieme con un solo loc[...] = ... sotto il Copy-on-Write. Il passo successivo è la visualizzazione con Matplotlib: i numeri riassuntivi nascondono molto, e un grafico è il primo modo di accorgersene.

Nella stessa serie trovi anche: 10 - Python per i dati 3: funzioni, file, eccezioni, oggetti, 16 - Insiemi e logica: il linguaggio della matematica (che richiamano questa lezione).

Fonti

Riferimenti bibliografici citati nel testo, non consultati online in questa revisione: D. B. Rubin, Inference and Missing Data, Biometrika 63(3), 581-592, 1976; H. Wickham, The Split-Apply-Combine Strategy for Data Analysis, Journal of Statistical Software 40(1), 2011. Tutte le uscite sono state ricalcolate con Python 3.14.4, NumPy 2.3.5 e pandas 3.0.3.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti