What You'll Build
You will build a complete multivariate LSTM time-series forecasting pipeline to predict IPL franchise stock-equivalent performance indices — a synthetic dataset modelling how a franchise's 'stock' (composite of match results, player transfers, and brand value) evolves over a season. The project covers the full forecasting workflow: generating and exploring multivariate time-series data, constructing sliding window sequences with temporal splitting, normalising without data leakage, building a stacked LSTM forecaster, training with callbacks, evaluating against a naive baseline, and visualising predictions vs actuals. This exercise synthesises all M4 concepts: LSTM gating, time-series preprocessing, evaluation metrics, and the critical data leakage pitfalls unique to sequential data. The completed pipeline is directly applicable to any real multivariate forecasting task — weather, sensor data, financial indices, or match performance tracking.
Prerequisites
- LSTM gating: cell state, forget/input/output gates, and why they solve vanishing gradients
- Time-series sliding window construction: (samples, lookback, features) format and temporal splitting
- MinMaxScaler: fit on training data only, transform val/test with the same fitted scaler
- Direct vs recursive multi-step forecasting: Dense(horizon) output for stable multi-step predictions
- Evaluation: MAE in original units after inverse_transform, always compare to naive persistence baseline
Setup & Project Structure
This project requires TensorFlow, NumPy, pandas, scikit-learn, and matplotlib. The pipeline has five steps: data generation and exploration, sliding window construction with temporal split and scaling, LSTM model building, training with callbacks, and final evaluation and visualisation. Each step builds on the previous — complete them in order, as the outputs of each step feed into the next.
# pip install tensorflow scikit-learn matplotlib
import numpy as np
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers, callbacks
from sklearn.preprocessing import MinMaxScaler
from sklearn.metrics import mean_absolute_error, mean_squared_error
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
np.random.seed(42); tf.random.set_seed(42)
print(f'TF {tf.__version__} | GPU: {bool(tf.config.list_physical_devices("GPU"))}')
# IPL Franchise performance metrics (4 features)
FEATURES = ['batting_avg', 'bowling_economy', 'net_run_rate', 'win_loss_ratio']
N_FEATURES = len(FEATURES)
LOOKBACK = 15 # last 15 match-days
HORIZON = 5 # predict next 5 match-days
TARGET_IDX = 2 # predict net_run_rate (index 2)Step 1 — Foundation
Step 1 generates synthetic multivariate IPL franchise performance data and constructs the sliding window dataset. The key constraint is the temporal split — the train set must consist of only the earliest time steps, validation of the next portion, and test of the final portion. Shuffling would leak future data into training. The MinMaxScaler is fitted exclusively on the training set's data — a scaler fitted on all data would encode knowledge of future minimum and maximum values into the normalisation, causing the training set to implicitly know the range of future data.
import numpy as np
from sklearn.preprocessing import MinMaxScaler
np.random.seed(42)
N_DAYS = 200 # total match-days in dataset
N_FEATURES = 4
LOOKBACK = 15
HORIZON = 5
TARGET_IDX = 2 # predict net_run_rate
def generate_franchise_data(n_days=200):
"""
Simulate 4 IPL franchise performance metrics over n_days match-days.
Each metric has trend + seasonality + noise.
"""
t = np.arange(n_days)
# batting_avg: slow upward trend + weekly cycle
batting_avg = 32 + 0.02*t + 3*np.sin(2*np.pi*t/7) + np.random.randn(n_days)*2
# bowling_economy: slow downward trend (improving) + noise
bowling_econ = 8.5 - 0.01*t + 1.5*np.cos(2*np.pi*t/14) + np.random.randn(n_days)*0.8
# net_run_rate: correlated with batting_avg - bowling_economy + noise
net_rr = (batting_avg - 32) * 0.05 - (bowling_econ - 8.0) * 0.3 + np.random.randn(n_days)*0.4
# win_loss_ratio: lagged version of net_rr (wins follow performance)
win_loss = np.convolve(net_rr, np.ones(5)/5, mode='same') + np.random.randn(n_days)*0.2
data = np.column_stack([batting_avg, bowling_econ, net_rr, win_loss])
return data.astype(np.float32)
raw_data = generate_franchise_data(N_DAYS)
print(f'Raw data shape: {raw_data.shape}')
for i, name in enumerate(['batting_avg','bowling_econ','net_rr','win_loss']):
print(f' {name}: mean={raw_data[:,i].mean():.2f}, std={raw_data[:,i].std():.2f}')
# Sliding window construction
def make_windows(data, lookback, horizon, target_idx):
X, y = [], []
for i in range(len(data) - lookback - horizon + 1):
X.append(data[i : i+lookback]) # all features
y.append(data[i+lookback : i+lookback+horizon, target_idx]) # target feature only
return np.array(X, dtype=np.float32), np.array(y, dtype=np.float32)
X, y = make_windows(raw_data, LOOKBACK, HORIZON, TARGET_IDX)
print(f'\nWindows — X: {X.shape}, y: {y.shape}')
# Temporal split: 70 / 15 / 15
n = len(X)
tr = int(0.70 * n); vl = int(0.85 * n)
X_train, X_val, X_test = X[:tr], X[tr:vl], X[vl:]
y_train, y_val, y_test = y[:tr], y[tr:vl], y[vl:]
print(f'Train: {X_train.shape}, Val: {X_val.shape}, Test: {X_test.shape}')
# MinMaxScaler — fit ONLY on training data
sc_X = MinMaxScaler()
sc_y = MinMaxScaler()
X_train = sc_X.fit_transform(X_train.reshape(-1, N_FEATURES)).reshape(X_train.shape)
X_val = sc_X.transform(X_val.reshape(-1, N_FEATURES)).reshape(X_val.shape)
X_test = sc_X.transform(X_test.reshape(-1, N_FEATURES)).reshape(X_test.shape)
y_train = sc_y.fit_transform(y_train.reshape(-1,1)).reshape(y_train.shape)
y_val = sc_y.transform(y_val.reshape(-1,1)).reshape(y_val.shape)
y_test = sc_y.transform(y_test.reshape(-1,1)).reshape(y_test.shape)
print('Scaling done. Train X mean (should be ~0.5):', X_train.mean().round(3))Step 2 — Core Logic
Step 2 builds the stacked LSTM model and defines the naive persistence baseline. The model uses two LSTM layers (return_sequences=True for the first, False for the second), a Dense hidden layer, and a Dense(HORIZON) output for direct multi-step forecasting. The naive baseline — predicting the last observed value of net_run_rate repeated for all HORIZON steps — is the minimum bar the LSTM must clear to demonstrate learning. Both gradient clipping (clipnorm=1.0) and EarlyStopping are essential for stable LSTM training.
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers, callbacks
import numpy as np
LOOKBACK = 15; HORIZON = 5; N_FEATURES = 4
def build_stacked_lstm(lookback, n_features, horizon, hidden=64):
return keras.Sequential([
layers.LSTM(hidden, input_shape=(lookback, n_features),
return_sequences=True,
recurrent_dropout=0.0), # keep 0 for cuDNN
layers.Dropout(0.2),
layers.LSTM(hidden // 2, return_sequences=False,
recurrent_dropout=0.0),
layers.Dense(32, activation='relu'),
layers.Dropout(0.2),
layers.Dense(horizon) # direct multi-step output
], name='ipl_forecaster')
model = build_stacked_lstm(LOOKBACK, N_FEATURES, HORIZON)
model.compile(
optimizer=keras.optimizers.Adam(learning_rate=1e-3, clipnorm=1.0),
loss='mse',
metrics=['mae']
)
model.summary()
# Naive persistence baseline
def naive_persistence(X_test_raw, horizon, target_idx):
"""Predict: last observed target value repeated for all horizon steps."""
last_obs = X_test_raw[:, -1, target_idx] # last observed value, unscaled
return np.tile(last_obs.reshape(-1,1), (1, horizon))
print('Model and baseline defined')Step 3 — Integration & Enhancement
Step 3 trains the model with the full callback suite and plots the training curves and prediction visualisation. The combined training + validation loss curve shows whether the model converges stably. The prediction plot overlays LSTM forecasts, naive baseline forecasts, and actual net_run_rate values on the test set — visually demonstrating whether the LSTM captures trends the naive baseline misses.
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import callbacks
import numpy as np
import matplotlib.pyplot as plt
# (Paste Steps 1 & 2 above, then run this cell)
cbs = [
callbacks.EarlyStopping('val_loss', patience=15, restore_best_weights=True, verbose=1),
callbacks.ReduceLROnPlateau('val_loss', factor=0.5, patience=7, min_lr=1e-6, verbose=1),
callbacks.ModelCheckpoint('/tmp/ipl_forecast_best.keras', save_best_only=True, verbose=0)
]
history = model.fit(
X_train, y_train,
epochs=200,
batch_size=32,
validation_data=(X_val, y_val),
callbacks=cbs,
verbose=1
)
print(f'Stopped at epoch {len(history.history["loss"])}')
# ── Prediction visualisation ───────────────────────────────────
best_model = keras.models.load_model('/tmp/ipl_forecast_best.keras')
preds_sc = best_model.predict(X_test, verbose=0)
# Inverse transform to original scale
from sklearn.preprocessing import MinMaxScaler
# sc_y was fitted in Step 1 — reuse it here
preds_orig = sc_y.inverse_transform(preds_sc.reshape(-1,1)).reshape(preds_sc.shape)
true_orig = sc_y.inverse_transform(y_test.reshape(-1,1)).reshape(y_test.shape)
# Naive baseline (use unscaled X_test)
X_test_unscaled = sc_X.inverse_transform(X_test.reshape(-1, N_FEATURES)).reshape(X_test.shape)
naive_orig = naive_persistence(X_test_unscaled, HORIZON, TARGET_IDX)
# Plot first 30 test samples, first forecast step
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 8))
# Training curves
ax1.plot(history.history['loss'], label='Train Loss', color='#1f77b4')
ax1.plot(history.history['val_loss'], label='Val Loss', color='#ff7f0e', linestyle='--')
ax1.set_title('IPL Forecaster Training Loss'); ax1.set_xlabel('Epoch')
ax1.legend(); ax1.grid(True, alpha=0.3)
# Predictions vs actuals (first step ahead)
N_SHOW = min(60, len(true_orig))
ax2.plot(true_orig[:N_SHOW, 0], label='Actual NRR', color='#2ca02c', linewidth=2)
ax2.plot(preds_orig[:N_SHOW, 0], label='LSTM 1-step', color='#1f77b4', linestyle='--')
ax2.plot(naive_orig[:N_SHOW, 0], label='Naive', color='#d62728', linestyle=':')
ax2.set_title('Net Run Rate: Actual vs LSTM vs Naive Baseline')
ax2.set_xlabel('Test Sample'); ax2.set_ylabel('Net Run Rate')
ax2.legend(); ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('/tmp/ipl_forecast_results.png', dpi=150, bbox_inches='tight')
print('Saved: /tmp/ipl_forecast_results.png')Step 4 — Testing & Verification
Step 4 evaluates the model on the held-out test set and computes all metrics in original units. The key verification is that LSTM MAE < naive baseline MAE across all forecast horizons — if not, debug the preprocessing pipeline (check for data leakage), architecture (check receptive field and hidden dimension), or data quality (check for sufficient training samples). The directional accuracy — percentage of test samples where the model correctly predicts whether NRR goes up or down — is an additional practical metric that complements MAE.
import numpy as np
from sklearn.metrics import mean_absolute_error, mean_squared_error
# Final evaluation — report all metrics in original units
print('=== IPL FORECAST TEST SET EVALUATION ===')
print(f'Test samples: {len(true_orig)}')
print()
print(f'{"Horizon":>8} | {"LSTM MAE":>10} | {"Naive MAE":>10} | {"Improvement":>12} | {"RMSE":>8}')
print('-' * 58)
for h in range(HORIZON):
lstm_mae = mean_absolute_error(true_orig[:, h], preds_orig[:, h])
naive_mae = mean_absolute_error(true_orig[:, h], naive_orig[:, h])
rmse = np.sqrt(mean_squared_error(true_orig[:, h], preds_orig[:, h]))
impr = (naive_mae - lstm_mae) / naive_mae * 100
print(f'+{h+1:>7} | {lstm_mae:>10.4f} | {naive_mae:>10.4f} | {impr:>11.1f}% | {rmse:>8.4f}')
# Directional accuracy: does the model predict the correct direction of change?
actual_dir = np.sign(true_orig[:, 0] - true_orig[:, -1]) # up or down over horizon
pred_dir = np.sign(preds_orig[:, 0] - true_orig[:, -1]) # predicted direction
direc_acc = (actual_dir == pred_dir).mean()
print(f'\nDirectional accuracy (step+1): {direc_acc:.2%}')
print('Expected: >55% to show the model learns directional trends')Warning: If LSTM MAE is higher than the naive baseline for all horizons, there are three common causes to check in order: (1) Data leakage — verify the scaler was fitted only on training data by checking that X_train.mean() ≈ 0.5 and X_train.std() ≈ 0.28 (MinMaxScaler should produce approximately uniform distribution); (2) Insufficient training data — time series forecasting requires at least 5× the HORIZON×LOOKBACK in training samples; (3) LOOKBACK too long — if LOOKBACK > 50, the model may struggle; try shorter windows.
Extension Challenge: (1) Add a TCN head alongside the LSTM head and combine predictions via averaging — ensemble forecasting almost always outperforms any single model. (2) Include exogenous features like 'days until next match', 'home/away', and 'opponent strength index' as additional input features — multivariate LSTM with external features typically outperforms univariate significantly. (3) Implement walk-forward validation: train on days 1–100, test on days 101–110, retrain on 1–110, test on 111–120, etc. — this gives a more reliable estimate of production performance than a single train/test split.
- Temporal splitting is non-negotiable for time series — random shuffle leaks future statistics into training, inflating performance metrics by 30–60% on typical financial and sports data.
- Fit MinMaxScaler on X_train only; transform X_val and X_test with the same fitted scaler — use separate scalers for features (sc_X) and target (sc_y) for clean inverse transformation.
- Direct multi-step forecasting with Dense(HORIZON) output avoids recursive error compounding — use it for all K>1 forecasting tasks unless the architecture requires sequential generation.
- Always inverse_transform predictions before reporting MAE — reporting error on scaled [0,1] values is meaningless to stakeholders who need error in original units (NRR points, runs per over).
- Naive persistence baseline (predict last observed value) is the minimum bar — LSTM must clearly beat it at all forecast horizons to demonstrate genuine learning beyond short-term persistence.
- Stacked LSTM (return_sequences=True first layer, False second layer) adds representational capacity without proportionally increasing parameters — prefer over single wide LSTM for complex sequences.