4.2 IDE, típusinformáció és hibakeresés

4.2.1 Cél és konkrét helyzet

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.

4.2.2 Interpreter, töréspont, watch és a futás közbeni igazság

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 NON_O a rendelések, NIN_I a tételek darabszáma. Az ii rendeléshez tartozó tételsorok száma nin_i. Ha a rendeléskulcs egyedi és minden tétel kulcsa létezik, a belső join sorainak száma

NJ=∑i=1NOni=NI. N_J = \sum_{i=1}^{N_O} n_i = N_I .

Egy rendelésszintű sis_i szállítási díj a join minden tételsorában megjelenik. A soronkénti összegzés hibásan

Srossz=∑i=1NOnisi, S_{\text{rossz}} = \sum_{i=1}^{N_O} n_i s_i ,

a helyes összeg pedig

Shelyes=∑i=1NOsi. S_{\text{helyes}} = \sum_{i=1}^{N_O} s_i .

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.

4.2.3 A minimális munkapélda lépésről lépésre

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()
False

A 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.

4.2.4 Eredmény értelmezése

A mini példában kézzel is ellenőrizhető a kontroll: az A rendeléshez nA=1n_A=1, a B rendeléshez nB=3n_B=3 tételsor tartozik. A hibás képlet 1⋅200+3⋅200=8001 \cdot 200 + 3 \cdot 200 = 800 centet ad, a helyes 200+200=400200 + 200 = 400 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 True

A 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.

4.2.5 Feladatok

  1. É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 SrosszS_{\text{rossz}} összeg általános képletét a nin_i tételszámokkal.

  2. 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.

  3. 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.

  4. 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?

4.2.6 Megoldások és indoklás

  1. A join a B rendelést háromszor ismétli, ezért NJ=4N_J=4 sor keletkezik. A (order_id, line_id) párok: A1, B1, B2, B3, mind egyedi. A hibás összeg 1⋅200+3⋅200=8001 \cdot 200 + 3 \cdot 200 = 800, a helyes 200+200=400200 + 200 = 400 cent. Az általános képlet Srossz=∑inisiS_{\text{rossz}}=\sum_i n_i s_i, ahol nin_i az adott rendelés tételsorainak száma, sis_i pedig a rendelésszintű szállítási díj.

  2. 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.

  3. 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.

  4. 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).

4.2.7 Gyakori tévedés ellenpéldával

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.

4.2.8 Önellenőrzés

4.2.9 Rövid összefoglaló

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.