1 From toy to real โ scaling up with nanoGPT
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.
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:
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
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:
| Setup | What you can realistically train | Notes |
|---|---|---|
| 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 T4 | A small GPT (a few million to ~100M params) on a modest dataset | Hours of training. This is our target today. |
| 8ร datacenter GPUs (A100/H100) | nanoGPT's GPT-2 (124M) reproduction in ~a day | This is what "small lab" scale looks like. |
| Thousands of GPUs | GPT-3/4-scale frontier models | Millions of dollars and months โ out of scope, but same recipe! |
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.
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.
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
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.
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.
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:
| Knob | Meaning | Small-GPT value |
|---|---|---|
n_layer | How many transformer blocks stacked | 6 |
n_head | Attention heads per block (Session 18) | 6 |
n_embd | Embedding/hidden width | 384 |
block_size | Context length (max tokens seen at once) | 256 |
batch_size | Sequences trained on in parallel | 32 |
learning_rate | Step size for gradient descent (Session 3) | 3e-4 |
max_iters | Total training steps | 5000 |
dropout | Regularisation 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:
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.
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.
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.
3 Parameter-efficient fine-tuning with LoRA
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).
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.
For one 768 ร 768 weight matrix with rank r = 8:
- Full fine-tuning trains
768 ร 768 = 589,824parameters. - LoRA trains
A(8 ร 768) +B(768 ร 8) =6,144 + 6,144 = 12,288parameters. - 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
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.
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.
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.
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
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 learned | Where 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. |
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:
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.
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.
โ 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.
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
- SST Deep Learning โ course site (roadmap & final-project guide) โ the online companion with the roadmap and capstone rubric for this session.
- Karpathy โ nanoGPT (GitHub) โ the small, readable codebase we use today; clone it and read
model.py. - LoRA: Low-Rank Adaptation of Large Language Models (Hu et al., 2021) โ the original paper behind the low-rank adapter trick.
- HuggingFace PEFT documentation โ the practical library for LoRA and other parameter-efficient fine-tuning methods.
- Attention Is All You Need (Vaswani et al., 2017) โ the transformer architecture every GPT is built on.
- OpenAI tiktoken โ the fast BPE tokenizer used in our data-prep step.