Multilayer Perceptrons and Backpropagation › Implementation in NumPy and Scikit-learn
Machine Learning from ScratchAll chapters Multilayer Perceptrons and Backpropagation › Implementation in NumPy and Scikit-learnSection view · Sections in this chapter6 / 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
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 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]Expand
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 StandardScalerPythonIn [8]Expand
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 . The loss function uses the stable logit form. The backward pass implements the equations derived above, including the division by the batch size 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]Expand
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, gradsWe 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]Expand
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]Expand
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]Expand
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]Expand

After checking the small-network derivatives, we fit the one-hidden-layer model on the training partition using the prespecified hyperparameters below.
PythonIn [14]Expand
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]Expand
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 for the first layer and 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 training examples and a positive integer maximum batch size , this loop makes updates per epoch. The ceiling rounds upward to an integer. Reducing 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 or small.
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!