diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..fee937c --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,27 @@ +name: tests + +on: + push: + pull_request: + +jobs: + pytest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install + run: | + python -m pip install --upgrade pip + 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]" + - name: Test + run: python -m pytest -q diff --git a/.gitignore b/.gitignore index b6e4761..b49289d 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,15 @@ dmypy.json # Pyre type checker .pyre/ + +# Patient-level data and anything derived from it never belong in this +# repository. Point the evaluation scripts at a directory outside it. +*.csv +*.parquet +*.feather +*.pkl +*.pickle +*.pt +*.npz +data/ +outputs/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 8834276..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "DeepSurvivalMachines"] - path = DeepSurvivalMachines - url = git@github.com:autonlab/DeepSurvivalMachines.git diff --git a/DeepSurvivalMachines b/DeepSurvivalMachines deleted file mode 160000 index c454774..0000000 --- a/DeepSurvivalMachines +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c454774199c389e7bb9fa3077f153cdf4f1e7696 diff --git a/ddh/losses.py b/ddh/losses.py index f0958bc..e28235d 100644 --- a/ddh/losses.py +++ b/ddh/losses.py @@ -73,8 +73,8 @@ def build_landmark_prefixes(x, s_all, visit_days=None, decay_days=None, A patient with V_i observed visits becomes V_i rows: row v keeps visits 1..v+1 and NaNs out everything after, exactly the input the model would receive if that visit were the patient's last observation. This is the - training-time counterpart of build_intermediate_landmark_dataset in the - DDH_implement pipeline, which does the same truncation at evaluation time. + training-time counterpart of landmark evaluation, which applies the same + truncation to every patient at test time. Args: x : [N, T, D] padded features, NaN after each patient's last visit. @@ -217,7 +217,7 @@ def landmark_nll_loss(model, x_exp, t_exp, e_exp, s_exp, weight, a single (final-visit) landmark. Rows whose landmark bin is at or beyond the patient's event bin carry no information about the remaining horizon and are dropped; the 1 / V_i weight is deliberately left as-is, so those - few patients (0.5% of rows on the VTE cohort) count slightly under 1. + few patients count slightly under 1. With `horizon_spec` and gamma > 0 the same forward pass also supplies horizon_likelihood_loss, and the returned scalar is @@ -272,7 +272,7 @@ def ranking_loss(cif, t, e, sigma, s=None, cause_weights=None): cause_weights: list of K non-negative floats (alpha_k in the DDH paper) scaling each cause's ranking contribution. Defaults to equal weights 1/K. Increase a cause's weight to prioritise its ranking accuracy - (e.g. [0.8, 0.2] to penalise wrong VTE ordering more than death). + (e.g. [0.8, 0.2] to penalise wrong ordering of cause 1 more than cause 2). Uses torch.sum over valid pairs per the DDH paper (Σ_{i≠j}). Because the sum scales with batch size, set alpha in total_loss small @@ -413,12 +413,12 @@ def total_loss(model, x, t, e, alpha, beta, sigma, s=None, cause_weights=None, i # Binary log likelihood is a strictly proper scoring rule, so its minimiser is # the true conditional probability at h -- the level, not just the ordering. # -# LABELS AND WEIGHTS mirror V3's evaluation code exactly (Graf/Gerds-Schumacher): +# LABELS AND WEIGHTS mirror the landmark evaluation exactly (Graf/Gerds-Schumacher): # T <= h, cause 1 -> y = 1, weight G(s) / G(T-) -# T <= h, cause 2 -> y = 0, weight G(s) / G(T-) (death without VTE is a true 0) +# T <= h, cause 2 -> y = 0, weight G(s) / G(T-) (a competing event is a true 0) # T > h -> y = 0, weight G(s) / G(h) # censored before h -> weight 0 (status unknown) -# with the G(s) factor conditioning on the landmark, which V3 does not need +# with the G(s) factor conditioning on the landmark, which the evaluation does not need # because it scores one landmark at a time and the factor divides out there. # Matching the metric matters more than picking a different defensible estimator: # a training term and a reported metric that disagree cannot be read together. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..809769d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "ddh" +version = "0.2.0" +description = "Dynamic-DeepHit in PyTorch, extended for landmark-based dynamic prediction" +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.10" +authors = [{ name = "Vincent Jeanselme" }, { name = "Dulin Wang" }] +dependencies = [ + "torch>=2.0", + "numpy", + "tqdm", + # Imported when auton-survival loads. auton-survival itself is installed + # with --no-deps (see README): its PyPI release pins torch<2.0. + "pandas", + "scikit-learn", + "scikit-survival", + "matplotlib", +] + +[project.optional-dependencies] +test = ["pytest"] + +[tool.setuptools] +packages = ["ddh"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/test_hazard_head.py b/tests/test_hazard_head.py index 4b97e24..fa0cdd7 100644 --- a/tests/test_hazard_head.py +++ b/tests/test_hazard_head.py @@ -210,8 +210,8 @@ def test_proportional_keeps_risk_ordering_constant_across_bins(): step = pmf.sum(-1) survival = 1.0 - step.cumsum(1) s_before = torch.cat([torch.ones(N, 1, dtype=torch.float64), survival[:, :-1]], 1) - lam_vte = (pmf[:, :, 0] / s_before) # [N, B] - order = lam_vte.argsort(dim=0) + lam_1 = (pmf[:, :, 0] / s_before) # [N, B] + order = lam_1.argsort(dim=0) for b in range(1, B): assert torch.equal(order[:, b], order[:, 0]), f"ranking changed at bin {b}" diff --git a/tests/test_horizon_likelihood.py b/tests/test_horizon_likelihood.py index c57fe75..7e7a11a 100644 --- a/tests/test_horizon_likelihood.py +++ b/tests/test_horizon_likelihood.py @@ -1,5 +1,5 @@ """The horizon-specific likelihood term must be (a) inert at gamma=0, (b) scored on -exactly the labels and weights V3's evaluation uses, and (c) minimised at the truth.""" +exactly the labels and weights the landmark evaluation uses, and (c) minimised at the truth.""" import sys from pathlib import Path @@ -104,8 +104,8 @@ def test_horizon_inside_the_landmark_bin_contributes_nothing(): assert parts["horizon"] == 0.0 -def test_labels_and_weights_match_v3_evaluation(): - """Graf/Gerds-Schumacher, as V3's parts(): cause-2 deaths before h are true +def test_labels_and_weights_match_landmark_evaluation(): + """Graf/Gerds-Schumacher, as the landmark evaluation: cause-2 deaths before h are true zeros carrying 1/G(T-), survivors past h carry 1/G(h), censored-before-h carry nothing.""" n_bins = 10 @@ -114,7 +114,7 @@ def test_labels_and_weights_match_v3_evaluation(): s_all = torch.zeros(4, 1).long() visit_days = torch.zeros(4, 1, dtype=torch.double) t = torch.tensor([3, 3, 9, 3]).long() # bins - e = torch.tensor([1, 2, 0, 0]).int() # VTE, death, censored late, censored early + e = torch.tensor([1, 2, 0, 0]).int() # cause 1, cause 2, censored late, censored early split_time = torch.linspace(0, 1000, n_bins + 1).double() # bin width 100 # horizon 500 days -> bucketize(500) - 1 = bin 4 mass = torch.full((2, n_bins), 0.02, dtype=torch.double) @@ -130,8 +130,8 @@ def test_labels_and_weights_match_v3_evaluation(): b_h = int(torch.bucketize(torch.tensor([500.0]), split_time, right=False).item()) - 1 p = float((cif[0][0, b_h] - cif[0][0, 0]) / (1 - cif[0][0, 0] - cif[1][0, 0])) want = 0.0 - want += -np.log(p) * float(1 / g[2]) # VTE at bin 3 <= 4, y=1, 1/G(T-) - want += -np.log(1 - p) * float(1 / g[2]) # death at bin 3, y=0, 1/G(T-) + want += -np.log(p) * float(1 / g[2]) # cause 1 at bin 3 <= 4, y=1, 1/G(T-) + want += -np.log(1 - p) * float(1 / g[2]) # cause 2 at bin 3, y=0, 1/G(T-) want += -np.log(1 - p) * float(1 / g[b_h]) # alive past h, y=0, 1/G(h) want += 0.0 # censored at bin 3 < h: dropped assert abs(val.item() - want / 2) < 1e-9, (val.item(), want / 2)