6.1 Séma, dtypes és hiányzó értékek

6.1.1 Cél és konkrét helyzet

A Northstar labor data/raw/orders_raw.csv fájlja szándékosan szennyezett rendelési adat. Egy sor egy rendelés (order), a kulcsnak szánt mező az order_id. A laborleírás szerint a fájl 2405 sort és 15 oszlopot tartalmaz, és négy szándékos hibacsoportot rejt: öt duplikált sor, két hiányzó distance_km, egy negatív basket_minor és egy olyan customer_id, amely nem létezik a customers.csv-ben.

Ebben az alfejezetben a feladat az, hogy ne a pandas automatikus típusfelismerésére bízzuk az adat értelmezését, hanem explicit sémával töltsük be, és mezőnként hibajegyzéket készítsünk. A kimenet három dolog: a betöltött DataFrame, egy mezőnkénti sémajelentés, és egy javítási szabálygyűjtemény, amely nem felülírja, hanem hibakóddal elkülöníti a problémás sorokat. Előzetes becslés: 2405 sor, a fenti négy hibacsoport darabszámai, valamint az, hogy a hibás sorok nem átfedőek. A futtatott kimenetet itt „elméletileg várt” jelöléssel adjuk; a lab tényleges futása a manifesttel ellenőrizhető.

6.1.2 Megfigyelés, változó és a mérés szintje

Adattáblában egy sor egy megfigyelés (observation), egy oszlop pedig egy változó (variable). A distance_km numerikus vagy kvantitatív változó: két érték összege, átlaga vagy különbsége értelmes, mértékegysége km. A currency ezzel szemben nominális vagy kategorikus változó: nem rendezhető nagyság szerint, és nincs értelmes számtani középérték. A region típusú oszlop ugyancsak nominális. Numerikus oszlopoknál a séma része a megengedett tartomány; például a basket_minor egész EUR-cent, és a labor-szerződés szerint nem lehet negatív, a distance_km-nek 0-nál nagyobbnak kell lennie. Kategorikus oszlopnál viszont a séma a megengedett kategóriák halmaza: currency itt kizárólag EUR.

Egy kis háromsoros példán látható a különbség:

order_id customer_id basket_minor distance_km currency
A1 C1 150 hiány EUR
A2 hiány érvénytelen szöveg -2 EUR
A3 C1 hiány 10 EUR

A basket_minor-t összegezhetjük: 150 + ? + ? csak a hiányok megoldása után értelmes. A currency-t nem tudjuk „átlagolni”, legfeljebb azt kérdezhetjük, hány sor EUR.

6.1.3 Explicit séma: read_csv, dtype és parse_dates

A pandas pd.read_csv alapértelmezésben először mintát olvas, és oszloponként megtippeli a típust. Ez csendes kockázat: ha egy oszlop később kap egy hiányzó értéket, megváltozhat a dtype, és a kód más ágon fut. Az explicit séma (explicit schema) ehelyett előre rögzíti minden oszlop nevét, típusát, mértékegységét és érvényes tartományát.

A pandas 2.2.3-ban a hagyományos int64 nem tud hiányzó értéket tárolni, ezért a pandas a hiányossá vált egész oszlopot gyakran float64-re konvertálja. A nullable típusok ezt oldják meg: az Int64, Float64, string és boolean dtype a hiányt egységesen a pd.NA jelölővel tárolja. A category dtype kategorikus oszlophoz való: memóriában a szintkészletet tárolja, és kódszámokkal hivatkozik a sorokra. A parse_dates paraméter az ISO-szövegeket datetime64[ns, UTC] típusú időbélyeggé alakítja, mert a CSV-ben explicit +00:00 offset szerepel.

A tisztaz orders.csv-hez tehát ilyen betöltés tartozhat:

import pandas as pd
from pathlib import Path

# Notebook-példa: a munkakönyvtár a lab projektgyökere.
ROOT = Path.cwd().resolve()
RAW = ROOT / "data" / "raw"

orders = pd.read_csv(
    RAW / "orders.csv",
    dtype={
        "order_id": "string",
        "customer_id": "string",
        "promised_days": "Int64",
        "quantity_sum": "Int64",
        "item_rows": "Int64",
        "basket_minor": "Int64",
        "shipping_fee_minor": "Int64",
        "currency": "category",
        "distance_km": "Float64",
        "priority_flag": "Int64",
        "delivery_days": "Int64",
        "target_late": "Int64",
    },
    parse_dates=["order_ts", "actual_delivery_at", "label_available_at"],
)

A dtype szótárban nem szereplő, de parse_dates által kezelt oszlopok automatikusan datetime típust kapnak. A dataset szerződése szerint a tiszta fájl 2400 sort tartalmaz, ezért az első ellenőrzés:

assert orders.shape == (2400, 15)
assert str(orders["distance_km"].dtype) == "Float64"
assert str(orders["order_id"].dtype) == "string"
assert pd.api.types.is_datetime64tz_dtype(orders["order_ts"])

Scriptben ne a Path.cwd()-re építsünk, hanem a 2.1-ben tanult Path(__file__).resolve() alapú projektgyökér-levezetésre; itt a notebook munkakönyvtára rögzített szerződés.

6.1.4 Hiány, üres szöveg és érvénytelen érték elkülönítése

A nyers CSV-ben három különböző probléma jelenhet meg, és ezeket nem szabad összemosni:

A három kategóriát egy mini CSV-n kézzel ellenőrizhetjük:

import io

mini = pd.read_csv(
    io.StringIO(
        "order_id,customer_id,basket_minor,distance_km,currency\n"
        "A1,C1,150,,EUR\n"
        "A2,,abc,-2,EUR\n"
        "A3,C1,,10,EUR\n"
    ),
    dtype={"order_id": "string", "customer_id": "string", "currency": "category"},
    keep_default_na=False,
)

Ebben a betöltésben az A1 sorbeli üres distance_km hiány, az A2 sorbeli üres customer_id üres szöveg, az A2 sorbeli abc érvénytelen numerikus szöveg, és az A2 sorbeli -2 érvénytelen tartomány. Ha megnézzük a dtypes-ot, a basket_minor object vagy string lesz, mert vegyesen tartalmaz számot és szöveget; a distance_km viszont float64, mert a hiány NaN-ként fér el benne. Az explicit Int64 konverziós terv csak az érvénytelen szöveg elkülönítése után hajtható végre.

6.1.5 Teljes minimális munkapélda: sémajelentés és hibajegyzék

A laborfájlra két lépésben dolgozunk. Először explicit nullable sémával betöltjük az orders_raw.csv-t, hogy a hiány és a tartományhiba egyaránt látszódjon:

raw = pd.read_csv(
    RAW / "orders_raw.csv",
    dtype={
        "order_id": "string",
        "customer_id": "string",
        "promised_days": "Int64",
        "quantity_sum": "Int64",
        "item_rows": "Int64",
        "basket_minor": "Int64",
        "shipping_fee_minor": "Int64",
        "currency": "category",
        "distance_km": "Float64",
        "priority_flag": "Int64",
        "delivery_days": "Int64",
        "target_late": "Int64",
    },
    parse_dates=["order_ts", "actual_delivery_at", "label_available_at"],
)

A labor-szerződés szerint a raw fájlban a numerikus oszlopok nem tartalmaznak érvénytelen szöveget, ezért ez a dtype dict nem dob konverziós hibát. A hiányos distance_km-ből pd.NA lesz. Most definiáljunk sémajelentést:

def schema_report(df, rules):
    """Mezőnkénti dtype, hiány, egyediség és tartalomhiba jelentés."""
    rows = []
    for col in df.columns:
        s = df[col]
        invalid = 0
        if col in rules:
            # A szabályt csak a nem hiányzó értékekre alkalmazzuk.
            invalid = int((s.notna() & ~rules[col](s)).sum())
        rows.append(
            {
                "column": col,
                "dtype": str(s.dtype),
                "missing": int(s.isna().sum()),
                "unique_non_null": int(s.dropna().nunique()),
                "invalid_non_null": invalid,
            }
        )
    return pd.DataFrame(rows).set_index("column")

rules = {
    "promised_days": lambda s: s.eq(7),
    "quantity_sum": lambda s: s.ge(0),
    "item_rows": lambda s: s.ge(1),
    "basket_minor": lambda s: s.ge(0),
    "shipping_fee_minor": lambda s: s.ge(0),
    "distance_km": lambda s: s.ge(0),
    "priority_flag": lambda s: s.isin([0, 1]),
    "delivery_days": lambda s: s.ge(1),
    "target_late": lambda s: s.isin([0, 1]),
}

report = schema_report(raw, rules)
print(report)

A hibajegyzéknek a kulcs-utalást is ellenőriznie kell. A customer_id csak akkor érvényes, ha szerepel a customers.csv ügyféllistájában:

customers = pd.read_csv(RAW / "customers.csv", dtype={"customer_id": "string"})
unknown_customer = (
    raw["customer_id"].notna()
    & ~raw["customer_id"].isin(customers["customer_id"])
)
duplicated_row = raw.duplicated()

Ezek után hibakód-oszlopot készítünk a sorszintű diagnózishoz:

issue = pd.Series("", index=raw.index, dtype="string")

issue[raw["distance_km"].isna()] = "missing_distance_km"
issue[(raw["basket_minor"] < 0).fillna(False)] = "invalid_basket_minor_negative"
issue[unknown_customer] = "unknown_customer"
# A duplikátum sorszintű hiba; a pandas adatminőségi tárgyalás s023.
issue[duplicated_row] = "duplicate_row"

raw_with_issue = raw.assign(issue_code=issue)

A javítási szabály nem csendes javítás, hanem osztályozás:

repair_rules = {
    "missing_distance_km": {
        "action": "exclude",
        "reason": "missing measurement, no safe zero-imputation",
    },
    "invalid_basket_minor_negative": {
        "action": "exclude",
        "reason": "violates minor-unit range",
    },
    "unknown_customer": {
        "action": "exclude",
        "reason": "fails customer key constraint",
    },
    "duplicate_row": {
        "action": "keep first",
        "reason": "row-level duplicate, review in s023",
    },
}

A nyers fájlt soha nem írjuk felül; a javított vagy szűrt változat a data/derived alá kerülne.

6.1.6 Eredmény értelmezése

A teljes munkapélda kimenete automatikusan még nem ellenőrzött, ezért szemléltető, elméletileg várt jellegű. Az orders_raw.csv betöltése után a következő invariánsoknak kell teljesülniük:

A saját gépen futtatva ellenőrizzük raw_with_issue["issue_code"].value_counts() értékét; ha egy hibacsoport darabszáma eltér, akkor a lab adata nem az alap generálási paraméterű, vagy a betöltési útvonal más. A unique_non_null oszlopok pontos értékét itt nem adjuk meg, mert az nem rögzített a mellékelt szerződésben; az alábbi report nem teljes sort mutat. A fontos, hogy a missing és invalid_non_null oszlopok a várt hibákat tükrözzék.

6.1.7 Feladatok

  1. Értelmezés. A mini CSV-ben miért nem lehet dtype={"basket_minor": "Int64"} értékkel betölteni a fájlt? Mi a dtype, ha a dtype szótárt teljesen elhagyjuk? Legalább két következményt indokolj.

  2. Megvalósítás. Olvasd be a tisztaz orders.csv-t a fent vázolt explicit sémával és parse_dates paraméterrel. Írj olyan assert sorokat, amelyek igazolják: a shape (2400, 15), nincs hiányzó érték semelyik oszlopban, az order_ts időzóna-tudatos datetime, és a currency egyetlen kategóriája EUR. A manifest.json-t most ne használd.

  3. Hibadiagnózis. Egy osztálytárs ezt írta a raw fájl auditálásához: clean = pd.to_numeric(raw["basket_minor"], errors="coerce"). Milyen hibát követ el a hibajegyzék szempontjából? Adj olyan, a coerce elé illesztett maszkot, amely a nem hiányzó, de nem numerikussá alakítható szöveget még a konverzió előtt megjelöli, és mutasd meg a mini CSV abc értékére.

  4. Megváltozott feltétel. A downstream folyamat mostantól csak a missing_distance_km és invalid_basket_minor_negative sorokat zárja ki; az unknown_customer hibákat későbbi lépés vizsgálja. Hozd létre a clean DataFrame-et ebből a feltételből. Ellenőrizd: clean.shape[0] == 2402, minden distance_km nem hiányzó, és minden basket_minor nemnegatív. Milyen további adathibák maradnak még a clean részben?

6.1.8 Megoldások és indoklás

1. A basket_minor oszlop a mini CSV-ben tartalmazza az abc szöveget. Az Int64 kötött numerikus dtype, így a pandas nem tudja az abc-t erre a típusra konvertálni, ezért kivétel keletkezik. Ha a dtype szótárt elhagyjuk, a pandas az oszlopot object vagy string típusúnak észleli, mert vegyesen van szám és szöveg. Ez azt jelenti, hogy a későbbi s.basket_minor.sum() nem fut le numerikus összegzésként, hanem hibát ad vagy string-konkatenációt végez, ezért az adatprobléma csendesen a betöltés után jelenik meg.

2. A betöltés az explicit dtype dicttel:

orders = pd.read_csv(
    RAW / "orders.csv",
    dtype={
        "order_id": "string",
        "customer_id": "string",
        "promised_days": "Int64",
        "quantity_sum": "Int64",
        "item_rows": "Int64",
        "basket_minor": "Int64",
        "shipping_fee_minor": "Int64",
        "currency": "category",
        "distance_km": "Float64",
        "priority_flag": "Int64",
        "delivery_days": "Int64",
        "target_late": "Int64",
    },
    parse_dates=["order_ts", "actual_delivery_at", "label_available_at"],
)

assert orders.shape == (2400, 15)
assert orders.isna().sum().sum() == 0
assert pd.api.types.is_datetime64tz_dtype(orders["order_ts"])
assert set(orders["currency"].cat.categories) == {"EUR"}

A parse_dates a datetime64 típusú oszlopokat időzóna-tudatosan hozza létre, mert a forrás +00:00 offsettel ír. A cat.categories a kategóriaszintet adja vissza.

3. A errors="coerce" a nem értelmezhető szöveget NaN-né alakítja, így az érvénytelen érték összekeveredik a valódi hiánnyal. A hibajegyzék ekkor aluljelenti az érvénytelen értékeket, és a downstream kód azt hiheti, hogy csak mérési hiány van. A javított maszk:

def invalid_text_mask(s):
    if s.dtype == "string":
        not_missing = s.notna()
    else:
        not_missing = s.notna()
    # A to_numeric a hibás szövegre NaN-t ad, ezért a "nem hiány, de NaN" az invalid.
    return not_missing & pd.to_numeric(s, errors="coerce").isna()

A mini CSV-ben mini["basket_minor"] vegyes oszlop; a maszk:

mini["basket_minor"].notna() & pd.to_numeric(mini["basket_minor"], errors="coerce").isna()

ez az abc sorra True-t ad, a hiány és a 150 értékre False-ot. A konverziót csak azután végezzük, hogy az invalid flag megvan.

4. A clean szűrés:

clean = raw_with_issue[
    ~raw_with_issue["issue_code"].isin(
        ["missing_distance_km", "invalid_basket_minor_negative"]
    )
].copy()

assert clean.shape[0] == 2402
assert clean["distance_km"].notna().all()
assert clean["basket_minor"].ge(0).all()

A clean részben továbbra is maradhat az unknown_customer hiba és az öt duplikált sor; ezeket későbbi Duplikáció, kulcs és adatminőségi szerződés és ügyfél-kulcs ellenőrzés kezeli. Ha egy sorra egyszerre több hibakód is igaz lett volna, a hibakód-értékadás sorrendje felülírta volna a korábbi kódot; a labori szerződés szerint a hibák külön sorokon vannak, ezért ez itt nem befolyásolja a 2402-es várt értéket.

6.1.9 Gyakori tévedés és ellenpélda

A leggyakoribb hiba a csendes errors="coerce" használata. A mini CSV basket_minor oszlopában az abc szöveg NaN lesz, és a sémajelentés hiányként mutatja. A következmény: a javítási szabály tévesen azt írja, hogy a hiányzó értékeket pótolni lehet, pedig az abc egyáltalán nem hiány, hanem sérült bemenet. Hasonló csendes összemosás az üres szöveg kezelése: az alapértelmezett read_csv az üres stringet is NaN-né alakítja, tehát ha a megfigyelési folyamat megkülönbözteti a „nem mért” és a „nem válaszolt” esetet, akkor auditáláskor keep_default_na=False szükséges. Az 5.1-ben a dtype és shape, az 2.1-ben a raw/derived elkülönítés tanította ugyanezt a célzott, explicit szerződéses szemléletet. Önellenőrzésként mindig kérdezzünk rá: ha futtatom a sémajelentést, akkor a missing és az invalid_non_null összege külön-külön egyezik a kézi labor-leírással?

A betöltéskor megadott dtype önmagában még nem igazolja a teljes adatkontraktust: a beolvasás után az egyediséget, az értéktartományokat, a hivatkozási integritást és a dátummezők feldolgozhatóságát is ellenőrizzük. A hiányzó és az érvénytelen érték külön hibakategória marad.

6.1.10 Rövid összefoglaló

Az explicit séma rögzíti az oszlopnevet, dtype-ot, mértékegységet és megengedett tartományt, így a pd.read_csv inferálása nem változtatja meg csendben az adattípust. A nullable Int64, Float64 és string dtype a hiányt pd.NA-ként tárolja; a category a nominális szinteket kezeli, a parse_dates az UTC-időbélyegeket elemzi. A sémajelentés a hiányt, az érvénytelen értéket és az üres szöveget külön oszlopban számolja. A labor orders_raw.csv betöltése után 2405 sor, két hiányzó távolság, egy negatív kosárérték és egy idegen ügyfél várható; a javítási szabály hibakóddal zár ki, nem coerce-szel. A következő lépésben a kiválasztást és a módosítást végezzük Copy-on-Write mellett.