Education › AI Engineering › Stage 1: Machine-learning foundations

Neural networks & deep learning

How networks learn, transformers at a glance, and fine-tuning a pretrained model.

Beginner ~40 min read Module 4 of 16

Deep learning is the technology behind image recognition, speech transcription, translation and every large language model. Beneath the scale there is a small set of ideas you have already met: a neural network is layers of matrix multiplications with simple non-linear functions between them, trained by gradient descent. This module shows how a network learns, in real PyTorch. It then explains the transformer, the architecture behind modern AI, at the level you need in order to use it well, and finishes with fine-tuning, which is how you will actually work with these models.

After this module you can
  • Describe a neural network as stacked layers of weights, biases and activation functions
  • Write and explain a PyTorch training loop: forward pass, loss, backward pass, optimiser step
  • Recognise and control overfitting in deep networks, and read training and validation curves
  • Explain tokens, embeddings, self-attention and positional information in a transformer, without heavy mathematics
  • Choose between prompting, fine-tuning and training from scratch, and explain freezing layers and LoRA

From a neuron to a network

A single neuron takes several numbers as input, multiplies each by a weight, adds them up together with a bias, and passes the result through an activation function. That is a dot product followed by a simple non-linear step, which you met in the maths module. A layer is many neurons looking at the same inputs, which is one matrix multiplication. A network is layers stacked so that each feeds the next.

text
one layer:    h = activation(x @ W + b)

input (10 features) -> [Linear 10->64] -> ReLU -> [Linear 64->32] -> ReLU -> [Linear 32->2] -> logits

parameters: (10x64 + 64) + (64x32 + 32) + (32x2 + 2) = 704 + 2,080 + 66 = 2,850

The activation function matters more than it looks. Without it, stacking layers would achieve nothing, because several matrix multiplications in a row collapse into a single one, and the network could only ever draw straight lines. The non-linearity lets each layer bend the space, and stacking layers lets the network compose simple bends into very complex shapes. ReLU, which is simply max(0, x), is the usual default. GELU is a smoother relative used in transformers.

The "deep" in deep learning refers to having many layers. What makes depth useful is that layers build on one another. In an image model, early layers respond to edges, middle layers to textures and shapes, and late layers to whole objects. Nobody programmed those features. They emerged because they reduced the loss. That is the central difference from the classical machine learning of the previous module, where you engineer the features by hand: a deep network learns its own features from raw data, which is why it dominates for images, audio and text, and why it needs so much data and computation.

The training loop

Training repeats four steps: run the inputs through the network to get predictions, which is the forward pass; measure how wrong they are with a loss function; calculate how each weight contributed to that error, which is the backward pass; and nudge every weight slightly in the direction that reduces the loss, which is the optimiser step. The backward pass uses backpropagation, the chain rule applied layer by layer from the output back to the input. PyTorch records the operations of the forward pass and calculates all the gradients for you.

python
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"


class Classifier(nn.Module):
    def __init__(self, n_features: int, n_classes: int):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_features, 64), nn.ReLU(), nn.Dropout(0.2),
            nn.Linear(64, 32), nn.ReLU(),
            nn.Linear(32, n_classes),          # raw logits; no softmax here
        )

    def forward(self, x):
        return self.net(x)


model = Classifier(n_features=10, n_classes=2).to(device)
loss_fn = nn.CrossEntropyLoss()                # applies softmax internally
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)

# X_train: float32 tensor (N, 10).  y_train: int64 tensor (N,) of class indices
train_loader = DataLoader(TensorDataset(X_train, y_train), batch_size=64, shuffle=True)

for epoch in range(20):
    model.train()                              # enables dropout
    for xb, yb in train_loader:
        xb, yb = xb.to(device), yb.to(device)
        optimizer.zero_grad()                  # 1. clear gradients from the last step
        logits = model(xb)                     # 2. forward pass
        loss = loss_fn(logits, yb)             # 3. how wrong?
        loss.backward()                        # 4. backward pass: compute gradients
        optimizer.step()                       # 5. update the weights

    model.eval()                               # disables dropout
    with torch.no_grad():                      # no gradients needed for evaluation
        val_logits = model(X_val.to(device))
        val_loss = loss_fn(val_logits, y_val.to(device)).item()
        val_acc = (val_logits.argmax(dim=1).cpu() == y_val).float().mean().item()
    print(f"epoch {epoch:2d}  train_loss={loss.item():.3f}  val_loss={val_loss:.3f}  val_acc={val_acc:.3f}")
TermMeaning
EpochOne complete pass over the training data
BatchThe handful of examples used for one weight update. Larger batches give smoother gradients and need more memory.
Learning rateThe size of each update. The most important setting; too high diverges, too low crawls.
OptimiserThe update rule. Adam, or AdamW, is the usual default.
TensorPyTorch's array type: like a NumPy array, but it can live on a GPU and track gradients
Watch out

Three lines cause most beginner bugs. Forgetting optimizer.zero_grad() makes gradients accumulate across steps, so training goes haywire. Forgetting model.eval() leaves dropout active during evaluation, which makes the results noisy and pessimistic. And forgetting torch.no_grad() during evaluation wastes memory by recording gradients nobody needs.

Deep learning runs on GPUs because the work is overwhelmingly matrix multiplication, which a GPU performs thousands of times in parallel. The practical constraint is usually GPU memory, which has to hold the weights, the gradients, the optimiser's state and the activations of the current batch. "CUDA out of memory" is the error you will meet most often, and the first remedy is a smaller batch size.

Keeping a deep network honest

A network with millions of parameters can memorise a training set with ease, so everything the previous module said about overfitting applies with extra force. The essential habit is to plot the training loss and the validation loss together as training proceeds.

What the curves showMeaningTry
Both fall and then level off close togetherHealthyStop; more epochs will not help
Training keeps falling while validation turns upwardOverfitting from that point onEarly stopping, more data, dropout, weight decay, augmentation
Both stay highUnderfitting, or a bugA larger model, a higher learning rate, check the data and labels
Loss swings wildly or becomes NaNLearning rate too high, or bad input valuesLower the learning rate, normalise the inputs, clip gradients
Loss does not move at allLearning rate far too low, or gradients not flowingCheck the learning rate, and that the loss is connected to the parameters
  • Early stopping: keep the weights from the epoch with the best validation loss, and stop when it has not improved for several epochs. It is simple and effective.
  • Dropout randomly zeroes a share of activations during training, so that the network cannot depend on any single path.
  • Weight decay is L2 regularisation: it gently pulls weights towards zero.
  • Data augmentation creates plausible variations of the training examples, such as flipped or cropped images, or noise added to audio. It is effectively free data.
  • Normalise the inputs, and use normalisation layers such as LayerNorm or BatchNorm inside deep networks, which makes training much more stable.
  • Overfit one batch first. Before a long run, check that the model can drive the loss to nearly zero on a single small batch. If it cannot, there is a bug in the model or the data pipeline, and no amount of training will fix it.

Transformers at a glance

The transformer, introduced in 2017, is the architecture behind essentially all modern language models, and increasingly behind vision and audio models too. You do not need to be able to implement one. You do need its vocabulary, because it explains how language models behave, what they cost, and where their limits come from.

  1. Tokens. Text is cut into pieces called tokens: common words, fragments of rarer words, punctuation. Each token is an integer ID from a fixed vocabulary of tens of thousands of entries. The model never sees letters or words, only token IDs.
  2. Embeddings. Each token ID is looked up in a table that gives a vector of several hundred or several thousand numbers. These vectors are learned during training, and tokens used in similar ways end up with similar vectors.
  3. Positional information. Attention, the next step, has no built-in sense of order, so information about each token's position is added. Without it, "dog bites man" and "man bites dog" would look the same.
  4. Self-attention. For each token, the model calculates how relevant every other token is to it, and builds a new vector for that token as a weighted mix of the others. In "the server crashed because it ran out of memory", attention is what lets the vector for "it" draw mostly on "server". The relevance scores are dot products, turned into weights by a softmax. Multi-head attention runs several of these in parallel, each free to track a different kind of relationship.
  5. Feed-forward layer. Each token's vector then passes through a small ordinary network, such as you built above.
  6. Repeat. Attention plus feed-forward is one block. Large models stack dozens of blocks, with residual connections and normalisation to keep training stable.
  7. Output. A final layer turns the last vector into a score for every token in the vocabulary, and a softmax makes those scores probabilities. A language model is a classifier that predicts the next token.
BLOCK, REPEATEDtokeniselook upvectorslast positionsampleappend, repeatText"The server crashed"Tokens[464, 4382, 14997]Embeddings+ positionSelf-attentionmix across tokensFeed-forwardper tokenSoftmaxover the vocabularyNext token" because" 0.31
A decoder-only transformer at a glance: text becomes token IDs, each ID becomes a vector with position added, a stack of attention-plus-feed-forward blocks mixes information between tokens, and a final softmax turns the last vector into a probability for every token in the vocabulary.

Two consequences are worth holding on to. First, a transformer processes all the tokens of its input in parallel during training, unlike the older recurrent networks that read one token at a time. That is what made it practical to train on enormous amounts of text. Second, because every token attends to every other token, the cost of attention grows roughly with the square of the sequence length. That is the root of the context window limit, and of the way cost and latency rise as prompts get longer, both of which the next stage examines.

FamilyAttention seesTypical use
Encoder-only (BERT-style)The whole input in both directionsClassification, search, producing embeddings
Decoder-only (GPT-style)Only the tokens to the leftGenerating text one token at a time; most LLMs
Encoder-decoder (T5-style)Encoder: everything; decoder: left only, plus the encoderTranslation, summarisation

Fine-tuning a pretrained model

Training a large model from scratch requires vast data and computation, and almost nobody does it. Transfer learning is the normal route. A pretrained model has already learned general features from an enormous dataset: what edges and textures look like, or how language works. You adapt it to your task with a comparatively tiny dataset, because most of what it needs to know is already there.

ApproachWhat changesData neededWhen
PromptingNothing; you instruct the model in its inputA few examples or noneTry this first, always. The next stage covers it.
Retrieval (RAG)Nothing; relevant documents are added to the promptYour documentsThe model lacks knowledge, as opposed to skill
Feature extractionTrain only a new final layer on top of a frozen modelHundreds to thousands of examplesA small dataset whose task resembles pretraining
Full fine-tuningAll the weights, gentlyThousands upwardsEnough data and computation; the largest quality gain
LoRA and other PEFTSmall added matrices; the original weights stay frozenThousands upwardsLarge models on modest hardware
From scratchEverything, from random initialisationMillions upwardsAlmost never
python
import torch
from torch import nn

# `backbone` is any pretrained model whose final layer has been removed,
# returning a feature vector of size `feature_dim` for each input.

for param in backbone.parameters():
    param.requires_grad = False                # freeze: these weights will not change

head = nn.Linear(feature_dim, n_classes)       # new, randomly initialised, trainable
model = nn.Sequential(backbone, head)

# pass ONLY the trainable parameters to the optimiser
optimizer = torch.optim.AdamW(head.parameters(), lr=1e-3)

# Later, to fine-tune everything: unfreeze, and use a much smaller learning rate
for param in backbone.parameters():
    param.requires_grad = True
optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5)
  • Use a small learning rate when fine-tuning pretrained weights, often a hundred times smaller than for training from scratch. Large updates destroy what the model already knows, which is called catastrophic forgetting.
  • Freeze first, then unfreeze. Train the new head by itself until it is sensible, then unfreeze the rest. Otherwise the random head sends large, meaningless gradients back through the good weights.
  • Preprocess exactly as the pretrained model expects: the same tokeniser, the same image size and normalisation. A mismatch degrades results silently.
  • LoRA (low-rank adaptation) keeps the original weight matrices frozen and learns a pair of small matrices alongside each one, whose product is added to it. Only a tiny fraction of the parameters is trained, so memory needs drop sharply and the result is a small adapter file instead of a full copy of the model. It is the most widely used member of the family called parameter-efficient fine-tuning (PEFT).
  • Evaluate against the simple alternatives. Fine-tuning a language model is rarely the right first step. Prompting and retrieval are cheaper, faster to iterate on, and need no retraining when the base model improves. Fine-tune when you need a consistent style or format, a specialised skill, or a smaller and cheaper model that matches a larger one on a narrow task.
Note

Model hubs distribute pretrained weights for thousands of models. Treat a downloaded model like any other dependency from the supply chain module: check its licence, prefer the safetensors format to pickled files, which can execute code when loaded, and pin the exact revision you tested.

Hands-on practice

Train, overfit, and fine-tune

  1. Install PyTorch. Convert a small tabular dataset, such as the one from the previous module, into float32 feature tensors and int64 label tensors, split into training and validation sets.
  2. Build the classifier from this module. Before training properly, take a single batch of 16 examples and train on it alone until the loss is close to zero. If it will not get there, find the bug.
  3. Train for 50 epochs, recording the training and validation loss each epoch, and plot both curves. Find the epoch where the validation loss is lowest.
  4. Remove dropout and weight decay, and enlarge the hidden layers to 512 units. Retrain, and watch the gap between the two curves open up. Put the regularisation back, and add early stopping.
  5. Set the learning rate to 1.0 and observe what happens, then set it to 1e-6 and observe that. Describe both in a sentence.
  6. Delete optimizer.zero_grad() and see how training behaves. Put it back.
  7. Count the model's parameters with sum(p.numel() for p in model.parameters()), and check the figure by hand from the layer sizes.
  8. Load any small pretrained image or text model. Freeze it, add a new linear head, and train only the head on a small dataset of your own. Then unfreeze it with a learning rate around 2e-5, and compare the results.
Cheat sheet

Neural networks & deep learning — at a glance

Main things to focus on

  • A network is stacked layers of activation(x @ W + b). The non-linearity is what makes depth useful.
  • Deep networks learn their own features, which is why they win on images, audio and text, and why they need data and GPUs.
  • The loop: zero_grad, forward, loss, backward, step. Use model.train() and model.eval() with no_grad() in the right places.
  • Plot training and validation loss together. Divergence between them means overfitting; stop early.
  • Overfit a single batch first, to prove that the model and the data pipeline work.
  • Transformer: tokens, embeddings, position, self-attention, feed-forward, repeated, then a softmax over the vocabulary.
  • Attention cost grows with the square of sequence length, which is where context limits come from.
  • Do not train from scratch. Prompt first, then retrieve, then fine-tune, with a small learning rate, and with LoRA for large models.

PyTorch building blocks

class Net(nn.Module): def forward(self, x): ...Define a model
nn.Linear(in_features, out_features)A fully connected layer: x @ W.T + b
nn.ReLU() / nn.GELU()Activation functions
nn.Dropout(0.2)Zero 20% of activations during training
nn.Sequential(layer1, layer2, ...)Chain layers together
nn.CrossEntropyLoss()Classification loss; takes raw logits and class indices
nn.MSELoss()Regression loss
torch.optim.AdamW(model.parameters(), lr=1e-3)The usual default optimiser
DataLoader(dataset, batch_size=64, shuffle=True)Batches and shuffling

The training step

model.train()Training mode: dropout on, batch norm updating
optimizer.zero_grad()Clear the old gradients
loss = loss_fn(model(xb), yb)Forward pass and loss
loss.backward()Backpropagation: compute every gradient
optimizer.step()Update the weights
model.eval(); with torch.no_grad(): ...Evaluation: no dropout, no gradient tracking
torch.save(model.state_dict(), "model.pt")Save the weights
model.load_state_dict(torch.load("model.pt"))Load the weights

Tensors and devices

torch.tensor(data, dtype=torch.float32)Create a tensor
torch.from_numpy(array)Share memory with a NumPy array
x.shape / x.dtype / x.deviceInspect a tensor
x.to(device)Move to the GPU or CPU; model and data must be on the same device
logits.argmax(dim=1)Predicted class per row
loss.item()A plain Python number from a one-element tensor
sum(p.numel() for p in model.parameters())Count the parameters

Reading loss curves

both fall, then level off togetherHealthy; stop
train falls, validation risesOverfitting; early stopping and regularisation
both stay highUnderfitting or a bug; try overfitting one batch
NaN or wild swingsLower the learning rate; normalise inputs; clip gradients
completely flatLearning rate too low, or gradients not flowing
CUDA out of memoryReduce the batch size first

Transformer and fine-tuning vocabulary

tokenA piece of text mapped to an integer ID
embeddingA learned vector for each token
self-attentionEach token becomes a relevance-weighted mix of all the tokens
context windowMaximum number of tokens the model can attend over
param.requires_grad = FalseFreeze a parameter
lr ~ 1e-5 to 5e-5Typical learning rates for fine-tuning pretrained weights
LoRA / PEFTTrain small added matrices; keep the original weights frozen
safetensorsA weight file format that cannot execute code on loading

Common pitfalls

  • Forgetting optimizer.zero_grad(), so that gradients pile up from one step to the next.
  • Evaluating with the model still in training mode, leaving dropout active.
  • Putting a softmax before nn.CrossEntropyLoss, which already applies one.
  • Watching only the training loss and never noticing that the validation loss turned upward.
  • Fine-tuning pretrained weights with a from-scratch learning rate and wiping out what they knew.
  • Reaching for fine-tuning, or for training from scratch, before trying a prompt or retrieval.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →