deepaft 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepaft/__init__.py +25 -0
- deepaft/censoring.py +264 -0
- deepaft/diagnostics.py +60 -0
- deepaft/estimator.py +367 -0
- deepaft/limitations.py +10 -0
- deepaft/methods.py +76 -0
- deepaft/metrics.py +80 -0
- deepaft/model.py +72 -0
- deepaft/predict.py +114 -0
- deepaft/simulate.py +146 -0
- deepaft-0.1.0.dist-info/METADATA +215 -0
- deepaft-0.1.0.dist-info/RECORD +15 -0
- deepaft-0.1.0.dist-info/WHEEL +5 -0
- deepaft-0.1.0.dist-info/licenses/LICENSE +21 -0
- deepaft-0.1.0.dist-info/top_level.txt +1 -0
deepaft/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
deepaft: Python deep accelerated failure time models.
|
|
3
|
+
|
|
4
|
+
Reproduces Norman et al. (2024) Statistics in Medicine methods and adds
|
|
5
|
+
limitation-aware extensions for covariate-dependent censoring and
|
|
6
|
+
heteroscedastic residuals.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from deepaft.estimator import DeepAFT, DeepAFTConfig
|
|
10
|
+
from deepaft.metrics import amse, harrell_c_index, integrated_brier_score
|
|
11
|
+
from deepaft.simulate import scenario_a, scenario_b, scenario_c, scenario_d, scenario_ph
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
__all__ = [
|
|
15
|
+
"DeepAFT",
|
|
16
|
+
"DeepAFTConfig",
|
|
17
|
+
"harrell_c_index",
|
|
18
|
+
"amse",
|
|
19
|
+
"integrated_brier_score",
|
|
20
|
+
"scenario_a",
|
|
21
|
+
"scenario_b",
|
|
22
|
+
"scenario_c",
|
|
23
|
+
"scenario_d",
|
|
24
|
+
"scenario_ph",
|
|
25
|
+
]
|
deepaft/censoring.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Kaplan-Meier and inverse censoring probability utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _prepare_survival(
|
|
11
|
+
time: np.ndarray,
|
|
12
|
+
event: np.ndarray,
|
|
13
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
14
|
+
time = np.asarray(time, dtype=np.float64).ravel()
|
|
15
|
+
event = np.asarray(event, dtype=np.int64).ravel()
|
|
16
|
+
if time.shape[0] != event.shape[0]:
|
|
17
|
+
raise ValueError("time and event must have the same length")
|
|
18
|
+
if np.any(time <= 0):
|
|
19
|
+
raise ValueError("survival times must be strictly positive")
|
|
20
|
+
return time, event
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def kaplan_meier(
|
|
24
|
+
time: np.ndarray,
|
|
25
|
+
event: np.ndarray,
|
|
26
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
27
|
+
"""Return (unique_times, survival_probabilities) for right-censored data."""
|
|
28
|
+
time, event = _prepare_survival(time, event)
|
|
29
|
+
order = np.argsort(time, kind="mergesort")
|
|
30
|
+
time = time[order]
|
|
31
|
+
event = event[order]
|
|
32
|
+
|
|
33
|
+
unique_times: list[float] = []
|
|
34
|
+
survival: list[float] = []
|
|
35
|
+
s = 1.0
|
|
36
|
+
n_at_risk = len(time)
|
|
37
|
+
i = 0
|
|
38
|
+
while i < len(time):
|
|
39
|
+
t = time[i]
|
|
40
|
+
d = 0
|
|
41
|
+
c = 0
|
|
42
|
+
while i < len(time) and time[i] == t:
|
|
43
|
+
if event[i] == 1:
|
|
44
|
+
d += 1
|
|
45
|
+
else:
|
|
46
|
+
c += 1
|
|
47
|
+
i += 1
|
|
48
|
+
if d > 0:
|
|
49
|
+
s *= 1.0 - d / max(n_at_risk, 1)
|
|
50
|
+
unique_times.append(float(t))
|
|
51
|
+
survival.append(float(s))
|
|
52
|
+
n_at_risk -= d + c
|
|
53
|
+
|
|
54
|
+
if not unique_times:
|
|
55
|
+
return np.array([0.0]), np.array([1.0])
|
|
56
|
+
|
|
57
|
+
return np.asarray(unique_times), np.asarray(survival)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def km_survival_at(
|
|
61
|
+
query: np.ndarray,
|
|
62
|
+
km_times: np.ndarray,
|
|
63
|
+
km_surv: np.ndarray,
|
|
64
|
+
) -> np.ndarray:
|
|
65
|
+
"""Step-function survival S(t) from a KM curve."""
|
|
66
|
+
query = np.asarray(query, dtype=np.float64)
|
|
67
|
+
out = np.ones_like(query, dtype=np.float64)
|
|
68
|
+
for i, q in enumerate(query):
|
|
69
|
+
idx = np.searchsorted(km_times, q, side="right") - 1
|
|
70
|
+
if idx < 0:
|
|
71
|
+
out[i] = 1.0
|
|
72
|
+
else:
|
|
73
|
+
out[i] = km_surv[idx]
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def censoring_survival(
|
|
78
|
+
time: np.ndarray,
|
|
79
|
+
event: np.ndarray,
|
|
80
|
+
*,
|
|
81
|
+
groups: np.ndarray | None = None,
|
|
82
|
+
mode: Literal["marginal", "covariate"] = "marginal",
|
|
83
|
+
) -> np.ndarray:
|
|
84
|
+
"""
|
|
85
|
+
Estimate P(C > t) for inverse probability of censoring weights.
|
|
86
|
+
|
|
87
|
+
For observed failures, G(t) = P(C > t | Z) when mode='covariate'.
|
|
88
|
+
"""
|
|
89
|
+
time, event = _prepare_survival(time, event)
|
|
90
|
+
censor_event = 1 - event # 1 if censored
|
|
91
|
+
|
|
92
|
+
if mode == "marginal" or groups is None:
|
|
93
|
+
km_t, km_s = kaplan_meier(time, censor_event)
|
|
94
|
+
return km_survival_at(time, km_t, km_s)
|
|
95
|
+
|
|
96
|
+
groups = np.asarray(groups)
|
|
97
|
+
ghat = np.empty_like(time, dtype=np.float64)
|
|
98
|
+
for g in np.unique(groups):
|
|
99
|
+
mask = groups == g
|
|
100
|
+
km_t, km_s = kaplan_meier(time[mask], censor_event[mask])
|
|
101
|
+
ghat[mask] = km_survival_at(time[mask], km_t, km_s)
|
|
102
|
+
return ghat
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def buckley_james_impute(
|
|
106
|
+
time: np.ndarray,
|
|
107
|
+
event: np.ndarray,
|
|
108
|
+
mu: np.ndarray | float = 0.0,
|
|
109
|
+
*,
|
|
110
|
+
groups: np.ndarray | None = None,
|
|
111
|
+
censoring_mode: Literal["marginal", "covariate"] = "marginal",
|
|
112
|
+
) -> np.ndarray:
|
|
113
|
+
"""
|
|
114
|
+
Buckley-James imputation for censored survival times (Norman et al. Eq. 2-3).
|
|
115
|
+
|
|
116
|
+
Residuals v = t / exp(mu) are imputed when delta=0.
|
|
117
|
+
"""
|
|
118
|
+
time = np.asarray(time, dtype=np.float64).ravel()
|
|
119
|
+
event = np.asarray(event, dtype=np.int64).ravel()
|
|
120
|
+
mu = np.asarray(mu, dtype=np.float64).ravel()
|
|
121
|
+
if mu.size == 1:
|
|
122
|
+
mu = np.full_like(time, float(mu[0]))
|
|
123
|
+
if mu.shape[0] != time.shape[0]:
|
|
124
|
+
raise ValueError("mu must be scalar or same length as time")
|
|
125
|
+
|
|
126
|
+
residual = time / np.exp(mu)
|
|
127
|
+
imputed = time.copy()
|
|
128
|
+
|
|
129
|
+
if groups is not None and censoring_mode == "covariate":
|
|
130
|
+
for g in np.unique(groups):
|
|
131
|
+
mask = groups == g
|
|
132
|
+
imputed[mask] = _bj_impute_group(residual[mask], event[mask])
|
|
133
|
+
else:
|
|
134
|
+
imputed = _bj_impute_group(residual, event)
|
|
135
|
+
|
|
136
|
+
return imputed * np.exp(mu)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _bj_impute_group(residual: np.ndarray, event: np.ndarray) -> np.ndarray:
|
|
140
|
+
"""
|
|
141
|
+
Buckley-James imputation: E(V | V > v_i) on scaled residuals.
|
|
142
|
+
|
|
143
|
+
Uses the KM integral form equivalent to Norman et al. Eq. (2):
|
|
144
|
+
v*_i = v_i + (1/S(v_i)) * integral_{v_i}^{infty} S(u) du
|
|
145
|
+
"""
|
|
146
|
+
km_t, km_s = kaplan_meier(residual, event)
|
|
147
|
+
if len(km_t) == 0:
|
|
148
|
+
return residual.copy()
|
|
149
|
+
|
|
150
|
+
imputed = residual.copy()
|
|
151
|
+
for i in np.where(event == 0)[0]:
|
|
152
|
+
ri = float(residual[i])
|
|
153
|
+
s_ri = float(km_survival_at(np.array([ri]), km_t, km_s)[0])
|
|
154
|
+
if s_ri <= 1e-10:
|
|
155
|
+
continue
|
|
156
|
+
integral = _km_survival_integral(ri, km_t, km_s)
|
|
157
|
+
imputed[i] = ri + integral / s_ri
|
|
158
|
+
return imputed
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _km_survival_integral(
|
|
162
|
+
start: float,
|
|
163
|
+
km_t: np.ndarray,
|
|
164
|
+
km_s: np.ndarray,
|
|
165
|
+
) -> float:
|
|
166
|
+
"""Integral from start to infinity of the KM survival step function."""
|
|
167
|
+
s_at = float(km_survival_at(np.array([start]), km_t, km_s)[0])
|
|
168
|
+
lo = start
|
|
169
|
+
integral = 0.0
|
|
170
|
+
for t, s in zip(km_t, km_s):
|
|
171
|
+
if t <= start:
|
|
172
|
+
continue
|
|
173
|
+
integral += (t - lo) * s_at
|
|
174
|
+
lo = t
|
|
175
|
+
s_at = s
|
|
176
|
+
if len(km_t):
|
|
177
|
+
tail = max(km_t[-1], lo)
|
|
178
|
+
integral += (tail - lo) * s_at
|
|
179
|
+
return integral
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def fan_gijbels_transform(
|
|
183
|
+
time: np.ndarray,
|
|
184
|
+
event: np.ndarray,
|
|
185
|
+
ghat: np.ndarray | None = None,
|
|
186
|
+
) -> tuple[np.ndarray, float]:
|
|
187
|
+
"""
|
|
188
|
+
Fan-Gijbels unbiased transformation (Norman et al. Section 2.2).
|
|
189
|
+
|
|
190
|
+
Returns transformed times and chosen alpha.
|
|
191
|
+
"""
|
|
192
|
+
time, event = _prepare_survival(time, event)
|
|
193
|
+
if ghat is None:
|
|
194
|
+
ghat = censoring_survival(time, event, mode="marginal")
|
|
195
|
+
ghat = np.clip(ghat, 1e-8, 1.0)
|
|
196
|
+
|
|
197
|
+
transformed = np.empty_like(time)
|
|
198
|
+
alpha_candidates: list[float] = []
|
|
199
|
+
|
|
200
|
+
for i in range(len(time)):
|
|
201
|
+
ti = time[i]
|
|
202
|
+
integral = _integral_g_inverse(time, ghat, ti)
|
|
203
|
+
if event[i] == 1:
|
|
204
|
+
alpha_i = (integral - ti) / (ti / ghat[i]) - integral
|
|
205
|
+
alpha_candidates.append(alpha_i)
|
|
206
|
+
if event[i] == 1:
|
|
207
|
+
alpha = alpha_candidates[-1] if alpha_candidates else 0.0
|
|
208
|
+
else:
|
|
209
|
+
alpha = min(alpha_candidates) if alpha_candidates else 0.0
|
|
210
|
+
phi1 = (1 + alpha) * integral - alpha * ti / ghat[i]
|
|
211
|
+
phi2 = (1 + alpha) * integral
|
|
212
|
+
transformed[i] = event[i] * phi1 + (1 - event[i]) * phi2
|
|
213
|
+
|
|
214
|
+
alpha_hat = float(min(alpha_candidates)) if alpha_candidates else 0.0
|
|
215
|
+
return transformed, alpha_hat
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _integral_g_inverse(
|
|
219
|
+
time: np.ndarray,
|
|
220
|
+
ghat: np.ndarray,
|
|
221
|
+
t: float,
|
|
222
|
+
) -> float:
|
|
223
|
+
"""Integral from 0 to t of 1/G(s) ds using step-function G."""
|
|
224
|
+
order = np.argsort(time)
|
|
225
|
+
sorted_t = time[order]
|
|
226
|
+
sorted_g = ghat[order]
|
|
227
|
+
unique_t = np.unique(sorted_t[sorted_t <= t])
|
|
228
|
+
if len(unique_t) == 0:
|
|
229
|
+
return 0.0
|
|
230
|
+
total = 0.0
|
|
231
|
+
prev = 0.0
|
|
232
|
+
for ut in unique_t:
|
|
233
|
+
g = float(sorted_g[sorted_t == ut][0])
|
|
234
|
+
total += (ut - prev) / max(g, 1e-8)
|
|
235
|
+
prev = ut
|
|
236
|
+
if t > prev:
|
|
237
|
+
g_last = float(sorted_g[sorted_t <= t][-1]) if np.any(sorted_t <= t) else 1.0
|
|
238
|
+
total += (t - prev) / max(g_last, 1e-8)
|
|
239
|
+
return total
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def check_ghat_degeneracy(
|
|
243
|
+
time: np.ndarray,
|
|
244
|
+
event: np.ndarray,
|
|
245
|
+
*,
|
|
246
|
+
min_unique_censor_times: int = 3,
|
|
247
|
+
min_ghat: float = 1e-3,
|
|
248
|
+
) -> tuple[bool, str]:
|
|
249
|
+
"""
|
|
250
|
+
Return (is_valid, reason) for IPCW / Transform methods.
|
|
251
|
+
|
|
252
|
+
Invalid when censoring is administrative (too few unique censor times)
|
|
253
|
+
or G hits zero too early.
|
|
254
|
+
"""
|
|
255
|
+
time, event = _prepare_survival(time, event)
|
|
256
|
+
censor_times = time[event == 0]
|
|
257
|
+
if len(censor_times) == 0:
|
|
258
|
+
return False, "no censored observations"
|
|
259
|
+
if len(np.unique(censor_times)) < min_unique_censor_times:
|
|
260
|
+
return False, "degenerate administrative censoring (too few unique censor times)"
|
|
261
|
+
ghat = censoring_survival(time, event, mode="marginal")
|
|
262
|
+
if np.any(ghat[event == 1] < min_ghat):
|
|
263
|
+
return False, "G(t) too small for some failure times"
|
|
264
|
+
return True, "ok"
|
deepaft/diagnostics.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Diagnostics and automatic method selection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from deepaft.censoring import check_ghat_degeneracy
|
|
10
|
+
from deepaft.methods import MethodName
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def select_method(
|
|
14
|
+
time: np.ndarray,
|
|
15
|
+
event: np.ndarray,
|
|
16
|
+
requested: MethodName,
|
|
17
|
+
*,
|
|
18
|
+
auto_fallback: bool = True,
|
|
19
|
+
) -> tuple[MethodName, str | None]:
|
|
20
|
+
"""
|
|
21
|
+
Apply degeneracy guard: IPCW/Transform fall back to buckley_james when invalid.
|
|
22
|
+
"""
|
|
23
|
+
if requested == "buckley_james":
|
|
24
|
+
return requested, None
|
|
25
|
+
valid, reason = check_ghat_degeneracy(time, event)
|
|
26
|
+
if valid:
|
|
27
|
+
return requested, None
|
|
28
|
+
if auto_fallback:
|
|
29
|
+
return "buckley_james", reason
|
|
30
|
+
raise ValueError(f"method '{requested}' invalid: {reason}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def proportional_hazards_test(
|
|
34
|
+
time: np.ndarray,
|
|
35
|
+
event: np.ndarray,
|
|
36
|
+
X: np.ndarray,
|
|
37
|
+
) -> tuple[float, float]:
|
|
38
|
+
"""
|
|
39
|
+
Global Grambsch-Therneau test via lifelines (optional dependency).
|
|
40
|
+
|
|
41
|
+
Returns (chi2, p_value). Raises ImportError if lifelines is missing.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
from lifelines.statistics import proportional_hazard_test
|
|
45
|
+
except ImportError as exc:
|
|
46
|
+
raise ImportError(
|
|
47
|
+
"Install lifelines for PH testing: pip install deepaft[metrics]"
|
|
48
|
+
) from exc
|
|
49
|
+
|
|
50
|
+
import pandas as pd
|
|
51
|
+
|
|
52
|
+
df = pd.DataFrame(X, columns=[f"x{i}" for i in range(X.shape[1])])
|
|
53
|
+
df["time"] = time
|
|
54
|
+
df["event"] = event
|
|
55
|
+
result = proportional_hazard_test(
|
|
56
|
+
df,
|
|
57
|
+
time_col="time",
|
|
58
|
+
event_col="event",
|
|
59
|
+
)
|
|
60
|
+
return float(result.test_statistic), float(result.p_value)
|