πŸ“š Study Notes / Home / Neural Nets / Session 19
Session 19 Β· Project β€” Mini-Transformer

Let's build a tiny GPT, from scratch, that writes text

This is a project session β€” no new theory dumped on you, just rolling up our sleeves and building a real, runnable mini-Transformer in PyTorch. We assemble everything from the components you met back in Session 15 (attention, embeddings, residuals, LayerNorm), train it on a small text file, and watch it generate brand-new text one character at a time. Every block is shown as complete code you can copy and run. Take it slow β€” by the end you'll have a working baby-GPT and you'll actually understand each line.

⏱ 22 min readπŸ“– 4 topics

1 Goal & the build plan


Explain like I'm 5

Imagine you want to teach a parrot to finish your sentences. You read it a big storybook over and over. Eventually, when you say "Once upon a…", the parrot squawks "time!" β€” not because it understands the story, but because it has heard which sounds usually follow which. Today we're going to build that parrot ourselves, out of small Lego-like pieces, and teach it on a little book. Then we'll ask it to keep talking and watch what it makes up.

Our goal for this project is concrete: build a decoder-only Transformer β€” a GPT-style model (the same family that powers ChatGPT) β€” small enough to train on a laptop, and have it generate text. "Decoder-only" just means it does one job: read the text so far and predict what comes next. It only ever looks backwards, never forwards (we'll enforce that with causal masking in Topic 3).

The one big idea

A GPT is just "predict the next token, given all previous tokens," wrapped in a neural network that we train by example. Everything we build today β€” embeddings, attention, the feed-forward layers β€” exists only to make that one prediction better. Stack the same block a few times, feed it lots of text, and language-like behaviour falls out.

What "from scratch" means here

We will use PyTorch's tensors and autograd (the automatic differentiation engine from Session 15) and basic layers like nn.Linear and nn.Embedding, but we will not call any pre-built attention block. We write the attention math ourselves so you can see exactly what happens. This mirrors Andrej Karpathy's famous "Let's build GPT" walkthrough (see References), simplified for one sitting.

Tokens: characters, not words

Real LLMs split text into sub-word tokens (recall the tokenisation idea β€” it shows up across modern NLP). To keep our project tiny and self-contained, we use the simplest possible tokeniser: each character is one token. So our vocabulary is just the set of unique characters in our training file (maybe ~65 of them). This is enough to learn real structure β€” words, spacing, even punctuation rhythm β€” without any external library.

The components we'll build

πŸ”€
Embeddings
token + position β†’ vectors
β†’
πŸ‘€
Self-attention
tokens look at past tokens
β†’
🧠
Multi-head + MLP
mix & think
β†’
🧱
Stack N blocks
residual + LayerNorm
β†’
🎯
Predict + sample
next-char loop
The full project at a glance (the file we'll fill in)

By the end we'll have a single Python file with this shape. The pieces in ... are what the next sections build:

# minigpt.py β€” a tiny decoder-only Transformer in PyTorch
import torch
import torch.nn as nn
from torch.nn import functional as F

# 1. config + data + tokeniser    (Topic 1 & 3)
# 2. Head, MultiHeadAttention, FeedForward, Block   (Topic 2)
# 3. MiniGPT model with embeddings + stacked blocks (Topic 2 & 3)
# 4. training loop                 (Topic 3)
# 5. generate() autoregressive loop (Topic 4)

Don't worry about understanding it yet β€” this is just the roadmap.

What you need installed

Just PyTorch: pip install torch. A GPU makes it faster but is optional β€” our model is small enough to train on CPU in a few minutes. We'll auto-detect the device in code.

Recap We're building a small decoder-only (GPT-style) Transformer that predicts the next character, trains on a little text file, and then generates new text. We'll write the attention ourselves on top of PyTorch tensors. The pieces to build: token + positional embeddings, self-attention, multi-head attention, an MLP, residual connections and LayerNorm β€” then stack and train.

2 Building the blocks


Explain like I'm 5

Think of a group project where everyone reads the same paragraph. First, each kid turns their word into a sticky note (an embedding) and writes which seat they're in (the position). Then each kid asks the others "how much should I listen to you?" and blends in what the most relevant friends said (that's attention). They do this a few times in parallel with different questions (multi-head), then each kid thinks quietly for a moment (the MLP). We're now building each of those little machines.

Every block here is a small PyTorch nn.Module. We'll define a tiny config first so the dimensions line up, then build each piece bottom-up.

Setup & hyperparameters

import torch
import torch.nn as nn
from torch.nn import functional as F

torch.manual_seed(1337)          # reproducible runs

# --- hyperparameters (small on purpose) ---
block_size = 128      # context length: how many chars the model sees at once
n_embd     = 128      # size of each token's vector (the "width" of the model)
n_head     = 4        # number of attention heads (128 / 4 = 32 dims per head)
n_layer    = 4        # how many Transformer blocks we stack
dropout    = 0.1      # regularisation: randomly zero 10% of activations while training
device     = 'cuda' if torch.cuda.is_available() else 'cpu'

Part A β€” Token & positional embeddings

An embedding turns a token's integer ID into a learnable vector of n_embd numbers that captures meaning. But a Transformer reads all positions in parallel, so on its own it has no sense of order ("dog bites man" would look identical to "man bites dog"). We fix that with a positional embedding: a second lookup table, one learnable vector per position 0..block_size-1. We add the two together so each token vector carries both "what I am" and "where I am."

# inside the model's __init__ (full model assembled in Topic 3):
self.token_embedding    = nn.Embedding(vocab_size, n_embd)   # what  (one row per char)
self.position_embedding = nn.Embedding(block_size, n_embd)   # where (one row per slot)

# inside forward(), idx is a (B, T) batch of token IDs:
B, T = idx.shape
tok = self.token_embedding(idx)                              # (B, T, n_embd)
pos = self.position_embedding(torch.arange(T, device=idx.device))  # (T, n_embd)
x   = tok + pos                                              # broadcast-add β†’ (B, T, n_embd)
Shapes you'll see everywhere: (B, T, C)

B = batch (how many sequences we process at once), T = time (how many tokens / the sequence length), C = channels (the vector size, our n_embd). Keeping these letters straight is 90% of reading Transformer code.

Part B β€” Scaled dot-product self-attention (one head)

This is the heart of the Transformer. For every token we build three vectors (recall the library analogy): a query ("what am I looking for?"), a key ("what do I contain?"), and a value ("what I'll hand over"). We compare each token's query against every token's key to get attention scores, scale them, mask out the future, softmax them into weights, and use those weights to average the values.

The "scaled dot-product" formula is just:

attention(Q, K, V) = softmax( (Q Β· Kα΅€) / √d_k ) Β· V

The √d_k (square root of the head size) keeps the scores from getting huge, which would make softmax too "peaky" and kill the gradients. Here's one head as code:

class Head(nn.Module):
    """One head of causal self-attention."""
    def __init__(self, head_size):
        super().__init__()
        self.key   = nn.Linear(n_embd, head_size, bias=False)
        self.query = nn.Linear(n_embd, head_size, bias=False)
        self.value = nn.Linear(n_embd, head_size, bias=False)
        # lower-triangular matrix of 1s; 'buffer' = saved but not a learned parameter
        self.register_buffer('tril', torch.tril(torch.ones(block_size, block_size)))
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        B, T, C = x.shape
        k = self.key(x)      # (B, T, head_size)
        q = self.query(x)    # (B, T, head_size)
        # compute attention scores ("affinities")
        wei = q @ k.transpose(-2, -1) * k.shape[-1]**-0.5   # (B, T, T), scaled by 1/√d_k
        # causal mask: a token may NOT attend to future tokens (Topic 3)
        wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf'))
        wei = F.softmax(wei, dim=-1)                       # (B, T, T), rows sum to 1
        wei = self.dropout(wei)
        v = self.value(x)                                  # (B, T, head_size)
        out = wei @ v                                      # (B, T, head_size)
        return out
Walking through one tiny attention step

Say T = 3 tokens. The scores wei is a 3Γ—3 grid: row i, column j = "how much should token i listen to token j." After the causal mask + softmax, row 2 (the third token) might be [0.2, 0.3, 0.5] β€” it splits its attention over itself and the two before it. Row 0 (the first token) can only attend to itself, so it's [1.0, 0, 0]. Then wei @ v blends the value vectors using exactly those weights. That blend is the token's new, context-aware representation.

Part C β€” Multi-head attention

One head learns one kind of relationship. Multi-head attention runs several heads in parallel β€” each with its own Q/K/V β€” so one head can track, say, subject–verb links while another tracks nearby punctuation. We concatenate their outputs and pass them through a final linear "projection" to mix the heads back together.

class MultiHeadAttention(nn.Module):
    """Several self-attention heads running in parallel."""
    def __init__(self, n_head, head_size):
        super().__init__()
        self.heads = nn.ModuleList([Head(head_size) for _ in range(n_head)])
        self.proj  = nn.Linear(head_size * n_head, n_embd)   # recombine the heads
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        out = torch.cat([h(x) for h in self.heads], dim=-1)  # (B, T, head_size*n_head)
        out = self.dropout(self.proj(out))                  # (B, T, n_embd)
        return out

Part D β€” The MLP / feed-forward

Attention lets tokens communicate. The feed-forward network (an MLP) lets each token think on its own about what it just gathered. It's a simple two-layer network applied to every position independently. By convention the hidden layer is 4Γ— wider than n_embd, with a non-linearity (ReLU here) in between.

class FeedForward(nn.Module):
    """Position-wise MLP: think after you've gathered context."""
    def __init__(self, n_embd):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(n_embd, 4 * n_embd),   # expand
            nn.ReLU(),                       # non-linearity
            nn.Linear(4 * n_embd, n_embd),   # project back down
            nn.Dropout(dropout),
        )

    def forward(self, x):
        return self.net(x)                  # (B, T, n_embd) β†’ (B, T, n_embd)

Part E β€” Residual connections + LayerNorm, packaged as a Block

Two more ingredients make deep Transformers trainable, both from Session 15:

  • Residual connection β€” instead of x = layer(x), we do x = x + layer(x). The layer only has to learn a small adjustment; the original signal flows straight through. This gives gradients a "highway" and lets us stack many layers without them collapsing.
  • LayerNorm β€” normalises each token's vector (mean 0, variance 1) so numbers stay in a sane range. We apply it before each sub-layer ("pre-norm"), which is the modern, more stable arrangement.

A Block is just: norm β†’ attention (+residual), then norm β†’ MLP (+residual).

class Block(nn.Module):
    """One Transformer block: communicate (attention), then think (MLP)."""
    def __init__(self, n_embd, n_head):
        super().__init__()
        head_size = n_embd // n_head
        self.sa   = MultiHeadAttention(n_head, head_size)
        self.ffwd = FeedForward(n_embd)
        self.ln1  = nn.LayerNorm(n_embd)
        self.ln2  = nn.LayerNorm(n_embd)

    def forward(self, x):
        x = x + self.sa(self.ln1(x))     # residual around attention (pre-norm)
        x = x + self.ffwd(self.ln2(x))   # residual around MLP
        return x
Key takeaway

A Transformer block is a repeatable unit: "let tokens look at each other (attention), then let each token think (MLP)," with residual + LayerNorm keeping training stable. Stack this same block N times and you have the body of a GPT. We never change the block β€” we just stack more of them and make them wider for bigger models.

Recap We built every piece: token + positional embeddings (added together for meaning + order); a single scaled-dot-product attention Head (QΒ·Kα΅€/√d_k, mask, softmax, Β·V); a MultiHeadAttention that runs several heads and projects; a 4Γ—-wide FeedForward MLP; and a Block wrapping them with residual connections and pre-LayerNorm.

3 Assembling & training


Explain like I'm 5

We've built all the Lego bricks. Now we snap them into a tower (stack the blocks), then go to school: we show the model lots of little text snippets, let it guess the next letter, tell it how wrong it was, and nudge its dials a tiny bit in the right direction. Do that thousands of times and the guesses get good. The one strict rule during school: no peeking ahead β€” it must guess the next letter using only the letters before it.

Step 1 β€” Load data & build the character tokeniser

Grab any plain-text file (a book, your favourite lyrics, the tiny Shakespeare file Karpathy uses). We map each unique character to an integer and back.

# --- load text ---
with open('input.txt', 'r', encoding='utf-8') as f:
    text = f.read()

chars = sorted(list(set(text)))   # the vocabulary = unique chars
vocab_size = len(chars)

stoi = { ch: i for i, ch in enumerate(chars) }   # string β†’ int
itos = { i: ch for i, ch in enumerate(chars) }   # int β†’ string
encode = lambda s: [stoi[c] for c in s]            # "hi" β†’ [40, 41]
decode = lambda l: ''.join([itos[i] for i in l])    # [40, 41] β†’ "hi"

data = torch.tensor(encode(text), dtype=torch.long)
n = int(0.9 * len(data))                # 90% train, 10% validation
train_data, val_data = data[:n], data[n:]

Step 2 β€” Batches of (input, target) pairs

For training we grab random chunks of length block_size. The clever trick: the target is the same chunk shifted one character to the right. So at every position the model learns "given everything up to here, what's the next char?" β€” that's next-token prediction, and it gives us block_size training signals per chunk.

batch_size = 32   # how many chunks per step

def get_batch(split):
    d = train_data if split == 'train' else val_data
    ix = torch.randint(len(d) - block_size, (batch_size,))  # random start points
    x = torch.stack([d[i      : i+block_size  ] for i in ix])  # (B, T) inputs
    y = torch.stack([d[i+1    : i+block_size+1] for i in ix])  # (B, T) targets (shifted by 1)
    return x.to(device), y.to(device)

Step 3 β€” Causal masking (why the model can't cheat)

Because each position is asked to predict its next character, the model must not be allowed to see that character (or any later one) when making the prediction β€” otherwise it would just copy the answer and learn nothing. Causal masking enforces this. Recall the tril (lower-triangular) buffer in our Head: we set every score for a future position to -inf before softmax, so after softmax those weights become exactly 0.

The mask, visually (for T = 4)

A βœ“ means "allowed to attend"; a βœ— means "masked out (future)":

query ↓ / key β†’t0t1t2t3
token 0βœ“βœ—βœ—βœ—
token 1βœ“βœ“βœ—βœ—
token 2βœ“βœ“βœ“βœ—
token 3βœ“βœ“βœ“βœ“

Each token sees only itself and the past. This single triangular mask is the only structural difference between a GPT-style decoder and a bidirectional encoder like BERT.

Step 4 β€” The full model

Now we assemble everything: the two embedding tables, a stack of n_layer Blocks, a final LayerNorm, and a "language-model head" β€” a linear layer mapping each token's vector to one logit (raw score) per vocabulary character.

class MiniGPT(nn.Module):
    def __init__(self):
        super().__init__()
        self.token_embedding    = nn.Embedding(vocab_size, n_embd)
        self.position_embedding = nn.Embedding(block_size, n_embd)
        self.blocks = nn.Sequential(*[Block(n_embd, n_head) for _ in range(n_layer)])
        self.ln_f   = nn.LayerNorm(n_embd)              # final norm
        self.lm_head = nn.Linear(n_embd, vocab_size)    # β†’ one score per char

    def forward(self, idx, targets=None):
        B, T = idx.shape
        tok = self.token_embedding(idx)                              # (B, T, C)
        pos = self.position_embedding(torch.arange(T, device=device))# (T, C)
        x = tok + pos                                                # (B, T, C)
        x = self.blocks(x)                                           # (B, T, C)
        x = self.ln_f(x)
        logits = self.lm_head(x)                                     # (B, T, vocab_size)

        if targets is None:
            loss = None
        else:
            B, T, V = logits.shape
            # cross-entropy expects (N, V) logits and (N,) targets
            loss = F.cross_entropy(logits.view(B*T, V), targets.view(B*T))
        return logits, loss
Why cross-entropy?

Cross-entropy loss measures how surprised the model is by the true next character. If it put 90% probability on the correct char, loss is small; if it was confidently wrong, loss is large. Minimising it = "be less surprised by real text." With a vocab of V chars, a totally untrained model has loss β‰ˆ ln(V) β€” a handy sanity check for your first print-out.

Step 5 β€” The training loop

This is the same pattern from Session 15: forward pass β†’ compute loss β†’ backward pass (autograd) β†’ optimiser step. We use AdamW, the go-to optimiser for Transformers.

model = MiniGPT().to(device)
print(sum(p.numel() for p in model.parameters())/1e6, 'M parameters')

optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)
max_iters, eval_interval = 5000, 500

@torch.no_grad()
def estimate_loss():           # average loss over a few batches, no gradients
    out = {}
    model.eval()
    for split in ['train', 'val']:
        losses = torch.zeros(50)
        for k in range(50):
            X, Y = get_batch(split)
            _, loss = model(X, Y)
            losses[k] = loss.item()
        out[split] = losses.mean()
    model.train()
    return out

for step in range(max_iters):
    if step % eval_interval == 0:
        l = estimate_loss()
        print(f"step {step}: train {l['train']:.4f}, val {l['val']:.4f}")

    xb, yb = get_batch('train')
    logits, loss = model(xb, yb)        # forward
    optimizer.zero_grad(set_to_none=True)
    loss.backward()                     # backward (autograd computes all gradients)
    optimizer.step()                    # nudge every parameter
What healthy training looks like

On the tiny-Shakespeare file with these settings, you'd see something like:

0.21 M parameters
step 0:    train 4.36, val 4.36     # β‰ˆ ln(65), basically random
step 500:  train 2.42, val 2.45
step 1000: train 2.05, val 2.08
step 2500: train 1.70, val 1.74
step 5000: train 1.52, val 1.57     # now it has learned real structure

If val loss starts climbing while train keeps dropping, that's overfitting β€” the model is memorising. More dropout, more data, or a smaller model helps (concepts from Session 15).

Recap We tokenise text by character, build (input, target-shifted-by-one) batches, and enforce causal masking so the model can't peek at the answer. The MiniGPT stacks embeddings β†’ N Blocks β†’ final LayerNorm β†’ linear head producing per-char logits, scored by cross-entropy. Training is the familiar loop: forward, loss, backward(), AdamW step β€” repeated thousands of times while watching train/val loss.

4 Generating & understanding


Explain like I'm 5

The parrot is trained. Now we play a game: we whisper a starting letter, it guesses the next one, we add that to what it heard, and it guesses again β€” over and over, building a sentence one letter at a time. If we let it pick its favourite guess every time it's a bit repetitive; if we let it roll a slightly random dice it's more creative. That dice is the "temperature" knob.

Autoregressive sampling

Autoregressive generation means the model feeds its own output back in: predict a token, append it, predict the next using the longer context, and repeat. Crucially, we only need the logits for the last position (the prediction for "what comes next"), and we must crop the context to the last block_size tokens since our positional table only goes that far.

    @torch.no_grad()
    def generate(self, idx, max_new_tokens, temperature=1.0, top_k=None):
        for _ in range(max_new_tokens):
            idx_cond = idx[:, -block_size:]          # crop to context window
            logits, _ = self(idx_cond)               # (B, T, vocab_size)
            logits = logits[:, -1, :] / temperature  # keep last step; apply temperature
            if top_k is not None:                  # optional: keep only top-k options
                v, _ = torch.topk(logits, top_k)
                logits[logits < v[:, [-1]]] = -float('Inf')
            probs = F.softmax(logits, dim=-1)         # (B, vocab_size)
            idx_next = torch.multinomial(probs, num_samples=1)  # sample one token
            idx = torch.cat((idx, idx_next), dim=1)   # append β†’ loop
        return idx

Add that method inside the MiniGPT class, then generate:

context = torch.zeros((1, 1), dtype=torch.long, device=device)  # start token (index 0)
out = model.generate(context, max_new_tokens=500, temperature=0.8, top_k=40)
print(decode(out[0].tolist()))
The sampling knobs (recall from your GenAI notes)

temperature < 1 sharpens toward the top choice (safe, repetitive); > 1 flattens the odds (creative, riskier). top-k keeps only the k most likely characters before sampling, so bizarre choices can't sneak in. torch.multinomial then rolls a weighted dice. Using argmax instead would be "greedy" and tends to loop on itself.

Inspecting attention β€” looking inside the box

One of the joys of building it yourself: you can peek at what the model attends to. The attention weights wei are literally a probability grid. We can expose and visualise them:

# quick hack: have Head.forward stash its weights, then read them after a forward pass
# in Head.forward, before returning:  self.last_attn = wei.detach()

model.eval()
xb, _ = get_batch('val')
_ = model(xb)
attn = model.blocks[0].sa.heads[0].last_attn   # (B, T, T) for layer 0, head 0
print(attn[0].shape)                            # (T, T) β€” one row per query position

# row i shows how token i distributed its attention over tokens 0..i
import matplotlib.pyplot as plt
plt.imshow(attn[0].cpu(), cmap='viridis'); plt.xlabel('key'); plt.ylabel('query'); plt.show()
What you'll typically see

The plot is a triangle (the upper-right is dark β€” masked future). Early-layer heads often attend strongly to the immediately previous character (a bright diagonal just below the main diagonal) β€” learning simple local patterns like "after 'q' comes 'u'." Deeper-layer heads spread attention to track longer structures like word boundaries or matching brackets. Different heads specialise differently β€” that's multi-head attention earning its keep.

What scales up to real LLMs

Our 0.2M-parameter character model and a 200-billion-parameter frontier model are the same architecture. The differences are quantitative, not structural:

KnobOur MiniGPTA real LLM
Tokeniser1 char = 1 token (~65 vocab)Sub-word BPE (~50k–200k vocab)
Context (block_size)128tens of thousands β†’ millions
Width (n_embd) & heads128, 4 headsthousands, dozens of heads
Depth (n_layer)4 blocksdozens to 100+ blocks
Parameters~0.2 millionbillions to trillions
Training dataone small text filetrillions of tokens of the internet
After pre-training(none)instruction tuning + RLHF to make it a helpful assistant

Production models also add efficiency tricks (e.g. faster positional schemes like RoPE, attention kernels like FlashAttention, a key/value cache for fast generation), but the core Block you wrote is exactly what's inside them. This is the bridge to the GenAI subject: everything there β€” prompting, tokens-as-money, context windows, temperature/top-p, RAG, agents β€” sits on top of the very machine you just built. You now know what's under the hood.

Key takeaway

You built a complete, runnable GPT. Generation is just the trained model in an autoregressive loop; sampling knobs (temperature, top-k) control its style; and the attention grids let you literally see it working. Scaling to ChatGPT is "more of the same, much bigger, on much more data, plus alignment tuning" β€” not a different kind of machine.

Recap We generate by feeding the model's own output back in, cropping to the context window and sampling the last position's logits (with temperature and top-k). We can inspect the attention weight grids to see heads specialise. Frontier LLMs use this identical architecture β€” just wider, deeper, with bigger vocab/context, far more data, and post-training alignment.

β˜… Putting it all together


You went from a pile of components to a working baby-GPT. Here's the one-paragraph story that connects the whole project:

We tokenised text into characters, turned each into a token embedding plus a positional embedding (meaning + order), then ran them through a stack of Transformer Blocks. Each block lets tokens communicate via scaled dot-product self-attention β€” many heads in parallel, with a causal mask so no token sees the future β€” and then think via a 4Γ—-wide feed-forward MLP, all wrapped in residual connections + LayerNorm for stable deep training. A final linear head produces a logit per character; we train it to minimise cross-entropy on next-character prediction with AdamW. To generate, we run the trained model autoregressively, sampling with temperature and top-k. That is, in miniature, exactly how every modern LLM works.

Quick self-check

Why do we add positional embeddings to token embeddings?

Because the Transformer processes all positions in parallel and is otherwise order-blind. The token embedding says "what I am"; the positional embedding says "where I am." Without it, "dog bites man" and "man bites dog" would look identical to the model.

What does the causal mask do, and why is it essential during training?

It sets the attention scores for all future positions to βˆ’βˆž before softmax (so their weights become 0), meaning each token only attends to itself and earlier tokens. It's essential because each position is being trained to predict its next character β€” if it could see the future it would just copy the answer and learn nothing.

Why divide the attention scores by √d_k?

As the head dimension grows, raw dot products grow large, which makes softmax extremely peaky and squashes the gradients (vanishing gradients). Scaling by 1/√d_k keeps the scores in a reasonable range so attention stays trainable.

What's the role of the residual connection (x = x + layer(x))?

It gives gradients a direct "highway" back through the network and lets each layer learn only a small correction to the existing signal, which is what makes it possible to stack many Transformer blocks without training collapsing.

What is the difference between temperature near 0 and temperature above 1 at generation time?

Low temperature sharpens the probabilities toward the single most likely character β€” safe, repetitive output. High temperature flattens them so less-likely characters get a real chance β€” more creative but riskier and more prone to gibberish.

Structurally, what's the main thing that separates our MiniGPT from a frontier LLM?

Almost nothing structural β€” it's the same stacked-Block architecture. The differences are scale (vocab, context length, width, depth, parameter count), the amount of training data, and post-training alignment (instruction tuning + RLHF). The core block is identical.

πŸ“š References & Further Reading


Class material

  • SST Deep Learning handout (Session 19) β€” your course handout for this project session, covering the mini-Transformer build.

Papers, docs & deep dives