👉 Vai al Tutorial Numpy Completo
Manipolazione Avanzata degli Array e Broadcasting
In questa parte di Tutorial, approfondiremo concetti più avanzati che ti permetteranno di manipolare gli array NumPy con maggiore flessibilità ed efficienza. Il broadcasting è una delle funzionalità più potenti e, a volte, più elusive di NumPy, mentre l’indicizzazione booleana e l’ordinamento sono strumenti indispensabili per l’analisi dei dati.
—
Concetto di Broadcasting: Spiegazione e Esempi
Il broadcasting è un meccanismo che permette a NumPy di lavorare con array di diverse forme quando si eseguono operazioni aritmetiche. Invece di generare un errore di “forme non corrispondenti”, NumPy tenta di “estendere” l’array più piccolo lungo le dimensioni mancanti o con dimensione 1, in modo che le forme diventino compatibili. Questo avviene senza creare copie aggiuntive in memoria, rendendo le operazioni molto efficienti.
Regole del Broadcasting:
Perché due array siano compatibili per il broadcasting, le loro dimensioni devono essere compatibili, partendo dall’ultima dimensione e andando all’indietro. Due dimensioni sono compatibili se:
- Sono uguali, OPPURE
- Una delle due è 1.
Se una delle condizioni non è soddisfatta, si verifica un errore ValueError.
Esempio 1: Scalare e Array
Questo è il caso più semplice di broadcasting, che abbiamo già visto implicitamente. Un singolo valore (scalare) viene “esteso” per corrispondere alla forma dell’array.
import numpy as np
ar = np.array([1, 2, 3])
scalare = 5
risultato = ar + scalare
print(f"Array: {ar}")
print(f"Scalare: {scalare}")
print(f"Risultato (ar + scalare): {risultato}\n")
# Spiegazione: 5 viene "esteso" a [5, 5, 5] e poi sommato elemento per elemento.
Array: [1 2 3]
Scalare: 5
Risultato (ar + scalare): [6 7 8]
Questo esempio è il punto di partenza più accessibile per comprendere il broadcasting. Mostra come una singola operazione su un valore costante possa essere applicata a un intero array senza la necessità di cicli espliciti.
È alla base di molte operazioni comuni come la normalizzazione dei dati (es. dividere tutti gli elementi di un array per un valore costante) o la conversione di unità di misura. Rende il codice più pulito e veloce rispetto a un’implementazione Python pura con un ciclo for.
Esempio 2: Array 1D e Array 2D
import numpy as np
matrice = np.array([
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]]) # Forma (3, 3)
vettore = np.array([10, 20, 30]) # Forma (3,)
risultato = matrice + vettore
print(f"Matrice:\n{matrice}")
print(f"Vettore: {vettore}")
print(f"Risultato (matrice + vettore):\n{risultato}\n")
# Spiegazione: Il vettore (3,) viene "esteso" a una forma (1, 3) e poi replicato lungo l'asse 0 per diventare (3, 3).
# Quindi, [10, 20, 30] viene sommato a ogni riga della matrice.
Matrice:
[[1 2 3]
[4 5 6]
[7 8 9]]
Vettore: [10 20 30]
Risultato (matrice + vettore):
[[11 22 33]
[14 25 36]
[17 28 39]]
Questo caso illustra il broadcasting tra un vettore e una matrice. Il vettore viene “allungato” per corrispondere a ciascuna riga della matrice.
Molto utile in situazioni dove si vuole applicare un “offset” o una “correzione” specifica a ogni riga di un dataset (rappresentato dalla matrice). Ad esempio, se ogni riga di una matrice rappresenta una misurazione e il vettore contiene un errore sistematico da sottrarre per ogni colonna. È un pattern comune nell’elaborazione di immagini o dati scientifici dove un “filtro” o un “bias” viene applicato uniformemente lungo una dimensione.
Esempio 3: Array 2D con dimensioni diverse ma compatibili
import numpy as np
A = np.array([[1, 2, 3],
[4, 5, 6]]) # Forma (2, 3)
B = np.array([[10],
[20]]) # Forma (2, 1)
risultato = A + B
print(f"A:\n{A}")
print(f"B:\n{B}")
print(f"Risultato (A + B):\n{risultato}\n")
# Spiegazione:
# A ha forma (2, 3)
# B ha forma (2, 1)
# Partendo da destra:
# Dimensione 1: 3 e 1 sono compatibili (una è 1). B viene esteso a (2, 3) replicando la colonna.
# Dimensione 0: 2 e 2 sono uguali. Compatibili.
# B diventa [[10, 10, 10], [20, 20, 20]] per l'operazione.
A:
[[1 2 3]
[4 5 6]]
B:
[[10]
[20]]
Risultato (A + B):
[[11 12 13]
[24 25 26]]
Questo esempio mostra un caso più avanzato di broadcasting dove un array (2, 1) viene esteso per operare con un array (2, 3). È un’ottima dimostrazione delle regole di compatibilità.
E’ il cuore del broadcasting multi-dimensionale. È fondamentale quando si lavora con matrici di trasformazione, applicando coefficienti diversi a righe o colonne specifiche senza duplicare esplicitamente i dati. Immagina di voler aggiungere un valore diverso a ciascuna colonna di una matrice, o di combinare matrici con dimensioni “compatibili” per operazioni complesse. È un potente strumento per ridurre il consumo di memoria e ottimizzare le performance in calcoli intensivi.
Esempio 4: Caso di non compatibilità
import numpy as np
A = np.array([[1, 2],
[3, 4]]) # Forma (2, 2)
B = np.array([10, 20, 30]) # Forma (3,)
try:
risultato = A + B
except ValueError as e:
print(f"Errore di Broadcasting: {e}\n")
# Spiegazione:
# A ha forma (2, 2)
# B ha forma (3,)
# Partendo da destra:
# Dimensione 1: 2 e 3 NON sono compatibili (non sono uguali e nessuna è 1).
# Quindi, si verifica un errore.
Questo esempio è cruciale perché illustra chiaramente quando il broadcasting fallisce e perché.
Sottolinea l’importanza di comprendere le forme degli array e le regole del broadcasting per evitare errori comuni. In un contesto applicativo, un ValueError di broadcasting indica spesso un’errata assunzione sulla struttura dei dati o un’operazione non allineata alle aspettative. È un debugging tool implicito che guida verso una migliore strutturazione del codice e dei dati.
Strategia Risolutiva (Visualizzare il Broadcasting):
Per capire il broadcasting, immagina che NumPy aggiunga dimensioni di dimensione 1 sul lato sinistro dell’array con meno dimensioni finché entrambi gli array non hanno lo stesso numero di dimensioni. Poi, per ogni dimensione, controlla la compatibilità. Se una dimensione è 1, quell’array viene “allungato” (broadcast) per corrispondere alla dimensione dell’altro array.
—
Operazioni Logiche e Booleane
NumPy permette di eseguire operazioni di confronto elemento per elemento, che restituiscono array di valori booleani (True o False). Questi array booleani sono estremamente utili per filtrare o selezionare dati.
>(maggiore di)<(minore di)==(uguale a)!=(diverso da)>=(maggiore o uguale a)<=(minore o uguale a)
Per combinare condizioni booleane, non si usano and, or, not di Python, ma gli operatori bitwise di NumPy:
&(AND logico)|(OR logico)~(NOT logico)
import numpy as np
ar = np.array([10, 20, 30, 40, 50])
# Confronto semplice
condizione1 = ar > 25
print(f"Array: {ar}")
print(f"ar > 25: {condizione1}\n")
# Confronto con un altro array (stessa forma)
ar2 = np.array([5, 30, 25, 45, 50])
condizione2 = ar == ar2
print(f"ar == ar2: {condizione2}\n")
# Combinazione di condizioni con operatori booleani di NumPy
condizione_complessa = (ar > 20) & (ar < 50) print(f"(ar > 20) & (ar < 50): {condizione_complessa}\n")
condizione_or = (ar == 10) | (ar == 40)
print(f"(ar == 10) | (ar == 40): {condizione_or}\n")
condizione_not = ~(ar < 30)
print(f"~(ar < 30): {condizione_not}\n")
Array: [10 20 30 40 50]
ar > 25: [False False True True True]
ar == ar2: [False False False False True]
(ar > 20) & (ar < 50): [False False True True False]
(ar == 10) | (ar == 40): [ True False False True False]
~(ar < 30): [False False True True True]
Indicizzazione Booleana
L’indicizzazione booleana è una tecnica potentissima per selezionare elementi da un array basandosi su una condizione. Si passa un array booleano (della stessa forma dell’array originale) come indice, e NumPy restituisce solo gli elementi corrispondenti a True.
import numpy as np
dati = np.array([10, 15, 20, 25, 30, 35, 40, 45, 50])
# Selezionare elementi maggiori di 30
maschera_booleana = dati > 30
print(f"Dati: {dati}")
print(f"Maschera booleana (dati > 30): {maschera_booleana}")
elementi_selezionati = dati[maschera_booleana]
print(f"Elementi selezionati (dati[dati > 30]): {elementi_selezionati}\n")
# Esempio più compatto
elementi_pari = dati[dati % 2 == 0]
print(f"Elementi pari: {elementi_pari}\n")
# Indicizzazione booleana con matrici
matrice = np.array([
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]])
# Selezionare elementi maggiori di 5
maschera_matrice = matrice > 5
print(f"Matrice:\n{matrice}")
print(f"Maschera booleana (matrice > 5):\n{maschera_matrice}")
elementi_grandi = matrice[maschera_matrice]
print(f"Elementi maggiori di 5: {elementi_grandi}\n")
# Nota: il risultato è sempre un array 1D, anche se l'input era 2D.
# Modificare elementi basandosi su una condizione
matrice[matrice % 2 != 0] = 0 # Imposta a 0 tutti gli elementi dispari
print(f"Matrice con elementi dispari impostati a 0:\n{matrice}\n")
Dati: [10 15 20 25 30 35 40 45 50]
Maschera booleana (dati > 30): [False False False False False True True True True]
Elementi selezionati (dati[dati > 30]): [35 40 45 50]
Elementi pari: [10 20 30 40 50]
Matrice:
[[1 2 3]
[4 5 6]
[7 8 9]]
Maschera booleana (matrice > 5):
[[False False False]
[False False True]
[ True True True]]
Elementi maggiori di 5: [6 7 8 9]
Matrice con elementi dispari impostati a 0:
[[0 2 0]
[4 0 6]
[0 8 0]]
Osservazione Strategica: L’indicizzazione booleana è uno strumento estremamente versatile per la pulizia, la selezione e la modifica dei dati. È molto più efficiente dei cicli for per queste operazioni.
—
Ordinamento degli Array
NumPy offre diverse funzioni per ordinare gli array, sia in-place che restituendo una copia ordinata.
np.sort(array, axis=-1): Restituisce una copia ordinata dell’array. L’argomentoaxisspecifica l’asse lungo cui ordinare. Seaxis=None(default), l’array viene appiattito e ordinato.array.sort(axis=-1): Ordina l’array in-place (modifica l’array originale) e restituisceNone.np.argsort(array, axis=-1): Restituisce gli indici che ordinerebbero l’array. Questo è utile se hai bisogno di riorganizzare un altro array in base all’ordinamento di questo.
import numpy as np
ar = np.array([3, 1, 4, 1, 5, 9, 2, 6])
# Ordinamento con np.sort() (non modifica l'originale)
ordinato = np.sort(ar)
print(f"Array originale: {ar}")
print(f"Array ordinato (np.sort): {ordinato}\n")
# Ordinamento in-place con .sort()
ar_inplace = np.array([3, 1, 4, 1, 5, 9, 2, 6])
ar_inplace.sort()
print(f"Array originale (dopo .sort()): {ar_inplace}\n")
# Ordinamento di matrici
matrice = np.array([
[0, 5, 2],
[8, 1, 7],
[3, 9, 4]])
print(f"Matrice originale:\n{matrice}\n")
# Ordinamento per riga (axis=1)
ordinato_righe = np.sort(matrice, axis=1)
print(f"Matrice ordinata per riga (axis=1):\n{ordinato_righe}\n")
# Ordinamento per colonna (axis=0)
ordinato_colonne = np.sort(matrice, axis=0)
print(f"Matrice ordinata per colonna (axis=0):\n{ordinato_colonne}\n")
# np.argsort(): ottenere gli indici di ordinamento
indici_ordinamento = np.argsort(ar)
print(f"Array originale: {ar}")
print(f"Indici di ordinamento: {indici_ordinamento}")
print(f"Array ordinato usando indici: {ar[indici_ordinamento]}\n")
# Esempio pratico di argsort: ordinare un array in base a un altro
nomi = np.array(['Alice', 'Bob', 'Charlie', 'David'])
età = np.array([25, 30, 22, 28])
# Ordina i nomi in base all'età
indici_eta_ordinata = np.argsort(età)
nomi_ordinati_per_eta = nomi[indici_eta_ordinata]
eta_ordinata = età[indici_eta_ordinata]
print(f"Nomi: {nomi}")
print(f"Età: {età}")
print(f"Nomi ordinati per età: {nomi_ordinati_per_eta}")
print(f"Età ordinata: {eta_ordinata}\n")
Array originale: [3 1 4 1 5 9 2 6]
Array ordinato (np.sort): [1 1 2 3 4 5 6 9]
Array originale (dopo .sort()): [1 1 2 3 4 5 6 9]
Matrice originale:
[[0 5 2]
[8 1 7]
[3 9 4]]
Matrice ordinata per riga (axis=1):
[[0 2 5]
[1 7 8]
[3 4 9]]
Matrice ordinata per colonna (axis=0):
[[0 1 2]
[3 5 4]
[8 9 7]]
Array originale: [3 1 4 1 5 9 2 6]
Indici di ordinamento: [1 3 6 0 2 4 7 5]
Array ordinato usando indici: [1 1 2 3 4 5 6 9]
Nomi: [‘Alice’ ‘Bob’ ‘Charlie’ ‘David’]
Età: [25 30 22 28]
Nomi ordinati per età: [‘Charlie’ ‘Alice’ ‘David’ ‘Bob’]
Età ordinata: [22 25 28 30]
Copie e Viste degli Array
Questo concetto è stato brevemente introdotto nel
Tutorial NumPy: Creare, Indicizzare e Manipolare Array in Python, ma è così importante che merita un ulteriore richiamo. Ricorda che lo slicing in NumPy crea una vista dell’array originale, il che significa che le modifiche alla vista si riflettono sull’array originale. Per ottenere una copia indipendente, devi usare esplicitamente il metodo .copy().
import numpy as np
originale = np.array([1, 2, 3, 4, 5])
vista = originale[1:4] # Vista
copia = originale[1:4].copy() # Copia indipendente
print(f"Originale: {originale}")
print(f"Vista: {vista}")
print(f"Copia: {copia}\n")
vista[0] = 99 # Modifica la vista
copia[0] = 100 # Modifica la copia
print(f"Dopo le modifiche:")
print(f"Originale: {originale}") # Originale è cambiato a causa della vista
print(f"Vista: {vista}")
print(f"Copia: {copia}\n")
Originale: [1 2 3 4 5]
Vista: [2 3 4]
Copia: [2 3 4]
Dopo le modifiche:
Originale: [ 1 99 3 4 5]
Vista: [99 3 4]
Copia: [100 3 4]
Ricorda: La distinzione tra viste e copie è una fonte comune di errori per i principianti in NumPy. Sii sempre consapevole se stai lavorando su una vista o su una copia, specialmente quando modifichi gli array.
Questo conclude il presente tutorial. Hai imparato a sfruttare il potente meccanismo di broadcasting, a eseguire operazioni logiche e a filtrare dati con l’indicizzazione booleana, e a ordinare gli array. Queste tecniche sono fondamentali per la manipolazione efficiente dei dati in NumPy. Nel prossimo, ci addentreremo nel mondo dell’algebra lineare con NumPy.





