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?”