Forward Pass and Computational Graphs
A neural network is a function composition, layer feeding layer, and the graph of that composition is exactly what gets differentiated to train the network. Writing the forward pass explicitly as a graph of small, primitive operations is what turns "compute gradients" from a calculus exercise into a completely mechanical procedure — this page establishes that graph view before Backpropagation differentiates it.
Writing the forward pass as a graph of primitive operations is what makes automatic differentiation mechanical rather than clever.

Layer as affine transform plus activation
Each layer computes:
An affine (linear-plus-offset) transform, then a non-linearity (Activation Functions) applied elementwise.
The full forward pass for an L-layer net
Starting from (the input), apply the layer equation above repeatedly for , with the network's final output.
Shape bookkeeping
| Quantity | Shape |
|---|---|
| per example, or for a batch | |
| per example |
Tracking these shapes explicitly at every layer is the single most useful habit for avoiding the broadcasting bugs introduced in Linear Algebra.
Batching, and why the batch dimension comes first
Processing examples one at a time wastes the parallel hardware GPUs and vectorised CPU operations are built for. Stacking examples into a single matrix lets one matrix multiplication compute all forward passes simultaneously. The convention of putting the batch dimension first ((batch, features) rather than (features, batch)) is nearly universal across frameworks, though the underlying math is identical either way.
| Symbol | Meaning |
|---|---|
| pre-activation (linear output) of layer | |
| post-activation output of layer | |
| number of units in layer | |
| batch size |
The computational graph
Represent the forward pass as a directed graph: nodes are operations (matrix multiply, add, activation), edges are the tensors flowing between them. This is not a metaphor — frameworks like PyTorch build this exact graph at runtime as operations execute, and Backpropagation is simply traversing this graph in reverse.
Static vs. dynamic graphs
Static graphs (TensorFlow 1.x's original design) are built once, ahead of time, then executed repeatedly — allows aggressive optimisation but makes debugging and variable-length inputs awkward. Dynamic graphs (PyTorch, and now TensorFlow's default eager mode) are rebuilt on every forward pass, exactly following the Python control flow that ran — easier to debug (a stack trace points at real Python code) and naturally handles variable-length or conditional computation, at some historical optimisation cost that modern JIT compilers have largely closed.
Memory: what must be cached for the backward pass
Computing during backpropagation requires (from the forward pass) — so every intermediate activation must be kept in memory until its corresponding backward computation runs. This is exactly what GPU Training and Mixed Precision's gradient checkpointing trades away (recomputing instead of storing) when memory is the bottleneck.
Code: a forward pass with explicit shape assertions
import numpy as np
def sigmoid(z):
return 1 / (1 + np.exp(-z))
def forward_pass(x, layer_sizes, weights, biases, verbose=True):
a = x
if verbose:
print(f"input shape: {a.shape}")
for l, (W, b) in enumerate(zip(weights, biases)):
z = a @ W + b
assert z.shape == (a.shape[0], W.shape[1]), f"shape mismatch at layer {l}"
a = sigmoid(z)
if verbose:
print(f"layer {l}: W{W.shape} -> z{z.shape} -> a{a.shape}")
return a
rng = np.random.default_rng(0)
layer_sizes = [10, 32, 16, 1] # input dim 10, two hidden layers, scalar output
batch_size = 8
weights = [rng.normal(scale=0.1, size=(layer_sizes[i], layer_sizes[i+1])) for i in range(len(layer_sizes)-1)]
biases = [np.zeros(layer_sizes[i+1]) for i in range(len(layer_sizes)-1)]
X = rng.normal(size=(batch_size, layer_sizes[0]))
output = forward_pass(X, layer_sizes, weights, biases)
print("final output shape:", output.shape)
# --- A deliberate shape mismatch, caught loudly ---
try:
bad_weights = weights.copy()
bad_weights[1] = rng.normal(size=(999, layer_sizes[2] + 1)) # wrong input dim
forward_pass(X, layer_sizes, bad_weights, biases, verbose=False)
except (ValueError, AssertionError) as e:
print(f"caught the expected shape error: {e}")
See also
- Backpropagation — differentiating this exact graph, edge by edge, in reverse.
- Linear Algebra — the matrix shapes and operations this forward pass is built from.