M53: Transformer Encoder–Decoder Architectures

CSCI 3151 — Foundations of Machine Learning

Frank Rudzicz

Learning outcomes

By the end of this module, you should be able to:

  • explain why a single attention head is limited and how multi-head attention addresses that limitation;
  • describe the role of positional encoding and distinguish sinusoidal from learned variants;
  • identify the sub-layers in a transformer encoder block and a transformer decoder block;
  • compare the encoder-only, decoder-only, and encoder–decoder transformer variants and the tasks they suit;
  • implement a small transformer encoder in PyTorch and interpret its behaviour on a sequence task.

Recap: From attention onwards

M52 gave us the core mechanism:

\[ \text{Attention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V \]

with \(Q \in \mathbb{R}^{T \times d_k}\), \(K \in \mathbb{R}^{S \times d_k}\), \(V \in \mathbb{R}^{S \times d_v}\).

Key properties established:

  • Any two positions interact in \(\mathcal{O}(1)\) layers.
  • Attention weights are interpretable diagnostics (not causal explanations).
  • Self-attention = query, key, and value all come from the same sequence.

Three open questions for this module:

  1. One set of \((Q, K, V)\) weights captures one “relational pattern.” What if multiple patterns matter simultaneously?
  2. Self-attention ignores order. How do we encode position?
  3. How do we stack these components into a full architecture?

Each question has a well-motivated answer in the transformer.

Multi-head attention

One head, one relational pattern

Scaled dot-product attention projects the input into a single query, key, and value space and computes one weighted blend.

That means one set of linear weights determines:

  • which pairs of positions are “related,”
  • how information from one position is summarised for another.

The limitation: natural language (and many other sequences) has multiple simultaneous relationships at each position.

E.g., for the sentence “The animal didn’t cross the street because it was tired”:

  • it” refers to “animal” — a coreference link
  • street” and “cross” are semantically related — verb–object
  • didn’t” and “cross” form a negation — grammatical

These are different kinds of dependency, active at the same position simultaneously.

Note

A single attention head can learn one predominant pattern well. To capture several at once, the transformer runs multiple heads in parallel — each with its own learned projections — and concatenates the results.

Multi-head attention: the idea

Run \(h\) attention heads in parallel, each with its own projection matrices.

For head \(i\):

\[ \text{head}_i = \text{Attention}(Q W_i^Q,\; K W_i^K,\; V W_i^V) \]

where \(W_i^Q \in \mathbb{R}^{d_{\text{model}} \times d_k}\), \(W_i^K \in \mathbb{R}^{d_{\text{model}} \times d_k}\), \(W_i^V \in \mathbb{R}^{d_{\text{model}} \times d_v}\).

Concatenate all heads and project back:

\[ \text{MultiHead}(Q, K, V) = \text{Concat}(\text{head}_1, \ldots, \text{head}_h)\, W^O \]

with \(W^O \in \mathbb{R}^{h d_v \times d_{\text{model}}}\).

Typical implementation choice

\(d_k = d_v = d_{\text{model}} / h\), so total computation is similar to a single head at full dimension.

Multi-head attention in one picture

From Vaswani et al. (2017)

Why does using multiple heads help?

Empirical observation (from Vaswani et al. (2017) and follow-up interpretability work):

  • Different heads in large language models tend to specialize:
    • some attend to nearby tokens (local syntax)
    • some track long-range coreference
    • some attend to positionally adjacent tokens
  • No single head does all of these well simultaneously.

(Somewhat) formal perspective:

  • Each head applies a different linear projection of the same input.
  • The model can therefore represent multiple subspaces of the interaction structure.
  • Concatenation merges the evidence; the output projection \(W^O\) re-mixes it.

Multi-head ≠ magic

In practice, some heads in trained models appear redundant or difficult to interpret. Head specialization is not guaranteed and varies by task and model size.

“Head pruning” experiments show many heads can be removed without significant performance loss (Michel, Levy, and Neubig 2019), suggesting the architecture provides capacity headroom, not all of which is always used.

❌️ Anti-example: why not just make \(d_k\) larger?

Tempting reasoning: if one head is limited, just increase its dimension so it has more capacity.

Why this is not the same:

  • Increasing \(d_k\) in a single head increases the dimension of each individual query–key comparison but still uses one set of projection weights.
  • The softmax over a single set of scores still collapses all relationships into one attention distribution over positions.
  • A single distribution cannot simultaneously give high weight to position 3 (coreference) and position 7 (verb–object) and position 2 (negation) without averaging them together.

Multi-head attention learns \(h\) separate distributions and combines them:

\[ \text{output} = \sum_{i=1}^h \text{head}_i W_i^O \quad \text{(after concat + project)}. \]

This is a different kind of expressiveness than raw dimension.

Positional encoding

Self-attention is permutation-equivariant

Recall from 🔗M52: self-attention computes each output position by comparing content (via dot products between query and key vectors).

There is no notion of order:

\[ \text{Attention}(Q, K, V)_{i} = \sum_j \alpha_{ij} v_j, \qquad \alpha_{ij} = \frac{e^{q_i^\top k_j / \sqrt{d_k}}}{\sum_\ell e^{q_i^\top k_\ell / \sqrt{d_k}}}. \]

If you shuffle the tokens, the attention weights shuffle accordingly — but the set of output vectors is unchanged (just reordered).

This means a pure self-attention layer cannot tell “token at position 0” from “token at position 4” unless that information is somehow injected into the representations themselves.

The same token in two positions = the same representation

Without positional information, the words “dog bites man” and “man bites dog” produce the same set of token representations after one self-attention layer. For most tasks, order matters.

Sinusoidal positional encoding

The original transformer (Vaswani et al. 2017) adds a fixed positional encoding \(PE \in \mathbb{R}^{T \times d_{\text{model}}}\) to the input embeddings before any attention.

For position \(pos \in \{0, \ldots, T-1\}\) and dimension \(2i\) or \(2i+1\):

\[ PE_{pos,\, 2i} = \sin\!\left(\frac{pos}{10000^{\,2i / d_{\text{model}}}}\right) \]

\[ PE_{pos,\, 2i+1} = \cos\!\left(\frac{pos}{10000^{\,2i / d_{\text{model}}}}\right) \]

Design goals (compared to simple linear indices):

  • Each position gets a unique vector.
  • Nearby positions have similar encodings (smoothness).
  • Encodings at different dimensions oscillate at different frequencies, providing a multi-scale “ruler.”
  • The encoding is deterministic (no learned parameters) — the model can in principle generalize to unseen sequence lengths.

Visualizing positional encodings

Code
import numpy as np
import matplotlib.pyplot as plt

def sinusoidal_pe(T, d_model):
    """Return a (T, d_model) positional encoding matrix."""
    pos  = np.arange(T)[:, None]            # (T, 1)
    i    = np.arange(d_model // 2)[None, :] # (1, d_model/2)
    denom = 10000 ** (2 * i / d_model)
    pe = np.zeros((T, d_model))
    pe[:, 0::2] = np.sin(pos / denom)
    pe[:, 1::2] = np.cos(pos / denom)
    return pe

T, d_model = 50, 128
PE = sinusoidal_pe(T, d_model)

fig, axes = plt.subplots(1, 2, figsize=(13, 4.5))

# --- Left: heatmap ---
im = axes[0].imshow(PE[:, :64].T, aspect="auto", cmap="RdBu_r",
                     vmin=-1, vmax=1, origin="lower")
axes[0].set_xlabel("Position (token index)", fontsize=11)
axes[0].set_ylabel("Encoding dimension", fontsize=11)
axes[0].set_title("Heatmap: PE[pos, dim]", fontsize=12, weight="bold")
plt.colorbar(im, ax=axes[0], fraction=0.035, pad=0.04)

# Annotate frequency bands
axes[0].axhline(8,  color="#fbbf24", lw=1.6, ls="--", alpha=0.8)
axes[0].axhline(32, color="#34d399", lw=1.6, ls="--", alpha=0.8)
axes[0].text(51, 4,  "high freq", fontsize=8, color="#fbbf24", clip_on=False)
axes[0].text(51, 28, "mid freq",  fontsize=8, color="#34d399", clip_on=False)

# --- Right: a few sine waves ---
for dim, col, lab in [(0, "#6d28d9", "dim 0  (highest freq)"),
                       (8, "#2563eb", "dim 8"),
                       (32, "#059669", "dim 32"),
                       (60, "#dc2626", "dim 60 (lowest freq shown)")]:
    axes[1].plot(PE[:, dim], color=col, lw=1.8, label=lab)

axes[1].set_xlabel("Position", fontsize=11)
axes[1].set_ylabel("Encoding value", fontsize=11)
axes[1].set_title("Selected dimensions across positions", fontsize=12, weight="bold")
axes[1].legend(fontsize=9, loc="upper right")
axes[1].axhline(0, color="#cbd5e1", lw=0.8, ls="--")
axes[1].set_ylim(-1.15, 1.3)

plt.tight_layout()
plt.show()

Sinusoidal positional encodings for positions 0–49 and the first 64 dimensions of a d_model=128 model. Low-frequency (right) dimensions change slowly; high-frequency (left) dimensions change rapidly — together they form a unique fingerprint for each position.

🔬 What the encoding buys us

After adding PE to the token embeddings:

  • The model receives, at every token, the content of that token plus where it sits in the sequence.
  • Attention weights can now (implicitly) incorporate positional information via the query–key dot products.
  • Relative position can still be captured: the product \(PE_{pos} \cdot PE_{pos+k}\) depends only on the offset \(k\) for sinusoidal encodings — a useful inductive bias.

Learned positional embeddings are an alternative: a separate learned vector for each position (up to a maximum length). Used in BERT (Devlin et al. 2019) and many successors. More flexible, but cannot extrapolate beyond the training length.

Property Sinusoidal Learned
No extra parameters
Generalizes beyond train length ✓ (in principle)
Adapts to data distribution
Used in Vaswani et al. (2017) BERT (Devlin et al. 2019), GPT (Radford et al. 2019)

Note

In practice, both perform similarly on standard benchmarks when training and test lengths match.

Encoder and decoder blocks

The encoder block

A single transformer encoder block has two sub-layers, each followed by residual connection and layer normalization:

\[ \text{sublayer}_1 = \text{LayerNorm}\!\bigl(x + \text{MultiHead}(x, x, x)\bigr) \]

\[ \text{sublayer}_2 = \text{LayerNorm}\!\bigl(\text{sublayer}_1 + \text{FFN}(\text{sublayer}_1)\bigr) \]

Position-wise feedforward network (FFN):

\[ \text{FFN}(z) = \max(0,\, z W_1 + b_1)\, W_2 + b_2 \]

with \(W_1 \in \mathbb{R}^{d_{\text{model}} \times d_{ff}}\), \(W_2 \in \mathbb{R}^{d_{ff} \times d_{\text{model}}}\), typically \(d_{ff} = 4 d_{\text{model}}\).

The FFN is applied independently at each position — it mixes information across dimensions but not across positions.

Why these two sublayers?

The two sublayers have complementary roles:

  • Self-attention mixes information across positions — every token collects context from the rest of the sequence.
  • The FFN transforms each position independently — applying the same type of nonlinear function everywhere to re-encode what attention just gathered.

Attention decides where to look; the FFN decides what to do with the result. Neither alone is sufficient.

🔬 Residual connections and layer norms

Why residual connections?

We saw in 🔗M34, e.g., that deep networks are prone to vanishing gradients. The identity shortcut in

\[ y = \text{LayerNorm}(x + f(x)) \]

gives gradients a direct path back through the network, stabilizing training even for stacks of 12–96 layers.

Why layer normalization? (as opposed to batch normalization)

Layer norm normalizes across the feature dimension for each token independently:

\[ \text{LayerNorm}(x) = \frac{x - \mu}{\sigma + \epsilon} \odot \gamma + \beta \]

where \(\mu, \sigma\) are computed over the \(d_{\text{model}}\) features of a single token. This works at any batch size and handles variable-length sequences naturally.

flowchart TB
    X(["Input x"])
    MHA(["Multi-Head<br>Self-Attention"])
    ADD1(["Add & Norm<Br>(+ residual)"])
    FFN(["Feed-Forward<br>Network (FFN)"])
    ADD2(["Add & Norm<br>(+ residual)"])
    OUT(["Encoder output"])

    X --> MHA
    X --> ADD1
    MHA --> ADD1
    ADD1 --> FFN
    ADD1 --> ADD2
    FFN --> ADD2
    ADD2 --> OUT

    style X    fill:#e0e7ff,stroke:#4338ca,color:#111
    style MHA  fill:#fef3c7,stroke:#d97706,color:#111
    style ADD1 fill:#dcfce7,stroke:#16a34a,color:#111
    style FFN  fill:#fce7f3,stroke:#db2777,color:#111
    style ADD2 fill:#dcfce7,stroke:#16a34a,color:#111
    style OUT  fill:#ede9fe,stroke:#7c3aed,color:#111

The decoder block

The decoder block has three sub-layers, each wrapped with Add & Norm:

  1. Masked self-attention — tokens attend only to earlier positions (causal mask).
  2. Cross-attention — decoder queries attend to encoder outputs (keys and values from the encoder).
  3. FFN — same position-wise feedforward as in the encoder.

\[ z^{(1)} = \text{LayerNorm}\!\bigl(y + \text{MaskedMultiHead}(y, y, y)\bigr) \] \[ z^{(2)} = \text{LayerNorm}\!\bigl(z^{(1)} + \text{MultiHead}(z^{(1)}, h^{enc}, h^{enc})\bigr) \] \[ z^{(3)} = \text{LayerNorm}\!\bigl(z^{(2)} + \text{FFN}(z^{(2)})\bigr) \]

where \(y\) are the (shifted right) target embeddings and \(h^{enc}\) are the encoder outputs.

Why these three sublayers?

The decoder has one extra job compared to the encoder — it must both generate and condition on the source:

  • Masked self-attention lets each target token gather context from previously generated tokens (but not future ones).
  • Cross-attention connects the decoder to the encoder — the target attends to the full source representation.
  • The FFN re-encodes the result position-by-position, as in the encoder.

🔬 Why 😷 mask the decoder self-attention?

During training, the decoder sees the entire target sequence at once (teacher forcing: “je suis étudiant” → “i am a student”).

Without a mask, position \(t\) could attend to position \(t+1\), i.e., the decoder would see the future ground-truth tokens while predicting them.

The causal (look-ahead) mask sets scores to \(-\infty\) for positions \(j > i\):

\[ M_{ij} = \begin{cases} 0 & j \le i \\ -\infty & j > i \end{cases} \]

so after softmax, future positions receive zero weight.

At inference (autoregressive generation), there is no target sequence yet — the decoder generates one token at a time, so no masking trick is necessary.

Causal mask for a sequence of length 5. Each position can attend to itself and earlier positions only.

Full encoder–decoder architecture

The encoder processes the entire source sequence in parallel; its outputs are fixed for all decoder steps. The decoder generates the target sequence autoregressively, consuming its own previous outputs.

Ablations

Not every task needs all three components; we can ablate (remove) components.

Variant What it keeps Typical use case Example
Encoder-only Encoder blocks only Classification, tagging, embeddings BERT
Decoder-only Masked decoder blocks only Language modelling, generation GPT series
Encoder–decoder Both Seq-to-seq: translation, summarization, Q&A T5, original Transformer

Design principle

  • If your task is understanding an existing sequence → encoder-only.
  • If your task is generating a sequence unconditionally → decoder-only.
  • If your task maps one sequence to another → encoder–decoder.

Worked examples

Example 1: positional encoding similarity

Goal: verify empirically that sinusoidal PE encodes proximity — nearby positions should have more similar encodings than distant ones.

Code
import numpy as np
import matplotlib.pyplot as plt
from numpy.linalg import norm

def sinusoidal_pe(T, d_model):
    pos  = np.arange(T)[:, None]
    i    = np.arange(d_model // 2)[None, :]
    denom = 10000 ** (2 * i / d_model)
    pe = np.zeros((T, d_model))
    pe[:, 0::2] = np.sin(pos / denom)
    pe[:, 1::2] = np.cos(pos / denom)
    return pe

T, d_model = 40, 128
PE = sinusoidal_pe(T, d_model)

# Cosine similarity matrix
norms = norm(PE, axis=1, keepdims=True)
PE_n  = PE / norms
sim   = PE_n @ PE_n.T

fig, axes = plt.subplots(1, 2, figsize=(12, 4.5))

# --- Left: similarity heatmap ---
im = axes[0].imshow(sim, cmap="viridis", vmin=0, vmax=1, origin="lower")
axes[0].set_xlabel("Position j", fontsize=11)
axes[0].set_ylabel("Position i", fontsize=11)
axes[0].set_title("Cosine similarity of PE vectors", fontsize=12, weight="bold")
plt.colorbar(im, ax=axes[0], fraction=0.04, pad=0.04)

# --- Right: similarity vs distance from pos 0 ---
ref = 0
axes[1].plot(sim[ref, :], color="#6d28d9", lw=2.2, label=f"reference pos = {ref}")
axes[1].plot(sim[T//2, :], color="#db2777", lw=2.2, ls="--",
             label=f"reference pos = {T//2}")
axes[1].set_xlabel("Position", fontsize=11)
axes[1].set_ylabel("Cosine similarity to reference", fontsize=11)
axes[1].set_title("Similarity decays with distance", fontsize=12, weight="bold")
axes[1].legend(fontsize=10)
axes[1].axhline(0, color="#cbd5e1", lw=0.8, ls=":")
axes[1].set_ylim(-0.15, 1.1)

plt.tight_layout()
plt.show()

Cosine similarity between every pair of positional encodings. The diagonal band shows that nearby positions are most similar, giving the model a sense of ‘closeness’ even before any learning.

Example 1: key design choice

What we see:

  • The diagonal band in the heatmap confirms that nearby positions are most similar.
  • Similarity from position 0 decays quickly at first then oscillates; from the midpoint it decays symmetrically in both directions.
  • No two positions have identical encodings (uniqueness guarantee).

Design choice: \(d_{\text{model}}\) must be large enough that the encodings are diverse.

  • For very small \(d\) (say, 4), the sinusoidal frequencies are coarse and many positions may end up with similar encodings.
  • In practice, \(d_{\text{model}} \geq 64\) gives sufficient diversity even for long sequences.

Sinusoidal PE does not prevent position confusion

A model trained on sequences of length ≤ 512 may behave unexpectedly on length 1024, because it has never seen those PE values in that combination with the data. This is one reason practitioners often cap sequence lengths or use relative position encodings (e.g., RoPE, ALiBi) in very large models.

Example 2: a small transformer encoder in PyTorch

Task: classify synthetic sequences by whether the sum of tokens is above or below the median. The transformer encoder reads the whole sequence and pools to a single label.

Code
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
import matplotlib.pyplot as plt

# ── 1. Reproducibility ────────────────────────────────────────────────────────
torch.manual_seed(42); np.random.seed(42)

# ── 2. Synthetic dataset ───────────────────────────────────────────────────────
# Each sequence: 20 integers in {1..10}, label = (sum > median of training sums)
N, T, vocab = 2000, 20, 10
seqs = np.random.randint(1, vocab + 1, size=(N, T))
sums = seqs.sum(axis=1)
threshold = np.median(sums[:int(0.8 * N)])   # computed on train sums only
labels = (sums > threshold).astype(int)

X = torch.tensor(seqs,   dtype=torch.long)
y = torch.tensor(labels, dtype=torch.long)

# Train / val / test  (70 / 15 / 15)
n_tr, n_va = int(0.70 * N), int(0.15 * N)
X_tr, y_tr = X[:n_tr],       y[:n_tr]
X_va, y_va = X[n_tr:n_tr+n_va], y[n_tr:n_tr+n_va]
X_te, y_te = X[n_tr+n_va:],  y[n_tr+n_va:]

tr_loader = DataLoader(TensorDataset(X_tr, y_tr), batch_size=64, shuffle=True)
va_loader = DataLoader(TensorDataset(X_va, y_va), batch_size=128)
te_loader = DataLoader(TensorDataset(X_te, y_te), batch_size=128)

print(f"Train: {len(X_tr)}, Val: {len(X_va)}, Test: {len(X_te)}")
print(f"Positive rate (train): {y_tr.float().mean():.3f}")
Train: 1400, Val: 300, Test: 300
Positive rate (train): 0.469

Example 2: model definition

Code
# ── 3. Sinusoidal PE helper ────────────────────────────────────────────────────
def make_pe(T, d_model):
    pos   = torch.arange(T).unsqueeze(1).float()
    i     = torch.arange(d_model // 2).unsqueeze(0).float()
    denom = 10000 ** (2 * i / d_model)
    pe    = torch.zeros(T, d_model)
    pe[:, 0::2] = torch.sin(pos / denom)
    pe[:, 1::2] = torch.cos(pos / denom)
    return pe.unsqueeze(0)          # (1, T, d_model) — broadcast over batch

# ── 4. Small Transformer encoder ──────────────────────────────────────────────
class TinyTransformerEncoder(nn.Module):
    def __init__(self, vocab_size, d_model=64, nhead=4, d_ff=128,
                 num_layers=2, max_len=30, n_classes=2, dropout=0.1):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size + 1, d_model, padding_idx=0)
        self.register_buffer("pe", make_pe(max_len, d_model))
        layer = nn.TransformerEncoderLayer(d_model=d_model, nhead=nhead,
                                           dim_feedforward=d_ff,
                                           dropout=dropout, batch_first=True)
        self.encoder = nn.TransformerEncoder(layer, num_layers=num_layers)
        self.classifier = nn.Linear(d_model, n_classes)
        self.drop = nn.Dropout(dropout)

    def forward(self, x):
        T = x.size(1)
        emb = self.embedding(x) + self.pe[:, :T, :]   # (B, T, d_model)
        emb = self.drop(emb)
        h   = self.encoder(emb)                        # (B, T, d_model)
        pooled = h.mean(dim=1)                         # mean pooling
        return self.classifier(pooled)                 # (B, n_classes)

model = TinyTransformerEncoder(vocab_size=vocab, d_model=64, nhead=4,
                                num_layers=2, d_ff=128)
n_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Trainable parameters: {n_params:,}")
Trainable parameters: 67,778

Example 2: training and evaluation

Code
device = "cuda" if torch.cuda.is_available() else "cpu"
model  = model.to(device)
opt    = optim.Adam(model.parameters(), lr=1e-3)
crit   = nn.CrossEntropyLoss()

def run_epoch(loader, train=True):
    if train: model.train()
    else:     model.eval()
    total_loss, correct, total = 0.0, 0, 0
    ctx = torch.no_grad() if not train else torch.enable_grad()
    with ctx:
        for xb, yb in loader:
            xb, yb = xb.to(device), yb.to(device)
            logits = model(xb)
            loss   = crit(logits, yb)
            if train:
                opt.zero_grad(); loss.backward(); opt.step()
            total_loss += loss.item() * len(yb)
            correct    += (logits.argmax(1) == yb).sum().item()
            total      += len(yb)
    return total_loss / total, correct / total

n_epochs = 30
tr_accs, va_accs = [], []
for ep in range(1, n_epochs + 1):
    tl, ta = run_epoch(tr_loader, train=True)
    _, va   = run_epoch(va_loader, train=False)
    tr_accs.append(ta); va_accs.append(va)

# Test
_, te_acc = run_epoch(te_loader, train=False)

# ── Plot ───────────────────────────────────────────────────────────────────────
fig, ax = plt.subplots(figsize=(9, 4.2))
ax.plot(range(1, n_epochs+1), tr_accs, color="#7c3aed", lw=2.2, label="Train acc")
ax.plot(range(1, n_epochs+1), va_accs, color="#d97706", lw=2.2, ls="--", label="Val acc")
ax.axhline(te_acc, color="#dc2626", lw=1.6, ls=":", label=f"Test acc = {te_acc:.3f}")
ax.set_xlabel("Epoch", fontsize=11)
ax.set_ylabel("Accuracy", fontsize=11)
ax.set_title("Tiny Transformer Encoder — sequence sum classification", fontsize=12, weight="bold")
ax.set_ylim(0.45, 1.05)
ax.legend(fontsize=10)
ax.grid(axis="y", alpha=0.35)
plt.tight_layout()
plt.show()

print(f"\nFinal test accuracy: {te_acc:.4f}")

Training and validation accuracy curves for the small transformer encoder. The model quickly learns to classify sequences by their aggregate sum, demonstrating that even two encoder layers with mean pooling can capture a global sequence property.

Final test accuracy: 0.9733

Example 2: attention pattern inspection

Code
import matplotlib.pyplot as plt
import torch

model.eval()

# Hook to capture attention weights from the first TransformerEncoderLayer
attn_weights = {}
def hook_fn(module, inp, out):
    # TransformerEncoderLayer output is just the hidden states;
    # we re-run attn manually for inspection
    pass

# Use PyTorch's built-in with need_weights=True
def get_attn(model, x):
    """Return (output, avg_attn) where avg_attn averages over heads."""
    x_t = x.unsqueeze(0)                           # (1, T)
    T   = x_t.size(1)
    emb = model.embedding(x_t) + model.pe[:, :T, :]
    layer = model.encoder.layers[0]
    # self_attn forward with need_weights
    with torch.no_grad():
        _, w = layer.self_attn(emb, emb, emb, need_weights=True,
                               average_attn_weights=True)
    return w[0].detach().numpy()   # (T, T)

fig, axes = plt.subplots(1, 2, figsize=(11, 4.5))
cmap = "YlOrRd"

for ax, idx, prefix in [(axes[0], 0, "Example A"), (axes[1], 5, "Example B")]:
    x_ex   = X_te[idx]
    w      = get_attn(model, x_ex)
    tokens = x_ex.numpy().tolist()

    im = ax.imshow(w, cmap=cmap, vmin=0, vmax=2.5/T, aspect="equal")
    plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label="weight")
    tick_pos = list(range(0, T, 2))          # every other position: 0,2,4,...
    ax.set_xticks(tick_pos)
    ax.set_xticklabels([str(p) for p in tick_pos], fontsize=8)
    ax.set_yticks(tick_pos)
    ax.set_yticklabels([str(p) for p in tick_pos], fontsize=8)
    ax.set_xlabel("Key position", fontsize=9)
    ax.set_ylabel("Query position", fontsize=9)

    lbl = "above" if y_te[idx].item() == 1 else "below"
    ax.set_title(f"{prefix}: sum={sum(tokens)}, label={lbl}", fontsize=10, weight="bold")

plt.suptitle("Layer-0 self-attention weights (averaged over 4 heads)", fontsize=11)
plt.tight_layout()
plt.show()

Averaged self-attention weights across all heads in layer 0, for two test sequences. Because the task depends on the sum of tokens (a global property), attention tends to spread relatively uniformly — each token needs to ‘know’ about all others.

Example 2: interpretation

What we observe:

  • The encoder achieves good test accuracy on this synthetic task despite having no recurrence.
  • Attention patterns are relatively uniform because the task requires integrating all token values — a global statistic.
  • Mean pooling over encoder outputs distils this global information into a single classification vector.
  • Two encoder layers + 4 heads are more than sufficient here; the model converges quickly.

Design choices that matter:

  • Positional encoding: removing it barely affects this task (sum is order-invariant), but it would matter for an order-sensitive task like “is the sequence monotonically increasing?”
  • Pooling strategy: mean vs [CLS] token pooling can differ; for global statistics mean pooling works well.

What this example does not show

This task is easy and well-specified. Real NLP tasks involve much longer sequences, larger vocabularies, ambiguous semantics, and pre-training requirements that a tiny from-scratch model cannot address.

The goal here is to confirm that the transformer machinery works as described — not to claim it is competitive with real language models.

🔬 parameter count in a transformer block

For a single encoder block with \(d_{\text{model}}\), \(h\) heads, and \(d_{ff}\):

Sub-layer Parameters
Multi-head attention \((W^Q, W^K, W^V, W^O)\) \(4 d_{\text{model}}^2\)
FFN \((W_1, b_1, W_2, b_2)\) \(2 d_{\text{model}} d_{ff} + d_{\text{model}} + d_{ff}\)
Layer norm (\(\gamma, \beta\), ×2) \(4 d_{\text{model}}\)
Total per block \(\approx 4 d_{\text{model}}^2 + 2 d_{\text{model}} d_{ff}\)

For \(d_{\text{model}} = 768\), \(d_{ff} = 3072\) (BERT-base):

\[ 4 \times 768^2 + 2 \times 768 \times 3072 \approx 2.36M + 4.72M \approx 7.1M\text{ per block}. \]

🔗BERT-base has 12 such blocks: \(\approx 85M\) parameters (plus embeddings).

Note

Most parameters in a transformer live in the FFN, not in the attention mechanism.

Summary

Ethics and deployment cautions

Transformers power most modern production NLP and vision systems. The scale at which they are deployed amplifies both benefits and risks:

  • Bias amplification: models trained on large crawled text reproduce and reinforce stereotypes from that text. Multi-head attention does not selectively avoid stereotyped associations.
  • Memorization: encoder–decoder models can memorize and reproduce verbatim training sequences, including private data such as email addresses or medical text.
  • Environmental cost: training large transformers consumes substantial energy. Even inference is costly at deployment scale.
  • Misuse of cross-attention: in multimodal models, cross-attention between text and images can be exploited to produce misleading content (e.g., caption attacks, adversarial images).

Simple mitigations at a student level:

  • Evaluate on diverse subgroups, not just aggregate accuracy.
  • Prefer fine-tuning small pre-trained models over training large ones from scratch.
  • Inspect what training data is used before deploying a model.

Positional encoding ≠ temporal reasoning

A model that encodes position can still fail at tasks requiring genuine temporal or causal reasoning. Position encoding tells the model where a token is; it does not give the model an understanding of time, causality, or logical order.

Do not conflate architectural features with cognitive abilities.

Interpretability caution

Cross-attention heatmaps in encoder–decoder models are widely used to explain translation decisions. As with self-attention (M52), they are diagnostics, not causal explanations. Be cautious in high-stakes settings.

Why transformers changed the landscape

The original Transformer paper (Vaswani et al. 2017) introduced a set of engineering decisions that, in combination, unlocked scale:

  1. Full parallelism — unlike RNNs, attention over the whole sequence can be computed simultaneously on GPU/TPU hardware.
  2. \(\mathcal{O}(1)\) interaction path — any two tokens interact directly, regardless of distance.
  3. Modular stacking — encoder and decoder blocks are similar in structure and ‘stackable’; adding more is trivially supported by the architecture.
  4. Scalable pre-training — decoder-only transformers trained on next-token prediction at scale produce representations that transfer broadly (GPT, LLaMA).

This is why the transformer is now the default architecture not just in NLP (Brown et al. 2020) but also in vision (Dosovitskiy et al. 2021), audio (Baevski et al. 2020), protein structure (Jumper et al. 2021), and scientific computing (Han et al. 2022).

Quick checks

Q1 A transformer model uses \(d_{\text{model}} = 256\) and \(h = 8\) heads. What is the standard per-head dimension \(d_k\)?

A. 256
B. 32
C. 8
D. 2048


Q2 You remove positional encoding from a transformer encoder and ask it to classify whether the sequence “1, 2, 3, 4, 5” is increasing. Which statement is most accurate?

A. Performance is unaffected because the attention mechanism inherently tracks order.
B. Performance may drop because the model cannot distinguish “1, 2, 3, 4, 5” from “3, 1, 5, 2, 4”.
C. Performance improves because less noise is added to the embeddings.
D. The model cannot be trained without positional encoding.


Q3 In the decoder of an encoder–decoder transformer, what are the queries in the cross-attention sub-layer derived from?

A. The encoder output states
B. The decoder’s own previous sub-layer output
C. The target vocabulary embeddings only
D. The positional encoding matrix


Q4 Short answer: a colleague claims “adding more attention heads always improves performance.” Give one reason this is not generally true.

References

Baevski, Alexei, Yining Zhou, Abdelrahman Mohamed, and Michael Auli. 2020. “Wav2vec 2.0: A Framework for Self-Supervised Learning of Speech Representations.” In Advances in Neural Information Processing Systems, 33:12449–60. Curran Associates, Inc. https://proceedings.neurips.cc/paper/2020/hash/92d1e1eb1cd6f9fba3227870bb6d7f07-Abstract.html.
Brown, Tom B., Benjamin Mann, Nick Ryder, Melanie Subbiah, Jared Kaplan, Prafulla Dhariwal, Arvind Neelakantan, et al. 2020. “Language Models Are Few-Shot Learners.” In Advances in Neural Information Processing Systems, 33:1877–1901. https://arxiv.org/abs/2005.14165.
Devlin, Jacob, Ming-Wei Chang, Kenton Lee, and Kristina Toutanova. 2019. “BERT: Pre-Training of Deep Bidirectional Transformers for Language Understanding.” In Proceedings of the 2019 Conference of the North American Chapter of the Association for Computational Linguistics: Human Language Technologies (NAACL-HLT), 4171–86.
Dosovitskiy, Alexey, Lucas Beyer, Alexander Kolesnikov, Dirk Weissenborn, Xiaohua Zhai, Thomas Unterthiner, Mostafa Dehghani, et al. 2021. “An Image Is Worth 16x16 Words: Transformers for Image Recognition at Scale.” In International Conference on Learning Representations. https://openreview.net/forum?id=YicbFdNTTy.
Han, Kai, Yunhe Wang, Hanting Chen, Xinghao Chen, Jianyuan Guo, Zhenhua Liu, Yehui Tang, et al. 2022. “A Survey on Vision Transformer.” IEEE Transactions on Pattern Analysis and Machine Intelligence 45 (1): 87–110. https://doi.org/10.1109/TPAMI.2022.3152247.
Jumper, John, Richard Evans, Alexander Pritzel, Tim Green, Michael Figurnov, Olaf Ronneberger, Kathryn Tunyasuvunakool, et al. 2021. “Highly Accurate Protein Structure Prediction with AlphaFold.” Nature 596: 583–89. https://doi.org/10.1038/s41586-021-03819-2.
Michel, Paul, Omer Levy, and Graham Neubig. 2019. “Are Sixteen Heads Really Better Than One?” In Advances in Neural Information Processing Systems, 32:14037–47. Curran Associates, Inc. https://proceedings.neurips.cc/paper/2019/hash/2c601ad9d2ff9bc8b282670cdd54f69f-Abstract.html.
Radford, Alec, Jeffrey Wu, Rewon Child, David Luan, Dario Amodei, and Ilya Sutskever. 2019. “Language Models Are Unsupervised Multitask Learners.” OpenAI. https://openai.com/blog/better-language-models.
Vaswani, Ashish, Noam Shazeer, Niki Parmar, Jakob Uszkoreit, Llion Jones, Aidan N. Gomez, Łukasz Kaiser, and Illia Polosukhin. 2017. “Attention Is All You Need.” In Advances in Neural Information Processing Systems (NeurIPS), 5998–6008. https://papers.nips.cc/paper/7181-attention-is-all-you-need.