pycrr 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.
- pycrr/__init__.py +19 -0
- pycrr/compare.py +176 -0
- pycrr/core.py +78 -0
- pycrr/estimator.py +255 -0
- pycrr/metrics.py +183 -0
- pycrr/model.py +995 -0
- pycrr/plot.py +186 -0
- pycrr/surv.py +91 -0
- pycrr-0.1.0.dist-info/METADATA +297 -0
- pycrr-0.1.0.dist-info/RECORD +13 -0
- pycrr-0.1.0.dist-info/WHEEL +5 -0
- pycrr-0.1.0.dist-info/licenses/LICENSE +21 -0
- pycrr-0.1.0.dist-info/top_level.txt +1 -0
pycrr/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pycrr.surv import Surv
|
|
2
|
+
from pycrr.model import FineGrayModel
|
|
3
|
+
from pycrr.core import estimate_ipcw_weights
|
|
4
|
+
from pycrr.estimator import AalenJohansen
|
|
5
|
+
from pycrr.compare import gray_test, GrayTestResult
|
|
6
|
+
from pycrr.metrics import brier_score, integrated_brier_score, concordance
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Surv",
|
|
10
|
+
"FineGrayModel",
|
|
11
|
+
"estimate_ipcw_weights",
|
|
12
|
+
"AalenJohansen",
|
|
13
|
+
"gray_test",
|
|
14
|
+
"GrayTestResult",
|
|
15
|
+
"brier_score",
|
|
16
|
+
"integrated_brier_score",
|
|
17
|
+
"concordance",
|
|
18
|
+
]
|
|
19
|
+
__version__ = "0.1.0"
|
pycrr/compare.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Gray's K-sample test for comparing cumulative incidence functions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy import stats
|
|
7
|
+
from pycrr.surv import Surv
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GrayTestResult:
|
|
11
|
+
"""Result of Gray's test."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, statistic, pvalue, df, group_stats, groups):
|
|
14
|
+
self.statistic = statistic
|
|
15
|
+
self.pvalue = pvalue
|
|
16
|
+
self.df = df
|
|
17
|
+
self.group_stats = group_stats # {group_label: U_g}
|
|
18
|
+
self.groups = groups
|
|
19
|
+
|
|
20
|
+
def __repr__(self):
|
|
21
|
+
return (
|
|
22
|
+
f"GrayTestResult(statistic={self.statistic:.4f}, "
|
|
23
|
+
f"pvalue={self.pvalue:.4f}, df={self.df})"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def summary(self):
|
|
27
|
+
print(f"Gray's test — chi-squared = {self.statistic:.4f}, "
|
|
28
|
+
f"df = {self.df}, p = {self.pvalue:.4f}")
|
|
29
|
+
print(f"{'Group':>12} {'U statistic':>12}")
|
|
30
|
+
print("-" * 27)
|
|
31
|
+
for g, u in self.group_stats.items():
|
|
32
|
+
print(f"{str(g):>12} {u:12.4f}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def gray_test(time, event, group=None, cause=1, rho=0):
|
|
36
|
+
"""
|
|
37
|
+
Gray's K-sample test for equality of cumulative incidence functions.
|
|
38
|
+
|
|
39
|
+
Tests H0: F_k^(1)(t) = ... = F_k^(G)(t) for all t, where k is the
|
|
40
|
+
specified cause of interest and superscripts denote groups.
|
|
41
|
+
|
|
42
|
+
The test uses the subdistribution risk set (subjects who experienced
|
|
43
|
+
competing events remain at risk) and optionally applies CIF-based
|
|
44
|
+
weighting following Gray (1988).
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
time : array-like of shape (n,)
|
|
49
|
+
Observed times.
|
|
50
|
+
event : array-like of shape (n,)
|
|
51
|
+
Event indicators: 0 = censored, 1 = cause of interest, 2+ = competing.
|
|
52
|
+
group : array-like of shape (n,)
|
|
53
|
+
Group labels. Can be any comparable values (int, str, etc.).
|
|
54
|
+
cause : int
|
|
55
|
+
Which cause to test. Default 1.
|
|
56
|
+
rho : float
|
|
57
|
+
Weight exponent for Fleming-Harrington-type weighting.
|
|
58
|
+
``rho=0`` (default) gives equal weights (log-rank type).
|
|
59
|
+
``rho=1`` downweights late differences.
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
GrayTestResult
|
|
64
|
+
Attributes: ``statistic``, ``pvalue``, ``df``, ``group_stats``.
|
|
65
|
+
|
|
66
|
+
References
|
|
67
|
+
----------
|
|
68
|
+
Gray, R. J. (1988). A class of K-sample tests for comparing the
|
|
69
|
+
cumulative incidence of a competing risk. Annals of Statistics,
|
|
70
|
+
16(3), 1141-1154.
|
|
71
|
+
"""
|
|
72
|
+
if isinstance(time, Surv):
|
|
73
|
+
# gray_test(Surv(time, event), group, ...)
|
|
74
|
+
group = event
|
|
75
|
+
time, event = time
|
|
76
|
+
elif group is None:
|
|
77
|
+
raise ValueError("group is required when time is not a Surv object.")
|
|
78
|
+
time = np.asarray(time, dtype=float)
|
|
79
|
+
event = np.asarray(event, dtype=int)
|
|
80
|
+
group = np.asarray(group)
|
|
81
|
+
n = len(time)
|
|
82
|
+
|
|
83
|
+
groups = np.sort(np.unique(group))
|
|
84
|
+
G = len(groups)
|
|
85
|
+
|
|
86
|
+
if G < 2:
|
|
87
|
+
raise ValueError("gray_test requires at least 2 groups.")
|
|
88
|
+
|
|
89
|
+
# Unique times where cause-k events occur (pooled across groups)
|
|
90
|
+
cause_mask = event == cause
|
|
91
|
+
cause_times = np.sort(np.unique(time[cause_mask]))
|
|
92
|
+
|
|
93
|
+
if len(cause_times) == 0:
|
|
94
|
+
raise ValueError(f"No events of cause {cause} found in the data.")
|
|
95
|
+
|
|
96
|
+
# Pooled CIF for weight function (needed when rho > 0)
|
|
97
|
+
pooled_cif_at_t = np.zeros(len(cause_times))
|
|
98
|
+
if rho > 0:
|
|
99
|
+
from pycrr.estimator import AalenJohansen
|
|
100
|
+
aj = AalenJohansen().fit(time, event)
|
|
101
|
+
if cause in aj.causes_:
|
|
102
|
+
_, cif_vals = aj.predict(cause, times=cause_times)
|
|
103
|
+
# We need CIF(t^-): use value at the step just before t
|
|
104
|
+
# Step interp already gives left-continuous values at t via "previous"
|
|
105
|
+
# Shift to get F(t^-): use interp at t - epsilon
|
|
106
|
+
eps = np.finfo(float).eps * cause_times
|
|
107
|
+
_, cif_before = aj.predict(cause, times=np.maximum(cause_times - 1e-8, 0))
|
|
108
|
+
pooled_cif_at_t = cif_before
|
|
109
|
+
|
|
110
|
+
# U statistics for each group, covariance matrix
|
|
111
|
+
U = np.zeros(G)
|
|
112
|
+
Sigma = np.zeros((G, G))
|
|
113
|
+
|
|
114
|
+
for j, t in enumerate(cause_times):
|
|
115
|
+
# Weight at time t
|
|
116
|
+
w = (max(1.0 - pooled_cif_at_t[j], 0.0)) ** rho if rho > 0 else 1.0
|
|
117
|
+
|
|
118
|
+
# Subdistribution risk set at t (pooled):
|
|
119
|
+
# subject i is at risk if T_i >= t OR (T_i < t AND cause_i is competing)
|
|
120
|
+
competing = (event != 0) & (event != cause)
|
|
121
|
+
in_risk = (time >= t) | ((time < t) & competing)
|
|
122
|
+
Y_total = int(in_risk.sum())
|
|
123
|
+
|
|
124
|
+
if Y_total == 0:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# Total cause-k events at t
|
|
128
|
+
dN_total = int(np.sum((time == t) & cause_mask))
|
|
129
|
+
|
|
130
|
+
# Per-group at-risk counts and event counts
|
|
131
|
+
Y_g = np.array([int((in_risk & (group == g)).sum()) for g in groups])
|
|
132
|
+
dN_g = np.array([int(np.sum((time == t) & cause_mask & (group == g)))
|
|
133
|
+
for g in groups])
|
|
134
|
+
|
|
135
|
+
expected = Y_g / Y_total * dN_total
|
|
136
|
+
U += w * (dN_g - expected)
|
|
137
|
+
|
|
138
|
+
# Covariance update
|
|
139
|
+
for gi in range(G):
|
|
140
|
+
for hi in range(G):
|
|
141
|
+
if gi == hi:
|
|
142
|
+
Sigma[gi, gi] += (
|
|
143
|
+
w**2
|
|
144
|
+
* Y_g[gi] * (Y_total - Y_g[gi])
|
|
145
|
+
/ Y_total**2
|
|
146
|
+
* dN_total
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
Sigma[gi, hi] -= (
|
|
150
|
+
w**2
|
|
151
|
+
* Y_g[gi] * Y_g[hi]
|
|
152
|
+
/ Y_total**2
|
|
153
|
+
* dN_total
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Drop last group (constraint: sum U_g = 0)
|
|
157
|
+
U_trunc = U[:G - 1]
|
|
158
|
+
Sig_trunc = Sigma[:G - 1, :G - 1]
|
|
159
|
+
|
|
160
|
+
# Chi-squared statistic
|
|
161
|
+
try:
|
|
162
|
+
Sig_inv = np.linalg.inv(Sig_trunc)
|
|
163
|
+
stat = float(U_trunc @ Sig_inv @ U_trunc)
|
|
164
|
+
stat = max(stat, 0.0) # numerical guard
|
|
165
|
+
except np.linalg.LinAlgError:
|
|
166
|
+
stat = np.nan
|
|
167
|
+
|
|
168
|
+
pvalue = float(1.0 - stats.chi2.cdf(stat, df=G - 1)) if np.isfinite(stat) else np.nan
|
|
169
|
+
|
|
170
|
+
return GrayTestResult(
|
|
171
|
+
statistic=stat,
|
|
172
|
+
pvalue=pvalue,
|
|
173
|
+
df=G - 1,
|
|
174
|
+
group_stats={g: float(U[i]) for i, g in enumerate(groups)},
|
|
175
|
+
groups=groups.tolist(),
|
|
176
|
+
)
|
pycrr/core.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from lifelines import KaplanMeierFitter
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _censoring_survival(time, event, clip=1e-10):
|
|
6
|
+
"""
|
|
7
|
+
KM estimate of the censoring survival function G(t) = P(C > t).
|
|
8
|
+
|
|
9
|
+
Returns a callable G_func(t) -> float that evaluates the left-continuous
|
|
10
|
+
step function at any time point. Used internally by FineGrayModel for
|
|
11
|
+
time-varying IPCW weights.
|
|
12
|
+
"""
|
|
13
|
+
time = np.asarray(time, dtype=float)
|
|
14
|
+
event = np.asarray(event)
|
|
15
|
+
km = KaplanMeierFitter()
|
|
16
|
+
km.fit(durations=time, event_observed=(event == 0).astype(int))
|
|
17
|
+
|
|
18
|
+
_times = km.event_table.index.values
|
|
19
|
+
_surv = km.survival_function_["KM_estimate"].values
|
|
20
|
+
|
|
21
|
+
def G_func(t):
|
|
22
|
+
idx = int(np.clip(
|
|
23
|
+
np.searchsorted(_times, np.asarray(t) - 1e-8, side="right") - 1,
|
|
24
|
+
0, len(_surv) - 1,
|
|
25
|
+
))
|
|
26
|
+
return float(max(_surv[idx], clip))
|
|
27
|
+
|
|
28
|
+
return G_func
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def estimate_ipcw_weights(time, event, clip=0.05):
|
|
32
|
+
"""
|
|
33
|
+
Estimate inverse probability of censoring weights (IPCW) via Kaplan-Meier.
|
|
34
|
+
|
|
35
|
+
Fits a KM estimator on the censoring distribution G(t) and returns
|
|
36
|
+
subject-level weights 1/G(t_i-), normalized to mean 1. Competing-event
|
|
37
|
+
subjects always receive weight 1.0 (they are not censored with respect to
|
|
38
|
+
the subdistribution).
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
time : array-like of shape (n,)
|
|
43
|
+
Observed times (time to event or censoring).
|
|
44
|
+
event : array-like of shape (n,)
|
|
45
|
+
Event indicators: 0 = censored, 1 = event of interest, 2 = competing event.
|
|
46
|
+
clip : float
|
|
47
|
+
Floor applied to G(t) before inversion to prevent extreme weights.
|
|
48
|
+
Default 0.05.
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
weights : ndarray of shape (n,)
|
|
53
|
+
Subject-level IPCW weights, normalized so that mean(weights) = 1.
|
|
54
|
+
ipcw_func : callable
|
|
55
|
+
A function ``ipcw_func(t) -> float`` that evaluates the weight at any
|
|
56
|
+
time point. Useful for time-varying weight schemes.
|
|
57
|
+
"""
|
|
58
|
+
time = np.asarray(time, dtype=float)
|
|
59
|
+
event = np.asarray(event)
|
|
60
|
+
|
|
61
|
+
censoring_indicator = (event == 0).astype(int)
|
|
62
|
+
km = KaplanMeierFitter()
|
|
63
|
+
km.fit(durations=time, event_observed=censoring_indicator)
|
|
64
|
+
|
|
65
|
+
event_times = km.event_table.index.values
|
|
66
|
+
surv_probs = km.survival_function_["KM_estimate"].values
|
|
67
|
+
|
|
68
|
+
def ipcw_func(t):
|
|
69
|
+
idx = np.searchsorted(event_times, np.asarray(t) - 1e-8, side="right") - 1
|
|
70
|
+
idx = np.clip(idx, 0, len(surv_probs) - 1)
|
|
71
|
+
G = surv_probs[idx]
|
|
72
|
+
return 1.0 / np.clip(G, clip, 1.0)
|
|
73
|
+
|
|
74
|
+
weights = ipcw_func(time)
|
|
75
|
+
weights[event == 2] = 1.0
|
|
76
|
+
weights = weights / np.mean(weights)
|
|
77
|
+
|
|
78
|
+
return weights, ipcw_func
|
pycrr/estimator.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Non-parametric Aalen-Johansen estimator of the cumulative incidence function.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy import stats
|
|
7
|
+
from scipy.interpolate import interp1d
|
|
8
|
+
from pycrr.surv import Surv
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AalenJohansen:
|
|
12
|
+
"""
|
|
13
|
+
Non-parametric Aalen-Johansen estimator for competing risks.
|
|
14
|
+
|
|
15
|
+
Estimates F_k(t) = P(T <= t, cause = k) without any covariate adjustment,
|
|
16
|
+
the correct non-parametric analog of 1 - KM when competing events are present.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
confidence_level : float
|
|
21
|
+
Confidence level for pointwise CI. Default 0.95.
|
|
22
|
+
|
|
23
|
+
Examples
|
|
24
|
+
--------
|
|
25
|
+
>>> from pycrr.estimator import AalenJohansen
|
|
26
|
+
>>> aj = AalenJohansen().fit(time, event)
|
|
27
|
+
>>> aj.summary(cause=1)
|
|
28
|
+
>>> t, cif = aj.predict(cause=1)
|
|
29
|
+
>>> lo, hi = aj.confidence_intervals(cause=1)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, confidence_level=0.95):
|
|
33
|
+
self.confidence_level = confidence_level
|
|
34
|
+
self.causes_ = None
|
|
35
|
+
self.n_ = None
|
|
36
|
+
self._tables = {} # cause -> dict(times, cif, var)
|
|
37
|
+
|
|
38
|
+
# ------------------------------------------------------------------
|
|
39
|
+
# Fit
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def fit(self, time, event=None):
|
|
43
|
+
"""
|
|
44
|
+
Fit the Aalen-Johansen estimator.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
time : array-like of shape (n,)
|
|
49
|
+
Observed times.
|
|
50
|
+
event : array-like of shape (n,)
|
|
51
|
+
Event indicators: 0 = censored, 1, 2, ... = cause codes.
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
self
|
|
56
|
+
"""
|
|
57
|
+
if isinstance(time, Surv):
|
|
58
|
+
if event is not None:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"Pass either a Surv object or separate (time, event), not both."
|
|
61
|
+
)
|
|
62
|
+
time, event = time
|
|
63
|
+
elif event is None:
|
|
64
|
+
raise ValueError("event is required when time is not a Surv object.")
|
|
65
|
+
|
|
66
|
+
time = np.asarray(time, dtype=float)
|
|
67
|
+
event = np.asarray(event, dtype=int)
|
|
68
|
+
self.n_ = len(time)
|
|
69
|
+
self.causes_ = sorted(int(c) for c in np.unique(event) if c != 0)
|
|
70
|
+
|
|
71
|
+
if not self.causes_:
|
|
72
|
+
raise ValueError("No events observed (all event codes are 0).")
|
|
73
|
+
|
|
74
|
+
# Unique times where any event (any cause) occurs
|
|
75
|
+
event_times = np.sort(np.unique(time[event != 0]))
|
|
76
|
+
|
|
77
|
+
# Walk through event times, tracking overall KM survival S(t^-)
|
|
78
|
+
S = 1.0 # overall KM, updated after each step
|
|
79
|
+
S_before = [] # S(t^-) at each event time
|
|
80
|
+
Y_vec = [] # at-risk count at each event time
|
|
81
|
+
dNk_vecs = {k: [] for k in self.causes_}
|
|
82
|
+
|
|
83
|
+
for t in event_times:
|
|
84
|
+
Y = int(np.sum(time >= t)) # at risk just before t
|
|
85
|
+
dN = int(np.sum((time == t) & (event != 0))) # all-cause events at t
|
|
86
|
+
|
|
87
|
+
S_before.append(S)
|
|
88
|
+
Y_vec.append(Y)
|
|
89
|
+
|
|
90
|
+
for k in self.causes_:
|
|
91
|
+
dNk_vecs[k].append(int(np.sum((time == t) & (event == k))))
|
|
92
|
+
|
|
93
|
+
if Y > 0:
|
|
94
|
+
S = S * (1.0 - dN / Y)
|
|
95
|
+
|
|
96
|
+
S_before = np.array(S_before, dtype=float)
|
|
97
|
+
Y_arr = np.array(Y_vec, dtype=float)
|
|
98
|
+
|
|
99
|
+
# Build per-cause CIF and variance
|
|
100
|
+
for k in self.causes_:
|
|
101
|
+
dNk = np.array(dNk_vecs[k], dtype=float)
|
|
102
|
+
|
|
103
|
+
# CIF increment: dF_k(t) = S(t^-) * dN_k(t) / Y(t)
|
|
104
|
+
safe_Y = np.where(Y_arr > 0, Y_arr, np.inf)
|
|
105
|
+
dF = S_before * dNk / safe_Y
|
|
106
|
+
|
|
107
|
+
times_full = np.concatenate([[0.0], event_times])
|
|
108
|
+
cif_full = np.concatenate([[0.0], np.cumsum(dF)])
|
|
109
|
+
|
|
110
|
+
# Variance (Greenwood-style approximation):
|
|
111
|
+
# Var[F_k(t)] ≈ sum_{s<=t} S(s^-)^2 * dN_k(s) / Y(s)^2
|
|
112
|
+
var_inc = np.where(Y_arr > 0, S_before**2 * dNk / Y_arr**2, 0.0)
|
|
113
|
+
var_full = np.concatenate([[0.0], np.cumsum(var_inc)])
|
|
114
|
+
|
|
115
|
+
self._tables[k] = dict(times=times_full, cif=cif_full, var=var_full)
|
|
116
|
+
|
|
117
|
+
return self
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Predict
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def predict(self, cause, times=None):
|
|
124
|
+
"""
|
|
125
|
+
Return CIF values for a given cause.
|
|
126
|
+
|
|
127
|
+
Parameters
|
|
128
|
+
----------
|
|
129
|
+
cause : int
|
|
130
|
+
times : array-like or None
|
|
131
|
+
Evaluation time points. If None, returns values at all observed
|
|
132
|
+
event times (plus t=0).
|
|
133
|
+
|
|
134
|
+
Returns
|
|
135
|
+
-------
|
|
136
|
+
times : ndarray
|
|
137
|
+
cif : ndarray
|
|
138
|
+
"""
|
|
139
|
+
self._check_fitted(cause)
|
|
140
|
+
tbl = self._tables[cause]
|
|
141
|
+
|
|
142
|
+
if times is None:
|
|
143
|
+
return tbl["times"].copy(), tbl["cif"].copy()
|
|
144
|
+
|
|
145
|
+
times = np.asarray(times, dtype=float)
|
|
146
|
+
f = self._step_interp(tbl["times"], tbl["cif"])
|
|
147
|
+
return times, f(times)
|
|
148
|
+
|
|
149
|
+
def confidence_intervals(self, cause, times=None):
|
|
150
|
+
"""
|
|
151
|
+
Pointwise confidence intervals via the log-log transform.
|
|
152
|
+
|
|
153
|
+
Parameters
|
|
154
|
+
----------
|
|
155
|
+
cause : int
|
|
156
|
+
times : array-like or None
|
|
157
|
+
|
|
158
|
+
Returns
|
|
159
|
+
-------
|
|
160
|
+
lower, upper : ndarray (same length as times or full time grid)
|
|
161
|
+
"""
|
|
162
|
+
self._check_fitted(cause)
|
|
163
|
+
tbl = self._tables[cause]
|
|
164
|
+
|
|
165
|
+
if times is not None:
|
|
166
|
+
times = np.asarray(times, dtype=float)
|
|
167
|
+
cif = self._step_interp(tbl["times"], tbl["cif"])(times)
|
|
168
|
+
var = self._step_interp(tbl["times"], tbl["var"])(times)
|
|
169
|
+
else:
|
|
170
|
+
times = tbl["times"]
|
|
171
|
+
cif = tbl["cif"]
|
|
172
|
+
var = tbl["var"]
|
|
173
|
+
|
|
174
|
+
z = stats.norm.ppf(1.0 - (1.0 - self.confidence_level) / 2.0)
|
|
175
|
+
se = np.sqrt(np.clip(var, 0.0, np.inf))
|
|
176
|
+
|
|
177
|
+
# log-log transform: theta = log(-log F_k)
|
|
178
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
179
|
+
log_cif = np.where(cif > 0, np.log(cif), -700.0)
|
|
180
|
+
theta = np.where(cif > 0, np.log(-log_cif), np.inf)
|
|
181
|
+
se_ll = np.where(
|
|
182
|
+
(cif > 0) & (np.abs(log_cif) > 1e-10),
|
|
183
|
+
se / (cif * np.abs(log_cif)),
|
|
184
|
+
np.inf,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
lower = np.exp(-np.exp(theta + z * se_ll))
|
|
188
|
+
upper = np.exp(-np.exp(theta - z * se_ll))
|
|
189
|
+
|
|
190
|
+
return np.clip(lower, 0.0, 1.0), np.clip(upper, 0.0, 1.0)
|
|
191
|
+
|
|
192
|
+
# ------------------------------------------------------------------
|
|
193
|
+
# Summary
|
|
194
|
+
# ------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def summary(self, cause, times=None, max_rows=20):
|
|
197
|
+
"""
|
|
198
|
+
Print a table of CIF estimates with confidence intervals.
|
|
199
|
+
|
|
200
|
+
Parameters
|
|
201
|
+
----------
|
|
202
|
+
cause : int
|
|
203
|
+
times : array-like or None
|
|
204
|
+
Specific evaluation time points. If None, evenly spaced rows
|
|
205
|
+
from the observed event time grid are shown.
|
|
206
|
+
max_rows : int
|
|
207
|
+
Maximum table rows when times is None.
|
|
208
|
+
"""
|
|
209
|
+
self._check_fitted(cause)
|
|
210
|
+
tbl = self._tables[cause]
|
|
211
|
+
|
|
212
|
+
if times is None:
|
|
213
|
+
full_t = tbl["times"]
|
|
214
|
+
idx = np.unique(
|
|
215
|
+
np.linspace(0, len(full_t) - 1, min(len(full_t), max_rows), dtype=int)
|
|
216
|
+
)
|
|
217
|
+
t_out = full_t[idx]
|
|
218
|
+
else:
|
|
219
|
+
t_out = np.asarray(times, dtype=float)
|
|
220
|
+
|
|
221
|
+
_, cif_out = self.predict(cause, times=t_out)
|
|
222
|
+
lo, hi = self.confidence_intervals(cause, times=t_out)
|
|
223
|
+
|
|
224
|
+
n_cause = int(np.sum(
|
|
225
|
+
[1 for v in self._tables.values()
|
|
226
|
+
for _ in [None]] )) # just a placeholder, see below
|
|
227
|
+
|
|
228
|
+
print(f"Aalen-Johansen CIF — cause {cause} (n={self.n_})")
|
|
229
|
+
print(f"{'Time':>10} {'CIF':>8} {'Lower':>8} {'Upper':>8}")
|
|
230
|
+
print("-" * 44)
|
|
231
|
+
for t, c, l, u in zip(t_out, cif_out, lo, hi):
|
|
232
|
+
if t == 0.0:
|
|
233
|
+
continue
|
|
234
|
+
print(f"{t:10.4f} {c:8.4f} {l:8.4f} {u:8.4f}")
|
|
235
|
+
|
|
236
|
+
# ------------------------------------------------------------------
|
|
237
|
+
# Helpers
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def _step_interp(x, y):
|
|
242
|
+
return interp1d(
|
|
243
|
+
x, y,
|
|
244
|
+
kind="previous",
|
|
245
|
+
bounds_error=False,
|
|
246
|
+
fill_value=(y[0], y[-1]),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def _check_fitted(self, cause):
|
|
250
|
+
if not self._tables:
|
|
251
|
+
raise RuntimeError("Model not fitted. Call fit() first.")
|
|
252
|
+
if cause not in self._tables:
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"Cause {cause} not in fitted causes {self.causes_}."
|
|
255
|
+
)
|