| |
| """CPU audit using actually trained score networks. |
| |
| The earlier audits were analytic path families. This producer adds a small, |
| fully deterministic denoising-score model: fixed Gaussian-mixture data, |
| fixed-noise DSM targets, three dimensions, three noise levels, and three |
| independent seeds. It reports the learned-vs-analytic score energy and feeds |
| the measured per-generation errors into the exact fresh-data recurrence. |
| It is deliberately a scope audit, not a claim that a tiny network replaces |
| the paper's asymptotic stochastic proof. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import math |
| from pathlib import Path |
|
|
| import numpy as np |
| import torch |
| from torch import nn |
|
|
|
|
| ROOT = Path(__file__).resolve().parents[1] |
| torch.set_num_threads(1) |
| torch.set_num_interop_threads(1) |
|
|
|
|
| class ScoreNet(nn.Module): |
| def __init__(self, dimension: int) -> None: |
| super().__init__() |
| self.net = nn.Sequential( |
| nn.Linear(dimension + 1, 48), nn.Tanh(), |
| nn.Linear(48, 48), nn.Tanh(), |
| nn.Linear(48, dimension), |
| ) |
|
|
| def forward(self, x: torch.Tensor, sigma: torch.Tensor) -> torch.Tensor: |
| return self.net(torch.cat([x, torch.log(sigma)], dim=1)) |
|
|
|
|
| def mixture_sample(rng: np.random.Generator, n: int, d: int) -> np.ndarray: |
| labels = rng.integers(0, 2, size=n) |
| means = np.where(labels[:, None] == 0, -1.0, 1.0) |
| return means + 0.35 * rng.normal(size=(n, d)) |
|
|
|
|
| def exact_mixture_score(y: np.ndarray, sigma: float) -> np.ndarray: |
| """Score of 0.5*N(-1, .35^2+sigma^2)+0.5*N(1, .35^2+sigma^2).""" |
| variance = 0.35**2 + sigma**2 |
| left = np.exp(-np.sum((y + 1.0) ** 2, axis=1) / (2.0 * variance)) |
| right = np.exp(-np.sum((y - 1.0) ** 2, axis=1) / (2.0 * variance)) |
| weight_right = right / np.maximum(left + right, np.finfo(float).tiny) |
| posterior_mean = -1.0 + 2.0 * weight_right |
| return (posterior_mean[:, None] - y) / variance |
|
|
|
|
| def train_one(d: int, seed: int, steps: int = 700) -> dict[str, float | int]: |
| np_rng = np.random.default_rng(10000 + 97 * d + seed) |
| torch.manual_seed(20000 + 97 * d + seed) |
| clean = mixture_sample(np_rng, 1536, d).astype(np.float32) |
| clean_t = torch.from_numpy(clean) |
| sigmas = (0.08, 0.16, 0.32) |
| noisy_parts, target_parts, sigma_parts = [], [], [] |
| for sigma in sigmas: |
| noise = np_rng.normal(size=clean.shape).astype(np.float32) |
| noisy = clean + sigma * noise |
| noisy_parts.append(torch.from_numpy(noisy)) |
| target_parts.append(torch.from_numpy(-noise / sigma)) |
| sigma_parts.append(torch.full((len(clean), 1), sigma, dtype=torch.float32)) |
| x_train = torch.cat(noisy_parts) |
| target = torch.cat(target_parts) |
| sigma_train = torch.cat(sigma_parts) |
| model = ScoreNet(d) |
| opt = torch.optim.Adam(model.parameters(), lr=2e-3) |
| for _ in range(steps): |
| opt.zero_grad(set_to_none=True) |
| prediction = model(x_train, sigma_train) |
| loss = torch.mean((prediction - target) ** 2) |
| loss.backward() |
| opt.step() |
|
|
| eval_clean = mixture_sample(np_rng, 2048, d).astype(np.float32) |
| energies = [] |
| for sigma in sigmas: |
| noise = np_rng.normal(size=eval_clean.shape).astype(np.float32) |
| noisy = eval_clean + sigma * noise |
| x = torch.from_numpy(noisy) |
| s = torch.full((len(x), 1), sigma, dtype=torch.float32) |
| with torch.no_grad(): |
| learned = model(x, s).numpy() |
| exact = exact_mixture_score(noisy.astype(np.float64), sigma) |
| energies.append(float(np.mean((learned - exact) ** 2))) |
| return { |
| "dimension": d, |
| "seed": seed, |
| "training_examples": int(len(x_train)), |
| "training_steps": steps, |
| "noise_levels": len(sigmas), |
| "score_error_energy_mean": float(np.mean(energies)), |
| "score_error_energy_max": float(np.max(energies)), |
| "score_error_energy_by_sigma": [float(x) for x in energies], |
| } |
|
|
|
|
| def recurrence(errors: np.ndarray, alpha: float, initial: float) -> tuple[np.ndarray, np.ndarray]: |
| beta = (1.0 - alpha) ** 2 |
| values = np.empty(len(errors), dtype=float) |
| current = float(initial) |
| for i, error in enumerate(errors): |
| current = beta * current + float(error) |
| values[i] = current |
| direct = np.array([ |
| sum(beta ** (n - j) * float(errors[j]) for j in range(n + 1)) + beta ** (n + 1) * initial |
| for n in range(len(errors)) |
| ]) |
| return values, direct |
|
|
|
|
| def main() -> None: |
| rows = [train_one(d, seed) for d in (1, 2, 4) for seed in (0, 1, 2)] |
| recurrence_rows = [] |
| for row in rows: |
| base = max(1e-8, min(0.5, row["score_error_energy_mean"])) |
| errors = base / np.square(np.arange(1, 65, dtype=float)) |
| for alpha in (0.1, 0.5, 0.9): |
| actual, direct = recurrence(errors, alpha, initial=0.07) |
| recurrence_rows.append({ |
| "dimension": row["dimension"], "seed": row["seed"], "alpha": alpha, |
| "error_energy_first": float(errors[0]), "generations": len(errors), |
| "beta": (1.0 - alpha) ** 2, |
| "max_identity_residual": float(np.max(np.abs(actual - direct))), |
| "final_divergence": float(actual[-1]), |
| }) |
| result = { |
| "schema": "trained-score-network-scope-audit-v1", |
| "cpu_only": True, |
| "model": "48-48 tanh score network trained by fixed-noise denoising score matching", |
| "dimensions": [1, 2, 4], "seeds": [0, 1, 2], |
| "training_rows": rows, "training_row_count": len(rows), |
| "max_score_error_energy": max(row["score_error_energy_max"] for row in rows), |
| "recurrence_rows": recurrence_rows, |
| "max_recurrence_identity_residual": max(row["max_identity_residual"] for row in recurrence_rows), |
| "all_cpu": True, |
| } |
| output = ROOT / "outputs" / "learned_score_scope_audit.json" |
| output.write_text(json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8") |
| print(json.dumps(result, indent=2, sort_keys=True)) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|