Python, de la zero Lecția 30 / 60

Joins: merge, concat, tiparele care nu te iau prin surprindere

merge pentru imbinarea tabelelor, concat pentru stivuirea lor si comportamentul de aliniere pe index care a salvat sau a ruinat multe analize.

Analiza reală nu trăiește aproape niciodată într-un singur DataFrame. Ai comenzile într-un tabel, clienții în altul, produsele într-un al treilea, iar întrebarea „care e valoarea medie a comenzii pe țară a clientului?” capătă sens abia după ce le-ai cusut împreună. Astăzi parcurgem cele trei operații care combină DataFrame-uri: merge (SQL JOIN), concat (stivuiește tabele unul peste altul sau alături) și join (un wrapper subțire peste merge care aliniază pe index). Vom întâlni și validate= și indicator=, două argumente care prind buguri ce, altfel, apar abia în producție.

Vom lucra pe parcurs cu două tabele mici:

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"],
    }
)

Cinci comenzi, trei clienți cunoscuți, o comandă (customer_id=40) fără client corespondent. Nepotrivirea e intenționată: acolo încep să conteze semantica join-urilor.

pd.merge, JOIN-ul SQL al pandas

pd.merge(left, right, on="key", how="inner") e calul de povară. Parametrul how alege tipul de join:

pd.merge(orders, customers, on="customer_id", how="inner")
# 4 randuri: doar clientii 10, 20, 30 se potrivesc. Clientul 40 dispare.

pd.merge(orders, customers, on="customer_id", how="left")
# 5 randuri: toate comenzile, cu NaN pentru numele si tara clientului 40.

pd.merge(orders, customers, on="customer_id", how="right")
# 3 clienti pastrati toti; clientul 30 are o comanda corespondenta, dar
# clientii fara comenzi ar aparea si ei (aici toti clientii au comenzi).

pd.merge(orders, customers, on="customer_id", how="outer")
# 5 randuri: reuniunea ambelor parti, NaN unde nu exista corespondent.

pd.merge(orders, customers, how="cross")
# Produs cartezian. 5 * 3 = 15 randuri. Foloseste cu masura.

În termeni SQL: inner = INNER JOIN, left = LEFT JOIN, right = RIGHT JOIN, outer = FULL OUTER JOIN, cross = CROSS JOIN. Valorile implicite se potrivesc cu ce vor de obicei analiștii: how="inner", on= dedus din coloanele cu nume comun. Pentru cod de producție, trimite mereu how= explicit: inner join-urile tăcute scapă rânduri pe care s-ar putea să nu realizezi că le pierzi, iar bug-ul apare în totaluri peste două săptămâni.

Join pe mai multe chei

Când relația e cheiată de un tuplu, să zicem (country, year), trimite o listă:

pd.merge(sales, fx_rates, on=["country", "year"], how="left")

Ambele părți au nevoie de aceleași nume de coloană pentru on=. Dacă nu le au, folosește left_on= și right_on=:

pd.merge(
    orders,
    customers.rename(columns={"customer_id": "id"}),
    left_on="customer_id",
    right_on="id",
    how="left",
)

Rezultatul are ambele coloane, customer_id și id, deși sunt identice. Aruncă explicit una după aceea dacă te deranjează (și ar trebui, în cod de producție):

result = pd.merge(
    orders, customers,
    left_on="customer_id", right_on="id",
    how="left",
).drop(columns=["id"])

Join pe index

Dacă partea dreaptă e indexată după cheia de join, folosește right_index=True:

customers_indexed = customers.set_index("customer_id")
pd.merge(orders, customers_indexed, left_on="customer_id", right_index=True, how="left")

E des întâlnit când ai cache-uit un tabel de lookup după cheia primară: nu e nevoie să resetezi indexul doar ca să faci merge.

Suffixes, când coloanele se ciocnesc

Dacă ambele DataFrame-uri au o coloană cu același nume (alta decât cheia de join), pandas adaugă sufixe ca să le deosebească:

left = pd.DataFrame({"id": [1, 2], "value": [10, 20]})
right = pd.DataFrame({"id": [1, 2], "value": [100, 200]})
pd.merge(left, right, on="id")
# coloane: id, value_x, value_y

_x și _y sunt valorile implicite și sunt groaznice. Trimite suffixes= cu nume care înseamnă ceva:

pd.merge(left, right, on="id", suffixes=("_orders", "_invoices"))

E o schimbare minusculă care se plătește singură prima oară când îți citește altcineva codul.

validate=, plasa de siguranță prea puțin folosită

E argumentul care ar trebui să fie pe orice merge de producție și aproape niciodată nu e. validate= îi spune pandas să verifice relația dintre cele două părți și să dea eroare dacă e încălcată:

pd.merge(orders, customers, on="customer_id", how="left", validate="many_to_one")

Valorile:

  • "one_to_one": fiecare cheie apare cel mult o dată pe fiecare parte. Util când îmbini două tabele de referință care ar trebui să fie cheiate amândouă.
  • "one_to_many": cheile sunt unice pe stânga, pot să se repete pe dreapta. Folosit când o înregistrare-părinte (order) se îmbină cu mulți copii (order_lines).
  • "many_to_one": cheile pot să se repete pe stânga, sunt unice pe dreapta. Cazul cel mai des întâlnit în analytics: un fact table (orders) se îmbină cu un dimension table (customers). Fiecare client apare o dată; multe comenzi pot referi același client.
  • "many_to_many": explicit „știu că nu e niciun fel de unicitate”. La fel cu lipsa validării, dar documentat.

Dacă îmbini orders cu customers și cineva duplică din greșeală un rând de client, un merge nevalidat îți dublează tăcut numărul de comenzi. validate="many_to_one" ridică MergeError imediat, ceea ce e mult mai plăcut decât să descoperi problema într-un dashboard arătat la o ședință de board. Trimite-l de fiecare dată când ești sigur de relație. E documentație gratuită și o aserțiune gratuită.

indicator=, debugging pentru „de ce lipsesc rânduri?”

Celălalt argument pentru care îți vei mulțumi: indicator=True adaugă o coloană numită _merge care spune, pentru fiecare rând, de unde vine:

result = pd.merge(orders, customers, on="customer_id", how="outer", indicator=True)
result["_merge"]
# Categorical: "left_only", "right_only" sau "both"

Dacă faci debugging la „mă așteptam la 1.000 de rânduri și am primit 970, unde s-au dus celelalte?”, fă merge-ul cu how="outer" și indicator=True, apoi numără pe _merge. Cele 30 de rânduri lipsă sunt fie left_only (fără corespondent în tabelul drept), fie right_only. De acolo le poți inspecta direct:

unmatched = result.loc[result["_merge"] == "left_only", ["customer_id", "amount"]]

Singurul ăsta argument mi-a salvat mai multe sesiuni de debugging decât aproape orice altceva în pandas.

pd.concat, stivuire, nu îmbinare

merge combină tabele unul lângă altul pe o cheie. concat le stivuiește: vertical (mai multe rânduri) sau orizontal (mai multe coloane):

q1 = pd.DataFrame({"order_id": [1, 2], "amount": [120, 85]})
q2 = pd.DataFrame({"order_id": [3, 4], "amount": [50, 200]})

pd.concat([q1, q2])                # stiva verticala, axis=0 implicit
pd.concat([q1, q2], ignore_index=True)   # reconstruieste un RangeIndex curat 0..N

Fără ignore_index=True, indicii originali sunt păstrați și concatenați așa cum sunt, deci vei avea două rânduri cu indexul 0, două cu indexul 1 etc. Aproape niciodată nu vrei asta; trimite ignore_index=True decât dacă ai nevoie explicit de cei originali.

Concat-ul vertical aliniază coloanele după nume. Coloanele lipsă devin 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)
# coloane: id, x, y -- cu NaN unde fiecare parte nu avea coloana

Concat-ul orizontal (axis=1) aliniază rândurile după index:

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)
# index: 10, 20, 30, 40 (outer join al indicilor); NaN pentru randurile care nu se potrivesc

Aici pandas îi ia prin surprindere pe oameni: concat orizontal e aliniat pe index, nu pe poziția rândului. Dacă cele două DataFrame-uri au indici fără legătură și voiai „lipește-le coloană cu coloană în ordinea rândurilor”, trebuie să faci reset_index(drop=True) pe amândouă întâi, apoi concat. Altfel obții un haos cu outer join.

Pentru „etichetează fiecare bucată cu locul de unde a venit”, keys= e la îndemână:

pd.concat([q1, q2], keys=["q1", "q2"])
# MultiIndex: ("q1", 0), ("q1", 1), ("q2", 0), ("q2", 1)

Primul nivel îți spune din ce sursă a venit fiecare rând. Util pentru „stivuiește fișierele trimestriale într-un singur fișier anual cu o etichetă de trimestru”.

df.join, scurtătura cheiată pe index

df.join e o metodă pe DataFrame, nu același lucru cu pd.merge. Implicit, face join pe indexul părții drepte și indexul celei stângi:

customers_indexed = customers.set_index("customer_id")
orders.set_index("customer_id").join(customers_indexed, how="left")

Acceptă și un argument on= ca să îmbine indexul din dreapta cu o coloană din stânga:

orders.join(customers_indexed, on="customer_id", how="left")

df.join e o comoditate pentru cazul des întâlnit „am un tabel de lookup indexat după cheie și vreau să îi adaug coloanele”. Orice lucru mai complex (nume de coloane diferite, validare, indicatori) folosește pd.merge. Nu încerca să ții minte forma argumentelor lui join; e suficient să-l ții minte pe merge.

Când să folosești fiecare

O scurtă rubrică:

  • pd.merge: îmbinarea a două tabele pe o coloană. Implicitul. Folosește-l 90% din timp.
  • pd.concat(axis=0): stivuirea verticală a tabelelor cu aceeași schemă. Încărcarea mai multor fișiere de aceeași formă, acumularea de bucăți.
  • pd.concat(axis=1): lipirea coloanelor din DataFrame-uri aliniate pe index. Mai puțin folosit decât ai crede; de obicei vrei un merge.
  • df.join: lookup rapid pe index. Bun pentru lucru ad-hoc, preferă merge în pipeline-uri.

Punând totul cap la cap

O analiză reală coase mai multe dintre acestea:

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)
)

Două left join-uri, ambele validate ca many-to-one, apoi agregare. Dacă un customer ID se duplică în amonte, validate="many_to_one" dă eroare la merge în loc să producă totaluri umflate. Dacă o pereche currency/year nepotrivită se strecoară, amount_eur devine NaN și apare la următoarea revizuire a totalurilor (sau, mai bine, adaugi o verificare result.loc[result["amount_eur"].isna()] după al doilea merge și aserți că e gol).

Indicator + validate, tiparul de producție

Pentru pipeline-urile la care țin, merge-ul arată așa:

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")

Validează cardinalitatea, marchează potrivirile cu indicator, aserți că nu există rânduri nepotrivite, aruncă indicatorul înainte de a continua. Trei linii în plus; salvează un trimestru de tickete cu „de ce sunt totalurile cu 0,3% în plus”.

Ce urmează

Asta a fost finalul modulului de pandas mastery. De aici intrăm în modulul 6: time-series și window functions, unde vom vedea resampling, rolling windows și versiunile date-aware ale operațiilor din ultimele lecții.

Lectură suplimentară

Ne vedem marți.

Caută