NumPy#

La Standard Library di Python mette a disposizione un’ampia gamma di funzioni utili per l’analisi dei dati. Tuttavia, per un’analisi più approfondita ed efficiente, è raccomandato avvalersi di funzioni specifiche disponibili in altri moduli esterni. Tra questi, i più rilevanti per l’analisi dei dati includono NumPy, Pandas, Matplotlib e Seaborn.

NumPy, abbreviazione di Numerical Python, rappresenta un’estensione del linguaggio Python focalizzata sul calcolo algebrico e matriciale. Questo modulo è essenziale per la maggior parte delle operazioni di calcolo numerico in Python, poiché introduce strutture dati avanzate progettate per l’elaborazione efficiente di vettori, matrici e insiemi di dati di grandi dimensioni. Beneficiando dell’implementazione in linguaggi ad alte prestazioni come C e Fortran, NumPy offre un’elevata efficienza, specialmente in operazioni vettorializzate che sfruttano vettori e matrici per formulare calcoli. Parallelamente, Pandas si afferma come strumento fondamentale per l’importazione e la manipolazione dei dati, mentre Matplotlib e Seaborn si distinguono per le loro capacità di visualizzazione, facilitando notevolmente la creazione di rappresentazioni grafiche dei dati. Questi moduli, combinati insieme, costituiscono un ecosistema robusto e versatile per l’analisi e l’elaborazione dei dati in Python.

In questo capitolo, introdurremo NumPy, che è ampiamente utilizzato in quasi tutti i calcoli numerici in Python. NumPy consente di lavorare con vettori e matrici in modo più efficiente e veloce rispetto alle liste e alle liste di liste (utilizzate come matrici) di Python. Oltre a ciò, NumPy fornisce una vasta gamma di funzioni matematiche di base, nonché strumenti avanzati per la generazione di numeri casuali (si veda il capitolo Generazione di numeri casuali).

Preparazione del Notebook#

import numpy as np

Utilizzo degli Array nel Modulo NumPy#

In Python standard, abbiamo a disposizione tipi di dati numerici (come numeri interi e decimali) e strutture come liste, dizionari e insiemi. NumPy, d’altro canto, introduce un nuovo tipo di struttura dati: l’array N-dimensionale, noto come ndarray. Questi array hanno alcune caratteristiche distintive:

  • Dimensioni: Gli ndarray possono variare nel numero di dimensioni, definite come «assi». Ad esempio, un array può essere unidimensionale (simile a un vettore lineare), bidimensionale (come una matrice o una tabella), tridimensionale (simile a un cubo), e così via.

  • Tipo di Dato: A differenza delle liste in Python standard che possono contenere diversi tipi di dati, ogni elemento all’interno di un ndarray deve essere dello stesso tipo, come numeri interi, decimali, booleani o stringhe.

  • Forma: La «forma» di un ndarray si riferisce alle sue dimensioni, ovvero quante righe, colonne o altri livelli di profondità ha. Per esempio, la forma (3, 4) indica un array con 3 righe e 4 colonne.

  • Indicizzazione: Gli ndarray possono essere indicizzati in modo simile agli array standard di Python, ma offrono anche opzioni più avanzate per l’indicizzazione.

Gli ndarray sono potenti per manipolare e analizzare i dati, grazie alle loro funzioni e metodi che includono operazioni matematiche e statistiche, trasformazioni e altre manipolazioni dei dati.

Terminologia Importante:

  • Size: Indica il numero totale di elementi in un array.

  • Rank: Si riferisce al numero di dimensioni, o assi, di un array.

  • Shape: Denota le dimensioni specifiche dell’array, ovvero una sequenza di numeri che rappresentano il conteggio degli elementi in ogni dimensione.

Come Creare un ndarray: Il modo più diretto per creare un ndarray è attraverso la conversione di una lista Python. Ad esempio, è possibile creare un array unidimensionale (1-D) a partire da una lista standard di Python.

x = np.array([1, 2, 3, 4, 5, 6])

L’istruzione precedente crea un array in NumPy, assegnandolo alla variabile x. Questo array è un vettore unidimensionale contenente sei elementi, che sono i numeri interi specificati all’interno delle parentesi quadre.

print(x)
[1 2 3 4 5 6]

Indicizzazione

Se vogliamo estrarre un singolo elemento del vettore lo indicizziamo con la sua posizione (si ricordi che l’indice inizia da 0):

x[0]
1
x[2]
3

Un array 2-D si crea nel modo seguente:

y = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]])
print(y)
[[ 1  2  3  4]
 [ 5  6  7  8]
 [ 9 10 11 12]]

Estraiamo un singolo elemento dall’array:

y[0, 2]
3

Estraiamo la seconda riga dall’array:

y[1]
array([5, 6, 7, 8])

Estraiamo la seconda colonna dall’array:

y[:, 1] 
array([ 2,  6, 10])

La sintassi con i due punti è chiamata «slicing» dell’array.

# Display the first row of the array
print("Displaying the first row:")
print(y[0, :])
Displaying the first row:
[1 2 3 4]
# Show the last two elements in the first row
print("Showing the last two elements in the first row:")
print(y[0, -2:])
Showing the last two elements in the first row:
[3 4]
# Retrieve every second element in the first row
print("Retrieving every second element in the first row:")
print(y[0, ::2])
Retrieving every second element in the first row:
[1 3]
# Extract a submatrix from the original array
print("Extracting a submatrix:")
print(y[:2, 1:3])
Extracting a submatrix:
[[2 3]
 [6 7]]

Funzioni per ndarray#

Numpy offre varie funzioni per creare ndarray. Per esempio, è possibile creare un array 1-D con la funzione .arange(start, stop, incr, dtype=..) che fornisce l’intervallo di numeri compreso fra start, stop, al passo incr:

z = np.arange(2, 9, 2)
print(z)
[2 4 6 8]

Si usa spesso .arange per creare sequenze a incrementi unitari:

w = np.arange(11)
print(w)
[ 0  1  2  3  4  5  6  7  8  9 10]

Un’altra funzione molto utile è .linspace:

x = np.linspace(0, 10, num=20)
print(x)
[ 0.          0.52631579  1.05263158  1.57894737  2.10526316  2.63157895
  3.15789474  3.68421053  4.21052632  4.73684211  5.26315789  5.78947368
  6.31578947  6.84210526  7.36842105  7.89473684  8.42105263  8.94736842
  9.47368421 10.        ]

Fissati gli estremi (qui 0, 10) e il numero di elementi desiderati, .linspace determina in maniera automatica l’incremento.

Una proprietà molto utile dei ndarray è la possibilità di filtrare gli elementi di un array che rispondono come True ad un criterio. Per esempio:

print(x[x > 7])
[ 7.36842105  7.89473684  8.42105263  8.94736842  9.47368421 10.        ]

perché solo gli ultimi sei elementi di x rispondono True al criterio \(x > 7\).

Le dimensioni («assi») di un ndarray vengono ritornate dal metodo .dim. Per esempio:

print(y)
[[ 1  2  3  4]
 [ 5  6  7  8]
 [ 9 10 11 12]]
y.ndim
2
print(y.max(axis=1))
[ 4  8 12]
print(y.max(axis=0))
[ 9 10 11 12]

Il numero di elementi per ciascun asse viene ritornato dal metodo .shape:

y.shape
(3, 4)

Manipolazione di Array con NumPy#

NumPy rende più agevole lavorare con grandi quantità di dati. Un concetto fondamentale in NumPy sono gli array monodimensionali, spesso utilizzati per rappresentare vettori, ovvero sequenze di numeri che possono rappresentare, ad esempio, le misurazioni di una variabile specifica. Grazie a NumPy, possiamo eseguire operazioni aritmetiche su questi vettori in modo semplice, applicando la stessa operazione a tutti gli elementi dell’array contemporaneamente.

Cosa Significa Vettorizzare un’Operazione#

La vettorizzazione è una delle funzionalità più efficaci di NumPy. Quando diciamo che un’operazione è vettorizzata, significa che questa operazione viene applicata in un colpo solo a tutti gli elementi dell’array, invece di dover agire su ciascun elemento individualmente. Questo approccio rende la manipolazione di grandi insiemi di dati non solo più veloce ma anche più intuitiva, poiché consente di trattare l’intero insieme di dati come un’unica entità anziché come una serie di punti dati individuali.

Supponiamo di avere raccolto i dati di 4 individui

m = np.array([1.62, 1.75, 1.55, 1.74])
kg = np.array([55.4, 73.6, 57.1, 59.5])

print(m)
print(kg)
[1.62 1.75 1.55 1.74]
[55.4 73.6 57.1 59.5]

dove m è l’array che contiene i dati relativi all’altezza in metri dei quattro individui e kg è l’array che contiene i dati relativi al peso in kg. I dati sono organizzati in modo tale che il primo elemento di entrambi i vettori si riferisce alle misure del primo individuo, il secondo elemento dei due vettori si riferisce alle misure del secondo individuo, ecc.

Supponiamo di volere calcolare l’indice BMI:

\[ BMI = \frac{kg}{m^2}. \]

Per il primo individuo del campione, l’indice di massa corporea è

55.4 / 1.62**2
21.109586953208346

Si noti che non abbiamo bisogno di scrivere 55.4 / (1.62**2) in quanto, in Python, l’elevazione a potenza viene eseguita prima della somma e della divisione (come in tutti i linguaggi). Usando i dati immagazzinati nei due vettori, lo stesso risultato si ottiene nel modo seguente:

kg[0] / m[0]**2
21.109586953208346

Se ora non specifichiamo l’indice (per esempio, [0]), le operazioni aritmetiche indicate verranno eseguite per ciascuna coppia di elementi corrispondenti nei due vettori:

bmi = kg / m**2

Otteniamo così, con una sola istruzione, l’indice BMI dei quattro individui:

bmi.round(1)
array([21.1, 24. , 23.8, 19.7])

Questo esempio illustra come le operazioni aritmetiche standard vengano eseguite elemento per elemento negli array, grazie al processo di vettorizzazione.

Broadcasting#

Il broadcasting è una caratteristica distintiva di NumPy che facilita l’esecuzione di operazioni tra array di dimensioni diverse o tra un array e uno scalare, anche se le loro dimensioni non sono direttamente compatibili. Grazie al broadcasting, NumPy è in grado di «espandere» automaticamente le dimensioni di uno degli operandi per rendere possibile l’operazione.

Questo significa che possiamo, per esempio, eseguire un’operazione tra un array e un numero singolo (un vettore e uno scalare) o tra due array di dimensioni differenti, senza la necessità di modificare manualmente le dimensioni di questi array. Il broadcasting si occupa di adattare le dimensioni in modo coerente per consentire l’operazione desiderata. Ciò rende il codice più snello e leggibile, eliminando la necessità di espandere gli array manualmente.

In breve, il broadcasting in NumPy è un potente strumento che semplifica l’esecuzione di operazioni su array di dimensioni diverse o tra array e scalari, automatizzando l’allineamento delle dimensioni.

Esempio di Broadcasting#

Immaginiamo di avere un array A con dimensioni 3x3 e un numero scalare B. Senza broadcasting, dovremmo espandere B in un array 3x3 riempiendo ogni cella con il valore di B per eseguire un’operazione come l’addizione su ciascun elemento di A. Grazie al broadcasting, possiamo semplicemente scrivere A + B, e NumPy si occuperà automaticamente di «espandere» B durante l’operazione, applicando il valore scalare a ogni elemento di A.

# Creiamo un array 3x3
A = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])

# Definiamo uno scalare
B = 5

# Applichiamo il broadcasting per aggiungere lo scalare a ogni elemento dell'array
C = A + B

print(C)
[[ 6  7  8]
 [ 9 10 11]
 [12 13 14]]

In questo esempio, C conterrà l’array originale A con ogni elemento incrementato di 5, dimostrando come il broadcasting semplifichi operazioni che altrimenti richiederebbero passaggi aggiuntivi.

Altre operazioni sugli array#

C’è un numero enorme di funzioni predefinite in NumPy che calcolano automaticamente diverse quantità sugli ndarray. Ad esempio:

  • mean(): calcola la media di un vettore o matrice;

  • sum(): calcola la somma di un vettore o matrice;

  • std(): calcola la deviazione standard;

  • min(): trova il minimo nel vettore o matrice;

  • max(): trova il massimo;

  • ndim: dimensione del vettore o matrice;

  • shape: restituisce una tupla con la «forma» del vettore o matrice;

  • size: restituisce la dimensione totale del vettore (=ndim) o della matrice;

  • dtype: scrive il tipo numpy del dato;

  • zeros(num): scrive un vettore di num elementi inizializzati a zero;

  • arange(start,stop,step): genera un intervallo di valori (interi o reali, a seconda dei valori di start, ecc.) intervallati di step. Nota che i dati vengono generati nell’intervallo aperto [start,stop)!

  • linstep(start,stop,num): genera un intervallo di num valori interi o reali a partire da start fino a stop (incluso!);

  • astype(tipo): converte l’ndarray nel tipo specificato

Per esempio:

x = np.array([1, 2, 3])
print(x)
[1 2 3]
[x.min(), x.max(), x.sum(), x.mean(), x.std()]
[1, 3, 6, 2.0, 0.816496580927726]

Lavorare con formule matematiche#

L’implementazione delle formule matematiche sugli array è un processo molto semplice con Numpy. Possiamo prendere ad esempio la formula della deviazione standard che discuteremo nel capitolo Indici di posizione e di scala:

\[ s = \sqrt{\sum_{i=1}^n\frac{(x_i - \bar{x})^2}{n}} \]

L’implementazione su un array NumPy è la seguente:

print(x)
[1 2 3]
np.sqrt(np.sum((x - np.mean(x)) ** 2) / np.size(x))
0.816496580927726

Questa implementazione funziona nello stesso modo sia che x contenga 3 elementi (come nel caso presente) sia che x contenga migliaia di elementi. È importante notare l’utilizzo delle parentesi tonde per specificare l’ordine di esecuzione delle operazioni. In particolare, nel codice fornito, si inizia calcolando la media degli elementi del vettore x per mezzo della funzione np.mean(x). Questa operazione produce uno scalare, ovvero un singolo valore numerico che rappresenta la media degli elementi del vettore. L’utilizzo delle parentesi tonde è fondamentale per garantire l’ordine corretto delle operazioni. In questo caso, la funzione np.mean() viene applicata al vettore x prima di qualsiasi altra operazione matematica. Senza le parentesi tonde, le operazioni verrebbero eseguite in un ordine diverso e il risultato potrebbe essere errato.

np.mean(x)
2.0

Successivamente, eseguiamo la sottrazione dei singoli elementi del vettore x per la media del vettore stesso, ovvero \(x_i - \bar{x}\), utilizzando il meccanismo del broadcasting.

x - np.mean(x)
array([-1.,  0.,  1.])

Eleviamo poi al quadrato gli elementi del vettore che abbiamo ottenuto:

(x - np.mean(x)) ** 2
array([1., 0., 1.])

Sommiamo gli elementi del vettore:

np.sum((x - np.mean(x)) ** 2)
2.0

Dividiamo il numero ottenuto per \(n\). Questa è la varianza di \(x\):

res = np.sum((x - np.mean(x)) ** 2) / np.size(x)
res
0.6666666666666666

Infine, per ottenere la deviazione standard, prendiamo la radice quadrata:

np.sqrt(res)
0.816496580927726

Il risultato ottenuto coincide con quello che si trova applicando la funzione np.std():

np.std(x)
0.816496580927726

Slicing#

Per concludere, spendiamo ancora alcune parole sull’indicizzazione degli ndarray.

Slicing in Numpy è un meccanismo che consente di selezionare una porzione di un array multidimensionale, ovvero una sotto-matrice o un sotto-vettore. Per selezionare una porzione di un array, si utilizza la sintassi [start:stop:step], dove start indica l’indice di partenza della porzione, stop indica l’indice di fine e step indica il passo da utilizzare per la selezione. Se uno o più di questi valori vengono omessi, vengono utilizzati dei valori di default.

Ad esempio, se abbiamo un array arr di dimensione (3, 4) e vogliamo selezionare la seconda colonna, possiamo usare la sintassi arr[:, 1]. In questo caso, il simbolo : indica che vogliamo selezionare tutte le righe, mentre il numero 1 indica che vogliamo selezionare la seconda colonna.

Inoltre, possiamo utilizzare il meccanismo di slicing anche per selezionare porzioni di array multidimensionali. Ad esempio, se abbiamo un array arr di dimensione (3, 4, 5) e vogliamo selezionare la prima riga di ciascuna matrice 4x5, possiamo usare la sintassi arr[:, 0, :].

Per esempio, creiamo l’array x di rango 2 con shape (3, 4):

x = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]])
print(x)
[[ 1  2  3  4]
 [ 5  6  7  8]
 [ 9 10 11 12]]

Utilizziamo il meccanismo di slicing per estrarre la sottomatrice composta dalle prime 2 righe e dalle colonne 1 e 2. y è l’array risultante di dimensione (2, 2):

y = x[:2, 1:3]
print(y)
[[2 3]
 [6 7]]

È importante sapere che uno slice di un array in Numpy è una vista degli stessi dati, il che significa che modificarlo implica la modifica dell’array originale. In pratica, quando si modifica uno slice di un array, si sta modificando direttamente l’array originale e tutte le altre visualizzazioni dell’array vedranno la stessa modifica. Questo avviene perché Numpy è progettato per gestire enormi quantità di dati, pertanto cerca di evitare il più possibile di effettuare copie dei dati.

Questo comportamento deve essere preso in considerazione durante la modifica degli array in Numpy, al fine di evitare modifiche accidentali o indesiderate. In alcuni casi, è possibile utilizzare il metodo copy() per creare una copia indipendente di un array e lavorare sulla copia senza modificare l’originale. Vediamo un esempio.

print(x[0, 1])   
2
y[0, 0] = 77     
print(x)
[[ 1 77  3  4]
 [ 5  6  7  8]
 [ 9 10 11 12]]
x = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]])

z = x.copy()
print(z)
[[ 1  2  3  4]
 [ 5  6  7  8]
 [ 9 10 11 12]]
z[0, 1] = 33
print(z)
[[ 1 33  3  4]
 [ 5  6  7  8]
 [ 9 10 11 12]]
print(x)
[[ 1  2  3  4]
 [ 5  6  7  8]
 [ 9 10 11 12]]

Copia e «Copia Profonda» in Python#

In Python, per ottimizzare le prestazioni, le assegnazioni di solito non copiano gli oggetti sottostanti. Questo è particolarmente importante, ad esempio, quando gli oggetti vengono passati tra funzioni, per evitare una quantità eccessiva di copie in memoria quando non sono necessarie (questo approccio è noto tecnicamente come «passaggio per riferimento»).

Consideriamo il seguente esempio con un array A:

A = np.array([[1, 2], [3, 4]])

Se creiamo un nuovo riferimento B a A:

B = A

Ora B si riferisce allo stesso insieme di dati di A. Se modifichiamo B, anche A viene modificato di conseguenza:

B[0,0] = 10

Dopo questa modifica, sia B che A saranno:

print(A)
[[10  2]
 [ 3  4]]

Se desideriamo evitare questo comportamento, in modo tale che B diventi un oggetto completamente indipendente da A, dobbiamo effettuare una cosiddetta «copia profonda» utilizzando la funzione copy:

B = np.copy(A)

Ora, se modificassimo B, A non subirebbe alcuna modifica. Ad esempio:

B[0,0] = -5

A questo punto, B sarà:

print(B)
[[-5  2]
 [ 3  4]]

Ma A rimarrà invariato:

print(A)
[[10  2]
 [ 3  4]]

Questo esempio mostra chiaramente la differenza tra una semplice assegnazione, che crea un riferimento all’oggetto originale, e una «copia profonda», che crea un nuovo oggetto indipendente.

Informazioni sull’Ambiente di Sviluppo#

%load_ext watermark
%watermark -n -u -v -iv -w -m
Last updated: Sun Jun 16 2024

Python implementation: CPython
Python version       : 3.12.3
IPython version      : 8.25.0

Compiler    : Clang 16.0.6 
OS          : Darwin
Release     : 23.4.0
Machine     : arm64
Processor   : arm
CPU cores   : 8
Architecture: 64bit

numpy: 1.26.4

Watermark: 2.4.3