File size: 13,566 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
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
"""The Schrodinger bridge over a finite set: rates in place of drifts.

A finite state space has no small displacement, so the control is a reweighting of jump rates and
relative entropy on paths is a time integral of a Bregman divergence between two rate matrices.
Everything else carries over. The chain rule splits the problem into an endpoint term and a bridge
term, and the two potentials are the Sinkhorn scalings of the reference endpoint law. Under Doob's
h-transform each reference rate is multiplied by the ratio of a backward potential at the
destination to its value at the current state.

Chapter 7, Sections 7.21 to 7.23.3 of the notes. Runs the two-state continuous-time example with
the generator Q, the source law (0.5, 0.5) and the requested terminal law (0.2, 0.8). Prints the
bridge rates at three times, the reverse rates, the Euler sampler's error as the step shrinks,
and the cost computed on the endpoints and on the paths.

    python lecture_7/discrete_bridge.py              # 10 Euler steps, then 20, 40 and 100
    python lecture_7/discrete_bridge.py --steps 20   # a finer sampler, for a first look
"""

# %% 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  # Double precision for the matrix exponentials and the time integral.

Q = torch.tensor([[-1.0, 1.0], [1.5, -1.5]], dtype=DTYPE)  # The reference generator.
PI_0 = torch.tensor([0.5, 0.5], dtype=DTYPE)  # The source law.
PI_1 = torch.tensor([0.2, 0.8], dtype=DTYPE)  # The requested terminal law.
STATIONARY = torch.tensor([0.6, 0.4], dtype=DTYPE)  # Proportional to (1.5, 1), the rows of Q.
GRID = 1001  # Points in the Simpson rule for the time integral of the rate error.


# %% 02. Relative entropy between two jump processes
#
# We collect the one-step relative entropies and let the step go to zero. What is left is a time
# integral of the Bregman divergence of u log u between the two rates. The divergence is
# nonnegative and vanishes only when the two rates agree. It is not symmetric in the ratio.
# Section 7.21.1.


def rate_error(controlled: torch.Tensor, reference: torch.Tensor) -> torch.Tensor:
    """Q^theta log(Q^theta / Q) - Q^theta + Q, the Bregman divergence of u log u, entry by entry."""
    safe = controlled.clamp(min=1e-300)  # A zero rate contributes the reference rate alone.
    return controlled * (safe / reference).log() - controlled + reference


def scaling_cost(c: float) -> float:
    """c log c - c + 1, the cost per unit of reference rate of scaling every rate by c."""
    return c * math.log(c) - c + 1.0


# %% 03. The Schrodinger problem for a chain
#
# The endpoint law of a chain with a time-independent generator is diag(pi_0^ref) e^Q. Sinkhorn
# scales that matrix to the two marginals, and those scalings are the two potentials.
# Section 7.21.2.


def endpoint_law(generator: torch.Tensor, start: torch.Tensor) -> torch.Tensor:
    """R_01 = diag(pi_0) e^Q, the reference probability of starting at x and ending at y."""
    return torch.diag(start) @ torch.matrix_exp(generator)  # [S, S].


def sinkhorn(matrix: torch.Tensor, start: torch.Tensor, end: torch.Tensor, sweeps: int = 400
             ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
    """Scale a positive matrix to the two marginals, returning f, g and the plan f R_01 g."""
    f = torch.ones_like(start)
    g = torch.ones_like(end)
    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.
    return f, g, f[:, None] * matrix * g[None, :]


def relative_entropy(p: torch.Tensor, q: torch.Tensor) -> float:
    """Sum over entries of p log(p / q), for two positive matrices or vectors."""
    mask = p > 0
    return float((p[mask] * (p[mask] / q[mask]).log()).sum())


# %% 04. Rates reweighted by an h-transform
#
# The backward potential is g averaged over the reference from the current time to time 1. We
# multiply each off-diagonal rate by the ratio of that potential at the destination to its value at
# the current state, and then set the diagonal so that the row sums to zero. Section 7.22.


def backward_potential(generator: torch.Tensor, g: torch.Tensor, t: float) -> torch.Tensor:
    """phi_t = e^{Q(1-t)} g, the reference average of g at the final state seen from time t."""
    return torch.matrix_exp(generator * (1.0 - t)) @ g  # [S].


def tilted_generator(generator: torch.Tensor, phi: torch.Tensor) -> torch.Tensor:
    """Q*(x, y) = Q(x, y) phi(y) / phi(x) off the diagonal, with the diagonal closing each row."""
    tilted = generator * phi[None, :] / phi[:, None]  # [S, S] every entry reweighted.
    tilted.fill_diagonal_(0.0)  # Discard the reweighted diagonal, a quantity with no meaning.
    return tilted - torch.diag(tilted.sum(dim=1))  # Each row now sums to zero.


def bridge_marginal(generator: torch.Tensor, start: torch.Tensor, f: torch.Tensor,
                    g: torch.Tensor, t: float) -> torch.Tensor:
    """p*_t = psi_t phi_t, the forward potential carried under the reference times the backward one."""
    psi = (start * f) @ torch.matrix_exp(generator * t)  # [S] the forward factor.
    return psi * backward_potential(generator, g, t)  # [S] their entrywise product.


# %% 05. Sampling the bridge with Euler steps
#
# Over a step the chain moves to a different state with probability dt Q*(x, y) and stays with the
# remaining probability. This is the Euler sampler of Chapter 4 applied to a generator that
# changes with time. Section 7.23.1.


def euler_marginal(generator: torch.Tensor, start: torch.Tensor, f: torch.Tensor,
                   g: torch.Tensor, steps: int) -> tuple[torch.Tensor, list[float]]:
    """Propagate p <- p (I + dt Q*_t) and record the probability of leaving state 1 at each step."""
    dt = 1.0 / steps
    p = start.clone()
    leaving = []
    identity = torch.eye(len(start), dtype=DTYPE)
    for step in range(steps):
        tilted = tilted_generator(generator, backward_potential(generator, g, step * dt))
        leaving.append(float(tilted[0, 1] * dt))  # The jump probability out of state 1.
        p = p @ (identity + dt * tilted)  # One Euler step of the master equation.
    return p, leaving


# %% 06. The same bridge run backwards
#
# We match the probability flux on an edge in both directions. That fixes the reverse rate at
# the forward rate times the ratio of the marginal at the two ends of the edge. Section 7.23.2.


def reverse_rates(tilted: torch.Tensor, marginal: torch.Tensor) -> torch.Tensor:
    """Q_hat(y, x) = Q(x, y) p_t(x) / p_t(y), the rates under which the flux on an edge matches."""
    return (tilted * marginal[:, None] / marginal[None, :]).T  # [S, S], reversed in both indices.


# %% 07. The cost of the bridge, computed twice
#
# On the endpoints the cost is the relative entropy of the plan from the reference endpoint law. On
# the paths it is the time integral of the rate error, weighted by the marginal of the bridge at
# that time. The two agree. Section 7.23.3.


def path_cost(generator: torch.Tensor, start: torch.Tensor, f: torch.Tensor, g: torch.Tensor,
              points: int = GRID) -> tuple[float, float]:
    """Integrate the weighted rate error over [0, 1] by Simpson's rule.

    Returns the integral and the value of the integrand at t = 1/2, the one the chapter prints.
    """
    off = ~torch.eye(len(generator), dtype=torch.bool)  # [S, S] true away from the diagonal.
    times = torch.linspace(0.0, 1.0, points, dtype=DTYPE)
    values = []
    for t in times.tolist():
        tilted = tilted_generator(generator, backward_potential(generator, g, t))
        marginal = bridge_marginal(generator, start, f, g, t)
        error = torch.zeros_like(tilted)
        error[off] = rate_error(tilted[off], generator[off])  # Only the edges carry a cost.
        values.append(float((marginal * error.sum(dim=1)).sum()))
    weights = torch.ones(points, dtype=DTYPE)
    weights[1:-1:2] = 4.0  # Simpson's rule on an even number of intervals.
    weights[2:-1:2] = 2.0
    integral = float((weights * torch.tensor(values, dtype=DTYPE)).sum() / (3.0 * (points - 1)))
    return integral, values[(points - 1) // 2]


# %% 08. The report
def main() -> None:
    parser = common_parser(__doc__.split("\n")[0])
    parser.set_defaults(steps=10)  # --steps is the coarsest number of Euler steps in the table.
    args = parser.parse_args()
    set_seed(args.seed)
    loud = not args.quiet
    coarse = max(args.steps, 2)

    if loud:
        print("Scaling every reference rate by c costs c log c - c + 1 per unit of rate")
        print(f"  halving {scaling_cost(0.5):.4f}, doubling {scaling_cost(2.0):.4f}")

    R01 = endpoint_law(Q, PI_0)
    f, g, plan = sinkhorn(R01, PI_0, PI_1)
    phi_half = backward_potential(Q, g, 0.5)
    tilted_half = tilted_generator(Q, phi_half)
    tilted_zero = tilted_generator(Q, backward_potential(Q, g, 0.0))
    tilted_one = tilted_generator(Q, g)
    marginal_half = bridge_marginal(Q, PI_0, f, g, 0.5)
    marginal_one = bridge_marginal(Q, PI_0, f, g, 1.0)
    reverse = reverse_rates(tilted_half, marginal_half)
    if loud:
        print(f"\ne^Q = {[[round(float(z), 4) for z in row] for row in torch.matrix_exp(Q)]}")
        print(f"R_01 = {[[round(float(z), 4) for z in row] for row in R01]}, "
              f"column sums {[round(float(z), 4) for z in R01.sum(0)]}")
        print(f"  f = {[round(float(z), 4) for z in f]}, g = {[round(float(z), 4) for z in g]}, "
              f"g ratio {float(g[1] / g[0]):.3f}")
        print(f"  pi* = {[[round(float(z), 4) for z in row] for row in plan]}")
        print(f"  phi_1/2 = {[round(float(z), 4) for z in phi_half]}, "
              f"ratio {float(phi_half[1] / phi_half[0]):.2f}")
        print(f"  Q*_1/2 = {[[round(float(z), 4) for z in row] for row in tilted_half]}")
        print(f"  rates out of the two states: at t = 0 "
              f"{float(tilted_zero[0, 1]):.4f} and {float(tilted_zero[1, 0]):.4f}, "
              f"at t = 1 {float(tilted_one[0, 1]):.4f} and {float(tilted_one[1, 0]):.4f}")
        print(f"  marginal at t = 1/2 {[round(float(z), 4) for z in marginal_half]}, "
              f"at t = 1 {[round(float(z), 4) for z in marginal_one]}")
        print(f"  reverse rates at t = 1/2: {float(reverse[1, 0]):.4f} from 2 back to 1, "
              f"{float(reverse[0, 1]):.4f} from 1 back to 2")
        print(f"  the flux on the edge: {float(marginal_half[0] * tilted_half[0, 1]):.4f} "
              f"against {float(marginal_half[1] * reverse[1, 0]):.4f}")

    sampler = {}
    for steps in (coarse, 2 * coarse, 4 * coarse, 10 * coarse):
        terminal, leaving = euler_marginal(Q, PI_0, f, g, steps)
        sampler[steps] = (terminal, float((terminal - PI_1).abs().sum() / 2), leaving)
    if loud:
        print("\nsteps   terminal law                 error    first and last jump probability")
        for steps, (terminal, error, leaving) in sampler.items():
            print(f"  {steps:<5d} ({float(terminal[0]):.4f}, {float(terminal[1]):.4f})"
                  f"           {error:.4f}   {leaving[0]:.4f}, {leaving[-1]:.4f}")

    endpoint_cost = relative_entropy(plan, R01)
    integral, at_half = path_cost(Q, PI_0, f, g)
    speeds = {}
    for lam in (0.5, 1.0, 2.0, 4.0):
        scaled_law = endpoint_law(lam * Q, PI_0)
        _, _, scaled_plan = sinkhorn(scaled_law, PI_0, PI_1)
        speeds[lam] = relative_entropy(scaled_plan, scaled_law)
    limit = relative_entropy(PI_1, STATIONARY)
    if loud:
        print(f"\nKL(pi* || R_01) = {endpoint_cost:.4f}")
        print(f"  the integrand at t = 1/2 is {at_half:.4f}, and its integral is {integral:.4f}")
        print(f"  at lambda = 1/2, 1, 2, 4 the cost is "
              f"{[round(speeds[k], 4) for k in (0.5, 1.0, 2.0, 4.0)]}")
        print(f"  the limit KL(pi_1 || (0.6, 0.4)) is {limit:.4f}")

    report = {
        "cost of halving every rate": scaling_cost(0.5),
        "cost of doubling every rate": scaling_cost(2.0),
        "R_01 column sums": [round(float(z), 4) for z in R01.sum(dim=0)],
        "f": [round(float(z), 4) for z in f],
        "g": [round(float(z), 4) for z in g],
        "pi*_11": float(plan[0, 0]),
        "phi at t = 1/2": [round(float(z), 4) for z in phi_half],
        "bridge rate out of state 1": float(tilted_half[0, 1]),
        "bridge rate out of state 2": float(tilted_half[1, 0]),
        "rate out of state 1 at t = 1": float(tilted_one[0, 1]),
        "marginal at t = 1/2": [round(float(z), 4) for z in marginal_half],
        "marginal at t = 1": [round(float(z), 4) for z in marginal_one],
        "reverse rate, 2 back to 1": float(reverse[1, 0]),
        "Euler error at the coarsest step": sampler[coarse][1],
        "Euler error, step halved": sampler[2 * coarse][1],
        "Euler error, step quartered": sampler[4 * coarse][1],
        "Euler error at ten times the steps": sampler[10 * coarse][1],
        "KL(pi* || R_01)": endpoint_cost,
        "rate error integrand at t = 1/2": at_half,
        "its integral over [0, 1]": integral,
        "cost at lambda = 1/2": speeds[0.5],
        "cost at lambda = 4": speeds[4.0],
        "KL(pi_1 || the stationary law)": limit,
    }
    print_report("The bridge over a finite set, Chapter 7 Sections 7.21 to 7.23.3", report)
    save_run(args.out, config=vars(args), report=report)  # Written only with --out.


if __name__ == "__main__":
    main()