Skip to content
GKgkml.dev
← LLM mechanisms

Part 8 · Deep learning foundations · module 1 of 3 · 22 min

The maths of deep learning

Transpose, dot products, matrix multiplication, softmax, logarithms, entropy, argmax, mean and variance, sampling, the t-test and derivatives.

The essence

Deep learning uses a small amount of mathematics very heavily. Linear algebra provides the operations — the dot product measures alignment, matrix multiplication applies many dot products at once. Probability provides the loss — entropy measures uncertainty, cross-entropy measures the cost of predicting badly. Calculus provides the learning — a derivative says which way to move a parameter.

Why it matters

Every quantity in this curriculum is one of these. Attention is a scaled dot product. Cross-entropy loss is entropy applied to a prediction. Backpropagation is the chain rule. Understanding a handful of operations well is what makes the rest read as sense rather than notation.

Topics covered (15)
  1. 01Terms and datatypes in mathematics and in computers
  2. 02Vector and matrix transpose
  3. 03Linear weighted combinations
  4. 04The dot product
  5. 05Matrix multiplication
  6. 06Softmax
  7. 07Logarithms
  8. 08Entropy and cross-entropy
  9. 09Min/max and argmin/argmax
  10. 10Mean and variance
  11. 11Random sampling and sampling variability
  12. 12The t-test
  13. 13Derivatives: intuition and polynomials
  14. 14How derivatives find minima
  15. 15Derivatives: the product and chain rules

Terms, and what they are in code

MathematicsIn codeShape
Scalarfloat, or a 0-dim tensor()
Vector1-D array(n,)
Matrix2-D array(m, n)
Tensorn-D array(a, b, c, …)
Transpose.T, or .transpose(i, j)(m, n) → (n, m)
Dot producta @ b, np.dot(n,), (n,) → ()
Matrix productA @ B(m, k), (k, n) → (m, n)
Elementwise productA * Bshapes must broadcast

Transpose, and why it appears everywhere

Transposing swaps rows and columns. It matters because matrix multiplication requires the inner dimensions to match, and transposing is how you make them match.

Attention is the clearest example: Q is [T, d] and K is [T, d], and you want a [T, T] matrix of scores between every pair of positions. Q @ K gives a dimension error; Q @ K.T gives exactly the scores. Similarly, the unembedding is often the embedding matrix transposed, which is what weight tying means.

The dot product

Multiply corresponding elements and sum. Geometrically it equals |a||b|cos θ, so it is large and positive when the vectors point the same way, zero when orthogonal, and negative when opposed. It measures alignment.

That interpretation is what makes it the field's core operation. Attention scores a query against a key by their dot product — how well does what this position is looking for match what that position offers? A logit is the dot product of a hidden state with an unembedding row — how aligned is the current representation with this token's direction? Cosine similarity is the dot product with the magnitudes divided out.

Dot products, weighted combinations, matrix multiplication
import numpy as np

a = np.array([1., 2., 3.])
b = np.array([4., 5., 6.])

print(a @ b)                       # 32.0 = 1*4 + 2*5 + 3*6
print(np.sum(a * b))               # the same thing, spelled out

# Geometric reading: |a||b|cos(theta)
cos = (a @ b) / (np.linalg.norm(a) * np.linalg.norm(b))
print(np.degrees(np.arccos(cos)))  # the angle between them

print(np.array([1., 0.]) @ np.array([0., 1.]))   # 0.0 — orthogonal

# A linear weighted combination IS a dot product, and it is what every layer
# of a network computes: weights dotted with inputs, plus a bias.
weights = np.array([0.5, 0.3, 0.2])
inputs = np.array([10., 20., 30.])
print(weights @ inputs)            # 17.0

# Matrix multiplication applies many dot products at once: element (i, j) of
# A @ B is row i of A dotted with column j of B.
A = np.random.randn(3, 4)
B = np.random.randn(4, 5)
print((A @ B).shape)               # (3, 5) — inner dimensions must match

# The attention score matrix, in one line:
Q, K = np.random.randn(6, 64), np.random.randn(6, 64)
scores = Q @ K.T / np.sqrt(64)     # (6, 6): every position against every other

# A @ B is NOT B @ A. Order matters, and reversing it is a common bug that
# sometimes still runs when the shapes happen to be compatible.

Logarithms, and why losses are logged

The logarithm answers: to what power must the base be raised to get this number? Its two useful properties here are that it turns products into sums, and that it compresses a very wide range into a manageable one.

Both matter for probabilities. The probability of a sequence is the product of many small numbers, which underflows to zero in floating point; summing their logarithms is numerically stable and mathematically equivalent. And a loss based on log-probability penalises confident errors severely — as p approaches 0, −log p goes to infinity — which is exactly the incentive you want.

Machine learning uses natural log by default, so units are nats. Information theory often uses base 2, giving bits. The conversion is a factor of ln 2 ≈ 0.693, and mixing them up is why a reported entropy sometimes looks off by about 30%.

Logs, softmax, and argmax
import numpy as np

print(np.log(np.e), np.log(1), np.log2(1024))    # 1.0  0.0  10.0
print(np.log(0.001 * 0.002), np.log(0.001) + np.log(0.002))   # equal

# Why log-probabilities: the product underflows, the sum does not.
probs = np.full(2000, 0.5)
print(np.prod(probs))              # 0.0 — underflowed
print(np.sum(np.log(probs)))       # -1386.29 — fine

def softmax(z):
    z = np.asarray(z, dtype=np.float64)
    z = z - z.max()                # shift for stability; result unchanged
    e = np.exp(z)
    return e / e.sum()

logits = np.array([2.0, 1.0, 0.1])
p = softmax(logits)
print(p, p.sum())                  # sums to 1

# Softmax is shift-invariant but NOT scale-invariant: adding a constant to
# every logit changes nothing, which is why subtracting the max is free.
print(np.allclose(softmax(logits), softmax(logits + 100)))     # True

# max vs argmax — the value vs the position. argmax is what turns a
# distribution into a prediction.
print(logits.max(), logits.argmax())        # 2.0  0
print(np.argsort(logits)[::-1][:2])         # top-2 indices
print(np.argmax([[1, 5], [7, 2]], axis=1))  # per row: [1 0]

Entropy and cross-entropy

Entropy is the average surprise of a distribution: −Σ p log p. It is zero when one outcome is certain and maximal when all outcomes are equally likely. For a vocabulary of 50,257 tokens the maximum is ln(50257) ≈ 10.8 nats, which is the entropy of an untrained model.

Cross-entropy measures using the wrong distribution: −Σ p log q, where p is the truth and q your prediction. It equals the entropy of p plus the KL divergence from p to q, so it is minimised exactly when q = p — which is why it is the right training objective.

For a single correct token the true distribution is one-hot, so the sum collapses to −log q(correct). Training a language model is therefore literally maximising the log-probability of the observed next token, and nothing more.

Entropy, cross-entropy, and perplexity
import numpy as np

def entropy(p):
    p = np.asarray(p, dtype=np.float64)
    p = p[p > 0]                          # 0 log 0 is defined as 0
    return float(-(p * np.log(p)).sum())

print(entropy([1.0, 0.0, 0.0]))           # 0.0    — certain
print(entropy([1/3, 1/3, 1/3]))           # 1.0986 — maximal for 3 outcomes
print(np.log(3))                          # same
print(np.log(50257))                      # 10.82  — an untrained LM

def cross_entropy(p, q):
    p, q = np.asarray(p), np.clip(np.asarray(q), 1e-12, 1)
    return float(-(p * np.log(q)).sum())

truth = np.array([0., 1., 0.])            # one-hot: the true token is index 1
print(cross_entropy(truth, [0.1, 0.8, 0.1]))   # 0.223 — good prediction
print(cross_entropy(truth, [0.4, 0.2, 0.4]))   # 1.609 — poor
print(cross_entropy(truth, [0.5, 0.001, 0.499]))  # 6.91 — confidently wrong

# With one-hot truth it reduces to -log q[correct]:
print(-np.log(0.8))

# Perplexity is its exponential — the effective number of choices.
losses = [0.223, 1.609, 6.908]
for L in losses:
    print(f"loss {L:.3f}  perplexity {np.exp(L):8.2f}")

Mean, variance, and sampling variability

The mean locates a distribution; the variance measures its spread, and the standard deviation puts that spread back in the original units. Layernorm computes both, per token, and rescales — so these two quantities are computed millions of times in a single forward pass.

The ddof detail matters when you are doing statistics rather than normalisation. Dividing by n gives the population variance; dividing by n−1 gives the unbiased sample estimate. NumPy defaults to n, most statistics defaults to n−1, and the discrepancy shows up in effect-size calculations.

Sampling variability is the concept underneath every claim in the interpretability parts. Any statistic computed on a sample differs from run to run, and the standard error — the standard deviation divided by √n — quantifies how much. It shrinks with √n, which is why doubling your sample only improves precision by about 40%, and why small-n interpretability results are so often unreplicable.

Descriptive statistics and the standard error
import numpy as np

x = np.array([2., 4., 4., 4., 5., 5., 7., 9.])

print(x.mean(), np.median(x))
print(x.var(), x.var(ddof=1))        # population vs sample (n-1)
print(x.std(), x.std(ddof=1))

# Standard error: the precision of the MEAN, not the spread of the data.
sem = x.std(ddof=1) / np.sqrt(len(x))
print(f"mean {x.mean():.2f} +/- {1.96 * sem:.2f}  (95% CI)")

# Sampling variability, demonstrated: the same population, different samples.
rng = np.random.default_rng(0)
population = rng.normal(100, 15, 100_000)
for n in [10, 50, 200, 1000]:
    means = [rng.choice(population, n).mean() for _ in range(500)]
    print(f"n={n:5}  sd of means {np.std(means):5.2f}  "
          f"predicted {15 / np.sqrt(n):5.2f}")
# The standard error formula predicts it exactly. This is why 'I found this
# neuron on 20 stimuli' is not a result.

# Bootstrap: a confidence interval without distributional assumptions.
def bootstrap_ci(data, statistic=np.mean, n_boot=10_000, alpha=0.05):
    rng = np.random.default_rng(0)
    boots = [statistic(rng.choice(data, len(data), replace=True))
             for _ in range(n_boot)]
    return np.percentile(boots, [100 * alpha / 2, 100 * (1 - alpha / 2)])

print(bootstrap_ci(x))

The t-test

The t-test asks whether two group means differ by more than sampling variability would produce. The statistic is the difference in means divided by its standard error, so it is a signal-to-noise ratio, and the p-value is the probability of seeing a t at least that large if the groups actually came from the same distribution.

Two cautions dominate its use here. A p-value is not an effect size: with a large enough sample, a trivially small difference becomes significant. Always report Cohen's d — the difference in standard-deviation units — alongside it.

And with many tests, small p-values happen by chance. Testing 98,000 neurons at p < 0.05 gives about 4,900 false positives. Correcting for multiple comparisons is not a formality; without it, single-neuron findings are noise.

A t-test done properly
import numpy as np
from scipy import stats

rng = np.random.default_rng(0)
group_a = rng.normal(0.5, 1.0, 40)
group_b = rng.normal(0.0, 1.0, 40)

# Welch's t-test — does not assume equal variances. Prefer it as the default.
t, p = stats.ttest_ind(group_a, group_b, equal_var=False)
print(f"t = {t:.3f}, p = {p:.4f}")

# Effect size: what the difference actually is, independent of n.
pooled = np.sqrt((group_a.var(ddof=1) + group_b.var(ddof=1)) / 2)
d = (group_a.mean() - group_b.mean()) / pooled
print(f"Cohen's d = {d:.3f}")     # ~0.2 small, ~0.5 medium, ~0.8 large

# Non-parametric alternative when the data are clearly not normal — which
# post-GELU activations usually are not.
print(stats.mannwhitneyu(group_a, group_b))

# Multiple comparisons: the correction that makes neuron scans valid.
from statsmodels.stats.multitest import multipletests
p_values = rng.uniform(0, 1, 5000)          # pure noise
print(f"uncorrected 'significant': {(p_values < 0.05).sum()}")   # ~250
reject, p_adj, *_ = multipletests(p_values, alpha=0.05, method="fdr_bh")
print(f"after FDR correction: {reject.sum()}")                   # ~0

Derivatives

A derivative is the instantaneous rate of change — the slope of the tangent. For a network it answers the only question training needs: if I nudge this parameter up, does the loss go up or down, and how fast?

The rules you need are few. Powers: d/dx xⁿ = n·xⁿ⁻¹. Sums differentiate termwise. The product rule handles products. And the chain rule handles composition: if y depends on u and u on x, then dy/dx = dy/du · du/dx.

The chain rule is the one that matters most, because a neural network is nothing but a deep composition of functions. Backpropagation is the chain rule applied layer by layer from the loss backwards, which is also why gradients vanish or explode — they are products of many terms, and a product of small numbers shrinks toward zero while a product of large ones grows without bound.

How derivatives find minima

At a minimum the derivative is zero — the function is momentarily flat. So minimisation means finding where the gradient vanishes, and gradient descent gets there by repeatedly stepping in the direction the gradient says decreases the function.

The sign is what makes it work. If the slope is positive, the function increases to the right, so you move left; if negative, you move right. Subtracting the gradient does both automatically, which is why the update is w ← w − lr·∇w and not plus.

A zero gradient alone does not identify a minimum — it also holds at maxima and saddle points. The second derivative distinguishes them, and in high dimensions saddle points vastly outnumber local minima, which turns out to be one reason gradient descent works better on large networks than the low-dimensional intuition suggests.

Derivatives analytically, numerically, and with autograd
import numpy as np, torch

# f(x) = x^3 - 3x, so f'(x) = 3x^2 - 3, zero at x = +/-1.
f = lambda x: x ** 3 - 3 * x
fp = lambda x: 3 * x ** 2 - 3

# Numerical check by central difference — always verify a hand-derived gradient.
def numerical(f, x, h=1e-5):
    return (f(x + h) - f(x - h)) / (2 * h)

for x in [-2.0, -1.0, 0.0, 1.0, 2.0]:
    print(f"x={x:+.1f}  analytic {fp(x):+7.3f}  numerical {numerical(f, x):+7.3f}")

# The chain rule, by hand: y = (3x + 1)^2
#   du/dx = 3,  dy/du = 2u  =>  dy/dx = 6(3x + 1)
chain = lambda x: 6 * (3 * x + 1)

# And by autograd, which is the same computation done for you.
x = torch.tensor(2.0, requires_grad=True)
y = (3 * x + 1) ** 2
y.backward()
print(x.grad.item(), chain(2.0))          # 42.0  42.0

# Product rule: d/dx [x^2 * sin(x)] = 2x sin(x) + x^2 cos(x)
x = torch.tensor(1.0, requires_grad=True)
(x ** 2 * torch.sin(x)).backward()
print(x.grad.item(),
      2 * np.sin(1) + np.cos(1))

# Vanishing gradients, in one line: a product of many small derivatives.
print(np.prod([0.25] * 50))        # ~8e-31 — this is why deep sigmoid nets
                                   # would not train, and why residual
                                   # connections were the fix.

Worth remembering

  • The dot product measures alignment; it is the single most-used operation in the field.
  • Cross-entropy is the average number of nats needed to encode the truth using your predicted distribution.
  • The chain rule is backpropagation — gradients multiply along a path.