File size: 7,165 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
"""Ensemble search for 95%+ recall AND <=10% FPR on OOD.



JEPA alone caps at the 94%/58% or 67%/8% endpoints of its Pareto curve.

v8 alone caps at 86%/58%. Neither individually meets the target.



Hypothesis: the false positives and the false negatives of these signals

are largely INDEPENDENT (JEPA's FPs are healthy coronal-T1 OpenNeuro that

v8 also flags; JEPA's recall recovery is on UniData multimodal that v8

misses). So:



  - Logical-OR (any signal -> tumor) maximises recall but adds FPs

  - Logical-AND (all signals must agree) drops FPR but loses recall

  - K-of-N voting can find a sweet spot



This script loads per-image scores from the previous evals and exhaustively

sweeps every combination of {v9b_JEPA, v9b_DDPM, v8} with per-signal

thresholds + voting rules, then reports the operating points that meet

the user's target (recall >= 0.95 AND FPR <= 0.10).

"""
from __future__ import annotations

import csv
import sys
from itertools import product
from pathlib import Path

import numpy as np

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

SAMPLES = ROOT / 'samples' / 'ood'


def load_scores():
    """Merge per-image scores across the v9b full eval + cascade eval."""
    full = list(csv.DictReader((SAMPLES / 'eval_v9b_full_results.csv').open(encoding='utf-8')))
    cascade = list(csv.DictReader((SAMPLES / 'eval_cascade_results.csv').open(encoding='utf-8')))
    # Index cascade by (source, file)
    casc_by_key = {(r['source'], r['file']): r for r in cascade}
    rows = []
    for r in full:
        key = (r['source'], r['file'])
        c = casc_by_key.get(key, {})
        rows.append({
            'source': r['source'], 'file': r['file'], 'gt': r['gt'],
            'jepa_p95':       float(r.get('v9b_app_p95', 0)),
            'ddpm_p95':       float(r.get('v9b_residual_p95', 0)),
            'v8_pmax':        float(c.get('seg_max', 0) or 0),
            'v8_area_020':    int(r.get('v8_area_020', 0) or 0),
        })
    return rows


def evaluate(rows, vote_rule):
    """vote_rule(r) -> True/False (True = tumor verdict)"""
    TP = sum(1 for r in rows if r['gt']=='tumor' and vote_rule(r))
    FN = sum(1 for r in rows if r['gt']=='tumor' and not vote_rule(r))
    FP = sum(1 for r in rows if r['gt']=='no_tumor' and vote_rule(r))
    TN = sum(1 for r in rows if r['gt']=='no_tumor' and not vote_rule(r))
    recall = TP/(TP+FN) if TP+FN else 0
    fpr = FP/(FP+TN) if FP+TN else 0
    acc = (TP+TN)/len(rows)
    f1 = 2*TP/(2*TP+FP+FN) if 2*TP+FP+FN else 0
    return TP, FN, FP, TN, recall, fpr, acc, f1


def main():
    rows = load_scores()
    print(f'[init] {len(rows)} OOD samples loaded with v9b_JEPA + v9b_DDPM + v8 scores')

    # Score ranges per signal
    for k in ('jepa_p95', 'ddpm_p95', 'v8_pmax'):
        vals = sorted(r[k] for r in rows)
        print(f'  {k:12s}  min={vals[0]:.3f}  p25={vals[len(vals)//4]:.3f}  '
              f'median={vals[len(vals)//2]:.3f}  p75={vals[3*len(vals)//4]:.3f}  max={vals[-1]:.3f}')
    print()

    # =================== single-signal baselines ===================
    print('='*82)
    print('SINGLE-SIGNAL BASELINES (sweep threshold for max F1 per signal)')
    print('='*82)
    for k in ('jepa_p95', 'ddpm_p95', 'v8_pmax'):
        cands = sorted(set(round(r[k], 4) for r in rows))
        best = (None, -1)
        for t in cands:
            _, _, _, _, re, fp, acc, f1 = evaluate(rows, lambda r, k=k, t=t: r[k] > t)
            if f1 > best[1]:
                best = (t, f1, re, fp, acc)
        t, f1, re, fp, acc = best
        print(f'  {k:12s}  t={t:.3f}  recall={re:.0%}  FPR={fp:.0%}  acc={acc:.0%}  F1={f1:.2f}')

    # =================== exhaustive ensemble sweep ===================
    # For each signal, pick a threshold candidate. For each {AND, OR, 2of3}
    # rule, evaluate. Record everything meeting target.
    print('\n' + '='*82)
    print('TARGET: recall >= 0.95  AND  FPR <= 0.10')
    print('='*82)

    # Coarse threshold grids (avoid quadratic blowup with fine grids)
    j_grid = sorted(set(round(r['jepa_p95'], 3) for r in rows))
    d_grid = sorted(set(round(r['ddpm_p95'], 3) for r in rows))
    v_grid = [49, 99, 199, 499, 999, 1999, 4999]   # v8 area thresholds (px)

    rules = {
        'AND':   lambda j, d, v: j and d and v,
        'OR':    lambda j, d, v: j or d or v,
        '2of3':  lambda j, d, v: (int(j) + int(d) + int(v)) >= 2,
        'JEPA_AND_v8':       lambda j, d, v: j and v,
        'JEPA_AND_DDPM':     lambda j, d, v: j and d,
        'DDPM_AND_v8':       lambda j, d, v: d and v,
        'JEPA_OR_(DDPM_AND_v8)': lambda j, d, v: j or (d and v),
        '(JEPA_AND_DDPM)_OR_v8': lambda j, d, v: (j and d) or v,
    }

    hits = []
    total = len(j_grid) * len(d_grid) * len(v_grid) * len(rules)
    print(f'[sweep] {total} combinations  ({len(j_grid)} jepa thrs x '
          f'{len(d_grid)} ddpm thrs x {len(v_grid)} v8 thrs x {len(rules)} rules)')

    for tj, td, ta, (rule_name, rule_fn) in product(j_grid, d_grid, v_grid, rules.items()):
        def vote(r, tj=tj, td=td, ta=ta, rule_fn=rule_fn):
            return rule_fn(r['jepa_p95'] > tj, r['ddpm_p95'] > td, r['v8_area_020'] >= ta)
        _, _, _, _, re, fp, acc, f1 = evaluate(rows, vote)
        if re >= 0.95 and fp <= 0.10:
            hits.append((re, fp, acc, f1, rule_name, tj, td, ta))

    hits.sort(key=lambda x: (-x[3], x[1], -x[0]))   # sort by F1 desc, then FPR asc

    if not hits:
        print('\n  ZERO combinations meet the target.')
    else:
        print(f'\n  {len(hits)} combinations meet the target. Top 15 by F1:')
        print(f'  {"rule":25s}  jepa_t  ddpm_t  v8_area  recall  FPR    acc    F1')
        for re, fp, acc, f1, rule, tj, td, ta in hits[:15]:
            print(f'  {rule:25s}  {tj:.3f}   {td:.3f}   {ta:>5d}    '
                  f'{re:.0%}    {fp:.0%}   {acc:.0%}   {f1:.2f}')

    # =================== relaxed targets ===================
    print('\n' + '='*82)
    print('RELAXED TARGETS (recall >=0.90, FPR <= 0.20)')
    print('='*82)
    relaxed = []
    for tj, td, ta, (rule_name, rule_fn) in product(j_grid, d_grid, v_grid, rules.items()):
        def vote(r, tj=tj, td=td, ta=ta, rule_fn=rule_fn):
            return rule_fn(r['jepa_p95'] > tj, r['ddpm_p95'] > td, r['v8_area_020'] >= ta)
        _, _, _, _, re, fp, acc, f1 = evaluate(rows, vote)
        if re >= 0.90 and fp <= 0.20:
            relaxed.append((re, fp, acc, f1, rule_name, tj, td, ta))
    relaxed.sort(key=lambda x: (-x[0], x[1]))
    if not relaxed:
        print('\n  ZERO combinations meet recall >= 0.90 AND FPR <= 0.20.')
    else:
        print(f'\n  {len(relaxed)} combinations. Top 10 by recall:')
        print(f'  {"rule":25s}  jepa_t  ddpm_t  v8_area  recall  FPR    acc    F1')
        for re, fp, acc, f1, rule, tj, td, ta in relaxed[:10]:
            print(f'  {rule:25s}  {tj:.3f}   {td:.3f}   {ta:>5d}    '
                  f'{re:.0%}    {fp:.0%}   {acc:.0%}   {f1:.2f}')


if __name__ == '__main__':
    main()