8.4 II. ellenőrző pont: adatból ellenőrzött feature-tábla

8.4.1 Cél és konkrét helyzet

Ebben az ellenőrző pontban a Northstar lab három tiszta forrásfájljából egyetlen rendelésszintű, modellépítésre alkalmas feature-táblát készítünk. A bemenet:

A megfigyelési egység a kimenetben egy rendelés. Az eredmény egy orders_features.parquet, egy data_quality_report.json, és egy független pandas-ellenőrzés, amely ugyanazt a táblát állítja elő, mint a DuckDB SQL. A feature-tábla csak a rendelés pillanatában ismert oszlopokat tartalmazhatja; a delivery_days, actual_delivery_at, label_available_at és target_late mezőket ki kell zárni, mert azok a későbbi célváltozó meghatározásához tartoznak.

Előzetes becslés: a kimenet pontosan 2400 sorból áll, az order_id 2400 egyedi értéket ad, a customer join nem veszít sort, és a tiszta lab adatban a prediktor oszlopok NULL-száma nulla. Az order_items aggregátumainak rendelésszintű összege meg kell egyezzen az orders.csv basket_minor és quantity_sum értékeivel. Mielőtt tovább olvasol, becsüld meg: ha véletlenül a tételszintű táblát közvetlenül joinolnád a rendelésekhez, hány sor keletkezne a 2400 rendelésből, és hogyan változna a szállítási díj összege?

Az ellenőrző pont feladata az eddigi építőelemek összekapcsolása: a Séma, dtypes és hiányzó értékek–Dátumok, időzónák és kategóriák pandas-séma, a Reláció, NULL és elemzési szemcsézettség–Paraméterezett lekérdezés és lekérdezési terv SQL-join és -aggregáció, valamint a DuckDB: helyi analitikus SQL szerver nélkül DuckDB-fájlscan és Relation viselkedés. Az I. ellenőrző pont: megismételhető kutatói munkaasztal alfejezetben felállított szabály szerint a kimenethez tartozik egy futási jegyzék és kontrollösszeg is, nem elég „ránézésre jó” táblát írni.

8.4.2 Fogalmi alapok: séma, join, NULL és feature-idő

8.4.2.1 Séma

A séma explicit oszlopszerződés: minden oszlophoz megadjuk a nevet, típust, szemcsézettséget és mértékegységet. A CSV beolvasásnál az automatikus típusfelismerés kényelmes, de egy üres mező vagy egy furcsa dátum felboríthatja az utána következő joinokat. A DuckDB read_csv hívásában ezért explicit oszloptípusokat adunk meg. A Parquet fájl magában hordozza a sémát, ezért a kimenet olvasója nem találgathat: a pyarrow.parquet.read_schema megmutatja a rögzített típusokat.

A kimeneti tábla oszlopai:

Oszlop Típus Jelentés
order_id string rendelés egyedi kulcsa, nem predictor
customer_id string ügyfélkulcs, nem predictor
order_ts timestamp predikciós időpont, splithez kell
distance_km float64 szállítási távolság kilométerben
priority_flag int 0/1 prioritásjelző
basket_minor int kosárérték EUR-centben
quantity_sum int összes darabszám a rendelésben
account_age_days int a rendelés napján ismert ügyfélkor napokban
segment, region, acquisition_channel string kategorikus ügyféljellemzők

A basket_minor és a quantity_sum pénz-, illetve darabszám-egysége egész; a distance_km folytonos lebegőpontos érték. A segment, region és acquisition_channel kategóriák, nem rendezett numerikus kódok, tehát stringként maradnak.

8.4.2.2 Join és szemcsézettség

A három forrás három különböző szemcsézettséget képvisel. A customers egy sor ügyfelenként, az orders egy sor rendelésenként, az order_items pedig egy sor tételenként. A cél egy sor rendelésenként. A tételszintű táblát ezért a join előtt rendelésszintre kell aggregálni:

SELECT
    order_id,
    COUNT(*)                        AS item_rows_calc,
    SUM(quantity)                    AS quantity_calc,
    SUM(line_total_minor)            AS basket_minor_calc,
    SUM(quantity * unit_price_minor - discount_minor) AS basket_minor_recomputed
FROM order_items
GROUP BY order_id

Ha ezt a lépést kihagyjuk, a tételtábla 4796 sora a 2400 rendeléssorhoz kapcsolva akár 4796 soros kimenetet adhat, és a rendelésszintű oszlopok, például a shipping_fee_minor vagy a distance_km, tételekként többszöröződnek. A Join-kardinalitás és duplázódó célváltozó alfejezetben látott one-to-many join pontosan ezért volt kiemelt hibaforrás: a szállítási díj egyszer létezik rendelésenként, nem szabad tételszintre szorozni.

8.4.2.3 NULL-szerződés

A tisztaz orders.csv prediktor mezőiben a lab szerződés szerint nincs hiányzó érték. Ezt nem hisszük el, hanem ellenőrizzük. A szabály: a feature-tábla nem tartalmazhat dokumentálatlan NULL-t a prediktor oszlopokban. Ha egy hiányzó distance_km bukkan fel, a szkript ne töltsön be hallgatólagosan nullát vagy átlagot, hanem álljon meg, és írja be a jelentésbe a null_rows > 0 állapotot. A NULL nem technikai kellemetlenség, hanem adatminőségi esemény, amelynek oka vagy dokumentált imputálási döntése kell legyen.

8.4.2.4 Feature-idő

A modell a rendelés pillanatában becsül. A feature-tábla minden sorának ezért van egy feature_time értéke: az order_ts. Az account_age_days kiszámítása:

ao=⌊order_dateo−signup_datec(o)⌋nap a_o = \left\lfloor \text{order\_date}_o - \text{signup\_date}_{c(o)} \right\rfloor_{\text{nap}}

ahol oo a rendelés, c(o)c(o) az ügyfél. A lab szintetikus adatában a signup_date statikus, ezért ez az érték mindig a rendelés előtt ismert. Ha a valós üzleti adatban az ügyfél szegmense vagy régiója időben változna, akkor a joinhoz havi pillanatfelvétel kellene; erről a 4. feladat szól. A későbbi tanító-teszt split további label_available_at szűrést igényel, de az már a modellépítés feladata, nem a feature-tábláé.

8.4.2.5 Kontrollösszeg

A kontrollösszeg egy numerikus oszlop összege a tábla összes sorára:

Sc=∑i=1nxi,c,Nc=#{i∣xi,c nem NULL} S_c = \sum_{i=1}^{n} x_{i,c}, \qquad N_c = \#\{i \mid x_{i,c} \text{ nem NULL}\}

A jelentésbe a basket_minor, quantity_sum, basket_minor_calc, quantity_calc és a discount_minor_total ScS_c értéke kerül. Az invariant: basket_minor összege az orders.csv-ben megegyezik a line_total_minor rendelésszintű összegével az order_items.csv-ben, és ugyanez igaz a quantity_sum és a tételenkénti quantity összegére. A kontrollösszeg nem bizonyítja, hogy minden sor helyes, de azonnal jelzi, ha egy join megdupláz vagy elveszít sorokat. A Parquet fájl SHA-256 lenyomatát is rögzítjük, hogy a átadott fájl később azonosítható legyen.

8.4.2.6 Végrehajtóválasztás

Ugyanazt a logikai műveletet három eszközzel is el lehet végezni:

Szempont DuckDB SQL pandas Polars
Fő erősség deklaratív join + aggregáció fájlból interaktív oszlopkezelés, dtype-kontroll lazy terv, oszlopos kiértékelés
Előzmény Reláció, NULL és elemzési szemcsézettség–DuckDB: helyi analitikus SQL szerver nélkül Séma, dtypes és hiányzó értékek–Dátumok, időzónák és kategóriák Polars lazy terv és streaming korlát
Szerep itt fő előállító független ellenőrzés opcionális harmadik

A DuckDB-t választjuk fő előállítónak, mert a fájlokat közvetlenül, pandas köztes DataFrame nélkül olvassa, és a COPY TO utasítás egy lépésben ír Parquet-et. A pandas az ellenőrzés nyelve, mert a könyv fő táblás felülete; ha két különböző implementáció azonos kimenetet ad, csökken a fordítási hiba esélye. A Polars ebben a fejezetben nem előfeltétel, de a Polars lazy terv és streaming korlát lazy terve ugyanehhez a kontrollhoz használható.

8.4.3 Teljes minimális munkapélda

A lab gyökerében hozzuk létre a make_orders_features.py szkriptet. A fájlokat a lab gyökeréből olvassuk, a kimenetet az artifacts/ mappába írjuk. A kód célja: a három CSV beolvasása explicit sémával, a tételtábla aggregálása, a feature-tábla összeállítása, a kontrollok kiszámítása, a Parquet és a JSON jelentés kiírása, végül egy független pandas-újraszámolás, amely assert_frame_equal hívással veti össze a két kimenetet.

import hashlib
import json
from pathlib import Path

import duckdb
import pandas as pd
import pyarrow.parquet as pq
from pandas.testing import assert_frame_equal

ROOT = Path(__file__).resolve().parent
ARTIFACTS = ROOT / "artifacts"
ARTIFACTS.mkdir(exist_ok=True)
PARQUET = ARTIFACTS / "orders_features.parquet"
REPORT = ARTIFACTS / "data_quality_report.json"

# --- 1. DuckDB: explicit séma és main query -----------------------------
con = duckdb.connect()

con.execute("""
CREATE OR REPLACE TEMP TABLE customers_raw AS
SELECT *
FROM read_csv('customers.csv', columns = {
    'customer_id': 'VARCHAR',
    'signup_date': 'DATE',
    'segment': 'VARCHAR',
    'region': 'VARCHAR',
    'acquisition_channel': 'VARCHAR'
});
""")

con.execute("""
CREATE OR REPLACE TEMP TABLE orders_raw AS
SELECT *
FROM read_csv('orders.csv', columns = {
    'order_id': 'VARCHAR',
    'customer_id': 'VARCHAR',
    'order_ts': 'TIMESTAMP',
    'quantity_sum': 'BIGINT',
    'item_rows': 'BIGINT',
    'basket_minor': 'BIGINT',
    'shipping_fee_minor': 'BIGINT',
    'currency': 'VARCHAR',
    'distance_km': 'DOUBLE',
    'priority_flag': 'TINYINT'
});
""")

con.execute("""
CREATE OR REPLACE TEMP TABLE items_raw AS
SELECT *
FROM read_csv('order_items.csv', columns = {
    'order_id': 'VARCHAR',
    'line_id': 'BIGINT',
    'product_id': 'VARCHAR',
    'quantity': 'BIGINT',
    'unit_price_minor': 'BIGINT',
    'discount_minor': 'BIGINT',
    'line_total_minor': 'BIGINT',
    'currency': 'VARCHAR'
});
""")

con.execute("""
CREATE OR REPLACE TEMP TABLE item_agg AS
SELECT
    order_id,
    COUNT(*) AS item_rows_calc,
    SUM(quantity) AS quantity_calc,
    SUM(line_total_minor) AS basket_minor_calc,
    SUM(quantity * unit_price_minor - discount_minor) AS basket_minor_recomputed,
    SUM(discount_minor) AS discount_minor_total,
    SUM(quantity * unit_price_minor) AS total_list_minor
FROM items_raw
GROUP BY order_id;
""")

con.execute("""
CREATE OR REPLACE TEMP TABLE orders_features AS
SELECT
    o.order_id,
    o.customer_id,
    o.order_ts,
    o.distance_km,
    o.priority_flag,
    o.basket_minor,
    o.quantity_sum,
    date_diff('day', c.signup_date, CAST(o.order_ts AS DATE)) AS account_age_days,
    c.segment,
    c.region,
    c.acquisition_channel
FROM orders_raw o
INNER JOIN customers_raw c USING (customer_id)
INNER JOIN item_agg i USING (order_id)
WHERE o.currency = 'EUR'
  AND o.distance_km IS NOT NULL;
""")

con.execute(
    f"COPY (SELECT * FROM orders_features ORDER BY order_id) "
    f"TO '{PARQUET}' (FORMAT PARQUET, COMPRESSION ZSTD);"
)

# --- 2. A jelentés kontrolljai ----------------------------------------
source_orders = con.sql("""
    SELECT COUNT(*) AS n,
           SUM(basket_minor) AS basket_sum,
           SUM(quantity_sum) AS q_sum,
           SUM(shipping_fee_minor) AS shipping_sum
    FROM orders_raw
""").fetchone()

final_rows = con.sql("""
    SELECT COUNT(*) AS n,
           COUNT(DISTINCT order_id) AS unique_order_ids,
           SUM(basket_minor) AS basket_sum,
           SUM(quantity_sum) AS q_sum
    FROM orders_features
""").fetchone()

item_totals = con.sql("""
    SELECT SUM(basket_minor_calc) AS basket_calc,
           SUM(quantity_calc) AS q_calc,
           SUM(basket_minor_recomputed) AS basket_recomputed
    FROM item_agg
""").fetchone()

lost_customer_join = con.sql("""
    SELECT COUNT(*) FROM orders_raw o
    WHERE NOT EXISTS (SELECT 1 FROM customers_raw c WHERE c.customer_id = o.customer_id)
""").fetchone()[0]

lost_item_join = con.sql("""
    SELECT COUNT(*) FROM orders_raw o
    WHERE NOT EXISTS (SELECT 1 FROM item_agg i WHERE i.order_id = o.order_id)
""").fetchone()[0]

# --- 3. pandas független ellenőrzés ------------------------------------
customers = pd.read_csv(
    ROOT / "customers.csv",
    dtype={"customer_id": "string", "segment": "string",
           "region": "string", "acquisition_channel": "string"},
    parse_dates=["signup_date"],
)
orders = pd.read_csv(
    ROOT / "orders.csv",
    dtype={"order_id": "string", "customer_id": "string",
           "currency": "string", "priority_flag": "int64",
           "distance_km": "float64"},
)
orders["order_ts"] = pd.to_datetime(orders["order_ts"], utc=True)

items = pd.read_csv(
    ROOT / "order_items.csv",
    dtype={"order_id": "string", "line_id": "int64",
           "product_id": "string", "quantity": "int64",
           "unit_price_minor": "int64", "discount_minor": "int64",
           "line_total_minor": "int64", "currency": "string"},
)
item_agg_pd = items.groupby("order_id").agg(
    item_rows_calc=("order_id", "size"),
    quantity_calc=("quantity", "sum"),
    basket_minor_calc=("line_total_minor", "sum"),
)
feat_pandas = (
    orders
    .merge(customers[["customer_id", "signup_date", "segment",
                      "region", "acquisition_channel"]],
           on="customer_id", how="inner")
)
feat_pandas = feat_pandas[
    feat_pandas["order_id"].isin(item_agg_pd.index)
]
feat_pandas["account_age_days"] = (
    feat_pandas["order_ts"].dt.tz_localize(None).dt.normalize()
    - feat_pandas["signup_date"]
).dt.days

feat_pandas = feat_pandas[[
    "order_id", "customer_id", "order_ts", "distance_km",
    "priority_flag", "basket_minor", "quantity_sum",
    "account_age_days", "segment", "region", "acquisition_channel"
]].sort_values("order_id").reset_index(drop=True)

duckdb_feat = pd.read_parquet(PARQUET, engine="pyarrow")

controls_passed = (
    source_orders[0] == final_rows[0]
    and final_rows[1] == final_rows[0]
    and source_orders[1] == item_totals[0]
    and source_orders[2] == item_totals[1]
    and item_totals[0] == item_totals[2]
    and lost_customer_join == 0
    and lost_item_join == 0
)

# --- 4. A pandas és a DuckDB kimenet egyezése ---------------------------
compare_cols = feat_pandas.columns.tolist()
assert_frame_equal(
    duckdb_feat[compare_cols].reset_index(drop=True),
    feat_pandas,
    check_dtype=False,
    check_exact=False,
    rtol=1e-13,
    atol=1e-9,
)

# --- 5. A jelentés és a hash kiírása ------------------------------------
report = {
    "generated_at": pd.Timestamp.now(tz="UTC").isoformat(),
    "input_files": ["customers.csv", "orders.csv", "order_items.csv"],
    "granularity": "one row per order",
    "output_file": str(PARQUET.name),
    "output_row_count": int(final_rows[0]),
    "unique_order_ids": int(final_rows[1]),
    "lost_customer_join": int(lost_customer_join),
    "lost_item_join": int(lost_item_join),
    "controls_passed": bool(controls_passed),
    "control_totals": {
        "orders_basket_minor": int(source_orders[1]),
        "items_basket_minor_calc": int(item_totals[0]),
        "items_basket_minor_recomputed": int(item_totals[2]),
        "orders_quantity_sum": int(source_orders[2]),
        "items_quantity_calc": int(item_totals[1]),
    },
    "parquet_sha256": hashlib.sha256(PARQUET.read_bytes()).hexdigest(),
    "schema": {field.name: str(field.type)
               for field in pq.read_schema(PARQUET)},
}
REPORT.write_text(json.dumps(report, indent=2), encoding="utf-8")
print("controls_passed =", controls_passed)
print("parquet_sha256 =", report["parquet_sha256"])

Futtatás a lab projektkörnyezetben, munkakönyvtár: lab/:

# POSIX shell és Windows PowerShell esetén is:
uv sync --locked
uv run python make_orders_features.py

Ha a uv.lock még nem létezik, előbb az I. ellenőrző pont: megismételhető kutatói munkaasztal alfejezetben megismert módon uv lock, majd a fenti uv sync --locked hívás jön. A kód itt bemutatott kimenete szemléltető: e fejezet írásakor nem futtattunk Python-kódot, ezért a konkrét összeg és hash a tanuló saját futásából származik.

8.4.4 Eredmény értelmezése és kézi kontroll

A fenti szkript várható logikája a lab szerződés alapján: output_row_count = 2400, unique_order_ids = 2400, lost_customer_join = 0, lost_item_join = 0, és a controls_passed értéke True. A szemléltető JSON innentől csak a formátumot mutatja; a számok a lab futása után lesznek mértek:

{
  "granularity": "one row per order",
  "output_row_count": 2400,
  "unique_order_ids": 2400,
  "lost_customer_join": 0,
  "lost_item_join": 0,
  "controls_passed": true
}

Kézi kontrollként vegyünk egyetlen rendelést három tétellel. Legyen a rendelés basket_minor = 410, shipping_fee_minor = 500, és a tételek:

Az összeg: quantity_calc = 5, basket_minor_calc = 410, tehát a rendelésszintű basket_minor egyezik. Ha a tételtáblát aggregálás nélkül joinolnánk, a három tétel miatt három sor keletkezne, és a shipping_fee_minor összege 3 * 500 = 1500 lenne a helyes 500 helyett. A kontrollösszeg ezért azonnal elárulja a hibás join-kardinalitást.

A pandas ellenőrzés nem csupán a kontrollösszegeket nézi, hanem a teljes 11 oszlopos táblát összehasonlítja a DuckDB Parquet kimenetével, order_id szerint rendezve. Az egész számlálóknál a tolerancia nulla, a lebegőpontos distance_km-nél rtol=1e-13 és atol=1e-9 engedélyezett, mert a két motor a lebegőpontos műveletek sorrendjében kismértékben eltérhet. A check_dtype=False azért szerepel, mert a pandas category vagy int64 és a DuckDB INTEGER közötti reprezentációs különbség nem jelent tartalmi hibát.

A data_quality_report.json a séma mellett rögzíti a Parquet SHA-256 lenyomatát is. Így később bárki ellenőrizheti, hogy a fájl az átadás óta nem változott. Ez az I. ellenőrző pont: megismételhető kutatói munkaasztal futási jegyzékkel felállított elv Parquet-szintű megfelelője.

8.4.5 Feladatok

  1. Értelmezés. A fenti háromtételes kézi példában mi a quantity_calc, basket_minor_calc, discount_minor_total és a tételszintű shipping_fee_minor összege, ha valaki az order_items táblát közvetlenül joinolja, aggregálás nélkül? Melyik rendelésszintű érték romlik el, és miért?

  2. Implementálás. Írj DuckDB SQL lekérdezést, amely egyetlen rendelésre a tételsorokból kiszámítja a mennyiséggel súlyozott átlagos egységárat:

    p‾o=∑jqjpj∑jqj, \bar p_o = \frac{\sum_{j} q_j p_j}{\sum_{j} q_j},

    ahol qjq_j a tétel darabszáma, pjp_j az egységár EUR-centben. A nevező biztosítására használj NULLIF-et, és a háromtételes példán ellenőrizd, hogy az eredmény 86.

  3. Hibakeresés. A jelentésben lost_item_join = 5 és controls_passed = false jelenik meg, miközben a lost_customer_join = 0. Mi lehet a hiba forrása, és milyen lekérdezéssel lokalizálod pontosan az öt hiányzó rendelést? A tisztaz orders.csv 2400 sora ellenére a kimenet csak 2395 sor.

  4. Megváltozott feltétel. Új forrás érkezik: customers_monthly_snapshot.csv oszlopai customer_id, snapshot_month, segment, region, acquisition_channel, ahol snapshot_month minden hónap első napja. Az ügyfél szegmense hónapról hónapra változhat. Hogyan módosul a join, hogy minden rendelés a saját rendelési hónapjában érvényes szegmenst kapja? Fogalmazd meg a feltételt, és mondd meg, mi történik, ha egy ügyfélnek hiányzik a snapshotja abban a hónapban.

  5. Transzfer. A pandas ellenőrző részben cseréld le az isin szűrést egy merge(..., how="left", indicator=True) alapú ellenőrzésre. Melyik _merge kategória darabszámának kell nullának lennie, és hogyan viszed be ezt a report JSON-ba külön pandas_lost_item_join mezőként?

8.4.6 Megoldások és indoklás

1. megoldás. A tételszintű táblában a három sor quantity_calc összege 2 + 2 + 1 = 5, a basket_minor_calc összege 200 + 150 + 60 = 410, a discount_minor_total = 0 + 10 + 10 = 20. Ha a táblát aggregálatlanul joinoljuk a rendeléshez, a rendelésszintű shipping_fee_minor három soron is megjelenik, így az összege 1500 lesz a valódi 500 helyett. Ez a szállítási díj többszöröződése; a basket_minor a join előtti rendelésértékkel maradna, de ha tévesen tételenként is hozzáadnánk, akkor 410 + 1500 = 1910 hamis kosárösszeg keletkezne. A hiba gyökere a one-to-many join kardinalitásának figyelmen kívül hagyása.

2. megoldás.

SELECT
    oi.order_id,
    ROUND(
        SUM(oi.quantity * oi.unit_price_minor)
        / NULLIF(SUM(oi.quantity), 0),
        2
    ) AS weighted_unit_price_minor
FROM order_items oi
WHERE oi.order_id = 'O1'
GROUP BY oi.order_id;

A háromtételes példában a számláló 2*100 + 2*80 + 1*70 = 430, a nevező 5, így az eredmény 86.00. A NULLIF arra az elvi esetre véd, ha a nevező nulla; ilyenkor az osztás NULL-t ad, amelyet a jelentés null-szűrése megfog. A ROUND itt a megjelenítést és a következő összehasonlítást stabilizálja, de a nyers arányt is érdemes megtartani.

3. megoldás. A lost_item_join = 5 azt jelenti, hogy öt rendeléshez nincs egyetlen tételsor sem az order_items.csv-ben, vagy az order_id értékük eltér például szóköz vagy kis/nagybetű miatt. A lokalizáló anti join:

SELECT o.order_id, o.customer_id, o.order_ts
FROM orders_raw o
LEFT JOIN item_agg i USING (order_id)
WHERE i.order_id IS NULL
ORDER BY o.order_id;

Ez pontosan az öt hiányzó rendelést adja. Ha az order_items.csv-ben ezek az azonosítók más formában szerepelnek, a hiba sztring-összehasonlítási eltérés; ha egyáltalán nincsenek ott, akkor adathiány. A controls_passed = false azért következik be, mert a basket_minor és a quantity_sum összege a 2395 soros kimenetben kisebb, mint a 2400 soros forrásban. A javítás nem a kontroll kikapcsolása, hanem a hiányzó tételek pótlása vagy a rendelések explicit kizárása dokumentált döntéssel.

4. megoldás. A havi snapshot joinja:

SELECT ...
FROM orders_raw o
INNER JOIN customers_monthly_snapshot cs
    ON cs.customer_id = o.customer_id
   AND cs.snapshot_month = CAST(date_trunc('month', o.order_ts) AS DATE)

A date_trunc('month', o.order_ts) a rendelés időbélyegét a hónap első napjára kerekíti; DATE-re castolva egyezhet a snapshot_month oszloppal. A feltétel biztosítja, hogy a rendelés pillanatában érvényes szegmenst használjuk. Ha egy ügyfélnek az adott hónapban hiányzik a snapshotja, az INNER JOIN eldobja a rendelést, és a jelentésben új lost_snapshot_join kontroll jelenik meg. Ilyenkor vagy LEFT JOIN-nal jelezzük a hiányt és dokumentált alapértelmezést adunk, vagy a snapshot-előállítást javítjuk. A csendes INNER JOIN-nal való eldobás nem elfogadható audit nélkül.

5. megoldás. A pandas ellenőrzés:

check = (
    orders[["order_id"]]
    .merge(item_agg_pd.reset_index()[["order_id"]],
           on="order_id", how="left", indicator=True)
)
lost = (check["_merge"] == "left_only").sum()
report["pandas_lost_item_join"] = int(lost)

A left_only sorok azok, amelyek az orders táblában szerepelnek, de az aggregált tételtáblában nincsenek. Ennek a számnak nullának kell lennie. Ha nem nulla, a pandas és a DuckDB lost_item_join értékének is egyeznie kell; az egyezés hiánya arra utal, hogy a két implementáció bemenete vagy szűrése eltér.

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

A leggyakoribb tévedés ezen az ellenőrző ponton az, hogy „a DuckDB és a pandas ugyanazt adta, tehát a mutató helyes”. A két végrehajtó egyezése csak a fordítási hibát csökkenti, a mutató definícióját nem igazolja. Ha mindkét implementáció azt a hibás szabályt használja, hogy a basket_minor-hoz minden tétel után hozzáadja a shipping_fee_minor-t, a két kimenet továbbra is egyezni fog, csak mindkettő hamis lesz. Ez a Paraméterezett lekérdezés és lekérdezési terv alfejezetben látott EXPLAIN-csapda rokona: a terv és a determinisztikus egyezés a végrehajtást írja le, nem a definíció helyességét. A védőháló a független szemantikai invariant: a basket_minor a line_total_minor összege, a shipping_fee_minor pedig rendelésszintű, tehát nem tartozik a kosárértékhez. Ezt a lab ADATOK.md szerződése rögzíti, és a kontrollösszeg e szerződés ellen dolgozik.

8.4.8 Önellenőrzés

Mielőtt továbblépnél, tedd fel magadnak a következő kérdéseket. A kimeneti tábla minden sora pontosan egy rendelés? A customer_id join után nincs duplikált order_id? A prediktor oszlopok NULL-száma nulla, vagy ha nem, a jelentés dokumentálta? A basket_minor és a quantity_sum összege egyezik az order_items aggregátumával? A delivery_days, actual_delivery_at, label_available_at és target_late oszlopok hiányoznak a feature-táblából? A Parquet SHA-256 lenyomata rögzítve van? Ha bármelyik válasz nem, az ellenőrző pont még nincs kész.

A feature-tábla minden mezőjéről igazolni kell, hogy a predikció pillanatában hozzáférhető volt. A feature „as-of” ideje és a tanítócímke megérkezésének ideje külön időtengely: egy késve rendelkezésre álló címke a releváns cutoff előtt nem használható tanításhoz.

8.4.9 Rövid összefoglaló

A II. ellenőrző pont rendelésszintű, időhelyes feature-táblát állít elő három Northstar forrásból. A tételtáblát a join előtt aggregáljuk, hogy a szállítási díj és a rendelésszintű összegek ne duplázódjanak. A DuckDB explicit CSV-sémát, inner joinokat és Parquet-kimenetet ad; a kontrollösszegek a basket_minor, a quantity_sum és a tételaggregátumok egyezését ellenőrzik. Egy független pandas implementáció a teljes 11 oszlopos táblát újraszámolja, és toleranciás keret-összehasonlítást végez. A jelentés rögzíti a sor- és kulcsszámot, a joinveszteséget, a NULL-állapotot és a fájl SHA-256 lenyomatát. A következő alkalmazás a Tanítás, validáció és érintetlen teszt modellépítés: ebből az érintetlen feature-táblából kell időhelyes tanító-teszt splitet készíteni, a célváltozót csak a split után csatolva.