Training Artificial Neural Networks (Part 2)

CSI 4106 - Fall 2026

Marcel Turcotte

Version: Oct 17, 2026 16:03

Preamble

Message of the Day

Learning objectives

  • Backpropagation Algorithm:
    • Explain error signals and the chain rule along a path and at branches.
    • Connect scalar derivatives to the Python and NumPy implementations.
    • Distinguish computing gradients from updating parameters.
  • Implementation:
    • Explain a sigmoid MLP with cached forward values and mini-batch backpropagation.

Notation

Units, layers, and activation functions

Symbol Meaning
\(z^{(l)}\) Preactivations: weighted sums plus biases in layer \(l\)
\(a^{(l)}\) Activations: the layer’s outputs; denoted \(o\) for units in Lecture 11
\(\varphi\) A general activation function, applied elementwise
\(\sigma\) The logistic sigmoid activation
\(W^{(l)}, b^{(l)}\) The layer’s weights and biases
\(f_l\) The complete layer transformation: affine computation followed by activation

Probabilities, labels, and loss

For binary classification, as in Lectures 6 and 12:

  • \(\hat p\) is the predicted probability of class 1.
  • \(\hat y=\mathbf{1}[\hat p\geq 0.5]\) is the predicted class label.
  • \(\ell\) is the loss for one example; \(J\) is the mean loss.

\[ \ell(y,\hat p)=-\left[y\log\hat p+(1-y)\log(1-\hat p)\right] \]

\[ \qquad J(\theta)=\frac{1}{N}\sum_{i=1}^{N}\ell(y_i,\hat p_i). \]

Notation: a two-layer network

One hidden layer and one output layer:

\[ a^{(2)}=f_2(f_1(x)) \]

Each layer computes

\[ f_l(a)=\varphi(aW^{(l)}+b^{(l)}). \]

Notation: a three-layer network

Two hidden layers and one output layer:

\[ a^{(3)}=f_3(f_2(f_1(x))) \]

where

\[ f_l(a)=\varphi(aW^{(l)}+b^{(l)}). \]

Notation: an \(L\)-layer network

\[ a^{(L)}=f_L(\ldots f_2(f_1(x))\ldots). \]

For \(l=1,\ldots,L\):

\[ z^{(l)}=a^{(l-1)}W^{(l)}+b^{(l)}, \qquad a^{(l)}=\varphi(z^{(l)}). \]

The output of one layer becomes the input to the next.

Back-propagation

Back-propagation

Learning representations by back-propagating errors

David E. Rumelhart, Geoffrey E. Hinton & Ronald J. Williams

We describe a new learning procedure, back-propagation, for networks of neurone-like units. The procedure repeatedly adjusts the weights of the connections in the network so as to minimize a measure of the difference between the actual output vector of the net and the desired output vector. As a result of the weight adjustments, internal ‘hidden’ units which are not part of the input or output come to represent important features of the task domain, and the regularities in the task are captured by the interactions of these units. The ability to create useful new features distinguishes back-propagation from earlier, simpler methods such as the perceptron-convergence procedure.

Representation and learning

  • A single-layer perceptron can separate only linearly separable classes.

  • A multilayer network can represent XOR, as demonstrated in Lecture 11.

  • Learning its hidden-layer parameters requires solving the credit assignment problem: how does each parameter affect the final loss?

Backpropagation in the 1986 paper

  • The paper used a squared-error objective and sigmoid units.

  • A forward pass computes activations; a backward pass computes parameter gradients.

  • Gradient descent uses those gradients to update the parameters.

  • Hidden units can learn useful internal representations.

Conceptual Idea

Conceptual Idea (continued)

Conceptual Idea (continued)

Backpropagation

  • Backpropagation is an algorithm for methodically computing the partial derivatives of a neural network’s loss function with respect to each weight and bias parameter.

  • Backpropagation applies the chain rule of calculus recursively to compute both \(\frac{\partial J}{\partial w_{i,j}^{(l)}}\) and \(\frac{\partial J}{\partial b_j^{(l)}}\) for every layer \(l\), efficiently reusing intermediate quantities from the forward pass.

Sigmoid and its derivative

\[ \sigma(z)=\frac{1}{1+e^{-z}},\qquad \sigma'(z)=\sigma(z)(1-\sigma(z)). \]

Show code
import numpy as np
import matplotlib.pyplot as plt
from scipy.special import expit

z_values = np.linspace(-6, 6, 300)
a_values = expit(z_values)
fig, axes = plt.subplots(1, 2, figsize=(10, 3))
axes[0].plot(z_values, a_values)
axes[0].set(title="Sigmoid", xlabel="z", ylabel="activation")
axes[1].plot(z_values, a_values * (1 - a_values))
axes[1].set(title="Sigmoid derivative", xlabel="z", ylabel="local slope")
for ax in axes:
    ax.grid(True)
plt.tight_layout()
plt.show()

Chain rule

Given,

\[ h(x) = f(g(x)) \]

using the Lagrange notation, we have

\[ h^\prime(x) = f^\prime(g(x)) g^\prime(x) \]

or equivalently using Leibniz notation

\[ \frac{dv}{dx} = \frac{dv}{du} \cdot \frac{du}{dx}, \qquad u=g(x),\quad v=f(u). \]

Applying the chain rule recursively

Computational graph

Scalar input; one hidden node

Let

\[ \ell = -\Bigl[y \,\log(\hat p) + (1-y)\,\log(1-\hat p)\Bigr] \]

\[ \hat p = a_2 = \sigma(z_2), \quad z_2 = w_2 \cdot a_1 + b_2 \]

\[ a_1 = \sigma(z_1), \quad z_1 = w_1 \cdot x + b_1 \]

Derivatives

\[ \frac{\partial \ell}{\partial \hat p} \]

\[ \frac{\partial \hat p}{\partial z_2} \]

\[ \frac{\partial z_2}{\partial w_2}, \quad \frac{\partial z_2}{\partial b_2}, \quad \frac{\partial z_2}{\partial a_1}, \]

\[ \frac{\partial a_1}{\partial z_1}, \]

\[ \frac{\partial z_1}{\partial w_1}, \quad \frac{\partial z_1}{\partial b_1}, \quad \frac{\partial z_1}{\partial x}, \]

Derivatives

Loss derivative w.r.t. \(\hat p\):

\[ \frac{\partial \ell}{\partial \hat p} = -\left(\frac{y}{\hat p}-\frac{1-y}{1-\hat p}\right) \]

Derivatives

\(\hat p\) derivative w.r.t. \(z_2\):

\[ \frac{\partial \hat p}{\partial z_2}=\sigma^{\prime}\left(z_2\right)=\hat p(1-\hat p) \]

Derivatives

Derivative \(z_2 = w_2 a_1 + b_2\):

\[ \frac{\partial z_2}{\partial w_2}=a_1, \quad \frac{\partial z_2}{\partial b_2}=1, \quad \frac{\partial z_2}{\partial a_1}=w_2 \]

Derivatives

Derivative \(a_1 = \sigma(z_1)\):

\[ \frac{\partial a_1}{\partial z_1}=\sigma^{\prime}\left(z_1\right)=a_1\left(1-a_1\right) \]

Derivatives

Derivative \(z_1 = w_1 x + b_1\):

\[ \frac{\partial z_1}{\partial w_1}=x, \quad \frac{\partial z_1}{\partial b_1}=1, \quad \frac{\partial z_1}{\partial x} = w_1 \]

Combined derivatives

For \(w_2\):

\[ \frac{\partial \ell}{\partial w_2}=\frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial w_2}=\left[-\left(\frac{y}{\hat p}-\frac{1-y}{1-\hat p}\right)\right] \cdot(\hat p(1-\hat p)) \cdot a_1 \]

Simplifies to:

\[ \frac{\partial \ell}{\partial w_2}=(\hat p-y) a_1 \]

Combined derivatives

For \(b_2\):

\[ \frac{\partial \ell}{\partial b_2}=\frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial b_2}=(\hat p-y) \cdot 1=\hat p-y \]

Combined derivatives

For \(w_1\):

\[ \frac{\partial \ell}{\partial w_1}=\frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \cdot \frac{\partial z_1}{\partial w_1} \]

Plug in:

\[ =\left[-\left(\frac{y}{\hat p}-\frac{1-y}{1-\hat p}\right)\right] \cdot(\hat p(1-\hat p)) \cdot w_2 \cdot\left(a_1\left(1-a_1\right)\right) \cdot x \]

Simplifies to:

\[ \frac{\partial \ell}{\partial w_1}=(\hat p-y) w_2\left(a_1\left(1-a_1\right)\right) x \]

Combined derivatives

For \(b_1\):

\[ \frac{\partial \ell}{\partial b_1}=\frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \cdot \frac{\partial z_1}{\partial b_1} \]

Plug in:

\[ =(\hat p-y) w_2\left(a_1\left(1-a_1\right)\right) \cdot 1 \]

Simplifies to:

\[ \frac{\partial \ell}{\partial b_1}=(\hat p-y) w_2\left(a_1\left(1-a_1\right)\right) \]

Key derivatives

\[ \begin{align} \frac{\partial \ell}{\partial w_2} & = (\hat p-y) a_1 \\ \frac{\partial \ell}{\partial b_2} & = \hat p-y \\ \frac{\partial \ell}{\partial w_1} & = (\hat p-y) w_2\left(a_1\left(1-a_1\right)\right) x \\ \frac{\partial \ell}{\partial b_1} & = (\hat p-y) w_2\left(a_1\left(1-a_1\right)\right) \\ \end{align} \]

Exploration

import math
import random

random.seed(42)

def sigma(x):
    return 1 / (1 + math.exp(-x))

alpha = 0.1

def init():

    global w1, w2, b1, b2

    w1 = random.random()
    w2 = random.random()
    b1 = 0
    b2 = 0

Forward

def forward():

    global z1, a1, z2, a2, loss, p_hat

    z1 = w1 * x + b1
    a1 = sigma(z1)

    z2 = w2 * a1 + b2
    a2 = sigma(z2)

    p_hat = a2

    loss = -(y * math.log(p_hat) + (1-y) * math.log(1 - p_hat))

Backward

def backward():

    grad_loss_w2 = (p_hat - y) * a1
    grad_loss_b2 = p_hat - y

    grad_loss_w1 = (p_hat - y) * w2 * (a1 * (1-a1)) * x
    grad_loss_b1 = (p_hat - y) * w2 * (a1 * (1-a1))

    return grad_loss_w1, grad_loss_b1, grad_loss_w2, grad_loss_b2

Gradient step

def step(gradients):

    global w1, b1, w2, b2

    grad_loss_w1, grad_loss_b1, grad_loss_w2, grad_loss_b2 = gradients

    w1 = w1 - alpha * grad_loss_w1
    b1 = b1 - alpha * grad_loss_b1
    w2 = w2 - alpha * grad_loss_w2
    b2 = b2 - alpha * grad_loss_b2

Training

init()

x = 3.14
y = 1

forward()
print(f"Before: p_hat = {p_hat:.2}, loss = {loss:.2}")

for i in range(500):
    forward()
    gradients = backward()
    step(gradients)

forward()
print(f"After: p_hat = {p_hat:.2}, loss = {loss:.2}")
Before: p_hat = 0.51, loss = 0.68
After: p_hat = 0.99, loss = 0.01

Key derivatives

\[ \begin{align} \frac{\partial \ell}{\partial w_2} & = \frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial w_2}\\ \frac{\partial \ell}{\partial b_2} & = \frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial b_2}\\ \frac{\partial \ell}{\partial w_1} & = \frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \cdot \frac{\partial z_1}{\partial w_1}\\ \frac{\partial \ell}{\partial b_1} & = \frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \cdot \frac{\partial z_2}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \cdot \frac{\partial z_1}{\partial b_1}\\ \end{align} \]

Error signals

Let

\[ \begin{align} \delta_2 & = \frac{\partial \ell}{\partial \hat p} \cdot \frac{\partial \hat p}{\partial z_2} \\ \delta_1 & = \delta_2 \cdot \frac{\partial z_2}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} \end{align} \]

Rewrite

\[ \begin{align} \frac{\partial \ell}{\partial w_2} & = \delta_2 \cdot \frac{\partial z_2}{\partial w_2}\\ \frac{\partial \ell}{\partial b_2} & = \delta_2 \cdot \frac{\partial z_2}{\partial b_2}\\ \frac{\partial \ell}{\partial w_1} & = \delta_1 \cdot \frac{\partial z_1}{\partial w_1}\\ \frac{\partial \ell}{\partial b_1} & = \delta_1 \cdot \frac{\partial z_1}{\partial b_1}\\ \end{align} \]

Backward with error signals

def backward_with_signals():

    delta2 = p_hat - y
    delta1 = delta2 * w2 * a1 * (1 - a1)

    grad_loss_w2 = delta2 * a1
    grad_loss_b2 = delta2
    grad_loss_w1 = delta1 * x
    grad_loss_b1 = delta1

    return grad_loss_w1, grad_loss_b1, grad_loss_w2, grad_loss_b2

When an activation feeds two units

\[ z_u=ra+b_u,\qquad u=\sigma(z_u) \]

\[ z_v=sa+b_v,\qquad v=\sigma(z_v) \]

\[ \hat p=\sigma(tu+qv+b_{\mathrm{out}}) \]

With \(\delta_u=\partial\ell/\partial z_u\) and \(\delta_v=\partial\ell/\partial z_v\):

\[ \frac{\partial\ell}{\partial a} =\underbrace{r\delta_u}_{\text{through }u} +\underbrace{s\delta_v}_{\text{through }v}. \]

Branching: Python

a = sigma(0.0)
r, s = 2.0, -1.0
t, q = 1.0, 1.0
u = sigma(r * a - 1.0)
v = sigma(s * a + 0.5)
p_hat = sigma(t * u + q * v - 1.0)
y = 1

delta_out = p_hat - y
delta_u = delta_out * t * u * (1 - u)
delta_v = delta_out * q * v * (1 - v)

through_u = r * delta_u
through_v = s * delta_v
delta_a = (through_u + through_v) * a * (1 - a)

print(f"Contributions: {through_u:.3f} + {through_v:.3f}")
print(f"d_loss/da: {through_u + through_v:.3f}")
print(f"delta_a: {delta_a:.5f}")
Contributions: -0.250 + 0.125
d_loss/da: -0.125
delta_a: -0.03125

Training with backpropagation

  1. Initialize the weights and biases.
  2. Forward pass: compute activations and retain intermediate values.
  3. Compute the loss for the current batch.
  4. Backward pass: compute all parameter gradients.
  5. Gradient step: update the parameters using those gradients.
  6. Repeat for the remaining batches and epochs.

Backpropagation on a computational graph

  • Start at the loss: its derivative with respect to itself is \(\frac{\partial\ell}{\partial\ell}=1\).
  • Visit operations in reverse computation order.
  • For each input \(u\), sum contributions from all downstream uses:

\[ \frac{\partial\ell}{\partial u} =\sum_{z\text{ directly uses }u} \frac{\partial z}{\partial u}\frac{\partial\ell}{\partial z}. \]

Initialization

  • Initialize weights randomly to break symmetry between units.
  • Zero biases are a common choice.
  • Scale the weights appropriately for the layer and activation function.

Forward pass and loss

  • Apply each layer’s affine transformation and activation.
  • Retain the preactivations and activations needed by the backward pass.
  • For binary classification, the sigmoid output is the probability \(\hat p\).
  • Compute the mean binary cross-entropy over the current batch.

Backward pass and parameter update

  1. Compute the output layer’s error signal.
  2. Move backwards through hidden layers: weighted sum of downstream signals, then activation derivative.
  3. Use each layer’s activations and error signals to compute its parameter gradients.
  4. After all gradients are available, update the parameters:

\[ \theta\leftarrow\theta-\alpha\nabla_\theta J_B. \]

Implementation

Backpropagation - Forward Pass

  • Compute activations layer by layer:

    \(z^{(l)} = a^{(l-1)} W^{(l)} + b^{(l)}\),

    \(a^{(l)} = \varphi(z^{(l)})\).

  • For binary classification, the output is a probability \(\hat p\); compute the single-example loss \(\ell(y,\hat p)\).

Backpropagation - Backward Pass

  • Output layer, for one example with sigmoid output and binary cross-entropy:

\[ \delta^{(L)}=\hat p-y. \]

  • Hidden layers, in reverse order \(l=L-1,\ldots,1\):

\[ \delta^{(l)} = (\delta^{(l+1)}(W^{(l+1)})^\top) \odot \varphi'(z^{(l)}) \]

From one example to a batch

Stack each example’s activations and error signals as rows of \(A^{(l)}\) and \(\Delta^{(l)}\).

For a batch of \(B\) examples:

\[ \frac{\partial J_B}{\partial W^{(l)}} =\frac{1}{B}(A^{(l-1)})^\top\Delta^{(l)}, \qquad \frac{\partial J_B}{\partial b^{(l)}} =\frac{1}{B}\sum_{i=1}^{B}\delta_i^{(l)}. \]

grad_W = activations_prev.T @ delta / batch_size
grad_b = delta.mean(axis=0)

SimplerMLP

Sigmoid units, binary cross-entropy, and a constant learning rate.

Show code
import numpy as np

def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-z))

def sigmoid_prime(z):
    a = sigmoid(z)
    return a * (1.0 - a)

def bce_loss(y_true, y_prob, eps=1e-9):
    """Binary cross-entropy averaged over examples."""
    p = np.clip(y_prob, eps, 1 - eps)
    return -np.mean(y_true * np.log(p) + (1 - y_true) * np.log(1 - p))

def xavier_init(rng, fan_in, fan_out):
    std = np.sqrt(2.0 / (fan_in + fan_out))
    return rng.normal(0.0, std, size=(fan_in, fan_out))

class SimplerMLP:
    """Sigmoid MLP for binary classification; constant learning rate."""

    def __init__(self, layer_sizes, lr=0.1, seed=None):
        self.sizes = list(layer_sizes)
        self.lr = float(lr)
        self.rng = np.random.default_rng(seed)

        self.W = []
        for din, dout in zip(self.sizes[:-1], self.sizes[1:]):
            Wk = xavier_init(self.rng, din, dout)
            self.W.append(Wk)
        self.b = [np.zeros(dout) for dout in self.sizes[1:]]

    def forward(self, X):
        a = X
        for W, b in zip(self.W, self.b):
            a = sigmoid(a @ W + b)
        return a.ravel()

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

    def predict(self, X, threshold=0.5):
        return (self.predict_proba(X) >= threshold).astype(int)

    def loss(self, X, y):
        p = self.predict_proba(X)
        return bce_loss(y, p)

    def _forward_full(self, X):
        a = X
        activations = [a]
        zs = []
        for W, b in zip(self.W, self.b):
            z = a @ W + b
            a = sigmoid(z)
            zs.append(z)
            activations.append(a)
        return activations, zs

    def train(self, X, y, epochs=30, batch_size=None, verbose=True):
        """X: (N, d), y: (N,); batch_size=None uses the full dataset."""
        N = X.shape[0]
        idx = np.arange(N)
        B = N if batch_size is None else int(batch_size)

        for ep in range(1, epochs + 1):
            self.rng.shuffle(idx)

            base_loss = self.loss(X, y)

            for start in range(0, N, B):
                sl = idx[start:start + B]
                Xb = X[sl]
                yb = y[sl].reshape(-1, 1)
                Bsz = Xb.shape[0]

                activations, zs = self._forward_full(Xb)
                A_L = activations[-1]

                # Each row is a single example's preactivation sensitivity.
                delta = A_L - yb
                grads_W = [None] * len(self.W)
                grads_b = [None] * len(self.b)

                # Average parameter gradients over examples exactly once.
                grads_W[-1] = activations[-2].T @ delta / Bsz
                grads_b[-1] = delta.mean(axis=0)

                for k in range(len(self.W) - 2, -1, -1):
                    z = zs[k]
                    d_act = sigmoid_prime(z)
                    delta = (delta @ self.W[k + 1].T) * d_act
                    grads_W[k] = activations[k].T @ delta / Bsz
                    grads_b[k] = delta.mean(axis=0)

                # All gradients use the weights from the same forward pass.
                for k in range(len(self.W)):
                    self.W[k] -= self.lr * grads_W[k]
                    self.b[k] -= self.lr * grads_b[k]

            new_loss = self.loss(X, y)
            if verbose:
                print(f"Epoch {ep:3d} | loss {base_loss:.5f} -> {new_loss:.5f} | lr={self.lr:.4f}")

Activation and derivative

def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-z))

def sigmoid_prime(z):
    a = sigmoid(z)
    return a * (1.0 - a)

Loss

def bce_loss(y_true, y_prob, eps=1e-9):
    """Binary cross-entropy averaged over examples."""
    p = np.clip(y_prob, eps, 1 - eps)
    return -np.mean(y_true * np.log(p) + (1 - y_true) * np.log(1 - p))

Initializing parameters

def xavier_init(rng, fan_in, fan_out):
    std = np.sqrt(2.0 / (fan_in + fan_out))
    return rng.normal(0.0, std, size=(fan_in, fan_out))

Class definition + constructor

class SimplerMLP:

    def __init__(self, layer_sizes, lr=0.1, seed=None):
        self.sizes = list(layer_sizes)
        self.lr = float(lr)
        self.rng = np.random.default_rng(seed)

        self.W = []
        for din, dout in zip(self.sizes[:-1], self.sizes[1:]):
            Wk = xavier_init(self.rng, din, dout)
            self.W.append(Wk)
        self.b = [np.zeros(dout) for dout in self.sizes[1:]]

Forward (public)

    def forward(self, X):
        a = X
        for W, b in zip(self.W, self.b):
            a = sigmoid(a @ W + b)
        return a.ravel()

Forward (private)

    def _forward_full(self, X):
        a = X
        activations = [a]
        zs = []
        for W, b in zip(self.W, self.b):
            z = a @ W + b
            a = sigmoid(z)
            zs.append(z)
            activations.append(a)
        return activations, zs

Training

    def train(self, X, y, epochs=30, batch_size=None, verbose=True):
        """X: (N, d), y: (N,); batch_size=None uses the full dataset."""
        N = X.shape[0]
        idx = np.arange(N)
        B = N if batch_size is None else int(batch_size)

        for ep in range(1, epochs + 1):
            self.rng.shuffle(idx)

            base_loss = self.loss(X, y)

            for start in range(0, N, B):
                sl = idx[start:start + B]
                Xb = X[sl]
                yb = y[sl].reshape(-1, 1)
                Bsz = Xb.shape[0]

                activations, zs = self._forward_full(Xb)
                A_L = activations[-1]

                # Each row is a single example's preactivation sensitivity.
                delta = A_L - yb
                grads_W = [None] * len(self.W)
                grads_b = [None] * len(self.b)

                # Average parameter gradients over examples exactly once.
                grads_W[-1] = activations[-2].T @ delta / Bsz
                grads_b[-1] = delta.mean(axis=0)

                for k in range(len(self.W) - 2, -1, -1):
                    z = zs[k]
                    d_act = sigmoid_prime(z)
                    delta = (delta @ self.W[k + 1].T) * d_act
                    grads_W[k] = activations[k].T @ delta / Bsz
                    grads_b[k] = delta.mean(axis=0)

                # All gradients use the weights from the same forward pass.
                for k in range(len(self.W)):
                    self.W[k] -= self.lr * grads_W[k]
                    self.b[k] -= self.lr * grads_b[k]

            new_loss = self.loss(X, y)
            if verbose:
                print(f"Epoch {ep:3d} | loss {base_loss:.5f} -> {new_loss:.5f} | lr={self.lr:.4f}")

Testing

from sklearn.datasets import make_circles
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score

X, y = make_circles(n_samples=200, factor=0.5, noise=0.08, random_state=1)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=0, stratify=y)

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

model = SimplerMLP([2, 4, 4, 1], lr=0.3, seed=42)
model.train(X_train, y_train, epochs=2000, batch_size=32, verbose=False)

print("Train acc:", accuracy_score(y_train, model.predict(X_train)))
print(" Test acc:", accuracy_score(y_test, model.predict(X_test)))
Train acc: 1.0
 Test acc: 1.0

Automatic differentiation

Automatic differentiation (autodiff) systematically applies the chain rule to compute exact derivatives of functions expressed as computer programs. It propagates derivatives through elementary operations, either forward (from inputs to outputs) or backward (from outputs to inputs), enabling efficient and precise gradient computation essential for optimization and learning algorithms.

Epilogue

Summary

Summary

Concept Role
Activation functions Introduce non-linearity (sigmoid in SimplerMLP).
Loss function Measures prediction error (e.g., Binary Cross-Entropy).
Learning rate (α) Controls step size in parameter updates.
Gradient descent Optimization method for weight adjustment.
Chain rule Mechanism for propagating derivatives backward.
Automatic differentiation Automates derivative computation; backpropagation uses reverse mode.

From backpropagation to implementation

  • Store the forward pass’s activations and preactivations.
  • Compute error signals backwards; add contributions at branches.
  • Average parameter gradients over examples, then update all parameters.
  • SimplerMLP implements this process with sigmoid units and a constant learning rate.

3Blue1Brown

A series of videos, with animations, providing the intuition behind the backpropagation algorithm.

StatQuest

Herman Kamper

One of the most thorough series of videos on the backpropagation algorithm.

Free book with implementation

In his book, Neural Networks and Deep Learning, Michael Nielsen provides a comprehensive Python implementation of a neural network.

Next lecture

  • Gradient propagation, activation functions, and initialization.
  • Extend SimplerMLP to SimpleMLP with activation choices, L2, and learning-rate decay.
  • Softmax, cross-entropy, and regularization.

References

Angermueller, Christof, Tanel Pärnamaa, Leopold Parts, and Oliver Stegle. 2016. “Deep Learning for Computational Biology.” Mol Syst Biol 12 (7): 878. https://doi.org/10.15252/msb.20156651.
Baydin, Atılım Günes, Barak A. Pearlmutter, Alexey Andreyevich Radul, and Jeffrey Mark Siskind. 2017. “Automatic Differentiation in Machine Learning: A Survey.” J. Mach. Learn. Res. 18 (1): 5595–637.
Bicknell, Klinton, Claire Brust, and Burr Settles. 2023. “How Duolingo’s AI Learns What You Need to Learn: The Language-Learning App Tries to Emulate a Great Human Tutor.” IEEE Spectrum 60 (3): 28–33. https://doi.org/10.1109/MSPEC.2023.10061631.
Rumelhart, David E., Geoffrey E. Hinton, and Ronald J. Williams. 1986. “Learning representations by back-propagating errors.” Nature 323 (6088): 533–36. https://doi.org/10.1038/323533a0.
Russell, Stuart, and Peter Norvig. 2020. Artificial Intelligence: A Modern Approach. 4th ed. Pearson. http://aima.cs.berkeley.edu/.

Marcel Turcotte

[email protected]

School of Electrical Engineering and Computer Science (EECS)

University of Ottawa