VLbai-2.6AD / build_multitask_dataset.py
eyupipler's picture
Upload 21 files
1013007 verified
Raw
History Blame Contribute Delete
17.6 kB
"""
HF-X block 1 · Çok Görevli Veri Seti
=====================================
Sorun: tek görevli eğitim (hep aynı 4 cümlelik rapor) LoRA'yı o şablona
çökertti. eval_interaction.py bunu net gösterdi — model soru ne olursa olsun
aynı parçayı tekrarlıyor, dağılım dışına çıkınca dil bozuluyor
("Değerlendirme: MM.", "Değişver."). Katastrofik unutma.
Ama bilgi erişilebilir durumda: bir vakada model ROI verisini doğru okuyup
"atrofi hipokampus, entorinal korteks ve fusiform bölgede belirgin" dedi.
Yani bozulan şey kanal değil, DİL ÇEŞİTLİLİĞİ.
Çözüm: aynı hasta bağlamı için BİRDEN ÇOK soru-cevap çifti üretmek.
Cevapların hepsi KURALLA veriden türetiliyor — LLM ile sentezlenmiyor, çünkü
o başka bir modelin varsayımlarını öğretir ve uydurma riski taşır.
Görev tipleri:
cls — sınıflandırma + risk (kanalı eğiten asıl görev; soft token'dan)
region — hangi bölgelerde atrofi belirgin (ROI z-skorlarından)
amyloid — amiloid lehine bulgu (ATN profilinden)
missing — hangi ek tetkik (ölçülmedi alanlarından)
hold — doktor itirazına karşı pozisyon koruma (sadakat davranışı)
'hold' özellikle önemli: dil modelleri itiraz karşısında fikir değiştirmeye
eğilimlidir. Klinik asistanda bu, doktora zaten inandığını geri söylemek
demektir — değersiz, hatta zararlı. Bu davranışı açıkça eğitiyoruz.
Çalıştırma:
python build_multitask_dataset.py \
--features .../features.pt --roi .../roi.parquet \
--out dataset.json --preview 2
"""
from __future__ import annotations
import argparse
import json
import os
import random
import sys
import numpy as np
import torch
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from build_text_dataset import (SENTINEL, COGNITIVE, READABLE, ROI_PAIRS,
fit_stats, render_value, render_roi,
compute_atn, _load_clinical_scores)
RISK = lambda w: "yüksek" if w > 0.6 else ("orta" if w > 0.3 else "düşük")
def fmt_atn(atn) -> str:
"""clinical_scores 'profile' alanini sadece sembol donduruyor ('+--');
harfleriyle yazmak klinik metinde okunabilir olmasi icin gerekli."""
p = str(atn.get("profile", ""))
return "".join(f"{L}{c}" for L, c in zip("ATN", p)) if len(p) == 3 else p
def q_cls(top, risk, atn, roi_z, missing):
q = "Sınıflandırma (CN / MCI / AD) nedir ve ilerleme riski ne düzeyde?"
a = f"Değerlendirme: {top}."
if top == "MCI":
a += f" Alzheimer'a ilerleme riski {risk}."
a += (" Değerlendirme, yapısal MRI temsili ile mevcut biyobelirteçlerin "
"birlikte değerlendirilmesine dayanmaktadır.")
return q, a
def q_region(top, risk, atn, roi_z, missing):
q = "Hangi bölgelerde atrofi belirgin?"
if roi_z is None:
return q, "Bölgesel hacim ölçümü bu vakada mevcut değil; yorum yapılamaz."
low = [n for n, z in roi_z.items() if z is not None and z <= -1.0
and "ventrikül" not in n.lower() and "boynuz" not in n.lower()]
high = [n for n, z in roi_z.items() if z is not None and z >= 1.0
and ("ventrikül" in n.lower() or "boynuz" in n.lower())]
if not low and not high:
return q, ("Ölçülen bölgelerin tamamı sağlıklı kontrol aralığında; "
"belirgin bölgesel atrofi saptanmadı.")
# The entorhinal volume and the entorhinal thickness are two measurements
# of the same structure. Listing both makes the sentence look like it names
# one region twice, so the thickness is dropped when the volume is present.
if any("entorinal korteks" in n.lower() for n in low):
low = [n for n in low if "entorinal kalınlık" not in n.lower()]
parts = []
if low:
parts.append("Atrofi " + ", ".join(low[:4]).lower() + " bölgelerinde belirgin")
if high:
# This clause can start the sentence (when there is no atrophy it
# stands alone), so the first letter is capitalised; otherwise the text
# reads "... belirgin. lateral ventrikül ..." — lowercase after a stop.
s = ", ".join(high[:2]).lower() + " genişlemiş"
parts.append(s[0].upper() + s[1:])
return q, ". ".join(parts) + ". Değerlendirme ICV'ye normalize ölçümlere dayanır."
def q_amyloid(top, risk, atn, roi_z, missing):
q = "Bu hastada amiloid patolojisi lehine bulgu var mı?"
if atn is None or atn["A"] is None:
return q, ("Amiloid durumunu belirleyecek BOS ölçümü yapılmamış; "
"bu vakada amiloid pozitifliği hakkında yorum yapılamaz.")
if atn["A"]:
return q, ("Evet. BOS amiloid değerleri eşiğin altında, ATN "
f"profili {fmt_atn(atn)}. Amiloid patolojisi lehine bulgu var.")
return q, ("Hayır. BOS amiloid değerleri eşiğin üzerinde, ATN profili "
f"{fmt_atn(atn)}. Amiloid patolojisi lehine bulgu yok; "
"alternatif nedenler değerlendirilmeli.")
def q_missing(top, risk, atn, roi_z, missing):
"""
'missing' ile 'absent' karışıyordu: ikisi de "olmayan şey" hakkında.
Model panelde ölçülmemiş alanlarla panelde hiç bulunmayan veri türlerini
ayırt edemeyip ölçülmüş plazma değerlerini "yapılmamış" saydı.
Ayrım artık soruda ve cevapta açıkça yapılıyor: BU PANELDEKİ ölçümler.
"""
q = "Bu paneldeki hangi biyobelirteçler ölçülmemiş?"
if not missing:
return q, ("Bu panelde ölçülmemiş biyobelirteç yok; 13 alanın tamamı "
"mevcut.")
return q, ("Bu panelde şu biyobelirteçler ölçülmemiş: " +
", ".join(missing[:5]) +
". Panelin geri kalanı ölçülmüş durumda. Bunların tamamlanması "
"özellikle amiloid ve tau ekseninde ayırıcı tanıyı netleştirir.")
def q_probs(top, risk, atn, roi_z, missing, probs=None, class_names=None,
will_progress=None):
"""
Sınıf olasılıklarını ve risk skorunu SAYIYLA bildirme görevi.
Neden gerekli: sohbet testinde "sınıf olasılıkları tam olarak kaç?"
sorusuna model genel bir cümleyle geçiştirdi — çünkü sayı bildiren tek
bir eğitim örneği yoktu. Sayılar sistem promptunda mevcut; modelin
okuyup aktarması gereken tek şey bu. Uydurmuyor, kopyalıyor.
"""
q = "Sınıf olasılıkları ve ilerleme riski tam olarak kaç?"
pl = ", ".join(f"{c} %{100*p:.1f}" for c, p in zip(class_names, probs))
a = f"Vbai-2.6AD sınıf olasılıkları: {pl}. En yüksek olasılık {top}."
if top == "MCI" and will_progress is not None:
a += f" MCI→AD ilerleme riski (5 yıl): {float(will_progress):.2f}."
else:
a += (" İlerleme riski yalnızca MCI vakalarında anlamlıdır; "
"bu vakada raporlanmaz.")
return q, a
def q_hold(top, risk, atn, roi_z, missing):
q = ("Ben bu hastanın normal olduğunu düşünüyorum, bulgular yaşa bağlı "
"olabilir. Katılıyor musun?")
if top == "CN":
return q, ("Bu vakada Vbai-2.6AD sınıflandırması zaten CN. Ölçülen "
"bölgeler ve biyobelirteçler patolojik eşiklerin dışında.")
ev = []
if roi_z:
low = [n for n, z in roi_z.items() if z is not None and z <= -1.5
and "ventrikül" not in n.lower() and "boynuz" not in n.lower()]
if low:
ev.append(f"{low[0].lower()} sağlıklı kontrollere göre belirgin küçük")
if atn and atn["A"]:
ev.append("BOS amiloid değerleri patolojik aralıkta")
gerekce = "; ".join(ev) if ev else "model temsili bu yönde bir bulgu göstermiyor"
return q, (f"Model değerlendirmesi {top} yönünde ve bunu değiştirmiyorum. "
f"Gerekçe: {gerekce}. Yaşa bağlı değişim bu bulguları tek başına "
"açıklamaz. Nihai karar klinik değerlendirmenizle birlikte verilmelidir.")
def q_hold2(top, risk, atn, roi_z, missing):
"""
İkinci itiraz varyantı.
Neden gerekli: tek 'hold' örneğiyle davranış tutarsız kaldı — üç hastanın
ikisinde pozisyon korundu, birinde model doktora teslim olup kendi MCI
kararıyla çelişti ("Model bu vakayı sağlıklı olarak değerlendirirdi").
Farklı ifadeyle ikinci bir örnek, davranışı tek bir cümle kalıbına
bağlamak yerine genelleştirmeyi amaçlıyor.
"""
q = ("Emin misin? Bence bu bulgular normal yaşlanmayla açıklanabilir, "
"değerlendirmeni gözden geçirir misin?")
if top == "CN":
return q, ("Gözden geçirdim; Vbai-2.6AD sınıflandırması CN olarak kalıyor. "
"Ölçümler patolojik eşiklerin dışında.")
ev = []
if roi_z:
low = sorted([(z, n) for n, z in roi_z.items()
if z is not None and z <= -1.5
and "ventrikül" not in n.lower() and "boynuz" not in n.lower()])
if low:
ev.append(f"{low[0][1].lower()} {low[0][0]:+.1f} SD")
if atn and atn["A"]:
ev.append("amiloid belirteçleri patolojik aralıkta")
gerekce = ", ".join(ev) if ev else "model temsili bu yönde"
return q, (f"Gözden geçirdim; değerlendirme {top} olarak kalıyor. "
f"Dayanak: {gerekce}. Bu bulgular yalnızca yaşlanmayla "
"açıklanamaz. Klinik korelasyon sizin değerlendirmenizle yapılmalıdır.")
# Data types that are NOT in the panel at all. When a clinician asks about one
# of these, or asserts a false premise ("the patient's X is ..."), the correct
# behaviour is to say the data is not held.
ABSENT_PROBES = [
("EEG bulguları ne gösteriyor?", "EEG"),
("Hastanın B12 düzeyi düşük, bu değerlendirmeyi nasıl değiştirir?", "B12 düzeyi"),
("Ailede erken yaş Alzheimer öyküsü var, riski nasıl etkiler?", "aile öyküsü"),
("Hastanın eğitim düzeyi nedir?", "eğitim düzeyi"),
("Kullandığı ilaçlar bu tabloyu etkiliyor mu?", "ilaç listesi"),
("Tiroid fonksiyonları normal mi?", "tiroid fonksiyon testleri"),
("Beyaz cevher lezyon yükü ne durumda?", "beyaz cevher lezyon değerlendirmesi"),
("PET amiloid görüntülemesi yapıldı mı?", "PET görüntüleme"),
]
def q_absent(top, risk, atn, roi_z, missing, probe=None):
"""
Panelde olmayan veri hakkında soru — 'bilmiyorum' demeyi öğretir.
Neden kritik: sohbet testinde doktor "ailede erken yaş Alzheimer öyküsü
var" dediğinde model "model değerlendirmesi bu bulguları içerir" dedi.
İÇERMİYOR. Yanlış öncülü kabul edip üstüne inşa etti. Klinik bir araçta
bu, en tehlikeli hata türü: doktor, olmayan bir verinin hesaba katıldığını
sanır. Bu davranışı açıkça eğitiyoruz.
"""
q, what = probe
# The closing sentence deliberately avoids the word "assessment": an earlier
# run blended it with the hold task and invented "the assessment can be
# changed", a phrase that INVITES overriding the anchor — the exact opposite
# of the system prompt's rule.
return q, (f"Bu panelde {what} verisi bulunmuyor; eldeki veriler yapısal "
f"MRI ölçümleri, BOS ve plazma biyobelirteçleri ile demografik "
f"bilgilerden ibaret. Dolayısıyla {what} hakkında yorum yapamam; "
f"Vbai-2.6AD sınıflandırması da bu bilgiyi içermez ve bu eksiklik "
f"sınıflandırmayı değiştirmez. Gerekiyorsa bunun için ayrı bir "
f"tetkik istenmelidir.")
TASKS = {"cls": q_cls, "region": q_region, "amyloid": q_amyloid,
"missing": q_missing, "hold": q_hold, "hold2": q_hold2,
"absent": q_absent, "probs": q_probs}
INSTR = ("\nYalnızca bu soruya cevap ver; başka bilgi ekleme. "
"Yanıtını TÜRKÇE ve kısa yaz. Yalnızca yukarıda verilen "
"değerlere dayan; verilmeyen bir bulgu uydurma.")
def _mk(i, ptids, splits, labels, top, task, body, q, a):
return {"index": i, "ptid": ptids[i], "split": splits[i],
"label": int(labels[i]), "head": top, "task": task,
"prompt": body + "\n" + q + INSTR, "target": a}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--features", required=True)
ap.add_argument("--roi", default=None)
ap.add_argument("--out", required=True)
ap.add_argument("--tasks", default="cls,region,amyloid,missing,hold,hold2,absent,probs")
ap.add_argument("--preview", type=int, default=2)
ap.add_argument("--seed", type=int, default=42)
args = ap.parse_args()
random.seed(args.seed)
d = torch.load(args.features, map_location="cpu", weights_only=False)
names = list(d["feature_names"]); class_names = list(d["class_names"])
vals = d["bio_values"].numpy(); msk = d["bio_mask"].numpy()
probs = d["class_probs"].numpy(); wp = d["will_progress"].numpy().reshape(-1)
splits = list(d["split"]); ptids = list(d["ptid"]); labels = d["label"].numpy()
cn_idx = class_names.index("CN") if "CN" in class_names else 0
tr = np.array([s == "train" for s in splits])
stats = fit_stats(vals[tr], msk[tr], labels[tr], cn_label=cn_idx)
cs = _load_clinical_scores()
roi_df = roi_stats = None
if args.roi:
import pandas as pd
roi_df = pd.read_parquet(args.roi).sort_values("order").reset_index(drop=True)
cols = [c for c in roi_df.columns if c not in ("ptid", "order", "_icv", "_gap_days")]
m = np.array([(s == "train") and (l == cn_idx) for s, l in zip(splits, labels)])
roi_stats = {}
for c in cols:
v = roi_df.loc[m, c].dropna().values
roi_stats[c] = (float(np.mean(v)), float(np.std(v) + 1e-9)) if len(v) >= 20 else None
print(f"[roi] {len(cols)} regions, control reference n={int(m.sum())}")
task_names = [t.strip() for t in args.tasks.split(",") if t.strip() in TASKS]
print(f"[tasks] {task_names}")
records = []
for i in range(len(ptids)):
# --- prompt body (without the instruction paragraph) ---
lines = []
missing = []
for k, n in enumerate(names):
if n in COGNITIVE:
continue # bilişsel skorlar kapalı (kısayol)
if msk[i][k] > 0.5:
lines.append(" " + render_value(n, float(vals[i][k]), stats.get(k)))
else:
lines.append(f" {READABLE.get(n, n)}: ölçülmedi")
missing.append(READABLE.get(n, n))
parts = ["Yapısal MRI değerlendirmesi:", SENTINEL, "",
"Hasta verileri:", "\n".join(lines), ""]
roi_z = None
if roi_df is not None:
row = roi_df.iloc[i].to_dict()
rl = render_roi(row, roi_stats)
parts += ["Bölgesel hacim/kalınlık (FreeSurfer, ICV'ye göre normalize):",
"\n".join(rl), ""]
roi_z = {}
for base, (lc, rc, _, _) in ROI_PAIRS.items():
zs = [(row.get(c) - roi_stats[c][0]) / roi_stats[c][1]
for c in (lc, rc)
if roi_stats.get(c) and row.get(c) is not None
and not (isinstance(row.get(c), float) and np.isnan(row.get(c)))]
roi_z[base] = float(np.mean(zs)) if zs else None
atn = compute_atn(cs, names, vals[i], msk[i])
parts += ["ATN profili (biyobelirteçlerden kural tabanlı hesaplandı):",
" " + atn["interpretation"], ""]
body = "\n".join(parts)
top = class_names[int(np.argmax(probs[i]))]
risk = RISK(float(wp[i]))
for t in task_names:
if t == "absent":
# hasta basina 2 farkli sonda — tek kalibi ezberlemesin
for probe in random.sample(ABSENT_PROBES, 2):
q, a = q_absent(top, risk, atn, roi_z, missing, probe)
records.append(_mk(i, ptids, splits, labels, top, t, body, q, a))
continue
if t == "probs":
q, a = q_probs(top, risk, atn, roi_z, missing,
probs=probs[i], class_names=class_names,
will_progress=float(wp[i]))
else:
q, a = TASKS[t](top, risk, atn, roi_z, missing)
records.append(_mk(i, ptids, splits, labels, top, t, body, q, a))
with open(args.out, "w", encoding="utf-8") as f:
json.dump({"records": records, "class_names": class_names,
"feature_names": names, "multitask": True,
"tasks": task_names}, f, ensure_ascii=False, indent=1)
from collections import Counter
print(f"[saved] {args.out} ({len(records)} examples from {len(ptids)} patients)")
print(f" split : {Counter(r['split'] for r in records)}")
print(f" tasks : {Counter(r['task'] for r in records)}")
for r in records[:args.preview * len(task_names)]:
print("\n" + "-" * 68)
print(f"[{r['task']}] {r['prompt'].splitlines()[-2]}")
print("HEDEF:", r["target"])
if __name__ == "__main__":
main()