What You'll Build
You will build a fully functional 3-layer neural network from scratch using only NumPy — no PyTorch, no TensorFlow, no shortcuts. The network will predict whether an Indian Premier League (IPL) team will win a match, given batting and bowling statistics for both teams. You will implement the forward pass (matrix multiplications, ReLU, sigmoid), the backward pass (chain rule, gradient computation for every weight and bias), the weight update step (SGD with momentum), and a training loop that monitors loss and accuracy. By the end you will have a working neural net you built yourself — giving you ground-level understanding of exactly what happens inside every framework. This is the foundation that makes debugging, optimising, and architecting real networks intuitive rather than magical.
Prerequisites
- NumPy basics: array creation, matrix multiplication (@), broadcasting, element-wise operations
- Understanding of forward pass: activation(W @ x + b) for each layer
- Understanding of backpropagation: chain rule, dL/dW = activation_upstream × local_input
- Familiarity with sigmoid and ReLU activation functions and their derivatives
- Basic Python: classes, for loops, list comprehensions
Setup & Project Structure
This exercise requires only NumPy — no additional libraries. Create a single Python file cricket_neural_net.py. The project will have four natural sections: data generation (synthetic IPL match statistics), the NeuralNet class (forward + backward + update), the training loop (loss monitoring, accuracy tracking), and evaluation (test set performance). Keep all code in one file for clarity — the goal is to see the entire system at once, not to engineer a production codebase.
# Setup — no pip installs needed, only NumPy
import numpy as np
# Verify NumPy version
print(f'NumPy version: {np.__version__}')
# Project structure (single file):
# cricket_neural_net.py
# ├── generate_ipl_data() — synthetic match stats
# ├── class NeuralNet — forward, backward, update
# │ ├── __init__() — weight initialisation
# │ ├── forward() — forward pass, cache activations
# │ ├── backward() — backprop, compute all gradients
# │ └── update() — SGD+momentum weight update
# ├── train() — training loop with loss logging
# └── evaluate() — accuracy on test set
print('Structure ready — begin implementation')Step 1 — Foundation
Step 1 builds the data layer and weight initialisation. We generate synthetic IPL match data: 6 features per match (home batting average, home bowling economy, home run rate, away batting average, away bowling economy, away run rate) and a binary label (1 = home team wins). We use He initialisation for the two ReLU hidden layers and Xavier for the sigmoid output layer. Correct initialisation is the first prerequisite for training convergence — initialise wrong and no amount of tuning will save the model.
import numpy as np
np.random.seed(42)
def generate_ipl_data(n_matches=1000):
"""
Synthetic IPL match statistics.
Features: [home_batting_avg, home_economy, home_run_rate,
away_batting_avg, away_economy, away_run_rate]
Label: 1 = home team wins
"""
# Home team stats — slightly biased toward higher batting avg (home advantage)
home_batting_avg = np.random.normal(32, 8, n_matches)
home_economy = np.random.normal(8.2, 1.5, n_matches)
home_run_rate = np.random.normal(8.5, 1.2, n_matches)
# Away team stats
away_batting_avg = np.random.normal(30, 8, n_matches)
away_economy = np.random.normal(8.5, 1.5, n_matches)
away_run_rate = np.random.normal(8.0, 1.2, n_matches)
X = np.column_stack([
home_batting_avg, home_economy, home_run_rate,
away_batting_avg, away_economy, away_run_rate
]) # shape (N, 6)
# Label: home wins if batting avg gap + run rate gap > 0
batting_edge = home_batting_avg - away_batting_avg
economy_edge = away_economy - home_economy # lower economy = better
run_rate_edge = home_run_rate - away_run_rate
raw_score = batting_edge * 0.5 + economy_edge * 0.3 + run_rate_edge * 0.4
y = (raw_score + np.random.randn(n_matches) * 3 > 0).astype(float) # shape (N,)
# Normalise features to mean=0, std=1 for stable training
X = (X - X.mean(axis=0)) / (X.std(axis=0) + 1e-8)
return X, y
def he_init(fan_in, fan_out):
return np.random.randn(fan_out, fan_in) * np.sqrt(2.0 / fan_in)
def xavier_init(fan_in, fan_out):
return np.random.randn(fan_out, fan_in) * np.sqrt(2.0 / (fan_in + fan_out))
X, y = generate_ipl_data(1000)
print(f'Dataset shape: X={X.shape}, y={y.shape}')
print(f'Win rate: {y.mean():.2%}') # should be ~50% (balanced)
# Weight initialisation
input_dim, hidden1, hidden2, output_dim = 6, 16, 8, 1
W1 = he_init(input_dim, hidden1); b1 = np.zeros(hidden1)
W2 = he_init(hidden1, hidden2); b2 = np.zeros(hidden2)
W3 = xavier_init(hidden2, output_dim); b3 = np.zeros(output_dim)
print(f'W1:{W1.shape} W2:{W2.shape} W3:{W3.shape}')Step 2 — Core Logic
Step 2 implements the complete NeuralNet class with forward pass, backward pass, and weight update. The forward pass must cache all intermediate values for backpropagation. The backward pass implements the chain rule layer by layer, computing dL/dW and dL/db for every parameter. The update step applies SGD with momentum. The full class is the heart of the exercise — every line corresponds directly to a mathematical operation you covered in the theory lessons.
import numpy as np
np.random.seed(42)
class IPLWinPredictor:
"""3-layer neural net from scratch — no frameworks."""
def __init__(self, input_dim=6, h1=16, h2=8, lr=0.05, momentum=0.9):
self.lr = lr
self.mom = momentum
# He init for ReLU layers, Xavier for sigmoid output
self.W1 = np.random.randn(h1, input_dim) * np.sqrt(2 / input_dim)
self.b1 = np.zeros(h1)
self.W2 = np.random.randn(h2, h1) * np.sqrt(2 / h1)
self.b2 = np.zeros(h2)
self.W3 = np.random.randn(1, h2) * np.sqrt(2 / (h2 + 1))
self.b3 = np.zeros(1)
# Momentum velocity terms
self.vW1 = np.zeros_like(self.W1)
self.vW2 = np.zeros_like(self.W2)
self.vW3 = np.zeros_like(self.W3)
self.vb1 = np.zeros_like(self.b1)
self.vb2 = np.zeros_like(self.b2)
self.vb3 = np.zeros_like(self.b3)
# ── Activations ──────────────────────────────────────────
@staticmethod
def relu(z): return np.maximum(0, z)
@staticmethod
def relu_grad(z): return (z > 0).astype(float)
@staticmethod
def sigmoid(z): return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
# ── Forward pass — MUST cache all intermediate values ────
def forward(self, X):
self.X = X
self.z1 = X @ self.W1.T + self.b1; self.a1 = self.relu(self.z1)
self.z2 = self.a1 @ self.W2.T + self.b2; self.a2 = self.relu(self.z2)
self.z3 = self.a2 @ self.W3.T + self.b3; self.a3 = self.sigmoid(self.z3)
return self.a3 # shape (N, 1)
# ── Backward pass — chain rule through all layers ────────
def backward(self, y_true):
N = self.X.shape[0]
y = y_true.reshape(-1, 1)
# Output layer: d(BCE)/d(z3) = a3 - y [simplified]
dz3 = self.a3 - y # (N, 1)
self.dW3 = dz3.T @ self.a2 / N # (1, 8)
self.db3 = dz3.mean(axis=0) # (1,)
# Hidden layer 2
da2 = dz3 @ self.W3 # (N, 8)
dz2 = da2 * self.relu_grad(self.z2) # (N, 8)
self.dW2 = dz2.T @ self.a1 / N # (8, 16)
self.db2 = dz2.mean(axis=0) # (8,)
# Hidden layer 1
da1 = dz2 @ self.W2 # (N, 16)
dz1 = da1 * self.relu_grad(self.z1) # (N, 16)
self.dW1 = dz1.T @ self.X / N # (16, 6)
self.db1 = dz1.mean(axis=0) # (16,)
# ── SGD + momentum update ────────────────────────────────
def update(self):
for W, dW, vW, b, db, vb in [
(self.W1,self.dW1,self.vW1, self.b1,self.db1,self.vb1),
(self.W2,self.dW2,self.vW2, self.b2,self.db2,self.vb2),
(self.W3,self.dW3,self.vW3, self.b3,self.db3,self.vb3),
]:
vW[:] = self.mom * vW + self.dW
W -= self.lr * vW
vb[:] = self.mom * vb + self.db
b -= self.lr * vb
def loss(self, y_true):
eps = 1e-8
p = np.clip(self.a3, eps, 1-eps).flatten()
return -np.mean(y_true * np.log(p) + (1-y_true) * np.log(1-p))
def predict(self, X):
return (self.forward(X).flatten() > 0.5).astype(float)
print('IPLWinPredictor class defined — proceed to training')Step 3 — Integration & Enhancement
Step 3 brings everything together: generate the dataset, split into train/test, run the training loop for 500 epochs with mini-batches, track loss and accuracy, and evaluate on the held-out test set. Mini-batch training is more realistic than full-batch gradient descent — it introduces the beneficial noise of stochastic updates and is how all real networks are trained. We use a batch size of 32, consistent with common production defaults.
import numpy as np
np.random.seed(42)
# Paste generate_ipl_data() and IPLWinPredictor class from Steps 1 & 2 here
# Then run the full training loop below:
def generate_ipl_data(n_matches=1000):
home_batting_avg = np.random.normal(32, 8, n_matches)
home_economy = np.random.normal(8.2, 1.5, n_matches)
home_run_rate = np.random.normal(8.5, 1.2, n_matches)
away_batting_avg = np.random.normal(30, 8, n_matches)
away_economy = np.random.normal(8.5, 1.5, n_matches)
away_run_rate = np.random.normal(8.0, 1.2, n_matches)
X = np.column_stack([home_batting_avg, home_economy, home_run_rate,
away_batting_avg, away_economy, away_run_rate])
raw_score = (home_batting_avg - away_batting_avg)*0.5 + \
(away_economy - home_economy)*0.3 + \
(home_run_rate - away_run_rate)*0.4
y = (raw_score + np.random.randn(n_matches)*3 > 0).astype(float)
X = (X - X.mean(axis=0)) / (X.std(axis=0) + 1e-8)
return X, y
X, y = generate_ipl_data(1000)
# Train / test split: 80% / 20%
split = int(0.8 * len(X))
idx = np.random.permutation(len(X))
X_train, y_train = X[idx[:split]], y[idx[:split]]
X_test, y_test = X[idx[split:]], y[idx[split:]]
model = IPLWinPredictor(lr=0.05, momentum=0.9)
batch_size = 32
epochs = 500
for innings_count in range(epochs):
# Shuffle training data each epoch
perm = np.random.permutation(len(X_train))
X_train_s, y_train_s = X_train[perm], y_train[perm]
# Mini-batch gradient descent
for i in range(0, len(X_train_s), batch_size):
Xb = X_train_s[i:i+batch_size]
yb = y_train_s[i:i+batch_size]
model.forward(Xb)
model.backward(yb)
model.update()
# Monitor every 100 epochs
if innings_count % 100 == 0:
model.forward(X_train)
train_loss = model.loss(y_train)
train_acc = (model.predict(X_train) == y_train).mean()
val_acc = (model.predict(X_test) == y_test).mean()
print(f'Epoch {innings_count:4d} | Loss: {train_loss:.4f} | '
f'Train Acc: {train_acc:.2%} | Val Acc: {val_acc:.2%}')
# Final evaluation
final_val_acc = (model.predict(X_test) == y_test).mean()
print(f'\nFinal test accuracy: {final_val_acc:.2%}')Step 4 — Testing & Verification
Verify the implementation is correct by running the script and checking three things: loss must decrease monotonically over the first 200 epochs, final test accuracy must exceed 70% (random would be 50%), and gradient norms must not be zero (confirming gradients actually flow through all three layers). A numerical gradient check is the gold-standard verification — compute finite-difference approximations and compare to analytical gradients.
# Run the full training script and check output:
# Expected output:
# Epoch 0 | Loss: 0.69xx | Train Acc: ~50% | Val Acc: ~50%
# Epoch 100 | Loss: 0.6xxx | Train Acc: ~60% | Val Acc: ~58%
# Epoch 200 | Loss: 0.5xxx | Train Acc: ~67% | Val Acc: ~65%
# Epoch 300 | Loss: 0.4xxx | Train Acc: ~72% | Val Acc: ~70%
# Epoch 400 | Loss: 0.4xxx | Train Acc: ~75% | Val Acc: ~72%
# Final test accuracy: ~72-76%
# Gradient check — verify backprop is correct
def numerical_gradient(model, X, y, eps=1e-5):
"""Finite-difference gradient for W3 — compare to analytical."""
model.forward(X); model.backward(y)
analytic_dW3 = model.dW3.copy()
numerical_dW3 = np.zeros_like(model.W3)
for i in range(model.W3.shape[0]):
for j in range(model.W3.shape[1]):
model.W3[i,j] += eps
model.forward(X); loss_plus = model.loss(y)
model.W3[i,j] -= 2*eps
model.forward(X); loss_minus = model.loss(y)
model.W3[i,j] += eps # restore
numerical_dW3[i,j] = (loss_plus - loss_minus) / (2*eps)
err = np.max(np.abs(analytic_dW3 - numerical_dW3))
print(f'Gradient check max error: {err:.2e} (should be < 1e-5)')
return err
# Use a small sample for speed
err = numerical_gradient(model, X_train[:10], y_train[:10])
assert err < 1e-4, f'Backprop may have a bug! Error: {err}'Warning: If your final test accuracy is stuck at exactly 50% after 500 epochs, check three things in order: (1) Is your loss function correct? Log(0) returning -inf will NaN your gradients immediately. (2) Did you forget to divide gradients by N (batch size) in the backward pass? Without this, gradient magnitudes scale with batch size and cause oscillation. (3) Did you shuffle training data each epoch? Without shuffling, mini-batches are always the same and the model memorises batch order rather than learning features.
Extension Challenge: (1) Add L2 regularisation to the loss function (lambda * sum(W²)) and to the gradient (dW += lambda * W) and observe the effect on overfitting. (2) Implement Adam from scratch inside the IPLWinPredictor class and compare convergence speed to SGD+momentum. (3) Add a fourth hidden layer and observe whether deeper improves or hurts accuracy on this dataset — explain why using what you know about vanishing gradients.
- Always normalise input features to mean=0, std=1 before training — unnormalised features cause gradients of very different magnitudes that destabilise training.
- Cache all intermediate values (X, z1, a1, z2, a2, z3, a3) during the forward pass — backpropagation needs them to compute weight gradients via the chain rule.
- The output gradient dz3 = a3 - y is the simplified chain rule derivative of BCE + sigmoid — always -1 to 1, perfectly conditioned for gradient descent.
- Divide all weight gradients by N (batch size) to compute the mean gradient — without this, gradient magnitude scales with batch size causing inconsistent step sizes.
- Use He initialisation for ReLU layers (std=sqrt(2/fan_in)) and Xavier for sigmoid/tanh layers — wrong initialisation is the most common cause of training that never starts.
- Mini-batch training (batch_size=32) with shuffling each epoch introduces beneficial gradient noise that helps escape local minima compared to full-batch gradient descent.