prism-backend / src /train_model_suite.py
Penguindrum920's picture
Prepare PRISM backend for Hugging Face Spaces
7bc5fde verified
Raw
History Blame Contribute Delete
65 kB
from __future__ import annotations
import argparse
import json
import sys
import warnings
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
import joblib
import numpy as np
import pandas as pd
import torch
from lightgbm import LGBMClassifier
from scipy import sparse
from sklearn.metrics import (
accuracy_score,
classification_report,
confusion_matrix,
f1_score,
precision_recall_fscore_support,
roc_auc_score,
)
from sklearn.model_selection import GroupKFold, GroupShuffleSplit
from sklearn.utils.class_weight import compute_class_weight
from sklearn.svm import SVC
from torch.utils.data import DataLoader
from tqdm.auto import tqdm
from xgboost import XGBClassifier
sys.path.append(str(Path(__file__).resolve().parent))
from data_preprocessing import DataPreprocessor # type: ignore
from training_runtime import PauseRequested, StopRequested, TrainingRunController # type: ignore
warnings.filterwarnings(
"ignore",
message="X does not have valid feature names, but LGBMClassifier was fitted with feature names",
)
ROOT = Path(__file__).resolve().parents[1]
MODEL_DIR = ROOT / "models" / "saved"
RUNS_DIR = MODEL_DIR / "training_runs"
EVAL_DIR = ROOT / "evaluation_results"
MODEL_METRICS_DIR = EVAL_DIR / "model_metrics"
CLASS_NAMES_DEFAULT = ["HC", "PD", "SWEDD", "PRODROMAL"]
TRADITIONAL_MODELS = ("lightgbm", "xgboost", "svm")
TRANSFORMER_MODELS = ("pubmedbert", "biogpt", "clinical_t5")
ALL_BASE_MODELS = TRADITIONAL_MODELS + TRANSFORMER_MODELS
TRANSFORMER_SELECTION_METRIC = "val_f1"
DEFAULT_TRANSFORMER_LOSS = "focal"
DEFAULT_FOCAL_GAMMA = 1.5
TRADITIONAL_SEARCH_SPACES: Dict[str, List[Dict[str, Any]]] = {
"lightgbm": [
{"n_estimators": 350, "learning_rate": 0.03, "max_depth": -1, "num_leaves": 31, "min_child_samples": 20, "subsample": 0.90, "colsample_bytree": 0.90},
{"n_estimators": 500, "learning_rate": 0.02, "max_depth": -1, "num_leaves": 63, "min_child_samples": 25, "subsample": 0.85, "colsample_bytree": 0.85},
{"n_estimators": 450, "learning_rate": 0.025, "max_depth": 10, "num_leaves": 47, "min_child_samples": 15, "subsample": 0.90, "colsample_bytree": 0.80},
{"n_estimators": 650, "learning_rate": 0.015, "max_depth": 12, "num_leaves": 95, "min_child_samples": 30, "subsample": 0.80, "colsample_bytree": 0.80},
{"n_estimators": 800, "learning_rate": 0.012, "max_depth": -1, "num_leaves": 127, "min_child_samples": 20, "subsample": 0.85, "colsample_bytree": 0.80},
{"n_estimators": 420, "learning_rate": 0.03, "max_depth": 8, "num_leaves": 63, "min_child_samples": 10, "subsample": 0.95, "colsample_bytree": 0.85},
],
"xgboost": [
{"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},
{"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},
{"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},
{"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},
{"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},
{"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},
],
"svm": [
{"C": 6.0, "gamma": "scale", "kernel": "rbf"},
{"C": 8.0, "gamma": "scale", "kernel": "rbf"},
{"C": 10.0, "gamma": 0.01, "kernel": "rbf"},
{"C": 12.0, "gamma": 0.005, "kernel": "rbf"},
{"C": 14.0, "gamma": "scale", "kernel": "rbf"},
{"C": 16.0, "gamma": 0.003, "kernel": "rbf"},
],
}
TRANSFORMER_TRIALS: Dict[str, List[Dict[str, Any]]] = {
"pubmedbert": [
{"model_kwargs": {"dropout": 0.10, "freeze_bert": False, "train_encoder_layers": 8}, "optimizer": {"lr": 8.0e-6, "weight_decay": 0.02}, "grad_accum": 2},
{"model_kwargs": {"dropout": 0.08, "freeze_bert": False, "train_encoder_layers": 6}, "optimizer": {"lr": 1.0e-5, "weight_decay": 0.02}, "grad_accum": 2},
{"model_kwargs": {"dropout": 0.12, "freeze_bert": False, "train_encoder_layers": 4}, "optimizer": {"lr": 1.5e-5, "weight_decay": 0.01}, "grad_accum": 2},
{"model_kwargs": {"dropout": 0.18, "freeze_bert": True}, "optimizer": {"lr": 2.5e-5, "weight_decay": 0.01}, "grad_accum": 2},
{"model_kwargs": {"dropout": 0.06, "freeze_bert": False, "train_encoder_layers": 10}, "optimizer": {"lr": 6.0e-6, "weight_decay": 0.03}, "grad_accum": 2},
{"model_kwargs": {"dropout": 0.10, "freeze_bert": False, "train_encoder_layers": 12}, "optimizer": {"lr": 5.0e-6, "weight_decay": 0.03}, "grad_accum": 1},
],
"biogpt": [
{"model_kwargs": {"dropout": 0.10, "train_decoder_layers": 4}, "optimizer": {"lr": 3.0e-5, "weight_decay": 0.01}, "grad_accum": 8},
{"model_kwargs": {"dropout": 0.15, "train_decoder_layers": 6}, "optimizer": {"lr": 2.5e-5, "weight_decay": 0.02}, "grad_accum": 8},
{"model_kwargs": {"dropout": 0.20, "train_decoder_layers": 8}, "optimizer": {"lr": 2.0e-5, "weight_decay": 0.02}, "grad_accum": 10},
{"model_kwargs": {"dropout": 0.12, "train_decoder_layers": 10}, "optimizer": {"lr": 1.5e-5, "weight_decay": 0.02}, "grad_accum": 12},
{"model_kwargs": {"dropout": 0.10, "train_decoder_layers": 12}, "optimizer": {"lr": 1.0e-5, "weight_decay": 0.02}, "grad_accum": 8},
{"model_kwargs": {"dropout": 0.08, "train_decoder_layers": 10}, "optimizer": {"lr": 1.2e-5, "weight_decay": 0.015}, "grad_accum": 6},
],
"clinical_t5": [
{"model_kwargs": {"dropout": 0.10, "freeze_encoder": False}, "optimizer": {"lr": 2.0e-5, "weight_decay": 0.01}, "grad_accum": 8},
{"model_kwargs": {"dropout": 0.15, "freeze_encoder": False}, "optimizer": {"lr": 1.5e-5, "weight_decay": 0.02}, "grad_accum": 8},
{"model_kwargs": {"dropout": 0.20, "freeze_encoder": True}, "optimizer": {"lr": 2.5e-5, "weight_decay": 0.01}, "grad_accum": 6},
{"model_kwargs": {"dropout": 0.08, "freeze_encoder": False}, "optimizer": {"lr": 1.0e-5, "weight_decay": 0.02}, "grad_accum": 10},
{"model_kwargs": {"dropout": 0.12, "freeze_encoder": False}, "optimizer": {"lr": 8.0e-6, "weight_decay": 0.02}, "grad_accum": 8},
{"model_kwargs": {"dropout": 0.10, "freeze_encoder": False}, "optimizer": {"lr": 1.2e-5, "weight_decay": 0.015}, "grad_accum": 6},
],
}
@dataclass
class TrainingBundle:
X_train_dense: np.ndarray
X_test_dense: np.ndarray
y_train: np.ndarray
y_test: np.ndarray
train_groups: np.ndarray
test_groups: np.ndarray
feature_names: List[str]
class_mapping: Dict[str, int]
class_names: List[str]
preprocessor: Any
@dataclass(frozen=True)
class GPUExecutionProfile:
name: str
train_batch_by_model: Dict[str, int]
eval_batch_by_model: Dict[str, int]
grad_accum_cap_by_model: Dict[str, int]
num_workers: int
prefetch_factor: int
persistent_workers: bool
notes: str
class FocalLoss(torch.nn.Module):
"""Class-weighted focal loss for imbalanced multi-class classification."""
def __init__(
self,
class_weights: Optional[torch.Tensor] = None,
gamma: float = DEFAULT_FOCAL_GAMMA,
reduction: str = "mean",
) -> None:
super().__init__()
if reduction not in {"mean", "sum", "none"}:
raise ValueError(f"Unsupported focal-loss reduction: {reduction}")
self.gamma = float(gamma)
self.reduction = reduction
if class_weights is not None:
normalized = class_weights.float() / class_weights.float().mean().clamp_min(1e-6)
self.register_buffer("class_weights", normalized)
else:
self.register_buffer("class_weights", None)
def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
log_probs = torch.nn.functional.log_softmax(logits, dim=1)
probs = log_probs.exp()
target_log_probs = log_probs.gather(1, targets.unsqueeze(1)).squeeze(1)
target_probs = probs.gather(1, targets.unsqueeze(1)).squeeze(1).clamp_min(1e-6)
ce_loss = -target_log_probs
focal_factor = (1.0 - target_probs).pow(self.gamma)
loss = focal_factor * ce_loss
if self.class_weights is not None:
loss = self.class_weights[targets] * loss
if self.reduction == "mean":
return loss.mean()
if self.reduction == "sum":
return loss.sum()
return loss
def _json_ready(value: Any) -> Any:
if isinstance(value, dict):
return {str(key): _json_ready(val) for key, val in value.items()}
if isinstance(value, (list, tuple)):
return [_json_ready(item) for item in value]
if isinstance(value, np.ndarray):
return value.tolist()
if isinstance(value, np.generic):
return value.item()
if isinstance(value, Path):
return str(value)
return value
def _as_dense(matrix: Any) -> np.ndarray:
if sparse.issparse(matrix):
return matrix.toarray()
return np.asarray(matrix)
def _candidate_file_paths() -> List[Path]:
return [
ROOT / "PPMI_Curated_Data_Cut_Public_20240129.csv",
ROOT / "PPMI_Curated_Data_Cut_Public_20241211.csv",
ROOT / "PPMI_Curated_Data_Cut_Public_20250321.csv",
ROOT / "PPMI_Curated_Data_Cut_Public_20250714.csv",
]
def _prepare_training_bundle() -> TrainingBundle:
preprocessor = DataPreprocessor()
X_train, X_test, y_train, y_test = preprocessor.prepare_data(
_candidate_file_paths(),
test_size=0.2,
use_patient_split=True,
)
train_df, test_df = preprocessor.get_split_frames()
feature_names = preprocessor.get_feature_names()
class_mapping = preprocessor.get_class_mapping()
class_names = [None] * len(class_mapping)
for label, idx in class_mapping.items():
class_names[idx] = label
EVAL_DIR.mkdir(parents=True, exist_ok=True)
np.savez(
EVAL_DIR / "leak_free_split.npz",
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,
"train_groups": train_df["PATNO"].to_numpy(),
"test_groups": test_df["PATNO"].to_numpy(),
},
EVAL_DIR / "leak_free_split_meta.joblib",
)
MODEL_DIR.mkdir(parents=True, exist_ok=True)
joblib.dump(preprocessor.get_preprocessor(), MODEL_DIR / "traditional_preprocessor.joblib")
(MODEL_DIR / "traditional_class_mapping.json").write_text(
json.dumps(_json_ready(class_mapping), indent=2),
encoding="utf-8",
)
return TrainingBundle(
X_train_dense=_as_dense(X_train).astype(np.float32),
X_test_dense=_as_dense(X_test).astype(np.float32),
y_train=np.asarray(y_train, dtype=np.int64),
y_test=np.asarray(y_test, dtype=np.int64),
train_groups=train_df["PATNO"].to_numpy(),
test_groups=test_df["PATNO"].to_numpy(),
feature_names=list(feature_names),
class_mapping=dict(class_mapping),
class_names=[
str(name if name is not None else CLASS_NAMES_DEFAULT[idx])
for idx, name in enumerate(class_names)
],
preprocessor=preprocessor.get_preprocessor(),
)
def _compute_class_weight_dict(y_train: np.ndarray) -> Dict[int, float]:
classes = np.unique(y_train)
weights = compute_class_weight(class_weight="balanced", classes=classes, y=y_train)
return {int(cls): float(weight) for cls, weight in zip(classes, weights)}
def _build_transformer_criterion(
loss_name: str,
class_weights_tensor: torch.Tensor,
focal_gamma: float,
) -> Tuple[torch.nn.Module, str]:
normalized_name = (loss_name or DEFAULT_TRANSFORMER_LOSS).strip().lower()
if normalized_name in {"ce", "cross_entropy", "cross-entropy"}:
return torch.nn.CrossEntropyLoss(weight=class_weights_tensor), "cross_entropy"
if normalized_name == "focal":
return FocalLoss(class_weights=class_weights_tensor, gamma=focal_gamma), f"focal(gamma={focal_gamma:.2f})"
raise ValueError(f"Unsupported transformer loss: {loss_name}")
def _is_better_validation_epoch(
val_f1: float,
val_loss: float,
best_val_f1: float,
best_val_loss: float,
min_delta: float = 1e-4,
) -> bool:
if val_f1 > (best_val_f1 + min_delta):
return True
if abs(val_f1 - best_val_f1) <= min_delta and val_loss < (best_val_loss - min_delta):
return True
return False
def _evaluate_predictions(
model_name: str,
model_type: str,
y_true: np.ndarray,
y_pred: np.ndarray,
probabilities: Optional[np.ndarray],
class_names: Sequence[str],
) -> Dict[str, Any]:
normalized_class_names = [str(name) for name in class_names]
accuracy = accuracy_score(y_true, y_pred)
precision, recall, f1, _ = precision_recall_fscore_support(y_true, y_pred, average="weighted", zero_division=0)
report = classification_report(y_true, y_pred, target_names=normalized_class_names, zero_division=0)
cm = confusion_matrix(y_true, y_pred)
auroc = None
if probabilities is not None:
try:
auroc = roc_auc_score(y_true, probabilities, multi_class="ovr", average="weighted")
except ValueError:
auroc = None
return {
"Model": model_name,
"Type": model_type,
"Accuracy": float(accuracy),
"Precision": float(precision),
"Recall": float(recall),
"F1_Score": float(f1),
"AUROC": None if auroc is None else float(auroc),
"classification_report": report,
"confusion_matrix": cm.tolist(),
}
def _traditional_model_builder(model_name: str, params: Dict[str, Any], class_weight_dict: Dict[int, float], num_classes: int):
if model_name == "lightgbm":
return LGBMClassifier(random_state=42, objective="multiclass", num_class=num_classes, verbose=-1, class_weight=class_weight_dict, **params)
if model_name == "xgboost":
return XGBClassifier(random_state=42, objective="multi:softprob", num_class=num_classes, eval_metric="mlogloss", verbosity=0, **params)
if model_name == "svm":
return SVC(random_state=42, probability=True, decision_function_shape="ovr", class_weight=class_weight_dict, cache_size=2048, **params)
raise ValueError(f"Unsupported traditional model: {model_name}")
def _run_grouped_traditional_search(
model_name: str,
search_space: List[Dict[str, Any]],
bundle: TrainingBundle,
controller: TrainingRunController,
max_trials: int,
) -> Tuple[Dict[str, Any], Dict[str, Any], Path]:
class_weight_dict = _compute_class_weight_dict(bundle.y_train)
checkpoint_state = controller.load_checkpoint_state(
model_name,
{
"phase": "search",
"trials": {},
"best_trial_index": None,
"best_score": None,
},
)
search_space = list(search_space[:max_trials])
n_splits = max(2, min(3, len(np.unique(bundle.train_groups))))
splitter = GroupKFold(n_splits=n_splits)
controller.mark_running("traditional_search", model_name=model_name)
controller.update_model_state(
model_name,
status="running",
family="traditional",
trials_total=len(search_space),
)
for trial_index, params in enumerate(search_space):
trial_key = str(trial_index)
trial_state = checkpoint_state["trials"].setdefault(
trial_key,
{
"params": params,
"fold_scores": [],
"status": "pending",
},
)
if trial_state.get("status") == "completed":
continue
controller.mark_running("traditional_trial", model_name=model_name, extra={"trial_index": trial_index})
for fold_index, (train_idx, val_idx) in enumerate(
splitter.split(bundle.X_train_dense, bundle.y_train, groups=bundle.train_groups)
):
if fold_index < len(trial_state["fold_scores"]):
continue
train_features = np.asarray(bundle.X_train_dense[train_idx], dtype=np.float32)
val_features = np.asarray(bundle.X_train_dense[val_idx], dtype=np.float32)
train_targets = np.asarray(bundle.y_train[train_idx], dtype=np.int64)
val_targets = np.asarray(bundle.y_train[val_idx], dtype=np.int64)
estimator = _traditional_model_builder(
model_name,
params,
class_weight_dict=class_weight_dict,
num_classes=len(bundle.class_names),
)
estimator.fit(train_features, train_targets)
preds = estimator.predict(val_features)
score = f1_score(val_targets, preds, average="weighted")
trial_state["fold_scores"].append(float(score))
trial_state["status"] = "running"
checkpoint_state["phase"] = "search"
controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
controller.update_model_state(
model_name,
current_trial=trial_index,
current_fold=fold_index,
latest_fold_score=float(score),
)
controller.raise_if_requested()
trial_state["mean_score"] = float(np.mean(trial_state["fold_scores"]))
trial_state["status"] = "completed"
controller.append_trial_result(
model_name,
{
"trial_index": trial_index,
"params": params,
"mean_score": trial_state["mean_score"],
},
)
if checkpoint_state.get("best_score") is None or trial_state["mean_score"] > checkpoint_state["best_score"]:
checkpoint_state["best_score"] = float(trial_state["mean_score"])
checkpoint_state["best_trial_index"] = trial_index
controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
controller.raise_if_requested()
if checkpoint_state.get("best_trial_index") is None:
raise RuntimeError(f"No completed trials were recorded for {model_name}")
best_index = int(checkpoint_state["best_trial_index"])
best_params = dict(search_space[best_index])
checkpoint_state["phase"] = "fit_final"
controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
controller.mark_running("traditional_fit_final", model_name=model_name)
best_model = _traditional_model_builder(
model_name,
best_params,
class_weight_dict=class_weight_dict,
num_classes=len(bundle.class_names),
)
best_model.fit(
np.asarray(bundle.X_train_dense, dtype=np.float32),
np.asarray(bundle.y_train, dtype=np.int64),
)
artifact_path = MODEL_DIR / f"{model_name}_model.joblib"
joblib.dump(best_model, artifact_path)
test_features = np.asarray(bundle.X_test_dense, dtype=np.float32)
y_pred = best_model.predict(test_features)
probabilities = best_model.predict_proba(test_features) if hasattr(best_model, "predict_proba") else None
metrics = _evaluate_predictions(
model_name=model_name.replace("_", " ").title().replace("Svm", "SVM").replace("Lightgbm", "LightGBM").replace("Xgboost", "XGBoost"),
model_type="Traditional ML",
y_true=bundle.y_test,
y_pred=y_pred,
probabilities=probabilities,
class_names=bundle.class_names,
)
checkpoint_state["phase"] = "complete"
checkpoint_state["artifact_path"] = str(artifact_path)
checkpoint_state["best_params"] = best_params
checkpoint_state["metrics"] = {
key: value for key, value in metrics.items()
if key in {"Accuracy", "Precision", "Recall", "F1_Score", "AUROC", "Model", "Type"}
}
controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
controller.update_model_state(
model_name,
status="completed",
artifact_path=str(artifact_path),
best_params=best_params,
best_cv_score=float(checkpoint_state["best_score"]),
metrics=checkpoint_state["metrics"],
)
return metrics, best_params, artifact_path
def _build_rag_contexts(features: np.ndarray, feature_names: Sequence[str], doc_manager: Any) -> List[str]:
contexts: List[str] = []
for row in features:
feature_desc = {name: float(val) for name, val in zip(feature_names, row)}
query_parts = []
for symptom_key, col in {
"tremor": "sym_tremor",
"rigidity": "sym_rigid",
"bradykinesia": "sym_brady",
"postural instability": "sym_posins",
}.items():
severity = feature_desc.get(col, 0)
if severity > 0:
query_parts.append(f"{symptom_key} severity:{severity:.2f}")
if feature_desc.get("moca", 30) < 26:
query_parts.append("cognitive impairment")
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:
contexts.append("")
continue
contexts.append(
" ".join(
f"From '{item.get('doc_title') or item.get('doc_id') or 'document'}' {item['text'][:240]}..."
for item in passages
)
)
return contexts
def _encode_contexts_for_model(model_name: str, model: Any, contexts: Optional[List[str]]) -> Optional[Dict[str, torch.Tensor]]:
if not contexts:
return None
if model_name == "clinical_t5":
texts = [f"classify patient: {text}" for text in contexts]
else:
texts = contexts
encoded = model.tokenizer(
texts,
return_tensors="pt",
padding=True,
truncation=True,
max_length=512,
)
return {key: value.cpu() for key, value in encoded.items()}
def _prepare_batch(batch: Any, device: torch.device):
if len(batch) == 3:
features, targets, contexts = batch
if isinstance(contexts, dict):
contexts = {key: value.to(device, non_blocking=True) for key, value in contexts.items()}
else:
contexts = list(contexts)
else:
features, targets = batch
contexts = None
return features.to(device), targets.to(device), contexts
def _create_transformer_model(model_name: str, input_dim: int, num_classes: int, model_kwargs: Dict[str, Any]):
from models.medical_transformers import ( # type: ignore
BioMistralClassifier as BioGPTForTabular,
ClinicalT5Classifier as ClinicalT5ForTabular,
PubMedBERTClassifier as PubMedBERTForTabular,
)
if model_name == "pubmedbert":
return PubMedBERTForTabular(input_dim=input_dim, num_classes=num_classes, **model_kwargs)
if model_name == "biogpt":
return BioGPTForTabular(input_dim=input_dim, num_classes=num_classes, **model_kwargs)
if model_name == "clinical_t5":
return ClinicalT5ForTabular(input_dim=input_dim, num_classes=num_classes, **model_kwargs)
raise ValueError(f"Unsupported transformer model: {model_name}")
def _torch_checkpoint_path(controller: TrainingRunController, model_name: str, trial_index: int) -> Path:
return controller.paths.checkpoints_dir / f"{model_name}_trial{trial_index}.pth"
def _canonical_transformer_artifacts(model_name: str) -> List[Path]:
aliases = {
"pubmedbert": ["pubmedbert_transformer.pth", "pubmedbert.pth"],
"biogpt": ["biogpt_transformer.pth", "biogpt.pth", "biomistral.pth"],
"clinical_t5": ["clinical_t5_transformer.pth", "clinicalt5_transformer.pth", "clinical_t5.pth"],
}
return [MODEL_DIR / filename for filename in aliases[model_name]]
def _run_transformer_search(
model_name: str,
trial_space: List[Dict[str, Any]],
bundle: TrainingBundle,
controller: TrainingRunController,
max_trials: int,
num_epochs: int,
patience: int,
use_rag: bool,
gpu_profile_name: str,
transformer_loss_name: str,
focal_gamma: float,
) -> Tuple[Dict[str, Any], Dict[str, Any], Path]:
from models.transformer_models import TabularDataset # type: ignore
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
amp_enabled = device.type == "cuda" and model_name != "clinical_t5"
gpu_profile = _detect_gpu_execution_profile(requested_profile=gpu_profile_name)
train_bs = gpu_profile.train_batch_by_model.get(model_name, 8)
eval_bs = gpu_profile.eval_batch_by_model.get(model_name, max(train_bs * 2, 8))
loader_kwargs = _build_loader_kwargs(device, gpu_profile)
class_weights = compute_class_weight("balanced", classes=np.unique(bundle.y_train), y=bundle.y_train)
class_weights_tensor = torch.FloatTensor(class_weights).to(device)
criterion, criterion_label = _build_transformer_criterion(
transformer_loss_name,
class_weights_tensor,
focal_gamma,
)
split = GroupShuffleSplit(n_splits=1, test_size=0.15, random_state=42)
train_index, val_index = next(split.split(bundle.X_train_dense, bundle.y_train, groups=bundle.train_groups))
train_features = bundle.X_train_dense[train_index]
train_targets = bundle.y_train[train_index]
val_features = bundle.X_train_dense[val_index]
val_targets = bundle.y_train[val_index]
train_contexts = None
val_contexts = None
if use_rag:
from document_manager import DocumentManager # type: ignore
doc_manager = DocumentManager(docs_dir=str(ROOT / "medical_docs"))
train_contexts = _build_rag_contexts(train_features, bundle.feature_names, doc_manager)
val_contexts = _build_rag_contexts(val_features, bundle.feature_names, doc_manager)
test_ds = TabularDataset(bundle.X_test_dense, bundle.y_test, bundle.feature_names, contexts=None)
test_loader = DataLoader(test_ds, batch_size=eval_bs, shuffle=False, **loader_kwargs)
checkpoint_state = controller.load_checkpoint_state(
model_name,
{
"phase": "search",
"trials": {},
"best_trial_index": None,
"best_f1": None,
"selection_metric": TRANSFORMER_SELECTION_METRIC,
"loss_name": criterion_label,
"focal_gamma": float(focal_gamma),
},
)
if (
checkpoint_state.get("selection_metric") != TRANSFORMER_SELECTION_METRIC
or checkpoint_state.get("loss_name") != criterion_label
or float(checkpoint_state.get("focal_gamma", focal_gamma)) != float(focal_gamma)
):
print(
f"[TRANSFORMER] {model_name} resetting saved trial state to match "
f"selection={TRANSFORMER_SELECTION_METRIC} and loss={criterion_label}"
)
checkpoint_state = {
"phase": "search",
"trials": {},
"best_trial_index": None,
"best_f1": None,
"selection_metric": TRANSFORMER_SELECTION_METRIC,
"loss_name": criterion_label,
"focal_gamma": float(focal_gamma),
}
for stale_checkpoint in controller.paths.checkpoints_dir.glob(f"{model_name}_trial*.pth"):
stale_checkpoint.unlink(missing_ok=True)
trial_space = list(trial_space[:max_trials])
controller.mark_running("transformer_search", model_name=model_name)
controller.update_model_state(
model_name,
status="running",
family="transformer",
trials_total=len(trial_space),
device=str(device),
gpu_profile=gpu_profile.name,
train_batch_size=train_bs,
eval_batch_size=eval_bs,
num_workers=loader_kwargs.get("num_workers", 0),
gpu_notes=gpu_profile.notes,
selection_metric=TRANSFORMER_SELECTION_METRIC,
transformer_loss=criterion_label,
)
for trial_index, trial_cfg in enumerate(trial_space):
trial_key = str(trial_index)
trial_state = checkpoint_state["trials"].setdefault(
trial_key,
{
"config": trial_cfg,
"status": "pending",
"history": {"train_loss": [], "val_loss": [], "val_f1": [], "val_acc": [], "lr": []},
"best_val_loss": None,
"best_val_f1": None,
"best_epoch": None,
"selection_metric": TRANSFORMER_SELECTION_METRIC,
"loss_name": criterion_label,
},
)
if trial_state.get("status") == "completed":
continue
controller.mark_running("transformer_trial", model_name=model_name, extra={"trial_index": trial_index})
model = _create_transformer_model(
model_name,
input_dim=bundle.X_train_dense.shape[1],
num_classes=len(bundle.class_names),
model_kwargs=trial_cfg["model_kwargs"],
).to(device)
encoded_train_contexts = _encode_contexts_for_model(model_name, model, train_contexts)
encoded_val_contexts = _encode_contexts_for_model(model_name, model, val_contexts)
train_ds = TabularDataset(train_features, train_targets, bundle.feature_names, contexts=encoded_train_contexts)
val_ds = TabularDataset(val_features, val_targets, bundle.feature_names, contexts=encoded_val_contexts)
train_loader = DataLoader(train_ds, batch_size=train_bs, shuffle=True, **loader_kwargs)
val_loader = DataLoader(val_ds, batch_size=eval_bs, shuffle=False, **loader_kwargs)
optimizer = torch.optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), **trial_cfg["optimizer"])
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="max", factor=0.5, patience=2, min_lr=1e-7)
scaler = torch.amp.GradScaler(device=device.type, enabled=amp_enabled)
grad_accum = min(
int(trial_cfg.get("grad_accum", 8)),
int(gpu_profile.grad_accum_cap_by_model.get(model_name, trial_cfg.get("grad_accum", 8))),
)
print(
f"[TRANSFORMER] {model_name} trial {trial_index + 1}/{len(trial_space)} "
f"| train_bs={train_bs} eval_bs={eval_bs} grad_accum={grad_accum} "
f"effective_batch={train_bs * grad_accum} "
f"train_steps={len(train_loader)} val_steps={len(val_loader)} "
f"| selection={TRANSFORMER_SELECTION_METRIC} loss={criterion_label}"
)
start_epoch = int(trial_state.get("next_epoch", 0))
early_stop_counter = int(trial_state.get("early_stop_counter", 0))
best_val_loss = float(trial_state["best_val_loss"]) if trial_state.get("best_val_loss") is not None else float("inf")
best_val_f1 = float(trial_state["best_val_f1"]) if trial_state.get("best_val_f1") is not None else float("-inf")
best_model_state_dict = None
torch_ckpt_path = _torch_checkpoint_path(controller, model_name, trial_index)
if torch_ckpt_path.exists():
saved = torch.load(torch_ckpt_path, map_location=device, weights_only=False)
saved_model_state = saved.get("model_state_dict", {})
checkpoint_has_non_finite = any(
torch.is_tensor(tensor) and not torch.isfinite(tensor).all()
for tensor in saved_model_state.values()
)
if (
saved.get("selection_metric") == TRANSFORMER_SELECTION_METRIC
and saved.get("loss_name") == criterion_label
and float(saved.get("focal_gamma", focal_gamma)) == float(focal_gamma)
and not checkpoint_has_non_finite
):
model.load_state_dict(saved["model_state_dict"])
optimizer.load_state_dict(saved["optimizer_state_dict"])
scheduler.load_state_dict(saved["scheduler_state_dict"])
if saved.get("scaler_state_dict") and device.type == "cuda":
scaler.load_state_dict(saved["scaler_state_dict"])
start_epoch = int(saved.get("epoch", -1)) + 1
early_stop_counter = int(saved.get("early_stop_counter", early_stop_counter))
best_val_loss = float(saved.get("best_val_loss", best_val_loss))
best_val_f1 = float(saved.get("best_val_f1", best_val_f1))
best_model_state_dict = saved.get("best_model_state_dict")
trial_state["history"] = saved.get("history", trial_state["history"])
else:
print(
f"[TRANSFORMER] {model_name} trial {trial_index + 1} ignoring stale "
f"checkpoint with incompatible selection/loss settings or non-finite weights"
)
start_epoch = 0
early_stop_counter = 0
best_val_loss = float("inf")
best_val_f1 = float("-inf")
best_model_state_dict = None
trial_state["history"] = {"train_loss": [], "val_loss": [], "val_f1": [], "val_acc": [], "lr": []}
torch_ckpt_path.unlink(missing_ok=True)
for epoch in range(start_epoch, num_epochs):
model.train()
running_train_loss = 0.0
processed_train_batches = 0
optimizer.zero_grad(set_to_none=True)
if device.type == "cuda":
torch.cuda.reset_peak_memory_stats(device)
progress = tqdm(
train_loader,
total=len(train_loader),
dynamic_ncols=True,
leave=False,
desc=f"{model_name} t{trial_index + 1}/{len(trial_space)} e{epoch + 1}/{num_epochs}",
)
for batch_index, batch in enumerate(progress):
features, targets, contexts = _prepare_batch(batch, device)
with torch.amp.autocast(device_type=device.type, enabled=amp_enabled):
outputs = model(features, contexts)
loss = criterion(outputs, targets) / grad_accum
if not torch.isfinite(outputs).all() or not torch.isfinite(loss):
optimizer.zero_grad(set_to_none=True)
continue
scaler.scale(loss).backward()
if (batch_index + 1) % grad_accum == 0 or (batch_index + 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)
running_train_loss += float(loss.item() * grad_accum)
processed_train_batches += 1
current_loss = float(loss.item() * grad_accum)
current_lr = float(optimizer.param_groups[0]["lr"])
if device.type == "cuda":
gpu_mem_gb = torch.cuda.memory_allocated(device) / 1024**3
progress.set_postfix(loss=f"{current_loss:.4f}", lr=f"{current_lr:.2e}", gpu_gb=f"{gpu_mem_gb:.2f}")
else:
progress.set_postfix(loss=f"{current_loss:.4f}", lr=f"{current_lr:.2e}")
progress.close()
avg_train_loss = running_train_loss / max(processed_train_batches, 1)
model.eval()
running_val_loss = 0.0
processed_val_batches = 0
all_preds: List[int] = []
all_targets: List[int] = []
with torch.no_grad():
for batch in val_loader:
features, targets, contexts = _prepare_batch(batch, device)
with torch.amp.autocast(device_type=device.type, enabled=amp_enabled):
outputs = model(features, contexts)
val_loss = criterion(outputs, targets)
if not torch.isfinite(outputs).all() or not torch.isfinite(val_loss):
continue
running_val_loss += float(val_loss.item())
processed_val_batches += 1
preds = torch.argmax(outputs, dim=1)
all_preds.extend(preds.cpu().numpy().tolist())
all_targets.extend(targets.cpu().numpy().tolist())
avg_val_loss = running_val_loss / max(processed_val_batches, 1)
if not all_preds:
val_acc = 0.0
val_f1 = 0.0
avg_val_loss = float("inf")
else:
val_acc = float(np.mean(np.asarray(all_preds) == np.asarray(all_targets)))
val_f1 = float(f1_score(all_targets, all_preds, average="weighted"))
scheduler.step(val_f1)
history = trial_state["history"]
history["train_loss"].append(float(avg_train_loss))
history["val_loss"].append(float(avg_val_loss))
history["val_f1"].append(val_f1)
history["val_acc"].append(val_acc)
history["lr"].append(float(optimizer.param_groups[0]["lr"]))
gpu_peak_gb = 0.0
if device.type == "cuda":
gpu_peak_gb = torch.cuda.max_memory_allocated(device) / 1024**3
print(
f"[TRANSFORMER] {model_name} trial {trial_index + 1}/{len(trial_space)} "
f"epoch {epoch + 1}/{num_epochs} "
f"train_loss={avg_train_loss:.4f} val_loss={avg_val_loss:.4f} "
f"val_f1={val_f1:.4f} val_acc={val_acc:.4f} "
f"lr={optimizer.param_groups[0]['lr']:.2e} "
f"gpu_peak_gb={gpu_peak_gb:.2f}"
)
if _is_better_validation_epoch(
val_f1=val_f1,
val_loss=avg_val_loss,
best_val_f1=best_val_f1,
best_val_loss=best_val_loss,
):
best_val_loss = avg_val_loss
best_val_f1 = val_f1
trial_state["best_val_loss"] = float(best_val_loss)
trial_state["best_val_f1"] = float(best_val_f1)
trial_state["best_epoch"] = epoch
best_model_state_dict = {
key: value.detach().cpu().clone()
for key, value in model.state_dict().items()
}
early_stop_counter = 0
else:
early_stop_counter += 1
trial_state["status"] = "running"
trial_state["next_epoch"] = epoch + 1
trial_state["early_stop_counter"] = early_stop_counter
checkpoint_state["phase"] = "search"
controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
torch.save(
{
"epoch": epoch,
"model_state_dict": model.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"scheduler_state_dict": scheduler.state_dict(),
"scaler_state_dict": scaler.state_dict() if device.type == "cuda" else None,
"history": history,
"early_stop_counter": early_stop_counter,
"best_val_loss": best_val_loss,
"best_val_f1": best_val_f1,
"best_model_state_dict": best_model_state_dict,
"selection_metric": TRANSFORMER_SELECTION_METRIC,
"loss_name": criterion_label,
"focal_gamma": float(focal_gamma),
},
torch_ckpt_path,
)
controller.update_model_state(
model_name,
current_trial=trial_index,
current_epoch=epoch,
grad_accum=grad_accum,
best_val_f1=float(best_val_f1),
best_val_loss=float(best_val_loss),
selection_metric=TRANSFORMER_SELECTION_METRIC,
transformer_loss=criterion_label,
)
controller.raise_if_requested()
if early_stop_counter >= patience:
print(
f"[TRANSFORMER] {model_name} trial {trial_index + 1}/{len(trial_space)} "
f"early-stopped after epoch {epoch + 1}"
)
break
trial_state["status"] = "completed"
controller.append_trial_result(
model_name,
{
"trial_index": trial_index,
"config": trial_cfg,
"best_val_f1": float(trial_state.get("best_val_f1") or 0.0),
"best_epoch": trial_state.get("best_epoch"),
},
)
best_so_far = checkpoint_state.get("best_f1")
if best_so_far is None or float(trial_state.get("best_val_f1") or -1.0) > float(best_so_far):
checkpoint_state["best_f1"] = float(trial_state["best_val_f1"])
checkpoint_state["best_trial_index"] = trial_index
controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
controller.raise_if_requested()
if checkpoint_state.get("best_trial_index") is None:
raise RuntimeError(f"No completed transformer trial found for {model_name}")
best_trial_index = int(checkpoint_state["best_trial_index"])
best_trial_cfg = dict(trial_space[best_trial_index])
checkpoint_state["phase"] = "evaluate"
controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
best_model = _create_transformer_model(
model_name,
input_dim=bundle.X_train_dense.shape[1],
num_classes=len(bundle.class_names),
model_kwargs=best_trial_cfg["model_kwargs"],
).to(device)
best_state = torch.load(_torch_checkpoint_path(controller, model_name, best_trial_index), map_location=device, weights_only=False)
best_model.load_state_dict(best_state.get("best_model_state_dict") or best_state["model_state_dict"])
best_model.eval()
all_preds: List[int] = []
all_targets: List[int] = []
all_probabilities: List[np.ndarray] = []
with torch.no_grad():
for batch in test_loader:
features, targets, contexts = _prepare_batch(batch, device)
outputs = best_model(features, contexts)
probs = torch.softmax(outputs, dim=1)
preds = torch.argmax(outputs, dim=1)
all_targets.extend(targets.cpu().numpy().tolist())
all_preds.extend(preds.cpu().numpy().tolist())
all_probabilities.append(probs.cpu().numpy())
probabilities = np.vstack(all_probabilities) if all_probabilities else None
metrics = _evaluate_predictions(
model_name={"pubmedbert": "PubMedBERT", "biogpt": "BioGPT", "clinical_t5": "Clinical-T5"}[model_name],
model_type="Transformer",
y_true=np.asarray(all_targets),
y_pred=np.asarray(all_preds),
probabilities=probabilities,
class_names=bundle.class_names,
)
primary_artifact = _canonical_transformer_artifacts(model_name)[0]
for path in _canonical_transformer_artifacts(model_name):
torch.save(best_model.state_dict(), path)
checkpoint_state["phase"] = "complete"
checkpoint_state["artifact_path"] = str(primary_artifact)
checkpoint_state["best_trial_index"] = best_trial_index
checkpoint_state["best_config"] = best_trial_cfg
checkpoint_state["metrics"] = {
key: value for key, value in metrics.items()
if key in {"Accuracy", "Precision", "Recall", "F1_Score", "AUROC", "Model", "Type"}
}
controller.save_checkpoint_state(model_name, _json_ready(checkpoint_state))
controller.update_model_state(
model_name,
status="completed",
artifact_path=str(primary_artifact),
best_trial_index=best_trial_index,
best_config=best_trial_cfg,
metrics=checkpoint_state["metrics"],
)
return metrics, best_trial_cfg, primary_artifact
def _train_ensemble(bundle: TrainingBundle, controller: TrainingRunController) -> Optional[Dict[str, Any]]:
from models.multimodal_ml import MultimodalEnsemble # type: ignore
controller.mark_running("ensemble_training", model_name="ensemble")
controller.raise_if_requested()
ensemble = MultimodalEnsemble()
ensemble.load_traditional_models(model_dir=str(MODEL_DIR))
ensemble.load_transformer_models(model_dir=str(MODEL_DIR), input_dim=bundle.X_train_dense.shape[1], num_classes=len(bundle.class_names))
ensemble.train_ensemble(bundle.X_train_dense, bundle.y_train, ensemble_type="stacking")
ensemble.save_ensemble(str(MODEL_DIR / "multimodal_ensemble.joblib"))
results = ensemble.evaluate_ensemble(bundle.X_test_dense, bundle.y_test)
probabilities = results.get("probabilities")
metrics = _evaluate_predictions(
model_name="Multimodal Ensemble",
model_type="Ensemble",
y_true=bundle.y_test,
y_pred=np.asarray(results["predictions"]),
probabilities=np.asarray(probabilities) if probabilities is not None else None,
class_names=bundle.class_names,
)
controller.update_model_state(
"ensemble",
status="completed",
artifact_path=str(MODEL_DIR / "multimodal_ensemble.joblib"),
metrics={key: value for key, value in metrics.items() if key in {"Accuracy", "Precision", "Recall", "F1_Score", "AUROC", "Model", "Type"}},
)
return metrics
def _write_metric_outputs(base_metrics: List[Dict[str, Any]], ensemble_metrics: Optional[Dict[str, Any]]) -> None:
EVAL_DIR.mkdir(parents=True, exist_ok=True)
MODEL_METRICS_DIR.mkdir(parents=True, exist_ok=True)
summary_rows = []
traditional_rows = []
transformer_rows = []
for metrics in base_metrics:
summary_rows.append({
"Model": metrics["Model"],
"Type": metrics["Type"],
"Accuracy": metrics["Accuracy"],
"Precision": metrics["Precision"],
"Recall": metrics["Recall"],
"F1_Score": metrics["F1_Score"],
"AUROC": metrics["AUROC"] if metrics["AUROC"] is not None else "",
})
if "traditional" in metrics["Type"].lower():
traditional_rows.append(metrics)
if "transformer" in metrics["Type"].lower():
transformer_rows.append(metrics)
summary_df = pd.DataFrame(summary_rows)
summary_df.to_csv(EVAL_DIR / "summary_metrics.csv", index=False)
summary_df.to_csv(MODEL_METRICS_DIR / "model_metrics_summary.csv", index=False)
(EVAL_DIR / "traditional_metrics_latest.json").write_text(json.dumps(_json_ready(traditional_rows), indent=2), encoding="utf-8")
(EVAL_DIR / "transformer_metrics_latest.json").write_text(json.dumps(_json_ready(transformer_rows), indent=2), encoding="utf-8")
if ensemble_metrics is not None:
(EVAL_DIR / "ensemble_metrics_latest.json").write_text(json.dumps(_json_ready(ensemble_metrics), indent=2), encoding="utf-8")
def _write_run_manifest_snapshot(controller: TrainingRunController, base_metrics: List[Dict[str, Any]], ensemble_metrics: Optional[Dict[str, Any]]) -> None:
controller.write_metrics_file(
"final_metrics.json",
{
"base_metrics": _json_ready(base_metrics),
"ensemble_metrics": _json_ready(ensemble_metrics),
},
)
def _parse_selected_models(raw: str) -> List[str]:
raw = (raw or "all").strip().lower()
if raw in {"all", "*"}:
return list(ALL_BASE_MODELS)
aliases = {
"lgbm": "lightgbm",
"lightgbm": "lightgbm",
"xgb": "xgboost",
"xgboost": "xgboost",
"svm": "svm",
"pubmed": "pubmedbert",
"pubmedbert": "pubmedbert",
"biogpt": "biogpt",
"bio": "biogpt",
"clinical": "clinical_t5",
"clinical_t5": "clinical_t5",
"t5": "clinical_t5",
}
selected: List[str] = []
for piece in [part.strip() for part in raw.split(",") if part.strip()]:
canonical = aliases.get(piece)
if canonical and canonical not in selected:
selected.append(canonical)
if not selected:
raise ValueError(f"No valid models were selected from '{raw}'")
return selected
def _detect_gpu_execution_profile(
requested_profile: str = "auto",
cuda_available: Optional[bool] = None,
device_name: Optional[str] = None,
total_memory_gb: Optional[float] = None,
) -> GPUExecutionProfile:
cuda_ready = torch.cuda.is_available() if cuda_available is None else cuda_available
if not cuda_ready:
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_cap_by_model={"pubmedbert": 12, "biogpt": 16, "clinical_t5": 16},
num_workers=0,
prefetch_factor=2,
persistent_workers=False,
notes="CPU fallback profile.",
)
name = (device_name or torch.cuda.get_device_name(0)).lower()
memory_gb = float(total_memory_gb if total_memory_gb is not None else (torch.cuda.get_device_properties(0).total_memory / 1024**3))
requested = (requested_profile or "auto").strip().lower()
if requested in {"rtx-a4000", "a4000"} or (requested == "auto" and ("a4000" in name or memory_gb >= 15.0)):
return GPUExecutionProfile(
name="rtx-a4000",
train_batch_by_model={"pubmedbert": 32, "biogpt": 12, "clinical_t5": 12},
eval_batch_by_model={"pubmedbert": 64, "biogpt": 24, "clinical_t5": 24},
grad_accum_cap_by_model={"pubmedbert": 2, "biogpt": 5, "clinical_t5": 5},
num_workers=6,
prefetch_factor=4,
persistent_workers=True,
notes="Optimized for RTX A4000 / ~16 GB VRAM with larger per-step batches.",
)
if requested in {"high-vram", "16gb"} or (requested == "auto" and 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_cap_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_cap_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: torch.device, profile: GPUExecutionProfile) -> Dict[str, Any]:
kwargs: Dict[str, Any] = {
"num_workers": profile.num_workers if device.type == "cuda" else 0,
"pin_memory": device.type == "cuda",
}
if kwargs["num_workers"] > 0:
kwargs["persistent_workers"] = profile.persistent_workers
kwargs["prefetch_factor"] = profile.prefetch_factor
return kwargs
def _configure_transformer_runtime(selected_models: Sequence[str], allow_cpu_transformers: bool, cuda_available: Optional[bool] = None) -> str:
cuda_ready = torch.cuda.is_available() if cuda_available is None else cuda_available
transformer_requested = any(model_name in TRANSFORMER_MODELS for model_name in selected_models)
if not transformer_requested:
return "not-applicable"
if not cuda_ready and not allow_cpu_transformers:
raise RuntimeError(
"Transformer retraining requires CUDA for this accuracy profile. "
"Use a CUDA-enabled PyTorch install/GPU, or rerun with --allow-cpu-transformers if you explicitly accept slower, lower-throughput training."
)
if cuda_ready:
torch.set_float32_matmul_precision("high")
if hasattr(torch.backends, "cuda") and hasattr(torch.backends.cuda, "matmul"):
torch.backends.cuda.matmul.allow_tf32 = True
if hasattr(torch.backends, "cudnn"):
torch.backends.cudnn.allow_tf32 = True
torch.backends.cudnn.benchmark = True
return "cuda"
return "cpu"
def _run_training(args: argparse.Namespace) -> int:
selected_models = _parse_selected_models(args.models)
transformer_runtime = _configure_transformer_runtime(
selected_models,
allow_cpu_transformers=args.allow_cpu_transformers,
)
controller = TrainingRunController(RUNS_DIR, args.run_name)
controller.initialize(
selected_models=selected_models,
config={
"epochs": args.epochs,
"patience": args.patience,
"traditional_trials": args.traditional_trials,
"transformer_trials": args.transformer_trials,
"use_rag": args.use_rag,
"train_ensemble": not args.skip_ensemble,
"allow_cpu_transformers": args.allow_cpu_transformers,
"transformer_runtime": transformer_runtime,
"gpu_profile": args.gpu_profile,
"transformer_loss": args.transformer_loss,
"focal_gamma": args.focal_gamma,
},
resume=args.resume,
)
if args.dry_run:
controller.mark_paused("dry-run initialized")
print(json.dumps(controller.status_summary(), indent=2))
return 0
try:
controller.clear_stop()
if args.resume:
controller.clear_pause()
bundle = _prepare_training_bundle()
base_metrics: List[Dict[str, Any]] = []
for model_name in selected_models:
if model_name in TRADITIONAL_MODELS:
metrics, best_config, artifact_path = _run_grouped_traditional_search(
model_name=model_name,
search_space=TRADITIONAL_SEARCH_SPACES[model_name],
bundle=bundle,
controller=controller,
max_trials=args.traditional_trials,
)
else:
metrics, best_config, artifact_path = _run_transformer_search(
model_name=model_name,
trial_space=TRANSFORMER_TRIALS[model_name],
bundle=bundle,
controller=controller,
max_trials=args.transformer_trials,
num_epochs=args.epochs,
patience=args.patience,
use_rag=args.use_rag,
gpu_profile_name=args.gpu_profile,
transformer_loss_name=args.transformer_loss,
focal_gamma=args.focal_gamma,
)
base_metrics.append(metrics)
controller.update_model_state(
model_name,
best_config=_json_ready(best_config),
artifact_path=str(artifact_path),
metrics={key: value for key, value in metrics.items() if key in {"Accuracy", "Precision", "Recall", "F1_Score", "AUROC", "Model", "Type"}},
)
ensemble_metrics = None
should_train_ensemble = (not args.skip_ensemble) and set(selected_models) == set(ALL_BASE_MODELS)
if should_train_ensemble:
ensemble_metrics = _train_ensemble(bundle, controller)
elif not args.skip_ensemble:
controller.update_model_state(
"ensemble",
status="skipped",
reason="ensemble retraining is only automatic when all six base models are selected",
)
_write_metric_outputs(base_metrics, ensemble_metrics)
_write_run_manifest_snapshot(controller, base_metrics, ensemble_metrics)
controller.mark_completed()
print(json.dumps(controller.status_summary(), indent=2))
return 0
except PauseRequested as exc:
controller.mark_paused(str(exc))
print(json.dumps(controller.status_summary(), indent=2))
return 0
except StopRequested as exc:
controller.mark_stopped(str(exc))
print(json.dumps(controller.status_summary(), indent=2))
return 0
except KeyboardInterrupt:
controller.mark_paused("keyboard interrupt")
print(json.dumps(controller.status_summary(), indent=2))
return 1
except Exception as exc:
controller.mark_failed(str(exc))
raise
def _run_status(args: argparse.Namespace) -> int:
controller = TrainingRunController(RUNS_DIR, args.run_name)
print(json.dumps(controller.status_summary(), indent=2))
return 0
def _run_pause(args: argparse.Namespace) -> int:
controller = TrainingRunController(RUNS_DIR, args.run_name)
controller.request_pause()
controller.mark_paused("pause requested from CLI")
print(json.dumps(controller.status_summary(), indent=2))
return 0
def _run_stop(args: argparse.Namespace) -> int:
controller = TrainingRunController(RUNS_DIR, args.run_name)
controller.request_stop()
controller.mark_stopped("stop requested from CLI")
print(json.dumps(controller.status_summary(), indent=2))
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Retrain the six base Parkinson's models with resumable checkpoints.")
subparsers = parser.add_subparsers(dest="command", required=True)
train_parser = subparsers.add_parser("train", help="Start a new training run.")
train_parser.add_argument("--run-name", default="full_retrain", help="Name of the training run.")
train_parser.add_argument("--models", default="all", help="Comma-separated list of models or 'all'.")
train_parser.add_argument("--epochs", type=int, default=30, help="Max epochs per transformer trial.")
train_parser.add_argument("--patience", type=int, default=8, help="Early-stopping patience for transformers.")
train_parser.add_argument("--traditional-trials", type=int, default=6, help="Number of traditional-model trials per model.")
train_parser.add_argument("--transformer-trials", type=int, default=6, help="Number of transformer trials per model.")
train_parser.add_argument("--use-rag", dest="use_rag", action="store_true", help="Build RAG contexts for transformer training.")
train_parser.add_argument("--no-rag", dest="use_rag", action="store_false", help="Disable RAG context enrichment for transformer training.")
train_parser.add_argument("--gpu-profile", default="auto", choices=["auto", "rtx-a4000", "high-vram", "compat"], help="CUDA loader/batch preset for transformer training.")
train_parser.add_argument("--transformer-loss", default=DEFAULT_TRANSFORMER_LOSS, choices=["cross_entropy", "focal"], help="Loss function for transformer fine-tuning.")
train_parser.add_argument("--focal-gamma", type=float, default=DEFAULT_FOCAL_GAMMA, help="Gamma value for focal loss when --transformer-loss focal is used.")
train_parser.add_argument("--allow-cpu-transformers", action="store_true", help="Allow transformer training on CPU when CUDA is unavailable.")
train_parser.add_argument("--skip-ensemble", action="store_true", help="Skip ensemble retraining after the six base models.")
train_parser.add_argument("--resume", action="store_true", help="Resume an existing run from saved state.")
train_parser.add_argument("--dry-run", action="store_true", help="Initialize the run manifest without training.")
train_parser.set_defaults(func=_run_training, use_rag=True)
resume_parser = subparsers.add_parser("resume", help="Resume a paused training run.")
resume_parser.add_argument("--run-name", default="full_retrain", help="Name of the training run.")
resume_parser.add_argument("--models", default="all", help="Comma-separated list of models or 'all'.")
resume_parser.add_argument("--epochs", type=int, default=30, help="Max epochs per transformer trial.")
resume_parser.add_argument("--patience", type=int, default=8, help="Early-stopping patience for transformers.")
resume_parser.add_argument("--traditional-trials", type=int, default=6, help="Number of traditional-model trials per model.")
resume_parser.add_argument("--transformer-trials", type=int, default=6, help="Number of transformer trials per model.")
resume_parser.add_argument("--use-rag", dest="use_rag", action="store_true", help="Build RAG contexts for transformer training.")
resume_parser.add_argument("--no-rag", dest="use_rag", action="store_false", help="Disable RAG context enrichment for transformer training.")
resume_parser.add_argument("--gpu-profile", default="auto", choices=["auto", "rtx-a4000", "high-vram", "compat"], help="CUDA loader/batch preset for transformer training.")
resume_parser.add_argument("--transformer-loss", default=DEFAULT_TRANSFORMER_LOSS, choices=["cross_entropy", "focal"], help="Loss function for transformer fine-tuning.")
resume_parser.add_argument("--focal-gamma", type=float, default=DEFAULT_FOCAL_GAMMA, help="Gamma value for focal loss when --transformer-loss focal is used.")
resume_parser.add_argument("--allow-cpu-transformers", action="store_true", help="Allow transformer training on CPU when CUDA is unavailable.")
resume_parser.add_argument("--skip-ensemble", action="store_true", help="Skip ensemble retraining after the six base models.")
resume_parser.add_argument("--dry-run", action="store_true", help="Load and print the current run state without training.")
resume_parser.set_defaults(func=_run_training, resume=True, use_rag=True)
pause_parser = subparsers.add_parser("pause", help="Request a graceful pause for a running training job.")
pause_parser.add_argument("--run-name", default="full_retrain", help="Name of the training run.")
pause_parser.set_defaults(func=_run_pause)
stop_parser = subparsers.add_parser("stop", help="Request a graceful stop for a running training job.")
stop_parser.add_argument("--run-name", default="full_retrain", help="Name of the training run.")
stop_parser.set_defaults(func=_run_stop)
status_parser = subparsers.add_parser("status", help="Show the current run manifest.")
status_parser.add_argument("--run-name", default="full_retrain", help="Name of the training run.")
status_parser.set_defaults(func=_run_status)
return parser
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return int(args.func(args))
if __name__ == "__main__":
raise SystemExit(main())