21.2 Preprocessing tanulás kizárólag a training részen

A prediktív gépi tanulásban a modell mellett a transzformációs lánc is tanul az adatból: hiánypótló átlagokat, standardizáló paramétereket, kategóriakódolási oszlopokat kell illeszteni. Ha ezeket a teljes adaton illesztjük, akkor a tesztadatokból származó információ beleszivárog a tanítási folyamatba, és az értékelés optimistává válik. Ez az alfejezet a scikit-learn Pipeline és ColumnTransformer segítségével mutatja be, hogyan kell a transzformációk illesztését a tanító részen belül tartani.

21.2.1 Cél és konkrét helyzet

A mesterséges feladat a Northstar-példa egyszerűsített változata: rendelésenként egy sorunk van, a cél pedig target_late, azaz hogy a kézbesítés 7 napon túli-e. A megfigyelési egység tehát a rendelés. A rendelkezésre álló jellemzők: distance_km (szállítási távolság kilométerben, numerikus, hiányzó értékekkel), basket_minor (kosárérték eurócentben, numerikus, hiányzó értékekkel), segment (ügyfélszegmens, kategorikus), priority_flag (elsőbbségi jelzés, 0/1).

A feladat: egy tanító és egy teszt részt rögzítünk, előfeldolgozó láncot építünk, majd egy logisztikus regressziót tanítunk. Ugyanezt egy szándékosan szivárgó változatban is végrehajtjuk, amelyben a transzformációkat a teljes adaton illesztjük. Mielőtt tovább olvasna, válaszoljon az induló kérdésre: szabad-e az imputáló átlagot a train_test_split előtt kiszámolni? A választ az alfejezet végén ellenőrizheti.

21.2.2 Fogalmi magyarázat

A scikit-learn transzformátorokban a fit (illesztés) tanulja meg a transzformáció paramétereit, a transform (átalakítás) pedig alkalmazza azokat. A tanító részen a kettőt egyszerre végzi a fit_transform, a teszt részen ellenben csak transform-ot hívunk, hogy a teszt statisztikái ne épüljenek be a folyamatba.

A SimpleImputer (egyszerű hiánypótló) strategy="mean" beállítással oszloponként az átlagot tanulja meg a fit során. Ha a j-edik oszlop megfigyelt értékei x1j,x2j,…,xkjx_{1j}, x_{2j}, \dots, x_{kj}, akkor

μj=1k∑i=1kxij. \mu_j = \frac{1}{k}\sum_{i=1}^{k} x_{ij}.

Az átmeneti statisztika így 1×p1 \times p alakú, ahol pp az oszlopok száma. A transform a hiányzó cellákat a megfelelő μj\mu_j-vel pótolja, a nem hiányzókat változatlanul hagyja. Fontos: a teszt részen is ugyanezt a μj\mu_j-t használjuk, nem a teszt oszlopátlagát.

A StandardScaler (standardizáló) szintén oszloponként tanul átlagot μj\mu_j-t és szórást σj\sigma_j-t,

σj=1k∑i=1k(xij−μj)2, \sigma_j = \sqrt{\frac{1}{k}\sum_{i=1}^{k}(x_{ij} - \mu_j)^2},

majd minden értéket z-értékké alakít:

zij=xij−μjσj. z_{ij} = \frac{x_{ij} - \mu_j}{\sigma_j}.

Az n×pn \times p méretű bemeneti mátrixból ugyanilyen alakú kimeneti mátrix lesz; a tanult statisztika mérete 1×p1 \times p. A tesztet ezzel a tanított μj\mu_j-vel és σj\sigma_j-vel transzformáljuk, nem a teszt saját statisztikáival. Ezt a korábbi lineáris algebrai alakintuíciót használjuk: minden soron ugyanazt az oszloponkénti műveletet hajtjuk végre.

A OneHotEncoder (egy forró kódoló) a kategorikus oszlop értékkészletéből indikátoroszlopokat készít. Ha a tanító részben a segment oszlop értékei consumer és business, akkor a fit megtanulja ezt a két kategóriát, a transform pedig minden sorból két oszlopot hoz létre: az egyik 1, ha az érték consumer, a másik 1, ha business. A teszt részben megjelenő, a tanításban nem látott kategória kezelését a handle_unknown paraméter szabályozza. Az alapértelmezett "error" hibát dob, a "ignore" pedig a tesztben ismeretlen kategóriához csupa 0 értékű sort rendel. Ez a viselkedés tükrözi a termelési helyzetet: új kategóriához nincs tanult oszlop.

Kézi kontrollként vegyünk egy tanító oszlopot: [2,4,NaN,8][2, 4, \text{NaN}, 8]. A nem hiányzó értékek átlaga (2+4+8)/3=14/3≈4.6667(2+4+8)/3 = 14/3 \approx 4.6667. A pótlás után [2,4,4.6667,8][2, 4, 4.6667, 8]; ennek átlaga 4.6667, szórása ((−2.6667)2+(−0.6667)2+02+3.33332)/4≈2.1602\sqrt{\left((-2.6667)^2 + (-0.6667)^2 + 0^2 + 3.3333^2\right)/4} \approx 2.1602. A standardizált értékek körülbelül [−1.2344,−0.3086,0,1.543][-1.2344, -0.3086, 0, 1.543]. Ha a tesztben a 10 érték szerepel, akkor a tanított paraméterekkel számolt z-érték (10−4.6667)/2.1602≈2.469(10-4.6667)/2.1602 \approx 2.469.

A helyes folyamatot a Pipeline és a ColumnTransformer együtt valósítja meg. A ColumnTransformer oszlopcsoportonként rendeli hozzá a transzformátorokat, a Pipeline pedig egymás után fűzi a lépéseket úgy, hogy a tanítás során a fit csak a tanító sorokon fut.

21.2.3 Teljes minimális munkapélda

A következő kód a lab virtuális környezetében futtatható. A lab projektgyökérből Linux/macOS alatt uv run python s082_demo.py, PowerShell alatt uv run python .\s082_demo.py parancsot használunk. A kód a seedet rögzíti, hogy a véletlen minták ismételhetők legyenek. A kimenetet szemléltetőként jelöljük: ezt a kódot a könyv előállításakor nem futtattuk, ezért a megadott értékek elméletileg vártak, nem mérési jegyzőkönyvből származnak.

import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score

rng = np.random.default_rng(42)
n = 200

true_distance = rng.normal(12, 5, n)
true_basket = rng.lognormal(5, 0.7, n)
segment = rng.choice(
    ["consumer", "business", "vip_only_test"],
    size=n, p=[0.6, 0.35, 0.05],
)
priority = rng.binomial(1, 0.25, n)
logit = -2.5 + 0.18 * true_distance + 0.9 * (segment == "business") + 0.8 * priority
prob = 1 / (1 + np.exp(-logit))
target = rng.binomial(1, prob)

df = pd.DataFrame({
    "distance_km": true_distance,
    "basket_minor": true_basket.round(0),
    "segment": segment,
    "priority_flag": priority,
    "target_late": target,
})

# hiányzó értékek bevezetése
df.loc[rng.choice(n, 12, replace=False), "distance_km"] = np.nan
df.loc[rng.choice(n, 8, replace=False), "basket_minor"] = np.nan

features = ["distance_km", "basket_minor", "segment", "priority_flag"]

# A vip_only_test kategória csak a tesztben jelenhet meg.
mask_vip = df["segment"] == "vip_only_test"
df_vip = df.loc[mask_vip]
df_rest = df.loc[~mask_vip]

X_rest, X_vip = df_rest[features], df_vip[features]
y_rest, y_vip = df_rest["target_late"], df_vip["target_late"]

X_train, X_test_rest, y_train, y_test_rest = train_test_split(
    X_rest, y_rest, test_size=0.2, random_state=7
)
X_test = pd.concat([X_test_rest, X_vip])
y_test = pd.concat([y_test_rest, y_vip])

# Helyes előfeldolgozó: csak a train részen illesztve, a Pipeline-on belül.
preprocessor = ColumnTransformer(
    transformers=[
        ("num", Pipeline([
            ("imputer", SimpleImputer(strategy="mean")),
            ("scaler", StandardScaler()),
        ]), ["distance_km", "basket_minor"]),
        ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), ["segment"]),
        ("flag", "passthrough", ["priority_flag"]),
    ]
)

model = Pipeline([
    ("preprocess", preprocessor),
    ("classifier", LogisticRegression(max_iter=1000)),
])

model.fit(X_train, y_train)
auc_correct = roc_auc_score(y_test, model.predict_proba(X_test)[:, 1])
print(f"Correct test AUC: {auc_correct:.3f}")

# Szivárgó változat: a transzformáció fit a teljes X-en.
preprocessor_bad = ColumnTransformer(
    transformers=[
        ("num", Pipeline([
            ("imputer", SimpleImputer(strategy="mean")),
            ("scaler", StandardScaler()),
        ]), ["distance_km", "basket_minor"]),
        ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), ["segment"]),
        ("flag", "passthrough", ["priority_flag"]),
    ]
)

X_all_transformed = preprocessor_bad.fit_transform(df[features])
X_all_transformed = pd.DataFrame(X_all_transformed, index=df.index)
X_train_bad = X_all_transformed.loc[X_train.index]
X_test_bad = X_all_transformed.loc[X_test.index]

model_bad = LogisticRegression(max_iter=1000).fit(X_train_bad, y_train)
auc_bad = roc_auc_score(y_test, model_bad.predict_proba(X_test_bad)[:, 1])
print(f"Leaky test AUC: {auc_bad:.3f}")

# Ellenőrzés: a helyes imputer statisztikája valóban a train oszlopátlagait adja.
correct_num = model.named_steps["preprocess"].named_transformers_["num"]
print("Imputer statistics (train):", correct_num.named_steps["imputer"].statistics_)
print("Full-data distance means:", df["distance_km"].mean(),
      "train means:", X_train["distance_km"].mean())

A szemléltető, nem garantált kimenet egy lehetséges futtatásból:

Correct test AUC: 0.712
Leaky test AUC: 0.831
Imputer statistics (train): [12.407  245.048]
Full-data distance means: 12.297
train means: 12.407

A szivárgó változat magasabb AUC-t mutat, mert az előfeldolgozó fitje látta a teszt sorait is: ismerte a vip_only_test kategóriát, és a teljes oszlopátlagokat is használta. Ez a magasabb érték nem a termelési teljesítmény becslése, hanem optimista torzítás.

21.2.4 Eredmény értelmezése

A helyes pipeline három lépésben tanul:

  1. A Pipeline a train részen meghívja a preprocessor fit_transform-ját: a numerikus rész imputáló átlagot és standardizáló paramétereket tanul, a kategorikus rész a train kategóriáit rögzíti.
  2. Ugyanez a preprocessor a teszt részen csak transform-ot kap: a tanult μj\mu_j, σj\sigma_j és kategóriaoszlopok alkalmazódnak.
  3. A LogisticRegression a transzformált train mátrixon tanul, a predikció pedig a transzformált teszt mátrixon történik.

A szivárgó változatban a preprocessor fitje a teljes df[features]-re futott. Ennek következménye: a tesztben szereplő vip_only_test kategória a fit során már ismertté vált, ezért a kódoló külön oszlopot kapott rá. A helyes folyamatban ez az oszlop nem létezik, és a handle_unknown="ignore" miatt a tesztben az összes kategóriaoszlop értéke 0 lesz a vip sorokban. A szivárgó változat tehát olyan feature-t használ, amely a valódi produkcióban nem állna rendelkezésre.

Az ellenőrző invariant: a helyes preprocessor által tanult numerikus statisztikák (az imputer statistics_ attribútuma) pontosan megegyeznek a train oszlopátlagaival, nem a teljes adat oszlopátlagaival. A transzformált train oszlopok számát a train kategóriák határozzák meg; a teszt oszlopszáma ugyanaz, akkor is, ha új kategória érkezik. Ha a kódban a handle_unknown értéke "error", az ismeretlen kategória ValueError-t okoz, ami segít felfedni az adatszerződés-sértést.

A példa AUC-különbsége egyetlen véletlen mintán nem determinisztikus, de a szivárgó becslés tendenciózusan optimista. A megbízható értékeléshez a Tanítás, validáció és érintetlen teszt alfejezetben bevezetett elvet kell alkalmazni: a teszt részből semmilyen statisztika nem származhat.

A ColumnTransformer-be kerülő összes adatfüggő transzformációt a CV-foldok tanítórészén kell illeszteni. A tesztbe átlógó kategóriakészlet, imputációs statisztika vagy feature-szelekció ugyanúgy szivárgás, mint a célváltozó átvétele.

21.2.5 Feladatok

  1. Értelmezés kézi számítással. Adott egy tanító oszlop [2, 4, NaN, 8] és egy teszt érték 10. Számítsa ki a tanító oszlop átlagát, pótolja a hiányzó értéket, majd a tanult átlaggal és szórással standardizálja mind a négy tanító értéket és a teszt értéket. Ellenőrizze, hogy a standardizált tanító értékek átlaga 0, szórása 1.

  2. Implementálás pipeline-nal. Módosítsa a fenti minimális példát úgy, hogy a numerikus részben a medián imputálást használja átlag helyett (strategy="median"). Futtassa le, majd a model.named_steps["preprocess"].named_transformers_["num"].named_steps["imputer"].statistics_ értékét hasonlítsa össze a train oszlopok mediánjaival. Ellenőrizze a transzformált train és teszt oszlopszámát.

  3. Hibadiagnózis. Valaki a következő kódot írta:

X = df[features].copy()
X["distance_km"] = X["distance_km"].fillna(X["distance_km"].mean())
X_train, X_test, y_train, y_test = train_test_split(
    X, df["target_late"], test_size=0.2, random_state=7
)
model = LogisticRegression(max_iter=1000).fit(X_train, y_train)

Magyarázza el, hol szivárog az információ, és írja át a kódot úgy, hogy a hiánypótló átlag kizárólag a tanító részből származzon.

  1. Megváltozott feltétel. Távolítsa el a train részből az összes business kategóriájú sort, hogy a business a tesztben unseen kategóriaként jelenjen meg. Futtassa a pipeline-t handle_unknown="error" beállítással, majd "ignore" beállítással. Dokumentálja a hibát, és ellenőrizze az ignore módban kapott predikciók shape-jét.

  2. Transzfer döntési helyzetre. A termelési adagban a distance_km oszlop mérföldben érkezik, nem kilométerben. Magyarázza el, miért nem elég a meglévő pipeline transform-ját lefuttatni; mi a helyes műszaki döntés?

21.2.6 Megoldások és indoklás

1. megoldás. Az átlag (2+4+8)/3=4.6667(2+4+8)/3 = 4.6667. A pótlás után a tanító értékek [2,4,4.6667,8][2, 4, 4.6667, 8]. Az eltérésnégyzetek: 2.66672+0.66672+0+3.33332≈18.66672.6667^2 + 0.6667^2 + 0 + 3.3333^2 \approx 18.6667; a szórás 18.6667/4≈2.1602\sqrt{18.6667/4} \approx 2.1602. A standardizált tanító értékek:

2−4.66672.1602≈−1.2344,4−4.66672.1602≈−0.3086,0,8−4.66672.1602≈1.543. \frac{2-4.6667}{2.1602} \approx -1.2344,\quad \frac{4-4.6667}{2.1602} \approx -0.3086,\quad 0,\quad \frac{8-4.6667}{2.1602} \approx 1.543.

Ezek összege körülbelül 0, átlaguk 0, szórásuk 1. A teszt érték: (10−4.6667)/2.1602≈2.469(10-4.6667)/2.1602 \approx 2.469. Az invariant tehát teljesül.

2. megoldás. A medián imputálóval a helyes statisztika a train oszlopok mediánja. A következő ellenőrzés futtatható:

num_pipeline = model.named_steps["preprocess"].named_transformers_["num"]
print(num_pipeline.named_steps["imputer"].statistics_)
print(X_train[["distance_km", "basket_minor"]].median().to_numpy())

A két tömbnek egyeznie kell. A transzformált train és teszt oszlopszáma egyezik; a kategorikus rész oszlopszámát a train kategóriái adják. A pd.DataFrame(model.named_steps["preprocess"].transform(X_train)).shape[1] és a teszt transzformált shape-jének második dimenziója megegyezik.

3. megoldás. A hiba az, hogy a fillna a teljes oszlop átlagát használja, tehát a teszt sorok is beleszámítanak μ\mu-ba. A javított változat:

X_train, X_test, y_train, y_test = train_test_split(
    df[features], df["target_late"], test_size=0.2, random_state=7
)
imputer = SimpleImputer(strategy="mean")
X_train_imp = imputer.fit_transform(X_train)
X_test_imp = imputer.transform(X_test)

Itt az imputer fitje csak a train sorokra vonatkozik, a teszt pedig ugyanazt a tanult átlagot kapja.

4. megoldás. Az "error" beállításnál a pipeline transform hívása a teszt business sorain ValueError-t dob, mert a kategóriát nem tanulta a fit. Az "ignore" beállításnál a transzformált teszt minden kategóriaoszlopában 0 lesz a business sorokra. Az oszlopszám változatlan. A predikció shape-je (n_test, 1) a predict_proba esetén második oszloppal, illetve (n_test,) a predict esetén.

5. megoldás. A meglévő pipeline a train átlagát és szórását kilométerben tanulta. Ha mérföldben érkező tesztet transzformálunk, a z-értékek félrevezetőek: a teszt numerikus értékeit a kilométeres statisztikák skáláján értelmezzük, ami a modell predikcióját torzítja. A helyes döntés: a betöltési rétegben konvertáljuk az értékeket kilométerre, vagy tanítsuk újra a pipeline-t a megváltozott mértékegységhez rögzített új szerződéssel. A pipeline önmagában nem tudja felismerni a mértékegységváltást.

21.2.7 Gyakori tévedés és önellenőrzés

Gyakori tévedés, hogy a teljes adaton tanult imputálás vagy skálázás „csak kevés” szivárgást okoz, ezért elfogadható. Tipikus ellenpélda: ha a tesztben a distance_km hiányzó értékei éppen a nagy távolságú rendelésekhez tartoznak, a teljes adaton számolt átlag magasabb lesz, mint a train átlag. A teljes átlaggal pótolt teszt sorok a modell számára hamisan alacsonyabb távolságot mutatnak, ami magasabb AUC-t produkál a holdouton, de a termelési feltételekben nem reprodukálható. A Tanítás, validáció és érintetlen teszt tanítása szerint a teszt résznek érintetlennek kell maradnia; a transzformáció statisztikája éppúgy tanult paraméter, mint a regressziós együttható.

Önellenőrzésként kérdezze meg minden transzformátorról: mi az, amit a fit megtanult, és mely sorokból tanulta? Ha a válaszban bármely teszt sor is szerepel, akkor a folyamat szivárog. Az Időhelyes feature és gördülő visszateszt időhelyes szemlélete ugyanezt mondja: a feature-előállítás pillanatában csak a múltbeli, ténylegesen ismert adatok állhatnak rendelkezésre.

21.2.8 Rövid összefoglaló

A transzformációk paramétereit kizárólag a tanító részen illesztjük. A SimpleImputer oszlopátlagot, a StandardScaler átlagot és szórást, a OneHotEncoder kategóriakészletet tanul; a teszt részen már csak a tanult paraméterek alkalmazhatók. A Pipeline és a ColumnTransformer ezt a szerződést automatikusan betartja, feltéve, hogy a teljes előfeldolgozó láncot a pipeline belsejébe helyezzük. A szivárgó változat magasabb teszt AUC-t mutathat, de ez a becslés torzított. Az ismeretlen kategóriát handle_unknown="ignore" móddal kezeljük. A következő alfejezetben egy baseline modellt és egyetlen teljes, korrekt módon értékelt kísérletet építünk.