29.3 Adatteszt és numerikus teszt

29.3.1 Helyzet és induló becslés

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

29.3.2 Séma, invariáns és dimenzió

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 (m,k)(m, k), akkor egy rendelésenkénti összegzés kimenete (n,)(n,) alakú, ahol n≤mn \leq m 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:

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

∑tételsorshipping_fee_minor\sum_{\text{tételsor}} \text{shipping\_fee\_minor}

összeg ilyenkor nagyobb lehet, mint a rendelésszintű

∑rendelésshipping_fee_minor,\sum_{\text{rendelés}} \text{shipping\_fee\_minor},

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.

29.3.3 Tolerancia, nem bitazonosság

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:

|actual−expected|≤atol+rtol⋅|expected|. |\text{actual} - \text{expected}| \le atol + rtol \cdot |\text{expected}|.

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ül

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

29.3.4 Fixture, property és regression test

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, items

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

29.3.5 Teljes minimális munkapélda

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 2+3x2 + 3x 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 -q

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

29.3.6 Eredmény értelmezése és önellenőrzés

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:

  1. A tesztem a szerződést ellenőrzi, vagy a saját függvényem belső kódját másolja?
  2. A tűrés mértékegysége illik az adathoz?
  3. Az időtesztben a címke rendelkezésre állása is feltétel, nem csak a rendelési idő?
  4. A zöld teszt után milyen statisztikai állítást nem tehetek meg?

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.

29.3.7 Feladatok

  1. É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.

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

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

A futtatás assert 2.0 == 1.9999999999999998 hibát ad. Magyarázd el, mi a hiba, és add meg a helyes tesztet.

  1. Megváltozott feltétel. A labor adataihoz több pénznem kerül: 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.

29.3.8 Megoldások és indoklás

  1. A séma-teszt csak a típust ellenőrzi; a -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.

  1. Egy lehetséges megoldás:
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.

  1. A hiba oka a lebegőpontos kerekítés. A == 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.

  1. Pénznemenkénti invariáns-teszt:
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.

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

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.

29.3.10 Rövid összefoglaló

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.