25.2 Kategória, hiány és ferde változó

25.2.1 Cél és konkrét helyzet

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.

25.2.2 Fogalmi magyarázat

25.2.2.1 One-hot kódolás

A one-hot (magyarul „egy forró”) kódolás egy nn elemű mintán egy kk értékű kategorikus változót kk darab bináris oszloppá alakít. Ha a cc kategória értéke xix_i, akkor

zi,j={1,ha xi=cj,0különben, z_{i,j} = \begin{cases} 1, & \text{ha } x_i = c_j,\\ 0 & \text{különben,} \end{cases}

ahol j=1,…,kj=1,\dots,k, és cjc_j a tanításkor látott kategóriák kódja. A kimeneti mátrix alakja n×kn \times k, dtype általában int8 vagy boolean; a OneHotEncoder alapértelmezésben ritka mátrixot adhat, ami nagy kk esetén memóriatakarékos. A region = north sor a north, central, south készletben például [1,0,0][1,0,0] 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.

25.2.2.2 Ordinális kódolás

Az ordinális kódolás xi∈{c1,…,ck}x_i \in \{c_1,\dots,c_k\} értékekhez monoton 0,1,…,k−10,1,\dots,k-1 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.

25.2.2.3 Ritka kategória összevonása

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.

25.2.2.4 Log transzformáció

A log transzformáció ferde, pozitív értékeket közelít szimmetrikusabb skálára:

zi=ln(xi) z_i = \ln(x_i)

ha xi>0x_i > 0. Ha xi=0x_i=0 is lehetséges, akkor ln(xi+c)\ln(x_i + c) eltolást használunk, például c=1c=1. A logaritmus a szorzó különbségeket additív különbséggé alakítja: x=10x=10 és x=100x=100 között tízszeres, x=100x=100 és x=1000x=1000 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 ln1000−ln100=ln100−ln10=ln10≈2,3026\ln 1000 - \ln 100 = \ln 100 - \ln 10 = \ln 10 \approx 2{,}3026. 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.

25.2.2.5 Missing indicator

A missing indicator (hiányjelző) egy új bináris oszlop:

mi=1ha xi hiányzik, különben 0. m_i = 1 \quad\text{ha } x_i \text{ hiányzik, különben } 0.

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.

25.2.2.6 Target encoding kockázata

A target encoding a kategorikus értéket a célváltozó csoporton belüli átlagával helyettesíti. A becslés simított alakja

p̂c=∑i∈cyi+αp0nc+α, \hat{p}_c = \frac{\sum_{i \in c} y_i + \alpha p_0}{n_c + \alpha},

ahol ncn_c a cc kategória mintamérete, p0p_0 a teljes célarány prior, α\alpha 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.

25.2.3 Teljes minimális munkapélda

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.

25.2.4 Eredmény értelmezése

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 x=10x=10 esetén ln10≈2,3026\ln 10 \approx 2{,}3026, x=100x=100 esetén ln100≈4,6052\ln 100 \approx 4{,}6052; a nyers különbség 90, a log különbség ln10\ln 10. 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.

25.2.5 Feladatok

  1. É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.

  2. 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 ln(x)\ln(x)-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]}).

  3. 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álja
  4. Megvá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?

  5. 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?

25.2.6 Megoldások és indoklás

  1. 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.

  2. 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 out

    Ellenőrzés a kézi bemeneten: a kimenet shape-je (2, 3), a transzformált értékek mind véges pozitívak, és mivel x1<x2⇒lnx1<lnx2x_1 < x_2 \Rightarrow \ln x_1 < \ln x_2, 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.

  3. 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.

  4. 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.

  5. 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 10810^8, ezért a többi érték standardizált helyzete nullához préselődik. A log skálán a kiugró ln10000≈9,2103\ln 10000 \approx 9{,}2103, a többi nagy érték ln500≈6,2146\ln 500 \approx 6{,}2146, a medián ln4≈1,3863\ln 4 \approx 1{,}3863; a log transzformáció a kiugró hatását jelentősen csökkenti, mert a tízszeres különbség additív ln10\ln 10 különbséggé válik. A standardizálás így stabilabb, a modell együtthatói nem robbannak fel egyetlen sor miatt.

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

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 (0+0+0+1+1+1+1+1+0+0)/10=0,5(0+0+0+1+1+1+1+1+0+0)/10 = 0{,}5. 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, 6/9≈0,6676/9 \approx 0{,}667-et kap, a nulla célú sor pedig 5/9≈0,5565/9 \approx 0{,}556-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?

25.2.8 Rövid összefoglaló

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.