2.2.2 - Gli Array¶
Nella lezione precedente abbiamo introdotto il concetto di array, la struttura dati "centrale" nell'ecosistema di NumPy. In questa lezione esploreremo come crearli, ispezionarli e manipolarli in modo efficiente.
Creazione di un Array¶
Il metodo più immediato per creare un array è l'uso del costruttore np.array() a cui passare una lista Python:
Passando una lista di liste, otteniamo un array bidimensionale (una matrice):
Upcasting (Conversione automatica)¶
A differenza delle liste, gli array NumPy devono contenere dati omogenei. Cosa succede se passiamo una lista con tipi misti, come un intero e un decimale?
NumPy applica il principio dell'upcasting: converte automaticamente tutti gli elementi al tipo di dato più "capiente" per evitare perdita di informazioni. In questo caso, l'intero 1 è diventato il float 1.0.
Se mescoliamo numeri e stringhe, tutto diventerà una stringa:
Ispezionare un Array (Shape, Ndim, Size)¶
Quando lavoriamo con dataset complessi (es. batch di immagini), è fondamentale conoscere la geometria dei nostri dati. NumPy offre tre attributi fondamentali:
>>> matrice = np.array([[1, 2, 3],[4, 5, 6]])
>>> matrice.ndim # Numero di dimensioni (Assi)
2
>>> matrice.shape # La forma: (righe, colonne)
(2, 3)
>>> matrice.size # Numero totale di elementi (2 * 3)
6
Altri metodi di creazione¶
Oltre a convertire liste esistenti, NumPy offre funzioni ottimizzate per generare array da zero (utilissime per preallocare memoria o creare dati fittizi).
Zeri, Uni e Vuoti¶
# Array 3x3 riempito di zeri
>>> z = np.zeros((3, 3))
# Array 2x4 riempito di uni
>>> u = np.ones((2, 4))
# Array non inizializzato (estremamente veloce da creare,
# contiene i valori "spazzatura" già presenti in quel blocco di RAM)
>>> e = np.empty((2, 2))
Generazione di sequenze (arange e linspace)¶
In Data Science, queste due funzioni sono onnipresenti per generare assi temporali o griglie di coordinate:
# Come il range() di Python, ma genera un array e accetta numeri decimali
>>> np.arange(0, 10, 2) # (start, stop_escluso, step)
array([0, 2, 4, 6, 8])
# Genera N numeri equamente distanziati tra due estremi
>>> np.linspace(0, 1, 5) # (start, stop_incluso, numero_di_elementi)
array([0. , 0.25, 0.5 , 0.75, 1. ])
Accesso agli elementi (Indexing)¶
L'Anti-pattern [ ][ ]
In Python standard, per accedere a una lista di liste scriveremmo lista[0][1]. In NumPy, questa sintassi è un grave errore di performance perché forza la creazione in memoria di un array temporaneo per la prima riga prima di estrarre la colonna.
La sintassi corretta, veloce e "NumPy-onica" utilizza la virgola per separare le dimensioni: array[riga, colonna].
>>> b = np.array([[10, 20, 30],[40, 50, 60]])
# Voglio l'elemento alla prima riga (indice 0) e seconda colonna (indice 1)
>>> b[0, 1]
20
Slicing Multi-dimensionale¶
Lo slicing in NumPy (start:stop:step) è potentissimo perché agisce simultaneamente su più dimensioni. È la tecnica alla base del ritaglio di immagini (cropping) nella Computer Vision.
>>> b = np.array([[1, 2, 3],
[4, 5, 6],[7, 8, 9]])
# Voglio le prime due righe (0:2), ma solo le ultime due colonne (1:)
>>> b[0:2, 1:]
array([[2, 3],[5, 6]])
# Voglio TUTTE le righe (:), ma solo la colonna centrale (1)
>>> b[:, 1]
array([2, 5, 8])
Maschere Booleane (Masking)¶
Possiamo estrarre sottoinsiemi di dati applicando condizioni logiche. Questo processo crea una "maschera" di True e False.
>>> b = np.array([1, 2, 3, 4, 5, 6])
# Quali elementi sono maggiori di 3?
>>> mask = b > 3
>>> mask
array([False, False, False, True, True, True])
# Applichiamo la maschera per estrarre i dati
>>> b[mask]
array([4, 5, 6])
# Forma compatta
>>> b[b > 3]
array([4, 5, 6])
Operatori Logici in NumPy
Per combinare più condizioni sulle maschere, non possiamo usare le keyword di Python and, or, not. Dobbiamo usare gli operatori bit-a-bit & (AND), | (OR), ~ (NOT), assicurandoci di mettere le condizioni tra parentesi tonde:
Sostituzione condizionale: np.where¶
Spesso non vogliamo solo estrarre dati, ma modificarli in base a una condizione (es. "imposta a 0 tutti i valori negativi"). La funzione np.where(condizione, valore_se_vero, valore_se_falso) è la soluzione standard:
Fancy Indexing¶
Chiudiamo con una tecnica avanzata chiamata fancy indexing, che permette di usare un array di indici interi per estrarre elementi in un ordine specifico o ripetuto.
Per dimostrarlo, generiamo prima dei dati casuali usando l'API moderna di NumPy per la riproducibilità (default_rng):
# Inizializziamo il generatore di numeri casuali (seed=42 per riproducibilità)
>>> rng = np.random.default_rng(42)
# Generiamo 10 numeri interi tra 0 e 100
>>> x = rng.integers(0, 100, size=10)
>>> x
array([ 8, 77, 65, 43, 43, 85, 8, 69, 20, 9])
# Vogliamo estrarre gli elementi agli indici 1, 5 e 2, in quest'ordine
>>> indici = np.array([1, 5, 2])
>>> x[indici]
array([77, 85, 65])
La potenza del fancy indexing sta nel fatto che la forma (shape) dell'array risultante non dipende dai dati di origine, ma dall'array degli indici: