File size: 12,552 Bytes
0ba9d09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
"""Entropy, cross-entropy, KL divergence and maximum likelihood, in nats.

Information content measures one observation, and entropy averages it under the distribution that
produced it. Cross-entropy averages the same quantity under a second distribution supplying the
probabilities, and the KL divergence is the difference between those two averages. So when we
minimize a negative log-likelihood over a model, we are minimizing a KL divergence to the data
distribution.

Chapter 1, Sections 1.33, 1.34 and 1.35 of the notes. Runs on the pair p = (0.8, 0.2) and
q = (0.6, 0.4) of Section 1.33.3 and on the ten DNA bases of Section 1.35. Prints the chapter's
number beside the computed one. Logarithms are natural, so every quantity is in nats.

    python lecture_1/information.py                   # every worked example
    python lecture_1/information.py --samples 200000  # a tighter Monte Carlo cross-entropy
    python lecture_1/information.py --quiet           # the closing report only
"""

# %% 01. Imports and configuration
from __future__ import annotations

import argparse
import math

import torch

from cis6270.runner import print_report, save_run, set_seed

torch.set_default_dtype(torch.float64)  # Double precision, because the chapter prints five decimal places.


P_REF = torch.tensor([0.8, 0.2])  # The reference distribution p of Sections 1.33.3 and 1.34.
Q_MODEL = torch.tensor([0.6, 0.4])  # The comparison distribution q scored against those samples.
BASE_COUNTS = torch.tensor([4.0, 3.0, 2.0, 1.0])  # n(A), n(C), n(G), n(T) over ten DNA bases.


WORKED: dict[str, float] = {}  # Every computed value, by label, for tests/test_lecture_1.py.


def show(label: str, by_hand: float, computed: float, quiet: bool = False) -> None:
    """Print the chapter's hand-worked value beside the value this file computes, and keep it."""
    WORKED[label] = computed  # The test suite asserts these against the chapter's own numbers.
    if not quiet:  # Under --quiet only the closing report is printed.
        computed = computed + 0.0  # Adding zero prints a negative zero as a plain zero.
        print(f"  {label:<36s} chapter {by_hand:>11.5f}   computed {computed:>11.5f}"
              f"   gap {abs(by_hand - computed):.1e}")


def entropy(p: torch.Tensor) -> float:
    """H(p) = -sum_x p(x) log p(x), the expected information in one sample drawn from p."""
    return float(-(p * p.clamp_min(1e-300).log()).sum())  # Clamp keeps 0 log 0 at zero.


def cross_entropy(p: torch.Tensor, q: torch.Tensor) -> float:
    """H(p, q) = -sum_x p(x) log q(x), the cost of scoring samples from p with the values of q."""
    return float(-(p * q.log()).sum())  # The weights come from p and the probabilities from q.


def divergence(p: torch.Tensor, q: torch.Tensor) -> float:
    """KL(p || q) = sum_x p(x) log(p(x) / q(x)), the expected log ratio taken under p."""
    return float((p * (p / q).log()).sum())  # Zero exactly when q matches p at every state.


# %% 02. Information content
def information(quiet: bool = False) -> dict:
    """Section 1.33.1: I(x) = -log p(x), so a rarer outcome has more information."""
    common = -math.log(0.5)  # An outcome of probability one half.
    rare = -math.log(0.01)  # An outcome fifty times less likely.
    certain = -math.log(1.0)  # A certain event, with zero information.
    show("I(x) at p(x) = 0.5", 0.693, common, quiet)
    show("I(x) at p(x) = 0.01", 4.605, rare, quiet)
    show("I(x) at p(x) = 1", 0.0, certain, quiet)
    show("ratio of the two surprises", 6.6, rare / common, quiet)
    return {"common": common, "rare": rare}


# %% 03. Entropy
def entropies(quiet: bool = False) -> dict:
    """Section 1.33.2: entropy is high when the mass is spread and zero when it is all on one state."""
    skewed = entropy(torch.tensor([0.9, 0.1]))  # 0.09482 + 0.23026 nats.
    balanced = entropy(torch.tensor([0.5, 0.5]))  # log 2, the largest value on two states.
    deterministic = entropy(torch.tensor([1.0, 0.0]))  # All mass on one state, so 1 log 1 = 0.
    uniform_four = entropy(torch.full((4,), 0.25))  # log K at K = 4, proved in Section 1.37.2.
    show("H((0.9, 0.1))", 0.325, skewed, quiet)
    show("H((0.5, 0.5)) = log 2", 0.693, balanced, quiet)
    show("H of a point mass", 0.0, deterministic, quiet)
    show("H of the uniform on 4 states", 1.386, uniform_four, quiet)
    return {"skewed": skewed, "balanced": balanced, "uniform_four": uniform_four}


# %% 04. Cross-entropy
def cross_entropies(quiet: bool = False) -> dict:
    """Section 1.33.3: scoring samples from p with the probabilities of q costs more than H(p)."""
    cost = cross_entropy(P_REF, Q_MODEL)  # 0.40866 + 0.18326 nats.
    own = entropy(P_REF)  # 0.17851 + 0.32189 nats, the irreducible part of that cost.
    same = cross_entropy(P_REF, P_REF)  # H(p, p) recovers the entropy exactly.
    show("H(p, q)", 0.592, cost, quiet)
    show("H(p)", 0.500, own, quiet)
    show("excess cost H(p, q) - H(p)", 0.092, cost - own, quiet)
    show("H(p, p) - H(p)", 0.0, same - own, quiet)
    return {"cost": cost, "own": own, "excess": cost - own}


# %% 05. Kullback-Leibler divergence
def kl(samples: int, quiet: bool = False) -> dict:
    """Section 1.34: the split H(p, q) = H(p) + KL(p || q), nonnegativity, and the asymmetry."""
    forward = divergence(P_REF, Q_MODEL)  # 0.23015 - 0.13863 nats, the price of using q.
    reverse = divergence(Q_MODEL, P_REF)  # -0.17261 + 0.27726 nats, the other direction.
    split = entropy(P_REF) + forward  # The boxed identity, and it must return H(p, q).
    logits = torch.randn(max(samples, 1), 2, 8)  # Random logits for pairs of distributions.
    pairs = torch.softmax(logits, dim=-1)  # Two distributions over eight states per row; [N, 2, 8].
    drawn = (pairs[:, 0] * (pairs[:, 0] / pairs[:, 1]).log()).sum(-1)  # KL for every pair; [N].
    violation = float((-drawn).clamp_min(0.0).max())  # The largest breach of KL >= 0 among them.
    t = torch.linspace(0.01, 5.0, 500)  # The range on which we check the inequality log t <= t - 1.
    show("KL(p || q)", 0.0915, forward, quiet)
    show("KL(q || p)", 0.1046, reverse, quiet)
    show("H(p) + KL(p || q) = H(p, q)", 0.592, split, quiet)
    show("KL(p || p)", 0.0, divergence(P_REF, P_REF), quiet)
    show("reverse over forward, percent", 14.0, 100.0 * (reverse / forward - 1.0), quiet)
    show("largest log t - (t - 1)", 0.0, float((t.log() - (t - 1.0)).max()), quiet)
    show(f"largest KL breach over {len(drawn)} pairs", 0.0, violation, quiet)
    return {"forward": forward, "reverse": reverse, "split": split,
            "smallest": float(drawn.min()), "violation": violation}


# %% 06. Cross-entropy by Monte Carlo
def monte_carlo(samples: int, repeats: int, quiet: bool = False) -> dict:
    """Section 1.5.3 applied to Section 1.33.3: H(p, q) is an expectation, so an average estimates it.

    We draw states from p and average -log q at the drawn states. The estimate moves with --seed
    and with --samples, while the exact value it approaches stays fixed. We repeat a ten-sample
    estimate ``repeats`` times. The spread of those estimates is the sd that Section 1.5.3
    predicts for a ten-sample average.
    """
    exact = cross_entropy(P_REF, Q_MODEL)  # The target of the estimator, 0.592 nats.
    draws = torch.multinomial(P_REF, max(samples, 10), replacement=True)  # [N] states drawn from p.
    surprise = -Q_MODEL.log()  # The two values -log q(x) the estimator averages over; [2].
    scored = surprise[draws]  # The summand -log q(x^(n)) at each drawn state; [N].
    estimate, spread = float(scored.mean()), float(scored.std())  # The average and its per-sample sd.
    error = spread / math.sqrt(len(draws))  # The sd of the average, and it falls like 1/sqrt(N).
    show("H(p, q) exactly", 0.592, exact, quiet)
    if not quiet:
        label = f"H(p, q) from {len(draws)} samples"
        print(f"  {label:<36s} exact   {exact:>11.5f}   estimate {estimate:>11.5f}"
              f"   sd {error:.1e}")
    rounds = max(repeats, 2)  # Two averages is the fewest we can take a standard deviation over.
    repeated = torch.multinomial(P_REF, 10 * rounds, replacement=True)  # [10 * rounds] states.
    averages = surprise[repeated].view(rounds, 10).mean(dim=1)  # One ten-sample estimate per round.
    exact_spread = float((P_REF @ (surprise - exact).square()).sqrt())  # sd of one scored draw.
    predicted = exact_spread / math.sqrt(10.0)  # The sd of a ten-sample average, Section 1.5.3.
    show(f"sd of {rounds} ten-sample estimates", predicted, float(averages.std()), quiet)
    return {"exact": exact, "estimate": estimate, "error": error, "draws": len(draws),
            "predicted": predicted, "measured": float(averages.std()), "rounds": rounds}


# %% 07. Maximum likelihood as KL minimization
def maximum_likelihood(quiet: bool = False) -> dict:
    """Section 1.35: the empirical loss at the frequencies is their entropy, and the gap to any
    other model is the KL divergence from the frequencies to it."""
    frequencies = BASE_COUNTS / BASE_COUNTS.sum()  # p_hat = (0.4, 0.3, 0.2, 0.1) over A, C, G, T.
    uniform = torch.full((4,), 0.25)  # The categorical model that spreads the mass evenly.
    at_frequencies = cross_entropy(frequencies, frequencies)  # The empirical loss at p_hat.
    at_uniform = cross_entropy(frequencies, uniform)  # The same data scored by the uniform model.
    gap = at_uniform - at_frequencies  # The reducible part of the cost, a KL divergence.
    terms = -frequencies * frequencies.log()  # The four terms the chapter adds up; [4].
    show("term for A, 0.4 log(1/0.4)", 0.36652, float(terms[0]), quiet)
    show("term for C, 0.3 log(1/0.3)", 0.36119, float(terms[1]), quiet)
    show("term for G, 0.2 log(1/0.2)", 0.32189, float(terms[2]), quiet)
    show("term for T, 0.1 log(1/0.1)", 0.23026, float(terms[3]), quiet)
    show("loss at the frequencies", 1.280, at_frequencies, quiet)
    show("loss under the uniform model", 1.386, at_uniform, quiet)
    show("gap between the two losses", 0.1064, gap, quiet)
    show("KL(p_hat || uniform)", 0.1064, divergence(frequencies, uniform), quiet)
    return {"frequencies": frequencies, "at_frequencies": at_frequencies,
            "at_uniform": at_uniform, "gap": gap}


# %% 08. The report
def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    parser.add_argument("--samples", type=int, default=20000, help="draws in the Monte Carlo checks")
    parser.add_argument("--steps", type=int, default=5000, help="repeats of the ten-sample estimate")
    parser.add_argument("--seed", type=int, default=0, help="random seed for the Monte Carlo draws")
    parser.add_argument("--out", type=str, default="", help="directory for the run's output")
    parser.add_argument("--quiet", action="store_true", help="print only the final report")
    args = parser.parse_args()

    set_seed(args.seed)  # Seed first, so the draws repeat under the same seed.
    if not args.quiet:
        print("Chapter 1, Sections 1.33 to 1.35, worked example by worked example\n")
    surprise = information(args.quiet)
    spread = entropies(args.quiet)
    cost = cross_entropies(args.quiet)
    gap = kl(args.samples, args.quiet)
    estimate = monte_carlo(args.samples, args.steps, args.quiet)
    fitted = maximum_likelihood(args.quiet)

    report = {
        "I(x) at p(x) = 0.01, nats": surprise["rare"],
        "H((0.9, 0.1)), nats": spread["skewed"],
        "H((0.5, 0.5)), nats": spread["balanced"],
        "H(p), nats": cost["own"],
        "H(p, q), nats": cost["cost"],
        "KL(p || q), nats": gap["forward"],
        "KL(q || p), nats": gap["reverse"],
        "smallest KL over random pairs": gap["smallest"],
        "largest KL breach, random pairs": gap["violation"],
        "H(p, q) from samples": estimate["estimate"],
        "Monte Carlo samples drawn": estimate["draws"],
        "ten-sample estimates taken": estimate["rounds"],
        "sd of those estimates": estimate["measured"],
        "sd Section 1.5.3 predicts": estimate["predicted"],
        "empirical loss at p_hat, nats": fitted["at_frequencies"],
        "loss under the uniform, nats": fitted["at_uniform"],
        "KL(p_hat || uniform), nats": fitted["gap"],
    }
    print_report("Information, Sections 1.33 to 1.35", report)
    save_run(args.out, config=vars(args), report=report)


if __name__ == "__main__":  # Execute when launched directly.
    main()  # Work every example of Sections 1.33 to 1.35 and print the report.