25. Introduzione a Pandas#

Abbiamo visto come Python, unito a numpy e matplotlib possa essere un potente strumento per il calcolo scientifico. Esistono tuttavia altre librerie che forniscono una maggiore astrazione per una più agevole gestione e analisi dei dati. In questo laboratorio vedremo le basi di della libreria Pandas, utile per l’analisi dei dati. In particolare vedremo:

  • Come creare e gestire serie di dati (Pandas Series);

  • Come creare e gestire DataFrame di Pandas;

  • Esempi su com manipolare i dati di un DataFrame.

Pandas è una libreria di alto livello che mette a disposizione diversi strumenti e strutture dati per l’analisi dei dati. In particolare, Pandas è molto utile per caricare, manipolare e visualizzare i dati i maniera veloce e conveniente prima di passare all’analisi vera e propria. Le due strutture dati principali di Pandas sono le Series e i DataFrame.

25.1. Series#

Una Series è una struttura monodimensionale (una serie di dati) molto simile a un array di numpy. A differenza di esso, i suoi elementi possono essere indicizzati mediante delle etichette, oltre che mediante numeri. I valori contenuti in una serie possono essere di qualsiasi tipo.

25.1.1. Creazione di Series#

E’ possibile definire una Series a partire da una lista o da un array di numpy:

import pandas as pd
import numpy as np
np.random.seed(123) #impostiamo un seed per ripetibilità
s1 = pd.Series([7,5,2,8,9,6])# alternativamente print(pd.Series(np.array([7,5,2])))
print(s1)
0    7
1    5
2    2
3    8
4    9
5    6
dtype: int64

I numeri visualizzati sulla sinistra rappresentano le etichette dei valori contenute nella serie, che, di default sono di tipo numerico e sequenziale. Durante la definizione di una serie, è possibile specificare delle opportune etichette (una per ogni valore):

valori = [7,5,2,8,9,6]
etichette = ['a','b','c','d','e','f']
s2 = pd.Series(valori, index=etichette)
print(s2)
a    7
b    5
c    2
d    8
e    9
f    6
dtype: int64

E’ possibile definire una serie anche mediante un dizionario che specifica contemporaneamente etichette e valori:

s3=pd.Series({'a':14,'h':18,'m':72})
print(s3)
a    14
h    18
m    72
dtype: int64

Opzionalmente, è possibile assegnare un nome a una serie:

pd.Series([1,2,3], name='Mia Serie')
0    1
1    2
2    3
Name: Mia Serie, dtype: int64

🙋‍♂️ Domanda 1

Qual è la principale differenza tra Series di Pandas e array di Numpy?

25.1.2. Indicizzazione di Series#

Quando gli indici sono numerici, le serie possono essere indicizzate come gli array di numpy:

print(s1[0]) #indicizzazione
print(s1[0:4:2]) #slicing
7
0    7
2    2
dtype: int64

Quando gli indici sono delle più generiche etichette, l’indicizzazione avviene in maniera simile, ma non è possibile utilizzare lo slicing:

print(s2['c'])
2

Chiaramente, è possibile modificare i valori di una serie mediante l’indicizzazione:

s2['c']=4
s2
a    7
b    5
c    4
d    8
e    9
f    6
dtype: int64

Qualora l’indice specificato non esistesse, un nuovo elemento verrà creato:

s2['z']=-2
s2
a    7
b    5
c    4
d    8
e    9
f    6
z   -2
dtype: int64

Qualora volessimo specificare più di una etichetta alla volta, potremmo passare una lista di etichette:

print(s2[['a','c','d']])
a    7
c    4
d    8
dtype: int64

Le serie con etichette alfanumeriche possono essere indicizzate anche seguendo l’ordine in cui i dati sono inseriti nella serie, in un certo senso “scartando” le etichette alfanumeriche e indicizzando gli elementi in maniera posizionale. Questo effetti si ottiene mediante il metodo iloc:

print(s2,'\n')
print("Elemento di indice 'a':",s2['a'])
print("Primo elemento della serie:",s2.iloc[0])
a    7
b    5
c    4
d    8
e    9
f    6
z   -2
dtype: int64 

Elemento di indice 'a': 7
Primo elemento della serie: 7

In certi casi, può essere utile ripristinare la numerazione degli indici. Ciò può essere fatto usando il metodo reset_index:

print(s3,'\n')
print(s3.reset_index(drop=True))#drop=True indica di scartare i vecchi indici
a    14
h    18
m    72
dtype: int64 

0    14
1    18
2    72
dtype: int64

Le serie ammettono anche l’indicizzazione logica:

print(s1,'\n') #serie s1
print(s1>2,'\n') #indicizzazione logica per selezionare gli elementi maggiori di 2
print(s1[s1>2],'\n') #applicazione dell'indicizzazione logica
0    7
1    5
2    2
3    8
4    9
5    6
dtype: int64 

0     True
1     True
2    False
3     True
4     True
5     True
dtype: bool 

0    7
1    5
3    8
4    9
5    6
dtype: int64 

E’ possibile specificare la combinazione tra due condizione mediante gli operatori logici “|” (or) e “&” (and), ricordando di racchiudere gli operandi tra parentesi tonde:

(s1>2) & (s1<6)
0    False
1     True
2    False
3    False
4    False
5    False
dtype: bool
s1[(s1>2) & (s1<6)]
1    5
dtype: int64

Analogamente per l’or:

(s1<2) | (s1>6)
0     True
1    False
2    False
3     True
4     True
5    False
dtype: bool
print(s1[(s1<2) | (s1>6)])
0    7
3    8
4    9
dtype: int64

Come nel caso degli array di Numpy, l’allocazione della memoria è gestita dinamicamente per le Serie. Pertanto, se assegno una serie ad una nuova variabile e modifico la seconda variabile, verrà modificata anche la prima:

s11=pd.Series([1,2,3])
s12=s11
s12[0]=-1
s11
0   -1
1    2
2    3
dtype: int64

Per ottenere una nuova Serie indipendente, possiamo usare il metodo copy:

s11=pd.Series([1,2,3])
s12=s11.copy()
s12[0]=-1
s11
0    1
1    2
2    3
dtype: int64

🙋‍♂️ Domanda 2

Cosa stampa a schermo il seguente codice?

s=pd.Series([1,2,3,4,6])
print(s[s%2])

25.1.3. Tipi di dato#

Le Series possono contenere diversi tipi di dato:

pd.Series([2.5,3.4,5.2])
0    2.5
1    3.4
2    5.2
dtype: float64

Ad una Series viene associato un unico tipo di dato. Se specifichiamo dati di tipi eterogenei, la Series sarà di tipo “object”:

s=pd.Series([2.5,'A',5.2])
s
0    2.5
1      A
2    5.2
dtype: object

E’ possibile cambiare il tipo di dato di una serie al volo con astype in maniera simile a quanto avviene con gli array di Numpy:

s=pd.Series([2,3,8,9,12,45])
print(s)
print(s.astype(float))
0     2
1     3
2     8
3     9
4    12
5    45
dtype: int64
0     2.0
1     3.0
2     8.0
3     9.0
4    12.0
5    45.0
dtype: float64

🙋‍♂️ Domanda 3

Considerata la Series definita come segue:

s=pd.Series([2.5,'A',5.2])

quali delle seguenti operazioni restituiscono un errore? Perché?

s.astype(str), s.astype(int)

25.1.4. Dati Mancanti#

In Pandas, è possibile specificare Series (e come vedremo anche DataFrame) con dati mancanti. Per indicare tali valori, viene utilizzato il valore np.nan:

missing = pd.Series([2,5,np.nan,8])
missing
0    2.0
1    5.0
2    NaN
3    8.0
dtype: float64

I valori NaN (not a number) indicano dei valori che possono essere mancanti per diversi motivi (es. dati risultato di operazioni non validi, dati che non sono stati raccolti correttamente, ecc).

In molti casi, può essere utile scartare i dati NaN. Ciò si può fare con l’operazione dropna():

missing.dropna()
0    2.0
1    5.0
3    8.0
dtype: float64

Alternativamente, possiamo sostituire i valori NaN con un valore specifico con fillna. Sostituiamo i NaN con zero:

missing = pd.Series([2,5,np.nan,8])
print(missing)

missing.fillna(0)
0    2.0
1    5.0
2    NaN
3    8.0
dtype: float64
0    2.0
1    5.0
2    0.0
3    8.0
dtype: float64

25.1.5. Operazioni su e tra Series#

Sono definite sulle serie le principali operazioni disponibili per gli array di numpy:

print("Min:",s1.min())
print("Max:",s1.max())
print("Mean:",s1.mean())
Min: 2
Max: 9
Mean: 6.166666666666667

Vediamo come sostiuire i valori mancanti di una serie con il valore medio:

data = pd.Series([1,5,2,np.nan,5,2,6,np.nan,9,2,3])
data

data.fillna(data.mean())
0     1.000000
1     5.000000
2     2.000000
3     3.888889
4     5.000000
5     2.000000
6     6.000000
7     3.888889
8     9.000000
9     2.000000
10    3.000000
dtype: float64

Si noti che la funzione mean ha semplicemente ignorato i valori NaN.

E’ possibile ottenere la dimensione di una serie mediante la funzione len:

print(len(s1))
6

E’ possibile ottenere delle statistiche sui valori univoci (ovvero i valori della serie, una volta rimossi i duplicati) contenuti in una serie mediante il metodo unique:

print("Unique:",s1.unique()) #restituisce i valori univoci
Unique: [7 5 2 8 9 6]

Per conoscere il numero di valori univoci in una serie, possiamo utilizzare il metodo nunique:

s1.nunique()
6

E’ possibile ottenere i valori univoci di una serie insieme alle frequenze con le quali essi appaiono nella serie mediante il metodo value_counts:

tmp = pd.Series(np.random.randint(0,10,100))
print(tmp.unique()) #valori univoci
tmp.value_counts() #valori univoci con relative frequenze
[2 6 1 3 9 0 4 7 8 5]
3    15
6    14
1    13
2    10
9    10
0    10
4    10
7     8
8     5
5     5
Name: count, dtype: int64

Il risultato di value_counts è una Series in cui gli indici rappresentano i valori univoci, mentre i valori sono le frequenze con cui essi appaiono nella serie. La serie è ordinata per valori.

Il metodo describe permette di calcolare diverse statistiche dei valori contenuti nella serie:

tmp.describe()
count    100.000000
mean       4.130000
std        2.830765
min        0.000000
25%        2.000000
50%        4.000000
75%        6.000000
max        9.000000
dtype: float64

Nelle operazioni tra serie, gli elementi vengono associati in base agli indici. Nel caso in cui esiste una perfetta corrispondenza tra gli elementi otteniamo il seguente risultato:

print(pd.Series([1,2,3])+pd.Series([4,4,4]),'\n')
print(pd.Series([1,2,3])*pd.Series([4,4,4]),'\n')
0    5
1    6
2    7
dtype: int64 

0     4
1     8
2    12
dtype: int64 

Nel caso in cui alcuni indici dovessero essere mancanti, le caselle corrispondenti verranno riempite con NaN (not a number) per indicare l’assenza del valore:

s1 = pd.Series([1,4,2], index = [1,2,3])
s2 = pd.Series([4,2,8], index = [0,1,2])
print(s1,'\n')
print(s2,'\n')
print(s1+s2)
1    1
2    4
3    2
dtype: int64 

0    4
1    2
2    8
dtype: int64 

0     NaN
1     3.0
2    12.0
3     NaN
dtype: float64

In questo caso, l’indice 0 era presente solo nella seconda serie (s2), mentre l’indice 3 era presente solo nella prima serie (s1).

Se vogliamo escludere i valori NaN (inclusi i relativi indici) possiamo utilizzare il metodo dropna:

s3=s1+s2
print(s3)
print(s3.dropna())
0     NaN
1     3.0
2    12.0
3     NaN
dtype: float64
1     3.0
2    12.0
dtype: float64

E’ possibile applicare una funzione a tutti gli elementi di una serie mediante il metodo apply. Supponiamo ad esempio di voler trasformare delle stringhe contenute in una serie in uppercase. Possiamo specificare la funzione str.upper mediante il metodo apply:

s=pd.Series(['aba','cda','daf','acc'])
s.apply(str.upper)
0    ABA
1    CDA
2    DAF
3    ACC
dtype: object

Mediante apply possiamo applicare anche funzioni definite dall’utente mediante espressioni lambda o mediante la consueta sintassi:

def miafun(x):
    y="Stringa: "
    return y+x
s.apply(miafun)
0    Stringa: aba
1    Stringa: cda
2    Stringa: daf
3    Stringa: acc
dtype: object

La stessa funzione si può scrivere in maniera più compatta come espressione lambda:

s.apply(lambda x: "Stringa: "+x)
0    Stringa: aba
1    Stringa: cda
2    Stringa: daf
3    Stringa: acc
dtype: object

E’ possibile modificare tutte le occorrenze di un dato valore di una serie mediante il metodo replace:

ser = pd.Series([1,2,15,-1,7,9,2,-1])
print(ser)
ser=ser.replace({-1:0}) #sostituisci tutti le occorrenze di "-1" con zeri
print(ser)
0     1
1     2
2    15
3    -1
4     7
5     9
6     2
7    -1
dtype: int64
0     1
1     2
2    15
3     0
4     7
5     9
6     2
7     0
dtype: int64

🙋‍♂️ Domanda 4

Si provi ad applicare il metodo apply ad una Series specificando una funzione che prende in unput due argomenti. E’ possibile farlo? Perché?

25.1.6. Conversione in array di Numpy#

E’ possibile accedere ai valori della series in forma di array di numpy mediante la proprietà values:

print(s3.values)
[nan  3. 12. nan]

Bisogna fare attenzione al fatto che values non crea una copia indipendente della serie, per cui, se modifichiamo l’array di numpy accessibile mediante values, di fatto modifichiamo anche la serie:

a = s3.values
print(s3)
a[0]=-1
print(s3)
0     NaN
1     3.0
2    12.0
3     NaN
dtype: float64
0    -1.0
1     3.0
2    12.0
3     NaN
dtype: float64

🙋‍♂️ Domanda 5

Come si può ottenere un array di Numpy da un Series in modo che le due entità siano indipendenti?

25.2. DataFrame#

Un DataFrame è sostanzialmente una tabella di numeri in cui:

  • Ogni riga rappresenta una osservazione diversa;

  • Ogni colonna rappresenta una variabile.

Righe e colonne possono avere dei nomi. In particolare, è molto comune assegnare dei nomi alle colonne per indicare a quale variabile corrisponde ognuna di esse.

25.2.1. Costruzione e visualizzazione di DataFrame#

E’ possibile costruire un DataFrame a partire da un array bidimensionale (una matrice) di numpy:

dati = np.random.rand(10,3) #matrice di valori random 10 x 3
#si tratta di una matrice di 10 osservazioni, ognuna caratterizzata da 3 variabili

df = pd.DataFrame(dati,columns=['A','B','C'])

df #in jupyter o in una shell ipython possiamo stampare il dataframe
#semplicemente scrivendo "df". In uno script dovremmo scrivere "print df"
A B C
0 0.309884 0.507204 0.280793
1 0.763837 0.108542 0.511655
2 0.909769 0.218376 0.363104
3 0.854973 0.711392 0.392944
4 0.231301 0.380175 0.549162
5 0.556719 0.004135 0.638023
6 0.057648 0.043027 0.875051
7 0.292588 0.762768 0.367865
8 0.873502 0.029424 0.552044
9 0.240248 0.884805 0.460238

Ad ogni riga è stato assegnato in automatico un indice numerico. Se vogliamo possiamo specificare nomi anche per le righe:

np.random.seed(123)#imposiamo un seed per ripetitibilità
df = pd.DataFrame(np.random.rand(4,3),columns=['A','B','C'],index=['X','Y','Z','W'])
df
A B C
X 0.696469 0.286139 0.226851
Y 0.551315 0.719469 0.423106
Z 0.980764 0.684830 0.480932
W 0.392118 0.343178 0.729050

Analogamente a quanto visto nel caso delle serie, è possibile costruire un DataFrame mediante un dizionario che specifica nome e valori di ogni colonna:

pd.DataFrame({'A':np.random.rand(10), 'B':np.random.rand(10), 'C':np.random.rand(10)})
A B C
0 0.438572 0.724455 0.430863
1 0.059678 0.611024 0.493685
2 0.398044 0.722443 0.425830
3 0.737995 0.322959 0.312261
4 0.182492 0.361789 0.426351
5 0.175452 0.228263 0.893389
6 0.531551 0.293714 0.944160
7 0.531828 0.630976 0.501837
8 0.634401 0.092105 0.623953
9 0.849432 0.433701 0.115618

Nel caso di dataset molto grandi, possiamo visualizzare solo le prime righe usando il metodo head:

df_big = pd.DataFrame(np.random.rand(100,3),columns=['A','B','C'])
df_big.head()
A B C
0 0.317285 0.414826 0.866309
1 0.250455 0.483034 0.985560
2 0.519485 0.612895 0.120629
3 0.826341 0.603060 0.545068
4 0.342764 0.304121 0.417022

E’ anche possibile specificare il numero di righe da mostrare:

df_big.head(2) 
A B C
0 0.317285 0.414826 0.866309
1 0.250455 0.483034 0.985560

In maniera simile, possiamo mostrare le ultime righe con tail:

df_big.tail(3)
A B C
97 0.811953 0.335544 0.349566
98 0.389874 0.754797 0.369291
99 0.242220 0.937668 0.908011

Possiamo ottenere il numero di righe del DataFrame mediante la funzione len:

print(len(df_big))
100

Se vogliamo conoscere sia il numero di righe che il numero di colonne, possiamo richiamare la proprietà shape:

print(df_big.shape)
(100, 3)

Analogamente a quanto visto per le Series, possiamo visualizzare un DataFrame come un array di numpy richiamando la proprietà values:

print(df,'\n')
print(df.values)
          A         B         C
X  0.696469  0.286139  0.226851
Y  0.551315  0.719469  0.423106
Z  0.980764  0.684830  0.480932
W  0.392118  0.343178  0.729050 

[[0.69646919 0.28613933 0.22685145]
 [0.55131477 0.71946897 0.42310646]
 [0.9807642  0.68482974 0.4809319 ]
 [0.39211752 0.34317802 0.72904971]]

Anche per i DataFrame valgono le stesse considerazioni sulla memoria fatte per le Series. Per ottenere una copia indipendente di una DataFrame, è possibile utilizzare il metodo copy:

df2=df.copy()

🙋‍♂️ Domanda 6

In cosa differisce principalmente un DataFrame da un array bidimensionale di Numpy? Qual è il vantaggio dei DataFrame?

25.2.2. Indicizzazione#

Pandas mette a disposizione una serie di strumenti per indicizzare i DataFrame selezionandone righe o colonne. Ad esempio, possiamo selezionare la colonna B come segue:

s=df['B']
print(s,'\n')
print("Tipo di s:",type(s))
X    0.286139
Y    0.719469
Z    0.684830
W    0.343178
Name: B, dtype: float64 

Tipo di s: <class 'pandas.core.series.Series'>

Da notare che il risultato di questa operazione è una Series (un DataFrame è in fondo una collezione di Series, ognuna rappresentante una colonna) che ha come nome il nome della colonna considerata. Possiamo selezionare più di una riga specificando una lista di righe:

dfAB = df[['A','C']]
dfAB
A C
X 0.696469 0.226851
Y 0.551315 0.423106
Z 0.980764 0.480932
W 0.392118 0.729050

Il risultato di questa operazione è invece un DataFrame. La selezione delle righe avviene mediante la proprietà loc:

df5 = df.loc['X']
df5 
A    0.696469
B    0.286139
C    0.226851
Name: X, dtype: float64

Anche il risultato di questa operazione è una Series, ma in questo caso gli indici rappresentano i nomi delle colonne, mentre il nome della serie corrisponde all’indice della riga selezionata. Come nel caso delle Series, possiamo usare iloc per indicizzare le righe in maniera posizionale:

df.iloc[1] #equivalente a df.loc['Y']
A    0.551315
B    0.719469
C    0.423106
Name: Y, dtype: float64

E’ anche possibile concatenare le operazioni di indicizzazione per selezionare uno specifico valore:

print(df.iloc[1]['A'])
print(df['A'].iloc[1])
0.5513147690828912
0.5513147690828912

Anche in questo caso possiamo utilizzare l’indicizzazione logica in in maniera simile a quanto visto per numpy:

df_big2=df_big[df_big['C']>0.5]
print(len(df_big), len(df_big2))
df_big2.head() #alcuni indici sono mancanti in quanto le righe corrispondenti sono state rimosse
100 53
A B C
0 0.317285 0.414826 0.866309
1 0.250455 0.483034 0.985560
3 0.826341 0.603060 0.545068
5 0.681301 0.875457 0.510422
6 0.669314 0.585937 0.624904

E’ possibile combinare quanto visto finora per manipolare i dati in maniera semplice e veloce. Supponiamo di voler selezionare le righe per le quali la somma tra i valori di B e C è minore di \(0.7\) e supponiamo di essere interessati solo ai valori A di tali righe (e non all’intera riga di valori A,B,C). Il risultato che ci aspettiamo è un array monodimensionale di valori. Possiamo ottenere il risultato voluto come segue:

res = df[(df['B']+df['C'])>0.7]['A']
print(res.head(), res.shape)
Y    0.551315
Z    0.980764
W    0.392118
Name: A, dtype: float64 (3,)

Possiamo applicare l’indicizzazione anche a livello dell’intera tabella (oltre che al livello delle singole colonne):

df>0.3
A B C
X True False False
Y True True True
Z True True True
W True True True

Se applichiamo questa indicizzazione al DataFrame otterremo l’apparizione di alcuni NaN, che indicano la presenza degli elementi che non rispettano la condizione considerata:

df[df>0.3]
A B C
X 0.696469 NaN NaN
Y 0.551315 0.719469 0.423106
Z 0.980764 0.684830 0.480932
W 0.392118 0.343178 0.729050

Possiamo rimuovere i valori NaN mediante il metodo dropna come visto nel caso delle Series. Tuttavia, in questo caso, verranno rimosse tutte le righe che presentano almeno un NaN:

df[df>0.3].dropna()
A B C
Y 0.551315 0.719469 0.423106
Z 0.980764 0.684830 0.480932
W 0.392118 0.343178 0.729050

Possiamo chiedere a dropna di rimuovere le colonne che presentano almeno un NaN specificando axis=1:

df[df>0.3].dropna(axis=1)
A
X 0.696469
Y 0.551315
Z 0.980764
W 0.392118

Alternativamente possiamo sostituire i valori NaN al volo mediante la funzione fillna:

df[df>0.3].fillna('VAL') #sostiuisce i NaN con 'VAL'
A B C
X 0.696469 VAL VAL
Y 0.551315 0.719469 0.423106
Z 0.980764 0.68483 0.480932
W 0.392118 0.343178 0.72905

Anche in questo caso, come visto nel caso delle Series possiamo ripristinare l’ordine degli indici mediante il metodo reset_index:

print(df)
print(df.reset_index(drop=True))
          A         B         C
X  0.696469  0.286139  0.226851
Y  0.551315  0.719469  0.423106
Z  0.980764  0.684830  0.480932
W  0.392118  0.343178  0.729050
          A         B         C
0  0.696469  0.286139  0.226851
1  0.551315  0.719469  0.423106
2  0.980764  0.684830  0.480932
3  0.392118  0.343178  0.729050

Se non specifichiamo drop=True, i vecchi indici verranno mantenuti come nuova colonna:

print(df.reset_index())
  index         A         B         C
0     X  0.696469  0.286139  0.226851
1     Y  0.551315  0.719469  0.423106
2     Z  0.980764  0.684830  0.480932
3     W  0.392118  0.343178  0.729050

Possiamo impostare qualsiasi colonna come nuovo indice. Ad esempio:

df.set_index('A')
B C
A
0.696469 0.286139 0.226851
0.551315 0.719469 0.423106
0.980764 0.684830 0.480932
0.392118 0.343178 0.729050

Va notato che questa operazione non modifica di fatto il DataFrame, ma crea una nuova “vista” dei dati con la modifica richiesta:

df
A B C
X 0.696469 0.286139 0.226851
Y 0.551315 0.719469 0.423106
Z 0.980764 0.684830 0.480932
W 0.392118 0.343178 0.729050

Per utilizzare questa versione modificata del dataframe, possiamo salvarla in un’altra variabile:

df2=df.set_index('A')
df2
B C
A
0.696469 0.286139 0.226851
0.551315 0.719469 0.423106
0.980764 0.684830 0.480932
0.392118 0.343178 0.729050

🙋‍♂️ Domanda 7

Si consideri il seguente DataFrame:

pd.DataFrame({'A':[1,2,3,4],'B':[3,2,6,7],'C':[0,2,-1,12]})

Si utilizzi l’indicizzazione logica per selezionare le righe in cui la somma dei valori di ‘C’ ed ‘A’ sia maggiore di \(1\).

25.2.3. Manipolazione di DataFrame#

I valori contenuti nelle righe e nelle colonne del dataframe possono essere facilmente modificati. Il seguente esempio moltiplica tutti i valori della colonna B per 2:

df['B']*=2
df.head()
A B C
X 0.696469 0.572279 0.226851
Y 0.551315 1.438938 0.423106
Z 0.980764 1.369659 0.480932
W 0.392118 0.686356 0.729050

Analogamente possiamo dividere tutti i valori della riga di indice 2 per 3:

df.iloc[2]=df.iloc[2]/3
df.head()
A B C
X 0.696469 0.572279 0.226851
Y 0.551315 1.438938 0.423106
Z 0.326921 0.456553 0.160311
W 0.392118 0.686356 0.729050

Possiamo definire una nuova colonna con una semplice operazione di assegnamento:

df['D'] = df['A'] + df['C']
df['E'] = np.ones(len(df))*5
df.head()
A B C D E
X 0.696469 0.572279 0.226851 0.923321 5.0
Y 0.551315 1.438938 0.423106 0.974421 5.0
Z 0.326921 0.456553 0.160311 0.487232 5.0
W 0.392118 0.686356 0.729050 1.121167 5.0

Possiamo rimuovere una colonna mediante il metodo drop e specificando axis=1 per indicare che vogliamo rimuovere una colonna:

df.drop('E',axis=1).head()
A B C D
X 0.696469 0.572279 0.226851 0.923321
Y 0.551315 1.438938 0.423106 0.974421
Z 0.326921 0.456553 0.160311 0.487232
W 0.392118 0.686356 0.729050 1.121167

Il metodo drop non modifica il DataFrame ma genera solo una nuova “vista” senza la colonna da rimuovere:

df.head()
A B C D E
X 0.696469 0.572279 0.226851 0.923321 5.0
Y 0.551315 1.438938 0.423106 0.974421 5.0
Z 0.326921 0.456553 0.160311 0.487232 5.0
W 0.392118 0.686356 0.729050 1.121167 5.0

Possiamo rimuovere la colonna effettivamente mediante un assegnamento:

df=df.drop('E',axis=1)
df.head()
A B C D
X 0.696469 0.572279 0.226851 0.923321
Y 0.551315 1.438938 0.423106 0.974421
Z 0.326921 0.456553 0.160311 0.487232
W 0.392118 0.686356 0.729050 1.121167

La rimozione delle righe avviene allo stesso modo, ma bisogna specificare axis =0:

df.drop('X', axis=0)
A B C D
Y 0.551315 1.438938 0.423106 0.974421
Z 0.326921 0.456553 0.160311 0.487232
W 0.392118 0.686356 0.729050 1.121167

E’ inoltre possibile aggiungere una nuova riga in coda al DataFrame mediante il metodo append. Dato che le righe di un DataFrame sono delle Series, dovremo costruire una serie con i giusti indici (corrispondenti alle colonne del DataFrame) e il giusto nome (corrispondente al nuovo indice):

new_row=pd.Series([1,2,3,4], index=['A','B','C','D'], name='H')
print(new_row)
df.append(new_row)
A    1
B    2
C    3
D    4
Name: H, dtype: int64
A B C D
X 0.696469 0.572279 0.226851 0.923321
Y 0.551315 1.438938 0.423106 0.974421
Z 0.326921 0.456553 0.160311 0.487232
W 0.392118 0.686356 0.729050 1.121167
H 1.000000 2.000000 3.000000 4.000000

Possiamo aggiungere più di una riga alla volta specificando un DataFrame:

new_rows = pd.DataFrame({'A':[0,1],'B':[2,3],'C':[4,5],'D':[6,7]}, index=['H','K'])
new_rows
A B C D
H 0 2 4 6
K 1 3 5 7
df.append(new_rows)
A B C D
X 0.696469 0.572279 0.226851 0.923321
Y 0.551315 1.438938 0.423106 0.974421
Z 0.326921 0.456553 0.160311 0.487232
W 0.392118 0.686356 0.729050 1.121167
H 0.000000 2.000000 4.000000 6.000000
K 1.000000 3.000000 5.000000 7.000000

🙋‍♂️ Domanda 8

Si consideri il seguente DataFrame:

pd.DataFrame({'A':[1,2,3,4],'B':[3,2,6,7],'C':[0,2,-1,12]})

Si inserisca nel DataFrame una nuova colonna ‘D’ che contenga il valore \(1\) in tutte le righe in cui il valore di B è superiore al valore di C.

Suggerimento: si usi “astype(int)” per trasformare i booleani in interi.

25.2.4. Operazioni tra e su DataFrame#

Restano definite sui DataFrame, con le opportune differenze, le operazioni viste nel caso delle serie. In genere, queste vengono applicate a tutte le colonne del DataFrame in maniera indipendente:

df.mean() #media di ogni colonna
A    0.491706
B    0.788531
C    0.384830
D    0.876535
dtype: float64
df.max() #massimo di ogni colonna
A    0.696469
B    1.438938
C    0.729050
D    1.121167
dtype: float64
df.describe() #statistiche per ogni colonna
A B C D
count 4.000000 4.000000 4.000000 4.000000
mean 0.491706 0.788531 0.384830 0.876535
std 0.165884 0.443638 0.255159 0.272747
min 0.326921 0.456553 0.160311 0.487232
25% 0.375818 0.543347 0.210216 0.814298
50% 0.471716 0.629317 0.324979 0.948871
75% 0.587603 0.874502 0.499592 1.011108
max 0.696469 1.438938 0.729050 1.121167

Dato che le colonne di un DataFrame sono delle serie, ad esse può essere applicato il metodo apply:

df['A']=df['A'].apply(lambda x: "Numero: "+str(x))
df
A B C D
X Numero: 0.6964691855978616 0.572279 0.226851 0.923321
Y Numero: 0.5513147690828912 1.438938 0.423106 0.974421
Z Numero: 0.3269213994615385 0.456553 0.160311 0.487232
W Numero: 0.3921175181941505 0.686356 0.729050 1.121167

E’ possibile ordinare le righe di un DataFrame rispetto ai valori di una delle colonne mediante il metodo sort_values:

df.sort_values(by='D')
A B C D
Z Numero: 0.3269213994615385 0.456553 0.160311 0.487232
X Numero: 0.6964691855978616 0.572279 0.226851 0.923321
Y Numero: 0.5513147690828912 1.438938 0.423106 0.974421
W Numero: 0.3921175181941505 0.686356 0.729050 1.121167

Per rendere l’ordinamento permanente, dobbiamo effettuare un assegnamento:

df=df.sort_values(by='D')
df
A B C D
Z Numero: 0.3269213994615385 0.456553 0.160311 0.487232
X Numero: 0.6964691855978616 0.572279 0.226851 0.923321
Y Numero: 0.5513147690828912 1.438938 0.423106 0.974421
W Numero: 0.3921175181941505 0.686356 0.729050 1.121167

🙋‍♂️ Domanda 9

Si consideri il seguente DataFrame:

pd.DataFrame({'A':[1,2,3,4],'B':[3,2,6,7],'C':[0,2,-1,12]})

Si trasformi la colonna “B” in modo che i nuovi valori siano:

  • uguali a zero se precedentemente pari;

  • uguali a -1 se precedentemente dispari.

25.2.5. Rimuovere righe e colonne#

È possibile rimuovere una riga o una colonna da un DataFrame:

data = pd.DataFrame({'A':[1,2,3,4],'B':[3,2,6,7],'C':[0,2,-1,12]})
print(data)

data.drop(1) #rimuoviamo la riga 1
data.drop('B', axis=1) #rimuoviamo la colonna B
   A  B   C
0  1  3   0
1  2  2   2
2  3  6  -1
3  4  7  12
A C
0 1 0
1 2 2
2 3 -1
3 4 12

Si noti che le operazioni viste sopra non modificano l’array di origine, ma ne restituiscono una copia modificata:

data = pd.DataFrame({'A':[1,2,3,4],'B':[3,2,6,7],'C':[0,2,-1,12]})
print(data)

data.drop(1) #rimuoviamo la riga 1
data
   A  B   C
0  1  3   0
1  2  2   2
2  3  6  -1
3  4  7  12
A B C
0 1 3 0
1 2 2 2
2 3 6 -1
3 4 7 12

Possiamo effettuare una modifica persistente mediante inplace=True:

data = pd.DataFrame({'A':[1,2,3,4],'B':[3,2,6,7],'C':[0,2,-1,12]})
print(data)

data.drop(1, inplace=True) #rimuoviamo la riga 1
data
   A  B   C
0  1  3   0
1  2  2   2
2  3  6  -1
3  4  7  12
A B C
0 1 3 0
2 3 6 -1
3 4 7 12

O in maniera più esplicita come segue:

data = pd.DataFrame({'A':[1,2,3,4],'B':[3,2,6,7],'C':[0,2,-1,12]})
print(data)

data = data.drop(1) #rimuoviamo la riga 1
data
   A  B   C
0  1  3   0
1  2  2   2
2  3  6  -1
3  4  7  12
A B C
0 1 3 0
2 3 6 -1
3 4 7 12

25.2.6. Groupby#

Il metodo groupby permette di raggruppare le righe di un DataFrame e richiamare delle funzioni aggreggate su di esse. Consideriamo un DataFrame un po’ più rappresentativo:

df=pd.DataFrame({'income':[10000,11000,9000,3000,1000,5000,7000,2000,7000,12000,8000],\
                 'age':[32,32,45,35,28,18,27,45,39,33,32],\
                 'sex':['M','F','M','M','M','F','F','M','M','F','F'],\
                 'company':['CDX','FLZ','PTX','CDX','PTX','CDX','FLZ','CDX','FLZ','PTX','FLZ']})
df
income age sex company
0 10000 32 M CDX
1 11000 32 F FLZ
2 9000 45 M PTX
3 3000 35 M CDX
4 1000 28 M PTX
5 5000 18 F CDX
6 7000 27 F FLZ
7 2000 45 M CDX
8 7000 39 M FLZ
9 12000 33 F PTX
10 8000 32 F FLZ

Il metodo groupby ci permette di raggruppare le righe del DataFrame per valore, rispetto a una colonna specificata. Supponiamo di voler raggruppare tutte le righe che hanno lo stesso valore di sex:

df.groupby('sex')
<pandas.core.groupby.groupby.DataFrameGroupBy object at 0x0000029E02F239E8>

Questa operazione restituisce un oggetto di tipo DataFrameGroupby sul quale sarà possibile effettuare delle operazioni aggregate (ad esempio, somme e medie). Supponiamo adesso di voler calcolare la media di tutti i valori che ricadono nello stesso gruppo (ovvero calcoliamo la media delle righe che hanno lo stesso valore di sex):

df.groupby('sex').mean()
income age
sex
F 8600.000000 28.400000
M 5333.333333 37.333333

Se siamo interessati a una sola delle variabili, possiamo selezionarla prima o dopo l’operazione sui dati aggregati:

df.groupby('sex')['age'].mean() #equivalentemente: df.groupby('sex').mean()['age']
sex
F    28.400000
M    37.333333
Name: age, dtype: float64

La tabella mostra il reddito medio e l’età media dei soggetti di sesso maschile e femminile. Dato che l’operazione di media si può applicare solo a valori numerici, la colonna Company è stata esclusa. Possiamo ottenere una tabella simile in cui mostriamo la somma dei redditi e la somma delle età cambiando mean in sum:

df.groupby('sex').sum()
income age
sex
F 43000 142
M 32000 224

In generale, è possibile utilizzare diverse funzioni aggregate oltre a mean e sum. Alcuni esempi sono min, max, std. Due funzioni particolarmente interessanti da usare in questo contesto sono count e describe. In particolare, count conta il numero di elementi interessati, mentre describe calcola diverse statistiche dei valori interessati. Vediamo due esempi:

df.groupby('sex').count()
income age company
sex
F 5 5 5
M 6 6 6

Il numero di elementi è uguale per le varie colonne in quanto non ci sono valori NaN nel DataFrame.

df.groupby('sex').describe()
age income
count mean std min 25% 50% 75% max count mean std min 25% 50% 75% max
sex
F 5.0 28.400000 6.268971 18.0 27.00 32.0 32.0 33.0 5.0 8600.000000 2880.972058 5000.0 7000.0 8000.0 11000.0 12000.0
M 6.0 37.333333 6.947422 28.0 32.75 37.0 43.5 45.0 6.0 5333.333333 3829.708431 1000.0 2250.0 5000.0 8500.0 10000.0

Per ogni variabile numerica (age e income) sono state calcolate diverse statistiche. A volte può essere più chiaro visualizzare il dataframe trasposto:

df.groupby('sex').describe().transpose()
sex F M
age count 5.000000 6.000000
mean 28.400000 37.333333
std 6.268971 6.947422
min 18.000000 28.000000
25% 27.000000 32.750000
50% 32.000000 37.000000
75% 32.000000 43.500000
max 33.000000 45.000000
income count 5.000000 6.000000
mean 8600.000000 5333.333333
std 2880.972058 3829.708431
min 5000.000000 1000.000000
25% 7000.000000 2250.000000
50% 8000.000000 5000.000000
75% 11000.000000 8500.000000
max 12000.000000 10000.000000

Questa vista ci permette di comparare diverse statistiche delle due variabili age e income per M e F.

🙋‍♂️ Domanda 10

Considerando il DataFrame precedentemente creato, si usi groupby per ottenere la somma dei redditi dei dipendenti di una data compagnia.

25.2.7. Crosstab#

Le Crosstab permettono di descrivere le relazioni tra due o più variabili categoriche. Una volta specificata una coppia di variabili categoriche, le righe e colonne della crosstab (nota anche come “tabella di contingenza”) enumerano indipendentemente tutti i valori univoci delle due variabili categoriche, così che ogni cella della crosstab identifica una determinata coppia di volari. All’interno delle celle, vengono dunque riportati i numeri di elementi per i quali le due variabili categoriche assumono una determinata coppia di valori.

Supponiamo di voler studiare le relazioni tra company e sex:

pd.crosstab(df['sex'],df['company'])
company CDX FLZ PTX
sex
F 1 3 1
M 3 1 2

La tabella sopra ci dice ad esempio che nella compagnia CDX, \(1\) soggetto è di sesso femminile, mentre \(3\) soggetti sono di sesso maschile. Allo stesso modo, un soggetto della compagnia FLZ è di sesso maschile, mentre tre soggetti sono di sesso femminile. E’ possibile ottenere delle frequenze invece che dei conteggi, mediante normalize=True:

pd.crosstab(df['sex'],df['company'], normalize=True)
company CDX FLZ PTX
sex
F 0.090909 0.272727 0.090909
M 0.272727 0.090909 0.181818

Alternativamente, possiamo normalizzare la tabella solo per righe o solo per colonne specificando normalize='index' o normalize='columns':

pd.crosstab(df['sex'],df['company'], normalize='index')
company CDX FLZ PTX
sex
F 0.2 0.600000 0.200000
M 0.5 0.166667 0.333333

Questa tabella riporta le percentuali di persone che lavorano nelle tre diverse compagnie per ciascun sesso, es., “il 20% delle donne lavora presso CDX”. Analogamente possiamo normalizzare per colonne come segue:

pd.crosstab(df['sex'],df['company'], normalize='columns')
company CDX FLZ PTX
sex
F 0.25 0.75 0.333333
M 0.75 0.25 0.666667

Questa tabella riporta le percentuali di uomini e donne che lavorano in ciascuna compagnia, es., “il 25% dei lavoratori di CDX sono donne”.

Se vogliamo studiare le relazioni tra più di due variabili categoriche, possiamo specificare una lista di colonne quando costruiamo la crosstab:

pd.crosstab(df['sex'],[df['age'],df['company']])
age 18 27 28 32 33 35 39 45
company CDX FLZ PTX CDX FLZ PTX CDX FLZ CDX PTX
sex
F 1 1 0 0 2 1 0 0 0 0
M 0 0 1 1 0 0 1 1 1 1

Ofni cella della crosstab sopra conta il numero di osservazioni che riportano una determinata terna di valori. Ad esempio, \(1\) soggetto è di sesso maschile, lavora per PTX e ha \(28\) anni, mentre \(2\) soggetti sono di sesso femminile, lavorano per FLZ e hanno \(32\) anni.

Oltre a riportare conteggi e frequenze, una crosstab permette di calcolare statistiche di terze variabili considerate non categoriche. Supponiamo di voler conoscere l’età media delle persone di un dato sesso che lavorano in una data azienda. Possiamo costruire una crosstab specificando una nuova variabile (age) per values. Dato che di questa variabile bisognerà calcolare un qualche valore aggregato, dobbiamo anche specificare aggfunc per esempio pari a mean (per calcolare la media dei valori interessati:

pd.crosstab(df['sex'],df['company'], values=df['age'], aggfunc='mean')
company CDX FLZ PTX
sex
F 18.000000 30.333333 33.0
M 37.333333 39.000000 36.5

La tabella ci dice che l’età media delle persone di sesso maschile che lavorano per CDX è di \(37,33\) anni.

🙋‍♂️ Domanda 11

Si costruisca una crosstab che, per ogni compagnia, riporti il numero di dipendenti di una data età.

25.2.8. Manipolazione “esplicita” di DataFrame#

In alcuni casi può essere utile trattare i DataFrame “esplicitamente” come matrici di valori. Consideriamo ad esempio il seguente DataFrame:

df123 = pd.DataFrame({'Category':[1,2,3], 'NumberOfElements':[3,1,2]})
df123
Category NumberOfElements
0 1 3
1 2 1
2 3 2

Supponiamo di voler costruire un nuovo DataFrame che, per ogni riga del dataframe df123 contenga esattamente “NumberOfElements” righe con valore di “NumberOfElements” pari a uno. Vogliamo in pratica “espandere” il DataFrame sopra come segue:

df123 = pd.DataFrame({'Category':[1,1,1,2,3,3], 'NumberOfElements':[1,1,1,1,1,1]})
df123
Category NumberOfElements
0 1 1
1 1 1
2 1 1
3 2 1
4 3 1
5 3 1

Per farlo in maniera automatica, possiamo trattare il DataFrame più “esplicitamente” come una matrice di valori iterandone le righe. Il nuovo DataFrame sarà dapprima costruito come una lista di Series (le righe del DataFrame) e poi trasformato in un DataFrame:

newdat = []
for i, row in df123.iterrows(): #iterrows permette di iterare le righe di un DataFrame
    for j in range(row['NumberOfElements']):
        newrow = row.copy()
        newrow['NumberOfElements']=1
        newdat.append(newrow)
pd.DataFrame(newdat)
Category NumberOfElements
0 1 1
1 1 1
2 1 1
3 2 1
4 3 1
5 3 1
pd.DataFrame({'Category':[1,2,3], 'NumberOfElements':[3,5,3], 'CheckedElements':[1,2,1]})
Category NumberOfElements CheckedElements
0 1 3 1
1 2 5 2
2 3 3 1

25.2.9. Concatenazione di DataFrame#

I DataFrame possono essere concatenati per righe come mostrato di seguito:

d1 = pd.DataFrame({'A':[1,2,3,4],'B':[3,2,6,7],'C':[0,2,-1,12]})
d2 = pd.DataFrame({'A':[92,8],'B':[44,-2],'C':[0,2]})
print(d1)
print(d2)
   A  B   C
0  1  3   0
1  2  2   2
2  3  6  -1
3  4  7  12
    A   B  C
0  92  44  0
1   8  -2  2
pd.concat([d1,d2])
A B C
0 1 3 0
1 2 2 2
2 3 6 -1
3 4 7 12
0 92 44 0
1 8 -2 2

Se le colonne non sono compatibili, appariranno dei NaN:

d1 = pd.DataFrame({'A':[1,2,3,4],'B':[3,2,6,7],'C':[0,2,-1,12]})
d2 = pd.DataFrame({'A':[92,8],'D':[0,2]})

pd.concat([d1,d2])
A B C D
0 1 3.0 0.0 NaN
1 2 2.0 2.0 NaN
2 3 6.0 -1.0 NaN
3 4 7.0 12.0 NaN
0 92 NaN NaN 0.0
1 8 NaN NaN 2.0

La concatenazione può avvenire anche per colonne:

d1 = pd.DataFrame({'A':[1,2],'B':[3,7],'C':[-1,12]})
d2 = pd.DataFrame({'A':[92,8],'D':[0,2]})

pd.concat([d1,d2], axis=1)
A B C A D
0 1 3 -1 92 0
1 2 7 12 8 2

25.2.10. Merge di DataFrame#

Due DataFrame possono essere uniti mediante una operazione di merge simile a quanto avviene nei database. Consideriamo due dataframe di esempio:

a = pd.DataFrame({'key': ['k0', 'k1', 'k2'],
                     'a': ['v1', 'v2', 'v3'],
                     'b': ['v4', 'v5', 'v6'],
                     'c': ['v7', 'v8', 'v9']})
   
a
key a b c
0 k0 v1 v4 v7
1 k1 v2 v5 v8
2 k2 v3 v6 v9
b = pd.DataFrame({'key': ['k0', 'k1', 'k2'],
                     'd': ['v9', 'v11', 'v12'],
                     'e': ['v13', 'v4', 'v5']})
b
key d e
0 k0 v9 v13
1 k1 v11 v4
2 k2 v12 v5

I due dataframe hanno una colonna in comune che agisce da chiave e altre colonne che agiscono da valori. L’operazione di merge permette di unire i dataframe sulla base delle chiavi:

pd.merge(a,b,on='key')
key a b c d e
0 k0 v1 v4 v7 v9 v13
1 k1 v2 v5 v8 v11 v4
2 k2 v3 v6 v9 v12 v5

25.2.11. Join di DataFrame#

Le operazioni di join possono essere applicate per unire dataframe indicizzati in maniera compatibile:

a = pd.DataFrame({'a': ['v1', 'v2', 'v3'],
                     'b': ['v4', 'v5', 'v6'],
                     'c': ['v7', 'v8', 'v9']},
                     index = ['k0', 'k1', 'k2'])
   
a
a b c
k0 v1 v4 v7
k1 v2 v5 v8
k2 v3 v6 v9
b = pd.DataFrame({'d': ['v9', 'v11', 'v12'],
                     'e': ['v13', 'v4', 'v5']},
                     index = ['k0', 'k3', 'k2'],)
b
d e
k0 v9 v13
k3 v11 v4
k2 v12 v5
a.join(b)
a b c d e
k0 v1 v4 v7 v9 v13
k1 v2 v5 v8 NaN NaN
k2 v3 v6 v9 v12 v5

Come si può notare, l’operazione di join avviene solo utilizzando gli indici in comune.

25.2.12. Input/Output#

Pandas mette a disposizione diverse funzioni per leggere e scrivere dati. Vedremo in particolare le funzioni per leggere e scrivere file csv. E’ possibile leggere file csv (https://it.wikipedia.org/wiki/Comma-separated_values) mediante la funzione pd.read_csv. La lettura può avvenire da file locali passando il path relativo o da URL:

data=pd.read_csv('http://iplab.dmi.unict.it/furnari/downloads/students.csv')
data.head()
admit gre gpa prestige
0 0 380 3.61 3
1 1 660 3.67 3
2 1 800 4.00 1
3 1 640 3.19 4
4 0 520 2.93 4

Allo stesso modo, è possibile scrivere un DataFrame su file csv come segue:

data.to_csv('file.csv')

Dato che gli indici possono non essere sequenziali in un DataFrame, Pandas li inserisce nel csv come “colonna senza nome”. Ad esempio, le prime righe del file che abbiamo appena scritto (file.csv) sono le seguenti:

,admit,gre,gpa,prestige
0,0,380,3.61,3
1,1,660,3.67,3
2,1,800,4.0,1
3,1,640,3.19,4
4,0,520,2.93,4

Come si può vedere, la prima colonna contiene i valori degli indici, ma non ha nome. Ciò può causare problemi quando carichiamo nuovamente il DataFrame:

data=pd.read_csv('file.csv')
data.head()
Unnamed: 0 admit gre gpa prestige
0 0 0 380 3.61 3
1 1 1 660 3.67 3
2 2 1 800 4.00 1
3 3 1 640 3.19 4
4 4 0 520 2.93 4

Possiamo risolvere il problema di diversi modi:

  • Eliminare la colonna “Unnamed: 0” dal DataFrame appena caricato (solo se gli indici sono sequenziali);

  • Specificare di usare la colonna “Unnamed: 0” come colonna degli indici durante il caricamento;

  • Salvare il DataFrame senza indici (solo se gli indici sono sequenziali).

Vediamo i tre casi:

data=pd.read_csv('file.csv')
data.drop('Unnamed: 0', axis=1).head()
admit gre gpa prestige
0 0 380 3.61 3
1 1 660 3.67 3
2 1 800 4.00 1
3 1 640 3.19 4
4 0 520 2.93 4
data=pd.read_csv('file.csv', index_col='Unnamed: 0')
data.head()
admit gre gpa prestige
0 0 380 3.61 3
1 1 660 3.67 3
2 1 800 4.00 1
3 1 640 3.19 4
4 0 520 2.93 4
data.to_csv('file.csv', index=False)
pd.read_csv('file.csv').head()
admit gre gpa prestige
0 0 380 3.61 3
1 1 660 3.67 3
2 1 800 4.00 1
3 1 640 3.19 4
4 0 520 2.93 4

25.3. Esempio di manipolazione dati#

Spesso, i dati devono essere opportunamente trattati per poterli analizzare. Vediamo un esempio di come combinare gli strumenti discussi sopra per manipolare un dataset reale.

Considereremo dataset disponibile a questo URL: https://www.kaggle.com/lava18/google-play-store-apps. Una copia del dataset è disponibile al seguente URL per scopi didattici: http://iplab.dmi.unict.it/furnari/downloads/googleplaystore.csv. Carichiamo il dataset e visualizziamo le prime righe per assicurarci che sia stato caricato correttamente:

data = pd.read_csv('http://iplab.dmi.unict.it/furnari/downloads/googleplaystore.csv')
data.head()
App Category Rating Reviews Size Installs Type Price Content Rating Genres Last Updated Current Ver Android Ver
0 Photo Editor & Candy Camera & Grid & ScrapBook ART_AND_DESIGN 4.1 159 19M 10,000+ Free 0 Everyone Art & Design January 7, 2018 1.0.0 4.0.3 and up
1 Coloring book moana ART_AND_DESIGN 3.9 967 14M 500,000+ Free 0 Everyone Art & Design;Pretend Play January 15, 2018 2.0.0 4.0.3 and up
2 U Launcher Lite – FREE Live Cool Themes, Hide ... ART_AND_DESIGN 4.7 87510 8.7M 5,000,000+ Free 0 Everyone Art & Design August 1, 2018 1.2.4 4.0.3 and up
3 Sketch - Draw & Paint ART_AND_DESIGN 4.5 215644 25M 50,000,000+ Free 0 Teen Art & Design June 8, 2018 Varies with device 4.2 and up
4 Pixel Draw - Number Art Coloring Book ART_AND_DESIGN 4.3 967 2.8M 100,000+ Free 0 Everyone Art & Design;Creativity June 20, 2018 1.1 4.4 and up

Visualizziamo le proprietà del dataset:

data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 10841 entries, 0 to 10840
Data columns (total 13 columns):
App               10841 non-null object
Category          10841 non-null object
Rating            9367 non-null float64
Reviews           10841 non-null object
Size              10841 non-null object
Installs          10841 non-null object
Type              10840 non-null object
Price             10841 non-null object
Content Rating    10840 non-null object
Genres            10841 non-null object
Last Updated      10841 non-null object
Current Ver       10833 non-null object
Android Ver       10838 non-null object
dtypes: float64(1), object(12)
memory usage: 1.1+ MB

Il dataset contiene \(10841\) osservazioni e \(13\) variabili. Osserviamo che molte delle variabili (tranne “Rating”) sono di tipo “object”, anche se rappresentano dei valori numerici (es., Reviews, Size, Installs e Price). Osservando le prime righe del DataFrame visualizzate sopra possiamo dedurre che:

  • Size non è rappresentato come valore numerico in quanto contiene l’unità di misura “M”;

  • Installs è in realtà una variabile categorica (riporta un “+” alla fine, quindi indica la classe “più di xxxx installazioni”);

Non ci sono motivazioni apparenti per cui Reviews e Rating non siano stati interpretati come numeri. Costruiamo una funzione filtro che identifichi se un valori è convertibile in un dato tipo o meno:

def cannot_convert(x, t=float):
    try:
        t(x)
        return False
    except:
        return True
print(cannot_convert('12'))
print(cannot_convert('12f'))
False
True

Applichiamo il filtro alla colonna Review per visualizzare i valori che non possono essere convertiti:

list(filter(cannot_convert,data['Reviews']))
['3.0M']

Possiamo sostituire questo valore con la sua versione per esteso mediante il metodo replace:

data['Reviews']=data['Reviews'].replace({'3.0M':3000000})

A questo punto possiamo convertire i tipi dei valori della colonna in interi:

data['Reviews']=data['Reviews'].astype(int)

Visualizziamo nuovamente le informazioni del DataFrame:

data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 10841 entries, 0 to 10840
Data columns (total 13 columns):
App               10841 non-null object
Category          10841 non-null object
Rating            9367 non-null float64
Reviews           10841 non-null int32
Size              10841 non-null object
Installs          10841 non-null object
Type              10840 non-null object
Price             10841 non-null object
Content Rating    10840 non-null object
Genres            10841 non-null object
Last Updated      10841 non-null object
Current Ver       10833 non-null object
Android Ver       10838 non-null object
dtypes: float64(1), int32(1), object(11)
memory usage: 1.0+ MB

Reviews è adesso un intero. Eseguiamo una analisi simile sulla variabile Price:

list(filter(cannot_convert, data['Price']))[:10] #visualizziamo solo i primi 10 elementi
['$4.99',
 '$4.99',
 '$4.99',
 '$4.99',
 '$3.99',
 '$3.99',
 '$6.99',
 '$1.49',
 '$2.99',
 '$3.99']

Possiamo convertire le stringhe in float eliminando il dollaro iniziale mediante apply:

def strip_dollar(x):
    if x[0]=='$':
        return x[1:]
    else:
        return x
data['Price']=data['Price'].apply(strip_dollar)

Vediamo se ci sono ancora valori che non possono essere convertiti:

list(filter(cannot_convert, data['Price']))
['Everyone']

Dato che non sappiamo come interpretare il valore “Everyone”, sostituiamolo con un NaN:

data['Price']=data['Price'].replace({'Everyone':np.nan})

Adesso possiamo procedere alla conversione:

data['Price']=data['Price'].astype(float)
data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 10841 entries, 0 to 10840
Data columns (total 13 columns):
App               10841 non-null object
Category          10841 non-null object
Rating            9367 non-null float64
Reviews           10841 non-null int32
Size              10841 non-null object
Installs          10841 non-null object
Type              10840 non-null object
Price             10840 non-null float64
Content Rating    10840 non-null object
Genres            10841 non-null object
Last Updated      10841 non-null object
Current Ver       10833 non-null object
Android Ver       10838 non-null object
dtypes: float64(2), int32(1), object(10)
memory usage: 1.0+ MB

Modifichiamo anche “Size” eliminando la “M” finale:

data['Size']=data['Size'].apply(lambda x : x[:-1])

Verifichiamo l’esistenza di valori non convertibili:

list(filter(cannot_convert,data['Size']))[:10]
['Varies with devic',
 'Varies with devic',
 'Varies with devic',
 'Varies with devic',
 'Varies with devic',
 'Varies with devic',
 'Varies with devic',
 'Varies with devic',
 'Varies with devic',
 'Varies with devic']

Sostituiamo questi valori con NaN:

data['Size']=data['Size'].replace({'Varies with devic':np.nan})

Verifichiamo nuovamente la presenza di valori che non possono essere convertiti:

list(filter(cannot_convert,data['Size']))
['1,000']

Possiamo rimuovere la virgola usata per indicare le migliaia e convertire in float:

data['Size']=data['Size'].apply(lambda x: float(str(x).replace(',','')))
data.info()
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 10841 entries, 0 to 10840
Data columns (total 13 columns):
App               10841 non-null object
Category          10841 non-null object
Rating            9367 non-null float64
Reviews           10841 non-null int32
Size              9146 non-null float64
Installs          10841 non-null object
Type              10840 non-null object
Price             10840 non-null float64
Content Rating    10840 non-null object
Genres            10841 non-null object
Last Updated      10841 non-null object
Current Ver       10833 non-null object
Android Ver       10838 non-null object
dtypes: float64(3), int32(1), object(9)
memory usage: 1.0+ MB

Valutiamo se è il caso di trasformare anche “Installs” in un valore numerico. Vediamo quanti valori univoci di “Installs” ci sono nel dataset:

data['Installs'].nunique()
22

Abbiamo solo \(22\) valori, che paragonati alle 10841 osservazioni, indicano che “Installs” è una variabile molto quantizzata, per cui probabilmente ha senso considerarla come categorica, piuttosto che come un valore numerico.

Il DataFrame contiene dei NaN. A seconda dei casi può avere senso tenerli o rimuoverli. Dato che i dati sono molti, possiamo rimuoverli con dropna:

data=data.dropna()

Possiamo quindi iniziare a esplorare i dati con gli strumenti visti. Visualizziamo ad esempio i valori medi delle variabili numeriche per categoria:

data.groupby('Category').mean()
Rating Reviews Size Price
Category
ART_AND_DESIGN 4.381034 1.874517e+04 12.939655 0.102931
AUTO_AND_VEHICLES 4.147619 1.575057e+04 24.728571 0.000000
BEAUTY 4.291892 5.020243e+03 15.513514 0.000000
BOOKS_AND_REFERENCE 4.320139 2.815291e+04 23.543750 0.145069
BUSINESS 4.119919 2.497765e+04 26.217480 0.249593
COMICS 4.130612 1.254822e+04 34.626531 0.000000
COMMUNICATION 4.102844 5.549962e+05 60.765403 0.197773
DATING 3.957803 2.254489e+04 18.312717 0.086590
EDUCATION 4.387273 6.435159e+04 30.588182 0.163273
ENTERTAINMENT 4.146667 1.621530e+05 21.853333 0.033222
EVENTS 4.478947 3.321605e+03 23.213158 0.000000
FAMILY 4.190347 1.801297e+05 37.459963 1.390074
FINANCE 4.112030 3.903023e+04 31.249624 9.172444
FOOD_AND_DRINK 4.097619 5.103793e+04 24.163095 0.059405
GAME 4.269507 1.386276e+06 46.827823 0.281704
HEALTH_AND_FITNESS 4.223767 4.519072e+04 29.658296 0.154350
HOUSE_AND_HOME 4.162500 2.935998e+04 17.505357 0.000000
LIBRARIES_AND_DEMO 4.204918 1.632359e+04 225.267213 0.000000
LIFESTYLE 4.093571 3.109027e+04 28.650714 6.976429
MAPS_AND_NAVIGATION 4.013684 3.858909e+04 26.282105 0.157474
MEDICAL 4.184259 4.392454e+03 49.643827 3.077901
NEWS_AND_MAGAZINES 4.143787 5.826802e+04 14.948521 0.023550
PARENTING 4.347727 1.999950e+04 21.579545 0.113409
PERSONALIZATION 4.323381 1.257211e+05 50.115827 0.427338
PHOTOGRAPHY 4.147034 3.260313e+05 18.970763 0.331992
PRODUCTIVITY 4.143830 1.854692e+05 38.899149 0.225362
SHOPPING 4.227933 2.624461e+05 33.397207 0.030615
SOCIAL 4.257062 1.830882e+05 24.317514 0.011186
SPORTS 4.204858 2.128411e+05 30.159109 0.324818
TOOLS 4.010742 1.663130e+05 47.929068 0.290047
TRAVEL_AND_LOCAL 4.043125 4.824554e+04 25.357500 0.165687
VIDEO_PLAYERS 4.025862 2.074172e+05 22.881897 0.008534
WEATHER 4.241176 7.639225e+04 24.805882 0.459608

🙋‍♂️ Domanda 12

Visualizzare il numero medio di review per tipo (variabile Type).

data=pd.read_csv('https://raw.githubusercontent.com/agconti/kaggle-titanic/master/data/train.csv')
data[data['Ticket']=='345779']
PassengerId Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked
81 82 1 3 Sheerlinck, Mr. Jan Baptist male 29.0 0 0 345779 9.5 NaN S

25.4. Esercizi#

🧑‍💻 Esercizio 1

Si carichi il dataset disponibile al seguente URL: https://raw.githubusercontent.com/agconti/kaggle-titanic/master/data/train.csv in modo da utilizzare i valori della colonna “PassengerId” come indici e si applichino le seguenti trasfromazioni:

  • Si rimuovano le righe che contengono dei valori NaN;

  • Si modifichi il DataFrame sostituendo nella colonna “Sex” ogni occorrenza di “male” con “M” e ogni occorrenza di “female” con “F”;

  • Si inserisca una nuova colonna “Old” con valore pari a “1” se Age è superiore all’età media dei passeggeri e “0” altrimenti;

  • Si convertano i valori della colonna “Age” in interi.

🧑‍💻 Esercizio 2

Si consideri il dataset caricato e modificato nell’esercizio precedente. Si trovi il nome del passeggero con ticket dal codice “345779”.

🧑‍💻 Esercizio 3

Quali sono i nomei dei 5 passeggeri più giovani?

🧑‍💻 Esercizio 4

Si consideri il dataset caricato e modificato nell’esercizio precedente. Si selezionino tutte le osservazioni relative a passeggeri di età compresa tra 20 e 30 anni.

🧑‍💻 Esercizio 5

Si consideri il dataset caricato e modificato negli esercizi precedenti. Si calcoli la tariffa media pagata dai passeggeri di sesso femminile in seconda classe.

🧑‍💻 Esercizio 6

Si consideri il dataset caricato e modificato negli esercizi precedenti. Si costruisca una crosstab che permetta di rispondere alle seguenti domande:

  • Quanti passeggeri di sesso maschile sono sopravvisuti?

  • Quanti passeggeri di sesso femminile non sono sopravvisuti?

🧑‍💻 Esercizio 7

Si consideri il dataset caricato e modificato negli esercizi precedenti. Si costruisca una crosstab che permetta di rispondere alle seguenti domande:

  • Quanti passeggeri di sesso maschile sono sopravvisuti in prima classe?

  • Quanti passeggeri di sesso femminile sono sopravvisuti in terza classe?

  • Quanti passeggeri di sesso maschile sono sopravvisuti in terza classe?

🧑‍💻 Esercizio 8

Si consideri il seguente DataFrame:

pd.DataFrame({'Category':[1,2,3], 'NumberOfElements':[3,2,3], 'CheckedElements':[1,2,1]})

Si costruisca un nuovo DataFrame che contenga le stesse colonne del DataFrame considerato e che per ogni riga di esso contenga NumberOfElements nuove righe con categoria uguale a Category di cui CheckedElements con valore di CheckedElements pari a uno e le restanti con valore di CheckedElements pari a zero.

Il risultato della manipolazione dovrà essere uguale al seguente dataset:

pd.DataFrame({'Category':[1,1,1,2,2,3,3,3], 
              'NumberOfElements':[1,1,1,1,1,1,1,1], 
              'CheckedElements':[1,0,0,1,1,1,0,0]})

25.5. Referenze#