kernelmix 0.3.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.
- kernelmix/__init__.py +36 -0
- kernelmix/_basis.py +299 -0
- kernelmix/_clusters.py +78 -0
- kernelmix/_generalized.py +488 -0
- kernelmix/_input.py +120 -0
- kernelmix/_kernel.py +132 -0
- kernelmix/_likelihoods.py +151 -0
- kernelmix/_prediction.py +43 -0
- kernelmix/_structure.py +326 -0
- kernelmix/_structured.py +886 -0
- kernelmix/_survival.py +358 -0
- kernelmix/diagnostics.py +77 -0
- kernelmix/effects.py +332 -0
- kernelmix/model.py +677 -0
- kernelmix/plots.py +205 -0
- kernelmix/priors.py +100 -0
- kernelmix/result.py +785 -0
- kernelmix/structured_result.py +1054 -0
- kernelmix-0.3.0.dist-info/METADATA +102 -0
- kernelmix-0.3.0.dist-info/RECORD +24 -0
- kernelmix-0.3.0.dist-info/WHEEL +5 -0
- kernelmix-0.3.0.dist-info/licenses/LICENSE +339 -0
- kernelmix-0.3.0.dist-info/licenses/NOTICE +23 -0
- kernelmix-0.3.0.dist-info/top_level.txt +1 -0
kernelmix/_survival.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Piecewise cause-specific survival likelihoods and conditional curves.
|
|
2
|
+
|
|
3
|
+
Likelihoods condition on survival to entry given the supplied latent predictors.
|
|
4
|
+
The caller defines predictor/random-effect priors for the observed entrant
|
|
5
|
+
cohort. These functions do not infer a population frailty distribution under
|
|
6
|
+
marginal left truncation. Predictor histories are supplied piecewise constants;
|
|
7
|
+
no interpolation or future predictor values are invented.
|
|
8
|
+
"""
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from scipy.special import logsumexp
|
|
13
|
+
|
|
14
|
+
from ._clusters import encode_clusters
|
|
15
|
+
|
|
16
|
+
_LOG_MAX = math.log(np.finfo(float).max)
|
|
17
|
+
_LOG_EPS = math.log(np.finfo(float).eps)
|
|
18
|
+
_BUDGET = 512 * 1024 ** 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _vector(value, name, n=None, *, finite=True):
|
|
22
|
+
try:
|
|
23
|
+
array = np.asarray(value, dtype=float)
|
|
24
|
+
except (TypeError, ValueError) as exc:
|
|
25
|
+
raise ValueError(f'{name} must be a numeric vector') from exc
|
|
26
|
+
if array.ndim != 1 or not len(array) or (n is not None and len(array) != n):
|
|
27
|
+
raise ValueError(f'{name} must have one value per row and cannot be empty')
|
|
28
|
+
if np.isnan(array).any() or (finite and not np.isfinite(array).all()):
|
|
29
|
+
raise ValueError(f'{name} contains missing or invalid nonfinite values')
|
|
30
|
+
return array.copy()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _breaks(value):
|
|
34
|
+
cuts = np.asarray(value, dtype=float)
|
|
35
|
+
if (cuts.ndim != 1 or not np.isfinite(cuts).all() or (cuts <= 0).any()
|
|
36
|
+
or (np.diff(cuts) <= 0).any()):
|
|
37
|
+
raise ValueError('baseline_breaks must be finite positive strictly increasing internal cuts')
|
|
38
|
+
return cuts.copy()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _times(value):
|
|
42
|
+
times = _vector(value, 'times')
|
|
43
|
+
if (times < 0).any() or (np.diff(times) <= 0).any():
|
|
44
|
+
raise ValueError('times must be nonnegative and strictly increasing')
|
|
45
|
+
return times
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _duration(start, stop, cuts):
|
|
49
|
+
left = np.r_[0., cuts]
|
|
50
|
+
right = np.r_[cuts, np.inf]
|
|
51
|
+
return np.maximum(np.minimum(stop[:, None], right) - np.maximum(start[:, None], left), 0.)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _subjects(subject, n):
|
|
55
|
+
if subject is None:
|
|
56
|
+
return np.arange(n, dtype=int), list(range(n))
|
|
57
|
+
return encode_clusters(subject, n)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _history_rows(codes, starts, stops, event=None):
|
|
61
|
+
"""Validate each subject's input-order contiguous history; do not sort it."""
|
|
62
|
+
order = np.argsort(codes, kind='stable')
|
|
63
|
+
pointers = np.r_[0, np.cumsum(np.bincount(codes))].astype(int)
|
|
64
|
+
terminal = np.zeros(len(codes), dtype=bool)
|
|
65
|
+
terminal[order[pointers[1:] - 1]] = True
|
|
66
|
+
earlier, later = order[:-1], order[1:]
|
|
67
|
+
same_subject = codes[earlier] == codes[later]
|
|
68
|
+
if not np.array_equal(starts[later[same_subject]], stops[earlier[same_subject]]):
|
|
69
|
+
raise ValueError('subject histories must be chronological, contiguous and nonoverlapping')
|
|
70
|
+
if event is not None and np.any(event[~terminal] != 0):
|
|
71
|
+
raise ValueError('an event may occur only in the last row for each subject')
|
|
72
|
+
return terminal, order, pointers
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def prepare_extended_survival(time, event, *, entry=None, upper=None, subject=None,
|
|
76
|
+
n_causes=None, baseline_breaks=None):
|
|
77
|
+
"""Validate exact/right/left/interval observations and risk histories.
|
|
78
|
+
|
|
79
|
+
``event`` is zero for right censoring, 1..K for an observed cause, or -1
|
|
80
|
+
for an unknown cause in a finite interval. ``time`` is the exact/censoring
|
|
81
|
+
time or interval lower bound. ``upper`` is the finite interval upper bound,
|
|
82
|
+
equal to time for exact observations, or infinity for right censoring.
|
|
83
|
+
Returned right-censored upper bounds are canonicalized to infinity.
|
|
84
|
+
|
|
85
|
+
Repeated subjects encode successive (start, stop] rows using entry/time.
|
|
86
|
+
Earlier rows are risk contributions with event 0; only the last may have an
|
|
87
|
+
event or finite censoring interval. Missing-row selection belongs to caller.
|
|
88
|
+
"""
|
|
89
|
+
lower = _vector(time, 'time')
|
|
90
|
+
n = len(lower)
|
|
91
|
+
status = _vector(event, 'event', n)
|
|
92
|
+
if (status != np.floor(status)).any() or (status < -1).any():
|
|
93
|
+
raise ValueError('event must contain integer cause codes0..K or-1 for unknown interval cause')
|
|
94
|
+
maximum = float(status.max())
|
|
95
|
+
if maximum >= np.iinfo(np.int64).max:
|
|
96
|
+
raise ValueError('event cause codes exceed supported integer range')
|
|
97
|
+
if n_causes is None:
|
|
98
|
+
if np.any(status == -1) and maximum < 1:
|
|
99
|
+
raise ValueError('n_causes is required when observed interval causes are all unknown')
|
|
100
|
+
causes = max(1, int(maximum))
|
|
101
|
+
else:
|
|
102
|
+
if (isinstance(n_causes, (bool, np.bool_))
|
|
103
|
+
or not isinstance(n_causes, (int, np.integer)) or n_causes < 1):
|
|
104
|
+
raise ValueError('n_causes must be a positive integer')
|
|
105
|
+
causes = int(n_causes)
|
|
106
|
+
if maximum > causes:
|
|
107
|
+
raise ValueError('event contains a cause exceeding n_causes')
|
|
108
|
+
status = status.astype(int)
|
|
109
|
+
starts = np.zeros(n) if entry is None else _vector(entry, 'entry', n)
|
|
110
|
+
if (starts < 0).any() or (lower < 0).any():
|
|
111
|
+
raise ValueError('entry and time must be nonnegative')
|
|
112
|
+
if upper is None:
|
|
113
|
+
ends = np.where(status == 0, np.inf, lower)
|
|
114
|
+
else:
|
|
115
|
+
ends = _vector(upper, 'upper', n, finite=False)
|
|
116
|
+
if np.isneginf(ends).any() or np.any(ends < lower):
|
|
117
|
+
raise ValueError('upper must be at least time; only right censoring may use infinity')
|
|
118
|
+
interval = np.isfinite(ends) & (ends > lower)
|
|
119
|
+
if np.any((status == 0) & interval):
|
|
120
|
+
raise ValueError('event0 means right censoring, not a finite censoring interval')
|
|
121
|
+
if np.any(np.isinf(ends) & (status != 0)):
|
|
122
|
+
raise ValueError('infinite upper bounds are allowed only for right censoring')
|
|
123
|
+
if np.any((status == -1) & ~interval):
|
|
124
|
+
raise ValueError('unknown causes require a finite nonzero censoring interval')
|
|
125
|
+
if np.any(lower[~interval] <= starts[~interval]):
|
|
126
|
+
raise ValueError('exact and right-censored times must be strictly after entry')
|
|
127
|
+
if np.any(lower[interval] < starts[interval]):
|
|
128
|
+
raise ValueError('interval lower bounds must be at least entry')
|
|
129
|
+
ends[status == 0] = np.inf
|
|
130
|
+
codes, levels = _subjects(subject, n)
|
|
131
|
+
terminal, order, pointers = _history_rows(codes, starts, lower, status)
|
|
132
|
+
if np.any(interval & ~terminal):
|
|
133
|
+
raise ValueError('finite censoring intervals are allowed only in terminal subject rows')
|
|
134
|
+
if baseline_breaks is None:
|
|
135
|
+
exact_known = lower[(status > 0) & ~interval]
|
|
136
|
+
if len(exact_known):
|
|
137
|
+
maximum_followup = max(float(lower.max()), float(np.max(ends[np.isfinite(ends)])))
|
|
138
|
+
cuts = np.unique(np.quantile(exact_known, np.arange(1, 10) / 10))
|
|
139
|
+
cuts = cuts[(cuts > 0) & (cuts < maximum_followup)]
|
|
140
|
+
else:
|
|
141
|
+
cuts = np.empty(0)
|
|
142
|
+
else:
|
|
143
|
+
cuts = _breaks(baseline_breaks)
|
|
144
|
+
if 8 * (2 * n * (len(cuts) + 1) + 8 * n) > _BUDGET:
|
|
145
|
+
raise ValueError('survival duration arrays exceed the512MiB working-memory limit')
|
|
146
|
+
durations = _duration(starts, lower, cuts)
|
|
147
|
+
interval_durations = np.zeros_like(durations)
|
|
148
|
+
if interval.any():
|
|
149
|
+
interval_durations[interval] = _duration(lower[interval], ends[interval], cuts)
|
|
150
|
+
return dict(time=lower, event=status, entry=starts, upper=ends,
|
|
151
|
+
subject_codes=codes, subject_levels=levels,
|
|
152
|
+
baseline_breaks=cuts, durations=durations,
|
|
153
|
+
interval_durations=interval_durations,
|
|
154
|
+
event_bin=np.searchsorted(cuts, lower, side='left'),
|
|
155
|
+
is_interval=interval, terminal=terminal,
|
|
156
|
+
subject_order=order, subject_ptr=pointers, n_causes=causes, K=causes)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _log_integrated_hazard(log_rate, widths):
|
|
160
|
+
selected = widths > 0
|
|
161
|
+
if not np.any(selected):
|
|
162
|
+
return -np.inf
|
|
163
|
+
return float(logsumexp(log_rate[selected] + np.log(widths[selected])))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _hazard_from_log(log_hazard):
|
|
167
|
+
if log_hazard == -np.inf:
|
|
168
|
+
return 0.
|
|
169
|
+
if log_hazard > _LOG_MAX:
|
|
170
|
+
return np.inf
|
|
171
|
+
return math.exp(log_hazard)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _log_failure(log_hazard):
|
|
175
|
+
"""log(1-exp(-H)) from log(H), preserving tiny interval probabilities."""
|
|
176
|
+
if log_hazard < _LOG_EPS:
|
|
177
|
+
# The relative correction to H is below machine precision; returning
|
|
178
|
+
# log(H) also avoids underflow when H itself is unrepresentable.
|
|
179
|
+
return log_hazard
|
|
180
|
+
if log_hazard > _LOG_MAX:
|
|
181
|
+
return 0.
|
|
182
|
+
return math.log(-math.expm1(-math.exp(log_hazard)))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def extended_survival_loglik(eta, baseline, data):
|
|
186
|
+
"""Full conditional log likelihood with cause-specific constant hazards.
|
|
187
|
+
|
|
188
|
+
Entry conditioning is rowwise given eta. Earlier subject rows accumulate
|
|
189
|
+
risk; a terminal interval row adds an integrated event probability. This
|
|
190
|
+
gives one entry-conditioned subject likelihood when its contiguous rows
|
|
191
|
+
are multiplied. Candidate nonfinite/invalid predictors or hazards return
|
|
192
|
+
minus infinity, never clipped likelihoods or rates.
|
|
193
|
+
"""
|
|
194
|
+
predictors = np.asarray(eta, dtype=float)
|
|
195
|
+
hazards = np.asarray(baseline, dtype=float)
|
|
196
|
+
n, k, j = len(data['time']), int(data['n_causes']), len(data['baseline_breaks']) + 1
|
|
197
|
+
if (predictors.shape != (n, k) or hazards.shape != (k, j)
|
|
198
|
+
or not np.isfinite(predictors).all() or not np.isfinite(hazards).all()
|
|
199
|
+
or (hazards <= 0).any()):
|
|
200
|
+
return -np.inf
|
|
201
|
+
log_baseline = np.log(hazards)
|
|
202
|
+
total = 0.
|
|
203
|
+
with np.errstate(over='ignore', under='ignore', invalid='ignore'):
|
|
204
|
+
for i in range(n):
|
|
205
|
+
log_cause = predictors[i, :, None] + log_baseline
|
|
206
|
+
if not np.isfinite(log_cause).all():
|
|
207
|
+
return -np.inf
|
|
208
|
+
# Center eta before adding baseline logs so cause fractions do
|
|
209
|
+
# not lose the baseline contrast under a huge common predictor.
|
|
210
|
+
reference = float(np.max(predictors[i]))
|
|
211
|
+
relative_cause = (predictors[i] - reference)[:, None] + log_baseline
|
|
212
|
+
relative_total = logsumexp(relative_cause, axis=0)
|
|
213
|
+
log_rate = reference + relative_total
|
|
214
|
+
log_fraction = relative_cause - relative_total
|
|
215
|
+
before = _hazard_from_log(_log_integrated_hazard(log_rate, data['durations'][i]))
|
|
216
|
+
if not np.isfinite(before):
|
|
217
|
+
return -np.inf
|
|
218
|
+
status = int(data['event'][i])
|
|
219
|
+
if not data['is_interval'][i]:
|
|
220
|
+
row = -before
|
|
221
|
+
if status > 0:
|
|
222
|
+
row += log_cause[status - 1, data['event_bin'][i]]
|
|
223
|
+
else:
|
|
224
|
+
widths = data['interval_durations'][i]
|
|
225
|
+
if status == -1:
|
|
226
|
+
row = -before + _log_failure(_log_integrated_hazard(log_rate, widths))
|
|
227
|
+
else:
|
|
228
|
+
pieces = []
|
|
229
|
+
elapsed = 0.
|
|
230
|
+
for b in np.flatnonzero(widths > 0):
|
|
231
|
+
log_mass = log_rate[b] + math.log(widths[b])
|
|
232
|
+
pieces.append(-elapsed + log_fraction[status - 1, b]
|
|
233
|
+
+ _log_failure(log_mass))
|
|
234
|
+
elapsed += _hazard_from_log(log_mass)
|
|
235
|
+
if not np.isfinite(elapsed):
|
|
236
|
+
break
|
|
237
|
+
row = -before + float(logsumexp(pieces))
|
|
238
|
+
total += row
|
|
239
|
+
if not np.isfinite(total):
|
|
240
|
+
return -np.inf
|
|
241
|
+
return float(total)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _prediction_inputs(eta, baseline, breaks, times):
|
|
245
|
+
predictors = np.asarray(eta, dtype=float)
|
|
246
|
+
cuts = _breaks(breaks)
|
|
247
|
+
hazards = np.asarray(baseline, dtype=float)
|
|
248
|
+
if predictors.ndim != 2 or not predictors.shape[0] or not predictors.shape[1] or not np.isfinite(predictors).all():
|
|
249
|
+
raise ValueError('eta must be a nonempty finite rows-by-causes matrix')
|
|
250
|
+
k = predictors.shape[1]
|
|
251
|
+
if hazards.shape != (k, len(cuts) + 1) or not np.isfinite(hazards).all() or (hazards <= 0).any():
|
|
252
|
+
raise ValueError('baseline must be positive finite causes-by-intervals hazards')
|
|
253
|
+
return predictors, np.log(hazards), cuts, _times(times)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _curve_budget(n, t, k):
|
|
257
|
+
if 8 * (n * t * (1 + 2 * k) + 4 * n * k) > _BUDGET:
|
|
258
|
+
raise ValueError('survival curves exceed the512MiB output-memory limit')
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _advance(survival, incidence, log_hazards, width):
|
|
262
|
+
"""Exact competing-risk mass increment on one constant-hazard segment."""
|
|
263
|
+
log_total = float(logsumexp(log_hazards))
|
|
264
|
+
integrated = _hazard_from_log(log_total + math.log(width))
|
|
265
|
+
failure = -math.expm1(-integrated)
|
|
266
|
+
incidence = incidence + survival * failure * np.exp(log_hazards - log_total)
|
|
267
|
+
survival = survival * math.exp(-integrated)
|
|
268
|
+
return survival, incidence
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _finite_hazard(log_hazards):
|
|
272
|
+
if np.any(log_hazards > _LOG_MAX) or not np.isfinite(log_hazards).all():
|
|
273
|
+
raise FloatingPointError('predicted hazard exceeds floating-point range; rescale predictors or priors')
|
|
274
|
+
return np.exp(log_hazards)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def survival_curves(eta, baseline, breaks, times, *, entry=0):
|
|
278
|
+
"""Return (S, CIF, hazard) arrays conditional on survival to each entry time.
|
|
279
|
+
|
|
280
|
+
S is survival probability; CIF is cumulative incidence by cause.
|
|
281
|
+
Shapes are profiles-by-times and profiles-by-times-by-causes. Before entry,
|
|
282
|
+
S=1,CIF=0 and hazards=0. At time zero the first baseline hazard is used.
|
|
283
|
+
Other boundaries are right closed. Beyond the last baseline cut, the final
|
|
284
|
+
hazard is extended; this is an explicit piecewise-baseline extrapolation.
|
|
285
|
+
"""
|
|
286
|
+
predictors, log_baseline, cuts, requested = _prediction_inputs(eta, baseline, breaks, times)
|
|
287
|
+
n, k = predictors.shape
|
|
288
|
+
starts = np.asarray(entry, dtype=float)
|
|
289
|
+
if starts.ndim == 0:
|
|
290
|
+
starts = np.full(n, starts.item())
|
|
291
|
+
if starts.shape != (n,) or not np.isfinite(starts).all() or (starts < 0).any():
|
|
292
|
+
raise ValueError('entry must be a nonnegative finite scalar or one value per profile')
|
|
293
|
+
_curve_budget(n, len(requested), k)
|
|
294
|
+
survival = np.ones((n, len(requested)))
|
|
295
|
+
cif = np.zeros((n, len(requested), k))
|
|
296
|
+
hazard = np.zeros_like(cif)
|
|
297
|
+
for i in range(n):
|
|
298
|
+
current, s, ci = float(starts[i]), 1., np.zeros(k)
|
|
299
|
+
for t, target in enumerate(requested):
|
|
300
|
+
if target < starts[i]:
|
|
301
|
+
continue
|
|
302
|
+
split = np.r_[cuts[(cuts > current) & (cuts < target)], target]
|
|
303
|
+
for endpoint in split:
|
|
304
|
+
if endpoint > current:
|
|
305
|
+
b = np.searchsorted(cuts, current, side='right')
|
|
306
|
+
logs = predictors[i] + log_baseline[:, b]
|
|
307
|
+
s, ci = _advance(s, ci, logs, float(endpoint - current))
|
|
308
|
+
current = float(endpoint)
|
|
309
|
+
survival[i, t], cif[i, t] = s, ci
|
|
310
|
+
b = np.searchsorted(cuts, target, side='left')
|
|
311
|
+
hazard[i, t] = _finite_hazard(predictors[i] + log_baseline[:, b])
|
|
312
|
+
return survival, cif, hazard
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def survival_path_curves(eta, baseline, breaks, times, *, start, stop, subject):
|
|
316
|
+
"""Conditional curves for supplied contiguous (start, stop] predictor paths.
|
|
317
|
+
|
|
318
|
+
Return (subject_levels, S, CIF, hazard). Subject order follows first occurrence;
|
|
319
|
+
rows within subjects must already be chronological. Each supplied row has
|
|
320
|
+
constant eta; no interpolation is used. Predictions after the last supplied
|
|
321
|
+
stop fail instead of extending the last predictor row. Before first start,
|
|
322
|
+
S=1,CIF=0,hazard=0. The subject's first start is its conditioning entry time.
|
|
323
|
+
"""
|
|
324
|
+
predictors, log_baseline, cuts, requested = _prediction_inputs(eta, baseline, breaks, times)
|
|
325
|
+
n, k = predictors.shape
|
|
326
|
+
starts, stops = _vector(start, 'start', n), _vector(stop, 'stop', n)
|
|
327
|
+
if (starts < 0).any() or np.any(stops <= starts):
|
|
328
|
+
raise ValueError('paths require finite0<=start<stop for every row')
|
|
329
|
+
codes, levels = _subjects(subject, n)
|
|
330
|
+
_, order, pointers = _history_rows(codes, starts, stops)
|
|
331
|
+
_curve_budget(len(levels), len(requested), k)
|
|
332
|
+
survival = np.ones((len(levels), len(requested)))
|
|
333
|
+
cif = np.zeros((len(levels), len(requested), k))
|
|
334
|
+
hazard = np.zeros_like(cif)
|
|
335
|
+
for i in range(len(levels)):
|
|
336
|
+
rows = order[pointers[i]:pointers[i + 1]]
|
|
337
|
+
if requested[-1] > stops[rows[-1]]:
|
|
338
|
+
raise ValueError('requested times extend beyond a supplied subject path; supply future rows explicitly')
|
|
339
|
+
current, s, ci = float(starts[rows[0]]), 1., np.zeros(k)
|
|
340
|
+
boundaries = np.unique(np.r_[cuts, stops[rows]])
|
|
341
|
+
for t, target in enumerate(requested):
|
|
342
|
+
if target < starts[rows[0]]:
|
|
343
|
+
continue
|
|
344
|
+
split = np.r_[boundaries[(boundaries > current) & (boundaries < target)], target]
|
|
345
|
+
for endpoint in split:
|
|
346
|
+
if endpoint > current:
|
|
347
|
+
# Immediately to the right of a path/baseline boundary,
|
|
348
|
+
# integration uses the next interval. Reporting at the
|
|
349
|
+
# boundary itself uses the preceding right-closed row.
|
|
350
|
+
row = rows[np.searchsorted(stops[rows], current, side='right')]
|
|
351
|
+
b = np.searchsorted(cuts, current, side='right')
|
|
352
|
+
s, ci = _advance(s, ci, predictors[row] + log_baseline[:, b], float(endpoint - current))
|
|
353
|
+
current = float(endpoint)
|
|
354
|
+
survival[i, t], cif[i, t] = s, ci
|
|
355
|
+
row = rows[np.searchsorted(stops[rows], target, side='left')]
|
|
356
|
+
b = np.searchsorted(cuts, target, side='left')
|
|
357
|
+
hazard[i, t] = _finite_hazard(predictors[row] + log_baseline[:, b])
|
|
358
|
+
return levels, survival, cif, hazard
|
kernelmix/diagnostics.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Rank-normalized split R-hat and autocorrelation-based effective sample sizes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.special import ndtri
|
|
7
|
+
from scipy.stats import rankdata
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _split(values):
|
|
11
|
+
half = values.shape[1] // 2
|
|
12
|
+
return np.concatenate((values[:, :half], values[:, -half:]), axis=0)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _rank_normalize(values):
|
|
16
|
+
ranks = rankdata(values.ravel(), method="average").reshape(values.shape)
|
|
17
|
+
return ndtri((ranks - 0.375) / (values.size + 0.25))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _rhat(values):
|
|
21
|
+
n = values.shape[1]
|
|
22
|
+
within = np.mean(np.var(values, axis=1, ddof=1))
|
|
23
|
+
between = n * np.var(np.mean(values, axis=1), ddof=1)
|
|
24
|
+
if within == 0:
|
|
25
|
+
return np.nan if between == 0 else np.inf
|
|
26
|
+
return float(np.sqrt(((n - 1) * within / n + between / n) / within))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ess(values):
|
|
30
|
+
"""Geyer initial positive, monotone pair estimate, capped at sample count."""
|
|
31
|
+
m, n = values.shape
|
|
32
|
+
centered = values - values.mean(axis=1, keepdims=True)
|
|
33
|
+
size = 1 << (2 * n - 1).bit_length()
|
|
34
|
+
transformed = np.fft.rfft(centered, n=size, axis=1)
|
|
35
|
+
acov = np.fft.irfft(transformed * np.conjugate(transformed), n=size, axis=1)
|
|
36
|
+
acov = acov[:, :n] / n
|
|
37
|
+
within = np.mean(np.var(values, axis=1, ddof=1))
|
|
38
|
+
between = n * np.var(values.mean(axis=1), ddof=1)
|
|
39
|
+
variance = (n - 1) * within / n + between / n
|
|
40
|
+
if not np.isfinite(variance) or variance <= 0 or within == 0:
|
|
41
|
+
return np.nan
|
|
42
|
+
rho = 1 - (within - acov.mean(axis=0)) / variance
|
|
43
|
+
rho[0] = 1.0
|
|
44
|
+
pairs = []
|
|
45
|
+
for lag in range(0, n - 1, 2):
|
|
46
|
+
pair = rho[lag] + rho[lag + 1]
|
|
47
|
+
if pair <= 0:
|
|
48
|
+
break
|
|
49
|
+
pairs.append(min(pair, pairs[-1]) if pairs else pair)
|
|
50
|
+
if not pairs:
|
|
51
|
+
return float(m * n)
|
|
52
|
+
tau = max(1.0, -1 + 2 * sum(pairs))
|
|
53
|
+
return float(min(m * n, m * n / tau))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def diagnose(values):
|
|
57
|
+
"""Check sampling diagnostics for one parameter shaped (chains, saved draws).
|
|
58
|
+
|
|
59
|
+
R-hat compares variation within and between split chains, using both ranks
|
|
60
|
+
and ranks of distances from the median. Effective sample size (ESS) estimates how many independent
|
|
61
|
+
draws would give similar precision. Bulk ESS uses ranks; tail ESS uses
|
|
62
|
+
indicators at the 5th and 95th percentiles. These checks do not prove
|
|
63
|
+
convergence or establish that the statistical model fits the data.
|
|
64
|
+
"""
|
|
65
|
+
values = np.asarray(values, dtype=float)
|
|
66
|
+
if values.ndim != 2 or not np.isfinite(values).all():
|
|
67
|
+
raise ValueError("diagnostics require a finite (chains, draws) array")
|
|
68
|
+
if values.shape[1] < 4:
|
|
69
|
+
return {"rhat": np.nan, "ess_bulk": np.nan, "ess_tail": np.nan}
|
|
70
|
+
split = _split(values)
|
|
71
|
+
ranks = _rank_normalize(split)
|
|
72
|
+
folded = _rank_normalize(np.abs(split - np.median(split)))
|
|
73
|
+
rhat = max(_rhat(ranks), _rhat(folded)) if values.shape[0] >= 2 else np.nan
|
|
74
|
+
low, high = np.quantile(split, [0.05, 0.95])
|
|
75
|
+
tails = [_ess((split <= low).astype(float)), _ess((split >= high).astype(float))]
|
|
76
|
+
tail = min(tails) if np.isfinite(tails).all() else np.nan
|
|
77
|
+
return {"rhat": float(rhat), "ess_bulk": _ess(ranks), "ess_tail": float(tail)}
|