File size: 16,238 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
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
"""Three objectives that fit the same score field.

We would like to minimize the squared distance from a model to the gradient of the log data
density, and a sample does not supply that target. One integration by parts removes the target and
leaves the squared norm of the model plus its divergence. One Gaussian corruption removes it a
second way and leaves a plain regression on the noise we drew. Both forms have the minimizer of
the first, and we fit all three here on a one-dimensional Gaussian mixture whose exact score is a
closed form we can measure them against.

In Chapter 3 t = 0 holds a clean sample and corruption grows with t. We fit at the chapter's
running level, a_t = 0.8 and sigma_t = 0.6.

Chapter 3, Sections 3.14 through 3.17 of the notes. Prints the three fitted score errors, the
integration-by-parts identity, the minimizer of each objective, the Hutchinson trace estimate, and
the posterior average at x_t = 7.4.

    python lecture_3/score_matching.py              # fit all three objectives
    python lecture_3/score_matching.py --steps 20   # a few seconds, for a first look
"""

# %% 01. Imports and configuration
import math

import torch

from cis6270.nets import VelocityMLP, count_parameters
from cis6270.runner import common_parser, pick_device, print_report, save_run, set_seed, train

A_T = 0.8  # Signal coefficient of the level fitted here.
SIGMA_T = 0.6  # Noise standard deviation at the same level, with a_t^2 + sigma_t^2 = 1.
MIXTURE_MEANS = torch.tensor([-2.0, 2.0])  # The two clean modes.
MIXTURE_WEIGHTS = torch.tensor([0.3, 0.7])  # Their mixture weights.
MIXTURE_STD = 0.5  # Standard deviation of each clean component.
TRACE_DIM = 64  # The chapter's 64-dimensional descriptor, used for the trace estimate.
NAMES = ("explicit", "implicit", "denoising")  # The three objectives, in the order derived.


# %% 02. The corrupted mixture and its exact score
#
# A Gaussian mixture corrupted by X_t = a_t X_0 + sigma_t eps is a Gaussian mixture again, with
# means a_t mu_m and variance a_t^2 s^2 + sigma_t^2, so its score is a closed form.

CORRUPTED_MEANS = A_T * MIXTURE_MEANS  # [M] means of p_t.
CORRUPTED_VARIANCE = A_T ** 2 * MIXTURE_STD ** 2 + SIGMA_T ** 2  # Shared variance of p_t.


def sample_clean(num_points: int, generator=None) -> torch.Tensor:
    """Draw clean samples from the mixture. Returns [N, 1]."""
    which = torch.multinomial(MIXTURE_WEIGHTS, num_points, True, generator=generator)  # [N].
    noise = torch.randn(num_points, generator=generator) * MIXTURE_STD  # [N].
    return (MIXTURE_MEANS[which] + noise)[:, None]  # [N, 1] clean points.


def exact_score(x: torch.Tensor) -> torch.Tensor:
    """The score of the corrupted mixture p_t at each row of x. [N, 1] -> [N, 1]."""
    offset = x - CORRUPTED_MEANS[None, :]  # [N, M] displacement from each corrupted mean.
    log_weight = MIXTURE_WEIGHTS.log()[None, :] - offset ** 2 / (2.0 * CORRUPTED_VARIANCE)
    responsibility = log_weight.softmax(dim=1)  # [N, M] posterior over the components.
    return (responsibility * (-offset / CORRUPTED_VARIANCE)).sum(dim=1, keepdim=True)  # [N, 1].


def model_score(model, x: torch.Tensor) -> torch.Tensor:
    """The network's score at x. We fit one level here, so the time input stays at a_t."""
    return model(x, torch.full((x.shape[0],), A_T))  # [N, 1].


def divergence(field, x: torch.Tensor) -> torch.Tensor:
    """The divergence of a field, in one coordinate the ordinary derivative. [N, 1]."""
    x = x.requires_grad_(True)  # Differentiate with respect to the input, not the weights.
    d_s, = torch.autograd.grad(field(x).sum(), x, create_graph=True)  # Trace of the Jacobian.
    return d_s


# %% 03. Explicit score matching
#
# J(theta) = (1/2) E || s_theta(x) - grad log p(x) ||^2, averaged over samples of p. In general we
# do not have that target, and here we do, so this objective is the reference for the other two.


def explicit_loss(model, x: torch.Tensor) -> torch.Tensor:
    """Half the squared distance between the predicted score and the exact one."""
    return 0.5 * (model_score(model, x) - exact_score(x)).square().sum(dim=1).mean()


# %% 04. The integration by parts that removes the data score
#
# E_p[s_theta . grad log p] = -E_p[div s_theta], from p grad log p = grad p followed by one
# integration by parts per coordinate, the boundary term vanishing for a density with Gaussian
# tails.


def integration_by_parts_gap(field, x: torch.Tensor) -> dict:
    """The two sides of the identity on one sample, and the gap between them."""
    left = float((field(x) * exact_score(x)).sum(dim=1).mean().detach())
    right = float(-divergence(field, x.clone()).sum(dim=1).mean().detach())
    return {"left": left, "right": right, "gap": abs(left - right)}


# %% 05. Implicit score matching with the trace term
#
# If we substitute the identity we get J(theta) = E[ (1/2) ||s_theta||^2 + div s_theta ] + C, with
# C a constant in theta. On the model s_a(x) = -a x with centered data of variance v, the
# expectation reduces to (1/2) a^2 v - a, and its minimizer is a = 1/v.


def implicit_loss(model, x: torch.Tensor) -> torch.Tensor:
    """The squared norm of the model plus its divergence, with the data score gone."""
    field = lambda z: model_score(model, z)  # noqa: E731
    s = field(x)  # [N, 1].
    return (0.5 * s.square().sum(dim=1) + divergence(field, x.clone()).sum(dim=1)).mean()


def one_parameter_loss(a: float, variance: float) -> float:
    """The implicit loss of s_a(x) = -a x on centered data of the given variance."""
    return 0.5 * a ** 2 * variance - a  # Minimized at a = 1 / variance.


# %% 06. Denoising score matching
#
# For a pair drawn as X_t = a_t X_0 + sigma_t eps we know the conditional score exactly,
# grad log q_t(x | x_0) = -(x - a_t x_0)/sigma_t^2 = -eps/sigma_t. Its posterior average at a fixed
# state is the marginal score, therefore the minimizer of the regression.


def denoising_loss(model, x_0: torch.Tensor, epsilon: torch.Tensor) -> torch.Tensor:
    """Half the squared distance between the predicted score and -eps / sigma_t."""
    x_t = A_T * x_0 + SIGMA_T * epsilon  # [N, 1] the corrupted state.
    return 0.5 * (model_score(model, x_t) + epsilon / SIGMA_T).square().sum(dim=1).mean()


# %% 07. One minimizer for the three objectives
#
# Along the family s_a = a s_exact each objective is a quadratic in a, and its minimizer is that
# objective's own numerator over the shared denominator E[||s||^2]. The explicit numerator is
# E[s . grad log p] and the implicit one is -E[div s], and the identity of Section 04 makes the
# two the same number. The denoising numerator E[s . (-eps/sigma_t)] comes out the same once
# more, because the posterior average of the conditional score is the marginal score.


def minimizers(x_0, epsilon) -> dict:
    """The minimizing a of each objective along the family a s_exact, on one sample."""
    x_t = A_T * x_0 + SIGMA_T * epsilon  # [N, 1] samples of p_t and the draws that made them.
    s = exact_score(x_t)  # [N, 1] the exact field of the family.
    squared = float(s.square().sum(dim=1).mean())  # E ||s||^2, the shared denominator.
    data = float((s * exact_score(x_t)).sum(dim=1).mean())  # E[s . grad log p], the explicit one.
    div_s = float(divergence(exact_score, x_t.clone()).detach().sum(dim=1).mean())  # E div s.
    target = float((s * (-epsilon / SIGMA_T)).sum(dim=1).mean())  # E[s . (-eps/sigma_t)].
    return {"explicit": data / squared, "implicit": -div_s / squared,
            "denoising": target / squared}


# %% 08. Random directions for the trace
#
# To extract the d diagonal entries of the Jacobian exactly we need d backward passes, 64 for a
# learned descriptor and 3072 for a colour image on a 32-by-32 grid. If we draw v with
# E[v v^T] = I and average v^T (grad s) v, we get the same number at one pass per direction.


def high_dimensional_score(x: torch.Tensor, means: torch.Tensor, variance: float) -> torch.Tensor:
    """The score of a two-component Gaussian mixture in TRACE_DIM coordinates. [N, d] -> [N, d]."""
    offset = x[:, None, :] - means[None, :, :]  # [N, 2, d].
    log_weight = -(offset ** 2).sum(dim=2) / (2.0 * variance)  # [N, 2] equal mixture weights.
    responsibility = log_weight.softmax(dim=1)[:, :, None]  # [N, 2, 1].
    return (responsibility * (-offset / variance)).sum(dim=1)  # [N, d].


def exact_trace(field, x: torch.Tensor) -> tuple[torch.Tensor, int]:
    """The divergence coordinate by coordinate, and the number of backward passes it took."""
    x = x.clone().requires_grad_(True)
    s = field(x)  # [N, d].
    total = torch.zeros(x.shape[0])
    for i in range(x.shape[1]):  # One backward pass per coordinate.
        column, = torch.autograd.grad(s[:, i].sum(), x, retain_graph=True)
        total = total + column[:, i]
    return total.detach(), x.shape[1]


def hutchinson_trace(field, x: torch.Tensor, probes: int, generator=None):
    """Average v^T (grad s) v over ``probes`` random directions, one pass each."""
    # A score field has a symmetric Jacobian, the Hessian of a log density, so the
    # vector-Jacobian product taken here equals the Jacobian-vector product of the notes.
    estimates = []
    for _ in range(probes):
        v = torch.randn(x.shape, generator=generator)  # [N, d] direction with E[v v^T] = I.
        point = x.clone().requires_grad_(True)
        projected, = torch.autograd.grad((field(point) * v).sum(), point)  # [N, d] one pass.
        estimates.append((v * projected).sum(dim=1).detach())  # [N] unbiased per direction.
    stacked = torch.stack(estimates)  # [probes, N].
    return stacked.mean(dim=0), stacked


# %% 09. The posterior average at x_t = 7.4
#
# The clean value 10 has prior weight 0.3 and the value 9 has 0.7, and at a_t = 0.8 and
# sigma_t = 0.6 both have conditional means near the observation 7.4.


def posterior_report(x_t: float, candidates, priors, epsilon_theta: float) -> dict:
    """Posterior weights, the clean estimate and the three predictions at one observation."""
    candidates, priors = torch.tensor(candidates), torch.tensor(priors)  # [C] and [C].
    conditional_mean = A_T * candidates  # [C] corruption mean of each candidate.
    weight = priors * torch.exp(-(x_t - conditional_mean) ** 2 / (2.0 * SIGMA_T ** 2))  # [C].
    posterior = weight / weight.sum()  # [C] weights after observing x_t.
    conditional_score = -(x_t - conditional_mean) / SIGMA_T ** 2  # [C] one per candidate.
    marginal = float((posterior * conditional_score).sum())  # The averaged score.
    clean_mean = float((posterior * candidates).sum())  # E[X_0 | X_t = x_t].
    score_hat = -epsilon_theta / SIGMA_T  # The score the chapter's prediction implies.
    return {
        "weights": [round(float(w), 4) for w in posterior],
        "clean_mean": clean_mean,
        "conditional_scores": [round(float(s), 4) for s in conditional_score],
        "marginal": [marginal, (A_T * clean_mean - x_t) / SIGMA_T ** 2],  # Two routes, one number.
        # The posterior variance of the target, and no function of x_t alone can remove it.
        "residual": float((posterior * (marginal - conditional_score) ** 2).sum()),
        "score_hat": score_hat,
        # The clean estimate from the noise, from the score, and from an optimal network.
        "x0": [(x_t - SIGMA_T * epsilon_theta) / A_T, (x_t + SIGMA_T ** 2 * score_hat) / A_T,
               (x_t + SIGMA_T ** 2 * marginal) / A_T],
    }


# %% 10. The report
def main() -> None:
    parser = common_parser(__doc__.split("\n")[0])
    parser.add_argument("--probes", type=int, default=32, help="random directions for the trace")
    parser.set_defaults(steps=250, batch_size=256, width=64, lr=1e-2)
    args = parser.parse_args()
    set_seed(args.seed)
    device = pick_device(args.device)  # The fields here are one-dimensional; a CPU is enough.
    generator = torch.Generator().manual_seed(args.seed + 1)  # Draws for the measurements below.

    objectives = {}
    for name in NAMES:
        set_seed(args.seed)  # Identical initialization for the three fits.
        model = VelocityMLP(dim=1, width=args.width, depth=2)
        # The training batches restart with the model, so the only difference between the three
        # runs is the objective itself.
        batches = torch.Generator().manual_seed(args.seed + 3)

        def loss_fn(step: int, name=name, model=model, generator=batches) -> torch.Tensor:
            x_0 = sample_clean(args.batch_size, generator)  # [B, 1] clean batch.
            epsilon = torch.randn(x_0.shape, generator=generator)  # [B, 1] corruption draw.
            if name == "denoising":
                return denoising_loss(model, x_0, epsilon)
            x_t = A_T * x_0 + SIGMA_T * epsilon  # [B, 1] samples of p_t for the other two.
            return explicit_loss(model, x_t) if name == "explicit" else implicit_loss(model, x_t)

        if not args.quiet:
            print(f"{name} score matching")
        objectives[name] = (model, train(model, loss_fn, args.steps, args.lr, quiet=args.quiet))

    # We measure every fitted field against the same exact score on one held-out batch.
    clean = sample_clean(4096, generator)  # [4096, 1].
    noise = torch.randn(clean.shape, generator=generator)  # [4096, 1].
    evaluation = A_T * clean + SIGMA_T * noise  # [4096, 1] samples of p_t.
    target = exact_score(evaluation)
    errors = {n: float((model_score(m, evaluation).detach() - target).square().mean().sqrt())
              for n, (m, _) in objectives.items()}

    identity = integration_by_parts_gap(lambda z: model_score(objectives["explicit"][0], z),
                                        evaluation)
    wide = torch.Generator().manual_seed(args.seed + 2)  # The family scan has its own draws.
    scan_clean = sample_clean(32768, wide)  # [32768, 1].
    scan = minimizers(scan_clean, torch.randn(scan_clean.shape, generator=wide))

    means = torch.randn(2, TRACE_DIM, generator=generator) * 1.5  # [2, d] two distant modes.
    points = torch.randn(8, TRACE_DIM, generator=generator)  # [8, d] evaluation points.
    field = lambda z: high_dimensional_score(z, means, 1.0)  # noqa: E731
    exact, passes = exact_trace(field, points)
    estimate, draws = hutchinson_trace(field, points, args.probes, generator)

    posterior = posterior_report(7.4, (10.0, 9.0), (0.3, 0.7), epsilon_theta=-0.8)
    report = {
        "parameters per model": count_parameters(objectives["explicit"][0]),
        "score error, the three fits": [round(errors[n], 4) for n in NAMES],
        "E[s . grad log p] and -E[div s]": [round(identity[k], 4) for k in ("left", "right")],
        "integration by parts gap": identity["gap"],
        "minimizing a, the three forms": [round(scan[n], 4) for n in NAMES],  # All near 1.
        # The one-parameter fit at a = 0.5 and a = 1 on unit variance, then at a = 1/v with v = 4.
        "one-parameter losses": [round(one_parameter_loss(a, v), 4)
                                 for a, v in ((0.5, 1.0), (1.0, 1.0), (0.25, 4.0))],
        "exact trace, backward passes": passes,  # 64, one per coordinate.
        "exact trace, mean": float(exact.mean()),
        "Hutchinson trace, mean": float(estimate.mean()),
        "Hutchinson standard error": float(draws.std(dim=0).mean() / math.sqrt(args.probes)),
        "posterior weights at x_t=7.4": posterior["weights"],  # 0.2156 and 0.7844.
        "posterior mean of x_0": posterior["clean_mean"],  # 9.2156.
        "conditional scores": posterior["conditional_scores"],  # 1.6667 and -0.5556.
        "marginal score, two routes": [round(v, 4) for v in posterior["marginal"]],  # -0.0765.
        "irreducible residual": posterior["residual"],  # 0.835.
        "score from eps_theta=-0.8": posterior["score_hat"],  # 1.3333.
        "x_0 estimates": [round(v, 4) for v in posterior["x0"]],  # 9.85, 9.85 and 9.22.
    }
    if not args.quiet:
        print(f"\ndevice={device} level a_t={A_T} sigma_t={SIGMA_T} seed={args.seed}")
    print_report("Three objectives, one score field", report)
    save_run(args.out, config=vars(args), report=report, losses=objectives["denoising"][1])


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