Appendix D

Solutions to Exercises

Worked solutions for every published exercise, grouped by chapter.

6 Probability Theory

Solution 6.1 ★ A density above one

The density at the mean is 1/(0.12π)≈3.991 / (0.1\sqrt{2\pi}) \approx 3.99. The probability of landing within 0.05 of 0 is Φ(0.5)−Φ(−0.5)≈0.383\Phi(0.5) - \Phi(-0.5) \approx 0.383, where Φ\Phi is the standard Gaussian CDF. A density measures probability per unit length. Squeezing the distribution into a narrow range must raise its height so that the total area stays 1. A probability is an area, bounded by the total area of 1. For a narrow interval, P(∣X∣<δ)≈2δ p(0)P(|X| < \delta) \approx 2\delta\, p(0), which is small however tall p(0)p(0) is.

Solution 6.2 ★ Base rates

With a 20% base rate, flagged essays are 0.2×0.95=0.190.2 \times 0.95 = 0.19 generated and 0.8×0.05=0.040.8 \times 0.05 = 0.04 human, so

P(generated∣flagged)=0.190.19+0.04≈0.83.P(\text{generated} \mid \text{flagged}) = \frac{0.19}{0.19 + 0.04} \approx 0.83 .

The detector’s error rates are properties of the detector, but the posterior also depends on the prior. When generated essays are rare, even a small false-positive rate applied to the large human majority produces more false alarms than true detections. The same arithmetic governs any classifier used to find a rare class.

Solution 6.3 ★★ When averaging stops helping

Expand the variance of the sum into variances and covariances:

Var⁡[1n∑iXi]=1n2(∑iVar⁡[Xi]+∑i≠jCov⁡[Xi,Xj]).\Var\Big[\frac1n \sum_i X_i\Big] = \frac{1}{n^2}\Big(\sum_i \Var[X_i] + \sum_{i \ne j} \Cov[X_i, X_j]\Big) .

For i.i.d. draws every covariance is zero, which leaves nσ2/n2=σ2/nn\sigma^2 / n^2 = \sigma^2 / n. With correlation ρ\rho, each of the n(n−1)n(n-1) covariance terms is ρσ2\rho\sigma^2, so

Var⁡[Xˉn]=nσ2+n(n−1)ρσ2n2=σ2(ρ+1−ρn).\Var[\bar{X}_n] = \frac{n\sigma^2 + n(n-1)\rho\sigma^2}{n^2} = \sigma^2\Big(\rho + \frac{1 - \rho}{n}\Big) .

However large nn becomes, the variance never falls below ρσ2\rho\sigma^2. With ρ=0.1\rho = 0.1 and n=32n = 32, it is 0.128 instead of the i.i.d. 0.031: four times larger. A minibatch of near-duplicates behaves like a much smaller batch. The simulation below, checked in the tests, builds correlated draws from a shared component:

Correlated draws and the variance of their mean
def variance_of_correlated_mean(n, rho, trials, rng):
    """Empirical Var of the mean of n unit-variance draws, pairwise correlation rho."""
    shared = rng.standard_normal((trials, 1))           # what every draw has in common
    own = rng.standard_normal((trials, n))
    x = np.sqrt(rho) * shared + np.sqrt(1 - rho) * own  # Var 1, Cov(x_i, x_j) = rho
    return x.mean(axis=1).var()
Solution 6.4 ★★ Gaussian maximum likelihood

Up to a constant, the average negative log-likelihood is

ℓ(μ,σ)=log⁡σ+12σ2N∑i(xi−μ)2.\ell(\mu, \sigma) = \log \sigma + \frac{1}{2\sigma^2 N} \sum_i (x_i - \mu)^2 .

Setting ∂ℓ/∂μ=−1σ2N∑i(xi−μ)=0\partial \ell / \partial \mu = -\frac{1}{\sigma^2 N}\sum_i (x_i - \mu) = 0 gives μ^=xˉ\hat\mu = \bar{x}. Setting ∂ℓ/∂σ=1σ−1σ3N∑i(xi−μ)2=0\partial \ell / \partial \sigma = \frac{1}{\sigma} - \frac{1}{\sigma^3 N}\sum_i (x_i - \mu)^2 = 0 gives σ^2=1N∑i(xi−xˉ)2\hat\sigma^2 = \frac1N \sum_i (x_i - \bar{x})^2. The tests confirm that the finite-difference gradient vanishes at these values.

For the bias, write xi−xˉ=(xi−μ)−(xˉ−μ)x_i - \bar{x} = (x_i - \mu) - (\bar{x} - \mu). Summing the squares, the cross terms combine into ∑i(xi−xˉ)2=∑i(xi−μ)2−N(xˉ−μ)2\sum_i (x_i - \bar{x})^2 = \sum_i (x_i - \mu)^2 - N(\bar{x} - \mu)^2. Take expectations, using Var⁡[xˉ]=σ2/N\Var[\bar{x}] = \sigma^2 / N:

E[∑i(xi−xˉ)2]=Nσ2−σ2=(N−1)σ2,E[σ^2]=N−1Nσ2.\E\Big[\sum_i (x_i - \bar{x})^2\Big] = N\sigma^2 - \sigma^2 = (N - 1)\sigma^2 , \qquad \E[\hat\sigma^2] = \frac{N - 1}{N} \sigma^2 .

The sample mean sits closer to the data than the true mean does, so deviations from it are too small on average. Dividing by N−1N - 1 instead removes the bias (NumPy’s ddof=1). With N=5N = 5, the maximum-likelihood variance averages 0.8 of the truth:

Measuring the bias by simulation
def average_mle_variance(n, trials, rng):
    """Average maximum-likelihood variance of many size-n samples from N(0, 1)."""
    x = rng.standard_normal((trials, n))
    return np.mean(np.mean((x - x.mean(axis=1, keepdims=True)) ** 2, axis=1))
Solution 6.5 ★★ Losses from noise models

With σ\sigma fixed, the Gaussian negative log-likelihood is 12σ2(y−y^)2\frac{1}{2\sigma^2}(y - \hat{y})^2 plus terms that do not depend on the prediction. Minimizing its average is minimizing mean squared error. For Laplace noise,

−log⁡p(y∣y^)=∣y−y^∣b+log⁡2b,-\log p(y \mid \hat{y}) = \frac{|y - \hat{y}|}{b} + \log 2b ,

so the loss is mean absolute error:

The Laplace negative log-likelihood
def laplace_regression_nll(y, prediction, scale=1.0):
    """y ~ Laplace(prediction, scale): mean absolute error / scale + log(2 scale)."""
    return np.mean(np.abs(y - prediction) / scale + np.log(2 * scale))

For a constant prediction, squared error is minimized by the mean of the targets and absolute error by the median. A few large outliers drag the mean but barely move the median, so MSE chases outliers and MAE resists them. Each loss is the right choice exactly when its noise model matches the data.

Solution 6.6 ★★ The score function and its baseline

For a discrete distribution, the gradient passes through the finite sum. Then use ∇p=p ∇log⁡p\nabla p = p\, \nabla \log p:

∇θ∑xf(x)pθ(x)=∑xf(x) pθ(x)∇θlog⁡pθ(x)=Epθ[f(x)∇θlog⁡pθ(x)].\nabla_\theta \sum_x f(x) p_\theta(x) = \sum_x f(x)\, p_\theta(x) \nabla_\theta \log p_\theta(x) = \E_{p_\theta}[f(x) \nabla_\theta \log p_\theta(x)] .

With f=1f = 1 the left side is the gradient of 1, so E[∇θlog⁡pθ]=0\E[\nabla_\theta \log p_\theta] = 0. Hence E[(f−b)∇θlog⁡pθ]=E[f∇θlog⁡pθ]\E[(f - b)\nabla_\theta \log p_\theta] = \E[f \nabla_\theta \log p_\theta] for any constant bb.

For x=1+εx = 1 + \varepsilon with ε∼N(0,1)\varepsilon \sim \mathcal{N}(0, 1), the score is ε\varepsilon. Use the moments E[ε2]=1\E[\varepsilon^2] = 1, E[ε4]=3\E[\varepsilon^4] = 3, E[ε6]=15\E[\varepsilon^6] = 15, with odd moments zero:

  • Score function: g=(1+ε)2ε=ε+2ε2+ε3g = (1 + \varepsilon)^2 \varepsilon = \varepsilon + 2\varepsilon^2 + \varepsilon^3. Then E[g2]=1+12+15+6=34\E[g^2] = 1 + 12 + 15 + 6 = 34, so Var⁡[g]=34−22=30\Var[g] = 34 - 2^2 = 30.

  • With baseline 2: g=ε3+2ε2−εg = \varepsilon^3 + 2\varepsilon^2 - \varepsilon. Then E[g2]=15+12+1−6=22\E[g^2] = 15 + 12 + 1 - 6 = 22, so Var⁡[g]=18\Var[g] = 18.

  • Reparameterized: g=2x=2+2εg = 2x = 2 + 2\varepsilon, so Var⁡[g]=4\Var[g] = 4.

The score-function estimator with a baseline
def score_function_gradient_with_baseline(f, mean, std, n, rng, baseline):
    """Subtracting a constant from f leaves the expected gradient unchanged."""
    x = mean + std * rng.standard_normal(n)
    return np.mean((f(x) - baseline) * (x - mean) / std ** 2)

The tests check all three means and standard deviations against these values.

Solution 6.7 ★★★ Why Gumbel-max works

Let F(g)=e−e−gF(g) = e^{-e^{-g}} and f(g)=e−ge−e−gf(g) = e^{-g} e^{-e^{-g}} be the Gumbel CDF and density. Index kk wins when zj+gj<zk+gkz_j + g_j < z_k + g_k for every j≠kj \ne k. Condition on gk=gg_k = g; the other noises are independent:

P(k wins)=∫f(g)∏j≠kF(zk+g−zj) dg=∫e−gexp⁡(−e−g∑jezj−zk)dg.P(k \text{ wins}) = \int f(g) \prod_{j \ne k} F(z_k + g - z_j)\, \dd g = \int e^{-g} \exp\Big(-e^{-g} \sum_{j} e^{z_j - z_k}\Big) \dd g .

The j=kj = k term in the sum comes from f(g)f(g) itself. Write S=∑jezj−zkS = \sum_j e^{z_j - z_k} and substitute t=e−gt = e^{-g}, so that dt=−e−g dg\dd t = -e^{-g}\, \dd g:

P(k wins)=∫0∞e−St dt=1S=ezk∑jezj=softmax⁡(z)k.P(k \text{ wins}) = \int_0^\infty e^{-S t}\, \dd t = \frac{1}{S} = \frac{e^{z_k}}{\sum_j e^{z_j}} = \softmax(\vz)_k .

Scaling after the noise, arg max⁡k(zk+gk)/T\argmax_k (z_k + g_k)/T, picks the same index for any T>0T > 0, so temperature must act on the logits first: arg max⁡k(zk/T+gk)\argmax_k (z_k / T + g_k) samples softmax⁡(z/T)\softmax(\vz / T). The tests compare empirical frequencies from 200,000 draws with the softmax probabilities at T=0.5T = 0.5 and T=2T = 2.

Solution 6.8 ★★★ Designing a sampler

The CDF is F(x)=∫0x2t dt=x2F(x) = \int_0^x 2t\, \dd t = x^2 on [0,1][0, 1], so F−1(u)=uF^{-1}(u) = \sqrt{u}:

Inverse-CDF sampling for the density 2x
def sample_triangle(n, rng):
    """Density 2x on [0, 1]: F(x) = x^2, so x = sqrt(u)."""
    return np.sqrt(rng.random(n))

The mean is ∫01x⋅2x dx=2/3\int_0^1 x \cdot 2x\, \dd x = 2/3, and P(X<1/2)=F(1/2)=1/4P(X < 1/2) = F(1/2) = 1/4. With 200,000 samples, both agree with these values to within 1%.

7 Information Theory

Solution 7.1 ★ Counting bits

Surprisal is −log⁡2p-\log_2 p bits, or −ln⁡p-\ln p nats:

  • A fair coin landing heads, p=1/2p = 1/2: 1 bit, or 0.693 nats.

  • A fair die showing six, p=1/6p = 1/6: 2.585 bits, or 1.792 nats.

  • One token from a uniform 128,000-token vocabulary: log⁡2128,000≈16.97\log_2 128{,}000 \approx 16.97 bits, or 11.76 nats.

Surprisal in bits
def surprisal_bits(probability):
    return float(-np.log2(probability))

A language model with a cross-entropy of 2 nats per token is therefore doing far better than uniform guessing, which would cost 11.76 nats per token.

Solution 7.2 ★ The most uncertain distribution

With u(x)=1/Ku(x) = 1/K,

DKL(p ∥ u)=∑xp(x)log⁡(Kp(x))=log⁡K+∑xp(x)log⁡p(x)=log⁡K−H(p).\KL(p \,\Vert\, u) = \sum_x p(x) \log \big(K p(x)\big) = \log K + \sum_x p(x) \log p(x) = \log K - H(p) .

Gibbs' inequality makes the left side nonnegative, so H(p)≤log⁡KH(p) \le \log K, with equality exactly when p=up = u. The tests check the identity for 200 random distributions.

Solution 7.3 ★★ Gibbs' inequality

Restrict the sum to outcomes with p(x)>0p(x) > 0, and let Z=q(x)/p(x)Z = q(x)/p(x) with x∼px \sim p. Jensen’s inequality for the concave logarithm gives

−DKL(p ∥ q)=Ep[log⁡q(x)p(x)]≤log⁡Ep[q(x)p(x)]=log⁡∑x: p(x)>0q(x)≤log⁡1=0.-\KL(p \,\Vert\, q) = \E_p\Big[\log \frac{q(x)}{p(x)}\Big] \le \log \E_p\Big[\frac{q(x)}{p(x)}\Big] = \log \sum_{x:\, p(x) > 0} q(x) \le \log 1 = 0 .

Equality in Jensen’s step requires q/pq/p to be constant where p>0p > 0. Equality in the last step requires qq to put all its mass there. Together they force q=pq = p.

Solution 7.4 ★★ Likelihood as cross-entropy

Group the sum over samples by value. The value xx appears Np^(x)N \hat{p}(x) times, so

−1N∑ilog⁡qθ(xi)=−1N∑xNp^(x)log⁡qθ(x)=H(p^,qθ),-\frac{1}{N} \sum_{i} \log q_\vtheta(x_i) = -\frac{1}{N} \sum_x N \hat{p}(x) \log q_\vtheta(x) = H(\hat{p}, q_\vtheta) ,

and H(p^,q)=H(p^)+DKL(p^∥q)H(\hat{p}, q) = H(\hat{p}) + \KL(\hat{p} \Vert q) splits it into a constant and a divergence. The tests confirm the equality numerically:

The average negative log-likelihood equals a cross-entropy
def empirical_distribution(samples, categories):
    """The fraction of samples equal to each category: p_hat."""
    return np.bincount(samples, minlength=categories) / len(samples)


def average_nll(samples, q):
    """The maximum-likelihood objective: -(1/N) sum_i log q(x_i)."""
    return float(-np.mean(np.log(q[samples])))


def cross_entropy_of_empirical(samples, q):
    """The same number, as the cross-entropy H(p_hat, q)."""
    return float(cross_entropy(empirical_distribution(samples, len(q)), q))

Over all distributions, Gibbs' inequality says the minimum is at q=p^q = \hat{p}: a model that only memorizes training frequencies, assigning zero probability to anything unseen. Real models are kept from that point by their parameterization, which cannot represent arbitrary tables and must share structure across inputs. They are also held back by regularization and early stopping, and by the softmax, which never outputs an exact zero. The gap between memorizing p^\hat{p} and generalizing toward pp is the subject of the next part of the book.

Solution 7.5 ★★ KL between Gaussians

The log-ratio of the densities is

log⁡p(x)q(x)=log⁡σ2σ1−(x−μ1)22σ12+(x−μ2)22σ22.\log \frac{p(x)}{q(x)} = \log \frac{\sigma_2}{\sigma_1} - \frac{(x - \mu_1)^2}{2\sigma_1^2} + \frac{(x - \mu_2)^2}{2\sigma_2^2} .

Under pp, E[(x−μ1)2]=σ12\E[(x - \mu_1)^2] = \sigma_1^2 and E[(x−μ2)2]=σ12+(μ1−μ2)2\E[(x - \mu_2)^2] = \sigma_1^2 + (\mu_1 - \mu_2)^2. Taking expectations gives (7.6). With equal parameters the result is 0+12−12=00 + \tfrac12 - \tfrac12 = 0. With equal variances it reduces to (μ1−μ2)2/(2σ2)(\mu_1 - \mu_2)^2 / (2\sigma^2): quadratic in the distance between the means. The tests compare the formula with numerical integration for three pairs of Gaussians.

Solution 7.6 ★★ An unbiased, nonnegative estimator

Summing over the support of qq, Eq[r]=∑xq(x)p(x)q(x)=∑xp(x)=1\E_q[r] = \sum_x q(x) \frac{p(x)}{q(x)} = \sum_x p(x) = 1. So Eq[r−1]=0\E_q[r - 1] = 0 and

Eq[k3]=Eq[r−1]+Eq[−log⁡r]=0+Eq[log⁡q(x)p(x)]=DKL(q ∥ p).\E_q[k_3] = \E_q[r - 1] + \E_q[-\log r] = 0 + \E_q\Big[\log \frac{q(x)}{p(x)}\Big] = \KL(q \,\Vert\, p) .

For nonnegativity, the logarithm is concave, so it lies below its tangent line at 1: log⁡r≤r−1\log r \le r - 1 for every r>0r > 0. Hence k3=(r−1)−log⁡r≥0k_3 = (r - 1) - \log r \ge 0, with equality only at r=1r = 1. Adding the zero-mean term r−1r - 1 cancels much of k1k_1's fluctuation, because r−1r - 1 and log⁡r\log r move together.

Solution 7.7 ★★★ Forward KL matches moments

DKL(p∥q)=−H(p)−Ep[log⁡q(x)]\KL(p \Vert q) = -H(p) - \E_p[\log q(x)], and only the second term depends on q=N(μ,σ2)q = \mathcal{N}(\mu, \sigma^2):

−Ep[log⁡q(x)]=log⁡σ+Ep[(x−μ)2]2σ2+12log⁡2π=log⁡σ+Var⁡p[x]+(Ep[x]−μ)22σ2+12log⁡2π.-\E_p[\log q(x)] = \log \sigma + \frac{\E_p[(x - \mu)^2]}{2\sigma^2} + \tfrac12 \log 2\pi = \log \sigma + \frac{\Var_p[x] + (\E_p[x] - \mu)^2}{2\sigma^2} + \tfrac12 \log 2\pi .

The mean enters only through (Ep[x]−μ)2(\E_p[x] - \mu)^2, which is minimized by μ=Ep[x]\mu = \E_p[x]. Setting the derivative in σ\sigma to zero gives σ2=Var⁡p[x]\sigma^2 = \Var_p[x]. For the two-mode target, the mean is 0 and the variance is 0.62+22=4.360.6^2 + 2^2 = 4.36, so σ≈2.09\sigma \approx 2.09. The grid search in the tests finds the same values, to within its step size.

The reverse direction is Eq[log⁡q]−Eq[log⁡p]\E_q[\log q] - \E_q[\log p]. The target’s log-density sits inside an expectation over the fitted distribution. For a mixture, that expectation has no closed form, and the objective has one local minimum per mode. Which one an optimizer finds depends on where it starts.

Solution 7.8 ★★★ Mutual information three ways

For (0.300.100.150.45)\left(\begin{smallmatrix} 0.30 & 0.10 \\ 0.15 & 0.45 \end{smallmatrix}\right), the marginals are p(x)=(0.4,0.6)p(x) = (0.4, 0.6) and p(y)=(0.45,0.55)p(y) = (0.45, 0.55):

  • As a KL divergence from the product of the marginals: 0.1258 nats.

  • From entropies, H(X)+H(Y)−H(X,Y)H(X) + H(Y) - H(X, Y): the same 0.1258 nats.

  • H(Y)=0.688H(Y) = 0.688 nats and H(Y∣X)=H(X,Y)−H(X)=0.562H(Y \mid X) = H(X, Y) - H(X) = 0.562 nats, so again 0.688−0.562=0.1260.688 - 0.562 = 0.126 nats, or 0.18 bits.

When Y=XY = X always, for example with the diagonal table diag⁡(0.2,0.3,0.5)\diag(0.2, 0.3, 0.5), knowing YY removes all uncertainty about XX. Then I(X;Y)=H(X)≈1.030I(X; Y) = H(X) \approx 1.030 nats. For the product of the marginals, the joint equals the product, and the mutual information is exactly 0. All three cases are checked in the tests.

8 Hypothesis Testing

Solution 8.1 ★ Standard error

The accuracy is 248/400=0.62248/400 = 0.62. The plug-in standard error is 0.62(1−0.62)/400=0.024269\sqrt{0.62(1-0.62)/400} = 0.024269. A 95% normal interval is 0.62±1.96⋅0.0242690.62 \pm 1.96 \cdot 0.024269, or 0.572 to 0.668. The standard error is the standard deviation we would expect for the accuracy estimate over repeated evaluation sets of the same size, not the model’s per-example error rate.

Solution 8.2 ★★ Bootstrap interpretation

The median has no simple Bernoulli standard-error formula, but it is still a statistic of rows. The bootstrap approximates its sampling distribution by resampling rows with replacement and recomputing the median. For an LLM benchmark, the row should be the independent evaluation unit: a task, prompt, conversation, or judged comparison, not an individual token from inside one answer.

Bootstrapping a median score
def median_interval(scores, rng):
    """Bootstrap a robust median score instead of an accuracy."""
    return bootstrap_ci(np.asarray(scores), np.median, draws=2000, rng=rng)
Solution 8.3 ★★ Paired test

The five discordant rows contain four wins for B and one win for A, so the accuracy gap is (4−1)/12=0.25(4-1)/12 = 0.25. Under the null, the five discordant rows are fair coin flips. Outcomes at least this extreme have zero or one B wins, or four or five B wins, so the p-value is (1+5+5+1)/25=0.375(1+5+5+1)/2^5 = 0.375. The exact permutation test and McNemar’s test coincide here:

Tested paired comparison
def paired_demo():
    """A tiny benchmark where B fixes four A errors and breaks one A success."""
    a_correct = np.array([1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1])
    b_correct = np.array([1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1])
    return {
        "delta": float(b_correct.mean() - a_correct.mean()),
        "permutation_p": exact_permutation_p_value(a_correct, b_correct),
        "mcnemar_p": mcnemar_exact_p_value(a_correct, b_correct),
    }
Solution 8.4 ★★★ Implementation

Rearrange h=1.96p(1−p)/Nh = 1.96\sqrt{p(1-p)/N} to get N=1.962p(1−p)/h2N = 1.96^2p(1-p)/h^2, then round up. The worst case is p=0.5p=0.5. With h=0.02h=0.02, N=1.962⋅0.25/0.022=2401N = 1.96^2 \cdot 0.25 / 0.02^2 = 2401. The implementation in the chapter uses exactly that formula and ceil, because collecting a fraction of an example is impossible.

9 Learning from Data

Solution 9.1 ★ Empirical risk

Population risk is E[ℓ(fθ(x),y)]\E[\ell(f_\vtheta(\vx), y)], the average loss on future examples from the deployment distribution. Empirical risk replaces that expectation with a sample mean over the training set. The estimate can be biased if the training set was collected from a different population, filtered by a previous model, deduplicated incorrectly, or contaminated with test examples. More examples reduce sampling noise but do not fix a mismatched sampling process.

Solution 9.2 ★★ Normal equations

Start with L(θ)=1N(Xθ−y)⊤(Xθ−y)L(\vtheta)=\frac1N(\mX\vtheta-\vy)^\T(\mX\vtheta-\vy). Expanding gives 1N(θ⊤X⊤Xθ−2y⊤Xθ+y⊤y)\frac1N(\vtheta^\T\mX^\T\mX\vtheta - 2\vy^\T\mX\vtheta + \vy^\T\vy). The gradient is 2NX⊤Xθ−2NX⊤y\frac2N\mX^\T\mX\vtheta - \frac2N\mX^\T\vy. Setting it to zero and multiplying by N/2N/2 gives X⊤Xθ=X⊤y\mX^\T\mX\vtheta = \mX^\T\vy. If X⊤X\mX^\T\mX is invertible, solve for θ\vtheta; otherwise use least squares or regularization.

Solution 9.3 ★★ BCE gradient

For one example, p=σ(z)p=\sigma(z) and z=x⊤w+bz=\vx^\T\vw+b. The BCE derivatives are ∂ℓ/∂p=−y/p+(1−y)/(1−p)\partial \ell/\partial p = -y/p + (1-y)/(1-p) and ∂p/∂z=p(1−p)\partial p/\partial z = p(1-p). Multiplying gives ∂ℓ/∂z=p−y\partial \ell/\partial z = p-y. The chain rule then gives ∂ℓ/∂w=x(p−y)\partial \ell/\partial \vw = \vx(p-y). Averaging rows stacks those row vectors into X⊤(p−y)/N\mX^\T(\vp-\vy)/N, whose shape matches w\vw because X⊤\mX^\T maps per-example errors back to feature weights.

Solution 9.4 ★★★ Implementation

The tested snippet builds a noisy cubic training set, fits three polynomial feature maps, and measures validation loss against the clean signal. Degree 1 underfits because it cannot bend. Degree 11 overfits because it uses extra powers to chase noise. Degree 3 best matches the data generating function in this example.

Polynomial capacity demo
def polynomial_losses(seed=9):
    rng = np.random.default_rng(seed)
    x_train = np.linspace(-1.0, 1.0, 12, dtype=np.float32)
    y_clean = 0.5 + x_train - 1.5 * x_train ** 2 + 0.7 * x_train ** 3
    y_train = y_clean + rng.normal(0.0, 0.08, size=len(x_train)).astype(np.float32)
    x_val = np.linspace(-1.0, 1.0, 200, dtype=np.float32)
    y_val = 0.5 + x_val - 1.5 * x_val ** 2 + 0.7 * x_val ** 3
    losses = {}
    for degree in (1, 3, 11):
        X_train = polynomial_features(x_train, degree)
        X_val = polynomial_features(x_val, degree)
        w, b = normal_equation(X_train, y_train)
        losses[degree] = float(np.mean((predict_linear(X_val, w, b) - y_val) ** 2))
    return losses

10 Automatic Differentiation

Solution 10.1 ★ Computational graph

The graph has leaves xx and yy. It computes xyxy, x2x^2, eye^y, then adds those three values and applies log. The node xx is shared because it feeds both xyxy and x2x^2. During the backward pass, x.gradx.grad must receive the contribution from the multiply path and the square path. Missing either contribution gives the derivative of a different program.

Solution 10.2 ★★ VJP derivation

For z=xyz=xy, a small change gives dz=y dx+x dy\dd z = y\,\dd x + x\,\dd y. Multiplying by the output adjoint zˉ\bar z gives xˉ+=zˉy\bar x \mathrel{\char"2B}= \bar z y and yˉ+=zˉx\bar y \mathrel{\char"2B}= \bar z x. For Y=AW\mY=\mA\mW, use the trace identity: ⟨Yˉ,dAW+AdW⟩\langle \bar{\mY}, \dd\mA\mW + \mA\dd\mW\rangle = ⟨YˉW⊤,dA⟩+⟨A⊤Yˉ,dW⟩\langle \bar{\mY}\mW^\T, \dd\mA\rangle + \langle \mA^\T\bar{\mY}, \dd\mW\rangle. Thus Aˉ\bar{\mA} has the same shape as A\mA, and Wˉ\bar{\mW} has the same shape as W\mW.

Solution 10.3 ★★ Forward or reverse?

Forward mode propagates one chosen input direction to all outputs, so computing a full gradient with respect to many parameters would require many sweeps. Reverse mode starts from the scalar loss adjoint and computes all parameter adjoints in one backward sweep, so it is the natural choice for training neural networks. Forward mode is attractive when the input dimension is small and the output dimension is large, or when only a few directional derivatives are needed.

Solution 10.4 ★★★ Implementation

The tested helper constructs the graph, calls backward, and returns gradients for all three inputs. The chapter test flattens XX, WW, and bb, recomputes the scalar loss under small central-difference perturbations, and checks the concatenated autodiff gradient.

Tiny network with autodiff gradients
def tiny_network_loss_and_grads(X, W, b):
    x = Tensor(X)
    w = Tensor(W)
    bias = Tensor(b)
    loss = ((x @ w + bias).relu().exp().log()).sum()
    loss.backward()
    return float(loss.data), x.grad, w.grad, bias.grad

11 Activation Functions

Solution 11.1 ★ Saturation and dead units

For sigmoid, σ′(x)=σ(x)(1−σ(x))\sigma'(x)=\sigma(x)(1-\sigma(x)). As x→∞x\to\infty, σ(x)→1\sigma(x)\to1; as x→−∞x\to-\infty, σ(x)→0\sigma(x)\to0. Either way the product goes to 0. Since tanh⁡′(x)=1−tanh⁡2(x)\tanh'(x)=1-\tanh^2(x) and tanh⁡(x)→±1\tanh(x)\to\pm1, tanh also saturates. ReLU has slope 0 for negative inputs, so a unit that is always negative never updates from the loss signal. Leaky ReLU replaces that 0 slope by α\alpha, leaving a path for gradients.

Solution 11.2 ★★ Derivatives by hand

For s=σ(x)=1/(1+e−x)s=\sigma(x)=1/(1+e^{-x}), s′=e−x/(1+e−x)2=s(1−s)s'=e^{-x}/(1+e^{-x})^2=s(1-s), whose largest value is 1/41/4 at s=1/2s=1/2. Using tanh⁡x=(ex−e−x)/(ex+e−x)\tanh x=(e^x-e^{-x})/(e^x+e^{-x}) gives tanh⁡′(x)=1−tanh⁡2(x)\tanh'(x)=1-\tanh^2(x), largest at 0 with value 1. SiLU is a product, so (xσ(x))′=σ(x)+xσ(x)(1−σ(x))(x\sigma(x))'=\sigma(x)+x\sigma(x)(1-\sigma(x)). Softplus has derivative ex/(1+ex)=σ(x)e^x/(1+e^x)=\sigma(x). The test test_elementwise_activation_derivatives_match_finite_differences checks these formulas against central differences.

Solution 11.3 ★★ GELU exact versus approximate

The exact GELU is g(x)=xΦ(x)g(x)=x\Phi(x). Since Φ′(x)=ϕ(x)\Phi'(x)=\phi(x), the product rule gives g′(x)=Φ(x)+xϕ(x)g'(x)=\Phi(x)+x\phi(x). The measured approximation error is computed, not guessed:

GELU approximation error
def gelu_tanh_max_error(limit=8.0, points=200_001):
    x = np.linspace(-limit, limit, points)
    return float(np.max(np.abs(gelu_exact(x) - gelu_tanh(x))))

On the grid used by the tests, the maximum absolute error is 4.7324×10−44.7324\times10^{-4}, and the test asserts that exact measured value.

Solution 11.4 ★★★ Matching gated-FFN parameters

Ignoring biases, the plain MLP has d(4d)+(4d)d=8d2d(4d)+(4d)d=8d^2 weights. The gated block has two input projections and one output projection, dh+dh+hd=3dhdh+dh+hd=3dh. Setting 3dh=8d23dh=8d^2 gives h=8d/3h=8d/3.

Matching the hidden width
def gated_hidden_width(model_width, mlp_multiplier=4):
    """Hidden width h with 3 d h parameters matching a d -> 4d -> d MLP."""
    return mlp_multiplier * 2 * model_width / 3

The backward pass in scratch.activations.gated_ffn_backward applies the product rule to the gate and is checked with finite differences for x\vx, W\mW, V\mV, and W2\mW_2.

12 Softmax & Cross-Entropy

Solution 12.1 ★ Shift and temperature

For every class, ezi+c/∑jezj+c=ecezi/(ec∑jezj)e^{z_i+c}/\sum_j e^{z_j+c}=e^c e^{z_i}/(e^c\sum_j e^{z_j}), so the factor cancels. Temperature changes the gaps before this normalization:

Softmax at three temperatures
def probabilities_at_temperatures(logits, temperatures):
    return [softmax(logits, temperature=T) for T in temperatures]

The test checks that for (2,1,−1)(2,1,-1) the largest probability is highest at T=0.5T=0.5, lower at T=1T=1, and closer to uniform at T=2T=2.

Solution 12.2 ★★ The Jacobian

Let Z=∑kezkZ=\sum_k e^{z_k} and pi=ezi/Zp_i=e^{z_i}/Z. For i=ji=j, quotient rule gives (eziZ−eziezi)/Z2=pi(1−pi)(e^{z_i}Z-e^{z_i}e^{z_i})/Z^2=p_i(1-p_i). For i≠ji\ne j, only the denominator changes, giving −eziezj/Z2=−pipj-e^{z_i}e^{z_j}/Z^2=-p_i p_j. Therefore J=diag⁡(p)−pp⊤J=\diag(\vp)-\vp\vp^\T. A row sum is pi−pi∑jpj=0p_i-p_i\sum_jp_j=0, matching shift invariance.

Solution 12.3 ★★ Cross-entropy gradient

Using log-softmax, L=−∑iyizi+∑iyilog⁡∑jezjL=-\sum_i y_i z_i+\sum_i y_i\log\sum_j e^{z_j}. Since ∑iyi=1\sum_i y_i=1, this is −y⊤z+log⁡∑jezj-\vy^\T\vz+\log\sum_j e^{z_j}. Differentiating gives −yj+pj-y_j+p_j. With temperature, all logits inside softmax are zj/Tz_j/T, so the chain rule multiplies the gradient by 1/T1/T.

Solution 12.4 ★★★ Fused implementation

The implementation computes log-softmax by shifting logits, then returns the mean loss and the gradient with respect to logits:

Loss-only wrapper used by the gradient check
def loss_only(logits, labels, smoothing=0.0, z_loss=0.0):
    loss, _ = softmax_cross_entropy(logits, labels,
                                    label_smoothing=smoothing,
                                    z_loss=z_loss)
    return loss

The tests check three cases: the plain one-hot gradient (p−y)/B(\vp-\vy)/B, a temperature and label-smoothing gradient, and the z-loss gradient added to cross-entropy.

13 Loss Functions & Divergences

Solution 13.1 ★ Losses from likelihoods

For Gaussian noise with fixed σ\sigma, −log⁡p(y∣y^)=(y−y^)2/(2σ2)log⁡σ12log⁡2π-\log p(y\mid\hat y)=(y-\hat y)^2/(2\sigma^2)\log\sigma\tfrac12\log2\pi. Only the squared residual depends on y^\hat y, so maximum likelihood minimizes MSE. For Laplace noise, −log⁡p(y∣y^)=∣y−y^∣/b+log⁡(2b)-\log p(y\mid\hat y)=|y-\hat y|/b+\log(2b), so the prediction-dependent part is MAE. The scale changes the gradient size but not the optimum when fixed.

Solution 13.2 ★★ Robust gradients and stable BCE

With r=y^−yr=\hat y-y, MSE has gradient 2r2r, MAE has sign⁡(r)\sign(r) away from zero, and Huber has rr for ∣r∣≤δ|r|\le\delta and δsign⁡(r)\delta\sign(r) outside. The cap is visible in the tested helper:

Outlier gradients
def outlier_gradients(residual, delta=1.0):
    prediction = np.array([residual], dtype=np.float64)
    target = np.array([0.0])
    _, mse_grad = (residual ** 2, np.array([2 * residual]))
    _, huber_grad = huber_loss(prediction, target, delta)
    return mse_grad[0], huber_grad[0]

For BCE, substitute σ(z)=1/(1+e−z)\sigma(z)=1/(1+e^{-z}) and simplify separately for z≥0z\ge0 and z<0z<0 to get max⁡(z,0)−zy+log⁡(1+e−∣z∣)\max(z,0)-zy+\log(1+e^{-|z|}). Differentiating gives σ(z)−y\sigma(z)-y. The stable implementation stays finite even for logits ±1000\pm1000:

Stable BCE example
def stable_bce_example():
    logits = np.array([1000.0, -1000.0])
    labels = np.array([1.0, 0.0])
    return bce_with_logits(logits, labels)[0]
Solution 13.3 ★★ Focal loss and KL direction

Focal loss multiplies BCE by (1−pt)γ(1-p_t)^\gamma. When pt≈1p_t\approx1, the multiplier is near zero, so easy examples contribute little. When ptp_t is small, the multiplier is near one, so hard examples keep a cross-entropy-like signal. Forward KL, DKL(p∥q)\KL(p\Vert q), averages over the target and punishes missing target mass. Reverse KL, DKL(q∥p)\KL(q\Vert p), averages over the model and is more willing to focus on one mode. Jensen-Shannon is symmetric because it averages DKL(p∥m)\KL(p\Vert m) and DKL(q∥m)\KL(q\Vert m) with the same midpoint m=(p+q)/2m=(p+q)/2.

Solution 13.4 ★★★ Distillation scaling

For softened student probabilities qT=softmax⁡(z/T)q_T=\softmax(z/T) and fixed teacher pTp_T, the cross-entropy gradient is (qT−pT)/T(q_T-p_T)/T. Multiplying the loss by T2T^2 makes it T(qT−pT)T(q_T-p_T). The tests use this wrapper:

Scaled and unscaled distillation gradients
def scaled_and_unscaled_distillation(student, teacher, temperature):
    _, scaled = distillation_loss(student, teacher, temperature, scale=True)
    _, unscaled = distillation_loss(student, teacher, temperature, scale=False)
    return scaled, unscaled

They check that the scaled gradient is T2T^2 times the unscaled one for the same softened distributions, and that the analytic gradient matches finite differences.

14 Neural Networks from Scratch

Solution 14.1 ★ Shapes and parameter count

The arrays have shapes Z1,H∈RB×10\mZ_1, \mH \in \R^{B \times 10} and Z2∈RB×4\mZ_2 \in \R^{B \times 4}. The parameters are W1∈R5×10\mW_1 \in \R^{5 \times 10}, b1∈R10\vb_1 \in \R^{10}, W2∈R10×4\mW_2 \in \R^{10 \times 4}, and b2∈R4\vb_2 \in \R^4. The count is 5⋅10+10+10⋅4+4=1045\cdot10 + 10 + 10\cdot4 + 4 = 104, checked by the helper:

Parameter count
def parameter_count():
    params = initialize(seed=0)
    return sum(value.size for value in params.values())
Solution 14.2 ★★ Softmax-cross-entropy gradient

The softmax derivative is ∂pk/∂zj=pk(δkj−pj)\partial p_k/\partial z_j = p_k(\delta_{kj}-p_j). Therefore

∂ℓ∂zj=∑k−ykpkpk(δkj−pj)=−yj+pj∑kyk=pj−yj.\frac{\partial \ell}{\partial z_j} = \sum_k \frac{-y_k}{p_k} p_k(\delta_{kj}-p_j) = -y_j + p_j\sum_k y_k = p_j - y_j .

For the mean loss, L=B−1∑iℓiL = B^{-1}\sum_i \ell_i, so every row gradient is divided by BB. All later matrix products are linear in that upstream gradient, so dividing again would make the gradient too small by another factor of BB. The tests check this gradient against finite differences.

Solution 14.3 ★★ Variance propagation

Because the terms are independent and zero mean, Var⁡[wixi]=E[wi2]E[xi2]=Var⁡[w]Var⁡[x]\Var[w_i x_i] = \E[w_i^2]\E[x_i^2] = \Var[w]\Var[x]. Variances of independent sums add, so Var⁡[z]=nVar⁡[w]Var⁡[x]\Var[z] = n\Var[w]\Var[x]. Choosing Var⁡[w]=1/n\Var[w] = 1/n keeps the preactivation scale comparable to the input scale. A ReLU makes about half of a symmetric preactivation zero, so the second moment is roughly halved; He initialization compensates with Var⁡[w]=2/n\Var[w] = 2/n.

Solution 14.4 ★★★ Implement and check

The tested implementation uses float64 copies for gradient checking and float32 for the training run. Its SGD loop lowers the synthetic-data loss and reaches high training accuracy. On a tiny set with permuted labels, it can fit the update examples better than it agrees with the original clean labels:

Tiny noisy-label experiment
def memorization_gap(seed=7):
    inputs, labels = synthetic_data(seed=seed, examples_per_class=4)
    rng = np.random.default_rng(seed)
    noisy = rng.permutation(labels)
    params, _ = train_sgd(inputs, noisy, epochs=400, learning_rate=0.12, seed=seed)
    _, cache = forward(params, inputs, noisy)
    train_accuracy = np.mean(cache["P"].argmax(axis=1) == noisy)
    _, clean_cache = forward(params, inputs, labels)
    clean_accuracy = np.mean(clean_cache["P"].argmax(axis=1) == labels)
    return float(train_accuracy), float(clean_accuracy)

That gap is overfitting: optimization succeeded on the training objective, but the fitted rule matched noise instead of the data-generating pattern.

15 Optimizers & Schedules

Solution 15.1 ★ Condition number

For eigenvalue λi\lambda_i, the error multiplier is 1−ηλi1-\eta\lambda_i. Stability requires ∣1−ηλi∣<1|1-\eta\lambda_i| < 1 for every eigenvalue, so 0<η<2/250 < \eta < 2/25. With η=1/25\eta=1/25, the 2525 direction goes to zero in one step, but the 11 direction is multiplied by 24/2524/25 each step. The best fixed-rate worst-case factor is:

Condition-number rate
def optimal_gd_rate(condition_number):
    return (condition_number - 1) / (condition_number + 1)
Solution 15.2 ★★ Adam bias correction

Unrolling the recurrence gives

mt=(1−β1)(g+β1g+⋯+β1t−1g)=(1−β1t)g.m_t=(1-\beta_1)(g+\beta_1 g+\cdots+\beta_1^{t-1}g) =(1-\beta_1^t)g .

The same geometric sum gives vt=(1−β2t)g2v_t=(1-\beta_2^t)g^2. Without correction, both moment estimates are biased toward zero at small tt. Dividing by 1−β1t1-\beta_1^t and 1−β2t1-\beta_2^t makes the constant-gradient estimates equal to gg and g2g^2.

The first Adam step after bias correction
def adam_first_step():
    params = {"w": np.array([2.0, -3.0])}
    grads = {"w": np.array([0.5, -0.25])}
    state = adam_state(params)
    before = params["w"].copy()
    adamw_step(params, grads, state, lr=0.01)
    return before - params["w"]
Solution 15.3 ★★ AdamW and clipping

With L2 regularization, Adam sees g+λθg+\lambda\theta and then divides by the adaptive v^+ϵ\sqrt{\hat{v}}+\epsilon, so the decay part is coordinate-scaled like any other gradient. AdamW instead applies θ←(1−ηλ)θ\theta \leftarrow (1-\eta\lambda)\theta separately, then takes the adaptive gradient step. For clipping, 13>513 > 5, so every tensor is scaled by 5/135/13:

Clipping demo
def clipped_demo_norm():
    grads = {"a": np.array([3.0, 4.0]), "b": np.array([12.0])}
    clipped, before = clip_by_global_norm(grads, max_norm=5.0)
    after = np.sqrt(sum(np.sum(g * g) for g in clipped.values()))
    return before, float(after)
Solution 15.4 ★★★ Muon implementation

For a matrix M=USV⊤\mM = \mU\mS\mV^\T, the nearest orthogonal polar factor in Frobenius norm is UV⊤\mU\mV^\T. The Newton-Schulz iteration approximates that factor using only matrix multiplies, which is cheaper than an SVD in a training step. The tests compare the chapter implementation with NumPy’s SVD on tall and wide matrices. Muon is restricted here to 2-D hidden weights because biases, gains, embeddings, and output heads do not have the same interior matrix geometry.

16 Normalization, Residuals & Precision

Solution 16.1 ★ Train vs inference statistics

BatchNorm estimates μj\mu_j and σj2\sigma_j^2 from the current minibatch during training, but inference may use a different batch size, or one example at a time, so it uses running statistics accumulated during training. For B×dB \times d, BatchNorm reduces over the BB axis for each feature. LayerNorm reduces over the dd axis inside each row, so it uses the current example’s own statistics in both training and inference.

Solution 16.2 ★★ LayerNorm backward

Let x^ˉ=yˉ⊙γ\bar{\hat{\vx}}=\bar{\vy}\odot\boldsymbol{\gamma} and x^=(x−μ)/s\hat{\vx}=(\vx-\mu)/s. A change in one input coordinate affects the normalized output directly, through the row mean, and through the row variance. Collecting those terms gives

xˉ=1s(x^ˉ−mean⁡(x^ˉ)−x^mean⁡(x^ˉ⊙x^)).\bar{\vx}=\frac{1}{s}\left(\bar{\hat{\vx}} -\operatorname{mean}(\bar{\hat{\vx}}) -\hat{\vx}\operatorname{mean}(\bar{\hat{\vx}}\odot\hat{\vx})\right).

The first mean removes the component caused by mean subtraction. The second removes the component caused by changing the row’s variance. The tests check this formula with finite differences.

Solution 16.3 ★★ RMSNorm and residuals

For a>0a>0 and ϵ=0\epsilon=0,

axd−1∑j(axj)2=axad−1∑jxj2=xd−1∑jxj2.\frac{a\vx}{\sqrt{d^{-1}\sum_j (a x_j)^2}} = \frac{a\vx}{a\sqrt{d^{-1}\sum_j x_j^2}} = \frac{\vx}{\sqrt{d^{-1}\sum_j x_j^2}} .

The helper checks the same invariance numerically:

RMSNorm scale invariance
def rms_scale_invariance(x, weight, factor):
    y1, _ = rms_norm_forward(x, weight)
    y2, _ = rms_norm_forward(factor * x, weight)
    return np.max(np.abs(y1 - y2))

For a residual block xl+1=xl+Fl(xl)\vx_{l+1}=\vx_l+F_l(\vx_l), differentiating gives an identity term xˉl+1\bar{\vx}_{l+1} plus the gradient through FlF_l. Even if the learned branch is small or badly scaled, the identity term passes gradient backward.

Solution 16.4 ★★★ Dropout and mixed precision

Inverted dropout samples a Bernoulli keep mask and divides kept activations by the keep probability, so its expectation equals the input. The tested helper estimates that mean:

Dropout expectation
def dropout_mean(seed=0):
    rng = np.random.default_rng(seed)
    x = np.ones(20_000)
    y, _ = inverted_dropout(x, 0.25, rng)
    return float(y.mean())

The precision demo casts large float32 values to fp16 and rounded bfloat16, showing fp16 overflow while bfloat16 remains finite. It also multiplies a tiny gradient by a scale before the fp16 cast and divides after, recovering a nonzero unscaled gradient. Float32 master weights then accumulate updates that would be lost if every step were rounded to a low-precision copy.

17 Tokenization & Embeddings

Solution 17.1 ★ Choosing units

Characters make unknown text easy and keep the alphabet small, but they make the sequence long. Words give short, readable sequences for common text, but unhappiness and the emoji may need unknown-token handling unless both were in the vocabulary. Byte-level subwords keep exact coverage because every string is bytes first, and frequent chunks such as un or ness can be merged; their drawback is that rare text may still split into many small pieces.

Solution 17.2 ★★ Lookup gradient

Stack the one-hot rows into X\mX, so the embedding output is Y=XE\mY = \mX\mE. For a scalar loss,

Eˉ=X⊤Yˉ.\bar{\mE} = \mX^\T\bar{\mY} .

Column jj of X⊤\mX^\T is 1 exactly for positions whose id is jj, so row jj of Eˉ\bar{\mE} is the sum of those upstream rows. The checked helper shows the repeated id receiving both contributions:

Scatter-add for a repeated token id
def repeated_index_gradient():
    """A repeated token id receives the sum of both upstream gradients."""
    indices = np.array([1, 3, 1])
    grad_output = np.array([[1.0, 0.0], [0.0, 2.0], [3.0, 4.0]])
    return embedding_backward(indices, grad_output, vocab_size=5)
Solution 17.3 ★★ Tiny BPE

The trainer in Section 17.2 counts pairs after each rewrite, so later merges can combine pieces that did not exist at the start. A compact way to check the result is to compare corpus length before and after BPE:

Tiny-corpus token budget
def corpus_token_lengths(texts=TINY_CORPUS, vocab_size=266):
    """Byte count before BPE and token count after BPE on a tiny corpus."""
    _vocab, merges = train_byte_bpe(texts, vocab_size)
    before = sum(len(utf8_bytes(text)) for text in texts)
    after = sum(len(encode(text, merges)) for text in texts)
    return before, after, 256 + len(merges)

The tests assert that the tiny corpus has 27 UTF-8 bytes, becomes 10 BPE tokens after ten merges, and still round-trips. The emoji round-trips because all 256 single-byte tokens remain in the vocabulary even if no emoji byte sequence appeared during training.

Solution 17.4 ★★★ Implement scatter-add

Use np.add.at, because plain grad_weight[ids] += grad_output can lose updates when an id repeats. A complete implementation is the embedding_backward function in Section 17.4. It creates a zero table with the vocabulary size and embedding width, then scatter-adds each upstream row into the selected token row. The chapter tests compare it with a Python loop over flattened ids.

18 Language Modeling

Solution 18.1 ★ Chain-rule sampling

The factorization writes a joint probability as a product of next-token conditionals. To sample, start with a beginning context, draw x1x_1 from q(x1)q(x_1), append it, then draw x2x_2 from q(x2∣x1)q(x_2 \mid x_1). Repeating this procedure samples from the product distribution defined by the model. Greedy decoding uses the same conditionals but takes the most likely token instead of sampling.

Solution 18.2 ★★ Smoothing a row

The row total is 3+0+1=43+0+1=4, and there are three possible next tokens. Add-one smoothing gives

(3+14+3,0+14+3,1+14+3)=(47,17,27).\left(\frac{3+1}{4+3},\frac{0+1}{4+3},\frac{1+1}{4+3}\right) =\left(\frac47,\frac17,\frac27\right).

The entries sum to (4+1+2)/7=1(4+1+2)/7=1. In general, the numerator adds α\alpha to each of VV cells, and the denominator adds αV\alpha V to the row total.

Smoothed loss on the tiny corpus
def smoothed_tiny_loss(alpha=1.0):
    ids, vocab = word_ids(SYNTHETIC_TEXT)
    previous, target = make_bigrams(ids)
    probs = smoothed_bigram_probs(ids, len(vocab), alpha)
    return average_nll_from_probs(probs, previous, target)
Solution 18.3 ★★ Neural bigram gradient

For one example, z=eiW\vz=\boldsymbol{e}_i\mW, q=softmax⁡(z)\vq=\softmax(\vz), and ℓ=−log⁡qy\ell=-\log q_y. The softmax-cross-entropy derivative is zˉ=q−ey\bar{\vz}=\vq-\boldsymbol{e}_y. Since z\vz is row ii of W\mW, only that row receives the gradient:

Wˉi,:+=q−ey.\bar{\mW}_{i,:}\mathrel{+}= \vq-\boldsymbol{e}_y .

A batch averages those row updates over NN examples. The tests check the implemented gradient against finite differences and then verify that gradient descent lowers the loss:

Training lowers the tiny neural-bigram loss
def trained_tiny_losses():
    ids, vocab = word_ids(SYNTHETIC_TEXT)
    previous, target = make_bigrams(ids)
    _W, losses = train_neural_bigram(previous, target, len(vocab))
    return np.array([losses[0], losses[-1]])
Solution 18.4 ★★★ Fixed-window implementation

For ids a b c d e and width 33, the contexts are (a,b,c) and (b,c,d), with targets d and e. The chapter code constructs this sliding window and feeds the gathered embeddings through an MLP. Any dependency farther back than the width is invisible: with width 33, the prediction after a b c d cannot depend on a except through parameters learned from other examples. Attention removes that fixed cutoff by letting the current position read earlier hidden states directly.

19 Scaled Dot-Product Attention

Solution 19.1 ★ Soft lookup

The query is the thing asking for information. The keys are searchable addresses, and the values are the content stored at those addresses. Attention compares the query with every key, turns the scores into weights, and returns the weighted average of the values. It is "soft" because every unmasked value can contribute and because the weights are differentiable.

Solution 19.2 ★★ Dot-product variance

For one coordinate, independence gives E[qℓkℓ]=E[qℓ]E[kℓ]=0\E[q_\ell k_\ell]=\E[q_\ell]\E[k_\ell]=0 and Var⁡(qℓkℓ)=E[qℓ2kℓ2]=E[qℓ2]E[kℓ2]=1\Var(q_\ell k_\ell)=\E[q_\ell^2k_\ell^2]=\E[q_\ell^2]\E[k_\ell^2]=1. Different coordinates are independent, so variances add:

Var⁡(∑ℓ=1dkqℓkℓ)=∑ℓ=1dk1=dk.\Var\left(\sum_{\ell=1}^{d_k}q_\ell k_\ell\right)=\sum_{\ell=1}^{d_k}1=d_k .

Dividing by dk\sqrt{d_k} divides the variance by dkd_k, leaving variance near one. The tested helper estimates the same ratio numerically:

Variance ratios for several widths
def variance_ratios(widths=(4, 16, 64)):
    """Return Var(q dot k) / d_k for several widths."""
    return np.array([dot_product_variance(width) / width for width in widths])
Solution 19.3 ★★ Softmax row backward

The softmax Jacobian for one row is J=diag⁡(a)−aa⊤J=\diag(\va)-\va\va^\T. Multiplying by the upstream row aˉ\bar{\va} gives

sˉ=Jaˉ=a⊙aˉ−a(a⊤aˉ)=a⊙(aˉ−(aˉ⊙a)1).\bar{\vs}=J\bar{\va} =\va\odot\bar{\va}-\va(\va^\T\bar{\va}) =\va\odot(\bar{\va}-(\bar{\va}\odot\va)\one).

The scalar a⊤aˉ\va^\T\bar{\va} is the row sum of aˉ⊙a\bar{\va}\odot\va. Applying this row by row gives (19.4).

Solution 19.4 ★★★ Gradient-check masked attention

The implementation in Section 19.3 follows the chain rule in reverse: O=AV\mO=\mA\mV, then row-softmax, then the scaled score matrix. Masked score gradients are set to zero. The chapter tests check all three inputs with finite differences under a causal mask. This tiny helper exposes the weights for a causal self-attention example:

Causal attention weights
def causal_attention_weights():
    """Attention weights for a tiny self-attention problem with a causal mask."""
    Q = np.array([[[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]]])
    K = Q.copy()
    V = np.eye(3)[None, :, :]
    _output, cache = attention_forward(Q, K, V, causal_mask(3), True)
    return cache[3][0]

20 Multi-Head Attention

Solution 20.1 ★ Why heads?

One head produces one attention distribution per query position. Several heads can produce several distributions, each after a different learned projection of the same hidden states. That lets the layer retrieve different mixtures of values for different relations, then combine them with WO\mW_O. If the total width stays fixed, this is not about adding more output dimensions; it is about giving the model several scoring subspaces.

Solution 20.2 ★★ Shape trace

Starting from (B,T,d)(B,T,d), each projection keeps shape (B,T,d)(B,T,d). Splitting into HH heads gives (B,H,T,dh)(B,H,T,d_h) with dh=d/Hd_h=d/H. Per-head attention returns (B,H,T,dh)(B,H,T,d_h). Combining heads transposes and reshapes that back to (B,T,d)(B,T,d), and the output projection WO∈Rd×d\mW_O \in \R^{d \times d} keeps (B,T,d)(B,T,d). The code path is the pair of split_heads and combine_heads in Section 20.1.

Solution 20.3 ★★ Parameter and FLOP budget

Each of WQ\mW_Q, WK\mW_K, WV\mW_V, and WO\mW_O has d2d^2 parameters, so the total is 4d24d^2. For self-attention, the four dense projections cost 4BTd24BTd^2 multiply-adds. Scores cost BHT2dh=BT2dBHT^2d_h=BT^2d, and multiplying weights by values costs another BT2dBT^2d, giving 4BTd2+2BT2d4BTd^2+2BT^2d. The helper computes the same budget for a tiny setting:

Tiny MHA budget
def tiny_budget(model_width=8, length=5, batch=2):
    """Parameters and dominant self-attention FLOPs for a tiny setting."""
    flops = self_attention_flops(batch, length, model_width)
    return parameter_count(model_width), flops
Solution 20.4 ★★★ Gradient-check MHA

Reverse the forward pass. Backpropagate through WO\mW_O, split the concatenated gradient into heads, call the single-head attention backward for each head, combine query/key/value head gradients, and then backpropagate through WQ\mW_Q, WK\mW_K, and WV\mW_V. For self-attention the same input fed all three projections, so add those three input gradients before comparing with finite differences. The tests also verify that a causal mask is shared across heads:

First-head causal weights
def first_head_causal_weights():
    """Return attention weights from the first head of a tiny masked MHA."""
    X = np.arange(12, dtype=np.float64).reshape(1, 3, 4) / 10
    eye = np.eye(4)
    params = (eye, eye, eye, eye)
    _out, cache = multi_head_attention_forward(
        X, X, X, params, 2, causal_mask(3), True
    )
    return cache[5][3][0, 0]

21 Positional Encoding & RoPE

Solution 21.1 ★ Prove equivariance

Let S=QK⊤/dh\mS=\mQ\mK^{\T}/\sqrt{d_h}. After applying the same permutation to queries and keys, the scores are PSP⊤\mP\mS\mP^{\T}. Rowwise softmax preserves that row and column permutation, so the weights are Psoftmax⁡(S)P⊤\mP\softmax(\mS)\mP^{\T}. Multiplying by PV\mP\mV leaves Psoftmax⁡(S)V\mP\softmax(\mS)\mV, proving the output is merely reordered. A language model must distinguish orders, so it needs an extra positional signal.

Permutation check used by the tests
def permutation_error(x, wq, wk, wv, permutation):
    q, k, v = x @ wq, x @ wk, x @ wv
    original = attention(q, k, v)
    xp = x[permutation]
    permuted = attention(xp @ wq, xp @ wk, xp @ wv)
    return np.max(np.abs(permuted - original[permutation]))
Solution 21.2 ★★ RoPE is relative

For one pair, Rm⊤=R−mR_m^{\T}=R_{-m} because a rotation matrix is orthogonal. Angles add, so R−mRn=Rn−mR_{-m}R_n=R_{n-m}. Summing over all independent two-dimensional pairs gives (21.4). If both positions are shifted by cc, the relative angle becomes (n+c)−(m+c)=n−m(n+c)-(m+c)=n-m, so the dot product is unchanged.

RoPE dot product before and after a shared shift
def shifted_rope_dot(q, k, m, n, shift):
    left = apply_rope(q, m) @ apply_rope(k, n)
    shifted = apply_rope(q, m + shift) @ apply_rope(k, n + shift)
    return left, shifted
Solution 21.3 ★★ ALiBi row

The last query has t=L−1t=L-1, so the row is [−α(L−1),−α(L−2),…,−α,0][-\alpha(L-1), -\alpha(L-2), \ldots, -\alpha, 0]. A larger α\alpha makes distant past keys pay a larger negative bias before softmax, concentrating attention more strongly on recent keys unless the content score overcomes it.

Last-query ALiBi row
def last_query_alibi(length, slope):
    return alibi_bias(length, slope)[-1]
Solution 21.4 ★★★ Implement and test RoPE

Use positions shaped (1,T,1)(1,T,1) so the cosine and sine tables broadcast across batch and heads. The even coordinates receive xecos⁡ϕ−xosin⁡ϕx_e\cos\phi-x_o\sin\phi, and the odd coordinates receive xesin⁡ϕ+xocos⁡ϕx_e\sin\phi+x_o\cos\phi. The inverse uses the negative angle. The chapter tests check inverse recovery and the shared-shift dot-product identity on seeded random arrays.

RoPE implementation
def apply_rope(x, positions, base=10_000.0, inverse=False):
    """Rotate each adjacent 2-D pair by positions * theta_i."""
    x = np.asarray(x)
    if x.shape[-1] % 2:
        raise ValueError("RoPE needs an even last dimension")
    positions = np.asarray(positions, dtype=x.dtype)
    if positions.ndim == 1 and x.ndim > 2:
        shape = [1] * (x.ndim - 1)
        shape[1] = positions.size
        positions = positions.reshape(shape)
    theta = rope_frequencies(x.shape[-1], base).astype(x.dtype)
    angles = positions[..., None] * theta
    if inverse:
        angles = -angles
    cos, sin = np.cos(angles), np.sin(angles)
    y = np.empty_like(x)
    even, odd = x[..., 0::2], x[..., 1::2]
    y[..., 0::2] = even * cos - odd * sin
    y[..., 1::2] = even * sin + odd * cos
    return y

22 The Transformer Block

Solution 22.1 ★ Residual-stream view

Attention is the communication sublayer: each token reads earlier tokens through causal weights and writes a proposed update. The feed-forward network is the local computation sublayer: it applies the same nonlinear map to each token independently. Pre-norm leaves the residual stream itself on an identity path, so gradients can move from deep layers to shallow layers without first passing through attention or the MLP.

Solution 22.2 ★★ RMSNorm backward

Let zi=xirz_i=x_i r and c=∑izˉixic=\sum_i \bar{z}_i x_i. Since r=(d−1∑jxj2+ϵ)−1/2r=(d^{-1}\sum_j x_j^2+\epsilon)^{-1/2},

∂r∂xi=−xidr3.\frac{\partial r}{\partial x_i}=-\frac{x_i}{d}r^3 .

The direct path gives rzˉir\bar{z}_i. The path through rr contributes c(−xir3/d)c(-x_i r^3/d). Adding them gives (22.3). With a learned gain, first set zˉi=yˉigi\bar{z}_i=\bar{y}_i g_i, and the gain gradient is gˉi=∑yˉizi\bar{g}_i=\sum \bar{y}_i z_i over batch and time.

Solution 22.3 ★★ Parameter count

Attention has query, key, value, and output matrices, each d×dd\times d, for 4d24d^2. SwiGLU has gate and up matrices d×hd\times h and a down matrix h×dh\times d, for 3dh3dh. Thus

Pblock≈4d2+3dh.P_{\text{block}} \approx 4d^2 + 3dh .

If h=4dh=4d, the block has 16d216d^2 parameters. If h=8d/3h=8d/3, the feed-forward part has 8d28d^2 and the block is near 12d212d^2, matching the classic attention-plus-MLP budget.

Counting the matrices
def block_parameter_count(d_model, hidden_dim):
    attention = 4 * d_model * d_model
    swiglu = 3 * d_model * hidden_dim
    norms = 2 * d_model
    return attention + swiglu + norms
Solution 22.4 ★★★ Gradient-check a block

The tests build a block with one batch item, a short sequence, two heads, and float64 parameters. They run the forward pass, backpropagate a fixed upstream array, flatten all parameter gradients, and compare both input and parameter gradients with central differences using scratch.gradcheck.check_gradient.

Scalar loss for the gradient check
def block_scalar_loss(x, params, n_heads, upstream):
    y, _ = transformer_block_forward(x, params, n_heads)
    return float(np.sum(y * upstream))

23 Training a GPT from Scratch

Solution 23.1 ★ Shifted targets

For start ii and length TT, the input row is (di,di+1,…,di+T−1)(d_i,d_{i+1},\ldots,d_{i+T-1}). The target row is (di+1,di+2,…,di+T)(d_{i+1},d_{i+2},\ldots,d_{i+T}). Because the transformer returns logits at every input position, each position predicts the next character for its prefix, giving TT supervised next-token examples from one contiguous slice.

Solution 23.2 ★★ Tied embedding gradient

For the output head z=hE⊤\vz=\vh\mE^{\T}, each row of E\mE receives the classifier gradient Eˉc,:+=zˉch\bar{E}_{c,:} += \bar{z}_c \vh. The hidden state also receives hˉ=zˉE\bar{\vh}=\bar{\vz}\mE. Backpropagating to the embedding lookup adds the upstream gradient for each input occurrence into that token’s row. If a character appears multiple times in the batch, np.add.at accumulates all of those sparse contributions.

Solution 23.3 ★★ Warmup and AdamW

Warmup makes the first few Adam steps smaller while the moment estimates are still settling. AdamW decouples shrinkage from the adaptive gradient: the code multiplies each parameter by 1−ηλ1-\eta\lambda and then applies the Adam direction. If weight decay were added to the gradient, Adam’s per-coordinate normalization would change the meaning of the decay term.

AdamW update
def adamw_step(params, grads, state, lr, weight_decay=0.01,
               beta1=0.9, beta2=0.999, eps=1e-8):
    state["t"] = state.get("t", 0) + 1
    t = state["t"]
    for path, param, grad in tree_items(params, grads):
        slot = state.setdefault(path, {
            "m": np.zeros_like(param),
            "v": np.zeros_like(param),
        })
        slot["m"] = beta1 * slot["m"] + (1.0 - beta1) * grad
        slot["v"] = beta2 * slot["v"] + (1.0 - beta2) * (grad * grad)
        m_hat = slot["m"] / (1.0 - beta1 ** t)
        v_hat = slot["v"] / (1.0 - beta2 ** t)
        param *= 1.0 - lr * weight_decay
        param -= lr * m_hat / (np.sqrt(v_hat) + eps)
Solution 23.4 ★★★ Train and sample

The tests create one fixed batch, run several AdamW updates, and assert that the last loss is smaller than the first. They also initialize a tiny model and call sample_text twice with the same seed, temperature, and top-k setting; both calls must produce the same string.

Sampling helper
def sample_text(params, prompt, stoi, itos, n_heads, steps, seed=0,
                temperature=1.0, top_k=None, max_context=64):
    rng = np.random.default_rng(seed)
    ids = [stoi[ch] for ch in prompt]
    for _ in range(steps):
        context = np.array([ids[-max_context:]], dtype=np.int64)
        logits = gpt_logits(params, context, n_heads)[0, -1] / temperature
        if top_k is not None and top_k < logits.size:
            keep = np.argpartition(logits, -top_k)[-top_k:]
            masked = np.full_like(logits, -np.inf)
            masked[keep] = logits[keep]
            logits = masked
        probs = softmax(logits)
        ids.append(int(sample_categorical(probs[None, :], rng)[0]))
    return "".join(itos[i] for i in ids)

24 KV Cache & Grouped-Query Attention

Solution 24.1 ★ Prefill versus decode

For the newest token tt, full recomputation evaluates the same projections ks,vs\vk_s, \vv_s for every s≤ts \le t that earlier decode steps already cached. The causal mask must never let a token read a future position. Under that mask, replacing old projection work with cache reads changes only the order of computation, not the attention sum. The tests confirm this with a random NumPy attention layer.

Solution 24.2 ★★ Cache accounting

Each cached token stores one key and one value per layer and KV head. Each vector has dhd_h numbers, and each number has bb bytes, so one sequence costs 2LGdhTb2LGd_hTb bytes.

Cache example in MiB
def memory_example_mib():
    bytes_used = kv_cache_bytes(
        layers=32,
        num_kv_heads=8,
        head_dim=128,
        tokens=4096,
        bytes_per_value=2,
    )
    return bytes_used // (1024 ** 2)

For the chapter’s dimensions this returns 512 MiB, and the test also asserts the exact byte count 536,870,912536{,}870{,}912.

Solution 24.3 ★★ Grouped-query extremes

G=1G=1 is multi-query attention: all query heads share one KV head. G=HG=H assigns one KV head to each query head. Then the group map is g(h)=hg(h)=h, no KV vector is repeated across different query heads, and the GQA equation is the ordinary multi-head equation. The test compares the implementation with a direct multi-head computation in that case.

Solution 24.4 ★★★ Implement a streaming mask

A compact implementation is:

Sliding window with one sink
def last_row_with_sink():
    return sliding_window_mask(tokens=6, window=3, sinks=1)[5].astype(int)

For T=6T=6, W=3W=3, and one sink, the final row is [1, 0, 0, 1, 1, 1]. The last token may read token 0 as the sink and tokens 3, 4, and 5 as the local causal window.

25 Multi-Head Latent Attention

Solution 25.1 ★ What is cached?

MLA caches cs=xsWDKV\vc_s = \vx_s\mW_{DKV} for each previous token. When a query attends, the layer up-projects that latent to head-specific keys and values, or absorbs the key projection into the query for score computation. MHA stores both key and value vectors for every head; MLA stores one latent vector, so its cache scales with dcd_c.

Solution 25.2 ★★ Absorb the key up-projection

With ks=csWUK\vk_s = \vc_s\mW_{UK}, associativity gives

qt⊤ks=qt⊤WUK⊤cs=(qtWUK⊤)⋅cs.\vq_t^\T\vk_s = \vq_t^\T\mW_{UK}^\T\vc_s = (\vq_t\mW_{UK}^\T)\cdot\vc_s .

The key up-projection moves from every cached token to the current query. The tests compute both sides for random tensors and assert equality.

Solution 25.3 ★★ RoPE and position dependence

RoPE inserts position-specific rotations, producing (Rtqt)⊤(RscsWUK)(R_t\vq_t)^\T(R_s\vc_s\mW_{UK}). The term involving RsR_s changes for each cached position, so there is no single absorbed query that works for all ss. A decoupled design keeps a small RoPE key channel outside the absorbed latent content path.

Solution 25.4 ★★★ Compute cache sizes and sparsify

The table values are computed here:

Cache sizes in MiB
def cache_table_mib():
    rows = cache_size_table(
        layers=32,
        tokens=4096,
        bytes_per_value=2,
        heads=32,
        kv_heads=8,
        head_dim=128,
        latent_dim=512,
    )
    return {name: bytes_used // (1024 ** 2) for name, bytes_used in rows.items()}

The result is {MHA: 2048, GQA: 512, MLA: 128} in MiB for the tested dimensions. To make the sparse toy causal, set every index score for future keys to negative infinity before taking the top-k set, then run the same masked softmax.

26 Online Softmax & FlashAttention

Solution 26.1 ★ Stable rows

Subtracting the maximum makes the largest exponent e0e^0, so exponentials avoid overflow while all softmax ratios stay unchanged. Online softmax needs the maximum because every stored partial sum is measured relative to it. If a later block has a larger maximum, the old partial sum must be rescaled to the new reference before adding the new block.

Solution 26.2 ★★ Derive the online update

For old scores, multiply and divide by emolde^{m_{old}}:

∑oldesi−mnew=emold−mnew∑oldesi−mold=emold−mnewℓold.\sum_{old} e^{s_i-m_{new}} = e^{m_{old}-m_{new}}\sum_{old}e^{s_i-m_{old}} = e^{m_{old}-m_{new}}\ell_{old} .

The new block gives emB−mnewℓBe^{m_B-m_{new}}\ell_B. The numerator uses the same weights with values attached:

nnew=emold−mnewnold+emB−mnew∑j∈Besj−mBvj.\vn_{new} = e^{m_{old}-m_{new}}\vn_{old} + e^{m_B-m_{new}}\sum_{j\in B}e^{s_j-m_B}\vv_j .
Solution 26.3 ★★ Memory scaling

A dense implementation has one score for each query-key pair, so it stores T2T^2 score elements. The tiled version keeps a maximum, normalizer, and output numerator per query row, plus the current tile, so persistent row state is linear in TT.

Memory element counts
def memory_elements_for_4096():
    return attention_memory_elements(4096)

For T=4096T=4096, the tested counts are 16,777,21616{,}777{,}216 naive score elements and 40964096 online-state entries.

Solution 26.4 ★★★ Tiled causal attention

The implementation in scratch/flash_attention.py already accepts causal=True. For each key block it builds key positions, compares them with query positions, and sets future scores to negative infinity before the online update. The tests compare tiled causal attention with dense causal attention for random arrays, including uneven block sizes, so the mask and rescaling are checked together.

27 Mixture of Experts

Solution 27.1 ★ Router arithmetic

The selected mass is 0.3+0.2=0.50.3 + 0.2 = 0.5. Renormalizing gives weights 0.3/0.5=0.60.3/0.5 = 0.6 and 0.2/0.5=0.40.2/0.5 = 0.4. They sum to one because every selected probability is divided by the same selected total: ∑i∈Spi/∑j∈Spj=1\sum_{i\in S} p_i / \sum_{j\in S}p_j = 1. The unselected expert contributes no expert output on this token.

Solution 27.2 ★★ Uniform minimizes the Switch fixed point

Use Cauchy’s inequality on ∑ipi=1\sum_i p_i = 1:

1=(∑ipi)2≤N∑ipi2.1 = \Big(\sum_i p_i\Big)^2 \le N\sum_i p_i^2 .

Thus the fixed-point Switch loss N∑ipi2N\sum_i p_i^2 is at least 11. Equality requires all pip_i to be equal, so pi=1/Np_i = 1/N. A collapsed router has one pi=1p_i = 1 and the rest zero, so its loss is NN. The tests assert the uniform value and the collapsed value for a small router.

Solution 27.3 ★★ Capacity and dropping

Expert 0 receives the first three assignments, but its capacity is two. The third assignment to expert 0 is dropped; the assignment to expert 1 is kept. A dropped assignment contributes zero to the weighted sum in (27.3). This is why capacity protects the batch shape and communication budget but can lose information if the router is badly imbalanced.

Solution 27.4 ★★★ Implement and compare

A reference implementation routes one token at a time and accumulates the selected expert outputs directly:

Dense reference MoE
def dense_reference_moe(x, w1, b1, w2, b2, logits, k):
    """Token-by-token reference used to test the sparse implementation."""
    experts, weights, _ = top_k_router(logits, k)
    out_dim = w2.shape[-1]
    y = np.zeros((x.shape[0], out_dim), dtype=x.dtype)
    for token in range(x.shape[0]):
        for slot, expert in enumerate(experts[token]):
            hidden = np.maximum(x[token] @ w1[expert] + b1[expert], 0)
            y[token] += weights[token, slot] * (hidden @ w2[expert] + b2[expert])
    return y

The chapter tests generate seeded random tensors, run this reference, and compare it with sparse_moe_forward to float32 tolerance. Shape checks would miss swapped experts, missing renormalization, and scatter-add mistakes; numerical equality catches those errors.

28 Linear Attention & State-Space Models

Solution 28.1 ★ Kernel replacement

The feature map should make K(q,k)=ϕ(q)⊤ϕ(k)K(\vq,\vk) = \vphi(\vq)^\T\vphi(\vk) nonnegative for every query-key pair used in the denominator. Then the normalized linear-attention formula is a weighted average: each value receives weight K(qt,ks)/∑u≤tK(qt,ku)K(\vq_t,\vk_s) / \sum_{u\le t}K(\vq_t,\vk_u), and the weights sum to one. If scores can be negative, the denominator can cancel or change sign, so the output is no longer an average of values.

Solution 28.2 ★★ Derive the recurrent form

Substitute K(qt,ks)=ϕ(qt)⊤ϕ(ks)K(\vq_t,\vk_s) = \vphi(\vq_t)^\T\vphi(\vk_s) into the numerator:

∑s≤tϕ(qt)⊤ϕ(ks)vs=ϕ(qt)⊤∑s≤tϕ(ks)vs⊤.\sum_{s\le t}\vphi(\vq_t)^\T\vphi(\vk_s)\vv_s = \vphi(\vq_t)^\T\sum_{s\le t}\vphi(\vk_s)\vv_s^\T .

The sum is exactly St\mS_t, which updates by adding the new outer product. The denominator is the same factorization without vs\vv_s, giving ct=∑s≤tϕ(ks)\vc_t = \sum_{s\le t}\vphi(\vk_s). The tests assert that this recurrent computation equals the explicit lower-triangular parallel form.

Solution 28.3 ★★ Error-correcting write

With k=(1,0)\vk = (1,0), only the first row of S\mS matters. Let the current prediction error be et=v−k⊤St\ve_t = \vv - \vk^\T\mS_t. The update with β=1/2\beta = 1/2 adds half the error to that row, so the next prediction error is et+1=et/2\ve_{t+1} = \ve_t/2. After repeated updates the error is multiplied by 1/21/2 each time. The test checks this decay and the closed form after repeated writes.

Solution 28.4 ★★★ Decode one token

A one-token decoding update only needs the current token and the cached state:

One-token linear-attention decoder update
def decode_one(q_t, k_t, v_t, state, normalizer, feature_map, eps=1e-8):
    """One-token update for linear-attention decoding."""
    qt, kt = feature_map(q_t), feature_map(k_t)
    state = state + np.outer(kt, v_t)
    normalizer = normalizer + kt
    y_t = (qt @ state) / max(qt @ normalizer, eps)
    return y_t, state, normalizer

Running this function over a prefix produces the same outputs as recurrent_linear_attention in the tests. The cached state contains dϕdv+dϕd_\phi d_v + d_\phi scalars: the matrix S\mS and vector c\vc. It does not grow with prefix length.

29 Scaling Laws & Pretraining Recipes

Solution 29.1 ★ FLOP accounting

The forward pass touches each parameter for each token at roughly one multiply-add, counted as 22 FLOPs, so it costs 2ND2ND. Backpropagation computes activation and weight gradients and is about twice the forward cost, 4ND4ND. The total is therefore 2ND+4ND=6ND2ND + 4ND = 6ND. If NN doubles while DD is fixed, compute doubles.

Solution 29.2 ★★ Fitting a power law

Take logarithms of y=ax−αy = ax^{-\alpha}:

log⁡y=log⁡a+log⁡x−α=log⁡a−αlog⁡x.\log y = \log a + \log x^{-\alpha} = \log a - \alpha\log x .

Thus a least-squares line fit with input log⁡x\log x and target log⁡y\log y has intercept log⁡a\log a and slope −α-\alpha. The tested implementation recovers both values on synthetic data:

Power-law fit
def fit_power_law(x, y):
    """Fit y = coefficient * x ** (-exponent) in log space."""
    x = np.asarray(x, dtype=np.float64)
    y = np.asarray(y, dtype=np.float64)
    slope, intercept = np.polyfit(np.log(x), np.log(y), deg=1)
    return float(np.exp(intercept)), float(-slope)
Solution 29.3 ★★ Chinchilla allocation

Let M=C/6M=C/6 and D=M/ND=M/N. The part of the loss that depends on NN is

AN−α+B(M/N)−β=AN−α+BM−βNβ.A N^{-\alpha} + B(M/N)^{-\beta} = A N^{-\alpha} + B M^{-\beta}N^\beta .

Differentiating and setting the result to zero gives −αAN−α−1+βBM−βNβ−1=0-\alpha A N^{-\alpha-1} + \beta B M^{-\beta}N^{\beta-1}=0. Multiplying by NN and substituting D=M/ND=M/N gives (29.4). At the optimum, the marginal benefit of spending compute on more parameters matches the marginal benefit of spending it on more tokens.

Solution 29.4 ★★★ Grid-search a frontier

A grid search is short and useful for checking the closed form:

Grid search over feasible Chinchilla candidates
def best_on_grid(compute, parameter_grid, token_grid):
    """Search a small grid for the lowest Chinchilla loss under 6ND <= compute."""
    best = None
    for parameters in parameter_grid:
        for tokens in token_grid:
            if 6 * parameters * tokens > compute:
                continue
            loss = chinchilla_loss(parameters, tokens)
            if best is None or loss < best[0]:
                best = (loss, parameters, tokens)
    return best

The tests verify that every feasible grid point has loss at least as large as the returned one. They also verify the closed-form stationarity condition, so the grid search is a sanity check, not the source of the formula.

30 Contrastive & Metric Learning

Solution 30.1 ★ Temperature and confidence

The target probability is softmax⁡([0.4,0]/τ)1\softmax([0.4,0]/\tau)_1. It is 0.982 at τ=0.1\tau=0.1 and 0.690 at τ=0.5\tau=0.5.

Temperature changes confidence
def positive_probability(gap, temperature):
    logits = np.array([[gap, 0.0]]) / temperature
    return float(softmax_rows(logits)[0, 0])

The smaller temperature makes the same score gap look larger. In (30.5), it also divides the gradient by τ\tau, so it changes update scale.

Solution 30.2 ★★ Derive the InfoNCE gradient

For one row,

Li=−Sii/τ+log⁡∑jexp⁡(Sij/τ).L_i = -S_{ii}/\tau + \log\sum_j \exp(S_{ij}/\tau) .

Differentiating with respect to SijS_{ij} gives −1[i=j]/τ-\one[i=j]/\tau from the first term and Pij/τP_{ij}/\tau from the second. Averaging NN rows gives (30.5). The tests finite-difference the implementation.

Solution 30.3 ★★ SigLIP’s bias gradient

At zero logits, each pair contributes −Yij/2-Y_{ij}/2 before the mean. With three positives and six negatives, the mean gradient is (−3/2+6/2)/9=1/6(-3/2 + 6/2)/9 = 1/6.

Bias gradient for a 3 by 3 SigLIP batch
def one_positive_three_negative_bias():
    similarity = np.zeros((3, 3))
    _, _, grad_bias = siglip_loss_and_grad(similarity, bias=0.0)
    return grad_bias

The positive sign means gradient descent decreases the bias. That raises the effective bar for calling a pair positive, compensating for there being more negatives.

Solution 30.4 ★★★ Build a tiny retriever

The implementation forms a full similarity matrix, trains with the symmetric CLIP loss, and then sorts each row. With the seeded synthetic data used in the tests, recall@1 starts at or below 0.10, then reaches at least 0.70; recall@5 reaches at least 0.90.

from scratch.contrastive_learning import (make_synthetic_pairs,
                                          retrieval_recall_at_k,
                                          train_linear_pair)

images, texts = make_synthetic_pairs(n=32, seed=3)
image_z, text_z = train_linear_pair(images, texts, steps=220, lr=0.7, seed=5)
print(retrieval_recall_at_k(image_z, text_z, k=1))
print(retrieval_recall_at_k(image_z, text_z, k=5))

The threshold, not an exact printed number, is the claim: the tests assert both recalls.

31 Vision Transformers

Solution 31.1 ★ Count visual tokens

The grid is 224/16=14224/16 = 14 patches on each side, so the image has 14⋅14=19614 \cdot 14 = 196 patch tokens and 197 tokens after prepending a class token. At 448×448448 \times 448, the grid is 28×2828 \times 28, giving 784 patch tokens.

Token-count arithmetic
def token_counts():
    grid = 224 // 16
    return grid, grid * grid, sequence_length(224, 224, 16, True)

The tests also assert that sequence_length(448, 448, 16) returns 784.

Solution 31.2 ★★ Patch order

The four patches are the two-by-two blocks in raster order:

def patch_order_example():
    image = np.arange(16, dtype=np.float32).reshape(1, 4, 4, 1)
    return patchify(image, 2)[0].astype(int).tolist()

They evaluate to (0,1,4,5)(0,1,4,5), (2,3,6,7)(2,3,6,7), (8,9,12,13)(8,9,12,13), and (10,11,14,15)(10,11,14,15). The test checks this exact order.

Solution 31.3 ★★ Embedding as convolution

Let a flattened patch be xr,c∈RP2C\vx_{r,c} \in \R^{P^2C} and the embedding weight be WE∈RP2C×d\mW_E \in \R^{P^2C \times d}. Reshape each column of WE\mW_E into a P×P×CP \times P \times C kernel. A stride-PP convolution places that kernel on exactly the pixels of one patch and computes the same dot product xr,cWE+bE\vx_{r,c}\mW_E + \vb_E. Because stride equals patch size, windows do not overlap. The test compares the two arrays.

Solution 31.4 ★★★ Trace a tiny ViT

For 8×88 \times 8 images and 2×22 \times 2 patches, the patch grid is 4×44 \times 4, so there are 16 patch tokens. Class-token mode sends 17 tokens into the encoder and reads token 0. Mean-pooling mode sends 16 tokens and averages them after the encoder. The classification head is shared, so both modes return logits shaped B×classesB \times \text{classes}. The test runs both modes and checks finite 3×33 \times 3 logits on a seeded synthetic batch.

32 Vision-Language Models

Solution 32.1 ★ Prompt classification

The first image is closest to the first prompt and the second image is closest to the second prompt, so the predicted prompt indices are [0, 1].

Toy CLIP zero-shot labels
def toy_zero_shot_label():
    images = np.array([[1.0, 0.0], [0.0, 1.0]])
    prompts = np.array([[0.9, 0.1], [0.1, 0.9], [-1.0, 0.0]])
    return np.argmax(clip_zero_shot(images, prompts), axis=1).tolist()

No classifier head is needed because the text embeddings are the class weights. Changing labels means encoding different prompts and recomputing similarities.

Solution 32.2 ★★ Flamingo’s identity start

Substitute g=0g=0 into (32.3). Since tanh⁡(0)=0\tanh(0)=0, the residual branch becomes 0⋅CrossAttn⁡(X,V)=00 \cdot \operatorname{CrossAttn}(\mX,\mV)=0. Therefore X′=X\mX'=\mX for any image tokens V\mV. The tests assert exact equality at initialization and a changed output when the gate is nonzero.

Solution 32.3 ★★ Dynamic-resolution arithmetic

The patch grid is 336/14=24336/14 = 24 by 672/14=48672/14 = 48, so the image has 24⋅48=115224 \cdot 48 = 1152 visual tokens before merging. A 2 by 2 merge halves both grid axes, giving 12⋅24=28812 \cdot 24 = 288 tokens.

Dynamic-resolution token counts
def dynamic_counts_example():
    return dynamic_token_counts(336, 672, patch_size=14, merge=2)

The code returns ((24, 48), 1152, 288), and the test asserts those values.

Solution 32.4 ★★★ Fixed visual prefix

perceiver_resampler broadcasts the learned queries across the batch, uses them as cross-attention queries, and attends over however many image tokens the encoder produced. The output length is therefore the number of learned queries, not the image-token length. With four queries, both a short sequence and a long sequence return shape B×4×dB \times 4 \times d. The bottleneck is that all visual evidence must be compressed into those four output tokens before the LLM sees it.

33 Supervised Fine-Tuning & LoRA

Solution 33.1 ★ Template discipline

Role markers are part of the token sequence. If training writes <|user|> and inference writes User:, the first tokens after every turn come from a distribution the model did not practice. In a user-assistant example, all tokens are context for later positions, but the loss mask is 1 only on assistant targets. User, system, padding, and boundary tokens have mask 0. The test test_chat_template_keeps_role_markers_and_masks_assistant_targets checks this exact split.

Solution 33.2 ★★ Masked gradient

For one position, ∂(−log⁡py)/∂zk=pk−1[k=y]\partial(-\log p_y)/\partial z_k = p_k-\one[k=y]. Multiplying by mtm_t and by the normalizer 1/M1/M in (33.1) gives (33.2). If mt=0m_t=0, every component of zˉt\bar{\vz}_t is zero, so changing that position’s logits cannot change the loss.

Masked loss and gradient check
def softmax(logits, axis=-1):
    """Stable softmax."""
    shifted = logits - np.max(logits, axis=axis, keepdims=True)
    exp = np.exp(shifted)
    return exp / np.sum(exp, axis=axis, keepdims=True)


def masked_cross_entropy(logits, targets, train_mask):
    """Mean next-token cross-entropy over masked positions, with gradient."""
    logits = np.asarray(logits)
    targets = np.asarray(targets, dtype=np.int64)
    mask = np.asarray(train_mask, dtype=bool)
    if not np.any(mask):
        raise ValueError("at least one position must be trainable")
    probabilities = softmax(logits, axis=-1)
    rows = np.arange(targets.shape[0])
    losses = -np.log(probabilities[rows, targets])
    normalizer = np.sum(mask)
    loss = np.sum(np.where(mask, losses, 0.0)) / normalizer
    grad_logits = probabilities.copy()
    grad_logits[rows, targets] -= 1.0
    grad_logits *= mask[:, None] / normalizer
    return loss, grad_logits
Solution 33.3 ★★ Packed boundaries

Both documents fit in one length-4 row after inserting boundaries:

tokens=[1,2,99,3],ids=[0,0,−1,1].\text{tokens}=[1,2,99,3],\qquad \text{ids}=[0,0,-1,1].

The inputs are [1,2,99][1,2,99], and the targets are [2,99,3][2,99,3]. The mask is [true,false,false][\text{true},\text{false},\text{false}]: token 1 may predict token 2 inside document 0, but token 2 should not predict the boundary and the boundary should not predict the next document. The chapter test uses the same rule on a larger packed batch.

Solution 33.4 ★★★ LoRA implementation

Let Δ=BA\boldsymbol{\Delta}=\mB\mA and Δˉ\bar{\boldsymbol{\Delta}} be the upstream gradient after the α/r\alpha/r scale and the XX multiply are accounted for. The differential is

dL=⟨Δˉ,dBA+BdA⟩=⟨ΔˉA⊤,dB⟩+⟨B⊤Δˉ,dA⟩.dL = \langle \bar{\boldsymbol{\Delta}}, d\mB\mA + \mB d\mA \rangle = \langle \bar{\boldsymbol{\Delta}}\mA^\T, d\mB \rangle + \langle \mB^\T\bar{\boldsymbol{\Delta}}, d\mA \rangle .

Restoring the scale gives (33.4). For 4096×40964096\times4096 and r=8r=8, the code computes 16,777,21616{,}777{,}216 base weights and 65,53665{,}536 LoRA weights, so the trainable matrix parameters are reduced by 256×256\times. The tests gradient-check both factors and assert those numbers.

LoRA gradients and parameter counting
def lora_gradients(x, a, b, alpha, grad_y):
    """Backpropagate through the LoRA update for a loss on Y."""
    scale = alpha / a.shape[0]
    grad_update = x.T @ grad_y
    grad_a = scale * b.T @ grad_update
    grad_b = scale * grad_update @ a.T
    return grad_a, grad_b


def lora_mse_loss_and_grads(x, w, a, b, alpha, target):
    """Tiny objective used by the chapter tests."""
    y = lora_output(x, w, a, b, alpha)
    diff = y - target
    loss = 0.5 * np.mean(diff * diff)
    grad_y = diff / diff.size
    grad_a, grad_b = lora_gradients(x, a, b, alpha, grad_y)
    return loss, grad_a, grad_b


def lora_parameter_savings(d_in, d_out, rank):
    base = d_in * d_out
    trainable = rank * (d_in + d_out)
    return base, trainable, base / trainable

34 Reinforcement Learning Foundations

Solution 34.1 ★ Bellman arithmetic

The terminal state’s value is 0. State 1 gives reward 1 and then terminates, so v(1)=1+0.8⋅0=1v(1)=1+0.8\cdot0=1. State 0 gives reward 0 and moves to state 1, so v(0)=0+0.8v(1)=0.8v(0)=0+0.8v(1)=0.8. The same computation is the Bellman solve in the tested tiny_chain helper.

Solution 34.2 ★★ Score-function policy gradient

Use the score-function identity on the trajectory distribution:

∇J=∇∑τpθ(τ)G0(τ)=∑τpθ(τ)G0(τ)∇log⁡pθ(τ).\nabla J = \nabla \sum_\tau p_\vtheta(\tau)G_0(\tau) = \sum_\tau p_\vtheta(\tau)G_0(\tau)\nabla\log p_\vtheta(\tau).

The trajectory log-probability is environment terms plus ∑tlog⁡πθ(at∣st)\sum_t\log\pi_\vtheta(a_t\mid s_t). Environment terms have zero θ\vtheta gradient. Rewards before time tt are fixed before action ata_t, so their expected score term is zero; replacing G0G_0 by GtG_t gives (34.4). The bandit test gradient-checks the resulting categorical gradient.

Solution 34.3 ★★ Baselines and importance sampling

Condition on sts_t. Since b(st)b(s_t) does not depend on the sampled action,

∑aπ(a∣st)b(st)∇log⁡π(a∣st)=b(st)∑a∇π(a∣st)=b(st)∇1=0.\sum_a \pi(a\mid s_t)b(s_t)\nabla\log\pi(a\mid s_t) = b(s_t)\sum_a \nabla\pi(a\mid s_t) = b(s_t)\nabla 1 = 0.

For importance sampling, use one copy of each behavior probability in expectation:

0.80.250.8⋅1+0.20.750.2⋅3=0.25+2.25=2.5.0.8\frac{0.25}{0.8}\cdot1 + 0.2\frac{0.75}{0.2}\cdot3 = 0.25 + 2.25 = 2.5.

The chapter test builds a logged batch with exactly those behavior proportions and checks the estimate.

Solution 34.4 ★★★ GAE recursion

Write the first few TD errors:

δt=rt+γVt+1−Vt,γδt+1=γrt+1+γ2Vt+2−γVt+1.\delta_t = r_t + \gamma V_{t+1} - V_t, \quad \gamma\delta_{t+1}=\gamma r_{t+1}+\gamma^2 V_{t+2}-\gamma V_{t+1}.

Intermediate value terms cancel, so ∑l=0k−1γlδt+l\sum_{l=0}^{k-1}\gamma^l\delta_{t+l} equals the kk-step reward sum plus γkVt+k−Vt\gamma^k V_{t+k}-V_t. Separating the first term of (34.9) gives

A^t=δt+γλ∑l=0∞(γλ)lδt+1+l=δt+γλA^t+1.\hat A_t = \delta_t + \gamma\lambda\sum_{l=0}^{\infty}(\gamma\lambda)^l\delta_{t+1+l} = \delta_t + \gamma\lambda\hat A_{t+1}.
Generalized advantage estimation
def gae_recursive(rewards, values, gamma, lam):
    """Generalized advantage estimates by the backward recursion."""
    deltas = td_errors(rewards, values, gamma)
    advantages = np.zeros_like(deltas)
    running = 0.0
    for t in range(len(deltas) - 1, -1, -1):
        running = deltas[t] + gamma * lam * running
        advantages[t] = running
    return advantages

35 Reward Models, PPO & RLHF

Solution 35.1 ★ Preference likelihood

The reward margin is d=2−0.5=1.5d=2-0.5=1.5. The Bradley-Terry probability is σ(1.5)=1/(1+e−1.5)≈0.818\sigma(1.5)=1/(1+e^{-1.5})\approx0.818. The loss for the preferred response winning is −log⁡σ(1.5)≈0.201-\log\sigma(1.5)\approx0.201. A larger positive margin makes the preference more likely and the loss smaller; a negative margin would mean the model currently scores the loser above the winner.

Solution 35.2 ★★ Reward-model gradient

For d=(xw−xl)⊤ϕd=(\vx_w-\vx_l)^\T\vphi,

∂∂dlog⁡(1+e−d)=−e−d1+e−d=σ(d)−1.\frac{\partial}{\partial d}\log(1+e^{-d}) = -\frac{e^{-d}}{1+e^{-d}} = \sigma(d)-1.

Thus

∇ϕℓ=(σ(d)−1)(xw−xl).\nabla_\vphi \ell = (\sigma(d)-1)(\vx_w-\vx_l).

Gradient descent subtracts this vector. Since σ(d)−1<0\sigma(d)-1<0, the update moves ϕ\vphi toward xw−xl\vx_w-\vx_l, raising rwr_w relative to rlr_l. The test gradient-checks this expression on synthetic preference pairs.

Bradley-Terry reward model
def bradley_terry_loss_and_grad(weights, winners, losers):
    """Loss -log sigmoid(r_w - r_l) for a linear reward model."""
    weights = np.asarray(weights, dtype=np.float64)
    features = np.asarray(winners) - np.asarray(losers)
    margins = features @ weights
    loss = np.mean(np.logaddexp(0.0, -margins))
    sigmoid = 1.0 / (1.0 + np.exp(-margins))
    grad = ((sigmoid - 1.0)[:, None] * features).mean(axis=0)
    return float(loss), grad


def train_reward_model(winners, losers, steps=300, lr=0.5):
    weights = np.zeros(winners.shape[1], dtype=np.float64)
    for _ in range(steps):
        _, grad = bradley_terry_loss_and_grad(weights, winners, losers)
        weights -= lr * grad
    return weights
Solution 35.3 ★★ PPO clipping cases

For A>0A>0, the unclipped term rArA increases with rr. The minimum in (35.5) is active as rArA while r≤1+ϵr\le1+\epsilon, and becomes the constant (1+ϵ)A(1+\epsilon)A when r>1+ϵr>1+\epsilon. So the gradient is Ar∇log⁡πA r\nabla\log\pi up to the upper clip and zero above it.

For A<0A<0, lowering rr improves the objective. The clipped constant (1−ϵ)A(1-\epsilon)A is selected when r<1−ϵr<1-\epsilon, so the gradient is zero below the lower clip and Ar∇log⁡πA r\nabla\log\pi otherwise. The chapter tests check both zero-gradient blocked regions and finite-difference the active region.

PPO clipped surrogate and gradient
def ppo_clipped_objective_and_grad(
    logits,
    old_logits,
    actions,
    advantages,
    clip_eps=0.2,
):
    """Mean PPO clipped surrogate and its gradient for one categorical state."""
    logits = np.asarray(logits, dtype=np.float64)
    old_logits = np.asarray(old_logits, dtype=np.float64)
    actions = np.asarray(actions, dtype=np.int64)
    advantages = np.asarray(advantages, dtype=np.float64)
    probs = softmax(logits)
    old_probs = softmax(old_logits)
    ratios = probs[actions] / old_probs[actions]
    clipped = np.clip(ratios, 1.0 - clip_eps, 1.0 + clip_eps)
    objective_terms = np.minimum(ratios * advantages, clipped * advantages)

    active = np.where(
        advantages >= 0.0,
        ratios <= 1.0 + clip_eps,
        ratios >= 1.0 - clip_eps,
    )
    grad = np.zeros_like(logits)
    for action, ratio, advantage, is_active in zip(actions, ratios, advantages, active):
        if not is_active:
            continue
        grad_logp = -probs.copy()
        grad_logp[action] += 1.0
        grad += advantage * ratio * grad_logp
    return float(np.mean(objective_terms)), grad / len(actions)
Solution 35.4 ★★★ Toy PPO implementation

With rewards [0,1,−0.2][0,1,-0.2], action 1 is best. The tested run starts from uniform logits, repeatedly samples a batch from the old policy, forms advantages by subtracting the batch mean reward, and applies clipped PPO ascent. The final probability of action 1 is above 0.9, and the test also confirms it increased from the first recorded policy.

For values (1,2)(1,2) and returns (0,4)(0,4), the squared value loss is 12((1−0)2+(2−4)2)/2=1.25\tfrac12((1-0)^2+(2-4)^2)/2=1.25. Its gradient is ((1−0),(2−4))/2=(0.5,−1)((1-0),(2-4))/2=(0.5,-1). Both numbers are asserted in the test.

Value loss, entropy, and toy PPO
def categorical_entropy(probs):
    probs = np.asarray(probs, dtype=np.float64)
    return float(-np.sum(probs * np.log(probs)))


def value_loss_and_grad(values, returns):
    values = np.asarray(values, dtype=np.float64)
    returns = np.asarray(returns, dtype=np.float64)
    diff = values - returns
    return float(0.5 * np.mean(diff * diff)), diff / diff.size


def toy_ppo_run(action_rewards, steps=80, batch_size=96, lr=0.35, seed=0):
    """PPO on a one-state categorical policy with known action rewards."""
    rng = np.random.default_rng(seed)
    rewards = np.asarray(action_rewards, dtype=np.float64)
    logits = np.zeros_like(rewards)
    history = []
    for _ in range(steps):
        old_logits = logits.copy()
        old_probs = softmax(old_logits)
        actions = rng.choice(len(rewards), size=batch_size, p=old_probs)
        batch_rewards = rewards[actions]
        advantages = batch_rewards - np.mean(batch_rewards)
        for _ in range(4):
            _, grad = ppo_clipped_objective_and_grad(
                logits,
                old_logits,
                actions,
                advantages,
            )
            logits += lr * grad
        history.append(softmax(logits))
    return logits, np.array(history)

36 Direct Preference Optimization

Solution 36.1 ★ Derive the optimum

Let Z=∑yπ0(y)er(y)/βZ = \sum_y \pi_0(y)e^{r(y)/\beta} and π∗(y)=π0(y)er(y)/β/Z\pi^*(y) = \pi_0(y)e^{r(y)/\beta}/Z. Then

J(π)=∑yπ(y)r(y)−β∑yπ(y)log⁡π(y)π0(y)=β∑yπ(y)log⁡π0(y)er(y)/βπ(y)=βlog⁡Z−β∑yπ(y)log⁡π(y)π∗(y).\begin{aligned} J(\pi) &= \sum_y \pi(y)r(y) - \beta\sum_y \pi(y)\log\frac{\pi(y)}{\pi_0(y)} \\ &= \beta\sum_y \pi(y)\log\frac{\pi_0(y)e^{r(y)/\beta}}{\pi(y)} \\ &= \beta\log Z - \beta\sum_y \pi(y)\log\frac{\pi(y)}{\pi^*(y)}. \end{aligned}

The last sum is DKL(π∥π∗)\KL(\pi\Vert\pi^*). By Section 7.4, it is nonnegative and equals zero only when π=π∗\pi=\pi^*. The tests compare this rewritten form with the original objective for several categorical policies.

Solution 36.2 ★★ Cancel the partition function

For two answers to the same prompt,

r(yw)−r(yl)=βlog⁡π(yw)π0(yw)+βlog⁡Z−βlog⁡π(yl)π0(yl)−βlog⁡Z=β(log⁡π(yw)π0(yw)−log⁡π(yl)π0(yl)).\begin{aligned} r(y_w)-r(y_l) &= \beta\log\frac{\pi(y_w)}{\pi_0(y_w)} + \beta\log Z \\ &\quad - \beta\log\frac{\pi(y_l)}{\pi_0(y_l)} - \beta\log Z \\ &= \beta\Big(\log\frac{\pi(y_w)}{\pi_0(y_w)} - \log\frac{\pi(y_l)}{\pi_0(y_l)}\Big). \end{aligned}
The DPO logit after cancellation
def dpo_delta(logp_w, logp_l, logref_w, logref_l, beta):
    return beta * ((logp_w - logref_w) - (logp_l - logref_l))

The tests construct a policy from the closed-form optimum, recover the implicit reward including βlog⁡Z\beta\log Z, and check that the preference logit equals the true reward difference.

Solution 36.3 ★★ Interpret the gradient

For L=−log⁡σ(δ^)\mathcal{L}=-\log\sigma(\hat{\delta}), use ddδlog⁡σ(δ)=1−σ(δ)\frac{d}{d\delta}\log\sigma(\delta)=1-\sigma(\delta):

∂L∂δ^=σ(δ^)−1=−σ(−δ^).\frac{\partial \mathcal{L}}{\partial \hat{\delta}} = \sigma(\hat{\delta}) - 1 = -\sigma(-\hat{\delta}).

Then multiply by ∇δ^=β∇(log⁡πw−log⁡πl)\nabla\hat{\delta}=\beta\nabla(\log\pi_w-\log\pi_l). The multiplier σ(−δ^)\sigma(-\hat{\delta}) is near one for a badly ranked pair and near zero for an already confident pair.

The hard-pair weight
def preference_weight(delta):
    return sigmoid(-delta)

The test checks the analytic gradient against finite differences.

Solution 36.4 ★★★ Implement the categorical toy

Use soft Bradley—​Terry targets pij=σ(ri−rj)p_{ij}=\sigma(r_i-r_j) for each unordered pair and binary cross-entropy on the DPO logit. The derivative for pair i,ji,j is β(σ(δ^ij)−pij)\beta(\sigma(\hat\delta_{ij})-p_{ij}) for item ii and its negative for item jj. Gradient descent then drives δ^ij\hat\delta_{ij} toward ri−rjr_i-r_j for every pair, so the learned policy has the same normalized form as (36.2).

The toy run’s maximum probability error
def toy_distance():
    reference = np.array([0.50, 0.30, 0.20])
    rewards = np.array([0.0, 0.7, -0.4])
    policy, optimum = fit_toy_dpo(reference, rewards, beta=0.6)
    return float(np.max(np.abs(policy - optimum)))

The tests assert that this error is below 10−310^{-3} and gradient-check the expected loss.

37 GRPO & Verifiable Rewards

Solution 37.1 ★ Compute group-relative advantages

For (1,0,0)(1,0,0), the mean is 1/31/3. The variance is ((2/3)2+(−1/3)2+(−1/3)2)/3=2/9((2/3)^2 + (-1/3)^2 + (-1/3)^2)/3 = 2/9, so the standard deviation is 2/3\sqrt{2}/3. The advantages are therefore approximately (1.414,−0.707,−0.707)(1.414,-0.707,-0.707).

Group-relative advantages for one passing answer
def centered_group_example():
    rewards = np.array([[1.0, 0.0, 0.0]])
    return group_advantages(rewards)[0]

For (0,0,0)(0,0,0), every reward equals the group mean and the standard deviation is zero, so the implementation returns all zeros. A prompt with no within-group ranking gives no direction for the policy update. The tests check zero mean and unit standard deviation for the mixed group.

Solution 37.2 ★★ Show what k3 estimates

With samples from πθ\pi_\theta, let u(y)=π0(y)/πθ(y)u(y)=\pi_0(y)/\pi_\theta(y). Then Eπθ[u]=∑yπ0(y)=1\E_{\pi_\theta}[u]=\sum_y \pi_0(y)=1. Hence

Eπθ[(u−1)−log⁡u]=0+Eπθ[log⁡πθ(y)π0(y)]=DKL(πθ∥π0).\E_{\pi_\theta}[(u-1)-\log u] = 0 + \E_{\pi_\theta}\Big[\log\frac{\pi_\theta(y)}{\pi_0(y)}\Big] = \KL(\pi_\theta\Vert\pi_0).
Exact expectation of k3 for a categorical policy
def exact_k3_kl(policy, reference):
    logp = np.log(policy)
    logref = np.log(reference)
    return float(np.sum(policy * k3_kl_to_reference(logp, logref)))

The tests compare this sum with the shared KL implementation and also check that each sample’s k3k_3 value is nonnegative.

Solution 37.3 ★★ Compare length normalizations

Sequence-level normalization gives the two completions equal weights (1/2,1/2)(1/2,1/2). Token-level normalization divides by the total of 88 tokens, giving weights (2/8,6/8)(2/8,6/8).

Sequence and token weights
def example_weights():
    lengths = np.array([[2.0, 6.0]])
    sequence = normalization_weights(lengths, "sequence")
    token = normalization_weights(lengths, "token")
    return sequence, token

With sequence-level weighting, a short answer and a long answer can have equal influence. With token-level weighting, the long answer contributes more token gradients. This is why length policy and loss normalization cannot be separated.

Solution 37.4 ★★★ Implement the toy RLVR run

The implementation treats each candidate answer as one categorical sequence. Each step freezes the current logits as πold\pi_{\mathrm{old}}, computes group-relative advantages from verifier rewards, and applies the clipped loss plus a small reference KL penalty. The gradient is checked by finite differences in the tests.

Correct-answer probabilities after toy GRPO
def toy_correct_probabilities():
    policy, rewards = toy_grpo_run()
    correct = rewards.astype(bool)
    return policy[correct]

The tests assert that both correct answers exceed probability 0.70.7 and that each wrong answer falls below 0.160.16.

38 Distillation & Reasoning Models

Solution 38.1 ★ Explain soft targets

Temperature divides the logits before softmax. With T=1T=1, (4,1,−1)(4,1,-1) is sharply peaked on the first class. With larger TT, the same ordering remains, but probability moves from the top class into the lower classes.

Softened teacher probabilities
def softened_teacher(teacher_logits, temperature):
    return softmax(np.asarray(teacher_logits) / temperature)

The useful information is in the relative sizes of the wrong classes. A target that says one wrong class is much more plausible than another gives the student a smoother learning signal than a one-hot label.

Solution 38.2 ★★ Derive the T2T^2T2 gradient scale

For pT=softmax⁡(zs/T)\vp_T=\softmax(\vz^s/T), the usual softmax-cross-entropy derivative with respect to zs/T\vz^s/T is pT−qT\vp_T-\vq_T. By the chain rule,

∂∂zs[−∑kqT,klog⁡pT,k]=pT−qTT.\frac{\partial}{\partial \vz^s} \Big[-\sum_k q_{T,k}\log p_{T,k}\Big] = \frac{\vp_T-\vq_T}{T}.

Multiplying the objective by T2T^2 gives T(pT−qT)T(\vp_T-\vq_T). Since the difference between softened distributions shrinks roughly like 1/T1/T, the multiplier keeps gradients from vanishing as temperature rises.

Scaled and unscaled gradients
def scaled_and_unscaled_gradients(student_logits, teacher_logits, temperature):
    _, scaled = distillation_loss_and_grad(student_logits, teacher_logits,
                                           temperature, scale_t2=True)
    _, unscaled = distillation_loss_and_grad(student_logits, teacher_logits,
                                             temperature, scale_t2=False)
    return scaled, unscaled

The tests assert that the scaled gradient is exactly T2T^2 times the unscaled gradient and check it against finite differences.

Solution 38.3 ★★ Compute majority-vote accuracy

For majority vote with p=0.6p=0.6 and n=5n=5, sum the cases with three, four, or five correct samples:

(53)0.630.42+(54)0.640.4+0.65=0.68256.{5\choose3}0.6^3 0.4^2 + {5\choose4}0.6^4 0.4 + 0.6^5 = 0.68256.

For best-of-55 with a perfect selector,

1−(1−0.6)5=0.98976.1 - (1 - 0.6)^5 = 0.98976.
Exact vote values
def vote_values():
    return majority_vote_accuracy(0.6, 5), best_of_n_accuracy(0.6, 5)

The second number is larger because it assumes a verifier can find one correct answer among the samples; majority voting has no such selector.

Solution 38.4 ★★★ Implement and check distillation

The implementation computes softened distributions, the T2T^2-scaled KL loss, and its analytic gradient. It also computes best-of-nn directly and majority vote by summing the binomial tail from strict majority to nn.

Temperature distillation implementation
def distillation_loss_and_grad(student_logits, teacher_logits, temperature=1.0,
                               scale_t2=True):
    """KL teacher_T || student_T, with optional T^2 multiplier."""
    student_logits = np.asarray(student_logits, dtype=np.float64)
    teacher_logits = np.asarray(teacher_logits, dtype=np.float64)
    teacher = softmax(teacher_logits / temperature)
    log_student = log_softmax(student_logits / temperature)
    loss = -np.sum(teacher * log_student)
    grad = (softmax(student_logits / temperature) - teacher) / temperature
    if scale_t2:
        loss *= temperature ** 2
        grad *= temperature ** 2
    return float(loss), grad

The tests gradient-check the distillation loss, verify the T2T^2 scaling relation, and assert the exact binomial values from Exercise 38.3.

39 Decoding & Speculative Sampling

Solution 39.1 ★ Filters

Top-k with k=2k=2 keeps the first two tokens and renormalizes to (2/3,1/3,0,0)(2/3, 1/3, 0, 0). Top-p with threshold 0.70 also keeps the first two, because 0.500.50 is not enough and 0.50+0.25=0.750.50 + 0.25 = 0.75 crosses the threshold. Min-p with α=0.30\alpha=0.30 keeps tokens with probability at least 0.150.15, so it keeps the first three and gives (0.50,0.25,0.15)/0.90(0.50, 0.25, 0.15)/0.90. The two-token filters are more peaked.

Solution 39.2 ★★ Speculative proof

For each token, p−q=(p−q)+−(q−p)+p-q = (p-q)_+ - (q-p)_+. Summing over tokens gives 0=∑x(p(x)−q(x))+−∑x(q(x)−p(x))+0 = \sum_x (p(x)-q(x))_+ - \sum_x (q(x)-p(x))_+, so the positive and negative mismatch masses are equal. Also min⁡(p,q)=q−(q−p)+\min(p,q) = q - (q-p)_+, hence 1−∑xmin⁡(p,q)=∑x(q−p)+=∑x(p−q)+1 - \sum_x \min(p,q) = \sum_x (q-p)_+ = \sum_x (p-q)_+. Multiplying the residual distribution by this rejection probability leaves (p(x)−q(x))+(p(x)-q(x))_+, which added to min⁡(p(x),q(x))\min(p(x),q(x)) equals p(x)p(x).

Solution 39.3 ★★ Expected accepted tokens

The draft accepts at least one token with probability 0.80.8, at least two with probability 0.8⋅0.7=0.560.8 \cdot 0.7 = 0.56, and all three with probability 0.8⋅0.7⋅0.5=0.280.8 \cdot 0.7 \cdot 0.5 = 0.28. Therefore E[N]=0.8+0.56+0.28=1.64\E[N] = 0.8 + 0.56 + 0.28 = 1.64. The test simulates 200,000 independent verification steps and asserts a mean within 0.01 of 1.64.

Solution 39.4 ★★★ Constrained implementation

The implementation treats the prefix as enough state for this tiny grammar: start allows [, [ or , allows a digit, a digit allows ] or ,, and a closed bracket allows nothing.

Tiny grammar mask
def tiny_json_number_mask(prefix, vocab):
    """Allowed tokens for a tiny grammar: '[' digit (',' digit)* ']'."""
    if not prefix:
        return np.array([token == "[" for token in vocab])
    if prefix[-1] in {"[", ","}:
        return np.array([token.isdigit() for token in vocab])
    if prefix[-1].isdigit():
        return np.array([token in {"]", ","} for token in vocab])
    return np.zeros(len(vocab), dtype=bool)


def grammar_step(logits, prefix, vocab):
    return softmax(mask_logits(logits, tiny_json_number_mask(prefix, vocab)))

The tests check that after [3 only ] and , have nonzero probability, and that digit-only masks put zero probability on non-digits.

40 Quantization & Serving

Solution 40.1 ★ Absmax codes

For 3 signed bits, qmax⁡=22−1=3q_{\max}=2^{2}-1=3. The scale is s=1/3s=1/3. Rounding x/sx/s gives integer codes (−3,−1,0,2,3)(-3,-1,0,2,3). Dequantization multiplies by ss, giving (−1,−1/3,0,2/3,1)(-1,-1/3,0,2/3,1). The chapter test asserts these codes and values.

Solution 40.2 ★★ Zero-point derivation

The affine quantizer is q=x/s+zq=x/s+z. Requiring xmin⁡x_{\min} to map to qmin⁡q_{\min} gives qmin⁡=xmin⁡/s+zq_{\min}=x_{\min}/s+z, hence z=qmin⁡−xmin⁡/sz=q_{\min}-x_{\min}/s. The value must be rounded because integer hardware stores an integer zero point, and clipped because rounding can move it outside the available unsigned code range. With x=(0,1,2)x=(0,1,2) and 2 bits, the tested scale is 2/32/3 and z=0z=0.

Solution 40.3 ★★★ GPTQ compensation

If H\mH has large off-diagonal entries, an error in one coordinate can be offset by changing a correlated coordinate. Independent rounding ignores those cross terms. GPTQ quantizes one coordinate, computes its error, and updates later coordinates with a factor from H−1\mH^{-1} before they are quantized.

Tiny GPTQ implementation
def gptq_quantize_vector(w, hessian, bits=2, damping=1e-8):
    """Quantize coordinates and compensate later ones with H^{-1}."""
    w = np.asarray(w, dtype=np.float64)
    hessian = np.asarray(hessian, dtype=np.float64)
    inv_h = np.linalg.inv(hessian + damping * np.eye(len(w)))
    work = w.copy()
    quantized = np.zeros_like(work)
    qmax = 2 ** (bits - 1) - 1
    scale = np.max(np.abs(w)) / qmax
    for i in range(len(w)):
        qi = np.clip(np.round(work[i] / scale), -qmax, qmax) * scale
        error = work[i] - qi
        quantized[i] = qi
        if i + 1 < len(w):
            work[i + 1:] -= error * inv_h[i + 1:, i] / inv_h[i, i]
    return quantized


def reconstruction_loss(w, q, hessian):
    error = np.asarray(w) - np.asarray(q)
    return float(error @ hessian @ error)

The test uses correlated calibration inputs and 2-bit weights; the GPTQ reconstruction loss is less than one quarter of round-to-nearest loss.

Solution 40.4 ★★★ Roofline calculator

For P=7⋅109P=7\cdot10^9 and two bytes per parameter, one token reads 14⋅10914\cdot10^9 bytes and performs about 14⋅10914\cdot10^9 FLOPs, so the arithmetic intensity is 11 FLOP/byte. At 3 TB/s, the bandwidth roof is 3⋅1012/(14⋅109)≈2143\cdot10^{12}/(14\cdot10^9) \approx 214 tokens/s before KV-cache traffic and overhead. Int4 halves the weight bytes, so the weight-only intensity doubles and the bandwidth roof doubles, provided the kernel can consume packed int4 efficiently.

41 Training at Scale

Solution 41.1 ★ Memory budget

Mixed-precision Adam stores bf16 weights, bf16 gradients, fp32 master weights, and two fp32 moments, so one million parameters use 16⋅10616\cdot10^6 bytes. The activation calculator gives 8⋅16⋅12⋅2=30728\cdot16\cdot12\cdot2=3072 bytes when it saves one tensor per layer. With three checkpoint segments, the tested calculator saves segment boundaries plus one segment interior during recomputation, giving 1792 bytes.

Solution 41.2 ★★ Ring and ZeRO

A ring all-reduce is reduce-scatter plus all-gather. Each phase sends (n−1)/n(n-1)/n of the tensor per rank, so together they send 2(n−1)size/n2(n-1)\text{size}/n. For P=1000P=1000 and n=4n=4, the formulas give 1600016000 bytes for ordinary data parallelism, 70007000 for ZeRO-1, 55005500 for ZeRO-2, and 40004000 for ZeRO-3. The test asserts these exact values.

Solution 41.3 ★★ Tensor-parallel equality

Write W1=[W1,1  W1,2]\mW_1=[\mW_{1,1}\;\mW_{1,2}] and W2=[W2,1W2,2]\mW_2=\begin{bmatrix}\mW_{2,1}\\\mW_{2,2}\end{bmatrix}. After the elementwise activation, H=[H1  H2]\mH=[\mH_1\;\mH_2]. Matrix multiplication by the row-split second weight gives HW2=H1W2,1+H2W2,2\mH\mW_2=\mH_1\mW_{2,1}+\mH_2\mW_{2,2}. The implementation generalizes this sum to any number of ranks.

Tensor-parallel MLP check
def gelu(x):
    return 0.5 * x * (1.0 + np.tanh(np.sqrt(2 / np.pi) *
                                    (x + 0.044715 * x ** 3)))


def mlp(x, w1, b1, w2, b2):
    return gelu(x @ w1 + b1) @ w2 + b2


def tensor_parallel_mlp(x, w1, b1, w2, b2, ranks):
    """Column-parallel first layer, row-parallel second layer."""
    w1_parts = np.array_split(w1, ranks, axis=1)
    b1_parts = np.array_split(b1, ranks)
    w2_parts = np.array_split(w2, ranks, axis=0)
    partials = []
    for w1_i, b1_i, w2_i in zip(w1_parts, b1_parts, w2_parts):
        partials.append(gelu(x @ w1_i + b1_i) @ w2_i)
    return np.sum(partials, axis=0) + b2
Solution 41.4 ★★★ Pipeline, experts, and FP8

The bubble fraction is (4−1)/(12+4−1)=3/15=0.2(4-1)/(12+4-1)=3/15=0.2. Source rank 0 sends two tokens to rank 0’s experts and one token to rank 1’s experts; source rank 1 sends all three tokens to rank 1. The count matrix is therefore [2103]\begin{bmatrix}2&1\\0&3\end{bmatrix}. Block FP8 scaling helps because the small first block gets its own scale instead of sharing a scale with values around 20; the test checks that this local scaling has lower MSE than one global block.

42 Tool Use & Agent Loops

Solution 42.1 ★ Reading a schema

The required argument list is just expression, and its schema says the value must be a string. The integer 3 is rejected because the calculator parses text arithmetic; accepting mixed types would make the tool contract ambiguous.

Required calculator arguments
def required_argument_names(schema):
    return tuple(schema.get("required", ()))
Solution 42.2 ★★ Tracing the loop

The first action is lookup({"key": "paris_population_millions"}), whose observation is {"ok": True, "content": "2.1"}. The second action is calculate({"expression": "2.1 + 2"}), whose observation is {"ok": True, "content": "4.1"}. The next model message is the final answer 4.1 million, so the stop reason is final.

The tested trace
def answer_population_question():
    return react_loop("What is the Paris population plus two?", ScriptedModel())
Solution 42.3 ★★ MCP without a socket

The request is a JSON-RPC object with method tools/call and params naming the tool and arguments: {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "calculate", "arguments": {"expression": "(8 - 3) / 2"}}}. The result is {"ok": True, "content": "2.5"}.

Calling the in-process MCP server
def mcp_calculate(expression):
    client = MCPClient(MCPServer())
    return client.request("tools/call", {
        "name": "calculate",
        "arguments": {"expression": expression},
    })["result"]
Solution 42.4 ★★★ Prompt-injection boundary

The wrapper below returns the lookup result exactly as an observation string. It does not parse the text for commands, call a tool named inside the text, or promote the text to a developer message. A real host would also label the channel in the prompt and keep tool permissions narrow.

Treating a suspicious observation as data
def observation_as_data(key):
    """Return lookup text without treating it as a developer instruction."""
    result = call_tool("lookup", {"key": key})
    if not result["ok"]:
        return "missing"
    return result["content"]

43 Retrieval, Memory, Planning & Evaluation

Solution 43.1 ★ Chunk boundaries

The chunks are one two three four five six and five six seven eight nine ten. The overlap is five six, so a fact that crosses the first boundary is still visible in a later retrieved chunk.

Overlapping chunks
def overlapping_chunks(text):
    return chunk_words(text, size=6, overlap=2)
Solution 43.2 ★★ Deriving pass@kkk

There are (102)=45\binom{10}{2} = 45 two-sample subsets. Since 10−3=710 - 3 = 7 samples are wrong, (72)=21\binom{7}{2} = 21 subsets fail completely. The estimator is 1−21/45=0.53331 - 21/45 = 0.5333.

Failed-subset ratio
def failed_subset_ratio(n, c, k):
    return 1.0 - pass_at_k(n, c, k)

The tests also enumerate all correctness patterns and check that the estimator’s expectation is 1−(1−p)k1 - (1-p)^k.

Exact expectation for small n
def exact_unbiased_value(n, k, p):
    return expected_pass_at_k(n, k, p)
Solution 43.3 ★★ Position-biased judge

If the judge always favors the first position, one order alone confounds quality with placement. Let judge(a, b) return the score difference in that displayed order. Evaluating both orders and using (judge(a, b) - judge(b, a)) / 2 cancels a constant first-position bonus. For lengths 4 and 2 with a +1 first-position bonus, the debiased difference is 2.

Swapping answer order
def debiased_pairwise_score(judge, answer_a, answer_b):
    first = judge(answer_a, answer_b)
    second = judge(answer_b, answer_a)
    return (first - second) / 2
Solution 43.4 ★★★ Implement tiny RAG

The prompt should label retrieved text as context and tell the model to use only that context for the answer. This does not make the context true or safe, but it prevents retrieved text from being silently promoted to a developer instruction.

One-document RAG prompt
def tiny_rag_prompt(question, documents):
    return retrieval_prompt(question, documents, k=1)

44 Capstone: An LLM End to End

Solution 44.1 ★ Follow one token

With d=2048d = 2048, 16 query heads, and 4 key-value heads (dh=128d_h = 128), a token at position tt passes through:

  • An ID, a scalar, which selects a row of the embedding table: shape (2048,).

  • RMSNorm: (2048,).

  • The query projection: (16, 128). The key and value projections: (4, 128) each. RoPE rotates the queries and keys.

  • Attention: each query head scores the tt cached keys of its group, giving (16, t) scores and weights. The weighted values are (16, 128), concatenated to (2048,) and projected back to (2048,).

  • The residual add, then RMSNorm: (2048,). SwiGLU’s two branches: (5461,) each. The down projection: (2048,), then the residual add.

  • After 16 blocks and a final RMSNorm, the tied output head gives logits of shape (32768,).

Solution 44.2 ★★ Counting a block

The query and output projections are d×dd \times d each, which contributes 2d22d^2. Keys and values map dd to HkvdhH_\text{kv} d_h each, which contributes 2dHkvdh2 d H_\text{kv} d_h. SwiGLU has two input matrices of shape d×8d3d \times \tfrac{8d}{3} and one output matrix of shape 8d3×d\tfrac{8d}{3} \times d, for 3⋅83d2=8d23 \cdot \tfrac{8}{3} d^2 = 8d^2. With Hkv=HH_\text{kv} = H, Hkvdh=dH_\text{kv} d_h = d, so the block has 2d2+2d2+8d2=12d22d^2 + 2d^2 + 8d^2 = 12d^2. The tests compare the formula with an explicit sum over all seven weight matrices, using integer widths; the two agree to within 0.1%.

Solution 44.3 ★★ Spending a compute budget

Substituting D=20ND = 20N gives C=120N2C = 120 N^2, so N=C/120N = \sqrt{C/120} and D=20C/120D = 20\sqrt{C/120}:

Allocating a compute budget
def allocate(flops, tokens_per_parameter=20):
    """Split a compute budget C = 6ND with D = 20N: N = sqrt(C / 120)."""
    parameters = math.sqrt(flops / (6 * tokens_per_parameter))
    return parameters, tokens_per_parameter * parameters

A budget of 102110^{21} FLOPs buys about 2.89 billion parameters trained on 57.7 billion tokens. Both grow as C\sqrt{C}, so ten times the compute buys about 3.2 times the parameters and 3.2 times the data.

Solution 44.4 ★★★ Serving on one device

Subtract the bf16 weights, 2N2N bytes, from the device memory. Then divide by the KV cache of one sequence, 2LHkvdhT×22 L H_\text{kv} d_h T \times 2 bytes:

Concurrent sequences in a memory budget
def concurrent_sequences(memory_bytes, context, kv_heads):
    """Sequences whose KV cache fits beside bf16 weights in a memory budget."""
    head_width = EXAMPLE["width"] // EXAMPLE["heads"]
    weights = 2 * transformer_parameters(**{**EXAMPLE, "kv_heads": kv_heads})
    per_sequence = kv_cache_bytes(EXAMPLE["layers"], kv_heads, head_width, context)
    return int((memory_bytes - weights) // per_sequence)

With 4 key-value heads, 292 sequences of 8,192 tokens fit. With 16 heads, only 72 fit: the cache per sequence is four times larger, and the weights grow slightly. Grouped-query attention therefore quadruples the batch a device can serve, which is its main purpose.

A Notation & Shapes

Solution A.1 ★ Shapes of a small network

b1∈R512\vb_1 \in \R^{512}, b2∈R10\vb_2 \in \R^{10}, H∈R32×512\mH \in \R^{32 \times 512}, and Z∈R32×10\mZ \in \R^{32 \times 10}. Gradients take the shape of their variable, so Wˉ1∈R128×512\bar{\mW}_1 \in \R^{128 \times 512} and bˉ2∈R10\bar{\vb}_2 \in \R^{10}. The parameters number 128⋅512+512+512⋅10+10=71,178128 \cdot 512 + 512 + 512 \cdot 10 + 10 = 71{,}178. The batch size does not appear: the same weights serve every example.

Solution A.2 ★ Reading PyTorch weights

Transpose the weight and keep the bias:

From PyTorch’s layout to the book’s
def from_pytorch_linear(weight, bias):
    """nn.Linear stores weight as (d_out, d_in) and computes x @ weight.T + bias."""
    return weight.T, bias

Since weight is W⊤\mW^\T, its gradient is the transpose of Wˉ\bar{\mW}: (X⊤Yˉ)⊤=Yˉ⊤X(\mX^\T \bar{\mY})^\T = \bar{\mY}^\T \mX, with shape dout×dind_\text{out} \times d_\text{in}. The tests confirm that the two layouts give identical outputs and gradients.

Solution A.3 ★★ Deriving the input gradient

XijX_{ij} appears in every output of row ii: Yik=∑j′Xij′Wj′k+bkY_{ik} = \sum_{j'} X_{ij'} W_{j'k} + b_k, so ∂Yik/∂Xij=Wjk\partial Y_{ik} / \partial X_{ij} = W_{jk}, and outputs of other rows do not depend on it. By the chain rule,

Xˉij=∑kYˉikWjk=∑kYˉik(W⊤)kj=(YˉW⊤)ij.\bar{X}_{ij} = \sum_{k} \bar{Y}_{ik} W_{jk} = \sum_k \bar{Y}_{ik} (\mW^\T)_{kj} = (\bar{\mY} \mW^\T)_{ij} .

To check it, fix a random G\mG and treat L(X)=∑G⊙affine⁡(X)L(\mX) = \sum G \odot \operatorname{affine}(\mX) as the loss. Its exact gradient is affine_backward(G, X, W)[0], which check_gradient compares with central differences. The book’s tests run this check for X\mX, W\mW, and b\vb.

Solution A.4 ★★ A Jacobian you never build

Output yiy_i depends only on xix_i, so the Jacobian is diagonal: J=diag⁡(f′(x))\mJ = \diag(f'(\vx)). The product is xˉ=J⊤yˉ=yˉ⊙f′(x)\bar{\vx} = \mJ^\T \bar{\vy} = \bar{\vy} \odot f'(\vx):

The same vector–Jacobian product, cheap and expensive
def elementwise_vjp(grad_y, x, derivative):
    return grad_y * derivative(x)              # O(n): the Jacobian is never built


def elementwise_vjp_dense(grad_y, x, derivative):
    jacobian = np.diag(derivative(x))          # (n, n), zero off the diagonal
    return jacobian.T @ grad_y                 # O(n^2) memory for the same answer

The dense Jacobian stores n2n^2 numbers, all but nn of them zero. For one layer of a small model with a million activations, that is 101210^{12} numbers, or 4 TB in float32. The direct product stores nn.

B NumPy for Deep Learning

Solution B.1 ★ Predict the shape

Right-align the shapes and treat missing leading axes as 1:

  • A + B: (4, 1, 3) with (1, 5, 1) gives (4, 5, 3).

  • A + C: (4, 1, 3) with (1, 1, 3) gives (4, 1, 3).

  • B + C: (5, 1) with (1, 3) gives (5, 3).

  • A * D: (4, 1, 3) with (1, 4, 3) gives (4, 4, 3).

D + B aligns (4, 3) with (5, 1). The last axes are compatible (3 against 1), but the next pair is 4 against 5. Neither is 1, so NumPy raises a ValueError. np.broadcast_shapes answers these questions without allocating anything.

Solution B.2 ★ The missing keepdims

X.sum(axis=1) has shape (N,), and broadcasting aligns it with the last axis of X:

The version without keepdims
def normalize_rows_wrong(X):
    return X / np.sum(X, axis=1)          # (N, d) / (N,): aligns N with the d axis
  • If N ≠ d and d ≠ 1, the shapes (N, d) and (N,) are incompatible and NumPy raises an error.

  • If N = d, it runs and computes Xij/sjX_{ij} / s_j, dividing column jj by the sum of row jj. The rows no longer sum to one.

  • If d = 1, (N, 1) and (N,) broadcast to (N, N): a matrix where a column was expected.

Write X.sum(axis=1, keepdims=True), or equivalently X.sum(axis=1)[:, None].

Solution B.3 ★★ Why summing is right

Each output depends on the bias through Yik=Xik+bkY_{ik} = X_{ik} + b_k, so ∂Yik/∂bj\partial Y_{ik} / \partial b_j is 1 when k=jk = j and 0 otherwise. The chain rule sums over every output:

∂L∂bj=∑i,k∂L∂Yik∂Yik∂bj=∑i∂L∂Yij.\frac{\partial L}{\partial b_j} = \sum_{i,k} \frac{\partial L}{\partial Y_{ik}} \frac{\partial Y_{ik}}{\partial b_j} = \sum_{i} \frac{\partial L}{\partial Y_{ij}}.

In general, broadcasting is a linear map B\mathcal{B} that copies each input entry to several output positions. For a linear map, the gradient with respect to the input is the adjoint applied to the upstream gradient: vˉ=B∗G\bar{\vv} = \mathcal{B}^{*}\mG. Expand the inner product by grouping the output positions that copy the same input entry:

⟨Bv,G⟩=∑outputs qvπ(q) Gq=∑inputs pvp∑q: π(q)=pGq.\langle \mathcal{B}\vv, \mG \rangle = \sum_{\text{outputs } q} v_{\pi(q)}\, G_q = \sum_{\text{inputs } p} v_p \sum_{q:\, \pi(q) = p} G_q .

Here π(q)\pi(q) is the input entry that output qq copies. The inner sum adds G\mG over exactly the positions that copied pp. Those are the leading axes that broadcasting added and the size-1 axes it stretched, which is precisely what unbroadcast sums. So unbroadcast is B∗\mathcal{B}^{*}, and the book’s tests check this adjoint identity with random v\vv and G\mG.

Solution B.4 ★★ Log-sum-exp

Factor eme^{m} out of the sum and take logarithms:

log⁡∑iezi=log⁡(em∑iezi−m)=m+log⁡∑iezi−m.\log \sum_i e^{z_i} = \log\Big(e^{m} \sum_i e^{z_i - m}\Big) = m + \log \sum_i e^{z_i - m}.

With m=max⁡izim = \max_i z_i, every term ezi−me^{z_i - m} lies in (0,1](0, 1], and the maximizing term equals 1. The sum therefore lies between 1 and nn. Its logarithm lies between 0 and log⁡n\log n, which gives both bounds. For the gradient,

∂∂zklog⁡∑iezi=ezk∑iezi=softmax⁡(z)k.\frac{\partial}{\partial z_k} \log \sum_i e^{z_i} = \frac{e^{z_k}}{\sum_i e^{z_i}} = \softmax(\vz)_k .

The tests confirm this with check_gradient, comparing against exp(log_softmax(z)).

Solution B.5 ★★ Choosing the step

Expand both sides to third order:

f(x±h)=f(x)±hf′(x)+h22f′′(x)±h36f′′′(ξ±).f(x \pm h) = f(x) \pm h f'(x) + \tfrac{h^2}{2} f''(x) \pm \tfrac{h^3}{6} f'''(\xi_\pm).

Subtracting cancels f(x)f(x) and f′′(x)f''(x). The two third-order terms average to f′′′(ξ)f'''(\xi) for some ξ\xi between them, by the intermediate value theorem. Dividing by 2h2h gives (B.6).

For E(h)=ah2+c/hE(h) = a h^2 + c/h, with a=∣f′′′∣/6a = |f'''|/6 and c=ε∣f∣c = \varepsilon |f|, set E′(h)=2ah−c/h2=0E'(h) = 2ah - c/h^2 = 0 to get h⋆=(c/2a)1/3=(3ε∣f∣/∣f′′′∣)1/3h^\star = (c / 2a)^{1/3} = (3\varepsilon|f|/|f'''|)^{1/3}. At the optimum both terms scale as ε2/3\varepsilon^{2/3}:

E(h⋆)=3⋅2−2/3 a1/3c2/3≈ε2/3when ∣f∣≈∣f′′′∣≈1.E(h^\star) = 3 \cdot 2^{-2/3}\, a^{1/3} c^{2/3} \approx \varepsilon^{2/3} \quad \text{when } |f| \approx |f'''| \approx 1 .

In float64, h⋆≈(6.7×10−16)1/3≈9×10−6h^\star \approx (6.7 \times 10^{-16})^{1/3} \approx 9 \times 10^{-6} and the error is about 4×10−114 \times 10^{-11}. In float32, h⋆≈7×10−3h^\star \approx 7 \times 10^{-3} and the error is about 2×10−52 \times 10^{-5}. Figure B.3 shows both floors.

Solution B.6 ★★★ Three embedding gradients

The lookup table[ids] is the matrix product O table\mO\,\text{table}, where O\mO has a single 1 per row, in the column of that row’s token. The gradient with respect to the table is therefore O⊤Yˉ\mO^\T \bar{\mY}. The loop and np.add.at compute the same sum one row at a time:

Three correct gradients and one buggy one
def embedding_backward_loop(upstream, ids, vocabulary_size):
    gradient = np.zeros((vocabulary_size, upstream.shape[-1]), dtype=upstream.dtype)
    for token, row in zip(ids.reshape(-1), upstream.reshape(-1, upstream.shape[-1])):
        gradient[token] += row
    return gradient


def embedding_backward_one_hot(upstream, ids, vocabulary_size):
    one_hot = np.eye(vocabulary_size, dtype=upstream.dtype)[ids.reshape(-1)]   # (n, V)
    return one_hot.T @ upstream.reshape(-1, upstream.shape[-1])                 # (V, d)


def embedding_backward_buggy(upstream, ids, vocabulary_size):
    gradient = np.zeros((vocabulary_size, upstream.shape[-1]), dtype=upstream.dtype)
    # Repeated ids collide: only the last write to each row survives.
    gradient[ids.reshape(-1)] += upstream.reshape(-1, upstream.shape[-1])
    return gradient

gradient[ids] += upstream means gradient[ids] = gradient[ids] + upstream. The right-hand side gathers a (possibly repeated) row for each ID and adds its upstream row. The assignment then writes those rows back in order. A repeated ID is written several times, and only the last write survives. Rows of tokens that appear at most once are correct: once-used tokens get their single contribution, and unused tokens stay zero. The tests check all of this with a sequence in which token 1 appears three times.

Solution B.7 ★★★ The wrong reshape
Reshaping directly to the head layout
def split_heads_wrong(X, heads):
    B, T, width = X.shape
    # Reinterprets memory in order, mixing tokens and heads.
    return X.reshape(B, heads, T, width // heads)

In memory, each batch entry of a C-ordered (B, T, H·d_h) array stores token 0’s whole feature vector, then token 1’s, and so on. A reshape reads those numbers in order. reshape(B, H, T, d_h) gives head 0 the first TdhT d_h numbers. With T=5T = 5, three heads, and dh=4d_h = 4, that is all 12 features of token 0 and the first 8 of token 1: a mixture of tokens. reshape(B, T, H, d_h) only splits the last axis, so head hh of token tt is the contiguous chunk X[b, t, h*d_h:(h+1)*d_h]. The transpose then moves the head axis forward by changing strides, without mixing anything. Any random input exposes the difference.

Solution B.8 ★★★ Where a sum stalls
A rounded running sum
def running_sum(value, steps, round_to):
    total = round_to(np.float32(0))
    for _ in range(steps):
        total = round_to(total + round_to(np.float32(value)))
    return float(total)


def bfloat16_vs_float32(value=1e-3, steps=10_000):
    bfloat16_total = running_sum(value, steps, round_to_bfloat16)
    float32_total = running_sum(value, steps, np.float32)
    return bfloat16_total, float32_total

The bfloat16 total stops at 0.5, the float16 total at 4.0, and the float32 total reaches about 10.0004. A sum stops growing once the addend is less than half the gap between neighbouring numbers at the current total, because rounding then returns the old total.

  • bfloat16 has 7 fraction bits. On [0.5,1)[0.5, 1) the gap is 2−1−7=2−8≈0.00392^{-1-7} = 2^{-8} \approx 0.0039, and half of it, 0.00195, exceeds 0.001. Just below 0.5 the gap is 2−9≈0.001952^{-9} \approx 0.00195. Each addition there is slightly more than half a gap, so it rounds up by a whole gap. The total overshoots and reaches 0.5 after about 383 steps instead of 500.

  • float16 has 10 fraction bits, so the same thing happens eight times higher. At 4 the gap is 22−10≈0.00392^{2-10} \approx 0.0039.

  • float32 has a gap of about 10−610^{-6} near 10, so each addition survives with a tiny rounding error. That error accumulates to the final 0.0004.

This is why mixed-precision training keeps master weights, optimizer state, and reductions in float32 even when matrix products run in 16 bits.

C Matrix Calculus Cookbook

Solution C.1 ★ Bias broadcasting

For each row ii, yij=xij+bjy_{ij}=x_{ij}+b_j. Therefore ∂yij/∂bj=1\partial y_{ij}/\partial b_j=1, and the VJP sums upstream gradients over the broadcasted batch axis: bˉj=∑iyˉij\bar{b}_j=\sum_i\bar{y}_{ij}.

Broadcasted bias gradient
def broadcast_add_gradient(grad, x, bias):
    del x
    return grad.sum(axis=0, keepdims=True).reshape(bias.shape)
Solution C.2 ★★ Matmul by indices

For AA,

aˉpq=∑ijyˉij∂yij∂apq=∑jyˉpjbqj,\bar{a}_{pq}=\sum_{ij}\bar{y}_{ij}\frac{\partial y_{ij}}{\partial a_{pq}} =\sum_j\bar{y}_{pj}b_{qj},

which is Aˉ=YˉB⊤\bar{A}=\bar{Y}B^\T. Similarly,

bˉpq=∑iaipyˉiq,\bar{b}_{pq}=\sum_i a_{ip}\bar{y}_{iq},

which is Bˉ=A⊤Yˉ\bar{B}=A^\T\bar{Y}.

Matmul gradient shapes
def matmul_shapes(a, b, grad):
    grad_a, grad_b = matmul_vjp(grad, a, b)
    return grad_a.shape, grad_b.shape
Solution C.3 ★★ Softmax VJP

For yi=exi/∑kexky_i=e^{x_i}/\sum_k e^{x_k}, ∂yi/∂xj=yi(1i=j−yj)\partial y_i/\partial x_j=y_i(1_{i=j}-y_j). Then

xˉj=∑iyˉiyi(1i=j−yj)=yj(yˉj−∑iyˉiyi).\bar{x}_j=\sum_i\bar{y}_i y_i(1_{i=j}-y_j) = y_j\left(\bar{y}_j-\sum_i\bar{y}_i y_i\right).
Softmax Jacobian-vector product
def softmax_jacobian_times_vector(logits, vector):
    y = softmax_forward(logits)
    return softmax_vjp(vector, y)
Solution C.4 ★★★ Attention gradient check

The attention VJP is the reverse of the forward decomposition: O=PVO=PV, P=softmax⁡(S)P=\softmax(S), S=QK⊤/dS=QK^\T/\sqrt d. The query gradient is Qˉ=SˉK/d\bar{Q}=\bar{S}K/\sqrt d. The test file checks this function against central differences for QQ, KK, and VV.

Query gradient for attention
def attention_query_gradient(q, k, v, grad):
    grad_q, _, _ = attention_vjp(grad, q, k, v)
    return grad_q