100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Deep Learning & Neural Networks
65 minadvanced

Practice — stock price forecasting with LSTM

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.

Analogy🏏Cricket
🏏 Think of it like cricket: Building a neural net from scratch is like a young cricketer learning the game by playing gully cricket with a tape-ball before ever stepping into a coaching academy. You don't use a bowling machine, you don't get video analysis, you don't have a structured training manual — you just play, make mistakes, adapt. Just as gully cricket teaches the core instincts of timing, footwork, and reading the ball that no academy drill can fully replicate, building a network from NumPy teaches the core mechanics of gradient flow, matrix shapes, and numerical stability that no framework hides. Just as every Indian international cricketer traces their instincts back to gully cricket roots, every deep learning practitioner benefits from having once implemented backpropagation themselves. The insight is that frameworks automate what you understand — building from scratch ensures you actually understand it.

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.

Analogy🏏Cricket
🏏 Think of it like cricket: Before a net session you lay out the ground in clear zones — the bowling machine and pitch (data generation of synthetic IPL stats), the batsman's technique broken into stance, backlift, and follow-through (the NeuralNet forward, backward, and update methods), the throwdown routine that repeats and tracks improvement (the training loop with loss and accuracy monitoring), and finally a match simulation to test readiness (test-set evaluation). Just as a coach keeps the whole drill on one ground so a player can see how each phase connects, keeping all the code in one file lets you trace how a forward pass flows into a gradient and then a weight update. Just as you need only bat, ball, and pitch — not a full stadium — for a productive net, this exercise needs only NumPy, no heavy libraries. The payoff: a clean, well-sectioned practice structure means every mechanic is visible and debuggable, so you understand exactly why the network learns rather than treating it as a black box.
bash
# 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.

Analogy🏏Cricket
🏏 Think of it like cricket: Before the first ball is bowled, the groundsman prepares the pitch — the pitch condition is the foundation that determines what is possible. Bad pitch preparation (wrong initialisation) makes even the best bowlers and batsmen ineffective. Just as a pitch that is too green (over-seamed) or too dry (over-spun) constrains the entire match, bad weight initialisation constrains the entire training run. He initialisation is the 'neutral pitch' — well-prepared, giving both batting and bowling a fair contest, from which any outcome is possible.
python
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.

Analogy🏏Cricket
🏏 Think of it like cricket: The NeuralNet class is the team management system — it tracks every player's contribution (forward pass caching), attributes match outcomes to specific decisions (backward pass gradients), and adjusts each player's role for the next match (weight update). Just as the team manager must record which batsman faced which bowler and how many runs were scored (cache activations) before attributing success or failure, the neural net must cache z and a at every layer before computing gradients. The momentum in SGD is the team's institutional memory — great past performances inform future selection policy, not overriding current evidence but weighted alongside it.
python
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.

Analogy🏏Cricket
🏏 Think of it like cricket: Mini-batch training is like a cricket coaching session where the coach evaluates the batsman against a random set of 32 deliveries from the session's total pool, not all 500 deliveries at once. Evaluating all 500 deliveries before giving any feedback (full-batch) is slow and ignores the fact that early corrections from the first 32 balls can already improve performance on balls 33–64. Mini-batch feedback (stochastic gradients) allows the batsman to improve continuously throughout the session, converging to good technique faster than waiting for the end-of-session review. The randomness (shuffle each epoch) ensures the coach doesn't accidentally train the batsman to handle only one type of delivery sequence.
python
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.

Analogy🏏Cricket
🏏 Think of it like cricket: Verifying the network is a batsman's fitness test with three non-negotiable checks. First, form must steadily improve — the loss must fall monotonically over the first 200 epochs, just as a player's error count should drop session after session, not swing wildly. Second, real skill must beat luck — test accuracy above 70% when random guessing is 50%, exactly as a batsman must clearly outscore a tail-ender swinging blindly to prove genuine technique. Third, every part of the technique must be engaged — non-zero gradient norms confirm the learning signal actually flows through all three layers, like checking footwork, backlift, and follow-through are each working rather than one part being frozen. The gold standard is the numerical gradient check: nudge a weight a tiny amount and confirm the loss changes as predicted, just as a coach films from two angles to confirm a fix is real, not imagined. The payoff: these checks prove your from-scratch network is genuinely learning and correctly wired, not just accidentally producing a decent number.
bash
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.
Lesson 24 of 35
0% complete