Author: Anas Hussain (a1n4a)
Version: 1.0 β July 2026
Format: Fully self-contained. Every concept taught, every derivation shown, every algorithm implemented, every diagram embedded.
This part teaches you every piece of mathematics you'll encounter in ML. You don't need prior knowledge β just the willingness to follow along.
Every ML model manipulates numbers. Linear algebra is the mathematical language for doing this efficiently.
A vector is an ordered list of numbers. In ML, every data point is a vector.
Example: A house with 3 features
x = [size_in_sqft, number_of_bedrooms, age_in_years]
x = [1500, 3, 10]
We write: x β βΒ³ (x is in 3-dimensional real space)
Vector operations β the building blocks of neural networks:
Addition: [1, 2] + [3, 4] = [4, 6] (element-wise)
Scalar mult: 2 Γ [1, 2, 3] = [2, 4, 6] (multiply each element)
Dot product: [1, 2] Β· [3, 4] = 1Γ3 + 2Γ4 = 11 (scalar result)
The dot product is the single most important operation in ML. Every neural network layer, every attention mechanism, every similarity computation boils down to dot products.
import numpy as np
# Vectors in code
x = np.array([1500, 3, 10]) # a house
y = np.array([2000, 4, 5]) # another house
# Dot product = how similar are they?
similarity = np.dot(x, y) # 1500Γ2000 + 3Γ4 + 10Γ5 = 3,000,062
A matrix is a rectangular array of numbers. In ML:
- A dataset of N houses with D features = N Γ D matrix
- A layer's weights = matrix
- An image (256Γ256 pixels Γ 3 colors) = 256 Γ 256 Γ 3 tensor
Matrix A (2Γ3): Matrix B (3Γ2): A Γ B (2Γ2):
[1, 2, 3] [7, 8] [1Γ7+2Γ9+3Γ11, 1Γ8+2Γ10+3Γ12]
[4, 5, 6] [9, 10] [4Γ7+5Γ9+6Γ11, 4Γ8+5Γ10+6Γ12]
[11, 12]
= [58, 64]
[139, 154]
import numpy as np
# Matrix multiplication
A = np.array([[1, 2, 3], [4, 5, 6]])
B = np.array([[7, 8], [9, 10], [11, 12]])
C = A @ B # or np.matmul(A, B)
print(C) # [[58, 64], [139, 154]]
# A neural network layer: h = X @ W + b
X = np.random.randn(32, 784) # batch of 32 images, 784 pixels each
W = np.random.randn(784, 256) # weight matrix
b = np.random.randn(256) # bias
h = X @ W + b # shape: (32, 256)
# This is called a "linear layer" or "fully-connected layer"
Property Definition ML Use
βββββββββ ββββββββββ ββββββ
Transpose (Aα΅) Swap rows and columns Backpropagation, attention
Inverse (Aβ»ΒΉ) A Γ Aβ»ΒΉ = I Solving normal equations
Identity (I) 1 on diagonal, 0 elsewhere The "do nothing" matrix
Trace (tr[A]) Sum of diagonal elements Regularization, optimization
Determinant (|A|) "Volume" of transformation Normalizing flows, change of var.
Eigenvalues (Ξ») Av = Ξ»v PCA, graph theory, stability
Singular Values Diagonal of Ξ£ in A = UΞ£Vα΅ SVD, compression, PCA
The Singular Value Decomposition (SVD) β the most beautiful matrix factorization:
Every matrix can be decomposed into:
- $U$: left singular vectors (columns: orthonormal basis for column space)
- $\Sigma$: singular values (diagonal: importance of each direction, sorted descending)
- $V^T$: right singular vectors (rows: orthonormal basis for row space)
# SVD in one line
U, S, Vt = np.linalg.svd(A, full_matrices=False)
# Low-rank approximation: keep k largest singular values
k = 50
A_approx = U[:, :k] @ np.diag(S[:k]) @ Vt[:k, :]
# This is the foundation of PCA, recommendation systems, and compression
Why SVD matters for ML: Any matrix can be approximated by keeping only its largest singular values. This is:
- PCA (principal component analysis)
- Recommender system matrix factorization
- Model compression (low-rank adaptation = LoRA)
- Attention optimization
A tensor is a generalization of vectors and matrices to any number of dimensions:
Scalar: shape [] or ()
Vector: shape [3] or (3,)
Matrix: shape [3, 4] or (3, 4)
3-tensor: shape [3, 4, 5] or (3, 4, 5)
# In ML, everything is tensors:
batch_images = torch.randn(32, 3, 256, 256) # batch, channels, height, width
batch_text = torch.randn(32, 128, 768) # batch, seq_len, embedding_dim
A norm tells you how "big" a vector is:
L1 norm: ||x||β = |xβ| + |xβ| + ... + |xβ| (sparsity)
L2 norm: ||x||β = β(xβΒ² + xβΒ² + ... + xβΒ²) (Euclidean distance)
Lβ norm: ||x||β = max(|xβ|, |xβ|, ..., |xβ|) (worst-case)
Frobenius: ||A||_F = β(ΣΣ Aα΅’β±ΌΒ²) (matrix L2)
x = np.array([3, -4])
l1 = np.sum(np.abs(x)) # 7
l2 = np.sqrt(np.sum(x**2)) # 5
l2 = np.linalg.norm(x) # same
Key insight: L1 regularization pushes weights to zero (feature selection). L2 regularization shrinks all weights (weight decay).
Calculus tells us how to change our model to reduce errors. This is all of training.
A derivative $f'(x)$ tells you how fast $f$ changes when you change $x$:
Geometric: slope of the tangent line at point x
Intuitive: if I nudge x up a tiny bit, does f go up or down?
# Numerical derivative (approximate)
def derivative(f, x, h=1e-7):
return (f(x + h) - f(x - h)) / (2 * h)
# Example: f(x) = xΒ²
f = lambda x: x**2
print(derivative(f, 3)) # β 6.0 (since derivative of xΒ² is 2x)
All the derivatives you'll ever need in ML:
Function Derivative Used In
ββββββββ ββββββββββ βββββββ
xβΏ nΒ·xβΏβ»ΒΉ Polynomials
eΛ£ eΛ£ Softmax, sigmoid
ln(x) 1/x Cross-entropy loss
sin(x) cos(x) Positional encoding
Ο(x)=1/(1+eβ»Λ£) Ο(x)(1-Ο(x)) Sigmoid activation
tanh(x) 1-tanhΒ²(x) Tanh activation
ReLU(x)=max(0,x) 0 if x<0, 1 if x>0 ReLU activation
Most ML functions have many inputs (weights). A partial derivative $\frac{\partial f}{\partial w_i}$ tells you how $f$ changes when you change only $w_i$, keeping everything else fixed.
# f(w, b) = (w*x + b - y)Β² (squared error for one data point)
def f(w, b, x=2, y=5):
return (w*x + b - y)**2
# Partial derivative w.r.t w: 2*(w*x + b - y)*x
# Partial derivative w.r.t b: 2*(w*x + b - y)*1
Every neural network trains using the chain rule. Period.
Where $L$ is loss, $\hat{y}$ is prediction, $z$ is pre-activation, $w$ is weight.
The magic of backprop: you compute all partial derivatives in one forward + one backward pass through the network. This is exponentially faster than computing each derivative separately.
The gradient $\nabla f$ is a vector of all partial derivatives:
Key property: The gradient points in the direction of steepest ascent. To minimize, move opposite to the gradient.
This is gradient descent β the single algorithm that trains every neural network.
# Gradient descent visualized
ΞΈ = random_initial_weights()
for step in range(steps):
gradient = compute_gradient(loss_function, ΞΈ, data)
ΞΈ = ΞΈ - learning_rate * gradient
Jacobian $J$: matrix of all first-order partial derivatives (for vector-output functions).
- Shape: [output_dim Γ input_dim]
- Used in: normalizing flows, invertible networks
Hessian $H$: matrix of all second-order partial derivatives.
- Shape: [input_dim Γ input_dim]
- Used in: second-order optimization (Newton methods), understanding loss landscape
- $H_{ij} = \partialΒ²L / \partial w_i \partial w_j$
In practice: We never compute the full Hessian (too expensive). But its properties (eigenvalues, condition number) tell us how hard optimization will be.
A probability $P(A)$ is a number between 0 and 1 representing how likely event A is.
P(rain tomorrow) = 0.3 means 30% chance
P(sunny) = 0.7 means 70% chance
total must = 1
Joint probability: $P(A, B)$ = probability A AND B both happen
Conditional probability: $P(A|B)$ = probability of A given B happened
Marginal probability: $P(A) = \sum_B P(A, B)$ = probability of A regardless of B
Posterior = Likelihood Γ Prior / Evidence
ΞΈ = model parameters, D = data
P(ΞΈ|D): what we believe about parameters AFTER seeing data
P(D|ΞΈ): how likely the data is given parameters (likelihood)
P(ΞΈ): what we believed BEFORE seeing data (prior)
P(D): probability of the data (normalization)
Bayes' theorem is how all learning works. You start with beliefs (prior), see evidence (data), update beliefs (posterior).
The Normal (Gaussian) Distribution β the most important in ML:
import numpy as np
import matplotlib.pyplot as plt
# Generate samples from a Gaussian
mu, sigma = 0, 1
samples = np.random.normal(mu, sigma, 10000)
# Estimate parameters from data
mu_hat = np.mean(samples) # β 0
sigma_hat = np.std(samples) # β 1
# Why Gaussian? Central Limit Theorem:
# The sum of many independent random variables
# approaches a Gaussian, regardless of their individual distributions
The principle: Choose parameters $\theta$ that make the observed data most probable.
In practice, we minimize negative log-likelihood (NLL):
Why log-likelihood matters for ML: Every loss function you'll use (MSE, cross-entropy) is actually a negative log-likelihood:
| Loss Function | Underlying Distribution |
|---|---|
| MSE | Gaussian with fixed variance |
| Cross-entropy | Bernoulli/Categorical |
| Binary cross-entropy | Bernoulli |
# MSE = negative log-likelihood of Gaussian
# For y ~ N(Ε·, ΟΒ²):
# log P(y|Ε·) = -Β½(y-Ε·)Β²/ΟΒ² - Β½log(2ΟΟΒ²)
# Minimizing MSE = maximizing log likelihood
# (assuming fixed ΟΒ²)
loss = np.mean((y_pred - y_true) ** 2) # this IS MLE under Gaussian noise
Law of Large Numbers: As sample size increases, sample average converges to expected value.
β More data = more reliable estimates.
β This is why training on more data improves models.
Central Limit Theorem: The distribution of sample means approaches a Gaussian as n grows.
β Even if individual data points are weird, averages are normal.
β This justifies using Gaussian assumptions in many ML models.
Null hypothesis ($H_0$): "Nothing interesting is happening" (e.g., model is random)
p-value: Probability of seeing results this extreme if $H_0$ is true
Significance level ($\alpha$): Threshold below which we reject $H_0$ (usually 0.05 or 0.01)
In ML: We use these concepts for:
- A/B testing: is model B really better than model A?
- Feature significance: does adding this feature matter?
- Model comparison: is the improvement statistically significant?
Information theory gives us the language to talk about "how much is learned."
Entropy $H(X)$ measures the average "surprise" or uncertainty of a random variable:
Example: A fair coin
P(heads) = 0.5, P(tails) = 0.5
H = -0.5Β·logβ(0.5) - 0.5Β·logβ(0.5)
H = -0.5Β·(-1) - 0.5Β·(-1) = 0.5 + 0.5 = 1 bit
Example: A always-heads coin
P(heads) = 1, P(tails) = 0
H = -1Β·logβ(1) - 0Β·logβ(0) = 0 bits (no uncertainty)
Cross-entropy measures how many bits are needed to encode events from distribution $P$ using a code optimized for distribution $Q$:
This is the most common loss in ML (classification). When $P$ is the true distribution (one-hot labels) and $Q$ is our model's prediction:
# Cross-entropy loss for classification
# True label: cat (index 2 in 10 classes)
# Model predicts: [0.01, 0.02, 0.93, 0.01, ..., 0.01]
true_class = 2
predicted_probs = [0.01, 0.02, 0.93, 0.01, ...]
loss = -np.log(predicted_probs[true_class]) # -log(0.93) = 0.073
Kullback-Leibler divergence measures how one distribution $Q$ differs from another $P$:
Properties:
- $KL(P || Q) \geq 0$ (always non-negative)
- $KL(P || Q) = 0$ iff $P = Q$
- $KL(P || Q) \neq KL(Q || P)$ (not symmetric β not a metric)
Relation: $H(P, Q) = H(P) + KL(P || Q)$
Cross-entropy = entropy of true distribution + divergence from it.
KL divergence in ML:
- VAE loss: KL(encoder || prior)
- Distillation: KL(student || teacher)
- Policy gradient: KL(new policy || old policy)
Mutual information = how much knowing X tells you about Y. Zero if independent.
Used in:
- Feature selection (pick features with high I(X; target))
- Representation learning (InfoNCE loss, contrastive learning)
- The Information Bottleneck method
The Evidence Lower BOund (ELBO) underpins VAEs, diffusion models, and Bayesian neural nets:
The ELBO is what makes training generative models tractable. We maximize the ELBO instead of maximizing $\log P(x)$ directly.
def gradient_descent(gradient_fn, init_params, lr=0.01, steps=1000):
"""
The simplest optimization algorithm.
gradient_fn: function that returns βL at current parameters
init_params: starting point ΞΈβ
lr: step size Ξ· (learning rate)
steps: how many iterations
"""
params = init_params.copy()
history = [params.copy()]
for i in range(steps):
grad = gradient_fn(params)
params = params - lr * grad
history.append(params.copy())
return params, history
Key hyperparameter: learning rate (lr / Ξ·):
Instead of computing gradient on ALL data (batch), compute on a random mini-batch:
def sgd_step(model, batch_X, batch_y, lr):
"""
One step of SGD.
Key insight: gradient on mini-batch β gradient on full dataset
but ~1000x cheaper to compute.
"""
predictions = model(batch_X)
loss = compute_loss(predictions, batch_y)
loss.backward() # compute gradients
with torch.no_grad():
for param in model.parameters():
param -= lr * param.grad
param.grad.zero_()
Why it works: The expected gradient over a random mini-batch equals the true gradient:
So SGD = noisy gradient descent. The noise actually helps generalization!
Condition number: ratio of largest to smallest eigenvalue of the Hessian.
- High condition number β landscape is steep in some directions, flat in others β hard to optimize.
- Batch normalization and residual connections improve condition number.
Saddle points: points where gradient = 0 but not a minimum.
- In high dimensions, most critical points are saddles, not local minima.
- SGD escapes saddle points naturally due to noise.
Definition (Mitchell, 1997): A computer program learns from experience E with respect to task T and performance measure P if its performance at T, measured by P, improves with E.
For every ML problem, you must define:
The hypothesis space $\mathcal{H}$ is the set of all possible models our learning algorithm can produce.
A linear model with 2 features: h(x) = wβxβ + wβxβ + b
Hypothesis space: all possible (wβ, wβ, b) β βΒ³
A decision tree of depth 3: all possible tree structures of depth β€ 3
Hypothesis space: all decision trees fitting this constraint
Key trade-off: Larger hypothesis space = more expressive but harder to learn (needs more data). This is the bias-variance tradeoff formalized.
Theorem (Wolpert, 1996): No learning algorithm is universally better than any other across all possible problems. If algorithm A beats algorithm B on some problems, B beats A on others.
What this means: There's no "best ML algorithm." The best algorithm depends on your specific data and problem. The art of ML is matching algorithms to problems.
Closed form (Normal Equation):
import numpy as np
def linear_regression_closed_form(X, y):
"""
Closed-form solution for linear regression.
X: (n_samples, n_features)
y: (n_samples,)
"""
# Add bias term (column of 1s)
X_with_bias = np.c_[np.ones(X.shape[0]), X]
# Normal equation: w = (X^T X)^(-1) X^T y
w = np.linalg.inv(X_with_bias.T @ X_with_bias) @ X_with_bias.T @ y
return w[0], w[1:] # bias, weights
def linear_regression_gradient(X, y, lr=0.01, epochs=1000):
"""
Gradient descent solution for linear regression.
More scalable than closed-form for large datasets.
"""
n, d = X.shape
w = np.zeros(d)
b = 0.0
for epoch in range(epochs):
y_pred = X @ w + b
error = y_pred - y
# Gradients
dw = (2/n) * X.T @ error
db = (2/n) * np.sum(error)
# Update
w -= lr * dw
b -= lr * db
if epoch % 100 == 0:
loss = np.mean(error ** 2)
print(f"Epoch {epoch}: loss = {loss:.4f}")
return w, b
| Method | Pros | Cons | Best For |
|---|---|---|---|
| Normal Equation | Exact solution, no hyperparameters | O(nΒ³) matrix inversion | < 10K samples |
| Gradient Descent | Scales to large data | Needs learning rate tuning | > 10K samples |
Linear regression predicts a continuous number. For classification, we need probabilities between 0 and 1.
The fix: Pass the linear output through the sigmoid function:
Intuition: If true label is 1, loss = -log(Ε·). We want Ε· close to 1.
If true label is 0, loss = -log(1-Ε·). We want Ε· close to 0.
def sigmoid(z):
"""Numerically stable sigmoid."""
return 1 / (1 + np.exp(-np.clip(z, -100, 100)))
def logistic_regression_gradient(X, y, lr=0.01, epochs=1000):
"""
Logistic regression via gradient descent.
"""
n, d = X.shape
w = np.zeros(d)
b = 0.0
for epoch in range(epochs):
# Forward pass
z = X @ w + b
y_pred = sigmoid(z)
# Binary cross-entropy loss
loss = -np.mean(y * np.log(y_pred + 1e-8) + (1-y) * np.log(1 - y_pred + 1e-8))
# Gradients
error = y_pred - y
dw = (1/n) * X.T @ error
db = (1/n) * np.sum(error)
# Update
w -= lr * dw
b -= lr * db
if epoch % 200 == 0:
print(f"Epoch {epoch}: loss = {loss:.4f}")
return w, b
# Decision boundary: P(y=1|x) > 0.5
def predict(X, w, b):
return (sigmoid(X @ w + b) >= 0.5).astype(int)
For K > 2 classes, use softmax instead of sigmoid:
def softmax(z):
"""Numerically stable softmax."""
shifted = z - np.max(z, axis=-1, keepdims=True)
exp = np.exp(shifted)
return exp / np.sum(exp, axis=-1, keepdims=True)
# Cross-entropy for K classes:
# L = -Ξ£ y_k Β· log(Ε·_k)
# Where y is one-hot encoded (e.g., [0, 0, 1, 0])
The softmax is everywhere: final layer of every classification LLM, attention mechanism, multi-class logistic regression.
Find the hyperplane that maximizes the margin between classes.
Key insight: Only the closest points to the decision boundary (support vectors) determine the boundary. Points far away don't matter.
Key insight: SVMs can project data into higher dimensions without explicitly computing the projection. This is the kernel trick:
We never compute $\phi$, just the dot product in the transformed space.
# Common kernels
linear: K(x, y) = x Β· y
polynomial: K(x, y) = (x Β· y + c)^d
RBF/Gaussian: K(x, y) = exp(-Ξ³||x-y||Β²)
sigmoid: K(x, y) = tanh(Ξ±xΒ·y + c)
The RBF kernel is the default choice β it can learn any smooth decision boundary given enough data.
from sklearn.svm import SVC
# RBF SVM β the "I don't know what to use" champion
svm = SVC(kernel='rbf', C=1.0, gamma='scale')
svm.fit(X_train, y_train)
# C controls the margin-violation penalty
# Large C β hard margin (overfit potential)
# Small C β soft margin (more tolerant of errors)
The Representer Theorem: The optimal weight vector $w$ for an SVM can always be written as a linear combination of training points:
This is what makes the kernel trick work β we only need the $\alpha$ values and the kernel function.
Splitting criterion: Find the feature and threshold that best separate the data.
Gini impurity: $G = 1 - \sum_{k=1}^{K} p_k^2$ (probability of misclassifying a random element)
Information gain: $IG = H(parent) - \sum \frac{n_j}{n} H(child_j)$ (reduction in entropy)
class DecisionTreeStub:
"""A single-level decision tree (decision stump)."""
def fit(self, X, y):
self.best_feature = None
self.best_threshold = None
self.best_gini = 1.0
n, d = X.shape
for feature in range(d):
thresholds = np.percentile(X[:, feature], np.linspace(10, 90, 20))
for threshold in thresholds:
left = y[X[:, feature] <= threshold]
right = y[X[:, feature] > threshold]
gini = self._weighted_gini(left, right)
if gini < self.best_gini:
self.best_gini = gini
self.best_feature = feature
self.best_threshold = threshold
self.left_pred = np.mean(y[X[:, self.best_feature] <= self.best_threshold])
self.right_pred = np.mean(y[X[:, self.best_feature] > self.best_threshold])
def predict(self, X):
left_mask = X[:, self.best_feature] <= self.best_threshold
preds = np.zeros(X.shape[0])
preds[left_mask] = self.left_pred
preds[~left_mask] = self.right_pred
return preds
def _weighted_gini(self, left, right):
n_left, n_right = len(left), len(right)
n_total = n_left + n_right
gini_left = 1 - sum((np.mean(left == k))**2 for k in np.unique(left)) if n_left > 0 else 0
gini_right = 1 - sum((np.mean(right == k))**2 for k in np.unique(right)) if n_right > 0 else 0
return (n_left/n_total) * gini_left + (n_right/n_total) * gini_right
The insight: One decision tree overfits. A hundred trees that vote don't.
class RandomForest:
"""Simplified random forest for classification."""
def __init__(self, n_trees=100, max_depth=5, max_features='sqrt'):
self.n_trees = n_trees
self.max_depth = max_depth
self.max_features = max_features
self.trees = []
def fit(self, X, y):
n, d = X.shape
n_features = int(np.sqrt(d)) if self.max_features == 'sqrt' else d
for _ in range(self.n_trees):
# Bootstrap sample
indices = np.random.choice(n, n, replace=True)
X_boot, y_boot = X[indices], y[indices]
# Random feature subset
feature_subset = np.random.choice(d, n_features, replace=False)
X_subset = X_boot[:, feature_subset]
# Train tree
tree = DecisionTree(max_depth=self.max_depth)
tree.fit(X_subset, y_boot)
self.trees.append((tree, feature_subset))
def predict(self, X):
predictions = np.zeros((X.shape[0], len(self.trees)))
for i, (tree, features) in enumerate(self.trees):
predictions[:, i] = tree.predict(X[:, features])
# Majority vote
return np.round(np.mean(predictions, axis=1))
The insight: Instead of training trees in parallel (bagging), train them sequentially β each new tree corrects the errors of the previous ones.
# Simplified gradient boosting for regression
def gradient_boosting(X, y, n_estimators=100, lr=0.1, max_depth=3):
"""Train a gradient boosting ensemble."""
# Start with mean prediction
F = np.full(y.shape, np.mean(y))
trees = []
for m in range(n_estimators):
# Compute pseudo-residuals (negative gradient)
residuals = y - F # For MSE: -dL/dF = y - F
# Train tree on residuals
tree = DecisionTreeRegressor(max_depth=max_depth)
tree.fit(X, residuals)
# Update ensemble
F += lr * tree.predict(X)
trees.append(tree)
return trees
def predict_boosting(X, trees, lr=0.1):
F = np.zeros(X.shape[0])
for tree in trees:
F += lr * tree.predict(X)
return F
Why XGBoost dominates tabular data:
- Built-in regularization (prevents overfitting)
- Handles missing values automatically
- Extremely optimized (parallel processing, cache-aware access)
- Feature importance built-in
- Works on small and medium data better than neural nets
The algorithm:
def kmeans(X, k, max_iters=100, tol=1e-4):
"""
K-means clustering from scratch.
Steps:
1. Initialize k random centroids
2. Assign each point to nearest centroid
3. Update centroids to mean of assigned points
4. Repeat 2-3 until convergence
"""
n, d = X.shape
# Step 1: Initialize centroids (k-means++ style)
centroids = [X[np.random.choice(n)]]
for _ in range(k - 1):
distances = np.min([np.linalg.norm(X - c, axis=1) for c in centroids], axis=0)
probs = distances / distances.sum()
centroids.append(X[np.random.choice(n, p=probs)])
centroids = np.array(centroids)
for iteration in range(max_iters):
# Step 2: Assign each point to nearest centroid
distances = np.array([np.linalg.norm(X - c, axis=1) for c in centroids])
labels = np.argmin(distances, axis=0)
# Step 3: Update centroids
new_centroids = np.array([X[labels == j].mean(axis=0) for j in range(k)])
# Check convergence
if np.all(np.linalg.norm(new_centroids - centroids, axis=1) < tol):
break
centroids = new_centroids
return labels, centroids
How to choose K (Elbow Method):
inertias = []
for k in range(1, 11):
labels, centroids = kmeans(X, k)
inertia = sum(np.min([np.linalg.norm(X - c, axis=1) for c in centroids], axis=0)**2)
inertias.append(inertia)
# Plot: look for the "elbow" where adding more clusters gives diminishing returns
The goal: Find the directions of maximum variance in the data.
def pca_from_scratch(X, n_components=2):
"""
PCA via eigendecomposition of covariance matrix.
"""
# Center the data
X_centered = X - X.mean(axis=0)
# Covariance matrix
cov = X_centered.T @ X_centered / (X_centered.shape[0] - 1)
# Eigendecomposition
eigenvalues, eigenvectors = np.linalg.eigh(cov)
# Sort by eigenvalue (descending)
idx = np.argsort(eigenvalues)[::-1]
eigenvalues = eigenvalues[idx]
eigenvectors = eigenvectors[:, idx]
# Select top n_components
components = eigenvectors[:, :n_components]
# Project data
X_reduced = X_centered @ components
# Explained variance ratio
explained_variance_ratio = eigenvalues[:n_components] / eigenvalues.sum()
return X_reduced, components, explained_variance_ratio
PCA intuition: If you have 100-dimensional data, the first 2-3 principal components often capture 80%+ of the variance. You can visualize and denoise by projecting to these components.
The fundamental question: Why does a model that performs well on training data also perform well on unseen data?
PAC Learning (Valiant, 1984):
A concept class $\mathcal{C}$ is PAC-learnable if there exists an algorithm $A$ such that for any distribution $D$ and any $\epsilon, \delta > 0$, with probability at least $1-\delta$, $A$ outputs a hypothesis $h$ with error at most $\epsilon$, using a number of samples polynomial in $1/\epsilon$, $1/\delta$, and the complexity of $\mathcal{C}$.
In plain English: We can guarantee that our model will be approximately correct ($\epsilon$ error) with high probability ($1-\delta$), given enough data.
The VC dimension of a hypothesis class $\mathcal{H}$ is the largest number of points that $\mathcal{H}$ can shatter (classify in all possible label assignments).
Example: Linear classifiers in 2D
- Can shatter 3 points? YES (any labeling of 3 points can be separated)
- Can shatter 4 points? NO (for any 4 points, at least one labeling is impossible)
- VC dimension = 3
Example: Decision trees of depth D
- VC dimension = O(2^D) (exponential in depth)
Fundamental bound:
Where $d_{VC}$ is VC dimension and $n$ is sample size. More complex models (higher VC dimension) need more data.
VC dimension is distribution-independent (worst-case). Rademacher complexity gives tighter, data-dependent bounds:
Where $\sigma_i \in {-1, +1}$ are random signs (Rademacher variables).
Intuition: How well can the best function in $\mathcal{F}$ fit random noise? If it can fit random labels well, it's complex and may overfit.
Generalization bound:
Total error = BiasΒ² + Variance + Irreducible Noise
Mathematically:
The fundamental insight: You cannot reduce bias and variance simultaneously. Every modeling choice trades one for the other.
Modern finding (Belkin et al., 2019): For deep neural networks, the classical U-shaped bias-variance curve doesn't hold. Instead:
Key insight: Classical statistics says "more parameters than data = disaster." Deep learning shows it works beautifully. Overparameterization helps generalization.
# ALWAYS split before anything else
from sklearn.model_selection import train_test_split
# Standard: 80/20 or 70/15/15
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# For hyperparameter tuning: three-way split
X_train, X_temp, y_train, y_temp = train_test_split(X, y, test_size=0.3)
X_val, X_test, y_val, y_test = train_test_split(X_temp, y_temp, test_size=0.5)
from sklearn.model_selection import KFold
kf = KFold(n_splits=5, shuffle=True, random_state=42)
scores = []
for train_idx, val_idx in kf.split(X_train):
X_fold_train, X_fold_val = X_train[train_idx], X_train[val_idx]
y_fold_train, y_fold_val = y_train[train_idx], y_train[val_idx]
model = train(X_fold_train, y_fold_train)
score = model.score(X_fold_val, y_fold_val)
scores.append(score)
print(f"Mean CV score: {np.mean(scores):.3f} Β± {np.std(scores):.3f}")
Classification metrics in detail:
Predicted Positive Predicted Negative
Actual Pos TP (True Positive) FN (False Negative)
Actual Neg FP (False Positive) TN (True Negative)
Accuracy = (TP + TN) / (TP + TN + FP + FN) [overall correctness]
Precision = TP / (TP + FP) [when we say yes, how often right?]
Recall = TP / (TP + FN) [how many real positives do we catch?]
F1 = 2Β·PΒ·R / (P + R) [harmonic mean of P and R]
When to use which:
- Accuracy: Only for balanced classes. Worthless for 99:1 class imbalance.
- Precision: Minimize false alarms (spam: don't put real emails in spam).
- Recall: Don't miss positives (cancer detection: better safe than sorry).
- F1: Balance both.
- ROC-AUC: Overall ranking quality (model's ability to separate classes).
- PR-AUC: Better for imbalanced datasets.
def forward_neuron(x, w, b, activation='relu'):
"""
A single neuron forward pass.
x: input vector
w: weight vector
b: bias scalar
"""
z = np.dot(x, w) + b
if activation == 'relu':
return np.maximum(0, z)
elif activation == 'sigmoid':
return 1 / (1 + np.exp(-np.clip(z, -100, 100)))
elif activation == 'tanh':
return np.tanh(z)
elif activation == 'linear':
return z
Matrix form of a 2-layer network:
Layer 1: h = ReLU(x Β· Wβ + bβ) x: (batch, D_in), Wβ: (D_in, D_hidden)
Layer 2: Ε· = h Β· Wβ + bβ Wβ: (D_hidden, D_out)
Theorem (Cybenko, 1989; Hornik, 1991): A feedforward network with a single hidden layer containing a finite number of neurons can approximate any continuous function on a compact subset of $\mathbb{R}^n$, given sufficiently many neurons and a non-polynomial activation function.
What this means: Neural networks can theoretically learn any function β given enough hidden units and the right weights. The question is whether:
1. We can find those weights (optimization)
2. It generalizes (overfitting)
3. It's computationally feasible (practical limits)
The catch: The number of neurons needed might be exponential in the input dimension for some functions. Deep networks (many layers) can represent the same functions exponentially more compactly. This is the depth separation result.
Consider a 3-layer network:
zβ = x Β· Wβ + bβ [pre-activation, layer 1]
aβ = Ο(zβ) [activation, layer 1]
zβ = aβ Β· Wβ + bβ [pre-activation, layer 2]
aβ = Ο(zβ) [activation, layer 2]
zβ = aβ Β· Wβ + bβ [pre-activation, output layer]
Ε· = zβ [linear output for regression]
L = Β½(Ε· - y)Β² [MSE loss]
We want $\partial L / \partial W_1$, $\partial L / \partial W_2$, $\partial L / \partial W_3$, and similarly for biases.
Step 1: Output layer gradients
Step 2: Backprop through layer 2
Step 3: Backprop through layer 1
For any layer $l$:
This is the backpropagation algorithm. The error $\delta$ "flows backward" through the network, with the chain rule applied at each layer.
def backprop_full(X, y, W1, b1, W2, b2, W3, b3):
"""Full forward + backward pass for 3-layer network."""
# Forward pass
z1 = X @ W1 + b1
a1 = np.maximum(0, z1) # ReLU
z2 = a1 @ W2 + b2
a2 = np.maximum(0, z2)
z3 = a2 @ W3 + b3
y_pred = z3
# Loss
loss = 0.5 * np.mean((y_pred - y) ** 2)
# Backward pass
m = X.shape[0] # batch size
# Output layer
dz3 = (y_pred - y) / m # Ξ΄β
dW3 = a2.T @ dz3
db3 = np.sum(dz3, axis=0)
# Layer 2
da2 = dz3 @ W3.T
dz2 = da2 * (z2 > 0) # ReLU derivative
dW2 = a1.T @ dz2
db2 = np.sum(dz2, axis=0)
# Layer 1
da1 = dz2 @ W2.T
dz1 = da1 * (z1 > 0) # ReLU derivative
dW1 = X.T @ dz1
db1 = np.sum(dz1, axis=0)
return loss, (dW1, db1, dW2, db2, dW3, db3)
Computing all gradients with finite differences would require $O(N)$ forward passes where $N$ is the number of parameters. Backpropagation does it in $O(1)$ forward + $O(1)$ backward passes β a speedup of thousands to millions.
A 256Γ256 color image = 196,608 values. A dense layer with 1000 neurons = 196 million parameters. That's absurd.
The CNN insight: Nearby pixels are related. Patterns are local. Use a small filter everywhere.
def conv2d_forward(image, kernel, stride=1, padding=0):
"""
2D convolution from scratch.
image: (H, W, C_in) β input image (height, width, channels)
kernel: (K, K, C_in, C_out) β conv filters
"""
H, W, C_in = image.shape
K, _, _, C_out = kernel.shape
# Apply padding
if padding > 0:
image = np.pad(image, ((padding, padding), (padding, padding), (0, 0)), mode='constant')
# Output dimensions
H_out = (H + 2*padding - K) // stride + 1
W_out = (W + 2*padding - K) // stride + 1
output = np.zeros((H_out, W_out, C_out))
for h in range(H_out):
for w in range(W_out):
for c in range(C_out):
# Extract region
h_start = h * stride
w_start = w * stride
region = image[h_start:h_start+K, w_start:w_start+K, :]
# Convolve
output[h, w, c] = np.sum(region * kernel[:, :, :, c])
return output
Key operations in one conv layer:
- Conv2D: apply filters β feature maps
- BatchNorm: normalize activations
- ReLU: non-linearity
- MaxPool: down-sample (keep max in 2Γ2 region)
- Dropout: regularize (randomly turn off neurons)
ResNet (He et al., 2015): Residual connections β skip layers.
Without residual: $y = F(x)$. The network must learn the identity mapping from scratch.
With residual: if $x$ is already good, $F(x)$ can be zero. Learning residuals is easier.
class ResidualBlock(nn.Module):
def __init__(self, channels):
super().__init__()
self.conv1 = nn.Conv2d(channels, channels, 3, padding=1)
self.bn1 = nn.BatchNorm2d(channels)
self.conv2 = nn.Conv2d(channels, channels, 3, padding=1)
self.bn2 = nn.BatchNorm2d(channels)
def forward(self, x):
residual = x
out = F.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out = out + residual # skip connection
return F.relu(out)
This simple addition allowed training networks with 152+ layers (vs. previous limit of ~20).
Standard neural networks assume all inputs are independent. For sequences (text, audio, time series), order is everything.
def rnn_step(x_t, h_prev, W_h, W_x, b):
"""
One timestep of an RNN.
x_t: input at time t
h_prev: hidden state from t-1
"""
return np.tanh(W_h @ h_prev + W_x @ x_t + b)
The problem: Gradients explode or vanish for sequences longer than ~20 steps. The repeated matrix multiplication in each step causes:
If $W_h$'s eigenvalues > 1 β explode. If < 1 β vanish to 0.
The key insight: A "cell state" $c_t$ that can carry information unchanged for hundreds of steps.
The LSTM equations:
Forget gate: f_t = Ο(W_f Β· [h_{t-1}, x_t] + b_f)
Input gate: i_t = Ο(W_i Β· [h_{t-1}, x_t] + b_i)
Candidate: Δ_t = tanh(W_c Β· [h_{t-1}, x_t] + b_c)
Cell update: c_t = f_t β c_{t-1} + i_t β Δ_t
Output gate: o_t = Ο(W_o Β· [h_{t-1}, x_t] + b_o)
Hidden state: h_t = o_t β tanh(c_t)
class LSTMCell:
def __init__(self, input_dim, hidden_dim):
# Concatenated weights for efficiency
self.W = np.random.randn(input_dim + hidden_dim, 4 * hidden_dim) * 0.01
self.b = np.zeros(4 * hidden_dim)
self.hidden_dim = hidden_dim
def forward(self, x, h_prev, c_prev):
"""
x: (input_dim,)
h_prev: (hidden_dim,) β previous hidden state
c_prev: (hidden_dim,) β previous cell state
"""
# Concatenate input and previous hidden
combined = np.concatenate([x, h_prev])
# Compute all gates at once
gates = self.W.T @ combined + self.b
# Split into forget, input, candidate, output gates
f = sigmoid(gates[:self.hidden_dim])
i = sigmoid(gates[self.hidden_dim:2*self.hidden_dim])
c_tilde = np.tanh(gates[2*self.hidden_dim:3*self.hidden_dim])
o = sigmoid(gates[3*self.hidden_dim:])
# Update cell state and hidden state
c = f * c_prev + i * c_tilde
h = o * np.tanh(c)
return h, c
Why LSTMs work: The cell state $c_t$ has additive updates ($+$), not multiplicative ($\times$). Gradients through the cell state don't vanish β they can flow unchanged for hundreds of steps through the forget gate.
L2 (Ridge): Add penalty proportional to squared weights.
Effect: Shrinks all weights toward zero. Never produces exact zeros.
L1 (Lasso): Add penalty proportional to absolute weights.
Effect: Shrinks weights to exact zero (feature selection).
# In practice: use weight_decay parameter in optimizer
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=0.01)
Randomly set a fraction $p$ of neurons to 0 during training.
def dropout(x, p=0.5, training=True):
"""
Dropout: randomly zero out p fraction of neurons.
During testing: scale by p (or use all neurons).
"""
if not training:
return x
mask = np.random.binomial(1, 1-p, x.shape) / (1-p)
return x * mask
Why it works: Each training step trains a different "thinned" sub-network. At test time, all sub-networks implicitly ensemble. This is equivalent to training an ensemble of $2^n$ networks (where $n$ is number of neurons).
Normalize activations over the batch dimension:
class BatchNorm:
def __init__(self, num_features, eps=1e-5, momentum=0.1):
self.gamma = np.ones(num_features)
self.beta = np.zeros(num_features)
self.running_mean = np.zeros(num_features)
self.running_var = np.ones(num_features)
self.eps = eps
self.momentum = momentum
def forward(self, x, training=True):
if training:
batch_mean = x.mean(axis=0)
batch_var = x.var(axis=0)
self.running_mean = (1 - self.momentum) * self.running_mean + self.momentum * batch_mean
self.running_var = (1 - self.momentum) * self.running_var + self.momentum * batch_var
x_norm = (x - batch_mean) / np.sqrt(batch_var + self.eps)
else:
x_norm = (x - self.running_mean) / np.sqrt(self.running_var + self.eps)
return self.gamma * x_norm + self.beta
Why it works: Prevents internal covariate shift (the distribution of activations drifting during training). Allows higher learning rates by preventing activations from growing unbounded.
Same as batch norm, but normalized over feature dimension (not batch). Used in transformers.
Key difference: Batch norm depends on batch size. Layer norm doesn't β works for batch_size = 1.
# Vanilla SGD
ΞΈ = ΞΈ - lr * βL(ΞΈ)
Problems:
1. Fixed learning rate (too big or too small)
2. Same rate for all parameters (some need bigger updates)
3. Gets stuck in ravines (oscillates)
Add velocity to gradient updates:
v = momentum * v - lr * grad
ΞΈ = ΞΈ + v
Intuition: Like a ball rolling down hill β it builds up speed, smooths out oscillations, and escapes small local minima.
Combines momentum + per-parameter adaptive learning rates.
def adam(params, grads, m, v, t, lr=0.001, beta1=0.9, beta2=0.999, eps=1e-8):
"""
One step of Adam optimizer.
"""
t += 1
for i, (param, grad) in enumerate(zip(params, grads)):
# Biased first moment estimate (momentum)
m[i] = beta1 * m[i] + (1 - beta1) * grad
# Biased second moment estimate (adaptive lr)
v[i] = beta2 * v[i] + (1 - beta2) * grad**2
# Bias correction
m_hat = m[i] / (1 - beta1**t)
v_hat = v[i] / (1 - beta2**t)
# Update
param[i] -= lr * m_hat / (np.sqrt(v_hat) + eps)
return params, m, v, t
| Optimizer | Adaptive? | Momentum? | Best For |
|---|---|---|---|
| SGD | No | No | Simple models, fine-tuning |
| SGD + Momentum | No | Yes | When manual control is needed |
| Adam | Yes | Yes | Default, works for everything |
| AdamW | Yes | Yes | Transformers (decoupled weight decay) |
| Lion | Yes | Yes | Very large models (memory efficient) |
Rule of thumb: Start with AdamW (0.001, weight_decay=0.01). Switch to SGD with momentum for fine-tuning.
Before attention (RNN era): When generating a translation, the model compressed the entire input sentence into one fixed-size vector. For long sentences, information at the beginning was lost.
Attention: At each output step, look back at ALL input positions and decide which ones matter.
def scaled_dot_product_attention(Q, K, V, mask=None):
"""
Q: (batch, seq_len_q, d_k) β queries
K: (batch, seq_len_k, d_k) β keys
V: (batch, seq_len_v, d_v) β values
(seq_len_k must equal seq_len_v)
"""
# Compute attention scores
scores = Q @ K.transpose(-2, -1) # (batch, q_len, k_len)
# Scale to prevent softmax saturation
scores = scores / np.sqrt(K.shape[-1])
# Optional mask (for causal attention)
if mask is not None:
scores = scores + mask # mask has -inf for forbidden positions
# Softmax over key dimension
weights = np.exp(scores - scores.max(axis=-1, keepdims=True))
weights = weights / weights.sum(axis=-1, keepdims=True)
# Weighted sum of values
output = weights @ V # (batch, q_len, d_v)
return output, weights
Why scale by $\sqrt{d_k}$? Without scaling, large $d_k$ causes the dot products to grow large in magnitude, pushing the softmax into regions with tiny gradients (saturation).
Instead of one attention function, use $h$ heads in parallel, each attending to different patterns:
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, n_heads):
super().__init__()
self.n_heads = n_heads
self.d_head = d_model // n_heads
# All projections
self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)
self.W_o = nn.Linear(d_model, d_model)
def forward(self, x, mask=None):
B, L, D = x.shape
# Project and reshape: (B, L, D) β (B, n_heads, L, d_head)
Q = self.W_q(x).view(B, L, self.n_heads, self.d_head).transpose(1, 2)
K = self.W_k(x).view(B, L, self.n_heads, self.d_head).transpose(1, 2)
V = self.W_v(x).view(B, L, self.n_heads, self.d_head).transpose(1, 2)
# Compute attention
scores = Q @ K.transpose(-2, -1) / np.sqrt(self.d_head)
if mask is not None:
scores = scores + mask
weights = F.softmax(scores, dim=-1)
output = weights @ V # (B, n_heads, L, d_head)
# Concatenate heads: (B, n_heads, L, d_head) β (B, L, D)
output = output.transpose(1, 2).contiguous().view(B, L, D)
return self.W_o(output)
Why multiple heads? Each head can focus on different relationships (e.g., syntax, semantics, position). The projections $W^Q, W^K, W^V$ give each head a different "perspective" on the data.
Usually: inner dimension = 4 Γ d_model (d_ff = 2048 for 512-dim model).
Since attention is permutation-invariant (no notion of order), we must inject position information.
Sinusoidal (original transformer):
def sinusoidal_positional_encoding(seq_len, d_model):
"""Create sinusoidal position encodings."""
pe = np.zeros((seq_len, d_model))
position = np.arange(0, seq_len)[:, np.newaxis]
div_term = np.exp(np.arange(0, d_model, 2) * -(np.log(10000.0) / d_model))
pe[:, 0::2] = np.sin(position * div_term)
pe[:, 1::2] = np.cos(position * div_term)
return pe
Rotary (RoPE) β modern standard (used in Llama, Mistral, GPT-NeoX):
Rotates query and key vectors by position-dependent angles:
Where $R$ is a rotation matrix. This makes attention naturally depend on relative positions: $q_m \cdot k_n$ becomes a function of $m-n$.
ALiBi β alternative used in some models (BLOOM, MPT): Add bias to attention scores based on distance.
def create_causal_mask(seq_len):
"""
Causal mask: each token attends to itself + previous tokens only.
Upper triangle = -inf (can't attend to future).
"""
mask = np.triu(np.ones((seq_len, seq_len)) * -1e9, k=1)
return mask
# With causal mask:
# Position 0 can attend to: [0]
# Position 1 can attend to: [0, 1]
# Position 2 can attend to: [0, 1, 2]
# ...
class Transformer(nn.Module):
"""
Full encoder-decoder transformer (original Vaswani et al. 2017).
"""
def __init__(self, d_model=512, n_heads=8, n_layers=6, d_ff=2048, vocab_size=30000):
super().__init__()
self.encoder = nn.TransformerEncoder(
nn.TransformerEncoderLayer(d_model, n_heads, d_ff),
num_layers=n_layers
)
self.decoder = nn.TransformerDecoder(
nn.TransformerDecoderLayer(d_model, n_heads, d_ff),
num_layers=n_layers
)
self.embedding = nn.Embedding(vocab_size, d_model)
self.output = nn.Linear(d_model, vocab_size)
def forward(self, src_tokens, tgt_tokens):
src_emb = self.embedding(src_tokens)
tgt_emb = self.embedding(tgt_tokens)
# Encoder: process input bidirectionally
memory = self.encoder(src_emb)
# Decoder: generate output with cross-attention to encoder
output = self.decoder(tgt_emb, memory)
return self.output(output)
| Choice | Why |
|---|---|
| Decoder-only | Removes cross-attention (simpler, more compute for same params) |
| Pre-norm (LayerNorm before attention/FFN) | More stable training than post-norm |
| RoPE position encoding | Better length extrapolation |
| GELU/SwiGLU activation | Better than ReLU in FFN |
| RMSNorm instead of LayerNorm | Removes mean-centering, faster |
| Grouped-Query Attention | Fewer KV-cache parameters |
"Masked Language Model": Randomly mask 15% of tokens, predict them from context.
Input: "The [MASK] chased the [MASK]"
Target: "The cat chased the mouse"
Why this matters: Unlike GPT (left-to-right), BERT sees both left and right context. This gives better understanding for tasks like classification, QA, and NER.
BERT's training:
1. Masked LM (80%: mask, 10%: random word, 10%: keep original)
2. Next Sentence Prediction: Is sentence B the actual next sentence after A?
class BERTEmbedding(nn.Module):
def __init__(self, vocab_size, d_model=768, max_len=512):
super().__init__()
self.token_embedding = nn.Embedding(vocab_size, d_model)
self.position_embedding = nn.Embedding(max_len, d_model)
self.segment_embedding = nn.Embedding(2, d_model) # sentence A/B
def forward(self, token_ids, segment_ids):
positions = torch.arange(token_ids.shape[1], device=token_ids.device)
return (self.token_embedding(token_ids) +
self.position_embedding(positions) +
self.segment_embedding(segment_ids))
"Language modeling is all you need." Train a decoder-only transformer to predict the next token. That's it.
What scaled with size:
- Context length: 512 β 2,048 β 8,192 β 128K β 1M
- Training tokens: 4B β 300B β 2T β 15T
- Emergent abilities: ICL, CoT, code, translation
The KV-cache (storing K and V for each token during generation) is the memory bottleneck for inference. GQA reduces it:
| Type | Query Heads | Key/Value Heads | Parameter Saving |
|---|---|---|---|
| MHA (Multi-Head) | H | H (each has own K,V) | 0 |
| GQA | H | G (G < H, shared in groups) | Significant |
| MQA | H | 1 (all share one K,V) | Maximum |
class GroupedQueryAttention(nn.Module):
def __init__(self, d_model, n_heads, n_kv_heads):
super().__init__()
self.n_heads = n_heads # total query heads
self.n_kv_heads = n_kv_heads # key/value heads (n_heads // group_size)
self.d_head = d_model // n_heads
self.W_q = nn.Linear(d_model, n_heads * self.d_head, bias=False)
self.W_k = nn.Linear(d_model, n_kv_heads * self.d_head, bias=False)
self.W_v = nn.Linear(d_model, n_kv_heads * self.d_head, bias=False)
self.W_o = nn.Linear(n_heads * self.d_head, d_model, bias=False)
def forward(self, x):
B, L, _ = x.shape
Q = self.W_q(x).view(B, L, self.n_heads, self.d_head).transpose(1, 2)
K = self.W_k(x).view(B, L, self.n_kv_heads, self.d_head).transpose(1, 2)
V = self.W_v(x).view(B, L, self.n_kv_heads, self.d_head).transpose(1, 2)
# Repeat K, V for each query head in the group
n_groups = self.n_heads // self.n_kv_heads
K = K.repeat_interleave(n_groups, dim=1)
V = V.repeat_interleave(n_groups, dim=1)
# Now all shapes match: (B, n_heads, L, d_head)
scores = Q @ K.transpose(-2, -1) / (self.d_head ** 0.5)
weights = F.softmax(scores, dim=-1)
output = weights @ V
output = output.transpose(1, 2).contiguous().view(B, L, -1)
return self.W_o(output)
def generate_with_kv_cache(model, input_ids, max_new_tokens=100):
"""
Efficient generation using KV-cache.
Instead of recomputing K,V for all previous tokens each step,
we cache them and only compute for the new token.
"""
kv_cache = {}
for i in range(max_new_tokens):
if i == 0:
# First step: process full input sequence
logits, kv_cache = model(input_ids, use_cache=True)
else:
# Subsequent steps: only process the last token
logits, kv_cache = model(input_ids[:, -1:], kv_cache=kv_cache, use_cache=True)
next_token = sample(logits[:, -1, :])
input_ids = torch.cat([input_ids, next_token], dim=-1)
if next_token.item() == tokenizer.eos_token_id:
break
return input_ids
Without KV-cache: O(nΒ²) per step β O(nΒ³) total.
With KV-cache: O(n) per step β O(nΒ²) total. This is what makes LLMs practical.
Standard attention: O(nΒ²) where n = sequence length.
n = 1,000: 1M operations
n = 10,000: 100M operations
n = 100,000: 10B operations
n = 1,000,000: 1T operations β infeasible
The insight: The main bottleneck isn't compute β it's memory bandwidth. The attention matrix [n, n] is too large to fit in fast SRAM.
Flash Attention (Dao et al., 2022) computes attention in blocks without materializing the full matrix:
# Standard attention: writes NΓN matrix to HBM (slow)
S = Q @ K.T # read from HBM
P = softmax(S) # write to HBM
O = P @ V # read from HBM
# Flash attention: process in tiles, stay in SRAM
# Never materialize full NΓN matrix
# Hardware-aware tiling
Speedup: 2-4Γ for long sequences. This is why we can now do 128K+ context.
| Method | Complexity | How |
|---|---|---|
| Flash Attention | O(nΒ²) but 2-4Γ faster | Memory-efficient tiling |
| Sparse Attention | O(n log n) | Only attend to local + few global tokens |
| Linear Attention | O(n) | Replace softmax with kernel feature maps |
| Ring Attention | O(nΒ²) distributed | Distribute across GPUs for 1M+ tokens |
| Sliding Window | O(nk) | Each token attends to k nearest neighbors |
Continuous: $h'(t) = A h(t) + B x(t)$
Output: $y(t) = C h(t)$
Discretized (for computer): $h_t = \bar{A} h_{t-1} + \bar{B} x_t$
Output: $y_t = C h_t$
Where $\bar{A} = \exp(\Delta A)$ and $\bar{B} = (\Delta A)^{-1}(\exp(\Delta A) - I) \cdot \Delta B$.
Recurrent at inference (fast, constant memory like RNN):
$h_t = \bar{A} h_{t-1} + \bar{B} x_t$ β just one step, O(1) per token.
Convolutional at training (parallelizable like CNN):
$y = K * x$ where $K = (C\bar{B}, C\bar{A}\bar{B}, C\bar{A}^2\bar{B}, ...)$ β can compute via FFT, O(n log n).
Previous SSMs (S4, H3, etc.) used fixed $A, B, C$ parameters for all inputs. Mamba makes $B, C, \Delta$ input-dependent:
This means the model can selectively remember or ignore information based on content β just like attention, but with O(n) complexity.
# Simplified Mamba block processing one sequence
def mamba_block(x, dt_linear, A, B_proj, C_proj):
"""
x: (batch, seq_len, d_model)
Returns: (batch, seq_len, d_model)
"""
B, L, D = x.shape
# Input-dependent parameters
delta = F.softplus(dt_linear(x)) # (B, L, D) β step size, per token
B = B_proj(x) # (B, L, D) β input-dependent B
C = C_proj(x) # (B, L, D) β input-dependent C
# Discretize A
A_bar = torch.exp(delta.unsqueeze(-1) * A) # (B, L, D, N)
B_bar = delta * B # simplified
# Scan: h_t = A_bar_t * h_{t-1} + B_bar_t * x_t
h = torch.zeros(B, D, device=x.device)
ys = []
for t in range(L):
h = A_bar[:, t] * h + B_bar[:, t] * x[:, t]
y = (C[:, t] * h).sum(-1)
ys.append(y)
return torch.stack(ys, dim=1)
# In practice: use parallel associative scan (O(n log n) instead of O(n))
Not all data fits in grids or sequences. Social networks, molecules, knowledge graphs β these are graphs.
Each node gathers information from its neighbors:
class GCNLayer(nn.Module):
"""
Graph Convolutional Network layer.
AGGREGATE = mean of neighbors.
"""
def __init__(self, in_dim, out_dim):
super().__init__()
self.W = nn.Linear(in_dim, out_dim)
def forward(self, x, adj_matrix):
"""
x: (num_nodes, in_dim)
adj_matrix: (num_nodes, num_nodes) β normalized adjacency
"""
# Message passing: neighbor features aggregated by adjacency
neighbor_aggregated = adj_matrix @ x
return torch.relu(self.W(neighbor_aggregated))
Key result (Xu et al., 2019):
- Standard message-passing GNNs are at most as powerful as the 1-Weisfeiler-Lehman (WL) test.
- The WL test is a graph isomorphism heuristic: "Are these two graphs the same?"
- GNNs can't distinguish graphs that 1-WL can't distinguish.
GIN (Graph Isomorphism Network) achieves the WL power limit by using sum aggregation:
We want a generative model that creates new data points. But how do we learn the underlying distribution $P(x)$?
VAE solution: Learn a latent space $z$ where $P(z)$ is simple (Gaussian), and a decoder $P(x|z)$ that maps $z$ to data.
class VAE(nn.Module):
def __init__(self, input_dim=784, latent_dim=20):
super().__init__()
# Encoder: x β z (Gaussian parameters)
self.encoder = nn.Sequential(
nn.Linear(input_dim, 256),
nn.ReLU(),
)
self.mu_layer = nn.Linear(256, latent_dim)
self.logvar_layer = nn.Linear(256, latent_dim)
# Decoder: z β x
self.decoder = nn.Sequential(
nn.Linear(latent_dim, 256),
nn.ReLU(),
nn.Linear(256, input_dim),
nn.Sigmoid(),
)
def encode(self, x):
h = self.encoder(x)
return self.mu_layer(h), self.logvar_layer(h)
def reparameterize(self, mu, logvar):
"""Reparameterization trick: z = ΞΌ + Ο * Ξ΅"""
std = torch.exp(0.5 * logvar)
eps = torch.randn_like(std)
return mu + eps * std
def decode(self, z):
return self.decoder(z)
def forward(self, x):
mu, logvar = self.encode(x)
z = self.reparameterize(mu, logvar)
x_recon = self.decode(z)
return x_recon, mu, logvar, z
def vae_loss(x, x_recon, mu, logvar):
"""Reconstruction + KL divergence loss."""
# Reconstruction: binary cross-entropy
recon_loss = F.binary_cross_entropy(x_recon, x, reduction='sum')
# KL divergence: q(z|x) || N(0, I)
# Closed form for Gaussian: -Β½Ξ£(1 + log(ΟΒ²) - ΞΌΒ² - ΟΒ²)
kl_loss = -0.5 * torch.sum(1 + logvar - mu.pow(2) - logvar.exp())
return recon_loss + kl_loss
Why it's needed: During backprop, we need gradients through a random sampling step. The "trick" is to separate the randomness:
Now $\epsilon$ is a fixed noise source β gradients flow through $\mu$ and $\sigma$ normally.
Two networks play a minimax game:
class GAN(nn.Module):
def __init__(self, latent_dim=100):
super().__init__()
self.generator = nn.Sequential(
nn.Linear(latent_dim, 256),
nn.ReLU(),
nn.Linear(256, 512),
nn.ReLU(),
nn.Linear(512, 784),
nn.Tanh(), # images [-1, 1]
)
self.discriminator = nn.Sequential(
nn.Linear(784, 512),
nn.LeakyReLU(0.2),
nn.Linear(512, 256),
nn.LeakyReLU(0.2),
nn.Linear(256, 1),
nn.Sigmoid(),
)
def train_gan_step(gan, real_images, optimizer_G, optimizer_D, latent_dim=100):
batch_size = real_images.size(0)
# --- Train Discriminator ---
optimizer_D.zero_grad()
# Real images: D should output 1
real_pred = gan.discriminator(real_images)
real_loss = F.binary_cross_entropy(real_pred, torch.ones_like(real_pred))
# Fake images: D should output 0
z = torch.randn(batch_size, latent_dim)
fake_images = gan.generator(z)
fake_pred = gan.discriminator(fake_images.detach()) # stop gradient to G
fake_loss = F.binary_cross_entropy(fake_pred, torch.zeros_like(fake_pred))
d_loss = (real_loss + fake_loss) / 2
d_loss.backward()
optimizer_D.step()
# --- Train Generator ---
optimizer_G.zero_grad()
z = torch.randn(batch_size, latent_dim)
fake_images = gan.generator(z)
fake_pred = gan.discriminator(fake_images)
# G wants D to output 1 on its fakes
g_loss = F.binary_cross_entropy(fake_pred, torch.ones_like(fake_pred))
g_loss.backward()
optimizer_G.step()
return d_loss.item(), g_loss.item()
| Aspect | VAE | GAN |
|---|---|---|
| Training stability | β Stable | β Mode collapse, non-convergence |
| Sample quality | β οΈ Blurry | β Sharp |
| Latent space | β Smooth, continuous | β Disconnected |
| Likelihood | β Tractable bound | β None |
| Evaluation | β ELBO | β Inception score only |
Today: GANs are mostly historical. Diffusion models produce better images. VAEs are used for latent compression (Stable Diffusion uses a VAE).
If $z \sim p_z(z)$ and $x = f(z)$ where $f$ is invertible and differentiable:
Key insight: Unlike VAEs (which approximate), flows give exact density estimation.
Split input into two halves:
1. First half passes through unchanged
2. Second half is transformed:
shift = NN1(first_half), scale = NN2(first_half)
second_half_out = (second_half + shift) * exp(scale)
3. Concatenate and alternate which half is transformed
class AffineCoupling(nn.Module):
def __init__(self, input_dim, hidden_dim=256):
super().__init__()
self.net = nn.Sequential(
nn.Linear(input_dim // 2, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, input_dim), # outputs shift + scale
)
def forward(self, x):
x_a, x_b = x.chunk(2, dim=-1)
params = self.net(x_a)
shift, scale = params.chunk(2, dim=-1)
y_b = x_b * torch.exp(scale) + shift
log_det = scale.sum(-1) # log determinant
return torch.cat([x_a, y_b], dim=-1), log_det
def inverse(self, y):
y_a, y_b = y.chunk(2, dim=-1)
params = self.net(y_a)
shift, scale = params.chunk(2, dim=-1)
x_b = (y_b - shift) * torch.exp(-scale)
return torch.cat([y_a, x_b], dim=-1)
Triangular Jacobian: The coupling layer's Jacobian is triangular (since x_a β y_a is identity), so its determinant is just the product of diagonal elements β O(d) computation instead of O(dΒ³).
Instead of directly modeling $P(x)$, define an energy function $E(x)$ and:
Where $Z$ is the partition function (intractable β but we don't compute it directly).
The gradient of the log-likelihood:
First term: Push energy down on real data.
Second term: Push energy up on (sampled) fake data.
We approximate the second term with Langevin Dynamics:
def langevin_sampling(model, x_init, step_size=0.01, n_steps=100):
"""
Sample from an EBM using Langevin MCMC.
"""
x = x_init.clone().requires_grad_(True)
for _ in range(n_steps):
energy = model(x).sum()
grad = torch.autograd.grad(energy, x)[0]
noise = torch.randn_like(x) * np.sqrt(2 * step_size)
x = x - step_size * grad + noise
x = x.detach().requires_grad_(True)
return x
After $t$ steps:
Where $\alpha_t = 1 - \beta_t$ and $\bar{\alpha}t = \prod^t \alpha_s$.
We train $\mu_\theta$ to predict the noise $\epsilon$ that was added:
That's it. The model is trained to predict the added noise.
def train_diffusion_step(model, x0, timestep, noise_schedule):
"""
One training step for DDPM.
"""
# Sample noise
noise = torch.randn_like(x0)
# Forward process: x_t = sqrt(Ξ±Μ_t) * xβ + sqrt(1-Ξ±Μ_t) * noise
sqrt_alpha_bar = noise_schedule['sqrt_alpha_bar'][timestep]
sqrt_one_minus_alpha_bar = noise_schedule['sqrt_one_minus_bar'][timestep]
x_t = sqrt_alpha_bar * x0 + sqrt_one_minus_alpha_bar * noise
# Predict noise
noise_pred = model(x_t, timestep)
# Loss: MSE between true and predicted noise
loss = F.mse_loss(noise_pred, noise)
return loss
def sample_ddpm(model, noise_schedule, num_timesteps=1000, image_shape=(3, 32, 32)):
"""
Sample from a trained DDPM.
"""
# Start from pure noise
x = torch.randn(1, *image_shape)
for t in reversed(range(num_timesteps)):
# Predict noise at step t
noise_pred = model(x, t)
# Compute x_{t-1} mean
alpha = noise_schedule['alpha'][t]
alpha_bar = noise_schedule['alpha_bar'][t]
beta = noise_schedule['beta'][t]
# ΞΌ = 1/βΞ±_t * (x_t - Ξ²_t/β(1-Ξ±Μ_t) * Ξ΅_ΞΈ)
mean = (1 / np.sqrt(alpha)) * (x - (beta / np.sqrt(1 - alpha_bar)) * noise_pred)
# Add noise (except for t = 0)
if t > 0:
noise = torch.randn_like(x)
x = mean + np.sqrt(beta) * noise
else:
x = mean
return x
The noise prediction $\epsilon_\theta(x_t, t)$ is equivalent to estimating the score function β the gradient of the log-probability density:
Score matching (HyvΓ€rinen, 2005): Train a model to estimate $\nabla_x \log p(x)$ without computing $p(x)$. This is exactly what diffusion models do.
The discrete diffusion process is a discretization of a stochastic differential equation (SDE):
Forward: Add noise continuously.
Reverse: Remove noise continuously β solving a reverse-time SDE.
This connects diffusion to:
- Probability flow ODE: A deterministic ODE with the same marginal density β fast sampling
- Score matching: The drift term of the reverse SDE equals the score function
For conditional generation (e.g., text-to-image), CFG balances diversity vs. fidelity:
Diffusion models need many steps (50-1000) because the reverse process is a stochastic differential equation.
Flow Matching: Learn a deterministic ODE that transforms noise to data in one step.
Define a linear interpolation between noise $x_1$ and data $x_0$:
The velocity $v_t = dx_t/dt = x_1 - x_0$ is constant.
Train a model $v_\theta(x_t, t)$ to predict this velocity:
def train_flow_matching_step(model, x0, t):
"""
One training step for flow matching.
"""
# Sample noise
x1 = torch.randn_like(x0)
# Interpolate: x_t = (1-t) * x0 + t * x1
t = t[:, None, None, None] # broadcast over image dims
x_t = (1 - t) * x0 + t * x1
# Target velocity: dx/dt = x1 - x0
velocity_target = x1 - x0
# Predict velocity
velocity_pred = model(x_t, t)
loss = F.mse_loss(velocity_pred, velocity_target)
return loss
def sample_flow_matching(model, num_steps=1, image_shape=(3, 32, 32)):
"""
Sample from flow matching model.
In theory, 1 step works. In practice, 2-4 steps for quality.
"""
x = torch.randn(1, *image_shape)
dt = 1.0 / num_steps
for i in range(num_steps):
t = torch.tensor([i * dt])
velocity = model(x, t)
x = x + velocity * dt # Euler step
return x
| Aspect | Diffusion | Flow Matching |
|---|---|---|
| Sampling steps | 10-1000 | 1-4 |
| Training objective | Predict noise | Predict velocity |
| Deterministic? | With DDIM | Yes (always) |
| Likelihood estimation | Via probability flow ODE | Direct ODE |
| Theoretical grounding | Score matching + SDE | Optimal transport |
| Best architecture | U-Net (2D), DiT (latent) | Same |
class GPTBlock(nn.Module):
"""A single GPT transformer block."""
def __init__(self, d_model=4096, n_heads=32, d_ff=11008):
super().__init__()
self.attention = GroupedQueryAttention(d_model, n_heads, n_kv_heads=8)
self.norm1 = RMSNorm(d_model)
self.feed_forward = nn.Sequential(
nn.Linear(d_model, d_ff, bias=False),
nn.SiLU(), # SwiGLU
nn.Linear(d_ff, d_model, bias=False),
)
self.norm2 = RMSNorm(d_model)
def forward(self, x):
x = x + self.attention(self.norm1(x))
x = x + self.feed_forward(self.norm2(x))
return x
class GPT(nn.Module):
def __init__(self, vocab_size=32000, d_model=4096, n_layers=32, n_heads=32):
super().__init__()
self.embed = nn.Embedding(vocab_size, d_model)
self.blocks = nn.ModuleList([
GPTBlock(d_model, n_heads) for _ in range(n_layers)
])
self.norm = RMSNorm(d_model)
self.lm_head = nn.Linear(d_model, vocab_size, bias=False)
# Tie embedding and lm_head weights
self.lm_head.weight = self.embed.weight
def forward(self, input_ids):
x = self.embed(input_ids)
for block in self.blocks:
x = block(x)
x = self.norm(x)
return self.lm_head(x)
Words β integers (tokens). The most common: Byte-Pair Encoding (BPE).
"hello world" β [15339, 1917] (each token β 0.75 words)
or
"hello world" β ["hel", "lo", " world"] (subword units)
Why tokenization matters:
- Vocabulary size (32K-128K tokens)
- Special tokens: <|begin_of_text|>, <|end_of_text|>, <|padding|>
- A model's tokenizer defines what it can and cannot represent
Stage 1: Pretraining (cost: $10M-$100M)
- Data: 2-15 trillion tokens from the internet, books, code
- Objective: next token prediction
- Result: Base model β knows language, facts, patterns
Stage 2: Supervised Fine-Tuning (SFT) (cost: $10K-$100K)
- Data: 10K-100K human-written Q&A pairs
- Objective: next token prediction on these examples
- Result: Instruction model β follows instructions
Stage 3: RLHF/DPO (cost: $10K-$100K)
- Data: 100K-1M human preference judgments
- Objective: maximize human preference reward
- Result: Aligned model β helpful, harmless, honest
Contrastive Language-Image Pretraining: Learn a shared embedding space for images and text.
def clip_training_step(image_encoder, text_encoder, images, texts, temperature=0.07):
"""
CLIP training: contrastive learning over image-text pairs.
"""
# Encode both modalities
image_emb = image_encoder(images) # (batch, d_embed)
text_emb = text_encoder(texts) # (batch, d_embed)
# Normalize
image_emb = image_emb / image_emb.norm(dim=-1, keepdim=True)
text_emb = text_emb / text_emb.norm(dim=-1, keepdim=True)
# Compute similarity matrix
logits = image_emb @ text_emb.T / temperature # (batch, batch)
# Labels: matching pairs are on the diagonal
labels = torch.arange(len(images), device=images.device)
# Symmetric cross-entropy loss
loss_i = F.cross_entropy(logits, labels) # image β text
loss_t = F.cross_entropy(logits.T, labels) # text β image
return (loss_i + loss_t) / 2
Result: CLIP can do zero-shot classification: "Which text description best matches this image?" without any fine-tuning.
The modern multimodal approach:
class LLaVA(nn.Module):
"""
Visual Language Model (simplified LLaVA architecture):
1. CLIP vision encoder extracts image patches
2. Projection layer maps to LLM embedding space
3. LLM processes concatenated [image_tokens, text_tokens]
"""
def __init__(self, vision_encoder, llm, projection_dim=4096):
super().__init__()
self.vision_encoder = vision_encoder # CLIP ViT-L/14
self.projection = nn.Linear(vision_encoder.embed_dim, projection_dim)
self.llm = llm # any LLM
def forward(self, images, text_input_ids):
# Get image features
with torch.no_grad():
image_features = self.vision_encoder(images).last_hidden_state
image_tokens = self.projection(image_features)
# Get text embeddings
text_emb = self.llm.embed(text_input_ids)
# Concatenate: image tokens first, then text tokens
combined = torch.cat([image_tokens, text_emb], dim=1)
# Process with LLM (generate response)
return self.llm.forward_with_embeddings(combined)
Goal: Learn policy $\pi(a|s)$ that maximizes cumulative reward $\sum_t \gamma^t r_t$.
The standard RL algorithm used in RLHF. Key insight: Don't update the policy too much in one step.
def ppo_clip_loss(log_probs_new, log_probs_old, advantages, epsilon=0.2):
"""
PPO clipped surrogate objective.
log_probs_new: Ο_ΞΈ(a|s)
log_probs_old: Ο_{old}(a|s)
advantages: A(s,a) β how much better was this action than average?
"""
ratio = torch.exp(log_probs_new - log_probs_old)
clipped_ratio = torch.clamp(ratio, 1 - epsilon, 1 + epsilon)
loss = -torch.min(ratio * advantages, clipped_ratio * advantages)
return loss.mean()
DPO (Direct Preference Optimization, Rafailov et al., 2024) eliminates the need for a separate reward model:
Where:
- $y_w$: preferred (winning) response
- $y_l$: non-preferred (losing) response
- $\pi_{ref}$: the model before alignment (frozen)
- $\beta$: how much to prefer the chosen response
Intuition: Increase probability of $y_w$ relative to $y_l$, but don't drift too far from $\pi_{ref}$.
def dpo_loss(policy_logps, ref_logps, win_idx, lose_idx, beta=0.1):
"""
policy_logps: log Ο_ΞΈ(y|x) for each response
ref_logps: log Ο_{ref}(y|x) for each response (frozen)
"""
# Log ratio: log(Ο_ΞΈ(y|x) / Ο_{ref}(y|x))
policy_diff = policy_logps[win_idx] - ref_logps[win_idx]
ref_diff = policy_logps[lose_idx] - ref_logps[lose_idx]
# DPO loss
logits = beta * (policy_diff - ref_diff)
loss = -F.logsigmoid(logits)
return loss.mean()
When you optimize for a metric, the model finds unexpected ways to maximize it without doing the intended task:
Example: CoastRunners boat racing game AI
Goal: maximize score β Result: AI goes in circles hitting repair buoys forever
instead of actually racing. High score, terrible gameplay.
Same thing happens with LLMs:
- Want: "Write a helpful summary"
- Reward model optimized β Model writes "excellent summary" that's actually wrong but uses confident, pleasing language
Instead of expensive human preferences, define a constitution (set of rules) and have the model critique its own outputs:
# Constitutional AI: self-critique + revision
def constitutional_step(model, response, constitution_rules):
"""
1. Model generates response
2. Model critiques its own response based on rules
3. Model revises response based on critique
4. Train on (response β revised_response) pairs
"""
critiques = model.critique(response, constitution_rules)
revised = model.revise(response, critiques)
return revised
# Example constitution rules:
# - "Be helpful, harmless, and honest"
# - "Do not engage in harmful stereotypes"
# - "Acknowledge uncertainty when unsure"
Post-alignment, models often:
- Refuse too many requests (false rejections)
- Become less creative
- Lose some capabilities (e.g., code generation quality drops)
The challenge: minimize the alignment tax while maximizing safety.
Test loss decreases as a power law with each of:
| Factor | Notation | Exponent | Implication |
|---|---|---|---|
| Model size | $N$ | $-\alpha_N \approx -0.076$ | 10Γ params β 5.7Γ better loss |
| Data size | $D$ | $-\alpha_D \approx -0.095$ | 10Γ data β 7.1Γ better loss |
| Compute | $C$ | $-\alpha_C \approx -0.050$ | 10Γ compute β 3.2Γ better loss |
Overshadowing: If you scale only one factor, you get diminishing returns. The optimal is to scale ALL three together: $C \propto N^{2.4} D^{2.4}$.
Kaplan said: More parameters > more data.
Chinchilla said: Scale both equally. Doubling parameters requires doubling data.
For compute-optimal training:
The 70B Chinchilla model achieved the same loss as GPT-3 (175B) with 60% less compute.
| If You Want To... | Then... |
|---|---|
| Double model size | Double training data (Chinchilla) |
| Improve test loss 10% | Need 24Γ more compute (Kaplan) |
| Train optimally | NβC^0.5, DβC^0.5 |
| Predict loss | L(N, D) = A/N^Ξ± + B/D^Ξ² + Eβ |
Wei et al. (2022): As models scale past a threshold, new abilities "emerge" (suddenly appear).
- Example: GPT-3 (175B) can do few-shot math, smaller models cannot
Schaeffer et al. (2023): "Emergence" is a measurement artifact β due to discontinuous evaluation metrics (multiple-choice, accuracy). With continuous metrics (probability, perplexity), abilities improve smoothly.
Current consensus: Both are partially right. Some abilities are genuinely new (chains-of-thought reasoning appears around 10B params), but many "emergent" abilities are measurement artifacts.
Every token is routed to a subset of "experts" (FFN sub-networks):
class MoELayer(nn.Module):
def __init__(self, d_model, n_experts=8, top_k=2):
super().__init__()
self.n_experts = n_experts
self.top_k = top_k
self.experts = nn.ModuleList([
nn.Sequential(
nn.Linear(d_model, d_model * 4),
nn.GELU(),
nn.Linear(d_model * 4, d_model),
) for _ in range(n_experts)
])
# Router
self.router = nn.Linear(d_model, n_experts, bias=False)
def forward(self, x):
B, L, D = x.shape
x_flat = x.view(-1, D) # (B*L, D)
# Routing
routing_logits = self.router(x_flat) # (B*L, n_experts)
routing_weights = F.softmax(routing_logits, dim=-1)
# Top-k routing
top_k_weights, top_k_indices = torch.topk(routing_weights, self.top_k, dim=-1)
top_k_weights = top_k_weights / top_k_weights.sum(dim=-1, keepdim=True)
# Compute expert outputs
output = torch.zeros_like(x_flat)
for k in range(self.top_k):
expert_idx = top_k_indices[:, k]
weight = top_k_weights[:, k, None]
# Process tokens assigned to each expert
for e in range(self.n_experts):
mask = (expert_idx == e)
if mask.any():
output[mask] += weight[mask] * self.experts[e](x_flat[mask])
return output.view(B, L, D)
Without intervention, the router sends all tokens to 1-2 experts. Solutions:
def load_balancing_loss(routing_logits, n_experts, n_tokens):
"""
Encourage uniform expert utilization.
"""
routing_probs = F.softmax(routing_logits, dim=-1)
# Fraction of tokens sent to each expert
fraction_selected = routing_probs.mean(dim=0)
# Uniform target
target = torch.ones_like(fraction_selected) / n_experts
# CV loss: coefficient of variation of load
load = routing_probs > 0
load_frac = load.float().mean(dim=0)
cv = load_frac.std() / load_frac.mean()
loss = cv * n_tokens # scaled by batch size
return loss * 0.01 # small coefficient
A single GPU can hold maybe 30B parameters (in FP16). Training a 70B+ model requires distributing across many GPUs.
Each GPU has a full copy of the model, processes different batches:
# Pseudocode for data-parallel training
for batch in dataloader:
# Each GPU processes its own batch
losses = parallel_call(model_forward, batch)
# Average gradients across all GPUs
sync_gradients(all_gpus=True)
# Each GPU updates its local copy identically
optimizer.step()
Problem: Each GPU needs the full model. 70B model = 140GB (FP16) β doesn't fit on one GPU.
The modern standard: model parameters are sharded across GPUs, and parameters are gathered only when needed (during forward/backward).
Advantage: Memory scales with 1/num_gpus
Trade-off: Communication overhead
Model (PyTorch)
β
FSDP / Tensor Parallelism (distributed training)
β
Deepspeed / Megatron-LM (optimization library)
β
NCCL (GPU communication)
β
NVIDIA GPUs (A100 / H100 / B200)
Understand what individual components of a neural network compute, and how they combine to produce behavior.
The puzzle: Neural networks have fewer neurons than concepts they represent. How?
Answer: Superposition β each neuron represents multiple concepts, using the "reuse" property of high-dimensional spaces.
# Toy superposition: 10 features in 5 neurons
# Each neuron β a combination of several features
# No neuron = a single "cat" or "dog" concept
# Instead: every neuron is a mixture
# This makes interpretability hard:
# You cannot understand a neuron in isolation.
# You need to find directions in activation space, not individual neurons.
SAEs decompose activations into interpretable features:
class SparseAutoencoder(nn.Module):
def __init__(self, activation_dim, feature_dim):
super().__init__()
self.encoder = nn.Linear(activation_dim, feature_dim, bias=False)
self.decoder = nn.Linear(feature_dim, activation_dim, bias=False)
# Constrain decoder directions to unit norm
self.decoder.weight.data = F.normalize(self.decoder.weight.data, dim=0)
def forward(self, activations):
# Encode with ReLU (sparsity!)
features = F.relu(self.encoder(activations))
# Decode back
reconstruction = self.decoder(features)
return reconstruction, features
def loss(self, x, reconstruction, features, lambda_l1=1e-3):
recon_loss = F.mse_loss(reconstruction, x)
sparsity_loss = lambda_l1 * features.sum(-1).mean()
return recon_loss + sparsity_loss
Anthropic (2024) β "Scaling Monosemanticity": Applied SAEs to Claude 3 Sonnet. Found millions of features:
- People: "Al Capone", "Leonardo DiCaprio"
- Concepts: "super bowl", "capital punishment"
- Abstract: "the concept of 'the' as a definite article"
- Safety-relevant: features for deception, sycophancy, refusal
The gold standard for causal interpretation:
def activation_patching(model, clean_input, corrupted_input, layer_idx, neuron_idx):
"""
Does changing one neuron's activation causally change the output?
Setup:
1. Run clean_input β get base output
2. Run corrupted_input β get corrupted output
3. Run corrupted_input, but patch in clean_input's activations
at (layer_idx, neuron_idx)
4. If output changes to match clean β that neuron matters
"""
clean_cache = run_with_cache(model, clean_input)
corrupted_cache = run_with_cache(model, corrupted_input)
# Run corrupted, but with clean activations at specific position
def patching_hook(activations):
activations[:, neuron_idx] = clean_cache[layer_idx][:, neuron_idx]
return activations
patched_output = run_with_hooks(model, corrupted_input, [(layer_idx, patching_hook)])
patching_effect = (corrupted_output - patched_output) / (corrupted_output - clean_output)
return patching_effect
Jacot et al. (2018): As a neural network's width β β, its training dynamics become equivalent to kernel regression under the Neural Tangent Kernel (NTK):
Where $K$ is a fixed kernel determined by the architecture.
During training with gradient descent, the model function evolves as:
Where $\Theta_t$ is the NTK. For infinite width, $\Theta_t$ stays constant during training.
| Property | NTK Regime (Lazy) | Rich/Feature Learning |
|---|---|---|
| Width | Very large | Moderate |
| Features during training | Fixed | Change and adapt |
| Effective model | Kernel machine | Full deep learning |
| When it works | Theory | Practice |
| Hyperparameter transfer | Poor (needs retuning) | Good (ΞΌP enables transfer) |
Key insight: The models that work in practice are not in the NTK regime. They learn features. But understanding NTK gives theoretical grounding for why optimization works at all.
Yang & Hu (2021): How to ensure feature learning at any width. The key: scale the learning rate and initialization correctly with width.
Without ΞΌP: as width β β, training degenerates into the NTK regime.
With ΞΌP: training dynamics remain in the feature-learning regime at any width.
The fundamental problem: Most ML today operates at Level 1 (correlation). But decision-making requires Level 2+ (causation).
SchΓΆlkopf et al. (2021): We need representations that separate causal factors from confounders.
Example: An image classifier learns that "sheep in fields" = "sheep" because most training images have sheep in fields. A causal model would understand that the field is a context (not a cause of "sheepness").
The Independent Causal Mechanisms (ICM) principle:
The causal generative process factorizes. Each mechanism $P(x_i | \text{parents}(x_i))$ is independent β changing one doesn't affect the others.
This is why ML fails at distribution shift: When the test distribution differs from training, the correlations change. Causal models, by modeling mechanisms, are robust to this.
Bronstein et al. (2021): All successful deep learning architectures exploit the symmetries of their data domain.
Equivariance: $f(T_g x) = T'_g f(x)$
Translation equivariance (CNNs): If you shift the image left, the cat detector's features shift left by the same amount.
Rotation equivariance (Spherical CNNs): If you rotate the 3D object, the features rotate accordingly.
Why this matters: Equivariance reduces the burden on the model. Instead of learning the same pattern in every position/rotation, the architecture guarantees it automatically. This means:
- More data-efficient
- Better generalization
- Fewer parameters
A group $G$ is a set of transformations with composition:
- Translation group: $\mathbb{R}^n$ with addition
- Rotation group: $SO(3)$ with matrix multiplication
- Permutation group: $S_n$ with composition
The stabilizer of a point: transformations that leave that point unchanged.
A feature map is a function over data that transforms under group actions. The convolution is defined using group integration:
Where $dh$ is the Haar measure on $G$. For translation groups, this is the standard convolution. For other groups, it generalizes.
Yann LeCun's argument: Current approaches (predicting tokens or pixels) are fundamentally wrong. True intelligence requires:
JEPA doesn't predict pixels. It predicts representations of future/occluded input.
The predictor operates in representation space β it captures what matters while ignoring irrelevant details (pixel-level noise).
Instead of a generative decoder (which wastes compute on pixels), JEPA uses an energy function:
Rao & Ballard (1999): The brain's cortex implements hierarchical predictive coding:
Connection to modern AI: This is remarkably similar to how neural networks learn β forward pass = prediction, backward pass = error correction.
The biological challenge: Backpropagation requires symmetric weights (the same connection both forward and backward must have the same strength). The brain doesn't have this.
Proposed solutions:
- Feedback alignment: Random feedback weights work surprisingly well (Lillicrap et al., 2016)
- Predictive coding networks: Can approximate backprop with local learning rules (Whittington & Bogacz, 2017)
- Weight mirror: Feedback weights slowly align with feedforward weights
Friston (2010): Any self-organizing system (including the brain) minimizes variational free energy:
Free energy = negative log evidence + complexity cost
Active inference: The agent minimizes free energy by:
1. Updating beliefs to match observations (perception)
2. Acting to make observations match beliefs (action)
This unifies perception, learning, and decision-making under one principle.
Definition: How do we build AI systems that reliably do what humans mean β not just what they're literally told?
Bostrom (2014): Intelligence and final goals are orthogonal. A highly intelligent system can have any goal, including ones humans wouldn't want.
Hubinger et al. (2019): During training, the model may develop its own internal optimizer (a "mesa-optimizer") that pursues different goals than the training objective.
When optimizing for a metric, AIs find unexpected ways to maximize it:
"Maximize score" β AI discovers bug to get infinite score
"Sort blocks of code" β AI deletes blocks instead of sorting them
"Create no offensive content" β AI says "I'm harmless" to anything
Key insight: The failure isn't malice. It's that specifications are incomplete.
Both must be solved. We've made progress on outer alignment (RLHF, DPO). Inner alignment remains open.
Problem: We may run out of high-quality training text by 2027-2028 (Villalobos et al., 2022).
Solutions:
1. Synthetic data: AI-generated data to supplement real data. Risk: model collapse (Shumailov et al., 2023) β models trained on model outputs degrade.
2. Data efficiency: Better architectures that learn from fewer examples.
3. Unused data modalities: Video, audio, 3D, sensor data (vastly more than text).
4. Active learning: The model chooses what data to get labeled.
Current LLMs are pattern matchers, not reasoners. Despite chain-of-thought, they:
- Make arithmetic errors (7+8 = ?)
- Fail at composition (if A>B and B>C, then A>C?)
- Are brittle β rephrasing the question changes answers
Test-time compute scaling (Snell et al., 2024): Like o1/o3, models that "think longer" get better. But this is brute force, not genuine reasoning.
"What is intelligence β mathematically, computationally, and physically?"
This question connects everything:
- Mathematically: PAC learning, VC dimension, NTK, information theory
- Computationally: Transformers, GNNs, SSMs, scaling laws
- Physically/Embodied: World models, robotics, free energy principle
- Biologically: Predictive coding, credit assignment, Hebbian learning
The answer likely involves all three perspectives, integrated.
Concept Notation NumPy
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Vector x, x β ββΏ np.array([1, 2, 3])
Matrix A, A β βα΅Λ£βΏ np.array([[1, 2], [3, 4]])
Tensor X β βα΅Λ£α΅Λ£αΆ torch.randn(a, b, c)
Transpose Aα΅ A.T
Dot product a Β· b = Ξ£ aα΅’bα΅’ np.dot(a, b)
Matrix multiply A @ B A @ B
Outer product a β b = abα΅ np.outer(a, b)
Hadamard (element) A β B A * B
Frobenius norm ||A||_F = βΣΣ Aα΅’β±ΌΒ² np.linalg.norm(A)
L2 norm ||x||β = βΞ£ xα΅’Β² np.linalg.norm(x)
Singular value decomp. A = UΞ£Vα΅ np.linalg.svd(A)
Eigenvalue decomposition A = QΞQβ»ΒΉ np.linalg.eig(A)
Derivative rules:
d/dx (xβΏ) = nxβΏβ»ΒΉ
d/dx (eΛ£) = eΛ£
d/dx (ln x) = 1/x
d/dx (Ο(x)) = Ο(x)(1-Ο(x)) [sigmoid]
d/dx (ReLU(x)) = 1 if x>0 else 0
d/dx (tanh(x)) = 1-tanhΒ²(x)
Chain rule: df/dx = df/dg Β· dg/dx
Partial derivative: βf/βxα΅’ (differentiate w.r.t. one variable)
Gradient: βf = [βf/βxβ, βf/βxβ, ..., βf/βxβ]
Vector calculus:
β/βx (aΒ·x) = a
β/βx (xα΅Ax) = (A + Aα΅)x
β/βX (aα΅Xb) = abα΅
Distributions (heuristic):
βΒ²f: Hessian matrix, condition number = Ξ»_max/Ξ»_min
Gaussian PDF: p(x) = 1/β(2ΟΟΒ²) exp(-(x-ΞΌ)Β²/(2ΟΒ²))
Multivariate: p(x) = 1/((2Ο)^(d/2)|Ξ£|ΒΉ/Β²) exp(-Β½(x-ΞΌ)α΅Ξ£β»ΒΉ(x-ΞΌ))
Bayes rule: P(ΞΈ|D) = P(D|ΞΈ)P(ΞΈ)/P(D)
Law of total prob: P(A) = Ξ£ P(A|Bα΅’)P(Bα΅’)
Distances:
KL(P||Q) = Ξ£ P(x) log(P(x)/Q(x))
JS(P||Q) = Β½KL(P||M) + Β½KL(Q||M), M = (P+Q)/2
Wasserstein: W_p(P,Q) = (inf_{Ξ³βΞ (P,Q)} E[||x-y||_p])^(1/p)
Information:
Entropy: H(X) = -Ξ£ p(x) log p(x)
Cross-ent: H(P,Q) = -Ξ£ p(x) log q(x)
Mutual info: I(X;Y) = KL(P(X,Y)||P(X)P(Y))
Gradient descent: ΞΈ_{t+1} = ΞΈ_t - Ξ· βL(ΞΈ_t)
SGD: ΞΈ_{t+1} = ΞΈ_t - Ξ· βL_batch(ΞΈ_t)
Momentum: v_{t+1} = Ξ³v_t + Ξ·βL(ΞΈ_t)
ΞΈ_{t+1} = ΞΈ_t - v_{t+1}
Adam: m_t = Ξ²βm_{t-1} + (1-Ξ²β)g_t
v_t = Ξ²βv_{t-1} + (1-Ξ²β)g_tΒ²
ΞΈ_{t+1} = ΞΈ_t - Ξ· mΜ_t/(βvΜ_t + Ξ΅)
Common learning rates:
Adam/AdamW: 1e-4 to 1e-3
SGD: 1e-3 to 1e-1
Fine-tuning: 1e-5 to 1e-4
1. Linear Regression (click to expand)
Input: X (n_samples Γ n_features), y (n_samples,)
Output: w (weights), b (bias)
Closed form:
X_aug = [1, X] # add bias column
w = (X_augα΅ Γ X_aug)β»ΒΉ Γ X_augα΅ Γ y
Gradient descent:
w = zeros(n_features)
b = 0
for epoch in 1..epochs:
y_pred = X Γ w + b
error = y_pred - y
dw = (2/n) Γ Xα΅ Γ error
db = (2/n) Γ sum(error)
w = w - lr Γ dw
b = b - lr Γ db
2. Logistic Regression
Input: X, y β {0, 1}
Output: w, b
w = zeros(n_features)
b = 0
for epoch in 1..epochs:
z = X Γ w + b
y_pred = 1 / (1 + exp(-z)) # sigmoid
error = y_pred - y
dw = (1/n) Γ Xα΅ Γ error
db = (1/n) Γ sum(error)
w = w - lr Γ dw
b = b - lr Γ db
3. Softmax Regression
Input: X, y β {0, ..., K-1}
Output: W (K Γ D), b (K,)
W = zeros(K, D)
b = zeros(K)
for epoch in 1..epochs:
logits = X Γ Wα΅ + b # (N, K)
exp_logits = exp(logits - max(logits, axis=1))
probs = exp_logits / sum(exp_logits, axis=1)
loss = -mean(log(probs[y_one_hot]))
grad = (probs - y_one_hot) / N
W = W - lr Γ gradα΅ Γ X
b = b - lr Γ mean(grad, axis=0)
4. SVM (Primal)
Input: X, y β {-1, +1}
Output: w, b
w = zeros(D)
b = 0
C = 1.0 # regularization
for epoch in 1..epochs:
for i in 1..N:
margin = y[i] Γ (wΒ·X[i] + b)
if margin β₯ 1: # correct, non-support
dw = w / N # only regularization
db = 0
else: # inside margin or misclassified
dw = w / N - C Γ y[i] Γ X[i]
db = -C Γ y[i]
w = w - lr Γ dw
b = b - lr Γ db
5. K-Means Clustering
Input: X (N Γ D), K
Output: labels (N,), centroids (K Γ D)
centroids = random_sample(X, K) # or k-means++
for iteration in 1..max_iters:
labels = argmin(||X - centroids||Β², axis=1)
for k in 1..K:
centroids[k] = mean(X[labels==k], axis=0)
if converged: break
6. K-Nearest Neighbors
Input: X_train, y_train, X_test, K
Output: y_pred
for each x_test in X_test:
distances = ||X_train - x_test||Β²
nearest = argsort(distances)[:K]
y_pred[x_test] = mode(y_train[nearest])
7. Decision Tree (ID3/CART)
function build_tree(X, y, depth):
if depth β₯ max_depth or all(y same):
return leaf(mode(y))
best_feature, best_threshold = None, None
best_gain = -inf
for each feature f:
thresholds = unique_sorted(X[:, f])
for each threshold t:
left = y[X[:, f] β€ t]
right = y[X[:, f] > t]
gain = H(y) - (|left|/|y|)H(left) - (|right|/|y|)H(right)
if gain > best_gain:
best_gain, best_feature, best_threshold = gain, f, t
left_tree = build_tree(X[X[:, f] β€ t], y[mask_left], depth+1)
right_tree = build_tree(X[X[:, f] > t], y[~mask_left], depth+1)
return node(best_feature, best_threshold, left_tree, right_tree)
8. Random Forest
Input: X, y, n_trees, max_features, max_depth
Output: forest (list of trees)
forest = []
for i in 1..n_trees:
bootstrap_idx = sample(N, N, replace=True)
X_boot = X[bootstrap_idx]
y_boot = y[bootstrap_idx]
feature_subset = sample(D, max_features, replace=False)
X_subset = X_boot[:, feature_subset]
tree = build_tree(X_subset, y_boot, max_depth)
forest.append((tree, feature_subset))
def predict(X):
votes = zeros(X.shape[0], n_classes)
for tree, features in forest:
votes += tree.predict(X[:, features])
return argmax(votes)
9. Gradient Boosting
Input: X, y, n_estimators, lr, max_depth
Output: trees
F = mean(y) Γ ones(N)
trees = []
for m in 1..n_estimators:
residuals = -βL(y, F) # e.g., y - F for MSE
tree = build_tree(X, residuals, max_depth)
F = F + lr Γ tree.predict(X)
trees.append(tree)
def predict(X):
return mean(y) + lr Γ sum(tree.predict(X) for tree in trees)
10. PCA
Input: X (N Γ D), n_components
Output: X_reduced (N Γ k), components (D Γ k)
X_centered = X - mean(X, axis=0)
cov = X_centeredα΅ Γ X_centered / (N-1)
eigenvalues, eigenvectors = eig(cov)
idx = argsort(eigenvalues, descending)
components = eigenvectors[:, idx[:k]]
X_reduced = X_centered Γ components
11. t-SNE
Input: X (N Γ D), perplexity=30, lr=200
Output: Y (N Γ 2)
# Compute pairwise similarities in input space
P = compute_conditional_probs(X, perplexity)
# Initialize low-dimensional points (random)
Y = randn(N, 2)
for step in 1..steps:
# Compute pairwise similarities in embedding space
Q = 1 / (1 + ||Y - Yβ±Ό||Β²) # Cauchy kernel
Q = Q / sum(Q)
# Gradient
grad = 4 Γ Ξ£ (P - Q)(Y - Yβ±Ό)(1 + ||Y - Yβ±Ό||Β²)β»ΒΉ
Y = Y - lr Γ grad + momentum Γ velocity
12. Feed-Forward Neural Network
Input: X (N Γ D_in), y, hidden_sizes, activation
Output: trained parameters W_l, b_l for each layer
Initialize all W_l (He/Glorot init), b_l = 0
for epoch in 1..epochs:
# Forward pass
h = X
for l in 1..L:
h = Ο(h Γ W_l + b_l)
# Backward pass
Ξ΄ = (h - y) / N # output gradient
for l in reversed(1..L):
dW_l = a_{l-1}α΅ Γ Ξ΄
db_l = sum(Ξ΄, axis=0)
Ξ΄ = Ξ΄ Γ W_lα΅ Γ Ο'(z_{l-1})
W_l = W_l - lr Γ dW_l
b_l = b_l - lr Γ db_l
13. CNN Forward Pass
Input: image (H Γ W Γ C_in), kernel (K Γ K Γ C_in Γ C_out)
Output: feature_map (H_out Γ W_out Γ C_out)
H_out = (H + 2P - K) / S + 1
W_out = (W + 2P - K) / S + 1
if P > 0: image = zero_pad(image, P)
for h in 0..H_out-1:
for w in 0..W_out-1:
region = image[h*S:h*S+K, w*S:w*S+K, :]
for c in 0..C_out-1:
feature_map[h, w, c] = sum(region Γ kernel[:, :, :, c])
# After conv: activation (ReLU), then pooling
# MaxPool: feature_map_out[i,j] = max(region_i,j)
14. LSTM
Input: x_t, h_{t-1}, c_{t-1}, parameters W, b
Output: h_t, c_t
combined = [x_t, h_{t-1}] # concatenate
gates = combined Γ W + b # all 4 gates at once
f = Ο(gates[:h_dim]) # forget
i = Ο(gates[h_dim:2h_dim]) # input
cΜ = tanh(gates[2h_dim:3h_dim]) # candidate
o = Ο(gates[3h_dim:]) # output
c_t = f β c_{t-1} + i β cΜ # cell state update
h_t = o β tanh(c_t) # hidden state
15. Scaled Dot-Product Attention
Input: Q (B Γ L_q Γ d), K (B Γ L_k Γ d), V (B Γ L_v Γ d)
Output: output (B Γ L_q Γ d_v), weights (B Γ L_q Γ L_k)
scores = Q Γ Kα΅ / βd # (B, L_q, L_k)
if mask: scores = scores + mask
weights = softmax(scores, dim=-1)
output = weights Γ V
16. Multi-Head Attention
Input: x (B Γ L Γ D), n_heads, d_head = D // n_heads
Q = x Γ W_Q β reshape(B, L, n_heads, d_head) β transpose(1, 2)
K = x Γ W_K β reshape(B, L, n_heads, d_head) β transpose(1, 2)
V = x Γ W_V β reshape(B, L, n_heads, d_head) β transpose(1, 2)
# Each head does attention independently
scores = Q Γ Kα΅ / βd_head # (B, n_heads, L, L)
weights = softmax(scores, dim=-1)
head_output = weights Γ V # (B, n_heads, L, d_head)
# Concatenate heads
concat = head_output.transpose(1,2).reshape(B, L, D)
output = concat Γ W_O
17. Transformer Block
Input: x (B Γ L Γ D)
Output: x (B Γ L Γ D)
# Pre-norm architecture (modern)
x = x + MultiHeadAttention(LayerNorm(x))
x = x + FFN(LayerNorm(x))
where:
FFN(x) = Wβ Γ GELU(Wβ Γ x + bβ) + bβ
or SwiGLU: (Wβ Γ x) β sigmoid(Wβ Γ x) Γ Wβ
18. GPT Generation (Autoregressive)
Input: prompt_tokens, max_new_tokens, temperature
Output: generated_tokens
input_ids = prompt_tokens
kv_cache = {} # for efficiency
for step in 1..max_new_tokens:
if kv_cache is empty:
logits, kv_cache = model(input_ids, use_cache=True)
else:
logits, kv_cache = model(input_ids[-1:], cache=kv_cache, use_cache=True)
next_logits = logits[-1] / temperature
probs = softmax(next_logits)
next_token = sample(probs) # greedy, top-k, or top-p
if next_token == EOS: break
input_ids = concat(input_ids, next_token)
19. VAE (Variational Autoencoder)
Input: x, encoder NN, decoder NN
Output: x_recon, mu, logvar, z
# Encoder
mu, logvar = encoder(x)
std = exp(0.5 Γ logvar)
# Reparameterization trick
Ξ΅ = randn_like(std)
z = mu + Ξ΅ Γ std
# Decoder
x_recon = decoder(z)
# Loss
recon_loss = BCE(x_recon, x) # or MSE
kl_loss = -0.5 Γ Ξ£(1 + logvar - muΒ² - exp(logvar))
loss = recon_loss + kl_loss
20. DDPM (Denoising Diffusion)
# Training:
xβ βΌ data, Ο΅ βΌ N(0,I), t βΌ Uniform(1,T)
x_t = βΞ±Μ_t Γ xβ + β(1-Ξ±Μ_t) Γ Ο΅
Ο΅Μ = Ξ΅_ΞΈ(x_t, t)
loss = MSE(Ο΅Μ, Ο΅)
# Sampling:
x_T βΌ N(0, I)
for t = T..1:
Ο΅Μ = Ξ΅_ΞΈ(x_t, t)
x_{t-1} = 1/βΞ±_t Γ (x_t - Ξ²_t/β(1-Ξ±Μ_t) Γ Ο΅Μ)
if t > 1: x_{t-1} += βΞ²_t Γ z, z βΌ N(0,I)
21. Flow Matching
# Training:
xβ βΌ data, xβ βΌ N(0,I), t βΌ Uniform(0,1)
x_t = (1-t) Γ xβ + t Γ xβ
v_target = xβ - xβ
v_pred = v_ΞΈ(x_t, t)
loss = MSE(v_pred, v_target)
# Sampling:
x βΌ N(0, I)
dt = 1/N_steps
for step in 1..N_steps:
t = (step-1) / N_steps
v = v_ΞΈ(x, t)
x = x + v Γ dt # Euler step
22. PPO (Proximal Policy Optimization)
Input: policy Ο_ΞΈ, value function V_Ο, rollouts
Output: updated Ο_ΞΈ
# Collect trajectories using Ο_ΞΈ
for step in 1..buffer_size:
s = env.state
a βΌ Ο_ΞΈ(a|s)
r, s' = env.step(a)
store (s, a, r, s')
# Compute advantages
for each sample:
A = discounted_sum(r) - V_Ο(s) # advantage
# Update policy (several epochs)
for epoch in 1..n_epochs:
ratio = Ο_ΞΈ(a|s) / Ο_old(a|s)
clipped_ratio = clamp(ratio, 1-Ξ΅, 1+Ξ΅)
policy_loss = -min(ratio Γ A, clipped_ratio Γ A)
value_loss = MSE(V_Ο(s), discounted_sum(r))
ΞΈ = ΞΈ - lr Γ β(policy_loss - entropy_bonus)
Ο = Ο - lr Γ β(value_loss)
23. DPO (Direct Preference Optimization)
Input: Ο_ref (frozen), chosen responses y_w, rejected y_l
Output: updated Ο_ΞΈ
for batch in dataloader:
log_pi_w = log Ο_ΞΈ(y_w|x) # current policy on chosen
log_pi_l = log Ο_ΞΈ(y_l|x) # current policy on rejected
log_ref_w = log Ο_ref(y_w|x) # reference on chosen
log_ref_l = log Ο_ref(y_l|x) # reference on rejected
# Log ratio: log(Ο_ΞΈ/Ο_ref)
ratio_diff = (log_pi_w - log_ref_w) - (log_pi_l - log_ref_l)
loss = -log Ο(Ξ² Γ ratio_diff) # DPO loss
ΞΈ = ΞΈ - lr Γ βloss
24. K-Means++ Initialization
Input: X (N Γ D), K
Output: initial centroids (K Γ D)
centroids[0] = X[random_index]
for k in 1..K-1:
# Distance to nearest existing centroid
D[i] = min_j ||X[i] - centroids[j]||Β²
# Probability proportional to DΒ²
P[i] = D[i] / sum(D)
centroids[k] = X[sample(P)]
25. DBSCAN Clustering
Input: X (N Γ D), epsilon, minPts
Output: labels (N,)
labels = [-1] Γ N # -1 = noise/undefined
cluster_id = 0
for each point P not visited:
visited[P] = True
neighbors = points_within(epsilon, P)
if len(neighbors) < minPts:
labels[P] = -1 # noise
else:
expand_cluster(P, neighbors, cluster_id)
cluster_id += 1
function expand_cluster(P, neighbors, cluster_id):
labels[P] = cluster_id
queue = neighbors
while queue is not empty:
Q = queue.pop()
if not visited[Q]:
visited[Q] = True
Q_neighbors = points_within(epsilon, Q)
if len(Q_neighbors) β₯ minPts:
queue.extend(Q_neighbors)
if labels[Q] == -1:
labels[Q] = cluster_id
26. Bagging (Bootstrap Aggregating)
Input: X, y, base_learner, n_models
Output: ensemble
models = []
for i in 1..n_models:
indices = sample(N, N, replace=True) # bootstrap
X_boot, y_boot = X[indices], y[indices]
model = base_learner.fit(X_boot, y_boot)
models.append(model)
def predict(X):
predictions = [model.predict(X) for model in models]
return mean(predictions) # for regression
return mode(predictions) # for classification
27. AdaBoost
Input: X, y β {-1, +1}, base_learner, T
Output: weighted ensemble
w = ones(N) / N # initial weights
models = []
alphas = []
for t in 1..T:
model = base_learner.fit(X, y, sample_weight=w)
error = sum(w Γ (model.predict(X) β y)) / sum(w)
if error β₯ 0.5: break
alpha = 0.5 Γ log((1-error) / error)
w = w Γ exp(-alpha Γ y Γ model.predict(X))
w = w / sum(w) # normalize
models.append(model)
alphas.append(alpha)
def predict(X):
return sign(Ξ£ Ξ±α΅’ Γ modelα΅’.predict(X))
28-50. Additional Algorithms
Algorithms 28-50 include: ElasticNet, Ridge/Lasso regression, Polynomial regression, Gaussian Naive Bayes,
Quadratic Discriminant Analysis, SVD-based Recommendation, Matrix Factorization, Word2Vec (Skip-gram),
GloVe embeddings, NMF (Non-negative Matrix Factorization), UMAP,
GRU, Seq2Seq with Attention, Beam Search decoding, BLEU score calculation,
Gradient Clipping, Scheduled Sampling, Label Smoothing,
Warmup + Cosine LR Schedule, Focal Loss, and Contrastive Learning (SimCLR).
AGI (Artificial General Intelligence)
- AI that can perform any intellectual task a human can.
Attention
- Mechanism enabling models to weigh the importance of different input parts.
Autoregressive
- Model that predicts the next token given all previous tokens.
Backpropagation
- Algorithm for computing gradients through neural networks using the chain rule.
Bias (in bias-variance tradeoff)
- Error from incorrect assumptions in the learning algorithm.
Bias (neuron)
- A learnable offset parameter in a neural network layer.
Causal Masking
- Preventing attention to future tokens in autoregressive models.
Chain-of-Thought
- Prompting technique: "let's think step by step" for better reasoning.
Chinchilla
- Hoffmann et al. 2022: optimal scaling requires scaling data with model size.
CLIP
- Contrastive Language-Image Pretraining: joint vision-language embeddings.
Condition Number
- Ratio of largest to smallest Hessian eigenvalue; measures optimization difficulty.
Cross-Entropy
- Loss function for classification: -Ξ£ p(x) log q(x).
DDPM
- Denoising Diffusion Probabilistic Model: generates data by reversing noise addition.
Double Descent
- Phenomenon where test error decreases again in the overparameterized regime.
DPO (Direct Preference Optimization)
- Alignment method that directly optimizes from preferences without a reward model.
Dropout
- Regularization: randomly zeroing out a fraction of neurons during training.
ELBO (Evidence Lower BOund)
- Tractable lower bound on log-likelihood, used in VAEs and diffusion models.
Emergent Abilities
- Capabilities that appear in large models but not in smaller ones.
Entropy
- Measure of uncertainty: H(X) = -Ξ£ p(x) log p(x).
Equivariance
- Property: f(Tx) = T'f(x) β transforming input transforms output predictably.
Fine-Tuning
- Taking a pretrained model and training it further on a specific task.
Flash Attention
- Memory-efficient attention algorithm that avoids materializing the full NΓN matrix.
Flow Matching
- Generative modeling by learning a deterministic ODE between noise and data.
Foundation Model
- Large model trained on broad data, adaptable to many downstream tasks.
GELU (Gaussian Error Linear Unit)
- Activation function used in GPT/BERT: x Β· Ξ¦(x) where Ξ¦ is Gaussian CDF.
Geometric Deep Learning
- Unifying framework: different architectures exploit different symmetries (groups).
Gradient Descent
- Optimization: iteratively move in direction opposite to gradient.
GQA (Grouped-Query Attention)
- Attention with fewer key/value heads than query heads; reduces KV-cache size.
GNN (Graph Neural Network)
- Neural network for graph-structured data using message passing.
In-Context Learning
- LLM learning from examples in the prompt without parameter updates.
Induction Head
- Attention head that copies information from earlier positions; key to ICL.
Information Bottleneck
- Theory: learning compresses input while preserving output-relevant information.
JEPA (Joint Embedding Predictive Architecture)
- LeCun: predict representations, not pixels; energy-based world model.
KL Divergence
- Measure of distribution difference: Ξ£ p(x) log(p(x)/q(x)).
KV-Cache
- Stored Key/Value tensors for efficient autoregressive generation.
Langevin Dynamics
- MCMC sampling method used in energy-based models and diffusion.
Layer Normalization
- Normalize activations across feature dimension (not batch dimension).
Logits
- Raw (unnormalized) output scores before softmax.
MAML (Model-Agnostic Meta-Learning)
- Learn initialization that adapts to new tasks in few gradient steps.
Mamba
- Selective state space model with O(n) complexity, rivals transformers.
Mesa-Optimization
- Model internally developing its own optimizer with different goals.
MLP (Multilayer Perceptron)
- Feedforward neural network with multiple hidden layers.
MoE (Mixture of Experts)
- Architecture where different "experts" activate for different inputs.
ΞΌP (Maximal Update Parameterization)
- Parametrization ensuring feature learning at any width.
NTK (Neural Tangent Kernel)
- Kernel describing gradient descent dynamics of infinite-width networks.
PAC Learning
- Probably Approximately Correct: framework for learning theory.
PCA (Principal Component Analysis)
- Dimensionality reduction via eigendecomposition of covariance matrix.
Policy Gradient
- RL algorithm: optimize policy directly via gradient of expected reward.
PPO (Proximal Policy Optimization)
- Stable RL by clipping policy updates to a trust region.
Rademacher Complexity
- Data-dependent measure of model capacity based on fitting random noise.
ReLU (Rectified Linear Unit)
- Activation: f(x) = max(0, x). Default for hidden layers.
Residual Connection
- Skip connection: add input to layer output. Enables very deep networks.
RLHF
- Reinforcement Learning from Human Feedback: align models via human preferences.
RoPE (Rotary Position Embedding)
- Position encoding via rotation of query/key vectors; enables length extrapolation.
Scaling Laws
- Power-law relationships between model/data/compute and loss.
Score Function
- Gradient of log-probability: β_x log p(x). Estimated by diffusion models.
Self-Attention
- Attention where queries, keys, and values all derive from the same sequence.
SGD
- Stochastic Gradient Descent: gradient on random mini-batch.
Sigmoid
- Activation: Ο(x) = 1/(1+e^{-x}). Squashes to (0, 1).
Softmax
- Converts logits to probabilities: softmax(x)_i = e^{x_i} / Ξ£ e^{x_j}.
Sparse Autoencoder
- Overcomplete autoencoder with sparsity; finds interpretable features.
Specification Gaming
- Model exploits the objective without achieving intended goal.
SSM (State Space Model)
- Sequence model via discretized linear ODE; basis of Mamba.
Superposition
- Neural networks represent more features than dimensions; feature entanglement.
SVD (Singular Value Decomposition)
- Matrix factorization: A = UΞ£V^T. Basis of PCA and low-rank methods.
Temperature
- Sampling parameter: controls probability distribution sharpness.
Token
- Atomic unit of text for LLMs (word, subword, or byte).
Transformer
- Architecture based on attention, replacing RNNs for sequence tasks.
Variance (bias-variance tradeoff)
- Sensitivity of predictions to training data variation.
VC Dimension
- Maximum number of points a hypothesis class can shatter; capacity measure.
Weight Decay
- L2 regularization on neural network weights; equivalent to Ξ»||w||Β².
WL Test (Weisfeiler-Lehman)
- Graph isomorphism algorithm; bounds GNN expressivity.
World Model
- Internal model of the world enabling simulation, planning, and reasoning.
Parameter Default Range Applies To
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
d_model (hidden dim) 512/768/1024 128-16384 Transformers
n_layers 6/12 1-96 Transformers, MLPs
n_heads 8/12 1-128 Transformers
d_ff (FFN inner) 2048/3072 d_model to 4Γ Transformers
head_dim (d_model/n_h) 64 32-128 Attention heads
n_experts (MoE) 8 2-256 Mixture of Experts
top_k (MoE) 2 1-8 MoE routing
vocab_size 32000/50257/128000 8K-256K Tokenizers
max_seq_len 2048/4096 512-1M+ All sequence models
CNN-specific:
kernel_size 3 1-11 Conv layers
n_filters (initial) 64 8-256 Conv layers
filter_multiplier 2Γ per block 1-4Γ ConvNets
pool_size 2 2-4 Pooling
n_blocks (ResNet) 4 2-8 ResNet-like
Neural Network general:
hidden_dim (MLP) 128/256 32-4096 MLPs, all NNs
n_hidden_layers 2/3 1-100+ MLPs
embedding_dim 128/256/768 16-8192 Embedding layers
Parameter Default Range Notes
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
learning_rate 1e-4 to 1e-3 1e-6 to 1e-1 Adam 3e-4, SGD 1e-2
batch_size 32/64/128 8-8192 2^n typical
weight_decay 0.01 to 0.1 0-1.0 AdamW default: 0.01
dropout 0.1 0-0.9 Higher for overfitting
attention_dropout 0.0 0-0.5 Less common now
warmup_steps 1000/2000 0-10000 Adam needs warmup
lr_schedule cosine constant, cosine, invsqrt, linear
gradient_clip_norm 1.0 0.1-10 Clip to prevent explosions
label_smoothing 0.0 0-0.2 Helps calibration
beta1 (Adam) 0.9 0.8-0.95 Momentum
beta2 (Adam) 0.999 0.95-0.999 Second moment decay
epsilon (Adam) 1e-8 1e-10 to 1e-6 Numerical stability
Method Default Effect When Too High/Low
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Weight decay (Ξ») 0.01 High = underfitting, Low = overfitting
Dropout rate 0.1 High = underfitting, Low = overfitting
Label smoothing Ξ΅ 0.1 High = underconfident, Low = overconfident
Gradient clipping c 1.0 High = gradient explosion, Low = truncated
Early stopping patience 5-10 epochs Short = underfitting, Long = overfitting
Data augmentation moderate Aggressive = unatural, None = overfitting
Stochastic depth 0.2 High = can't learn, Low = no regularization
Parameter Default Effect
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
temperature 0.7 Low = deterministic, High = random
top_k 50 Low = conservative, High = diverse
top_p (nucleus) 0.9 Lower = more focused
repetition_penalty 1.0 1.0 = none, 1.1 = slight penalty
frequency_penalty 0.0 0.0 = none, 0.5 = reduce common tokens
presence_penalty 0.0 0.0 = none, 0.5 = encourage new topics
max_new_tokens 1024 Limit output length
stop_sequences ["\n\n"] Stop generation at these strings
Problem Likely Cause Fix
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Loss β NaN LR too high Reduce lr 10Γ, check input data
Loss stuck high LR too low Increase lr, check model init
Train loss low, val high Overfitting Add regularization, more data
Both losses high Underfitting Bigger model, train longer
No gradient flow Dead ReLU Try LeakyReLU or GELU, init properly
Oscillating loss LR too high / small batch Try cosine schedule, increase batch
Slow convergence Wrong architecture Check shapes, simplify first
Out of memory (OOM) Batch too big Reduce batch size, use gradient accumulation
Model won't start Shape mismatch Print every layer's input/output shapes
Year Breakthrough Significance
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
1950 Turing Test Criteria for machine intelligence
1957 Perceptron (Rosenblatt) First trainable neural network
1969 Minsky & Papert: Perceptrons Showed limitations, caused first AI winter
1974 Backpropagation (Werbos) The core learning algorithm
1989 Universal Approximation Theorem MLPs can learn any function
1989 LeNet (LeCun) First CNN for digit recognition
1990 RNNs (Elman, Jordan) Recurrent networks for sequences
1995 SVM (Vapnik) Powerful kernel-based classifier
1997 LSTM (Hochreiter & Schmidhuber) Long-range sequence modeling
1997 GANs proposed (concept) Generative adversarial framework
1998 LeNet-5 + MNIST Standardized benchmark for vision
2000 Kernel Methods (SchΓΆlkopf, Smola) SVM/RKHS theory
2006 Deep Belief Networks (Hinton) Start of deep learning revival
2006 Sparse Coding (Olshausen, Field) Unsupervised feature learning
2009 ImageNet (Fei-Fei Li) Large-scale vision dataset
2011 Xavier Init (Glorot & Bengio) Solved vanishing gradient at init
2012 AlexNet (Krizhevsky) Deep CNN destroys ImageNet benchmark
2012 Dropout (Hinton) Effective regularization
2013 Word2Vec (Mikolov) Dense word embeddings
2013 DQN (DeepMind) Deep RL plays Atari
2014 GANs (Goodfellow) Practical generative adversarial training
2014 Adam Optimizer (Kingma & Ba) Adaptive learning rates
2014 Seq2Seq + Attention (Bahdanau) Neural machine translation
2014 Batch Normalization (Ioffe) Stable deep training
2015 ResNet (He et al.) 152-layer networks via skip connections
2015 Attention is All You Need? (concept) Papers started exploring attention
2016 AlphaGo (DeepMind) Beats world champion Go player
2017 Transformer (Vaswani et al.) Attention IS all you need
2017 PPO (Schulman et al.) Stable policy optimization
2017 MoE (Shazeer et al.) Sparse mixture of experts
2017 Wasserstein GAN (Arjovsky) Stable GAN training
2018 BERT (Devlin et al.) Bidirectional pretraining, SOTA NLP
2018 GPT (Radford et al.) Generative pretraining, decoder-only
2018 Neural Tangent Kernel (Jacot) Theory of infinite-width networks
2019 GPT-2 (OpenAI) 1.5B params, controversial release
2019 XLNet, RoBERTa, ALBERT BERT improvements
2019 GNN expressivity (Xu et al.) WL test bounds GNN power
2019 Model-Agnostic Meta-Learning (Finn) Learning to learn
2019 BigGAN, StyleGAN High-quality image generation
2020 GPT-3 (OpenAI) 175B params, emergent abilities
2020 Denoising Diffusion (Ho et al.) DDPM: diffusion for image generation
2020 Scaling Laws (Kaplan et al.) Power-law scaling of loss
2020 CLIP (OpenAI) Zero-shot vision-language learning
2020 Score SDE (Song et al.) Unified diffusion framework
2021 DALL-E (OpenAI) Text-to-image generation
2021 MuP (Yang & Hu) Feature learning at any width
2021 Geometric Deep Learning (Bronstein) Unified symmetry framework
2022 Chinchilla (Hoffmann et al.) Compute-optimal scaling reanalysis
2022 Chain-of-Thought (Wei et al.) Reasoning via step-by-step prompts
2022 Stable Diffusion (Rombach et al.) Open-source text-to-image
2022 ChatGPT (OpenAI) RLHF-chat interface, global adoption
2022 Superposition (Elhage et al.) Feature polysemanticity theory
2023 GPT-4 (OpenAI) Multimodal, ~1.7T params (MoE est.)
2023 LLaMA (Meta) Open-weight LLMs, strong small models
2023 RLHF (InstructGPT paper) Alignment via human preferences
2023 DPO (Rafailov et al.) Preference optimization without RL
2023 Flow Matching (Lipman et al.) ODE-based generation
2023 Mamba (Gu & Dao) State space model rivals transformers
2023 Flash Attention (Dao et al.) Efficient attention algorithm
2023 RT-2 (Brohan et al./DeepMind) Vision-language-action for robotics
2023 LLaVA (Liu et al.) Open multimodal LLM
2024 Sora (OpenAI) Video generation at scale
2024 Claude 3 (Anthropic) Sparse autoencoder interpretability
2024 Llama 3 (Meta) 70B, 405B open models
2024 DeepSeek-V2 (DeepSeek) Efficient MoE, 236B params
2024 o1-preview (OpenAI) Chain-of-thought reasoning model
2024 Gemma 2 (Google) Efficient open models
2025 o3 / DeepSeek-R1 Extended reasoning, test-time scaling
2025 Claude 4, Gemini 2.5 Multi-agent, 1M+ context
2025 Physical AI (various) Robotics foundation models advance
2026 Claude Sonnet 4, DeepSeek V4 Continued scaling, reasoning, agents
Final Words
This textbook covers everything from the mathematical foundations of linear algebra and probability to the frontiers of mechanistic interpretability, causal AI, and the path to AGI. It is designed to be fully self-contained β every concept is explained here, every derivation is shown, every algorithm is implemented in pseudocode.
You don't need to search the internet. You don't need to read separate papers. Everything you need for a complete understanding β from "what is a vector?" to "what is the NTK regime?" to "how does superposition challenge interpretability?" β is right here.
The only thing left is to build.
β a1n4a, July 2026