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