Multilayer Perceptrons and Backpropagation

Michael BrenndoerferOctober 29, 202651 min read

Build a one-hidden-layer multilayer perceptron from scratch: derive forward and backward passes, check gradients, and train with mini-batches.

Machine Learning from ScratchAll chapters
Part 7 of 15Supervised Learning Beyond Linear Models
Chapter 57Multilayer Perceptrons and Backpropagation
Reading the full chapter · 10 sections · 51 min10 sections · 51 min

All 10 sections · 51 min read

Multilayer Perceptrons and BackpropagationFull chapter · Sections in this chapter1 / 10 ↓
Reading tools Definitions, links & notes

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Notes & comments

Multilayer Perceptrons and Backpropagation

A logistic regression model using the original input features draws a linear decision boundary. In the noisy two-moons example below, a straight line cuts across the crescent-shaped groups. A multilayer perceptron (MLP) can represent a curved boundary by stacking affine transformations with nonlinear activation functions. The first layer learns a new representation of the inputs, and the final layer makes a prediction from that representation.

The central difficulty is training. The hidden-layer network has more parameters than the linear baseline, and its loss is generally nonconvex in the combined layer parameters. We fit it with iterative gradient updates. Backpropagation computes the loss gradient with respect to every weight and bias by applying the chain rule systematically to the computation graph.

In this chapter we build a one-hidden-layer network from primitive NumPy operations. We derive the forward pass, the stable binary cross-entropy loss, and every gradient used in training. We check the gradients with finite differences, train the network with mini-batches, and compare it with logistic regression on a noisy nonlinear classification task. The chapter assumes familiarity with gradient descent and numerical optimization and with train/test discipline from validation strategies and data leakage.

Advantages

The main advantage of an MLP is representational flexibility. A hidden layer applies several learned affine projections followed by a nonlinearity, so the network can construct features that are not present in the raw input coordinates. In a two-dimensional classification problem, this lets the decision boundary bend. In higher-dimensional tabular problems, it lets the model represent interactions such as "high income matters only when the account is new." Logistic regression on the original features has additive log-odds; to represent an income-by-account-age interaction in the log-odds, it needs additional interaction or nonlinear features.

The same hidden-layer machinery extends to several prediction tasks. A sigmoid output with binary cross-entropy gives binary classification; a softmax output with cross-entropy gives multiclass classification; a linear output with squared error gives regression. The output dimension is chosen for the target representation: one logit for binary classification, one logit per class for a softmax output, and one value per regression target. The output activation and loss must match that task. The hidden-layer chain-rule calculation follows the same principle.

The forward and backward passes can be expressed with matrix multiplications and elementwise operations, with examples arranged in batches. Mini-batch training uses fewer examples for each update than full-batch gradient descent and averages more per-example gradients than one-example stochastic gradient descent. Regularization can be added directly to the loss, and validation data can monitor the resulting training loop.

Disadvantages

MLPs are nonconvex. The loss surface can contain many local minima, flat regions, saddle points, and steep cliffs. Gradient descent does not come with a global convergence guarantee. In practice, careful initialization, input scaling, sensible learning rates, and mini-batch noise make training work well on many problems, but the optimization is still more fragile than fitting a linear model or a convex regularized model.

MLPs are sensitive to preprocessing and hyperparameters. Features measured on very different scales can produce gradients with very different magnitudes, making a single learning rate awkward to choose. Training can stall or diverge. Hidden width, activation, learning rate, batch size, regularization strength, and training duration all affect the fitted model. A small network can underfit nonlinear structure; a large network can overfit, especially with few labeled examples.

Finally, MLPs are less interpretable than linear models. The weights of the first layer are not directly the effects of the original features, because they are mixed by the hidden units and the activation function. Interpretation usually requires post hoc methods, which we treat later in the book. For small tabular datasets where a linear model or a tree ensemble already performs well, the extra complexity of an MLP may not be justified.

Formula

With the motivation in place, the formula section answers a practical question: what exactly does the network compute, and how do we adjust every parameter so that its predictions improve? We will proceed in the same order as the implementation. First we define the forward pass and keep track of shapes. Then we define a numerically stable loss. Finally we differentiate that loss layer by layer. The order matters: backpropagation is not a separate learning algorithm, it is the chain rule applied to the forward computation we have just written down.

We begin with notation. Suppose we have nn training examples, each with dd input features. Let xi∈Rdx_i \in \mathbb{R}^d be the ii-th input and yi∈{0,1}y_i \in \{0,1\} its binary label. We use a network with one hidden layer containing hh hidden units. The hidden layer has weight matrix W∈Rh×d\mathbf{W} \in \mathbb{R}^{h \times d} and bias vector b∈Rh\mathbf{b} \in \mathbb{R}^h. The output layer has weight vector v∈Rh\mathbf{v} \in \mathbb{R}^h and bias c∈Rc \in \mathbb{R}.

where:

  • nn: number of training examples, with one example per row when stored in the matrix X\mathbf{X}
  • dd: number of input features per example
  • xix_i: the ii-th input vector, an element of Rd\mathbb{R}^d
  • yiy_i: the binary label of the ii-th example, either 00 or 11
  • hh: number of hidden units in the single hidden layer
  • W\mathbf{W}: hidden-layer weight matrix, whose jj-th row Wj∈Rd\mathbf{W}_j \in \mathbb{R}^d holds the weights feeding hidden unit jj
  • b\mathbf{b}: hidden-layer bias vector, whose jj-th entry bjb_j shifts hidden unit jj
  • v\mathbf{v}: output-layer weight vector, whose jj-th entry vjv_j scales hidden activation aja_j in the output logit
  • cc: output-layer bias, which shifts the logit before the sigmoid

The symbol Rh×d\mathbb{R}^{h \times d} means the set of real matrices with hh rows and dd columns. Each row of W\mathbf{W} is the weight vector for one hidden unit. The bias bjb_j shifts the jj-th hidden unit, just as the intercept shifts a regression line. One way to read the notation is as a contract: the input has dd numbers, the hidden layer is allowed to create hh new numbers, and the output layer must combine those hh numbers into one prediction. The rest of the derivation is about making that contract precise and keeping the dimensions consistent.

Affine layers, nonlinear activations, and shapes

We start with the simplest part: an affine transformation. For a single input xx, the hidden preactivation is

z=Wx+b,z = \mathbf{W}x + \mathbf{b},

where:

  • xx: a single input vector, so that Wx\mathbf{W}x applies the h×dh \times d hidden weight matrix
  • zz: the hidden preactivation vector, the affine output before the nonlinearity

The vector z∈Rhz \in \mathbb{R}^h contains one number per hidden unit. The jj-th component is

zj=∑k=1dWjkxk+bj.z_j = \sum_{k=1}^{d} W_{jk}x_k + b_j.

where:

  • zjz_j: the preactivation of hidden unit jj
  • WjkW_{jk}: the weight connecting input feature kk to hidden unit jj, the entry in row jj and column kk of W\mathbf{W}
  • xkx_k: the kk-th feature of the input
  • bjb_j: the bias of hidden unit jj
  • dd: the number of input features

We read ∑k=1d\sum_{k=1}^{d} as “sum over the input features k=1,…,dk=1,\dots,d.” The entry WjkW_{jk} is the weight connecting input feature kk to hidden unit jj. This is exactly the same weighted sum that appears in linear regression, repeated hh times with its own weights for each hidden unit. The hidden layer is a bank of learned affine projections, which can respond to different directions in the input space. The bias bjb_j shifts the point at which the jj-th projection is centered.

Without a nonlinearity, stacking two affine layers would collapse into one affine layer:

v⊤(Wx+b)+c=(v⊤W)x+(v⊤b+c),\mathbf{v}^\top(\mathbf{W}x+\mathbf{b})+c = (\mathbf{v}^\top\mathbf{W})x + (\mathbf{v}^\top\mathbf{b}+c),

which is still affine (linear plus a constant) in xx. A nonlinear activation allows the composition to represent functions that a single affine layer cannot. A common choice is the hyperbolic tangent,

aj=tanh⁡(zj)=ezj−e−zjezj+e−zj,a_j = \tanh(z_j) = \frac{e^{z_j}-e^{-z_j}}{e^{z_j}+e^{-z_j}},

where:

  • aja_j: the activation of hidden unit jj
  • zjz_j: the preactivation of hidden unit jj entering the activation

The derivative of tanh is

ddzjtanh⁡(zj)=1−tanh⁡2(zj)=1−aj2.\frac{d}{dz_j}\tanh(z_j)=1-\tanh^2(z_j)=1-a_j^2.

Another common choice is the rectified linear unit,

aj=ReLU⁡(zj)=max⁡(0,zj),a_j = \operatorname{ReLU}(z_j)=\max(0,z_j),

where:

  • aja_j: the activation of hidden unit jj
  • zjz_j: the preactivation of hidden unit jj entering the activation

The ReLU derivative is 11 when zj>0z_j>0 and 00 when zj<0z_j<0. At exactly zero the derivative is not defined, and implementations choose a convention, usually 00. The sigmoid activation

σ(z)=11+e−z\sigma(z)=\frac{1}{1+e^{-z}}

where:

  • σ\sigma: the logistic (sigmoid) function mapping any real input to (0,1)(0,1)
  • zz: the input to the sigmoid, for example an output logit

The sigmoid is often used at the output for binary classification, and sometimes in hidden layers. Its derivative is σ(z)(1−σ(z))\sigma(z)(1-\sigma(z)). These derivatives tell us how a small change in a preactivation changes the activation. The backward pass multiplies them by the upstream loss gradient to obtain the gradient with respect to the preactivation. In a saturated region, tanh or sigmoid has a small derivative and attenuates a fixed upstream gradient. A ReLU has derivative zero on the negative side, so its local data-loss gradient is zero for that input.

The figure below makes the local exchange rates visible. Solid curves are the activations, and dashed curves are their derivatives. Where a derivative is near zero, the backward pass transmits very little gradient. The figure also shows that sigmoid and tanh flatten at large magnitudes, while ReLU stays linear on the positive side and becomes exactly zero on the negative side.

The hidden setup below makes the shared plotting module available and seeds the book plots once. Each plotting cell imports the style helpers it uses. The cell after it only prepares the data that the activation figure will display.

PythonIn [3]
CollapseExpand
import numpy as np

z_grid = np.linspace(-4.0, 4.0, 401)
tanh_vals = np.tanh(z_grid)
sigmoid_vals = 1.0 / (1.0 + np.exp(-z_grid))
relu_vals = np.maximum(0.0, z_grid)
tanh_deriv = 1.0 - tanh_vals**2
sigmoid_deriv = sigmoid_vals * (1.0 - sigmoid_vals)
relu_deriv = (z_grid > 0.0).astype(float)
VisualizationOut [4]
CollapseExpand
Curves of tanh, sigmoid, and ReLU activations with dashed local derivatives.
Common hidden-layer activations and their local derivatives. Solid lines show the activation values, and dashed lines show the derivative used by backpropagation. For a fixed upstream loss gradient, a near-zero activation derivative attenuates the gradient passed to the preactivation. This can slow updates through saturated hidden units.

For a mini-batch of BB examples, we store the inputs in a matrix X∈RB×d\mathbf{X} \in \mathbb{R}^{B \times d}, with one example per row, and use the same hidden-layer mapping as in the single-example derivation. The hidden preactivation matrix is

Z=XW⊤+1b⊤,\mathbf{Z} = \mathbf{X}\mathbf{W}^\top + \mathbf{1}\mathbf{b}^\top,

where:

  • BB: the number of examples in the mini-batch
  • X\mathbf{X}: the B×dB \times d input matrix, with one example per row
  • W⊤\mathbf{W}^\top: the d×hd \times h transpose of the hidden weight matrix
  • 1\mathbf{1}: a column vector of ones with length BB
  • b⊤\mathbf{b}^\top: the 1×h1 \times h transpose of the hidden bias, broadcast across rows
  • Z\mathbf{Z}: the B×hB \times h hidden preactivation matrix

The transpose appears because X\mathbf{X} has examples as rows while W\mathbf{W} has hidden units as rows. The product XW⊤\mathbf{X}\mathbf{W}^\top has shape B×hB \times h: one row per example, one column per hidden unit. Broadcasting adds the same bias vector b⊤\mathbf{b}^\top to every row. The activation matrix is A=g(Z)\mathbf{A}=g(\mathbf{Z}), where gg is applied elementwise.

The output logit is

s=Av+c,s = \mathbf{A}\mathbf{v}+c,

where:

  • A\mathbf{A}: the B×hB \times h hidden activation matrix
  • v\mathbf{v}: the h×1h \times 1 output weight vector
  • cc: the output bias
  • ss: the output logit vector of length BB

For binary classification we convert the logit to a probability with the sigmoid:

pi=σ(si)=11+e−si.p_i = \sigma(s_i)=\frac{1}{1+e^{-s_i}}.

where:

  • sis_i: the output logit of example ii
  • pip_i: the predicted probability that example ii belongs to class 11

The shape chain is worth checking: X\mathbf{X} is B×dB \times d, W⊤\mathbf{W}^\top is d×hd \times h, and Z\mathbf{Z} and A\mathbf{A} are B×hB \times h. In the column-vector notation used here, v\mathbf{v} is h×1h \times 1 and ss is B×1B \times 1. The NumPy implementation stores these vectors as one-dimensional arrays with shapes (h,) and (B,); the scalar formulas are the same. Writing each shape beside the corresponding operation helps find dimension mismatches before coding.

Forward passes and stable losses

The forward pass is the sequence of operations that turns inputs into predictions. For one mini-batch we compute Z\mathbf{Z}, then A=g(Z)\mathbf{A}=g(\mathbf{Z}), then s=Av+cs=\mathbf{A}\mathbf{v}+c, then p=σ(s)p=\sigma(s). The forward pass stores X\mathbf{X}, Z\mathbf{Z}, and A\mathbf{A} in a cache. This tanh backward pass reuses A\mathbf{A} to compute the activation derivatives elementwise as 1−aij21-a_{ij}^2; it receives X\mathbf{X} separately and does not read the cached Z\mathbf{Z}. It is useful to think of the forward pass as building a cache: every quantity that will be needed later is stored now, so the backward pass can reuse it instead of recomputing it.

For binary labels, the negative log-likelihood of one example is

ℓi=−[yilog⁡pi+(1−yi)log⁡(1−pi)].\ell_i = -\left[y_i\log p_i + (1-y_i)\log(1-p_i)\right].

where:

  • ℓi\ell_i: the binary cross-entropy loss for example ii
  • yiy_i: the binary label, either 00 or 11
  • pip_i: the predicted probability of class 11 for example ii

This is the binary cross-entropy loss. If yi=1y_i=1, only −log⁡pi-\log p_i remains; if yi=0y_i=0, only −log⁡(1−pi)-\log(1-p_i) remains. The loss is small when the predicted probability agrees with the label and large when it disagrees. In that sense the loss is a precise way of saying “how surprised should the model be by the observed label?”

For a very negative logit, a naive sigmoid calculation can overflow in e−sie^{-s_i} and round the predicted probability to zero. Taking its logarithm then produces negative infinity even though the mathematical log probability is finite. We compute the loss from the logit instead. Since p=σ(s)=1/(1+e−s)p=\sigma(s)=1/(1+e^{-s}), we have

−log⁡p=log⁡(1+e−s)-\log p = \log(1+e^{-s})

and

−log⁡(1−p)=log⁡(1+es).-\log(1-p) = \log(1+e^{s}).

where:

  • pp: the predicted probability for the example, equal to σ(s)\sigma(s)
  • ss: the output logit for the example

Combining the two cases gives

ℓi=log⁡(1+esi)−yisi.\ell_i = \log(1+e^{s_i}) - y_i s_i.

where:

  • ℓi\ell_i: the binary cross-entropy loss for example ii, computed from the logit
  • sis_i: the output logit of example ii
  • yiy_i: the binary label, either 00 or 11

The expression log⁡(1+esi)\log(1+e^{s_i}) is exactly log⁡(e0+esi)\log(e^0+e^{s_i}), which is a log-sum-exp written in the form log⁡(e0+esi)\log(e^0 + e^{s_i}). Numerical libraries provide a stable function often called logaddexp, which computes log⁡(ea+eb)\log(e^a+e^b) without overflowing. Thus we compute

ℓi=logaddexp⁡(0,si)−yisi.\ell_i = \operatorname{logaddexp}(0,s_i) - y_i s_i.

For label 1 the loss decreases as the logit grows; for label 0 it increases. The logaddexp calculation avoids forming an overflowing exponential directly. The plotted losses remain finite over the displayed logit range.

PythonIn [5]
CollapseExpand
import numpy as np

s_curve = np.linspace(-5.0, 5.0, 401)
loss_label_one = np.logaddexp(0.0, s_curve) - 1.0 * s_curve
loss_label_zero = np.logaddexp(0.0, s_curve) - 0.0 * s_curve
VisualizationOut [6]
CollapseExpand
Two curves showing binary cross-entropy loss against the logit for labels zero and one.
Binary cross-entropy as a function of the output logit for each binary label. For label 1 the loss decreases as the logit grows, while for label 0 it increases. The stable logit form stays finite and grows approximately linearly for confident mistakes.

where:

  • logaddexp⁡(a,b)\operatorname{logaddexp}(a,b): a numerically stable computation of log⁡(ea+eb)\log(e^a+e^b)
  • sis_i: the output logit of example ii
  • yiy_i: the binary label, either 00 or 11

For a batch we average:

L=1B∑i=1Bℓi.L=\frac{1}{B}\sum_{i=1}^{B}\ell_i.

where:

  • LL: the mean binary cross-entropy over the BB examples
  • BB: the number of examples in the batch
  • ℓi\ell_i: the binary cross-entropy loss for example ii

The same idea extends to multiclass classification. If the network produces logits si1,…,siKs_{i1},\dots,s_{iK} for KK classes, the softmax probability of class kk is

p^ik=esik∑m=1Kesim,\hat p_{ik} = \frac{e^{s_{ik}}}{\sum_{m=1}^{K}e^{s_{im}}},

where:

  • p^ik\hat p_{ik}: the predicted probability that example ii belongs to class kk
  • siks_{ik}: the logit for class kk on example ii
  • KK: the number of classes

For this multiclass formula, let yi∈{1,…,K}y_i\in\{1,\dots,K\} denote the true class index, replacing the earlier binary encoding. The multiclass cross-entropy is

ℓi=−si,yi+log⁡∑m=1Kesim.\ell_i = -s_{i,y_i} + \log\sum_{m=1}^{K}e^{s_{im}}.

where:

  • ℓi\ell_i: the cross-entropy loss for example ii
  • KK: the number of classes
  • sims_{im}: the logit for class mm on example ii
  • si,yis_{i,y_i}: the logit for the true class yiy_i on example ii

The second term is computed with a stable log-sum-exp operation. In this chapter we focus on the binary case, but the backward pass for softmax cross-entropy has the same clean form: the gradient with respect to the logits is predicted probability minus one-hot label.

Backpropagation and gradient checking

Backpropagation is the chain rule applied from the output backward. We first compute the derivative of the loss with respect to the output logit sis_i. A useful discipline is to derive the gradient for one scalar example first, then average over the batch; the only change is the factor 1/B1/B, which appears in the delta defined below. For one example,

∂ℓ∂s=∂ℓ∂p∂p∂s.\frac{\partial \ell}{\partial s} = \frac{\partial \ell}{\partial p}\frac{\partial p}{\partial s}.

where:

  • ℓ\ell: the binary cross-entropy loss for one example
  • ss: the output logit for that example
  • p=σ(s)p=\sigma(s): the predicted probability for that example

We read ∂ℓ/∂s\partial \ell/\partial s as the partial derivative of the loss with respect to the logit ss. The derivative of the binary cross-entropy with respect to pp is

∂ℓ∂p=−yp+1−y1−p.\frac{\partial \ell}{\partial p} = -\frac{y}{p}+\frac{1-y}{1-p}.

where:

  • ℓ\ell: the binary cross-entropy loss for one example
  • pp: the predicted probability for that example
  • yy: the binary label of that example

The derivative of the sigmoid is ∂p/∂s=p(1−p)\partial p/\partial s=p(1-p). Multiplying gives

∂ℓ∂s=(−yp+1−y1−p)p(1−p)=p−y.\frac{\partial \ell}{\partial s} = \left(-\frac{y}{p}+\frac{1-y}{1-p}\right)p(1-p) = p-y.

where:

  • ∂ℓ/∂s\partial \ell/\partial s: the derivative of the loss with respect to the logit
  • pp: the predicted probability
  • yy: the binary label

This is a simple result: the gradient at the logit is the predicted probability minus the label. If the network predicts p=0.9p=0.9 and the label is 11, the gradient is −0.1-0.1, so gradient descent increases the logit slightly. If the label is 00, the gradient is 0.90.9, a larger correction. The same pattern will reappear in multiclass classification, where the gradient is the predicted probability vector minus the one-hot label.

For a batch with mean loss, we divide by BB. Define

δi=pi−yiB.\delta_i = \frac{p_i-y_i}{B}.

where:

  • δi\delta_i: the scaled output delta for example ii
  • pip_i: the predicted probability for example ii
  • yiy_i: the binary label for example ii
  • BB: the number of examples in the batch, so division by BB reflects the mean loss

The gradient with respect to the output weights is

∂L∂vj=∑i=1Bδiaij,\frac{\partial L}{\partial v_j} = \sum_{i=1}^{B}\delta_i a_{ij},

where:

  • LL: the mean loss over the batch
  • vjv_j: the jj-th output weight
  • aija_{ij}: the activation of hidden unit jj on example ii

This holds because si=∑jvjaij+cs_i=\sum_j v_j a_{ij}+c, so ∂si/∂vj=aij\partial s_i/\partial v_j=a_{ij}. In matrix form,

∇vL=A⊤δ,\nabla_{\mathbf{v}}L = \mathbf{A}^\top \delta,

where:

  • ∇vL\nabla_{\mathbf{v}}L: the gradient of the mean loss with respect to the output weight vector
  • A\mathbf{A}: the B×hB \times h hidden activation matrix
  • δ\delta: the B×1B \times 1 vector of δi\delta_i values

The transpose is needed because A\mathbf{A} is B×hB \times h and δ\delta is B×1B \times 1, so A⊤δ\mathbf{A}^\top\delta is h×1h \times 1, matching v\mathbf{v}. The gradient with respect to the output bias is

∂L∂c=∑i=1Bδi,\frac{\partial L}{\partial c}=\sum_{i=1}^{B}\delta_i,

where:

  • cc: the output bias
  • δi\delta_i: the scaled output delta for example ii

Now we propagate backward into the hidden layer. For hidden unit jj and example ii, the logit depends on aija_{ij} through si=∑jvjaij+cs_i=\sum_j v_j a_{ij}+c, so

∂L∂aij=δivj.\frac{\partial L}{\partial a_{ij}}=\delta_i v_j.

where:

  • ∂L/∂aij\partial L/\partial a_{ij}: the derivative of the mean loss with respect to the activation of hidden unit jj on example ii
  • δi\delta_i: the scaled output delta for example ii
  • vjv_j: the output weight for hidden unit jj

The activation aij=g(zij)a_{ij}=g(z_{ij}) contributes its local derivative:

∂L∂zij=δivj g′(zij).\frac{\partial L}{\partial z_{ij}} = \delta_i v_j\,g'(z_{ij}).

where:

  • ∂L/∂zij\partial L/\partial z_{ij}: the derivative of the mean loss with respect to the preactivation of hidden unit jj on example ii
  • δi\delta_i: the scaled output delta for example ii
  • vjv_j: the output weight for hidden unit jj
  • g′(zij)g'(z_{ij}): the derivative of the hidden activation evaluated at the preactivation

For tanh⁡\tanh, g′(zij)=1−aij2g'(z_{ij})=1-a_{ij}^2. Define the hidden delta matrix

Δij=δivj (1−aij2),\Delta_{ij} = \delta_i v_j\,(1-a_{ij}^2),

where:

  • Δij\Delta_{ij}: the hidden delta for hidden unit jj on example ii
  • δi\delta_i: the scaled output delta for example ii
  • vjv_j: the output weight for hidden unit jj
  • aija_{ij}: the activation of hidden unit jj on example ii

In matrix notation,

Δ=(δv⊤)⊙(1−A⊙A),\Delta = (\delta\mathbf{v}^\top)\odot(1-\mathbf{A}\odot\mathbf{A}),

where:

  • Δ\Delta: the B×hB \times h hidden delta matrix
  • δ\delta: the B×1B \times 1 vector of scaled output deltas
  • v\mathbf{v}: the h×1h \times 1 output weight vector, so δv⊤\delta\mathbf{v}^\top is a B×hB \times h outer product
  • A\mathbf{A}: the B×hB \times h hidden activation matrix
  • ⊙\odot: elementwise (Hadamard) multiplication

The outer product δv⊤\delta\mathbf{v}^\top has shape B×hB \times h, and 1−A⊙A1-\mathbf{A}\odot\mathbf{A} has the same shape. The hidden preactivation is zij=∑kWjkxik+bjz_{ij}=\sum_k W_{jk}x_{ik}+b_j, so

∂L∂Wjk=∑i=1BΔijxik,∂L∂bj=∑i=1BΔij.\frac{\partial L}{\partial W_{jk}} = \sum_{i=1}^{B}\Delta_{ij}x_{ik}, \qquad \frac{\partial L}{\partial b_j} = \sum_{i=1}^{B}\Delta_{ij}.

where:

  • ∂L/∂Wjk\partial L/\partial W_{jk}: the derivative of the mean loss with respect to the hidden weight connecting input feature kk to hidden unit jj
  • ∂L/∂bj\partial L/\partial b_j: the derivative of the mean loss with respect to the hidden bias of unit jj
  • Δij\Delta_{ij}: the hidden delta for hidden unit jj on example ii
  • xikx_{ik}: the kk-th feature of example ii

In matrix form,

∇WL=Δ⊤X,∇bL=Δ⊤1.\nabla_{\mathbf{W}}L = \Delta^\top \mathbf{X}, \qquad \nabla_{\mathbf{b}}L = \Delta^\top \mathbf{1}.

where:

  • ∇WL\nabla_{\mathbf{W}}L: the gradient of the mean loss with respect to the hidden weight matrix
  • ∇bL\nabla_{\mathbf{b}}L: the gradient of the mean loss with respect to the hidden bias vector
  • Δ⊤\Delta^\top: the h×Bh \times B transpose of the hidden delta matrix
  • X\mathbf{X}: the B×dB \times d input matrix
  • 1\mathbf{1}: the B×1B \times 1 column vector of ones

Again the shapes match: Δ⊤\Delta^\top is h×Bh \times B, X\mathbf{X} is B×dB \times d, so Δ⊤X\Delta^\top\mathbf{X} is h×dh \times d, the same shape as W\mathbf{W}. The gradient with respect to b\mathbf{b} is the column sum of Δ\Delta. Notice how each matrix expression encodes a sum: the transpose aligns examples with the quantity being summed, and the matrix product performs the sum over examples in one operation.

If we add L2 regularization, the loss becomes

Lreg=L+λ2(∑j,kWjk2+∑jvj2).L_{\text{reg}} = L + \frac{\lambda}{2}\left(\sum_{j,k}W_{jk}^2+\sum_j v_j^2\right).

where:

  • LregL_{\text{reg}}: the regularized mean loss
  • LL: the unregularized mean binary cross-entropy
  • λ\lambda: the L2 regularization strength, with λ≥0\lambda \geq 0
  • WjkW_{jk}: the hidden weight connecting input feature kk to hidden unit jj
  • vjv_j: the output weight for hidden unit jj

The factor 1/21/2 makes the derivative of the penalty with respect to WjkW_{jk} equal to λWjk\lambda W_{jk}, and similarly for vjv_j. We do not regularize the biases by default, because they do not control the scale of the learned features in the same way.

Gradient checking verifies the backward pass by comparing analytic gradients with finite differences. It is the unit test of a hand-written backward pass. For a parameter θi\theta_i,

∂L∂θi≈L(θi+ϵ)−L(θi−ϵ)2ϵ.\frac{\partial L}{\partial \theta_i} \approx \frac{L(\theta_i+\epsilon)-L(\theta_i-\epsilon)}{2\epsilon}.

where:

  • θi\theta_i: the ii-th scalar parameter being checked
  • L(θi+ϵ)L(\theta_i+\epsilon): the loss with parameter θi\theta_i increased by the perturbation size ϵ\epsilon
  • L(θi−ϵ)L(\theta_i-\epsilon): the loss with parameter θi\theta_i decreased by ϵ\epsilon
  • ϵ\epsilon: the finite-difference step size

Perturb one parameter by ϵ\epsilon in each direction, compute the two losses, and divide their difference by 2ϵ2\epsilon. For a sufficiently smooth loss, the centered formula has truncation error of order ϵ2\epsilon^2. Floating-point cancellation grows as the step becomes too small, so this is not a guarantee of greater accuracy for every step size. A relative error below about 10−410^{-4} is a useful diagnostic for a smooth float64 example at a suitable step size, but near-zero gradients and activation kinks need separate inspection.

Mathematical Properties

With a sufficiently wide hidden layer, a suitable continuous sigmoidal activation, and a linear output, a network can approximate continuous functions on a compact input domain to arbitrary accuracy. This is an existence statement about a family of functions, not a guarantee that gradient descent will find the required parameters from finite data. Our binary classifier instead uses a sigmoid output to keep predictions between zero and one. Approximation capacity does not remove the need for regularization and validation.

The joint loss is generally nonconvex in W\mathbf{W}, b\mathbf{b}, v\mathbf{v}, and cc. It also has a permutation symmetry: permuting hidden units and applying the same permutation to rows of W\mathbf{W}, entries of b\mathbf{b}, and entries of v\mathbf{v} leaves the network output unchanged. Distinct parameter vectors can therefore represent the same function. Random initialization helps give hidden units different starting points. If every weight and bias in this tanh network starts at zero, hidden activations and output weights are zero; the hidden features cannot begin learning under the stated gradient updates.

The gradient with respect to WjkW_{jk} contains the input factor xkx_k. Holding the other gradient factors fixed, rescaling that feature rescales this gradient. Standardization brings marginal feature scales closer together, but it does not remove correlations or guarantee a well-conditioned loss. Saturating tanh and sigmoid activations have small local derivatives at large magnitudes. These multipliers can attenuate gradient flow; ReLU avoids positive-side saturation but has zero derivative on its negative side.

Example

To make the formula concrete, we compute it by hand. The following example is deliberately tiny: two input features, two hidden units, and one binary output. Its purpose is not to be realistic but to expose every number that the code will later compute. We will carry the forward pass, the backward pass, and one gradient step by hand, so that each matrix in the implementation has a concrete scalar counterpart. Calculations retain full precision; displayed results are rounded to four decimal places.

Suppose we have two input features, two hidden units, and one binary output. Let the input be

x=[1.0−1.0],y=1.x = \begin{bmatrix}1.0\\-1.0\end{bmatrix}, \qquad y=1.

Let the hidden weights and biases be

W=[0.5−0.51.00.5],b=[0.1−0.2],\mathbf{W} = \begin{bmatrix} 0.5 & -0.5\\ 1.0 & 0.5 \end{bmatrix}, \qquad \mathbf{b}= \begin{bmatrix} 0.1\\-0.2 \end{bmatrix},

and let the output weights and bias be

v=[1.2−0.8],c=0.05.\mathbf{v}= \begin{bmatrix} 1.2\\-0.8 \end{bmatrix}, \qquad c=0.05.

We use tanh⁡\tanh in the hidden layer and sigmoid at the output.

Forward pass

We compute the hidden preactivations one unit at a time. For the first hidden unit,

z1=0.5(1.0)+(−0.5)(−1.0)+0.1=0.5+0.5+0.1=1.1.z_1 = 0.5(1.0)+(-0.5)(-1.0)+0.1 = 0.5+0.5+0.1=1.1.

Then

a1=tanh⁡(1.1)≈0.8005.a_1=\tanh(1.1)\approx 0.8005.

For the second hidden unit,

z2=1.0(1.0)+0.5(−1.0)−0.2=1.0−0.5−0.2=0.3,z_2 = 1.0(1.0)+0.5(-1.0)-0.2 = 1.0-0.5-0.2=0.3,

so

a2=tanh⁡(0.3)≈0.2913.a_2=\tanh(0.3)\approx 0.2913.

The output logit is

s=1.2a1−0.8a2+0.05≈0.7775.s = 1.2a_1-0.8a_2+0.05\approx 0.7775.

The predicted probability is

p=σ(s)=11+e−s≈0.6852.p=\sigma(s)=\frac{1}{1+e^{-s}}\approx 0.6852.

where:

  • σ\sigma: the sigmoid function mapping the logit to a probability
  • pp: the predicted probability that the example belongs to class 11

The loss is

ℓ=−log⁡(p)≈0.3781.\ell = -\log(p)\approx 0.3781.

where:

  • ℓ\ell: the binary cross-entropy loss for this single example
  • log⁡\log: the natural logarithm

Equivalently, using the stable logit form,

ℓ=logaddexp⁡(0,s)−s≈0.3781.\ell=\operatorname{logaddexp}(0,s)-s\approx 0.3781.

We can read this forward pass as a pipeline: the input is projected into two hidden numbers, each hidden number is squashed by tanh, and the two squashed numbers are combined into a logit. The predicted probability is above 0.50.5, so the network already leans toward class 11, and the observed label is 11. Since the assigned probability p≈0.6852p\approx0.6852 is below 11, its binary cross-entropy −log⁡p≈0.3781-\log p\approx0.3781 is positive.

Backward pass

We now reverse the forward pass. The output gradient is

δ=p−y≈−0.3148,\delta = p-y \approx -0.3148,

The output weight gradients are

∂ℓ∂v1=δa1≈−0.2520,\frac{\partial \ell}{\partial v_1}=\delta a_1\approx -0.2520, ∂ℓ∂v2=δa2≈−0.0917,\frac{\partial \ell}{\partial v_2}=\delta a_2\approx -0.0917,

where:

  • δ=p−y\delta = p - y: the output delta for this single example, without the 1/B1/B scaling used for a batch mean
  • a1,a2a_1, a_2: the hidden activations computed in the forward pass
  • v1,v2v_1, v_2: the output weights

and the output bias gradient is

∂ℓ∂c=δ≈−0.3148.\frac{\partial \ell}{\partial c}=\delta\approx -0.3148.

For the hidden units, we first compute

∂ℓ∂a1=δv1≈−0.3778,\frac{\partial \ell}{\partial a_1}=\delta v_1\approx -0.3778, ∂ℓ∂a2=δv2≈0.2519.\frac{\partial \ell}{\partial a_2}=\delta v_2\approx 0.2519.

The tanh derivatives are

1−a12≈0.3592,1−a22≈0.9151,1-a_1^2\approx 0.3592, \qquad 1-a_2^2\approx 0.9151,

Therefore the hidden preactivation gradients are

Δ1=δv1(1−a12)≈−0.1357,\Delta_1=\delta v_1(1-a_1^2)\approx -0.1357, Δ2=δv2(1−a22)≈0.2305.\Delta_2=\delta v_2(1-a_2^2)\approx 0.2305.

Now we can compute the weight gradients. Since z1=W11x1+W12x2+b1z_1=W_{11}x_1+W_{12}x_2+b_1,

∂ℓ∂W11=Δ1x1≈−0.1357,\frac{\partial \ell}{\partial W_{11}}=\Delta_1 x_1\approx -0.1357, ∂ℓ∂W12=Δ1x2≈0.1357.\frac{\partial \ell}{\partial W_{12}}=\Delta_1 x_2\approx 0.1357.

Similarly,

∂ℓ∂W21=Δ2x1≈0.2305,\frac{\partial \ell}{\partial W_{21}}=\Delta_2 x_1\approx 0.2305, ∂ℓ∂W22=Δ2x2≈−0.2305.\frac{\partial \ell}{\partial W_{22}}=\Delta_2 x_2\approx -0.2305.

The hidden bias gradients are simply Δ1\Delta_1 and Δ2\Delta_2:

∂ℓ∂b1≈−0.1357,∂ℓ∂b2≈0.2305.\frac{\partial \ell}{\partial b_1}\approx -0.1357, \qquad \frac{\partial \ell}{\partial b_2}\approx 0.2305.

At this point every gradient has been computed by following the chain rule one link at a time. The output delta is the derivative of the loss with respect to the logit; its sign tells us which local change in the logit would reduce the loss. Multiplying by the hidden activations tells us how to change the output weights. Multiplying the output delta by the output weights and tanh derivative gives the loss gradient with respect to each hidden preactivation. Finally, multiplying by the input tells us how to change the hidden weights.

One gradient step

Gradient descent subtracts a multiple of the gradient. With learning rate η=0.5\eta=0.5, the new first hidden weight is

W11new=W11−η∂ℓ∂W11≈0.5679.W_{11}^{\text{new}}=W_{11}-\eta\frac{\partial\ell}{\partial W_{11}}\approx 0.5679.

where:

  • W11newW_{11}^{\text{new}}: the updated hidden weight after the gradient step
  • 0.50.5: the initial value of W11W_{11}
  • 0.50.5: the learning rate η\eta
  • −0.1357-0.1357: the gradient ∂ℓ/∂W11\partial \ell/\partial W_{11}

The new first output weight is

v1new=v1−η∂ℓ∂v1≈1.3260.v_1^{\text{new}}=v_1-\eta\frac{\partial\ell}{\partial v_1}\approx 1.3260.

where:

  • v1newv_1^{\text{new}}: the updated first output weight after the gradient step
  • 1.21.2: the initial value of v1v_1
  • 0.50.5: the learning rate η\eta
  • −0.2520-0.2520: the rounded gradient ∂ℓ/∂v1\partial \ell/\partial v_1

Applying the same rule at full precision and rounding the displayed values gives

W11≈0.5679,W12≈−0.5679,W21≈0.8847,W22≈0.6153,b1≈0.1679,b2≈−0.3153,v1≈1.3260,v2≈−0.7541,c≈0.2074.\begin{aligned} W_{11}&\approx0.5679, & W_{12}&\approx-0.5679,\\ W_{21}&\approx0.8847, & W_{22}&\approx0.6153,\\ b_1&\approx0.1679, & b_2&\approx-0.3153,\\ v_1&\approx1.3260, & v_2&\approx-0.7541,\\ c&\approx0.2074. \end{aligned}

A new forward pass with these updated parameters gives z1≈1.3036z_1\approx 1.3036, a1≈0.8626a_1\approx 0.8626, z2≈−0.0458z_2\approx -0.0458, a2≈−0.0457a_2\approx -0.0457, s≈1.3858s\approx 1.3858, p≈0.7999p\approx 0.7999, and loss ≈0.2232\approx 0.2232. The loss decreased from about 0.3780.378 to about 0.2230.223, which is the expected effect of a step in the negative gradient direction. It is a small confirmation that the signs and scales in the backward pass are consistent with the goal of reducing the loss.

Implementation in NumPy and Scikit-learn

We choose NumPy for the network because the goal is to expose every operation in the forward and backward passes. We use scikit-learn for data generation, train/test splitting, standardization, evaluation metrics, and the logistic regression baseline. The network itself is implemented with matrix operations and explicit gradients, so we can compare its predictions and check its gradients against finite differences. The point is not to hide the network behind a high-level API; it is to make the path from a mathematical expression to an executable update visible. All code cells below declare explicit Quarto directives so that computation is shown, while any printed results are displayed from calculated variables in a separate output cell.

We generate a noisy two-moons classification problem with 600600 examples, split it into training and test sets, and fit the scaler on the training set only. Fitting the scaler on the full dataset would leak test-set information into training, a point we emphasized in validation strategies and data leakage.

PythonIn [7]
CollapseExpand
import numpy as np
from book_plot_style import PALETTE, polish_axes, theme_color
from sklearn.datasets import make_moons
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
PythonIn [8]
CollapseExpand
X, y = make_moons(n_samples=600, noise=0.25, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.30, stratify=y, random_state=7
)

scaler = StandardScaler().fit(X_train)
Xtr = scaler.transform(X_train)
Xte = scaler.transform(X_test)

logit = LogisticRegression(max_iter=1000, random_state=0).fit(Xtr, y_train)

The next cell defines the network functions. The forward pass returns the input, hidden preactivation, and hidden activation in its cache. This tanh backward pass retrieves the hidden activation and uses the separately passed input; it does not need the cached preactivation because each activation derivative is 1−aij21-a_{ij}^2. The loss function uses the stable logit form. The backward pass implements the equations derived above, including the division by the batch size BB because the loss is a mean. As you read the code, match each line to an equation: delta_out is the output delta, delta_hidden is the hidden delta matrix, and the matrix products for v, W, b, and c are the gradient formulas written in the same order as the derivation.

PythonIn [9]
CollapseExpand
def sigmoid(s):
    return 1.0 / (1.0 + np.exp(-s))


def stable_binary_cross_entropy(s, y):
    return np.mean(np.logaddexp(0.0, s) - y * s)


def init_params(n_in, n_hidden, rng):
    W = rng.normal(0.0, np.sqrt(2.0 / n_in), size=(n_hidden, n_in))
    b = np.zeros(n_hidden)
    v = rng.normal(0.0, np.sqrt(2.0 / n_hidden), size=n_hidden)
    c = np.array(0.0)
    return {"W": W, "b": b, "v": v, "c": c}


def forward(params, X):
    Z = X @ params["W"].T + params["b"]
    A = np.tanh(Z)
    s = A @ params["v"] + params["c"]
    return s, {"X": X, "Z": Z, "A": A}


def loss_and_grads(params, X, y, lam=0.0):
    s, cache = forward(params, X)
    loss = stable_binary_cross_entropy(s, y)
    loss += 0.5 * lam * (np.sum(params["W"] ** 2) + np.sum(params["v"] ** 2))

    p = sigmoid(s)
    delta_out = (p - y) / X.shape[0]
    A = cache["A"]

    grads = {
        "v": A.T @ delta_out,
        "c": np.sum(delta_out),
    }
    delta_hidden = np.outer(delta_out, params["v"]) * (1.0 - A**2)
    grads["W"] = delta_hidden.T @ X
    grads["b"] = np.sum(delta_hidden, axis=0)

    grads["W"] += lam * params["W"]
    grads["v"] += lam * params["v"]
    return loss, grads

We train with mini-batches. Each epoch shuffles the training examples, processes them in batches, and updates the parameters after each batch. Code and output are kept in separate cells: training runs in code-only cells, and the resulting metrics are displayed from calculated variables in output-only cells. At the end of every epoch we compute the full-batch training loss for monitoring. This is not used for the parameter updates, it is only a diagnostic. The monitoring loss is full-batch on purpose: it gives a stable view of the training objective even though the updates themselves are noisy.

PythonIn [10]
CollapseExpand
def train_mlp(
    X, y, n_hidden=16, epochs=400, batch_size=64, lr=0.08, lam=1e-3, seed=0
):
    rng = np.random.default_rng(seed)
    params = init_params(X.shape[1], n_hidden, rng)
    n = X.shape[0]
    history = []

    for epoch in range(epochs):
        order = rng.permutation(n)
        for start in range(0, n, batch_size):
            idx = order[start : start + batch_size]
            _, grads = loss_and_grads(params, X[idx], y[idx], lam)
            for key in params:
                params[key] -= lr * grads[key]
        history.append(loss_and_grads(params, X, y, lam)[0])

    return params, np.asarray(history)

Before trusting the training run, we check the analytic gradients against centered finite differences on a small network. The relative error should be small. The check is not a proof of correctness for every possible input, but it catches sign errors, missing terms, and shape mistakes that are common in hand-written backward passes. The check perturbs one parameter entry at a time, recomputes the loss, and compares the resulting slope with the analytic gradient for that same entry. A maximum relative error near machine precision confirms that the analytic and numerical gradients agree.

PythonIn [11]
CollapseExpand
def numerical_gradient(params, X, y, lam=0.0, eps=1e-5):
    num = {k: np.zeros_like(v) for k, v in params.items()}
    for key in params:
        it = np.nditer(
            params[key], flags=["multi_index"], op_flags=["readwrite"]
        )
        while not it.finished:
            idx = it.multi_index
            original = params[key][idx]
            params[key][idx] = original + eps
            loss_plus, _ = loss_and_grads(params, X, y, lam)
            params[key][idx] = original - eps
            loss_minus, _ = loss_and_grads(params, X, y, lam)
            params[key][idx] = original
            num[key][idx] = (loss_plus - loss_minus) / (2.0 * eps)
            it.iternext()
    return num


rng_check = np.random.default_rng(11)
params_small = init_params(2, 3, rng_check)
X_small = rng_check.normal(size=(5, 2))
y_small = rng_check.integers(0, 2, size=5).astype(float)

loss_small, grads_small = loss_and_grads(
    params_small, X_small, y_small, lam=0.01
)
num_small = numerical_gradient(params_small, X_small, y_small, lam=0.01)
max_rel_error = max(
    np.max(
        np.abs(grads_small[k] - num_small[k])
        / np.maximum(1e-8, np.abs(grads_small[k]) + np.abs(num_small[k]))
    )
    for k in grads_small
)

With the analytic and numerical gradients computed, we can inspect their agreement directly. The plotting data below reuse the already-seeded small network from the gradient check; no new sampling or fitting is performed.

PythonIn [12]
CollapseExpand
gradient_blocks = [
    np.column_stack([grads_small[key].ravel(), num_small[key].ravel()])
    for key in grads_small
]
grad_pairs = np.vstack(gradient_blocks)
VisualizationOut [13]
CollapseExpand
Scatter of analytic versus numerical gradients with points lying on the identity line.
Analytic gradients plotted against centered finite differences for every parameter entry in the small gradient-check network. Points falling on the identity line show that the hand-written backward pass matches the numerical slope closely, confirming that signs, missing terms, and shapes are consistent.

After checking the small-network derivatives, we fit the one-hidden-layer model on the training partition using the prespecified hyperparameters below.

PythonIn [14]
CollapseExpand
params, history = train_mlp(
    Xtr,
    y_train.astype(float),
    n_hidden=16,
    epochs=400,
    batch_size=64,
    lr=0.08,
    lam=1e-3,
    seed=3,
)

The next cell computes the test accuracy and log loss for both models. The final training loss and the maximum relative gradient-check error were computed in the earlier cells. The following cell then displays all of these results, which are calculated from the executed variables rather than hardcoded.

PythonIn [15]
CollapseExpand
s_te, _ = forward(params, Xte)
p_mlp_te = sigmoid(s_te)
mlp_pred = (p_mlp_te >= 0.5).astype(int)

mlp_acc = accuracy_score(y_test, mlp_pred)
logit_acc = accuracy_score(y_test, logit.predict(Xte))
mlp_loss = stable_binary_cross_entropy(s_te, y_test.astype(float))
probs_logit = np.clip(logit.predict_proba(Xte)[:, 1], 1e-12, 1 - 1e-12)
logit_loss = -np.mean(
    y_test * np.log(probs_logit) + (1 - y_test) * np.log(1 - probs_logit)
)

The table compares both models on one held-out split. The full-batch training objective decreased during the displayed run, and the small relative gradient-check error shows agreement between the analytic and numerical derivatives on the checked inputs. Neither observation proves global convergence or better generalization. The decision-boundary figures below show the shape learned by each model; that graphical property is distinct from its test log loss.

The executable example uses fixed hyperparameters and does not implement validation-based selection. Its test data support the final metrics and illustrative plots. When tuning a model, reserve validation data from the training partition or use cross-validation, then evaluate the selected procedure on untouched test data.

Key Parameters

The hyperparameters are not independent knobs; they trade off capacity, optimization stability, and generalization. The main ones are:

  • n_hidden: the width of the hidden layer (default: 16). A larger value gives the network more capacity to represent complicated functions, but it also increases the number of parameters and can increase overfitting risk when the added capacity is not controlled by data and regularization. Start around 8 to 32 for low-dimensional tabular data.
  • lr: the learning rate (default: 0.08). If it is too large, the loss can oscillate or diverge; if it is too small, training takes many epochs. Values around 0.01 to 0.1 are reasonable starting points for standardized inputs.
  • batch_size: the maximum number of examples in each mini-batch gradient estimate (default: 64); the final batch may be smaller. At fixed parameters, smaller batches can give noisier gradient estimates. That randomness can sometimes move training out of a narrow basin, where the loss rises quickly around a local minimum, but it can also keep the objective from settling. Values around 32 to 128 are starting candidates for this small standardized example; choose among them with validation under a comparable training budget.
  • epochs: the number of passes over the training data (default: 400). Choose this with early stopping on a validation split.
  • lam: the L2 regularization strength (default: 1e-3). It shrinks the weights toward zero and can improve generalization, especially when the network is wide relative to the amount of training data. Values around 1e-4 to 1e-2 are reasonable starting ranges.
  • seed: the seed for the random number generator (default: 0). Set it to an integer to ensure consistent initialization and mini-batch shuffling across runs.

The initialization scale is controlled inside init_params. We use a standard deviation proportional to 1/d1/\sqrt{d} for the first layer and 1/h1/\sqrt{h} for the output layer, which keeps the initial preactivations from growing too quickly with the number of inputs.

Key Methods

The implementation is deliberately small, and each function covers one conceptual step, described in its surrounding prose. The plain gradient descent loop in train_mlp makes the mechanics visible; an extended training loop may use an adaptive optimizer and validation-based early stopping. Early stopping can be added to either plain gradient descent or an adaptive optimizer.

Mini-batch training, regularization, and failure cases

At fixed parameters, averaging a uniformly sampled batch of 64 per-example gradients has no greater sampling variance than using one sampled example, and has lower variance in directions where the per-example gradients vary. During actual training the parameters change after each batch, so this fixed-parameter comparison does not guarantee a smoother or better optimization trajectory. For NN training examples and a positive integer maximum batch size BB, this loop makes ⌈N/B⌉\lceil N/B\rceil updates per epoch. The ceiling rounds N/BN/B upward to an integer. Reducing BB cannot lower that count, but nearby batch sizes can give the same count. Mini-batch gradient noise can also keep the training objective from settling. Compare training and validation loss: decreasing training loss with rising validation loss suggests overfitting, while both remaining high suggests underfitting or an optimization problem.

L2 regularization adds a penalty proportional to squared weights. It discourages large weights, which can reduce sensitivity to individual training points. Dropout changes the forward pass during training and is outside this implementation. Early stopping selects training duration with validation data: retain the parameters from the best observed validation epoch and stop after a chosen period without improvement.

Hidden units with identical incoming weights, biases, and outgoing weights receive identical gradients under the stated updates, so they remain identical. Random initialization helps separate their starting points. Input scales can make different weight gradients very different in magnitude. A learning rate that is too high can make the loss explode; one that is too low can make progress hard to see. If a first-layer ReLU unit is negative on every training input, its data-loss gradient is zero. With plain gradient descent and no other parameter updates, such a unit cannot recover. Tanh and sigmoid can instead saturate at large preactivations, making their local derivatives 1−a21-a^2 or p(1−p)p(1-p) small.

Visualizing Multilayer Perceptrons

The training curve shows the regularized full-training-set objective after each mini-batch epoch. Since updates use different mini-batches, this monitored objective need not decrease at every epoch. The displayed decrease describes the optimization run; judging model capacity and stopping time also requires validation results.

VisualizationOut [17]
CollapseExpand
A line chart with epoch on the horizontal axis and regularized training loss on a logarithmic vertical axis. The training loss falls rapidly at first and then flattens.
Mean training binary cross-entropy plus the L2 weight penalty across mini-batch epochs, plotted on a logarithmic scale. The objective decreases quickly at first and then more slowly; this training curve alone does not establish validation performance or convergence to a global minimum.

The next two figures compare the decision boundaries of logistic regression and the trained MLP on the same standardized test data. The first shows the logistic boundary, which is constrained to be a straight line in the input space. The second shows the MLP, which can bend its boundary because the hidden layer transforms the inputs before the output layer makes its decision. On a two-moons problem, this flexibility is exactly what allows the network to represent a curved decision region.

The decision-boundary figures build a probability grid over the feature space for both models before plotting, so the plotting cells below contain presentation logic only. The logistic panel shows the strong linear constraint of logistic regression, while the MLP panel shows a curved boundary that can bend to follow the crescent shapes.

PythonIn [18]
CollapseExpand
xx, yy = np.meshgrid(
    np.linspace(Xte[:, 0].min() - 0.5, Xte[:, 0].max() + 0.5, 200),
    np.linspace(Xte[:, 1].min() - 0.5, Xte[:, 1].max() + 0.5, 200),
)
grid = np.c_[xx.ravel(), yy.ravel()]

s_grid, _ = forward(params, grid)
p_mlp_grid = sigmoid(s_grid).reshape(xx.shape)
p_logit_grid = logit.predict_proba(grid)[:, 1].reshape(xx.shape)
VisualizationOut [19]
CollapseExpand
Scatter plot of two interleaving crescent-shaped classes with a nearly straight decision boundary from logistic regression.
Decision boundary of logistic regression on the two-moons test set. Shading indicates the predicted probability of class 1, and the contour marks the 0.5 threshold. The linear constraint keeps the boundary nearly straight, so the interleaving crescents cannot be cleanly separated, which motivates the nonlinear MLP.
VisualizationOut [20]
CollapseExpand
Scatter plot of the two crescent-shaped classes with a curved MLP decision boundary that follows the crescents more closely.
Decision boundary of the one-hidden-layer MLP on the two-moons test set. The curved boundary follows the crescent shapes more closely than the linear baseline, showing how the hidden layer adds representational flexibility and can separate a curved region rather than a single straight cut.

The comparison illustrates the role of the hidden layer. The MLP boundary bends to follow the two moons more closely than the linear baseline does, which is consistent with the hidden layer providing the representational flexibility to represent a curved region on this task. This graphical observation is distinct from the earlier single-split test metrics, and it does not by itself establish that the MLP has better generalization performance than the linear baseline. The logistic model has d+1d+1 parameters and a linear log-odds surface. The MLP has h(d+1)+h+1h(d+1)+h+1 parameters and can represent a much richer family of surfaces. That added capacity is not automatically better: with too little data, too much noise, or too little regularization, the MLP can fit idiosyncrasies of the training set. A validation split can guide capacity and regularization choices when tuning; that selection step is not implemented in this example, which uses prespecified hyperparameters.

Practical Implications

An MLP is a candidate for fixed-length numeric inputs when nonlinear relationships matter. Examples include tabular prediction with interactions, classification from fixed-length sensor or signal features, and learned feature stages inside larger systems. A one-hidden-layer network is a useful nonlinear baseline to validate before adding depth. Compare it with regularized logistic regression and tree ensembles using the same validation protocol. A simple linear model or shallow tree may be easier to inspect directly; large ensembles and MLPs can require additional explanation methods. Variance and tuning burden depend on the particular fitted models and regularization.

The practical question is not whether an MLP can represent a relationship, but whether the dataset, validation budget, and deployment constraints justify its extra capacity. A network that is too small underfits nonlinear structure, while a network that is too large can memorize noise. Comparing against a linear baseline and a tree ensemble clarifies whether the added flexibility produces a real gain. If the MLP does not improve validation performance after reasonable tuning, the simpler model is usually preferable.

Best Practices

Begin with a small one-hidden-layer network, for example n_hidden between 8 and 32 for low-dimensional tabular data, and increase width only if training and validation loss both remain high. For standardized inputs and mini-batch gradient descent with tanh activations, learning rates around 0.01 to 0.1, batch sizes around 32 to 128, and L2 strengths around 1e-4 to 1e-2 are reasonable initial ranges. These are starting points rather than universal settings. Evaluate several seeds because nonconvex training can produce noticeably different solutions.

Use a validation split to select the number of epochs and the regularization strength. Training loss alone is not a model-selection criterion. If training loss falls while validation loss rises, increase lam, reduce n_hidden, or stop earlier. If both losses remain high, increase capacity or train longer, but first check that the learning rate is not too small and that the inputs are properly prepared. Tune capacity and regularization before spending time on fine-grained learning-rate schedules.

Run a gradient check on a small network before a long training run. Compare analytic and numerical gradients for every parameter block, and treat a maximum relative error much larger than 1e-4 in float64 as a signal to inspect signs, missing terms, and shapes. After the check passes, monitor both training and validation curves, retain the parameters from the best validation epoch, and report the chosen threshold or decision rule separately from the fitted weights.

Data Requirements and Preprocessing

The network expects a fixed-length numeric matrix with one row per example. Categorical variables must be encoded, missing values must be imputed or handled explicitly, and any preprocessing that learns parameters, such as scaling, imputation, or target encoding, must be fitted on the training data only and then applied unchanged to validation and test data. Binary targets are encoded as 0 and 1; multiclass targets use a softmax output and an appropriate integer or one-hot representation; regression targets use a linear output and squared error.

For each example, the hidden-weight gradient contains the corresponding input value as a factor. Holding the other gradient factors fixed, rescaling that input rescales its gradient. Features with very different ranges can produce gradients of very different magnitudes and make a single learning rate ineffective. Standardizing each feature using training-set statistics is a sound default. If outliers are extreme, robust scaling or justified winsorization may be preferable, but the transformation must remain part of the deployed preprocessing pipeline.

The amount of data needed depends on the number of parameters, the noise level, and the complexity of the target function. A one-hidden-layer network has h(d+1)+h+1h(d+1)+h+1 parameters, so increasing input dimension or hidden width adds parameters and can increase the data needed for reliable predictive performance. That effect depends on effective capacity, feature structure, noise, and regularization; parameter count alone gives no universal sample requirement. There is no universal sample-size threshold. Use validation performance and learning curves to decide whether more data, a smaller network, or stronger regularization is the appropriate response, and use stratified splits for classification when class frequencies are uneven.

Common Pitfalls

Initializing hidden units with identical incoming weights, biases, and outgoing weights preserves a symmetry under these gradient updates. The units receive identical gradients and cannot develop distinct features. Random initialization helps break that symmetry. A learning rate that is too large can also make the loss oscillate or grow, or move saturating activations into regions with very small derivatives. If the loss is unstable, inspect the learning rate before adding capacity.

Selecting a model by training loss can favor an overfit model. Training loss can improve while validation performance degrades, especially when the network is wide relative to the dataset. Keep a validation split, use early stopping, and tune regularization on validation data rather than on the test set. It is also easy to leak information by fitting a scaler, imputer, or feature selector on the full dataset before splitting; all such transformations belong inside a training-only pipeline.

Do not assume that 0.5 is the correct decision threshold for every binary problem. When classes are imbalanced or false positives and false negatives have different costs, the threshold should be chosen on validation data using the relevant metric. Finally, a passing gradient check does not guarantee good generalization; it only increases confidence that the backward pass implements the intended derivatives. Assessing model quality requires appropriate data preparation, validation, and comparison with simpler baselines.

Computational Considerations

For a mini-batch of BB examples, a forward pass through one hidden layer costs roughly O(Bdh)O(Bdh) operations, dominated by the input-to-hidden and hidden-to-output matrix products. The backward pass has the same order, and inference for a single example costs O(dh)O(dh). Training therefore scales with the number of examples, the input dimension, the hidden width, and the number of epochs. Very wide hidden layers increase both computation and the number of parameters, while larger batches can improve hardware utilization until compute or memory limits are reached. Larger batches can also reduce the number of parameter updates per epoch; the count is ⌈N/B⌉\lceil N/B\rceil, so nearby batch sizes can give the same count.

Batch activations and cached intermediates grow with the number of rows evaluated at once. Parameters and parameter gradients instead grow with input dimension and hidden width. This example forms full-training-set activations when it records the objective after each epoch, so its monitoring step is not strictly bounded by batch_size. For a large dataset, compute monitoring losses in chunks as well as training in mini-batches. Avoid retaining full-dataset activation matrices when they do not fit in memory.

Scalability also depends on preprocessing and evaluation. Transforming a large dataset repeatedly can dominate runtime if the scaler or feature pipeline is refitted unnecessarily. Fit preprocessing once on training data, cache the transformed arrays when memory permits, and evaluate on held-out data in batches. If training time becomes a constraint, reduce the number of epochs with early stopping, tune the batch size, or reduce the hidden width before adding more complex optimization machinery.

Performance and Deployment Considerations

Evaluate probabilistic classifiers with more than accuracy when probabilities matter. Log loss measures the quality of predicted probabilities, while accuracy, precision, recall, and ROC AUC emphasize different aspects of classification performance. For imbalanced data, precision-recall curves and the chosen decision threshold can be more informative than accuracy alone. Performance should be judged relative to a simple baseline on the same validation or test split; an MLP that matches a logistic regression model with much greater complexity may not be worth deploying.

A deployed model consists of more than its weight matrices. Save the fitted scaler or preprocessing object, the weight arrays, the architecture and activation choices, the threshold or decision rule, and the software versions used to produce them. Apply exactly the same preprocessing at inference time that was used during training. Monitor input distributions and predictive performance after deployment, because drift in features or class balance can degrade a model even when its code has not changed.

Use a held-out test set once for a final estimate, not for repeated tuning. Choose the stopping epoch and threshold on validation data, and report the corresponding test metrics with the preprocessing and evaluation protocol. For production systems, batch inference can improve throughput, while latency-sensitive applications may require a smaller network or additional optimization. Evidence of drift or degraded predictive performance should trigger investigation. Retrain when the change is relevant to the task and an updated model passes independent evaluation, including the appropriate gradient, validation, and baseline-comparison checks.

Summary

A multilayer perceptron composes affine transformations with nonlinear activations. The hidden layer creates a learned representation, and the output layer maps that representation to a prediction. For binary classification, a sigmoid output with binary cross-entropy gives a stable loss when written in terms of the logit as logaddexp⁡(0,s)−ys\operatorname{logaddexp}(0,s)-ys.

Backpropagation is the chain rule applied backward through the network. The gradient at the output logit is predicted probability minus label. The output weights receive the hidden activations multiplied by that gradient, and the hidden preactivations receive the output gradient multiplied by the output weights and the activation derivative. Matrix notation makes the implementation compact: matrix products express weighted sums, while outer and elementwise products combine local gradient factors.

We verified the gradients with finite differences, trained a one-hidden-layer network with mini-batches and L2 regularization, and compared its decision boundary with logistic regression. The MLP can represent curved boundaries that a linear model cannot, while introducing nonconvex optimization and additional architectural and training choices. Input scaling and initialization require care. The held-out comparison evaluates this fitted model on new examples, while the gradient check tests its derivative implementation.

Quiz

Check the affine-layer collapse, stable binary loss, and backpropagation updates.

Multilayer Perceptrons and Backpropagation

Question 1 of 70 of 7 completed
What happens if two affine layers are stacked without a nonlinear activation between them?
Join the discussionAsk a question or share what you learned.

Comments

Add to the conversation

A question, a correction, or an example from your own work.

No comments yet. Be the first to share your thoughts!

A different angle
All writing
Machine Learning

Linear and Quadratic Discriminant Analysis

Build LDA and QDA classifiers from Gaussian class models and Bayes' rule, with covariance estimation, shrinkage, and NumPy implementations.

59 min read
Machine Learning

Naive Bayes

Learn how naive Bayes classifiers work: Bayes decision rule, Gaussian and multinomial event models, smoothing, log-sum-exp, and NumPy implementation.

64 min read
Machine Learning

k-Nearest Neighbors

Build kNN from scratch, covering distance metrics, majority voting, local averaging, feature scaling, distance weighting, and the curse of dimensionality.

56 min read

About the author

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2026multilayerperceptrons, author = {Michael Brenndoerfer}, title = {Multilayer Perceptrons and Backpropagation}, year = {2026}, url = {https://mbrenndoerfer.com/writing/multilayer-perceptrons-backpropagation-from-scratch}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-10-30} }
APAAcademic
Michael Brenndoerfer (2026). Multilayer Perceptrons and Backpropagation. Retrieved from https://mbrenndoerfer.com/writing/multilayer-perceptrons-backpropagation-from-scratch
MLAAcademic
Michael Brenndoerfer. "Multilayer Perceptrons and Backpropagation." 2026. Web. October 30, 2026. <https://mbrenndoerfer.com/writing/multilayer-perceptrons-backpropagation-from-scratch>.
CHICAGOAcademic
Michael Brenndoerfer. "Multilayer Perceptrons and Backpropagation." Accessed October 30, 2026. https://mbrenndoerfer.com/writing/multilayer-perceptrons-backpropagation-from-scratch.
HARVARDAcademic
Michael Brenndoerfer (2026) 'Multilayer Perceptrons and Backpropagation'. Available at: https://mbrenndoerfer.com/writing/multilayer-perceptrons-backpropagation-from-scratch (Accessed: October 30, 2026).
SimpleBasic
Michael Brenndoerfer (2026). Multilayer Perceptrons and Backpropagation. https://mbrenndoerfer.com/writing/multilayer-perceptrons-backpropagation-from-scratch
Newsletter

Stay up to date

Get new articles, book updates, and notes on AI and technology.

Unsubscribe at any time. Privacy policy