Kolommen toevoegen — getallen maken die het bestand niet heeft
Nieuwe kolommen maken op basis van de kolommen die er al zijn: rekenen met hele kolommen en waarom de index bepaalt waar elke waarde terechtkomt, assign, np.where en np.select, map, pd.cut, .str en .dt, transform om een rij te vergelijken met zijn groep, en apply als laatste redmiddel.
Hoe wil je leren?
Het probleem dat we oplossen
De eigenaar van de winkel stuurt een berichtje: "Voor elke bestelling wil ik zien hoeveel geld het heeft opgeleverd, of het een grote bestelling was, welke manager ervoor verantwoordelijk is en welke dag van de week het was. En voor elke bestelling: welk aandeel van de omzet van die vestiging is dat?"
Je opent orders.csv. Het bevat een quantity en een price, een branch (welke van de drie winkels) en een date. Geen van de dingen waar ze om vroeg staat in het bestand. Elk ervan moet worden gemaakt op basis van kolommen die er al zijn.
Als je achtergrond in standaard Python ligt, weten je vingers al wat ze moeten typen — een lus over de rijen:
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.0Het werkt. Het is ook de gewoonte die dit hoofdstuk wil afleren, om drie redenen die pas later aan het licht komen.
Het is traag: Python voert de inhoud van de lus één keer per rij uit, dus een bestand van een miljoen rijen betekent een miljoen tochtjes door de interpreter. Het is lang: vier regels voor één vermenigvuldiging, en de gevraagde lijst heeft nog vijf kolommen meer. En het is kwetsbaar op een manier die moeilijk te zien is. De lus telt posities 0, 1, 2, … en zoekt die vervolgens op als labels met .loc. Dat werkt alleen zolang die twee toevallig hetzelfde zijn. Filter de tabel eerst — alleen de bestellingen van de vestiging north, zoals in hoofdstuk vijf — en dat is niet langer het geval:
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: 1De rijen van north behouden hun oorspronkelijke labels 0, 2, 4, 7. De lus vraagt om label 1, wat een bestelling uit south is en niet in north voorkomt, waardoor het programma crasht. Er zijn ergere varianten van deze bug die helemaal niet crashen — die lezen geruisloos de verkeerde rij uit.
Pandas heeft een heel andere manier om hierover na te denken: je berekent een kolom niet rij voor rij; je berekent hem in één keer, voor de hele kolom. Dat ene idee is waar dit hoofdstuk om draait, en zodra het kwartje valt, is elke kolom waar de winkeleigenaar om vroeg één regel code.
Aan het einde van dit hoofdstuk kun je
- Een nieuwe kolom maken door te rekenen met hele kolommen, en uitleggen waarom er geen lus nodig is
- Voorspellen hoe pandas waarden uitlijnt op index wanneer je een
Seriestoewijst, en deNaNvoorkomen die door verkeerde uitlijning ontstaat - Kolommen toevoegen zonder de oorspronkelijke tabel aan te passen met
assign() - Kiezen tussen twee of meer waarden per rij met
np.whereennp.select - Waarden opzoeken met
.map(), getallen indelen in bereiken metpd.cut, en stukjes halen uit tekst en datums met.stren.dt - Elke rij vergelijken met zijn groep met
groupby(...).transform(...) - Herkennen wanneer
apply(axis=1)gerechtvaardigd is, en waarom het het laatste redmiddel is - Uitleggen wat Copy-on-Write in pandas 3 betekent voor het toevoegen van kolommen aan een gefilterde tabel
Voorkennis: Groeperen en aggregeren.
Maak het bestand eerst aan
Elk voorbeeld in dit hoofdstuk leest orders.csv in, hetzelfde bestand dat sinds hoofdstuk vier wordt gebruikt. Als je het nog niet hebt, maak het dan aan in je projectmap met precies deze regels:
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.0Sommige voorbeelden gebruiken ook NumPy, dat samen met pandas is geïnstalleerd — import numpy as np is alles wat nodig is.
Voordat je de code schrijft
Een nieuwe kolom is een klein programmaatje. Vijf minuten plannen bespaart je een uur piekeren over waar een NaN vandaan kwam.
1. Formuleer de vraag in één zin, per kolom. Elke kolom waar de winkeleigenaar om vroeg wordt één regel in het plan:
revenue— hoeveel geld de bestelling heeft opgeleverd. Gemaakt vanquantity×price.size—"big"als de omzet minstens 500 is, anders"small". Gemaakt vanrevenue.manager— wie de leiding heeft over de vestiging waar de bestelling vandaan kwam. Gemaakt vanbranch, via een opzoektabel.weekday— de dag van de week waarop de bestelling is geplaatst. Gemaakt vandate.branch_share— de omzet van deze bestelling als aandeel in het totaal van de vestiging. Gemaakt vanrevenueenbranch.
Let op het ene dat ze allemaal gemeen hebben: één waarde per rij. Een nieuwe kolom is altijd precies even lang als de tabel. Dat is het verschil met hoofdstuk acht, waar groupby(...).sum() één waarde per groep opleverde. Als hetgeen je berekent minder waarden heeft dan de tabel rijen heeft, is het nog geen kolom.
2. Bekijk de invoer voordat je eraan begint. Welke hulpmiddelen je kunt gebruiken, hangt af van de types van de kolommen waarmee je begint:
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: objectAcht rijen; dat aantal moet aan het einde nog steeds acht zijn. quantity is int64 en price is float64, dus die vermenigvuldigen levert echte berekeningen op. date is str — tekst die lijkt op een datum. Tekst heeft geen weekdag, dus die moet worden geconverteerd voordat je .dt erop kunt gebruiken. Dit nu opschrijven voorkomt straks de AttributeError.
3. Maak een schets van de uitvoer, en reken één rij met de hand uit. Bepaal voordat je iets uitvoert wat twee van de rijen moeten worden — een gewone rij en een uiterste:
product quantity price revenue size
pen 12 15.0 180.0 small
bag 2 850.0 1700.0 big12 × 15.0 = 180.0, en 180 is onder de 500, dus small. Een met de hand gecontroleerde rij is een test: als rij 0 na het uitvoeren van de code niet 180.0 en small aangeeft, is de code fout, hoe aannemelijk hij er ook uitziet.
4. Kies het gereedschap dat past bij de vorm van de regel. Vrijwel elke nieuwe kolom valt onder een van deze situaties:
- Rekenen met kolommen of een getal →
+ - * /op hele kolommen. Voorbeeld:quantity * price. - Eén van twee waarden, op basis van een voorwaarde →
np.where. Voorbeeld:"big"of"small". - Eén van meerdere waarden, op basis van voorwaarden op volgorde gecontroleerd →
np.select. Voorbeeld:"bulk","normal","few". - Een waarde opgezocht in een vaste tabel →
.map(dict). Voorbeeld: branch → manager. - Binnen welk bereik een getal valt →
pd.cut. Voorbeeld: quantity → band. - Een stukje van een tekst → de
.str-methoden. Voorbeeld: de eerste letters van de categorie. - Een stukje van een datum →
pd.to_datetime, en dan.dt. Voorbeeld: de weekdag. - Een vergelijking met de eigen groep van de rij →
groupby(...).transform(...). Voorbeeld: aandeel in het totaal van de vestiging. - Geen van bovenstaande →
apply(axis=1). Voorbeeld: een regel die te onregelmatig is voor de rest.
Lees de lijst van boven naar beneden en stop bij de eerste regel die past. De volgorde is bewust gekozen: de eerste acht werken op hele kolommen tegelijk; de laatste valt terug op één Python-aanroep per rij.
5. Bepaal wat je achteraf controleert. Drie controles, elke keer weer: het aantal rijen is niet veranderd; de nieuwe kolom heeft het type dat je verwachtte; en isna().sum() erop is nul, of precies het aantal dat je kunt verklaren. Plus de handmatig nagerekende rij.
Rekenen met hele kolommen
Hier is de omzetkolom op de pandas-manier:
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.0Rij 0 geeft 180.0 aan — de met de hand nagerekende waarde. Geen lus, geen indexberekeningen.
Waarom dit werkt. orders["quantity"] is een Series van acht getallen; orders["price"] is dat ook. Wanneer je een * tussen twee Series zet, koppelt pandas hun waarden aan elkaar op indexlabel — label 0 met label 0, label 1 met label 1 — en vermenigvuldigt elk paar. De lus gebeurt nog steeds, maar binnen de gecompileerde code van pandas, niet in Python. Daarom is het snel, en daarom kan het ook nooit de verkeerde rij lezen: er is geen teller die uit de pas kan lopen.
Vervolgens doet orders["revenue"] = ... een van twee dingen, afhankelijk van de naam:
- als
revenuenog niet bestaat, voegt het helemaal rechts een nieuwe kolom toe; - als de kolom al bestaat, vervangt het deze — geruisloos. Er is geen "weet je het zeker?".
Het type van het resultaat volgt de berekening: int64 × float64 levert float64 op, en daarom toont revenue 180.0 in plaats van 180.
Een kolom kan ook worden gecombineerd met een enkel getal. Dat getal wordt dan voor elke rij gebruikt — pandas noemt dit 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.00De haakjes doen ertoe: .round(2) is van toepassing op het hele resultaat van de vermenigvuldiging, wat weer een Series is. Elke stap hier neemt een kolom en geeft een kolom terug, zodat je bewerkingen aan elkaar kunt blijven koppelen.
De index bepaalt welke waarde waar terechtkomt
Dat koppelen op basis van labels geldt niet alleen voor berekeningen. Het toewijzen van een Series aan een kolom lijnt ook uit op label. Meestal merk je daar niets van, omdat de nieuwe waarden uit dezelfde tabel komen en de labels exact overeenkomen. Je merkt het op de dag dat ze dat niet doen.
Stel dat je een kolom wilt met het aantal voor alleen de bestellingen van de vestiging 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 heeft de labels 0, 2, 4, 7. Pandas plaatste elke waarde bij de rij met hetzelfde label, en elke andere rij kreeg NaN — "geen waarde voor dit label". En omdat NaN een float is, werd de kolom float64, ook al zijn aantallen gehele getallen. Dat is misschien precies wat je wilde, of het begin van een lange zoektocht naar ontbrekende data die je zelf hebt gecreëerd. De manier om het verschil te weten is de regel kennen: toewijzing koppelt op labels, niet op posities.
Dezelfde regel beschermt je ook. Sorteren (hoofdstuk zeven) verandert de volgorde van een Series, maar houdt elke waarde bij zijn label, dus een gesorteerde Series weer terug toewijzen plaatst elke waarde weer netjes in zijn oorspronkelijke rij:
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
TrueDe gesorteerde Series loopt in de volgorde van labels 0, 4, 6 — de tweede waarde, 240.0, hoort bij rij 4, niet bij rij 1. En toch is revenue_copy rij voor rij identiek aan revenue, wat de laatste regel voor alle acht rijen bevestigt. Pandas gebruikte de labels en negeerde de volgorde. Was het op positie afgegaan, dan had rij 1 (het notitieblok, 300.0) 240.0 gekregen.
Een gewone Python-lijst heeft geen labels, dus die wordt op positie gebruikt — en dan moet de lengte exact overeenkomen:
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)Drie waarden voor acht rijen worden geweigerd in plaats van geraden. Dat is het veiligst mogelijke gedrag, en het is goed om dat een keer gezien te hebben zodat de foutmelding bekend voorkomt.
We hebben de experimentele kolommen niet meer nodig. drop(columns=...) geeft de tabel terug zonder die kolommen:
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(): nieuwe kolommen zonder het origineel te wijzigen
orders["revenue"] = ... wijzigt orders zelf. Vaak is dat wat je wilt. Soms niet — je wilt een paar kolommen uitproberen, of een rapportagetabel opbouwen, en het origineel precies behouden zoals het is ingelezen. assign() geeft een nieuwe DataFrame terug met de extra kolommen en laat het origineel met rust:
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
FalseDrie dingen om op te letten. (De import numpy as np bovenaan is voor de volgende sectie.)
De argumentnamen worden de kolomnamen — revenue=... maakt een kolom genaamd revenue. Daarom moeten de kolomnamen geldige Python-namen zijn wanneer je assign() gebruikt.
vat is geschreven als een lambda. Dat moet ook wel: vat heeft revenue nodig, en revenue bestaat niet op orders — het bestaat alleen op de nieuwe tabel die op dat moment wordt opgebouwd. Een lambda d: ... ontvangt die nieuwe tabel als d, zodat d["revenue"] er al is. Binnen één assign() worden kolommen van boven naar beneden toegevoegd, en elke lambda ziet de kolommen erboven.
En de laatste regel print False: orders heeft geen revenue-kolom. Er is niets achter je rug om gewijzigd.
np.where: één van twee waarden
"big als de omzet minstens 500 is, anders small" is een if/else voor elke rij. Een if schrijven zou betekenen dat we terug moeten naar een lus, want een if heeft één enkele True of False nodig, terwijl een vergelijking van een hele kolom er acht oplevert (dat is de fout "truth value of a Series is ambiguous" uit hoofdstuk vijf).
NumPy's where is een if/else die op een hele kolom werkt: waar de voorwaarde True is, neem de eerste waarde; elders de tweede.
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 smallRij 0 is small, rij 2 is big — beide komen overeen met de schets. De voorwaarde is hetzelfde soort boolean masker dat je in hoofdstuk vijf hebt gebouwd; np.where zet simpelweg elke True/False om in een waarde. Het grensgeval is de moeite van het controleren waard: >= 500 betekent dat een bestelling van precies 500 big is. Bepaal dat bewust, niet per ongeluk.
Meer dan twee waarden: np.select
Drie niveaus op basis van aantal — 20 of meer is "bulk", 5 of meer is "normal", al het andere is "few". np.select verwacht een lijst met voorwaarden en een lijst met bijbehorende waarden, plus een default voor rijen die aan geen enkele voorwaarde voldoen:
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 fewDe eerste voorwaarde die True is, wint. Daarom komt de strengste voorwaarde vooraan te staan. Draai de volgorde maar eens om en kijk wat er gebeurt met de bestelling van 30 gummen:
wrong = np.select(
[report["quantity"] >= 5, report["quantity"] >= 20],
["normal", "bulk"],
default="few",
)
print(wrong)['normal' 'normal' 'few' 'normal' 'normal' 'few' 'normal' 'few']Elke bestelling is normal of few; niets is bulk, want 30 en 20 zijn ook >= 5 en die voorwaarde werd als eerste gecontroleerd. Geen foutmelding, maar gewoon een verkeerde kolom — en daarom moet de handmatig nagerekende rij in je plan altijd een uiterste waarde bevatten.
.map(): waarden opzoeken
De manager wordt nergens uit berekend. Het is een gegeven dat je weet: Asha leidt de vestiging north, Omar de vestiging south, Lina de vestiging east. Wanneer de regel "zoek het op" is, schrijf je de opzoektabel als een dictionary en haal je de kolom erdoorheen met .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
0Elke vestiging werd vervangen door zijn waarde in de dictionary — de naam van de manager. De tweede print is de controle uit het plan, en die is belangrijker dan het lijkt, vanwege wat .map() doet met een sleutel die het niet kan vinden. Stel je voor dat de dictionary was geschreven voordat de vestiging east werd geopend:
old_managers = {"north": "Asha", "south": "Omar"}
print(report["branch"].map(old_managers).isna().sum())2Geen foutmelding. De twee bestellingen uit east kregen simpelweg NaN. Bij acht rijen zie je dat meteen; bij tachtigduizend rijen niet, en een latere groupby("manager") zou die bestellingen geruisloos weglaten (hoofdstuk acht: NaN-sleutels vallen af). Bij elke .map() hoort direct daarna een isna().sum().
pd.cut: binnen welk bereik een getal valt
De winkel wil elke bestelling indelen naar aantal: tot en met 5 stuks is small, 6 tot en met 15 is medium, meer dan 15 is large. Dat zou je kunnen schrijven met np.select, maar "binnen welk bereik" heeft een eigen hulpmiddel. pd.cut verwacht de grenzen van de bereiken en een label voor elk bereik:
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 smallVier grenzen maken drie bereiken: (0, 5], (5, 15] en (15, ∞]. Het ronde haakje betekent "exclusief", het rechte haakje betekent "inclusief" — elk bereik bevat dus zijn rechtergrens. Daarom is de bestelling van het notitieblok van precies 5 stuks small, niet medium. np.inf is oneindig, een bovengrens die geen enkel aantal kan overschrijden.
Het resultaat heeft een type dat je nog niet eerder bent tegengekomen:
print(report["band"].dtype)
print(report["band"].cat.categories.tolist())category
['small', 'medium', 'large']Een category-kolom onthoudt dat small < medium < large. Sorteren op band levert dus die logische volgorde op in plaats van de alfabetische volgorde — meestal precies wat een rapport nodig heeft.
Tekst en datums: .str en .dt
Stukjes tekst
In hoofdstuk vijf werd .str.contains gebruikt om te filteren. Dezelfde .str-accessor bouwt kolommen: elke string-methode die je kent uit Python, toegepast op elke waarde tegelijk. Stel dat elke bestelling een korte code nodig heeft zoals STA-1001 — de eerste drie letters van de categorie, in hoofdletters, een streepje, en dan het bestelnummer:
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] selecteert een deel van elke waarde, .str.upper() zet elke waarde om in hoofdletters, en + tussen tekstkolommen voegt ze rij voor rij samen. order_id is een getal, en een getal kan niet worden opgeteld bij tekst, dus .astype(str) maakt er eerst tekst van.
Stukjes van een datum
Het plan waarschuwde al dat date een str is. Converteer dit met pd.to_datetime, en de .dt-accessor komt beschikbaar:
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 SaturdayDe kolom is nu datetime64 — een echte datum, geen tekst. (De [us] is de precisie die pandas 3 ervoor heeft gekozen; dat mag je negeren.) Vanuit een echte datum biedt .dt je .dt.day_name(), .dt.month, .dt.year, .dt.day en meer. En de conversie doe je één keer, weer toegewezen aan date, zodat elke latere stap een datum krijgt en geen string.
transform: een rij vergelijken met zijn groep
Het laatste verzoek — het aandeel van elke bestelling in de omzet van de eigen vestiging — heeft twee dingen nodig op dezelfde rij: de omzet van de bestelling, en het totaal van de vestiging. Hoofdstuk acht leverde ons de totalen op:
print(report.groupby("branch")["revenue"].sum())branch
east 1140.0
north 2600.0
south 1150.0
Name: revenue, dtype: float64Maar dat zijn drie waarden, één per vestiging, en de tabel heeft acht rijen. Je kunt acht getallen niet delen door drie. Wat je nodig hebt, is het vestigingstotaal herhaald op elke rij van die vestiging. Dat is precies wat transform teruggeeft:
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: float64Zelfde groepering, zelfde "sum", maar het resultaat heeft acht waarden met de eigen labels van de tabel — elke rij van north draagt 2600, elke rij van south 1150. Nu is het een gewone kolomberekening:
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.185Controleer het: de bestelling voor de tas in de vestiging north is 1700 / 2600 = 0.654. En de aandelen binnen elke vestiging moeten optellen tot 1:
print(report.groupby("branch")["branch_share"].sum())branch
east 1.0
north 1.0
south 1.0
Name: branch_share, dtype: float64De regel om te onthouden: agg/sum verkleinen een groep tot één rij; transform geeft voor elke rij één waarde terug. Gebruik de eerste voor een samenvattingstabel, de tweede voor een nieuwe kolom.
apply(axis=1): het laatste redmiddel
Soms is de regel echt onregelmatig. De bezorgkosten zijn als volgt: bij de vestiging north — de hoofdvestiging, naast het magazijn — gratis wanneer de omzet minstens 500 is, anders 60; bij de andere vestigingen 120, maar gratis boven de 1000. Dat kun je schrijven als een gewone Python-functie en met apply toepassen op elke rij:
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 betekent "geef de functie één rij per keer". Binnenin is row een Series waarvan de labels de kolomnamen zijn, dus row["branch"] werkt gewoon.
Het leest prettig weg, en dat is de hele aantrekkingskracht. De prijs die je betaalt, is dat dit opnieuw de lus van het begin van het hoofdstuk is: pandas bouwt voor elke rij een Series op en roept je Python-functie één keer per rij aan. Bij acht rijen stelt dat niets voor; bij een miljoen rijen is het het verschil tussen een oogwenk en een koffiepauze.
Probeer daarom, voordat je naar apply grijpt, de regel te formuleren met hulpmiddelen die op hele kolommen werken. Deze regel is eigenlijk "drie gevallen, op volgorde gecontroleerd" — oftewel 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())TrueIdentiek resultaat, zonder een Python-aanroep per rij. Bewaar apply(axis=1) voor regels die echt niet op deze manier kunnen worden geschreven — zoals het aanroepen van een externe functie, of het ontleden van een rommelige string met meerdere stappen — en zie het grijpen daarnaar als een signaal om nog eens goed na te denken.
Een kolom toevoegen aan een gefilterde tabel: Copy-on-Write
Je zult vaak eerst filteren en daarna een kolom toevoegen aan het resultaat. In pandas 3 is dat eenvoudig, en de reden waarom het eenvoudig is, is goed om te begrijpen omdat oudere handleidingen iets anders beweren.
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']Geen waarschuwing, en orders heeft geen kolom revenue_k gekregen. Pandas 3 gebruikt Copy-on-Write: elk resultaat van filteren of selecteren gedraagt zich als een eigen, onafhankelijke tabel. Het wijzigen van north kan orders nooit veranderen.
Als je oudere code of antwoorden op internet leest, zie je dit exacte patroon gevolgd door een SettingWithCopyWarning ("A value is trying to be set on a copy of a slice from a DataFrame"), en het advies om orders[...].copy() te schrijven. Die waarschuwing bestond omdat eerdere versies van pandas soms data deelden tussen de twee tabellen, en niemand goed kon zien wanneer. Pandas 3 heeft de onzekerheid weggenomen, en daarmee ook de waarschuwing. Het schrijven van .copy() is nu overbodig, al kan het geen kwaad.
Wat Copy-on-Write niet toestaat, is het aanpassen van orders via een keten van twee stappen. Als je waarden in de oorspronkelijke tabel voor bepaalde rijen wilt veranderen, doet dit niets:
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-assignmentDe eerste set haken maakt een nieuwe tabel, de tweede set haken stelt waarden in binnen die nieuwe tabel, en die nieuwe tabel wordt weggeworpen. Pandas waarschuwt je met een ChainedAssignmentError, en de lijst toont revenue ongewijzigd — de bestellingen van north vermelden nog steeds 180.0, 1700.0, 240.0 en 480.0. (Waarschuwingen gaan naar de error-stream, dus in je terminal kan de waarschuwing boven de lijst verschijnen in plaats van eronder.) Om het origineel te wijzigen, doe je dat in één stap met .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 branchRijen die voldoen krijgen de waarde; de kolom wordt aangemaakt indien nodig; rijen die niet voldoen krijgen NaN. Eén stap, de oorspronkelijke tabel, geen waarschuwing.
Een compleet voorbeeld
enrich.py beantwoordt het volledige bericht van de winkeleigenaar, volgens het plan: inlezen, controleren, kolommen toevoegen, opnieuw controleren.
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: objectWaarom het op deze manier is geschreven:
- De conversie komt als eerste.
datewordt omgezet in een echte datum voordat iets er gebruik van maakt, zodat.dtlater niet kan mislukken en niemand verderop in het proces hoeft te onthouden om te converteren. - Eén regel per regel code. Elke regel beantwoordt één regel van het plan, dus wanneer een getal er verkeerd uitziet, weet je precies welke regel dat heeft gemaakt.
- De opzoektabel is een benoemde constante bovenaan. Wanneer de winkel een vestiging west opent, is de wijziging één regel op een duidelijke plek, en slaat de
isna-controle direct alarm als iemand dat vergeet. - De controles zijn
asserts, geen prints. Als het aantal rijen verandert, een.map()een vestiging mist, of rij 0 niet langer 180 is, stopt het programma in plaats van een verkeerd rapport te printen dat er goed uitziet. dtypeswordt aan het einde geprint omdat het type onderdeel is van het antwoord:revenuemoet een float zijn,branch_shareeen float, en de tekstkolommenstr.
Wanneer het misgaat
KeyError: 'Price' Een kolomnaam aan de rechterkant is verkeerd gespeld of heeft de verkeerde hoofdletters. Print list(orders.columns) en kopieer de naam van daaruit. Let ook op de linkerkant: orders["revenu"] = ... geeft geen enkele foutmelding — het maakt een nieuwe, verkeerd gespelde kolom aan naast degene die je bedoelde. Als een kolom die je "bijwerkte" niet is veranderd, zoek dan naar een tweeling met een typefout.
ValueError: Length of values (3) does not match length of index (8) Je hebt een lijst (of array) toegewezen waarvan de lengte niet overeenkomt met het aantal rijen. Een lijst heeft geen labels, dus pandas kan deze niet uitlijnen en weigert het. Bouw de waarden op basis van kolommen uit de tabel zelf, of controleer eerst len(values) == len(df).
De nieuwe kolom staat vol met NaN in sommige rijen, en het type is veranderd in float64 Je hebt een Series toegewezen waarvan de labels slechts gedeeltelijk overeenkomen met de tabel — meestal eentje die is opgebouwd uit een gefilterde tabel. Toewijzing koppelt op labels, niet op posities. Bouw de nieuwe kolom op basis van de volledige tabel (met np.where of .loc[mask, "col"] = ...) in plaats van uit een deelverzameling.
revenue bevat dingen als 15.015.015.0…, of TypeError: can't multiply sequence by non-int of type 'float' Een van de kolommen is tekst, geen getal. Een int vermenigvuldigen met een string herhaalt de string — de Python-regel 3 * "ab" == "ababab" — en pandas past dit zonder morren rij voor rij toe:
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: strDe komma voor duizendtallen in "1,200" maakte de hele kolom price van het type str. Dit is waarom het plan voorschrijft om naar dtypes te kijken vóór het rekenen. Los het op bij het inlezen, met thousands=",", of converteer met 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 De kolom is nog steeds tekst. Converteer deze eerst met df["date"] = pd.to_datetime(df["date"]) en gebruik daarna pas .dt.
KeyError: 'quantity' bij apply Je bent axis=1 vergeten. Zonder dat geeft apply hele kolommen door aan je functie, waardoor row["quantity"] zoekt naar een rij met het label quantity binnen een kolom en faalt. Voeg axis=1 toe — en vraag je daarna af of je apply überhaupt nodig hebt.
IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer Je hebt .astype(int) aangeroepen op een kolom die NaN bevat. Een NumPy-integer heeft geen mogelijkheid om "ontbrekend" op te slaan. Vul de lege plekken eerst op (hoofdstuk zes), of gebruik het nullable integer-type van pandas, .astype("Int64") met een hoofdletter I, dat wel ontbrekende waarden kan bevatten.
ChainedAssignmentError: A value is being set on a copy of a DataFrame or Series through chained assignment. Je hebt df[mask]["col"] = value geschreven. Onder Copy-on-Write maakt de eerste stap een aparte tabel aan, waardoor het origineel nooit verandert. Schrijf het in één stap: df.loc[mask, "col"] = value.
Een .map()-kolom bevat NaN waar je een waarde verwachtte Een sleutel in de kolom komt niet voor in de dictionary — een nieuwe vestiging, een andere spelling, een spatie aan het einde. Vind ze met df.loc[df["new"].isna(), "key_column"].unique() en pas daarna de dictionary of de data aan.
Stap 4 van 6 — Voorspellen
Test je begrip
Een kolom die is gemaakt op basis van de north-bestellingen wordt toegewezen aan de volledige tabel. Wat wordt er geprint?
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
Het doel is om een revenue-kolom toe te voegen aan orders. Wat wordt er geprint?
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` geeft een nieuwe tabel terug en laat `orders` ongewijzigd, en het resultaat is niet bewaard
- BEen `KeyError: 'revenue'`
- CEen `ChainedAssignmentError`
- D`True`
Je hebt een kolom nodig die op elke rij het aandeel van die bestelling in het totale aantal stuks van de eigen vestiging bevat. Welke regel levert één waarde per rij op?
- 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()
Voor antwoorden heb je een account nodig
Log in om je antwoorden te controleren
De vragen staan hierboven, en ze in je hoofd uitwerken is waar het om draait. Log in om de antwoorden, de uitleg en de hints in drie niveaus te zien.
Jouw beurt
Schrijf met behulp van hetzelfde orders.csv het bestand enrich_task.py dat de eigenaar van de winkel een kortingsrapport levert. Plan eerst — schrijf de lijst met nieuwe kolommen op en reken rij 0 met de hand uit — en voeg vervolgens deze kolommen toe, in deze volgorde:
revenue=quantity×pricediscount= 10% vanrevenuewanneerquantity10 of meer is, anders 0net=revenue−discountmanager— op basis van de vestiging, met behulp van de dictionary uit dit hoofdstukband— op basis vannet: tot en met 300 is"low", boven 300 tot en met 800 is"mid", boven 800 is"high"weekday— op basis vandatecategory_share— hetnetvan elke bestelling als aandeel in het totalenetvan de eigen category, afgerond op 2 decimalen
Print de shape voor en na, daarna order_id, product, quantity, net, band, weekday en category_share, gesorteerd op net van hoog naar laag.
Je programma is correct wanneer:
- Het exact de onderstaande uitvoer print.
- Er nergens een
for-lus ofapplystaat — elke kolom is in één keer gemaakt op basis van hele kolommen. - Als er een vestiging ontbreekt in de dictionary, stopt het programma met een
AssertionErrorin plaats van door te gaan metNaN. - Het aantal rijen zowel voor als na 8 is.
Verwachte uitvoer:
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.17Voorspel voordat je het uitvoert: welke bestelling heeft het hoogste net, en in welk bereik (band) valt de bestelling van 30 gummen? Controleer het daarna aan de hand van de uitvoer.
Oplossing
Het plan. Rij 0 met de hand: de pennenbestelling, 12 × 15.0 = 180.0; aantal is 12, dus de korting is 18.0 en net is 162.0; 162 is maximaal 300, dus low. De gummenbestelling: 30 × 8.0 = 240.0, korting 24.0, net 216.0 — ook low, ook al is het naar aantal gemeten de grootste bestelling. Het hoogste net moet de bestelling van 2 tassen zijn: 1700.0, geen korting.
revenueennet— kolomberekening: gewone vermenigvuldiging en aftrekking.discount—np.where: één van twee waarden per rij.manager—.map(dict), gevolgd doorisna().sum(): een opzoekactie, gecontroleerd.band—pd.cutmet grenzen[0, 300, 800, inf]: bereiken. Elk bereik bevat zijn rechtergrens, dus precies 300 islowen precies 800 ismid.weekday—pd.to_datetime, en dan.dt.day_name(): datumonderdelen hebben een echte datum nodig.category_share—groupby("category")["net"].transform("sum"): één waarde per rij, vergeleken met de eigen groep.
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.17Het resultaat getoetst aan het plan:
- Shape ging van 7 naar 14 kolommen en bleef op 8 rijen: zeven nieuwe kolommen, geen rijen erbij of eraf.
- Rij 0 (bestelling 1001) heeft
net162.0en bandlow— de met de hand nagerekende waarden. - Het hoogste
netis bestelling 1003, de tas, op1700.0, zoals voorspeld. De gummenbestelling 1005 islowop216.0: de meeste artikelen, maar wel goedkope. np.wherenam een kolom als zijn "waar"-waarde —orders["revenue"] * 0.10— zodat elke rij zijn eigen 10% kreeg, niet één enkel getal. Beide takken vannp.wherekunnen kolommen of gewone waarden zijn.- De grens van een bereik is zichtbaar. De notitieblok-bestelling heeft een
netvan precies300.0en islow, omdat(0, 300]300 insluit. Als de eigenaar van de winkel bedoelde "300 en hoger is mid", moeten de grenzen worden aangepast — een beslissing, geen detail. - De aandelen tellen op binnen elke categorie. Accessories:
0.44 + 0.22 + 0.22 + 0.12 = 1.00. Stationery:0.32 + 0.28 + 0.23 + 0.17 = 1.00. Als de aandelen van een categorie niet optelden tot 1, zou detransformop de verkeerde kolom zijn gegroepeerd.
Laat het daarna met opzet crashen, één keer per situatie: verwijder "east" uit de dictionary en zie hoe de assert het programma stopt; verander bins zodat het begint bij 200 in plaats van 0 en kijk welke rijen NaN krijgen in band, en waarom.
Stap 6 van 6
Uitdaging — de hoofdstukquiz
Tien vragen van makkelijk naar moeilijk. De laatste zijn met opzet lastig.
Log in om de quiz te maken