Time Series Basics
Temporal Order Cannot Be Ignored
Standard ML assumes samples are independent and identically distributed (IID). Time series violates this fundamentally — tomorrow's sales depend on today's sales. Shuffling training data destroys temporal information, and using future data to predict the past is look-ahead bias (a form of leakage).
The three structural components of most time series are: Trend (long-term upward/downward direction), Seasonality (repeating patterns at fixed periods — weekly, yearly), and Residual (unexplained noise after removing trend and seasonality).
from statsmodels.tsa.seasonal import seasonal_decompose import pandas as pd # Parse dates, set as index with monthly frequency df['date'] = pd.to_datetime(df['date']) df = df.set_index('date').asfreq('M') # Multiplicative: trend * seasonality * residual (use when amplitude grows) # Additive: trend + seasonality + residual (use when amplitude is constant) result = seasonal_decompose(df['sales'], model='multiplicative', period=12) result.plot() # shows 4 subplots: original, trend, seasonal, residual # Access components individually trend = result.trend seasonal = result.seasonal residual = result.resid
train_test_split(shuffle=True) on time series, you create look-ahead bias — the model trains on data from 2024 to predict data from 2022. Future information contaminates the past. Always split chronologically: the last N% of time steps as the test set.Auto-Regression, Integration, Moving Average
ARIMA(p,d,q) is the workhorse of classical forecasting. It combines three mechanisms to model stationary time series:
from statsmodels.tsa.arima.model import ARIMA # Manual ARIMA(1,1,1) — AR=1, difference once, MA=1 model = ARIMA(train, order=(1, 1, 1)) result = model.fit() forecast = result.forecast(steps=12) print(result.summary()) # Auto ARIMA: tests combinations and picks best by AIC from pmdarima import auto_arima # pip install pmdarima auto_model = auto_arima( train, seasonal=True, m=12, # monthly seasonality → SARIMA stepwise=True, # faster search strategy information_criterion='aic' # Akaike IC for model selection ) print(f"Best order: {auto_model.order}")
Using sklearn Models on Time Series
You don't have to use ARIMA. Any sklearn model (XGBoost, Random Forest) can forecast time series if you convert the problem into supervised learning by creating lag features: the value at time t-1, t-2, t-7, etc. This approach often outperforms ARIMA on complex series with external predictors.
For cross-validation, use TimeSeriesSplit instead of KFold — it creates expanding windows where validation always follows training chronologically.
from sklearn.model_selection import TimeSeriesSplit from xgboost import XGBRegressor # ── Create lag and rolling features ───────────────────── df = df.sort_values('date') for lag in [1, 2, 3, 7, 14, 28]: # 1–4 weeks back df[f'lag_{lag}'] = df['sales'].shift(lag) # Rolling statistics (shift 1 to avoid using current value) df['roll_7_mean'] = df['sales'].shift(1).rolling(7).mean() df['roll_28_std'] = df['sales'].shift(1).rolling(28).std() # Drop rows with NaN from lagging df = df.dropna() # ── Correct CV: always train before test chronologically ─ tss = TimeSeriesSplit(n_splits=5) scores = [] for train_idx, val_idx in tss.split(X): X_tr, X_val = X.iloc[train_idx], X.iloc[val_idx] y_tr, y_val = y.iloc[train_idx], y.iloc[val_idx] model = XGBRegressor(n_estimators=200) model.fit(X_tr, y_tr) scores.append(model.score(X_val, y_val)) print(f"Mean R²: {sum(scores)/len(scores):.3f}")
.shift(1).rolling(7).mean() computes the 7-day mean of the 7 days before the current row. Without .shift(1), the rolling mean includes the current value — your model learns from the value it is trying to predict, which is perfect on training data but impossible in production.Full Pipeline: Decompose → Feature → Forecast
Combining everything into a real forecasting pipeline using the Rossmann Store Sales pattern (daily store sales with promotions and holidays).
import pandas as pd import numpy as np from xgboost import XGBRegressor from sklearn.metrics import mean_absolute_percentage_error # 1. Load and sort by date df['Date'] = pd.to_datetime(df['Date']) df = df.sort_values('Date').reset_index(drop=True) # 2. Date features df['year'] = df['Date'].dt.year df['month'] = df['Date'].dt.month df['dayofweek'] = df['Date'].dt.dayofweek df['dayofyear'] = df['Date'].dt.dayofyear df['week'] = df['Date'].dt.isocalendar().week.astype(int) # 3. Lag features (group by store to avoid store cross-contamination) for lag in [1, 7, 14, 28]: df[f'sales_lag_{lag}'] = ( df.groupby('Store')['Sales'].shift(lag) ) df['sales_roll7'] = df.groupby('Store')['Sales'].shift(1).rolling(7).mean() df = df.dropna() # 4. Chronological split — last 6 weeks = test cutoff = df['Date'].max() - pd.Timedelta(weeks=6) train = df[df['Date'] <= cutoff] test = df[df['Date'] > cutoff] features = ['year', 'month', 'dayofweek', 'Promo', 'sales_lag_1', 'sales_lag_7', 'sales_roll7'] model = XGBRegressor(n_estimators=500, learning_rate=0.05) model.fit(train[features], train['Sales']) y_pred = model.predict(test[features]) mape = mean_absolute_percentage_error(test['Sales'], y_pred) print(f"Test MAPE: {mape:.2%}") # typically 5–15% on this dataset