ManmohanSharma commited on
Commit
1489894
·
verified ·
1 Parent(s): 56478de

Upload code/run_eval_296m.py

Browse files
Files changed (1) hide show
  1. code/run_eval_296m.py +415 -0
code/run_eval_296m.py ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unified 75M evaluation: held-out DESI (Test 1), stress curve (Test 3), line-vs-continuum rec (Test 4).
2
+
3
+ Loads the 75M checkpoint once and runs many configurations against an external held-out cache.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import copy
9
+ import json
10
+ import math
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import matplotlib
15
+
16
+ matplotlib.use("Agg")
17
+ import matplotlib.pyplot as plt
18
+ import numpy as np
19
+ import torch
20
+ import torch.nn.functional as F
21
+ from torch.utils.data import DataLoader
22
+ from tqdm import tqdm
23
+
24
+ from native_specz.data import SpectraListDataset
25
+ from native_specz.hybrid_redshift import HybridSpecZ, RawCollatorConfig, RawSpectraCollator, move_to_device
26
+ from native_specz.metrics import redshift_metrics
27
+
28
+
29
+ # ----- model + checkpoint -----
30
+
31
+ def build_model(ckpt: dict[str, Any], device: torch.device) -> HybridSpecZ:
32
+ a = ckpt.get("args", {}) if isinstance(ckpt, dict) else {}
33
+ model = HybridSpecZ(
34
+ d_model=int(a.get("d_model", 256)),
35
+ conv_width=int(a.get("conv_width", 128)),
36
+ layers=int(a.get("layers", 5)),
37
+ heads=int(a.get("heads", 8)),
38
+ dropout=float(a.get("dropout", 0.1)),
39
+ z_bins=int(a.get("z_bins", 64)),
40
+ stem_stride=int(a.get("stem_stride", 8)),
41
+ rec_hidden_mult=int(a.get("rec_hidden_mult", 0)),
42
+ rec_refine_width=int(a.get("rec_refine_width", 16)),
43
+ rec_refine_kernel=int(a.get("rec_refine_kernel", 5)),
44
+ layerscale_init=float(a.get("layerscale_init", 0.0)),
45
+ prediction_mode=str(a.get("prediction_mode", "regression")),
46
+ bin_temperature=float(a.get("bin_temperature", 1.0)),
47
+ residual_scale=float(a.get("residual_scale", 0.06)),
48
+ candidate_topk=int(a.get("candidate_topk", 5)),
49
+ ).to(device)
50
+ state = ckpt["model"] if isinstance(ckpt, dict) and "model" in ckpt else ckpt
51
+ missing, unexpected = model.load_state_dict(state, strict=False); print(f"NONSTRICT missing={len(missing)} unexpected={len(unexpected)}")
52
+ model.eval()
53
+ return model
54
+
55
+
56
+ # ----- perturbed sample helpers (for stress curve, Test 3) -----
57
+
58
+ def perturb_sample(sample: dict[str, Any], mode: str, strength: float, rng: np.random.Generator) -> dict[str, Any]:
59
+ """Apply a real instrument-shift perturbation directly to a sample dict."""
60
+ s = {k: (v.copy() if isinstance(v, np.ndarray) else v) for k, v in sample.items()}
61
+ flux = s["flux"].astype(np.float32)
62
+ ivar = s["ivar"].astype(np.float32)
63
+ lam = s["lambda"].astype(np.float32)
64
+ bad = s["bad_mask"].astype(np.bool_)
65
+ n = len(flux)
66
+ if mode == "wavelength_crop":
67
+ # Keep a contiguous window covering fraction 1-strength of the spectrum.
68
+ keep_frac = max(0.15, 1.0 - strength)
69
+ width = max(64, int(n * keep_frac))
70
+ start = int(rng.integers(0, max(1, n - width)))
71
+ keep = np.zeros(n, dtype=np.bool_)
72
+ keep[start : start + width] = True
73
+ bad |= ~keep
74
+ elif mode == "noise":
75
+ # strength is a multiplier on typical sigma.
76
+ good = np.isfinite(ivar) & (ivar > 0)
77
+ sigma = np.zeros_like(flux)
78
+ sigma[good] = 1.0 / np.sqrt(np.maximum(ivar[good], 1e-8))
79
+ flux = flux + rng.normal(0.0, sigma * float(strength)).astype(np.float32)
80
+ elif mode == "throughput":
81
+ # strength scales the curvature amplitude.
82
+ x = np.linspace(-1.0, 1.0, n, dtype=np.float32)
83
+ amp = float(strength)
84
+ coeff = rng.normal(0.0, [0.10 * amp, 0.05 * amp, 0.03 * amp]).astype(np.float32)
85
+ curve = 1.0 + coeff[0] * x + coeff[1] * (x * x - 0.33) + coeff[2] * np.sin(np.pi * x)
86
+ flux = flux * np.clip(curve, 0.3, 1.7).astype(np.float32)
87
+ elif mode == "resolution":
88
+ # Gaussian smoothing followed by replacement — simulates lower-resolution spectrograph.
89
+ # strength = sigma in pixels.
90
+ sigma_pix = max(1.0, float(strength))
91
+ radius = max(3, int(math.ceil(3.0 * sigma_pix)))
92
+ xs = np.arange(-radius, radius + 1, dtype=np.float32)
93
+ k = np.exp(-0.5 * (xs / sigma_pix) ** 2)
94
+ k = k / k.sum()
95
+ good = np.isfinite(flux) & (~bad)
96
+ f = np.where(good, flux, 0.0)
97
+ w = good.astype(np.float32)
98
+ f_sm = np.convolve(f, k, mode="same")
99
+ w_sm = np.convolve(w, k, mode="same")
100
+ flux = np.where(w_sm > 0.01, f_sm / np.maximum(w_sm, 1e-6), flux)
101
+ else:
102
+ raise ValueError(f"unknown mode {mode}")
103
+ s["flux"] = flux.astype(np.float32)
104
+ s["ivar"] = ivar.astype(np.float32)
105
+ s["bad_mask"] = bad.astype(np.bool_)
106
+ return s
107
+
108
+
109
+ class PerturbedDataset(torch.utils.data.Dataset):
110
+ def __init__(self, samples: list[dict[str, Any]], mode: str, strength: float, seed: int):
111
+ self.samples = samples
112
+ self.mode = mode
113
+ self.strength = float(strength)
114
+ self.seed = int(seed)
115
+
116
+ def __len__(self) -> int:
117
+ return len(self.samples)
118
+
119
+ def __getitem__(self, idx: int) -> dict[str, Any]:
120
+ s = self.samples[idx]
121
+ if self.mode == "none" or self.strength <= 0:
122
+ return s
123
+ h = abs(hash((self.seed, self.mode, s["object_id"]))) % (2**32 - 1)
124
+ rng = np.random.default_rng(h)
125
+ return perturb_sample(s, self.mode, self.strength, rng)
126
+
127
+
128
+ # ----- core eval loop -----
129
+
130
+ @torch.no_grad()
131
+ def run_eval(
132
+ model: HybridSpecZ,
133
+ samples: list[dict[str, Any]],
134
+ cfg: RawCollatorConfig,
135
+ device: torch.device,
136
+ *,
137
+ perturb_mode: str = "none",
138
+ perturb_strength: float = 0.0,
139
+ batch_size: int = 16,
140
+ num_workers: int = 2,
141
+ collator_seed: int = 31415,
142
+ max_samples: int | None = None,
143
+ ) -> dict[str, np.ndarray]:
144
+ if max_samples is not None and max_samples < len(samples):
145
+ samples = samples[:max_samples]
146
+ ds = PerturbedDataset(samples, perturb_mode, perturb_strength, seed=collator_seed)
147
+ loader = DataLoader(
148
+ ds,
149
+ batch_size=batch_size,
150
+ shuffle=False,
151
+ num_workers=num_workers,
152
+ pin_memory=True,
153
+ collate_fn=RawSpectraCollator(cfg, train=False, seed=collator_seed),
154
+ )
155
+ z_true_l, y_true_l, y_pred_l, zwarn_l = [], [], [], []
156
+ rec_l, rec_line_l, rec_cont_l = [], [], []
157
+ line_count_l, cont_count_l = [], []
158
+ oid_l: list[str] = []
159
+ object_ids = [s["object_id"] for s in samples]
160
+ idx_offset = 0
161
+ for batch in tqdm(loader, desc=f"{perturb_mode}_s{perturb_strength:.2f}", leave=False):
162
+ batch = move_to_device(batch, device)
163
+ with torch.autocast(device_type="cuda", dtype=torch.bfloat16, enabled=device.type == "cuda"):
164
+ out = model(batch["x"], batch["valid"], batch["loglam"])
165
+ y_pred = out.get("y_pred", out["y_mu"]).float()
166
+ y_true = batch["y"].float()
167
+ finite = torch.isfinite(y_true)
168
+ z_true_l.append(batch["z"][finite].detach().cpu().numpy())
169
+ y_true_l.append(y_true[finite].detach().cpu().numpy())
170
+ y_pred_l.append(y_pred[finite].detach().cpu().numpy())
171
+ zwarn_l.append(batch["zwarn"][finite].detach().cpu().numpy().astype(np.bool_))
172
+
173
+ # Per-sample rec losses on masked pixels (all / line / continuum)
174
+ rec = out.get("rec")
175
+ bs = batch["y"].shape[0]
176
+ if rec is not None and "target_flux" in batch and "loss_mask" in batch:
177
+ per_pix = F.smooth_l1_loss(rec.float(), batch["target_flux"].float(), reduction="none", beta=0.5).detach().cpu().numpy()
178
+ mask = batch["loss_mask"].detach().cpu().numpy().astype(np.float32)
179
+ line_region = batch["line_region"].detach().cpu().numpy().astype(np.bool_)
180
+ denom = mask.sum(axis=1).clip(min=1.0)
181
+ rec_per = (per_pix * mask).sum(axis=1) / denom
182
+ line_mask = mask * line_region.astype(np.float32)
183
+ cont_mask = mask * (~line_region).astype(np.float32)
184
+ line_denom = line_mask.sum(axis=1)
185
+ cont_denom = cont_mask.sum(axis=1)
186
+ rec_line_per = np.where(line_denom > 0, (per_pix * line_mask).sum(axis=1) / np.maximum(line_denom, 1.0), np.nan)
187
+ rec_cont_per = np.where(cont_denom > 0, (per_pix * cont_mask).sum(axis=1) / np.maximum(cont_denom, 1.0), np.nan)
188
+ rec_l.append(rec_per[finite.detach().cpu().numpy()])
189
+ rec_line_l.append(rec_line_per[finite.detach().cpu().numpy()])
190
+ rec_cont_l.append(rec_cont_per[finite.detach().cpu().numpy()])
191
+ line_count_l.append(line_denom[finite.detach().cpu().numpy()])
192
+ cont_count_l.append(cont_denom[finite.detach().cpu().numpy()])
193
+ else:
194
+ rec_l.append(np.full((int(finite.sum()),), np.nan, dtype=np.float32))
195
+ rec_line_l.append(np.full((int(finite.sum()),), np.nan, dtype=np.float32))
196
+ rec_cont_l.append(np.full((int(finite.sum()),), np.nan, dtype=np.float32))
197
+ line_count_l.append(np.zeros((int(finite.sum()),), dtype=np.float32))
198
+ cont_count_l.append(np.zeros((int(finite.sum()),), dtype=np.float32))
199
+
200
+ finite_np = finite.detach().cpu().numpy()
201
+ batch_oids = [object_ids[idx_offset + i] for i in range(bs)]
202
+ oid_l.extend([o for o, ok in zip(batch_oids, finite_np) if ok])
203
+ idx_offset += bs
204
+
205
+ return {
206
+ "z_true": np.concatenate(z_true_l).astype(np.float32),
207
+ "y_true": np.concatenate(y_true_l).astype(np.float32),
208
+ "y_pred": np.concatenate(y_pred_l).astype(np.float32),
209
+ "zwarn": np.concatenate(zwarn_l).astype(np.bool_),
210
+ "rec": np.concatenate(rec_l).astype(np.float32),
211
+ "rec_line": np.concatenate(rec_line_l).astype(np.float32),
212
+ "rec_cont": np.concatenate(rec_cont_l).astype(np.float32),
213
+ "line_count": np.concatenate(line_count_l).astype(np.float32),
214
+ "cont_count": np.concatenate(cont_count_l).astype(np.float32),
215
+ "object_id": np.asarray(oid_l, dtype=object),
216
+ }
217
+
218
+
219
+ def summarize(prefix: str, res: dict[str, np.ndarray]) -> dict[str, float]:
220
+ y_true = res["y_true"]
221
+ y_pred = res["y_pred"]
222
+ metrics = {f"{prefix}/{k}": v for k, v in redshift_metrics(y_true, y_pred).items()}
223
+ metrics[f"{prefix}/n"] = float(len(y_true))
224
+ metrics[f"{prefix}/zwarn_fraction"] = float(np.mean(res["zwarn"])) if len(res["zwarn"]) else math.nan
225
+ metrics[f"{prefix}/rec"] = float(np.nanmean(res["rec"])) if res["rec"].size else math.nan
226
+ metrics[f"{prefix}/rec_line"] = float(np.nanmean(res["rec_line"])) if res["rec_line"].size else math.nan
227
+ metrics[f"{prefix}/rec_cont"] = float(np.nanmean(res["rec_cont"])) if res["rec_cont"].size else math.nan
228
+ metrics[f"{prefix}/rec_line_count_mean"] = float(np.nanmean(res["line_count"])) if res["line_count"].size else math.nan
229
+ metrics[f"{prefix}/rec_cont_count_mean"] = float(np.nanmean(res["cont_count"])) if res["cont_count"].size else math.nan
230
+ # Per-slice metrics
231
+ z = np.expm1(y_true)
232
+ slices = {"z_lt_0p4": z < 0.4, "z_0p4_1p0": (z >= 0.4) & (z < 1.0), "z_1p0_2p0": (z >= 1.0) & (z < 2.0), "z_gte_2p0": z >= 2.0}
233
+ for name, mask in slices.items():
234
+ if mask.sum() >= 5:
235
+ sub = redshift_metrics(y_true[mask], y_pred[mask])
236
+ for k, v in sub.items():
237
+ metrics[f"{prefix}_slice/{name}/{k}"] = v
238
+ metrics[f"{prefix}_slice/{name}/n"] = float(mask.sum())
239
+ clean = ~res["zwarn"]
240
+ if clean.any():
241
+ sub = redshift_metrics(y_true[clean], y_pred[clean])
242
+ for k, v in sub.items():
243
+ metrics[f"{prefix}_clean/{k}"] = v
244
+ metrics[f"{prefix}_clean/n"] = float(clean.sum())
245
+ metrics[f"{prefix}_clean/rec"] = float(np.nanmean(res["rec"][clean])) if res["rec"][clean].size else math.nan
246
+ return metrics
247
+
248
+
249
+ def ensemble_z_median(ys: list[np.ndarray]) -> np.ndarray:
250
+ stack = np.stack(ys, axis=0)
251
+ return np.nanmedian(stack, axis=0).astype(np.float32)
252
+
253
+
254
+ def main() -> None:
255
+ parser = argparse.ArgumentParser()
256
+ parser.add_argument("--checkpoint", required=True)
257
+ parser.add_argument("--cache", default="/workspace/native_specz_mae/cache/desi_heldout_2500.pt")
258
+ parser.add_argument("--output-dir", required=True)
259
+ parser.add_argument("--batch-size", type=int, default=16)
260
+ parser.add_argument("--num-workers", type=int, default=2)
261
+ parser.add_argument("--mask-ratios", default="0.30,0.50,0.65,0.75")
262
+ parser.add_argument("--mask-mode", default="pixel")
263
+ parser.add_argument("--mask-span-min", type=int, default=16)
264
+ parser.add_argument("--mask-span-max", type=int, default=80)
265
+ parser.add_argument("--tta-views", type=int, default=5)
266
+ parser.add_argument("--max-samples", type=int, default=0)
267
+ parser.add_argument("--skip-stress", action="store_true")
268
+ args = parser.parse_args()
269
+
270
+ out_dir = Path(args.output_dir)
271
+ out_dir.mkdir(parents=True, exist_ok=True)
272
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
273
+
274
+ print(f"LOAD_CHECKPOINT {args.checkpoint}")
275
+ ckpt = torch.load(args.checkpoint, map_location=device, weights_only=False)
276
+ model = build_model(ckpt, device)
277
+ ckpt_args = ckpt.get("args", {}) if isinstance(ckpt, dict) else {}
278
+ target_length = int(ckpt_args.get("target_length", 8192))
279
+ n_params = sum(p.numel() for p in model.parameters())
280
+ print(f"MODEL_PARAMS {n_params}")
281
+
282
+ cache_payload = torch.load(args.cache, map_location="cpu", weights_only=False)
283
+ samples = cache_payload["samples"] if isinstance(cache_payload, dict) and "samples" in cache_payload else cache_payload
284
+ if args.max_samples > 0:
285
+ samples = samples[: args.max_samples]
286
+ print(f"HELDOUT_SAMPLES {len(samples)}")
287
+
288
+ mask_ratios = [float(x) for x in args.mask_ratios.split(",") if x]
289
+ base_cfg = RawCollatorConfig(
290
+ target_length=target_length,
291
+ eval_mask_ratio=0.25,
292
+ mask_mode=args.mask_mode,
293
+ mask_span_min=args.mask_span_min,
294
+ mask_span_max=args.mask_span_max,
295
+ line_region_percentile=90.0,
296
+ )
297
+
298
+ all_metrics: dict[str, Any] = {
299
+ "checkpoint": args.checkpoint,
300
+ "cache": args.cache,
301
+ "n_params": int(n_params),
302
+ "heldout_n": int(len(samples)),
303
+ "mask_ratios": mask_ratios,
304
+ "mask_mode": args.mask_mode,
305
+ "tta_views": int(args.tta_views),
306
+ }
307
+
308
+ # ===== Test 1a: held-out base eval at default mask 0.25 (no TTA, no aug) =====
309
+ print("=== TEST 1a: held-out base eval (mask=0.25) ===")
310
+ base_res = run_eval(model, samples, base_cfg, device, batch_size=args.batch_size, num_workers=args.num_workers, collator_seed=31415)
311
+ all_metrics.update(summarize("heldout_base", base_res))
312
+ np.savez_compressed(out_dir / "heldout_base.npz", **{k: v for k, v in base_res.items() if k != "object_id"}, object_id=base_res["object_id"].astype(str))
313
+
314
+ # ===== Test 1b: TTA — multiple eval passes with different mask seeds + light aug =====
315
+ print(f"=== TEST 1b: TTA ({args.tta_views} views) ===")
316
+ tta_cfg = copy.deepcopy(base_cfg)
317
+ tta_cfg.augment_ood = True
318
+ tta_cfg.noise_prob = 0.4
319
+ tta_cfg.throughput_prob = 0.4
320
+ tta_views_y: list[np.ndarray] = [base_res["y_pred"]] # include base prediction
321
+ for v in range(args.tta_views):
322
+ seed = 1000 + v * 17
323
+ view_res = run_eval(model, samples, tta_cfg, device, batch_size=args.batch_size, num_workers=args.num_workers, collator_seed=seed)
324
+ tta_views_y.append(view_res["y_pred"])
325
+ # Align by object_id (they should match because data order is stable)
326
+ y_tta_med = ensemble_z_median(tta_views_y)
327
+ tta_res = dict(base_res)
328
+ tta_res["y_pred"] = y_tta_med
329
+ all_metrics.update(summarize("heldout_tta", tta_res))
330
+ np.savez_compressed(out_dir / "heldout_tta.npz", y_pred_med=y_tta_med, y_pred_views=np.stack(tta_views_y, axis=0).astype(np.float32))
331
+
332
+ # ===== Test 1c: multi-mask reconstruction sweep =====
333
+ print("=== TEST 1c: multi-mask rec sweep ===")
334
+ multi_mask: dict[str, Any] = {}
335
+ for r in mask_ratios:
336
+ cfg = copy.deepcopy(base_cfg)
337
+ cfg.eval_mask_ratio = float(r)
338
+ res = run_eval(model, samples, cfg, device, batch_size=args.batch_size, num_workers=args.num_workers, collator_seed=31415, max_samples=min(1000, len(samples)))
339
+ key = f"heldout_mask{int(round(r*100)):02d}"
340
+ sub = summarize(key, res)
341
+ all_metrics.update(sub)
342
+ multi_mask[key] = {"rec": sub[f"{key}/rec"], "rec_line": sub[f"{key}/rec_line"], "rec_cont": sub[f"{key}/rec_cont"], "n": sub[f"{key}/n"]}
343
+ (out_dir / "multi_mask.json").write_text(json.dumps(multi_mask, indent=2), encoding="utf-8")
344
+
345
+ # ===== Test 4: line-region vs continuum-region rec — already produced by every eval =====
346
+ # We pull out the mask=0.50 case as the headline.
347
+ print("=== TEST 4: line vs continuum rec at mask=0.50 ===")
348
+ cfg = copy.deepcopy(base_cfg)
349
+ cfg.eval_mask_ratio = 0.50
350
+ cfg.mask_mode = "line_span"
351
+ line_res = run_eval(model, samples, cfg, device, batch_size=args.batch_size, num_workers=args.num_workers, collator_seed=31415, max_samples=min(1500, len(samples)))
352
+ line_metrics = summarize("heldout_linevscont50", line_res)
353
+ all_metrics.update(line_metrics)
354
+ np.savez_compressed(out_dir / "linevscont50.npz", **{k: v for k, v in line_res.items() if k != "object_id"})
355
+
356
+ # ===== Test 3: stress curve =====
357
+ if not args.skip_stress:
358
+ print("=== TEST 3: stress curve ===")
359
+ stress_results: dict[str, Any] = {}
360
+ sweeps = {
361
+ "wavelength_crop": [0.0, 0.20, 0.35, 0.50, 0.65],
362
+ "noise": [0.0, 2.0, 5.0, 10.0],
363
+ "throughput": [0.0, 1.0, 2.0, 4.0],
364
+ "resolution": [0.0, 1.5, 3.0, 6.0],
365
+ }
366
+ stress_cfg = copy.deepcopy(base_cfg)
367
+ stress_cfg.eval_mask_ratio = 0.25
368
+ stress_samples = samples[: min(600, len(samples))]
369
+ for mode, strengths in sweeps.items():
370
+ mode_metrics: list[dict[str, Any]] = []
371
+ for st in strengths:
372
+ pmode = "none" if st == 0 else mode
373
+ res = run_eval(model, stress_samples, stress_cfg, device, perturb_mode=pmode, perturb_strength=float(st), batch_size=args.batch_size, num_workers=args.num_workers, collator_seed=31415)
374
+ sub = summarize(f"stress_{mode}_s{st:.2f}", res)
375
+ mode_metrics.append({"strength": float(st), **sub})
376
+ all_metrics.update(sub)
377
+ stress_results[mode] = mode_metrics
378
+ print(f"STRESS_DONE {mode}")
379
+ (out_dir / "stress_curve.json").write_text(json.dumps(stress_results, indent=2), encoding="utf-8")
380
+
381
+ # Plot stress curves
382
+ fig, axes = plt.subplots(2, 2, figsize=(12, 8))
383
+ for ax, (mode, results) in zip(axes.flat, stress_results.items()):
384
+ xs = [r["strength"] for r in results]
385
+ mae_z = [r[f"stress_{mode}_s{r['strength']:.2f}/mae_z"] for r in results]
386
+ cat = [r[f"stress_{mode}_s{r['strength']:.2f}/cat_0p01"] for r in results]
387
+ ax.plot(xs, mae_z, "o-", label="MAE(z)")
388
+ ax2 = ax.twinx()
389
+ ax2.plot(xs, cat, "s--", color="tab:red", label="Cat>0.01")
390
+ ax.set_title(f"stress: {mode}")
391
+ ax.set_xlabel("strength")
392
+ ax.set_ylabel("MAE(z)")
393
+ ax2.set_ylabel("Cat>0.01")
394
+ ax.grid(alpha=0.2)
395
+ fig.tight_layout()
396
+ fig.savefig(out_dir / "stress_curve.png", dpi=150)
397
+ plt.close(fig)
398
+
399
+ # Save final summary
400
+ (out_dir / "summary.json").write_text(json.dumps(all_metrics, indent=2, sort_keys=True), encoding="utf-8")
401
+ print(f"WROTE {out_dir/'summary.json'}")
402
+ headline_keys = [
403
+ "heldout_base/mae_z", "heldout_base/nmad", "heldout_base/cat_0p01", "heldout_base/rec", "heldout_base/n",
404
+ "heldout_tta/mae_z", "heldout_tta/nmad", "heldout_tta/cat_0p01",
405
+ "heldout_clean/mae_z", "heldout_clean/cat_0p01", "heldout_clean/n",
406
+ "heldout_linevscont50/rec_line", "heldout_linevscont50/rec_cont", "heldout_linevscont50/rec",
407
+ ]
408
+ print("HEADLINE")
409
+ for k in headline_keys:
410
+ if k in all_metrics:
411
+ print(f" {k}: {all_metrics[k]:.5f}")
412
+
413
+
414
+ if __name__ == "__main__":
415
+ main()