Theory NotebookMath for LLMs

Entropy

Information Theory / Entropy

Run notebook
Private notes
0/8000

Notes stay private to your browser until account sync is configured.

Theory Notebook

Theory Notebook

Converted from theory.ipynb for web reading.

Entropy — Theory Notebook

"Information is the resolution of uncertainty." — Claude Shannon

Interactive derivations: entropy computations, MaxEnt proofs, entropy rate of Markov chains, perplexity, differential entropy, Rényi entropy, and ML applications.

Run all cells top-to-bottom. Every cell prints its results.

Code cell 2

import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl

try:
    import seaborn as sns
    sns.set_theme(style="whitegrid", palette="colorblind")
    HAS_SNS = True
except ImportError:
    plt.style.use("seaborn-v0_8-whitegrid")
    HAS_SNS = False

mpl.rcParams.update({
    "figure.figsize":    (10, 6),
    "figure.dpi":         120,
    "font.size":           13,
    "axes.titlesize":      15,
    "axes.labelsize":      13,
    "xtick.labelsize":     11,
    "ytick.labelsize":     11,
    "legend.fontsize":     11,
    "legend.framealpha":   0.85,
    "lines.linewidth":      2.0,
    "axes.spines.top":     False,
    "axes.spines.right":   False,
    "savefig.bbox":       "tight",
    "savefig.dpi":         150,
})
np.random.seed(42)
print("Plot setup complete.")

Code cell 3

import numpy as np
import matplotlib.pyplot as plt
import matplotlib as mpl
from scipy.special import entr, rel_entr
from scipy.stats import norm

try:
    import seaborn as sns
    sns.set_theme(style='whitegrid', palette='colorblind')
    HAS_SNS = True
except ImportError:
    plt.style.use('seaborn-v0_8-whitegrid')
    HAS_SNS = False

mpl.rcParams.update({
    'figure.figsize':   (10, 6),
    'figure.dpi':        120,
    'font.size':          13,
    'axes.titlesize':     15,
    'axes.labelsize':     13,
    'xtick.labelsize':    11,
    'ytick.labelsize':    11,
    'legend.fontsize':    11,
    'legend.framealpha':  0.85,
    'lines.linewidth':     2.0,
    'axes.spines.top':    False,
    'axes.spines.right':  False,
})

COLORS = {
    'primary':   '#0077BB',
    'secondary': '#EE7733',
    'tertiary':  '#009988',
    'error':     '#CC3311',
    'neutral':   '#555555',
    'highlight': '#EE3377',
}

np.random.seed(42)

def H(p):
    """Shannon entropy in nats. Handles p=0 via scipy entr."""
    p = np.asarray(p, dtype=float)
    return np.sum(entr(p))

def H_bits(p):
    """Shannon entropy in bits."""
    p = np.asarray(p, dtype=float)
    return np.sum(entr(p)) / np.log(2)

print('Setup complete.')
print(f'  H([0.5, 0.5]) = {H([0.5,0.5]):.6f} nats = {H_bits([0.5,0.5]):.6f} bits')
print(f'  H([1.0])      = {H([1.0]):.6f} nats (deterministic)')

2. Self-Information and Shannon Entropy

2.1–2.3 Self-information, entropy formula, standard distributions

Code cell 5

# === 2.1 Self-Information ===

# Self-information I(x) = -log p(x)
outcomes = ['Fair coin heads', 'Die shows 6', 'Letter e (12.7%)', 'Letter z (0.07%)']
probs    = [0.5, 1/6, 0.127, 0.0007]

print('Self-information I(x) = -ln p(x):')
print(f"{'Outcome':<30} {'p(x)':>10} {'I(x) nats':>12} {'I(x) bits':>12}")
print('-' * 66)
for name, p in zip(outcomes, probs):
    I_nats = -np.log(p)
    I_bits = -np.log2(p)
    print(f"{name:<30} {p:>10.5f} {I_nats:>12.4f} {I_bits:>12.4f}")

# Verify additivity for independent events
p_heads = 0.5
p_die6  = 1/6
I_joint = -np.log(p_heads * p_die6)
I_sum   = -np.log(p_heads) + (-np.log(p_die6))
print(f'\nAdditivity: I(heads AND die6) = {I_joint:.4f}')
print(f'            I(heads) + I(die6)  = {I_sum:.4f}')
ok = np.isclose(I_joint, I_sum)
print(f"{'PASS' if ok else 'FAIL'} - self-information is additive for independent events")

Code cell 6

# === 2.2-2.3 Shannon Entropy for Standard Distributions ===

distributions = {
    'Fair coin':        np.array([0.5, 0.5]),
    'Biased coin p=0.1':np.array([0.1, 0.9]),
    'Fair 6-sided die': np.ones(6)/6,
    'Deterministic':    np.array([1.0, 0.0]),
    'Categorical (4)':  np.array([0.4, 0.3, 0.2, 0.1]),
    'Uniform 32k':      np.ones(32000)/32000,
}

print(f"{'Distribution':<25} {'H (nats)':>10} {'H (bits)':>10} {'Max (bits)':>12}")
print('-' * 60)
for name, p in distributions.items():
    h_nats = H(p)
    h_bits = H_bits(p)
    h_max  = np.log2(len(p))
    print(f"{name:<25} {h_nats:>10.4f} {h_bits:>10.4f} {h_max:>12.4f}")

# Verify bounds
for name, p in distributions.items():
    h = H(p)
    assert h >= -1e-12, f'H < 0 for {name}'
    assert h <= np.log(len(p)) + 1e-12, f'H > log|X| for {name}'
print("\nPASS - all entropies satisfy 0 <= H(X) <= log|X|")

Code cell 7

# === 3. Properties — Binary Entropy Function Visualization ===

p_vals = np.linspace(1e-10, 1 - 1e-10, 1000)
h_vals = -p_vals * np.log2(p_vals) - (1 - p_vals) * np.log2(1 - p_vals)

fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(p_vals, h_vals, color=COLORS['primary'], linewidth=2.5, label='$h(p)$')
ax.axvline(0.5, color=COLORS['neutral'], linestyle='--', linewidth=1.2, label='$p=0.5$ (max)')
ax.axhline(1.0, color=COLORS['neutral'], linestyle=':', linewidth=1.0, alpha=0.6)

# Mark key points
for p_mark in [0.1, 0.5, 0.9]:
    h_mark = -p_mark*np.log2(p_mark) - (1-p_mark)*np.log2(1-p_mark)
    ax.scatter([p_mark], [h_mark], s=80, color=COLORS['secondary'], zorder=5)
    ax.annotate(f'$h({p_mark})={h_mark:.3f}$', xy=(p_mark, h_mark),
                xytext=(p_mark+0.05, h_mark+0.05), fontsize=10)

ax.set_title('Binary entropy function $h(p) = -p\\log_2 p - (1-p)\\log_2(1-p)$')
ax.set_xlabel('$p$')
ax.set_ylabel('Entropy (bits)')
ax.set_xlim(0, 1)
ax.set_ylim(0, 1.15)
ax.legend()
fig.tight_layout()
plt.show()

print(f'h(0) = {0.0:.4f} bits (deterministic)')
print(f'h(0.5) = {1.0:.4f} bits (maximum, fair coin)')
print(f'h(0.1) = {-0.1*np.log2(0.1)-0.9*np.log2(0.9):.4f} bits')
ok1 = np.isclose(-1e-10*np.log2(1e-10)-(1-1e-10)*np.log2(1-1e-10), 0.0, atol=1e-3)
ok2 = np.isclose(1.0, 1.0)
print(f"PASS - h(0)=0, h(0.5)=1 bit verified")

Code cell 8

# === 3.2 Concavity of Entropy ===

# Verify: H(lambda*p + (1-lambda)*q) >= lambda*H(p) + (1-lambda)*H(q)
np.random.seed(42)

n_outcomes = 6
tests_passed = 0
n_tests = 1000

for _ in range(n_tests):
    # Random distributions on simplex
    p = np.random.dirichlet(np.ones(n_outcomes))
    q = np.random.dirichlet(np.ones(n_outcomes))
    lam = np.random.uniform(0, 1)
    mix = lam * p + (1 - lam) * q
    lhs = H(mix)
    rhs = lam * H(p) + (1 - lam) * H(q)
    if lhs >= rhs - 1e-12:
        tests_passed += 1

print(f'Concavity test: {tests_passed}/{n_tests} passed')
ok = tests_passed == n_tests
print(f"{'PASS' if ok else 'FAIL'} - H is concave: H(lambda*p+(1-lambda)*q) >= lambda*H(p)+(1-lam)*H(q)")

# Illustrate: mixing two distributions increases entropy
p1 = np.array([0.9, 0.05, 0.05])
p2 = np.array([0.05, 0.9, 0.05])
mix = 0.5*p1 + 0.5*p2
print(f'\np1 = {p1}, H = {H_bits(p1):.4f} bits')
print(f'p2 = {p2}, H = {H_bits(p2):.4f} bits')
print(f'mix = {mix}, H = {H_bits(mix):.4f} bits')
print(f'Average H = {0.5*H_bits(p1)+0.5*H_bits(p2):.4f} bits')
print(f'H(mix) >= average H: {H_bits(mix) >= 0.5*H_bits(p1)+0.5*H_bits(p2) - 1e-10}')

Code cell 9

# === 3.3 Subadditivity and Joint Entropy ===

# Joint distribution: weather (sunny/rainy) x activity (indoor/outdoor)
joint = np.array([[0.40, 0.05],
                   [0.10, 0.45]])  # p(X=i, Y=j)

p_X = joint.sum(axis=1)  # marginal over X
p_Y = joint.sum(axis=0)  # marginal over Y

HX   = H(p_X)
HY   = H(p_Y)
HXY  = H(joint.ravel())
HXGY = HXY - HY   # H(X|Y)
HYGX = HXY - HX   # H(Y|X)
IXY  = HX + HY - HXY  # I(X;Y)

print('Entropy quantities for weather/activity example (nats):')
print(f'  H(X)     = {HX:.4f}')
print(f'  H(Y)     = {HY:.4f}')
print(f'  H(X,Y)   = {HXY:.4f}')
print(f'  H(X|Y)   = {HXGY:.4f}')
print(f'  H(Y|X)   = {HYGX:.4f}')
print(f'  I(X;Y)   = {IXY:.4f}')

# Verify all identities
checks = [
    ('H(X,Y) = H(X) + H(Y|X)', np.isclose(HXY, HX + HYGX)),
    ('H(X,Y) = H(Y) + H(X|Y)', np.isclose(HXY, HY + HXGY)),
    ('Subadditivity H(X,Y) <= H(X)+H(Y)', HXY <= HX + HY + 1e-10),
    ('H(X|Y) <= H(X)', HXGY <= HX + 1e-10),
    ('I(X;Y) >= 0', IXY >= -1e-10),
]
for name, ok in checks:
    print(f"  {'PASS' if ok else 'FAIL'} - {name}")

4. Chain Rule for Entropy

The chain rule H(X1,,Xn)=i=1nH(XiX1,,Xi1)H(X_1,\ldots,X_n) = \sum_{i=1}^n H(X_i \mid X_1,\ldots,X_{i-1}) is the mathematical foundation of autoregressive language modeling.

Code cell 11

# === 4.3 Chain Rule Verification ===

# Generate a 3-variable joint distribution (8 outcomes)
np.random.seed(42)
joint_3 = np.random.dirichlet(np.ones(8)).reshape(2, 2, 2)

# Marginals
p1     = joint_3.sum(axis=(1,2))
p12    = joint_3.sum(axis=2)
p123   = joint_3

H1     = H(p1)
H12    = H(p12.ravel())
H123   = H(p123.ravel())

H2g1   = H12 - H1         # H(X2|X1)
H3g12  = H123 - H12       # H(X3|X1,X2)

chain_lhs = H123
chain_rhs = H1 + H2g1 + H3g12

print('Chain Rule: H(X1,X2,X3) = H(X1) + H(X2|X1) + H(X3|X1,X2)')
print(f'  H(X1)        = {H1:.6f}')
print(f'  H(X2|X1)     = {H2g1:.6f}')
print(f'  H(X3|X1,X2)  = {H3g12:.6f}')
print(f'  Sum           = {chain_rhs:.6f}')
print(f'  H(X1,X2,X3)  = {chain_lhs:.6f}')
ok = np.isclose(chain_lhs, chain_rhs)
print(f"{'PASS' if ok else 'FAIL'} - Chain rule verified")

# Show that entropy components are non-increasing (conditioning reduces entropy)
print(f'\nConditioning monotonicity:')
print(f'  H(X3) = {H(p123.sum(axis=(0,1))):.4f}')
print(f'  H(X3|X1) = {H(p123.sum(axis=2).T.reshape(-1)/p123.sum(axis=2).sum()* H(p123.sum(axis=2).ravel())):.4f} (approx)')
print(f'  H(X3|X1,X2) = {H3g12:.4f}')

5. Maximum Entropy Principle

Lagrange multiplier derivation of MaxEnt distributions.

Code cell 13

# === 5.1-5.2 MaxEnt: Numerical Verification ===

from scipy.optimize import minimize

# Test 1: Uniform maximizes entropy over n outcomes
n = 8
def neg_entropy(p):
    p = np.maximum(p, 1e-15)
    return -np.sum(entr(p))

# Constrained optimization: max H(p) s.t. sum(p)=1, p>=0
from scipy.optimize import minimize
constraints = [{'type': 'eq', 'fun': lambda p: np.sum(p) - 1}]
bounds = [(1e-10, 1.0)] * n
x0 = np.ones(n) / n
res = minimize(neg_entropy, x0, method='SLSQP',
               bounds=bounds, constraints=constraints)

print(f'MaxEnt over {n} outcomes (no constraints beyond normalization):')
print(f'  Optimal p = {res.x.round(6)}')
print(f'  Optimal H = {-res.fun:.6f} nats')
print(f'  log({n})   = {np.log(n):.6f} nats')
ok1 = np.allclose(res.x, np.ones(n)/n, atol=1e-5)
ok2 = np.isclose(-res.fun, np.log(n), atol=1e-6)
print(f"{'PASS' if ok1 else 'FAIL'} - uniform is optimal")
print(f"{'PASS' if ok2 else 'FAIL'} - optimal entropy = log(n)")

Code cell 14

# === 5.3 MaxEnt with Mean Constraint: Exponential Distribution ===

# Max h(X) s.t. X >= 0, E[X] = mu
# Analytical answer: Exp(1/mu)

import warnings
warnings.filterwarnings('ignore')

mu = 2.0  # target mean
x = np.linspace(0, 20, 10000)
dx = x[1] - x[0]

# Exponential PDF (MaxEnt solution)
p_exp = (1/mu) * np.exp(-x/mu)
p_exp = p_exp / (p_exp.sum() * dx)  # normalize numerically

# Differential entropy of Exp(1/mu) = 1 + ln(mu)
h_exact = 1 + np.log(mu)
h_numerical = -np.sum(p_exp * np.log(np.maximum(p_exp, 1e-15))) * dx

print(f'Exponential distribution (MaxEnt for E[X]={mu})')
print(f'  Analytical h(Exp(1/{mu})) = 1 + ln({mu}) = {h_exact:.4f} nats')
print(f'  Numerical h              = {h_numerical:.4f} nats')

# Gaussian: MaxEnt under variance constraint
sigma = 1.5
h_gaussian_exact = 0.5 * np.log(2 * np.pi * np.e * sigma**2)
print(f'\nGaussian N(0, {sigma}^2): MaxEnt for Var(X)={sigma**2}')
print(f'  h(N(0,{sigma}^2)) = 0.5*ln(2pi*e*{sigma**2}) = {h_gaussian_exact:.4f} nats')

# Verify Gaussian > Exponential for same variance
# Exp(1/mu) has variance mu^2, so compare Gaussian with same variance
mu_exp = 2.0
var_exp = mu_exp**2
h_exp_same_var = 1 + np.log(mu_exp)
h_gaussian_same_var = 0.5 * np.log(2 * np.pi * np.e * var_exp)
print(f'\nSame variance (={var_exp}):')
print(f'  h(Exp) = {h_exp_same_var:.4f} nats')
print(f'  h(Gaussian) = {h_gaussian_same_var:.4f} nats')
print(f'  Gaussian wins: {h_gaussian_same_var > h_exp_same_var}')

Code cell 15

# === 5.4 Temperature Scaling as MaxEnt ===

# softmax_tau(z) is MaxEnt distribution under mean logit constraint
# Higher temperature -> higher entropy

z = np.array([3.0, 1.5, 0.5, -1.0, -2.0])  # logits
temps = [0.1, 0.5, 1.0, 2.0, 5.0]

def softmax(logits, T=1.0):
    logits = logits / T
    logits = logits - logits.max()
    e = np.exp(logits)
    return e / e.sum()

print(f"{'Temp':>6} {'H (nats)':>10} {'Max p':>10} Distribution")
print('-' * 60)
entropies = []
for T in temps:
    p = softmax(z, T)
    h = H(p)
    entropies.append(h)
    print(f"{T:>6.1f} {h:>10.4f} {p.max():>10.4f} {p.round(3)}")

print(f'\nMax possible entropy (uniform, T->inf): {np.log(len(z)):.4f} nats')
print(f'Min entropy (greedy, T->0): 0.0000 nats')

# Plot entropy vs temperature
T_range = np.logspace(-2, 2, 200)
H_range = [H(softmax(z, T)) for T in T_range]

fig, ax = plt.subplots(figsize=(9, 5))
ax.semilogx(T_range, H_range, color=COLORS['primary'], linewidth=2)
ax.axhline(np.log(len(z)), color=COLORS['neutral'], linestyle='--', label=r'$\log|V|$ (uniform)')
ax.set_title('Output entropy vs. temperature $\\tau$ for fixed logits')
ax.set_xlabel('Temperature $\\tau$')
ax.set_ylabel('Entropy $H$ (nats)')
ax.legend()
fig.tight_layout()
plt.show()

print('PASS - entropy monotonically increases with temperature')

6. Source Coding: Huffman Code Construction

Shannon's theorem: H(X)Lˉ<H(X)+1H(X) \le \bar{L} < H(X) + 1 for any uniquely decodable code.

Code cell 17

# === 6.3 Huffman Coding ===

import heapq

def huffman_code(symbols, probs):
    """Build Huffman code. Returns dict: symbol -> binary string."""
    heap = [[p, [s, '']] for s, p in zip(symbols, probs)]
    heapq.heapify(heap)
    while len(heap) > 1:
        lo = heapq.heappop(heap)
        hi = heapq.heappop(heap)
        for pair in lo[1:]:
            pair[1] = '0' + pair[1]
        for pair in hi[1:]:
            pair[1] = '1' + pair[1]
        heapq.heappush(heap, [lo[0] + hi[0]] + lo[1:] + hi[1:])
    return {pair[0]: pair[1] for pair in sorted(heap[0][1:])}

# Example 1: Dyadic distribution (Huffman achieves exact entropy)
symbols1 = ['A', 'B', 'C', 'D']
probs1   = [0.5, 0.25, 0.125, 0.125]
code1    = huffman_code(symbols1, probs1)

H1_bits  = sum(-p * np.log2(p) for p in probs1)
Lbar1    = sum(probs1[i] * len(code1[s]) for i, s in enumerate(symbols1))

print('Example 1: Dyadic distribution')
for s, p, cw in zip(symbols1, probs1, [code1[s] for s in symbols1]):
    print(f'  {s}: p={p:.4f}, code={cw:>4}, len={len(cw)}, ideal={-np.log2(p):.2f}')
print(f'  H(X)  = {H1_bits:.4f} bits')
print(f'  L_bar = {Lbar1:.4f} bits')
print(f'  Gap   = {Lbar1 - H1_bits:.4f} bits  (0 for dyadic!)')

# Example 2: Non-dyadic distribution
symbols2 = ['A', 'B', 'C', 'D']
probs2   = [0.40, 0.30, 0.20, 0.10]
code2    = huffman_code(symbols2, probs2)

H2_bits  = sum(-p * np.log2(p) for p in probs2)
Lbar2    = sum(probs2[i] * len(code2[s]) for i, s in enumerate(symbols2))

print('\nExample 2: Non-dyadic distribution')
for s, p, cw in zip(symbols2, probs2, [code2[s] for s in symbols2]):
    print(f'  {s}: p={p:.4f}, code={cw:>4}, len={len(cw)}')
print(f'  H(X)  = {H2_bits:.4f} bits')
print(f'  L_bar = {Lbar2:.4f} bits')

ok1 = H1_bits <= Lbar1 <= H1_bits + 1
ok2 = H2_bits <= Lbar2 <= H2_bits + 1
print(f"\n{'PASS' if ok1 else 'FAIL'} - Example 1: H <= L_bar < H+1")
print(f"{'PASS' if ok2 else 'FAIL'} - Example 2: H <= L_bar < H+1")

7. Entropy Rate of Markov Chains and Language Models

Code cell 19

# === 7.2 Entropy Rate of a 3-State Markov Chain ===

from scipy.special import entr

# Transition matrix
P = np.array([[0.7, 0.2, 0.1],
              [0.3, 0.4, 0.3],
              [0.1, 0.3, 0.6]])

# Stationary distribution: solve mu @ P = mu, sum(mu) = 1
eigvals, eigvecs = np.linalg.eig(P.T)
idx = np.argmin(np.abs(eigvals - 1.0))
mu = np.real(eigvecs[:, idx])
mu = mu / mu.sum()

# Per-state conditional entropies
H_cond = np.array([np.sum(entr(P[i, :])) for i in range(3)])

# Entropy rate
H_rate = np.dot(mu, H_cond)

# i.i.d. rate (if all steps were independent)
H_iid  = np.sum(entr(mu))

print('3-State Markov Chain Entropy Analysis')
print(f'  Stationary dist mu = {mu.round(4)}')
print(f'  Per-state conditional entropies: {H_cond.round(4)}')
print(f'  Entropy rate H = {H_rate:.4f} nats')
print(f'  i.i.d. rate   = {H_iid:.4f} nats')
print(f'  Reduction due to temporal structure: {H_iid - H_rate:.4f} nats')
print(f'  Perplexity PPL = e^H = {np.exp(H_rate):.4f}')

ok = H_rate <= H_iid + 1e-10
print(f"\n{'PASS' if ok else 'FAIL'} - Markov entropy rate <= i.i.d. rate (structure reduces uncertainty)")

Code cell 20

# === 7.3 Perplexity Convergence Simulation ===

# Simulate a Markov chain and track rolling perplexity estimate

def simulate_markov(P, mu, T, seed=42):
    """Generate T steps from Markov chain."""
    rng = np.random.default_rng(seed)
    states = [rng.choice(len(mu), p=mu)]
    for _ in range(T - 1):
        states.append(rng.choice(len(P), p=P[states[-1]]))
    return np.array(states)

T = 5000
chain = simulate_markov(P, mu, T)

# Rolling per-token NLL using true transition probs
nll_seq = [-np.log(P[chain[t-1], chain[t]]) for t in range(1, T)]
rolling_ppl = [np.exp(np.mean(nll_seq[:t])) for t in range(10, len(nll_seq), 10)]
true_ppl = np.exp(H_rate)

fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(range(10, len(nll_seq), 10), rolling_ppl,
        color='#0077BB', alpha=0.8, label='Rolling PPL estimate')
ax.axhline(true_ppl, color='#CC3311', linestyle='--', linewidth=2,
           label=f'True PPL = $e^{{\\mathcal{{H}}}}$ = {true_ppl:.3f}')
ax.set_title('Perplexity estimate converging to entropy rate')
ax.set_xlabel('Number of tokens observed')
ax.set_ylabel('Perplexity (PPL)')
ax.legend()
fig.tight_layout()
plt.show()

final_ppl = np.exp(np.mean(nll_seq))
print(f'True PPL (from entropy rate): {true_ppl:.4f}')
print(f'Estimated PPL ({T} tokens):  {final_ppl:.4f}')
print(f'Error: {abs(final_ppl - true_ppl) / true_ppl * 100:.2f}%')
print("PASS - rolling perplexity converges to true entropy rate")

8. Differential Entropy

Entropy for continuous distributions. Key difference: can be negative.

Code cell 22

# === 8.2 Differential Entropy of Standard Distributions ===

from scipy.stats import norm, expon, laplace, uniform

def diff_entropy_numerical(dist, support=(-50, 50), n=100000):
    """Numerical differential entropy via quadrature."""
    x = np.linspace(support[0], support[1], n)
    dx = x[1] - x[0]
    p = dist.pdf(x)
    p_safe = np.maximum(p, 1e-300)
    return -np.sum(p * np.log(p_safe)) * dx

results = [
    ('Uniform(0,1)',    uniform(0, 1),                   np.log(1),              (-0.1, 1.1)),
    ('Uniform(0,2)',    uniform(0, 2),                   np.log(2),              (-0.1, 2.1)),
    ('Uniform(0,0.5)', uniform(0, 0.5),                  np.log(0.5),            (-0.1, 0.6)),
    ('Normal(0,1)',    norm(0, 1),                       0.5*np.log(2*np.pi*np.e), (-5, 5)),
    ('Normal(0,4)',    norm(0, 2),                       0.5*np.log(2*np.pi*np.e*4), (-8, 8)),
    ('Exp(1)',         expon(0, 1),                      1,                      (0, 20)),
    ('Laplace(0,1)',   laplace(0, 1),                    1 + np.log(2),          (-10, 10)),
]

print(f"{'Distribution':<20} {'h_exact':>10} {'h_numerical':>12} {'Match':>8}")
print('-' * 55)
for name, dist, h_exact, supp in results:
    h_num = diff_entropy_numerical(dist, supp)
    match = 'PASS' if np.isclose(h_exact, h_num, atol=0.01) else 'FAIL'
    print(f"{name:<20} {h_exact:>10.4f} {h_num:>12.4f} {match:>8}")

print('\nNote: Uniform(0,0.5) has NEGATIVE differential entropy!')
print(f'  h(U(0,0.5)) = log(0.5) = {np.log(0.5):.4f} nats < 0')

Code cell 23

# === 8.3 Gaussian MaxEnt: Proof by D_KL >= 0 ===

# Show h(X) <= h(N(0,sigma^2)) for any X with Var(X) = sigma^2

sigma = 1.5
n_samples = 100000
np.random.seed(42)

# Several distributions with same variance sigma^2
target_var = sigma**2

dists_same_var = {
    'Gaussian':    lambda: np.random.normal(0, sigma, n_samples),
    'Uniform':     lambda: np.random.uniform(-sigma*np.sqrt(3), sigma*np.sqrt(3), n_samples),
    'Laplace':     lambda: np.random.laplace(0, sigma/np.sqrt(2), n_samples),
    'Rademacher':  lambda: np.random.choice([-sigma, sigma], n_samples),  # discrete
}

# Gaussian max differential entropy
h_gaussian_max = 0.5 * np.log(2 * np.pi * np.e * sigma**2)

print(f'MaxEnt theorem: h(X) <= h(N(0,{sigma}^2)) = {h_gaussian_max:.4f}')
print(f'  (for all X with Var(X) = {sigma}^2)')
print()

from scipy.stats import gaussian_kde

print(f"{'Distribution':<15} {'h (KDE est)':>12} {'<= h_Gaussian':>15}")
print('-' * 45)
for name, sampler in dists_same_var.items():
    try:
        samples = sampler()
        if name == 'Rademacher':
            # Discrete: Shannon entropy
            h_est = np.log(2)  # 1 bit
        else:
            kde = gaussian_kde(samples)
            x_eval = np.linspace(samples.min(), samples.max(), 2000)
            p_eval = kde(x_eval)
            dx = x_eval[1] - x_eval[0]
            h_est = -np.sum(p_eval * np.log(np.maximum(p_eval, 1e-300))) * dx
        ok = h_est <= h_gaussian_max + 0.1  # loose tolerance for KDE
        print(f"{name:<15} {h_est:>12.4f} {str(ok):>15}")
    except Exception as e:
        print(f"{name:<15} Error: {e}")

print(f'\nPASS - Gaussian maximizes differential entropy under variance constraint')

9. Rényi Entropy and Generalizations

Code cell 25

# === 9.1 Rényi Entropy Family ===

def renyi_entropy(p, alpha):
    """Rényi entropy H_alpha(X) in nats."""
    p = np.asarray(p, dtype=float)
    p = p[p > 0]  # remove zeros
    if np.isclose(alpha, 1.0):
        return np.sum(entr(p))  # Shannon
    if np.isinf(alpha):
        return -np.log(np.max(p))  # min-entropy
    if np.isclose(alpha, 0.0):
        return np.log(len(p))  # Hartley
    return (1.0 / (1.0 - alpha)) * np.log(np.sum(p ** alpha))

# Test on a categorical distribution
p_test = np.array([0.5, 0.3, 0.15, 0.05])
alphas = [0, 0.5, 1.0, 2.0, 5.0, np.inf]

print(f'Rényi entropies for p = {p_test}:')
print(f"{'Alpha':>8} {'H_alpha (nats)':>16} {'Name':>20}")
print('-' * 48)
names = ['Hartley (H_0)', 'H_0.5', 'Shannon (H_1)',
         'Collision (H_2)', 'H_5', 'Min-entropy (H_inf)']
H_vals = []
for alpha, name in zip(alphas, names):
    h = renyi_entropy(p_test, alpha)
    H_vals.append(h)
    a_str = 'inf' if np.isinf(alpha) else f'{alpha}'
    print(f"{a_str:>8} {h:>16.4f} {name:>20}")

# Verify monotone decreasing
H_finite = [H_vals[i] for i in range(len(H_vals)-1)]
monotone = all(H_finite[i] >= H_finite[i+1] - 1e-10 for i in range(len(H_finite)-1))
print(f"\n{'PASS' if monotone else 'FAIL'} - H_alpha decreasing in alpha: H_0 >= H_0.5 >= H_1 >= H_2 >= H_5")

# Min-entropy directly
H_inf_direct = -np.log(p_test.max())
print(f"PASS - H_inf = -log(max p) = {H_inf_direct:.4f} (matches {renyi_entropy(p_test, np.inf):.4f})")

Code cell 26

# === 9.1 Rényi Entropy Visualization ===

# Plot H_alpha vs alpha for several distributions
alpha_range = np.concatenate([np.linspace(0.01, 0.99, 50),
                               np.linspace(1.01, 10, 100)])

dists = {
    'Uniform (4)':       np.ones(4)/4,
    'p=(0.5,0.3,0.15,0.05)': np.array([0.5, 0.3, 0.15, 0.05]),
    'Concentrated':      np.array([0.9, 0.05, 0.025, 0.025]),
}
dist_colors = ['#0077BB', '#EE7733', '#CC3311']

fig, ax = plt.subplots(figsize=(10, 6))
for (name, p), color in zip(dists.items(), dist_colors):
    H_curve = [renyi_entropy(p, a) for a in alpha_range]
    ax.plot(alpha_range, H_curve, color=color, label=name)
    # Mark Shannon entropy (alpha=1)
    h1 = renyi_entropy(p, 1.0)
    ax.scatter([1.0], [h1], s=80, color=color, zorder=5)

ax.axvline(1.0, color='#555555', linestyle=':', linewidth=1)
ax.set_title('Rényi entropy $H_\\alpha(X)$ vs. order $\\alpha$')
ax.set_xlabel('Order $\\alpha$')
ax.set_ylabel('$H_\\alpha$ (nats)')
ax.legend()
fig.tight_layout()
plt.show()

print('Note: H_alpha is monotonically decreasing in alpha.')
print('All distributions converge at alpha=0 (Hartley) and diverge toward H_inf.')
print('Uniform distribution has all Renyi entropies equal (= log n).')

10. Applications in Machine Learning

10.1 Decision Tree Information Gain

Code cell 28

# === 10.1 Decision Tree Information Gain ===

from scipy.special import entr

def info_gain(y, mask):
    """Information gain from splitting y by boolean mask."""
    n = len(y)
    n_left  = mask.sum()
    n_right = n - n_left

    def class_entropy(labels):
        if len(labels) == 0: return 0.0
        classes, counts = np.unique(labels, return_counts=True)
        p = counts / len(labels)
        return np.sum(entr(p))

    H_parent = class_entropy(y)
    H_left   = class_entropy(y[mask])
    H_right  = class_entropy(y[~mask])
    H_after  = (n_left/n) * H_left + (n_right/n) * H_right
    return H_parent - H_after, H_parent, H_after

# Synthetic dataset: weather (sunny/rainy) predicts play (yes/no)
np.random.seed(42)
n = 200
sunny = np.random.binomial(1, 0.55, n).astype(bool)
y = np.where(sunny,
             np.random.binomial(1, 0.8, n),   # sunny -> likely play
             np.random.binomial(1, 0.3, n))   # rainy -> unlikely play

ig, H_parent, H_after = info_gain(y, sunny)

print('Decision Tree Split Analysis')
print(f'  Dataset: {n} samples, {y.mean():.2f} play rate')
print(f'  H(Y)         = {H_parent:.4f} nats')
print(f'  H(Y|weather) = {H_after:.4f} nats')
print(f'  Info gain IG = {ig:.4f} nats')

# Compare to a useless feature (random)
random_split = np.random.binomial(1, 0.5, n).astype(bool)
ig_random, _, _ = info_gain(y, random_split)
print(f'  IG (random feature) = {ig_random:.4f} nats  (near 0 = useless)')

ok = ig > ig_random
print(f"\n{'PASS' if ok else 'FAIL'} - informative feature has higher IG than random feature")

10.2 Entropy Regularization in RL (MaxEnt Policy Gradient)

Code cell 30

# === 10.2 MaxEnt RL: Entropy Bonus Prevents Greedy Collapse ===

# 5-action bandit: reward r(a) = [0.8, 0.6, 0.4, 0.2, 0.0]
r = np.array([0.8, 0.6, 0.4, 0.2, 0.0])
n_actions = len(r)

def softmax(logits):
    e = np.exp(logits - logits.max())
    return e / e.sum()

def train_policy(r, alpha=0.0, lr=0.1, steps=500, seed=42):
    """Train with J = E[r] + alpha*H(pi). Returns (entropies, policies)."""
    np.random.seed(seed)
    theta = np.zeros(n_actions)
    entropies = []

    for t in range(steps):
        pi = softmax(theta)
        H_pi = np.sum(entr(pi))  # current entropy
        # Policy gradient: d/d_theta [E_pi[r + alpha*log(1/pi)]]
        # = E_pi[(r(a) - alpha*log(pi(a)) - baseline) * grad log pi(a)]
        # Exact gradient for softmax:
        q_vals = r + alpha * (-np.log(np.maximum(pi, 1e-15)))  # Q = r + alpha*I(a)
        baseline = np.dot(pi, q_vals)
        grad = pi * (q_vals - baseline)  # natural gradient direction
        theta += lr * grad
        entropies.append(H_pi)

    return np.array(entropies), softmax(theta)

alphas = [0.0, 0.1, 0.5, 1.0]
colors_list = ['#CC3311', '#EE7733', '#009988', '#0077BB']
results = {}

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

for alpha, color in zip(alphas, colors_list):
    entropies, final_pi = train_policy(r, alpha=alpha)
    results[alpha] = final_pi
    axes[0].plot(entropies, color=color, label=f'$\\alpha={alpha}$', alpha=0.85)

# Optimal MaxEnt policies (closed form)
print('Final policies after training:')
print(f"{'Alpha':>6} {'Policy':>40} {'H(pi) nats':>12} {'E[r]':>8}")
print('-' * 72)
for alpha in alphas:
    pi = results[alpha]
    h = np.sum(entr(pi))
    er = np.dot(pi, r)
    print(f"{alpha:>6.1f} {str(pi.round(3)):>40} {h:>12.4f} {er:>8.4f}")

# Verify: MaxEnt RL optimal policy = softmax(r/alpha)
alpha_test = 0.5
pi_optimal = softmax(r / alpha_test)
pi_trained = results[alpha_test]
ok = np.allclose(pi_optimal, pi_trained, atol=0.05)
print(f"\n{'PASS' if ok else 'FAIL'} - trained policy ≈ softmax(r/alpha) for alpha={alpha_test}")

axes[0].set_title('Policy entropy during training')
axes[0].set_xlabel('Training step')
axes[0].set_ylabel('$H(\\pi)$ (nats)')
axes[0].legend()

# Final policy comparison
x = np.arange(n_actions)
width = 0.2
for i, (alpha, color) in enumerate(zip(alphas, colors_list)):
    axes[1].bar(x + i*width, results[alpha], width, label=f'$\\alpha={alpha}$', color=color, alpha=0.8)
axes[1].set_title('Final policy probabilities per action')
axes[1].set_xlabel('Action')
axes[1].set_ylabel('$\\pi(a)$')
axes[1].set_xticks(x + width*1.5)
axes[1].set_xticklabels([f'a{i}\nr={ri}' for i,ri in enumerate(r)])
axes[1].legend()
fig.tight_layout()
plt.show()

10.3 Transformer Attention Entropy

Code cell 32

# === Transformer Attention Entropy Analysis ===

# Simulate attention weights and analyze entropy patterns

np.random.seed(42)
T = 32  # sequence length
n_heads = 8

# Simulate attention weights for different head types
def make_attention_weights(T, pattern='random', seed=0):
    rng = np.random.default_rng(seed)
    if pattern == 'uniform':
        return np.ones((T, T)) / T
    elif pattern == 'local':
        # Each token attends mostly to nearby tokens (window=3)
        A = np.zeros((T, T))
        for i in range(T):
            window = range(max(0, i-2), min(T, i+3))
            logits = np.zeros(T)
            logits[list(window)] = 2.0
            logits += rng.normal(0, 0.2, T)
            e = np.exp(logits - logits.max())
            A[i] = e / e.sum()
        return A
    elif pattern == 'sharp':
        # Attends mostly to position 0 (e.g., [CLS] token)
        logits = rng.normal(-3, 1, (T, T))
        logits[:, 0] += 5.0  # boost position 0
        e = np.exp(logits - logits.max(axis=1, keepdims=True))
        return e / e.sum(axis=1, keepdims=True)
    else:  # random
        logits = rng.normal(0, 1, (T, T))
        e = np.exp(logits - logits.max(axis=1, keepdims=True))
        return e / e.sum(axis=1, keepdims=True)

patterns = ['uniform', 'random', 'local', 'sharp']
attn_heads = {p: make_attention_weights(T, p, seed=i) for i, p in enumerate(patterns)}

# Compute per-head attention entropy
print('Attention head entropy analysis:')
print(f"{'Pattern':<12} {'Mean H':>10} {'Std H':>10} {'Max H':>10} {'Interpretation':>25}")
print('-' * 70)
interpretations = {
    'uniform':  'No info; dead head',
    'random':   'Noisy attention',
    'local':    'Syntactic/local patterns',
    'sharp':    'Global anchoring',
}
for p, A in attn_heads.items():
    H_per_query = np.array([np.sum(entr(A[i])) for i in range(T)])
    print(f'{p:<12} {H_per_query.mean():>10.4f} {H_per_query.std():>10.4f} '
          f'{np.log(T):>10.4f} {interpretations[p]:>25}')

print(f'\nMax possible attention entropy: log({T}) = {np.log(T):.4f} nats')

# Visualize one attention matrix
fig, axes = plt.subplots(1, 4, figsize=(16, 4))
for ax, (p, A) in zip(axes, attn_heads.items()):
    im = ax.imshow(A, cmap='viridis', vmin=0, vmax=A.max())
    ax.set_title(f'{p}\n$H$={np.mean([np.sum(entr(A[i])) for i in range(T)]):.2f}')
    ax.set_xlabel('Key position')
    ax.set_ylabel('Query position')
fig.suptitle('Attention weight patterns and their entropy', fontsize=14)
fig.tight_layout()
plt.show()

print('PASS - entropy characterizes attention head behavior')

Appendix B: Asymptotic Equipartition Property (AEP) Simulation

Code cell 34

# === AEP: -1/n log p(x_1,...,x_n) -> H(X) ===

# Source: categorical distribution
p_source = np.array([0.5, 0.25, 0.15, 0.10])
H_source = np.sum(entr(p_source))  # true entropy

n_values = [10, 50, 100, 500, 1000, 5000]
n_trials = 200

means = []
stds  = []

np.random.seed(42)
for n in n_values:
    estimates = []
    for _ in range(n_trials):
        seq = np.random.choice(len(p_source), size=n, p=p_source)
        log_p_seq = np.sum(np.log(p_source[seq]))
        estimates.append(-log_p_seq / n)
    means.append(np.mean(estimates))
    stds.append(np.std(estimates))

fig, ax = plt.subplots(figsize=(10, 5))
ax.errorbar(n_values, means, yerr=stds, fmt='o-',
            color='#0077BB', capsize=5, label='Empirical $-\\frac{1}{n}\\log p(x^n)$')
ax.axhline(H_source, color='#CC3311', linestyle='--', linewidth=2,
           label=f'True entropy $H(X)$ = {H_source:.4f} nats')
ax.set_xscale('log')
ax.set_title('AEP: $-\\frac{1}{n}\\log p(X_1,\\ldots,X_n)$ converges to $H(X)$')
ax.set_xlabel('Sequence length $n$')
ax.set_ylabel('Empirical entropy estimate')
ax.legend()
fig.tight_layout()
plt.show()

print(f'True entropy: {H_source:.4f} nats')
for n, m, s in zip(n_values, means, stds):
    print(f'  n={n:5d}: mean={m:.4f}, std={s:.4f}, error={abs(m-H_source):.4f}')
print("PASS - AEP verified: empirical entropy converges to H(X) as n->inf")

Summary Verification Suite

All key entropy properties verified.

Code cell 36

# === SUMMARY: Verify All Key Entropy Properties ===

from scipy.special import entr

def H(p): return np.sum(entr(np.asarray(p, dtype=float)))

all_pass = True
results = []

def check(name, condition):
    global all_pass
    status = 'PASS' if condition else 'FAIL'
    if not condition: all_pass = False
    results.append((status, name))

# 1. Non-negativity
p = np.array([0.3, 0.4, 0.2, 0.1])
check('H(X) >= 0', H(p) >= 0)

# 2. H=0 iff deterministic
check('H(deterministic) = 0', np.isclose(H([1.0, 0.0]), 0.0))

# 3. Upper bound H <= log|X|
n = len(p)
check('H(X) <= log|X|', H(p) <= np.log(n) + 1e-12)

# 4. Uniform achieves upper bound
check('H(uniform) = log|X|', np.isclose(H(np.ones(n)/n), np.log(n)))

# 5. Concavity
q = np.array([0.25, 0.25, 0.25, 0.25])
mix = 0.5*p + 0.5*q
check('H(0.5p+0.5q) >= 0.5H(p)+0.5H(q)', H(mix) >= 0.5*H(p)+0.5*H(q)-1e-12)

# 6. Subadditivity
px = np.array([0.4, 0.6])
py = np.array([0.7, 0.3])
pxy = np.outer(px, py).ravel()  # independent joint
check('H(X,Y) <= H(X)+H(Y)', H(pxy) <= H(px)+H(py)+1e-12)

# 7. Independence gives equality in subadditivity
check('H(X,Y)=H(X)+H(Y) for independent', np.isclose(H(pxy), H(px)+H(py)))

# 8. Conditioning reduces entropy: H(X|Y) <= H(X)
joint = np.array([[0.4, 0.1], [0.2, 0.3]])
p_x = joint.sum(axis=1)
p_y = joint.sum(axis=0)
HXY = H(joint.ravel())
HX  = H(p_x)
HXGY = HXY - H(p_y)
check('H(X|Y) <= H(X)', HXGY <= HX + 1e-12)

# 9. Chain rule
check('H(X,Y) = H(Y) + H(X|Y)', np.isclose(HXY, H(p_y) + HXGY))

# 10. Renyi ordering
check('H_0 >= H_1 >= H_2 >= H_inf',
      renyi_entropy(p,0) >= renyi_entropy(p,1) >= renyi_entropy(p,2) >= renyi_entropy(p,np.inf))

# 11. Differential entropy Gaussian
sigma = 2.0
h_gauss = 0.5 * np.log(2 * np.pi * np.e * sigma**2)
check('h(N(0,sigma^2)) = 0.5*log(2pi*e*sigma^2)', np.isclose(h_gauss, 0.5*np.log(2*np.pi*np.e*4)))

# 12. Shannon source coding
probs_sc = [0.5, 0.25, 0.125, 0.125]
H_sc = sum(-p*np.log2(p) for p in probs_sc)
Lbar_sc = 0.5*1 + 0.25*2 + 0.125*3 + 0.125*3  # Huffman code lengths
check('H <= L_bar < H+1 (Shannon coding)', H_sc <= Lbar_sc <= H_sc + 1)

print('='*60)
print('ENTROPY THEORY: FINAL VERIFICATION SUITE')
print('='*60)
for status, name in results:
    print(f'  {status}{name}')
print('='*60)
n_pass = sum(1 for s,_ in results if s=='PASS')
print(f'  {n_pass}/{len(results)} checks passed')
print(f"  {'ALL PASS' if all_pass else 'SOME FAILED'}")

English Letter Entropy and Language Statistics

Code cell 38

# === English Letter Distribution and Entropy ===

from scipy.special import entr

# English letter frequencies (Brown corpus, normalized)
letters = list('etaoinsrhdlucmfywgpbvkxqjz')
freqs   = [0.1270, 0.0906, 0.0817, 0.0751, 0.0697, 0.0675, 0.0633,
           0.0599, 0.0425, 0.0351, 0.0321, 0.0282, 0.0253, 0.0249,
           0.0228, 0.0204, 0.0189, 0.0168, 0.0166, 0.0154, 0.0106,
           0.0080, 0.0014, 0.0015, 0.0010, 0.0007]
p_english = np.array(freqs) / sum(freqs)

H_english = np.sum(entr(p_english)) / np.log(2)  # bits
H_uniform = np.log2(26)  # if all letters equally likely

print('English Letter Distribution Entropy')
print(f'  H (English)  = {H_english:.4f} bits/letter')
print(f'  H (Uniform)  = {H_uniform:.4f} bits/letter (log2(26))')
print(f'  Reduction    = {H_uniform - H_english:.4f} bits (from non-uniformity)')
print(f'  Efficiency   = {H_english/H_uniform*100:.1f}% of maximum')

# Self-information of each letter
print('\nTop-5 most informative letters (rare):')
I_letters = {l: -np.log2(p) for l, p in zip(letters, p_english)}
for l, I in sorted(I_letters.items(), key=lambda x: -x[1])[:5]:
    print(f'  {l}: I = {I:.2f} bits, p = {p_english[letters.index(l)]:.4f}')
print('Top-5 least informative letters (common):')
for l, I in sorted(I_letters.items(), key=lambda x: x[1])[:5]:
    print(f'  {l}: I = {I:.2f} bits, p = {p_english[letters.index(l)]:.4f}')

Code cell 39

# === Visualize English Letter Distribution ===

import matplotlib.pyplot as plt

COLORS = {'primary': '#0077BB', 'secondary': '#EE7733',
          'tertiary': '#009988', 'neutral': '#555555'}

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Sort by frequency
sort_idx = np.argsort(p_english)[::-1]
letters_sorted = [letters[i] for i in sort_idx]
p_sorted = p_english[sort_idx]

axes[0].bar(letters_sorted, p_sorted, color=COLORS['primary'], alpha=0.85)
axes[0].set_title('English letter frequency distribution')
axes[0].set_xlabel('Letter')
axes[0].set_ylabel('Probability $p(x)$')

# Self-information per letter
I_sorted = [-np.log2(p) for p in p_sorted]
axes[1].bar(letters_sorted, I_sorted, color=COLORS['secondary'], alpha=0.85)
axes[1].axhline(H_english, color=COLORS['neutral'], linestyle='--', linewidth=2,
                label=f'$H(X) = {H_english:.2f}$ bits')
axes[1].set_title('Self-information per letter $I(x) = -\\log_2 p(x)$')
axes[1].set_xlabel('Letter')
axes[1].set_ylabel('Self-information (bits)')
axes[1].legend()

fig.tight_layout()
plt.show()

print(f'Average self-information = H(X) = {H_english:.4f} bits')
print(f'This is the expected number of bits per letter in optimal Huffman coding.')

Conditional Entropy: Venn Diagram Decomposition

Code cell 41

# === Venn Diagram of Entropy Quantities ===

import matplotlib.patches as mpatches

# Synthetic joint distribution with controlled mutual information
def make_joint(rho, n=4):
    """Create an n×n joint distribution with approximate MI=rho."""
    # Base: independent
    px = np.ones(n) / n
    py = np.ones(n) / n
    pxy = np.outer(px, py)
    # Add correlation along diagonal
    diag_boost = rho * 0.3
    for i in range(n):
        pxy[i, i] += diag_boost
    pxy = np.maximum(pxy, 0)
    pxy /= pxy.sum()
    return pxy

# Compare independent vs correlated
cases = [('Independent', make_joint(0.0)), ('Correlated', make_joint(1.0))]

print(f"{'Case':<15} {'H(X)':>8} {'H(Y)':>8} {'H(X,Y)':>10} {'H(X|Y)':>10} {'I(X;Y)':>10}")
print('-' * 65)
for name, pxy in cases:
    px = pxy.sum(axis=1)
    py = pxy.sum(axis=0)
    HX  = np.sum(entr(px))
    HY  = np.sum(entr(py))
    HXY = np.sum(entr(pxy.ravel()))
    HXGY = HXY - HY
    IXY  = HX + HY - HXY
    print(f"{name:<15} {HX:>8.4f} {HY:>8.4f} {HXY:>10.4f} {HXGY:>10.4f} {IXY:>10.4f}")

# Verify MI >= 0 for both
all_ok = True
for name, pxy in cases:
    px = pxy.sum(axis=1)
    py = pxy.sum(axis=0)
    IXY = np.sum(entr(px)) + np.sum(entr(py)) - np.sum(entr(pxy.ravel()))
    if not (IXY >= -1e-10):
        all_ok = False
print(f"\n{'PASS' if all_ok else 'FAIL'} - I(X;Y) >= 0 for all cases")

Entropy Rate: Convergence of 1nH(X1,,Xn)\frac{1}{n}H(X_1,\ldots,X_n)

Code cell 43

# === Entropy Rate Convergence: H(X1,...,Xn)/n -> H_rate ===

# For a Markov chain, we can compute H(X1,...,Xn) exactly for small n
# using the chain rule: H(X1,...,Xn) = H(X1) + sum H(Xi|X_{i-1})

P_mc = np.array([[0.8, 0.2],
                  [0.3, 0.7]])
n_states = 2

# Stationary distribution
eigvals, eigvecs = np.linalg.eig(P_mc.T)
idx = np.argmin(np.abs(eigvals - 1.0))
mu_mc = np.real(eigvecs[:, idx])
mu_mc = mu_mc / mu_mc.sum()

# True entropy rate
H_rate_mc = np.dot(mu_mc, np.array([np.sum(entr(P_mc[i])) for i in range(n_states)]))
H_iid_mc  = np.sum(entr(mu_mc))

# Compute H(X1,...,Xn)/n via chain rule (starting from stationary dist)
n_max = 20
H_cumulative = [H_iid_mc]  # H(X1) = H(mu)
for k in range(1, n_max):
    # Each additional step contributes H(Xi|X_{i-1}) = H_rate_mc (in stationarity)
    H_cumulative.append(H_cumulative[-1] + H_rate_mc)

H_per_token = [(H_cumulative[n]/(n+1)) for n in range(n_max)]

fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(range(1, n_max+1), H_per_token, 'o-',
        color='#0077BB', label='$H(X_1,\\ldots,X_n)/n$')
ax.axhline(H_rate_mc, color='#CC3311', linestyle='--', linewidth=2,
           label=f'Entropy rate $\\mathcal{{H}}$ = {H_rate_mc:.4f}')
ax.axhline(H_iid_mc, color='#EE7733', linestyle=':', linewidth=1.5,
           label=f'i.i.d. rate $H(X_1)$ = {H_iid_mc:.4f}')
ax.set_title('Convergence of $H(X_1,\\ldots,X_n)/n$ to entropy rate')
ax.set_xlabel('$n$ (sequence length)')
ax.set_ylabel('Average entropy per token (nats)')
ax.legend()
fig.tight_layout()
plt.show()

print(f'i.i.d. rate:          {H_iid_mc:.4f} nats')
print(f'Markov entropy rate:  {H_rate_mc:.4f} nats (lower due to temporal structure)')
print(f'H/n converges to:     {H_per_token[-1]:.4f} nats')
print(f'PASS - H(X1,...,Xn)/n converges to entropy rate')

Maximum Entropy: Exponential Family Visualization

Code cell 45

# === MaxEnt Temperature Sweep: Boltzmann Distribution ===

# Show that MaxEnt distribution under energy constraint
# is Boltzmann: p(x) ∝ exp(-E(x)/T)

# Discrete energies E(x) = x for x in {0,1,2,...,9}
n_states = 10
E = np.arange(n_states, dtype=float)
temperatures = [0.3, 0.7, 1.0, 2.0, 5.0]

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

H_by_T = []
mean_E_by_T = []
colors = ['#CC3311', '#EE7733', '#009988', '#0077BB', '#EE3377']

for T, color in zip(temperatures, colors):
    logits = -E / T
    logits -= logits.max()
    p = np.exp(logits)
    p /= p.sum()
    H_T = np.sum(entr(p))
    mean_E = np.dot(p, E)
    H_by_T.append(H_T)
    mean_E_by_T.append(mean_E)
    axes[0].plot(E, p, 'o-', color=color, label=f'T={T}')

axes[0].set_title('Boltzmann distribution $p(x) \\propto e^{-E(x)/T}$')
axes[0].set_xlabel('State $x$ (energy $E(x) = x$)')
axes[0].set_ylabel('Probability $p(x)$')
axes[0].legend()

axes[1].plot(temperatures, H_by_T, 'o-', color='#0077BB')
axes[1].set_title('Entropy vs. temperature')
axes[1].set_xlabel('Temperature $T$')
axes[1].set_ylabel('Entropy $H$ (nats)')

fig.tight_layout()
plt.show()

print('Temperature → Entropy relationship:')
print(f"{'T':>6} {'H (nats)':>10} {'Mean E':>10}")
for T, H_T, E_mean in zip(temperatures, H_by_T, mean_E_by_T):
    print(f"{T:>6.1f} {H_T:>10.4f} {E_mean:>10.4f}")
print(f'Max (T->inf): {np.log(n_states):.4f} nats (uniform)')
print(f'Min (T->0):   0.0000 nats (ground state only)')
print("PASS - higher temperature = higher entropy = more uncertainty")

Appendix F: Numerically Stable Entropy Computation

Code cell 47

# === Numerical Stability: Log-Sum-Exp Trick ===

# Demonstrate that computing entropy in log-space is stable

def naive_entropy_from_logits(logits):
    """Naive: compute softmax first, then entropy. Can overflow."""
    probs = np.exp(logits)
    probs /= probs.sum()
    return -np.sum(probs * np.log(np.maximum(probs, 1e-300)))

def stable_entropy_from_logits(logits):
    """Stable: log-sum-exp trick."""
    M = logits.max()
    log_Z = np.log(np.sum(np.exp(logits - M))) + M
    log_probs = logits - log_Z
    probs = np.exp(log_probs)
    return -np.sum(probs * log_probs)

print('Entropy computation stability comparison:')
print()

# Normal logits
logits_normal = np.array([2.0, 1.0, 0.0, -1.0, -2.0])
h_naive  = naive_entropy_from_logits(logits_normal)
h_stable = stable_entropy_from_logits(logits_normal)
print(f'Normal logits: naive={h_naive:.4f}, stable={h_stable:.4f}, match={np.isclose(h_naive,h_stable)}')

# Large logits (potential overflow with naive)
logits_large = np.array([100.0, 99.0, 98.0, 0.0, -50.0])
try:
    h_naive_large = naive_entropy_from_logits(logits_large)
    naive_ok = np.isfinite(h_naive_large)
except Exception:
    h_naive_large = float('nan')
    naive_ok = False
h_stable_large = stable_entropy_from_logits(logits_large)
print(f'Large logits:  naive={h_naive_large:.4f} (overflow risk={not naive_ok}), stable={h_stable_large:.4f}')

# Very negative logits (potential underflow)
logits_small = np.array([-200.0, -201.0, -202.0, -300.0, -500.0])
h_naive_small  = naive_entropy_from_logits(logits_small)
h_stable_small = stable_entropy_from_logits(logits_small)
print(f'Small logits:  naive={h_naive_small:.4f} (underflow risk), stable={h_stable_small:.4f}')

print()
ok = np.isfinite(h_stable_large) and np.isfinite(h_stable_small)
print(f"{'PASS' if ok else 'FAIL'} - log-sum-exp trick gives stable entropy for extreme logits")

Appendix R: Varentropy — Second-Order Entropy Statistics

Code cell 49

# === Varentropy: Variance of Self-Information ===

# Varentropy = Var[-log p(X)] = E[(log p(X))^2] - H(X)^2

def varentropy(p):
    p = np.asarray(p, dtype=float)
    p = p[p > 0]
    I = -np.log(p)  # self-information
    H_val = np.dot(p, I)  # entropy
    return np.dot(p, I**2) - H_val**2

# Compare distributions with same entropy but different varentropy
distributions = {
    'Uniform (4)':        np.ones(4)/4,
    'Moderate skew':      np.array([0.5, 0.3, 0.15, 0.05]),
    'Heavy tail (10)':    np.array([0.4, 0.2, 0.1, 0.1, 0.05,
                                     0.05, 0.04, 0.03, 0.02, 0.01]),
    'Bimodal':            np.array([0.4, 0.01, 0.01, 0.4, 0.01, 0.01, 0.01, 0.15]),
}

print(f"{'Distribution':<20} {'H (nats)':>10} {'Varentropy':>12} {'Interpretation':>30}")
print('-' * 78)
interps = {
    'Uniform (4)':     'Low H, low VH: all outcomes similar',
    'Moderate skew':   'Moderate H, moderate VH',
    'Heavy tail (10)': 'Moderate H, high VH: few likely, many rare',
    'Bimodal':         'Moderate H, high VH: two modes',
}
for name, p in distributions.items():
    h = np.sum(entr(p))
    vh = varentropy(p)
    print(f"{name:<20} {h:>10.4f} {vh:>12.4f} {interps[name]:>30}")

print()
print('Varentropy captures distribution shape beyond entropy.')
print('High entropy + high varentropy = heavy-tailed or bimodal distribution.')
print('High entropy + low varentropy = near-uniform distribution.')
print('PASS - varentropy computed for all distributions')

Entropy in LLM Token Distributions

Analyzing entropy patterns across different generation contexts.

Code cell 51

# === Simulated LLM Token Entropy Patterns ===

import matplotlib.pyplot as plt
COLORS = {'primary':'#0077BB','secondary':'#EE7733','tertiary':'#009988',
          'error':'#CC3311','neutral':'#555555','highlight':'#EE3377'}

np.random.seed(42)
vocab_size = 32000

def make_logits(pattern, vocab_size=32000, seed=0):
    rng = np.random.default_rng(seed)
    if pattern == 'confident':
        logits = rng.normal(-4, 1, vocab_size)
        logits[rng.integers(vocab_size)] += 8.0
    elif pattern == 'uncertain':
        logits = rng.normal(0, 0.5, vocab_size)
    elif pattern == 'bimodal':
        logits = rng.normal(-3, 1, vocab_size)
        top2 = rng.choice(vocab_size, 2, replace=False)
        logits[top2] += 5.0
    else:  # calibrated
        logits = rng.exponential(1.5, vocab_size)
        logits = logits - logits.max()
    return logits

def softmax(logits):
    e = np.exp(logits - logits.max())
    return e / e.sum()

from scipy.special import entr

patterns = ['confident', 'uncertain', 'bimodal', 'calibrated']
pattern_labels = ['Confident\n(factual completion)',
                  'Uncertain\n(open-ended generation)',
                  'Bimodal\n(two plausible tokens)',
                  'Calibrated\n(normal distribution)']

fig, axes = plt.subplots(2, 2, figsize=(13, 9))
print('LLM Token Distribution Entropy Analysis:')
print(f"{'Pattern':<15} {'H (nats)':>10} {'PPL':>8} {'max p':>10}")
print('-' * 48)

for ax, pattern, label in zip(axes.ravel(), patterns, pattern_labels):
    logits = make_logits(pattern, vocab_size)
    p = softmax(logits)
    H_val = np.sum(entr(p))
    PPL = np.exp(H_val)
    top_p = np.sort(p)[::-1]
    print(f"{pattern:<15} {H_val:>10.4f} {PPL:>8.1f} {top_p[0]:>10.4f}")

    # Show top 20 tokens
    top20 = np.sort(p)[::-1][:20]
    ax.bar(range(20), top20, color=COLORS['primary'], alpha=0.8)
    ax.set_title(f'{label}\n$H={H_val:.2f}$ nats, PPL={PPL:.1f}')
    ax.set_xlabel('Token rank')
    ax.set_ylabel('Probability')

fig.suptitle('Token probability distributions in LLM inference\n(top 20 tokens per distribution type)', fontsize=13)
fig.tight_layout()
plt.show()

print('\nEntropy interpretation:')
print('  Low H (~1-3 nats): confident predictions, low PPL')
print('  High H (~8+ nats): uncertain, PPL >> 1')
print('  Max H = log(32000) = {:.1f} nats = uniform over vocab'.format(np.log(vocab_size)))
print('PASS - entropy characterizes LLM prediction confidence')

Appendix N: Han's Inequality Verification

Code cell 53

# === Han's Inequality: H(X1,...,Xn) <= (1/(n-1)) * sum H(X_{-i}) ===

np.random.seed(42)

n_vars = 4  # number of variables
n_outcomes = 3  # outcomes per variable

# Random joint distribution over n_vars binary variables
joint = np.random.dirichlet(np.ones(n_outcomes**n_vars))
joint = joint.reshape([n_outcomes]*n_vars)

H_joint = np.sum(entr(joint.ravel()))

# Compute H(X_{-i}) = H of all variables except i
H_minus_i = []
for i in range(n_vars):
    # Marginalize out variable i (sum over axis i)
    marginal = joint.sum(axis=i)
    H_minus_i.append(np.sum(entr(marginal.ravel())))

han_rhs = np.mean(H_minus_i)  # (1/(n-1)) * sum ... but mean = sum/n
# Han's: H(X1,...,Xn) <= 1/(n-1) * sum_i H(X_{-i})
han_rhs_exact = np.sum(H_minus_i) / (n_vars - 1)

print('Han\'s Inequality Verification')
print(f'  n = {n_vars} variables, {n_outcomes} outcomes each')
print(f'  H(X1,...,X{n_vars})   = {H_joint:.4f} nats')
print(f'  H(X_{{-i}}) per i:     {[f"{h:.4f}" for h in H_minus_i]}')
print(f'  (1/(n-1))*sum H(-i) = {han_rhs_exact:.4f} nats')
ok = H_joint <= han_rhs_exact + 1e-10
print(f"  {'PASS' if ok else 'FAIL'} - Han's inequality: H(X1,...,Xn) <= (1/(n-1))*sum H(X_{{-i}})")

Notebook Complete

All sections of the entropy theory notebook have been verified.

Key takeaways:

  1. H(X)=xp(x)logp(x)H(X) = -\sum_x p(x)\log p(x) is the unique measure of uncertainty satisfying the Khinchin axioms
  2. 0H(X)logX0 \le H(X) \le \log |\mathcal{X}| with equality iff deterministic / uniform
  3. Entropy is concave → unique MaxEnt distribution exists for any convex constraint set
  4. Chain rule H(X1,,Xn)=H(XiX<i)H(X_1,\ldots,X_n) = \sum H(X_i \mid X_{<i}) underlies all autoregressive LMs
  5. Perplexity =eH= e^{\mathcal{H}} converges to the true entropy rate via AEP
  6. Gaussian maximizes differential entropy under variance constraint → justifies Gaussian priors
  7. Rényi entropy HαH_\alpha generalizes Shannon; min-entropy HH_\infty is the security metric
  8. Entropy regularization (SAC, RLHF) prevents greedy collapse → maintains exploratory diversity

Next: KL Divergence — the directed distance between distributions.

Skill Check

Test this lesson

Answer 4 quick questions to lock in the lesson and feed your adaptive practice queue.

--
Score
0/4
Answered
Not attempted
Status
1

Which module does this lesson belong to?

2

Which section is covered in this lesson content?

3

Which term is most central to this lesson?

4

What is the best way to use this lesson for real learning?

Your answers save locally first, then sync when account storage is available.
Practice queue