What You'll Build
You will build a complete end-to-end tabular classification pipeline using Keras to predict IPL match outcomes — whether a team wins or loses given pre-match statistics. The project covers the full production workflow: loading and exploring a tabular dataset, preprocessing with normalisation and encoding, building a Keras Sequential model with BatchNorm and Dropout, training with EarlyStopping and ModelCheckpoint callbacks, evaluating performance with accuracy and ROC-AUC, plotting training curves, and generating a confusion matrix. By the end you will have a reusable tabular classification template applicable to any structured dataset — customer churn, credit risk, medical diagnosis — using IPL cricket as the concrete domain. This exercise consolidates every M2 concept: both Keras APIs, regularisation, callbacks, and GPU training patterns applied together in one coherent pipeline.
Prerequisites
- Keras Sequential API: building and compiling models with Dense, Dropout, BatchNormalization layers
- Training callbacks: EarlyStopping with restore_best_weights=True, ModelCheckpoint with save_best_only=True
- Regularisation: when to use Dropout (Dense layers) vs BatchNorm and appropriate rates
- NumPy and pandas basics: DataFrame operations, train-test split, feature normalisation
- Loss functions: binary_crossentropy for binary classification; understanding val_loss vs val_accuracy monitoring
Setup & Project Structure
This project uses pandas for data manipulation, scikit-learn for train-test splitting and evaluation metrics, and TensorFlow/Keras for the model. Install dependencies if not already present. The project has five logical sections: data generation and exploration, preprocessing pipeline, model building, training with callbacks, and evaluation with visualisation. All outputs (model file, training curves, confusion matrix) are saved to /tmp for the exercise.
# Install and import required libraries
# pip install tensorflow pandas scikit-learn matplotlib
import numpy as np
import pandas as pd
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers, callbacks
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score
import matplotlib
matplotlib.use('Agg') # non-interactive backend for scripts
import matplotlib.pyplot as plt
np.random.seed(42); tf.random.set_seed(42)
print('TF version:', tf.__version__)
print('GPU available:', bool(tf.config.list_physical_devices('GPU')))
# Project structure:
# ipl_classifier/
# ├── generate_data.py — synthetic IPL match dataset
# ├── preprocess.py — scaling, encoding, splitting
# ├── model.py — Keras model builder
# ├── train.py — training loop with callbacks
# └── evaluate.py — metrics, confusion matrix, ROC curveStep 1 — Foundation
Step 1 generates a realistic synthetic IPL match dataset and builds the preprocessing pipeline. The dataset has 14 features per match: both teams' batting average, strike rate, 4s per over, 6s per over, bowling economy, wicket rate, powerplay run rate, and death overs economy. The label is binary: 1 = home team wins. Preprocessing normalises all features to mean=0 std=1 using StandardScaler (fit only on training data to prevent data leakage) and performs an 80/10/10 train/val/test split. Correct preprocessing is the foundation of any ML pipeline — leaking the test set's statistics into the scaler is one of the most common and consequential bugs in data science.
import numpy as np, pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
np.random.seed(42)
def generate_ipl_dataset(n=5000):
"""Synthetic IPL match dataset with 14 features."""
data = {}
for team in ['home', 'away']:
data[f'{team}_batting_avg'] = np.random.normal(32, 8, n)
data[f'{team}_strike_rate'] = np.random.normal(138, 18, n)
data[f'{team}_fours_per_over'] = np.random.normal(2.1, 0.6, n)
data[f'{team}_sixes_per_over'] = np.random.normal(0.9, 0.4, n)
data[f'{team}_bowling_economy'] = np.random.normal(8.2, 1.4, n)
data[f'{team}_wicket_rate'] = np.random.normal(1.8, 0.5, n)
data[f'{team}_powerplay_rr'] = np.random.normal(8.5, 1.3, n)
data[f'{team}_death_economy'] = np.random.normal(10.1, 2.0, n)
df = pd.DataFrame(data)
feature_cols = list(df.columns)
# Win label: home team wins if batting edge + bowling edge > noise threshold
batting_edge = (df['home_batting_avg'] - df['away_batting_avg']) * 0.4 + \
(df['home_strike_rate'] - df['away_strike_rate']) * 0.02
bowling_edge = (df['away_bowling_economy'] - df['home_bowling_economy']) * 0.5 + \
(df['home_wicket_rate'] - df['away_wicket_rate']) * 0.3
raw_score = batting_edge + bowling_edge + np.random.randn(n) * 3
df['home_wins'] = (raw_score > 0).astype(int)
return df, feature_cols
df, feature_cols = generate_ipl_dataset(5000)
print(df.describe().T[['mean','std','min','max']].round(2))
print(f"\nWin rate: {df['home_wins'].mean():.2%}")
print(f"Features: {len(feature_cols)}, Samples: {len(df)}")
# ── Train / Val / Test split ───────────────────────────────────
X = df[feature_cols].values.astype(np.float32)
y = df['home_wins'].values.astype(np.float32)
X_trainval, X_test, y_trainval, y_test = train_test_split(X, y, test_size=0.10, random_state=42)
X_train, X_val, y_train, y_val = train_test_split(X_trainval, y_trainval, test_size=0.111, random_state=42)
# ── StandardScaler — fit ONLY on training data ────────────────
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train).astype(np.float32) # fit + transform
X_val = scaler.transform(X_val).astype(np.float32) # transform only
X_test = scaler.transform(X_test).astype(np.float32) # transform only
print(f'\nTrain: {X_train.shape}, Val: {X_val.shape}, Test: {X_test.shape}')
print(f'Train mean (should be ~0): {X_train.mean():.4f}, std (should be ~1): {X_train.std():.4f}')Step 2 — Core Logic
Step 2 builds the Keras model and configures the training callbacks. The model uses the production-grade pattern: Dense → BatchNorm → Activation → Dropout, repeated across three hidden layers with a funnel architecture (128 → 64 → 32 neurons). The callback suite applies EarlyStopping, ModelCheckpoint, and ReduceLROnPlateau — the standard three-callback combination for any supervised learning task. The model is compiled with AdamW (weight decay regularisation) and binary cross-entropy. Architecture choices are documented inline so you can experiment with different widths, depths, and dropout rates.
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers, callbacks
def build_ipl_classifier(input_dim, dropout_rates=(0.4, 0.3, 0.2)):
"""
Tabular classifier with BatchNorm + Dropout regularisation.
Architecture: 128 → 64 → 32 → 1 (funnel)
"""
model = keras.Sequential([
# Layer 1: wide feature extraction
layers.Dense(128, input_shape=(input_dim,), kernel_initializer='he_normal'),
layers.BatchNormalization(),
layers.Activation('relu'),
layers.Dropout(dropout_rates[0]),
# Layer 2: feature combination
layers.Dense(64, kernel_initializer='he_normal'),
layers.BatchNormalization(),
layers.Activation('relu'),
layers.Dropout(dropout_rates[1]),
# Layer 3: abstract representation
layers.Dense(32, kernel_initializer='he_normal'),
layers.BatchNormalization(),
layers.Activation('relu'),
layers.Dropout(dropout_rates[2]),
# Output: win probability
layers.Dense(1, activation='sigmoid', dtype='float32')
], name='ipl_win_classifier')
model.compile(
optimizer=keras.optimizers.AdamW(learning_rate=1e-3, weight_decay=1e-4),
loss='binary_crossentropy',
metrics=['accuracy', keras.metrics.AUC(name='auc')]
)
return model
model = build_ipl_classifier(input_dim=16) # 16 features (8 per team)
model.summary()
print(f'Total params: {model.count_params():,}')
# ── Callbacks ─────────────────────────────────────────────────
cb_early = callbacks.EarlyStopping(
monitor='val_loss', patience=15,
restore_best_weights=True, verbose=1
)
cb_ckpt = callbacks.ModelCheckpoint(
'/tmp/ipl_classifier_best.keras',
monitor='val_auc', mode='max',
save_best_only=True, verbose=0
)
cb_lr = callbacks.ReduceLROnPlateau(
monitor='val_loss', factor=0.5, patience=7,
min_lr=1e-6, verbose=1
)
print('Model and callbacks ready')Step 3 — Integration & Enhancement
Step 3 runs the full training loop and plots the training curves. We build a tf.data pipeline for efficiency, train with all three callbacks active, and plot both loss and AUC curves for training and validation sets. The training curve plot is the most diagnostic tool available — it immediately reveals overfitting (diverging train/val curves), underfitting (both curves plateau at poor performance), or good training (both curves improve together then val stabilises). We also run a hyperparameter comparison between two dropout configurations to demonstrate how the gap between train and val curves changes with regularisation strength.
import numpy as np, tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers, callbacks
import matplotlib.pyplot as plt
# Paste data generation + preprocessing from Step 1 here
# Then run:
def plot_training_curves(history, title='IPL Classifier Training'):
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
# Loss 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(f'{title} — Loss'); ax1.set_xlabel('Epoch'); ax1.set_ylabel('BCE Loss')
ax1.legend(); ax1.grid(True, alpha=0.3)
# AUC curves
ax2.plot(history.history['auc'], label='Train AUC', color='#2ca02c')
ax2.plot(history.history['val_auc'], label='Val AUC', color='#d62728', linestyle='--')
ax2.set_title(f'{title} — AUC'); ax2.set_xlabel('Epoch'); ax2.set_ylabel('ROC-AUC')
ax2.legend(); ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig(f'/tmp/{title.replace(" ","_")}.png', dpi=150, bbox_inches='tight')
plt.close()
print(f'Saved training curve: /tmp/{title.replace(" ","_")}.png')
# Main training run
BATCH_SIZE = 128
train_ds = (tf.data.Dataset.from_tensor_slices((X_train, y_train))
.shuffle(4000).batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE))
val_ds = (tf.data.Dataset.from_tensor_slices((X_val, y_val))
.batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE))
model = build_ipl_classifier(input_dim=X_train.shape[1])
history = model.fit(
train_ds, epochs=200,
validation_data=val_ds,
callbacks=[cb_early, cb_ckpt, cb_lr],
verbose=1
)
plot_training_curves(history, 'IPL Classifier')
print(f'Stopped at epoch {len(history.history["loss"])}')
# ── Hyperparameter comparison: low vs high dropout ─────────────
results = {}
for dropout in [(0.1,0.1,0.05), (0.5,0.4,0.3)]:
m = build_ipl_classifier(X_train.shape[1], dropout_rates=dropout)
h = m.fit(train_ds, epochs=50, validation_data=val_ds,
callbacks=[keras.callbacks.EarlyStopping('val_loss', patience=10,
restore_best_weights=True)],
verbose=0)
train_auc = max(h.history['auc'])
val_auc = max(h.history['val_auc'])
results[str(dropout)] = {'train_auc': train_auc, 'val_auc': val_auc, 'gap': train_auc-val_auc}
print(f'Dropout {dropout}: TrainAUC={train_auc:.3f} ValAUC={val_auc:.3f} Gap={train_auc-val_auc:.3f}')Step 4 — Testing & Verification
Step 4 evaluates on the held-out test set and generates a confusion matrix and classification report. The test set is evaluated only once — multiple evaluations on the test set constitute implicit hyperparameter tuning on it, which defeats its purpose as an unbiased estimate of generalisation. We compute accuracy, AUC, precision, recall, and F1-score, and visualise the confusion matrix to see which error type dominates (false positives vs false negatives).
# Evaluation on held-out test set (evaluate only ONCE)
test_ds = tf.data.Dataset.from_tensor_slices((X_test, y_test)).batch(256).prefetch(2)
# Load best checkpoint
best_model = keras.models.load_model('/tmp/ipl_classifier_best.keras')
# Predict probabilities
y_prob = best_model.predict(X_test, verbose=0).flatten()
y_pred = (y_prob > 0.5).astype(int)
# Metrics
from sklearn.metrics import classification_report, confusion_matrix, roc_auc_score
import matplotlib.pyplot as plt, seaborn
print('=== TEST SET EVALUATION ===')
print(f'Accuracy: {(y_pred == y_test.astype(int)).mean():.2%}')
print(f'ROC-AUC: {roc_auc_score(y_test, y_prob):.4f}')
print('\nClassification Report:')
print(classification_report(y_test, y_pred, target_names=['Away Win','Home Win']))
# Confusion matrix
cm = confusion_matrix(y_test, y_pred)
fig, ax = plt.subplots(figsize=(6,5))
im = ax.imshow(cm, cmap='Blues')
ax.set_xticks([0,1]); ax.set_yticks([0,1])
ax.set_xticklabels(['Away Win','Home Win'])
ax.set_yticklabels(['Away Win','Home Win'])
for i in range(2):
for j in range(2):
ax.text(j, i, cm[i,j], ha='center', va='center', fontsize=14, fontweight='bold')
ax.set_xlabel('Predicted'); ax.set_ylabel('Actual')
ax.set_title('IPL Win Predictor — Confusion Matrix')
plt.colorbar(im, ax=ax)
plt.tight_layout()
plt.savefig('/tmp/confusion_matrix.png', dpi=150, bbox_inches='tight')
print('Confusion matrix saved to /tmp/confusion_matrix.png')
# Expected: >72% accuracy, >0.78 AUC on this synthetic datasetWarning: Never evaluate on the test set more than once. If you tune hyperparameters (dropout rate, learning rate, model depth) by checking test set performance after each change, the test set becomes part of your training process — it is no longer an unbiased estimate of generalisation. All hyperparameter decisions must be made using only train and validation metrics. The test set is evaluated exactly once, at the end, to report the final unbiased performance.
Extension Challenge: (1) Add a second output head to predict the winning margin (regression, MSE loss) alongside the win/loss classification — this requires the Functional API and a multi-output model. (2) Replace the StandardScaler with a Keras Normalization layer (layers.Normalization()) that is fitted during the training step — this way normalisation is part of the model and you do not need to preprocess at inference time. (3) Implement k-fold cross-validation (k=5) using sklearn's StratifiedKFold to get a more reliable estimate of generalisation performance than a single train/val/test split.
- Fit the StandardScaler only on training data and transform val/test — fitting on all data leaks test distribution into preprocessing, inflating measured performance.
- The production Keras tabular pattern is: Dense → BatchNorm → Activation('relu') → Dropout, repeated with decreasing width (funnel architecture).
- Monitor val_auc (not val_accuracy) in callbacks for classification — AUC is a continuous metric that captures probability calibration improvements that accuracy's rounding misses.
- Use AdamW (not Adam) for tabular classification — the decoupled weight decay provides consistent L2 regularisation across all parameter groups.
- Evaluate the test set exactly once after all hyperparameter decisions are made using train/val metrics — multiple test evaluations constitute implicit overfitting to the test set.
- Training curves (loss + AUC for train and val) are the primary diagnostic tool — diverging curves mean overfitting, both plateauing low means underfitting, both improving then val stabilising means good training.