# Knowledge: Transformer Components ## Purpose Canonical correct implementations of transformer building blocks that papers frequently reference but don't re-explain. When a paper says "standard transformer encoder," this file tells you what that means and what mistakes to avoid. --- ## Multi-Head Attention ### Canonical implementation ```python class MultiHeadAttention(nn.Module): def __init__(self, d_model: int, n_heads: int, dropout: float = 0.0, bias: bool = True): super().__init__() assert d_model % n_heads == 0, "d_model must be divisible by n_heads" self.d_model = d_model self.n_heads = n_heads self.d_k = d_model // n_heads # head dimension self.W_q = nn.Linear(d_model, d_model, bias=bias) self.W_k = nn.Linear(d_model, d_model, bias=bias) self.W_v = nn.Linear(d_model, d_model, bias=bias) self.W_o = nn.Linear(d_model, d_model, bias=bias) self.dropout = nn.Dropout(dropout) def forward(self, query: torch.Tensor, key: torch.Tensor, value: torch.Tensor, mask: Optional[torch.Tensor] = None) -> torch.Tensor: batch_size = query.size(0) # Project and reshape: (batch, seq, d_model) -> (batch, n_heads, seq, d_k) q = self.W_q(query).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) k = self.W_k(key).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) v = self.W_v(value).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2) # Scaled dot-product attention # (batch, n_heads, seq_q, d_k) @ (batch, n_heads, d_k, seq_k) -> (batch, n_heads, seq_q, seq_k) scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores = scores.masked_fill(mask == 0, float('-inf')) attn_weights = F.softmax(scores, dim=-1) # (batch, n_heads, seq_q, seq_k) attn_weights = self.dropout(attn_weights) # (batch, n_heads, seq_q, seq_k) @ (batch, n_heads, seq_k, d_k) -> (batch, n_heads, seq_q, d_k) context = torch.matmul(attn_weights, v) # Reshape back: (batch, n_heads, seq_q, d_k) -> (batch, seq_q, d_model) context = context.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) return self.W_o(context) # (batch, seq_q, d_model) ``` ### Common mistakes 1. **Scaling by sqrt(d_model) instead of sqrt(d_k)** - The scale factor is `sqrt(d_k)` where `d_k = d_model / n_heads` - NOT `sqrt(d_model)`. This is the single most common mistake. - Vaswani et al. §3.2.1: "We suspect that for large values of d_k, the dot products grow large in magnitude" 2. **Wrong mask convention** - Additive mask: add a large negative number (e.g., -1e9 or -inf) to scores BEFORE softmax - Multiplicative mask: multiply attention weights by 0/1 AFTER softmax - Both are valid, but papers rarely specify which. Additive is more common and numerically cleaner. - Using -inf is cleaner than -1e9 (avoids non-zero attention for -1e9 with float16) 3. **Forgetting `.contiguous()` after transpose** - After `transpose(1, 2)`, the tensor may not be contiguous - `.view()` requires a contiguous tensor - This will crash, not silently fail — but it's a common "why doesn't my code run" bug 4. **Causal mask shape** - Should be `(1, 1, seq_len, seq_len)` for broadcasting with `(batch, n_heads, seq_len, seq_len)` scores - Mask where `mask[i][j] = 1` if position `j` is allowed for position `i` - Upper triangular = disallowed, not lower triangular (common mistake) ### Causal masking ```python def create_causal_mask(seq_len: int, device: torch.device) -> torch.Tensor: """Creates a causal (autoregressive) attention mask. Returns a (1, 1, seq_len, seq_len) boolean tensor where True = attend, False = mask. """ mask = torch.tril(torch.ones(seq_len, seq_len, device=device, dtype=torch.bool)) return mask.unsqueeze(0).unsqueeze(0) # (1, 1, seq_len, seq_len) ``` --- ## Positional Encodings ### Sinusoidal (Vaswani et al., 2017) ```python class SinusoidalPositionalEncoding(nn.Module): """Fixed sinusoidal positional encoding from 'Attention Is All You Need'. PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model)) """ def __init__(self, d_model: int, max_len: int = 5000, dropout: float = 0.0): super().__init__() self.dropout = nn.Dropout(dropout) pe = torch.zeros(max_len, d_model) # (max_len, d_model) position = torch.arange(0, max_len).unsqueeze(1).float() # (max_len, 1) div_term = torch.exp( torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model) ) # (d_model/2,) pe[:, 0::2] = torch.sin(position * div_term) # even indices pe[:, 1::2] = torch.cos(position * div_term) # odd indices pe = pe.unsqueeze(0) # (1, max_len, d_model) self.register_buffer('pe', pe) def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (batch, seq_len, d_model) x = x + self.pe[:, :x.size(1)] return self.dropout(x) ``` ### Common mistakes with sinusoidal PE: - Using `arange(0, d_model)` instead of `arange(0, d_model, 2)` for div_term - Off-by-one in position indexing (should start at 0) - Forgetting to `register_buffer` (so it's not a parameter but moves with the model to GPU) ### Learned positional embeddings ```python class LearnedPositionalEmbedding(nn.Module): def __init__(self, max_len: int, d_model: int): super().__init__() self.embedding = nn.Embedding(max_len, d_model) def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (batch, seq_len, d_model) positions = torch.arange(x.size(1), device=x.device) # (seq_len,) return x + self.embedding(positions) # broadcast over batch ``` ### Rotary Position Embedding (RoPE) — Su et al., 2021 ```python class RotaryPositionalEmbedding(nn.Module): """RoPE: Enhanced Transformer with Rotary Position Embedding. Applied to each head individually within the attention computation, AFTER the Q and K projections but BEFORE the dot product. """ def __init__(self, d_head: int, max_len: int = 8192, base: float = 10000.0): super().__init__() inv_freq = 1.0 / (base ** (torch.arange(0, d_head, 2).float() / d_head)) self.register_buffer('inv_freq', inv_freq) self.max_len = max_len def forward(self, x: torch.Tensor, seq_len: int) -> Tuple[torch.Tensor, torch.Tensor]: t = torch.arange(seq_len, device=x.device).float() freqs = torch.outer(t, self.inv_freq) # (seq_len, d_head/2) emb = torch.cat([freqs, freqs], dim=-1) # (seq_len, d_head) return emb.cos(), emb.sin() def apply_rotary_emb(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor: """Apply RoPE to query or key tensor. x: (batch, n_heads, seq_len, d_head) """ d_half = x.shape[-1] // 2 x1, x2 = x[..., :d_half], x[..., d_half:] return torch.cat([ x1 * cos[..., :d_half] - x2 * sin[..., :d_half], x2 * cos[..., d_half:] + x1 * sin[..., d_half:] ], dim=-1) ``` ### Key difference: RoPE is applied to Q and K individually, NOT summed onto embeddings like sinusoidal PE. ### ALiBi (Press et al., 2022) ALiBi doesn't use positional embeddings at all. Instead, it adds a linear bias to the attention scores: ```python def get_alibi_slopes(n_heads: int) -> torch.Tensor: """Compute ALiBi slopes for each head. Head i gets slope 2^(-8i/n_heads) for i = 1, ..., n_heads """ ratio = 2 ** (-8 / n_heads) slopes = torch.tensor([ratio ** i for i in range(1, n_heads + 1)]) return slopes # (n_heads,) def apply_alibi(scores: torch.Tensor, slopes: torch.Tensor) -> torch.Tensor: """Apply ALiBi bias to attention scores. scores: (batch, n_heads, seq_q, seq_k) slopes: (n_heads,) """ seq_q, seq_k = scores.size(-2), scores.size(-1) # Position difference: relative distance between query and key positions positions = torch.arange(seq_k, device=scores.device).unsqueeze(0) - \ torch.arange(seq_q, device=scores.device).unsqueeze(1) # (seq_q, seq_k) bias = slopes.unsqueeze(-1).unsqueeze(-1) * positions.unsqueeze(0) # (n_heads, seq_q, seq_k) return scores + bias.unsqueeze(0) # broadcast over batch ``` --- ## Layer Normalization ### Pre-norm vs Post-norm — THIS MATTERS ENORMOUSLY **Post-norm (original Transformer):** ```python # Post-norm: normalize AFTER the residual addition x = self.norm(x + self.sublayer(x)) ``` **Pre-norm (GPT-2, most modern transformers):** ```python # Pre-norm: normalize BEFORE the sublayer, residual OUTSIDE the norm x = x + self.sublayer(self.norm(x)) ``` **Why it matters:** - Post-norm requires learning rate warmup and careful initialization - Pre-norm is much more stable to train at scale - They produce different quality models — not interchangeable - Many papers show post-norm in figures but use pre-norm in experiments — ALWAYS CHECK ### RMSNorm (Zhang & Sennrich, 2019) ```python class RMSNorm(nn.Module): """Root Mean Square Layer Normalization. Used in LLaMA, T5. Simpler than LayerNorm (no centering, no bias). """ def __init__(self, d_model: int, eps: float = 1e-6): super().__init__() self.weight = nn.Parameter(torch.ones(d_model)) self.eps = eps def forward(self, x: torch.Tensor) -> torch.Tensor: rms = torch.sqrt(torch.mean(x ** 2, dim=-1, keepdim=True) + self.eps) return x / rms * self.weight ``` --- ## Feed-Forward Network ### Standard (Vaswani et al.) ```python class FeedForward(nn.Module): """Two-layer feed-forward network with expansion factor. FFN(x) = W_2 * activation(W_1 * x + b_1) + b_2 """ def __init__(self, d_model: int, d_ff: int, dropout: float = 0.0, activation: str = "relu"): super().__init__() self.linear1 = nn.Linear(d_model, d_ff) self.linear2 = nn.Linear(d_ff, d_model) self.dropout = nn.Dropout(dropout) if activation == "relu": self.activation = nn.ReLU() elif activation == "gelu": self.activation = nn.GELU() elif activation == "silu": self.activation = nn.SiLU() else: raise ValueError(f"Unknown activation: {activation}") def forward(self, x: torch.Tensor) -> torch.Tensor: # (batch, seq, d_model) -> (batch, seq, d_ff) -> (batch, seq, d_model) return self.linear2(self.dropout(self.activation(self.linear1(x)))) ``` ### SwiGLU (Shazeer, 2020) — used in LLaMA, PaLM ```python class SwiGLU(nn.Module): """Gated feed-forward with SiLU activation. SwiGLU(x) = (SiLU(W_1 * x) ⊙ W_3 * x) * W_2 Note: uses 3 weight matrices, not 2. This changes parameter count. """ def __init__(self, d_model: int, d_ff: int): super().__init__() self.w1 = nn.Linear(d_model, d_ff, bias=False) self.w2 = nn.Linear(d_ff, d_model, bias=False) self.w3 = nn.Linear(d_model, d_ff, bias=False) def forward(self, x: torch.Tensor) -> torch.Tensor: return self.w2(F.silu(self.w1(x)) * self.w3(x)) ``` --- ## Embedding with Weight Tying ```python class TransformerEmbedding(nn.Module): """Token + positional embedding with optional weight tying to output projection. Weight tying (Press & Wolf, 2017): The embedding matrix and the output projection matrix are the SAME tensor. This reduces parameters and often improves performance. Many papers do this without mentioning it explicitly. """ def __init__(self, vocab_size: int, d_model: int, max_len: int, dropout: float = 0.0, scale: bool = True): super().__init__() self.token_emb = nn.Embedding(vocab_size, d_model) self.pos_emb = LearnedPositionalEmbedding(max_len, d_model) self.dropout = nn.Dropout(dropout) self.scale = math.sqrt(d_model) if scale else 1.0 # Vaswani et al. §3.4: "we multiply those weights by sqrt(d_model)" def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (batch, seq_len) of token IDs tok = self.token_emb(x) * self.scale # (batch, seq_len, d_model) return self.dropout(self.pos_emb(tok)) ``` **Weight tying note:** If the paper ties embedding and output weights, the output projection is `F.linear(x, model.embedding.token_emb.weight)` — not a separate `nn.Linear`. Many papers do this without stating it. Check the parameter count in the paper against your model — if yours is higher, weight tying might be missing. --- ## Complete Transformer Block ### Post-norm variant (original) ```python class TransformerBlockPostNorm(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout): super().__init__() self.attn = MultiHeadAttention(d_model, n_heads, dropout) self.ff = FeedForward(d_model, d_ff, dropout) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.dropout1 = nn.Dropout(dropout) self.dropout2 = nn.Dropout(dropout) def forward(self, x, mask=None): x = self.norm1(x + self.dropout1(self.attn(x, x, x, mask))) x = self.norm2(x + self.dropout2(self.ff(x))) return x ``` ### Pre-norm variant (modern standard) ```python class TransformerBlockPreNorm(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout): super().__init__() self.attn = MultiHeadAttention(d_model, n_heads, dropout) self.ff = FeedForward(d_model, d_ff, dropout) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.dropout1 = nn.Dropout(dropout) self.dropout2 = nn.Dropout(dropout) def forward(self, x, mask=None): x = x + self.dropout1(self.attn(self.norm1(x), self.norm1(x), self.norm1(x), mask)) x = x + self.dropout2(self.ff(self.norm2(x))) return x ``` **Key difference:** In pre-norm, LayerNorm is applied BEFORE each sublayer. The residual connection adds the UN-normalized input. This is more stable for training deep models.