5.1. Serie#

Una delle classi principali implementate in pandas è Series. Le sue istanze rappresentano serie di osservazioni di un certo carattere fatto su un insieme di individui. La cella seguente recupera dalla lista heroes precedentemente creata i nomi dei supereroi e il loro anno di prima apparizione e li utilizza per creare una serie:

import csv
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd

with open('data/heroes.csv', 'r') as heroes_file:
  heroes_reader = csv.reader(heroes_file, quotechar='"')
  heroes = list(heroes_reader)[1:]

years = [int(h[13]) if h[13] else None for h in heroes]
names = [h[0] for h in heroes]
first_appearance = pd.Series(years, index = names)

Nella creazione della lista year è stata utilizzata una list comprehension in cui l'espressione int(h[13]) if h[13] else None utilizza un operatore ternario tramite cui la stringa vuota viene trasformata nel valore speciale None, mentre tutte le altre vengono convertite nel corrispondente intero.

La differenza tra una serie e una lista o una tupla è legata alla possibilità di invocare su di essa delle funzioni specifiche. Inoltre a ogni serie è associato un indice che permette di identificare ogni elemento osservato. Nell'esempio sopra riportato, il primo argomento specificato nel costruttore è una lista (ma sarebbe andata bene anche una tupla) di anni che indicano la prima apparizione di un supereroe e il secondo rappresenta appunto l'indice, che in questo caso è la lista dei corrispondenti nomi. Quando si visualizza una serie, ogni osservazione viene associata al corrispondente elemento usando appunto l'indice:

first_appearance
Laufey              2018.0
Laugh-a-Lot Bear       NaN
Laughing Jack          NaN
Laura Moon             NaN
Laura Palmer           NaN
                     ...  
Aphoom-Zhah            NaN
Aphrodite-1            NaN
Aphrodite-2            NaN
Aphrodite-3         2012.0
Aphrodite-4            NaN
Length: 17355, dtype: float64

La visualizzazione della serie (che in questo caso riporta solo i primi e gli ultimi elementi perché la serie è troppo lunga) termina indicando il tipo di dato usato per rappresentare le varie osservazioni. Nell'esempio precedente viene utilizzato il tipo float64 (pandas utilizza internamente gli array di numpy, in cui è presente un'implementazione dei tipi in virgola mobile diversa da quella standard di python), nonostante i dati originari fossero numeri interi. Ciò è dovuto alla presenza di valori mancanti. Di norma vengono indicati con la sigla NA (dall'inglese "not available"), ma in pandas essi vengono rappresentati utilizzando il concetto di "not a number" dello standard IEEE per la virgola mobile: si noti come tutte le occorrenze di None nella lista originale siano state automaticamente convertite in np.nan.

L'accesso ai dati contenuti in una serie può avvenire in due modi:

  • specificando un valore per l'indice tra parentesi quadre dopo la serie o dopo la sua proprietà loc:

(first_appearance['Wonder Woman'], first_appearance.loc['Wonder Woman'])
(np.float64(nan), np.float64(nan))
  • indicando un valore per la posizione tra parentesi quadre dopo la serie o dopo la sua proprietà iloc:

(first_appearance[128], first_appearance.iloc[128])
/tmp/ipykernel_3260/1782672992.py:1: FutureWarning: Series.__getitem__ treating keys as positions is deprecated. In a future version, integer keys will always be treated as labels (consistent with DataFrame behavior). To access a value by position, use `ser.iloc[pos]`
  (first_appearance[128], first_appearance.iloc[128])
(np.float64(nan), np.float64(nan))

Nomenclatura

Se l'indice di una serie è basato su valori interi, i valori tra parentesi quadre immediatamente dopo la serie faranno riferimento all'indice e non alla posizione: ciò potrebbe essere fuorviante se gli elementi dell'indice non partono da zero e non sono consecutivi.

È inoltre possibile utilizzare una notazione simile al list slicing specificando valori dell'indice oppure posizioni. Va però notato che gli slicing basati su indice comprenderanno il primo e l'ultimo valore specificato:

first_appearance['Wonder Girl':'Wonder Woman']
Wonder Girl      1996.0
Wonder Girl-1       NaN
Wonder Girl-2    2011.0
Wonder Girl-3       NaN
Wonder Girl-4    2021.0
Wonder Girl-5       NaN
Wonder Girl-6    2021.0
Wonder Man          NaN
Wonder Man-1     2005.0
Wonder Wife         NaN
Wonder Woman        NaN
dtype: float64

mentre gli slice basati su posizione escluderanno l'ultimo elemento:

first_appearance[60:63]
Leo Bonhart   NaN
Leo Dooley    NaN
Leo Fitz      NaN
dtype: float64

L'accesso posizionale può anche fare riferimento a numeri negativi, contando in analogia a liste e tuple a partire dall'ultimo elemento:

first_appearance[-5:]
Aphoom-Zhah       NaN
Aphrodite-1       NaN
Aphrodite-2       NaN
Aphrodite-3    2012.0
Aphrodite-4       NaN
dtype: float64

È possibile accedere ai primi e ultimi elementi di una serie anche utilizzando le funzioni head e tail, che mostrano rispettivamente solo le prime e le ultime righe:

first_appearance.head(7)
Laufey               2018.0
Laugh-a-Lot Bear        NaN
Laughing Jack           NaN
Laura Moon              NaN
Laura Palmer            NaN
Laura Weathermill       NaN
Lauren Reed             NaN
dtype: float64

L'accesso alle liste può anche essere fatto specificando una lista (ma non una tupla) di posizioni al posto di una sola posizione, con l'effetto di ottenere i corrispondenti elementi.

first_appearance[[1, 42, 709]]
/tmp/ipykernel_3260/2760408145.py:1: FutureWarning: Series.__getitem__ treating keys as positions is deprecated. In a future version, integer keys will always be treated as labels (consistent with DataFrame behavior). To access a value by position, use `ser.iloc[pos]`
  first_appearance[[1, 42, 709]]
Laugh-a-Lot Bear   NaN
Legion-1           NaN
Malia Tate         NaN
dtype: float64

Questo tipo di accesso può essere fatto anche specificando una lista di valori per l'indice. Infine, si può utilizzare una lista di valori booleani in cui True indica gli elementi da estrarre e False quelli da filtrare:

first_appearance[[1970 <= y <1975 for y in first_appearance]]
Leatherface            1974.0
Lilith Drake           1974.0
Luke Cage              1972.0
Mad Harriet            1972.0
Man-Bat                1970.0
                        ...  
Jennifer Kale          1972.0
Jiger                  1970.0
King Caesar (Showa)    1974.0
Klarion                1973.0
Kronos-1               1973.0
Length: 81, dtype: float64

Nomenclatura

L'uso di questa modalità di accesso richiede che la lista di valori booleani abbia la stessa lunghezza della serie. L'uso di liste di dimensioni minori l'accesso, che comporta un filtraggio effettuato solo nei primi elementi della serie, è deprecato e va quindi evitato.

Infine, è possibile effettuare delle query su una serie specificando tra parentesi quadre un'espressione logica che indica quali elementi visualizzare, utilizzando la serie come simbolo che ne indica un suo generico elemento:

first_appearance[first_appearance > 2010]
Laufey                           2018.0
Lazav                            2013.0
Leapfrog                         2022.0
Legacy                           2012.0
Legacy (WW2)                     2013.0
                                  ...  
Anti-Venom (Klyntar Symbiote)    2017.0
Anton (Cosmoball)                2020.0
Anubis                           2012.0
Ao Oni                           2012.0
Aphrodite-3                      2012.0
Length: 1138, dtype: float64

Nomenclatura

Tecnicamente, l'espressione first_appearance > 2010 genera una nuova serie che ha lo stesso indice di first_appearance e in cui i valori sono True in corrispondenza degli anni successivi al 2010 e False altrimenti. Questa nuova serie viene utilizzata per filtrare first_appearance.

Vediamo ora come utilizzando le serie sia molto più semplice calcolare e visualizzare le frequenze assolute: il metodo value_counts restituisce un'altra serie in cui gli indici sono i valori osservati e i valori le corrispondenti frequenze assolute, ordinate in senso non crescente.

first_appearance.value_counts()
2021.0    140
2018.0    115
2019.0    108
2012.0    107
2013.0     93
         ... 
2099.0      1
1935.0      1
1950.0      1
1932.0      1
1933.0      1
Name: count, Length: 93, dtype: int64

Va notato come il tipo delle frequenze sia, correttamente, intero e come i valori mancanti siano automaticamente esclusi dal calcolo delle frequenze, mentre sono sempre presenti gli outlier. Per ottenere una serie i cui elementi siano ordinati per valore non decrescente della voce nell'indice è sufficiente invocare il metodo sort_index; già che ci siamo, è un buon momento per eliminare i valori fuori scala dal conteggio effettuando una query sulla serie:

first_app_freq = first_appearance[first_appearance < 2090].value_counts().sort_index()
first_app_freq.head(10)
1928.0     3
1931.0     1
1932.0     1
1933.0     1
1935.0     1
1936.0     1
1938.0     2
1939.0     7
1940.0    17
1941.0    10
Name: count, dtype: int64

5.1.1. Visualizzazione grafica di una serie#

Pandas mette a disposizione l'oggetto plot per visualizzare graficamente i contenuti di una serie, utilizzando matplotlib dietro le quinte; in particolare, il metodo bar visualizza un grafico a barre:

# Don't try this at home (men che meno all'esame!)

first_appearance.plot.bar()
plt.show()
../../_images/baa563f85b2f3d08bacd057a4cdd222dbf66d198a60bb9ca64bc092e13a1b252.png

Il grafico ottenuto, diciamolo, fa schifo. Questo perché bar considera un punto per ogni elemento della serie, in cui le ascisse corrispondono alla posizione (zero per la prima osservazione, uno per la seconda e così via, sebbene nel grafico sull'asse delle ascisse vengano poi visualizzati i valori dell'indice) e le ordinate al valore osservato. Per ognuno dei punti così ottenuti viene poi tracciato un segmento che lo congiunge perpendicolarmente all'asse delle ascisse. Il risultato è decisamente poco informativo, sia da un punto di vista grafico (le etichette sull'asse delle ascisse si sovrappongono, così che non si riesce a leggere nulla), sia da un punto di vista analitico: le barre hanno altezze simili e quindi le loro differenze sono poco apprezzabili a colpo d'occhio; inoltre il grafico dipende per esempio dall'ordine in cui sono elencate le osservazioni e non ci permette di solito di trarre alcuna informazione sulla relazione che lega tra loro le osservazioni.

Si ottengono dei risultati decisamente più interessanti se si visualizza un grafico analogo per le frequenze assolute:

first_app_freq.plot.bar()
plt.show()
../../_images/b4283c7e3ecc3d831e7cd9e209b34b1ecadc92c72f9c1bdcc85b2c0cb914b5e7.png

Il grafico ottenuto è sicuramente migliore di quello precedente, ma rimane il problema di leggibilità dell'asse delle ascisse. Ciò è dovuto al fatto che pandas non inserisce le barre sul grafico nelle ascisse corrispondenti agli anni, ma le posiziona una accanto all'altra, come possiamo renderci conto visualizzando un po' meglio solo alcune delle etichette (in prima istanza non è importante capire come venga generato questo grafico, ma se siete curiosi potete leggere l'approfondimento che trovate dopo il commento al grafico stesso):

years = np.arange(1945, 2010, 10)
index_pos = [first_app_freq.index.get_loc(y) for y in years]
first_app_freq.plot.bar()
plt.xticks(index_pos, years)
plt.ylim((0, 18.5))
plt.show()
../../_images/1815ec61e9da7fbf2bb0166dd2461a1746a13aea69e2667a8d6f6c14769cd564.png

Si può osservare che tra due valori successivi evidenziati nell'asse delle ascisse intercorre una distanza di dieci anni, ma le etichette non risultano equispaziate: ciò è dovuto al fatto che in realtà la prima barra ha ascissa 1, la seconda ha ascissa 2 e così via, mentre le etichette mostrate sull'asse delle ascisse corrispondono ai valori degli indici.

Nomenclatura

Per generare il grafico precedente è necessario utilizzare alcune funzionalità avanzate delle librerie considerate: np.arange permette di costruire un array i cui valori vanno di dieci in dieci partendo da 1945 e arrivando a 2005; la proprietà index di una serie permette di estrarne l'indice e il metodo get_loc di quest'ultimo restituisce la posizione corrispondente a un dato valore dell'indice. Infine, il metodo xticks di matplotlib permette di specificare quali valori evidenziare sull'asse delle ascisse e quali etichette utilizzare.

Per ottenere un grafico simile in cui le ascisse siano effettivamente gli anni di prima apparizione è necessario tornare a utilizzare esplicitamente matplotlib, passando al metodo bar rispettivamente l'indice e i valori della serie, che si ottengono rispettivamente utilizzando la proprietà index e invocando il metodo get_values.

plt.bar(first_app_freq.index, first_app_freq.values)
plt.xlim((1935, 2015))
plt.ylim(0, 18.5)
plt.show()
../../_images/8f187a3a856d3f55e262055a98d35cf4a76aa47089b36f51602e7f744d597947.png

5.1.2. Operazioni con le serie#

Consideriamo le seguenti domande:

  1. Quanti supereroi sono apparsi dopo il 1960?

  2. Quanti tra il 1940 e il 1965?

  3. Quanti dopo il 1970?

Per rispondere alla prima domanda dobbiamo isolare le frequenze che corrispondono agli anni di apparizione che vanno dal 1960 in avanti. Notiamo che l'indice della serie contiene i valori degli anni; è quindi possibile utilizzare l'accesso tramite list slicing per recuperare le frequenze degli anni di apparizione che vanno dal 1960 in avanti:

first_app_freq[1960:]
/tmp/ipykernel_3260/1885720840.py:1: FutureWarning: The behavior of obj[i:j] with a float-dtype index is deprecated. In a future version, this will be treated as positional instead of label-based. For label-based slicing, use obj.loc[i:j] instead
  first_app_freq[1960:]
1960.0      8
1961.0      7
1962.0     16
1963.0     35
1964.0     27
         ... 
2020.0     73
2021.0    140
2022.0     81
2023.0     42
2024.0     17
Name: count, Length: 65, dtype: int64

A questo punto è sufficiente invocare la funzione sum sulla sotto-serie individuata per ottenere la somma delle frequenze:

sum(first_app_freq[1960:])
/tmp/ipykernel_3260/3547051780.py:1: FutureWarning: The behavior of obj[i:j] with a float-dtype index is deprecated. In a future version, this will be treated as positional instead of label-based. For label-based slicing, use obj.loc[i:j] instead
  sum(first_app_freq[1960:])
2514

La seconda domanda trova risposta in modo analogo, filtrando le frequenze degli anni di apparizione tra il 1940 e il 1966:

sum(first_app_freq[1940:1966])
/tmp/ipykernel_3260/3478196715.py:1: FutureWarning: The behavior of obj[i:j] with a float-dtype index is deprecated. In a future version, this will be treated as positional instead of label-based. For label-based slicing, use obj.loc[i:j] instead
  sum(first_app_freq[1940:1966])
222

Analogamente, all'ultima domanda si risponde selezionando gli anni dal 1970 in avanti:

sum(first_app_freq[:1971])
/tmp/ipykernel_3260/266580610.py:1: FutureWarning: The behavior of obj[i:j] with a float-dtype index is deprecated. In a future version, this will be treated as positional instead of label-based. For label-based slicing, use obj.loc[i:j] instead
  sum(first_app_freq[:1971])
303

Nomenclatura

La funzione sum accetta come argomento liste, tuple e serie: in tutti i casi restituisce la somma dei valori in esse contenute.

Un modo alternativo per calcolare la somma dei valori in una serie è quella di invocare su di essa l'omonimo metodo sum. Le serie sono inoltre in tutto e per tutto dei vettori, sui quali è possibile effettuare operazioni algebriche. Consideriamo per esempio le due serie contenenti altezza e peso dei supereroi:

height = pd.Series([float(h[4]) if h[4] else None for h in heroes], index=names)
weight = pd.Series([float(h[5]) if h[5] else None for h in heroes], index=names)

Una prima categoria di operazioni è quella che si ottiene indicando il nome di una serie all'interno di un'espressione aritmetica: il risultato è una nuova serie ottenuta calcolando l'espressione su tutti gli elementi della serie di partenza. Per esempio, la cella seguente crea la serie contenente l'altezza degli eroi misurata in metri e ne visualizza i primi dieci elementi:

(height/100)[:10]
Laufey               1.00
Laugh-a-Lot Bear     0.45
Laughing Jack        0.01
Laura Moon           0.30
Laura Palmer         1.00
Laura Weathermill    0.01
Lauren Reed          0.01
Lavagirl             0.25
Lavender             1.00
Lavos                1.00
dtype: float64

Quando si considerano operazioni più complicate, è possibile utilizzare il metodo apply indicando come suo argomento la funzione da applicare agli elementi della serie. Per esempio, nella cella seguente viene creata una nuova serie ottenuta esprimendo le altezze dei supereroi in metri e successivamente elevando il risultato al quadrato.

height.apply(lambda h: (h/100)**2)[:10]
Laufey               1.0000
Laugh-a-Lot Bear     0.2025
Laughing Jack        0.0001
Laura Moon           0.0900
Laura Palmer         1.0000
Laura Weathermill    0.0001
Lauren Reed          0.0001
Lavagirl             0.0625
Lavender             1.0000
Lavos                1.0000
dtype: float64

Un'altra importante categoria di operazioni è quella che vede due serie indicate come argomenti di un operatore aritmetico binario. In questo caso verrà ancora creata una nuova serie, in cui l'operazione viene calcolata elemento per elemento nelle serie indicate. Per esempio, la cella seguente crea una nuova serie bmi contenente l'indice di massa corporea (BMI) dei supereroi (ottenuto dividendo il peso specificato in chilogrammi per il quadrato dell'altezza misurata in metri), e mostra i quindici supereroi con il BMI più elevato.

bmi = weight / height.apply(lambda h: (h/100)**2)
bmi.sort_values(ascending=False)[:15]
The Top             inf
Captain Napalm      inf
Angel Of Destiny    inf
SCP-606             inf
Typhoon             inf
Ika-ika             inf
Lani-loli           inf
Johnny Storm        inf
Queen Metalia       inf
Kurogiri            inf
Aku-Aku             inf
Akane               inf
Kupuna-wa           inf
Darryl Morris       inf
Maude Pearson       inf
dtype: float64

A parte notare Hulk è solo il quindicesimo della classifica, va sottolineato che le operazioni fatte elemento per elemento allineano i vettori corrispondenti alle serie in base all'indice (e non alla posizione). Consideriamo per esempio la seguente cella, in cui vengono selezionati altezze e pesi più o meno plausibili per un essere umano, calcolando poi i corrispondenti BMI.

standard_weight = weight[(weight < 100) & (weight > 40)]
standard_height = height[(height < 210) & (height > 120)]/100
(standard_weight / (standard_height**2))[:15]
.GIFfany                           NaN
10,000 Volt Ghost                  NaN
5pb.                               NaN
666:Satan                          NaN
A (Fourth Raikage)                 NaN
A-120                              NaN
A-60                               NaN
A-90                               NaN
A-Qing (Sword of the Yue Maiden)   NaN
A.G.O.T.I                          NaN
Aang (Avatar State)                NaN
Abhorash                           NaN
Abnermal                           NaN
Abomination                        NaN
Abomination-2                      NaN
dtype: float64

Si nota un numero relativamente elevato di NaN, e ciò è appunto dovuto al fatto che il rapporto alla base del calcolo del BMI viene fatto usando peso e altezza di valori che hanno lo stesso indice. Ora, non è detto che un supereroe che ha un peso plausibile abbia anche un'altezza plausibile, e viceversa. Quello che succede quando si esegue un'operazione tra due serie e solo una di essa è definita in corrispondenza di uno specifico valore dell'indice, il risultato conterrà NaN per quel valore.