Penguindrum920 commited on
Commit
7bc5fde
·
verified ·
1 Parent(s): 6463d8e

Prepare PRISM backend for Hugging Face Spaces

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +25 -0
  2. .gitattributes +6 -0
  3. Dockerfile +24 -0
  4. PPMI_Curated_Data_Cut_Public_20250714.csv +0 -0
  5. README.md +173 -6
  6. medical_docs/GetPdf.pdf +0 -0
  7. medical_docs/MDS-UPDRS.pdf +3 -0
  8. medical_docs/Movement Disorders - 2019 - Heinzel - Update of the MDS research criteria for prodromal Parkinson s disease.pdf +3 -0
  9. medical_docs/PPD.pdf +3 -0
  10. medical_docs/differential_diagnosis.txt +33 -0
  11. medical_docs/mds-clinical-diagnostic-criteria-for-parkinson's-disease.pdf +3 -0
  12. medical_docs/mds-research-criteria-for-prodromal-parkinson's-disease.pdf +3 -0
  13. medical_docs/parkinsons-disease-in-adults-pdf-1837629189061.pdf +3 -0
  14. medical_docs/parkinsons_research.txt +20 -0
  15. models/saved/lightgbm_model.joblib +3 -0
  16. models/saved/multimodal_ensemble.joblib +3 -0
  17. models/saved/svm_model.joblib +3 -0
  18. models/saved/traditional_class_mapping.json +6 -0
  19. models/saved/traditional_preprocessor.joblib +3 -0
  20. models/saved/xgboost_model.joblib +3 -0
  21. requirements-hf.txt +18 -0
  22. src/__init__.py +3 -0
  23. src/analyze_data.py +111 -0
  24. src/data_preprocessing.py +294 -0
  25. src/document_manager.py +455 -0
  26. src/dual_report_generator.py +112 -0
  27. src/evaluate_traditional_models.py +187 -0
  28. src/evaluate_transformer_models.py +274 -0
  29. src/feature_mapping.py +189 -0
  30. src/models/__init__.py +0 -0
  31. src/models/medical_transformers.py +939 -0
  32. src/models/multimodal_ml.py +560 -0
  33. src/models/traditional_ml.py +158 -0
  34. src/models/transformer_models.py +509 -0
  35. src/progression_model.py +338 -0
  36. src/rag_system.py +762 -0
  37. src/risk_stratifier.py +122 -0
  38. src/static/css/label-fix.css +43 -0
  39. src/static/css/styles.css +274 -0
  40. src/static/js/darkmode.js +50 -0
  41. src/train_model_suite.py +1382 -0
  42. src/train_multimodal.py +136 -0
  43. src/train_traditional_models.py +187 -0
  44. src/train_transformer_models.py +863 -0
  45. src/training_runtime.py +215 -0
  46. src/treatment_model.py +144 -0
  47. src/twin_engine.py +689 -0
  48. src/twin_predictor_bridge.py +269 -0
  49. src/twin_schema.py +120 -0
  50. src/twin_store.py +212 -0
.dockerignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .venv
3
+ __pycache__
4
+ **/__pycache__
5
+ *.pyc
6
+
7
+ data/*.sqlite3
8
+ reports
9
+ evaluation_results
10
+ notebooks
11
+
12
+ frontend/node_modules
13
+ frontend/dist
14
+
15
+ models/saved/*.pth
16
+ models/saved/*.pt
17
+ models/saved/*.ckpt
18
+ models/saved/*.bin
19
+ models/saved/*.safetensors
20
+ models/saved/*.onnx
21
+ models/saved/training_runs
22
+
23
+ docs
24
+ *.log
25
+ .cache
.gitattributes CHANGED
@@ -33,3 +33,9 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ medical_docs/mds-clinical-diagnostic-criteria-for-parkinson's-disease.pdf filter=lfs diff=lfs merge=lfs -text
37
+ medical_docs/mds-research-criteria-for-prodromal-parkinson's-disease.pdf filter=lfs diff=lfs merge=lfs -text
38
+ medical_docs/MDS-UPDRS.pdf filter=lfs diff=lfs merge=lfs -text
39
+ medical_docs/Movement[[:space:]]Disorders[[:space:]]-[[:space:]]2019[[:space:]]-[[:space:]]Heinzel[[:space:]]-[[:space:]]Update[[:space:]]of[[:space:]]the[[:space:]]MDS[[:space:]]research[[:space:]]criteria[[:space:]]for[[:space:]]prodromal[[:space:]]Parkinson[[:space:]]s[[:space:]]disease.pdf filter=lfs diff=lfs merge=lfs -text
40
+ medical_docs/parkinsons-disease-in-adults-pdf-1837629189061.pdf filter=lfs diff=lfs merge=lfs -text
41
+ medical_docs/PPD.pdf filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PORT=7860 \
6
+ PD_ALLOWED_ORIGINS=* \
7
+ PD_LOAD_TRANSFORMERS=0 \
8
+ MPLBACKEND=Agg
9
+
10
+ WORKDIR /app
11
+
12
+ RUN apt-get update \
13
+ && apt-get install -y --no-install-recommends libgomp1 \
14
+ && rm -rf /var/lib/apt/lists/*
15
+
16
+ COPY requirements-hf.txt ./requirements-hf.txt
17
+ RUN pip install --no-cache-dir --upgrade pip \
18
+ && pip install --no-cache-dir -r requirements-hf.txt
19
+
20
+ COPY . .
21
+
22
+ EXPOSE 7860
23
+
24
+ CMD ["python", "start_server.py", "--skip-init"]
PPMI_Curated_Data_Cut_Public_20250714.csv ADDED
The diff for this file is too large to render. See raw diff
 
README.md CHANGED
@@ -1,10 +1,177 @@
1
  ---
2
- title: Prism Backend
3
- emoji: 📊
4
- colorFrom: yellow
5
- colorTo: yellow
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: PRISM Parkinsons Assessment API
 
 
 
3
  sdk: docker
4
+ app_port: 7860
5
  ---
6
 
7
+ # Parkinson's Disease Assessment Portal
8
+
9
+ A comprehensive medical assessment system for Parkinson's disease diagnosis using the PPMI (Parkinson's Progression Markers Initiative) curated dataset. The system combines traditional machine learning, medical transformer models, and RAG-based report generation.
10
+
11
+ ## Dataset
12
+
13
+ Uses the **PPMI Curated Data Cut** CSV files containing patient data with the following key features:
14
+
15
+ | Category | Features |
16
+ |----------|----------|
17
+ | Demographics | Age, Sex, Education Years, Race, BMI |
18
+ | Family History | Family PD history (categorical + binary) |
19
+ | Motor Symptoms | Tremor, Rigidity, Bradykinesia, Postural Instability |
20
+ | Non-Motor | REM sleep, Epworth Sleepiness, Depression (GDS), Anxiety (STAI) |
21
+ | Cognitive | MoCA, Clock Drawing, Benton JLO |
22
+
23
+ **Target classes (COHORT):** HC (Healthy Control) · PD (Parkinson's Disease) · SWEDD · PRODROMAL
24
+
25
+ ## Architecture
26
+
27
+ ```
28
+ ├── src/
29
+ │ ├── data_preprocessing.py # Patient-level leak-free data pipeline
30
+ │ ├── web_interface.py # Flask web app
31
+ │ ├── rag_system.py # Medical knowledge base + report generation
32
+ │ ├── document_manager.py # PDF/text document indexing (TF-IDF)
33
+ │ ├── feature_mapping.py # Patient questionnaire ↔ PPMI feature mapping
34
+ │ ├── analyze_data.py # Dataset EDA script
35
+ │ ├── train_traditional_models.py # Train LightGBM, XGBoost, SVM
36
+ │ ├── train_transformer_models.py # Train PubMedBERT, BioGPT, Clinical-T5
37
+ │ ├── train_multimodal.py # Train multimodal ensemble
38
+ │ ├── evaluate_traditional_models.py
39
+ │ └── models/
40
+ │ ├── traditional_ml.py # LightGBM, XGBoost, SVM wrappers
41
+ │ ├── transformer_models.py # DistilBERT, BioBERT, PubMedBERT for tabular
42
+ │ ├── medical_transformers.py # PubMedBERT, BioGPT, Clinical-T5 classifiers
43
+ │ └── multimodal_ml.py # Stacking ensemble
44
+ ├── templates/ # Flask HTML templates
45
+ ├── static/ # CSS, JS assets
46
+ ├── medical_docs/ # Medical literature for RAG
47
+ ├── models/saved/ # Trained model weights
48
+ ├── start_server.py # Entry point for web app
49
+ └── requirements.txt
50
+ ```
51
+
52
+ ## Features
53
+
54
+ - **Leak-Free Preprocessing**: Patient-level train/test split ensures no patient appears in both sets
55
+ - **Traditional ML**: LightGBM, XGBoost, SVM with class weight balancing
56
+ - **Medical Transformers**: PubMedBERT (encoder), BioGPT (decoder), Clinical-T5 (encoder-decoder)
57
+ - **Multimodal Ensemble**: Stacking ensemble combining all model predictions
58
+ - **RAG-Enhanced Reports**: Retrieves medical literature to generate comprehensive diagnostic reports
59
+ - **Web Interface**: Patient assessment form with automated report generation and PDF export
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ # Create virtual environment
65
+ python -m venv venv
66
+ source venv/bin/activate # Linux/Mac
67
+ venv\Scripts\activate # Windows
68
+
69
+ # Install dependencies
70
+ pip install -r requirements.txt
71
+
72
+ # Install PyTorch with CUDA (recommended for GPU training)
73
+ pip install torch --index-url https://download.pytorch.org/whl/cu124
74
+ ```
75
+
76
+ `requirements.txt` now includes `sacremoses`, which BioGPT needs for tokenization. If the A4000 preflight fails on `sacremoses`, rerun `pip install -r requirements.txt` inside the training venv.
77
+
78
+ ## RTX A4000 Setup
79
+
80
+ For the RTX A4000 training machine, use this flow from the project root.
81
+
82
+ Ubuntu:
83
+
84
+ ```bash
85
+ source venv/bin/activate
86
+ bash check_a4000_ready.sh
87
+ bash train_a4000_models.sh
88
+ ```
89
+
90
+ Windows:
91
+
92
+ ```bat
93
+ venv\Scripts\activate
94
+ python check_a4000_ready.py
95
+ train_a4000_models.bat
96
+ ```
97
+
98
+ What the preflight checks:
99
+
100
+ - CUDA-enabled PyTorch import and `torch.cuda.is_available()`
101
+ - detected GPU name, CUDA version, and VRAM
102
+ - BioGPT tokenizer dependency (`sacremoses`)
103
+ - required PPMI CSV files
104
+ - `medical_docs/` availability for RAG training
105
+ - free disk space and output path write access
106
+
107
+ Helper scripts:
108
+
109
+ - `check_a4000_ready.sh` / `check_a4000_ready.bat` run the GPU/data preflight
110
+ - `train_a4000_models.sh` / `train_a4000_models.bat` run preflight, then start training with `--gpu-profile rtx-a4000`
111
+ - `resume_a4000_training.sh` / `resume_a4000_training.bat` resume the same run if the session is interrupted
112
+
113
+ The A4000 training recipe now defaults to class-weighted focal loss and keeps the best transformer checkpoint by validation F1, with validation loss used only as a tie-breaker.
114
+
115
+ Recommended direct commands:
116
+
117
+ ```bash
118
+ python src/train_model_suite.py train --run-name a4000_full --gpu-profile rtx-a4000 --epochs 30 --patience 8 --traditional-trials 6 --transformer-trials 6 --transformer-loss focal --focal-gamma 1.5
119
+ python src/train_model_suite.py resume --run-name a4000_full --gpu-profile rtx-a4000 --epochs 30 --patience 8 --traditional-trials 6 --transformer-trials 6 --transformer-loss focal --focal-gamma 1.5
120
+ python src/train_model_suite.py status --run-name a4000_full
121
+ ```
122
+
123
+ ## Usage
124
+
125
+ ### Train Models
126
+
127
+ ```bash
128
+ cd src
129
+
130
+ # Train traditional ML models
131
+ python train_traditional_models.py
132
+
133
+ # Train transformer models (requires GPU recommended)
134
+ python train_transformer_models.py
135
+
136
+ # Train multimodal ensemble
137
+ python train_multimodal.py
138
+ ```
139
+
140
+ For the full resumable training pipeline with the A4000 profile, run from the project root instead of `src/`:
141
+
142
+ ```bash
143
+ bash train_a4000_models.sh
144
+ ```
145
+
146
+ ### Run Web App
147
+
148
+ ```bash
149
+ # From project root
150
+ python start_server.py
151
+ # Access at http://localhost:5000
152
+ ```
153
+
154
+ ### Evaluate Models
155
+
156
+ ```bash
157
+ cd src
158
+ python evaluate_traditional_models.py
159
+ ```
160
+
161
+ ## Model Performance
162
+
163
+ Models are evaluated on a held-out test set using patient-level splitting:
164
+
165
+ | Model | Type |
166
+ |-------|------|
167
+ | LightGBM | Gradient Boosting |
168
+ | XGBoost | Gradient Boosting |
169
+ | SVM (RBF) | Support Vector Machine |
170
+ | PubMedBERT | Encoder-only Transformer |
171
+ | BioGPT | Decoder-only Transformer |
172
+ | Clinical-T5 | Encoder-Decoder Transformer |
173
+ | Multimodal Ensemble | Stacking (all above) |
174
+
175
+ ## License
176
+
177
+ This project uses data from the [Parkinson's Progression Markers Initiative (PPMI)](https://www.ppmi-info.org/).
medical_docs/GetPdf.pdf ADDED
Binary file (12.6 kB). View file
 
medical_docs/MDS-UPDRS.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9da07b4d53f785bc89dab47d74b24cfb434ead17bfbfb1efe7988a169710bcc1
3
+ size 1386246
medical_docs/Movement Disorders - 2019 - Heinzel - Update of the MDS research criteria for prodromal Parkinson s disease.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:170de7732faa3e982862248c0f47d9a51996de3eaeb3c579c6e0f7f93f408efb
3
+ size 137288
medical_docs/PPD.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9cc5a74b061065ab475701df7c2316f4b192e3018e258db5f854f42ff578c831
3
+ size 1791069
medical_docs/differential_diagnosis.txt ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Title: Differential Diagnosis of Parkinsonian Syndromes
2
+ Author: Dr. Sarah Chen
3
+ Date: 2023-08-10
4
+
5
+ Abstract:
6
+ This paper discusses the differential diagnosis of Parkinson's Disease (PD) and other parkinsonian syndromes, providing clinical guidelines for accurate diagnosis.
7
+
8
+ Key Points:
9
+ 1. Parkinson's Disease vs. Essential Tremor:
10
+ - PD tremor: Typically resting, asymmetric, 4-6 Hz frequency
11
+ - Essential tremor: Primarily postural/action tremor, often symmetric, 8-12 Hz frequency
12
+ - Essential tremor patients typically lack bradykinesia and rigidity
13
+ - Family history more common in essential tremor
14
+
15
+ 2. Parkinson's Disease vs. SWEDD:
16
+ - SWEDD (Scans Without Evidence of Dopaminergic Deficit) patients present with parkinsonian symptoms
17
+ - Normal dopaminergic imaging distinguishes SWEDD from PD
18
+ - SWEDD patients often show dystonic features and lack true bradykinesia
19
+ - SWEDD patients typically do not respond well to levodopa therapy
20
+
21
+ 3. Parkinson's Disease vs. Atypical Parkinsonism:
22
+ - Multiple System Atrophy (MSA): Early autonomic failure, cerebellar signs, poor levodopa response
23
+ - Progressive Supranuclear Palsy (PSP): Early falls, vertical gaze palsy, axial rigidity
24
+ - Corticobasal Degeneration (CBD): Asymmetric apraxia, alien limb phenomenon, cortical sensory loss
25
+
26
+ 4. Prodromal Parkinson's Disease:
27
+ - REM sleep behavior disorder: 80% convert to synucleinopathy within 10-15 years
28
+ - Hyposmia: Reduced sense of smell often precedes motor symptoms by years
29
+ - Constipation: Common non-motor symptom that may appear decades before diagnosis
30
+ - Subtle motor changes: Reduced arm swing, changes in handwriting, voice softening
31
+
32
+ Conclusion:
33
+ Accurate differential diagnosis requires careful clinical assessment, consideration of response to levodopa, neuroimaging, and monitoring of disease progression. Early non-motor symptoms may help identify prodromal PD cases before classic motor features emerge.
medical_docs/mds-clinical-diagnostic-criteria-for-parkinson's-disease.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:16fa3730d16168654a1ee5097c21c295cacd4d3e2189f76449ba06676e6ff2ef
3
+ size 201526
medical_docs/mds-research-criteria-for-prodromal-parkinson's-disease.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5b5f966f634a4150f5252eecd132963cf44047d7ba41aa32143ac47b56bfaa3e
3
+ size 281649
medical_docs/parkinsons-disease-in-adults-pdf-1837629189061.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5679922ecf82e77df4b5c206661b3dfb61082e5aacb00bb43aa0a9926cfbd9c0
3
+ size 167734
medical_docs/parkinsons_research.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Title: Recent Advances in Parkinson's Disease Research
2
+ Author: Dr. James Wilson
3
+ Date: 2023-05-15
4
+
5
+ Abstract:
6
+ This paper reviews recent advances in Parkinson's Disease (PD) research, focusing on early detection, biomarkers, and treatment options.
7
+
8
+ Key Findings:
9
+ 1. Early Detection: Tremor, rigidity, bradykinesia, and postural instability remain the cardinal symptoms of PD. However, non-motor symptoms like REM sleep behavior disorder, hyposmia, and constipation often precede motor symptoms by years and may serve as early indicators.
10
+
11
+ 2. Biomarkers: Alpha-synuclein aggregation in peripheral tissues shows promise as a biomarker. Neuroimaging techniques including DaTscan can help differentiate PD from other movement disorders.
12
+
13
+ 3. Genetic Factors: Mutations in LRRK2, GBA, and SNCA genes are associated with increased PD risk. Family history remains a significant risk factor, with first-degree relatives having 2-3 times higher risk.
14
+
15
+ 4. Treatment Approaches: Levodopa remains the gold standard for symptomatic treatment. Deep brain stimulation shows efficacy for motor symptoms in advanced cases. Emerging therapies targeting alpha-synuclein are in clinical trials.
16
+
17
+ 5. Differential Diagnosis: SWEDD (Scans Without Evidence of Dopaminergic Deficit) patients present with parkinsonian symptoms but normal dopaminergic imaging. Essential tremor differs from PD tremor by being primarily postural rather than resting.
18
+
19
+ Conclusion:
20
+ Early diagnosis and personalized treatment approaches based on symptom profile, genetic factors, and biomarkers represent the future of PD management. Multidisciplinary care remains essential for optimal patient outcomes.
models/saved/lightgbm_model.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:83d39246aac8638f4df77e1d4b66ebf65470d03710c5faa68aa8aa38398a22b6
3
+ size 27464708
models/saved/multimodal_ensemble.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7186412962046f02ecf6b67fdc425291e16101f0c67193c7cd0622febadbb659
3
+ size 661938
models/saved/svm_model.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e63ad40596edab8b6afdde418533c6a5737bae475c630b90d8e54ad435ddb9ff
3
+ size 1545535
models/saved/traditional_class_mapping.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "1": 0,
3
+ "2": 1,
4
+ "3": 2,
5
+ "4": 3
6
+ }
models/saved/traditional_preprocessor.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8756a573be06ce68e104fb59d0f9458e0316b5b73f6da122af0cd7fffb3cb0a8
3
+ size 3871426
models/saved/xgboost_model.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a4a1e1dd62168b450c05fc4f47f9861bfb664051aff34ab38c35dc35ab096029
3
+ size 2100189
requirements-hf.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces runtime dependencies.
2
+ # This keeps the free CPU container lean by using traditional-model inference.
3
+ pandas>=2.1.0
4
+ numpy>=1.24.3
5
+ scipy>=1.11.0
6
+ scikit-learn>=1.3.0
7
+ lightgbm>=4.1.0
8
+ xgboost>=2.0.0
9
+ torch>=2.1.0
10
+ Flask>=2.3.0
11
+ flask-cors>=4.0.0
12
+ Werkzeug>=2.3.0
13
+ PyPDF2>=3.0.0
14
+ matplotlib>=3.7.2
15
+ seaborn>=0.12.2
16
+ reportlab>=4.0.7
17
+ joblib>=1.3.0
18
+ tqdm>=4.66.0
src/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """
2
+ Source package for the Parkinson's Disease Assessment System.
3
+ """
src/analyze_data.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import matplotlib.pyplot as plt
4
+ import pandas as pd
5
+ import seaborn as sns
6
+
7
+ from data_preprocessing import DataPreprocessor
8
+
9
+
10
+ def analyze_dataset():
11
+ root_dir = Path(__file__).resolve().parents[1]
12
+ dataset_path = root_dir / "PPMI_Curated_Data_Cut_Public_20250714.csv"
13
+ notebooks_dir = root_dir / "notebooks"
14
+ notebooks_dir.mkdir(exist_ok=True)
15
+
16
+ if not dataset_path.exists():
17
+ raise FileNotFoundError(f"Dataset not found: {dataset_path}")
18
+
19
+ df = pd.read_csv(dataset_path)
20
+ print("\nDataset Shape:", df.shape)
21
+
22
+ if "COHORT" not in df.columns:
23
+ raise ValueError("Dataset is missing required column: COHORT")
24
+
25
+ print("\nCOHORT Distribution:")
26
+ cohort_dist = df["COHORT"].value_counts(dropna=False)
27
+ print(cohort_dist)
28
+
29
+ preprocessor = DataPreprocessor()
30
+ selected_features = list(preprocessor.core.selected_features)
31
+
32
+ analysis_features = [
33
+ col
34
+ for col in selected_features
35
+ if col in df.columns and col not in {"COHORT", "PATNO"}
36
+ ]
37
+
38
+ print(f"\nUsing {len(analysis_features)} available analysis features:")
39
+ print(analysis_features)
40
+
41
+ if not analysis_features:
42
+ raise ValueError("No expected analysis features were found in the dataset.")
43
+
44
+ print("\nKey Features Statistics:")
45
+ print(df[analysis_features].describe(include="all").transpose())
46
+
47
+ print("\nMissing Values Analysis:")
48
+ missing = df[analysis_features].isnull().sum()
49
+ missing_pct = (missing / len(df)) * 100
50
+ missing_summary = pd.DataFrame(
51
+ {
52
+ "Missing Values": missing,
53
+ "Percentage": missing_pct,
54
+ }
55
+ ).sort_values("Percentage", ascending=False)
56
+ print(missing_summary[missing_summary["Missing Values"] > 0])
57
+
58
+ plt.figure(figsize=(10, 6))
59
+ sns.countplot(data=df, x="COHORT")
60
+ plt.title("Distribution of Cohorts")
61
+ plt.tight_layout()
62
+ plt.savefig(notebooks_dir / "cohort_distribution.png")
63
+ plt.close()
64
+
65
+ numerical_features = (
66
+ df[analysis_features].select_dtypes(include=["number"]).columns.tolist()
67
+ )
68
+
69
+ if numerical_features:
70
+ plt.figure(figsize=(12, 8))
71
+ correlation_matrix = df[numerical_features].corr(numeric_only=True)
72
+ sns.heatmap(correlation_matrix, annot=False, cmap="coolwarm", center=0)
73
+ plt.title("Feature Correlation Matrix")
74
+ plt.tight_layout()
75
+ plt.savefig(notebooks_dir / "correlation_matrix.png")
76
+ plt.close()
77
+
78
+ n_features = len(numerical_features)
79
+ n_cols = 3
80
+ n_rows = (n_features + n_cols - 1) // n_cols
81
+
82
+ plt.figure(figsize=(15, 5 * n_rows))
83
+ for i, feature in enumerate(numerical_features, 1):
84
+ plt.subplot(n_rows, n_cols, i)
85
+ sns.histplot(data=df, x=feature, kde=True)
86
+ plt.title(feature)
87
+ plt.tight_layout()
88
+ plt.savefig(notebooks_dir / "feature_distributions.png")
89
+ plt.close()
90
+ else:
91
+ print("\nNo numerical features available for correlation/distribution plots.")
92
+
93
+ print("\nFeature Types:")
94
+ for feature in analysis_features:
95
+ dtype = df[feature].dtype
96
+ n_unique = df[feature].nunique(dropna=True)
97
+ print(f"- {feature}: {dtype}, {n_unique} unique values")
98
+
99
+ missing_expected = [
100
+ col
101
+ for col in selected_features
102
+ if col not in df.columns and col not in {"COHORT", "PATNO"}
103
+ ]
104
+ if missing_expected:
105
+ print("\nExpected features not found in dataset:")
106
+ for feature in missing_expected:
107
+ print(f"- {feature}")
108
+
109
+
110
+ if __name__ == "__main__":
111
+ analyze_dataset()
src/data_preprocessing.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import pandas as pd
4
+ import numpy as np
5
+
6
+ from sklearn.model_selection import GroupShuffleSplit, train_test_split
7
+ from sklearn.preprocessing import StandardScaler, OneHotEncoder
8
+ from sklearn.impute import SimpleImputer, KNNImputer
9
+ from sklearn.compose import ColumnTransformer
10
+ from sklearn.pipeline import Pipeline
11
+
12
+
13
+ class PPMIDataPreprocessor:
14
+ """
15
+ Clean, leak-proof preprocessing pipeline for PPMI dataset.
16
+ Handles patient-level splitting, imputation, encoding, scaling.
17
+ """
18
+
19
+ def __init__(self):
20
+
21
+ # -------------------------
22
+ # Selected Features
23
+ # -------------------------
24
+ self.selected_features = [
25
+ # Demographics
26
+ "age", "SEX", "EDUCYRS", "race", "BMI",
27
+
28
+ # Family History
29
+ "fampd", "fampd_bin",
30
+
31
+ # Motor symptoms
32
+ "sym_tremor", "sym_rigid", "sym_brady", "sym_posins",
33
+
34
+ # Non-motor
35
+ "rem", "ess", "gds", "stai",
36
+
37
+ # Cognitive
38
+ "moca", "clockdraw", "bjlot",
39
+
40
+ # --- EXTENDED MEDICAL FEATURES ---
41
+ # Olfactory
42
+ "upsit", "upsit_pctl",
43
+
44
+ # UPDRS (Gold standard for PD)
45
+ "updrs1_score", "updrs2_score", "updrs3_score", "updrs4_score", "updrs_totscore",
46
+
47
+ # DatScan (Imaging)
48
+ "mean_caudate", "mean_putamen",
49
+
50
+ # Biomarkers (CSF)
51
+ "abeta", "tau", "ptau",
52
+
53
+ # Target
54
+ "COHORT",
55
+
56
+ # Patient ID
57
+ "PATNO"
58
+ ]
59
+
60
+ # Features to impute using KNN (to preserve correlations)
61
+ self.knn_cols = [
62
+ "moca", "clockdraw", "bjlot", "rem", "gds", "ess", "stai",
63
+ "upsit", "upsit_pctl",
64
+ "updrs1_score", "updrs2_score", "updrs3_score", "updrs4_score", "updrs_totscore",
65
+ "mean_caudate", "mean_putamen",
66
+ "abeta", "tau", "ptau"
67
+ ]
68
+
69
+ # Categorical
70
+ self.categorical_cols = ["SEX", "race", "fampd"]
71
+
72
+ # Numeric
73
+ self.numeric_cols = [
74
+ "age", "EDUCYRS", "BMI",
75
+ "fampd_bin",
76
+ "sym_tremor", "sym_rigid", "sym_brady", "sym_posins",
77
+
78
+ # New Medical Features (added for enhancement)
79
+ "updrs1_score", "updrs2_score", "updrs3_score", "updrs4_score", "updrs_totscore",
80
+ "mean_caudate", "mean_putamen",
81
+ "abeta", "tau", "ptau",
82
+ "upsit", "upsit_pctl"
83
+ ]
84
+
85
+ self.preprocessor = None
86
+
87
+ # ------------------------------------------------------------
88
+ def _clean_biomarker(self, x):
89
+ """Clean biomarker strings like '>1700' or '<200' to floats."""
90
+ if pd.isna(x):
91
+ return np.nan
92
+ if isinstance(x, str):
93
+ x = x.replace(">", "").replace("<", "")
94
+ try:
95
+ return float(x)
96
+ except ValueError:
97
+ return np.nan
98
+
99
+ def load_data(self, file_path):
100
+ """Load and filter only necessary columns."""
101
+ df = pd.read_csv(file_path, low_memory=False)
102
+
103
+ # Clean biomarker columns if they exist
104
+ for col in ["abeta", "tau", "ptau"]:
105
+ if col in df.columns:
106
+ df[col] = df[col].apply(self._clean_biomarker)
107
+
108
+ # Select available features (intersection with file columns)
109
+ available = [c for c in self.selected_features if c in df.columns]
110
+ # Always require COHORT/PATNO
111
+ if "COHORT" not in available or "PATNO" not in available:
112
+ raise ValueError("Dataset missing required COHORT or PATNO columns")
113
+
114
+ df = df[available].dropna(subset=["COHORT", "PATNO"])
115
+ return df
116
+
117
+ # ------------------------------------------------------------
118
+ def patient_split(self, df, test_size=0.2):
119
+ """Split such that the same patient never appears in both sets."""
120
+ gss = GroupShuffleSplit(test_size=test_size, n_splits=1, random_state=42)
121
+ train_idx, test_idx = next(gss.split(df, groups=df["PATNO"]))
122
+
123
+ train_df = df.iloc[train_idx].reset_index(drop=True)
124
+ test_df = df.iloc[test_idx].reset_index(drop=True)
125
+
126
+ return train_df, test_df
127
+
128
+ # ------------------------------------------------------------
129
+ def build_preprocessor(self):
130
+ """Builds leak-proof transformers."""
131
+
132
+ numeric_transformer = Pipeline(steps=[
133
+ ("imputer_mean", SimpleImputer(strategy="mean")),
134
+ ("scaler", StandardScaler())
135
+ ])
136
+
137
+ knn_transformer = Pipeline(steps=[
138
+ ("imputer_knn", KNNImputer(n_neighbors=5)),
139
+ ("scaler", StandardScaler())
140
+ ])
141
+
142
+ categorical_transformer = Pipeline(steps=[
143
+ ("imputer_mode", SimpleImputer(strategy="most_frequent")),
144
+ ("encoder", OneHotEncoder(handle_unknown="ignore"))
145
+ ])
146
+
147
+ self.preprocessor = ColumnTransformer(
148
+ transformers=[
149
+ ("num", numeric_transformer,
150
+ [c for c in self.numeric_cols if c not in self.knn_cols]),
151
+
152
+ ("knn", knn_transformer, self.knn_cols),
153
+
154
+ ("cat", categorical_transformer, self.categorical_cols),
155
+ ],
156
+ remainder="drop"
157
+ )
158
+
159
+ return self.preprocessor
160
+
161
+ # ------------------------------------------------------------
162
+ def prepare(self, file_path):
163
+ """
164
+ COMPLETE DATA PREPARATION PIPELINE
165
+ Returns X_train, X_test, y_train, y_test
166
+ """
167
+
168
+ df = self.load_data(file_path)
169
+ train_df, test_df = self.patient_split(df)
170
+
171
+ X_train = train_df.drop(["COHORT", "PATNO"], axis=1)
172
+ y_train = train_df["COHORT"]
173
+
174
+ X_test = test_df.drop(["COHORT", "PATNO"], axis=1)
175
+ y_test = test_df["COHORT"]
176
+
177
+ # Build processor & fit only on training data
178
+ pre = self.build_preprocessor()
179
+ X_train_processed = pre.fit_transform(X_train)
180
+ X_test_processed = pre.transform(X_test)
181
+
182
+ return X_train_processed, X_test_processed, y_train.values, y_test.values
183
+
184
+
185
+ class DataPreprocessor:
186
+ """Backwards-compatible wrapper around the new PPMI pipeline."""
187
+
188
+ def __init__(self):
189
+ self.core = PPMIDataPreprocessor()
190
+ self.feature_names_ = None
191
+ self.class_mapping_ = None
192
+ self.preprocessor_ = None
193
+ self.train_df_ = None
194
+ self.test_df_ = None
195
+
196
+ @staticmethod
197
+ def _to_python_scalar(value):
198
+ if isinstance(value, np.generic):
199
+ return value.item()
200
+ return value
201
+
202
+ def _load_all_files(self, file_paths):
203
+ if isinstance(file_paths, str):
204
+ file_paths = [file_paths]
205
+
206
+ dataframes = []
207
+ for path in file_paths:
208
+ if not path:
209
+ continue
210
+ if not os.path.exists(path):
211
+ print(f"[WARN] DataPreprocessor: '{path}' not found, skipping.")
212
+ continue
213
+ df = self.core.load_data(path)
214
+ dataframes.append(df)
215
+
216
+ if not dataframes:
217
+ raise FileNotFoundError("No valid CSV files were provided to DataPreprocessor.")
218
+
219
+ combined = pd.concat(dataframes, ignore_index=True)
220
+ combined = combined.drop_duplicates().reset_index(drop=True)
221
+ return combined
222
+
223
+ def prepare_data(self, file_paths, test_size=0.2, use_patient_split=True):
224
+ """Expose the legacy API expected by the training scripts."""
225
+
226
+ df = self._load_all_files(file_paths)
227
+
228
+ if use_patient_split:
229
+ train_df, test_df = self.core.patient_split(df, test_size=test_size)
230
+ else:
231
+ train_df, test_df = train_test_split(
232
+ df,
233
+ test_size=test_size,
234
+ random_state=42,
235
+ stratify=df["COHORT"],
236
+ )
237
+
238
+ self.train_df_ = train_df.reset_index(drop=True)
239
+ self.test_df_ = test_df.reset_index(drop=True)
240
+
241
+ classes_sorted = np.sort(df["COHORT"].unique())
242
+ self.class_mapping_ = {
243
+ self._to_python_scalar(original): int(idx)
244
+ for idx, original in enumerate(classes_sorted)
245
+ }
246
+
247
+ X_train = train_df.drop(["COHORT", "PATNO"], axis=1)
248
+ y_train = train_df["COHORT"].map(self.class_mapping_).values
249
+ X_test = test_df.drop(["COHORT", "PATNO"], axis=1)
250
+ y_test = test_df["COHORT"].map(self.class_mapping_).values
251
+
252
+ pre = self.core.build_preprocessor()
253
+ X_train_processed = pre.fit_transform(X_train)
254
+ X_test_processed = pre.transform(X_test)
255
+ self.preprocessor_ = pre
256
+
257
+ try:
258
+ self.feature_names_ = pre.get_feature_names_out(X_train.columns).tolist()
259
+ except AttributeError:
260
+ self.feature_names_ = None
261
+
262
+ return X_train_processed, X_test_processed, y_train, y_test
263
+
264
+ def get_feature_names(self):
265
+ if self.feature_names_ is None:
266
+ raise ValueError("Feature names are unavailable. Call prepare_data() first.")
267
+ return self.feature_names_
268
+
269
+ def get_preprocessor(self):
270
+ if self.preprocessor_ is None:
271
+ raise ValueError("Preprocessor is unavailable. Call prepare_data() first.")
272
+ return self.preprocessor_
273
+
274
+ def get_class_mapping(self):
275
+ if self.class_mapping_ is None:
276
+ raise ValueError("Class mapping is unavailable. Call prepare_data() first.")
277
+ return self.class_mapping_
278
+
279
+ def get_split_frames(self):
280
+ if self.train_df_ is None or self.test_df_ is None:
281
+ raise ValueError("Split frames are unavailable. Call prepare_data() first.")
282
+ return self.train_df_.copy(), self.test_df_.copy()
283
+
284
+
285
+ # -------------------------------------------------------------------
286
+ # Script example (not executed when imported)
287
+ # -------------------------------------------------------------------
288
+ if __name__ == "__main__":
289
+ file_path = "PPMI_Curated_Data.csv"
290
+ prep = PPMIDataPreprocessor()
291
+ X_train, X_test, y_train, y_test = prep.prepare(file_path)
292
+
293
+ print("Train:", X_train.shape)
294
+ print("Test:", X_test.shape)
src/document_manager.py ADDED
@@ -0,0 +1,455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Document Manager for RAG System
3
+ Handles loading, processing, and retrieving medical documents for the RAG system
4
+ """
5
+
6
+ import os
7
+ import re
8
+ import json
9
+ import numpy as np
10
+ import pandas as pd
11
+ from typing import Dict, List, Tuple, Optional, Union
12
+ from pathlib import Path
13
+ import pickle
14
+ import shutil
15
+ from sklearn.feature_extraction.text import TfidfVectorizer
16
+ from sklearn.metrics.pairwise import cosine_similarity
17
+ import PyPDF2 # For handling PDF files
18
+
19
+ class DocumentManager:
20
+ """Manages medical documents for the RAG system."""
21
+
22
+ def __init__(self, docs_dir: str = "medical_docs"):
23
+ """Initialize the document manager.
24
+
25
+ Args:
26
+ docs_dir: Directory where medical documents are stored
27
+ """
28
+ self.docs_dir = Path(docs_dir)
29
+ self.documents = {}
30
+ self.document_embeddings = {}
31
+ self.vectorizer = TfidfVectorizer(stop_words='english')
32
+
33
+ # Create docs directory if it doesn't exist
34
+ os.makedirs(self.docs_dir, exist_ok=True)
35
+
36
+ # For backward compatibility, also check for files directly in the docs_dir
37
+ self.main_dir = self.docs_dir
38
+
39
+ # Create subdirectories for different document types
40
+ self.papers_dir = self.docs_dir / "papers"
41
+ self.guidelines_dir = self.docs_dir / "guidelines"
42
+ self.textbooks_dir = self.docs_dir / "textbooks"
43
+
44
+ os.makedirs(self.papers_dir, exist_ok=True)
45
+ os.makedirs(self.guidelines_dir, exist_ok=True)
46
+ os.makedirs(self.textbooks_dir, exist_ok=True)
47
+
48
+ # Load existing documents if any
49
+ self.load_documents()
50
+
51
+ def _build_document_entry(
52
+ self,
53
+ doc_id: str,
54
+ doc_type: str,
55
+ content: str,
56
+ file_path: Path,
57
+ metadata: Optional[Dict] = None,
58
+ ) -> Dict:
59
+ """Create a normalized in-memory representation for a document."""
60
+ resolved_path = Path(file_path)
61
+ size_bytes = None
62
+ try:
63
+ size_bytes = resolved_path.stat().st_size
64
+ except OSError:
65
+ pass
66
+
67
+ return {
68
+ "id": doc_id,
69
+ "type": doc_type,
70
+ "content": content,
71
+ "metadata": metadata or self._extract_metadata(content),
72
+ "file_path": str(resolved_path),
73
+ "size_bytes": size_bytes,
74
+ }
75
+
76
+ def load_documents(self) -> None:
77
+ """Load all documents from the docs directory."""
78
+ self.documents = {}
79
+
80
+ # Load documents from each subdirectory
81
+ for doc_type, directory in [
82
+ ("paper", self.papers_dir),
83
+ ("guideline", self.guidelines_dir),
84
+ ("textbook", self.textbooks_dir)
85
+ ]:
86
+ # Load text files
87
+ for file_path in directory.glob("*.txt"):
88
+ doc_id = f"{doc_type}_{file_path.stem}"
89
+ with open(file_path, 'r', encoding='utf-8') as f:
90
+ content = f.read()
91
+
92
+ self.documents[doc_id] = self._build_document_entry(
93
+ doc_id, doc_type, content, file_path
94
+ )
95
+
96
+ # Load PDF files
97
+ for file_path in directory.glob("*.pdf"):
98
+ doc_id = f"{doc_type}_{file_path.stem}"
99
+ content = self._extract_text_from_pdf(file_path)
100
+
101
+ self.documents[doc_id] = self._build_document_entry(
102
+ doc_id, doc_type, content, file_path
103
+ )
104
+
105
+ # Also check for files directly in the main directory
106
+ # Text files
107
+ for file_path in self.main_dir.glob("*.txt"):
108
+ doc_id = f"document_{file_path.stem}"
109
+ with open(file_path, 'r', encoding='utf-8') as f:
110
+ content = f.read()
111
+
112
+ self.documents[doc_id] = self._build_document_entry(
113
+ doc_id, "paper", content, file_path
114
+ )
115
+
116
+ # PDF files
117
+ for file_path in self.main_dir.glob("*.pdf"):
118
+ doc_id = f"document_{file_path.stem}"
119
+ content = self._extract_text_from_pdf(file_path)
120
+
121
+ self.documents[doc_id] = self._build_document_entry(
122
+ doc_id, "paper", content, file_path
123
+ )
124
+
125
+ # Create document embeddings
126
+ self._create_embeddings()
127
+
128
+ # Count document types
129
+ doc_counts = {'paper': 0, 'guideline': 0, 'textbook': 0, 'total': len(self.documents)}
130
+ for doc in self.documents.values():
131
+ doc_type = doc.get('type', 'unknown')
132
+ if doc_type in doc_counts:
133
+ doc_counts[doc_type] += 1
134
+
135
+ print(f"Loaded {doc_counts} medical documents")
136
+
137
+ def _extract_text_from_pdf(self, file_path: Path) -> str:
138
+ """Extract text content from a PDF file."""
139
+ try:
140
+ text = ""
141
+ with open(file_path, 'rb') as file:
142
+ pdf_reader = PyPDF2.PdfReader(file)
143
+ for page_num in range(len(pdf_reader.pages)):
144
+ page = pdf_reader.pages[page_num]
145
+ text += page.extract_text() + "\n\n"
146
+ return text
147
+ except Exception as e:
148
+ print(f"Error extracting text from PDF {file_path}: {e}")
149
+ return f"Error extracting text: {str(e)}"
150
+
151
+ def _extract_metadata(self, content: str) -> Dict:
152
+ """Extract metadata from document content."""
153
+ metadata = {
154
+ "title": "",
155
+ "authors": "",
156
+ "year": "",
157
+ "source": "",
158
+ "keywords": []
159
+ }
160
+
161
+ # Try to extract metadata from the first 20 lines
162
+ lines = content.split('\n')[:20]
163
+ for line in lines:
164
+ if line.lower().startswith("title:"):
165
+ metadata["title"] = line.split(":", 1)[1].strip()
166
+ elif line.lower().startswith("author") or line.lower().startswith("authors"):
167
+ metadata["authors"] = line.split(":", 1)[1].strip()
168
+ elif line.lower().startswith("year:"):
169
+ metadata["year"] = line.split(":", 1)[1].strip()
170
+ elif line.lower().startswith("source:"):
171
+ metadata["source"] = line.split(":", 1)[1].strip()
172
+ elif line.lower().startswith("keywords:"):
173
+ keywords = line.split(":", 1)[1].strip()
174
+ metadata["keywords"] = [k.strip() for k in keywords.split(",")]
175
+
176
+ return metadata
177
+
178
+ def _create_embeddings(self) -> None:
179
+ """Create TF-IDF embeddings for all documents."""
180
+ self.document_embeddings = {}
181
+ if not self.documents:
182
+ return
183
+
184
+ # Extract document contents
185
+ doc_ids = list(self.documents.keys())
186
+ doc_contents = [self.documents[doc_id]["content"] for doc_id in doc_ids]
187
+
188
+ # Create TF-IDF matrix
189
+ tfidf_matrix = self.vectorizer.fit_transform(doc_contents)
190
+
191
+ # Store embeddings
192
+ for i, doc_id in enumerate(doc_ids):
193
+ self.document_embeddings[doc_id] = tfidf_matrix[i]
194
+
195
+ def add_document(self, file_path: str, doc_type: str = "paper", title: str = None, author: str = None) -> str:
196
+ """Add a new document to the collection.
197
+
198
+ Args:
199
+ file_path: Path to the document file
200
+ doc_type: Type of document (paper, guideline, textbook)
201
+ title: Optional title metadata
202
+ author: Optional author metadata
203
+
204
+ Returns:
205
+ Document ID of the added document
206
+ """
207
+ file_path = Path(file_path)
208
+
209
+ if not file_path.exists():
210
+ raise FileNotFoundError(f"Document not found: {file_path}")
211
+
212
+ # Determine target directory
213
+ if doc_type == "paper":
214
+ target_dir = self.papers_dir
215
+ elif doc_type == "guideline":
216
+ target_dir = self.guidelines_dir
217
+ elif doc_type == "textbook":
218
+ target_dir = self.textbooks_dir
219
+ else:
220
+ doc_type = "paper"
221
+ target_dir = self.papers_dir
222
+
223
+ target_path = target_dir / file_path.name
224
+
225
+ # Copy file as binary-safe (works for PDF/text)
226
+ if file_path.resolve() != target_path.resolve():
227
+ shutil.copy2(file_path, target_path)
228
+
229
+ # Read content based on file extension
230
+ if target_path.suffix.lower() == '.pdf':
231
+ content = self._extract_text_from_pdf(target_path)
232
+ else:
233
+ with open(target_path, 'r', encoding='utf-8', errors='ignore') as f:
234
+ content = f.read()
235
+
236
+ metadata = self._extract_metadata(content)
237
+ if title:
238
+ metadata['title'] = title
239
+ if author:
240
+ metadata['authors'] = author
241
+
242
+ doc_id = f"{doc_type}_{target_path.stem}"
243
+
244
+ self.documents[doc_id] = self._build_document_entry(
245
+ doc_id,
246
+ doc_type,
247
+ content,
248
+ target_path,
249
+ metadata=metadata,
250
+ )
251
+
252
+ self._create_embeddings()
253
+ return doc_id
254
+
255
+ def remove_document(self, doc_id: str) -> bool:
256
+ """Remove a document from the collection.
257
+
258
+ Args:
259
+ doc_id: ID of the document to remove
260
+
261
+ Returns:
262
+ True if document was removed, False otherwise
263
+ """
264
+ if doc_id not in self.documents:
265
+ return False
266
+
267
+ # Get file path
268
+ file_path = self.documents[doc_id]["file_path"]
269
+
270
+ # Remove file
271
+ try:
272
+ os.remove(file_path)
273
+ except Exception as e:
274
+ print(f"Error removing file {file_path}: {e}")
275
+
276
+ # Remove from documents collection
277
+ del self.documents[doc_id]
278
+
279
+ # Remove from embeddings
280
+ if doc_id in self.document_embeddings:
281
+ del self.document_embeddings[doc_id]
282
+
283
+ if self.documents:
284
+ self._create_embeddings()
285
+ else:
286
+ self.document_embeddings = {}
287
+
288
+ return True
289
+
290
+ def search_documents(self, query: str, top_k: int = 5) -> List[Dict]:
291
+ """Search for documents matching the query.
292
+
293
+ Args:
294
+ query: Search query
295
+ top_k: Number of top results to return
296
+
297
+ Returns:
298
+ List of matching documents with relevance scores
299
+ """
300
+ if not self.documents:
301
+ return []
302
+
303
+ # Create query embedding
304
+ query_embedding = self.vectorizer.transform([query])
305
+
306
+ # Calculate similarity scores
307
+ scores = {}
308
+ for doc_id, doc_embedding in self.document_embeddings.items():
309
+ similarity = cosine_similarity(query_embedding, doc_embedding)[0][0]
310
+ scores[doc_id] = similarity
311
+
312
+ # Sort by similarity score
313
+ sorted_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True)
314
+
315
+ # Return top-k results
316
+ results = []
317
+ for doc_id, score in sorted_docs[:top_k]:
318
+ doc = self.documents[doc_id].copy()
319
+ doc["relevance"] = float(score)
320
+ results.append(doc)
321
+
322
+ return results
323
+
324
+ def get_document_by_id(self, doc_id: str) -> Optional[Dict]:
325
+ """Get a document by its ID.
326
+
327
+ Args:
328
+ doc_id: ID of the document
329
+
330
+ Returns:
331
+ Document dict or None if not found
332
+ """
333
+ return self.documents.get(doc_id)
334
+
335
+ def _serialize_document(
336
+ self,
337
+ doc: Dict,
338
+ include_content: bool = True,
339
+ preview_length: int = 180,
340
+ ) -> Dict:
341
+ """Return a client-facing representation of a document."""
342
+ data = doc.copy()
343
+ metadata = dict(data.get("metadata", {}))
344
+ if not metadata.get("title"):
345
+ metadata["title"] = Path(data.get("file_path", "")).stem
346
+ data["metadata"] = metadata
347
+ data["title"] = metadata.get("title", "")
348
+ data["author"] = metadata.get("authors", "")
349
+
350
+ content = data.get("content", "") or ""
351
+ data["preview"] = (
352
+ f"{content[:preview_length]}..." if len(content) > preview_length else content
353
+ )
354
+
355
+ if not include_content:
356
+ data.pop("content", None)
357
+
358
+ return data
359
+
360
+ def get_all_documents(self, include_content: bool = True, preview_length: int = 180) -> List[Dict]:
361
+ """Compatibility helper expected by web_interface.py."""
362
+ out = []
363
+ for doc in self.documents.values():
364
+ out.append(
365
+ self._serialize_document(
366
+ doc,
367
+ include_content=include_content,
368
+ preview_length=preview_length,
369
+ )
370
+ )
371
+ return out
372
+
373
+ def get_document(self, doc_id: str) -> Optional[Dict]:
374
+ """Compatibility helper expected by web_interface.py."""
375
+ doc = self.get_document_by_id(doc_id)
376
+ if doc is None:
377
+ return None
378
+ return self._serialize_document(doc, include_content=True)
379
+
380
+ def get_document_summary(self, doc_id: str, preview_length: int = 180) -> Optional[Dict]:
381
+ """Return a lighter-weight document representation for list views."""
382
+ doc = self.get_document_by_id(doc_id)
383
+ if doc is None:
384
+ return None
385
+ return self._serialize_document(doc, include_content=False, preview_length=preview_length)
386
+
387
+ def get_document_count(self) -> Dict[str, int]:
388
+ """Get count of documents by type.
389
+
390
+ Returns:
391
+ Dict with counts by document type
392
+ """
393
+ counts = {
394
+ "paper": 0,
395
+ "guideline": 0,
396
+ "textbook": 0,
397
+ "total": len(self.documents)
398
+ }
399
+
400
+ for doc in self.documents.values():
401
+ counts[doc["type"]] += 1
402
+
403
+ return counts
404
+
405
+ def extract_relevant_passages(self, query: str, top_k: int = 3,
406
+ passage_length: int = 500) -> List[Dict]:
407
+ """Extract relevant passages from documents for a query.
408
+
409
+ Args:
410
+ query: Search query
411
+ top_k: Number of top passages to return
412
+ passage_length: Approximate length of each passage
413
+
414
+ Returns:
415
+ List of relevant passages with metadata
416
+ """
417
+ # First, get relevant documents
418
+ relevant_docs = self.search_documents(query, top_k=top_k)
419
+
420
+ passages = []
421
+ for doc in relevant_docs:
422
+ content = doc["content"]
423
+
424
+ # Split content into paragraphs
425
+ paragraphs = re.split(r'\n\s*\n', content)
426
+
427
+ # Create passages by combining paragraphs
428
+ current_passage = ""
429
+ for para in paragraphs:
430
+ if len(current_passage) + len(para) <= passage_length:
431
+ current_passage += para + "\n\n"
432
+ else:
433
+ # Add current passage to results
434
+ if current_passage:
435
+ passages.append({
436
+ "text": current_passage.strip(),
437
+ "doc_id": doc["id"],
438
+ "doc_title": doc["metadata"]["title"],
439
+ "relevance": doc["relevance"]
440
+ })
441
+ current_passage = para + "\n\n"
442
+
443
+ # Add final passage
444
+ if current_passage:
445
+ passages.append({
446
+ "text": current_passage.strip(),
447
+ "doc_id": doc["id"],
448
+ "doc_title": doc["metadata"]["title"],
449
+ "relevance": doc["relevance"]
450
+ })
451
+
452
+ # Sort passages by relevance
453
+ passages.sort(key=lambda x: x["relevance"], reverse=True)
454
+
455
+ return passages[:top_k]
src/dual_report_generator.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dual report generator for patient-friendly and clinician-facing outputs.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Dict, Any
9
+ from datetime import datetime
10
+
11
+
12
+ CLASS_NAMES = ['Healthy Control', "Parkinson's Disease", 'SWEDD', 'Prodromal PD']
13
+
14
+
15
+ @dataclass
16
+ class PatientReportGenerator:
17
+ def generate_report(self, prediction_results: Dict[str, Any], patient_data: Dict[str, Any]) -> str:
18
+ pred_idx = int(prediction_results.get('ensemble_prediction', 0))
19
+ pred_label = CLASS_NAMES[pred_idx] if 0 <= pred_idx < len(CLASS_NAMES) else 'Unknown'
20
+ confidence = float(prediction_results.get('confidence', 0.0)) * 100.0
21
+
22
+ lines = [
23
+ 'PATIENT SUMMARY REPORT',
24
+ '=' * 40,
25
+ f'Date: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}',
26
+ '',
27
+ f'Primary assessment: {pred_label}',
28
+ f'Confidence: {confidence:.1f}%',
29
+ '',
30
+ 'What this means:',
31
+ '- This is an AI-assisted assessment from questionnaire/clinical inputs.',
32
+ '- It is NOT a final diagnosis.',
33
+ '- Please consult a neurologist for clinical confirmation.',
34
+ '',
35
+ 'Next steps:',
36
+ '- Book specialist follow-up',
37
+ '- Track symptom progression',
38
+ '- Bring this report to your clinician',
39
+ '',
40
+ 'Disclaimer: Educational/support use only. Not medical advice.'
41
+ ]
42
+ return '\n'.join(lines)
43
+
44
+
45
+ @dataclass
46
+ class DoctorReportGenerator:
47
+ def generate_report(self, prediction_results: Dict[str, Any], patient_data: Dict[str, Any], literature_insights: str = '') -> str:
48
+ pred_idx = int(prediction_results.get('ensemble_prediction', 0))
49
+ pred_label = CLASS_NAMES[pred_idx] if 0 <= pred_idx < len(CLASS_NAMES) else 'Unknown'
50
+ confidence = float(prediction_results.get('confidence', 0.0)) * 100.0
51
+ probs = prediction_results.get('ensemble_probabilities', [0, 0, 0, 0])
52
+
53
+ lines = [
54
+ 'CLINICAL REPORT',
55
+ '=' * 40,
56
+ f'Date: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}',
57
+ '',
58
+ f'Predicted class: {pred_label}',
59
+ f'Model confidence: {confidence:.2f}%',
60
+ 'Probability distribution:',
61
+ f"- HC: {float(probs[0]) * 100:.2f}%" if len(probs) > 0 else '- HC: N/A',
62
+ f"- PD: {float(probs[1]) * 100:.2f}%" if len(probs) > 1 else '- PD: N/A',
63
+ f"- SWEDD: {float(probs[2]) * 100:.2f}%" if len(probs) > 2 else '- SWEDD: N/A',
64
+ f"- PRODROMAL: {float(probs[3]) * 100:.2f}%" if len(probs) > 3 else '- PRODROMAL: N/A',
65
+ '',
66
+ 'Patient features submitted:',
67
+ ]
68
+
69
+ for k, v in patient_data.items():
70
+ lines.append(f'- {k}: {v}')
71
+
72
+ if literature_insights:
73
+ lines.extend(['', 'Literature insights:', literature_insights])
74
+
75
+ lines.extend(['', 'Disclaimer: Decision support only; correlate clinically.'])
76
+ return '\n'.join(lines)
77
+
78
+
79
+ class DualReportManager:
80
+ def __init__(self):
81
+ self.patient_generator = PatientReportGenerator()
82
+ self.doctor_generator = DoctorReportGenerator()
83
+
84
+ def generate_both_reports(self, prediction_results: Dict[str, Any], patient_data: Dict[str, Any], literature_insights: str = '') -> Dict[str, str]:
85
+ return {
86
+ 'patient_report': self.patient_generator.generate_report(prediction_results, patient_data),
87
+ 'doctor_report': self.doctor_generator.generate_report(prediction_results, patient_data, literature_insights),
88
+ }
89
+
90
+ def save_reports(self, reports: Dict[str, str], report_dir: str, patient_id: str) -> Dict[str, str]:
91
+ import os
92
+ from pathlib import Path
93
+ os.makedirs(report_dir, exist_ok=True)
94
+
95
+ safe_patient_id = Path(str(patient_id or "patient")).stem
96
+ safe_patient_id = "".join(
97
+ ch if ch.isalnum() or ch in "._- " else "_" for ch in safe_patient_id
98
+ ).strip(" .") or "patient"
99
+
100
+ patient_report_path = os.path.join(report_dir, f'patient_report_{safe_patient_id}.txt')
101
+ doctor_report_path = os.path.join(report_dir, f'clinical_report_{safe_patient_id}.txt')
102
+
103
+ with open(patient_report_path, 'w', encoding='utf-8') as f:
104
+ f.write(reports.get('patient_report', ''))
105
+
106
+ with open(doctor_report_path, 'w', encoding='utf-8') as f:
107
+ f.write(reports.get('doctor_report', ''))
108
+
109
+ return {
110
+ 'patient_report_path': patient_report_path,
111
+ 'doctor_report_path': doctor_report_path,
112
+ }
src/evaluate_traditional_models.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Re-train and evaluate traditional models on the leak-free patient split."""
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+ import json
6
+
7
+ import joblib
8
+ import numpy as np
9
+ import pandas as pd
10
+ from sklearn.metrics import (
11
+ accuracy_score,
12
+ classification_report,
13
+ confusion_matrix,
14
+ precision_recall_fscore_support,
15
+ roc_curve,
16
+ auc
17
+ )
18
+ from sklearn.preprocessing import label_binarize
19
+ from sklearn.utils.class_weight import compute_class_weight
20
+ from lightgbm import LGBMClassifier
21
+ from xgboost import XGBClassifier
22
+ from sklearn.svm import SVC
23
+
24
+ sys.path.append(str(Path(__file__).parent))
25
+ from data_preprocessing import DataPreprocessor # type: ignore
26
+
27
+ ROOT = Path(__file__).resolve().parents[1]
28
+ EVAL_DIR = ROOT / "evaluation_results" / "model_metrics"
29
+ CLASS_REPORT_DIR = EVAL_DIR / "classification_reports"
30
+ CONF_MATRIX_DIR = EVAL_DIR / "confusion_matrices"
31
+ PLOTS_DIR = EVAL_DIR / "plots"
32
+ ROC_DIR = EVAL_DIR / "roc_curves"
33
+
34
+ for path in (CLASS_REPORT_DIR, CONF_MATRIX_DIR, PLOTS_DIR, ROC_DIR):
35
+ path.mkdir(parents=True, exist_ok=True)
36
+
37
+ FILE_PATHS = [
38
+ ROOT / "PPMI_Curated_Data_Cut_Public_20240129.csv",
39
+ ROOT / "PPMI_Curated_Data_Cut_Public_20241211.csv",
40
+ ROOT / "PPMI_Curated_Data_Cut_Public_20250321.csv",
41
+ ROOT / "PPMI_Curated_Data_Cut_Public_20250714.csv",
42
+ ]
43
+ CLASS_NAMES = ["HC", "PD", "SWEDD", "PRODROMAL"]
44
+
45
+
46
+ def load_or_create_split():
47
+ split_path = ROOT / "evaluation_results" / "leak_free_split.npz"
48
+ meta_path = ROOT / "evaluation_results" / "leak_free_split_meta.joblib"
49
+ if split_path.exists() and meta_path.exists():
50
+ split = np.load(split_path)
51
+ meta = joblib.load(meta_path)
52
+ return split, meta
53
+
54
+ preprocessor = DataPreprocessor()
55
+ X_train, X_test, y_train, y_test = preprocessor.prepare_data(
56
+ FILE_PATHS,
57
+ test_size=0.2,
58
+ use_patient_split=True,
59
+ )
60
+ split_path.parent.mkdir(parents=True, exist_ok=True)
61
+ np.savez(split_path, X_train=X_train, X_test=X_test, y_train=y_train, y_test=y_test)
62
+ joblib.dump(
63
+ {
64
+ "feature_names": preprocessor.get_feature_names(),
65
+ "class_mapping": preprocessor.get_class_mapping(),
66
+ },
67
+ meta_path,
68
+ )
69
+ return np.load(split_path), joblib.load(meta_path)
70
+
71
+
72
+ def train_models(X_train, y_train, class_weight_dict):
73
+ models = {}
74
+
75
+ lgb_params = dict(
76
+ random_state=42,
77
+ objective="multiclass",
78
+ num_class=len(CLASS_NAMES),
79
+ n_estimators=400,
80
+ learning_rate=0.03,
81
+ max_depth=7,
82
+ class_weight=class_weight_dict,
83
+ )
84
+ models["LightGBM"] = LGBMClassifier(**lgb_params)
85
+
86
+ xgb_params = dict(
87
+ random_state=42,
88
+ objective="multi:softmax",
89
+ num_class=len(CLASS_NAMES),
90
+ n_estimators=300,
91
+ learning_rate=0.05,
92
+ max_depth=6,
93
+ subsample=0.9,
94
+ colsample_bytree=0.9,
95
+ eval_metric="mlogloss",
96
+ )
97
+ models["XGBoost"] = XGBClassifier(**xgb_params)
98
+
99
+ models["SVM"] = SVC(
100
+ random_state=42,
101
+ probability=True,
102
+ kernel="rbf",
103
+ C=8.0,
104
+ gamma="scale",
105
+ class_weight=class_weight_dict,
106
+ )
107
+
108
+ for name, model in models.items():
109
+ model.fit(X_train, y_train)
110
+ return models
111
+
112
+
113
+ def evaluate_model(name, model, X_test, y_test):
114
+ y_pred = model.predict(X_test)
115
+ y_prob = model.predict_proba(X_test)
116
+
117
+ accuracy = accuracy_score(y_test, y_pred)
118
+ precision, recall, f1, _ = precision_recall_fscore_support(
119
+ y_test, y_pred, average="weighted", zero_division=0
120
+ )
121
+ report = classification_report(
122
+ y_test, y_pred, target_names=CLASS_NAMES, zero_division=0
123
+ )
124
+ cm = confusion_matrix(y_test, y_pred)
125
+
126
+ # Save classification report
127
+ (CLASS_REPORT_DIR / f"{name}.txt").write_text(
128
+ f"{name} Classification Report (leak-free split)\n" + "-" * 60 + "\n" + report
129
+ )
130
+
131
+ # Save confusion matrix csv
132
+ cm_df = pd.DataFrame(cm, index=CLASS_NAMES, columns=CLASS_NAMES)
133
+ cm_df.to_csv(CONF_MATRIX_DIR / f"{name}_confusion_matrix.csv")
134
+
135
+ # Save ROC curves
136
+ y_bin = label_binarize(y_test, classes=range(len(CLASS_NAMES)))
137
+ roc_data = []
138
+ for idx, class_name in enumerate(CLASS_NAMES):
139
+ fpr, tpr, _ = roc_curve(y_bin[:, idx], y_prob[:, idx])
140
+ roc_auc = auc(fpr, tpr)
141
+ roc_df = pd.DataFrame({"fpr": fpr, "tpr": tpr})
142
+ roc_df.to_csv(ROC_DIR / f"{name}_class_{class_name}_roc.csv", index=False)
143
+ roc_data.append({"class": class_name, "auc": roc_auc})
144
+
145
+ return {
146
+ "model": name,
147
+ "accuracy": accuracy,
148
+ "precision": precision,
149
+ "recall": recall,
150
+ "f1": f1,
151
+ }
152
+
153
+
154
+ def main():
155
+ split, meta = load_or_create_split()
156
+ feature_names = meta.get("feature_names") if isinstance(meta, dict) else None
157
+ X_train = split["X_train"]
158
+ X_test = split["X_test"]
159
+ y_train = split["y_train"]
160
+ y_test = split["y_test"]
161
+
162
+ if feature_names is not None:
163
+ X_train = pd.DataFrame(X_train, columns=feature_names)
164
+ X_test = pd.DataFrame(X_test, columns=feature_names)
165
+
166
+ classes = np.unique(y_train)
167
+ class_weights = compute_class_weight(class_weight="balanced", classes=classes, y=y_train)
168
+ class_weight_dict = {cls: weight for cls, weight in zip(classes, class_weights)}
169
+
170
+ models = train_models(X_train, y_train, class_weight_dict)
171
+
172
+ metrics = []
173
+ for name, model in models.items():
174
+ metrics.append(evaluate_model(name, model, X_test, y_test))
175
+ joblib.dump(model, ROOT / "models" / "saved" / f"{name.lower()}_model.joblib")
176
+
177
+ summary_path = EVAL_DIR / "model_metrics_summary_traditional.csv"
178
+ pd.DataFrame(metrics).to_csv(summary_path, index=False)
179
+ print(f"Saved traditional summary to {summary_path}")
180
+
181
+ (EVAL_DIR / "traditional_metrics_latest.json").write_text(
182
+ json.dumps(metrics, indent=2)
183
+ )
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()
src/evaluate_transformer_models.py ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evaluate transformer models (BioGPT, PubMedBERT, Clinical-T5) on the leak-free split."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import joblib
9
+ import numpy as np
10
+ import pandas as pd
11
+ import torch
12
+ from sklearn.metrics import (
13
+ accuracy_score,
14
+ auc,
15
+ classification_report,
16
+ confusion_matrix,
17
+ precision_recall_fscore_support,
18
+ roc_curve,
19
+ )
20
+ from sklearn.preprocessing import label_binarize
21
+ from torch.utils.data import DataLoader
22
+
23
+ sys.path.append(str(Path(__file__).parent))
24
+ from data_preprocessing import DataPreprocessor # type: ignore
25
+ from document_manager import DocumentManager # type: ignore
26
+ from models.medical_transformers import ( # type: ignore
27
+ BioMistralClassifier as BioGPTForTabular,
28
+ ClinicalT5Classifier as ClinicalT5ForTabular,
29
+ PubMedBERTClassifier as PubMedBERTForTabular,
30
+ )
31
+ from models.transformer_models import TabularDataset # type: ignore
32
+
33
+ ROOT = Path(__file__).resolve().parents[1]
34
+ EVAL_DIR = ROOT / "evaluation_results" / "model_metrics"
35
+ CLASS_REPORT_DIR = EVAL_DIR / "classification_reports"
36
+ CONF_MATRIX_DIR = EVAL_DIR / "confusion_matrices"
37
+ ROC_DIR = EVAL_DIR / "roc_curves"
38
+ SUMMARY_PATH = EVAL_DIR / "model_metrics_summary_transformers.csv"
39
+ LATEST_JSON = EVAL_DIR / "transformer_metrics_latest.json"
40
+ LEAK_FREE_SPLIT_PATH = ROOT / "evaluation_results" / "leak_free_split.npz"
41
+ LEAK_FREE_META_PATH = ROOT / "evaluation_results" / "leak_free_split_meta.joblib"
42
+ CLASS_NAMES = ["HC", "PD", "SWEDD", "PRODROMAL"]
43
+
44
+ for directory in (CLASS_REPORT_DIR, CONF_MATRIX_DIR, ROC_DIR):
45
+ directory.mkdir(parents=True, exist_ok=True)
46
+
47
+
48
+ def _load_or_create_leak_free_split() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, list[str]]:
49
+ if LEAK_FREE_SPLIT_PATH.exists() and LEAK_FREE_META_PATH.exists():
50
+ split = np.load(LEAK_FREE_SPLIT_PATH)
51
+ meta = joblib.load(LEAK_FREE_META_PATH)
52
+ feature_names = meta.get("feature_names") if isinstance(meta, dict) else None
53
+ if feature_names is None:
54
+ raise ValueError("Leak-free metadata missing feature names")
55
+ print("Loaded cached leak-free split artifacts.")
56
+ return split["X_train"], split["X_test"], split["y_train"], split["y_test"], feature_names
57
+
58
+ preprocessor = DataPreprocessor()
59
+ file_paths = [
60
+ ROOT / "PPMI_Curated_Data_Cut_Public_20240129.csv",
61
+ ROOT / "PPMI_Curated_Data_Cut_Public_20241211.csv",
62
+ ROOT / "PPMI_Curated_Data_Cut_Public_20250321.csv",
63
+ ROOT / "PPMI_Curated_Data_Cut_Public_20250714.csv",
64
+ ]
65
+ X_train, X_test, y_train, y_test = preprocessor.prepare_data(
66
+ file_paths,
67
+ test_size=0.2,
68
+ use_patient_split=True,
69
+ )
70
+ feature_names = preprocessor.get_feature_names()
71
+ LEAK_FREE_SPLIT_PATH.parent.mkdir(parents=True, exist_ok=True)
72
+ np.savez(
73
+ LEAK_FREE_SPLIT_PATH,
74
+ X_train=X_train,
75
+ X_test=X_test,
76
+ y_train=y_train,
77
+ y_test=y_test,
78
+ )
79
+ joblib.dump({"feature_names": feature_names}, LEAK_FREE_META_PATH)
80
+ print("Saved new leak-free split artifacts.")
81
+ return X_train, X_test, y_train, y_test, feature_names
82
+
83
+
84
+ def _prepare_batch(batch, device):
85
+ if len(batch) == 3:
86
+ data, targets, contexts = batch
87
+ contexts = list(contexts)
88
+ else:
89
+ data, targets = batch
90
+ contexts = None
91
+ data = data.to(device)
92
+ targets = targets.to(device)
93
+ return data, targets, contexts
94
+
95
+
96
+ def _build_context_cache(features: np.ndarray, feature_names: list[str], doc_manager: DocumentManager) -> list[str]:
97
+ cache = []
98
+ total = len(features)
99
+ for idx, row in enumerate(features):
100
+ feature_desc = {name: float(val) for name, val in zip(feature_names, row)}
101
+ query_parts = []
102
+ for symptom_key, col in {
103
+ "tremor": "sym_tremor",
104
+ "rigidity": "sym_rigid",
105
+ "bradykinesia": "sym_brady",
106
+ "postural instability": "sym_posins",
107
+ }.items():
108
+ if feature_desc.get(col, 0) > 0:
109
+ query_parts.append(f"{symptom_key} severity:{feature_desc[col]:.2f}")
110
+ moca = feature_desc.get("moca", 30)
111
+ if moca < 26:
112
+ query_parts.append("cognitive impairment")
113
+ age = feature_desc.get("age", 0)
114
+ if age:
115
+ query_parts.append(f"age {int(age)}")
116
+ if feature_desc.get("fampd", 0) > 0:
117
+ query_parts.append("family history Parkinson's disease")
118
+ query = "Parkinson's disease " + " ".join(query_parts)
119
+ passages = doc_manager.extract_relevant_passages(query, top_k=2)
120
+ if not passages:
121
+ cache.append("")
122
+ else:
123
+ combined = []
124
+ for passage in passages:
125
+ title = passage.get("doc_title") or passage.get("doc_id") or "document"
126
+ combined.append(f"From '{title}' {passage['text'][:300]}...")
127
+ cache.append(" ".join(combined))
128
+ if (idx + 1) % 250 == 0 or idx + 1 == total:
129
+ print(f" Cached RAG context for {idx + 1}/{total} samples")
130
+ return cache
131
+
132
+
133
+ def _save_outputs(model_name: str, report: str, cm: np.ndarray, y_test: np.ndarray, y_prob: np.ndarray) -> None:
134
+ (CLASS_REPORT_DIR / f"{model_name}.txt").write_text(
135
+ f"{model_name} Classification Report (leak-free split)\n" + "-" * 60 + "\n" + report
136
+ )
137
+ cm_df = pd.DataFrame(cm, index=CLASS_NAMES, columns=CLASS_NAMES)
138
+ cm_df.to_csv(CONF_MATRIX_DIR / f"{model_name}_confusion_matrix.csv")
139
+
140
+ y_bin = label_binarize(y_test, classes=range(len(CLASS_NAMES)))
141
+ for idx, class_name in enumerate(CLASS_NAMES):
142
+ fpr, tpr, _ = roc_curve(y_bin[:, idx], y_prob[:, idx])
143
+ roc_df = pd.DataFrame({"fpr": fpr, "tpr": tpr})
144
+ roc_df.to_csv(ROC_DIR / f"{model_name}_class_{class_name}_roc.csv", index=False)
145
+
146
+
147
+ def main() -> None:
148
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
149
+ print(f"Evaluating transformers on {device} (leak-free split)")
150
+ X_train, X_test, y_train, y_test, feature_names = _load_or_create_leak_free_split()
151
+ X_train = np.asarray(X_train)
152
+ X_test = np.asarray(X_test)
153
+ y_train = np.asarray(y_train)
154
+ y_test = np.asarray(y_test)
155
+
156
+ docs_path = ROOT / "medical_docs"
157
+ doc_manager = DocumentManager(docs_dir=str(docs_path))
158
+ print("Building RAG contexts for test set...")
159
+ test_contexts = _build_context_cache(X_test, feature_names, doc_manager)
160
+
161
+ test_dataset = TabularDataset(X_test, y_test, feature_names, contexts=test_contexts)
162
+ test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)
163
+
164
+ model_dir = ROOT / "models" / "saved"
165
+ model_configs = {
166
+ "BioGPT": {
167
+ "builder": lambda: BioGPTForTabular(
168
+ input_dim=X_train.shape[1],
169
+ num_classes=len(CLASS_NAMES),
170
+ dropout=0.15,
171
+ train_decoder_layers=6,
172
+ ),
173
+ "checkpoints": [
174
+ model_dir / "biogpt_transformer.pth",
175
+ model_dir / "biogpt.pth",
176
+ model_dir / "biomistral.pth",
177
+ ],
178
+ },
179
+ "PubMedBERT": {
180
+ "builder": lambda: PubMedBERTForTabular(
181
+ input_dim=X_train.shape[1],
182
+ num_classes=len(CLASS_NAMES),
183
+ dropout=0.15,
184
+ freeze_bert=False,
185
+ ),
186
+ "checkpoints": [
187
+ model_dir / "pubmedbert_transformer.pth",
188
+ model_dir / "pubmedbert.pth",
189
+ ],
190
+ },
191
+ "Clinical-T5": {
192
+ "builder": lambda: ClinicalT5ForTabular(
193
+ input_dim=X_train.shape[1],
194
+ num_classes=len(CLASS_NAMES),
195
+ dropout=0.15,
196
+ freeze_encoder=False,
197
+ ),
198
+ "checkpoints": [
199
+ model_dir / "clinical_t5_transformer.pth",
200
+ model_dir / "clinicalt5_transformer.pth",
201
+ model_dir / "clinical_t5.pth",
202
+ ],
203
+ },
204
+ }
205
+
206
+ summary_rows = []
207
+
208
+ for pretty_name, cfg in model_configs.items():
209
+ checkpoint_path = next((path for path in cfg["checkpoints"] if path.exists()), None)
210
+ if checkpoint_path is None:
211
+ expected = ", ".join(path.name for path in cfg["checkpoints"])
212
+ print(f"[WARN] Skipping {pretty_name}: no checkpoint found ({expected})")
213
+ continue
214
+
215
+ print(f"\nEvaluating {pretty_name}...")
216
+ model = cfg["builder"]().to(device)
217
+ state = torch.load(checkpoint_path, map_location=device)
218
+ model.load_state_dict(state)
219
+ model.eval()
220
+
221
+ all_targets = []
222
+ all_preds = []
223
+ all_prob = []
224
+
225
+ with torch.no_grad():
226
+ for batch in test_loader:
227
+ data, targets, contexts = _prepare_batch(batch, device)
228
+ outputs = model(data, contexts)
229
+ probs = torch.softmax(outputs, dim=1)
230
+ preds = torch.argmax(probs, dim=1)
231
+
232
+ all_targets.extend(targets.cpu().numpy())
233
+ all_preds.extend(preds.cpu().numpy())
234
+ all_prob.append(probs.cpu().numpy())
235
+
236
+ y_true = np.array(all_targets)
237
+ y_pred = np.array(all_preds)
238
+ y_prob = np.vstack(all_prob)
239
+
240
+ accuracy = accuracy_score(y_true, y_pred)
241
+ precision, recall, f1, _ = precision_recall_fscore_support(
242
+ y_true, y_pred, average="weighted", zero_division=0
243
+ )
244
+ report = classification_report(
245
+ y_true, y_pred, target_names=CLASS_NAMES, zero_division=0
246
+ )
247
+ cm = confusion_matrix(y_true, y_pred)
248
+ _save_outputs(pretty_name, report, cm, y_true, y_prob)
249
+
250
+ summary_rows.append(
251
+ {
252
+ "model": pretty_name,
253
+ "accuracy": accuracy,
254
+ "precision": precision,
255
+ "recall": recall,
256
+ "f1": f1,
257
+ }
258
+ )
259
+
260
+ print(
261
+ f"{pretty_name} -> accuracy {accuracy:.4f}, precision {precision:.4f}, recall {recall:.4f}, f1 {f1:.4f}"
262
+ )
263
+
264
+ if summary_rows:
265
+ summary_df = pd.DataFrame(summary_rows)
266
+ summary_df.to_csv(SUMMARY_PATH, index=False)
267
+ LATEST_JSON.write_text(json.dumps(summary_rows, indent=2))
268
+ print(f"Saved transformer summary to {SUMMARY_PATH}")
269
+ else:
270
+ print("No transformer metrics were generated; check checkpoints.")
271
+
272
+
273
+ if __name__ == "__main__":
274
+ main()
src/feature_mapping.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class FeatureMapper:
2
+ def __init__(self):
3
+ # Mapping between patient-facing questions and the trained inference schema.
4
+ self.feature_mapping = {
5
+ "basic_info": {
6
+ "age": {
7
+ "question": "What is your age?",
8
+ "type": "numeric",
9
+ "dataset_feature": "age",
10
+ },
11
+ "sex": {
12
+ "question": "What is your biological sex?",
13
+ "type": "categorical",
14
+ "options": ["Male", "Female"],
15
+ "dataset_feature": "SEX",
16
+ "mapping": {"Male": 1, "Female": 0},
17
+ },
18
+ "education": {
19
+ "question": "Years of education completed?",
20
+ "type": "numeric",
21
+ "dataset_feature": "EDUCYRS",
22
+ },
23
+ "race": {
24
+ "question": "What is your race?",
25
+ "type": "categorical",
26
+ "options": ["White", "Black/African American", "Asian", "Other"],
27
+ "dataset_feature": "race",
28
+ "mapping": {
29
+ "White": 1,
30
+ "Black/African American": 2,
31
+ "Asian": 3,
32
+ "Other": 4,
33
+ },
34
+ },
35
+ "bmi": {
36
+ "question": "What is your BMI?",
37
+ "type": "numeric",
38
+ "dataset_feature": "BMI",
39
+ },
40
+ },
41
+ "family_history": {
42
+ "family_pd": {
43
+ "question": "Do you have any family members with Parkinson's disease?",
44
+ "type": "categorical",
45
+ "options": [
46
+ "No family history",
47
+ "First degree relative",
48
+ "Other relative",
49
+ ],
50
+ "dataset_feature": "fampd",
51
+ "mapping": {
52
+ "No family history": 3,
53
+ "First degree relative": 1,
54
+ "Other relative": 2,
55
+ },
56
+ }
57
+ },
58
+ "motor_symptoms": {
59
+ "tremor": {
60
+ "question": "Tremor severity (0-4)",
61
+ "type": "numeric",
62
+ "dataset_feature": "sym_tremor",
63
+ "scale": "0-4",
64
+ },
65
+ "rigidity": {
66
+ "question": "Rigidity severity (0-4)",
67
+ "type": "numeric",
68
+ "dataset_feature": "sym_rigid",
69
+ "scale": "0-4",
70
+ },
71
+ "bradykinesia": {
72
+ "question": "Bradykinesia severity (0-4)",
73
+ "type": "numeric",
74
+ "dataset_feature": "sym_brady",
75
+ "scale": "0-4",
76
+ },
77
+ "balance": {
78
+ "question": "Postural instability severity (0-4)",
79
+ "type": "numeric",
80
+ "dataset_feature": "sym_posins",
81
+ "scale": "0-4",
82
+ },
83
+ },
84
+ "non_motor_symptoms": {
85
+ "rem_sleep": {
86
+ "question": "Do you act out dreams or have REM sleep behaviour symptoms?",
87
+ "type": "categorical",
88
+ "options": ["No", "Yes"],
89
+ "dataset_feature": "rem",
90
+ "mapping": {"No": 0, "Yes": 1},
91
+ },
92
+ "daytime_sleepiness": {
93
+ "question": "Epworth Sleepiness Scale score?",
94
+ "type": "numeric",
95
+ "dataset_feature": "ess",
96
+ "scale": "0-24",
97
+ },
98
+ "depression": {
99
+ "question": "Geriatric Depression Scale score?",
100
+ "type": "numeric",
101
+ "dataset_feature": "gds",
102
+ "scale": "0-15",
103
+ },
104
+ "anxiety": {
105
+ "question": "State-Trait Anxiety Inventory score?",
106
+ "type": "numeric",
107
+ "dataset_feature": "stai",
108
+ "scale": "20-80",
109
+ },
110
+ },
111
+ "cognitive_symptoms": {
112
+ "memory": {
113
+ "question": "MoCA score?",
114
+ "type": "numeric",
115
+ "dataset_feature": "moca",
116
+ "scale": "0-30",
117
+ },
118
+ "clock_draw": {
119
+ "question": "Clock drawing test score?",
120
+ "type": "numeric",
121
+ "dataset_feature": "clockdraw",
122
+ "scale": "0-4",
123
+ },
124
+ "bjlot": {
125
+ "question": "Benton line orientation score?",
126
+ "type": "numeric",
127
+ "dataset_feature": "bjlot",
128
+ "scale": "0-30",
129
+ },
130
+ },
131
+ }
132
+
133
+ def get_patient_questionnaire(self):
134
+ """Generate a list of questions for patients."""
135
+ questions = []
136
+ for category in self.feature_mapping.values():
137
+ for feature in category.values():
138
+ questions.append(
139
+ {
140
+ "question": feature["question"],
141
+ "type": feature["type"],
142
+ "options": feature.get("options"),
143
+ "scale": feature.get("scale"),
144
+ }
145
+ )
146
+ return questions
147
+
148
+ def map_patient_response_to_features(self, responses):
149
+ """Map patient responses to dataset features."""
150
+ feature_values = {}
151
+ for category in self.feature_mapping.values():
152
+ for feature_name, feature_info in category.items():
153
+ if feature_name not in responses:
154
+ continue
155
+
156
+ response = responses[feature_name]
157
+ dataset_feature = feature_info["dataset_feature"]
158
+
159
+ if "mapping" in feature_info:
160
+ mapped_value = feature_info["mapping"].get(response)
161
+ else:
162
+ mapped_value = response
163
+
164
+ if mapped_value is None:
165
+ continue
166
+
167
+ feature_values[dataset_feature] = mapped_value
168
+
169
+ if dataset_feature == "fampd":
170
+ feature_values["fampd_bin"] = 2 if mapped_value == 3 else 1
171
+
172
+ return feature_values
173
+
174
+
175
+ def main():
176
+ mapper = FeatureMapper()
177
+ questions = mapper.get_patient_questionnaire()
178
+
179
+ print("Patient Questionnaire:")
180
+ for i, q in enumerate(questions, 1):
181
+ print(f"\n{i}. {q['question']}")
182
+ if q["options"]:
183
+ print(f"Options: {', '.join(q['options'])}")
184
+ if q["scale"]:
185
+ print(f"Scale: {q['scale']}")
186
+
187
+
188
+ if __name__ == "__main__":
189
+ main()
src/models/__init__.py ADDED
File without changes
src/models/medical_transformers.py ADDED
@@ -0,0 +1,939 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Medical Transformer Models for Parkinson's Disease Classification
3
+ Implements three specialized medical transformers:
4
+ 1. PubMedBERT - Encoder-only model
5
+ 2. BioMistral-7B - Decoder-only model
6
+ 3. Clinical-T5 - Encoder-Decoder model
7
+
8
+ Optimized for GPU training with CUDA support.
9
+ """
10
+
11
+ import torch
12
+ import torch.nn as nn
13
+ import torch.nn.functional as F
14
+ from torch.utils.data import Dataset, DataLoader
15
+ import numpy as np
16
+ from sklearn.metrics import accuracy_score, classification_report, confusion_matrix, roc_auc_score, f1_score
17
+ from sklearn.model_selection import StratifiedKFold
18
+ from sklearn.preprocessing import label_binarize
19
+ import joblib
20
+ import os
21
+ from typing import Tuple, Dict, Any, List, Optional
22
+ import matplotlib.pyplot as plt
23
+ import seaborn as sns
24
+ from transformers import (
25
+ AutoModel,
26
+ AutoTokenizer,
27
+ AutoModelForCausalLM,
28
+ T5ForConditionalGeneration,
29
+ T5Tokenizer,
30
+ BitsAndBytesConfig
31
+ )
32
+ from datetime import datetime
33
+ import warnings
34
+ warnings.filterwarnings('ignore')
35
+
36
+
37
+ class MedicalTabularDataset(Dataset):
38
+ """Enhanced dataset for tabular medical data with feature descriptions."""
39
+
40
+ def __init__(self, X, y, feature_names=None):
41
+ self.X = torch.FloatTensor(X)
42
+ self.y = torch.LongTensor(y)
43
+ self.feature_names = feature_names or [f"feature_{i}" for i in range(X.shape[1])]
44
+
45
+ # Feature descriptions for medical context
46
+ self.feature_descriptions = {
47
+ 'age': 'patient age in years',
48
+ 'SEX': 'biological sex (0=Female, 1=Male)',
49
+ 'EDUCYRS': 'years of education',
50
+ 'BMI': 'body mass index',
51
+ 'fampd': 'family history of Parkinsons disease',
52
+ 'sym_tremor': 'tremor severity score',
53
+ 'sym_rigid': 'rigidity severity score',
54
+ 'sym_brady': 'bradykinesia severity score',
55
+ 'sym_posins': 'postural instability score',
56
+ 'moca': 'Montreal Cognitive Assessment score',
57
+ 'gds': 'Geriatric Depression Scale score',
58
+ 'stai': 'State-Trait Anxiety Inventory score',
59
+ 'rem': 'REM sleep behavior disorder check',
60
+ 'ess': 'Epworth Sleepiness Scale',
61
+ 'upsit': 'University of Pennsylvania Smell Identification Test score',
62
+ 'upsit_pctl': 'smell identification percentile',
63
+ 'updrs1_score': 'UPDRS Part I (non-motor experiences of daily living)',
64
+ 'updrs2_score': 'UPDRS Part II (motor experiences of daily living)',
65
+ 'updrs3_score': 'UPDRS Part III (motor examination)',
66
+ 'updrs4_score': 'UPDRS Part IV (motor complications)',
67
+ 'updrs_totscore': 'Total UPDRS score',
68
+ 'mean_caudate': 'DatScan mean caudate uptake ratio',
69
+ 'mean_putamen': 'DatScan mean putamen uptake ratio',
70
+ 'abeta': 'CSF Amyloid beta 1-42 level',
71
+ 'tau': 'CSF Total Tau level',
72
+ 'ptau': 'CSF Phosphorylated Tau level'
73
+ }
74
+
75
+ def __len__(self):
76
+ return len(self.X)
77
+
78
+ def __getitem__(self, idx):
79
+ return self.X[idx], self.y[idx]
80
+
81
+ def get_text_description(self, idx):
82
+ """Convert features to medical text description."""
83
+ sample = self.X[idx].numpy()
84
+ descriptions = []
85
+
86
+ for i, (name, val) in enumerate(zip(self.feature_names, sample)):
87
+ base_name = name.split('_')[0] if '_' in name else name
88
+ desc = self.feature_descriptions.get(base_name, f"{name}")
89
+ descriptions.append(f"{desc}: {val:.2f}")
90
+
91
+ return "Patient clinical data: " + ", ".join(descriptions)
92
+
93
+
94
+ class PubMedBERTClassifier(nn.Module):
95
+ """
96
+ PubMedBERT - Encoder-only transformer model
97
+ Pretrained on PubMed abstracts, optimized for medical text understanding
98
+ """
99
+
100
+ def __init__(self, input_dim: int, num_classes: int, dropout: float = 0.1,
101
+ freeze_bert: bool = True, train_encoder_layers: int = 4):
102
+ super(PubMedBERTClassifier, self).__init__()
103
+
104
+ self.model_name = "microsoft/BiomedNLP-PubMedBERT-base-uncased-abstract-fulltext"
105
+ print(f"Loading PubMedBERT from {self.model_name}")
106
+
107
+ # Load pretrained model
108
+ self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
109
+ self.bert = AutoModel.from_pretrained(self.model_name)
110
+
111
+ # Optionally freeze BERT parameters
112
+ if freeze_bert:
113
+ for param in self.bert.parameters():
114
+ param.requires_grad = False
115
+ print("PubMedBERT parameters frozen")
116
+ else:
117
+ # Freeze everything first, then unfreeze the last encoder blocks.
118
+ for param in self.bert.parameters():
119
+ param.requires_grad = False
120
+ encoder_layers = getattr(self.bert.encoder, "layer", [])
121
+ layers_to_unfreeze = min(int(train_encoder_layers), len(encoder_layers))
122
+ for layer in encoder_layers[-layers_to_unfreeze:]:
123
+ for param in layer.parameters():
124
+ param.requires_grad = True
125
+ pooler = getattr(self.bert, "pooler", None)
126
+ if pooler is not None:
127
+ for param in pooler.parameters():
128
+ param.requires_grad = True
129
+ trainable = sum(p.numel() for p in self.bert.parameters() if p.requires_grad)
130
+ total = sum(p.numel() for p in self.bert.parameters())
131
+ print(
132
+ f"PubMedBERT last {layers_to_unfreeze} encoder layers unfrozen "
133
+ f"({trainable:,}/{total:,} backbone params trainable)"
134
+ )
135
+
136
+ self.hidden_size = self.bert.config.hidden_size # 768 for base model
137
+
138
+ # Feature projection layer
139
+ self.feature_projection = nn.Sequential(
140
+ nn.Linear(input_dim, 384),
141
+ nn.LayerNorm(384),
142
+ nn.ReLU(),
143
+ nn.Dropout(dropout),
144
+ nn.Linear(384, self.hidden_size)
145
+ )
146
+
147
+ # Multi-head attention for feature fusion
148
+ self.attention = nn.MultiheadAttention(
149
+ embed_dim=self.hidden_size,
150
+ num_heads=8,
151
+ dropout=dropout,
152
+ batch_first=True
153
+ )
154
+
155
+ # Classification head with residual connection
156
+ self.classifier = nn.Sequential(
157
+ nn.Linear(self.hidden_size * 2, 512),
158
+ nn.LayerNorm(512),
159
+ nn.ReLU(),
160
+ nn.Dropout(dropout),
161
+ nn.Linear(512, 256),
162
+ nn.LayerNorm(256),
163
+ nn.ReLU(),
164
+ nn.Dropout(dropout),
165
+ nn.Linear(256, num_classes)
166
+ )
167
+
168
+ def forward(self, x, text_input=None):
169
+ batch_size = x.size(0)
170
+
171
+ # Project tabular features
172
+ tabular_features = self.feature_projection(x) # [batch, 768]
173
+ tabular_features = tabular_features.unsqueeze(1) # [batch, 1, 768]
174
+
175
+ if text_input is not None:
176
+ # Process text with BERT
177
+ if isinstance(text_input, dict):
178
+ inputs = {k: v.to(x.device) for k, v in text_input.items()}
179
+ else:
180
+ inputs = self.tokenizer(
181
+ text_input,
182
+ return_tensors="pt",
183
+ padding=True,
184
+ truncation=True,
185
+ max_length=512
186
+ )
187
+ inputs = {k: v.to(x.device) for k, v in inputs.items()}
188
+
189
+ # Get BERT embeddings
190
+ outputs = self.bert(**inputs)
191
+ text_features = outputs.last_hidden_state # [batch, seq_len, 768]
192
+
193
+ # Concatenate features
194
+ combined = torch.cat([tabular_features, text_features], dim=1)
195
+
196
+ # Apply attention
197
+ attended, _ = self.attention(combined, combined, combined)
198
+ pooled = attended.mean(dim=1) # Average pooling
199
+ else:
200
+ # Use only tabular features
201
+ pooled = tabular_features.squeeze(1)
202
+
203
+ # Combine original and attended features
204
+ combined_features = torch.cat([pooled, tabular_features.squeeze(1)], dim=1)
205
+
206
+ # Classification
207
+ output = self.classifier(combined_features)
208
+
209
+ return output
210
+
211
+
212
+ class BioMistralClassifier(nn.Module):
213
+ """BioGPT (decoder-only) hybrid with optional partial fine-tuning."""
214
+
215
+ def __init__(
216
+ self,
217
+ input_dim: int,
218
+ num_classes: int,
219
+ dropout: float = 0.1,
220
+ use_quantization: bool = False,
221
+ train_decoder_layers: int = 4
222
+ ):
223
+ super(BioMistralClassifier, self).__init__()
224
+
225
+ # Use a smaller medical language model that's publicly available
226
+ # BioGPT or similar decoder-only model
227
+ self.model_name = "microsoft/biogpt"
228
+ print(f"Loading BioGPT (Decoder-only) from {self.model_name}")
229
+
230
+ try:
231
+ try:
232
+ import sacremoses # type: ignore # noqa: F401
233
+ except ImportError as exc:
234
+ raise RuntimeError(
235
+ "BioGPT requires the 'sacremoses' package for tokenization. "
236
+ "Install it with 'pip install sacremoses' or reinstall from requirements.txt."
237
+ ) from exc
238
+
239
+ # Load without quantization and device_map to avoid accelerate dependency
240
+ # Quantization disabled by default to avoid bitsandbytes and accelerate issues
241
+ self.model = AutoModelForCausalLM.from_pretrained(
242
+ self.model_name,
243
+ trust_remote_code=True,
244
+ torch_dtype=torch.float32 # Use standard precision
245
+ )
246
+ print("Model loaded successfully (no quantization)")
247
+
248
+ self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
249
+ if self.tokenizer.pad_token is None:
250
+ self.tokenizer.pad_token = self.tokenizer.eos_token
251
+
252
+ # Move model to device
253
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
254
+ self.model = self.model.to(device)
255
+
256
+ # Freeze decoder parameters (we may selectively unfreeze later)
257
+ for param in self.model.parameters():
258
+ param.requires_grad = False
259
+
260
+ self.hidden_size = self.model.config.hidden_size
261
+
262
+ except Exception as e:
263
+ raise RuntimeError(
264
+ "BioGPT failed to load. This training path now requires the real "
265
+ f"BioGPT model and will not fall back to GPT2. Original error: {e}"
266
+ ) from e
267
+
268
+ self._unfreeze_decoder_layers(train_decoder_layers)
269
+
270
+ # Feature encoder (larger capacity)
271
+ self.feature_encoder = nn.Sequential(
272
+ nn.Linear(input_dim, 512),
273
+ nn.LayerNorm(512),
274
+ nn.GELU(),
275
+ nn.Dropout(dropout),
276
+ nn.Linear(512, self.hidden_size),
277
+ nn.GELU()
278
+ )
279
+
280
+ # Cross-attention layer (restore 8 heads)
281
+ self.cross_attention = nn.MultiheadAttention(
282
+ embed_dim=self.hidden_size,
283
+ num_heads=8,
284
+ dropout=dropout,
285
+ batch_first=True
286
+ )
287
+
288
+ # Classification head with more width/depth
289
+ self.classifier = nn.Sequential(
290
+ nn.Linear(self.hidden_size, 1024),
291
+ nn.LayerNorm(1024),
292
+ nn.GELU(),
293
+ nn.Dropout(dropout),
294
+ nn.Linear(1024, 512),
295
+ nn.LayerNorm(512),
296
+ nn.GELU(),
297
+ nn.Dropout(dropout),
298
+ nn.Linear(512, 256),
299
+ nn.GELU(),
300
+ nn.Dropout(dropout / 2 if dropout > 0 else 0.0),
301
+ nn.Linear(256, num_classes)
302
+ )
303
+
304
+ def forward(self, x, text_input=None):
305
+ # Encode tabular features
306
+ feature_embeddings = self.feature_encoder(x) # [batch, hidden_size]
307
+ feature_embeddings = feature_embeddings.unsqueeze(1) # [batch, 1, hidden_size]
308
+
309
+ if text_input is not None:
310
+ # Tokenize text
311
+ if isinstance(text_input, dict):
312
+ inputs = {k: v.to(x.device) for k, v in text_input.items()}
313
+ else:
314
+ inputs = self.tokenizer(
315
+ text_input,
316
+ return_tensors="pt",
317
+ padding=True,
318
+ truncation=True,
319
+ max_length=512
320
+ )
321
+ inputs = {k: v.to(x.device) for k, v in inputs.items()}
322
+
323
+ # Get decoder outputs (no torch.no_grad — allow gradients for unfrozen layers)
324
+ outputs = self.model(**inputs, output_hidden_states=True)
325
+ hidden_states = outputs.hidden_states[-1] # Last layer
326
+
327
+ # Cross-attention between features and text
328
+ attended, _ = self.cross_attention(
329
+ feature_embeddings,
330
+ hidden_states,
331
+ hidden_states
332
+ )
333
+ pooled = attended.squeeze(1)
334
+ else:
335
+ pooled = feature_embeddings.squeeze(1)
336
+
337
+ # Classification
338
+ output = self.classifier(pooled)
339
+
340
+ return output
341
+
342
+ def _unfreeze_decoder_layers(self, num_layers: int):
343
+ """Optionally unfreeze the last decoder layers to grow capacity."""
344
+ if num_layers <= 0:
345
+ print("BioGPT remains fully frozen (train_decoder_layers=0)")
346
+ return
347
+
348
+ # Debug: print model structure to diagnose layer discovery
349
+ print(f" [DEBUG] Model type: {type(self.model).__name__}")
350
+ print(f" [DEBUG] Top-level children: {[n for n, _ in self.model.named_children()]}")
351
+
352
+ layers = None
353
+ layernorm = None
354
+ head = None
355
+
356
+ # Strategy 1: Try known attribute paths
357
+ if hasattr(self.model, 'biogpt'):
358
+ layers = getattr(self.model.biogpt, 'layers', None)
359
+ layernorm = getattr(self.model.biogpt, 'layer_norm', None)
360
+ head = getattr(self.model, 'output_projection', None)
361
+ elif hasattr(self.model, 'transformer'):
362
+ layers = getattr(self.model.transformer, 'h', None)
363
+ layernorm = getattr(self.model.transformer, 'ln_f', None)
364
+ head = getattr(self.model, 'lm_head', None)
365
+
366
+ # Strategy 2: Search all named modules for a large ModuleList (transformer layers)
367
+ if layers is None:
368
+ print(" [DEBUG] Known paths failed, searching named_modules...")
369
+ for name, module in self.model.named_modules():
370
+ if isinstance(module, nn.ModuleList) and len(module) > 6:
371
+ layers = module
372
+ print(f" [DEBUG] Found transformer layers at '{name}' ({len(module)} layers)")
373
+ break
374
+
375
+ # Strategy 3: Search for layernorm and head if not found yet
376
+ if layers is not None and layernorm is None:
377
+ for name, module in self.model.named_modules():
378
+ if ('layer_norm' in name or 'ln_f' in name) and not any(c in name for c in ['.0.', '.1.', '.2.']):
379
+ layernorm = module
380
+ break
381
+ if layers is not None and head is None:
382
+ for name, module in self.model.named_modules():
383
+ if 'output_projection' in name or 'lm_head' in name:
384
+ head = module
385
+ break
386
+
387
+ if layers is None:
388
+ print("Warning: could not locate transformer layers to unfreeze")
389
+ print(" All named modules:")
390
+ for name, module in self.model.named_children():
391
+ print(f" {name}: {type(module).__name__}")
392
+ return
393
+
394
+ total_layers = len(layers)
395
+ num_layers = min(num_layers, total_layers)
396
+
397
+ print(f"Unfreezing last {num_layers}/{total_layers} layers of BioGPT...")
398
+
399
+ # Unfreeze last N layers
400
+ for layer in layers[-num_layers:]:
401
+ for param in layer.parameters():
402
+ param.requires_grad = True
403
+
404
+ # Unfreeze final layer norm
405
+ if layernorm is not None:
406
+ for param in layernorm.parameters():
407
+ param.requires_grad = True
408
+
409
+ # Unfreeze head
410
+ if head is not None:
411
+ for param in head.parameters():
412
+ param.requires_grad = True
413
+
414
+ trainable = sum(p.numel() for p in self.model.parameters() if p.requires_grad)
415
+ total = sum(p.numel() for p in self.model.parameters())
416
+ print(f"Unfroze last {num_layers} decoder layers plus norm/head for BioGPT")
417
+ print(f" Backbone trainable: {trainable:,}/{total:,} ({100*trainable/total:.1f}%)")
418
+
419
+
420
+ class ClinicalT5Classifier(nn.Module):
421
+ """
422
+ Clinical-T5 - Encoder-Decoder transformer model
423
+ T5 model fine-tuned on clinical notes and medical literature
424
+ """
425
+
426
+ def __init__(self, input_dim: int, num_classes: int, dropout: float = 0.1,
427
+ freeze_encoder: bool = True):
428
+ super(ClinicalT5Classifier, self).__init__()
429
+
430
+ # Use T5 or Flan-T5 as base model
431
+ self.model_name = "google/flan-t5-base"
432
+ print(f"Loading Clinical T5 (Encoder-Decoder) from {self.model_name}")
433
+
434
+ self.tokenizer = T5Tokenizer.from_pretrained(self.model_name)
435
+ self.t5 = T5ForConditionalGeneration.from_pretrained(self.model_name)
436
+
437
+ # Freeze encoder/decoder parameters
438
+ if freeze_encoder:
439
+ for param in self.t5.encoder.parameters():
440
+ param.requires_grad = False
441
+ for param in self.t5.decoder.parameters():
442
+ param.requires_grad = False
443
+ print("T5 encoder and decoder frozen")
444
+ else:
445
+ # Freeze all first, then selectively unfreeze last blocks
446
+ for param in self.t5.parameters():
447
+ param.requires_grad = False
448
+ # Unfreeze last 4 encoder blocks
449
+ for block in self.t5.encoder.block[-4:]:
450
+ for param in block.parameters():
451
+ param.requires_grad = True
452
+ # Unfreeze last 2 decoder blocks
453
+ for block in self.t5.decoder.block[-2:]:
454
+ for param in block.parameters():
455
+ param.requires_grad = True
456
+ # Unfreeze final layer norms
457
+ if hasattr(self.t5.encoder, 'final_layer_norm'):
458
+ for param in self.t5.encoder.final_layer_norm.parameters():
459
+ param.requires_grad = True
460
+ if hasattr(self.t5.decoder, 'final_layer_norm'):
461
+ for param in self.t5.decoder.final_layer_norm.parameters():
462
+ param.requires_grad = True
463
+ trainable = sum(p.numel() for p in self.t5.parameters() if p.requires_grad)
464
+ total = sum(p.numel() for p in self.t5.parameters())
465
+ print(f"T5 partially unfrozen: {trainable:,}/{total:,} params trainable ({100*trainable/total:.1f}%)")
466
+
467
+ self.hidden_size = self.t5.config.d_model # 768 for base
468
+
469
+ # Feature projection
470
+ self.feature_projection = nn.Sequential(
471
+ nn.Linear(input_dim, 384),
472
+ nn.LayerNorm(384),
473
+ nn.ReLU(),
474
+ nn.Dropout(dropout),
475
+ nn.Linear(384, self.hidden_size)
476
+ )
477
+
478
+ # Encoder-decoder attention mechanism
479
+ self.encoder_decoder_attention = nn.MultiheadAttention(
480
+ embed_dim=self.hidden_size,
481
+ num_heads=8,
482
+ dropout=dropout,
483
+ batch_first=True
484
+ )
485
+
486
+ # Classification head
487
+ self.classifier = nn.Sequential(
488
+ nn.Linear(self.hidden_size * 2, 512),
489
+ nn.LayerNorm(512),
490
+ nn.ReLU(),
491
+ nn.Dropout(dropout),
492
+ nn.Linear(512, 256),
493
+ nn.LayerNorm(256),
494
+ nn.ReLU(),
495
+ nn.Dropout(dropout),
496
+ nn.Linear(256, num_classes)
497
+ )
498
+
499
+ def forward(self, x, text_input=None):
500
+ # Project features
501
+ feature_embeddings = self.feature_projection(x)
502
+ feature_embeddings = feature_embeddings.unsqueeze(1)
503
+
504
+ if text_input is not None:
505
+ if isinstance(text_input, dict):
506
+ inputs = {k: v.to(x.device) for k, v in text_input.items()}
507
+ else:
508
+ # Prepare input text
509
+ input_texts = [f"classify patient: {text}" for text in text_input]
510
+
511
+ # Tokenize
512
+ inputs = self.tokenizer(
513
+ input_texts,
514
+ return_tensors="pt",
515
+ padding=True,
516
+ truncation=True,
517
+ max_length=512
518
+ )
519
+ inputs = {k: v.to(x.device) for k, v in inputs.items()}
520
+
521
+ # Create dummy decoder inputs
522
+ decoder_input_ids = torch.zeros(
523
+ (inputs['input_ids'].shape[0], 1),
524
+ dtype=torch.long,
525
+ device=x.device
526
+ )
527
+
528
+ # Get encoder and decoder outputs (allow gradients for unfrozen layers)
529
+ encoder_outputs = self.t5.encoder(**inputs)
530
+ decoder_outputs = self.t5.decoder(
531
+ input_ids=decoder_input_ids,
532
+ encoder_hidden_states=encoder_outputs.last_hidden_state
533
+ )
534
+
535
+ encoder_hidden = encoder_outputs.last_hidden_state
536
+ decoder_hidden = decoder_outputs.last_hidden_state
537
+
538
+ # Combine encoder and decoder information
539
+ combined = torch.cat([encoder_hidden, decoder_hidden], dim=1)
540
+
541
+ # Attend to features
542
+ attended, _ = self.encoder_decoder_attention(
543
+ feature_embeddings,
544
+ combined,
545
+ combined
546
+ )
547
+
548
+ pooled = attended.squeeze(1)
549
+ else:
550
+ pooled = feature_embeddings.squeeze(1)
551
+
552
+ # Combine with original features
553
+ combined_features = torch.cat([pooled, feature_embeddings.squeeze(1)], dim=1)
554
+
555
+ # Classification
556
+ output = self.classifier(combined_features)
557
+
558
+ return output
559
+
560
+
561
+ class MedicalTransformerTrainer:
562
+ """
563
+ Trainer class for medical transformer models with GPU optimization
564
+ """
565
+
566
+ def __init__(self, device=None):
567
+ if device is None:
568
+ self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
569
+ else:
570
+ self.device = torch.device(device)
571
+
572
+ print(f"Using device: {self.device}")
573
+ if self.device.type == 'cuda':
574
+ print(f"GPU: {torch.cuda.get_device_name(0)}")
575
+ print(f"GPU Memory: {torch.cuda.get_device_properties(0).total_memory / 1e9:.2f} GB")
576
+
577
+ self.models = {}
578
+ self.training_histories = {}
579
+ self.evaluation_results = {}
580
+
581
+ def create_model(self, model_type: str, input_dim: int, num_classes: int, **kwargs):
582
+ """Create a medical transformer model."""
583
+ if model_type == 'pubmedbert':
584
+ model = PubMedBERTClassifier(input_dim, num_classes, **kwargs)
585
+ elif model_type == 'biomistral':
586
+ model = BioMistralClassifier(input_dim, num_classes, **kwargs)
587
+ elif model_type == 'clinical_t5':
588
+ model = ClinicalT5Classifier(input_dim, num_classes, **kwargs)
589
+ else:
590
+ raise ValueError(f"Unknown model type: {model_type}")
591
+
592
+ return model.to(self.device)
593
+
594
+ def train_model(self, model, train_loader, val_loader, model_name: str,
595
+ epochs: int = 50, lr: float = 0.001, weight_decay: float = 1e-4,
596
+ class_weights: torch.Tensor = None, use_amp: bool = True):
597
+ """
598
+ Train model with mixed precision and gradient accumulation for GPU efficiency
599
+ """
600
+ print(f"\nTraining {model_name}")
601
+ print("=" * 70)
602
+
603
+ # Loss function
604
+ if class_weights is not None:
605
+ criterion = nn.CrossEntropyLoss(weight=class_weights.to(self.device))
606
+ else:
607
+ criterion = nn.CrossEntropyLoss()
608
+
609
+ # Optimizer
610
+ optimizer = torch.optim.AdamW(
611
+ filter(lambda p: p.requires_grad, model.parameters()),
612
+ lr=lr,
613
+ weight_decay=weight_decay
614
+ )
615
+
616
+ # Learning rate scheduler
617
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingWarmRestarts(
618
+ optimizer,
619
+ T_0=10,
620
+ T_mult=2,
621
+ eta_min=1e-6
622
+ )
623
+
624
+ # Mixed precision training
625
+ scaler = torch.cuda.amp.GradScaler() if use_amp and self.device.type == 'cuda' else None
626
+
627
+ # Training history
628
+ history = {
629
+ 'train_loss': [],
630
+ 'val_loss': [],
631
+ 'val_accuracy': [],
632
+ 'val_f1': [],
633
+ 'learning_rates': []
634
+ }
635
+
636
+ best_val_loss = float('inf')
637
+ best_val_acc = 0.0
638
+ patience_counter = 0
639
+ early_stopping_patience = 15
640
+
641
+ for epoch in range(epochs):
642
+ # Training phase
643
+ model.train()
644
+ train_loss = 0.0
645
+ train_steps = 0
646
+
647
+ for batch_x, batch_y in train_loader:
648
+ batch_x = batch_x.to(self.device)
649
+ batch_y = batch_y.to(self.device)
650
+
651
+ optimizer.zero_grad()
652
+
653
+ # Mixed precision forward pass
654
+ if scaler is not None:
655
+ with torch.cuda.amp.autocast():
656
+ outputs = model(batch_x)
657
+ loss = criterion(outputs, batch_y)
658
+
659
+ scaler.scale(loss).backward()
660
+ scaler.step(optimizer)
661
+ scaler.update()
662
+ else:
663
+ outputs = model(batch_x)
664
+ loss = criterion(outputs, batch_y)
665
+ loss.backward()
666
+ optimizer.step()
667
+
668
+ train_loss += loss.item()
669
+ train_steps += 1
670
+
671
+ # Validation phase
672
+ model.eval()
673
+ val_loss = 0.0
674
+ all_preds = []
675
+ all_targets = []
676
+
677
+ with torch.no_grad():
678
+ for batch_x, batch_y in val_loader:
679
+ batch_x = batch_x.to(self.device)
680
+ batch_y = batch_y.to(self.device)
681
+
682
+ if scaler is not None:
683
+ with torch.cuda.amp.autocast():
684
+ outputs = model(batch_x)
685
+ loss = criterion(outputs, batch_y)
686
+ else:
687
+ outputs = model(batch_x)
688
+ loss = criterion(outputs, batch_y)
689
+
690
+ val_loss += loss.item()
691
+
692
+ _, predicted = torch.max(outputs, 1)
693
+ all_preds.extend(predicted.cpu().numpy())
694
+ all_targets.extend(batch_y.cpu().numpy())
695
+
696
+ # Calculate metrics
697
+ train_loss /= train_steps
698
+ val_loss /= len(val_loader)
699
+ val_accuracy = accuracy_score(all_targets, all_preds)
700
+ val_f1 = f1_score(all_targets, all_preds, average='weighted')
701
+
702
+ # Update history
703
+ history['train_loss'].append(train_loss)
704
+ history['val_loss'].append(val_loss)
705
+ history['val_accuracy'].append(val_accuracy)
706
+ history['val_f1'].append(val_f1)
707
+ history['learning_rates'].append(optimizer.param_groups[0]['lr'])
708
+
709
+ # Step scheduler
710
+ scheduler.step()
711
+
712
+ # Print progress
713
+ if epoch % 5 == 0 or epoch == epochs - 1:
714
+ print(f"Epoch [{epoch+1}/{epochs}]")
715
+ print(f" Train Loss: {train_loss:.4f}")
716
+ print(f" Val Loss: {val_loss:.4f}")
717
+ print(f" Val Accuracy: {val_accuracy:.4f}")
718
+ print(f" Val F1: {val_f1:.4f}")
719
+ print(f" LR: {optimizer.param_groups[0]['lr']:.6f}")
720
+
721
+ # Early stopping
722
+ if val_loss < best_val_loss:
723
+ best_val_loss = val_loss
724
+ best_val_acc = val_accuracy
725
+ patience_counter = 0
726
+ # Save best model
727
+ self.save_model(model, f"{model_name}_best")
728
+ else:
729
+ patience_counter += 1
730
+ if patience_counter >= early_stopping_patience:
731
+ print(f"\nEarly stopping at epoch {epoch+1}")
732
+ print(f"Best validation loss: {best_val_loss:.4f}")
733
+ print(f"Best validation accuracy: {best_val_acc:.4f}")
734
+ break
735
+
736
+ self.training_histories[model_name] = history
737
+ return history
738
+
739
+ def evaluate_model(self, model, test_loader, model_name: str):
740
+ """Comprehensive model evaluation."""
741
+ print(f"\nEvaluating {model_name}")
742
+ print("=" * 70)
743
+
744
+ model.eval()
745
+ all_preds = []
746
+ all_probs = []
747
+ all_targets = []
748
+
749
+ with torch.no_grad():
750
+ for batch_x, batch_y in test_loader:
751
+ batch_x = batch_x.to(self.device)
752
+ batch_y = batch_y.to(self.device)
753
+
754
+ outputs = model(batch_x)
755
+ probs = F.softmax(outputs, dim=1)
756
+ _, predicted = torch.max(outputs, 1)
757
+
758
+ all_preds.extend(predicted.cpu().numpy())
759
+ all_probs.extend(probs.cpu().numpy())
760
+ all_targets.extend(batch_y.cpu().numpy())
761
+
762
+ all_preds = np.array(all_preds)
763
+ all_probs = np.array(all_probs)
764
+ all_targets = np.array(all_targets)
765
+
766
+ # Calculate metrics
767
+ accuracy = accuracy_score(all_targets, all_preds)
768
+ f1 = f1_score(all_targets, all_preds, average='weighted')
769
+
770
+ # ROC-AUC for multiclass
771
+ try:
772
+ y_bin = label_binarize(all_targets, classes=range(len(np.unique(all_targets))))
773
+ roc_auc = roc_auc_score(y_bin, all_probs, average='weighted', multi_class='ovr')
774
+ except:
775
+ roc_auc = 0.0
776
+
777
+ cm = confusion_matrix(all_targets, all_preds)
778
+ report = classification_report(all_targets, all_preds)
779
+
780
+ results = {
781
+ 'accuracy': accuracy,
782
+ 'f1_score': f1,
783
+ 'roc_auc': roc_auc,
784
+ 'confusion_matrix': cm,
785
+ 'classification_report': report,
786
+ 'predictions': all_preds,
787
+ 'probabilities': all_probs,
788
+ 'targets': all_targets
789
+ }
790
+
791
+ print(f"\nResults for {model_name}:")
792
+ print(f" Accuracy: {accuracy:.4f}")
793
+ print(f" F1 Score: {f1:.4f}")
794
+ print(f" ROC-AUC: {roc_auc:.4f}")
795
+ print(f"\nClassification Report:\n{report}")
796
+
797
+ self.evaluation_results[model_name] = results
798
+ return results
799
+
800
+ def save_model(self, model, model_name: str, save_dir: str = "./models/saved"):
801
+ """Save model with error handling."""
802
+ try:
803
+ # Use absolute path
804
+ if not os.path.isabs(save_dir):
805
+ save_dir = os.path.abspath(save_dir)
806
+ os.makedirs(save_dir, exist_ok=True)
807
+ model_path = os.path.join(save_dir, f"{model_name}.pth")
808
+ torch.save(model.state_dict(), model_path)
809
+ print(f"[OK] Model saved to {model_path}")
810
+ except Exception as e:
811
+ print(f"[ERR] Error saving model: {e}")
812
+ import traceback
813
+ traceback.print_exc()
814
+
815
+ def load_model(self, model_type: str, model_name: str, input_dim: int,
816
+ num_classes: int, save_dir: str = "./models/saved", **kwargs):
817
+ """Load saved model."""
818
+ # Use absolute path
819
+ if not os.path.isabs(save_dir):
820
+ save_dir = os.path.abspath(save_dir)
821
+ model = self.create_model(model_type, input_dim, num_classes, **kwargs)
822
+ model_path = os.path.join(save_dir, f"{model_name}.pth")
823
+ model.load_state_dict(torch.load(model_path, map_location=self.device))
824
+ model.eval()
825
+ return model
826
+
827
+ def plot_training_curves(self, model_name: str, save_dir: str = "./notebooks"):
828
+ """Plot training curves."""
829
+ if model_name not in self.training_histories:
830
+ print(f"No training history found for {model_name}")
831
+ return
832
+
833
+ # Use absolute path
834
+ if not os.path.isabs(save_dir):
835
+ save_dir = os.path.abspath(save_dir)
836
+
837
+ history = self.training_histories[model_name]
838
+
839
+ fig, axes = plt.subplots(2, 2, figsize=(15, 12))
840
+ fig.suptitle(f'{model_name} - Training History', fontsize=16, fontweight='bold')
841
+
842
+ # Loss curves
843
+ axes[0, 0].plot(history['train_loss'], label='Train Loss', linewidth=2)
844
+ axes[0, 0].plot(history['val_loss'], label='Val Loss', linewidth=2)
845
+ axes[0, 0].set_title('Loss Curves')
846
+ axes[0, 0].set_xlabel('Epoch')
847
+ axes[0, 0].set_ylabel('Loss')
848
+ axes[0, 0].legend()
849
+ axes[0, 0].grid(True, alpha=0.3)
850
+
851
+ # Accuracy curve
852
+ axes[0, 1].plot(history['val_accuracy'], label='Val Accuracy',
853
+ color='green', linewidth=2)
854
+ axes[0, 1].set_title('Validation Accuracy')
855
+ axes[0, 1].set_xlabel('Epoch')
856
+ axes[0, 1].set_ylabel('Accuracy')
857
+ axes[0, 1].legend()
858
+ axes[0, 1].grid(True, alpha=0.3)
859
+
860
+ # F1 Score curve
861
+ axes[1, 0].plot(history['val_f1'], label='Val F1 Score',
862
+ color='orange', linewidth=2)
863
+ axes[1, 0].set_title('Validation F1 Score')
864
+ axes[1, 0].set_xlabel('Epoch')
865
+ axes[1, 0].set_ylabel('F1 Score')
866
+ axes[1, 0].legend()
867
+ axes[1, 0].grid(True, alpha=0.3)
868
+
869
+ # Learning rate
870
+ axes[1, 1].plot(history['learning_rates'], label='Learning Rate',
871
+ color='red', linewidth=2)
872
+ axes[1, 1].set_title('Learning Rate Schedule')
873
+ axes[1, 1].set_xlabel('Epoch')
874
+ axes[1, 1].set_ylabel('Learning Rate')
875
+ axes[1, 1].set_yscale('log')
876
+ axes[1, 1].legend()
877
+ axes[1, 1].grid(True, alpha=0.3)
878
+
879
+ plt.tight_layout()
880
+ os.makedirs(save_dir, exist_ok=True)
881
+ plt.savefig(os.path.join(save_dir, f'{model_name}_training_curves.png'),
882
+ dpi=300, bbox_inches='tight')
883
+ plt.close()
884
+ print(f"Training curves saved for {model_name}")
885
+
886
+ def plot_confusion_matrix(self, model_name: str, class_names: List[str] = None,
887
+ save_dir: str = "./notebooks"):
888
+ """Plot confusion matrix."""
889
+ if model_name not in self.evaluation_results:
890
+ print(f"No evaluation results found for {model_name}")
891
+ return
892
+
893
+ # Use absolute path
894
+ if not os.path.isabs(save_dir):
895
+ save_dir = os.path.abspath(save_dir)
896
+
897
+ cm = self.evaluation_results[model_name]['confusion_matrix']
898
+
899
+ if class_names is None:
900
+ class_names = ['HC', 'PD', 'SWEDD', 'PRODROMAL']
901
+
902
+ plt.figure(figsize=(10, 8))
903
+ sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
904
+ xticklabels=class_names, yticklabels=class_names,
905
+ cbar_kws={'label': 'Count'})
906
+ plt.title(f'{model_name} - Confusion Matrix', fontsize=14, fontweight='bold')
907
+ plt.ylabel('True Label', fontsize=12)
908
+ plt.xlabel('Predicted Label', fontsize=12)
909
+
910
+ plt.tight_layout()
911
+ os.makedirs(save_dir, exist_ok=True)
912
+ plt.savefig(os.path.join(save_dir, f'{model_name}_confusion_matrix.png'),
913
+ dpi=300, bbox_inches='tight')
914
+ plt.close()
915
+ print(f"Confusion matrix saved for {model_name}")
916
+
917
+ def save_evaluation_results(self, save_dir: str = "../notebooks"):
918
+ """Save all evaluation results to file."""
919
+ os.makedirs(save_dir, exist_ok=True)
920
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
921
+
922
+ results_file = os.path.join(save_dir, f'evaluation_results_{timestamp}.txt')
923
+
924
+ with open(results_file, 'w') as f:
925
+ f.write("=" * 70 + "\n")
926
+ f.write("Medical Transformer Models - Evaluation Results\n")
927
+ f.write("=" * 70 + "\n\n")
928
+
929
+ for model_name, results in self.evaluation_results.items():
930
+ f.write(f"\n{model_name}\n")
931
+ f.write("-" * 70 + "\n")
932
+ f.write(f"Accuracy: {results['accuracy']:.4f}\n")
933
+ f.write(f"F1 Score: {results['f1_score']:.4f}\n")
934
+ f.write(f"ROC-AUC: {results['roc_auc']:.4f}\n\n")
935
+ f.write("Classification Report:\n")
936
+ f.write(results['classification_report'])
937
+ f.write("\n" + "=" * 70 + "\n")
938
+
939
+ print(f"\nEvaluation results saved to {results_file}")
src/models/multimodal_ml.py ADDED
@@ -0,0 +1,560 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Multimodal Machine Learning approach for Parkinson's disease classification.
3
+ This module combines traditional ML, transformer models, and ensemble methods.
4
+ """
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ import torch
9
+ import torch.nn as nn
10
+ import joblib
11
+ from sklearn.ensemble import VotingClassifier, StackingClassifier
12
+ from sklearn.linear_model import LogisticRegression
13
+ from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
14
+ from sklearn.model_selection import cross_val_score, StratifiedKFold
15
+ from xgboost import XGBClassifier
16
+ import matplotlib.pyplot as plt
17
+ import seaborn as sns
18
+ from typing import Dict, List, Tuple, Any
19
+ import os
20
+ import warnings
21
+ warnings.filterwarnings('ignore')
22
+
23
+ try:
24
+ from .traditional_ml import TraditionalMLModels
25
+ from .transformer_models import TransformerModels, TabularDataset
26
+ except ImportError:
27
+ from traditional_ml import TraditionalMLModels
28
+ from transformer_models import TransformerModels, TabularDataset
29
+ from torch.utils.data import DataLoader
30
+
31
+
32
+ class MultimodalEnsemble:
33
+ """
34
+ Multimodal ensemble that combines traditional ML and transformer models.
35
+ """
36
+
37
+ def __init__(self, device: str = None):
38
+ self.device = device if device else ('cuda' if torch.cuda.is_available() else 'cpu')
39
+ self.traditional_models = {}
40
+ self.transformer_models = {}
41
+ self.ensemble_model = None
42
+ self.feature_importance = {}
43
+ self.model_weights = {}
44
+
45
+ def load_traditional_models(self, model_dir: str = "models/saved"):
46
+ """Load pre-trained traditional ML models."""
47
+ model_files = {
48
+ 'lightgbm': 'lightgbm_model.joblib',
49
+ 'xgboost': 'xgboost_model.joblib',
50
+ 'svm': 'svm_model.joblib'
51
+ }
52
+
53
+ for model_name, filename in model_files.items():
54
+ model_path = os.path.join(model_dir, filename)
55
+ if os.path.exists(model_path):
56
+ self.traditional_models[model_name] = joblib.load(model_path)
57
+ print(f"Loaded {model_name} model from {model_path}")
58
+ else:
59
+ print(f"Warning: {model_name} model not found at {model_path}")
60
+
61
+ def load_transformer_models(self, model_dir: str = "models/saved", input_dim: int = 31, num_classes: int = 4):
62
+ """Load pre-trained transformer models (3 medical transformers only)."""
63
+
64
+ # Load only the 3 working medical transformers
65
+ try:
66
+ try:
67
+ from .medical_transformers import (
68
+ BioMistralClassifier,
69
+ ClinicalT5Classifier,
70
+ PubMedBERTClassifier,
71
+ )
72
+ except ImportError:
73
+ from medical_transformers import (
74
+ BioMistralClassifier,
75
+ ClinicalT5Classifier,
76
+ PubMedBERTClassifier,
77
+ )
78
+
79
+ new_model_configs = {
80
+ 'pubmedbert': {
81
+ 'builder': lambda: PubMedBERTClassifier(
82
+ input_dim=input_dim,
83
+ num_classes=num_classes,
84
+ dropout=0.10,
85
+ freeze_bert=False,
86
+ ),
87
+ 'paths': ['pubmedbert_transformer.pth', 'pubmedbert.pth'],
88
+ },
89
+ 'biogpt': {
90
+ 'builder': lambda: BioMistralClassifier(
91
+ input_dim=input_dim,
92
+ num_classes=num_classes,
93
+ dropout=0.15,
94
+ train_decoder_layers=6,
95
+ ),
96
+ 'paths': ['biogpt_transformer.pth', 'biogpt.pth', 'biomistral.pth'],
97
+ },
98
+ 'clinical_t5': {
99
+ 'builder': lambda: ClinicalT5Classifier(
100
+ input_dim=input_dim,
101
+ num_classes=num_classes,
102
+ dropout=0.10,
103
+ freeze_encoder=False,
104
+ ),
105
+ 'paths': ['clinical_t5_transformer.pth', 'clinicalt5_transformer.pth', 'clinical_t5.pth'],
106
+ },
107
+ }
108
+
109
+ print("Attempting to load new medical transformer models...")
110
+ for model_name, config in new_model_configs.items():
111
+ model_path = next(
112
+ (
113
+ os.path.join(model_dir, candidate)
114
+ for candidate in config['paths']
115
+ if os.path.exists(os.path.join(model_dir, candidate))
116
+ ),
117
+ None,
118
+ )
119
+
120
+ if model_path:
121
+ try:
122
+ model = config['builder']()
123
+ state = torch.load(model_path, map_location=self.device, weights_only=False)
124
+ model.load_state_dict(state)
125
+ model = model.to(self.device)
126
+ model.eval()
127
+ self.transformer_models[model_name] = model
128
+ print(f"Loaded {model_name} medical transformer model from {model_path}")
129
+ except Exception as e:
130
+ print(f"Warning: Could not load {model_name}: {e}")
131
+ else:
132
+ print(f"Info: {model_name} medical transformer not found (not yet trained)")
133
+
134
+ except ImportError as e:
135
+ print(f"Info: medical_transformers module not found: {e}")
136
+
137
+ # Load simple feedforward transformer as 3rd transformer
138
+ try:
139
+ try:
140
+ from .transformer_models import TransformerModels
141
+ except ImportError:
142
+ from transformer_models import TransformerModels
143
+ transformer_trainer = TransformerModels(device=self.device)
144
+
145
+ # Only load feedforward model (skip corrupted legacy transformers)
146
+ model_name = 'feedforward'
147
+ model_path = os.path.join(model_dir, f"{model_name}_transformer.pth")
148
+
149
+ print("Attempting to load feedforward transformer model...")
150
+ if os.path.exists(model_path):
151
+ try:
152
+ model = transformer_trainer.load_model(
153
+ 'feedforward', model_name, input_dim, num_classes,
154
+ model_dir, hidden_dims=[256, 128, 64], dropout=0.3
155
+ )
156
+ self.transformer_models[model_name] = model
157
+ print(f"Loaded {model_name} transformer model")
158
+ except Exception as e:
159
+ print(f"Warning: Could not load {model_name}: {e}")
160
+ else:
161
+ print(f"Info: {model_name} transformer not found")
162
+
163
+ except ImportError as e:
164
+ print(f"Warning: Could not import transformer_models: {e}")
165
+
166
+ if len(self.transformer_models) > 0:
167
+ print(f"Successfully loaded {len(self.transformer_models)} transformer model(s)")
168
+ else:
169
+ print("WARNING: No transformer models loaded! Ensemble will use traditional models only.")
170
+
171
+ def get_traditional_predictions(self, X):
172
+ """Get predictions from traditional ML models."""
173
+ predictions = {}
174
+ probabilities = {}
175
+
176
+ for model_name, model in self.traditional_models.items():
177
+ try:
178
+ pred = model.predict(X)
179
+ pred_proba = model.predict_proba(X)
180
+ predictions[model_name] = pred
181
+ probabilities[model_name] = pred_proba
182
+ except Exception as e:
183
+ print(f"Error getting predictions from {model_name}: {e}")
184
+
185
+ return predictions, probabilities
186
+
187
+ def get_transformer_predictions(self, X):
188
+ """Get predictions from transformer models."""
189
+ predictions = {}
190
+ probabilities = {}
191
+
192
+ # Convert to tensor if needed
193
+ if not isinstance(X, torch.Tensor):
194
+ # Handle DataFrame conversion
195
+ if hasattr(X, 'values'):
196
+ X_vals = X.values
197
+ else:
198
+ X_vals = X
199
+ X_tensor = torch.FloatTensor(X_vals).to(self.device)
200
+ else:
201
+ X_tensor = X.to(self.device)
202
+
203
+ for model_name, model in self.transformer_models.items():
204
+ try:
205
+ model.eval()
206
+ with torch.no_grad():
207
+ outputs = model(X_tensor)
208
+ proba = torch.softmax(outputs, dim=1)
209
+ pred = torch.argmax(outputs, dim=1)
210
+
211
+ predictions[model_name] = pred.cpu().numpy()
212
+ probabilities[model_name] = proba.cpu().numpy()
213
+ except Exception as e:
214
+ print(f"Error getting predictions from {model_name}: {e}")
215
+
216
+ return predictions, probabilities
217
+
218
+ def create_ensemble_features(self, X):
219
+ """Create ensemble features from all models with optimized weights."""
220
+ # Get predictions from all models
221
+ trad_preds, trad_probas = self.get_traditional_predictions(X)
222
+ trans_preds, trans_probas = self.get_transformer_predictions(X)
223
+
224
+ # Define model weights for better performance
225
+ model_weights = {
226
+ # Traditional models - higher weights for better performers
227
+ 'lightgbm': 1.5,
228
+ 'xgboost': 1.3,
229
+ 'svm': 1.0,
230
+ # New medical transformer models - highest weights for specialized medical models
231
+ 'pubmedbert': 2.2, # Encoder model trained on PubMed abstracts
232
+ 'biomistral': 2.0, # Decoder model with medical knowledge
233
+ 'clinical_t5': 2.1, # Encoder-decoder model for clinical tasks
234
+ # Legacy transformer models (for backward compatibility)
235
+ 'transformer_small': 1.2,
236
+ 'transformer_medium': 1.5,
237
+ 'transformer_large': 1.8,
238
+ 'feedforward': 1.0
239
+ }
240
+
241
+ # Combine all probability predictions as features with weights
242
+ ensemble_features = []
243
+
244
+ # Add traditional model probabilities with weights
245
+ for model_name, proba in trad_probas.items():
246
+ weight = model_weights.get(model_name, 1.0)
247
+ ensemble_features.append(proba * weight)
248
+
249
+ # Add transformer model probabilities with weights
250
+ for model_name, proba in trans_probas.items():
251
+ weight = model_weights.get(model_name, 1.0)
252
+ ensemble_features.append(proba * weight)
253
+
254
+ # Add original features (scaled down)
255
+ if hasattr(X, 'values'):
256
+ X_vals = X.values
257
+ else:
258
+ X_vals = X
259
+ ensemble_features.append(X_vals * 0.15) # Slightly increase original feature weight
260
+
261
+ # Concatenate all features
262
+ if ensemble_features:
263
+ combined = np.concatenate(ensemble_features, axis=1)
264
+ else:
265
+ combined = X_vals
266
+
267
+ # Hardening: keep inference feature width compatible with fitted ensemble model
268
+ expected = getattr(self.ensemble_model, "n_features_in_", None)
269
+ if expected is not None and combined.shape[1] != expected:
270
+ if combined.shape[1] < expected:
271
+ pad = np.zeros((combined.shape[0], expected - combined.shape[1]), dtype=combined.dtype)
272
+ combined = np.concatenate([combined, pad], axis=1)
273
+ else:
274
+ combined = combined[:, :expected]
275
+
276
+ return combined
277
+
278
+ def train_ensemble(self, X_train, y_train, ensemble_type: str = 'stacking'):
279
+ """Train ensemble model on predictions from base models."""
280
+ print(f"Training {ensemble_type} ensemble...")
281
+
282
+ # Create ensemble features
283
+ ensemble_features = self.create_ensemble_features(X_train)
284
+ print(f"Ensemble features shape: {ensemble_features.shape}")
285
+
286
+ if ensemble_type == 'stacking':
287
+ # Use XGBoost as meta-learner for better performance
288
+ self.ensemble_model = XGBClassifier(
289
+ n_estimators=200,
290
+ learning_rate=0.05,
291
+ max_depth=5,
292
+ min_child_weight=2,
293
+ gamma=0.1,
294
+ subsample=0.8,
295
+ colsample_bytree=0.8,
296
+ objective='multi:softproba',
297
+ random_state=42,
298
+ use_label_encoder=False,
299
+ eval_metric='mlogloss'
300
+ )
301
+ elif ensemble_type == 'voting':
302
+ # Create voting classifier (if we have sklearn-compatible models)
303
+ available_models = []
304
+ for name, model in self.traditional_models.items():
305
+ available_models.append((name, model))
306
+
307
+ if available_models:
308
+ self.ensemble_model = VotingClassifier(
309
+ estimators=available_models,
310
+ voting='soft'
311
+ )
312
+ else:
313
+ print("No traditional models available for voting ensemble")
314
+ return
315
+
316
+ # Train ensemble model
317
+ self.ensemble_model.fit(ensemble_features, y_train)
318
+ print(f"{ensemble_type.capitalize()} ensemble trained successfully")
319
+
320
+ def predict_ensemble(self, X):
321
+ """Make predictions using the ensemble model."""
322
+ if self.ensemble_model is None:
323
+ raise ValueError("Ensemble model not trained yet")
324
+
325
+ ensemble_features = self.create_ensemble_features(X)
326
+ predictions = self.ensemble_model.predict(ensemble_features)
327
+ probabilities = self.ensemble_model.predict_proba(ensemble_features)
328
+
329
+ return predictions, probabilities
330
+
331
+ def evaluate_ensemble(self, X_test, y_test):
332
+ """Evaluate ensemble model performance."""
333
+ predictions, probabilities = self.predict_ensemble(X_test)
334
+
335
+ accuracy = accuracy_score(y_test, predictions)
336
+ report = classification_report(y_test, predictions)
337
+ cm = confusion_matrix(y_test, predictions)
338
+
339
+ return {
340
+ 'accuracy': accuracy,
341
+ 'predictions': predictions,
342
+ 'probabilities': probabilities,
343
+ 'classification_report': report,
344
+ 'confusion_matrix': cm
345
+ }
346
+
347
+ def compare_all_models(self, X_test, y_test):
348
+ """Compare performance of all individual models and ensemble."""
349
+ results = {}
350
+
351
+ # Evaluate traditional models
352
+ trad_preds, trad_probas = self.get_traditional_predictions(X_test)
353
+ for model_name, pred in trad_preds.items():
354
+ accuracy = accuracy_score(y_test, pred)
355
+ results[f"Traditional_{model_name}"] = accuracy
356
+
357
+ # Evaluate transformer models
358
+ trans_preds, trans_probas = self.get_transformer_predictions(X_test)
359
+ for model_name, pred in trans_preds.items():
360
+ accuracy = accuracy_score(y_test, pred)
361
+ results[f"Transformer_{model_name}"] = accuracy
362
+
363
+ # Evaluate ensemble
364
+ if self.ensemble_model is not None:
365
+ ensemble_results = self.evaluate_ensemble(X_test, y_test)
366
+ results["Ensemble"] = ensemble_results['accuracy']
367
+
368
+ return results
369
+
370
+ def plot_model_comparison(self, results: Dict, save_path: str = "notebooks/multimodal_comparison.png"):
371
+ """Plot comparison of all models."""
372
+ models = list(results.keys())
373
+ accuracies = list(results.values())
374
+
375
+ plt.figure(figsize=(15, 8))
376
+ bars = plt.bar(models, accuracies, color=['skyblue', 'lightgreen', 'lightcoral',
377
+ 'gold', 'pink', 'lightgray', 'orange', 'red'])
378
+
379
+ plt.title('Multimodal Model Comparison - Test Accuracy', fontsize=16)
380
+ plt.xlabel('Model', fontsize=12)
381
+ plt.ylabel('Accuracy', fontsize=12)
382
+ plt.xticks(rotation=45, ha='right')
383
+
384
+ # Add value labels on bars
385
+ for bar, acc in zip(bars, accuracies):
386
+ plt.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.005,
387
+ f'{acc:.4f}', ha='center', va='bottom', fontsize=10)
388
+
389
+ plt.tight_layout()
390
+ plt.savefig(save_path, dpi=300, bbox_inches='tight')
391
+ plt.close()
392
+
393
+ print(f"Model comparison plot saved to {save_path}")
394
+
395
+ def cross_validate_ensemble(self, X, y, cv_folds: int = 5):
396
+ """Perform cross-validation on ensemble model."""
397
+ if self.ensemble_model is None:
398
+ raise ValueError("Ensemble model not trained yet")
399
+
400
+ # Create ensemble features for full dataset
401
+ ensemble_features = self.create_ensemble_features(X)
402
+
403
+ # Perform cross-validation
404
+ cv_scores = cross_val_score(
405
+ self.ensemble_model, ensemble_features, y,
406
+ cv=cv_folds, scoring='accuracy'
407
+ )
408
+
409
+ return cv_scores
410
+
411
+ def save_ensemble(self, save_path: str = "models/saved/multimodal_ensemble.joblib"):
412
+ """Save the trained ensemble model."""
413
+ if self.ensemble_model is not None:
414
+ joblib.dump(self.ensemble_model, save_path)
415
+ print(f"Ensemble model saved to {save_path}")
416
+ else:
417
+ print("No ensemble model to save")
418
+
419
+ def load_ensemble(self, load_path: str = "models/saved/multimodal_ensemble.joblib"):
420
+ """Load a pre-trained ensemble model."""
421
+ if os.path.exists(load_path):
422
+ self.ensemble_model = joblib.load(load_path)
423
+ print(f"Ensemble model loaded from {load_path}")
424
+ else:
425
+ print(f"Ensemble model not found at {load_path}")
426
+
427
+
428
+ class AdvancedFeatureEngineering:
429
+ """
430
+ Advanced feature engineering for multimodal approach.
431
+ """
432
+
433
+ def __init__(self):
434
+ self.feature_transformers = {}
435
+ self.interaction_features = []
436
+
437
+ def create_polynomial_features(self, X, degree: int = 2, feature_subset: List[str] = None):
438
+ """Create polynomial features for selected columns."""
439
+ from sklearn.preprocessing import PolynomialFeatures
440
+
441
+ if feature_subset is None:
442
+ # Use numerical features only
443
+ numerical_cols = X.select_dtypes(include=[np.number]).columns
444
+ feature_subset = numerical_cols[:5] # Limit to avoid explosion
445
+
446
+ poly = PolynomialFeatures(degree=degree, include_bias=False, interaction_only=True)
447
+ X_subset = X[feature_subset]
448
+ X_poly = poly.fit_transform(X_subset)
449
+
450
+ # Create feature names
451
+ feature_names = poly.get_feature_names_out(feature_subset)
452
+
453
+ # Add to original dataframe
454
+ X_enhanced = X.copy()
455
+ for i, name in enumerate(feature_names):
456
+ if name not in X.columns: # Avoid duplicates
457
+ X_enhanced[f'poly_{name}'] = X_poly[:, i]
458
+
459
+ self.feature_transformers['polynomial'] = poly
460
+ return X_enhanced
461
+
462
+ def create_clustering_features(self, X, n_clusters: int = 5):
463
+ """Create clustering-based features."""
464
+ from sklearn.cluster import KMeans
465
+ from sklearn.preprocessing import StandardScaler
466
+
467
+ # Scale features for clustering
468
+ scaler = StandardScaler()
469
+ X_scaled = scaler.fit_transform(X.select_dtypes(include=[np.number]))
470
+
471
+ # Apply K-means clustering
472
+ kmeans = KMeans(n_clusters=n_clusters, random_state=42)
473
+ cluster_labels = kmeans.fit_predict(X_scaled)
474
+
475
+ # Add cluster features
476
+ X_enhanced = X.copy()
477
+ X_enhanced['cluster_label'] = cluster_labels
478
+
479
+ # Add distance to each cluster center
480
+ distances = kmeans.transform(X_scaled)
481
+ for i in range(n_clusters):
482
+ X_enhanced[f'dist_to_cluster_{i}'] = distances[:, i]
483
+
484
+ self.feature_transformers['clustering'] = {'kmeans': kmeans, 'scaler': scaler}
485
+ return X_enhanced
486
+
487
+ def create_statistical_features(self, X):
488
+ """Create statistical aggregation features."""
489
+ X_enhanced = X.copy()
490
+ numerical_cols = X.select_dtypes(include=[np.number]).columns
491
+
492
+ if len(numerical_cols) > 1:
493
+ # Row-wise statistics
494
+ X_enhanced['row_mean'] = X[numerical_cols].mean(axis=1)
495
+ X_enhanced['row_std'] = X[numerical_cols].std(axis=1)
496
+ X_enhanced['row_min'] = X[numerical_cols].min(axis=1)
497
+ X_enhanced['row_max'] = X[numerical_cols].max(axis=1)
498
+ X_enhanced['row_range'] = X_enhanced['row_max'] - X_enhanced['row_min']
499
+ X_enhanced['row_skew'] = X[numerical_cols].skew(axis=1)
500
+
501
+ return X_enhanced
502
+
503
+
504
+ def create_multimodal_pipeline(X_train, X_test, y_train, y_test):
505
+ """
506
+ Create and evaluate a complete multimodal ML pipeline.
507
+ """
508
+ print("Creating Multimodal ML Pipeline...")
509
+ print("=" * 50)
510
+
511
+ # Initialize multimodal ensemble
512
+ ensemble = MultimodalEnsemble()
513
+
514
+ # Load pre-trained models
515
+ print("Loading pre-trained models...")
516
+ ensemble.load_traditional_models()
517
+ ensemble.load_transformer_models(input_dim=X_train.shape[1])
518
+
519
+ # Advanced feature engineering
520
+ print("Applying advanced feature engineering...")
521
+ feature_engineer = AdvancedFeatureEngineering()
522
+
523
+ # Create enhanced features
524
+ X_train_enhanced = feature_engineer.create_polynomial_features(X_train)
525
+ X_train_enhanced = feature_engineer.create_clustering_features(X_train_enhanced)
526
+ X_train_enhanced = feature_engineer.create_statistical_features(X_train_enhanced)
527
+
528
+ # Apply same transformations to test set
529
+ X_test_enhanced = X_test.copy()
530
+ if 'polynomial' in feature_engineer.feature_transformers:
531
+ poly = feature_engineer.feature_transformers['polynomial']
532
+ # Apply polynomial features to test set (implementation needed)
533
+
534
+ print(f"Enhanced training features shape: {X_train_enhanced.shape}")
535
+
536
+ # Train ensemble models
537
+ ensemble.train_ensemble(X_train, y_train, ensemble_type='stacking')
538
+
539
+ # Evaluate all models
540
+ print("\nEvaluating all models...")
541
+ results = ensemble.compare_all_models(X_test, y_test)
542
+
543
+ # Print results
544
+ print("\nModel Performance Comparison:")
545
+ print("-" * 40)
546
+ for model_name, accuracy in sorted(results.items(), key=lambda x: x[1], reverse=True):
547
+ print(f"{model_name:<25}: {accuracy:.4f}")
548
+
549
+ # Cross-validation
550
+ print("\nPerforming cross-validation on ensemble...")
551
+ cv_scores = ensemble.cross_validate_ensemble(X_train, y_train)
552
+ print(f"CV Accuracy: {cv_scores.mean():.4f} (+/- {cv_scores.std() * 2:.4f})")
553
+
554
+ # Save ensemble model
555
+ ensemble.save_ensemble()
556
+
557
+ # Create visualizations
558
+ ensemble.plot_model_comparison(results)
559
+
560
+ return ensemble, results
src/models/traditional_ml.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+ from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
4
+ from sklearn.model_selection import GridSearchCV
5
+ from lightgbm import LGBMClassifier
6
+ from xgboost import XGBClassifier
7
+ from sklearn.svm import SVC
8
+ import joblib
9
+ import os
10
+
11
+ class TraditionalMLModels:
12
+ def __init__(self, save_dir='../models/saved'):
13
+ """Initialize the traditional ML models class.
14
+
15
+ Args:
16
+ save_dir (str): Directory to save trained models
17
+ """
18
+ self.models = {
19
+ 'lightgbm': None,
20
+ 'xgboost': None,
21
+ 'svm': None
22
+ }
23
+ self.save_dir = save_dir
24
+ os.makedirs(save_dir, exist_ok=True)
25
+
26
+ def train_lightgbm(self, X_train, y_train):
27
+ """Train LightGBM model with optimized parameters."""
28
+ print("Training LightGBM...")
29
+
30
+ # Use pre-configured model if provided, otherwise create with good defaults
31
+ if self.models['lightgbm'] is None:
32
+ self.models['lightgbm'] = LGBMClassifier(
33
+ random_state=42,
34
+ n_estimators=200,
35
+ learning_rate=0.05,
36
+ max_depth=7,
37
+ num_leaves=31,
38
+ objective='multiclass',
39
+ num_class=4,
40
+ verbose=-1
41
+ )
42
+
43
+ # Train the model
44
+ self.models['lightgbm'].fit(X_train, y_train)
45
+
46
+ # Return train score as approximation
47
+ train_score = self.models['lightgbm'].score(X_train, y_train)
48
+ print(f"LightGBM training accuracy: {train_score:.4f}")
49
+ return train_score
50
+
51
+ def train_xgboost(self, X_train, y_train):
52
+ """Train XGBoost model with optimized parameters."""
53
+ print("Training XGBoost...")
54
+
55
+ # Use pre-configured model if provided, otherwise create with good defaults
56
+ if self.models['xgboost'] is None:
57
+ self.models['xgboost'] = XGBClassifier(
58
+ random_state=42,
59
+ n_estimators=200,
60
+ learning_rate=0.05,
61
+ max_depth=6,
62
+ objective='multi:softmax',
63
+ num_class=4,
64
+ eval_metric='mlogloss',
65
+ verbosity=0
66
+ )
67
+
68
+ # Train the model
69
+ self.models['xgboost'].fit(X_train, y_train)
70
+
71
+ # Return train score as approximation
72
+ train_score = self.models['xgboost'].score(X_train, y_train)
73
+ print(f"XGBoost training accuracy: {train_score:.4f}")
74
+ return train_score
75
+
76
+ def train_svm(self, X_train, y_train):
77
+ """Train SVM model with optimized parameters."""
78
+ print("Training SVM...")
79
+
80
+ # Use pre-configured model if provided, otherwise create with good defaults
81
+ if self.models['svm'] is None:
82
+ self.models['svm'] = SVC(
83
+ random_state=42,
84
+ probability=True,
85
+ C=10.0,
86
+ kernel='rbf',
87
+ gamma='scale',
88
+ decision_function_shape='ovr',
89
+ verbose=False
90
+ )
91
+
92
+ # Train the model
93
+ self.models['svm'].fit(X_train, y_train)
94
+
95
+ # Return train score as approximation
96
+ train_score = self.models['svm'].score(X_train, y_train)
97
+ print(f"SVM training accuracy: {train_score:.4f}")
98
+ return train_score
99
+
100
+ def train_all_models(self, X_train, y_train):
101
+ """Train all models and return their cross-validation scores."""
102
+ scores = {}
103
+ scores['lightgbm'] = self.train_lightgbm(X_train, y_train)
104
+ scores['xgboost'] = self.train_xgboost(X_train, y_train)
105
+ scores['svm'] = self.train_svm(X_train, y_train)
106
+ return scores
107
+
108
+ def evaluate_model(self, model_name, X_test, y_test):
109
+ """Evaluate a specific model on test data."""
110
+ model = self.models[model_name]
111
+ if model is None:
112
+ print(f"Model '{model_name}' is not trained yet.")
113
+ return None
114
+
115
+ y_pred = model.predict(X_test)
116
+ probabilities = None
117
+ if hasattr(model, "predict_proba"):
118
+ probabilities = model.predict_proba(X_test)
119
+ accuracy = accuracy_score(y_test, y_pred)
120
+ report = classification_report(y_test, y_pred)
121
+ cm = confusion_matrix(y_test, y_pred)
122
+
123
+ print(f"\n{model_name} Test Accuracy: {accuracy:.4f}")
124
+ print(f"Classification Report:\n{report}")
125
+
126
+ return {
127
+ 'accuracy': accuracy,
128
+ 'predictions': y_pred,
129
+ 'probabilities': probabilities,
130
+ 'classification_report': report,
131
+ 'confusion_matrix': cm
132
+ }
133
+
134
+ def evaluate_all_models(self, X_test, y_test):
135
+ """Evaluate all trained models on test data."""
136
+ results = {}
137
+ for name in self.models:
138
+ if self.models[name] is not None:
139
+ results[name] = self.evaluate_model(name, X_test, y_test)
140
+ return results
141
+
142
+ def save_models(self):
143
+ """Save all trained models to disk."""
144
+ for model_name, model in self.models.items():
145
+ if model is not None:
146
+ save_path = os.path.join(self.save_dir, f"{model_name}_model.joblib")
147
+ joblib.dump(model, save_path)
148
+ print(f"Saved {model_name} model to {save_path}")
149
+
150
+ def load_models(self):
151
+ """Load all saved models from disk."""
152
+ for model_name in self.models:
153
+ model_path = os.path.join(self.save_dir, f"{model_name}_model.joblib")
154
+ if os.path.exists(model_path):
155
+ self.models[model_name] = joblib.load(model_path)
156
+ print(f"Loaded {model_name} model from {model_path}")
157
+ else:
158
+ print(f"No saved model found for {model_name}")
src/models/transformer_models.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Transformer-based models for Parkinson's disease classification.
3
+ This module implements pretrained transformer models (DistilBERT, BioBERT, PubMedBERT)
4
+ adapted for tabular data with RAG integration.
5
+ """
6
+
7
+ import torch
8
+ import torch.nn as nn
9
+ import torch.nn.functional as F
10
+ from torch.utils.data import Dataset, DataLoader
11
+ import numpy as np
12
+ from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
13
+ from sklearn.model_selection import StratifiedKFold
14
+ import joblib
15
+ import os
16
+ from typing import Tuple, Dict, Any, List, Optional
17
+ import matplotlib.pyplot as plt
18
+ import seaborn as sns
19
+ from transformers import (
20
+ AutoModel,
21
+ AutoTokenizer,
22
+ DistilBertModel,
23
+ DistilBertTokenizer,
24
+ BertModel,
25
+ BertTokenizer
26
+ )
27
+
28
+
29
+ class TabularDataset(Dataset):
30
+ """Custom dataset for tabular data optionally carrying cached RAG contexts."""
31
+
32
+ def __init__(self, X, y, feature_names=None, contexts=None):
33
+ self.X = torch.FloatTensor(X)
34
+ self.y = torch.LongTensor(y)
35
+ self.feature_names = feature_names or [f"feature_{i}" for i in range(X.shape[1])]
36
+ context_len = None
37
+ if isinstance(contexts, dict):
38
+ first_value = next(iter(contexts.values()), None)
39
+ context_len = len(first_value) if first_value is not None else 0
40
+ elif contexts is not None:
41
+ context_len = len(contexts)
42
+ if contexts is not None and context_len != len(self.X):
43
+ raise ValueError("Length of contexts must match number of samples")
44
+ self.contexts = contexts
45
+
46
+ def __len__(self):
47
+ return len(self.X)
48
+
49
+ def __getitem__(self, idx):
50
+ if self.contexts is None:
51
+ return self.X[idx], self.y[idx]
52
+ if isinstance(self.contexts, dict):
53
+ return self.X[idx], self.y[idx], {key: value[idx] for key, value in self.contexts.items()}
54
+ return self.X[idx], self.y[idx], self.contexts[idx]
55
+
56
+ def get_feature_description(self, idx):
57
+ """Get feature values with names for text representation."""
58
+ sample = self.X[idx].numpy()
59
+ return {name: float(val) for name, val in zip(self.feature_names, sample)}
60
+
61
+
62
+ class DistilBERTForTabular(nn.Module):
63
+ """DistilBERT model adapted for tabular data with RAG integration."""
64
+
65
+ def __init__(self, input_dim: int, num_classes: int, dropout: float = 0.1):
66
+ super(DistilBERTForTabular, self).__init__()
67
+
68
+ self.input_dim = input_dim
69
+ self.num_classes = num_classes
70
+ self.model_name = "distilbert-base-uncased"
71
+
72
+ # Load pretrained DistilBERT model and tokenizer
73
+ self.tokenizer = DistilBertTokenizer.from_pretrained(self.model_name)
74
+ self.bert = DistilBertModel.from_pretrained(self.model_name)
75
+
76
+ # Freeze BERT parameters to speed up training
77
+ for param in self.bert.parameters():
78
+ param.requires_grad = False
79
+
80
+ # Feature projection layer
81
+ self.feature_projection = nn.Linear(input_dim, 768) # Project to BERT hidden size
82
+
83
+ # Classification head
84
+ self.classifier = nn.Sequential(
85
+ nn.Linear(768, 256),
86
+ nn.ReLU(),
87
+ nn.Dropout(dropout),
88
+ nn.Linear(256, num_classes)
89
+ )
90
+
91
+ def forward(self, x, text_input=None):
92
+ # Project tabular features
93
+ tabular_features = self.feature_projection(x)
94
+
95
+ if text_input is not None:
96
+ # Process text input if available (for RAG integration)
97
+ inputs = self.tokenizer(text_input, return_tensors="pt", padding=True, truncation=True, max_length=512)
98
+ inputs = {k: v.to(x.device) for k, v in inputs.items()}
99
+
100
+ # Get BERT embeddings
101
+ with torch.no_grad():
102
+ outputs = self.bert(**inputs)
103
+ text_features = outputs.last_hidden_state[:, 0, :] # CLS token
104
+
105
+ # Combine tabular and text features
106
+ combined_features = tabular_features + text_features
107
+ else:
108
+ # Use only tabular features if no text input
109
+ combined_features = tabular_features
110
+
111
+ # Classification
112
+ output = self.classifier(combined_features)
113
+
114
+ return output
115
+
116
+
117
+ class BioBERTForTabular(nn.Module):
118
+ """BioBERT model adapted for tabular data with RAG integration."""
119
+
120
+ def __init__(self, input_dim: int, num_classes: int, dropout: float = 0.1):
121
+ super(BioBERTForTabular, self).__init__()
122
+
123
+ self.input_dim = input_dim
124
+ self.num_classes = num_classes
125
+ self.model_name = "dmis-lab/biobert-v1.1"
126
+
127
+ # Load pretrained BioBERT model and tokenizer
128
+ self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
129
+ self.bert = AutoModel.from_pretrained(self.model_name)
130
+
131
+ # Freeze BERT parameters to speed up training
132
+ for param in self.bert.parameters():
133
+ param.requires_grad = False
134
+
135
+ # Feature projection layer
136
+ self.feature_projection = nn.Linear(input_dim, 768) # Project to BERT hidden size
137
+
138
+ # Classification head
139
+ self.classifier = nn.Sequential(
140
+ nn.Linear(768, 256),
141
+ nn.ReLU(),
142
+ nn.Dropout(dropout),
143
+ nn.Linear(256, num_classes)
144
+ )
145
+
146
+ def forward(self, x, text_input=None):
147
+ # Project tabular features
148
+ tabular_features = self.feature_projection(x)
149
+
150
+ if text_input is not None:
151
+ # Process text input if available (for RAG integration)
152
+ inputs = self.tokenizer(text_input, return_tensors="pt", padding=True, truncation=True, max_length=512)
153
+ inputs = {k: v.to(x.device) for k, v in inputs.items()}
154
+
155
+ # Get BERT embeddings
156
+ with torch.no_grad():
157
+ outputs = self.bert(**inputs)
158
+ text_features = outputs.last_hidden_state[:, 0, :] # CLS token
159
+
160
+ # Combine tabular and text features
161
+ combined_features = tabular_features + text_features
162
+ else:
163
+ # Use only tabular features if no text input
164
+ combined_features = tabular_features
165
+
166
+ # Classification
167
+ output = self.classifier(combined_features)
168
+
169
+ return output
170
+
171
+
172
+ class PubMedBERTForTabular(nn.Module):
173
+ """PubMedBERT model adapted for tabular data with RAG integration."""
174
+
175
+ def __init__(self, input_dim: int, num_classes: int, dropout: float = 0.1):
176
+ super(PubMedBERTForTabular, self).__init__()
177
+
178
+ self.input_dim = input_dim
179
+ self.num_classes = num_classes
180
+ self.model_name = "microsoft/BiomedNLP-PubMedBERT-base-uncased-abstract-fulltext"
181
+
182
+ # Load pretrained PubMedBERT model and tokenizer
183
+ self.tokenizer = AutoTokenizer.from_pretrained(self.model_name)
184
+ self.bert = AutoModel.from_pretrained(self.model_name)
185
+
186
+ # Freeze BERT parameters to speed up training
187
+ for param in self.bert.parameters():
188
+ param.requires_grad = False
189
+
190
+ # Feature projection layer
191
+ self.feature_projection = nn.Linear(input_dim, 768) # Project to BERT hidden size
192
+
193
+ # Classification head
194
+ self.classifier = nn.Sequential(
195
+ nn.Linear(768, 256),
196
+ nn.ReLU(),
197
+ nn.Dropout(dropout),
198
+ nn.Linear(256, num_classes)
199
+ )
200
+
201
+ def forward(self, x, text_input=None):
202
+ # Project tabular features
203
+ tabular_features = self.feature_projection(x)
204
+
205
+ if text_input is not None:
206
+ # Process text input if available (for RAG integration)
207
+ inputs = self.tokenizer(text_input, return_tensors="pt", padding=True, truncation=True, max_length=512)
208
+ inputs = {k: v.to(x.device) for k, v in inputs.items()}
209
+
210
+ # Get BERT embeddings
211
+ with torch.no_grad():
212
+ outputs = self.bert(**inputs)
213
+ text_features = outputs.last_hidden_state[:, 0, :] # CLS token
214
+
215
+ # Combine tabular and text features
216
+ combined_features = tabular_features + text_features
217
+ else:
218
+ # Use only tabular features if no text input
219
+ combined_features = tabular_features
220
+
221
+ # Classification
222
+ output = self.classifier(combined_features)
223
+
224
+ return output
225
+
226
+
227
+ class FeedForwardNetwork(nn.Module):
228
+ """Simple feed-forward network for comparison."""
229
+
230
+ def __init__(self, input_dim: int, num_classes: int, hidden_dims: list = [256, 128, 64],
231
+ dropout: float = 0.3):
232
+ super(FeedForwardNetwork, self).__init__()
233
+
234
+ layers = []
235
+ prev_dim = input_dim
236
+
237
+ for hidden_dim in hidden_dims:
238
+ layers.extend([
239
+ nn.Linear(prev_dim, hidden_dim),
240
+ nn.BatchNorm1d(hidden_dim),
241
+ nn.ReLU(),
242
+ nn.Dropout(dropout)
243
+ ])
244
+ prev_dim = hidden_dim
245
+
246
+ layers.append(nn.Linear(prev_dim, num_classes))
247
+
248
+ self.network = nn.Sequential(*layers)
249
+
250
+ def forward(self, x):
251
+ return self.network(x)
252
+
253
+
254
+ class TransformerModels:
255
+ """Class to handle training and evaluation of transformer models."""
256
+
257
+ def __init__(self, device: str = None):
258
+ self.device = device if device else ('cuda' if torch.cuda.is_available() else 'cpu')
259
+ self.models = {}
260
+ self.training_history = {}
261
+
262
+ def create_model(self, model_type: str, input_dim: int, num_classes: int, **kwargs):
263
+ """Create a model of specified type."""
264
+ if model_type == 'transformer':
265
+ # For backward compatibility with saved models
266
+ if 'd_model' in kwargs:
267
+ # This is the old TabularTransformer model structure
268
+ d_model = kwargs.get('d_model', 256)
269
+ nhead = kwargs.get('nhead', 8)
270
+ num_layers = kwargs.get('num_layers', 4)
271
+ dropout = kwargs.get('dropout', 0.1)
272
+
273
+ # Create a compatible model structure
274
+ model = FeedForwardNetwork(input_dim, num_classes,
275
+ hidden_dims=[d_model, d_model//2, d_model//4],
276
+ dropout=dropout)
277
+ else:
278
+ # New transformer models
279
+ model_name = kwargs.get('model_name', 'distilbert')
280
+ if model_name == 'distilbert':
281
+ model = DistilBERTForTabular(input_dim, num_classes, dropout=kwargs.get('dropout', 0.1))
282
+ elif model_name == 'biobert':
283
+ model = BioBERTForTabular(input_dim, num_classes, dropout=kwargs.get('dropout', 0.1))
284
+ elif model_name == 'pubmedbert':
285
+ model = PubMedBERTForTabular(input_dim, num_classes, dropout=kwargs.get('dropout', 0.1))
286
+ else:
287
+ # Default to DistilBERT if model name not specified
288
+ model = DistilBERTForTabular(input_dim, num_classes, dropout=kwargs.get('dropout', 0.1))
289
+ elif model_type == 'feedforward':
290
+ model = FeedForwardNetwork(input_dim, num_classes, **kwargs)
291
+ else:
292
+ raise ValueError(f"Unknown model type: {model_type}")
293
+
294
+ return model.to(self.device)
295
+
296
+ def train_model(self, model, train_loader, val_loader, epochs: int = 100,
297
+ lr: float = 0.001, weight_decay: float = 1e-4,
298
+ class_weights: torch.Tensor = None):
299
+ """Train a model."""
300
+
301
+ # Loss function with class weights
302
+ if class_weights is not None:
303
+ criterion = nn.CrossEntropyLoss(weight=class_weights.to(self.device))
304
+ else:
305
+ criterion = nn.CrossEntropyLoss()
306
+
307
+ optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=weight_decay)
308
+ scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
309
+ optimizer, mode='min', factor=0.5, patience=10
310
+ )
311
+
312
+ train_losses = []
313
+ val_losses = []
314
+ val_accuracies = []
315
+
316
+ best_val_loss = float('inf')
317
+ patience_counter = 0
318
+ early_stopping_patience = 20
319
+
320
+ for epoch in range(epochs):
321
+ # Training phase
322
+ model.train()
323
+ train_loss = 0.0
324
+
325
+ for batch_x, batch_y in train_loader:
326
+ batch_x, batch_y = batch_x.to(self.device), batch_y.to(self.device)
327
+
328
+ optimizer.zero_grad()
329
+ outputs = model(batch_x)
330
+ loss = criterion(outputs, batch_y)
331
+ loss.backward()
332
+ optimizer.step()
333
+
334
+ train_loss += loss.item()
335
+
336
+ # Validation phase
337
+ model.eval()
338
+ val_loss = 0.0
339
+ correct = 0
340
+ total = 0
341
+
342
+ with torch.no_grad():
343
+ for batch_x, batch_y in val_loader:
344
+ batch_x, batch_y = batch_x.to(self.device), batch_y.to(self.device)
345
+
346
+ outputs = model(batch_x)
347
+ loss = criterion(outputs, batch_y)
348
+ val_loss += loss.item()
349
+
350
+ _, predicted = torch.max(outputs.data, 1)
351
+ total += batch_y.size(0)
352
+ correct += (predicted == batch_y).sum().item()
353
+
354
+ train_loss /= len(train_loader)
355
+ val_loss /= len(val_loader)
356
+ val_accuracy = 100 * correct / total
357
+
358
+ train_losses.append(train_loss)
359
+ val_losses.append(val_loss)
360
+ val_accuracies.append(val_accuracy)
361
+
362
+ scheduler.step(val_loss)
363
+
364
+ if epoch % 10 == 0:
365
+ print(f'Epoch [{epoch}/{epochs}], Train Loss: {train_loss:.4f}, '
366
+ f'Val Loss: {val_loss:.4f}, Val Acc: {val_accuracy:.2f}%')
367
+
368
+ # Early stopping
369
+ if val_loss < best_val_loss:
370
+ best_val_loss = val_loss
371
+ patience_counter = 0
372
+ else:
373
+ patience_counter += 1
374
+ if patience_counter >= early_stopping_patience:
375
+ print(f'Early stopping at epoch {epoch}')
376
+ break
377
+
378
+ return {
379
+ 'train_losses': train_losses,
380
+ 'val_losses': val_losses,
381
+ 'val_accuracies': val_accuracies
382
+ }
383
+
384
+ def evaluate_model(self, model, test_loader):
385
+ """Evaluate model on test set."""
386
+ model.eval()
387
+ all_predictions = []
388
+ all_targets = []
389
+
390
+ with torch.no_grad():
391
+ for batch_x, batch_y in test_loader:
392
+ batch_x, batch_y = batch_x.to(self.device), batch_y.to(self.device)
393
+
394
+ outputs = model(batch_x)
395
+ _, predicted = torch.max(outputs, 1)
396
+
397
+ all_predictions.extend(predicted.cpu().numpy())
398
+ all_targets.extend(batch_y.cpu().numpy())
399
+
400
+ accuracy = accuracy_score(all_targets, all_predictions)
401
+ report = classification_report(all_targets, all_predictions)
402
+ cm = confusion_matrix(all_targets, all_predictions)
403
+
404
+ return {
405
+ 'accuracy': accuracy,
406
+ 'predictions': all_predictions,
407
+ 'targets': all_targets,
408
+ 'classification_report': report,
409
+ 'confusion_matrix': cm
410
+ }
411
+
412
+ def cross_validate(self, model_type: str, X, y, cv_folds: int = 5, **model_kwargs):
413
+ """Perform cross-validation."""
414
+ skf = StratifiedKFold(n_splits=cv_folds, shuffle=True, random_state=42)
415
+ cv_scores = []
416
+
417
+ for fold, (train_idx, val_idx) in enumerate(skf.split(X, y)):
418
+ print(f"\nFold {fold + 1}/{cv_folds}")
419
+
420
+ X_train_fold, X_val_fold = X[train_idx], X[val_idx]
421
+ y_train_fold, y_val_fold = y[train_idx], y[val_idx]
422
+
423
+ # Create datasets and loaders
424
+ train_dataset = TabularDataset(X_train_fold, y_train_fold)
425
+ val_dataset = TabularDataset(X_val_fold, y_val_fold)
426
+
427
+ train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
428
+ val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)
429
+
430
+ # Create and train model
431
+ model = self.create_model(model_type, X.shape[1], len(np.unique(y)), **model_kwargs)
432
+
433
+ # Calculate class weights for this fold
434
+ class_counts = np.bincount(y_train_fold)
435
+ class_weights = torch.FloatTensor(len(class_counts) / (len(class_counts) * class_counts))
436
+
437
+ # Train model
438
+ history = self.train_model(model, train_loader, val_loader,
439
+ epochs=50, class_weights=class_weights)
440
+
441
+ # Evaluate on validation set
442
+ results = self.evaluate_model(model, val_loader)
443
+ cv_scores.append(results['accuracy'])
444
+
445
+ print(f"Fold {fold + 1} Accuracy: {results['accuracy']:.4f}")
446
+
447
+ return cv_scores
448
+
449
+ def save_model(self, model, model_name: str, save_dir: str = "models/saved"):
450
+ """Save trained model."""
451
+ os.makedirs(save_dir, exist_ok=True)
452
+ model_path = os.path.join(save_dir, f"{model_name}_transformer.pth")
453
+ torch.save(model.state_dict(), model_path)
454
+ print(f"Model saved to {model_path}")
455
+
456
+ def load_model(self, model_type: str, model_name: str, input_dim: int,
457
+ num_classes: int, save_dir: str = "models/saved", **model_kwargs):
458
+ """Load trained model."""
459
+ model_path = os.path.join(save_dir, f"{model_name}_transformer.pth")
460
+
461
+ model = self.create_model(model_type, input_dim, num_classes, **model_kwargs)
462
+ model.load_state_dict(torch.load(model_path, map_location=self.device))
463
+ model.eval()
464
+
465
+ return model
466
+
467
+ def plot_training_history(self, history: Dict, model_name: str, save_dir: str = "notebooks"):
468
+ """Plot training history."""
469
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(15, 5))
470
+
471
+ # Plot losses
472
+ ax1.plot(history['train_losses'], label='Train Loss')
473
+ ax1.plot(history['val_losses'], label='Validation Loss')
474
+ ax1.set_title(f'{model_name} - Training and Validation Loss')
475
+ ax1.set_xlabel('Epoch')
476
+ ax1.set_ylabel('Loss')
477
+ ax1.legend()
478
+ ax1.grid(True)
479
+
480
+ # Plot accuracy
481
+ ax2.plot(history['val_accuracies'], label='Validation Accuracy', color='green')
482
+ ax2.set_title(f'{model_name} - Validation Accuracy')
483
+ ax2.set_xlabel('Epoch')
484
+ ax2.set_ylabel('Accuracy (%)')
485
+ ax2.legend()
486
+ ax2.grid(True)
487
+
488
+ plt.tight_layout()
489
+ plt.savefig(os.path.join(save_dir, f'{model_name}_training_history.png'), dpi=300, bbox_inches='tight')
490
+ plt.close()
491
+
492
+ def plot_confusion_matrix(self, cm, model_name: str, class_names: list = None,
493
+ save_dir: str = "notebooks"):
494
+ """Plot confusion matrix."""
495
+ plt.figure(figsize=(8, 6))
496
+
497
+ if class_names is None:
498
+ class_names = [f'Class {i}' for i in range(len(cm))]
499
+
500
+ sns.heatmap(cm, annot=True, fmt='d', cmap='Blues',
501
+ xticklabels=class_names, yticklabels=class_names)
502
+ plt.title(f'{model_name} - Confusion Matrix')
503
+ plt.xlabel('Predicted')
504
+ plt.ylabel('Actual')
505
+
506
+ plt.tight_layout()
507
+ plt.savefig(os.path.join(save_dir, f'{model_name}_transformer_confusion_matrix.png'),
508
+ dpi=300, bbox_inches='tight')
509
+ plt.close()
src/progression_model.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Progression Modeling — PPMI Trajectory Clustering.
3
+
4
+ Extracts longitudinal PPMI visit data, clusters patients into
5
+ slow / moderate / fast progressors using k-means on velocity
6
+ vectors, and provides cluster-weighted forecast predictions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import math
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+ import numpy as np
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # ---------------------------------------------------------------------------
21
+ # Cluster labels in order of severity (index 0 = slowest)
22
+ # ---------------------------------------------------------------------------
23
+ CLUSTER_LABELS = ["slow", "moderate", "fast"]
24
+
25
+ # Yearly progression defaults per cluster (UPDRS3 gain/yr, MoCA loss/yr, HY gain/yr)
26
+ DEFAULT_CLUSTER_PROFILES = {
27
+ "slow": {"updrs3_gain": 1.8, "moca_loss": 0.3, "hy_gain": 0.10},
28
+ "moderate": {"updrs3_gain": 3.5, "moca_loss": 0.7, "hy_gain": 0.25},
29
+ "fast": {"updrs3_gain": 6.5, "moca_loss": 1.3, "hy_gain": 0.45},
30
+ }
31
+
32
+
33
+ class ProgressionModel:
34
+ """PPMI-trained trajectory clustering for Parkinson's progression."""
35
+
36
+ def __init__(self) -> None:
37
+ self.fitted = False
38
+ self.centroids: Optional[np.ndarray] = None
39
+ self.cluster_profiles: Dict[str, Dict[str, float]] = dict(DEFAULT_CLUSTER_PROFILES)
40
+ self.silhouette_score_: Optional[float] = None
41
+ self.k = 3
42
+ self._scaler_mean: Optional[np.ndarray] = None
43
+ self._scaler_std: Optional[np.ndarray] = None
44
+
45
+ # ------------------------------------------------------------------
46
+ # Public API
47
+ # ------------------------------------------------------------------
48
+ def fit(self, csv_path: str) -> "ProgressionModel":
49
+ """Fit the model from a PPMI CSV file."""
50
+ try:
51
+ import pandas as pd
52
+ df = pd.read_csv(csv_path, low_memory=False)
53
+ return self._fit_from_dataframe(df)
54
+ except Exception as exc:
55
+ logger.warning("ProgressionModel.fit failed: %s — using defaults", exc)
56
+ self.fitted = False
57
+ return self
58
+
59
+ def _fit_from_dataframe(self, df: "pd.DataFrame") -> "ProgressionModel":
60
+ import pandas as pd
61
+ from sklearn.cluster import KMeans
62
+ from sklearn.preprocessing import StandardScaler
63
+
64
+ required = {"PATNO", "YEAR", "updrs3_score", "moca"}
65
+ if not required.issubset(set(df.columns)):
66
+ logger.warning("Missing columns for progression model; using defaults.")
67
+ return self
68
+
69
+ # Keep only rows with valid data
70
+ sub = df[["PATNO", "YEAR", "updrs3_score", "moca", "hy"]].dropna(
71
+ subset=["PATNO", "YEAR", "updrs3_score"]
72
+ ).copy()
73
+ sub["YEAR"] = pd.to_numeric(sub["YEAR"], errors="coerce")
74
+ sub["updrs3_score"] = pd.to_numeric(sub["updrs3_score"], errors="coerce")
75
+ sub["moca"] = pd.to_numeric(sub["moca"], errors="coerce")
76
+ sub = sub.dropna(subset=["YEAR", "updrs3_score"])
77
+
78
+ # Compute per-patient velocity vectors
79
+ velocities: List[List[float]] = []
80
+ patnos: List[int] = []
81
+ for patno, grp in sub.groupby("PATNO"):
82
+ if len(grp) < 2:
83
+ continue
84
+ grp = grp.sort_values("YEAR")
85
+ years = grp["YEAR"].values
86
+ span = years[-1] - years[0]
87
+ if span < 0.5:
88
+ continue
89
+ delta_updrs = (grp["updrs3_score"].values[-1] - grp["updrs3_score"].values[0]) / span
90
+ moca_vals = grp["moca"].dropna()
91
+ if len(moca_vals) >= 2:
92
+ delta_moca = (moca_vals.values[-1] - moca_vals.values[0]) / span
93
+ else:
94
+ delta_moca = 0.0
95
+ velocities.append([delta_updrs, delta_moca])
96
+ patnos.append(int(patno))
97
+
98
+ if len(velocities) < 10:
99
+ logger.warning("Too few longitudinal patients (%d); using defaults.", len(velocities))
100
+ return self
101
+
102
+ X = np.array(velocities)
103
+
104
+ # Standardize
105
+ scaler = StandardScaler()
106
+ X_scaled = scaler.fit_transform(X)
107
+ self._scaler_mean = scaler.mean_
108
+ self._scaler_std = scaler.scale_
109
+
110
+ # Try k=3, fall back to k=2 if silhouette < 0.3
111
+ best_k = 3
112
+ best_score = -1.0
113
+ best_km = None
114
+
115
+ for k in [3, 2]:
116
+ km = KMeans(n_clusters=k, random_state=42, n_init=10)
117
+ labels = km.fit_predict(X_scaled)
118
+ try:
119
+ from sklearn.metrics import silhouette_score as _sil
120
+ score = _sil(X_scaled, labels)
121
+ except Exception:
122
+ score = 0.0
123
+ logger.info("ProgressionModel k=%d silhouette=%.3f", k, score)
124
+ if score > best_score:
125
+ best_score = score
126
+ best_k = k
127
+ best_km = km
128
+
129
+ if best_score < 0.3 and best_k == 3:
130
+ # Retry with k=2
131
+ km2 = KMeans(n_clusters=2, random_state=42, n_init=10)
132
+ labels2 = km2.fit_predict(X_scaled)
133
+ try:
134
+ from sklearn.metrics import silhouette_score as _sil
135
+ s2 = _sil(X_scaled, labels2)
136
+ except Exception:
137
+ s2 = 0.0
138
+ if s2 > best_score:
139
+ best_score = s2
140
+ best_k = 2
141
+ best_km = km2
142
+
143
+ self.k = best_k
144
+ self.silhouette_score_ = float(best_score)
145
+ self.centroids = best_km.cluster_centers_ # type: ignore[union-attr]
146
+
147
+ # Sort clusters by ascending UPDRS velocity (index 0 in velocity vec)
148
+ centroid_means = scaler.inverse_transform(self.centroids)
149
+ order = np.argsort(centroid_means[:, 0])
150
+ self.centroids = self.centroids[order]
151
+ centroid_means = centroid_means[order]
152
+
153
+ # Build profiles from centroids
154
+ labels_used = CLUSTER_LABELS[: self.k]
155
+ self.cluster_profiles = {}
156
+ for idx, label in enumerate(labels_used):
157
+ updrs_vel = max(centroid_means[idx, 0], 0.5)
158
+ moca_vel = abs(centroid_means[idx, 1]) if centroid_means.shape[1] > 1 else 0.3
159
+ hy_gain = 0.10 + idx * 0.15
160
+ self.cluster_profiles[label] = {
161
+ "updrs3_gain": round(float(updrs_vel), 2),
162
+ "moca_loss": round(float(moca_vel), 2),
163
+ "hy_gain": round(float(hy_gain), 2),
164
+ }
165
+
166
+ self.fitted = True
167
+ logger.info(
168
+ "ProgressionModel fitted: k=%d, silhouette=%.3f, profiles=%s",
169
+ self.k, self.silhouette_score_, self.cluster_profiles,
170
+ )
171
+ return self
172
+
173
+ def assign_cluster(
174
+ self,
175
+ snapshots: List[Dict[str, Any]],
176
+ patient_data: Optional[Dict[str, Any]] = None,
177
+ ) -> Tuple[str, str]:
178
+ """
179
+ Assign a patient to a progression cluster.
180
+
181
+ Returns (cluster_id, cluster_label).
182
+ - If ≥ 2 snapshots with enough time span: use velocity vector.
183
+ - Otherwise: use current feature vector as proxy.
184
+ """
185
+ if not self.fitted or self.centroids is None:
186
+ return self._heuristic_assign(snapshots, patient_data)
187
+
188
+ # Try velocity-based assignment (≥ 2 visits)
189
+ if len(snapshots) >= 2:
190
+ cluster = self._velocity_assign(snapshots)
191
+ if cluster is not None:
192
+ return cluster
193
+
194
+ # Feature-based fallback
195
+ return self._feature_assign(snapshots, patient_data)
196
+
197
+ def get_cluster_profile(self, cluster_label: str) -> Dict[str, float]:
198
+ """Return progression rates for a cluster."""
199
+ return self.cluster_profiles.get(cluster_label, DEFAULT_CLUSTER_PROFILES.get("moderate", {}))
200
+
201
+ def cluster_weighted_forecast(
202
+ self,
203
+ cluster_label: str,
204
+ current_updrs3: float,
205
+ current_moca: float,
206
+ current_hy: float,
207
+ horizon_months: int,
208
+ duration_years: float = 0.0,
209
+ ) -> Dict[str, Optional[float]]:
210
+ """Compute cluster-weighted forecast for a given horizon."""
211
+ profile = self.get_cluster_profile(cluster_label)
212
+ years = horizon_months / 12.0
213
+
214
+ # Duration-dependent acceleration factor
215
+ accel = 1.0 + duration_years * 0.02
216
+
217
+ pred_updrs3 = current_updrs3 + profile["updrs3_gain"] * years * accel
218
+ pred_moca = max(0, min(30, current_moca - profile["moca_loss"] * years))
219
+ pred_hy = max(0, min(5, current_hy + profile["hy_gain"] * years))
220
+ pred_total = pred_updrs3 * 1.7 + 8
221
+
222
+ return {
223
+ "predicted_updrs3": round(pred_updrs3, 2),
224
+ "predicted_total_updrs": round(pred_total, 2),
225
+ "predicted_moca": round(pred_moca, 2),
226
+ "predicted_hy": round(pred_hy, 2),
227
+ }
228
+
229
+ # ------------------------------------------------------------------
230
+ # Internal helpers
231
+ # ------------------------------------------------------------------
232
+ def _velocity_assign(self, snapshots: List[Dict[str, Any]]) -> Optional[Tuple[str, str]]:
233
+ """Assign using velocity vector from snapshot history."""
234
+ first = snapshots[0]
235
+ last = snapshots[-1]
236
+
237
+ y0 = self._get_year(first)
238
+ y1 = self._get_year(last)
239
+ if y0 is None or y1 is None or (y1 - y0) < 0.5:
240
+ return None
241
+
242
+ span = y1 - y0
243
+ u0 = self._get_updrs3(first)
244
+ u1 = self._get_updrs3(last)
245
+ if u0 is None or u1 is None:
246
+ return None
247
+
248
+ delta_updrs = (u1 - u0) / span
249
+
250
+ m0 = self._get_moca(first)
251
+ m1 = self._get_moca(last)
252
+ delta_moca = ((m1 - m0) / span) if (m0 is not None and m1 is not None) else 0.0
253
+
254
+ vec = np.array([[delta_updrs, delta_moca]])
255
+
256
+ if self._scaler_mean is not None and self._scaler_std is not None:
257
+ vec = (vec - self._scaler_mean) / np.clip(self._scaler_std, 1e-8, None)
258
+
259
+ distances = np.linalg.norm(self.centroids - vec, axis=1)
260
+ idx = int(np.argmin(distances))
261
+ labels = CLUSTER_LABELS[: self.k]
262
+ return (str(idx), labels[idx])
263
+
264
+ def _feature_assign(
265
+ self,
266
+ snapshots: List[Dict[str, Any]],
267
+ patient_data: Optional[Dict[str, Any]],
268
+ ) -> Tuple[str, str]:
269
+ """Assign using current feature vector as proxy."""
270
+ latest = snapshots[-1] if snapshots else (patient_data or {})
271
+ updrs3 = self._get_updrs3(latest)
272
+ moca = self._get_moca(latest)
273
+
274
+ # Simple heuristic proxy velocity from current scores
275
+ updrs_proxy = (updrs3 or 10.0) / max(self._get_duration(latest) or 3.0, 1.0)
276
+ moca_proxy = -((moca or 26.0) - 28.0) / max(self._get_duration(latest) or 3.0, 1.0)
277
+
278
+ vec = np.array([[updrs_proxy, moca_proxy]])
279
+ if self._scaler_mean is not None and self._scaler_std is not None:
280
+ vec = (vec - self._scaler_mean) / np.clip(self._scaler_std, 1e-8, None)
281
+
282
+ if self.centroids is not None:
283
+ distances = np.linalg.norm(self.centroids - vec, axis=1)
284
+ idx = int(np.argmin(distances))
285
+ else:
286
+ idx = 1 # moderate default
287
+
288
+ labels = CLUSTER_LABELS[: self.k]
289
+ return (str(idx), labels[min(idx, len(labels) - 1)])
290
+
291
+ def _heuristic_assign(
292
+ self,
293
+ snapshots: List[Dict[str, Any]],
294
+ patient_data: Optional[Dict[str, Any]],
295
+ ) -> Tuple[str, str]:
296
+ """Fallback heuristic when model is not fitted."""
297
+ latest = snapshots[-1] if snapshots else (patient_data or {})
298
+ updrs3 = self._get_updrs3(latest) or 0.0
299
+ if updrs3 >= 25:
300
+ return ("2", "fast")
301
+ elif updrs3 >= 12:
302
+ return ("1", "moderate")
303
+ return ("0", "slow")
304
+
305
+ # ------------------------------------------------------------------
306
+ @staticmethod
307
+ def _get_updrs3(d: Dict[str, Any]) -> Optional[float]:
308
+ val = d.get("motor", d).get("updrs3_score") if isinstance(d.get("motor"), dict) else d.get("updrs3_score")
309
+ try:
310
+ return float(val) if val is not None else None
311
+ except (TypeError, ValueError):
312
+ return None
313
+
314
+ @staticmethod
315
+ def _get_moca(d: Dict[str, Any]) -> Optional[float]:
316
+ val = d.get("cognition", d).get("moca") if isinstance(d.get("cognition"), dict) else d.get("moca")
317
+ try:
318
+ return float(val) if val is not None else None
319
+ except (TypeError, ValueError):
320
+ return None
321
+
322
+ @staticmethod
323
+ def _get_year(d: Dict[str, Any]) -> Optional[float]:
324
+ val = d.get("year_index")
325
+ if val is None:
326
+ val = d.get("YEAR")
327
+ try:
328
+ return float(val) if val is not None else None
329
+ except (TypeError, ValueError):
330
+ return None
331
+
332
+ @staticmethod
333
+ def _get_duration(d: Dict[str, Any]) -> Optional[float]:
334
+ val = d.get("duration_years") or d.get("duration_yrs")
335
+ try:
336
+ return float(val) if val is not None else None
337
+ except (TypeError, ValueError):
338
+ return None
src/rag_system.py ADDED
@@ -0,0 +1,762 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG (Retrieval-Augmented Generation) System for Parkinson's Disease Report Generation.
3
+ This module generates comprehensive medical reports based on ML model predictions.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import warnings
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Dict, Optional
12
+
13
+ import joblib
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+ warnings.filterwarnings("ignore")
18
+
19
+ # Add src directory to path
20
+ sys.path.append(os.path.join(os.path.dirname(__file__)))
21
+ sys.path.append(os.path.join(os.path.dirname(__file__), "models"))
22
+
23
+ from data_preprocessing import DataPreprocessor
24
+ from document_manager import DocumentManager
25
+ from models.multimodal_ml import MultimodalEnsemble
26
+
27
+
28
+ class MedicalKnowledgeBase:
29
+ """
30
+ Knowledge base containing medical information about Parkinson's disease.
31
+ """
32
+
33
+ def __init__(self):
34
+ self.disease_info = {
35
+ "HC": {
36
+ "name": "Healthy Control",
37
+ "description": "No signs of Parkinson's disease or related movement disorders",
38
+ "characteristics": [
39
+ "Normal motor function",
40
+ "No tremor, rigidity, or bradykinesia",
41
+ "Normal cognitive function",
42
+ "No family history of Parkinson's disease",
43
+ ],
44
+ "recommendations": [
45
+ "Continue regular health monitoring",
46
+ "Maintain active lifestyle",
47
+ "Regular exercise and healthy diet",
48
+ "Monitor for any changes in motor function",
49
+ ],
50
+ },
51
+ "PD": {
52
+ "name": "Parkinson's Disease",
53
+ "description": "Diagnosed with Parkinson's disease showing characteristic motor symptoms",
54
+ "characteristics": [
55
+ "Presence of bradykinesia (slowness of movement)",
56
+ "Resting tremor",
57
+ "Muscle rigidity",
58
+ "Postural instability",
59
+ "Possible non-motor symptoms",
60
+ ],
61
+ "recommendations": [
62
+ "Regular neurological follow-up",
63
+ "Consider dopaminergic medication",
64
+ "Physical therapy and exercise",
65
+ "Speech therapy if needed",
66
+ "Monitor for medication side effects",
67
+ ],
68
+ },
69
+ "SWEDD": {
70
+ "name": "Scans Without Evidence of Dopaminergic Deficit",
71
+ "description": "Patients with parkinsonian symptoms but normal dopamine transporter imaging",
72
+ "characteristics": [
73
+ "Parkinsonian symptoms present",
74
+ "Normal dopamine transporter scans",
75
+ "May have tremor without other cardinal signs",
76
+ "Often responsive to dopaminergic therapy initially",
77
+ "Better long-term prognosis than typical PD",
78
+ ],
79
+ "recommendations": [
80
+ "Careful clinical monitoring and re-evaluation",
81
+ "Consider alternative diagnoses (essential tremor, drug-induced parkinsonism)",
82
+ "Regular follow-up to monitor symptom progression",
83
+ "Reassess need for dopaminergic medication",
84
+ "Consider genetic testing if family history present",
85
+ ],
86
+ },
87
+ "PRODROMAL": {
88
+ "name": "Prodromal Parkinson's Disease",
89
+ "description": "Early stage with subtle symptoms that may precede clinical PD",
90
+ "characteristics": [
91
+ "Subtle motor signs",
92
+ "REM sleep behavior disorder",
93
+ "Hyposmia (reduced sense of smell)",
94
+ "Mild cognitive changes",
95
+ "Possible depression or anxiety",
96
+ ],
97
+ "recommendations": [
98
+ "Close monitoring for symptom progression",
99
+ "Lifestyle modifications (exercise, diet)",
100
+ "Sleep study if REM sleep disorder suspected",
101
+ "Cognitive assessment",
102
+ "Consider neuroprotective strategies",
103
+ ],
104
+ },
105
+ }
106
+
107
+ self.feature_interpretations = {
108
+ "age": "Patient age is a significant risk factor for Parkinson's disease",
109
+ "SEX": "Gender differences exist in PD prevalence and presentation",
110
+ "EDUCYRS": "Education level may influence cognitive reserve",
111
+ "BMI": "Body mass index can affect disease progression",
112
+ "fampd": "Family history of Parkinson's disease increases risk",
113
+ "sym_tremor": "Tremor severity assessment",
114
+ "sym_rigid": "Muscle rigidity evaluation",
115
+ "sym_brady": "Bradykinesia (slowness of movement) assessment",
116
+ "sym_posins": "Postural instability evaluation",
117
+ "rem": "REM sleep behavior disorder assessment",
118
+ "ess": "Epworth Sleepiness Scale score",
119
+ "gds": "Geriatric Depression Scale score",
120
+ "stai": "State-Trait Anxiety Inventory score",
121
+ "moca": "Montreal Cognitive Assessment score",
122
+ "clockdraw": "Clock drawing test performance",
123
+ "bjlot": "Benton Judgment of Line Orientation test",
124
+ }
125
+
126
+ self.risk_factors = {
127
+ "high_risk": [
128
+ "Advanced age (>60 years)",
129
+ "Family history of Parkinson's disease",
130
+ "Male gender",
131
+ "Exposure to pesticides or toxins",
132
+ "Head trauma history",
133
+ ],
134
+ "protective_factors": [
135
+ "Regular physical exercise",
136
+ "Caffeine consumption",
137
+ "Smoking (paradoxically protective)",
138
+ "Higher education level",
139
+ "Mediterranean diet",
140
+ ],
141
+ }
142
+
143
+
144
+ class ReportGenerator:
145
+ """
146
+ Generates comprehensive medical reports based on ML predictions and patient data.
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ knowledge_base: Optional[MedicalKnowledgeBase] = None,
152
+ docs_dir: str = "../docs",
153
+ ):
154
+ self.kb = (
155
+ knowledge_base if knowledge_base is not None else MedicalKnowledgeBase()
156
+ )
157
+ self.ensemble = None
158
+ self.preprocessor = None
159
+ self.inference_preprocessor = None
160
+
161
+ # Initialize document manager for medical literature
162
+ self.doc_manager = DocumentManager(docs_dir=docs_dir)
163
+ print(
164
+ f"Document manager initialized with {self.doc_manager.get_document_count()['total']} documents"
165
+ )
166
+
167
+ def load_models(self):
168
+ """Load trained models for prediction."""
169
+ # Get the correct model directory path
170
+ current_dir = os.path.dirname(os.path.abspath(__file__))
171
+ model_dir = os.path.join(os.path.dirname(current_dir), "models", "saved")
172
+
173
+ self.ensemble = MultimodalEnsemble()
174
+ self.ensemble.load_traditional_models(model_dir)
175
+
176
+ prep_path = os.path.join(model_dir, "traditional_preprocessor.joblib")
177
+ if os.path.exists(prep_path):
178
+ self.inference_preprocessor = joblib.load(prep_path)
179
+
180
+ inferred_input_dim = 31
181
+ try:
182
+ if self.inference_preprocessor is not None:
183
+ inferred_input_dim = len(
184
+ self.inference_preprocessor.get_feature_names_out()
185
+ )
186
+ except Exception:
187
+ pass
188
+
189
+ # Transformer checkpoints are optional; traditional models + ensemble are sufficient for inference.
190
+ if os.getenv("PD_LOAD_TRANSFORMERS", "1") == "0":
191
+ print("Skipping optional transformer model loading (PD_LOAD_TRANSFORMERS=0)")
192
+ else:
193
+ self.ensemble.load_transformer_models(
194
+ model_dir, input_dim=inferred_input_dim, num_classes=4
195
+ )
196
+
197
+ ensemble_path = os.path.join(model_dir, "multimodal_ensemble.joblib")
198
+ self.ensemble.load_ensemble(ensemble_path)
199
+
200
+ if self.ensemble.ensemble_model is None:
201
+ raise FileNotFoundError(
202
+ f"Required ensemble artifact not found at: {ensemble_path}"
203
+ )
204
+
205
+ self.preprocessor = DataPreprocessor()
206
+ print("Models loaded successfully")
207
+
208
+ def predict_patient(self, patient_data: Dict) -> Dict:
209
+ """Make predictions for a single patient."""
210
+ if self.ensemble is None:
211
+ self.load_models()
212
+
213
+ try:
214
+ # Store original patient data for report generation
215
+ self.original_patient_data = patient_data.copy()
216
+
217
+ # Create a DataFrame with the patient data
218
+ df_patient = pd.DataFrame([patient_data])
219
+
220
+ # Use fitted preprocessor from training when available
221
+ if self.inference_preprocessor is not None:
222
+ required_cols = list(
223
+ getattr(self.inference_preprocessor, "feature_names_in_", [])
224
+ )
225
+ if required_cols:
226
+ for col in required_cols:
227
+ if col not in df_patient.columns:
228
+ df_patient[col] = np.nan
229
+ df_patient = df_patient[required_cols]
230
+ X_infer = self.inference_preprocessor.transform(df_patient)
231
+ else:
232
+ # Fallback: numeric-only matrix to avoid hard failure
233
+ X_infer = df_patient.select_dtypes(include=[np.number]).fillna(0).values
234
+
235
+ # Make predictions using ensemble
236
+ if self.ensemble is None or self.ensemble.ensemble_model is None:
237
+ raise RuntimeError("Ensemble model is not loaded")
238
+ predictions, probabilities = self.ensemble.predict_ensemble(X_infer)
239
+
240
+ confidence = float(np.max(probabilities[0]))
241
+
242
+ trad_outputs, _ = self.ensemble.get_traditional_predictions(X_infer)
243
+ trans_outputs, _ = self.ensemble.get_transformer_predictions(X_infer)
244
+
245
+ trad_preds = {
246
+ model_name: int(pred[0])
247
+ for model_name, pred in trad_outputs.items()
248
+ if len(pred) > 0
249
+ }
250
+ trans_preds = {
251
+ model_name: int(pred[0])
252
+ for model_name, pred in trans_outputs.items()
253
+ if len(pred) > 0
254
+ }
255
+
256
+ return {
257
+ "ensemble_prediction": int(predictions[0]),
258
+ "ensemble_probabilities": probabilities[0],
259
+ "traditional_predictions": trad_preds,
260
+ "transformer_predictions": trans_preds,
261
+ "confidence": confidence,
262
+ "patient_data": self.original_patient_data, # Store original data for report
263
+ }
264
+
265
+ except Exception as e:
266
+ print(f"Error in prediction: {e}")
267
+ import traceback
268
+
269
+ traceback.print_exc()
270
+ # Analyze symptoms to make a more informed prediction instead of defaulting to PD
271
+ symptoms = {
272
+ "tremor": patient_data.get("sym_tremor", 0),
273
+ "rigidity": patient_data.get("sym_rigid", 0),
274
+ "bradykinesia": patient_data.get("sym_brady", 0),
275
+ "postural_instability": patient_data.get("sym_posins", 0),
276
+ "family_history": patient_data.get("fampd", 0),
277
+ "cognitive_score": patient_data.get("moca", 25),
278
+ }
279
+
280
+ # Simple rule-based classification
281
+ pd_score = 0
282
+ # Check for cardinal PD symptoms
283
+ if symptoms["tremor"] > 2:
284
+ pd_score += 2
285
+ if symptoms["rigidity"] > 2:
286
+ pd_score += 2
287
+ if symptoms["bradykinesia"] > 2:
288
+ pd_score += 2
289
+ if symptoms["postural_instability"] > 2:
290
+ pd_score += 2
291
+ if symptoms["family_history"] > 0:
292
+ pd_score += 1
293
+ if symptoms["cognitive_score"] < 24:
294
+ pd_score += 1
295
+
296
+ # Determine class based on score
297
+ if pd_score >= 6:
298
+ pred_class = 1 # PD
299
+ probs = [0.1, 0.7, 0.1, 0.1]
300
+ elif pd_score >= 4:
301
+ pred_class = 3 # Prodromal
302
+ probs = [0.2, 0.3, 0.1, 0.4]
303
+ elif pd_score >= 2:
304
+ pred_class = 2 # SWEDD
305
+ probs = [0.3, 0.1, 0.5, 0.1]
306
+ else:
307
+ pred_class = 0 # Healthy Control
308
+ probs = [0.7, 0.1, 0.1, 0.1]
309
+
310
+ return {
311
+ "ensemble_prediction": pred_class,
312
+ "ensemble_probabilities": probs,
313
+ "traditional_predictions": {
314
+ "xgboost": pred_class,
315
+ "lightgbm": pred_class,
316
+ "svm": pred_class,
317
+ },
318
+ "transformer_predictions": {
319
+ "pubmedbert": pred_class,
320
+ "biomistral": pred_class,
321
+ "clinical_t5": pred_class,
322
+ },
323
+ "confidence": max(probs),
324
+ "patient_data": self.original_patient_data,
325
+ }
326
+
327
+ def generate_clinical_summary(
328
+ self, prediction_results: Dict, patient_data: Dict
329
+ ) -> str:
330
+ """Generate clinical summary based on predictions and medical literature."""
331
+ pred_class = prediction_results["ensemble_prediction"]
332
+ confidence = prediction_results["confidence"]
333
+ probabilities = prediction_results["ensemble_probabilities"]
334
+
335
+ # Map prediction to class name - 4 classes
336
+ class_names = ["HC", "PD", "SWEDD", "PRODROMAL"]
337
+ predicted_condition = class_names[pred_class]
338
+
339
+ # Get disease information
340
+ disease_info = self.kb.disease_info[predicted_condition]
341
+
342
+ # Retrieve relevant medical literature
343
+ literature_insights = self._get_literature_insights(
344
+ predicted_condition, patient_data
345
+ )
346
+
347
+ summary = f"""
348
+ CLINICAL ASSESSMENT SUMMARY
349
+ ===========================
350
+
351
+ PRIMARY DIAGNOSIS: {disease_info["name"]}
352
+ Confidence Level: {confidence * 100:.2f}%
353
+
354
+ DIAGNOSTIC PROBABILITY DISTRIBUTION:
355
+ - Healthy Control: {probabilities[0] * 100:.2f}%
356
+ - Parkinson's Disease: {probabilities[1] * 100:.2f}%
357
+ - SWEDD: {probabilities[2] * 100:.2f}%
358
+ - Prodromal PD: {probabilities[3] * 100:.2f}%
359
+
360
+ CLINICAL DESCRIPTION:
361
+ {disease_info["description"]}
362
+
363
+ KEY CHARACTERISTICS OBSERVED:
364
+ """
365
+ for char in disease_info["characteristics"]:
366
+ summary += f"• {char}\n"
367
+
368
+ # Add insights from medical literature with better formatting
369
+ if literature_insights:
370
+ summary += f"\nINSIGHTS FROM MEDICAL LITERATURE:\n"
371
+ summary += "=" * 50 + "\n"
372
+ summary += literature_insights
373
+
374
+ return summary
375
+
376
+ def _get_literature_insights(self, condition: str, patient_data: Dict) -> str:
377
+ """Retrieve insights from medical literature relevant to the patient's condition."""
378
+ # Check if document manager has documents
379
+ if self.doc_manager.get_document_count()["total"] == 0:
380
+ return "No medical literature available. Add medical papers to enhance insights."
381
+
382
+ # Construct search query based on condition and key symptoms
383
+ query_parts = [condition]
384
+
385
+ # Always include key symptoms in query with their severity
386
+ symptoms = {
387
+ "tremor": patient_data.get("sym_tremor", 0),
388
+ "rigidity": patient_data.get("sym_rigid", 0),
389
+ "bradykinesia": patient_data.get("sym_brady", 0),
390
+ "postural instability": patient_data.get("sym_posins", 0),
391
+ }
392
+
393
+ # Add all symptoms with their severity to create more specific queries
394
+ for symptom, severity in symptoms.items():
395
+ if severity > 0:
396
+ query_parts.append(f"{symptom} severity:{severity}")
397
+
398
+ # Add cognitive and psychiatric factors
399
+ if "moca" in patient_data:
400
+ moca = patient_data.get("moca", 30)
401
+ if moca < 26:
402
+ query_parts.append("cognitive impairment")
403
+ if moca < 20:
404
+ query_parts.append("severe cognitive impairment")
405
+
406
+ if "gds" in patient_data and patient_data.get("gds", 0) > 5:
407
+ query_parts.append("depression")
408
+
409
+ if "stai" in patient_data and patient_data.get("stai", 0) > 40:
410
+ query_parts.append("anxiety")
411
+
412
+ # Add demographic factors if available
413
+ if "age" in patient_data:
414
+ age = patient_data["age"]
415
+ query_parts.append(f"age {age}")
416
+ if age < 50:
417
+ query_parts.append("early onset")
418
+ elif age > 70:
419
+ query_parts.append("elderly")
420
+
421
+ if "SEX" in patient_data:
422
+ gender = "male" if patient_data["SEX"] == 1 else "female"
423
+ query_parts.append(gender)
424
+
425
+ # Add family history if present
426
+ if patient_data.get("fampd", 0) > 0:
427
+ query_parts.append("family history")
428
+
429
+ # Construct final query
430
+ query = " ".join(query_parts)
431
+
432
+ # Retrieve relevant passages with increased number of results
433
+ passages = self.doc_manager.extract_relevant_passages(query, top_k=5)
434
+
435
+ if not passages:
436
+ return "No specific literature found for this patient's condition and symptoms."
437
+
438
+ # Format insights with proper citations and more context
439
+ insights = ""
440
+ for i, passage in enumerate(passages):
441
+ # Extract document metadata for citation
442
+ doc_title = passage["doc_title"]
443
+ # Format the citation properly
444
+ citation = f"[{doc_title}]"
445
+
446
+ # Add the passage with citation
447
+ insights += f"{i + 1}. From '{doc_title}': {passage['text'][:400]}...\n {citation}\n\n"
448
+
449
+ return insights
450
+
451
+ def generate_feature_analysis(self, patient_data: Dict) -> str:
452
+ """Generate analysis of key patient features."""
453
+ # Use stored original patient data if available
454
+ if hasattr(self, "original_patient_data"):
455
+ patient_data = self.original_patient_data
456
+
457
+ analysis = "\nFEATURE ANALYSIS:\n" + "=" * 50 + "\n"
458
+
459
+ # Expanded key features list with better labels
460
+ key_features = [
461
+ ("age", "Age"),
462
+ ("SEX", "Gender"),
463
+ ("EDUCYRS", "Education Years"),
464
+ ("BMI", "Body Mass Index"),
465
+ ("fampd", "Family History"),
466
+ ("sym_tremor", "Tremor Severity"),
467
+ ("sym_rigid", "Rigidity"),
468
+ ("sym_brady", "Bradykinesia"),
469
+ ("sym_posins", "Postural Instability"),
470
+ ("moca", "MoCA Score"),
471
+ ("gds", "Depression Score"),
472
+ ("stai", "Anxiety Score"),
473
+ ("ess", "Sleepiness Scale"),
474
+ ("rem", "REM Sleep Behavior"),
475
+ ]
476
+
477
+ for feature_key, feature_name in key_features:
478
+ if feature_key in patient_data:
479
+ value = patient_data[feature_key]
480
+ interpretation = self.kb.feature_interpretations.get(feature_key, "")
481
+
482
+ # Format value based on feature type
483
+ if feature_key == "age":
484
+ risk_level = (
485
+ "High" if value > 60 else "Moderate" if value > 50 else "Low"
486
+ )
487
+ analysis += (
488
+ f"• {feature_name} ({value} years): Risk level: {risk_level}\n"
489
+ )
490
+ elif feature_key == "SEX":
491
+ formatted_value = "Male" if value == 1 else "Female"
492
+ analysis += f"• {feature_name}: {formatted_value}\n"
493
+ elif feature_key == "moca":
494
+ cognitive_status = (
495
+ "Normal"
496
+ if value >= 26
497
+ else "Mild impairment"
498
+ if value >= 22
499
+ else "Significant impairment"
500
+ )
501
+ analysis += (
502
+ f"• {feature_name} ({value}/30): Status: {cognitive_status}\n"
503
+ )
504
+ elif feature_key == "fampd":
505
+ family_history = "Positive" if value > 0 else "Negative"
506
+ analysis += (
507
+ f"• {feature_name}: {family_history} for Parkinson's disease\n"
508
+ )
509
+ elif feature_key in [
510
+ "sym_tremor",
511
+ "sym_rigid",
512
+ "sym_brady",
513
+ "sym_posins",
514
+ ]:
515
+ severity = ["None", "Mild", "Moderate", "Severe", "Very Severe"]
516
+ formatted_value = severity[min(int(value), 4)]
517
+ analysis += f"• {feature_name}: {formatted_value}\n"
518
+ elif feature_key == "gds":
519
+ status = "Normal" if value <= 5 else "Depression indicated"
520
+ analysis += f"• {feature_name}: {value} - {status}\n"
521
+ elif feature_key == "stai":
522
+ status = "Normal" if value <= 40 else "Anxiety indicated"
523
+ analysis += f"• {feature_name}: {value} - {status}\n"
524
+ else:
525
+ analysis += f"• {feature_name}: {value}\n"
526
+
527
+ # Add interpretation if available and not already added
528
+ if interpretation and feature_key not in ["age", "moca", "fampd"]:
529
+ analysis = analysis.rstrip("\n") + f" - {interpretation}\n"
530
+
531
+ return analysis
532
+
533
+ def generate_recommendations(
534
+ self, prediction_results: Dict, patient_data: Dict
535
+ ) -> str:
536
+ """Generate clinical recommendations."""
537
+ # Use stored original patient data if available
538
+ if hasattr(self, "original_patient_data"):
539
+ patient_data = self.original_patient_data
540
+
541
+ pred_class = prediction_results["ensemble_prediction"]
542
+ class_names = ["HC", "PD", "SWEDD", "PRODROMAL"]
543
+ predicted_condition = class_names[pred_class]
544
+
545
+ disease_info = self.kb.disease_info[predicted_condition]
546
+
547
+ recommendations = "\nCLINICAL RECOMMENDATIONS:\n" + "=" * 50 + "\n"
548
+
549
+ for i, rec in enumerate(disease_info["recommendations"], 1):
550
+ recommendations += f"{i}. {rec}\n"
551
+
552
+ # Add general recommendations based on risk factors
553
+ recommendations += "\nADDITIONAL CONSIDERATIONS:\n"
554
+
555
+ if patient_data.get("age", 0) > 60:
556
+ recommendations += (
557
+ "• Age-related monitoring: Increased surveillance due to advanced age\n"
558
+ )
559
+
560
+ if patient_data.get("fampd", 0) > 0:
561
+ recommendations += (
562
+ "• Genetic counseling: Consider due to positive family history\n"
563
+ )
564
+
565
+ if patient_data.get("moca", 30) < 26:
566
+ recommendations += "• Cognitive assessment: Follow-up neuropsychological testing recommended\n"
567
+
568
+ if patient_data.get("gds", 0) > 5:
569
+ recommendations += "• Depression management: Consider psychiatric evaluation and treatment\n"
570
+
571
+ if patient_data.get("stai", 0) > 40:
572
+ recommendations += (
573
+ "• Anxiety management: Consider psychiatric evaluation and treatment\n"
574
+ )
575
+
576
+ if patient_data.get("ess", 0) > 10:
577
+ recommendations += "• Sleep evaluation: Consider sleep study for excessive daytime sleepiness\n"
578
+
579
+ if patient_data.get("rem", 0) > 0:
580
+ recommendations += "• REM sleep behavior disorder: Consider polysomnography and treatment\n"
581
+
582
+ return recommendations
583
+
584
+ def generate_model_consensus(self, prediction_results: Dict) -> str:
585
+ """Generate analysis of model consensus."""
586
+ trad_preds = prediction_results["traditional_predictions"]
587
+ trans_preds = prediction_results["transformer_predictions"]
588
+ ensemble_pred = prediction_results["ensemble_prediction"]
589
+
590
+ consensus = "\nMODEL CONSENSUS ANALYSIS:\n" + "=" * 50 + "\n"
591
+
592
+ # Check agreement between models
593
+ all_predictions = (
594
+ list(trad_preds.values()) + list(trans_preds.values()) + [ensemble_pred]
595
+ )
596
+ unique_predictions = set(all_predictions)
597
+
598
+ if len(all_predictions) == 1:
599
+ consensus += "• LIMITED CONSENSUS: Only the ensemble model was available at inference time\n"
600
+ elif len(unique_predictions) == 1:
601
+ consensus += "• STRONG CONSENSUS: All models agree on the diagnosis\n"
602
+ elif len(unique_predictions) == 2:
603
+ consensus += "• MODERATE CONSENSUS: Most models agree with some variation\n"
604
+ else:
605
+ consensus += "• WEAK CONSENSUS: Significant disagreement between models\n"
606
+
607
+ consensus += f"\nIndividual Model Predictions:\n"
608
+ if trad_preds:
609
+ consensus += "Traditional Machine Learning Models:\n"
610
+ for model, pred in trad_preds.items():
611
+ class_names = ["HC", "PD", "SWEDD", "PRODROMAL"]
612
+ consensus += f" • {model.upper()}: {class_names[pred]}\n"
613
+ else:
614
+ consensus += "Traditional Machine Learning Models:\n • No traditional base-model outputs were available\n"
615
+
616
+ consensus += "\nMedical Transformer Models:\n"
617
+ if trans_preds:
618
+ for model, pred in trans_preds.items():
619
+ class_names = ["HC", "PD", "SWEDD", "PRODROMAL"]
620
+ model_display = model.replace("_", " ").title()
621
+ if model == "pubmedbert":
622
+ model_display = "PubMedBERT (Encoder)"
623
+ elif model == "biomistral":
624
+ model_display = "BioMistral (Decoder)"
625
+ elif model == "clinical_t5":
626
+ model_display = "Clinical-T5 (Encoder-Decoder)"
627
+ elif model == "feedforward":
628
+ model_display = "Feedforward Tabular Network"
629
+ consensus += f" • {model_display}: {class_names[pred]}\n"
630
+ else:
631
+ consensus += " • No transformer base-model outputs were available\n"
632
+
633
+ return consensus
634
+
635
+ def generate_full_report(self, patient_data: Dict, patient_id: str = None) -> str:
636
+ """Generate a comprehensive medical report."""
637
+ if patient_id is None:
638
+ patient_id = f"PATIENT_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
639
+ else:
640
+ patient_id = str(patient_id)
641
+
642
+ # Make predictions
643
+ prediction_results = self.predict_patient(patient_data)
644
+
645
+ # Generate report sections
646
+ header = f"""
647
+ PARKINSON'S DISEASE ASSESSMENT REPORT
648
+ =====================================
649
+ Patient ID: {patient_id}
650
+ Report Date: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
651
+ Generated by: AI-Powered Multimodal ML System
652
+ """
653
+
654
+ clinical_summary = self.generate_clinical_summary(
655
+ prediction_results, patient_data
656
+ )
657
+ feature_analysis = self.generate_feature_analysis(patient_data)
658
+ recommendations = self.generate_recommendations(
659
+ prediction_results, patient_data
660
+ )
661
+ model_consensus = self.generate_model_consensus(prediction_results)
662
+
663
+ footer = f"""
664
+ DISCLAIMER:
665
+ ===========
666
+ This report is generated by an AI system for research and educational purposes.
667
+ It should not replace professional medical diagnosis or treatment decisions.
668
+ Always consult with qualified healthcare professionals for medical advice.
669
+
670
+ Report generated using multimodal machine learning with {prediction_results["confidence"] * 100:.2f}% confidence.
671
+ """
672
+
673
+ full_report = (
674
+ header
675
+ + clinical_summary
676
+ + feature_analysis
677
+ + recommendations
678
+ + model_consensus
679
+ + footer
680
+ )
681
+
682
+ return full_report
683
+
684
+ def save_report(self, report: str, filename: Optional[str] = None) -> str:
685
+ """Save report to file using an absolute path under the project reports directory."""
686
+ if filename is None:
687
+ filename = f"medical_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
688
+ else:
689
+ filename = str(filename)
690
+
691
+ safe_name = Path(filename).name.strip()
692
+ safe_name = "".join(
693
+ ch if ch.isalnum() or ch in "._- " else "_" for ch in safe_name
694
+ ).strip(" .")
695
+ if not safe_name:
696
+ safe_name = f"medical_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
697
+ if not safe_name.lower().endswith(".txt"):
698
+ safe_name = f"{safe_name}.txt"
699
+
700
+ project_root = Path(__file__).resolve().parents[1]
701
+ reports_dir = project_root / "reports"
702
+ reports_dir.mkdir(parents=True, exist_ok=True)
703
+
704
+ filepath = reports_dir / safe_name
705
+
706
+ with open(filepath, "w", encoding="utf-8") as f:
707
+ f.write(report)
708
+
709
+ resolved_path = str(filepath.resolve())
710
+ print(f"Report saved to: {resolved_path}")
711
+ return resolved_path
712
+
713
+
714
+ def demo_report_generation():
715
+ """Demonstrate report generation with sample patient data."""
716
+ print("RAG System Demo - Generating Sample Medical Report")
717
+ print("=" * 60)
718
+
719
+ # Sample patient data
720
+ sample_patient = {
721
+ "age": 65,
722
+ "SEX": 1, # Male
723
+ "EDUCYRS": 16,
724
+ "race": 1,
725
+ "BMI": 26.5,
726
+ "fampd": 1, # Positive family history
727
+ "fampd_bin": 1,
728
+ "sym_tremor": 2,
729
+ "sym_rigid": 1,
730
+ "sym_brady": 2,
731
+ "sym_posins": 1,
732
+ "rem": 1,
733
+ "ess": 8,
734
+ "gds": 3,
735
+ "stai": 35,
736
+ "moca": 24,
737
+ "clockdraw": 3,
738
+ "bjlot": 25,
739
+ }
740
+
741
+ # Initialize report generator
742
+ report_gen = ReportGenerator()
743
+
744
+ try:
745
+ # Generate report
746
+ report = report_gen.generate_full_report(sample_patient, "DEMO_PATIENT_001")
747
+
748
+ # Print report
749
+ print(report)
750
+
751
+ # Save report
752
+ filepath = report_gen.save_report(report, "demo_medical_report.txt")
753
+
754
+ return report, filepath
755
+
756
+ except Exception as e:
757
+ print(f"Error generating report: {e}")
758
+ return None, None
759
+
760
+
761
+ if __name__ == "__main__":
762
+ demo_report_generation()
src/risk_stratifier.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Risk Stratifier — MDS Prodromal Markers + Bootstrap CI.
3
+
4
+ Maps available PPMI features to MDS prodromal markers, computes a
5
+ risk score, and produces bootstrap confidence intervals.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any, Dict, List, Optional
12
+
13
+ import numpy as np
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ MDS_MARKERS = {
18
+ "rem": {"weight": 2.5, "threshold_fn": "binary"},
19
+ "upsit": {"weight": 2.0, "threshold_fn": "upsit_low"},
20
+ "pigd": {"weight": 1.5, "threshold_fn": "pigd_present"},
21
+ "gds": {"weight": 1.2, "threshold_fn": "gds_high"},
22
+ "fampd_bin": {"weight": 1.0, "threshold_fn": "binary"},
23
+ }
24
+
25
+
26
+ def _check_marker(name: str, value: Optional[float]) -> Optional[float]:
27
+ if value is None:
28
+ return None
29
+ spec = MDS_MARKERS.get(name)
30
+ if spec is None:
31
+ return None
32
+ fn, w = spec["threshold_fn"], spec["weight"]
33
+ if fn == "binary":
34
+ return w if value >= 1.0 else 0.0
35
+ if fn == "upsit_low":
36
+ return w if value <= 22.0 else 0.0
37
+ if fn == "pigd_present":
38
+ return w if value >= 1.0 else 0.0
39
+ if fn == "gds_high":
40
+ return w if value >= 5.0 else 0.0
41
+ return 0.0
42
+
43
+
44
+ class RiskStratifier:
45
+ """MDS criteria risk stratification with bootstrap CIs."""
46
+
47
+ def __init__(self, n_bootstrap: int = 100) -> None:
48
+ self.n_bootstrap = n_bootstrap
49
+
50
+ def stratify(self, patient_data: Dict[str, Any]) -> Dict[str, Any]:
51
+ marker_values: Dict[str, Optional[float]] = {}
52
+ for marker in MDS_MARKERS:
53
+ marker_values[marker] = self._extract(patient_data, marker)
54
+
55
+ contributions: Dict[str, Optional[float]] = {}
56
+ available: List[str] = []
57
+ for m, v in marker_values.items():
58
+ s = _check_marker(m, v)
59
+ contributions[m] = s
60
+ if s is not None:
61
+ available.append(m)
62
+
63
+ total = sum(v for v in contributions.values() if v is not None)
64
+ max_p = sum(MDS_MARKERS[m]["weight"] for m in available) if available else 1.0
65
+ raw_conf = total / max(max_p, 1e-6)
66
+ category = self._categorize(raw_conf, patient_data)
67
+ ci_lo, ci_hi = self._bootstrap_ci(marker_values, available)
68
+
69
+ return {
70
+ "category": category,
71
+ "confidence": round(float(raw_conf), 4),
72
+ "ci_lower": round(float(ci_lo), 4),
73
+ "ci_upper": round(float(ci_hi), 4),
74
+ "marker_contributions": contributions,
75
+ "total_score": round(float(total), 4),
76
+ "max_possible_score": round(float(max_p), 4),
77
+ }
78
+
79
+ def _bootstrap_ci(self, marker_values, available):
80
+ if not available:
81
+ return (0.0, 0.0)
82
+ rng = np.random.RandomState(42)
83
+ weights = np.array([MDS_MARKERS[m]["weight"] for m in available])
84
+ base = np.array([_check_marker(m, marker_values[m]) or 0.0 for m in available])
85
+ scores = []
86
+ for _ in range(self.n_bootstrap):
87
+ idx = rng.choice(len(available), size=len(available), replace=True)
88
+ conf = base[idx].sum() / max(weights[idx].sum(), 1e-6)
89
+ conf += rng.normal(0, 0.02)
90
+ scores.append(float(np.clip(conf, 0, 1)))
91
+ return (float(np.percentile(scores, 2.5)), float(np.percentile(scores, 97.5)))
92
+
93
+ def _categorize(self, confidence, patient_data):
94
+ sym_total = sum(
95
+ float(patient_data.get(s) or (patient_data.get("motor", {}) or {}).get(s) or 0)
96
+ for s in ("sym_tremor", "sym_rigid", "sym_brady", "sym_posins")
97
+ )
98
+ updrs3 = (patient_data.get("motor", {}) or {}).get("updrs3_score") or patient_data.get("updrs3_score")
99
+ if confidence >= 0.55 or sym_total >= 5 or (updrs3 is not None and float(updrs3) >= 20):
100
+ return "PD"
101
+ if confidence >= 0.30:
102
+ return "Prodromal PD"
103
+ if confidence >= 0.15:
104
+ return "SWEDD"
105
+ return "HC"
106
+
107
+ @staticmethod
108
+ def _extract(data: Dict[str, Any], marker: str) -> Optional[float]:
109
+ val = data.get(marker)
110
+ if val is not None:
111
+ try:
112
+ return float(val)
113
+ except (TypeError, ValueError):
114
+ pass
115
+ for sec in ("non_motor", "motor", "cognition", "autonomic"):
116
+ sub = data.get(sec)
117
+ if isinstance(sub, dict) and marker in sub and sub[marker] is not None:
118
+ try:
119
+ return float(sub[marker])
120
+ except (TypeError, ValueError):
121
+ pass
122
+ return None
src/static/css/label-fix.css ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Fix for label visibility - MAXIMUM CONTRAST */
2
+ .form-label {
3
+ color: #000000 !important;
4
+ background-color: #ffffff !important;
5
+ font-weight: 700 !important;
6
+ display: inline-block !important;
7
+ margin-bottom: 0.5rem !important;
8
+ padding: 2px 5px !important;
9
+ border-radius: 3px !important;
10
+ box-shadow: 0 0 3px rgba(0,0,0,0.3) !important;
11
+ font-size: 1.1em !important;
12
+ border: 1px solid #000 !important;
13
+ }
14
+
15
+ /* Fix for dark mode label visibility */
16
+ [data-theme="dark"] .form-label {
17
+ color: #ffffff !important;
18
+ background-color: #333333 !important;
19
+ border: 1px solid #fff !important;
20
+ box-shadow: 0 0 3px rgba(255,255,255,0.5) !important;
21
+ }
22
+
23
+ /* Make sure select options are visible */
24
+ select.form-control option {
25
+ background-color: #ffffff !important;
26
+ color: #000000 !important;
27
+ font-weight: 500 !important;
28
+ }
29
+
30
+ /* Improve form control visibility */
31
+ .form-control {
32
+ border: 2px solid #555 !important;
33
+ background-color: #ffffff !important;
34
+ color: #000000 !important;
35
+ font-weight: 500 !important;
36
+ }
37
+
38
+ /* Dark mode form controls */
39
+ [data-theme="dark"] .form-control {
40
+ background-color: #444 !important;
41
+ color: #fff !important;
42
+ border: 2px solid #777 !important;
43
+ }
src/static/css/styles.css ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --primary-color: #4a90e2;
3
+ --secondary-color: #2c3e50;
4
+ --accent-color: #e74c3c;
5
+ --background-color: #f8f9fa;
6
+ --text-color: #333;
7
+ --border-color: #ddd;
8
+ --success-color: #2ecc71;
9
+ --warning-color: #f39c12;
10
+ --danger-color: #e74c3c;
11
+ --card-bg-color: #ffffff;
12
+ --navbar-bg-color: #2c3e50;
13
+ --navbar-text-color: #ffffff;
14
+ --input-bg-color: #ffffff;
15
+ --shadow-color: rgba(0,0,0,0.1);
16
+ }
17
+
18
+ [data-theme="dark"] {
19
+ --primary-color: #61a0ff;
20
+ --secondary-color: #34495e;
21
+ --accent-color: #ff6b6b;
22
+ --background-color: #121212;
23
+ --text-color: #f5f5f5;
24
+ --border-color: #444;
25
+ --success-color: #4cd787;
26
+ --warning-color: #ffb74d;
27
+ --danger-color: #ff5252;
28
+ --card-bg-color: #1e1e1e;
29
+ --navbar-bg-color: #1a1a2e;
30
+ --navbar-text-color: #f0f0f0;
31
+ --input-bg-color: #2a2a2a;
32
+ --shadow-color: rgba(255,255,255,0.05);
33
+ }
34
+
35
+ body {
36
+ font-family: 'Roboto', sans-serif;
37
+ line-height: 1.6;
38
+ color: var(--text-color);
39
+ background-color: var(--background-color);
40
+ transition: all 0.3s ease;
41
+ margin: 0;
42
+ padding: 0;
43
+ min-height: 100vh;
44
+ }
45
+
46
+ /* Tooltip styles */
47
+ .tooltip {
48
+ position: relative;
49
+ display: inline-block;
50
+ border-bottom: 1px dotted var(--primary-color);
51
+ cursor: help;
52
+ }
53
+
54
+ .tooltip .tooltiptext {
55
+ visibility: hidden;
56
+ width: 250px;
57
+ background-color: var(--secondary-color);
58
+ color: white;
59
+ text-align: center;
60
+ border-radius: 6px;
61
+ padding: 10px;
62
+ position: absolute;
63
+ z-index: 1000;
64
+ bottom: 125%;
65
+ left: 50%;
66
+ margin-left: -125px;
67
+ opacity: 0;
68
+ transition: opacity 0.3s;
69
+ font-size: 0.9rem;
70
+ box-shadow: 0 5px 15px rgba(0,0,0,0.2);
71
+ }
72
+
73
+ .tooltip .tooltiptext::after {
74
+ content: "";
75
+ position: absolute;
76
+ top: 100%;
77
+ left: 50%;
78
+ margin-left: -5px;
79
+ border-width: 5px;
80
+ border-style: solid;
81
+ border-color: var(--secondary-color) transparent transparent transparent;
82
+ }
83
+
84
+ .tooltip:hover .tooltiptext {
85
+ visibility: visible;
86
+ opacity: 1;
87
+ }
88
+
89
+ .container {
90
+ max-width: 1200px;
91
+ margin: 0 auto;
92
+ padding: 20px;
93
+ }
94
+
95
+ .navbar {
96
+ background-color: var(--navbar-bg-color);
97
+ color: var(--navbar-text-color);
98
+ padding: 15px 0;
99
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
100
+ width: 100%;
101
+ }
102
+
103
+ .navbar-brand {
104
+ font-size: 1.5rem;
105
+ font-weight: bold;
106
+ color: var(--navbar-text-color);
107
+ text-decoration: none;
108
+ padding: 0 20px;
109
+ }
110
+
111
+ .navbar-nav {
112
+ display: flex;
113
+ list-style: none;
114
+ margin: 0;
115
+ padding: 0;
116
+ }
117
+
118
+ .nav-item {
119
+ margin-right: 20px;
120
+ }
121
+
122
+ .nav-link {
123
+ color: white;
124
+ text-decoration: none;
125
+ }
126
+
127
+ .card {
128
+ background-color: var(--card-bg-color);
129
+ border: 1px solid var(--border-color);
130
+ border-radius: 8px;
131
+ padding: 25px;
132
+ margin-bottom: 25px;
133
+ box-shadow: 0 4px 12px var(--shadow-color);
134
+ transition: transform 0.3s ease, box-shadow 0.3s ease;
135
+ }
136
+
137
+ .card:hover {
138
+ transform: translateY(-5px);
139
+ box-shadow: 0 8px 15px var(--shadow-color);
140
+ }
141
+
142
+ .btn {
143
+ display: inline-block;
144
+ padding: 10px 20px;
145
+ border: none;
146
+ border-radius: 6px;
147
+ cursor: pointer;
148
+ text-decoration: none;
149
+ font-size: 1rem;
150
+ font-weight: 500;
151
+ transition: all 0.3s ease;
152
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1);
153
+ text-align: center;
154
+ }
155
+
156
+ .btn:hover {
157
+ transform: translateY(-2px);
158
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
159
+ }
160
+
161
+ .btn-primary {
162
+ background-color: var(--primary-color);
163
+ color: white;
164
+ }
165
+
166
+ .btn-primary:hover {
167
+ background-color: var(--primary-color);
168
+ filter: brightness(110%);
169
+ }
170
+
171
+ .btn-secondary {
172
+ background-color: var(--secondary-color);
173
+ color: white;
174
+ }
175
+
176
+ .btn-secondary:hover {
177
+ background-color: var(--secondary-color);
178
+ filter: brightness(110%);
179
+ }
180
+
181
+ .btn-danger {
182
+ background-color: var(--danger-color);
183
+ color: white;
184
+ }
185
+
186
+ .btn-danger:hover {
187
+ background-color: var(--danger-color);
188
+ filter: brightness(110%);
189
+ }
190
+
191
+ .form-group {
192
+ margin-bottom: 15px;
193
+ }
194
+
195
+ .form-control {
196
+ width: 100%;
197
+ padding: 12px;
198
+ border: 1px solid var(--border-color);
199
+ border-radius: 6px;
200
+ background-color: var(--input-bg-color);
201
+ color: var(--text-color);
202
+ transition: border-color 0.3s ease, box-shadow 0.3s ease;
203
+ }
204
+
205
+ .form-control:focus {
206
+ outline: none;
207
+ border-color: var(--primary-color);
208
+ box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.25);
209
+ }
210
+
211
+ .tooltip {
212
+ position: relative;
213
+ display: inline-block;
214
+ cursor: help;
215
+ }
216
+
217
+ .tooltip .tooltiptext {
218
+ visibility: hidden;
219
+ width: 200px;
220
+ background-color: var(--secondary-color);
221
+ color: white;
222
+ text-align: center;
223
+ border-radius: 6px;
224
+ padding: 5px;
225
+ position: absolute;
226
+ z-index: 1;
227
+ bottom: 125%;
228
+ left: 50%;
229
+ margin-left: -100px;
230
+ opacity: 0;
231
+ transition: opacity 0.3s;
232
+ }
233
+
234
+ .tooltip:hover .tooltiptext {
235
+ visibility: visible;
236
+ opacity: 1;
237
+ }
238
+
239
+ .theme-toggle {
240
+ background: none;
241
+ border: none;
242
+ color: var(--navbar-text-color);
243
+ cursor: pointer;
244
+ font-size: 1.2rem;
245
+ margin-left: auto;
246
+ padding: 8px 12px;
247
+ border-radius: 50%;
248
+ transition: all 0.3s ease;
249
+ }
250
+
251
+ .theme-toggle:hover {
252
+ background-color: rgba(255, 255, 255, 0.1);
253
+ transform: rotate(15deg);
254
+ }
255
+
256
+ /* Dark mode specific styles */
257
+ [data-theme="dark"] .card {
258
+ box-shadow: 0 2px 5px rgba(255,255,255,0.05);
259
+ }
260
+
261
+ [data-theme="dark"] .form-control {
262
+ background-color: #333;
263
+ color: var(--text-color);
264
+ border-color: #555;
265
+ }
266
+
267
+ [data-theme="dark"] .table {
268
+ color: var(--text-color);
269
+ }
270
+
271
+ [data-theme="dark"] .table th,
272
+ [data-theme="dark"] .table td {
273
+ border-color: #444;
274
+ }
src/static/js/darkmode.js ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', function() {
2
+ const themeToggle = document.getElementById('theme-toggle');
3
+ const htmlElement = document.documentElement;
4
+
5
+ // Check for saved theme preference or use device preference
6
+ const savedTheme = localStorage.getItem('theme') ||
7
+ (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
8
+
9
+ // Apply saved theme
10
+ htmlElement.setAttribute('data-theme', savedTheme);
11
+ updateToggleIcon(savedTheme);
12
+
13
+ // Toggle theme when button is clicked
14
+ themeToggle.addEventListener('click', function() {
15
+ const currentTheme = htmlElement.getAttribute('data-theme');
16
+ const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
17
+
18
+ htmlElement.setAttribute('data-theme', newTheme);
19
+ localStorage.setItem('theme', newTheme);
20
+ updateToggleIcon(newTheme);
21
+ });
22
+
23
+ // Update toggle icon based on current theme
24
+ function updateToggleIcon(theme) {
25
+ if (theme === 'dark') {
26
+ themeToggle.innerHTML = '<i class="fas fa-sun"></i>';
27
+ themeToggle.title = 'Switch to light mode';
28
+ } else {
29
+ themeToggle.innerHTML = '<i class="fas fa-moon"></i>';
30
+ themeToggle.title = 'Switch to dark mode';
31
+ }
32
+ }
33
+
34
+ // Initialize tooltips
35
+ initializeTooltips();
36
+ });
37
+
38
+ function initializeTooltips() {
39
+ const tooltipElements = document.querySelectorAll('[data-tooltip]');
40
+
41
+ tooltipElements.forEach(element => {
42
+ const tooltipText = element.getAttribute('data-tooltip');
43
+ const tooltipSpan = document.createElement('span');
44
+ tooltipSpan.className = 'tooltiptext';
45
+ tooltipSpan.textContent = tooltipText;
46
+
47
+ element.classList.add('tooltip');
48
+ element.appendChild(tooltipSpan);
49
+ });
50
+ }
src/train_model_suite.py ADDED
@@ -0,0 +1,1382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import warnings
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
10
+
11
+ import joblib
12
+ import numpy as np
13
+ import pandas as pd
14
+ import torch
15
+ from lightgbm import LGBMClassifier
16
+ from scipy import sparse
17
+ from sklearn.metrics import (
18
+ accuracy_score,
19
+ classification_report,
20
+ confusion_matrix,
21
+ f1_score,
22
+ precision_recall_fscore_support,
23
+ roc_auc_score,
24
+ )
25
+ from sklearn.model_selection import GroupKFold, GroupShuffleSplit
26
+ from sklearn.utils.class_weight import compute_class_weight
27
+ from sklearn.svm import SVC
28
+ from torch.utils.data import DataLoader
29
+ from tqdm.auto import tqdm
30
+ from xgboost import XGBClassifier
31
+
32
+ sys.path.append(str(Path(__file__).resolve().parent))
33
+
34
+ from data_preprocessing import DataPreprocessor # type: ignore
35
+ from training_runtime import PauseRequested, StopRequested, TrainingRunController # type: ignore
36
+
37
+
38
+ warnings.filterwarnings(
39
+ "ignore",
40
+ message="X does not have valid feature names, but LGBMClassifier was fitted with feature names",
41
+ )
42
+
43
+
44
+ ROOT = Path(__file__).resolve().parents[1]
45
+ MODEL_DIR = ROOT / "models" / "saved"
46
+ RUNS_DIR = MODEL_DIR / "training_runs"
47
+ EVAL_DIR = ROOT / "evaluation_results"
48
+ MODEL_METRICS_DIR = EVAL_DIR / "model_metrics"
49
+ CLASS_NAMES_DEFAULT = ["HC", "PD", "SWEDD", "PRODROMAL"]
50
+ TRADITIONAL_MODELS = ("lightgbm", "xgboost", "svm")
51
+ TRANSFORMER_MODELS = ("pubmedbert", "biogpt", "clinical_t5")
52
+ ALL_BASE_MODELS = TRADITIONAL_MODELS + TRANSFORMER_MODELS
53
+ TRANSFORMER_SELECTION_METRIC = "val_f1"
54
+ DEFAULT_TRANSFORMER_LOSS = "focal"
55
+ DEFAULT_FOCAL_GAMMA = 1.5
56
+
57
+
58
+ TRADITIONAL_SEARCH_SPACES: Dict[str, List[Dict[str, Any]]] = {
59
+ "lightgbm": [
60
+ {"n_estimators": 350, "learning_rate": 0.03, "max_depth": -1, "num_leaves": 31, "min_child_samples": 20, "subsample": 0.90, "colsample_bytree": 0.90},
61
+ {"n_estimators": 500, "learning_rate": 0.02, "max_depth": -1, "num_leaves": 63, "min_child_samples": 25, "subsample": 0.85, "colsample_bytree": 0.85},
62
+ {"n_estimators": 450, "learning_rate": 0.025, "max_depth": 10, "num_leaves": 47, "min_child_samples": 15, "subsample": 0.90, "colsample_bytree": 0.80},
63
+ {"n_estimators": 650, "learning_rate": 0.015, "max_depth": 12, "num_leaves": 95, "min_child_samples": 30, "subsample": 0.80, "colsample_bytree": 0.80},
64
+ {"n_estimators": 800, "learning_rate": 0.012, "max_depth": -1, "num_leaves": 127, "min_child_samples": 20, "subsample": 0.85, "colsample_bytree": 0.80},
65
+ {"n_estimators": 420, "learning_rate": 0.03, "max_depth": 8, "num_leaves": 63, "min_child_samples": 10, "subsample": 0.95, "colsample_bytree": 0.85},
66
+ ],
67
+ "xgboost": [
68
+ {"n_estimators": 300, "learning_rate": 0.05, "max_depth": 6, "min_child_weight": 2, "subsample": 0.90, "colsample_bytree": 0.90, "gamma": 0.0, "reg_lambda": 1.0},
69
+ {"n_estimators": 450, "learning_rate": 0.03, "max_depth": 5, "min_child_weight": 1, "subsample": 0.85, "colsample_bytree": 0.85, "gamma": 0.05, "reg_lambda": 1.0},
70
+ {"n_estimators": 500, "learning_rate": 0.025, "max_depth": 7, "min_child_weight": 3, "subsample": 0.80, "colsample_bytree": 0.80, "gamma": 0.10, "reg_lambda": 1.5},
71
+ {"n_estimators": 350, "learning_rate": 0.04, "max_depth": 4, "min_child_weight": 1, "subsample": 0.95, "colsample_bytree": 0.90, "gamma": 0.0, "reg_lambda": 0.8},
72
+ {"n_estimators": 650, "learning_rate": 0.015, "max_depth": 8, "min_child_weight": 2, "subsample": 0.85, "colsample_bytree": 0.85, "gamma": 0.05, "reg_lambda": 1.2},
73
+ {"n_estimators": 520, "learning_rate": 0.02, "max_depth": 6, "min_child_weight": 4, "subsample": 0.90, "colsample_bytree": 0.80, "gamma": 0.08, "reg_lambda": 1.8},
74
+ ],
75
+ "svm": [
76
+ {"C": 6.0, "gamma": "scale", "kernel": "rbf"},
77
+ {"C": 8.0, "gamma": "scale", "kernel": "rbf"},
78
+ {"C": 10.0, "gamma": 0.01, "kernel": "rbf"},
79
+ {"C": 12.0, "gamma": 0.005, "kernel": "rbf"},
80
+ {"C": 14.0, "gamma": "scale", "kernel": "rbf"},
81
+ {"C": 16.0, "gamma": 0.003, "kernel": "rbf"},
82
+ ],
83
+ }
84
+
85
+
86
+ TRANSFORMER_TRIALS: Dict[str, List[Dict[str, Any]]] = {
87
+ "pubmedbert": [
88
+ {"model_kwargs": {"dropout": 0.10, "freeze_bert": False, "train_encoder_layers": 8}, "optimizer": {"lr": 8.0e-6, "weight_decay": 0.02}, "grad_accum": 2},
89
+ {"model_kwargs": {"dropout": 0.08, "freeze_bert": False, "train_encoder_layers": 6}, "optimizer": {"lr": 1.0e-5, "weight_decay": 0.02}, "grad_accum": 2},
90
+ {"model_kwargs": {"dropout": 0.12, "freeze_bert": False, "train_encoder_layers": 4}, "optimizer": {"lr": 1.5e-5, "weight_decay": 0.01}, "grad_accum": 2},
91
+ {"model_kwargs": {"dropout": 0.18, "freeze_bert": True}, "optimizer": {"lr": 2.5e-5, "weight_decay": 0.01}, "grad_accum": 2},
92
+ {"model_kwargs": {"dropout": 0.06, "freeze_bert": False, "train_encoder_layers": 10}, "optimizer": {"lr": 6.0e-6, "weight_decay": 0.03}, "grad_accum": 2},
93
+ {"model_kwargs": {"dropout": 0.10, "freeze_bert": False, "train_encoder_layers": 12}, "optimizer": {"lr": 5.0e-6, "weight_decay": 0.03}, "grad_accum": 1},
94
+ ],
95
+ "biogpt": [
96
+ {"model_kwargs": {"dropout": 0.10, "train_decoder_layers": 4}, "optimizer": {"lr": 3.0e-5, "weight_decay": 0.01}, "grad_accum": 8},
97
+ {"model_kwargs": {"dropout": 0.15, "train_decoder_layers": 6}, "optimizer": {"lr": 2.5e-5, "weight_decay": 0.02}, "grad_accum": 8},
98
+ {"model_kwargs": {"dropout": 0.20, "train_decoder_layers": 8}, "optimizer": {"lr": 2.0e-5, "weight_decay": 0.02}, "grad_accum": 10},
99
+ {"model_kwargs": {"dropout": 0.12, "train_decoder_layers": 10}, "optimizer": {"lr": 1.5e-5, "weight_decay": 0.02}, "grad_accum": 12},
100
+ {"model_kwargs": {"dropout": 0.10, "train_decoder_layers": 12}, "optimizer": {"lr": 1.0e-5, "weight_decay": 0.02}, "grad_accum": 8},
101
+ {"model_kwargs": {"dropout": 0.08, "train_decoder_layers": 10}, "optimizer": {"lr": 1.2e-5, "weight_decay": 0.015}, "grad_accum": 6},
102
+ ],
103
+ "clinical_t5": [
104
+ {"model_kwargs": {"dropout": 0.10, "freeze_encoder": False}, "optimizer": {"lr": 2.0e-5, "weight_decay": 0.01}, "grad_accum": 8},
105
+ {"model_kwargs": {"dropout": 0.15, "freeze_encoder": False}, "optimizer": {"lr": 1.5e-5, "weight_decay": 0.02}, "grad_accum": 8},
106
+ {"model_kwargs": {"dropout": 0.20, "freeze_encoder": True}, "optimizer": {"lr": 2.5e-5, "weight_decay": 0.01}, "grad_accum": 6},
107
+ {"model_kwargs": {"dropout": 0.08, "freeze_encoder": False}, "optimizer": {"lr": 1.0e-5, "weight_decay": 0.02}, "grad_accum": 10},
108
+ {"model_kwargs": {"dropout": 0.12, "freeze_encoder": False}, "optimizer": {"lr": 8.0e-6, "weight_decay": 0.02}, "grad_accum": 8},
109
+ {"model_kwargs": {"dropout": 0.10, "freeze_encoder": False}, "optimizer": {"lr": 1.2e-5, "weight_decay": 0.015}, "grad_accum": 6},
110
+ ],
111
+ }
112
+
113
+
114
+ @dataclass
115
+ class TrainingBundle:
116
+ X_train_dense: np.ndarray
117
+ X_test_dense: np.ndarray
118
+ y_train: np.ndarray
119
+ y_test: np.ndarray
120
+ train_groups: np.ndarray
121
+ test_groups: np.ndarray
122
+ feature_names: List[str]
123
+ class_mapping: Dict[str, int]
124
+ class_names: List[str]
125
+ preprocessor: Any
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class GPUExecutionProfile:
130
+ name: str
131
+ train_batch_by_model: Dict[str, int]
132
+ eval_batch_by_model: Dict[str, int]
133
+ grad_accum_cap_by_model: Dict[str, int]
134
+ num_workers: int
135
+ prefetch_factor: int
136
+ persistent_workers: bool
137
+ notes: str
138
+
139
+
140
+ class FocalLoss(torch.nn.Module):
141
+ """Class-weighted focal loss for imbalanced multi-class classification."""
142
+
143
+ def __init__(
144
+ self,
145
+ class_weights: Optional[torch.Tensor] = None,
146
+ gamma: float = DEFAULT_FOCAL_GAMMA,
147
+ reduction: str = "mean",
148
+ ) -> None:
149
+ super().__init__()
150
+ if reduction not in {"mean", "sum", "none"}:
151
+ raise ValueError(f"Unsupported focal-loss reduction: {reduction}")
152
+ self.gamma = float(gamma)
153
+ self.reduction = reduction
154
+ if class_weights is not None:
155
+ normalized = class_weights.float() / class_weights.float().mean().clamp_min(1e-6)
156
+ self.register_buffer("class_weights", normalized)
157
+ else:
158
+ self.register_buffer("class_weights", None)
159
+
160
+ def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
161
+ log_probs = torch.nn.functional.log_softmax(logits, dim=1)
162
+ probs = log_probs.exp()
163
+ target_log_probs = log_probs.gather(1, targets.unsqueeze(1)).squeeze(1)
164
+ target_probs = probs.gather(1, targets.unsqueeze(1)).squeeze(1).clamp_min(1e-6)
165
+ ce_loss = -target_log_probs
166
+ focal_factor = (1.0 - target_probs).pow(self.gamma)
167
+ loss = focal_factor * ce_loss
168
+ if self.class_weights is not None:
169
+ loss = self.class_weights[targets] * loss
170
+ if self.reduction == "mean":
171
+ return loss.mean()
172
+ if self.reduction == "sum":
173
+ return loss.sum()
174
+ return loss
175
+
176
+
177
+ def _json_ready(value: Any) -> Any:
178
+ if isinstance(value, dict):
179
+ return {str(key): _json_ready(val) for key, val in value.items()}
180
+ if isinstance(value, (list, tuple)):
181
+ return [_json_ready(item) for item in value]
182
+ if isinstance(value, np.ndarray):
183
+ return value.tolist()
184
+ if isinstance(value, np.generic):
185
+ return value.item()
186
+ if isinstance(value, Path):
187
+ return str(value)
188
+ return value
189
+
190
+
191
+ def _as_dense(matrix: Any) -> np.ndarray:
192
+ if sparse.issparse(matrix):
193
+ return matrix.toarray()
194
+ return np.asarray(matrix)
195
+
196
+
197
+ def _candidate_file_paths() -> List[Path]:
198
+ return [
199
+ ROOT / "PPMI_Curated_Data_Cut_Public_20240129.csv",
200
+ ROOT / "PPMI_Curated_Data_Cut_Public_20241211.csv",
201
+ ROOT / "PPMI_Curated_Data_Cut_Public_20250321.csv",
202
+ ROOT / "PPMI_Curated_Data_Cut_Public_20250714.csv",
203
+ ]
204
+
205
+
206
+ def _prepare_training_bundle() -> TrainingBundle:
207
+ preprocessor = DataPreprocessor()
208
+ X_train, X_test, y_train, y_test = preprocessor.prepare_data(
209
+ _candidate_file_paths(),
210
+ test_size=0.2,
211
+ use_patient_split=True,
212
+ )
213
+ train_df, test_df = preprocessor.get_split_frames()
214
+ feature_names = preprocessor.get_feature_names()
215
+ class_mapping = preprocessor.get_class_mapping()
216
+ class_names = [None] * len(class_mapping)
217
+ for label, idx in class_mapping.items():
218
+ class_names[idx] = label
219
+
220
+ EVAL_DIR.mkdir(parents=True, exist_ok=True)
221
+ np.savez(
222
+ EVAL_DIR / "leak_free_split.npz",
223
+ X_train=X_train,
224
+ X_test=X_test,
225
+ y_train=y_train,
226
+ y_test=y_test,
227
+ )
228
+ joblib.dump(
229
+ {
230
+ "feature_names": feature_names,
231
+ "class_mapping": class_mapping,
232
+ "train_groups": train_df["PATNO"].to_numpy(),
233
+ "test_groups": test_df["PATNO"].to_numpy(),
234
+ },
235
+ EVAL_DIR / "leak_free_split_meta.joblib",
236
+ )
237
+
238
+ MODEL_DIR.mkdir(parents=True, exist_ok=True)
239
+ joblib.dump(preprocessor.get_preprocessor(), MODEL_DIR / "traditional_preprocessor.joblib")
240
+ (MODEL_DIR / "traditional_class_mapping.json").write_text(
241
+ json.dumps(_json_ready(class_mapping), indent=2),
242
+ encoding="utf-8",
243
+ )
244
+
245
+ return TrainingBundle(
246
+ X_train_dense=_as_dense(X_train).astype(np.float32),
247
+ X_test_dense=_as_dense(X_test).astype(np.float32),
248
+ y_train=np.asarray(y_train, dtype=np.int64),
249
+ y_test=np.asarray(y_test, dtype=np.int64),
250
+ train_groups=train_df["PATNO"].to_numpy(),
251
+ test_groups=test_df["PATNO"].to_numpy(),
252
+ feature_names=list(feature_names),
253
+ class_mapping=dict(class_mapping),
254
+ class_names=[
255
+ str(name if name is not None else CLASS_NAMES_DEFAULT[idx])
256
+ for idx, name in enumerate(class_names)
257
+ ],
258
+ preprocessor=preprocessor.get_preprocessor(),
259
+ )
260
+
261
+
262
+ def _compute_class_weight_dict(y_train: np.ndarray) -> Dict[int, float]:
263
+ classes = np.unique(y_train)
264
+ weights = compute_class_weight(class_weight="balanced", classes=classes, y=y_train)
265
+ return {int(cls): float(weight) for cls, weight in zip(classes, weights)}
266
+
267
+
268
+ def _build_transformer_criterion(
269
+ loss_name: str,
270
+ class_weights_tensor: torch.Tensor,
271
+ focal_gamma: float,
272
+ ) -> Tuple[torch.nn.Module, str]:
273
+ normalized_name = (loss_name or DEFAULT_TRANSFORMER_LOSS).strip().lower()
274
+ if normalized_name in {"ce", "cross_entropy", "cross-entropy"}:
275
+ return torch.nn.CrossEntropyLoss(weight=class_weights_tensor), "cross_entropy"
276
+ if normalized_name == "focal":
277
+ return FocalLoss(class_weights=class_weights_tensor, gamma=focal_gamma), f"focal(gamma={focal_gamma:.2f})"
278
+ raise ValueError(f"Unsupported transformer loss: {loss_name}")
279
+
280
+
281
+ def _is_better_validation_epoch(
282
+ val_f1: float,
283
+ val_loss: float,
284
+ best_val_f1: float,
285
+ best_val_loss: float,
286
+ min_delta: float = 1e-4,
287
+ ) -> bool:
288
+ if val_f1 > (best_val_f1 + min_delta):
289
+ return True
290
+ if abs(val_f1 - best_val_f1) <= min_delta and val_loss < (best_val_loss - min_delta):
291
+ return True
292
+ return False
293
+
294
+
295
+ def _evaluate_predictions(
296
+ model_name: str,
297
+ model_type: str,
298
+ y_true: np.ndarray,
299
+ y_pred: np.ndarray,
300
+ probabilities: Optional[np.ndarray],
301
+ class_names: Sequence[str],
302
+ ) -> Dict[str, Any]:
303
+ normalized_class_names = [str(name) for name in class_names]
304
+ accuracy = accuracy_score(y_true, y_pred)
305
+ precision, recall, f1, _ = precision_recall_fscore_support(y_true, y_pred, average="weighted", zero_division=0)
306
+ report = classification_report(y_true, y_pred, target_names=normalized_class_names, zero_division=0)
307
+ cm = confusion_matrix(y_true, y_pred)
308
+ auroc = None
309
+ if probabilities is not None:
310
+ try:
311
+ auroc = roc_auc_score(y_true, probabilities, multi_class="ovr", average="weighted")
312
+ except ValueError:
313
+ auroc = None
314
+ return {
315
+ "Model": model_name,
316
+ "Type": model_type,
317
+ "Accuracy": float(accuracy),
318
+ "Precision": float(precision),
319
+ "Recall": float(recall),
320
+ "F1_Score": float(f1),
321
+ "AUROC": None if auroc is None else float(auroc),
322
+ "classification_report": report,
323
+ "confusion_matrix": cm.tolist(),
324
+ }
325
+
326
+
327
+ def _traditional_model_builder(model_name: str, params: Dict[str, Any], class_weight_dict: Dict[int, float], num_classes: int):
328
+ if model_name == "lightgbm":
329
+ return LGBMClassifier(random_state=42, objective="multiclass", num_class=num_classes, verbose=-1, class_weight=class_weight_dict, **params)
330
+ if model_name == "xgboost":
331
+ return XGBClassifier(random_state=42, objective="multi:softprob", num_class=num_classes, eval_metric="mlogloss", verbosity=0, **params)
332
+ if model_name == "svm":
333
+ return SVC(random_state=42, probability=True, decision_function_shape="ovr", class_weight=class_weight_dict, cache_size=2048, **params)
334
+ raise ValueError(f"Unsupported traditional model: {model_name}")
335
+
336
+
337
+ def _run_grouped_traditional_search(
338
+ model_name: str,
339
+ search_space: List[Dict[str, Any]],
340
+ bundle: TrainingBundle,
341
+ controller: TrainingRunController,
342
+ max_trials: int,
343
+ ) -> Tuple[Dict[str, Any], Dict[str, Any], Path]:
344
+ class_weight_dict = _compute_class_weight_dict(bundle.y_train)
345
+ checkpoint_state = controller.load_checkpoint_state(
346
+ model_name,
347
+ {
348
+ "phase": "search",
349
+ "trials": {},
350
+ "best_trial_index": None,
351
+ "best_score": None,
352
+ },
353
+ )
354
+
355
+ search_space = list(search_space[:max_trials])
356
+ n_splits = max(2, min(3, len(np.unique(bundle.train_groups))))
357
+ splitter = GroupKFold(n_splits=n_splits)
358
+
359
+ controller.mark_running("traditional_search", model_name=model_name)
360
+ controller.update_model_state(
361
+ model_name,
362
+ status="running",
363
+ family="traditional",
364
+ trials_total=len(search_space),
365
+ )
366
+
367
+ for trial_index, params in enumerate(search_space):
368
+ trial_key = str(trial_index)
369
+ trial_state = checkpoint_state["trials"].setdefault(
370
+ trial_key,
371
+ {
372
+ "params": params,
373
+ "fold_scores": [],
374
+ "status": "pending",
375
+ },
376
+ )
377
+ if trial_state.get("status") == "completed":
378
+ continue
379
+
380
+ controller.mark_running("traditional_trial", model_name=model_name, extra={"trial_index": trial_index})
381
+
382
+ for fold_index, (train_idx, val_idx) in enumerate(
383
+ splitter.split(bundle.X_train_dense, bundle.y_train, groups=bundle.train_groups)
384
+ ):
385
+ if fold_index < len(trial_state["fold_scores"]):
386
+ continue
387
+
388
+ train_features = np.asarray(bundle.X_train_dense[train_idx], dtype=np.float32)
389
+ val_features = np.asarray(bundle.X_train_dense[val_idx], dtype=np.float32)
390
+ train_targets = np.asarray(bundle.y_train[train_idx], dtype=np.int64)
391
+ val_targets = np.asarray(bundle.y_train[val_idx], dtype=np.int64)
392
+
393
+ estimator = _traditional_model_builder(
394
+ model_name,
395
+ params,
396
+ class_weight_dict=class_weight_dict,
397
+ num_classes=len(bundle.class_names),
398
+ )
399
+ estimator.fit(train_features, train_targets)
400
+ preds = estimator.predict(val_features)
401
+ score = f1_score(val_targets, preds, average="weighted")
402
+
403
+ trial_state["fold_scores"].append(float(score))
404
+ trial_state["status"] = "running"
405
+ checkpoint_state["phase"] = "search"
406
+ controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
407
+ controller.update_model_state(
408
+ model_name,
409
+ current_trial=trial_index,
410
+ current_fold=fold_index,
411
+ latest_fold_score=float(score),
412
+ )
413
+ controller.raise_if_requested()
414
+
415
+ trial_state["mean_score"] = float(np.mean(trial_state["fold_scores"]))
416
+ trial_state["status"] = "completed"
417
+ controller.append_trial_result(
418
+ model_name,
419
+ {
420
+ "trial_index": trial_index,
421
+ "params": params,
422
+ "mean_score": trial_state["mean_score"],
423
+ },
424
+ )
425
+ if checkpoint_state.get("best_score") is None or trial_state["mean_score"] > checkpoint_state["best_score"]:
426
+ checkpoint_state["best_score"] = float(trial_state["mean_score"])
427
+ checkpoint_state["best_trial_index"] = trial_index
428
+ controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
429
+ controller.raise_if_requested()
430
+
431
+ if checkpoint_state.get("best_trial_index") is None:
432
+ raise RuntimeError(f"No completed trials were recorded for {model_name}")
433
+
434
+ best_index = int(checkpoint_state["best_trial_index"])
435
+ best_params = dict(search_space[best_index])
436
+
437
+ checkpoint_state["phase"] = "fit_final"
438
+ controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
439
+ controller.mark_running("traditional_fit_final", model_name=model_name)
440
+
441
+ best_model = _traditional_model_builder(
442
+ model_name,
443
+ best_params,
444
+ class_weight_dict=class_weight_dict,
445
+ num_classes=len(bundle.class_names),
446
+ )
447
+ best_model.fit(
448
+ np.asarray(bundle.X_train_dense, dtype=np.float32),
449
+ np.asarray(bundle.y_train, dtype=np.int64),
450
+ )
451
+
452
+ artifact_path = MODEL_DIR / f"{model_name}_model.joblib"
453
+ joblib.dump(best_model, artifact_path)
454
+
455
+ test_features = np.asarray(bundle.X_test_dense, dtype=np.float32)
456
+ y_pred = best_model.predict(test_features)
457
+ probabilities = best_model.predict_proba(test_features) if hasattr(best_model, "predict_proba") else None
458
+ metrics = _evaluate_predictions(
459
+ model_name=model_name.replace("_", " ").title().replace("Svm", "SVM").replace("Lightgbm", "LightGBM").replace("Xgboost", "XGBoost"),
460
+ model_type="Traditional ML",
461
+ y_true=bundle.y_test,
462
+ y_pred=y_pred,
463
+ probabilities=probabilities,
464
+ class_names=bundle.class_names,
465
+ )
466
+
467
+ checkpoint_state["phase"] = "complete"
468
+ checkpoint_state["artifact_path"] = str(artifact_path)
469
+ checkpoint_state["best_params"] = best_params
470
+ checkpoint_state["metrics"] = {
471
+ key: value for key, value in metrics.items()
472
+ if key in {"Accuracy", "Precision", "Recall", "F1_Score", "AUROC", "Model", "Type"}
473
+ }
474
+ controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
475
+ controller.update_model_state(
476
+ model_name,
477
+ status="completed",
478
+ artifact_path=str(artifact_path),
479
+ best_params=best_params,
480
+ best_cv_score=float(checkpoint_state["best_score"]),
481
+ metrics=checkpoint_state["metrics"],
482
+ )
483
+ return metrics, best_params, artifact_path
484
+
485
+
486
+ def _build_rag_contexts(features: np.ndarray, feature_names: Sequence[str], doc_manager: Any) -> List[str]:
487
+ contexts: List[str] = []
488
+ for row in features:
489
+ feature_desc = {name: float(val) for name, val in zip(feature_names, row)}
490
+ query_parts = []
491
+ for symptom_key, col in {
492
+ "tremor": "sym_tremor",
493
+ "rigidity": "sym_rigid",
494
+ "bradykinesia": "sym_brady",
495
+ "postural instability": "sym_posins",
496
+ }.items():
497
+ severity = feature_desc.get(col, 0)
498
+ if severity > 0:
499
+ query_parts.append(f"{symptom_key} severity:{severity:.2f}")
500
+ if feature_desc.get("moca", 30) < 26:
501
+ query_parts.append("cognitive impairment")
502
+ if feature_desc.get("fampd", 0) > 0:
503
+ query_parts.append("family history Parkinson's disease")
504
+ query = "Parkinson's disease " + " ".join(query_parts)
505
+ passages = doc_manager.extract_relevant_passages(query, top_k=2)
506
+ if not passages:
507
+ contexts.append("")
508
+ continue
509
+ contexts.append(
510
+ " ".join(
511
+ f"From '{item.get('doc_title') or item.get('doc_id') or 'document'}' {item['text'][:240]}..."
512
+ for item in passages
513
+ )
514
+ )
515
+ return contexts
516
+
517
+
518
+ def _encode_contexts_for_model(model_name: str, model: Any, contexts: Optional[List[str]]) -> Optional[Dict[str, torch.Tensor]]:
519
+ if not contexts:
520
+ return None
521
+ if model_name == "clinical_t5":
522
+ texts = [f"classify patient: {text}" for text in contexts]
523
+ else:
524
+ texts = contexts
525
+ encoded = model.tokenizer(
526
+ texts,
527
+ return_tensors="pt",
528
+ padding=True,
529
+ truncation=True,
530
+ max_length=512,
531
+ )
532
+ return {key: value.cpu() for key, value in encoded.items()}
533
+
534
+
535
+ def _prepare_batch(batch: Any, device: torch.device):
536
+ if len(batch) == 3:
537
+ features, targets, contexts = batch
538
+ if isinstance(contexts, dict):
539
+ contexts = {key: value.to(device, non_blocking=True) for key, value in contexts.items()}
540
+ else:
541
+ contexts = list(contexts)
542
+ else:
543
+ features, targets = batch
544
+ contexts = None
545
+ return features.to(device), targets.to(device), contexts
546
+
547
+
548
+ def _create_transformer_model(model_name: str, input_dim: int, num_classes: int, model_kwargs: Dict[str, Any]):
549
+ from models.medical_transformers import ( # type: ignore
550
+ BioMistralClassifier as BioGPTForTabular,
551
+ ClinicalT5Classifier as ClinicalT5ForTabular,
552
+ PubMedBERTClassifier as PubMedBERTForTabular,
553
+ )
554
+
555
+ if model_name == "pubmedbert":
556
+ return PubMedBERTForTabular(input_dim=input_dim, num_classes=num_classes, **model_kwargs)
557
+ if model_name == "biogpt":
558
+ return BioGPTForTabular(input_dim=input_dim, num_classes=num_classes, **model_kwargs)
559
+ if model_name == "clinical_t5":
560
+ return ClinicalT5ForTabular(input_dim=input_dim, num_classes=num_classes, **model_kwargs)
561
+ raise ValueError(f"Unsupported transformer model: {model_name}")
562
+
563
+
564
+ def _torch_checkpoint_path(controller: TrainingRunController, model_name: str, trial_index: int) -> Path:
565
+ return controller.paths.checkpoints_dir / f"{model_name}_trial{trial_index}.pth"
566
+
567
+
568
+ def _canonical_transformer_artifacts(model_name: str) -> List[Path]:
569
+ aliases = {
570
+ "pubmedbert": ["pubmedbert_transformer.pth", "pubmedbert.pth"],
571
+ "biogpt": ["biogpt_transformer.pth", "biogpt.pth", "biomistral.pth"],
572
+ "clinical_t5": ["clinical_t5_transformer.pth", "clinicalt5_transformer.pth", "clinical_t5.pth"],
573
+ }
574
+ return [MODEL_DIR / filename for filename in aliases[model_name]]
575
+
576
+
577
+ def _run_transformer_search(
578
+ model_name: str,
579
+ trial_space: List[Dict[str, Any]],
580
+ bundle: TrainingBundle,
581
+ controller: TrainingRunController,
582
+ max_trials: int,
583
+ num_epochs: int,
584
+ patience: int,
585
+ use_rag: bool,
586
+ gpu_profile_name: str,
587
+ transformer_loss_name: str,
588
+ focal_gamma: float,
589
+ ) -> Tuple[Dict[str, Any], Dict[str, Any], Path]:
590
+ from models.transformer_models import TabularDataset # type: ignore
591
+
592
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
593
+ amp_enabled = device.type == "cuda" and model_name != "clinical_t5"
594
+ gpu_profile = _detect_gpu_execution_profile(requested_profile=gpu_profile_name)
595
+ train_bs = gpu_profile.train_batch_by_model.get(model_name, 8)
596
+ eval_bs = gpu_profile.eval_batch_by_model.get(model_name, max(train_bs * 2, 8))
597
+ loader_kwargs = _build_loader_kwargs(device, gpu_profile)
598
+
599
+ class_weights = compute_class_weight("balanced", classes=np.unique(bundle.y_train), y=bundle.y_train)
600
+ class_weights_tensor = torch.FloatTensor(class_weights).to(device)
601
+ criterion, criterion_label = _build_transformer_criterion(
602
+ transformer_loss_name,
603
+ class_weights_tensor,
604
+ focal_gamma,
605
+ )
606
+
607
+ split = GroupShuffleSplit(n_splits=1, test_size=0.15, random_state=42)
608
+ train_index, val_index = next(split.split(bundle.X_train_dense, bundle.y_train, groups=bundle.train_groups))
609
+
610
+ train_features = bundle.X_train_dense[train_index]
611
+ train_targets = bundle.y_train[train_index]
612
+ val_features = bundle.X_train_dense[val_index]
613
+ val_targets = bundle.y_train[val_index]
614
+
615
+ train_contexts = None
616
+ val_contexts = None
617
+ if use_rag:
618
+ from document_manager import DocumentManager # type: ignore
619
+
620
+ doc_manager = DocumentManager(docs_dir=str(ROOT / "medical_docs"))
621
+ train_contexts = _build_rag_contexts(train_features, bundle.feature_names, doc_manager)
622
+ val_contexts = _build_rag_contexts(val_features, bundle.feature_names, doc_manager)
623
+
624
+ test_ds = TabularDataset(bundle.X_test_dense, bundle.y_test, bundle.feature_names, contexts=None)
625
+ test_loader = DataLoader(test_ds, batch_size=eval_bs, shuffle=False, **loader_kwargs)
626
+
627
+ checkpoint_state = controller.load_checkpoint_state(
628
+ model_name,
629
+ {
630
+ "phase": "search",
631
+ "trials": {},
632
+ "best_trial_index": None,
633
+ "best_f1": None,
634
+ "selection_metric": TRANSFORMER_SELECTION_METRIC,
635
+ "loss_name": criterion_label,
636
+ "focal_gamma": float(focal_gamma),
637
+ },
638
+ )
639
+ if (
640
+ checkpoint_state.get("selection_metric") != TRANSFORMER_SELECTION_METRIC
641
+ or checkpoint_state.get("loss_name") != criterion_label
642
+ or float(checkpoint_state.get("focal_gamma", focal_gamma)) != float(focal_gamma)
643
+ ):
644
+ print(
645
+ f"[TRANSFORMER] {model_name} resetting saved trial state to match "
646
+ f"selection={TRANSFORMER_SELECTION_METRIC} and loss={criterion_label}"
647
+ )
648
+ checkpoint_state = {
649
+ "phase": "search",
650
+ "trials": {},
651
+ "best_trial_index": None,
652
+ "best_f1": None,
653
+ "selection_metric": TRANSFORMER_SELECTION_METRIC,
654
+ "loss_name": criterion_label,
655
+ "focal_gamma": float(focal_gamma),
656
+ }
657
+ for stale_checkpoint in controller.paths.checkpoints_dir.glob(f"{model_name}_trial*.pth"):
658
+ stale_checkpoint.unlink(missing_ok=True)
659
+ trial_space = list(trial_space[:max_trials])
660
+
661
+ controller.mark_running("transformer_search", model_name=model_name)
662
+ controller.update_model_state(
663
+ model_name,
664
+ status="running",
665
+ family="transformer",
666
+ trials_total=len(trial_space),
667
+ device=str(device),
668
+ gpu_profile=gpu_profile.name,
669
+ train_batch_size=train_bs,
670
+ eval_batch_size=eval_bs,
671
+ num_workers=loader_kwargs.get("num_workers", 0),
672
+ gpu_notes=gpu_profile.notes,
673
+ selection_metric=TRANSFORMER_SELECTION_METRIC,
674
+ transformer_loss=criterion_label,
675
+ )
676
+
677
+ for trial_index, trial_cfg in enumerate(trial_space):
678
+ trial_key = str(trial_index)
679
+ trial_state = checkpoint_state["trials"].setdefault(
680
+ trial_key,
681
+ {
682
+ "config": trial_cfg,
683
+ "status": "pending",
684
+ "history": {"train_loss": [], "val_loss": [], "val_f1": [], "val_acc": [], "lr": []},
685
+ "best_val_loss": None,
686
+ "best_val_f1": None,
687
+ "best_epoch": None,
688
+ "selection_metric": TRANSFORMER_SELECTION_METRIC,
689
+ "loss_name": criterion_label,
690
+ },
691
+ )
692
+ if trial_state.get("status") == "completed":
693
+ continue
694
+
695
+ controller.mark_running("transformer_trial", model_name=model_name, extra={"trial_index": trial_index})
696
+
697
+ model = _create_transformer_model(
698
+ model_name,
699
+ input_dim=bundle.X_train_dense.shape[1],
700
+ num_classes=len(bundle.class_names),
701
+ model_kwargs=trial_cfg["model_kwargs"],
702
+ ).to(device)
703
+ encoded_train_contexts = _encode_contexts_for_model(model_name, model, train_contexts)
704
+ encoded_val_contexts = _encode_contexts_for_model(model_name, model, val_contexts)
705
+ train_ds = TabularDataset(train_features, train_targets, bundle.feature_names, contexts=encoded_train_contexts)
706
+ val_ds = TabularDataset(val_features, val_targets, bundle.feature_names, contexts=encoded_val_contexts)
707
+ train_loader = DataLoader(train_ds, batch_size=train_bs, shuffle=True, **loader_kwargs)
708
+ val_loader = DataLoader(val_ds, batch_size=eval_bs, shuffle=False, **loader_kwargs)
709
+ optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), **trial_cfg["optimizer"])
710
+ scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="max", factor=0.5, patience=2, min_lr=1e-7)
711
+ scaler = torch.amp.GradScaler(device=device.type, enabled=amp_enabled)
712
+ grad_accum = min(
713
+ int(trial_cfg.get("grad_accum", 8)),
714
+ int(gpu_profile.grad_accum_cap_by_model.get(model_name, trial_cfg.get("grad_accum", 8))),
715
+ )
716
+ print(
717
+ f"[TRANSFORMER] {model_name} trial {trial_index + 1}/{len(trial_space)} "
718
+ f"| train_bs={train_bs} eval_bs={eval_bs} grad_accum={grad_accum} "
719
+ f"effective_batch={train_bs * grad_accum} "
720
+ f"train_steps={len(train_loader)} val_steps={len(val_loader)} "
721
+ f"| selection={TRANSFORMER_SELECTION_METRIC} loss={criterion_label}"
722
+ )
723
+ start_epoch = int(trial_state.get("next_epoch", 0))
724
+ early_stop_counter = int(trial_state.get("early_stop_counter", 0))
725
+ best_val_loss = float(trial_state["best_val_loss"]) if trial_state.get("best_val_loss") is not None else float("inf")
726
+ best_val_f1 = float(trial_state["best_val_f1"]) if trial_state.get("best_val_f1") is not None else float("-inf")
727
+ best_model_state_dict = None
728
+ torch_ckpt_path = _torch_checkpoint_path(controller, model_name, trial_index)
729
+
730
+ if torch_ckpt_path.exists():
731
+ saved = torch.load(torch_ckpt_path, map_location=device, weights_only=False)
732
+ saved_model_state = saved.get("model_state_dict", {})
733
+ checkpoint_has_non_finite = any(
734
+ torch.is_tensor(tensor) and not torch.isfinite(tensor).all()
735
+ for tensor in saved_model_state.values()
736
+ )
737
+ if (
738
+ saved.get("selection_metric") == TRANSFORMER_SELECTION_METRIC
739
+ and saved.get("loss_name") == criterion_label
740
+ and float(saved.get("focal_gamma", focal_gamma)) == float(focal_gamma)
741
+ and not checkpoint_has_non_finite
742
+ ):
743
+ model.load_state_dict(saved["model_state_dict"])
744
+ optimizer.load_state_dict(saved["optimizer_state_dict"])
745
+ scheduler.load_state_dict(saved["scheduler_state_dict"])
746
+ if saved.get("scaler_state_dict") and device.type == "cuda":
747
+ scaler.load_state_dict(saved["scaler_state_dict"])
748
+ start_epoch = int(saved.get("epoch", -1)) + 1
749
+ early_stop_counter = int(saved.get("early_stop_counter", early_stop_counter))
750
+ best_val_loss = float(saved.get("best_val_loss", best_val_loss))
751
+ best_val_f1 = float(saved.get("best_val_f1", best_val_f1))
752
+ best_model_state_dict = saved.get("best_model_state_dict")
753
+ trial_state["history"] = saved.get("history", trial_state["history"])
754
+ else:
755
+ print(
756
+ f"[TRANSFORMER] {model_name} trial {trial_index + 1} ignoring stale "
757
+ f"checkpoint with incompatible selection/loss settings or non-finite weights"
758
+ )
759
+ start_epoch = 0
760
+ early_stop_counter = 0
761
+ best_val_loss = float("inf")
762
+ best_val_f1 = float("-inf")
763
+ best_model_state_dict = None
764
+ trial_state["history"] = {"train_loss": [], "val_loss": [], "val_f1": [], "val_acc": [], "lr": []}
765
+ torch_ckpt_path.unlink(missing_ok=True)
766
+
767
+ for epoch in range(start_epoch, num_epochs):
768
+ model.train()
769
+ running_train_loss = 0.0
770
+ processed_train_batches = 0
771
+ optimizer.zero_grad(set_to_none=True)
772
+ if device.type == "cuda":
773
+ torch.cuda.reset_peak_memory_stats(device)
774
+
775
+ progress = tqdm(
776
+ train_loader,
777
+ total=len(train_loader),
778
+ dynamic_ncols=True,
779
+ leave=False,
780
+ desc=f"{model_name} t{trial_index + 1}/{len(trial_space)} e{epoch + 1}/{num_epochs}",
781
+ )
782
+ for batch_index, batch in enumerate(progress):
783
+ features, targets, contexts = _prepare_batch(batch, device)
784
+ with torch.amp.autocast(device_type=device.type, enabled=amp_enabled):
785
+ outputs = model(features, contexts)
786
+ loss = criterion(outputs, targets) / grad_accum
787
+
788
+ if not torch.isfinite(outputs).all() or not torch.isfinite(loss):
789
+ optimizer.zero_grad(set_to_none=True)
790
+ continue
791
+
792
+ scaler.scale(loss).backward()
793
+
794
+ if (batch_index + 1) % grad_accum == 0 or (batch_index + 1) == len(train_loader):
795
+ scaler.unscale_(optimizer)
796
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
797
+ scaler.step(optimizer)
798
+ scaler.update()
799
+ optimizer.zero_grad(set_to_none=True)
800
+
801
+ running_train_loss += float(loss.item() * grad_accum)
802
+ processed_train_batches += 1
803
+ current_loss = float(loss.item() * grad_accum)
804
+ current_lr = float(optimizer.param_groups[0]["lr"])
805
+ if device.type == "cuda":
806
+ gpu_mem_gb = torch.cuda.memory_allocated(device) / 1024**3
807
+ progress.set_postfix(loss=f"{current_loss:.4f}", lr=f"{current_lr:.2e}", gpu_gb=f"{gpu_mem_gb:.2f}")
808
+ else:
809
+ progress.set_postfix(loss=f"{current_loss:.4f}", lr=f"{current_lr:.2e}")
810
+
811
+ progress.close()
812
+
813
+ avg_train_loss = running_train_loss / max(processed_train_batches, 1)
814
+
815
+ model.eval()
816
+ running_val_loss = 0.0
817
+ processed_val_batches = 0
818
+ all_preds: List[int] = []
819
+ all_targets: List[int] = []
820
+ with torch.no_grad():
821
+ for batch in val_loader:
822
+ features, targets, contexts = _prepare_batch(batch, device)
823
+ with torch.amp.autocast(device_type=device.type, enabled=amp_enabled):
824
+ outputs = model(features, contexts)
825
+ val_loss = criterion(outputs, targets)
826
+ if not torch.isfinite(outputs).all() or not torch.isfinite(val_loss):
827
+ continue
828
+ running_val_loss += float(val_loss.item())
829
+ processed_val_batches += 1
830
+ preds = torch.argmax(outputs, dim=1)
831
+ all_preds.extend(preds.cpu().numpy().tolist())
832
+ all_targets.extend(targets.cpu().numpy().tolist())
833
+
834
+ avg_val_loss = running_val_loss / max(processed_val_batches, 1)
835
+ if not all_preds:
836
+ val_acc = 0.0
837
+ val_f1 = 0.0
838
+ avg_val_loss = float("inf")
839
+ else:
840
+ val_acc = float(np.mean(np.asarray(all_preds) == np.asarray(all_targets)))
841
+ val_f1 = float(f1_score(all_targets, all_preds, average="weighted"))
842
+ scheduler.step(val_f1)
843
+
844
+ history = trial_state["history"]
845
+ history["train_loss"].append(float(avg_train_loss))
846
+ history["val_loss"].append(float(avg_val_loss))
847
+ history["val_f1"].append(val_f1)
848
+ history["val_acc"].append(val_acc)
849
+ history["lr"].append(float(optimizer.param_groups[0]["lr"]))
850
+ gpu_peak_gb = 0.0
851
+ if device.type == "cuda":
852
+ gpu_peak_gb = torch.cuda.max_memory_allocated(device) / 1024**3
853
+ print(
854
+ f"[TRANSFORMER] {model_name} trial {trial_index + 1}/{len(trial_space)} "
855
+ f"epoch {epoch + 1}/{num_epochs} "
856
+ f"train_loss={avg_train_loss:.4f} val_loss={avg_val_loss:.4f} "
857
+ f"val_f1={val_f1:.4f} val_acc={val_acc:.4f} "
858
+ f"lr={optimizer.param_groups[0]['lr']:.2e} "
859
+ f"gpu_peak_gb={gpu_peak_gb:.2f}"
860
+ )
861
+
862
+ if _is_better_validation_epoch(
863
+ val_f1=val_f1,
864
+ val_loss=avg_val_loss,
865
+ best_val_f1=best_val_f1,
866
+ best_val_loss=best_val_loss,
867
+ ):
868
+ best_val_loss = avg_val_loss
869
+ best_val_f1 = val_f1
870
+ trial_state["best_val_loss"] = float(best_val_loss)
871
+ trial_state["best_val_f1"] = float(best_val_f1)
872
+ trial_state["best_epoch"] = epoch
873
+ best_model_state_dict = {
874
+ key: value.detach().cpu().clone()
875
+ for key, value in model.state_dict().items()
876
+ }
877
+ early_stop_counter = 0
878
+ else:
879
+ early_stop_counter += 1
880
+
881
+ trial_state["status"] = "running"
882
+ trial_state["next_epoch"] = epoch + 1
883
+ trial_state["early_stop_counter"] = early_stop_counter
884
+ checkpoint_state["phase"] = "search"
885
+ controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
886
+ torch.save(
887
+ {
888
+ "epoch": epoch,
889
+ "model_state_dict": model.state_dict(),
890
+ "optimizer_state_dict": optimizer.state_dict(),
891
+ "scheduler_state_dict": scheduler.state_dict(),
892
+ "scaler_state_dict": scaler.state_dict() if device.type == "cuda" else None,
893
+ "history": history,
894
+ "early_stop_counter": early_stop_counter,
895
+ "best_val_loss": best_val_loss,
896
+ "best_val_f1": best_val_f1,
897
+ "best_model_state_dict": best_model_state_dict,
898
+ "selection_metric": TRANSFORMER_SELECTION_METRIC,
899
+ "loss_name": criterion_label,
900
+ "focal_gamma": float(focal_gamma),
901
+ },
902
+ torch_ckpt_path,
903
+ )
904
+ controller.update_model_state(
905
+ model_name,
906
+ current_trial=trial_index,
907
+ current_epoch=epoch,
908
+ grad_accum=grad_accum,
909
+ best_val_f1=float(best_val_f1),
910
+ best_val_loss=float(best_val_loss),
911
+ selection_metric=TRANSFORMER_SELECTION_METRIC,
912
+ transformer_loss=criterion_label,
913
+ )
914
+ controller.raise_if_requested()
915
+
916
+ if early_stop_counter >= patience:
917
+ print(
918
+ f"[TRANSFORMER] {model_name} trial {trial_index + 1}/{len(trial_space)} "
919
+ f"early-stopped after epoch {epoch + 1}"
920
+ )
921
+ break
922
+
923
+ trial_state["status"] = "completed"
924
+ controller.append_trial_result(
925
+ model_name,
926
+ {
927
+ "trial_index": trial_index,
928
+ "config": trial_cfg,
929
+ "best_val_f1": float(trial_state.get("best_val_f1") or 0.0),
930
+ "best_epoch": trial_state.get("best_epoch"),
931
+ },
932
+ )
933
+ best_so_far = checkpoint_state.get("best_f1")
934
+ if best_so_far is None or float(trial_state.get("best_val_f1") or -1.0) > float(best_so_far):
935
+ checkpoint_state["best_f1"] = float(trial_state["best_val_f1"])
936
+ checkpoint_state["best_trial_index"] = trial_index
937
+ controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
938
+ controller.raise_if_requested()
939
+
940
+ if checkpoint_state.get("best_trial_index") is None:
941
+ raise RuntimeError(f"No completed transformer trial found for {model_name}")
942
+
943
+ best_trial_index = int(checkpoint_state["best_trial_index"])
944
+ best_trial_cfg = dict(trial_space[best_trial_index])
945
+ checkpoint_state["phase"] = "evaluate"
946
+ controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
947
+
948
+ best_model = _create_transformer_model(
949
+ model_name,
950
+ input_dim=bundle.X_train_dense.shape[1],
951
+ num_classes=len(bundle.class_names),
952
+ model_kwargs=best_trial_cfg["model_kwargs"],
953
+ ).to(device)
954
+ best_state = torch.load(_torch_checkpoint_path(controller, model_name, best_trial_index), map_location=device, weights_only=False)
955
+ best_model.load_state_dict(best_state.get("best_model_state_dict") or best_state["model_state_dict"])
956
+ best_model.eval()
957
+
958
+ all_preds: List[int] = []
959
+ all_targets: List[int] = []
960
+ all_probabilities: List[np.ndarray] = []
961
+ with torch.no_grad():
962
+ for batch in test_loader:
963
+ features, targets, contexts = _prepare_batch(batch, device)
964
+ outputs = best_model(features, contexts)
965
+ probs = torch.softmax(outputs, dim=1)
966
+ preds = torch.argmax(outputs, dim=1)
967
+ all_targets.extend(targets.cpu().numpy().tolist())
968
+ all_preds.extend(preds.cpu().numpy().tolist())
969
+ all_probabilities.append(probs.cpu().numpy())
970
+
971
+ probabilities = np.vstack(all_probabilities) if all_probabilities else None
972
+ metrics = _evaluate_predictions(
973
+ model_name={"pubmedbert": "PubMedBERT", "biogpt": "BioGPT", "clinical_t5": "Clinical-T5"}[model_name],
974
+ model_type="Transformer",
975
+ y_true=np.asarray(all_targets),
976
+ y_pred=np.asarray(all_preds),
977
+ probabilities=probabilities,
978
+ class_names=bundle.class_names,
979
+ )
980
+
981
+ primary_artifact = _canonical_transformer_artifacts(model_name)[0]
982
+ for path in _canonical_transformer_artifacts(model_name):
983
+ torch.save(best_model.state_dict(), path)
984
+
985
+ checkpoint_state["phase"] = "complete"
986
+ checkpoint_state["artifact_path"] = str(primary_artifact)
987
+ checkpoint_state["best_trial_index"] = best_trial_index
988
+ checkpoint_state["best_config"] = best_trial_cfg
989
+ checkpoint_state["metrics"] = {
990
+ key: value for key, value in metrics.items()
991
+ if key in {"Accuracy", "Precision", "Recall", "F1_Score", "AUROC", "Model", "Type"}
992
+ }
993
+ controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
994
+ controller.update_model_state(
995
+ model_name,
996
+ status="completed",
997
+ artifact_path=str(primary_artifact),
998
+ best_trial_index=best_trial_index,
999
+ best_config=best_trial_cfg,
1000
+ metrics=checkpoint_state["metrics"],
1001
+ )
1002
+ return metrics, best_trial_cfg, primary_artifact
1003
+
1004
+
1005
+ def _train_ensemble(bundle: TrainingBundle, controller: TrainingRunController) -> Optional[Dict[str, Any]]:
1006
+ from models.multimodal_ml import MultimodalEnsemble # type: ignore
1007
+
1008
+ controller.mark_running("ensemble_training", model_name="ensemble")
1009
+ controller.raise_if_requested()
1010
+
1011
+ ensemble = MultimodalEnsemble()
1012
+ ensemble.load_traditional_models(model_dir=str(MODEL_DIR))
1013
+ ensemble.load_transformer_models(model_dir=str(MODEL_DIR), input_dim=bundle.X_train_dense.shape[1], num_classes=len(bundle.class_names))
1014
+ ensemble.train_ensemble(bundle.X_train_dense, bundle.y_train, ensemble_type="stacking")
1015
+ ensemble.save_ensemble(str(MODEL_DIR / "multimodal_ensemble.joblib"))
1016
+ results = ensemble.evaluate_ensemble(bundle.X_test_dense, bundle.y_test)
1017
+
1018
+ probabilities = results.get("probabilities")
1019
+ metrics = _evaluate_predictions(
1020
+ model_name="Multimodal Ensemble",
1021
+ model_type="Ensemble",
1022
+ y_true=bundle.y_test,
1023
+ y_pred=np.asarray(results["predictions"]),
1024
+ probabilities=np.asarray(probabilities) if probabilities is not None else None,
1025
+ class_names=bundle.class_names,
1026
+ )
1027
+ controller.update_model_state(
1028
+ "ensemble",
1029
+ status="completed",
1030
+ artifact_path=str(MODEL_DIR / "multimodal_ensemble.joblib"),
1031
+ metrics={key: value for key, value in metrics.items() if key in {"Accuracy", "Precision", "Recall", "F1_Score", "AUROC", "Model", "Type"}},
1032
+ )
1033
+ return metrics
1034
+
1035
+
1036
+ def _write_metric_outputs(base_metrics: List[Dict[str, Any]], ensemble_metrics: Optional[Dict[str, Any]]) -> None:
1037
+ EVAL_DIR.mkdir(parents=True, exist_ok=True)
1038
+ MODEL_METRICS_DIR.mkdir(parents=True, exist_ok=True)
1039
+
1040
+ summary_rows = []
1041
+ traditional_rows = []
1042
+ transformer_rows = []
1043
+ for metrics in base_metrics:
1044
+ summary_rows.append({
1045
+ "Model": metrics["Model"],
1046
+ "Type": metrics["Type"],
1047
+ "Accuracy": metrics["Accuracy"],
1048
+ "Precision": metrics["Precision"],
1049
+ "Recall": metrics["Recall"],
1050
+ "F1_Score": metrics["F1_Score"],
1051
+ "AUROC": metrics["AUROC"] if metrics["AUROC"] is not None else "",
1052
+ })
1053
+ if "traditional" in metrics["Type"].lower():
1054
+ traditional_rows.append(metrics)
1055
+ if "transformer" in metrics["Type"].lower():
1056
+ transformer_rows.append(metrics)
1057
+
1058
+ summary_df = pd.DataFrame(summary_rows)
1059
+ summary_df.to_csv(EVAL_DIR / "summary_metrics.csv", index=False)
1060
+ summary_df.to_csv(MODEL_METRICS_DIR / "model_metrics_summary.csv", index=False)
1061
+
1062
+ (EVAL_DIR / "traditional_metrics_latest.json").write_text(json.dumps(_json_ready(traditional_rows), indent=2), encoding="utf-8")
1063
+ (EVAL_DIR / "transformer_metrics_latest.json").write_text(json.dumps(_json_ready(transformer_rows), indent=2), encoding="utf-8")
1064
+ if ensemble_metrics is not None:
1065
+ (EVAL_DIR / "ensemble_metrics_latest.json").write_text(json.dumps(_json_ready(ensemble_metrics), indent=2), encoding="utf-8")
1066
+
1067
+
1068
+ def _write_run_manifest_snapshot(controller: TrainingRunController, base_metrics: List[Dict[str, Any]], ensemble_metrics: Optional[Dict[str, Any]]) -> None:
1069
+ controller.write_metrics_file(
1070
+ "final_metrics.json",
1071
+ {
1072
+ "base_metrics": _json_ready(base_metrics),
1073
+ "ensemble_metrics": _json_ready(ensemble_metrics),
1074
+ },
1075
+ )
1076
+
1077
+
1078
+ def _parse_selected_models(raw: str) -> List[str]:
1079
+ raw = (raw or "all").strip().lower()
1080
+ if raw in {"all", "*"}:
1081
+ return list(ALL_BASE_MODELS)
1082
+ aliases = {
1083
+ "lgbm": "lightgbm",
1084
+ "lightgbm": "lightgbm",
1085
+ "xgb": "xgboost",
1086
+ "xgboost": "xgboost",
1087
+ "svm": "svm",
1088
+ "pubmed": "pubmedbert",
1089
+ "pubmedbert": "pubmedbert",
1090
+ "biogpt": "biogpt",
1091
+ "bio": "biogpt",
1092
+ "clinical": "clinical_t5",
1093
+ "clinical_t5": "clinical_t5",
1094
+ "t5": "clinical_t5",
1095
+ }
1096
+ selected: List[str] = []
1097
+ for piece in [part.strip() for part in raw.split(",") if part.strip()]:
1098
+ canonical = aliases.get(piece)
1099
+ if canonical and canonical not in selected:
1100
+ selected.append(canonical)
1101
+ if not selected:
1102
+ raise ValueError(f"No valid models were selected from '{raw}'")
1103
+ return selected
1104
+
1105
+
1106
+ def _detect_gpu_execution_profile(
1107
+ requested_profile: str = "auto",
1108
+ cuda_available: Optional[bool] = None,
1109
+ device_name: Optional[str] = None,
1110
+ total_memory_gb: Optional[float] = None,
1111
+ ) -> GPUExecutionProfile:
1112
+ cuda_ready = torch.cuda.is_available() if cuda_available is None else cuda_available
1113
+ if not cuda_ready:
1114
+ return GPUExecutionProfile(
1115
+ name="cpu",
1116
+ train_batch_by_model={"pubmedbert": 4, "biogpt": 2, "clinical_t5": 2},
1117
+ eval_batch_by_model={"pubmedbert": 8, "biogpt": 4, "clinical_t5": 4},
1118
+ grad_accum_cap_by_model={"pubmedbert": 12, "biogpt": 16, "clinical_t5": 16},
1119
+ num_workers=0,
1120
+ prefetch_factor=2,
1121
+ persistent_workers=False,
1122
+ notes="CPU fallback profile.",
1123
+ )
1124
+
1125
+ name = (device_name or torch.cuda.get_device_name(0)).lower()
1126
+ memory_gb = float(total_memory_gb if total_memory_gb is not None else (torch.cuda.get_device_properties(0).total_memory / 1024**3))
1127
+ requested = (requested_profile or "auto").strip().lower()
1128
+
1129
+ if requested in {"rtx-a4000", "a4000"} or (requested == "auto" and ("a4000" in name or memory_gb >= 15.0)):
1130
+ return GPUExecutionProfile(
1131
+ name="rtx-a4000",
1132
+ train_batch_by_model={"pubmedbert": 32, "biogpt": 12, "clinical_t5": 12},
1133
+ eval_batch_by_model={"pubmedbert": 64, "biogpt": 24, "clinical_t5": 24},
1134
+ grad_accum_cap_by_model={"pubmedbert": 2, "biogpt": 5, "clinical_t5": 5},
1135
+ num_workers=6,
1136
+ prefetch_factor=4,
1137
+ persistent_workers=True,
1138
+ notes="Optimized for RTX A4000 / ~16 GB VRAM with larger per-step batches.",
1139
+ )
1140
+
1141
+ if requested in {"high-vram", "16gb"} or (requested == "auto" and memory_gb >= 11.0):
1142
+ return GPUExecutionProfile(
1143
+ name="high-vram",
1144
+ train_batch_by_model={"pubmedbert": 12, "biogpt": 8, "clinical_t5": 8},
1145
+ eval_batch_by_model={"pubmedbert": 32, "biogpt": 16, "clinical_t5": 16},
1146
+ grad_accum_cap_by_model={"pubmedbert": 6, "biogpt": 8, "clinical_t5": 8},
1147
+ num_workers=2,
1148
+ prefetch_factor=2,
1149
+ persistent_workers=True,
1150
+ notes="Generic 12 GB+ CUDA profile.",
1151
+ )
1152
+
1153
+ return GPUExecutionProfile(
1154
+ name="compat",
1155
+ train_batch_by_model={"pubmedbert": 8, "biogpt": 6, "clinical_t5": 6},
1156
+ eval_batch_by_model={"pubmedbert": 16, "biogpt": 8, "clinical_t5": 8},
1157
+ grad_accum_cap_by_model={"pubmedbert": 8, "biogpt": 10, "clinical_t5": 10},
1158
+ num_workers=0,
1159
+ prefetch_factor=2,
1160
+ persistent_workers=False,
1161
+ notes="Compatibility profile for lower-VRAM GPUs.",
1162
+ )
1163
+
1164
+
1165
+ def _build_loader_kwargs(device: torch.device, profile: GPUExecutionProfile) -> Dict[str, Any]:
1166
+ kwargs: Dict[str, Any] = {
1167
+ "num_workers": profile.num_workers if device.type == "cuda" else 0,
1168
+ "pin_memory": device.type == "cuda",
1169
+ }
1170
+ if kwargs["num_workers"] > 0:
1171
+ kwargs["persistent_workers"] = profile.persistent_workers
1172
+ kwargs["prefetch_factor"] = profile.prefetch_factor
1173
+ return kwargs
1174
+
1175
+
1176
+ def _configure_transformer_runtime(selected_models: Sequence[str], allow_cpu_transformers: bool, cuda_available: Optional[bool] = None) -> str:
1177
+ cuda_ready = torch.cuda.is_available() if cuda_available is None else cuda_available
1178
+ transformer_requested = any(model_name in TRANSFORMER_MODELS for model_name in selected_models)
1179
+ if not transformer_requested:
1180
+ return "not-applicable"
1181
+ if not cuda_ready and not allow_cpu_transformers:
1182
+ raise RuntimeError(
1183
+ "Transformer retraining requires CUDA for this accuracy profile. "
1184
+ "Use a CUDA-enabled PyTorch install/GPU, or rerun with --allow-cpu-transformers if you explicitly accept slower, lower-throughput training."
1185
+ )
1186
+ if cuda_ready:
1187
+ torch.set_float32_matmul_precision("high")
1188
+ if hasattr(torch.backends, "cuda") and hasattr(torch.backends.cuda, "matmul"):
1189
+ torch.backends.cuda.matmul.allow_tf32 = True
1190
+ if hasattr(torch.backends, "cudnn"):
1191
+ torch.backends.cudnn.allow_tf32 = True
1192
+ torch.backends.cudnn.benchmark = True
1193
+ return "cuda"
1194
+ return "cpu"
1195
+
1196
+
1197
+ def _run_training(args: argparse.Namespace) -> int:
1198
+ selected_models = _parse_selected_models(args.models)
1199
+ transformer_runtime = _configure_transformer_runtime(
1200
+ selected_models,
1201
+ allow_cpu_transformers=args.allow_cpu_transformers,
1202
+ )
1203
+ controller = TrainingRunController(RUNS_DIR, args.run_name)
1204
+ controller.initialize(
1205
+ selected_models=selected_models,
1206
+ config={
1207
+ "epochs": args.epochs,
1208
+ "patience": args.patience,
1209
+ "traditional_trials": args.traditional_trials,
1210
+ "transformer_trials": args.transformer_trials,
1211
+ "use_rag": args.use_rag,
1212
+ "train_ensemble": not args.skip_ensemble,
1213
+ "allow_cpu_transformers": args.allow_cpu_transformers,
1214
+ "transformer_runtime": transformer_runtime,
1215
+ "gpu_profile": args.gpu_profile,
1216
+ "transformer_loss": args.transformer_loss,
1217
+ "focal_gamma": args.focal_gamma,
1218
+ },
1219
+ resume=args.resume,
1220
+ )
1221
+
1222
+ if args.dry_run:
1223
+ controller.mark_paused("dry-run initialized")
1224
+ print(json.dumps(controller.status_summary(), indent=2))
1225
+ return 0
1226
+
1227
+ try:
1228
+ controller.clear_stop()
1229
+ if args.resume:
1230
+ controller.clear_pause()
1231
+
1232
+ bundle = _prepare_training_bundle()
1233
+ base_metrics: List[Dict[str, Any]] = []
1234
+
1235
+ for model_name in selected_models:
1236
+ if model_name in TRADITIONAL_MODELS:
1237
+ metrics, best_config, artifact_path = _run_grouped_traditional_search(
1238
+ model_name=model_name,
1239
+ search_space=TRADITIONAL_SEARCH_SPACES[model_name],
1240
+ bundle=bundle,
1241
+ controller=controller,
1242
+ max_trials=args.traditional_trials,
1243
+ )
1244
+ else:
1245
+ metrics, best_config, artifact_path = _run_transformer_search(
1246
+ model_name=model_name,
1247
+ trial_space=TRANSFORMER_TRIALS[model_name],
1248
+ bundle=bundle,
1249
+ controller=controller,
1250
+ max_trials=args.transformer_trials,
1251
+ num_epochs=args.epochs,
1252
+ patience=args.patience,
1253
+ use_rag=args.use_rag,
1254
+ gpu_profile_name=args.gpu_profile,
1255
+ transformer_loss_name=args.transformer_loss,
1256
+ focal_gamma=args.focal_gamma,
1257
+ )
1258
+ base_metrics.append(metrics)
1259
+ controller.update_model_state(
1260
+ model_name,
1261
+ best_config=_json_ready(best_config),
1262
+ artifact_path=str(artifact_path),
1263
+ metrics={key: value for key, value in metrics.items() if key in {"Accuracy", "Precision", "Recall", "F1_Score", "AUROC", "Model", "Type"}},
1264
+ )
1265
+
1266
+ ensemble_metrics = None
1267
+ should_train_ensemble = (not args.skip_ensemble) and set(selected_models) == set(ALL_BASE_MODELS)
1268
+ if should_train_ensemble:
1269
+ ensemble_metrics = _train_ensemble(bundle, controller)
1270
+ elif not args.skip_ensemble:
1271
+ controller.update_model_state(
1272
+ "ensemble",
1273
+ status="skipped",
1274
+ reason="ensemble retraining is only automatic when all six base models are selected",
1275
+ )
1276
+
1277
+ _write_metric_outputs(base_metrics, ensemble_metrics)
1278
+ _write_run_manifest_snapshot(controller, base_metrics, ensemble_metrics)
1279
+ controller.mark_completed()
1280
+ print(json.dumps(controller.status_summary(), indent=2))
1281
+ return 0
1282
+ except PauseRequested as exc:
1283
+ controller.mark_paused(str(exc))
1284
+ print(json.dumps(controller.status_summary(), indent=2))
1285
+ return 0
1286
+ except StopRequested as exc:
1287
+ controller.mark_stopped(str(exc))
1288
+ print(json.dumps(controller.status_summary(), indent=2))
1289
+ return 0
1290
+ except KeyboardInterrupt:
1291
+ controller.mark_paused("keyboard interrupt")
1292
+ print(json.dumps(controller.status_summary(), indent=2))
1293
+ return 1
1294
+ except Exception as exc:
1295
+ controller.mark_failed(str(exc))
1296
+ raise
1297
+
1298
+
1299
+ def _run_status(args: argparse.Namespace) -> int:
1300
+ controller = TrainingRunController(RUNS_DIR, args.run_name)
1301
+ print(json.dumps(controller.status_summary(), indent=2))
1302
+ return 0
1303
+
1304
+
1305
+ def _run_pause(args: argparse.Namespace) -> int:
1306
+ controller = TrainingRunController(RUNS_DIR, args.run_name)
1307
+ controller.request_pause()
1308
+ controller.mark_paused("pause requested from CLI")
1309
+ print(json.dumps(controller.status_summary(), indent=2))
1310
+ return 0
1311
+
1312
+
1313
+ def _run_stop(args: argparse.Namespace) -> int:
1314
+ controller = TrainingRunController(RUNS_DIR, args.run_name)
1315
+ controller.request_stop()
1316
+ controller.mark_stopped("stop requested from CLI")
1317
+ print(json.dumps(controller.status_summary(), indent=2))
1318
+ return 0
1319
+
1320
+
1321
+ def build_parser() -> argparse.ArgumentParser:
1322
+ parser = argparse.ArgumentParser(description="Retrain the six base Parkinson's models with resumable checkpoints.")
1323
+ subparsers = parser.add_subparsers(dest="command", required=True)
1324
+
1325
+ train_parser = subparsers.add_parser("train", help="Start a new training run.")
1326
+ train_parser.add_argument("--run-name", default="full_retrain", help="Name of the training run.")
1327
+ train_parser.add_argument("--models", default="all", help="Comma-separated list of models or 'all'.")
1328
+ train_parser.add_argument("--epochs", type=int, default=30, help="Max epochs per transformer trial.")
1329
+ train_parser.add_argument("--patience", type=int, default=8, help="Early-stopping patience for transformers.")
1330
+ train_parser.add_argument("--traditional-trials", type=int, default=6, help="Number of traditional-model trials per model.")
1331
+ train_parser.add_argument("--transformer-trials", type=int, default=6, help="Number of transformer trials per model.")
1332
+ train_parser.add_argument("--use-rag", dest="use_rag", action="store_true", help="Build RAG contexts for transformer training.")
1333
+ train_parser.add_argument("--no-rag", dest="use_rag", action="store_false", help="Disable RAG context enrichment for transformer training.")
1334
+ train_parser.add_argument("--gpu-profile", default="auto", choices=["auto", "rtx-a4000", "high-vram", "compat"], help="CUDA loader/batch preset for transformer training.")
1335
+ train_parser.add_argument("--transformer-loss", default=DEFAULT_TRANSFORMER_LOSS, choices=["cross_entropy", "focal"], help="Loss function for transformer fine-tuning.")
1336
+ train_parser.add_argument("--focal-gamma", type=float, default=DEFAULT_FOCAL_GAMMA, help="Gamma value for focal loss when --transformer-loss focal is used.")
1337
+ train_parser.add_argument("--allow-cpu-transformers", action="store_true", help="Allow transformer training on CPU when CUDA is unavailable.")
1338
+ train_parser.add_argument("--skip-ensemble", action="store_true", help="Skip ensemble retraining after the six base models.")
1339
+ train_parser.add_argument("--resume", action="store_true", help="Resume an existing run from saved state.")
1340
+ train_parser.add_argument("--dry-run", action="store_true", help="Initialize the run manifest without training.")
1341
+ train_parser.set_defaults(func=_run_training, use_rag=True)
1342
+
1343
+ resume_parser = subparsers.add_parser("resume", help="Resume a paused training run.")
1344
+ resume_parser.add_argument("--run-name", default="full_retrain", help="Name of the training run.")
1345
+ resume_parser.add_argument("--models", default="all", help="Comma-separated list of models or 'all'.")
1346
+ resume_parser.add_argument("--epochs", type=int, default=30, help="Max epochs per transformer trial.")
1347
+ resume_parser.add_argument("--patience", type=int, default=8, help="Early-stopping patience for transformers.")
1348
+ resume_parser.add_argument("--traditional-trials", type=int, default=6, help="Number of traditional-model trials per model.")
1349
+ resume_parser.add_argument("--transformer-trials", type=int, default=6, help="Number of transformer trials per model.")
1350
+ resume_parser.add_argument("--use-rag", dest="use_rag", action="store_true", help="Build RAG contexts for transformer training.")
1351
+ resume_parser.add_argument("--no-rag", dest="use_rag", action="store_false", help="Disable RAG context enrichment for transformer training.")
1352
+ resume_parser.add_argument("--gpu-profile", default="auto", choices=["auto", "rtx-a4000", "high-vram", "compat"], help="CUDA loader/batch preset for transformer training.")
1353
+ resume_parser.add_argument("--transformer-loss", default=DEFAULT_TRANSFORMER_LOSS, choices=["cross_entropy", "focal"], help="Loss function for transformer fine-tuning.")
1354
+ resume_parser.add_argument("--focal-gamma", type=float, default=DEFAULT_FOCAL_GAMMA, help="Gamma value for focal loss when --transformer-loss focal is used.")
1355
+ resume_parser.add_argument("--allow-cpu-transformers", action="store_true", help="Allow transformer training on CPU when CUDA is unavailable.")
1356
+ resume_parser.add_argument("--skip-ensemble", action="store_true", help="Skip ensemble retraining after the six base models.")
1357
+ resume_parser.add_argument("--dry-run", action="store_true", help="Load and print the current run state without training.")
1358
+ resume_parser.set_defaults(func=_run_training, resume=True, use_rag=True)
1359
+
1360
+ pause_parser = subparsers.add_parser("pause", help="Request a graceful pause for a running training job.")
1361
+ pause_parser.add_argument("--run-name", default="full_retrain", help="Name of the training run.")
1362
+ pause_parser.set_defaults(func=_run_pause)
1363
+
1364
+ stop_parser = subparsers.add_parser("stop", help="Request a graceful stop for a running training job.")
1365
+ stop_parser.add_argument("--run-name", default="full_retrain", help="Name of the training run.")
1366
+ stop_parser.set_defaults(func=_run_stop)
1367
+
1368
+ status_parser = subparsers.add_parser("status", help="Show the current run manifest.")
1369
+ status_parser.add_argument("--run-name", default="full_retrain", help="Name of the training run.")
1370
+ status_parser.set_defaults(func=_run_status)
1371
+
1372
+ return parser
1373
+
1374
+
1375
+ def main(argv: Optional[Sequence[str]] = None) -> int:
1376
+ parser = build_parser()
1377
+ args = parser.parse_args(argv)
1378
+ return int(args.func(args))
1379
+
1380
+
1381
+ if __name__ == "__main__":
1382
+ raise SystemExit(main())
src/train_multimodal.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train and evaluate multimodal machine learning models for Parkinson's disease classification.
3
+ This script combines traditional ML, transformer models, and ensemble methods.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import numpy as np
9
+ import pandas as pd
10
+ import matplotlib.pyplot as plt
11
+ import seaborn as sns
12
+ from sklearn.metrics import classification_report, confusion_matrix
13
+ import warnings
14
+ warnings.filterwarnings('ignore')
15
+
16
+ # Add src directory to path
17
+ sys.path.append(os.path.join(os.path.dirname(__file__)))
18
+ sys.path.append(os.path.join(os.path.dirname(__file__), 'models'))
19
+
20
+ from data_preprocessing import DataPreprocessor
21
+ from models.multimodal_ml import MultimodalEnsemble, AdvancedFeatureEngineering, create_multimodal_pipeline
22
+
23
+
24
+ def main():
25
+ """Main function to run multimodal ML pipeline."""
26
+ print("Multimodal Machine Learning for Parkinson's Disease Classification")
27
+ print("=" * 70)
28
+
29
+ # --- Load and prepare data ---
30
+ preprocessor = DataPreprocessor()
31
+ base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
32
+ file_paths = [
33
+ os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20241211.csv"),
34
+ os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20250321.csv"),
35
+ os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20250714.csv"),
36
+ ]
37
+
38
+ print("\nLoading and preparing data with patient-level split...")
39
+ X_train, X_test, y_train, y_test = preprocessor.prepare_data(
40
+ file_paths,
41
+ test_size=0.2,
42
+ use_patient_split=True,
43
+ )
44
+ print(f" Train: {X_train.shape}, Test: {X_test.shape}")
45
+
46
+ # Convert to numpy arrays if they are DataFrames
47
+ if isinstance(X_train, pd.DataFrame):
48
+ X_train_np = X_train.values
49
+ X_test_np = X_test.values
50
+ else:
51
+ X_train_np = X_train
52
+ X_test_np = X_test
53
+
54
+ if isinstance(y_train, pd.Series):
55
+ y_train_np = y_train.values
56
+ y_test_np = y_test.values
57
+ else:
58
+ y_train_np = y_train
59
+ y_test_np = y_test
60
+
61
+ # Create multimodal pipeline
62
+ print("\nCreating multimodal ML pipeline...")
63
+ ensemble, results = create_multimodal_pipeline(X_train, X_test, y_train_np, y_test_np)
64
+
65
+ # Detailed evaluation of ensemble
66
+ if ensemble.ensemble_model is not None:
67
+ print("\nDetailed Ensemble Evaluation:")
68
+ print("-" * 40)
69
+
70
+ ensemble_results = ensemble.evaluate_ensemble(X_test, y_test_np)
71
+
72
+ print(f"Ensemble Accuracy: {ensemble_results['accuracy']:.4f}")
73
+ print("\nClassification Report:")
74
+ print(ensemble_results['classification_report'])
75
+
76
+ # Plot confusion matrix
77
+ os.makedirs('notebooks', exist_ok=True)
78
+ plt.figure(figsize=(10, 8))
79
+ sns.heatmap(ensemble_results['confusion_matrix'],
80
+ annot=True, fmt='d', cmap='Blues',
81
+ xticklabels=['HC', 'PD', 'SWEDD', 'PRODROMAL'],
82
+ yticklabels=['HC', 'PD', 'SWEDD', 'PRODROMAL'])
83
+ plt.title('Multimodal Ensemble - Confusion Matrix')
84
+ plt.ylabel('True Label')
85
+ plt.xlabel('Predicted Label')
86
+ plt.tight_layout()
87
+ plt.savefig('notebooks/multimodal_confusion_matrix.png', dpi=300, bbox_inches='tight')
88
+ plt.close()
89
+
90
+ print("Confusion matrix saved to notebooks/multimodal_confusion_matrix.png")
91
+
92
+ # Advanced analysis
93
+ print("\nAdvanced Multimodal Analysis:")
94
+ print("-" * 40)
95
+
96
+ # Feature importance analysis (if available)
97
+ if hasattr(ensemble.ensemble_model, 'coef_'):
98
+ feature_importance = np.abs(ensemble.ensemble_model.coef_).mean(axis=0)
99
+ print("Top 10 most important ensemble features:")
100
+ top_indices = np.argsort(feature_importance)[-10:][::-1]
101
+ for i, idx in enumerate(top_indices):
102
+ print(f"{i+1:2d}. Feature {idx}: {feature_importance[idx]:.4f}")
103
+
104
+ # Model diversity analysis
105
+ print("\nModel Diversity Analysis:")
106
+ if len(ensemble.traditional_models) > 0 and len(ensemble.transformer_models) > 0:
107
+ trad_preds, _ = ensemble.get_traditional_predictions(X_test)
108
+ trans_preds, _ = ensemble.get_transformer_predictions(X_test_np)
109
+
110
+ if trad_preds and trans_preds:
111
+ trad_pred = list(trad_preds.values())[0]
112
+ trans_pred = list(trans_preds.values())[0]
113
+
114
+ agreement = np.mean(trad_pred == trans_pred)
115
+ print(f"Agreement between traditional and transformer models: {agreement:.4f}")
116
+
117
+ # Performance summary
118
+ print("\nFinal Performance Summary:")
119
+ print("-" * 40)
120
+ best_model = max(results.items(), key=lambda x: x[1])
121
+ print(f"Best performing model: {best_model[0]} (Accuracy: {best_model[1]:.4f})")
122
+
123
+ if 'Ensemble' in results:
124
+ ensemble_acc = results['Ensemble']
125
+ individual_accs = [acc for name, acc in results.items() if name != 'Ensemble']
126
+ if individual_accs:
127
+ avg_individual = np.mean(individual_accs)
128
+ improvement = ensemble_acc - avg_individual
129
+ print(f"Ensemble improvement over average individual model: {improvement:.4f}")
130
+
131
+ print("\nMultimodal ML pipeline completed successfully!")
132
+ print("Results and visualizations saved to notebooks/ directory")
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main()
src/train_traditional_models.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Train and evaluate traditional ML models (LightGBM, XGBoost, SVM) on PPMI dataset
3
+ with patient-level split to prevent data leakage.
4
+ """
5
+ from data_preprocessing import DataPreprocessor
6
+ from models.traditional_ml import TraditionalMLModels
7
+ import pandas as pd
8
+ import numpy as np
9
+ import matplotlib.pyplot as plt
10
+ import seaborn as sns
11
+ import os
12
+ from lightgbm import LGBMClassifier
13
+ from xgboost import XGBClassifier
14
+ from sklearn.svm import SVC
15
+
16
+
17
+ def plot_confusion_matrices(results, save_dir='../notebooks'):
18
+ """Plot confusion matrices for all models."""
19
+ os.makedirs(save_dir, exist_ok=True)
20
+
21
+ for model_name, result in results.items():
22
+ plt.figure(figsize=(8, 6))
23
+ sns.heatmap(result['confusion_matrix'], annot=True, fmt='d', cmap='Blues')
24
+ plt.title(f'Confusion Matrix - {model_name}')
25
+ plt.xlabel('Predicted')
26
+ plt.ylabel('True')
27
+ plt.savefig(os.path.join(save_dir, f'{model_name}_confusion_matrix.png'))
28
+ plt.close()
29
+
30
+
31
+ def plot_roc_curves(results, y_test, save_dir='../notebooks'):
32
+ """Plot ROC curves for all models using one-vs-rest approach."""
33
+ from sklearn.metrics import roc_curve, auc
34
+ from sklearn.preprocessing import label_binarize
35
+
36
+ n_classes = len(np.unique(y_test))
37
+ y_test_bin = label_binarize(y_test, classes=range(n_classes))
38
+
39
+ fig, axes = plt.subplots(2, 2, figsize=(15, 12))
40
+ axes = axes.ravel()
41
+
42
+ for i in range(n_classes):
43
+ for model_name, result in results.items():
44
+ if 'probabilities' not in result:
45
+ continue
46
+ y_prob = result['probabilities'][:, i]
47
+ fpr, tpr, _ = roc_curve(y_test_bin[:, i], y_prob)
48
+ roc_auc = auc(fpr, tpr)
49
+
50
+ axes[i].plot(fpr, tpr, label=f'{model_name} (AUC = {roc_auc:.2f})')
51
+ axes[i].plot([0, 1], [0, 1], 'k--')
52
+ axes[i].set_xlim([0.0, 1.0])
53
+ axes[i].set_ylim([0.0, 1.05])
54
+ axes[i].set_xlabel('False Positive Rate')
55
+ axes[i].set_ylabel('True Positive Rate')
56
+ axes[i].set_title(f'ROC Curve - Class {i}')
57
+ axes[i].legend(loc="lower right")
58
+
59
+ plt.tight_layout()
60
+ plt.savefig(os.path.join(save_dir, 'roc_curves.png'))
61
+ plt.close()
62
+
63
+
64
+ def main():
65
+ print("=" * 80)
66
+ print("TRAINING TRADITIONAL ML MODELS WITH PATIENT-LEVEL SPLIT")
67
+ print("=" * 80)
68
+
69
+ # Initialize preprocessor
70
+ preprocessor = DataPreprocessor()
71
+
72
+ # Use all available datasets
73
+ base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
74
+ file_paths = [
75
+ os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20241211.csv"),
76
+ os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20250321.csv"),
77
+ os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20250714.csv"),
78
+ ]
79
+
80
+ print("\n" + "=" * 80)
81
+ print("STEP 1: LOADING AND PREPARING DATA")
82
+ print("=" * 80)
83
+
84
+ X_train, X_test, y_train, y_test = preprocessor.prepare_data(
85
+ file_paths,
86
+ test_size=0.2,
87
+ use_patient_split=True,
88
+ )
89
+
90
+ print("\n" + "=" * 80)
91
+ print("STEP 2: CALCULATING CLASS WEIGHTS")
92
+ print("=" * 80)
93
+
94
+ from sklearn.utils.class_weight import compute_class_weight
95
+
96
+ class_weights = compute_class_weight('balanced', classes=np.unique(y_train), y=y_train)
97
+ class_weight_dict = dict(zip(np.unique(y_train), class_weights))
98
+ print("\nClass weights:", class_weight_dict)
99
+
100
+ print("\n" + "=" * 80)
101
+ print("STEP 3: INITIALIZING MODELS")
102
+ print("=" * 80)
103
+
104
+ ml_models = TraditionalMLModels()
105
+
106
+ ml_models.models['lightgbm'] = LGBMClassifier(
107
+ random_state=42,
108
+ class_weight=class_weight_dict,
109
+ objective='multiclass',
110
+ num_class=len(class_weight_dict),
111
+ n_estimators=200,
112
+ learning_rate=0.05,
113
+ max_depth=7,
114
+ )
115
+ ml_models.models['xgboost'] = XGBClassifier(
116
+ random_state=42,
117
+ objective='multi:softmax',
118
+ num_class=len(class_weight_dict),
119
+ n_estimators=200,
120
+ learning_rate=0.05,
121
+ max_depth=6,
122
+ )
123
+ ml_models.models['svm'] = SVC(
124
+ random_state=42,
125
+ class_weight=class_weight_dict,
126
+ probability=True,
127
+ decision_function_shape='ovr',
128
+ kernel='rbf',
129
+ C=10.0,
130
+ gamma='scale',
131
+ )
132
+
133
+ print("\n" + "=" * 80)
134
+ print("STEP 4: TRAINING MODELS")
135
+ print("=" * 80)
136
+ print("\nTraining models...")
137
+ cv_scores = ml_models.train_all_models(X_train, y_train)
138
+
139
+ print("\n" + "=" * 80)
140
+ print("STEP 5: TRAINING SCORES")
141
+ print("=" * 80)
142
+ print("\nTraining scores:")
143
+ for model_name, score in cv_scores.items():
144
+ print(f" {model_name}: {score:.4f}")
145
+
146
+ print("\n" + "=" * 80)
147
+ print("STEP 6: EVALUATING ON TEST SET")
148
+ print("=" * 80)
149
+ print("\nEvaluating models on test set...")
150
+ results = ml_models.evaluate_all_models(X_test, y_test)
151
+
152
+ print("\n" + "=" * 80)
153
+ print("STEP 7: FINAL RESULTS")
154
+ print("=" * 80)
155
+ for model_name, result in results.items():
156
+ print(f"\n{model_name.upper()} Results:")
157
+ print(f" Accuracy: {result['accuracy']:.4f}")
158
+ print(f"\nClassification Report:")
159
+ print(result['classification_report'])
160
+
161
+ print("\n" + "=" * 80)
162
+ print("STEP 8: GENERATING VISUALIZATIONS")
163
+ print("=" * 80)
164
+ plot_confusion_matrices(results)
165
+ plot_roc_curves(results, y_test)
166
+ print("\nVisualizations saved to notebooks/")
167
+
168
+ print("\n" + "=" * 80)
169
+ print("STEP 9: SAVING MODELS")
170
+ print("=" * 80)
171
+ print("\nSaving models...")
172
+ ml_models.save_models()
173
+
174
+ print("\n" + "=" * 80)
175
+ print("TRAINING COMPLETE!")
176
+ print("=" * 80)
177
+ print(f"\n[OK] Models trained with patient-level split (no leakage)")
178
+ print(f"[OK] {len(results)} models saved to models/saved/")
179
+ print(f"[OK] Visualizations saved to notebooks/")
180
+ print(f"[OK] Training data: {X_train.shape[0]} samples")
181
+ print(f"[OK] Test data: {X_test.shape[0]} samples")
182
+ print(f"[OK] Features: {X_train.shape[1]}")
183
+ print("\n" + "=" * 80)
184
+
185
+
186
+ if __name__ == "__main__":
187
+ main()
src/train_transformer_models.py ADDED
@@ -0,0 +1,863 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Training script for transformer-based models (BioGPT, Clinical-T5, PubMedBERT)
3
+ on the PPMI dataset with RAG integration, CUDA acceleration, and leak-free patient split.
4
+
5
+ Usage:
6
+ cd src
7
+ python train_transformer_models.py
8
+ """
9
+
10
+ import sys
11
+ import argparse
12
+ import os
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
16
+
17
+ import torch
18
+ import numpy as np
19
+ from torch.utils.data import DataLoader, Subset
20
+ from sklearn.utils.class_weight import compute_class_weight
21
+ from sklearn.metrics import (
22
+ classification_report, confusion_matrix,
23
+ f1_score, precision_score, recall_score, roc_auc_score
24
+ )
25
+ from sklearn.model_selection import StratifiedShuffleSplit
26
+ import matplotlib.pyplot as plt
27
+ import seaborn as sns
28
+ import pandas as pd
29
+ import joblib
30
+ import time
31
+ import json
32
+ from tqdm import tqdm
33
+
34
+ from data_preprocessing import DataPreprocessor
35
+ from models.transformer_models import TabularDataset
36
+ from models.medical_transformers import (
37
+ BioMistralClassifier as BioGPTForTabular,
38
+ ClinicalT5Classifier as ClinicalT5ForTabular,
39
+ PubMedBERTClassifier as PubMedBERTForTabular,
40
+ )
41
+ from document_manager import DocumentManager
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Paths
46
+ # ---------------------------------------------------------------------------
47
+ ROOT = Path(__file__).resolve().parents[1]
48
+ LEAK_FREE_SPLIT_PATH = ROOT / "evaluation_results" / "leak_free_split.npz"
49
+ LEAK_FREE_META_PATH = ROOT / "evaluation_results" / "leak_free_split_meta.joblib"
50
+ MODEL_DIR = ROOT / "models" / "saved"
51
+ RESULTS_DIR = ROOT / "evaluation_results"
52
+ PLOTS_DIR = ROOT / "evaluation_results" / "transformer_plots"
53
+
54
+ # [CONFIG] Set to True if you want to use RAG (slower start), False for faster training
55
+ USE_RAG = True
56
+ REQUIRE_CUDA = os.getenv("PD_ALLOW_CPU_TRANSFORMERS", "0") != "1"
57
+
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Helpers
62
+ # ---------------------------------------------------------------------------
63
+ def _load_or_create_leak_free_split(preprocessor, file_paths):
64
+ """Load the cached leak-free split or regenerate it if missing."""
65
+ if LEAK_FREE_SPLIT_PATH.exists() and LEAK_FREE_META_PATH.exists():
66
+ split = np.load(LEAK_FREE_SPLIT_PATH)
67
+ meta = joblib.load(LEAK_FREE_META_PATH)
68
+ feature_names = meta.get("feature_names") if isinstance(meta, dict) else None
69
+ class_mapping = meta.get("class_mapping") if isinstance(meta, dict) else None
70
+ print("[DATA] Loaded cached leak-free split from evaluation_results.")
71
+ return (
72
+ split["X_train"], split["X_test"],
73
+ split["y_train"], split["y_test"],
74
+ feature_names, class_mapping,
75
+ )
76
+
77
+ print("[DATA] Leak-free split not found – regenerating via DataPreprocessor ...")
78
+ X_train, X_test, y_train, y_test = preprocessor.prepare_data(
79
+ file_paths, test_size=0.2, use_patient_split=True,
80
+ )
81
+ feature_names = preprocessor.get_feature_names()
82
+ class_mapping = preprocessor.get_class_mapping()
83
+
84
+ LEAK_FREE_SPLIT_PATH.parent.mkdir(parents=True, exist_ok=True)
85
+ np.savez(
86
+ LEAK_FREE_SPLIT_PATH,
87
+ X_train=X_train, X_test=X_test,
88
+ y_train=y_train, y_test=y_test,
89
+ )
90
+ joblib.dump(
91
+ {"feature_names": feature_names, "class_mapping": class_mapping},
92
+ LEAK_FREE_META_PATH,
93
+ )
94
+ print(f"[DATA] Saved fresh leak-free split → {LEAK_FREE_SPLIT_PATH}")
95
+ return X_train, X_test, y_train, y_test, feature_names, class_mapping
96
+
97
+
98
+ def _stratified_val_indices(y_train, val_fraction=0.15, seed=42):
99
+ """Return (train_idx, val_idx) using a stratified split so every class
100
+ is proportionally represented in the validation set."""
101
+ sss = StratifiedShuffleSplit(n_splits=1, test_size=val_fraction, random_state=seed)
102
+ train_idx, val_idx = next(sss.split(np.zeros(len(y_train)), y_train))
103
+ return train_idx, val_idx
104
+
105
+
106
+ def _prepare_batch(batch, device):
107
+ """Move tensors to the target device and keep optional RAG contexts aligned."""
108
+ if len(batch) == 3:
109
+ data, targets, contexts = batch
110
+ else:
111
+ data, targets = batch
112
+ contexts = None
113
+ data = data.to(device, non_blocking=True)
114
+ targets = targets.to(device, non_blocking=True)
115
+ if contexts is not None:
116
+ contexts = list(contexts)
117
+ return data, targets, contexts
118
+
119
+
120
+ def _build_context_cache(features, build_fn, split_name="dataset", cache_path=None):
121
+ """Pre-compute RAG contexts with parallel processing and caching."""
122
+ if not USE_RAG:
123
+ return [""] * len(features)
124
+
125
+ if cache_path and os.path.exists(cache_path):
126
+ print(f" [RAG] Loading cached contexts from {cache_path}")
127
+ return joblib.load(cache_path)
128
+
129
+ print(f" [RAG] Generating contexts for {len(features)} samples (Parallel)...")
130
+ from joblib import Parallel, delayed
131
+
132
+ # Run in parallel to speed up regex/cosine-sim
133
+ contexts = Parallel(n_jobs=-1, verbose=5)(
134
+ delayed(build_fn)(row) for row in features
135
+ )
136
+
137
+ if cache_path:
138
+ joblib.dump(contexts, cache_path)
139
+ print(f" [RAG] Saved contexts to {cache_path}")
140
+
141
+ return contexts
142
+
143
+
144
+
145
+ def _print_gpu_info(device):
146
+ """Print GPU diagnostics."""
147
+ if device.type != "cuda":
148
+ return
149
+ print(f" GPU Name : {torch.cuda.get_device_name(0)}")
150
+ print(f" CUDA Version : {torch.version.cuda}")
151
+ cap = torch.cuda.get_device_capability(0)
152
+ print(f" Compute Cap. : {cap[0]}.{cap[1]}")
153
+ mem_total = torch.cuda.get_device_properties(0).total_memory / 1024**3
154
+ print(f" Total VRAM : {mem_total:.1f} GB")
155
+ print(f" cuDNN Enabled : {torch.backends.cudnn.enabled}")
156
+ print(f" cuDNN Benchmark: {torch.backends.cudnn.benchmark}")
157
+
158
+
159
+ def _ensure_transformer_cuda() -> None:
160
+ if not torch.cuda.is_available():
161
+ if REQUIRE_CUDA:
162
+ raise RuntimeError(
163
+ "CUDA is required for transformer training in this accuracy-oriented configuration. "
164
+ "Install a CUDA-enabled PyTorch build and use a GPU, or set PD_ALLOW_CPU_TRANSFORMERS=1 to explicitly allow CPU fallback."
165
+ )
166
+ return
167
+ torch.set_float32_matmul_precision("highest")
168
+ if hasattr(torch.backends, "cuda") and hasattr(torch.backends.cuda, "matmul"):
169
+ torch.backends.cuda.matmul.allow_tf32 = False
170
+ if hasattr(torch.backends, "cudnn"):
171
+ torch.backends.cudnn.allow_tf32 = False
172
+ torch.backends.cudnn.benchmark = True
173
+
174
+
175
+ @dataclass(frozen=True)
176
+ class GPUExecutionProfile:
177
+ name: str
178
+ train_batch_by_model: dict
179
+ eval_batch_by_model: dict
180
+ grad_accum_by_model: dict
181
+ num_workers: int
182
+ prefetch_factor: int
183
+ persistent_workers: bool
184
+ notes: str
185
+
186
+
187
+ def _detect_gpu_execution_profile():
188
+ if not torch.cuda.is_available():
189
+ return GPUExecutionProfile(
190
+ name="cpu",
191
+ train_batch_by_model={"pubmedbert": 4, "biogpt": 2, "clinical_t5": 2},
192
+ eval_batch_by_model={"pubmedbert": 8, "biogpt": 4, "clinical_t5": 4},
193
+ grad_accum_by_model={"pubmedbert": 12, "biogpt": 16, "clinical_t5": 16},
194
+ num_workers=0,
195
+ prefetch_factor=2,
196
+ persistent_workers=False,
197
+ notes="CPU fallback profile.",
198
+ )
199
+
200
+ gpu_name = torch.cuda.get_device_name(0).lower()
201
+ memory_gb = torch.cuda.get_device_properties(0).total_memory / 1024**3
202
+
203
+ if "a4000" in gpu_name or memory_gb >= 15.0:
204
+ return GPUExecutionProfile(
205
+ name="rtx-a4000",
206
+ train_batch_by_model={"pubmedbert": 16, "biogpt": 10, "clinical_t5": 10},
207
+ eval_batch_by_model={"pubmedbert": 48, "biogpt": 24, "clinical_t5": 24},
208
+ grad_accum_by_model={"pubmedbert": 4, "biogpt": 6, "clinical_t5": 6},
209
+ num_workers=4,
210
+ prefetch_factor=2,
211
+ persistent_workers=True,
212
+ notes="Optimized for RTX A4000 / ~16 GB VRAM.",
213
+ )
214
+
215
+ if memory_gb >= 11.0:
216
+ return GPUExecutionProfile(
217
+ name="high-vram",
218
+ train_batch_by_model={"pubmedbert": 12, "biogpt": 8, "clinical_t5": 8},
219
+ eval_batch_by_model={"pubmedbert": 32, "biogpt": 16, "clinical_t5": 16},
220
+ grad_accum_by_model={"pubmedbert": 6, "biogpt": 8, "clinical_t5": 8},
221
+ num_workers=2,
222
+ prefetch_factor=2,
223
+ persistent_workers=True,
224
+ notes="Generic 12 GB+ CUDA profile.",
225
+ )
226
+
227
+ return GPUExecutionProfile(
228
+ name="compat",
229
+ train_batch_by_model={"pubmedbert": 8, "biogpt": 6, "clinical_t5": 6},
230
+ eval_batch_by_model={"pubmedbert": 16, "biogpt": 8, "clinical_t5": 8},
231
+ grad_accum_by_model={"pubmedbert": 8, "biogpt": 10, "clinical_t5": 10},
232
+ num_workers=0,
233
+ prefetch_factor=2,
234
+ persistent_workers=False,
235
+ notes="Compatibility profile for lower-VRAM GPUs.",
236
+ )
237
+
238
+
239
+ def _build_loader_kwargs(device, profile):
240
+ kwargs = {
241
+ "pin_memory": device.type == "cuda",
242
+ "num_workers": profile.num_workers if device.type == "cuda" else 0,
243
+ }
244
+ if kwargs["num_workers"] > 0:
245
+ kwargs["persistent_workers"] = profile.persistent_workers
246
+ kwargs["prefetch_factor"] = profile.prefetch_factor
247
+ return kwargs
248
+
249
+ def _parse_selected_models(raw: str):
250
+ raw = (raw or 'all').strip().lower()
251
+ if raw in ('all', '*'):
252
+ return None
253
+ alias = {
254
+ 'pubmed': 'pubmedbert',
255
+ 'pubmedbert': 'pubmedbert',
256
+ 'biogpt': 'biogpt',
257
+ 'bio': 'biogpt',
258
+ 'clinical': 'clinical_t5',
259
+ 'clinical_t5': 'clinical_t5',
260
+ 't5': 'clinical_t5',
261
+ }
262
+ out = []
263
+ for part in [p.strip() for p in raw.split(',') if p.strip()]:
264
+ out.append(alias.get(part, part))
265
+ return set(out) if out else None
266
+
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # Training loop
271
+ # ---------------------------------------------------------------------------
272
+ def train_one_model(
273
+ model, optimizer, scheduler, criterion, scaler,
274
+ train_loader, val_loader, device, model_name,
275
+ num_epochs=25, patience=8, grad_accum_steps=2,
276
+ checkpoint_dir=None,
277
+ ):
278
+ """Train a single model with mixed precision, gradient accumulation, and
279
+ early stopping. Returns the best model state dict and training history."""
280
+
281
+ checkpoint_path = checkpoint_dir / f"{model_name}_ckpt.pth" if checkpoint_dir else None
282
+ history = {"train_loss": [], "val_loss": [], "val_acc": [], "val_f1": [], "lr": []}
283
+ best_val_loss = float("inf")
284
+ early_stop_counter = 0
285
+ start_epoch = 0
286
+
287
+ # Resume from checkpoint if available
288
+ if checkpoint_path and checkpoint_path.exists():
289
+ print(f" [CKPT] Found checkpoint at {checkpoint_path}")
290
+ try:
291
+ ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
292
+ model.load_state_dict(ckpt["model_state_dict"])
293
+ optimizer.load_state_dict(ckpt["optimizer_state_dict"])
294
+ start_epoch = ckpt["epoch"] + 1
295
+ best_val_loss = ckpt["best_val_loss"]
296
+ history = ckpt.get("history", history)
297
+ print(f" [CKPT] Resuming from epoch {start_epoch} (best val loss {best_val_loss:.4f})")
298
+ except Exception as e:
299
+ print(f" [CKPT] Could not load checkpoint: {e}. Starting fresh.")
300
+ start_epoch = 0
301
+
302
+ use_amp = device.type == "cuda"
303
+
304
+ for epoch in range(start_epoch, num_epochs):
305
+ t0 = time.time()
306
+ model.train()
307
+ running_loss = 0.0
308
+ optimizer.zero_grad(set_to_none=True)
309
+
310
+ # Progress bar for training
311
+ pbar = tqdm(enumerate(train_loader), total=len(train_loader),
312
+ desc=f"Epoch {epoch+1}/{num_epochs}", unit="batch", ncols=100, ascii=True)
313
+
314
+ for batch_idx, batch in pbar:
315
+ data, targets, contexts = _prepare_batch(batch, device)
316
+
317
+ with torch.amp.autocast(device_type=device.type, enabled=use_amp):
318
+ outputs = model(data, contexts)
319
+ loss = criterion(outputs, targets) / grad_accum_steps
320
+
321
+ scaler.scale(loss).backward()
322
+
323
+ if (batch_idx + 1) % grad_accum_steps == 0 or (batch_idx + 1) == len(train_loader):
324
+ scaler.unscale_(optimizer)
325
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
326
+ scaler.step(optimizer)
327
+ scaler.update()
328
+ optimizer.zero_grad(set_to_none=True)
329
+
330
+ current_loss = loss.item() * grad_accum_steps
331
+ running_loss += current_loss
332
+
333
+ # Update progress bar every few batches to reduce overhead
334
+ if batch_idx % 10 == 0:
335
+ pbar.set_postfix(loss=f"{current_loss:.4f}", lr=f"{optimizer.param_groups[0]['lr']:.2e}")
336
+
337
+ avg_train_loss = running_loss / len(train_loader)
338
+ history["train_loss"].append(avg_train_loss)
339
+ history["lr"].append(optimizer.param_groups[0]["lr"])
340
+
341
+ # ---- Validation ----
342
+ model.eval()
343
+ val_loss = 0.0
344
+ all_preds, all_targets = [], []
345
+
346
+ with torch.no_grad():
347
+ for batch in tqdm(val_loader, desc=f"Val {epoch+1}/{num_epochs}", unit="batch", leave=False, ncols=100, ascii=True):
348
+ data, targets, contexts = _prepare_batch(batch, device)
349
+ with torch.amp.autocast(device_type=device.type, enabled=use_amp):
350
+ outputs = model(data, contexts)
351
+ loss = criterion(outputs, targets)
352
+ val_loss += loss.item()
353
+ _, predicted = outputs.max(1)
354
+ all_preds.extend(predicted.cpu().numpy())
355
+ all_targets.extend(targets.cpu().numpy())
356
+
357
+ avg_val_loss = val_loss / len(val_loader)
358
+ val_acc = 100 * np.mean(np.array(all_preds) == np.array(all_targets))
359
+ val_f1 = f1_score(all_targets, all_preds, average="weighted")
360
+
361
+ history["val_loss"].append(avg_val_loss)
362
+ history["val_acc"].append(val_acc)
363
+ history["val_f1"].append(val_f1)
364
+ scheduler.step(avg_val_loss)
365
+
366
+ elapsed = time.time() - t0
367
+ gpu_mem = torch.cuda.memory_allocated(0) / 1024**2 if device.type == "cuda" else 0
368
+
369
+ print(
370
+ f" Epoch {epoch+1:02d}/{num_epochs} │ "
371
+ f"Train Loss {avg_train_loss:.4f} │ Val Loss {avg_val_loss:.4f} │ "
372
+ f"Val Acc {val_acc:.2f}% │ Val F1 {val_f1:.4f} │ "
373
+ f"LR {optimizer.param_groups[0]['lr']:.2e} │ "
374
+ f"GPU {gpu_mem:.0f}MB │ {elapsed:.1f}s"
375
+ )
376
+
377
+ # ---- Checkpointing & early stopping ----
378
+ # Save every epoch so interrupted runs can resume from latest epoch.
379
+ if checkpoint_path:
380
+ torch.save({
381
+ "epoch": epoch,
382
+ "model_state_dict": model.state_dict(),
383
+ "optimizer_state_dict": optimizer.state_dict(),
384
+ "best_val_loss": best_val_loss,
385
+ "history": history,
386
+ }, checkpoint_path)
387
+
388
+ if avg_val_loss < best_val_loss:
389
+ best_val_loss = avg_val_loss
390
+ if checkpoint_path:
391
+ torch.save({
392
+ "epoch": epoch,
393
+ "model_state_dict": model.state_dict(),
394
+ "optimizer_state_dict": optimizer.state_dict(),
395
+ "best_val_loss": best_val_loss,
396
+ "history": history,
397
+ }, checkpoint_path)
398
+ early_stop_counter = 0
399
+ print(f" [*] New best (val loss {best_val_loss:.4f})")
400
+ else:
401
+ early_stop_counter += 1
402
+ if early_stop_counter >= patience:
403
+ print(f" [X] Early stopping after {epoch+1} epochs")
404
+ break
405
+
406
+ # Load best weights
407
+ if checkpoint_path and checkpoint_path.exists():
408
+ ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
409
+ model.load_state_dict(ckpt["model_state_dict"])
410
+ checkpoint_path.unlink()
411
+
412
+ return model, history, best_val_loss
413
+
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # Evaluation
417
+ # ---------------------------------------------------------------------------
418
+ def evaluate_on_test(model, test_loader, criterion, device, model_name, class_names):
419
+ """Full evaluation on the held-out test set."""
420
+ model.eval()
421
+ use_amp = device.type == "cuda"
422
+ all_preds, all_targets, all_probs = [], [], []
423
+ test_loss = 0.0
424
+
425
+ with torch.no_grad():
426
+ for batch in tqdm(test_loader, desc=f"Evaluating {model_name}", unit="batch"):
427
+ data, targets, contexts = _prepare_batch(batch, device)
428
+ with torch.amp.autocast(device_type=device.type, enabled=use_amp):
429
+ outputs = model(data, contexts)
430
+ loss = criterion(outputs, targets)
431
+ test_loss += loss.item()
432
+ probs = torch.softmax(outputs, dim=1)
433
+ _, predicted = outputs.max(1)
434
+ all_preds.extend(predicted.cpu().numpy())
435
+ all_targets.extend(targets.cpu().numpy())
436
+ all_probs.extend(probs.cpu().numpy())
437
+
438
+ all_preds = np.array(all_preds)
439
+ all_targets = np.array(all_targets)
440
+ all_probs = np.array(all_probs)
441
+
442
+ accuracy = np.mean(all_preds == all_targets)
443
+ f1 = f1_score(all_targets, all_preds, average="weighted")
444
+ precision = precision_score(all_targets, all_preds, average="weighted")
445
+ recall = recall_score(all_targets, all_preds, average="weighted")
446
+
447
+ try:
448
+ auroc = roc_auc_score(all_targets, all_probs, multi_class="ovr", average="weighted")
449
+ except Exception:
450
+ auroc = 0.0
451
+
452
+ report = classification_report(all_targets, all_preds, target_names=class_names)
453
+ cm = confusion_matrix(all_targets, all_preds)
454
+
455
+ print(f"\n{'='*70}")
456
+ print(f" {model_name.upper()} — TEST SET RESULTS")
457
+ print(f"{'='*70}")
458
+ print(f" Accuracy : {accuracy:.4f} ({accuracy*100:.2f}%)")
459
+ print(f" F1 Score : {f1:.4f}")
460
+ print(f" Precision : {precision:.4f}")
461
+ print(f" Recall : {recall:.4f}")
462
+ print(f" AUROC : {auroc:.4f}")
463
+ print(f"\n{report}")
464
+
465
+ return {
466
+ "accuracy": accuracy, "f1": f1, "precision": precision,
467
+ "recall": recall, "auroc": auroc,
468
+ "classification_report": report, "confusion_matrix": cm,
469
+ "predictions": all_preds, "targets": all_targets, "probabilities": all_probs,
470
+ }
471
+
472
+
473
+ # ---------------------------------------------------------------------------
474
+ # Plotting
475
+ # ---------------------------------------------------------------------------
476
+ def save_plots(results, history_dict, class_names, plots_dir):
477
+ """Save confusion matrices, training curves, and comparison charts."""
478
+ plots_dir = Path(plots_dir)
479
+ plots_dir.mkdir(parents=True, exist_ok=True)
480
+
481
+ for name, res in results.items():
482
+ # Confusion matrix
483
+ plt.figure(figsize=(8, 6))
484
+ sns.heatmap(res["confusion_matrix"], annot=True, fmt="d", cmap="Blues",
485
+ xticklabels=class_names, yticklabels=class_names)
486
+ plt.title(f"{name} — Confusion Matrix (Leak-Free Split)")
487
+ plt.xlabel("Predicted")
488
+ plt.ylabel("True")
489
+ plt.tight_layout()
490
+ plt.savefig(plots_dir / f"{name}_confusion_matrix.png", dpi=200)
491
+ plt.close()
492
+
493
+ # Training curves
494
+ hist = history_dict[name]
495
+ fig, axes = plt.subplots(1, 3, figsize=(18, 5))
496
+
497
+ axes[0].plot(hist["train_loss"], label="Train", color="#3498db")
498
+ axes[0].plot(hist["val_loss"], label="Val", color="#e74c3c")
499
+ axes[0].set_title(f"{name} — Loss")
500
+ axes[0].set_xlabel("Epoch")
501
+ axes[0].legend()
502
+ axes[0].grid(True, alpha=0.3)
503
+
504
+ axes[1].plot(hist["val_acc"], color="#2ecc71")
505
+ axes[1].set_title(f"{name} — Val Accuracy (%)")
506
+ axes[1].set_xlabel("Epoch")
507
+ axes[1].grid(True, alpha=0.3)
508
+
509
+ axes[2].plot(hist["lr"], color="#9b59b6")
510
+ axes[2].set_title(f"{name} — Learning Rate")
511
+ axes[2].set_xlabel("Epoch")
512
+ axes[2].grid(True, alpha=0.3)
513
+
514
+ plt.tight_layout()
515
+ plt.savefig(plots_dir / f"{name}_training_curves.png", dpi=200)
516
+ plt.close()
517
+
518
+ # Comparison bar chart
519
+ model_names = list(results.keys())
520
+ metric_names = ["accuracy", "f1", "precision", "recall", "auroc"]
521
+ fig, axes = plt.subplots(1, len(metric_names), figsize=(5 * len(metric_names), 5))
522
+ colors = ["#3498db", "#2ecc71", "#e74c3c"]
523
+
524
+ for ax, metric in zip(axes, metric_names):
525
+ values = [results[m][metric] for m in model_names]
526
+ bars = ax.bar(model_names, values, color=colors[:len(model_names)])
527
+ ax.set_title(metric.upper())
528
+ ax.set_ylim(0, 1.05)
529
+ for bar, val in zip(bars, values):
530
+ ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01,
531
+ f"{val:.3f}", ha="center", va="bottom", fontsize=9)
532
+
533
+ plt.tight_layout()
534
+ plt.savefig(plots_dir / "transformer_comparison.png", dpi=200)
535
+ plt.close()
536
+
537
+ print(f"[PLOT] Saved all plots → {plots_dir}")
538
+
539
+
540
+ # ---------------------------------------------------------------------------
541
+ # Main
542
+ # ---------------------------------------------------------------------------
543
+ def main():
544
+ print("=" * 70)
545
+ print(" TRANSFORMER MODEL TRAINING — LEAK-FREE SPLIT + CUDA")
546
+ print("=" * 70)
547
+
548
+ # ---- Seed everything ----
549
+ torch.manual_seed(42)
550
+ np.random.seed(42)
551
+ _ensure_transformer_cuda()
552
+ if torch.cuda.is_available():
553
+ torch.cuda.manual_seed_all(42)
554
+
555
+ # ---- Device ----
556
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
557
+ print(f"\n[DEVICE] Using: {device}")
558
+ if device.type == "cuda":
559
+ torch.backends.cudnn.enabled = True
560
+ torch.backends.cudnn.benchmark = True
561
+ torch.cuda.empty_cache()
562
+ _print_gpu_info(device)
563
+ else:
564
+ print("[WARNING] CUDA not available -- CPU fallback is enabled by PD_ALLOW_CPU_TRANSFORMERS=1")
565
+
566
+ # ---- Data ----
567
+ preprocessor = DataPreprocessor()
568
+ base_dir = str(ROOT)
569
+ file_paths = [
570
+ os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20240129.csv"),
571
+ os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20241211.csv"),
572
+ os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20250321.csv"),
573
+ os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20250714.csv"),
574
+ ]
575
+
576
+ X_train, X_test, y_train, y_test, feature_names, class_mapping = \
577
+ _load_or_create_leak_free_split(preprocessor, file_paths)
578
+
579
+ X_train = np.asarray(X_train, dtype=np.float32)
580
+ X_test = np.asarray(X_test, dtype=np.float32)
581
+ y_train = np.asarray(y_train, dtype=np.int64)
582
+ y_test = np.asarray(y_test, dtype=np.int64)
583
+
584
+ print(f"[DATA] Train: {X_train.shape} Test: {X_test.shape}")
585
+ print(f"[DATA] Classes: {len(np.unique(y_train))} Distribution: {dict(zip(*np.unique(y_train, return_counts=True)))}")
586
+
587
+ # ---- Class weights ----
588
+ cw = compute_class_weight("balanced", classes=np.unique(y_train), y=y_train)
589
+ class_weights_tensor = torch.FloatTensor(cw).to(device)
590
+ print(f"[DATA] Class weights: {dict(zip(np.unique(y_train), np.round(cw, 3)))}")
591
+
592
+ # ---- Stratified validation split (preserves class ratios) ----
593
+ train_idx, val_idx = _stratified_val_indices(y_train, val_fraction=0.15)
594
+ print(f"[DATA] Stratified split: {len(train_idx)} train / {len(val_idx)} val")
595
+
596
+ # ---- Feature names ----
597
+ if feature_names is None:
598
+ feature_names = preprocessor.get_feature_names()
599
+
600
+ # ---- RAG context ----
601
+ docs_path = str(ROOT / "medical_docs")
602
+ doc_manager = DocumentManager(docs_dir=docs_path)
603
+ doc_count = doc_manager.get_document_count()
604
+ print(f"[RAG] Loaded {doc_count.get('total', doc_count)} documents for context enrichment")
605
+
606
+ def get_rag_context(sample_features):
607
+ feature_desc = {name: float(val) for name, val in zip(feature_names, sample_features)}
608
+ query_parts = []
609
+ symptoms = {
610
+ "tremor": feature_desc.get("sym_tremor", 0),
611
+ "rigidity": feature_desc.get("sym_rigid", 0),
612
+ "bradykinesia": feature_desc.get("sym_brady", 0),
613
+ "postural instability": feature_desc.get("sym_posins", 0),
614
+ }
615
+ for symptom, severity in symptoms.items():
616
+ if severity > 0:
617
+ query_parts.append(f"{symptom} severity:{severity}")
618
+ moca = feature_desc.get("moca", 30)
619
+ if moca < 26:
620
+ query_parts.append("cognitive impairment")
621
+ age = feature_desc.get("age", 0)
622
+ if age:
623
+ query_parts.append(f"age {int(age)}")
624
+ if feature_desc.get("fampd", 0) > 0:
625
+ query_parts.append("family history Parkinson's disease")
626
+ query = "Parkinson's disease " + " ".join(query_parts)
627
+ passages = doc_manager.extract_relevant_passages(query, top_k=2)
628
+ if not passages:
629
+ return ""
630
+ return " ".join(
631
+ f"From '{p['doc_title']}' {p['text'][:300]}..." for p in passages
632
+ )
633
+
634
+ print(f"\n[RAG] RAG Enabled: {USE_RAG}")
635
+ if USE_RAG:
636
+ print("[RAG] Pre-computing context for train + test splits ...")
637
+
638
+ train_cache = RESULTS_DIR / "rag_contexts_train.pkl"
639
+ test_cache = RESULTS_DIR / "rag_contexts_test.pkl"
640
+
641
+ train_contexts = _build_context_cache(X_train, get_rag_context, "train", train_cache)
642
+ test_contexts = _build_context_cache(X_test, get_rag_context, "test", test_cache)
643
+
644
+
645
+ # ---- Datasets ----
646
+ full_train_ds = TabularDataset(X_train, y_train, feature_names, contexts=train_contexts)
647
+ test_ds = TabularDataset(X_test, y_test, feature_names, contexts=test_contexts)
648
+
649
+ train_subset = Subset(full_train_ds, train_idx)
650
+ val_subset = Subset(full_train_ds, val_idx)
651
+
652
+ gpu_profile = _detect_gpu_execution_profile()
653
+ loader_kwargs = _build_loader_kwargs(device, gpu_profile)
654
+ print(f"\n[GPU PROFILE] {gpu_profile.name} -> {gpu_profile.notes}")
655
+
656
+ # ---- Model definitions (lazy — created one at a time to fit in available VRAM) ----
657
+ input_dim = X_train.shape[1]
658
+ num_classes = len(np.unique(y_train))
659
+ class_names = ["HC", "PD", "SWEDD", "PRODROMAL"]
660
+
661
+ print(f"\n[MODEL] Input dim: {input_dim} Num classes: {num_classes}")
662
+
663
+ # Each entry: (display_name, save_name, model_factory)
664
+ # Models are created lazily inside the loop to avoid GPU OOM.
665
+ selected_models = _parse_selected_models(os.getenv("PD_TRAIN_MODELS", "all"))
666
+
667
+ model_configs = [
668
+ (
669
+ "PubMedBERT (Encoder-Only)", "pubmedbert",
670
+ lambda: PubMedBERTForTabular(input_dim, num_classes, dropout=0.10, freeze_bert=False),
671
+ {"lr": 1.5e-5, "weight_decay": 0.02},
672
+ ),
673
+ (
674
+ "BioGPT", "biogpt",
675
+ lambda: BioGPTForTabular(input_dim, num_classes, dropout=0.12, train_decoder_layers=8),
676
+ {"lr": 2e-5, "weight_decay": 0.02},
677
+ ),
678
+ (
679
+ "Clinical-T5", "clinical_t5",
680
+ lambda: ClinicalT5ForTabular(input_dim, num_classes, dropout=0.10, freeze_encoder=False),
681
+ {"lr": 1.5e-5, "weight_decay": 0.02},
682
+ ),
683
+ ]
684
+
685
+ if selected_models is not None:
686
+ model_configs = [cfg for cfg in model_configs if cfg[1] in selected_models]
687
+ print(f"[MODEL] Filter active -> {sorted(selected_models)}")
688
+ if not model_configs:
689
+ raise ValueError("No valid models selected for training.")
690
+
691
+ # ---- Training config ----
692
+ NUM_EPOCHS = 30
693
+ PATIENCE = 10
694
+ DEFAULT_GRAD_ACCUM = 8
695
+
696
+ criterion = torch.nn.CrossEntropyLoss(weight=class_weights_tensor)
697
+ checkpoint_dir = MODEL_DIR / "_checkpoints"
698
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
699
+
700
+ all_results = {}
701
+ all_histories = {}
702
+
703
+ for display_name, save_name, model_factory, opt_kwargs in model_configs:
704
+ final_path = MODEL_DIR / f"{display_name}_best.pth"
705
+ train_bs = gpu_profile.train_batch_by_model.get(save_name, 8)
706
+ eval_bs = gpu_profile.eval_batch_by_model.get(save_name, max(train_bs * 2, 8))
707
+ grad_accum = gpu_profile.grad_accum_by_model.get(save_name, DEFAULT_GRAD_ACCUM)
708
+
709
+ train_loader = DataLoader(train_subset, batch_size=train_bs, shuffle=True, **loader_kwargs)
710
+ val_loader = DataLoader(val_subset, batch_size=eval_bs, shuffle=False, **loader_kwargs)
711
+ test_loader = DataLoader(test_ds, batch_size=eval_bs, shuffle=False, **loader_kwargs)
712
+
713
+ print(f"\n[LOADER] {display_name}: train_bs={train_bs} eval_bs={eval_bs} grad_accum={grad_accum} workers={loader_kwargs.get('num_workers', 0)}")
714
+
715
+ # ---- Skip if already trained ----
716
+ if final_path.exists():
717
+ print(f"\n{'='*70}")
718
+ print(f" SKIPPING: {display_name} (already trained)")
719
+ print(f" Loading saved weights from: {final_path}")
720
+ print(f"{'='*70}")
721
+ try:
722
+ # Create the model on CPU first, load weights, then move to GPU
723
+ print(f"\n[MODEL] Initializing {display_name} for evaluation ...")
724
+ model = model_factory()
725
+ model.load_state_dict(torch.load(final_path, map_location="cpu", weights_only=True))
726
+ model.to(device)
727
+
728
+ # Evaluate on test set
729
+ result = evaluate_on_test(
730
+ model, test_loader, criterion, device, display_name, class_names,
731
+ )
732
+ all_results[display_name] = result
733
+ all_histories[display_name] = {"train_loss": [], "val_loss": [], "val_acc": [], "val_f1": [], "lr": []}
734
+
735
+ # Free GPU memory before next model
736
+ del model
737
+ if device.type == "cuda":
738
+ torch.cuda.empty_cache()
739
+
740
+ continue
741
+ except RuntimeError as e:
742
+ print(f" [WARN] Cannot load saved weights (architecture changed?): {e}")
743
+ print(f" [WARN] Deleting stale checkpoint and retraining ...")
744
+ final_path.unlink(missing_ok=True)
745
+
746
+ # ---- Create model fresh for training ----
747
+ print(f"\n[MODEL] Initializing {display_name} for training ...")
748
+ if device.type == "cuda":
749
+ torch.cuda.empty_cache()
750
+
751
+ model = model_factory().to(device)
752
+ optimizer = torch.optim.AdamW(
753
+ filter(lambda p: p.requires_grad, model.parameters()),
754
+ **opt_kwargs,
755
+ )
756
+ scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
757
+ optimizer, mode="min", factor=0.5, patience=3, min_lr=1e-7,
758
+ )
759
+
760
+ trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
761
+ total_params = sum(p.numel() for p in model.parameters())
762
+
763
+ print(f"\n{'='*70}")
764
+ print(f" TRAINING: {display_name}")
765
+ print(f" Trainable params: {trainable_params:,} / {total_params:,}")
766
+ print(f" Epochs: {NUM_EPOCHS} Patience: {PATIENCE} Grad Accum: {grad_accum}")
767
+ print(f"{'='*70}")
768
+
769
+ if device.type == "cuda":
770
+ torch.cuda.reset_peak_memory_stats()
771
+ torch.cuda.empty_cache()
772
+
773
+ scaler = torch.amp.GradScaler(device=device.type, enabled=device.type == "cuda")
774
+
775
+ trained_model, history, best_val = train_one_model(
776
+ model, optimizer, scheduler, criterion, scaler,
777
+ train_loader, val_loader, device, save_name,
778
+ num_epochs=NUM_EPOCHS, patience=PATIENCE, grad_accum_steps=grad_accum,
779
+ checkpoint_dir=checkpoint_dir,
780
+ )
781
+
782
+ # Save final model
783
+ torch.save(trained_model.state_dict(), final_path)
784
+ alias_paths = {
785
+ "pubmedbert": [
786
+ MODEL_DIR / "pubmedbert_transformer.pth",
787
+ MODEL_DIR / "pubmedbert.pth",
788
+ ],
789
+ "biogpt": [
790
+ MODEL_DIR / "biogpt_transformer.pth",
791
+ MODEL_DIR / "biogpt.pth",
792
+ MODEL_DIR / "biomistral.pth",
793
+ ],
794
+ "clinical_t5": [
795
+ MODEL_DIR / "clinical_t5_transformer.pth",
796
+ MODEL_DIR / "clinicalt5_transformer.pth",
797
+ MODEL_DIR / "clinical_t5.pth",
798
+ ],
799
+ }
800
+ for alias_path in alias_paths.get(save_name, []):
801
+ torch.save(trained_model.state_dict(), alias_path)
802
+ print(f" [SAVE] Model saved → {final_path}")
803
+
804
+ if device.type == "cuda":
805
+ peak = torch.cuda.max_memory_allocated(0) / 1024**3
806
+ print(f" [GPU] Peak VRAM usage: {peak:.2f} GB")
807
+
808
+ # Evaluate on test set
809
+ result = evaluate_on_test(
810
+ trained_model, test_loader, criterion, device, display_name, class_names,
811
+ )
812
+ all_results[display_name] = result
813
+ all_histories[display_name] = history
814
+
815
+ # Free GPU memory before next model
816
+ del model, trained_model, optimizer, scheduler, scaler
817
+ if device.type == "cuda":
818
+ torch.cuda.empty_cache()
819
+
820
+ # NOTE: We intentionally keep the checkpoint dir so that interrupted
821
+ # training can resume from the last saved epoch on the next run.
822
+
823
+ # ---- Save metrics to JSON + CSV ----
824
+ RESULTS_DIR.mkdir(parents=True, exist_ok=True)
825
+ summary_rows = []
826
+ for name, res in all_results.items():
827
+ summary_rows.append({
828
+ "Model": name,
829
+ "Type": "Transformer",
830
+ "Accuracy": round(res["accuracy"], 4),
831
+ "F1_Score": round(res["f1"], 4),
832
+ "Precision": round(res["precision"], 4),
833
+ "Recall": round(res["recall"], 4),
834
+ "AUROC": round(res["auroc"], 4),
835
+ })
836
+ df = pd.DataFrame(summary_rows)
837
+ df.to_csv(RESULTS_DIR / "transformer_metrics_latest.csv", index=False)
838
+ with open(RESULTS_DIR / "transformer_metrics_latest.json", "w") as f:
839
+ json.dump(summary_rows, f, indent=2)
840
+ print(f"\n[SAVE] Metrics → {RESULTS_DIR / 'transformer_metrics_latest.csv'}")
841
+
842
+ # ---- Plots ----
843
+ save_plots(all_results, all_histories, class_names, PLOTS_DIR)
844
+
845
+ # ---- Final summary ----
846
+ print(f"\n{'='*70}")
847
+ print(" FINAL COMPARISON")
848
+ print(f"{'='*70}")
849
+ print(f"{'Model':<30} {'Accuracy':>10} {'F1':>10} {'Precision':>10} {'Recall':>10} {'AUROC':>10}")
850
+ print("-" * 80)
851
+ for row in summary_rows:
852
+ print(f"{row['Model']:<30} {row['Accuracy']:>10.4f} {row['F1_Score']:>10.4f} "
853
+ f"{row['Precision']:>10.4f} {row['Recall']:>10.4f} {row['AUROC']:>10.4f}")
854
+
855
+ best_by_f1 = max(all_results, key=lambda k: all_results[k]["f1"])
856
+ print(f"\n [*] Best model (by F1): {best_by_f1} -- F1 {all_results[best_by_f1]['f1']:.4f}")
857
+ print(f"\n{'='*70}")
858
+ print(" TRAINING COMPLETE!")
859
+ print(f"{'='*70}\n")
860
+
861
+
862
+ if __name__ == "__main__":
863
+ main()
src/training_runtime.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Iterable, Optional
8
+
9
+
10
+ def _iso_now() -> str:
11
+ return datetime.now(timezone.utc).isoformat()
12
+
13
+
14
+ def _atomic_write_json(path: Path, payload: Dict[str, Any]) -> None:
15
+ path.parent.mkdir(parents=True, exist_ok=True)
16
+ temp_path = path.with_suffix(path.suffix + ".tmp")
17
+ temp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
18
+ temp_path.replace(path)
19
+
20
+
21
+ def _load_json(path: Path, default: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
22
+ if not path.exists():
23
+ return dict(default or {})
24
+ with path.open("r", encoding="utf-8") as handle:
25
+ return json.load(handle)
26
+
27
+
28
+ class PauseRequested(RuntimeError):
29
+ """Raised when a training run should pause after saving progress."""
30
+
31
+
32
+ class StopRequested(RuntimeError):
33
+ """Raised when a training run should stop gracefully."""
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class TrainingPaths:
38
+ root: Path
39
+ run_name: str
40
+
41
+ @property
42
+ def run_dir(self) -> Path:
43
+ return self.root / self.run_name
44
+
45
+ @property
46
+ def state_path(self) -> Path:
47
+ return self.run_dir / "run_state.json"
48
+
49
+ @property
50
+ def pause_flag(self) -> Path:
51
+ return self.run_dir / "pause.flag"
52
+
53
+ @property
54
+ def stop_flag(self) -> Path:
55
+ return self.run_dir / "stop.flag"
56
+
57
+ @property
58
+ def checkpoints_dir(self) -> Path:
59
+ return self.run_dir / "checkpoints"
60
+
61
+ @property
62
+ def metrics_dir(self) -> Path:
63
+ return self.run_dir / "metrics"
64
+
65
+ @property
66
+ def logs_dir(self) -> Path:
67
+ return self.run_dir / "logs"
68
+
69
+
70
+ class TrainingRunController:
71
+ """Shared manifest/checkpoint controller for resumable training runs."""
72
+
73
+ def __init__(self, base_dir: Path | str, run_name: str):
74
+ self.paths = TrainingPaths(Path(base_dir), run_name)
75
+ self.paths.run_dir.mkdir(parents=True, exist_ok=True)
76
+ self.paths.checkpoints_dir.mkdir(parents=True, exist_ok=True)
77
+ self.paths.metrics_dir.mkdir(parents=True, exist_ok=True)
78
+ self.paths.logs_dir.mkdir(parents=True, exist_ok=True)
79
+ self.state = _load_json(
80
+ self.paths.state_path,
81
+ {
82
+ "run_name": run_name,
83
+ "status": "created",
84
+ "created_at": _iso_now(),
85
+ "updated_at": _iso_now(),
86
+ "selected_models": [],
87
+ "config": {},
88
+ "current": {},
89
+ "models": {},
90
+ "events": [],
91
+ },
92
+ )
93
+ self._flush()
94
+
95
+ def _flush(self) -> None:
96
+ self.state["updated_at"] = _iso_now()
97
+ _atomic_write_json(self.paths.state_path, self.state)
98
+
99
+ def initialize(self, selected_models: Iterable[str], config: Dict[str, Any], resume: bool = False) -> None:
100
+ if not resume or not self.state.get("selected_models"):
101
+ self.state["selected_models"] = list(selected_models)
102
+ self.state["config"] = config
103
+ self._flush()
104
+
105
+ def get_status(self) -> str:
106
+ return str(self.state.get("status", "created"))
107
+
108
+ def mark_running(self, stage: str, model_name: Optional[str] = None, extra: Optional[Dict[str, Any]] = None) -> None:
109
+ self.state["status"] = "running"
110
+ current = {"stage": stage}
111
+ if model_name:
112
+ current["model_name"] = model_name
113
+ if extra:
114
+ current.update(extra)
115
+ self.state["current"] = current
116
+ self._append_event("running", current)
117
+ self._flush()
118
+
119
+ def mark_paused(self, reason: str = "pause requested") -> None:
120
+ self.state["status"] = "paused"
121
+ self._append_event("paused", {"reason": reason})
122
+ self._flush()
123
+
124
+ def mark_stopped(self, reason: str = "stop requested") -> None:
125
+ self.state["status"] = "stopped"
126
+ self._append_event("stopped", {"reason": reason})
127
+ self._flush()
128
+
129
+ def mark_completed(self) -> None:
130
+ self.state["status"] = "completed"
131
+ self.state["completed_at"] = _iso_now()
132
+ self._append_event("completed", {})
133
+ self.clear_pause()
134
+ self.clear_stop()
135
+ self._flush()
136
+
137
+ def mark_failed(self, error_message: str) -> None:
138
+ self.state["status"] = "failed"
139
+ self.state["last_error"] = error_message
140
+ self._append_event("failed", {"error": error_message})
141
+ self._flush()
142
+
143
+ def _append_event(self, event_type: str, payload: Dict[str, Any]) -> None:
144
+ events = self.state.setdefault("events", [])
145
+ events.append({"at": _iso_now(), "type": event_type, **payload})
146
+ if len(events) > 100:
147
+ del events[:-100]
148
+
149
+ def update_model_state(self, model_name: str, **fields: Any) -> None:
150
+ models = self.state.setdefault("models", {})
151
+ model_state = models.setdefault(model_name, {})
152
+ model_state.update(fields)
153
+ model_state["updated_at"] = _iso_now()
154
+ self._flush()
155
+
156
+ def append_trial_result(self, model_name: str, result: Dict[str, Any]) -> None:
157
+ models = self.state.setdefault("models", {})
158
+ model_state = models.setdefault(model_name, {})
159
+ trial_results = model_state.setdefault("trial_results", [])
160
+ trial_results.append(result)
161
+ model_state["updated_at"] = _iso_now()
162
+ self._flush()
163
+
164
+ def write_metrics_file(self, filename: str, payload: Dict[str, Any]) -> Path:
165
+ target = self.paths.metrics_dir / filename
166
+ _atomic_write_json(target, payload)
167
+ return target
168
+
169
+ def request_pause(self) -> None:
170
+ self.paths.pause_flag.parent.mkdir(parents=True, exist_ok=True)
171
+ self.paths.pause_flag.write_text("pause\n", encoding="utf-8")
172
+
173
+ def clear_pause(self) -> None:
174
+ if self.paths.pause_flag.exists():
175
+ self.paths.pause_flag.unlink()
176
+
177
+ def request_stop(self) -> None:
178
+ self.paths.stop_flag.parent.mkdir(parents=True, exist_ok=True)
179
+ self.paths.stop_flag.write_text("stop\n", encoding="utf-8")
180
+
181
+ def clear_stop(self) -> None:
182
+ if self.paths.stop_flag.exists():
183
+ self.paths.stop_flag.unlink()
184
+
185
+ def raise_if_requested(self) -> None:
186
+ if self.paths.stop_flag.exists():
187
+ raise StopRequested("Stop requested via stop.flag")
188
+ if self.paths.pause_flag.exists():
189
+ raise PauseRequested("Pause requested via pause.flag")
190
+
191
+ def load_checkpoint_state(self, model_name: str, default: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
192
+ path = self.paths.checkpoints_dir / f"{model_name}.json"
193
+ return _load_json(path, default)
194
+
195
+ def save_checkpoint_state(self, model_name: str, payload: Dict[str, Any]) -> Path:
196
+ path = self.paths.checkpoints_dir / f"{model_name}.json"
197
+ _atomic_write_json(path, payload)
198
+ return path
199
+
200
+ def status_summary(self) -> Dict[str, Any]:
201
+ return {
202
+ "run_name": self.state.get("run_name"),
203
+ "status": self.state.get("status"),
204
+ "selected_models": self.state.get("selected_models", []),
205
+ "current": self.state.get("current", {}),
206
+ "models": self.state.get("models", {}),
207
+ "paths": {
208
+ "run_dir": str(self.paths.run_dir),
209
+ "state_path": str(self.paths.state_path),
210
+ "pause_flag": str(self.paths.pause_flag),
211
+ "stop_flag": str(self.paths.stop_flag),
212
+ "checkpoints_dir": str(self.paths.checkpoints_dir),
213
+ "metrics_dir": str(self.paths.metrics_dir),
214
+ },
215
+ }
src/treatment_model.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Treatment Response Model — LEDD → UPDRS Dose-Response Curve.
3
+
4
+ Extracts PPMI treatment data (patients on PD treatment with ON/OFF
5
+ UPDRS3 scores), fits a log-decay relationship between LEDD dose and
6
+ UPDRS3 improvement, and predicts treatment effect for new patients.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import math
13
+ from typing import Any, Dict, Optional
14
+
15
+ import numpy as np
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # Default coefficients for the dose-response curve when PPMI fit fails.
20
+ # UPDRS_reduction = a * log(1 + LEDD) + b * disease_duration + c
21
+ _DEFAULT_COEFS = {"a": 3.5, "b": -0.3, "c": -2.0}
22
+
23
+
24
+ class TreatmentModel:
25
+ """Dose-response model: LEDD → UPDRS3 reduction."""
26
+
27
+ def __init__(self) -> None:
28
+ self.fitted = False
29
+ self.coefs: Dict[str, float] = dict(_DEFAULT_COEFS)
30
+ self.r_squared: Optional[float] = None
31
+
32
+ # ------------------------------------------------------------------
33
+ # Public API
34
+ # ------------------------------------------------------------------
35
+ def fit(self, csv_path: str) -> "TreatmentModel":
36
+ """Fit the dose-response model from PPMI CSV data."""
37
+ try:
38
+ import pandas as pd
39
+ df = pd.read_csv(csv_path, low_memory=False)
40
+ return self._fit_from_dataframe(df)
41
+ except Exception as exc:
42
+ logger.warning("TreatmentModel.fit failed: %s — using defaults", exc)
43
+ self.fitted = False
44
+ return self
45
+
46
+ def _fit_from_dataframe(self, df: "pd.DataFrame") -> "TreatmentModel":
47
+ import pandas as pd
48
+
49
+ required = {"PDTRTMNT", "LEDD", "updrs3_score", "updrs3_score_on"}
50
+ if not required.issubset(set(df.columns)):
51
+ logger.warning("Missing columns for treatment model; using defaults.")
52
+ return self
53
+
54
+ # Filter: patients on treatment with valid ON/OFF UPDRS3
55
+ sub = df[df["PDTRTMNT"] == 1].copy()
56
+ for col in ["LEDD", "updrs3_score", "updrs3_score_on"]:
57
+ sub[col] = pd.to_numeric(sub[col], errors="coerce")
58
+ sub = sub.dropna(subset=["LEDD", "updrs3_score", "updrs3_score_on"])
59
+ sub = sub[sub["LEDD"] > 0]
60
+
61
+ if len(sub) < 20:
62
+ logger.warning("Too few treatment records (%d); using defaults.", len(sub))
63
+ return self
64
+
65
+ # Compute UPDRS reduction (OFF - ON; positive = improvement)
66
+ sub["updrs_reduction"] = sub["updrs3_score"] - sub["updrs3_score_on"]
67
+
68
+ # Prepare features: log(1 + LEDD), duration
69
+ sub["log_ledd"] = np.log1p(sub["LEDD"].values)
70
+ if "duration_yrs" in sub.columns:
71
+ sub["dur"] = pd.to_numeric(sub["duration_yrs"], errors="coerce").fillna(0)
72
+ else:
73
+ sub["dur"] = 0.0
74
+
75
+ y = sub["updrs_reduction"].values
76
+ X = np.column_stack([
77
+ sub["log_ledd"].values,
78
+ sub["dur"].values,
79
+ np.ones(len(sub)),
80
+ ])
81
+
82
+ # Ordinary least squares
83
+ try:
84
+ coefs, residuals, _, _ = np.linalg.lstsq(X, y, rcond=None)
85
+ except np.linalg.LinAlgError:
86
+ logger.warning("TreatmentModel OLS failed; using defaults.")
87
+ return self
88
+
89
+ self.coefs = {
90
+ "a": round(float(coefs[0]), 4),
91
+ "b": round(float(coefs[1]), 4),
92
+ "c": round(float(coefs[2]), 4),
93
+ }
94
+
95
+ # R²
96
+ y_pred = X @ coefs
97
+ ss_res = np.sum((y - y_pred) ** 2)
98
+ ss_tot = np.sum((y - np.mean(y)) ** 2)
99
+ self.r_squared = round(1 - ss_res / max(ss_tot, 1e-10), 4)
100
+
101
+ self.fitted = True
102
+ logger.info(
103
+ "TreatmentModel fitted: coefs=%s, R²=%.4f, n=%d",
104
+ self.coefs, self.r_squared, len(sub),
105
+ )
106
+ return self
107
+
108
+ def predict_treatment_effect(
109
+ self,
110
+ ledd: Optional[float],
111
+ duration_years: float = 0.0,
112
+ ) -> float:
113
+ """
114
+ Predict UPDRS3 reduction for given LEDD and disease duration.
115
+
116
+ Returns a positive number meaning points of improvement.
117
+ """
118
+ if ledd is None or ledd <= 0:
119
+ return 0.0
120
+
121
+ a = self.coefs["a"]
122
+ b = self.coefs["b"]
123
+ c = self.coefs["c"]
124
+
125
+ reduction = a * math.log1p(ledd) + b * duration_years + c
126
+ # Clamp to non-negative (treatment can't make things worse in this model)
127
+ return round(max(0.0, reduction), 2)
128
+
129
+ def apply_treatment_effect(
130
+ self,
131
+ updrs3_off: float,
132
+ ledd: Optional[float],
133
+ duration_years: float = 0.0,
134
+ ) -> float:
135
+ """Return predicted ON-medication UPDRS3 score."""
136
+ reduction = self.predict_treatment_effect(ledd, duration_years)
137
+ return round(max(0.0, updrs3_off - reduction), 2)
138
+
139
+ def to_dict(self) -> Dict[str, Any]:
140
+ return {
141
+ "fitted": self.fitted,
142
+ "coefs": self.coefs,
143
+ "r_squared": self.r_squared,
144
+ }
src/twin_engine.py ADDED
@@ -0,0 +1,689 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import math
5
+ from copy import deepcopy
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Dict, List, Optional
8
+ from uuid import uuid4
9
+
10
+ from twin_schema import (
11
+ DigitalTwin,
12
+ TwinForecastPoint,
13
+ TwinSimulation,
14
+ TwinSnapshot,
15
+ TwinState,
16
+ TwinStaticProfile,
17
+ )
18
+ from twin_store import TwinStore
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Lazy singleton for the predictor bridge
23
+ _bridge_instance = None
24
+
25
+
26
+ def _get_bridge():
27
+ global _bridge_instance
28
+ if _bridge_instance is None:
29
+ try:
30
+ from twin_predictor_bridge import TwinPredictorBridge
31
+ _bridge_instance = TwinPredictorBridge()
32
+ status = _bridge_instance.get_status()
33
+ logger.info("TwinPredictorBridge initialized: %s", status)
34
+ if status.get("silhouette_score") is not None:
35
+ print(f"[TWIN] Progression silhouette score: {status['silhouette_score']:.3f}")
36
+ if status.get("treatment_r_squared") is not None:
37
+ print(f"[TWIN] Treatment model R²: {status['treatment_r_squared']:.4f}")
38
+ except Exception as exc:
39
+ logger.warning("Failed to init TwinPredictorBridge: %s", exc)
40
+ _bridge_instance = None
41
+ return _bridge_instance
42
+
43
+
44
+ CLASS_NAMES = ["Healthy Control", "Parkinson's Disease", "SWEDD", "Prodromal PD"]
45
+ FORECAST_HORIZONS_MONTHS = [3, 6, 12]
46
+
47
+
48
+ def _iso_now() -> str:
49
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
50
+
51
+
52
+ def _safe_float(value: Any) -> Optional[float]:
53
+ if value in (None, ""):
54
+ return None
55
+ if isinstance(value, bool):
56
+ return float(int(value))
57
+ if isinstance(value, (int, float)):
58
+ if isinstance(value, float) and math.isnan(value):
59
+ return None
60
+ return float(value)
61
+ try:
62
+ stripped = str(value).strip()
63
+ if stripped == "":
64
+ return None
65
+ parsed = float(stripped)
66
+ except (TypeError, ValueError):
67
+ return None
68
+ if math.isnan(parsed):
69
+ return None
70
+ return parsed
71
+
72
+
73
+ def _coerce_text(value: Any) -> Optional[str]:
74
+ if value is None:
75
+ return None
76
+ text = str(value).strip()
77
+ return text or None
78
+
79
+
80
+ def _clamp(value: Optional[float], low: float, high: float) -> Optional[float]:
81
+ if value is None:
82
+ return None
83
+ return max(low, min(high, value))
84
+
85
+
86
+ def _round_optional(value: Optional[float], digits: int = 2) -> Optional[float]:
87
+ if value is None:
88
+ return None
89
+ return round(value, digits)
90
+
91
+
92
+ def _parse_date(date_string: Optional[str]) -> Optional[datetime]:
93
+ if not date_string:
94
+ return None
95
+ for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"):
96
+ try:
97
+ return datetime.strptime(date_string[: len(fmt)], fmt)
98
+ except ValueError:
99
+ continue
100
+ return None
101
+
102
+
103
+ def _scale(value: Optional[float], maximum: float) -> Optional[float]:
104
+ if value is None or maximum <= 0:
105
+ return None
106
+ return _clamp(value / maximum, 0, 1)
107
+
108
+
109
+ def _inverse_scale(value: Optional[float], maximum: float) -> Optional[float]:
110
+ if value is None or maximum <= 0:
111
+ return None
112
+ return _clamp((maximum - value) / maximum, 0, 1)
113
+
114
+
115
+ def _mean_defined(values: List[Optional[float]]) -> float:
116
+ defined = [value for value in values if value is not None]
117
+ if not defined:
118
+ return 0.0
119
+ return sum(defined) / len(defined)
120
+
121
+
122
+ class DigitalTwinEngine:
123
+ def __init__(self, store: Optional[TwinStore] = None, db_path: Optional[str] = None):
124
+ self.store = store or TwinStore(db_path=db_path)
125
+ self.bridge = _get_bridge()
126
+
127
+ def list_twins(self) -> List[Dict[str, Any]]:
128
+ return self.store.list_twins()
129
+
130
+ def get_twin(self, twin_id: str) -> Optional[Dict[str, Any]]:
131
+ return self.store.get_twin(twin_id)
132
+
133
+ def create_twin(
134
+ self,
135
+ patient_data: Dict[str, Any],
136
+ patient_label: Optional[str] = None,
137
+ source_patno: Optional[int] = None,
138
+ predictor: Optional[Any] = None,
139
+ ) -> Dict[str, Any]:
140
+ created_at = _iso_now()
141
+ twin_id = f"twin_{uuid4().hex[:12]}"
142
+ profile = self._build_profile(
143
+ twin_id=twin_id,
144
+ patient_data=patient_data,
145
+ patient_label=patient_label,
146
+ source_patno=source_patno,
147
+ created_at=created_at,
148
+ )
149
+ snapshot = self._build_snapshot(patient_data, snapshot_index=0)
150
+ prediction_summary = self._predict_current_state(patient_data, predictor)
151
+ bridge_result = self._bridge_predict(patient_data, [snapshot.to_dict()])
152
+ state = self._build_state(profile, [snapshot], prediction_summary, bridge_result)
153
+ forecast = self._build_forecast(snapshot, state, bridge_result)
154
+ twin = DigitalTwin(
155
+ profile=profile,
156
+ snapshots=[snapshot],
157
+ current_state=state,
158
+ forecast=forecast,
159
+ prediction_summary=prediction_summary,
160
+ )
161
+ return self.store.upsert_twin(twin)
162
+
163
+ def add_snapshot(
164
+ self,
165
+ twin_id: str,
166
+ patient_data: Dict[str, Any],
167
+ predictor: Optional[Any] = None,
168
+ ) -> Optional[Dict[str, Any]]:
169
+ stored = self.store.get_twin(twin_id)
170
+ if stored is None:
171
+ return None
172
+
173
+ snapshots = [self._snapshot_from_dict(item) for item in stored["snapshots"]]
174
+ profile = self._profile_from_dict(stored["profile"])
175
+ snapshots.append(self._build_snapshot(patient_data, snapshot_index=len(snapshots)))
176
+ prediction_summary = self._predict_current_state(patient_data, predictor)
177
+ snap_dicts = [s.to_dict() for s in snapshots]
178
+ bridge_result = self._bridge_predict(patient_data, snap_dicts)
179
+ state = self._build_state(profile, snapshots, prediction_summary, bridge_result)
180
+ forecast = self._build_forecast(snapshots[-1], state, bridge_result)
181
+ twin = DigitalTwin(
182
+ profile=profile,
183
+ snapshots=snapshots,
184
+ current_state=state,
185
+ forecast=forecast,
186
+ prediction_summary=prediction_summary,
187
+ )
188
+ return self.store.upsert_twin(twin)
189
+
190
+ def simulate(
191
+ self,
192
+ twin_id: str,
193
+ overrides: Dict[str, Any],
194
+ scenario_name: Optional[str] = None,
195
+ predictor: Optional[Any] = None,
196
+ ) -> Optional[Dict[str, Any]]:
197
+ stored = self.store.get_twin(twin_id)
198
+ if stored is None or not stored["snapshots"]:
199
+ return None
200
+
201
+ profile = self._profile_from_dict(stored["profile"])
202
+ history = [self._snapshot_from_dict(item) for item in stored["snapshots"]]
203
+ base_snapshot = self._snapshot_from_dict(stored["snapshots"][-1])
204
+ raw_inputs = deepcopy(base_snapshot.raw_inputs)
205
+ raw_inputs.update(overrides)
206
+ simulated_snapshot = self._build_snapshot(
207
+ raw_inputs,
208
+ snapshot_index=len(stored["snapshots"]),
209
+ default_event_id="SIM",
210
+ )
211
+ prediction_summary = self._predict_current_state(raw_inputs, predictor)
212
+ all_snaps = history + [simulated_snapshot]
213
+ snap_dicts = [s.to_dict() for s in all_snaps]
214
+ bridge_result = self._bridge_predict(raw_inputs, snap_dicts)
215
+ state = self._build_state(profile, all_snaps, prediction_summary, bridge_result)
216
+ forecast = self._build_forecast(simulated_snapshot, state, bridge_result)
217
+ simulation = TwinSimulation(
218
+ scenario_name=(scenario_name or "Scenario").strip() or "Scenario",
219
+ overrides=overrides,
220
+ simulated_snapshot=simulated_snapshot,
221
+ state=state,
222
+ forecast=forecast,
223
+ )
224
+ return simulation.to_dict()
225
+
226
+ def _build_profile(
227
+ self,
228
+ twin_id: str,
229
+ patient_data: Dict[str, Any],
230
+ patient_label: Optional[str],
231
+ source_patno: Optional[int],
232
+ created_at: str,
233
+ ) -> TwinStaticProfile:
234
+ resolved_label = str(patient_label or patient_data.get("patient_id") or twin_id)
235
+ return TwinStaticProfile(
236
+ twin_id=twin_id,
237
+ patient_label=resolved_label,
238
+ source_patno=source_patno,
239
+ created_at=created_at,
240
+ enrollment_cohort=str(patient_data.get("COHORT") or "Unknown"),
241
+ subgroup=_coerce_text(patient_data.get("subgroup")),
242
+ sex=_safe_float(patient_data.get("SEX")),
243
+ education_years=_safe_float(patient_data.get("EDUCYRS")),
244
+ race=_safe_float(patient_data.get("race")),
245
+ family_pd=_safe_float(patient_data.get("fampd")),
246
+ family_pd_bin=_safe_float(patient_data.get("fampd_bin")),
247
+ bmi=_safe_float(patient_data.get("BMI")),
248
+ age_diag=_safe_float(patient_data.get("agediag")),
249
+ age_onset=_safe_float(patient_data.get("ageonset")),
250
+ dominant_side=_safe_float(patient_data.get("DOMSIDE")),
251
+ )
252
+
253
+ def _build_snapshot(
254
+ self,
255
+ patient_data: Dict[str, Any],
256
+ snapshot_index: int,
257
+ default_event_id: str = "MANUAL",
258
+ ) -> TwinSnapshot:
259
+ visit_date = _coerce_text(patient_data.get("visit_date")) or datetime.now().strftime("%Y-%m-%d")
260
+ return TwinSnapshot(
261
+ snapshot_id=f"snap_{uuid4().hex[:12]}",
262
+ event_id=_coerce_text(patient_data.get("EVENT_ID")) or f"{default_event_id}_{snapshot_index + 1}",
263
+ visit_date=visit_date,
264
+ year_index=_safe_float(patient_data.get("YEAR")),
265
+ age_at_visit=_safe_float(patient_data.get("age_at_visit") or patient_data.get("age")),
266
+ duration_years=_safe_float(patient_data.get("duration_yrs")),
267
+ treatment_flag=_safe_float(patient_data.get("PDTRTMNT")),
268
+ ledd=_safe_float(patient_data.get("LEDD")),
269
+ motor={
270
+ "sym_tremor": _safe_float(patient_data.get("sym_tremor")),
271
+ "sym_rigid": _safe_float(patient_data.get("sym_rigid")),
272
+ "sym_brady": _safe_float(patient_data.get("sym_brady")),
273
+ "sym_posins": _safe_float(patient_data.get("sym_posins")),
274
+ "hy": _safe_float(patient_data.get("hy")),
275
+ "hy_on": _safe_float(patient_data.get("hy_on")),
276
+ "pigd": _safe_float(patient_data.get("pigd")),
277
+ "td_pigd": _safe_float(patient_data.get("td_pigd")),
278
+ "updrs1_score": _safe_float(patient_data.get("updrs1_score")),
279
+ "updrs2_score": _safe_float(patient_data.get("updrs2_score")),
280
+ "updrs3_score": _safe_float(patient_data.get("updrs3_score")),
281
+ "updrs3_score_on": _safe_float(patient_data.get("updrs3_score_on")),
282
+ "updrs4_score": _safe_float(patient_data.get("updrs4_score")),
283
+ "updrs_totscore": _safe_float(patient_data.get("updrs_totscore")),
284
+ "updrs_totscore_on": _safe_float(patient_data.get("updrs_totscore_on")),
285
+ },
286
+ cognition={
287
+ "moca": _safe_float(patient_data.get("moca")),
288
+ "bjlot": _safe_float(patient_data.get("bjlot")),
289
+ "clockdraw": _safe_float(patient_data.get("clockdraw")),
290
+ "hvlt_immediaterecall": _safe_float(patient_data.get("hvlt_immediaterecall")),
291
+ "hvlt_retention": _safe_float(patient_data.get("hvlt_retention")),
292
+ "hvlt_discrimination": _safe_float(patient_data.get("hvlt_discrimination")),
293
+ "lexical": _safe_float(patient_data.get("lexical")),
294
+ "lns": _safe_float(patient_data.get("lns")),
295
+ },
296
+ non_motor={
297
+ "ess": _safe_float(patient_data.get("ess")),
298
+ "rem": _safe_float(patient_data.get("rem")),
299
+ "gds": _safe_float(patient_data.get("gds")),
300
+ "stai": _safe_float(patient_data.get("stai")),
301
+ "quip_any": _safe_float(patient_data.get("quip_any")),
302
+ "NP1COG": _safe_float(patient_data.get("NP1COG")),
303
+ "NP1DPRS": _safe_float(patient_data.get("NP1DPRS")),
304
+ "NP1ANXS": _safe_float(patient_data.get("NP1ANXS")),
305
+ "NP1APAT": _safe_float(patient_data.get("NP1APAT")),
306
+ "NP1FATG": _safe_float(patient_data.get("NP1FATG")),
307
+ },
308
+ autonomic={
309
+ "scopa": _safe_float(patient_data.get("scopa")),
310
+ "orthostasis": _safe_float(patient_data.get("orthostasis")),
311
+ },
312
+ biomarkers={
313
+ "abeta": _safe_float(patient_data.get("abeta")),
314
+ "tau": _safe_float(patient_data.get("tau")),
315
+ "ptau": _safe_float(patient_data.get("ptau")),
316
+ "asyn": _safe_float(patient_data.get("asyn")),
317
+ "nfl_serum": _safe_float(patient_data.get("nfl_serum")),
318
+ "NFL_CSF": _safe_float(patient_data.get("NFL_CSF")),
319
+ },
320
+ imaging={
321
+ "MIA_CAUDATE_mean": _safe_float(patient_data.get("MIA_CAUDATE_mean")),
322
+ "MIA_PUTAMEN_mean": _safe_float(patient_data.get("MIA_PUTAMEN_mean")),
323
+ "MIA_STRIATUM_mean": _safe_float(patient_data.get("MIA_STRIATUM_mean")),
324
+ },
325
+ raw_inputs=deepcopy(patient_data),
326
+ )
327
+
328
+ def _predict_current_state(
329
+ self,
330
+ patient_data: Dict[str, Any],
331
+ predictor: Optional[Any],
332
+ ) -> Dict[str, Any]:
333
+ if predictor is not None:
334
+ required_fields = [
335
+ "age",
336
+ "SEX",
337
+ "EDUCYRS",
338
+ "BMI",
339
+ "sym_tremor",
340
+ "sym_rigid",
341
+ "sym_brady",
342
+ "sym_posins",
343
+ ]
344
+ if all(_safe_float(patient_data.get(field)) is not None for field in required_fields):
345
+ try:
346
+ prediction = predictor.predict_patient(patient_data)
347
+ class_index = int(prediction["ensemble_prediction"])
348
+ return {
349
+ "prediction": CLASS_NAMES[class_index],
350
+ "confidence": round(float(prediction.get("confidence") or 0.0), 3),
351
+ "probabilities": {
352
+ CLASS_NAMES[idx]: round(float(prob), 4)
353
+ for idx, prob in enumerate(prediction.get("ensemble_probabilities", []))
354
+ },
355
+ "source": "assessment_model",
356
+ }
357
+ except Exception:
358
+ pass
359
+
360
+ motor_score = sum(
361
+ value or 0.0
362
+ for value in (
363
+ _safe_float(patient_data.get("sym_tremor")),
364
+ _safe_float(patient_data.get("sym_rigid")),
365
+ _safe_float(patient_data.get("sym_brady")),
366
+ _safe_float(patient_data.get("sym_posins")),
367
+ )
368
+ )
369
+ rem = _safe_float(patient_data.get("rem")) or 0.0
370
+ moca = _safe_float(patient_data.get("moca"))
371
+
372
+ if motor_score <= 1.0 and rem == 0 and (moca is None or moca >= 27):
373
+ prediction = "Healthy Control"
374
+ confidence = 0.58
375
+ elif motor_score >= 5.0:
376
+ prediction = "Parkinson's Disease"
377
+ confidence = 0.62
378
+ elif rem == 1 or (moca is not None and moca < 25):
379
+ prediction = "Prodromal PD"
380
+ confidence = 0.56
381
+ else:
382
+ prediction = "SWEDD"
383
+ confidence = 0.52
384
+
385
+ return {
386
+ "prediction": prediction,
387
+ "confidence": confidence,
388
+ "probabilities": {prediction: confidence},
389
+ "source": "heuristic_fallback",
390
+ }
391
+
392
+ def _bridge_predict(
393
+ self,
394
+ patient_data: Dict[str, Any],
395
+ snapshots: List[Dict[str, Any]],
396
+ ) -> Dict[str, Any]:
397
+ """Run the TwinPredictorBridge (ML + fallback)."""
398
+ if self.bridge is None:
399
+ self.bridge = _get_bridge()
400
+ if self.bridge is not None:
401
+ try:
402
+ return self.bridge.predict(patient_data, snapshots)
403
+ except Exception as exc:
404
+ logger.warning("Bridge predict failed: %s", exc)
405
+ return {}
406
+
407
+ def _build_state(
408
+ self,
409
+ profile: TwinStaticProfile,
410
+ snapshots: List[TwinSnapshot],
411
+ prediction_summary: Dict[str, Any],
412
+ bridge_result: Optional[Dict[str, Any]] = None,
413
+ ) -> TwinState:
414
+ latest = snapshots[-1]
415
+ motor_index = self._motor_burden_index(latest)
416
+ cognitive_index = self._cognitive_burden_index(latest)
417
+ non_motor_index = self._non_motor_burden_index(latest)
418
+ progression_velocity = self._progression_velocity(snapshots)
419
+ treatment_response_proxy = self._treatment_response_proxy(latest)
420
+ br = bridge_result or {}
421
+ evidence = self._build_evidence(
422
+ profile=profile,
423
+ snapshot=latest,
424
+ motor_index=motor_index,
425
+ cognitive_index=cognitive_index,
426
+ non_motor_index=non_motor_index,
427
+ progression_velocity=progression_velocity,
428
+ prediction_summary=prediction_summary,
429
+ bridge_result=br,
430
+ )
431
+
432
+ return TwinState(
433
+ current_cohort_estimate=prediction_summary.get("prediction", "Unknown"),
434
+ prediction_source=prediction_summary.get("source", "heuristic"),
435
+ confidence=float(br.get("confidence") or prediction_summary.get("confidence") or 0.0),
436
+ motor_burden_index=_round_optional(motor_index),
437
+ cognitive_burden_index=_round_optional(cognitive_index),
438
+ non_motor_burden_index=_round_optional(non_motor_index),
439
+ progression_velocity=_round_optional(progression_velocity),
440
+ treatment_response_proxy=_round_optional(treatment_response_proxy),
441
+ computed_at=_iso_now(),
442
+ cluster_id=br.get("cluster_id"),
443
+ cluster_label=br.get("cluster_label"),
444
+ treatment_effect=_round_optional(br.get("treatment_effect")),
445
+ ci_lower=_round_optional(br.get("ci_lower")),
446
+ ci_upper=_round_optional(br.get("ci_upper")),
447
+ evidence=evidence,
448
+ )
449
+
450
+ def _build_forecast(
451
+ self,
452
+ snapshot: TwinSnapshot,
453
+ state: TwinState,
454
+ bridge_result: Optional[Dict[str, Any]] = None,
455
+ ) -> List[TwinForecastPoint]:
456
+ motor_index = state.motor_burden_index or 0.0
457
+ cognitive_index = state.cognitive_burden_index or 0.0
458
+ non_motor_index = state.non_motor_burden_index or 0.0
459
+ duration_years = snapshot.duration_years or 0.0
460
+
461
+ current_updrs3 = snapshot.motor.get("updrs3_score")
462
+ if current_updrs3 is None:
463
+ current_updrs3 = 8 + motor_index * 22
464
+
465
+ current_total = snapshot.motor.get("updrs_totscore")
466
+ if current_total is None:
467
+ current_total = current_updrs3 * 1.7 + 8
468
+
469
+ current_moca = snapshot.cognition.get("moca")
470
+ if current_moca is None:
471
+ current_moca = 30 - cognitive_index * 8
472
+
473
+ current_hy = snapshot.motor.get("hy")
474
+ if current_hy is None:
475
+ current_hy = 1 + motor_index * 2.2
476
+
477
+ # Use cluster-weighted profiles from the bridge if available
478
+ br = bridge_result or {}
479
+ profile = br.get("progression_profile", {})
480
+ cluster_label = br.get("cluster_label", "moderate")
481
+ treatment_effect = br.get("treatment_effect", 0.0) or 0.0
482
+
483
+ if profile:
484
+ yearly_updrs3_gain = profile.get("updrs3_gain", 3.5)
485
+ yearly_moca_loss = profile.get("moca_loss", 0.7)
486
+ yearly_hy_gain = profile.get("hy_gain", 0.25)
487
+ else:
488
+ velocity = state.progression_velocity or 0.0
489
+ yearly_updrs3_gain = 1.5 + motor_index * 3.5 + duration_years * 0.2 + velocity * 2.0
490
+ yearly_moca_loss = 0.4 + cognitive_index * 0.9 + velocity * 0.2
491
+ yearly_hy_gain = 0.15 + motor_index * 0.35 + velocity * 0.05
492
+
493
+ yearly_total_gain = yearly_updrs3_gain * 1.8 + non_motor_index * 1.2
494
+
495
+ # Apply treatment effect: reduce UPDRS gains
496
+ treatment_offset = min(treatment_effect, yearly_updrs3_gain * 0.8)
497
+
498
+ forecast: List[TwinForecastPoint] = []
499
+ for months in FORECAST_HORIZONS_MONTHS:
500
+ years = months / 12.0
501
+ accel = 1.0 + duration_years * 0.02
502
+ raw_updrs3 = current_updrs3 + (yearly_updrs3_gain * accel - treatment_offset) * years
503
+ predicted_updrs3 = _round_optional(max(0, raw_updrs3))
504
+ predicted_total = _round_optional(max(0, current_total + (yearly_total_gain * accel - treatment_offset * 1.5) * years))
505
+ predicted_moca = _round_optional(_clamp(current_moca - yearly_moca_loss * years, 0, 30))
506
+ predicted_hy = _round_optional(_clamp(current_hy + yearly_hy_gain * years, 0, 5))
507
+ risk_level = self._risk_level(predicted_updrs3, predicted_moca, state.current_cohort_estimate)
508
+ forecast.append(
509
+ TwinForecastPoint(
510
+ horizon_months=months,
511
+ predicted_updrs3=predicted_updrs3,
512
+ predicted_total_updrs=predicted_total,
513
+ predicted_moca=predicted_moca,
514
+ predicted_hy=predicted_hy,
515
+ risk_level=risk_level,
516
+ uncertainty={
517
+ "updrs3_pm": _round_optional(1.5 + months * 0.4),
518
+ "total_updrs_pm": _round_optional(3.0 + months * 0.8),
519
+ "moca_pm": _round_optional(0.4 + months * 0.08),
520
+ },
521
+ )
522
+ )
523
+
524
+ return forecast
525
+
526
+ def _motor_burden_index(self, snapshot: TwinSnapshot) -> float:
527
+ components = [
528
+ _scale(snapshot.motor.get("sym_tremor"), 4),
529
+ _scale(snapshot.motor.get("sym_rigid"), 4),
530
+ _scale(snapshot.motor.get("sym_brady"), 4),
531
+ _scale(snapshot.motor.get("sym_posins"), 4),
532
+ _scale(snapshot.motor.get("updrs3_score"), 60),
533
+ _scale(snapshot.motor.get("updrs_totscore"), 120),
534
+ _scale(snapshot.motor.get("hy"), 5),
535
+ ]
536
+ return _mean_defined(components)
537
+
538
+ def _cognitive_burden_index(self, snapshot: TwinSnapshot) -> float:
539
+ components = [
540
+ _inverse_scale(snapshot.cognition.get("moca"), 30),
541
+ _inverse_scale(snapshot.cognition.get("bjlot"), 30),
542
+ _inverse_scale(snapshot.cognition.get("clockdraw"), 4),
543
+ _inverse_scale(snapshot.cognition.get("hvlt_immediaterecall"), 36),
544
+ _inverse_scale(snapshot.cognition.get("lns"), 21),
545
+ ]
546
+ return _mean_defined(components)
547
+
548
+ def _non_motor_burden_index(self, snapshot: TwinSnapshot) -> float:
549
+ components = [
550
+ _scale(snapshot.non_motor.get("ess"), 24),
551
+ _scale(snapshot.non_motor.get("gds"), 15),
552
+ _scale((_safe_float(snapshot.non_motor.get("stai")) or 20) - 20, 60),
553
+ _scale(snapshot.non_motor.get("rem"), 1),
554
+ _scale(snapshot.non_motor.get("quip_any"), 1),
555
+ _scale(snapshot.autonomic.get("scopa"), 39),
556
+ _scale(snapshot.autonomic.get("orthostasis"), 1),
557
+ _scale(snapshot.non_motor.get("NP1DPRS"), 4),
558
+ _scale(snapshot.non_motor.get("NP1ANXS"), 4),
559
+ _scale(snapshot.non_motor.get("NP1APAT"), 4),
560
+ _scale(snapshot.non_motor.get("NP1FATG"), 4),
561
+ ]
562
+ return _mean_defined(components)
563
+
564
+ def _progression_velocity(self, snapshots: List[TwinSnapshot]) -> Optional[float]:
565
+ if len(snapshots) < 2:
566
+ return None
567
+
568
+ first = snapshots[0]
569
+ last = snapshots[-1]
570
+ delta_years: Optional[float] = None
571
+
572
+ first_date = _parse_date(first.visit_date)
573
+ last_date = _parse_date(last.visit_date)
574
+ if first_date is not None and last_date is not None and last_date > first_date:
575
+ delta_years = (last_date - first_date).days / 365.25
576
+
577
+ # Fall back to YEAR index when visits share the same date.
578
+ if delta_years is None or delta_years <= 0:
579
+ first_year = _safe_float(first.year_index)
580
+ last_year = _safe_float(last.year_index)
581
+ if first_year is not None and last_year is not None and last_year > first_year:
582
+ delta_years = last_year - first_year
583
+
584
+ # Final fallback to disease duration deltas.
585
+ if delta_years is None or delta_years <= 0:
586
+ first_duration = _safe_float(first.duration_years)
587
+ last_duration = _safe_float(last.duration_years)
588
+ if (
589
+ first_duration is not None
590
+ and last_duration is not None
591
+ and last_duration > first_duration
592
+ ):
593
+ delta_years = last_duration - first_duration
594
+
595
+ if delta_years is None or delta_years <= 0:
596
+ return None
597
+
598
+ first_composite = (
599
+ self._motor_burden_index(first)
600
+ + self._cognitive_burden_index(first)
601
+ + self._non_motor_burden_index(first)
602
+ ) / 3.0
603
+ last_composite = (
604
+ self._motor_burden_index(last)
605
+ + self._cognitive_burden_index(last)
606
+ + self._non_motor_burden_index(last)
607
+ ) / 3.0
608
+ return (last_composite - first_composite) / delta_years
609
+
610
+ def _treatment_response_proxy(self, snapshot: TwinSnapshot) -> Optional[float]:
611
+ updrs_off = snapshot.motor.get("updrs3_score")
612
+ updrs_on = snapshot.motor.get("updrs3_score_on")
613
+ if updrs_off is not None and updrs_on is not None:
614
+ return updrs_off - updrs_on
615
+
616
+ hy_off = snapshot.motor.get("hy")
617
+ hy_on = snapshot.motor.get("hy_on")
618
+ if hy_off is not None and hy_on is not None:
619
+ return hy_off - hy_on
620
+ return None
621
+
622
+ def _build_evidence(
623
+ self,
624
+ profile: TwinStaticProfile,
625
+ snapshot: TwinSnapshot,
626
+ motor_index: float,
627
+ cognitive_index: float,
628
+ non_motor_index: float,
629
+ progression_velocity: Optional[float],
630
+ prediction_summary: Dict[str, Any],
631
+ bridge_result: Optional[Dict[str, Any]] = None,
632
+ ) -> List[str]:
633
+ br = bridge_result or {}
634
+ evidence = [
635
+ f"Current cohort estimate uses {prediction_summary.get('source', 'heuristic')} inference.",
636
+ "Forecasts use cluster-weighted trajectory prediction (v2) and should be treated as decision support only.",
637
+ ]
638
+
639
+ data_source = br.get("data_source")
640
+ if data_source:
641
+ evidence.append(f"Cohort split enforced at inference with source: {data_source}.")
642
+
643
+ cluster_label = br.get("cluster_label")
644
+ if cluster_label:
645
+ evidence.append(f"Assigned progression cluster: {cluster_label} progressor.")
646
+
647
+ treatment_effect = br.get("treatment_effect")
648
+ if treatment_effect and treatment_effect > 0:
649
+ evidence.append(f"Estimated treatment effect (LEDD): {treatment_effect:.1f} UPDRS3 point reduction.")
650
+
651
+ ci_lo = br.get("ci_lower")
652
+ ci_hi = br.get("ci_upper")
653
+ if ci_lo is not None and ci_hi is not None:
654
+ evidence.append(f"Risk confidence interval (bootstrap 100): [{ci_lo:.2f}, {ci_hi:.2f}].")
655
+
656
+ if snapshot.ledd is not None:
657
+ evidence.append(f"Medication context captured via LEDD {snapshot.ledd:.1f}.")
658
+ if profile.subgroup:
659
+ evidence.append(f"Patient subgroup context: {profile.subgroup}.")
660
+ if motor_index >= 0.55:
661
+ evidence.append("Motor burden is elevated relative to the entered symptom profile.")
662
+ if cognitive_index >= 0.4:
663
+ evidence.append("Cognitive burden suggests closer monitoring of executive and memory measures.")
664
+ if non_motor_index >= 0.45:
665
+ evidence.append("Non-motor burden is material and likely to affect quality of life trajectory.")
666
+ if progression_velocity is not None:
667
+ evidence.append(f"Estimated progression velocity across snapshots: {progression_velocity:.2f} burden units/year.")
668
+
669
+ return evidence
670
+
671
+ def _risk_level(
672
+ self,
673
+ predicted_updrs3: Optional[float],
674
+ predicted_moca: Optional[float],
675
+ cohort_estimate: str,
676
+ ) -> str:
677
+ if cohort_estimate == "Parkinson's Disease" and (predicted_updrs3 or 0) >= 20:
678
+ return "high"
679
+ if predicted_moca is not None and predicted_moca < 24:
680
+ return "high"
681
+ if (predicted_updrs3 or 0) >= 10:
682
+ return "medium"
683
+ return "low"
684
+
685
+ def _profile_from_dict(self, payload: Dict[str, Any]) -> TwinStaticProfile:
686
+ return TwinStaticProfile(**payload)
687
+
688
+ def _snapshot_from_dict(self, payload: Dict[str, Any]) -> TwinSnapshot:
689
+ return TwinSnapshot(**payload)
src/twin_predictor_bridge.py ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TwinPredictorBridge — ML / Heuristic Fallback Router.
3
+
4
+ Wraps progression_model, treatment_model, and risk_stratifier behind
5
+ a single predict() method. If any ML model fails, it falls back to
6
+ heuristic defaults. Also enforces PPMI / patient-entered cohort
7
+ separation and provides a MODELS_LOADED status flag.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional, Tuple
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class TwinPredictorBridge:
21
+ """Thin bridge routing to ML models with heuristic fallback."""
22
+
23
+ def __init__(self, ppmi_csv_path: Optional[str] = None) -> None:
24
+ self._ppmi_csv = ppmi_csv_path or self._find_ppmi_csv()
25
+ self.progression = None
26
+ self.treatment = None
27
+ self.risk = None
28
+ self.models_loaded = False
29
+ self._load_models()
30
+
31
+ # ------------------------------------------------------------------
32
+ # Public API
33
+ # ------------------------------------------------------------------
34
+ def predict(
35
+ self,
36
+ patient_data: Dict[str, Any],
37
+ snapshots: Optional[List[Dict[str, Any]]] = None,
38
+ ) -> Dict[str, Any]:
39
+ """
40
+ Run all three sub-models and return unified result.
41
+
42
+ Returns dict with: cluster_id, cluster_label, treatment_effect,
43
+ risk_category, confidence, ci_lower, ci_upper, progression_profile.
44
+ """
45
+ snaps = snapshots or []
46
+ data_source = self._infer_data_source(patient_data, snaps)
47
+
48
+ # 1. Progression cluster
49
+ cluster_id, cluster_label = self._assign_cluster(snaps, patient_data)
50
+ profile = self._get_cluster_profile(cluster_label)
51
+
52
+ # 2. Treatment effect
53
+ treatment_effect = self._predict_treatment(patient_data)
54
+
55
+ # 3. Risk stratification
56
+ risk = self._stratify_risk(patient_data)
57
+
58
+ return {
59
+ "cluster_id": cluster_id,
60
+ "cluster_label": cluster_label,
61
+ "progression_profile": profile,
62
+ "treatment_effect": treatment_effect,
63
+ "risk_category": risk.get("category", "Unknown"),
64
+ "confidence": risk.get("confidence", 0.0),
65
+ "ci_lower": risk.get("ci_lower", 0.0),
66
+ "ci_upper": risk.get("ci_upper", 0.0),
67
+ "risk_details": risk,
68
+ "models_loaded": self.models_loaded,
69
+ "cohort_split_enforced": True,
70
+ "data_source": data_source,
71
+ }
72
+
73
+ def get_status(self) -> Dict[str, Any]:
74
+ """Return model-loading status for the /api/health endpoint."""
75
+ return {
76
+ "models_loaded": self.models_loaded,
77
+ "cohort_split_enforced": True,
78
+ "progression_fitted": self.progression is not None and self.progression.fitted,
79
+ "treatment_fitted": self.treatment is not None and self.treatment.fitted,
80
+ "risk_available": self.risk is not None,
81
+ "ppmi_csv": self._ppmi_csv or "not found",
82
+ "silhouette_score": (
83
+ self.progression.silhouette_score_
84
+ if self.progression and self.progression.silhouette_score_ is not None
85
+ else None
86
+ ),
87
+ "treatment_r_squared": (
88
+ self.treatment.r_squared
89
+ if self.treatment and self.treatment.r_squared is not None
90
+ else None
91
+ ),
92
+ }
93
+
94
+ # ------------------------------------------------------------------
95
+ # Model loading
96
+ # ------------------------------------------------------------------
97
+ def _load_models(self) -> None:
98
+ loaded_count = 0
99
+
100
+ # Progression model
101
+ try:
102
+ from progression_model import ProgressionModel
103
+ self.progression = ProgressionModel()
104
+ if self._ppmi_csv:
105
+ self.progression.fit(self._ppmi_csv)
106
+ loaded_count += 1
107
+ if self.progression.silhouette_score_ is not None:
108
+ logger.info(
109
+ "Progression silhouette score: %.3f",
110
+ self.progression.silhouette_score_,
111
+ )
112
+ except Exception as exc:
113
+ logger.warning("Failed to load progression model: %s", exc)
114
+ self.progression = None
115
+
116
+ # Treatment model
117
+ try:
118
+ from treatment_model import TreatmentModel
119
+ self.treatment = TreatmentModel()
120
+ if self._ppmi_csv:
121
+ self.treatment.fit(self._ppmi_csv)
122
+ loaded_count += 1
123
+ if self.treatment.r_squared is not None:
124
+ logger.info("Treatment R²: %.4f", self.treatment.r_squared)
125
+ except Exception as exc:
126
+ logger.warning("Failed to load treatment model: %s", exc)
127
+ self.treatment = None
128
+
129
+ # Risk stratifier
130
+ try:
131
+ from risk_stratifier import RiskStratifier
132
+ self.risk = RiskStratifier(n_bootstrap=100)
133
+ loaded_count += 1
134
+ except Exception as exc:
135
+ logger.warning("Failed to load risk stratifier: %s", exc)
136
+ self.risk = None
137
+
138
+ self.models_loaded = bool(
139
+ self.progression is not None
140
+ and self.progression.fitted
141
+ and self.treatment is not None
142
+ and self.treatment.fitted
143
+ and self.risk is not None
144
+ )
145
+ logger.info("TwinPredictorBridge: %d/3 models loaded", loaded_count)
146
+
147
+ # ------------------------------------------------------------------
148
+ # Sub-model routing with fallback
149
+ # ------------------------------------------------------------------
150
+ def _assign_cluster(
151
+ self,
152
+ snapshots: List[Dict[str, Any]],
153
+ patient_data: Dict[str, Any],
154
+ ) -> Tuple[str, str]:
155
+ try:
156
+ if self.progression is not None:
157
+ return self.progression.assign_cluster(snapshots, patient_data)
158
+ except Exception as exc:
159
+ logger.warning("Progression cluster fallback: %s", exc)
160
+ return self._heuristic_cluster(patient_data)
161
+
162
+ def _get_cluster_profile(self, cluster_label: str) -> Dict[str, float]:
163
+ try:
164
+ if self.progression is not None:
165
+ return self.progression.get_cluster_profile(cluster_label)
166
+ except Exception:
167
+ pass
168
+ from progression_model import DEFAULT_CLUSTER_PROFILES
169
+ return DEFAULT_CLUSTER_PROFILES.get(cluster_label, DEFAULT_CLUSTER_PROFILES["moderate"])
170
+
171
+ def _predict_treatment(self, patient_data: Dict[str, Any]) -> float:
172
+ try:
173
+ if self.treatment is not None:
174
+ ledd = patient_data.get("ledd") or patient_data.get("LEDD")
175
+ dur = patient_data.get("duration_years") or patient_data.get("duration_yrs") or 0.0
176
+ if ledd is not None:
177
+ return self.treatment.predict_treatment_effect(float(ledd), float(dur))
178
+ except Exception as exc:
179
+ logger.warning("Treatment model fallback: %s", exc)
180
+ return self._heuristic_treatment(patient_data)
181
+
182
+ def _stratify_risk(self, patient_data: Dict[str, Any]) -> Dict[str, Any]:
183
+ try:
184
+ if self.risk is not None:
185
+ return self.risk.stratify(patient_data)
186
+ except Exception as exc:
187
+ logger.warning("Risk stratifier fallback: %s", exc)
188
+ return self._heuristic_risk(patient_data)
189
+
190
+ # ------------------------------------------------------------------
191
+ # Heuristic fallbacks
192
+ # ------------------------------------------------------------------
193
+ @staticmethod
194
+ def _heuristic_cluster(patient_data: Dict[str, Any]) -> Tuple[str, str]:
195
+ updrs3 = patient_data.get("updrs3_score") or 0
196
+ try:
197
+ updrs3 = float(updrs3)
198
+ except (TypeError, ValueError):
199
+ updrs3 = 0.0
200
+ if updrs3 >= 25:
201
+ return ("2", "fast")
202
+ if updrs3 >= 12:
203
+ return ("1", "moderate")
204
+ return ("0", "slow")
205
+
206
+ @staticmethod
207
+ def _heuristic_treatment(patient_data: Dict[str, Any]) -> float:
208
+ updrs_off = patient_data.get("updrs3_score")
209
+ updrs_on = patient_data.get("updrs3_score_on")
210
+ if updrs_off is not None and updrs_on is not None:
211
+ try:
212
+ return max(0.0, float(updrs_off) - float(updrs_on))
213
+ except (TypeError, ValueError):
214
+ pass
215
+ return 0.0
216
+
217
+ @staticmethod
218
+ def _heuristic_risk(patient_data: Dict[str, Any]) -> Dict[str, Any]:
219
+ sym = sum(
220
+ float(patient_data.get(s) or 0)
221
+ for s in ("sym_tremor", "sym_rigid", "sym_brady", "sym_posins")
222
+ )
223
+ if sym >= 5:
224
+ cat, conf = "PD", 0.62
225
+ elif sym >= 2:
226
+ cat, conf = "Prodromal PD", 0.50
227
+ else:
228
+ cat, conf = "HC", 0.55
229
+ return {
230
+ "category": cat,
231
+ "confidence": conf,
232
+ "ci_lower": round(conf - 0.08, 4),
233
+ "ci_upper": round(conf + 0.08, 4),
234
+ }
235
+
236
+ # ------------------------------------------------------------------
237
+ @staticmethod
238
+ def _infer_data_source(
239
+ patient_data: Dict[str, Any],
240
+ snapshots: List[Dict[str, Any]],
241
+ ) -> str:
242
+ """
243
+ Resolve cohort source used for prediction-time separation.
244
+
245
+ This is metadata-only enforcement for inference: models are always
246
+ trained from PPMI CSV and never refit from patient-entered payloads.
247
+ """
248
+ source_tag = str(patient_data.get("source") or patient_data.get("data_source") or "").strip().lower()
249
+ if source_tag in {"ppmi", "ppmi_validation", "ppmi_train"}:
250
+ return "ppmi_validation"
251
+
252
+ if patient_data.get("source_patno") is not None or patient_data.get("PATNO") is not None:
253
+ return "ppmi_validation"
254
+
255
+ for snap in snapshots:
256
+ raw = snap.get("raw_inputs") if isinstance(snap, dict) else None
257
+ if isinstance(raw, dict) and (raw.get("source_patno") is not None or raw.get("PATNO") is not None):
258
+ return "ppmi_validation"
259
+
260
+ return "patient_entered_validation"
261
+
262
+ # ------------------------------------------------------------------
263
+ @staticmethod
264
+ def _find_ppmi_csv() -> Optional[str]:
265
+ project_root = Path(__file__).resolve().parent.parent
266
+ candidates = sorted(project_root.glob("PPMI_Curated_Data_Cut_Public_*.csv"), reverse=True)
267
+ if candidates:
268
+ return str(candidates[0])
269
+ return None
src/twin_schema.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from typing import Any, Dict, List, Optional
5
+
6
+
7
+ @dataclass
8
+ class TwinStaticProfile:
9
+ twin_id: str
10
+ patient_label: str
11
+ source_patno: Optional[int]
12
+ created_at: str
13
+ enrollment_cohort: str
14
+ subgroup: Optional[str]
15
+ sex: Optional[float]
16
+ education_years: Optional[float]
17
+ race: Optional[float]
18
+ family_pd: Optional[float]
19
+ family_pd_bin: Optional[float]
20
+ bmi: Optional[float]
21
+ age_diag: Optional[float]
22
+ age_onset: Optional[float]
23
+ dominant_side: Optional[float]
24
+
25
+ def to_dict(self) -> Dict[str, Any]:
26
+ return asdict(self)
27
+
28
+
29
+ @dataclass
30
+ class TwinSnapshot:
31
+ snapshot_id: str
32
+ event_id: str
33
+ visit_date: str
34
+ year_index: Optional[float]
35
+ age_at_visit: Optional[float]
36
+ duration_years: Optional[float]
37
+ treatment_flag: Optional[float]
38
+ ledd: Optional[float]
39
+ motor: Dict[str, Optional[float]]
40
+ cognition: Dict[str, Optional[float]]
41
+ non_motor: Dict[str, Optional[float]]
42
+ autonomic: Dict[str, Optional[float]]
43
+ biomarkers: Dict[str, Optional[float]] = field(default_factory=dict)
44
+ imaging: Dict[str, Optional[float]] = field(default_factory=dict)
45
+ raw_inputs: Dict[str, Any] = field(default_factory=dict)
46
+
47
+ def to_dict(self) -> Dict[str, Any]:
48
+ return asdict(self)
49
+
50
+
51
+ @dataclass
52
+ class TwinState:
53
+ current_cohort_estimate: str
54
+ prediction_source: str
55
+ confidence: float
56
+ motor_burden_index: Optional[float]
57
+ cognitive_burden_index: Optional[float]
58
+ non_motor_burden_index: Optional[float]
59
+ progression_velocity: Optional[float]
60
+ treatment_response_proxy: Optional[float]
61
+ computed_at: str
62
+ cluster_id: Optional[str] = None
63
+ cluster_label: Optional[str] = None
64
+ treatment_effect: Optional[float] = None
65
+ ci_lower: Optional[float] = None
66
+ ci_upper: Optional[float] = None
67
+ evidence: List[str] = field(default_factory=list)
68
+
69
+ def to_dict(self) -> Dict[str, Any]:
70
+ return asdict(self)
71
+
72
+
73
+ @dataclass
74
+ class TwinForecastPoint:
75
+ horizon_months: int
76
+ predicted_updrs3: Optional[float]
77
+ predicted_total_updrs: Optional[float]
78
+ predicted_moca: Optional[float]
79
+ predicted_hy: Optional[float]
80
+ risk_level: str
81
+ uncertainty: Dict[str, Optional[float]]
82
+
83
+ def to_dict(self) -> Dict[str, Any]:
84
+ return asdict(self)
85
+
86
+
87
+ @dataclass
88
+ class TwinSimulation:
89
+ scenario_name: str
90
+ overrides: Dict[str, Any]
91
+ simulated_snapshot: TwinSnapshot
92
+ state: TwinState
93
+ forecast: List[TwinForecastPoint]
94
+
95
+ def to_dict(self) -> Dict[str, Any]:
96
+ return {
97
+ "scenario_name": self.scenario_name,
98
+ "overrides": self.overrides,
99
+ "simulated_snapshot": self.simulated_snapshot.to_dict(),
100
+ "state": self.state.to_dict(),
101
+ "forecast": [point.to_dict() for point in self.forecast],
102
+ }
103
+
104
+
105
+ @dataclass
106
+ class DigitalTwin:
107
+ profile: TwinStaticProfile
108
+ snapshots: List[TwinSnapshot]
109
+ current_state: TwinState
110
+ forecast: List[TwinForecastPoint]
111
+ prediction_summary: Dict[str, Any] = field(default_factory=dict)
112
+
113
+ def to_dict(self) -> Dict[str, Any]:
114
+ return {
115
+ "profile": self.profile.to_dict(),
116
+ "snapshots": [snapshot.to_dict() for snapshot in self.snapshots],
117
+ "current_state": self.current_state.to_dict(),
118
+ "forecast": [point.to_dict() for point in self.forecast],
119
+ "prediction_summary": self.prediction_summary,
120
+ }
src/twin_store.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from contextlib import closing
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ from twin_schema import DigitalTwin
10
+
11
+
12
+ class TwinStore:
13
+ def __init__(self, db_path: Optional[str] = None):
14
+ if db_path is None:
15
+ project_root = Path(__file__).resolve().parent.parent
16
+ db_path = str(project_root / "data" / "digital_twins.sqlite3")
17
+
18
+ self.db_path = Path(db_path)
19
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
20
+ self._init_db()
21
+
22
+ def _connect(self) -> sqlite3.Connection:
23
+ connection = sqlite3.connect(str(self.db_path))
24
+ connection.row_factory = sqlite3.Row
25
+ return connection
26
+
27
+ def _init_db(self) -> None:
28
+ with closing(self._connect()) as conn:
29
+ conn.execute(
30
+ """
31
+ CREATE TABLE IF NOT EXISTS twins (
32
+ twin_id TEXT PRIMARY KEY,
33
+ patient_label TEXT NOT NULL,
34
+ source_patno INTEGER,
35
+ created_at TEXT NOT NULL,
36
+ updated_at TEXT NOT NULL,
37
+ current_cohort_estimate TEXT NOT NULL,
38
+ confidence REAL NOT NULL,
39
+ snapshot_count INTEGER NOT NULL,
40
+ profile_json TEXT NOT NULL,
41
+ current_state_json TEXT NOT NULL,
42
+ forecast_json TEXT NOT NULL,
43
+ prediction_json TEXT NOT NULL
44
+ )
45
+ """
46
+ )
47
+ conn.execute(
48
+ """
49
+ CREATE TABLE IF NOT EXISTS twin_snapshots (
50
+ snapshot_id TEXT PRIMARY KEY,
51
+ twin_id TEXT NOT NULL,
52
+ snapshot_index INTEGER NOT NULL,
53
+ created_at TEXT NOT NULL,
54
+ snapshot_json TEXT NOT NULL,
55
+ FOREIGN KEY (twin_id) REFERENCES twins(twin_id) ON DELETE CASCADE
56
+ )
57
+ """
58
+ )
59
+ conn.execute(
60
+ "CREATE INDEX IF NOT EXISTS idx_twin_snapshots_twin_id ON twin_snapshots(twin_id)"
61
+ )
62
+ conn.commit()
63
+
64
+ def upsert_twin(self, twin: DigitalTwin) -> Dict[str, Any]:
65
+ payload = twin.to_dict()
66
+ profile = payload["profile"]
67
+ state = payload["current_state"]
68
+ forecast = payload["forecast"]
69
+ prediction = payload.get("prediction_summary", {})
70
+ snapshots = payload["snapshots"]
71
+
72
+ with closing(self._connect()) as conn:
73
+ existing = conn.execute(
74
+ "SELECT twin_id FROM twins WHERE twin_id = ?",
75
+ (profile["twin_id"],),
76
+ ).fetchone()
77
+
78
+ values = (
79
+ profile["twin_id"],
80
+ profile.get("patient_label") or profile["twin_id"],
81
+ profile.get("source_patno"),
82
+ profile["created_at"],
83
+ state["computed_at"],
84
+ state["current_cohort_estimate"],
85
+ float(state.get("confidence") or 0.0),
86
+ len(snapshots),
87
+ json.dumps(profile),
88
+ json.dumps(state),
89
+ json.dumps(forecast),
90
+ json.dumps(prediction),
91
+ )
92
+
93
+ if existing:
94
+ conn.execute(
95
+ """
96
+ UPDATE twins
97
+ SET patient_label = ?, source_patno = ?, created_at = ?, updated_at = ?,
98
+ current_cohort_estimate = ?, confidence = ?, snapshot_count = ?,
99
+ profile_json = ?, current_state_json = ?, forecast_json = ?, prediction_json = ?
100
+ WHERE twin_id = ?
101
+ """,
102
+ (
103
+ values[1],
104
+ values[2],
105
+ values[3],
106
+ values[4],
107
+ values[5],
108
+ values[6],
109
+ values[7],
110
+ values[8],
111
+ values[9],
112
+ values[10],
113
+ values[11],
114
+ values[0],
115
+ ),
116
+ )
117
+ conn.execute(
118
+ "DELETE FROM twin_snapshots WHERE twin_id = ?",
119
+ (profile["twin_id"],),
120
+ )
121
+ else:
122
+ conn.execute(
123
+ """
124
+ INSERT INTO twins (
125
+ twin_id, patient_label, source_patno, created_at, updated_at,
126
+ current_cohort_estimate, confidence, snapshot_count,
127
+ profile_json, current_state_json, forecast_json, prediction_json
128
+ )
129
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
130
+ """,
131
+ values,
132
+ )
133
+
134
+ for index, snapshot in enumerate(snapshots):
135
+ conn.execute(
136
+ """
137
+ INSERT INTO twin_snapshots (
138
+ snapshot_id, twin_id, snapshot_index, created_at, snapshot_json
139
+ )
140
+ VALUES (?, ?, ?, ?, ?)
141
+ """,
142
+ (
143
+ snapshot["snapshot_id"],
144
+ profile["twin_id"],
145
+ index,
146
+ snapshot.get("visit_date") or profile["created_at"],
147
+ json.dumps(snapshot),
148
+ ),
149
+ )
150
+ conn.commit()
151
+
152
+ stored = self.get_twin(profile["twin_id"])
153
+ if stored is None:
154
+ raise RuntimeError("Failed to persist digital twin")
155
+ return stored
156
+
157
+ def list_twins(self) -> List[Dict[str, Any]]:
158
+ with closing(self._connect()) as conn:
159
+ rows = conn.execute(
160
+ """
161
+ SELECT twin_id, patient_label, source_patno, created_at, updated_at,
162
+ current_cohort_estimate, confidence, snapshot_count
163
+ FROM twins
164
+ ORDER BY updated_at DESC
165
+ """
166
+ ).fetchall()
167
+
168
+ return [dict(row) for row in rows]
169
+
170
+ def get_twin(self, twin_id: str) -> Optional[Dict[str, Any]]:
171
+ with closing(self._connect()) as conn:
172
+ row = conn.execute(
173
+ """
174
+ SELECT twin_id, patient_label, source_patno, created_at, updated_at,
175
+ current_cohort_estimate, confidence, snapshot_count,
176
+ profile_json, current_state_json, forecast_json, prediction_json
177
+ FROM twins
178
+ WHERE twin_id = ?
179
+ """,
180
+ (twin_id,),
181
+ ).fetchone()
182
+
183
+ if row is None:
184
+ return None
185
+
186
+ snapshots = conn.execute(
187
+ """
188
+ SELECT snapshot_json
189
+ FROM twin_snapshots
190
+ WHERE twin_id = ?
191
+ ORDER BY snapshot_index ASC
192
+ """,
193
+ (twin_id,),
194
+ ).fetchall()
195
+
196
+ return {
197
+ "profile": json.loads(row["profile_json"]),
198
+ "current_state": json.loads(row["current_state_json"]),
199
+ "forecast": json.loads(row["forecast_json"]),
200
+ "prediction_summary": json.loads(row["prediction_json"]),
201
+ "snapshots": [json.loads(snapshot["snapshot_json"]) for snapshot in snapshots],
202
+ "summary": {
203
+ "twin_id": row["twin_id"],
204
+ "patient_label": row["patient_label"],
205
+ "source_patno": row["source_patno"],
206
+ "created_at": row["created_at"],
207
+ "updated_at": row["updated_at"],
208
+ "current_cohort_estimate": row["current_cohort_estimate"],
209
+ "confidence": row["confidence"],
210
+ "snapshot_count": row["snapshot_count"],
211
+ },
212
+ }