NumPy è la libreria fondamentale del data science in Python — su questo non ci sono dubbi. Eppure la maggior parte dei tutorial si ferma alle operazioni di base: creare array, accedere agli elementi, fare semplici somme. Onestamente, è un peccato. La vera potenza di NumPy emerge quando si padroneggiano vettorizzazione, broadcasting e le ottimizzazioni introdotte con la serie 2.x. In questa guida esploreremo ogni concetto con esempi pratici, benchmark reali e le ultime novità di NumPy 2.4 (dicembre 2025). Pronti? Allora andiamo.
Perché evitare i loop Python in NumPy
Python è un linguaggio interpretato con un overhead significativo per ogni operazione: ogni iterazione di un ciclo for richiede la risoluzione di tipi, il controllo degli errori e l'esecuzione nell'interprete CPython. Su dataset di grandi dimensioni, questo overhead si accumula molto in fretta.
NumPy aggira questo problema spostando il ciclo nel codice C/Fortran compilato. L'operazione viene eseguita una sola volta a livello di array, sfruttando istruzioni SIMD (Single Instruction Multiple Data) e ottimizzazioni a basso livello della CPU. Il risultato? Spesso stupisce.
Ecco un benchmark concreto con timeit:
import numpy as np
import timeit
N = 1_000_000
arr = np.random.rand(N)
# Loop Python (lento)
def loop_square(a):
result = np.empty_like(a)
for i, v in enumerate(a):
result[i] = v * v
return result
# Vettorizzazione NumPy (veloce)
def vec_square(a):
return a * a
t_loop = timeit.timeit(lambda: loop_square(arr), number=5) / 5
t_vec = timeit.timeit(lambda: vec_square(arr), number=5) / 5
print(f"Loop Python: {t_loop:.4f}s")
print(f"NumPy vect.: {t_vec:.6f}s")
print(f"Speedup: {t_loop / t_vec:.0f}x")
# Esempio di output:
# Loop Python: 0.8241s
# NumPy vect.: 0.001153s
# Speedup: 715x
Un fattore di accelerazione di oltre 700x su un milione di elementi. Non di rado. Su dataset da decine di milioni di righe — tipici nei pipeline ETL o nel preprocessing ML — la differenza tra loop e vettorizzazione può valere minuti interi di elaborazione. Minuti che, in produzione, si trasformano in costi reali.
Universal Functions (ufunc): il motore della vettorizzazione
Le universal functions (ufunc) sono funzioni NumPy che operano elemento per elemento su array, eseguendo il ciclo in C. Esempi comuni: np.sqrt, np.exp, np.log, np.sin, np.abs. Sono loro il vero motore.
import numpy as np
temperatures_celsius = np.array([0.0, 20.0, 37.0, 100.0])
# ufunc: operazione vettorizzata su tutto l'array
temperatures_fahrenheit = temperatures_celsius * 9/5 + 32
print(temperatures_fahrenheit) # [32. 68. 98.6 212.]
# Confronto: np.vectorize NON è una vera ufunc
# (è un wrapper per-loop — non usarla per le performance)
slow_fn = np.vectorize(lambda x: x * 9/5 + 32)
Attenzione: np.vectorize() è una funzione di comodità, non di performance. La documentazione ufficiale avverte esplicitamente che la sua implementazione è essenzialmente un ciclo for. Per le performance, usa sempre le ufunc native o le operazioni di broadcasting. (Sì, anche io l'ho scoperto a mie spese, usando np.vectorize su array enormi e chiedendomi perché andasse così lento.)
Le ufunc supportano anche metodi di riduzione molto utili:
import numpy as np
data = np.array([1, 2, 3, 4, 5, 6])
# Riduzione cumulativa con np.add.accumulate
cum_sum = np.add.accumulate(data)
print(cum_sum) # [ 1 3 6 10 15 21]
# Riduzione con np.multiply.reduce
product = np.multiply.reduce(data)
print(product) # 720
# Outer product (prodotto esterno)
a = np.array([1, 2, 3])
b = np.array([10, 20])
outer = np.multiply.outer(a, b)
print(outer)
# [[10 20]
# [20 40]
# [30 60]]
Broadcasting: operare su array di forme diverse
Il broadcasting è uno dei concetti più potenti — e spesso fraintesi — di NumPy. Permette di eseguire operazioni aritmetiche tra array di forme diverse senza copiare dati in memoria, "espandendo" virtualmente gli array minori per renderli compatibili con quelli maggiori. Una volta che si capisce davvero, diventa quasi magico.
Le tre regole del broadcasting
NumPy confronta le forme degli array da destra a sinistra. Due dimensioni sono compatibili se:
- Sono uguali, oppure
- Una di esse è 1 (in questo caso viene "allargata" a corrispondere all'altra).
Se gli array hanno un numero diverso di dimensioni, quello con meno dimensioni viene paddato con 1 a sinistra.
import numpy as np
# Esempio 1: scalare + array 1D
a = np.array([1, 2, 3, 4])
result = a + 10 # 10 viene broadcastato su tutti gli elementi
print(result) # [11 12 13 14]
# Esempio 2: array 2D + array 1D
matrix = np.ones((3, 4)) # shape (3, 4)
row = np.array([1, 2, 3, 4]) # shape (4,) → broadcastato a (3, 4)
print(matrix + row)
# [[2. 3. 4. 5.]
# [2. 3. 4. 5.]
# [2. 3. 4. 5.]]
# Esempio 3: array colonna + array riga → matrice
col = np.array([[0], [10], [20]]) # shape (3, 1)
row = np.array([1, 2, 3, 4]) # shape (4,) → (1, 4)
print(col + row)
# [[ 1 2 3 4]
# [11 12 13 14]
# [21 22 23 24]]
Caso pratico: normalizzazione di un dataset
Una delle applicazioni più comuni del broadcasting è la normalizzazione Z-score, fondamentale nel preprocessing per il machine learning. È uno di quei casi in cui NumPy brilla davvero:
import numpy as np
# Dataset: 1000 campioni, 5 feature
data = np.random.randn(1000, 5) * np.array([10, 5, 100, 1, 50])
# Calcolo media e deviazione standard per colonna (shape: (5,))
mean = data.mean(axis=0) # shape (5,)
std = data.std(axis=0) # shape (5,)
# Broadcasting: (1000, 5) - (5,) e (1000, 5) / (5,) → automaticamente allineati
normalized = (data - mean) / std
print(f"Forma originale: {data.shape}")
print(f"Forma normalizzata: {normalized.shape}")
print(f"Media post-norm: {normalized.mean(axis=0).round(4)}") # ≈ 0
print(f"Std post-norm: {normalized.std(axis=0).round(4)}") # ≈ 1
Questa operazione, eseguita con broadcasting, è equivalente a un ciclo su tutte e 5 le colonne ma viene completata in una singola istruzione ottimizzata. Niente male, vero?
Indicizzazione avanzata: fancy indexing e boolean indexing
NumPy offre meccanismi di indicizzazione che vanno ben oltre le slice standard di Python. Vediamone due tra i più utili:
import numpy as np
scores = np.array([88, 45, 92, 61, 78, 55, 95, 30, 84, 72])
# Boolean indexing: seleziona elementi che soddisfano una condizione
passing = scores[scores >= 60]
print(passing) # [88 92 61 78 95 84 72]
# Fancy indexing: accesso tramite array di indici
top_3_idx = np.argsort(scores)[-3:][::-1]
print(f"Indici top 3: {top_3_idx}") # es. [6 2 0]
print(f"Punteggi top 3: {scores[top_3_idx]}") # [95 92 88]
# Assegnazione condizionale con boolean mask
grades = scores.copy().astype(float)
grades[grades < 60] = np.nan # sostituisce i voti insufficienti con NaN
print(grades) # [88. nan 92. 61. 78. nan 95. nan 84. 72.]
np.where e np.select: operazioni condizionali vettorizzate
Invece di scrivere cicli con istruzioni if/else, NumPy offre np.where e np.select per la logica condizionale vettorizzata. Sono strumenti che, una volta adottati, non si abbandonano più:
import numpy as np
prices = np.array([10.5, 25.0, 8.3, 42.0, 19.9, 5.5, 33.7])
# np.where: scelta binaria (simile all'operatore ternario)
# "prezzo scontato se > 20, altrimenti prezzo pieno"
discounted = np.where(prices > 20, prices * 0.85, prices)
print(discounted.round(2))
# [10.5 21.25 8.3 35.7 19.9 5.5 28.64]
# np.select: logica multi-condizione (simile a if/elif/else)
categories = np.select(
condlist=[prices < 10, prices < 25, prices >= 25],
choicelist=["economico", "medio", "premium"],
default="sconosciuto"
)
print(categories)
# ['medio' 'medio' 'economico' 'premium' 'medio' 'economico' 'premium']
Questi costrutti sono essenziali nel feature engineering con pandas e scikit-learn per creare feature derivate e variabili binned direttamente su array NumPy senza overhead di loop.
Algebra lineare con numpy.linalg
Il modulo numpy.linalg fornisce operazioni di algebra lineare ottimizzate — fondamentali per algoritmi di machine learning, riduzione della dimensionalità e sistemi di equazioni. Se hai mai implementato la regressione lineare a mano, sai quanto questo modulo sia prezioso:
import numpy as np
# Moltiplicazione matriciale: usa @ o np.matmul (NON np.dot per matrici 2D+)
A = np.random.randn(100, 50)
B = np.random.randn(50, 30)
C = A @ B # shape: (100, 30)
# Decomposizione ai valori singolari (SVD) — usata nella PCA
U, s, Vt = np.linalg.svd(A, full_matrices=False)
print(f"U: {U.shape}, s: {s.shape}, Vt: {Vt.shape}")
# U: (100, 50), s: (50,), Vt: (50, 50)
# Risoluzione di sistemi lineari Ax = b
A_sq = np.array([[2., 1.], [1., 3.]])
b = np.array([8., 13.])
x = np.linalg.solve(A_sq, b)
print(x) # [3. 2.] — soluzione esatta senza inversione di matrice
# Autovalori e autovettori (es. per PCA manuale)
cov = np.cov(np.random.randn(50, 5).T)
eigenvalues, eigenvectors = np.linalg.eigh(cov)
print(f"Varianza spiegata dai primi 2 componenti: "
f"{eigenvalues[-2:].sum() / eigenvalues.sum() * 100:.1f}%")
Queste operazioni sono il cuore di molti algoritmi di Machine Learning con scikit-learn: regressione lineare, SVM, PCA e K-Means si basano tutti su decomposizioni matriciali eseguite efficientemente da BLAS/LAPACK attraverso NumPy.
NumPy 2.x: le novità di performance (2024–2026)
La serie NumPy 2.x, lanciata a giugno 2024, è la prima major version dalla 1.0 del 2006. Un traguardo storico per l'ecosistema Python, e porta con sé miglioramenti davvero significativi.
NumPy 2.0 (giugno 2024)
- SIMD ottimizzato: accelerazioni marcate su operazioni array come moltiplicazione matriciale e aggregazioni grazie a istruzioni vettoriali avanzate.
- NEP 50 — nuovo sistema di type promotion: il comportamento di promozione dei tipi è ora più prevedibile e coerente; non dipende più dai valori dei dati ma solo dai dtype.
- Dimensioni array ampliate: il numero massimo di dimensioni è passato da 32 a 64.
- Default int64 su Windows: allineato con Linux/macOS, elimina sorprese sui calcoli a 32 bit.
NumPy 2.2 (dicembre 2024)
- Hugepage per
np.zeros: su Linux, le allocazioni grandi usano hugepage per migliorare le performance di accesso alla memoria. - Multithreading migliorato: migliore scaling con molti thread su build free-threaded di CPython.
- Lookup attributi più veloci: riduzione dell'overhead nelle chiamate di funzione con oggetti Python custom.
NumPy 2.4 (dicembre 2025)
- Speedup massiccio per
np.uniquesu stringhe: algoritmo hash-based che porta da 498s a 33.5s su ~1 miliardo di stringhe (~15x più veloce). Impressionante. numpy.ndindex5x più veloce grazie aitertools.productinternamente.- Hash-based unique per tipi complessi: 1.4–5x di speedup su array
complex128. - Nuove funzioni
matvecevecmatper prodotti matrice-vettore ottimizzati. - Introspezione con
inspect.signature(): IDE e type checker ora funzionano meglio con NumPy.
# Migrare da NumPy 1.x a 2.x: punti critici
import numpy as np
# 1. Controllare la versione
print(np.__version__) # es. "2.4.5"
# 2. NEP 50 — la promozione dei tipi è cambiata
# In NumPy 1.x:
# np.array([1], dtype=np.int8) + 1 → int8 (se 1 piccolo) o int64 (se grande)
# In NumPy 2.x:
# np.array([1], dtype=np.int8) + np.int8(1) → sempre int8
# np.array([1], dtype=np.int8) + 1 → int64 (Python int è sempre 64-bit)
# 3. bool() su array vuoti ora lancia errore
arr_empty = np.array([])
# bool(arr_empty) # → ValueError in NumPy 2.2+
# Usare invece:
if arr_empty.size > 0:
print("Array non vuoto")
Ottimizzazione avanzata: memorie, strides e layout
Per massimizzare le performance, è utile capire come NumPy gestisce la memoria. Non serve diventare esperti di architetture CPU, ma qualche nozione di base fa la differenza:
import numpy as np
# Layout C-order (row-major) vs Fortran-order (column-major)
arr_c = np.ones((1000, 1000), order='C') # efficiente per operazioni su righe
arr_f = np.ones((1000, 1000), order='F') # efficiente per operazioni su colonne
# Contiguous check
print(arr_c.flags['C_CONTIGUOUS']) # True
print(arr_f.flags['F_CONTIGUOUS']) # True
# ascontiguousarray: forza layout C-order per ufunc ottimali
arr_slice = arr_f[::2, :] # non-contiguo
arr_contig = np.ascontiguousarray(arr_slice)
# Views vs copie: una view non copia dati in memoria
a = np.arange(12).reshape(3, 4)
b = a[1:, ::2] # view — modifica b modifica anche a
c = a[1:, ::2].copy() # copia — indipendente
print(b.base is a) # True (è una view)
print(c.base is a) # False (è una copia)
Integrazione con pandas e il pipeline di analisi
NumPy e pandas sono profondamente integrati: i DataFrame pandas sono costruiti su array NumPy e si può passare facilmente da uno all'altro con .values o .to_numpy(). In pratica, conoscere bene NumPy rende anche pandas molto più immediato da capire.
import numpy as np
import pandas as pd
df = pd.DataFrame({
"età": [25, 32, 28, 45, 22, 38, 55, 29],
"reddito": [30000, 55000, 42000, 85000, 28000, 62000, 95000, 37000]
})
# Estrarre array NumPy da pandas (preferire .to_numpy() su .values)
ages = df["età"].to_numpy()
incomes = df["reddito"].to_numpy()
# Calcolo vettorizzato: correlazione di Pearson manuale
def pearson_corr(x, y):
x_c = x - x.mean()
y_c = y - y.mean()
return (x_c * y_c).sum() / (np.sqrt((x_c**2).sum()) * np.sqrt((y_c**2).sum()))
r = pearson_corr(ages, incomes)
print(f"Correlazione età-reddito: {r:.4f}")
# Confronto con SciPy per validazione
from scipy import stats
r_scipy, p_value = stats.pearsonr(ages, incomes)
print(f"Verifica SciPy: r={r_scipy:.4f}, p={p_value:.4f}")
Questo tipo di calcolo vettorizzato è alla base dell' analisi statistica in Python con SciPy e statsmodels: comprendere NumPy rende immediato capire come SciPy e pandas ottimizzano internamente le loro operazioni.
Riepilogo: best practice per codice NumPy performante
Per chiudere, ecco le linee guida che mi trovo ad applicare ogni volta che lavoro con dati numerici in Python:
- Evita i loop Python: se stai iterando su array NumPy con
for, cerca prima un'equivalente vettorizzata. - Usa ufunc native (
np.sqrt,np.exp, ecc.) invece dinp.vectorize()o loop applicati elemento per elemento. - Sfrutta il broadcasting per evitare copie di array:
data - meanè più efficiente di espandere manualmentemean. - Prediligi il layout C-order (default) per operazioni su righe; usa Fortran-order solo se le operazioni sono principalmente su colonne.
- Misura prima di ottimizzare: usa
%timeitin Jupyter o il modulotimeitper identificare i veri colli di bottiglia. - Per operazioni non vettorizzabili, considera Numba (
@numba.jit) che compila il loop Python in codice macchina ottimizzato. - Aggiorna a NumPy 2.x: la versione 2.4.5 (maggio 2026) porta speedup reali senza richiedere modifiche al codice per la maggior parte dei casi d'uso.
Domande Frequenti
Qual è la differenza tra vettorizzazione e np.vectorize() in NumPy?
np.vectorize() è una funzione di comodità che applica una funzione Python elemento per elemento — internamente è un ciclo for, quindi non offre vantaggi di performance. La vera vettorizzazione si ottiene usando le ufunc native di NumPy (np.sqrt, operatori aritmetici, np.where, ecc.) che eseguono il ciclo in codice C ottimizzato.
Quando il broadcasting crea copie di dati in memoria?
Il broadcasting non crea copie permanenti degli array: NumPy "espande" virtualmente gli array minori solo durante il calcolo, senza allocare nuova memoria per gli array intermedi. Tuttavia, il risultato dell'operazione è sempre un nuovo array con la forma broadcastata finale. Usa np.broadcast_to() per creare view read-only broadcastate senza costi di memoria.
Come si migra da NumPy 1.x a NumPy 2.x senza errori?
I principali punti di attenzione sono: (1) il nuovo sistema di type promotion NEP 50 può cambiare il dtype dei risultati — aggiungi dtype espliciti dove necessario; (2) bool(np.array([])) ora lancia ValueError — usa arr.size > 0; (3) verifica le dipendenze del progetto con pip check perché pacchetti compilati contro NumPy 1.x potrebbero non essere compatibili a livello ABI. Il tool ufficiale ruff --select NPY aiuta a identificare le incompatibilità nel codice.
Quando usare Numba invece di NumPy per l'ottimizzazione?
Usa Numba (@numba.jit o @numba.njit) quando il tuo algoritmo ha dipendenze tra iterazioni (es. simulazioni, algoritmi iterativi) che non si prestano alla vettorizzazione con NumPy. Numba compila il loop Python in codice macchina LLVM ottenendo performance simili a C, pur mantenendo la sintassi Python. Per operazioni già vettorizzabili, NumPy puro è di solito più semplice e altrettanto veloce.
Come verificare se un'operazione NumPy sta copiando dati o creando una view?
Controlla l'attributo .base: se b.base is a restituisce True, b è una view di a (nessuna copia). Le operazioni di slicing (a[1:, ::2]) creano view; operazioni con fancy indexing (a[[0, 2], :]) creano sempre copie. Puoi anche usare np.shares_memory(a, b) per un controllo esplicito.