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()
|