Python, dalle fondamenta Lezione 45 / 60

SciPy: la cassetta degli attrezzi che quasi tutti dimenticano

Statistica, ottimizzazione, signal processing, matrici sparse: la libreria standard del Python scientifico.

Se NumPy è la fondazione del Python scientifico e matplotlib disegna le figure, SciPy è la cassetta degli attrezzi che sta in mezzo. C’è dal 2001, è enorme, e la maggior parte degli sviluppatori Python che lavorano davvero ne tocca solo uno o due submodule prima di dimenticare che esista. È un peccato, perché nel momento in cui ti serve un t-test, un curve fit, una matrice sparsa o una FFT, la risposta sta quasi sempre già in scipy.qualcosa e la documentazione è eccellente.

Questa lezione è un giro panoramico. Non un manuale di riferimento, sarebbe un libro, ma una mappa abbastanza dettagliata da farti capire quale submodule aprire quando incontri un problema che NumPy da solo non copre. Andremo in profondità sui tre submodule che capitano più spesso (stats, optimize, sparse) e nomineremo velocemente il resto.

Come è organizzato SciPy

SciPy è una collezione di submodule, ognuno focalizzato su un dominio. Quasi mai fai import scipy e lo usi direttamente; importi il submodule che ti serve:

from scipy import stats
from scipy import optimize
from scipy import sparse
from scipy import signal
from scipy import interpolate
from scipy import spatial

La libreria è alla versione 1.x nel 2026: è sulla linea 1.x dal 2017 e l’API è solidissima. Le cose si rompono raramente fra una versione e l’altra. Installa con uv add scipy; sulla maggior parte dei sistemi tira dentro NumPy automaticamente.

scipy.stats: probabilità e test statistici

Questo è il submodule che apro più spesso. Ha due pezzi principali: oggetti delle distribuzioni di probabilità e test statistici.

Le distribuzioni sono oggetti che impacchettano PDF, CDF, sampling e stima dei parametri per una distribuzione nota:

from scipy import stats
import numpy as np

# Normale standard
stats.norm.pdf(0)              # 0.3989... (picco della curva a campana)
stats.norm.cdf(1.96)           # 0.975, il famoso confine del 95%
stats.norm.ppf(0.975)          # 1.959..., CDF inversa, ti dà il quantile
stats.norm.rvs(size=1000)      # 1000 campioni casuali

# Non standard? Passa loc e scale
stats.norm(loc=100, scale=15).rvs(size=1000)   # distribuzione tipo QI

# Le altre distribuzioni seguono la stessa interfaccia
stats.binom(n=10, p=0.3).pmf(3)        # P(X=3) per Binomiale(10, 0.3)
stats.poisson(mu=4.5).cdf(6)           # P(X<=6) per Poisson(4.5)
stats.t(df=20).ppf(0.975)              # valore critico per una t con 20 df

Il fatto che ogni distribuzione abbia la stessa interfaccia .pdf / .cdf / .ppf / .rvs / .fit è la cosa bella poco celebrata di questo modulo. Una volta che conosci il pattern puoi usare una qualsiasi delle decine di distribuzioni senza imparare una nuova API.

I test statistici sono dove arrivano i guadagni pratici. Quelli che si guadagnano lo stipendio nel lavoro reale sui dati:

# T-test a due campioni: analisi di A/B test, classico
control = np.array([2.1, 2.3, 1.9, 2.5, 2.0, 2.2])
treatment = np.array([2.4, 2.6, 2.5, 2.7, 2.3, 2.5])
result = stats.ttest_ind(control, treatment)
print(result.statistic, result.pvalue)

# Mann-Whitney U: alternativa non parametrica quando i dati non sono normali
stats.mannwhitneyu(control, treatment)

# Chi-quadro di indipendenza: per tabelle di contingenza
table = np.array([[50, 30], [20, 40]])
chi2, p, dof, expected = stats.chi2_contingency(table)

# Correlazione di Pearson e Spearman
stats.pearsonr(control, treatment)
stats.spearmanr(control, treatment)

Quando questo conta nel lavoro quotidiano sui dati: analisi di A/B test (t-test o Mann-Whitney a seconda della forma dei dati), distribution fitting per capire con che tipo di variabile hai a che fare, individuazione di outlier basata su quantili (stats.norm.ppf(0.99) per trovare l’1% della coda superiore di una distribuzione fittata) e l’occasionale chi-quadro quando confronti gruppi categorici.

C’è anche stats.describe(arr) che ti dà media, varianza, asimmetria, curtosi e min/max in una sola chiamata: comodo per dare una rapida occhiata alla forma di una colonna.

scipy.optimize: minimizzazione, ricerca di radici, curve fitting

Tre lavori: trovare il minimo di una funzione, trovare dove una funzione vale zero, e fittare i parametri di un modello ai dati.

La minimizzazione è la più generale:

from scipy import optimize

# Minimizza una semplice parabola
def f(x):
    return (x - 3) ** 2 + 1

result = optimize.minimize(f, x0=0.0)
print(result.x)            # [3.], il minimo
print(result.fun)          # 1.0, il valore al minimo
print(result.success)      # True

x0 è la stima iniziale. Per le funzioni convesse il punto di partenza conta poco; per quelle non convesse conta moltissimo. optimize.minimize accetta una funzione N-dimensionale: x0 può essere un array di qualsiasi dimensione, e sceglie un solver di default sensato (BFGS per problemi non vincolati, L-BFGS-B se passi dei bound, SLSQP per vincoli).

Il curve fitting è quello che usano davvero quasi tutti quelli che lavorano con i dati, anche se hanno dimenticato che sta qui:

# Fitta una curva logistica su dati di crescita
def logistic(t, L, k, t0):
    return L / (1 + np.exp(-k * (t - t0)))

t = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
y = np.array([0.1, 0.15, 0.3, 0.6, 1.1, 1.8, 2.5, 2.9, 3.1, 3.15, 3.18])

popt, pcov = optimize.curve_fit(logistic, t, y, p0=[3.0, 1.0, 4.0])
L_fit, k_fit, t0_fit = popt
print(f"L={L_fit:.2f}, k={k_fit:.2f}, t0={t0_fit:.2f}")

curve_fit prende la tua funzione modello (il primo argomento è la variabile indipendente, gli altri sono i parametri), i dati x e y e una stima iniziale opzionale p0. Restituisce i parametri di best-fit e la matrice di covarianza (la cui diagonale ti dà gli errori standard dei parametri). È così che si fittano curve logistiche alla crescita di un prodotto, esponenziali al decadimento, Gompertz alla diffusione di una malattia, qualsiasi cosa in cui hai una forma funzionale nota e vuoi i coefficienti.

La ricerca di radici per quando vuoi risolvere f(x) = 0:

optimize.brentq(lambda x: x**3 - 2*x - 5, 1, 3)   # radice in [1, 3]

brentq è robusto ed è quasi sempre la scelta giusta quando hai un intervallo di bracketing in cui la funzione cambia segno.

scipy.sparse: matrici fatte quasi tutte di zeri

Il terzo submodule che si guadagna lo stipendio. Una matrice sparsa memorizza solo le entry diverse da zero. Per una matrice un milione per un milione in cui il 99,99% delle entry sono zero, estremamente comune nell’NLP, nei recommendation system, nei problemi su grafi, lo storage sparso è la differenza fra “ci sta in 2 GB di RAM” e “servirebbero 8 TB”.

from scipy import sparse

# Costruisci una matrice sparsa da triplette di coordinate
rows = np.array([0, 1, 2, 0])
cols = np.array([0, 1, 2, 2])
vals = np.array([1.0, 2.0, 3.0, 4.0])

M = sparse.csr_matrix((vals, (rows, cols)), shape=(3, 3))
print(M.toarray())
# [[1. 0. 4.]
#  [0. 2. 0.]
#  [0. 0. 3.]]

I quattro formati che vedrai: csr_matrix (compressed sparse row, slicing per riga veloce, prodotti matrice-vettore veloci), csc_matrix (versione per colonna della stessa cosa), coo_matrix (il formato a triplette, buono per costruire una matrice sparsa in modo incrementale e poi convertirla) e lil_matrix (quella da usare quando devi mutare le entry). Il pattern: costruisci in COO o LIL, converti a CSR per il calcolo.

L’uso classico: matrici termini-documenti. Un corpus con 100k documenti e un vocabolario di 50k parole è, in forma densa, una matrice da 5 miliardi di celle. Come csr_matrix sparsa diventa qualche centinaio di megabyte. Il TfidfVectorizer di scikit-learn restituisce direttamente una csr_matrix; la puoi passare a una LogisticRegression e l’intera pipeline resta sparsa da capo a fondo.

Il resto della cassetta degli attrezzi

I submodule su cui non sono entrato, ma di cui dovresti almeno conoscere il nome:

  • scipy.interpolate: quando hai dei punti dati e vuoi una stima continua fra di essi. interp1d per il caso 1-D, RegularGridInterpolator per n-D su una griglia, le spline tramite UnivariateSpline. Il submodule del “ho dei dati a tabella di lookup e devo valutarli in punti arbitrari”.
  • scipy.signal: signal processing. Progettazione e applicazione di filtri digitali (butter, filtfilt), calcolo di FFT (il più recente scipy.fft è la casa moderna per questo), correlazioni, convoluzioni, ricerca di picchi (find_peaks). Se lavori con dati di sensori, audio o qualsiasi serie temporale in cui il contenuto in frequenza conta, è il tuo submodule.
  • scipy.spatial: metriche di distanza e strutture dati spaziali. cKDTree per query veloci di nearest-neighbor (molto più veloce della forza bruta su più di qualche migliaio di punti), distance.cdist per le distanze a coppie fra due insiemi di punti.
  • scipy.cluster: clustering gerarchico (linkage, dendrogram, fcluster) e un k-means di base. Per gran parte del lavoro di clustering nel 2026 si ricorre a scikit-learn, ma le funzioni gerarchiche di scipy restano lo standard per quello specifico algoritmo.
  • scipy.integrate: integrazione numerica (quad per integrali 1-D) e solver di ODE (solve_ivp).
  • scipy.linalg: algebra lineare. In gran parte un superset di numpy.linalg con qualcosa in più: fattorizzazioni di matrici, solver speciali, il lstsq che usano tutti per i minimi quadrati.
  • scipy.ndimage: image processing n-dimensionale. Filtri, morfologia, labeling. La cosa su cui è costruito scikit-image.

Il pattern con tutti questi: non li memorizzi. Fai questa lezione una volta, ti ricordi “ah giusto, è una cosa di SciPy”, e quando incontri il problema fra due mesi apri scipy.org/doc/scipy/reference/ e trovi la funzione in cinque minuti.

Un esempio concreto: A/B test, dall’inizio alla fine

Mettiamo stats al lavoro su qualcosa di concreto. Hai fatto un A/B test su un pulsante di checkout. Il gruppo A ha visto il vecchio design, il gruppo B il nuovo. Hai loggato i tempi di conversione in secondi per gli utenti che hanno completato il checkout. Il nuovo design ha reso la gente più veloce?

import numpy as np
from scipy import stats

rng = np.random.default_rng(42)
group_a = rng.lognormal(mean=2.0, sigma=0.5, size=500)
group_b = rng.lognormal(mean=1.85, sigma=0.5, size=500)

# Statistiche descrittive rapide
print(f"A: median={np.median(group_a):.2f}, mean={group_a.mean():.2f}")
print(f"B: median={np.median(group_b):.2f}, mean={group_b.mean():.2f}")

# T-test sui valori grezzi: ma aspetta, i dati lognormali sono asimmetrici
t_result = stats.ttest_ind(group_a, group_b)
print(f"t-test:    p={t_result.pvalue:.4f}")

# Meglio: trasformazione logaritmica, visto che i dati sottostanti sono lognormali
log_a, log_b = np.log(group_a), np.log(group_b)
t_log = stats.ttest_ind(log_a, log_b)
print(f"log t-test: p={t_log.pvalue:.4f}")

# Oppure salta del tutto l'assunzione parametrica
mw = stats.mannwhitneyu(group_a, group_b, alternative="greater")
print(f"Mann-Whitney: p={mw.pvalue:.4f}")

Tre risposte alla stessa domanda, ognuna con assunzioni diverse. È esattamente a questo che serve scipy.stats: avere gli strumenti per chiedere “è reale?” in due o tre modi e vedere se concordano.

Un secondo esempio: interpolare letture di sensori

Un secondo pattern in cui inciampo in continuazione. Hai un sensore che logga a intervalli irregolari. Vuoi una lettura pulita ogni minuto per il processing a valle. scipy.interpolate.interp1d fa il lavoro:

from scipy import interpolate

# Timestamp irregolari (in secondi dall'inizio) e i valori del sensore in ognuno
t_irregular = np.array([0, 7, 23, 41, 58, 79, 102, 130, 165])
values = np.array([20.1, 20.3, 21.0, 22.4, 23.1, 22.8, 21.9, 20.5, 19.8])

# Costruisci l'interpolatore (lineare di default; "cubic" per smooth)
f = interpolate.interp1d(t_irregular, values, kind="cubic")

# Campiona su una griglia regolare
t_regular = np.arange(0, 165, 5)
values_regular = f(t_regular)

f ora è un callable: dagli un qualsiasi tempo all’interno del range originale e restituisce un valore interpolato. kind="linear" è il default ed è la scelta giusta quando hai dati rumorosi; kind="cubic" è più liscio ma può oscillare attorno agli outlier. Per dati fuori dal range originale ti servirà fill_value="extrapolate" o bounds_error=False: tieni presente che estrapolare oltre i tuoi dati di training è una trappola di falsa sicurezza.

Per 2-D e dimensioni superiori, RegularGridInterpolator è l’API moderna che sostituisce il vecchio e deprecato griddata per dati su griglia.

Chiudiamo il modulo

Modulo 8 fatto. La lezione 43 ti ha dato il modello ad array di NumPy e il broadcasting. La 44 ha coperto le tre librerie di plotting. La 45, questa, ha puntato il dito sul resto della cassetta degli attrezzi scientifica.

Il pattern attraverso tutte e tre le lezioni è lo stesso: un piccolo nucleo di operazioni copre la stragrande maggioranza del lavoro reale, e le librerie sono abbastanza grandi che nessuno memorizza tutto. Impari la forma del toolkit una volta, metti i bookmark sulla documentazione e vai a cercare le cose quando ti servono. Quell’abitudine, “so che SciPy ha qualcosa per questo, vado a trovarlo”, vale più che memorizzare le firme delle funzioni, che tanto IDE e LLM possono fornirti su richiesta.

Il modulo 9 riprende il filo e inizia a usare tutto questo per lavoro ML vero: scikit-learn per i modelli classici, poi una svolta verso lo stack moderno (basi di PyTorch, Hugging Face per l’NLP, i pezzi di MLOps che non fanno schifo). La fondazione numerica che abbiamo appena costruito è ciò che rende possibile tutto quanto.


Riferimento: documentazione di SciPy, consultata il 2026-05-01.

Cerca