4.3 Notebookból script és jelentés

4.3.1 Cél és konkrét helyzet

Az 4.2 alfejezetben a debuggerrel kimutattuk, hogy a Northstar orders.csv és order_items.csv táblák hibás joinolásakor a rendelésszintű shipping_fee_minor minden tételnél megismétlődik. Most ezt a javított aggregációt kiemeljük egy tiszta Python-modulba, hogy ugyanazt a mutatót használhassa a notebook, a parancssori script és a statikus jelentés is.

A konkrét bemenet a kísérő labor két fájlja:

A megfigyelési egység most a rendelés. A cél egy rendelésszintű total_minor mutató előállítása úgy, hogy a kosárérték és a szállítási díj egyszer szerepeljen minden rendelésnél. Az ellenőrzési feltétel kettős:

  1. A kimeneti tábla shipping_fee_minor összege egyenlő a bemeneti orders.csv shipping_fee_minor összegével.
  2. A tételekből számított kosárösszeg minden rendelésnél egyenlő a basket_minor mezővel, tehát az eltérés pontosan 0.

A mutatót egyetlen helyen definiáljuk:

totalminor,i=basketminor,i+shipping_feeminor,i,total_{minor,i} = basket_{minor,i} + shipping\_fee_{minor,i},

ahol i=1,…,Ni=1,\dots,N a rendelések indexe, NN a rendelések száma. A basket_minor és shipping_fee_minor egész EUR-centben adott, ezért a total_minor is egész EUR-cent; a kimenő vektor alakja (N,). A basket_minor maga is ellenőrizhető a tételekből:

basketminor,i=∑j∈Tiline_totalminor,j,basket_{minor,i} = \sum_{j \in T_i} line\_total_{minor,j},

ahol TiT_i az ii-edik rendeléshez tartozó tételek halmaza. Ezt a definíciót kell megőrizni a notebook, a CLI és a jelentés között.

Előzetes becslés: a 4796 tétel 2400 rendeléshez tartozik, tehát a tételszintű join után a rendelések átlagos tételszáma körülbelül 4796/2400≈24796 / 2400 \approx 2. A nyers, rendelés és tétel közötti joinban a szállítási díj körülbelül kétszereződne; a helyes aggregáció után a szállításidíj-összeg pontosan egyezik a rendeléstábláéval.

4.3.2 Fogalmi magyarázat

Tiszta függvény (pure function). Egy függvény akkor tiszta, ha ugyanarra a bemenetre mindig ugyanazt a kimenetet adja, és nincs a függvényen kívüli mellékhatása: nem ír fájlt, nem olvas globális változót, nem módosítja a bemeneti táblát. A compute_order_totals(orders, item_aggr) tiszta függvény: bemeneti DataFrame-ekből új DataFrame-et ad. Ezt tudjuk egységteszttel ellenőrizni.

Modul és belépési pont. A Python-modul importálható .py fájl. A northstar/metrics.py tartalmazza a tiszta függvényeket, a northstar/cli.py pedig az if __name__ == "__main__": blokkban hívja ezeket. Így a fájl scriptként és importként is viselkedhet anélkül, hogy a számítás két helyen élne.

Notebook hívókód. A notebook nem újraszámolja a mutatót; ugyanazt a modult importálja, mint a CLI. A notebook cellái lehetnek felfedező jellegűek, de a termelési logika a modulban marad.

nbconvert. Az nbconvert --to script a notebookot Python-fájllá konvertálja, de a cellákat általában lineárisan összefűzi; ez nem tiszta függvény, és a cellák közötti rejtett függőségeket megőrzi.

Quarto. A .qmd forrás Markdown-szöveget és futtatható kódblokkokat tartalmaz. A quarto render parancs a helyi Jupyter-kernellel futtatja a kódblokkokat, és statikus HTML/PDF jelentést készít. A jelentés a northstar.metrics modulból dolgozik, tehát a mutatódefiníció ismét nem másolódik.

Ugyanaz a mutató három felületen akkor marad konzisztens, ha mindegyik ugyanazt a modulbeli definíciót importálja:

Ha a definíciót három helyre bemásolnánk, bármelyik frissítés elmaradhat, és a három felület néma eltérést mutatna.

4.3.3 Teljes minimális munkapélda

Hozzuk létre a lab gyökerében a következő struktúrát:

lab/
├── orders.csv
├── order_items.csv
├── northstar/
│   ├── __init__.py
│   ├── metrics.py
│   └── cli.py
├── explore.ipynb
├── summary.qmd
└── artifacts/

A northstar/metrics.py tartalma:

# northstar/metrics.py
"""Egységes rendelésszintű mutatószámítás a Northstar labhoz.

Minden pénzmennyiség egész EUR-centben van (minor unit).
"""

import pandas as pd


def aggregate_items(items: pd.DataFrame) -> pd.DataFrame:
    """Tételsorokat aggregál rendelésszintre.

    Bemenet:
      items: olyan DataFrame, amelyben minden sor egy rendelési tétel.
             Szükséges oszlopok: order_id, line_id, line_total_minor,
             quantity.
    Kimenet:
      DataFrame, amelyben minden order_id egyetlen sor; oszlopai:
      derived_basket_minor, item_quantity_sum, item_rows.

    Invariáns:
      derived_basket_minor == sum(line_total_minor) az adott rendelésre.
    """
    required = {"order_id", "line_id", "line_total_minor", "quantity"}
    if not required.issubset(items.columns):
        missing = required - set(items.columns)
        raise ValueError(f"Hiányzó tétel oszlopok: {sorted(missing)}")

    item_aggr = (
        items.groupby("order_id", as_index=False)
        .agg(
            derived_basket_minor=("line_total_minor", "sum"),
            item_quantity_sum=("quantity", "sum"),
            item_rows=("line_id", "count"),
        )
    )
    return item_aggr


def compute_order_totals(
    orders: pd.DataFrame, item_aggr: pd.DataFrame
) -> pd.DataFrame:
    """Rendelésszintű teljes összeg a szállítási díj egyszeri beszámításával.

    Bemenet:
      orders: rendelésenként egyetlen sor; oszlopai order_id,
              basket_minor, shipping_fee_minor.
      item_aggr: az aggregate_items kimenete.
    Kimenet:
      one-to-one join rendelésenként; a total_minor oszlop az
      basket_minor + shipping_fee_minor összeget adja,
      a basket_check_minor pedig a tételekből számított és a rendelésbeli
      kosárérték különbségét.
    """
    required = {"order_id", "basket_minor", "shipping_fee_minor"}
    if not required.issubset(orders.columns):
        missing = required - set(orders.columns)
        raise ValueError(f"Hiányzó rendelés oszlopok: {sorted(missing)}")

    if orders["order_id"].duplicated().any():
        raise ValueError("Az orders.order_id nem egyedi")

    if item_aggr["order_id"].duplicated().any():
        raise ValueError("Az item_aggr.order_id nem egyedi")

    merged = orders.merge(
        item_aggr, on="order_id", how="left", validate="one_to_one"
    )
    merged["basket_check_minor"] = (
        merged["derived_basket_minor"] - merged["basket_minor"]
    )
    merged["total_minor"] = (
        merged["basket_minor"] + merged["shipping_fee_minor"]
    )
    return merged

A northstar/cli.py:

# northstar/cli.py
"""Parancssori felület a rendelésösszeg-adat előállításához."""

import argparse
import pandas as pd

from .metrics import aggregate_items, compute_order_totals


def main(argv=None) -> int:
    parser = argparse.ArgumentParser(
        description="Rendelésszintű total_minor adatot állít elő."
    )
    parser.add_argument("--orders", default="orders.csv")
    parser.add_argument("--items", default="order_items.csv")
    parser.add_argument("--output", default="artifacts/order_totals.csv")
    args = parser.parse_args(argv)

    orders = pd.read_csv(args.orders)
    items = pd.read_csv(args.items)

    item_aggr = aggregate_items(items)
    totals = compute_order_totals(orders, item_aggr)

    shipping_in = int(orders["shipping_fee_minor"].sum())
    shipping_out = int(totals["shipping_fee_minor"].sum())
    basket_rows_ok = bool((totals["basket_check_minor"] == 0).all())

    if shipping_in != shipping_out or not basket_rows_ok:
        print(
            "INVARIANT_FAILED "
            f"shipping_in={shipping_in} shipping_out={shipping_out} "
            f"basket_rows_ok={basket_rows_ok}"
        )
        return 2

    totals.to_csv(args.output, index=False)
    print(
        f"OK rows={len(totals)} "
        f"shipping_in_minor={shipping_in} shipping_out_minor={shipping_out}"
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Futtassuk a lab gyökeréből:

# bash vagy Windows PowerShell; munkakönyvtár: lab/
mkdir -p artifacts
python -m northstar.cli \
  --orders orders.csv \
  --items order_items.csv \
  --output artifacts/order_totals.csv

Szemléltető kimenet; a konkrét shipping_in_minor érték a helyi generált adatból adódik, ezért itt helykitöltővel jelöljük:

OK rows=2400 shipping_in_minor=<helyi mért érték> shipping_out_minor=<helyi mért érték>

A notebook explore.ipynb egy cellája ugyanezt a modult hívja:

import pandas as pd
from northstar.metrics import aggregate_items, compute_order_totals

orders = pd.read_csv("orders.csv")
items = pd.read_csv("order_items.csv")

item_aggr = aggregate_items(items)
totals = compute_order_totals(orders, item_aggr)

assert int(totals["shipping_fee_minor"].sum()) == int(
    orders["shipping_fee_minor"].sum()
)
assert (totals["basket_check_minor"] == 0).all()

totals[
    ["order_id", "basket_minor", "shipping_fee_minor", "total_minor"]
].head()

Ez a notebook hívókód nem számol újra; az assert a modul kimenetét ellenőrzi. A notebook ezen túl lehet felfedező: például hisztogramot rajzol a total_minor eloszlásáról.

A notebook --to script exportja:

# bash; munkakönyvtár: lab/
jupyter nbconvert --to script explore.ipynb --output-dir artifacts/

Az így készült explore.py a cellákat sorba fűzi. Szemrevételezve látszik, hogy a compute_order_totals nem a notebookban lakik, hanem importálva van. Ez a különbség a fenntarthatóság alapja.

A Quarto forrás summary.qmd:

---
title: "Rendelésösszeg-ellenőrző jelentés"
format: html
execute:
  echo: true
---

A jelentés a `northstar.metrics.compute_order_totals` függvényt használja.
Minden rendelésre a mutató definíciója:

$$total_{minor,i} = basket_{minor,i} + shipping\_fee_{minor,i},$$

ahol az $i$ index a rendelés, a pénznem EUR, az egység egész EUR-cent.

```{python}
import pandas as pd
from northstar.metrics import aggregate_items, compute_order_totals

orders = pd.read_csv("orders.csv")
items = pd.read_csv("order_items.csv")

item_aggr = aggregate_items(items)
totals = compute_order_totals(orders, item_aggr)

assert int(totals["shipping_fee_minor"].sum()) == int(
    orders["shipping_fee_minor"].sum()
)
assert (totals["basket_check_minor"] == 0).all()

totals[
    ["order_id", "basket_minor", "shipping_fee_minor", "total_minor"]
].head()
```

Renderelés a lab gyökeréből:

# bash; munkakönyvtár: lab/
quarto render summary.qmd

Szemléltető renderkimenet: a Quarto lefuttatja a Python-chunkot, és summary.html áll elő. Az aktuális nbconvert és Quarto argumentumok a telepített verziónál --help kapcsolóval ellenőrizhetők; a reference.md és sources.md forráskulcsai további kiindulópontot adnak.

4.3.4 Eredmény értelmezése

Ellenőrizzük a mutatót egy kézi mini példán, mielőtt a 2400 soros táblára bíznánk. Legyen két rendelés:

order_id basket_minor shipping_fee_minor
ORD-A 100 20
ORD-B 40 10

A tételek:

order_id line_id line_total_minor quantity
ORD-A 1 60 2
ORD-A 2 40 1
ORD-B 1 40 5

A compute_order_totals kimenete:

order_id derived_basket_minor total_minor basket_check_minor
ORD-A 100 120 0
ORD-B 40 50 0

A két ellenőrző invariant most is teljesül: a shipping_fee_minor összege 20+10=3020 + 10 = 30 a rendeléstáblában és a kimenetben egyaránt; a basket_check_minor oszlop mindenütt 0. Mivel az adatok egész EUR-centben vannak, nincs lebegőpontos kerekítési tolerancia; az összehasonlítás pontos, egészértékű.

A total_minor mértékegysége EUR-cent, a rendelésenkénti sor a (2400,) alakú. A notebook, a CLI és a Quarto jelentés ugyanazt a modulbeli definíciót hívja, ezért ha a mutató képlete később megváltozik, elegendő a metrics.py-ben módosítani.

Azonos szemantika három felületen. Egy mutató közös modulba emelése szükséges, de nem elégséges: a notebook, CLI és jelentés azonos bemeneti adatkészletet, azonos verziójú modult és azonos konfigurációt is használjon. Külön teszt igazolja, hogy a három felület ugyanazt a total_minor vektort adja, ne csupán hasonló összegzést.

Az nbconvert --to script export nem bizonyítja a reprodukálható futást. A Quarto-renderhez a választott formátum (HTML vagy PDF) külön előfeltételei tartozhatnak, PDF-nél például megfelelő TeX-rendszer; a leírásban ne ígérjünk formátumfüggetlen sikert ellenőrzés nélkül.

4.3.5 Feladatok

  1. Értelmezés. Az nbconvert --to script kimenete miért nem helyettesíti a northstar/metrics.py modult? Nevezzen meg egy konkrét notebook-jellemzőt, amely az exportált scriptben rejtett állapotot okozhat.

  2. Megvalósítás. Írjon average_total_by_segment(orders, item_aggr, customers) függvényt a modulba, amely a customers.csv segment oszlopával csoportosítva adja meg a total_minor rendelésenkénti átlagát. A notebook jelenítse meg a csoportátlagokat, a CLI pedig kapjon --summary kapcsolót. Bemenet: orders.csv, order_items.csv, customers.csv; az utóbbi egy sora egy ügyfél, customer_id kulccsal, segment értéke consumer vagy business.

  3. Hibadiagnózis. A notebookban valaki ezt a cellát futtatta:

    merged = orders.merge(
        items, on="order_id", how="left", validate="one_to_one"
    )

    és ValueError keletkezett. Melyik oldal nem egyedi, miért, és melyik modullal kell elkerülni a hibát? Ellenőrizze az 4.2-ben tanult nunique() és duplicated().sum() kifejezésekkel is.

  4. Megváltozott feltétel. Az új orders.csv változatból hiányzik a basket_minor oszlop; a szerződés szerint a tételekből származtatott derived_basket_minor a mérvadó. Módosítsa a compute_order_totals függvényt úgy, hogy a basket_minor hiánya esetén a derived_basket_minor kerüljön a total_minor képletébe, és a különbség-ellenőrzés csak akkor fusson, ha a basket_minor oszlop jelen van. Mi lesz az új invariant a szállítási díjra?

4.3.6 Megoldások és indoklás

  1. Az nbconvert --to script a notebook celláit sorrendben egy .py fájlba írja, de nem hoz létre paraméterezhető interfészt. A notebook tartalmazhat globális változókat, például egy pd.set_option("display.max_rows", 5) hívást vagy egy korábbi cellában definiált shipping_fee_minor változót; ezek az exportált scriptben is globálisan megmaradnak, és ha a scriptet importálják vagy más sorrendben futtatják, az eredmény megváltozhat. A modul ezzel szemben a compute_order_totals függvényt egyértelmű paraméterlistával definiálja, és nem függ a notebook celláinak sorrendjétől.

  2. A modulba kerülő függvény:

    def average_total_by_segment(
        totals: pd.DataFrame, customers: pd.DataFrame
    ) -> pd.DataFrame:
        required = {"customer_id", "total_minor"}
        if not required.issubset(totals.columns):
            raise ValueError("A totals táblából hiányzik a customer_id vagy total_minor")
        joined = totals.merge(
            customers[["customer_id", "segment"]],
            on="customer_id",
            how="left",
            validate="many_to_one",
        )
        return (
            joined.groupby("segment", as_index=False)
            .agg(
                order_count=("order_id", "count"),
                average_total_minor=("total_minor", "mean"),
                total_sum_minor=("total_minor", "sum"),
            )
        )

    A notebookban:

    from northstar.metrics import average_total_by_segment
    
    customers = pd.read_csv("customers.csv")
    average_total_by_segment(totals, customers)

    A CLI --summary kapcsolója hívja ugyanezt, és kiírja a csoportátlagokat. A csoportátlagok súlyozott átlaga meg kell egyezzen a teljes total_minor átlaggal, ha a csoportok lefedik a táblát; ez a felület konzisztenciájának ellenőrző invariantja.

  3. Az items tábla tételszintű, tehát az order_id benne nem egyedi: egy rendeléshez több tételsor tartozik. A validate="one_to_one" pontosan ezt a sérülést jelzi a jobb oldali táblán. Az ellenőrzés:

    print(len(items), items["order_id"].nunique())
    print(int(items["order_id"].duplicated().sum()))

    Az első sor mutatja, hogy a sorok száma nagyobb az egyedi rendelések számánál; a második a duplikált order_id sorok számát adja. A helyes megoldás az aggregate_items(items) hívása, majd a visszaadott rendelésszintű táblán a validate="one_to_one" join; így a jobb oldal már egyedi.

  4. A módosított függvény:

    def compute_order_totals(orders, item_aggr):
        if "basket_minor" in orders.columns:
            merged = orders.copy()
            has_basket = True
        else:
            merged = orders.copy()
            has_basket = False
    
        merged = merged.merge(
            item_aggr, on="order_id", how="left", validate="one_to_one"
        )
        if has_basket:
            merged["basket_check_minor"] = (
                merged["derived_basket_minor"] - merged["basket_minor"]
            )
            merged["total_minor"] = (
                merged["basket_minor"] + merged["shipping_fee_minor"]
            )
        else:
            merged["total_minor"] = (
                merged["derived_basket_minor"] + merged["shipping_fee_minor"]
            )
        return merged

    Az új invariant: a shipping_fee_minor összege továbbra is egyezik a rendeléstábla shipping_fee_minor összegével, mert a join továbbra is one-to-one. A kosárérték most a tételekből származik; a régi basket_minor ellenőrzés csak akkor alkalmazható, ha az oszlop létezik.

4.3.7 Gyakori tévedés és ellenpélda

A leggyakoribb tévedés: „A notebookból nbconvert --to script-tel előállított fájl már karbantartható program.” Ez nem igaz. Az export nem hoz létre modul- és függvényhatárt, és a notebook globális cellái megmaradnak. Ellenpélda: tegyük fel, hogy a notebook harmadik cellája pd.set_option("display.max_rows", 5) hívást tartalmaz, a negyedik pedig egy basket_minor = orders["basket_minor"] * 1 sorral „teszteli” a kerekítést. A két cella exportálva is globális hatású marad; ha később a scriptet egy másik munkafolyamat importálja, a display-beállítás és a véletlen globális név mellékhatássá válik. A tiszta modul ezzel szemben csak a paraméterekből dolgozik, nincs rejtett állapota.

Önellenőrzés: hány helyen szerepel a total_minor = basket_minor + shipping_fee_minor képlet az ideális felépítésben? A válasz: pontosan egy helyen, a northstar/metrics.py modulban; a notebook, a CLI és a Quarto-chunk csak importálja ezt a definíciót.

4.3.8 Rövid összefoglaló

A notebookból kiemelt northstar.metrics modul egy helyen tartalmazza a rendelésszintű mutatót, így a total_minor = basket_minor + shipping_fee_minor definíciót a notebook, a CLI és a Quarto jelentés is ugyanazzal a függvénnyel számolja. Az aggregate_items előbb rendelésszintre hozza a tételeket, a compute_order_totals pedig one-to-one joinnal kerüli el a szállítási díj duplázódását. A CLI if __name__ == "__main__" blokkja és az argparse teszi scriptté a modult; az nbconvert exportja nem helyettesíti a tiszta interface-t. A következő alfejezetben az I. ellenőrző pont a teljes munkaasztalt újra futtatja: modult, notebookot, parancssori hívást és jelentést együtt ellenőrzi.