L’analisi vera quasi mai vive in un solo DataFrame. Hai gli ordini in una tabella, i clienti in un’altra, i prodotti in una terza, e la domanda “qual è il valore medio dell’ordine per Paese del cliente?” ha senso solo dopo averli ricuciti insieme. Oggi vediamo le tre operazioni che combinano DataFrame: merge (la JOIN di SQL), concat (impila tabelle una sopra l’altra o una di fianco all’altra), e join (un sottile wrapper attorno a merge che si allinea sull’indice). Incontreremo anche validate= e indicator=, due argomenti che intercettano i bug che altrimenti saltano fuori solo in produzione.
Lavoreremo con due piccole tabelle per tutta la lezione:
import pandas as pd
pd.set_option("mode.copy_on_write", True)
orders = pd.DataFrame(
{
"order_id": [1, 2, 3, 4, 5],
"customer_id": [10, 20, 10, 30, 40],
"amount": [120.0, 85.0, 50.0, 200.0, 75.0],
}
)
customers = pd.DataFrame(
{
"customer_id": [10, 20, 30],
"name": ["Ada", "Boris", "Chiara"],
"country": ["IT", "DE", "IT"],
}
)
Cinque ordini, tre clienti noti, un ordine (customer_id=40) senza cliente corrispondente. Quel disallineamento è voluto: È dove la semantica del join inizia a fare la differenza.
pd.merge: la JOIN di SQL in pandas
pd.merge(left, right, on="key", how="inner") è il cavallo da tiro. Il parametro how sceglie il tipo di join:
pd.merge(orders, customers, on="customer_id", how="inner")
# 4 righe: solo i clienti 10, 20, 30 corrispondono. Il cliente 40 viene scartato.
pd.merge(orders, customers, on="customer_id", how="left")
# 5 righe: tutti gli ordini, con NaN per name e country del cliente 40.
pd.merge(orders, customers, on="customer_id", how="right")
# 3 clienti tutti mantenuti; il cliente 30 ha un ordine corrispondente, ma
# anche i clienti senza ordini compaiono (qui tutti hanno ordini, quindi nessuna differenza).
pd.merge(orders, customers, on="customer_id", how="outer")
# 5 righe: unione di entrambi i lati, NaN dove uno dei due lati non corrisponde.
pd.merge(orders, customers, how="cross")
# Prodotto cartesiano. 5 * 3 = 15 righe. Usalo con parsimonia.
In termini SQL: inner = INNER JOIN, left = LEFT JOIN, right = RIGHT JOIN, outer = FULL OUTER JOIN, cross = CROSS JOIN. I default coincidono con quello che la maggior parte degli analisti intende: how="inner", on= dedotto dai nomi di colonna in comune. Per il codice di produzione, passa sempre how= esplicitamente: gli inner join silenziosi scartano righe che potresti non accorgerti vengano scartate, e il bug compare nei totali due settimane dopo.
Join su più chiavi
Quando la relazione è identificata da una tupla, ad esempio (country, year), passa una lista:
pd.merge(sales, fx_rates, on=["country", "year"], how="left")
Entrambi i lati devono avere gli stessi nomi di colonna per on=. Se non li hanno, usa left_on= e right_on=:
pd.merge(
orders,
customers.rename(columns={"customer_id": "id"}),
left_on="customer_id",
right_on="id",
how="left",
)
Il risultato ha entrambe le colonne, customer_id e id, anche se sono identiche. Eliminane una esplicitamente subito dopo (e dovresti farlo, in codice di produzione):
result = pd.merge(
orders, customers,
left_on="customer_id", right_on="id",
how="left",
).drop(columns=["id"])
Join contro l’indice
Se il lato destro è indicizzato sulla chiave di join, usa right_index=True:
customers_indexed = customers.set_index("customer_id")
pd.merge(orders, customers_indexed, left_on="customer_id", right_index=True, how="left")
È comune quando hai messo in cache una tabella di lookup tramite la sua primary key: non c’è bisogno di fare reset dell’indice solo per fare il merge.
Suffissi: quando le colonne collidono
Se entrambi i DataFrame hanno una colonna con lo stesso nome (oltre alla chiave di join), pandas aggiunge dei suffissi per disambiguare:
left = pd.DataFrame({"id": [1, 2], "value": [10, 20]})
right = pd.DataFrame({"id": [1, 2], "value": [100, 200]})
pd.merge(left, right, on="id")
# colonne: id, value_x, value_y
_x e _y sono i default e sono pessimi. Passa suffixes= con nomi che significhino qualcosa:
pd.merge(left, right, on="id", suffixes=("_orders", "_invoices"))
È una piccola modifica che ripaga la prima volta che qualcun altro legge il tuo codice.
validate=: la rete di sicurezza poco usata
È l’argomento che dovrebbe esserci su ogni merge di produzione e quasi mai c’è. validate= fa controllare a pandas la relazione tra i due lati e fa scattare un errore se viene violata:
pd.merge(orders, customers, on="customer_id", how="left", validate="many_to_one")
I valori:
"one_to_one": ogni chiave compare al massimo una volta su ciascun lato. Utile quando uniamo due tabelle di riferimento che dovrebbero avere ognuna la propria chiave."one_to_many": chiavi uniche a sinistra, possono ripetersi a destra. Si usa quando un record padre (order) si unisce a molti figli (order_lines)."many_to_one": chiavi che possono ripetersi a sinistra, uniche a destra. Il caso più comune in analytics: una fact table (orders) che si unisce a una dimension table (customers). Ogni cliente compare una volta; molti ordini possono fare riferimento allo stesso cliente."many_to_many": dichiarazione esplicita “so che non c’è unicità”. Equivale a non validare nulla, ma documentato.
Se stai unendo orders a customers e qualcuno duplica per sbaglio una riga cliente, un merge non validato raddoppia silenziosamente il conteggio degli ordini. validate="many_to_one" solleva immediatamente MergeError, il che è molto più piacevole che scoprirlo in una dashboard durante un consiglio di amministrazione. Passalo, ogni volta che sei sicuro della relazione. È documentazione gratuita e un assert gratuito.
indicator=: per fare debug del “perché mancano delle righe?”
L’altro argomento per cui ti ringrazierai: indicator=True aggiunge una colonna chiamata _merge che dice, per ogni riga, da dove viene:
result = pd.merge(orders, customers, on="customer_id", how="outer", indicator=True)
result["_merge"]
# Categorical: "left_only", "right_only", o "both"
Se stai cercando di capire “mi aspettavo 1.000 righe e ne ho 970, dove sono finite le altre?”, fai il merge con how="outer" e indicator=True, poi conta per _merge. Le 30 righe mancanti sono o left_only (nessuna corrispondenza nella tabella di destra) o right_only. Da lì puoi ispezionarle direttamente:
unmatched = result.loc[result["_merge"] == "left_only", ["customer_id", "amount"]]
Questo singolo argomento mi ha fatto risparmiare più sessioni di debug di quasi qualsiasi altra cosa in pandas.
pd.concat: impilare, non unire
merge combina tabelle fianco a fianco su una chiave. concat le impila: verticalmente (più righe) o orizzontalmente (più colonne):
q1 = pd.DataFrame({"order_id": [1, 2], "amount": [120, 85]})
q2 = pd.DataFrame({"order_id": [3, 4], "amount": [50, 200]})
pd.concat([q1, q2]) # impilamento verticale, default axis=0
pd.concat([q1, q2], ignore_index=True) # ricostruisce un RangeIndex pulito 0..N
Senza ignore_index=True, gli indici originali vengono preservati e concatenati così come sono, quindi avrai due righe con indice 0, due con indice 1, eccetera. Quasi mai è quello che vuoi; passa ignore_index=True a meno che tu non abbia specificamente bisogno degli originali.
Il concat verticale allinea le colonne per nome. Le colonne mancanti diventano NaN:
a = pd.DataFrame({"id": [1, 2], "x": [10, 20]})
b = pd.DataFrame({"id": [3, 4], "y": [30, 40]})
pd.concat([a, b], ignore_index=True)
# colonne: id, x, y -- con NaN dove ciascun lato non aveva la colonna
Il concat orizzontale (axis=1) allinea le righe per indice:
left = pd.DataFrame({"a": [1, 2, 3]}, index=[10, 20, 30])
right = pd.DataFrame({"b": [4, 5, 6]}, index=[10, 20, 40])
pd.concat([left, right], axis=1)
# indice: 10, 20, 30, 40 (outer join degli indici); NaN per le righe non corrispondenti
È qui che pandas sorprende: il concat orizzontale è allineato sull’indice, non sulla posizione di riga. Se i tuoi due DataFrame hanno indici scorrelati e volevi “incollarli colonna per colonna nell’ordine delle righe”, ti serve reset_index(drop=True) su entrambi prima, poi concat. Altrimenti ottieni un pasticcio di outer join.
Per “etichettare ogni blocco con la sua origine”, keys= è comodo:
pd.concat([q1, q2], keys=["q1", "q2"])
# MultiIndex: ("q1", 0), ("q1", 1), ("q2", 0), ("q2", 1)
Il primo livello ti dice da quale fonte arriva ogni riga. Utile per “impila i file trimestrali in un unico file annuale con etichetta di trimestre”.
df.join: la scorciatoia indicizzata
df.join è un metodo del DataFrame, non è la stessa cosa di pd.merge. Di default unisce sull’indice del lato destro e sull’indice del lato sinistro:
customers_indexed = customers.set_index("customer_id")
orders.set_index("customer_id").join(customers_indexed, how="left")
Accetta anche un argomento on= per fare il join dell’indice di destra contro una colonna di sinistra:
orders.join(customers_indexed, on="customer_id", how="left")
df.join è una comodità per il caso comune “ho una tabella di lookup indicizzata sulla chiave, e voglio aggiungere le sue colonne”. Per qualunque cosa più complessa (nomi di colonna diversi, validazione, indicator) usa pd.merge. Non cercare di ricordare la forma degli argomenti di join; ricordarsi merge è sufficiente.
Quando usare cosa
Una breve regola pratica:
pd.merge: unire due tabelle su una colonna. Il default. Usalo il 90% delle volte.pd.concat(axis=0): impilare verticalmente tabelle con lo stesso schema. Caricare più file della stessa forma, accumulare blocchi.pd.concat(axis=1): incollare colonne da DataFrame allineati per indice. Meno comune di quanto si pensi; di solito quello che vuoi è un merge.df.join: lookup veloce per indice. Va bene per lavoro ad-hoc, in pipeline preferiscimerge.
Mettere insieme tutto
Un’analisi vera ricuce diversi di questi pezzi:
import pandas as pd
pd.set_option("mode.copy_on_write", True)
orders = pd.read_parquet("orders.parquet")
customers = pd.read_parquet("customers.parquet")
fx_rates = pd.read_parquet("fx_rates.parquet") # (currency, year) -> rate
result = (
orders
.merge(
customers,
on="customer_id",
how="left",
validate="many_to_one",
)
.merge(
fx_rates,
on=["currency", "year"],
how="left",
validate="many_to_one",
)
.assign(amount_eur=lambda d: d["amount"] * d["rate"])
.groupby(["country", "year"], as_index=False)
.agg(
total_eur=("amount_eur", "sum"),
n_orders=("order_id", "size"),
)
.sort_values(["country", "year"])
.reset_index(drop=True)
)
Due left join, entrambi validati come many-to-one, poi aggregazione. Se un customer ID viene duplicato a monte, validate="many_to_one" fa errore al merge invece di produrre totali gonfiati. Se si infila una coppia currency/year senza corrispondenza, amount_eur diventa NaN e salta fuori alla prossima revisione dei totali (o, meglio, aggiungi un controllo result.loc[result["amount_eur"].isna()] dopo il secondo merge e fai assert che sia vuoto).
Indicator + validate, lo schema da produzione
Per le pipeline a cui tengo, il merge ha questa forma:
merged = pd.merge(
orders,
customers,
on="customer_id",
how="left",
validate="many_to_one",
indicator=True,
)
unmatched = merged.loc[merged["_merge"] == "left_only", "customer_id"].unique()
if len(unmatched):
raise ValueError(f"{len(unmatched)} unmatched customer_ids: {unmatched[:10]}")
merged = merged.drop(columns="_merge")
Valida la cardinalità, usa indicator per le corrispondenze, fai assert che non ci siano righe senza match, droppa l’indicator prima di andare avanti. Tre righe in più; risparmiano un trimestre di ticket “perché i totali sono sballati dello 0,3%”.
Cosa viene dopo
È la fine del modulo di mastery di pandas. Da qui passiamo al modulo 6, time series e window function, dove vedremo resampling, rolling window e le versioni date-aware delle operazioni delle ultime lezioni.
Letture di approfondimento
- pandas: Merge, join, concatenate: la user guide, con i diagrammi. Vale una lettura lenta una volta.
- pandas: riferimento di
merge: elenco completo degli argomenti, inclusivalidateeindicator. - pandas: riferimento di
concat: elenco completo degli argomenti, inclusikeyseignore_index.
Ci vediamo martedì.