Stanford's machine learning course in the shape of the 2008 SEE release: a lecture sequence, handouts, and problem sets with full solutions. It follows the Spring 2026 content, including transformers, diffusion, foundation models and RL for reasoning.
The 2018 PS0 (a math warm-up) is no longer posted publicly, so the Prerequisite set below covers the same topics, with solutions. The 2018 PS1–PS4 were posted on the class forum and are mirrored on GitHub (search "cs229 ps1 2018"). Many mirrors include solutions, so take only the problem PDF and starter code.
The math prerequisites have not changed: linear algebra, probability and multivariable calculus at the level of the review handouts below. The modern lectures lean harder on three things the old course used less: Jacobians and the chain rule in matrix form (backprop, transformers), Gaussian identities (diffusion, VAEs), and KL divergence. The handouts cover the first two; Appendix A of the 2026 notes collects the Gaussian and KL facts the diffusion chapter uses. If you have not taken a probability course, follow the math track below.
Software. No MATLAB is needed. The course had moved from MATLAB/Octave to Python by Autumn 2018; the Autumn 2018 problem sets and the sets on this page use Python 3 with NumPy and Matplotlib. Only the 2008 SEE problem sets use MATLAB; run them in GNU Octave (free) or port them to NumPy. PyTorch is needed only for optional extensions (the B6 neural denoiser, CS336).
Self-study that runs alongside the schedule, with no meetings of its own. It fills the two gaps most people have coming from a multivariable calculus and intro linear algebra background: probability, and the symmetric-matrix part of linear algebra. Probability follows Harvard's Stat 110 (Joe Blitzstein), whose lectures are free on YouTube; the book is Blitzstein and Hwang, Introduction to Probability (probabilitybook.net). Linear algebra follows Strang's MIT 18.06 lectures on MIT OpenCourseWare. Expect two to three hours a week; Stat 110 lectures run about 50 minutes each.
| When | Stat 110 lectures | Book | What it is for | Linear algebra and handouts |
|---|---|---|---|---|
| Before meetings 1–2 | 4, 5, 7, 9 | Ch. 2; Ch. 3.1–3.4; Ch. 4.1–4.2 | Conditional probability, Bayes' rule, random variables, expectation and linearity. Used from the first week. | CS229 linear algebra handout §3.11–3.13 (quadratic forms, PSD, symmetric eigenvectors); Strang 18.06 Lecture 25 |
| Before meetings 3–4 | 11, 12, 13, 14 | Ch. 4.4–4.7; Ch. 5.1–5.4 | Variance, the Poisson and Normal distributions. Meeting 3 derives least squares as a Gaussian maximum-likelihood fit; meeting 4 builds Poisson regression. | Strang 18.06 Lecture 27 (positive definite matrices and minima) |
| Before meetings 5–6 | 18, 19, 21, 30 | Ch. 7 | Joint, marginal and conditional distributions, covariance, the multivariate Normal. GDA fits one multivariate Normal per class; Naive Bayes is a conditional-independence assumption. | CS229 handout: The Multivariate Gaussian Distribution |
| Before meetings 7–8 | 20 (optional) | Ch. 7.4 | Catch-up week; the neural network meetings need little new probability. The Multinomial lecture helps with softmax. | — |
| Before meetings 9–10 | 25, 26, 28 | Ch. 9.1–9.3; Ch. 10.1 | Conditional expectation and Jensen's inequality. EM is derived directly from Jensen's inequality. | Strang 18.06 Lecture 29 (SVD), for PCA |
| Before meetings 11–12 | 27 | Ch. 9 (rest) | Adam's and Eve's laws. B5 (denoising learns the score) is a conditional-expectation argument. | Main notes Appendix A (Gaussian and KL facts); CS229 handout: More on Multivariate Gaussians |
| Before meetings 13–15 | 29 (optional) | Ch. 10.2–10.3 | Law of large numbers and the central limit theorem: why averaging noisy gradient estimates works. | — |
| Before meetings 16–17 | 31, 32 | Ch. 11 | Markov chains. An MDP is a Markov chain whose transitions depend on the action you choose. | — |
Skip what you already know. If time is short, the core is Stat 110 Lectures 4, 5, 9, 12–14, 19, 21, 27 and 28. Lectures 1–3 (counting) and the Markov chain lectures matter least for this course.
| Meeting | Spring 2026 lecture | Reading | Meeting problem (about an hour) | More practice |
|---|---|---|---|---|
| 1 | L1 · Introduction | Part I intro | P1 + P2: gradients, Hessians, PSD matrices · 40 min | P3–P5: spectral theorem, probability, Gaussians (~1.5 h) |
| 2 | L2 · Supervised Learning Setup | §1.1–1.2 | S1: normal equations and the gradient descent step size · 50 min | Whatever is left of P3–P5 |
| 3 | L3 · Weighted Least Squares | §1.3–1.4, 2.1, 2.4 | 2018 PS1 Q5(a): weighted normal equations and their probabilistic meaning (written) · 45 min | PS1 Q5 coding parts (~1.5 h); PS1 Q1(a)–(c), logistic regression with Newton (~1.5 h) |
| 4 | L4 · Exponential Family, GLMs, Classification | §2.2–2.3, Ch. 3 | 2018 PS1 Q3, written parts: Poisson as an exponential family, the GLM update · 45 min | PS1 Q3 coding part (~45 min); PS1 Q4, convexity of GLMs (~1 h); A2 (~20 min); PS2 Q1 (~1 h) |
| 5 | L5 · Gaussian Discriminant Analysis | Ch. 4 | 2018 PS1 Q1, the GDA parts: show the posterior is logistic, derive the MLEs · 60 min | PS1 Q1 coding parts (~1.5 h); PS1 Q2, positive-only labels (~2 h); PS2 Q6(a)–(c), Naive Bayes spam (~2 h) |
| 6 | L6 · Dataset Split, ML Advice | §8.1–8.2, §9.3; ML advice slides | 2018 PS2 Q2: model calibration · 40 min | PS2 Q3, Bayesian view of regularization (~1 h) |
| Optional | No 2026 lecture · Kernels and SVMs (Ng 2018 L6–7) | Ch. 5–6 | 2018 PS2 Q4: constructing kernels · 45 min | PS2 Q5, kernelized perceptron (~1.5 h); PS2 Q6(d), SVM spam (~30 min) |
| 7 | L7 · Neural Networks 1 (Architecture) | §7.1–7.3 | 2018 PS3 Q1: a simple neural network · 50 min | A2 if not done (~20 min) |
| 8 | L8 · Neural Networks 2 (Backprop) | §7.4–7.5 | S2: backprop by hand for a two-layer network · 55 min | 2018 PS4 Q1, MNIST classifier with backprop (~3 h) |
| 9 | L9 · K-Means and GMM (non-EM) | Ch. 10 | 2018 PS3 Q5: k-means for image compression (coding; load the starter code beforehand) · 50 min | — |
| 10 | L10 · GMM (EM), PCA | §11.1–11.4, Ch. 12–13 | 2018 PS4 Q3: PCA as the best projection (written) · 40 min | PS3 Q4, semi-supervised EM (~3 h); PS4 Q4, ICA (~2 h) |
| 11 | L11 · Diffusion Models | §11.5, Ch. 14 | B3 + B4: forward marginal and reverse posterior · 55 min | B1, B2, B5 (~50 min together); B6 (~2 h); 2018 PS3 Q2, KL and maximum likelihood (~45 min) |
| 12 | L12 · Representation Learning | Ch. 16 | C3 + C4: InfoNCE gradients, retrieval geometry · 40 min | C2 (~15 min); C5 part 1 (~45 min) |
| 13 | L13 · LLMs, Next-Word Prediction Loss | §17.1–17.2 | A2 + A3: softmax Jacobian, causal masking, KV cache · 45 min | CS336 Assignment 1 (multi-week project) |
| 14 | L14 · Transformers, In-Context Learning | §17.3–17.7 | A1 + A4 + A5: √d scaling, parameter counts, RoPE · 40 min | A6, attention in NumPy (~1.5 h) |
| 15 | L15 · not posted publicly | Ch. 15, §17.8, Ch. 18 | C1 + C2: LoRA and linear probes · 40 min | C5 part 2, LoRA recovery (~45 min) |
| 16 | L16 · Basic Concepts in RL, Policy Gradient | §19.1–19.2, §21.1 | D1 + D2: log-derivative trick and baselines · 35 min | D5 part 1 (~45 min) |
| 17 | L17 · not posted publicly | §19.3–19.5 | 2018 PS4 Q5: Markov decision processes (written) · 45 min | PS4 Q6, inverted pendulum (~3 h); PS4 Q2, off-policy evaluation (~1.5 h) |
| 18 | L18 · see note | §21.2 (PPO), §18.2 (RLVR) | D3 + D4: PPO clipping and group-normalized advantages · 40 min | D5 part 2 (~45 min) |
| 19 | L19 · not posted publicly | Ch. 20 (LQR, DDP, LQG) | D6(a)–(b): the Riccati recursion · 35 min | D6(c), simulate the controller (~30 min) |
| 20 | L20 · see note | Review | Each person brings one unfinished problem to present · 60 min | — |
One meeting per Spring 2026 lecture: watch Lecture N (and do its reading) first, then hold meeting N. Meeting 1 comes after Lecture 1; its problems use the linear algebra handout §3.11–3.13 and §4, so skim those before meeting 1 too. Lectures 1–14 and 16 are posted with the titles shown. Lectures 15, 17 and 19 are not in the public set, so those meetings use the notes alone. The videos posted as Lectures 18 and 20 carry the same title as Lecture 10 (GMM, PCA) in at least one archive, so they may be mislabeled; check them in the playlist, and if they are repeats, treat those meetings like 15 and 17. The 2026 lectures skip kernels and SVMs, which are still in the notes; the optional row covers them with Ng's 2018 Lectures 6–7.
Times are estimates for a group that has read the notes and watched the lecture; a first attempt alone usually takes longer. A full 2018 problem set was meant to take a Stanford student about ten to fifteen hours. Official problem numbers refer to the Autumn 2018 problem sets as they appear in the GitHub mirrors. Check each against the PDF you download, since a mirror can number questions differently.
A symmetric matrix \(A\) is positive semidefinite (PSD, written \(A\succeq0\)) if \(x^\top Ax\ge0\) for every \(x\), and positive definite if \(x^\top Ax \gt 0\) for every \(x\ne0\).
Every symmetric \(A\) can be written \(A = Q\Lambda Q^\top\), where the columns of \(Q\) are orthonormal eigenvectors \(q_1,\dots,q_n\) (so \(Q^\top Q = I\)) and \(\Lambda = \operatorname{diag}(\lambda_1,\dots,\lambda_n)\).
import numpy as np
def logistic_loss_grad(theta, x, y):
"""f(theta) = log(1 + exp(-y theta^T x)), y in {-1, +1}."""
m = y * theta @ x
return np.log1p(np.exp(-m)), -y * x / (1 + np.exp(m))
def sample_gaussian(mu, Sigma, n, rng):
"""x = mu + Q Lambda^{1/2} z with Sigma = Q Lambda Q^T and z ~ N(0, I)."""
lam, Q = np.linalg.eigh(Sigma)
z = rng.normal(size=(n, len(mu)))
return mu + z @ (Q * np.sqrt(lam)).T
if __name__ == "__main__":
rng = np.random.default_rng(0)
# P1(c): logistic loss gradient vs finite differences
theta, x, y = rng.normal(size=4), rng.normal(size=4), -1.0
f, g = logistic_loss_grad(theta, x, y); eps = 1e-6
num = np.array([(logistic_loss_grad(theta + eps * e, x, y)[0] - logistic_loss_grad(theta - eps * e, x, y)[0]) / (2 * eps) for e in np.eye(4)])
print(f"P1 logistic gradient max error: {np.abs(g - num).max():.1e}")
# P2(d) and P3(d): eigenvalues of the two example matrices
print("P2 eigenvalues of [[4,-2],[-2,1]]:", np.round(np.linalg.eigvalsh([[4.0, -2], [-2, 1]]), 6))
A = np.array([[2.0, 1], [1, 2]]); lam, Q = np.linalg.eigh(A)
print("P3 eigenvalues of [[2,1],[1,2]]:", lam, "| trace:", np.trace(A))
xs = rng.normal(size=(100000, 2)); xs /= np.linalg.norm(xs, axis=1, keepdims=True)
print(f"P3 max of x^T A x over 100k random unit vectors: {np.einsum('ij,jk,ik->i', xs, A, xs).max():.4f}")
# P4(a): Bayes' rule
prior, sens, fpr = 0.01, 0.95, 0.05
print(f"P4 P(disease | positive) = {sens*prior / (sens*prior + fpr*(1-prior)):.3f}")
# P5(c): sampling with the spectral decomposition
mu = np.array([1.0, -2.0]); Sigma = np.array([[2.0, 1.2], [1.2, 1.0]])
X = sample_gaussian(mu, Sigma, 200000, rng)
print("P5 sample mean:", np.round(X.mean(0), 3), " sample covariance:", np.round(np.cov(X.T), 3).tolist())
P1 logistic gradient max error: 8.4e-11
P2 eigenvalues of [[4,-2],[-2,1]]: [0. 5.]
P3 eigenvalues of [[2,1],[1,2]]: [1. 3.] | trace: 4.0
P3 max of x^T A x over 100k random unit vectors: 3.0000
P4 P(disease | positive) = 0.161
P5 sample mean: [ 0.999 -2.002] sample covariance: [[2.006, 1.202], [1.202, 1.001]]
Let \(J(\theta) = \tfrac12\|X\theta - \vec y\|^2\) with \(X^\top X\) invertible, and let \(\lambda_{\max}\) be its largest eigenvalue.
A network computes \(h = \sigma(W_1x + b_1)\) with the logistic sigmoid \(\sigma\), then \(p = \operatorname{softmax}(W_2h + b_2)\), with loss \(\mathcal L = -\log p_y\).
import numpy as np
# ---- S1: normal equations and the gradient descent step-size limit ----
def gd(X, y, alpha, steps):
theta = np.zeros(X.shape[1])
for _ in range(steps):
theta -= alpha * X.T @ (X @ theta - y) # gradient of J = 0.5 ||X theta - y||^2
return theta
# ---- S2: backprop for a two-layer network (sigmoid hidden, softmax output) ----
def sigmoid(z): return 1 / (1 + np.exp(-z))
def softmax(z):
z = z - z.max(); e = np.exp(z); return e / e.sum()
def forward_backward(x, y, W1, b1, W2, b2):
h = sigmoid(W1 @ x + b1)
p = softmax(W2 @ h + b2)
loss = -np.log(p[y])
d2 = p.copy(); d2[y] -= 1 # dL/dz2 = p - e_y
dW2, db2 = np.outer(d2, h), d2
d1 = (W2.T @ d2) * h * (1 - h) # dL/dz1
dW1, db1 = np.outer(d1, x), d1
return loss, (dW1, db1, dW2, db2)
if __name__ == "__main__":
rng = np.random.default_rng(0)
X = rng.normal(size=(100, 3)) @ np.diag([3.0, 1.0, 0.3]); y = X @ np.array([1.0, -2.0, 0.5]) + 0.1 * rng.normal(size=100)
theta_ne = np.linalg.solve(X.T @ X, X.T @ y)
lam_max = np.linalg.eigvalsh(X.T @ X).max()
print(f"2 / lambda_max = {2/lam_max:.5f}")
for frac in [0.5, 0.99, 1.01]:
th = gd(X, y, frac * 2 / lam_max, 5000)
print(f"alpha = {frac:4.2f} x limit: |theta - theta_normal_eq| = {np.linalg.norm(th - theta_ne):.3e}")
d, hdim, k = 4, 5, 3
W1, b1 = rng.normal(size=(hdim, d)), rng.normal(size=hdim)
W2, b2 = rng.normal(size=(k, hdim)), rng.normal(size=k)
x, yy = rng.normal(size=d), 2
_, grads = forward_backward(x, yy, W1, b1, W2, b2)
params = [W1, b1, W2, b2]; eps = 1e-6; err = 0.0
for P, G in zip(params, grads):
for idx in np.ndindex(P.shape):
old = P[idx]
P[idx] = old + eps; lp = forward_backward(x, yy, *params)[0]
P[idx] = old - eps; lm = forward_backward(x, yy, *params)[0]
P[idx] = old
err = max(err, abs((lp - lm) / (2 * eps) - G[idx]))
print(f"backprop vs finite differences, max error: {err:.2e}")
2 / lambda_max = 0.00186
alpha = 0.50 x limit: |theta - theta_normal_eq| = 4.213e-15
alpha = 0.99 x limit: |theta - theta_normal_eq| = 1.897e-15
alpha = 1.01 x limit: |theta - theta_normal_eq| = 9.851e+42
backprop vs finite differences, max error: 1.85e-10
Let \(q, k \in \mathbb{R}^d\) be independent, each with i.i.d. entries of mean 0 and variance 1.
Let \(s = \operatorname{softmax}(z) \in \mathbb{R}^n\).
A decoder-only model is trained on \(x_1,\dots,x_T\) with loss \(\sum_{t=1}^{T-1} -\log p_\theta(x_{t+1}\mid x_{\le t})\). Causal attention sets the score from position \(t\) to position \(j\) to \(-\infty\) whenever \(j \gt t\).
A block has multi-head attention with \(h\) heads of width \(d/h\) and projections \(W_Q, W_K, W_V, W_O\), followed by an MLP \(d \to 4d \to d\). Ignore biases and layer norm.
RoPE rotates the query at position \(m\) and the key at position \(n\): \(\tilde q_m = R(m\omega)q\), \(\tilde k_n = R(n\omega)k\), where \(R(\alpha)\) is the \(2\times2\) rotation by \(\alpha\). Show that \(\tilde q_m^\top \tilde k_n = q^\top R\big((n-m)\omega\big)k\), so the attention score depends on position only through the offset \(n-m\).
\(\tilde q_m^\top \tilde k_n = q^\top R(m\omega)^\top R(n\omega) k\). Rotations satisfy \(R(\alpha)^\top = R(-\alpha)\) and \(R(\alpha)R(\beta) = R(\alpha+\beta)\), so the product is \(R((n-m)\omega)\). In \(d\) dimensions RoPE applies this to each pair of coordinates with its own frequency \(\omega_i\), and the same argument holds pair by pair.
Write softmax(z, axis), causal_self_attention(X, Wq, Wk, Wv) for X of shape (T, d), multi_head(X, heads, Wo), and softmax_jacobian(z). Then check:
Subtract the row maximum inside softmax for stability. Masked entries are \(-\infty\); this is safe because every row keeps its diagonal entry.
import numpy as np
def softmax(z, axis=-1):
z = z - z.max(axis=axis, keepdims=True)
e = np.exp(z)
return e / e.sum(axis=axis, keepdims=True)
def causal_self_attention(X, Wq, Wk, Wv):
"""X: (T, d_model). Returns (T, d_v)."""
T = X.shape[0]
Q, K, V = X @ Wq, X @ Wk, X @ Wv
scores = Q @ K.T / np.sqrt(Q.shape[1])
mask = np.triu(np.ones((T, T), dtype=bool), k=1) # True above diagonal = future
scores = np.where(mask, -np.inf, scores)
return softmax(scores, axis=1) @ V
def multi_head(X, heads, Wo):
"""heads: list of (Wq, Wk, Wv), each projecting to d_model // h."""
return np.concatenate([causal_self_attention(X, *w) for w in heads], axis=1) @ Wo
def softmax_jacobian(z):
s = softmax(z)
return np.diag(s) - np.outer(s, s)
if __name__ == "__main__":
rng = np.random.default_rng(0)
T, d, h = 6, 16, 4
dh = d // h
heads = [tuple(rng.normal(size=(d, dh)) / np.sqrt(d) for _ in range(3)) for _ in range(h)]
Wo = rng.normal(size=(d, d)) / np.sqrt(d)
X = rng.normal(size=(T, d))
Y = multi_head(X, heads, Wo)
# (c) causality: changing token t+1.. must not change outputs at positions <= t
t = 2
X2 = X.copy(); X2[t + 1:] = rng.normal(size=(T - t - 1, d))
Y2 = multi_head(X2, heads, Wo)
print("causal ok:", np.allclose(Y[:t + 1], Y2[:t + 1]), "| future changed:", not np.allclose(Y[t + 1:], Y2[t + 1:]))
# (d) softmax Jacobian vs finite differences
z = rng.normal(size=5); J = softmax_jacobian(z); eps = 1e-6
Jnum = np.stack([(softmax(z + eps * e) - softmax(z - eps * e)) / (2 * eps) for e in np.eye(5)], axis=1)
print("jacobian max err:", np.abs(J - Jnum).max(), "| rank:", np.linalg.matrix_rank(J), "| row sums ~0:", np.allclose(J.sum(1), 0))
# (e) why 1/sqrt(d): variance of q.k grows like d
for dd in [16, 256, 4096]:
q, k = rng.normal(size=(20000, dd)), rng.normal(size=(20000, dd))
print(f"d={dd:5d} Var(q.k)={np.var((q * k).sum(1)):9.1f} Var(q.k/sqrt d)={np.var((q * k).sum(1) / np.sqrt(dd)):.3f}")
causal ok: True | future changed: True
jacobian max err: 4.850038426429393e-11 | rank: 4 | row sums ~0: True
d= 16 Var(q.k)= 16.1 Var(q.k/sqrt d)=1.009
d= 256 Var(q.k)= 255.6 Var(q.k/sqrt d)=0.998
d= 4096 Var(q.k)= 4183.0 Var(q.k/sqrt d)=1.021
For any distribution \(q(z)\) with the same support as \(p(z\mid x)\), show
\[\log p(x) = \underbrace{\mathbb{E}_{q}\big[\log p(x,z) - \log q(z)\big]}_{\text{ELBO}} + \mathrm{KL}\big(q(z)\,\|\,p(z\mid x)\big).\]Conclude that the ELBO lower-bounds \(\log p(x)\), with equality exactly when \(q\) is the posterior.
\(\log p(x)\) does not depend on \(z\), so \(\log p(x) = \mathbb{E}_q[\log p(x,z) - \log p(z\mid x)]\). Add and subtract \(\log q(z)\) inside the expectation: the first piece is the ELBO and the second is \(\mathbb{E}_q[\log q(z) - \log p(z\mid x)] = \mathrm{KL}(q\,\|\,p(\cdot\mid x)) \ge 0\), which is zero iff \(q = p(\cdot \mid x)\).
The forward process is \(x_t = \sqrt{\alpha_t}\,x_{t-1} + \sqrt{\beta_t}\,\varepsilon_t\) with independent \(\varepsilon_t \sim \mathcal N(0, I)\). Show \(q(x_t\mid x_0) = \mathcal N\big(\sqrt{\bar\alpha_t}\,x_0,\,(1-\bar\alpha_t)I\big)\). What does \(\bar\alpha_T \approx 0\) imply about \(x_T\)?
Induct. If \(x_{t-1} = \sqrt{\bar\alpha_{t-1}}x_0 + \sqrt{1-\bar\alpha_{t-1}}\,\varepsilon'\), then \(x_t = \sqrt{\bar\alpha_t}x_0 + \sqrt{\alpha_t(1-\bar\alpha_{t-1})}\,\varepsilon' + \sqrt{\beta_t}\,\varepsilon_t\). A sum of independent Gaussians is Gaussian with variance \(\alpha_t - \bar\alpha_t + 1 - \alpha_t = 1-\bar\alpha_t\). When \(\bar\alpha_T\approx0\), \(x_T\) is close to \(\mathcal N(0,I)\) whatever \(x_0\) was, which is why sampling starts from pure noise.
Training minimizes \(\mathbb{E}\,\|\varepsilon - \varepsilon_\theta(x_t, t)\|^2\) with \(x_t = \sqrt{\bar\alpha_t}x_0 + \sqrt{1-\bar\alpha_t}\,\varepsilon\).
Use 1-D data from a mixture of two Gaussians, so the optimal \(\varepsilon^*\) can be written in closed form and no network is needed. Each component \(\mathcal N(m_k, v_k)\) becomes \(\mathcal N(a m_k, a^2 v_k + s^2)\) at step \(t\), and B5 turns its score into \(\varepsilon^*\). Use \(T=200\) and a linear \(\beta\) schedule from \(10^{-4}\) to \(0.05\).
q_sample and check its mean and variance against running the chain step by step.eps_star(x, t) and confirm it has lower MSE than predicting zero.Extension: replace eps_star with a small MLP trained on \((x_t, t)\) pairs and compare the histograms.
import numpy as np
# Data: 1-D mixture of two Gaussians
W = np.array([0.3, 0.7]); M = np.array([-2.0, 1.5]); S2 = np.array([0.15, 0.3])
def sample_data(n, rng):
k = rng.choice(2, size=n, p=W)
return rng.normal(M[k], np.sqrt(S2[k]))
T = 200
betas = np.linspace(1e-4, 0.05, T)
alphas = 1 - betas
abar = np.cumprod(alphas)
def q_sample(x0, t, rng):
eps = rng.normal(size=x0.shape)
return np.sqrt(abar[t]) * x0 + np.sqrt(1 - abar[t]) * eps, eps
def eps_star(x, t):
"""Exact E[eps | x_t = x] for the mixture: -sqrt(1 - abar) * score of p_t."""
a, s2 = np.sqrt(abar[t]), 1 - abar[t]
var = a**2 * S2 + s2 # per-component variance of x_t
logw = np.log(W) - 0.5 * np.log(var) - 0.5 * (x[:, None] - a * M) ** 2 / var
r = np.exp(logw - logw.max(1, keepdims=True)); r /= r.sum(1, keepdims=True)
score = (r * (-(x[:, None] - a * M) / var)).sum(1)
return -np.sqrt(s2) * score
def ancestral_sample(n, rng):
x = rng.normal(size=n)
for t in range(T - 1, -1, -1):
mean = (x - betas[t] / np.sqrt(1 - abar[t]) * eps_star(x, t)) / np.sqrt(alphas[t])
if t > 0:
var = (1 - abar[t - 1]) / (1 - abar[t]) * betas[t]
x = mean + np.sqrt(var) * rng.normal(size=n)
else:
x = mean
return x
if __name__ == "__main__":
rng = np.random.default_rng(0)
x0 = sample_data(200000, rng)
# (a) closed-form marginal q(x_t | x_0) vs. running the chain step by step
t = 120
x = x0[:50000].copy()
for s in range(t + 1):
x = np.sqrt(alphas[s]) * x + np.sqrt(betas[s]) * rng.normal(size=x.shape)
xt, _ = q_sample(x0[:50000], t, rng)
print(f"chain mean/var {x.mean():.3f}/{x.var():.3f} closed form {xt.mean():.3f}/{xt.var():.3f}")
print(f"abar_T = {abar[-1]:.2e} (x_T is ~ N(0,1))")
# (b) the exact epsilon-predictor is the regression target: it beats any other function
xt, eps = q_sample(x0[:50000], 60, rng)
print(f"MSE exact eps*: {np.mean((eps - eps_star(xt, 60))**2):.4f} MSE predict 0: {np.mean(eps**2):.4f}")
# (c) sample and compare to the data distribution
xs = ancestral_sample(100000, rng)
frac_left = (xs < 0).mean()
print(f"samples: mean {xs.mean():.3f} (data {x0.mean():.3f}), var {xs.var():.3f} (data {x0.var():.3f}), P(x<0) {frac_left:.3f} (data {(x0<0).mean():.3f})")
chain mean/var 0.181/1.270 closed form 0.182/1.298
abar_T = 6.12e-03 (x_T is ~ N(0,1))
MSE exact eps*: 0.4899 MSE predict 0: 0.9935
samples: mean 0.446 (data 0.451), var 2.810 (data 2.829), P(x<0) 0.303 (data 0.302)
A frozen weight \(W\in\mathbb{R}^{d\times k}\) is adapted as \(W + BA\) with \(B\in\mathbb{R}^{d\times r}\), \(A\in\mathbb{R}^{r\times k}\).
A batch has \(n\) positive pairs \((u_i, v_i)\) of unit-norm embeddings. Let \(S_{ij} = u_i^\top v_j/\tau\), \(P = \operatorname{softmax}\) of each row of \(S\), and \(\mathcal L = -\frac1n\sum_i \log P_{ii}\).
info_nce(U, V, tau) returning the loss and \(\partial\mathcal L/\partial U\). Check the gradient against finite differences.import numpy as np
def softmax(z, axis=-1):
z = z - z.max(axis=axis, keepdims=True); e = np.exp(z)
return e / e.sum(axis=axis, keepdims=True)
# ---- Part 1: InfoNCE (CLIP-style, one direction) ----
def info_nce(U, V, tau):
"""U, V: (n, d) unit-norm embeddings; positives are matching rows."""
S = U @ V.T / tau
P = softmax(S, axis=1)
loss = -np.mean(np.log(np.diag(P)))
dS = (P - np.eye(len(U))) / len(U) # dLoss/dS
dU = dS @ V / tau
return loss, dU
# ---- Part 2: LoRA on a linear model ----
def lora_fit(X, Y, W0, r, lr, steps, rng):
d_out, d_in = W0.shape
A = rng.normal(size=(r, d_in)) / np.sqrt(d_in)
B = np.zeros((d_out, r)) # B = 0 => delta W = 0 at init
n = len(X); hist = []
for _ in range(steps):
R = X @ (W0 + B @ A).T - Y # residuals (n, d_out)
G = R.T @ X / n # dL/dW for L = ||R||^2 / (2n)
gB, gA = G @ A.T, B.T @ G
B -= lr * gB; A -= lr * gA
hist.append(0.5 * np.mean(np.sum(R**2, 1)))
return B, A, hist
if __name__ == "__main__":
rng = np.random.default_rng(0)
n, d, tau = 8, 5, 0.1
U = rng.normal(size=(n, d)); U /= np.linalg.norm(U, axis=1, keepdims=True)
V = U + 0.3 * rng.normal(size=(n, d)); V /= np.linalg.norm(V, axis=1, keepdims=True)
loss, dU = info_nce(U, V, tau)
eps = 1e-6; num = np.zeros_like(U)
for i in range(n):
for j in range(d):
Up, Um = U.copy(), U.copy(); Up[i, j] += eps; Um[i, j] -= eps
num[i, j] = (info_nce(Up, V, tau)[0] - info_nce(Um, V, tau)[0]) / (2 * eps)
print(f"InfoNCE loss {loss:.4f}, grad max err {np.abs(dU - num).max():.2e}, chance level log n = {np.log(n):.4f}")
d_in, d_out, r_true = 40, 30, 3
W0 = rng.normal(size=(d_out, d_in)) / np.sqrt(d_in)
dW_true = rng.normal(size=(d_out, r_true)) @ rng.normal(size=(r_true, d_in)) / np.sqrt(d_in)
X = rng.normal(size=(2000, d_in)); Y = X @ (W0 + dW_true).T
# gradient of A is exactly zero at init (B = 0)
G0 = (X @ W0.T - Y).T @ X / len(X); B0 = np.zeros((d_out, r_true)); A0 = rng.normal(size=(r_true, d_in))
print("at init: |grad A| =", np.abs(B0.T @ G0).max(), " |grad B| =", round(float(np.abs(G0 @ A0.T).max()), 3))
for r in [1, 3, 8]:
B, A, hist = lora_fit(X, Y, W0, r, lr=0.1, steps=4000, rng=rng)
rel = np.linalg.norm(B @ A - dW_true) / np.linalg.norm(dW_true)
print(f"r={r}: params {r*(d_in+d_out):4d} vs full {d_in*d_out}; final loss {hist[-1]:.2e}; rel err of dW {rel:.3f}")
InfoNCE loss 0.3675, grad max err 1.01e-10, chance level log n = 2.0794
at init: |grad A| = 0.0 |grad B| = 3.691
r=1: params 70 vs full 1200; final loss 2.85e+01; rel err of dW 0.739
r=3: params 210 vs full 1200; final loss 3.11e-30; rel err of dW 0.000
r=8: params 560 vs full 1200; final loss 2.68e-30; rel err of dW 0.000
The best rank-1 approximation of this \(\Delta W^\star\) has relative error 0.739, which matches the \(r=1\) run.
A trajectory \(\tau = (s_0, a_0, s_1, \dots)\) has probability \(p_\theta(\tau) = p(s_0)\prod_t \pi_\theta(a_t\mid s_t)P(s_{t+1}\mid s_t, a_t)\). Show
\[\nabla_\theta\,\mathbb{E}_{\tau\sim p_\theta}[R(\tau)] = \mathbb{E}\Big[R(\tau)\sum_t\nabla_\theta\log\pi_\theta(a_t\mid s_t)\Big],\]and explain why the dynamics \(P\) never need to be known.
\(\nabla\int p_\theta R = \int p_\theta\,(\nabla\log p_\theta)\,R\). The log of the product is a sum, and \(\log p(s_0)\) and \(\log P(s_{t+1}\mid s_t,a_t)\) do not depend on \(\theta\), so only the policy terms survive. The estimator needs sampled trajectories and \(\nabla\log\pi\), never a model of the environment.
With probability ratio \(r = \pi_\theta(a\mid s)/\pi_{\text{old}}(a\mid s)\) and advantage \(A\), PPO maximizes \(L = \min\big(rA,\ \operatorname{clip}(r, 1-\epsilon, 1+\epsilon)A\big)\).
In RL with verifiable rewards, a model samples \(G\) answers to one prompt and a checker gives each a reward \(r_i\in\{0,1\}\). GRPO-style methods use \(A_i = (r_i - \bar r)/\operatorname{std}(r)\) instead of a learned value function.
import numpy as np
def softmax(z):
z = z - z.max(); e = np.exp(z); return e / e.sum()
def grad_log_pi(theta, a):
g = -softmax(theta); g[a] += 1.0 # d/dtheta log softmax(theta)_a = e_a - pi
return g
def reinforce_grads(theta, means, n, baseline, rng):
pi = softmax(theta); gs = []
for _ in range(n):
a = rng.choice(len(theta), p=pi)
r = rng.normal(means[a], 1.0)
b = pi @ means if baseline else 0.0 # value baseline V = E_pi[r]
gs.append((r - b) * grad_log_pi(theta, a))
return np.array(gs)
def group_advantages(rewards):
s = rewards.std()
return np.zeros_like(rewards) if s == 0 else (rewards - rewards.mean()) / s
def ppo_clip_grad(theta, theta_old, actions, adv, eps):
"""Gradient of mean_i min(r_i A_i, clip(r_i, 1-eps, 1+eps) A_i)."""
pi, pi_old = softmax(theta), softmax(theta_old)
g = np.zeros_like(theta)
for a, A in zip(actions, adv):
ratio = pi[a] / pi_old[a]
clipped = (A > 0 and ratio > 1 + eps) or (A < 0 and ratio < 1 - eps)
if not clipped:
g += A * ratio * grad_log_pi(theta, a) # d r/dtheta = r * grad log pi
return g / len(actions)
if __name__ == "__main__":
rng = np.random.default_rng(0)
means = np.array([1.0, 2.0, 5.0, 5.5]); theta = np.zeros(4)
for base in [False, True]:
G = reinforce_grads(theta, means, 20000, base, rng)
print(f"baseline={base}: mean grad {np.round(G.mean(0), 3)}, total variance {G.var(0).sum():.2f}")
print("exact grad:", np.round(softmax(theta) * (means - softmax(theta) @ means), 3))
# Verifiable-reward task: 4 answers, only answer 3 is "correct" (reward 1).
# GRPO-style loop: sample a group of 8, normalize rewards, take a few PPO-clip steps.
theta = np.array([1.0, 0.5, 0.0, -1.0])
print(f"start P(correct) = {softmax(theta)[3]:.3f}")
for it in range(60):
pi = softmax(theta); acts = rng.choice(4, size=8, p=pi)
adv = group_advantages((acts == 3).astype(float))
theta_old = theta.copy()
for _ in range(4):
theta = theta + 0.5 * ppo_clip_grad(theta, theta_old, acts, adv, eps=0.2)
print(f"end P(correct) = {softmax(theta)[3]:.3f}")
for p in [0.125, 0.5, 0.875]:
k = int(8 * p); r = np.array([1.0] * k + [0.0] * (8 - k))
A = group_advantages(r)
print(f"p={p}: A(correct)={A[0]:.3f} vs sqrt((1-p)/p)={np.sqrt((1-p)/p):.3f}; A(wrong)={A[-1]:.3f} vs -sqrt(p/(1-p))={-np.sqrt(p/(1-p)):.3f}")
baseline=False: mean grad [-0.606 -0.344 0.415 0.535], total variance 11.23
baseline=True: mean grad [-0.597 -0.341 0.404 0.534], total variance 2.59
exact grad: [-0.594 -0.344 0.406 0.531]
start P(correct) = 0.064
end P(correct) = 0.988
p=0.125: A(correct)=2.646 vs sqrt((1-p)/p)=2.646; A(wrong)=-0.378 vs -sqrt(p/(1-p))=-0.378
p=0.5: A(correct)=1.000 vs sqrt((1-p)/p)=1.000; A(wrong)=-1.000 vs -sqrt(p/(1-p))=-1.000
p=0.875: A(correct)=0.378 vs sqrt((1-p)/p)=0.378; A(wrong)=-2.646 vs -sqrt(p/(1-p))=-2.646
Both estimators agree with the exact gradient; the baseline cuts total variance by about 4×.
A scalar system evolves as \(s_{t+1} = a s_t + b u_t + w_t\) with \(w_t \sim \mathcal N(0, \sigma^2)\) independent. The cost is \(\sum_{t=0}^{T-1}\big(q s_t^2 + r u_t^2\big) + q s_T^2\), with \(q, r \gt 0\).
import numpy as np
# Scalar finite-horizon LQR: s_{t+1} = a s_t + b u_t + w_t, w_t ~ N(0, sigma^2)
# cost = sum_{t=0}^{T-1} (q s_t^2 + r u_t^2) + q s_T^2
a, b, q, r, sigma, T = 1.1, 0.5, 1.0, 0.2, 0.3, 30
def riccati(a, b, q, r, sigma, T):
"""Backward pass. V_t(s) = p[t] s^2 + c[t]; optimal u_t = -K[t] s_t."""
p, c, K = np.zeros(T + 1), np.zeros(T + 1), np.zeros(T)
p[T] = q
for t in range(T - 1, -1, -1):
K[t] = a * b * p[t + 1] / (r + b * b * p[t + 1])
p[t] = q + a * a * p[t + 1] - (a * b * p[t + 1]) ** 2 / (r + b * b * p[t + 1])
c[t] = c[t + 1] + p[t + 1] * sigma ** 2
return p, c, K
def rollout_cost(K, s0, n, rng):
s = np.full(n, s0, dtype=float); cost = np.zeros(n)
for t in range(T):
u = -K[t] * s
cost += q * s * s + r * u * u
s = a * s + b * u + sigma * rng.normal(size=n)
return cost + q * s * s
if __name__ == "__main__":
rng = np.random.default_rng(0)
p, c, K = riccati(a, b, q, r, sigma, T)
s0 = 2.0
print(f"K_0 = {K[0]:.4f} (steady-state gain; a = {a} is unstable, closed loop a - bK = {a - b*K[0]:.4f})")
print(f"predicted expected cost p_0 s0^2 + c_0 = {p[0]*s0**2 + c[0]:.4f}")
print(f"simulated expected cost = {rollout_cost(K, s0, 400000, rng).mean():.4f}")
for scale in [0.9, 1.1]:
print(f"gain x{scale}: simulated cost {rollout_cost(scale * K, s0, 400000, rng).mean():.4f}")
_, _, K0 = riccati(a, b, q, r, 0.0, T)
print("gains identical with and without noise:", np.allclose(K, K0))
K_0 = 1.4823 (steady-state gain; a = 1.1 is unstable, closed loop a - bK = 0.3589)
predicted expected cost p_0 s0^2 + c_0 = 10.9992
simulated expected cost = 10.9975
gain x0.9: simulated cost 11.1052
gain x1.1: simulated cost 11.0978
gains identical with and without noise: True