Image-Text-to-Text
PEFT
Safetensors
English
Turkish
early_diagnosis
reasoning
diagnosis
health
healthcare
alzheimer
athropy
dementia
biomarkers
biology
academic
lora
mri
Instructions to use Neurazum/VLbai-2.6AD with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use Neurazum/VLbai-2.6AD with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
Upload 21 files
Browse files- Vbai-2.6AD.pt +3 -0
- benchmark_medical.py +218 -0
- build_multitask_dataset.py +357 -0
- build_text_dataset.py +619 -0
- chat.py +385 -0
- config.py +212 -0
- dataset.py +284 -0
- eval_faithfulness.py +265 -0
- eval_interaction.py +202 -0
- eval_thinking.py +232 -0
- extract_features.py +226 -0
- extract_roi.py +212 -0
- hfx_runtime.py +119 -0
- model.py +318 -0
- mri_contribution.py +217 -0
- probe_features.py +98 -0
- projector.pt +3 -0
- projector.pt_lora/adapter_config.json +47 -0
- projector.pt_lora/adapter_model.safetensors +3 -0
- projector.pt_lora/desktop.ini +0 -0
- token_probe.py +381 -0
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()
|