Spaces:
Sleeping
Sleeping
| """ | |
| Training script for transformer-based models (BioGPT, Clinical-T5, PubMedBERT) | |
| on the PPMI dataset with RAG integration, CUDA acceleration, and leak-free patient split. | |
| Usage: | |
| cd src | |
| python train_transformer_models.py | |
| """ | |
| import sys | |
| import argparse | |
| import os | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| sys.path.append(os.path.dirname(os.path.abspath(__file__))) | |
| import torch | |
| import numpy as np | |
| from torch.utils.data import DataLoader, Subset | |
| from sklearn.utils.class_weight import compute_class_weight | |
| from sklearn.metrics import ( | |
| classification_report, confusion_matrix, | |
| f1_score, precision_score, recall_score, roc_auc_score | |
| ) | |
| from sklearn.model_selection import StratifiedShuffleSplit | |
| import matplotlib.pyplot as plt | |
| import seaborn as sns | |
| import pandas as pd | |
| import joblib | |
| import time | |
| import json | |
| from tqdm import tqdm | |
| from data_preprocessing import DataPreprocessor | |
| from models.transformer_models import TabularDataset | |
| from models.medical_transformers import ( | |
| BioMistralClassifier as BioGPTForTabular, | |
| ClinicalT5Classifier as ClinicalT5ForTabular, | |
| PubMedBERTClassifier as PubMedBERTForTabular, | |
| ) | |
| from document_manager import DocumentManager | |
| # --------------------------------------------------------------------------- | |
| # Paths | |
| # --------------------------------------------------------------------------- | |
| ROOT = Path(__file__).resolve().parents[1] | |
| LEAK_FREE_SPLIT_PATH = ROOT / "evaluation_results" / "leak_free_split.npz" | |
| LEAK_FREE_META_PATH = ROOT / "evaluation_results" / "leak_free_split_meta.joblib" | |
| MODEL_DIR = ROOT / "models" / "saved" | |
| RESULTS_DIR = ROOT / "evaluation_results" | |
| PLOTS_DIR = ROOT / "evaluation_results" / "transformer_plots" | |
| # [CONFIG] Set to True if you want to use RAG (slower start), False for faster training | |
| USE_RAG = True | |
| REQUIRE_CUDA = os.getenv("PD_ALLOW_CPU_TRANSFORMERS", "0") != "1" | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def _load_or_create_leak_free_split(preprocessor, file_paths): | |
| """Load the cached leak-free split or regenerate it if missing.""" | |
| if LEAK_FREE_SPLIT_PATH.exists() and LEAK_FREE_META_PATH.exists(): | |
| split = np.load(LEAK_FREE_SPLIT_PATH) | |
| meta = joblib.load(LEAK_FREE_META_PATH) | |
| feature_names = meta.get("feature_names") if isinstance(meta, dict) else None | |
| class_mapping = meta.get("class_mapping") if isinstance(meta, dict) else None | |
| print("[DATA] Loaded cached leak-free split from evaluation_results.") | |
| return ( | |
| split["X_train"], split["X_test"], | |
| split["y_train"], split["y_test"], | |
| feature_names, class_mapping, | |
| ) | |
| print("[DATA] Leak-free split not found – regenerating via DataPreprocessor ...") | |
| X_train, X_test, y_train, y_test = preprocessor.prepare_data( | |
| file_paths, test_size=0.2, use_patient_split=True, | |
| ) | |
| feature_names = preprocessor.get_feature_names() | |
| class_mapping = preprocessor.get_class_mapping() | |
| LEAK_FREE_SPLIT_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| np.savez( | |
| LEAK_FREE_SPLIT_PATH, | |
| X_train=X_train, X_test=X_test, | |
| y_train=y_train, y_test=y_test, | |
| ) | |
| joblib.dump( | |
| {"feature_names": feature_names, "class_mapping": class_mapping}, | |
| LEAK_FREE_META_PATH, | |
| ) | |
| print(f"[DATA] Saved fresh leak-free split → {LEAK_FREE_SPLIT_PATH}") | |
| return X_train, X_test, y_train, y_test, feature_names, class_mapping | |
| def _stratified_val_indices(y_train, val_fraction=0.15, seed=42): | |
| """Return (train_idx, val_idx) using a stratified split so every class | |
| is proportionally represented in the validation set.""" | |
| sss = StratifiedShuffleSplit(n_splits=1, test_size=val_fraction, random_state=seed) | |
| train_idx, val_idx = next(sss.split(np.zeros(len(y_train)), y_train)) | |
| return train_idx, val_idx | |
| def _prepare_batch(batch, device): | |
| """Move tensors to the target device and keep optional RAG contexts aligned.""" | |
| if len(batch) == 3: | |
| data, targets, contexts = batch | |
| else: | |
| data, targets = batch | |
| contexts = None | |
| data = data.to(device, non_blocking=True) | |
| targets = targets.to(device, non_blocking=True) | |
| if contexts is not None: | |
| contexts = list(contexts) | |
| return data, targets, contexts | |
| def _build_context_cache(features, build_fn, split_name="dataset", cache_path=None): | |
| """Pre-compute RAG contexts with parallel processing and caching.""" | |
| if not USE_RAG: | |
| return [""] * len(features) | |
| if cache_path and os.path.exists(cache_path): | |
| print(f" [RAG] Loading cached contexts from {cache_path}") | |
| return joblib.load(cache_path) | |
| print(f" [RAG] Generating contexts for {len(features)} samples (Parallel)...") | |
| from joblib import Parallel, delayed | |
| # Run in parallel to speed up regex/cosine-sim | |
| contexts = Parallel(n_jobs=-1, verbose=5)( | |
| delayed(build_fn)(row) for row in features | |
| ) | |
| if cache_path: | |
| joblib.dump(contexts, cache_path) | |
| print(f" [RAG] Saved contexts to {cache_path}") | |
| return contexts | |
| def _print_gpu_info(device): | |
| """Print GPU diagnostics.""" | |
| if device.type != "cuda": | |
| return | |
| print(f" GPU Name : {torch.cuda.get_device_name(0)}") | |
| print(f" CUDA Version : {torch.version.cuda}") | |
| cap = torch.cuda.get_device_capability(0) | |
| print(f" Compute Cap. : {cap[0]}.{cap[1]}") | |
| mem_total = torch.cuda.get_device_properties(0).total_memory / 1024**3 | |
| print(f" Total VRAM : {mem_total:.1f} GB") | |
| print(f" cuDNN Enabled : {torch.backends.cudnn.enabled}") | |
| print(f" cuDNN Benchmark: {torch.backends.cudnn.benchmark}") | |
| def _ensure_transformer_cuda() -> None: | |
| if not torch.cuda.is_available(): | |
| if REQUIRE_CUDA: | |
| raise RuntimeError( | |
| "CUDA is required for transformer training in this accuracy-oriented configuration. " | |
| "Install a CUDA-enabled PyTorch build and use a GPU, or set PD_ALLOW_CPU_TRANSFORMERS=1 to explicitly allow CPU fallback." | |
| ) | |
| return | |
| torch.set_float32_matmul_precision("highest") | |
| if hasattr(torch.backends, "cuda") and hasattr(torch.backends.cuda, "matmul"): | |
| torch.backends.cuda.matmul.allow_tf32 = False | |
| if hasattr(torch.backends, "cudnn"): | |
| torch.backends.cudnn.allow_tf32 = False | |
| torch.backends.cudnn.benchmark = True | |
| class GPUExecutionProfile: | |
| name: str | |
| train_batch_by_model: dict | |
| eval_batch_by_model: dict | |
| grad_accum_by_model: dict | |
| num_workers: int | |
| prefetch_factor: int | |
| persistent_workers: bool | |
| notes: str | |
| def _detect_gpu_execution_profile(): | |
| if not torch.cuda.is_available(): | |
| return GPUExecutionProfile( | |
| name="cpu", | |
| train_batch_by_model={"pubmedbert": 4, "biogpt": 2, "clinical_t5": 2}, | |
| eval_batch_by_model={"pubmedbert": 8, "biogpt": 4, "clinical_t5": 4}, | |
| grad_accum_by_model={"pubmedbert": 12, "biogpt": 16, "clinical_t5": 16}, | |
| num_workers=0, | |
| prefetch_factor=2, | |
| persistent_workers=False, | |
| notes="CPU fallback profile.", | |
| ) | |
| gpu_name = torch.cuda.get_device_name(0).lower() | |
| memory_gb = torch.cuda.get_device_properties(0).total_memory / 1024**3 | |
| if "a4000" in gpu_name or memory_gb >= 15.0: | |
| return GPUExecutionProfile( | |
| name="rtx-a4000", | |
| train_batch_by_model={"pubmedbert": 16, "biogpt": 10, "clinical_t5": 10}, | |
| eval_batch_by_model={"pubmedbert": 48, "biogpt": 24, "clinical_t5": 24}, | |
| grad_accum_by_model={"pubmedbert": 4, "biogpt": 6, "clinical_t5": 6}, | |
| num_workers=4, | |
| prefetch_factor=2, | |
| persistent_workers=True, | |
| notes="Optimized for RTX A4000 / ~16 GB VRAM.", | |
| ) | |
| if memory_gb >= 11.0: | |
| return GPUExecutionProfile( | |
| name="high-vram", | |
| train_batch_by_model={"pubmedbert": 12, "biogpt": 8, "clinical_t5": 8}, | |
| eval_batch_by_model={"pubmedbert": 32, "biogpt": 16, "clinical_t5": 16}, | |
| grad_accum_by_model={"pubmedbert": 6, "biogpt": 8, "clinical_t5": 8}, | |
| num_workers=2, | |
| prefetch_factor=2, | |
| persistent_workers=True, | |
| notes="Generic 12 GB+ CUDA profile.", | |
| ) | |
| return GPUExecutionProfile( | |
| name="compat", | |
| train_batch_by_model={"pubmedbert": 8, "biogpt": 6, "clinical_t5": 6}, | |
| eval_batch_by_model={"pubmedbert": 16, "biogpt": 8, "clinical_t5": 8}, | |
| grad_accum_by_model={"pubmedbert": 8, "biogpt": 10, "clinical_t5": 10}, | |
| num_workers=0, | |
| prefetch_factor=2, | |
| persistent_workers=False, | |
| notes="Compatibility profile for lower-VRAM GPUs.", | |
| ) | |
| def _build_loader_kwargs(device, profile): | |
| kwargs = { | |
| "pin_memory": device.type == "cuda", | |
| "num_workers": profile.num_workers if device.type == "cuda" else 0, | |
| } | |
| if kwargs["num_workers"] > 0: | |
| kwargs["persistent_workers"] = profile.persistent_workers | |
| kwargs["prefetch_factor"] = profile.prefetch_factor | |
| return kwargs | |
| def _parse_selected_models(raw: str): | |
| raw = (raw or 'all').strip().lower() | |
| if raw in ('all', '*'): | |
| return None | |
| alias = { | |
| 'pubmed': 'pubmedbert', | |
| 'pubmedbert': 'pubmedbert', | |
| 'biogpt': 'biogpt', | |
| 'bio': 'biogpt', | |
| 'clinical': 'clinical_t5', | |
| 'clinical_t5': 'clinical_t5', | |
| 't5': 'clinical_t5', | |
| } | |
| out = [] | |
| for part in [p.strip() for p in raw.split(',') if p.strip()]: | |
| out.append(alias.get(part, part)) | |
| return set(out) if out else None | |
| # --------------------------------------------------------------------------- | |
| # Training loop | |
| # --------------------------------------------------------------------------- | |
| def train_one_model( | |
| model, optimizer, scheduler, criterion, scaler, | |
| train_loader, val_loader, device, model_name, | |
| num_epochs=25, patience=8, grad_accum_steps=2, | |
| checkpoint_dir=None, | |
| ): | |
| """Train a single model with mixed precision, gradient accumulation, and | |
| early stopping. Returns the best model state dict and training history.""" | |
| checkpoint_path = checkpoint_dir / f"{model_name}_ckpt.pth" if checkpoint_dir else None | |
| history = {"train_loss": [], "val_loss": [], "val_acc": [], "val_f1": [], "lr": []} | |
| best_val_loss = float("inf") | |
| early_stop_counter = 0 | |
| start_epoch = 0 | |
| # Resume from checkpoint if available | |
| if checkpoint_path and checkpoint_path.exists(): | |
| print(f" [CKPT] Found checkpoint at {checkpoint_path}") | |
| try: | |
| ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False) | |
| model.load_state_dict(ckpt["model_state_dict"]) | |
| optimizer.load_state_dict(ckpt["optimizer_state_dict"]) | |
| start_epoch = ckpt["epoch"] + 1 | |
| best_val_loss = ckpt["best_val_loss"] | |
| history = ckpt.get("history", history) | |
| print(f" [CKPT] Resuming from epoch {start_epoch} (best val loss {best_val_loss:.4f})") | |
| except Exception as e: | |
| print(f" [CKPT] Could not load checkpoint: {e}. Starting fresh.") | |
| start_epoch = 0 | |
| use_amp = device.type == "cuda" | |
| for epoch in range(start_epoch, num_epochs): | |
| t0 = time.time() | |
| model.train() | |
| running_loss = 0.0 | |
| optimizer.zero_grad(set_to_none=True) | |
| # Progress bar for training | |
| pbar = tqdm(enumerate(train_loader), total=len(train_loader), | |
| desc=f"Epoch {epoch+1}/{num_epochs}", unit="batch", ncols=100, ascii=True) | |
| for batch_idx, batch in pbar: | |
| data, targets, contexts = _prepare_batch(batch, device) | |
| with torch.amp.autocast(device_type=device.type, enabled=use_amp): | |
| outputs = model(data, contexts) | |
| loss = criterion(outputs, targets) / grad_accum_steps | |
| scaler.scale(loss).backward() | |
| if (batch_idx + 1) % grad_accum_steps == 0 or (batch_idx + 1) == len(train_loader): | |
| scaler.unscale_(optimizer) | |
| torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) | |
| scaler.step(optimizer) | |
| scaler.update() | |
| optimizer.zero_grad(set_to_none=True) | |
| current_loss = loss.item() * grad_accum_steps | |
| running_loss += current_loss | |
| # Update progress bar every few batches to reduce overhead | |
| if batch_idx % 10 == 0: | |
| pbar.set_postfix(loss=f"{current_loss:.4f}", lr=f"{optimizer.param_groups[0]['lr']:.2e}") | |
| avg_train_loss = running_loss / len(train_loader) | |
| history["train_loss"].append(avg_train_loss) | |
| history["lr"].append(optimizer.param_groups[0]["lr"]) | |
| # ---- Validation ---- | |
| model.eval() | |
| val_loss = 0.0 | |
| all_preds, all_targets = [], [] | |
| with torch.no_grad(): | |
| for batch in tqdm(val_loader, desc=f"Val {epoch+1}/{num_epochs}", unit="batch", leave=False, ncols=100, ascii=True): | |
| data, targets, contexts = _prepare_batch(batch, device) | |
| with torch.amp.autocast(device_type=device.type, enabled=use_amp): | |
| outputs = model(data, contexts) | |
| loss = criterion(outputs, targets) | |
| val_loss += loss.item() | |
| _, predicted = outputs.max(1) | |
| all_preds.extend(predicted.cpu().numpy()) | |
| all_targets.extend(targets.cpu().numpy()) | |
| avg_val_loss = val_loss / len(val_loader) | |
| val_acc = 100 * np.mean(np.array(all_preds) == np.array(all_targets)) | |
| val_f1 = f1_score(all_targets, all_preds, average="weighted") | |
| history["val_loss"].append(avg_val_loss) | |
| history["val_acc"].append(val_acc) | |
| history["val_f1"].append(val_f1) | |
| scheduler.step(avg_val_loss) | |
| elapsed = time.time() - t0 | |
| gpu_mem = torch.cuda.memory_allocated(0) / 1024**2 if device.type == "cuda" else 0 | |
| print( | |
| f" Epoch {epoch+1:02d}/{num_epochs} │ " | |
| f"Train Loss {avg_train_loss:.4f} │ Val Loss {avg_val_loss:.4f} │ " | |
| f"Val Acc {val_acc:.2f}% │ Val F1 {val_f1:.4f} │ " | |
| f"LR {optimizer.param_groups[0]['lr']:.2e} │ " | |
| f"GPU {gpu_mem:.0f}MB │ {elapsed:.1f}s" | |
| ) | |
| # ---- Checkpointing & early stopping ---- | |
| # Save every epoch so interrupted runs can resume from latest epoch. | |
| if checkpoint_path: | |
| torch.save({ | |
| "epoch": epoch, | |
| "model_state_dict": model.state_dict(), | |
| "optimizer_state_dict": optimizer.state_dict(), | |
| "best_val_loss": best_val_loss, | |
| "history": history, | |
| }, checkpoint_path) | |
| if avg_val_loss < best_val_loss: | |
| best_val_loss = avg_val_loss | |
| if checkpoint_path: | |
| torch.save({ | |
| "epoch": epoch, | |
| "model_state_dict": model.state_dict(), | |
| "optimizer_state_dict": optimizer.state_dict(), | |
| "best_val_loss": best_val_loss, | |
| "history": history, | |
| }, checkpoint_path) | |
| early_stop_counter = 0 | |
| print(f" [*] New best (val loss {best_val_loss:.4f})") | |
| else: | |
| early_stop_counter += 1 | |
| if early_stop_counter >= patience: | |
| print(f" [X] Early stopping after {epoch+1} epochs") | |
| break | |
| # Load best weights | |
| if checkpoint_path and checkpoint_path.exists(): | |
| ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False) | |
| model.load_state_dict(ckpt["model_state_dict"]) | |
| checkpoint_path.unlink() | |
| return model, history, best_val_loss | |
| # --------------------------------------------------------------------------- | |
| # Evaluation | |
| # --------------------------------------------------------------------------- | |
| def evaluate_on_test(model, test_loader, criterion, device, model_name, class_names): | |
| """Full evaluation on the held-out test set.""" | |
| model.eval() | |
| use_amp = device.type == "cuda" | |
| all_preds, all_targets, all_probs = [], [], [] | |
| test_loss = 0.0 | |
| with torch.no_grad(): | |
| for batch in tqdm(test_loader, desc=f"Evaluating {model_name}", unit="batch"): | |
| data, targets, contexts = _prepare_batch(batch, device) | |
| with torch.amp.autocast(device_type=device.type, enabled=use_amp): | |
| outputs = model(data, contexts) | |
| loss = criterion(outputs, targets) | |
| test_loss += loss.item() | |
| probs = torch.softmax(outputs, dim=1) | |
| _, predicted = outputs.max(1) | |
| all_preds.extend(predicted.cpu().numpy()) | |
| all_targets.extend(targets.cpu().numpy()) | |
| all_probs.extend(probs.cpu().numpy()) | |
| all_preds = np.array(all_preds) | |
| all_targets = np.array(all_targets) | |
| all_probs = np.array(all_probs) | |
| accuracy = np.mean(all_preds == all_targets) | |
| f1 = f1_score(all_targets, all_preds, average="weighted") | |
| precision = precision_score(all_targets, all_preds, average="weighted") | |
| recall = recall_score(all_targets, all_preds, average="weighted") | |
| try: | |
| auroc = roc_auc_score(all_targets, all_probs, multi_class="ovr", average="weighted") | |
| except Exception: | |
| auroc = 0.0 | |
| report = classification_report(all_targets, all_preds, target_names=class_names) | |
| cm = confusion_matrix(all_targets, all_preds) | |
| print(f"\n{'='*70}") | |
| print(f" {model_name.upper()} — TEST SET RESULTS") | |
| print(f"{'='*70}") | |
| print(f" Accuracy : {accuracy:.4f} ({accuracy*100:.2f}%)") | |
| print(f" F1 Score : {f1:.4f}") | |
| print(f" Precision : {precision:.4f}") | |
| print(f" Recall : {recall:.4f}") | |
| print(f" AUROC : {auroc:.4f}") | |
| print(f"\n{report}") | |
| return { | |
| "accuracy": accuracy, "f1": f1, "precision": precision, | |
| "recall": recall, "auroc": auroc, | |
| "classification_report": report, "confusion_matrix": cm, | |
| "predictions": all_preds, "targets": all_targets, "probabilities": all_probs, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Plotting | |
| # --------------------------------------------------------------------------- | |
| def save_plots(results, history_dict, class_names, plots_dir): | |
| """Save confusion matrices, training curves, and comparison charts.""" | |
| plots_dir = Path(plots_dir) | |
| plots_dir.mkdir(parents=True, exist_ok=True) | |
| for name, res in results.items(): | |
| # Confusion matrix | |
| plt.figure(figsize=(8, 6)) | |
| sns.heatmap(res["confusion_matrix"], annot=True, fmt="d", cmap="Blues", | |
| xticklabels=class_names, yticklabels=class_names) | |
| plt.title(f"{name} — Confusion Matrix (Leak-Free Split)") | |
| plt.xlabel("Predicted") | |
| plt.ylabel("True") | |
| plt.tight_layout() | |
| plt.savefig(plots_dir / f"{name}_confusion_matrix.png", dpi=200) | |
| plt.close() | |
| # Training curves | |
| hist = history_dict[name] | |
| fig, axes = plt.subplots(1, 3, figsize=(18, 5)) | |
| axes[0].plot(hist["train_loss"], label="Train", color="#3498db") | |
| axes[0].plot(hist["val_loss"], label="Val", color="#e74c3c") | |
| axes[0].set_title(f"{name} — Loss") | |
| axes[0].set_xlabel("Epoch") | |
| axes[0].legend() | |
| axes[0].grid(True, alpha=0.3) | |
| axes[1].plot(hist["val_acc"], color="#2ecc71") | |
| axes[1].set_title(f"{name} — Val Accuracy (%)") | |
| axes[1].set_xlabel("Epoch") | |
| axes[1].grid(True, alpha=0.3) | |
| axes[2].plot(hist["lr"], color="#9b59b6") | |
| axes[2].set_title(f"{name} — Learning Rate") | |
| axes[2].set_xlabel("Epoch") | |
| axes[2].grid(True, alpha=0.3) | |
| plt.tight_layout() | |
| plt.savefig(plots_dir / f"{name}_training_curves.png", dpi=200) | |
| plt.close() | |
| # Comparison bar chart | |
| model_names = list(results.keys()) | |
| metric_names = ["accuracy", "f1", "precision", "recall", "auroc"] | |
| fig, axes = plt.subplots(1, len(metric_names), figsize=(5 * len(metric_names), 5)) | |
| colors = ["#3498db", "#2ecc71", "#e74c3c"] | |
| for ax, metric in zip(axes, metric_names): | |
| values = [results[m][metric] for m in model_names] | |
| bars = ax.bar(model_names, values, color=colors[:len(model_names)]) | |
| ax.set_title(metric.upper()) | |
| ax.set_ylim(0, 1.05) | |
| for bar, val in zip(bars, values): | |
| ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01, | |
| f"{val:.3f}", ha="center", va="bottom", fontsize=9) | |
| plt.tight_layout() | |
| plt.savefig(plots_dir / "transformer_comparison.png", dpi=200) | |
| plt.close() | |
| print(f"[PLOT] Saved all plots → {plots_dir}") | |
| # --------------------------------------------------------------------------- | |
| # Main | |
| # --------------------------------------------------------------------------- | |
| def main(): | |
| print("=" * 70) | |
| print(" TRANSFORMER MODEL TRAINING — LEAK-FREE SPLIT + CUDA") | |
| print("=" * 70) | |
| # ---- Seed everything ---- | |
| torch.manual_seed(42) | |
| np.random.seed(42) | |
| _ensure_transformer_cuda() | |
| if torch.cuda.is_available(): | |
| torch.cuda.manual_seed_all(42) | |
| # ---- Device ---- | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| print(f"\n[DEVICE] Using: {device}") | |
| if device.type == "cuda": | |
| torch.backends.cudnn.enabled = True | |
| torch.backends.cudnn.benchmark = True | |
| torch.cuda.empty_cache() | |
| _print_gpu_info(device) | |
| else: | |
| print("[WARNING] CUDA not available -- CPU fallback is enabled by PD_ALLOW_CPU_TRANSFORMERS=1") | |
| # ---- Data ---- | |
| preprocessor = DataPreprocessor() | |
| base_dir = str(ROOT) | |
| file_paths = [ | |
| os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20240129.csv"), | |
| os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20241211.csv"), | |
| os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20250321.csv"), | |
| os.path.join(base_dir, "PPMI_Curated_Data_Cut_Public_20250714.csv"), | |
| ] | |
| X_train, X_test, y_train, y_test, feature_names, class_mapping = \ | |
| _load_or_create_leak_free_split(preprocessor, file_paths) | |
| X_train = np.asarray(X_train, dtype=np.float32) | |
| X_test = np.asarray(X_test, dtype=np.float32) | |
| y_train = np.asarray(y_train, dtype=np.int64) | |
| y_test = np.asarray(y_test, dtype=np.int64) | |
| print(f"[DATA] Train: {X_train.shape} Test: {X_test.shape}") | |
| print(f"[DATA] Classes: {len(np.unique(y_train))} Distribution: {dict(zip(*np.unique(y_train, return_counts=True)))}") | |
| # ---- Class weights ---- | |
| cw = compute_class_weight("balanced", classes=np.unique(y_train), y=y_train) | |
| class_weights_tensor = torch.FloatTensor(cw).to(device) | |
| print(f"[DATA] Class weights: {dict(zip(np.unique(y_train), np.round(cw, 3)))}") | |
| # ---- Stratified validation split (preserves class ratios) ---- | |
| train_idx, val_idx = _stratified_val_indices(y_train, val_fraction=0.15) | |
| print(f"[DATA] Stratified split: {len(train_idx)} train / {len(val_idx)} val") | |
| # ---- Feature names ---- | |
| if feature_names is None: | |
| feature_names = preprocessor.get_feature_names() | |
| # ---- RAG context ---- | |
| docs_path = str(ROOT / "medical_docs") | |
| doc_manager = DocumentManager(docs_dir=docs_path) | |
| doc_count = doc_manager.get_document_count() | |
| print(f"[RAG] Loaded {doc_count.get('total', doc_count)} documents for context enrichment") | |
| def get_rag_context(sample_features): | |
| feature_desc = {name: float(val) for name, val in zip(feature_names, sample_features)} | |
| query_parts = [] | |
| symptoms = { | |
| "tremor": feature_desc.get("sym_tremor", 0), | |
| "rigidity": feature_desc.get("sym_rigid", 0), | |
| "bradykinesia": feature_desc.get("sym_brady", 0), | |
| "postural instability": feature_desc.get("sym_posins", 0), | |
| } | |
| for symptom, severity in symptoms.items(): | |
| if severity > 0: | |
| query_parts.append(f"{symptom} severity:{severity}") | |
| moca = feature_desc.get("moca", 30) | |
| if moca < 26: | |
| query_parts.append("cognitive impairment") | |
| age = feature_desc.get("age", 0) | |
| if age: | |
| query_parts.append(f"age {int(age)}") | |
| if feature_desc.get("fampd", 0) > 0: | |
| query_parts.append("family history Parkinson's disease") | |
| query = "Parkinson's disease " + " ".join(query_parts) | |
| passages = doc_manager.extract_relevant_passages(query, top_k=2) | |
| if not passages: | |
| return "" | |
| return " ".join( | |
| f"From '{p['doc_title']}' {p['text'][:300]}..." for p in passages | |
| ) | |
| print(f"\n[RAG] RAG Enabled: {USE_RAG}") | |
| if USE_RAG: | |
| print("[RAG] Pre-computing context for train + test splits ...") | |
| train_cache = RESULTS_DIR / "rag_contexts_train.pkl" | |
| test_cache = RESULTS_DIR / "rag_contexts_test.pkl" | |
| train_contexts = _build_context_cache(X_train, get_rag_context, "train", train_cache) | |
| test_contexts = _build_context_cache(X_test, get_rag_context, "test", test_cache) | |
| # ---- Datasets ---- | |
| full_train_ds = TabularDataset(X_train, y_train, feature_names, contexts=train_contexts) | |
| test_ds = TabularDataset(X_test, y_test, feature_names, contexts=test_contexts) | |
| train_subset = Subset(full_train_ds, train_idx) | |
| val_subset = Subset(full_train_ds, val_idx) | |
| gpu_profile = _detect_gpu_execution_profile() | |
| loader_kwargs = _build_loader_kwargs(device, gpu_profile) | |
| print(f"\n[GPU PROFILE] {gpu_profile.name} -> {gpu_profile.notes}") | |
| # ---- Model definitions (lazy — created one at a time to fit in available VRAM) ---- | |
| input_dim = X_train.shape[1] | |
| num_classes = len(np.unique(y_train)) | |
| class_names = ["HC", "PD", "SWEDD", "PRODROMAL"] | |
| print(f"\n[MODEL] Input dim: {input_dim} Num classes: {num_classes}") | |
| # Each entry: (display_name, save_name, model_factory) | |
| # Models are created lazily inside the loop to avoid GPU OOM. | |
| selected_models = _parse_selected_models(os.getenv("PD_TRAIN_MODELS", "all")) | |
| model_configs = [ | |
| ( | |
| "PubMedBERT (Encoder-Only)", "pubmedbert", | |
| lambda: PubMedBERTForTabular(input_dim, num_classes, dropout=0.10, freeze_bert=False), | |
| {"lr": 1.5e-5, "weight_decay": 0.02}, | |
| ), | |
| ( | |
| "BioGPT", "biogpt", | |
| lambda: BioGPTForTabular(input_dim, num_classes, dropout=0.12, train_decoder_layers=8), | |
| {"lr": 2e-5, "weight_decay": 0.02}, | |
| ), | |
| ( | |
| "Clinical-T5", "clinical_t5", | |
| lambda: ClinicalT5ForTabular(input_dim, num_classes, dropout=0.10, freeze_encoder=False), | |
| {"lr": 1.5e-5, "weight_decay": 0.02}, | |
| ), | |
| ] | |
| if selected_models is not None: | |
| model_configs = [cfg for cfg in model_configs if cfg[1] in selected_models] | |
| print(f"[MODEL] Filter active -> {sorted(selected_models)}") | |
| if not model_configs: | |
| raise ValueError("No valid models selected for training.") | |
| # ---- Training config ---- | |
| NUM_EPOCHS = 30 | |
| PATIENCE = 10 | |
| DEFAULT_GRAD_ACCUM = 8 | |
| criterion = torch.nn.CrossEntropyLoss(weight=class_weights_tensor) | |
| checkpoint_dir = MODEL_DIR / "_checkpoints" | |
| checkpoint_dir.mkdir(parents=True, exist_ok=True) | |
| all_results = {} | |
| all_histories = {} | |
| for display_name, save_name, model_factory, opt_kwargs in model_configs: | |
| final_path = MODEL_DIR / f"{display_name}_best.pth" | |
| train_bs = gpu_profile.train_batch_by_model.get(save_name, 8) | |
| eval_bs = gpu_profile.eval_batch_by_model.get(save_name, max(train_bs * 2, 8)) | |
| grad_accum = gpu_profile.grad_accum_by_model.get(save_name, DEFAULT_GRAD_ACCUM) | |
| train_loader = DataLoader(train_subset, batch_size=train_bs, shuffle=True, **loader_kwargs) | |
| val_loader = DataLoader(val_subset, batch_size=eval_bs, shuffle=False, **loader_kwargs) | |
| test_loader = DataLoader(test_ds, batch_size=eval_bs, shuffle=False, **loader_kwargs) | |
| 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)}") | |
| # ---- Skip if already trained ---- | |
| if final_path.exists(): | |
| print(f"\n{'='*70}") | |
| print(f" SKIPPING: {display_name} (already trained)") | |
| print(f" Loading saved weights from: {final_path}") | |
| print(f"{'='*70}") | |
| try: | |
| # Create the model on CPU first, load weights, then move to GPU | |
| print(f"\n[MODEL] Initializing {display_name} for evaluation ...") | |
| model = model_factory() | |
| model.load_state_dict(torch.load(final_path, map_location="cpu", weights_only=True)) | |
| model.to(device) | |
| # Evaluate on test set | |
| result = evaluate_on_test( | |
| model, test_loader, criterion, device, display_name, class_names, | |
| ) | |
| all_results[display_name] = result | |
| all_histories[display_name] = {"train_loss": [], "val_loss": [], "val_acc": [], "val_f1": [], "lr": []} | |
| # Free GPU memory before next model | |
| del model | |
| if device.type == "cuda": | |
| torch.cuda.empty_cache() | |
| continue | |
| except RuntimeError as e: | |
| print(f" [WARN] Cannot load saved weights (architecture changed?): {e}") | |
| print(f" [WARN] Deleting stale checkpoint and retraining ...") | |
| final_path.unlink(missing_ok=True) | |
| # ---- Create model fresh for training ---- | |
| print(f"\n[MODEL] Initializing {display_name} for training ...") | |
| if device.type == "cuda": | |
| torch.cuda.empty_cache() | |
| model = model_factory().to(device) | |
| optimizer = torch.optim.AdamW( | |
| filter(lambda p: p.requires_grad, model.parameters()), | |
| **opt_kwargs, | |
| ) | |
| scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( | |
| optimizer, mode="min", factor=0.5, patience=3, min_lr=1e-7, | |
| ) | |
| trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad) | |
| total_params = sum(p.numel() for p in model.parameters()) | |
| print(f"\n{'='*70}") | |
| print(f" TRAINING: {display_name}") | |
| print(f" Trainable params: {trainable_params:,} / {total_params:,}") | |
| print(f" Epochs: {NUM_EPOCHS} Patience: {PATIENCE} Grad Accum: {grad_accum}") | |
| print(f"{'='*70}") | |
| if device.type == "cuda": | |
| torch.cuda.reset_peak_memory_stats() | |
| torch.cuda.empty_cache() | |
| scaler = torch.amp.GradScaler(device=device.type, enabled=device.type == "cuda") | |
| trained_model, history, best_val = train_one_model( | |
| model, optimizer, scheduler, criterion, scaler, | |
| train_loader, val_loader, device, save_name, | |
| num_epochs=NUM_EPOCHS, patience=PATIENCE, grad_accum_steps=grad_accum, | |
| checkpoint_dir=checkpoint_dir, | |
| ) | |
| # Save final model | |
| torch.save(trained_model.state_dict(), final_path) | |
| alias_paths = { | |
| "pubmedbert": [ | |
| MODEL_DIR / "pubmedbert_transformer.pth", | |
| MODEL_DIR / "pubmedbert.pth", | |
| ], | |
| "biogpt": [ | |
| MODEL_DIR / "biogpt_transformer.pth", | |
| MODEL_DIR / "biogpt.pth", | |
| MODEL_DIR / "biomistral.pth", | |
| ], | |
| "clinical_t5": [ | |
| MODEL_DIR / "clinical_t5_transformer.pth", | |
| MODEL_DIR / "clinicalt5_transformer.pth", | |
| MODEL_DIR / "clinical_t5.pth", | |
| ], | |
| } | |
| for alias_path in alias_paths.get(save_name, []): | |
| torch.save(trained_model.state_dict(), alias_path) | |
| print(f" [SAVE] Model saved → {final_path}") | |
| if device.type == "cuda": | |
| peak = torch.cuda.max_memory_allocated(0) / 1024**3 | |
| print(f" [GPU] Peak VRAM usage: {peak:.2f} GB") | |
| # Evaluate on test set | |
| result = evaluate_on_test( | |
| trained_model, test_loader, criterion, device, display_name, class_names, | |
| ) | |
| all_results[display_name] = result | |
| all_histories[display_name] = history | |
| # Free GPU memory before next model | |
| del model, trained_model, optimizer, scheduler, scaler | |
| if device.type == "cuda": | |
| torch.cuda.empty_cache() | |
| # NOTE: We intentionally keep the checkpoint dir so that interrupted | |
| # training can resume from the last saved epoch on the next run. | |
| # ---- Save metrics to JSON + CSV ---- | |
| RESULTS_DIR.mkdir(parents=True, exist_ok=True) | |
| summary_rows = [] | |
| for name, res in all_results.items(): | |
| summary_rows.append({ | |
| "Model": name, | |
| "Type": "Transformer", | |
| "Accuracy": round(res["accuracy"], 4), | |
| "F1_Score": round(res["f1"], 4), | |
| "Precision": round(res["precision"], 4), | |
| "Recall": round(res["recall"], 4), | |
| "AUROC": round(res["auroc"], 4), | |
| }) | |
| df = pd.DataFrame(summary_rows) | |
| df.to_csv(RESULTS_DIR / "transformer_metrics_latest.csv", index=False) | |
| with open(RESULTS_DIR / "transformer_metrics_latest.json", "w") as f: | |
| json.dump(summary_rows, f, indent=2) | |
| print(f"\n[SAVE] Metrics → {RESULTS_DIR / 'transformer_metrics_latest.csv'}") | |
| # ---- Plots ---- | |
| save_plots(all_results, all_histories, class_names, PLOTS_DIR) | |
| # ---- Final summary ---- | |
| print(f"\n{'='*70}") | |
| print(" FINAL COMPARISON") | |
| print(f"{'='*70}") | |
| print(f"{'Model':<30} {'Accuracy':>10} {'F1':>10} {'Precision':>10} {'Recall':>10} {'AUROC':>10}") | |
| print("-" * 80) | |
| for row in summary_rows: | |
| print(f"{row['Model']:<30} {row['Accuracy']:>10.4f} {row['F1_Score']:>10.4f} " | |
| f"{row['Precision']:>10.4f} {row['Recall']:>10.4f} {row['AUROC']:>10.4f}") | |
| best_by_f1 = max(all_results, key=lambda k: all_results[k]["f1"]) | |
| print(f"\n [*] Best model (by F1): {best_by_f1} -- F1 {all_results[best_by_f1]['f1']:.4f}") | |
| print(f"\n{'='*70}") | |
| print(" TRAINING COMPLETE!") | |
| print(f"{'='*70}\n") | |
| if __name__ == "__main__": | |
| main() | |