23. Introduzione a Numpy#

Numpy è la libreria di riferimento di scipy per il calcolo scientifico. Il cuore della libreria è costituito dai numpy array che permettono di gestire agevolmente operazioni tra vettori e matrici. Gli array di Python sono in generale tensori, ovvero strutture numeriche dal numero di dimensioni variabili, che possono dunque essere array monodimensionali, matrici bidimensionali, o strutture a più dimensioni (es. cuboidi 10 x 10 x 10). Per utilizzare gli array di numpy, dobbiamo prima importare il pacchetto numpy:

import numpy as np #la notazione "as" ci permette di referenziare il namespace numpy semplicemente con np in futuro

23.1. Numpy Arrays#

Un array multidimensionale di numpy può essere definito a partire da una lista di liste, come segue:

l = [[1,2,3],[4,5,2],[1,8,3]] #una lista contenente tre liste
print("List of lists:",l) #viene visualizzata così come l'abbiamo definita
a = np.array(l) #costruisco un array di numpy a partire dalla lista di liste
print("Numpy array:\n",a) #ogni lista interna viene identificata come una riga di una matrice bidimensionale
print("Numpy array from tuple:\n",np.array(((1,2,3),(4,5,6)))) #posso creare numpy array anche da tuple
List of lists: [[1, 2, 3], [4, 5, 2], [1, 8, 3]]
Numpy array:
 [[1 2 3]
 [4 5 2]
 [1 8 3]]
Numpy array from tuple:
 [[1 2 3]
 [4 5 6]]

🙋‍♂️ Domanda 1

Qual è un vantaggio evidente degli array di numpy rispetto alle liste, visti gli esempi mostrati sopra?

Ogni array di numpy ha una proprietà shape che ci permette di determinare il numero di dimensioni della struttura:

print(a.shape) #si tratta di una matrice 3 x 3
(3, 3)

Vediamo qualche altro esempio:

array = np.array([1,2,3,4]) 
matrice = np.array([[1,2,3,4],[5,4,2,3],[7,5,3,2],[0,2,3,1]]) 
tensore = np.array([[[1,2,3,4],['a','b','c','d']],[[5,4,2,3],['a','b','c','d']],[[7,5,3,2],['a','b','c','d']],[[0,2,3,1],['a','b','c','d']]]) 
print('Array:',array, array.shape) #array monodimensionale, avrà una sola dimensione
print('Matrix:\n',matrice, matrice.shape)
print('matrix:\n',tensore, tensore.shape) #tensore, avrà due dimensioni
Array: [1 2 3 4] (4,)
Matrix:
 [[1 2 3 4]
 [5 4 2 3]
 [7 5 3 2]
 [0 2 3 1]] (4, 4)
matrix:
 [[['1' '2' '3' '4']
  ['a' 'b' 'c' 'd']]

 [['5' '4' '2' '3']
  ['a' 'b' 'c' 'd']]

 [['7' '5' '3' '2']
  ['a' 'b' 'c' 'd']]

 [['0' '2' '3' '1']
  ['a' 'b' 'c' 'd']]] (4, 2, 4)

🙋‍♂️ Domanda 2

In Numpy, esiste una differenza formale tra un array monodimensionale e una matrice contenente una unica riga (o una unica colonna)?

Gli array di Numpy supportano le operazioni elemento per elemento. Vediamo le principali operazioni di questo tipo:

a1 = np.array([1,2,3,4]) 
a2 = np.array([4,3,8,1]) 
print("Sum:",a1+a2) #somma tra vettori
print("Elementwise multiplication:",a1*a2) #moltiplicazione tra elementi corrispondenti
print("Power of two:",a1**2) #quadrato degli elementi
print("Elementwise power:",a1**a2) #elevamento a potenza elemento per elemento
print("Vector product:",a1.dot(a2)) #prodotto vettoriale
print("Minimum:",a1.min()) #minimo dell'array
print("Maximum:",a1.max()) #massimo dell'array
print("Sum:",a2.sum()) #somma di tutti i valori dell'array
print("Product:",a2.prod()) #prodotto di tutti i valori dell'array
print("Mean:",a1.mean()) #media di tutti i valori dell'array
Sum: [ 5  5 11  5]
Elementwise multiplication: [ 4  6 24  4]
Power of two: [ 1  4  9 16]
Elementwise power: [   1    8 6561    4]
Vector product: 38
Minimum: 1
Maximum: 4
Sum: 16
Product: 96
Mean: 2.5

Operazioni tra matrici:

m1 = np.array([[1,2,3,4],[5,4,2,3],[7,5,3,2],[0,2,3,1]]) 
m2 = np.array([[8,2,1,4],[0,4,6,1],[4,4,2,0],[0,1,8,6]]) 

print("Sum:",m1+m2) #somma tra matrici
print("Elementwise product:\n",m1*m2) #prodotto elemento per elemento
print("Power of two:\n",m1**2) #quadrato degli elementi
print("Elementwise power:\n",m1**m2) #elevamento a potenza elemento per elemento
print("Matrix multiplication:\n",m1.dot(m2)) #prodotto matriciale
print("Minimum:",m1.min()) #minimo
print("Maximum:",m1.max()) #massimo
print("Minimum along columns:",m1.min(0)) #minimo per colonne
print("Minimum along rows:",m1.min(1)) #minimo per righe
print("Sum:",m1.sum()) #somma dei valori
print("Mean:",m1.mean()) #valore medio
print("Diagonal:",m1.diagonal()) #diagonale principale della matrice
print("Transposed:\n",m1.T) #matrice trasposta
Sum: [[ 9  4  4  8]
 [ 5  8  8  4]
 [11  9  5  2]
 [ 0  3 11  7]]
Elementwise product:
 [[ 8  4  3 16]
 [ 0 16 12  3]
 [28 20  6  0]
 [ 0  2 24  6]]
Power of two:
 [[ 1  4  9 16]
 [25 16  4  9]
 [49 25  9  4]
 [ 0  4  9  1]]
Elementwise power:
 [[   1    4    3  256]
 [   1  256   64    3]
 [2401  625    9    1]
 [   1    2 6561    1]]
Matrix multiplication:
 [[20 26 51 30]
 [48 37 57 42]
 [68 48 59 45]
 [12 21 26  8]]
Minimum: 0
Maximum: 7
Minimum along columns: [0 2 2 1]
Minimum along rows: [1 2 2 0]
Sum: 47
Mean: 2.9375
Diagonal: [1 4 3 1]
Transposed:
 [[1 5 7 0]
 [2 4 5 2]
 [3 2 3 3]
 [4 3 2 1]]

🙋‍♂️ Domanda 3

Qual è il vantaggio di utilizzare le operazioni illustrate sopra? Che codice servirebbe per effettuare l’operazione a1**a2 se a1 e a2 fossero delle liste di Python piuttosto che degli array di numpy?

23.2. Linspace, Arange, Zeros, Ones, Eye e Random#

Le funzioni linspace, arange, zeros, ones, eye e random di numpy sono utili a generare array numerici al volo. In particolare, la funzione linspace, permette di generare una sequenza di n numeri equispaziati che vanno da un valore minimo a un valore massimo:

a=np.linspace(10,20,5) # genera 5 valori equispaziati che vanno da 10 a 20
print(a)
[10.  12.5 15.  17.5 20. ]

arange è molto simile a range, ma restituisce direttamente un array di numpy:

print(np.arange(10)) #numeri da 0 a 9
print(np.arange(1,6)) #numeri da 1 a 5
print(np.arange(0,7,2)) #numeri pari da 0 a 6
[0 1 2 3 4 5 6 7 8 9]
[1 2 3 4 5]
[0 2 4 6]

🙋‍♂️ Domanda 4

È possibile ottenere lo stesso risultato di arange utilizzando range? Come?

Possiamo creare array contenenti zero o uno di forme arbitrarie mediante zeros e ones:

print(np.zeros((3,4)))#zeros e ones prendono come parametro una tupla contenente le dimensioni desiderate
print(np.ones((2,1)))
[[0. 0. 0. 0.]
 [0. 0. 0. 0.]
 [0. 0. 0. 0.]]
[[1.]
 [1.]]

La funzione eye permette di creare una matrice quadrata identità:

print(np.eye(3))
print(np.eye(5))
[[1. 0. 0.]
 [0. 1. 0.]
 [0. 0. 1.]]
[[1. 0. 0. 0. 0.]
 [0. 1. 0. 0. 0.]
 [0. 0. 1. 0. 0.]
 [0. 0. 0. 1. 0.]
 [0. 0. 0. 0. 1.]]

Per costruire un array di valori casuali (distribuzione uniforme) tra 0 e 1 (zero incluso, uno escluso), basta scrivere:

print(np.random.rand(5)) #un array con 5 valori casuali tra 0 e 1
print(np.random.rand(3,2)) #una matrice 3x2 di valori casuali tra 0 e 1
[0.4455531  0.15464262 0.73300802 0.83126699 0.4840601 ]
[[0.04519461 0.77306473]
 [0.81167333 0.33469426]
 [0.51806682 0.16306516]]

Possiamo generare un array di valori casuali distribuiti in maniera normale (Gaussiana) con randn:

print(np.random.randn(5,2))
[[-0.27659656 -1.47446936]
 [ 0.30758864  0.95848991]
 [-0.18955928  0.98689232]
 [-1.06568839  0.79190779]
 [ 0.63691861 -1.36459616]]

Parleremo più in dettaglio della distribuzione Gaussiana in seguito.

Possiamo generare numeri interi compresi tra un minimo (incluso) e un massimo (escluso) usando randint:

print(np.random.randint(0,50,3))#tre valori compresi tra 0 e 50 (escluso)
print(np.random.randint(0,50,(2,3)))#matrice 2x3 di valori interi casuali tra 0 e 50 (escluso)
[ 0 26 31]
[[ 7  9  8]
 [47  3 27]]

Per generare valori casuali in maniera replicabile, è possibile specificare un seed:

np.random.seed(123)
print(np.random.rand(5))
[0.69646919 0.28613933 0.22685145 0.55131477 0.71946897]

Il codice sopra (inclusa la definizione del seed) restituisce gli stessi risultati se rieseguito:

np.random.seed(123)
print(np.random.rand(5))
[0.69646919 0.28613933 0.22685145 0.55131477 0.71946897]

🙋‍♂️ Domanda 5

Qual è l’effetto di ri-eseguire la cella precedente cambiando il seed?

23.3. max, min, sum, argmax, argmin#

È possibile calcolare massimi e minimi e somme di una matrice per righe e colonne come segue:

mat = np.array([[1,-5,0],[4,3,6],[5,8,-7],[10,6,-12]])
print(mat)
print()
print( mat.max(0))# massimi per colonne
print()
print(mat.max(1))# massimi per righe
print()
print (mat.min(0))# minimi per colonne
print()
print(mat.min(1))# minimi per righe
print()
print(mat.sum(0))# somma per colonne
print()
print(mat.sum(1))# somma per righe
print()
print(mat.max())# massimo globale
print(mat.min())# minimo globale
print(mat.sum())# somma globale
[[  1  -5   0]
 [  4   3   6]
 [  5   8  -7]
 [ 10   6 -12]]

[10  8  6]

[ 1  6  8 10]

[  1  -5 -12]

[ -5   3  -7 -12]

[ 20  12 -13]

[-4 13  6  4]

10
-12
19

È inoltre possibile ottenere gli indici in corrispondenza dei quali si hanno massimi e minimi usando la funzione argmax:

print(mat.argmax(0))
[3 2 1]

Abbiamo un massimo alla quarta riga nella prima colonna, uno alla terza riga nella seconda colonna e uno alla seconda riga nella terza colonna. Verifichiamolo:

print(mat[3,0])
print(mat[2,1])
print(mat[1,2])
print(mat.max(0))
10
8
6
[10  8  6]

Analogamente:

print(mat.argmax(1))
print(mat.argmin(0))
print(mat.argmin(1))
[0 2 1 0]
[0 0 3]
[1 1 2 2]

23.4. Indicizzazione e Slicing#

Gli array di numpy possono essere indicizzati in maniera simile a quanto avviene per le liste di Python:

arr = np.array([1,2,3,4,5])
print("arr[0]       ->",arr[0]) #primo elemento dell'array
print("arr[:3]      ->",arr[:3]) #primi tre elementi
print("arr[1:5:2]   ->",arr[1:4:2]) #dal secondo al quarto (incluso) a passo 2
arr[0]       -> 1
arr[:3]      -> [1 2 3]
arr[1:5:2]   -> [2 4]

Quando si indicizza un array a più di una dimensioni con un unico indice, viene in automatico indicizzata la prima dimensione. Vediamo qualche esempio con le matrici bidimensionali:

mat = np.array(([1,5,2,7],[2,7,3,2],[1,5,2,1])) 
print("Matrice:\n",mat,mat.shape) #matrice 3 x 4
print("mat[0]     ->",mat[0]) #una matrice è una collezione di righe, per cui mat[0] restituisce la prima riga
print("mat[-1]     ->",mat[-1]) #ultima riga
print("mat[::2]     ->",mat[::2]) #righe dispari
Matrice:
 [[1 5 2 7]
 [2 7 3 2]
 [1 5 2 1]] (3, 4)
mat[0]     -> [1 5 2 7]
mat[-1]     -> [1 5 2 1]
mat[::2]     -> [[1 5 2 7]
 [1 5 2 1]]

Vediamo qualche esempio con tensori a più dimensioni:

tens = np.array(([[1,5,2,7],
                [2,7,3,2],
                [1,5,2,1]],
                [[1,5,2,7],
                [2,7,3,2],
                [1,5,2,1]])) 
print("Matrice:\n",tens,tens.shape) #tensore 2x3x4
print("tens[0]    ->",tens[0])#si tratta della prima matrice 3x4
print("tens[-1]    ->",tens[-1])#ultima mtraice 3x4
Matrice:
 [[[1 5 2 7]
  [2 7 3 2]
  [1 5 2 1]]

 [[1 5 2 7]
  [2 7 3 2]
  [1 5 2 1]]] (2, 3, 4)
tens[0]    -> [[1 5 2 7]
 [2 7 3 2]
 [1 5 2 1]]
tens[-1]    -> [[1 5 2 7]
 [2 7 3 2]
 [1 5 2 1]]

L’indicizzazione può proseguire attraverso le altre dimensioni specificando un ulteriore indice in parentesi quadre o separando i vari indici con la virgola:

mat = np.array(([1,5,2,7],[2,7,3,2],[1,5,2,1])) 
print("Matrice:\n",mat,mat.shape) #matrice 3 x 4
print("mat[2][1]  ->",mat[2][1]) #terza riga, seconda colonna
print("mat[0,0]   ->",mat[0,0]) #prima riga, prima colonna (notazione più compatta)

print("mat[0]     -> ",mat[0]) #restituisce l'intera prima riga della matrice
print("mat[:,0]   -> ",mat[:,0]) #restituisce la prima colonna della matrice. 
                                #I due punti ":" significano "lascia tutto inalterato lungo questa dimensione"
print("mat[0,:]   ->",mat[0,:]) #notazione alternativa per ottenere la prima riga della matrice
print("mat[0:2,:] ->\n",mat[0:2,:]) #prime due righe
print("mat[:,0:2] ->\n",mat[:,0:2]) #prime due colonne
print("mat[-1]    ->",mat[-1]) #ultima riga
Matrice:
 [[1 5 2 7]
 [2 7 3 2]
 [1 5 2 1]] (3, 4)
mat[2][1]  -> 5
mat[0,0]   -> 1
mat[0]     ->  [1 5 2 7]
mat[:,0]   ->  [1 2 1]
mat[0,:]   -> [1 5 2 7]
mat[0:2,:] ->
 [[1 5 2 7]
 [2 7 3 2]]
mat[:,0:2] ->
 [[1 5]
 [2 7]
 [1 5]]
mat[-1]    -> [1 5 2 1]

Caso di tensori a più dimensioni:

mat=np.array([[[1,2,3,4],['a','b','c','d']],
              [[5,4,2,3],['a','b','c','d']],
              [[7,5,3,2],['a','b','c','d']],
              [[0,2,3,1],['a','b','c','d']]]) 
print("mat[:,:,0] ->", mat[:,:,0]) #matrice 3 x 3 contenuta nel "primo canale" del tensore
print("mat[:,:,1] ->",mat[:,:,1]) #matrice 3 x 3 contenuta nel "secondo canale" del tensore
print("mat[...,0] ->", mat[...,0]) #matrice 3 x 3 contenuta nel "primo canale" del tensore (notazione alternativa)
print("mat[...,1] ->",mat[...,1]) #matrice 3 x 3 contenuta nel "secondo canale" del tensore (notazione alternativa)
#la notazione "..." serve a dire "lascia tutto invariato lungo le dimensioni omesse"
mat[:,:,0] -> [['1' 'a']
 ['5' 'a']
 ['7' 'a']
 ['0' 'a']]
mat[:,:,1] -> [['2' 'b']
 ['4' 'b']
 ['5' 'b']
 ['2' 'b']]
mat[...,0] -> [['1' 'a']
 ['5' 'a']
 ['7' 'a']
 ['0' 'a']]
mat[...,1] -> [['2' 'b']
 ['4' 'b']
 ['5' 'b']
 ['2' 'b']]

In genere, quando da un array si estrae un sottoinsieme di dati, si parla di slicing.

🙋‍♂️ Domanda 6

Le liste di Python non supportano lo slicing. Come si può implementare una istruzione del tipo a[2:8:2] utilizzando le liste di Python?

23.5. Indicizzazione e Slicing Logici#

In numpy è inoltre possibile indicizzare gli array in maniera “logica”, ovvero passando come indici un array di valori booleani. Ad esempio, se vogliamo selezionare il primo e il terzo valore di un array, dobbiamo passare come indici l’array [True, False, True]:

x = np.array([1,2,3])
print(x[np.array([True,False,True])]) #per selezionare solo 1 e 3
print(x[np.array([False,True,False])]) #per selezionare solo 2
[1 3]
[2]

L’indicizzazione logica è molto utile se combinata alla possibilità di costruire array logici “al volo” specificando una condizione che gli elementi di un array possono o non possono soddisfare. Ad esempio:

x = np.arange(10)
print(x)
print(x>2) #genera un array di valori booleani
#che conterrà True in presenza dei valori di x
#che verificano la condizione x>2
print(x==3) #True solo in presenza del valore 3
[0 1 2 3 4 5 6 7 8 9]
[False False False  True  True  True  True  True  True  True]
[False False False  True False False False False False False]

Unendo questi due principi, è semplice selezionare solo alcuni valori da un array, sulla base di una condizione:

x = np.arange(10)
print(x[x%2==0]) #seleziona i valori pari
print(x[x%2!=0]) #seleziona i valori dispari
print(x[x>2]) #seleziona i valori maggiori di 2
[0 2 4 6 8]
[1 3 5 7 9]
[3 4 5 6 7 8 9]

🙋‍♂️ Domanda 7

Si considerino i due array a=np.array([1,2,3]) e b=np.array([5,2,4]). Si utilizzi l’indicizzazione logica per estrarre i numeri in a che si trovano in posizioni contenenti valori pari in b.

23.6. Reshape#

In alcuni casi può essere utile cambiare la “shape” di una matrice. Ad esempio, una matrice 3x2 può essere modificata riarrangiando gli elementi in modo da ottenere una matrice 2x3, una matrice 1x6 o una matrice 6x1. Ciò si può fare mediante il metodo “reshape”:

mat = np.array([[1,2],[3,4],[5,6]])
print(mat)
print(mat.reshape(2,3))
print(mat.reshape(1,6))
print(mat.reshape(6,1)) #matrice 6 x 1
print(mat.reshape(6)) #vettore unidimensionale
print(mat.ravel())#equivalente al precedente, ma aparametrico
[[1 2]
 [3 4]
 [5 6]]
[[1 2 3]
 [4 5 6]]
[[1 2 3 4 5 6]]
[[1]
 [2]
 [3]
 [4]
 [5]
 [6]]
[1 2 3 4 5 6]
[1 2 3 4 5 6]

Notiamo che, se leggiamo per righe (da sinistra verso destra, dall’alto verso il basso), l’ordine degli elementi resta immutato. Possiamo anche lascire che numpy clacoli una delle dimensioni sostituendola con -1:

print(mat.reshape(2,-1))
print(mat.reshape(-1,6))
[[1 2 3]
 [4 5 6]]
[[1 2 3 4 5 6]]

Reshape può prendere in input le singole dimensioni o una tupla contenente la shape. Nell’ultimo caso, risulta comodo fare operazioni di questo genere:

mat1 = np.random.rand(3,2)
mat2 = np.random.rand(2,3)
print(mat2.reshape(mat1.shape)) #diamo a mat2 la stessa shape di mat1
[[0.72904971 0.43857224]
 [0.0596779  0.39804426]
 [0.73799541 0.18249173]]

23.7. Composizione di Array Mediante concatenate e stack#

Numpy permette di unire diversi array mediante due principali funzioni: concatenate e vstack. La funzione concatenate prende in input una lista (o tupla) di array e permette di concatenarli lungo una dimensione (axis) esistente specificata, che di default è pari a zero (concatenazione per righe):

a=np.arange(9).reshape(3,3)
print(a,a.shape,"\n")
cat=np.concatenate([a,a])
print(cat,cat.shape,"\n")
cat2=np.concatenate([a,a,a])
print(cat2,cat2.shape)
[[0 1 2]
 [3 4 5]
 [6 7 8]] (3, 3) 

[[0 1 2]
 [3 4 5]
 [6 7 8]
 [0 1 2]
 [3 4 5]
 [6 7 8]] (6, 3) 

[[0 1 2]
 [3 4 5]
 [6 7 8]
 [0 1 2]
 [3 4 5]
 [6 7 8]
 [0 1 2]
 [3 4 5]
 [6 7 8]] (9, 3)

E’ possibile concatenare array su una dimensione diversa specificandola mediante il parametro axis:

a=np.arange(9).reshape(3,3)
print(a,a.shape,"\n")
cat=np.concatenate([a,a], axis=1) #concatenazione per colonne
print(cat,cat.shape,"\n")
cat2=np.concatenate([a,a,a], axis=1) #concatenazione per colonne
print(cat2,cat2.shape)
[[0 1 2]
 [3 4 5]
 [6 7 8]] (3, 3) 

[[0 1 2 0 1 2]
 [3 4 5 3 4 5]
 [6 7 8 6 7 8]] (3, 6) 

[[0 1 2 0 1 2 0 1 2]
 [3 4 5 3 4 5 3 4 5]
 [6 7 8 6 7 8 6 7 8]] (3, 9)

Affinché la concatenazione sia compatibile, gli array della lista devono avere lo stesso numero di dimensioni lungo quelle che non vengono concatenate:

print(cat.shape,a.shape) #concatenazione lungo l'asse 0, le dimensioni lungo gli altri assi devono essere uguali
(3, 6) (3, 3)
np.concatenate([cat,a], axis=0) #concatenazione per colonne #errore!

La funzione stack, a differenza di concatenate permette di concatenare array lungo una nuova dimensione. Si confrontino gli output delle due funzioni:

cat=np.concatenate([a,a])
print(cat,cat.shape)
stack=np.stack([a,a])
print(stack,stack.shape)
[[0 1 2]
 [3 4 5]
 [6 7 8]
 [0 1 2]
 [3 4 5]
 [6 7 8]] (6, 3)
[[[0 1 2]
  [3 4 5]
  [6 7 8]]

 [[0 1 2]
  [3 4 5]
  [6 7 8]]] (2, 3, 3)

Nel caso di stack, gli array sono stati concatenati lungo una nuova dimensione. E’ possibile specificare dimensioni alternative come nel caso di concatenate:

stack=np.stack([a,a],axis=1)
print(stack,stack.shape)
[[[0 1 2]
  [0 1 2]]

 [[3 4 5]
  [3 4 5]]

 [[6 7 8]
  [6 7 8]]] (3, 2, 3)

In questo caso, gli array sono stati concatenati lungo la seconda dimensione.

stack=np.stack([a,a],axis=2)
print(stack,stack.shape)
[[[0 0]
  [1 1]
  [2 2]]

 [[3 3]
  [4 4]
  [5 5]]

 [[6 6]
  [7 7]
  [8 8]]] (3, 3, 2)

In questo caso, gli array sono stati concatenati lungo l’ultima dimensione.

23.8. Tipi#

Ogni array di numpy ha il suo tipo (si veda https://docs.scipy.org/doc/numpy-1.13.0/user/basics.types.html per la lista di tipi supportati). Possiamo vedere il tipo di un array ispezionando la proprietà dtype:

print(mat1.dtype)
float64

Possiamo specificare il tipo in fase di costruzione dell’array:

mat = np.array([[1,2,3],[4,5,6]],int)
print(mat.dtype)
int64

Possiamo inoltre cambiare il tipo di un array al volo utilizzando astype. Questo è comodo ad esempio se vogliamo effettuare una divisione non intera:

print(mat/2)
print(mat.astype(float)/2)
[[0.5 1.  1.5]
 [2.  2.5 3. ]]
[[0.5 1.  1.5]
 [2.  2.5 3. ]]

23.9. Gestione della Memoria in Numpy#

Numpy gestisce la memoria in maniera dinamica per questioni di efficienza. Pertanto, un assegnamento o una operazione di slicing, in genere non creano una nuova copia dei dati. Si consideri ad esempio questo codice:

a=np.array([[1,2,3],[4,5,6]])
print(a)
b=a[0,0:2]
print(b)
[[1 2 3]
 [4 5 6]]
[1 2]

L’operazione di slicing b=a[0,0:2] ha solo permesso di ottenere una nuova “vista” di una parte di a, ma i dati non sono stati replicati in memoria. Pertanto, se modifichiamo un elemento di b, la modifica verrà applicata in realtà ad a:

b[0]=-1
print(b)
print(a)
[-1  2]
[[-1  2  3]
 [ 4  5  6]]

Per evitare questo genere di comportamenti, è possibile utilizzare il metodo copy che forza numpy a creare una nuova copia dei dati:

a=np.array([[1,2,3],[4,5,6]])
print(a)
b=a[0,0:2].copy()

print(b)
b[0]=-1
print(b)
print(a)
[[1 2 3]
 [4 5 6]]
[1 2]
[-1  2]
[[1 2 3]
 [4 5 6]]

In questa nuova versione del codice, a non viene più modificato alla modifica di b.

23.10. Broadcasting#

Numpy gestisce in maniera intelligente le operazioni tra array che presentano shape diverse sotto determinate condizioni. Vediamo un esempio pratico: supponiamo di avere una matrice \(2\times3\) e un array \(1\times3\):

mat=np.array([[1,2,3],[4,5,6]],dtype=float)
arr=np.array([2,3,8])
print(mat)
print(arr)
[[1. 2. 3.]
 [4. 5. 6.]]
[2 3 8]

Supponiamo adesso di voler dividere, elemento per elemento, tutti i valori di ogni riga della matrice per i valori dell’array. Possiamo eseguire l’operazione richiesta mediante un ciclo for:

mat2=mat.copy() #copia il contenuto della matrice per non sovrascriverla
for i in range(mat2.shape[0]):#indicizza le righe
    mat2[i]=mat2[i]/arr
print(mat2)
[[0.5        0.66666667 0.375     ]
 [2.         1.66666667 0.75      ]]
arr.shape
(3,)

Se non volessimo utilizzare cicli for, potremmo replicare arr in modo da ottenere una matrice \(2 \times 3\) e poi effettuare una semplice divisione elemento per elemento:

arr2=np.stack([arr,arr])
print(arr2)

print(mat/arr2)
[[2 3 8]
 [2 3 8]]
[[0.5        0.66666667 0.375     ]
 [2.         1.66666667 0.75      ]]

Lo stesso risultato si può ottenere semplicemente chiedendo a numpy di dividere mat per arr:

print(mat/arr)
[[0.5        0.66666667 0.375     ]
 [2.         1.66666667 0.75      ]]

Ciò avviene in quanto numpy confronta le dimensioni dei due operandi (\(2 \times 3\) e \(1 \times 3\)) e adatta l’operando con shape più piccola a quello con shape più grande, replicandone gli elementi lungo la dimensione unitaria (la prima). Il broadcasting in pratica generalizza le operazioni tra scalari e vettori/matrici del tipo:

print(2*mat)
print(2*arr)
[[ 2.  4.  6.]
 [ 8. 10. 12.]]
[ 4  6 16]

In generale, quando vengono effettuate operazioni tra due array, numpy compara le shape dimensione per dimensione, dall’ultima alla prima. Due dimensioni sono compatibili se:

  • Sono uguali;

  • Una di loro è uguale a uno.

Inoltre, le due shape non devono avere necessariamente lo stesso numero di dimensioni.

Ad esempio, le seguenti shape sono compatibili:

\[\begin{split} 2 \times 3 \times 5 \\ 2 \times 3 \times 5 \end{split}\]
\[\begin{split} 2 \times 3 \times 5 \\ 2 \times 1 \times 5 \end{split}\]
\[\begin{split} 2 \times 3 \times 5 \\ 3 \times 5 \end{split}\]
\[\begin{split} 2 \times 3 \times 5 \\ 3 \times 1 \end{split}\]

Vediamo altri esempi di broadcasting:

mat1=np.array([[[1,3,5],[7,6,2]],[[6,5,2],[8,9,9]]])
mat2=np.array([[2,1,3],[7,6,2]])
print("Mat1 shape",mat1.shape)
print("Mat2 shape",mat2.shape)
print()
print("Mat1\n",mat1)
print()
print("Mat2\n",mat2)
print()
print("Mat1*Mat2\n",mat1*mat2)
Mat1 shape (2, 2, 3)
Mat2 shape (2, 3)

Mat1
 [[[1 3 5]
  [7 6 2]]

 [[6 5 2]
  [8 9 9]]]

Mat2
 [[2 1 3]
 [7 6 2]]

Mat1*Mat2
 [[[ 2  3 15]
  [49 36  4]]

 [[12  5  6]
  [56 54 18]]]

Il prodotto tra i due tensori è stato effettuato moltiplicando le matrici bidimensionali mat1[0,...] e mat2[0,...] per mat2. Ciò è equivalente a ripetere gli elementi di mat2 lungo la dimensione mancante ed effettuare un prodotto punto a punto tra mat1 e la versione adattata di mat2.

mat1=np.array([[[1,3,5],[7,6,2]],[[6,5,2],[8,9,9]]])
mat2=np.array([[[1,3,5]],[[6,5,2]]])
print("Mat1 shape",mat1.shape)
print("Mat2 shape",mat2.shape)
print()
print("Mat1\n",mat1)
print()
print("Mat2\n",mat2)
print()
print("Mat1*Mat2\n",mat1*mat2)
Mat1 shape (2, 2, 3)
Mat2 shape (2, 1, 3)

Mat1
 [[[1 3 5]
  [7 6 2]]

 [[6 5 2]
  [8 9 9]]]

Mat2
 [[[1 3 5]]

 [[6 5 2]]]

Mat1*Mat2
 [[[ 1  9 25]
  [ 7 18 10]]

 [[36 25  4]
  [48 45 18]]]

In questo caso, il prodotto tra i due tensori è stato ottenuto moltiplicando tutte le righe delle matrici bidimensionali mat1[0,...] per mat2[0] (prima riga di mat2) e tutte le righe delle matrici bidimensionali mat1[1,...] per mat2[1] (seconda riga di mat2). Ciò è equivalente a ripetere tutti gli elementi di mat2 lungo la seconda dimensione (quella contenente \(1\)) ed effettuare un prodotto punto a punto tra mat1 e la versione adattata di mat2.

23.11. Esercizi#

🧑‍💻 Esercizio 1

Definire una matrice 3x4, poi:

  • Stampare la prima riga della matrice;

  • Stampare la seconda colonna della matrice;

  • Sommare la prima e l’ultima colonna della matrice;

  • Sommare gli elementi lungo la diagonale principale;

  • Stampare il numero di elementi della matrice.

🧑‍💻 Esercizio 2

Si generi un array di \(100\) numeri compresi tra \(2\) e \(4\) e si calcoli la somma degli elementi i cui quadrati hanno un valore maggiore di \(8\).

🧑‍💻 Esercizio 3

Si generi la seguente matrice scrivendo la minore quantità di codice possibile:

0 1 2
3 4 5
6 7 8

Si ripeta l’esercizio generando una matrice \(25 \times 13\) dello stesso tipo.