Aggiungere colonne — ricavare i numeri che il file non ha
Creare nuove colonne a partire da quelle già presenti: operazioni aritmetiche su intere colonne e perché l'indice decide dove va a finire ciascun valore, assign, np.where e np.select, map, pd.cut, .str e .dt, transform per confrontare una riga con il suo gruppo e apply come ultima risorsa.
Come vuoi imparare?
Il problema che stiamo risolvendo
La titolare del negozio invia un messaggio: "Per ogni ordine, voglio vedere quanto denaro ha portato, se si trattava di un ordine grande, quale responsabile ne risponde e che giorno della settimana era. E per ciascun ordine, che quota rappresenta rispetto agli incassi della sua filiale?"
Apri orders.csv. Contiene quantity e price, branch (quale dei tre negozi) e date. Nessuna delle cose che ha chiesto si trova nel file. Ognuna di esse deve essere creata a partire da colonne già presenti.
Se vieni dal Python di base, le tue mani sanno già cosa digitare — un ciclo sulle righe:
import pandas as pd
orders = pd.read_csv("orders.csv")
revenues = []
for i in range(len(orders)):
revenues.append(orders.loc[i, "quantity"] * orders.loc[i, "price"])
orders["revenue"] = revenues
print(orders[["product", "quantity", "price", "revenue"]])product quantity price revenue
0 pen 12 15.0 180.0
1 notebook 5 60.0 300.0
2 bag 2 850.0 1700.0
3 bottle 7 120.0 840.0
4 eraser 30 8.0 240.0
5 bag 1 850.0 850.0
6 pen 20 15.0 300.0
7 bottle 4 120.0 480.0Funziona. Ma è anche l'abitudine che questo capitolo si propone di sostituire, per tre motivi che emergono solo più tardi.
È lento: Python esegue il corpo del ciclo una volta per riga, quindi un file di un milione di righe significa un milione di passaggi attraverso l'interprete. È lungo: quattro righe per una sola moltiplicazione, e l'elenco richiesto contiene altre cinque colonne. Ed è fragile in un modo difficile da notare. Il ciclo conta le posizioni 0, 1, 2, … e poi le cerca come etichette con .loc. Questo funziona solo finché le due cose per caso coincidono. Filtra prima la tabella — solo gli ordini della filiale north, come nel capitolo cinque — e non coincideranno più:
north = orders[orders["branch"] == "north"]
print(north.index.tolist())
revenues = []
for i in range(len(north)):
revenues.append(north.loc[i, "quantity"] * north.loc[i, "price"])[0, 2, 4, 7]
KeyError: 1Le righe di north mantengono le loro etichette originali 0, 2, 4, 7. Il ciclo richiede l'etichetta 1, che è un ordine di south e non è presente in north, quindi va in crash. Versioni peggiori di questo bug non vanno affatto in crash — leggono silenziosamente la riga sbagliata.
Pandas ha un modo diverso di affrontare la questione: non calcoli una colonna una riga alla volta; la calcoli una volta sola, per l'intera colonna. Questo capitolo è incentrato su quest'unica idea, e una volta compresa, ogni colonna chiesta dalla titolare si risolve in una sola riga.
Alla fine di questo capitolo sarai in grado di
- Creare una nuova colonna con operazioni aritmetiche su intere colonne e spiegare perché non serve alcun ciclo
- Prevedere in che modo pandas allinea i valori in base all'indice quando assegni una
Series, ed evitare iNaNcausati dal disallineamento - Aggiungere colonne senza modificare la tabella originale usando
assign() - Scegliere tra due o più valori per riga con
np.whereenp.select - Cercare valori con
.map(), suddividere numeri in intervalli conpd.cuted estrarre parti da testo e date con.stre.dt - Confrontare ogni riga con il proprio gruppo usando
groupby(...).transform(...) - Riconoscere quando l'uso di
apply(axis=1)è giustificato e perché è l'ultima risorsa - Spiegare cosa comporta il Copy-on-Write in pandas 3 quando si aggiungono colonne a una tabella filtrata
Prerequisiti: Raggruppamento e aggregazione.
Crea prima il file
Ogni esempio in questo capitolo legge orders.csv, lo stesso file usato a partire dal quarto capitolo. Se non lo hai ancora, crealo nella cartella del tuo progetto esattamente con queste righe:
order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0Alcuni esempi utilizzano anche NumPy, installato insieme a pandas — basta scrivere import numpy as np.
Prima di scrivere il codice
Una nuova colonna è un piccolo programma. Cinque minuti di pianificazione ti risparmiano un'ora trascorsa a chiederti da dove sia spuntato un NaN.
1. Esprimi la richiesta in una sola frase, per ciascuna colonna. Ogni colonna richiesta dalla titolare diventa una riga del piano:
revenue— quanto denaro ha portato l'ordine. Ricavato daquantity×price.size—"big"se il ricavo è almeno 500, altrimenti"small". Ricavato darevenue.manager— chi gestisce la filiale da cui proviene l'ordine. Ricavato dabranch, tramite una tabella di corrispondenza.weekday— il giorno della settimana in cui è stato effettuato l'ordine. Ricavato dadate.branch_share— il ricavo di questo ordine come quota rispetto al totale della sua filiale. Ricavato darevenueebranch.
Nota l'unica cosa che hanno tutte in comune: un valore per riga. Una nuova colonna ha sempre esattamente la stessa lunghezza della tabella. È questa la differenza rispetto al capitolo otto, dove groupby(...).sum() restituiva un valore per gruppo. Se ciò che stai calcolando ha meno valori di quante righe ha la tabella, non è ancora una colonna.
2. Osserva i dati in ingresso prima di toccarli. Gli strumenti che puoi usare dipendono dai tipi delle colonne di partenza:
import pandas as pd
orders = pd.read_csv("orders.csv")
print(orders.shape)
print(orders.dtypes)(8, 7)
order_id int64
date str
branch str
product str
category str
quantity int64
price float64
dtype: objectOtto righe; quel numero deve rimanere otto anche alla fine. quantity è int64 e price è float64, quindi moltiplicarli eseguirà un'operazione aritmetica vera e propria. date è str — testo che ha l'aspetto di una data. Il testo non ha giorni della settimana, quindi andrà convertito prima di potervi usare .dt. Metterlo a fuoco adesso è ciò che eviterà un AttributeError più avanti.
3. Fai una bozza dell'output e calcola una riga a mano. Prima di eseguire qualsiasi operazione, decidi cosa dovrebbero diventare due delle righe — una ordinaria e una estrema:
product quantity price revenue size
pen 12 15.0 180.0 small
bag 2 850.0 1700.0 big12 × 15.0 = 180.0, e 180 è inferiore a 500, quindi small. Una riga verificata a mano funge da test: dopo l'esecuzione del codice, se la riga 0 non riporta 180.0 e small, il codice è errato, per quanto possa sembrare ragionevole.
4. Scegli lo strumento adatto alla forma della regola. Quasi ogni nuova colonna rientra in uno di questi casi:
- Operazione aritmetica su colonne o con un numero →
+ - * /su intere colonne. Esempio:quantity * price. - Uno tra due valori, in base a una condizione →
np.where. Esempio:"big"o"small". - Uno tra diversi valori, secondo condizioni verificate in ordine →
np.select. Esempio:"bulk","normal","few". - Un valore ricavato da una tabella fissa →
.map(dict). Esempio: branch → manager. - In quale intervallo rientra un numero →
pd.cut. Esempio: quantity → band. - Una parte di un testo → i metodi
.str. Esempio: le prime lettere della categoria. - Una parte di una data →
pd.to_datetime, poi.dt. Esempio: il giorno della settimana. - Un confronto con il gruppo a cui appartiene la riga →
groupby(...).transform(...). Esempio: quota sul totale della filiale. - Nessuno dei precedenti →
apply(axis=1). Esempio: una regola troppo irregolare per gli altri metodi.
Leggi la lista dall'alto e fermati alla prima riga che si adatta al tuo caso. L'ordine è intenzionale: i primi otto metodi operano su intere colonne tutte in una volta; l'ultimo torna a una chiamata Python per riga.
5. Decidi cosa verificare al termine. Tre controlli, ogni volta: il conteggio delle righe non è cambiato; la nuova colonna ha il tipo previsto; e isna().sum() su di essa è zero, o esattamente il numero che sei in grado di spiegare. Più la riga verificata a mano.
Operazioni aritmetiche su intere colonne
Ecco la colonna dei ricavi nel modo proprio di pandas:
import pandas as pd
orders = pd.read_csv("orders.csv")
orders["revenue"] = orders["quantity"] * orders["price"]
print(orders[["product", "quantity", "price", "revenue"]])product quantity price revenue
0 pen 12 15.0 180.0
1 notebook 5 60.0 300.0
2 bag 2 850.0 1700.0
3 bottle 7 120.0 840.0
4 eraser 30 8.0 240.0
5 bag 1 850.0 850.0
6 pen 20 15.0 300.0
7 bottle 4 120.0 480.0La riga 0 riporta 180.0 — il valore calcolato a mano. Nessun ciclo, nessun calcolo sugli indici.
Perché funziona. orders["quantity"] è una Series di otto numeri; lo stesso vale per orders["price"]. Quando inserisci * tra due Series, pandas accoppia i loro valori per etichetta dell'indice — l'etichetta 0 con l'etichetta 0, l'etichetta 1 con l'etichetta 1 — e moltiplica ciascuna coppia. Il ciclo avviene ancora, ma all'interno del codice compilato di pandas, non in Python. Ecco perché è veloce, ed ecco anche perché non può leggere la riga sbagliata: non c'è alcun contatore che possa sfasarsi.
Successivamente orders["revenue"] = ... compie una tra due azioni, a seconda del nome:
- se
revenuenon esiste, aggiunge una nuova colonna all'estremità destra; - se esiste già, la sostituisce — silenziosamente. Non c'è alcun messaggio di conferma "sei sicuro?".
Il tipo del risultato segue l'operazione aritmetica: int64 × float64 produce float64, motivo per cui revenue mostra 180.0 anziché 180.
Una colonna può anche essere combinata con un singolo numero. Il numero viene usato per ogni riga — pandas chiama questo meccanismo broadcasting:
orders["price_with_vat"] = (orders["price"] * 1.15).round(2)
print(orders[["product", "price", "price_with_vat"]].head(4))product price price_with_vat
0 pen 15.0 17.25
1 notebook 60.0 69.00
2 bag 850.0 977.50
3 bottle 120.0 138.00Le parentesi sono importanti: .round(2) si applica all'intero risultato della moltiplicazione, che è a sua volta una Series. Ogni passaggio qui prende una colonna e restituisce una colonna, così puoi continuare a concatenare operazioni.
L'indice decide dove va a finire ciascun valore
Quell'abbinamento per etichetta non riguarda solo le operazioni aritmetiche. Anche l'assegnazione di una Series a una colonna allinea i valori per etichetta. La maggior parte delle volte non te ne accorgi nemmeno, perché i nuovi valori provengono dalla stessa tabella e le etichette coincidono alla perfezione. Te ne accorgi il giorno in cui non coincidono.
Supponi di volere una colonna che contenga la quantità solo per gli ordini della filiale north:
north = orders[orders["branch"] == "north"]
orders["north_qty"] = north["quantity"]
print(orders[["branch", "quantity", "north_qty"]])branch quantity north_qty
0 north 12 12.0
1 south 5 NaN
2 north 2 2.0
3 east 7 NaN
4 north 30 30.0
5 south 1 NaN
6 east 20 NaN
7 north 4 4.0north possiede le etichette 0, 2, 4, 7. Pandas ha posizionato ciascun valore in corrispondenza della riga con la stessa etichetta, e ogni altra riga ha ricevuto NaN — "nessun valore per questa etichetta". E poiché NaN è un float, la colonna è diventata di tipo float64, anche se le quantità sono numeri interi. Questo potrebbe essere esattamente ciò che volevi, oppure l'inizio di una lunga ricerca di dati mancanti creati involontariamente da te. Il modo per distinguerli è conoscere la regola: l'assegnazione abbina le etichette, non le posizioni.
La stessa regola ti protegge. L'ordinamento (capitolo sette) riorganizza una Series ma mantiene ciascun valore associato alla propria etichetta, quindi riassegnare una Series ordinata ripone ogni valore nella sua riga originale:
by_revenue = orders["revenue"].sort_values()
print(by_revenue.head(3))
orders["revenue_copy"] = by_revenue
print(orders[["product", "revenue", "revenue_copy"]].head(3))
print((orders["revenue_copy"] == orders["revenue"]).all())0 180.0
4 240.0
6 300.0
Name: revenue, dtype: float64
product revenue revenue_copy
0 pen 180.0 180.0
1 notebook 300.0 300.0
2 bag 1700.0 1700.0
TrueLa Series ordinata segue l'ordine delle etichette 0, 4, 6 — il secondo valore, 240.0, appartiene alla riga 4, non alla riga 1. Eppure revenue_copy è identica a revenue riga per riga, cosa confermata dall'ultima riga di codice per tutte le otto righe. Pandas ha usato le etichette e ha ignorato l'ordine. Se avesse proceduto per posizione, la riga 1 (il notebook, 300.0) avrebbe ricevuto 240.0.
Una semplice lista Python non ha etichette, quindi viene usata per posizione — e in tal caso la sua lunghezza deve corrispondere esattamente:
try:
orders["rating"] = [5, 4, 3]
except ValueError as error:
print("ValueError:", error)ValueError: Length of values (3) does not match length of index (8)Tre valori per otto righe vengono rifiutati anziché tirati a indovinare. È il comportamento più sicuro possibile, e vale la pena vederlo una volta affinché il messaggio diventi familiare.
Non abbiamo più bisogno delle colonne di prova. drop(columns=...) restituisce la tabella senza di esse:
orders = orders.drop(columns=["north_qty", "revenue_copy", "price_with_vat"])
print(list(orders.columns))['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']assign(): nuove colonne senza modificare l'originale
orders["revenue"] = ... modifica orders stesso. Spesso è proprio quello che vuoi. A volte invece no — vuoi provare alcune colonne o creare una tabella di riepilogo, mantenendo l'originale esattamente come è stato letto. assign() restituisce un nuovo DataFrame con le colonne aggiuntive e lascia intatto l'originale:
import numpy as np
import pandas as pd
orders = pd.read_csv("orders.csv")
report = orders.assign(
revenue=orders["quantity"] * orders["price"],
vat=lambda d: (d["revenue"] * 0.15).round(2),
)
print(report[["product", "revenue", "vat"]].head(3))
print("revenue" in orders.columns)product revenue vat
0 pen 180.0 27.0
1 notebook 300.0 45.0
2 bag 1700.0 255.0
FalseTre cose da notare. (L'import numpy as np in alto serve per la sezione successiva.)
I nomi degli argomenti diventano i nomi delle colonne — revenue=... crea una colonna chiamata revenue. Ecco perché i nomi delle colonne devono essere nomi Python validi quando usi assign().
vat è scritta sotto forma di lambda. È necessario che sia così: vat ha bisogno di revenue, e revenue non esiste in orders — esiste solo nella nuova tabella in fase di creazione. Una funzione lambda d: ... riceve quella nuova tabella come d, quindi d["revenue"] è già presente. All'interno di un singolo assign(), le colonne vengono aggiunte dall'alto verso il basso, e ciascuna lambda vede quelle definite sopra di essa.
E l'ultima riga stampa False: orders non ha alcuna colonna revenue. Nulla è stato modificato a tua insaputa.
np.where: uno tra due valori
"big se il ricavo è almeno 500, altrimenti small" è un if/else per ogni riga. Scrivere un if significherebbe tornare a un ciclo, perché un if richiede un singolo True o False, mentre il confronto di un'intera colonna ne restituisce otto (è l'errore "truth value of a Series is ambiguous" del capitolo cinque).
La funzione where di NumPy è un if/else che opera su un'intera colonna: dove la condizione è True, prende il primo valore; altrove, il secondo.
report["size"] = np.where(report["revenue"] >= 500, "big", "small")
print(report[["product", "revenue", "size"]])product revenue size
0 pen 180.0 small
1 notebook 300.0 small
2 bag 1700.0 big
3 bottle 840.0 big
4 eraser 240.0 small
5 bag 850.0 big
6 pen 300.0 small
7 bottle 480.0 smallLa riga 0 è small, la riga 2 è big — entrambe corrispondono alla bozza. La condizione è lo stesso tipo di maschera booleana che hai costruito nel capitolo cinque; np.where trasforma semplicemente ogni True/False in un valore. Vale la pena verificare il caso limite: >= 500 significa che un ordine di esattamente 500 è big. Decidilo consapevolmente, non per caso.
Più di due valori: np.select
Tre fasce in base alla quantità — 20 o più è "bulk", 5 o più è "normal", qualsiasi altra cosa è "few". np.select accetta una lista di condizioni e una lista di valori corrispondenti, oltre a un valore default per le righe che non soddisfano alcuna condizione:
conditions = [
report["quantity"] >= 20,
report["quantity"] >= 5,
]
labels = ["bulk", "normal"]
report["tier"] = np.select(conditions, labels, default="few")
print(report[["product", "quantity", "tier"]])product quantity tier
0 pen 12 normal
1 notebook 5 normal
2 bag 2 few
3 bottle 7 normal
4 eraser 30 bulk
5 bag 1 few
6 pen 20 bulk
7 bottle 4 fewVince la prima condizione che risulta True. Ecco perché la condizione più restrittiva va inserita per prima. Inverti l'ordine e osserva cosa succede all'ordine delle 30 gomme per cancellare:
wrong = np.select(
[report["quantity"] >= 5, report["quantity"] >= 20],
["normal", "bulk"],
default="few",
)
print(wrong)['normal' 'normal' 'few' 'normal' 'normal' 'few' 'normal' 'few']Ogni ordine è normal o few; nessun ordine è bulk, perché sia 30 che 20 sono anche >= 5 e quella condizione è stata verificata per prima. Nessun errore, solo una colonna sbagliata — motivo per cui la riga del piano verificata a mano dovrebbe includere un valore estremo.
.map(): cercare valori per corrispondenza
Il responsabile non si calcola a partire da nulla. È un dato di fatto noto: Asha gestisce la filiale north, Omar la south, Lina la east. Quando la regola è "cerca la corrispondenza", scrivi la tabella di corrispondenza come dizionario e passaci la colonna con .map():
manager_of = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
report["manager"] = report["branch"].map(manager_of)
print(report[["branch", "manager"]])
print(report["manager"].isna().sum())branch manager
0 north Asha
1 south Omar
2 north Asha
3 east Lina
4 north Asha
5 south Omar
6 east Lina
7 north Asha
0Ciascuna filiale è stata sostituita dal rispettivo valore nel dizionario — il nome del responsabile. La seconda istruzione di stampa è la verifica prevista dal piano, ed è più importante di quanto sembri, per via di ciò che fa .map() quando non trova una chiave. Immagina che il dizionario sia stato scritto prima dell'apertura della filiale east:
old_managers = {"north": "Asha", "south": "Omar"}
print(report["branch"].map(old_managers).isna().sum())2Nessun errore. I due ordini di east hanno semplicemente ricevuto NaN. Su otto righe te ne accorgeresti; su ottantamila no, e un successivo groupby("manager") escluderebbe silenziosamente quegli ordini (capitolo otto: le chiavi NaN vengono scartate). Ogni .map() deve essere seguito subito da un isna().sum().
pd.cut: in quale intervallo rientra un numero
Il negozio vuole che ogni ordine sia suddiviso in fasce in base alla quantità: fino a 5 articoli è small, da 6 a 15 è medium, più di 15 è large. Potresti scriverlo con np.select, ma determinare "in quale intervallo" ha un proprio strumento dedicato. pd.cut accetta gli estremi degli intervalli e un'etichetta per ciascun intervallo:
report["band"] = pd.cut(
report["quantity"],
bins=[0, 5, 15, np.inf],
labels=["small", "medium", "large"],
)
print(report[["product", "quantity", "band"]])product quantity band
0 pen 12 medium
1 notebook 5 small
2 bag 2 small
3 bottle 7 medium
4 eraser 30 large
5 bag 1 small
6 pen 20 large
7 bottle 4 smallQuattro estremi formano tre intervalli: (0, 5], (5, 15] e (15, ∞]. La parentesi tonda significa "escluso", la parentesi quadra significa "incluso" — quindi ogni intervallo include il proprio estremo destro. Ecco perché l'ordine di quaderni con esattamente 5 unità è small, non medium. np.inf rappresenta l'infinito, un limite superiore che nessuna quantità può superare.
Il risultato ha un tipo che non hai ancora incontrato:
print(report["band"].dtype)
print(report["band"].cat.categories.tolist())category
['small', 'medium', 'large']Una colonna di tipo category ricorda che small < medium < large. Di conseguenza, ordinare per band produce quell'ordine anziché l'ordine alfabetico — che è solitamente ciò che si desidera in un report.
Testo e date: .str e .dt
Parti di testo
Il capitolo cinque ha utilizzato .str.contains per filtrare. Lo stesso accessor .str serve per costruire colonne: ogni metodo per stringhe che conosci da Python, applicato a tutti i valori contemporaneamente. Supponi che ogni ordine necessiti di un codice breve come STA-1001 — le prime tre lettere della categoria, in maiuscolo, un trattino e poi il numero dell'ordine:
report["code"] = (
report["category"].str[:3].str.upper()
+ "-"
+ report["order_id"].astype(str)
)
print(report[["category", "order_id", "code"]].head(3))category order_id code
0 stationery 1001 STA-1001
1 stationery 1002 STA-1002
2 accessories 1003 ACC-1003.str[:3] estrae una porzione di ogni valore, .str.upper() converte ogni valore in maiuscolo e + tra colonne di testo le unisce riga per riga. order_id è un numero, e un numero non può essere sommato al testo, quindi .astype(str) lo trasforma prima in testo.
Parti di una data
Il piano aveva già segnalato che date è di tipo str. Convertila con pd.to_datetime, e l'accessor .dt diventa utilizzabile:
report["date"] = pd.to_datetime(report["date"])
print(report["date"].dtype)
report["weekday"] = report["date"].dt.day_name()
print(report[["date", "weekday"]].head(4))datetime64[us]
date weekday
0 2024-03-01 Friday
1 2024-03-01 Friday
2 2024-03-02 Saturday
3 2024-03-02 SaturdayLa colonna è ora di tipo datetime64 — una data vera e propria, non testo. (La dicitura [us] è la precisione scelta da pandas 3; puoi ignorarla.) Da una data vera e propria, .dt ti offre .dt.day_name(), .dt.month, .dt.year, .dt.day e molti altri. E la conversione viene eseguita una sola volta, riassegnata a date, cosicché ogni passaggio successivo riceva una data e non una stringa.
transform: confrontare una riga con il suo gruppo
L'ultima richiesta — la quota di ciascun ordine rispetto ai ricavi della sua filiale — richiede due cose sulla stessa riga: il ricavo dell'ordine e il totale della sua filiale. Il capitolo otto ci ha fornito i totali:
print(report.groupby("branch")["revenue"].sum())branch
east 1140.0
north 2600.0
south 1150.0
Name: revenue, dtype: float64Ma questi sono tre valori, uno per filiale, e la tabella ha otto righe. Non puoi dividere otto numeri per tre. Ciò di cui hai bisogno è il totale della filiale ripetuto su ogni riga di quella filiale. Ed è esattamente ciò che restituisce transform:
branch_total = report.groupby("branch")["revenue"].transform("sum")
print(branch_total)0 2600.0
1 1150.0
2 2600.0
3 1140.0
4 2600.0
5 1150.0
6 1140.0
7 2600.0
Name: revenue, dtype: float64Stesso raggruppamento, stesso "sum", ma il risultato ha otto valori con le etichette proprie della tabella — ogni riga di north riporta 2600, ogni riga di south 1150. Ora si tratta di una normale operazione aritmetica tra colonne:
report["branch_share"] = (report["revenue"] / branch_total).round(3)
print(report[["branch", "product", "revenue", "branch_share"]])branch product revenue branch_share
0 north pen 180.0 0.069
1 south notebook 300.0 0.261
2 north bag 1700.0 0.654
3 east bottle 840.0 0.737
4 north eraser 240.0 0.092
5 south bag 850.0 0.739
6 east pen 300.0 0.263
7 north bottle 480.0 0.185Verifichiamo: l'ordine della borsa nella filiale north è 1700 / 2600 = 0.654. E le quote all'interno di ciascuna filiale devono sommare a 1:
print(report.groupby("branch")["branch_share"].sum())branch
east 1.0
north 1.0
south 1.0
Name: branch_share, dtype: float64La regola da ricordare: agg/sum riducono un gruppo a una sola riga; transform restituisce un valore per ogni riga. Usa il primo per una tabella di riepilogo, il secondo per una nuova colonna.
apply(axis=1): l'ultima risorsa
A volte la regola è davvero irregolare. Le spese di consegna sono: per la filiale north — quella principale, accanto al magazzino — gratuite quando il ricavo è di almeno 500, altrimenti 60; nelle altre filiali, 120, ma gratuite sopra 1000. Puoi scriverlo come una normale funzione Python e applicarla con apply a ogni riga:
def delivery_fee(row):
if row["branch"] == "north":
return 0 if row["revenue"] >= 500 else 60
return 0 if row["revenue"] > 1000 else 120
report["fee"] = report.apply(delivery_fee, axis=1)
print(report[["branch", "revenue", "fee"]])branch revenue fee
0 north 180.0 60
1 south 300.0 120
2 north 1700.0 0
3 east 840.0 120
4 north 240.0 60
5 south 850.0 120
6 east 300.0 120
7 north 480.0 60axis=1 significa "passa alla funzione una riga alla volta". All'interno, row è una Series le cui etichette sono i nomi delle colonne, quindi row["branch"] funziona.
È facile da leggere, e questo è il suo principale pregio. Il prezzo da pagare è che si tratta di nuovo del ciclo dell'inizio del capitolo: pandas costruisce una Series per ogni riga e chiama la tua funzione Python una volta per riga. Su otto righe non è nulla; su un milione di righe è la differenza tra un battito di ciglia e una pausa caffè.
Quindi, prima di ricorrere ad apply, prova a esprimere la regola con strumenti che operano su intere colonne. Questa regola è in realtà "tre casi, verificati in ordine" — np.select:
in_north = report["branch"] == "north"
fee_fast = np.select(
[in_north & (report["revenue"] >= 500), in_north, report["revenue"] > 1000],
[0, 60, 0],
default=120,
)
print((fee_fast == report["fee"]).all())TrueRisultato identico, nessuna chiamata Python per riga. Riserva apply(axis=1) per le regole che davvero non possono essere scritte in questo modo — chiamare una funzione esterna, analizzare una stringa complessa con diversi passaggi — e considera la tentazione di usarlo come un segnale per riflettere un'altra volta.
Aggiungere una colonna a una tabella filtrata: Copy-on-Write
Spesso prima filtrerai i dati e poi aggiungerai una colonna al risultato. In pandas 3 questo è semplice, e vale la pena capire perché lo sia, dato che i tutorial più datati affermano il contrario:
import pandas as pd
orders = pd.read_csv("orders.csv")
orders["revenue"] = orders["quantity"] * orders["price"]
north = orders[orders["branch"] == "north"]
north["revenue_k"] = north["revenue"] / 1000
print(north[["product", "revenue", "revenue_k"]])
print(list(orders.columns))product revenue revenue_k
0 pen 180.0 0.18
2 bag 1700.0 1.70
4 eraser 240.0 0.24
7 bottle 480.0 0.48
['order_id', 'date', 'branch', 'product', 'category', 'quantity', 'price', 'revenue']Nessun avviso, e orders non ha ricevuto la colonna revenue_k. Pandas 3 utilizza il Copy-on-Write: ogni risultato di un filtraggio o di una selezione si comporta come una propria tabella indipendente. Modificare north non potrà mai modificare orders.
Se leggi codice più vecchio o risposte online, vedrai questo identico pattern seguito da un SettingWithCopyWarning ("A value is trying to be set on a copy of a slice from a DataFrame"), con il consiglio di scrivere orders[...].copy(). Quell'avviso esisteva perché le versioni precedenti di pandas a volte condividevano effettivamente i dati tra le due tabelle, e nessuno poteva prevedere facilmente quando ciò accadesse. Pandas 3 ha rimosso l'incertezza, e con essa l'avviso. Scrivere .copy() è ormai superfluo, sebbene innocuo.
Ciò che il Copy-on-Write non consente è modificare orders attraverso una catena di due passaggi. Se vuoi modificare i valori nella tabella originale per alcune righe, questo codice non fa nulla:
orders[orders["branch"] == "north"]["revenue"] = 0
print(orders["revenue"].tolist())[180.0, 300.0, 1700.0, 840.0, 240.0, 850.0, 300.0, 480.0]
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment.
Such chained assignment never works to update the original DataFrame or Series, because the intermediate object on which we are setting values always behaves as a copy (due to Copy-on-Write).
Try using '.loc[row_indexer, col_indexer] = value' instead, to perform the assignment in a single step.
See the documentation for a more detailed explanation: https://pandas.pydata.org/pandas-docs/stable/user_guide/copy_on_write.html#chained-assignmentLa prima parentesi crea una nuova tabella, la seconda imposta valori in quella nuova tabella, e la nuova tabella viene poi scartata. Pandas ti avvisa con un ChainedAssignmentError, e la lista mostra revenue invariato — gli ordini di north riportano ancora 180.0, 1700.0, 240.0 e 480.0. (Gli avvisi vanno sul flusso degli errori, quindi nel tuo terminale l'avviso potrebbe comparire sopra la lista anziché sotto.) Per modificare l'originale, fallo in un solo passaggio con .loc[rows, column]:
orders.loc[orders["branch"] == "north", "note"] = "main branch"
print(orders[["branch", "note"]])branch note
0 north main branch
1 south NaN
2 north main branch
3 east NaN
4 north main branch
5 south NaN
6 east NaN
7 north main branchLe righe corrispondenti ricevono il valore; la colonna viene creata se necessario; le righe che non corrispondono ricevono NaN. Un solo passaggio, la tabella originale, nessun avviso.
Un esempio completo
enrich.py risponde all'intero messaggio della titolare del negozio, seguendo il piano: leggere, verificare, aggiungere le colonne, verificare di nuovo.
import numpy as np
import pandas as pd
MANAGER_OF = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
orders = pd.read_csv("orders.csv")
rows_before = len(orders)
# 1. Conversions first, so later steps get the right types.
orders["date"] = pd.to_datetime(orders["date"])
# 2. New columns, one rule each.
orders["revenue"] = orders["quantity"] * orders["price"]
orders["size"] = np.where(orders["revenue"] >= 500, "big", "small")
orders["manager"] = orders["branch"].map(MANAGER_OF)
orders["weekday"] = orders["date"].dt.day_name()
branch_total = orders.groupby("branch")["revenue"].transform("sum")
orders["branch_share"] = (orders["revenue"] / branch_total).round(3)
# 3. Checks: same rows, nothing missing, the hand-worked row is right.
new_columns = ["revenue", "size", "manager", "weekday", "branch_share"]
assert len(orders) == rows_before
assert orders[new_columns].isna().sum().sum() == 0
assert orders.loc[0, "revenue"] == 180.0
print(orders[["order_id", "branch", "manager", "weekday", "revenue", "size", "branch_share"]])
print()
print(orders[new_columns].dtypes)order_id branch manager weekday revenue size branch_share
0 1001 north Asha Friday 180.0 small 0.069
1 1002 south Omar Friday 300.0 small 0.261
2 1003 north Asha Saturday 1700.0 big 0.654
3 1004 east Lina Saturday 840.0 big 0.737
4 1005 north Asha Sunday 240.0 small 0.092
5 1006 south Omar Sunday 850.0 big 0.739
6 1007 east Lina Monday 300.0 small 0.263
7 1008 north Asha Monday 480.0 small 0.185
revenue float64
size str
manager str
weekday str
branch_share float64
dtype: objectPerché è scritto in questo modo:
- La conversione viene per prima.
dateviene trasformata in una data vera e propria prima che qualsiasi operazione la utilizzi, così.dtnon potrà fallire successivamente e nessuno a valle dovrà ricordarsi di convertirla. - Una regola per riga. Ogni riga risponde a una riga del piano, così quando un numero sembra errato sai esattamente quale singola riga lo ha generato.
- La tabella di corrispondenza è una costante con nome in cima al file. Quando il negozio aprirà una filiale west, la modifica consisterà in una sola riga in una posizione evidente, e il controllo con
isnafallirà rumorosamente se qualcuno se ne dimentica. - I controlli sono asserzioni (
assert), non semplici istruzioni di stampa. Se il numero di righe cambia, se un.map()manca una filiale, o se la riga 0 smette di valere 180, il programma si interrompe anziché stampare un report errato dall'aspetto apparentemente corretto. dtypesviene stampato alla fine perché il tipo è parte della risposta:revenuedeve essere un float,branch_shareun float, le colonne di testostr.
Quando qualcosa va storto
KeyError: 'Price' Il nome di una colonna a destra dell'uguale è scritto male o presenta maiuscole/minuscole errate. Stampa list(orders.columns) e copia il nome da lì. Fai attenzione anche al lato sinistro: orders["revenu"] = ... non solleva alcuna eccezione — crea una nuova colonna con un refuso accanto a quella desiderata. Se una colonna che credevi di aver "aggiornato" non è cambiata, cerca una colonna gemella creata per sbaglio con un refuso.
ValueError: Length of values (3) does not match length of index (8) Hai assegnato una lista (o un array) la cui lunghezza non corrisponde al numero di righe. Una lista non ha etichette, quindi pandas non può allinearla e rifiuta l'operazione. Costruisci i valori a partire dalle colonne della tabella stessa, oppure verifica prima con len(values) == len(df).
La nuova colonna è piena di NaN in alcune righe e il tipo è diventato float64 Hai assegnato una Series le cui etichette corrispondono solo in parte a quelle della tabella — di solito una Series costruita a partire da una tabella filtrata. L'assegnazione abbina le etichette, non le posizioni. Costruisci la nuova colonna a partire dalla tabella completa (con np.where o .loc[mask, "col"] = ...) anziché da un sottoinsieme.
revenue contiene valori come 15.015.015.0…, oppure TypeError: can't multiply sequence by non-int of type 'float' Una delle colonne è testo, non un numero. Moltiplicare un int per una stringa ripete la stringa — la regola di Python 3 * "ab" == "ababab" — e pandas la applica riga per riga senza segnalare errori:
import pandas as pd
from io import StringIO
RAW = """product,quantity,price
pen,12,15.0
bag,2,"1,200"
"""
bad = pd.read_csv(StringIO(RAW))
print(bad.dtypes)
print(bad["quantity"] * bad["price"])product str
quantity int64
price str
dtype: object
0 15.015.015.015.015.015.015.015.015.015.015.015.0
1 1,2001,200
dtype: strIl separatore delle migliaia in "1,200" ha reso l'intera colonna price di tipo str. Ecco perché il piano suggerisce di verificare dtypes prima dell'operazione aritmetica. Risolvi il problema al momento della lettura con thousands=",", oppure converti i dati con pd.to_numeric:
good = pd.read_csv(StringIO(RAW), thousands=",")
print(good.dtypes["price"])
print(good["quantity"] * good["price"])float64
0 180.0
1 2400.0
dtype: float64AttributeError: Can only use .dt accessor with datetimelike values La colonna è ancora testo. Convertila prima con df["date"] = pd.to_datetime(df["date"]) e usa .dt successivamente.
KeyError: 'quantity' generato da apply Hai dimenticato axis=1. Senza di esso, apply passa intere colonne alla tua funzione, quindi row["quantity"] cerca una riga etichettata quantity all'interno di una colonna e fallisce. Aggiungi axis=1 — e poi chiediti se hai davvero bisogno di apply.
IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer Hai chiamato .astype(int) su una colonna contenente NaN. Un intero NumPy non ha modo di rappresentare un valore "mancante". Riempi prima i vuoti (capitolo sei), oppure usa il tipo intero nullable di pandas, .astype("Int64") con la I maiuscola, che può contenere valori mancanti.
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. Hai scritto df[mask]["col"] = value. Con il Copy-on-Write il primo passaggio crea una tabella separata, quindi l'originale non viene mai modificato. Scrivilo in un solo passaggio: df.loc[mask, "col"] = value.
Una colonna creata con .map() presenta NaN dove ti aspettavi un valore Una chiave nella colonna non è presente nel dizionario — una nuova filiale, una grafia diversa, uno spazio finale. Individuali con df.loc[df["new"].isna(), "key_column"].unique(), poi correggi il dizionario o i dati.
Passo 4 di 6 — Prevedi
Verifica cosa hai capito
Una colonna creata a partire dagli ordini della filiale north viene assegnata all'intera tabella. Cosa viene stampato?
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
orders = pd.read_csv(StringIO(RAW))
north = orders[orders["branch"] == "north"]
orders["north_qty"] = north["quantity"]
print(orders["north_qty"].isna().sum(), orders["north_qty"].dtype)- A4 int64
- B0 int64
- C0 float64
- D4 float64
L'obiettivo è aggiungere una colonna revenue a orders. Cosa viene stampato?
from io import StringIO
import pandas as pd
# Stands in for orders.csv, so this snippet runs on its own.
RAW = """order_id,date,branch,product,category,quantity,price
1001,2024-03-01,north,pen,stationery,12,15.0
1002,2024-03-01,south,notebook,stationery,5,60.0
1003,2024-03-02,north,bag,accessories,2,850.0
1004,2024-03-02,east,bottle,accessories,7,120.0
1005,2024-03-03,north,eraser,stationery,30,8.0
1006,2024-03-03,south,bag,accessories,1,850.0
1007,2024-03-04,east,pen,stationery,20,15.0
1008,2024-03-04,north,bottle,accessories,4,120.0
"""
orders = pd.read_csv(StringIO(RAW))
orders.assign(revenue=orders["quantity"] * orders["price"])
print("revenue" in orders.columns)- A`False` — `assign` restituisce una nuova tabella e lascia `orders` invariato, e il risultato non è stato conservato
- BUn `KeyError: 'revenue'`
- CUn `ChainedAssignmentError`
- D`True`
Ti serve una colonna che, su ogni riga, contenga la quota di quell'ordine rispetto alla quantità totale della sua filiale. Quale riga restituisce un valore per riga?
- Aorders["quantity"] / orders.groupby("branch")["quantity"].sum()
- Borders["quantity"] / orders.groupby("branch")["quantity"].transform("sum")
- Corders.groupby("branch")["quantity"].sum() / orders["quantity"]
- Dorders["quantity"] / orders["quantity"].sum()
Per rispondere serve un account
Accedi per verificare le tue risposte
Le domande sono qui sopra, e ragionarci a mente è la parte che conta. Accedi per vedere le risposte, le spiegazioni e i suggerimenti a tre livelli.
Tocca a te
Utilizzando lo stesso orders.csv, scrivi enrich_task.py per fornire alla titolare del negozio un report sugli sconti. Pianifica prima — scrivi l'elenco delle nuove colonne e calcola a mano la riga 0 — poi aggiungi queste colonne, in questo ordine:
revenue=quantity×pricediscount= 10% direvenuequandoquantityè pari o superiore a 10, altrimenti 0net=revenue−discountmanager— dalla filiale, usando il dizionario di questo capitoloband— danet: fino a 300 è"low", sopra 300 fino a 800 è"mid", sopra 800 è"high"weekday— dadatecategory_share— ilnetdi ciascun ordine come quota rispetto alnettotale della sua categoria, arrotondato a 2 cifre decimali
Stampa la shape prima e dopo, poi order_id, product, quantity, net, band, weekday e category_share, ordinati per net dal più alto al più basso.
Il tuo programma è corretto quando:
- Stampa esattamente l'output riportato sotto.
- Non c'è alcun ciclo
forné alcunapplyda nessuna parte — ogni colonna è creata a partire da intere colonne tutte in una volta. - Se una filiale manca dal dizionario, il programma si interrompe con un
AssertionErrorinvece di proseguire conNaN. - Il conteggio delle righe è pari a 8 sia prima che dopo.
Output previsto:
before: (8, 7)
after: (8, 14)
order_id product quantity net band weekday category_share
2 1003 bag 2 1700.0 high Saturday 0.44
5 1006 bag 1 850.0 high Sunday 0.22
3 1004 bottle 7 840.0 high Saturday 0.22
7 1008 bottle 4 480.0 mid Monday 0.12
1 1002 notebook 5 300.0 low Friday 0.32
6 1007 pen 20 270.0 low Monday 0.28
4 1005 eraser 30 216.0 low Sunday 0.23
0 1001 pen 12 162.0 low Friday 0.17Prima di eseguirlo, fai una previsione: quale ordine ha il net più alto, e in quale fascia rientra l'ordine delle 30 gomme per cancellare? Poi confronta la tua previsione con l'output.
Soluzione
Il piano. Riga 0 a mano: l'ordine delle penne, 12 × 15.0 = 180.0; la quantità è 12, quindi lo sconto è 18.0 e net è 162.0; 162 è al massimo 300, quindi low. L'ordine delle gomme per cancellare: 30 × 8.0 = 240.0, sconto 24.0, net 216.0 — anch'esso low, sebbene sia l'ordine più grande per quantità. Il net più alto dovrebbe essere quello dell'ordine di 2 borse: 1700.0, nessuno sconto.
revenueenet— operazioni aritmetiche tra colonne: semplice moltiplicazione e sottrazione.discount—np.where: uno tra due valori per riga.manager—.map(dict), seguito daisna().sum(): una ricerca per corrispondenza, verificata.band—pd.cutcon estremi[0, 300, 800, inf]: intervalli. Ciascun intervallo include il proprio estremo destro, quindi esattamente 300 èlowed esattamente 800 èmid.weekday—pd.to_datetime, poi.dt.day_name(): le parti di una data richiedono una data reale.category_share—groupby("category")["net"].transform("sum"): un valore per riga, confrontato con il proprio gruppo.
enrich_task.py:
import numpy as np
import pandas as pd
MANAGER_OF = {
"north": "Asha",
"south": "Omar",
"east": "Lina",
}
orders = pd.read_csv("orders.csv")
print("before:", orders.shape)
orders["revenue"] = orders["quantity"] * orders["price"]
orders["discount"] = np.where(orders["quantity"] >= 10, orders["revenue"] * 0.10, 0.0)
orders["net"] = orders["revenue"] - orders["discount"]
orders["manager"] = orders["branch"].map(MANAGER_OF)
assert orders["manager"].isna().sum() == 0
orders["band"] = pd.cut(
orders["net"],
bins=[0, 300, 800, np.inf],
labels=["low", "mid", "high"],
)
orders["date"] = pd.to_datetime(orders["date"])
orders["weekday"] = orders["date"].dt.day_name()
category_total = orders.groupby("category")["net"].transform("sum")
orders["category_share"] = (orders["net"] / category_total).round(2)
print("after: ", orders.shape)
print()
columns = ["order_id", "product", "quantity", "net", "band", "weekday", "category_share"]
print(orders[columns].sort_values("net", ascending=False))before: (8, 7)
after: (8, 14)
order_id product quantity net band weekday category_share
2 1003 bag 2 1700.0 high Saturday 0.44
5 1006 bag 1 850.0 high Sunday 0.22
3 1004 bottle 7 840.0 high Saturday 0.22
7 1008 bottle 4 480.0 mid Monday 0.12
1 1002 notebook 5 300.0 low Friday 0.32
6 1007 pen 20 270.0 low Monday 0.28
4 1005 eraser 30 216.0 low Sunday 0.23
0 1001 pen 12 162.0 low Friday 0.17Rileggendo il codice rispetto al piano:
- La shape è passata da 7 colonne a 14 ed è rimasta a 8 righe: sette nuove colonne, nessuna riga aggiunta o persa.
- La riga 0 (ordine 1001) ha
netpari a162.0e fascialow— i valori calcolati a mano. - Il
netpiù alto appartiene all'ordine 1003, la borsa, con1700.0, come previsto. L'ordine delle gomme 1005 èlowcon216.0: il maggior numero di articoli, ma economici. np.whereha preso una colonna come valore "vero" —orders["revenue"] * 0.10— così ogni riga ha ricevuto il proprio 10%, non un singolo numero. Entrambi i rami dinp.wherepossono essere colonne o valori semplici.- L'estremo di una fascia è ben visibile. L'ordine dei quaderni ha un
netdi esattamente300.0ed èlow, perché(0, 300]include 300. Se la titolare intendeva "da 300 in su è mid", gli estremi devono essere modificati — una decisione, non un dettaglio. - Le quote sommano a 1 all'interno di ciascuna categoria. Accessories:
0.44 + 0.22 + 0.22 + 0.12 = 1.00. Stationery:0.32 + 0.28 + 0.23 + 0.17 = 1.00. Se le quote di una categoria non avessero sommato a 1, iltransformsarebbe stato raggruppato per la colonna sbagliata.
Poi prova a mandarlo in errore intenzionalmente, una volta per ciascun caso: rimuovi "east" dal dizionario e osserva l'assert che blocca il programma; modifica bins per iniziare da 200 anziché da 0 e osserva quali righe ricevono NaN in band, e perché.
Passo 6 di 6
Sfida — il quiz del capitolo
Dieci domande dalla più facile alla più difficile. Le ultime sono difficili di proposito.
Accedi per fare il quiz