Back to Blog
Oct 1, 20253 min readOnuzulike Anthony

Building Neural Networks from Scratch in NumPy

Forward pass, backward pass, gradient computation — no framework. A concrete XOR implementation with the full math spelled out.

AI/MLNumPyNeural NetworksAI/MLFrom Scratch

Every deep learning framework is hiding the same ten lines of math. Before you reach for PyTorch or TensorFlow, writing a network in raw NumPy forces you to understand what actually happens during training. This post builds a working XOR classifier step by step.

The Math You Actually Need

A neural network is a composition of functions. Each layer applies a linear transformation followed by a non-linearity:

Z = X @ W + b       # linear step
A = activation(Z)   # non-linear step

Training is gradient descent on a loss function. To compute gradients, you apply the chain rule backwards through the computation graph — that's all backpropagation is.

Forward Pass

For XOR, we need at least one hidden layer. A 2→8→1 architecture works:

python
import numpy as np
 
def sigmoid(z):
    return 1 / (1 + np.exp(-z))
 
def sigmoid_deriv(a):
    return a * (1 - a)
 
def relu(z):
    return np.maximum(0, z)
 
def relu_deriv(z):
    return (z > 0).astype(float)
 
# Xavier initialization
W1 = np.random.randn(2, 8) * np.sqrt(1 / 2)
b1 = np.zeros((1, 8))
W2 = np.random.randn(8, 1) * np.sqrt(1 / 8)
b2 = np.zeros((1, 1))
 
def forward(X):
    Z1 = X @ W1 + b1
    A1 = relu(Z1)
    Z2 = A1 @ W2 + b2
    A2 = sigmoid(Z2)
    cache = (X, Z1, A1, Z2, A2)
    return A2, cache

The cache stores intermediate values — you'll need them during backprop.

Loss Function

Binary cross-entropy for a binary classifier:

python
def bce_loss(y_pred, y_true):
    eps = 1e-9
    return -np.mean(
        y_true * np.log(y_pred + eps) + (1 - y_true) * np.log(1 - y_pred + eps)
    )

The gradient of BCE with respect to the output (before sigmoid) simplifies to A2 - y, which is why sigmoid + BCE is such a common pairing.

Backward Pass

This is where most tutorials get vague. Every gradient is computed by asking: "how does this variable affect the loss?"

python
def backward(cache, y_true, lr=0.05):
    global W1, b1, W2, b2
    X, Z1, A1, Z2, A2 = cache
    m = X.shape[0]
 
    # Output layer gradient
    dA2 = (A2 - y_true) / m          # dL/dA2 (BCE + sigmoid shortcut)
    dW2 = A1.T @ dA2
    db2 = np.sum(dA2, axis=0, keepdims=True)
 
    # Hidden layer gradient
    dA1 = dA2 @ W2.T
    dZ1 = dA1 * relu_deriv(Z1)       # chain rule through ReLU
    dW1 = X.T @ dZ1
    db1 = np.sum(dZ1, axis=0, keepdims=True)
 
    # Gradient descent step
    W2 -= lr * dW2
    b2 -= lr * db2
    W1 -= lr * dW1
    b1 -= lr * db1

The key line is dZ1 = dA1 * relu_deriv(Z1). The derivative of ReLU is 1 where the input was positive and 0 where it was negative — gradients don't flow through dead neurons.

Training Loop

python
X = np.array([[0,0],[0,1],[1,0],[1,1]], dtype=np.float32)
y = np.array([[0],[1],[1],[0]], dtype=np.float32)
 
for epoch in range(3000):
    pred, cache = forward(X)
    loss = bce_loss(pred, y)
    backward(cache, y)
    if epoch % 500 == 0:
        print(f"Epoch {epoch}: loss={loss:.4f}")
 
# Results after training
pred, _ = forward(X)
print(pred.round(2))  # → [[0.02], [0.97], [0.97], [0.03]]

What Neural Nexus Adds

Neural Nexus wraps this pattern in a block system so you don't repeat the boilerplate. Affine(2, 8) is the W1/b1 pair. ReLU() caches its input for backward. NeuralNet.fit() runs the loop. But the math is identical — the library just orchestrates it.

Understanding this from scratch means you can debug a training run when the loss explodes (usually a learning rate or initialization problem), when gradients vanish (usually depth + saturating activations), or when the model memorizes but doesn't generalize (usually lack of regularization).