diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fee937c..d93c7de 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,6 +22,6 @@ jobs: pip install torch --index-url https://download.pytorch.org/whl/cpu # auton-survival 0.1.0 (release commit); --no-deps because its metadata pins torch<2.0 pip install --no-deps "auton-survival @ git+https://github.com/autonlab/auton-survival@5dde465f7223601717abddc1d075e837707c403b" - pip install -e ".[test]" + pip install -e ".[test,eval]" - name: Test run: python -m pytest -q diff --git a/evaluation/__init__.py b/evaluation/__init__.py new file mode 100644 index 0000000..6f106cc --- /dev/null +++ b/evaluation/__init__.py @@ -0,0 +1,9 @@ +"""How the landmark Dynamic-DeepHit models are trained and evaluated. + +Scripts (run with python -m evaluation.; each has --help): + make_synthetic a synthetic cohort in the expected layout + train train one configuration on every split + apply_external score an external / temporal cohort with each split's model + evaluate compare configurations on landmarks, visits and the external cohort + history_ablation within-patient test of whether earlier visits are used +""" diff --git a/evaluation/apply_external.py b/evaluation/apply_external.py new file mode 100644 index 0000000..955b98a --- /dev/null +++ b/evaluation/apply_external.py @@ -0,0 +1,57 @@ +"""Score an external (e.g. later-calendar-period) cohort with every split's trained model. + +The external table has the same layout as the training tables, preprocessed with +split k's own transforms (so it is read from /split_k/). For a +temporal validation it should hold, per patient, every visit up to and including +the ANCHOR visit (e.g. the first visit inside the validation window) and nothing +after it, with the outcome measured from the patient's first visit. + +Writes to //split_k/: + external_anchor_predictions.csv each patient scored at the anchor (their last row) + external_every_visit_predictions.csv each patient scored at every retained visit + +The evaluation averages the split models' anchor predictions into one ensemble. + + python -m evaluation.apply_external --arm band +""" +from __future__ import annotations + +import argparse + +from .config import (EXTERNAL_ANCHOR_FILE, EXTERNAL_EVERY_VISIT_FILE, Columns, add_path_arguments, + paths_from_args) +from .data import build_every_visit, build_sequences, read_visits +from .model import load_run, predict_at_horizons, set_determinism + + +def main(argv=None) -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + add_path_arguments(ap, out=False) + ap.add_argument("--arm", required=True) + ap.add_argument("--splits", type=int, nargs="+", default=list(range(10))) + ap.add_argument("--seed", type=int, default=42) + args = ap.parse_args(argv) + cols, paths = Columns.load(args.columns), paths_from_args(args) + + for split in args.splits: + run_dir = paths.run_dir(args.arm, split) + set_determinism(args.seed) + train_frame = read_visits(paths.split_file(split, "train"), cols) + model, meta = load_run(run_dir, cols, train_frame) + cohort = read_visits(paths.external_file(split), cols) + missing = [c for c in meta["predictors"] if c not in cohort] + if missing: + raise SystemExit(f"split {split}: external cohort lacks predictors {missing}") + horizons = meta["horizons"] + + every = predict_at_horizons(model, build_every_visit(cohort, meta["predictors"], cols), horizons, cols) + anchor = predict_at_horizons(model, build_sequences(cohort, meta["predictors"], cols), horizons, cols) + for frame in (every, anchor): + frame.insert(0, "split", split) + every.to_csv(run_dir / EXTERNAL_EVERY_VISIT_FILE, index=False) + anchor.to_csv(run_dir / EXTERNAL_ANCHOR_FILE, index=False) + print(f"split {split}: {len(anchor)} patients, {len(every)} visits -> {run_dir}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/evaluation/config.py b/evaluation/config.py new file mode 100644 index 0000000..f0426cc --- /dev/null +++ b/evaluation/config.py @@ -0,0 +1,158 @@ +"""Where the data lives and what its columns are called. + +Nothing in the evaluation scripts names a file or a column directly. Every path +comes from a command-line flag or, when the flag is omitted, an environment +variable; every column name comes from a Columns mapping, which defaults to the +generic names below and can be replaced with a JSON file (--columns or +DDH_COLUMNS). Patient-level data therefore stays wherever its owner keeps it. + +Layout under the data root, one folder per random split: + + /split_/