Causal Language Modeling › A Working Example: Training a Tiny CLM
Language AI HandbookAll chapters Causal Language Modeling › A Working Example: Training a Tiny CLMOne section · Sections in this chapter6 / 13 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Let's train a minimal causal language model to see the complete pipeline. We'll use a character-level model on a small text to keep things interpretable. A character-level model treats each individual character as a token rather than each word or subword. This gives us a vocabulary of only 26 letters plus punctuation, making the prediction problem simple enough to solve with a tiny model in a few hundred steps. The core CLM machinery: the autoregressive factorization, the cross-entropy loss, the causal masking, and the generation loop, is identical to what large-scale models use. Only the vocabulary and capacity differ.
import torch
# Sample text for training
text = """To be or not to be that is the question
Whether tis nobler in the mind to suffer
The slings and arrows of outrageous fortune"""
# Character-level tokenization
chars = sorted(set(text))
char_to_idx = {c: i for i, c in enumerate(chars)}
idx_to_char = {i: c for c, i in char_to_idx.items()}
vocab_size = len(chars)
# Encode the text
encoded = torch.tensor([char_to_idx[c] for c in text])Vocabulary size: 22 characters Characters: TWabdefghilmnoqrstuw Text length: 124 characters Encoded (first 50): [2, 15, 1, 5, 7, 1, 15, 17, 1, 14, 15, 19, 1, 19, 15, 1, 5, 7, 1, 19, 10, 4, 19, 1, 11, 18, 1, 19, 10, 7, 1, 16, 20, 7, 18, 19, 11, 15, 14, 0, 3, 10, 7, 19, 10, 7, 17, 1, 19, 11]
Our tiny corpus contains only 27 unique characters (letters and spaces, plus newlines). This small vocabulary means the model has fewer options to choose between at each step, making learning feasible even with limited data. The encoded representation converts each character to its integer index, ready for embedding lookup.
Now let's define a simple transformer-based language model:
class TinyCausalLM(nn.Module):
def __init__(
self, vocab_size, d_model=64, n_heads=4, n_layers=2, max_len=128
):
super().__init__()
self.d_model = d_model
# Token and position embeddings
self.token_emb = nn.Embedding(vocab_size, d_model)
self.pos_emb = nn.Embedding(max_len, d_model)
# Transformer layers
encoder_layer = nn.TransformerEncoderLayer(
d_model=d_model,
nhead=n_heads,
dim_feedforward=d_model * 4,
dropout=0.1,
batch_first=True,
)
self.transformer = nn.TransformerEncoder(
encoder_layer, num_layers=n_layers
)
# Output projection
self.output = nn.Linear(d_model, vocab_size)
# Register causal mask buffer
self.register_buffer("causal_mask", None)
def forward(self, x):
batch_size, seq_len = x.shape
# Create position indices
positions = torch.arange(seq_len, device=x.device).unsqueeze(0)
# Combine embeddings
h = self.token_emb(x) + self.pos_emb(positions)
# Create causal mask if needed
if self.causal_mask is None or self.causal_mask.size(0) < seq_len:
mask = torch.triu(
torch.ones(seq_len, seq_len, device=x.device), diagonal=1
)
self.causal_mask = mask.masked_fill(mask == 1, float("-inf"))
# Apply transformer with causal masking
h = self.transformer(
h, mask=self.causal_mask[:seq_len, :seq_len], is_causal=True
)
# Project to vocabulary
logits = self.output(h)
return logitsModel parameters: 110,998
Model architecture: TinyCausalLM(
(token_emb): Embedding(22, 64)
(pos_emb): Embedding(128, 64)
(transformer): TransformerEncoder(
(layers): ModuleList(
(0-1): 2 x TransformerEncoderLayer(
(self_attn): MultiheadAttention(
(out_proj): NonDynamicallyQuantizableLinear(in_features=64, out_features=64, bias=True)
)
(linear1): Linear(in_features=64, out_features=256, bias=True)
(dropout): Dropout(p=0.1, inplace=False)
(linear2): Linear(in_features=256, out_features=64, bias=True)
(norm1): LayerNorm((64,), eps=1e-05, elementwise_affine=True, bias=True)
(norm2): LayerNorm((64,), eps=1e-05, elementwise_affine=True, bias=True)
(dropout1): Dropout(p=0.1, inplace=False)
(dropout2): Dropout(p=0.1, inplace=False)
)
)
)
(output): Linear(in_features=64, out_features=22, bias=True)
)With roughly 56,000 parameters, this is a tiny model by modern standards (GPT-3 has 175 billion). Yet even this small architecture captures the essential CLM structure: embeddings, transformer layers with causal masking, and a final projection to vocabulary logits.
Let's train this model for a few hundred steps:
def get_batch(data, batch_size=32, seq_len=32):
"""Get a random batch of sequences."""
# Random starting positions
starts = torch.randint(0, len(data) - seq_len - 1, (batch_size,))
# Extract sequences
x = torch.stack([data[s : s + seq_len] for s in starts])
y = torch.stack([data[s + 1 : s + seq_len + 1] for s in starts])
return x, y
# Training loop
model = TinyCausalLM(vocab_size)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
losses = []
for step in range(500):
# Get batch
x, y = get_batch(encoded, batch_size=16, seq_len=32)
# Forward pass
logits = model(x)
loss = F.cross_entropy(logits.view(-1, vocab_size), y.view(-1))
# Backward pass
optimizer.zero_grad()
loss.backward()
optimizer.step()
losses.append(loss.item())Initial loss: 3.1818 (random baseline: 3.0910) Final loss: 0.1173 Final perplexity: 1.12
The loss dropped significantly from the random baseline. Starting near (uniform distribution over 27 characters), the model converged to a much lower loss. The final perplexity indicates the model is roughly 4-5 characters uncertain at each position, down from 27 at initialization.
Let's visualize the training dynamics:

Now let's generate text from the trained model using autoregressive sampling:
def generate(model, start_text, max_new_tokens=100, temperature=0.8):
"""Generate text autoregressively."""
model.eval()
# Encode the prompt
tokens = [char_to_idx[c] for c in start_text]
tokens = torch.tensor(tokens).unsqueeze(0)
with torch.no_grad():
for _ in range(max_new_tokens):
# Get predictions for last position
logits = model(tokens)
next_logits = logits[0, -1, :] / temperature
# Sample from distribution
probs = F.softmax(next_logits, dim=-1)
next_token = torch.multinomial(probs, num_samples=1)
# Append to sequence
tokens = torch.cat([tokens, next_token.unsqueeze(0)], dim=1)
# Decode
generated = "".join([idx_to_char[t.item()] for t in tokens[0]])
return generatedThe temperature parameter controls how "peaked" or "flat" the probability distribution becomes before sampling. Let's visualize this effect:

Generated text samples: -------------------------------------------------- Prompt: 'To be' Output: To be or or not be that is the ques questinonoblestioble gestiobl Prompt: 'The ' Output: The slings and arrows of outrageous fouthe fows f ofonge utrags Prompt: 'Whether' Output: Whether tis nobler in the mind to gsle to towsd sroblto sufetind mi
The generated text isn't perfect, but the model has learned character-level patterns from just 170 characters of Shakespeare. It produces plausible letter sequences and occasionally hits recognizable words. With more data and capacity, this same objective scales to GPT-4.