Tidsserier
Kunna dela upp tidsserier korrekt, bygga lag-features och undvika framtidsläckage.
Förkunskaper
Intuition
Tidsserier bryter mot antagandet all annan maskininlärning vilar på: att observationerna är oberoende och utbytbara. Det får tre konsekvenser.
1. Slumpmässig uppdelning är fel. Tränar du på slumpvis valda dagar och testar på resten har modellen sett framtiden. Dela kronologiskt:
|--------- träning ---------|--- val ---|--- test ---|
2024 2025 2026
2. Baslinjen är svårare än man tror. «I morgon blir som i dag» (naiv prognos) och «samma veckodag förra veckan» (säsongsnaiv) är förvånansvärt starka. Slår din modell inte dem har den inget värde.
3. Framtidsläckage är lätt att råka ut för. Ett rullande medelvärde som råkar inkludera nuvarande eller kommande värden ger fantastiska resultat i utvärderingen och värdelösa i drift.
Formellt
Features som inte läcker:
| Feature | Konstruktion | Fälla |
|---|---|---|
| Lag | y.shift(k) med k ≥ prognoshorisont | shift(1) när du prognosticerar 7 dagar fram är läckage |
| Rullande medel | y.shift(1).rolling(w).mean() | rolling(w) utan shift inkluderar nuvarande värde |
| Differens | y.shift(1) - y.shift(2) | |
| Kalender | veckodag, månad, helgdag | inget läckage — känt i förväg |
| Externa | väder, kampanj | måste vara känt vid prognostillfället |
Regeln: varje feature måste gå att beräkna med enbart information som fanns vid prognostillfället. Skriv ner tidpunkten och fråga för varje kolumn: visste vi detta då?
Korsvalidering görs med expanderande eller glidande fönster, aldrig med KFold:
veck 1: [träna....] [test]
veck 2: [träna.......] [test]
veck 3: [träna..........] [test]
sklearn.model_selection.TimeSeriesSplit gör detta. Lägg dessutom in ett gap mellan träning och test om målet beror på flera dagar bakåt, annars läcker det över gränsen.
Mått, och när de vilseleder:
| Mått | Bra på | Fälla |
|---|---|---|
| MAE | tolkbart i enheten | |
| RMSE | straffar stora fel | känsligt för utstickare |
| MAPE | procentuellt, jämförbart | exploderar när det sanna värdet är nära 0 |
| sMAPE | symmetriskt | fortfarande instabilt vid små värden |
| MASE | skalat mot naiv prognos | det ärligaste — 1,0 betyder «lika bra som naiv» |
MASE är att föredra just för att den har en självklar referenspunkt: över 1,0 är modellen sämre än att gissa gårdagens värde.
Metodval: börja med de klassiska. ARIMA, exponentiell utjämning och särskilt gradient boosting på lag-features är svårslagna på de flesta verkliga serier. Djupa sekvensmodeller vinner först vid många parallella serier och mycket data — och i M-tävlingarna har enkla statistiska metoder länge hållit sig i toppen.
Kod
import numpy as np, pandas as pd
from sklearn.model_selection import TimeSeriesSplit
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.metrics import mean_absolute_error
rng = np.random.default_rng(0)
n = 730
idx = pd.date_range("2024-01-01", periods=n, freq="D")
trend = np.linspace(100, 140, n)
veckosasong = 12 * np.sin(2 * np.pi * np.arange(n) / 7)
y = trend + veckosasong + rng.normal(0, 5, n)
df = pd.DataFrame({"y": y}, index=idx)
HORISONT = 7 # vi prognosticerar 7 dagar fram
def bygg_features(df, horisont=HORISONT):
d = df.copy()
# Alla lag måste vara minst `horisont` steg bakåt — annars läckage
for lag in (horisont, horisont + 1, horisont + 7, horisont + 14):
d[f"lag_{lag}"] = d["y"].shift(lag)
for w in (7, 28):
d[f"medel_{w}"] = d["y"].shift(horisont).rolling(w).mean()
d[f"std_{w}"] = d["y"].shift(horisont).rolling(w).std()
d["veckodag"] = d.index.dayofweek
d["manad"] = d.index.month
return d.dropna()
d = bygg_features(df)
X, mal = d.drop(columns="y"), d["y"]
# Baslinjer först — de måste slås
naiv = d["lag_7"] # "samma dag förra veckan"
print(f"säsongsnaiv MAE: {mean_absolute_error(mal, naiv):.2f}")
# Kronologisk korsvalidering med gap
tscv = TimeSeriesSplit(n_splits=5, gap=HORISONT)
fel = []
for tr, te in tscv.split(X):
m = HistGradientBoostingRegressor(random_state=0).fit(X.iloc[tr], mal.iloc[tr])
fel.append(mean_absolute_error(mal.iloc[te], m.predict(X.iloc[te])))
print(f"modell MAE: {np.mean(fel):.2f} ± {np.std(fel):.2f}")
# MASE: skalat mot den naiva prognosen — 1,0 betyder "lika bra som naiv"
def mase(sant, pred, naiv_pred):
return mean_absolute_error(sant, pred) / mean_absolute_error(sant, naiv_pred)
# SÅ HÄR SER LÄCKAGE UT — och hur bra det ser ut i utvärderingen
lackt = df.copy()
lackt["medel_7_LACKER"] = lackt["y"].rolling(7).mean() # ingen shift!
print("korrelation mellan läckande feature och målet:",
round(float(lackt[["y", "medel_7_LACKER"]].dropna().corr().iloc[0, 1]), 4))
# ~0.95 — modellen får i praktiken se svaret, och MAE blir orealistiskt låg
Sista blocket är värt att köra. En rullande medelvärdesfeature utan shift korrelerar nära perfekt med målet, ger ett fantastiskt valideringsresultat, och fungerar inte alls i drift — eftersom värdet den bygger på inte finns när prognosen ska göras.
Behärskning innebär
- Delar upp tidsserier kronologiskt
- Bygger lag- och rullande features utan läckage
- Väljer baslinje och mått medvetet
Logga in för att göra övningarna och bygga upp din behärskning.
Källor
- Hyndman & Athanasopoulos — Forecasting: Principles and Practice — fri att läsa online
- scikit-learn User Guide (BSD-3) — BSD-3-Clause
- pandas — User Guide (BSD-3) — BSD-3-Clause