File size: 15,710 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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
"""The simplex, the softmax, transition matrices and the matrix exponential.

A distribution over K states is a point of the simplex, a softmax maps unrestricted logits onto
that simplex, and a row-stochastic matrix moves one point of it to the next. Along an
eigendirection a matrix power is a power of a number. That fact gives the stationary distribution,
the rate at which a chain reaches it, and the matrix exponential of continuous time.

Chapter 1, Sections 1.12, 1.13, 1.15, 1.16, 1.17 and 1.18 of the notes. Runs on the three-base
simplex of Section 1.12.2, the two-state chain of Section 1.15.1 and the symmetric matrix
A = [[2, 1], [1, 2]] of Section 1.16. Prints the chapter's number beside the computed one.

    python lecture_1/linear_algebra.py             # every worked example
    python lecture_1/linear_algebra.py --steps 40  # run the chain further toward p*
    python lecture_1/linear_algebra.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_BASES = torch.tensor([0.5, 0.3, 0.2])  # The three-state distribution over A, C, G; Section 1.12.2.
H_BASES = torch.tensor([1.0, 0.0, 4.0])  # Hydrogen-bond contacts per base, the property h.
Q_BASES = torch.tensor([0.1, 0.1, 0.8])  # The second point of the simplex, mixed with P_BASES.
LOGITS = torch.tensor([2.0, 1.0, 0.0])  # The logit vector z of Section 1.13.1.
P_CHAIN = torch.tensor([[0.8, 0.2],  # Row 1: stay in state one with 0.8, move to state two with 0.2.
                        [0.3, 0.7]])  # Row 2: move to state one with 0.3, stay with 0.7.
A_MATRIX = torch.tensor([[2.0, 1.0], [1.0, 2.0]])  # The running matrix of Sections 1.16 and 1.18.
X_START = torch.tensor([3.0, 1.0])  # The initial vector x_0 = 2 v_1 + v_2 of Section 1.16.2.


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}")


# %% 02. Probability vectors and the simplex
def simplex(quiet: bool = False) -> dict:
    """Sections 1.12.2 and 1.12.3: expectation as a dot product, and a mixture of two points."""
    expectation = float(P_BASES @ H_BASES)  # E[h(X)] = p^T h = 0.5 + 0 + 0.8 = 1.3.
    mixture = 0.5 * P_BASES + 0.5 * Q_BASES  # r = lambda p + (1 - lambda) q at lambda = 0.5; [3].
    corners = 0.3 * torch.tensor([1.0, 0.0]) + 0.7 * torch.tensor([0.0, 1.0])  # Two-state mixture.
    show("sum_i p_i", 1.0, float(P_BASES.sum()), quiet)
    show("E[h(X)] = p^T h", 1.3, expectation, quiet)
    show("mixture r_1 at lambda = 0.5", 0.30, float(mixture[0]), quiet)
    show("mixture r_2 at lambda = 0.5", 0.20, float(mixture[1]), quiet)
    show("mixture r_3 at lambda = 0.5", 0.50, float(mixture[2]), quiet)
    show("sum_i r_i, still a distribution", 1.0, float(mixture.sum()), quiet)
    show("0.3 e_1 + 0.7 e_2, first entry", 0.3, float(corners[0]), quiet)
    return {"expectation": expectation, "mixture": mixture}


# %% 03. Logits and the softmax
def softmax(quiet: bool = False) -> dict:
    """Section 1.13.1: softmax(z)_i = e^{z_i} / sum_j e^{z_j} on the logits z = (2, 1, 0)."""
    exponentials = LOGITS.exp()  # e^2, e^1, e^0; [3].
    partition = float(exponentials.sum())  # The normalizing sum, 7.389 + 2.718 + 1.000.
    probabilities = exponentials / partition  # The point of the simplex the logits name; [3].
    shifted = torch.softmax(LOGITS + 5.0, dim=0)  # Adding a constant c cancels in the ratio.
    show("e^{z_1}", 7.389, float(exponentials[0]), quiet)
    show("e^{z_2}", 2.718, float(exponentials[1]), quiet)
    show("e^{z_3}", 1.000, float(exponentials[2]), quiet)
    show("partition function sum_j e^{z_j}", 11.107, partition, quiet)
    show("softmax(z)_1", 0.665, float(probabilities[0]), quiet)
    show("softmax(z)_2", 0.245, float(probabilities[1]), quiet)
    show("softmax(z)_3", 0.090, float(probabilities[2]), quiet)
    show("sum_i softmax(z)_i", 1.0, float(probabilities.sum()), quiet)
    show("shift invariance, largest change", 0.0, float((shifted - probabilities).abs().max()), quiet)
    return {"probabilities": probabilities, "partition": partition}


# %% 04. A probability vector under a transition matrix
def one_transition(quiet: bool = False) -> dict:
    """Section 1.15.1: p_{t+1} = P^T p_t moves an even split to (0.55, 0.45) in one step."""
    p_t = torch.tensor([0.5, 0.5])  # The current distribution over the two states; [2].
    p_next = P_CHAIN.T @ p_t  # Each new entry collects the mass arriving from every state; [2].
    show("row sums of P, row 1", 1.0, float(P_CHAIN[0].sum()), quiet)
    show("row sums of P, row 2", 1.0, float(P_CHAIN[1].sum()), quiet)
    show("p_{t+1}(1)", 0.55, float(p_next[0]), quiet)
    show("p_{t+1}(2)", 0.45, float(p_next[1]), quiet)
    show("sum_j p_{t+1}(j)", 1.0, float(p_next.sum()), quiet)
    return {"p_next": p_next}


# %% 05. Eigenvalues and eigenvectors
def eigen(quiet: bool = False) -> dict:
    """Section 1.16: the characteristic equation of A, its two eigenvalues and its eigenvectors."""
    trace = float(A_MATRIX.trace())  # The sum of the eigenvalues, 4.
    determinant = float(torch.linalg.det(A_MATRIX))  # The product of the eigenvalues, 3.
    values, vectors = torch.linalg.eigh(A_MATRIX)  # A is symmetric, so the eigenvalues are real.
    order = values.argsort(descending=True)  # The chapter indexes lambda_1 = 3 before lambda_2 = 1.
    values, vectors = values[order], vectors[:, order]  # [2] and [2, 2].
    vectors = vectors / vectors[0]  # Rescale each one to first entry one, here (1, ±1).
    residual = (A_MATRIX @ vectors - vectors * values).abs().max()  # A v - lambda v at both pairs.
    show("trace A, lambda_1 + lambda_2", 4.0, trace, quiet)
    show("det A, lambda_1 lambda_2", 3.0, determinant, quiet)
    show("lambda_1", 3.0, float(values[0]), quiet)
    show("lambda_2", 1.0, float(values[1]), quiet)
    show("v_1 second entry over first", 1.0, float(vectors[1, 0]), quiet)
    show("v_2 second entry over first", -1.0, float(vectors[1, 1]), quiet)
    show("largest |A v - lambda v|", 0.0, float(residual), quiet)
    return {"values": values, "vectors": vectors}


# %% 06. Powers along an eigendirection
def powers(values: torch.Tensor, vectors: torch.Tensor, quiet: bool = False) -> dict:
    """Section 1.16.2: x_n = c_1 lambda_1^n v_1 + c_2 lambda_2^n v_2, against A^n x_0 taken directly."""
    coefficients = torch.linalg.solve(vectors, X_START)  # c_1 = 2 and c_2 = 1 for x_0 = (3, 1).
    eigen_route = vectors @ (coefficients * values ** 2)  # The n = 2 state along the two directions.
    direct = torch.linalg.matrix_power(A_MATRIX, 2) @ X_START  # A^2 x_0, squaring the matrix first.
    far = vectors @ (coefficients * values ** 10)  # The same decomposition carried to n = 10.
    show("c_1", 2.0, float(coefficients[0]), quiet)
    show("c_2", 1.0, float(coefficients[1]), quiet)
    show("lambda_1^10", 59049.0, float(values[0]) ** 10, quiet)
    show("x_2 first entry, eigen route", 19.0, float(eigen_route[0]), quiet)
    show("x_2 second entry, eigen route", 17.0, float(eigen_route[1]), quiet)
    show("x_2 first entry, A^2 x_0", 19.0, float(direct[0]), quiet)
    show("x_10 first entry", 118099.0, float(far[0]), quiet)
    show("x_10 second entry", 118097.0, float(far[1]), quiet)
    return {"x_two": eigen_route, "x_ten": far}


# %% 07. Diagonalization and the dominant eigenvalue
def diagonalization(values: torch.Tensor, vectors: torch.Tensor, quiet: bool = False) -> dict:
    """Sections 1.16.3 and 1.16.4: A = V Lambda V^{-1}, so A^n = V Lambda^n V^{-1} telescopes."""
    inverse = torch.linalg.inv(vectors)  # V^{-1} = 0.5 [[1, 1], [1, -1]]; [2, 2].
    rebuilt = vectors @ torch.diag(values) @ inverse  # V Lambda V^{-1} returns A itself; [2, 2].
    cube = vectors @ torch.diag(values ** 3) @ inverse  # V diag(27, 1) V^{-1}, the n = 3 power.
    direct_cube = torch.linalg.matrix_power(A_MATRIX, 3)  # A^3 computed by repeated multiplication.
    dominant = torch.tensor([2.0, 0.5]) ** 10  # The coefficients 2^10 and 0.5^10 of Section 1.16.4.
    show("V^{-1}_11", 0.5, float(inverse[0, 0]), quiet)
    show("largest |V Lambda V^{-1} - A|", 0.0, float((rebuilt - A_MATRIX).abs().max()), quiet)
    show("A^3_11 through the eigenvalues", 14.0, float(cube[0, 0]), quiet)
    show("A^3_12 through the eigenvalues", 13.0, float(cube[0, 1]), quiet)
    show("largest |V Lambda^3 V^{-1} - A^3|", 0.0, float((cube - direct_cube).abs().max()), quiet)
    show("dominant coefficient 2^10", 1024.0, float(dominant[0]), quiet)
    show("decaying coefficient 0.5^10", 0.000977, float(dominant[1]), quiet)
    show("ratio of the two, in millions", 1.05, float(dominant[0] / dominant[1]) / 1e6, quiet)
    return {"inverse": inverse, "cube": cube}


# %% 08. Stationary distributions
def stationary(steps: int, quiet: bool = False) -> dict:
    """Section 1.17: p* solves (P^T - I) p* = 0 with sum one, and the chain halves its deviation."""
    drift = (P_CHAIN.T - torch.eye(2))[0]  # The first row of P^T - I, or 0.3 p_2 = 0.2 p_1.
    system = torch.stack([drift, torch.ones(2)])  # That one equation, with normalization; [2, 2].
    p_star = torch.linalg.solve(system, torch.tensor([0.0, 1.0]))  # The lambda = 1 eigenvector; [2].
    trace, determinant = float(P_CHAIN.trace()), float(torch.linalg.det(P_CHAIN))  # 1.5 and 0.5.
    second = (trace - math.sqrt(trace ** 2 - 4.0 * determinant)) / 2.0  # The smaller root, 0.5.
    p_t, history = torch.tensor([0.5, 0.5]), []  # Start from the even split of Section 1.15.1.
    for _ in range(max(steps, 1)):  # Iterate p_{t+1} = P^T p_t for as many steps as requested.
        p_t = P_CHAIN.T @ p_t  # One more application of the transition matrix; [2].
        history.append(p_t.clone())
    show("p*_1", 0.6, float(p_star[0]), quiet)
    show("p*_2", 0.4, float(p_star[1]), quiet)
    show("largest |P^T p* - p*|", 0.0, float((P_CHAIN.T @ p_star - p_star).abs().max()), quiet)
    show("trace P", 1.5, trace, quiet)
    show("det P", 0.5, determinant, quiet)
    show("second eigenvalue, the rate", 0.5, second, quiet)
    for index, value in ((0, 0.55), (1, 0.575), (2, 0.5875), (9, 0.59990)):
        if index < len(history):  # The chapter prints p_1, p_2, p_3 and p_10 of this sequence.
            show(f"p_{index + 1}(1)", value, float(history[index][0]), quiet)
    deviation = float((history[-1] - p_star).abs().max())  # |d_n| = 0.5^n |d_0| after n steps.
    show(f"|d_n| after {len(history)} steps", 0.1 * second ** len(history), deviation, quiet)
    return {"p_star": p_star, "second": second, "history": history, "deviation": deviation}


# %% 09. From discrete steps to continuous time
def matrix_exponential(values: torch.Tensor, vectors: torch.Tensor, quiet: bool = False) -> dict:
    """Section 1.18.3: the four-term series and the eigenvalue formula for e^{tA} at t = 0.1."""
    t = 0.1  # The small time at which the chapter truncates the series.
    series, power, factorial = torch.eye(2), torch.eye(2), 1.0  # Accumulate I + tA + t^2A^2/2 + ...
    for k in range(1, 4):  # Terms k = 1, 2, 3, and with the identity they make four.
        power, factorial = power @ A_MATRIX, factorial * k  # A^k and k factorial.
        series = series + t ** k * power / factorial  # Add t^k A^k / k!; [2, 2].
    discarded = t ** 4 * (power @ A_MATRIX) / 24.0  # The first omitted term, t^4 A^4 / 4!.
    scaled = torch.diag((t * values).exp())  # e^{t Lambda}, the diagonal of entry exponentials; [2, 2].
    eigen_route = vectors @ scaled @ torch.linalg.inv(vectors)  # V e^{t Lambda} V^{-1}; [2, 2].
    gap = float((series - eigen_route).abs().max())  # How far four terms fall short at t = 0.1.
    coefficients = torch.linalg.solve(vectors, X_START)  # c_1 = 2, c_2 = 1, as in Section 1.16.2.
    x_one = vectors @ (coefficients * values.exp())  # x_t = sum_i c_i e^{t lambda_i} v_i at t = 1.
    show("four-term series, entry 11", 1.22733, float(series[0, 0]), quiet)
    show("four-term series, entry 12", 0.12217, float(series[0, 1]), quiet)
    show("eigenvalue route, entry 11", 1.22751, float(eigen_route[0, 0]), quiet)
    show("eigenvalue route, entry 12", 0.12234, float(eigen_route[0, 1]), quiet)
    show("largest gap between the routes", 1.8e-4, gap, quiet)
    show("first discarded term, entry 11", 1.71e-4, float(discarded[0, 0]), quiet)
    show("first discarded term, entry 12", 1.67e-4, float(discarded[0, 1]), quiet)
    show("x_1 first entry, 2e^3 + e", 42.889, float(x_one[0]), quiet)
    show("x_1 second entry, 2e^3 - e", 37.453, float(x_one[1]), quiet)
    show("decay e^{-2t} at t = 1", 0.1353, math.exp(-2.0), quiet)
    show("decay e^{-2t} at t = 3", 0.002479, math.exp(-6.0), quiet)
    show("sqrt(lambda_1), Gaussian long axis", 1.732, math.sqrt(float(values[0])), quiet)
    return {"series": series, "eigen_route": eigen_route, "gap": gap, "x_one": x_one}


# %% 10. The report
def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    parser.add_argument("--steps", type=int, default=10, help="iterations of p_{t+1} = P^T p_t")
    parser.add_argument("--seed", type=int, default=0, help="random seed, for a repeatable run")
    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 run repeats under the same seed.
    if not args.quiet:
        print("Chapter 1, Sections 1.12 to 1.18, worked example by worked example\n")
    triangle = simplex(args.quiet)
    logits = softmax(args.quiet)
    step = one_transition(args.quiet)
    spectrum = eigen(args.quiet)
    grown = powers(spectrum["values"], spectrum["vectors"], args.quiet)
    diagonalization(spectrum["values"], spectrum["vectors"], args.quiet)
    chain = stationary(args.steps, args.quiet)
    flow = matrix_exponential(spectrum["values"], spectrum["vectors"], args.quiet)

    report = {
        "E[h(X)] on the three bases": triangle["expectation"],
        "softmax(z)_1": float(logits["probabilities"][0]),
        "p_{t+1}(1) after one step": float(step["p_next"][0]),
        "lambda_1 of A": float(spectrum["values"][0]),
        "lambda_2 of A": float(spectrum["values"][1]),
        "x_10 first entry": float(grown["x_ten"][0]),
        "p*_1, stationary": float(chain["p_star"][0]),
        "second eigenvalue of P": chain["second"],
        "chain steps run": len(chain["history"]),
        "deviation from p* at the end": chain["deviation"],
        "e^{tA}_11 at t = 0.1": float(flow["eigen_route"][0, 0]),
        "four-term truncation error": flow["gap"],
    }
    print_report("Linear algebra, Sections 1.12 to 1.18", report)
    save_run(args.out, config=vars(args), report=report)


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