What it actually does
A single linear layer can only draw a straight boundary. Put two of them back to back with a nonlinearity in between and the first layer can bend the space before the second one cuts it — which is enough to separate things no straight line could. Stack more and each layer works on features the previous one built rather than on the raw input. Training runs the network forward to see how wrong it is, then walks the error backwards through the layers, and at each step the chain rule says how much each weight contributed. Backpropagation is exactly that: the chain rule, with the shared intermediate results computed once instead of once per weight.
- The mapping from inputs to targets is a continuous function that a composition of linear maps and nonlinearities can approximate — which the universal approximation theorem guarantees, while saying nothing about how wide, how learnable, or how well it generalizes
- Enough data to fit a model with no structural prior; the flexibility that makes it general is what makes it data-hungry
- Inputs are scaled comparably, since the network is entirely sensitive to feature scale
- Training and deployment inputs come from the same distribution — outside it the output is arbitrary rather than approximately right
- Examples are independent and identically distributed, which shuffled mini-batches assume and time series violate
Loss function
Average the loss over the data and add a price on weight size. The composition beneath it is where the content is: alternating linear maps and elementwise nonlinearities, and the nonlinearity is load-bearing in a way that is easy to miss — without it, a product of matrices is a matrix, and a hundred layers compute exactly what one does. The objective is non-convex, so there is no unique solution and no guarantee of finding the best one. That sounds worse than it is: in high dimensions almost all critical points turn out to be saddles rather than poor local minima, which is a large part of why training deep networks works at all.
- the nonlinearity — remove it and the whole stack collapses to a single linear layer, which is the entire reason it is there
- weights and bias of layer l; every input connects to every unit, which is what "no structural prior" means
- cross-entropy for classification, squared error for regression — the architecture does not change with it
- weight decay; one of several regularizers, and the least effective of them on its own
How it is minimized
Mini-batch stochastic gradient descent with backpropagation; Adam or SGD with momentum in practice
- the error signal at layer l — how much the loss changes with that layer’s pre-activation
- the error propagated backwards through the next layer’s weights; this is the whole of backpropagation
- elementwise product with the activation derivative — the gate that stops the signal where the unit was saturated
- the activation coming in, saved during the forward pass because the backward pass needs it
Backpropagation is the chain rule with the shared intermediate results computed once instead of once per parameter, which is what makes gradients affordable — reverse-mode automatic differentiation costs roughly one forward pass regardless of how many parameters there are. The forward activations must be kept for the backward pass, and that memory, not the arithmetic, is what usually limits batch size. Three choices matter more than the optimizer. The activation function: ReLU replaced sigmoid because a saturated sigmoid has a near-zero derivative and the delta above is multiplied by it at every layer, so the signal vanishes with depth. Initialization: scaling the initial weights by fan-in — Xavier or He — is what keeps activations from exploding or collapsing through the stack, and getting it wrong makes a deep network untrainable rather than merely slow. And normalization between layers, which flattens the loss surface enough that a larger learning rate becomes usable.
depth and width
The capacity dial. Depth is exponentially more parameter-efficient than width for some functions, and also harder to optimize (2 to 5 hidden layers for tabular data; wider tends to be safer than deeper here)
learning rate
The single most consequential setting. Too large diverges, too small never arrives, and the usable range spans orders of magnitude (1e-4 to 1e-2 with Adam, higher with SGD plus momentum and a schedule)
batch size
Gradient noise versus throughput. Small batches regularize and large ones use hardware better, and the interaction with the learning rate is strong (32 to 1024; scale the learning rate with it)
activation
ReLU by default, with GELU or SiLU in transformers. The choice is about gradient flow rather than expressiveness
initialization
He for ReLU, Xavier for tanh. Not a refinement — the wrong scale makes a deep network untrainable from the first step
regularization
Weight decay, dropout, early stopping. Early stopping is usually the most effective and the least discussed
Non-convex, so nothing is guaranteed, and in practice it usually works — the reason being that in high dimensions the critical points a gradient method encounters are overwhelmingly saddles rather than bad minima, and stochastic gradients escape saddles readily. The named failure modes are about gradient flow rather than optimality. Vanishing gradients: with a saturating activation the delta is multiplied by a small derivative at every layer, so deep stacks receive nothing at the front — the problem ReLU and residual connections exist to solve. Exploding gradients: the same product in the other direction, addressed by clipping. Dead ReLUs: a unit pushed permanently negative has zero gradient for ever and never recovers. And a learning rate slightly too high produces a loss that decreases and then diverges, often after appearing to train fine for a while.
Forward: O(batch · sum of layer products), which is a sequence of matrix multiplications and is what GPUs exist for. Backward: roughly twice the forward cost. Memory: O(batch · sum of layer widths) for the stored activations, and that is what caps batch size rather than parameter count. Inference is one forward pass and a small fraction of training cost per example, which is why a model expensive to train can be cheap to serve.
How, where, and why it is used
Time-Series Forecasting
ViableThe same lagged supervised table any regressor would use, fed to a dense network. It learns interactions between lags and drivers without being told they exist, and a single network can be trained across many series at once — a global model — which is how the recent forecasting competitions were won, though usually by architectures with more structure than this one.
- Global models trained across thousands of series, where cross-series learning is worth more than per-series tailoring
- Multi-horizon forecasting with a vector output, producing all horizons in one pass rather than recursing
- The neural baseline that shows whether the extra structure of a recurrent or attention model is earning anything
- Problems with many exogenous drivers whose interactions matter
Its advantages here are cross-series learning and interaction discovery, both real. Its disadvantage is the one that defines the whole entry: no structural prior, so nothing about it knows that inputs are ordered in time. Lag features supply that ordering by hand, and an architecture that encodes it — a convolution, a recurrence, attention — gets the same information for far fewer parameters. On a single ordinary series with a few hundred observations this loses to exponential smoothing outright, and the honest reason is sample size rather than architecture.
- Scale inputs and targets: unlike a tree, a dense network is entirely sensitive to feature scale and will not train well without it
- Detrend or difference, since the network extrapolates a fitted shape rather than a trend and does so unpredictably
- Encode calendar structure explicitly — cyclical sine and cosine features rather than raw indices, which the network would otherwise have to learn from scratch
- Train across series with a series embedding, which is what makes the global approach outperform per-series fits
Rolling-origin backtesting with MASE against seasonal-naive, and against exponential smoothing specifically — the neural model must beat the simple one to justify its cost, and on many business series it does not.
- Shuffled validation splits, which leak the future and make every number meaningless
- Scaling statistics computed on the full series before splitting — the most common leak in neural forecasting
- Extrapolation: the network has no notion of trend and its behaviour outside the training range is arbitrary rather than merely wrong
- Comparing against a weak baseline, which is how most neural forecasting results are made to look good
Anomaly Detection
Not applicableA supervised dense network needs labelled examples of both classes and has no notion of density or reconstruction; using one for anomaly detection means either ordinary rare-class classification or the autoencoder, which is a different architecture with a different objective.
Optimization
AdaptedTwo roles, neither of them as a solver. As a differentiable surrogate: when the true objective is expensive to evaluate — a simulation, a physical experiment — fit a network to the evaluations you have and optimize the network instead, using its gradients directly. And as a learned heuristic: train a network to propose good starting points or branching decisions for a classical solver, which keeps the solver’s guarantees while shortening its search.
- Surrogate modelling for expensive simulations, where gradients through the surrogate replace derivative-free search
- Learned heuristics inside branch-and-bound and routing solvers, where the network proposes and the solver verifies
- Differentiable relaxations of discrete decisions, which make an end-to-end gradient possible where the true problem has none
- Amortized optimization: train once to map a problem instance directly to a solution, trading exactness for microsecond inference
A network can approximate an objective that has no closed form and supply gradients for it, which is genuinely useful when evaluation is the bottleneck. What it fundamentally cannot do is guarantee constraint satisfaction — there is no mechanism in a dense network that enforces a hard constraint, and a proposed solution can be infeasible with no indication. That is why in any setting where feasibility is non-negotiable it belongs inside the loop, proposing and being checked, rather than replacing the solver. When the objective is cheap to evaluate, this apparatus buys nothing over direct search.
- Scale decision variables to a common range, since the network is sensitive to input scale in a way a solver is not
- Encode constraints as inputs or penalties, and verify feasibility outside the network — a penalty discourages violation and does not prevent it
- Train on the distribution of instances actually encountered; a surrogate is only valid where it was fitted, and optimizers seek out exactly the regions where it is extrapolating
Evaluate the decision against a classical solver on the same instances — objective value achieved and feasibility rate, not surrogate accuracy. A surrogate with low error that leads the optimizer into an infeasible region has failed at the only thing that mattered.
- The optimizer exploiting the surrogate where it is wrong, which is not an edge case but the expected behaviour of an optimizer
- Treating penalty terms as constraints, so a returned solution is infeasible and nothing says so
- Extrapolation outside the training distribution, where a dense network’s output is arbitrary rather than approximately right
Recommendation & Ranking
Primary fitThe dense stack that sits on top of learned embeddings. Categorical identifiers — user, item, context — become embedding vectors, those are concatenated with dense features, and an MLP maps the result to a score. That is the shape of essentially every deep recommender: the embeddings carry the identities and the dense layers learn the interactions between them.
- The scoring tower of a two-stage recommender, where a ranker refines candidates from a cheaper retrieval step
- Click-through and conversion prediction, where feature interactions carry most of the signal
- Deep learning recommendation models, where the MLP is the interaction layer over embedded features
- Multi-task heads that predict several objectives — click, purchase, dwell — from a shared representation
It is the natural fit because the problem is genuinely about interactions between heterogeneous features, and that is exactly what a dense stack does without being told which interactions to look for. Embeddings also solve the cold-ish-start problem matrix factorization cannot: a new item with content features gets a representation immediately. The costs are the usual ones and they bite here — inference latency inside a request budget, embedding tables that dominate model size, and popularity bias amplified through the training data, which no architecture fixes.
- Embed high-cardinality identifiers rather than one-hot encoding them, which is the difference between a tractable model and an impossible one
- Hash rare identifiers into shared buckets so the embedding table stays bounded as the catalogue grows
- Normalize dense features; embeddings and raw counts on the same input layer train badly together
- Include explicit crossed features where a specific interaction is known to matter, rather than relying on the network to find it
AUC or log-loss for the prediction and NDCG or recall@k for the ranking, on a temporal split. Watch catalogue coverage alongside — a model that improves engagement by narrowing what it shows is not the improvement it appears to be.
- Random rather than temporal splits, which leak future interactions and inflate every offline number
- Embedding tables dominating model size, so serving cost is a memory problem rather than a compute one
- Feedback loops: training on logged data teaches the model what the previous model showed, and offline metrics cannot detect it
Causal Inference & Uplift
AdaptedAs the nuisance-function estimator inside a doubly-robust or orthogonalized design. Double machine learning needs a model of the outcome given covariates and a model of the treatment given covariates; both are pure prediction problems where flexibility helps, and a dense network is a reasonable choice when the covariates are high-dimensional or unstructured — text, images, long behavioural histories.
- Nuisance-function estimation inside double machine learning, where flexibility is wanted and the identification comes from elsewhere
- Representation learning over unstructured confounders that a linear adjustment cannot use at all
- Heterogeneous treatment-effect networks with a shared trunk and separate outcome heads
It earns a place only inside a design that keeps it away from the estimand. Flexibility is genuinely useful for the nuisance functions — a better model of the outcome given covariates removes more confounding — but the identification argument comes from the design, and the network only estimates a component of it. Used directly to predict outcomes and differencing the predictions is the error the causal ML literature exists to correct: an accurate predictor of the outcome is not an estimator of the effect, and a flexible one is frequently worse because it absorbs the treatment variation the estimate depends on.
- Cross-fitting is mandatory: fit the nuisance functions on one fold and evaluate on another, or the estimate inherits the network’s overfitting as bias
- Never regularize or shrink the treatment coefficient itself; the penalty that helps prediction destroys the estimand
- Exclude post-treatment variables, which bias the estimate no matter how much they improve the fit
Interval coverage under simulation with a known effect, not predictive accuracy. The nuisance models are judged by whether the resulting effect estimate has coverage, and a network that predicts the outcome better while degrading coverage has made things worse.
- Skipping cross-fitting, which is the single most common way a flexible nuisance model corrupts the estimate
- Reading differences in predicted outcomes as treatment effects, which conflates prognosis with response
- Using a network where a linear model suffices, adding variance to the effect estimate for no gain in identification
What it costs to run
A sequence of matrix multiplications per batch, which is what GPUs are built for — minutes to hours depending on scale. The memory ceiling is the stored forward activations rather than the parameters, and that is what caps batch size in practice.
One forward pass: a few matrix-vector products, sub-millisecond on CPU for a modest network and batchable to far better throughput on a GPU. Model size is the parameter count, which for a dense network with embedding tables is dominated by the tables rather than the layers.
Periodic and full — there is no exact incremental update, though warm-starting from the previous weights is standard and cuts training time substantially. In recommendation the cadence is usually daily, driven by catalogue and behaviour drift rather than by compute.
- Track the input feature distributions, since a dense network extrapolates arbitrarily and a shifted input produces a confident, meaningless output
- Monitor prediction distribution rather than accuracy alone, because a drifting output distribution moves before labels arrive to reveal it
- Watch calibration explicitly: a network’s confidence and its accuracy diverge as it drifts, and modern networks are systematically overconfident to begin with
- For embedding models, track the share of requests hitting unseen or hashed-bucket identifiers, which is where the representation is weakest
- Scaling statistics are part of the model and must be persisted; recomputing them at inference silently changes every prediction
- Training and inference must use the same behaviour for dropout and normalization layers — leaving a model in training mode is a classic and completely silent bug
- Softmax outputs are not calibrated probabilities: modern networks are systematically overconfident, and anything thresholding them on cost needs temperature scaling fitted on held-out data
- Non-determinism from GPU reductions and data ordering means two runs on the same data differ; reproducibility requires pinning seeds and, on some hardware, deterministic kernels
- The network extrapolates without complaint outside the training range, producing confident output where it has no information — input range checks are a deployment requirement rather than a nicety
Pros and cons, in context
Universal approximation: no functional form has to be specified
Given enough width it can represent any continuous function on a compact set. The theorem is often over-read — it says nothing about how many units, whether gradient descent finds them, or whether the result generalizes — but the flexibility is real and is why this is the default when the structure is unknown.
Learns feature interactions without being told which
The reason it works on heterogeneous tabular inputs and is the interaction layer in every deep recommender. Worth much less when the interactions are few and known, where writing them into a linear model is cheaper and more interpretable.
Composes with everything else in the category
It is the head on a CNN, the feedforward block in a transformer, the policy network in RL. Learning it once pays off across every later entry, which is why it sits in foundations.
Gradients cost about one forward pass regardless of parameter count
Reverse-mode differentiation is what makes training a billion parameters feasible at all. The cost is memory for the stored activations, which is a real and often binding constraint.
No structural prior, so it needs far more data than a model that has one
A CNN gets translation invariance for free; this has to learn it from examples. That is the whole trade of the category — every later architecture is this with an assumption added, and the assumption is what buys the sample efficiency.
Loses to gradient boosting on tabular data
Repeatedly and by a clear margin on typical business datasets, which is now a well-replicated result rather than folklore. Reaching for a network on a tabular problem should require an argument, not a default.
Non-convex, stochastic, and hard to reproduce exactly
Different seeds give different models; GPU reductions are non-deterministic; the learning rate spans orders of magnitude. Manageable, and a real cost in audited settings where a linear model’s exact reproducibility is worth more than accuracy.
Confidently wrong outside the training distribution
It extrapolates arbitrarily and reports high confidence while doing so. Combined with systematic overconfidence even in-distribution, this makes calibration and input-range checking deployment requirements rather than refinements.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""A two-layer network - forward and backward, transcribed.
Backpropagation is the chain rule with the shared intermediates computed once
instead of once per weight. Every loop below is one factor of that chain,
written out so the dependency is visible.
"""
import math
import random
def sigmoid(z):
return 1.0 / (1.0 + math.exp(-z)) if z >= 0 else math.exp(z) / (1.0 + math.exp(z))
def fit(X, y, hidden, lr=0.1, epochs=1000, seed=0):
"""One hidden layer, sigmoid activation, squared error."""
rng = random.Random(seed)
n = len(X)
d = len(X[0])
# Small random init, NOT zeros: with identical weights every hidden unit
# computes the same thing and receives the same gradient forever, so the
# layer never differentiates. Symmetry breaking is why this is random.
W1 = [[rng.gauss(0.0, 0.1) for _ in range(hidden)] for _ in range(d)]
b1 = [0.0] * hidden
W2 = [rng.gauss(0.0, 0.1) for _ in range(hidden)]
b2 = 0.0
for _ in range(epochs):
for i in range(n):
# ---- forward ----------------------------------------------
z1 = [0.0] * hidden
a1 = [0.0] * hidden
for h in range(hidden):
total = b1[h]
for j in range(d):
total += X[i][j] * W1[j][h]
z1[h] = total
a1[h] = sigmoid(total)
z2 = b2
for h in range(hidden):
z2 += a1[h] * W2[h]
prediction = z2
# ---- backward ---------------------------------------------
# delta at the output: dL/dz2 for squared error is 2 * error.
delta2 = 2.0 * (prediction - y[i])
# delta at the hidden layer: propagate through W2, then multiply
# by the activation derivative. That elementwise product is the
# gate - a saturated unit has derivative near zero and passes
# nothing back, which is the vanishing-gradient problem in
# miniature.
delta1 = [0.0] * hidden
for h in range(hidden):
delta1[h] = delta2 * W2[h] * a1[h] * (1.0 - a1[h])
# ---- update ------------------------------------------------
for h in range(hidden):
W2[h] -= lr * delta2 * a1[h]
b2 -= lr * delta2
for j in range(d):
for h in range(hidden):
W1[j][h] -= lr * delta1[h] * X[i][j]
for h in range(hidden):
b1[h] -= lr * delta1[h]
return W1, b1, W2, b2
def predict(W1, b1, W2, b2, x):
hidden = len(b1)
total = b2
for h in range(hidden):
z = b1[h]
for j in range(len(x)):
z += x[j] * W1[j][h]
total += sigmoid(z) * W2[h]
return totalO(n * d * hidden) per epoch in interpreter loops, one example at a time, with sigmoid activations that saturate and stall the gradient.
How this language actually wants it written.
"""An MLP - typed layers, He init, ReLU, fused softmax cross-entropy."""
from dataclasses import dataclass, field
import numpy as np
from numpy.typing import NDArray
Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
@dataclass
class DenseLayer:
"""One affine map plus a nonlinearity, with its own backward pass."""
weights: Matrix
bias: Vector
_inputs: Matrix | None = field(default=None, init=False, repr=False)
_pre_activation: Matrix | None = field(default=None, init=False, repr=False)
@classmethod
def he_initialized(cls, fan_in: int, fan_out: int, rng: np.random.Generator) -> "DenseLayer":
"""Scale by sqrt(2 / fan_in) - He initialization for ReLU.
Not a refinement. With the wrong scale the activation variance grows
or shrinks by a constant factor at every layer, so a deep stack either
saturates or collapses to zero before the first gradient arrives.
Getting this wrong makes a network untrainable rather than slow.
"""
scale = np.sqrt(2.0 / fan_in)
return cls(
weights=rng.normal(0.0, scale, size=(fan_in, fan_out)),
bias=np.zeros(fan_out, dtype=np.float64),
)
def forward(self, inputs: Matrix) -> Matrix:
if inputs.ndim != 2 or inputs.shape[1] != self.weights.shape[0]:
raise ValueError(
f"expected (batch, {self.weights.shape[0]}) input, got {inputs.shape}"
)
# Saved for the backward pass: the gradient of the weights needs the
# incoming activation, and this is what makes activation memory - not
# parameter count - the thing that caps batch size.
self._inputs = inputs
self._pre_activation = inputs @ self.weights + self.bias
return np.maximum(self._pre_activation, 0.0) # ReLU
def backward(self, upstream: Matrix, learning_rate: float) -> Matrix:
if self._inputs is None or self._pre_activation is None:
raise RuntimeError("backward called before forward")
# The ReLU derivative is a gate: pass the signal where the unit was
# active, block it where it was not. Unlike sigmoid it does not shrink
# the signal, which is why deep stacks became trainable.
delta = upstream * (self._pre_activation > 0.0)
grad_weights = self._inputs.T @ delta
grad_bias = delta.sum(axis=0)
downstream = delta @ self.weights.T
self.weights -= learning_rate * grad_weights
self.bias -= learning_rate * grad_bias
return downstream
def softmax_cross_entropy(logits: Matrix, labels: NDArray) -> tuple[float, Matrix]:
"""Loss and its gradient, computed together and stably.
Two things matter here. The max is subtracted before exponentiating,
because exp of a large logit overflows and the naive version silently
produces NaN. And the gradient of the fused pair is simply
(softmax - one_hot) - the Jacobian of the softmax and the derivative of
the log cancel exactly, so computing them separately does more work and
is less stable.
"""
shifted = logits - logits.max(axis=1, keepdims=True)
exponentials = np.exp(shifted)
partition = exponentials.sum(axis=1, keepdims=True)
log_probabilities = shifted - np.log(partition)
rows = np.arange(labels.size)
loss = float(-log_probabilities[rows, labels].mean())
gradient = exponentials / partition
gradient[rows, labels] -= 1.0
return loss, gradient / labels.size
def fit(
X: Matrix,
y: NDArray,
hidden_sizes: list[int],
n_classes: int,
learning_rate: float = 0.01,
epochs: int = 50,
batch_size: int = 64,
seed: int = 0,
) -> list[DenseLayer]:
"""Train by mini-batch SGD. Raises ValueError on malformed input."""
if X.ndim != 2:
raise ValueError(f"X must be 2-D, got shape {X.shape}")
if X.shape[0] != y.size:
raise ValueError(f"X has {X.shape[0]} rows but y has {y.size}")
if batch_size < 1:
raise ValueError(f"batch_size must be at least 1, got {batch_size}")
rng = np.random.default_rng(seed)
sizes = [X.shape[1], *hidden_sizes, n_classes]
layers = [
DenseLayer.he_initialized(sizes[i], sizes[i + 1], rng)
for i in range(len(sizes) - 1)
]
for _ in range(epochs):
order = rng.permutation(X.shape[0])
for start in range(0, X.shape[0], batch_size):
batch = order[start : start + batch_size]
activations = X[batch]
for layer in layers:
activations = layer.forward(activations)
_, gradient = softmax_cross_entropy(activations, y[batch])
for layer in reversed(layers):
gradient = layer.backward(gradient, learning_rate)
return layersWhat changed: Three changes, and only one is about vectorization. The activation moves from sigmoid to ReLU, which is what made deep stacks trainable: a saturated sigmoid has a near-zero derivative and the backward signal is multiplied by it at every layer, so it vanishes with depth. Initialization becomes He scaling by fan-in, which is not a refinement — with the wrong scale the activation variance compounds by a constant factor per layer and a deep network is untrainable from the first step. And softmax and cross-entropy are fused, because their derivatives cancel exactly to (softmax − one-hot), which is both less work and numerically stable where computing them separately overflows.
O(batch * sum of layer products) per step, executed as BLAS matrix products rather than interpreter loops.
Faster, and the justification for each change.
"""An MLP - preallocated activations, in-place ReLU, Adam in place."""
import numpy as np
from numpy.typing import NDArray
Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]
class FusedMlp:
"""Every buffer allocated once for the whole run.
Training allocates the same shapes thousands of times: activations,
gradients, and one moment estimate per parameter. At a fixed batch size
every one of those shapes is known up front, so they are allocated once
and rewritten - which removes the allocator from the inner loop entirely
and keeps the working set resident.
Activation memory, not parameter count, is what caps batch size in a real
network. Preallocating makes that ceiling explicit rather than discovering
it as an out-of-memory error mid-epoch.
"""
def __init__(self, sizes: list[int], batch_size: int, seed: int = 0) -> None:
rng = np.random.default_rng(seed)
self._sizes = sizes
self._batch = batch_size
# C-contiguous float32: half the memory traffic of float64, and every
# GEMM reads the arrays directly rather than through a copy. Training
# is memory-bound far more often than precision-bound.
self._weights = [
np.ascontiguousarray(
rng.normal(0.0, np.sqrt(2.0 / sizes[i]), size=(sizes[i], sizes[i + 1])),
dtype=np.float32,
)
for i in range(len(sizes) - 1)
]
self._biases = [np.zeros(sizes[i + 1], dtype=np.float32) for i in range(len(sizes) - 1)]
# Adam moments, allocated once alongside the parameters.
self._m = [np.zeros_like(w) for w in self._weights]
self._v = [np.zeros_like(w) for w in self._weights]
self._step = 0
# Forward activations and backward deltas, one buffer per layer.
self._activations = [
np.empty((batch_size, size), dtype=np.float32) for size in sizes
]
self._deltas = [
np.empty((batch_size, size), dtype=np.float32) for size in sizes[1:]
]
def forward(self, batch: Matrix) -> Matrix:
np.copyto(self._activations[0], batch)
for index, (weights, bias) in enumerate(zip(self._weights, self._biases)):
# matmul straight into the preallocated buffer - no temporary per
# layer per step, which across an epoch is thousands of arrays.
np.matmul(self._activations[index], weights, out=self._activations[index + 1])
self._activations[index + 1] += bias
if index < len(self._weights) - 1:
# ReLU in place: the output buffer IS the input buffer.
np.maximum(self._activations[index + 1], 0.0, out=self._activations[index + 1])
return self._activations[-1]
def backward(self, gradient: Matrix, learning_rate: float,
beta1: float = 0.9, beta2: float = 0.999, eps: float = 1e-8) -> None:
self._step += 1
np.copyto(self._deltas[-1], gradient)
for index in reversed(range(len(self._weights))):
delta = self._deltas[index]
if index < len(self._weights) - 1:
# ReLU gate applied in place against the saved pre-activation.
np.multiply(delta, self._activations[index + 1] > 0.0, out=delta)
grad_weights = self._activations[index].T @ delta
grad_bias = delta.sum(axis=0)
if index > 0:
np.matmul(delta, self._weights[index].T, out=self._deltas[index - 1])
# Adam, entirely in place: three full-size temporaries per
# parameter tensor per step would otherwise be allocated, and with
# a large model that is the dominant allocation in training.
m, v = self._m[index], self._v[index]
m *= beta1
m += (1.0 - beta1) * grad_weights
v *= beta2
v += (1.0 - beta2) * (grad_weights * grad_weights)
bias1 = 1.0 - beta1**self._step
bias2 = 1.0 - beta2**self._step
self._weights[index] -= learning_rate * (m / bias1) / (np.sqrt(v / bias2) + eps)
self._biases[index] -= learning_rate * grad_biasWhat changed: Training allocates the same shapes thousands of times — activations, deltas, and three temporaries per parameter tensor per Adam step — and at a fixed batch size every shape is known up front. Allocating them once and writing through them removes the allocator from the inner loop entirely and keeps the working set resident, which matters because training is memory-bound far more often than it is arithmetic-bound. The ReLU and the Adam update both run in place for the same reason. Precision drops to float32, which halves the memory traffic on every one of those buffers, and the arrays are pinned C-contiguous so each GEMM reads them without an internal copy.
Pre-allocate output arrays and use in-place operations
Activation, delta and Adam-moment buffers are allocated once for the whole run and rewritten each step, rather than allocating several full-size arrays per layer per step.
Trade-off: Fixes the batch size at construction — a final partial batch needs separate handling — and the buffers are shared mutable state, so the object cannot be used from two threads or reentered.
Ensure contiguous memory layout and a single dtype
C-contiguous float32 halves the traffic on every buffer and lets each GEMM read the arrays directly instead of through an internal conversion or copy.
Trade-off: float32 accumulates rounding error over long training runs, and some losses genuinely need float64 accumulation — the usual answer is mixed precision, which is more machinery than this shows.
Fuse adjacent operations so intermediates are never materialized
The ReLU writes into its own input buffer and the Adam update composes in place, so neither the activation nor the three moment intermediates ever exist as separate arrays.
Trade-off: The in-place chain is order-dependent and destroys the pre-activation the backward pass needs unless the gate is applied against the right buffer — a reordering here is a silent wrong gradient rather than an error.
O(batch * sum of layer products) per step with one round of allocation for the whole run. Illustrative, not a measured benchmark.