Modulul 8 a fost un tur: NumPy în lecția 41, pandas în 42, matplotlib și seaborn în 43, statistici cu SciPy în 44, calcul științific cu SciPy în 45, apoi un ocol prin caracteristici-pe-care-le-am-învățat-prea-târziu și notebook-uri. Avem piesele. Lecția asta le lipește într-o analiză numerică reală de la cap la coadă.
Scopul nu e să predăm o bibliotecă nouă. E să arătăm cum arată o analiză completă atunci când te așezi la un notebook cu un CSV, o întrebare și o după-amiază.
Datasetul
Alege un dataset public. Voi folosi datasetul Olympic athletes de pe Kaggle (120 de ani de Olimpiade de vară și de iarnă) fiindcă are un amestec curat de variabile categoriale, ordinale și continue. Dacă vrei alte date, CSV-urile cu PIB/populație de la World Bank funcționează, sau oricare dintre datasetele publice de sănătate de pe data.gov.
Descarcă athlete_events.csv. Aproximativ 270k de rânduri, coloane care includ Sex, Age, Height, Weight, Sport, Year, Medal.
Întrebarea pe care o voi urmări: s-au schimbat medaliații bărbați la maraton în compoziția corpului de-a lungul celor 120 de ani de istorie a Olimpiadelor moderne?
Asta e o întrebare reală. Are un răspuns măsurabil. E genul de lucru pe care l-ai face ca încălzire înainte de un proiect mai serios.
Pasul 1: încarcă și inspectează
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()
Inspectează mereu prima dată. Observă valorile lipsă, tipurile, gunoaiele evidente. Age, Height, Weight vor avea NaN-uri (Olimpiadele timpurii nu le înregistrau mereu). Medal este NaN pentru ne-medaliați.
Pasul 2: curăță și subseteaza
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()
Trei lucruri contează aici. Întâi, am făcut un .copy() ca să evit SettingWithCopyWarning când adaug coloana BMI. În al doilea rând, am eliminat doar rândurile cărora le lipseau variabilele pe care le voi folosi efectiv. În al treilea rând, calculez BMI ca o caracteristică derivată, fiindcă e un număr de „compoziție corporală” mai semnificativ decât greutatea sau înălțimea singure.
Afișează mereu dimensiunile eșantionului după un filtru. Dacă ai 18 rânduri când te așteptai la 200, filtrul tău e greșit.
Pasul 3: statistici descriptive
Acesta e pasul peste care majoritatea tutorialelor trec superficial. Nu o face.
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()))
Asimetria măsoară necentrarea; kurtoza măsoară greutatea cozilor în raport cu o normală. Pentru BMI-ul maratoniștilor de elită, te-ai aștepta la o distribuție strânsă, simetrică. Dacă asimetria este departe de zero sau kurtoza e mare, ai outlieri care merită investigați înainte de orice test.
Pasul 4: vizualizează
O statistică sumară este o minciună până când vezi distribuția.
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)
De obicei vei vedea Age ușor asimetric la dreapta (câțiva medaliați mai în vârstă), Height aproximativ normal, Weight asimetric la dreapta, BMI strâns în jur de 20-21.
Acum graficul de schimbare în timp:
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)
Dacă cutiile arată identice, ipoteza ta e moartă înainte să o testezi. Dacă mediana se mișcă vizibil, ai ceva care merită un test formal.
Pasul 5: test de ipoteză
Să presupunem că boxplot-ul arată că era modernă are BMI mai mic. Întrebarea formală: e diferența semnificativă statistic?
Verifică mai întâi normalitatea. Dacă ambele grupuri sunt aproximativ normale, folosește un test t. Dacă nu, folosește 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}")
# Normalitate Shapiro-Wilk
print("Shapiro old:", stats.shapiro(old))
print("Shapiro new:", stats.shapiro(new))
# Test t Welch (varianță inegală)
t = stats.ttest_ind(old, new, equal_var=False)
print(f"Welch t: t={t.statistic:.3f}, p={t.pvalue:.4f}")
# Mann-Whitney U ca rezervă neparametrică
u = stats.mannwhitneyu(old, new, alternative="two-sided")
print(f"Mann-Whitney: U={u.statistic:.1f}, p={u.pvalue:.4f}")
Acesta e exact locul în care asistenții AI sunt surprinzător de utili. Dacă îți descrii datele („două grupuri independente, rezultat continuu, posibil non-normal, n=30 vs n=80”), Claude sau GPT vor recomanda testul corect și vor explica presupunerile lui. Sunt mult mai buni la selectarea testelor statistice decât te-ai aștepta, fiindcă arborele de decizie este material de antrenament bine documentat. Verifică recomandarea în documentația SciPy, dar tratează-o ca pe un punct de pornire solid.
Pasul 6: ajustare de curbă
Pentru varietate, hai să ajustăm o curbă. Alege o întrebare diferită: cum s-a schimbat recordul mondial la maraton de-a lungul anilor? Ai avea nevoie de un dataset separat pentru asta, dar tehnica e aceeași. Aici voi ajusta o curbă logistică pe contorul cumulativ de medaliați unici la maraton pe an, ca substitut pentru „creșterea evenimentului”:
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 returnează parametrii ajustați și matricea lor de covarianță. Rădăcina pătrată a diagonalei lui pcov îți dă erorile 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}")
Raportează mereu incertitudinile. O ajustare fără bare de eroare e jumătate de rezultat.
Pasul 7: scrierea finală
Pasul peste care toată lumea trece. Formatează rezultatul final ca să-l poată citi un om fără să se uite la cod:
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))
Pasul de interpretare este cel pe care majoritatea tutorialelor îl scapă și cel care contează cel mai mult. O p-valoare sub 0,05 nu înseamnă „maratoniștii moderni sunt mai slabi”. Înseamnă „dacă nu ar fi nicio diferență reală, șansa de a vedea date cel puțin la fel de diferite ar fi sub 5%”. Apoi întreabă: este mărimea efectului practic semnificativă? O diferență de BMI de 0,3 puncte la p=0,001 este semnificativă statistic și fizic irelevantă. Raportează mereu efectul alături de p-valoare.
Scriptul rulabil
Întreaga analiză de mai sus, factorizată într-un singur fișier .py, are în jur de 150 de linii. Iată scheletul:
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}")
Observă structura: fiecare pas e o funcție cu input și output clare. Blocul if __name__ == "__main__": este rețeta. Acum analiza este testabilă, programabilă și importabilă: exact calitățile pe care lecția 47 spunea că le lipsesc notebook-urilor.
Câteva capcane care merită numite
Comparații multiple. În clipa în care începi să rulezi teste pe mai mult de un rezultat (BMI, vârstă, înălțime, greutate, pe eră, pe regiune), p-valorile tale încetează să însemne ce crezi. Cu douăzeci de teste independente la p<0,05, te aștepți la un fals pozitiv în medie. Aplică corecția Bonferroni (împarte alpha la numărul de teste) sau, mai puțin brutal, procedura Benjamini-Hochberg. SciPy are stats.false_discovery_control pentru aceasta din urmă.
Confounding. Dacă maratoniștii moderni au BMI mai mic și sunt majoritar est-africani (ceea ce sunt) iar est-africanii au în medie o compoziție corporală diferită de medaliații europeni de la începutul secolului XX, măsori parțial „de unde vin medaliații” și parțial „din ce eră sunt”. O analiză mai curată ar stratifica pe regiune sau ar folosi o regresie cu era și regiunea ca predictori.
Lipsă nu la întâmplare. Olimpiadele timpurii nu înregistrau mereu înălțimea și greutatea. Sportivii pentru care erau înregistrate s-ar putea să nu fie un eșantion reprezentativ. O constatare care depinde de datele din Olimpiadele timpurii are nevoie de o analiză de sensibilitate: rerulează excluzând pre-1948 și vezi dacă concluzia se menține.
Astea nu sunt motive să abandonezi analiza. Sunt motive să pui rezerve la rezultat și să te gândești bine la ce ți-ar trebui ca să le adresezi. O scriere bună își numește limitele explicit.
O listă rapidă de verificare pentru analiza ta
Înainte să consideri o analiză gata:
- Știu dimensiunea eșantionului la fiecare pas de filtrare?
- Am plotat fiecare variabilă pe care am testat-o înainte să o testez?
- Am raportat mărimile efectelor, nu doar p-valorile?
- Am numit presupunerile testului folosit?
- Aș putea rerula asta din CSV-ul brut cu o singură comandă?
- Ar putea un coleg reproduce numerele mele doar din script?
Dacă vreun răspuns este „nu”, analiza nu e terminată. Poate fi totuși utilă (o explorare rapidă e în regulă), dar nu livra o constatare până când lista nu e verde.
Ce ai construit
Ai încărcat un dataset real, l-ai curățat, ai calculat statistici descriptive, ai vizualizat distribuții, ai rulat un test de ipoteză, ai ajustat o curbă și ai scris un script care face totul reproductibil. Acela e un flux numeric complet. Fiecare analiză pe care o vei face pentru restul carierei tale are aproximativ această formă: încarcă, curăță, sumarizează, vizualizează, testează, modelează, scrie.
Bibliotecile exacte se schimbă. Forma nu. Fie că lucrezi cu recorduri olimpice, date climatice, randamente financiare sau rezultate de teste A/B, pașii sunt aceiași și disciplina e aceeași. Partea grea nu este memorarea semnăturilor de funcții (acelea sunt la un autocomplete distanță). Partea grea este să pui întrebarea corectă, să alegi testul corect și să fii cinstit despre ce spun și ce nu spun numerele tale.
Asta încheie Modulul 8 (Python numeric). Acum ai NumPy, pandas, matplotlib, seaborn și SciPy în trusă, plus disciplina de a le folosi într-un mod structurat. Modulul 9 reia cu scikit-learn și cu trecerea de la analiza descriptivă la învățarea automată predictivă. Aceleași date, întrebare diferită: în loc de „cum arată?” întrebăm „ce putem prezice?”