Python, dalle fondamenta Lezione 48 / 60

Progetto numerico: un'analisi vera

Prendi un dataset pubblico ed esegui un'analisi numerica completa: statistiche descrittive, fit, test di ipotesi, plot.

Il modulo 8 è stato un giro panoramico: NumPy nella lezione 41, pandas nella 42, matplotlib e seaborn nella 43, statistica con SciPy nella 44, calcolo scientifico con SciPy nella 45, poi una deviazione attraverso le feature-che-ho-imparato-troppo-tardi e i notebook. Abbiamo i pezzi. Questa lezione li incolla insieme in una vera analisi numerica end-to-end.

L’obiettivo non è insegnare una nuova libreria. È mostrare com’è fatta un’analisi completa quando ti siedi davanti a un notebook con un CSV, una domanda e un pomeriggio.

Il dataset

Scegli un dataset pubblico. Userò il dataset Olympic athletes di Kaggle (120 anni di Olimpiadi estive e invernali) perché ha un mix pulito di variabili categoriche, ordinali e continue. Se vuoi dati diversi, i CSV PIL/popolazione della Banca Mondiale funzionano, oppure uno qualsiasi dei dataset di salute pubblica su data.gov.

Scarica athlete_events.csv. Più o meno 270k righe, colonne fra cui Sex, Age, Height, Weight, Sport, Year, Medal.

La domanda che inseguirò: i medagliati maschi nella maratona sono cambiati nella composizione corporea durante i 120 anni di storia delle Olimpiadi moderne?

È una domanda vera. Ha una risposta misurabile. È il tipo di cosa che potresti fare come riscaldamento prima di un progetto più serio.

Step 1: caricare e ispezionare

from pathlib import Path
import pandas as pd
import numpy as np

DATA = Path("athlete_events.csv")
df = pd.read_csv(DATA)

print(f"{df.shape=}")
print(df.dtypes)
df.head()

Ispeziona sempre per primo. Nota i missing, i tipi, l’ovvia immondizia. Age, Height, Weight avranno NaN (le prime Olimpiadi non li registravano sempre). Medal è NaN per i non medagliati.

Step 2: pulire e selezionare

marathons = (
    df[(df["Sport"] == "Athletics")
       & (df["Event"].str.contains("Marathon", na=False))
       & (df["Sex"] == "M")
       & df["Medal"].notna()]
    .dropna(subset=["Age", "Height", "Weight"])
    .copy()
)

marathons["BMI"] = marathons["Weight"] / (marathons["Height"] / 100) ** 2
print(f"{len(marathons)} medalist-events after cleaning")
marathons[["Year", "Age", "Height", "Weight", "BMI"]].describe()

Tre cose contano qui. Primo, ho fatto una .copy() per evitare il SettingWithCopyWarning quando aggiungo la colonna BMI. Secondo, ho droppato solo le righe a cui mancavano le variabili che userò davvero. Terzo, sto calcolando il BMI come feature derivata perché è un numero più significativo di “composizione corporea” rispetto a peso o altezza da soli.

Stampa sempre la dimensione del campione dopo un filtro. Se hai 18 righe quando te ne aspettavi 200, il tuo filtro è sbagliato.

Step 3: statistiche descrittive

Questo è lo step su cui la maggior parte dei tutorial passa sopra in fretta. Non farlo.

from scipy import stats

def describe(series: pd.Series) -> dict:
    return {
        "n": len(series),
        "mean": series.mean(),
        "median": series.median(),
        "std": series.std(),
        "p25": series.quantile(0.25),
        "p75": series.quantile(0.75),
        "iqr": series.quantile(0.75) - series.quantile(0.25),
        "skew": stats.skew(series),
        "kurtosis": stats.kurtosis(series),
    }

for col in ["Age", "Height", "Weight", "BMI"]:
    s = describe(marathons[col])
    print(f"{col}: " + ", ".join(f"{k}={v:.2f}" for k, v in s.items()))

L’asimmetria misura l’asimmetria; la curtosi misura il peso delle code rispetto a una normale. Per il BMI di maratoneti d’élite, ti aspetteresti una distribuzione stretta e simmetrica. Se l’asimmetria è lontana da zero o la curtosi è alta, hai outlier che vale la pena investigare prima di qualsiasi test.

Step 4: visualizzare

Una statistica riassuntiva è una bugia finché non vedi la distribuzione.

import seaborn as sns
import matplotlib.pyplot as plt

sns.set_theme(style="whitegrid")
fig, axes = plt.subplots(2, 2, figsize=(11, 8))
for ax, col in zip(axes.flat, ["Age", "Height", "Weight", "BMI"]):
    sns.histplot(marathons[col], kde=True, ax=ax, bins=30)
    ax.set_title(col)
fig.tight_layout()
plt.savefig("marathon_distributions.png", dpi=150)

In genere vedrai Age leggermente asimmetrico a destra (qualche medagliato più anziano), Height grossomodo normale, Weight asimmetrico a destra, BMI stretto attorno a 20-21.

Adesso il plot del cambiamento nel tempo:

marathons["Era"] = pd.cut(
    marathons["Year"],
    bins=[1895, 1948, 1980, 2025],
    labels=["1896-1948", "1952-1980", "1984-2024"],
)

fig, ax = plt.subplots(figsize=(9, 5))
sns.boxplot(data=marathons, x="Era", y="BMI", ax=ax)
ax.set_title("BMI of male marathon medalists by era")
fig.tight_layout()
plt.savefig("bmi_by_era.png", dpi=150)

Se i box sembrano identici, la tua ipotesi è morta prima di testarla. Se la mediana si muove visibilmente, hai qualcosa che vale un test formale.

Step 5: test di ipotesi

Supponi che il boxplot mostri che l’era moderna ha un BMI più basso. Domanda formale: la differenza è statisticamente significativa?

Prima controlla la normalità. Se entrambi i gruppi sono grossomodo normali, usa un t-test. Altrimenti, Mann-Whitney U.

old = marathons.loc[marathons["Era"] == "1896-1948", "BMI"].to_numpy()
new = marathons.loc[marathons["Era"] == "1984-2024", "BMI"].to_numpy()

print(f"old: n={len(old)}, mean={old.mean():.2f}")
print(f"new: n={len(new)}, mean={new.mean():.2f}")

# Normalità di Shapiro-Wilk
print("Shapiro old:", stats.shapiro(old))
print("Shapiro new:", stats.shapiro(new))

# T-test di Welch (varianza diseguale)
t = stats.ttest_ind(old, new, equal_var=False)
print(f"Welch t: t={t.statistic:.3f}, p={t.pvalue:.4f}")

# Mann-Whitney U come backup non parametrico
u = stats.mannwhitneyu(old, new, alternative="two-sided")
print(f"Mann-Whitney: U={u.statistic:.1f}, p={u.pvalue:.4f}")

Questo è esattamente il punto in cui gli assistenti AI sono sorprendentemente utili. Se descrivi i tuoi dati: “due gruppi indipendenti, outcome continuo, possibilmente non normale, n=30 vs n=80”, Claude o GPT raccomanderanno il test giusto e ne spiegheranno le assunzioni. Sono molto più bravi di quanto ci si aspetti nella selezione di test statistici, perché l’albero decisionale è materiale di training ben documentato. Verifica la raccomandazione contro la documentazione di SciPy, ma trattala come un punto di partenza solido.

Step 6: curve fitting

Per varietà, fittiamo una curva. Scegli una domanda diversa: come è cambiato il record mondiale di maratona negli anni? Servirebbe un dataset separato, ma la tecnica è la stessa. Qui fitterò una curva logistica al conteggio cumulativo dei medagliati unici della maratona per anno come surrogato della “crescita dell’evento”:

from scipy.optimize import curve_fit

yearly = (
    marathons.groupby("Year").size()
    .cumsum()
    .reset_index(name="cumulative")
)

def logistic(x, L, k, x0):
    return L / (1 + np.exp(-k * (x - x0)))

x = yearly["Year"].to_numpy(dtype=float)
y = yearly["cumulative"].to_numpy(dtype=float)

p0 = [y.max(), 0.05, x.mean()]
popt, pcov = curve_fit(logistic, x, y, p0=p0, maxfev=5000)
L_fit, k_fit, x0_fit = popt

x_smooth = np.linspace(x.min(), x.max(), 200)
y_smooth = logistic(x_smooth, *popt)

fig, ax = plt.subplots(figsize=(9, 5))
ax.scatter(x, y, label="observed", alpha=0.6)
ax.plot(x_smooth, y_smooth, color="red", label=f"logistic (L={L_fit:.0f})")
ax.set_xlabel("Year")
ax.set_ylabel("Cumulative medalists")
ax.legend()
fig.tight_layout()
plt.savefig("marathon_growth.png", dpi=150)

curve_fit restituisce i parametri fittati e la loro matrice di covarianza. La radice quadrata della diagonale di pcov ti dà gli errori standard:

perr = np.sqrt(np.diag(pcov))
print(f"L = {L_fit:.1f} +/- {perr[0]:.1f}")
print(f"k = {k_fit:.4f} +/- {perr[1]:.4f}")
print(f"x0 = {x0_fit:.1f} +/- {perr[2]:.1f}")

Riporta sempre le incertezze. Un fit senza barre d’errore è mezzo risultato.

Step 7: scrivere

Lo step che salta tutti. Formatta il risultato finale in modo che un essere umano possa leggerlo senza guardare il codice:

def summary(old: np.ndarray, new: np.ndarray, t_result, u_result) -> str:
    return (
        f"Marathon medalist BMI: era 1896-1948 (n={len(old)}) "
        f"mean {old.mean():.2f} (SD {old.std():.2f}); "
        f"era 1984-2024 (n={len(new)}) "
        f"mean {new.mean():.2f} (SD {new.std():.2f}). "
        f"Welch t = {t_result.statistic:.2f}, p = {t_result.pvalue:.4f}. "
        f"Mann-Whitney U = {u_result.statistic:.0f}, p = {u_result.pvalue:.4f}."
    )

print(summary(old, new, t, u))

Il passaggio dell’interpretazione è quello che la maggior parte dei tutorial salta ed è quello che conta di più. Un p-value sotto 0.05 non significa “i maratoneti moderni sono più magri”. Significa “se non ci fosse alcuna differenza reale, la probabilità di vedere dati almeno così diversi sarebbe sotto il 5%”. Poi chiediti: la dimensione dell’effetto è praticamente significativa? Una differenza di BMI di 0,3 punti a p=0.001 è statisticamente significativa e fisicamente irrilevante. Riporta sempre l’effetto accanto al p-value.

Lo script eseguibile

Tutta l’analisi qui sopra, fattorizzata in un singolo file .py, è circa 150 righe. Ecco lo scheletro:

from pathlib import Path
import numpy as np
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
from scipy import stats
from scipy.optimize import curve_fit

def load(path: Path) -> pd.DataFrame:
    df = pd.read_csv(path)
    return df

def select_marathons(df: pd.DataFrame) -> pd.DataFrame:
    mask = (
        (df["Sport"] == "Athletics")
        & df["Event"].str.contains("Marathon", na=False)
        & (df["Sex"] == "M")
        & df["Medal"].notna()
    )
    out = df[mask].dropna(subset=["Age", "Height", "Weight"]).copy()
    out["BMI"] = out["Weight"] / (out["Height"] / 100) ** 2
    return out

def by_era(df: pd.DataFrame) -> pd.DataFrame:
    df = df.copy()
    df["Era"] = pd.cut(
        df["Year"],
        bins=[1895, 1948, 1980, 2025],
        labels=["1896-1948", "1952-1980", "1984-2024"],
    )
    return df

def test_bmi(df: pd.DataFrame) -> tuple:
    old = df.loc[df["Era"] == "1896-1948", "BMI"].to_numpy()
    new = df.loc[df["Era"] == "1984-2024", "BMI"].to_numpy()
    t = stats.ttest_ind(old, new, equal_var=False)
    u = stats.mannwhitneyu(old, new, alternative="two-sided")
    return old, new, t, u

if __name__ == "__main__":
    df = load(Path("athlete_events.csv"))
    marathons = by_era(select_marathons(df))
    old, new, t, u = test_bmi(marathons)
    print(f"old n={len(old)} mean={old.mean():.2f}")
    print(f"new n={len(new)} mean={new.mean():.2f}")
    print(f"t={t.statistic:.2f}, p={t.pvalue:.4f}")
    print(f"U={u.statistic:.0f}, p={u.pvalue:.4f}")

Nota la struttura: ogni step è una funzione con input e output chiari. Il blocco if __name__ == "__main__": è la ricetta. Adesso l’analisi è testabile, schedulabile e importabile, esattamente le qualità che la lezione 47 diceva mancare ai notebook.

Qualche trappola che vale la pena nominare

Confronti multipli. Nel momento in cui inizi a far girare test su più di un outcome (BMI, età, altezza, peso, per era, per regione) i tuoi p-value smettono di significare quello che pensi che significhino. Con venti test indipendenti a p<0.05, ti aspetti in media un falso positivo. Applica la correzione di Bonferroni (dividi il tuo alpha per il numero di test) o, in modo meno brutale, la procedura di Benjamini-Hochberg. SciPy ha stats.false_discovery_control per quest’ultima.

Confondimento. Se i maratoneti moderni hanno un BMI più basso e sono anche per lo più est-africani (cosa che sono), e gli est-africani in media hanno una composizione corporea diversa da quella dei medagliati europei dell’inizio del XX secolo, stai in parte misurando “da dove vengono i medagliati” e in parte “in che era sono”. Un’analisi più pulita stratificherebbe per regione o userebbe una regressione con sia era sia regione come predittori.

Mancanza non casuale. Le prime Olimpiadi non registravano sempre altezza e peso. Gli atleti per cui erano registrati potrebbero non essere un campione rappresentativo. Una conclusione che dipende dai dati delle prime Olimpiadi richiede un’analisi di sensibilità: rieseguila escludendo i dati pre-1948 e guarda se la conclusione regge.

Queste non sono ragioni per abbandonare l’analisi. Sono ragioni per accompagnare il risultato con caveat e riflettere bene su cosa servirebbe per affrontarli. Una buona scrittura nomina esplicitamente i propri limiti.

Una checklist veloce per la tua analisi

Prima di considerare un’analisi conclusa:

  • Conosco la dimensione del campione a ogni step di filtraggio?
  • Ho plottato ogni variabile che ho testato prima di testarla?
  • Ho riportato le dimensioni dell’effetto, non solo i p-value?
  • Ho nominato le assunzioni del test che ho usato?
  • Potrei rieseguirla dal CSV grezzo con un solo comando?
  • Un collega potrebbe riprodurre i miei numeri solo dallo script?

Se una qualsiasi risposta è “no”, l’analisi non è finita. Potrebbe comunque essere utile, un’esplorazione veloce va bene, ma non rilasciare una conclusione finché la checklist non è verde.

Cosa hai costruito

Hai caricato un dataset reale, lo hai pulito, hai calcolato statistiche descrittive, hai visualizzato distribuzioni, hai eseguito un test di ipotesi, hai fittato una curva, e hai scritto uno script che fa tutto in modo riproducibile. Questo è un workflow numerico completo. Ogni analisi che farai per il resto della tua carriera avrà grossomodo questa forma: caricare, pulire, riassumere, visualizzare, testare, modellare, scrivere.

Le librerie esatte cambiano. La forma no. Che tu stia lavorando con record olimpici, dati climatici, rendimenti finanziari o esiti di A/B test, gli step sono gli stessi e la disciplina è la stessa. La parte difficile non è memorizzare le firme delle funzioni: quelle sono a un autocomplete di distanza. La parte difficile è porre la domanda giusta, scegliere il test giusto ed essere onesti su cosa i tuoi numeri dicono e non dicono.

Questo conclude il modulo 8: Python numerico. Adesso hai NumPy, pandas, matplotlib, seaborn e SciPy nel tuo toolkit, più la disciplina per usarli in modo strutturato. Il modulo 9 riprende con scikit-learn e il passaggio dall’analisi descrittiva al machine learning predittivo. Stessi dati, domanda diversa: invece di “che aspetto ha?” chiediamo “cosa possiamo prevedere?”

Cerca