Python, dalle fondamenta Lezione 43 / 60

NumPy: array, broadcasting, le fondamenta del Python scientifico

Cos'è un ndarray, perché il broadcasting cambia il modo in cui scrivi i loop, e il piccolo insieme di funzioni che copre la maggior parte dei casi.

Abbiamo passato l’ultimo blocco di lezioni dentro i DataFrame: pandas, Polars, il mondo tabulare. Sotto a tutto questo, in fondo a ogni stack di Python numerico che toccherai mai, c’è NumPy. Pandas memorizza la maggior parte delle sue colonne come array NumPy. scikit-learn prende array NumPy come input. PyTorch e JAX implementano entrambi una API compatibile con NumPy sopra ai loro tensori così che la gente non debba reimparare le basi. Anche Polars, che usa Apache Arrow internamente, ti dà .to_numpy() perché tutti a valle se lo aspettano.

Quindi prima di aprire il Modulo 8 in modo proprio con plotting e SciPy, ci serve una lezione pulita su NumPy in sé. Non un approfondimento profondo (sarebbe un corso a parte), ma quanto basta per leggere il codice, scrivere le operazioni che ti serviranno davvero, e capire perché il broadcasting è la mossa concettuale che rende l’intera libreria degna di essere imparata.

L’ndarray

L’oggetto centrale di NumPy è l’ndarray, un array n-dimensionale. Da fuori sembra una lista Python di numeri. Dentro è qualcosa di completamente diverso:

  • Memoria contigua. Tutti gli elementi vivono uno accanto all’altro in un singolo blocco di RAM, come fa un array C. Le liste Python sono array di puntatori a oggetti sparsi; gli array NumPy sono lastre piatte di byte.
  • dtype fisso. Ogni elemento è dello stesso tipo (float64, int32, bool, qualunque) e il tipo è marchiato sull’array, non su ogni elemento.
  • Shape e stride. Lo stesso blocco di memoria 1-D può essere interpretato come un vettore 1-D, una matrice 2-D o un tensore di dimensione superiore cambiando il modo in cui NumPy ci cammina dentro.

Quel layout è l’intero motivo per cui NumPy è veloce. Quando fai arr * 2, NumPy non itera in Python: dispatcha a un loop C che gira sopra i byte contigui senza overhead dell’interprete, spesso vettorizzato a istruzioni SIMD dal compilatore. La stessa operazione su una lista Python è grosso modo 50-100x più lenta, e il divario cresce con la dimensione dell’array.

import numpy as np

a = np.array([1, 2, 3, 4, 5])
print(a.dtype)    # int64 on most platforms
print(a.shape)    # (5,)
print(a.ndim)     # 1

Creare array

Cinque costruttori coprono la maggior parte di ciò che farai:

np.array([1, 2, 3])              # from a list
np.zeros((3, 4))                 # 3x4 of zeros, dtype float64
np.ones((2, 2), dtype=np.int32)  # 2x2 of ones, integer
np.arange(0, 10, 2)              # [0, 2, 4, 6, 8] — like range()
np.linspace(0, 1, 5)             # [0., 0.25, 0.5, 0.75, 1.] — N evenly-spaced points

arange ti dà un passo, linspace ti dà un conteggio: questa è l’unica differenza che vale la pena ricordare. Per dati casuali: np.random.default_rng(seed=42).normal(size=(1000, 3)) è la API moderna; il vecchio stile np.random.randn funziona ancora ma la strada via default_rng è quella che la documentazione raccomanda adesso.

Il reshape sposta la stessa memoria in un layout diverso:

a = np.arange(12)              # shape (12,)
b = a.reshape((3, 4))          # shape (3, 4), same data
c = a.reshape((2, 2, 3))       # shape (2, 2, 3), same data

-1 in un reshape significa “inferisci questa dimensione”: a.reshape((3, -1)) su un array di 12 elementi ti dà (3, 4). Usalo costantemente.

La vettorizzazione è il punto

La prima cosa da interiorizzare: non scrivi loop sopra gli array NumPy. Ogni operazione che sembra dover essere un loop ne è già uno, scritto in C, chiamato ufunc:

prices = np.array([10.0, 20.0, 35.5, 7.99])
with_vat = prices * 1.22          # element-wise multiplication
total = prices.sum()              # scalar reduction
log_prices = np.log(prices)       # element-wise log

Niente for. Niente list comprehension. Solo operatori e funzioni con un nome, applicati all’intero array in una sola volta. Questo è ciò che la gente intende per “codice vettorizzato”. Quando ti ritrovi a scrivere un loop Python sopra gli elementi di un ndarray, fermati e cerca l’equivalente vettorizzato: quasi sempre esiste.

Broadcasting

Il broadcasting è la regola che dice: quando fai un’operazione su due array di shape diversa, NumPy prova a stirarli a shape compatibili prima di fare l’operazione element-wise. È la feature che trasforma “devo sottrarre questo vettore 1-D da ogni riga di una matrice” da un loop a una singola riga.

Le regole, allineate a destra (confronta le dimensioni partendo da destra):

  1. Se un array ha meno dimensioni, tratta quelle mancanti come dimensione 1.
  2. Due dimensioni sono compatibili se sono uguali, o se una di loro è 1.
  3. Una dimensione di taglia 1 viene stirata per matchare l’altra.
  4. Se una dimensione è incompatibile, ottieni un ValueError.

L’esempio classico: sottrarre le medie di colonna da una matrice 2-D.

X = np.array([
    [1.0, 2.0, 3.0],
    [4.0, 5.0, 6.0],
    [7.0, 8.0, 9.0],
])

col_means = X.mean(axis=0)        # shape (3,) — [4., 5., 6.]
centered = X - col_means          # shape (3, 3) - shape (3,) → broadcasts

X è (3, 3). col_means è (3,). Allinea a destra le shape:

X:          3 x 3
col_means:      3

Le dimensioni di coda sono entrambe uguali a 3, quindi sono compatibili. La dimensione di testa mancante su col_means viene trattata come 1, poi stirata a 3. Il risultato è lo stesso che avresti ripetuto col_means tre volte lungo l’asse delle righe, ma nessuna copia viene fatta davvero; il broadcasting è un’operazione in tempo di view.

Se vuoi sottrarre le medie di riga invece, devi tenere l’asse delle righe:

row_means = X.mean(axis=1, keepdims=True)   # shape (3, 1) instead of (3,)
centered_rows = X - row_means

(3, 1) si allinea con (3, 3): l’1 si stira a 3 lungo le colonne. Senza keepdims=True, otterresti (3,) e il broadcasting proverebbe ad allinearlo contro le colonne di X invece, facendo silenziosamente la cosa sbagliata se la tua matrice è quadrata. Questo è il bug di broadcasting che chiunque incontra una volta.

Slicing e indicizzazione booleana

Lo slicing di NumPy assomiglia allo slicing delle liste Python esteso a più dimensioni, con una differenza importante: gli slice sono view, non copie. Modificare lo slice modifica l’originale.

arr = np.arange(20).reshape((4, 5))

arr[1:3, 0]        # rows 1 and 2, column 0 — shape (2,)
arr[:, :3]         # all rows, first three columns — shape (4, 3)
arr[-1, :]         # last row — shape (5,)

L’indicizzazione booleana, scegliere gli elementi dove una condizione è vera, è il cavallo di battaglia:

arr = np.array([1, -2, 3, -4, 5, -6])
arr[arr > 0]                      # array([1, 3, 5])
arr[arr > 0] = 0                  # zero out positives in place

Combinata con np.where per la sostituzione condizionale:

np.where(arr < 0, 0, arr)         # replace negatives with 0, leave the rest

Il parametro axis

Ogni reduction (sum, mean, min, max, std, argmax, …) prende un argomento axis. Questa è la seconda cosa che la gente sbaglia, dopo il broadcasting.

X = np.arange(12).reshape((3, 4))
X.sum()              # 66 — reduce over everything, scalar
X.sum(axis=0)        # shape (4,) — sum down each column
X.sum(axis=1)        # shape (3,) — sum across each row

Il mnemonico che alla fine me l’ha fatto entrare in testa: axis= è la dimensione che scompare. axis=0 collassa l’asse delle righe, lasciandoti con un numero per colonna. axis=1 collassa l’asse delle colonne, lasciandone uno per riga. Lo stesso vale per qualunque caso di dimensione superiore.

Qualche altra funzione che vale la pena conoscere

  • np.concatenate([a, b], axis=0): incolla array lungo un asse esistente.
  • np.stack([a, b], axis=0): incolla array lungo un nuovo asse (crea una nuova dimensione).
  • np.unique(arr, return_counts=True): valori distinti e quanto spesso appare ognuno.
  • arr.astype(np.float32): cambia il dtype.
  • np.allclose(a, b): uguaglianza approssimata element-wise, il modo giusto di confrontare i float.

Layout di memoria (brevemente)

Due fatti da tenere in tasca dietro. Gli array NumPy sono di default C-contigui: le righe vivono una accanto all’altra in memoria, come C dispone un array 2-D. Fortran-contiguo è l’altro layout (colonne una accanto all’altra), quello che MATLAB e Fortran usano nativamente. Quasi mai devi pensarci, eccetto in due posti: quando passi array a un’estensione C o a PyTorch (alcuni kernel pretendono input contiguo: arr.contiguous() o np.ascontiguousarray(arr) lo sistema), e quando stai spremendo l’ultimo bit di performance da un’operazione column-major.

NumPy 2.x è il mondo attuale

NumPy 2.0 è uscito a metà 2024 e la linea 2.x è dove vive tutto nel 2026. Il cambiamento grosso è stato un cleanup del sottosistema dei dtype: tipi stringa più consistenti, regole di promozione più pulite (int + float non ti sorprende più nei casi limite), e un sacco di API deprecate finalmente rimosse. Se stai leggendo codice più vecchio del 2024 potresti vedere cose come np.int (sparito, usa int o np.int64) o np.product (sparito, usa np.prod). I deprecation warning dell’era 1.20-1.26 dicevano a tutti che questo stava arrivando; la 2.0 l’ha solo consegnato.

Per codice nuovo, non ci pensare. Pinna numpy>=2 nel tuo pyproject.toml, installa con uv, vai oltre.

View, copie, e il bug che chiunque incontra

Un’ultima cosa prima di chiudere: NumPy distingue con cura tra view e copie. Lo slicing restituisce una view, stessa memoria, descrittore di shape diverso. L’indicizzazione booleana e il fancy indexing (passare un array di indici) restituiscono copie. Questo conta quando inizi a mutare:

arr = np.arange(10)
view = arr[2:5]
view[0] = 999
print(arr)            # [0, 1, 999, 3, 4, 5, 6, 7, 8, 9]  — original was modified

arr2 = np.arange(10)
copy = arr2[arr2 > 3]
copy[0] = 999
print(arr2)           # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]    — original unchanged

La regola del pollice: nel dubbio, chiama .copy() esplicitamente. Il costo di performance di solito è trascurabile rispetto al costo di debuggare un bug di aliasing alle 11 di sera prima di una scadenza.

np.shares_memory(a, b) ti dirà se due array sono view l’uno dell’altro. Utile quando stai inseguendo “perché cambiando X è cambiato anche Y?”

Quando NON usare NumPy direttamente

Ecco la verità scomoda alla fine di una lezione su NumPy: la maggior parte del lavoro sui dati nel 2026 in realtà non parte da np.array(...). Se i tuoi dati sono tabulari (colonne con nomi, tipi misti, valori mancanti), pandas, Polars o PyArrow ti danno tutti tutto ciò che NumPy fa, piu’ le label, più una migliore gestione di stringhe e datetime, più un I/O migliore. Tira fuori NumPy quando hai dati genuinamente numerici, omogenei, n-dimensionali: un’immagine, una matrice di feature per un modello, una griglia di simulazione, una serie temporale di misurazioni.

L’altra verità scomoda è che per molti workload ML la libreria di array che vuoi davvero è PyTorch (o JAX). Entrambe implementano una API compatibile con NumPy sopra i loro tipi tensor, ed entrambe girano su GPU. torch.tensor(arr) fa round-trip con NumPy in microsecondi, quindi il workflow “preprocessa in NumPy, poi sposta in torch per il modello” è quello a cui assomigliano davvero la maggior parte delle pipeline. La buona notizia: tutto quello che hai imparato in questa lezione (broadcasting, assi, reshape, slicing) si trasferisce direttamente. Le due librerie hanno divergiuto in API un decennio fa e da allora stanno tranquillamente convergendo.

Le prossime due lezioni assumono che tu abbia quei numeri. La lezione 44 li plotta; la lezione 45 ci fa girare sopra statistiche, ottimizzazione e signal processing. NumPy è il substrato sotto a entrambe.


Riferimento: documentazione NumPy, recuperata il 2026-05-01. Release notes di NumPy 2.x per i cambiamenti di dtype e le API rimosse.

Cerca