Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Suppose CodeAssist receives a code-completion request with a prompt-size score of 6 and an active queue-wait score of 8. A quiet request has values 2 and 3. We want a scoring function that flags the first request as high risk before worker threads time out. Raw numbers don't make an operational decision on their own, though. What computation should sit between those incoming features and a routing decision?
You already have the ingredients for a neural network: feature vectors and dot products from the vectors and matrices lesson. A network arranges those computations into layers, then uses learned weights and biases to turn inputs into outputs. We'll freeze the weights first so you can check the arithmetic before training starts changing them.
One neuron scores a single request
Start with one scoring unit, or neuron. Multiply each input feature by its own weight, then add their contributions together. Both inputs use illustrative zero-to-ten scales; raw token counts and seconds would have different units and ranges. Before reading the sum, check which feature contributes more:
| Feature | Value | Chosen weight | Contribution |
|---|---|---|---|
| Prompt-size score | 6 | 0.7 | 4.2 |
| Queue-wait score | 8 | 0.3 | 2.4 |
The contributions total 6.6. Now add a bias, an offset of -4.0, to get 2.6. This weighted sum plus bias is an affine map. A purely linear map satisfies , which forces . Without bias, zero input features give a weighted sum of zero before activation, which sigmoid would map to 0.5. The bias lets the model change that baseline and shift the decision boundary away from the origin.
In vector notation, the feature vector holds [6, 8], the weight vector holds [0.7, 0.3], and :
The superscript denotes a transpose, making the dot product. An activation function transforms the resulting pre-activation . For this binary risk scorer, pass through the sigmoid function, which maps 2.6 to roughly 0.931:
Here is Euler's constant, roughly 2.718. Before entering sigmoid, is called a logit. Hidden-layer pre-activations aren't generally logits because they don't feed directly into a logistic probability mapping. Mathematically, sigmoid stays strictly between zero and one for finite inputs. Floating-point implementations can round extreme outputs to zero or one; we'll address that numerical distinction below.
These hand-chosen parameters make the score 0.931. A production probability claim needs calibration evidence on representative, verified request outcomes; training alone leaves calibration unmeasured.[1]
Compute this scoring path once with Python's standard library. The snippet below prints the logit and sigmoid output separately so you can verify each arithmetic step:
from math import exp
prompt_size = 6.0
queue_wait = 8.0
w_prompt, w_queue, b = 0.7, 0.3, -4.0
z = prompt_size * w_prompt + queue_wait * w_queue + b
score = 1.0 / (1.0 + exp(-z))
print(f"logit: {z:.3f}")
print(f"sigmoid score: {score:.3f}")
assert abs(z - 2.6) < 1e-9logit: 2.600
sigmoid score: 0.931
A bias shifts the intervention threshold without altering feature values. Keep the same weights and compare a quiet request with our high-load request. Notice how each logit changes while the weights stay fixed:
from math import exp
w_prompt, w_queue, b = 0.7, 0.3, -4.0
requests = {
"quiet": (2.0, 3.0),
"high load": (6.0, 8.0),
}
for name, (prompt_size, queue_wait) in requests.items():
z = prompt_size * w_prompt + queue_wait * w_queue + b
score = 1.0 / (1.0 + exp(-z))
print(f"{name:9s} logit={z:5.2f} score={score:.3f}")quiet logit=-1.70 score=0.154
high load logit= 2.60 score=0.931At a score threshold of 0.5, the high-load request crosses the alert line while the quiet request stays below it. Equivalently, compare each logit with zero. The bias subtracts the exact same amount from both logits, shifting the decision boundary without touching feature weights.
Why shouldn't you treat 0.931 as a verified timeout probability in production?
Answer
These parameters are hand-chosen to demonstrate the forward pass. Sigmoid bounds the output between zero and one. To interpret a score near 0.93 as roughly a 93% timeout rate, compare predictions with observed outcomes.
Linear maps collapse without non-linearities
One neuron scores a weighted sum. Why can't a stack of linear layers do anything that one layer cannot?
Consider a scalar example: the first layer doubles an input and adds 1; the second triples that result and subtracts 2. Starting from 4, the first layer gives 2(4) + 1 = 9. The second gives 3(9) - 2 = 25. Notice that multiplying 4 directly by 6 and adding 1 also gives 25. For any input , . The two layers collapsed into one.
The exact same collapse happens with vectors and matrices. Let be a hidden vector. The first layer applies weights and biases ; the second applies and :
Now substitute the expression for directly into the output equation:
Define and . The two layers collapse into:
The merged map remains affine: stacking affine layers preserves this form, while narrow hidden layers can further constrain its rank. An affine decision boundary cannot represent XOR, where and are positive but and are negative. No single hyperplane separates those diagonal pairs.
A non-linear activation placed between layers breaks that linear superposition, allowing the network to carve curved and piecewise-linear decision surfaces.[2]
Test this collapse directly in NumPy. Without an activation, the two-layer calculation matches the merged affine map down to machine precision. Inserting ReLU breaks that equivalence:
import numpy as np
x = np.array([1.0, 4.0]) # small prompt, long queue
w1 = np.array([[0.3, -0.1], [0.1, 0.6]])
b1 = np.array([0.0, 0.0])
w2 = np.array([0.4, 0.1])
b2 = -0.5
z1 = w1 @ x + b1
two_affine = w2 @ z1 + b2
merged_affine = (w2 @ w1) @ x + (w2 @ b1 + b2)
with_relu = w2 @ np.maximum(0.0, z1) + b2
print("hidden pre-activation:", z1)
print("affine equals merged:", np.allclose(two_affine, merged_affine))
print(f"two affine output: {float(two_affine):.3f}")
print(f"with ReLU output: {float(with_relu):.3f}")
# Every affine map satisfies f(x) + f(-x) = 2 f(0).
def forward(point):
return w2 @ np.maximum(0.0, w1 @ point + b1) + b2
print("affine symmetry holds:", np.isclose(forward(x) + forward(-x), 2 * forward(np.zeros_like(x))))hidden pre-activation: [-0.1 2.5]
affine equals merged: True
two affine output: -0.290
with ReLU output: -0.250
affine symmetry holds: FalseThe first hidden pre-activation is -0.1. ReLU clips it to zero, changing the hidden vector from [-0.1, 2.5] to [0.0, 2.5]. The output changes from -0.290 to -0.250. One changed output only distinguishes these two calculations; the symmetry check goes further. This network violates an identity satisfied by every affine map, so no single affine map reproduces it for all inputs. Other choices of weights can still make a network with ReLU behave affinely.
Comparing modern activation functions
The activation you choose changes both what a layer can represent and how gradients flow through it during training. Compare these five on the same pre-activation:
-
Sigmoid: , mapping to . Its derivative is , reaching a peak of only at . Sigmoid is useful for binary classification outputs. Its local derivative is at most
0.25and shrinks in the tails, which can contribute to vanishing gradients in deep hidden stacks. For one neuron on one example, : nonnegative inputs make these weight gradients share the sign of . That isn't a rule for every weight in a layer or a batch update. -
Tanh (Hyperbolic Tangent): , mapping to . Its derivative is , peaking at at . Tanh is odd and permits both negative and positive outputs. The mean of actual hidden activations depends on their input distribution. Its derivative shrinks as grows, so it can also contribute to vanishing gradients.
-
ReLU (Rectified Linear Unit): , mapping to . Its derivative is exactly for and for . ReLU's positive branch doesn't shrink the gradient locally, but products of weights and other network operations can still make gradients vanish or explode. A unit that is negative on every training input receives no gradient through its ReLU branch and can remain inactive. Changes in upstream features or optimization dynamics can revive it, so inactivity isn't an unconditional permanent state. ReLU is continuous at zero, where its ordinary derivative doesn't exist; implementations commonly choose a zero backward value.
-
GELU (Gaussian Error Linear Unit): , mapping to roughly . Hendrycks and Gimpel introduced GELU to smoothly gate values by their probabilistic magnitude under standard Gaussian noise.[3] Unlike ReLU's corner, GELU is smooth and dips slightly negative ( near ). Its derivative is zero at its minimum and becomes very small far into the negative tail, so gradient flow still depends on the inputs and surrounding layers. GELU appears in BERT and several GPT and Vision Transformer architectures; exact or approximate variants depend on the checkpoint.
-
Swish / SiLU (Sigmoid Linear Unit): , and when , it's called SiLU: , mapping to roughly . Swish was studied through automated activation search; its form also appears as SiLU in the earlier GELU paper.[3] [4] SiLU is smooth, non-monotonic, and self-gated, with a minimum near at . SwiGLU uses two separate projections, multiplying elementwise by , then applying an output projection.[5] Activation choice varies across models: the published Gemma 4 26B-A4B configuration uses a GELU approximation, so check a checkpoint's published configuration before assuming it uses SwiGLU.[6]
| Activation | Formula | Output range | Output signs | Example uses |
|---|---|---|---|---|
| Sigmoid | Positive | Binary classification scores and gates | ||
| Tanh | Both; odd function | LSTM and GRU hidden-state candidates | ||
| ReLU | Nonnegative | Convolutional and dense hidden layers | ||
| GELU | Minimum about , unbounded above | Both; asymmetric | Transformer feed-forward layers | |
| SiLU () | Minimum about , unbounded above | Both; asymmetric | SwiGLU's activated projection |

Evaluate all five activations across sample pre-activations to see how their numerical behaviors diverge:
from math import erf, exp, sqrt, tanh
def sigmoid(z: float) -> float:
return 1.0 / (1.0 + exp(-z)) if z >= 0 else exp(z) / (1.0 + exp(z))
def gelu(z: float) -> float:
return z * 0.5 * (1.0 + erf(z / sqrt(2.0)))
def swish(z: float) -> float:
return z * sigmoid(z)
zs = [-2.0, -1.0, 0.0, 1.0, 2.0]
print(f"{'z':>5} {'sigmoid':>7} {'tanh':>7} {'relu':>5} {'gelu':>6} {'swish':>6}")
for z in zs:
print(f"{z:5.1f} {sigmoid(z):7.3f} {tanh(z):7.3f} {max(0.0, z):5.3f} {gelu(z):6.3f} {swish(z):6.3f}")z sigmoid tanh relu gelu swish
-2.0 0.119 -0.964 0.000 -0.046 -0.238
-1.0 0.269 -0.762 0.000 -0.159 -0.269
0.0 0.500 0.000 0.000 0.000 0.000
1.0 0.731 0.762 1.000 0.841 0.731
2.0 0.881 0.964 2.000 1.954 1.762At , ReLU clips to zero, while GELU returns -0.159 and SiLU returns -0.269. These smooth functions don't cut off the entire negative branch. The direction and size of an update still depend on the loss gradient and the activation's local derivative, which can itself be negative or zero. One unit's curve is settled. A layer with several units still has to say which weight meets which feature when a batch of requests arrives together.
How do the negative branches of GELU and SiLU differ from ReLU's?
Answer
ReLU is continuous but has a sharp corner at zero; its negative branch has zero derivative. GELU and SiLU have smooth transitions, with derivatives that reach zero at their minima and become tiny in the tails. Compare activations on the target task because smoothness alone says little about downstream quality.
Multi-layer dense networks and tensor shape contracts
A dense, or fully connected, layer connects every output unit to every input feature. The shape of each tensor is the contract that keeps those weights aligned.
PyTorch stores layer weights as (n_out, n_in), meaning number of output units by number of input features.[7] Each row of the weight matrix stores the parameters for one output neuron. For a single column vector :
If a layer takes input features and computes hidden units, has shape (2, 2), has shape (2,), and has shape (2,).
![For request x=[2,5], each row of W1 computes one hidden pre-activation: 0.3 times 2 minus 0.1 times 5 gives 0.10, and 0.1 times 2 plus 0.6 times 5 gives 3.20. ReLU keeps both values. In a batch, [B,2] maps through W1 of shape [2,2] to hidden [B,2], then W2 of shape [1,2] maps each row to one score [B,1].](/cdn/content-image/preparation/neural-networks-from-scratch/illustrations/_generated/neural_network_anatomy_dark.png?v=9673a3cedd21)
Trace our complete 2 -> 2 -> 1 network by hand on request :
The hidden pre-activation vector is:
Both values are positive, so ReLU leaves them untouched: . The second layer has weights and bias :
Run this single-request calculation with explicit row loops to confirm every intermediate number:
from math import exp
x = [2.0, 5.0]
w1 = [[0.3, -0.1], [0.1, 0.6]]
w2 = [0.4, 0.1]
b2 = -0.5
z1 = [sum(row[j] * x[j] for j in range(2)) for row in w1]
h = [max(0.0, value) for value in z1]
z2 = sum(w2[i] * h[i] for i in range(2)) + b2
score = 1.0 / (1.0 + exp(-z2))
print("hidden pre-activation:", [round(value, 2) for value in z1])
print("hidden after ReLU: ", [round(value, 2) for value in h])
print(f"output logit: {z2:.3f}")
print(f"sigmoid score: {score:.3f}")
assert abs(z1[0] - 0.1) < 1e-12
assert abs(z1[1] - 3.2) < 1e-12
assert abs(z2 - (-0.14)) < 1e-12hidden pre-activation: [0.1, 3.2]
hidden after ReLU: [0.1, 3.2]
output logit: -0.140
sigmoid score: 0.465Batched matrix multiplication and tensor shapes
The same calculation works for many requests at once. Stack requests as rows of and one matrix multiplication evaluates them together. Larger operations can improve GPU utilization, depending on shapes, hardware, and precision. The NumPy example below runs on the CPU.
Under the PyTorch weight layout where is stored as (n_out, n_in), we multiply the batch, whose rows are examples, by :
Follow the dimensional contract step by step:
- Input batch has shape
(B, Din) = (B, 2). - Weight matrix has shape
(Dhid, Din) = (2, 2). Its transpose has shape(Din, Dhid) = (2, 2). - Matrix product has shape
(B, Dhid) = (B, 2). - Bias vector has shape
(Dhid,) = (2,). Broadcasting reuses that vector for every row without constructing a repeated bias matrix. - Activation preserves shape
(B, Dhid) = (B, 2). - Output layer maps
(B, Dhid)to(B, Dout) = (B, 1). - Sigmoid maps logits to probability scores of shape
(B, Dout).
Execute this batched forward pass on four requests simultaneously:
import numpy as np
x = np.array([
[2.0, 5.0], # hand-traced request
[6.0, 8.0], # high load
[1.0, 1.0], # tiny prompt, short queue
[8.0, 2.0], # large prompt, short queue
])
w1 = np.array([[0.3, -0.1], [0.1, 0.6]])
b1 = np.array([0.0, 0.0])
w2 = np.array([[0.4, 0.1]])
b2 = np.array([-0.5])
h = np.maximum(0.0, x @ w1.T + b1)
logits = h @ w2.T + b2
scores = 1.0 / (1.0 + np.exp(-logits))
print("input shape: ", x.shape)
print("hidden shape:", h.shape)
print("score shape: ", scores.shape)
print("scores:", np.round(scores[:, 0], 3))
assert scores.shape == (4, 1)
assert np.isclose(scores[0, 0], 0.465, atol=5e-4)input shape: (4, 2)
hidden shape: (4, 2)
score shape: (4, 1)
scores: [0.465 0.608 0.413 0.641]The first row matches our scalar hand trace of 0.465.
Beware the silent transpose trap: when , both X @ W1 and X @ W1.T produce valid matrices of shape (B, Dhid). The operation X @ W1 pairs each neuron with the wrong transposed weights:
import numpy as np
x = np.array([[2.0, 5.0]])
w1 = np.array([[0.3, -0.1], [0.1, 0.6]])
right = x @ w1.T
wrong = x @ w1
print("W1 @ x convention: ", right[0])
print("X @ W1 other layout: ", wrong[0])
print("same numbers:", np.allclose(right, wrong))
assert np.allclose(right[0], [0.1, 3.2])
assert np.allclose(wrong[0], [1.1, 2.8])W1 @ x convention: [0.1 3.2]
X @ W1 other layout: [1.1 2.8]
same numbers: FalseThe wrong multiply produces [1.1, 2.8] instead of [0.1, 3.2]. Dimensional shape checks alone won't catch this bug when layers are square. Always verify your weight layout convention against a single-sample test vector.
Counting trainable parameters
Each weight matrix entry and bias element is a trainable parameter that updates during training. In a dense layer with inputs and outputs, the parameter count is:
The product counts one connection weight per input-output pair; the final term adds one bias per output unit. In our 2 -> 2 -> 1 network:
- Layer 1 (
2 -> 2): parameters. - Layer 2 (
2 -> 1): parameters. - Total parameters: .
Compare that tiny network to a flattened image classifier processing pixels:
def dense_parameters(n_in: int, n_out: int) -> int:
if n_in <= 0 or n_out <= 0:
raise ValueError("dense dimensions must be positive")
return n_in * n_out + n_out
tiny = dense_parameters(2, 2) + dense_parameters(2, 1)
image_classifier = (
dense_parameters(784, 256)
+ dense_parameters(256, 128)
+ dense_parameters(128, 10)
)
print("2 -> 2 -> 1 parameters:", tiny)
print("784 -> 256 -> 128 -> 10 parameters:", f"{image_classifier:,}")
try:
dense_parameters(0, 2)
except ValueError as error:
print(error)2 -> 2 -> 1 parameters: 9
784 -> 256 -> 128 -> 10 parameters: 235,146
dense dimensions must be positiveThe image classifier needs 235,146 parameters because a dense layer connects every pixel to every hidden unit, ignoring spatial adjacency. The count says how large the fit is. It doesn't say which of the nine weights in the tiny network should move when a score is wrong.
Deriving backpropagation for a two-layer network
The forward pass gives us predictions. Training asks how each weight and bias contributed to the loss, so we calculate the partial derivatives with respect to all four parameter arrays: , , , and .
Backpropagation applies the multivariate chain rule recursively, moving backward from the final loss through each intermediate activation.[8]
![For input [2,5], the forward pass gives hidden values [0.10,3.20], logit -0.14, and prediction 0.465. With target 1, binary cross-entropy from logits is 0.766 and the output error is -0.535. Backpropagation sends that error to output-weight gradient [-0.053,-1.712] and hidden gradient [-0.214,-0.053]; the active ReLU gate passes both values, and the diagram shows the first-layer weight-gradient row for h₁: [-0.428,-1.070].](/cdn/content-image/preparation/neural-networks-from-scratch/illustrations/_generated/tiny_forward_pass_trace_dark.png?v=df0384e2eca8)
Here is the step-by-step backward derivation for our 2 -> 2 -> 1 network with Binary Cross-Entropy loss.
Step 1: The loss function
For a batch of requests with true labels and sigmoid predictions :
Step 2: Output logit gradient
First take , so the loss contains one sample. Differentiate it with respect to the output logit :
Differentiating the cross-entropy loss with respect to gives:
Differentiating sigmoid with respect to its logit gives:
Multiplying them together cancels the shared factor:
The sigmoid derivative's factor cancels the reciprocal factor in the loss derivative. For a batch of size :
Here , the loss is unweighted, and the mean covers all scalar predictions. If you extend this to independent binary outputs with the default BCEWithLogitsLoss(reduction="mean"), the divisor becomes : PyTorch averages across the batch and output axes.[9] Using (y_hat - y) / B in that extension makes the gradients times too large. Summing each request's output losses and then averaging requests defines a different objective. For pos_weight, use the target-weighted gradient formula.
Step 3: Layer 2 parameter gradients
Since :
Step 4: Propagating gradients to the hidden layer
By the chain rule, the gradient flowing back to hidden activations is:
Step 5: Passing through the ReLU gate
Because , the local derivative is if and if . At zero the ordinary derivative doesn't exist; we choose zero for the backward calculation, matching PyTorch ReLU:
Here denotes elementwise multiplication.
Step 6: Layer 1 parameter gradients
Since :
Verifying NumPy gradients against PyTorch autograd
Implement the backward pass in NumPy, check both weight matrices with finite differences, and, when PyTorch is installed, compare all four parameter gradients with autograd (loss.backward()). Compute loss directly from logits as , using logaddexp to avoid overflowing the exponential. BCEWithLogitsLoss also computes the equivalent loss from logits for numerical stability.[9]
import numpy as np
# Input batch: B=4 requests, Din=2 features
X = np.array([
[2.0, 5.0],
[6.0, 8.0],
[1.0, 1.0],
[8.0, 2.0],
])
# Ground-truth binary targets (1 = timeout, 0 = success)
y = np.array([[0.0], [1.0], [0.0], [1.0]])
# Network parameters: 2 -> 2 -> 1
W1 = np.array([[0.3, -0.1], [0.1, 0.6]])
b1 = np.array([0.0, 0.0])
W2 = np.array([[0.4, 0.1]])
b2 = np.array([-0.5])
# Forward pass in NumPy
Z1 = X @ W1.T + b1
H = np.maximum(0.0, Z1)
Z2 = H @ W2.T + b2
y_hat = 1.0 / (1.0 + np.exp(-Z2))
loss = np.mean(np.logaddexp(0.0, Z2) - y * Z2)
# Backward pass via chain rule
B = X.shape[0]
dZ2 = (y_hat - y) / B
dW2 = dZ2.T @ H
db2 = np.sum(dZ2, axis=0)
dH = dZ2 @ W2
dZ1 = dH * (Z1 > 0.0)
dW1 = dZ1.T @ X
db1 = np.sum(dZ1, axis=0)
# Numerical gradient check using two-sided finite differences
def compute_loss(w1_val, b1_val, w2_val, b2_val):
z1_val = X @ w1_val.T + b1_val
h_val = np.maximum(0.0, z1_val)
z2_val = h_val @ w2_val.T + b2_val
return np.mean(np.logaddexp(0.0, z2_val) - y * z2_val)
eps = 1e-6
num_dW2 = np.zeros_like(W2)
for i in range(W2.shape[0]):
for j in range(W2.shape[1]):
wp, wm = W2.copy(), W2.copy()
wp[i, j] += eps
wm[i, j] -= eps
num_dW2[i, j] = (compute_loss(W1, b1, wp, b2) - compute_loss(W1, b1, wm, b2)) / (2 * eps)
num_dW1 = np.zeros_like(W1)
for i in range(W1.shape[0]):
for j in range(W1.shape[1]):
wp, wm = W1.copy(), W1.copy()
wp[i, j] += eps
wm[i, j] -= eps
num_dW1[i, j] = (compute_loss(wp, b1, W2, b2) - compute_loss(wm, b1, W2, b2)) / (2 * eps)
assert np.allclose(dW2, num_dW2, rtol=0.0, atol=1e-7)
assert np.allclose(dW1, num_dW1, rtol=0.0, atol=1e-7)
# PyTorch autograd verification if installed
torch_checked = False
try:
import torch
t_W1 = torch.tensor(W1, dtype=torch.float64, requires_grad=True)
t_b1 = torch.tensor(b1, dtype=torch.float64, requires_grad=True)
t_W2 = torch.tensor(W2, dtype=torch.float64, requires_grad=True)
t_b2 = torch.tensor(b2, dtype=torch.float64, requires_grad=True)
t_loss = torch.nn.functional.binary_cross_entropy_with_logits(
torch.relu(torch.nn.functional.linear(torch.tensor(X, dtype=torch.float64), t_W1, t_b1)) @ t_W2.T + t_b2,
torch.tensor(y, dtype=torch.float64),
)
t_loss.backward()
for manual, parameter in [(dW1, t_W1), (db1, t_b1), (dW2, t_W2), (db2, t_b2)]:
assert np.allclose(manual, parameter.grad.numpy(), rtol=1e-12, atol=1e-12)
torch_checked = True
except ImportError:
pass
print(f"NumPy BCE loss: {loss:.4f}")
print("dW2 gradient:", np.round(dW2, 4))
print("db2 gradient:", np.round(db2, 4))
print("dW1 gradient:", np.round(dW1, 4))
print("db1 gradient:", np.round(db1, 4))
print("Gradient check passed:", True)
print("PyTorch autograd checked:", torch_checked)
# Use the gradients for one optimization step on this same batch.
learning_rate = 0.1
next_loss = compute_loss(
W1 - learning_rate * dW1, b1 - learning_rate * db1,
W2 - learning_rate * dW2, b2 - learning_rate * db2,
)
print(f"BCE after one gradient step: {next_loss:.4f}")
assert next_loss < lossNumPy BCE loss: 0.5252
dW2 gradient: [[-0.2631 -0.2639]]
db2 gradient: [0.0319]
dW1 gradient: [[-0.3878 -0.1113]
[-0.097 -0.0278]]
db1 gradient: [0.0128 0.0032]
Gradient check passed: True
PyTorch autograd checked: True
BCE after one gradient step: 0.4980The shown run has PyTorch installed, so both weight and both bias gradients pass the tight comparison. With NumPy alone, the finite-difference checks still run, but PyTorch autograd checked prints False.
The checks also depend on where we evaluate them. Every hidden pre-activation here is positive, and the perturbations of size 1e-6 keep them positive. At a ReLU corner, a central difference can disagree with the chosen backward value because there is no ordinary derivative there.[10] Compute the same check directly at zero and on either side:
import torch
eps = 1e-6
for value in [-0.1, 0.0, 0.1]:
z = torch.tensor(value, dtype=torch.float64, requires_grad=True)
torch.relu(z).backward()
central = (max(0.0, value + eps) - max(0.0, value - eps)) / (2 * eps)
print(f"z={value:4.1f}: backward={z.grad.item():.1f}, central={central:.1f}")z=-0.1: backward=0.0, central=0.0
z= 0.0: backward=0.0, central=0.5
z= 0.1: backward=1.0, central=1.0At ReLU's nondifferentiable corner, the central difference gives 0.5 while PyTorch selects its backward value 0.0; this is expected. Check away from corners in double precision, keep perturbations within the same active branches, and compare several perturbation sizes: very small differences can lose precision, while large ones can cross branches. The smooth-region agreement validates this fixture on the tested inputs and weights; evaluate other configurations separately.
The last step uses those gradients: loss on the same four requests falls from 0.5252 to 0.4980. This verifies the update on this batch at this learning rate. Evaluate held-out requests and other step sizes to assess broader behavior.
Our backward calculation reuses for , the signs of for the ReLU gate, and for . These values can be saved or recomputed when needed. Activation memory can be large at long sequence lengths or large batches, but weights, gradients, and optimizer state also contribute to training memory. Which term dominates depends on the workload. The formulas can still be right while the run breaks. What happens when one feature is a token count and another is a zero-to-ten score?
Why must the backward pass have access to some forward-pass values, and how can checkpointing reduce saved activation memory?
Answer
Parameter gradients depend on incoming activations: and . The ReLU backward calculation also needs the active-unit mask. Checkpointing saves selected intermediate values and recomputes omitted values during backward, trading computation for memory. This doesn't require keeping every intermediate tensor on the GPU until backward finishes.
Diagnosing feature scale and numerical overflow
Shapes and gradient formulas can all be correct while scale still breaks a run. Two common causes are features measured in very different units, and exponentials that overflow.
Feature scale mismatch
Suppose CodeAssist changes units: queue wait is measured on a 0 to 10 scale, while prompt length is measured in raw tokens ranging from 10 to 1,000:
A queue wait of 8 contributes . A prompt length of 900 tokens contributes . Even though both features share the exact same coefficient of 0.3, the token count dominates the affine sum by two orders of magnitude:
raw = [8.0, 900.0] # queue-wait score, prompt token count
raw_contributions = [0.3 * value for value in raw]
mean = [5.0, 500.0]
scale = [2.0, 200.0]
standardized = [(value - m) / s for value, m, s in zip(raw, mean, scale)]
scaled_contributions = [0.3 * value for value in standardized]
print("raw contributions: ", raw_contributions)
print("standardized features: ", [round(value, 2) for value in standardized])
print("scaled contributions: ", [round(value, 2) for value in scaled_contributions])
assert raw_contributions == [2.4, 270.0]raw contributions: [2.4, 270.0]
standardized features: [1.5, 2.0]
scaled contributions: [0.45, 0.6]Standardizing each feature by subtracting training-set means and dividing by training-set standard deviations brings their typical scales closer: 1.5 and 2.0 here. Reusing the illustrative coefficients gives contributions 0.45 and 0.60; a real model's coefficients must be trained or transformed for the chosen units. Standardization can help optimization, while feature correlations and outliers still need separate attention.
Fit the means and scales on the training split, then reuse them for validation, test data, and inference. Using test data to fit the scaler leaks information. A constant training feature needs a policy too: scikit-learn's StandardScaler uses a scale factor of one.[11]
Exponential overflow in activations
The standard sigmoid formula 1.0 / (1.0 + exp(-z)) is numerically safe for , but it crashes for extreme negative logits. At , exp(-z) evaluates exp(1000), exceeding the floating-point range of 64-bit floats and triggering an OverflowError.
Use an algebraically equivalent branch that evaluates for negative inputs:
from math import exp
def stable_sigmoid(z: float) -> float:
if z >= 0:
return 1.0 / (1.0 + exp(-z))
ez = exp(z)
return ez / (1.0 + ez)
for logit in [-1000.0, -2.0, 0.0, 2.0, 1000.0]:
print(f"logit={logit:7.1f} score={stable_sigmoid(logit):.6f}")logit=-1000.0 score=0.000000
logit= -2.0 score=0.119203
logit= 0.0 score=0.500000
logit= 2.0 score=0.880797
logit= 1000.0 score=1.000000For finite negative logits, produces small numbers. Extreme outputs can still round to zero or one, making log(score) unsafe; compute the loss directly from logits.
Compare two extremely confident wrong predictions using the loss directly from logits:
import numpy as np
logits = np.array([-1000.0, 1000.0])
targets = np.array([1.0, 0.0])
losses = np.logaddexp(0.0, logits) - targets * logits
print("per-request losses:", losses)
print("mean BCE loss:", losses.mean())
assert np.all(np.isfinite(losses))
assert np.allclose(losses, [1000.0, 1000.0])per-request losses: [1000. 1000.]
mean BCE loss: 1000.0Now combine shape assertions, the stable sigmoid branch, and an illustrative routing rule in a checked toy scoring report:
import numpy as np
def stable_sigmoid(values: np.ndarray) -> np.ndarray:
scores = np.empty_like(values, dtype=float)
nonnegative = values >= 0
scores[nonnegative] = 1.0 / (1.0 + np.exp(-values[nonnegative]))
exp_values = np.exp(values[~nonnegative])
scores[~nonnegative] = exp_values / (1.0 + exp_values)
return scores
feature_names = ["prompt-size score", "queue-wait score"]
request_ids = ["CA-104", "CA-208", "CA-311", "CA-412"]
x = np.array([
[2.0, 5.0],
[6.0, 8.0],
[1.0, 1.0],
[8.0, 2.0],
])
w1 = np.array([[0.3, -0.1], [0.1, 0.6]])
b1 = np.array([0.0, 0.0])
w2 = np.array([[0.4, 0.1]])
b2 = np.array([-0.5])
assert x.shape[1] == len(feature_names) == w1.shape[1]
assert w1.shape[0] == b1.shape[0] == w2.shape[1]
assert w2.shape[0] == b2.shape[0] == 1
hidden = np.maximum(0.0, x @ w1.T + b1)
logits = hidden @ w2.T + b2
scores = stable_sigmoid(logits)
assert scores.shape == (len(request_ids), 1)
assert np.isclose(scores[0, 0], 0.465, atol=5e-4)
assert np.all(np.isfinite(stable_sigmoid(np.array([-1000.0, 1000.0]))))
print("Timeout-risk scoring report")
for request_id, score in zip(request_ids, scores[:, 0], strict=True):
route = "slow_path" if score >= 0.60 else "fast_path"
print(f"{request_id}: score={score:.3f} -> {route}")Timeout-risk scoring report
CA-104: score=0.465 -> fast_path
CA-208: score=0.608 -> slow_path
CA-311: score=0.413 -> fast_path
CA-412: score=0.641 -> slow_pathRequests CA-208 and CA-412 cross the illustrative 0.60 threshold and receive slow_path labels. Hand-picked weights produce these four scores, with no training or calibration. What functions can a wide enough network represent, and what other conditions shape whether training learns one?
Universal approximation and its practical limits
The four-request report used hand-picked weights. Universal approximation theorems ask what functions a wide enough network can represent, and they come with important limits. Cybenko (1989) proved a result for sigmoidal activations, and Hornik et al. (1989) established related results.[12] [13] A useful version allows a continuous non-polynomial activation, such as sigmoid or ReLU: with biases, a linear output, and enough hidden units, one hidden layer can approximate any continuous function uniformly on a compact (closed and bounded) domain to arbitrary accuracy .[14] Non-linearity alone isn't sufficient; a fixed polynomial activation is a counterexample.
For one-dimensional intuition, take . The difference forms a smooth bump. Three shifted ReLU units form an exact triangle:
It rises from zero to one on , falls to zero on , and is zero outside that interval. Shifted and scaled triangles can approximate a continuous curve by increasingly fine piecewise-linear segments. This example builds one-dimensional intuition; higher-dimensional approximation requires additional arguments.
To use this result in practice, ask three further questions:
-
How much width is enough? Required width depends on the target function, activation, input dimension, and desired error. Depth-separation results construct particular function families that small deep networks represent much more efficiently than shallow ones.[15] These examples show where depth helps; gains for a particular task depend on its function and architecture.
-
Representability and learnability: The theorem establishes that a suitable set of weights exists in parameter space. Training still depends on whether gradient descent can reach suitable weights from its initialization; optimization can encounter poor local minima or flat saddle points.
-
Can training data identify the function? The mathematical approximation covers the chosen compact domain, including unsampled points. The theorem assumes a target function and proves suitable weights exist; finite training examples may not identify those weights or yield good test performance. Architectural assumptions can help: CNNs share local filters across an image, while attention, positions, and masks determine how a Transformer combines tokens. These choices need to fit the task. A CNN's translation behavior depends on its architecture and boundary handling; position-aware or causally masked attention can break permutation equivariance.
Practice problems
- In
bias-shifts-threshold.py, change the bias from-4.0to-6.0. Calculate the new logits and explain why both requests receive lower risk scores even though their input features didn't change. - Extend
batch-forward-pass.pyto support three input features (add a server CPU load score) while keeping two hidden units. Write out the required shapes of , , and before running the code. - In
backpropagation-two-layer-mlp.py, change the ground-truth target vector from[0, 1, 0, 1]to all ones[1, 1, 1, 1]. Predict the sign of and explain which direction the weights must move to reduce the loss.
Compare your solutions with these worked answers.
Answer
- Lowering the bias by 2 subtracts exactly 2.0 from every logit: quiet becomes -3.70 (score ), and high load becomes 0.60 (score ). Because sigmoid increases monotonically with its input, reducing the logit always reduces the output score.
- With three features and two hidden units: has shape
(4, 3), has shape(2, 3)under PyTorch layout, and has shape(2,). Hidden activations retain shape(4, 2), so (shape (1, 2)) and output logits (shape (4, 1)) remain unchanged. - With these moderate logits and all targets equal to , is negative for every sample. In this fixture, inputs, active hidden values, and output weights are nonnegative, so both weight matrices have nonpositive gradients; gradient descent adds nonnegative increments. In a different fixture, negative inputs or output weights can change individual weight-gradient signs. A negative logit gradient alone doesn't determine every weight's update direction.