Python, dalle fondamenta Lezione 31 / 60

Reshape: pivot, melt, stack, unstack

Da wide a long, da long a wide, e quando le colonne MultiIndex sono una feature invece che un bug.

I dati tabellari hanno una forma. Gli stessi numeri possono stare in un DataFrame in due layout molto diversi, e quale dei due vuoi dipende interamente da cosa stai per fare dopo. Oggi vediamo il reshape: le quattro funzioni (melt, pivot, stack, unstack) che spostano i dati tra layout, la trappola che frega chi usa pivot, e perché le colonne MultiIndex sono talvolta utili e di solito fastidiose.

Wide vs long

Una tabella wide ha una riga per entità e molte colonne per gli attributi:

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

Una tabella long ha una riga per coppia entità-attributo:

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

Stessa informazione, riorganizzata. Ognuna delle due forme ha il suo posto. Wide è quello che gli umani leggono in un report: trimestri in alto, totali in fondo, gli occhi scorrono da sinistra a destra. Long è quello che i database memorizzano, quello che le pipeline di machine learning si aspettano, quello che groupby e la maggior parte delle librerie di plotting assumono. SQL è fondamentalmente un linguaggio long-table. più diventi veloce a convertire tra le due, meno tempo passi a lottare con pandas.

La regola generale: wide per il reporting, long per il processing. Se stai mandando per email a un CFO un riepilogo trimestrale, usa wide. Se lo stai dando in pasto a un modello, calcolando aggregazioni, o spedendolo a un database, usa long.

Da wide a long: melt

melt prende un DataFrame wide e impila le colonne scelte in due nuove: una per i vecchi nomi di colonna, una per i 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",
)

I quattro argomenti che vale la pena conoscere:

  • id_vars: colonne che restano come colonne identificative; vengono ripetute una volta per ogni riga melted.
  • value_vars: colonne da fare melt. Se la ometti, ogni colonna che non è in id_vars viene melted.
  • var_name: il nome della nuova colonna che contiene i vecchi nomi di colonna. Il default è "variable", che non è mai quello che vuoi; impostalo sempre.
  • value_name: il nome della nuova colonna che contiene i valori. Il default è "value", anche questo raramente giusto.

Dopo, long ha 12 righe (3 ordini per 4 trimestri) e il layout che si aspetta la maggior parte degli strumenti analitici. Puoi raggruppare per trimestre, plottare l’andamento trimestre su trimestre, fare join contro una calendar table: tutte cose scomode nel layout wide.

Un errore comune: dimenticare che id_vars conta. Se il tuo DataFrame wide ha colonne di metadati (regione, nome cliente, valuta) che dovrebbero viaggiare con ogni riga melted, elencale tutte in id_vars o le perderai.

Da long a wide: pivot

pivot è l’inverso di melt. Dato un DataFrame long, scegli una colonna che diventa il nuovo indice di riga, una che diventa le nuove intestazioni di colonna, e una che riempie le celle.

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

Risultato:

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

Abbastanza pulito su un input pulito. Ma c’è una trappola.

La trappola dei duplicati

pivot richiede che le coppie (index, columns) siano uniche. Nel momento in cui hai due righe con lo stesso order_id e quarter, ad esempio perché la tabella originale è per riga d’ordine invece che per ordine, pivot fallisce con ValueError: Index contains duplicate entries, cannot reshape.

La soluzione è pivot_table, che prende una aggfunc e aggrega i duplicati invece di dare errore:

df.pivot_table(
    index="order_id",
    columns="quarter",
    values="sales",
    aggfunc="sum",  # oppure "mean", "max", un callable custom, una lista di questi
    fill_value=0,   # cosa mettere nelle celle senza dati
)

In codice di produzione vado di default su pivot_table. È strettamente più potente di pivot e rende l’aggregazione esplicita. L’unico motivo per usare pivot è quando vuoi davvero che dia errore se compaiono duplicati, come sanity check sul fatto che il join a monte non sia andato male.

pivot_table accetta anche più colonne values= e più voci di aggfunc=, ed è così che ottieni un layout di reporting gerarchico (somma vendite, conteggio vendite, valore medio per vendita tutto in una tabella). È lì che entrano in scena le colonne MultiIndex.

Colonne MultiIndex

Quando fai pivot_table con due colonne value o due aggfunc, le colonne del risultato diventano un MultiIndex: colonne con due livelli impilati uno sopra l’altro:

report = sales.pivot_table(
    index="region",
    columns="quarter",
    values=["revenue", "units"],
    aggfunc="sum",
)
# le colonne ora sono: ('revenue', 'Q1'), ('revenue', 'Q2'), ..., ('units', 'Q1'), ...

È ottimo per i report da esportare in Excel, dove l’intestazione su due righe si legge naturalmente. È una rottura per quasi tutto il resto: SQL non ha colonne gerarchiche, le librerie ML neanche, e accedere a una singola colonna significa scrivere report[("revenue", "Q1")] invece di report["revenue_Q1"].

Per appiattire una colonna MultiIndex in nomi a singolo livello:

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

Lo faccio quasi per riflesso dopo ogni pivot_table che produce un MultiIndex. Risparmia così tanto dolore a valle che il costo di una riga in più è trascurabile.

stack e unstack

stack e unstack spostano i dati tra righe e colonne a livello di indice: sono gli equivalenti MultiIndex di melt e pivot.

stack() prende il livello più interno delle colonne e lo sposta giù in un nuovo livello più interno dell’indice di riga:

report.stack()  # i trimestri si spostano dal livello colonna a un MultiIndex di riga

unstack() è l’inverso: prende il livello più interno dell’indice di riga e lo sposta su in un nuovo livello di colonna:

long_indexed.unstack()  # l'indice di riga più interno diventa colonne

In pratica, unstack è più utile: molte operazioni pandas (specie groupby con più chiavi) ti lasciano con una Serie a MultiIndex, e unstack è l’one-liner che la trasforma in un DataFrame wide pulito:

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

Ottieni un DataFrame indicizzato per regione con una colonna per trimestre. Questo schema, groupby su due colonne, somma del valore, unstack della seconda chiave, è così comune che vale la pena memorizzarlo.

Puoi specificare quale livello fare stack/unstack con l’argomento level=: df.unstack(level="quarter") o df.unstack(level=0). Il default è il più interno.

reset_index: appiattire il MultiIndex e riportarlo a colonne

Dopo tutta questa manipolazione di MultiIndex, il passo finale tipico è rimettere tutto in colonne semplici per l’export. reset_index() fa esattamente questo:

flat = report.reset_index()
# l'indice diventa una colonna normale, il risultato è un DataFrame piatto

Se l’indice ha più livelli, diventano tutti colonne. Se vuoi appiattirne solo alcuni, passa level=. L’operazione speculare, set_index(), va nell’altra direzione ed è utile prima di join o unstack.

Un esempio reale: report vendite gerarchico verso un export SQL

Supponi di avere dati di vendita grezzi, una riga per riga d’ordine, e di voler produrre due output: un report gerarchico per il CFO (trimestri in orizzontale, regioni e categorie prodotto annidate come righe), e una tabella long piatta per il 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],
})

# Report CFO: regioni e categorie in verticale, trimestri in orizzontale
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, piatto, una riga per regione-categoria-trimestre
warehouse = (
    sales
    .groupby(["region", "category", "quarter"], as_index=False)["amount"]
    .sum()
)
warehouse.to_parquet("sales_long.parquet")

Stessa fonte, due forme, due consumatori, ognuno contento. Il CFO ottiene il layout gerarchico wide che i suoi occhi possono scorrere; il warehouse ottiene il layout long con cui SQL può fare join. Il reshape è il ponte.

Quando usare cosa

Un albero decisionale approssimativo:

  • Si passa da “foglio di calcolo fatto da umani” a “dati che posso analizzare”: melt.
  • Si passa da “dati che ho analizzato” a “tabella per un umano”: pivot_table.
  • Hai già una Serie con MultiIndex da un groupby a più chiavi: unstack.
  • Devi appiattire un MultiIndex in colonne semplici per l’export: reset_index + la list comprehension che appiattisce le colonne.

La maggior parte dei problemi di reshape è uno di questi quattro casi. Quando ti ritrovi a scrivere logica di reshape complicata, fermati e controlla: quasi sempre c’è un one-liner che ti sei perso.

Cosa viene dopo

La lezione 32 è time series: DatetimeIndex, resample, rolling window, e la trappola del fuso orario che azzanna almeno una volta ogni pipeline di produzione. Pandas è nato originariamente per le time series finanziarie, e si vede: l’API time-series è una delle parti più rifinite della libreria.

Letture di approfondimento

Ci vediamo venerdì.

Cerca