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:
orders.csv: egy sor egy rendelés; 2400 sor az
alapseeddel. Rendelésszintű mezői között van customer_id,
order_id, basket_minor,
shipping_fee_minor, promised_days=7 és a
rendelés időpontja.order_items.csv: egy sor egy rendelési tétel; 4796 sor
az alapseeddel. Kulcsa (order_id, line_id). A tétel sora
tartalmazza a
line_total_minor = quantity * unit_price_minor - discount_minor
értéket EUR-centben.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:
shipping_fee_minor összege egyenlő a
bemeneti orders.csv shipping_fee_minor
összegével.basket_minor mezővel, tehát az eltérés pontosan 0.A mutatót egyetlen helyen definiáljuk:
ahol
a rendelések indexe,
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:
ahol az -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 . 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.
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:
from northstar.metrics import compute_order_totalsHa 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.
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 mergedA 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.csvSzemlé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.qmdSzemlé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.
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
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.
É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.
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.
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.
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?
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.
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.
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.
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 mergedAz ú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.
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.
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.