Skip to content

09 · Neural Network Fundamentals with PyTorch

Neural networks power modern AI — vision, speech, language models — and PyTorch is the dominant way to build them. Under the hype, a neural net is just layers of linear regression with non-linear "squashes" between them, trained by gradient descent. This module builds that understanding from the ground up: tensors, autograd, a small multi-layer perceptron (MLP), and the training loop — the four ideas that everything from here to GPT is built on.

Tensors: NumPy arrays with superpowers

A torch.Tensor is an N-D array like ndarray, with two additions: it can live on a GPU, and it can track gradients.

import torch

t = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
print(t.shape, t.dtype)     # torch.Size([2, 2]) torch.float32

print(t * 2)                # elementwise, just like NumPy
print(t @ t)                # matrix multiply
print(t.mean(dim=0))        # tensor([2., 3.])  -- dim ~ NumPy's axis

# NumPy interop is trivial:
import numpy as np
a = np.array([1.0, 2.0, 3.0])
t2 = torch.from_numpy(a)
back = t2.numpy()

Everything you learned about shapes, vectorization, and axis-wise reduction transfers directly — dim instead of axis, float32 (not 64) as the default working dtype.

device = "cuda" if torch.cuda.is_available() else "cpu"
print(device)               # "cpu" is fine for everything in this module

Autograd: derivatives for free

Training means adjusting weights to reduce a loss, which requires the loss's derivative with respect to every weight. PyTorch records the operations you perform and computes all those derivatives automatically:

w = torch.tensor(3.0, requires_grad=True)
loss = (w - 5.0) ** 2       # loss is smallest at w = 5
loss.backward()             # compute d(loss)/dw
print(w.grad)               # tensor(-4.)  -- indeed 2*(3-5) = -4

The gradient says "loss decreases if w increases" (negative slope), so gradient descent nudges w a small step against the gradient: w ← w − lr · grad. Repeat thousands of times over all weights at once and that's deep learning — everything else is bookkeeping.

A dataset and a network

We'll classify make_moons — the two interleaved crescents from Module 06 that defeat any straight-line classifier:

import torch
from torch import nn
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

X, y = make_moons(n_samples=1000, noise=0.15, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, random_state=42, stratify=y
)
scaler = StandardScaler().fit(X_train)          # same rules as always:
X_train, X_test = scaler.transform(X_train), scaler.transform(X_test)

# to float32 tensors
X_train_t = torch.tensor(X_train, dtype=torch.float32)
y_train_t = torch.tensor(y_train, dtype=torch.long)
X_test_t  = torch.tensor(X_test, dtype=torch.float32)
y_test_t  = torch.tensor(y_test, dtype=torch.long)

The model — an MLP with one hidden layer:

torch.manual_seed(42)

model = nn.Sequential(
    nn.Linear(2, 16),   # 2 inputs -> 16 hidden units (a linear layer = Wx + b)
    nn.ReLU(),          # non-linearity: max(0, x)
    nn.Linear(16, 2),   # 16 hidden -> 2 output scores ("logits"), one per class
)
print(sum(p.numel() for p in model.parameters()), "parameters")   # 82

The ReLU between the linear layers is what makes this more than linear regression: without it, two stacked linear layers collapse into one linear map, and the crescents stay unseparable. With it, the network can bend its decision boundary.

The training loop: forward → loss → backward → step

This loop is deep learning. Every framework wraps it; PyTorch shows it to you honestly:

loss_fn   = nn.CrossEntropyLoss()                       # standard classification loss
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)

for epoch in range(200):
    model.train()
    logits = model(X_train_t)              # 1. forward: predictions
    loss   = loss_fn(logits, y_train_t)    # 2. loss: how wrong are we?
    optimizer.zero_grad()                  #    (clear old gradients)
    loss.backward()                        # 3. backward: gradients via autograd
    optimizer.step()                       # 4. step: nudge every weight

    if epoch % 50 == 0:
        print(f"epoch {epoch:3d}  loss {loss.item():.4f}")
# epoch   0  loss 0.7075
# epoch  50  loss 0.2242
# epoch 100  loss 0.1103
# epoch 150  loss 0.0674

Line by line: the forward pass computes predictions; CrossEntropyLoss converts logits + true labels into a single wrongness number; backward() fills p.grad for all 82 parameters; step() applies the Adam update rule. zero_grad() matters because PyTorch accumulates gradients by default — forget it and updates go haywire (a classic first bug).

Evaluate:

model.eval()
with torch.no_grad():                       # no gradient tracking needed
    test_logits = model(X_test_t)
    pred = test_logits.argmax(dim=1)
    acc = (pred == y_test_t).float().mean()
print(f"test accuracy: {acc.item():.3f}")   # test accuracy: ~0.99

~99% on data a linear model caps out around 85% on — the hidden layer learned the curve. (model.eval()/model.train() toggle layers like dropout; harmless here, a vital habit later.)

For datasets too big to feed at once, PyTorch batches with DataLoader — same loop, wrapped in for xb, yb in loader::

from torch.utils.data import TensorDataset, DataLoader
loader = DataLoader(TensorDataset(X_train_t, y_train_t),
                    batch_size=64, shuffle=True)

Each pass through the whole dataset is an epoch; each xb is a mini-batch. Mini-batching is why deep learning scales to datasets that don't fit in memory.

When deep learning is — and isn't — the right tool

Situation Reach for
Tabular data, hundreds–100k rows Gradient-boosted trees / random forests — they still routinely beat neural nets here.
Images, audio, text Neural networks — nothing else is close.
Need interpretable coefficients Linear/logistic regression.
Tiny dataset (< a few hundred rows) Simple models; a neural net will memorize it.
Huge data + complex patterns + GPU budget Neural networks shine.

The honest summary for Level 1: for the tabular problems in this course, scikit-learn models are usually equal or better with far less fuss. You learn PyTorch now because the concepts (tensors, autograd, the loop) are the foundation for Level 2's CNNs and Level 3's transformers.

Cheat sheet

Task Code
Tensor from data torch.tensor(arr, dtype=torch.float32)
Labels for CrossEntropy dtype torch.long
Track gradients requires_grad=True; loss.backward(); .grad
Define an MLP nn.Sequential(nn.Linear(i,h), nn.ReLU(), nn.Linear(h,o))
Classification loss nn.CrossEntropyLoss() (takes raw logits)
Regression loss nn.MSELoss()
Optimizer torch.optim.Adam(model.parameters(), lr=0.01)
The loop forward → losszero_grad()backward()step()
Inference mode model.eval() + with torch.no_grad():
Predicted class logits.argmax(dim=1)
Reproducibility torch.manual_seed(42)
Mini-batches DataLoader(TensorDataset(X, y), batch_size=64, shuffle=True)

How It Actually Works

Autograd builds a computation graph as operations run, then walks it backward. Every tensor operation you perform on a requires_grad=True tensor doesn't just compute a value — it also records, on a grad_fn attached to the result, which operation produced it and from which input tensors. This builds up a directed acyclic graph (DAG) of the whole forward pass, node by node, as it executes ("define-by-run"). loss.backward() then walks that DAG from the loss node back to every leaf tensor, applying the chain rule of calculus at each node: if y = f(x), the gradient flowing into x is dL/dx = dL/dy · dy/dx, where dy/dx is a small, hand-derived local formula PyTorch knows for that specific operation (for w - 5, dy/dw = 1; for squaring, d(y²)/dy = 2y). Composing these local derivatives back through the graph is exactly how loss.backward() fills w.grad with 2*(3-5) = -4 — no numerical approximation, no finite differences, just mechanical application of the chain rule at each recorded step. For a real network with 82 parameters, the same graph-walk computes all 82 partial derivatives in one backward pass, which is what makes training thousands of weights per step computationally tractable.

Why gradient descent's update rule works. The gradient dL/dw points in the direction of steepest increase of the loss with respect to w; moving w a small step in the opposite direction (w ← w - lr·grad) is therefore guaranteed, for a small enough step, to decrease the loss — that's the entire mathematical justification, a first-order Taylor approximation: L(w - lr·grad) ≈ L(w) - lr·grad², which is less than L(w) whenever lr and grad² are positive. lr (learning rate) scales how big that step is; too large and the linear approximation breaks down (the update overshoots and the loss can increase instead — the "divergence" the exercise asks you to watch for with lr=1.0). Adam, used here instead of plain gradient descent, additionally tracks a running average of past gradients and their squares per parameter, adapting the effective step size per-weight — which is why it converges faster and more reliably than a fixed-rate update, without changing the underlying chain-rule math.

Why ReLU is not a cosmetic detail — it's what makes depth meaningful. A linear layer computes Wx + b. Stacking two linear layers gives W2(W1x + b1) + b2 = (W2W1)x + (W2b1 + b2), which is algebraically just another linear layer with weights W2W1 — no matter how many linear layers you stack, the composition collapses to one matrix multiply, capable only of drawing a single straight decision boundary. nn.ReLU() (max(0, x), applied elementwise) breaks that collapse because it's not linear: f(a+b) ≠ f(a)+f(b) in general. With a ReLU between them, the two Linear layers cannot be algebraically merged into one, and the resulting function is genuinely piecewise-linear — literally made of many flat linear "facets" stitched together at the points where individual ReLU units switch between passing their input through and outputting zero. With 16 hidden units there are up to 16 such switching boundaries the network can place, which is exactly the mechanism that lets it bend around the interleaved moons instead of being stuck with one straight line — and exactly why deleting nn.ReLU() caps accuracy at whatever a plain linear classifier achieves, regardless of hidden-layer width.

CrossEntropyLoss combines softmax and log-likelihood in one numerically stable step. The network's final Linear(16, 2) layer outputs raw, unbounded "logits" — one real number per class. CrossEntropyLoss internally applies softmax (p_c = e^(z_c) / Σ_k e^(z_k), turning logits into a probability distribution that sums to 1) and then computes -log(p_true_class) — the negative log-probability the model assigned to the correct class. A model that's confidently correct produces a loss near 0; one that's confidently wrong produces a loss that grows without bound, which is precisely the pressure that drives backward()'s gradients: weights are pushed to raise the logit of the true class and lower the others, batch after batch, until the loss curve you see printed (0.71 → 0.07) flattens out.

Exercise

Three experiments on the moons setup, changing one thing at a time: (1) delete the nn.ReLU() — how good can the purely-linear network get, and why does that ceiling exist? (2) restore ReLU but shrink the hidden layer to 2 units, then grow it to 64 — compare test accuracy and note diminishing returns; (3) set lr=1.0 and watch the loss curve — describe what "divergence" looks like. Bonus: switch the data to make_circles(noise=0.1, factor=0.4) and check the same 16-unit network still works.