A Northstar prediktív projektben a rendelés pillanatában szeretnénk
becsülni, hogy a kézbesítés meghaladja-e a 7 napot:
target_late. A modell bemenete csak rendeléskor ismert
oszlop lehet. A kategorikus elemek a segment,
region és acquisition_channel; a ferde
numerikus jelöltek a distance_km, basket_minor
és quantity_sum. A hiányzó értékeket a tiszta
orders.csv nem tartalmazza, ezért a missing indicator
fogalmát külön szintetikus példán mutatjuk be; a nyers
orders_raw.csv hiányos distance_km oszlopa az
adatminőségi fejezet témája.
Ebben a laborban kizárólag belső validáción hasonlítunk össze három reprezentációt a tanító időablakon belül: teljes one-hot, ritka kategóriát összevonó one-hot, valamint nyers numerikus jellemzők helyett log-transzformált numerikus jellemzők. Előzetes becslésként azt várjuk, hogy a ritka kategória összevonása nem rontja érdemben az AUC-t, a log transzformáció pedig a logisztikus regresszió számára kedvezőbb, kevésbé ferde skálát adhat. A cél nem a legjobb modell kiválasztása, hanem annak ellenőrzése, hogy a reprezentáció döntését pipeline-ban, szivárgásmentesen tudjuk meghozni.
A one-hot (magyarul „egy forró”) kódolás egy elemű mintán egy értékű kategorikus változót darab bináris oszloppá alakít. Ha a kategória értéke , akkor
ahol
,
és
a tanításkor látott kategóriák kódja. A kimeneti mátrix alakja
,
dtype általában int8 vagy boolean; a
OneHotEncoder alapértelmezésben ritka mátrixot adhat, ami
nagy
esetén memóriatakarékos. A region = north sor a
north, central, south készletben például
lesz. A fák számára a one-hot gyakran gyenge választás, mert az osztást
egyetlen kategóriára bontja; a lineáris modellek viszont kategóriánként
külön együtthatót kapnak.
Az ordinális kódolás
értékekhez monoton
számokat rendel. Csak akkor értelmes, ha a kategóriák között valódi,
értelmezhető sorrend van. A Northstar segment vagy
region értékei nem rendezettek, tehát az ordinális kódolás
hamis távolságot vinne a modellbe: a
north=0, central=1, south=2 kódolás azt sugallná, hogy
south közelebb van central-hoz, mint
north-hoz, aminek nincs domain-alapja. Rendelhető példa
lenne például egy priority_level = low, normal, high
változó, ahol a sorrend ténylegesen fontos.
Ha egy kategória nagyon kevés tanítópéldában fordul elő, a becsült
együttható vagy döntési szabály nagy varianciájú lesz. Ritka
kategóriakezeléskor (rare category encoding) a tanító foldon mért
gyakoriság alapján kijelöljük a ritka értékeket, és azokat egy
other vagy infrequent gyűjtőkategóriába vonjuk
össze. A küszöb lehet minimális abszolút darabszám vagy arány, például
5%. A küszöböt kizárólag a tanító részen illesztjük, mert a teszt részen
mért gyakoriság jövőbeli információ lenne. A scikit-learn
újabb verzióiban a
OneHotEncoder(min_frequency=..., handle_unknown="infrequent_if_exist")
paraméterezés ezt közvetlenül elvégzi; régebbi verzióban a mappinget
manuálisan kell a tanító foldon meghatározni.
A log transzformáció ferde, pozitív értékeket közelít szimmetrikusabb skálára:
ha . Ha is lehetséges, akkor eltolást használunk, például . A logaritmus a szorzó különbségeket additív különbséggé alakítja: és között tízszeres, és között szintén tízszeres különbség van, de nyers skálán az első különbség 90, a második 900. A log skálán . Ez csökkenti a kiugró értékek torzító hatását, és gyakran javítja a lineáris modellek numerikus stabilitását. A transzformáció nem garantáltan javít minden modellen; fák esetén a monoton log transzformáció az egyváltozós vágási pontokon nem változtat a modell kifejezőerején, mert a fa csak a sorrendet használja.
A missing indicator (hiányjelző) egy új bináris oszlop:
A hiányzó értéket ezután imputáljuk, például az oszlop átlagával, de
a hiány tényét megőrizzük. Ez azért fontos, mert a hiány önmagában
informálhat: egy mérési folyamat hibája vagy egy szolgáltatás nem
elérhetősége összefügghet a célváltozóval. A
SimpleImputer(add_indicator=True) pontosan ezt a szerződést
valósítja meg: az imputáció a tanító fold átlagát használja, a
hiányjelző oszlop pedig a hiányzó sorokat jelöli.
A target encoding a kategorikus értéket a célváltozó csoporton belüli átlagával helyettesíti. A becslés simított alakja
ahol a kategória mintamérete, a teljes célarány prior, pedig simítási erő. Ha a csoportátlagot a teljes adaton, tehát a validációs vagy teszt sorokat is beleértve számítjuk, a modell a jövőbeli címkét látja. Ez súlyos leakage: a kategória átlaga maga is tartalmazza a sor saját célját, ezért a betanított modell mesterségesen erős kapcsolatot talál. A helyes megoldás kizárólag out-of-fold becslés: minden sor predikciójához olyan encodingot használunk, amelyet a sor foldját kihagyó tanító foldokból számoltunk. Ebben a laborban a target encodingot éppen a kockázata miatt nem használjuk a validációs kísérletben.
A kód a lab gyökeréből futtatható, ahol a
data/orders.csv és a data/customers.csv
található. A kód a 2025-05-01 cutoff előtti, már megfigyelt
címkéjű tanító részt használja, és azon belül időrendi belső validációt
végez. A kimeneti értékeket még nem ellenőriztük automatizáltan, ezért
azokat szemléltetőként jelöljük.
from pathlib import Path
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer, make_column_selector
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, FunctionTransformer, StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import roc_auc_score
data_dir = Path("data")
orders = pd.read_csv(
data_dir / "orders.csv",
parse_dates=["order_ts", "actual_delivery_at", "label_available_at"],
)
customers = pd.read_csv(data_dir / "customers.csv", parse_dates=["signup_date"])
cutoff = pd.Timestamp("2025-05-01T00:00:00+00:00")
train_pool = orders[(orders["order_ts"] < cutoff) & (orders["label_available_at"] < cutoff)]
df = train_pool.merge(customers, on="customer_id", how="left")
df["account_age_days"] = (df["order_ts"] - df["signup_date"]).dt.days
feature_cols = [
"segment", "region", "acquisition_channel",
"distance_km", "priority_flag", "basket_minor",
"quantity_sum", "account_age_days",
]
categorical_cols = ["segment", "region", "acquisition_channel"]
numeric_cols = ["distance_km", "priority_flag", "basket_minor",
"quantity_sum", "account_age_days"]
log_cols = ["distance_km", "basket_minor", "quantity_sum"]
df = df.sort_values("order_ts").reset_index(drop=True)
X = df[feature_cols]
y = df["target_late"].astype(int)
def safe_log(x):
return np.log(np.clip(x, 1e-9, None))
# Közös numerikus ág: imputálás és standardizálás.
numeric_pipe = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
])
def make_model(onehot_kwargs, numeric_transform=None):
if numeric_transform is not None:
numeric_pipe_local = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("transform", FunctionTransformer(numeric_transform, validate=False)),
("scaler", StandardScaler()),
])
else:
numeric_pipe_local = numeric_pipe
preprocessor = ColumnTransformer([
("cat", OneHotEncoder(**onehot_kwargs), categorical_cols),
("num", numeric_pipe_local, numeric_cols),
])
return Pipeline([
("prep", preprocessor),
("model", LogisticRegression(max_iter=1000, random_state=20261020)),
])
def numeric_identity(x):
return x
def log_selected_columns(x):
out = x.copy()
cols = ["distance_km", "basket_minor", "quantity_sum"]
out[cols] = safe_log(out[cols])
return out
variants = {
"onehot_full": make_model({"handle_unknown": "ignore"}),
"onehot_rare": make_model({
"min_frequency": 0.05,
"handle_unknown": "infrequent_if_exist",
}),
"onehot_full_log": make_model(
{"handle_unknown": "ignore"},
numeric_transform=log_selected_columns,
),
}
tscv = TimeSeriesSplit(n_splits=5)
results = {}
for name, pipe in variants.items():
aucs = []
for train_idx, val_idx in tscv.split(X):
pipe.fit(X.iloc[train_idx], y.iloc[train_idx])
pred = pipe.predict_proba(X.iloc[val_idx])[:, 1]
aucs.append(roc_auc_score(y.iloc[val_idx], pred))
results[name] = (float(np.mean(aucs)), float(np.std(aucs, ddof=1)))
print(pd.DataFrame(
results,
index=["mean_auc", "std_auc"],
).T.round(4).to_string())A TimeSeriesSplit az időrend szerint növekvő tanító
ablakokat és rájuk következő validációs ablakokat képez. A
ColumnTransformer a kategorikus és numerikus ágakat
összefogja, a Pipeline pedig biztosítja, hogy a rare
kategória küszöb, az imputálási medián, a log transzformáció és a
standardizálási paraméterek minden foldban csak a tanító foldból
illesztődjenek.
Az alábbi táblázat nem mért eredmény, hanem a reprezentációktól várt jellegű szemléltető kimenet:
mean_auc std_auc
onehot_full 0.6213 0.0187
onehot_rare 0.6197 0.0179
onehot_full_log 0.6339 0.0164
A kézi kontroll a transzformációkra koncentrál: ha a tanító foldban a
region gyakorisága
north=0.58, central=0.37, south=0.05, akkor a 0,05-ös
küszöb mellett a south a ritka gyűjtőkategóriába kerül,
tehát az oszlopszám a teljes one-hot háromhoz képest kettő. A log
transzformációra
esetén
,
esetén
;
a nyers különbség 90, a log különbség
.
Az AUC-különbségeket önmagukban nem tekintjük bizonyítottnak, mert a
foldok száma kicsi, a szórás nem konfidenciaintervallum, és az
összehasonlítás egyetlen adatmintán történt.
A várható viselkedés: a rare összevonás a teljes one-hothoz közeli
AUC-t ad, de kevesebb paraméterrel; a log transzformáció a logisztikus
regressziónál jellemzően javít, mert a basket_minor és
distance_km ferde eloszlásán a standardizált nyers skála
egy-egy nagy értékű sort dominálhat. Végső döntés előtt ugyanazt a
protokollt kellene a független időbeli teszten is lefuttatni.
A ritka kategóriák csoportosítása, imputáció és skálázás illesztendő paramétereit csak a train-adatokból becsüljük. A log-transzformáció előtti tartományellenőrzés vegye figyelembe a nullát és a negatív értékeket.
Értelmezés. A segment és
region értékeit miért nem szabad ordinális kódolással
ellátni? Adj egy olyan példát, ahol ordinális kódolás indokolt lenne, és
nevezd meg, milyen domain-feltételnek kell teljesülnie.
Implementálás. Írj egy
log_transform_df függvényt, amely egy DataFrame-et vár, és
a distance_km, basket_minor,
quantity_sum oszlopokat
-szel
transzformálja. Ellenőrizd, hogy a kimenet shape-je megegyezik a
bemenettel, a három oszlopban nincs null vagy negatív érték, és a
transzformáció monoton növekvő. Az ellenőrzéshez használd a kézi
bemenetet:
pd.DataFrame({"distance_km": [1.0, 10.0], "basket_minor": [100, 1000], "quantity_sum": [2, 20]}).
Hibadiagnózis. A következő kódvázlat a rare
kategóriákat a teljes tanító adaton határozza meg a
TimeSeriesSplit előtt, majd minden foldban ugyanazzal a
mappinggel kódol. Mi a hiba, és hogyan javítanád a Pipeline
és a OneHotEncoder(min_frequency=...) használatával?
value_counts = train_pool["region"].value_counts(normalize=True)
rare_values = value_counts[value_counts < 0.05].index.tolist()
train_pool["region_grouped"] = train_pool["region"].where(
~train_pool["region"].isin(rare_values), other="rare_other"
)
# majd minden fold ezt a grouped oszlopot használjaMegváltozott feltétel. Tegyük fel, hogy a teszt
időszakban megjelenik egy új region érték,
east, amely a tanító adatban nem szerepelt. Magyarázd el,
hogyan viselkedik a handle_unknown="ignore" és a
handle_unknown="infrequent_if_exist" beállítás, és melyik
változat hogyan kezeli az új értéket. Milyen kockázatot hordoz a csupa
nulla one-hot kód?
Továbbvitel. A quantity_sum
eloszlásában egyetlen 10000 darabos rendelés kiugró érték. Számold ki a
nyers és a log transzformált érték standardizált, mediánnal
középpontozott helyzetét egy 11 elemű kis példán, ahol a medián 4, az öt
körüli átlagos értékek 2, 3, 4, 5, 6, a többi öt 500, és a kiugró 10000.
Hogyan befolyásolja a kiugró a nyers skálán a standardizálást?
A segment (consumer,
business) és a region (north,
central, south) nominális kategóriák: nincs
köztük természetes sorrend, ezért az ordinális kódolás mesterségesen
additív távolságot hozna létre. Például a region 0/1/2
kódja azt sugallná, hogy south kétszer olyan „távol” van
north-tól, mint central-tól, amit a domain nem
támaszt alá. Ordinális kódolás indokolt például egy
delivery_slot = morning, midday, evening változó esetén, ha
a kézbesítési sorrend monoton előrehaladást jelent. A feltétel: a
kategóriák között egyértelmű, a feladat szempontjából értelmezett
rendezési reláció van.
A függvény:
def log_transform_df(df):
out = df.copy()
for col in ["distance_km", "basket_minor", "quantity_sum"]:
out[col] = np.log(out[col])
return outEllenőrzés a kézi bemeneten: a kimenet shape-je (2, 3),
a transzformált értékek mind véges pozitívak, és mivel
,
a monotonitás fennáll. A FunctionTransformer-es változatban
a validate=False jelzi, hogy a kimenet nem szigorúan 2D-s
tömb, így a pandas DataFrame oszlopszerkezete megőrződik.
A kód a ritka küszöböt a teljes tanító halmazon számolja,
beleértve azokat a sorokat is, amelyek az adott foldban validációs
szerepet kapnak. Ez ugyanaz a szivárgási forma, mint amikor a
StandardScaler átlagát a teljes adaton illesztenénk: a
validációs fold kategóriaösszetétele visszahat a kódolásra. A javítás
az, hogy a rare döntést a
OneHotEncoder(min_frequency=0.05, handle_unknown="infrequent_if_exist")
kapja, és az encoder a ColumnTransformer részeként a
Pipeline belsejében legyen. Ekkor a fit minden
foldban csak a tanító részt látja, a küszöb ott érvényesül, a validációs
foldon pedig csak a már megtanult mapping alkalmazódik.
A handle_unknown="ignore" az east sort
csupa nullává alakítja: egyik tanult one-hot oszlop sem kap 1-et. Ez azt
jelenti, hogy a modell az új régiót a referencia-kategóriák egyikeként
sem ismeri fel, és implicit módon egy „ismeretlen” állapotba helyezi. A
handle_unknown="infrequent_if_exist" az ismeretlen értéket
a ritka gyűjtőkategóriába sorolja, tehát az east sort
ugyanúgy kezeli, mint a tanításkor ritkának ítélt kategóriákat, ha van
ilyen gyűjtőoszlop. A csupa nulla kód kockázata, hogy a lineáris modell
a ritka kategória együtthatóját nem tudja ehhez a sorhoz kapcsolni, így
az előrejelzés a numerikus oszlopokra támaszkodik; ez nem feltétlenül
rossz, de tudatos döntést igényel, és dokumentálni kell a
modellkártyán.
A nyers adatok:
[2,3,4,5,6,500,500,500,500,500,10000]. A medián 4. A nyers
eltérések [-2,-1,0,1,2,496,496,496,496,496,9996]. A
10000-es kiugró a nyers szórást nagyon megnöveli: az
eltérésnégyzet-összegben a 9996 négyzete önmagában közel
,
ezért a többi érték standardizált helyzete nullához préselődik. A log
skálán a kiugró
,
a többi nagy érték
,
a medián
;
a log transzformáció a kiugró hatását jelentősen csökkenti, mert a
tízszeres különbség additív
különbséggé válik. A standardizálás így stabilabb, a modell együtthatói
nem robbannak fel egyetlen sor miatt.
A leggyakoribb hiba a target encoding teljes adaton való számítása.
Legyen adott hat képzési sor, egyetlen region kategória, a
célváltozó [0,0,0,1,1,1], és négy validációs sor ugyanabból
a kategóriából, cél [1,1,0,0]. A teljes adaton számolt
csoportátlag
.
A validációs sorok mindegyike 0,5-öt kap, tehát a modell nem tudja őket
megkülönböztetni; ha viszont a kódolás a sor saját célját is látja,
például az átlagot úgy számoljuk, hogy a saját sort nem hagyjuk ki, az
első validációs sor, amelynek célja 1,
-et
kap, a nulla célú sor pedig
-ot.
A modell a teszt címkéből származó sorrendet tanulja meg, és az AUC a
validáción mesterségesen magas lesz. Ez a leakage ellentéte a Preprocessing tanulás kizárólag a training részen
alfejezetben tanult pipeline-szerződésnek: minden, a célváltozóból
származó statisztikát a megfelelő foldon belül kell becsülni. A target
encodingnál ez out-of-fold becslést vagy az Időhelyes aggregátum és targetfüggetlen feature
alfejezetben látott, csak a cutoffig rendelkezésre álló előzményből
számított simított átlagot jelent.
Önellenőrzés: a kategorikus kódoló paraméterei a tanító foldból illesztődnek-e? A ritka küszöböt nem a teljes adaton számoljuk-e? A log transzformáció előtt kezeljük-e a zérus vagy negatív értékeket? Az ismeretlen kategóriára van-e dokumentált kezelési stratégia? A target encodingból származó jellemzőt kizárólag out-of-fold módon használjuk-e?
A kategorikus reprezentációt a kategória természetéhez és a modellcsaládhoz kell kötni. Nominális változóknál a one-hot, rendezett kategóriánál az ordinális kódolás adható. A ritka kategóriákat a tanító foldon mért gyakoriság alapján gyűjtőkategóriába vonjuk össze; a log transzformáció a pozitív, ferde numerikus jellemzőket közelíti szimmetrikusabb skálára; a missing indicator a hiány tényét őrzi meg az imputálás mellett. A target encoding csak out-of-fold módon biztonságos, mert a teljes adaton számolt kategóriaátlag a jövőbeli címkét szivárogtatja. A laborban a teljes one-hot, a rare one-hot és a log-transzformált numerikus ág belső időbeli validáción mérhető össze; a következő Feature-szelekció és magas dimenzió a feature-szelekciót és a magas dimenziós reprezentációt kapcsolja ehhez a pipeline-szerződéshez.