Abbiamo passato dodici lezioni a costruire un vocabolario: dtype, IO, selezione, join, groupby, serie temporali, performance, Polars. Oggi facciamo la cosa per cui quel vocabolario esiste. Prendiamo un dataset reale, lo portiamo dal CSV grezzo a una risposta pulita, e scriviamo il tipo di script che committeresti davvero in un repository. Questa è la lezione di chiusura del Modulo 6 e un template di lavoro per quasi ogni task di analisi che ti capiterà tra le mani nel tuo primo lavoro.
Il dataset che useremo è il file mensile dei NYC Yellow Taxi Trips: pubblicamente disponibile, circa 3 milioni di righe al mese, sporco nel modo interessante in cui lo sono i dati reali. La domanda a cui risponderemo: qual è la percentuale media di mancia per ora del giorno, e differisce tra chi paga con carta di credito e chi paga in contanti? È il tipo di domanda che un manager fa davvero, e rispondervi richiede ogni passo della pipeline.
Lo script completo è alla fine della lezione. Qui camminiamo attraverso le sei fasi.
La forma: lo script di scaffolding
Prima di qualsiasi codice, la forma. Un buon script di analisi è un singolo file Python, eseguibile dall’inizio alla fine, con commenti di sezione. All’incirca:
"""nyc_taxi_tips.py — average tip % by hour, by payment type."""
# 1. Load + inspect
# 2. Clean
# 3. Explore
# 4. Transform
# 5. Answer
# 6. Save / present
if __name__ == "__main__":
main()
Perché un file unico e non un notebook? I notebook sono ottimi per la fase di esplorazione, ma la versione che finisce in un repository e viene rieseguita sui dati del mese prossimo dovrebbe essere uno script: riproducibile, diffabile, eseguibile da cron. Il pattern che uso io: esplora in un notebook, poi una volta che conosci i passaggi, copiali in uno script che fa solo quei passaggi in ordine. Il notebook resta come diario; lo script è il deliverable.
Fase 1: caricare e ispezionare
Sempre le stesse tre cose: shape, dtype, sample, null. Non saltarne nessuna, per quanto tu sia sicuro del file.
import pandas as pd
df = pd.read_parquet("yellow_tripdata_2025-01.parquet", dtype_backend="pyarrow")
print(df.shape)
print(df.dtypes)
print(df.head())
print(df.tail())
print(df.sample(5))
print(df.isna().sum())
print(df.describe())
Eseguendolo su un file dei taxi NYC di gennaio ottieni qualcosa del genere:
(3_267_104, 19)
VendorID int64[pyarrow]
tpep_pickup_datetime timestamp[us][pyarrow]
tpep_dropoff_datetime timestamp[us][pyarrow]
passenger_count double[pyarrow]
trip_distance double[pyarrow]
...
fare_amount double[pyarrow]
tip_amount double[pyarrow]
total_amount double[pyarrow]
payment_type int64[pyarrow]
Tre osservazioni da quel singolo dump:
payment_typeè un codice intero, non una label. servirà una lookup table per renderlo umano.passenger_countè un float, cosa sospetta per un conteggio. Probabilmente ha dei null ed è stato promosso.- Il dataset ha 3,2 milioni di righe e 19 colonne. Sta in memoria comodamente; oggi non c’è bisogno di chunking o Polars.
describe() ti racconterà il resto della storia: distanza minima del viaggio pari a 0 (viaggi a zero miglia, sono bug), mancia massima di 400 dollari (qualcuno ha lasciato 400 dollari di mancia su una corsa da 30? probabilmente un errore di data entry), fare_amount che va leggermente in negativo (rimborsi, o errori di segno). Tutto questo richiederà decisioni nella fase di pulizia.
isna().sum() ti mostra quali colonne hanno null. In questo file passenger_count e qualche altra ne hanno una piccola percentuale; le gestiremo.
Fase 2: pulire
È qui che la maggior parte delle analisi spende la maggior parte del tempo, ed è dove lo script si guadagna la fiducia. Ogni decisione di pulizia è un judgement call che fai sui dati, e scriverle esplicitamente è la differenza tra uno script riproducibile e uno misterioso.
# Parse the payment type codes (from the NYC TLC data dictionary)
payment_map = {
1: "credit_card",
2: "cash",
3: "no_charge",
4: "dispute",
5: "unknown",
6: "voided_trip",
}
df["payment_label"] = df["payment_type"].map(payment_map).astype("category")
# Drop rows where the trip is obviously bogus
df = df[df["trip_distance"] > 0] # zero-distance trips
df = df[df["fare_amount"] > 0] # negative fares
df = df[df["tip_amount"] >= 0] # negative tips
df = df[df["total_amount"] > 0]
df = df[df["tpep_dropoff_datetime"] > df["tpep_pickup_datetime"]] # time travel
# Cap outliers at the 99.5th percentile so one $1000 ride doesn't skew averages
fare_cap = df["fare_amount"].quantile(0.995)
df = df[df["fare_amount"] <= fare_cap]
# Fill passenger_count nulls with 1 (the most common value, and a reasonable assumption)
df["passenger_count"] = df["passenger_count"].fillna(1).astype("Int64")
# Cast known-categorical columns
df["VendorID"] = df["VendorID"].astype("category")
Qualche pattern da notare:
- Ogni filtro è su una riga separata, con un commento. Un futuro lettore (incluso te-del-futuro) deve poter scorrere il codice e vedere cosa è stato buttato via.
- Il cap al 99,5esimo percentile sul fare è un judgement call: sto dicendo “tutto sopra a questo valore è probabilmente dato sbagliato, e includerlo distorcerebbe la risposta.” è il tipo di decisione che vale la pena giustificare in un commento.
- Il cast a categoria alla fine in modo che le fasi di esplorazione e groupby siano veloci.
Dopo la pulizia, stampa il nuovo shape:
print(f"Cleaned shape: {df.shape}, dropped {3_267_104 - len(df):,} rows")
Devi sempre sapere quante righe hai scartato. Se sono più di una piccola percentuale, indaga il Perché.
Fase 3: esplorare
Prima di calcolare la risposta, guarda i dati. È la fase in cui gli assistenti AI sono insolitamente utili: incolla l’output di df.describe() e df.head() in Claude o ChatGPT e chiedi “cosa dovrei guardare ora data la domanda ‘mance per ora per tipo di pagamento’?” Il modello suggerirà in modo affidabile le derivazioni di colonna giuste e i sanity check standard. Non è magia: è pattern matching contro diecimila analisi simili su internet, ma velocizza parecchio il loop “cosa mi sto perdendo?”. Dove l’AI è meno utile è la domanda di quale domanda fare, e i judgement call sulla pulizia. Quelli restano tuoi.
Concretamente, nella fase di esplorazione:
print(df["payment_label"].value_counts(normalize=True))
print(df.groupby("payment_label", observed=True)["tip_amount"].describe())
print(df["tpep_pickup_datetime"].dt.hour.value_counts().sort_index())
Cosa cercherei qui: chi paga con carta di credito e chi paga in contanti hanno distribuzioni di mancia sospettosamente diverse? (Sì: le mance in contanti sono di solito 0 dollari in questo dataset, Perché l’autista non le inserisce. È una scoperta enorme per la nostra domanda e dovremo gestirla.) Ci sono ore del giorno con un numero stranamente basso di corse? (3-4 di mattina, atteso.) L’intervallo temporale è corretto, o il file include qualche riga vagante di dicembre?
Fase 4: trasformare
Ora le colonne che la domanda richiede davvero:
df["hour"] = df["tpep_pickup_datetime"].dt.hour
df["tip_pct"] = (df["tip_amount"] / df["fare_amount"]) * 100
# The cash-tip problem: cash trips almost always show tip=0 because of how
# the data is collected. Including them would systematically pull cash tips
# toward zero in a way that doesn't reflect reality. The right call here is
# to either restrict to credit-card trips for the headline number, or to
# show both with a clear caveat. We'll do both.
df_cc = df[df["payment_label"] == "credit_card"].copy()
Quel terzo blocco di commento è il tipo di judgement call che trasforma “uno script” in “un’analisi”. Un lettore che vede solo i numeri non saprà che i dati cash sono strutturalmente distorti. Un lettore che vede questo script sì.
Fase 5: rispondere
Il calcolo vero e proprio, dopo tutto quel setup, è qualche riga:
# Headline: tip % by hour, credit-card payers only
hourly = (
df_cc.groupby("hour", observed=True)["tip_pct"]
.agg(["mean", "median", "count"])
.round(2)
)
print(hourly)
# Comparison: tip % by hour, by payment type, both populations
by_pmt = (
df.groupby(["hour", "payment_label"], observed=True)["tip_pct"]
.mean()
.unstack()
.round(2)
)
print(by_pmt)
Guarda il risultato e fai un sanity check. Percentuale media di mancia attorno al 18-22% durante il giorno, in calo di un paio di punti a tarda notte, in salita alle 4-6 del mattino (campioni piccoli, corse aeroportuali verso LGA/JFK con corse più grandi e mance più tonde). La colonna cash vicino a zero in tutta la giornata, esattamente come ci si aspetta dal problema strutturale.
Sanity check sempre. Se la risposta sembra drammatica, il bug è più spesso nel tuo codice che nei dati.
Fase 6: salvare e presentare
Scrivi il risultato da qualche parte di durevole, in un formato diffabile e ricaricabile:
hourly.to_parquet("output/tip_pct_by_hour.parquet")
with open("output/summary.md", "w") as f:
f.write("# NYC taxi tips, January 2025\n\n")
f.write(f"Trips analyzed: {len(df_cc):,} (credit-card only)\n")
f.write(f"Overall mean tip %: {df_cc['tip_pct'].mean():.2f}\n\n")
f.write("## Hourly tip percentage\n\n")
f.write(hourly.to_markdown())
ax = hourly["mean"].plot(kind="bar", figsize=(10, 5),
title="Mean tip % by hour of day (credit-card)")
ax.set_xlabel("Hour")
ax.set_ylabel("Tip %")
ax.figure.savefig("output/tip_pct_by_hour.png", dpi=150, bbox_inches="tight")
Tre artefatti: un file Parquet (per ulteriore lavoro programmatico), un riassunto Markdown (per il lettore umano), e un grafico PNG (per le slide). Questo è l’output durevole dell’analisi. Lo script può essere rieseguito il mese prossimo con un input diverso e produrre gli stessi tre artefatti.
Lo script intero
Tutto incollato insieme, con il boilerplate:
"""nyc_taxi_tips.py — average tip % by hour, by payment type."""
from pathlib import Path
import pandas as pd
INPUT = "data/yellow_tripdata_2025-01.parquet"
OUTPUT_DIR = Path("output")
PAYMENT_MAP = {
1: "credit_card", 2: "cash", 3: "no_charge",
4: "dispute", 5: "unknown", 6: "voided_trip",
}
def load_and_inspect(path: str) -> pd.DataFrame:
df = pd.read_parquet(path, dtype_backend="pyarrow")
print(f"Loaded {df.shape[0]:,} rows, {df.shape[1]} cols")
print(df.dtypes, "\n")
print(df.describe(), "\n")
print("Nulls per column:")
print(df.isna().sum()[df.isna().sum() > 0], "\n")
return df
def clean(df: pd.DataFrame) -> pd.DataFrame:
initial = len(df)
df = df.copy()
df["payment_label"] = df["payment_type"].map(PAYMENT_MAP).astype("category")
df = df[df["trip_distance"] > 0]
df = df[df["fare_amount"] > 0]
df = df[df["tip_amount"] >= 0]
df = df[df["total_amount"] > 0]
df = df[df["tpep_dropoff_datetime"] > df["tpep_pickup_datetime"]]
df = df[df["fare_amount"] <= df["fare_amount"].quantile(0.995)]
df["passenger_count"] = df["passenger_count"].fillna(1).astype("Int64")
df["VendorID"] = df["VendorID"].astype("category")
print(f"Dropped {initial - len(df):,} rows ({(initial - len(df)) / initial:.1%})")
return df
def transform(df: pd.DataFrame) -> pd.DataFrame:
df = df.copy()
df["hour"] = df["tpep_pickup_datetime"].dt.hour
df["tip_pct"] = (df["tip_amount"] / df["fare_amount"]) * 100
return df
def analyze(df: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
df_cc = df[df["payment_label"] == "credit_card"]
hourly = (
df_cc.groupby("hour", observed=True)["tip_pct"]
.agg(["mean", "median", "count"])
.round(2)
)
by_pmt = (
df.groupby(["hour", "payment_label"], observed=True)["tip_pct"]
.mean().unstack().round(2)
)
return hourly, by_pmt
def save(hourly: pd.DataFrame, by_pmt: pd.DataFrame, df_cc: pd.DataFrame) -> None:
OUTPUT_DIR.mkdir(exist_ok=True)
hourly.to_parquet(OUTPUT_DIR / "tip_pct_by_hour.parquet")
by_pmt.to_parquet(OUTPUT_DIR / "tip_pct_by_hour_by_payment.parquet")
with open(OUTPUT_DIR / "summary.md", "w") as f:
f.write("# NYC taxi tips, January 2025\n\n")
f.write(f"- Credit-card trips analyzed: **{len(df_cc):,}**\n")
f.write(f"- Overall mean tip %: **{df_cc['tip_pct'].mean():.2f}**\n\n")
f.write("## Hourly tip percentage (credit-card)\n\n")
f.write(hourly.to_markdown())
ax = hourly["mean"].plot(
kind="bar", figsize=(10, 5),
title="Mean tip % by hour of day (credit-card)",
)
ax.set_xlabel("Hour"); ax.set_ylabel("Tip %")
ax.figure.savefig(OUTPUT_DIR / "tip_pct_by_hour.png",
dpi=150, bbox_inches="tight")
def main() -> None:
df = load_and_inspect(INPUT)
df = clean(df)
df = transform(df)
hourly, by_pmt = analyze(df)
print("\nHourly (credit-card):"); print(hourly)
print("\nBy payment type:"); print(by_pmt)
save(hourly, by_pmt, df[df["payment_label"] == "credit_card"])
if __name__ == "__main__":
main()
Circa cento righe, sei funzioni, eseguibile dall’inizio alla fine. Ogni funzione fa una fase. Ogni fase stampa qualcosa, così vedi i progressi. L’output va in una directory. Potresti schedularlo con cron stasera e non guardarlo mai più, oppure rieseguirlo sul file di marzo cambiando una sola costante.
Pattern che vale la pena portarsi dietro
Qualche cosa nello script qua sopra che torna in ogni analisi che abbia mai scritto:
- Funzioni per fase, chiamate da un
main(). più facili da testare, più facili da saltare quando stai iterando (“rilancia soloanalyzesul frame in memoria”). - Costanti in cima, percorsi inclusi. Mai sotterrare un percorso file dentro una funzione.
- Stampa dopo ogni fase. Lunghi script pandas che girano in silenzio per dieci minuti e poi crashano sono demoralizzanti; stampa il numero di righe dopo ogni passo.
- Commenta i judgement call, non il codice. “Cap a p99,5 Perché la coda lunga è dato sporco” merita una riga. “Calcola la media della colonna” no.
- Salva in Parquet, non in CSV, per output intermedi. più veloce, più piccolo, dtype-safe.
- Un grafico per domanda. Troppi grafici e nessuno li guarda più.
Quando ricorrere a Polars
Per un dataset da 3 milioni di righe pandas va benissimo, e lo script qua sopra gira in 30 secondi. Se lo stesso script processasse un anno di dati taxi NYC (36 milioni di righe su 12 file) riscriverei le fasi di load e clean in Polars (pl.scan_parquet("data/*.parquet") seguito dagli stessi filtri in forma di espressione) e convertirei a pandas solo per il groupby finale e il plotting, dove l’integrazione di pandas con matplotlib è comoda. Il pattern della lezione 35 (Polars per il lavoro pesante, pandas per l’handoff) è esattamente questo.
Chiusura del Modulo 6
così si chiude il Modulo 6. In dieci lezioni siamo passati da “cos’è un DataFrame” a “ecco una pipeline di analisi completa contro un dataset pubblico reale”, con tappe a ogni API importante di pandas e una deviazione seria attraverso Polars. Adesso ne sai abbastanza per fare il lavoro sui dati in praticamente qualsiasi ruolo Python: leggi il file, puliscilo, esploralo, rispondi alla domanda, salva la risposta, e abbi un’opinione su se usare pandas o Polars per il prossimo.
Il Modulo 7 è data engineering: prendere analisi come quella sopra e renderle production-grade. Significa gestire i fallimenti come si deve, scriverle come job schedulabili, monitorarle quando girano senza supervisione, e la noiosa-ma-essenziale domanda di come accorgersi quando uno dei tuoi script ha cominciato silenziosamente a produrre spazzatura. Il salto è da “ho lanciato l’analisi una volta e ho ottenuto una risposta” a “questa cosa gira ogni lunedì alle 6 e il business ci si appoggia sopra”. Mentalità diversa, stesso Python.
Letture di approfondimento
- pandas user guide: la roba completa. Vale la rilettura una volta che hai sviluppato la memoria muscolare del progetto. Recuperato il 2026-05-01.
- Polars user guide: i pattern di migrazione che abbiamo usato nella lezione 35, espansi. Recuperato il 2026-05-01.
- NYC TLC trip record data: il dataset, file Parquet mensili, gratis.
Ci vediamo lunedì per il Modulo 7.