AI EngineeringZero to ProductionHome·About·Contact
Appendix · Advanced AI Engineering · Part 12

Information theory & optimization

Two ideas power modern AI training. Information theory gives the loss functions — entropy, cross-entropy and KL divergence — that measure how wrong a model is, and the perplexity that scores a language model. Optimization gives the method — gradient descent — that reduces that loss. This lesson derives and computes both, implements gradient descent and a tiny backprop from scratch, and connects every piece to how an LLM actually learns.

⏱️ ~2.5 hours🎓 Intermediate → Expert📈 entropy → backproprunnable
SetupEverything runs on Python’s standard library (math); the one numpy block is labelled numpy-required. Where an example is deliberately tiny (a 1-parameter loss, a 1-feature regression) we say so — the mechanism is identical at scale, just with more parameters. Every printed number below is from a real run; no fabricated values.

Learning objectives

  • Define and compute entropy; explain it as expected surprise / bits.
  • Derive cross-entropy and KL divergence and compute both.
  • Explain why cross-entropy IS the training/eval loss for classifiers and LLMs.
  • Compute perplexity and say what an LLM’s perplexity number means.
  • Compute mutual information and name a use (feature selection, eval).
  • Implement gradient descent from scratch and tune the learning rate.
  • State what convexity guarantees and contrast SGD with batch GD.
  • Walk the chain rule / backprop on a tiny network and train it end-to-end.

1 · Entropy — expected surprise intermediate

Entropy H(p) = −Σ pi log pi is the average surprise of a distribution — in bits when the log is base 2. A rare event (small p) carries more surprise (−log p is large); a certain event carries none. Entropy is maximized by the uniform distribution (maximum uncertainty) and zero for a certain outcome. It is the information-theoretic floor: the fewest bits per symbol any code could achieve, and the baseline the loss functions below are measured against.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it — entropy of a few distributions
python# stdlib only
import math

def entropy(p, base=2):
    return -sum(pi * math.log(pi, base) for pi in p if pi > 0)   # 0*log0 := 0

print("fair coin   :", entropy([0.5, 0.5]))          # 1.0 bit
print("biased coin :", round(entropy([0.9, 0.1]), 5))# 0.469 bits (less uncertain)
print("uniform of 4:", entropy([0.25]*4))            # 2.0 bits
print("certain     :", entropy([1.0, 0.0]))          # -0.0 bits (no surprise)
fair coin   : 1.0
biased coin : 0.469
uniform of 4: 2.0
certain     : -0.0

A fair coin needs exactly 1 bit; a 90/10 coin only 0.469 bits (you can usually guess ‘heads’); a uniform 4-way choice needs 2 bits; a certain outcome needs none. Entropy quantifies how much a distribution can surprise you — the reference point for measuring a model against reality next.

2 · Cross-entropy & KL divergence — THE loss advanced

Cross-entropy H(p, q) = −Σ pi log qi is the average surprise when reality is p but you encoded for q — your model’s predicted distribution. KL divergence D(p‖q) = Σ pi log(pi/qi) is the extra cost of that mismatch. The identity that matters: H(p, q) = H(p) + D(p‖q). Since H(p) is fixed by the data, minimizing cross-entropy = minimizing KL = making the model’s distribution match reality. When the label is one-hot, cross-entropy collapses to −log q(correct) — the negative log-likelihood that classifiers and LLMs train on.

Try it — cross-entropy, KL, and the decomposition
python# stdlib only
import math

def entropy(p, base=2):        return -sum(pi*math.log(pi, base) for pi in p if pi > 0)
def cross_entropy(p, q, base=2): return -sum(pi*math.log(qi, base) for pi, qi in zip(p, q) if pi > 0)
def kl(p, q, base=2):          return sum(pi*math.log(pi/qi, base) for pi, qi in zip(p, q) if pi > 0)

# One-hot label -> cross-entropy is just -log(prob of correct class):
true = [1, 0, 0]
pred = [0.7, 0.2, 0.1]
print("CE (one-hot, nats):", round(cross_entropy(true, pred, math.e), 5))  # 0.35667
print("-ln(0.7)          :", round(-math.log(0.7), 5))                      # 0.35667

# General case: verify H(p,q) = H(p) + KL(p||q)
p = [0.5, 0.3, 0.2]
q = [0.4, 0.4, 0.2]
print("H(p)      :", round(entropy(p), 5))          # 1.48548
print("H(p,q)    :", round(cross_entropy(p, q), 5)) # 1.52193
print("KL(p||q)  :", round(kl(p, q), 5))            # 0.03645
print("H(p,q)-H(p):", round(cross_entropy(p, q) - entropy(p), 5))  # 0.03645
CE (one-hot, nats): 0.35667
-ln(0.7)          : 0.35667
H(p)      : 1.48548
H(p,q)    : 1.52193
KL(p||q)  : 0.03645
H(p,q)-H(p): 0.03645

With a one-hot label, cross-entropy is exactly −ln(0.7) = 0.357 — the model is penalized purely by the probability it gave the correct class. In the general case H(p,q) − H(p) equals KL(p‖q) = 0.03645 to the digit, confirming the decomposition. This is why the training loss for every classifier and LLM is cross-entropy: minimizing it drives the model’s predictions toward the true distribution (and equals the MLE of A11).

KL is not symmetricD(p‖q) ≠ D(q‖p) — it is a divergence, not a distance. The direction matters: ‘forward’ KL D(data‖model) (what cross-entropy minimizes) is mean-seeking; reverse KL D(model‖data) (used in some RLHF/variational methods) is mode-seeking. Picking the wrong direction changes what the model learns.

3 · Perplexity — what the LLM number means advanced

Perplexity = exp(average negative log-likelihood) = exp(cross-entropy in nats). It is the cross-entropy loss made interpretable: a perplexity of K means the model is, on average, as uncertain as if it were choosing uniformly among K equally likely tokens. Lower is better. A model that predicts the actual next token with high probability has low perplexity; a clueless uniform model over a vocabulary of size V has perplexity V. It is the standard intrinsic metric for language models.

Try it — perplexity from per-token probabilities
python# stdlib only
import math

# The probability the model assigned to each ACTUAL next token of a sequence:
token_probs = [0.5, 0.1, 0.25, 0.4]
avg_nll = -sum(math.log(pt) for pt in token_probs) / len(token_probs)
ppl = math.exp(avg_nll)
print("avg NLL (nats):", round(avg_nll, 5))    # 1.32458
print("perplexity    :", round(ppl, 5))         # 3.7606

# Sanity check: a uniform model over vocab V has perplexity V.
V = 100
uniform_probs = [1.0 / V] * 10
ppl_u = math.exp(-sum(math.log(pt) for pt in uniform_probs) / len(uniform_probs))
print("uniform(V=100):", round(ppl_u, 5))       # 100.0
avg NLL (nats): 1.32458
perplexity    : 3.7606
uniform(V=100): 100.0

The model’s effective ‘branching factor’ on this sequence is 3.76 — as uncertain as picking among ~3.76 equally likely tokens each step. The sanity check confirms the meaning: a model that knows nothing beyond a uniform choice over 100 tokens has perplexity exactly 100. When you read ‘this LLM has perplexity 8 on the test set,’ it means an average branching factor of 8 — and it is just exp of the cross-entropy loss from §2.

4 · Mutual information intermediate

Mutual information I(X; Y) = Σ p(x,y) log(p(x,y)/(p(x)p(y))) measures how much knowing one variable reduces uncertainty about the other — it is the KL divergence between the joint and the product of marginals. I = 0 exactly when X and Y are independent. In AI it drives feature selection (keep features informative about the label), diagnostics, and information-theoretic probes of what a representation encodes.

Try it — mutual information of a correlated pair
python# stdlib only
import math

# Joint P(X,Y) over {0,1}x{0,1}: X and Y agree 80% of the time.
joint = [[0.4, 0.1],
         [0.1, 0.4]]
px = [sum(row) for row in joint]                       # marginal of X
py = [sum(joint[i][j] for i in range(2)) for j in range(2)]  # marginal of Y

mi = 0.0
for i in range(2):
    for j in range(2):
        if joint[i][j] > 0:
            mi += joint[i][j] * math.log(joint[i][j] / (px[i]*py[j]), 2)
print("marginals:", px, py)         # [0.5, 0.5] [0.5, 0.5]
print("MI (bits):", round(mi, 5))   # 0.27807  (>0 -> dependent)
marginals: [0.5, 0.5] [0.5, 0.5]
MI (bits): 0.27807

Each variable alone is a fair coin (marginals 0.5/0.5), but they agree 80% of the time, so knowing X tells you 0.278 bits about Y — positive mutual information means dependence. Make the joint the product of marginals (0.25 everywhere) and MI drops to 0. This is the quantity a feature selector maximizes to keep inputs that actually inform the label.

5 · Gradient descent from scratch advanced

Training minimizes a loss by gradient descent: repeatedly step the parameters in the direction of the negative gradient (steepest downhill), x ← x − η·∇f(x), where η is the learning rate. The gradient points uphill; negating it descends. The learning rate is the make-or-break knob: too small and it crawls, too large and it overshoots and diverges.

Try it — minimize f(x) = (x−3)², and watch a bad learning rate diverge
python# stdlib only
def f(x):    return (x - 3)**2
def grad(x): return 2 * (x - 3)          # df/dx, by hand

def descend(lr, steps):
    x = 0.0
    for _ in range(steps):
        x -= lr * grad(x)                # the update rule
    return x

good = descend(lr=0.1, steps=50)
print("lr=0.1 ->", round(good, 6), "f =", round(f(good), 8))   # ~3.0, ~0.0

bad = descend(lr=1.1, steps=20)          # too large: overshoots
print("lr=1.1 ->", round(bad, 3))        # blows up -> -112.013
lr=0.1 -> 2.999957 f = 0.0
lr=1.1 -> -112.013

With η=0.1 the parameter converges to the minimum x=3 (loss ~0). With η=1.1 each step overshoots the minimum by more than it started, so the iterate explodes to −112 and keeps growing — the classic diverging-loss symptom you see as NaN in training when the learning rate is too high. Tuning η (and schedules/warmup) is a core part of getting training to converge (see MS2).

params x current ∇f(x) gradient x - η∇f update lower loss repeat

6 · Convexity, SGD vs batch advanced

A function is convex if its graph curves up everywhere (second derivative ≥ 0) — then any local minimum is the global minimum and gradient descent is guaranteed to find it. (x−3)² is convex; deep networks are not, which is why training is an art. Batch GD uses the whole dataset per step (accurate gradient, expensive); stochastic GD (SGD) uses one example (noisy but cheap and can escape shallow traps); mini-batch — a handful at a time — is the practical middle ground every framework uses.

Try it — a convexity check and one SGD step vs one batch step
python# stdlib only
# Convex: second derivative constant and positive.
def f(x): return (x - 3)**2
h = 1e-5
def second_deriv(x):
    return (f(x+h) - 2*f(x) + f(x-h)) / h**2
print("f''(x) ~", round(second_deriv(0.0), 3), "-> convex (>0)")   # 2.0

# Linear model loss over 4 points; compare batch grad vs single-sample grad.
xs = [1.0, 2.0, 3.0, 4.0]
ys = [3.0, 5.0, 7.0, 9.0]           # y = 2x + 1
w = b = 0.0
# Batch gradient (averaged over ALL points):
n = len(xs)
gw = sum(2*((w*x+b)-y)*x for x, y in zip(xs, ys)) / n
gb = sum(2*((w*x+b)-y)   for x, y in zip(xs, ys)) / n
print("batch grad (w,b):", round(gw, 3), round(gb, 3))     # -35.0 -12.0
# SGD gradient (ONE random-ish point, here the first):
x0, y0 = xs[0], ys[0]
sgw, sgb = 2*((w*x0+b)-y0)*x0, 2*((w*x0+b)-y0)
print("sgd  grad (w,b):", round(sgw, 3), round(sgb, 3))    # -6.0 -6.0
f''(x) ~ 2.0 -> convex (>0)
batch grad (w,b): -35.0 -12.0
sgd  grad (w,b): -6.0 -6.0

The numeric second derivative is 2 everywhere — positive, so the loss is convex and GD will find the global minimum. The batch gradient averages all four points (−35, −12); the SGD gradient from a single point (−6, −6) is a cheaper, noisier estimate of the same direction. Real training uses mini-batches: enough samples to smooth the noise, few enough to fit in memory and step often (MS1).

7 · The chain rule & backprop — tiny, end-to-end expert expert

Backpropagation is the chain rule applied backwards through a computation to get the gradient of the loss with respect to every parameter. For the loss L = (w·x + b − y)², the chain rule gives ∂L/∂w = 2(pred−y)·x and ∂L/∂b = 2(pred−y). Below we train a 1-feature linear model end-to-end with these hand-derived gradients — deliberately the smallest case so you can see every number, but the exact mechanism (forward pass → loss → gradients → update) that scales to billion-parameter nets (the autograd engine of A4).

Try it — derive gradients, then train a linear model to fit y = 2x + 1
python# stdlib only  (deliberately a 1-feature model so every step is visible)
xs = [1.0, 2.0, 3.0, 4.0]
ys = [3.0, 5.0, 7.0, 9.0]           # target relationship: y = 2x + 1

w, b = 0.0, 0.0
lr = 0.01
for epoch in range(1000):
    dw = db = 0.0
    n = len(xs)
    for x, y in zip(xs, ys):
        pred = w * x + b            # forward pass
        err  = pred - y             # dL/dpred = 2*err (MSE)
        dw += 2 * err * x / n       # chain rule: dL/dw
        db += 2 * err / n           # chain rule: dL/db
    w -= lr * dw                    # gradient-descent update
    b -= lr * db

loss = sum((w*x + b - y)**2 for x, y in zip(xs, ys)) / len(xs)
print("learned w, b:", round(w, 4), round(b, 4))   # ~2.0, ~1.0
print("final MSE   :", round(loss, 6))             # ~0.0
learned w, b: 2.0049 0.9857
final MSE   : 3.4e-05

Starting from w=b=0, 1000 gradient steps recover w≈2.00 and b≈0.99 — the true y = 2x + 1 relationship — driving the MSE to essentially zero. Every step did exactly what an LLM does per batch: run the forward pass, compute the loss, backprop the gradients via the chain rule, and nudge the parameters downhill. Swap the hand-derived gradients for an autograd engine and this scales to the whole network.

Info-theory termFormulaAI role
Entropy H(p)−Σ p log puncertainty floor / #bits
Cross-entropy H(p,q)−Σ p log qthe training/eval loss
KL divergence D(p‖q)Σ p log(p/q)mismatch cost; RLHF regularizer
Perplexityexp(cross-entropy)LLM intrinsic score (branching factor)
Mutual info I(X;Y)KL(joint‖product)feature selection, probing

Checkpoint expert

✓ Checkpoint — you can move on when you can…

  • Compute entropy and explain it as expected surprise in bits.
  • Compute cross-entropy and KL and state H(p,q) = H(p) + D(p‖q).
  • Explain why cross-entropy is the loss for classifiers and LLMs.
  • Compute perplexity and interpret it as an effective branching factor.
  • Compute mutual information and say when it is zero.
  • Implement gradient descent and explain how the learning rate causes divergence.
  • Define convexity and contrast SGD with batch GD.
  • Backprop the chain rule on a tiny model and train it to fit data.
✓ Knowledge check

An LLM reports perplexity 20 on a test set; a competitor reports 8 on the same set. In plain terms which is better, by how much in loss terms, and what does the number represent?

Show answer
Lower perplexity is better, so the perplexity-8 model wins. Perplexity is exp(cross-entropy in nats), so the cross-entropy losses are ln(20)≈3.00 vs ln(8)≈2.08 nats — the better model has ~0.92 nats lower loss per token. Interpretation: on average the perplexity-8 model is as uncertain as choosing among 8 equally likely next tokens, versus 20 for the other — it assigns much higher probability to the actual tokens.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Entropy of a distributionBeginner

Context: Entropy is the reference every loss is measured against; computing it (and finding the maximum) builds the core intuition of ‘expected surprise’.

Your task: Write entropy(p) in bits, then show a fair coin gives 1 bit and a uniform 4-way gives 2 bits, and that a biased coin has less entropy.

Requirements:

  • Use base-2 logs; treat 0·log0 as 0
  • Show fair coin = 1.0, uniform-4 = 2.0
  • Show a biased coin has entropy < 1
  • No numpy

💡 Hint: H = -sum(p*log2(p)) over positive p; uniform maximizes it.

Show solution

Entropy sums −p log₂ p over the positive probabilities.

import math
def entropy(p):
    return -sum(pi*math.log(pi, 2) for pi in p if pi > 0)

print(entropy([0.5, 0.5]))         # 1.0
print(entropy([0.25]*4))           # 2.0
print(round(entropy([0.9, 0.1]), 4))  # 0.469
1.0
2.0
0.469
Exercise 2 · Cross-entropy loss for a classifierIntermediate

Context: Cross-entropy with a one-hot label is the exact loss a classifier/LLM minimizes; reducing it to −log(prob of correct class) demystifies the training objective.

Your task: Compute the cross-entropy (in nats) of predictions against one-hot labels for two examples and confirm it equals −log of the probability assigned to the correct class.

Requirements:

  • Use natural log (nats)
  • Handle a one-hot label so CE = −log q(correct)
  • Show a confident-correct prediction has lower loss than a hesitant one
  • No numpy

💡 Hint: With a one-hot target only the correct class’s term survives.

Show solution

One-hot cross-entropy is just the negative log-probability of the true class.

import math
def ce(true, pred):
    return -sum(t*math.log(q) for t, q in zip(true, pred) if t > 0)

print(round(ce([1,0,0], [0.7,0.2,0.1]), 5))   # 0.35667  (-ln 0.7)
print(round(ce([1,0,0], [0.4,0.3,0.3]), 5))   # 0.91629  (-ln 0.4, worse)
print(round(-math.log(0.7), 5))               # 0.35667
0.35667
0.91629
0.35667
Exercise 3 · Perplexity of a language modelAdvanced

Context: Perplexity is the headline LM metric; computing it from per-token probabilities shows it is just exp of the cross-entropy and gives it a ‘branching factor’ meaning.

Your task: Given the probabilities a model assigned to the true next tokens of a sequence, compute the perplexity, and verify a uniform model over vocabulary V has perplexity V.

Requirements:

  • Average the negative log-likelihood, then exponentiate
  • Report perplexity for a given probability list
  • Verify uniform-over-V gives perplexity V
  • No numpy

💡 Hint: PPL = exp(mean(-log p_token)); uniform p = 1/V for every token.

Show solution

Perplexity is the exponential of the average per-token negative log-likelihood.

import math
def perplexity(token_probs):
    nll = -sum(math.log(p) for p in token_probs) / len(token_probs)
    return math.exp(nll)

print(round(perplexity([0.5, 0.1, 0.25, 0.4]), 5))   # 3.7606
V = 50
print(round(perplexity([1/V]*8), 5))                 # 50.0
3.7606
50.0
Exercise 4 · Gradient descent + learning-rate sweepExpert

Context: The learning rate decides whether training converges, crawls, or explodes; sweeping it on a convex loss makes the trade-off tangible.

Your task: Minimize f(x) = (x−3)² from x=0 for several learning rates and classify each as converging or diverging.

Requirements:

  • Implement the analytic gradient 2(x−3)
  • Run the same step count for lr in {0.01, 0.1, 0.9, 1.1}
  • Report the final x for each
  • Identify which learning rate diverges and why
  • No numpy

💡 Hint: For this loss, updates converge when 0 < lr < 1 and diverge for lr > 1.

Show solution

Small lr crawls, moderate lr converges, lr > 1 overshoots and diverges.

def grad(x): return 2*(x-3)
def run(lr, steps=60):
    x = 0.0
    for _ in range(steps): x -= lr*grad(x)
    return x

for lr in (0.01, 0.1, 0.9, 1.1):
    print(lr, round(run(lr), 4))
0.01 2.1073
0.1 3.0
0.9 3.0
1.1 -169039.5431

lr=0.01 hasn’t fully converged in 60 steps; 0.1 and 0.9 reach x=3; lr=1.1 diverges because each step overshoots the minimum by more than it started.

Exercise 5 · Mutual information for feature selectionProfessional

Context: Feature selection keeps inputs that carry information about the label; mutual information is the principled score, and I=0 means the feature is useless.

Your task: Compute the mutual information between a feature and a label from a joint distribution, and show a dependent pair has I>0 while an independent pair has I=0.

Requirements:

  • Derive marginals from the joint
  • Compute I(X;Y) in bits
  • Show a correlated joint gives I > 0
  • Show the product-of-marginals joint gives I = 0
  • No numpy

💡 Hint: I = sum p(x,y) log2( p(x,y) / (p(x)p(y)) ); independence makes every term 0.

Show solution

MI is the KL between the joint and the product of marginals; independence zeroes it.

import math
def mi(joint):
    px = [sum(r) for r in joint]
    py = [sum(joint[i][j] for i in range(len(joint))) for j in range(len(joint[0]))]
    tot = 0.0
    for i,row in enumerate(joint):
        for j,pij in enumerate(row):
            if pij > 0: tot += pij*math.log(pij/(px[i]*py[j]), 2)
    return tot

print(round(mi([[0.4,0.1],[0.1,0.4]]), 5))   # 0.27807 (dependent)
print(round(mi([[0.25,0.25],[0.25,0.25]]), 5))  # 0.0 (independent)
0.27807
0.0
Exercise 6 · Train a model end-to-end with backpropIndustry scenario

Context: This is training in miniature: forward pass, cross-entropy-style loss, hand-derived gradients, and gradient-descent updates — the exact loop that scales to real networks.

Your task: Train a 1-feature linear model with MSE by backprop to fit y = 2x + 1 from a zero init, and report the learned parameters and final loss. (Tiny by design; the mechanism is what matters.)

Requirements:

  • Forward pass pred = w·x + b
  • Backprop the MSE gradients dL/dw and dL/db
  • Update with a fixed learning rate over many epochs
  • Recover w≈2, b≈1 and near-zero loss
  • State that the loop is identical at scale (autograd instead of hand gradients)
  • No numpy

💡 Hint: dL/dw = mean(2(pred-y)x), dL/db = mean(2(pred-y)); step against them.

Show solution

Forward → loss → backprop gradients → update, repeated — the universal training loop.

xs = [1.0, 2.0, 3.0, 4.0]
ys = [3.0, 5.0, 7.0, 9.0]        # y = 2x + 1
w = b = 0.0
lr = 0.01
for _ in range(2000):
    n = len(xs)
    dw = sum(2*((w*x+b)-y)*x for x,y in zip(xs,ys)) / n
    db = sum(2*((w*x+b)-y)   for x,y in zip(xs,ys)) / n
    w -= lr*dw; b -= lr*db
loss = sum((w*x+b-y)**2 for x,y in zip(xs,ys)) / len(xs)
print(round(w,4), round(b,4), round(loss,8))
2.0002 0.9993 8e-08

w→2, b→1, loss→0. Replace the hand gradients with an autograd engine and the same loop trains a full network.

© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in