File size: 11,806 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
"""Three samplers reading one score.

Once we have a score, we can generate in three ways. Ancestral DDPM sampling comes from
discretizing the reverse-time equation on a grid, and it injects fresh noise at every step. The
probability-flow ODE comes from rewriting the Laplacian of the Fokker-Planck equation as a
divergence, and it reproduces the same marginals with no noise at all. Langevin dynamics holds one
density fixed and runs its score as a drift, so it needs a ladder of noise levels to cross between
modes. We target the four-Gaussian mixture here, and its corrupted score is a closed form, so all
three run on the same exact field.

In Chapter 3 t = 0 holds a clean sample and corruption grows with t. To generate we run the same
interval backward, from t = 1 to t = 0.

Chapter 3, Sections 3.22, 3.26, 3.32, 3.34 and 3.35 of the notes. Prints the mode coverage of each
sampler, the number of score evaluations each spends, and the chapter's one backward step from
x = 1.2 under both dynamics.

    python lecture_3/samplers.py              # 200 steps for each of the three
    python lecture_3/samplers.py --steps 20   # a coarse grid, for a first look
"""

# %% 01. Imports and configuration
import math

import torch

from cis6270.data import GAUSSIAN_CENTERS, GAUSSIAN_STD
from cis6270.metrics import mean_log_density, mode_report
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed

BETA_MIN = 0.1  # beta(0) of the linear variance-preserving schedule.
BETA_MAX = 20.0  # beta(1) of the same schedule.
LADDER_START = 0.7  # Noisiest level of the Langevin ladder, in diffusion time.
LADDER_END = 0.01  # Least noisy level of the ladder.


# %% 02. The target and its exact score at any noise level
#
# If we corrupt a Gaussian mixture by X_t = a_t X_0 + sigma_t eps we get a Gaussian mixture with
# means a_t mu_m and the common variance a_t^2 s^2 + sigma_t^2, so the score of p_t is a closed
# form at every level. We count every call, since the cost of a sampler is the number of
# evaluations a trained network would have served.


def beta(t: float) -> float:
    """The noising rate beta(t) = 0.1 + 19.9 t of the linear schedule."""
    return BETA_MIN + (BETA_MAX - BETA_MIN) * t


def signal(t: float) -> float:
    """a_t = exp(-B(t)/2) with B(t) = 0.1 t + 9.95 t^2, the accumulated noising."""
    return math.exp(-(BETA_MIN * t + 0.5 * (BETA_MAX - BETA_MIN) * t ** 2) / 2.0)


class ExactScore:
    """The score of p_t at a given signal coefficient, with a counter on the calls."""

    def __init__(self):
        self.calls = 0  # One call stands for one network evaluation.

    def __call__(self, x: torch.Tensor, a_t: float) -> torch.Tensor:
        """x: [B, 2], a_t the signal coefficient -> [B, 2]."""
        self.calls += 1
        variance = a_t ** 2 * GAUSSIAN_STD ** 2 + (1.0 - a_t ** 2)  # Width of p_t.
        offset = x[:, None, :] - a_t * GAUSSIAN_CENTERS.to(x)[None, :, :]  # [B, 4, 2].
        log_weight = -(offset ** 2).sum(dim=2) / (2.0 * variance)  # [B, 4] equal mixture weights.
        responsibility = log_weight.softmax(dim=1)[:, :, None]  # [B, 4, 1].
        return (responsibility * (-offset / variance)).sum(dim=1)  # [B, 2].


def make_schedule(num_steps: int) -> dict:
    """The DDPM tensors on a grid of K steps, read off the continuous schedule.

    We set abar_k = a_{t_k}^2 at t_k = k/K and recover alpha_k = abar_k / abar_{k-1}. These match
    the continuous marginals at every grid point. At K = 1000 the betas agree with the linear
    sequence from 1e-4 to 0.02 to the accuracy of log(1 - beta) = -beta. At a coarse K every beta_k
    stays below one, and beta(t_k)/K can rise above one there.
    """
    grid = (torch.arange(0, num_steps + 1).to(torch.get_default_dtype())
            / num_steps)  # [K+1] the times t_k, with t_0 = 0.
    alphas_cumprod = torch.exp(
        -(BETA_MIN * grid + 0.5 * (BETA_MAX - BETA_MIN) * grid ** 2)
    )  # [K+1] abar_k, with abar_0 = 1 at the clean state.
    alphas_cumprod_prev = torch.cat([torch.ones(1), alphas_cumprod[:-1]])  # abar_{k-1}.
    alphas = alphas_cumprod / alphas_cumprod_prev  # One-step retention alpha_k.
    betas = 1.0 - alphas  # Per-step variance beta_k, zero at the placeholder index 0.
    return {
        "betas": betas, "alphas": alphas, "alphas_cumprod": alphas_cumprod,
        "posterior_variance": betas * (1.0 - alphas_cumprod_prev) / (1.0 - alphas_cumprod),
    }


# %% 03. Ancestral DDPM sampling
#
# x_{k-1} = mu_theta(x_k, k) + sqrt(beta-tilde_k) z with the reverse mean built from the noise
# prediction, eps = -sigma_k s_k. Fresh noise enters at every step except the last.


@torch.no_grad()
def ancestral_sample(score, schedule, num_points: int, generator=None) -> torch.Tensor:
    """Run the reverse chain from X_K ~ N(0, I) down to X_0. Returns [N, 2]."""
    num_steps = len(schedule["betas"]) - 1
    x = torch.randn(num_points, 2, generator=generator)  # [N, 2] the terminal Gaussian.
    for step in range(num_steps, 0, -1):  # k = K down to k = 1.
        abar_k = float(schedule["alphas_cumprod"][step])
        sigma_k = math.sqrt(1.0 - abar_k)  # Corruption standard deviation at this step.
        eps_theta = -sigma_k * score(x, math.sqrt(abar_k))  # [N, 2] the noise the score implies.
        mean = (x - float(schedule["betas"][step]) / sigma_k * eps_theta) / math.sqrt(
            float(schedule["alphas"][step])
        )  # Reverse mean mu_theta.
        if step > 1:  # No noise at the final step.
            variance = float(schedule["posterior_variance"][step])
            x = mean + math.sqrt(variance) * torch.randn(x.shape, generator=generator)
        else:
            x = mean
    return x  # [N, 2].


# %% 04. The probability-flow ODE
#
# v_t(x) = -(1/2) beta(t) [x + s_t(x)] has the same marginals with no Brownian increment, and we
# integrate it backward, x_{t - dt} = x_t - dt v_t(x_t). Once we draw X_T the whole trajectory is
# fixed.


@torch.no_grad()
def probability_flow_sample(score, num_points: int, steps: int, generator=None) -> torch.Tensor:
    """Euler integration of the probability-flow ODE from t = 1 to t = 0. Returns [N, 2]."""
    x = torch.randn(num_points, 2, generator=generator)  # [N, 2] the terminal Gaussian.
    dt = 1.0 / steps  # Positive step, taken in the direction of decreasing t.
    for step in range(steps, 0, -1):
        t = step * dt
        a_t = signal(t)
        velocity = -0.5 * beta(t) * (x + score(x, a_t))  # [N, 2] forward-time velocity.
        x = x - dt * velocity  # One backward Euler step.
    return x  # [N, 2].


# %% 05. Langevin dynamics and the noise ladder
#
# dX = s(X) d_tau + sqrt(2) dW holds p stationary at one fixed density, and the Euler-Maruyama
# update is x' = x + eta s(x) + sqrt(2 eta) z. A chain run at the data density alone crosses
# between modes slowly, so we visit the levels in decreasing order of corruption, with a few
# iterations at each.


@torch.no_grad()
def annealed_langevin_sample(score, num_points, levels, per_level, scale, generator=None):
    """Run ``per_level`` Langevin iterations at each level of the ladder. Returns [N, 2]."""
    x = torch.randn(num_points, 2, generator=generator)  # [N, 2] a Gaussian start.
    for t in levels:
        a_t = signal(t)
        width = a_t ** 2 * GAUSSIAN_STD ** 2 + (1.0 - a_t ** 2)  # Width of p_t at this level.
        eta = scale * width  # Step size tied to the level, large when the density is broad.
        for _ in range(per_level):
            noise = torch.randn(x.shape, generator=generator)  # [N, 2] fresh at every iteration.
            x = x + eta * score(x, a_t) + math.sqrt(2.0 * eta) * noise
    return x  # [N, 2] at the least corrupted level of the ladder.


def langevin_step(x: float, score_value: float, eta: float, z: float) -> float:
    """One Langevin update in one coordinate, the chapter's x + eta s(x) + sqrt(2 eta) z."""
    return x + eta * score_value + math.sqrt(2.0 * eta) * z


# %% 06. One backward step under both dynamics
#
# The score enters the reverse stochastic equation with the full g(t)^2 and the deterministic
# velocity with half of it, and that factor of two is the only structural difference between the
# two samplers.


def one_step_comparison(x_t, beta_t, sigma_t, eps_theta, dt) -> dict:
    """The chapter's state advanced one step by the ODE and by the reverse-time equation."""
    score_value = -eps_theta / sigma_t  # The score the noise prediction implies.
    velocity = -0.5 * beta_t * (x_t + score_value)  # Probability-flow velocity.
    reverse_drift = -0.5 * beta_t * x_t - beta_t * score_value  # f - g^2 s.
    return {
        "score": score_value, "velocity": velocity, "ode_next": x_t - dt * velocity,
        "sde_drift": reverse_drift, "sde_mean": x_t - dt * reverse_drift,
        "sde_noise_std": math.sqrt(beta_t * dt),
    }


# %% 07. The report
def main() -> None:
    parser = common_parser(__doc__.split("\n")[0])
    parser.add_argument("--levels", type=int, default=20, help="rungs of the Langevin ladder")
    parser.add_argument("--langevin-scale", type=float, default=0.15, help="step size over width")
    parser.set_defaults(steps=200, samples=2048)
    args = parser.parse_args()
    set_seed(args.seed)
    device = pick_device(args.device)  # Two coordinates per point; a CPU is enough.

    results, counts = {}, {}
    generator = torch.Generator().manual_seed(args.seed)
    score = ExactScore()
    results["ancestral DDPM"] = ancestral_sample(
        score, make_schedule(args.steps), args.samples, generator,
    )
    counts["ancestral DDPM"] = score.calls

    generator = torch.Generator().manual_seed(args.seed)  # The same starting noise for each.
    score = ExactScore()
    results["probability flow"] = probability_flow_sample(
        score, args.samples, args.steps, generator,
    )
    counts["probability flow"] = score.calls

    generator = torch.Generator().manual_seed(args.seed)
    score = ExactScore()
    per_level = max(args.steps // args.levels, 1)  # Spend the same budget as the other two.
    ladder = [LADDER_START + (LADDER_END - LADDER_START) * i / (args.levels - 1)
              for i in range(args.levels)]  # Decreasing corruption.
    results["annealed Langevin"] = annealed_langevin_sample(
        score, args.samples, ladder, per_level, args.langevin_scale, generator,
    )
    counts["annealed Langevin"] = score.calls

    report = {}
    for name, points in results.items():
        measured = mode_report(points)
        report[f"{name}: score evaluations"] = counts[name]
        report[f"{name}: modes found"] = measured["modes_found"]
        report[f"{name}: mode entropy"] = measured["mode_entropy"]  # log 4 = 1.386 when even.
        report[f"{name}: mode fractions"] = measured["mode_fractions"]
        report[f"{name}: distance to mode"] = measured["nearest_distance"]  # 0.44 on the target.
        report[f"{name}: mean log density"] = mean_log_density(points)

    step = one_step_comparison(1.2, 2.0, 0.5, 0.8, 0.1)
    report["score at the worked state"] = step["score"]  # -1.6.
    report["probability-flow velocity"] = step["velocity"]  # 0.4.
    report["ODE next state"] = step["ode_next"]  # 1.16.
    report["reverse-time drift"] = step["sde_drift"]  # 2.0.
    report["reverse-time mean"] = step["sde_mean"]  # 1.0.
    report["reverse-time noise std"] = step["sde_noise_std"]  # 0.4472.
    report["Langevin step from x=2"] = langevin_step(2.0, -2.0, 0.01, 0.5)  # 2.0507.

    if not args.quiet:
        print(f"device={device} points={args.samples} steps={args.steps} seed={args.seed}")
    print_report("Three samplers on one exact score", report)
    save_run(args.out, config=vars(args), report=report)


# %% 08. Run the script
if __name__ == "__main__":
    main()