A practical, code-first guide to building, training, and running a GPT model — with every tensor shape traced from input to output.
In the first article, we learned about RNNs, LSTMs, and Attention mechanisms. We saw how RNNs gave us memory, LSTMs gave us long-term memory, and Attention gave us focus — but RNNs were still painfully slow because they read words one by one.
In the second article, we built every fundamental piece of the Transformer: Self-Attention, Multi-Head Attention, Positional Encoding, Masking, and Feed-Forward Networks. We understood how each building block works in isolation.
But we stopped short of the most important question: do they actually work together?
The "Glass Box" Effect: While prompting GPT-4 feels like working with a "black box," building your own model from scratch creates a "glass box" where every gear is visible. For an AI engineer, this transition is critical—it’s the difference between guessing prompts and truly mastering the mechanics required to fine-tune Small Language Models (SLMs), optimize custom architectures, or debug deep-seated training issues.
We built the engine parts. Today, we assemble the car and drive it.
By the end of this article, you will have built a complete GPT (Generative Pre-trained Transformer) model from scratch in PyTorch, trained it on the complete works of William Shakespeare, and generated completely new text that looks like this:
Generated Output (from our trained model): ROMEO: What, is the king dead? What is the matter?
JULIET: I do not know the matter, but I hear The news is bad.
Let's dive in.
The original 2017 Transformer architecture ("Attention Is All You Need") consisted of two halves: an Encoder (to read the input) and a Decoder (to generate the output). This makes intuitive sense for translation: read English, generate French.
GPT's radical, industry-changing insight was simple: What if we only need the Decoder?
| Architecture | Components | Example Models | Best For |
|---|---|---|---|
| Encoder-only | Encoder | BERT, RoBERTa | Understanding (classification, NER) |
| Encoder-Decoder | Both | T5, BART | Translation, Summarization |
| Decoder-only | Decoder | GPT, LLaMA, Gemini | Generation (The modern standard) |
Why did Decoder-only win? Because framing every problem as a generation problem — where the input is just the start of a sequence and the output is the continuation — turned out to be incredibly effective.
By dropping the Encoder, we also drop Cross-Attention (since there's no encoder output to attend to). We are left with just three ingredients from our previous article:
Analogy: Encoder-Decoder is like having a separate reader and writer collaborating. GPT says: what if the writer is so smart they can just read the context and seamlessly continue writing?

Before we write a single line of model code, we need to decide on our hyperparameters. These control everything about our model and training. Think of them as the recipe ingredients — change them and the result changes.
import torch
import torch.nn as nn
import torch.nn.functional as F
# --- Device: CPU or GPU ---
device = 'cuda' if torch.cuda.is_available() else 'cpu'
# --- Model architecture ---
batch_size = 64 # How many sequences to process in parallel
block_size = 256 # Maximum context length (how many characters the model "sees")
d_model = 384 # Embedding dimension (size of each token's vector)
num_heads = 6 # Number of attention heads
num_layers = 6 # Number of transformer blocks stacked
d_ff = 4 * d_model # FFN hidden dimension = 1536
dropout = 0.2 # Dropout rate: randomly zero 20% of values during training
# --- Training ---
max_iters = 5000 # Total training iterations
eval_interval = 500 # Evaluate loss every N iterations
eval_iters = 200 # How many batches to average for evaluation loss
learning_rate = 3e-4 # Adam optimizer learning rate (0.0003)
torch.manual_seed(1337) # Reproducibility
Here is what each hyperparameter means:
| Hyperparameter | Value | What It Controls |
|---|---|---|
batch_size | 64 | How many independent sequences we process in parallel |
block_size | 256 | Maximum context length — "Given these 256 characters, predict character 257" |
d_model | 384 | The size of each token's vector representation |
num_heads | 6 | Number of parallel attention heads (d_model / num_heads = 384/6 = 64 dims per head) |
num_layers | 6 | Number of transformer blocks stacked on top of each other |
d_ff | 1536 | FFN hidden dimension (4× expansion for "thinking space") |
dropout | 0.2 | Randomly zero 20% of values during training (prevents memorizing) |
learning_rate | 3e-4 | How fast the model adjusts its weights (too high → unstable, too low → slow) |
max_iters | 5000 | Total number of training steps |
In One Line: Throughout this article, our core tensor dimensions are (B, T, C) = (64, 256, 384) — 64 sequences, each 256 characters long, each character represented by 384 numbers.

We are going to train our model on the Tiny Shakespeare dataset — a single text file containing all the works of William Shakespeare. It's approximately 1.1 MB of raw text, roughly 1.1 million characters.
import requests
import os
data_path = 'input.txt'
if not os.path.exists(data_path):
url = 'https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt'
response = requests.get(url)
with open(data_path, 'w', encoding='utf-8') as f:
f.write(response.text)
with open(data_path, 'r', encoding='utf-8') as f:
text = f.read()
print(f"Total characters: {len(text):,}") # 1,115,394
print(f"That's about {len(text) // 5:,} words") # ~223,078 words
First 200 characters of Shakespeare:
First Citizen:
Before we proceed any further, hear me speak.
All:
Speak, speak.
First Citizen:
You are all resolved rather to die than to famish?
All:
Resolved. resolved.
First Citizen:
This is our training data. The model will learn patterns in this text and then generate new text that follows the same patterns.
A tokenizer converts text → numbers and numbers → text. Real GPT uses BPE (Byte Pair Encoding) with ~50,000 subword tokens. We use characters, so our vocabulary is tiny — just 65 unique characters.
# Get all unique characters in the text
chars = sorted(list(set(text)))
vocab_size = len(chars) # 65
print(f"Vocabulary: {''.join(chars)}")
# Output:
!$&',-.3:;?ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
# Create mapping: character → integer, integer → character
stoi = {ch: i for i, ch in enumerate(chars)} # "string to integer"
itos = {i: ch for i, ch in enumerate(chars)} # "integer to string"
# Encode function: string → list of integers
def encode(s):
return [stoi[c] for c in s]
# Decode function: list of integers → string
def decode(l):
return ''.join([itos[i] for i in l])
Tokenizer Demo:
Text: 'Hello!'
Encoded: [20, 43, 50, 50, 53, 2]
Decoded: 'Hello!'
Round-trip match: True ✓
| Feature | Our Model | Real GPT |
|---|---|---|
| Tokenizer | Character-level | BPE (Byte Pair Encoding) |
| Vocab Size | 65 | ~50,000 |
| Token for "Hello" | [20, 43, 50, 50, 53] (5 tokens) | [15496] (1 token) |
| Architecture | Identical | Identical |
The architecture is exactly the same — character-level tokenization just makes everything easier to understand.
Now we encode the entire Shakespeare dataset into a tensor of integers:
data = torch.tensor(encode(text), dtype=torch.long)
print(f"Shape: {data.shape}") # torch.Size([1115394])
print(f"Type: {data.dtype}") # torch.int64
print(f"First 20 tokens: {data[:20].tolist()}")
# [18, 47, 56, 57, 58, 1, 15, 47, 58, 47, 64, 43, 52, 10, 0, 14, 43, 44, 53, 56]
We split the data into two parts:
n = int(0.9 * len(data)) # First 90% = training
train_data = data[:n]
val_data = data[n:]
print(f"Training: {len(train_data):,} characters (90%)") # 1,003,854
print(f"Validation: {len(val_data):,} characters (10%)") # 111,540
If training loss goes down but validation loss goes up, the model is memorizing the training data instead of learning the language.
This is where many tutorials hand-wave the details. How do we take a 1-million-character-long sequence and feed it to the model?
GPT is trained on the task: "Given characters 1..T, predict character T+1."
The trick: the target is just the input shifted by 1 position!
# Example with block_size=8:
# Input: "First Ci" → [18, 47, 56, 57, 58, 1, 15, 47]
# Target: "irst Cit" → [47, 56, 57, 58, 1, 15, 47, 58]
But we also train on all prefixes simultaneously. A single sequence of length 8 gives us 8 training examples:
When input = [18] → predict 47 ('i')
When input = [18, 47] → predict 56 ('r')
When input = [18, 47, 56] → predict 57 ('s')
When input = [18, 47, 56, 57] → predict 58 ('t')
When input = [18, 47, 56, 57, 58] → predict 1 (' ')
When input = [18, 47, 56, 57, 58, 1] → predict 15 ('C')
When input = [18, 47, 56, 57, 58, 1, 15]→ predict 47 ('i')
When input = [18, 47, 56, 57, 58, 1, 15, 47] → predict 58 ('t')
This is incredibly efficient — one sequence, 8 training examples!
def get_batch(split):
"""Generate a random batch of input-target pairs."""
data_source = train_data if split == 'train' else val_data
# Pick random starting positions for each sequence in the batch
ix = torch.randint(len(data_source) - block_size, (batch_size,))
# Stack into batch tensors
x = torch.stack([data_source[i : i + block_size] for i in ix])
y = torch.stack([data_source[i+1 : i + block_size + 1] for i in ix])
x, y = x.to(device), y.to(device)
return x, y
# Demo: grab one batch
xb, yb = get_batch('train')
print(f"Input (x): {xb.shape}") # (64, 256) → 64 sequences × 256 characters
print(f"Target (y): {yb.shape}") # (64, 256) → 64 sequences × 256 characters
Tensor shapes:
x: (B, T) = (64, 256) — 64 sequences of 256 integer IDs eachy: (B, T) = (64, 256) — same shape, shifted by 1 positionAnalogy: Imagine a sliding window of 256 characters dragged across the Shakespeare text. For each window position, the model must predict the next character at every position within that window. We run 64 of these windows in parallel for speed.

Now we build the model, starting with the most fundamental unit: one head of causal (masked) self-attention. This is the core operation we covered conceptually in the previous article — now we implement it for our specific Shakespeare model.
Each head works with head_size = d_model / num_heads = 384 / 6 = 64 dimensions.
head_size = d_model // num_heads # 64
class Head(nn.Module):
"""
One head of causal (masked) self-attention.
Input: (batch, seq_len, d_model) → (B, T, 384)
Output: (batch, seq_len, head_size) → (B, T, 64)
"""
def __init__(self, head_size):
super().__init__()
self.key = nn.Linear(d_model, head_size, bias=False)
self.query = nn.Linear(d_model, head_size, bias=False)
self.value = nn.Linear(d_model, head_size, bias=False)
# Causal mask: lower-triangular matrix
# register_buffer = store in the model but don't train it
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
# Step 1: Compute Q, K, V
k = self.key(x) # (B, T, head_size) → (B, T, 64)
q = self.query(x) # (B, T, head_size) → (B, T, 64)
v = self.value(x) # (B, T, head_size) → (B, T, 64)
# Step 2: Attention scores = Q × K^T / √head_size
# (B, T, 64) @ (B, 64, T) = (B, T, T)
wei = q @ k.transpose(-2, -1) * (head_size ** -0.5)
# Step 3: Causal mask — set future positions to -inf
wei = wei.masked_fill(self.tril[:T, :T] == 0, float('-inf'))
# Step 4: Softmax → probabilities (each row sums to 1)
wei = F.softmax(wei, dim=-1) # (B, T, T)
wei = self.dropout(wei)
# Step 5: Weighted sum of Values
# (B, T, T) @ (B, T, 64) = (B, T, 64)
out = wei @ v
return out
Let's trace the exact tensor dimensions through a single attention head:
| Step | Operation | Shape | Explanation |
|---|---|---|---|
| Input | x | (B, T, 384) | Each token is a 384-dim vector |
| Step 1 | q = query(x) | (B, T, 64) | Project to 64-dim query |
| Step 1 | k = key(x) | (B, T, 64) | Project to 64-dim key |
| Step 1 | v = value(x) | (B, T, 64) | Project to 64-dim value |
| Step 2 | q @ k.T / √64 | (B, T, T) = (B, 256, 256) | 256×256 attention matrix |
| Step 3 | masked_fill(-inf) | (B, T, T) | Future tokens blocked |
| Step 4 | softmax(wei) | (B, T, T) | Each row sums to 1.0 |
| Step 5 | wei @ v | (B, T, 64) | Context-aware output |

As we learned in the previous article, without scaling, dot products of high-dimensional vectors become very large. Large values push softmax to extreme regions (close to 0 or 1), causing vanishing gradients during training. Dividing by √64 = 8 keeps values in a healthy range.
A single attention head looks at relationships from only one perspective. Multi-Head Attention runs multiple heads in parallel, each learning to focus on different types of patterns:
class MultiHeadAttention(nn.Module):
"""
Multiple heads of causal self-attention running in parallel.
Input: (batch, seq_len, d_model) → (B, T, 384)
Output: (batch, seq_len, d_model) → (B, T, 384)
"""
def __init__(self, num_heads, head_size):
super().__init__()
self.heads = nn.ModuleList([Head(head_size) for _ in range(num_heads)])
self.proj = nn.Linear(head_size * num_heads, d_model) # Output projection
self.dropout = nn.Dropout(dropout)
def forward(self, x):
# Run all heads in parallel and concatenate
out = torch.cat([h(x) for h in self.heads], dim=-1)
# (B, T, 64) × 6 heads → concat → (B, T, 384)
out = self.dropout(self.proj(out)) # Project back to d_model
return out
| Step | Shape | Explanation |
|---|---|---|
| Input | (B, T, 384) | Full embedding dimension |
| Each head output | (B, T, 64) | Each head processes 64 dims |
| After concat (6 heads) | (B, T, 384) | 6 × 64 = 384 = d_model ✓ |
| After projection | (B, T, 384) | Same as input! |
Analogy: Imagine you're analyzing a crime scene. One detective focuses on fingerprints, another on footprints, and another on the timeline. Each perspective gives different insights. Multi-Head Attention is like having 6 detectives working together, then combining their findings.
Key insight: The output shape (B, T, 384) is identical to the input shape. This is what allows us to stack layers.
After attention (words talking to each other), each position thinks independently. The Feed-Forward Network processes each token through a two-layer neural network:
class FeedForward(nn.Module):
"""
Position-wise Feed-Forward Network.
Input: (batch, seq_len, d_model) → (B, T, 384)
Output: (batch, seq_len, d_model) → (B, T, 384)
"""
def __init__(self, d_model):
super().__init__()
self.net = nn.Sequential(
nn.Linear(d_model, d_ff), # Expand: 384 → 1536
nn.ReLU(), # Non-linearity
nn.Linear(d_ff, d_model), # Compress: 1536 → 384
nn.Dropout(dropout),
)
def forward(self, x):
return self.net(x)
| Step | Shape | Explanation |
|---|---|---|
| Input | (B, T, 384) | From attention output |
| Expand | (B, T, 1536) | 4× expansion for "thinking space" |
| ReLU | (B, T, 1536) | Non-linearity (without it, stacking layers is pointless) |
| Compress | (B, T, 384) | Back to d_model |
Analogy: If attention is like a group discussion (words talking to each other), the FFN is like individual thinking time — each word processes its own information independently after hearing what everyone else had to say.
The 4× expansion (384 → 1536) gives the model more computational capacity. It's like expanding your workspace to spread out all your papers, then folding everything back up into a tidy summary.
We now have all three ingredients. A single Transformer Block wires them together with two critical additions from deep learning: Layer Normalization and Residual Connections.
When passing data through multiple layers, values tend to either explode to huge numbers or vanish to zero. LayerNorm fixes this by normalizing each token's features to have a mean of 0 and variance of 1, then applying a learnable scale and shift.
Analogy: Imagine a classroom where some students whisper (tiny values) and some shout (huge values). LayerNorm gives everyone a microphone whose output volume is strictly clamped to a comfortable level.
In a deep network, gradients during backpropagation shrink exponentially as they travel back through many layers (the vanishing gradient problem). The solution is simple: instead of output = Layer(x), we use output = Layer(x) + x. We add the original input back to the output!
Analogy: Imagine a highway with 6 toll booths. At each booth, you lose money (gradient decay). A residual connection is a free bypass lane that lets traffic flow unhindered all the way back to the start.
We use Pre-Norm (LayerNorm before the sublayer), which is more stable than the original paper's Post-Norm approach. Here is the data flow inside one block:
class Block(nn.Module):
"""
One Transformer block: communication (attention) + computation (FFN).
Input: (batch, seq_len, d_model) → (B, T, 384)
Output: (batch, seq_len, d_model) → (B, T, 384)
"""
def __init__(self, d_model, num_heads):
super().__init__()
head_size = d_model // num_heads
self.sa = MultiHeadAttention(num_heads, head_size)
self.ffwd = FeedForward(d_model)
self.ln1 = nn.LayerNorm(d_model) # LayerNorm 1
self.ln2 = nn.LayerNorm(d_model) # LayerNorm 2
def forward(self, x):
# Pre-norm residual connection for attention
x = x + self.sa(self.ln1(x))
# Pre-norm residual connection for FFN
x = x + self.ffwd(self.ln2(x))
return x
The absolute superpower of the Transformer block: Input Shape = Output Shape = (B, T, C). Because the shape never changes, we can stack as many of these blocks as our GPU memory allows. We stack 6 of them.

Now we assemble everything into the complete GPT model. Here is the full architecture:
Input token IDs: (batch, seq_len) = (64, 256)
↓
Token Embedding: look up each token → (64, 256, 384)
↓
+ Position Embedding: add position info → (64, 256, 384)
↓
Transformer Block × 6 (the core of the model)
↓
LayerNorm (final)
↓
Linear → (64, 256, 65) ← logits (scores for each character)
↓
Cross-Entropy Loss (during training)
or
Softmax → Sample (during generation)

class GPT(nn.Module):
"""
Character-level GPT Language Model.
Forward pass:
Input: token indices (batch, seq_len) e.g., (64, 256)
Output: logits (batch, seq_len, vocab_size) e.g., (64, 256, 65)
"""
def __init__(self):
super().__init__()
# Token Embedding: maps each character (integer) to a d_model vector
# vocab_size=65 × d_model=384
self.token_embedding_table = nn.Embedding(vocab_size, d_model)
# Position Embedding: maps each position (0..255) to a d_model vector
# This is LEARNED (unlike the sinusoidal encoding in the original paper)
self.position_embedding_table = nn.Embedding(block_size, d_model)
# Stack of 6 identical transformer blocks
self.blocks = nn.Sequential(*[
Block(d_model, num_heads) for _ in range(num_layers)
])
# Final Layer Norm
self.ln_f = nn.LayerNorm(d_model)
# Language Model Head: projects d_model → vocab_size
self.lm_head = nn.Linear(d_model, vocab_size)
# Initialize weights (GPT-2 conventions)
self.apply(self._init_weights)
def _init_weights(self, module):
"""Weight initialization following GPT-2 conventions."""
if isinstance(module, nn.Linear):
torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
if module.bias is not None:
torch.nn.init.zeros_(module.bias)
elif isinstance(module, nn.Embedding):
torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
def forward(self, idx, targets=None):
B, T = idx.shape
# Step 1: Token Embeddings — (B, T) → (B, T, 384)
tok_emb = self.token_embedding_table(idx)
# Step 2: Position Embeddings — (T,) → (T, 384) → broadcasts to (B, T, 384)
pos_emb = self.position_embedding_table(torch.arange(T, device=device))
# Step 3: Add token + position embeddings — (B, T, 384)
x = tok_emb + pos_emb
# Step 4: Pass through all 6 transformer blocks — (B, T, 384)
x = self.blocks(x)
# Step 5: Final layer norm — (B, T, 384)
x = self.ln_f(x)
# Step 6: Project to vocabulary — (B, T, 384) → (B, T, 65)
logits = self.lm_head(x)
# Step 7: Compute loss (only during training)
if targets is None:
loss = None
else:
B, T, C = logits.shape
logits_flat = logits.view(B * T, C) # (16384, 65)
targets_flat = targets.view(B * T) # (16384,)
loss = F.cross_entropy(logits_flat, targets_flat)
return logits, loss
Let's trace a character's journey from raw text to prediction:
| Stage | Operation | Shape | Meaning |
|---|---|---|---|
| 0 | Raw input (integer IDs) | (64, 256) | 64 sequences × 256 characters |
| 1 | Token embedding lookup | (64, 256, 384) | Each token → 384-dim vector |
| 2 | + Position embedding | (64, 256, 384) | Now knows "what" AND "where" |
| 3 | Transformer Block ×6 | (64, 256, 384) | Shape stays the same! |
| 4 | Final LayerNorm | (64, 256, 384) | Normalize for output |
| 5 | LM Head | (64, 256, 65) | Score for each of 65 characters |
| 6 | Flatten for loss | (16384, 65) + (16384,) | PyTorch cross-entropy format |
GPT-2 introduced a specific initialization strategy: all linear layers and embeddings are initialized from a normal distribution with mean=0, std=0.02. Without this, a deep transformer will likely fail to converge. The small standard deviation ensures that initial outputs are close to zero, preventing exploding values at initialization.
model = GPT().to(device)
total_params = sum(p.numel() for p in model.parameters())
print(f"Total parameters: {total_params:,}") # ~10,788,929 ≈ 10.8M
# Test forward pass
test_logits, test_loss = model(xb, yb)
print(f"Logits shape: {test_logits.shape}") # (64, 256, 65)
print(f"Initial loss: {test_loss.item():.4f}") # ~4.17
print(f"Expected: -ln(1/65) = {-math.log(1/65):.4f}") # 4.1744
Before training, the model guesses randomly among 65 characters. The expected initial loss is -ln(1/65) ≈ 4.17. If your starting loss is ~4.17, your architecture is wired correctly!
| Feature | Our Model | GPT-2 Small |
|---|---|---|
| Parameters | ~10.8M | 124M |
| d_model | 384 | 768 |
| Layers | 6 | 12 |
| Heads | 6 | 12 |
| Vocab size | 65 (chars) | 50,257 (BPE) |
| Context length | 256 | 1,024 |
| Training data | 1 MB | 40 GB |
The ARCHITECTURE is the same! The difference is just scale: more parameters, more data, more compute = better results.
During training, we periodically check how well the model is doing. We compute the average loss over many batches for a stable estimate, and we check both train loss and validation loss to detect overfitting.
@torch.no_grad() # Don't compute gradients during evaluation (saves memory)
def estimate_loss():
"""Estimate mean loss on train and val sets."""
out = {}
model.eval() # Set to evaluation mode (disables dropout)
for split in ['train', 'val']:
losses = torch.zeros(eval_iters)
for k in range(eval_iters):
X, Y = get_batch(split)
_, loss = model(X, Y)
losses[k] = loss.item()
out[split] = losses.mean().item()
model.train() # Set back to training mode
return out
Why model.eval() vs model.train()? Dropout randomly zeros out 20% of values during training (to prevent memorization), but during evaluation we want to use all values for an accurate loss estimate. model.eval() disables dropout; model.train() re-enables it.
We bring everything together into the training loop. This is standard deep learning:
import math
import time
# Create optimizer — AdamW is the standard for Transformers
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)
start_time = time.time()
for iter_num in range(max_iters):
# Evaluate periodically
if iter_num % eval_interval == 0 or iter_num == max_iters - 1:
losses = estimate_loss()
elapsed = time.time() - start_time
print(f"Step {iter_num:5d}/{max_iters} | "
f"train loss: {losses['train']:.4f} | "
f"val loss: {losses['val']:.4f} | "
f"time: {elapsed:.1f}s")
# 1. Get a batch
xb, yb = get_batch('train')
# 2. Forward pass
logits, loss = model(xb, yb)
# 3. Zero gradients (PyTorch accumulates by default)
optimizer.zero_grad(set_to_none=True)
# 4. Backward pass — backpropagation through ALL layers
loss.backward()
# 5. Update weights
optimizer.step()
total_time = time.time() - start_time
print(f"
✅ Training complete in {total_time:.1f} seconds!")
We use AdamW (Adam with decoupled weight decay) instead of plain SGD. Adam adapts the learning rate per-parameter — it's much smarter than SGD and is the standard optimizer for Transformers.
Step 0/5000 | train loss: 4.1744 | val loss: 4.1756 | time: 5.2s
Step 500/5000 | train loss: 2.0123 | val loss: 2.0845 | time: 55.3s
Step 1000/5000 | train loss: 1.6234 | val loss: 1.7456 | time: 110.1s
Step 2000/5000 | train loss: 1.4123 | val loss: 1.5678 | time: 220.5s
Step 3000/5000 | train loss: 1.3234 | val loss: 1.5123 | time: 330.2s
Step 4000/5000 | train loss: 1.2845 | val loss: 1.4834 | time: 440.8s
Step 4999/5000 | train loss: 1.2534 | val loss: 1.4723 | time: 550.1s
The loss drops from ~4.17 (random guessing) down to ~1.47 on validation — the model has learned Shakespeare!

Training is done. Now for the magic. We give the model a starting sequence (even just a newline character), and it predicts the next character. We append that character, feed it back, and predict the next. This process is called autoregressive generation.
@torch.no_grad()
def generate(self, idx, max_new_tokens, temperature=1.0, top_k=None):
"""
Generate new text autoregressively.
Args:
idx: (batch, seq_len) — starting context
max_new_tokens: how many new characters to generate
temperature: controls randomness (lower = more conservative)
top_k: only sample from top-k most likely characters
"""
for _ in range(max_new_tokens):
# Crop context to block_size (model can't see more than 256 chars)
idx_cond = idx[:, -block_size:]
# Forward pass: get predictions
logits, _ = self(idx_cond)
# Focus only on the LAST position (next character prediction)
logits = logits[:, -1, :] # (B, vocab_size) = (1, 65)
# Apply temperature
logits = logits / temperature
# Optional: Top-k sampling
if top_k is not None:
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
logits[logits < v[:, [-1]]] = float('-inf')
# Convert to probabilities
probs = F.softmax(logits, dim=-1) # (1, 65)
# Sample from the distribution
idx_next = torch.multinomial(probs, num_samples=1) # (1, 1)
# Append to context
idx = torch.cat((idx, idx_next), dim=1) # (1, T+1)
return idx
| Step | Shape | What Happens |
|---|---|---|
| Context input | (1, T) | Starting characters |
| Crop to block_size | (1, 256) max | Can't exceed context window |
| Forward pass → logits | (1, 256, 65) | Predictions for all positions |
| Take last position | (1, 65) | We only care about the next char |
| Apply temperature | (1, 65) | Scale confidence |
| Softmax → probs | (1, 65) | Convert to probabilities |
| Sample | (1, 1) | Pick one character |
| Concat to context | (1, T+1) | Grow the sequence |
Temperature controls the randomness of generation by dividing the logits before softmax:
| Temperature | Effect | Use Case |
|---|---|---|
0.5 | Very conservative, repetitive but coherent | When you need reliable output |
0.8 | Good balance of creativity and coherence | Recommended default |
1.0 | Normal — uses the raw learned distribution | Standard sampling |
1.5 | Very creative, often incoherent | Creative exploration |
Let's see what the model produces at different stages of training:
Step 0 (Random weights — complete garbage):
xXqJv!m$3:?L_A-k&,Wf!Yz9Mh$pB
Step 500 (Learns spacing, common letters):
The was to math thou for mather!
To the cour and is be word!
Step 5000 (Fully trained — writes Shakespeare!):
ROMEO:
What, is the king dead? What is the matter?
JULIET:
I do not know the matter, but I hear
The news is bad.
First Citizen:
Before we proceed any further, hear me speak.
All:
Speak, speak.
From just 1 MB of text and ~10M parameters, our model learned:
model.eval()
# Generate with prompt
prompt = "ROMEO:"
prompt_encoded = torch.tensor([encode(prompt)], dtype=torch.long, device=device)
generated = model.generate(prompt_encoded, max_new_tokens=500, temperature=0.8)
print(decode(generated[0].tolist()))

Let's look back at the journey across all three articles:
| Article | What We Learned |
|---|---|
| Article 1: RNNs | Sequential memory, LSTMs, Attention, and why RNNs are too slow |
| Article 2: Transformer Building Blocks | Self-Attention, Multi-Head Attention, Positional Encoding, Masking, FFN |
| Article 3: This Article | Assembled a full GPT, trained it on Shakespeare, generated new text |
| Article 4: Next Article | Fine-tuning LLMs: LoRA, RLHF, and adapting models to specific tasks |
In this article, we:
We traced every tensor shape through the entire model, from a raw (64, 256) integer tensor to (64, 256, 65) output logits.
The architecture of our ~10M parameter Shakespeare model is fundamentally identical to GPT-4, LLaMA, Gemini, and Claude.
The difference is scale:
You now understand the architecture powering modern AI.
Now that you know how to build a GPT, the next step is learning how to adapt it for specialized tasks.
In the next article, we'll explore:
Code Link
https://colab.research.google.com/drive/1_13IwDkxptoi7_XP6TafPO7Qm1xRey0A?usp=sharing
Attention Is All You Need
https://arxiv.org/abs/1706.03762
GPT-1: Improving Language Understanding by Generative Pre-Training
https://cdn.openai.com/research-covers/language-unsupervised/language_understanding_paper.pdf
GPT-2: Language Models are Unsupervised Multitask Learners
https://cdn.openai.com/better-language-models/language_models_are_unsupervised_multitask_learners.pdf
Andrej Karpathy's nanoGPT
https://github.com/karpathy/nanoGPT
The Illustrated Transformer
https://jalammar.github.io/illustrated-transformer/
Article 1: Why Vanilla RNNs Struggle with Sequence Problems
https://infominez.com/blog/vanilla_rnns_struggle
Article 2: The Building Blocks of Transformers
https://infominez.com/blog/building-blocks-transformers
About the Author

Pratik Jat
Pratik Jat is an AI Research Engineer at Infominez. He specializes in computer vision, NLP, and deep learning, with a focus on building practical AI solutions for real-world applications.
About the Author

Pratik Jat
Pratik Jat is an AI Research Engineer at Infominez. He specializes in computer vision, NLP, and deep learning, with a focus on building practical AI solutions for real-world applications.