Python, dalle fondamenta Lezione 26 / 60

Series e DataFrame: il modello dei dati

Cosa sono davvero le strutture dati di pandas: un array indicizzato, e un dizionario di array indicizzati.

Ogni utente confuso di pandas che ho aiutato al lavoro era confuso allo stesso livello. Non sulla superficie dell’API, i docs li sapevano leggere, ma a livello di cosa siano questi oggetti. Avevano la vaga sensazione che df["col"] ti dia “una colonna”, che df.loc[5] ti dia “una riga”, che ci sia qualcosa chiamato “indice” che per lo più sembra fastidioso. Il modello nella loro testa era “una tabella 2D, come Excel”. Quando quel modello falliva, e fallisce nel momento in cui fai un’operazione aritmetica tra due slice che non hanno lo stesso ordine di righe, rimbalzavano e si attaccavano a df.values per scappare in NumPy, che è il posto dove le intuizioni competenti vanno a morire.

Pandas ha due strutture dati fondamentali: Series e DataFrame. Sono semplici. Oggi le spieghiamo. Il resto del Modulo 5 avrà allora senso.

Series: un array etichettato 1-D

Una Series è un array unidimensionale di valori, più un array unidimensionale di etichette (l’indice) della stessa lunghezza. Tutto qui. Ogni valore ha un’etichetta, e l’etichetta è ciò che rende pandas diverso da NumPy.

import pandas as pd

s = pd.Series([10, 20, 30, 40])
print(s)
0    10
1    20
2    30
3    40
dtype: int64

La colonna di sinistra (0, 1, 2, 3) è l’indice. Quando non ne specifichi uno, pandas te ne fa uno di default, un RangeIndex da 0. La colonna di destra sono i valori. La riga dtype in fondo ti dice il tipo di quei valori.

Puoi specificare il tuo indice: stringhe, date, qualsiasi cosa hashable:

revenue = pd.Series(
    [120, 135, 148, 162],
    index=["Q1", "Q2", "Q3", "Q4"],
    name="revenue_2025",
)
print(revenue)
Q1    120
Q2    135
Q3    148
Q4    162
Name: revenue_2025, dtype: int64

Puoi costruirne una da un dict, le chiavi diventano automaticamente l’indice:

revenue = pd.Series({"Q1": 120, "Q2": 135, "Q3": 148, "Q4": 162})

L’indice è la killer feature, ed è anche ciò che rende pandas sorprendente. Le operazioni tra Series si allineano per etichetta, non per posizione. Guarda:

revenue_2025 = pd.Series({"Q1": 120, "Q2": 135, "Q3": 148, "Q4": 162})
revenue_2024 = pd.Series({"Q2": 100, "Q3": 110, "Q4": 125, "Q1": 95})

growth = revenue_2025 - revenue_2024
print(growth)
Q1    25
Q2    35
Q3    38
Q4    37
dtype: int64

Nota che revenue_2024 aveva i suoi trimestri in un ordine diverso. Non è importato. Pandas ha accoppiato Q1 con Q1 e così via, per etichetta. Se avessi fatto la stessa cosa in NumPy, avresti ottenuto un risultato senza senso, Q1 meno Q2, Q2 meno Q3, in silenzio. Questo è il compromesso che pandas fa: un oggetto leggermente più complesso, in cambio di un’aritmetica che significa quello che volevi.

Cosa succede con etichette non corrispondenti?

a = pd.Series({"x": 1, "y": 2})
b = pd.Series({"y": 10, "z": 20})

print(a + b)
x     NaN
y    12.0
z     NaN
dtype: float64

Le etichette non presenti in entrambe le Series producono NaN. Il dtype è stato promosso a float64 perché gli interi non possono essere NaN in pandas con backend NumPy, una insidia famosa che sistemeremo quando discuteremo il backend Arrow tra un attimo.

Dtype: che tipo di valori

Ogni Series ha un dtype, e quale hai controlla parecchio comportamento. Quelli comuni in pandas:

  • int64: interi a 64 bit con segno. Default quando tutti i valori sono numeri interi e non ci sono null.
  • float64: virgola mobile a 64 bit. Default quando ci sono decimali, o quando una colonna int ha NaN.
  • object: una via di fuga “questo è un oggetto Python” di NumPy. In pratica, quasi sempre significa stringhe, a volte tipi misti. Lento.
  • string[python] / string[pyarrow]: dtype di stringa propri. Quello con backend Arrow è significativamente più veloce ed è il default moderno.
  • bool: booleani.
  • category: per valori che si ripetono da un piccolo insieme fisso (pensa: codici paese, enum di stato). Memorizzati come interi sotto il cofano con una tabella di lookup; risparmia enorme memoria su dataset reali.
  • datetime64[ns]: timestamp con precisione al nanosecondo.
  • timedelta64[ns]: durate.

Poi gli equivalenti nullable, i tipi extension di pandas, lettera maiuscola davanti: Int64, Float64, boolean, string. Supportano pd.NA (un valore “mancante” proprio) senza cambiare il dtype. Sono stati il ponte tra il vecchio pandas con backend NumPy e il nuovo pandas con backend Arrow; li vedi ancora ma per codice nuovo, vai dritto al backend Arrow.

# Vecchio stile: una colonna int con un valore mancante diventa float
pd.Series([1, 2, None])
# 0    1.0
# 1    2.0
# 2    NaN
# dtype: float64

# Backend Arrow: rimane intera, ottiene un NA proprio
pd.Series([1, 2, None], dtype="int64[pyarrow]")
# 0       1
# 1       2
# 2    <NA>
# dtype: int64[pyarrow]

Quella seconda è quella che vuoi davvero. Le stringhe sono la vittoria più grossa:

# Vecchio stile: array NumPy "object" di istanze str di Python
pd.Series(["alpha", "beta", "gamma"])  # dtype: object

# Stringhe Arrow: memoria più stretta, operazioni su stringhe ~10x più veloci
pd.Series(["alpha", "beta", "gamma"], dtype="string[pyarrow]")

Se metti pd.options.future.infer_string = True vicino all’inizio del tuo script, pandas userà automaticamente il dtype stringa di Arrow quando legge colonne di testo. Fallo. Il te futuro avrà meno preoccupazioni.

DataFrame: un dict di Series allineate

Un DataFrame è un dizionario di Series che condividono tutte lo stesso indice di righe. Questo è l’intero modello mentale. Ogni colonna è una Series, ogni colonna ha la stessa lunghezza, e ogni colonna ha le stesse etichette di riga.

import pandas as pd

df = pd.DataFrame({
    "country": ["IT", "FR", "DE", "ES"],
    "population_m": [59, 68, 84, 47],
    "in_eurozone": [True, True, True, True],
})
print(df)
  country  population_m  in_eurozone
0      IT            59         True
1      FR            68         True
2      DE            84         True
3      ES            47         True

L’hai creato da un dict-of-list. Le chiavi sono diventate i nomi di colonna, i valori sono diventati Series, l’indice di riga è stato di default un RangeIndex da 0.

Puoi anche costruire un DataFrame da una lista di dict (un dict per riga), che è più vicino a quello che ti torna indietro da un’API JSON:

rows = [
    {"country": "IT", "population_m": 59, "in_eurozone": True},
    {"country": "FR", "population_m": 68, "in_eurozone": True},
    {"country": "DE", "population_m": 84, "in_eurozone": True},
]
df = pd.DataFrame(rows)

O da un array NumPy 2D (poi specifichi le colonne tu):

import numpy as np
arr = np.array([[1, 2, 3], [4, 5, 6]])
df = pd.DataFrame(arr, columns=["a", "b", "c"])

O, cosa più comune nella vita reale, da un file:

df = pd.read_csv("countries.csv")
df = pd.read_parquet("countries.parquet")

Lo facciamo nella lezione 27.

Gli attributi che toccherai ogni giorno

Un DataFrame ha un piccolo set di attributi a cui ricorrerai costantemente:

df.shape       # (4, 3) -- righe, colonne
df.dtypes      # il dtype di ogni colonna, come una Series
df.index       # le etichette di riga (qui un RangeIndex(0, 4))
df.columns     # le etichette di colonna (Index(['country', 'population_m', 'in_eurozone']))
df.values      # l'array NumPy sottostante (evita di usarlo; usa .to_numpy() se ti serve)
df.info()      # sommario stampato: dtype, conteggi non-null, uso di memoria
df.head(3)     # prime 3 righe
df.tail(3)     # ultime 3 righe
df.describe()  # statistiche di sintesi per colonne numeriche

df.info() e df.head() sono le due cose che esegui su qualsiasi nuovo DataFrame, ogni volta. Sono come controlli quello che ti è effettivamente arrivato, prima di qualsiasi computazione.

Selezionare una colonna ti dà una Series. Due colonne ti danno un DataFrame.

Questo inciampa i principianti ed è critico interiorizzarlo:

df["country"]        # Series -- 1-D
df[["country"]]      # DataFrame -- 2-D, con una colonna
df[["country", "population_m"]]  # DataFrame, due colonne

Le parentesi singole con una stringa restituiscono una Series. Le parentesi doppie con una lista di stringhe, anche una lista di una sola, restituiscono un DataFrame. Molte funzioni accettano una cosa o l’altra ma non entrambe, e “gli ho passato df["col"] e si è lamentato che voleva una cosa 2-D” è un’occorrenza quotidiana all’inizio.

Lo specchio succede con le righe: df.loc[3] restituisce una Series (una riga), df.loc[[3]] restituisce un DataFrame (una tabella di 1 riga). Copriremo la selezione di righe come si deve nella lezione 28.

L’Index: noioso, importante

Ogni DataFrame ha un Index per le sue righe, e i nomi delle colonne sono essi stessi un Index per le colonne. L’indice di riga di default è un RangeIndex (0, 1, 2, …), ma puoi usare qualsiasi cosa: date, codici paese, ID utente, combinazioni multi-livello di tutti e tre.

df = pd.DataFrame(
    {"population_m": [59, 68, 84, 47]},
    index=["IT", "FR", "DE", "ES"],
)
df.loc["IT"]    # seleziona la riga IT per etichetta

Un MultiIndex è un indice con più livelli, ad esempio (paese, anno), (regione, negozio, data). È come fai dati gerarchici o stile pivot-table in pandas.

df = pd.DataFrame(
    {"sales": [100, 110, 95, 105]},
    index=pd.MultiIndex.from_tuples(
        [("IT", 2024), ("IT", 2025), ("FR", 2024), ("FR", 2025)],
        names=["country", "year"],
    ),
)
df.loc["IT"]            # tutte le righe per IT
df.loc[("IT", 2025)]    # specifico (paese, anno)

I MultiIndex sono potenti e un po’ fastidiosi. Li reincontreremo nella lezione 32 quando rimodelliamo i dati.

L’opinione nel 2026: non essere precioso sull’indice. Molti tutorial pandas moderni e gli utenti di Polars sostengono che dovresti per lo più lasciare l’indice come RangeIndex di default e trattare le tue colonne “chiave” come colonne normali. È un modello mentale più pulito. Usa un indice significativo quando ne benefici davvero (time series, join per etichetta, aggregazioni gerarchiche); saltalo altrimenti.

Backend NumPy contro backend Arrow, con un esempio

Per rendere concreta la distinzione di backend:

import pandas as pd

# Backend NumPy (il default storico)
df_np = pd.DataFrame({
    "name": ["Ada", "Linus", "Grace"],
    "score": [98, None, 92],
})
print(df_np.dtypes)
# name      object
# score    float64        <-- promosso a float per via del None
# dtype: object

# Backend Arrow
df_pa = pd.DataFrame({
    "name": ["Ada", "Linus", "Grace"],
    "score": [98, None, 92],
}).convert_dtypes(dtype_backend="pyarrow")
print(df_pa.dtypes)
# name      string[pyarrow]
# score      int64[pyarrow]   <-- rimane intero, con NA proprio
# dtype: object

Entrambi i DataFrame si comportano uguale per quasi ogni operazione che farai; quello con backend Arrow ha dtype più puliti, stringhe più veloci, e una rappresentazione più onesta dei dati mancanti. Andando avanti, ogni esempio in questo corso che non dimostra specificamente un comportamento del backend NumPy assumerà che tu stia lavorando con backend Arrow. Imposta dtype_backend="pyarrow" sulle tue chiamate read_* e ci sei.

Cosa viene dopo

Ora che sai cosa stai tenendo in mano quando tieni un DataFrame, la lezione 27 copre come ottenerne effettivamente uno: pd.read_csv, pd.read_parquet, le insidie in ogni formato, e perché Parquet è la risposta giusta quasi ogni volta che è permesso qualcosa di meglio del CSV.

Letture di approfondimento

Ci vediamo martedì per il vasto mondo di pd.read_*.

Cerca