File size: 12,567 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
"""Discrete Diffusion Schrodinger Bridge Matching: one categorical feature of a molecular graph.

A molecule is a graph with a categorical feature on every atom and on every pair of atoms, and the
reference resamples each feature independently, so one feature with S categories specifies the
whole construction. We scale the reference kernel to the two laws to get the bridge coupling for
that feature, and we pin a path at a known terminal graph to get the rate a network regresses onto.
Both stay usable on a state space of 10^27 graphs because the reference is a product over the
features, so every sum over graphs becomes a sum over the categories of one of them.

Chapter 7, Section 7.24 of the notes. Runs on one bond with three types, no bond, single and
double, at the survival probability r = 0.4. Prints the kernel and its generator, the bridge
coupling and the mass it leaves unchanged, the pinned rates and the posterior average a network
learns, and the counting over the forty-five features of a nine-atom graph.

    python lecture_7/ddsbm.py              # 200 Sinkhorn sweeps on the bond coupling
    python lecture_7/ddsbm.py --steps 1    # the first sweep alone, computed by hand
"""

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

import math

import torch

from cis6270.runner import common_parser, print_report, save_run, set_seed

DTYPE = torch.float64  # The chapter quotes four figures of the coupling and of the pinned rates.

PRIOR = torch.full((3,), 1.0 / 3.0, dtype=DTYPE)  # m, uniform over the three bond types.
BOND_0 = torch.tensor([0.7, 0.2, 0.1], dtype=DTYPE)  # pi_0, mostly no bond.
BOND_1 = torch.tensor([0.1, 0.2, 0.7], dtype=DTYPE)  # pi_1, mostly double.
SURVIVAL = 0.4  # r = alpha_bar(1) / alpha_bar(0) over the whole unit interval.
ATOMS = 9  # Heavy atoms in the molecule the chapter counts.
ATOM_TYPES, BOND_TYPES = 5, 4  # Categories of a node feature and of a bond feature.


# %% 02. The reference process on one feature
#
# The kernel keeps the category with probability r and redraws it from the prior m otherwise. Over
# a short step r = 1 - h beta(t), so the rate out of x toward a different category y is the
# resampling rate beta(t) times the chance m(y) that the redraw lands on y. Section 7.24.1.


def replacement_kernel(r: float, prior: torch.Tensor) -> torch.Tensor:
    """K_st(x, y) = r 1_{x=y} + (1 - r) m(y), the uniform-replacement kernel with a general prior."""
    return r * torch.eye(len(prior), dtype=DTYPE) + (1.0 - r) * prior[None, :]  # [S, S].


def replacement_generator(beta: float, prior: torch.Tensor) -> torch.Tensor:
    """Q_t(x, y) = beta(t) m(y) off the diagonal, with the diagonal -beta(t) (1 - m(x))."""
    rates = beta * prior[None, :].repeat(len(prior), 1)  # [S, S].
    rates = rates - torch.diag(rates.diagonal())  # Clear the diagonal before closing the row.
    return rates - torch.diag(rates.sum(dim=1))


def resampling_rate(r: float) -> float:
    """beta = -log alpha_bar(1) for the schedule alpha_bar(t) = r^t, constant in time."""
    return -math.log(r)


# %% 03. A bridge for a single bond
#
# We scale diag(pi_0) K_01 to the two marginals and get the coupling. The mass it leaves on the
# diagonal sits between the mass an independent coupling happens to leave and the largest any
# coupling can leave, and r fixes where between the two it lands. Section 7.24.2.


def sinkhorn(matrix: torch.Tensor, start: torch.Tensor, end: torch.Tensor, sweeps: int
             ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, list[float]]:
    """Scale a positive matrix to the two marginals, returning f, g, the plan and the column errors."""
    f = torch.ones_like(start)
    g = torch.ones_like(end)
    errors: list[float] = []
    for _ in range(sweeps):
        g = end / (matrix.T @ f)  # Column sums become exactly pi_1.
        f = start / (matrix @ g)  # Row sums become exactly pi_0, and the columns move again.
        errors.append(float(((f[:, None] * matrix * g[None, :]).sum(dim=0) - end).abs().sum()))
    return f, g, f[:, None] * matrix * g[None, :], errors


def held_mass(plan: torch.Tensor) -> float:
    """The trace of the plan, the fraction of bonds whose type the bridge does not change."""
    return float(plan.diagonal().sum())


# %% 04. Training with the terminal graph fixed
#
# We pin the endpoint with an h-transform, the reference probability of arriving at x_1 in the
# place of the potential, so a move onto the terminal type has its rate multiplied and a move off
# it divided. Several terminal types can occur at one state, and the minimizer of the rate error
# over a single number is their posterior average. Section 7.24.3.


def pinned_rate(generator: torch.Tensor, kernel: torch.Tensor, x: int, y: int, x1: int) -> float:
    """Q_t(x, y; x_1) = Q_t(x, y) K_t1(y, x_1) / K_t1(x, x_1), the rate of the bridge ending at x_1."""
    return float(generator[x, y] * kernel[y, x1] / kernel[x, x1])


def half_survival_time(r: float) -> float:
    """The time where alpha_bar(1) / alpha_bar(t) = r^{1-t} equals 1/2, on alpha_bar(t) = r^t."""
    return 1.0 - math.log(0.5) / math.log(r)  # At r = 0.4 this is log 0.8 / log 0.4 = 0.2435.


def rate_error(controlled: float, network: float) -> float:
    """Q^pin log(Q^pin / Q^theta) - Q^pin + Q^theta, the Bregman divergence of u log u on one edge."""
    return controlled * math.log(controlled / network) - controlled + network


def posterior_loss(rates: list[float], weights: list[float], network: float) -> float:
    """The expected rate error at a state whose terminal type is uncertain, over the posterior."""
    return sum(w * rate_error(q, network) for q, w in zip(rates, weights))


# %% 05. The factorization over a graph
#
# The reference applies the same kernel independently at every node and every pair of nodes, so the
# generator changes one feature at a time. The total jump rate out of a graph is then the sum of
# the per-feature rates, and in the pinned rate every factor cancels but the one of the feature
# that moves. Section 7.24.4.


def feature_count(atoms: int) -> tuple[int, int, int]:
    """The node features, the bond features and their total, for a molecule with this many atoms."""
    bonds = atoms * (atoms - 1) // 2  # One feature per unordered pair.
    return atoms, bonds, atoms + bonds


def state_space_size(atoms: int, atom_types: int, bond_types: int) -> float:
    """atom_types^nodes times bond_types^bonds, the number of graphs the sums would run over."""
    nodes, bonds, _ = feature_count(atoms)
    return float(atom_types ** nodes) * float(bond_types ** bonds)


def total_jump_rate(features: int, beta: float, categories: int) -> float:
    """The sum of the per-feature rates out of a graph, and it fixes the sampler's step size."""
    return features * beta * (1.0 - 1.0 / categories)  # A redraw lands elsewhere with 1 - m(x).


# %% 06. The report
def main() -> None:
    parser = common_parser(__doc__.split("\n")[0])
    parser.set_defaults(steps=200)  # --steps is the number of Sinkhorn sweeps on the bond coupling.
    args = parser.parse_args()
    set_seed(args.seed)
    loud = not args.quiet
    sweeps = max(args.steps, 1)

    kernel = replacement_kernel(SURVIVAL, PRIOR)
    beta = resampling_rate(SURVIVAL)
    rates = replacement_generator(beta, PRIOR)
    reference = torch.diag(BOND_0) @ kernel
    if loud:
        print(f"One bond with three types at r = {SURVIVAL}, beta = {beta:.4f}, "
              f"off-diagonal rate {float(rates[0, 1]):.4f}")
        print(f"  K_01 = {[[round(float(z), 1) for z in row] for row in kernel]}")
        print(f"  diag(pi_0) K_01 has column sums "
              f"{[round(float(z), 2) for z in reference.sum(dim=0)]}, so the first g is "
              f"{[round(float(z), 4) for z in BOND_1 / reference.sum(dim=0)]}")

    f, g, plan, errors = sinkhorn(reference, BOND_0, BOND_1, sweeps)
    independent = float((BOND_0 * BOND_1).sum())
    largest = float(torch.minimum(BOND_0, BOND_1).sum())
    held = {r: held_mass(sinkhorn(torch.diag(BOND_0) @ replacement_kernel(r, PRIOR),
                                  BOND_0, BOND_1, 200)[2])
            for r in (0.2, 0.4, 0.6, 0.8, 0.99)}
    if loud:
        print(f"\nAfter {sweeps} sweeps, column error {errors[-1]:.2e}")
        print(f"  f = {[round(float(z), 4) for z in f]}, "
              f"g = {[round(float(z), 4) for z in g]}")
        print(f"  pi* = {[[round(float(z), 3) for z in row] for row in plan]}")
        print(f"  mass that stays: bridge {held_mass(plan):.4f}, independent {independent:.2f}, "
              f"largest possible {largest:.1f}")
        print(f"  diagonal mass at r = 0.2, 0.4, 0.6, 0.8, 0.99: "
              f"{[round(held[r], 4) for r in (0.2, 0.4, 0.6, 0.8, 0.99)]}")

    pinned_time = half_survival_time(SURVIVAL)
    pinned_kernel = replacement_kernel(0.5, PRIOR)
    toward = pinned_rate(rates, pinned_kernel, 0, 2, 2)  # A move onto the terminal type.
    away = pinned_rate(rates, pinned_kernel, 2, 0, 2)  # A move off it.
    between = pinned_rate(rates, pinned_kernel, 0, 1, 2)  # Between two non-terminal types.
    one_edge = rate_error(toward, 1.0)  # The term one edge contributes at a network rate of 1.
    weights = [0.6, 0.4]  # The posterior over the two terminal types at this state.
    posterior = weights[0] * toward + weights[1] * between
    losses = {q: posterior_loss([toward, between], weights, q) for q in (posterior, 1.0, 0.5)}
    if loud:
        print(f"\nAt t = {pinned_time:.4f} the survival ratio to time 1 is 1/2, so K_t1 is "
              f"{float(pinned_kernel[0, 0]):.4f} on the diagonal and "
              f"{float(pinned_kernel[0, 1]):.4f} off it")
        print(f"  pinned rates: {toward:.4f} toward the terminal type, {away:.4f} away from it "
              f"and {between:.4f} between two others, a factor of {toward / away:.0f}")
        print(f"  one edge at a network rate of 1 contributes {one_edge:.4f}")
        print(f"  with two terminal types the network learns {posterior:.4f}, at a loss of "
              f"{losses[posterior]:.4f}, against {losses[1.0]:.4f} at 1 and {losses[0.5]:.4f} at 0.5")

    nodes, bonds, features = feature_count(ATOMS)
    graphs = state_space_size(ATOMS, ATOM_TYPES, BOND_TYPES)
    jump_rate = total_jump_rate(bonds, beta, BOND_TYPES)
    if loud:
        print(f"\n{ATOMS} heavy atoms: {nodes} node features and {bonds} bond features, "
              f"{features} in all")
        print(f"  {ATOM_TYPES}^{nodes} x {BOND_TYPES}^{bonds} = {graphs:.1e} graphs, "
              f"which no sum reaches")
        print(f"  the total jump rate out of a graph is {bonds} x {beta:.4f} x "
              f"(1 - 1/{BOND_TYPES}) = {jump_rate:.2f} per unit time")

    report = {
        "beta of alpha_bar(t) = 0.4^t": beta,
        "off-diagonal reference rate": float(rates[0, 1]),
        "K_01 on the diagonal": float(kernel[0, 0]),
        "K_01 off the diagonal": float(kernel[0, 1]),
        "rows of the generator sum to": float(rates.sum(dim=1).abs().max()),
        "column sums of diag(pi_0) K_01": [round(float(z), 2) for z in reference.sum(dim=0)],
        "the first g": [round(float(z), 4) for z in BOND_1 / reference.sum(dim=0)],
        "sweeps run": sweeps,
        "column error after the last sweep": errors[-1],
        "f": [round(float(z), 4) for z in f],
        "g": [round(float(z), 4) for z in g],
        "bond plan, no bond to double": float(plan[0, 2]),
        "bond mass that stays": held_mass(plan),
        "bond mass, independent coupling": independent,
        "bond mass, largest possible": largest,
        "diagonal mass at r = 0.2": held[0.2],
        "diagonal mass at r = 0.99": held[0.99],
        "time where the survival ratio is 1/2": pinned_time,
        "pinned rate toward the terminal": toward,
        "pinned rate away from it": away,
        "pinned rate between two others": between,
        "one edge at a network rate of 1": one_edge,
        "posterior average of the rates": posterior,
        "loss at the posterior average": losses[posterior],
        "loss at 1": losses[1.0],
        "loss at 0.5": losses[0.5],
        "node, bond and total features": [nodes, bonds, features],
        "graphs on nine heavy atoms": f"{graphs:.1e}",
        "total jump rate out of a graph": jump_rate,
    }
    print_report("Discrete Diffusion Schrodinger Bridge Matching, Chapter 7 Section 7.24", report)
    save_run(args.out, config=vars(args), report=report)  # Written only with --out.


if __name__ == "__main__":
    main()