"""ImageBind-style binding of IF / nuclei / markers to a frozen H&E (ImageBind-Huge vision) anchor. loss = sum_m InfoNCE_sym(he, m) (--loss anchor, ImageBind default; IF<->nuclei etc. are *emergent*) + sum_{m sim[np.arange(n), np.arange(n)][:, None]).sum(1) # 0 = correct is top-1 r, c = linear_sum_assignment(-sim) return {"R@1": float((rank < 1).mean()), "R@5": float((rank < 5).mean()), "R@10": float((rank < 10).mean()), "MRR": float((1 / (rank + 1)).mean()), "MedR": float(np.median(rank + 1)), "pair_acc": float((c[np.argsort(r)] == np.arange(n)).mean()), "N": n, "chance_R@1": 1 / n} def fixed_gallery(zq, zg, size=100, reps=50): """R@1 / pairing accuracy averaged over random galleries of a fixed size (comparable across splits).""" rng = np.random.default_rng(0) n = len(zq) if n <= size: m = retrieval_metrics(zq, zg) return {"R@1_g100": m["R@1"], "pair_acc_g100": m["pair_acc"]} r1, pa = [], [] for _ in range(reps): s = torch.from_numpy(rng.choice(n, size, replace=False)) m = retrieval_metrics(zq[s], zg[s]) r1.append(m["R@1"]); pa.append(m["pair_acc"]) return {"R@1_g100": float(np.mean(r1)), "pair_acc_g100": float(np.mean(pa))} def evaluate(idx, fixed=False): z = embed_all(idx) res = {} for a, b in itertools.permutations(MODALITIES, 2): res[f"{a}->{b}"] = retrieval_metrics(z[a], z[b]) if fixed: res[f"{a}->{b}"].update(fixed_gallery(z[a], z[b])) return res, z def mean_r1(res): return float(np.mean([v["R@1"] for v in res.values()])) # ---------------- train ---------------- best, hist = -1, [] t0 = time.time() for ep in range(args.epochs): model.train() perm = np.random.permutation(tr_idx) tl = [] for s in range(0, len(perm) - args.bs + 1, args.bs): idx = perm[s:s + args.bs] b = batch(idx, d=torch.randint(0, 8, (len(idx),)), train=True) with torch.autocast("cuda", dtype=torch.bfloat16): loss, logs = model.loss(model.embed(b)) opt.zero_grad(set_to_none=True) loss.backward() opt.step(); sched.step() tl.append(loss.item()) vres, _ = evaluate(va_idx) v = mean_r1(vres) hist.append({"epoch": ep, "train_loss": float(np.mean(tl)), "val_meanR1": v, **{k: x for k, x in logs.items()}}) print(f"ep {ep} loss {np.mean(tl):.3f} val meanR@1 {v:.3f} ({time.time() - t0:.0f}s)", flush=True) if v > best: best = v torch.save(model.state_dict(), os.path.join(run_dir, "best.pt")) # ---------------- test ---------------- model.load_state_dict(torch.load(os.path.join(run_dir, "best.pt"))) tres, z = evaluate(te_idx, fixed=True) stats = pd.read_csv(os.path.join(os.path.dirname(RUNS), "results", "tile_stats.csv")).set_index("tile_id") tissue = stats.loc[te_idx, "n_nuclei"].values >= args.min_nuclei_tissue tres_tissue = {} for a, b in itertools.permutations(MODALITIES, 2): tres_tissue[f"{a}->{b}"] = retrieval_metrics(z[a][tissue], z[b][tissue]) tres_tissue[f"{a}->{b}"].update(fixed_gallery(z[a][tissue], z[b][tissue])) out = {"args": vars(args), "n_train": len(tr_idx), "n_val": len(va_idx), "n_test": len(te_idx), "n_test_tissue": int(tissue.sum()), "best_val_meanR1": best, "test": tres, "test_tissue": tres_tissue, "history": hist} json.dump(out, open(os.path.join(run_dir, "metrics.json"), "w"), indent=1) torch.save({m: v for m, v in z.items()}, os.path.join(run_dir, "test_embeddings.pt")) print("TEST mean R@1 (all pairs):", mean_r1(tres), "| tissue-only:", mean_r1(tres_tissue)) for k in ["he->if", "he->nuclei", "he->markers", "if->nuclei", "if->markers", "nuclei->markers"]: print(f" {k:18s} R@1 {tres[k]['R@1']:.3f} R@5 {tres[k]['R@5']:.3f} pair_acc {tres[k]['pair_acc']:.3f} R@1_g100 {tres[k]['R@1_g100']:.3f}")