File size: 12,675 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
"""Diamond Maps and Meta Flow Maps, the posterior over endpoints that guidance needs.

To steer a flow toward a reward we need the value function, the log expected exponential reward
over the terminal states reachable from the current one, and that average runs over a posterior. A
deterministic map returns one endpoint, so it can only estimate the value by the reward at that
endpoint, low by the Jensen gap. If we give the map a fresh noise argument and index it by the
state it is conditioned on, it becomes a family of inner flow maps, one per conditioning pair,
and their draws are posterior samples.

Chapter 6, Sections 6.33 and 6.34 of the notes. Runs on a two-point reward problem and on the four
Gaussian blobs of ``cis6270.data.gaussian_mixture_2d``. Prints the Jensen gap of 0.434, the bias
of the K-sample value at K = 1, 2, 4 and 8, the softmax-weighted value gradient of -0.4483, the
importance weight that pulls a renoised proposal from 0.420 back to 0.0131, and the stop-gradient
fixed point 0.625 of Meta Flow Maps.

    python lecture_6/posterior_map.py                 # 16 inner samples per state
    python lecture_6/posterior_map.py --steps 20      # fewer inner substeps, for a first look
"""

# %% 01. Imports and configuration
import math

import torch

from cis6270.data import gaussian_mixture_2d
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed

torch.set_num_threads(1)  # The work here is small, and one thread keeps the timing stable.
# ``tests/conftest.py`` sets the same thing for the test session, and this line acts when a reader
# runs the script directly.
torch.set_default_dtype(torch.float64)  # Exact posteriors, compared against sample averages.


INNER_STEPS = 48  # Substeps of the inner flow, the leg from noise to a posterior sample.


# %% 02. The tilt, the value function and the Jensen gap
#
# The tilted target is the data times the exponential reward, and the same reweighting acts on the
# posterior over endpoints at every intermediate state. A deterministic map returns the posterior
# mean, so its estimate of the value is the reward there, and the exponential is convex, so that
# estimate is always the low one.
def two_point(reward_gap: float = 2.0, x: float = 0.7, t: float = 0.8):
    """The chapter's two-point example, with z in {0, 2}, r(z) = z and a linear schedule."""
    weights = torch.tensor([1.0, math.exp(reward_gap)])  # e^{r(0)} and e^{r(2)}.
    tilted = weights / weights.sum()  # The tilted target over the two points.
    far = math.exp(-((x - t * 2.0) ** 2) / (2.0 * (1.0 - t) ** 2))  # Unnormalized, at z = 2.
    near = math.exp(-(x ** 2) / (2.0 * (1.0 - t) ** 2))  # Unnormalized, at z = 0.
    posterior = torch.tensor([near, far]) / (near + far)  # The posterior over endpoints at x.
    value = math.log(float((posterior * weights).sum()))  # V_t(x) at that state.
    tilted_posterior = posterior * weights / float((posterior * weights).sum())
    exact = math.log(0.5 + 0.5 * math.exp(reward_gap))  # V over the data itself.
    return {
        "tilted target": [round(v, 4) for v in tilted.tolist()],
        "posterior at x=0.7": [round(v, 4) for v in posterior.tolist()],
        "tilted posterior": [round(v, 4) for v in tilted_posterior.tolist()],
        "V_t(0.7)": value,
        "V over the data": exact,
        "single-endpoint estimate": 1.0 * reward_gap / 2.0,  # r at the posterior mean z = 1.
        "Jensen gap": exact - reward_gap / 2.0,
    }


def value_bias(reward_gap: float = 2.0):
    """E[V_K] over K independent posterior draws, and it approaches the exact value like 1/K."""
    exact = math.log(0.5 + 0.5 * math.exp(reward_gap))
    out = {}
    for k in (1, 2, 4, 8):
        expectation = sum(
            math.comb(k, j) * 2.0 ** (-k)
            * math.log(((k - j) + j * math.exp(reward_gap)) / k) for j in range(k + 1))
        out[f"E[V_{k}]"] = expectation
        out[f"bias at K={k}"] = exact - expectation
    return out


# %% 03. The value gradient from K samples
#
# If we differentiate the logarithm of an average of exponentials we get a softmax over the
# rewards, so a sample whose reward is far above the others dominates the direction and a set with
# equal rewards gives their plain average.
def value_gradient(rewards, gradients):
    """The softmax weights over the rewards, and the weighted average of the reward gradients."""
    weights = torch.softmax(rewards, dim=0)  # [K].
    return weights, float((weights * gradients).sum()), float(gradients.mean())


# %% 04. The inner clock
#
# For a fixed conditioning pair the map is an ordinary two-time flow map on an inner time axis
# running from the noise draw at 0 to a posterior sample at 1. Its velocity is the flow matching
# velocity of the posterior, available in closed form over the training points.
def posterior_weights(x, t, data):
    """p_{1|t}(z_i | x), a softmax over the training points under the linear interpolant. [B, N]."""
    spread = max(1.0 - t, 1e-6)
    squared = ((x[:, None, :] - t * data[None]) ** 2).sum(-1)  # [B, N].
    return torch.softmax(-squared / (2.0 * spread ** 2), dim=1)  # [B, N].


def inner_map(noise, weights, data, substeps: int):
    """Y_{0,1}(eps | x, t), the inner flow from a Gaussian draw to a posterior sample. [B, K, 2]."""
    y = noise  # [B, K, 2] one inner state per sample.
    step = 1.0 / substeps
    for i in range(substeps):
        a = i * step  # The inner time.
        squared = ((y[:, :, None, :] - a * data[None, None]) ** 2).sum(-1)  # [B, K, N].
        inner = torch.softmax(torch.log(weights[:, None, :].clamp_min(1e-30))
                              - squared / (2.0 * max(1.0 - a, 1e-6) ** 2), dim=-1)  # [B, K, N].
        denoiser = inner @ data  # [B, K, 2] the inner posterior mean.
        y = y + step * (denoiser - y) / max(1.0 - a, 1e-6)  # Euler on the inner velocity.
    return y


# %% 05. Posterior samples from a map we already have
#
# We can renoise the current state back to an earlier time with fresh noise and push it through
# the existing map. That discards information and widens the law of the endpoint. The correction
# is an importance weight, written as a shift of the reward by the log likelihood of the current
# state given that endpoint.
def renoise_example(x: float = 0.7, t: float = 0.8, earlier: float = 0.6):
    """The chapter's renoised proposal, and the importance weight that recovers the posterior."""
    scale = earlier / t  # alpha_{t'}/alpha_t on the linear schedule.
    variance = (1.0 - earlier) ** 2 - scale ** 2 * (1.0 - t) ** 2  # The variance added back.
    mean = scale * x  # The center of the proposal.
    boundary = (earlier * 2.0 / 2.0 - mean) / math.sqrt(variance)  # Where the map switches mode.
    proposal_far = 0.5 * math.erfc(boundary / math.sqrt(2.0))  # Share of draws reaching z = 2.
    penalty_far = (x - t * 2.0) ** 2 / (2.0 * (1.0 - t) ** 2)  # Log likelihood of x given z = 2.
    penalty_near = x ** 2 / (2.0 * (1.0 - t) ** 2)  # The same for z = 0.
    far = proposal_far * math.exp(-penalty_far)
    near = (1.0 - proposal_far) * math.exp(-penalty_near)
    return {"renoise scale": scale, "renoise variance": variance,
            "renoise standard deviation": math.sqrt(variance),
            "proposal weight on the far mode": proposal_far,
            "penalty at z=2": penalty_far, "penalty at z=0": penalty_near,
            "corrected weight on the far mode": far / (far + near)}


# %% 06. Meta Flow Maps, the stop-gradient form
#
# The controlled velocity needs a ratio of two expectations, and if we regress onto a per-sample
# ratio the result is biased at every sample size. If we multiply through by the denominator, the
# condition that remains carries one stop-gradient, and its fixed point is the ratio of the
# expectations.
def stop_gradient_fixed_point(weights, gradients, diffusion_squared: float = 2.0):
    """The fixed point of the loss, the per-sample ratio it is compared against, and the gap."""
    exact = 0.5 * diffusion_squared * float(gradients.mean()) / float(weights.mean())
    naive = float((gradients / weights).mean()) * 0.5 * diffusion_squared
    covariance = float(gradients.mean()) - float((gradients / weights).mean() * weights.mean())
    return {"E[w]": float(weights.mean()), "E[G]": float(gradients.mean()),
            "stop-gradient fixed point": exact, "per-sample ratio average": naive,
            "gap": naive - exact, "covariance of G/w and w": covariance,
            "gap from the covariance": -covariance / float(weights.mean())}


# %% 07. The report
def main() -> None:
    parser = common_parser(__doc__.split("\n")[0])
    parser.set_defaults(steps=INNER_STEPS, samples=16)
    args = parser.parse_args()  # --steps is the inner substep count here, since nothing trains.
    set_seed(args.seed)
    pick_device(args.device)  # Every number here is double precision, and not every
    # accelerator carries it, so the work stays on the CPU whatever --device names.
    substeps = max(args.steps, 8)

    report = two_point()
    report.update(value_bias())
    weights, weighted, plain = value_gradient(torch.tensor([0.2, 1.5, 0.9]),
                                              torch.tensor([0.4, -1.2, 0.5]))
    report["softmax weights"] = [round(v, 4) for v in weights.tolist()]
    report["weighted value gradient"] = weighted
    report["plain average gradient"] = plain
    report.update(renoise_example())
    report.update(stop_gradient_fixed_point(torch.tensor([1.0, 2.0, 5.0]),
                                            torch.tensor([0.4, 1.0, 3.6])))
    if not args.quiet:
        print(f"Jensen gap {report['Jensen gap']:.3f} between the exact value "
              f"{report['V over the data']:.4f} and the single endpoint "
              f"{report['single-endpoint estimate']:.3f}")
        print(f"renoised proposal {report['proposal weight on the far mode']:.3f} corrected to "
              f"{report['corrected weight on the far mode']:.4f} against the posterior "
              f"{report['posterior at x=0.7'][1]:.4f}")

    # %% 08. The same quantities on the four Gaussian blobs
    data = gaussian_mixture_2d(num_points=256, seed=args.seed).double()  # [N, 2] the endpoints.
    outer_t = 0.6  # The outer time the sampler is conditioned at.
    torch.manual_seed(args.seed + 1)
    index = torch.randint(0, len(data), (32,))
    states = ((1.0 - outer_t) * torch.randn(32, 2) + outer_t * data[index])  # [32, 2] contexts.
    states.requires_grad_(True)
    exact_weights = posterior_weights(states, outer_t, data)  # [32, N] the exact posterior.
    reward = data[:, 0]  # r(z) = the first coordinate, a reward with a clear direction.
    exact_value = torch.log((exact_weights * reward.exp()[None, :]).sum(1))  # [32] V_t(x).
    exact_grad = torch.autograd.grad(exact_value.sum(), states, retain_graph=True)[0]  # [32, 2].

    noise = torch.randn(32, args.samples, 2)  # [32, K, 2] inner draws, one per posterior sample.
    drawn = inner_map(noise, exact_weights, data, substeps)  # [32, K, 2] posterior samples.
    sampled_reward = drawn[:, :, 0]  # [32, K] the reward at each sample.
    estimate = torch.logsumexp(sampled_reward, dim=1) - math.log(args.samples)  # [32] V hat.
    sample_grad = torch.autograd.grad(estimate.sum(), states, retain_graph=True)[0]  # [32, 2].
    deterministic = (exact_weights @ reward)  # [32] the reward at the posterior mean.

    exact_value, estimate = exact_value.detach(), estimate.detach()  # Past the two gradients.
    deterministic, drawn = deterministic.detach(), drawn.detach()
    report["exact V, averaged"] = float(exact_value.mean())
    report[f"V from {args.samples} samples"] = float(estimate.mean())
    report["single endpoint, averaged"] = float(deterministic.mean())
    report["Jensen gap on the blobs"] = float((exact_value - deterministic).mean())
    report["posterior mean error"] = float(
        (drawn.mean(1) - exact_weights.detach() @ data).norm(dim=1).mean())
    report["value gradient error"] = float((sample_grad - exact_grad).norm(dim=1).mean())
    report["exact value gradient norm"] = float(exact_grad.norm(dim=1).mean())
    states = states.detach()
    samples = [f"state ({float(x[0]):+.2f}, {float(x[1]):+.2f})  exact V {float(v):.3f}  "
               f"{args.samples} samples {float(e):.3f}  one endpoint {float(d):.3f}"
               for x, v, e, d in zip(states[:4], exact_value[:4], estimate[:4], deterministic[:4])]

    print_report("Diamond Maps and Meta Flow Maps, Chapter 6", report, samples)
    save_run(args.out, config=vars(args), report=report, samples=samples)


if __name__ == "__main__":
    main()