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.dtypesedf.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.
- Quanti sono e dove?
df.isna().sum()conta per colonna. - 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.
- 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.
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:
sizecontrocount.sizeconta le righe del gruppo;countconta 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 defaultgroupbyscarta (come dice la guida a groupby) silenziosamente il gruppo la cui chiave èNaN: quei 4 ordini sparirebbero dal totale. Condropna=Falsecompaiono 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.
- Quanto vale la durata media per ciascuna delle due classi?
- Quanto vale la media delle due medie? E la media di tutti e otto i prestiti?
- 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.
- Quante righe escono da
prestiti.merge(lettori, on="id_lettore", how="left")? - A quanto arriva il totale dei giorni dopo quel merge?
- Quale parametro del
mergeavrebbe 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
NaNscartata dagroupby. Rimedio:dropna=Falsee controllo che la somma dei gruppi coincida con il totale. NaNche inquina le medie. Causa: confronto o somma con valori mancanti. Rimedio: conoscereskipna(vedi la guida ai valori mancanti), decidere una strategia esplicita, maifillna(0)per riflesso (uno zero è un valore, non un'assenza).and/orsu colonne. Sintomo:ValueErrorsul valore di verità ambiguo. Rimedio:&,|,~con parentesi, oppurequery.loceilocconfusi dopo un filtro. Rimedio: usareset_index(drop=True)quando l'indice non ha significato, o ragiona sempre in etichette.- Date lette al contrario. Rimedio:
format=esplicito. - Un ciclo
forsulle 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
mergee ognigroupby(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
assigno 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:
- Calcola il fatturato mensile per segmento (
privato/azienda), includendo gli ordini senza anagrafica come segmento «ignoto». - Aggiungi la colonna
rango_nel_cliente: la posizione dell'ordine per importo decrescente all'interno di ciascun cliente (suggerimento:groupby+rank). - Verifica con un
assertche la somma del fatturato per segmento coincida con il totale degli importi noti. - 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
mergein 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
- pandas, What's new in 3.0.0 - tipo
strdi default e regola del Copy-on-Write con fine dell'assegnazione a catena; consultata il 2026-11-12 - pandas User Guide, Merge, join, concatenate - il parametro
validatecome controllo sull'unicità delle chiavi; consultata il 2026-11-12 - pandas User Guide, Group by - i valori mancanti nelle chiavi sono esclusi salvo
dropna=False; consultata il 2026-11-12 - pandas User Guide, Working with missing data -
skipnanelle riduzioni; consultata il 2026-11-12 - pandas User Guide, Indexing and selecting data - estremi inclusi nelle fette per etichetta; consultata il 2026-11-12
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