Luong Attention › Implementation

Michael BrenndoerferMay 18, 20257 min read
Language AI HandbookAll chapters
Part 12 of 67Sequence-to-Sequence
Chapter 88Luong Attention › Implementation
Reading section 6 of 11 · Implementation · 7 minSection 6 of 11 · 7 min

Book contents

Section 6 of 11 · 7 min read

Luong Attention › ImplementationOne section · Sections in this chapter6 / 11 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement Luong attention in PyTorch. We'll build a complete attention module that supports all three score functions, then integrate it into a sequence-to-sequence decoder.

Attention Module

First, we define the core attention computation. The module takes encoder outputs and a decoder hidden state, computes attention weights, and returns the context vector.

In[8]:
Code
import torch
import torch.nn as nn
import torch.nn.functional as F


class LuongAttention(nn.Module):
    """Luong attention with configurable score function."""

    def __init__(self, hidden_dim, encoder_dim=None, method="dot"):
        super().__init__()
        self.method = method
        self.hidden_dim = hidden_dim
        self.encoder_dim = encoder_dim or hidden_dim

        if method == "general":
            # W_a for bilinear scoring
            self.W_a = nn.Linear(self.encoder_dim, hidden_dim, bias=False)
        elif method == "concat":
            # Two-layer scoring network
            self.W_a = nn.Linear(
                hidden_dim + self.encoder_dim, hidden_dim, bias=False
            )
            self.v_a = nn.Linear(hidden_dim, 1, bias=False)

    def score(self, decoder_hidden, encoder_outputs):
        """Compute attention scores for all encoder positions."""
        if self.method == "dot":
            scores = torch.bmm(
                encoder_outputs, decoder_hidden.unsqueeze(2)
            ).squeeze(2)
        elif self.method == "general":
            transformed = self.W_a(encoder_outputs)
            scores = torch.bmm(
                transformed, decoder_hidden.unsqueeze(2)
            ).squeeze(2)
        elif self.method == "concat":
            src_len = encoder_outputs.size(1)
            decoder_expanded = decoder_hidden.unsqueeze(1).expand(
                -1, src_len, -1
            )
            concat = torch.cat([decoder_expanded, encoder_outputs], dim=2)
            scores = self.v_a(torch.tanh(self.W_a(concat))).squeeze(2)
        return scores

    def forward(self, decoder_hidden, encoder_outputs, mask=None):
        """Compute attention weights and context vector."""
        scores = self.score(decoder_hidden, encoder_outputs)
        if mask is not None:
            scores = scores.masked_fill(mask == 0, float("-inf"))
        attention_weights = F.softmax(scores, dim=1)
        context = torch.bmm(
            attention_weights.unsqueeze(1), encoder_outputs
        ).squeeze(1)
        return context, attention_weights

The LuongAttention class implements all three score functions. The constructor accepts the hidden dimension, an optional encoder dimension, and the scoring method. The score method computes alignment scores using dot product, general (bilinear), or concat approaches. The forward method orchestrates the full attention computation: scores, optional masking, softmax normalization, and weighted sum.

Attentional Decoder

Now let's build a decoder that uses Luong attention. The key difference from a standard RNN decoder is the attention step after the RNN and the combination layer that produces the attentional hidden state.

In[9]:
Code
class LuongDecoder(nn.Module):
    """Decoder with Luong attention."""

    def __init__(
        self,
        vocab_size,
        embed_dim,
        hidden_dim,
        encoder_dim=None,
        attention_method="dot",
        num_layers=1,
        dropout=0.1,
    ):
        super().__init__()
        self.hidden_dim = hidden_dim
        self.encoder_dim = encoder_dim or hidden_dim

        self.embedding = nn.Embedding(vocab_size, embed_dim)
        self.rnn = nn.GRU(
            embed_dim,
            hidden_dim,
            num_layers=num_layers,
            batch_first=True,
            dropout=dropout if num_layers > 1 else 0,
        )
        self.attention = LuongAttention(
            hidden_dim, self.encoder_dim, attention_method
        )
        self.W_c = nn.Linear(
            self.encoder_dim + hidden_dim, hidden_dim, bias=False
        )
        self.output_projection = nn.Linear(hidden_dim, vocab_size)
        self.dropout = nn.Dropout(dropout)

    def forward_step(self, input_token, hidden, encoder_outputs, mask=None):
        """Single decoding step with attention."""
        embedded = self.dropout(self.embedding(input_token.unsqueeze(1)))
        rnn_output, hidden = self.rnn(embedded, hidden)
        rnn_output = rnn_output.squeeze(1)
        context, attention_weights = self.attention(
            hidden[-1], encoder_outputs, mask
        )
        combined = torch.cat([context, rnn_output], dim=1)
        attentional_hidden = torch.tanh(self.W_c(combined))
        output = self.output_projection(self.dropout(attentional_hidden))
        return output, hidden, attention_weights

The LuongDecoder class combines an embedding layer, a GRU, the attention module, a combination layer for producing the attentional hidden state, and an output projection. The forward_step method implements a single decoding step: embed, RNN, then attention. This order is the defining characteristic of Luong attention.

Testing the Implementation

Let's verify our implementation works correctly with some example data.

In[10]:
Code
# Create sample data
batch_size = 4
src_len = 10
hidden_dim = 64
vocab_size = 1000

# Simulated encoder outputs (normally from an encoder RNN)
encoder_outputs = torch.randn(batch_size, src_len, hidden_dim)

# Padding mask (last 2 positions are padding)
mask = torch.ones(batch_size, src_len)
mask[:, -2:] = 0

# Initialize decoder
decoder = LuongDecoder(
    vocab_size=vocab_size,
    embed_dim=32,
    hidden_dim=hidden_dim,
    attention_method="dot",
)

# Initial hidden state
hidden = torch.zeros(1, batch_size, hidden_dim)

# Input token (e.g., start token)
input_token = torch.zeros(batch_size, dtype=torch.long)

# Run one decoding step
output, new_hidden, attention_weights = decoder.forward_step(
    input_token, hidden, encoder_outputs, mask
)
Out[11]:
Console
Output shape: torch.Size([4, 1000])
Hidden shape: torch.Size([1, 4, 64])
Attention weights shape: torch.Size([4, 10])

Attention weights sum to 1: 1.0000
Attention on padded positions: 0.000000

The output has the expected shape for vocabulary logits, and attention weights sum to 1 (ignoring padded positions). The near-zero attention on padded positions confirms our masking works correctly.

Visualizing Attention Patterns

Let's compare the attention patterns produced by different score functions on the same input.

In[12]:
Code
# Create decoders with different attention methods
methods = ["dot", "general", "concat"]
decoders = {
    method: LuongDecoder(vocab_size, 32, hidden_dim, attention_method=method)
    for method in methods
}

# Run decoding steps and collect attention weights
all_attention = {}
for method, dec in decoders.items():
    hidden = torch.zeros(1, batch_size, hidden_dim)
    _, _, attn = dec.forward_step(input_token, hidden, encoder_outputs, mask)
    all_attention[method] = attn.detach()
Out[13]:
Visualization
Bar chart showing dot product attention weights across source positions.
Dot product attention weights across 10 source positions for the first batch item. Parameter-free scoring based on direct vector similarity produces a peaked distribution.
Bar chart showing general attention weights across source positions.
General (bilinear) attention weights for the same input. The learned weight matrix W_a allows more flexible scoring, often producing a different distribution than dot product.
Bar chart showing concat attention weights across source positions.
Concat attention weights for the same input. The two-layer network with tanh nonlinearity can capture complex compatibility patterns. Gray shading marks the two padded positions.

With random weights, the attention patterns are similar across methods. After training, each method would develop distinct patterns based on what it learns about alignment. Dot product attention tends to produce sharper distributions when encoder and decoder representations align well, while concat attention can learn more complex compatibility functions.

To see what trained attention looks like, let's simulate a full translation with realistic attention patterns. The heatmap below shows attention weights across multiple decoding steps, revealing how the decoder systematically aligns with different source positions.

Out[14]:
Visualization
Heatmap showing attention weights with a diagonal pattern indicating word alignment between English source and French target sentences.
Simulated attention heatmap for translating 'The black cat sat quietly' to 'Le chat noir etait assis tranquillement'. Each row shows attention weights when generating one target word. The roughly diagonal pattern reflects word-order correspondence between the two languages, while deviations (e.g., 'noir' attending to 'black' at position 1) demonstrate how attention handles the French adjective-noun reordering.