Download lecture_3/score_matching.py from ChatterjeeLab/CIS6270: direct link, hf CLI and curl.
- Browser
- Download file 16.2 kB
-
https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_3/score_matching.py
- Command line
-
hf download hf://ChatterjeeLab/CIS6270/lecture_3/score_matching.py
-
curl -L -o score_matching.py https://huggingface.co/ChatterjeeLab/CIS6270/resolve/main/lecture_3/score_matching.py
16.2 kB
| """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() | |