Skip to main content

Understanding Deep Learning - Simon J. D. Prince

sYnuFW

An authoritative, accessible, and up-to-date treatment of deep learning that strikes a pragmatic middle ground between theory and practice.

About the Author

Simon J. D. Prince is a leading expert in computer vision and machine learning:

  • Professor of Computer Vision:Durham University
  • Researcher:Specializes in deep learning, computer vision, and generative models
  • Author:Previously wrote "Computer Vision: Models, Learning, and Inference"
  • Educator:Known for clear, intuitive explanations of complex topics

Prince is renowned for his ability to balance mathematical rigor with practical intuition, making advanced topics accessible to a broad audience.

Core Content

1. Neural Network Foundations

import numpy as np

# Perceptron: The basic building block
# y = f(w·x + b)

class Perceptron:
def __init__(self, input_size, activation='sigmoid'):
self.weights = np.random.randn(input_size) * 0.01
self.bias = 0

if activation == 'sigmoid':
self.activation = lambda x: 1 / (1 + np.exp(-x))
self.activation_deriv = lambda x: x * (1 - x)
elif activation == 'relu':
self.activation = lambda x: np.maximum(0, x)
self.activation_deriv = lambda x: (x > 0).astype(float)

def forward(self, x):
z = np.dot(x, self.weights) + self.bias
return self.activation(z)

def train_step(self, x, y, lr=0.01):
# Forward pass
pred = self.forward(x)

# Backward pass (gradient descent)
error = pred - y
self.weights -= lr * error * self.activation_deriv(pred) * x
self.bias -= lr * error * self.activation_deriv(pred)

return error ** 2

# Multi-layer Perceptron (MLP)
class MLP:
def __init__(self, layer_sizes):
self.layers = []
for i in range(len(layer_sizes) - 1):
self.layers.append({
'W': np.random.randn(layer_sizes[i], layer_sizes[i+1]) * 0.01,
'b': np.zeros((1, layer_sizes[i+1]))
})

def forward(self, X):
self.activations = [X]
for layer in self.layers:
z = np.dot(self.activations[-1], layer['W']) + layer['b']
a = self.relu(z)
self.activations.append(a)
return self.activations[-1]

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

def relu_deriv(self, x):
return (x > 0).astype(float)

2. Backpropagation

# Backpropagation: Computing gradients efficiently
# Chain rule applied to computational graphs

def backward(self, X, y, output):
m = X.shape[0] # batch size

# Output layer gradient
delta = output - y # derivative of MSE loss

# Backpropagate through layers
for i in reversed(range(len(self.layers))):
layer = self.layers[i]

# Gradients for this layer
dW = np.dot(self.activations[i].T, delta) / m
db = np.sum(delta, axis=0, keepdims=True) / m

# Gradient for previous layer
if i > 0:
delta = np.dot(delta, layer['W'].T) * self.relu_deriv(self.activations[i])

# Update weights
layer['W'] -= 0.01 * dW
layer['b'] -= 0.01 * db

# Computational graph perspective
# Each operation is a node
# Gradients flow backward through the graph

# Example: y = ReLU(Wx + b)
# Forward: x → Wx → Wx+b → ReLU → y
# Backward: ∂L/∂y → ∂L/∂ReLU → ∂L/∂(Wx+b) → ∂L/∂W, ∂L/∂b

3. Convolutional Neural Networks (CNNs)

import tensorflow as tf
from tensorflow import keras

# Convolution: Sliding filter over input
# Key concepts:
# - Kernel/Filter: Learnable weights
# - Stride: Step size
# - Padding: 'valid' or 'same'

# 2D Convolution layer
conv_layer = keras.layers.Conv2D(
filters=32, # Number of output channels
kernel_size=3, # 3x3 filter
strides=1, # Step size
padding='same', # Output same size as input
activation='relu'
)

# Pooling: Downsampling
# - Max pooling: Take maximum in each region
# - Average pooling: Take average

max_pool = keras.layers.MaxPooling2D(
pool_size=2,
strides=2
)

# Typical CNN architecture
cnn = keras.Sequential([
# Conv block 1
keras.layers.Conv2D(32, 3, activation='relu', padding='same', input_shape=(32, 32, 3)),
keras.layers.Conv2D(32, 3, activation='relu', padding='same'),
keras.layers.MaxPooling2D(),
keras.layers.Dropout(0.25),

# Conv block 2
keras.layers.Conv2D(64, 3, activation='relu', padding='same'),
keras.layers.Conv2D(64, 3, activation='relu', padding='same'),
keras.layers.MaxPooling2D(),
keras.layers.Dropout(0.25),

# Fully connected
keras.layers.Flatten(),
keras.layers.Dense(512, activation='relu'),
keras.layers.Dropout(0.5),
keras.layers.Dense(10, activation='softmax')
])

# Why CNNs work for images:
# 1. Local connectivity: Exploits spatial structure
# 2. Parameter sharing: Same filter across image
# 3. Translation equivariance: Features detected anywhere

4. Sequence Models (RNN, LSTM, GRU)

# RNN: Processing sequences
# h_t = tanh(W_hh * h_{t-1} + W_xh * x_t + b)

# Simple RNN
rnn = keras.Sequential([
keras.layers.SimpleRNN(
units=64,
return_sequences=True, # Output full sequence
input_shape=(None, 128) # (timesteps, features)
),
keras.layers.Dropout(0.2),
keras.layers.SimpleRNN(32),
keras.layers.Dense(10, activation='softmax')
])

# LSTM: Long Short-Term Memory
# Solves vanishing gradient problem
# Gates: Input, Forget, Output

lstm = keras.Sequential([
keras.layers.Embedding(input_dim=10000, output_dim=128),
keras.layers.LSTM(
units=64,
return_sequences=True,
dropout=0.2,
recurrent_dropout=0.2
),
keras.layers.LSTM(32),
keras.layers.Dense(1, activation='sigmoid') # Binary classification
])

# GRU: Gated Recurrent Unit
# Simplified LSTM with fewer parameters

gru = keras.Sequential([
keras.layers.GRU(64, return_sequences=True),
keras.layers.GRU(32),
keras.layers.Dense(10, activation='softmax')
])

# Applications:
# - Language modeling
# - Machine translation
# - Speech recognition
# - Time series prediction

5. Attention and Transformers

import tensorflow as tf

# Scaled Dot-Product Attention
def scaled_dot_product_attention(Q, K, V, mask=None):
"""
Q, K, V: (batch_size, seq_len, d_k)
"""
d_k = tf.cast(tf.shape(K)[-1], tf.float32)

# Attention scores
scores = tf.matmul(Q, K, transpose_b=True) # (batch, seq_len, seq_len)
scores /= tf.math.sqrt(d_k)

# Apply mask (for decoder)
if mask is not None:
scores += (mask * -1e9)

# Softmax and weighted sum
attention_weights = tf.nn.softmax(scores, axis=-1)
output = tf.matmul(attention_weights, V)

return output, attention_weights

# Multi-Head Attention
class MultiHeadAttention(tf.keras.layers.Layer):
def __init__(self, d_model, num_heads):
super().__init__()
self.num_heads = num_heads
self.d_model = d_model

assert d_model % num_heads == 0
self.depth = d_model // num_heads

self.wq = tf.keras.layers.Dense(d_model)
self.wk = tf.keras.layers.Dense(d_model)
self.wv = tf.keras.layers.Dense(d_model)
self.dense = tf.keras.layers.Dense(d_model)

def split_heads(self, x, batch_size):
x = tf.reshape(x, (batch_size, -1, self.num_heads, self.depth))
return tf.transpose(x, perm=[0, 2, 1, 3])

def call(self, Q, K, V, mask=None):
batch_size = tf.shape(Q)[0]

q = self.split_heads(self.wq(Q), batch_size)
k = self.split_heads(self.wk(K), batch_size)
v = self.split_heads(self.wv(V), batch_size)

scaled_attention, _ = scaled_dot_product_attention(q, k, v, mask)

scaled_attention = tf.transpose(scaled_attention, perm=[0, 2, 1, 3])
concat_attention = tf.reshape(scaled_attention, (batch_size, -1, self.d_model))

return self.dense(concat_attention)

# Transformer Encoder Layer
class EncoderLayer(tf.keras.layers.Layer):
def __init__(self, d_model, num_heads, dff, dropout_rate=0.1):
super().__init__()
self.mha = MultiHeadAttention(d_model, num_heads)
self.ffn = tf.keras.Sequential([
tf.keras.layers.Dense(dff, activation='relu'),
tf.keras.layers.Dense(d_model)
])
self.layernorm1 = tf.keras.layers.LayerNormalization(epsilon=1e-6)
self.layernorm2 = tf.keras.layers.LayerNormalization(epsilon=1e-6)
self.dropout = tf.keras.layers.Dropout(dropout_rate)

def call(self, x, training=False, mask=None):
attn_output = self.mha(x, x, x, mask)
attn_output = self.dropout(attn_output, training=training)
out1 = self.layernorm1(x + attn_output)

ffn_output = self.ffn(out1)
ffn_output = self.dropout(ffn_output, training=training)
out2 = self.layernorm2(out1 + ffn_output)

return out2

6. Generative Models

# Autoencoders: Unsupervised learning
# Encoder: Compress input to latent space
# Decoder: Reconstruct from latent space

autoencoder = keras.Sequential([
# Encoder
keras.layers.Dense(128, activation='relu', input_shape=(784,)),
keras.layers.Dense(64, activation='relu'),
keras.layers.Dense(32, activation='relu'), # Bottleneck

# Decoder
keras.layers.Dense(64, activation='relu'),
keras.layers.Dense(128, activation='relu'),
keras.layers.Dense(784, activation='sigmoid')
])

autoencoder.compile(optimizer='adam', loss='mse')

# Variational Autoencoder (VAE)
# Encoder outputs distribution parameters (μ, σ)
# Sample from distribution and decode

class VAE(keras.Model):
def __init__(self, latent_dim):
super().__init__()
self.latent_dim = latent_dim

self.encoder = keras.Sequential([
keras.layers.Dense(128, activation='relu'),
keras.layers.Dense(64, activation='relu'),
keras.layers.Dense(latent_dim * 2) # μ and log(σ²)
])

self.decoder = keras.Sequential([
keras.layers.Dense(64, activation='relu'),
keras.layers.Dense(128, activation='relu'),
keras.layers.Dense(784, activation='sigmoid')
])

def encode(self, x):
h = self.encoder(x)
mu, log_var = tf.split(h, 2, axis=-1)
return mu, log_var

def reparameterize(self, mu, log_var):
eps = tf.random.normal(shape=tf.shape(mu))
std = tf.exp(0.5 * log_var)
return mu + eps * std

def decode(self, z):
return self.decoder(z)

def call(self, x):
mu, log_var = self.encode(x)
z = self.reparameterize(mu, log_var)
return self.decode(z)

# Generative Adversarial Networks (GANs)
# Generator: Create fake samples
# Discriminator: Distinguish real from fake

generator = keras.Sequential([
keras.layers.Dense(7*7*256, use_bias=False, input_shape=(100,)),
keras.layers.BatchNormalization(),
keras.layers.LeakyReLU(),
keras.layers.Reshape((7, 7, 256)),

keras.layers.Conv2DTranspose(128, 5, strides=1, padding='same', use_bias=False),
keras.layers.BatchNormalization(),
keras.layers.LeakyReLU(),

keras.layers.Conv2DTranspose(64, 5, strides=2, padding='same', use_bias=False),
keras.layers.BatchNormalization(),
keras.layers.LeakyReLU(),

keras.layers.Conv2DTranspose(1, 5, strides=2, padding='same', use_bias=False, activation='tanh')
])

discriminator = keras.Sequential([
keras.layers.Conv2D(64, 5, strides=2, padding='same', input_shape=(28, 28, 1)),
keras.layers.LeakyReLU(),
keras.layers.Dropout(0.3),

keras.layers.Conv2D(128, 5, strides=2, padding='same'),
keras.layers.LeakyReLU(),
keras.layers.Dropout(0.3),

keras.layers.Flatten(),
keras.layers.Dense(1, activation='sigmoid') # Real or fake
])

7. Training Deep Networks

# Optimization algorithms

# SGD with Momentum
optimizer = keras.optimizers.SGD(
learning_rate=0.01,
momentum=0.9,
nesterov=True
)

# Adam: Adaptive learning rates
optimizer = keras.optimizers.Adam(
learning_rate=0.001,
beta_1=0.9,
beta_2=0.999,
epsilon=1e-7
)

# Learning rate scheduling
lr_schedule = keras.optimizers.schedules.CosineDecay(
initial_decay_rate=0.1,
decay_steps=10000
)

optimizer = keras.optimizers.Adam(learning_rate=lr_schedule)

# Regularization techniques

# 1. Dropout
keras.layers.Dropout(rate=0.5)

# 2. Batch Normalization
keras.layers.BatchNormalization()

# 3. Weight Decay (L2 regularization)
keras.regularizers.l2(1e-4)

# 4. Data Augmentation
data_augmentation = keras.Sequential([
keras.layers.RandomFlip('horizontal'),
keras.layers.RandomRotation(0.1),
keras.layers.RandomZoom(0.1),
])

# Callbacks
callbacks = [
keras.callbacks.EarlyStopping(
monitor='val_loss',
patience=10,
restore_best_weights=True
),
keras.callbacks.ModelCheckpoint(
'best_model.h5',
monitor='val_loss',
save_best_only=True
),
keras.callbacks.ReduceLROnPlateau(
monitor='val_loss',
factor=0.5,
patience=5,
min_lr=1e-7
)
]

8. Transfer Learning

# Using pre-trained models

# 1. Feature Extraction (freeze base model)
base_model = keras.applications.ResNet50(
include_top=False,
weights='imagenet',
input_shape=(224, 224, 3),
pooling='avg'
)
base_model.trainable = False

model = keras.Sequential([
base_model,
keras.layers.Dense(256, activation='relu'),
keras.layers.Dropout(0.5),
keras.layers.Dense(num_classes, activation='softmax')
])

# 2. Fine-tuning (unfreeze some layers)
base_model.trainable = True

# Freeze early layers, unfreeze later layers
for layer in base_model.layers[:100]:
layer.trainable = False

model.compile(
optimizer=keras.optimizers.Adam(learning_rate=1e-5), # Lower LR for fine-tuning
loss='categorical_crossentropy',
metrics=['accuracy']
)

# Popular pre-trained models
# - ResNet: Deep residual networks
# - EfficientNet: Scalable architecture
# - ViT (Vision Transformer): Pure attention for images
# - BERT: Pre-trained transformer for NLP
# - GPT: Generative pre-trained transformer

Key Quotes

Deep learning is not magic; it's just linear algebra, calculus, and a lot of data.

The key to understanding neural networks is understanding the flow of gradients.

Convolution is the key to efficient processing of structured data like images.

Attention mechanisms allow models to focus on the most relevant parts of the input.

Transfer learning is the closest thing we have to free lunch in deep learning.

Reading Notes

"Understanding Deep Learning" is a comprehensive and modern treatment of deep learning. Simon Prince strikes an excellent balance between mathematical rigor and practical intuition.

What I found most valuable is the clear visual explanations. Complex concepts like backpropagation, attention mechanisms, and generative models are explained with intuitive diagrams that make the mathematics more accessible.

The coverage of modern architectures is excellent. From CNNs to Transformers, from VAEs to GANs, the book covers the essential architectures that power today's AI applications.

The practical training techniques chapter is invaluable. Understanding optimization algorithms, regularization methods, and debugging strategies is essential for successfully training deep networks.

For anyone looking to understand deep learning beyond just using high-level APIs, this book provides the depth needed to truly comprehend how and why these models work.

Highly recommended for:

  • Students wanting a thorough introduction
  • Practitioners seeking deeper understanding
  • Researchers looking for a solid reference