AbstractPhil commited on
Commit
8514bad
Β·
verified Β·
1 Parent(s): cb75c5f

Create colab_cv_sweep_batched.py

Browse files
Files changed (1) hide show
  1. colab_cv_sweep_batched.py +456 -0
colab_cv_sweep_batched.py ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CV Loss Sweep β€” Pure Noise Prediction
3
+ ========================================
4
+ Random inputs β†’ MLP encoder β†’ S^(d-1) β†’ constellation β†’ predict 10 random labels.
5
+
6
+ No dataset. No structure. No signal. The model memorizes random noise→label
7
+ mappings. Any geometric regularity (CV convergence) is purely from:
8
+ - The unit hypersphere S^(d-1)
9
+ - The smooth optimizer (AdamW)
10
+ - The CV loss pressure (or lack thereof)
11
+
12
+ If CV β‰ˆ 0.20 with zero CV loss on pure noise, the constant is the sphere's
13
+ property, not a training artifact and not a data property.
14
+
15
+ Each run: 200 steps, ~2 seconds. Full sweep: ~1 minute.
16
+ """
17
+
18
+ import torch
19
+ import torch.nn as nn
20
+ import torch.nn.functional as F
21
+ import math
22
+ import time
23
+ import json
24
+
25
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
26
+ torch.backends.cuda.matmul.allow_tf32 = True
27
+ torch.backends.cudnn.allow_tf32 = True
28
+
29
+
30
+ # ═══════════════════════════════════════════════════════════════
31
+ # Noise Dataset β€” pure random, zero structure
32
+ # ═══════════════════════════════════════════════════════════════
33
+
34
+ class NoiseDataset(torch.utils.data.Dataset):
35
+ """Random Gaussian inputs with random labels. No signal."""
36
+ def __init__(self, n_samples=5000, input_dim=128, num_classes=10, seed=0):
37
+ torch.manual_seed(seed)
38
+ self.data = torch.randn(n_samples, input_dim)
39
+ self.labels = torch.randint(0, num_classes, (n_samples,))
40
+
41
+ def __len__(self):
42
+ return len(self.data)
43
+
44
+ def __getitem__(self, idx):
45
+ return self.data[idx], self.labels[idx]
46
+
47
+
48
+ # ═══════════════════════════════════════════════════════════════
49
+ # Minimal MLP Encoder β†’ S^(d-1)
50
+ # ═══════════════════════════════════════════════════════════════
51
+
52
+ class NoiseEncoder(nn.Module):
53
+ """MLP β†’ sphere. No convolutions, no structure."""
54
+ def __init__(self, input_dim=128, hidden_dim=256, output_dim=128):
55
+ super().__init__()
56
+ self.net = nn.Sequential(
57
+ nn.Linear(input_dim, hidden_dim),
58
+ nn.GELU(),
59
+ nn.Linear(hidden_dim, hidden_dim),
60
+ nn.GELU(),
61
+ nn.Linear(hidden_dim, output_dim),
62
+ nn.LayerNorm(output_dim),
63
+ )
64
+
65
+ def forward(self, x):
66
+ return F.normalize(self.net(x), dim=-1)
67
+
68
+
69
+ # ═══════════════════════════════════════════════════════════════
70
+ # Minimal Constellation + Classifier
71
+ # ═══════════════════════════════════════════════════════════════
72
+
73
+ class NoiseConstellation(nn.Module):
74
+ """Minimal: anchors + patchwork + classifier. No bridge, no push, no magnitude."""
75
+ def __init__(self, dim=128, n_anchors=64, n_comp=8, num_classes=10):
76
+ super().__init__()
77
+ self.n_anchors = n_anchors
78
+ self.n_comp = n_comp
79
+
80
+ anchors = F.normalize(torch.randn(n_anchors, dim), dim=-1)
81
+ self.anchors = nn.Parameter(anchors)
82
+
83
+ apc = n_anchors // n_comp
84
+ self.patchwork = nn.ModuleList([
85
+ nn.Sequential(nn.Linear(apc, 64), nn.GELU(), nn.Linear(64, 64))
86
+ for _ in range(n_comp)
87
+ ])
88
+ self.classifier = nn.Linear(n_comp * 64 + dim, num_classes)
89
+
90
+ def forward(self, emb):
91
+ anchors_n = F.normalize(self.anchors, dim=-1)
92
+ tri = emb @ anchors_n.T
93
+ apc = self.n_anchors // self.n_comp
94
+ pw_parts = []
95
+ for k in range(self.n_comp):
96
+ pw_parts.append(self.patchwork[k](tri[:, k*apc:(k+1)*apc]))
97
+ pw = torch.cat(pw_parts, dim=-1)
98
+ logits = self.classifier(torch.cat([pw, emb], dim=-1))
99
+ return logits, emb
100
+
101
+
102
+ # ═══════════════════════════════════════════════════════════════
103
+ # CV Computation
104
+ # ═══════════════════════════════════════════════════════════════
105
+
106
+ def _batch_volumes(emb, n_samples=200, n_points=5):
107
+ """Batched pentachoron volumes β€” zero Python loops."""
108
+ N, D = emb.shape
109
+ device, dtype = emb.device, emb.dtype
110
+ pool = min(N, 512)
111
+
112
+ # Batched randperm via argsort on random values
113
+ rand_keys = torch.rand(n_samples, pool, device=device)
114
+ indices = rand_keys.argsort(dim=1)[:, :n_points] # (n_samples, n_points)
115
+
116
+ # Gather: (n_samples, n_points, D)
117
+ pts = emb[:pool][indices]
118
+
119
+ # Gram β†’ squared distances: all batched
120
+ gram = torch.bmm(pts, pts.transpose(1, 2))
121
+ norms = torch.diagonal(gram, dim1=1, dim2=2)
122
+ d2 = F.relu(norms.unsqueeze(2) + norms.unsqueeze(1) - 2 * gram)
123
+
124
+ # Cayley-Menger: (n_samples, n_points+1, n_points+1)
125
+ M = n_points + 1
126
+ cm = torch.zeros(n_samples, M, M, device=device, dtype=dtype)
127
+ cm[:, 0, 1:] = 1.0
128
+ cm[:, 1:, 0] = 1.0
129
+ cm[:, 1:, 1:] = d2
130
+
131
+ k = n_points - 1
132
+ pf = ((-1.0) ** (k + 1)) / ((2.0 ** k) * (math.factorial(k) ** 2))
133
+
134
+ # Single batched det call
135
+ dets = pf * torch.linalg.det(cm.float())
136
+ valid = dets > 1e-20
137
+ return dets[valid].to(dtype).sqrt()
138
+
139
+
140
+ def cv_loss(emb, target=0.22, n_samples=32, n_points=5):
141
+ """Differentiable CV loss β€” batched."""
142
+ if emb.shape[0] < n_points:
143
+ return torch.tensor(0.0, device=emb.device, requires_grad=True)
144
+ vols = _batch_volumes(emb, n_samples=n_samples, n_points=n_points)
145
+ if vols.shape[0] < 5:
146
+ return torch.tensor(0.0, device=emb.device, requires_grad=True)
147
+ cv = vols.std() / (vols.mean() + 1e-8)
148
+ return (cv - target).pow(2)
149
+
150
+
151
+ def cv_metric(emb, n_samples=200, n_points=5):
152
+ """Non-differentiable CV β€” batched."""
153
+ with torch.no_grad():
154
+ vols = _batch_volumes(emb, n_samples=n_samples, n_points=n_points)
155
+ if vols.shape[0] < 10:
156
+ return 0.0
157
+ return (vols.std() / (vols.mean() + 1e-8)).item()
158
+
159
+
160
+ # ═══════════════════════════════════════════════════════════════
161
+ # Single Run
162
+ # ═══════════════════════════════════════════════════════════════
163
+
164
+ def run_experiment(cv_weight, cv_target, n_steps=200, dim=128, n_anchors=64,
165
+ batch_size=256, n_samples=5000, seed=42, pure_cv=False):
166
+ """One configuration. Returns results dict. ~2 seconds."""
167
+ torch.manual_seed(seed)
168
+
169
+ ds = NoiseDataset(n_samples=n_samples, input_dim=dim, num_classes=10, seed=seed + 1000)
170
+ loader = torch.utils.data.DataLoader(ds, batch_size=batch_size, shuffle=True, drop_last=True)
171
+
172
+ encoder = NoiseEncoder(input_dim=dim, hidden_dim=256, output_dim=dim).to(DEVICE)
173
+ constellation = NoiseConstellation(dim=dim, n_anchors=n_anchors).to(DEVICE)
174
+
175
+ params = list(encoder.parameters()) + list(constellation.parameters())
176
+ optimizer = torch.optim.AdamW(params, lr=0.001, weight_decay=0.05)
177
+
178
+ step = 0
179
+ cv_history = []
180
+ ce_history = []
181
+ acc_history = []
182
+
183
+ while step < n_steps:
184
+ for data, labels in loader:
185
+ if step >= n_steps:
186
+ break
187
+ data, labels = data.to(DEVICE), labels.to(DEVICE)
188
+ emb = encoder(data)
189
+ logits, _ = constellation(emb)
190
+
191
+ l_ce = F.cross_entropy(logits, labels)
192
+
193
+ if cv_weight > 0:
194
+ l_cv = cv_loss(emb, target=cv_target, n_samples=32)
195
+ else:
196
+ l_cv = torch.tensor(0.0, device=DEVICE)
197
+
198
+ if pure_cv:
199
+ loss = cv_weight * l_cv # NO CE, pure geometric pressure
200
+ else:
201
+ loss = l_ce + cv_weight * l_cv
202
+
203
+ optimizer.zero_grad()
204
+ loss.backward()
205
+ torch.nn.utils.clip_grad_norm_(params, 1.0)
206
+ optimizer.step()
207
+
208
+ acc = (logits.argmax(-1) == labels).float().mean().item()
209
+
210
+ # Measure CV every 50 steps
211
+ if step % 50 == 0 or step == n_steps - 1:
212
+ with torch.no_grad():
213
+ # Collect embeddings for CV measurement
214
+ all_emb = []
215
+ for d_batch, _ in loader:
216
+ all_emb.append(encoder(d_batch.to(DEVICE)))
217
+ if len(all_emb) * batch_size >= 2000:
218
+ break
219
+ all_emb = torch.cat(all_emb)[:2000]
220
+ v_cv = cv_metric(all_emb, n_samples=200)
221
+
222
+ # Effective dim
223
+ centered = all_emb[:1000] - all_emb[:1000].mean(0)
224
+ s = torch.linalg.svdvals(centered.float())
225
+ s_n = s / s.sum()
226
+ eff_dim = (1.0 / (s_n ** 2).sum()).item()
227
+
228
+ cv_history.append({'step': step, 'cv': round(v_cv, 4), 'eff_dim': round(eff_dim, 1)})
229
+
230
+ ce_history.append(l_ce.item())
231
+ acc_history.append(acc)
232
+ step += 1
233
+
234
+ # Final measurement
235
+ with torch.no_grad():
236
+ all_emb = []
237
+ for d_batch, _ in loader:
238
+ all_emb.append(encoder(d_batch.to(DEVICE)))
239
+ if len(all_emb) * batch_size >= 2000:
240
+ break
241
+ all_emb = torch.cat(all_emb)[:2000]
242
+ final_cv = cv_metric(all_emb, n_samples=300)
243
+ centered = all_emb[:1000] - all_emb[:1000].mean(0)
244
+ s = torch.linalg.svdvals(centered.float())
245
+ s_n = s / s.sum()
246
+ final_dim = (1.0 / (s_n ** 2).sum()).item()
247
+
248
+ return {
249
+ 'cv_weight': cv_weight,
250
+ 'cv_target': cv_target,
251
+ 'pure_cv': pure_cv,
252
+ 'seed': seed,
253
+ 'n_steps': n_steps,
254
+ 'dim': dim,
255
+ 'final_cv': round(final_cv, 4),
256
+ 'final_dim': round(final_dim, 1),
257
+ 'final_ce': round(sum(ce_history[-20:]) / 20, 4),
258
+ 'final_acc': round(sum(acc_history[-20:]) / 20 * 100, 1),
259
+ 'cv_trajectory': cv_history,
260
+ }
261
+
262
+
263
+ # ═══════════════════════════════════════════════════════════════
264
+ # SWEEP
265
+ # ═══════════════════════════════════════════════════════════════
266
+
267
+ print("=" * 80)
268
+ print("CV LOSS SWEEP β€” PURE NOISE PREDICTION")
269
+ print(" Random inputs β†’ MLP β†’ S^(d-1) β†’ constellation β†’ 10 random labels")
270
+ print(" No data structure. No signal. Pure sphere geometry + optimizer.")
271
+ print(" 200 steps per run, ~2s each.")
272
+ print("=" * 80)
273
+
274
+ # (cv_weight, cv_target, label, seed)
275
+ configs = [
276
+ # ── NO CV LOSS β€” baseline ──
277
+ (0.0, 0.0, "no_cv", 42),
278
+ (0.0, 0.0, "no_cv_s2", 123),
279
+ (0.0, 0.0, "no_cv_s3", 456),
280
+ (0.0, 0.0, "no_cv_s4", 789),
281
+ (0.0, 0.0, "no_cv_s5", 1337),
282
+
283
+ # ── CORRECT TARGET, VARYING WEIGHT ──
284
+ (0.001, 0.22, "w0.001_t0.22", 42),
285
+ (0.01, 0.22, "w0.01_t0.22", 42),
286
+ (0.1, 0.22, "w0.1_t0.22", 42),
287
+ (0.5, 0.22, "w0.5_t0.22", 42),
288
+ (1.0, 0.22, "w1.0_t0.22", 42),
289
+ (5.0, 0.22, "w5.0_t0.22", 42),
290
+ (10.0, 0.22, "w10_t0.22", 42),
291
+ (50.0, 0.22, "w50_t0.22", 42),
292
+ (100.0, 0.22, "w100_t0.22", 42),
293
+
294
+ # ── WRONG TARGETS, LOW WEIGHT (gentle push) ──
295
+ (0.01, 0.00, "w0.01_t0.00", 42),
296
+ (0.01, 0.05, "w0.01_t0.05", 42),
297
+ (0.01, 0.10, "w0.01_t0.10", 42),
298
+ (0.01, 0.30, "w0.01_t0.30", 42),
299
+ (0.01, 0.50, "w0.01_t0.50", 42),
300
+ (0.01, 0.80, "w0.01_t0.80", 42),
301
+ (0.01, 1.00, "w0.01_t1.00", 42),
302
+ (0.01, 2.00, "w0.01_t2.00", 42),
303
+
304
+ # ── WRONG TARGETS, MEDIUM WEIGHT (strong push) ──
305
+ (1.0, 0.00, "w1_t0.00", 42),
306
+ (1.0, 0.05, "w1_t0.05", 42),
307
+ (1.0, 0.50, "w1_t0.50", 42),
308
+ (1.0, 0.80, "w1_t0.80", 42),
309
+ (1.0, 1.00, "w1_t1.00", 42),
310
+
311
+ # ── WRONG TARGETS, EXTREME WEIGHT (maximum force) ──
312
+ (100.0, 0.00, "w100_t0.00", 42),
313
+ (100.0, 0.05, "w100_t0.05", 42),
314
+ (100.0, 0.10, "w100_t0.10", 42),
315
+ (100.0, 0.50, "w100_t0.50", 42),
316
+ (100.0, 0.80, "w100_t0.80", 42),
317
+ (100.0, 1.00, "w100_t1.00", 42),
318
+
319
+ # ── CV LOSS ONLY, NO CE (pure geometric pressure) ──
320
+ (1.0, 0.22, "pure_cv_t0.22", 42), # mark for CE override
321
+ (1.0, 0.05, "pure_cv_t0.05", 42),
322
+ (1.0, 0.50, "pure_cv_t0.50", 42),
323
+ (1.0, 0.80, "pure_cv_t0.80", 42),
324
+ (1.0, 1.00, "pure_cv_t1.00", 42),
325
+
326
+ # ── DIMENSION SWEEP (does dim change the constant?) ──
327
+ (0.0, 0.0, "dim16", 42), # mark for dim override
328
+ (0.0, 0.0, "dim32", 42),
329
+ (0.0, 0.0, "dim64", 42),
330
+ (0.0, 0.0, "dim256", 42),
331
+ (0.0, 0.0, "dim512", 42),
332
+ ]
333
+
334
+ # Special handling
335
+ pure_cv_labels = {l for _, _, l, _ in configs if l.startswith("pure_cv")}
336
+ dim_overrides = {"dim16": 16, "dim32": 32, "dim64": 64, "dim256": 256, "dim512": 512}
337
+
338
+ all_results = []
339
+ total = len(configs)
340
+
341
+ print(f"\n Running {total} configurations, 200 steps each")
342
+ print(f" Estimated time: ~{total * 3}s\n")
343
+
344
+ for i, (cv_w, cv_t, label, seed) in enumerate(configs):
345
+ t0 = time.time()
346
+ dim = dim_overrides.get(label, 128)
347
+ is_pure_cv = label in pure_cv_labels
348
+
349
+ print(f"[{i+1:2d}/{total}] {label:20s} w={cv_w:<8.3f} t={cv_t:<5.2f} d={dim:<4d}", end=" ", flush=True)
350
+
351
+ result = run_experiment(
352
+ cv_weight=cv_w,
353
+ cv_target=cv_t,
354
+ n_steps=200,
355
+ dim=dim,
356
+ seed=seed,
357
+ pure_cv=is_pure_cv,
358
+ )
359
+ result['label'] = label
360
+
361
+ elapsed = time.time() - t0
362
+ print(f"β†’ CV={result['final_cv']:.4f} dim={result['final_dim']:.0f} "
363
+ f"acc={result['final_acc']:.0f}% ({elapsed:.1f}s)")
364
+
365
+ all_results.append(result)
366
+
367
+
368
+ # ═══════════════════════════════════════════════════════════════
369
+ # SUMMARY TABLE
370
+ # ═════════════════════════════════════════��═════════════════════
371
+
372
+ print(f"\n\n{'='*90}")
373
+ print(f"{'LABEL':20s} {'CV_W':>8s} {'CV_T':>6s} {'DIM':>5s} {'FINAL_CV':>9s} {'EFF_DIM':>8s} {'ACC%':>6s} {'CE':>8s}")
374
+ print(f"{'─'*90}")
375
+
376
+ for r in all_results:
377
+ cv_mark = "βœ“" if 0.17 <= r['final_cv'] <= 0.24 else "~" if 0.15 <= r['final_cv'] <= 0.27 else "βœ—"
378
+ print(f"{r['label']:20s} {r['cv_weight']:>8.3f} {r['cv_target']:>6.2f} {r['dim']:>5d} "
379
+ f"{r['final_cv']:>8.4f}{cv_mark} {r['final_dim']:>7.0f} {r['final_acc']:>5.0f}% {r['final_ce']:>8.4f}")
380
+
381
+
382
+ # ═══════════════════════════════════════════════════════════════
383
+ # ANALYSIS
384
+ # ═══════════════════════════════════════════════════════════════
385
+
386
+ print(f"\n\n{'='*90}")
387
+ print("ANALYSIS")
388
+ print(f"{'='*90}")
389
+
390
+ # 1. Baseline: no CV loss
391
+ no_cv = [r for r in all_results if r['cv_weight'] == 0 and r['dim'] == 128]
392
+ if no_cv:
393
+ cvs = [r['final_cv'] for r in no_cv]
394
+ dims = [r['final_dim'] for r in no_cv]
395
+ print(f"\n [1] NO CV LOSS, PURE NOISE (d=128, {len(no_cv)} seeds):")
396
+ print(f" CV: mean={sum(cvs)/len(cvs):.4f} min={min(cvs):.4f} max={max(cvs):.4f} spread={max(cvs)-min(cvs):.4f}")
397
+ print(f" Dim: mean={sum(dims)/len(dims):.1f}")
398
+ within_band = sum(1 for c in cvs if 0.17 <= c <= 0.24)
399
+ print(f" Within [0.17, 0.24]: {within_band}/{len(cvs)}")
400
+
401
+ # 2. Weight sweep at correct target
402
+ weight_sweep = [r for r in all_results if r['cv_target'] == 0.22 and r['dim'] == 128 and not r['pure_cv']]
403
+ if weight_sweep:
404
+ print(f"\n [2] WEIGHT SWEEP (target=0.22, d=128):")
405
+ for r in sorted(weight_sweep, key=lambda x: x['cv_weight']):
406
+ print(f" w={r['cv_weight']:>8.3f} β†’ CV={r['final_cv']:.4f} acc={r['final_acc']:.0f}%")
407
+
408
+ # 3. Target sweep at fixed weight
409
+ for w in [0.01, 1.0, 100.0]:
410
+ target_runs = [r for r in all_results if r['cv_weight'] == w and r['dim'] == 128 and not r['pure_cv']]
411
+ if len(target_runs) > 2:
412
+ print(f"\n [3] TARGET SWEEP (w={w}, d=128):")
413
+ for r in sorted(target_runs, key=lambda x: x['cv_target']):
414
+ cv_mark = "βœ“" if 0.17 <= r['final_cv'] <= 0.24 else "βœ—"
415
+ print(f" target={r['cv_target']:.2f} β†’ CV={r['final_cv']:.4f}{cv_mark} acc={r['final_acc']:.0f}%")
416
+
417
+ # 4. Dimension sweep
418
+ dim_runs = [r for r in all_results if r['label'].startswith('dim')]
419
+ if dim_runs:
420
+ print(f"\n [4] DIMENSION SWEEP (no CV loss):")
421
+ for r in sorted(dim_runs, key=lambda x: x['dim']):
422
+ print(f" d={r['dim']:>4d} β†’ CV={r['final_cv']:.4f} eff_dim={r['final_dim']:.0f}")
423
+
424
+ # 5. Key question: can extreme weight move CV?
425
+ extreme = [r for r in all_results if r['cv_weight'] >= 100 and r['dim'] == 128]
426
+ if extreme:
427
+ print(f"\n [5] EXTREME FORCE (wβ‰₯100, d=128):")
428
+ for r in sorted(extreme, key=lambda x: x['cv_target']):
429
+ delta = abs(r['final_cv'] - 0.20)
430
+ print(f" target={r['cv_target']:.2f} β†’ CV={r['final_cv']:.4f} (Ξ” from 0.20: {delta:.4f}) acc={r['final_acc']:.0f}%")
431
+
432
+ # 5b. Pure CV β€” no CE, only geometric pressure
433
+ pure_runs = [r for r in all_results if r['pure_cv']]
434
+ if pure_runs:
435
+ print(f"\n [5b] PURE CV (no CE loss, only geometric pressure):")
436
+ for r in sorted(pure_runs, key=lambda x: x['cv_target']):
437
+ delta = abs(r['final_cv'] - 0.20)
438
+ print(f" target={r['cv_target']:.2f} β†’ CV={r['final_cv']:.4f} (Ξ” from 0.20: {delta:.4f}) dim={r['final_dim']:.0f}")
439
+
440
+ # 6. CV trajectory analysis β€” does it start elsewhere and converge?
441
+ print(f"\n [6] CV TRAJECTORIES (step 0 β†’ step 200):")
442
+ for r in all_results[:5]: # first 5 runs
443
+ traj = r.get('cv_trajectory', [])
444
+ if len(traj) >= 2:
445
+ first = traj[0]['cv']
446
+ last = traj[-1]['cv']
447
+ print(f" {r['label']:20s}: {first:.4f} β†’ {last:.4f} (Ξ”={last-first:+.4f})")
448
+
449
+ # Save
450
+ with open('cv_sweep_results.json', 'w') as f:
451
+ json.dump(all_results, f, indent=2, default=str)
452
+ print(f"\n Raw results saved to cv_sweep_results.json")
453
+
454
+ print(f"\n{'='*80}")
455
+ print("CV SWEEP COMPLETE")
456
+ print(f"{'='*80}")