← Back to Home

Attention from scratch: NumPy vs PyTorch

TransformerAttentionPyTorch

Most people stall on the same page of the Transformer paper: Attention(Q, K, V) = softmax(QK^T / sqrt(d_k))V. You can memorize the formula and still not be able to write it. Where do Q, K, V come from? Which step divides by d_k? Does the mask go before or after the softmax? Why is the reshape in multi-head attention ordered the way it is?

I tested this the slow way: I wrote the whole thing from scratch in NumPy, then ran it against PyTorch 2.14.1 scaled_dot_product_attention and nn.MultiheadAttention with identical weights and identical inputs. The largest absolute difference between my version and PyTorch's was 2.220446049250313e-16, which is floating-point noise. Every number below was produced on my own Windows CPU on October 12, 2026, and the code is copy-paste runnable.

Disclosure: there are no affiliate links in this article and nothing here is sponsored.

TL;DR

The division is the part worth reading twice

Section 3.2.1 of "Attention Is All You Need" (arXiv:1706.03762, v1 submitted June 12, 2017; version v7 cited here revised August 2, 2023) states the formula as:

Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V

The same section gives the reason, in the authors' words: "We suspect that for large values of d_k, the dot products grow large in magnitude, pushing the softmax function into regions where it has extremely small gradients."

The footnote does the math: if the components of q and k are independent with mean 0 and variance 1, then q·k = Σ q_i·k_i has mean 0 and variance d_k. Higher dimension, larger dot products, and the softmax gets pushed into a region with almost no gradient — the output is nearly one-hot and backpropagation has almost nothing to work with.

Section 3.2.2 gives the base configuration: d_model = 512, h = 8 heads, d_k = d_v = d_model / h = 64, N = 6 identical layers in each stack, dropout rate 0.1.

What happens without it: watching softmax collapse

Rather than restate the claim, I ran it. This generates 2,000 keys for d_k = 16 / 64 / 512 and compares the softmax shape with and without scaling:

import numpy as np

def softmax(x):
    x = x - x.max()
    e = np.exp(x)
    return e / e.sum()

for dk in (16, 64, 512):
    r = np.random.default_rng(7)
    q = r.normal(size=(dk,))
    k = r.normal(size=(2000, dk))
    dots = k @ q
    w_raw = softmax(dots)
    w_scl = softmax(dots / np.sqrt(dk))
    print(f"dk={dk:4d}  dot std={dots.std():6.2f}  "
          f"max weight unscaled={w_raw.max():.6f}  max weight scaled={w_scl.max():.6f}  "
          f"effective terms unscaled={1/np.sum(w_raw**2):7.2f}  scaled={1/np.sum(w_scl**2):7.2f}")

Output on my machine:

dk=  16  dot std=  2.47  max weight unscaled=0.227685  max weight scaled=0.004241  effective terms unscaled=  14.85  scaled=1341.59
dk=  64  dot std=  7.32  max weight unscaled=0.921946  max weight scaled=0.009399  effective terms unscaled=   1.17  scaled= 829.74
dk= 512  dot std= 20.99  max weight unscaled=0.933175  max weight scaled=0.005796  effective terms unscaled=   1.15  scaled= 917.82

"Effective terms" is my own metric, defined as 1 / Σ w_i², read as "how many of those 2,000 keys are actually being attended to."

The d_k=64 row makes the point: unscaled, the max weight is 92.19% and effective terms is 1.17 — across a 2,000-token sequence, attention lands almost entirely on one position and the gradient for the rest is close to zero. Scaled, the max weight drops to 0.94% and effective terms rises to 829.74, so the head is actually aggregating. At d_k=16 the gap is smaller (14.85 vs 1341.59), which lines up with the paper's own "for small values of d_k the two mechanisms perform similarly."

The minimal runnable implementation: under 40 lines of NumPy

import numpy as np

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

def scaled_dot_product_attention(Q, K, V, mask=None):
    """Q:(..., Tq, dk)  K:(..., Tk, dk)  V:(..., Tk, dv)
    mask: bool, True means this position gets masked out (replaced by -inf)
    """
    dk = Q.shape[-1]
    scores = Q @ np.swapaxes(K, -1, -2) / np.sqrt(dk)
    if mask is not None:
        scores = np.where(mask, -np.inf, scores)
    w = softmax(scores, axis=-1)
    return w @ V, w

That is a one-to-one translation of the paper's equation, plus np.where(mask, -np.inf, scores) and the max subtraction inside softmax (numerical stability, not decoration).

With 5 tokens and d_k=4 (seed fixed at 20261012), the first row of attention weights comes out as:

[0.027843 0.167136 0.246698 0.211041 0.347282]

Five positions, five weights, summing to 1. I asserted the row sums: the largest deviation from 1.0 was 1.1102230246251565e-16. Measure this yourself — if you ever see that deviation at 1e-3, suspect the softmax axis before you suspect NumPy.

Multi-head: split, attend separately, concatenate

Section 3.2.2 describes it as projecting Q, K, V h times with different learned projections down to d_k, d_k, d_v dimensions, attending in parallel, concatenating, and projecting once more. In code that is a reshape plus a transpose:

def multi_head_attention(X, WQ, WK, WV, WO, h, causal=False):
    T, d_model = X.shape
    dh = d_model // h
    Q, K, V = X @ WQ, X @ WK, X @ WV
    # (T, d_model) -> (h, T, dh)
    Qh = Q.reshape(T, h, dh).transpose(1, 0, 2)
    Kh = K.reshape(T, h, dh).transpose(1, 0, 2)
    Vh = V.reshape(T, h, dh).transpose(1, 0, 2)
    mask = np.triu(np.ones((T, T), dtype=bool), k=1) if causal else None
    heads, w = scaled_dot_product_attention(Qh, Kh, Vh, mask=mask)
    # (h, T, dh) -> (T, d_model)
    return heads.transpose(1, 0, 2).reshape(T, d_model) @ WO, w

With 5 tokens, d_model=16, h=4: output shape (5, 16), weights shape (4, 5, 5) — one 5×5 attention table per head.

One thing worth knowing about reshape(T, h, dh): it slices along the last axis in order, so head 0 gets columns 0..dh-1. That is why WQ, WK, WV should stay full d_model × d_model matrices and let each head learn its own block, rather than pre-slicing the weights into h pieces and initializing them separately. The latter runs, but it is not what the paper describes.

Section 3.2.3 also lists three uses of attention: encoder self-attention (Q, K, V from the same place), decoder self-attention with the causal mask, and encoder-decoder attention (Q from the previous decoder layer, K and V from the encoder output stack). The implementation above passes X as all three, which covers the first two; for the third, hand it the encoder output as K and V and the function needs no changes.

Causal masking: the paper says -inf, not 0

Section 3.2.3 is blunt about it: "We need to prevent leftward information flow in the decoder to preserve the auto-regressive property. We implement this inside of scaled dot-product attention by masking out (setting to −∞) all values in the input of the softmax which correspond to illegal connections."

Two facts in that sentence: the mask applies to the input of the softmax, and the value is −∞.

I verified it on the same 5-token example with a causal mask: the largest weight at a masked position was 0.0, and row 2 had only 3 positions with weight above zero, meaning position 2 can see 0, 1, 2 — matching "up to and including that position."

If your masked positions come out as 1e-9 rather than exactly 0, you used a float mask with -1e9 instead of -inf. It works numerically, but they are different things: -inf makes exp produce exactly 0, -1e9 only makes it produce a very small number.

Checking my code against PyTorch

My NumPy version and F.scaled_dot_product_attention, same weights and inputs in float64: max absolute difference 2.220446049250313e-16. Loading WQ/WK/WV into nn.MultiheadAttention's in_proj_weight and WO into out_proj.weight and running the full multi-head path: max absolute difference 4.440892098500626e-16.

One honest performance number from the same CPU: at T=128, d=64, the per-row for-loop version took 5.669 ms and the matrix version 1.050 ms, 5.4x faster, with outputs differing by 4.00e-15. The loop version is not wrong, it just splits one matrix multiply into 128 pieces — which is what the paper means by dot-product attention being "implemented using highly optimized matrix multiplication code."

Here is the trap. The PyTorch 2.14 docs flag it in a Note on the scaled_dot_product_attention page: boolean mask semantics are the inverse of MultiheadAttention's key_padding_mask. In scaled_dot_product_attention, True means the element takes part in attention; in MultiheadAttention, True means masked out.

I ran both ways with the same causal mask:

correct convention (inverted before passing): 2.220446049250313e-16
reversed convention (passed as-is):          3.668259537831219

A difference of 3.67, and no error is raised — the model quietly attends to future tokens. If you take one thing from this article, take that.

Troubleshooting: five real errors

All five were captured while I was writing this code, copied verbatim. They are not illustrative examples.

Error 1: forgot to transpose K (NumPy)

ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0,
with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 5 is different from 4)

Q is (5, 4) and so is K, so Q @ K cannot work. Write Q @ K.T, or np.swapaxes(K, -1, -2) for batched input. In self-attention Q and K have identical shapes, so the error never says "transpose" — if you see "size 5 is different from 4", check the transpose first.

Error 2: forgot to transpose K (PyTorch)

RuntimeError: mat1 and mat2 shapes cannot be multiplied (5x4 and 5x4)

Same mistake, more direct message. Build the habit of writing K.transpose(-2, -1) in full.

Error 3: is_causal and attn_mask at the same time

RuntimeError: _scaled_dot_product_attention: Explicit attn_mask should not be set when is_causal=True

The docs state "An error is thrown if both attn_mask and is_causal are set." Pick one: is_causal=True for a standard square causal mask, or pass attn_mask for non-square or custom masks and leave is_causal at its default.

Error 4: exp underflows in float32 and softmax returns nan

dot = -1802.824
exp(dot) = 0.0
e/e = nan
RuntimeWarning: invalid value encountered in scalar divide

Measured with 1024-dimensional float32 vectors scaled by 10. The dot product is -1802.824, exp underflows to 0.0, numerator and denominator are both zero, and the division gives nan. With a large positive dot product you get inf instead, and inf/inf is nan too. The fix is not float64, it is subtracting the max before exp: after x - x.max() the largest argument to exp is 0, so exp(0)=1 and the denominator is at least 1. The implementation above already does this.

Error 5: wrong softmax axis — silent, and wrong

axis=0 row sums:    [0.838357 1.937897 0.662364 0.992172 0.569209]
axis=-1 row sums:   [1. 1. 1. 1. 1.]
output difference:  1.2147038513899666

np.exp(s) / np.exp(s).sum(axis=0) runs fine, warns about nothing, and has the right shape — it just normalizes over columns instead of rows. The check is one line: assert that w.sum(axis=-1) is all ones. This is the case where an assertion is not optional, because nothing else will tell you.

Where to go next

FAQ

Q1: Can I train with the from-scratch version?

No, and you shouldn't want to. Its value is confirming that you understood the algorithm: matching PyTorch to 1e-16 means the formula, axis order, and mask placement are all right. For training, use F.scaled_dot_product_attention, which selects among FlashAttention, Memory-Efficient attention, and the C++ implementation (the three listed in the docs), and supports GQA.

Q2: Why is my difference 1e-7 instead of 1e-16?

Check the dtype first. Everything above was measured in float64; float32 lands around 1e-7, which is expected. The docs also note that fused floating-point kernels can produce different results from each other, and that the C++ implementation supports float64.

Q3: Can I use -1e9 instead of -inf in the mask?

It runs, but it is not exact. -inf makes exp return exactly 0; -1e9 leaves a tiny non-zero value in float32. The 0.0 I measured at masked positions came from using -inf. Also note that PyTorch's float attn_mask is added to the scores, so adding -inf is what masks.

Q4: Must d_k equal d_model / h?

That is how the paper sets it (d_k = d_v = d_model/h = 64, h=8, d_model=512), with the stated reason that with the reduced per-head dimension, "the total computational cost is similar to that of single-head attention with full dimensionality." It is a convention, not a mathematical requirement, and modern models do deviate.

Sources and how the numbers were produced

The formula, the justification for dividing by sqrt(d_k), h=8 / d_k=d_v=64 / d_model=512 / N=6 / dropout 0.1, and the -inf masking all come from sections 3.2.1–3.2.3 of arXiv:1706.03762, "Attention Is All You Need" (v1 June 12, 2017; v7 cited here, August 2, 2023).

Every measured number (2.22e-16, 4.44e-16, 0.921946, effective terms 1.17, the 5.4x timing, 3.668259537831219, and the five error messages) was produced on my Windows CPU on October 12, 2026 with Python 3.13, NumPy 2.5.4, and PyTorch 2.14.1+cpu. Seeds are hardcoded in the snippets, so the runs are reproducible.

The boolean mask semantics, the is_causal/attn_mask exclusivity, and the backend selection statements come from the PyTorch 2.14 documentation page for scaled_dot_product_attention, cross-checked against what I measured locally.

No affiliate links and no product recommendations here. The timing numbers depend on CPU model and load and will differ on your machine; they are here to show the order of magnitude, not to serve as a benchmark.

👉 Join Xiaomi MiMo Platform: Leading AI model platform with cost-effective inference

👉 Join Aliyun AI: Top AI products with exclusive coupons for business innovation

📌 This article was AI-assisted generated and human-reviewed | TechPassive — An AI-driven content testing site focused on real tool reviews

🔗 Recommended Tools

These are carefully selected tools. Using our affiliate links supports us to keep producing quality content:

☁️ DigitalOcean Cloud ⚡ Vultr VPS 🤖 QoderWork CN (Refer & Earn) ☁️ Aliyun AI Products 📚 WordPress Books 🔍 WordPress SEO Books 🌐 Web Hosting Books 🐳 Docker Books 🐧 Linux Books 🐍 Python Books 💰 Affiliate Marketing 💵 Passive Income Books 🖥️ Server Books ☁️ Cloud Computing Books 🚀 DevOps Books 🤖 Xiaomi MiMo Platform
← Back to Home