Pandas è stato scritto originariamente in AQR, un hedge fund quantitativo, per gestire le time series finanziarie. E si vede. L’API time-series è una delle parti più rifinite della libreria: una volta che le dai un DataFrame con un DatetimeIndex, ti si apre tutta una macchina, conversione di frequenza, rolling window, riempimento dei buchi, aritmetica di fuso orario. Oggi vediamo le parti che contano per il normale lavoro analitico, e il bug di fuso orario che salta fuori almeno una volta in ogni pipeline di produzione.
DatetimeIndex: lo sblocco
Un DataFrame con un DatetimeIndex è una time series: pandas sa come lavorarci. Un DataFrame con una colonna created_at di tipo datetime è solo un DataFrame con una colonna datetime; puoi filtrarci sopra ma non ottieni il macchinario delle time series. Impostarlo è il primo passo:
import pandas as pd
df = pd.read_csv("sales.csv", parse_dates=["ts"], date_format="ISO8601")
df = df.set_index("ts").sort_index()
Due cose contano qui. Uno, imposta l’indice dopo il parsing: parse_dates= rende la colonna un vero datetime, poi set_index la promuove a indice. Due, sort_index(): la maggior parte delle operazioni time-series assume che l’indice sia monotonicamente crescente, e otterrai risposte sbagliate o warning se non lo è. Ordina una volta sola al caricamento.
Una volta che hai un DatetimeIndex, l’indicizzazione parziale per stringa funziona:
df["2026"] # tutte le righe del 2026
df["2026-03"] # tutte le righe di marzo 2026
df["2026-03-15":"2026-03-20"] # uno slice
È una di quelle comodità di pandas che la prima volta che funziona sembra magia.
Stringhe di frequenza
Quasi ogni funzione time-series prende una stringa di frequenza: la stenografia di pandas per “che dimensione di bucket vuoi”. Quelle che userai davvero:
"D": giornaliero"H"o"h": orario (la forma minuscola è quella moderna in pandas 2.2+; entrambe funzionano ancora)"min": minuti (la vecchia"T"è ancora accettata ma deprecata)"W": settimanale (default fine settimana di domenica; usa"W-MON","W-FRI", eccetera per altri ancoraggi)"M"/"ME": fine mese. In pandas 2.2+,"ME"è l’alias esplicito e"M"prima o poi darà warning."MS": inizio mese"Q"/"QE": fine trimestre ("QS"per inizio trimestre)"Y"/"YE": fine anno ("YS"per inizio anno)
Puoi anche combinarle: "5min", "15min", "4H", "2W". Pandas accetta quasi qualsiasi composizione sensata.
Resampling: resample()
resample è il groupby per il tempo. Mette le righe in bucket per una frequenza e ti lascia aggregare.
Downsampling: passare da frequenza più alta a più bassa, ad esempio dati al minuto a totali giornalieri. È il caso comune:
daily = df.resample("D").sum()
hourly_avg = df.resample("h").mean()
weekly_max = df.resample("W").max()
Puoi applicare più aggregazioni in una sola volta con .agg:
df.resample("D").agg({"sales": "sum", "visitors": "mean", "orders": "count"})
Upsampling: passare da frequenza più bassa a più alta. Crea righe che non esistono nella sorgente, e devi dire a pandas come riempirle:
df.resample("h").ffill() # forward-fill: porta avanti l'ultimo valore noto
df.resample("h").bfill() # back-fill: tira indietro il prossimo valore noto
df.resample("h").interpolate() # interpolazione lineare tra i punti noti
Il forward-fill è il default sicuro per cose come letture di sensori dove “l’ultimo valore è ancora vero finché non ne arriva uno nuovo”. L’interpolazione è giusta per quantità genuinamente continue (temperatura, prezzi). Non interpolare i conteggi.
asfreq: per cambiare la frequenza dell’indice senza aggregare. Utile quando vuoi una griglia regolare ma niente aggregazione fantasiosa:
df.asfreq("D", method="ffill") # una riga al giorno, forward-filled
È anche così che allinei una serie sparsa e irregolare su un calendario regolare: comune quando hai una riga per evento e vuoi una riga al giorno.
Rolling window: rolling()
Una rolling window è la cugina maggiore della media mobile: ti permette di calcolare qualsiasi aggregazione su una finestra scorrevole dei dati.
df["sales"].rolling(window=7).mean() # media trailing su 7 righe
df["sales"].rolling(window=7).std() # deviazione standard trailing su 7 righe
df["sales"].rolling(window="7D").mean() # ultimi 7 giorni, indipendentemente dal numero di righe
L’argomento window è la feature decisiva. Passa un intero e ottieni una finestra basata sul numero di righe; passa una stringa di frequenza e ottieni una finestra basata sul tempo. Quest’ultima è robusta ai buchi nei dati: una rolling mean con window="7D" su una time series sparsa ti dà “la media di tutti i dati negli ultimi 7 giorni”, qualunque sia il numero di righe. Le finestre basate sulle righe ti danno qualcosa di insensato quando ci sono buchi.
min_periods: di default, rolling restituisce NaN finché la finestra non è piena. I primi sei giorni di una rolling average di 7 giorni sono NaN. Per consentire finestre parziali:
df["sales"].rolling(window=7, min_periods=1).mean()
Ora il giorno 1 restituisce la media di se stesso, il giorno 2 dei primi due, e così via. Utile quando vuoi una serie senza NaN iniziali, ma tieni presente che i primi valori sono basati su meno dati: non confrontarli con quelli maturi senza contesto.
rolling ha tutto il menu di aggregazioni: mean, sum, std, min, max, median, count, quantile, più apply per funzioni arbitrarie. La strada delle funzioni arbitrarie è più lenta; se puoi esprimere quello che vuoi con le built-in, fallo.
Expanding window
expanding() è rolling con una finestra che cresce dall’inizio invece di scorrere. Il valore del giorno 1 è solo il giorno 1; quello del giorno 2 è l’aggregato sui giorni 1-2; quello del giorno 100 è l’aggregato sui giorni 1-100.
df["sales"].expanding().mean() # media cumulativa dall'inizio
df["sales"].expanding().sum() # equivalente a .cumsum()
df["sales"].expanding().max() # massimo corrente
È lo strumento giusto per “totale corrente dall’inizio del periodo”: ricavi correnti, massimo storico, media di vita totale, ovunque tu prenderesti altrimenti cumsum o cummax e voglia un aggregatore più flessibile.
Pesatura esponenziale: ewm()
ewm (exponentially weighted) è la cugina più sveglia di rolling. Invece di trattare ogni punto della finestra allo stesso modo, dà più peso ai punti recenti e meno ai vecchi, in modo continuo:
df["sales"].ewm(span=7).mean() # span di 7 periodi (analogo a una media a 7 giorni)
df["sales"].ewm(halflife=7).mean() # il peso si dimezza ogni 7 periodi
df["sales"].ewm(alpha=0.3).mean() # fattore di smoothing esplicito
Usa ewm quando vuoi reattività: una EWMA reagisce a un cambio di livello più velocemente di una media mobile semplice di pari span. È lo standard per la volatilità finanziaria, l’anomaly detection, e qualsiasi dashboard dove “cosa sta succedendo di recente” conta più di “cosa è successo nell’intera finestra”.
shift(): lag e lead
shift(n) sposta i dati di n righe in giù (n positivo) o in su (n negativo). Con un DatetimeIndex regolare e una frequenza, fa lag di quel numero di periodi:
df["sales_yesterday"] = df["sales"].shift(1)
df["sales_next_week"] = df["sales"].shift(-7)
df["wow_growth"] = df["sales"] / df["sales"].shift(7) - 1 # week-on-week
È il building block per qualsiasi calcolo “rispetto al periodo precedente”. I primi n valori (o gli ultimi, per shift negativi) sono NaN: per il primo giorno non c’è un ieri.
Fusi orari: la trappola da produzione
E veniamo alla parte che frega tutti. I datetime in pandas vengono in due sapori: naive (nessun fuso orario allegato, solo una stringa wall-clock) e aware (fuso orario allegato). Non si mescolano: non puoi confrontare un timestamp naive con uno aware senza errore.
Era territorio della lezione 8 nel linguaggio; qui salta fuori negli indici dei DataFrame. Le due operazioni:
# l'indice è naive; sai che in realtà è UTC
df.index = df.index.tz_localize("UTC")
# ora converti a un fuso di visualizzazione
df.index = df.index.tz_convert("Europe/Rome")
tz_localize attacca un fuso orario (nessun cambio di valore). tz_convert cambia il fuso (il valore wall-clock si aggiorna di conseguenza). Localizzare al fuso sbagliato e poi convertire è uno dei modi classici di spedire in produzione “timestamp sfasati di 6 ore”.
La trappola più profonda: DST e resample. Supponi che il tuo indice sia Europe/Rome e fai resample("D").sum(). Il giorno dell’ora legale primaverile hai 23 ore nel tuo “giorno”; al ritorno autunnale, 25. Pandas lo gestisce correttamente se l’indice è timezone-aware, e lo sbaglia in modo sottile se hai memorizzato l’ora locale come indice naive. La regola: memorizza i dati in UTC, converti in locale solo per la visualizzazione. Imposta tz_localize("UTC") all’ingestione se la fonte è UTC, tz_convert("UTC") se è un altro fuso, e poi non ragionare mai più in ora locale dentro la tua pipeline. Converti al bordo.
Un esempio reale: vendite giornaliere e-commerce con viste rolling e settimanali
Mettendo tutto insieme, un’analisi tipica di prima passata su una tabella ordini e-commerce:
import pandas as pd
orders = pd.read_parquet("orders.parquet")
orders["created_at"] = pd.to_datetime(orders["created_at"], utc=True)
orders = orders.set_index("created_at").sort_index()
# Totali vendite giornalieri in UTC
daily = orders["amount"].resample("D").sum().rename("daily_sales")
# Media mobile a 7 giorni per la dashboard
daily_smooth = daily.rolling("7D", min_periods=1).mean().rename("rolling_7d")
# Totali settimanali per l'email
weekly = daily.resample("W-MON").sum().rename("weekly_sales")
# Crescita week-on-week
weekly_growth = (weekly / weekly.shift(1) - 1).rename("wow_growth")
report = pd.concat([daily, daily_smooth], axis=1)
report.tail(30).to_csv("daily_dashboard.csv")
weekly_growth.to_csv("weekly_growth.csv")
Tre viste degli stessi dati, ognuna adatta a un consumatore diverso: una dashboard giornaliera a 30 giorni con linea smussata, un riepilogo settimanale, una serie di crescita week-on-week. Niente di tutto questo è più di poche righe perché l’indice fa il lavoro.
Cosa viene dopo
La lezione 33 è l’ottimizzazione dei dtype: category e string[pyarrow], i due dtype che trasformano un problema “questo dataset non sta in memoria” in “questo dataset va benissimo”. Se hai mai guardato un job pandas finire in swap su disco e morire, vorrai leggerla.
Letture di approfondimento
- pandas: Time series / date functionality: il riferimento completo, inclusa la tabella degli alias di offset.
- pandas: Time deltas: per l’aritmetica tra datetime.
- PEP 615 - Support for the IANA Time Zone Database: contesto su
zoneinfo, la fonte di fusi orari della standard library che pandas usa.
Ci vediamo martedì.