DEV Community

Vuk Rosić
Vuk Rosić

Posted on

Code a Neural Network from Scratch in NumPy

Part of my Zero to AI Researcher / Engineer Course

Part 1: Getting Started - Your First Neural Network

import numpy as np import matplotlib.pyplot as plt # Create simple dataset - XOR problem X = np.array([[0, 0], [0, 1], [1, 0], [1, 1]]) y = np.array([[0], [1], [1], [0]]) print(f"Input shape: {X.shape}") # (4, 2) print(f"Output shape: {y.shape}") # (4, 1) print(f"Dataset:") for i in range(len(X)): print(f" {X[i]} -> {y[i][0]}") # Print each input pair and its expected output 
Enter fullscreen mode Exit fullscreen mode

What happened: We created the XOR dataset - a classic non-linearly separable problem that requires a neural network to solve.

Part 2: Understanding the Math - Forward Pass

Basic Network Architecture

# Network architecture: 2 -> 4 -> 1 (input -> hidden -> output) input_size = 2 # Number of input features (x and y coordinates) hidden_size = 4 # Number of neurons in hidden layer output_size = 1 # Number of output values (0 or 1)  # Initialize weights and biases np.random.seed(42) W1 = np.random.randn(input_size, hidden_size) * 0.5 # Weights: connection strengths between layers b1 = np.zeros((1, hidden_size)) # Biases: adjustable offsets for each neuron W2 = np.random.randn(hidden_size, output_size) * 0.5 # Weights for output layer b2 = np.zeros((1, output_size)) # Bias for output layer  print(f"W1 shape: {W1.shape}") # (2, 4) print(f"b1 shape: {b1.shape}") # (1, 4) print(f"W2 shape: {W2.shape}") # (4, 1) print(f"b2 shape: {b2.shape}") # (1, 1) 
Enter fullscreen mode Exit fullscreen mode

OPTIONAL: Understanding Matrix Shapes in Neural Networks

# Let's trace through the shapes step by step print("Shape flow through network:") print(f"Input X: {X.shape}") # (4, 2) print(f"Weights W1: {W1.shape}") # (2, 4) print(f"X @ W1: {(X @ W1).shape}") # (4, 4) print(f"Bias b1: {b1.shape}") # (1, 4)  # Broadcasting explanation sample_mult = X @ W1 print(f"\nBroadcasting bias:") print(f"(X @ W1) shape: {sample_mult.shape}") # (4, 4) print(f"b1 shape: {b1.shape}") # (1, 4) print(f"Result shape: {(sample_mult + b1).shape}") # (4, 4) 
Enter fullscreen mode Exit fullscreen mode

Forward Pass Implementation

def forward_pass(X, W1, b1, W2, b2): """Complete forward pass through the network""" # Hidden layer  z1 = X @ W1 + b1 # Linear transformation  a1 = sigmoid(z1) # Activation  # Output layer  z2 = a1 @ W2 + b2 # Linear transformation  a2 = sigmoid(z2) # Activation  return z1, a1, z2, a2 # Test forward pass z1, a1, z2, predictions = forward_pass(X, W1, b1, W2, b2) print(f"Predictions shape: {predictions.shape}") print(f"Predictions:\n{predictions.flatten()}") print(f"Actual labels:\n{y.flatten()}") 
Enter fullscreen mode Exit fullscreen mode

OPTIONAL: Understanding the Sigmoid Function

def sigmoid(x): """Sigmoid activation function""" return 1 / (1 + np.exp(-np.clip(x, -500, 500))) # Clip to prevent overflow  # Test sigmoid on different inputs test_values = np.array([-10, -1, 0, 1, 10]) sigmoid_values = sigmoid(test_values) print("Sigmoid function behavior:") for i, val in enumerate(test_values): print(f" sigmoid({val:3.0f}) = {sigmoid_values[i]:.3f}") # Visualize sigmoid x_range = np.linspace(-10, 10, 100) y_sigmoid = sigmoid(x_range) plt.figure(figsize=(8, 4)) plt.plot(x_range, y_sigmoid, 'b-', linewidth=2) plt.title('Sigmoid Activation Function') plt.xlabel('x') plt.ylabel('sigmoid(x)') plt.grid(True, alpha=0.3) plt.show() 
Enter fullscreen mode Exit fullscreen mode

OPTIONAL: Breaking Down the Sigmoid Formula

# Let's understand sigmoid step by step: 1 / (1 + e^(-x)) x = 2.0 print(f"Input: x = {x}") print(f"Step 1: -x = {-x}") print(f"Step 2: e^(-x) = np.exp(-x) = {np.exp(-x):.3f}") print(f"Step 3: 1 + e^(-x) = {1 + np.exp(-x):.3f}") print(f"Step 4: 1 / (1 + e^(-x)) = {1 / (1 + np.exp(-x)):.3f}") print(f"Sigmoid result: {sigmoid(x):.3f}") # Why clipping? print(f"\nWhy we clip large values:") large_x = 1000 print(f"Without clipping: np.exp(-{large_x}) would cause overflow") print(f"With clipping: np.exp(-500) = {np.exp(-500):.2e} (very small, safe)") 
Enter fullscreen mode Exit fullscreen mode

Key insight: The forward pass transforms input through weighted connections and activations to produce predictions.

Part 3: Computing Loss and Gradients

Loss Function

def compute_loss(predictions, targets): """Mean squared error loss""" return np.mean((predictions - targets) ** 2) # Square the difference to penalize large errors 
Enter fullscreen mode Exit fullscreen mode

OPTIONAL: Why We Square the Difference

# Let's see why we use (predictions - targets) ** 2 pred = np.array([0.8, 0.2, 0.9, 0.1]) target = np.array([1.0, 0.0, 1.0, 0.0]) differences = pred - target squared_differences = differences ** 2 print("Understanding squared error:") print(f"Predictions: {pred}") print(f"Targets: {target}") print(f"Differences: {differences}") print(f"Squared: {squared_differences}") print(f"Mean squared error: {np.mean(squared_differences):.4f}") # Why not just absolute difference? abs_differences = np.abs(differences) print(f"\nComparison:") print(f"Absolute differences: {abs_differences}") print(f"Squared differences: {squared_differences}") print("Squared errors penalize large mistakes more heavily!") 
Enter fullscreen mode Exit fullscreen mode

Calculate initial loss

initial_loss = compute_loss(predictions, y) print(f"Initial loss: {initial_loss:.4f}") 
Enter fullscreen mode Exit fullscreen mode

OPTIONAL: Understanding Derivative of Sigmoid

def sigmoid_derivative(x): """Derivative of sigmoid function""" s = sigmoid(x) return s * (1 - s) # Derivative formula: sigmoid(x) * (1 - sigmoid(x))  # Test derivative test_vals = np.array([-2, -1, 0, 1, 2]) sigmoid_vals = sigmoid(test_vals) derivative_vals = sigmoid_derivative(test_vals) print("Sigmoid and its derivative:") for i, val in enumerate(test_vals): print(f" x={val:2.0f}: sigmoid={sigmoid_vals[i]:.3f}, derivative={derivative_vals[i]:.3f}") # Visualize both functions x_range = np.linspace(-6, 6, 100) y_sigmoid = sigmoid(x_range) y_derivative = sigmoid_derivative(x_range) plt.figure(figsize=(10, 4)) plt.plot(x_range, y_sigmoid, 'b-', label='sigmoid(x)', linewidth=2) plt.plot(x_range, y_derivative, 'r--', label="sigmoid'(x)", linewidth=2) plt.title('Sigmoid Function and Its Derivative') plt.xlabel('x') plt.ylabel('y') plt.legend() plt.grid(True, alpha=0.3) plt.show() 
Enter fullscreen mode Exit fullscreen mode

OPTIONAL: Chain Rule in Backpropagation

# The chain rule: if y = f(g(x)), then dy/dx = f'(g(x)) * g'(x) # # For our network: Loss = MSE(sigmoid(W2 * sigmoid(W1 * X + b1) + b2), y) # We need: dLoss/dW2, dLoss/db2, dLoss/dW1, dLoss/db1  print("Chain rule breakdown:") print("dLoss/dW2 = dLoss/da2 * da2/dz2 * dz2/dW2") print(" where:") print(" dLoss/da2 = 2 * (predictions - targets) # MSE derivative") print(" da2/dz2 = sigmoid'(z2) # sigmoid derivative") print(" dz2/dW2 = a1 # linear layer derivative") 
Enter fullscreen mode Exit fullscreen mode

Backpropagation Implementation

def backward_pass(X, y, z1, a1, z2, a2, W1, b1, W2, b2): """Compute gradients using backpropagation""" m = X.shape[0] # Number of samples  # Output layer gradients  dz2 = 2 * (a2 - y) * sigmoid_derivative(z2) # (4, 1)  dW2 = a1.T @ dz2 / m # (4, 1)  db2 = np.mean(dz2, axis=0, keepdims=True) # (1, 1)  # Hidden layer gradients  dz1 = (dz2 @ W2.T) * sigmoid_derivative(z1) # (4, 4)  dW1 = X.T @ dz1 / m # (2, 4)  db1 = np.mean(dz1, axis=0, keepdims=True) # (1, 4)  return dW1, db1, dW2, db2 # Test backpropagation dW1, db1, dW2, db2 = backward_pass(X, y, z1, a1, z2, predictions, W1, b1, W2, b2) print(f"Gradient shapes:") print(f" dW1: {dW1.shape}, dW2: {dW2.shape}") print(f" db1: {db1.shape}, db2: {db2.shape}") 
Enter fullscreen mode Exit fullscreen mode

OPTIONAL: Understanding Output Layer Gradients

# Let's break down the output layer gradient calculation print("Output layer gradient breakdown:") print("dz2 = 2 * (a2 - y) * sigmoid_derivative(z2)") # Step by step error = a2 - y # How far off our predictions are print(f"Error (a2 - y) shape: {error.shape}") print(f"Error values:\n{error.flatten()}") mse_gradient = 2 * error # Derivative of MSE print(f"\nMSE gradient (2 * error) shape: {mse_gradient.shape}") sigmoid_grad = sigmoid_derivative(z2) # Derivative of sigmoid print(f"Sigmoid gradient shape: {sigmoid_grad.shape}") dz2_step = mse_gradient * sigmoid_grad # Chain rule print(f"Combined gradient (dz2) shape: {dz2_step.shape}") 
Enter fullscreen mode Exit fullscreen mode

OPTIONAL: Understanding Hidden Layer Gradients

# Hidden layer gradients are more complex due to chain rule print("Hidden layer gradient breakdown:") print("dz1 = (dz2 @ W2.T) * sigmoid_derivative(z1)") # Step by step error_propagated = dz2 @ W2.T # Propagate error backwards print(f"Error propagated shape: {error_propagated.shape}") print(f"This spreads output error to each hidden neuron") hidden_sigmoid_grad = sigmoid_derivative(z1) # Local gradient print(f"Hidden sigmoid gradient shape: {hidden_sigmoid_grad.shape}") dz1_step = error_propagated * hidden_sigmoid_grad # Final gradient print(f"Combined hidden gradient (dz1) shape: {dz1_step.shape}") 
Enter fullscreen mode Exit fullscreen mode

OPTIONAL: Understanding Weight Gradients

# Weight gradients show how to adjust connections print("Weight gradient calculation:") print("dW2 = a1.T @ dz2 / m") print(f"a1.T shape: {a1.T.shape}") # Transposed hidden activations print(f"dz2 shape: {dz2.shape}") # Output gradients print(f"dW2 shape: {(a1.T @ dz2).shape}") # Weight gradients  # This gives us the gradient for each weight connection print(f"\nWeight gradients tell us:") print(f"- Positive gradient: decrease this weight") print(f"- Negative gradient: increase this weight") print(f"- Large gradient: this weight has big impact on error") 
Enter fullscreen mode Exit fullscreen mode

Critical concept: Backpropagation uses the chain rule to compute how much each weight contributes to the total error.

Part 4: Training the Network

Training Loop

def train_network(X, y, epochs=1000, learning_rate=1.0): """Train the neural network""" # Initialize weights  np.random.seed(42) W1 = np.random.randn(2, 4) * 0.5 b1 = np.zeros((1, 4)) W2 = np.random.randn(4, 1) * 0.5 b2 = np.zeros((1, 1)) losses = [] for epoch in range(epochs): # Forward pass  z1, a1, z2, predictions = forward_pass(X, W1, b1, W2, b2) # Compute loss  loss = compute_loss(predictions, y) losses.append(loss) # Backward pass  dW1, db1, dW2, db2 = backward_pass(X, y, z1, a1, z2, predictions, W1, b1, W2, b2) # Update weights  W1 -= learning_rate * dW1 b1 -= learning_rate * db1 W2 -= learning_rate * dW2 b2 -= learning_rate * db2 # Print progress  if epoch % 100 == 0: print(f"Epoch {epoch:4d}: Loss = {loss:.6f}") return W1, b1, W2, b2, losses # Train the network W1_trained, b1_trained, W2_trained, b2_trained, loss_history = train_network(X, y) 
Enter fullscreen mode Exit fullscreen mode

OPTIONAL: Understanding Learning Rate

# Learning rate controls how big steps we take during optimization # Too small: slow convergence, too large: might overshoot minimum  learning_rates = [0.1, 1.0, 10.0] plt.figure(figsize=(12, 4)) for i, lr in enumerate(learning_rates): plt.subplot(1, 3, i+1) # Train with this learning rate  _, _, _, _, losses = train_network(X, y, epochs=500, learning_rate=lr) plt.plot(losses) plt.title(f'Learning Rate = {lr}') plt.xlabel('Epoch') plt.ylabel('Loss') plt.yscale('log') plt.grid(True, alpha=0.3) plt.tight_layout() plt.show() 
Enter fullscreen mode Exit fullscreen mode

OPTIONAL: Visualizing Training Progress

# Plot loss curve plt.figure(figsize=(10, 4)) plt.subplot(1, 2, 1) plt.plot(loss_history) plt.title('Training Loss') plt.xlabel('Epoch') plt.ylabel('Loss') plt.yscale('log') plt.grid(True, alpha=0.3) plt.subplot(1, 2, 2) plt.plot(loss_history[-100:]) # Last 100 epochs plt.title('Training Loss (Last 100 Epochs)') plt.xlabel('Epoch') plt.ylabel('Loss') plt.grid(True, alpha=0.3) plt.tight_layout() plt.show() print(f"Final loss: {loss_history[-1]:.6f}") 
Enter fullscreen mode Exit fullscreen mode

Part 5: Testing the Trained Network

Final Predictions

# Test the trained network z1_final, a1_final, z2_final, final_predictions = forward_pass(X, W1_trained, b1_trained, W2_trained, b2_trained) print("Final Results:") print("Input -> Target | Prediction | Rounded") print("-" * 40) for i in range(len(X)): pred = final_predictions[i, 0] rounded = round(pred) target = y[i, 0] print(f"{X[i]} -> {target} | {pred:.4f} | {rounded}") # Calculate accuracy rounded_predictions = np.round(final_predictions) accuracy = np.mean(rounded_predictions == y) print(f"\nAccuracy: {accuracy:.1%}") 
Enter fullscreen mode Exit fullscreen mode

OPTIONAL: Visualizing Decision Boundary

# Create a grid of points to visualize the decision boundary def plot_decision_boundary(W1, b1, W2, b2): """Plot the decision boundary learned by the network""" # Create a grid  xx, yy = np.meshgrid(np.linspace(-0.5, 1.5, 100), np.linspace(-0.5, 1.5, 100)) # Flatten the grid for prediction  grid_points = np.c_[xx.ravel(), yy.ravel()] # Make predictions on the grid  _, _, _, grid_predictions = forward_pass(grid_points, W1, b1, W2, b2) grid_predictions = grid_predictions.reshape(xx.shape) # Plot  plt.figure(figsize=(8, 6)) plt.contourf(xx, yy, grid_predictions, levels=50, alpha=0.8, cmap='RdYlBu') plt.colorbar(label='Network Output') # Plot data points  colors = ['red' if label == 0 else 'blue' for label in y.flatten()] plt.scatter(X[:, 0], X[:, 1], c=colors, s=100, edgecolors='black', linewidth=2) # Add labels  for i, (x, y_val) in enumerate(zip(X, y.flatten())): plt.annotate(f'({x[0]},{x[1]})→{y_val}', (x[0], x[1]), xytext=(5, 5), textcoords='offset points') plt.title('Neural Network Decision Boundary') plt.xlabel('Input 1') plt.ylabel('Input 2') plt.grid(True, alpha=0.3) plt.show() # Visualize the decision boundary plot_decision_boundary(W1_trained, b1_trained, W2_trained, b2_trained) 
Enter fullscreen mode Exit fullscreen mode

Part 6: Understanding What We Built

Complete Neural Network Class

class SimpleNeuralNetwork: """A simple 2-layer neural network implementation""" def __init__(self, input_size=2, hidden_size=4, output_size=1): # Initialize weights  self.W1 = np.random.randn(input_size, hidden_size) * 0.5 self.b1 = np.zeros((1, hidden_size)) self.W2 = np.random.randn(hidden_size, output_size) * 0.5 self.b2 = np.zeros((1, output_size)) def sigmoid(self, x): return 1 / (1 + np.exp(-np.clip(x, -500, 500))) def forward(self, X): self.z1 = X @ self.W1 + self.b1 self.a1 = self.sigmoid(self.z1) self.z2 = self.a1 @ self.W2 + self.b2 self.a2 = self.sigmoid(self.z2) return self.a2 def backward(self, X, y): m = X.shape[0] # Output layer gradients  dz2 = 2 * (self.a2 - y) * self.sigmoid(self.z2) * (1 - self.sigmoid(self.z2)) dW2 = self.a1.T @ dz2 / m db2 = np.mean(dz2, axis=0, keepdims=True) # Hidden layer gradients  dz1 = (dz2 @ self.W2.T) * self.sigmoid(self.z1) * (1 - self.sigmoid(self.z1)) dW1 = X.T @ dz1 / m db1 = np.mean(dz1, axis=0, keepdims=True) return dW1, db1, dW2, db2 def train(self, X, y, epochs=1000, learning_rate=1.0): losses = [] for epoch in range(epochs): # Forward pass  predictions = self.forward(X) # Compute loss  loss = np.mean((predictions - y) ** 2) losses.append(loss) # Backward pass  dW1, db1, dW2, db2 = self.backward(X, y) # Update weights  self.W1 -= learning_rate * dW1 self.b1 -= learning_rate * db1 self.W2 -= learning_rate * dW2 self.b2 -= learning_rate * db2 if epoch % 100 == 0: print(f"Epoch {epoch:4d}: Loss = {loss:.6f}") return losses def predict(self, X): return self.forward(X) # Test the class nn = SimpleNeuralNetwork() losses = nn.train(X, y, epochs=1000, learning_rate=1.0) predictions = nn.predict(X) print("\nClass-based Neural Network Results:") for i in range(len(X)): pred = predictions[i, 0] target = y[i, 0] print(f"{X[i]} -> {target} | Prediction: {pred:.4f} | Rounded: {round(pred)}") 
Enter fullscreen mode Exit fullscreen mode

OPTIONAL: Comparing with Different Architectures

# Test different hidden layer sizes hidden_sizes = [2, 4, 8, 16] results = {} for hidden_size in hidden_sizes: print(f"\nTesting hidden size: {hidden_size}") nn = SimpleNeuralNetwork(input_size=2, hidden_size=hidden_size, output_size=1) losses = nn.train(X, y, epochs=1000, learning_rate=1.0) predictions = nn.predict(X) # Calculate accuracy  accuracy = np.mean(np.round(predictions) == y) results[hidden_size] = { 'final_loss': losses[-1], 'accuracy': accuracy, 'predictions': predictions } print(f" Final loss: {losses[-1]:.6f}") print(f" Accuracy: {accuracy:.1%}") # Plot comparison plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) hidden_sizes_list = list(results.keys()) final_losses = [results[hs]['final_loss'] for hs in hidden_sizes_list] plt.bar(hidden_sizes_list, final_losses) plt.title('Final Loss vs Hidden Size') plt.xlabel('Hidden Layer Size') plt.ylabel('Final Loss') plt.yscale('log') plt.subplot(1, 2, 2) accuracies = [results[hs]['accuracy'] for hs in hidden_sizes_list] plt.bar(hidden_sizes_list, accuracies) plt.title('Accuracy vs Hidden Size') plt.xlabel('Hidden Layer Size') plt.ylabel('Accuracy') plt.ylim(0, 1) plt.tight_layout() plt.show() 
Enter fullscreen mode Exit fullscreen mode

Key takeaway: You've built a complete neural network from scratch! The network learns to solve the XOR problem by discovering the right weights and biases through gradient descent.


Summary

You've successfully implemented:

  1. Forward propagation: Computing predictions from inputs
  2. Loss computation: Measuring how wrong the predictions are
  3. Backpropagation: Computing gradients using the chain rule
  4. Weight updates: Using gradient descent to improve the network
  5. Complete training loop: Putting it all together

The neural network learns by repeatedly adjusting its weights based on the errors it makes, eventually discovering the complex decision boundary needed to solve the XOR problem.

Top comments (0)