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
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).
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.
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
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) |
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)
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
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 |
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
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)
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!
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 |
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
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 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) |
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)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
Next Steps for Production
- 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