AbstractPhil commited on
Commit
6fc1618
Β·
verified Β·
1 Parent(s): a8abe0b

Create svae_cadence.py

Browse files
Files changed (1) hide show
  1. svae_cadence.py +517 -0
svae_cadence.py ADDED
@@ -0,0 +1,517 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SVAE v2 Conduit Trainer β€” Prototype
3
+ =====================================
4
+ Train PatchSVAEv2 from random init on noise.
5
+ The decoder MUST reconstruct from decomposed spectral + conduit bundles.
6
+ No M_hat shortcut. Every conduit element is load-bearing.
7
+
8
+ Readouts per epoch:
9
+ Standard: MSE, S profile, erank, s_delta, CV
10
+ Conduit: friction stats, settle distribution, char_coeff profile,
11
+ per-mode reconstruction contribution
12
+
13
+ Should converge rapidly or fail β€” we'll know within 10 epochs.
14
+
15
+ Usage:
16
+ python train_v2_conduit.py
17
+ """
18
+
19
+ import os
20
+ import math
21
+ import time
22
+ import json
23
+ import numpy as np
24
+ import torch
25
+ import torch.nn as nn
26
+ import torch.nn.functional as F
27
+ from tqdm import tqdm
28
+
29
+ try:
30
+ from google.colab import userdata
31
+ os.environ["HF_TOKEN"] = userdata.get('HF_TOKEN')
32
+ from huggingface_hub import login
33
+ login(token=os.environ["HF_TOKEN"])
34
+ except Exception:
35
+ pass
36
+
37
+ from geolip_svae.model import cv_of, extract_patches, stitch_patches
38
+ from geolip_svae.model_v2 import PatchSVAEv2
39
+
40
+
41
+ # ═══════════════════════════════════════════════════════════════
42
+ # CONFIG
43
+ # ═══════════════════════════════════════════════════════════════
44
+
45
+ HF_REPO = 'AbstractPhil/geolip-SVAE'
46
+ VERSION = 'v51_conduit_proto'
47
+ LOCAL_DIR = f'/content/{VERSION}_checkpoints'
48
+ LOG_PATH = os.path.join(LOCAL_DIR, 'training_log.json')
49
+
50
+ CFG = dict(
51
+ # Architecture (inherited from Fresnel v50)
52
+ V=48, D=4, ps=4, hidden=384, depth=4, n_cross=2,
53
+
54
+ # Training
55
+ img_size=64,
56
+ batch_size=256,
57
+ lr=3e-4,
58
+ epochs=50,
59
+ ds_size=1280000,
60
+ val_size=10000,
61
+
62
+ # CV soft hand
63
+ target_cv=0.125,
64
+ cv_weight=0.3,
65
+ boost=0.5,
66
+ sigma=0.15,
67
+
68
+ # Checkpointing
69
+ save_every=5,
70
+ val_per_type_every=5,
71
+ )
72
+
73
+
74
+ # ═══════════════════════════════════════════════════════════════
75
+ # NOISE DATASET (16 types, same as Freckles)
76
+ # ═══════════════════════════════════════════════════════════════
77
+
78
+ NOISE_NAMES = {
79
+ 0: 'gaussian', 1: 'uniform', 2: 'uniform_sc', 3: 'poisson',
80
+ 4: 'pink', 5: 'brown', 6: 'salt_pepper', 7: 'sparse',
81
+ 8: 'block', 9: 'gradient', 10: 'checker', 11: 'mixed',
82
+ 12: 'structural', 13: 'cauchy', 14: 'exponential', 15: 'laplace',
83
+ }
84
+
85
+
86
+ def _pink(shape):
87
+ w = torch.randn(shape)
88
+ S = torch.fft.rfft2(w)
89
+ h, ww = shape[-2], shape[-1]
90
+ fy = torch.fft.fftfreq(h).unsqueeze(-1).expand(-1, ww // 2 + 1)
91
+ fx = torch.fft.rfftfreq(ww).unsqueeze(0).expand(h, -1)
92
+ return torch.fft.irfft2(S / torch.sqrt(fx**2 + fy**2).clamp(min=1e-8), s=(h, ww))
93
+
94
+
95
+ def _brown(shape):
96
+ w = torch.randn(shape)
97
+ S = torch.fft.rfft2(w)
98
+ h, ww = shape[-2], shape[-1]
99
+ fy = torch.fft.fftfreq(h).unsqueeze(-1).expand(-1, ww // 2 + 1)
100
+ fx = torch.fft.rfftfreq(ww).unsqueeze(0).expand(h, -1)
101
+ return torch.fft.irfft2(S / (fx**2 + fy**2).clamp(min=1e-8), s=(h, ww))
102
+
103
+
104
+ def _gen_noise(noise_type, s, rng):
105
+ if noise_type == 0: return torch.randn(3, s, s)
106
+ elif noise_type == 1: return torch.rand(3, s, s) * 2 - 1
107
+ elif noise_type == 2: return (torch.rand(3, s, s) - 0.5) * 4
108
+ elif noise_type == 3:
109
+ lam = rng.uniform(0.5, 20.0)
110
+ return torch.poisson(torch.full((3, s, s), lam)) / lam - 1.0
111
+ elif noise_type == 4:
112
+ img = _pink((3, s, s)); return img / (img.std() + 1e-8)
113
+ elif noise_type == 5:
114
+ img = _brown((3, s, s)); return img / (img.std() + 1e-8)
115
+ elif noise_type == 6:
116
+ return torch.where(torch.rand(3, s, s) > 0.5,
117
+ torch.ones(3, s, s) * 2, -torch.ones(3, s, s) * 2) + torch.randn(3, s, s) * 0.1
118
+ elif noise_type == 7:
119
+ return torch.randn(3, s, s) * (torch.rand(3, s, s) > 0.9).float() * 3
120
+ elif noise_type == 8:
121
+ b = rng.randint(2, max(3, s // 2))
122
+ sm = torch.randn(3, s // b + 1, s // b + 1)
123
+ return F.interpolate(sm.unsqueeze(0), size=s, mode='nearest').squeeze(0)
124
+ elif noise_type == 9:
125
+ gy = torch.linspace(-2, 2, s).unsqueeze(1).expand(s, s)
126
+ gx = torch.linspace(-2, 2, s).unsqueeze(0).expand(s, s)
127
+ a = rng.uniform(0, 2 * math.pi)
128
+ return (math.cos(a) * gx + math.sin(a) * gy).unsqueeze(0).expand(3, -1, -1) + torch.randn(3, s, s) * 0.5
129
+ elif noise_type == 10:
130
+ cs = rng.randint(2, max(3, s // 2))
131
+ cy = torch.arange(s) // cs; cx = torch.arange(s) // cs
132
+ return ((cy.unsqueeze(1) + cx.unsqueeze(0)) % 2).float().unsqueeze(0).expand(3, -1, -1) * 2 - 1 + torch.randn(3, s, s) * 0.3
133
+ elif noise_type == 11:
134
+ alpha = rng.uniform(0.2, 0.8)
135
+ return alpha * torch.randn(3, s, s) + (1 - alpha) * (torch.rand(3, s, s) * 2 - 1)
136
+ elif noise_type == 12:
137
+ img = torch.zeros(3, s, s); h2 = s // 2
138
+ img[:, :h2, :h2] = torch.randn(3, h2, h2)
139
+ img[:, :h2, h2:] = torch.rand(3, h2, h2) * 2 - 1
140
+ img[:, h2:, :h2] = _pink((3, h2, h2)) / 2
141
+ img[:, h2:, h2:] = torch.where(torch.rand(3, h2, h2) > 0.5,
142
+ torch.ones(3, h2, h2), -torch.ones(3, h2, h2))
143
+ return img
144
+ elif noise_type == 13:
145
+ return torch.tan(math.pi * (torch.rand(3, s, s) - 0.5)).clamp(-3, 3)
146
+ elif noise_type == 14:
147
+ return torch.empty(3, s, s).exponential_(1.0) - 1.0
148
+ elif noise_type == 15:
149
+ u = torch.rand(3, s, s) - 0.5
150
+ return -torch.sign(u) * torch.log1p(-2 * u.abs())
151
+ return torch.randn(3, s, s)
152
+
153
+
154
+ class OmegaNoiseDataset(torch.utils.data.Dataset):
155
+ def __init__(self, size=1280000, img_size=64):
156
+ self.size = size
157
+ self.img_size = img_size
158
+ self._rng = np.random.RandomState(42)
159
+ self._call_count = 0
160
+ def __len__(self):
161
+ return self.size
162
+ def __getitem__(self, idx):
163
+ self._call_count += 1
164
+ if self._call_count % 1000 == 0:
165
+ self._rng = np.random.RandomState(int.from_bytes(os.urandom(4), 'big'))
166
+ torch.manual_seed(int.from_bytes(os.urandom(4), 'big'))
167
+ noise_type = idx % 16
168
+ img = _gen_noise(noise_type, self.img_size, self._rng).clamp(-4, 4)
169
+ return img.float(), noise_type
170
+
171
+
172
+ def eval_per_type(model, img_size, device, n_per=32):
173
+ rng = np.random.RandomState(99)
174
+ model.eval()
175
+ results = {}
176
+ with torch.no_grad():
177
+ for t in range(16):
178
+ imgs = torch.stack([_gen_noise(t, img_size, rng).clamp(-4, 4)
179
+ for _ in range(n_per)]).to(device)
180
+ out = model(imgs)
181
+ results[t] = F.mse_loss(out['recon'], imgs).item()
182
+ return results
183
+
184
+
185
+ # ═══════════════════════════════════════════════════════════════
186
+ # CONDUIT READOUTS
187
+ # ═══════════════════════════════════════════════════════════════
188
+
189
+ def conduit_readout(model, images, device):
190
+ """Extract and summarize conduit telemetry from a batch."""
191
+ model.eval()
192
+ with torch.no_grad():
193
+ out = model(images.to(device))
194
+ packet = model.last_conduit_packet
195
+
196
+ S = out['svd']['S_orig'] # (B, N, D)
197
+ B, N, D = S.shape
198
+
199
+ friction = packet.friction.reshape(B, N, D)
200
+ settle = packet.settle.reshape(B, N, D)
201
+ char_coeffs = packet.char_coeffs.reshape(B, N, D)
202
+ ext_order = packet.extraction_order.reshape(B, N, D)
203
+ refine_res = packet.refinement_residual.reshape(B, N)
204
+
205
+ # Log-friction for readable stats
206
+ log_fric = torch.log1p(friction)
207
+
208
+ stats = {
209
+ 'S_mean': S.mean(dim=(0, 1)).cpu().tolist(),
210
+ 'S_std': S.std(dim=(0, 1)).cpu().tolist(),
211
+ 'friction_mean': friction.mean().item(),
212
+ 'friction_max': friction.max().item(),
213
+ 'friction_std': friction.std().item(),
214
+ 'log_fric_mean': log_fric.mean(dim=(0, 1)).cpu().tolist(),
215
+ 'log_fric_std': log_fric.std(dim=(0, 1)).cpu().tolist(),
216
+ 'settle_mean': settle.mean(dim=(0, 1)).cpu().tolist(),
217
+ 'settle_frac_gt2': (settle > 2).float().mean().item(),
218
+ 'char_coeffs_mean': char_coeffs.mean(dim=(0, 1)).cpu().tolist(),
219
+ 'refine_res_mean': refine_res.mean().item(),
220
+ 'refine_res_max': refine_res.max().item(),
221
+ }
222
+
223
+ # Per-mode friction spatial CV
224
+ for d in range(D):
225
+ per_img = friction[:, :, d].reshape(B, -1)
226
+ cvs = per_img.std(dim=1) / (per_img.mean(dim=1) + 1e-8)
227
+ stats[f'friction_spatial_cv_mode{d}'] = cvs.mean().item()
228
+
229
+ return stats
230
+
231
+
232
+ def print_conduit_readout(stats, D=4):
233
+ """Pretty-print conduit telemetry."""
234
+ print(f" S: [{', '.join(f'{v:.3f}' for v in stats['S_mean'])}]")
235
+ print(f" S_std: [{', '.join(f'{v:.4f}' for v in stats['S_std'])}]")
236
+ print(f" log_fric: [{', '.join(f'{v:.3f}' for v in stats['log_fric_mean'])}] "
237
+ f"Β± [{', '.join(f'{v:.3f}' for v in stats['log_fric_std'])}]")
238
+ print(f" fric_raw: mean={stats['friction_mean']:.1f} max={stats['friction_max']:.0f}")
239
+ print(f" settle: [{', '.join(f'{v:.2f}' for v in stats['settle_mean'])}] "
240
+ f"(>{2}: {stats['settle_frac_gt2']:.1%})")
241
+ print(f" char_c: [{', '.join(f'{v:.4f}' for v in stats['char_coeffs_mean'])}]")
242
+ print(f" refine: mean={stats['refine_res_mean']:.2e} max={stats['refine_res_max']:.2e}")
243
+ spatial_cvs = [stats.get(f'friction_spatial_cv_mode{d}', 0) for d in range(D)]
244
+ print(f" fric_cv: [{', '.join(f'{v:.4f}' for v in spatial_cvs)}]")
245
+
246
+
247
+ # ═══════════════════════════════════════════════════════════════
248
+ # LOGGING
249
+ # ═══════════════════════════════════════════════════════════════
250
+
251
+ def load_log():
252
+ if os.path.exists(LOG_PATH):
253
+ with open(LOG_PATH) as f:
254
+ return json.load(f)
255
+ return {'version': VERSION, 'entries': []}
256
+
257
+
258
+ def save_log(log):
259
+ os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
260
+ with open(LOG_PATH, 'w') as f:
261
+ json.dump(log, f, indent=2)
262
+
263
+
264
+ # ═══════════════════════════════════════════════════════════════
265
+ # SAVE & PUSH
266
+ # ═══════════════════════════════════════════════════════════════
267
+
268
+ def save_checkpoint(model, opt, sched, epoch, val_mse, log,
269
+ path, is_best=False):
270
+ os.makedirs(os.path.dirname(path), exist_ok=True)
271
+ ckpt = {
272
+ 'config': {
273
+ 'V': CFG['V'], 'D': CFG['D'], 'patch_size': CFG['ps'],
274
+ 'hidden': CFG['hidden'], 'depth': CFG['depth'],
275
+ 'n_cross_layers': CFG['n_cross'],
276
+ 'img_size': CFG['img_size'],
277
+ 'model_type': 'v2',
278
+ },
279
+ 'model_state_dict': model.state_dict(),
280
+ 'optimizer_state_dict': opt.state_dict(),
281
+ 'scheduler_state_dict': sched.state_dict(),
282
+ 'epoch': epoch,
283
+ 'val_mse': val_mse,
284
+ }
285
+ torch.save(ckpt, path)
286
+ size_mb = os.path.getsize(path) / (1024 * 1024)
287
+ print(f" πŸ’Ύ {path} ({size_mb:.1f}MB, ep{epoch}, MSE={val_mse:.6f})")
288
+
289
+ try:
290
+ from huggingface_hub import HfApi
291
+ api = HfApi()
292
+ api.upload_file(
293
+ path_or_fileobj=path,
294
+ path_in_repo=f'{VERSION}/checkpoints/{os.path.basename(path)}',
295
+ repo_id=HF_REPO, repo_type='model',
296
+ commit_message=f'{VERSION} ep{epoch} mse={val_mse:.6f}')
297
+ if is_best:
298
+ api.upload_file(
299
+ path_or_fileobj=path,
300
+ path_in_repo=f'{VERSION}/checkpoints/best.pt',
301
+ repo_id=HF_REPO, repo_type='model',
302
+ commit_message=f'{VERSION} BEST ep{epoch} mse={val_mse:.6f}')
303
+ save_log(log)
304
+ api.upload_file(
305
+ path_or_fileobj=LOG_PATH,
306
+ path_in_repo=f'{VERSION}/training_log.json',
307
+ repo_id=HF_REPO, repo_type='model',
308
+ commit_message=f'{VERSION} log ep{epoch}')
309
+ print(f" ☁️ Pushed ep{epoch}")
310
+ except Exception as e:
311
+ print(f" ⚠️ Push failed: {e}")
312
+
313
+
314
+ # ═══════════════════════════════════════════════════════════════
315
+ # TRAINING
316
+ # ═══════════════════════════════════════════════════════════════
317
+
318
+ def train():
319
+ device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
320
+ os.makedirs(LOCAL_DIR, exist_ok=True)
321
+
322
+ print("\n" + "=" * 70)
323
+ print(f"SVAE v2 CONDUIT TRAINER β€” {VERSION}")
324
+ print("=" * 70)
325
+
326
+ # ── Fresh v2 model from random init ──
327
+ D = CFG['D']
328
+ model = PatchSVAEv2(
329
+ V=CFG['V'], D=D, ps=CFG['ps'],
330
+ hidden=CFG['hidden'], depth=CFG['depth'],
331
+ n_cross=CFG['n_cross'],
332
+ ).to(device)
333
+
334
+ n_params = sum(p.numel() for p in model.parameters())
335
+ print(f"\n Fresh PatchSVAEv2 from random init")
336
+ print(f" Total params: {n_params:,}")
337
+
338
+ # ── Data ──
339
+ print(f"\n Dataset: 16 noise types, {CFG['ds_size']:,} samples/epoch")
340
+ print(f" Image size: {CFG['img_size']}Γ—{CFG['img_size']}")
341
+ print(f" Batch size: {CFG['batch_size']}")
342
+
343
+ train_ds = OmegaNoiseDataset(size=CFG['ds_size'], img_size=CFG['img_size'])
344
+ val_ds = OmegaNoiseDataset(size=CFG['val_size'], img_size=CFG['img_size'])
345
+ train_loader = torch.utils.data.DataLoader(
346
+ train_ds, batch_size=CFG['batch_size'], shuffle=True,
347
+ num_workers=4, pin_memory=True, drop_last=True)
348
+ val_loader = torch.utils.data.DataLoader(
349
+ val_ds, batch_size=CFG['batch_size'], shuffle=False,
350
+ num_workers=4, pin_memory=True)
351
+
352
+ # ── Optimizer (all params β€” full model training) ──
353
+ opt = torch.optim.Adam(model.parameters(), lr=CFG['lr'])
354
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=CFG['epochs'])
355
+
356
+ # CV soft hand
357
+ target_cv = CFG['target_cv']
358
+ cv_weight = CFG['cv_weight']
359
+ boost = CFG['boost']
360
+ sigma = CFG['sigma']
361
+
362
+ # Log
363
+ log = load_log()
364
+ best_mse = float('inf')
365
+
366
+ # ── Initial conduit readout ──
367
+ print(f"\n Initial conduit profile:")
368
+ sample_batch = next(iter(val_loader))[0][:64]
369
+ init_stats = conduit_readout(model, sample_batch, device)
370
+ print_conduit_readout(init_stats, D)
371
+
372
+ # ── Initial MSE (will be terrible β€” decoder is random) ──
373
+ model.eval()
374
+ init_mse = 0
375
+ init_n = 0
376
+ with torch.no_grad():
377
+ for imgs, _ in val_loader:
378
+ imgs = imgs.to(device)
379
+ out = model(imgs)
380
+ init_mse += F.mse_loss(out['recon'], imgs).item() * len(imgs)
381
+ init_n += len(imgs)
382
+ if init_n >= 2560:
383
+ break
384
+ init_mse /= init_n
385
+ print(f"\n Initial MSE (random decoder): {init_mse:.4f}")
386
+ print("=" * 70)
387
+
388
+ # ═══════════════════════════════════════════════════════════
389
+ # TRAINING LOOP
390
+ # ═══════════════════════════════════════════════════════════
391
+
392
+ for epoch in range(1, CFG['epochs'] + 1):
393
+
394
+ model.train()
395
+ total_loss, total_recon, n = 0, 0, 0
396
+ last_cv = target_cv
397
+ t0 = time.time()
398
+
399
+ pbar = tqdm(train_loader,
400
+ desc=f"Ep {epoch}/{CFG['epochs']}",
401
+ bar_format='{l_bar}{bar:20}{r_bar}')
402
+
403
+ for batch_idx, (images, _) in enumerate(pbar):
404
+ images = images.to(device)
405
+ opt.zero_grad()
406
+ out = model(images)
407
+ recon_loss = F.mse_loss(out['recon'], images)
408
+
409
+ # CV soft hand
410
+ with torch.no_grad():
411
+ if batch_idx % 50 == 0:
412
+ cur_cv = cv_of(out['svd']['M'][0, 0])
413
+ if cur_cv > 0:
414
+ last_cv = cur_cv
415
+ delta = last_cv - target_cv
416
+ prox = math.exp(-delta**2 / (2 * sigma**2))
417
+
418
+ recon_w = 1.0 + boost * prox
419
+ cv_pen = cv_weight * (1.0 - prox)
420
+ loss = recon_w * recon_loss + cv_pen * (last_cv - target_cv)**2
421
+ loss.backward()
422
+
423
+ torch.nn.utils.clip_grad_norm_(
424
+ model.cross_attn.parameters(), max_norm=0.5)
425
+
426
+ opt.step()
427
+
428
+ total_loss += loss.item() * len(images)
429
+ total_recon += recon_loss.item() * len(images)
430
+ n += len(images)
431
+ pbar.set_postfix_str(f"mse={recon_loss.item():.4f} cv={last_cv:.3f}")
432
+
433
+ sched.step()
434
+ epoch_time = time.time() - t0
435
+
436
+ # ── Validation ──
437
+ model.eval()
438
+ val_mse, val_n = 0, 0
439
+ with torch.no_grad():
440
+ for imgs, _ in val_loader:
441
+ imgs = imgs.to(device)
442
+ out = model(imgs)
443
+ val_mse += F.mse_loss(out['recon'], imgs).item() * len(imgs)
444
+ val_n += len(imgs)
445
+ val_mse /= val_n
446
+
447
+ # ── Geometry snapshot ──
448
+ with torch.no_grad():
449
+ sample = next(iter(val_loader))[0][:64].to(device)
450
+ out = model(sample)
451
+ S_mean = out['svd']['S_orig'].mean(dim=(0, 1))
452
+ S_coord = out['svd']['S'].mean(dim=(0, 1))
453
+ erank = model.effective_rank(
454
+ out['svd']['S'].reshape(-1, D)).mean().item()
455
+ s_delta = model.s_delta(out['svd']['S_orig'], out['svd']['S'])
456
+
457
+ # ── Conduit readout ──
458
+ cond_stats = conduit_readout(model, sample.cpu(), device)
459
+
460
+ # ── Print ──
461
+ is_best = val_mse < best_mse
462
+ if is_best:
463
+ best_mse = val_mse
464
+
465
+ print(f"\n ep{epoch:3d} | recon={total_recon/n:.4f} val={val_mse:.4f} "
466
+ f"{'β˜… BEST' if is_best else ''} | "
467
+ f"er={erank:.2f} Sd={s_delta:.4f} cv={last_cv:.3f} | {epoch_time:.0f}s")
468
+ print_conduit_readout(cond_stats, D)
469
+
470
+ # ── Per-type eval ──
471
+ if epoch % CFG['val_per_type_every'] == 0 or epoch <= 3:
472
+ type_mse = eval_per_type(model, CFG['img_size'], device)
473
+ type_str = " ".join(
474
+ f"{NOISE_NAMES[t][:4]}={v:.3f}" for t, v in sorted(type_mse.items()))
475
+ print(f" types: {type_str}")
476
+
477
+ # ── Log entry ──
478
+ log['entries'].append({
479
+ 'epoch': epoch,
480
+ 'train_mse': total_recon / n,
481
+ 'val_mse': val_mse,
482
+ 'cv': last_cv,
483
+ 'erank': erank,
484
+ 's_delta': s_delta,
485
+ 'S_mean': S_mean.cpu().tolist(),
486
+ 'conduit': cond_stats,
487
+ 'epoch_time': epoch_time,
488
+ 'lr': opt.param_groups[0]['lr'],
489
+ })
490
+
491
+ # ── Checkpoint ──
492
+ if is_best:
493
+ save_checkpoint(model, opt, sched, epoch, val_mse, log,
494
+ os.path.join(LOCAL_DIR, 'best.pt'),
495
+ is_best=True)
496
+
497
+ if epoch % CFG['save_every'] == 0:
498
+ save_checkpoint(model, opt, sched, epoch, val_mse, log,
499
+ os.path.join(LOCAL_DIR, f'epoch_{epoch:04d}.pt'))
500
+
501
+ # ═══════════════════════════════════════════════════════════
502
+ # DONE
503
+ # ═══════════════════════════════════════════════════════════
504
+
505
+ print(f"\n{'=' * 70}")
506
+ print(f"v2 CONDUIT TRAINING COMPLETE β€” {VERSION}")
507
+ print(f" Best MSE: {best_mse:.6f}")
508
+ print(f" Epochs: {CFG['epochs']}")
509
+ print(f" Params: {n_params:,}")
510
+ print(f"{'=' * 70}")
511
+
512
+ return model
513
+
514
+
515
+ if __name__ == "__main__":
516
+ torch.set_float32_matmul_precision('high')
517
+ train()