Back to TinyGPT Hub

Deconstructing TinyGPT from Scratch

Part 2: Pure PyTorch Implementation

File Map

Three files: attention.py ($\sim$30 lines) — multi-head causal self-attention. gpt.py ($\sim$120 lines) — Block, TinyGPT, generation, inspection. train.py — synthetic addition dataset, training loop, evaluation.

Multi-Head Causal Self-Attention

class MultiHeadCausalSelfAttention(nn.Module):
    def __init__(self, n_embd, n_head, block_size, dropout=0.0):
        super().__init__()
        self.n_head = n_head
        self.d_head = n_embd // n_head
        self.qkv = nn.Linear(n_embd, 3 * n_embd)
        self.proj = nn.Linear(n_embd, n_embd)
        mask = torch.tril(torch.ones(block_size, block_size))
        self.register_buffer("mask", mask.view(1, 1, block_size, block_size))

    def forward(self, x):
        B, T, C = x.shape
        q, k, v = self.qkv(x).split(C, dim=2)
        q = q.view(B, T, self.n_head, self.d_head).transpose(1, 2)
        k = k.view(B, T, self.n_head, self.d_head).transpose(1, 2)
        v = v.view(B, T, self.n_head, self.d_head).transpose(1, 2)
        att = (q @ k.transpose(-2, -1)) / math.sqrt(self.d_head)
        att = att.masked_fill(self.mask[:, :, :T, :T] == 0, float("-inf"))
        att = F.softmax(att, dim=-1)
        y = (att @ v).transpose(1, 2).contiguous().view(B, T, C)
        return self.proj(y)

A single linear projects to $[Q \mid K \mid V]$ at once — saves three calls to the dispatcher. The mask is registered as a buffer so it moves with .to(device) but is not a learnable parameter.

The Block

class Block(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.ln1 = nn.LayerNorm(cfg.n_embd)
        self.attn = MultiHeadCausalSelfAttention(cfg.n_embd, cfg.n_head,
                                                  cfg.block_size, cfg.dropout)
        self.ln2 = nn.LayerNorm(cfg.n_embd)
        self.mlp = nn.Sequential(
            nn.Linear(cfg.n_embd, 4 * cfg.n_embd), nn.GELU(),
            nn.Linear(4 * cfg.n_embd, cfg.n_embd), nn.Dropout(cfg.dropout))

    def forward(self, x):
        x = x + self.attn(self.ln1(x))
        x = x + self.mlp(self.ln2(x))
        return x

Pre-norm. The $4\times$ MLP expansion is the GPT-2 default. GELU is used because it is what GPT-2 used.

The Model

class TinyGPT(nn.Module):
    def __init__(self, cfg):
        super().__init__()
        self.cfg = cfg
        self.tok_emb = nn.Embedding(cfg.vocab_size, cfg.n_embd)
        self.pos_emb = nn.Embedding(cfg.block_size, cfg.n_embd)
        self.drop = nn.Dropout(cfg.dropout)
        self.blocks = nn.ModuleList([Block(cfg) for _ in range(cfg.n_layer)])
        self.ln_f = nn.LayerNorm(cfg.n_embd)
        self.lm_head = nn.Linear(cfg.n_embd, cfg.vocab_size, bias=False)
        self.lm_head.weight = self.tok_emb.weight   # weight tying

Total parameter count (vocab 12, block 12, 4 layers, 4 heads, $n_\text{embd}=64$): 200,832. Weight tying means the embedding matrix and the LM head share their $12 \times 64$ weights.

Synthetic Dataset

Each example is a 12-character string: sample $A, B \sim \text{Uniform}\{0, \ldots, 999\}$, format as ABC+DEF=, compute $C = A + B$, zero-pad to four digits, reverse. Concatenate to get a 12-character training string.

Supervision masking. We supervise as a standard causal LM but set the target tokens for the prompt portion to $-100$ (CrossEntropyLoss's ignore_index). Only the four answer positions contribute to the loss. The model still attends to the prompt; we just do not train it to reconstruct the prompt.

Training Loop

opt = torch.optim.AdamW(model.parameters(), lr=5e-4, weight_decay=0.01)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
for epoch in range(epochs):
    for x_batch, y_batch in batches:
        _, loss = model(x_batch, y_batch)
        opt.zero_grad(set_to_none=True)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        opt.step()
    sched.step()

Standard recipe: AdamW with weight decay $0.01$, cosine annealing, gradient clipping at $1.0$. Nothing exotic. Batch size $128$, $50$ epochs, $10{,}000$ training examples.

Summary

Part 3 runs the experiment, reports the per-position learning curriculum, and inspects the trained attention.

Full code on GitHub: github.com/soveshmohapatra/TinyGPT