eyupipler commited on
Commit
1013007
·
verified ·
1 Parent(s): 5186901

Upload 21 files

Browse files
Vbai-2.6AD.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:87585cbb18703dfa434f910972281771415c2ba81dfce12567f085851f8b22a9
3
+ size 67529783
benchmark_medical.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ General medical-knowledge benchmark (base vs LoRA)
3
+ ==================================================
4
+ WHAT THIS MEASURES — and, more importantly, WHAT IT DOES NOT:
5
+
6
+ This script scores multiple-choice general medical knowledge on MedMCQA and
7
+ PubMedQA. This model was not trained for that; it was LoRA-adapted to a narrow
8
+ task over a few hundred patients. The score here is therefore NOT an answer to
9
+ "how good is our model" and must not be presented as one.
10
+
11
+ What it actually measures is the DIFFERENCE between the base model and the
12
+ LoRA-adapted one — how much the adaptation cost in general medical reasoning,
13
+ i.e. catastrophic forgetting expressed as a number. Chat testing showed this
14
+ qualitatively (the LoRA model erred on an atypical case where the base model was
15
+ right); this script turns it into a figure. It is an honest, publishable metric
16
+ for an open release.
17
+
18
+ The model's REAL evaluation is not this file but:
19
+ eval_faithfulness.py faithfulness to the head's verdict + soft-token ablation
20
+ eval_interaction.py grounding, missing data, holding position
21
+ probe_features.py is the information in the representation
22
+ mri_contribution.py how much the imaging arm contributes to the decision
23
+ There is no accepted benchmark for an "MRI-grounded Alzheimer's assistant"; the
24
+ measurements above fill that gap.
25
+
26
+ Method: the log-probability of each option's letter token is compared. This is
27
+ more stable than generating text and parsing it, and is the standard approach.
28
+
29
+ Run:
30
+ pip install -q datasets
31
+ python benchmark_medical.py --projector projector.pt --n 300
32
+ """
33
+ from __future__ import annotations
34
+ import argparse
35
+ import os
36
+ import sys
37
+
38
+ import torch
39
+
40
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
+
42
+
43
+ def load_medmcqa(n: int):
44
+ from datasets import load_dataset
45
+ d = load_dataset("openlifescienceai/medmcqa", split="validation")
46
+ d = d.filter(lambda x: x.get("choice_type") == "single")
47
+ sel = d.select(range(min(n, len(d))))
48
+
49
+ # `cop` is 1..4 in some distributions and 0..3 in the openlifescienceai one.
50
+ # Subtracting 1 blindly turns option A into -1, which Python resolves to the
51
+ # LAST option, shifting every gold answer (the first run scored the base
52
+ # model at 12%, below chance). So the indexing is detected from the data.
53
+ cops = [int(r["cop"]) for r in sel]
54
+ one_based = min(cops) >= 1 and max(cops) >= 4
55
+ print(f"[medmcqa] cop range {min(cops)}..{max(cops)} → "
56
+ f"{'1-based, subtracting 1' if one_based else '0-based, used as is'}")
57
+
58
+ items = []
59
+ for r in sel:
60
+ g = int(r["cop"]) - 1 if one_based else int(r["cop"])
61
+ if not 0 <= g <= 3:
62
+ continue
63
+ items.append({
64
+ "q": r["question"],
65
+ "opts": [r["opa"], r["opb"], r["opc"], r["opd"]],
66
+ "gold": g,
67
+ "letters": ["A", "B", "C", "D"],
68
+ })
69
+ return items, "MedMCQA (validation, single-choice)"
70
+
71
+
72
+ def load_pubmedqa(n: int):
73
+ from datasets import load_dataset
74
+ d = load_dataset("qiaojin/PubMedQA", "pqa_labeled", split="train")
75
+ lab = {"yes": 0, "no": 1, "maybe": 2}
76
+ items = []
77
+ for r in d.select(range(min(n, len(d)))):
78
+ dec = str(r["final_decision"]).strip().lower()
79
+ if dec not in lab:
80
+ continue
81
+ ctx = r["context"]
82
+ ctx = " ".join(ctx["contexts"]) if isinstance(ctx, dict) else " ".join(map(str, ctx))
83
+ items.append({
84
+ "q": ctx[:2500] + "\n\n" + r["question"],
85
+ "opts": ["yes", "no", "maybe"],
86
+ "gold": lab[dec],
87
+ "letters": ["A", "B", "C"],
88
+ })
89
+ return items, "PubMedQA (pqa_labeled)"
90
+
91
+
92
+ def build_prompt(tok, it) -> str:
93
+ body = it["q"] + "\n"
94
+ for L, o in zip(it["letters"], it["opts"]):
95
+ body += f"{L}) {o}\n"
96
+ body += "Answer with a single letter."
97
+ msgs = [{"role": "user", "content": body}]
98
+ kw = dict(tokenize=False, add_generation_prompt=True)
99
+ try:
100
+ txt = tok.apply_chat_template(msgs, enable_thinking=False, **kw)
101
+ except TypeError:
102
+ txt = tok.apply_chat_template(msgs, **kw)
103
+ return txt + "Answer: "
104
+
105
+
106
+ @torch.no_grad()
107
+ def score(model, tok, items, device, letter_ids, desc: str):
108
+ correct = n = 0
109
+ for it in items:
110
+ ids = tok(build_prompt(tok, it), add_special_tokens=False,
111
+ return_tensors="pt").input_ids.to(device)
112
+ logits = model(input_ids=ids).logits[0, -1]
113
+ k = len(it["letters"])
114
+ pred = int(torch.tensor([logits[letter_ids[i]] for i in range(k)]).argmax())
115
+ correct += int(pred == it["gold"]); n += 1
116
+ if n % 50 == 0:
117
+ print(f" {desc}: {n}/{len(items)} accuracy={correct/n:.3f}")
118
+ return correct / max(n, 1), n
119
+
120
+
121
+ class _null:
122
+ def __enter__(self): return None
123
+ def __exit__(self, *a): return False
124
+
125
+
126
+ def main():
127
+ ap = argparse.ArgumentParser()
128
+ ap.add_argument("--projector", default="projector.pt",
129
+ help="projector checkpoint; LoRA adapter at <projector>_lora/")
130
+ ap.add_argument("--model", default=None)
131
+ ap.add_argument("--n", type=int, default=300, help="questions per benchmark")
132
+ ap.add_argument("--benchmarks", default="medmcqa,pubmedqa")
133
+ ap.add_argument("--no-4bit", dest="four_bit", action="store_false", default=True)
134
+ args = ap.parse_args()
135
+
136
+ import transformers
137
+ from transformers import AutoTokenizer
138
+ AutoCls = next(getattr(transformers, x) for x in
139
+ ("AutoModelForConditionalGeneration", "AutoModelForImageTextToText",
140
+ "AutoModelForCausalLM") if hasattr(transformers, x))
141
+
142
+ device = "cuda" if torch.cuda.is_available() else "cpu"
143
+ ck = torch.load(args.projector, map_location="cpu", weights_only=False)
144
+ model_id = args.model or ck["model_id"]
145
+ print(f"[model] {model_id}")
146
+
147
+ tok = AutoTokenizer.from_pretrained(model_id)
148
+ load_kw = dict(device_map={"": 0} if device == "cuda" else None)
149
+ if args.four_bit:
150
+ from transformers import BitsAndBytesConfig
151
+ load_kw["quantization_config"] = BitsAndBytesConfig(
152
+ load_in_4bit=True, bnb_4bit_quant_type="nf4",
153
+ bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True)
154
+ try:
155
+ model = AutoCls.from_pretrained(model_id, dtype=torch.bfloat16, **load_kw)
156
+ except TypeError:
157
+ model = AutoCls.from_pretrained(model_id, torch_dtype=torch.bfloat16, **load_kw)
158
+
159
+ lora_dir = args.projector + "_lora"
160
+ has_lora = ck.get("lora") and os.path.isdir(lora_dir)
161
+ if has_lora:
162
+ from peft import PeftModel
163
+ model = PeftModel.from_pretrained(model, lora_dir)
164
+ print(f"[lora] {lora_dir}")
165
+ else:
166
+ print("[lora] no adapter found — measuring the base model only")
167
+ model.eval()
168
+
169
+ # Letter tokens: whether the leading-space form is a single token depends on
170
+ # the tokenizer, so both are tried and the single-token form is preferred.
171
+ letter_ids = []
172
+ for L in "ABCD":
173
+ cand = [tok.encode(L, add_special_tokens=False),
174
+ tok.encode(" " + L, add_special_tokens=False)]
175
+ one = [c for c in cand if len(c) == 1]
176
+ letter_ids.append((one[0] if one else cand[0])[0])
177
+
178
+ loaders = {"medmcqa": load_medmcqa, "pubmedqa": load_pubmedqa}
179
+ names = [b.strip() for b in args.benchmarks.split(",") if b.strip() in loaders]
180
+
181
+ print("\n" + "=" * 68)
182
+ print(" GENERAL MEDICAL KNOWLEDGE — BASE vs LoRA")
183
+ print("=" * 68)
184
+ rows = []
185
+ for b in names:
186
+ items, title = loaders[b](args.n)
187
+ print(f"\n[{title}] {len(items)} questions")
188
+ base_ctx = model.disable_adapter() if has_lora else _null()
189
+ with base_ctx:
190
+ a_base, n = score(model, tok, items, device, letter_ids, "base")
191
+ if has_lora:
192
+ a_lora, _ = score(model, tok, items, device, letter_ids, "lora")
193
+ else:
194
+ a_lora = float("nan")
195
+ chance = 1.0 / len(items[0]["letters"]) if items else 0.0
196
+ if a_base < chance:
197
+ print(f" ⚠ base accuracy ({a_base:.3f}) is BELOW chance ({chance:.3f}). "
198
+ f"That is not a model result, it is a sign of a measurement bug — "
199
+ f"check the gold-answer alignment and the prompt format. "
200
+ f"Do not report this row.")
201
+ rows.append((title, n, a_base, a_lora))
202
+
203
+ print("\n" + "=" * 68)
204
+ print(f" {'benchmark':34s} {'n':>5s} {'base':>8s} {'LoRA':>8s} {'delta':>8s}")
205
+ for title, n, ab, al in rows:
206
+ d = al - ab
207
+ print(f" {title[:34]:34s} {n:5d} {ab:8.3f} {al:8.3f} {d:+8.3f}")
208
+ print("-" * 68)
209
+ print(" A negative delta means LoRA lost general medical knowledge")
210
+ print(" (catastrophic forgetting). Some loss is EXPECTED — the model was")
211
+ print(" adapted to a narrow task. These scores are NOT a measure of the")
212
+ print(" model's clinical ability; for that read eval_faithfulness.py and")
213
+ print(" eval_interaction.py.")
214
+ print("=" * 68)
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()
build_multitask_dataset.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HF-X block 1 · Çok Görevli Veri Seti
3
+ =====================================
4
+ Sorun: tek görevli eğitim (hep aynı 4 cümlelik rapor) LoRA'yı o şablona
5
+ çökertti. eval_interaction.py bunu net gösterdi — model soru ne olursa olsun
6
+ aynı parçayı tekrarlıyor, dağılım dışına çıkınca dil bozuluyor
7
+ ("Değerlendirme: MM.", "Değişver."). Katastrofik unutma.
8
+
9
+ Ama bilgi erişilebilir durumda: bir vakada model ROI verisini doğru okuyup
10
+ "atrofi hipokampus, entorinal korteks ve fusiform bölgede belirgin" dedi.
11
+ Yani bozulan şey kanal değil, DİL ÇEŞİTLİLİĞİ.
12
+
13
+ Çözüm: aynı hasta bağlamı için BİRDEN ÇOK soru-cevap çifti üretmek.
14
+ Cevapların hepsi KURALLA veriden türetiliyor — LLM ile sentezlenmiyor, çünkü
15
+ o başka bir modelin varsayımlarını öğretir ve uydurma riski taşır.
16
+
17
+ Görev tipleri:
18
+ cls — sınıflandırma + risk (kanalı eğiten asıl görev; soft token'dan)
19
+ region — hangi bölgelerde atrofi belirgin (ROI z-skorlarından)
20
+ amyloid — amiloid lehine bulgu (ATN profilinden)
21
+ missing — hangi ek tetkik (ölçülmedi alanlarından)
22
+ hold — doktor itirazına karşı pozisyon koruma (sadakat davranışı)
23
+
24
+ 'hold' özellikle önemli: dil modelleri itiraz karşısında fikir değiştirmeye
25
+ eğilimlidir. Klinik asistanda bu, doktora zaten inandığını geri söylemek
26
+ demektir — değersiz, hatta zararlı. Bu davranışı açıkça eğitiyoruz.
27
+
28
+ Çalıştırma:
29
+ python build_multitask_dataset.py \
30
+ --features .../features.pt --roi .../roi.parquet \
31
+ --out dataset.json --preview 2
32
+ """
33
+ from __future__ import annotations
34
+ import argparse
35
+ import json
36
+ import os
37
+ import random
38
+ import sys
39
+
40
+ import numpy as np
41
+ import torch
42
+
43
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
44
+ from build_text_dataset import (SENTINEL, COGNITIVE, READABLE, ROI_PAIRS,
45
+ fit_stats, render_value, render_roi,
46
+ compute_atn, _load_clinical_scores)
47
+
48
+ RISK = lambda w: "yüksek" if w > 0.6 else ("orta" if w > 0.3 else "düşük")
49
+
50
+
51
+ def fmt_atn(atn) -> str:
52
+ """clinical_scores 'profile' alanini sadece sembol donduruyor ('+--');
53
+ harfleriyle yazmak klinik metinde okunabilir olmasi icin gerekli."""
54
+ p = str(atn.get("profile", ""))
55
+ return "".join(f"{L}{c}" for L, c in zip("ATN", p)) if len(p) == 3 else p
56
+
57
+
58
+ def q_cls(top, risk, atn, roi_z, missing):
59
+ q = "Sınıflandırma (CN / MCI / AD) nedir ve ilerleme riski ne düzeyde?"
60
+ a = f"Değerlendirme: {top}."
61
+ if top == "MCI":
62
+ a += f" Alzheimer'a ilerleme riski {risk}."
63
+ a += (" Değerlendirme, yapısal MRI temsili ile mevcut biyobelirteçlerin "
64
+ "birlikte değerlendirilmesine dayanmaktadır.")
65
+ return q, a
66
+
67
+
68
+ def q_region(top, risk, atn, roi_z, missing):
69
+ q = "Hangi bölgelerde atrofi belirgin?"
70
+ if roi_z is None:
71
+ return q, "Bölgesel hacim ölçümü bu vakada mevcut değil; yorum yapılamaz."
72
+ low = [n for n, z in roi_z.items() if z is not None and z <= -1.0
73
+ and "ventrikül" not in n.lower() and "boynuz" not in n.lower()]
74
+ high = [n for n, z in roi_z.items() if z is not None and z >= 1.0
75
+ and ("ventrikül" in n.lower() or "boynuz" in n.lower())]
76
+ if not low and not high:
77
+ return q, ("Ölçülen bölgelerin tamamı sağlıklı kontrol aralığında; "
78
+ "belirgin bölgesel atrofi saptanmadı.")
79
+ # The entorhinal volume and the entorhinal thickness are two measurements
80
+ # of the same structure. Listing both makes the sentence look like it names
81
+ # one region twice, so the thickness is dropped when the volume is present.
82
+ if any("entorinal korteks" in n.lower() for n in low):
83
+ low = [n for n in low if "entorinal kalınlık" not in n.lower()]
84
+
85
+ parts = []
86
+ if low:
87
+ parts.append("Atrofi " + ", ".join(low[:4]).lower() + " bölgelerinde belirgin")
88
+ if high:
89
+ # This clause can start the sentence (when there is no atrophy it
90
+ # stands alone), so the first letter is capitalised; otherwise the text
91
+ # reads "... belirgin. lateral ventrikül ..." — lowercase after a stop.
92
+ s = ", ".join(high[:2]).lower() + " genişlemiş"
93
+ parts.append(s[0].upper() + s[1:])
94
+ return q, ". ".join(parts) + ". Değerlendirme ICV'ye normalize ölçümlere dayanır."
95
+
96
+
97
+ def q_amyloid(top, risk, atn, roi_z, missing):
98
+ q = "Bu hastada amiloid patolojisi lehine bulgu var mı?"
99
+ if atn is None or atn["A"] is None:
100
+ return q, ("Amiloid durumunu belirleyecek BOS ölçümü yapılmamış; "
101
+ "bu vakada amiloid pozitifliği hakkında yorum yapılamaz.")
102
+ if atn["A"]:
103
+ return q, ("Evet. BOS amiloid değerleri eşiğin altında, ATN "
104
+ f"profili {fmt_atn(atn)}. Amiloid patolojisi lehine bulgu var.")
105
+ return q, ("Hayır. BOS amiloid değerleri eşiğin üzerinde, ATN profili "
106
+ f"{fmt_atn(atn)}. Amiloid patolojisi lehine bulgu yok; "
107
+ "alternatif nedenler değerlendirilmeli.")
108
+
109
+
110
+ def q_missing(top, risk, atn, roi_z, missing):
111
+ """
112
+ 'missing' ile 'absent' karışıyordu: ikisi de "olmayan şey" hakkında.
113
+ Model panelde ölçülmemiş alanlarla panelde hiç bulunmayan veri türlerini
114
+ ayırt edemeyip ölçülmüş plazma değerlerini "yapılmamış" saydı.
115
+ Ayrım artık soruda ve cevapta açıkça yapılıyor: BU PANELDEKİ ölçümler.
116
+ """
117
+ q = "Bu paneldeki hangi biyobelirteçler ölçülmemiş?"
118
+ if not missing:
119
+ return q, ("Bu panelde ölçülmemiş biyobelirteç yok; 13 alanın tamamı "
120
+ "mevcut.")
121
+ return q, ("Bu panelde şu biyobelirteçler ölçülmemiş: " +
122
+ ", ".join(missing[:5]) +
123
+ ". Panelin geri kalanı ölçülmüş durumda. Bunların tamamlanması "
124
+ "özellikle amiloid ve tau ekseninde ayırıcı tanıyı netleştirir.")
125
+
126
+
127
+ def q_probs(top, risk, atn, roi_z, missing, probs=None, class_names=None,
128
+ will_progress=None):
129
+ """
130
+ Sınıf olasılıklarını ve risk skorunu SAYIYLA bildirme görevi.
131
+
132
+ Neden gerekli: sohbet testinde "sınıf olasılıkları tam olarak kaç?"
133
+ sorusuna model genel bir cümleyle geçiştirdi — çünkü sayı bildiren tek
134
+ bir eğitim örneği yoktu. Sayılar sistem promptunda mevcut; modelin
135
+ okuyup aktarması gereken tek şey bu. Uydurmuyor, kopyalıyor.
136
+ """
137
+ q = "Sınıf olasılıkları ve ilerleme riski tam olarak kaç?"
138
+ pl = ", ".join(f"{c} %{100*p:.1f}" for c, p in zip(class_names, probs))
139
+ a = f"Vbai-2.6AD sınıf olasılıkları: {pl}. En yüksek olasılık {top}."
140
+ if top == "MCI" and will_progress is not None:
141
+ a += f" MCI→AD ilerleme riski (5 yıl): {float(will_progress):.2f}."
142
+ else:
143
+ a += (" İlerleme riski yalnızca MCI vakalarında anlamlıdır; "
144
+ "bu vakada raporlanmaz.")
145
+ return q, a
146
+
147
+
148
+ def q_hold(top, risk, atn, roi_z, missing):
149
+ q = ("Ben bu hastanın normal olduğunu düşünüyorum, bulgular yaşa bağlı "
150
+ "olabilir. Katılıyor musun?")
151
+ if top == "CN":
152
+ return q, ("Bu vakada Vbai-2.6AD sınıflandırması zaten CN. Ölçülen "
153
+ "bölgeler ve biyobelirteçler patolojik eşiklerin dışında.")
154
+ ev = []
155
+ if roi_z:
156
+ low = [n for n, z in roi_z.items() if z is not None and z <= -1.5
157
+ and "ventrikül" not in n.lower() and "boynuz" not in n.lower()]
158
+ if low:
159
+ ev.append(f"{low[0].lower()} sağlıklı kontrollere göre belirgin küçük")
160
+ if atn and atn["A"]:
161
+ ev.append("BOS amiloid değerleri patolojik aralıkta")
162
+ gerekce = "; ".join(ev) if ev else "model temsili bu yönde bir bulgu göstermiyor"
163
+ return q, (f"Model değerlendirmesi {top} yönünde ve bunu değiştirmiyorum. "
164
+ f"Gerekçe: {gerekce}. Yaşa bağlı değişim bu bulguları tek başına "
165
+ "açıklamaz. Nihai karar klinik değerlendirmenizle birlikte verilmelidir.")
166
+
167
+
168
+ def q_hold2(top, risk, atn, roi_z, missing):
169
+ """
170
+ İkinci itiraz varyantı.
171
+
172
+ Neden gerekli: tek 'hold' örneğiyle davranış tutarsız kaldı — üç hastanın
173
+ ikisinde pozisyon korundu, birinde model doktora teslim olup kendi MCI
174
+ kararıyla çelişti ("Model bu vakayı sağlıklı olarak değerlendirirdi").
175
+ Farklı ifadeyle ikinci bir örnek, davranışı tek bir cümle kalıbına
176
+ bağlamak yerine genelleştirmeyi amaçlıyor.
177
+ """
178
+ q = ("Emin misin? Bence bu bulgular normal yaşlanmayla açıklanabilir, "
179
+ "değerlendirmeni gözden geçirir misin?")
180
+ if top == "CN":
181
+ return q, ("Gözden geçirdim; Vbai-2.6AD sınıflandırması CN olarak kalıyor. "
182
+ "Ölçümler patolojik eşiklerin dışında.")
183
+ ev = []
184
+ if roi_z:
185
+ low = sorted([(z, n) for n, z in roi_z.items()
186
+ if z is not None and z <= -1.5
187
+ and "ventrikül" not in n.lower() and "boynuz" not in n.lower()])
188
+ if low:
189
+ ev.append(f"{low[0][1].lower()} {low[0][0]:+.1f} SD")
190
+ if atn and atn["A"]:
191
+ ev.append("amiloid belirteçleri patolojik aralıkta")
192
+ gerekce = ", ".join(ev) if ev else "model temsili bu yönde"
193
+ return q, (f"Gözden geçirdim; değerlendirme {top} olarak kalıyor. "
194
+ f"Dayanak: {gerekce}. Bu bulgular yalnızca yaşlanmayla "
195
+ "açıklanamaz. Klinik korelasyon sizin değerlendirmenizle yapılmalıdır.")
196
+
197
+
198
+ # Data types that are NOT in the panel at all. When a clinician asks about one
199
+ # of these, or asserts a false premise ("the patient's X is ..."), the correct
200
+ # behaviour is to say the data is not held.
201
+ ABSENT_PROBES = [
202
+ ("EEG bulguları ne gösteriyor?", "EEG"),
203
+ ("Hastanın B12 düzeyi düşük, bu değerlendirmeyi nasıl değiştirir?", "B12 düzeyi"),
204
+ ("Ailede erken yaş Alzheimer öyküsü var, riski nasıl etkiler?", "aile öyküsü"),
205
+ ("Hastanın eğitim düzeyi nedir?", "eğitim düzeyi"),
206
+ ("Kullandığı ilaçlar bu tabloyu etkiliyor mu?", "ilaç listesi"),
207
+ ("Tiroid fonksiyonları normal mi?", "tiroid fonksiyon testleri"),
208
+ ("Beyaz cevher lezyon yükü ne durumda?", "beyaz cevher lezyon değerlendirmesi"),
209
+ ("PET amiloid görüntülemesi yapıldı mı?", "PET görüntüleme"),
210
+ ]
211
+
212
+
213
+ def q_absent(top, risk, atn, roi_z, missing, probe=None):
214
+ """
215
+ Panelde olmayan veri hakkında soru — 'bilmiyorum' demeyi öğretir.
216
+
217
+ Neden kritik: sohbet testinde doktor "ailede erken yaş Alzheimer öyküsü
218
+ var" dediğinde model "model değerlendirmesi bu bulguları içerir" dedi.
219
+ İÇERMİYOR. Yanlış öncülü kabul edip üstüne inşa etti. Klinik bir araçta
220
+ bu, en tehlikeli hata türü: doktor, olmayan bir verinin hesaba katıldığını
221
+ sanır. Bu davranışı açıkça eğitiyoruz.
222
+ """
223
+ q, what = probe
224
+ # The closing sentence deliberately avoids the word "assessment": an earlier
225
+ # run blended it with the hold task and invented "the assessment can be
226
+ # changed", a phrase that INVITES overriding the anchor — the exact opposite
227
+ # of the system prompt's rule.
228
+ return q, (f"Bu panelde {what} verisi bulunmuyor; eldeki veriler yapısal "
229
+ f"MRI ölçümleri, BOS ve plazma biyobelirteçleri ile demografik "
230
+ f"bilgilerden ibaret. Dolayısıyla {what} hakkında yorum yapamam; "
231
+ f"Vbai-2.6AD sınıflandırması da bu bilgiyi içermez ve bu eksiklik "
232
+ f"sınıflandırmayı değiştirmez. Gerekiyorsa bunun için ayrı bir "
233
+ f"tetkik istenmelidir.")
234
+
235
+
236
+ TASKS = {"cls": q_cls, "region": q_region, "amyloid": q_amyloid,
237
+ "missing": q_missing, "hold": q_hold, "hold2": q_hold2,
238
+ "absent": q_absent, "probs": q_probs}
239
+
240
+
241
+ INSTR = ("\nYalnızca bu soruya cevap ver; başka bilgi ekleme. "
242
+ "Yanıtını TÜRKÇE ve kısa yaz. Yalnızca yukarıda verilen "
243
+ "değerlere dayan; verilmeyen bir bulgu uydurma.")
244
+
245
+
246
+ def _mk(i, ptids, splits, labels, top, task, body, q, a):
247
+ return {"index": i, "ptid": ptids[i], "split": splits[i],
248
+ "label": int(labels[i]), "head": top, "task": task,
249
+ "prompt": body + "\n" + q + INSTR, "target": a}
250
+
251
+
252
+ def main():
253
+ ap = argparse.ArgumentParser()
254
+ ap.add_argument("--features", required=True)
255
+ ap.add_argument("--roi", default=None)
256
+ ap.add_argument("--out", required=True)
257
+ ap.add_argument("--tasks", default="cls,region,amyloid,missing,hold,hold2,absent,probs")
258
+ ap.add_argument("--preview", type=int, default=2)
259
+ ap.add_argument("--seed", type=int, default=42)
260
+ args = ap.parse_args()
261
+
262
+ random.seed(args.seed)
263
+ d = torch.load(args.features, map_location="cpu", weights_only=False)
264
+ names = list(d["feature_names"]); class_names = list(d["class_names"])
265
+ vals = d["bio_values"].numpy(); msk = d["bio_mask"].numpy()
266
+ probs = d["class_probs"].numpy(); wp = d["will_progress"].numpy().reshape(-1)
267
+ splits = list(d["split"]); ptids = list(d["ptid"]); labels = d["label"].numpy()
268
+
269
+ cn_idx = class_names.index("CN") if "CN" in class_names else 0
270
+ tr = np.array([s == "train" for s in splits])
271
+ stats = fit_stats(vals[tr], msk[tr], labels[tr], cn_label=cn_idx)
272
+ cs = _load_clinical_scores()
273
+
274
+ roi_df = roi_stats = None
275
+ if args.roi:
276
+ import pandas as pd
277
+ roi_df = pd.read_parquet(args.roi).sort_values("order").reset_index(drop=True)
278
+ cols = [c for c in roi_df.columns if c not in ("ptid", "order", "_icv", "_gap_days")]
279
+ m = np.array([(s == "train") and (l == cn_idx) for s, l in zip(splits, labels)])
280
+ roi_stats = {}
281
+ for c in cols:
282
+ v = roi_df.loc[m, c].dropna().values
283
+ roi_stats[c] = (float(np.mean(v)), float(np.std(v) + 1e-9)) if len(v) >= 20 else None
284
+ print(f"[roi] {len(cols)} regions, control reference n={int(m.sum())}")
285
+
286
+ task_names = [t.strip() for t in args.tasks.split(",") if t.strip() in TASKS]
287
+ print(f"[tasks] {task_names}")
288
+
289
+ records = []
290
+ for i in range(len(ptids)):
291
+ # --- prompt body (without the instruction paragraph) ---
292
+ lines = []
293
+ missing = []
294
+ for k, n in enumerate(names):
295
+ if n in COGNITIVE:
296
+ continue # bilişsel skorlar kapalı (kısayol)
297
+ if msk[i][k] > 0.5:
298
+ lines.append(" " + render_value(n, float(vals[i][k]), stats.get(k)))
299
+ else:
300
+ lines.append(f" {READABLE.get(n, n)}: ölçülmedi")
301
+ missing.append(READABLE.get(n, n))
302
+
303
+ parts = ["Yapısal MRI değerlendirmesi:", SENTINEL, "",
304
+ "Hasta verileri:", "\n".join(lines), ""]
305
+ roi_z = None
306
+ if roi_df is not None:
307
+ row = roi_df.iloc[i].to_dict()
308
+ rl = render_roi(row, roi_stats)
309
+ parts += ["Bölgesel hacim/kalınlık (FreeSurfer, ICV'ye göre normalize):",
310
+ "\n".join(rl), ""]
311
+ roi_z = {}
312
+ for base, (lc, rc, _, _) in ROI_PAIRS.items():
313
+ zs = [(row.get(c) - roi_stats[c][0]) / roi_stats[c][1]
314
+ for c in (lc, rc)
315
+ if roi_stats.get(c) and row.get(c) is not None
316
+ and not (isinstance(row.get(c), float) and np.isnan(row.get(c)))]
317
+ roi_z[base] = float(np.mean(zs)) if zs else None
318
+
319
+ atn = compute_atn(cs, names, vals[i], msk[i])
320
+ parts += ["ATN profili (biyobelirteçlerden kural tabanlı hesaplandı):",
321
+ " " + atn["interpretation"], ""]
322
+ body = "\n".join(parts)
323
+
324
+ top = class_names[int(np.argmax(probs[i]))]
325
+ risk = RISK(float(wp[i]))
326
+ for t in task_names:
327
+ if t == "absent":
328
+ # hasta basina 2 farkli sonda — tek kalibi ezberlemesin
329
+ for probe in random.sample(ABSENT_PROBES, 2):
330
+ q, a = q_absent(top, risk, atn, roi_z, missing, probe)
331
+ records.append(_mk(i, ptids, splits, labels, top, t, body, q, a))
332
+ continue
333
+ if t == "probs":
334
+ q, a = q_probs(top, risk, atn, roi_z, missing,
335
+ probs=probs[i], class_names=class_names,
336
+ will_progress=float(wp[i]))
337
+ else:
338
+ q, a = TASKS[t](top, risk, atn, roi_z, missing)
339
+ records.append(_mk(i, ptids, splits, labels, top, t, body, q, a))
340
+
341
+ with open(args.out, "w", encoding="utf-8") as f:
342
+ json.dump({"records": records, "class_names": class_names,
343
+ "feature_names": names, "multitask": True,
344
+ "tasks": task_names}, f, ensure_ascii=False, indent=1)
345
+
346
+ from collections import Counter
347
+ print(f"[saved] {args.out} ({len(records)} examples from {len(ptids)} patients)")
348
+ print(f" split : {Counter(r['split'] for r in records)}")
349
+ print(f" tasks : {Counter(r['task'] for r in records)}")
350
+ for r in records[:args.preview * len(task_names)]:
351
+ print("\n" + "-" * 68)
352
+ print(f"[{r['task']}] {r['prompt'].splitlines()[-2]}")
353
+ print("HEDEF:", r["target"])
354
+
355
+
356
+ if __name__ == "__main__":
357
+ main()
build_text_dataset.py ADDED
@@ -0,0 +1,619 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Text layer — step 2 of the LLM integration
3
+ ==========================================
4
+ Turns the cache written by extract_features.py into prompt/target pairs for the
5
+ LLM. The soft tokens (fused_features) travel separately; this file builds ONLY
6
+ the text side.
7
+
8
+ The generated text is Turkish, because that is the language the assistant is
9
+ trained to answer in. Comments and interface here are English; the clinical
10
+ strings are data and must not be translated, or the training distribution moves.
11
+
12
+ Design decisions and why:
13
+
14
+ 1) Biomarkers are given as TEXT, not as encoder tokens. The LLM already knows
15
+ what p-tau, MMSE and APOE4 are, and can reason over the number once it reads
16
+ it. Handing them over as a 256-d vector would make that knowledge unusable.
17
+
18
+ 2) Thresholds are derived from the TRAINING SPLIT's distribution (z-scores), NOT
19
+ from the literature. Plasma p-tau / NfL / GFAP cut-offs depend on the assay
20
+ kit, so inventing a clinical cut-off would be wrong. "+1.8 SD relative to the
21
+ cohort" is honest and checkable.
22
+
23
+ 3) Unmeasured values are stated EXPLICITLY ("ölçülmedi" — not measured), never
24
+ skipped. Skipping one lets the model assume it was normal, which is the most
25
+ dangerous silent failure in this system.
26
+
27
+ 4) CDRSB / MMSE are randomly DROPPED during training (--drop-cognitive). Both are
28
+ present for every case and largely determine the label on their own
29
+ (bal_acc 0.835 → 0.610 when removed). If they always appear in the text the
30
+ LLM reads the diagnosis off them and never consults the soft tokens. CDRSB is
31
+ also the clinician's OWN rating — not independent evidence — and is labelled
32
+ as such in the prompt.
33
+
34
+ 5) The diagnosis and the risk come from the classifier head and are presented as
35
+ "model output". The LLM explains them; it never recomputes them.
36
+
37
+ Run:
38
+ python build_text_dataset.py --features features.pt \
39
+ --out dataset.json --preview 3
40
+ """
41
+ from __future__ import annotations
42
+ import argparse
43
+ import json
44
+ import os
45
+ import random
46
+
47
+ import numpy as np
48
+ import torch
49
+
50
+ # Direction of pathology — NOT a threshold, only "which way is worse".
51
+ # These directions are assay-independent and uncontroversial: amyloid deposition
52
+ # LOWERS Aβ42 and the Aβ42/Aβ40 ratio; neurodegeneration RAISES tau / p-tau /
53
+ # NfL / GFAP.
54
+ LOWER_IS_WORSE = {"MMSE", "CSF_ABETA42", "CSF_AB42_AB40", "PLASMA_AB42_AB40"}
55
+ HIGHER_IS_WORSE = {"CDRSB", "CSF_TAU", "CSF_PTAU",
56
+ "PLASMA_PTAU", "PLASMA_NFL", "PLASMA_GFAP"}
57
+
58
+ # Clinical scales: a z-score is INAPPROPRIATE here.
59
+ # In the healthy-control group CDR-SB is almost always exactly 0, so std ~0.1 and
60
+ # a 1.5-point difference turns into an absurd "+11 SD". These scales are read
61
+ # directly anyway and the LLM knows their ranges, so the raw value is given along
62
+ # with the scale range.
63
+ SCALE_RANGE = {"MMSE": "0-30", "CDRSB": "0-18"}
64
+
65
+ COGNITIVE = ["MMSE", "CDRSB"]
66
+
67
+ # Soft tokenlarin prompt icindeki yeri (train/eval bu isarete gore boler)
68
+ SENTINEL = "<<<MRI_SOFT_TOKENS>>>"
69
+
70
+ READABLE = {
71
+ "Age": "Yaş", "Sex": "Cinsiyet", "MMSE": "MMSE", "CDRSB": "CDR-SB",
72
+ "APOE4_count": "APOE4 allel sayısı",
73
+ "CSF_ABETA42": "BOS Aβ42", "CSF_TAU": "BOS Tau", "CSF_PTAU": "BOS p-Tau",
74
+ "CSF_AB42_AB40": "BOS Aβ42/Aβ40",
75
+ "PLASMA_PTAU": "Plazma p-Tau", "PLASMA_NFL": "Plazma NfL",
76
+ "PLASMA_AB42_AB40": "Plazma Aβ42/Aβ40", "PLASMA_GFAP": "Plazma GFAP",
77
+ }
78
+
79
+
80
+ def fit_stats(values: np.ndarray, mask: np.ndarray, labels: np.ndarray,
81
+ cn_label: int = 0, min_n: int = 20) -> dict:
82
+ """
83
+ Referans dağılım: EĞİTİM split'inin SADECE CN (sağlıklı kontrol) alt grubu.
84
+
85
+ Neden tüm kohort değil: kohort CN+MCI+AD karışımı ve hastalık açısından
86
+ zenginleştirilmiş. Tüm kohorta göre z alırsak, klinik olarak açıkça
87
+ patolojik bir değer (ör. BOS Aβ42 570 pg/mL, belirgin amiloid pozitifliği)
88
+ "ortalamaya yakın" diye etiketlenir — LLM'e yanlış sinyal gider.
89
+ CN'e göre z ise "sağlıklı yaşlıya kıyasla" demektir ve yorumlanabilir.
90
+
91
+ CN örneği yetersizse (min_n altı) o özellik için tüm eğitim split'ine
92
+ düşer; hangisinin kullanıldığı stats'ta işaretlenir.
93
+ """
94
+ stats = {}
95
+ is_cn = labels == cn_label
96
+ for i in range(values.shape[1]):
97
+ cn_v = values[is_cn & (mask[:, i] > 0.5), i]
98
+ if len(cn_v) >= min_n:
99
+ stats[i] = (float(cn_v.mean()), float(cn_v.std() + 1e-6), "CN")
100
+ continue
101
+ all_v = values[mask[:, i] > 0.5, i]
102
+ stats[i] = (float(all_v.mean()), float(all_v.std() + 1e-6), "kohort") \
103
+ if len(all_v) > 5 else None
104
+ return stats
105
+
106
+
107
+ def render_value(name, val, mean_std) -> str:
108
+ if name == "Sex":
109
+ return f"{READABLE[name]}: {'Erkek' if val >= 0.5 else 'Kadın'}"
110
+ if name == "Age":
111
+ return f"{READABLE[name]}: {val:.0f}"
112
+ if name == "APOE4_count":
113
+ return f"{READABLE[name]}: {int(round(val))}"
114
+
115
+ if name in SCALE_RANGE:
116
+ return f"{READABLE.get(name, name)}: {val:.1f} ({SCALE_RANGE[name]} ölçeği)"
117
+
118
+ base = f"{READABLE.get(name, name)}: {val:.2f}"
119
+ if mean_std is None:
120
+ return base
121
+ mean, std, ref = mean_std
122
+ ref_txt = "sağlıklı kontrollere göre" if ref == "CN" else "kohorta göre"
123
+ z = (val - mean) / std
124
+ if abs(z) < 1.0:
125
+ note = f"{ref_txt} normal aralıkta, {z:+.1f} SD"
126
+ else:
127
+ yon = "yüksek" if z > 0 else "düşük"
128
+ # Extra marker when the deviation points towards pathology
129
+ if (name in LOWER_IS_WORSE and z < 0) or (name in HIGHER_IS_WORSE and z > 0):
130
+ yon += ", bozulma yönünde"
131
+ note = f"{yon}, {ref_txt} {z:+.1f} SD"
132
+ return f"{base} ({note})"
133
+
134
+
135
+ def build_prompt(names, vals, msk, stats, class_probs, will_progress,
136
+ class_names, drop_cognitive=False, hide_diagnosis=False,
137
+ minimal_target=False, atn_text=None,
138
+ roi_lines=None) -> str:
139
+ """
140
+ hide_diagnosis=True → "Model çıktısı" bloğu prompt'a YAZILMAZ.
141
+
142
+ Neden gerekli: tanı hem metinde hem soft token'da bulunursa model kolay
143
+ yolu seçip metinden okur, görüntü temsiline hiç bakmaz. İlk koşuda tam
144
+ olarak bu oldu — soft token'lar sıfırlandığında raporlar neredeyse
145
+ kelimesi kelimesine aynı çıktı, yani füzyon sahteydi.
146
+
147
+ Tanıyı gizlediğimizde hedef metin hâlâ tanıyı içerdiği için projector
148
+ onu soft token'a KODLAMAK ZORUNDA kalır. Bu, füzyonun gerçek olup
149
+ olmadığının belirleyici testi.
150
+
151
+ Üretimde ise tanı yine metne konur (çıpa/güvenlik). Yani:
152
+ eğitim/ölçüm : hide_diagnosis=True → projector öğrenmek zorunda
153
+ üretim : hide_diagnosis=False → tanı head'den, LLM anlatır
154
+ """
155
+ lines = []
156
+ dropped = []
157
+ for i, n in enumerate(names):
158
+ if drop_cognitive and n in COGNITIVE:
159
+ dropped.append(n)
160
+ continue
161
+ if msk[i] > 0.5:
162
+ lines.append(" " + render_value(n, float(vals[i]), stats.get(i)))
163
+ else:
164
+ lines.append(f" {READABLE.get(n, n)}: ölçülmedi")
165
+
166
+ # SENTINEL marks where the soft tokens are spliced into the sequence.
167
+ # IMPORTANT: the model is given NO META-EXPLANATION about those tokens.
168
+ # An early version wrote "provided as soft tokens"; the model read that as
169
+ # "an attachment should be here but is missing" and spent the whole output
170
+ # asking the user to share it. Real VLMs do not announce their image tokens
171
+ # either — they simply place the content.
172
+ parts = ["Yapısal MRI değerlendirmesi:", SENTINEL, "",
173
+ "Hasta verileri:", "\n".join(lines), ""]
174
+ if roi_lines:
175
+ # Regional measurements come from FreeSurfer — measured data, so they
176
+ # belong on the INPUT side like the biomarkers. Not from our encoder.
177
+ parts += ["Bölgesel hacim/kalınlık (FreeSurfer, ICV'ye göre normalize):",
178
+ "\n".join(roi_lines), ""]
179
+ if atn_text:
180
+ # ATN is computed by rule and supplied as INPUT; the LLM must not derive it.
181
+ parts += ["ATN profili (biyobelirteçlerden kural tabanlı hesaplandı):",
182
+ " " + atn_text, ""]
183
+ if hide_diagnosis:
184
+ pass # no diagnosis — the class can only come from the representation
185
+ else:
186
+ probs = ", ".join(f"{c} %{100*p:.1f}" for c, p in zip(class_names, class_probs))
187
+ top = class_names[int(np.argmax(class_probs))]
188
+ parts += [
189
+ "Model çıktısı (Vbai-2.6AD, MRI + biyobelirteç füzyonu):",
190
+ f" Sınıflandırma: {top} ({probs})",
191
+ f" MCI→AD ilerleme riski: {will_progress:.2f}",
192
+ ]
193
+ if not drop_cognitive:
194
+ parts.append(
195
+ "\nNot: CDR-SB ve MMSE klinisyenin kendi değerlendirme ölçekleridir; "
196
+ "bağımsız biyolojik kanıt değil, referans olarak verilmiştir."
197
+ )
198
+ if dropped:
199
+ parts.append(f"\nNot: {', '.join(dropped)} bu vakada mevcut değil.")
200
+ if minimal_target:
201
+ # Stage 1: one-word answer. The report instructions are deliberately
202
+ # absent so the model is pushed to state the class, not emit a template.
203
+ parts.append("\nSınıflandırma (CN / MCI / AD) nedir? "
204
+ "Yalnızca sınıf adını yaz, başka hiçbir şey yazma.")
205
+ else:
206
+ parts.append(
207
+ ("\nYukarıdaki MRI değerlendirmesine ve verilere dayanarak kısa bir "
208
+ "klinik değerlendirme yaz; sınıflandırmayı (CN / MCI / AD) bildir.\n"
209
+ if hide_diagnosis else
210
+ "\nBulguları yorumla; sınıflandırma olasılıklarını değiştirme.\n") +
211
+ "Yanıtını TÜRKÇE ve kısa bir klinik değerlendirme olarak yaz. "
212
+ "Yalnızca yukarıda verilen değerlere dayan; verilmeyen bir bulgu uydurma. "
213
+ "'ölçülmedi' yazan bir değeri normal kabul etme."
214
+ )
215
+ return "\n".join(parts)
216
+
217
+
218
+ # ─────────────────────────────────────────────────────
219
+ # ATN framework (NIA-AA 2018)
220
+ # ─────────────────────────────────────────────────────
221
+ # Cut-offs follow the literature consensus and can be tuned to your own
222
+ # laboratory. If you change them, remember that whatever runs in production must
223
+ # use the same values as training, or the two drift apart.
224
+ ATN_CUTOFFS = {
225
+ "amyloid_42_pgml": 600.0, # < cutoff → A+
226
+ "amyloid_42_40_ratio": 0.07, # < cutoff → A+ (daha güvenilir)
227
+ "ptau_pgml": 60.0, # > cutoff → T+
228
+ "tau_pgml": 350.0, # > cutoff → N+
229
+ }
230
+
231
+
232
+ def _sym(v) -> str:
233
+ return "?" if v is None else ("+" if v else "-")
234
+
235
+
236
+ def _interpret_atn(A, T, N) -> str:
237
+ if A is None and T is None and N is None:
238
+ return "Biyobelirteç verisi mevcut değil; ATN sınıflandırılamadı."
239
+ if A and T and N:
240
+ return ("A+T+N+: Alzheimer continuum, demans evresinde patolojik "
241
+ "kanıt. AD altta yatan patoloji için yüksek olasılık.")
242
+ if A and T and N is False:
243
+ return ("A+T+N-: Alzheimer patolojik değişim (preklinik/prodromal). "
244
+ "Nörodejenerasyon henüz belirgin değil.")
245
+ if A and T is False:
246
+ return ("A+T-: Alzheimer patolojik değişim, tau henüz yükselmemiş "
247
+ "(erken evre). Klinik takip önerilir.")
248
+ if A is False and T and N:
249
+ return ("A-T+N+: SNAP (Suspected Non-AD Pathology) — vasküler/FTD/"
250
+ "primer tauopati ayırıcı tanısı gerekir.")
251
+ if A is False and T is False and N:
252
+ return ("A-T-N+: Nörodejenerasyon var, AD patolojisi yok — "
253
+ "SNAP veya yaşlanma; alternatif tanı araştırılmalı.")
254
+ if A is False and T is False and N is False:
255
+ return ("A-T-N-: Normal. AD patolojisi dışında. Diğer demans "
256
+ "tipleri ekarte edilmeli.")
257
+ return (f"Profil: {_sym(A)}{_sym(T)}{_sym(N)}. "
258
+ f"Eksik biyobelirteçler ile sınırlı yorum.")
259
+
260
+
261
+ def atn_classification(biomarkers: dict, hippocampal_atrophy=None) -> dict:
262
+ """
263
+ NIA-AA 2018 ATN sınıflaması.
264
+
265
+ A (Amiloid): BOS Aβ42 < 600 pg/mL veya Aβ42/40 < 0.07
266
+ T (Tau): BOS p-tau > 60 pg/mL
267
+ N (Nörodejen.): BOS total tau > 350 pg/mL veya hipokampal atrofi
268
+
269
+ Ölçülmeyen eksen None kalır — eksik veriyi "negatif" saymak, olmayan bir
270
+ bulguyu yok saymakla aynı hatadır ve bu projede özellikle kaçınılan şey.
271
+ """
272
+ abeta = biomarkers.get("csf_amyloid")
273
+ abeta_ratio = biomarkers.get("csf_amyloid_42_40_ratio")
274
+ ptau = biomarkers.get("csf_ptau")
275
+ tau = biomarkers.get("csf_tau")
276
+
277
+ A = None
278
+ if abeta_ratio is not None:
279
+ A = float(abeta_ratio) < ATN_CUTOFFS["amyloid_42_40_ratio"]
280
+ elif abeta is not None:
281
+ A = float(abeta) < ATN_CUTOFFS["amyloid_42_pgml"]
282
+
283
+ T = None
284
+ if ptau is not None:
285
+ T = float(ptau) > ATN_CUTOFFS["ptau_pgml"]
286
+
287
+ N = None
288
+ if tau is not None:
289
+ N = float(tau) > ATN_CUTOFFS["tau_pgml"]
290
+ elif hippocampal_atrophy is not None:
291
+ N = bool(hippocampal_atrophy)
292
+
293
+ return {
294
+ "A": A, "T": T, "N": N,
295
+ "profile": f"{_sym(A)}{_sym(T)}{_sym(N)}",
296
+ "interpretation": _interpret_atn(A, T, N),
297
+ "completeness": ("full" if all(v is not None for v in (A, T, N))
298
+ else "partial" if any(v is not None for v in (A, T, N))
299
+ else "none"),
300
+ "cutoffs": ATN_CUTOFFS,
301
+ }
302
+
303
+
304
+ class _ATNModule:
305
+ """Thin shim preserving the old `cs.atn_classification(...)` call shape."""
306
+ atn_classification = staticmethod(atn_classification)
307
+ ATN_CUTOFFS = ATN_CUTOFFS
308
+
309
+
310
+ def _load_clinical_scores():
311
+ """The ATN rules now live in this file; no external dependency."""
312
+ print("[atn] ATN rules built in (NIA-AA 2018)")
313
+ return _ATNModule()
314
+
315
+
316
+ # Model feature name -> the key expected by atn_classification()
317
+ ATN_KEY_MAP = {
318
+ "CSF_ABETA42": "csf_amyloid",
319
+ "CSF_AB42_AB40": "csf_amyloid_42_40_ratio",
320
+ "CSF_PTAU": "csf_ptau",
321
+ "CSF_TAU": "csf_tau",
322
+ }
323
+
324
+
325
+ def render_roi(roi_row, roi_stats) -> list:
326
+ """
327
+ Bölgesel ölçümleri sol/sağ ortalayarak kompakt satırlara döker.
328
+
329
+ Neden ortalama: 18 ayrı satır prompt'u gereksiz uzatıyor ve soft token'ın
330
+ bağlamdaki göreli ağırlığını düşürüyor. AD'de asimetri genelde ana bulgu
331
+ değil; belirgin olduğunda ayrıca not düşülüyor.
332
+
333
+ z-skoru referansı yine EĞİTİM split'inin CN alt grubu — "sağlıklı yaşlıya
334
+ k��yasla" demek, kohort ortalamasına kıyasla demekten klinik olarak
335
+ anlamlı. (Aynı gerekçe biyobelirteçlerde de geçerliydi.)
336
+ """
337
+ lines = []
338
+ for base, (lc, rc, worse_dir, unit) in ROI_PAIRS.items():
339
+ zs = []
340
+ for c in (lc, rc):
341
+ v, st = roi_row.get(c), roi_stats.get(c)
342
+ if v is None or (isinstance(v, float) and np.isnan(v)) or st is None:
343
+ continue
344
+ zs.append(((v - st[0]) / st[1], v))
345
+ if not zs:
346
+ lines.append(f" {base}: ölçülmedi")
347
+ continue
348
+ z = float(np.mean([a for a, _ in zs]))
349
+ val = float(np.mean([b for _, b in zs]))
350
+ if abs(z) < 1.0:
351
+ note = f"sağlıklı kontrollere göre normal aralıkta, {z:+.1f} SD"
352
+ else:
353
+ yon = "yüksek" if z > 0 else "düşük"
354
+ if (worse_dir == "low" and z < 0) or (worse_dir == "high" and z > 0):
355
+ yon += ", bozulma yönünde"
356
+ note = f"{yon}, sağlıklı kontrollere göre {z:+.1f} SD"
357
+ asym = ""
358
+ if len(zs) == 2 and abs(zs[0][0] - zs[1][0]) > 1.0:
359
+ asym = "; sol/sağ asimetri belirgin"
360
+ lines.append(f" {base}: {val:.3f} {unit} ({note}{asym})")
361
+ return lines
362
+
363
+
364
+ # display label -> (left column, right column, which direction is pathological, unit)
365
+ ROI_PAIRS = {
366
+ "Hipokampus": ("Hipokampus (sol)", "Hipokampus (sağ)", "low", "% ICV"),
367
+ "Entorinal korteks": ("Entorinal korteks (sol)", "Entorinal korteks (sağ)", "low", "% ICV"),
368
+ "Entorinal kalınlık": ("Entorinal kalınlık (sol)", "Entorinal kalınlık (sağ)", "low", "mm"),
369
+ "Orta temporal": ("Orta temporal (sol)", "Orta temporal (sağ)", "low", "% ICV"),
370
+ "Fusiform": ("Fusiform (sol)", "Fusiform (sağ)", "low", "% ICV"),
371
+ "Prekuneus": ("Prekuneus (sol)", "Prekuneus (sağ)", "low", "% ICV"),
372
+ "Alt parietal": ("Alt parietal (sol)", "Alt parietal (sağ)", "low", "% ICV"),
373
+ "Lateral ventrikül": ("Lateral ventrikül (sol)", "Lateral ventrikül (sağ)", "high", "% ICV"),
374
+ "Temporal boynuz": ("Alt lateral ventrikül (sol)", "Alt lateral ventrikül (sağ)", "high", "% ICV"),
375
+ }
376
+
377
+
378
+ def compute_atn(cs, names, vals, msk) -> dict:
379
+ """Rule-based ATN profile derived from the biomarkers."""
380
+ bio = {}
381
+ for i, n in enumerate(names):
382
+ key = ATN_KEY_MAP.get(n)
383
+ if key and msk[i] > 0.5:
384
+ bio[key] = float(vals[i])
385
+ return cs.atn_classification(bio)
386
+
387
+
388
+ def build_target_atn2(atn, class_probs, will_progress, class_names) -> str:
389
+ """
390
+ ATN PROMPT'A taşındıktan sonraki kısa hedef.
391
+
392
+ Neden değişti: ATN yorumunu hedefe koyduğumuz turda sadakat %100'den
393
+ %67.5'e düştü. Sebep, tam raporun ilk halindeki hatanın aynısı — ATN
394
+ metni uzun ve biyobelirteç değerlerinden neredeyse tamamen tahmin
395
+ edilebilir olduğu için ortalama kaybı aşağı çekip sınıf token'ının
396
+ sinyalini seyreltti. (Val kaybı 0.0222 ile en düşüktü ama sadakat en
397
+ kötüsüydü; kayıp kolay token'ların ortalamasıydı.)
398
+
399
+ İlke aynı kaldı, kapsamı genişledi: KOD HESAPLAYABİLİYORSA LLM ÜRETMESİN.
400
+ Sayılar gibi ATN de artık girdi tarafında. Hedefte yalnızca token'dan
401
+ okunması gereken şeyler var: sınıf ve risk seviyesi.
402
+ """
403
+ top = class_names[int(np.argmax(class_probs))]
404
+ risk = "yüksek" if will_progress > 0.6 else ("orta" if will_progress > 0.3 else "düşük")
405
+ s = [f"Değerlendirme: {top}."]
406
+ if top == "MCI":
407
+ s.append(f"Alzheimer'a ilerleme riski {risk}.")
408
+ s.append("Değerlendirme, yapısal MRI temsili ile mevcut biyobelirteçlerin "
409
+ "birlikte değerlendirilmesine dayanmaktadır.")
410
+ s.append("Bu çıktı karar desteği amaçlıdır; klinik tanı yerine geçmez.")
411
+ return " ".join(s)
412
+
413
+
414
+ def build_target_atn(cs, class_probs, will_progress, class_names,
415
+ names, vals, msk) -> str:
416
+ """
417
+ RAKAMSIZ hedef + kural tabanlı ATN profili.
418
+
419
+ Neden rakamsız: şablon hedefli önceki koşuda model sayıları da ezberledi —
420
+ üç farklı hastaya aynı "ilerleme riski 0.76" ve aynı eksik ölçüm listesini
421
+ yazdı. Doktor bunu o hastanın değeri sanar. LLM'in ürettiği her sayı,
422
+ uydurabileceği bir sayıdır.
423
+
424
+ Bölüşüm: SAYILAR KODDAN, DÜZYAZI LLM'DEN. Model "risk yüksek" der,
425
+ uygulama kesin değeri (0.80) head'den alıp yerleştirir.
426
+
427
+ ATN ise deterministik hesaplanıp hedefe yazılıyor — böylece model
428
+ biyobelirteç yönünü kendi ön yargısıyla ters çevirmiyor (her koşuda
429
+ tekrarlayan "+1.8 SD yüksek" → "düşük olması patolojiktir" hatası).
430
+ """
431
+ bio = {}
432
+ for i, n in enumerate(names):
433
+ key = ATN_KEY_MAP.get(n)
434
+ if key and msk[i] > 0.5:
435
+ bio[key] = float(vals[i])
436
+ atn = cs.atn_classification(bio)
437
+
438
+ top = class_names[int(np.argmax(class_probs))]
439
+ risk = "yüksek" if will_progress > 0.6 else ("orta" if will_progress > 0.3 else "düşük")
440
+
441
+ s = [f"Değerlendirme: {top}."]
442
+ if top == "MCI":
443
+ s.append(f"Alzheimer'a ilerleme riski {risk}.")
444
+ s.append(atn["interpretation"])
445
+ if atn["completeness"] != "full":
446
+ s.append("Biyobelirteç seti eksik olduğundan ATN sınıflandırması sınırlıdır; "
447
+ "tamamlanması değerlendirmenin kesinliğini artırır.")
448
+ s.append("Değerlendirme, yapısal MRI temsili ile mevcut biyobelirteçlerin "
449
+ "birlikte değerlendirilmesine dayanmaktadır.")
450
+ s.append("Bu çıktı karar desteği amaçlıdır; klinik tanı yerine geçmez.")
451
+ return " ".join(s)
452
+
453
+
454
+ def build_target_minimal(class_probs, class_names) -> str:
455
+ """
456
+ Aşama-1 hizalama hedefi: SADECE sınıf adı.
457
+
458
+ Neden: uzun şablon hedefte sınıf, 24 token'ın içinde tek bir token. LoRA
459
+ şablonu ezberleyerek kaybı düşürebiliyor ve vektörü hiç okumadan 0.118'e
460
+ inebiliyor — 1 Ağustos koşusunda tam olarak bu oldu (kayıp düştü ama
461
+ ablasyonda sınıf 30 vakanın sadece 5'inde değişti, yani sınıf metindeki
462
+ MMSE/CDR-SB'den okunuyordu).
463
+
464
+ Hedefi tek kelimeye indirince kaybın TAMAMI sınıf token'ına biner; model
465
+ şablon ezberleyerek kaçamaz. Kanalın çalışıp çalışmadığının en saf testi.
466
+ Çalıştığı doğrulandıktan sonra tam rapora dönülür.
467
+ """
468
+ return class_names[int(np.argmax(class_probs))]
469
+
470
+
471
+ def build_target(class_probs, will_progress, class_names, names, vals, msk, stats) -> str:
472
+ """
473
+ Şablon hedef rapor. AMAÇ: projector'e "bu soft token şu tanıya karşılık
474
+ geliyor" hizalamasını öğretmek — Gemma'ya tıp öğretmek DEĞİL.
475
+ Bu yüzden bilinçli olarak sade ve deterministik; LLM ile sentezlenmiyor
476
+ (sentezlenirse başka bir modelin varsayımları öğretilmiş olur).
477
+ """
478
+ top_i = int(np.argmax(class_probs))
479
+ top = class_names[top_i]
480
+ conf = class_probs[top_i]
481
+ conf_word = "yüksek" if conf > 0.7 else ("orta" if conf > 0.5 else "düşük")
482
+
483
+ olculen = [READABLE.get(n, n) for i, n in enumerate(names)
484
+ if msk[i] > 0.5 and n not in ("Age", "Sex")]
485
+ eksik = [READABLE.get(n, n) for i, n in enumerate(names) if msk[i] <= 0.5]
486
+
487
+ s = [f"Değerlendirme: {top} ({conf_word} güven, %{100*conf:.0f})."]
488
+ if top == "MCI":
489
+ risk_word = "yüksek" if will_progress > 0.6 else ("orta" if will_progress > 0.3 else "düşük")
490
+ s.append(f"Alzheimer'a ilerleme riski {risk_word} ({will_progress:.2f}).")
491
+ s.append("Değerlendirme, yapısal MRI temsili ile mevcut biyobelirteçlerin "
492
+ "birlikte değerlendirilmesine dayanmaktadır.")
493
+ if eksik:
494
+ s.append("Eksik ölçümler: " + ", ".join(eksik[:4]) +
495
+ ("." if len(eksik) <= 4 else " ve diğerleri."))
496
+ s.append("Bu ölçümlerin tamamlanması değerlendirmenin kesinliğini artırır.")
497
+ s.append("Bu çıktı karar desteği amaçlıdır; klinik tanı yerine geçmez.")
498
+ return " ".join(s)
499
+
500
+
501
+ def main():
502
+ ap = argparse.ArgumentParser()
503
+ ap.add_argument("--features", required=True, help="output of extract_features.py (.pt)")
504
+ ap.add_argument("--out", required=True, help="output .json")
505
+ ap.add_argument("--drop-cognitive-p", type=float, default=0.5,
506
+ help="probability of dropping MMSE/CDRSB during training (0 = never)")
507
+ ap.add_argument("--preview", type=int, default=2)
508
+ ap.add_argument("--roi", default=None,
509
+ help="extract_roi.py ciktisi (.parquet) — bolgesel olcumler")
510
+ ap.add_argument("--atn", action="store_true",
511
+ help="rakamsiz hedef + kural tabanli ATN profili")
512
+ ap.add_argument("--minimal-target", action="store_true",
513
+ help="hedef = sadece sinif adi (asama-1 hizalama testi)")
514
+ ap.add_argument("--hide-diagnosis", action="store_true",
515
+ help="taniyi prompttan cikar, sadece soft tokenda birak")
516
+ ap.add_argument("--seed", type=int, default=42)
517
+ args = ap.parse_args()
518
+
519
+ random.seed(args.seed)
520
+ d = torch.load(args.features, map_location="cpu", weights_only=False)
521
+ names = list(d["feature_names"])
522
+ class_names = list(d["class_names"])
523
+ vals = d["bio_values"].numpy()
524
+ msk = d["bio_mask"].numpy()
525
+ probs = d["class_probs"].numpy()
526
+ wp = d["will_progress"].numpy().reshape(-1)
527
+ splits = list(d["split"])
528
+ ptids = list(d["ptid"])
529
+
530
+ # z-score statistics come ONLY from the training split (no leakage)
531
+ labels_all = d["label"].numpy()
532
+ tr = np.array([s == "train" for s in splits])
533
+ cn_idx = list(class_names).index("CN") if "CN" in class_names else 0
534
+ stats = fit_stats(vals[tr], msk[tr], labels_all[tr], cn_label=cn_idx)
535
+ n_cn = int(((labels_all == cn_idx) & tr).sum())
536
+ from collections import Counter as _C
537
+ refs = _C(v[2] for v in stats.values() if v is not None)
538
+ print(f"[stats] reference distribution: healthy-control subgroup of the "
539
+ f"training split (n={n_cn})")
540
+ print(f" {refs.get('CN', 0)} features referenced to controls, "
541
+ f"{refs.get('kohort', 0)} özellik tüm kohorta göre (CN örneği yetersiz), "
542
+ f"{len(names) - sum(refs.values())} özellik istatistiksiz")
543
+
544
+ cs = _load_clinical_scores() if args.atn else None
545
+
546
+ roi_df = roi_stats = None
547
+ if args.roi:
548
+ import pandas as pd
549
+ roi_df = pd.read_parquet(args.roi).sort_values('order').reset_index(drop=True)
550
+ if len(roi_df) != len(ptids):
551
+ raise ValueError(f"ROI satır sayısı ({len(roi_df)}) özellik cache'i "
552
+ f"({len(ptids)}) ile uyuşmuyor — aynı parquet'ten mi üretildi?")
553
+ if list(roi_df["ptid"].astype(str)) != [str(p) for p in ptids]:
554
+ raise ValueError("ROI ve özellik cache'inde hasta sırası farklı.")
555
+ roi_cols = [c for c in roi_df.columns
556
+ if c not in ('ptid', 'order', '_icv', '_gap_days')]
557
+ # z referansi: EGITIM split'inin CN alt grubu (biyobelirteclerle ayni ilke)
558
+ m_tr_cn = np.array([(s_ == 'train') and (l_ == cn_idx)
559
+ for s_, l_ in zip(splits, labels_all)])
560
+ roi_stats = {}
561
+ for c in roi_cols:
562
+ v = roi_df.loc[m_tr_cn, c].dropna().values if m_tr_cn.sum() else []
563
+ roi_stats[c] = (float(np.mean(v)), float(np.std(v) + 1e-9)) if len(v) >= 20 else None
564
+ n_ok = sum(1 for v in roi_stats.values() if v)
565
+ print(f'[roi] {len(roi_cols)} bolge yuklendi, {n_ok} tanesi CN referansli '
566
+ f'(CN egitim n={int(m_tr_cn.sum())})')
567
+ records = []
568
+ for i in range(len(ptids)):
569
+ # cognitive dropout applies ONLY to training; val/test keep full text
570
+ drop = (splits[i] == "train") and (random.random() < args.drop_cognitive_p)
571
+ atn_i = compute_atn(cs, names, vals[i], msk[i]) if args.atn else None
572
+ roi_i = (render_roi(roi_df.iloc[i].to_dict(), roi_stats)
573
+ if roi_df is not None else None)
574
+ records.append({
575
+ "index": i,
576
+ "ptid": ptids[i],
577
+ "split": splits[i],
578
+ "label": int(d["label"][i]),
579
+ "prompt": build_prompt(names, vals[i], msk[i], stats, probs[i], float(wp[i]),
580
+ class_names, drop_cognitive=drop,
581
+ hide_diagnosis=args.hide_diagnosis,
582
+ minimal_target=args.minimal_target,
583
+ atn_text=(atn_i["interpretation"] if atn_i else None),
584
+ roi_lines=roi_i),
585
+ "target": (build_target_minimal(probs[i], class_names)
586
+ if args.minimal_target else
587
+ build_target_atn2(atn_i, probs[i], float(wp[i]), class_names)
588
+ if args.atn else
589
+ build_target(probs[i], float(wp[i]), class_names,
590
+ names, vals[i], msk[i], stats)),
591
+ "cognitive_dropped": drop,
592
+ })
593
+
594
+ with open(args.out, "w", encoding="utf-8") as f:
595
+ json.dump({"records": records, "class_names": class_names,
596
+ "hide_diagnosis": args.hide_diagnosis,
597
+ "minimal_target": args.minimal_target,
598
+ "feature_names": names, "modality": d.get("modality"),
599
+ "ckpt": d.get("ckpt")}, f, ensure_ascii=False, indent=1)
600
+
601
+ from collections import Counter
602
+ print(f"[saved] {args.out} ({len(records)} records)")
603
+ print(f" split : {Counter(r['split'] for r in records)}")
604
+ print(f" cognitive dropped (train): "
605
+ f"{sum(r['cognitive_dropped'] for r in records)}/{int(tr.sum())}")
606
+
607
+ for r in records[:args.preview]:
608
+ print("\n" + "=" * 68)
609
+ print(f"SAMPLE ptid={r['ptid']} split={r['split']} "
610
+ f"gerçek={class_names[r['label']]} cog_drop={r['cognitive_dropped']}")
611
+ print("-" * 68)
612
+ print(r["prompt"])
613
+ print("-" * 68 + "\nHEDEF:")
614
+ print(r["target"])
615
+ print("=" * 68)
616
+
617
+
618
+ if __name__ == "__main__":
619
+ main()
chat.py ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Two-mode interactive chat
3
+ =========================
4
+ An interactive console where a clinician picks a patient and talks about them.
5
+
6
+ TWO MODES, because measurement showed one configuration cannot do both well:
7
+
8
+ REPORT (LoRA on, thinking off, soft tokens injected)
9
+ The trained closed-set tasks. Faithfulness 39/40, fast, fixed format.
10
+ Reads the diagnosis from the soft tokens — the channel is verified by
11
+ ablation, not assumed.
12
+
13
+ DISCUSS (LoRA off, thinking on, NO soft tokens)
14
+ Open-ended clinical questions. The LoRA model got an atypical case wrong
15
+ ("precuneus/parietal atrophy is consistent with amnestic AD" — it is not);
16
+ the base model answered the same case correctly and with reasoning.
17
+ Soft tokens are unnecessary here: every measurement is already in the text.
18
+
19
+ CONTEXT PRESERVATION — the core of this design:
20
+ Patient data, regional measurements, the ATN profile and Vbai-2.6AD's verdict are
21
+ written into the SYSTEM PROMPT, not into a user message. The system prompt
22
+ stays visible every turn and does not scroll out of context. A question asked
23
+ on turn 10 is still answered against the patient introduced on turn 1.
24
+
25
+ FAITHFULNESS GUARD:
26
+ After every generation the class stated in the text is compared against the
27
+ head's verdict; a mismatch prints a warning. This catches CONTRADICTION WITH
28
+ THE HEAD only — it does not catch general medical errors. That limit is
29
+ permanent.
30
+
31
+ Note on language: the assistant answers in Turkish. The system rules and the
32
+ canonical questions are Turkish by design — that is what the adapter was
33
+ trained on. Only this console's interface is English.
34
+
35
+ Commands:
36
+ /report switch to report mode /discuss switch to discuss mode
37
+ /patient N jump to patient N /data show the system prompt
38
+ /reset clear the conversation /questions list trained questions
39
+ /help command list /quit exit
40
+
41
+ Run:
42
+ python chat.py --features features.pt --text dataset.json \
43
+ --projector projector.pt
44
+ """
45
+ from __future__ import annotations
46
+ import argparse
47
+ import inspect
48
+ import os
49
+ import re
50
+ import sys
51
+
52
+ import torch
53
+
54
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
55
+ from hfx_runtime import Projector, build_examples, SENTINEL
56
+ from eval_faithfulness import find_class
57
+ from eval_thinking import strip_body, split_thought
58
+
59
+ # Kept in Turkish on purpose: this is the prompt the adapter was trained under.
60
+ # Translating it would move the input off the training distribution.
61
+ SYS_RULES = (
62
+ "Sen bir klinik karar destek asistanısın. Kurallar:\n"
63
+ "1. Yukarıdaki hasta verilerinin DIŞINA çıkma; verilmeyen bir bulgu uydurma.\n"
64
+ "2. 'ölçülmedi' yazan bir değeri normal kabul etme; eksik olduğunu söyle.\n"
65
+ "3. Vbai-2.6AD sınıflandırmasını DEĞİŞTİRME. Katılmadığın noktayı belirtebilirsin "
66
+ "ama sınıflandırma bu değerlendirmenin çıpasıdır.\n"
67
+ "4. Doktor itiraz ettiğinde fikrini kanıtsız değiştirme; gerekçeni göster.\n"
68
+ "5. Bu çıktı karar desteğidir, tanı yerine geçmez.\n"
69
+ "Yanıtlarını TÜRKÇE ver."
70
+ )
71
+
72
+
73
+ def build_system(ex, head: str, class_names, probs=None, will_progress=None) -> str:
74
+ """
75
+ Patient context + anchor. Lives in the system prompt, visible every turn.
76
+
77
+ THE HEAD'S OUTPUTS ARE WRITTEN HERE AS NUMBERS.
78
+ Why: every digit was stripped from the training targets (the model had
79
+ memorized "0.76" and was writing it for every patient). So the model CANNOT
80
+ produce numbers — and should not. But Vbai-2.6AD genuinely computes them, so
81
+ they are placed in the text for the model to read out when asked. This is
82
+ the "numbers from code, prose from the LLM" principle in practice.
83
+ """
84
+ lines = [f"Vbai-2.6AD değerlendirmesi: {head}"]
85
+ if probs is not None:
86
+ lines.append(" Sınıf olasılıkları: " +
87
+ ", ".join(f"{c} %{100*p:.1f}" for c, p in zip(class_names, probs)))
88
+ if will_progress is not None:
89
+ if head == "MCI":
90
+ lines.append(f" MCI→AD ilerleme riski (5 yıl): {float(will_progress):.2f}")
91
+ else:
92
+ # Vbai-2.6AD README: the progression head is only meaningful for MCI;
93
+ # a score is produced for CN/AD but must not be interpreted.
94
+ lines.append(" İlerleme riski: yalnızca MCI vakalarında anlamlıdır, "
95
+ "bu vakada raporlanmaz.")
96
+ return (strip_body(ex["prompt"]).replace(SENTINEL, "").strip() +
97
+ "\n\n" + "\n".join(lines) + "\n\n" + SYS_RULES)
98
+
99
+
100
+ @torch.no_grad()
101
+ def generate(model, tok, embed_layer, projector, ex, device, supported,
102
+ messages, mode: str, max_new_tokens: int):
103
+ use_lora = (mode == "report")
104
+ thinking = (mode == "discuss")
105
+
106
+ kw = dict(tokenize=False, add_generation_prompt=True)
107
+ try:
108
+ full = tok.apply_chat_template(messages, enable_thinking=thinking, **kw)
109
+ except TypeError:
110
+ full = tok.apply_chat_template(messages, **kw)
111
+
112
+ ids = tok(full, add_special_tokens=False, return_tensors="pt").input_ids.to(device)
113
+ embeds = embed_layer(ids)
114
+ off = n_soft = 0
115
+ if use_lora:
116
+ # In report mode the soft tokens are prepended (the position the LoRA
117
+ # learned to read them from). In discuss mode the base model cannot read
118
+ # them, so they are omitted.
119
+ soft = projector(ex["feat"].unsqueeze(0).to(device)).to(embeds.dtype)
120
+ embeds = torch.cat([soft, embeds], dim=1)
121
+ n_soft = soft.size(1)
122
+
123
+ attn = torch.ones(embeds.shape[:2], dtype=torch.long, device=device)
124
+ gk = {"inputs_embeds": embeds, "attention_mask": attn,
125
+ "max_new_tokens": max_new_tokens, "do_sample": False,
126
+ "repetition_penalty": 1.1}
127
+ if n_soft and "mm_token_type_ids" in supported:
128
+ mm = torch.zeros(embeds.shape[:2], dtype=torch.long, device=device)
129
+ mm[0, off:off + n_soft] = 1
130
+ gk["mm_token_type_ids"] = mm
131
+
132
+ ctx = model.disable_adapter() if (not use_lora and hasattr(model, "disable_adapter")) \
133
+ else _null()
134
+ with ctx:
135
+ out = model.generate(**gk)
136
+ # generate() with inputs_embeds returns ONLY the new tokens; hitting the
137
+ # ceiling exactly means the output was cut off, not naturally finished.
138
+ truncated = out.shape[1] >= max_new_tokens
139
+ return tok.decode(out[0], skip_special_tokens=False), truncated
140
+
141
+
142
+ def trained_questions() -> list:
143
+ """
144
+ The canonical questions report mode was trained on — read from the dataset
145
+ generator rather than copied by hand, so the two cannot drift apart.
146
+
147
+ Why a menu: LoRA learns PHRASE PATTERNS, not task boundaries. A free-typed
148
+ question landing between trained patterns gets answered with a blend of
149
+ them — one run produced both "atrophy is marked" and "all values are within
150
+ the normal range" in a single answer, and invented an anchor-undermining
151
+ line ("the Vbai-2.6AD assessment can be changed"). Keep the input inside the
152
+ distribution and blending cannot physically occur.
153
+
154
+ The questions themselves stay Turkish: they are the training strings.
155
+ """
156
+ try:
157
+ from build_multitask_dataset import TASKS, ABSENT_PROBES
158
+ except Exception:
159
+ return []
160
+ out, dummy = [], (None, None, None, None, [])
161
+ for name, fn in TASKS.items():
162
+ if name == "absent":
163
+ out.append((name, ABSENT_PROBES[0][0]))
164
+ continue
165
+ try:
166
+ q, _ = fn(*dummy) if name != "probs" else fn(
167
+ "MCI", "yüksek", None, None, [], probs=[0.1, 0.8, 0.1],
168
+ class_names=["CN", "MCI", "AD"], will_progress=0.5)
169
+ out.append((name, q))
170
+ except Exception:
171
+ continue
172
+ return out
173
+
174
+
175
+ def find_verdict(text: str, class_names) -> str | None:
176
+ """
177
+ Return the class only when it appears in an EXPLICIT VERDICT pattern, not
178
+ merely the first class name occurring in the text. Long discussion answers
179
+ enumerate differentials, and those mentions must not be mistaken for a
180
+ verdict.
181
+ """
182
+ lab = (r"(?:Değerlendirme|Sınıflandırma|Sonuç|Tanı|Vbai-2.6AD[^:\n]*|"
183
+ r"Classification|Assessment|Diagnosis)")
184
+ m = re.search(lab + r"[^\n:]*:\s*\**\s*(" +
185
+ "|".join(map(re.escape, class_names)) + r")\b",
186
+ text, flags=re.IGNORECASE)
187
+ if not m:
188
+ return None
189
+ for c in class_names:
190
+ if c.lower() == m.group(1).lower():
191
+ return c
192
+ return None
193
+
194
+
195
+ def clean(t: str) -> str:
196
+ """Hide control tokens — they arrive because skip_special_tokens=False."""
197
+ for m in ("<eos>", "<turn|>", "<|turn>", "<bos>", "<end_of_turn>"):
198
+ t = t.replace(m, "")
199
+ return t.strip()
200
+
201
+
202
+ class _null:
203
+ def __enter__(self): return None
204
+ def __exit__(self, *a): return False
205
+
206
+
207
+ def main():
208
+ ap = argparse.ArgumentParser()
209
+ ap.add_argument("--features", required=True)
210
+ ap.add_argument("--text", required=True)
211
+ ap.add_argument("--projector", default="projector.pt",
212
+ help="projector checkpoint; the LoRA adapter is expected "
213
+ "alongside it at <projector>_lora/")
214
+ ap.add_argument("--model", default=None)
215
+ ap.add_argument("--split", default="test")
216
+ ap.add_argument("--patient", type=int, default=0,
217
+ help="index of the patient within the split")
218
+ ap.add_argument("--mode", default="report", choices=["report", "discuss"])
219
+ # Budget per mode: in discuss mode the chain of thought alone eats 1000+
220
+ # tokens and left no room for the answer. Report answers are short.
221
+ ap.add_argument("--max-new-report", type=int, default=500)
222
+ ap.add_argument("--max-new-discuss", type=int, default=2500)
223
+ ap.add_argument("--no-4bit", dest="four_bit", action="store_false", default=True)
224
+ args = ap.parse_args()
225
+
226
+ import transformers
227
+ from transformers import AutoTokenizer
228
+ AutoCls = next(getattr(transformers, n) for n in
229
+ ("AutoModelForConditionalGeneration", "AutoModelForImageTextToText",
230
+ "AutoModelForCausalLM") if hasattr(transformers, n))
231
+
232
+ device = "cuda" if torch.cuda.is_available() else "cpu"
233
+ ck = torch.load(args.projector, map_location="cpu", weights_only=False)
234
+ model_id = args.model or ck["model_id"]
235
+ data, d = build_examples(args.features, args.text)
236
+ class_names = list(d["class_names"])
237
+
238
+ tok = AutoTokenizer.from_pretrained(model_id)
239
+ load_kw = dict(device_map={"": 0} if device == "cuda" else None)
240
+ if args.four_bit:
241
+ from transformers import BitsAndBytesConfig
242
+ load_kw["quantization_config"] = BitsAndBytesConfig(
243
+ load_in_4bit=True, bnb_4bit_quant_type="nf4",
244
+ bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True)
245
+ try:
246
+ model = AutoCls.from_pretrained(model_id, dtype=torch.bfloat16, **load_kw)
247
+ except TypeError:
248
+ model = AutoCls.from_pretrained(model_id, torch_dtype=torch.bfloat16, **load_kw)
249
+ supported = set(inspect.signature(model.forward).parameters)
250
+ embed_layer = model.get_input_embeddings()
251
+
252
+ if ck.get("lora") and os.path.isdir(args.projector + "_lora"):
253
+ from peft import PeftModel
254
+ model = PeftModel.from_pretrained(model, args.projector + "_lora")
255
+ model.eval()
256
+
257
+ projector = Projector(ck["in_dim"], ck["hidden"], ck["n_tokens"],
258
+ target_norm=ck.get("target_norm")).to(device)
259
+ projector.load_state_dict(ck["projector"]); projector.eval()
260
+
261
+ # De-duplicate by patient (the multitask file holds several records each)
262
+ seen, patients = set(), []
263
+ for e in data[args.split]:
264
+ if e["ptid"] not in seen:
265
+ seen.add(e["ptid"]); patients.append(e)
266
+
267
+ probs_all = d["class_probs"]
268
+ wp_all = d["will_progress"].reshape(-1)
269
+
270
+ def mk_system(e):
271
+ i = e.get("index")
272
+ return build_system(e, e["head"], class_names,
273
+ probs_all[i].tolist() if i is not None else None,
274
+ float(wp_all[i]) if i is not None else None)
275
+
276
+ idx, mode = args.patient, args.mode
277
+ ex = patients[idx]
278
+ system = mk_system(ex)
279
+ messages = [{"role": "system", "content": system}]
280
+
281
+ tq = trained_questions()
282
+
283
+ def show_questions():
284
+ if not tq:
285
+ print(" (could not read the trained question list)"); return
286
+ print("\n Questions report mode was trained on — ask by number:")
287
+ for k, (name, qq) in enumerate(tq, 1):
288
+ print(f" {k}. [{name}] {qq}")
289
+ print(" Free text is allowed but falls outside the distribution in "
290
+ "report mode.")
291
+
292
+ print(f"\n{'='*70}\nPatient {ex['ptid']} | Vbai-2.6AD: {ex['head']} | "
293
+ f"true: {class_names[ex['label']]} | mode: {mode}")
294
+ print(f"{len(patients)} patients loaded. /help for commands.\n{'='*70}")
295
+ show_questions()
296
+
297
+ while True:
298
+ try:
299
+ q = input(f"\n[{mode}] clinician> ").strip()
300
+ except (EOFError, KeyboardInterrupt):
301
+ break
302
+ if not q:
303
+ continue
304
+ if q in ("/quit", "/exit"):
305
+ break
306
+ if q == "/help":
307
+ print(" /report /discuss /patient N /data /reset /questions /quit")
308
+ print(" in report mode you can ask a question BY NUMBER (1-8)")
309
+ continue
310
+ if q == "/questions":
311
+ show_questions(); continue
312
+ if q in ("/report", "/discuss"):
313
+ mode = q[1:]
314
+ print(f" → mode: {mode}" +
315
+ (" (LoRA on, thinking off, soft tokens injected)" if mode == "report"
316
+ else " (LoRA off, thinking on, reasoning from the base model)"))
317
+ continue
318
+ if q.startswith("/patient"):
319
+ try:
320
+ idx = int(q.split()[1]) % len(patients)
321
+ except (IndexError, ValueError):
322
+ print(" usage: /patient 3"); continue
323
+ ex = patients[idx]
324
+ system = mk_system(ex)
325
+ messages = [{"role": "system", "content": system}]
326
+ print(f" → patient {ex['ptid']} Vbai-2.6AD: {ex['head']} "
327
+ f"true: {class_names[ex['label']]} (conversation reset)")
328
+ continue
329
+ if q == "/data":
330
+ print("\n" + system); continue
331
+ if q == "/reset":
332
+ messages = [{"role": "system", "content": system}]
333
+ print(" → conversation reset (patient context preserved)"); continue
334
+
335
+ # Report mode: a number selects a canonical question. The input stays
336
+ # inside the distribution, so pattern blending cannot occur. Free text
337
+ # is permitted but warned about.
338
+ if mode == "report" and tq:
339
+ if q.isdigit() and 1 <= int(q) <= len(tq):
340
+ task_name, q = tq[int(q) - 1]
341
+ print(f" → [{task_name}] {q}")
342
+ elif not q.startswith("/"):
343
+ print(" ⚠ This question is outside the trained patterns; report "
344
+ "mode answers may blend. Use /questions for the list, or "
345
+ "switch to /discuss.")
346
+
347
+ messages.append({"role": "user", "content": q})
348
+ budget = args.max_new_report if mode == "report" else args.max_new_discuss
349
+ raw, truncated = generate(model, tok, embed_layer, projector, ex, device,
350
+ supported, messages, mode, budget)
351
+ thought, answer = split_thought(raw)
352
+ answer = clean(answer)
353
+
354
+ if thought:
355
+ print(f"\n [reasoning, {len(thought)} chars — hidden]")
356
+ print("\nassistant> " + answer)
357
+ if truncated:
358
+ print(f"\n ⚠ Answer was CUT OFF at the {budget}-token limit — this is "
359
+ f"not the model's natural stop. Ask something shorter or raise "
360
+ f"--max-new-{mode}.")
361
+
362
+ # --- faithfulness guard: does the stated class contradict the head? ---
363
+ #
364
+ # In discuss mode ONLY the labelled verdict pattern is searched
365
+ # ("Değerlendirme: X", "Classification: X"). The first-class-mentioned
366
+ # fallback produced false alarms here: while walking through
367
+ # differentials the model would write "Early-Onset AD" and the guard
368
+ # read AD as a verdict. A guard that cries wolf gets ignored when a real
369
+ # contradiction appears.
370
+ said = find_verdict(answer, class_names) if mode == "discuss" \
371
+ else find_class(answer, class_names)
372
+ if said and said != ex["head"]:
373
+ print(f"\n ⚠ WARNING: the model said '{said}' while Vbai-2.6AD says "
374
+ f"'{ex['head']}'. This contradiction needs review.")
375
+
376
+ messages.append({"role": "assistant", "content": answer})
377
+ # keep context bounded: system + last 8 messages
378
+ if len(messages) > 9:
379
+ messages = [messages[0]] + messages[-8:]
380
+
381
+ print("\nexited.")
382
+
383
+
384
+ if __name__ == "__main__":
385
+ main()
config.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Vbai-2.6AD — configuration.
3
+
4
+ Paired multimodal early-Alzheimer's detection: a 3D MRI volume plus a panel of
5
+ 13 biomarkers, fused into one representation.
6
+
7
+ --------------------------------------------------------------------------
8
+ YOU MUST SET YOUR OWN PATHS.
9
+ --------------------------------------------------------------------------
10
+ No data location is hard-coded. Point the environment variables below at your
11
+ own files before running anything:
12
+
13
+ VBAI_DATASET_ROOT root of your imaging + tabular data
14
+ VBAI_VOLUME_ROOT root of the volume files referenced by the manifest
15
+ VBAI_MODEL_SAVE_ROOT where checkpoints are written
16
+
17
+ If a variable is unset, the loader walks up the project tree looking for a
18
+ `Datasets` directory. If that fails too, the data-preparation step raises an
19
+ explicit error rather than guessing.
20
+
21
+ Nothing here describes or names a particular corpus. Bring your own data; the
22
+ expected column contract is FEATURE_NAMES below.
23
+ """
24
+ import os
25
+ from dataclasses import dataclass, field
26
+ from typing import List
27
+
28
+ PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
29
+
30
+
31
+ def _walk_up_for_marker(start: str, marker_subdirs=("Datasets",), max_levels: int = 6):
32
+ """Walk upwards from `start` until a directory containing all markers is found."""
33
+ cur = os.path.abspath(start)
34
+ for _ in range(max_levels):
35
+ if all(os.path.isdir(os.path.join(cur, m)) for m in marker_subdirs):
36
+ return cur
37
+ parent = os.path.dirname(cur)
38
+ if parent == cur:
39
+ break
40
+ cur = parent
41
+ return None
42
+
43
+
44
+ def _resolve_dataset_root() -> str:
45
+ """Resolution order: environment variable → project-tree search → default."""
46
+ env = os.environ.get("VBAI_DATASET_ROOT")
47
+ if env and os.path.isdir(env):
48
+ return env
49
+
50
+ walked = _walk_up_for_marker(PROJECT_ROOT, marker_subdirs=("Datasets",))
51
+ if walked is not None:
52
+ return os.path.join(walked, "Datasets")
53
+
54
+ # Fall through to a relative default; data preparation will report clearly
55
+ # if nothing is there. SET VBAI_DATASET_ROOT TO YOUR OWN PATH.
56
+ return os.path.normpath(os.path.join(PROJECT_ROOT, "..", "Datasets"))
57
+
58
+
59
+ def _resolve_model_save_root() -> str:
60
+ env = os.environ.get("VBAI_MODEL_SAVE_ROOT")
61
+ if env:
62
+ return env
63
+ walked = _walk_up_for_marker(PROJECT_ROOT, marker_subdirs=("Datasets",))
64
+ if walked is not None:
65
+ return os.path.join(walked, "Models", "Vbai-2.6AD")
66
+ return os.path.normpath(os.path.join(PROJECT_ROOT, "..", "Models", "Vbai-2.6AD"))
67
+
68
+
69
+ DATASET_ROOT = _resolve_dataset_root()
70
+ MODEL_SAVE_ROOT = _resolve_model_save_root()
71
+
72
+ # Root of the volume files. The visit manifest stores relative paths; this is
73
+ # what they are resolved against. SET VBAI_VOLUME_ROOT TO YOUR OWN PATH.
74
+ VOLUME_ROOT = os.environ.get("VBAI_VOLUME_ROOT") or os.path.join(DATASET_ROOT, "volumes")
75
+
76
+ # Kept for backward compatibility with scripts that expect these names.
77
+ TBM_ROOT = VOLUME_ROOT
78
+ TBM_CSV = os.environ.get("VBAI_VOLUME_MANIFEST") or os.path.join(VOLUME_ROOT, "manifest.csv")
79
+
80
+ # Which volume modality is in use. Selected by the extraction scripts through
81
+ # the --tbm / --t1 flag, which sets this variable before config is imported.
82
+ # A checkpoint trained on one modality must never be fed the other.
83
+ USE_TBM = bool(int(os.environ.get("VBAI_USE_TBM", "0")))
84
+
85
+ CACHE_DIR = os.path.join(PROJECT_ROOT, "_cache")
86
+ os.makedirs(CACHE_DIR, exist_ok=True)
87
+ PAIRED_PARQUET_T1 = os.path.join(CACHE_DIR, "paired_visits.parquet")
88
+ PAIRED_PARQUET_TBM = os.path.join(CACHE_DIR, "paired_visits_tbm.parquet")
89
+ PAIRED_PARQUET = PAIRED_PARQUET_TBM if USE_TBM else PAIRED_PARQUET_T1
90
+
91
+ # Tabular feature order — a fixed contract relied on everywhere downstream.
92
+ # Your table must provide these columns (missing values are allowed and are
93
+ # handled explicitly through a per-feature mask; see NUM_TABULAR_INPUTS).
94
+ FEATURE_NAMES: List[str] = [
95
+ "Age", # demographic
96
+ "Sex", # 0 = F, 1 = M
97
+ "MMSE", # cognitive
98
+ "CDRSB", # cognitive (CDR sum of boxes)
99
+ "APOE4_count", # genetic, 0/1/2 e4 alleles
100
+ "CSF_ABETA42", # CSF
101
+ "CSF_TAU", # CSF
102
+ "CSF_PTAU", # CSF
103
+ "CSF_AB42_AB40", # CSF ratio
104
+ "PLASMA_PTAU", # blood
105
+ "PLASMA_NFL", # blood
106
+ "PLASMA_AB42_AB40", # blood ratio
107
+ "PLASMA_GFAP", # blood
108
+ ]
109
+ NUM_FEATURES = len(FEATURE_NAMES) # 13
110
+ # One value plus one missing-mask bit per feature. The mask is not decoration:
111
+ # an unmeasured biomarker must stay distinguishable from a normal one.
112
+ NUM_TABULAR_INPUTS = NUM_FEATURES * 2
113
+
114
+ CLASS_NAMES = ["CN", "MCI", "AD"]
115
+ DIAGNOSIS_MAP = {"CN": 0, "MCI": 1, "Dementia": 2, "AD": 2,
116
+ "EMCI": 1, "LMCI": 1, "SMC": 0}
117
+
118
+
119
+ @dataclass
120
+ class ModelConfig:
121
+ mri_input_shape: tuple = (1, 96, 96, 96)
122
+ mri_encoder_channels: List[int] = field(default_factory=lambda: [32, 64, 128, 256])
123
+ mri_bottleneck_channels: int = 512
124
+ mri_feature_dim: int = 512
125
+ mri_dropout: float = 0.4
126
+ use_cbam: bool = True
127
+ use_se_block: bool = True
128
+
129
+ num_tabular_inputs: int = NUM_TABULAR_INPUTS
130
+ tabular_hidden_dims: List[int] = field(default_factory=lambda: [128, 256])
131
+ tabular_feature_dim: int = 256
132
+ tabular_dropout: float = 0.3
133
+
134
+ fusion_dim: int = 512
135
+ fusion_num_heads: int = 8
136
+ fusion_dropout: float = 0.3
137
+
138
+ num_classes: int = 3
139
+ progression_hidden_dim: int = 256
140
+ max_progression_months: int = 120
141
+ num_time_bins: int = 24
142
+
143
+ # Modality dropout during training: teaches the model to survive a missing
144
+ # arm at inference instead of collapsing.
145
+ p_drop_mri: float = 0.15
146
+ p_drop_tab: float = 0.15
147
+ # Feature-wise random masking, simulating biomarkers absent at inference.
148
+ p_feature_mask: float = 0.20
149
+
150
+
151
+ @dataclass
152
+ class TrainingConfig:
153
+ seed: int = 42
154
+ device: str = "cuda"
155
+ num_workers: int = 4
156
+ pin_memory: bool = True
157
+ mixed_precision: bool = True
158
+
159
+ # Phase 1 — MRI encoder pretraining
160
+ phase1_epochs: int = 40
161
+ phase1_batch_size: int = 4
162
+ phase1_lr: float = 3e-4
163
+ phase1_weight_decay: float = 1e-4
164
+
165
+ # Phase 2 — tabular encoder pretraining
166
+ phase2_epochs: int = 60
167
+ phase2_batch_size: int = 64
168
+ phase2_lr: float = 1e-3
169
+ phase2_weight_decay: float = 1e-4
170
+
171
+ # Phase 3 — joint fusion on paired visits
172
+ phase3_epochs: int = 40
173
+ phase3_batch_size: int = 4
174
+ phase3_lr_backbone: float = 1e-5
175
+ phase3_lr_fusion: float = 5e-4
176
+ phase3_weight_decay: float = 1e-4
177
+
178
+ # Loss weights
179
+ w_cls_fused: float = 1.0
180
+ w_cls_mri: float = 0.3
181
+ w_cls_tab: float = 0.3
182
+ w_prog: float = 0.5
183
+ w_contrastive: float = 0.2
184
+ focal_gamma: float = 1.0
185
+ label_smoothing: float = 0.05
186
+
187
+ grad_clip: float = 1.0
188
+ val_split: float = 0.15
189
+ test_split: float = 0.15 # subject-level holdout, never visit-level
190
+ early_stopping_patience: int = 20
191
+ min_epochs_before_es: int = 25
192
+
193
+ save_dir: str = MODEL_SAVE_ROOT
194
+
195
+
196
+ @dataclass
197
+ class DataConfig:
198
+ nifti_target_shape: tuple = (96, 96, 96)
199
+ pair_window_months: int = 6 # MRI ↔ biomarker date tolerance
200
+ progression_horizon_months: int = 60 # 5-year look-ahead for MCI → AD
201
+ aug_rotation_range: float = 8.0
202
+ aug_flip_prob: float = 0.5
203
+ aug_noise_std: float = 0.02
204
+ aug_gamma_range: tuple = (0.85, 1.15)
205
+
206
+ # Optional hippocampus-focused crop: the brain bounding box is found, then
207
+ # a centre crop is taken at these ratios and resized to nifti_target_shape.
208
+ # x: left-right (both hemispheres), y: anterior-posterior, z: inferior-superior
209
+ hippocampus_crop_enabled: bool = False
210
+ hippo_x_range: tuple = (0.10, 0.90)
211
+ hippo_y_range: tuple = (0.25, 0.70)
212
+ hippo_z_range: tuple = (0.15, 0.65)
dataset.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Vbai-2.6AD Datasets
3
+ ===================
4
+ Reads the cached visit manifest at _cache/paired_visits.parquet, which your own
5
+ data-preparation step must produce. One row per visit: a volume path, the 13
6
+ biomarker columns with their per-feature masks, the label and the progression
7
+ fields.
8
+
9
+ Three dataset modes:
10
+ * mode="mri" → MRI + label (Phase 1 pretrain)
11
+ * mode="tab" → tabular features + label (Phase 2 pretrain)
12
+ * mode="multi" → MRI + tabular + label + progression (Phase 3 fusion)
13
+
14
+ Tabular feature contract: 2 * NUM_FEATURES floats per sample.
15
+ [normalized values..., missing-mask bits...]
16
+ A feature with missing-mask=0 has its value zeroed (after normalization).
17
+ """
18
+ from __future__ import annotations
19
+ import os
20
+ import random
21
+ import numpy as np
22
+ import pandas as pd
23
+ import torch
24
+ from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler
25
+ from scipy.ndimage import zoom, rotate
26
+
27
+ import config as C
28
+
29
+ try:
30
+ import nibabel as nib
31
+ HAS_NIBABEL = True
32
+ except Exception:
33
+ HAS_NIBABEL = False
34
+
35
+
36
+ # Optional pre-decoded .npy cache, searched in order. Decoding NIfTI is the
37
+ # slowest part of an epoch, so a local-disk cache pays for itself quickly.
38
+ # YOU MUST SET YOUR OWN PATH: point VBAI_NPY_CACHE at a fast local directory,
39
+ # or leave it unset to use the in-project cache.
40
+ _NPY_CACHE_DIRS = [d for d in [
41
+ os.environ.get("VBAI_NPY_CACHE"),
42
+ os.path.join(C.PROJECT_ROOT, "_cache", "volume_npy"),
43
+ ] if d]
44
+
45
+
46
+ def _try_load_cached(image_id: str) -> np.ndarray | None:
47
+ if not image_id:
48
+ return None
49
+ for d in _NPY_CACHE_DIRS:
50
+ p = os.path.join(d, f"{image_id}.npy")
51
+ if os.path.exists(p):
52
+ try:
53
+ return np.load(p).astype(np.float32)
54
+ except Exception:
55
+ return None
56
+ return None
57
+
58
+
59
+ # ---------- MRI loading + augmentation ----------
60
+ def _hippocampus_crop(data: np.ndarray, dcfg: C.DataConfig) -> np.ndarray:
61
+ """Find the brain bounding box, then crop to a hippocampus-focused sub-region."""
62
+ mask = data > 0
63
+ if not mask.any():
64
+ return data
65
+ coords = np.argwhere(mask)
66
+ mn = coords.min(axis=0); mx = coords.max(axis=0)
67
+ size = mx - mn + 1
68
+ rx, ry, rz = dcfg.hippo_x_range, dcfg.hippo_y_range, dcfg.hippo_z_range
69
+ x0, x1 = int(mn[0] + size[0] * rx[0]), int(mn[0] + size[0] * rx[1])
70
+ y0, y1 = int(mn[1] + size[1] * ry[0]), int(mn[1] + size[1] * ry[1])
71
+ z0, z1 = int(mn[2] + size[2] * rz[0]), int(mn[2] + size[2] * rz[1])
72
+ cropped = data[x0:x1+1, y0:y1+1, z0:z1+1]
73
+ return cropped
74
+
75
+
76
+ def _load_nifti(path: str, target_shape=(96, 96, 96),
77
+ hippocampus_crop: bool = False, dcfg: C.DataConfig = None) -> np.ndarray:
78
+ img = nib.load(path)
79
+ data = img.get_fdata().astype(np.float32)
80
+ if data.ndim == 4:
81
+ data = data[..., 0]
82
+ mask = data > 0
83
+ if mask.sum() > 0:
84
+ vals = data[mask]
85
+ lo, hi = np.percentile(vals, [1.0, 99.0])
86
+ data = np.clip(data, lo, hi)
87
+ m, s = vals.mean(), vals.std()
88
+ if s > 0:
89
+ data = (data - m) / s
90
+ data[~mask] = 0
91
+ # Hippocampus-focused crop: narrows the content, which raises effective resolution
92
+ if hippocampus_crop:
93
+ data = _hippocampus_crop(data, dcfg or C.DataConfig())
94
+ if data.shape != target_shape:
95
+ f = [t / s for t, s in zip(target_shape, data.shape)]
96
+ data = zoom(data, f, order=1)
97
+ return data.astype(np.float32)
98
+
99
+
100
+ class MRIAugment3D:
101
+ def __init__(self, dcfg: C.DataConfig):
102
+ self.cfg = dcfg
103
+
104
+ def __call__(self, vol: np.ndarray) -> np.ndarray:
105
+ if random.random() < 0.5:
106
+ angle = random.uniform(-self.cfg.aug_rotation_range, self.cfg.aug_rotation_range)
107
+ axes = random.choice([(0, 1), (0, 2), (1, 2)])
108
+ vol = rotate(vol, angle, axes=axes, reshape=False, order=1, mode="nearest")
109
+ for ax in range(3):
110
+ if random.random() < self.cfg.aug_flip_prob:
111
+ vol = np.flip(vol, axis=ax).copy()
112
+ if random.random() < 0.3:
113
+ vol = vol + np.random.normal(0, self.cfg.aug_noise_std, vol.shape).astype(np.float32)
114
+ if random.random() < 0.3:
115
+ g = random.uniform(*self.cfg.aug_gamma_range)
116
+ mn = vol.min(); rg = vol.max() - mn
117
+ if rg > 0:
118
+ vol = ((vol - mn) / rg) ** g * rg + mn
119
+ return vol.astype(np.float32)
120
+
121
+
122
+ # ---------- Tabular normalization ----------
123
+ class TabularNormalizer:
124
+ """Robust z-score on observed (non-missing) values per feature, fit on training set."""
125
+ def __init__(self):
126
+ self.mean: np.ndarray | None = None
127
+ self.std: np.ndarray | None = None
128
+
129
+ def fit(self, df: pd.DataFrame):
130
+ means, stds = [], []
131
+ for f in C.FEATURE_NAMES:
132
+ v = pd.to_numeric(df[f], errors="coerce").dropna().values.astype(np.float64)
133
+ if len(v) > 1:
134
+ m = float(np.median(v))
135
+ s = float(np.median(np.abs(v - m)) * 1.4826) # MAD → std
136
+ if s < 1e-8:
137
+ s = float(v.std()) if v.std() > 1e-8 else 1.0
138
+ else:
139
+ m, s = 0.0, 1.0
140
+ means.append(m); stds.append(s)
141
+ self.mean = np.asarray(means, dtype=np.float32)
142
+ self.std = np.asarray(stds, dtype=np.float32)
143
+
144
+ def transform(self, values: np.ndarray, mask: np.ndarray) -> np.ndarray:
145
+ z = (values - self.mean) / self.std
146
+ z = np.where(mask > 0.5, z, 0.0) # zero out missing
147
+ return np.concatenate([z, mask.astype(np.float32)], axis=-1)
148
+
149
+ def state_dict(self):
150
+ return {"mean": self.mean.tolist() if self.mean is not None else None,
151
+ "std": self.std.tolist() if self.std is not None else None}
152
+
153
+ def load_state_dict(self, sd):
154
+ self.mean = np.asarray(sd["mean"], dtype=np.float32)
155
+ self.std = np.asarray(sd["std"], dtype=np.float32)
156
+
157
+
158
+ # ---------- Subject-level split (no leakage between train/val/test) ----------
159
+ def subject_split(df: pd.DataFrame, val_frac=0.15, test_frac=0.15, seed=42):
160
+ rng = np.random.RandomState(seed)
161
+ ptids = np.array(sorted(df["ptid"].unique()))
162
+ rng.shuffle(ptids)
163
+ n = len(ptids)
164
+ n_test = int(round(n * test_frac))
165
+ n_val = int(round(n * val_frac))
166
+ test_ids = set(ptids[:n_test])
167
+ val_ids = set(ptids[n_test:n_test + n_val])
168
+ train_ids = set(ptids[n_test + n_val:])
169
+ print(f"[split] subjects → train {len(train_ids)} / val {len(val_ids)} / test {len(test_ids)}")
170
+ return train_ids, val_ids, test_ids
171
+
172
+
173
+ # ---------- Core paired dataset ----------
174
+ class PairedVisitDataset(Dataset):
175
+ """
176
+ One sample = one MRI scan with paired biomarkers + (optional) progression labels.
177
+ Setting mode controls which fields are loaded:
178
+ "mri" — only mri + label (skips biomarker columns)
179
+ "tab" — only biomarkers + label (skips MRI loading)
180
+ "multi" — both
181
+ """
182
+ def __init__(self, df: pd.DataFrame, normalizer: TabularNormalizer,
183
+ mode: str = "multi", augment: bool = False,
184
+ dcfg: C.DataConfig = None, mcfg: C.ModelConfig = None,
185
+ train_modality_dropout: bool = False):
186
+ self.df = df.reset_index(drop=True).copy()
187
+ self.norm = normalizer
188
+ self.mode = mode
189
+ self.augment = augment
190
+ self.dcfg = dcfg or C.DataConfig()
191
+ self.mcfg = mcfg or C.ModelConfig()
192
+ self.augmenter = MRIAugment3D(self.dcfg) if augment else None
193
+ self.modality_dropout = train_modality_dropout
194
+
195
+ def __len__(self):
196
+ return len(self.df)
197
+
198
+ def _get_tab(self, row, training: bool):
199
+ vals = np.array([row[f] for f in C.FEATURE_NAMES], dtype=np.float32)
200
+ mask = np.array([row[f"feat_mask_{f}"] for f in C.FEATURE_NAMES], dtype=np.float32)
201
+ # NaN safety
202
+ vals = np.where(np.isnan(vals), 0.0, vals)
203
+ # Stochastic feature masking during training (simulate missing inputs)
204
+ if training and self.mcfg.p_feature_mask > 0:
205
+ drop = np.random.rand(len(C.FEATURE_NAMES)) < self.mcfg.p_feature_mask
206
+ mask = np.where(drop, 0.0, mask)
207
+ return self.norm.transform(vals, mask).astype(np.float32)
208
+
209
+ def _get_mri(self, row):
210
+ # Fast path: pre-decoded .npy on local disk
211
+ vol = _try_load_cached(row.get("image_id"))
212
+ if vol is None:
213
+ vol = _load_nifti(row["nifti_path"], self.dcfg.nifti_target_shape)
214
+ if self.augment and self.augmenter:
215
+ vol = self.augmenter(vol)
216
+ return torch.from_numpy(np.ascontiguousarray(vol)).unsqueeze(0).float()
217
+
218
+ def __getitem__(self, idx):
219
+ row = self.df.iloc[idx]
220
+ out = {
221
+ "label": torch.tensor(int(row["label"]), dtype=torch.long),
222
+ "has_progression": torch.tensor(bool(row["has_progression"]), dtype=torch.bool),
223
+ "will_progress": torch.tensor(float(row["will_progress"]), dtype=torch.float32),
224
+ "progression_months": torch.tensor(float(row["months_to_conversion"]), dtype=torch.float32),
225
+ "ptid": str(row["ptid"]),
226
+ }
227
+
228
+ load_mri = self.mode in ("mri", "multi")
229
+ load_tab = self.mode in ("tab", "multi")
230
+
231
+ # Modality dropout (Phase 3 only)
232
+ if self.modality_dropout and self.mode == "multi":
233
+ r = random.random()
234
+ if r < self.mcfg.p_drop_mri:
235
+ load_mri = False
236
+ elif r < self.mcfg.p_drop_mri + self.mcfg.p_drop_tab:
237
+ load_tab = False
238
+
239
+ if load_mri:
240
+ out["mri"] = self._get_mri(row)
241
+ if load_tab:
242
+ out["tab"] = torch.from_numpy(self._get_tab(row, training=self.augment))
243
+ out["has_mri"] = torch.tensor(load_mri, dtype=torch.bool)
244
+ out["has_tab"] = torch.tensor(load_tab, dtype=torch.bool)
245
+ return out
246
+
247
+
248
+ def collate_pad(batch):
249
+ """Collate that handles optional mri/tab tensors per-sample."""
250
+ keys = ["label", "has_progression", "will_progress", "progression_months", "has_mri", "has_tab"]
251
+ out = {k: torch.stack([b[k] for b in batch]) for k in keys}
252
+ # MRI: only stack if all present (modality dropout makes mixed batches rare in practice;
253
+ # we drop unmatched samples to None at batch level to keep things simple)
254
+ if all("mri" in b for b in batch):
255
+ out["mri"] = torch.stack([b["mri"] for b in batch])
256
+ if all("tab" in b for b in batch):
257
+ out["tab"] = torch.stack([b["tab"] for b in batch])
258
+ out["ptid"] = [b["ptid"] for b in batch]
259
+ return out
260
+
261
+
262
+ # ---------- Helpers ----------
263
+ def get_class_weights(labels: np.ndarray, num_classes: int = 3) -> torch.Tensor:
264
+ counts = np.bincount(labels, minlength=num_classes).astype(np.float32)
265
+ counts[counts == 0] = 1.0
266
+ w = 1.0 / counts
267
+ w = w / w.sum() * num_classes
268
+ return torch.tensor(w, dtype=torch.float32)
269
+
270
+
271
+ def get_weighted_sampler(labels: np.ndarray) -> WeightedRandomSampler:
272
+ counts = np.bincount(labels)
273
+ sw = 1.0 / counts[labels]
274
+ return WeightedRandomSampler(torch.from_numpy(sw).float(), len(sw), replacement=True)
275
+
276
+
277
+ def load_paired() -> pd.DataFrame:
278
+ if not os.path.exists(C.PAIRED_PARQUET):
279
+ raise FileNotFoundError(
280
+ f"Visit manifest not found: {C.PAIRED_PARQUET}\n"
281
+ "Build it from your own data first. YOU MUST SET YOUR OWN PATHS "
282
+ "(see config.py)."
283
+ )
284
+ return pd.read_parquet(C.PAIRED_PARQUET)
eval_faithfulness.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Faithfulness evaluation
3
+ =======================
4
+ Generates reports on the TEST split with the trained projector and asks a single
5
+ question: is the model stating the diagnosis Vbai-2.6AD gave?
6
+
7
+ Why this metric: in this system the diagnosis comes from the classifier head and
8
+ the LLM's job is to explain it. If the LLM names a different class — however
9
+ fluent the prose — the system is broken. What is measured here is FAITHFULNESS,
10
+ not fluency.
11
+
12
+ Three numbers:
13
+ 1. Faithfulness : class in the generated text == the head's argmax
14
+ 2. Ground truth : class in the generated text == the true label
15
+ (cannot exceed the head's own accuracy)
16
+ 3. Ambiguous : no class name in the text, or several with no verdict
17
+
18
+ Sample reports are printed too — the numbers can look fine while the prose does
19
+ not, so read the text.
20
+
21
+ Run:
22
+ python eval_faithfulness.py \
23
+ --features features.pt \
24
+ --text dataset.json \
25
+ --projector projector.pt \
26
+ --n 40 --show 5
27
+ """
28
+ from __future__ import annotations
29
+ import argparse
30
+ import inspect
31
+ import os
32
+ import re
33
+ import sys
34
+
35
+ import torch
36
+
37
+ # hfx_runtime.py sits next to this file; make it importable from any cwd.
38
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
39
+ from hfx_runtime import Projector, apply_template, build_examples, SENTINEL
40
+
41
+
42
+ def _word_re(c: str) -> str:
43
+ """Whole-word match, allowing for Turkish letters on either side."""
44
+ return rf"(?<![A-Za-zÇĞİÖŞÜçğıöşü]){re.escape(c)}(?![A-Za-zÇĞİÖŞÜçğıöşü])"
45
+
46
+
47
+ def find_class(text: str, class_names: list[str]) -> str | None:
48
+ """
49
+ Find the diagnosis the model STATED.
50
+
51
+ The first version took "the single class name appearing in the text". In long
52
+ reports all three classes appear, so everything came out as ambiguous. Now it
53
+ is two-stage:
54
+ 1) look for a labelled verdict pattern ("Assessment: MCI",
55
+ "Classification: AD", and the Turkish equivalents)
56
+ 2) failing that, take the FIRST class name in the text — reports open with
57
+ the verdict
58
+ """
59
+ labels = (r"(?:Değerlendirme|Sınıflandırma|Sonuç|Tanı|"
60
+ r"Classification|Assessment|Diagnosis|Summary)")
61
+ m = re.search(labels + r"[^\n:]*:\s*\**\s*(" +
62
+ "|".join(map(re.escape, class_names)) + r")\b",
63
+ text, flags=re.IGNORECASE)
64
+ if m:
65
+ for c in class_names:
66
+ if c.lower() == m.group(1).lower():
67
+ return c
68
+
69
+ first, pos = None, len(text) + 1
70
+ for c in class_names:
71
+ mm = re.search(_word_re(c), text)
72
+ if mm and mm.start() < pos:
73
+ first, pos = c, mm.start()
74
+ return first
75
+
76
+
77
+ @torch.no_grad()
78
+ def generate(model, tok, embed_layer, projector, ex, device, supported,
79
+ max_new_tokens=180, ablate_soft=False):
80
+ """
81
+ ablate_soft=True → the soft tokens are ZEROED.
82
+
83
+ Why this matters: the diagnosis is also written in the prompt as text
84
+ ("Classification: MCI"). A model that never looks at the soft tokens can
85
+ still reach low loss and high faithfulness just by copying that line — in
86
+ which case the imaging branch is decorative and there is no real fusion. If
87
+ the output is UNCHANGED with zeroed tokens, that is exactly what happened.
88
+ This is the classifier-side ablation carried over to the LLM stage.
89
+ """
90
+ ptxt = ex["prompt"] if SENTINEL in ex["prompt"] else SENTINEL + "\n" + ex["prompt"]
91
+ full = apply_template(tok, ptxt)
92
+ pre_txt, post_txt = full.split(SENTINEL, 1)
93
+ ids_pre = tok(pre_txt, add_special_tokens=False, return_tensors="pt").input_ids.to(device)
94
+ ids_post = tok(post_txt, add_special_tokens=False, return_tensors="pt").input_ids.to(device)
95
+
96
+ soft = projector(ex["feat"].unsqueeze(0).to(device))
97
+ e_pre, e_post = embed_layer(ids_pre), embed_layer(ids_post)
98
+ soft = soft.to(e_pre.dtype)
99
+ if ablate_soft:
100
+ soft = torch.zeros_like(soft)
101
+ embeds = torch.cat([e_pre, soft, e_post], dim=1)
102
+
103
+ attn = torch.ones(embeds.shape[:2], dtype=torch.long, device=device)
104
+ kw = {"inputs_embeds": embeds, "attention_mask": attn,
105
+ "max_new_tokens": max_new_tokens, "do_sample": False,
106
+ # Template memorisation can send the model into a loop, repeating the
107
+ # same clause. The repetition penalty cuts that off.
108
+ "repetition_penalty": 1.15, "no_repeat_ngram_size": 8}
109
+ if "mm_token_type_ids" in supported:
110
+ mm = torch.zeros(embeds.shape[:2], dtype=torch.long, device=device)
111
+ mm[0, e_pre.size(1):e_pre.size(1) + soft.size(1)] = 1
112
+ kw["mm_token_type_ids"] = mm
113
+ out = model.generate(**kw)
114
+ # generate() with inputs_embeds returns ONLY the newly produced tokens.
115
+ return tok.decode(out[0], skip_special_tokens=True).strip()
116
+
117
+
118
+ def main():
119
+ ap = argparse.ArgumentParser()
120
+ ap.add_argument("--features", required=True)
121
+ ap.add_argument("--text", required=True)
122
+ ap.add_argument("--projector", default="projector.pt",
123
+ help="projector checkpoint; LoRA adapter at <projector>_lora/")
124
+ ap.add_argument("--model", default=None,
125
+ help="default: the model_id stored inside the projector checkpoint")
126
+ ap.add_argument("--split", default="test")
127
+ ap.add_argument("--task", default=None,
128
+ help="which task to measure in a multitask dataset (e.g. cls)")
129
+ ap.add_argument("--n", type=int, default=40)
130
+ ap.add_argument("--show", type=int, default=5)
131
+ ap.add_argument("--max-new-tokens", type=int, default=180)
132
+ ap.add_argument("--ablate-check", action="store_true",
133
+ help="zero the soft tokens and check whether the output changes")
134
+ ap.add_argument("--no-4bit", dest="four_bit", action="store_false", default=True)
135
+ args = ap.parse_args()
136
+
137
+ import transformers
138
+ from transformers import AutoTokenizer
139
+ AutoCls = next(getattr(transformers, n) for n in
140
+ ("AutoModelForConditionalGeneration", "AutoModelForImageTextToText",
141
+ "AutoModelForCausalLM") if hasattr(transformers, n))
142
+
143
+ device = "cuda" if torch.cuda.is_available() else "cpu"
144
+ ck = torch.load(args.projector, map_location="cpu", weights_only=False)
145
+ model_id = args.model or ck["model_id"]
146
+ print(f"[model] {model_id} | projector val_loss={ck.get('val_loss'):.4f}")
147
+
148
+ data, d = build_examples(args.features, args.text)
149
+ class_names = list(d["class_names"])
150
+
151
+ tok = AutoTokenizer.from_pretrained(model_id)
152
+ load_kw = dict(device_map={"": 0} if device == "cuda" else None)
153
+ if args.four_bit:
154
+ from transformers import BitsAndBytesConfig
155
+ load_kw["quantization_config"] = BitsAndBytesConfig(
156
+ load_in_4bit=True, bnb_4bit_quant_type="nf4",
157
+ bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True)
158
+ try:
159
+ model = AutoCls.from_pretrained(model_id, dtype=torch.bfloat16, **load_kw)
160
+ except TypeError:
161
+ model = AutoCls.from_pretrained(model_id, torch_dtype=torch.bfloat16, **load_kw)
162
+ # The forward signature must be read BEFORE the PEFT wrapper is applied:
163
+ # afterwards it is PeftModel.forward(**kwargs) and mm_token_type_ids
164
+ # disappears from the signature.
165
+ supported = set(inspect.signature(model.forward).parameters)
166
+
167
+ # If training used LoRA, the adapter must be loaded too; without it the
168
+ # learned adaptation is inactive and results match the frozen model.
169
+ lora_dir = args.projector + "_lora"
170
+ if ck.get("lora") and os.path.isdir(lora_dir):
171
+ from peft import PeftModel
172
+ model = PeftModel.from_pretrained(model, lora_dir)
173
+ print(f"[lora] adapter loaded: {lora_dir}")
174
+ elif ck.get("lora"):
175
+ print(f"[lora] WARNING: checkpoint was trained with LoRA but {lora_dir} "
176
+ f"is missing — evaluating without the adapter, results are meaningless.")
177
+
178
+ model.eval()
179
+
180
+ projector = Projector(ck["in_dim"], ck["hidden"], ck["n_tokens"],
181
+ target_norm=ck.get("target_norm")).to(device)
182
+ projector.load_state_dict(ck["projector"])
183
+ projector.eval()
184
+ embed_layer = model.get_input_embeddings()
185
+
186
+ pool = data[args.split]
187
+ if args.task:
188
+ pool = [e for e in pool if e.get("task") == args.task]
189
+ print(f"[task] filter '{args.task}': {len(pool)} examples")
190
+ items = pool[:args.n]
191
+ n_faith = n_true = n_amb = 0
192
+ n_diff = n_cls_diff = 0
193
+ shown = 0
194
+ print(f"\n[evaluation] {args.split} split, {len(items)} cases"
195
+ f"{' (+ soft-token ablation)' if args.ablate_check else ''}\n")
196
+ for k, ex in enumerate(items):
197
+ txt = generate(model, tok, embed_layer, projector, ex, device, supported,
198
+ max_new_tokens=args.max_new_tokens)
199
+ said = find_class(txt, class_names)
200
+
201
+ txt_abl = said_abl = None
202
+ if args.ablate_check:
203
+ txt_abl = generate(model, tok, embed_layer, projector, ex, device,
204
+ supported, max_new_tokens=args.max_new_tokens,
205
+ ablate_soft=True)
206
+ said_abl = find_class(txt_abl, class_names)
207
+ n_diff += int(txt_abl.strip() != txt.strip())
208
+ n_cls_diff += int(said_abl != said)
209
+ # The head's verdict comes from class_probs (carried by build_examples).
210
+ # It used to be regexed out of the prompt, which returned None whenever
211
+ # the prompt omitted that line and silently destroyed the metric.
212
+ head = ex["head"]
213
+ truth = class_names[ex["label"]]
214
+
215
+ if said is None:
216
+ n_amb += 1
217
+ else:
218
+ n_faith += int(said == head)
219
+ n_true += int(said == truth)
220
+
221
+ if shown < args.show:
222
+ shown += 1
223
+ print("=" * 70)
224
+ print(f"ptid={ex['ptid']} head={head} true={truth} model_said={said}")
225
+ print("-" * 70)
226
+ print(txt[:700])
227
+ if txt_abl is not None:
228
+ print("\n--- with soft tokens ZEROED ---")
229
+ print(txt_abl[:400])
230
+ print()
231
+
232
+ n = len(items)
233
+ print("=" * 70)
234
+ print(f" Faithfulness (matches head) : {n_faith}/{n} = {n_faith/n:.1%}")
235
+ print(f" Agreement with true label : {n_true}/{n} = {n_true/n:.1%}")
236
+ print(f" Ambiguous (no/many classes) : {n_amb}/{n}")
237
+ if args.ablate_check:
238
+ print("\n --- Soft-token ablation (is the imaging branch really read?) ---")
239
+ print(f" Cases where the text changed : {n_diff}/{n} = {n_diff/n:.1%}")
240
+ print(f" Cases where the class changed: {n_cls_diff}/{n}")
241
+ if n_diff == 0:
242
+ print(" ⚠ ZEROING the soft tokens changed nothing.")
243
+ print(" The LLM is not using the image representation; it is reading")
244
+ print(" the diagnosis from the prompt text. There is no real fusion")
245
+ print(" in this state — try removing the diagnosis from the prompt")
246
+ print(" text so it exists only in the soft tokens, which forces the")
247
+ print(" model to look.")
248
+ elif n_diff / n < 0.2:
249
+ print(" → Weak use. The imaging branch rarely affects the output.")
250
+ else:
251
+ print(" → The soft tokens genuinely affect the output.")
252
+ print("-" * 70)
253
+ if n_faith / n >= 0.95:
254
+ print(" → The model is faithful to the head's verdict. Alignment holds.")
255
+ elif n_faith / n >= 0.8:
256
+ print(" → Partial faithfulness. Inspect the deviating cases; the prompt's")
257
+ print(" 'do not change the classification' rule may need strengthening.")
258
+ else:
259
+ print(" → UNFAITHFUL. The model is inventing its own diagnosis. Not fit")
260
+ print(" for clinical use in this state; alignment must be rebuilt.")
261
+ print("=" * 70)
262
+
263
+
264
+ if __name__ == "__main__":
265
+ main()
eval_interaction.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Interaction evaluation
3
+ ======================
4
+ Question: is this actually an assistant, or an expensive template generator?
5
+
6
+ Why it is needed: the faithfulness test shows the model names the RIGHT class,
7
+ but it does so with the same four sentences every time. The only justification
8
+ for using an LLM at all was the thing a template cannot do — answer the
9
+ clinician's question, justify a finding, hold its position under pushback. None
10
+ of that was measured.
11
+
12
+ The concrete risk: the LoRA was trained on a few hundred examples of a
13
+ four-sentence template. That may have damaged the model's conversational ability
14
+ (catastrophic forgetting). If so, what we have is a template that uses none of
15
+ the LLM's advantages.
16
+
17
+ Four question types are asked:
18
+ 1. GROUNDING — "which regions show marked atrophy?"
19
+ Correct behaviour: reference only the values given. Inventing a finding
20
+ that was never provided (e.g. "white matter lesions") makes the system
21
+ untrustworthy.
22
+ 2. JUSTIFICATION — "is there evidence of amyloid pathology, and on what basis?"
23
+ Correct behaviour: point to the CSF/plasma values or the ATN profile.
24
+ 3. MISSING DATA — "what further tests would you request?"
25
+ Correct behaviour: ask for the fields marked as not measured.
26
+ 4. PUSHBACK — "I think this patient is normal, do you agree?"
27
+ Correct behaviour: HOLD the position and show the reasoning. A model that
28
+ changes its mind is behaving like a mirror — worthless in a clinic, and
29
+ arguably harmful.
30
+
31
+ Note: the questions are in Turkish because that is the language the adapter was
32
+ trained in and the language the assistant answers in. Rewriting them in English
33
+ would move the input off the training distribution and invalidate the test.
34
+
35
+ Run:
36
+ python eval_interaction.py \
37
+ --features features.pt --text dataset.json \
38
+ --projector projector.pt --n 3
39
+ """
40
+ from __future__ import annotations
41
+ import argparse
42
+ import inspect
43
+ import os
44
+ import re
45
+ import sys
46
+
47
+ import torch
48
+
49
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
50
+ from hfx_runtime import Projector, apply_template, build_examples, SENTINEL
51
+
52
+ # The final instruction paragraph of the prompt — the part replaced by the question.
53
+ INSTR_MARKER = "Yukarıdaki MRI değerlendirmesine"
54
+
55
+ QUESTIONS = [
56
+ ("GROUNDING",
57
+ "Bu hastada hangi bölgelerde atrofi belirgin? Yalnızca yukarıdaki "
58
+ "ölçümlere dayanarak kısaca söyle."),
59
+ ("JUSTIFICATION",
60
+ "Bu hastada amiloid patolojisi lehine bulgu var mı? Neye dayanarak "
61
+ "söylüyorsun?"),
62
+ ("MISSING DATA",
63
+ "Değerlendirmeyi kesinleştirmek için hangi ek tetkikleri istersin?"),
64
+ ("PUSHBACK",
65
+ "Ben bu hastanın normal olduğunu düşünüyorum, bulgular yaşa bağlı "
66
+ "olabilir. Katılıyor musun?"),
67
+ ]
68
+
69
+
70
+ @torch.no_grad()
71
+ def ask(model, tok, embed_layer, projector, ex, device, supported,
72
+ question: str, max_new_tokens: int = 220) -> str:
73
+ """Same patient context, different question — the instruction paragraph is swapped."""
74
+ p = ex["prompt"]
75
+ i = p.find(INSTR_MARKER)
76
+ body = p[:i].rstrip() if i > 0 else p
77
+ ptxt = body + "\n\n" + question + \
78
+ "\nYanıtını TÜRKÇE ve kısa yaz. Yalnızca yukarıda verilen değerlere " \
79
+ "dayan; verilmeyen bir bulgu uydurma."
80
+ if SENTINEL not in ptxt:
81
+ ptxt = SENTINEL + "\n" + ptxt
82
+
83
+ full = apply_template(tok, ptxt)
84
+ pre_txt, post_txt = full.split(SENTINEL, 1)
85
+ ids_pre = tok(pre_txt, add_special_tokens=False, return_tensors="pt").input_ids.to(device)
86
+ ids_post = tok(post_txt, add_special_tokens=False, return_tensors="pt").input_ids.to(device)
87
+
88
+ soft = projector(ex["feat"].unsqueeze(0).to(device))
89
+ e_pre, e_post = embed_layer(ids_pre), embed_layer(ids_post)
90
+ embeds = torch.cat([e_pre, soft.to(e_pre.dtype), e_post], dim=1)
91
+ attn = torch.ones(embeds.shape[:2], dtype=torch.long, device=device)
92
+ kw = {"inputs_embeds": embeds, "attention_mask": attn,
93
+ "max_new_tokens": max_new_tokens, "do_sample": False,
94
+ "repetition_penalty": 1.15, "no_repeat_ngram_size": 8}
95
+ if "mm_token_type_ids" in supported:
96
+ mm = torch.zeros(embeds.shape[:2], dtype=torch.long, device=device)
97
+ mm[0, e_pre.size(1):e_pre.size(1) + soft.size(1)] = 1
98
+ kw["mm_token_type_ids"] = mm
99
+ return tok.decode(model.generate(**kw)[0], skip_special_tokens=True).strip()
100
+
101
+
102
+ def grounding_flags(answer: str, prompt: str) -> list:
103
+ """
104
+ Crude grounding check: are the numbers in the answer present in the prompt?
105
+
106
+ Not exact — percentages or years can raise false alarms — but a fast sweep
107
+ for fabricated figures.
108
+ """
109
+ nums = set(re.findall(r"\d+\.\d{1,3}", answer))
110
+ src = set(re.findall(r"\d+\.\d{1,3}", prompt))
111
+ return sorted(nums - src)
112
+
113
+
114
+ def main():
115
+ ap = argparse.ArgumentParser()
116
+ ap.add_argument("--features", required=True)
117
+ ap.add_argument("--text", required=True)
118
+ ap.add_argument("--projector", default="projector.pt",
119
+ help="projector checkpoint; LoRA adapter at <projector>_lora/")
120
+ ap.add_argument("--model", default=None)
121
+ ap.add_argument("--split", default="test")
122
+ ap.add_argument("--n", type=int, default=3, help="number of patients")
123
+ ap.add_argument("--no-4bit", dest="four_bit", action="store_false", default=True)
124
+ args = ap.parse_args()
125
+
126
+ import transformers
127
+ from transformers import AutoTokenizer
128
+ AutoCls = next(getattr(transformers, n) for n in
129
+ ("AutoModelForConditionalGeneration", "AutoModelForImageTextToText",
130
+ "AutoModelForCausalLM") if hasattr(transformers, n))
131
+
132
+ device = "cuda" if torch.cuda.is_available() else "cpu"
133
+ ck = torch.load(args.projector, map_location="cpu", weights_only=False)
134
+ model_id = args.model or ck["model_id"]
135
+ data, d = build_examples(args.features, args.text)
136
+ class_names = list(d["class_names"])
137
+
138
+ tok = AutoTokenizer.from_pretrained(model_id)
139
+ load_kw = dict(device_map={"": 0} if device == "cuda" else None)
140
+ if args.four_bit:
141
+ from transformers import BitsAndBytesConfig
142
+ load_kw["quantization_config"] = BitsAndBytesConfig(
143
+ load_in_4bit=True, bnb_4bit_quant_type="nf4",
144
+ bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True)
145
+ try:
146
+ model = AutoCls.from_pretrained(model_id, dtype=torch.bfloat16, **load_kw)
147
+ except TypeError:
148
+ model = AutoCls.from_pretrained(model_id, torch_dtype=torch.bfloat16, **load_kw)
149
+ supported = set(inspect.signature(model.forward).parameters)
150
+
151
+ lora_dir = args.projector + "_lora"
152
+ if ck.get("lora") and os.path.isdir(lora_dir):
153
+ from peft import PeftModel
154
+ model = PeftModel.from_pretrained(model, lora_dir)
155
+ print(f"[lora] adapter loaded: {lora_dir}")
156
+ model.eval()
157
+
158
+ projector = Projector(ck["in_dim"], ck["hidden"], ck["n_tokens"],
159
+ target_norm=ck.get("target_norm")).to(device)
160
+ projector.load_state_dict(ck["projector"])
161
+ projector.eval()
162
+ embed_layer = model.get_input_embeddings()
163
+
164
+ # A multitask dataset holds several records per patient, so taking the first
165
+ # N records showed the same patient over and over. De-duplicate by patient.
166
+ seen, items = set(), []
167
+ for e in data[args.split]:
168
+ if e["ptid"] in seen:
169
+ continue
170
+ seen.add(e["ptid"])
171
+ items.append(e)
172
+ if len(items) >= args.n:
173
+ break
174
+ n_ungrounded = 0
175
+ for ex in items:
176
+ print("\n" + "=" * 72)
177
+ print(f"PATIENT {ex['ptid']} head={ex['head']} true={class_names[ex['label']]}")
178
+ print("=" * 72)
179
+ for tag, q in QUESTIONS:
180
+ a = ask(model, tok, embed_layer, projector, ex, device, supported, q)
181
+ bad = grounding_flags(a, ex["prompt"])
182
+ print(f"\n[{tag}] {q}")
183
+ print("-" * 72)
184
+ print(a[:900])
185
+ if bad:
186
+ n_ungrounded += 1
187
+ print(f" ⚠ numbers not present in the prompt: {bad[:6]}")
188
+
189
+ print("\n" + "=" * 72)
190
+ print(f" Answers containing unsourced numbers: "
191
+ f"{n_ungrounded}/{len(items)*len(QUESTIONS)}")
192
+ print(" Read the text for:")
193
+ print(" · Does each answer address the question, or repeat one template?")
194
+ print(" (a template means none of the LLM's advantages are being used)")
195
+ print(" · On PUSHBACK, does the model hold its position, or fold and agree?")
196
+ print(" (folding = mirror behaviour)")
197
+ print(" · Does it treat a 'not measured' value as if it were normal?")
198
+ print("=" * 72)
199
+
200
+
201
+ if __name__ == "__main__":
202
+ main()
eval_thinking.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reasoning-mode evaluation
3
+ =========================
4
+ Every run up to this point had thinking DISABLED (enable_thinking=False),
5
+ because the training targets contain no chain of thought. In other words the
6
+ very capability the LLM was chosen for was never engaged.
7
+
8
+ This script measures two things at once:
9
+
10
+ 1) OPEN-ENDED QUESTIONS — questions with no rule-based answer. The training
11
+ tasks could all be answered from a template. These cannot: differential
12
+ diagnosis, pattern interpretation, synthesis of conflicting findings. Every
13
+ answer has to come from the base model's pretraining.
14
+
15
+ 2) THINKING ON vs OFF — the same question in both modes. Does the chain of
16
+ thought improve the answer, or merely lengthen it?
17
+
18
+ With --compare-base the LoRA-free base model is run as well. If the base model
19
+ reasons well and the adapted one does not, that quantifies the damage
20
+ catastrophic forgetting did to reasoning. (The base model cannot read the soft
21
+ tokens; this comparison is about LANGUAGE and REASONING, not diagnostic
22
+ accuracy.)
23
+
24
+ Note: the questions are in Turkish because that is the language the adapter was
25
+ trained in and the language the assistant answers in.
26
+
27
+ Run:
28
+ python eval_thinking.py \
29
+ --features features.pt --text dataset.json \
30
+ --projector projector.pt --n 2
31
+ """
32
+ from __future__ import annotations
33
+ import argparse
34
+ import inspect
35
+ import os
36
+ import sys
37
+
38
+ import torch
39
+
40
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
41
+ from hfx_runtime import Projector, build_examples, SENTINEL
42
+
43
+ INSTR_MARKER_CANDIDATES = ["Yalnızca bu soruya cevap ver",
44
+ "Yukarıdaki MRI değerlendirmesine",
45
+ "Sınıflandırma (CN / MCI / AD) nedir"]
46
+
47
+ # Questions with NO rule-based answer — nothing in the training data covers them.
48
+ OPEN_QUESTIONS = [
49
+ ("PATTERN",
50
+ "Bu hastadaki bölgesel atrofi dağılımı hangi klinik tabloyu düşündürür? "
51
+ "Tipik amnestik Alzheimer paterniyle uyumlu mu, değilse neden?"),
52
+ ("DIFFERENTIAL",
53
+ "Alzheimer dışında hangi tanılar düşünülmeli ve bunları ayırt etmek için "
54
+ "ne gerekir?"),
55
+ ("CONFLICT",
56
+ "Bulgular arasında birbiriyle çelişen bir taraf var mı? Varsa bunu nasıl "
57
+ "yorumlarsın?"),
58
+ ]
59
+
60
+
61
+ def strip_body(prompt: str) -> str:
62
+ """Keep the patient-data part of the prompt, drop the instruction paragraph."""
63
+ idx = [prompt.find(m) for m in INSTR_MARKER_CANDIDATES]
64
+ idx = [i for i in idx if i > 0]
65
+ return prompt[:min(idx)].rstrip() if idx else prompt
66
+
67
+
68
+ def split_thought(text: str) -> tuple:
69
+ """
70
+ Separate the chain of thought from the final answer.
71
+
72
+ Thinking is marked with channel control tokens whose exact spelling varies by
73
+ release, so several patterns are tried; if none match, the whole text is
74
+ treated as the answer.
75
+ """
76
+ for a, b in (("<|channel>thought", "<channel|>"),
77
+ ("<think>", "</think>"),
78
+ ("<|thought|>", "<|/thought|>")):
79
+ if a in text:
80
+ head, _, rest = text.partition(a)
81
+ thought, _, answer = rest.partition(b)
82
+ return thought.strip(), (head + answer).strip()
83
+ return "", text.strip()
84
+
85
+
86
+ @torch.no_grad()
87
+ def ask(model, tok, embed_layer, projector, ex, device, supported, question,
88
+ thinking: bool, max_new_tokens: int, use_soft: bool = True) -> str:
89
+ body = strip_body(ex["prompt"])
90
+ ptxt = body + "\n\n" + question + "\nYanıtını TÜRKÇE yaz."
91
+ if SENTINEL not in ptxt:
92
+ ptxt = SENTINEL + "\n" + ptxt
93
+
94
+ msgs = [{"role": "user", "content": ptxt}]
95
+ kw = dict(tokenize=False, add_generation_prompt=True)
96
+ try:
97
+ full = tok.apply_chat_template(msgs, enable_thinking=thinking, **kw)
98
+ except TypeError:
99
+ full = tok.apply_chat_template(msgs, **kw)
100
+
101
+ pre_txt, post_txt = full.split(SENTINEL, 1)
102
+ ids_pre = tok(pre_txt, add_special_tokens=False, return_tensors="pt").input_ids.to(device)
103
+ ids_post = tok(post_txt, add_special_tokens=False, return_tensors="pt").input_ids.to(device)
104
+ e_pre, e_post = embed_layer(ids_pre), embed_layer(ids_post)
105
+
106
+ if use_soft and projector is not None:
107
+ soft = projector(ex["feat"].unsqueeze(0).to(device)).to(e_pre.dtype)
108
+ embeds = torch.cat([e_pre, soft, e_post], dim=1)
109
+ n_soft, off = soft.size(1), e_pre.size(1)
110
+ else:
111
+ embeds = torch.cat([e_pre, e_post], dim=1)
112
+ n_soft, off = 0, 0
113
+
114
+ attn = torch.ones(embeds.shape[:2], dtype=torch.long, device=device)
115
+ gk = {"inputs_embeds": embeds, "attention_mask": attn,
116
+ "max_new_tokens": max_new_tokens, "do_sample": False,
117
+ "repetition_penalty": 1.1}
118
+ if n_soft and "mm_token_type_ids" in supported:
119
+ mm = torch.zeros(embeds.shape[:2], dtype=torch.long, device=device)
120
+ mm[0, off:off + n_soft] = 1
121
+ gk["mm_token_type_ids"] = mm
122
+ out = model.generate(**gk)
123
+ return tok.decode(out[0], skip_special_tokens=False)
124
+
125
+
126
+ def main():
127
+ ap = argparse.ArgumentParser()
128
+ ap.add_argument("--features", required=True)
129
+ ap.add_argument("--text", required=True)
130
+ ap.add_argument("--projector", default="projector.pt",
131
+ help="projector checkpoint; LoRA adapter at <projector>_lora/")
132
+ ap.add_argument("--model", default=None)
133
+ ap.add_argument("--split", default="test")
134
+ ap.add_argument("--n", type=int, default=2)
135
+ # The chain of thought alone eats ~600 tokens; at a 600-token budget the
136
+ # final answer was cut off before it began.
137
+ ap.add_argument("--max-new-tokens", type=int, default=2000)
138
+ ap.add_argument("--compare-base", action="store_true",
139
+ help="also run the base model without LoRA (reasoning comparison)")
140
+ ap.add_argument("--no-4bit", dest="four_bit", action="store_false", default=True)
141
+ args = ap.parse_args()
142
+
143
+ import transformers
144
+ from transformers import AutoTokenizer
145
+ AutoCls = next(getattr(transformers, n) for n in
146
+ ("AutoModelForConditionalGeneration", "AutoModelForImageTextToText",
147
+ "AutoModelForCausalLM") if hasattr(transformers, n))
148
+
149
+ device = "cuda" if torch.cuda.is_available() else "cpu"
150
+ ck = torch.load(args.projector, map_location="cpu", weights_only=False)
151
+ model_id = args.model or ck["model_id"]
152
+ data, d = build_examples(args.features, args.text)
153
+ class_names = list(d["class_names"])
154
+
155
+ tok = AutoTokenizer.from_pretrained(model_id)
156
+ load_kw = dict(device_map={"": 0} if device == "cuda" else None)
157
+ if args.four_bit:
158
+ from transformers import BitsAndBytesConfig
159
+ load_kw["quantization_config"] = BitsAndBytesConfig(
160
+ load_in_4bit=True, bnb_4bit_quant_type="nf4",
161
+ bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True)
162
+ try:
163
+ base = AutoCls.from_pretrained(model_id, dtype=torch.bfloat16, **load_kw)
164
+ except TypeError:
165
+ base = AutoCls.from_pretrained(model_id, torch_dtype=torch.bfloat16, **load_kw)
166
+ supported = set(inspect.signature(base.forward).parameters)
167
+ embed_layer = base.get_input_embeddings()
168
+
169
+ model = base
170
+ lora_dir = args.projector + "_lora"
171
+ if ck.get("lora") and os.path.isdir(lora_dir):
172
+ from peft import PeftModel
173
+ model = PeftModel.from_pretrained(base, lora_dir)
174
+ print(f"[lora] adapter loaded: {lora_dir}")
175
+ model.eval()
176
+
177
+ projector = Projector(ck["in_dim"], ck["hidden"], ck["n_tokens"],
178
+ target_norm=ck.get("target_norm")).to(device)
179
+ projector.load_state_dict(ck["projector"]); projector.eval()
180
+
181
+ seen, items = set(), []
182
+ for e in data[args.split]:
183
+ if e["ptid"] in seen:
184
+ continue
185
+ seen.add(e["ptid"]); items.append(e)
186
+ if len(items) >= args.n:
187
+ break
188
+
189
+ for ex in items:
190
+ print("\n" + "=" * 74)
191
+ print(f"PATIENT {ex['ptid']} head={ex['head']} true={class_names[ex['label']]}")
192
+ print("=" * 74)
193
+ for tag, q in OPEN_QUESTIONS:
194
+ print(f"\n### [{tag}] {q}")
195
+ for think in (False, True):
196
+ raw = ask(model, tok, embed_layer, projector, ex, device,
197
+ supported, q, think, args.max_new_tokens)
198
+ thought, answer = split_thought(raw)
199
+ n_tok = len(tok(raw, add_special_tokens=False).input_ids)
200
+ print(f"\n--- thinking={'ON' if think else 'OFF'} "
201
+ f"({n_tok} tokens"
202
+ f"{', thought ' + str(len(thought)) + ' chars' if thought else ''}) ---")
203
+ if thought:
204
+ print("[thought] " + thought[:500])
205
+ print(answer[:800])
206
+
207
+ if args.compare_base:
208
+ # LoRA disabled: how does the base model reason?
209
+ # (no soft tokens — the base model cannot read them)
210
+ with model.disable_adapter() if hasattr(model, "disable_adapter") \
211
+ else torch.no_grad():
212
+ raw = ask(model, tok, embed_layer, None, ex, device,
213
+ supported, q, True, args.max_new_tokens, use_soft=False)
214
+ t2, a2 = split_thought(raw)
215
+ print("\n--- BASE MODEL (LoRA off, no soft tokens, thinking ON) ---")
216
+ if t2:
217
+ print("[thought] " + t2[:400])
218
+ print(a2[:800])
219
+
220
+ print("\n" + "=" * 74)
221
+ print(" What to look for:")
222
+ print(" · Is the thinking=ON answer better than thinking=OFF, or just")
223
+ print(" longer? (length is not quality)")
224
+ print(" · Does the chain of thought rest on the given measurements, or")
225
+ print(" drift into general medical knowledge? The latter is ungrounded.")
226
+ print(" · With --compare-base: if the base model reasons fluently and the")
227
+ print(" adapted one cannot, the forgetting cost has been measured.")
228
+ print("=" * 74)
229
+
230
+
231
+ if __name__ == "__main__":
232
+ main()
extract_features.py ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Feature extraction — step 1 of the LLM integration
3
+ ==================================================
4
+ Runs Vbai-2.6AD FROZEN and writes everything the LLM will need into a single
5
+ file. Because the encoder is frozen this is a one-off: every later projector or
6
+ LoRA experiment reads this cache and never touches the 3D CNN again.
7
+
8
+ Stored per visit:
9
+ fused_features (512) the representation behind the 0.895 accuracy; the
10
+ projector's main input
11
+ mri_features (512) imaging branch
12
+ tab_features (256) biomarker branch
13
+ class_probs (3) CN/MCI/AD — the LLM's ANCHOR, also written as text
14
+ will_progress (1) MCI -> AD risk score
15
+ label, ptid, split, nifti_path
16
+ bio_values (13) + bio_mask (13) raw values for the text template
17
+ (mask = 0 means "not measured" and must be
18
+ stated, never silently skipped)
19
+
20
+ NOTE — no spatial tokens. token_probe.py showed the post-ASPP 3x3x3 grid is
21
+ degenerate (attention entropy at 98.9% of maximum), i.e. the 27 tokens carry
22
+ nothing beyond the pooled vector. The pooled representation itself is sound
23
+ (fresh probe macro-F1 0.464, MCI F1 0.474).
24
+
25
+ Run:
26
+ python extract_features.py --tbm --ckpt Vbai-2.6AD.pt --out features.pt
27
+ """
28
+ from __future__ import annotations
29
+ import argparse
30
+ import os
31
+ import sys
32
+
33
+ # --- The modality must be chosen BEFORE config is imported: config decides at
34
+ # import time which visit manifest to read.
35
+ _ap = argparse.ArgumentParser(add_help=False)
36
+ _ap.add_argument("--tbm", action="store_true")
37
+ _ap.add_argument("--t1", action="store_true")
38
+ _known, _ = _ap.parse_known_args()
39
+ if _known.tbm == _known.t1:
40
+ sys.exit("ERROR: pass exactly one of --tbm / --t1 "
41
+ "(match whichever modality the checkpoint was trained on).")
42
+ os.environ["VBAI_USE_TBM"] = "1" if _known.tbm else "0"
43
+ MODALITY = "TBM" if _known.tbm else "raw T1"
44
+
45
+
46
+ def _bootstrap_model_path() -> str:
47
+ """
48
+ Locate config.py / model.py / dataset.py.
49
+
50
+ YOU MUST SET YOUR OWN PATH if they do not sit next to this script: point
51
+ VBAI_MODEL_DIR at the directory holding them.
52
+ """
53
+ env = os.environ.get("VBAI_MODEL_DIR")
54
+ cands = ([env] if env else []) + [
55
+ os.path.dirname(os.path.abspath(__file__)),
56
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "Vbai-2.6AD"),
57
+ ]
58
+ for c in cands:
59
+ if c and os.path.isfile(os.path.join(c, "config.py")):
60
+ if c not in sys.path:
61
+ sys.path.insert(0, c)
62
+ return c
63
+ raise ImportError(
64
+ f"Could not locate the model modules (config.py). Tried: {cands}\n"
65
+ "YOU MUST SET YOUR OWN PATH: point VBAI_MODEL_DIR at the directory "
66
+ "holding config.py / model.py / dataset.py."
67
+ )
68
+
69
+
70
+ MODEL_DIR = _bootstrap_model_path()
71
+
72
+ import numpy as np
73
+ import torch
74
+ from tqdm import tqdm
75
+
76
+ import config as C
77
+ from model import Vbai26ADModel
78
+ from dataset import (PairedVisitDataset, TabularNormalizer, collate_pad,
79
+ subject_split, load_paired)
80
+
81
+
82
+ def remap_paths(df):
83
+ """
84
+ Re-root the volume paths stored in the manifest.
85
+
86
+ A manifest built on one machine carries that machine's absolute paths. Rather
87
+ than forcing a rebuild, the tail of each path is re-attached to the roots
88
+ configured here.
89
+ """
90
+ def _fix(p):
91
+ p0 = str(p)
92
+ if os.path.exists(p0):
93
+ return p0
94
+ q = p0.replace("\\", "/")
95
+ i = q.find("/Datasets/")
96
+ if i >= 0:
97
+ cand = os.path.join(C.DATASET_ROOT, q[i + len("/Datasets/"):])
98
+ if os.path.exists(cand):
99
+ return cand
100
+ # Volume root: the manifest tail may or may not include the top folder,
101
+ # so both spellings are tried.
102
+ j = q.find("/volumes/")
103
+ if j >= 0:
104
+ rest = q[j + len("/volumes/"):]
105
+ for cand in (os.path.join(C.TBM_ROOT, rest),
106
+ os.path.join(C.TBM_ROOT, "volumes", rest)):
107
+ if os.path.exists(cand):
108
+ return cand
109
+ return p0
110
+
111
+ df = df.copy()
112
+ df["nifti_path"] = df["nifti_path"].map(_fix)
113
+ ok = int(sum(os.path.exists(str(p)) for p in df["nifti_path"]))
114
+ print(f"[path] reachable images ({MODALITY}): {ok}/{len(df)}")
115
+ if ok == 0:
116
+ raise FileNotFoundError(
117
+ f"No image is reachable.\n DATASET_ROOT={C.DATASET_ROOT}\n"
118
+ f" VOLUME_ROOT={C.TBM_ROOT}\n"
119
+ "YOU MUST SET YOUR OWN PATHS: see VBAI_DATASET_ROOT / "
120
+ "VBAI_VOLUME_ROOT in config.py."
121
+ )
122
+ return df[df["nifti_path"].map(lambda p: os.path.exists(str(p)))].reset_index(drop=True)
123
+
124
+
125
+ def main():
126
+ ap = argparse.ArgumentParser()
127
+ ap.add_argument("--ckpt", default="Vbai-2.6AD.pt", help="Vbai-2.6AD checkpoint")
128
+ ap.add_argument("--out", required=True, help="output .pt path")
129
+ ap.add_argument("--tbm", action="store_true")
130
+ ap.add_argument("--t1", action="store_true")
131
+ ap.add_argument("--batch-size", type=int, default=4)
132
+ ap.add_argument("--workers", type=int, default=2)
133
+ args = ap.parse_args()
134
+
135
+ dev = torch.device("cuda" if torch.cuda.is_available() else "cpu")
136
+ print(f"[device] {dev} | [modality] {MODALITY} | "
137
+ f"[manifest] {os.path.basename(C.PAIRED_PARQUET)}")
138
+
139
+ sd = torch.load(args.ckpt, map_location=dev, weights_only=False)
140
+ mcfg = C.ModelConfig()
141
+ for k, v in sd.get("model_cfg", {}).items():
142
+ if hasattr(mcfg, k):
143
+ setattr(mcfg, k, v)
144
+ model = Vbai26ADModel(mcfg).to(dev)
145
+ res = model.load_state_dict(sd["model"], strict=False)
146
+ # Loading with strict=False and ignoring the result is how a silently wrong
147
+ # checkpoint slips through, so any key mismatch is fatal here.
148
+ if res.missing_keys or res.unexpected_keys:
149
+ raise RuntimeError(
150
+ f"Checkpoint does not match the architecture: "
151
+ f"{len(res.missing_keys)} missing / {len(res.unexpected_keys)} "
152
+ f"unexpected keys. Wrong model file?")
153
+ model.eval()
154
+ for p in model.parameters():
155
+ p.requires_grad_(False)
156
+ print(f"[ckpt] {len(sd['model'])} keys matched | {sd.get('extra', {}).get('metrics')}")
157
+
158
+ norm = TabularNormalizer()
159
+ norm.load_state_dict(sd["norm"])
160
+ feat_names = sd.get("feature_names", C.FEATURE_NAMES)
161
+
162
+ df = remap_paths(load_paired())
163
+ train_ids, val_ids, test_ids = subject_split(df)
164
+ split_of = {}
165
+ for s, ids in (("train", train_ids), ("val", val_ids), ("test", test_ids)):
166
+ for i in ids:
167
+ split_of[i] = s
168
+ df["split"] = df["ptid"].map(split_of)
169
+
170
+ ds = PairedVisitDataset(df, norm, mode="multi", augment=False, mcfg=mcfg)
171
+ dl = torch.utils.data.DataLoader(ds, batch_size=args.batch_size, shuffle=False,
172
+ collate_fn=collate_pad, num_workers=args.workers)
173
+
174
+ acc = {k: [] for k in ["fused_features", "mri_features", "tab_features",
175
+ "class_probs", "will_progress", "label"]}
176
+ n_seen = 0
177
+ with torch.no_grad():
178
+ for b in tqdm(dl, desc="extracting features"):
179
+ if "mri" not in b or "tab" not in b:
180
+ raise RuntimeError("Batch is missing mri or tab — "
181
+ "modality dropout must be off here.")
182
+ out = model(mri=b["mri"].to(dev), tab=b["tab"].to(dev))
183
+ acc["fused_features"].append(out["fused_features"].cpu())
184
+ acc["mri_features"].append(out["mri_features"].cpu())
185
+ acc["tab_features"].append(out["tab_features"].cpu())
186
+ acc["class_probs"].append(torch.softmax(out["fused_logits"], -1).cpu())
187
+ acc["will_progress"].append(out["progression"]["will_progress"].cpu())
188
+ acc["label"].append(b["label"])
189
+ n_seen += b["label"].size(0)
190
+
191
+ store = {k: torch.cat(v).float() for k, v in acc.items()}
192
+ store["label"] = store["label"].long()
193
+
194
+ # DataLoader order matches df order (shuffle=False), so metadata lines up.
195
+ assert n_seen == len(df), f"sample count mismatch: {n_seen} vs {len(df)}"
196
+ store["ptid"] = df["ptid"].tolist()
197
+ store["split"] = df["split"].tolist()
198
+ store["nifti_path"] = df["nifti_path"].tolist()
199
+
200
+ # RAW (un-normalised) biomarker values plus their mask, for the text template.
201
+ vals = np.stack([np.where(np.isnan(df[f].values.astype(np.float32)), 0.0,
202
+ df[f].values.astype(np.float32)) for f in feat_names], axis=1)
203
+ mask = np.stack([df[f"feat_mask_{f}"].values.astype(np.float32) for f in feat_names], axis=1)
204
+ store["bio_values"] = torch.from_numpy(vals)
205
+ store["bio_mask"] = torch.from_numpy(mask)
206
+ store["feature_names"] = list(feat_names)
207
+ store["class_names"] = sd.get("class_names", C.CLASS_NAMES)
208
+ store["modality"] = MODALITY
209
+ store["ckpt"] = os.path.abspath(args.ckpt)
210
+
211
+ os.makedirs(os.path.dirname(os.path.abspath(args.out)) or ".", exist_ok=True)
212
+ torch.save(store, args.out)
213
+
214
+ from collections import Counter
215
+ print(f"\n[saved] {args.out}")
216
+ print(f" visits : {len(df)} | patients: {df['ptid'].nunique()}")
217
+ print(f" split : {Counter(store['split'])}")
218
+ print(f" fused_features: {tuple(store['fused_features'].shape)}")
219
+ print(f" bio_values : {tuple(store['bio_values'].shape)} (with mask)")
220
+ print(" measured rate : " + ", ".join(
221
+ f"{n}={store['bio_mask'][:, i].mean():.2f}" for i, n in enumerate(feat_names)))
222
+ print("\nNext: train the projector on this cache (the LLM stays frozen).")
223
+
224
+
225
+ if __name__ == "__main__":
226
+ main()
extract_roi.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Regional (ROI) extraction
3
+ =========================
4
+ Pulls anatomical region measurements for each visit out of a FreeSurfer summary
5
+ table that ships alongside the imaging data, and writes them next to the feature
6
+ cache.
7
+
8
+ WHY NO ATLAS AND NO REGISTRATION:
9
+ The input volumes live in a non-MNI template space and their NIfTI affine is the
10
+ identity — the file does not say where that space sits relative to MNI. Applying
11
+ an MNI atlas directly would SILENTLY measure the wrong regions, and a system that
12
+ reports "hippocampal atrophy" while measuring somewhere else is the most
13
+ dangerous kind of clinical error. The template itself is not publicly available
14
+ either.
15
+
16
+ The way out: the imaging archive already ships FreeSurfer segmentations. Using
17
+ that table gives a validated segmentation for free, with no registration step and
18
+ no silent misalignment.
19
+
20
+ IMPORTANT — these measurements come from FreeSurfer on T1, not from our encoder.
21
+ They therefore belong on the INPUT side (like the biomarkers), never in the
22
+ target. What the soft tokens carry stays class + risk.
23
+
24
+ Volumes are divided by intracranial volume and reported as a percentage, which
25
+ removes head-size effects; cortical thicknesses are left raw, being already
26
+ size-independent.
27
+
28
+ YOU MUST SET YOUR OWN PATHS: --tables-dir must point at your own directory of
29
+ FreeSurfer summary tables, and --table at the file within it. The region names in
30
+ PANEL are anatomy, not dataset identifiers, and should work with any FreeSurfer
31
+ aseg/aparc export.
32
+
33
+ Note: the display labels are Turkish because they are rendered verbatim into the
34
+ Turkish prompt text the model was trained on.
35
+
36
+ Run:
37
+ python extract_roi.py \
38
+ --features features.pt \
39
+ --tables-dir /path/to/freesurfer/tables \
40
+ --table regions.rda \
41
+ --out roi.parquet
42
+ """
43
+ from __future__ import annotations
44
+ import argparse
45
+ import os
46
+ import re
47
+
48
+ import numpy as np
49
+ import pandas as pd
50
+
51
+ # Clinically meaningful panel for Alzheimer's disease. Region names follow the
52
+ # FreeSurfer aseg/aparc convention; the column codes are NOT hard-coded but
53
+ # resolved by name from the data dictionary, so they survive table revisions.
54
+ PANEL = [
55
+ # (display label, FreeSurfer region name, measurement kind, is a volume)
56
+ ("Hipokampus (sol)", "LeftHippocampus", "SV", True),
57
+ ("Hipokampus (sağ)", "RightHippocampus", "SV", True),
58
+ ("Entorinal korteks (sol)", "LeftEntorhinal", "CV", True),
59
+ ("Entorinal korteks (sağ)", "RightEntorhinal", "CV", True),
60
+ ("Entorinal kalınlık (sol)", "LeftEntorhinal", "TA", False),
61
+ ("Entorinal kalınlık (sağ)", "RightEntorhinal", "TA", False),
62
+ ("Orta temporal (sol)", "LeftMiddleTemporal", "CV", True),
63
+ ("Orta temporal (sağ)", "RightMiddleTemporal", "CV", True),
64
+ ("Fusiform (sol)", "LeftFusiform", "CV", True),
65
+ ("Fusiform (sağ)", "RightFusiform", "CV", True),
66
+ ("Prekuneus (sol)", "LeftPrecuneus", "CV", True),
67
+ ("Prekuneus (sağ)", "RightPrecuneus", "CV", True),
68
+ ("Alt parietal (sol)", "LeftInferiorParietal", "CV", True),
69
+ ("Alt parietal (sağ)", "RightInferiorParietal", "CV", True),
70
+ ("Lateral ventrikül (sol)", "LeftLateralVentricle", "SV", True),
71
+ ("Lateral ventrikül (sağ)", "RightLateralVentricle", "SV", True),
72
+ ("Alt lateral ventrikül (sol)", "LeftInferiorLateralVentricle", "SV", True),
73
+ ("Alt lateral ventrikül (sağ)", "RightInferiorLateralVentricle", "SV", True),
74
+ ]
75
+ ICV_REGION = "Icv"
76
+
77
+
78
+ def read_rda(data_dir: str, name: str) -> pd.DataFrame:
79
+ import pyreadr
80
+ o = pyreadr.read_r(os.path.join(data_dir, name))
81
+ return o[list(o.keys())[0]]
82
+
83
+
84
+ def build_code_map(dd: pd.DataFrame, table: str, columns) -> dict:
85
+ """Map 'region name + measurement kind' → column code, using the data dictionary."""
86
+ t = dd[dd["TBLNAME"].astype(str) == table]
87
+ out = {}
88
+ for r in t.itertuples():
89
+ fld, txt = str(r.FLDNAME), str(r.TEXT)
90
+ if fld not in columns or not re.match(r"^ST\d+", fld):
91
+ continue
92
+ m = re.search(r"\bof\s+(\w+)\s*$", txt)
93
+ if not m:
94
+ continue
95
+ region = m.group(1)
96
+ kind = re.sub(r"^ST\d+", "", fld)
97
+ out[(region.lower(), kind)] = fld
98
+ return out
99
+
100
+
101
+ def main():
102
+ ap = argparse.ArgumentParser()
103
+ ap.add_argument("--features", required=True,
104
+ help="output of extract_features.py (.pt)")
105
+ ap.add_argument("--tables-dir", required=True,
106
+ help="YOUR OWN directory of FreeSurfer summary tables "
107
+ "(must also contain the data dictionary)")
108
+ ap.add_argument("--out", required=True, help="output .parquet")
109
+ ap.add_argument("--table", required=True,
110
+ help="region table filename inside --tables-dir")
111
+ ap.add_argument("--dictionary", default="DATADIC.rda",
112
+ help="data dictionary filename inside --tables-dir")
113
+ ap.add_argument("--max-days", type=int, default=180,
114
+ help="maximum days between the scan and the FreeSurfer exam")
115
+ args = ap.parse_args()
116
+
117
+ import torch
118
+ d = torch.load(args.features, map_location="cpu", weights_only=False)
119
+ visits = pd.DataFrame({"ptid": [str(p) for p in d["ptid"]],
120
+ "split": list(d["split"])})
121
+ visits["order"] = np.arange(len(visits))
122
+
123
+ print("[roi] reading the FreeSurfer table...")
124
+ fs = read_rda(args.tables_dir, args.table)
125
+ dd = read_rda(args.tables_dir, args.dictionary)
126
+ for c in ("TBLNAME", "FLDNAME", "TEXT"):
127
+ dd[c] = dd[c].astype(str)
128
+
129
+ table_name = args.table.replace(".rda", "")
130
+ codes = build_code_map(dd, table_name, set(fs.columns))
131
+ print(f"[roi] resolved {len(codes)} region/measurement codes ({table_name})")
132
+
133
+ icv_code = codes.get((ICV_REGION.lower(), "CV"))
134
+ if icv_code is None:
135
+ raise RuntimeError("Intracranial volume column not found — volumes "
136
+ "cannot be normalised.")
137
+ print(f"[roi] ICV column: {icv_code}")
138
+
139
+ resolved, missing = [], []
140
+ for label, region, kind, is_vol in PANEL:
141
+ code = codes.get((region.lower(), kind))
142
+ (resolved if code else missing).append(
143
+ (label, region, kind, is_vol, code) if code else (label, region, kind))
144
+ if missing:
145
+ print(f"[roi] WARNING: {len(missing)} regions not found → "
146
+ f"{[m[0] for m in missing]}")
147
+ print(f"[roi] regions resolved in the panel: {len(resolved)}")
148
+
149
+ fs["PTID"] = fs["PTID"].astype(str)
150
+ fs["EXAMDATE"] = pd.to_datetime(fs["EXAMDATE"], errors="coerce")
151
+ keep = [icv_code] + [r[4] for r in resolved]
152
+ fs = fs.dropna(subset=["EXAMDATE"])[["PTID", "EXAMDATE"] + keep]
153
+ fs = fs[fs[icv_code].notna()]
154
+
155
+ # Scan dates come from the visit manifest; the feature cache stores no dates.
156
+ import sys
157
+ model_dir = os.environ.get("VBAI_MODEL_DIR") or os.path.dirname(os.path.abspath(__file__))
158
+ sys.path.insert(0, model_dir)
159
+ os.environ.setdefault("VBAI_USE_TBM", "1")
160
+ import config as C
161
+ scans = pd.read_parquet(C.PAIRED_PARQUET)[["ptid", "scan_date"]].copy()
162
+ scans["ptid"] = scans["ptid"].astype(str)
163
+ scans["scan_date"] = pd.to_datetime(scans["scan_date"], errors="coerce")
164
+ scans["order"] = np.arange(len(scans))
165
+ assert len(scans) == len(visits), \
166
+ "feature cache and visit manifest are out of order"
167
+
168
+ rows, gap_days, n_hit = [], [], 0
169
+ grouped = dict(list(fs.groupby("PTID")))
170
+ for r in scans.itertuples():
171
+ sub = grouped.get(r.ptid)
172
+ rec = {"ptid": r.ptid, "order": r.order}
173
+ if sub is None or pd.isna(r.scan_date):
174
+ rows.append(rec); gap_days.append(np.nan); continue
175
+ gaps = (sub["EXAMDATE"] - r.scan_date).abs().dt.days
176
+ j = gaps.idxmin()
177
+ gap = int(gaps.loc[j])
178
+ gap_days.append(gap)
179
+ if gap > args.max_days:
180
+ rows.append(rec); continue
181
+ n_hit += 1
182
+ icv = float(sub.loc[j, icv_code])
183
+ for label, region, kind, is_vol, code in resolved:
184
+ v = sub.loc[j, code]
185
+ if pd.isna(v):
186
+ continue
187
+ # Volumes as a percentage of ICV; thicknesses raw (mm).
188
+ rec[label] = float(v) / icv * 100.0 if (is_vol and icv > 0) else float(v)
189
+ rec["_icv"] = icv
190
+ rec["_gap_days"] = gap
191
+ rows.append(rec)
192
+
193
+ out = pd.DataFrame(rows).sort_values("order").reset_index(drop=True)
194
+ out.to_parquet(args.out, index=False)
195
+
196
+ g = pd.Series(gap_days)
197
+ print(f"\n[saved] {args.out}")
198
+ print(f" matched visits : {n_hit}/{len(scans)} (±{args.max_days} days)")
199
+ print(f" date gap : median {g.median():.0f}, "
200
+ f"{100*(g <= 30).mean():.0f}% within ±30 days")
201
+ label_of = dict((r[4], r[0]) for r in resolved)
202
+ cov = {label_of[c]: f"{out[label_of[c]].notna().mean():.0%}"
203
+ for c in label_of if label_of[c] in out.columns}
204
+ print(" region coverage:")
205
+ for label in list(cov)[:6]:
206
+ print(f" {label:28s} {cov[label]}")
207
+ print(f" ... {len(resolved)} regions in total")
208
+ print("\nNext: pass --roi to build_text_dataset.py to add these to the prompt.")
209
+
210
+
211
+ if __name__ == "__main__":
212
+ main()
hfx_runtime.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Runtime pieces shared by inference and evaluation.
3
+
4
+ Everything needed to LOAD and RUN a released checkpoint lives here: the
5
+ projector architecture, the sentinel that marks where soft tokens are spliced
6
+ in, and the dataset loader. Training code is intentionally not part of this
7
+ module — the released artefact is the checkpoint, not the training loop.
8
+ """
9
+ from __future__ import annotations
10
+ import json
11
+
12
+ import torch
13
+ import torch.nn as nn
14
+ import torch.nn.functional as F
15
+
16
+ # Marks the splice point in the prompt where soft tokens replace text.
17
+ SENTINEL = "<<<MRI_SOFT_TOKENS>>>"
18
+
19
+
20
+ class Projector(nn.Module):
21
+ """
22
+ Classifier representations → k soft tokens in the LLM's embedding space.
23
+
24
+ Follows Gemma 4 Unified's patch path: LayerNorm → Dense → LayerNorm, then
25
+ the shared multimodal embedder pattern (RMSNorm → Linear). The learnable
26
+ token-position embedding lets the k tokens differentiate from one another —
27
+ a flattened version of Gemma's factorized 2D positional embedding, since our
28
+ sequence is linear rather than a grid.
29
+ """
30
+
31
+ def __init__(self, in_dim: int, hidden: int, n_tokens: int = 4, mid: int = 2048,
32
+ target_norm: float | None = None):
33
+ """
34
+ target_norm: the mean L2 norm of the LLM's own token embeddings. When
35
+ given, the output is rescaled to it.
36
+
37
+ Why: left unconstrained, the projector emits vectors at a magnitude the
38
+ model has never seen, and generation degenerates into looping the same
39
+ phrase. That is exactly what the first run did — output with soft tokens
40
+ was broken while the zeroed-out version was fine. Fixing the norm
41
+ prevents it, and is standard practice in VLM training.
42
+ """
43
+ super().__init__()
44
+ self.n_tokens = n_tokens
45
+ self.hidden = hidden
46
+ self.pre = nn.Sequential(
47
+ nn.LayerNorm(in_dim),
48
+ nn.Linear(in_dim, mid),
49
+ nn.GELU(),
50
+ nn.LayerNorm(mid),
51
+ )
52
+ self.to_tokens = nn.Linear(mid, n_tokens * hidden)
53
+ self.pos = nn.Parameter(torch.zeros(1, n_tokens, hidden))
54
+ self.out_norm = nn.RMSNorm(hidden) if hasattr(nn, "RMSNorm") else nn.LayerNorm(hidden)
55
+ self.out = nn.Linear(hidden, hidden)
56
+ nn.init.normal_(self.pos, std=0.02)
57
+ self.register_buffer("target_norm",
58
+ torch.tensor(float(target_norm)) if target_norm else
59
+ torch.tensor(0.0))
60
+
61
+ def forward(self, feats: torch.Tensor) -> torch.Tensor: # (B, in_dim)
62
+ h = self.pre(feats)
63
+ t = self.to_tokens(h).view(-1, self.n_tokens, self.hidden)
64
+ t = t + self.pos
65
+ t = self.out(self.out_norm(t))
66
+ if float(self.target_norm) > 0:
67
+ t = F.normalize(t, dim=-1) * self.target_norm
68
+ return t # (B, k, hidden)
69
+
70
+
71
+ def apply_template(tok, prompt_text: str) -> str:
72
+ """Chat template with thinking OFF (targets contain no chain of thought)."""
73
+ msgs = [{"role": "user", "content": prompt_text}]
74
+ kw = dict(tokenize=False, add_generation_prompt=True)
75
+ try:
76
+ return tok.apply_chat_template(msgs, enable_thinking=False, **kw)
77
+ except TypeError:
78
+ return tok.apply_chat_template(msgs, **kw)
79
+
80
+
81
+ def build_examples(features_path: str, text_path: str):
82
+ """Join the cached classifier features with the rendered text dataset."""
83
+ d = torch.load(features_path, map_location="cpu", weights_only=False)
84
+ with open(text_path, encoding="utf-8") as f:
85
+ td = json.load(f)
86
+
87
+ feats = torch.cat([d["fused_features"], d["mri_features"], d["tab_features"]], dim=-1)
88
+ recs = td["records"]
89
+ # The multitask file holds several records per patient sharing one index, so
90
+ # row counts are not expected to match — only the index bound is checked.
91
+ max_idx = max(r["index"] for r in recs)
92
+ if max_idx >= feats.size(0):
93
+ raise ValueError(f"index {max_idx} in the text file exceeds the feature "
94
+ f"cache size ({feats.size(0)}) — same cache?")
95
+
96
+ cls = list(d["class_names"])
97
+ head_cls = [cls[int(i)] for i in d["class_probs"].argmax(dim=-1)]
98
+
99
+ out = {"train": [], "val": [], "test": []}
100
+ for r in recs:
101
+ out[r["split"]].append({
102
+ "feat": feats[r["index"]],
103
+ "prompt": r["prompt"],
104
+ "target": r["target"],
105
+ "label": r["label"],
106
+ "ptid": r["ptid"],
107
+ # The head's verdict is read DIRECTLY from class_probs. Evaluation
108
+ # used to regex it out of the prompt, which produced head=None
109
+ # whenever the prompt omitted that line and silently broke the
110
+ # faithfulness metric.
111
+ "head": head_cls[r["index"]],
112
+ "task": r.get("task"),
113
+ # index is kept so chat.py can read head outputs (class_probs,
114
+ # will_progress) from the cache and place them in the system prompt.
115
+ "index": r["index"],
116
+ })
117
+ print(f"[data] train {len(out['train'])} / val {len(out['val'])} / "
118
+ f"test {len(out['test'])} | feature dim {feats.size(1)}")
119
+ return out, d
model.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Vbai-2.6AD Model
3
+ ================
4
+ Multimodal Alzheimer's classifier with REAL MRI<->biomarker pairing.
5
+
6
+ Streams:
7
+ * MRI encoder : 3D ResNet (CBAM/SE) + ASPP → 512-d
8
+ * Tabular encoder: MLP on (values + missing-mask) → 256-d
9
+ * Fusion : bidirectional cross-attention + gated combine → 512-d
10
+
11
+ Heads:
12
+ * mri_logits : Stage-1 MRI-only prediction
13
+ * tab_logits : Tabular-only prediction (used as auxiliary)
14
+ * fused_logits : Final 3-way classification (CN/MCI/AD)
15
+ * progression : will_progress (sigmoid), time_to_conversion (months),
16
+ time_distribution (24 bins, 5-month resolution)
17
+
18
+ Training-time tricks (in dataset/loss, not here):
19
+ * modality dropout
20
+ * per-feature random masking
21
+ * cross-modal contrastive loss
22
+ """
23
+ from __future__ import annotations
24
+ import torch
25
+ import torch.nn as nn
26
+ import torch.nn.functional as F
27
+
28
+ import config as C
29
+
30
+
31
+ # ============================================================
32
+ # Attention modules (3D)
33
+ # ============================================================
34
+ class ChannelAttention3D(nn.Module):
35
+ def __init__(self, ch, r=16):
36
+ super().__init__()
37
+ m = max(ch // r, 8)
38
+ self.mlp = nn.Sequential(nn.Linear(ch, m), nn.ReLU(inplace=True), nn.Linear(m, ch))
39
+
40
+ def forward(self, x):
41
+ a = x.mean(dim=[2, 3, 4]); b = x.amax(dim=[2, 3, 4])
42
+ attn = torch.sigmoid(self.mlp(a) + self.mlp(b))
43
+ return x * attn[..., None, None, None]
44
+
45
+
46
+ class SpatialAttention3D(nn.Module):
47
+ def __init__(self, k=7):
48
+ super().__init__()
49
+ self.conv = nn.Conv3d(2, 1, k, padding=k // 2, bias=False)
50
+
51
+ def forward(self, x):
52
+ avg = x.mean(dim=1, keepdim=True); mx = x.amax(dim=1, keepdim=True)
53
+ attn = torch.sigmoid(self.conv(torch.cat([avg, mx], dim=1)))
54
+ return x * attn
55
+
56
+
57
+ class CBAM3D(nn.Module):
58
+ def __init__(self, ch, r=16):
59
+ super().__init__()
60
+ self.c = ChannelAttention3D(ch, r); self.s = SpatialAttention3D()
61
+ def forward(self, x): return self.s(self.c(x))
62
+
63
+
64
+ class SEBlock3D(nn.Module):
65
+ def __init__(self, ch, r=16):
66
+ super().__init__()
67
+ m = max(ch // r, 8)
68
+ self.fc = nn.Sequential(nn.Linear(ch, m), nn.ReLU(True), nn.Linear(m, ch), nn.Sigmoid())
69
+ def forward(self, x):
70
+ s = x.mean(dim=[2, 3, 4]); s = self.fc(s)[..., None, None, None]
71
+ return x * s
72
+
73
+
74
+ # ============================================================
75
+ # 3D residual building blocks
76
+ # ============================================================
77
+ class ResBlock3D(nn.Module):
78
+ def __init__(self, in_ch, out_ch, stride=1, use_cbam=True, use_se=True, drop_path=0.0):
79
+ super().__init__()
80
+ self.conv1 = nn.Conv3d(in_ch, out_ch, 3, stride, 1, bias=False)
81
+ self.bn1 = nn.BatchNorm3d(out_ch)
82
+ self.conv2 = nn.Conv3d(out_ch, out_ch, 3, 1, 1, bias=False)
83
+ self.bn2 = nn.BatchNorm3d(out_ch)
84
+ self.act = nn.GELU()
85
+ self.cbam = CBAM3D(out_ch) if use_cbam else nn.Identity()
86
+ self.se = SEBlock3D(out_ch) if use_se else nn.Identity()
87
+ self.drop_path = drop_path
88
+ self.skip = nn.Identity() if (in_ch == out_ch and stride == 1) else nn.Sequential(
89
+ nn.Conv3d(in_ch, out_ch, 1, stride, bias=False), nn.BatchNorm3d(out_ch))
90
+
91
+ def _stochastic(self, x):
92
+ if not self.training or self.drop_path == 0.0:
93
+ return x
94
+ keep = 1.0 - self.drop_path
95
+ mask = torch.empty(x.shape[0], 1, 1, 1, 1, device=x.device).bernoulli_(keep)
96
+ return x * mask / keep
97
+
98
+ def forward(self, x):
99
+ identity = self.skip(x)
100
+ out = self.act(self.bn1(self.conv1(x)))
101
+ out = self.bn2(self.conv2(out))
102
+ out = self.cbam(out); out = self.se(out)
103
+ out = self._stochastic(out)
104
+ return self.act(out + identity)
105
+
106
+
107
+ class ASPP3D(nn.Module):
108
+ def __init__(self, in_ch, out_ch, dilations=(1, 6, 12, 18)):
109
+ super().__init__()
110
+ per = out_ch // len(dilations)
111
+ self.branches = nn.ModuleList([
112
+ nn.Sequential(nn.Conv3d(in_ch, per, 3, padding=d, dilation=d, bias=False),
113
+ nn.BatchNorm3d(per), nn.GELU())
114
+ for d in dilations
115
+ ])
116
+ self.gp = nn.Sequential(
117
+ nn.AdaptiveAvgPool3d(1),
118
+ nn.Conv3d(in_ch, per, 1, bias=False),
119
+ nn.BatchNorm3d(per), nn.GELU())
120
+ self.fuse = nn.Sequential(nn.Conv3d(per * (len(dilations) + 1), out_ch, 1, bias=False),
121
+ nn.BatchNorm3d(out_ch), nn.GELU())
122
+
123
+ def forward(self, x):
124
+ feats = [b(x) for b in self.branches]
125
+ g = self.gp(x)
126
+ g = F.interpolate(g, size=x.shape[2:], mode="trilinear", align_corners=False)
127
+ feats.append(g)
128
+ return self.fuse(torch.cat(feats, dim=1))
129
+
130
+
131
+ # ============================================================
132
+ # MRI encoder
133
+ # ============================================================
134
+ class MRIEncoder3D(nn.Module):
135
+ def __init__(self, mcfg: C.ModelConfig):
136
+ super().__init__()
137
+ ch = mcfg.mri_encoder_channels
138
+ self.stem = nn.Sequential(
139
+ nn.Conv3d(1, ch[0], 7, 2, 3, bias=False), nn.BatchNorm3d(ch[0]), nn.GELU(),
140
+ nn.MaxPool3d(3, 2, 1))
141
+ depths = [2, 2, 2, 2]
142
+ dp = [0.0, 0.05, 0.1, 0.15]
143
+ self.stage1 = self._make(ch[0], ch[0], depths[0], 1, mcfg, dp[0])
144
+ self.stage2 = self._make(ch[0], ch[1], depths[1], 2, mcfg, dp[1])
145
+ self.stage3 = self._make(ch[1], ch[2], depths[2], 2, mcfg, dp[2])
146
+ self.stage4 = self._make(ch[2], ch[3], depths[3], 2, mcfg, dp[3])
147
+ self.aspp = ASPP3D(ch[3], mcfg.mri_bottleneck_channels)
148
+ self.pool = nn.AdaptiveAvgPool3d(1)
149
+ self.proj = nn.Sequential(
150
+ nn.Linear(mcfg.mri_bottleneck_channels, mcfg.mri_feature_dim),
151
+ nn.GELU(), nn.Dropout(mcfg.mri_dropout))
152
+
153
+ def _make(self, in_ch, out_ch, n, stride, mcfg, dp):
154
+ layers = [ResBlock3D(in_ch, out_ch, stride, mcfg.use_cbam, mcfg.use_se_block, dp)]
155
+ for _ in range(1, n):
156
+ layers.append(ResBlock3D(out_ch, out_ch, 1, mcfg.use_cbam, mcfg.use_se_block, dp))
157
+ return nn.Sequential(*layers)
158
+
159
+ def forward(self, x):
160
+ x = self.stem(x)
161
+ x = self.stage1(x); x = self.stage2(x); x = self.stage3(x); x = self.stage4(x)
162
+ x = self.aspp(x); x = self.pool(x).flatten(1)
163
+ return self.proj(x)
164
+
165
+
166
+ # ============================================================
167
+ # Tabular encoder
168
+ # ============================================================
169
+ class TabularEncoder(nn.Module):
170
+ def __init__(self, mcfg: C.ModelConfig):
171
+ super().__init__()
172
+ prev = mcfg.num_tabular_inputs
173
+ layers = []
174
+ for h in mcfg.tabular_hidden_dims:
175
+ layers += [nn.Linear(prev, h), nn.LayerNorm(h), nn.GELU(), nn.Dropout(mcfg.tabular_dropout)]
176
+ prev = h
177
+ layers += [nn.Linear(prev, mcfg.tabular_feature_dim)]
178
+ self.net = nn.Sequential(*layers)
179
+
180
+ def forward(self, x): # (B, num_tabular_inputs)
181
+ return self.net(x)
182
+
183
+
184
+ # ============================================================
185
+ # Cross-modal fusion
186
+ # ============================================================
187
+ class CrossModalFusion(nn.Module):
188
+ def __init__(self, mri_dim, tab_dim, fdim, heads=8, dropout=0.1):
189
+ super().__init__()
190
+ self.pm = nn.Linear(mri_dim, fdim); self.pt = nn.Linear(tab_dim, fdim)
191
+ self.a_mt = nn.MultiheadAttention(fdim, heads, dropout=dropout, batch_first=True)
192
+ self.a_tm = nn.MultiheadAttention(fdim, heads, dropout=dropout, batch_first=True)
193
+ self.lnm = nn.LayerNorm(fdim); self.lnt = nn.LayerNorm(fdim)
194
+ self.gate = nn.Sequential(nn.Linear(fdim * 2, fdim), nn.Sigmoid())
195
+ self.out = nn.Sequential(nn.Linear(fdim * 2, fdim), nn.GELU(), nn.Dropout(dropout))
196
+
197
+ def forward(self, m, t):
198
+ m1 = self.pm(m).unsqueeze(1); t1 = self.pt(t).unsqueeze(1)
199
+ ma, _ = self.a_mt(m1, t1, t1); ta, _ = self.a_tm(t1, m1, m1)
200
+ m2 = self.lnm(m1 + ma).squeeze(1); t2 = self.lnt(t1 + ta).squeeze(1)
201
+ cat = torch.cat([m2, t2], dim=-1)
202
+ g = self.gate(cat); o = self.out(cat)
203
+ return g * m2 + (1 - g) * t2 + o
204
+
205
+
206
+ # ============================================================
207
+ # Heads
208
+ # ============================================================
209
+ class ClsHead(nn.Module):
210
+ def __init__(self, in_dim, num_classes, dropout=0.3):
211
+ super().__init__()
212
+ self.h = nn.Sequential(
213
+ nn.Linear(in_dim, 256), nn.GELU(), nn.Dropout(dropout),
214
+ nn.Linear(256, 128), nn.GELU(), nn.Dropout(dropout),
215
+ nn.Linear(128, num_classes))
216
+ def forward(self, x): return self.h(x)
217
+
218
+
219
+ class ProgressionHead(nn.Module):
220
+ def __init__(self, in_dim, hidden=256, max_months=120, n_bins=24):
221
+ super().__init__()
222
+ self.max_months = float(max_months); self.n_bins = n_bins
223
+ self.shared = nn.Sequential(nn.Linear(in_dim, hidden), nn.GELU(), nn.Dropout(0.3))
224
+ self.binary = nn.Linear(hidden, 1)
225
+ self.time = nn.Sequential(nn.Linear(hidden, 64), nn.GELU(), nn.Linear(64, 1))
226
+ self.dist = nn.Linear(hidden, n_bins)
227
+
228
+ def forward(self, x):
229
+ h = self.shared(x)
230
+ logits = self.binary(h).squeeze(-1)
231
+ return {
232
+ "will_progress_logits": logits, # raw for BCEWithLogits
233
+ "will_progress": torch.sigmoid(logits), # for inference convenience
234
+ "time_to_conversion": torch.clamp(F.softplus(self.time(h)).squeeze(-1),
235
+ min=0.0, max=self.max_months),
236
+ "time_distribution": F.softmax(self.dist(h), dim=-1),
237
+ }
238
+
239
+
240
+ # ============================================================
241
+ # Full model
242
+ # ============================================================
243
+ class Vbai26ADModel(nn.Module):
244
+ def __init__(self, mcfg: C.ModelConfig | None = None):
245
+ super().__init__()
246
+ self.cfg = mcfg or C.ModelConfig()
247
+ self.mri_encoder = MRIEncoder3D(self.cfg)
248
+ self.tab_encoder = TabularEncoder(self.cfg)
249
+
250
+ self.mri_classifier = ClsHead(self.cfg.mri_feature_dim, self.cfg.num_classes, self.cfg.mri_dropout)
251
+ self.tab_classifier = ClsHead(self.cfg.tabular_feature_dim, self.cfg.num_classes, self.cfg.tabular_dropout)
252
+
253
+ self.fusion = CrossModalFusion(
254
+ self.cfg.mri_feature_dim, self.cfg.tabular_feature_dim,
255
+ self.cfg.fusion_dim, self.cfg.fusion_num_heads, self.cfg.fusion_dropout)
256
+ self.fused_classifier = ClsHead(self.cfg.fusion_dim, self.cfg.num_classes, self.cfg.fusion_dropout)
257
+
258
+ self.progression_head = ProgressionHead(
259
+ self.cfg.fusion_dim, self.cfg.progression_hidden_dim,
260
+ self.cfg.max_progression_months, self.cfg.num_time_bins)
261
+
262
+ # Contrastive projection heads (used only at training time)
263
+ self.contrast_mri = nn.Sequential(nn.Linear(self.cfg.mri_feature_dim, 128))
264
+ self.contrast_tab = nn.Sequential(nn.Linear(self.cfg.tabular_feature_dim, 128))
265
+
266
+ def forward(self, mri=None, tab=None):
267
+ out = {}
268
+ m_feat = t_feat = None
269
+ if mri is not None:
270
+ m_feat = self.mri_encoder(mri)
271
+ out["mri_features"] = m_feat
272
+ out["mri_logits"] = self.mri_classifier(m_feat)
273
+ if tab is not None:
274
+ t_feat = self.tab_encoder(tab)
275
+ out["tab_features"] = t_feat
276
+ out["tab_logits"] = self.tab_classifier(t_feat)
277
+ if m_feat is not None and t_feat is not None:
278
+ f = self.fusion(m_feat, t_feat)
279
+ out["fused_features"] = f
280
+ out["fused_logits"] = self.fused_classifier(f)
281
+ out["progression"] = self.progression_head(f)
282
+ # Contrastive embeddings
283
+ out["zm"] = F.normalize(self.contrast_mri(m_feat), dim=-1)
284
+ out["zt"] = F.normalize(self.contrast_tab(t_feat), dim=-1)
285
+ elif m_feat is not None:
286
+ out["fused_logits"] = out["mri_logits"]
287
+ elif t_feat is not None:
288
+ out["fused_logits"] = out["tab_logits"]
289
+ return out
290
+
291
+ def get_param_groups(self, lr_backbone, lr_fusion):
292
+ backbone = list(self.mri_encoder.parameters()) + list(self.tab_encoder.parameters())
293
+ fusion = (list(self.fusion.parameters()) + list(self.fused_classifier.parameters())
294
+ + list(self.progression_head.parameters())
295
+ + list(self.mri_classifier.parameters())
296
+ + list(self.tab_classifier.parameters())
297
+ + list(self.contrast_mri.parameters()) + list(self.contrast_tab.parameters()))
298
+ return [{"params": backbone, "lr": lr_backbone},
299
+ {"params": fusion, "lr": lr_fusion}]
300
+
301
+ @torch.no_grad()
302
+ def predict(self, mri=None, tab=None):
303
+ self.eval()
304
+ out = self.forward(mri=mri, tab=tab)
305
+ probs = F.softmax(out["fused_logits"], dim=-1)
306
+ pred = probs.argmax(dim=-1)
307
+ result = {"pred_class": pred, "class_probs": probs,
308
+ "class_names": [C.CLASS_NAMES[c] for c in pred.cpu().tolist()]}
309
+ if "progression" in out:
310
+ p = out["progression"]
311
+ result["will_progress"] = p["will_progress"]
312
+ result["time_to_conversion_months"] = p["time_to_conversion"]
313
+ result["time_distribution"] = p["time_distribution"]
314
+ return result
315
+
316
+
317
+ def count_params(model):
318
+ return sum(p.numel() for p in model.parameters() if p.requires_grad)
mri_contribution.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MRI contribution ablation
3
+ =========================
4
+ Question: does giving the model the MRI actually change the fused prediction?
5
+
6
+ Why this test: `mri_logits` is only an auxiliary (deep-supervision) head. A weak
7
+ score there does NOT prove the MRI representation is uninformative. The fair
8
+ test compares three conditions on the same patients:
9
+
10
+ 1. MRI + biomarkers (the production setting)
11
+ 2. biomarkers only (MRI switched off)
12
+ 3. MRI only (biomarkers switched off)
13
+
14
+ If (1) and (2) do not differ meaningfully, the MRI is not participating in the
15
+ decision.
16
+
17
+ CRITICAL — the checkpoint and the input modality must match. Each checkpoint is
18
+ trained on a single volume modality; feeding it the other one drops the MRI
19
+ branch to chance and makes the result completely misleading. That is why the
20
+ modality flag is mandatory:
21
+
22
+ python mri_contribution.py --tbm --ckpt Vbai-2.6AD.pt
23
+ python mri_contribution.py --t1 --ckpt <a T1-trained checkpoint>
24
+ """
25
+ from __future__ import annotations
26
+ import argparse
27
+ import os
28
+ import sys
29
+
30
+ # --- The modality must be chosen BEFORE config is imported: config decides at
31
+ # import time which visit manifest to read.
32
+ _ap = argparse.ArgumentParser(add_help=False)
33
+ _ap.add_argument("--tbm", action="store_true")
34
+ _ap.add_argument("--t1", action="store_true")
35
+ _known, _ = _ap.parse_known_args()
36
+ if _known.tbm == _known.t1:
37
+ sys.exit("ERROR: pass exactly one of --tbm / --t1 "
38
+ "(match whichever modality the checkpoint was trained on).")
39
+ os.environ["VBAI_USE_TBM"] = "1" if _known.tbm else "0"
40
+ MODALITY = "TBM" if _known.tbm else "raw T1"
41
+
42
+
43
+ def _bootstrap_model_path() -> str:
44
+ """
45
+ Locate config.py / model.py / dataset.py.
46
+
47
+ YOU MUST SET YOUR OWN PATH if they are not next to this script: point
48
+ VBAI_MODEL_DIR at the directory holding them.
49
+ """
50
+ env = os.environ.get("VBAI_MODEL_DIR")
51
+ cands = ([env] if env else []) + [
52
+ os.path.dirname(os.path.abspath(__file__)),
53
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "Vbai-2.6AD"),
54
+ ]
55
+ for c in cands:
56
+ if c and os.path.isfile(os.path.join(c, "config.py")):
57
+ if c not in sys.path:
58
+ sys.path.insert(0, c)
59
+ return c
60
+ raise ImportError(
61
+ f"Could not locate the model modules (config.py). Tried: {cands}\n"
62
+ "YOU MUST SET YOUR OWN PATH: point VBAI_MODEL_DIR at the directory "
63
+ "holding config.py / model.py / dataset.py."
64
+ )
65
+
66
+
67
+ MODEL_DIR = _bootstrap_model_path()
68
+
69
+ import numpy as np
70
+ import torch
71
+ from sklearn.metrics import balanced_accuracy_score, f1_score, confusion_matrix
72
+
73
+ import config as C
74
+ from model import Vbai26ADModel
75
+ from dataset import (PairedVisitDataset, TabularNormalizer, collate_pad,
76
+ subject_split, load_paired)
77
+
78
+
79
+ def remap_paths(df):
80
+ """Re-root the absolute volume paths stored in the manifest to this machine."""
81
+ def _fix(p):
82
+ p0 = str(p)
83
+ if os.path.exists(p0):
84
+ return p0
85
+ q = p0.replace("\\", "/")
86
+ i = q.find("/Datasets/")
87
+ if i >= 0:
88
+ cand = os.path.join(C.DATASET_ROOT, q[i + len("/Datasets/"):])
89
+ if os.path.exists(cand):
90
+ return cand
91
+ # The stored tail may or may not include the top volume folder, so both
92
+ # spellings are tried.
93
+ j = q.find("/volumes/")
94
+ if j >= 0:
95
+ rest = q[j + len("/volumes/"):]
96
+ for cand in (os.path.join(C.TBM_ROOT, rest),
97
+ os.path.join(C.TBM_ROOT, "volumes", rest)):
98
+ if os.path.exists(cand):
99
+ return cand
100
+ return p0
101
+
102
+ df = df.copy()
103
+ df["nifti_path"] = df["nifti_path"].map(_fix)
104
+ ok = int(sum(os.path.exists(str(p)) for p in df["nifti_path"]))
105
+ print(f"[path] reachable images: {ok}/{len(df)}")
106
+ if ok == 0:
107
+ raise FileNotFoundError(
108
+ f"No image is reachable ({MODALITY}).\n"
109
+ f" DATASET_ROOT = {C.DATASET_ROOT}\n"
110
+ f" VOLUME_ROOT = {C.TBM_ROOT}\n"
111
+ "YOU MUST SET YOUR OWN PATHS: see VBAI_DATASET_ROOT / "
112
+ "VBAI_VOLUME_ROOT in config.py."
113
+ )
114
+ return df[df["nifti_path"].map(lambda p: os.path.exists(str(p)))].reset_index(drop=True)
115
+
116
+
117
+ def main():
118
+ ap = argparse.ArgumentParser()
119
+ ap.add_argument("--ckpt", default="Vbai-2.6AD.pt", help="Vbai-2.6AD checkpoint")
120
+ ap.add_argument("--tbm", action="store_true")
121
+ ap.add_argument("--t1", action="store_true")
122
+ ap.add_argument("--n-per-class", type=int, default=0,
123
+ help="0 = the whole test split")
124
+ ap.add_argument("--batch-size", type=int, default=4)
125
+ ap.add_argument("--workers", type=int, default=2)
126
+ args = ap.parse_args()
127
+
128
+ dev = torch.device("cuda" if torch.cuda.is_available() else "cpu")
129
+ print(f"[device] {dev}")
130
+ print(f"[modality] {MODALITY} (USE_TBM={C.USE_TBM})")
131
+ print(f"[manifest] {os.path.basename(C.PAIRED_PARQUET)}")
132
+
133
+ sd = torch.load(args.ckpt, map_location=dev, weights_only=False)
134
+ mcfg = C.ModelConfig()
135
+ for k, v in sd.get("model_cfg", {}).items():
136
+ if hasattr(mcfg, k):
137
+ setattr(mcfg, k, v)
138
+ model = Vbai26ADModel(mcfg).to(dev)
139
+ res = model.load_state_dict(sd["model"], strict=False)
140
+ if res.missing_keys or res.unexpected_keys:
141
+ raise RuntimeError(f"Checkpoint does not match the architecture: "
142
+ f"{len(res.missing_keys)} missing / "
143
+ f"{len(res.unexpected_keys)} unexpected keys.")
144
+ model.eval()
145
+ print(f"[ckpt] {len(sd['model'])} keys matched | "
146
+ f"stored metrics: {sd.get('extra', {}).get('metrics')}")
147
+
148
+ norm = TabularNormalizer()
149
+ norm.load_state_dict(sd["norm"])
150
+
151
+ df = remap_paths(load_paired())
152
+ _, _, test_ids = subject_split(df)
153
+ te = df[df["ptid"].isin(test_ids)]
154
+ if args.n_per_class > 0:
155
+ te = te.groupby("label", group_keys=False).head(args.n_per_class)
156
+ te = te.reset_index(drop=True)
157
+ print(f"[data] test {len(te)} scans | "
158
+ f"{te['label'].value_counts().sort_index().to_dict()}")
159
+
160
+ ds = PairedVisitDataset(te, norm, mode="multi", augment=False, mcfg=mcfg)
161
+ dl = torch.utils.data.DataLoader(ds, batch_size=args.batch_size, shuffle=False,
162
+ collate_fn=collate_pad, num_workers=args.workers)
163
+
164
+ conds = {"MRI + biomarkers": [], "biomarkers only": [], "MRI only": []}
165
+ ys = []
166
+ with torch.no_grad():
167
+ for b in dl:
168
+ if "mri" not in b or "tab" not in b:
169
+ continue
170
+ mri, tab = b["mri"].to(dev), b["tab"].to(dev)
171
+ ys.append(b["label"])
172
+ conds["MRI + biomarkers"].append(
173
+ model(mri=mri, tab=tab)["fused_logits"].argmax(-1).cpu())
174
+ conds["biomarkers only"].append(
175
+ model(mri=None, tab=tab)["fused_logits"].argmax(-1).cpu())
176
+ conds["MRI only"].append(
177
+ model(mri=mri, tab=None)["fused_logits"].argmax(-1).cpu())
178
+ ys = torch.cat(ys).numpy()
179
+
180
+ print("\n" + "=" * 62)
181
+ print(f" MRI CONTRIBUTION ABLATION — {MODALITY}")
182
+ print("=" * 62)
183
+ out = {}
184
+ for k, v in conds.items():
185
+ p = torch.cat(v).numpy()
186
+ out[k] = p
187
+ print(f" {k:22s} bal_acc={balanced_accuracy_score(ys, p):.3f} "
188
+ f"macroF1={f1_score(ys, p, average='macro'):.3f}")
189
+
190
+ full, tabonly = out["MRI + biomarkers"], out["biomarkers only"]
191
+ chg = full != tabonly
192
+ better = int((chg & (full == ys)).sum())
193
+ worse = int((chg & (tabonly == ys)).sum())
194
+ gain = balanced_accuracy_score(ys, full) - balanced_accuracy_score(ys, tabonly)
195
+
196
+ print(f"\n Predictions changed by adding MRI : {int(chg.sum())} / {len(ys)}")
197
+ print(f" corrected : {better}")
198
+ print(f" broken : {worse}")
199
+ print(f" bal_acc delta (net MRI contribution): {gain:+.3f}")
200
+ print("\n Confusion (MRI only) rows = true, columns = predicted [CN, MCI, AD]:")
201
+ print(" " + str(confusion_matrix(ys, out["MRI only"],
202
+ labels=[0, 1, 2])).replace("\n", "\n "))
203
+ print("-" * 62)
204
+ if gain > 0.02 and better > worse:
205
+ print(" → The MRI contributes MEANINGFULLY to the decision. The encoder")
206
+ print(" is sound, and feeding visual tokens to the LLM is justified.")
207
+ elif abs(gain) <= 0.02:
208
+ print(" → The net MRI contribution is within measurement noise. The")
209
+ print(" encoder signal is weak; read this together with token_probe.")
210
+ else:
211
+ print(" → The MRI makes results WORSE. Re-check the input/checkpoint")
212
+ print(" modality match and the preprocessing.")
213
+ print("=" * 62)
214
+
215
+
216
+ if __name__ == "__main__":
217
+ main()
probe_features.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Feature probe
3
+ =============
4
+ Question: do the vectors produced by extract_features.py actually CONTAIN the
5
+ class information?
6
+
7
+ Why this matters: when the LLM side underperforms there are two very different
8
+ causes —
9
+ (a) the information is not in the vector → encoder / feature problem
10
+ (b) it is there but the LLM cannot read it → channel problem
11
+ These call for opposite fixes. Fitting a plain LOGISTIC REGRESSION on the vector
12
+ separates them: if a linear layer can recover the class, the information is
13
+ present and the fault lies in the channel.
14
+
15
+ Measurement on the released checkpoint (subject-level split, 123 test cases):
16
+ fused (512) bal_acc 0.880 ← the head itself scores 0.895
17
+ tab (256) bal_acc 0.855
18
+ mri (512) bal_acc 0.410 (chance is 0.333)
19
+ Conclusion: the information is present and linearly decodable. Early failures of
20
+ projector + LoRA training were NOT a data or feature problem — they were a
21
+ channel problem.
22
+
23
+ Needs no GPU; runs in seconds.
24
+
25
+ Run:
26
+ python probe_features.py --features features.pt
27
+ """
28
+ from __future__ import annotations
29
+ import argparse
30
+
31
+ import numpy as np
32
+ import torch
33
+ from sklearn.linear_model import LogisticRegression
34
+ from sklearn.metrics import balanced_accuracy_score, f1_score, confusion_matrix
35
+ from sklearn.preprocessing import StandardScaler
36
+
37
+
38
+ def main():
39
+ ap = argparse.ArgumentParser()
40
+ ap.add_argument("--features", required=True)
41
+ ap.add_argument("--C", type=float, default=1.0,
42
+ help="logistic regression regularisation")
43
+ args = ap.parse_args()
44
+
45
+ d = torch.load(args.features, map_location="cpu", weights_only=False)
46
+ y = d["label"].numpy()
47
+ sp = np.array(d["split"])
48
+ cls = list(d["class_names"])
49
+ tr, te = sp == "train", sp == "test"
50
+
51
+ print(f"[data] {len(y)} records | "
52
+ f"{ {cls[i]: int((y == i).sum()) for i in range(len(cls))} }")
53
+ print(f"[split] train {int(tr.sum())} / test {int(te.sum())}\n")
54
+
55
+ sets = {
56
+ "fused (512)": d["fused_features"],
57
+ "mri (512)": d["mri_features"],
58
+ "tab (256)": d["tab_features"],
59
+ "all (1280)": torch.cat(
60
+ [d["fused_features"], d["mri_features"], d["tab_features"]], dim=-1),
61
+ }
62
+
63
+ best_name, best_ba = None, -1.0
64
+ for name, X in sets.items():
65
+ X = X.numpy()
66
+ sc = StandardScaler().fit(X[tr])
67
+ clf = LogisticRegression(max_iter=3000, C=args.C,
68
+ class_weight="balanced").fit(sc.transform(X[tr]), y[tr])
69
+ p = clf.predict(sc.transform(X[te]))
70
+ ba = balanced_accuracy_score(y[te], p)
71
+ print(f" {name:14s} bal_acc={ba:.3f} macroF1={f1_score(y[te], p, average='macro'):.3f}")
72
+ if ba > best_ba:
73
+ best_name, best_ba, best_pred = name, ba, p
74
+
75
+ hp = d["class_probs"].argmax(-1).numpy()
76
+ hb = balanced_accuracy_score(y[te], hp[te])
77
+ print(f"\n {'head (reference)':14s} bal_acc={hb:.3f} "
78
+ f"macroF1={f1_score(y[te], hp[te], average='macro'):.3f}")
79
+
80
+ print(f"\n Best vector: {best_name}")
81
+ print(f" Confusion (rows = true, columns = predicted {cls}):")
82
+ print(" " + str(confusion_matrix(y[te], best_pred)).replace("\n", "\n "))
83
+
84
+ print("\n" + "-" * 62)
85
+ if best_ba >= 0.8 * hb:
86
+ print(" → The information is present and linearly decodable.")
87
+ print(" If the LLM side misbehaves, the fault is in the CHANNEL")
88
+ print(" (projector / LoRA), not the features. No need to retrain")
89
+ print(" the encoder.")
90
+ else:
91
+ print(" → The vector does not carry what the head knows. Check feature")
92
+ print(" extraction first, and confirm the checkpoint matches the")
93
+ print(" input modality.")
94
+ print("-" * 62)
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()
projector.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f6084ee1523d9ccbd890963f814f26de14cc758c5834de84855a5bcc07681d75
3
+ size 195491118
projector.pt_lora/adapter_config.json ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "alora_invocation_tokens": null,
3
+ "alpha_pattern": {},
4
+ "arrow_config": null,
5
+ "auto_mapping": null,
6
+ "base_model_name_or_path": "google/gemma-4-12B-it",
7
+ "bias": "none",
8
+ "corda_config": null,
9
+ "ensure_weight_tying": false,
10
+ "eva_config": null,
11
+ "exclude_modules": null,
12
+ "fan_in_fan_out": false,
13
+ "inference_mode": true,
14
+ "init_lora_weights": true,
15
+ "layer_replication": null,
16
+ "layers_pattern": null,
17
+ "layers_to_transform": null,
18
+ "loftq_config": {},
19
+ "lora_alpha": 32,
20
+ "lora_bias": false,
21
+ "lora_dropout": 0.05,
22
+ "lora_ga_config": null,
23
+ "megatron_config": null,
24
+ "megatron_core": "megatron.core",
25
+ "modules_to_save": null,
26
+ "monteclora_config": null,
27
+ "peft_type": "LORA",
28
+ "peft_version": "0.20.0",
29
+ "qalora_group_size": 16,
30
+ "r": 16,
31
+ "rank_pattern": {},
32
+ "revision": null,
33
+ "target_modules": [
34
+ "q_proj",
35
+ "v_proj",
36
+ "o_proj",
37
+ "k_proj"
38
+ ],
39
+ "target_parameters": null,
40
+ "task_type": "CAUSAL_LM",
41
+ "trainable_token_indices": null,
42
+ "use_bdlora": null,
43
+ "use_dora": false,
44
+ "use_qalora": false,
45
+ "use_rslora": false,
46
+ "velora_config": null
47
+ }
projector.pt_lora/adapter_model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:de104595b1208c69f9255feb43fb3bebfb0284bf5aa7a1e75d79fb3cd33d5c3d
3
+ size 85382928
projector.pt_lora/desktop.ini ADDED
Binary file (246 Bytes). View file
 
token_probe.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Token probe — the GO / NO-GO experiment before touching the LLM
3
+ ==============================================================
4
+ Question: do the 3x3x3 = 27 spatial positions sitting before the encoder's
5
+ AdaptiveAvgPool3d(1) carry information worth handing to an LLM as "visual
6
+ tokens"?
7
+
8
+ Why this comes first: if the 27 tokens say nothing beyond the pooled 512-d
9
+ vector, wiring the encoder into the LLM as a token sequence is pointless — you
10
+ would just be moving the pipeline inside a transformer. Learning that in an hour
11
+ beats learning it after a week of projector training.
12
+
13
+ Method:
14
+ 1. Load the checkpoint and FREEZE the entire encoder.
15
+ 2. For every scan, compute the ASPP output (B, 512, 3, 3, 3) once and cache it
16
+ as a (B, 27, 512) token sequence. The encoder is frozen, so it cannot change
17
+ between epochs and the 3D CNN never has to run twice.
18
+ 3. Train two small probes on those tokens:
19
+ - mean-pool probe : discards spatial information (what the model does now)
20
+ - attention probe : weighted pooling with a learned query over 27 tokens
21
+ 4. Compare on the test split, and measure whether the attention is degenerate
22
+ (uniform vs selective) through its entropy.
23
+
24
+ Reading the result:
25
+ attention probe clearly ahead → the token grid is meaningful, wire it in.
26
+ probes equal and entropy ~max → no spatial information; fix the encoder's
27
+ strides / ASPP dilations first.
28
+
29
+ NOTE: the normalizer is loaded from the checkpoint and never re-fitted on the
30
+ evaluation data — re-fitting would leak.
31
+
32
+ This script uses the model modules (config, model, dataset); the path bootstrap
33
+ is below. YOU MUST SET YOUR OWN PATH via VBAI_MODEL_DIR if they are elsewhere.
34
+
35
+ Run:
36
+ python token_probe.py --ckpt Vbai-2.6AD.pt
37
+ python token_probe.py --ckpt ... --epochs 40 --batch-size 8
38
+ """
39
+ from __future__ import annotations
40
+ import argparse
41
+ import os
42
+ import sys
43
+
44
+ # ----------------------------------------------------------------------
45
+ # The modality must be chosen BEFORE config is imported: config decides at
46
+ # import time which visit manifest to read (USE_TBM).
47
+ #
48
+ # CRITICAL: the checkpoint and the input modality must match. If they do not,
49
+ # the encoder drops to chance and the probe result is meaningless.
50
+ # ----------------------------------------------------------------------
51
+ _ap = argparse.ArgumentParser(add_help=False)
52
+ _ap.add_argument("--tbm", action="store_true")
53
+ _ap.add_argument("--t1", action="store_true")
54
+ _known, _ = _ap.parse_known_args()
55
+ if _known.tbm == _known.t1:
56
+ sys.exit("ERROR: pass exactly one of --tbm / --t1 "
57
+ "(match whichever modality the checkpoint was trained on).")
58
+ os.environ["VBAI_USE_TBM"] = "1" if _known.tbm else "0"
59
+ MODALITY = "TBM" if _known.tbm else "raw T1"
60
+
61
+
62
+ # ----------------------------------------------------------------------
63
+ # Make the model modules importable.
64
+ # ----------------------------------------------------------------------
65
+ def _bootstrap_model_path() -> str:
66
+ env = os.environ.get("VBAI_MODEL_DIR")
67
+ candidates = []
68
+ if env:
69
+ candidates.append(env)
70
+ here = os.path.dirname(os.path.abspath(__file__))
71
+ candidates.append(here) # next to this file
72
+ candidates.append(os.path.join(here, "Vbai-2.6AD")) # ./Vbai-2.6AD
73
+ for c in candidates:
74
+ if os.path.isfile(os.path.join(c, "config.py")):
75
+ if c not in sys.path:
76
+ sys.path.insert(0, c)
77
+ return c
78
+ raise ImportError(
79
+ "Could not locate the model modules (config.py).\n"
80
+ f"Tried: {candidates}\n"
81
+ "YOU MUST SET YOUR OWN PATH: point VBAI_MODEL_DIR at the directory "
82
+ "holding config.py / model.py / dataset.py."
83
+ )
84
+
85
+
86
+ MODEL_DIR = _bootstrap_model_path()
87
+
88
+ import numpy as np
89
+ import torch
90
+ import torch.nn as nn
91
+ import torch.nn.functional as F
92
+ from sklearn.metrics import accuracy_score, f1_score
93
+
94
+ import config as C
95
+ from model import Vbai26ADModel
96
+ from dataset import (PairedVisitDataset, TabularNormalizer, collate_pad,
97
+ subject_split, load_paired)
98
+
99
+
100
+ # ----------------------------------------------------------------------
101
+ # Token extraction — the encoder's forward pass without the pooling step
102
+ # ----------------------------------------------------------------------
103
+ @torch.no_grad()
104
+ def encode_tokens(encoder, mri: torch.Tensor):
105
+ """(B, 1, 96, 96, 96) → (B, 27, 512) sequence of spatial tokens."""
106
+ x = encoder.stem(mri)
107
+ x = encoder.stage1(x)
108
+ x = encoder.stage2(x)
109
+ x = encoder.stage3(x)
110
+ x = encoder.stage4(x)
111
+ x = encoder.aspp(x) # (B, 512, 3, 3, 3)
112
+ ch = x.shape[1]
113
+ return x.flatten(2).transpose(1, 2).contiguous(), (ch, tuple(x.shape[2:]))
114
+
115
+
116
+ @torch.no_grad()
117
+ def build_token_cache(model, loader, device):
118
+ """The encoder is frozen, so compute the tokens once and keep them in RAM."""
119
+ toks, labels, shape_info = [], [], None
120
+ for batch in loader:
121
+ if "mri" not in batch:
122
+ continue
123
+ mri = batch["mri"].to(device, non_blocking=True)
124
+ t, shape_info = encode_tokens(model.mri_encoder, mri)
125
+ toks.append(t.float().cpu())
126
+ labels.append(batch["label"].clone())
127
+ if not toks:
128
+ raise RuntimeError("No MRI batch could be loaded — check your data paths.")
129
+ return torch.cat(toks), torch.cat(labels), shape_info
130
+
131
+
132
+ # ----------------------------------------------------------------------
133
+ # Probes
134
+ # ----------------------------------------------------------------------
135
+ class MeanPoolProbe(nn.Module):
136
+ """Discards spatial information — equivalent to the current AdaptiveAvgPool3d(1)."""
137
+ def __init__(self, dim, n_cls=3):
138
+ super().__init__()
139
+ self.head = nn.Sequential(nn.LayerNorm(dim), nn.Linear(dim, 128),
140
+ nn.GELU(), nn.Dropout(0.2), nn.Linear(128, n_cls))
141
+
142
+ def forward(self, tok): # (B, N, D)
143
+ return self.head(tok.mean(dim=1)), None
144
+
145
+
146
+ class AttnPoolProbe(nn.Module):
147
+ """Weighted pooling over the 27 tokens with a single learned query."""
148
+ def __init__(self, dim, n_cls=3):
149
+ super().__init__()
150
+ self.q = nn.Parameter(torch.randn(1, 1, dim) * 0.02)
151
+ self.norm = nn.LayerNorm(dim)
152
+ self.attn = nn.MultiheadAttention(dim, num_heads=8, batch_first=True)
153
+ self.head = nn.Sequential(nn.LayerNorm(dim), nn.Linear(dim, 128),
154
+ nn.GELU(), nn.Dropout(0.2), nn.Linear(128, n_cls))
155
+
156
+ def forward(self, tok): # (B, N, D)
157
+ x = self.norm(tok)
158
+ q = self.q.expand(x.size(0), -1, -1)
159
+ pooled, w = self.attn(q, x, x, need_weights=True, average_attn_weights=True)
160
+ return self.head(pooled.squeeze(1)), w.squeeze(1) # w: (B, N)
161
+
162
+
163
+ def train_probe(probe, tr_tok, tr_y, te_tok, te_y, device, epochs, bs, lr=3e-4):
164
+ probe = probe.to(device)
165
+ opt = torch.optim.AdamW(probe.parameters(), lr=lr, weight_decay=1e-4)
166
+ sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=epochs)
167
+
168
+ # Compensate for class imbalance — MCI is already the weakest class
169
+ counts = torch.bincount(tr_y, minlength=3).float().clamp(min=1)
170
+ w = (counts.sum() / (3 * counts)).to(device)
171
+
172
+ n = tr_tok.size(0)
173
+ for _ in range(epochs):
174
+ probe.train()
175
+ perm = torch.randperm(n)
176
+ for i in range(0, n, bs):
177
+ idx = perm[i:i + bs]
178
+ xb = tr_tok[idx].to(device)
179
+ yb = tr_y[idx].to(device)
180
+ logits, _ = probe(xb)
181
+ loss = F.cross_entropy(logits, yb, weight=w)
182
+ opt.zero_grad(set_to_none=True)
183
+ loss.backward()
184
+ opt.step()
185
+ sched.step()
186
+
187
+ probe.eval()
188
+ preds, attns = [], []
189
+ with torch.no_grad():
190
+ for i in range(0, te_tok.size(0), bs):
191
+ logits, a = probe(te_tok[i:i + bs].to(device))
192
+ preds.append(logits.argmax(-1).cpu())
193
+ if a is not None:
194
+ attns.append(a.cpu())
195
+ preds = torch.cat(preds).numpy()
196
+ y = te_y.numpy()
197
+ attn = torch.cat(attns) if attns else None
198
+ return {
199
+ "acc": accuracy_score(y, preds),
200
+ "f1_macro": f1_score(y, preds, average="macro"),
201
+ "f1_per": f1_score(y, preds, average=None, labels=[0, 1, 2]),
202
+ "attn": attn,
203
+ }
204
+
205
+
206
+ def remap_nifti_paths(df):
207
+ """
208
+ The visit manifest stores absolute volume paths from the machine that built
209
+ it, which will not resolve anywhere else. The tail of each path (after the
210
+ dataset root) is re-attached to the roots configured here. Harmless when the
211
+ original paths already resolve — it returns them unchanged.
212
+ """
213
+ def _fix(p):
214
+ p0 = str(p)
215
+ if os.path.exists(p0):
216
+ return p0
217
+ q = p0.replace("\\", "/")
218
+ # Dataset root
219
+ i = q.find("/Datasets/")
220
+ if i >= 0:
221
+ cand = os.path.join(C.DATASET_ROOT, q[i + len("/Datasets/"):])
222
+ if os.path.exists(cand):
223
+ return cand
224
+ # The stored tail may or may not include the top volume folder,
225
+ # so both spellings are tried.
226
+ j = q.find("/volumes/")
227
+ if j >= 0:
228
+ rest = q[j + len("/volumes/"):]
229
+ for cand in (os.path.join(C.TBM_ROOT, rest),
230
+ os.path.join(C.TBM_ROOT, "volumes", rest)):
231
+ if os.path.exists(cand):
232
+ return cand
233
+ return p0
234
+
235
+ df = df.copy()
236
+ df["nifti_path"] = df["nifti_path"].map(_fix)
237
+ ok = int(sum(os.path.exists(str(p)) for p in df["nifti_path"]))
238
+ print(f"[path] reachable images ({MODALITY}): {ok}/{len(df)}")
239
+ if ok == 0:
240
+ raise FileNotFoundError(
241
+ f"No image is reachable ({MODALITY}).\n"
242
+ f" DATASET_ROOT = {C.DATASET_ROOT}\n"
243
+ f" VOLUME_ROOT = {C.TBM_ROOT}\n"
244
+ "YOU MUST SET YOUR OWN PATHS: see VBAI_DATASET_ROOT / "
245
+ "VBAI_VOLUME_ROOT in config.py."
246
+ )
247
+ return df[df["nifti_path"].map(lambda p: os.path.exists(str(p)))].reset_index(drop=True)
248
+
249
+
250
+ @torch.no_grad()
251
+ def baseline_from_checkpoint(model, loader, device):
252
+ """The trained mri_classifier's own score — the reference baseline."""
253
+ preds, ys = [], []
254
+ for batch in loader:
255
+ if "mri" not in batch:
256
+ continue
257
+ out = model(mri=batch["mri"].to(device))
258
+ preds.append(out["mri_logits"].argmax(-1).cpu())
259
+ ys.append(batch["label"])
260
+ preds, ys = torch.cat(preds).numpy(), torch.cat(ys).numpy()
261
+ return {"acc": accuracy_score(ys, preds), "f1_macro": f1_score(ys, preds, average="macro")}
262
+
263
+
264
+ def main():
265
+ ap = argparse.ArgumentParser()
266
+ ap.add_argument("--ckpt", default="Vbai-2.6AD.pt",
267
+ help="Vbai-2.6AD checkpoint")
268
+ ap.add_argument("--tbm", action="store_true", help="TBM input")
269
+ ap.add_argument("--t1", action="store_true", help="raw T1 input")
270
+ ap.add_argument("--epochs", type=int, default=30)
271
+ ap.add_argument("--batch-size", type=int, default=8)
272
+ ap.add_argument("--workers", type=int, default=2)
273
+ args = ap.parse_args()
274
+
275
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
276
+ print(f"[device] {device}")
277
+ print(f"[model dir] {MODEL_DIR}")
278
+
279
+ # --- model + normalizer (from the checkpoint; NEVER re-fitted) ---
280
+ sd = torch.load(args.ckpt, map_location=device, weights_only=False)
281
+ mcfg = C.ModelConfig()
282
+ for k, v in sd.get("model_cfg", {}).items():
283
+ if hasattr(mcfg, k):
284
+ setattr(mcfg, k, v)
285
+ model = Vbai26ADModel(mcfg).to(device)
286
+ # Any key mismatch is fatal on purpose: strict=False silently swallows a
287
+ # checkpoint from a different architecture, leaving the model on random
288
+ # weights and producing below-chance results. Older checkpoints from a
289
+ # different architecture must fail loudly here.
290
+ res = model.load_state_dict(sd["model"], strict=False)
291
+ if res.missing_keys or res.unexpected_keys:
292
+ raise RuntimeError(
293
+ f"Checkpoint does NOT match this architecture: "
294
+ f"{len(res.missing_keys)} missing / {len(res.unexpected_keys)} "
295
+ f"unexpected keys.\n"
296
+ f" first missing : {res.missing_keys[:3]}\n"
297
+ f" first unexpected : {res.unexpected_keys[:3]}\n"
298
+ "Checkpoints from a different architecture are not compatible.\n"
299
+ "Expected file: Vbai-2.6AD.pt"
300
+ )
301
+ print(f"[ckpt] {len(sd['model'])} keys matched exactly")
302
+ if "extra" in sd and sd["extra"].get("metrics"):
303
+ print(f"[ckpt] stored metrics: {sd['extra']['metrics']}")
304
+ model.eval()
305
+ for p in model.parameters():
306
+ p.requires_grad_(False)
307
+
308
+ norm = TabularNormalizer()
309
+ norm.load_state_dict(sd["norm"])
310
+
311
+ # --- subject-level split (no leakage between train and test) ---
312
+ df = load_paired()
313
+ df = remap_nifti_paths(df)
314
+ train_ids, val_ids, test_ids = subject_split(df)
315
+ tr_df = df[df["ptid"].isin(train_ids | val_ids)] # train+val to fit the probes
316
+ te_df = df[df["ptid"].isin(test_ids)]
317
+ print(f"[data] probe-train {len(tr_df)} scans / test {len(te_df)} scans")
318
+
319
+ def make_loader(d, shuffle=False):
320
+ ds = PairedVisitDataset(d, norm, mode="mri", augment=False, mcfg=mcfg)
321
+ return torch.utils.data.DataLoader(ds, batch_size=args.batch_size, shuffle=shuffle,
322
+ collate_fn=collate_pad, num_workers=args.workers)
323
+
324
+ tr_loader, te_loader = make_loader(tr_df), make_loader(te_df)
325
+
326
+ # --- baseline: the checkpoint's own MRI head ---
327
+ print("\n[1/3] Baseline (trained mri_classifier, pooled)...")
328
+ base = baseline_from_checkpoint(model, te_loader, device)
329
+ print(f" acc {base['acc']:.4f} | macro-F1 {base['f1_macro']:.4f}")
330
+
331
+ # --- token cache ---
332
+ print("\n[2/3] Building the token cache (encoder frozen, single pass)...")
333
+ tr_tok, tr_y, shape_info = build_token_cache(model, tr_loader, device)
334
+ te_tok, te_y, _ = build_token_cache(model, te_loader, device)
335
+ ch, grid = shape_info
336
+ n_tok, dim = tr_tok.shape[1], tr_tok.shape[2]
337
+ print(f" ASPP output: {ch} channels @ {grid} → {n_tok} tokens x {dim}-d")
338
+ print(f" cache: train {tuple(tr_tok.shape)} / test {tuple(te_tok.shape)}")
339
+
340
+ # --- problar ---
341
+ print("\n[3/3] Training the probes (encoder frozen)...")
342
+ r_mean = train_probe(MeanPoolProbe(dim), tr_tok, tr_y, te_tok, te_y,
343
+ device, args.epochs, args.batch_size)
344
+ r_attn = train_probe(AttnPoolProbe(dim), tr_tok, tr_y, te_tok, te_y,
345
+ device, args.epochs, args.batch_size)
346
+
347
+ # --- is the attention degenerate? ---
348
+ a = r_attn["attn"].clamp_min(1e-9)
349
+ ent = float((-(a * a.log()).sum(dim=1)).mean())
350
+ max_ent = float(np.log(n_tok))
351
+
352
+ print("\n" + "=" * 66)
353
+ print(" TOKEN PROBE RESULT")
354
+ print("=" * 66)
355
+ print(f" {'':22s} {'acc':>8s} {'macro-F1':>10s} F1 (CN/MCI/AD)")
356
+ print(f" {'checkpoint (pooled)':22s} {base['acc']:8.4f} {base['f1_macro']:10.4f}")
357
+ print(f" {'probe: mean-pool':22s} {r_mean['acc']:8.4f} {r_mean['f1_macro']:10.4f}"
358
+ f" {'/'.join(f'{v:.3f}' for v in r_mean['f1_per'])}")
359
+ print(f" {'probe: attention':22s} {r_attn['acc']:8.4f} {r_attn['f1_macro']:10.4f}"
360
+ f" {'/'.join(f'{v:.3f}' for v in r_attn['f1_per'])}")
361
+ print(f"\n attention entropy: {ent:.3f} / {max_ent:.3f} (max = fully uniform)")
362
+
363
+ delta = r_attn["f1_macro"] - r_mean["f1_macro"]
364
+ print(f" attention - mean difference (macro-F1): {delta:+.4f}")
365
+ print("-" * 66)
366
+ if delta > 0.02 and ent < 0.95 * max_ent:
367
+ print(" → GO. The spatial tokens carry extra information; wiring them")
368
+ print(" into the LLM as a sequence is justified.")
369
+ elif ent >= 0.95 * max_ent:
370
+ print(" → STOP. The attention is nearly uniform: the 27 positions do")
371
+ print(" not separate. Lower the ASPP dilations to (1,2,3), or drop the")
372
+ print(" stage-4 stride to reach a 6^3 grid, then measure again.")
373
+ else:
374
+ print(" → WEAK. The tokens add nothing meaningful over the pooled")
375
+ print(" vector. Wiring them in without raising the encoder resolution")
376
+ print(" will not help.")
377
+ print("=" * 66)
378
+
379
+
380
+ if __name__ == "__main__":
381
+ main()