Deep Learning Intermediate–Advanced ⏱ 35 min GPTtransformertrainingPyTorchgradient descentlanguage modelloss curveshands-on exercisedeep learning

Train Your Own GPT

Published 2026-07-10 — Dr Neal Aggarwal
📚 Lessons series — #5. This is the hands-on payoff of Lesson 4, and it leans on the gradient-descent foundations of Lesson 1 and Lesson 2. You'll want a laptop with Python and PyTorch installed. The full curriculum lives on the Lessons page.

Train Your Own GPT

Lesson 4 ended with a promise: stop reading and train one. Today we keep it.

By the end of this lesson you will have taken the TinyGPT we built last time, pointed it at a text corpus small enough for a laptop, written the training loop with your own hands, and — this is the part I want you to actually watch, in real time, rather than take on faith — seen its output evolve from random characters, to word-shaped gibberish, to grammatical sentences in the voice of your corpus. Nothing I know of calibrates your intuitions about what these models are better than watching one condense out of randomness in front of you. It takes about an hour, most of which is the computer's time rather than yours.

What "training" actually means here

Let's assemble the pieces we already own. From Lessons 1–2: a neural network is a parameterised function, a loss measures how wrong it is on data, and gradient descent nudges every parameter slightly downhill on that loss, over and over. From Lesson 4: the transformer is such a function — it reads a token sequence and outputs, at every position, a probability distribution over the next token.

Training a GPT is nothing more than the marriage of the two:

ONE TRAINING STEP — REPEATED THOUSANDS OF TIMES ① Grab a batch random snippets of corpus, targets = same text shifted by 1 ② Forward pass model predicts next-token probabilities at every position ③ Score it cross-entropy: how surprised was it by the real next char? ④ Backward pass gradient of loss w.r.t. every one of the ~800k parameters ⑤ Optimizer step every parameter nudged a tiny distance downhill repeat ~5,000× This is Lessons 1–2's gradient descent, applied to Lesson 4's architecture. There is no additional idea.
Figure 1. The whole training loop. Note what's absent: no labels, no annotation effort, no human grading. The text itself is the supervision — every character is the "correct answer" for the characters before it. This is why language models can train on essentially unlimited data.
💡 Key idea. Language modelling is self-supervised: the training signal is manufactured from raw text by shifting it one position. That single trick — no labelling bottleneck — is arguably as responsible for the LLM era as the transformer itself. Compare the labelled-data famine in medical imaging, where every training example costs radiologist-hours.

The corpus

Frontier models train on trillions of tokens. We need something a laptop can chew through in minutes, which means roughly a few megabytes of plain text — and at this scale, the honest choice is character-level modelling: our vocabulary will be the set of distinct characters in the file (typically 60–100), not the 50,000-piece token vocabularies of Lesson 4. Every concept transfers unchanged; only the granularity differs.

Any plain-text file works — collected essays, public-domain novels, your own writing. Being who I am, I trained mine on a public-domain classic of medical literature: the 1918 edition of Gray's Anatomy, freely available from Project Gutenberg (about 5 MB of glorious Edwardian anatomical prose). Watching a neural network learn to hallucinate confident anatomy is an instructive experience for any clinician — more on that below. Save whatever you choose as corpus.txt next to your script.

The data pipeline

Ten lines. We build the character vocabulary, encode the whole corpus as integers, hold out the last 10% for validation, and write a function that serves random training snippets:

import torch

text = open("corpus.txt", encoding="utf-8").read()
chars = sorted(set(text))
vocab_size = len(chars)                      # typically 60–100 characters
stoi = {c: i for i, c in enumerate(chars)}
itos = {i: c for c, i in stoi.items()}
encode = lambda s: [stoi[c] for c in s]
decode = lambda t: "".join(itos[i] for i in t)

data = torch.tensor(encode(text), dtype=torch.long)
n = int(0.9 * len(data))
train_data, val_data = data[:n], data[n:]    # last 10% never trained on

ctx, batch_size = 256, 64

def get_batch(split):
    d = train_data if split == "train" else val_data
    ix = torch.randint(len(d) - ctx - 1, (batch_size,))
    x = torch.stack([d[i     : i+ctx    ] for i in ix])   # inputs
    y = torch.stack([d[i + 1 : i+ctx + 1] for i in ix])   # same text, shifted by 1
    return x.to(device), y.to(device)

Look hard at get_batch — it is Figure 1's step ① and the heart of self-supervision. The target y is literally the input x shifted one character. Because of the causal mask from Lesson 4, position t in the model's output only ever saw characters up to t, so predicting y[t] — the character at t+1 — is a fair exam, at all 256 positions of all 64 snippets simultaneously. One batch therefore contains 16,384 individual next-character exams.

The held-out 10% deserves emphasis. The model will never take a gradient step on it, which makes it our external validation cohort: performance there tells us the model is learning the language, not memorising the file. Every clinician who has watched a risk score validated only on its derivation cohort knows exactly why this split is non-negotiable.

The training loop

Bring in the TinyGPT from Lesson 4 unchanged, then:

import torch.nn.functional as F

device = ("mps" if torch.backends.mps.is_available()      # Apple Silicon
          else "cuda" if torch.cuda.is_available()        # NVIDIA
          else "cpu")

model = TinyGPT(vocab_size).to(device)     # ~800k parameters at default size
opt = torch.optim.AdamW(model.parameters(), lr=3e-4)

@torch.no_grad()
def estimate_loss(split, iters=50):
    model.eval()
    losses = []
    for _ in range(iters):
        x, y = get_batch(split)
        logits = model(x)
        losses.append(F.cross_entropy(
            logits.view(-1, vocab_size), y.view(-1)).item())
    model.train()
    return sum(losses) / len(losses)

for step in range(5001):
    if step % 500 == 0:
        tr, va = estimate_loss("train"), estimate_loss("val")
        print(f"step {step:5d}   train {tr:.3f}   val {va:.3f}")

    x, y = get_batch("train")
    logits = model(x)                                          # ② forward
    loss = F.cross_entropy(logits.view(-1, vocab_size),        # ③ score
                           y.view(-1))
    opt.zero_grad()
    loss.backward()                                            # ④ gradients
    opt.step()                                                 # ⑤ nudge

That's the entire loop — compare it line by line against Figure 1. Two notes for the practitioners:

The optimizer is AdamW, not raw gradient descent. It's the same "step downhill" idea from Lesson 2, with two refinements earned by a decade of practice: each parameter gets an adaptive step size based on its recent gradient history, and a slight pull toward zero (weight decay) discourages overfitting. You could train with plain SGD; it would just take longer and land somewhere slightly worse.

The loss number is interpretable — use that. Cross-entropy here is the model's average surprise, in units of nats, at each true next character. Before training, with ~85 characters all equally likely, expect ln(85) ≈ 4.4. A model that has learned English character statistics lands around 2.0; a well-trained one on this corpus reaches 1.4–1.5 on validation. When your step-0 print shows ≈4.4, that's your first sanity check passing: the untrained model is exactly as ignorant as theory predicts.

Sampling: watching it think out loud

To generate text we run Lesson 4's Figure 1 literally — predict, sample, append, repeat:

@torch.no_grad()
def generate(model, prompt, max_new_tokens=400, temperature=1.0):
    idx = torch.tensor([encode(prompt)], device=device)
    model.eval()
    for _ in range(max_new_tokens):
        logits = model(idx[:, -ctx:])            # crop to context window
        logits = logits[:, -1, :] / temperature  # last position only
        probs = F.softmax(logits, dim=-1)
        nxt = torch.multinomial(probs, num_samples=1)
        idx = torch.cat([idx, nxt], dim=1)
    model.train()
    return decode(idx[0].tolist())

Now add print(generate(model, "The ")) inside the every-500-steps block, run the script, and watch. This is the part I promised. Roughly — your exact output will differ — here is what my Gray's Anatomy run produced:

step 0 — loss 4.43 — pure noise:
The q)Kf;öJ2và]x9Yt(Q…7zR&fB[wp0ûNnV,ceT!SkAé§m8—D5W&)Hq;;ô
step 500 — loss 2.31 — word-shaped noise:
The pertion of the mecle is ativery of the sof the brone and lation, and the extremity of the moth whith the ceparatirs of the
step 2000 — loss 1.72 — the corpus's voice emerges:
The muscle is inserted into the outer surface of the humerus, and is supplied by the anterior branches of the artery which descend behind the tendon
step 5000 — loss 1.46 — fluent, confident, and frequently wrong:
The internal carotid artery ascends in front of the transverse process of the axis, and divides into two branches, which supply the deep surface of the deltoid and the integument of the back of the neck.

Sit with that progression for a moment, because it compresses the entire deep-learning story into four snippets. At step 0: uniform randomness. By 500 the model has learned the statistics of English spelling — letter frequencies, vowel placement, word lengths — with no idea what a word means. By 2000 it produces real anatomical vocabulary in grammatical arrangements. By 5000 it writes fluent Edwardian anatomical prose that is anatomically wrong — that final sample is confident nonsense; the internal carotid does no such thing.

⚕️ And there it is: your first hallucination, bred in captivity. Nobody "programmed" it to make things up. The model learned to produce text that is statistically shaped like its corpus, and at 800k parameters, statistical shape is all it can afford. Fluency arrives long before fidelity — and fluency is what human readers instinctively use as a proxy for fidelity. Every clinical warning I've written about trusting LLM output traces back to this exact asymmetry, and now you've watched it form from scratch on your own laptop.

Reading the loss curves

While samples give you the qualitative story, the two loss numbers you're printing tell the quantitative one:

WHAT THE TWO LOSSES TELL YOU 4.4 2.9 1.4 training steps → loss (nats) steep early drop: spelling statistics are cheap to learn long slow middle: grammar, vocabulary, style ⚠ curves part company: memorisation begins — train keeps falling, val stalls. Stop here. train loss (seen data) val loss (held-out data)
Figure 2. The characteristic shape of a small-model training run. The validation curve is the only one you should ever brag about: it measures generalisation to text the model has never seen. The moment it stops falling while train loss continues down, further training is teaching the model your file, not your language.

That divergence point is overfitting, and our 800k-parameter model on a 5 MB corpus will hit it eventually — the model is small relative to the data, so it holds off for a while, but it comes. The remedies are the classics: more data, a smaller model, more weight decay, or simply stopping when validation loss bottoms out. If this sounds exactly like the derivation-cohort-versus-validation-cohort discipline of clinical prediction models: yes. It is the same statistics wearing different clothes.

Practical notes for your run

On timing: with the default settings (4 layers, 128-wide, context 256), a 5,000-step run takes roughly 10–20 minutes on an Apple Silicon Mac (the script auto-selects the mps backend) or a modest NVIDIA GPU, and an hour or two on pure CPU. If CPU is all you have, drop ctx to 128 and the model to 3 layers — the qualitative arc from noise to sentences survives entirely intact.

On knobs worth turning once it works, in rough order of instructional value: temperature in generate() (0.5 is cautious and repetitive; 1.5 is creative and unhinged — an intuition worth having, since it's the same parameter you set in every LLM API); the learning rate (try 3e-3 and watch training destabilise — the divergence you'll see is Lesson 2's "overshooting the valley" made vivid); model width and depth (double both and watch validation loss improve — a miniature scaling law on your own hardware); and the corpus itself (swap in a different author; the model's entire personality follows the data — nothing else changed).

The complete script — data pipeline, Lesson 4's model, training loop, sampling, all assembled and commented — is here: train_tinygpt.py. Read it top to bottom before running it; it's ~130 lines and there is nothing in it you haven't now met.

What scaling changes — and what it doesn't

It's worth saying plainly what separates your afternoon's model from a frontier one, because the list is shorter than most people assume: more data (megabytes → tens of trillions of tokens), more parameters (800k → hundreds of billions), subword tokens instead of characters (Lesson 4), longer context, and a few architectural refinements per year of engineering. The training loop you just wrote — batch, forward, cross-entropy, backward, step — is, to a first approximation, what the big labs run, distributed across thousands of GPUs for months. The loss-versus-compute curves you sketched by doubling your model's width become, at industrial scale, the scaling laws that let labs forecast a model's loss before spending the money to train it.

And one thing scaling does not change: the model that comes out of this loop — at any size — is a raw next-token predictor with exactly the confident-fluency-without-fidelity property you bred in your own laptop run. It answers questions by continuing them, sometimes with more questions. Turning that raw material into something that reliably helps — that answers, follows instructions, declines gracefully, cites honestly — is a separate act of shaping, performed after pretraining.

What's next

That shaping is Lesson 6 — now live: post-training — how a base model becomes an assistant. Instruction tuning, feedback-based refinement, why the same weights can host such different behaviours, and what that pipeline means for the failure modes you'll meet in deployed clinical tools. The raw predictor you trained today is the "before" picture; next time we look at the "after," and at exactly what happens in between.

Until then: run the script. Change the corpus. Break the learning rate on purpose. The point of this lesson was never the 8 MB of weights you'll end up with — it's that "GPT" now names a process you have personally executed, end to end, rather than a product you consume.

— Neal

📚 Continue the series: all lessons, in order, on the Lessons page.
tags: GPT transformer training PyTorch gradient descent language model loss curves hands-on exercise deep learning