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.
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.
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
,
akkor
Az átmeneti statisztika így
alakú, ahol
az oszlopok száma. A transform a hiányzó cellákat a
megfelelő
-vel
pótolja, a nem hiányzókat változatlanul hagyja. Fontos: a teszt részen
is ugyanezt a
-t
használjuk, nem a teszt oszlopátlagát.
A StandardScaler (standardizáló) szintén oszloponként tanul átlagot -t és szórást -t,
majd minden értéket z-értékké alakít:
Az méretű bemeneti mátrixból ugyanilyen alakú kimeneti mátrix lesz; a tanult statisztika mérete . A tesztet ezzel a tanított -vel és -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: . A nem hiányzó értékek átlaga . A pótlás után ; ennek átlaga 4.6667, szórása . A standardizált értékek körülbelül . Ha a tesztben a 10 érték szerepel, akkor a tanított paraméterekkel számolt z-érték .
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.
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.
A helyes pipeline három lépésben tanul:
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.transform-ot
kap: a tanult
,
és kategóriaoszlopok alkalmazódnak.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.
É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.
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.
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.
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.
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?
1. megoldás. Az átlag . A pótlás után a tanító értékek . Az eltérésnégyzetek: ; a szórás . A standardizált tanító értékek:
Ezek összege körülbelül 0, átlaguk 0, szórásuk 1. A teszt érték: . 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
-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.
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.
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.