How many extra bits do you pay for believing the wrong thing?
That one number trains almost every modern model.
Average code length when you code with the wrong model.
The extra bits. Never negative. Zero only when you are right.
Max likelihood = min KL. Softmax loss = cross-entropy.
VAEs, GANs, distillation, RLHF: all KL in disguise.
Definitions, theorems with proof sketches, and runnable stdlib-only Python on every key idea. Outputs shown are from real runs (Python 3.11+).
How do model probabilities turn into a stable loss and gradient?
Understand the data, the rule, and the required result. Then run the complete Python program and modify one thing at a time.
No companion presentation is required. Python syntax and any mathematical notation are explained as they are introduced.
An unnormalized real score.
Normalized exponentials across classes.
Expected negative log model probability.
Extra expected coding cost when p is modeled by q.
Trace each step and name what changed. Ask which fact stayed true and what should happen on an empty, invalid, or boundary input.
Surprise (self-information) of an outcome with probability p(x):
I(x) = −log2 p(x) bits
Rare things surprise us a lot. Sure things do not surprise us at all: −log 1 = 0.
Entropy is the average surprise:
H(p) = −Σx p(x) log2 p(x)
| x | p(x) | −log2p | ideal code |
|---|---|---|---|
| A | 1/2 | 1 | 0 |
| B | 1/4 | 2 | 10 |
| C | 1/8 | 3 | 110 |
| D | 1/8 | 3 | 111 |
H = ½·1 + ¼·2 + ⅛·3 + ⅛·3 = 1.75 bits
Key idea: an ideal code gives symbol x a codeword of length −log p(x). Your model of the world is your code.
Units: log base 2 gives bits. Natural log gives nats. 1 nat = 1.4427 bits. ML libraries use nats.
Cross-entropy of model q on data from p:
H(p, q) = −Σx p(x) log q(x) = Ex~p[−log q(x)]
You build the code from q (lengths −log q(x)). But the symbols really arrive from p. So this is the average length you actually pay.
Suppose we think all four letters are equally likely. Then q = (¼, ¼, ¼, ¼) and every codeword has 2 bits.
H(p,q) = 2 bits vs H(p) = 1.75 bits
We waste 0.25 bits per symbol. That waste has a name.
from math import log2
def entropy(p):
return -sum(pi * log2(pi) for pi in p if pi > 0)
def cross_entropy(p, q):
return -sum(pi * log2(qi) for pi, qi in zip(p, q) if pi > 0)
def kl(p, q):
return sum(pi * log2(pi / qi) for pi, qi in zip(p, q) if pi > 0)
p = [0.5, 0.25, 0.125, 0.125] # true source
q = [0.25, 0.25, 0.25, 0.25] # model: uniform
print(f"H(p) = {entropy(p):.3f} bits")
print(f"H(p,q) = {cross_entropy(p, q):.3f} bits")
print(f"KL = {kl(p, q):.3f} bits")
assert abs(cross_entropy(p, q) - (entropy(p) + kl(p, q))) < 1e-12
H(p) = 1.750 bits H(p,q) = 2.000 bits KL = 0.250 bits
The if pi > 0 guard uses the convention 0 log 0 = 0. It is the limit of t log t as t → 0.
Continuous version with densities:
KL(p ‖ q) = ∫ p(x) log p(x)⁄q(x) dx
Unlike differential entropy, KL keeps its meaning for densities. It stays ≥ 0 and does not change if you rescale x.
| Property | Holds? |
|---|---|
| KL(p‖q) ≥ 0 | yes (Gibbs) |
| KL(p‖q) = 0 ⇔ p = q | yes |
| Symmetric | no |
| Triangle inequality | no |
| Convex in the pair (p, q) | yes |
| Additive for independent pairs | yes |
| Invariant under invertible maps of x | yes |
| Can be infinite | yes |
It is a divergence, not a distance. It fails symmetry and the triangle inequality. Its square root is not a metric either.
Let S = {x : p(x) > 0}. Work in nats.
−KL(p‖q) = Σx∈S p(x) ln q(x)⁄p(x)
≤ Σx∈S p(x) ( q(x)⁄p(x) − 1 )
= Σx∈S q(x) − 1 ≤ 1 − 1 = 0
The first step is tight only when q(x)/p(x) = 1 on all of S. The last step is tight only when q puts no mass outside S. Together they give p = q. ∎
log is concave. So E[log Y] ≤ log E[Y]. Take Y = q(X)/p(X) with X ~ p:
−KL = E[log Y] ≤ log E[Y] = log ΣS q(x) ≤ log 1 = 0
import random
from math import log2
def kl(p, q):
return sum(pi * log2(pi / qi) for pi, qi in zip(p, q) if pi > 0)
def rand_dist(k):
w = [random.random() for _ in range(k)]
s = sum(w)
return [x / s for x in w]
random.seed(0)
worst = min(kl(rand_dist(5), rand_dist(5)) for _ in range(100_000))
print(f"smallest KL over 100k random pairs: {worst:.5f}")
smallest KL over 100k random pairs: 0.00076
Corollary: H(p) ≤ log |X|. Take q uniform: KL(p‖u) = log|X| − H(p) ≥ 0.
H(p,q) = −Σ p log q
= −Σ p log p + Σ p log (p/q)
= H(p) + KL(p‖q) ∎
H(p) is a property of the data. You cannot change it. So when you train q:
argminq H(p,q) = argminq KL(p‖q)
For a fixed discrete target distribution p, the unrestricted cross-entropy floor is H(p). Classification with input x uses conditional entropy H(Y|X); a one-hot target has entropy zero.
The assert in the cross-entropy snippet checks this identity numerically. Try it yourself on a few random pairs.
True weather p and a forecaster's model q over sun, cloud, rain. All logs base 2.
| x | p | q | p·(−log p) | p·(−log q) | p·log(p/q) |
|---|---|---|---|---|---|
| sun | 1/2 | 1/4 | 0.5 | 1.0 | +0.5 |
| cloud | 1/4 | 1/4 | 0.5 | 0.5 | 0 |
| rain | 1/4 | 1/2 | 0.5 | 0.25 | −0.25 |
| column sums | H(p) = 1.5 | H(p,q) = 1.75 | KL = 0.25 | ||
The forecaster pays 1.75 bits per day. A perfect model would pay 1.5. The 0.25 bits per day is the price of believing q when the truth is p.
The rain row of KL is −0.25. Single terms can be negative, where q > p. Only the sum is ≥ 0 (Gibbs). Do not "fix" a negative term with abs.
In nats the same numbers are 1.040, 1.213 and 0.173. Multiply bits by ln 2 ≈ 0.693 to get nats. PyTorch and NumPy use nats.
Here KL(q‖p) is also 0.25, because q is p with two labels swapped. That is luck. The next slide shows the usual case.
KL(p‖q) = 0.9 log2(0.9/0.5) + 0.1 log2(0.1/0.5)
= 0.9(0.848) + 0.1(−2.322) = 0.531
KL(q‖p) = 0.5 log2(0.5/0.9) + 0.5 log2(0.5/0.1)
= 0.5(−0.848) + 0.5(2.322) = 0.737
from math import log2
def kl(p, q):
return sum(pi * log2(pi / qi) for pi, qi in zip(p, q) if pi > 0)
p = [0.9, 0.1]
q = [0.5, 0.5]
print(f"KL(p||q) = {kl(p, q):.4f}")
print(f"KL(q||p) = {kl(q, p):.4f}")
print(f"KL(p||p) = {kl(p, p):.4f}")
KL(p||q) = 0.5310 KL(q||p) = 0.7370 KL(p||p) = 0.0000
KL(q‖p) weights by q. Under the fair coin, tails comes up half the time. But p gave tails only 0.1. So the code built from p spends 3.32 bits on each tail. That hurts a lot.
KL(p‖q) weights by p. Tails is rare there, so the fair code's small waste on heads dominates.
Rule of thumb: KL punishes the model hard where the first argument has mass but the second does not.
Take a=(.9,.1), b=(.5,.5), c=(.1,.9). Then KL(a‖c) = 2.536 bits. But KL(a‖b) + KL(b‖c) = 0.531 + 0.737 = 1.268.
Covering/seeking modes is a tendency for restricted families. The unrestricted optimum of either direction is p=q; representational and optimization constraints determine practical behavior.
Expectation is under p. If q ≈ 0 where p > 0, the cost blows up. So q must cover every mode, even if it puts mass in the gap. For a Gaussian q the optimum just matches the mean and variance of p. Used by MLE and supervised learning.
With a restricted unimodal family and separated target modes, reverse KL often favors one mode. This is a modeling/optimization tendency, not a universal theorem: an expressive family can match all of p.
Ideal finite-logit softmax is positive, but floating-point probabilities can underflow to zero. Stable log-space losses avoid artificial zeros without masking real support mismatches.
Absolute continuity. p ≪ q means: whenever q(x) = 0, also p(x) = 0.
KL(p‖q) < ∞ requires p ≪ q
If p(x) > 0 = q(x) for some x, the term p(x) log(p(x)/0) = +∞. Your code has no codeword for a symbol that really occurs.
| p(x) | q(x) | term p log(p/q) |
|---|---|---|
| 0 | anything | 0 (by convention) |
| > 0 | > 0 | finite |
| > 0 | 0 | +∞ |
log(0) raises ValueError in Python and gives -inf in NumPy. Clamp with a tiny ε or work in log-space.Forward KL is "zero-avoiding": q must be > 0 wherever p is. Reverse KL is "zero-forcing": q must be 0 wherever p is.
The empirical-KL identity here is discrete. Atomic empirical measures compared with continuous-density probability measures generally have infinite measure KL.
Data x1, …, xN. The empirical distribution puts mass 1/N on each sample:
p̂(x) = #{i : xi = x}⁄N
−1⁄N log ∏i qθ(xi) = −1⁄N Σi log qθ(xi)
= −Σx p̂(x) log qθ(x) (group equal x)
= H(p̂, qθ) = H(p̂) + KL(p̂ ‖ qθ)
H(p̂) does not depend on θ, and −log is decreasing. ∎
Check it: 10 coin flips, 7 heads. Scan θ on a grid.
from math import log
flips = "HHTHHHTHHT" # data: 7 heads, 3 tails
p_hat = flips.count("H") / len(flips) # empirical distribution
def nll(theta): # average negative log-likelihood
return -sum(log(theta if c == "H" else 1 - theta) for c in flips) / len(flips)
def kl_emp(theta): # KL(p_hat || Bernoulli(theta))
return (p_hat * log(p_hat / theta)
+ (1 - p_hat) * log((1 - p_hat) / (1 - theta)))
grid = [i / 100 for i in range(1, 100)]
print("argmin NLL :", min(grid, key=nll))
print("argmin KL :", min(grid, key=kl_emp))
h = -(p_hat * log(p_hat) + (1 - p_hat) * log(1 - p_hat))
print(all(abs(nll(t) - (h + kl_emp(t))) < 1e-12 for t in grid))
argmin NLL : 0.7 argmin KL : 0.7 True
The last line confirms NLL(θ) = H(p̂) + KL(p̂‖qθ) for all 99 grid values. The closed form is θ̂ = 7/10.
As N → ∞, p̂ → p. So MLE is a sample estimate of "minimize KL from the truth". This is the forward, mode-covering direction.
The q−y gradient uses natural-log loss; base-2 loss adds 1/ln(2). Batch averaging adds its normalization. A bounded logit gradient does not bound all parameter gradients.
A classifier outputs logits z ∈ ℝK. Softmax turns them into a distribution:
qi = ezi / Σj ezj
The target is a one-hot y (true class c). Cross-entropy loss:
L = H(y, q) = −log qc = −zc + log Σj ezj
∂L/∂zi = −[i = c] + ezi/Σj ezj
∇z L = q − y
The nats-based gradient with respect to logits is q-y (scaled by any batch averaging). It remains informative for a confidently wrong true-class probability; earlier parameter gradients can still vanish or explode through the network Jacobian.
The gradient entries sum to 0 because softmax only cares about differences of logits. Adding a constant to every zi changes nothing.
Finite-difference check (central differences, h = 10−6):
from math import exp, log
def softmax(z):
m = max(z)
e = [exp(v - m) for v in z]
s = sum(e)
return [v / s for v in e]
def ce_loss(z, y): # y = index of the true class
return -log(softmax(z)[y])
def ce_grad(z, y): # analytic: p - onehot(y)
p = softmax(z)
return [pi - (1 if i == y else 0) for i, pi in enumerate(p)]
z, y, h = [2.0, -1.0, 0.5], 0, 1e-6
numeric = []
for i in range(len(z)):
zp = z[:]; zp[i] += h
zm = z[:]; zm[i] -= h
numeric.append((ce_loss(zp, y) - ce_loss(zm, y)) / (2 * h))
print("analytic:", [round(g, 6) for g in ce_grad(z, y)])
print("numeric :", [round(g, 6) for g in numeric])
analytic: [-0.214403, 0.039113, 0.17529] numeric : [-0.214403, 0.039113, 0.17529]
import random
from math import exp, log
random.seed(1)
# 2-D points: class 1 if x + y > 1 (with a little label noise)
data = []
for _ in range(200):
x, y = random.random(), random.random()
label = 1 if x + y > 1 else 0
if random.random() < 0.05:
label = 1 - label
data.append(((x, y), label))
def sigmoid(t):
return 1 / (1 + exp(-t))
w1 = w2 = b = 0.0
lr = 1.0
for epoch in range(2001):
g1 = g2 = gb = loss = 0.0
for (x, y), t in data:
p = sigmoid(w1 * x + w2 * y + b)
loss -= t * log(p) + (1 - t) * log(1 - p) # binary cross-entropy
g1 += (p - t) * x; g2 += (p - t) * y; gb += p - t
n = len(data)
w1 -= lr * g1 / n; w2 -= lr * g2 / n; b -= lr * gb / n
if epoch % 500 == 0:
print(f"epoch {epoch:4d} BCE = {loss / n:.4f}")
acc = sum((sigmoid(w1*x + w2*y + b) > 0.5) == t for (x, y), t in data) / len(data)
print(f"accuracy = {acc:.2f}")
epoch 0 BCE = 0.6931 epoch 500 BCE = 0.3724 epoch 1000 BCE = 0.3668 epoch 1500 BCE = 0.3661 epoch 2000 BCE = 0.3660 accuracy = 0.88
Binary cross-entropy is softmax cross-entropy with K = 2:
L = −[t log p + (1−t) log(1−p)], p = σ(w·x + b)
∇w L = (p − t) x
The q−y gradient uses natural-log loss; base-2 loss adds 1/ln(2). Batch averaging adds its normalization. A bounded logit gradient does not bound all parameter gradients.
Binary case, p = σ(z), true label t = 1:
CE: ∂L/∂z = p − 1
MSE: ∂L/∂z = 2(p − 1) · p(1 − p)
The MSE gradient has an extra σ'(z) = p(1−p) factor. When the model is confidently wrong (p → 0), that factor goes to 0. Learning stalls exactly when it matters most.
| p (for true class) | |CE grad| | |MSE grad| |
|---|---|---|
| 0.5 | 0.50 | 0.25 |
| 0.1 | 0.90 | 0.162 |
| 0.01 | 0.99 | 0.0196 |
| 0.0001 | 0.9999 | 0.0002 |
MSE is still right for regression with Gaussian noise. That is MLE too, just with a different model.
Replace the one-hot target with a softened one (Szegedy et al., 2016):
yLS = (1 − ε) y + ε / K
The loss then splits:
H(yLS, q) = (1−ε) H(y, q) + ε H(u, q)
where u is uniform. The second term is a KL-to-uniform penalty (up to a constant). It pulls q away from extreme confidence.
from math import log
def ce(target, pred):
return -sum(t * log(p) for t, p in zip(target, pred) if t > 0)
def smooth(k, y, eps):
return [(1 - eps) + eps / k if i == y else eps / k for i in range(k)]
confident = [0.998, 0.001, 0.001]
calibrated = [0.90, 0.05, 0.05]
for eps in (0.0, 0.1):
t = smooth(3, 0, eps)
print(f"eps={eps}: loss(confident)={ce(t, confident):.3f}"
f" loss(calibrated)={ce(t, calibrated):.3f}")
eps=0.0: loss(confident)=0.002 loss(calibrated)=0.105 eps=0.1: loss(confident)=0.462 loss(calibrated)=0.298
With hard labels, the 99.8%-confident model wins. With ε = 0.1, the calmer 90% model wins. The optimum is now q = yLS, not a spike.
A language model predicts q(wt | w<t). On a test text of n tokens:
CE = −1⁄n Σt log2 q(wt | w<t) bits/token
Perplexity = 2CE
from collections import Counter
from math import log2
train = "the cat sat on the mat . the dog sat on the log . the cat saw the dog .".split()
test = "the dog sat on the mat .".split()
vocab = set(train)
V = len(vocab)
bigrams = Counter(zip(train, train[1:]))
unigrams = Counter(train[:-1])
def prob(prev, word): # add-one (Laplace) smoothing
return (bigrams[(prev, word)] + 1) / (unigrams[prev] + V)
bits = -sum(log2(prob(a, b)) for a, b in zip(test, test[1:]))
n = len(test) - 1
print(f"vocab={V} cross-entropy={bits / n:.3f} bits/token")
print(f"perplexity={2 ** (bits / n):.2f} (uniform guess would be {V})")
vocab=9 cross-entropy=2.293 bits/token perplexity=4.90 (uniform guess would be 9)
Add-one smoothing keeps every bigram probability above 0. Without it, any unseen pair in a test sentence, such as ("cat", "log"), would get probability 0 and infinite perplexity.
Mutual information between X and Y:
I(X; Y) = KL( p(x, y) ‖ p(x) p(y) )
It is how far the joint is from "independent". So by Gibbs, I ≥ 0, with equality iff X ⊥ Y.
I(X;Y) = H(X) − H(X|Y)
= H(X) + H(Y) − H(X,Y)
= Ey[ KL( p(x|y) ‖ p(x) ) ]
The last form says: MI is the average information gain about X from seeing Y.
Seeing the umbrella removes about half of our 0.93 bits of doubt about the weather.
from math import log2
# joint distribution of (weather, umbrella)
joint = {("rain", "yes"): 0.30, ("rain", "no"): 0.05,
("sun", "yes"): 0.05, ("sun", "no"): 0.60}
px, py = {}, {}
for (x, y), p in joint.items():
px[x] = px.get(x, 0) + p
py[y] = py.get(y, 0) + p
mi = sum(p * log2(p / (px[x] * py[y])) for (x, y), p in joint.items())
hx = -sum(p * log2(p) for p in px.values())
print(f"I(X;Y) = {mi:.4f} bits H(X) = {hx:.4f} bits")
I(X;Y) = 0.4727 bits H(X) = 0.9341 bits
f-divergence (Csiszár 1963; Ali & Silvey 1966). For convex f with f(1) = 0:
Df(p ‖ q) = Σx q(x) f( p(x) / q(x) )
Jensen gives Df ≥ f(1) = 0, the same proof as Gibbs.
| f(t) | divergence |
|---|---|
| t log t | KL(p‖q) |
| −log t | KL(q‖p) |
| (t − 1)² | Pearson χ² |
| ½|t − 1| | total variation |
| (√t − 1)² | squared Hellinger (×2) |
| t log t − (t+1) logt+1⁄2 | 2 · Jensen–Shannon |
Pinsker: TV(p,q) ≤ √( KL(p‖q) / 2 ) in nats. Small KL forces the distributions to be close.
Jensen–Shannon divergence. With m = ½(p + q):
JS(p, q) = ½ KL(p‖m) + ½ KL(q‖m)
Symmetric. Always finite, between 0 and 1 bit. Its square root is a metric.
from math import log2
def kl(p, q):
return sum(pi * log2(pi / qi) for pi, qi in zip(p, q) if pi > 0)
def js(p, q):
m = [(a + b) / 2 for a, b in zip(p, q)]
return 0.5 * kl(p, m) + 0.5 * kl(q, m)
p = [1.0, 0.0]
q = [0.0, 1.0]
print(f"JS(p,q) = {js(p, q):.4f} bits JS(q,p) = {js(q, p):.4f}")
try:
kl(p, q)
except ZeroDivisionError:
print("KL(p||q) = infinity (q is 0 where p is not)")
JS(p,q) = 1.0000 bits JS(q,p) = 1.0000 KL(p||q) = infinity (q is 0 where p is not)
Generator G makes fakes with law pg. Discriminator D(x) guesses "real?". The game:
minG maxD Epdata[log D(x)] + Epg[log(1 − D(x))]
This is binary cross-entropy for D, with the sign flipped.
Pointwise, a log D + b log(1−D) peaks at D = a/(a+b). Substitute and write each term as a KL to the mixture m = (pdata + pg)/2. The constants add up to −log 4. ∎
JS = 1.0000 bits on the last slide.Latent model p(x, z) = p(z) p(x|z). We want the posterior p(z|x). But p(x) = ∫ p(x,z) dz is intractable.
Pick a simple family qφ(z) and minimize the reverse KL KL(q ‖ p(z|x)).
log p(x) = Eq[ log p(x,z) − log q(z) ] + KL( q(z) ‖ p(z|x) )
= ELBO(q) + KL(q ‖ posterior)
Proof: write log p(x) = log p(x,z) − log p(z|x) for any z. Add and subtract log q(z). Take Eq. ∎
ELBO = Eq[log p(x|z)] − KL( q(z) ‖ p(z) )
Term 1: reconstruct the data well. Term 2: keep the code close to the prior. That is the VAE loss.
With the VAE prior N(0, 1) it becomes
KL = ½( σ² + μ² − 1 ) − log σ
For a d-dim diagonal Gaussian, sum this over the d coordinates (additivity).
import random
from math import log, pi, exp, sqrt
def kl_gauss(mu, sigma): # KL( N(mu, sigma^2) || N(0, 1) )
return 0.5 * (sigma**2 + mu**2 - 1) - log(sigma)
def logpdf(x, mu, s):
return -0.5 * log(2 * pi * s * s) - (x - mu) ** 2 / (2 * s * s)
random.seed(0)
mu, sigma = 1.0, 0.5
xs = [random.gauss(mu, sigma) for _ in range(200_000)]
mc = sum(logpdf(x, mu, sigma) - logpdf(x, 0, 1) for x in xs) / len(xs)
print(f"closed form : {kl_gauss(mu, sigma):.4f} nats")
print(f"Monte Carlo : {mc:.4f} nats")
closed form : 0.8181 nats Monte Carlo : 0.8206 nats
Monte Carlo averages log q(z) − log p(z) over samples from q. It agrees to 2 decimals with 200k samples. The closed form has no noise, which is why VAEs use it.
Fine-tune a language model π against a learned reward r(x, y). Keep it near the reference model πref:
maxπ Ey~π[ r(x, y) ] − β · KL( π(·|x) ‖ πref(·|x) )
from math import exp, log
ref = {"helpful": 0.40, "verbose": 0.30, "flattery": 0.20, "rude": 0.10}
reward = {"helpful": 1.0, "verbose": 0.6, "flattery": 1.5, "rude": -2.0}
def optimal_policy(beta): # argmax E[r] - beta * KL(pi || ref)
w = {y: ref[y] * exp(reward[y] / beta) for y in ref}
z = sum(w.values())
return {y: v / z for y, v in w.items()}
for beta in (10.0, 1.0, 0.1):
pi = optimal_policy(beta)
kl = sum(p * log(p / ref[y]) for y, p in pi.items())
print(f"beta={beta:<4}", {y: round(p, 2) for y, p in pi.items()},
f"KL={kl:.3f}")
beta=10.0 {'helpful': 0.41, 'verbose': 0.3, 'flattery': 0.22, 'rude': 0.08} KL=0.004
beta=1.0 {'helpful': 0.43, 'verbose': 0.21, 'flattery': 0.35, 'rude': 0.01} KL=0.141
beta=0.1 {'helpful': 0.01, 'verbose': 0.0, 'flattery': 0.99, 'rude': 0.0} KL=1.528
The reward model overrates "flattery". With large β the policy barely moves. With β = 0.1 it puts 99% on flattery. That is reward hacking in four lines.
This is reverse KL (π first), so it is mode-seeking. PPO usually adds it as a per-token penalty.
Train a small student to match a big teacher's softened outputs. With temperature T:
piT = softmax(z / T)i
L = (1−α) H(y, q) + α T² KL( pTteacher ‖ qTstudent )
from math import exp, log
def softmax(z, T=1.0):
m = max(z)
e = [exp((v - m) / T) for v in z]
s = sum(e)
return [v / s for v in e]
teacher_logits = [6.0, 2.5, 2.0, -1.0] # cat, dog, fox, car
for T in (1, 4):
print(f"T={T}:", [round(p, 3) for p in softmax(teacher_logits, T)])
def distill_loss(student, teacher, T):
p, q = softmax(teacher, T), softmax(student, T)
return T * T * sum(pi * log(pi / qi) for pi, qi in zip(p, q))
print(f"KD loss = {distill_loss([4.0, 1.0, 1.5, 0.0], teacher_logits, 4):.4f}")
T=1: [0.953, 0.029, 0.017, 0.001] T=4: [0.511, 0.213, 0.188, 0.089] KD loss = 0.4710
At T = 1 the three wrong classes share under 5%, so they barely teach anything. At T = 4, dog and fox get about 20% each while car gets 9%. The student learns what looks like a cat.
Log-sum-exp trick. For any constant m:
log Σj ezj = m + log Σj ezj − m
Pick m = max z. Then every exponent is ≤ 0. Nothing overflows, and the largest term is exactly 1.
from math import exp, log
def logsumexp(z):
m = max(z)
return m + log(sum(exp(v - m) for v in z))
z = [1000.0, 999.0, 998.0]
try:
naive = log(sum(exp(v) for v in z))
except OverflowError:
naive = "OverflowError"
print("naive :", naive)
print("stable:", round(logsumexp(z), 6))
# log-softmax, then cross-entropy for class 0, never forming tiny probs
print("CE :", round(logsumexp(z) - z[0], 6))
naive : OverflowError stable: 1000.407606 CE : 0.407606
log(softmax(z)) in two steps. Tiny probabilities round to 0, then log gives -inf. Use a fused log_softmax or cross_entropy(logits, y).KLDivLoss in PyTorch expects log-probs for the input and probs for the target. The argument order is (q, p), which confuses many.Stay in log-space as long as you can. Exponentiate only at the very end, if ever.
np.log and PyTorch losses use nats. A loss of 0.693 nats is 1 bit. Perplexity is 2CE for bits but eCE for nats. Mixing them gives nonsense.
Mode covering/seeking is a useful tendency for some restricted families, not a rule that applies to every pair of distributions or optimizer.
0 · log(0/q) = 0: safe to skip. But p · log(p/0) = ∞ for p > 0. A model that says "impossible" to something that happens pays infinite loss.
It is not symmetric, and the triangle rule fails. For coins with heads-probability 0.1, 0.5 and 0.9 (in nats): KL(0.1‖0.9) = 1.758, but KL(0.1‖0.5) + KL(0.5‖0.9) = 0.368 + 0.511 = 0.879.
Cross-entropy on soft or noisy labels bottoms out at H(p), not 0. With label smoothing ε = 0.1 and 3 classes, the floor is H(yLS) ≈ 0.291 nats.
cross_entropy(logits, y) applies softmax itself. Passing probabilities applies it twice. The loss still falls, but the gradients are wrong.
Try each one before you look to the right.
| Year | Who | What |
|---|---|---|
| 1902 | J. W. Gibbs | The inequality, in statistical mechanics |
| 1906 | J. Jensen | Jensen's inequality for convex functions |
| 1948 | C. Shannon | Entropy, source coding, the bit |
| 1951 | S. Kullback & R. Leibler | "On Information and Sufficiency": the divergence we now call KL |
| 1957 | E. T. Jaynes | Maximum entropy principle |
| 1963–66 | I. Csiszár; Ali & Silvey | f-divergences |
| 1973 | H. Akaike | AIC: model selection as estimated KL |
| 1991 | J. Lin | Jensen–Shannon divergence |
| 1999 | Jordan et al. | Variational methods for graphical models |
| 2013 | Kingma & Welling | Variational autoencoders |
| 2014 | Goodfellow et al. | GANs and the JS connection |
| 2015 | Hinton, Vinyals, Dean | Knowledge distillation |
| 2015–16 | Szegedy et al. | Label smoothing (Inception v3) |
| 2017–22 | Christiano; Ouyang et al. | RLHF with a KL penalty (InstructGPT) |
| 2023 | Rafailov et al. | DPO: RLHF's KL optimum as a classifier loss |
H(p) = −Σ p log p
H(p,q) = −Σ p log q
KL = Σ p log(p/q)
H(p,q) = H(p) + KL
I(X;Y) = KL(pXY‖pXpY)
JS = ½KL(p‖m) + ½KL(q‖m)
∇z CE = softmax(z) − y
| Use | KL |
|---|---|
| MLE / supervised | KL(data‖model) |
| Variational inference | KL(q‖posterior) |
| VAE regularizer | KL(q(z|x)‖prior) |
| RLHF penalty | KL(π‖πref) |
| Distillation | KL(teacher‖student) |
log(softmax).Learning is the search for the model that wastes the fewest bits on the data.
| Term | Meaning |
|---|---|
| Entropy H(p) | Average surprise under p; best possible average code length |
| Cross-entropy H(p,q) | Average code length when data come from p but you code with q |
| KL divergence KL(p‖q) | Extra cost H(p,q) − H(p) ≥ 0; not symmetric |
| Gibbs' inequality | KL ≥ 0, with 0 only when p = q |
| Forward / reverse KL | KL(data‖model) covers modes; KL(model‖data) picks one |
| Bit / nat | Log base 2 / base e; 1 nat ≈ 1.4427 bits |
| Perplexity | 2CE in bits: the "effective number of choices" |
| Mutual information | KL(p(x,y)‖p(x)p(y)): how far from independent |
| Term | Meaning |
|---|---|
| Logits | Raw scores z before softmax |
| Softmax | ezi / Σ ezj: turns logits into probabilities |
| MLE | Maximum likelihood: same as minimizing KL(empirical‖model) |
| Label smoothing | Mix the one-hot target with uniform, weight ε |
| JS divergence | Symmetric average of KLs to the mixture; 0 to 1 bit |
| ELBO | Evidence lower bound: log p(x) − KL(q‖posterior) |
| Temperature T | Divide logits by T; higher T gives a flatter softmax |
| Log-sum-exp | Stable way to compute log Σ ez: subtract the max first |
How do model probabilities turn into a stable loss and gradient?
The file contains its own imports, data generator or fixture, implementation, and checks.
python3 -m venv .venv source .venv/bin/activate # No third-party packages required python kl_cross_entropy.py python kl_cross_entropy.py --self-test
Use Python 3.10+. On Windows, activate with .venv\Scripts\Activate.ps1. Required packages: Python standard library.
Read the contract first. Identify the inputs, intermediate state, and return value in this excerpt from the verified file.
def softmax(logits):
"""Pre: finite nonempty logits. Post: normalized probabilities using a stable shift."""
if not logits or any(not math.isfinite(z) for z in logits):
raise ValueError("logits must be finite and nonempty")
maximum = max(logits)
shifted = [math.exp(z-maximum) for z in logits]
total = sum(shifted)
return [z/total for z in shifted]H(p), CE(p,q), KL(p||q) in nats: 1.0397207708399179 1.2130075659799042 0.17328679513998635 Stable softmax: [0.665241, 0.244728, 0.090031] CE gradient at target 0: [-0.334759, 0.244728, 0.090031] KL([1,0] || [.9,.1]): 0.10536051565782628 Reverse KL: inf
Output was captured from the complete program running in an isolated temporary directory. Floating-point digits and compressed sizes may vary across versions/hardware.
14 checks passed, including boundary cases and independent reference comparisons where applicable. These executed checks complement the reasoning; they are not a universal proof.
Replace finite model probability with zero where the target has positive mass. Explain the returned infinity instead of clipping it away.
A genuinely impossible modeled outcome has infinite loss. Log-space arithmetic prevents numerical underflow for positive ideal probabilities; it does not change a true support mismatch.