Yarın kaç bisiklet gerekecek?
Pedal İzmir operasyon ekibi her gece kamyonlarla bisikletleri istasyonlar arasında taşıyor. Sabah 8'de Alsancak'ta bisiklet kalmazsa müşteri kaybediliyor, Bostanlı'da yığılırsa boş yuva kalmıyor. Bunun için yarının saatlik talebini bilmeleri gerekiyor. Bu bir zaman serisi tahmini (time series forecasting) problemidir: geçmiş gözlemlerin sırasından ve düzeninden geleceği çıkarmak.
Zaman serisinin pandas tarafını (resample, shift, rolling) Zaman serisi analizi dersinde gördün. Bu derste tahmin yapacaksın: önce yenmesi gereken temel çizgiler, sonra bileşen ayrıştırma, üstel düzeltme (ETS), ARIMA/SARIMAX, lag özellikli makine öğrenmesi ve hepsini dürüstçe karşılaştıran geriye dönük test (backtest). Veri: Pedal İzmir'in Haziran–Ağustos 2024 yolculukları (116 517 yolculuk) ve saatlik hava gözlemleri.
Tahmin problemini tanımla: ufuk, sıklık, toplama seviyesi
| Karar | Soru | Pedal İzmir örneği |
|---|---|---|
| Ufuk (horizon) | Ne kadar ileriyi tahmin ediyorsun? | Yarının 24 saati (ufuk 1–24 saat ya da gün öncesi) |
| Sıklık | Hangi zaman adımında? | Saatlik (operasyon), günlük (bütçe) |
| Toplama seviyesi | Hangi birimde? | Şehir toplamı mı, istasyon başına mı? |
| Bilgi anı | Tahmin anında ne biliniyor? | Dünün talebi ve yarının hava tahmini; bugünün öğleden sonrası değil |
Toplama seviyesi yükseldikçe seri düzgünleşir ve tahmin kolaylaşır: tek bir istasyonun saatlik talebi gürültülüdür, şehrin toplamı değildir. "Bilgi anı" ise sızıntının kaynağıdır: yarını tahmin ederken lag 1 (bir saat önce) kullanamazsın; en kısa lag 24 saattir.
import numpy as np
import pandas as pd
import veri
yol = veri.oku("pedal_izmir", "bk_trips", usecols=["started_at"], parse_dates=["started_at"])
saatlik = yol.set_index("started_at").resample("h").size()
saatlik = saatlik.reindex(pd.date_range("2024-06-01", "2024-08-31 23:00", freq="h"),
fill_value=0).rename("talep") # yolculuksuz saat = 0
gunluk = saatlik.resample("D").sum()
print(len(saatlik), "saat,", len(gunluk), "gün; toplam", saatlik.sum())
print("Saat profili:", saatlik.groupby(saatlik.index.hour).mean().round(0).astype(int).tolist())
print("Gün profili:", gunluk.groupby(gunluk.index.dayofweek).mean().round(0).astype(int).tolist())
İki mevsimsellik var: günlük (sabah 8 ve akşam 18 tepeleri, gece neredeyse sıfır) ve haftalık (hafta sonu daha düşük). Saatlik seride haftalık döngü 168 adımdır.
Temel çizgiler: önce bunları yen
Karmaşık bir model kurmadan önce basit tahmincilerin skorunu bil. Şaşırtıcı sıklıkla en iyileri bunlardır.
- Naive: geleceğin her adımı son gözlenen değere eşit.
- Mevsimsel naive (seasonal naive): her adım, bir mevsim önceki değere eşit: saatlik seride geçen haftanın aynı saati (lag 168) ya da dünün aynı saati (lag 24).
- Hareketli ortalama: son k gözlemin ortalaması (düz bir çizgi).
egitim, test = saatlik[:-168], saatlik[-168:] # son hafta test
tahminler = {
"naive": np.repeat(egitim.iloc[-1], 168),
"mevsimsel_24": np.tile(egitim[-24:].to_numpy(), 7),
"mevsimsel_168": egitim[-168:].to_numpy(),
"ort_168": np.repeat(egitim[-168:].mean(), 168),
}
for ad, t in tahminler.items():
print(f"{ad:14s} MAE = {np.mean(np.abs(test.to_numpy() - t)):.2f}")
Geçen haftanın aynı saati (MAE ≈ 7.7) dünün aynı saatinden (≈ 26) çok daha iyi; çünkü hafta sonu ile hafta içini ayırıyor. Bu, yenmen gereken çıtadır.
Metrikler ve tuzakları
| Metrik | Formül (özet) | Tuzak |
|---|---|---|
| MAE | ortalama |y − ŷ| | Ölçeğe bağlı; seriler arası kıyas yapılamaz |
| RMSE | √ortalama (y − ŷ)² | Büyük hataları ağır cezalandırır (yağmurlu saatler) |
| MAPE | ortalama |y − ŷ| / |y| | y = 0 olduğunda tanımsız; gece saatleri sonsuz hata üretir; düşük tahmini kayırır |
| sMAPE | ortalama 2|y − ŷ| / (|y| + |ŷ|) | 0–200 arası; ikisi de 0 ise 0 say |
| MASE | MAE / eğitimdeki mevsimsel naive MAE'si | 1'in altı = mevsimsel naive'den iyi; ölçekten bağımsız |
Bileşen ayrıştırma ve durağanlık
STL (Seasonal-Trend decomposition using Loess) seriyi üç parçaya ayırır: trend, mevsimsel bileşen ve kalıntı. Kalıntının varyansı mevsimsel+kalıntıya göre küçükse mevsimsellik güçlüdür: F = max(0, 1 − Var(kalıntı) / Var(mevsim + kalıntı)). ARIMA ailesi ise durağan (stationary: ortalaması ve varyansı zamanla değişmeyen) seri ister. ADF testi (Augmented Dickey-Fuller) "seride birim kök var, durağan değil" boş hipotezini sınar; p < 0.05 ise durağan kabul edilir. Durağan değilse fark alınır (ARIMA'daki d).
from statsmodels.tsa.seasonal import STL
from statsmodels.tsa.stattools import adfuller
stl = STL(saatlik, period=24).fit()
guc = max(0.0, 1 - np.var(stl.resid) / np.var(stl.seasonal + stl.resid))
print("Günlük mevsimsellik gücü:", round(guc, 3))
print("ADF p (günlük toplam):", f"{adfuller(gunluk)[1]:.2e}")
Üstel düzeltme (ETS, Holt-Winters)
Üstel düzeltme yakın geçmişe daha çok ağırlık verir. Holt-Winters bunu üç bileşene uygular: seviye, trend ve mevsim. statsmodels'ta ExponentialSmoothing sınıfıyla kurulur; seasonal="add" mevsimsel etkinin sabit büyüklükte, "mul" seviyeyle orantılı olduğunu varsayar.
import warnings
from statsmodels.tsa.holtwinters import ExponentialSmoothing
warnings.filterwarnings("ignore")
g_egt, g_test = gunluk[:-14], gunluk[-14:]
ets = ExponentialSmoothing(g_egt, seasonal="add", seasonal_periods=7).fit()
ets_t = ets.forecast(14)
mevs = np.tile(g_egt[-7:].to_numpy(), 2)
print("ETS MAE:", round(np.mean(np.abs(g_test - ets_t)), 1),
" mevsimsel naive MAE:", round(np.mean(np.abs(g_test.to_numpy() - mevs)), 1))
ARIMA ve SARIMAX: dışsal değişkenle tahmin
ARIMA(p, d, q) seriyi kendi geçmiş değerleri (AR, p), fark alma (I, d) ve geçmiş hataları (MA, q) ile modeller. SARIMA buna mevsimsel bir parça ekler: seasonal_order=(P, D, Q, s). SARIMAX'teki X, dışsal (exogenous) değişkendir: yağış gibi talebi etkileyen ama talepten etkilenmeyen bir seri. Günlük talep ile günlük yağış arasındaki korelasyon −0.90; yağmurlu bir günü yalnız geçmişe bakan bir model göremez.
from statsmodels.tsa.statespace.sarimax import SARIMAX
hava = veri.oku("pedal_izmir", "bk_weather", parse_dates=["obs_hour"]).set_index("obs_hour")
yagis = hava["rain_mm"].resample("D").sum().rename("yagis").to_frame()
sx = SARIMAX(g_egt, exog=yagis[:-14], order=(1, 0, 0), seasonal_order=(1, 0, 0, 7),
trend="c").fit(disp=False, maxiter=200)
ongoru = sx.get_forecast(14, exog=yagis[-14:]) # gelecek yağış = hava tahmini
print("yağış katsayısı:", round(sx.params["yagis"], 1))
print("SARIMAX MAE:", round(np.mean(np.abs(g_test - ongoru.predicted_mean)), 1))
print(ongoru.conf_int(alpha=0.2).round(0).head(3)) # %80 tahmin aralığı
Lag özellikli makine öğrenmesi
Zaman serisini tablo problemine çevirebilirsin: her satır bir saat; özellikler saat, hafta sonu, lag 24, lag 168, hava; hedef o saatin talebi. Sonra Ridge, gradient boosting ya da herhangi bir regresyon modeli. Çok adımlı tahminde iki strateji var:
- Özyinelemeli (recursive): tek adımlık modeli kullan, tahmini bir sonraki adımın lag'i olarak geri besle. Basit ama hata birikir.
- Doğrudan (direct): her ufuk h için ayrı bir model eğit (y(t+h) ~ t anındaki bilgi). Hata birikmez ama H model gerekir.
Lag'leri ufuktan küçük seçmezsen (gün öncesi tahminde lag ≥ 24) iki strateji de gerekmez: tüm özellikler tahmin anında bilinir.
from sklearn.linear_model import Ridge
from sklearn.model_selection import TimeSeriesSplit
d = saatlik.to_frame().join(hava[["rain_mm", "temp_c"]])
d["lag24"], d["lag168"] = d["talep"].shift(24), d["talep"].shift(168)
d["hs"] = (d.index.dayofweek >= 5).astype(int)
d = d.dropna()
X = pd.get_dummies(d.index.hour, prefix="s").astype(float).set_index(d.index)
X = X.join(d[["hs", "lag24", "lag168", "rain_mm", "temp_c"]])
for i, (egt, tst) in enumerate(TimeSeriesSplit(n_splits=3, test_size=168).split(X)):
m = Ridge().fit(X.iloc[egt], d["talep"].iloc[egt])
mae = np.mean(np.abs(m.predict(X.iloc[tst]) - d["talep"].iloc[tst]))
print(f"kat {i}: eğitim {len(egt)} saat, test {X.index[tst[0]].date()} → MAE {mae:.2f}")
Geriye dönük test: genişleyen pencere
Zaman serisinde rastgele bölme, modelin geleceği görerek geçmişi tahmin etmesi demektir. Doğrusu genişleyen pencere (expanding window): 1 Ağustos'a kadar eğit, bir hafta tahmin et; 8 Ağustos'a kadar eğit, bir sonraki haftayı tahmin et... TimeSeriesSplit bunu otomatik yapar; test_size ufku, gap eğitim ile test arasındaki boşluğu (bilgi gecikmesi) ayarlar. Tek bir test haftası şanslı ya da şanssız olabilir; birkaç katın ortalaması ve dağılımı karar için gerekir.
conf_int) varsayımlara dayanır. Daha sağlam yol: backtest hatalarının yüzdelikleri. Geçmiş hataların %10 ve %90 yüzdeliklerini tahmine ekle, sonra yeni dönemde gerçeklerin yüzde kaçının aralığa düştüğünü (kapsama) ölç.Hiyerarşik tahmin
İstasyon tahminlerinin toplamı şehir tahminine eşit olmalı (tutarlılık, coherence). Alttan yukarı topla, üstten aşağı oranla dağıt ya da MinT uzlaştırma kullan (Nixtla hierarchicalforecast).
Kütüphane manzarası
statsmodels klasik modeller; sktime ve Darts tek arayüzde istatistiksel + ML modelleri ve backtest; Nixtla statsforecast binlerce seriyi hızlı ETS/ARIMA ile; Prophet tatil ve çoklu mevsimsellikte pratik; LightGBM + lag M5 yarışmasının kazananı. Derin öğrenme (N-BEATS, TFT) çok sayıda ilişkili seride öne çıkar.
Mülakat cümlesi
"Önce mevsimsel naive temel çizgisini ve MASE'i raporlarım. Bölmeyi zamana göre, genişleyen pencereyle yaparım; özellikleri tahmin anında bilinenlerle sınırlarım. MAPE'yi sıfırlı serilerde kullanmam; aralıkları backtest hatalarından kurar ve kapsamasını ölçerim."