20.2 Időhelyes feature és gördülő visszateszt

20.2.1 Cél és konkrét helyzet

A Northstar webshop napi rendelésszámát szeretnénk egy nappal előre látni, hogy a raktár a másnapi kapacitást tervezhesse. A megfigyelési egység egy naptári nap, a bemenet a teljesített rendelések darabszáma aznap. A célváltozó yt+1y_{t+1}, ahol tt a döntési nap indexe; a horizont h=1h=1 nap. A döntéshozó a tt nap zárásakor rendelkezésre álló múltat ismeri, tehát yty_t-t már igen, yt+1y_{t+1}-t még nem.

A labor egy 60 napos, determinisztikus szintetikus sort használ, hogy a feature-ök minden gépen kézzel is ellenőrizhetők legyenek. Három előre rögzített originnal futtatunk gördülő visszatesztet: 2025-02-11, 2025-02-18 és 2025-02-25. Mindegyik originre a tanítóhalmaz az origint megelőző sorokból áll, a teszthalmaz pontosan a következő nap. Az induló becslés: a sor heti ciklust és enyhe trendet tartalmaz, ezért a naiv, utolsó megfigyelt értékes baseline a heti fázisváltásnál akár 15–17 rendeléses hibát is vét; egy 7 napos laget használó modelltől ennél jobbat várunk.

20.2.2 Fogalmi magyarázat

Legyen yty_t a tt napi rendelésszám, mértékegysége darab/nap, t=1,…,Nt=1,\dots,N egész napindex. A teljes megfigyelt sorozat alakja NN. Az ℱt\mathcal{F}_t jelölés azt az információhalmazt jelenti, amely a tt időpontban ténylegesen rendelkezésre áll: informálisan minden, ami legkésőbb a tt napig ismert. Egy prediktor akkor időhelyes, ha ℱt\mathcal{F}_t-mérhető, azaz nem használ tt-nél későbbi értéket.

Lag (késleltetés). A kk lépéses lag a múltbeli érték:

Lkyt=yt−k,k=1,2,…L^ky_t = y_{t-k}, \qquad k=1,2,\dots

Pandasban series.shift(k). A kimenet ugyanolyan hosszú, mint a bemenet; az első kk elem NaN, mert ott a múlt még nincs meg. Ha a cél yt+1y_{t+1}, akkor L1yt=ytL^1y_t=y_t még múlt, tehát használható predictor.

Rolling (mozgó ablak). A ww szélességű mozgóablak a tt napon záródó, ww napos múlt átlaga:

Rwyt=1w∑i=0w−1yt−i.R_wy_t=\frac{1}{w}\sum_{i=0}^{w-1} y_{t-i}.

Pandasban series.rolling(window=w).mean(). Az ablak t−w+1,…,tt-w+1,\dots,t napokat tartalmazza; a rolling önmagában nem néz a jövőbe. Ha a cél a tt napi érték lenne, a tt-t is tartalmazó ablak már a céllal átfedne; ilyenkor shift(1).rolling(w).mean() a t−1t-1-ig záródó ablakot adja. A mi célunk yt+1y_{t+1}, ezért a tt nap még múlt, és a tt záródású ablak megengedett.

Forecast horizon (előrejelzési horizont). A cél yt+hy_{t+h}, ahol hh a jövőbeli lépések száma. Az itteni labor h=1h=1 lépéssel dolgozik: a tt sorhoz tartozó címke a következő napi rendelésszám, kódban shift(-1). Az utolsó sor célja NaN, mert a sorozat végén túl nincs jövő.

Gap (tisztító hézag, purge gap). A gap a felhasznált múlt vége és a cél időszaka közötti, szándékosan kihagyott tartomány. Két szerepe van. Egyrészt a hétnapos horizontnál a t+1,…,t+6t+1,\dots,t+6 napok sem feature-ként, sem tanítócímkeként nem állnak rendelkezésre a tt napon. Másrészt a realisztikus folyamatban a címke maga is késve érkezik: a Northstar kezdőlaborban a label_available_at csak a tényleges kézbesítéskor lép életbe, ezért a cutoff előtti rendelések közül a még meg nem figyelt célú sorokat egy kihagyott határsávval zárjuk ki a tanításból. A gap tehát nem üres sor, hanem adatfolyami feltétel.

Expanding split. Az időbeli tanítóhalmaz a sorozat elejétől az originig bővül; a tesztablak mindig az origin után következik. Ha az origint tologatjuk, gördülő originhoz jutunk. Például az első origin a 41. nap, a második a 48., a harmadik az 55.; minden lépésben a tanítóhalmaz egyre hosszabb, a teszthalmaz mindig a soron következő nap.

Temporal leakage (időbeli szivárgás). Akkor lép fel, ha a modell olyan adatot használ, amely a döntés pillanatában még nem volt ismert. Formája lehet feature-szivárgás: például a célképzéshez használt shift(-1) oszlop véletlenül bekerül a predictorok közé; vagy split-szivárgás: a sorok véletlen összekeverésével a jövőbeli szomszédok a tanítóhalmazba kerülnek.

A shift és a rolling prefixaggregációkra vezethető vissza. Ha Ct=∑s=1tysC_t=\sum_{s=1}^{t}y_s a kumulált összeg C0=0C_0=0 mellett, akkor a mozgóablak egy prefixkülönbség:

Rwyt=Ct−Ct−ww,t>w.R_w y_t = \frac{C_t - C_{t-w}}{w}, \qquad t>w.

Mindkét oldal ℱt\mathcal{F}_t-mérhető: a tt napig tartó, kumulált múltból számol. Ez a matematikai hidunk: nem az egyes napokat memorizáljuk, hanem az időben monoton módon elérhető aggregátumokat.

20.2.3 Teljes minimális munkapélda

Először kézi kontroll egy 12 napos mini soron. A kód pandas 2.2.3-mal futtatható; a kimenetet nem a szövegíró futtatta, hanem a shift és rolling definíciójából levezetett elméleti érték.

import pandas as pd

idx = pd.date_range("2025-01-01", periods=12, freq="D")
df_mini = pd.DataFrame(
    {"orders": [10, 12, 9, 11, 13, 15, 14, 16, 18, 20, 19, 22]},
    index=idx,
)
df_mini["lag1"] = df_mini["orders"].shift(1)
df_mini["roll3_past"] = df_mini["orders"].shift(1).rolling(window=3).mean()
print(df_mini)

Elméletileg várt kimenet:

index orders lag1 roll3_past
2025-01-01 10 NaN NaN
2025-01-02 12 10.0 NaN
2025-01-03 9 12.0 NaN
2025-01-04 11 9.0 10.33
2025-01-05 13 11.0 10.67
2025-01-06 15 13.0 11.00
2025-01-07 14 15.0 13.00
2025-01-08 16 14.0 14.00
2025-01-09 18 16.0 15.00
2025-01-10 20 18.0 16.00
2025-01-11 19 20.0 18.00
2025-01-12 22 19.0 19.00

A 2025-01-04 sor roll3_past értéke a 01-01, 01-02 és 01-03 rendelések átlaga: (10+12+9)/3=10.33(10+12+9)/3=10.33. A shift(1) miatt a saját napja, a 11, nem szerepel az ablakban.

A laborra készítsük el a 60 napos determinisztikus sort és a három origint. A munkakönyvtár a projektgyökér; a fájlt mentsük s078_rolling_backtest.py néven, és futtassuk a lab uv környezetével:

uv run python s078_rolling_backtest.py
import numpy as np
import pandas as pd
import statsmodels.api as sm

t = np.arange(60)
raw = 40.0 + 20.0 * np.sin(2 * np.pi * t / 7) + 0.25 * t
daily = pd.Series(
    raw,
    index=pd.date_range("2025-01-01", periods=60, freq="D"),
    name="orders",
).round(1)

feat = pd.DataFrame({"orders": daily})
feat["lag1"] = feat["orders"].shift(1)
feat["lag7"] = feat["orders"].shift(7)
feat["roll7"] = feat["orders"].rolling(window=7).mean()
feat["target_lead1"] = feat["orders"].shift(-1)
feat = feat.dropna()

origins = pd.to_datetime(["2025-02-11", "2025-02-18", "2025-02-25"])

for origin in origins:
    train = feat.loc[feat.index < origin]
    test_row = feat.loc[origin]

    baseline_pred = test_row["orders"]

    X_train = sm.add_constant(train[["orders", "lag7"]])
    y_train = train["target_lead1"]
    model = sm.OLS(y_train, X_train).fit()

    x_test = np.array([[1.0, test_row["orders"], test_row["lag7"]]])
    model_pred = float(model.predict(x_test)[0])

    print(
        f"origin={origin.date()} baseline={baseline_pred:.1f} "
        f"model={model_pred:.1f} actual={test_row['target_lead1']:.1f}"
    )

A naiv baseline predikciója az origin napi érték, tehát yoriginy_{\text{origin}}, mert h=1h=1 mellett ezt ismerjük utoljára. A modell az orders jelenlegi értékét és a 7 napos laget használja. A három sor elméletileg várt baseline és tényértéke a determinisztikus képletből:

origin=2025-02-11 baseline=34.6 model=<futtatástól függ> actual=50.5
origin=2025-02-18 baseline=36.4 model=<futtatástól függ> actual=52.2
origin=2025-02-25 baseline=38.1 model=<futtatástól függ> actual=54.0

A model oszlop a statsmodels által megoldott legkisebb négyzetes becslés eredménye; ez a szemléltető kimenet, nem a könyvírás során mért érték.

20.2.4 Eredmény értelmezése

A három origin a tiszta időbeli felosztás szerződését mutatja. Minden originra a tanítóhalmaz csak az origint megelőző sorokból áll, ezért a legkésőbbi tanítócímke az origin napi rendelésszám, amely a nap zárásakor már ismert. A tesztsor az origin naphoz tartozik, célja az origin+1 nap; ez a döntés pillanatában még jövő, de az értékeléskor már tény. A naiv baseline a három fázisváltós napon rendre alábecsül: a hétfői/vasárnapi ciklusból adódó emelkedést nem tudja. A heti laget használó modell a 7 nappal korábbi hasonló hétnapot látja, ezért várhatóan közelebb kerül a tényhez.

A kód időhelyességi feltétele: a prediktoroszlopokban nincs a sor napjánál későbbi információ. A target_lead1 céloszlop szándékosan a következő napból származik, ezért az előbbi állítás a teljes feat táblára nem igaz. A lag7 minden sora t−7t-7-ből jön, a roll7 ablak t−6,…,tt-6,\dots,t napokat fed le, a cél t+1t+1. A tanítóhalmaz index < origin, tehát az OLS illesztésekor a legkésőbbi felhasznált nap az origin előtti; a tesztpredikció az origin napi múltból számol.

A három origin hibáinak átlaga nem konfidenciaintervallum. A Tesztválasztás és hatásméret alfejezetben tanultak szerint kis számú, összefüggő hiba esetén a foldok szórása nem becsli megbízhatóan a mintavételi bizonytalanságot. A DuckDB: helyi analitikus SQL szerver nélkül alfejezetben tanult egy sor, egy megfigyelési egység elv itt napi szinten érvényes: ha valaki a rendeléstételeket napokra összegezné, az aggregáció előtt szemcsézettségi kontroll kell.

As-of-validáció: gördülő visszatesztnél minden feature előállítása kizárólag az adott origin időpontban rendelkezésre álló adatokból történhet. A teljes idősoron becsült imputáció, skálázás vagy szezonális komponens időbeli szivárgást okozhat.

20.2.5 Feladatok

  1. Értelmezés. A mini táblázatban add meg a 2025-01-07, 2025-01-08 és 2025-01-12 sorok lag1 és roll3_past értékeit. Melyik sorban először értelmezhető a roll3_past, és miért pont ott?

  2. Implementálás. A 60 napos labor daily sorához készítsd el a lag7 és a roll7 oszlopot, majd írasd ki a 2025-02-26 és 2025-03-01 közötti négy sort az orders, lag7, roll7 oszlopokkal. Ellenőrizd, hogy az utolsó sor roll7 értéke a tőle visszafelé számított hét nap átlaga.

  3. Hibadiagnózis. Egy kolléga a következőt futtatja a 60 napos adaton:

from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error

X = df[["orders"]].iloc[:-1]
y = df["orders"].shift(-1).iloc[:-1]
X_tr, X_te, y_tr, y_te = train_test_split(
    X, y, test_size=0.3, random_state=0
)
m = LinearRegression().fit(X_tr, y_tr)
print(np.sqrt(mean_squared_error(y_te, m.predict(X_te))))

A kolléga 1.7-es RMSE-t kap, miközben a valós egy lépéses előrejelzés hibája a laborban 10 felett van. Mi a hiba a szerződésben, és hogyan javítanád úgy, hogy az értékelés a döntési pillanatban elérhető múltat tükrözze?

  1. Megváltozott feltétel. Az üzlet most a 7 nappal későbbi rendelésszámot szeretné előrejelezni, tehát h=7h=7. Írd át a labor feature- és targetképzését, valamint a tanító/teszt felosztást. Add meg a cél oszlopot, a gap feltételt, és azt, hogy a tanítócímkéknek mikorra kell megérkezniük az origin pillanatához képest.

20.2.6 Megoldások és indoklás

  1. A kért sorok: 2025-01-07 lag1=15.0, roll3_past=13.00; 2025-01-08 lag1=14.0, roll3_past=14.00; 2025-01-12 lag1=19.0, roll3_past=19.00. A roll3_past a 2025-01-04 sorban értelmezhető először, mert a shift(1) után a 01-04 sorban a 01-03, 01-02, 01-01 hármas áll; a 01-03 soron a shiftelt ablak még tartalmazza a 01-01 NaN-ját.

  2. A megoldás a labor feature-kódját használja:

feat["lag7"] = feat["orders"].shift(7)
feat["roll7"] = feat["orders"].rolling(window=7).mean()
print(feat.loc["2025-02-26":"2025-03-01", ["orders", "lag7", "roll7"]])

Az utolsó sor 2025-03-01, indexe t=59t=59. A roll7 ablak a 2025-02-23 és 2025-03-01 közötti hét nap. Elfogadási tűrés: a kerekített napi értékekkel számolva az ellenőrző átlag és a kinyomtatott roll7 eltérése kisebb, mint 0.1. Amelyik sor a saját napját is belevenné a lag7 helyére, az invariáns megsértője.

  1. A kolléga kódja egyszerre vét feature-leakage-et és split-leakage-et. Az orders oszlop a tt nap értéke, a címke shift(-1) a t+1t+1 nap. A train_test_split alapértelmezett shuffle=True keveréssel a szomszédos sorokat együtt dobálja: a tanítóhalmazba olyan tt sorokból származó t+1t+1 értékek kerülnek, amelyek a teszthalmaz orders jellemzőivel majdnem azonosak. Ettől a modell szinte tökéletesnek látszik, miközben valós használatkor a t+1t+1 nap nem ismert. Javítás: időrendi split, keverés nélkül, előre rögzített originnal:
origin = feat.index[41]
train = feat.loc[feat.index < origin]
X_tr = train[["orders"]]
y_tr = train["target_lead1"]
test = feat.loc[[origin]]
# predikció a test orders-ből
  1. A h=7h=7 szerződésben a cél target_lead7 = orders.shift(-7). A feature-ök legkésőbb tt-ig használhatók; a roll7 a tt-ig záródó ablak. A gap: a t+1,…,t+6t+1,\dots,t+6 napok sem a targetben, sem a feature-ökben nem jelenhetnek meg, és a tanítócímke akkor áll rendelkezésre, ha t+7t+7 teljesült. Az origin napon a tanítóhalmaz utolsó megengedett sora ezért t≤origin−7t \le \text{origin}-7; a teszt sor az origin nap, célja origin+7. A Northstar kezdőlabor label_available_at < cutoff feltétele ugyanezt a purging gapet írja elő a 7 napos kézbesítési célra.

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

A leggyakoribb hiba az, hogy a véletlen train_test_split az idősorokon biztonságosabbnak tűnik a random_state rögzítése miatt, de a reprodukálhatóság nem pótolja az időbeli szerződést. A fenti 3. feladatban a véletlen keveréses modell 1.7-es RMSE-t mutat, miközben ugyanaz a predictor valós, egy lépéses használatban a heti ciklus fázisváltásainál 15-17 rendeléses hibát követ el. A random_state csak azt garantálja, hogy ugyanaz a hibás sorrend ismétlődik; nem azt, hogy a jövő ne szivárogjon a múltba. Az Időtengely, resampling és állomásossági kérdés alfejezetben tanult frekvencia és gap itt döntő: a rendszeres napi rács mellett is külön kell kérdezni, hogy a címke mikortól része ℱt\mathcal{F}_t-nek.

Önellenőrzés: minden predictorom indexe legfeljebb a döntési nap? A target minden sora a döntési nap után h lépéssel van? A tanító sorok címkéi megérkeztek-e az originig? Ha bármelyik kérdésre nemleges a válasz, a gapet vagy a splitet javítani kell.

20.2.8 Rövid összefoglaló

A lag és a rolling csak akkor időhelyes, ha kizárólag az ℱt\mathcal{F}_t-ben ismert múltból számol; a cél yt+hy_{t+h} horizontjához képest a tt nap még múlt lehet, a t+1t+1 már nem. A gap a címke késését és a hétnapos horizont közbülső napjait vágja ki; a Northstar label_available_at szűrése ugyanezt a szerződést valósítja meg. Az expanding split az originig növekvő tanítóablakot, utána jövőbeli tesztnapot ad; a véletlen keverés idősoron szivárgást okoz. A három origin hibája nem statisztikai intervallum. Következő alkalmazásként az Egyszerű előrejelző modellek és baseline alfejezetben a naiv és szezonális baseline mellé egyszerű, időhelyes feature-ökkel dolgozó előrejelző modelleket építünk.