A Northstar-labor egyik munkatársa tételszintű elemzést készített: a
rendelések tábláját összekapcsolta a rendelési tételek táblájával, majd
a kapott táblából akarta összegezni a szállítási díjat. Azt
feltételezte, hogy a join után a shipping_fee_minor oszlop
sorainak összege ugyanaz, mint a rendelésszintű
orders.csv-ben. A jelentése szerint viszont a szállítási
bevétel gyanúsan magas lett.
A bemenet a lab orders.csv (megfigyelési egység: egy
rendelés, kulcs order_id) és order_items.csv
(megfigyelési egység: egy rendelési tétel, kulcs
(order_id, line_id)). A cél nem az egész tábla kiíratása,
hanem egy törésponttal és kis mintával megmutatni, hogy a join után a
megfigyelési egység tétellé változott, ezért a rendelésszintű
pénzösszegek tételenként ismétlődnek. Előzetes becslés: a belső join
sorainak száma a tételek sorainak számával egyezik meg, ha minden tétel
rendelése érvényes; a helyes szállítási összeg a rendelések táblájában
marad, a soronkénti összegzés pedig rendelésenként a tételszámmal
szorozza meg az összeget. A pontos eltérést a labor futtatása mutatja
meg; itt a kimenetek szemléltető jellegűek.
A VS Code Python-környezet egy interpretert
(értelmezőt) választ a futtatáshoz. Ez az a
.venv/bin/python (Windows alatt
.venv\\Scripts\\python.exe) folyamat, amelyet az
s010-ben a uv sync --locked hozott létre. A
notebook kernelje ettől külön választás: a Jupyter egy másik futó
folyamatot indíthat, ezért a VS Code-ban a Python interpreter
kiválasztása és a notebook kernel kiválasztása két külön lépés. A
Command Palette (Ctrl+Shift+P / Cmd+Shift+P)
Python: Select Interpreter parancsával a projekt
.venv-jét válaszd ki; ezt a státuszsor jobb alsó sarkában
ellenőrizheted. Ha a notebook más kernellel fut, a cella és a script
eltérő csomagkészletet láthat.
A debugger (hibakereső) ugyanazzal az interpreterrel
indítja a programot, de egy megjelölt sor előtt megállítja a
végrehajtást. A megjelölt sor a breakpoint (töréspont).
Amíg a program áll, a Variables panel a lokális
változók értékét és típusát mutatja, a Debug
Console-ban pedig tetszőleges kifejezést kiértékelhetünk az
aktuális végrehajtási keretben. A watch (figyelő)
kifejezéseket rögzítünk, és a debugger minden léptetés után újra
kiértékeli őket. A Step Over a következő sort hajtja végre
anélkül, hogy a hívott függvény belsejébe lépne; Step Into
a hívás belsejébe lép; Continue a következő töréspontig
fut.
A DataFrame.shape tuple, első eleme a sorok száma,
második az oszlopok száma. Az orders.dtypes az oszlopok
tényleges numpy típusát mutatja (int64,
float64, object, datetime64[ns],
category). Ez futás közbeni igazság: a
read_csv következtethet rosszul, ezért a típust nem csak a fájl
szerződése, hanem a betöltött tábla alapján is ellenőrizni kell. A
karakteres azonosítót ne alakítsd számmá, mert az azonosító nem
mennyiség. A Series.is_unique akkor igaz, ha a sorozat
minden értéke különböző; a join előtti kulcsegyediség és a join utáni
kulcsegyediség külön kérdés.
Matematikai nyelven: legyen a rendelések, a tételek darabszáma. Az rendeléshez tartozó tételsorok száma . Ha a rendeléskulcs egyedi és minden tétel kulcsa létezik, a belső join sorainak száma
Egy rendelésszintű szállítási díj a join minden tételsorában megjelenik. A soronkénti összegzés hibásan
a helyes összeg pedig
A kettő csak akkor egyenlő, ha minden rendeléshez pontosan egy tétel
tartozik. A shape a darabszámot, az index a
sorcímkét, a kulcsoszlop a megfigyelési egységet mutatja; a join után az
index továbbra is RangeIndex, de a logikai kulcs már nem
feltétlenül egyedi.
Előbb készíts egy kis minimális reprodukciót (minimal reproduction): olyan kis bemenetet, amely ugyanazt a hibát előidézi, de a teljes táblázatot kézzel átláthatóvá teszi. A minimális példa elvárt kimenete:
import pandas as pd
mini_orders = pd.DataFrame({
"order_id": ["A", "B"],
"shipping_fee_minor": [200, 200],
})
mini_items = pd.DataFrame({
"order_id": ["A", "B", "B", "B"],
"line_id": [1, 1, 2, 3],
})
mini_merged = mini_orders.merge(
mini_items, on="order_id", how="inner", validate="one_to_many"
)
print(mini_merged)
print("shape:", mini_merged.shape)
print("hibás soronkénti összeg:",
mini_merged["shipping_fee_minor"].sum())
print("helyes rendelésenkénti összeg:",
mini_orders["shipping_fee_minor"].sum())Elméletileg várt kimenet:
order_id shipping_fee_minor line_id
0 A 200 1
1 B 200 1
2 B 200 2
3 B 200 3
shape: (4, 3)
hibás soronkénti összeg: 800
helyes rendelésenkénti összeg: 400
A validate="one_to_many" azt szerződteti, hogy a bal
oldali order_id egyedi, a jobb oldalon viszont egy
rendeléshez több sor is tartozhat. Ha a bal oldalon duplikált kulcs
lenne, a merge azonnal MergeError kivételt ad.
A labor tényleges adatán készíts a notebook mellé egy
debug_join.py scriptet, mert a VS Code-debugger tiszta
scriptben a legátláthatóbb:
# debug_join.py -- a labor gyökérkönyvtárából futtatható
from pathlib import Path
import pandas as pd
DATA = Path("data") # a labor ADATOK.md szerinti elérési útjához igazítsd
orders = pd.read_csv(
DATA / "orders.csv",
parse_dates=["order_ts", "actual_delivery_at", "label_available_at"],
)
items = pd.read_csv(DATA / "order_items.csv")
merged = orders.merge(
items, on="order_id", how="inner", validate="one_to_many"
)
# Tegyél töréspontot a következő sorra!
print("betöltve:", orders.shape, items.shape, merged.shape)
wrong = merged["shipping_fee_minor"].sum()
right = orders["shipping_fee_minor"].sum()
print("hibás:", wrong, "helyes:", right)
# a duplázódás magyarázata rendelésenként:
counts = merged.groupby("order_id").size()
per_order_fee = orders.set_index("order_id")["shipping_fee_minor"]
duplication = (
merged.groupby("order_id")["shipping_fee_minor"].sum()
/ per_order_fee
)
print("tételszámok:", counts.describe().to_dict())
print("duplázódási szorzók:", duplication.describe().to_dict())A script megnyitása után válaszd ki az interpretert a projekt
.venv-jéből. A print("betöltve:", ...) sor bal
margójára kattintva helyezz el töréspontot. Indítsd a debuggert a
Run and Debug panelen a Python: Current File
konfigurációval. Amikor a futás megáll, a Variables
panelben a merged mellett a futás közbeni típus és méret
látható. A Watch panelben rögzítsd az alábbi
kifejezéseket:
orders.shape
items.shape
merged.shape
orders.order_id.is_unique
items.duplicated(["order_id", "line_id"]).any()
merged[["order_id", "line_id"]].duplicated().any()
A Debug Console-ban a kulcsegyediség és a tételszámok közvetlenül is vizsgálhatók:
>>> orders.shape
# szemléltető: (2400, 15), a pontos szám a laborban ellenőrizendő
>>> items.shape
# szemléltető: (4796, 8)
>>> merged.shape
# szemléltető: (4796, 22)
>>> orders.order_id.is_unique
True
>>> merged[["order_id", "line_id"]].duplicated().any()
FalseA shape azt mutatja, hogy a join eredménye a tételek szerinti 4796 soros szemcsézettségű, nem a rendelések szerinti 2400 soros. A rendelési kulcs a join után természetesen nem egyedi; a tételek összetett kulcsa viszont egyedi.
A mini példában kézzel is ellenőrizhető a kontroll: az A rendeléshez
,
a B rendeléshez
tételsor tartozik. A hibás képlet
centet ad, a helyes
centet. A labor adatán a laborfutás után a wrong nagyobb
lesz a right értékénél, ha létezik egynél több tételes
rendelés; a duplication szorzó értéke éppen az adott
rendelés tételsorainak száma. A fájl szerződéséből 2400 rendelés és 4796
tétel következik, tehát sok rendelés több tételes; a pontos
szorzómegoszlást nem becslésből, hanem a futásból kapod meg.
A helyes rendelésszintű összeghez vagy az eredeti orders
táblát kell használni, vagy a duplikált csoportból egyetlen reprezentáns
sort kell venni:
correct_from_joined = (
merged.groupby("order_id")["shipping_fee_minor"].last().sum()
)A .last() azért helyes, mert a rendelésszintű oszlop a
csoport minden sorában ugyanaz; a .sum() viszont a
tételszám szorzóval adná vissza. Az ellenőrzés pontos, mert a pénz egész
minor unitban van, tehát itt nincs lebegőpontos tolerancia: az
egyenlőség egész számokra értendő. Ez az invariant nem csak a szállítási
díjra, hanem a basket_minor rendelésszintű oszlopára is
igaz.
A VS Code-debugger nélkül ugyanezt a logikát gépi assertté is
alakíthatod. A labor átadandó ellenőrzése legyen a
check_join függvény:
def check_join(orders, items, merged):
assert orders["order_id"].is_unique, "orders.order_id nem egyedi"
assert not items.duplicated(["order_id", "line_id"]).any(), \
"tételkulcs nem egyedi"
assert merged.shape[0] == items.shape[0], \
"a join sorainak száma nem a tételek sorainak száma"
assert not merged[["order_id", "line_id"]].duplicated().any(), \
"a joined tételkulcs nem egyedi"
before = orders["shipping_fee_minor"].sum()
after = merged.groupby("order_id")["shipping_fee_minor"].last().sum()
assert before == after, \
f"szállítási díj duplázódott: {before} != {after}"
return TrueA négy invariáns rendre a rendeléskulcs egyediségét, a tételkulcs egyediségét, a join sorainak szemcsézettségét és a pénzösszeg változatlanságát rögzíti.
Hibakeresés statisztikai kontrollal. A debugger megmutathatja a join után létrejött sorok számát, de nem dönti el önmagában, hogy a sorok a megfelelő megfigyelési egységet jelentik. A két tábla illesztése után ellenőrizzük a kulcsok egyediségét, az egy rendelésre jutó tételszámot és a rendelésszintű összegek változatlanságát.
Ellenpélda: ha egy rendeléshez három tételsor tartozik, akkor a rendelésszintű szállítási díj soronkénti összeadása háromszor számolja ugyanazt a díjat. A helyes számítás előbb tételszinten aggregál, majd rendelésszinten csatlakoztat; a debugger mellé invariánsteszt is kell.
Értelmezés. A mini példa
mini_orders táblájában az A rendeléshez 1, a B rendeléshez
3 tételsor tartozik, a szállítási díj mindkettőnél 200 cent. Számold ki
kézzel a join sorainak számát, a kulcsegyediséget és a hibás, illetve
helyes szállítási összeget. Írd fel a
összeg általános képletét a
tételszámokkal.
Megvalósítás. Egészítsd ki a
check_join függvényt egy ötödik invariánssal: a
basket_minor rendelésszintű kosárösszegnek is
változatlannak kell maradnia a tételszintű join után. Futtasd a
függvényt a mini példán és a labor tiszta
orders/order_items adatán. A teljes ellenőrző
script neve legyen check_join.py.
Hibakeresés. Egy munkatárs a teljes laboron ezt
írta: merged["shipping_fee_minor"].sum(), és a szállítási
összeg a vártnak körülbelül a kétszerese lett. Hova teszel töréspontot a
debug_join.py scriptben, melyik négy kifejezést írod a
Watch panelbe, és mi a legvalószínűbb magyarázat? Ne használj hosszú
print-kimenetet.
Megváltozott feltétel. Tegyük fel, hogy egy
order_items.csv sorból hiányzik egy tétel, és a munkatárs
how="left" joinnel dolgozik. Adj konkrét mini bemenetet
három rendeléssel, amelyek közül az egyikhez egyáltalán nem tartozik
tétel. Mennyi lesz a join shape[0] értéke, mit jelez a
check_join, és változik-e a .last().sum()
szállítási összeg?
A join a B rendelést háromszor ismétli, ezért
sor keletkezik. A (order_id, line_id) párok: A1, B1, B2,
B3, mind egyedi. A hibás összeg
,
a helyes
cent. Az általános képlet
,
ahol
az adott rendelés tételsorainak száma,
pedig a rendelésszintű szállítási díj.
Az ötödik invariant:
basket_before = orders["basket_minor"].sum()
basket_after = merged.groupby("order_id")["basket_minor"].last().sum()
assert basket_before == basket_after, \
f"basket_minor duplázódott: {basket_before} != {basket_after}"A mini példán nincs basket_minor oszlop, ezért a mini
verzióhoz előbb oszloppal kell kiegészíteni, például
basket_minor = [950, 1700]. A labor tiszta adatán a
check_join.py futtatásakor mind az öt assertnek teljesülnie
kell; bármelyik megszegése a feltételezés hibáját jelzi.
Töréspontot a print("betöltve:", ...) sorra kell
tenni, mert ott mindkét bemeneti tábla és a merged is
elérhető. Watch kifejezések: orders.shape,
items.shape, merged.shape,
items.duplicated(["order_id", "line_id"]).any(). A
merged.shape sorszáma várhatóan 4796, azaz a tételszint; a
.sum() ezért a rendelésszintű oszlopot minden tételsorban
beszámítja. A munkatárs által kapott „körülbelül kétszeres” az átlagos
tételszámnak felel meg, de a pontos hányados rendelésenként a
tételszám.
Konkrét bemenet:
mini_orders = pd.DataFrame({
"order_id": ["A", "B", "C"],
"shipping_fee_minor": [100, 200, 300],
})
mini_items = pd.DataFrame({
"order_id": ["A", "B", "B"],
"line_id": [1, 1, 2],
})
merged_left = mini_orders.merge(
mini_items, on="order_id", how="left", validate="one_to_many"
)A merged_left.shape[0] értéke 4, a
mini_items.shape[0] viszont 3, ezért a
check_join shape-invariánsa hibát jelez. A
.last().sum() szállítási összeg 600 cent marad, tehát a
pénzösszeg-invariáns önmagában nem veszi észre a hiányzó tételt; ezért
is kell a shape-ellenőrzés külön. A nullákat a join után a hiányzó tétel
oszlopaiban a dtype is mutatja (NaN).
A hosszú print-kimenet nem helyettesíti a megfogalmazott invariánst.
A mini példában a print(mini_merged) kimenete négysoros, és
a szem hamar észreveszi, hogy a B rendelés három sorban szerepel. A
labor 4796 soránál ugyanez a hiba gyakran nem feltűnő, mert a duplikált
rendelések közé ékelődnek a különböző tételek, és a képernyőn kigördülő
részletből nem tudható, hány rendelés ismétlődik. A
print(mini_merged["shipping_fee_minor"].sum()) egyetlen
800-as számot ad; ha nem tudjuk, hogy a helyes összeg 400, a szám
önmagában nem hibás. Az invariant viszont egyenlőségként mondja ki, mit
várunk: a join előtti és utáni rendelésszintű összegnek egyeznie kell. A
check_join assertjei akkor is megállítják a futást, ha a
hiba a tábla közepén van.
A korábbi 4.1 figyelmeztetése ide is átnyúlik: a notebook rejtett
állapotához hasonlóan a tábla mérete is „csendes” tulajdonság. A cella
lefutása nem bizonyítja, hogy a benne lévő számítás a kívánt
szemcsézettségen dolgozott; a shape, a dtype
és a kulcsegyediség ellenőrzése viszont a futás közbeni igazságot
rögzíti.
orders.shape[0] a belső
join sorainak száma?.last(), és miért nem a .sum()
adja a rendelésszintű szállítási díjat a join után?A VS Code-debugger a kiválasztott projektinterpreterrel futtat, és
töréspontnál megállítja a programot. A Watch panelben rögzített
shape, dtype és kulcsegyediség kifejezésekkel
a tábla tényleges szemcsézettségét vizsgáljuk, nem a feltételezésünket.
A Northstar join példájában a belső join sorainak száma a tételek száma,
ezért a rendelésszintű szállítási díj minden rendelésnél a tételszám
szorzóval ismétlődik; a helyes összeghez a rendelésenkénti egyetlen
reprezentáns sor kell. A hosszú print helyett a check_join
assertjei rögzítik a shape- és pénzösszeg-invariánst. A következő
alfejezetben a notebookból készítünk tiszta scriptet és jelentést.