| """ |
| GPT Language Model β Built Entirely from Scratch |
| ================================================= |
| This file contains the COMPLETE transformer architecture. |
| Nothing is imported from `transformers` β every layer is hand-written |
| so you understand exactly what's happening inside a GPT. |
| |
| Architecture overview (what happens when text goes through the model): |
| |
| Input Text |
| β |
| Token Embedding (words β vectors) |
| + |
| Position Embedding (position β vectors) |
| β |
| N Γ Transformer Blocks, each containing: |
| βββ Layer Norm |
| βββ Multi-Head Self-Attention (tokens attend to each other) |
| βββ Residual Connection |
| βββ Layer Norm |
| βββ Feed-Forward Network (MLP β process each token independently) |
| βββ Residual Connection |
| β |
| Final Layer Norm |
| β |
| Linear Projection β Vocabulary Logits |
| β |
| Softmax β Next Token Probabilities |
| |
| Note on positions: the diagram shows "position embedding" as a separate additive step; in **this** |
| implementation, absolute position is injected inside attention via **RoPE** on Q/K instead, |
| so `tok_emb` is only token IDs β vectors (no learned positional embedding table). |
| """ |
|
|
| |
| |
| import math |
| |
| import torch |
| |
| import torch.nn as nn |
| |
| import torch.nn.functional as F |
| |
| from dataclasses import dataclass |
|
|
|
|
| @dataclass |
| class GPTConfig: |
| """ |
| All hyperparameters for the model in one place. |
| |
| Typical shapes implied by these numbers: |
| - vocab_size V: token IDs live in [0, V-1]; lm_head maps embd β V logits per position. |
| - n_embd C: every token becomes a length-C vector everywhere inside the trunk. |
| - n_head H: attention splits C into H heads, each with head_dim = C/H (must divide evenly). |
| - n_layer: depth β how many TransformerBlocks are stacked. |
| - block_size T_max: max context length this model was laid out for (RoPE cache, masks, crop in generate). |
| """ |
| vocab_size: int = 32000 |
| n_embd: int = 768 |
| n_head: int = 12 |
| n_layer: int = 12 |
| block_size: int = 512 |
| dropout: float = 0.1 |
| bias: bool = False |
|
|
|
|
| class RMSNorm(nn.Module): |
| """ |
| Root Mean Square Layer Normalization (RMSNorm). |
| |
| Why RMSNorm instead of LayerNorm? |
| - RMSNorm is simpler and faster (no mean subtraction, no bias) |
| - Used in modern models like Llama, Mistral, etc. |
| - Achieves similar or better training stability |
| |
| Math: RMSNorm(x) = x / RMS(x) * Ξ³ |
| where RMS(x) = sqrt(mean(xΒ²) + Ξ΅) |
| |
| Tensor view: |
| - forward(x) with x.shape = (..., dim) keeps all leading axes; only the last axis is normalized. |
| - Output matches x.shape exactly; Ξ³ broadcasts on the last axis. |
| """ |
|
|
| def __init__(self, dim: int, eps: float = 1e-6): |
| |
| super().__init__() |
| |
| self.eps = eps |
| |
| |
| self.weight = nn.Parameter(torch.ones(dim)) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| """ |
| Normalize the last dimension of `x` to RMS 1, then apply Ξ³. |
| |
| Args: |
| x: tensor with last dim = `dim`, e.g. (B, T, dim). |
| |
| Returns: |
| Same shape as x. |
| """ |
| |
| |
| |
| |
| rms = torch.sqrt(torch.mean(x * x, dim=-1, keepdim=True) + self.eps) |
| |
| |
| return x / rms * self.weight |
|
|
|
|
| class RotaryPositionalEmbedding(nn.Module): |
| """ |
| Rotary Positional Embedding (RoPE). |
| |
| Why RoPE instead of learned positional embeddings? |
| - Encodes RELATIVE position, not absolute |
| - Generalizes better to sequence lengths not seen during training |
| - Used in Llama, Mistral, Qwen β the modern standard |
| - No learnable parameters (purely mathematical) |
| |
| How it works: |
| - Pairs up embedding dimensions: (d0, d1), (d2, d3), ... |
| - Rotates each pair by an angle proportional to position |
| - Nearby tokens get small rotations, far tokens get large rotations |
| - The dot product between two rotated vectors naturally encodes distance |
| |
| forward(x, seq_len) returns: |
| - cos, sin each shaped for broadcasting to q,k in attention, typically (1, 1, seq_len, head_dim). |
| """ |
|
|
| def __init__(self, dim: int, max_seq_len: int = 2048, base: float = 10000.0): |
| super().__init__() |
| |
| |
| |
| inv_freq = 1.0 / (base ** (torch.arange(0, dim, 2).float() / dim)) |
| |
| |
| self.register_buffer("inv_freq", inv_freq) |
|
|
| |
| t = torch.arange(max_seq_len).float() |
| |
| |
| freqs = torch.outer(t, inv_freq) |
| self.register_buffer("cos_cached", freqs.cos()) |
| self.register_buffer("sin_cached", freqs.sin()) |
|
|
| def forward(self, x: torch.Tensor, seq_len: int): |
| """ |
| Args: |
| x: unused in this minimal implementation (some codebases use dtype/device from x). |
| seq_len: current sequence length T (β€ cached max). |
| |
| Returns: |
| cos, sin broadcast tensors for apply_rotary_emb, shape (1, 1, T, head_dim) each. |
| """ |
| |
| |
| |
| cos = self.cos_cached[:seq_len].unsqueeze(0).unsqueeze(0) |
| sin = self.sin_cached[:seq_len].unsqueeze(0).unsqueeze(0) |
| |
| |
| |
| cos = torch.cat([cos, cos], dim=-1) |
| sin = torch.cat([sin, sin], dim=-1) |
| return cos, sin |
|
|
|
|
| def apply_rotary_emb(q: torch.Tensor, k: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor): |
| """ |
| Apply rotary embeddings to query and key tensors. |
| |
| Takes pairs of dimensions and rotates them: |
| [x0, x1] β [x0*cos - x1*sin, x0*sin + x1*cos] |
| |
| Inputs / shapes (typical): |
| - q, k: (B, H, T, head_dim) after projection and head split β RoPE only touches last axis. |
| - cos, sin: broadcastable to q,k β here (1, 1, T, head_dim) so same angles for all batch/heads. |
| Outputs: same shapes as q and k, with phase encoding of absolute position folded into q,k. |
| |
| WHY rotate q and k (not v)? Attention score is qΒ·k; rotating both by the same angle |
| preserves relative geometry we care about (dot products encode relative position via ΞΈ_i - ΞΈ_j). |
| """ |
| def rotate_half(x): |
| |
| |
| x1 = x[..., : x.shape[-1] // 2] |
| x2 = x[..., x.shape[-1] // 2 :] |
| return torch.cat((-x2, x1), dim=-1) |
|
|
| |
| q_rotated = (q * cos) + (rotate_half(q) * sin) |
| k_rotated = (k * cos) + (rotate_half(k) * sin) |
| return q_rotated, k_rotated |
|
|
|
|
| class CausalSelfAttention(nn.Module): |
| """ |
| Multi-Head Causal Self-Attention. |
| |
| This is the CORE mechanism of a transformer. Here's what it does: |
| |
| 1. Each token creates three vectors: Query (Q), Key (K), Value (V) |
| - Q = "what am I looking for?" |
| - K = "what do I contain?" |
| - V = "what information do I provide?" |
| |
| 2. Attention scores = Q @ K^T / sqrt(d_k) |
| - Each token's query is compared to all keys |
| - Divided by sqrt(d_k) to keep gradients stable |
| |
| 3. CAUSAL MASK: token at position i can only attend to positions β€ i |
| - This is what makes GPT "autoregressive" (left-to-right) |
| |
| 4. Output = softmax(scores) @ V |
| - Weighted combination of values |
| |
| Multi-head: we run this N times in parallel with different learned projections, |
| then concatenate. This lets the model attend to different types of relationships. |
| |
| Shape recipe (forward): |
| - Input x: (B, T, C) |
| - After QKV + split + reshape: Q,K,V each (B, H, T, d) with C = HΒ·d |
| - SDPA output: (B, H, T, d) β merge heads β (B, T, C) β out_proj β (B, T, C) |
| """ |
|
|
| def __init__(self, config: GPTConfig): |
| super().__init__() |
| |
| assert config.n_embd % config.n_head == 0, \ |
| f"n_embd ({config.n_embd}) must be divisible by n_head ({config.n_head})" |
|
|
| self.n_head = config.n_head |
| |
| self.head_dim = config.n_embd // config.n_head |
| self.n_embd = config.n_embd |
|
|
| |
| |
| |
| self.qkv_proj = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias) |
| |
| |
| self.out_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias) |
|
|
| |
| self.attn_dropout = nn.Dropout(config.dropout) |
| |
| self.resid_dropout = nn.Dropout(config.dropout) |
|
|
| |
| self.rope = RotaryPositionalEmbedding(self.head_dim, config.block_size) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| """ |
| Args: |
| x: (B, T, C) hidden states **before** this attention layer (already on residual stream). |
| |
| Returns: |
| (B, T, C) attention output **before** the residual add (dropout applied); block adds residual. |
| """ |
| B, T, C = x.size() |
| |
|
|
| |
| qkv = self.qkv_proj(x) |
| |
| q, k, v = qkv.split(self.n_embd, dim=2) |
| |
|
|
| |
| |
| |
| q = q.view(B, T, self.n_head, self.head_dim).transpose(1, 2) |
| k = k.view(B, T, self.n_head, self.head_dim).transpose(1, 2) |
| v = v.view(B, T, self.n_head, self.head_dim).transpose(1, 2) |
|
|
| |
| |
| cos, sin = self.rope(q, T) |
| q, k = apply_rotary_emb(q, k, cos, sin) |
| |
|
|
| |
| |
| |
| |
| |
| y = F.scaled_dot_product_attention( |
| q, k, v, |
| attn_mask=None, |
| dropout_p=self.attn_dropout.p if self.training else 0.0, |
| is_causal=True, |
| ) |
| |
|
|
| |
| y = y.transpose(1, 2).contiguous().view(B, T, C) |
| |
|
|
| |
| |
| return self.resid_dropout(self.out_proj(y)) |
|
|
|
|
| class FeedForward(nn.Module): |
| """ |
| Feed-Forward Network (MLP) with SwiGLU activation. |
| |
| After attention lets tokens "talk" to each other, the FFN processes |
| each token independently. Think of it as: |
| - Attention = inter-token reasoning (relationships between words) |
| - FFN = intra-token reasoning (processing within each word's representation) |
| |
| SwiGLU (used in Llama, Mistral, modern GPTs): |
| - Better than plain ReLU or GELU |
| - Uses a gating mechanism: output = Swish(xWβ) β (xWβ) |
| - The β is element-wise multiplication (gating) |
| - Expansion factor is typically 8/3 Γ n_embd (β 2.67Γ) |
| """ |
|
|
| def __init__(self, config: GPTConfig): |
| super().__init__() |
| |
| hidden_dim = int(8 / 3 * config.n_embd) |
| |
| |
| hidden_dim = 64 * ((hidden_dim + 63) // 64) |
|
|
| |
| self.w1 = nn.Linear(config.n_embd, hidden_dim, bias=config.bias) |
| |
| self.w2 = nn.Linear(hidden_dim, config.n_embd, bias=config.bias) |
| |
| self.w3 = nn.Linear(config.n_embd, hidden_dim, bias=config.bias) |
| self.dropout = nn.Dropout(config.dropout) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| """ |
| Args: |
| x: (B, T, C) residual-stream activations. |
| |
| Returns: |
| (B, T, C) FFN output to be added back in the residual. |
| """ |
| |
| |
| |
| |
| |
| |
| return self.dropout(self.w2(F.silu(self.w1(x)) * self.w3(x))) |
|
|
|
|
| class TransformerBlock(nn.Module): |
| """ |
| A single Transformer block. |
| |
| Structure (Pre-Norm architecture, used in all modern LLMs): |
| |
| x ββββββββββ |
| β β |
| RMSNorm β |
| β β |
| Attention β |
| β β |
| + βββββββββββ (residual connection) |
| β |
| βββββββββββ |
| β β |
| RMSNorm β |
| β β |
| FFN β |
| β β |
| + βββββββββ (residual connection) |
| |
| Residual connections are critical: |
| - They let gradients flow directly back through the network |
| - Without them, deep networks (12+ layers) would be nearly impossible to train |
| - They create "information highways" through the network |
| """ |
|
|
| def __init__(self, config: GPTConfig): |
| super().__init__() |
| |
| self.ln1 = RMSNorm(config.n_embd) |
| self.attn = CausalSelfAttention(config) |
| self.ln2 = RMSNorm(config.n_embd) |
| self.ffn = FeedForward(config) |
|
|
| def forward(self, x: torch.Tensor) -> torch.Tensor: |
| """ |
| Args: |
| x: (B, T, C) input to this block. |
| |
| Returns: |
| (B, T, C) after self-attention + FFN substeps, both with residuals. |
| """ |
| |
| |
| |
| |
| x = x + self.attn(self.ln1(x)) |
| |
| x = x + self.ffn(self.ln2(x)) |
| return x |
|
|
|
|
| class GPT(nn.Module): |
| """ |
| The complete GPT language model. |
| |
| Putting it all together: |
| 1. Token embeddings: each token ID β a learned vector |
| 2. Stack of N transformer blocks |
| 3. Final layer norm |
| 4. Linear head: project back to vocabulary size for next-token prediction |
| |
| Weight tying: the token embedding matrix is shared with the output projection. |
| This means the model uses the same "understanding" of words for both |
| input and output. Saves parameters and often improves performance. |
| |
| WHY weight tying saves params: normally you'd have |
| - Embedding E: (V, C) for token β vector |
| - Separate head W: (V, C) for vector β logits |
| Tying reuses the **same** (V, C) matrix for both roles β exactly VΓC fewer weights |
| and a useful inductive bias: input and output token live in the same space. |
| """ |
|
|
| def __init__(self, config: GPTConfig): |
| super().__init__() |
| self.config = config |
|
|
| |
| self.transformer = nn.ModuleDict({ |
| |
| "tok_emb": nn.Embedding(config.vocab_size, config.n_embd), |
| "drop": nn.Dropout(config.dropout), |
| "blocks": nn.ModuleList([TransformerBlock(config) for _ in range(config.n_layer)]), |
| "ln_f": RMSNorm(config.n_embd), |
| }) |
| |
| |
| self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False) |
|
|
| |
| |
| |
| self.transformer["tok_emb"].weight = self.lm_head.weight |
|
|
| |
| |
| self.apply(self._init_weights) |
| |
| |
| for name, p in self.named_parameters(): |
| if name.endswith("out_proj.weight") or name.endswith("w2.weight"): |
| torch.nn.init.normal_(p, mean=0.0, std=0.02 / math.sqrt(2 * config.n_layer)) |
|
|
| def _init_weights(self, module: nn.Module): |
| """ |
| Weight initialization matters enormously for training stability. |
| - Linear layers: normal distribution with std=0.02 |
| - Embeddings: same normal distribution |
| - Biases: zero |
| """ |
| 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 count_parameters(self) -> int: |
| """Count total trainable parameters.""" |
| |
| return sum(p.numel() for p in self.parameters() if p.requires_grad) |
|
|
| def forward(self, idx: torch.Tensor, targets: torch.Tensor = None): |
| """ |
| Forward pass. |
| |
| Args: |
| idx: token indices, shape (batch_size, sequence_length) |
| targets: target token indices for computing loss (shifted by 1) |
| |
| Returns: |
| logits: next-token predictions, shape (batch_size, sequence_length, vocab_size) |
| Each position t predicts the token at t+1 when trained with shifted targets. |
| loss: cross-entropy loss if targets provided, else None |
| """ |
| _, T = idx.size() |
| |
| assert T <= self.config.block_size, \ |
| f"Sequence length {T} exceeds block_size {self.config.block_size}" |
|
|
| |
| x = self.transformer["tok_emb"](idx) |
| |
| x = self.transformer["drop"](x) |
| |
|
|
| |
| for block in self.transformer["blocks"]: |
| x = block(x) |
| |
|
|
| |
| x = self.transformer["ln_f"](x) |
| |
|
|
| |
| logits = self.lm_head(x) |
| |
|
|
| |
| loss = None |
| if targets is not None: |
| |
| |
| loss = F.cross_entropy( |
| logits.view(-1, logits.size(-1)), |
| targets.view(-1), |
| ignore_index=-1, |
| ) |
| |
|
|
| return logits, loss |
|
|
| @torch.no_grad() |
| def generate(self, idx: torch.Tensor, max_new_tokens: int, |
| temperature: float = 0.8, top_k: int = 50) -> torch.Tensor: |
| """ |
| Autoregressive text generation. |
| |
| How generation works: |
| 1. Feed the prompt through the model |
| 2. Get probabilities for the next token |
| 3. Sample from the distribution (with temperature and top-k) |
| 4. Append the sampled token |
| 5. Repeat |
| |
| Temperature controls randomness: |
| - temp < 1.0: more deterministic (sharper distribution) |
| - temp = 1.0: standard sampling |
| - temp > 1.0: more random (flatter distribution) |
| |
| Top-k sampling: only consider the k most likely tokens. |
| Prevents the model from choosing extremely unlikely tokens. |
| |
| Args: |
| idx: starting token IDs, shape (batch, current_length) β grows by 1 each iteration. |
| max_new_tokens: how many sampling steps to run. |
| temperature: softmax temperature (scalar) applied to last-position logits. |
| top_k: if set, restrict sampling mass to the k largest logits (per batch row). |
| |
| Returns: |
| idx: shape (batch, current_length + max_new_tokens) β prompt with new tokens appended. |
| """ |
| self.eval() |
| |
| for _ in range(max_new_tokens): |
| |
| |
| idx_cond = idx if idx.size(1) <= self.config.block_size else idx[:, -self.config.block_size:] |
|
|
| logits, _ = self(idx_cond) |
| |
| logits = logits[:, -1, :] |
| |
| |
| logits = logits / temperature |
|
|
| if top_k is not None: |
| |
| v, _ = torch.topk(logits, min(top_k, logits.size(-1))) |
| |
| logits[logits < v[:, [-1]]] = float("-inf") |
|
|
| probs = F.softmax(logits, dim=-1) |
| |
| idx_next = torch.multinomial(probs, num_samples=1) |
| |
| idx = torch.cat([idx, idx_next], dim=1) |
| |
|
|
| return idx |
|
|