Neural Network from Scratch · lesson 03/7
Implementing a Network
A network is layers composed in sequence, where each layer's output becomes the next layer's input. The composition is what buys the extra expressiveness: a single linear layer can only draw one straight boundary, and no amount of training fixes that. Chaining a nonlinear layer in front of it can fit boundaries that are not lines at all.
The idea
Build a network by chaining functions. A two-layer model is f(x) = W₂ · ReLU(W₁x + b₁) + b₂, evaluated left to right: hidden activations, then output logits.
- Shape chaining. Each layer's
out_featuresmust equal the next layer'sin_features, or the matmul fails. - Parameter counting.
2 → 4 → 1holds2·4 + 4 = 12weights and biases in the hidden layer,4·1 + 1 = 5in the output layer: 17 trainable numbers. - Nonlinearity between layers. Remove the ReLU and
W₂W₁x + bcollapses into one linear map, which has exactly the representational power of a single layer. - The training loop contract. Forward pass, compute loss,
zero_grad(),backward(),step(). Skipzero_grad()and gradients accumulate across steps; callstep()beforebackward()and the optimizer moves on stale gradients.
XOR is the standard test of this machinery. Its four examples are not linearly separable: no single line splits (0,1) and (1,0) from (0,0) and (1,1).
Worked example
Train two models on the XOR truth table, targets (0, 1, 1, 0).
- A single
Linear(2, 1)plateaus at a loss of0.6931with every input predicted at probability0.5. That isln 2, the loss of guessing blindly. - A
Linear(2, 4) → ReLU → Linear(4, 1)network reaches0.0008and predictions(0.000, 1.000, 0.998, 0.000).
The MLP loss trace at learning rate 0.5 is 0.6877 at step 0, 0.1044 at step 100, 0.0083 at step 500, and 0.0008 at step 4000. Random initialization still matters: with torch.manual_seed(2) the same network stalls at 0.6931, because its ReLU units start below zero for every input and receive no gradient.
In code
import torch
X = torch.tensor([[0., 0.], [0., 1.], [1., 0.], [1., 1.]])
y = torch.tensor([[0.], [1.], [1.], [0.]])
lossf = torch.nn.BCEWithLogitsLoss()
torch.manual_seed(0)
model = torch.nn.Sequential(
torch.nn.Linear(2, 4), torch.nn.ReLU(), torch.nn.Linear(4, 1)
)
opt = torch.optim.SGD(model.parameters(), lr=0.5)
step = 0
for _ in range(4001):
opt.zero_grad()
loss = lossf(model(X), y)
loss.backward()
opt.step()
if step in (0, 100, 500, 4000):
print(step, round(loss.item(), 4)) # 0.6877 0.1044 0.0083 0.0008
step += 1
print([round(v, 3) for v in model(X).sigmoid().flatten().tolist()]) # [0.0, 1.0, 0.998, 0.0]
print(sum(p.numel() for p in model.parameters())) # 17Check yourself
- Why can no
Linear(2, 1)model fit XOR, no matter how long it trains? - What does
opt.zero_grad()clear, and what goes wrong within a minute of training without it? - If you delete the ReLU from this network, what does the model reduce to, and what loss will it reach on XOR?
Key takeaways
- A network is a chain of layers, and only the nonlinearity between them makes depth worth paying for.
- The training loop is forward, loss,
zero_grad,backward,step— in that order, every step. - Small networks can fail from initialization as easily as from architecture; a stalled loss is a clue.