"""Test 8: how far must λ_L1 be reduced before DAB and UNI become meaningful? Same setup as test 7 (saved Adam state from C7 best_uni_step6500.pt, 4 batches of 8 tiles). Sweep λ_L1 in {25, 15, 10, 5}. For each: * Compute influence P_L1, P_UNI, P_DAB, P_HF via leave-one-out counterfactual (using saved Adam moments) * Total update norm ||Δθ_all||_2 * cos(Δθ_all(λ), Δθ_all(25)) — how much the update rotates as we tame L1 * head_conv per-block breakdown Other lambdas unchanged: λ_UNI=0.25, λ_DAB=1.0, λ_HF=0.01. """ import os, sys, glob, random, math, json, copy, time sys.path.insert(0, "/home/ubuntu/work") from pathlib import Path import numpy as np import torch import torch.nn as nn import torch.nn.functional as F from PIL import Image from src_ds_her2_thunder import UniDwtC7, UNI2HSpatialLoss, StainDeconvDAB from pytorch_wavelets import DWTForward DEVICE = "cuda" DATA = "/home/ubuntu/her2_data" CKPT = "/home/ubuntu/ckpts/c7_best.pt" BATCH = 8 K_BATCHES = 4 SEED = 42 LR = 2e-4 LAMBDAS_L1 = [25, 15, 10, 5] LOSSES = ["L1", "UNI", "DAB", "HF"] print("Loading UNI-2H + C7 + saved optimizer state ...", flush=True) uni_loss = UNI2HSpatialLoss(target_size=224).to(DEVICE).eval() for p in uni_loss.uni.parameters(): p.requires_grad_(False) G = UniDwtC7( uni_module=uni_loss.uni, wavelet="haar", taps=(13, 19), dec_ch=(512, 384, 256, 128, 64), skip_ch=(384, 256, 128), blocks_per_stage=(4, 3, 3, 2, 2), uni_target_size=224, ).to(DEVICE).train() ck = torch.load(CKPT, map_location=DEVICE, weights_only=False) G.load_state_dict(ck["model"], strict=False) for n, p in G.named_parameters(): p.requires_grad_(not n.startswith("uni.")) saved_opt_state = ck["optimizer"] print(f" model @ step={ck['step']} optimizer states={len(saved_opt_state['state'])}", flush=True) stain_dab = StainDeconvDAB().to(DEVICE).eval() dwt = DWTForward(J=2, wave="haar", mode="periodization").to(DEVICE) BLOCK_TARGETS = [ "dwt_skips.enc_sk4", "dwt_skips.enc_sk3", "dwt_skips.enc_sk2", "decoder.stem", "decoder.stage1", "decoder.up1", "decoder.fuse2", "decoder.stage2", "decoder.stage3", "decoder.stage4", "decoder.stage5", "decoder.head_conv", ] named = dict(G.named_modules()) def _block_of(pname): for tgt in BLOCK_TARGETS: if pname.startswith(tgt + "."): return tgt return None trainable = [(n, p) for n, p in G.named_parameters() if p.requires_grad] def compute_loss_terms(gens, ihcs, lam_L1=25.0): l1 = F.l1_loss(gens, ihcs) * lam_L1 unic = uni_loss(gens, target=ihcs) * 0.25 dab = F.l1_loss(stain_dab(gens), stain_dab(ihcs)) * 1.0 ylg, yhg = dwt((gens + 1) * 0.5) ylt, yht = dwt((ihcs + 1) * 0.5) hf = F.l1_loss(yhg[0], yht[0]) * 0.01 + F.l1_loss(yhg[1], yht[1]) * 0.01 return dict(L1=l1, UNI=unic, DAB=dab, HF=hf) def _load_pair(p): he = Image.open(p.replace("/IHC/", "/HE/")).convert("RGB").resize((256, 256), Image.BICUBIC) ic = Image.open(p).convert("RGB").resize((256, 256), Image.BICUBIC) return (torch.from_numpy(np.asarray(he, dtype=np.float32) / 255.).permute(2, 0, 1) * 2 - 1, torch.from_numpy(np.asarray(ic, dtype=np.float32) / 255.).permute(2, 0, 1) * 2 - 1) def _bf(pth): im = Image.open(pth).convert("RGB").resize((256, 256), Image.BICUBIC) x = torch.from_numpy(np.asarray(im, dtype=np.float32) / 255.).permute(2, 0, 1).unsqueeze(0) * 2 - 1 with torch.no_grad(): return float((stain_dab(x.to(DEVICE)) > 0.3).float().mean().item()) def _slide(p): return Path(p).name.split("_")[0] random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED) ihc = sorted(glob.glob(f"{DATA}/IHC/val/*.jpg")) pool = random.sample(ihc, min(len(ihc), 1500)) scan = [(p, _bf(p)) for p in pool] by_p, by_n = {}, {} for p, s in scan: sid = _slide(p) if s > 0.1: by_p.setdefault(sid, []).append(p) elif s < 0.01: by_n.setdefault(sid, []).append(p) def _bal(bs, N): out = []; slides = list(bs.keys()); random.shuffle(slides) while len(out) < N and slides: for s in list(slides): if not bs[s]: slides.remove(s); continue out.append(bs[s].pop()) if len(out) >= N: break return out her2p = _bal(by_p, 64); her2n = _bal(by_n, 64) picks = her2p + her2n random.shuffle(picks) picks = picks[:K_BATCHES * BATCH] print(f"tiles: {len(picks)}", flush=True) def _snapshot_model(): return {n: p.detach().clone() for n, p in G.named_parameters() if p.requires_grad} def _restore_model(snap): with torch.no_grad(): for n, p in G.named_parameters(): if n in snap: p.data.copy_(snap[n]) def _mk_opt_with_saved_state(): opt = torch.optim.AdamW([p for _, p in trainable], lr=LR) sd = copy.deepcopy(saved_opt_state) try: opt.load_state_dict(sd) except Exception: saved_ids = sd["param_groups"][0]["params"] new_ids = opt.state_dict()["param_groups"][0]["params"] new_sd = {"param_groups": opt.state_dict()["param_groups"], "state": {}} for old_id, new_id in zip(saved_ids, new_ids): if old_id in sd["state"]: new_sd["state"][new_id] = sd["state"][old_id] opt.load_state_dict(new_sd) return opt def _flatten_delta(pre, post): return {n: (post[n] - pre[n]).flatten() for n in pre} # ============================================================================= # Sweep # ============================================================================= # For cosine-vs-original, we need delta_all(λ=25) as reference for each batch. per_lambda_results = {lam: {"per_batch": []} for lam in LAMBDAS_L1} ref_deltas_batch = [] # delta_all at λ_L1=25, one per batch t0 = time.time() for bi in range(K_BATCHES): print(f"\n--- batch {bi+1}/{K_BATCHES} ---", flush=True) chunk = picks[bi * BATCH:(bi + 1) * BATCH] pairs = [_load_pair(p) for p in chunk] hes = torch.stack([p[0] for p in pairs]).to(DEVICE) ihcs = torch.stack([p[1] for p in pairs]).to(DEVICE) pre_params = _snapshot_model() for lam_L1 in LAMBDAS_L1: # delta_all with this lambda opt = _mk_opt_with_saved_state() G.zero_grad(set_to_none=True) gens = G(hes) terms = compute_loss_terms(gens, ihcs, lam_L1=lam_L1) total = sum(terms.values()) total.backward() opt.step() delta_all = _flatten_delta(pre_params, {n: p.detach().clone() for n, p in G.named_parameters() if p.requires_grad}) _restore_model(pre_params) # Leave-one-out per loss per_loss_delta = {} for skip in LOSSES: _restore_model(pre_params) opt = _mk_opt_with_saved_state() G.zero_grad(set_to_none=True) gens = G(hes) terms = compute_loss_terms(gens, ihcs, lam_L1=lam_L1) total_minus = sum(v for k, v in terms.items() if k != skip) total_minus.backward() opt.step() per_loss_delta[skip] = _flatten_delta(pre_params, {n: p.detach().clone() for n, p in G.named_parameters() if p.requires_grad}) _restore_model(pre_params) # Influence per loss + per block overall_inf = {loss: 0.0 for loss in LOSSES} per_block_inf = {loss: {tgt: 0.0 for tgt in BLOCK_TARGETS} for loss in LOSSES} with torch.no_grad(): for pname, _ in trainable: d_all = delta_all[pname] for loss in LOSSES: diff = (d_all - per_loss_delta[loss][pname]).norm().pow(2).item() overall_inf[loss] += diff blk = _block_of(pname) if blk is not None: per_block_inf[loss][blk] += diff for loss in LOSSES: overall_inf[loss] = math.sqrt(overall_inf[loss]) for blk in BLOCK_TARGETS: per_block_inf[loss][blk] = math.sqrt(per_block_inf[loss][blk]) # Total update norm ||Δθ_all||_2 with torch.no_grad(): update_norm_sq = 0.0 flat_all_pieces = [] for pname, _ in trainable: v = delta_all[pname] update_norm_sq += float(v.pow(2).sum().item()) flat_all_pieces.append(v) update_norm = math.sqrt(update_norm_sq) flat_all = torch.cat(flat_all_pieces) # Save reference at λ=25 for this batch if lam_L1 == 25: ref_deltas_batch.append(flat_all.clone()) cos_vs_ref = 1.0 else: ref = ref_deltas_batch[bi] cos_vs_ref = float((flat_all @ ref) / (flat_all.norm() * ref.norm() + 1e-12)) tot_i = sum(overall_inf.values()) + 1e-30 entry = dict( overall=overall_inf, per_block=per_block_inf, update_norm=update_norm, cos_vs_ref_lambda25=cos_vs_ref, overall_pct={loss: overall_inf[loss] / tot_i * 100 for loss in LOSSES}, ) per_lambda_results[lam_L1]["per_batch"].append(entry) print(f" λ_L1={lam_L1:>3} P(L1)={entry['overall_pct']['L1']:>5.1f}% " f"P(UNI)={entry['overall_pct']['UNI']:>4.1f}% " f"P(DAB)={entry['overall_pct']['DAB']:>4.1f}% " f"P(HF)={entry['overall_pct']['HF']:>4.1f}% " f"||Δθ||={update_norm:.3f} cos={cos_vs_ref:.4f}", flush=True) # ============================================================================= # Aggregate + report # ============================================================================= print("\n" + "=" * 80) print(f"{'λ_L1':>5} {'P(L1)':>7} {'P(UNI)':>7} {'P(DAB)':>7} {'P(HF)':>6}" f" {'||Δθ||':>10} {'cos vs λ25':>10}") print("-" * 80) final = {} for lam in LAMBDAS_L1: batches = per_lambda_results[lam]["per_batch"] avg_pct = {loss: float(np.mean([b["overall_pct"][loss] for b in batches])) for loss in LOSSES} avg_norm = float(np.mean([b["update_norm"] for b in batches])) avg_cos = float(np.mean([b["cos_vs_ref_lambda25"] for b in batches])) per_block_pct_avg = {} for tgt in BLOCK_TARGETS: row_avg = {} for loss in LOSSES: row_avg[loss] = float(np.mean([b["per_block"][loss][tgt] for b in batches])) row_tot = sum(row_avg.values()) + 1e-30 per_block_pct_avg[tgt] = {loss: row_avg[loss] / row_tot * 100 for loss in LOSSES} final[lam] = dict(overall_pct=avg_pct, update_norm=avg_norm, cos_vs_ref_lambda25=avg_cos, per_block_pct=per_block_pct_avg) print(f"{lam:>5} {avg_pct['L1']:>6.2f}% {avg_pct['UNI']:>6.2f}% {avg_pct['DAB']:>6.2f}% " f" {avg_pct['HF']:>5.2f}% {avg_norm:>10.4f} {avg_cos:>10.4f}") print("\nHead_conv per-loss share by lambda:") print(f"{'λ_L1':>5} {'P(L1)':>7} {'P(UNI)':>7} {'P(DAB)':>7} {'P(HF)':>6}") for lam in LAMBDAS_L1: row = final[lam]["per_block_pct"]["decoder.head_conv"] print(f"{lam:>5} {row['L1']:>6.2f}% {row['UNI']:>6.2f}% {row['DAB']:>6.2f}% {row['HF']:>5.2f}%") outp = Path("/home/ubuntu/work/test8_lambda_L1_sweep.json") outp.write_text(json.dumps({"config": {"lambdas": LAMBDAS_L1, "K_batches": K_BATCHES, "batch": BATCH, "lr": LR}, "results": final}, indent=2)) print(f"\nSaved: {outp}") print(f"Wall: {time.time() - t0:.0f}s")