File size: 8,225 Bytes
e8fa066
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
"""Text <-> H&E image pairing benchmark on ALL real image-text pairs (unified pool from 19_pairsets_prep.py:
6144 TCGA patches, 6480 pairs; text types caption / summary / qa_open / qa_close).

Protocol: patient-level 5-fold CV over the pool (inside each training fold 10% of patients are held out for early
stopping). Training uses every (image, text) pair of the training patients; an image with several texts counts all of
them as positives (multi-positive InfoNCE). Every image encoder x text encoder combination in three modes:
  zeroshot  : the CLIP model's own image and text towers, no training (only when image enc == text enc)
  text_head : frozen image features = anchor; trainable MLP head on the frozen text features (ImageBind recipe)
  two_heads : trainable MLP heads on both frozen towers into a shared 512-d space (CLIP-style projection)
Evaluation on the held-out fold, per text type and for "all" (one text per image): tie-aware R@1/5/10, MRR,
Hungarian one-to-one pairing accuracy over the whole gallery, R@1 on random 100-pair galleries; both directions.
Caveat: PathGen-CLIP-L was trained on PathGen-1.6M (most of this pool) -> its numbers are not held-out.
Outputs results/pairing_long.csv, results/pairing_summary.csv
"""
import argparse
import os

import numpy as np
import pandas as pd
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.model_selection import GroupKFold

from common import CACHE, ROOT, metrics

p = argparse.ArgumentParser()
p.add_argument("--img", nargs="+", default=["uni2", "phikonv2", "plip", "biomedclip", "imagebind", "pathgenclip"])
p.add_argument("--txt", nargs="+", default=["plip", "biomedclip", "imagebind", "pathgenclip"])
p.add_argument("--epochs", type=int, default=60)
p.add_argument("--bs", type=int, default=256)
p.add_argument("--lr", type=float, default=3e-4)
p.add_argument("--tag", default="")
args = p.parse_args()
dev = "cuda"
OUT = os.path.join(ROOT, "results")
I = pd.read_csv(os.path.join(CACHE, "allimg_meta.csv"))
T = pd.read_csv(os.path.join(CACHE, "alltxt_meta.csv"))
TTYPES = ["caption", "summary", "qa_open", "qa_close"]


def load(kind, enc):
    f = os.path.join(CACHE, f"all{kind}_{enc}.npy")
    return torch.from_numpy(np.load(f).astype(np.float32)).to(dev) if os.path.exists(f) else None


IMG = {e: load("img_he", e) for e in args.img}                       # (n_img, 8, D)
TXT = {e: (lambda a: a[:, 0] if a is not None else None)(load("txt_text", e)) for e in args.txt}  # (n_txt, D)
t_img = torch.as_tensor(T.img_idx.values, device=dev)


def mlp(din, dout, hid=512):  # same head as 12_train_text_pathgen.py / 09_bench.py
    return nn.Sequential(nn.Linear(din, hid), nn.GELU(), nn.Dropout(0.1), nn.Linear(hid, hid), nn.GELU(),
                         nn.LayerNorm(hid), nn.Linear(hid, dout))


def r1(a, b):
    s = a @ b.T
    return float(((s > s.diag()[:, None]).sum(1) == 0).float().mean())


def eval_pairs(t_ids, rng):
    """one text per image (random) -> aligned (img_ids, txt_ids) without duplicate images"""
    sub = T.loc[t_ids]
    pick = sub.groupby("img_idx").apply(lambda g: g.index[rng.integers(len(g))], include_groups=False).values
    return T.img_idx.values[pick], pick


def train(ie, te, mode, tr_t, va_t, seed=0):
    torch.manual_seed(seed); rng = np.random.default_rng(seed)
    X, Tx = IMG[ie], TXT[te]
    th = mlp(Tx.shape[1], 512 if mode == "two_heads" else X.shape[-1]).to(dev)
    ih = mlp(X.shape[-1], 512).to(dev) if mode == "two_heads" else nn.Identity()
    logit = nn.Parameter(torch.tensor(np.log(1 / 0.07), dtype=torch.float32, device=dev))
    opt = torch.optim.AdamW(list(th.parameters()) + list(ih.parameters()) + [logit], lr=args.lr, weight_decay=0.05)
    steps = args.epochs * max(1, len(tr_t) // args.bs)
    sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=args.lr, total_steps=steps, pct_start=0.05)
    va_i, va_tt = eval_pairs(va_t, np.random.default_rng(0))

    def enc_img(ids, view=None):
        return F.normalize(ih(X[ids, 0 if view is None else view]), dim=-1)

    def enc_txt(ids):
        return F.normalize(th(Tx[ids]), dim=-1)

    best, state = -1, None
    for ep in range(args.epochs):
        th.train(); ih.train()
        perm = rng.permutation(tr_t)
        for s in range(0, len(perm) - args.bs + 1, args.bs):
            b = torch.as_tensor(perm[s:s + args.bs], device=dev)
            ii = t_img[b]
            zi = enc_img(ii, torch.randint(0, 8, (len(b),), device=dev))
            zt = enc_txt(b)
            lg = logit.exp().clamp(max=100) * zi @ zt.T
            pos = (ii[:, None] == ii[None, :]).float()           # same image -> positive (multi-positive InfoNCE)
            tgt = pos / pos.sum(1, keepdim=True)
            loss = (torch.sum(-tgt * F.log_softmax(lg, 1), 1).mean() + torch.sum(-tgt * F.log_softmax(lg.T, 1), 1).mean()) / 2
            opt.zero_grad(set_to_none=True); loss.backward(); opt.step(); sched.step()
        th.eval(); ih.eval()
        with torch.no_grad():
            zi, zt = enc_img(torch.as_tensor(va_i, device=dev)), enc_txt(torch.as_tensor(va_tt, device=dev))
            v = (r1(zi, zt) + r1(zt, zi)) / 2
        if v > best:
            best = v
            state = ({k: x.clone() for k, x in th.state_dict().items()}, {k: x.clone() for k, x in ih.state_dict().items()})
    th.load_state_dict(state[0]); ih.load_state_dict(state[1]); th.eval(); ih.eval()
    return (lambda i_ids, t_ids: (enc_img(torch.as_tensor(i_ids, device=dev)).cpu(), enc_txt(torch.as_tensor(t_ids, device=dev)).cpu())), best


folds = list(GroupKFold(5).split(I, groups=I.patient))
rows = []
for k, (trv_img, te_img) in enumerate(folds):
    pats = np.random.default_rng(k).permutation(sorted(I.patient.iloc[trv_img].unique()))
    va_p = set(pats[: max(1, len(pats) // 10)])
    img_split = pd.Series("test", index=I.index)
    img_split.iloc[trv_img] = np.where(I.patient.iloc[trv_img].isin(va_p), "val", "train")
    tsplit = img_split.loc[T.img_idx].values
    tr_t, va_t, te_t = (T.index[tsplit == s].values for s in ["train", "val", "test"])
    # evaluation sets: per text type and 'all', one text per image
    ev = {"all": eval_pairs(te_t, np.random.default_rng(k))}
    for tt in TTYPES:
        ids = te_t[T.ttype.values[te_t] == tt]
        if len(ids) >= 20:
            ev[tt] = eval_pairs(ids, np.random.default_rng(k))
    for ie in args.img:
        for te in args.txt:
            if IMG[ie] is None or TXT[te] is None:
                continue
            for mode in (["zeroshot"] if ie == te else []) + ["text_head", "two_heads"]:
                if mode == "zeroshot":
                    f = lambda i_ids, t_ids: (F.normalize(IMG[ie][torch.as_tensor(i_ids, device=dev), 0], dim=-1).cpu(),
                                              F.normalize(TXT[te][torch.as_tensor(t_ids, device=dev)], dim=-1).cpu())
                    val = np.nan
                else:
                    f, val = train(ie, te, mode, tr_t, va_t)
                for name, (i_ids, t_ids) in ev.items():
                    with torch.no_grad():
                        zi, zt = f(i_ids, t_ids)
                    for q, g, d in [(zi, zt, "img->text"), (zt, zi, "text->img")]:
                        rows.append({"eval": name, "fold": k, "img_enc": ie, "txt_enc": te, "mode": mode, "dir": d,
                                     "val_r1": val, **metrics(q, g)})
            print(f"fold {k} {ie:11s} {te:11s} done (n_train_pairs={len(tr_t)}, n_test_img={len(ev['all'][0])})", flush=True)
    pd.DataFrame(rows).to_csv(os.path.join(OUT, f"pairing_long{args.tag}.csv"), index=False)

R = pd.DataFrame(rows)
cols = ["R@1", "R@5", "R@10", "pair_acc", "R@1_g100"]
S = R.groupby(["eval", "mode", "img_enc", "txt_enc", "fold"])[cols + ["N"]].mean().groupby(["eval", "mode", "img_enc", "txt_enc"]).agg(["mean", "std"])
S.round(4).to_csv(os.path.join(OUT, f"pairing_summary{args.tag}.csv"))
pd.set_option("display.width", 250); pd.set_option("display.max_rows", 500)
for e in ["all"] + TTYPES:
    print(f"\n=== eval: {e} (mean over both directions and 5 folds) ===")
    print(R[R["eval"] == e].groupby(["mode", "img_enc", "txt_enc"])[cols + ["N"]].mean().round(3).sort_values("R@1_g100", ascending=False).to_string())