Spaces:
Sleeping
Sleeping
Prepare PRISM backend for Hugging Face Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +25 -0
- .gitattributes +6 -0
- Dockerfile +24 -0
- PPMI_Curated_Data_Cut_Public_20250714.csv +0 -0
- README.md +173 -6
- medical_docs/GetPdf.pdf +0 -0
- medical_docs/MDS-UPDRS.pdf +3 -0
- medical_docs/Movement Disorders - 2019 - Heinzel - Update of the MDS research criteria for prodromal Parkinson s disease.pdf +3 -0
- medical_docs/PPD.pdf +3 -0
- medical_docs/differential_diagnosis.txt +33 -0
- medical_docs/mds-clinical-diagnostic-criteria-for-parkinson's-disease.pdf +3 -0
- medical_docs/mds-research-criteria-for-prodromal-parkinson's-disease.pdf +3 -0
- medical_docs/parkinsons-disease-in-adults-pdf-1837629189061.pdf +3 -0
- medical_docs/parkinsons_research.txt +20 -0
- models/saved/lightgbm_model.joblib +3 -0
- models/saved/multimodal_ensemble.joblib +3 -0
- models/saved/svm_model.joblib +3 -0
- models/saved/traditional_class_mapping.json +6 -0
- models/saved/traditional_preprocessor.joblib +3 -0
- models/saved/xgboost_model.joblib +3 -0
- requirements-hf.txt +18 -0
- src/__init__.py +3 -0
- src/analyze_data.py +111 -0
- src/data_preprocessing.py +294 -0
- src/document_manager.py +455 -0
- src/dual_report_generator.py +112 -0
- src/evaluate_traditional_models.py +187 -0
- src/evaluate_transformer_models.py +274 -0
- src/feature_mapping.py +189 -0
- src/models/__init__.py +0 -0
- src/models/medical_transformers.py +939 -0
- src/models/multimodal_ml.py +560 -0
- src/models/traditional_ml.py +158 -0
- src/models/transformer_models.py +509 -0
- src/progression_model.py +338 -0
- src/rag_system.py +762 -0
- src/risk_stratifier.py +122 -0
- src/static/css/label-fix.css +43 -0
- src/static/css/styles.css +274 -0
- src/static/js/darkmode.js +50 -0
- src/train_model_suite.py +1382 -0
- src/train_multimodal.py +136 -0
- src/train_traditional_models.py +187 -0
- src/train_transformer_models.py +863 -0
- src/training_runtime.py +215 -0
- src/treatment_model.py +144 -0
- src/twin_engine.py +689 -0
- src/twin_predictor_bridge.py +269 -0
- src/twin_schema.py +120 -0
- 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:
|
| 3 |
-
emoji: 📊
|
| 4 |
-
colorFrom: yellow
|
| 5 |
-
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|