Python, de la zero Lecția 31 / 60

Reshape: pivot, melt, stack, unstack

Wide-to-long, long-to-wide si cand coloanele MultiIndex sunt o functionalitate, nu un bug.

Datele tabulare au o formă. Aceleași numere pot sta într-un DataFrame în două layout-uri foarte diferite, iar pe care îl vrei depinde în întregime de ce urmează să faci. Astăzi acoperim reshaping-ul: cele patru funcții (melt, pivot, stack, unstack) care mută datele între layout-uri, capcana în care cad oamenii pe pivot și de ce coloanele MultiIndex sunt uneori utile și de obicei enervante.

Wide vs long

Un tabel wide are un rând pentru fiecare entitate și multe coloane pentru atribute:

order_id    Q1     Q2     Q3     Q4
1          120    150    180    210
2           90    100    110    130

Un tabel long are un rând pentru fiecare pereche entitate-atribut:

order_id    quarter    sales
1           Q1         120
1           Q2         150
1           Q3         180
1           Q4         210
2           Q1          90
...

Aceeași informație, rearanjată. Fiecare formă are locul ei. Wide e ce citesc oamenii într-un raport: trimestrele sus, totalurile jos, ochii scanează de la stânga la dreapta. Long e ce stochează bazele de date, ce așteaptă pipeline-urile de machine learning, ce presupun groupby și majoritatea bibliotecilor de plotting. SQL e fundamental un limbaj de tabele long. Cu cât te descurci mai repede să convertești între cele două, cu atât pierzi mai puțin timp luptându-te cu pandas.

Regula generală: wide pentru raportare, long pentru procesare. Dacă trimiți pe email unui CFO un rezumat trimestrial, mergi wide. Dacă îl bagi într-un model, calculezi agregări sau îl trimiți într-o bază de date, mergi long.

Wide la long: melt

melt ia un DataFrame wide și stivuiește coloanele alese în două coloane noi: una pentru numele vechilor coloane, una pentru valori.

import pandas as pd

wide = pd.DataFrame({
    "order_id": [1, 2, 3],
    "Q1": [120, 90, 200],
    "Q2": [150, 100, 220],
    "Q3": [180, 110, 240],
    "Q4": [210, 130, 260],
})

long = wide.melt(
    id_vars=["order_id"],
    value_vars=["Q1", "Q2", "Q3", "Q4"],
    var_name="quarter",
    value_name="sales",
)

Cele patru argumente care merită știute:

  • id_vars: coloanele care rămân ca identificatori; sunt repetate o dată pentru fiecare rând topit.
  • value_vars: coloanele de topit. Dacă îl omiți, fiecare coloană care nu e în id_vars e topită.
  • var_name: numele noii coloane care ține vechile nume de coloană. Implicitul e "variable", ce nu e niciodată ce vrei; setează-l mereu.
  • value_name: numele noii coloane care ține valorile. Implicitul e "value", rareori potrivit.

După asta, long are 12 rânduri (3 comenzi × 4 trimestre) și layout-ul așteptat de majoritatea uneltelor analitice. Poți face groupby pe trimestru, plotezi tendințele de la trimestru la trimestru, faci join cu un tabel de calendar, toate lucruri stângace în layout-ul wide.

O greșeală des întâlnită: să uiți că id_vars contează. Dacă DataFrame-ul tău wide are coloane de metadata (regiune, nume client, monedă) care ar trebui să călătorească cu fiecare rând topit, listează-le pe toate în id_vars sau le pierzi.

Long la wide: pivot

pivot e inversul lui melt. Având un DataFrame long, alegi o coloană să devină noul row index, una să devină noile column headers și una să umple celulele.

back_to_wide = long.pivot(
    index="order_id",
    columns="quarter",
    values="sales",
)

Rezultat:

quarter      Q1     Q2     Q3     Q4
order_id
1           120    150    180    210
2            90    100    110    130
3           200    220    240    260

Curat pe un input curat. Dar e o capcană.

Capcana duplicatelor

pivot cere ca perechile (index, columns) să fie unice. În momentul în care ai două rânduri cu același order_id și quarter, să zicem pentru că tabelul original e per-line-item în loc de per-order, pivot eșuează cu ValueError: Index contains duplicate entries, cannot reshape.

Soluția e pivot_table, care primește un aggfunc și agregă duplicatele în loc să dea eroare:

df.pivot_table(
    index="order_id",
    columns="quarter",
    values="sales",
    aggfunc="sum",  # sau "mean", "max", un callable, o lista de astea
    fill_value=0,   # ce sa puna in celulele care nu au date
)

Implicit folosesc pivot_table în cod de producție. E strict mai puternic decât pivot și face agregarea explicită. Singurul motiv pentru care apuci pivot e când chiar vrei să dea eroare dacă apar duplicate: o verificare de sanitate că un join în amonte nu a eșuat.

pivot_table acceptă și mai multe coloane values= și mai multe intrări aggfunc=, așa obții un layout ierarhic de raportare (suma vânzărilor, numărul vânzărilor, valoarea medie, toate într-un singur tabel). Aici intră în scenă coloanele MultiIndex.

Coloanele MultiIndex

Când faci pivot_table cu două coloane de valori sau două aggfuncs, coloanele rezultatului devin MultiIndex: coloane cu două niveluri stivuite unul peste altul:

report = sales.pivot_table(
    index="region",
    columns="quarter",
    values=["revenue", "units"],
    aggfunc="sum",
)
# coloanele sunt acum: ('revenue', 'Q1'), ('revenue', 'Q2'), ..., ('units', 'Q1'), ...

E grozav pentru rapoarte exportate în Excel, unde header-ul pe două rânduri se citește natural. E o pacoste pentru aproape orice altceva: SQL nu are coloane ierarhice, bibliotecile de ML nici atât, iar accesarea unei singure coloane înseamnă să scrii report[("revenue", "Q1")] în loc de report["revenue_Q1"].

Ca să aplatizezi o coloană MultiIndex în nume single-level:

report.columns = ["_".join(map(str, c)).strip("_") for c in report.columns]
# acum: revenue_Q1, revenue_Q2, ..., units_Q1, units_Q2, ...

Fac asta aproape reflex după orice pivot_table care produce un MultiIndex. Salvează atâta durere în aval încât costul unei linii în plus e neglijabil.

stack și unstack

stack și unstack mută datele între rânduri și coloane la nivelul indexului: sunt echivalentele MultiIndex pentru melt și pivot.

stack() ia cel mai interior nivel al coloanelor și îl coboară într-un nou nivel cel mai interior al row index-ului:

report.stack()  # trimestrele se muta din nivel de coloana intr-un row MultiIndex

unstack() e inversul: ia cel mai interior nivel al row index-ului și îl urcă într-un nou nivel de coloană:

long_indexed.unstack()  # cel mai interior row index devine coloane

În practică, unstack e mai util: multe operații pandas (în special groupby cu chei multiple) te lasă cu o serie MultiIndex, iar unstack e one-liner-ul care o transformă într-un DataFrame wide curat:

sales.groupby(["region", "quarter"])["amount"].sum().unstack()

Obții un DataFrame indexat după regiune cu o coloană pe trimestru. Tiparul ăsta, groupby pe două coloane, suma valorii, unstack pe a doua cheie, e atât de des întâlnit încât merită memorat.

Poți specifica ce nivel să faci stack/unstack cu argumentul level=: df.unstack(level="quarter") sau df.unstack(level=0). Implicit e cel mai interior.

reset_index: aplatizează MultiIndex-ul înapoi în coloane

După toată manipularea asta de MultiIndex, pasul final tipic e să pui totul înapoi în coloane simple pentru export. reset_index() face exact asta:

flat = report.reset_index()
# index-ul devine o coloana normala, rezultatul e un DataFrame plat

Dacă indexul are mai multe niveluri, toate devin coloane. Dacă vrei doar pe unele aplatizate, trimite level=. Operația oglindă, set_index(), merge invers și e utilă înainte de joins sau unstack.

Un exemplu real: raport ierarhic de vânzări către un export SQL

Să zicem că ai date brute de vânzări, un rând per line item, și vrei să produci două output-uri: un raport ierarhic pentru CFO (trimestrele de-a lungul, regiunile și categoriile de produse imbricate ca rânduri) și un tabel long plat pentru data warehouse.

import pandas as pd

sales = pd.DataFrame({
    "region": ["EU", "EU", "US", "US", "EU", "US"],
    "category": ["A", "B", "A", "B", "A", "A"],
    "quarter": ["Q1", "Q1", "Q1", "Q1", "Q2", "Q2"],
    "amount": [100, 50, 200, 150, 120, 220],
})

# Raport CFO: regiunile si categoriile in jos, trimestrele de-a lungul
report = sales.pivot_table(
    index=["region", "category"],
    columns="quarter",
    values="amount",
    aggfunc="sum",
    fill_value=0,
)
report.to_excel("cfo_report.xlsx")

# Export warehouse: long, plat, un rand per regiune-categorie-trimestru
warehouse = (
    sales
    .groupby(["region", "category", "quarter"], as_index=False)["amount"]
    .sum()
)
warehouse.to_parquet("sales_long.parquet")

Aceeași sursă, două forme, doi consumatori, fiecare mulțumit. CFO-ul primește layout-ul ierarhic wide pe care ochii ei îl pot scana; warehouse-ul primește layout-ul long pe care SQL îl poate îmbina. Reshaping-ul e podul.

Când să folosești fiecare

Un arbore de decizie aproximativ:

  • Treci de la „spreadsheet făcut de oameni” la „date pe care le pot analiza”: melt.
  • Treci de la „date analizate” la „tabel pentru un om”: pivot_table.
  • Ai deja o serie MultiIndex dintr-un groupby cu chei multiple: unstack.
  • Trebuie să aplatizezi un MultiIndex înapoi în coloane simple pentru export: reset_index + list comprehension-ul de aplatizare a coloanelor.

Majoritatea problemelor de reshape sunt unul din cele patru cazuri. Când te trezești scriind logică complicată de reshape, oprește-te și verifică: aproape mereu există un one-liner pe care l-ai pierdut.

Ce urmează

Lecția 32 e time series: DatetimeIndex, resample, rolling windows și capcana de timezone care mușcă orice pipeline de producție măcar o dată. Pandas a fost construit inițial pentru serii temporale financiare și se vede: API-ul de time-series e una dintre cele mai șlefuite părți din bibliotecă.

Lectură suplimentară

Ne vedem vineri.

Caută