Spaces:
Running
Running
Update logbook: Repro - Who Said Neural Networks Aren't Linear?
Browse files
logbook.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
| 10 |
"icml2026-repro",
|
| 11 |
"paper-Eqm5VEaAom"
|
| 12 |
],
|
| 13 |
-
"updated_at": "2026-07-
|
| 14 |
"root": {
|
| 15 |
"slug": "index",
|
| 16 |
"title": "Repro - Who Said Neural Networks Aren't Linear?",
|
|
@@ -60,6 +60,6 @@
|
|
| 60 |
}
|
| 61 |
]
|
| 62 |
},
|
| 63 |
-
"agent_view_tokens":
|
| 64 |
-
"revision": "
|
| 65 |
}
|
|
|
|
| 10 |
"icml2026-repro",
|
| 11 |
"paper-Eqm5VEaAom"
|
| 12 |
],
|
| 13 |
+
"updated_at": "2026-07-16T06:59:55+00:00",
|
| 14 |
"root": {
|
| 15 |
"slug": "index",
|
| 16 |
"title": "Repro - Who Said Neural Networks Aren't Linear?",
|
|
|
|
| 60 |
}
|
| 61 |
]
|
| 62 |
},
|
| 63 |
+
"agent_view_tokens": 6941,
|
| 64 |
+
"revision": "1784185195171135424"
|
| 65 |
}
|
pages/claim-3-idempotence-iff-a-2-a-lemma-4-2/page.md
CHANGED
|
@@ -278,3 +278,850 @@ ALL CHECKS PASSED
|
|
| 278 |
## Claim 3 — REPRODUCED in both directions (exact); architectural enforcement confirmed
|
| 279 |
|
| 280 |
Lemma 4.2 (f idempotent iff A^2 = A) verified numerically: (i) a random symmetric projection P = Q diag(1,1,1,1,0,0,0,0) Q^T gives f(f(x)) = f(x) to 9e-16; (ii) the paper's Section-4.3 architectural parameterization — a binary diagonal core, which is what the straight-through-estimator training produces — is exactly idempotent by construction; (iii) the converse holds: a random non-idempotent A yields f(f(x)) != f(x) with gap ~47, i.e. idempotence genuinely requires A^2 = A, it is not an artifact of the construction. This confirms the paper's key point that idempotency can be enforced architecturally (choose an idempotent core) rather than via an optimization penalty as in IGN (Shocher et al. 2024). Note: the paper's own ign/ experiment code is an empty stub in the official repo (https://github.com/assafshocher/Linearizer), so the MNIST generative-projection experiment is not reproducible from released code — the lemma itself, and the mechanism that the experiment relies on, are verified exactly here.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
## Claim 3 — REPRODUCED in both directions (exact); architectural enforcement confirmed
|
| 279 |
|
| 280 |
Lemma 4.2 (f idempotent iff A^2 = A) verified numerically: (i) a random symmetric projection P = Q diag(1,1,1,1,0,0,0,0) Q^T gives f(f(x)) = f(x) to 9e-16; (ii) the paper's Section-4.3 architectural parameterization — a binary diagonal core, which is what the straight-through-estimator training produces — is exactly idempotent by construction; (iii) the converse holds: a random non-idempotent A yields f(f(x)) != f(x) with gap ~47, i.e. idempotence genuinely requires A^2 = A, it is not an artifact of the construction. This confirms the paper's key point that idempotency can be enforced architecturally (choose an idempotent core) rather than via an optimization penalty as in IGN (Shocher et al. 2024). Note: the paper's own ign/ experiment code is an empty stub in the official repo (https://github.com/assafshocher/Linearizer), so the MNIST generative-projection experiment is not reproducible from released code — the lemma itself, and the mechanism that the experiment relies on, are verified exactly here.
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
---
|
| 284 |
+
<!-- trackio-cell
|
| 285 |
+
{"type": "code", "id": "cell_6cc95a8b5e24", "created_at": "2026-07-16T06:59:41+00:00", "title": "HF GPU Job: train real-MNIST global image projector", "command": ["/home/ubuntu/samuel/repro/.venv/bin/hf", "jobs", "uv", "run", "--flavor", "l4x1", "--timeout", "30m", "--detach", "--label", "paper=Eqm5VEaAom", "--label", "claim=idempotent-projector", "--volume", "hf://buckets/SabaPivot/repro-linearizer-artifacts:/mnt/artifacts:rw", "--with", "torch==2.5.1", "--with", "torchvision==0.20.1", "--with", "matplotlib==3.10.3", "--with", "numpy==1.26.4", "claim3_image_projector.py", "--mode", "full", "--device", "cuda", "--output-dir", "/mnt/artifacts/experiments/claim3_image_projector_hf_job", "--data-dir", "/tmp/mnist", "--log-every", "100"], "exit_code": 1, "duration_s": 10.778}
|
| 286 |
+
-->
|
| 287 |
+
````bash
|
| 288 |
+
$ /home/ubuntu/samuel/repro/.venv/bin/hf jobs uv run --flavor l4x1 --timeout 30m --detach --label paper=Eqm5VEaAom --label claim=idempotent-projector --volume hf://buckets/SabaPivot/repro-linearizer-artifacts:/mnt/artifacts:rw --with torch==2.5.1 --with torchvision==0.20.1 --with matplotlib==3.10.3 --with numpy==1.26.4 claim3_image_projector.py --mode full --device cuda --output-dir /mnt/artifacts/experiments/claim3_image_projector_hf_job --data-dir /tmp/mnist --log-every 100
|
| 289 |
+
````
|
| 290 |
+
|
| 291 |
+
exit 1 · 10.8s
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
````python title=claim3_image_projector.py
|
| 295 |
+
#!/usr/bin/env python3
|
| 296 |
+
"""Real-data check of the Linearizer idempotent-network claim.
|
| 297 |
+
|
| 298 |
+
This script trains an exact, invertible additive-coupling map ``g`` around a
|
| 299 |
+
*fixed* diagonal projector ``P`` and evaluates
|
| 300 |
+
|
| 301 |
+
f(x) = g^{-1}(P g(x)).
|
| 302 |
+
|
| 303 |
+
Because ``P**2 == P`` and ``g`` is bijective by construction, ``f`` is
|
| 304 |
+
idempotent architecturally; no idempotence term appears in the training loss.
|
| 305 |
+
The experiment is deliberately restorative rather than unconditional: it
|
| 306 |
+
learns to project Gaussian-corrupted MNIST images toward clean MNIST images.
|
| 307 |
+
|
| 308 |
+
The full preset uses 10,000 training digits, 1,000 untouched test digits, a
|
| 309 |
+
rank-588 core, and a 454,512-parameter bijection. The smoke preset exercises
|
| 310 |
+
the complete train/evaluate/artifact path on a small subset.
|
| 311 |
+
"""
|
| 312 |
+
|
| 313 |
+
from __future__ import annotations
|
| 314 |
+
|
| 315 |
+
import argparse
|
| 316 |
+
import copy
|
| 317 |
+
import csv
|
| 318 |
+
import hashlib
|
| 319 |
+
import json
|
| 320 |
+
import math
|
| 321 |
+
import os
|
| 322 |
+
import platform
|
| 323 |
+
import random
|
| 324 |
+
import sys
|
| 325 |
+
import time
|
| 326 |
+
from dataclasses import dataclass
|
| 327 |
+
from pathlib import Path
|
| 328 |
+
from typing import Any, Iterable
|
| 329 |
+
|
| 330 |
+
# Required by PyTorch for deterministic CUDA GEMMs. This must be set before
|
| 331 |
+
# importing torch.
|
| 332 |
+
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
|
| 333 |
+
|
| 334 |
+
import numpy as np
|
| 335 |
+
import torch
|
| 336 |
+
from torch import Tensor, nn
|
| 337 |
+
from torch.nn import functional as F
|
| 338 |
+
from torch.utils.data import DataLoader, Subset
|
| 339 |
+
from torchvision import datasets, transforms
|
| 340 |
+
|
| 341 |
+
INPUT_DIM = 28 * 28
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@dataclass(frozen=True)
|
| 345 |
+
class Preset:
|
| 346 |
+
train_samples: int
|
| 347 |
+
test_samples: int
|
| 348 |
+
ood_samples: int
|
| 349 |
+
epochs: int
|
| 350 |
+
batch_size: int
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
PRESETS = {
|
| 354 |
+
"smoke": Preset(
|
| 355 |
+
train_samples=512,
|
| 356 |
+
test_samples=128,
|
| 357 |
+
ood_samples=128,
|
| 358 |
+
epochs=1,
|
| 359 |
+
batch_size=128,
|
| 360 |
+
),
|
| 361 |
+
"full": Preset(
|
| 362 |
+
train_samples=10_000,
|
| 363 |
+
test_samples=1_000,
|
| 364 |
+
ood_samples=1_000,
|
| 365 |
+
epochs=50,
|
| 366 |
+
batch_size=256,
|
| 367 |
+
),
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
class Conditioner(nn.Module):
|
| 372 |
+
"""Small translation network used by an additive coupling layer."""
|
| 373 |
+
|
| 374 |
+
def __init__(self, half_dim: int, hidden_dim: int) -> None:
|
| 375 |
+
super().__init__()
|
| 376 |
+
self.net = nn.Sequential(
|
| 377 |
+
nn.Linear(half_dim, hidden_dim),
|
| 378 |
+
nn.SiLU(),
|
| 379 |
+
nn.Linear(hidden_dim, half_dim),
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
# Start g close to the identity. The first layer is still randomly
|
| 383 |
+
# initialized, so symmetry breaks as soon as the final layer learns.
|
| 384 |
+
final = self.net[-1]
|
| 385 |
+
assert isinstance(final, nn.Linear)
|
| 386 |
+
nn.init.zeros_(final.weight)
|
| 387 |
+
nn.init.zeros_(final.bias)
|
| 388 |
+
|
| 389 |
+
def forward(self, x: Tensor) -> Tensor:
|
| 390 |
+
return self.net(x)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
class AdditiveCoupling(nn.Module):
|
| 394 |
+
"""Exactly invertible additive coupling with an alternating split."""
|
| 395 |
+
|
| 396 |
+
def __init__(self, dim: int, hidden_dim: int, update_first: bool) -> None:
|
| 397 |
+
super().__init__()
|
| 398 |
+
if dim % 2:
|
| 399 |
+
raise ValueError(f"coupling dimension must be even, got {dim}")
|
| 400 |
+
self.half_dim = dim // 2
|
| 401 |
+
self.update_first = update_first
|
| 402 |
+
self.conditioner = Conditioner(self.half_dim, hidden_dim)
|
| 403 |
+
|
| 404 |
+
def forward(self, x: Tensor) -> Tensor:
|
| 405 |
+
first, second = x.split(self.half_dim, dim=-1)
|
| 406 |
+
if self.update_first:
|
| 407 |
+
first = first + self.conditioner(second)
|
| 408 |
+
else:
|
| 409 |
+
second = second + self.conditioner(first)
|
| 410 |
+
return torch.cat((first, second), dim=-1)
|
| 411 |
+
|
| 412 |
+
def inverse(self, y: Tensor) -> Tensor:
|
| 413 |
+
first, second = y.split(self.half_dim, dim=-1)
|
| 414 |
+
if self.update_first:
|
| 415 |
+
first = first - self.conditioner(second)
|
| 416 |
+
else:
|
| 417 |
+
second = second - self.conditioner(first)
|
| 418 |
+
return torch.cat((first, second), dim=-1)
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
class InvertibleMNISTMap(nn.Module):
|
| 422 |
+
"""Four additive couplings plus an invertible latent translation.
|
| 423 |
+
|
| 424 |
+
With dim=784, hidden_dim=144, and four blocks, the coupling networks have
|
| 425 |
+
453,728 parameters. The learned 784-vector translation brings the total
|
| 426 |
+
to exactly 454,512 parameters while preserving an analytic inverse.
|
| 427 |
+
"""
|
| 428 |
+
|
| 429 |
+
def __init__(
|
| 430 |
+
self, dim: int = INPUT_DIM, hidden_dim: int = 144, blocks: int = 4
|
| 431 |
+
) -> None:
|
| 432 |
+
super().__init__()
|
| 433 |
+
if blocks < 2:
|
| 434 |
+
raise ValueError("at least two alternating coupling blocks are required")
|
| 435 |
+
self.dim = dim
|
| 436 |
+
self.hidden_dim = hidden_dim
|
| 437 |
+
self.num_blocks = blocks
|
| 438 |
+
self.blocks = nn.ModuleList(
|
| 439 |
+
AdditiveCoupling(dim, hidden_dim, update_first=bool(index % 2))
|
| 440 |
+
for index in range(blocks)
|
| 441 |
+
)
|
| 442 |
+
self.latent_translation = nn.Parameter(torch.zeros(dim))
|
| 443 |
+
|
| 444 |
+
def encode(self, x: Tensor) -> Tensor:
|
| 445 |
+
z = x
|
| 446 |
+
for block in self.blocks:
|
| 447 |
+
z = block(z)
|
| 448 |
+
return z + self.latent_translation
|
| 449 |
+
|
| 450 |
+
def decode(self, z: Tensor) -> Tensor:
|
| 451 |
+
x = z - self.latent_translation
|
| 452 |
+
for block in reversed(self.blocks):
|
| 453 |
+
x = block.inverse(x)
|
| 454 |
+
return x
|
| 455 |
+
|
| 456 |
+
def linearizer(self, x: Tensor, core_diag: Tensor) -> Tensor:
|
| 457 |
+
if core_diag.ndim != 1 or core_diag.numel() != self.dim:
|
| 458 |
+
raise ValueError(
|
| 459 |
+
f"core must have shape ({self.dim},), got {tuple(core_diag.shape)}"
|
| 460 |
+
)
|
| 461 |
+
return self.decode(self.encode(x) * core_diag)
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
def parse_args(argv: Iterable[str] | None = None) -> argparse.Namespace:
|
| 465 |
+
parser = argparse.ArgumentParser(
|
| 466 |
+
description="Train and audit a globally idempotent MNIST Linearizer projector."
|
| 467 |
+
)
|
| 468 |
+
parser.add_argument("--mode", choices=sorted(PRESETS), default="full")
|
| 469 |
+
parser.add_argument(
|
| 470 |
+
"--output-dir", type=Path, default=Path("outputs/claim3_image_projector")
|
| 471 |
+
)
|
| 472 |
+
parser.add_argument("--data-dir", type=Path, default=Path("data"))
|
| 473 |
+
parser.add_argument(
|
| 474 |
+
"--device", default="auto", help="auto, cpu, cuda, cuda:0, or mps"
|
| 475 |
+
)
|
| 476 |
+
parser.add_argument("--seed", type=int, default=25191)
|
| 477 |
+
parser.add_argument("--train-samples", type=int, default=None)
|
| 478 |
+
parser.add_argument("--test-samples", type=int, default=None)
|
| 479 |
+
parser.add_argument("--ood-samples", type=int, default=None)
|
| 480 |
+
parser.add_argument("--epochs", type=int, default=None)
|
| 481 |
+
parser.add_argument("--batch-size", type=int, default=None)
|
| 482 |
+
parser.add_argument("--eval-batch-size", type=int, default=256)
|
| 483 |
+
parser.add_argument("--learning-rate", type=float, default=2e-3)
|
| 484 |
+
parser.add_argument("--weight-decay", type=float, default=1e-6)
|
| 485 |
+
parser.add_argument("--identity-weight", type=float, default=0.25)
|
| 486 |
+
parser.add_argument("--noise-sigma", type=float, default=0.35)
|
| 487 |
+
parser.add_argument("--rank", type=int, default=588)
|
| 488 |
+
parser.add_argument("--hidden-dim", type=int, default=144)
|
| 489 |
+
parser.add_argument("--blocks", type=int, default=4)
|
| 490 |
+
parser.add_argument("--gradient-clip", type=float, default=5.0)
|
| 491 |
+
parser.add_argument("--num-workers", type=int, default=0)
|
| 492 |
+
parser.add_argument("--gallery-count", type=int, default=10)
|
| 493 |
+
parser.add_argument("--log-every", type=int, default=25)
|
| 494 |
+
parser.add_argument(
|
| 495 |
+
"--download",
|
| 496 |
+
action=argparse.BooleanOptionalAction,
|
| 497 |
+
default=True,
|
| 498 |
+
help="allow torchvision to download MNIST if it is not cached",
|
| 499 |
+
)
|
| 500 |
+
args = parser.parse_args(argv)
|
| 501 |
+
|
| 502 |
+
preset = PRESETS[args.mode]
|
| 503 |
+
for field in (
|
| 504 |
+
"train_samples",
|
| 505 |
+
"test_samples",
|
| 506 |
+
"ood_samples",
|
| 507 |
+
"epochs",
|
| 508 |
+
"batch_size",
|
| 509 |
+
):
|
| 510 |
+
if getattr(args, field) is None:
|
| 511 |
+
setattr(args, field, getattr(preset, field))
|
| 512 |
+
|
| 513 |
+
if not 0 < args.rank <= INPUT_DIM:
|
| 514 |
+
parser.error(f"--rank must be in [1, {INPUT_DIM}]")
|
| 515 |
+
if args.train_samples <= 0 or args.test_samples <= 0 or args.ood_samples <= 0:
|
| 516 |
+
parser.error("sample counts must be positive")
|
| 517 |
+
if args.epochs <= 0 or args.batch_size <= 0 or args.eval_batch_size <= 0:
|
| 518 |
+
parser.error("epochs and batch sizes must be positive")
|
| 519 |
+
if args.noise_sigma < 0 or args.identity_weight < 0:
|
| 520 |
+
parser.error("noise sigma and identity weight must be non-negative")
|
| 521 |
+
return args
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
def choose_device(requested: str) -> torch.device:
|
| 525 |
+
if requested == "auto":
|
| 526 |
+
if torch.cuda.is_available():
|
| 527 |
+
return torch.device("cuda")
|
| 528 |
+
if torch.backends.mps.is_available():
|
| 529 |
+
return torch.device("mps")
|
| 530 |
+
return torch.device("cpu")
|
| 531 |
+
device = torch.device(requested)
|
| 532 |
+
if device.type == "cuda" and not torch.cuda.is_available():
|
| 533 |
+
raise RuntimeError("CUDA was requested but torch.cuda.is_available() is false")
|
| 534 |
+
if device.type == "mps" and not torch.backends.mps.is_available():
|
| 535 |
+
raise RuntimeError("MPS was requested but is not available")
|
| 536 |
+
return device
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def seed_everything(seed: int) -> None:
|
| 540 |
+
random.seed(seed)
|
| 541 |
+
np.random.seed(seed)
|
| 542 |
+
torch.manual_seed(seed)
|
| 543 |
+
if torch.cuda.is_available():
|
| 544 |
+
torch.cuda.manual_seed_all(seed)
|
| 545 |
+
torch.use_deterministic_algorithms(True, warn_only=True)
|
| 546 |
+
if hasattr(torch.backends, "cudnn"):
|
| 547 |
+
torch.backends.cudnn.benchmark = False
|
| 548 |
+
torch.backends.cudnn.deterministic = True
|
| 549 |
+
if hasattr(torch, "set_float32_matmul_precision"):
|
| 550 |
+
torch.set_float32_matmul_precision("highest")
|
| 551 |
+
if hasattr(torch.backends, "cuda") and hasattr(torch.backends.cuda, "matmul"):
|
| 552 |
+
torch.backends.cuda.matmul.allow_tf32 = False
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
def tensor_sha256(values: Tensor) -> str:
|
| 556 |
+
array = values.detach().cpu().contiguous().numpy()
|
| 557 |
+
return hashlib.sha256(array.tobytes()).hexdigest()
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
def file_sha256(path: Path) -> str:
|
| 561 |
+
digest = hashlib.sha256()
|
| 562 |
+
with path.open("rb") as handle:
|
| 563 |
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
| 564 |
+
digest.update(chunk)
|
| 565 |
+
return digest.hexdigest()
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def make_projector_diag(dim: int, rank: int, device: torch.device) -> Tensor:
|
| 569 |
+
"""Return a spatially interleaved binary diagonal projector.
|
| 570 |
+
|
| 571 |
+
Evenly spreading the discarded coordinates prevents the untrained control
|
| 572 |
+
from receiving an accidental advantage by dropping only MNIST's blank
|
| 573 |
+
bottom rows. The operator remains a fixed diagonal matrix.
|
| 574 |
+
"""
|
| 575 |
+
|
| 576 |
+
diag = torch.ones(dim, dtype=torch.float32)
|
| 577 |
+
discarded = dim - rank
|
| 578 |
+
if discarded:
|
| 579 |
+
# For the default 588/784 split this selects 3, 7, ..., 783: exactly
|
| 580 |
+
# one discarded coordinate in every group of four.
|
| 581 |
+
discard_indices = (
|
| 582 |
+
torch.arange(1, discarded + 1, dtype=torch.int64) * dim // discarded - 1
|
| 583 |
+
)
|
| 584 |
+
if torch.unique(discard_indices).numel() != discarded:
|
| 585 |
+
raise RuntimeError(
|
| 586 |
+
"failed to construct unique interleaved projector coordinates"
|
| 587 |
+
)
|
| 588 |
+
diag[discard_indices] = 0.0
|
| 589 |
+
if int(diag.sum().item()) != rank:
|
| 590 |
+
raise RuntimeError("constructed projector has the wrong rank")
|
| 591 |
+
return diag.to(device)
|
| 592 |
+
|
| 593 |
+
|
| 594 |
+
def load_mnist(
|
| 595 |
+
args: argparse.Namespace,
|
| 596 |
+
) -> tuple[DataLoader, Tensor, Tensor, Tensor, Tensor, Tensor]:
|
| 597 |
+
transform = transforms.ToTensor()
|
| 598 |
+
train_dataset = datasets.MNIST(
|
| 599 |
+
root=str(args.data_dir), train=True, transform=transform, download=args.download
|
| 600 |
+
)
|
| 601 |
+
test_dataset = datasets.MNIST(
|
| 602 |
+
root=str(args.data_dir),
|
| 603 |
+
train=False,
|
| 604 |
+
transform=transform,
|
| 605 |
+
download=args.download,
|
| 606 |
+
)
|
| 607 |
+
if args.train_samples > len(train_dataset):
|
| 608 |
+
raise ValueError(
|
| 609 |
+
f"requested {args.train_samples} training items, only {len(train_dataset)} exist"
|
| 610 |
+
)
|
| 611 |
+
if args.test_samples > len(test_dataset):
|
| 612 |
+
raise ValueError(
|
| 613 |
+
f"requested {args.test_samples} test items, only {len(test_dataset)} exist"
|
| 614 |
+
)
|
| 615 |
+
|
| 616 |
+
split_generator = torch.Generator().manual_seed(args.seed + 10)
|
| 617 |
+
train_indices = torch.randperm(len(train_dataset), generator=split_generator)[
|
| 618 |
+
: args.train_samples
|
| 619 |
+
]
|
| 620 |
+
test_indices = torch.randperm(len(test_dataset), generator=split_generator)[
|
| 621 |
+
: args.test_samples
|
| 622 |
+
]
|
| 623 |
+
|
| 624 |
+
loader_generator = torch.Generator().manual_seed(args.seed + 20)
|
| 625 |
+
train_loader = DataLoader(
|
| 626 |
+
Subset(train_dataset, train_indices.tolist()),
|
| 627 |
+
batch_size=args.batch_size,
|
| 628 |
+
shuffle=True,
|
| 629 |
+
generator=loader_generator,
|
| 630 |
+
num_workers=args.num_workers,
|
| 631 |
+
pin_memory=torch.cuda.is_available(),
|
| 632 |
+
drop_last=False,
|
| 633 |
+
persistent_workers=args.num_workers > 0,
|
| 634 |
+
)
|
| 635 |
+
|
| 636 |
+
# Materialize the held-out set so untrained and trained models see exactly
|
| 637 |
+
# the same examples and corruption tensor.
|
| 638 |
+
heldout_loader = DataLoader(
|
| 639 |
+
Subset(test_dataset, test_indices.tolist()),
|
| 640 |
+
batch_size=args.eval_batch_size,
|
| 641 |
+
shuffle=False,
|
| 642 |
+
num_workers=args.num_workers,
|
| 643 |
+
persistent_workers=args.num_workers > 0,
|
| 644 |
+
)
|
| 645 |
+
images: list[Tensor] = []
|
| 646 |
+
labels: list[Tensor] = []
|
| 647 |
+
for image, label in heldout_loader:
|
| 648 |
+
images.append(image.flatten(1))
|
| 649 |
+
labels.append(label)
|
| 650 |
+
clean = torch.cat(images, dim=0)
|
| 651 |
+
heldout_labels = torch.cat(labels, dim=0)
|
| 652 |
+
|
| 653 |
+
eval_generator = torch.Generator().manual_seed(args.seed + 30)
|
| 654 |
+
noise = torch.randn(clean.shape, generator=eval_generator)
|
| 655 |
+
noisy = (clean + args.noise_sigma * noise).clamp(0.0, 1.0)
|
| 656 |
+
return train_loader, clean, noisy, heldout_labels, train_indices, test_indices
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
def make_noise_generator(device: torch.device, seed: int) -> torch.Generator:
|
| 660 |
+
try:
|
| 661 |
+
generator = torch.Generator(device=device)
|
| 662 |
+
except TypeError:
|
| 663 |
+
generator = torch.Generator(device=device.type)
|
| 664 |
+
generator.manual_seed(seed)
|
| 665 |
+
return generator
|
| 666 |
+
|
| 667 |
+
|
| 668 |
+
def train_model(
|
| 669 |
+
model: InvertibleMNISTMap,
|
| 670 |
+
train_loader: DataLoader,
|
| 671 |
+
projector_diag: Tensor,
|
| 672 |
+
args: argparse.Namespace,
|
| 673 |
+
device: torch.device,
|
| 674 |
+
) -> list[dict[str, float | int]]:
|
| 675 |
+
optimizer = torch.optim.AdamW(
|
| 676 |
+
model.parameters(), lr=args.learning_rate, weight_decay=args.weight_decay
|
| 677 |
+
)
|
| 678 |
+
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
|
| 679 |
+
optimizer, T_max=max(1, args.epochs), eta_min=args.learning_rate * 0.05
|
| 680 |
+
)
|
| 681 |
+
noise_generator = make_noise_generator(device, args.seed + 40)
|
| 682 |
+
history: list[dict[str, float | int]] = []
|
| 683 |
+
global_step = 0
|
| 684 |
+
model.train()
|
| 685 |
+
|
| 686 |
+
for epoch in range(1, args.epochs + 1):
|
| 687 |
+
sums = {"loss": 0.0, "restoration_loss": 0.0, "identity_loss": 0.0}
|
| 688 |
+
seen = 0
|
| 689 |
+
epoch_start = time.perf_counter()
|
| 690 |
+
for clean_images, _ in train_loader:
|
| 691 |
+
clean = clean_images.flatten(1).to(device, non_blocking=True)
|
| 692 |
+
noise = torch.randn(
|
| 693 |
+
clean.shape, dtype=clean.dtype, device=device, generator=noise_generator
|
| 694 |
+
)
|
| 695 |
+
noisy = (clean + args.noise_sigma * noise).clamp(0.0, 1.0)
|
| 696 |
+
|
| 697 |
+
optimizer.zero_grad(set_to_none=True)
|
| 698 |
+
restored = model.linearizer(noisy, projector_diag)
|
| 699 |
+
clean_projection = model.linearizer(clean, projector_diag)
|
| 700 |
+
restoration_loss = F.mse_loss(restored, clean)
|
| 701 |
+
identity_loss = F.mse_loss(clean_projection, clean)
|
| 702 |
+
loss = restoration_loss + args.identity_weight * identity_loss
|
| 703 |
+
loss.backward()
|
| 704 |
+
gradient_norm = torch.nn.utils.clip_grad_norm_(
|
| 705 |
+
model.parameters(), args.gradient_clip
|
| 706 |
+
)
|
| 707 |
+
optimizer.step()
|
| 708 |
+
|
| 709 |
+
batch_items = clean.shape[0]
|
| 710 |
+
seen += batch_items
|
| 711 |
+
sums["loss"] += float(loss.detach()) * batch_items
|
| 712 |
+
sums["restoration_loss"] += float(restoration_loss.detach()) * batch_items
|
| 713 |
+
sums["identity_loss"] += float(identity_loss.detach()) * batch_items
|
| 714 |
+
global_step += 1
|
| 715 |
+
if args.log_every and global_step % args.log_every == 0:
|
| 716 |
+
print(
|
| 717 |
+
f"step={global_step} epoch={epoch}/{args.epochs} "
|
| 718 |
+
f"loss={float(loss.detach()):.6f} "
|
| 719 |
+
f"restore={float(restoration_loss.detach()):.6f} "
|
| 720 |
+
f"identity={float(identity_loss.detach()):.6f} "
|
| 721 |
+
f"grad_norm={float(gradient_norm):.4f}",
|
| 722 |
+
flush=True,
|
| 723 |
+
)
|
| 724 |
+
|
| 725 |
+
scheduler.step()
|
| 726 |
+
row: dict[str, float | int] = {
|
| 727 |
+
"epoch": epoch,
|
| 728 |
+
"step": global_step,
|
| 729 |
+
"loss": sums["loss"] / seen,
|
| 730 |
+
"restoration_loss": sums["restoration_loss"] / seen,
|
| 731 |
+
"identity_loss": sums["identity_loss"] / seen,
|
| 732 |
+
"learning_rate": optimizer.param_groups[0]["lr"],
|
| 733 |
+
"seconds": time.perf_counter() - epoch_start,
|
| 734 |
+
}
|
| 735 |
+
history.append(row)
|
| 736 |
+
print(
|
| 737 |
+
f"epoch={epoch}/{args.epochs} mean_loss={row['loss']:.6f} "
|
| 738 |
+
f"mean_restore={row['restoration_loss']:.6f} "
|
| 739 |
+
f"mean_identity={row['identity_loss']:.6f} "
|
| 740 |
+
f"seconds={row['seconds']:.2f}",
|
| 741 |
+
flush=True,
|
| 742 |
+
)
|
| 743 |
+
return history
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
@torch.inference_mode()
|
| 747 |
+
def apply_in_batches(
|
| 748 |
+
model: InvertibleMNISTMap,
|
| 749 |
+
values: Tensor,
|
| 750 |
+
core_diag: Tensor,
|
| 751 |
+
device: torch.device,
|
| 752 |
+
batch_size: int,
|
| 753 |
+
) -> Tensor:
|
| 754 |
+
outputs: list[Tensor] = []
|
| 755 |
+
model.eval()
|
| 756 |
+
for start in range(0, values.shape[0], batch_size):
|
| 757 |
+
batch = values[start : start + batch_size].to(device)
|
| 758 |
+
outputs.append(model.linearizer(batch, core_diag).cpu())
|
| 759 |
+
return torch.cat(outputs, dim=0)
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
def reconstruction_metrics(prediction: Tensor, target: Tensor) -> dict[str, float]:
|
| 763 |
+
per_sample_mse = (prediction - target).square().mean(dim=1)
|
| 764 |
+
global_mse = float(per_sample_mse.mean())
|
| 765 |
+
per_sample_psnr = -10.0 * torch.log10(per_sample_mse.clamp_min(1e-12))
|
| 766 |
+
clipped = prediction.clamp(0.0, 1.0)
|
| 767 |
+
clipped_mse = float((clipped - target).square().mean())
|
| 768 |
+
return {
|
| 769 |
+
"mse": global_mse,
|
| 770 |
+
"mean_psnr_db": float(per_sample_psnr.mean()),
|
| 771 |
+
"psnr_from_global_mse_db": -10.0 * math.log10(max(global_mse, 1e-12)),
|
| 772 |
+
"clipped_mse": clipped_mse,
|
| 773 |
+
"clipped_psnr_from_global_mse_db": -10.0 * math.log10(max(clipped_mse, 1e-12)),
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
def error_metrics(actual: Tensor, expected: Tensor) -> dict[str, float]:
|
| 778 |
+
difference = actual - expected
|
| 779 |
+
return {
|
| 780 |
+
"rms": float(torch.sqrt(difference.double().square().mean())),
|
| 781 |
+
"max_abs": float(difference.abs().max()),
|
| 782 |
+
"mean_abs": float(difference.abs().double().mean()),
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
|
| 786 |
+
@torch.inference_mode()
|
| 787 |
+
def structural_metrics(
|
| 788 |
+
model: InvertibleMNISTMap,
|
| 789 |
+
values: Tensor,
|
| 790 |
+
core_diag: Tensor,
|
| 791 |
+
device: torch.device,
|
| 792 |
+
batch_size: int,
|
| 793 |
+
) -> dict[str, dict[str, float]]:
|
| 794 |
+
roundtrip_parts: list[Tensor] = []
|
| 795 |
+
idempotence_parts: list[Tensor] = []
|
| 796 |
+
model.eval()
|
| 797 |
+
for start in range(0, values.shape[0], batch_size):
|
| 798 |
+
original_cpu = values[start : start + batch_size]
|
| 799 |
+
original = original_cpu.to(device)
|
| 800 |
+
roundtrip = model.decode(model.encode(original))
|
| 801 |
+
projected_once = model.linearizer(original, core_diag)
|
| 802 |
+
projected_twice = model.linearizer(projected_once, core_diag)
|
| 803 |
+
roundtrip_parts.append((roundtrip - original).cpu())
|
| 804 |
+
idempotence_parts.append((projected_twice - projected_once).cpu())
|
| 805 |
+
|
| 806 |
+
roundtrip_difference = torch.cat(roundtrip_parts, dim=0)
|
| 807 |
+
idempotence_difference = torch.cat(idempotence_parts, dim=0)
|
| 808 |
+
zeros_roundtrip = torch.zeros_like(roundtrip_difference)
|
| 809 |
+
zeros_idempotence = torch.zeros_like(idempotence_difference)
|
| 810 |
+
return {
|
| 811 |
+
"g_inverse_roundtrip": error_metrics(roundtrip_difference, zeros_roundtrip),
|
| 812 |
+
"f_idempotence": error_metrics(idempotence_difference, zeros_idempotence),
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
|
| 816 |
+
def make_ood_domains(sample_count: int, seed: int) -> dict[str, Tensor]:
|
| 817 |
+
generator = torch.Generator().manual_seed(seed)
|
| 818 |
+
domains = {
|
| 819 |
+
"ood_uniform_minus1_to_2": -1.0
|
| 820 |
+
+ 3.0 * torch.rand((sample_count, INPUT_DIM), generator=generator),
|
| 821 |
+
}
|
| 822 |
+
for scale in (0.1, 1.0, 3.0):
|
| 823 |
+
label = str(scale).replace(".", "p")
|
| 824 |
+
domains[f"ood_gaussian_scale_{label}"] = scale * torch.randn(
|
| 825 |
+
(sample_count, INPUT_DIM), generator=generator
|
| 826 |
+
)
|
| 827 |
+
return domains
|
| 828 |
+
|
| 829 |
+
|
| 830 |
+
def save_history(path: Path, history: list[dict[str, float | int]]) -> None:
|
| 831 |
+
if not history:
|
| 832 |
+
return
|
| 833 |
+
with path.open("w", newline="", encoding="utf-8") as handle:
|
| 834 |
+
writer = csv.DictWriter(handle, fieldnames=list(history[0]))
|
| 835 |
+
writer.writeheader()
|
| 836 |
+
writer.writerows(history)
|
| 837 |
+
|
| 838 |
+
|
| 839 |
+
def save_gallery(
|
| 840 |
+
path: Path,
|
| 841 |
+
clean: Tensor,
|
| 842 |
+
noisy: Tensor,
|
| 843 |
+
untrained: Tensor,
|
| 844 |
+
trained: Tensor,
|
| 845 |
+
trained_twice: Tensor,
|
| 846 |
+
labels: Tensor,
|
| 847 |
+
count: int,
|
| 848 |
+
) -> None:
|
| 849 |
+
import matplotlib
|
| 850 |
+
|
| 851 |
+
matplotlib.use("Agg")
|
| 852 |
+
import matplotlib.pyplot as plt
|
| 853 |
+
|
| 854 |
+
count = min(count, clean.shape[0])
|
| 855 |
+
rows = [
|
| 856 |
+
("clean", clean),
|
| 857 |
+
("corrupt", noisy),
|
| 858 |
+
("untrained f(x)", untrained),
|
| 859 |
+
("trained f(x)", trained),
|
| 860 |
+
("trained f(f(x))", trained_twice),
|
| 861 |
+
]
|
| 862 |
+
figure, axes = plt.subplots(
|
| 863 |
+
len(rows), count, figsize=(1.45 * count, 1.45 * len(rows)), squeeze=False
|
| 864 |
+
)
|
| 865 |
+
for row_index, (row_name, images) in enumerate(rows):
|
| 866 |
+
for column in range(count):
|
| 867 |
+
axis = axes[row_index, column]
|
| 868 |
+
axis.imshow(
|
| 869 |
+
images[column].reshape(28, 28).clamp(0, 1), cmap="gray", vmin=0, vmax=1
|
| 870 |
+
)
|
| 871 |
+
axis.axis("off")
|
| 872 |
+
if row_index == 0:
|
| 873 |
+
axis.set_title(f"label {int(labels[column])}", fontsize=8)
|
| 874 |
+
if column == 0:
|
| 875 |
+
axis.set_ylabel(row_name, fontsize=9)
|
| 876 |
+
figure.suptitle(
|
| 877 |
+
"Architecturally idempotent MNIST restoration (outputs displayed clipped to [0,1])",
|
| 878 |
+
fontsize=12,
|
| 879 |
+
)
|
| 880 |
+
figure.tight_layout(rect=(0, 0, 1, 0.97))
|
| 881 |
+
figure.savefig(path, dpi=180, bbox_inches="tight")
|
| 882 |
+
plt.close(figure)
|
| 883 |
+
|
| 884 |
+
|
| 885 |
+
def environment_metadata(device: torch.device) -> dict[str, Any]:
|
| 886 |
+
cuda_name = None
|
| 887 |
+
if device.type == "cuda":
|
| 888 |
+
cuda_name = torch.cuda.get_device_name(device)
|
| 889 |
+
return {
|
| 890 |
+
"python": sys.version.split()[0],
|
| 891 |
+
"platform": platform.platform(),
|
| 892 |
+
"torch": torch.__version__,
|
| 893 |
+
"torchvision": getattr(sys.modules.get("torchvision"), "__version__", None),
|
| 894 |
+
"numpy": np.__version__,
|
| 895 |
+
"device": str(device),
|
| 896 |
+
"cuda_available": torch.cuda.is_available(),
|
| 897 |
+
"cuda_version": torch.version.cuda,
|
| 898 |
+
"cuda_device_name": cuda_name,
|
| 899 |
+
"deterministic_algorithms": torch.are_deterministic_algorithms_enabled(),
|
| 900 |
+
"cublas_workspace_config": os.environ.get("CUBLAS_WORKSPACE_CONFIG"),
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
|
| 904 |
+
def main(argv: Iterable[str] | None = None) -> int:
|
| 905 |
+
args = parse_args(argv)
|
| 906 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
| 907 |
+
seed_everything(args.seed)
|
| 908 |
+
device = choose_device(args.device)
|
| 909 |
+
started = time.perf_counter()
|
| 910 |
+
print(f"mode={args.mode} device={device} seed={args.seed}", flush=True)
|
| 911 |
+
|
| 912 |
+
(
|
| 913 |
+
train_loader,
|
| 914 |
+
clean_test,
|
| 915 |
+
noisy_test,
|
| 916 |
+
heldout_labels,
|
| 917 |
+
train_indices,
|
| 918 |
+
test_indices,
|
| 919 |
+
) = load_mnist(args)
|
| 920 |
+
np.savez_compressed(
|
| 921 |
+
args.output_dir / "split_indices.npz",
|
| 922 |
+
train_indices=train_indices.cpu().numpy(),
|
| 923 |
+
test_indices=test_indices.cpu().numpy(),
|
| 924 |
+
)
|
| 925 |
+
|
| 926 |
+
model = InvertibleMNISTMap(
|
| 927 |
+
dim=INPUT_DIM, hidden_dim=args.hidden_dim, blocks=args.blocks
|
| 928 |
+
).to(device)
|
| 929 |
+
parameter_count = sum(parameter.numel() for parameter in model.parameters())
|
| 930 |
+
projector_diag = make_projector_diag(INPUT_DIM, args.rank, device)
|
| 931 |
+
nonprojector_diag = projector_diag.clone()
|
| 932 |
+
nonprojector_diag[nonprojector_diag == 0] = 0.5
|
| 933 |
+
initial_state = copy.deepcopy(model.state_dict())
|
| 934 |
+
|
| 935 |
+
print(
|
| 936 |
+
f"parameters={parameter_count} projector_rank={int(projector_diag.sum())} "
|
| 937 |
+
f"train_samples={args.train_samples} heldout_samples={args.test_samples}",
|
| 938 |
+
flush=True,
|
| 939 |
+
)
|
| 940 |
+
|
| 941 |
+
untrained_output = apply_in_batches(
|
| 942 |
+
model, noisy_test, projector_diag, device, args.eval_batch_size
|
| 943 |
+
)
|
| 944 |
+
untrained_metrics = reconstruction_metrics(untrained_output, clean_test)
|
| 945 |
+
corrupt_metrics = reconstruction_metrics(noisy_test, clean_test)
|
| 946 |
+
|
| 947 |
+
history = train_model(model, train_loader, projector_diag, args, device)
|
| 948 |
+
save_history(args.output_dir / "training_history.csv", history)
|
| 949 |
+
|
| 950 |
+
trained_output = apply_in_batches(
|
| 951 |
+
model, noisy_test, projector_diag, device, args.eval_batch_size
|
| 952 |
+
)
|
| 953 |
+
trained_twice = apply_in_batches(
|
| 954 |
+
model, trained_output, projector_diag, device, args.eval_batch_size
|
| 955 |
+
)
|
| 956 |
+
trained_metrics = reconstruction_metrics(trained_output, clean_test)
|
| 957 |
+
|
| 958 |
+
domains = {
|
| 959 |
+
"heldout_clean_mnist": clean_test,
|
| 960 |
+
"heldout_noisy_mnist": noisy_test,
|
| 961 |
+
**make_ood_domains(args.ood_samples, args.seed + 50),
|
| 962 |
+
}
|
| 963 |
+
projector_checks: dict[str, Any] = {}
|
| 964 |
+
nonprojector_checks: dict[str, Any] = {}
|
| 965 |
+
for name, values in domains.items():
|
| 966 |
+
print(f"structural_check={name} samples={values.shape[0]}", flush=True)
|
| 967 |
+
projector_checks[name] = structural_metrics(
|
| 968 |
+
model, values, projector_diag, device, args.eval_batch_size
|
| 969 |
+
)
|
| 970 |
+
nonprojector_checks[name] = structural_metrics(
|
| 971 |
+
model, values, nonprojector_diag, device, args.eval_batch_size
|
| 972 |
+
)["f_idempotence"]
|
| 973 |
+
|
| 974 |
+
save_gallery(
|
| 975 |
+
args.output_dir / "gallery.png",
|
| 976 |
+
clean_test,
|
| 977 |
+
noisy_test,
|
| 978 |
+
untrained_output,
|
| 979 |
+
trained_output,
|
| 980 |
+
trained_twice,
|
| 981 |
+
heldout_labels,
|
| 982 |
+
args.gallery_count,
|
| 983 |
+
)
|
| 984 |
+
|
| 985 |
+
checkpoint_path = args.output_dir / "checkpoint.pt"
|
| 986 |
+
config_for_artifact = {
|
| 987 |
+
key: str(value) if isinstance(value, Path) else value
|
| 988 |
+
for key, value in vars(args).items()
|
| 989 |
+
}
|
| 990 |
+
torch.save(
|
| 991 |
+
{
|
| 992 |
+
"model_class": "InvertibleMNISTMap",
|
| 993 |
+
"model_state_dict": model.state_dict(),
|
| 994 |
+
"initial_state_dict": initial_state,
|
| 995 |
+
"projector_diag": projector_diag.cpu(),
|
| 996 |
+
"nonprojector_diag": nonprojector_diag.cpu(),
|
| 997 |
+
"config": config_for_artifact,
|
| 998 |
+
"parameter_count": parameter_count,
|
| 999 |
+
"training_history": history,
|
| 1000 |
+
},
|
| 1001 |
+
checkpoint_path,
|
| 1002 |
+
)
|
| 1003 |
+
|
| 1004 |
+
projector_core_residual = float(
|
| 1005 |
+
(projector_diag.square() - projector_diag).abs().max()
|
| 1006 |
+
)
|
| 1007 |
+
nonprojector_core_residual = float(
|
| 1008 |
+
(nonprojector_diag.square() - nonprojector_diag).abs().max()
|
| 1009 |
+
)
|
| 1010 |
+
worst_idempotence_max = max(
|
| 1011 |
+
check["f_idempotence"]["max_abs"] for check in projector_checks.values()
|
| 1012 |
+
)
|
| 1013 |
+
worst_roundtrip_max = max(
|
| 1014 |
+
check["g_inverse_roundtrip"]["max_abs"] for check in projector_checks.values()
|
| 1015 |
+
)
|
| 1016 |
+
minimum_control_rms = min(check["rms"] for check in nonprojector_checks.values())
|
| 1017 |
+
elapsed = time.perf_counter() - started
|
| 1018 |
+
|
| 1019 |
+
results: dict[str, Any] = {
|
| 1020 |
+
"schema_version": 1,
|
| 1021 |
+
"claim": "A Linearizer f=g^-1 P g is idempotent iff its core is idempotent.",
|
| 1022 |
+
"scope": (
|
| 1023 |
+
"Real-data restorative projection on MNIST; this is not an unconditional "
|
| 1024 |
+
"image-generation experiment."
|
| 1025 |
+
),
|
| 1026 |
+
"architecture": {
|
| 1027 |
+
"map": "four alternating additive-coupling blocks plus latent translation",
|
| 1028 |
+
"dimension": INPUT_DIM,
|
| 1029 |
+
"hidden_dimension": args.hidden_dim,
|
| 1030 |
+
"coupling_blocks": args.blocks,
|
| 1031 |
+
"parameter_count": parameter_count,
|
| 1032 |
+
"analytic_inverse": True,
|
| 1033 |
+
"core": "fixed interleaved binary diagonal projector",
|
| 1034 |
+
"core_rank": int(projector_diag.sum().item()),
|
| 1035 |
+
"core_idempotence_max_abs": projector_core_residual,
|
| 1036 |
+
},
|
| 1037 |
+
"protocol": {
|
| 1038 |
+
"mode": args.mode,
|
| 1039 |
+
"seed": args.seed,
|
| 1040 |
+
"train_samples": args.train_samples,
|
| 1041 |
+
"heldout_samples": args.test_samples,
|
| 1042 |
+
"ood_samples_per_domain": args.ood_samples,
|
| 1043 |
+
"noise_sigma": args.noise_sigma,
|
| 1044 |
+
"epochs": args.epochs,
|
| 1045 |
+
"batch_size": args.batch_size,
|
| 1046 |
+
"learning_rate": args.learning_rate,
|
| 1047 |
+
"identity_weight": args.identity_weight,
|
| 1048 |
+
"training_loss": "MSE(f(noisy), clean) + identity_weight*MSE(f(clean), clean)",
|
| 1049 |
+
"idempotence_penalty_used": False,
|
| 1050 |
+
"train_indices_sha256": tensor_sha256(train_indices),
|
| 1051 |
+
"test_indices_sha256": tensor_sha256(test_indices),
|
| 1052 |
+
},
|
| 1053 |
+
"heldout_restoration": {
|
| 1054 |
+
"corrupt_input": corrupt_metrics,
|
| 1055 |
+
"untrained_projector": untrained_metrics,
|
| 1056 |
+
"trained_projector": trained_metrics,
|
| 1057 |
+
"mse_improvement_vs_corrupt_fraction": (
|
| 1058 |
+
corrupt_metrics["mse"] - trained_metrics["mse"]
|
| 1059 |
+
)
|
| 1060 |
+
/ corrupt_metrics["mse"],
|
| 1061 |
+
"mse_improvement_vs_untrained_fraction": (
|
| 1062 |
+
untrained_metrics["mse"] - trained_metrics["mse"]
|
| 1063 |
+
)
|
| 1064 |
+
/ untrained_metrics["mse"],
|
| 1065 |
+
},
|
| 1066 |
+
"global_projector_checks": projector_checks,
|
| 1067 |
+
"nonidempotent_core_control": {
|
| 1068 |
+
"construction": "replace every zero diagonal entry of P with 0.5",
|
| 1069 |
+
"core_a2_minus_a_max_abs": nonprojector_core_residual,
|
| 1070 |
+
"domain_checks": nonprojector_checks,
|
| 1071 |
+
"minimum_domain_rms": minimum_control_rms,
|
| 1072 |
+
},
|
| 1073 |
+
"summary": {
|
| 1074 |
+
"worst_projector_idempotence_max_abs": worst_idempotence_max,
|
| 1075 |
+
"worst_g_inverse_roundtrip_max_abs": worst_roundtrip_max,
|
| 1076 |
+
"minimum_nonprojector_control_rms": minimum_control_rms,
|
| 1077 |
+
"trained_heldout_mse": trained_metrics["mse"],
|
| 1078 |
+
"trained_mean_psnr_db": trained_metrics["mean_psnr_db"],
|
| 1079 |
+
"elapsed_seconds": elapsed,
|
| 1080 |
+
},
|
| 1081 |
+
"training_history": history,
|
| 1082 |
+
"environment": environment_metadata(device),
|
| 1083 |
+
"artifacts": {
|
| 1084 |
+
"checkpoint": checkpoint_path.name,
|
| 1085 |
+
"checkpoint_sha256": file_sha256(checkpoint_path),
|
| 1086 |
+
"gallery": "gallery.png",
|
| 1087 |
+
"training_history": "training_history.csv",
|
| 1088 |
+
"split_indices": "split_indices.npz",
|
| 1089 |
+
},
|
| 1090 |
+
}
|
| 1091 |
+
metrics_path = args.output_dir / "metrics.json"
|
| 1092 |
+
with metrics_path.open("w", encoding="utf-8") as handle:
|
| 1093 |
+
json.dump(results, handle, indent=2, sort_keys=True)
|
| 1094 |
+
handle.write("\n")
|
| 1095 |
+
|
| 1096 |
+
print(
|
| 1097 |
+
"RESULT "
|
| 1098 |
+
f"corrupt_mse={corrupt_metrics['mse']:.6f} "
|
| 1099 |
+
f"untrained_mse={untrained_metrics['mse']:.6f} "
|
| 1100 |
+
f"trained_mse={trained_metrics['mse']:.6f} "
|
| 1101 |
+
f"trained_psnr_db={trained_metrics['mean_psnr_db']:.3f} "
|
| 1102 |
+
f"worst_idempotence_max={worst_idempotence_max:.3e} "
|
| 1103 |
+
f"worst_roundtrip_max={worst_roundtrip_max:.3e} "
|
| 1104 |
+
f"minimum_control_rms={minimum_control_rms:.3e}",
|
| 1105 |
+
flush=True,
|
| 1106 |
+
)
|
| 1107 |
+
print(f"METRICS_JSON={metrics_path.resolve()}", flush=True)
|
| 1108 |
+
return 0
|
| 1109 |
+
|
| 1110 |
+
|
| 1111 |
+
if __name__ == "__main__":
|
| 1112 |
+
raise SystemExit(main())
|
| 1113 |
+
|
| 1114 |
+
````
|
| 1115 |
+
|
| 1116 |
+
|
| 1117 |
+
````output
|
| 1118 |
+
/home/ubuntu/samuel/repro/.venv/lib/python3.10/site-packages/huggingface_hub/utils/_experimental.py:59: UserWarning: 'HfApi.run_uv_job' is experimental and might be subject to breaking changes in the future without prior notice. You can disable this warning by setting `HF_HUB_DISABLE_EXPERIMENTAL_WARNING=1` as environment variable.
|
| 1119 |
+
warnings.warn(
|
| 1120 |
+
Error: Client error '402 Payment Required' for url 'https://huggingface.co/api/jobs/SabaPivot' (Request ID: Root=1-6a58815c-0a19259b24932e3178a76fae;aa469bb5-baa1-4b4a-aa8e-e72007b079bd)
|
| 1121 |
+
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/402
|
| 1122 |
+
|
| 1123 |
+
Pre-paid credit balance is insufficient - add more credits to your account to use Jobs.
|
| 1124 |
+
Hint: set HF_DEBUG=1 as environment variable for full traceback.
|
| 1125 |
+
Your script and Job artifacts will be saved in this bucket: https://huggingface.co/buckets/SabaPivot/jobs-artifacts
|
| 1126 |
+
|
| 1127 |
+
````
|