Python, dalle fondamenta Lezione 28 / 60

Selezione: .loc, .iloc, indicizzazione booleana

I tre modi di affettare un DataFrame, le differenze, e il SettingWithCopyWarning di cui tutti rimangono vittima.

Hai un DataFrame. Adesso ne vuoi un pezzo: una colonna, qualche riga, le righe dove vale qualche condizione, la cella alla riga 7 colonna 3. Pandas ti dà tre modi per farlo, e si somigliano abbastanza da farli mescolare tra loro ai principianti finché qualcosa non funziona. Il risultato è codice che gira oggi e si rompe la prossima settimana quando l’index cambia. Oggi sbroglieremo i tre idiomi, parentesi quadre, .loc, .iloc, e alla fine guarderemo il SettingWithCopyWarning, il messaggio più cercato su Google nel mondo pandas.

Lavoreremo con un DataFrame di esempio per tutta la lezione:

import pandas as pd

df = pd.DataFrame(
    {
        "country": ["IT", "DE", "FR", "IT", "DE", "FR"],
        "year":    [2024, 2024, 2024, 2025, 2025, 2025],
        "revenue": [120.0, 340.0, 210.0, 145.0, 380.0, 225.0],
        "orders":  [12, 25, 18, 15, 28, 19],
    }
)

Sei righe, quattro colonne, index intero di default. Abbastanza semplice da ragionarci sopra, abbastanza reale da essere utile.

Le parentesi quadre, comode e ambigue

La sintassi nuda df[...] è quella che la gente impara per prima perché assomiglia a un dict Python. Fa quattro cose diverse a seconda di cosa ci metti dentro:

df["revenue"]                   # una colonna -> Series
df[["country", "revenue"]]      # lista di colonne -> DataFrame
df[0:3]                         # slice di righe -> DataFrame (prime 3 righe)
df[df["revenue"] > 200]         # boolean mask -> DataFrame (righe filtrate)

Quattro operazioni diverse che condividono una sintassi. È comodo nella REPL: non devi ricordarti quale usare, scrivi qualcosa e pandas lo capisce. È anche ambiguo: se hai una colonna che si chiama letteralmente 0, o un DataFrame con index a stringhe, il significato di df[0:3] cambia. Per analisi una tantum le parentesi quadre vanno bene. Per codice che rileggerai fra sei mesi, preferisci .loc o .iloc, che ognuno significa esattamente una cosa.

L’altro motivo per evitare le parentesi quadre nelle pipeline è il chaining. df[df["revenue"] > 200]["orders"] = 0 sembra che dovrebbe mettere orders a zero sulle righe ad alto fatturato. Non lo fa, in modo affidabile. Tra un attimo torniamo sul perché.

.loc, selezione per label

.loc indicizza per label: per il valore dell’index sulle righe, per il valore del nome della colonna sulle colonne. Due argomenti, separati da una virgola:

df.loc[row_labels, col_labels]

Per il nostro index intero di default, le label delle righe coincidono con le posizioni delle righe: df.loc[2] è la terza riga perché la sua label è 2. Quell’equivalenza è la trappola: funziona finché non fai df = df.sort_values("revenue") e adesso df.loc[2] è dovunque la riga con label originale 2 sia finita, non la terza riga. Pensa sempre a .loc come “per label”, anche quando le label sembrano posizioni.

Forme concrete:

df.loc[0]                              # riga con label 0 (Series)
df.loc[[0, 2, 4]]                      # righe con queste label (DataFrame)
df.loc[1:3]                            # righe da label 1 a label 3 INCLUSE
df.loc[:, "revenue"]                   # tutte le righe, una colonna (Series)
df.loc[:, ["country", "revenue"]]      # tutte le righe, due colonne (DataFrame)
df.loc[0:2, "country":"revenue"]       # slice rettangolare, entrambi gli estremi inclusi

Due cose da interiorizzare:

Le slice sono inclusive a entrambi gli estremi. df.loc[1:3] restituisce le label 1, 2 e 3. È il contrario di ogni altro slice Python che hai mai visto, ed è voluto: con valori di index arbitrari (stringhe, date), “fermati appena prima di questa label” è raramente quello che vuoi. La prima volta sorprende comunque.

Le boolean mask vivono sull’asse delle righe. Questo è lo schema da produzione:

mask = df["revenue"] > 200
df.loc[mask, "orders"]                 # colonna orders per le righe ad alto fatturato
df.loc[mask, ["country", "orders"]]    # due colonne per le righe ad alto fatturato
df.loc[mask, "orders"] = 0             # ASSEGNAZIONE (questa e' la forma sicura)

La maschera deve essere una Series booleana allineata all’index delle righe. .loc la accetta posizionalmente, primo argomento, asse delle righe, e puoi combinarla con una label di colonna o una lista sul secondo asse. Questa è la forma da memorizzare; è così che dovrebbe apparire l’80% del filtraggio del mondo reale.

.iloc, selezione per posizione

.iloc è l’altra. Stessa forma, regole diverse: indicizza per posizione intera, non per label. Le slice si comportano come le slice Python normali (semi-aperte):

df.iloc[0]                  # prima riga (Series)
df.iloc[-1]                 # ultima riga
df.iloc[0:3]                # prime 3 righe (lo stop e' esclusivo, come in una list)
df.iloc[:, 0]               # prima colonna (Series)
df.iloc[0:3, 0:2]           # prime 3 righe, prime 2 colonne
df.iloc[[0, 2, 4]]          # righe a queste posizioni

Usa .iloc quando intendi davvero “la terza riga” indipendentemente dalla sua label: la prima riga di una vista ordinata, le ultime 100 righe, una riga sì e una no. Usa .loc quando intendi un valore con label specifica, incluse tutte le boolean mask.

Una regola utile: .iloc per la plumbing posizionale (head, tail, sampling), .loc per tutto ciò che riguarda cosa c’è nei dati.

Indicizzazione booleana, il filtro di tutti i giorni

Una Series booleana della stessa lunghezza dell’asse delle righe diventa un filtro per le righe:

df.loc[df["revenue"] > 200]
df.loc[df["country"] == "IT"]
df.loc[df["country"].isin(["IT", "FR"])]
df.loc[df["revenue"].between(150, 300)]
df.loc[~df["country"].isin(["DE"])]    # ~ e' "not"

Combina le maschere con & (and), | (or), ~ (not). Usa le parentesi, perché & lega più strettamente di > e altrimenti otterrai un errore confondente:

df.loc[(df["revenue"] > 200) & (df["country"] == "DE")]
df.loc[(df["country"] == "IT") | (df["country"] == "FR")]

Se ti dimentichi le parentesi, pandas solleva un TypeError che parla di operazioni bit a bit su bool. Quello è il messaggio; la soluzione sono sempre le parentesi.

Per NaN, i predicati delle maschere hanno helper specifici:

df.loc[df["revenue"].isna()]
df.loc[df["revenue"].notna()]

Non usare df["revenue"] == None o df["revenue"] != float("nan"): NaN non è uguale a niente, neanche a se stesso. isna/notna è l’unico modo corretto.

.query, quando le maschere diventano rumorose

Se stai impilando tre o quattro condizioni, le parentesi diventano noiose. df.query accetta una stringa con sapore SQL:

df.query("revenue > 200 and country == 'DE'")
df.query("country in ['IT', 'FR'] and year == 2025")

Qualche regola:

  • I letterali stringa vanno tra apici singoli (perché tutta l’espressione è tra apici doppi).
  • I nomi di colonna con spazi o punti vogliono i backtick: df.query("order count > 5").
  • Riferisciti alle variabili Python con @: threshold = 200; df.query("revenue > @threshold").

.query è una vera vittoria sulla leggibilità per filtri di media taglia. Per filtri di una sola condizione, .loc[mask] è più corto; per filtri a molte condizioni, .query si legge come l’SQL che l’analista sta comunque cercando di scrivere.

Impostare valori, lo schema giusto

Leggere è solo metà della storia. Scrivere è dove iniziano i warning.

Lo schema giusto è df.loc[mask, col] = value:

df.loc[df["revenue"] > 300, "country"] = "DE-PREMIUM"
df.loc[df["country"] == "IT", "orders"] *= 2
df.loc[df["revenue"].isna(), "revenue"] = 0

Una sola chiamata a .loc, due argomenti, assegnazione. Pandas sa che è una scrittura, muta il DataFrame originale in place, e non c’è ambiguità su se stai modificando una vista o una copia.

Lo schema sbagliato, quello che produce il warning famoso, è il chained indexing:

df[df["revenue"] > 300]["country"] = "DE-PREMIUM"   # NO

Questo chiama df[df["revenue"] > 300] (che restituisce un nuovo DataFrame filtrato, forse una vista, forse una copia) e poi assegna a ["country"] su quello. Pandas non sa dire se la tua scrittura sta colpendo il df originale o una copia temporanea che sta per essere garbage-collected. Storicamente, a volte funzionava, a volte no, a seconda del layout dei dtype. Quindi pandas lanciava SettingWithCopyWarning e incrociava le dita.

SettingWithCopyWarning, in breve

Il testo del warning è notoriamente confondente:

A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer, col_indexer] = value instead.

Cosa significa: hai usato chained indexing, e pandas non è sicuro che la tua assegnazione sia andata a buon fine. La soluzione è quella che il warning ti dice letteralmente: fai collassare la catena in un singolo .loc[mask, col] = value.

La causa profonda: gli array NumPy stanno dietro i DataFrame, e fare slice di un array NumPy a volte restituisce una vista (le scritture si propagano all’originale) e a volte restituisce una copia (le scritture no). Pandas non poteva dirlo dall’interno, quindi avvisava in via difensiva ogni volta che la struttura sembrava pericolosa.

La soluzione di pandas 2.0+: copy-on-write. Pandas ha ora una modalità copy-on-write che risolve l’ambiguità garantendo che ogni operazione si comporti come se restituisse una copia: le scritture vanno sempre all’oggetto su cui hai scritto, mai silenziosamente al suo genitore. A partire da pandas 2.2 è opt-in tramite pd.set_option("mode.copy_on_write", True); in pandas 3.0 diventa il default. Nel 2026, accenderla in cima a qualsiasi progetto nuovo è la scelta giusta:

import pandas as pd

pd.set_option("mode.copy_on_write", True)

Con CoW attivo, lo schema del chained indexing semplicemente non scrive da nessuna parte di utile (modifica la copia usa-e-getta e l’originale resta intoccato), e pandas emette un ChainedAssignmentError invece di un warning, molto più difficile da ignorare. In ogni caso, lo schema giusto non cambia: df.loc[mask, col] = value.

Aggiungere colonne

Due forme equivalenti:

df["revenue_per_order"] = df["revenue"] / df["orders"]
df.loc[:, "revenue_per_order"] = df["revenue"] / df["orders"]

Per la creazione incondizionata di colonne la forma con le parentesi quadre va bene ed è idiomatica: non c’è catena, solo una singola assegnazione. Per la creazione condizionata di colonne (imposta un valore solo su alcune righe), usa .loc:

df["tier"] = "standard"
df.loc[df["revenue"] > 300, "tier"] = "premium"

Oppure, per molti rami, np.select:

import numpy as np

df["tier"] = np.select(
    [df["revenue"] > 300, df["revenue"] > 150],
    ["premium", "mid"],
    default="standard",
)

np.select è l’if/elif/else a più rami per l’assegnazione vettorizzata.

Mettiamo insieme i pezzi

Una pipeline di filtraggio reale assomiglia a qualcosa così:

import pandas as pd

pd.set_option("mode.copy_on_write", True)

df = pd.read_parquet("sales.parquet")

# Filtra alle righe che ci interessano.
df = df.loc[
    (df["country"].isin(["IT", "DE", "FR"]))
    & (df["year"] == 2025)
    & df["revenue"].notna()
]

# Aggiungi una colonna derivata.
df["revenue_per_order"] = df["revenue"] / df["orders"]

# Aggiusta condizionatamente alcune righe.
df.loc[df["country"] == "IT", "revenue"] *= 1.05  # correzione del 5% per IT

# Scegli le colonne che vogliamo per il downstream.
result = df.loc[:, ["country", "revenue", "orders", "revenue_per_order"]]

Ogni operazione è una sola chiamata a .loc, niente catene, nessun warning. Rileggilo: filtra, aggiungi, aggiusta, proietta. È tutto il vocabolario.

Cosa c’è dopo

La lezione 29 è groupby e aggregazione: una volta che sai scegliere le righe che vuoi, la cosa successiva che vuoi è riassumerle per gruppo. Copriremo le aggregazioni nominate, la distinzione agg/apply/transform (che frega quasi tutti), e le pivot table.

Letture di approfondimento

Ci vediamo martedì.

Cerca