SabaPivot commited on
Commit
4e4e019
·
verified ·
1 Parent(s): f2fb2cb

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-16T05:04:20+00:00",
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": 6650,
64
- "revision": "1784178260139660995"
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
+ ````