Python, dalle fondamenta Lezione 30 / 60

Join: merge, concat, gli schemi che non ti sorprendono

merge per unire tabelle, concat per impilarle, e il comportamento di allineamento sull'indice che ha salvato o rovinato non poche analisi.

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 preferisci merge.

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

Ci vediamo martedì.

Cerca