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 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)