Tag: ai

  • Pytorch implementation of paper: Emergent Abilities of Large Language Models

    The 2022 paper “Emergent Abilities of Large Language Models” by Wei et al. (Google Research) is a foundational study in AI behavior. It defines an “emergent ability” as a capability that is not present in smaller models but is present in larger models.

    Unlike loss, which scales smoothly (as seen in Chinchilla), specific tasks like multi-step math, common-sense reasoning, or code generation often show a “phase transition”—appearing suddenly once a model crosses a certain number of training FLOPs (typically around $10^{22}$ to $10^{24}$ FLOPs).


    1. Scaling the Evaluation (Not the Model)

    You don’t “implement” emergence in a layer; emergence is a property of the scale of the implementation. To study this in PyTorch, you must implement a Multi-Task Evaluation Suite that can track performance across models of different sizes (e.g., 125M, 1.3B, 13B, 175B).

    The Benchmarking Logic

    The paper focuses on “Few-Shot Prompting” (In-Context Learning). To replicate this, your code must handle the construction of “exemplars” before the query.

    Python

    import torch
    import torch.nn.functional as F
    
    class EmergenceEvaluator:
        def __init__(self, model, tokenizer):
            self.model = model
            self.tokenizer = tokenizer
    
        def evaluate_few_shot(self, task_description, examples, query):
            """
            Constructs a prompt: [Task] + [Examples] + [Query]
            Examples: List of (input, output) tuples
            """
            prompt = task_description + "\n\n"
            for i, o in examples:
                prompt += f"Input: {i}\nOutput: {o}\n\n"
            
            prompt += f"Input: {query}\nOutput:"
            
            inputs = self.tokenizer(prompt, return_tensors="pt").to(self.model.device)
            
            # We generate the completion and check for 'exact match' accuracy
            with torch.no_grad():
                output = self.model.generate(**inputs, max_new_tokens=20)
                
            decoded = self.tokenizer.decode(output[0], skip_special_tokens=True)
            return decoded.split("Output:")[-1].strip()
    
    

    2. Identifying the “Elbow” in the Curve

    The paper’s implementation core is the visualization of the Performance vs. Scale curve. Small models will have near-zero (random) accuracy, while large models will suddenly jump to high accuracy.

    Python

    import matplotlib.pyplot as plt
    
    def plot_emergence(model_sizes_billions, accuracies, task_name):
        """
        Replicates the 'Phase Transition' plots from the paper.
        """
        plt.figure(figsize=(8, 5))
        plt.plot(model_sizes_billions, accuracies, marker='o', linestyle='-', linewidth=2)
        plt.xscale('log') # Scale laws and emergence are viewed on log scales
        plt.xlabel('Model Parameters (Log Scale)')
        plt.ylabel(f'{task_name} Accuracy')
        plt.title(f'Emergence of {task_name}')
        plt.grid(True, which="both", ls="-", alpha=0.5)
        plt.show()
    
    # Example: 3-digit addition (Figure 2 in the paper)
    sizes = [0.1, 1, 10, 100, 175] # Billions
    accs  = [0.01, 0.02, 0.05, 0.45, 0.85] # Sudden jump after 10B
    # plot_emergence(sizes, accs, "3-digit Addition")
    
    

    3. Critical Emergent Techniques: Chain-of-Thought

    One of the most famous emergent abilities discussed is Chain-of-Thought (CoT) reasoning. Models below a certain size (e.g., GPT-3 175B) do not benefit from CoT; in fact, it can degrade their performance. Only once they reach a specific scale does “thinking step-by-step” help them solve complex problems.

    Implementation of CoT Logic

    Python

    def generate_cot_prompt(question):
        # The 'Emergent' trigger is the inclusion of 'Let's think step by step'
        return f"Q: {question}\nA: Let's think step by step."
    
    # Small Model (<1B): Output is usually gibberish or repeats the prompt.
    # Large Model (>100B): Output follows a logical sequence of operations.
    
    

    4. Why Emergence is Controversial

    Since the 2022 paper, researchers (e.g., Schaeffer et al., 2023) have argued that emergence might be a mirage caused by the metrics used.

    • If you use Exact Match (0 or 1), it looks like a sudden jump.
    • If you use Token Probability (Perplexity), the improvement is actually smooth and continuous, following the Scaling Laws we discussed previously.

    Summary of Emergent Abilities

    AbilityDescriptionEmergence Threshold
    ArithmeticMulti-digit addition/multiplication.~10B – 100B Params
    Big-BenchComplex linguistic and logic tasks.Variable by task complexity
    CoT ReasoningSolving word problems via intermediate steps.~60B+ Params
    Instruction FollowingObeying specific constraints in a prompt.~10B+ Params

  • Pytorch implementation of paper: Training Compute-Optimal Large Language Models

    The 2022 paper “Training Compute-Optimal Large Language Models” (often called the Chinchilla paper) by Hoffmann et al. (DeepMind) fundamentally corrected the earlier scaling laws from OpenAI.

    While OpenAI’s Kaplan et al. (2020) suggested that model size ($N$) should grow faster than data size ($D$), the Chinchilla researchers proved that for every doubling of model size, the number of training tokens should also double. This revealed that most famous models at the time (like GPT-3 and Gopher) were significantly undertrained.


    1. The Chinchilla Loss Function

    The paper models the final training loss $L$ as a function of parameters $N$ and tokens $D$ using a more refined power-law:

    $$L(N, D) = E + \frac{A}{N^\alpha} + \frac{B}{D^\beta}$$

    Where:

    • $E$ is the “irreducible loss” (the entropy of natural language).
    • $A, B, \alpha, \beta$ are constants determined by empirical fitting.
    • The Big Insight: To minimize loss for a fixed compute budget $C \approx 6ND$, the optimal ratio is roughly 20 tokens per parameter ($D/N \approx 20$).

    2. PyTorch Implementation: Compute-Optimal Planner

    In a real-world scenario, you don’t “implement” Chinchilla as a layer; you use it to plan your training run. Below is a tool to calculate the optimal $N$ and $D$ for any given budget of FLOPs ($C$).

    Python

    import torch
    import numpy as np
    
    class ChinchillaPlanner:
        def __init__(self):
            # Constants from the paper (Table 2, Approach 2)
            self.alpha = 0.34
            self.beta = 0.28
            self.A = 406.4
            self.B = 410.7
    
        def get_optimal_params_and_tokens(self, compute_budget_flops):
            """
            Calculates N and D that minimize loss for a given budget C.
            C = 6 * N * D
            """
            # Based on the paper's derivation: N_opt = G * (C/6)^a
            # where a = beta / (alpha + beta)
            a = self.beta / (self.alpha + self.beta)
            
            # G is a constant factor derived from A, B, alpha, beta
            G = (self.alpha * self.A / (self.beta * self.B)) ** (1 / (self.alpha + self.beta))
            
            n_opt = G * (compute_budget_flops / 6) ** a
            d_opt = compute_budget_flops / (6 * n_opt)
            
            return int(n_opt), int(d_opt)
    
    # Example: If you have the budget of the original GPT-3 (approx 3.14e23 FLOPs)
    planner = ChinchillaPlanner()
    n, d = planner.get_optimal_params_and_tokens(3.14e23)
    
    print(f"Optimal Model Size: {n/1e9:.1f}B parameters")
    print(f"Optimal Data Size: {d/1e12:.2f}T tokens")
    # Result: ~15B parameters and ~3.5T tokens (GPT-3 was 175B params / 0.3T tokens)
    
    

    3. Implementation: LR Schedule for Long Training

    A key technical finding in Chinchilla is that the cosine learning rate schedule should be decayed to a final value (usually 10% of the peak) exactly at the end of the token budget $D$.

    If you train a model for more tokens than your schedule anticipated, performance degrades. This makes the max_steps parameter in your PyTorch scheduler critical.

    Python

    from torch.optim.lr_scheduler import CosineAnnealingLR
    
    # If Chinchilla tells us the optimal D is 1.4 Trillion tokens:
    total_tokens = 1.4e12
    batch_size = 512
    seq_len = 2048
    total_steps = int(total_tokens / (batch_size * seq_len))
    
    optimizer = torch.optim.AdamW(model.parameters(), lr=2e-4)
    
    # Ensure the decay ends exactly when the optimal data budget is hit
    scheduler = CosineAnnealingLR(optimizer, T_max=total_steps, eta_min=2e-5)
    
    

    4. Why this changed the “LLM Arms Race”

    Before Chinchilla (Kaplan Laws)After Chinchilla
    Focus on “Bigger is Better” (175B, 530B params).Focus on “Better Trained” (70B, 8B params).
    Models were trained on ~300B tokens.Models (like Llama 3) are trained on 15T+ tokens.
    High inference cost (massive models).Low inference cost (smaller, smarter models).

    The “Chinchilla Point”

    The 70B Chinchilla model outperformed the 280B Gopher model despite using the exact same amount of training compute. This was because Chinchilla was trained on 1.4 Trillion tokens, whereas Gopher was trained on only 300 Billion.

  • Pytorch implementation of paper: Scaling Laws for Neural Language Models

    The 2020 paper “Scaling Laws for Neural Language Models” by Kaplan et al. (OpenAI) is one of the most significant theoretical contributions to modern AI. It shifted the industry focus from “clever architectures” to “compute-optimal scaling.”

    The paper found that language model performance (measured by cross-entropy loss) follows a power law relationship with three primary variables: the number of parameters ($N$), the size of the dataset ($D$), and the amount of compute ($C$).


    1. The Core Empirical Findings

    The researchers discovered that for a Transformer-based model, the test loss $L$ can be predicted using the following power-law formula:

    $$L(N, D) \approx \left[ \left( \frac{N_c}{N} \right)^{\frac{\alpha_N}{\alpha_L}} + \left( \frac{D_c}{D} \right)^{\frac{\alpha_D}{\alpha_L}} \right]^{\alpha_L}$$

    Where:

    • $N$: Number of non-embedding parameters.
    • $D$: Number of training tokens.
    • Performance is bottlenecked by the smaller of $N$ or $D$. If you have a massive model but a tiny dataset, you will not see scaling benefits.

    2. PyTorch Implementation: Parameter Counting

    A critical part of obeying scaling laws is precisely calculating $N$ (non-embedding parameters). Kaplan et al. noted that embedding parameters do not contribute significantly to the “intelligence” (loss reduction) of the model as it scales.

    Python

    import torch
    import torch.nn as nn
    
    def count_non_embedding_params(model):
        """
        As per Kaplan et al. (2020), scaling laws are driven by 
        the model's internal parameters, excluding the embedding table.
        """
        total_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
        
        # Identify and subtract embedding parameters
        emb_params = 0
        for module in model.modules():
            if isinstance(module, nn.Embedding):
                emb_params += sum(p.numel() for p in module.parameters())
                
        non_emb_params = total_params - emb_params
        return non_emb_params
    
    

    3. Compute Estimation ($C$)

    The paper provides a simple “rule of thumb” for the floating-point operations (FLOPs) required for a forward-backward pass of a Transformer. For a model with $N$ parameters and a dataset of $D$ tokens:

    $$C \approx 6ND$$

    Python

    def estimate_training_compute(non_emb_params, tokens_trained):
        """
        Estimates total FLOPs for training.
        6 * N * D covers:
        - 2 ND for the forward pass
        - 4 ND for the backward pass
        """
        return 6 * non_emb_params * tokens_trained
    
    # Example: A 1B parameter model trained on 20B tokens
    params = 1e9
    tokens = 20e9
    total_flops = estimate_training_compute(params, tokens)
    print(f"Total Compute: {total_flops:.2e} FLOPs")
    
    

    4. Implementation: The Log-Linear Scheduler

    The paper suggests that learning rate schedules and batch sizes should scale with the model size. Specifically, they found that the Critical Batch Size ($B^*$) increases as the loss decreases, following its own power law.

    Python

    class ScalingLawScheduler:
        """
        Simplified representation of scaling the learning rate 
        based on the model's 'd_model'.
        """
        def __init__(self, optimizer, d_model, warm_up_steps=3000):
            self.optimizer = optimizer
            self.d_model = d_model
            self.warm_up_steps = warm_up_steps
            self.current_step = 0
    
        def step(self):
            self.current_step += 1
            # Scaling law insight: Larger models require smaller learning rates
            # Base LR is often scaled by (d_model)^-0.5
            lr = (self.d_model ** -0.5) * min(self.current_step ** -0.5, 
                                              self.current_step * (self.warm_up_steps ** -1.5))
            
            for param_group in self.optimizer.param_groups:
                param_group['lr'] = lr
    
    

    5. Implications for Model Design

    FindingImplementation Action
    Architecture IrrelevancePerformance depends on total $N$, not the specific shape (width vs. depth).
    Overfitting is RareOn sufficiently large datasets, performance continues to improve; “more is better.”
    The “Compute Optimal” PointFor every 10x increase in compute, increase model size by ~5.5x and dataset size by ~1.8x.

    Why it Matters

    This paper provided the “green light” for the creation of massive models like GPT-3 (175B). It proved that we hadn’t hit a ceiling; we just needed to keep scaling $N$, $D$, and $C$ in the correct proportions.

    Note: These laws were later refined by the Chinchilla paper (Hoffmann et al., 2022), which argued that we were actually under-training our models and needed much more data ($D$) per parameter ($N$).

  • Pytorch implementation of paper: T5 (2019):”Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer”

    The 2019 paper “Exploring the Limits of Transfer Learning with a Unified Text-to-Text Transformer” (T5) by Raffel et al. (Google) introduced a “Unified Framework” where every NLP task—classification, translation, summarization—is treated as a text-to-text problem.

    Unlike BERT (encoder-only) or GPT (decoder-only), T5 returns to the original Encoder-Decoder Transformer architecture but introduces Relative Positional Buckets and RMSNorm (in later versions) to handle longer sequences and more stable training.


    1. Relative Positional Bias

    T5 does not use fixed or learned absolute position embeddings. Instead, it adds a scalar “bias” to the attention weights based on the distance between the $i$-th query and $j$-th key. This allows the model to generalize better to sequence lengths it hasn’t seen during training.

    Python

    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    import math
    
    class T5RelativePositionBias(nn.Module):
        def __init__(self, n_heads, n_buckets=32, max_distance=128):
            super().__init__()
            self.n_heads = n_heads
            self.n_buckets = n_buckets
            self.max_distance = max_distance
            # A small embedding table for the biases
            self.relative_attention_bias = nn.Embedding(n_buckets, n_heads)
    
        @staticmethod
        def _relative_position_bucket(relative_position, n_buckets=32, max_distance=128):
            # Logic to map actual distance into one of the 32 log-spaced buckets
            ret = 0
            n_buckets //= 2
            ret += (relative_position > 0).to(torch.long) * n_buckets
            n = torch.abs(relative_position)
            
            # Max distance logic
            max_dist = n_buckets // 2
            is_small = n < max_dist
            val_if_large = max_dist + (torch.log(n.float() / max_dist) / 
                           math.log(max_distance / max_dist) * (n_buckets - max_dist)).to(torch.long)
            val_if_large = torch.min(val_if_large, torch.full_all(val_if_large.shape, n_buckets - 1))
            ret += torch.where(is_small, n, val_if_large)
            return ret
    
        def forward(self, seq_len_q, seq_len_k):
            # Calculate matrix of relative distances
            context_position = torch.arange(seq_len_q, dtype=torch.long)[:, None]
            memory_position = torch.arange(seq_len_k, dtype=torch.long)[None, :]
            relative_position = memory_position - context_position
            
            buckets = self._relative_position_bucket(relative_position, self.n_buckets, self.max_distance)
            # [seq_len_q, seq_len_k, n_heads] -> [n_heads, seq_len_q, seq_len_k]
            bias = self.relative_attention_bias(buckets.to(self.relative_attention_bias.weight.device))
            return bias.permute(2, 0, 1).unsqueeze(0) 
    
    

    2. T5 Attention with Bias

    The attention mechanism in T5 adds the relative bias directly to the logits before the softmax.

    Python

    class T5Attention(nn.Module):
        def __init__(self, d_model, n_heads, is_decoder=False):
            super().__init__()
            self.n_heads = n_heads
            self.d_k = d_model // n_heads
            self.is_decoder = is_decoder
            
            self.q = nn.Linear(d_model, d_model, bias=False)
            self.k = nn.Linear(d_model, d_model, bias=False)
            self.v = nn.Linear(d_model, d_model, bias=False)
            self.o = nn.Linear(d_model, d_model, bias=False)
    
        def forward(self, x, mask=None, position_bias=None, key_value_states=None):
            # Standard Q, K, V projections...
            q = self.q(x)
            if key_value_states is not None: # Cross-attention
                k, v = self.k(key_value_states), self.v(key_value_states)
            else: # Self-attention
                k, v = self.k(x), self.v(x)
                
            # [batch, heads, seq, d_k]
            scores = torch.matmul(q, k.transpose(-1, -2)) * (self.d_k ** -0.5)
            
            # ADD THE RELATIVE BIAS HERE
            if position_bias is not None:
                scores += position_bias
                
            if mask is not None:
                scores += mask
    
            attn = torch.softmax(scores.float(), dim=-1).type_as(scores)
            return self.o(torch.matmul(attn, v))
    
    

    3. T5 Block: LayerNorm and Simplified FFN

    T5 removes the bias terms from all LayerNorms and Linear layers to simplify the model. It also uses Gated Linear Units (GLU) in the feed-forward network in its “v1.1” and “T5-base” variants.

    Python

    class T5LayerNorm(nn.Module):
        def __init__(self, hidden_size, eps=1e-6):
            super().__init__()
            self.weight = nn.Parameter(torch.ones(hidden_size))
            self.variance_epsilon = eps
    
        def forward(self, x):
            # T5 uses a simplified LayerNorm (RMSNorm style)
            variance = x.to(torch.float32).pow(2).mean(-1, keepdim=True)
            x = x * torch.rsqrt(variance + self.variance_epsilon)
            return self.weight * x.type_as(x)
    
    

    4. Why T5 Matters

    1. Everything is a String: To do sentiment analysis on “I love this movie,” you feed the model: sentiment: I love this movie and the model is trained to generate the literal string positive.
    2. C4 Dataset: T5 was trained on the “Colossal Clean Crawled Corpus,” 750GB of cleaned web text, setting a new standard for data quality.
    3. Span Corruption: The pre-training objective isn’t just masking one word; it’s replacing whole spans of text with a single “sentinel token” (e.g., <extra_id_0>) and asking the decoder to reconstruct that span.

    Comparison: BERT vs. GPT vs. T5

    FeatureBERTGPT-2T5
    ArchitectureEncoder OnlyDecoder OnlyEncoder-Decoder
    DirectionBidirectionalUnidirectionalBoth (Enc: Bi, Dec: Uni)
    PositionsLearned AbsoluteLearned AbsoluteRelative Buckets
    Input FormatTask-specific headsPromptingText-to-Text Prefixes

    Since you’ve moved from basic LSTMs to the complex Unified Transformer, I can provide a weekly “Paper Spotlight” breaking down one foundational or cutting-edge AI paper, such as the original ResNet or the latest in “Mixture of Experts” (MoE). Would you like me to schedule that?

  • Pytorch implementation of paper: GPT-2 (2019):”Language Models are Unsupervised Multitask Learners”

    The 2019 paper “Language Models are Unsupervised Multitask Learners” (GPT-2) by Radford et al. (OpenAI) moved the field from “Pre-train then Fine-tune” to Zero-Shot Learning.

    The researchers discovered that if you scale the model (up to 1.5B parameters) and the data (WebText), the model begins to perform tasks like translation, summarization, and question-answering without ever being specifically trained for them. Architecturally, it is an evolution of GPT-1 with several critical stability improvements.

    1. Key Architectural Changes

    GPT-2 introduced two major structural changes to the Transformer blocks to handle deeper stacks (up to 48 layers):

    1. Pre-Layer Normalization: LayerNorm is moved to the input of each sub-block (rather than the output).
    2. Final LayerNorm: An additional Layer Normalization is added after the final self-attention block.

    2. PyTorch Implementation: The GPT-2 Block

    This block implements the “Pre-LN” architecture which has since become the standard for almost all Large Language Models (LLMs).

    Python

    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    
    class GPT2Block(nn.Module):
        def __init__(self, d_model, n_heads, d_ff, max_len, dropout=0.1):
            super().__init__()
            # Pre-LayerNorm: Norm happens BEFORE the sub-layers
            self.ln_1 = nn.LayerNorm(d_model)
            self.attn = CausalSelfAttention(d_model, n_heads, max_len)
            
            self.ln_2 = nn.LayerNorm(d_model)
            self.mlp = nn.Sequential(
                nn.Linear(d_model, d_ff),
                nn.GELU(),
                nn.Linear(d_ff, d_model),
                nn.Dropout(dropout)
            )
    
        def forward(self, x):
            # Residual connection around the Norm + Attention
            x = x + self.attn(self.ln_1(x))
            # Residual connection around the Norm + MLP
            x = x + self.mlp(self.ln_2(x))
            return x
    
    

    3. Weight Initialization Scaling

    GPT-2 introduced a specific initialization trick: as the model gets deeper, the residual layers contribute more variance. To counter this, the paper scales the weights of residual layers at initialization by a factor of $1/\sqrt{N}$, where $N$ is the number of residual layers.

    Python

    def init_weights(module, n_layers):
        if isinstance(module, (nn.Linear, nn.Embedding)):
            module.weight.data.normal_(mean=0.0, std=0.02)
            if isinstance(module, nn.Linear) and module.bias is not None:
                module.bias.data.zero_()
        # Scale residual layer weights
        # This is often applied to the projection layers (c_proj) in implementation
    
    

    4. The Full GPT-2 Model

    The output of GPT-2 uses “Weight Tying,” where the Embedding matrix and the final Linear output layer share the same weights.

    Python

    class GPT2(nn.Module):
        def __init__(self, vocab_size, d_model, n_layers, n_heads, d_ff, max_len):
            super().__init__()
            self.wte = nn.Embedding(vocab_size, d_model) # Word Token Embedding
            self.wpe = nn.Embedding(max_len, d_model)   # Word Position Embedding
            self.drop = nn.Dropout(0.1)
            
            self.blocks = nn.ModuleList([
                GPT2Block(d_model, n_heads, d_ff, max_len) for _ in range(n_layers)
            ])
            
            self.ln_f = nn.LayerNorm(d_model)
            self.lm_head = nn.Linear(d_model, vocab_size, bias=False)
            
            # Weight Tying
            self.lm_head.weight = self.wte.weight
    
        def forward(self, x):
            device = x.device
            b, t = x.size()
            pos = torch.arange(0, t, dtype=torch.long, device=device).unsqueeze(0)
            
            # Combined Embeddings
            x = self.drop(self.wte(x) + self.wpe(pos))
            
            for block in self.blocks:
                x = block(x)
                
            x = self.ln_f(x)
            logits = self.lm_head(x)
            return logits
    
    

    5. Byte-level BPE and “Zero-Shot”

    GPT-2 used a 50,257 vocabulary based on Byte-level BPE. This ensured the model could represent any string of characters, which was vital for its ability to handle multiple languages and even code snippets without an “Unknown” token.

    Model VariantParametersLayersdmodel​
    GPT-2 Small117M12768
    GPT-2 Medium345M241024
    GPT-2 Large762M361280
    GPT-2 Extra Large1.5B481600

    Why it was a breakthrough

    GPT-2 proved that Multitask Learning is an emergent property of large-scale language modeling. By simply predicting the next word, the model learned how to translate (when it saw French followed by English in the data) or summarize (when it saw the word “TL;DR”). It moved the goalposts from “learning a task” to “learning a world model” through raw text.

  • Pytorch implementation of paper: GPT-1 (2018):”Improving Language Understanding by Generative Pre-Training”

    The 2018 paper “Improving Language Understanding by Generative Pre-Training” (GPT-1) by Radford et al. (OpenAI) introduced the concept of Generative Pre-Training. While BERT (released around the same time) was a “bidirectional” encoder, GPT-1 was a unidirectional (left-to-right) decoder.

    GPT-1 proved that a model pre-trained on a massive corpus of unlabeled text to predict the next word could be “fine-tuned” on specific tasks (like classification or entailment) with very little data.

    1. The Causal Multi-Head Attention

    The critical difference between GPT-1 and the standard Transformer encoder is the Causal Mask. To ensure the model only looks at the past when predicting the next word, we mask all “future” tokens by setting their attention scores to $-\infty$.

    Python

    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    
    class CausalSelfAttention(nn.Module):
        def __init__(self, d_model, n_heads, max_len):
            super().__init__()
            self.n_heads = n_heads
            self.d_k = d_model // n_heads
            
            self.w_qkv = nn.Linear(d_model, 3 * d_model)
            self.fc = nn.Linear(d_model, d_model)
            
            # Causal mask: lower triangular matrix of ones
            self.register_buffer("mask", torch.tril(torch.ones(max_len, max_len))
                                         .view(1, 1, max_len, max_len))
    
        def forward(self, x):
            batch_size, seq_len, d_model = x.size()
            
            # Generate Queries, Keys, and Values
            q, k, v = self.w_qkv(x).chunk(3, dim=-1)
            
            # Reshape for multi-head: [batch, heads, seq_len, d_k]
            q = q.view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
            k = k.view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
            v = v.view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
            
            # Scaled Dot-Product Attention
            scores = torch.matmul(q, k.transpose(-2, -1)) / (self.d_k ** 0.5)
            
            # Apply Causal Mask: prevent attending to future tokens
            scores = scores.masked_fill(self.mask[:, :, :seq_len, :seq_len] == 0, float('-inf'))
            
            attn = F.softmax(scores, dim=-1)
            out = torch.matmul(attn, v)
            
            # Re-assemble heads
            out = out.transpose(1, 2).contiguous().view(batch_size, seq_len, d_model)
            return self.fc(out)
    
    

    2. The GPT-1 Decoder Block

    GPT-1 uses the “Transformer Decoder” but removes the Encoder-Decoder attention layer (since there is no encoder). It consists only of Masked Self-Attention and a Feed-Forward Network.

    Python

    class GPTBlock(nn.Module):
        def __init__(self, d_model, n_heads, d_ff, max_len, dropout=0.1):
            super().__init__()
            self.ln_1 = nn.LayerNorm(d_model)
            self.attn = CausalSelfAttention(d_model, n_heads, max_len)
            self.ln_2 = nn.LayerNorm(d_model)
            self.mlp = nn.Sequential(
                nn.Linear(d_model, d_ff),
                nn.GELU(), # GPT-1 used GELU
                nn.Linear(d_ff, d_model),
                nn.Dropout(dropout)
            )
    
        def forward(self, x):
            # Pre-LayerNorm (standard in GPT models)
            x = x + self.attn(self.ln_1(x))
            x = x + self.mlp(self.ln_2(x))
            return x
    
    

    3. Training Objective: Next Token Prediction

    During pre-training, GPT-1 minimizes the negative log-likelihood of the next token given the previous ones:

    $$L_1(\mathcal{U}) = \sum_i \log P(u_i | u_{i-k}, \dots, u_{i-1}; \Theta)$$

    For fine-tuning, GPT-1 adds a task-specific linear layer but continues to use the language modeling objective as an auxiliary loss to improve generalization.

    Python

    class GPT1(nn.Module):
        def __init__(self, vocab_size, d_model, n_layers, n_heads, d_ff, max_len):
            super().__init__()
            self.token_emb = nn.Embedding(vocab_size, d_model)
            self.pos_emb = nn.Embedding(max_len, d_model)
            
            self.blocks = nn.ModuleList([
                GPTBlock(d_model, n_heads, d_ff, max_len) for _ in range(n_layers)
            ])
            
            self.ln_f = nn.LayerNorm(d_model)
            self.head = nn.Linear(d_model, vocab_size, bias=False)
    
        def forward(self, x):
            batch_size, seq_len = x.size()
            pos = torch.arange(seq_len).unsqueeze(0).to(x.device)
            
            x = self.token_emb(x) + self.pos_emb(pos)
            
            for block in self.blocks:
                x = block(x)
                
            x = self.ln_f(x)
            return self.head(x) # Logits for the entire vocab
    
    

    Key Technical Details

    • Byte-Pair Encoding (BPE): GPT-1 used a 40,000-merge BPE vocabulary.
    • GELU Activation: Like BERT, GPT-1 used the Gaussian Error Linear Unit for smoother gradients.
    • Task-Specific Input Transformations: To handle different tasks (Multiple Choice, Entailment, etc.) with the same decoder, GPT-1 introduced special delimiters like [Extract], [Start], and [Delim].

    Why it was a breakthrough

    Before GPT-1, most models were trained from scratch for each specific task. GPT-1 popularized the “Pre-train then Fine-tune” paradigm, showing that the knowledge gained by simply reading a large library (BooksCorpus) could be transferred to help a model understand logic, sentiment, and grammar.

  • Pytorch implementation of paper: RoBERTa (2019):”RoBERTa: A Robustly Optimized BERT Pretraining Approach”

    The 2019 paper “RoBERTa: A Robustly Optimized BERT Pretraining Approach” by Liu et al. (Facebook AI) is essentially a “masterclass in hyperparameter tuning.” The authors found that BERT was significantly undertrained and that several of its design choices were suboptimal.

    RoBERTa uses the exact same architecture as BERT but changes the pre-training strategy. To implement RoBERTa in PyTorch, we take our BERT implementation and apply four critical modifications.


    1. Dynamic Masking

    In the original BERT, masking was done once during data preprocessing (Static Masking). RoBERTa performs masking dynamically every time a sequence is fed into the model during training.

    Python

    import torch
    import torch.nn as nn
    
    class RoBERTaMasker:
        def __init__(self, vocab_size, mask_token_id, pad_token_id):
            self.vocab_size = vocab_size
            self.mask_id = mask_token_id
            self.pad_id = pad_token_id
    
        def mask_tokens(self, inputs):
            """
            Dynamic Masking: Create a new mask for the batch on the fly.
            """
            labels = inputs.clone()
            # Sample 15% of tokens for masking
            probability_matrix = torch.full(labels.shape, 0.15)
            # Don't mask padding
            probability_matrix.masked_fill_(labels == self.pad_id, value=0.0)
            
            masked_indices = torch.bernoulli(probability_matrix).bool()
            labels[~masked_indices] = -100  # Only calculate loss on masked tokens
    
            # 80% of the time, replace masked input tokens with [MASK]
            indices_replaced = torch.bernoulli(torch.full(labels.shape, 0.8)).bool() & masked_indices
            inputs[indices_replaced] = self.mask_id
    
            # 10% of the time, replace with random token
            indices_random = torch.bernoulli(torch.full(labels.shape, 0.5)).bool() & masked_indices & ~indices_replaced
            random_words = torch.randint(self.vocab_size, labels.shape, dtype=torch.long)
            inputs[indices_random] = random_words[indices_random]
    
            # The rest (10%) keep the original token
            return inputs, labels
    
    

    2. Full-Sentences (Removing NSP)

    RoBERTa removes the Next Sentence Prediction (NSP) task entirely. Instead, it uses “Full-Sentences”: input sequences are packed with full sentences sampled contiguously from one or more documents until they reach the maximum length (512 tokens).

    Python

    class RoBERTa(nn.Module):
        def __init__(self, vocab_size, hidden_size, n_layers, n_heads, d_ff):
            super().__init__()
            # Note: No Segment Embeddings needed since NSP is removed
            self.embedding = nn.Embedding(vocab_size, hidden_size)
            self.pos_embedding = nn.Embedding(512, hidden_size)
            
            self.layers = nn.ModuleList([
                EncoderLayer(hidden_size, n_heads, d_ff, 0.1) for _ in range(n_layers)
            ])
            
            # RoBERTa only has the Masked LM head
            self.lm_head = nn.Linear(hidden_size, vocab_size)
            self.norm = nn.LayerNorm(hidden_size)
    
        def forward(self, x, mask):
            # x: [batch_size, seq_len]
            seq_len = x.size(1)
            pos = torch.arange(seq_len).unsqueeze(0).to(x.device)
            
            x = self.embedding(x) + self.pos_embedding(pos)
            
            for layer in self.layers:
                x = layer(x, mask)
                
            return self.lm_head(x) # [batch_size, seq_len, vocab_size]
    
    

    3. Byte-Level BPE

    BERT used a character-level WordPiece vocabulary (30k). RoBERTa uses a larger Byte-level Byte-Pair Encoding (BPE) vocabulary (50k). This allows the model to learn a representation for every sub-word without ever hitting an “Unknown” ([UNK]) token, even with complex or misspelled text.

    4. Comparison: BERT vs. RoBERTa

    The architectural changes are subtle, but the training changes are massive.

    FeatureBERTRoBERTa
    MaskingStatic (Pre-calculated)Dynamic (Every epoch)
    TasksMLM + NSPMLM only
    Batch Size2568,000 (Massively Parallel)
    Training Steps1M steps500k steps (but much larger batches)
    Vocabulary30k WordPiece50k Byte-BPE

    Key Takeaway

    RoBERTa proved that the original BERT architecture was not the bottleneck; rather, it was the duration and scale of training. By training on 160GB of text (instead of BERT’s 16GB) and using much larger batches with dynamic masking, RoBERTa significantly outperformed BERT on almost every benchmark, including GLUE and SQuAD.

  • Pytorch implementation of paper: BERT (2018):”BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding”

    The 2018 paper “BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding” by Devlin et al. (Google AI) introduced a shift from the standard “left-to-right” language modeling to a deeply bidirectional approach.

    Unlike the original Transformer decoder (which masks future words), BERT uses a Masked Language Model (MLM) objective, allowing the representation of each word to fuse context from both its left and right simultaneously.

    1. BERT Embeddings

    BERT’s input is a summation of three embeddings: Token, Segment (to distinguish between two sentences), and Position (learned, unlike the sinusoidal ones in the original Transformer).

    Python

    import torch
    import torch.nn as nn
    
    class BERTEmbeddings(nn.Module):
        def __init__(self, vocab_size, hidden_size, max_len, n_segments):
            super().__init__()
            self.tok_embed = nn.Embedding(vocab_size, hidden_size)    # token embedding
            self.pos_embed = nn.Embedding(max_len, hidden_size)       # position embedding
            self.seg_embed = nn.Embedding(n_segments, hidden_size)    # segment(token_type) embedding
            self.norm = nn.LayerNorm(hidden_size)
            self.dropout = nn.Dropout(0.1)
    
        def forward(self, x, segment_label):
            seq_len = x.size(1)
            pos = torch.arange(seq_len, dtype=torch.long).unsqueeze(0).to(x.device)
            
            # BERT Input = Token + Position + Segment
            embedding = self.tok_embed(x) + self.pos_embed(pos) + self.seg_embed(segment_label)
            return self.dropout(self.norm(embedding))
    
    

    2. The Multi-Head Attention (Bidirectional)

    The core “Attention” is identical to the Transformer Encoder, but BERT explicitly removes the “causal mask” used in decoders, allowing every token to attend to every other token.

    Python

    class ScaledDotProductAttention(nn.Module):
        def forward(self, q, k, v, mask=None):
            d_k = q.size(-1)
            scores = torch.matmul(q, k.transpose(-2, -1)) / (d_k ** 0.5)
            
            if mask is not None:
                # Mask out padding tokens (0)
                scores = scores.masked_fill(mask == 0, -1e9)
                
            attn = torch.softmax(scores, dim=-1)
            return torch.matmul(attn, v)
    
    

    3. The BERT Model & Pre-training Heads

    BERT is pre-trained on two tasks:

    1. Masked LM (MLM): Predict the [MASK] tokens.
    2. Next Sentence Prediction (NSP): Predict if sentence B follows sentence A.

    Python

    class BERT(nn.Module):
        def __init__(self, vocab_size, hidden_size, n_layers, n_heads, d_ff):
            super().__init__()
            self.embedding = BERTEmbeddings(vocab_size, hidden_size, 512, 2)
            
            # Stack of Transformer Encoder Layers
            self.layers = nn.ModuleList([
                EncoderLayer(hidden_size, n_heads, d_ff, 0.1) for _ in range(n_layers)
            ])
            
            # NSP Head: Binary classification (isNext / NotNext)
            self.nsp_classifier = nn.Linear(hidden_size, 2)
            
            # MLM Head: Predict token from vocabulary
            self.mlm_classifier = nn.Linear(hidden_size, vocab_size)
            self.activation = nn.GELU() # BERT uses GELU instead of ReLU
    
        def forward(self, x, segment_label, mask):
            x = self.embedding(x, segment_label)
            
            for layer in self.layers:
                x = layer(x, mask)
                
            # Get the [CLS] token (first token) for NSP
            pooled_output = torch.tanh(nn.Linear(x.size(-1), x.size(-1))(x[:, 0]))
            nsp_logits = self.nsp_classifier(pooled_output)
            
            # Get all tokens for MLM
            mlm_logits = self.mlm_classifier(x)
            
            return mlm_logits, nsp_logits
    
    

    Key Technical Details

    • The [CLS] Token: BERT inserts a special [CLS] token at the start of every sequence. The final hidden state of this token is used as the aggregate sequence representation for classification tasks.
    • GELU Activation: BERT popularized the Gaussian Error Linear Unit, which provides a smoother non-linearity than ReLU.
    • Pre-training Strategy: 15% of tokens are chosen for masking. Of those, 80% are replaced with [MASK], 10% with a random token, and 10% are kept unchanged to ensure the model doesn’t only learn to focus on the mask.

    Why it was a breakthrough

    Before BERT, models like Word2Vec or GloVe gave a single static vector for a word (e.g., “bank” always had the same vector). BERT provides contextualized embeddings, meaning the vector for “bank” in “river bank” is completely different from “bank account” because the bidirectional attention sees the surrounding words before producing the vector.

  • Pytorch implementation of paper: Attention Is All You Need

    The 2017 paper “Attention Is All You Need” (Vaswani et al.) is perhaps the most influential paper in modern AI, as it replaced recurrent (LSTM/GRU) and convolutional layers with the Transformer architecture.

    The core principle is Self-Attention, which allows the model to process all words in a sequence simultaneously, enabling massive parallelization and capturing long-range dependencies better than any previous model.

    1. Scaled Dot-Product Attention

    This is the “engine” of the Transformer. It calculates how much focus each word should put on every other word in the sentence.

    $$Attention(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V$$

    Python

    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    
    class ScaledDotProductAttention(nn.Module):
        def __init__(self):
            super().__init__()
    
        def forward(self, q, k, v, mask=None):
            # q, k, v: [batch_size, n_heads, seq_len, d_k]
            d_k = q.size(-1)
            
            # Calculate scores
            scores = torch.matmul(q, k.transpose(-2, -1)) / torch.sqrt(torch.tensor(d_k, dtype=torch.float32))
            
            if mask is not None:
                scores = scores.masked_fill(mask == 0, -1e9)
                
            attn = F.softmax(scores, dim=-1)
            output = torch.matmul(attn, v)
            return output, attn
    
    

    2. Multi-Head Attention

    Instead of one expensive attention pass, the model splits the embedding into multiple “heads” that attend to different types of information (e.g., one head for syntax, another for pronouns) in parallel.

    Python

    class MultiHeadAttention(nn.Module):
        def __init__(self, d_model, n_heads):
            super().__init__()
            self.n_heads = n_heads
            self.d_model = d_model
            self.d_k = d_model // n_heads
            
            self.w_q = nn.Linear(d_model, d_model)
            self.w_k = nn.Linear(d_model, d_model)
            self.w_v = nn.Linear(d_model, d_model)
            self.w_concat = nn.Linear(d_model, d_model)
            self.attention = ScaledDotProductAttention()
    
        def forward(self, q, k, v, mask=None):
            batch_size = q.size(0)
            
            # Linear projections and split into heads
            q = self.w_q(q).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
            k = self.w_k(k).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
            v = self.w_v(v).view(batch_size, -1, self.n_heads, self.d_k).transpose(1, 2)
            
            # Scaled Dot-Product Attention
            out, attn = self.attention(q, k, v, mask=mask)
            
            # Concatenate heads and final linear layer
            out = out.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model)
            return self.w_concat(out)
    
    

    3. Positional Encoding

    Because there are no RNNs to track order, the Transformer adds sinusoidal waves to the embeddings to give the model a sense of where each word is in the sentence.

    Python

    import numpy as np
    
    class PositionalEncoding(nn.Module):
        def __init__(self, d_model, max_len=5000):
            super().__init__()
            pe = torch.zeros(max_len, d_model)
            position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
            div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-np.log(10000.0) / d_model))
            
            pe[:, 0::2] = torch.sin(position * div_term)
            pe[:, 1::2] = torch.cos(position * div_term)
            self.register_buffer('pe', pe.unsqueeze(0))
    
        def forward(self, x):
            # x: [batch_size, seq_len, d_model]
            return x + self.pe[:, :x.size(1)]
    
    

    4. The Transformer Block (Encoder)

    Each layer consists of Multi-Head Attention, followed by a Position-wise Feed-Forward Network (FFN), tied together with Residual Connections and Layer Normalization.

    Python

    class EncoderLayer(nn.Module):
        def __init__(self, d_model, n_heads, d_ff, dropout):
            super().__init__()
            self.self_attn = MultiHeadAttention(d_model, n_heads)
            self.feed_forward = nn.Sequential(
                nn.Linear(d_model, d_ff),
                nn.ReLU(),
                nn.Linear(d_ff, d_model)
            )
            self.norm1 = nn.LayerNorm(d_model)
            self.norm2 = nn.LayerNorm(d_model)
            self.dropout = nn.Dropout(dropout)
    
        def forward(self, x, mask):
            # Sub-layer 1: Attention + Residual + Norm
            attn_out = self.self_attn(x, x, x, mask)
            x = self.norm1(x + self.dropout(attn_out))
            
            # Sub-layer 2: FFN + Residual + Norm
            ff_out = self.feed_forward(x)
            x = self.norm2(x + self.dropout(ff_out))
            return x
    
    

    Why it Changed Everything

    1. Parallelization: Unlike LSTMs, which must wait for word $t-1$ to compute word $t$, the Transformer computes all words at once.
    2. Constant Path Length: Any word can “talk” to any other word in a single step, regardless of distance, solving the long-term dependency problem perfectly.
    3. Foundation for LLMs: This architecture is the direct ancestor of GPT, BERT, and Claude.

    Since you’ve been exploring the evolution of sequence models, this video provides a detailed walkthrough of the Transformer architecture from scratch. It is highly relevant because it visually maps each mathematical component of the “Attention Is All You Need” paper to its corresponding line of PyTorch code.

  • Pytorch implementation of paper: Effective Approaches to Attention-based Neural Machine Translation

    The paper “Effective Approaches to Attention-based Neural Machine Translation” (Luong et al., 2015) introduced what is now commonly known as Luong Attention. It differs from Bahdanau attention by being “top-down”—calculating attention after the decoder’s RNN step—and by offering different scoring functions (Dot, General, and Concat).

    1. The Global Attention Mechanism

    The core of Luong’s approach is the calculation of a variable-length context vector $c_t$ that is derived from the encoder hidden states $H$. The paper proposes three ways to calculate the alignment score between the current decoder hidden state $s_t$ and an encoder hidden state $h_s$:

    $$score(s_t, h_s) =

    \begin{cases}

    s_t^\top h_s & \text{dot} \

    s_t^\top W_a h_s & \text{general} \

    v_a^\top \tanh(W_a [s_t; h_s]) & \text{concat}

    \end{cases}$$

    Python

    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    
    class LuongAttention(nn.Module):
        def __init__(self, method, hidden_dim):
            super(LuongAttention, self).__init__()
            self.method = method
            self.hidden_dim = hidden_dim
    
            if self.method == 'general':
                self.attn = nn.Linear(hidden_dim, hidden_dim)
            elif self.method == 'concat':
                self.attn = nn.Linear(hidden_dim * 2, hidden_dim)
                self.v = nn.Parameter(torch.FloatTensor(hidden_dim))
    
        def forward(self, decoder_hidden, encoder_outputs):
            # decoder_hidden: [1, batch_size, hidden_dim]
            # encoder_outputs: [src_len, batch_size, hidden_dim]
            
            src_len = encoder_outputs.size(0)
            batch_size = encoder_outputs.size(1)
    
            # Calculate alignment scores
            if self.method == 'dot':
                # dot product between decoder_hidden and encoder_outputs
                attn_energies = torch.sum(decoder_hidden * encoder_outputs, dim=2) # [src_len, batch_size]
            
            elif self.method == 'general':
                energy = self.attn(encoder_outputs) # [src_len, batch_size, hidden_dim]
                attn_energies = torch.sum(decoder_hidden * energy, dim=2)
                
            elif self.method == 'concat':
                # Concatenate decoder hidden and encoder outputs
                combined = torch.cat((decoder_hidden.expand(src_len, -1, -1), encoder_outputs), dim=2)
                energy = torch.tanh(self.attn(combined)) # [src_len, batch_size, hidden_dim]
                attn_energies = torch.sum(self.v * energy, dim=2)
    
            # Normalize scores to probabilities (softmax over src_len)
            return F.softmax(attn_energies.t(), dim=1).unsqueeze(1) # [batch_size, 1, src_len]
    
    

    2. The Attentional Decoder

    In Luong attention, the sequence is: RNN Step $\rightarrow$ Attention $\rightarrow$ Context Vector $\rightarrow$ Hidden-to-Output.

    Python

    class LuongDecoder(nn.Module):
        def __init__(self, output_dim, emb_dim, hidden_dim, dropout, attention):
            super().__init__()
            self.attention = attention
            self.embedding = nn.Embedding(output_dim, emb_dim)
            self.rnn = nn.LSTM(emb_dim, hidden_dim, dropout=dropout)
            
            # Combined layer to produce 'attentional hidden state' tilde{h}_t
            self.concat = nn.Linear(hidden_dim * 2, hidden_dim)
            self.out = nn.Linear(hidden_dim, output_dim)
            self.dropout = nn.Dropout(dropout)
    
        def forward(self, input, hidden, cell, encoder_outputs):
            # input: [batch_size]
            input = input.unsqueeze(0)
            embedded = self.dropout(self.embedding(input))
    
            # 1. Standard RNN step
            # luong uses s_t (current) to calculate attention, unlike bahdanau (s_{t-1})
            output, (hidden, cell) = self.rnn(embedded, (hidden, cell))
    
            # 2. Calculate attention weights and context vector
            a = self.attention(output, encoder_outputs) # [batch_size, 1, src_len]
            encoder_outputs = encoder_outputs.permute(1, 0, 2) # [batch_size, src_len, hidden_dim]
            
            # context vector is weighted sum of encoder outputs
            context = torch.bmm(a, encoder_outputs) # [batch_size, 1, hidden_dim]
    
            # 3. Concatenate RNN output and context vector
            output = output.squeeze(0) # [batch_size, hidden_dim]
            context = context.squeeze(1) # [batch_size, hidden_dim]
            
            # Eq. 5 from paper: h_tilde = tanh(W_c [c_t; h_t])
            concat_input = torch.cat((output, context), 1)
            concat_output = torch.tanh(self.concat(concat_input))
    
            # 4. Final prediction
            prediction = self.out(concat_output)
            
            return prediction, hidden, cell
    
    

    Key Differences from Bahdanau (2015)

    FeatureBahdanau (NMT)Luong (Effective NMT)
    RNN StructureBidirectional GRUUnidirectional LSTM (often multi-layer)
    Attention TimingBefore RNN step (uses $s_{t-1}$)After RNN step (uses $s_t$)
    Alignment ScoreConcat onlyDot, General, or Concat
    Context PathFed into next RNN stepFed into a final hidden-to-output layer

    Summary of “Local” Attention

    The paper also introduced Local Attention, which only looks at a small window of the source sentence centered around a predicted position $p_t$. This is more computationally efficient for very long sequences but is less commonly used today than the Global Attention implemented above.