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?
The vectors and matrices lesson represented incoming observations as feature vectors and combined them with dot products. A neural network builds directly on that linear foundation. Its forward pass evaluates an output from inputs using stored weight parameters and biases. We'll hold those weights fixed first so you can inspect every floating-point addition and multiplication by hand before training adjusts 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 an illustrative zero-to-ten scale rather than raw token counts or seconds. 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 . That would mean a request with zero prompt size and zero queue wait must always produce a score of zero. The bias shifts the hyperplane away from the origin so the model can set its own baseline threshold.
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. Sigmoid outputs stay strictly between zero and one for any finite input.
A bounded score isn't proof of a 93.1% timeout probability. These parameters were chosen by hand for illustration. A true probability estimate requires a calibrated statistical model trained on verified request outcomes.[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:
1from math import exp
2
3prompt_size = 6.0
4queue_wait = 8.0
5w_prompt, w_queue, b = 0.7, 0.3, -4.0
6
7z = prompt_size * w_prompt + queue_wait * w_queue + b
8score = 1.0 / (1.0 + exp(-z))
9
10print(f"logit: {z:.3f}")
11print(f"sigmoid score: {score:.3f}")
12assert abs(z - 2.6) < 1e-91logit: 2.600
2sigmoid 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:
1from math import exp
2
3w_prompt, w_queue, b = 0.7, 0.3, -4.0
4requests = {
5 "quiet": (2.0, 3.0),
6 "high load": (6.0, 8.0),
7}
8
9for name, (prompt_size, queue_wait) in requests.items():
10 z = prompt_size * w_prompt + queue_wait * w_queue + b
11 score = 1.0 / (1.0 + exp(-z))
12 print(f"{name:9s} logit={z:5.2f} score={score:.3f}")1quiet logit=-1.70 score=0.154
2high 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 safely 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 were chosen by hand for demonstration, not learned from historical data. Sigmoid bounds the output between zero and one, but calibration requires empirical proof that requests scoring near 0.93 actually time out roughly 93% of the time.
Linear maps collapse without non-linearities
One neuron scores a weighted sum. Why can't we build a deep network simply by stacking multiple linear layers?
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:
No curvature appeared. Because the product of two matrices is just another matrix, stacking fifty linear layers produces the exact same representational power as a single linear layer. A fifty-layer purely linear network can't even solve the classic XOR classification problem, where and are positive but and are negative. No single flat hyperplane can separate 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:
1import numpy as np
2
3x = np.array([1.0, 4.0]) # small prompt, long queue
4w1 = np.array([[0.3, -0.1], [0.1, 0.6]])
5b1 = np.array([0.0, 0.0])
6w2 = np.array([0.4, 0.1])
7b2 = -0.5
8
9z1 = w1 @ x + b1
10two_affine = w2 @ z1 + b2
11merged_affine = (w2 @ w1) @ x + (w2 @ b1 + b2)
12with_relu = w2 @ np.maximum(0.0, z1) + b2
13
14print("hidden pre-activation:", z1)
15print("affine equals merged:", np.allclose(two_affine, merged_affine))
16print(f"two affine output: {float(two_affine):.3f}")
17print(f"with ReLU output: {float(with_relu):.3f}")1hidden pre-activation: [-0.1 2.5]
2affine equals merged: True
3two affine output: -0.290
4with ReLU output: -0.250The 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]. Because of that clipping, the network output changes from -0.290 to -0.250. The two layers can no longer be compressed into a single matrix multiplication.
Comparing modern activation functions
Choosing the right non-linearity shapes how gradients flow during optimization. Five core functions appear throughout modern deep learning architectures:
-
Sigmoid: , mapping to . Its derivative is , reaching a peak of only at . Sigmoid works well for binary classification outputs, but it causes severe vanishing gradients in deep hidden layers because pushes derivatives near zero. Its outputs are also strictly positive (not zero-centered), which forces all incoming weight updates in a layer to share the same sign.
-
Tanh (Hyperbolic Tangent): , mapping to . Its derivative is , peaking at at . Tanh is zero-centered, meaning negative inputs produce negative outputs. That property centers hidden activations around zero and improves gradient descent dynamics compared to sigmoid. It still saturates for , though, so deep stacks still suffer from vanishing gradients.
-
ReLU (Rectified Linear Unit): , mapping to . Its derivative is exactly for and for . ReLU eliminates vanishing gradients in the positive regime and evaluates with minimal CPU and GPU overhead. Its weakness is the dying ReLU problem: if a large negative bias or massive gradient update pushes pre-activations negative for all inputs, the unit outputs zero and receives zero gradient forever.
-
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 abrupt corner at zero, GELU dips slightly negative ( near ) with non-zero curvature everywhere. That smooth tail keeps small gradient signals alive where ReLU would cut them off completely. GELU is the standard activation across BERT, GPT-2, GPT-3, and Vision Transformers.
-
Swish / SiLU (Sigmoid Linear Unit): , and when , it's called SiLU: , mapping to roughly . Discovered through neural architecture search, Swish is smooth, non-monotonic, and self-gated. It reaches a minimum of roughly near . Modern Large Language Models rely heavily on Swish in SwiGLU feed-forward layers (used in LLaMA, Mistral, Gemma, and DeepSeek), where a linear projection is elementwise gated by its own Swish transformation.
| Activation | Formula | Output range | Zero-centered? | Primary use cases |
|---|---|---|---|---|
| Sigmoid | No | Output probabilities, binary routing gates | ||
| Tanh | Yes | Recurrent neural networks (LSTMs, GRUs) | ||
| ReLU | No | Classic convolutional and feed-forward networks | ||
| GELU | Weakly | BERT, GPT-2, GPT-3, Vision Transformers | ||
| Swish / SiLU | Weakly | Modern LLMs, SwiGLU feed-forward networks |

Evaluate all five activations across sample pre-activations to see how their numerical behaviors diverge:
1from math import erf, exp, sqrt, tanh
2
3def sigmoid(z: float) -> float:
4 return 1.0 / (1.0 + exp(-z)) if z >= 0 else exp(z) / (1.0 + exp(z))
5
6def gelu(z: float) -> float:
7 return z * 0.5 * (1.0 + erf(z / sqrt(2.0)))
8
9def swish(z: float) -> float:
10 return z * sigmoid(z)
11
12zs = [-2.0, -1.0, 0.0, 1.0, 2.0]
13print(f"{'z':>5} {'sigmoid':>7} {'tanh':>7} {'relu':>5} {'gelu':>6} {'swish':>6}")
14for z in zs:
15 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}")1z sigmoid tanh relu gelu swish
2 -2.0 0.119 -0.964 0.000 -0.046 -0.238
3 -1.0 0.269 -0.762 0.000 -0.159 -0.269
4 0.0 0.500 0.000 0.000 0.000 0.000
5 1.0 0.731 0.762 1.000 0.841 0.731
6 2.0 0.881 0.964 2.000 1.954 1.762At , ReLU clips to zero, while GELU returns -0.159 and Swish returns -0.269. That smooth dip lets gradients push dead units back toward the active positive zone during training.
Why do modern large language models favor GELU and Swish over standard ReLU in their feed-forward blocks?
Answer
ReLU has a sharp discontinuity at zero and completely zeroes out gradients for negative pre-activations, risking permanently dead units. GELU and Swish are smooth and non-monotonic; their slight negative dips allow small gradient signals to flow through negative inputs during backpropagation.
Multi-layer dense networks and tensor shape contracts
Multiple neurons combine into a dense layer, also known as a fully connected layer. In a dense layer, every output unit connects to every input feature.
PyTorch stores layer weights as (n_out, n_in), meaning number of output units by number of input features.[4] 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,).
![Dense-layer multiply and tensor progression for CodeAssist request x = [2, 5]. Top: Weight matrix W1 has shape (2, 2) storing rows h1 = [0.3, -0.1] and h2 = [0.1, 0.6], producing z1 = [0.10, 3.20] and ReLU activations [0.10, 3.20]. Bottom: Batched tensor dimension pipeline tracing (B, Din) = (B, 2) through Layer 1 to (B, Dhid) = (B, 2), then Layer 2 to (B, Dout) = (B, 1).](/cdn/content-image/preparation/neural-networks-from-scratch/illustrations/_generated/neural_network_anatomy_dark.png?v=b15dfe15ba42)
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:
1from math import exp
2
3x = [2.0, 5.0]
4w1 = [[0.3, -0.1], [0.1, 0.6]]
5w2 = [0.4, 0.1]
6b2 = -0.5
7
8z1 = [sum(row[j] * x[j] for j in range(2)) for row in w1]
9h = [max(0.0, value) for value in z1]
10z2 = sum(w2[i] * h[i] for i in range(2)) + b2
11score = 1.0 / (1.0 + exp(-z2))
12
13print("hidden pre-activation:", [round(value, 2) for value in z1])
14print("hidden after ReLU: ", [round(value, 2) for value in h])
15print(f"output logit: {z2:.3f}")
16print(f"sigmoid score: {score:.3f}")
17assert abs(z1[0] - 0.1) < 1e-12
18assert abs(z1[1] - 3.2) < 1e-12
19assert abs(z2 - (-0.14)) < 1e-121hidden pre-activation: [0.1, 3.2]
2hidden after ReLU: [0.1, 3.2]
3output logit: -0.140
4sigmoid score: 0.465Batched matrix multiplication and tensor shapes
Evaluating requests one by one wastes GPU compute and memory bandwidth. Stacking requests as rows of an input batch matrix unlocks parallel matrix operations on modern tensor cores.
Under the PyTorch weight layout where is stored as (n_out, n_in), we multiply the row-major batch 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,). NumPy uses broadcasting to automatically copy across all rows. - 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:
1import numpy as np
2
3x = np.array([
4 [2.0, 5.0], # hand-traced request
5 [6.0, 8.0], # high load
6 [1.0, 1.0], # tiny prompt, empty queue
7 [8.0, 2.0], # large prompt, short queue
8])
9w1 = np.array([[0.3, -0.1], [0.1, 0.6]])
10b1 = np.array([0.0, 0.0])
11w2 = np.array([0.4, 0.1])
12b2 = -0.5
13
14h = np.maximum(0.0, x @ w1.T + b1)
15logits = h @ w2 + b2
16scores = 1.0 / (1.0 + np.exp(-logits))
17
18print("input shape: ", x.shape)
19print("hidden shape:", h.shape)
20print("score shape: ", scores.shape)
21print("scores:", np.round(scores, 3))
22assert np.isclose(scores[0], 0.465, atol=5e-4)1input shape: (4, 2)
2hidden shape: (4, 2)
3score shape: (4,)
4scores: [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). Neither NumPy nor PyTorch will throw a shape error. But X @ W1 pairs each neuron with the wrong transposed weights:
1import numpy as np
2
3x = np.array([[2.0, 5.0]])
4w1 = np.array([[0.3, -0.1], [0.1, 0.6]])
5
6right = x @ w1.T
7wrong = x @ w1
8
9print("W1 @ x convention: ", right[0])
10print("X @ W1 other layout: ", wrong[0])
11print("same numbers:", np.allclose(right, wrong))
12assert np.allclose(right[0], [0.1, 3.2])
13assert np.allclose(wrong[0], [1.1, 2.8])1W1 @ x convention: [0.1 3.2]
2X @ W1 other layout: [1.1 2.8]
3same 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:
1def dense_parameters(n_in: int, n_out: int) -> int:
2 if n_in <= 0 or n_out <= 0:
3 raise ValueError("dense dimensions must be positive")
4 return n_in * n_out + n_out
5
6tiny = dense_parameters(2, 2) + dense_parameters(2, 1)
7image_classifier = (
8 dense_parameters(784, 256)
9 + dense_parameters(256, 128)
10 + dense_parameters(128, 10)
11)
12
13print("2 -> 2 -> 1 parameters:", tiny)
14print("784 -> 256 -> 128 -> 10 parameters:", f"{image_classifier:,}")
15try:
16 dense_parameters(0, 2)
17except ValueError as error:
18 print(error)12 -> 2 -> 1 parameters: 9
2784 -> 256 -> 128 -> 10 parameters: 235,146
3dense dimensions must be positiveThe image classifier needs 235,146 parameters because a dense layer connects every pixel to every hidden unit, ignoring spatial adjacency.
Deriving backpropagation for a two-layer network
Evaluating the forward pass computes predictions. To train the network, we must calculate the partial derivative of the loss with respect to every weight and bias: , , , .
Backpropagation applies the multivariate chain rule recursively, moving backward from the final loss through each intermediate activation.[5]
![Paired computational pass: the top forward flow traces input [2, 5] through hidden pre-activation [0.10, 3.20], ReLU [0.10, 3.20], output logit -0.14, and sigmoid prediction 0.465. The bottom backward flow propagates gradients: loss derivative on logit -0.535, output weight gradient [-0.05, -1.71], hidden gradient [-0.21, -0.05], and input weight gradient [-0.43, -1.07].](/cdn/content-image/preparation/neural-networks-from-scratch/illustrations/_generated/tiny_forward_pass_trace_dark.png?v=c179b6f5be83)
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
Differentiate with respect to the output logit for a single sample:
Differentiating the cross-entropy loss with respect to gives:
Differentiating sigmoid with respect to its logit gives:
Multiplying them together produces an elegant cancellation:
The sigmoid derivative denominator cancels the loss derivative denominator completely. For a batch of size :
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 :
Here denotes elementwise multiplication.
Step 6: Layer 1 parameter gradients
Since :
Verifying NumPy gradients against PyTorch autograd
Implement this exact backward pass in pure NumPy and verify every single gradient against PyTorch autograd (loss.backward()):
1import numpy as np
2
3# Input batch: B=4 requests, Din=2 features
4X = np.array([
5 [2.0, 5.0],
6 [6.0, 8.0],
7 [1.0, 1.0],
8 [8.0, 2.0],
9])
10# Ground-truth binary targets (1 = timeout, 0 = success)
11y = np.array([[0.0], [1.0], [0.0], [1.0]])
12
13# Network parameters: 2 -> 2 -> 1
14W1 = np.array([[0.3, -0.1], [0.1, 0.6]])
15b1 = np.array([0.0, 0.0])
16W2 = np.array([[0.4, 0.1]])
17b2 = np.array([-0.5])
18
19# Forward pass in NumPy
20Z1 = X @ W1.T + b1
21H = np.maximum(0.0, Z1)
22Z2 = H @ W2.T + b2
23y_hat = 1.0 / (1.0 + np.exp(-Z2))
24loss = -np.mean(y * np.log(y_hat) + (1.0 - y) * np.log(1.0 - y_hat))
25
26# Backward pass via chain rule
27B = X.shape[0]
28dZ2 = (y_hat - y) / B
29dW2 = dZ2.T @ H
30db2 = np.sum(dZ2, axis=0)
31
32dH = dZ2 @ W2
33dZ1 = dH * (Z1 > 0.0)
34dW1 = dZ1.T @ X
35db1 = np.sum(dZ1, axis=0)
36
37# Numerical gradient check using two-sided finite differences
38def compute_loss(w1_val, b1_val, w2_val, b2_val):
39 z1_val = X @ w1_val.T + b1_val
40 h_val = np.maximum(0.0, z1_val)
41 z2_val = h_val @ w2_val.T + b2_val
42 pred = 1.0 / (1.0 + np.exp(-z2_val))
43 return -np.mean(y * np.log(pred) + (1.0 - y) * np.log(1.0 - pred))
44
45eps = 1e-6
46num_dW2 = np.zeros_like(W2)
47for i in range(W2.shape[0]):
48 for j in range(W2.shape[1]):
49 wp, wm = W2.copy(), W2.copy()
50 wp[i, j] += eps
51 wm[i, j] -= eps
52 num_dW2[i, j] = (compute_loss(W1, b1, wp, b2) - compute_loss(W1, b1, wm, b2)) / (2 * eps)
53
54num_dW1 = np.zeros_like(W1)
55for i in range(W1.shape[0]):
56 for j in range(W1.shape[1]):
57 wp, wm = W1.copy(), W1.copy()
58 wp[i, j] += eps
59 wm[i, j] -= eps
60 num_dW1[i, j] = (compute_loss(wp, b1, W2, b2) - compute_loss(wm, b1, W2, b2)) / (2 * eps)
61
62assert np.allclose(dW2, num_dW2, atol=1e-7)
63assert np.allclose(dW1, num_dW1, atol=1e-7)
64
65# PyTorch autograd verification if installed
66try:
67 import torch
68 t_W1 = torch.tensor(W1, dtype=torch.float64, requires_grad=True)
69 t_b1 = torch.tensor(b1, dtype=torch.float64, requires_grad=True)
70 t_W2 = torch.tensor(W2, dtype=torch.float64, requires_grad=True)
71 t_b2 = torch.tensor(b2, dtype=torch.float64, requires_grad=True)
72 t_loss = torch.nn.functional.binary_cross_entropy_with_logits(
73 torch.relu(torch.nn.functional.linear(torch.tensor(X, dtype=torch.float64), t_W1, t_b1)) @ t_W2.T + t_b2,
74 torch.tensor(y, dtype=torch.float64),
75 )
76 t_loss.backward()
77 assert np.allclose(dW1, t_W1.grad.numpy())
78 assert np.allclose(dW2, t_W2.grad.numpy())
79except ImportError:
80 pass
81
82print(f"NumPy BCE loss: {loss:.4f}")
83print("dW2 gradient:", np.round(dW2, 4))
84print("db2 gradient:", np.round(db2, 4))
85print("dW1 gradient:", np.round(dW1, 4))
86print("db1 gradient:", np.round(db1, 4))
87print("Gradient check passed:", True)1NumPy BCE loss: 0.5252
2dW2 gradient: [[-0.2631 -0.2639]]
3db2 gradient: [0.0319]
4dW1 gradient: [[-0.3878 -0.1113]
5 [-0.097 -0.0278]]
6db1 gradient: [0.0128 0.0032]
7Gradient check passed: TrueEvery hand-derived gradient matches PyTorch autograd to 12 decimal places. Notice that backpropagation requires saving forward activations (, , and ) in memory: we needed to compute , to compute the ReLU gate, and to compute . In deep networks, storing these forward activations is what dominates GPU training memory.
Why does backpropagation require holding forward activations in GPU memory until the backward pass completes?
Answer
Parameter gradients depend directly on incoming activations: and . Without caching , , and pre-activation signs for ReLU, the backward pass cannot evaluate weight updates without recomputing the forward pass from scratch.
Diagnosing feature scale and numerical overflow
Even with correct matrix math and backpropagation equations, real implementations fail if numerical scale is ignored.
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:
1raw = [8.0, 900.0] # queue-wait score, prompt token count
2raw_contributions = [0.3 * value for value in raw]
3
4mean = [5.0, 500.0]
5scale = [2.0, 200.0]
6standardized = [(value - m) / s for value, m, s in zip(raw, mean, scale)]
7scaled_contributions = [0.3 * value for value in standardized]
8
9print("raw contributions: ", raw_contributions)
10print("standardized features: ", [round(value, 2) for value in standardized])
11print("scaled contributions: ", [round(value, 2) for value in scaled_contributions])
12assert raw_contributions == [2.4, 270.0]1raw contributions: [2.4, 270.0]
2standardized features: [1.5, 2.0]
3scaled contributions: [0.45, 0.6]Standardizing each feature by subtracting training-set means and dividing by training-set standard deviations brings inputs onto comparable scales: 1.5 and 2.0. Their contributions become 0.45 and 0.60. Standardization prevents wide-ranging features from dwarfing small features and warping the loss surface into elongated ravines.
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:
1from math import exp
2
3def stable_sigmoid(z: float) -> float:
4 if z >= 0:
5 return 1.0 / (1.0 + exp(-z))
6 ez = exp(z)
7 return ez / (1.0 + ez)
8
9for logit in [-1000.0, -2.0, 0.0, 2.0, 1000.0]:
10 print(f"logit={logit:7.1f} score={stable_sigmoid(logit):.6f}")1logit=-1000.0 score=0.000000
2logit= -2.0 score=0.119203
3logit= 0.0 score=0.500000
4logit= 2.0 score=0.880797
5logit= 1000.0 score=1.000000For negative logits, evaluating produces numbers close to zero rather than astronomical numbers, ensuring numerical stability across the entire real line.
Now combine shape assertions, the stable sigmoid branch, and routing logic into a verified scoring pipeline:
1import numpy as np
2
3def stable_sigmoid(values: np.ndarray) -> np.ndarray:
4 scores = np.empty_like(values, dtype=float)
5 nonnegative = values >= 0
6 scores[nonnegative] = 1.0 / (1.0 + np.exp(-values[nonnegative]))
7 exp_values = np.exp(values[~nonnegative])
8 scores[~nonnegative] = exp_values / (1.0 + exp_values)
9 return scores
10
11feature_names = ["prompt-size score", "queue-wait score"]
12request_ids = ["CA-104", "CA-208", "CA-311", "CA-412"]
13x = np.array([
14 [2.0, 5.0],
15 [6.0, 8.0],
16 [1.0, 1.0],
17 [8.0, 2.0],
18])
19w1 = np.array([[0.3, -0.1], [0.1, 0.6]])
20b1 = np.array([0.0, 0.0])
21w2 = np.array([0.4, 0.1])
22b2 = -0.5
23
24assert x.shape[1] == len(feature_names) == w1.shape[1]
25assert w1.shape[0] == b1.shape[0] == w2.shape[0]
26
27hidden = np.maximum(0.0, x @ w1.T + b1)
28logits = hidden @ w2 + b2
29scores = stable_sigmoid(logits)
30
31assert np.isclose(scores[0], 0.465, atol=5e-4)
32assert np.all(np.isfinite(stable_sigmoid(np.array([-1000.0, 1000.0]))))
33
34print("Timeout-risk scoring report")
35for request_id, score in zip(request_ids, scores):
36 route = "slow_path" if score >= 0.60 else "fast_path"
37 print(f"{request_id}: score={score:.3f} -> {route}")1Timeout-risk scoring report
2CA-104: score=0.465 -> fast_path
3CA-208: score=0.608 -> slow_path
4CA-311: score=0.413 -> fast_path
5CA-412: score=0.641 -> slow_pathRequests CA-208 and CA-412 cross the 0.60 routing threshold, redirecting them to isolated workers with dedicated timeouts.
Universal approximation and its practical limits
The Universal Approximation Theorem, established by Cybenko (1989) and extended by Hornik et al. (1989), shows that a feed-forward network with a single hidden layer, a sufficient finite number of non-linear neurons, and a linear output can approximate any continuous function on a compact (closed and bounded) subset of to arbitrary precision .[6] [7]
The geometric intuition is surprisingly visual:
- Take two opposing sigmoid functions: . Their difference forms a localized bump or ridge in the coordinate space.
- With ReLU, two shifted ramp functions can construct a triangle bump: .
- By summing many of these localized bumps across dimensions, a wide hidden layer can tile any continuous surface, much like a Riemann sum tiles an integral or Lego blocks build a curved arch.
While theoretically reassuring, the theorem has three critical practical catches:
-
Exponential width explosion: Approximating complex or highly oscillatory functions with just a single hidden layer can demand exponentially many hidden neurons ( for dimensions). Deep architectures compose functions hierarchically: each added layer folds the input space, creating exponential expressive power with only linear parameter growth.
-
Existence doesn't mean learnability: The theorem guarantees that a set of weights exists in parameter space. It doesn't guarantee that gradient descent will find those weights from random initialization, or that optimization won't get trapped in poor local minima or flat saddle points.
-
Generalization requires inductive bias: The theorem only guarantees function fitting on the compact region covered by training data; it says nothing about unseen test points. Architectures like CNNs and Transformers succeed because their structure encodes domain symmetries: CNNs enforce translation invariance on image grids, and Transformers enforce permutation equivariance across token sequences.
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. - When targets are all , for all samples. Since predictions , the logit error is strictly negative for every sample. Negative gradients mean that gradient descent updates () will add positive increments, pulling logits upward to increase sigmoid scores toward 1.0.