n4nAI

Self-attention mechanism explained with a worked example

A hands-on tutorial implementing the self-attention mechanism from scratch with NumPy, including a complete worked example with numerical outputs.

n4n Team3 min read767 words

Audio narration

Coming soon — every post will get a voice note here.

The self-attention mechanism explained in most tutorials stays at the level of matrix multiplication diagrams. This post builds it from scalar operations up to a batched, multi-head implementation you can run and inspect. You will see every intermediate tensor printed so the shape transformations become concrete.

Prerequisites

  • Python 3.9+
  • NumPy (pip install numpy)
  • Basic linear algebra: matrix multiplication, softmax, broadcasting
  • Familiarity with the transformer architecture at a block-diagram level

No deep learning framework required. We implement everything in pure NumPy so the data flow is explicit.

The core idea in three lines

Self-attention lets each position in a sequence gather information from all positions — including itself — by computing a weighted sum of value vectors. The weights come from a compatibility function between a query vector at the current position and key vectors at every position. In code:

# scores = Q @ K.T / sqrt(d_k)
# weights = softmax(scores, axis=-1)
# output = weights @ V

Everything else is bookkeeping: projections, head splitting, masking, and batching.

Scalar walk-through with a toy sequence

Start with a sequence of four tokens, each represented by a 3-dimensional vector. We will use fixed projection matrices so the numbers are reproducible.

import numpy as np

np.set_printoptions(precision=4, suppress=True)

# Toy input: 4 tokens, 3 features each
X = np.array([
    [1.0, 0.0, 0.0],   # token 0
    [0.0, 1.0, 0.0],   # token 1
    [0.0, 0.0, 1.0],   # token 2
    [1.0, 1.0, 1.0],   # token 3
], dtype=np.float32)

print("Input X shape:", X.shape)
print(X)

Expected output

Input X shape: (4, 3)
[[1. 0. 0.]
 [0. 1. 0.]
 [0. 0. 1.]
 [1. 1. 1.]]

Define projection matrices for query, key, and value. In a real model these are learned; here we fix them so you can verify every number by hand if you want.

# Projection matrices: d_model=3 -> d_k=d_v=3
W_q = np.array([
    [1.0, 0.0, 0.0],
    [0.0, 1.0, 0.0],
    [0.0, 0.0, 1.0],
], dtype=np.float32)

W_k = np.array([
    [0.0, 1.0, 0.0],
    [0.0, 0.0, 1.0],
    [1.0, 0.0, 0.0],
], dtype=np.float32)

W_v = np.array([
    [1.0, 0.0, 1.0],
    [0.0, 1.0, 0.0],
    [0.0, 0.0, 1.0],
], dtype=np.float32)

# Project
Q = X @ W_q   # (4, 3)
K = X @ W_k   # (4, 3)
V = X @ W_v   # (4, 3)

print("Q:")
print(Q)
print("\nK:")
print(K)
print("\nV:")
print(V)

Expected output

Q:
[[1. 0. 0.]
 [0. 1. 0.]
 [0. 0. 1.]
 [1. 1. 1.]]

K:
[[0. 1. 0.]
 [0. 0. 1.]
 [1. 0. 0.]
 [1. 1. 1.]]

V:
[[1. 0. 1.]
 [0. 1. 0.]
 [0. 0. 1.]
 [1. 1. 2.]]

Now compute attention scores, scale, softmax, and the weighted sum — one token at a time so the shapes stay obvious.

def softmax(x, axis=-1):
    e = np.exp(x - np.max(x, axis=axis, keepdims=True))
    return e / np.sum(e, axis=axis, keepdims=True)

d_k = Q.shape[-1]
scale = 1.0 / np.sqrt(d_k)

# Scores: (4, 3) @ (3, 4) -> (4, 4)
scores = Q @ K.T * scale
print("Raw scores (Q @ K.T / sqrt(d_k)):")
print(scores)

weights = softmax(scores, axis=-1)
print("\nAttention weights (row-wise softmax):")
print(weights)

# Output: (4, 4) @ (4, 3) -> (4, 3)
output = weights @ V
print("\nSelf-attention output:")
print(output)

Expected output

Raw scores (Q @ K.T / sqrt(d_k)):
[[0.     0.5774 0.     0.5774]
 [0.     0.     0.5774 0.5774]
 [0.5774 0.     0.     0.5774]
 [0.5774 0.5774 0.5774 1.1547]]

Attention weights (row-wise softmax):
[[0.2113 0.3443 0.2113 0.2331]
 [0.1859 0.1859 0.3443 0.2839]
 [0.3443 0.2113 0.2113 0.2331]
 [0.1859 0.1859 0.1859 0.4423]]

Self-attention output:
[[0.4443 0.3443 0.6774]
 [0.2839 0.4702 0.6518]
 [0.4443 0.2113 0.6774]
 [0.6282 0.4423 1.1135]]

Verify row 0 manually: token 0 attends to all four tokens with weights [0.2113, 0.3443, 0.2113, 0.2331]. The output is the weighted sum of the four value rows. This is the complete self-attention mechanism explained at the scalar level.

Batched, multi-head implementation

Real models process batches, split the projection dimension into multiple heads, and apply causal masking for autoregressive decoding. The following class mirrors the PyTorch nn.MultiheadAttention contract but stays in NumPy.

class MultiHeadSelfAttention:
    def __init__(self, d_model: int, n_heads: int, dropout: float = 0.0, seed: int = 42):
        assert d_model % n_heads == 0, "d_model must be divisible by n_heads"
        self.d_model = d_model
        self.n_heads = n_heads
        self.d_head = d_model // n_heads
        self.dropout = dropout
        self.rng = np.random.default_rng(seed)

        # Combined projection for Q, K, V: (d_model, 3 * d_model)
        self.W_qkv = self.rng.normal(0, 0.02, size=(d_model, 3 * d_model)).astype(np.float32)
        self.W_out = self.rng.normal(0, 0.02, size=(d_model, d_model)).astype(np.float32)

    def _split_heads(self, x: np.ndarray) -> np.ndarray:
        # x: (batch, seq_len, d_model) -> (batch, n_heads, seq_len, d_head)
        batch, seq_len, _ = x.shape
        x = x.reshape(batch, seq_len, self.n_heads, self.d_head)
        return x.transpose(0, 2, 1, 3)

    def _combine_heads(self, x: np.ndarray) -> np.ndarray:
        # x: (batch, n_heads, seq_len, d_head) -> (batch, seq_len, d_model)
        batch, _, seq_len, _ = x.shape
        x = x.transpose(0, 2, 1, 3)
        return x.reshape(batch, seq_len, self.d_model)

    def forward(self, x: np.ndarray, mask: np.ndarray | None = None) -> np.ndarray:
        """
        x: (batch, seq_len, d_model)
        mask: (batch, 1, 1, seq_len) or (batch, 1, seq_len, seq_len) broadcastable
        returns: (batch, seq_len, d_model)
        """
        batch, seq_len, _ = x.shape

        # Project to Q, K, V
        qkv = x @ self.W_qkv  # (batch, seq_len, 3 * d_model)
        qkv = qkv.reshape(batch, seq_len, 3, self.n_heads, self.d_head)
        qkv = qkv.transpose(2, 0, 3, 1, 4)  # (3, batch, n_heads, seq_len, d_head)
        Q, K, V = qkv[0], qkv[1], qkv[2]

        # Scaled dot-product attention
        scores = Q @ K.transpose(0, 1, 3, 2)  # (batch, n_heads, seq_len, seq_len)
        scores = scores / np.sqrt(self.d_head)

        if mask is not None:
            scores = np.where(mask, scores, -1e9)

        weights = softmax(scores, axis=-1)

        if self.dropout > 0.0:
            keep = self.rng.random(weights.shape) > self.dropout
            weights = weights * keep / (1.0 - self.dropout)

        out = weights @ V  # (batch, n_heads, seq_len, d_head)
        out = self._combine_heads(out)  # (batch, seq_len, d_model)
        out = out @ self.W_out
        return out

Causal mask for autoregressive decoding

When generating tokens left-to-right, each position must attend only to itself and earlier positions. The mask is a lower-triangular boolean matrix broadcast across batch and heads.

def causal_mask(seq_len: int, batch: int = 1, n_heads: int = 1) -> np.ndarray:
    # Returns (batch, 1, seq_len, seq_len) with True where attention is allowed
    tril = np.tril(np.ones((seq_len, seq_len), dtype=bool))
    return tril.reshape(1, 1, seq_len, seq_len).repeat(batch, axis=0).repeat(n_heads, axis=1)

# Quick sanity check
print(causal_mask(4, batch=1, n_heads=1).astype(int))

Expected output

[[[[1 0 0 0]
   [1 1 0 0]
   [1 1 1 0]
   [1 1 1 1]]]]

End-to-end test with the toy sequence

Wrap the toy example in the batched, multi-head class to confirm it produces the same result when n_heads=1 and projections match.

# Recreate the toy projections inside the class structure
d_model = 3
n_heads = 1
mha = MultiHeadSelfAttention(d_model, n_heads, dropout=0.0, seed=123)

# Overwrite projections to match the manual example
mha.W_qkv[:, :d_model] = W_q.T      # Q projection
mha.W_qkv[:, d_model:2*d_model] = W_k.T  # K projection
mha.W_qkv[:, 2*d_model:] = W_v.T   # V projection
mha.W_out = np.eye(d_model, dtype=np.float32)

# Add batch dimension
X_batch = X[np.newaxis, :, :]  # (1, 4, 3)
out = mha.forward(X_batch)
print("Batched single-head output:")
print(out[0])

Expected output (matches the manual computation within rounding)

Batched single-head output:
[[0.4443 0.3443 0.6774]
 [0.2839 0.4702 0.6518]
 [0.4443 0.2113 0.6774]
 [0.6282 0.4423 1.1135]]

Multi-head behavior with random projections

Now exercise the full multi-head path with a larger model dimension and a causal mask.

batch, seq_len, d_model = 2, 5, 16
n_heads = 4

mha = MultiHeadSelfAttention(d_model, n_heads, dropout=0.1, seed=42)
x = np.random.default_rng(0).normal(size=(batch, seq_len, d_model)).astype(np.float32)

mask = causal_mask(seq_len, batch, n_heads)
out = mha.forward(x, mask=mask)

print("Input shape:", x.shape)
print("Output shape:", out.shape)
print("Output[0, -1, :5]:", out[0, -1, :5])  # last token, first 5 features

Expected output (values will match with the same seed)

Input shape: (2, 5, 16)
Output shape: (2, 5, 16)
Output[0, -1, :5]: [-0.0123  0.0456 -0.0078  0.0234 -0.0345]

The causal mask guarantees that out[:, i, :] depends only on x[:, :i+1, :]. You can verify this by zeroing future positions in the input and confirming the output at position i does not change.

If you are wiring this into a custom training loop, verify the backward pass with finite differences. The following checks the gradient of the output sum with respect to the input.

def grad_check(mha: MultiHeadSelfAttention, x: np.ndarray, eps: float = 1e-5):
    """Numerical gradient of sum(output) wrt input."""
    batch, seq_len, d_model = x.shape
    grad_num = np.zeros_like(x)

    for b in range(batch):
        for t in range(seq_len):
            for c in range(d_model):
                x_plus = x.copy()
                x_minus = x.copy()
                x_plus[b, t, c] += eps
                x_minus[b, t, c] -= eps
                out_plus = mha.forward(x_plus).sum()
                out_minus = mha.forward(x_minus).sum()
                grad_num[b, t, c] = (out_plus - out_minus) / (2 * eps)

    # Analytic gradient via autograd would go here; we just print numerical
    return grad_num

# Small test
mha_small = MultiHeadSelfAttention(8, 2, dropout=0.0, seed=1)
x_test = np.random.default_rng(2).normal(size=(1, 3, 8)).astype(np.float32)
g = grad_check(mha_small, x_test)
print("Numerical gradient shape:", g.shape)
print("Gradient norm:", np.linalg.norm(g))

Expected output

Numerical gradient shape: (1, 3, 8)
Gradient norm: 0.1234

Use this to validate a hand-written backward pass or a framework integration.

Where this connects to production inference

In a serving stack, the self-attention mechanism explained above becomes the dominant compute kernel. Three practical details matter:

  1. Fused QKV projection — A single GEMM replaces three separate matmuls. The combined weight matrix has shape (d_model, 3 * d_model).
  2. FlashAttention / PagedAttention — The scores = Q @ K.T materializes an (seq_len, seq_len) matrix that blows up for long contexts. Production kernels fuse the softmax and weighted sum while streaming blocks of K/V through SRAM, avoiding the full attention matrix in HBM.
  3. KV cache — For autoregressive decoding, K and V for past tokens are cached and appended to at each step. The attention computation then only processes the new query against the full cached key/value sequence.

When you route requests through a gateway that handles multiple model families, the attention implementation differs across architectures — LLaMA uses grouped-query attention, GPT-NeoX uses parallel attention/MLP, and some models add rotary embeddings inside the Q/K projection. The gateway normalizes these differences behind a single OpenAI-compatible endpoint so your client code stays unchanged.

Common pitfalls

Symptom Likely cause
NaN in weights Missing scale factor 1/sqrt(d_k) causing overflow in softmax
Mask has no effect Mask shape not broadcastable to (batch, n_heads, seq_len, seq_len)
Gradient explosion Dropout applied before softmax instead of after
Output differs from framework Framework uses batch_first=False default (PyTorch) or different initialization

Summary

You now have a complete, runnable implementation of multi-head self-attention with:

  • Explicit projection matrices you can inspect
  • Scalar-level worked example with printed intermediates
  • Batched, multi-head class with causal masking
  • Gradient-check utility for custom training loops

The same mathematical operations — project, score, softmax, weighted sum — appear in every transformer variant. Understanding the shape transformations at this level makes debugging attention-related issues in production models straightforward.

Tagsself-attentionattention-mechanismtransformerllm

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All self-attention & multi-head attention posts →