A Konfiguráció és futási napló alfejezetben a
kutatási notebookból már kiemeltünk egy konfigurációval és
futásazonosítóval ellátott szkriptet. Most a kérdés az, hogyan
ellenőrizzük a features.py számításait anélkül, hogy a
notebook celláit másolnánk le. A labor szerződése három tesztet kér:
bevételmegmaradási invariáns (revenue conservation invariant), időhelyes
feature-ellenőrzés és ismert kis modellpredikció.
Az induló példa legyen két kicsi rendelés. Az ORD-1
rendeléshez két tétel tartozik, line_total_minor = 300 és
450, tehát a szerződés szerint
basket_minor = 750. Az ORD-2 esetén egyetlen
tétel van 999 cent értékben. A
shipping_fee_minor legyen 500 cent mindkét
rendelésnél; ez nem része a kosárnak, és a tételszintű join után sem
szabad megduplázódnia. Az elvárt teszt tehát: a rendelésenkénti
tételösszeg pontosan {ORD-1: 750, ORD-2: 999}, a szállítási
díj rendelésenként egyszer szerepel, és a modell y = 2 + 3x
összefüggésből 14-et jósol x = 4 esetén.
A fejezet projektgyökere továbbra is nslab/, a forrás a
src/northstar/features.py, a tesztek pedig a
tests/ mappába kerülnek. A pytest a Konfiguráció és futási napló alfejezetben már
bevezetett uv run --locked paranccsal futtatható.
A sémateszt (schema test) nem az üzleti tartalmat, hanem a bemenet
alakját ellenőrzi: mely oszlopok léteznek, milyen típusúak, és mely
kulcsok egyediek. Egy adattábla esetén ez gyakran azt jelenti, hogy az
order_id nem lehet üres, és nem ismétlődhet a
rendelés-táblában. A dimenzió fogalmát itt shape-ként használjuk: ha a
bemeneti order_items tábla shape-je
,
akkor egy rendelésenkénti összegzés kimenete
alakú, ahol
az egyedi rendelések száma.
A join invariáns (join invariant) olyan mennyiségi törvény, amelynek a join előtt és után is teljesülnie kell. A Northstar adatszerződés kimondja:
line_total_minor = quantity * unit_price_minor - discount_minor,basket_minor értéke a hozzá tartozó
line_total_minor értékek összege.A szállítási díj kivétel: shipping_fee_minor
rendelésenként egyszer létezik, és nem adódik hozzá a kosárhoz. Ha az
order_items táblát bal oldali joinnal kötjük az
orders táblához, akkor minden tételsor megkapja a rendelés
szállítási díját. A
összeg ilyenkor nagyobb lehet, mint a rendelésszintű
amit a tesztnek tiltania kell. A megfigyelési egység itt a rendelés, a tételszintű join ezt nem változtathatja meg.
A séma szerinti minimális oszlop-ellenőrzés:
REQUIRED_ORDERS_COLUMNS = {
"order_id": "object",
"basket_minor": "int64",
"shipping_fee_minor": "int64",
}
def test_orders_schema(small_orders: pd.DataFrame) -> None:
assert set(REQUIRED_ORDERS_COLUMNS).issubset(small_orders.columns)
for column, dtype in REQUIRED_ORDERS_COLUMNS.items():
assert small_orders[column].dtype == dtype
assert small_orders["order_id"].is_unique
assert small_orders["order_id"].notna().all()Ez a teszt semmit nem mond arról, hogy a basket_minor
helyes-e. Például egy negatív kosárérték is átmegy rajta, ha a dtype
int64. Ezért kell külön invariáns-teszt.
A pénz a Northstar adatokban egész minor unitban van, tehát a
kosár-összehasonlítás egzakt lehet. A lebegőpontos (floating point)
számoknál viszont a bitazonosság rossz elvárás: a 0.1 + 0.2
nem egyenlő 0.3-mal a gépi ábrázolás miatt. A helyes
ellenőrzés abszolút és relatív tűréshatárral (tolerance) történik:
Itt minden mennyiség dimenziótlan a képlet szintjén, de a
gyakorlatban a mértékegység számít: ha az érték centben van, az
atol=0.01 fél centet jelenthet, ami pénzügyi ellenőrzésnél
gyakran elfogadhatatlan. A Lebegőpont, hiányzó érték
és stabil számítás alfejezetben bevezetett stabil számítás elve ide
is átjön: minor unit egésznél ne engedjünk lebegőpontos kerekítést,
modellpredikciónál viszont a pytest.approx vagy a
math.isclose a helyes eszköz.
Példa a kettő közötti különbségre:
import math
assert math.isclose(0.1 + 0.2, 0.3, rel_tol=1e-15, abs_tol=1e-15)
assert (300 + 450) == 750 # egész minor unit, tolerancia nélkülA modellpredikciós tesztnél a lineáris regresszió lebegőpontos
műveleteket végez, ott
np.testing.assert_allclose(pred, 14.0, atol=1e-9) indokolt.
Ez a tűrés numerikus közelítési hibát kezel, nem üzleti hibahatárt.
A pytest fixture újrafelhasználható, kis bemenetet ad a
teszteknek. A laborhoz két rendelésből és három tételsorból álló
DataFrame elegendő:
import pandas as pd
import pytest
@pytest.fixture
def small_orders() -> tuple[pd.DataFrame, pd.DataFrame]:
orders = pd.DataFrame(
{
"order_id": ["ORD-1", "ORD-2"],
"basket_minor": [750, 999],
"shipping_fee_minor": [500, 500],
"currency": ["EUR", "EUR"],
}
)
items = pd.DataFrame(
{
"order_id": ["ORD-1", "ORD-1", "ORD-2"],
"line_id": [1, 2, 1],
"line_total_minor": [300, 450, 999],
}
)
return orders, itemsA property teszt (property test) itt nem véletlenített property-based
könyvtárat jelent, hanem matematikai tulajdonság ellenőrzését több kis
bemeneten. A compute_basket_totals függvényre ilyen
tulajdonság, hogy a kimenete minden rendelésre pontosan egy értéket ad,
és a kimenet összege megegyezik a bemeneti tételösszegek összegével:
def test_basket_total_property(small_orders: tuple[pd.DataFrame, pd.DataFrame]) -> None:
orders, items = small_orders
result = compute_basket_totals(items)
# minden rendelés egyszer szerepel
assert result.index.is_unique
assert len(result) == orders["order_id"].nunique()
# a tételösszegek total megmarad
assert result.sum() == items["line_total_minor"].sum()A regression test (regression test) ismert bemenetre rögzített, előre
kiszámolt kimenetet őriz. Célja, hogy elkapja a kód későbbi, nem
szándékos változását. Ha a compute_basket_totals korábbi
verziója véletlenül a quantity-t összegezné a
line_total_minor helyett, a szigorúbb szerződésteszt
elbukna. A kézzel rögzített érték ezért a szerződésből származik, nem a
függvény saját kódjából.
A laborban a features.py egyetlen tiszta függvényt
tartalmaz:
# src/northstar/features.py
import pandas as pd
def compute_basket_totals(order_items: pd.DataFrame) -> pd.Series:
"""Rendelésenkénti line_total_minor összeg, nem tartalmazza a szállítást."""
return order_items.groupby("order_id")["line_total_minor"].sum()A tests/test_contracts.py első fele a bevételmegmaradást
és a join invariánst ellenőrzi:
from northstar.features import compute_basket_totals
def test_basket_equals_sum_of_line_totals(
small_orders: tuple[pd.DataFrame, pd.DataFrame],
) -> None:
orders, items = small_orders
result = compute_basket_totals(items)
expected = orders.set_index("order_id")["basket_minor"]
pd.testing.assert_series_equal(result, expected, check_names=False)
def test_shipping_is_not_duplicated_by_item_join(
small_orders: tuple[pd.DataFrame, pd.DataFrame],
) -> None:
orders, items = small_orders
joined = items.merge(
orders[["order_id", "shipping_fee_minor"]],
on="order_id",
how="left",
)
# Egy rendelés egy tételsoronként látja a shippinget, de a konszolidált
# rendelésenkénti összegnek a distinct értékek összegét kell adnia.
per_order_shipping = joined.groupby("order_id")["shipping_fee_minor"].first()
assert per_order_shipping.sum() == orders["shipping_fee_minor"].sum()Az időhelyes feature-teszt egy egyszerű cutoff-szűrőt ellenőriz. A
tanulóhalmaz definíciója a Northstar szerződésből:
order_ts < cutoff és
label_available_at < cutoff. A teszt adatai közé
szándékosan teszünk egy későn érkező címkét, amelynek a tanítóhalmazból
ki kell maradnia:
def test_label_availability_split_excludes_late_label() -> None:
orders = pd.DataFrame(
{
"order_id": ["A", "B", "C"],
"order_ts": pd.to_datetime(
[
"2025-04-28T10:00:00+00:00",
"2025-04-29T10:00:00+00:00",
"2025-05-02T10:00:00+00:00",
]
),
"label_available_at": pd.to_datetime(
[
"2025-04-30T10:00:00+00:00",
"2025-05-05T10:00:00+00:00",
"2025-05-06T10:00:00+00:00",
]
),
}
)
cutoff = pd.Timestamp("2025-05-01T00:00:00+00:00")
train, border, test = split_by_label_availability(orders, cutoff)
assert list(train["order_id"]) == ["A"]
assert list(border["order_id"]) == ["B"]
assert list(test["order_id"]) == ["C"]A split_by_label_availability a Konfiguráció és futási napló splitmanifestjének
kiszolgáló függvénye; itt csak annyit kell tudnunk, hogy a három halmaz
diszjunkt, és a határsávba az a B sor kerül, amelynek
rendelési ideje cutoff előtti, de címkéje cutoff után érkezett meg.
A modellpredikciós teszt ismert, mesterséges összefüggést használ:
import numpy as np
from sklearn.linear_model import LinearRegression
def test_known_linear_model_prediction() -> None:
x = np.array([[0.0], [1.0], [2.0], [3.0]])
y = np.array([2.0, 5.0, 8.0, 11.0])
model = LinearRegression().fit(x, y)
pred = model.predict([[4.0]])[0]
assert pred == pytest.approx(14.0, abs=1e-9)
assert model.intercept_ == pytest.approx(2.0, abs=1e-9)
assert model.coef_[0] == pytest.approx(3.0, abs=1e-9)A y értékek a
képletből jönnek, ezért az elvárt paraméterek ismertek. A teszt azt
igazolja, hogy a modellillesztés numerikusan helyesen számol; nem
bizonyítja, hogy a modell jó üzleti előrejelző a Northstar
rendeléseken.
Futtatás a projektgyökérből:
# macOS/Linux; Windows PowerShellben a sor eleji uv ugyanúgy működik
uv run --locked pytest tests/test_contracts.py -qAz itt közölt kimenet szemléltető; tényleges futtatási jegyzőkönyv csak a labor lokális eredményjegyzékéből nevezhető mértnek.
A test_basket_equals_sum_of_line_totals zöld eredménye
azt jelenti, hogy a compute_basket_totals az adatszerződés
kosárdefinícióját tartja. A
test_shipping_is_not_duplicated_by_item_join a leggyakoribb
one-to-many join hibát fogja meg: ha a shippinget tételsoronként adnánk
össze, akkor az ORD-1 esetén 500 + 500 = 1000
centet kapnánk a helyes 500 helyett. A modellpredikciós
teszt a floating point tűrést ellenőrzi, a 1e-9 abszolút
hiba numerikus kerekítésből fakad, nem modellhibából. A séma-teszt pedig
a dtype-ok és az egyedi kulcs feltételeit védi.
Önellenőrző kérdések:
A numerikus tesztekben az abszolút és relatív tolerancia eltérő problémát old meg; 0 közeli várható értékhez abszolút tolerancia kell. Adatsématesztnél szükséges a hiányzó és duplikált kulcsok tesztje.
Értelmezés. A fenti
test_orders_schema teszt zöld egy olyan táblára, ahol a
basket_minor minden sora negatív. Mondd meg, miért nem
hibás a teszt, és milyen új invariáns-tesztet adnál hozzá a pénzösszegek
nemnegativitásának ellenőrzésére.
Implementálás. Írj
test_basket_totals_match_orders tesztet a
small_orders fixture alapján, amely a
compute_basket_totals kimenetét a small_orders
első DataFrame-jének basket_minor oszlopához hasonlítja. A
teszt ne használja a fixture-tuple indexelésén kívül a
groupby-t.
Hibadiagnózis. A kolléga tesztje így néz ki:
def test_model_predicts():
x = np.array([[0.0], [1.0]])
y = np.array([0.0, 1.0])
m = LinearRegression().fit(x, y)
assert m.predict([[2.0]])[0] == 2.0A futtatás assert 2.0 == 1.9999999999999998 hibát ad.
Magyarázd el, mi a hiba, és add meg a helyes tesztet.
EUR és USD sorok is vannak, de
pénznemkonverzió nincs. A bevételmegmaradási invariáns mostantól csak
pénznemen belül igaz. Írj groupby("currency")-alapú
tesztet, amely mindkét oldalt pénznemenként hasonlítja össze.-12 érték
int64 dtype-pal rendelkezik, tehát átmegy. A hiányzó üzleti
feltétel a nemnegativitás. A javítás:def test_money_columns_are_nonnegative(small_orders):
orders, items = small_orders
for frame in (orders, items):
for column in ("basket_minor", "shipping_fee_minor", "line_total_minor"):
if column in frame:
assert (frame[column] >= 0).all()Indoklás: a séma a dtype-ot, az invariáns a tartalmi tartományt védi.
def test_basket_totals_match_orders(small_orders):
orders, items = small_orders
totals = compute_basket_totals(items)
expected = orders.set_index("order_id")["basket_minor"]
pd.testing.assert_series_equal(totals, expected, check_names=False)Az assert_series_equal a sorrendre érzékeny; ha a
sorrend nem garantált, rendezni kell. Itt a small_orders
fixture rendezett.
==
bitazonosságot vár, a lineáris regresszió belső műveletei azonban
lebegőpontos hibát adnak. Helyes változat:assert m.predict([[2.0]])[0] == pytest.approx(2.0, abs=1e-9)A pytest.approx az abszolút tűréshatárt alkalmazza, és a
teszt így a numerikus közelítési hibát tolerálja.
def test_basket_invariant_per_currency(
small_orders: tuple[pd.DataFrame, pd.DataFrame],
) -> None:
orders, items = small_orders
items_with_currency = items.merge(
orders[["order_id", "currency"]], on="order_id", how="left"
)
by_currency_items = items_with_currency.groupby("currency")[
"line_total_minor"
].sum()
by_currency_orders = orders.groupby("currency")["basket_minor"].sum()
pd.testing.assert_series_equal(by_currency_items, by_currency_orders)Indoklás: a pénznem dimenziót választó oszlop, ezért a csoportosított összegeknek kell egyezniük. Konverzió nélkül a teljes, pénznemet figyelmen kívül hagyó összeg megtévesztő lehet.
A leggyakoribb félreértés: a zöld unit teszt bizonyítja a modell
statisztikai érvényességét. A fenti
test_known_linear_model_prediction csak egy mesterséges,
determinisztikus összefüggést ellenőriz. Ha a valódi Northstar pipeline
szivárgást tartalmaz, például a tesztadat célját használja feature-ként,
akkor a pipeline unit tesztjei zöldek lehetnek, mégis a jövőbeli
rendeléseken az AUC 0.80 helyett 0.50 közelébe eshet. Ez ugyanaz a
tanulság, mint a Feature-szelekció és magas
dimenzió feature-szelekciós összefoglalójában: a független
kiértékelés és a megfelelő időbeli split nem helyettesíthető
kódtesztekkel. A Diagnosztika és robusztus standard
error diagnosztikája sem lesz jobb attól, hogy a modellinterfész
tesztje hiba nélkül fut. A unit teszt a szoftver szerződését igazolja, a
statisztikai érvényességhez külön értékelési terv kell.
A sémateszt a dtype-okat és kulcsokat, a join invariáns a
bevételmegmaradást és a szállítási díj egyszeri beszámítását védi. A
tolerancia a lebegőpontos közelítést kezeli; minor unit egésznél egzakt
összehasonlítás, modellpredikciónál pytest.approx indokolt.
A fixture kis, ismert bemenetet ad, a property teszt a számítás
általános tulajdonságait vizsgálja, a regression test rögzített elvárt
értékeket őriz. A zöld teszt a szoftver szerződését igazolja, nem a
modell statisztikai érvényességét. A következő alkalmazás a kódminőség
és az együttműködő review, ahol ezeket a teszteket a merge előtti
ellenőrzésbe illesztjük.