Causal Language Modeling › A Working Example: Training a Tiny CLM

Michael BrenndoerferUpdated July 7, 20256 min read
Language AI HandbookAll chapters
Part 22 of 67Pre-training Objectives
Chapter 155Causal Language Modeling › A Working Example: Training a Tiny CLM
Reading section 6 of 13 · A Working Example: Training a Tiny CLM · 6 minSection 6 of 13 · 6 min

Book contents

Section 6 of 13 · 6 min read

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.

In[13]:
Code
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])
Out[14]:
Console
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:

In[15]:
Code
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 logits
Out[16]:
Console
Model 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:

In[17]:
Code
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())
Out[18]:
Console
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 log⁡(27)≈3.3\log(27) \approx 3.3 (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:

Out[19]:
Visualization
Line plot showing raw and smoothed training loss decreasing from about 3.1 to 0.1 over 500 training steps, with a random baseline at 3.09.
Training loss curve for the character-level causal language model. The loss drops rapidly in early training as the model learns basic character statistics and common patterns, then tapers toward roughly 0.1 by step 500.

Now let's generate text from the trained model using autoregressive sampling:

In[20]:
Code
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 generated

The temperature parameter controls how "peaked" or "flat" the probability distribution becomes before sampling. Let's visualize this effect:

Out[21]:
Visualization
Grouped bar chart comparing four next-token probability distributions at temperatures 0.3, 0.7, 1.0, and 1.5; lower temperatures concentrate probability more sharply on the first-ranked token.
Effect of temperature on the next-token probability distribution. Lower temperatures (T=0.3) sharpen the distribution, making the model more deterministic by concentrating probability on the most likely tokens. Higher temperatures (T=1.5) flatten the distribution, increasing randomness and diversity in generation. Temperature 1.0 uses the raw model probabilities.
Out[22]:
Console
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.