Module 10. Algorithmic Information Theory: Shortest Descriptions, MDL, and Universal Prediction
Try each exercise first. Expand worked answers or project guidance when needed; their open state is shared between RU/EN.
Download course files (.zip) 33.9 KBThe archive preserves the folder structure of nine Python solution files shared by RU/EN.
Exercise 1. How many strings can be compressed?
For a fixed universal prefix machine, show that among -bit strings the fraction satisfying
is smaller than .
-
How many binary programs have length below ?
-
Why can one halting program not describe two different strings?
-
How does the argument change for plain complexity ?
Show answer and solution
The number of binary programs of length strictly below is
A prefix machine may accept fewer programs, since not every bit string must be a valid halting program.
For a fixed deterministic machine, one halting program has at most one finite output. Therefore fewer than distinct -bit strings satisfy
Dividing by the total number gives
Thus
The counting step itself uses only the number of binary programs and the fact that a deterministic program has at most one finite output. Prefix-freeness matters for the chosen definition of , for Kraft's inequality, and for the later weights .
The same elementary counting argument applies to plain complexity . Prefix versus plain complexity matters elsewhere—for self-delimitation, chain rules, and algorithmic probability.
Exercise 2. A short generator and poor practical compression
Consider bytes generated by a deterministic PRNG from a fixed seed.
-
Give an upper bound on using the generator program, the seed, and a description of .
-
Explain why gzip may still return almost bits.
-
Why does this not mean that gzip failed at its own objective?
-
What side information must the decoder receive to use the short generative description?
Show answer and solution
Let be a fixed PRNG implementation, its seed, and the requested output length. A program for can contain
-
a description of ;
-
the seed ;
-
a self-delimiting description of ;
-
the instruction “generate the first bytes.”
Hence
For fixed and , this grows only as while the object length is bits.
Gzip may nevertheless fail to compress the stream because it searches for dictionaries, repetitions, and local statistical patterns of a particular kind. A good PRNG is designed to look random to broad classes of bounded tests. The short rule “run with seed ” exists, but gzip does not search arbitrary programs.
This is not a failure relative to gzip's own objective. Gzip finds a good code in a fixed format and model family under resource constraints; it does not compute the uncomputable quantity .
To exploit the generative description, the decoder must know the exact PRNG implementation and version, the seed, , the byte-generation convention, and any platform-dependent semantics. All of these contribute to total description length.
A short description also does not imply fast decoding. The definition of does not charge for runtime, so a shortest program may take an enormous amount of time.
Exercise 3. Entropy and expected Kolmogorov complexity
Let
-
Compute .
-
Explain why for prefix complexity.
-
If describing the distribution costs bits, what upper bound follows from
-
Why does the bound become more informative for a block of i.i.d. observations? What extra cost is needed to describe ?
Show answer and solution
For
Shortest prefix programs form a prefix code for the possible outcomes. The expected length of any prefix code is at least entropy, so
If bits, then
For one draw from a four-symbol alphabet, this is extremely loose because model cost dominates. For an i.i.d. block, the decoder must also know the block length. A more explicit bound is
where . Therefore
The fixed model cost vanishes per observation.
A single observation does not identify a unique distribution : infinitely many distributions assign positive probability to the same outcome. Describing one object and predicting future data are therefore different requirements.
Exercise 4. The MDL switching point
Two models have description lengths
Their average data-code lengths per object are
-
Write the total lengths for a dataset of size .
-
What is the smallest for which model is shorter?
-
Explain why this illustrates the dependence of admissible model complexity on data volume.
-
What changes if depends on parameter precision?
Show answer and solution
The total lengths are
Model wins when
so
At , both descriptions are bits. The smallest integer sample size for which is strictly shorter is
The more complex model pays an additional bits once, but saves bit per observation. Small datasets favor model cost; large datasets let a persistent fit advantage accumulate.
If depends on parameter precision, model cost may itself grow with sample size because more data can justify transmitting weights more accurately. One then optimizes jointly over model and quantization precision.
MAP selects one model by minimizing . A Bayesian mixture code uses and need not announce a single hypothesis first. The two protocols can therefore assign different lengths and make different selections.
Exercise 5. NCD by hand
Suppose a compressor gives
bytes.
-
Compute .
-
How does the result change if and the minimum is used for symmetrization?
-
Why is the result not an exact NID?
-
Name two possible artifacts for short files.
Show answer and solution
Without symmetrization,
If
then
This is not exact NID because is not , the compressor detects only a restricted family of regularities, real files include headers and checksums, ideal normal-compressor properties may fail, and concatenation order can affect dictionary state.
For short files, fixed headers, insufficient data for dictionary learning, block alignment, and irrelevant metadata may dominate.
Compressor suitability should be checked empirically: compare several codecs, both concatenation orders, sensitivity to representation and preprocessing, and held-out nearest-neighbor or clustering performance.
Exercise 6. Python: compressors, prequential MDL, and NCD
Run M10_algorithmic_mdl_experiments.py.
It performs three experiments:
-
compares gzip, bz2, and LZMA on regular and pseudorandom objects;
-
compares the prequential length of two representations with similar final accuracy;
-
builds an NCD matrix for related texts, code fragments, and DNA-like sequences.
Answer the following:
- Why do PRNG and SHA streams compress poorly with ordinary compressors despite having short generative descriptions?
- Why does the representation with fewer nuisance dimensions obtain a shorter prequential code?
- Why does NCD group within-domain pairs while its absolute values depend on bz2?
- What checks are required before using NCD as an operational metric?
Show answer and solution
The script uses fixed seeds and settings, so the results are reproducible.
1. Practical compressors
Bits per input byte:
| Object | gzip | bz2 | LZMA |
|---|---|---|---|
| Zeros | 0.0117 | 0.0052 | 0.0171 |
| Periodic string | 0.0168 | 0.0078 | 0.0181 |
| Repeated English-like text | 0.0343 | 0.0216 | 0.0244 |
| PRNG, seed=7 | 8.0046 | 8.0789 | 8.0078 |
| SHA-256 counter stream | 8.0046 | 8.0756 | 8.0078 |
The first three objects contain regularities aligned with compressor inductive biases. The last two have short generative programs but appear statistically random to these algorithms. Format overhead even pushes the archives slightly above the raw eight bits per byte.
2. Prequential MDL
| Representation | bits/label | final accuracy |
|---|---|---|
| 4 informative dimensions | 0.7423 | 0.7440 |
| 4 signal + 96 nuisance dimensions | 0.9086 | 0.7270 |
The second representation contains the same basic signal plus many nuisance dimensions. The probe is less sample-efficient and assigns worse probabilities in early blocks. Prequential coding accumulates this learning cost, while a single final accuracy partly hides it. Even if the final accuracies were exactly equal, the prequential lengths could differ because they depend on the entire learning trajectory.
3. NCD
The smallest off-diagonal distances are the within-domain pairs:
text-A/text-B: about ;code-A/code-B: about ;dna-A/dna-B: about .
Most cross-domain distances lie between roughly and .
The diagonal is not exactly zero because headers matter and need not equal . This illustrates the gap between theoretical NID and practical NCD.
Before production use, check robustness across compressors, both concatenation orders, file-size and header effects, preprocessing, held-out clustering or nearest-neighbor performance, and permutation or domain-matched negative baselines.
Code solution · the same source file for RU/EN
from __future__ import annotations
import bz2
import gzip
import hashlib
import json
import lzma
import math
import random
import zlib
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, log_loss
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
ROOT = Path(__file__).resolve().parent
OUT = ROOT / "generated_experiment"
OUT.mkdir(exist_ok=True)
SEED = 20260713
rng = np.random.default_rng(SEED)
def repeat_to_length(pattern: bytes, n: int) -> bytes:
return (pattern * (n // len(pattern) + 1))[:n]
def python_prng_bytes(n: int, seed: int = 7) -> bytes:
# A short generator plus a short seed describes this object, even though
# generic compressors will usually see random-looking bytes.
r = random.Random(seed)
return bytes(r.randrange(256) for _ in range(n))
def hash_counter_stream(n: int, seed: bytes = b"course-m10") -> bytes:
out = bytearray()
counter = 0
while len(out) < n:
out.extend(hashlib.sha256(seed + counter.to_bytes(8, "big")).digest())
counter += 1
return bytes(out[:n])
COMPRESSORS: dict[str, Callable[[bytes], bytes]] = {
"gzip": lambda x: gzip.compress(x, compresslevel=9),
"bz2": lambda x: bz2.compress(x, compresslevel=9),
"lzma": lambda x: lzma.compress(x, preset=9),
}
def compressor_experiment(n: int = 65536) -> list[dict[str, float | str]]:
corpus = {
"zeros": b"\x00" * n,
"periodic": repeat_to_length(b"abc123|", n),
"English-like": repeat_to_length(
b"prediction and compression are two views of the same log-loss; ", n
),
"PRNG seed=7": python_prng_bytes(n, 7),
"SHA256 stream": hash_counter_stream(n),
}
rows: list[dict[str, float | str]] = []
for name, data in corpus.items():
for comp_name, comp in COMPRESSORS.items():
packed = comp(data)
rows.append(
{
"object": name,
"compressor": comp_name,
"raw_bytes": len(data),
"compressed_bytes": len(packed),
"bits_per_byte": 8.0 * len(packed) / len(data),
}
)
names = list(corpus)
x = np.arange(len(names))
width = 0.24
fig, ax = plt.subplots(figsize=(10.5, 5.6))
for j, comp_name in enumerate(COMPRESSORS):
vals = [
next(
float(row["bits_per_byte"])
for row in rows
if row["object"] == name and row["compressor"] == comp_name
)
for name in names
]
ax.bar(x + (j - 1) * width, vals, width=width, label=comp_name)
ax.axhline(8.0, linewidth=1.2, linestyle="--", label="raw: 8 bits/byte")
ax.set_xticks(x, names, rotation=18, ha="right")
ax.set_ylabel("bits per input byte")
ax.set_title("Practical compressors are computable upper-bound mechanisms, not K-oracles")
ax.legend(ncol=2)
fig.tight_layout()
fig.savefig(OUT / "M10_experiment_compressor_bounds.png", dpi=190)
plt.close(fig)
return rows
@dataclass
class PrequentialResult:
name: str
total_bits: float
bits_per_label: float
final_accuracy: float
cumulative_points: list[tuple[int, float]]
def make_classification_representations(n: int = 5000) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
z = rng.normal(size=(n, 4))
logits = 1.3 * z[:, 0] - 0.9 * z[:, 1] + 0.5 * z[:, 2]
probs = 1.0 / (1.0 + np.exp(-logits))
y = rng.binomial(1, probs)
easy = z + rng.normal(scale=0.08, size=z.shape)
nuisance = rng.normal(size=(n, 96))
hard = np.concatenate([z + rng.normal(scale=0.18, size=z.shape), nuisance], axis=1)
return easy, hard, y.astype(int)
def prequential_code(
X: np.ndarray,
y: np.ndarray,
name: str,
boundaries: list[int],
) -> PrequentialResult:
n = len(y)
if boundaries[-1] != n:
raise ValueError("last boundary must equal dataset size")
first = boundaries[0]
total_bits = float(first) # uniform binary code for first labels
points: list[tuple[int, float]] = [(first, total_bits)]
for left, right in zip(boundaries[:-1], boundaries[1:]):
model = make_pipeline(
StandardScaler(),
LogisticRegression(max_iter=2500, C=1.0, solver="lbfgs", random_state=SEED),
)
model.fit(X[:left], y[:left])
probs = model.predict_proba(X[left:right])
block_nats = log_loss(y[left:right], probs, labels=[0, 1], normalize=False)
total_bits += float(block_nats / math.log(2.0))
points.append((right, total_bits))
final_model = make_pipeline(
StandardScaler(),
LogisticRegression(max_iter=2500, C=1.0, solver="lbfgs", random_state=SEED),
)
split = int(0.8 * n)
final_model.fit(X[:split], y[:split])
acc = accuracy_score(y[split:], final_model.predict(X[split:]))
return PrequentialResult(
name=name,
total_bits=total_bits,
bits_per_label=total_bits / n,
final_accuracy=float(acc),
cumulative_points=points,
)
def prequential_experiment() -> list[PrequentialResult]:
X_easy, X_hard, y = make_classification_representations()
boundaries = [64, 128, 256, 512, 1024, 2048, 3500, len(y)]
results = [
prequential_code(X_easy, y, "4 informative dimensions", boundaries),
prequential_code(X_hard, y, "4 signal + 96 nuisance dimensions", boundaries),
]
fig, ax = plt.subplots(figsize=(9.6, 5.4))
for result in results:
xs = [p[0] for p in result.cumulative_points]
ys = [p[1] / p[0] for p in result.cumulative_points]
ax.plot(xs, ys, marker="o", label=result.name)
ax.axhline(1.0, linestyle="--", linewidth=1.2, label="uniform binary code")
ax.set_xscale("log", base=2)
ax.set_xlabel("number of labels encoded")
ax.set_ylabel("cumulative bits per label")
ax.set_title("Prequential MDL rewards representations that are learned quickly")
ax.legend()
fig.tight_layout()
fig.savefig(OUT / "M10_experiment_prequential.png", dpi=190)
plt.close(fig)
return results
def ncd(x: bytes, y: bytes, compressor: Callable[[bytes], bytes]) -> float:
cx = len(compressor(x))
cy = len(compressor(y))
cxy = min(len(compressor(x + y)), len(compressor(y + x)))
return (cxy - min(cx, cy)) / max(cx, cy)
def mutate(data: bytes, every: int, replacement: bytes) -> bytes:
out = bytearray(data)
for i in range(0, len(out), every):
out[i : i + len(replacement)] = replacement[: min(len(replacement), len(out) - i)]
return bytes(out)
def ncd_experiment() -> tuple[list[str], np.ndarray]:
n = 24000
objects = {
"text-A": repeat_to_length(b"the model predicts the next token and pays in bits. ", n),
"text-B": mutate(repeat_to_length(b"the model predicts the next token and pays in bits. ", n), 337, b"loss"),
"code-A": repeat_to_length(b"for(i=0;i<n;i++){sum+=a[i];}\n", n),
"code-B": mutate(repeat_to_length(b"for(i=0;i<n;i++){sum+=a[i];}\n", n), 401, b"while"),
"dna-A": repeat_to_length(b"ACGTACGTGATTACA", n),
"dna-B": mutate(repeat_to_length(b"ACGTACGTGATTACA", n), 389, b"TTAA"),
}
labels = list(objects)
comp = lambda b: bz2.compress(b, compresslevel=9)
matrix = np.zeros((len(labels), len(labels)))
for i, a in enumerate(labels):
for j, b in enumerate(labels):
matrix[i, j] = ncd(objects[a], objects[b], comp)
fig, ax = plt.subplots(figsize=(7.5, 6.4))
im = ax.imshow(matrix, vmin=0.0, vmax=max(1.0, float(matrix.max())))
ax.set_xticks(np.arange(len(labels)), labels, rotation=35, ha="right")
ax.set_yticks(np.arange(len(labels)), labels)
ax.set_title("Normalized compression distance: shared regularity becomes proximity")
for i in range(len(labels)):
for j in range(len(labels)):
ax.text(j, i, f"{matrix[i, j]:.2f}", ha="center", va="center", fontsize=8)
fig.colorbar(im, ax=ax, label="NCD")
fig.tight_layout()
fig.savefig(OUT / "M10_experiment_ncd.png", dpi=190)
plt.close(fig)
return labels, matrix
def main() -> None:
compressor_rows = compressor_experiment()
prequential_results = prequential_experiment()
labels, matrix = ncd_experiment()
summary = {
"compressor_rows": compressor_rows,
"prequential": [
{
"name": r.name,
"total_bits": r.total_bits,
"bits_per_label": r.bits_per_label,
"final_accuracy": r.final_accuracy,
}
for r in prequential_results
],
"ncd_labels": labels,
"ncd_matrix": matrix.tolist(),
}
(OUT / "M10_experiment_results.json").write_text(
json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8"
)
print("Compression experiment (bits per byte):")
for row in compressor_rows:
print(
f" {row['object']:14s} {row['compressor']:4s}: "
f"{float(row['bits_per_byte']):.3f}"
)
print("\nPrequential results:")
for r in prequential_results:
print(
f" {r.name}: {r.bits_per_label:.4f} bits/label, "
f"final accuracy={r.final_accuracy:.4f}"
)
print("\nNCD matrix:")
print(np.array2string(matrix, precision=3, suppress_small=True))
if __name__ == "__main__":
main()
Bonus. Universal dominance and excess log loss
Assume the universal semimeasure satisfies
for every finite prefix of a computable source .
-
Show that
-
Decompose the likelihood ratio over time using the chain rule.
-
Explain why this yields a finite cumulative excess log-loss bill without making the universal predictor computable.
Show answer and solution
From
we get
Taking a base-two logarithm,
For a sequence with well-defined conditionals,
Taking expectation under turns each term into a conditional KL divergence. Thus cumulative expected excess log loss is bounded by bits. The true model's complexity is paid through the factor , which becomes the additive term after taking logarithms.
The bound does not make computable. The semimeasure is lower semicomputable: discovering additional programs can increase partial sums, but no algorithm can know that no unseen program will contribute later. Conditional ratios do not become a computable oracle either.