Author: admin

  • 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: 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.

  • 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.

  • Pytorch implementation of paper: Bidirectional LSTMs (2005): Alex Graves and Schmidhuber published “Framewise Phoneme Classification with Bidirectional LSTM,” which allowed the model to look at both the past and the future context (very useful for speech and text).

    The 2005 paper by Alex Graves and Jürgen Schmidhuber was a major milestone for sequence labeling, particularly in speech recognition (phoneme classification). While bidirectional RNNs existed since 1997, Graves and Schmidhuber combined them with the LSTM‘s ability to handle long-term dependencies.

    The core idea is to process the input sequence using two separate hidden layers—one moving forward in time and one moving backward—and then concatenating their outputs at each time step.

    1. The Bidirectional Logic

    For any given time step $t$, the model has access to:

    1. Past context: Via the forward layer $\overrightarrow{h}_t$.
    2. Future context: Via the backward layer $\overleftarrow{h}_t$.

    The final representation for that time step is typically the concatenation: $h_t = [\overrightarrow{h}_t ; \overleftarrow{h}_t]$.


    2. PyTorch Implementation

    While PyTorch provides a bidirectional=True flag in its standard nn.LSTM, here is how you build it from the ground up using the LSTM cells we’ve discussed to understand the data flow.

    Python

    import torch
    import torch.nn as nn
    
    class BidirectionalLSTM(nn.Module):
        def __init__(self, input_size, hidden_size, output_size):
            super(BidirectionalLSTM, self).__init__()
            self.hidden_size = hidden_size
            
            # Two independent LSTM cells
            self.forward_cell = nn.LSTMCell(input_size, hidden_size)
            self.backward_cell = nn.LSTMCell(input_size, hidden_size)
            
            # The output layer takes the concatenated hidden states (2 * hidden_size)
            self.fc = nn.Linear(hidden_size * 2, output_size)
    
        def forward(self, x):
            # x shape: [batch_size, seq_len, input_size]
            batch_size, seq_len, _ = x.size()
            device = x.device
            
            # 1. Initialize states for both directions
            h_f = torch.zeros(batch_size, self.hidden_size).to(device)
            c_f = torch.zeros(batch_size, self.hidden_size).to(device)
            h_b = torch.zeros(batch_size, self.hidden_size).to(device)
            c_b = torch.zeros(batch_size, self.hidden_size).to(device)
            
            # Lists to store hidden states for each time step
            forward_states = [None] * seq_len
            backward_states = [None] * seq_len
            
            # 2. Forward Pass
            for t in range(seq_len):
                h_f, c_f = self.forward_cell(x[:, t, :], (h_f, c_f))
                forward_states[t] = h_f
                
            # 3. Backward Pass
            for t in reversed(range(seq_len)):
                h_b, c_b = self.backward_cell(x[:, t, :], (h_b, c_b))
                backward_states[t] = h_b
                
            # 4. Concatenation & Output
            # Combine [forward_t, backward_t] for every t
            combined_outputs = []
            for f, b in zip(forward_states, backward_states):
                combined_outputs.append(torch.cat((f, b), dim=1))
                
            # Stack to shape: [seq_len, batch_size, hidden_size * 2]
            out = torch.stack(combined_outputs)
            
            # 5. Framewise Classification (as per the paper)
            # Apply the linear layer to every time step
            logits = self.fc(out)
            
            return logits # [seq_len, batch_size, output_size]
    
    

    3. Why this was a breakthrough for Speech

    In speech recognition (phoneme classification), the sound you are hearing now is often disambiguated by the sound that comes immediately after it.

    • Standard LSTM: Only knows what has been said so far. It has to “guess” until the next phoneme arrives.
    • Bidirectional LSTM: Can “wait” to see the entire utterance before deciding what the phoneme at $t=5$ was, using context from $t=6, 7, 8…$

    4. Key Implementation Nuance: Padding

    When using bidirectional models on mini-batches of varying lengths, you must be careful with padding. If you don’t use pack_padded_sequence, the “backward” layer will start by processing a bunch of zero-padding tokens, which can corrupt the initial hidden state for the actual data.


  • Pytorch implementation of paper: Peephole Connections (2000): Gers and Schmidhuber introduced “Peephole” connections, allowing gates to “look” at the cell state directly for better timing.

    The Peephole LSTM (Gers et al., 2000) was introduced to solve tasks where the network needs to measure precise time intervals. In a standard LSTM, the gates only see the previous hidden state $h_{t-1}$ and current input $x_t$. If the output gate is closed, the rest of the network has no idea what is happening inside the “vault” of the cell state $c_t$.

    Peephole connections allow the gates to “peep” at the cell state $c_t$ even when the output gate is closed, providing the gates with the internal context necessary for precise timing.

    1. The Mathematical Change

    The gates are modified to include the cell state in their activation. Note that the cell state used for the input and forget gates is $c_{t-1}$, while the output gate uses the updated $c_t$:

    • Forget Gate: $f_t = \sigma(W_f x_t + U_f h_{t-1} + \mathbf{V_f c_{t-1}} + b_f)$
    • Input Gate: $i_t = \sigma(W_i x_t + U_i h_{t-1} + \mathbf{V_i c_{t-1}} + b_i)$
    • Output Gate: $o_t = \sigma(W_o x_t + U_o h_{t-1} + \mathbf{V_o c_{t}} + b_o)$

    2. PyTorch Implementation

    In PyTorch, the peephole connections are implemented as diagonal weight matrices (element-wise multiplication) rather than full matrix multiplications to keep the parameter count manageable.

    Python

    import torch
    import torch.nn as nn
    
    class PeepholeLSTMCell(nn.Module):
        def __init__(self, input_size, hidden_size):
            super(PeepholeLSTMCell, self).__init__()
            self.input_size = input_size
            self.hidden_size = hidden_size
            
            # Standard weights for x and h
            self.x2h = nn.Linear(input_size, 4 * hidden_size)
            self.h2h = nn.Linear(hidden_size, 4 * hidden_size)
            
            # Peephole weights (diagonal/element-wise)
            # Note: We only need 3 peephole vectors (f, i, o)
            self.w_pc = nn.Parameter(torch.Tensor(hidden_size)) # for input gate
            self.w_pf = nn.Parameter(torch.Tensor(hidden_size)) # for forget gate
            self.w_po = nn.Parameter(torch.Tensor(hidden_size)) # for output gate
            
            self.reset_parameters()
    
        def reset_parameters(self):
            nn.init.orthogonal_(self.x2h.weight)
            nn.init.orthogonal_(self.h2h.weight)
            nn.init.constant_(self.w_pc, 0)
            nn.init.constant_(self.w_pf, 0)
            nn.init.constant_(self.w_po, 0)
    
        def forward(self, x, states):
            h_prev, c_prev = states
            
            # 1. Pre-calculate x and h transformations
            pre_gates = self.x2h(x) + self.h2h(h_prev)
            f_pre, i_pre, g_pre, o_pre = pre_gates.chunk(4, 1)
            
            # 2. Forget Gate (sees c_prev)
            f = torch.sigmoid(f_pre + self.w_pf * c_prev)
            
            # 3. Input Gate (sees c_prev)
            i = torch.sigmoid(i_pre + self.w_pc * c_prev)
            
            # 4. Cell State Update
            g = torch.tanh(g_pre)
            c_next = (f * c_prev) + (i * g)
            
            # 5. Output Gate (sees updated c_next)
            o = torch.sigmoid(o_pre + self.w_po * c_next)
            
            # 6. Hidden State
            h_next = o * torch.tanh(c_next)
            
            return h_next, c_next
    
    

    3. Key Technical Notes

    • Temporal Precision: Peephole connections are particularly famous for allowing LSTMs to learn the “Counting” task or “Clock” tasks where the network must emit a pulse every $N$ steps.
    • Parameter Count: Using diagonal peephole connections only adds $3 \times \text{hidden\_size}$ parameters, which is negligible compared to the $W$ and $U$ matrices.
    • Why $c_{next}$ for Output Gate? The output gate is responsible for deciding which parts of the already updated memory should be visible to the rest of the network, hence it uses the current state rather than the previous one.

  • Pytorch implementation of Paper: “Learning to Forget: Continual Prediction with LSTM”

    The paper “Learning to Forget: Continual Prediction with LSTM” (Gers, Schmidhuber, and Cummins, 2000) is the most significant update to the original LSTM. It introduced the Forget Gate, which allows the network to reset its internal state when a sequence ends or information becomes obsolete.

    Without this gate, the cell state ($c_t$) can grow indefinitely, leading to numerical instability and a “saturated” memory that can’t learn new patterns.

    1. The Core Innovation: The Forget Gate

    In the 1997 version, $c_t = c_{t-1} + i_t g_t$. In the 2000 version, the forget gate $f_t$ was added to scale the previous cell state:

    $$c_t = f_t \odot c_{t-1} + i_t \odot g_t$$


    2. PyTorch Implementation

    This implementation follows the precise equations from the 2000 paper, including the forget gate logic.

    Python

    import torch
    import torch.nn as nn
    
    class ForgetGateLSTMCell(nn.Module):
        def __init__(self, input_size, hidden_size):
            super(ForgetGateLSTMCell, self).__init__()
            self.input_size = input_size
            self.hidden_size = hidden_size
            
            # Linear layers for the 4 internal components
            # (f: forget, i: input, g: cell candidate, o: output)
            self.x2h = nn.Linear(input_size, 4 * hidden_size)
            self.h2h = nn.Linear(hidden_size, 4 * hidden_size)
    
        def forward(self, x, states):
            # states: (h_prev, c_prev)
            h_prev, c_prev = states
            
            # Calculate all gate inputs at once
            pre_gates = self.x2h(x) + self.h2h(h_prev)
            
            # Split into individual gates
            # In the 2000 paper, these are:
            # y_in_f (forget), y_in_i (input), y_in_c (cell), y_in_o (output)
            f_pre, i_pre, g_pre, o_pre = pre_gates.chunk(4, 1)
            
            # Gate activations (usually Sigmoid for gates, Tanh for cell)
            f = torch.sigmoid(f_pre)  # Forget Gate: f_t
            i = torch.sigmoid(i_pre)  # Input Gate: i_t
            o = torch.sigmoid(o_pre)  # Output Gate: o_t
            g = torch.tanh(g_pre)     # Cell Input: g_t
            
            # --- THE FORGET GATE UPDATE ---
            # c_t = f_t * c_{t-1} + i_t * g_t
            c_next = (f * c_prev) + (i * g)
            
            # Hidden state output
            h_next = o * torch.tanh(c_next)
            
            return h_next, c_next
    
    

    3. Comparison: 1997 vs. 2000

    The 2000 paper changed how the LSTM handles “continual” tasks where the data stream isn’t chopped into neat, independent segments.

    FeatureOriginal LSTM (1997)Forget Gate LSTM (2000)
    Cell Update$c_t = c_{t-1} + i_t g_t$$c_t = f_t c_{t-1} + i_t g_t$
    Gradient FlowConstant (1.0)Controlled by $f_t$
    MemoryNever resets; can saturateResets based on input context
    Ideal ForShort, segmented sequencesLong-term, continuous time series

    4. Why “Learning to Forget” Matters

    The authors demonstrated that on tasks like the Embedded Reber Grammar, the original LSTM would fail once the sequences became too long because the internal state would drift. By “forgetting,” the model can “clear its desk” once a specific sub-task is finished, allowing the same neurons to be reused for the next part of the sequence.

    If you are training this on modern hardware, you’ll find that $f_t$ is often initialized with a bias of 1.0. This ensures that at the start of training, the gate is wide open (not forgetting anything), preventing the vanishing gradient problem from returning prematurely.

  • Pytorch implementation of Paper: “Long Short-Term Memory”

    Following Sepp Hochreiter’s 1991 thesis, he collaborated with Jürgen Schmidhuber to publish “Long Short-Term Memory” (1997). This paper introduced the Constant Error Carousel (CEC) and Gating Units (specifically the Input and Output gates) to solve the vanishing gradient problem.

    Note: The “Forget Gate” was actually added later (Gers et al., 2000), but I will include it here as it is standard in modern PyTorch implementations.

    1. The LSTM Cell Architecture

    The core idea is the Cell State ($c_t$), which acts as a “conveyor belt” allowing information to flow through time with only minor linear interactions.

    Python

    import torch
    import torch.nn as nn
    import math
    
    class LSTMCell(nn.Module):
        def __init__(self, input_size, hidden_size):
            super(LSTMCell, self).__init__()
            self.input_size = input_size
            self.hidden_size = hidden_size
            
            # We combine all gates into one weight matrix for efficiency
            # Gates: Input (i), Forget (f), Cell (g), Output (o)
            self.x2h = nn.Linear(input_size, 4 * hidden_size)
            self.h2h = nn.Linear(hidden_size, 4 * hidden_size)
            
            self.reset_parameters()
    
        def reset_parameters(self):
            std = 1.0 / math.sqrt(self.hidden_size)
            for w in self.parameters():
                w.data.uniform_(-std, std)
    
        def forward(self, x, states):
            # states is a tuple (h_prev, c_prev)
            h_prev, c_prev = states
            
            # Batch computation of gate linear transformations
            gates = self.x2h(x) + self.h2h(h_prev)
            
            # Split the tensor into the 4 gate components
            i_gate, f_gate, g_gate, o_gate = gates.chunk(4, 1)
            
            # Apply activations
            i_gate = torch.sigmoid(i_gate) # Input gate
            f_gate = torch.sigmoid(f_gate) # Forget gate
            o_gate = torch.sigmoid(o_gate) # Output gate
            g_gate = torch.tanh(g_gate)    # New candidate cell state
            
            # Update Cell State: f_t * c_{t-1} + i_t * g_t
            # This addition is the "Constant Error Carousel" logic
            c_next = (f_gate * c_prev) + (i_gate * g_gate)
            
            # Update Hidden State
            h_next = o_gate * torch.tanh(c_next)
            
            return h_next, c_next
    
    

    2. The LSTM Model (Handling Sequences)

    To process a full sentence or time series, we wrap the cell in a loop. PyTorch’s built-in nn.LSTM does this in C++ for speed, but here is the manual implementation for clarity:

    Python

    class LSTMModel(nn.Module):
        def __init__(self, input_size, hidden_size, output_size):
            super(LSTMModel, self).__init__()
            self.hidden_size = hidden_size
            self.cell = LSTMCell(input_size, hidden_size)
            self.fc = nn.Linear(hidden_size, output_size)
            
        def forward(self, x):
            # x shape: [batch_size, sequence_length, input_size]
            batch_size, seq_len, _ = x.size()
            
            # Initialize h and c states with zeros
            h = torch.zeros(batch_size, self.hidden_size).to(x.device)
            c = torch.zeros(batch_size, self.hidden_size).to(x.device)
            
            # Loop through the sequence
            for t in range(seq_len):
                h, c = self.cell(x[:, t, :], (h, c))
                
            # Use the final hidden state for prediction
            out = self.fc(h)
            return out
    
    

    Why this solved the problem

    As highlighted in the 1997 paper, the LSTM avoids vanishing gradients because:

    1. Additive Updates: The cell state update $c_t = f_t c_{t-1} + i_t g_t$ uses addition rather than multiplication. When the forget gate $f_t$ is near 1, the gradient can flow backward through the cell state indefinitely without shrinking.
    2. Gating: The gates act as “valves” that learn when to let information in, when to clear the memory, and when to pass the memory to the next layer.
    ComponentFunctionMath Symbol
    Forget GateDecides what to discard from the cell state$f_t$
    Input GateDecides which new values to update$i_t$
    Cell StateThe long-term memory (CEC)$c_t$
    Output GateDecides what parts of the cell state to output$o_t$
  • Pytorch Implementation of: Paper: Untersuchungen zu dynamischen neuronalen Netzen (Diploma Thesis)

    Reference: https://threepointformula.wordpress.com/wp-admin/post.php?post=1567

    This paper, authored by Sepp Hochreiter in 1991, is the seminal work that first identified the vanishing and exploding gradient problem in recurrent neural networks (RNNs). While it didn’t propose the full LSTM architecture as we know it today (that came in 1997), it laid the mathematical groundwork for “Constant Error Carousels” to solve the gradient issues.

    Since the thesis focuses on the fundamental failure of standard RNNs to capture long-term dependencies, a PyTorch implementation involves demonstrating a Vanilla RNN and the logic used to analyze gradient flow.

    1. The Vanilla RNN (The Problematic Architecture)

    Hochreiter’s thesis proved that as we backpropagate through time, the gradient is multiplied by the weight matrix repeatedly. If the weights are small, the gradient vanishes; if large, it explodes.

    Python

    import torch
    import torch.nn as nn
    
    class VanillaRNN(nn.Module):
        def __init__(self, input_size, hidden_size, output_size):
            super(VanillaRNN, self).__init__()
            self.hidden_size = hidden_size
            
            # Standard RNN equations: 
            # h_t = tanh(W_ih * x_t + b_ih + W_hh * h_{t-1} + b_hh)
            self.rnn = nn.RNN(input_size, hidden_size, batch_first=True)
            self.fc = nn.Linear(hidden_size, output_size)
    
        def forward(self, x):
            # Initialize hidden state with zeros
            h0 = torch.zeros(1, x.size(0), self.hidden_size).to(x.device)
            
            # Forward propagate RNN
            out, hn = self.rnn(x, h0)
            
            # Reshape for the linear layer
            out = self.fc(out[:, -1, :])
            return out
    
    

    2. Monitoring Gradient Decay (The Thesis Core)

    To replicate Hochreiter’s findings, we need to track the norm of the gradients of the hidden states across many time steps. In a “dynamic neural network,” the gradient $\frac{\partial E}{\partial h_t}$ can be expressed as:

    $$\frac{\partial E}{\partial h_t} = \frac{\partial E}{\partial h_T} \prod_{k=t}^{T-1} \frac{\partial h_{k+1}}{\partial h_k}$$

    If the spectral radius of the weight matrix $W$ is less than 1, the product $\prod \frac{\partial h_{k+1}}{\partial h_k}$ tends toward zero.

    Python

    def analyze_gradients(model, input_data, target):
        model.zero_grad()
        output = model(input_data)
        loss = nn.MSELoss()(output, target)
        loss.backward()
    
        # Access gradients of the recurrent weight matrix
        # This is where Hochreiter observed the 'vanishing' effect
        for name, param in model.named_parameters():
            if 'weight_hh' in name:
                grad_norm = param.grad.norm()
                print(f"Gradient Norm for {name}: {grad_norm.item()}")
    
    

    3. The “Constant Error Carousel” (The Solution)

    The thesis proposed that to prevent the gradient from vanishing, the internal state should be updated linearly, effectively making $\frac{\partial h_{k+1}}{\partial h_k} \approx 1$.

    In PyTorch, this logic eventually became the LSTM. Below is a simplified implementation showing the “Linear Path” (Cell State) that Hochreiter argued for:

    Python

    class ConstantErrorCarousel(nn.Module):
        """
        Simplified logic inspired by Hochreiter's 1991 thesis:
        Maintaining a linear flow of information.
        """
        def __init__(self, input_size, hidden_size):
            super().__init__()
            self.hidden_size = hidden_size
            self.input_weights = nn.Linear(input_size, hidden_size)
            
        def forward(self, x, state=None):
            if state is None:
                state = torch.zeros(x.size(0), self.hidden_size)
                
            # Instead of h_t = tanh(W * h_{t-1} + ...), 
            # we focus on h_t = h_{t-1} + transformation(x)
            # This additive update ensures the gradient flow is roughly 1.0
            new_info = torch.tanh(self.input_weights(x))
            new_state = state + new_info 
            
            return new_state
    
    

    Summary of Findings in the Paper

    • The Vanishing Gradient: In deep or long-recurrent networks, the error signal effectively disappears, making it impossible for the model to learn relationships spanning more than 5–10 steps.
    • Sensitivity Analysis: Hochreiter used this to show that the “importance” of an input at time $t$ to an output at time $T$ decays exponentially.
    • Path to LSTM: This thesis was the “Warning Shot” for the AI community, leading directly to the 1997 LSTM paper where Gating Units were introduced to control this linear flow.