๐Ÿ“š Study Notes / Home / Neural Nets / Session 20
Session 20 ยท Project โ€” Train a Small LM

Project Week: train your very own small language model

This is the capstone โ€” the session where everything you've learned becomes a real, running thing. Across this course you built MLPs, hand-cranked backprop, taught CNNs to see, gave RNNs memory, and finally cracked attention and transformers. Today we put it all to work: we'll take Andrej Karpathy's nanoGPT, pretrain a tiny GPT from scratch on a text dataset, watch it learn to babble in English, and then specialise it cheaply using LoRA. Every step has complete, runnable code. Take it slow, run things yourself, and by the end you'll have trained a language model with your own hands.

โฑ 19 min read๐Ÿ“– 4 topics

1 From toy to real โ€” scaling up with nanoGPT


Explain like I'm 5

Last week you built a little toy car out of LEGO that actually rolled. It worked! But it was tiny and slow. Now imagine someone hands you a proper starter kit โ€” real wheels, a real motor, clear instructions โ€” so you can build a car that drives across the whole room. nanoGPT is that starter kit for language models. It's the same idea as your toy mini-transformer, just tidied up, sped up, and ready to actually train on lots of text.

In Session 19 you built a mini-transformer by hand โ€” embeddings, self-attention, a feed-forward block, and a next-token loss. That was the "toy car." It taught you that the whole machine is understandable. The problem with hand-rolled code is that it's slow and missing the unglamorous bits (efficient data loading, mixed precision, checkpointing, learning-rate schedules). nanoGPT is a famous, deliberately small, readable codebase by Andrej Karpathy that has all those bits while staying simple enough to read in an afternoon.

The big idea

A "real" GPT is not a different machine from your toy. It's the exact same transformer block (attention + feed-forward + residuals + layer-norm, recall Session 18) stacked deeper, made wider, and fed far more data with an industrial training loop. Scaling up is mostly about plumbing and patience, not new ideas.

The four stages of any GPT project

nanoGPT organises the work into four scripts, and this is a great mental model for every language-model project:

๐Ÿ“š
1. Data
Tokenise text โ†’ binary token stream
โ†’
โš™๏ธ
2. Config
Set model size + training knobs
โ†’
๐Ÿ‹๏ธ
3. Train
Run the loop, watch loss fall
โ†’
๐Ÿ’ฌ
4. Sample
Generate text from the model

Let's name the parts. The training pipeline is this whole assembly line. prepare.py turns raw text into a stream of token IDs saved to disk. train.py reads those tokens, builds the GPT, and runs gradient descent. sample.py loads a saved checkpoint (the model's learned weights on disk) and generates new text.

Getting nanoGPT and seeing the model

Worked example โ€” clone it and look at the model

First, grab the code and dependencies:

# Clone Karpathy's nanoGPT and install the essentials
git clone https://github.com/karpathy/nanoGPT
cd nanoGPT
pip install torch numpy tiktoken datasets tqdm

The heart of it is model.py. Stripped to its skeleton, a nanoGPT block is exactly the transformer you built in Session 19:

class Block(nn.Module):           # one transformer layer
    def __init__(self, config):
        self.ln_1 = nn.LayerNorm(config.n_embd)
        self.attn = CausalSelfAttention(config)   # masked self-attention
        self.ln_2 = nn.LayerNorm(config.n_embd)
        self.mlp  = MLP(config)                    # the feed-forward part

    def forward(self, x):
        x = x + self.attn(self.ln_1(x))   # residual + attention
        x = x + self.mlp(self.ln_2(x))    # residual + MLP
        return x

Notice x = x + ... twice โ€” those are the residual connections from Session 18. The CausalSelfAttention is masked so each token can only look backwards (you can't peek at the answer). Stack n_layer of these Blocks and you have a GPT.

GPU and compute realities

Here's the honest part nobody tells beginners. Training language models is bottlenecked by compute. The key piece of hardware is a GPU (Graphics Processing Unit) โ€” a chip that does the huge matrix multiplies of a neural net thousands of times faster than a CPU. A few realities:

SetupWhat you can realistically trainNotes
CPU only (your laptop)The tiny char-level Shakespeare model (a few minutes to an hour)Fine for learning; far too slow for real datasets.
1 consumer GPU / free Colab T4A small GPT (a few million to ~100M params) on a modest datasetHours of training. This is our target today.
8ร— datacenter GPUs (A100/H100)nanoGPT's GPT-2 (124M) reproduction in ~a dayThis is what "small lab" scale looks like.
Thousands of GPUsGPT-3/4-scale frontier modelsMillions of dollars and months โ€” out of scope, but same recipe!
Watch out โ€” three things that bite beginners

1. Memory (VRAM): the model's parameters, the activations, and the optimizer state all live in GPU memory. Run out and training crashes โ€” shrink the batch_size or model. 2. Precision: use bfloat16/float16 (called mixed precision) to roughly halve memory and double speed. 3. Time: loss falls fast at first, then crawls โ€” don't panic when it slows.

Key takeaway

nanoGPT is the bridge from your toy to a real model. Same architecture, professional plumbing, four clean stages: data โ†’ config โ†’ train โ†’ sample. The limiting factor isn't cleverness โ€” it's how much GPU compute you can throw at it.

Recap A real GPT is just your Session-19 transformer block stacked deeper and trained with proper plumbing. nanoGPT gives you that plumbing in four scripts: prepare, config, train, sample. Compute (GPU memory + time) is the real constraint, and mixed precision is your friend.

2 Pretraining a small GPT โ€” the full run


Explain like I'm 5

Imagine teaching a parrot to talk by reading it the same big book over and over. At first it just squawks nonsense. After a while it starts saying real words. Later it strings together little phrases that sound like the book. Pretraining is exactly that: we read a model a giant pile of text again and again, asking it to guess the next word each time, until it learns to talk like the text.

Pretraining means training a language model from scratch (random weights) on a large amount of raw text, with one job: predict the next token (recall Sessions 16 & 19 โ€” this is the core language-modeling objective). The model isn't told facts or rules; it just learns the statistics of language by guessing the next token billions of times.

Step 1 โ€” Dataset preparation & tokenization (BPE)

The model can't read letters; it reads tokens โ€” integer IDs for sub-word chunks. We turn text into tokens with a tokenizer. The standard method is Byte-Pair Encoding (BPE), which we introduced back in Session 16 (and which the GenAI track covers in depth). BPE keeps frequent chunks whole (like "the") and breaks rare words into smaller known pieces, so the model can represent any text with a fixed vocabulary.

Two tokenizer choices

Character-level (vocab โ‰ˆ 65 unique characters): dead simple, great for a first run on tiny data โ€” every character is a token. BPE (vocab โ‰ˆ 50,257 for GPT-2 via the tiktoken library): far more efficient on real text, and what real GPTs use. We'll show both.

Worked example โ€” prepare a dataset with BPE

Here's a complete prepare.py that reads a text file, BPE-tokenizes it with GPT-2's tokenizer, splits into train/val, and writes a compact binary token stream:

import numpy as np, tiktoken, os

# 1. Read raw text (point this at your own corpus, e.g. input.txt)
with open('input.txt', 'r') as f:
    data = f.read()
n = len(data)
train_data = data[:int(n*0.9)]   # 90% train
val_data   = data[int(n*0.9):]   # 10% validation

# 2. BPE-encode with GPT-2's tokenizer (cross-ref Session 16)
enc = tiktoken.get_encoding("gpt2")
train_ids = enc.encode_ordinary(train_data)
val_ids   = enc.encode_ordinary(val_data)
print(f"train has {len(train_ids):,} tokens")

# 3. Save as a flat array of uint16 token IDs (vocab < 65536)
np.array(train_ids, dtype=np.uint16).tofile('train.bin')
np.array(val_ids,   dtype=np.uint16).tofile('val.bin')

The output is two files of raw token IDs. Why binary? Because at training time we want to grab a random window of tokens instantly, millions of times โ€” a flat array memory-mapped from disk is perfect for that.

Step 2 โ€” The config (choosing the model size)

The config is just the set of dials that define your model and training run. For a small GPT trainable on one GPU, sensible starting values are:

KnobMeaningSmall-GPT value
n_layerHow many transformer blocks stacked6
n_headAttention heads per block (Session 18)6
n_embdEmbedding/hidden width384
block_sizeContext length (max tokens seen at once)256
batch_sizeSequences trained on in parallel32
learning_rateStep size for gradient descent (Session 3)3e-4
max_itersTotal training steps5000
dropoutRegularisation to fight overfitting (Session 7)0.2

That gives roughly a 10M-parameter model โ€” small enough for a free Colab GPU, big enough to write coherent text after training.

Step 3 โ€” The training run

The training loop is the same recipe from Sessions 3 and 19: get a batch, forward pass to get the loss, backward pass for gradients, optimizer step. Here it is, complete and runnable, using nanoGPT's GPT model:

Worked example โ€” a complete training loop
import torch, numpy as np
from model import GPT, GPTConfig   # from nanoGPT

device = 'cuda' if torch.cuda.is_available() else 'cpu'
block_size, batch_size = 256, 32

# memory-map the token stream we built in prepare.py
train_data = np.memmap('train.bin', dtype=np.uint16, mode='r')

def get_batch():
    # pick batch_size random start positions
    ix = torch.randint(len(train_data) - block_size, (batch_size,))
    x = torch.stack([torch.from_numpy(train_data[i:i+block_size].astype(np.int64)) for i in ix])
    y = torch.stack([torch.from_numpy(train_data[i+1:i+1+block_size].astype(np.int64)) for i in ix])
    return x.to(device), y.to(device)   # y is x shifted by one token

# build the model from our config
cfg = GPTConfig(n_layer=6, n_head=6, n_embd=384,
                block_size=block_size, vocab_size=50257, dropout=0.2)
model = GPT(cfg).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)

for step in range(5000):
    x, y = get_batch()
    logits, loss = model(x, y)        # forward: predict next token, get cross-entropy loss
    optimizer.zero_grad()
    loss.backward()                   # backprop (Session 4) computes gradients
    optimizer.step()                  # nudge weights downhill (Session 3)
    if step % 500 == 0:
        print(f"step {step}: loss {loss.item():.3f}")

torch.save(model.state_dict(), 'ckpt.pt')   # save the checkpoint

Watch the printed loss. A character-level model starts near ln(65) โ‰ˆ 4.2 (pure random guessing) and should fall toward ~1.5. The crucial trick is y being x shifted one token right โ€” that's how "predict the next token" becomes a supervised label for every position at once.

Step 4 โ€” Sampling (generating text)

Once trained, generation is the autoregressive loop you know: feed in a prompt, predict the next token's probabilities, sample one, append it, repeat.

Worked example โ€” generate from your model
import torch, tiktoken
from model import GPT, GPTConfig

enc = tiktoken.get_encoding("gpt2")
model = GPT(GPTConfig(...))           # same config as training
model.load_state_dict(torch.load('ckpt.pt'))
model.eval()                          # turn off dropout for inference

prompt = enc.encode("Once upon a time")
x = torch.tensor(prompt, dtype=torch.long)[None, ...]   # add batch dim

with torch.no_grad():
    out = model.generate(x, max_new_tokens=200, temperature=0.8, top_k=200)
print(enc.decode(out[0].tolist()))

The temperature and top_k knobs control how adventurous the sampling is (low = safe and repetitive, high = creative but riskier โ€” exactly the decoding dials from the GenAI track). After a real run, a tiny model will produce text that's grammatical and on-topic, even if not always factual.

Watch out โ€” overfitting on small data

If your dataset is tiny, the model will memorise it: training loss keeps dropping but validation loss starts rising. That's overfitting (Session 7). Watch both losses, use dropout, and stop when val loss bottoms out.

Recap Pretraining = train from scratch to predict the next token. Prep data by BPE-tokenizing into a binary token stream; pick a small config; run the standard get-batch โ†’ forward โ†’ backward โ†’ step loop while watching train and val loss; then sample autoregressively with temperature/top-k. You now have a model you trained yourself.

3 Parameter-efficient fine-tuning with LoRA


Explain like I'm 5

Imagine a huge, finished painting. You want to give it a slightly different mood โ€” warmer, cosier. You could repaint the entire canvas (slow, expensive, risky). Or you could lay a thin tinted sheet of glass over the top that gently shifts every colour. LoRA is that thin sheet: a small add-on that adjusts a giant model's behaviour without repainting the whole thing.

After pretraining, you usually want to fine-tune โ€” keep training a model on a smaller, specific dataset so it specialises (e.g. answering in your company's style, or on medical text). The obvious way is full fine-tuning: update all the model's weights. But that's expensive.

Why full fine-tuning hurts

  • Memory: you must store gradients and optimizer state for every one of the billions of parameters โ€” often 3โ€“4ร— the model's own size in GPU memory.
  • Storage: every fine-tuned copy is a full-size model. Ten tasks = ten giant files.
  • Data hunger & overfitting: nudging all the weights with a small dataset risks forgetting what pretraining taught (called catastrophic forgetting).
The LoRA idea

LoRA (Low-Rank Adaptation) freezes the entire pretrained model and inserts small, trainable adapter matrices alongside the big weight matrices. You train only those tiny adapters. The huge original weights never change. Because the adapters are low-rank (very skinny), they have a minuscule number of parameters.

The low-rank trick, made concrete

Inside the transformer, much of the work is a big matrix multiply y = Wยทx, where W might be 768 ร— 768 (โ‰ˆ590,000 numbers). LoRA doesn't touch W. Instead it learns a small correction ฮ”W and represents it as the product of two skinny matrices:

y = Wยทx + (BยทA)ยทx,   where A is r ร— 768 and B is 768 ร— r.

Here r is the rank โ€” a small number like 4, 8, or 16. The insight (from the LoRA paper) is that the update a model needs for a new task has low "intrinsic rank," so two skinny matrices capture it just fine.

Worked example โ€” counting the savings

For one 768 ร— 768 weight matrix with rank r = 8:

  • Full fine-tuning trains 768 ร— 768 = 589,824 parameters.
  • LoRA trains A (8 ร— 768) + B (768 ร— 8) = 6,144 + 6,144 = 12,288 parameters.
  • That's ~2% of the original โ€” a ~48ร— reduction in trainable parameters for that matrix.

Across a whole model, LoRA commonly trains well under 1% of the parameters while matching full fine-tuning quality. A starts random, B starts at zero โ€” so at step 0 the adapter contributes nothing and the model behaves exactly like the frozen original, then gently learns the correction.

Code sketch โ€” LoRA in PyTorch

Worked example โ€” a minimal LoRA layer
import torch, torch.nn as nn

class LoRALinear(nn.Module):
    def __init__(self, base: nn.Linear, r=8, alpha=16):
        super().__init__()
        self.base = base                 # the frozen pretrained weight
        for p in self.base.parameters():
            p.requires_grad = False   # FREEZE the big matrix
        d_in, d_out = base.in_features, base.out_features
        self.A = nn.Parameter(torch.randn(r, d_in) * 0.01)  # skinny, random
        self.B = nn.Parameter(torch.zeros(d_out, r))            # skinny, zero-init
        self.scale = alpha / r           # LoRA scaling factor

    def forward(self, x):
        # frozen path + tiny trainable low-rank correction
        return self.base(x) + self.scale * (x @ self.A.T @ self.B.T)

Only A and B have requires_grad=True, so the optimizer touches just those. In practice you wrap the attention query/value projections with this and leave everything else frozen.

You rarely write this by hand

HuggingFace's PEFT (Parameter-Efficient Fine-Tuning) library does all of this for you. A real fine-tune is about five lines:

from peft import LoraConfig, get_peft_model

config = LoraConfig(r=8, lora_alpha=16, target_modules=["q_proj", "v_proj"],
                    lora_dropout=0.05)
model = get_peft_model(base_model, config)
model.print_trainable_parameters()   # e.g. "trainable: 0.24% of all params"
# ...then train normally; save just the tiny adapter, not the whole model.

The saved adapter is often just a few megabytes โ€” you can keep dozens, one per task, and swap them onto the same frozen base model on the fly.

Key takeaway

LoRA = freeze the giant model, train two skinny matrices per layer. You get most of full fine-tuning's quality for a tiny fraction of the trainable parameters, the memory, and the storage โ€” which is exactly why parameter-efficient fine-tuning powers most custom LLMs today.

Recap Full fine-tuning updates every weight โ€” expensive in memory and storage and prone to forgetting. LoRA freezes the base model and learns a low-rank update BยทA per matrix, with a small rank r. That trains <1% of params, produces tiny swappable adapters, and (via the PEFT library) is only a few lines of code.

4 Wrapping up the course โ€” where this all leads


Explain like I'm 5

Think of everything you learned as LEGO bricks. Early on you got plain bricks (neurons). Then you learned how to click them into walls (layers), how to fix a wobbly tower (backprop), special bricks for pictures (CNNs) and for stories (RNNs), and finally a magic brick that lets every piece talk to every other (attention). Today you snapped them all together into a real spaceship โ€” a language model you trained yourself. Now you know how to build almost anything.

You've reached the end of Neural Networks & Computer Vision. Step back and see how the pieces connect into the systems behind modern AI.

How everything connects to modern LLMs

A frontier model like GPT-4, Claude, or Gemini is not magic beyond what you now understand โ€” it's this course's ideas scaled enormously:

What you learnedWhere it lives in a modern LLM
Neurons, MLPs, activations (Sessions 1โ€“2)The feed-forward block inside every transformer layer.
Backprop & gradient descent (Sessions 3โ€“4)The exact algorithm that trains all of them.
Regularisation, dropout, normalisation (Sessions 7โ€“8)Keeps giant models stable and generalising.
Embeddings (Session 16-ish)How tokens become vectors at the model's input.
Attention & transformers (Sessions 17โ€“19)The core engine of every LLM.
Pretraining + fine-tuning + LoRA (today)How the model is built, then specialised cheaply.
The big idea

A frontier LLM is a transformer (Session 18) pretrained to predict the next token (today) on internet-scale text, then aligned with techniques like instruction-tuning and RLHF, and adapted to tasks with methods like LoRA. Every single layer of that stack is something you can now explain.

Where to go next

  • Scaling laws & bigger pretraining โ€” how model size, data, and compute trade off.
  • Alignment โ€” instruction tuning, RLHF, and DPO to make models helpful and safe.
  • Retrieval (RAG) & agents โ€” giving models tools and fresh knowledge (covered in the GenAI track).
  • Efficiency โ€” quantization, distillation, and faster attention for cheaper inference.
  • Multimodality โ€” combining the vision skills (CNNs) you learned with language.

Final-project guidance

Your capstone is to train and fine-tune a small language model end-to-end. A solid submission:

๐Ÿ“‚
Pick data
A focused text corpus you find interesting
โ†’
๐Ÿ‹๏ธ
Pretrain
A small GPT with nanoGPT; log train/val loss
โ†’
๐ŸŽฏ
Fine-tune
Specialise it with LoRA on a sub-task
โ†’
๐Ÿ“
Report
Samples + loss curves + what you learned

Keep it small, get it running end to end, and document your loss curves and sample outputs. See the class handout and the final-project guide (linked in References) for the full rubric.

Pro tip for the project

Resist the urge to go big. A 10M-parameter model that trains in an hour and clearly improves is a far better project than a giant one that never finishes. Show the loss going down and before/after samples โ€” that's the story graders want.

Recap Modern LLMs are this course's bricks scaled up: MLPs as feed-forward blocks, backprop as the trainer, transformers as the engine, pretraining + LoRA as the build-and-specialise recipe. Next stops are scaling, alignment, retrieval, efficiency, and multimodality. Your capstone: pretrain a small GPT, fine-tune it with LoRA, and report your loss curves and samples.

โ˜… Putting it all together


This session is where the whole course pays off. Here's the one-paragraph story tying it together:

You took your toy mini-transformer from Session 19 and stepped up to nanoGPT, which is the same architecture with professional plumbing organised into data โ†’ config โ†’ train โ†’ sample. You pretrained a small GPT from scratch โ€” BPE-tokenizing text into a binary token stream, choosing a small config, running the get-batch โ†’ forward โ†’ backward โ†’ step loop while watching train and val loss, then sampling autoregressively. Then you specialised it cheaply with LoRA, freezing the giant weights and learning only tiny low-rank adapter matrices โ€” under 1% of the parameters. And you saw that this is how modern LLMs are built: transformers, pretraining, and parameter-efficient fine-tuning, scaled up.

๐ŸŽ‰ You finished the whole course!

Congratulations โ€” you've completed Neural Networks & Computer Vision from start to finish. Look at the journey you made: from a single neuron and MLPs, to the mechanics of backpropagation, to teaching machines to see with CNNs and computer vision, to giving them memory with RNNs and LSTMs, to the breakthrough of attention, into full transformers, and finally to building real projects โ€” training and fine-tuning your own language model. You started knowing none of this. You now understand, end to end, how the most powerful AI systems in the world actually work. That's a genuinely big deal. Be proud, keep building, and go make something. ๐Ÿš€

Quick self-check

What are the four stages of a nanoGPT project?

Data (tokenise text into a binary token stream), Config (set model size and training knobs), Train (run the loop and watch loss fall), and Sample (load the checkpoint and generate text).

In the training loop, why is the label y just x shifted by one token?

Because the objective is "predict the next token." Shifting the input by one position makes every position's correct next token its label, giving supervision at every position at once.

What problem does BPE tokenization solve?

It lets a fixed vocabulary represent any text by keeping frequent chunks whole and splitting rare words into smaller known pieces โ€” more efficient than characters, more flexible than whole words (cross-ref Session 16).

How does LoRA cut the number of trainable parameters so drastically?

It freezes the big weight matrix and represents the update as the product of two skinny (low-rank) matrices BยทA with rank r. For a 768ร—768 matrix at r=8 that's ~12K trainable params instead of ~590K โ€” under a few percent.

Why initialise LoRA's B matrix to zero?

So that at step 0 the adapter contributes nothing (BยทA = 0) and the model behaves exactly like the frozen pretrained original, then gradually learns the task-specific correction.

If training loss keeps dropping but validation loss rises, what's happening?

Overfitting (Session 7) โ€” the model is memorising the small training set. Use dropout, more data, or stop training when validation loss bottoms out.

๐Ÿ“š References & Further Reading


Class material

  • SST Deep Learning handout (Session 20) โ€” your course handout, including the course roadmap and the final-project guide.

Papers, docs & deep dives