moved_repo_first_update
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatientSplit:
|
||||
"""Patient-disjoint split definition for a fold."""
|
||||
|
||||
train: pd.DataFrame
|
||||
val: pd.DataFrame
|
||||
holdout: Optional[pd.DataFrame] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LoaderBundle:
|
||||
"""All loaders needed by a training run."""
|
||||
|
||||
train: Any
|
||||
val: Any
|
||||
holdout: Optional[Any] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FoldResult:
|
||||
"""Normalized fold output from trainer implementations."""
|
||||
|
||||
fold: int
|
||||
metrics: dict[str, Any]
|
||||
artifacts: dict[str, Any]
|
||||
|
||||
|
||||
class SplitManager(Protocol):
|
||||
def build_plans(
|
||||
self,
|
||||
*,
|
||||
clinical: Any,
|
||||
args: Any,
|
||||
profile: Optional[Any] = None,
|
||||
) -> list[PatientSplit]:
|
||||
...
|
||||
|
||||
|
||||
class GraphFactory(Protocol):
|
||||
def build(
|
||||
self,
|
||||
*,
|
||||
clinical: Any,
|
||||
args: Any,
|
||||
fold: int,
|
||||
profile: Optional[Any] = None,
|
||||
) -> Any:
|
||||
...
|
||||
|
||||
|
||||
class LoaderFactory(Protocol):
|
||||
def build(
|
||||
self,
|
||||
*,
|
||||
clinical: Any,
|
||||
split: PatientSplit,
|
||||
args: Any,
|
||||
fold: int,
|
||||
profile: Optional[Any] = None,
|
||||
) -> LoaderBundle:
|
||||
...
|
||||
|
||||
|
||||
class Trainer(Protocol):
|
||||
def fit(
|
||||
self,
|
||||
*,
|
||||
graph: Any,
|
||||
loaders: LoaderBundle,
|
||||
args: Any,
|
||||
fold: int,
|
||||
profile: Optional[Any] = None,
|
||||
) -> FoldResult:
|
||||
...
|
||||
|
||||
|
||||
class NetworkManager:
|
||||
"""
|
||||
V2 orchestration entrypoint.
|
||||
|
||||
This class is intentionally small and modular:
|
||||
- split policy is delegated to a SplitManager
|
||||
- graph assembly is delegated to a GraphFactory
|
||||
- dataloaders are delegated to a LoaderFactory
|
||||
- train/eval/checkpoint lifecycle is delegated to a Trainer
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
clinical: Any,
|
||||
args: Any,
|
||||
split_manager: SplitManager,
|
||||
graph_factory: GraphFactory,
|
||||
loader_factory: LoaderFactory,
|
||||
trainer: Trainer,
|
||||
profile: Optional[Any] = None,
|
||||
) -> None:
|
||||
self.clinical = clinical
|
||||
self.args = args
|
||||
self.split_manager = split_manager
|
||||
self.graph_factory = graph_factory
|
||||
self.loader_factory = loader_factory
|
||||
self.trainer = trainer
|
||||
self.profile = profile
|
||||
self._split_plans: Optional[list[PatientSplit]] = None
|
||||
|
||||
def run_fold(self, fold: int) -> FoldResult:
|
||||
plans = self._get_split_plans()
|
||||
if fold < 0 or fold >= len(plans):
|
||||
raise IndexError(f"Requested fold {fold} but only {len(plans)} fold plans are available")
|
||||
split = plans[fold]
|
||||
self._validate_patient_disjointness(split)
|
||||
self._validate_labels(split)
|
||||
|
||||
graph = self.graph_factory.build(
|
||||
clinical=self.clinical,
|
||||
args=self.args,
|
||||
fold=fold,
|
||||
profile=self.profile,
|
||||
)
|
||||
loaders = self.loader_factory.build(
|
||||
clinical=self.clinical,
|
||||
split=split,
|
||||
args=self.args,
|
||||
fold=fold,
|
||||
profile=self.profile,
|
||||
)
|
||||
return self.trainer.fit(
|
||||
graph=graph,
|
||||
loaders=loaders,
|
||||
args=self.args,
|
||||
fold=fold,
|
||||
profile=self.profile,
|
||||
)
|
||||
|
||||
def run_all_folds(self, n_splits: Optional[int] = None) -> list[FoldResult]:
|
||||
plans = self._get_split_plans()
|
||||
max_folds = len(plans)
|
||||
if n_splits is None:
|
||||
n = max_folds
|
||||
else:
|
||||
n = int(n_splits)
|
||||
if n < 1:
|
||||
raise ValueError("n_splits must be >= 1")
|
||||
if n > max_folds:
|
||||
raise ValueError(f"Requested {n} folds but only {max_folds} fold plans are available")
|
||||
return [self.run_fold(fold) for fold in range(n)]
|
||||
|
||||
def _get_split_plans(self) -> list[PatientSplit]:
|
||||
if self._split_plans is None:
|
||||
self._split_plans = self.split_manager.build_plans(
|
||||
clinical=self.clinical,
|
||||
args=self.args,
|
||||
profile=self.profile,
|
||||
)
|
||||
if not self._split_plans:
|
||||
raise ValueError("SplitManager returned no fold plans")
|
||||
return self._split_plans
|
||||
|
||||
def _validate_patient_disjointness(self, split: PatientSplit) -> None:
|
||||
train_ids = self._patient_ids(split.train)
|
||||
val_ids = self._patient_ids(split.val)
|
||||
holdout_ids = self._patient_ids(split.holdout) if split.holdout is not None else set()
|
||||
|
||||
if train_ids & val_ids:
|
||||
overlap = sorted(train_ids & val_ids)[:10]
|
||||
raise ValueError(f"Patient leakage between train/val: {overlap}")
|
||||
if train_ids & holdout_ids:
|
||||
overlap = sorted(train_ids & holdout_ids)[:10]
|
||||
raise ValueError(f"Patient leakage between train/holdout: {overlap}")
|
||||
if val_ids & holdout_ids:
|
||||
overlap = sorted(val_ids & holdout_ids)[:10]
|
||||
raise ValueError(f"Patient leakage between val/holdout: {overlap}")
|
||||
|
||||
def _validate_labels(self, split: PatientSplit) -> None:
|
||||
label_col = getattr(self.clinical, "label_col", None)
|
||||
if not label_col:
|
||||
return
|
||||
for name, df in (("train", split.train), ("val", split.val), ("holdout", split.holdout)):
|
||||
if df is None:
|
||||
continue
|
||||
if label_col not in df.columns:
|
||||
raise ValueError(f"{name} split is missing label column {label_col!r}")
|
||||
|
||||
@staticmethod
|
||||
def _patient_ids(df: Optional[pd.DataFrame]) -> set[Any]:
|
||||
if df is None or df.empty:
|
||||
return set()
|
||||
if "Patient ID" not in df.columns:
|
||||
raise ValueError("Split dataframes must include 'Patient ID'")
|
||||
return set(df["Patient ID"].tolist())
|
||||
Reference in New Issue
Block a user