File size: 12,112 Bytes
1cf3825
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
"""Foundation-model linear-probe AUC on the 148-sample OOD bench.



Phase 2 of the v9c plan. Compares candidate medical-imaging foundation

models by extracting their off-the-shelf embeddings on our 148 OOD

samples, then training a linear logistic-regression probe to discriminate

tumor vs healthy. The winner becomes the frozen backbone for the

normative-JEPA head in Phase 3.



Candidates evaluated:

  - BiomedCLIP (microsoft/BiomedCLIP-PubMedBERT_256-vit_base_patch16_224)

    Trained on 15M biomedical image-text pairs from PubMed. Specifically

    biomedical, includes some MRI in its training set.



  - RAD-DINO (microsoft/rad-dino)

    Trained on ~838k chest X-rays with DINOv2 SSL. Domain is X-ray not

    MRI, included as a sanity-check baseline — should be WORSE than

    BiomedCLIP if our hypothesis holds.



  - DINOv2 (facebook/dinov2-base)

    Generic natural-image SSL backbone. Lowest-prior baseline; if it

    beats the medical models, that means our task isn't actually

    domain-specific enough to need a medical foundation model.



Metric: AUC and 5-fold stratified CV accuracy of logistic regression

on the pooled embeddings (label = tumor vs healthy).



Outputs samples/ood/foundation_probe_results.csv.

"""
from __future__ import annotations

import csv
import sys
import time
from pathlib import Path

import numpy as np
import torch
from PIL import Image

ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))

from scripts.eval_ood_cascade import GT as _GT
# Ensure the IXI2D healthy cohort added during Phase 1 is included, and the
# Navoneel healthy cohort added during Phase 2 (so Navoneel is no longer
# source-monolithic and LOSO AUC becomes computable).
GT = dict(_GT)
GT.setdefault('healthy_ixi2d', 'no_tumor')
GT.setdefault('healthy_navoneel', 'no_tumor')

# Folder name -> logical source group. Used for leave-one-source-out CV
# so the probe can't cheat by recognising scanner / preprocessing
# signatures. Folders not listed here use their own name as the group.
SOURCE_GROUPS = {
    'tumor_binary_navoneel_via_miladfa7': 'navoneel',
    'healthy_navoneel': 'navoneel',
    # all other folders map to themselves (one folder = one source)
}


def _source_group(folder_name: str) -> str:
    return SOURCE_GROUPS.get(folder_name, folder_name)


SAMPLES_DIR = ROOT / 'samples' / 'ood'

# Define candidate models. Each entry tells us how to load + run inference.
# We use HuggingFace transformers for the BiomedCLIP / RAD-DINO / DINOv2.
CANDIDATES = [
    {
        'name': 'biomedclip',
        'hf_id': 'microsoft/BiomedCLIP-PubMedBERT_256-vit_base_patch16_224',
        'loader': 'open_clip',   # BiomedCLIP ships via open_clip, not standard transformers
        'image_size': 224,
    },
    {
        'name': 'rad-dino',
        'hf_id': 'microsoft/rad-dino',
        'loader': 'transformers_dino',
        'image_size': 518,   # ViT-B/14 default
    },
    {
        'name': 'dinov2-base',
        'hf_id': 'facebook/dinov2-base',
        'loader': 'transformers_dino',
        'image_size': 224,
    },
]


def _load_open_clip(hf_id: str, device: str):
    try:
        import open_clip
    except ImportError:
        return None, 'pip install open_clip_torch'
    model, _, preprocess = open_clip.create_model_and_transforms(
        f'hf-hub:{hf_id}')
    model = model.to(device).eval()
    return (model, preprocess), None


def _load_transformers_dino(hf_id: str, device: str):
    try:
        from transformers import AutoModel, AutoImageProcessor
    except ImportError:
        return None, 'pip install transformers'
    try:
        proc = AutoImageProcessor.from_pretrained(hf_id)
        model = AutoModel.from_pretrained(hf_id).to(device).eval()
    except Exception as exc:
        return None, f'load failed: {type(exc).__name__}: {exc}'
    return (model, proc), None


@torch.no_grad()
def _embed_open_clip(loader_state, img_pil, device):
    model, preprocess = loader_state
    x = preprocess(img_pil).unsqueeze(0).to(device)
    feat = model.encode_image(x)
    return feat.squeeze(0).cpu().numpy()


@torch.no_grad()
def _embed_dino(loader_state, img_pil, device):
    model, proc = loader_state
    x = proc(images=img_pil, return_tensors='pt').pixel_values.to(device)
    out = model(pixel_values=x, output_hidden_states=False)
    # Use the CLS token (first patch of last hidden state) as embedding
    feat = out.last_hidden_state[:, 0, :]
    return feat.squeeze(0).cpu().numpy()


def _stratified_auc_probe(X: np.ndarray, y: np.ndarray, n_splits: int = 5) -> dict:
    """Logistic regression with stratified k-fold CV; report AUC + accuracy."""
    from sklearn.linear_model import LogisticRegression
    from sklearn.model_selection import StratifiedKFold
    from sklearn.metrics import roc_auc_score, accuracy_score

    if len(np.unique(y)) < 2:
        return {'auc_mean': float('nan'), 'auc_std': float('nan'),
                 'acc_mean': float('nan')}
    skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=42)
    aucs, accs = [], []
    for train_idx, test_idx in skf.split(X, y):
        Xtr, ytr = X[train_idx], y[train_idx]
        Xte, yte = X[test_idx], y[test_idx]
        clf = LogisticRegression(max_iter=2000, C=1.0).fit(Xtr, ytr)
        prob = clf.predict_proba(Xte)[:, 1]
        aucs.append(roc_auc_score(yte, prob))
        accs.append(accuracy_score(yte, clf.predict(Xte)))
    return {
        'auc_mean': float(np.mean(aucs)), 'auc_std': float(np.std(aucs)),
        'acc_mean': float(np.mean(accs)),
    }


def _leave_source_out_probe(X: np.ndarray, y: np.ndarray,

                              sources: list[str]) -> dict:
    """Leave-one-source-out CV. Holds out ENTIRE source per fold so the

    probe cannot learn 'which dataset this image is from' instead of

    'does this image contain a tumor'.



    If stratified-K-fold AUC is ~1.0 but LOSO AUC is ~0.5, the high

    stratified-K-fold AUC was a source-confound artifact.

    """
    from sklearn.linear_model import LogisticRegression
    from sklearn.metrics import roc_auc_score
    src_arr = np.array(sources)
    unique_sources = sorted(set(sources))
    if len(unique_sources) < 2:
        return {'loso_auc_mean': float('nan'), 'per_source': {}}
    aucs = {}
    for held_out in unique_sources:
        test_idx = np.where(src_arr == held_out)[0]
        train_idx = np.where(src_arr != held_out)[0]
        Xtr, ytr = X[train_idx], y[train_idx]
        Xte, yte = X[test_idx], y[test_idx]
        # If the held-out source is monolithic (all same label) we can't
        # compute AUC on it — record N/A and skip
        if len(np.unique(yte)) < 2 or len(np.unique(ytr)) < 2:
            aucs[held_out] = float('nan')
            continue
        clf = LogisticRegression(max_iter=2000, C=1.0).fit(Xtr, ytr)
        prob = clf.predict_proba(Xte)[:, 1]
        aucs[held_out] = float(roc_auc_score(yte, prob))
    valid = [v for v in aucs.values() if not np.isnan(v)]
    return {
        'loso_auc_mean': float(np.mean(valid)) if valid else float('nan'),
        'per_source': aucs,
    }


def main():
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    print(f'[init] device={device}')

    samples = sorted(p for p in SAMPLES_DIR.rglob('*')
                      if p.suffix.lower() in ('.png', '.jpg', '.jpeg')
                      and p.parent.name in GT)
    print(f'[init] {len(samples)} OOD samples')
    y = np.array([1 if GT[p.parent.name] == 'tumor' else 0 for p in samples])
    sources = [_source_group(p.parent.name) for p in samples]
    print(f'[init] tumor={int(y.sum())}  healthy={int((1-y).sum())}')
    print(f'[init] unique source groups (for LOSO): {sorted(set(sources))}')
    # Tally per source group for sanity
    from collections import Counter
    for src in sorted(set(sources)):
        idx = [i for i, s in enumerate(sources) if s == src]
        ys = y[idx]
        print(f'   {src:24s}  n={len(idx):3d}  tumor={int(ys.sum())}  healthy={int((1-ys).sum())}')

    results = []
    for cand in CANDIDATES:
        print(f'\n=== {cand["name"]} ({cand["hf_id"]}) ===')
        t0 = time.perf_counter()
        if cand['loader'] == 'open_clip':
            ls, err = _load_open_clip(cand['hf_id'], device)
            embed_fn = _embed_open_clip
        elif cand['loader'] == 'transformers_dino':
            ls, err = _load_transformers_dino(cand['hf_id'], device)
            embed_fn = _embed_dino
        else:
            ls, err = None, f'unknown loader {cand["loader"]!r}'
        if err:
            print(f'  [skip] {err}')
            results.append({'name': cand['name'], 'error': err,
                            'auc_mean': None, 'acc_mean': None})
            continue
        print(f'  loaded in {time.perf_counter()-t0:.1f}s; embedding {len(samples)} samples ...')

        feats: list[np.ndarray] = []
        bad = 0
        te = time.perf_counter()
        for i, p in enumerate(samples):
            try:
                img = Image.open(p).convert('RGB')
                f = embed_fn(ls, img, device)
                feats.append(f.astype(np.float32))
            except Exception as exc:
                bad += 1
                if bad <= 3:
                    print(f'    embed fail on {p.name}: {type(exc).__name__}')
                feats.append(np.zeros(768, dtype=np.float32))   # placeholder
        embed_time = time.perf_counter() - te
        print(f'  embedded {len(feats)} ({bad} fails) in {embed_time:.0f}s '
              f'({embed_time/len(feats)*1000:.0f} ms/sample)')

        # Stack and probe
        X = np.stack(feats, axis=0)
        # Some models have variable feature size; pad/truncate to a fixed
        # consistent dim (use the actual returned dim of this model)
        D = X.shape[1]
        print(f'  feature dim = {D}')
        stats = _stratified_auc_probe(X, y, n_splits=5)
        print(f'  stratified-5-fold AUC = {stats["auc_mean"]:.4f} ± {stats["auc_std"]:.4f}   '
              f'acc = {stats["acc_mean"]:.4f}')
        # CRITICAL: leave-one-source-out probe. If stratified AUC is ~1.0
        # but LOSO AUC collapses, the probe was learning source ID not tumor.
        loso = _leave_source_out_probe(X, y, sources)
        print(f'  leave-source-out  AUC = {loso["loso_auc_mean"]:.4f}  '
              f'(by source: {loso["per_source"]})')
        results.append({
            'name': cand['name'], 'hf_id': cand['hf_id'],
            'feature_dim': D,
            'auc_mean': stats['auc_mean'], 'auc_std': stats['auc_std'],
            'acc_mean': stats['acc_mean'],
            'loso_auc': loso['loso_auc_mean'],
            'embed_time_total_s': round(embed_time, 1),
            'ms_per_sample': round(embed_time / max(len(feats), 1) * 1000, 1),
        })
        # Free GPU mem before next model
        del ls
        if device == 'cuda':
            torch.cuda.empty_cache()

    # ---- baselines for context ----
    print('\n=== for reference (from earlier audits) ===')
    print('  v9b JEPA (from scratch)      AUC = 0.564   [our baseline]')
    print('  symmetry geometry             AUC = 0.653')
    print('  DDPM residual                 AUC ~ 0.706')
    print('  v8 segmentation               (not directly comparable, mask-based)')

    # Persist
    out_csv = SAMPLES_DIR / 'foundation_probe_results.csv'
    if results:
        fields = ['name', 'hf_id', 'feature_dim', 'auc_mean', 'auc_std',
                   'acc_mean', 'embed_time_total_s', 'ms_per_sample', 'error']
        with out_csv.open('w', newline='', encoding='utf-8') as f:
            w = csv.DictWriter(f, fieldnames=fields)
            w.writeheader()
            for r in results:
                w.writerow({k: r.get(k) for k in fields})
        print(f'\n[csv] {out_csv}')


if __name__ == '__main__':
    main()