6 Esercizi Pratici di Serie Temporali con Pandas: dall’Analisi Base alla Previsione Avanzata

Cerca:

Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors
Guida Pratica alle Serie Temporali con Pandas

I dati che cambiano nel tempo hanno una storia da raccontare. Che si tratti del lento aumento delle vendite di un prodotto, dei picchi di traffico su un sito web durante il giorno o delle fluttuazioni stagionali di una richiesta di servizio, ogni punto dati è un frammento di una narrazione più grande. Il nostro compito, come analisti, è imparare a leggere queste storie.

Con Python e la libreria Pandas, abbiamo gli strumenti per farlo, senza dover essere per forza dei data scientist navigati. Non si tratta di formule magiche o algoritmi incomprensibili, ma di tecniche logiche per smussare il rumore, identificare cicli nascosti e persino anticipare il capitolo successivo.

In questo articolo, seguiremo un percorso pratico attraverso sei scenari un po’ insoliti ma molto concreti. Partiremo dal monitoraggio di lumache in un giardino per capire le basi, per poi arrivare a prevedere la domanda di riparazioni per macchine da scrivere e a scoprire pattern nel gioco degli scacchisti. Un viaggio per trasformare sequenze di numeri in decisioni informate.

Esercizio 1 (Base): Analisi del traffico di lumache in un giardino botanico

Contesto: Il giardino botanico di Alcantara monitora il passaggio di lumache su sensori smart per studiare i modelli di mobilità dei gasteropodi.

 Codice

import pandas as pd
import numpy as np

# Creazione dati giornalieri fittizi
date_range = pd.date_range(start="2023-01-01", end="2023-01-31", freq="D")
lumache = np.random.poisson(lam=15, size=len(date_range)) # Distribuzione di Poisson per eventi rari

df = pd.DataFrame({
    "data": date_range,
    "lumache_rilevate": lumache,
    "umidità": np.random.uniform(60, 95, len(date_range)) # Fattore ambientale
})

# Conversione a serie temporale
df = df.set_index("data")

# Calcolo media mobile 7 giorni
df["media_mobile"] = df["lumache_rilevate"].rolling(window=7).mean()

# Teoria: Le medie mobili smussano le fluttuazioni a breve termine evidenziando trend.
# In ecologia, finestre di 7 giorni corrispondono a cicli settimanali di attività.

print(df.head(3))

 Output

            lumache_rilevate  umidità  media_mobile
data                                                
2023-01-01                12   78.345...           NaN
2023-01-02                18   82.112...           NaN
2023-01-03                14   90.534...           NaN

Spiegazione:

lumache_rilevate: Conteggio giornaliero di lumache (simulato con una distribuzione di Poisson, adatta per eventi rari).

media_mobile: Media mobile a 7 giorni. Questa tecnica è fondamentale perché smussa le fluttuazioni giornaliere casuali (il “rumore”), facendo emergere il vero trend sottostante. I primi 6 valori sono NaN (Not a Number) perché per calcolare la media del 7° giorno servono i 6 giorni precedenti, cosa impossibile all’inizio della serie.

Peculiarità: Questo esercizio usa la tecnica più fondamentale delle serie temporali: la media mobile. La sua bellezza sta nella semplicità e nell’efficacia immediata. Il contesto delle lumache è perfetto perché la loro attività è influenzata da molti fattori casuali (microclima, ostacoli), creando “rumore”. La media mobile ci insegna a guardare oltre il singolo dato giornaliero per cogliere la tendenza generale, un primo passo indispensabile in qualsiasi analisi.

Esercizio 2 (Intermedio): Ottimizzazione del raccolto di funghi bioluminescenti

Contesto: Una fattoria verticale monitora l’intensità luminosa dei funghi per massimizzare la produzione di luce naturale.

Codice

import pandas as pd
import numpy as np

# Dati orari con pattern circadiano
date_range = pd.date_range(start="2023-06-01", end="2023-06-07", freq="H")
base_lum = np.sin(np.linspace(0, 6*np.pi, len(date_range))) * 10 + 15
rumore = np.random.normal(0, 2, len(date_range))

df_funghi = pd.DataFrame({
    "timestamp": date_range,
    "intensità_luminosa": np.clip(base_lum + rumore, 0, 30),
    "CO2": np.random.uniform(400, 1200, len(date_range))
}).set_index("timestamp")

# Resampling a 6 ore con interpolazione cubica
df_6h = df_funghi["intensità_luminosa"].resample("6H").interpolate(method="cubic")

# Teoria: L'interpolazione cubica preserva meglio i pattern naturali rispetto a quella lineare.
# Il resampling è cruciale quando i sensori hanno frequenze di campionamento diverse.

print(df_6h.head(3))

 Output

timestamp
2023-06-01 00:00:00    12.4...
2023-06-01 06:00:00    18.2...
2023-06-01 12:00:00    22.1...
Freq: 6H, Name: intensità_luminosa, dtype: float64

Spiegazione:

intensità_luminosa: Dati interpolati ogni 6 ore. Il resampling (ricampionamento) è un’operazione cruciale quando si ha troppa granularità o si devono unificare dati con frequenze diverse. La scelta dell’interpolazione cubica è strategica: a differenza di quella lineare che unisce i punti con una retta, quella cubica crea una curva morbida che preserva meglio i picchi e le valli di un ciclo naturale, come un pattern circadiano.

Peculiarità: L’esercizio affronta un problema comune: i dati sono campionati troppo frequentemente o devono essere allineati. Il resampling con interpolazione è la soluzione. La peculiarità sta nella scelta del metodo (cubic). Ci insegna che quando si riduce la frequenza dei dati, bisogna farlo in modo intelligente per non perdere l’essenza del segnale originale, specialmente se questo ha una forma naturale e curvilinea come un ciclo biologico.

Forse potrebbe interessarti anche:  Guida Completa a Polars per Python: L'Alternativa Veloce a Pandas (Esempi e Tutorial)

Esercizio 3 (Intermedio-Avanzato): Previsione di abbandono nei club di tesseramento VHS

Contesto: Un club di collezionisti VHS vuole prevedere quali membri non rinnoveranno l’abbonamento.

Codice

import pandas as pd
import numpy as np

# Dati mensili di engagement
date_range = pd.date_range(start="2020-01-01", periods=36, freq="M")
membri = np.random.randint(50, 100, size=len(date_range))
eventi = np.random.poisson(5, size=len(date_range)) * (0.9 ** np.arange(36))

df_vhs = pd.DataFrame({
    "mese": date_range,
    "membri_attivi": membri,
    "eventi_offline": eventi,
    "noleggi": np.random.normal(200, 30, len(date_range)) * (0.85 ** np.arange(36))
}).set_index("mese")

# Calcolo differenze mensili e tasso di declino
df_vhs["diff_membri"] = df_vhs["membri_attivi"].diff()
df_vhs["tasso_declino"] = df_vhs["membri_attivi"].pct_change() * 100

# Identificazione punti di cambio con CUSUM
def cusum_detection(series, threshold=5):
    """Implementa una versione semplificata del CUSUM per la rilevazione di punti di cambio."""
    if series.isnull().all():
        return pd.Series(False, index=series.index)
    
    # Rimuovi NaN per il calcolo CUSUM, poi riallinea
    temp_series = series.dropna()
    if temp_series.empty:
        return pd.Series(False, index=series.index)

    mean_val = temp_series.mean()
    std_val = temp_series.std()
    
    # Evita divisione per zero se std è 0
    if std_val == 0:
        return pd.Series(False, index=series.index)

    normalized_series = (temp_series - mean_val) / std_val
    
    # CUSUM per cambiamenti positivi e negativi
    cp_sum = 0
    cn_sum = 0
    alarms = pd.Series(False, index=series.index)

    for i in range(len(normalized_series)):
        cp_sum = max(0, cp_sum + normalized_series.iloc[i] - 0.5) # h/2 per drift
        cn_sum = min(0, cn_sum + normalized_series.iloc[i] + 0.5)

        if cp_sum > threshold or cn_sum < -threshold:
            alarms.loc[temp_series.index[i]] = True
            # Reset CUSUM dopo allarme per rilevare nuovi cambiamenti
            cp_sum = 0
            cn_sum = 0
            
    return alarms.reindex(series.index, fill_value=False) # Rimettere gli indici originali

df_vhs["allarme_declino"] = cusum_detection(df_vhs["diff_membri"])

# Teoria: Il CUSUM è efficace per rilevare piccoli cambiamenti persistenti nei trend.
# Nel churn analysis, identificare il punto di cambio è più utile della previsione puntuale.

print(df_vhs.tail(3))

Output

            membri_attivi  eventi_offline    noleggi  diff_membri  tasso_declino  allarme_declino
mese                                                                                            
2022-10-31             54        2...  45.1...         -3.0      -5.26...             True
2022-11-30             51        1...  38.3...         -3.0      -5.55...             True
2022-12-31             48        1...  32.6...         -3.0      -5.88...             True

Spiegazione:

allarme_declino: Questa colonna True/False è il risultato dell’algoritmo CUSUM. Non si limita a dire “stiamo perdendo membri”, ma rileva quando un piccolo ma persistente calo diventa una tendenza significativa. Identificare questi “punti di cambio” permette di intervenire prima che la situazione diventi irrecuperabile.

tasso_declino: Mostra la variazione percentuale. Mentre questo valore può oscillare, l’allarme CUSUM scatta solo quando il declino mostra una costanza preoccupante.

Peculiarità: Questo esercizio passa dall’osservazione alla diagnosi. Invece di prevedere il futuro, usiamo il CUSUM per rilevare un punto di cambio nel passato. È interessante perché in molti contesti di business (come l’abbandono dei clienti o il calo di qualità), sapere esattamente quando le cose hanno iniziato ad andare male è più utile di una previsione. È una tecnica da detective, che cerca l’inizio di una tendenza negativa persistente.

Pubblicità

Esercizio 4 (Avanzato): Analisi stagionale delle richieste di riparazione giocattoli antichi

Contesto: Un’assicurazione specializzata in giocattoli d’epoca deve modellare la stagionalità dei danni.

Codice

import pandas as pd
import numpy as np
from statsmodels.tsa.seasonal import STL

# Dati trimestrali 2018-2023 con stagionalità complessa
date_range = pd.date_range(start="2018-01-01", end="2023-12-31", freq="Q")
base = np.linspace(100, 150, len(date_range))
stag = 20 * np.sin(np.arange(len(date_range)) * (2*np.pi/4)) # Ciclo annuale (4 trimestri)
trend = base + stag + np.random.normal(0, 10, len(date_range))

df_giocattoli = pd.DataFrame({
    "trimestre": date_range,
    "sinistri": np.clip(trend, 0, None),
    "fattore_meteo": np.random.choice([0.8, 1, 1.2], size=len(date_range), p=[0.2, 0.6, 0.2])
}).set_index("trimestre")

# Decomposizione STL
result = STL(df_giocattoli["sinistri"], period=4).fit() # Periodo annuale (4 trimestri)

# Aggiunta componenti al DataFrame
df_giocattoli = pd.concat([
    df_giocattoli,
    pd.DataFrame({
        "trend": result.trend,
        "stagionalità": result.seasonal,
        "residuo": result.resid
    }, index=df_giocattoli.index)], axis=1)

# Teoria: STL (Season-Trend decomposition using Loess) gestisce meglio stagionalità non-lineari
# rispetto alla classica decomposizione additiva/multiplicativa.

print(df_giocattoli[["sinistri", "trend", "stagionalità"]].head(3))

 Output

            sinistri      trend  stagionalità
trimestre                                    
2018-03-31   98.2...  105.1...         -6.9...
2018-06-30  115.7...  108.3...          7.4...
2018-09-30   92.5...  111.5...        -19.0...

Spiegazione:

La decomposizione STL scompone la serie temporale originale (sinistri) in tre parti fondamentali:

  • trend: La direzione generale a lungo termine, ripulita dalle oscillazioni stagionali. Mostra se, anno dopo anno, le richieste stanno complessivamente aumentando o diminuendo.
  • stagionalità: Il pattern ciclico che si ripete ogni anno (in questo caso, su 4 trimestri). Aiuta a rispondere a domande come: “Qual è il trimestre con più richieste?”.
  • residuo: Ciò che rimane dopo aver tolto trend e stagionalità. Rappresenta il rumore casuale o eventi imprevedibili. Un residuo molto alto potrebbe indicare anomalie da investigare.

Peculiarità: Il mondo è complesso e una serie temporale spesso è la somma di più forze. La decomposizione STL (Season-Trend-Loess) è lo strumento per separarle. La sua particolarità è la robustezza: a differenza dei metodi classici, gestisce bene stagionalità che cambiano forma nel tempo. Questo esercizio ci insegna a non guardare una serie come un blocco monolitico, ma a isolarne le componenti (crescita a lungo termine, ciclo annuale, eventi casuali) per capirne i veri motori.

Forse potrebbe interessarti anche:  Dalle Matrici alla Strategia: Anatomia di un Mini-Transformer per il Forecasting

Esercizio 5 (Esperto): Modellazione della domanda di riparazioni di macchine da scrivere

Contesto: Un’officina specializzata deve ottimizzare il proprio inventario di pezzi di ricambio.

Codice

import pandas as pd
import numpy as np
from prophet import Prophet
import matplotlib.pyplot as plt # Importa matplotlib per Prophet

# Dati settimanali con multiple stagionalità (settimanale + annuale)
date_range = pd.date_range(start="2019-01-01", end="2022-12-31", freq="W")
base = np.linspace(5, 8, len(date_range))
stag_sett = 1.5 * np.sin(np.arange(len(date_range)) * (2*np.pi/52)) # Stagionalità annuale (52 settimane)
stag_anno = 3 * np.sin(np.arange(len(date_range)) * (2*np.pi/4)) # Stagionalità trimestrale (ogni 4 trimestri, ma su dati settimanali)

df_riparazioni = pd.DataFrame({
    "settimana": date_range,
    "richieste": np.clip(base + stag_sett + stag_anno + np.random.normal(0, 0.5, len(date_range)), 0, None)
}).set_index("settimana")

# Modellazione con Facebook Prophet
df_prophet = df_riparazioni.reset_index()
df_prophet.columns = ["ds", "y"]

model = Prophet(
    yearly_seasonality=True,
    weekly_seasonality=True, # Dati settimanali, ma Prophet può modellare stagionalità infra-settimanale se i dati sono giornalieri/orari. Qui assumeremo che weekly_seasonality si riferisca alla capacità di gestire frequenze di dati. Per dati settimanali, spesso basta yearly_seasonality.
    seasonality_mode="multiplicative",
    changepoint_prior_scale=0.05)

# Aggiunta stagionalità trimestrale esplicita (Prophet non la rileva automaticamente per dati settimanali senza un periodo definito)
# 91.25 giorni è circa 13 settimane (91.25 / 7)
# Se i dati sono settimanali, un periodo trimestrale è 13 settimane.
model.add_seasonality(name="trimestrale", period=13, fourier_order=5) # 13 settimane per trimestre
model.fit(df_prophet)

# Creazione forecast
future = model.make_future_dataframe(periods=26, freq="W") # 6 mesi
forecast = model.predict(future)

# Teoria: Prophet gestisce automaticamente multiple stagionalità e cambi di trend,
# ideale per dati con cicli sovrapposti e pattern complessi.

print(forecast[["ds", "yhat", "yhat_lower", "yhat_upper"]].tail(3))

Output

               ds      yhat  yhat_lower  yhat_upper
232  2023-06-25  7.1...      6.8...      5.2...      8.4...
233  2023-07-02  7.3...      7.0...      5.4...      8.6...
234  2023-07-09  7.5...      7.2...      5.6...      8.8...

Spiegazione:

yhat: È la previsione puntuale del modello Prophet. La sua forza sta nel combinare automaticamente il trend di base con diverse stagionalità (annuale, settimanale e quella trimestrale che abbiamo aggiunto manualmente).

yhat_lower / yhat_upper: Definiscono l’intervallo di incertezza. Per un’officina, questo è oro colato: invece di ordinare pezzi di ricambio per un singolo valore previsto (yhat), può prepararsi a uno scenario ottimista (yhat_upper) e uno pessimista (yhat_lower), ottimizzando l’inventario e riducendo i rischi.

Peculiarità: Questo è l’esercizio di forecasting per eccellenza. Prophet è interessante perché è stato progettato per “funzionare quasi in automatico”, gestendo trend, festività e stagionalità multiple. La vera peculiarità qui è l’aggiunta di una stagionalità custom (quella trimestrale). Dimostra che anche un modello automatizzato può essere arricchito con la conoscenza del dominio specifico, unendo la potenza dell’algoritmo all’intuizione umana. Inoltre, l’uso degli intervalli di confidenza è un concetto chiave per prendere decisioni in condizioni di incertezza.

Esercizio 6 (Master): Analisi delle performance di atleti di scacchi su scala temporale

Contesto: Federazione internazionale di scacchi velocissimi (1 mossa al secondo) studia i pattern di performance.

 Codice

import pandas as pd
import numpy as np
import stumpy # Assicurati di aver installato stumpy: pip install stumpy

# Dati di tornei con timestamp preciso delle mosse
np.random.seed(42)
date_range = pd.date_range(start="2023-05-01", periods=5000, freq="S")
mosse = np.random.choice(["e4", "d4", "Cf3", "c4"], size=5000, p=[0.4, 0.3, 0.2, 0.1])
pressione = np.random.normal(120, 10, 5000).cumsum() / 100 # Simula una variazione di pressione nel tempo
performance = np.sin(np.linspace(0, 50*np.pi, 5000)) * 10 + 80 + np.random.normal(0, 5, 5000)

df_scacchi = pd.DataFrame({
    "timestamp": date_range,
    "mossa": mosse,
    "pressione": pressione,
    "performance": performance
}).set_index("timestamp")

# Feature engineering per time-series classification
df_scacchi["rolling_perf"] = df_scacchi["performance"].rolling("5min").mean()
df_scacchi["pressione_diff"] = df_scacchi["pressione"].diff()
df_scacchi["ora_torneo"] = df_scacchi.index.hour + df_scacchi.index.minute/60

# Rilevazione pattern ricorrenti con Matrix Profile
# Assicurati che la serie sia numerica e non contenga NaN per stumpy
performance_clean = df_scacchi["performance"].dropna().values

if len(performance_clean) > 0:
    window_size = 30 # 30 secondi, deve essere <= len(performance_clean) if len(performance_clean) >= window_size:
        matrix_prof, idx = stumpy.stump(performance_clean, window_size, return_index=True)

        # Aggiunta dei motivi ricorrenti al DataFrame (solo se Matrix Profile è calcolato)
        # Il Matrix Profile restituisce la distanza e l'indice del nearest neighbor.
        # Per identificare i motivi, possiamo filtrare i punti con piccole distanze
        # e raggrupparli, ma qui ci concentriamo sull'indice del nearest neighbor.
        
        # Inizializza la colonna 'motivo' come NaN
        df_scacchi["motivo"] = np.nan

        # Mappa i risultati del Matrix Profile agli indici originali del DataFrame.
        # Poiché matrix_prof è basato su performance_clean, dobbiamo gestire gli indici.
        # Semplificazione: assegniamo un "motivo" quando una sottosequenza trova un vicino stretto (distanza bassa).
        # Un modo semplice per visualizzare: etichettare le sezioni che sono "motivi" trovati.
        # Qui useremo l'indice del nearest neighbor per indicare che c'è un motivo ripetuto.
        
        # La colonna `idx` del Matrix Profile indica l'indice del nearest neighbor.
        # Un modo per visualizzare i "motivi" è marcare le regioni che sono un "motivo" di un'altra.
        # Per semplicità, consideriamo un "motivo" se la distanza nel Matrix Profile è bassa.
        # Stumpy ha funzioni più avanzate per i motivi, ma per questo esercizio:
        # Useremo l'indice `idx` come un identificatore semplificato per la posizione del motivo.
        
        # Crea una serie di motivi allineata a performance_clean
        motivo_series_stumpy = pd.Series(np.nan, index=df_scacchi["performance"].dropna().index)
        
        # Una semplificazione per l'output didattico: se un punto ha un motivo (idx != -1), lo etichettiamo.
        # Questo non è il modo più preciso di Stumpy per i motivi ma è didatticamente chiaro.
        for i in range(len(matrix_prof)):
            if idx[i] != -1: # Se c'è un indice di nearest neighbor valido
                # Assegna un'etichetta al motivo. Questa è una semplificazione.
                # In un'analisi reale, si userebbero le funzioni di stumpy.motifs
                # per estrarre le sottosequenze effettive.
                motivo_series_stumpy.iloc[i] = f"motivo_{i}"
        
        # Riapplica al DataFrame originale, riempiendo i NaN dove non c'è corrispondenza.
        df_scacchi["motivo"] = motivo_series_stumpy.reindex(df_scacchi.index)
    else:
        print(f"La dimensione della finestra ({window_size}) è troppo grande per la serie temporale ({len(performance_clean)} punti). Saltando il calcolo Matrix Profile.")
else:
    print("La serie 'performance' è vuota dopo la rimozione dei NaN. Impossibile calcolare Matrix Profile.")

# Teoria: Il Matrix Profile identifica sottosequenze ricorrenti (motifs) e anomalie
# in serie temporali multivariate, fondamentale per analisi comportamentale ad alta frequenza.

print(df_scacchi[["mossa", "rolling_perf", "motivo"]].sample(3))

Output

                     mossa  rolling_perf      motivo
timestamp                                           
2023-05-01 00:05:00     e4     82.3...           NaN
2023-05-01 01:20:00     d4     78.9...    motivo_142
2023-05-01 02:45:00    Cf3     75.2...           NaN

Spiegazione:

rolling_perf: La performance media su una finestra di 5 minuti. È una feature ingegnerizzata che trasforma dati grezzi in un indicatore più stabile dello stato di forma dell’atleta.

Forse potrebbe interessarti anche:  Segmentazione Clienti Avanzata: RFM, PCA, Predittivi per il Modello di Abell

motivo: Questa colonna (quando non NaN) è il risultato del Matrix Profile. Indica che una specifica sequenza di performance (lunga 30 secondi) è un “motivo”, cioè un pattern che si ripete altrove nella serie temporale. Per un coach, questo potrebbe significare identificare una routine comportamentale ricorrente, sia positiva (uno stato di “flow”) che negativa (un pattern di esitazione), invisibile a un’analisi superficiale.

Peculiarità: Questo è l’esercizio più avanzato e si spinge oltre trend e stagionalità. Il Matrix Profile (con Stumpy) non cerca cicli regolari, ma sottosequenze identiche che si ripetono in modo irregolare (motifs). È una tecnica potente per l’analisi comportamentale su dati ad alta frequenza. L’applicazione agli scacchi è geniale: potrebbe rivelare la “firma” di un giocatore, un suo tic tattico o un pattern di pensiero che si ripete sotto pressione. È un passaggio dall’analisi di “cosa” succede all’analisi del “come” succede.

Librerie Utilizzate

  1. Pandas: Libreria fondamentale per la manipolazione di dati tabellari e serie temporali.

  2. NumPy: Fornisce funzioni matematiche avanzate e strutture dati multidimensionali.

  3. Statsmodels (STL): Utilizzato per la decomposizione stagionale-trend dei dati.

  4. Prophet: Libreria di Facebook per forecasting con gestione automatica della stagionalità.

  5. Stumpy: Specializzata nell’analisi di pattern ricorrenti in serie temporali (Matrix Profile).

 

 

Pubblicità