"""Training loop and evaluation harness for the lesson 35 GPT model. Implements: batch construction with input/target shift by one, cross entropy loss in `calc_loss_batch`, held-out evaluation in `evaluate_model`, a qualitative generation probe in `generate_and_print_sample`, AdamW with a decay/no-decay split, a linear-warmup-plus-cosine learning rate schedule, gradient norm clipping, and a JSONL log of per step loss in `outputs/losses.jsonl`. The demo trains a tiny model on synthetic byte-level tokens for a small number of steps, writes the JSONL log, and prints eval losses and generated samples at the probe points. End to end runs in well under a minute on CPU. Run: python3 code/main.py """ from __future__ import annotations import json import math from dataclasses import dataclass from pathlib import Path from typing import Iterator import torch import torch.nn as nn import torch.nn.functional as F HERE = Path(__file__).resolve().parent OUTPUTS = HERE.parent / "outputs" OUTPUTS.mkdir(parents=True, exist_ok=True) LOG_PATH = OUTPUTS / "losses.jsonl" @dataclass class TrainConfig: """Training and evaluation hyperparameters for the demo run.""" batch_size: int = 4 context_length: int = 32 num_steps: int = 80 eval_every: int = 20 eval_batches: int = 4 max_lr: float = 3e-3 min_lr: float = 3e-4 warmup_steps: int = 10 weight_decay: float = 0.01 grad_clip: float = 1.0 sample_max_new_tokens: int = 16 seed: int = 0 @dataclass class ModelConfig: vocab_size: int = 256 context_length: int = 32 d_model: int = 64 num_heads: int = 4 num_layers: int = 2 mlp_expansion: int = 4 dropout: float = 0.1 use_bias: bool = True weight_tying: bool = True class LayerNorm(nn.Module): def __init__(self, d_model: int, eps: float = 1e-5) -> None: super().__init__() self.eps = eps self.scale = nn.Parameter(torch.ones(d_model)) self.shift = nn.Parameter(torch.zeros(d_model)) def forward(self, x: torch.Tensor) -> torch.Tensor: mean = x.mean(dim=-1, keepdim=True) var = x.var(dim=-1, keepdim=True, unbiased=False) return self.scale * (x - mean) / torch.sqrt(var + self.eps) + self.shift class MultiHeadAttention(nn.Module): def __init__(self, cfg: ModelConfig) -> None: super().__init__() if cfg.d_model % cfg.num_heads != 0: raise ValueError("d_model must be divisible by num_heads") self.d_model = cfg.d_model self.num_heads = cfg.num_heads self.head_dim = cfg.d_model // cfg.num_heads self.context_length = cfg.context_length self.qkv = nn.Linear(cfg.d_model, 3 * cfg.d_model, bias=cfg.use_bias) self.out_proj = nn.Linear(cfg.d_model, cfg.d_model, bias=cfg.use_bias) self.attn_dropout = nn.Dropout(cfg.dropout) self.resid_dropout = nn.Dropout(cfg.dropout) mask = torch.triu( torch.ones(cfg.context_length, cfg.context_length, dtype=torch.bool), diagonal=1, ) self.register_buffer("causal_mask", mask, persistent=False) def forward(self, x: torch.Tensor) -> torch.Tensor: batch, seq, dim = x.shape qkv = self.qkv(x) q, k, v = qkv.split(self.d_model, dim=-1) q = q.view(batch, seq, self.num_heads, self.head_dim).transpose(1, 2) k = k.view(batch, seq, self.num_heads, self.head_dim).transpose(1, 2) v = v.view(batch, seq, self.num_heads, self.head_dim).transpose(1, 2) scores = q @ k.transpose(-2, -1) / math.sqrt(self.head_dim) scores = scores.masked_fill(self.causal_mask[:seq, :seq], float("-inf")) attn = F.softmax(scores, dim=-1) attn = self.attn_dropout(attn) out = (attn @ v).transpose(1, 2).contiguous().view(batch, seq, dim) return self.resid_dropout(self.out_proj(out)) class FeedForward(nn.Module): def __init__(self, cfg: ModelConfig) -> None: super().__init__() hidden = cfg.mlp_expansion * cfg.d_model self.fc1 = nn.Linear(cfg.d_model, hidden, bias=cfg.use_bias) self.act = nn.GELU(approximate="tanh") self.fc2 = nn.Linear(hidden, cfg.d_model, bias=cfg.use_bias) self.dropout = nn.Dropout(cfg.dropout) def forward(self, x: torch.Tensor) -> torch.Tensor: return self.dropout(self.fc2(self.act(self.fc1(x)))) class TransformerBlock(nn.Module): def __init__(self, cfg: ModelConfig) -> None: super().__init__() self.ln1 = LayerNorm(cfg.d_model) self.attn = MultiHeadAttention(cfg) self.ln2 = LayerNorm(cfg.d_model) self.mlp = FeedForward(cfg) def forward(self, x: torch.Tensor) -> torch.Tensor: x = x + self.attn(self.ln1(x)) x = x + self.mlp(self.ln2(x)) return x class GPTModel(nn.Module): def __init__(self, cfg: ModelConfig) -> None: super().__init__() self.cfg = cfg self.tok_embed = nn.Embedding(cfg.vocab_size, cfg.d_model) self.pos_embed = nn.Embedding(cfg.context_length, cfg.d_model) self.embed_dropout = nn.Dropout(cfg.dropout) self.blocks = nn.ModuleList([TransformerBlock(cfg) for _ in range(cfg.num_layers)]) self.final_ln = LayerNorm(cfg.d_model) self.lm_head = nn.Linear(cfg.d_model, cfg.vocab_size, bias=False) if cfg.weight_tying: self.lm_head.weight = self.tok_embed.weight self.register_buffer( "position_ids", torch.arange(cfg.context_length, dtype=torch.long), persistent=False, ) self.apply(self._init_weights) scale = 1.0 / math.sqrt(2 * cfg.num_layers) for block in self.blocks: block.attn.out_proj.weight.data.mul_(scale) block.mlp.fc2.weight.data.mul_(scale) @staticmethod def _init_weights(module: nn.Module) -> None: if isinstance(module, nn.Linear): nn.init.normal_(module.weight, mean=0.0, std=0.02) if module.bias is not None: nn.init.zeros_(module.bias) elif isinstance(module, nn.Embedding): nn.init.normal_(module.weight, mean=0.0, std=0.02) def forward(self, tokens: torch.Tensor) -> torch.Tensor: batch, seq = tokens.shape tok = self.tok_embed(tokens) pos = self.pos_embed(self.position_ids[:seq]) x = self.embed_dropout(tok + pos) for block in self.blocks: x = block(x) return self.lm_head(self.final_ln(x)) def make_batches( token_ids: torch.Tensor, batch_size: int, context_length: int, seed: int = 0, ) -> Iterator[tuple[torch.Tensor, torch.Tensor]]: """Yield (input, target) batches where target is input shifted by one position. Sampling is uniform random over valid start positions. With a fixed seed the sequence of batches is reproducible across runs. """ if token_ids.dim() != 1: raise ValueError("token_ids must be a 1D tensor") if token_ids.numel() < context_length + 1: raise ValueError("token_ids too short for the requested context_length") generator = torch.Generator().manual_seed(seed) max_start = token_ids.numel() - context_length - 1 while True: starts = torch.randint(0, max_start + 1, (batch_size,), generator=generator) inputs = torch.stack([token_ids[s : s + context_length] for s in starts.tolist()]) targets = torch.stack( [token_ids[s + 1 : s + 1 + context_length] for s in starts.tolist()] ) yield inputs, targets def calc_loss_batch( model: GPTModel, inputs: torch.Tensor, targets: torch.Tensor, ) -> torch.Tensor: """Forward, flatten across batch and time, return scalar cross entropy.""" logits = model(inputs) return F.cross_entropy( logits.reshape(-1, logits.size(-1)), targets.reshape(-1), ) @torch.no_grad() def evaluate_model( model: GPTModel, val_loader: Iterator[tuple[torch.Tensor, torch.Tensor]], max_batches: int, ) -> float: """Mean cross entropy over `max_batches` validation batches; no grad, no dropout.""" was_training = model.training model.eval() total = 0.0 count = 0 for inputs, targets in val_loader: if count >= max_batches: break loss = calc_loss_batch(model, inputs, targets) total += float(loss.item()) count += 1 if was_training: model.train() return total / max(count, 1) @torch.no_grad() def generate_and_print_sample( model: GPTModel, prompt: torch.Tensor, max_new_tokens: int, temperature: float = 1.0, top_k: int = 40, seed: int = 0, ) -> list[int]: """Print a short generated continuation from a fixed prompt and return the tokens.""" sample_gen = torch.Generator(device=prompt.device).manual_seed(seed) was_training = model.training model.eval() tokens = prompt.clone() for _ in range(max_new_tokens): window = tokens[:, -model.cfg.context_length :] logits = model(window) next_logits = logits[:, -1, :] / temperature if top_k > 0: top_k_eff = min(top_k, next_logits.size(-1)) values, _ = torch.topk(next_logits, top_k_eff, dim=-1) threshold = values[..., -1:] next_logits = torch.where( next_logits < threshold, torch.full_like(next_logits, float("-inf")), next_logits ) probs = F.softmax(next_logits, dim=-1) next_token = torch.multinomial(probs, num_samples=1, generator=sample_gen) tokens = torch.cat([tokens, next_token], dim=1) if was_training: model.train() seq = tokens.tolist()[0] print(f" sample tokens : {seq}") return seq def build_param_groups(model: nn.Module, weight_decay: float) -> list[dict]: """Split parameters: matrix-shaped tensors get decay; scale/bias/embedding biases do not.""" decay: list[nn.Parameter] = [] no_decay: list[nn.Parameter] = [] for name, param in model.named_parameters(): if not param.requires_grad: continue if param.dim() < 2 or name.endswith(".bias") or name.endswith(".shift") or name.endswith(".scale"): no_decay.append(param) else: decay.append(param) return [ {"params": decay, "weight_decay": weight_decay}, {"params": no_decay, "weight_decay": 0.0}, ] def cosine_with_warmup( step: int, warmup_steps: int, total_steps: int, max_lr: float, min_lr: float, ) -> float: """Linear warmup then cosine decay to min_lr over the remaining steps.""" if step < warmup_steps: return max_lr * (step + 1) / max(warmup_steps, 1) progress = (step - warmup_steps) / max(total_steps - warmup_steps, 1) progress = min(max(progress, 0.0), 1.0) cosine = 0.5 * (1.0 + math.cos(math.pi * progress)) return min_lr + (max_lr - min_lr) * cosine def train( model: GPTModel, train_tokens: torch.Tensor, val_tokens: torch.Tensor, cfg: TrainConfig, prompt: torch.Tensor, log_path: Path = LOG_PATH, ) -> list[dict]: """Run the training loop, persist losses.jsonl, return the in-memory log.""" torch.manual_seed(cfg.seed) optimizer = torch.optim.AdamW( build_param_groups(model, cfg.weight_decay), lr=cfg.max_lr, betas=(0.9, 0.95), ) train_loader = make_batches(train_tokens, cfg.batch_size, cfg.context_length, seed=cfg.seed) if log_path.exists(): log_path.unlink() records: list[dict] = [] model.train() for step in range(cfg.num_steps): lr = cosine_with_warmup(step, cfg.warmup_steps, cfg.num_steps, cfg.max_lr, cfg.min_lr) for group in optimizer.param_groups: group["lr"] = lr inputs, targets = next(train_loader) optimizer.zero_grad(set_to_none=True) loss = calc_loss_batch(model, inputs, targets) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=cfg.grad_clip) optimizer.step() record = {"step": step, "train_loss": float(loss.item()), "lr": lr} if (step + 1) % cfg.eval_every == 0 or step == cfg.num_steps - 1: val_loader = make_batches( val_tokens, cfg.batch_size, cfg.context_length, seed=cfg.seed + 1 ) val_loss = evaluate_model(model, val_loader, cfg.eval_batches) record["val_loss"] = val_loss print( f"step {step:4d} | lr {lr:.5f} | train_loss {loss.item():.4f} | val_loss {val_loss:.4f}" ) generate_and_print_sample( model, prompt, cfg.sample_max_new_tokens, temperature=0.8, top_k=20, seed=step ) records.append(record) with log_path.open("a", encoding="utf-8") as fh: fh.write(json.dumps(record) + "\n") return records def _synthetic_byte_tokens(length: int, vocab_size: int, seed: int) -> torch.Tensor: """Deterministic synthetic tokens. Bytes drawn from a small repeating pattern so the model has structure to learn in a handful of steps; the eval loss should drop visibly during the demo. """ rng = torch.Generator().manual_seed(seed) base = torch.randint(0, vocab_size, (32,), generator=rng) repeats = (length + base.numel() - 1) // base.numel() tokens = base.repeat(repeats)[:length] noise = torch.randint(0, vocab_size, (length,), generator=rng) mask = (torch.rand(length, generator=rng) < 0.1) tokens = torch.where(mask, noise, tokens) return tokens.to(dtype=torch.long) def demo() -> None: torch.manual_seed(0) cfg = TrainConfig() mcfg = ModelConfig( vocab_size=256, context_length=cfg.context_length, d_model=64, num_heads=4, num_layers=2, dropout=0.0, ) train_tokens = _synthetic_byte_tokens(length=4096, vocab_size=mcfg.vocab_size, seed=1) val_tokens = _synthetic_byte_tokens(length=1024, vocab_size=mcfg.vocab_size, seed=2) print(f"train tokens : {train_tokens.numel():,}") print(f"val tokens : {val_tokens.numel():,}") print(f"model params : {sum(p.numel() for p in GPTModel(mcfg).parameters()):,} (untied count)") model = GPTModel(mcfg) prompt = torch.tensor([[7, 11, 13, 17]], dtype=torch.long) print("\nTraining run:") records = train(model, train_tokens, val_tokens, cfg, prompt) print("\nFinal log records (last 3):") for record in records[-3:]: print(" ", record) print(f"\nWrote losses to {LOG_PATH}") first_loss = records[0]["train_loss"] last_loss = records[-1]["train_loss"] print(f"First step train_loss : {first_loss:.4f}") print(f"Last step train_loss : {last_loss:.4f}") assert last_loss < first_loss, "training loss should decrease across the demo run" print("Training loop check passed.") if __name__ == "__main__": demo()