Time series data powers every prediction we make about demand, traffic, and revenue—yet many teams stumble on the fundamentals before reaching sophisticated models. Getting the basics right means understanding how to structure your data, respect temporal order, and build features that capture real-world patterns your business actually experiences.
In this practical guide, we'll walk through time series forecasting basics: best practices explained, covering the core techniques that prevent leakage, exploit temporal dependencies, and establish baselines that keep you grounded in reality.
Why time series forecasting is different
Unlike classification or regression on static datasets, time series forecasting requires discipline around one constraint: you cannot shuffle your training data. If you're predicting next month's revenue, you cannot train on data from next month and test on last month. The order matters because future information must never leak into your training set.
We've seen teams accidentally train on future data and deploy models with beautiful validation metrics that fail silently in production. By the time they discover the problem, weeks of forecasts are questionable. Respecting temporal order isn't optional—it's the foundation.
Respect time order in splits
The first rule: split your data in time order, never randomly. If you have 24 months of historical data and want a train-test split, pick a cutoff date. Everything before is training; everything after is testing. No shuffling, no stratification tricks.
For example, if you're forecasting daily website traffic:
import pandas as pd
import numpy as np
# Load your time series
df = pd.read_csv('traffic.csv', parse_dates=['date'])
df = df.sort_values('date').reset_index(drop=True)
# Split at day 500 of 730 days
split_idx = int(len(df) * 0.68)
train = df[:split_idx]
test = df[split_idx:]
print(f"Train period: {train['date'].min()} to {train['date'].max()}")
print(f"Test period: {test['date'].min()} to {test['date'].max()}")
# No leakage: test data comes strictly after training data
assert train['date'].max() < test['date'].min()This ensures your test set reflects what the model would face in production: predicting into a future it has never seen.
For rolling-window validation (more realistic for operational use), retrain your model as time moves forward:
# Walk-forward validation
from sklearn.metrics import mean_absolute_error
errors = []
window_size = 365 # 1 year of training data
for i in range(500, len(df)):
# Train on historical window
train_window = df[i - window_size:i]
test_point = df.iloc[i:i+1]
# Fit model, predict, record error
# (pseudo-code; replace with your model)
forecast = fit_and_predict(train_window, test_point)
actual = test_point['traffic'].values[0]
errors.append(mean_absolute_error([actual], [forecast]))
print(f"Mean error over walk-forward CV: {np.mean(errors):.2f}")Walk-forward validation mirrors production: you retrain regularly and evaluate on truly unseen data. It's slower but honest.
Lag features and rolling averages
Time series models need to see recent history to predict the future. The most direct way is via lag features—the value from one or more timesteps in the past—and rolling averages, which smooth noise and capture momentum.
If you're forecasting hourly demand at a warehouse, lag-1 (demand one hour ago) is often your strongest predictor. Lag-24 (same hour yesterday) captures daily seasonality. Rolling averages over 7 or 30 days reveal trends.
df['demand_lag1'] = df['demand'].shift(1) # Previous hour
df['demand_lag24'] = df['demand'].shift(24) # Same hour yesterday
df['demand_lag168'] = df['demand'].shift(168) # Same hour last week
# Rolling averages
df['demand_ma7'] = df['demand'].rolling(window=7, min_periods=1).mean() # 7-hour average
df['demand_ma168'] = df['demand'].rolling(window=168, min_periods=1).mean() # Weekly average
# Drop NaN rows from lagging
df = df.dropna()
# Your features for the model
features = ['demand_lag1', 'demand_lag24', 'demand_lag168', 'demand_ma7', 'demand_ma168']
X = df[features]
y = df['demand']A practical tip: lag features work best in tree-based models (XGBoost, LightGBM) and simple linear models. Neural networks often learn their own latent patterns; explicit lags are less critical but still helpful. Experiment with your architecture and measure.
Rolling averages act as denoising. A 7-day rolling average on daily revenue smooths out single-day spikes and shows the underlying trend. The trade-off is that you lose information near the boundaries; that's why we set min_periods=1 to allow averages on smaller windows at the start.
Seasonality: daily, weekly, yearly
Patterns repeat at multiple timescales. Restaurants see lunch and dinner rushes (daily). Retail spikes on weekends (weekly). E-commerce explodes in November-December (yearly). Missing seasonality means your forecasts lag reality and your model overfits to recent noise.
There are three practical ways to handle seasonality:
- Encode it directly: Add hour-of-day, day-of-week, month-of-year as categorical or trigonometric features.
- Seasonal decomposition: Split the series into trend, seasonality, and residual; forecast each separately.
- Differencing: Subtract the value from the same period last year, then forecast the delta.
Direct encoding:
df['hour'] = df['timestamp'].dt.hour
df['day_of_week'] = df['timestamp'].dt.dayofweek # 0=Monday
df['month'] = df['timestamp'].dt.month
df['is_weekend'] = (df['day_of_week'] >= 5).astype(int)
# Trigonometric encoding for cyclical features
# Hour ranges 0-23; sin/cos map it onto a circle
df['hour_sin'] = np.sin(2 * np.pi * df['hour'] / 24)
df['hour_cos'] = np.cos(2 * np.pi * df['hour'] / 24)
# Day of week ranges 0-6
df['dow_sin'] = np.sin(2 * np.pi * df['day_of_week'] / 7)
df['dow_cos'] = np.cos(2 * np.pi * df['day_of_week'] / 7)Trigonometric encoding prevents the model from treating hour 23 as very different from hour 0, even though they're adjacent.
Seasonal decomposition:
from statsmodels.tsa.seasonal import seasonal_decompose
# Decompose into trend, seasonality, residual
decomposition = seasonal_decompose(df['revenue'], model='additive', period=365)
df['trend'] = decomposition.trend
df['seasonal'] = decomposition.seasonal
df['residual'] = decomposition.resid
# Forecast each component separately if needed
# Trend with linear regression, seasonal as repeating pattern, residual with ARIMADifferencing for yearly seasonality:
# If your data is daily revenue over 3 years
# Subtract last year's value (lag-365)
df['revenue_diff_yearly'] = df['revenue'].diff(365)
# Now the differenced series removes yearly patterns
# Forecast the difference, then add it back to last year's value
df_train = df[df['date'] < '2023-01-01']
df_test = df[df['date'] >= '2023-01-01']
# Train on differenced series
forecast_diff = model.predict(df_test)
forecast_revenue = df_test['revenue'].shift(365).values + forecast_diffChoose based on your data and model. Direct encoding is interpretable and works well with tree models. Decomposition is explicit but assumes additive or multiplicative patterns. Differencing is elegant for strong yearly seasonality.
Baseline: last value or same period last year
Before training anything complex, establish a baseline. A model is only useful if it beats a simple rule. For time series, common baselines are:
- Persistence (naive forecast): Predict tomorrow's value equals today's.
- Same period last year (seasonal naive): Predict this Tuesday equals last Tuesday.
- Average of recent periods: Predict the mean of the last 7 days.
If your fancy neural network barely beats "predict the same day last year," you haven't added much value. But if it beats that baseline by 20%, you have something real.
from sklearn.metrics import mean_absolute_percentage_error as mape
# Test set: last 90 days of daily revenue
test_dates = df_test['date']
actual = df_test['revenue'].values
# Baseline 1: Persistence (last value)
baseline_persist = df_test['revenue'].shift(1).values # Today = yesterday
error_persist = mape(actual[1:], baseline_persist[1:])
# Baseline 2: Same day last year (lag-365)
baseline_seasonal = df_test['revenue'].shift(365).values # Today = last year today
error_seasonal = mape(actual[365:], baseline_seasonal[365:])
# Your model's forecast
forecast_model = model.predict(df_test[features])
error_model = mape(actual, forecast_model)
print(f"Baseline (persistence): {error_persist:.2%}")
print(f"Baseline (seasonal): {error_seasonal:.2%}")
print(f"Your model: {error_model:.2%}")
print(f"Improvement over seasonal: {(error_seasonal - error_model) / error_seasonal:.1%}")A 5% MAPE when your seasonal baseline is 6% is real progress. A 5% MAPE when the baseline is also 5% means you've added no value despite extra complexity. Always compare.
Common mistakes
Mistake 1: Random train-test splits. Shuffling time series data introduces leakage. Your validation metrics look good, but the model fails live because it learned patterns that shouldn't exist in production.
Mistake 2: Ignoring the calendar. Demand on Black Friday is not the same as a regular Friday. If your model hasn't seen holiday patterns in training, it will forecast low. Encode holidays, major events, and business calendars explicitly.
Mistake 3: Over-engineering features before validating on a baseline. Build the simplest model first (persistence or seasonal naive), then add complexity only if it beats the baseline. Many teams invest weeks in feature engineering and arrive at a model no better than "predict last year's value."
Mistake 4: Trusting a single metric. RMSE, MAE, MAPE each tell different stories. A model with low MAE but high MAPE performs differently on large vs. small values. Use multiple metrics and plot residuals to understand where your model fails.
Mistake 5: Not retraining regularly. A model trained on 2022 data won't capture 2024 trends. In production, retrain weekly or monthly depending on how fast your business changes. Walk-forward validation is a hint: if you're sliding a window, you're retraining.
Key takeaways
- Time order matters: Split your data chronologically, never randomly. Use walk-forward validation in production.
- Lag and rolling features capture history: Lag-1, lag-24, and lag-365 (for daily data) often outperform complex features. Rolling averages smooth noise and reveal trends.
- Seasonality is multi-scale: Daily, weekly, and yearly patterns coexist. Encode them via features, decomposition, or differencing.
- Establish a baseline first: "Predict last year's value" is often stronger than you'd expect. Only add complexity if your model beats it by a meaningful margin.
- Retrain regularly in production: Time series drift is real. A model stale by 6 months will forecast poorly.
- Validate on a held-out future window: Not a random sample. This is the only way to trust your metrics.






Comments
Loading comments…