Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
3 changes: 0 additions & 3 deletions .gitmodules

This file was deleted.

1 change: 0 additions & 1 deletion DeepSurvivalMachines
Submodule DeepSurvivalMachines deleted from c45477
14 changes: 7 additions & 7 deletions ddh/losses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 2 additions & 2 deletions tests/test_hazard_head.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
12 changes: 6 additions & 6 deletions tests/test_horizon_likelihood.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading