Tag: technology

  • 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: 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: Convolutional Sequence to Sequence Learning

    The paper “Convolutional Sequence to Sequence Learning” (Gehring et al., 2017) by Facebook AI Research (FAIR) was a landmark shift in NMT. Unlike LSTMs or GRUs, which are inherently sequential, this model uses Convolutional Neural Networks (CNNs).

    Because CNNs can be computed in parallel, this architecture is significantly faster to train than RNNs. It also uses Gated Linear Units (GLU) and a sophisticated Position Embedding system to maintain word order.

    1. Position Embeddings

    Since CNNs have no natural concept of “time” or “order,” we must explicitly add positional information to the word embeddings.

    Python

    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    
    class Encoder(nn.Module):
        def __init__(self, input_dim, emb_dim, hid_dim, n_layers, kernel_size, dropout, device, max_length=100):
            super().__init__()
            self.device = device
            self.scale = torch.sqrt(torch.FloatTensor([0.5])).to(device)
            
            self.tok_embedding = nn.Embedding(input_dim, emb_dim)
            self.pos_embedding = nn.Embedding(max_length, emb_dim)
            
            # Linear layer to map embedding dim to hidden dim
            self.emb2hid = nn.Linear(emb_dim, hid_dim)
            self.hid2emb = nn.Linear(hid_dim, emb_dim)
            
            # Convolutional blocks
            self.convs = nn.ModuleList([nn.Conv1d(in_channels=hid_dim, 
                                                  out_channels=2 * hid_dim, 
                                                  kernel_size=kernel_size, 
                                                  padding=(kernel_size - 1) // 2) 
                                        for _ in range(n_layers)])
            self.dropout = nn.Dropout(dropout)
    
        def forward(self, src):
            # src: [batch_size, src_len]
            batch_size = src.shape[0]
            src_len = src.shape[1]
            
            # Create position tensor
            pos = torch.arange(0, src_len).unsqueeze(0).repeat(batch_size, 1).to(self.device)
            
            # Combined embedding
            e = self.dropout(self.tok_embedding(src) + self.pos_embedding(pos))
            
            # Element-wise scaling as per paper
            conv_input = self.emb2hid(e).permute(0, 2, 1) 
            
            for conv in self.convs:
                # Residual connection
                convolved = conv(self.dropout(conv_input))
                
                # Gated Linear Unit (GLU) activation
                # Splits the 2*hid_dim channels into two, applies sigmoid to one
                convolved = F.glu(convolved, dim=1)
                
                # Add residual and scale
                conv_input = (conv_input + convolved) * self.scale
                
            # Extract combined and element-wise representations
            combined = self.hid2emb(conv_input.permute(0, 2, 1))
            return conv_input, combined
    
    

    2. The Gated Linear Unit (GLU)

    The paper uses GLUs instead of ReLU or Tanh. A GLU takes a vector $A$ and $B$ (split from the convolution output) and computes $A \otimes \sigma(B)$. This allows the network to control which information flows through the hierarchy.

    3. The Decoder with Multi-Step Attention

    The decoder uses a similar CNN structure but includes an attention mechanism in every layer. It looks at the encoder’s state to decide which source words are relevant.

    Python

    class Decoder(nn.Module):
        def __init__(self, output_dim, emb_dim, hid_dim, n_layers, kernel_size, dropout, trg_pad_idx, device, max_length=100):
            super().__init__()
            self.kernel_size = kernel_size
            self.trg_pad_idx = trg_pad_idx
            self.device = device
            self.scale = torch.sqrt(torch.FloatTensor([0.5])).to(device)
            
            self.tok_embedding = nn.Embedding(output_dim, emb_dim)
            self.pos_embedding = nn.Embedding(max_length, emb_dim)
            
            self.emb2hid = nn.Linear(emb_dim, hid_dim)
            self.hid2emb = nn.Linear(hid_dim, emb_dim)
            self.attn_hid2emb = nn.Linear(hid_dim, emb_dim)
            self.fc_out = nn.Linear(emb_dim, output_dim)
            
            self.convs = nn.ModuleList([nn.Conv1d(hid_dim, 2 * hid_dim, kernel_size, padding=kernel_size - 1) 
                                        for _ in range(n_layers)])
            self.dropout = nn.Dropout(dropout)
    
        def calculate_attention(self, embedded, convolved, encoder_convolved, encoder_combined):
            # convolved: [batch_size, hid_dim, trg_len]
            # encoder_convolved: [batch_size, hid_dim, src_len]
            
            # 1. Map convolved back to embedding space
            convolved_emb = self.attn_hid2emb(convolved.permute(0, 2, 1))
            
            # 2. Add current target embedding (Residual)
            query = (convolved_emb + embedded) * self.scale
            
            # 3. Dot product attention with encoder_convolved
            energy = torch.matmul(query, encoder_convolved.permute(0, 2, 1))
            attention = F.softmax(energy, dim=2)
            
            # 4. Weighted sum of encoder_combined
            weighted = torch.matmul(attention, encoder_combined)
            
            # 5. Map back to hid_dim
            return self.emb2hid(weighted).permute(0, 2, 1), attention
    
        def forward(self, trg, encoder_convolved, encoder_combined):
            # trg: [batch_size, trg_len]
            batch_size = trg.shape[0]
            trg_len = trg.shape[1]
            
            pos = torch.arange(0, trg_len).unsqueeze(0).repeat(batch_size, 1).to(self.device)
            embedded = self.dropout(self.tok_embedding(trg) + self.pos_embedding(pos))
            
            conv_input = self.emb2hid(embedded).permute(0, 2, 1)
            
            for conv in self.convs:
                # Casual Padding: ensure the conv doesn't "see" the future
                padding = torch.zeros(batch_size, conv_input.shape[1], self.kernel_size - 1).to(self.device)
                padded_input = torch.cat((padding, conv_input), dim=2)
                
                convolved = conv(self.dropout(padded_input))
                convolved = F.glu(convolved[:, :, :conv_input.shape[2] + self.kernel_size - 1], dim=1)
                
                # Multi-step Attention
                attn_weighted, _ = self.calculate_attention(embedded, convolved, encoder_convolved, encoder_combined)
                
                # Residual + Attention + Scale
                convolved = (convolved + attn_weighted) * self.scale
                conv_input = (conv_input + convolved) * self.scale
                
            output = self.fc_out(self.hid2emb(conv_input.permute(0, 2, 1)))
            return output
    
    

    Key Takeaways from the Paper

    1. Parallelism: Unlike LSTMs, the entire source sentence is processed at once through the convolutional layers.
    2. Causal Convolutions: In the decoder, we use specific padding to ensure that the prediction for word $t$ only depends on words $1 \dots t-1$.
    3. Multi-Step Attention: Applying attention at every layer allows the model to refine its “focus” as it goes deeper into the architecture.
    4. Deep Residuals: Because the network is deep, residual connections and the $\sqrt{0.5}$ scaling factor are critical for stable training.

    This architecture served as the bridge between RNN-based translation and the Transformer (Attention is All You Need), which would arrive just a few months later.

  • Pytorch implementation of paper: Neural Machine Translation by Jointly Learning to Align and Translate

    Implementing the architecture from the paper “Neural Machine Translation by Jointly Learning to Align and Translate” (Bahdanau et al., 2015) involves three main components: a Bidirectional GRU Encoder, the Bahdanau Attention mechanism, and an Attentional Decoder.

    The key innovation is that the decoder doesn’t use a single fixed vector; instead, it “looks” at all encoder hidden states and calculates a weighted sum (the context vector) based on their relevance to the current decoding step.

    1. The Encoder (Bidirectional GRU)

    The encoder processes the source sequence in both directions. The annotation for each word is the concatenation of the forward ($\overrightarrow{h}$) and backward ($\overleftarrow{h}$) hidden states.

    Python

    import torch
    import torch.nn as nn
    import torch.nn.functional as F
    
    class Encoder(nn.Module):
        def __init__(self, input_dim, emb_dim, enc_hid_dim, dec_hid_dim, dropout):
            super().__init__()
            self.embedding = nn.Embedding(input_dim, emb_dim)
            self.rnn = nn.GRU(emb_dim, enc_hid_dim, bidirectional=True)
            self.fc = nn.Linear(enc_hid_dim * 2, dec_hid_dim)
            self.dropout = nn.Dropout(dropout)
            
        def forward(self, src):
            # src: [src_len, batch_size]
            embedded = self.dropout(self.embedding(src))
            
            # outputs: [src_len, batch_size, enc_hid_dim * 2]
            # hidden: [n_layers * directions, batch_size, enc_hid_dim]
            outputs, hidden = self.rnn(embedded)
            
            # Initial decoder hidden state is a linear transformation of the 
            # concatenated final forward and backward encoder hidden states
            hidden = torch.tanh(self.fc(torch.cat((hidden[-2,:,:], hidden[-1,:,:]), dim=1)))
            
            return outputs, hidden
    
    

    2. The Attention Mechanism (Bahdanau Attention)

    This is a feed-forward layer that takes the previous decoder hidden state $s_{i-1}$ and all encoder hidden states $h_j$ to calculate the energy (alignment) scores.

    $$e_{ij} = v_a^\top \tanh(W_a s_{i-1} + U_a h_j)$$

    Python

    class Attention(nn.Module):
        def __init__(self, enc_hid_dim, dec_hid_dim):
            super().__init__()
            self.attn = nn.Linear((enc_hid_dim * 2) + dec_hid_dim, dec_hid_dim)
            self.v = nn.Linear(dec_hid_dim, 1, bias=False)
            
        def forward(self, hidden, encoder_outputs):
            # hidden: [batch_size, dec_hid_dim]
            # encoder_outputs: [src_len, batch_size, enc_hid_dim * 2]
            
            batch_size = encoder_outputs.shape[1]
            src_len = encoder_outputs.shape[0]
            
            # Repeat decoder hidden state src_len times
            hidden = hidden.unsqueeze(1).repeat(1, src_len, 1)
            encoder_outputs = encoder_outputs.permute(1, 0, 2)
            
            # Energy: [batch_size, src_len, dec_hid_dim]
            energy = torch.tanh(self.attn(torch.cat((hidden, encoder_outputs), dim=2)))
            
            # Attention: [batch_size, src_len]
            attention = self.v(energy).squeeze(2)
            
            return F.softmax(attention, dim=1)
    
    

    3. The Decoder

    The decoder takes the attention weights, creates a context vector, and uses it along with the current input to predict the next word.

    Python

    class Decoder(nn.Module):
        def __init__(self, output_dim, emb_dim, enc_hid_dim, dec_hid_dim, dropout, attention):
            super().__init__()
            self.output_dim = output_dim
            self.attention = attention
            self.embedding = nn.Embedding(output_dim, emb_dim)
            self.rnn = nn.GRU((enc_hid_dim * 2) + emb_dim, dec_hid_dim)
            self.fc_out = nn.Linear((enc_hid_dim * 2) + dec_hid_dim + emb_dim, output_dim)
            self.dropout = nn.Dropout(dropout)
            
        def forward(self, input, hidden, encoder_outputs):
            # input: [batch_size]
            input = input.unsqueeze(0)
            embedded = self.dropout(self.embedding(input))
            
            # Calculate attention weights
            a = self.attention(hidden, encoder_outputs).unsqueeze(1)
            
            # Context vector: [batch_size, 1, enc_hid_dim * 2]
            encoder_outputs = encoder_outputs.permute(1, 0, 2)
            weighted = torch.bmm(a, encoder_outputs)
            weighted = weighted.permute(1, 0, 2)
            
            # RNN Input: concat(embedded, context_vector)
            rnn_input = torch.cat((embedded, weighted), dim=2)
            output, hidden = self.rnn(rnn_input, hidden.unsqueeze(0))
            
            # Final Linear layer
            prediction = self.fc_out(torch.cat((output.squeeze(0), weighted.squeeze(0), embedded.squeeze(0)), dim=1))
            
            return prediction, hidden.squeeze(0)
    
    

    4. Seq2Seq Wrapper

    Finally, combine everything into a single model to manage the training loop and inference.

    Python

    class Seq2Seq(nn.Module):
        def __init__(self, encoder, decoder, device):
            super().__init__()
            self.encoder = encoder
            self.decoder = decoder
            self.device = device
            
        def forward(self, src, trg, teacher_forcing_ratio=0.5):
            batch_size = src.shape[1]
            trg_len = trg.shape[0]
            trg_vocab_size = self.decoder.output_dim
            
            outputs = torch.zeros(trg_len, batch_size, trg_vocab_size).to(self.device)
            enc_outputs, hidden = self.encoder(src)
            
            input = trg[0,:]
            
            for t in range(1, trg_len):
                output, hidden = self.decoder(input, hidden, enc_outputs)
                outputs[t] = output
                teacher_force = torch.rand(1).item() < teacher_forcing_ratio
                top1 = output.argmax(1)
                input = trg[t] if teacher_force else top1
                
            return outputs
    
    

    Key Implementation Details:

    • Context Vector: A weighted sum of encoder hidden states that dynamically represents the “attended” parts of the source sentence for each target word.
    • Teacher Forcing: A technique used during training where the actual ground-truth word is fed as the next input instead of the model’s own prediction.
    • Tanh Activation: Used in the attention scoring function to introduce non-linearity, which allows the model to learn complex alignment patterns.
  • Pytorch implementation of paper: CTC (2006): Graves introduced Connectionist Temporal Classification, a way to train LSTMs on unsegmented sequence data (like handwriting or audio).

    The 2006 paper “Connectionist Temporal Classification: Labelling Unsegmented Sequence Data with Recurrent Neural Networks” by Alex Graves et al. solved a massive bottleneck in AI: the need for perfectly “aligned” data.

    Before CTC, if you wanted to train an LSTM to recognize the word “Hello” in audio, you had to manually label exactly which milliseconds corresponded to the ‘H’, the ‘e’, and so on. CTC allows the model to output a sequence of characters and “aligns” them to the target text automatically using a hidden “blank” token.

    1. The CTC Algorithm Logic

    CTC introduces a special blank symbol ($\epsilon$). The model predicts a probability distribution over the alphabet plus this blank for every time step. To get the final prediction, CTC:

    1. Collapses repeated characters (e.g., h h e l l l o $\rightarrow$ h e l o).
    2. Removes the blank tokens (e.g., h - e - l - l - o $\rightarrow$ hello).

    2. PyTorch Implementation

    PyTorch has a highly optimized nn.CTCLoss because the “forward-backward” algorithm required to calculate the loss is computationally expensive.

    Python

    import torch
    import torch.nn as nn
    
    class CTCModel(nn.Module):
        def __init__(self, input_size, hidden_size, num_classes):
            super(CTCModel, self).__init__()
            # 1. Bidirectional LSTM to capture context
            self.lstm = nn.LSTM(input_size, hidden_size, 
                                bidirectional=True, batch_first=True)
            
            # 2. Fully connected layer to map to character probabilities
            # num_classes must include the "blank" token (usually index 0)
            self.fc = nn.Linear(hidden_size * 2, num_classes)
            
            # 3. Log Softmax is required for CTCLoss
            self.softmax = nn.LogSoftmax(dim=2)
    
        def forward(self, x):
            # x: [batch, seq_len, features]
            lstm_out, _ = self.lstm(x)
            logits = self.fc(lstm_out)
            return self.softmax(logits)
    
    # --- Usage & Training Setup ---
    
    # Example parameters
    num_chars = 26 # a-z
    num_classes = num_chars + 1 # +1 for CTC blank token
    model = CTCModel(input_size=40, hidden_size=256, num_classes=num_classes)
    
    # Define CTC Loss
    # zero_infinity=True helps with stability in early training
    ctc_loss = nn.CTCLoss(blank=0, zero_infinity=True)
    
    # Dummy Data
    # input: [seq_len, batch, features] (Note: CTC expects seq_len first by default)
    input_data = torch.randn(50, 16, 40) 
    # targets: [sum_of_target_lengths] (1D tensor of all labels concatenated)
    targets = torch.randint(1, num_classes, (16, 10)) 
    
    # Lengths are required for CTC to know where sequences end
    input_lengths = torch.full(size=(16,), fill_value=50, dtype=torch.long)
    target_lengths = torch.full(size=(16,), fill_value=10, dtype=torch.long)
    
    # Forward pass
    log_probs = model(input_data.transpose(0, 1)).transpose(0, 1)
    
    # Calculate Loss
    loss = ctc_loss(log_probs, targets, input_lengths, target_lengths)
    loss.backward()
    
    
    

    3. Key Concepts of CTC

    • The Blank Token: It acts as a “buffer” between characters. This allows the model to predict the same character twice (like the ‘l’ in ‘hello’) without them being collapsed into one.
    • Input vs. Target Length: For CTC to work, the input sequence length ($T$) must be greater than or equal to the target sequence length ($L$). If your audio is 1 second long but you have 500 characters of text, the math breaks.
    • Decoding: During inference, we usually use Greedy Decoding (taking the argmax at each step and collapsing) or Beam Search Decoding (exploring multiple paths) to find the most likely sentence.

    4. Why it was a breakthrough

    CTC enabled “End-to-End” speech recognition. You could feed raw audio and the corresponding transcript directly into a loss function, and the model would learn the alignment by itself. This paved the way for modern systems like DeepSpeech and the voice assistants we use today.