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 KBThe archive preserves the folder structure of nine Python solution files shared by RU/EN.
Exercise 1. Binary entropy
For
show that:
-
and bit;
-
for ,
-
is strictly concave and has a unique maximum at .
Show answer and solution
Recall that
1. Symmetry and the value at
Under the substitution , the two terms simply exchange places, so
Also,
2. First derivative
For ,
while
The constants cancel:
The only stationary point satisfies
hence .
3. Strict concavity and the maximum
Thus is strictly concave. The stationary point found above is therefore its unique maximum, whose value is 1 bit.
At the boundary, continuity together with gives
Exercise 2. A biased die and an exact prefix code
Let
Compute and compare it with the entropy of a fair six-sided die. Can you construct a binary prefix code whose average length is exactly bits per roll? If so, give one.
Show answer and solution
The distribution is
All probabilities are dyadic: . Their surprisals are therefore integers.
| outcome | contribution | ||
|---|---|---|---|
| 1 | 1 | ||
| 2 | 2 | ||
| 3 | 3 | ||
| 4 | 4 | ||
| 5 | 5 | ||
| 6 | 5 |
Hence
For a fair six-sided die,
The bias in the distribution has reduced uncertainty by about bits per roll.
One exact binary prefix code is
| outcome | codeword | length |
|---|---|---|
| 1 | 0 | 1 |
| 2 | 10 | 2 |
| 3 | 110 | 3 |
| 4 | 1110 | 4 |
| 5 | 11110 | 5 |
| 6 | 11111 | 5 |
No codeword is a prefix of another. Its average length is
Why is exact equality possible? Here are integers, and Kraft's inequality holds with equality:
For a general non-dyadic distribution, a one-symbol binary prefix code does not usually attain exactly. We return to this in Module 6.
Exercise 3. Conditioning can locally increase entropy
Let
and define the conditional distribution of by
-
Find , , and .
-
Find the marginal distribution of and .
-
Verify both and
-
Using the chain rule and the result of §2.2, derive subadditivity, and state the equality condition.
Computational
Show answer and solution
We are given
where is a fair Bernoulli conditional on , and is always zero conditional on .
1. Conditional entropies
because the conditional distribution is uniform on two outcomes, while
because the outcome is deterministic.
The average conditional entropy is
2. The marginal distribution of
The value 1 can occur only when , and then with probability :
Therefore,
3. Both inequalities at once
On average, conditioning reduced entropy:
But for the particular event , the opposite occurred:
This is why the theorem cannot be rewritten by blindly replacing average conditional entropy with .
4. Subadditivity
By the chain rule,
Since ,
Equality holds if and only if
which, by §2.2, is equivalent to independence of and .
Exercise 4. What an -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 of length and a following symbol , let the empirical counts be and . Compute the plug-in estimate
for .
Then split the text chronologically: the first 80% is train, the final 20% is test. Fit an add- -gram model on train, for example with , and measure held-out cross-entropy on test for .
Answer two questions:
-
Why does the in-sample plug-in estimate rapidly approach zero for large —and why does that not imply that the entropy rate of English text is almost zero?
-
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:
-
plug-in conditional entropy on the same sample used to count frequencies;
-
held-out cross-entropy of a model fitted on the first 80% of the text and evaluated on the final 20%.
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 characters.
In-sample plug-in estimate
| context length | bits/character |
|---|---|
| 0 | 4.0458 |
| 1 | 3.2098 |
| 2 | 2.4446 |
| 3 | 1.8240 |
| 4 | 1.4157 |
| 5 | 1.0881 |
| 6 | 0.8365 |
| 7 | 0.6142 |
| 8 | 0.4367 |
| 9 | 0.3132 |
Held-out add- cross-entropy
| context length | bits/character |
|---|---|
| 0 | 4.0576 |
| 1 | 3.2143 |
| 2 | 2.5408 |
| 3 | 2.1391 |
| 4 | 2.1653 |
| 5 | 2.4272 |
| 6 | 2.8067 |
| 7 | 3.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 , 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
Once almost every context is unique, the flexible -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 -gram table than about the source entropy rate.
Why the held-out curve first falls and then rises
For small , 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 . With a fixed training set, many contexts become rare or unseen. Estimates acquire high variance, while add- 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.
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.”