VKVlad KulikovPublic / lectures
Information Theory for ML2 / 16

Module 2. Entropy

Try each exercise first. Expand worked answers or project guidance when needed; their open state is shared between RU/EN.

Theoretical

Download course files (.zip) 33.9 KB

The archive preserves the folder structure of nine Python solution files shared by RU/EN.

Exercise 1. Binary entropy

For

h2(p)=−plog⁡2p−(1−p)log⁡2(1−p),h_2(p)=-p\log_2p-(1-p)\log_2(1-p),

show that:

  1. h2(p)=h2(1−p)h_2(p)=h_2(1-p) and h2(1/2)=1h_2(1/2)=1 bit;

  2. for 0<p<10<p<1, h2′(p)=log⁡21−pp;h_2'(p)=\log_2\frac{1-p}{p};

  3. h2h_2 is strictly concave and has a unique maximum at p=1/2p=1/2.

Show answer and solution

Recall that

h2(p)=−plog⁡2p−(1−p)log⁡2(1−p).h_2(p)=-p\log_2p-(1-p)\log_2(1-p).

1. Symmetry and the value at p=1/2p=1/2

Under the substitution p↦1−pp\mapsto1-p, the two terms simply exchange places, so

h2(p)=h2(1−p).h_2(p)=h_2(1-p).

Also,

h2 ⁣(12)=−12log⁡212−12log⁡212=12+12=1 bit.\begin{aligned} h_2\!\left(\frac12\right) &=-\frac12\log_2\frac12-\frac12\log_2\frac12\\ &=\frac12+\frac12=1\ \text{bit}. \end{aligned}

2. First derivative

For 0<p<10<p<1,

ddp[−plog⁡2p]=−log⁡2p−1ln⁡2,\frac{d}{dp}\bigl[-p\log_2p\bigr] =-\log_2p-\frac1{\ln2},

while

ddp[−(1−p)log⁡2(1−p)]=log⁡2(1−p)+1ln⁡2.\frac{d}{dp}\bigl[-(1-p)\log_2(1-p)\bigr] =\log_2(1-p)+\frac1{\ln2}.

The constants cancel:

h2′(p)=log⁡2(1−p)−log⁡2p=log⁡21−pp.h_2'(p)=\log_2(1-p)-\log_2p =\log_2\frac{1-p}{p}.

The only stationary point satisfies

1−pp=1,\frac{1-p}{p}=1,

hence p=1/2p=1/2.

3. Strict concavity and the maximum

h2′′(p)=−1(1−p)ln⁡2−1pln⁡2=−1p(1−p)ln⁡2<0,0<p<1.\begin{aligned} h_2''(p) &=-\frac1{(1-p)\ln2}-\frac1{p\ln2}\\ &=-\frac1{p(1-p)\ln2}<0, \qquad 0<p<1. \end{aligned}

Thus h2h_2 is strictly concave. The stationary point found above is therefore its unique maximum, whose value is 1 bit.

At the boundary, continuity together with 0log⁡0=00\log0=0 gives

h2(0)=h2(1)=0.h_2(0)=h_2(1)=0.

Exercise 2. A biased die and an exact prefix code

Let

p=(12,14,18,116,132,132).p=\left(\frac12,\frac14,\frac18,\frac1{16},\frac1{32},\frac1{32}\right).

Compute H(X)H(X) and compare it with the entropy of a fair six-sided die. Can you construct a binary prefix code whose average length is exactly H(X)H(X) bits per roll? If so, give one.

Show answer and solution

The distribution is

p=(12,14,18,116,132,132).p=\left(\frac12,\frac14,\frac18,\frac1{16},\frac1{32},\frac1{32}\right).

All probabilities are dyadic: pi=2−ℓip_i=2^{-\ell_i}. Their surprisals −log⁡2pi-\log_2p_i are therefore integers.

outcomepip_i−log⁡2pi-\log_2p_icontribution −pilog⁡2pi-p_i\log_2p_i
11/21/211/21/2
21/41/421/21/2
31/81/833/83/8
41/161/1641/41/4
51/321/3255/325/32
61/321/3255/325/32

Hence

H(X)=16+16+12+8+5+532=3116=1.9375 bits.H(X) =\frac{16+16+12+8+5+5}{32} =\frac{31}{16} =1.9375\ \text{bits}.

For a fair six-sided die,

Hfair=log⁡26≈2.585 bits.H_{\text{fair}}=\log_2 6\approx2.585\ \text{bits}.

The bias in the distribution has reduced uncertainty by about 0.6470.647 bits per roll.

One exact binary prefix code is

outcomecodewordlength
101
2102
31103
411104
5111105
6111115

No codeword is a prefix of another. Its average length is

L=∑ipiℓi=3116=H(X).L=\sum_ip_i\ell_i=\frac{31}{16}=H(X).

Why is exact equality possible? Here ℓi=−log⁡2pi\ell_i=-\log_2p_i are integers, and Kraft's inequality holds with equality:

∑i2−ℓi=∑ipi=1.\sum_i2^{-\ell_i}=\sum_ip_i=1.

For a general non-dyadic distribution, a one-symbol binary prefix code does not usually attain H(X)H(X) exactly. We return to this in Module 6.

Exercise 3. Conditioning can locally increase entropy

Let

P(X=0)=0.1,P(X=1)=0.9,P(X=0)=0.1,\qquad P(X=1)=0.9,

and define the conditional distribution of YY by

Y∣X=0∼Bernoulli⁡(1/2),Y∣X=1=0 almost surely.Y\mid X=0\sim\operatorname{Bernoulli}(1/2),\qquad Y\mid X=1=0\ \text{almost surely}.
  1. Find H(Y∣X=0)H(Y\mid X=0), H(Y∣X=1)H(Y\mid X=1), and H(Y∣X)H(Y\mid X).

  2. Find the marginal distribution of YY and H(Y)H(Y).

  3. Verify both H(Y∣X)≤H(Y)H(Y\mid X)\le H(Y) and H(Y∣X=0)>H(Y).H(Y\mid X=0)>H(Y).

  4. Using the chain rule and the result of §2.2, derive subadditivity, H(X,Y)≤H(X)+H(Y),H(X,Y)\le H(X)+H(Y), and state the equality condition.

Computational

Show answer and solution

We are given

P(X=0)=0.1,P(X=1)=0.9,P(X=0)=0.1,\qquad P(X=1)=0.9,

where YY is a fair Bernoulli conditional on X=0X=0, and is always zero conditional on X=1X=1.

1. Conditional entropies

H(Y∣X=0)=1 bit,H(Y\mid X=0)=1\ \text{bit},

because the conditional distribution is uniform on two outcomes, while

H(Y∣X=1)=0,H(Y\mid X=1)=0,

because the outcome is deterministic.

The average conditional entropy is

H(Y∣X)=0.1⋅1+0.9⋅0=0.1 bit.H(Y\mid X) =0.1\cdot1+0.9\cdot0 =0.1\ \text{bit}.

2. The marginal distribution of YY

The value 1 can occur only when X=0X=0, and then with probability 1/21/2:

P(Y=1)=0.1⋅12=0.05,P(Y=1)=0.1\cdot\frac12=0.05,

P(Y=0)=0.95.P(Y=0)=0.95.

Therefore,

H(Y)=h2(0.05)=−0.05log⁡20.05−0.95log⁡20.95≈0.2864 bits.H(Y)=h_2(0.05) =-0.05\log_2 0.05-0.95\log_2 0.95 \approx0.2864\ \text{bits}.

3. Both inequalities at once

On average, conditioning reduced entropy:

H(Y∣X)=0.1<0.2864≈H(Y).H(Y\mid X)=0.1<0.2864\approx H(Y).

But for the particular event X=0X=0, the opposite occurred:

H(Y∣X=0)=1>0.2864≈H(Y).H(Y\mid X=0)=1>0.2864\approx H(Y).

This is why the theorem H(Y∣X)≤H(Y)H(Y\mid X)\le H(Y) cannot be rewritten by blindly replacing average conditional entropy with H(Y∣X=x)H(Y\mid X=x).

4. Subadditivity

By the chain rule,

H(X,Y)=H(X)+H(Y∣X).H(X,Y)=H(X)+H(Y\mid X).

Since H(Y∣X)≤H(Y)H(Y\mid X)\le H(Y),

H(X,Y)≤H(X)+H(Y).H(X,Y)\le H(X)+H(Y).

Equality holds if and only if

H(Y∣X)=H(Y),H(Y\mid X)=H(Y),

which, by §2.2, is equivalent to independence of XX and YY.

Exercise 4. What an nn-gram estimate actually shows

Take Alice's Adventures in Wonderland from Project Gutenberg. Map the text to a 27-symbol alphabet: a–z and space. Lowercase it, replace all other characters by spaces, and collapse repeated spaces.

For a context uu of length kk and a following symbol aa, let the empirical counts be N(u,a)N(u,a) and N(u)=∑aN(u,a)N(u)=\sum_aN(u,a). Compute the plug-in estimate

h^kplug-in=−∑u,aN(u,a)N−klog⁡2N(u,a)N(u)\widehat h_k^{\text{plug-in}} =-\sum_{u,a}\frac{N(u,a)}{N-k} \log_2\frac{N(u,a)}{N(u)}

for k=0,1,…,9k=0,1,\ldots,9.

Then split the text chronologically: the first 80% is train, the final 20% is test. Fit an add-α\alpha nn-gram model on train, for example with α=0.1\alpha=0.1, and measure held-out cross-entropy on test for k=0,…,7k=0,\ldots,7.

Answer two questions:

  1. Why does the in-sample plug-in estimate rapidly approach zero for large kk—and why does that not imply that the entropy rate of English text is almost zero?

  2. Why does held-out cross-entropy first decrease and then possibly increase?

Show answer and solution

The reproducible solution below computes two different quantities that should not be conflated:

  1. plug-in conditional entropy on the same sample used to count frequencies;

  2. held-out cross-entropy of a model fitted on the first 80% of the text and evaluated on the final 20%.

Python
from __future__ import annotations

import hashlib
import math
import re
from collections import Counter
from dataclasses import dataclass

import requests

URL = "https://www.gutenberg.org/files/11/11-0.txt"
ALPHABET = "abcdefghijklmnopqrstuvwxyz "
ALPHABET_SIZE = len(ALPHABET)
REFERENCE_TEXT_SHA256 = "c7172a25460df6168670a3ac6c778482feb23546fabf24d05ba9d19dbb9256c8"


def download_and_normalize(url: str = URL) -> str:
    """Download Project Gutenberg text and map it to a 27-symbol alphabet."""
    response = requests.get(url, timeout=30)
    response.raise_for_status()
    raw = response.content.decode("utf-8")

    # Remove the Project Gutenberg wrapper while tolerating minor header changes.
    start = re.search(
        r"\*\*\*\s*START OF THE PROJECT GUTENBERG EBOOK.*?\*\*\*",
        raw,
        flags=re.IGNORECASE,
    )
    end = re.search(
        r"\*\*\*\s*END OF THE PROJECT GUTENBERG EBOOK.*?\*\*\*",
        raw,
        flags=re.IGNORECASE,
    )
    if start is not None:
        raw = raw[start.end() :]
    if end is not None:
        # If the start wrapper was removed, search again in the shortened string.
        end_after_start = re.search(
            r"\*\*\*\s*END OF THE PROJECT GUTENBERG EBOOK.*?\*\*\*",
            raw,
            flags=re.IGNORECASE,
        )
        if end_after_start is not None:
            raw = raw[: end_after_start.start()]

    text = re.sub(r"[^a-z]+", " ", raw.lower())
    text = re.sub(r" +", " ", text).strip()
    if not text:
        raise ValueError("Normalization produced an empty text")
    digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
    print(f"Normalized corpus SHA-256: {digest}")
    if digest != REFERENCE_TEXT_SHA256:
        raise ValueError("Corpus differs from the reference input; compare the text before comparing tables")
    return text


def plugin_conditional_entropy(text: str, k: int) -> float:
    """Empirical H(next symbol | context of length k), evaluated in-sample."""
    if not 0 <= k < len(text):
        raise ValueError("k must satisfy 0 <= k < len(text)")

    pair_counts: Counter[tuple[str, str]] = Counter()
    context_counts: Counter[str] = Counter()

    for i in range(k, len(text)):
        context = text[i - k : i] if k else ""
        symbol = text[i]
        pair_counts[(context, symbol)] += 1
        context_counts[context] += 1

    total = len(text) - k
    return -sum(
        (count / total) * math.log2(count / context_counts[context])
        for (context, _symbol), count in pair_counts.items()
    )


@dataclass(frozen=True)
class NGramCounts:
    pair: Counter[tuple[str, str]]
    context: Counter[str]


def fit_ngram(text: str, k: int) -> NGramCounts:
    """Count context/next-symbol pairs in the training prefix."""
    pair_counts: Counter[tuple[str, str]] = Counter()
    context_counts: Counter[str] = Counter()

    for i in range(k, len(text)):
        context = text[i - k : i] if k else ""
        symbol = text[i]
        pair_counts[(context, symbol)] += 1
        context_counts[context] += 1

    return NGramCounts(pair=pair_counts, context=context_counts)


def heldout_cross_entropy(
    full_text: str,
    split: int,
    k: int,
    alpha: float = 0.1,
) -> float:
    """Score test targets using an add-alpha model fitted only on the prefix."""
    if not 0 < split < len(full_text):
        raise ValueError("split must lie strictly inside the text")
    if not 0 <= k <= split:
        raise ValueError("k must satisfy 0 <= k <= split")
    if alpha <= 0:
        raise ValueError("alpha must be positive")

    train = full_text[:split]
    counts = fit_ngram(train, k)

    nll_bits = 0.0
    n_targets = 0

    # Every scored target lies in test. For the first k test targets, the context
    # may cross the train/test boundary; this is legitimate chronological context.
    for i in range(split, len(full_text)):
        context = full_text[i - k : i] if k else ""
        symbol = full_text[i]
        numerator = counts.pair[(context, symbol)] + alpha
        denominator = counts.context[context] + alpha * ALPHABET_SIZE
        probability = numerator / denominator
        nll_bits -= math.log2(probability)
        n_targets += 1

    if n_targets == 0:
        raise ValueError("The test split contains no targets")
    return nll_bits / n_targets


text = download_and_normalize()
print(f"Normalized length: {len(text):,} characters")

print("\nIn-sample plug-in conditional entropy")
for k in range(10):
    value = plugin_conditional_entropy(text, k)
    print(f"k={k:2d}: {value:.4f} bits/character")

split = int(0.8 * len(text))
print("\nHeld-out add-0.1 cross-entropy")
for k in range(8):
    value = heldout_cross_entropy(text, split, k, alpha=0.1)
    print(f"k={k:2d}: {value:.4f} bits/character")

For the file version available when this module was audited, removing the Gutenberg wrapper and applying the stated normalization produced N=135,508N=135{,}508 characters.

In-sample plug-in estimate

context length kkbits/character
04.0458
13.2098
22.4446
31.8240
41.4157
51.0881
60.8365
70.6142
80.4367
90.3132

Held-out add-0.10.1 cross-entropy

context length kkbits/character
04.0576
13.2143
22.5408
32.1391
42.1653
52.4272
62.8067
73.1954

The final digits may shift if Project Gutenberg updates the file, if the wrapper is trimmed differently, or if the split or smoothing changes. The qualitative pattern, not the last decimal place, is what matters.

Why the in-sample curve nearly falls to zero

For large kk, most long contexts occur only once. For such a context, the empirical model observes exactly one next symbol and assigns it conditional probability 1. Its contribution to training conditional entropy is

−log⁡21=0.-\log_21=0.

Once almost every context is unique, the flexible nn-gram table simply memorizes the sample. This is an optimistic in-sample estimate, not evidence that future English text is nearly deterministic.

Two related facts are useful here:

  • the ordinary plug-in estimator of a fixed discrete entropy has negative bias on average;
  • when the context space grows, the problem becomes far more severe because the same sample is used both to fit the conditional distributions and to evaluate them.

The near-zero number therefore says more about the capacity of the nn-gram table than about the source entropy rate.

Why the held-out curve first falls and then rises

For small kk, added context captures real dependence: after q, the letter u is much more likely than it is without context. Approximation bias decreases, so test cross-entropy falls.

But the number of possible contexts grows as 27k27^k. With a fixed training set, many contexts become rare or unseen. Estimates acquire high variance, while add-α\alpha smoothing allocates increasing mass to the wrong symbols. Eventually estimation error outweighs the benefit of extra context, and held-out cross-entropy rises.

This is a discrete version of the bias–variance trade-off. Better smoothing, backoff/interpolation, or a model that shares statistical strength across similar contexts can move the minimum rightward and downward, but none removes the need for held-out evaluation.

Bonus

Score the same fragment with GPT-2 or another causal LM using a sliding window. Convert total NLL to bits per token and bits per character. Do not aim for a preordained “magic number”: record the model, text version, preprocessing, context length, and verify how many target tokens actually entered the loss.

Show answer and solution

Append this block after the Exercise 4 code in the same .py file: it needs the normalized text string produced there.

The code below makes every original text token enter the loss exactly once. This is subtle: a causal LM internally shifts labels by one position, so multiplying out.loss by the length of the unmasked tail is not generally correct. We must count labels that remain valid after the shift.

Python
import math

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

MODEL_ID = "openai-community/gpt2"
MODEL_REVISION = "607a30d783dfa663caf39e06633721c8d4cfcd7e"
SAMPLE_CHARS = 50_000
STRIDE = 512

# `text` is the normalized string from Exercise 4.
sample = text[:SAMPLE_CHARS]
if not sample:
    raise ValueError("The sample is empty")

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID, revision=MODEL_REVISION)
model = AutoModelForCausalLM.from_pretrained(MODEL_ID, revision=MODEL_REVISION).to(device).eval()

text_ids = tokenizer(
    sample,
    return_tensors="pt",
    add_special_tokens=False,
).input_ids

if tokenizer.eos_token_id is None:
    raise ValueError("The tokenizer has no EOS token for the initial prefix")

# Prefix EOS lets the model assign probability to the first text token as well.
eos = torch.tensor([[tokenizer.eos_token_id]], dtype=text_ids.dtype)
input_ids = torch.cat([eos, text_ids], dim=1).to(device)

max_length = int(model.config.n_positions)
if not 0 < STRIDE < max_length:
    raise ValueError("STRIDE must satisfy 0 < STRIDE < model context length")

nll_sum_nats = 0.0
n_scored_tokens = 0
previous_end = 0
sequence_length = input_ids.size(1)

for begin in range(0, sequence_length, STRIDE):
    end = min(begin + max_length, sequence_length)
    new_length = end - previous_end

    chunk = input_ids[:, begin:end]
    labels = chunk.clone()
    labels[:, :-new_length] = -100

    # Hugging Face causal-LM loss compares logits[:, :-1] with labels[:, 1:].
    # Count exactly those shifted labels that are not masked.
    n_loss_tokens = int((labels[:, 1:] != -100).sum().item())

    if n_loss_tokens > 0:
        with torch.no_grad():
            output = model(chunk, labels=labels)
        nll_sum_nats += float(output.loss.item()) * n_loss_tokens
        n_scored_tokens += n_loss_tokens

    previous_end = end
    if end == sequence_length:
        break

expected_scored = text_ids.size(1)
if n_scored_tokens != expected_scored:
    raise RuntimeError(
        f"Scored {n_scored_tokens} tokens, expected {expected_scored}"
    )

bits_per_token = nll_sum_nats / (n_scored_tokens * math.log(2))
bits_per_character = nll_sum_nats / (len(sample) * math.log(2))
perplexity = math.exp(nll_sum_nats / n_scored_tokens)

print(f"Scored tokens:      {n_scored_tokens:,}")
print(f"Bits per token:     {bits_per_token:.4f}")
print(f"Bits per character: {bits_per_character:.4f}")
print(f"Token perplexity:   {perplexity:.4f}")

The result should be documented together with the model, revision, text file, normalization, context length, and stride. Alice carries an additional caveat: this public-domain classic may have appeared in GPT-2's pretraining corpus. The experiment is therefore useful as a tutorial on computing cross-entropy, but not as a clean estimate of generalization or of “the true entropy of English.”