Deep Neural Networks

Build deep learning foundations with multi-layer perceptrons and backpropagation

intermediate50 min
On this page

The Big Picture: What are Deep Neural Networks?

A Deep Neural Network (DNN) is a function that learns complex patterns from data. It's called "deep" because it has many layers of neurons stacked on top of each other.

Why Depth Matters

The Universal Approximation Theorem tells us that a single hidden layer can theoretically approximate any continuous function. However, deep networks require exponentially fewer neurons to learn the same function. Depth enables feature hierarchies

Shallow vs Deep Network Capacity
Shallow Network Large Input Hidden Output Deep Network L1 L2 L3 L4 Out Fewer neurons, better learning through feature hierarchies
Key Insight: A deep network learns hierarchical representations. Early layers learn low-level features, middle layers combine them, and deep layers learn abstract semantic features. This is why depth is powerful.

AdTech Example: Conversion Prediction

Imagine you're building a model for Bing Ads to predict the probability that a user will convert (purchase) after clicking an ad. A DNN takes user, ad, and context features and outputs P(conversion).

Feature Pipeline → DNN → Conversion Probability
Features User Features • Browser history • Geolocation • Demographics Ad Features • Creative ID • Campaign ID • Bid amount Context Features • Time of day • Device type • Page category Deep Neural Network 15+ Input (15) Hidden (128) Hidden (64) Output (1) P(Conversion) 0.73 73% chance user converts

Real-world Use Cases

CTR Prediction

Predict P(click) given an ad impression. Used to rank ads.

Conversion Prediction

Predict P(conversion) given a click. Used for bid optimization.

Multi-touch Attribution

Learn how each touch contributes to final conversion.

Audience Modeling

Learn dense embeddings of user segments for targeting.

Why DNNs Win in AdTech: Features interact in complex ways. A user with high purchase intent + relevant creative + right timing = conversion. DNNs automatically discover these interaction patterns without hand-crafted features.

Network Architecture: Layers, Neurons & Weights

A DNN is organized into layers. Each layer contains neurons. Each neuron performs two operations: a linear transformation (multiply by weights + add bias) and a nonlinear transformation (apply activation function).

Input Layer

The input layer has one neuron per feature. In our AdTech example, we have 15 features, so 15 input neurons. There's no computation here—these are just placeholders for feature values.

Hidden Layers

Each hidden layer neuron computes:

z = w₁·x₁ + w₂·x₂ + ... + wₙ·xₙ + b
a = σ(z)  (apply activation function)

z is called the pre-activation, a is the post-activation. The weights w and bias b are learned during training.

Output Layer

The output layer applies a final transformation. For binary classification (CTR/conversion), we use sigmoid to output a probability in [0,1]. For multi-class (ad category), we use softmax.

Complete Architecture Diagram

3-Layer Network: Forward Pass Computation
Input Layer Hidden Layer 1 (5 neurons) Hidden Layer 2 (3 neurons) Output (1) x₁ x₂ x₃ ... h₁ h₂ h₃ h₄ h₅ h₆ h₇ h₈ y For each hidden neuron: z = w·x + b a = ReLU(z) = max(0, z) Output layer: z_out = w·h + b ŷ = σ(z_out) (sigmoid)

Parameters Summary

Component What It Represents Learned?
Weights (w) Strength of connection between neurons Yes (gradient descent)
Bias (b) Offset term, allows non-zero activation when all inputs are zero Yes (gradient descent)
Activation σ Nonlinearity that enables learning complex functions No (fixed function)
Key Point: Total parameters = (input_size × hidden_size + hidden_size) + (hidden_size × hidden_size + hidden_size) + ... For our example: 15×128 + 128 + 128×64 + 64 + 64×1 + 1 = 10,689 parameters to learn!

Forward Propagation: Data Flowing Forward

Forward propagation is the process of computing the output given an input. Data flows from input layer through hidden layers to output.

General Form

For a network with L layers:

z⁽¹⁾ = w⁽¹⁾·x + b⁽¹⁾
a⁽¹⁾ = σ(z⁽¹⁾)

z⁽²⁾ = w⁽²⁾·a⁽¹⁾ + b⁽²⁾
a⁽²⁾ = σ(z⁽²⁾)

...

z⁽ᴸ⁾ = w⁽ᴸ⁾·a⁽ᴸ⁻¹⁾ + b⁽ᴸ⁾
ŷ = σ(z⁽ᴸ⁾)  [output activation]

Concrete Example: 2-Hidden-Layer Network

Input: x = [0.5, -1.2, 0.3] (3 features). Target: y = 1 (conversion occurred)

Step-by-Step Forward Propagation
Layer 1: Linear + ReLU Input: x = [0.5, -1.2, 0.3] Weights w⁽¹⁾ (3×4 matrix): [0.2 0.5 -0.3] [0.1 -0.4 0.6] [0.3 0.1 -0.2] [-0.5 0.2 0.4] Bias b⁽¹⁾: [0.1, 0.2, -0.1, 0.0] z⁽¹⁾ = w⁽¹⁾·x + b⁽¹⁾: z₁ = 0.2(0.5) + 0.5(-1.2) + (-0.3)(0.3) + 0.1 = -0.555 z₂ = 0.1(0.5) + (-0.4)(-1.2) + 0.6(0.3) + 0.2 = 1.35 z₃ = 0.3(0.5) + 0.1(-1.2) + (-0.2)(0.3) - 0.1 = -0.04 z₄ = -0.5(0.5) + 0.2(-1.2) + 0.4(0.3) + 0.0 = -0.49 a⁽¹⁾ = ReLU(z⁽¹⁾): a⁽¹⁾ = [0.0, 1.35, 0.0, 0.0] (ReLU zeroes negatives) Layer 2: Linear + ReLU Input from Layer 1: a⁽¹⁾ = [0.0, 1.35, 0.0, 0.0] Weights w⁽²⁾ (4×2), Bias b⁽²⁾: w⁽²⁾ and b⁽²⁾ omitted for brevity z⁽²⁾ = w⁽²⁾·a⁽¹⁾ + b⁽²⁾: z₁ = 0.4(0.0) + 0.8(1.35) + (-0.2)(0.0) + 0.1(0.0) + 0.05 = 1.13 z₂ = 0.3(0.0) + (-0.5)(1.35) + 0.6(0.0) + 0.2(0.0) - 0.1 = -0.775 a⁽²⁾ = ReLU(z⁽²⁾): a⁽²⁾ = [1.13, 0.0] Output Layer: Linear + Sigmoid z⁽ᴼᵘᵗ⁾ = w⁽ᴼᵘᵗ⁾·a⁽²⁾ + b⁽ᴼᵘᵗ⁾: z_out = 0.9(1.13) + (-0.7)(0.0) + 0.2 = 1.217 ŷ = σ(z_out) = 1/(1 + e^(-1.217)) ≈ 0.771 PREDICTION: 77.1% chance of conversion Data Flow [0.5, -1.2, 0.3] → z⁽¹⁾ → ReLU → a⁽¹⁾=[0.0, 1.35, 0.0, 0.0] → z⁽²⁾ → ReLU → a⁽²⁾=[1.13, 0.0] → z_out → sigmoid → 0.771
Key Insight: Forward propagation is just a series of matrix multiplications and nonlinear activations. It's fast and deterministic—given fixed weights, the same input always produces the same output.

Loss & Cost Functions: Measuring Error

Loss measures error on a single sample. Cost is the average loss over the entire dataset. Training minimizes cost by adjusting weights.

Binary Cross-Entropy (BCE) for CTR/Conversion Prediction

Used when predicting a single probability (click or not, convert or not):

Loss(ŷ, y) = -[y·log(ŷ) + (1-y)·log(1-ŷ)]

If y=1 (conversion happened):
  Loss = -log(ŷ)
  • If ŷ=0.99: Loss ≈ 0.01 (good prediction)
  • If ŷ=0.01: Loss ≈ 4.6 (terrible prediction)

If y=0 (no conversion):
  Loss = -log(1-ŷ)
  • If ŷ=0.01: Loss ≈ 0.01 (good)
  • If ŷ=0.99: Loss ≈ 4.6 (terrible)

Categorical Cross-Entropy (CCE) for Multi-Class

Used when predicting one of K mutually exclusive classes (e.g., ad category: [Gaming, Fashion, Finance, Tech]):

Loss(ŷ, y) = -Σᵢ yᵢ·log(ŷᵢ)

where ŷ comes from softmax: ŷᵢ = e^(zᵢ) / Σⱼ e^(zⱼ)

Mean Squared Error (MSE) for Regression

Used when predicting continuous values (e.g., predicted revenue):

Loss(ŷ, y) = (ŷ - y)²
Cost = (1/N) Σᵢ (ŷᵢ - yᵢ)²

Comparison of Loss Functions

Loss Function Behavior
Binary Cross-Entropy ŷ Loss 0 1 Categorical Cross-Entropy prediction quality Loss poor perfect Mean Squared Error (ŷ - y) Loss y-value

Why Loss Functions Matter

Function Use Case Key Property
Binary Cross-Entropy Binary classification (click/no-click, convert/no-convert) Heavily penalizes confident wrong predictions
Categorical Cross-Entropy Multi-class classification (ad category, user segment) Works with softmax to encourage one-hot outputs
MSE Regression (revenue prediction, lifetime value) Penalizes large errors quadratically
Cross-Entropy Intuition: It measures the "distance" between predicted distribution and true distribution. When your model is confident but wrong, it gets severely punished. This encourages calibrated probabilities.

Note: For detailed BCE derivation from first principles, see the Logistic Regression guide.

Backpropagation: Learning Through the Chain Rule

Backpropagation is the algorithm that computes gradients efficiently by propagating error signals backward through the network. It uses the chain rule from calculus.

The Core Question

Given that the model made an error, "How much is each weight responsible for that error?" Backprop answers this by computing ∂Loss/∂w for every weight w.

The Chain Rule Intuition

Computation Graph: Forward (Blue) and Backward (Orange) Pass
FORWARD PASS (Computing Prediction) x w,b z=wx+b a=σ(z) ŷ (output) Loss BACKWARD PASS (Computing Gradients) ∂L/∂ŷ ∂L/∂a ∂L/∂z ∂L/∂w Using the Chain Rule: ∂L/∂w = ∂L/∂ŷ · ∂ŷ/∂z · ∂z/∂w ∂L/∂b = ∂L/∂ŷ · ∂ŷ/∂z · ∂z/∂b Gradient "flows backward" through the network: Loss → output → hidden → input

Step-by-Step Derivation for 2-Layer Network

Let's compute gradients for our earlier example. We have:

Layer 1: z⁽¹⁾ = w⁽¹⁾·x + b⁽¹⁾, a⁽¹⁾ = ReLU(z⁽¹⁾)
Layer 2: z⁽²⁾ = w⁽²⁾·a⁽¹⁾ + b⁽²⁾, a⁽²⁾ = ReLU(z⁽²⁾)
Output:  z_out = w_out·a⁽²⁾ + b_out, ŷ = σ(z_out)
Loss:    L = -[y·log(ŷ) + (1-y)·log(1-ŷ)]

Output Layer Gradients

Starting from the loss:

∂L/∂ŷ = -[y/ŷ - (1-y)/(1-ŷ)] = (ŷ - y) / [ŷ(1-ŷ)]

∂ŷ/∂z_out = σ'(z_out) = ŷ(1-ŷ)

∂L/∂z_out = ∂L/∂ŷ · ∂ŷ/∂z_out = (ŷ - y)

∂L/∂w_out = ∂L/∂z_out · ∂z_out/∂w_out = (ŷ - y) · a⁽²⁾ᵀ
∂L/∂b_out = ∂L/∂z_out = (ŷ - y)

Hidden Layer 2 Gradients

Propagating backward through layer 2:

∂L/∂a⁽²⁾ = ∂L/∂z_out · ∂z_out/∂a⁽²⁾ = (ŷ - y) · w_outᵀ

∂L/∂z⁽²⁾ = ∂L/∂a⁽²⁾ · ∂a⁽²⁾/∂z⁽²⁾ = (ŷ - y) · w_outᵀ · ReLU'(z⁽²⁾)
           where ReLU'(z) = 1 if z > 0, else 0

∂L/∂w⁽²⁾ = ∂L/∂z⁽²⁾ · ∂z⁽²⁾/∂w⁽²⁾ = ∂L/∂z⁽²⁾ · a⁽¹⁾ᵀ
∂L/∂b⁽²⁾ = ∂L/∂z⁽²⁾

Hidden Layer 1 Gradients

Continuing backward:

∂L/∂a⁽¹⁾ = ∂L/∂z⁽²⁾ · ∂z⁽²⁾/∂a⁽¹⁾ = ∂L/∂z⁽²⁾ · w⁽²⁾ᵀ

∂L/∂z⁽¹⁾ = ∂L/∂a⁽¹⁾ · ∂a⁽¹⁾/∂z⁽¹⁾ = ∂L/∂a⁽¹⁾ · ReLU'(z⁽¹⁾)

∂L/∂w⁽¹⁾ = ∂L/∂z⁽¹⁾ · xᵀ
∂L/∂b⁽¹⁾ = ∂L/∂z⁽¹⁾

Weight Update (Stochastic Gradient Descent)

After computing gradients, we update weights in the opposite direction:

w ← w - α · ∂L/∂w
b ← b - α · ∂L/∂b

where α is the learning rate (e.g., 0.001)
Gradient Flow Through Layers (Conceptual)
Loss=2.1 Layer 3 ∂L/∂z=0.85 Layer 2 ∂L/∂z=0.42 Layer 1 ∂L/∂z=0.10 Notice: Gradients shrink as they flow backward! Layer 3: 0.85 → Layer 2: 0.42 (×0.5) → Layer 1: 0.10 (×0.24) This is the VANISHING GRADIENT problem (detailed in Section 7)
Backprop Summary: We compute "how much each weight contributed to the error" by applying the chain rule backward through the network. Each gradient tells us the direction and magnitude to adjust that weight. This is efficient because we compute all gradients in a single backward pass, reusing intermediate values from the forward pass.

The Vanishing Gradient Problem

Deep networks trained with sigmoid activation suffer from a critical problem: gradients become exponentially smaller as they propagate backward through layers. This makes early layers learn extremely slowly.

Why Sigmoid Causes Vanishing Gradients

The derivative of sigmoid is:

σ(z) = 1 / (1 + e^(-z))
σ'(z) = σ(z)·(1-σ(z))

Maximum value: σ'(0) = 0.25
For z far from 0 (saturated region): σ'(z) ≈ 0

During backprop, gradients multiply: ∂L/∂w⁽¹⁾ ∝ (σ'(z₃) × σ'(z₂) × σ'(z₁)). If each σ'(zᵢ) ≤ 0.25, this product becomes tiny!

Sigmoid Derivative and Vanishing Gradients
Sigmoid σ(z) = 1/(1+e^(-z)) z σ(z) -∞ +∞ 0.5 Sigmoid Derivative σ'(z) max=0.25 z 0.25 Concrete Example: 10-Layer Network with Sigmoid Gradient at each layer (assuming σ'(zᵢ) ≈ 0.25 at saturation): ∂L/∂w⁽¹⁰⁾ ~ 1.0 (output layer) ∂L/∂w⁽⁹⁾ ~ 0.25 (×0.25 for each layer) ∂L/∂w⁽⁸⁾ ~ 0.0625 (0.25²) ∂L/∂w⁽¹⁾ ~ 0.00000095 (0.25¹⁰ ≈ 9.5×10⁻⁷) Layer 1 gradient is 1 MILLIONTH of the output layer! Training is essentially frozen. Gradient Magnitude Through Layers:
1.0 0.25 0.06 0.015 Layer 1 Layer 4 Layer 7 Layer 10

The Opposite Problem: Exploding Gradients

In rare cases, if weights are initialized too large, gradients can grow exponentially (∂L/∂w ∝ 2¹⁰ or larger), causing numerical overflow. Solution: gradient clipping.

Solutions to Vanishing Gradients

Solution How It Works Drawback
ReLU Activation Derivative = 1 for z > 0, no multiplication chain effect "Dying ReLU": neurons can get stuck at 0
Batch Normalization Normalize activations to mean 0, std 1. Stabilizes gradients. Adds computational cost, behaves differently at train vs test time
Residual Connections Skip connections allow gradients to flow directly backward More complex architecture to implement
Weight Initialization Xavier/He initialization scales weights properly Must choose initialization scheme for activation function
Key Takeaway: Vanishing gradients is why deep networks were hard to train before ReLU and batch normalization were invented. Modern techniques solve this, enabling training of networks with hundreds of layers.

Activation Functions: Adding Nonlinearity

Activation functions introduce nonlinearity. Without them, stacking linear layers would be equivalent to one big linear layer—not expressive enough for complex patterns.

Sigmoid

σ(z) = 1 / (1 + e^(-z))
σ'(z) = σ(z)·(1 - σ(z)), max value 0.25

Output range: (0, 1)
When to use: Output layer for binary classification
Pros: Outputs probability, good historical choice
Cons: Saturates, causes vanishing gradients

Tanh (Hyperbolic Tangent)

tanh(z) = (e^z - e^(-z)) / (e^z + e^(-z)) = 2·σ(2z) - 1
tanh'(z) = 1 - tanh(z)², max value 1.0

Output range: (-1, 1)
When to use: Hidden layers in older models
Pros: Zero-centered (helps training), steeper gradient than sigmoid
Cons: Still saturates and causes vanishing gradients

ReLU (Rectified Linear Unit) ⭐ MOST POPULAR

ReLU(z) = max(0, z)
ReLU'(z) = 1 if z > 0, else 0

Output range: [0, ∞)
When to use: Hidden layers (default choice for deep networks)
Pros: No saturation for z>0, fast to compute, avoids vanishing gradients
Cons: "Dying ReLU" (dead neurons if learning rate too high)

LeakyReLU (Fixes Dying ReLU)

LeakyReLU(z) = max(αz, z), where α is small (e.g., 0.01)
LeakyReLU'(z) = 1 if z > 0, else α

Output range: (-∞, ∞)
When to use: Hidden layers (improvement over ReLU)
Pros: No dying neurons, still efficient
Cons: Minimal practical improvement over ReLU in most cases

ELU (Exponential Linear Unit)

ELU(z) = z if z > 0, else α(e^z - 1)
ELU'(z) = 1 if z > 0, else α·e^z

Output range: (-α, ∞)
Pros: Smooth, near zero-centered for negative values
Cons: Slower to compute (exponential)

GELU (Used in Transformers)

GELU(z) = z · Φ(z), where Φ is the standard normal CDF

Output range: approx (-0.17, z+0.84)
When to use: State-of-the-art language models, vision transformers
Pros: Smooth, better empirical performance than ReLU
Cons: More expensive to compute, less well-understood

Visual Comparison

Activation Functions and Their Derivatives
Sigmoid z σ(z) z σ'(z) Tanh z z ReLU z z 1 LeakyReLU z z When to Use Which Activation? Hidden Layers: ReLU (default) or LeakyReLU if dying neurons observed Output Binary Classification: Sigmoid (outputs P ∈ [0,1]) Output Multi-class: Softmax (outputs probabilities for K classes) Output Regression: Linear (no activation) or ReLU if output must be ≥ 0 Transformers: GELU in hidden layers, no activation in output Rule of Thumb: Start with ReLU everywhere except output layer. Adjust based on empirical performance.
ReLU Revolution: Hinton et al. showed in 2012 that ReLU enables training very deep networks. This was a breakthrough that kicked off the deep learning era. Always start with ReLU in hidden layers.

Dropout & Regularization: Preventing Overfitting

A model can memorize training data instead of learning generalizable patterns. Regularization techniques prevent this by adding constraints during training.

Dropout: Random Neuron Deactivation

During training, randomly zero out (drop) neurons with probability p. This prevents co-adaptation where neurons rely too much on specific neighbors.

Dropout: Training vs Inference
Training (p=0.5 dropout) Dashed = Dropped (zeroed out) Prevents co-adaptation, acts like ensemble Inference (no dropout) All neurons active at full strength Scale outputs: multiply by (1-p) to account for training behavior

Dropout Implementation Detail

Two approaches, both equivalent:

Approach 1: Dropout during training, scale at inference
  During training:  a_dropped = a * (Bernoulli(1-p)) / (1-p)
  During inference: a (no dropout)

Approach 2: Inverted Dropout (more common)
  During training:  a_dropped = a * (Bernoulli(1-p)) / (1-p)
  During inference: a (no change needed)

Batch Normalization

Normalize activations to have mean 0 and variance 1 at each layer. This stabilizes training and acts as regularization.

During training (using batch statistics):
  z_normalized = (z - batch_mean) / sqrt(batch_var + ε)
  z_scaled = γ·z_normalized + β   [learnable parameters]

During inference (using running statistics):
  z_normalized = (z - running_mean) / sqrt(running_var + ε)
  z_scaled = γ·z_normalized + β

Effect: Reduces internal covariate shift, allows higher learning rates

L2 Weight Decay (Ridge Regularization)

Add penalty term to loss proportional to weight magnitude:

L_total = L_original + λ·Σ(w²)

Gradient update:
  w ← w - α·∂L/∂w - 2αλ·w

Effect: Shrinks weights toward zero, prevents large weights

Regularization Comparison

Technique Mechanism When to Use Hyperparameter
Dropout Random neuron deactivation When overfitting observed; default 0.5 for hidden layers p ∈ [0.2, 0.5]
Batch Norm Normalize activations Always in modern networks (layer-wise) momentum, epsilon
L2 Decay Penalize weight magnitude Most datasets; encourage small weights λ ∈ [1e-5, 1e-3]
Early Stopping Stop when validation loss stops improving Always (prevents overfitting) patience (e.g., 10 epochs)
Regularization Strategy: Combine multiple techniques. Typical setup: Batch Norm in hidden layers + Dropout + L2 weight decay + Early Stopping. This multi-pronged approach is most effective.

Training vs Inference: Different Modes

DNNs operate differently during training and inference. Understanding these differences is critical for production deployments.

Key Differences

Aspect Training Inference
Dropout Active: randomly zero out neurons with probability p Inactive: all neurons active
Batch Norm Statistics Uses batch mean/variance (changing per batch) Uses running mean/variance (fixed, computed during training)
Gradient Computation Compute: yes (backward pass) Compute: no (only forward pass)
Weight Updates Yes (gradient descent) No (weights frozen)
Output Distribution Different batch → slightly different prediction (due to dropout) Deterministic: same input → same output always
Speed Slower (backward pass, weight updates) Faster (forward pass only)

Practical Implications for AdTech Serving

Set Model Mode in Python

Use model.train() for training, model.eval() for inference. PyTorch/TensorFlow frameworks handle mode-specific behavior automatically.

Latency Considerations

Inference should be ~10-20% of training cost. No gradient computation = huge speedup. Batch predictions together to amortize setup cost.

Numerical Stability

Ensure Batch Norm statistics are properly saved and loaded. Using training statistics at inference = worse calibration.

Model Monitoring

Track training loss vs validation loss divergence. If validation loss increases while training loss decreases = overfitting.

Code Pattern

import torch

model = MyDNN()
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)

# TRAINING LOOP
model.train()  # Set to training mode
for epoch in range(100):
    for batch_x, batch_y in train_loader:
        # Forward pass
        predictions = model(batch_x)
        loss = criterion(predictions, batch_y)

        # Backward pass
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

# INFERENCE (serving)
model.eval()  # Set to inference mode
with torch.no_grad():  # Disable gradient computation for speed
    for batch_x in test_loader:
        predictions = model(batch_x)
        # Use predictions (no gradients)
Critical for Production: Always call model.eval() before inference. Forgetting this is a common bug that silently degrades model performance by using stale batch norm statistics or applying dropout.

Python Implementation: Building a DNN from Scratch

Let's build a complete Deep Neural Network using only NumPy and train it on the MNIST digit classification task.

Complete DNN Implementation

import numpy as np
from scipy.special import softmax
import matplotlib.pyplot as plt

class DeepNeuralNetwork:
    """
    A fully-connected deep neural network with configurable architecture.
    Supports ReLU and Sigmoid activations, dropout, and L2 regularization.
    """

    def __init__(self, layer_sizes, activation='relu', dropout_rate=0.2, l2_lambda=0.0001):
        """
        Args:
            layer_sizes: list of ints, e.g. [784, 128, 64, 10]
                         First element is input size, last is output size
            activation: 'relu' or 'sigmoid' for hidden layers
            dropout_rate: probability of dropping a neuron (0 = no dropout)
            l2_lambda: L2 regularization coefficient
        """
        self.layer_sizes = layer_sizes
        self.activation = activation
        self.dropout_rate = dropout_rate
        self.l2_lambda = l2_lambda
        self.training_mode = True

        # Initialize weights and biases
        self.weights = []
        self.biases = []
        for i in range(len(layer_sizes) - 1):
            w = np.random.randn(layer_sizes[i], layer_sizes[i+1]) * 0.01
            b = np.zeros((1, layer_sizes[i+1]))
            self.weights.append(w)
            self.biases.append(b)

    def relu(self, z):
        return np.maximum(0, z)

    def relu_derivative(self, z):
        return (z > 0).astype(float)

    def sigmoid(self, z):
        return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

    def sigmoid_derivative(self, z):
        s = self.sigmoid(z)
        return s * (1 - s)

    def forward(self, X):
        """
        Forward propagation. Returns cache for backprop.
        X shape: (batch_size, input_size)
        """
        self.cache = []
        A = X

        # Hidden layers
        for i in range(len(self.weights) - 1):
            Z = np.dot(A, self.weights[i]) + self.biases[i]
            if self.activation == 'relu':
                A_new = self.relu(Z)
            else:  # sigmoid
                A_new = self.sigmoid(Z)

            # Dropout
            if self.training_mode and self.dropout_rate > 0:
                mask = np.random.binomial(1, 1 - self.dropout_rate, A_new.shape)
                A_new = A_new * mask / (1 - self.dropout_rate)
            else:
                mask = None

            self.cache.append((A, Z, A_new, mask))
            A = A_new

        # Output layer (sigmoid for binary, softmax for multi-class)
        Z_out = np.dot(A, self.weights[-1]) + self.biases[-1]
        A_out = self.sigmoid(Z_out)

        self.cache.append((A, Z_out, A_out, None))
        return A_out

    def backward(self, X, Y, learning_rate):
        """
        Backpropagation. Computes gradients and updates weights.
        """
        m = X.shape[0]
        gradients_w = [np.zeros_like(w) for w in self.weights]
        gradients_b = [np.zeros_like(b) for b in self.biases]

        # Output layer gradient
        A_out = self.cache[-1][2]
        dZ = A_out - Y  # Binary cross-entropy gradient
        dA_prev = self.cache[-2][2]

        gradients_w[-1] = np.dot(dA_prev.T, dZ) / m
        gradients_b[-1] = np.sum(dZ, axis=0, keepdims=True) / m

        # Hidden layers (backpropagate)
        dA = dZ
        for i in range(len(self.weights) - 2, -1, -1):
            dA = np.dot(dA, self.weights[i + 1].T)

            # Apply dropout mask from forward pass
            if self.cache[i][3] is not None:
                dA = dA * self.cache[i][3] / (1 - self.dropout_rate)

            # Activation derivative
            Z = self.cache[i][1]
            if self.activation == 'relu':
                dA = dA * self.relu_derivative(Z)
            else:
                dA = dA * self.sigmoid_derivative(Z)

            A_prev = self.cache[i][0]
            gradients_w[i] = np.dot(A_prev.T, dA) / m
            gradients_b[i] = np.sum(dA, axis=0, keepdims=True) / m

        # Update weights with L2 regularization
        for i in range(len(self.weights)):
            self.weights[i] -= learning_rate * (gradients_w[i] + self.l2_lambda * self.weights[i])
            self.biases[i] -= learning_rate * gradients_b[i]

    def train_mode(self):
        self.training_mode = True

    def eval_mode(self):
        self.training_mode = False

    def predict(self, X):
        return self.forward(X)

    def loss(self, Y_pred, Y_true):
        """Binary cross-entropy loss"""
        m = Y_true.shape[0]
        epsilon = 1e-15
        Y_pred = np.clip(Y_pred, epsilon, 1 - epsilon)
        loss = -np.mean(Y_true * np.log(Y_pred) + (1 - Y_true) * np.log(1 - Y_pred))
        return loss

# ============================================================================
# TRAINING ON MNIST
# ============================================================================

from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

# Load MNIST digits (8x8 images = 64 features)
digits = load_digits()
X = digits.data / 16.0  # Normalize to [0, 1]
y = digits.target

# For simplicity, binary classification: digit 0 vs others
Y = (y == 0).astype(int).reshape(-1, 1)

# Split data
X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.2, random_state=42)

# Standardize
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)

# Create model: 64 inputs -> 128 hidden -> 64 hidden -> 1 output
model = DeepNeuralNetwork(
    layer_sizes=[64, 128, 64, 1],
    activation='relu',
    dropout_rate=0.3,
    l2_lambda=0.0001
)

# Training parameters
epochs = 100
learning_rate = 0.01
batch_size = 32

train_losses = []
val_losses = []

print("Training Deep Neural Network on MNIST (0 vs others)...")
for epoch in range(epochs):
    model.train_mode()

    # Mini-batch training
    indices = np.random.permutation(X_train.shape[0])
    epoch_loss = 0
    num_batches = 0

    for i in range(0, X_train.shape[0], batch_size):
        batch_indices = indices[i:i+batch_size]
        X_batch = X_train[batch_indices]
        Y_batch = Y_train[batch_indices]

        # Forward pass
        Y_pred = model.forward(X_batch)
        loss = model.loss(Y_pred, Y_batch)
        epoch_loss += loss
        num_batches += 1

        # Backward pass
        model.backward(X_batch, Y_batch, learning_rate)

    train_losses.append(epoch_loss / num_batches)

    # Validation
    model.eval_mode()
    Y_val_pred = model.forward(X_test)
    val_loss = model.loss(Y_val_pred, Y_test)
    val_losses.append(val_loss)

    if (epoch + 1) % 10 == 0:
        accuracy = np.mean((Y_val_pred > 0.5) == Y_test)
        print(f"Epoch {epoch+1:3d} | Train Loss: {train_losses[-1]:.4f} | "
              f"Val Loss: {val_loss:.4f} | Accuracy: {accuracy:.4f}")

print("Training complete!")

# ============================================================================
# RESULTS
# ============================================================================

model.eval_mode()
Y_pred = model.forward(X_test)
accuracy = np.mean((Y_pred > 0.5) == Y_test)
print(f"\nFinal Test Accuracy: {accuracy:.4f}")
print(f"Final Train Loss: {train_losses[-1]:.4f}")
print(f"Final Val Loss: {val_losses[-1]:.4f}")

Key Implementation Details

Weight Initialization

Small random values (0.01 scale) prevent saturation. Larger networks may need Xavier/He initialization.

Mini-batch SGD

Process 32 samples per update instead of all 1000. Faster, better generalization.

Dropout in Forward

Only apply dropout when training_mode=True. Scale activations by 1/(1-p) to maintain expected value.

L2 Regularization

Add λw to weight gradient update. Shrinks weights toward zero, preventing overfitting.

Training Curves: What to Look For

Loss Curves Over Time
Good Training (Healthy) Epoch Loss Train Val Both decrease, small gap = good Overfitting (Bad) Epoch Loss Train ↓, Val ↑ = overfitting! Underfitting (Weak) Epoch Loss Both high, little improvement

Next Steps for Production

Production Deployment:
  • Save model weights and architecture to disk
  • Use optimized framework (PyTorch, TensorFlow) for serving
  • Batch predictions to reduce latency (e.g., predict 100 samples at once)
  • Monitor model performance: track accuracy, calibration, prediction distribution drift
  • Retrain periodically as data distribution changes
End of Deep Neural Networks