Quando si inizia a usare NumPy, uno dei concetti più importanti da comprendere è quello delle ufunc. Questo termine compare spesso nella documentazione ufficiale e in molte guide pratiche, perché rappresenta uno dei pilastri del lavoro con gli array in Python. Capire bene come funzionano le ufunc significa scrivere codice più pulito, più rapido e spesso anche più leggibile.
In questa guida firmata Codegrind vedremo in modo semplice che cosa sono le ufunc di NumPy, perché vengono considerate così centrali e in che modo si collegano al concetto di vectorization. L’obiettivo è offrire un’introduzione chiara anche a chi è alle prime armi, senza rinunciare alla precisione tecnica necessaria per costruire basi solide.
Cosa sono le ufunc in NumPy e come funzionano
Il termine ufunc è l’abbreviazione di universal function. In NumPy, una ufunc è una funzione progettata per eseguire un’operazione su uno o più elementi di un array in modo efficiente. In pratica, invece di scorrere i valori uno per uno con un ciclo scritto a mano, si può applicare direttamente una funzione all’intero array.
Per esempio, operazioni molto comuni come la somma, la sottrazione, il valore assoluto, il seno, il coseno o l’elevamento a potenza vengono gestite da ufunc ottimizzate. Questo permette di lavorare con grandi quantità di dati con un approccio molto più naturale rispetto alle liste Python tradizionali.
Un primo esempio aiuta a chiarire subito l’idea.
import numpy as np
numeri = np.array([1, 2, 3, 4])
risultato = np.sqrt(numeri)
print(risultato)
In questo caso, np.sqrt è una ufunc. Non si limita a lavorare su un singolo numero: prende l’intero array e calcola la radice quadrata di ciascun elemento. Il risultato è un nuovo array con i valori trasformati.
Le ufunc possono essere di due tipi molto comuni. Le unary ufunc lavorano su un solo input, come np.sqrt, np.exp o np.sin. Le binary ufunc, invece, lavorano su due input, come np.add, np.multiply o np.maximum.
import numpy as np
a = np.array([1, 2, 3])
b = np.array([4, 5, 6])
somma = np.add(a, b)
prodotto = np.multiply(a, b)
print(somma)
print(prodotto)
Naturalmente, in NumPy molte di queste operazioni si possono scrivere anche con gli operatori classici come + e *, ma dietro le quinte entra spesso in gioco proprio il meccanismo delle ufunc.
Un aspetto molto interessante è che le ufunc supportano il broadcasting, cioè la capacità di applicare operazioni tra array con forme compatibili senza dover replicare manualmente i dati. È una caratteristica potente, anche se all’inizio conviene assimilarla con calma.
import numpy as np
a = np.array([1, 2, 3])
b = 10
risultato = np.add(a, b)
print(risultato)
Qui NumPy aggiunge il valore 10 a ogni elemento dell’array, senza bisogno di un ciclo esplicito. Questo stile di lavoro è proprio uno dei motivi per cui NumPy viene utilizzato così spesso in ambito scientifico, analitico e nel machine learning.
Perché conviene usare le ufunc di NumPy nei progetti Python
Usare le ufunc non è soltanto una questione di comodità. Il loro vero vantaggio emerge quando si lavora con array numerici di dimensioni medio-grandi. In questi casi, affidarsi a funzioni ottimizzate di NumPy è in genere molto più efficiente rispetto a scrivere cicli for in Python puro.
Il primo beneficio è la velocità. Le ufunc sono implementate in modo da sfruttare routine interne altamente ottimizzate. Per questo motivo riescono a elaborare molti dati in tempi ridotti.
Il secondo beneficio è la leggibilità. Un’operazione espressa con una ufunc risulta spesso più immediata da comprendere rispetto a un blocco di codice con accumuli, condizioni e iterazioni manuali.
Il terzo beneficio è la riduzione degli errori. Meno codice ripetitivo significa anche meno occasioni per introdurre bug banali, soprattutto quando si sta imparando.
Proviamo a confrontare un approccio classico con uno basato su NumPy. Supponiamo di voler calcolare il quadrato di una serie di valori.
Con un ciclo Python si potrebbe scrivere così:
numeri = [1, 2, 3, 4]
quadrati = []
for numero in numeri
{
quadrati.append(numero ** 2)
}
print(quadrati)
Con NumPy, invece, l’operazione diventa molto più diretta:
import numpy as np
numeri = np.array([1, 2, 3, 4])
quadrati = np.square(numeri)
print(quadrati)
Il secondo esempio è più compatto, più espressivo e rispecchia meglio il modo in cui si lavora con strutture dati numeriche. Inoltre, in contesti reali con array molto più grandi, la differenza di prestazioni può essere significativa.
Un altro punto forte riguarda la coerenza. Una volta compreso il comportamento delle ufunc, molte operazioni NumPy iniziano a sembrare parte di un unico linguaggio operativo. Questo rende più semplice leggere la documentazione ufficiale e passare da un’operazione all’altra senza sentirsi disorientati.
Vectorization con NumPy: perché è un concetto chiave
Parlando di ufunc, prima o poi si incontra inevitabilmente il termine vectorization. In NumPy, la vectorization consiste nello scrivere operazioni che agiscono direttamente sugli array, evitando per quanto possibile i cicli espliciti in Python.
Non significa soltanto “fare le stesse cose più in fretta”. Significa adottare un modo di pensare più adatto al calcolo numerico: invece di ragionare elemento per elemento, si ragiona su blocchi di dati.
Facciamo un esempio molto semplice. Immaginiamo di voler sommare due insiemi di numeri corrispondenti.
import numpy as np
a = np.array([10, 20, 30])
b = np.array([1, 2, 3])
risultato = a + b
print(risultato)
Questa è vectorization: un’unica istruzione produce l’operazione su tutti gli elementi compatibili. Non c’è bisogno di scrivere un ciclo, né di gestire manualmente gli indici. Il codice resta corto, leggibile e molto vicino al significato matematico dell’operazione.
La vectorization si appoggia proprio alle ufunc. Quando si scrive un’espressione come a + b, NumPy utilizza internamente una funzione universale per elaborare i dati. Per questo motivo i due concetti sono strettamente collegati.
Un altro esempio utile è l’applicazione di una condizione a un intero array.
import numpy as np
valori = np.array([5, 12, 7, 18, 3])
maschera = valori > 10
print(maschera)
Anche il confronto valori > 10 viene gestito in modo vettoriale. Il risultato non è un singolo valore booleano, ma un array di True e False che descrive la verifica elemento per elemento. Questo meccanismo è alla base di molte tecniche di filtraggio e analisi dati in NumPy.
Per chi inizia, il consiglio migliore è abituarsi presto a questo approccio. Se viene spontaneo scrivere un ciclo, vale la pena chiedersi: “Posso farlo direttamente con una ufunc o con un’operazione vettoriale?”. Spesso la risposta è sì, ed è proprio lì che NumPy dà il meglio di sé.
Esempi pratici per capire meglio le ufunc e la vectorization
Vediamo ora alcuni piccoli esempi concreti, utili per fissare i concetti principali.
Calcolare il valore assoluto
import numpy as np
valori = np.array([-3, -1, 0, 2, 4])
assoluti = np.abs(valori)
print(assoluti)
Sommare una costante a tutti gli elementi
import numpy as np
valori = np.array([1, 2, 3])
nuovi_valori = valori + 5
print(nuovi_valori)
Combinare più operazioni in modo compatto
import numpy as np
valori = np.array([1, 4, 9, 16])
risultato = np.sqrt(valori) + 2
print(risultato)
Questi esempi mostrano bene una delle qualità più apprezzate di NumPy: la possibilità di trasformare, confrontare e combinare dati numerici con sintassi chiara e risultati immediati.
Errori comuni da evitare quando si iniziano a usare le ufunc
Chi muove i primi passi con NumPy incontra spesso alcune difficoltà ricorrenti. La prima è confondere le liste Python con gli array NumPy. Le ufunc lavorano al meglio con gli array NumPy, mentre le liste seguono logiche diverse.
Per esempio, questa operazione con una lista non produce una somma elemento per elemento:
lista = [1, 2, 3]
print(lista * 2)
Con un array NumPy, invece, il comportamento è numerico:
import numpy as np
array = np.array([1, 2, 3])
print(array * 2)
Un altro errore comune è ignorare la shape degli array. Se si tenta di combinare strutture non compatibili, NumPy può restituire un errore legato al broadcasting. È quindi utile controllare sempre forma e dimensioni dei dati quando un’operazione non si comporta come previsto.
Infine, c’è la tendenza a usare cicli Python anche quando non servono. È una fase normale dell’apprendimento, ma con un po’ di pratica si impara a riconoscere le situazioni in cui una ufunc rende il codice più efficace e più elegante.