Hai mai provato a moltiplicare per due tutti i numeri di una lista scrivendo lista * 2? Se sì, sai già come va a finire: Python non raddoppia i valori, duplica la lista. Ti aspettavi [2, 4, 6] e ti ritrovi [1, 2, 3, 1, 2, 3]. Non è un bug: è il segnale che le liste non sono nate per fare matematica. In questa lezione capiamo perché, conosciamo l’oggetto che invece per la matematica è nato — l’array NumPy — e misuriamo con un cronometro vero quanto è più veloce.
L’ndarray, indicizzazione e slicing, broadcasting e vettorizzazione sono trattati nel libro.
Due richiami rapidi prima di partire. Se non ricordi cos’è una lista, ti basta sapere che è il contenitore ordinato e modificabile di Python, quello con le parentesi quadre. E se non hai ancora un ambiente pronto: Python 3.12 in un ambiente virtuale (python -m venv .venv oppure, con lo strumento più moderno, uv venv), editor a scelta tra VS Code e PyCharm. Installare NumPy è una riga:
# Con pip, dentro l'ambiente virtuale attivato:
pip install numpy
# Oppure, se usi uv (più veloce, stessa sintassi):
uv pip install numpy
Il cassetto dei cavi e la cassettiera a scomparti
Una lista Python è come il cassetto dei cavi che tutti abbiamo in casa: dentro ci può finire di tutto — un caricabatterie, un numero, una stringa, un’altra lista — e ogni oggetto ha forma e dimensione diversa. Per permettere questa libertà, Python non mette i valori nel cassetto: mette dei bigliettini con l’indirizzo di dove ogni oggetto si trova davvero in memoria. Tecnicamente si chiamano puntatori, e gli oggetti veri sono sparpagliati un po’ ovunque nella RAM.
Un array NumPy è invece una cassettiera a scomparti identici: ogni cella ha la stessa dimensione, contiene direttamente il valore (non un bigliettino) e le celle sono una attaccata all’altra, in un unico blocco di memoria contigua. Il prezzo da pagare è la disciplina: in un array tutti gli elementi devono avere lo stesso tipo, il dtype. Il guadagno è enorme, e ora vediamo perché.
La lista conserva puntatori a oggetti sparsi per la memoria; l’array NumPy impacchetta i valori uno accanto all’altro. È da questa differenza che nasce tutta la velocità.
Il primo array: stesso tipo, tanta velocità
Creare un array è immediato: gli passi una lista e NumPy la impacchetta.
import numpy as np
# Da lista ad array: NumPy sceglie il dtype guardando i valori
voti = np.array([27, 30, 24, 29])
print(voti.dtype) # int64: interi a 64 bit, tutti uguali
# La matematica ora funziona come te la aspetti
print(voti * 2) # [54 60 48 58] — moltiplica i VALORI
print(voti + 1) # [28 31 25 30] — un punto in più a tutti
print(voti.mean()) # 27.5 — la media, senza cicli
# La disciplina del dtype: se mischi, NumPy converte tutto
misto = np.array([1, 2, 3.5])
print(misto.dtype) # float64: gli interi sono stati "promossi"
Nota l’ultima riga: metti un solo numero con la virgola e l’intero array diventa float64. Nella cassettiera gli scomparti devono essere tutti uguali, quindi NumPy sceglie il tipo più capiente in grado di contenere tutto. Se ci infili una stringa, l’array diventa un array di stringhe e addio matematica: tienilo a mente quando carichi dati sporchi.
Il cronometro non mente: misuriamo
Le promesse sono belle, i numeri di più. Confrontiamo la somma di dieci milioni di valori fatta con una lista e con un array, usando timeit della libreria standard:
import numpy as np
from timeit import timeit
N = 10_000_000
lista = list(range(N)) # lista Python di 10 milioni di interi
array = np.arange(N) # stesso contenuto, ma array NumPy
# Somma con la lista: Python scorre 10 milioni di oggetti uno a uno
t_lista = timeit(lambda: sum(lista), number=10)
# Somma con l'array: un'unica chiamata a codice C ottimizzato
t_array = timeit(lambda: array.sum(), number=10)
print(f"Lista: {t_lista:.3f} s")
print(f"Array: {t_array:.3f} s")
print(f"Speedup: {t_lista / t_array:.0f}x")
# E la memoria? Confrontiamo i byte occupati dai soli dati
print(f"Array: {array.nbytes / 1e6:.0f} MB") # ~80 MB, 8 byte a intero
# La lista occupa diverse volte tanto: ogni intero è un oggetto
# Python completo (~28 byte) più il puntatore che lo indica (8 byte)
Su questa somma lo speedup è di circa un ordine di grandezza — attorno a 8x sulla mia macchina, con oscillazioni a seconda dell’hardware — ma la differenza netta la vedrai anche tu. Il motivo è doppio. Primo: array.sum() non è un ciclo Python, è una sola chiamata a codice C compilato che macina i valori senza passare dall’interprete. Secondo: la memoria contigua. La CPU non legge la RAM un byte alla volta, legge a blocchi, come un carrello della spesa: se i prodotti che ti servono sono tutti sullo stesso scaffale (memoria contigua) riempi il carrello in un passaggio; se sono sparsi per il supermercato (la lista coi suoi puntatori) fai avanti e indietro per ogni singolo articolo.
La velocità di NumPy non è magia: è la somma di due scelte precise — valori dello stesso tipo, uno accanto all’altro — che permettono alla CPU di lavorare come le piace lavorare.
Questo modo di scrivere — niente for, operazioni applicate all’array intero — si chiama vettorizzazione, ed è l’abitudine numero uno da costruire: quasi tutto scikit-learn, pandas e il deep learning moderno poggiano su questa idea.
Onestà: quando la lista resta la scelta giusta
Sarebbe disonesto venderti NumPy come sostituto universale. La lista vince ancora quando:
- I dati sono eterogenei: nomi, date e importi insieme stanno bene in una lista (o meglio ancora in un dizionario o un DataFrame, che vedremo con pandas).
- La collezione cresce e si accorcia di continuo:
appendsu una lista è velocissimo; «appendere» a un array NumPy crea ogni volta un array nuovo copiando tutto. Se accumuli risultati in un ciclo, accumula in una lista e converti in array alla fine. - Gli elementi sono pochi: per venti numeri il costo di importare e creare l’array supera il guadagno. La vettorizzazione paga sulle migliaia, brilla sui milioni.
La regola pratica: tanti valori, stesso tipo, tanta matematica → array. Pochi valori, tipi misti, tante modifiche strutturali → lista.
Prova tu
Simula le temperature orarie di un anno intero: genera 8760 valori con np.random.default_rng(42).normal(15, 8, 8760) (media 15 gradi, deviazione 8). Poi, senza scrivere nemmeno un for: converti tutto in Fahrenheit (F = C * 9/5 + 32), calcola media, minimo e massimo dell’anno, e conta quante ore sono state sotto zero (suggerimento: (temperature < 0).sum() — il confronto restituisce un array di True/False, e la somma conta i True). Per finire, rifai il conteggio delle ore sotto zero con una lista e un ciclo, cronometra entrambe le versioni con timeit e guarda la differenza coi tuoi occhi.
Per approfondire: la guida ufficiale per principianti NumPy: the absolute basics for beginners è scritta benissimo, e se vuoi capire quanto NumPy sia il fondamento di tutto l’ecosistema scientifico Python, il paper Array programming with NumPy (Harris et al., Nature, 2020) racconta la storia e l’architettura della libreria. Nella prossima lezione mettiamo le mani sugli array sul serio: indexing, slicing, reshape e operazioni vettoriali.

I commenti sono riservati agli iscritti.
Accedi per commentare