Stanford CS336 Assignment 1: Building a Transformer LM

Complete solution implementation for CS336 Assignment 1 (Basics)

Github repo.

Assignment Overview

This assignment involves building all components needed to train a standard Transformer language model from scratch. The implementation includes:


Section 2: Byte-Pair Encoding (BPE) Tokenizer

2.1 Unicode Standard

Problem (unicode1): Understanding Unicode

(a) What Unicode character does chr(0) return?

(b) How does this character's string representation differ from its printed representation?

(c) What happens when this character occurs in text?

Solution:

(a) chr(0) returns the NULL character (NUL), which is a control character in the ASCII/Unicode standard.

(b) The string representation shows as '\x00' (hexadecimal escape sequence), while the printed representation displays nothing visible.

(c) When the NULL character occurs in text, it typically acts as a string terminator in C-style strings, but in Python strings, it's handled as a regular character that doesn't display visually.

2.2 Unicode Encodings

Problem (unicode2): Unicode Encodings

(a) What are some reasons to prefer training our tokenizer on UTF-8 encoded bytes?

(b) Why is the given decode_utf8_bytes_to_str_wrong function incorrect?

(c) Give a two-byte sequence that does not decode to any Unicode character(s).

Solution:

(a) UTF-8 is preferred because it's more space-efficient for ASCII text (1 byte per character), widely adopted on the internet (98%+ of webpages), and backward compatible with ASCII.

(b) The function is incorrect because it tries to decode each byte individually, but UTF-8 uses multi-byte sequences for many characters. For example, "こ" requires 3 bytes that must be decoded together.

(c) The sequence b'\xc0\x80' is invalid UTF-8 as it's an overlong encoding that doesn't map to any valid Unicode character.

2.4 BPE Tokenizer Training Implementation

Problem (train_bpe): BPE Tokenizer Training

Write a function that trains a byte-level BPE tokenizer given input text, vocabulary size, and special tokens.

Solution: train_bpe.py

import re
import regex
from collections import Counter

def merge(tpl: tuple, pair: tuple) -> tuple:
    """Merge a pair of tokens in a tuple"""
    b1, b2 = pair[0], pair[1]
    ret_val = ()
    i = 0
    while i <= len(tpl) - 1:
        x = tpl[i]
        if i < len(tpl) - 1:
            y = tpl[i+1]
            if x == b1 and y == b2:
                ret_val += (b1 + b2, )
                i += 2
            else:
                ret_val += (x, )
                i += 1
        else:
            ret_val += (x, )
            i += 1
    return ret_val

def train_bpe(input_path: str, vocab_size: int, special_tokens: list[str]):
    """Train a BPE tokenizer"""
    f = open(input_path)
    text = f.read()

    # Split by special tokens
    pattern = "|".join([re.escape(x) for x in special_tokens])
    parts = re.split(pattern, text)

    # Pre-tokenization pattern (GPT-2 style)
    PAT = r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""

    token_counts = Counter()
    vocab = {x: bytes([x]) for x in range(256)}
    vocab[256] = special_tokens[0].encode()  # Add special token

    # Create initial token counts
    for part in parts:
        words = regex.findall(PAT, part)
        for word in words:
            tups = tuple(bytes([b]) for b in word.encode("utf-8"))
            token_counts[tups] += 1

    merges = []
    while len(vocab) < vocab_size:
        # Count pairs
        pair_counts = Counter()
        for tpl, cnt in token_counts.items():
            for x, y in zip(tpl[:-1], tpl[1:]):
                pair_counts[(x, y)] += cnt

        # Find best pair to merge
        best_pair = max(pair_counts, key=lambda k: (pair_counts[k], k))
        merges.append(best_pair)

        # Update token counts with merged tokens
        new_token_counts = Counter()
        for tpl, cnt in token_counts.items():
            new_tpl = merge(tpl, best_pair)
            new_token_counts[new_tpl] += cnt
        token_counts = new_token_counts

        # Add merged token to vocabulary
        vocab[len(vocab)] = best_pair[0] + best_pair[1]

    return vocab, merges

Problem (train_bpe_tinystories): BPE Training on TinyStories

(a) Train a BPE tokenizer on TinyStories with vocab size 10,000. How much time and memory did it take?

(b) Profile your code. What part takes the most time?

Solution:

(a) Training completed in approximately 90 seconds using ~8GB of memory with multiprocessing enabled for pre-tokenization.

(b) Pre-tokenization phase takes the most time (~70%), followed by the merge counting step (~25%). Parallelizing pre-tokenization significantly reduced runtime.

2.6 BPE Tokenizer: Encoding and Decoding

Problem (tokenizer): Implementing the tokenizer

Implement a Tokenizer class that encodes text to token IDs and decodes IDs back to text.

Solution: tokenizer.py

from collections.abc import Iterable, Iterator
import regex
import pickle

class Tokenizer:
    def __init__(self, vocab: dict[int, bytes],
                 merges: list[tuple[bytes, bytes]],
                 special_tokens: list[str] | None = None):
        self.token_to_id = {token: idx for idx, token in vocab.items()}
        self.vocab = vocab
        self.merge_to_idx = {merge: i for i, merge in enumerate(merges)}
        self.special_tokens = special_tokens

    @classmethod
    def from_files(cls, vocab_filepath: str, merges_filepath: str,
                   special_tokens: list[str] | None = None):
        with open(vocab_filepath, 'rb') as f:
            vocab = pickle.load(f)
        with open(merges_filepath, 'rb') as f:
            merges = pickle.load(f)
        return cls(vocab, merges, special_tokens)

    def encode(self, text: str) -> list[int]:
        """Encode text into token IDs"""
        if self.special_tokens:
            pattern = "(" + "|".join([re.escape(x) for x in self.special_tokens]) + ")"
            parts = re.split(pattern, text)
        else:
            parts = [text]

        PAT = r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
        encoded = []

        for part in parts:
            if self.special_tokens and part in self.special_tokens:
                encoded.append(self.token_to_id[part.encode("utf-8")])
            else:
                words = regex.findall(PAT, part)
                for word in words:
                    # Apply BPE merges
                    tokens = self._apply_bpe_to_word(word)
                    encoded.extend(tokens)
        return encoded

    def decode(self, ids: list[int]) -> str:
        """Decode token IDs back to text"""
        byte_sequence = b"".join([self.vocab[i] for i in ids])
        return byte_sequence.decode("utf-8", errors='replace')

Section 3: Transformer Language Model Architecture

3.3 Basic Building Blocks

Problem (linear): Implementing the linear module

Implement a Linear class that performs a linear transformation without bias.

Solution: linear.py

import torch
import torch.nn as nn
from torch.nn.init import trunc_normal_

class Linear(nn.Module):
    def __init__(self, in_features, out_features, device=None, dtype=None):
        super().__init__()
        self.W = nn.Parameter(torch.empty(out_features, in_features,
                                         device=device, dtype=dtype))
        # Initialize with truncated normal
        trunc_normal_(self.W, mean=0.0,
                     std=(2/(in_features + out_features))**0.5,
                     a=-3.0, b=3.0)

    def forward(self, x):
        return x @ self.W.T  # Note the transpose for row-major convention

Problem (embedding): Implement the embedding module

Implement an Embedding class for token embeddings.

Solution: embedding.py

import torch
import torch.nn as nn
from torch.nn.init import trunc_normal_

class Embedding(nn.Module):
    def __init__(self, num_embeddings, embedding_dim, device=None, dtype=None):
        super().__init__()
        self.embedding_matrix = nn.Parameter(
            torch.empty(num_embeddings, embedding_dim, device=device, dtype=dtype)
        )
        trunc_normal_(self.embedding_matrix, mean=0.0, std=1.0, a=-3.0, b=3.0)

    def forward(self, token_ids):
        return self.embedding_matrix[token_ids]

3.4 Pre-Norm Transformer Block

Problem (rmsnorm): Root Mean Square Layer Normalization

Implement RMSNorm as used in modern transformers.

Solution: rmsnorm.py

import torch
import torch.nn as nn

class RMSNorm(nn.Module):
    def __init__(self, d_model: int, eps: float = 1e-5, device=None, dtype=None):
        super().__init__()
        self.eps = eps
        self.gain = nn.Parameter(torch.ones(d_model, device=device, dtype=dtype))

    def forward(self, x):
        in_dtype = x.dtype
        x = x.to(torch.float32)

        # Compute RMS
        rms = torch.sqrt(torch.mean(x ** 2, dim=-1, keepdim=True) + self.eps)

        # Normalize and apply gain
        result = (x / rms) * self.gain

        return result.to(in_dtype)

Problem (positionwise_feedforward): Implement SwiGLU feed-forward network

Implement the SwiGLU activation function for the feed-forward network.

Solution: positionwise_feedforward.py

import torch
import torch.nn as nn
from linear import Linear

class SwiGLU(nn.Module):
    def __init__(self, d_model, d_ff=None):
        super().__init__()
        if d_ff is None:
            d_ff = int(8/3 * d_model)
            d_ff = (d_ff // 64) * 64  # Round to multiple of 64

        self.W1 = Linear(d_model, d_ff)  # Gate
        self.W2 = Linear(d_ff, d_model)  # Output
        self.W3 = Linear(d_model, d_ff)  # Input

    def forward(self, x):
        # SiLU activation: x * sigmoid(x)
        gate = self.W1(x)
        gate = gate * torch.sigmoid(gate)  # SiLU activation

        # Gated linear unit
        hidden = gate * self.W3(x)

        return self.W2(hidden)

Problem (rope): Implement Rotary Position Embeddings

Implement RoPE for positional encoding.

Solution: rope.py

import torch
import torch.nn as nn

class RotaryPositionalEmbedding(nn.Module):
    def __init__(self, theta: float, d_k: int, max_seq_len: int, device=None):
        super().__init__()
        self.theta = theta
        self.d_k = d_k

        # Precompute sin and cos values
        positions = torch.arange(max_seq_len, device=device)
        freqs = 1.0 / (theta ** (torch.arange(0, d_k, 2, device=device) / d_k))
        angles = positions[:, None] * freqs[None, :]

        self.register_buffer('cos_cached', torch.cos(angles), persistent=False)
        self.register_buffer('sin_cached', torch.sin(angles), persistent=False)

    def forward(self, x, token_positions):
        # Apply rotary embeddings
        cos = self.cos_cached[token_positions]
        sin = self.sin_cached[token_positions]

        # Reshape x for rotation
        x_reshaped = x.reshape(*x.shape[:-1], -1, 2)
        x_rot = torch.stack([-x_reshaped[..., 1], x_reshaped[..., 0]], dim=-1)
        x_rot = x_rot.reshape(x.shape)

        # Apply rotation
        return x * cos + x_rot * sin

Problem (multihead_self_attention): Implement causal multi-head self-attention

Implement the multi-head self-attention mechanism with causal masking.

Solution: multihead_self_attention.py

import torch
import torch.nn as nn
from einops import rearrange
from scaled_dot_product_attention import ScaledDotProductAttention

class MultiheadSelfAttention(nn.Module):
    def __init__(self, d_model, num_heads, rope=None):
        super().__init__()
        self.d_model = d_model
        self.num_heads = num_heads
        self.d_k = d_model // num_heads
        self.rope = rope

        # Combined QKV projection for efficiency
        self.W_qkv = nn.Parameter(torch.empty(3 * d_model, d_model))
        self.W_o = nn.Parameter(torch.empty(d_model, d_model))

        # Initialize weights
        nn.init.trunc_normal_(self.W_qkv, std=(2/(d_model + d_model))**0.5)
        nn.init.trunc_normal_(self.W_o, std=(2/(d_model + d_model))**0.5)

        self.attention = ScaledDotProductAttention()

    def forward(self, x):
        batch_size, seq_len, _ = x.shape

        # Project to Q, K, V
        qkv = x @ self.W_qkv.T
        q, k, v = rearrange(qkv, 'b s (three h d) -> three b h s d',
                           three=3, h=self.num_heads)

        # Apply RoPE to queries and keys
        if self.rope is not None:
            positions = torch.arange(seq_len, device=x.device)
            q = self.rope(q, positions)
            k = self.rope(k, positions)

        # Create causal mask
        mask = torch.triu(torch.ones(seq_len, seq_len), diagonal=1).bool()
        mask = ~mask  # Invert: True where attention is allowed

        # Compute attention
        attn_output = self.attention(q, k, v, mask)

        # Concatenate heads and project
        output = rearrange(attn_output, 'b h s d -> b s (h d)')
        return output @ self.W_o.T

3.5 The Full Transformer LM

Problem (transformer_block): Implement the Transformer block

Combine components into a pre-norm Transformer block.

Solution: transformer_block.py

import torch.nn as nn
from multihead_self_attention import MultiheadSelfAttention
from positionwise_feedforward import SwiGLU
from rmsnorm import RMSNorm

class TransformerBlock(nn.Module):
    def __init__(self, d_model, num_heads, d_ff, rope=None):
        super().__init__()
        self.norm1 = RMSNorm(d_model)
        self.attention = MultiheadSelfAttention(d_model, num_heads, rope)
        self.norm2 = RMSNorm(d_model)
        self.ffn = SwiGLU(d_model, d_ff)

    def forward(self, x):
        # Pre-norm attention with residual
        x = x + self.attention(self.norm1(x))

        # Pre-norm FFN with residual
        x = x + self.ffn(self.norm2(x))

        return x

Problem (transformer_lm): Implementing the Transformer LM

Assemble the complete Transformer language model.

Solution: transformer_lm.py

import torch.nn as nn
from transformer_block import TransformerBlock
from embedding import Embedding
from rmsnorm import RMSNorm
from linear import Linear
from rope import RotaryPositionalEmbedding

class TransformerLM(nn.Module):
    def __init__(self, vocab_size, context_length, d_model,
                 num_layers, num_heads, d_ff):
        super().__init__()

        # Initialize RoPE
        rope = RotaryPositionalEmbedding(
            theta=10000.0,
            d_k=d_model // num_heads,
            max_seq_len=context_length
        )

        # Model components
        self.embedding = Embedding(vocab_size, d_model)
        self.blocks = nn.ModuleList([
            TransformerBlock(d_model, num_heads, d_ff, rope)
            for _ in range(num_layers)
        ])
        self.final_norm = RMSNorm(d_model)
        self.lm_head = Linear(d_model, vocab_size)

    def forward(self, input_ids):
        # Token embeddings
        x = self.embedding(input_ids)

        # Pass through transformer blocks
        for block in self.blocks:
            x = block(x)

        # Final norm and output projection
        x = self.final_norm(x)
        logits = self.lm_head(x)

        return logits

Section 4: Training Components

4.1 Cross-entropy Loss

Problem (cross_entropy): Implement cross-entropy loss

Implement numerically stable cross-entropy loss.

Solution: cross_entropy.py

import torch

def cross_entropy_loss(logits, targets):
    """Compute cross-entropy loss with numerical stability"""
    # Subtract max for numerical stability
    logits_max = logits.max(dim=-1, keepdim=True)[0]
    log_probs = logits - logits_max - torch.logsumexp(
        logits - logits_max, dim=-1, keepdim=True
    )

    # Gather log probabilities for target tokens
    batch_size, seq_len = targets.shape
    target_log_probs = log_probs.gather(
        dim=-1,
        index=targets.unsqueeze(-1)
    ).squeeze(-1)

    # Return mean loss
    return -target_log_probs.mean()

4.2-4.3 Optimizers

Problem (adamw): Implement AdamW optimizer

Implement the AdamW optimizer with decoupled weight decay.

Solution: adamw.py

import torch
import math

class AdamW(torch.optim.Optimizer):
    def __init__(self, params, lr=1e-3, betas=(0.9, 0.999),
                 eps=1e-8, weight_decay=0.01):
        defaults = dict(lr=lr, betas=betas, eps=eps, weight_decay=weight_decay)
        super().__init__(params, defaults)

    def step(self, closure=None):
        loss = None if closure is None else closure()

        for group in self.param_groups:
            lr = group['lr']
            beta1, beta2 = group['betas']
            eps = group['eps']
            weight_decay = group['weight_decay']

            for p in group['params']:
                if p.grad is None:
                    continue

                grad = p.grad.data
                state = self.state[p]

                # Initialize state
                if len(state) == 0:
                    state['step'] = 0
                    state['m'] = torch.zeros_like(p.data)
                    state['v'] = torch.zeros_like(p.data)

                m, v = state['m'], state['v']
                state['step'] += 1
                t = state['step']

                # Bias correction
                lr_t = lr * math.sqrt(1 - beta2**t) / (1 - beta1**t)

                # Weight decay
                p.data.mul_(1 - lr * weight_decay)

                # Update moments
                m.mul_(beta1).add_(grad, alpha=1 - beta1)
                v.mul_(beta2).addcmul_(grad, grad, value=1 - beta2)

                # Update parameters
                p.data.addcdiv_(m, v.sqrt().add_(eps), value=-lr_t)

        return loss

4.4 Learning Rate Scheduling

Problem (learning_rate_schedule): Implement cosine learning rate schedule with warmup

Solution: learning_rate_schedule.py

import math

def get_lr_cosine_schedule(t, lr_max, lr_min, T_warmup, T_cosine):
    """Cosine learning rate schedule with linear warmup"""
    if t < T_warmup:
        # Linear warmup
        return (t / T_warmup) * lr_max
    elif t <= T_cosine:
        # Cosine annealing
        progress = (t - T_warmup) / (T_cosine - T_warmup)
        return lr_min + 0.5 * (lr_max - lr_min) * (1 + math.cos(math.pi * progress))
    else:
        # Post-annealing
        return lr_min

4.5 Gradient Clipping

Problem (gradient_clipping): Implement gradient clipping

Solution: gradient_clipping.py

import torch

def clip_gradients(parameters, max_norm, eps=1e-6):
    """Clip gradients by global norm"""
    # Compute total gradient norm
    total_norm = 0.0
    for p in parameters:
        if p.grad is not None:
            total_norm += p.grad.data.norm(2).item() ** 2
    total_norm = total_norm ** 0.5

    # Clip if necessary
    if total_norm > max_norm:
        scale = max_norm / (total_norm + eps)
        for p in parameters:
            if p.grad is not None:
                p.grad.data.mul_(scale)

    return total_norm

Section 5: Training Loop

5.1 Data Loading

Problem (data_loading): Implement data loading

Create a function to sample training batches.

Solution: data_loading.py

import torch
import numpy as np

def get_batch(data, batch_size, context_length, device='cuda'):
    """Sample a batch of training data"""
    # Random starting positions
    ix = torch.randint(len(data) - context_length, (batch_size,))

    # Extract sequences
    x = torch.stack([
        torch.from_numpy(data[i:i+context_length].astype(np.int64))
        for i in ix
    ])

    # Targets are next tokens
    y = torch.stack([
        torch.from_numpy(data[i+1:i+context_length+1].astype(np.int64))
        for i in ix
    ])

    return x.to(device), y.to(device)

5.2 Checkpointing

Problem (checkpointing): Implement model checkpointing

Solution: checkpointing.py

import torch

def save_checkpoint(model, optimizer, iteration, filepath):
    """Save training checkpoint"""
    checkpoint = {
        'model_state_dict': model.state_dict(),
        'optimizer_state_dict': optimizer.state_dict(),
        'iteration': iteration
    }
    torch.save(checkpoint, filepath)

def load_checkpoint(filepath, model, optimizer):
    """Load training checkpoint"""
    checkpoint = torch.load(filepath)
    model.load_state_dict(checkpoint['model_state_dict'])
    optimizer.load_state_dict(checkpoint['optimizer_state_dict'])
    return checkpoint['iteration']

5.3 Complete Training Loop

Problem (training_together): Put it together - complete training script

Solution: training_together.py

import torch
import numpy as np
from transformer_lm import TransformerLM
from adamw import AdamW
from data_loading import get_batch
from learning_rate_schedule import get_lr_cosine_schedule
from gradient_clipping import clip_gradients
import time

def train_model(config):
    """Main training loop"""
    # Load tokenized data
    train_data = np.memmap(config['train_path'], dtype=np.uint16, mode='r')
    val_data = np.memmap(config['val_path'], dtype=np.uint16, mode='r')

    # Initialize model
    model = TransformerLM(
        vocab_size=config['vocab_size'],
        context_length=config['context_length'],
        d_model=config['d_model'],
        num_layers=config['num_layers'],
        num_heads=config['num_heads'],
        d_ff=config['d_ff']
    ).to(config['device'])

    # Initialize optimizer
    optimizer = AdamW(
        model.parameters(),
        lr=config['learning_rate'],
        betas=config['betas'],
        weight_decay=config['weight_decay']
    )

    # Training loop
    for step in range(config['max_steps']):
        # Get learning rate
        lr = get_lr_cosine_schedule(
            step,
            config['learning_rate'],
            config['min_learning_rate'],
            config['warmup_steps'],
            config['max_steps']
        )
        for param_group in optimizer.param_groups:
            param_group['lr'] = lr

        # Sample batch
        x, y = get_batch(train_data, config['batch_size'],
                        config['context_length'], config['device'])

        # Forward pass
        logits = model(x)
        loss = cross_entropy_loss(
            logits.view(-1, logits.size(-1)),
            y.view(-1)
        )

        # Backward pass
        optimizer.zero_grad()
        loss.backward()

        # Gradient clipping
        clip_gradients(model.parameters(), config['grad_clip'])

        # Optimizer step
        optimizer.step()

        # Logging
        if step % config['log_interval'] == 0:
            print(f"Step {step}: loss={loss.item():.4f}, lr={lr:.6f}")

        # Validation
        if step % config['eval_interval'] == 0:
            model.eval()
            with torch.no_grad():
                val_x, val_y = get_batch(val_data, config['batch_size'],
                                        config['context_length'], config['device'])
                val_logits = model(val_x)
                val_loss = cross_entropy_loss(
                    val_logits.view(-1, val_logits.size(-1)),
                    val_y.view(-1)
                )
                perplexity = torch.exp(val_loss)
                print(f"Validation: loss={val_loss.item():.4f}, "
                      f"perplexity={perplexity.item():.2f}")
            model.train()

        # Checkpointing
        if step % config['save_interval'] == 0:
            save_checkpoint(model, optimizer, step,
                          f"checkpoint_step_{step}.pt")

# Example configuration
config = {
    'vocab_size': 10000,
    'context_length': 256,
    'd_model': 512,
    'num_layers': 4,
    'num_heads': 16,
    'd_ff': 1344,
    'batch_size': 64,
    'learning_rate': 3e-4,
    'min_learning_rate': 3e-5,
    'betas': (0.9, 0.95),
    'weight_decay': 0.01,
    'grad_clip': 1.0,
    'max_steps': 5000,
    'warmup_steps': 500,
    'device': 'cuda',
    'log_interval': 100,
    'eval_interval': 500,
    'save_interval': 1000
}

Section 6: Text Generation

Problem (decoding): Implement text generation with temperature and top-p sampling

Solution: decode.py

import torch
import torch.nn.functional as F

def generate_text(model, tokenizer, prompt, max_tokens=256,
                  temperature=1.0, top_p=0.9):
    """Generate text from the model"""
    model.eval()

    # Encode prompt
    tokens = tokenizer.encode(prompt)
    tokens = torch.tensor(tokens, dtype=torch.long).unsqueeze(0).to('cuda')

    with torch.no_grad():
        for _ in range(max_tokens):
            # Get model predictions
            logits = model(tokens)
            logits = logits[:, -1, :]  # Last token predictions

            # Apply temperature
            if temperature != 1.0:
                logits = logits / temperature

            # Convert to probabilities
            probs = F.softmax(logits, dim=-1)

            # Top-p (nucleus) sampling
            if top_p < 1.0:
                sorted_probs, sorted_indices = torch.sort(probs, descending=True)
                cumulative_probs = torch.cumsum(sorted_probs, dim=-1)

                # Find cutoff
                cutoff_index = (cumulative_probs > top_p).nonzero()[0]
                sorted_probs[cutoff_index:] = 0
                sorted_probs = sorted_probs / sorted_probs.sum()

                # Sample from filtered distribution
                next_token_idx = torch.multinomial(sorted_probs, 1)
                next_token = sorted_indices[next_token_idx]
            else:
                # Regular sampling
                next_token = torch.multinomial(probs, 1)

            # Stop if end token
            if next_token.item() == tokenizer.token_to_id[b'<|endoftext|>']:
                break

            # Append to sequence
            tokens = torch.cat([tokens, next_token], dim=-1)

    # Decode to text
    generated_ids = tokens[0].tolist()
    return tokenizer.decode(generated_ids)

Section 7: Experiments and Results

7.2 TinyStories Experiments

Training Configuration

Learning Rate Experiments

As shown in the original HTML file, I experimented with various learning rates from 1e-5 to 1.0:

Learning Rate Validation Loss Perplexity Notes
1e-5 3.05 21.12 Too slow convergence
3e-5 2.57 13.06 Better but still slow
1e-4 2.01 7.46 Good convergence
3e-4 1.81 6.11 Near optimal
1e-3 1.69 5.41 Optimal
3e-3 2.19 8.94 Beginning to diverge
1e-2 3.19 24.28 Too high
3e-2+ >4.0 >50 Diverges

Batch Size Experiments

Testing various batch sizes while keeping training time constant:

Batch Size Steps (fixed time) Final Loss Notes
1 288,130 2.05 Very noisy gradients
4 173,890 1.78 Better stability
16 48,820 1.68 Good balance
32 24,780 1.59 Optimal
64 12,100 1.73 Diminishing returns
128 5,910 1.75 Too few updates
150 5,030 1.65 GPU memory limit

Architecture Ablations

RMSNorm Removal

Without RMSNorm:

Conclusion: RMSNorm is critical for training stability and enables higher learning rates.

Pre-norm vs Post-norm

Learning Rate Pre-norm Loss Post-norm Loss
1e-3 1.64 1.74
1e-4 2.07 2.10
1e-5 3.10 3.08

Conclusion: Pre-norm consistently outperforms post-norm, especially at higher learning rates.

RoPE vs No Position Embeddings

With RoPE: Loss = 1.64
Without RoPE: Loss = 1.78

Conclusion: RoPE provides meaningful improvements in modeling capability.

SwiGLU vs SiLU

With SwiGLU: Loss = 1.66
With SiLU only: Loss = 1.76

Conclusion: The gating mechanism in SwiGLU provides measurable benefits.


Generated Text Samples

TinyStories Model Output (after 5000 steps):

Once upon a time, there was a pretty girl named Lily. She loved to eat gum,
especially the big black one. One day, Lily's mom asked her to help cook dinner.
Lily was so excited! She loved to help her mom.

Lily's mom made a big pot of soup for dinner. Lily was so happy and said,
"Thank you, Mommy! I love you." She helped her mom pour the soup into a big bowl.

After dinner, Lily's mom made some yummy soup. Lily loved it! She said,
"Thank you, Mommy! This soup is so yummy!" Her mom smiled and said,
"I'm glad you like it, Lily." They finished cooking and continued to cook together.

The end.

Key Learnings and Observations

1. Learning Rate Dynamics

The optimal learning rate (1e-3) achieves the lowest loss while maintaining stability. As learning rate increases beyond this point, gradient norms increase dramatically, leading to training instability. The "edge of stability" phenomenon is clearly visible in the gradient norm plots.

2. Batch Size Trade-offs

Batch size 32 provided the best performance in fixed-time experiments. Smaller batches are too noisy, while larger batches don't get enough gradient updates in the same time period. This demonstrates the importance of balancing gradient quality with update frequency.

3. Architectural Components

4. Implementation Insights

The most challenging aspects were:


Conclusion

This implementation successfully demonstrates a complete transformer language model pipeline from scratch, achieving a validation perplexity of 5.41 on TinyStories with just 17M parameters. The systematic ablation studies reveal the importance of each architectural component, with layer normalization and proper learning rate selection being the most critical factors for successful training.

The model generates coherent, grammatically correct children's stories after training, validating the correctness of the implementation. The comprehensive experiments provide insights into hyperparameter selection and architectural design choices that are applicable to larger-scale language model training.

Future Work / Yet to be Done:


Assignment completed as part of self-study of Stanford CS336: Language Modeling from Scratch