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
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""Noncentered BKMR for count and right-censored survival outcomes.
|
|
2
|
+
|
|
3
|
+
Standard-normal latent blocks are updated by elliptical slice sampling;
|
|
4
|
+
positive hyperparameters use log random-walk Metropolis updates. Survival
|
|
5
|
+
baselines use exact conditional Gamma draws. These likelihood extensions use
|
|
6
|
+
proper Gaussian priors for adjustment coefficients, including the intercept.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import time
|
|
11
|
+
import warnings
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from ._input import prepare_data
|
|
17
|
+
from ._kernel import gaussian_kernel
|
|
18
|
+
from ._likelihoods import (
|
|
19
|
+
baseline_posterior_parameters,
|
|
20
|
+
negative_binomial_loglik,
|
|
21
|
+
poisson_loglik,
|
|
22
|
+
prepare_survival,
|
|
23
|
+
survival_loglik,
|
|
24
|
+
)
|
|
25
|
+
from .priors import Prior, gamma_logpdf
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _count(value, name, minimum):
|
|
29
|
+
if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)) or value < minimum:
|
|
30
|
+
raise ValueError(f'{name} must be an integer at least {minimum}')
|
|
31
|
+
return int(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _positive(value, name):
|
|
35
|
+
if isinstance(value, (bool, np.bool_)) or not np.isscalar(value) or not np.isfinite(value) or value <= 0:
|
|
36
|
+
raise ValueError(f'{name} must be a finite positive scalar')
|
|
37
|
+
return float(value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _vector(value, size, name):
|
|
41
|
+
result = np.asarray(value, dtype=float)
|
|
42
|
+
if result.shape != (size,) or not np.isfinite(result).all():
|
|
43
|
+
raise ValueError(f'{name} must be a finite vector of length {size}')
|
|
44
|
+
return result.copy()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _original_vector(value, size, name):
|
|
48
|
+
result = np.asarray(value, dtype=float)
|
|
49
|
+
if result.shape != (size,):
|
|
50
|
+
raise ValueError(f'{name} must have one entry per original observation')
|
|
51
|
+
if np.isinf(result).any():
|
|
52
|
+
raise ValueError(f'{name} cannot contain infinity')
|
|
53
|
+
return result.copy()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _prepare(y, Z, X, family, standardize, intercept, missing,
|
|
57
|
+
exposure_names, covariate_names, groups, cluster, offset, event, baseline_breaks):
|
|
58
|
+
outcome = np.asarray(y, dtype=float)
|
|
59
|
+
if outcome.ndim != 1:
|
|
60
|
+
raise ValueError('y must be a one-dimensional outcome vector')
|
|
61
|
+
n = len(outcome)
|
|
62
|
+
if np.isinf(outcome).any():
|
|
63
|
+
raise ValueError('y cannot contain infinity')
|
|
64
|
+
finite = np.isfinite(outcome)
|
|
65
|
+
masked = outcome.copy()
|
|
66
|
+
if family in ('poisson', 'negative_binomial'):
|
|
67
|
+
if event is not None or baseline_breaks is not None:
|
|
68
|
+
raise ValueError('event and baseline_breaks apply only to survival outcomes')
|
|
69
|
+
if np.any(outcome[finite] < 0) or np.any(outcome[finite] != np.floor(outcome[finite])):
|
|
70
|
+
raise ValueError('count outcomes must be nonnegative integers')
|
|
71
|
+
offsets = np.zeros(n) if offset is None else _original_vector(offset, n, 'offset')
|
|
72
|
+
masked[np.isnan(offsets)] = np.nan
|
|
73
|
+
else:
|
|
74
|
+
if offset is not None:
|
|
75
|
+
raise ValueError('offset applies only to count outcomes')
|
|
76
|
+
if event is None:
|
|
77
|
+
raise ValueError('survival outcomes require event indicators')
|
|
78
|
+
if np.any(outcome[finite] <= 0):
|
|
79
|
+
raise ValueError('observed survival times must be positive')
|
|
80
|
+
events = _original_vector(event, n, 'event')
|
|
81
|
+
if not np.isin(events[np.isfinite(events)], [0, 1]).all():
|
|
82
|
+
raise ValueError('event must contain only zero and one')
|
|
83
|
+
masked[np.isnan(events)] = np.nan
|
|
84
|
+
# Inject only additional missingness into a copied outcome. The common
|
|
85
|
+
# helper preserves original row numbers, named columns and train transforms.
|
|
86
|
+
data = prepare_data(masked, Z, X, family='gaussian', standardize=standardize,
|
|
87
|
+
intercept=intercept, missing=missing, exposure_names=exposure_names,
|
|
88
|
+
covariate_names=covariate_names, groups=groups, cluster=cluster)
|
|
89
|
+
rows = data['rows']
|
|
90
|
+
data['y'] = outcome[rows].copy()
|
|
91
|
+
if family == 'survival':
|
|
92
|
+
data.update(prepare_survival(data['y'], events[rows], baseline_breaks))
|
|
93
|
+
else:
|
|
94
|
+
data['offset'] = offsets[rows].copy()
|
|
95
|
+
return data
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def elliptical_slice(current, log_likelihood, rng, *, current_loglik=None, max_steps=10000):
|
|
99
|
+
"""One ESS step for a standard-normal prior, returning state, loglik, evaluations.
|
|
100
|
+
|
|
101
|
+
The bracket is randomly placed around the current angle, following Murray,
|
|
102
|
+
Adams and MacKay (2010), Figure 2. The bounded loop fails explicitly rather
|
|
103
|
+
than returning a rejected state as if it were a successful ESS update.
|
|
104
|
+
"""
|
|
105
|
+
current = np.asarray(current, dtype=float)
|
|
106
|
+
if current.ndim != 1 or not np.isfinite(current).all():
|
|
107
|
+
raise ValueError('ESS requires a finite one-dimensional current state')
|
|
108
|
+
max_steps = _count(max_steps, 'max_steps', 1)
|
|
109
|
+
evaluations = 0
|
|
110
|
+
if current_loglik is None:
|
|
111
|
+
current_loglik = float(log_likelihood(current))
|
|
112
|
+
evaluations += 1
|
|
113
|
+
if not np.isfinite(current_loglik):
|
|
114
|
+
raise ValueError('ESS requires a finite initial log likelihood')
|
|
115
|
+
direction = rng.normal(size=current.size)
|
|
116
|
+
# 1-U is in (0,1], avoiding log(0) without clipping a slice level.
|
|
117
|
+
threshold = current_loglik + math.log1p(-float(rng.random()))
|
|
118
|
+
theta = float(rng.uniform(0, 2 * math.pi))
|
|
119
|
+
lower, upper = theta - 2 * math.pi, theta
|
|
120
|
+
for _ in range(max_steps):
|
|
121
|
+
proposal = current * math.cos(theta) + direction * math.sin(theta)
|
|
122
|
+
proposed_loglik = float(log_likelihood(proposal))
|
|
123
|
+
evaluations += 1
|
|
124
|
+
if np.isfinite(proposed_loglik) and proposed_loglik > threshold:
|
|
125
|
+
return proposal, proposed_loglik, evaluations
|
|
126
|
+
if theta < 0:
|
|
127
|
+
lower = theta
|
|
128
|
+
else:
|
|
129
|
+
upper = theta
|
|
130
|
+
if not lower < upper:
|
|
131
|
+
break
|
|
132
|
+
theta = float(rng.uniform(lower, upper))
|
|
133
|
+
raise RuntimeError('elliptical slice bracket did not close; rescale predictors or inspect the likelihood')
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _initialize(initial, data, prior, family, selection, fixed, units, rng):
|
|
137
|
+
n, m = data['Z'].shape
|
|
138
|
+
p = data['X'].shape[1]
|
|
139
|
+
g = len(data['cluster_levels'])
|
|
140
|
+
allowed = {'beta', 'r', 'lambda', 'delta', 'pi', 'latent_white'}
|
|
141
|
+
if g:
|
|
142
|
+
allowed.update(('lambda_cluster', 'u_white'))
|
|
143
|
+
if family == 'negative_binomial':
|
|
144
|
+
allowed.add('dispersion')
|
|
145
|
+
if family == 'survival':
|
|
146
|
+
allowed.add('baseline_hazard')
|
|
147
|
+
if initial is None:
|
|
148
|
+
initial = {}
|
|
149
|
+
if not isinstance(initial, dict) or set(initial) - allowed:
|
|
150
|
+
raise ValueError('initial must be a dictionary containing only parameters applicable to this model')
|
|
151
|
+
pi = _positive(initial.get('pi', 1. if selection == 'none' else .5), 'initial pi')
|
|
152
|
+
if (selection == 'none' and pi != 1) or (selection != 'none' and pi >= 1):
|
|
153
|
+
raise ValueError('initial pi must be one without selection and strictly between zero and one otherwise')
|
|
154
|
+
delta = np.ones(m, dtype=bool)
|
|
155
|
+
if selection != 'none':
|
|
156
|
+
delta[:] = False
|
|
157
|
+
for members in units:
|
|
158
|
+
if rng.random() < pi:
|
|
159
|
+
delta[rng.choice(members)] = True
|
|
160
|
+
if 'delta' in initial:
|
|
161
|
+
supplied = np.asarray(initial['delta'])
|
|
162
|
+
if supplied.shape != (m,) or not np.isin(supplied, [0, 1]).all():
|
|
163
|
+
raise ValueError('initial delta must contain one zero/one indicator per exposure')
|
|
164
|
+
delta = supplied.astype(bool)
|
|
165
|
+
if selection == 'none' and fixed is None and not delta.all():
|
|
166
|
+
raise ValueError('without selection all exposure indicators must be active')
|
|
167
|
+
if selection == 'group' and any(delta[members].sum() > 1 for members in units):
|
|
168
|
+
raise ValueError('initial delta may select at most one exposure per group')
|
|
169
|
+
if fixed is not None:
|
|
170
|
+
if 'r' in initial and not np.array_equal(np.asarray(initial['r']), fixed):
|
|
171
|
+
raise ValueError('initial r conflicts with r_fixed')
|
|
172
|
+
if 'delta' in initial and not np.array_equal(delta, fixed > 0):
|
|
173
|
+
raise ValueError('initial delta conflicts with r_fixed')
|
|
174
|
+
r, delta = fixed.copy(), fixed > 0
|
|
175
|
+
elif 'r' in initial:
|
|
176
|
+
r = _vector(initial['r'], m, 'initial r')
|
|
177
|
+
if np.any(r[~delta] != 0) or any(not np.isfinite(prior.log_r(value)) for value in r[delta]):
|
|
178
|
+
raise ValueError('initial r must be zero for excluded exposures and inside its prior support otherwise')
|
|
179
|
+
else:
|
|
180
|
+
r = np.array([prior.sample_r(rng) if selected else 0. for selected in delta])
|
|
181
|
+
beta = _vector(initial.get('beta', np.zeros(p)), p, 'initial beta')
|
|
182
|
+
v = _vector(initial.get('latent_white', np.zeros(n)), n, 'initial latent_white')
|
|
183
|
+
w = _vector(initial.get('u_white', np.zeros(g)), g, 'initial u_white')
|
|
184
|
+
lambda_ = _positive(initial.get('lambda', prior.lambda_mean), 'initial lambda')
|
|
185
|
+
lambda_cluster = _positive(initial.get('lambda_cluster', prior.cluster_lambda_mean), 'initial lambda_cluster') if g else None
|
|
186
|
+
dispersion = _positive(initial.get('dispersion', prior.dispersion_shape / prior.dispersion_rate), 'initial dispersion') if family == 'negative_binomial' else None
|
|
187
|
+
baseline = None
|
|
188
|
+
if family == 'survival':
|
|
189
|
+
j = data['durations'].shape[1]
|
|
190
|
+
baseline = _vector(initial.get('baseline_hazard', np.full(j, prior.baseline_shape / prior.baseline_rate)), j, 'initial baseline_hazard')
|
|
191
|
+
if np.any(baseline <= 0):
|
|
192
|
+
raise ValueError('initial baseline_hazard must be strictly positive')
|
|
193
|
+
white = np.concatenate((v, beta / prior.beta_sd, w))
|
|
194
|
+
if not np.isfinite(white).all():
|
|
195
|
+
raise ValueError('initial standardized coefficients overflowed; rescale beta or its prior')
|
|
196
|
+
return dict(white=white, r=r, delta=delta, pi=pi, lambda_=lambda_,
|
|
197
|
+
lambda_cluster=lambda_cluster, dispersion=dispersion, baseline=baseline)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _gp_factor(Z, r):
|
|
201
|
+
"""Dense GP factor with the prediction path's numerical-null convention.
|
|
202
|
+
|
|
203
|
+
Eigenvalues at most 100*n*eps*largest_eigenvalue are numerical null modes.
|
|
204
|
+
Materially negative values fail; no diagonal jitter or low-rank model
|
|
205
|
+
approximation is introduced.
|
|
206
|
+
"""
|
|
207
|
+
kernel = gaussian_kernel(Z, r=r)
|
|
208
|
+
eigenvalues, eigenvectors = np.linalg.eigh((kernel + kernel.T) / 2)
|
|
209
|
+
tolerance = 100 * len(kernel) * np.finfo(float).eps * eigenvalues.max()
|
|
210
|
+
if eigenvalues.min() < -tolerance:
|
|
211
|
+
raise np.linalg.LinAlgError('GP kernel is materially indefinite')
|
|
212
|
+
retained = np.where(eigenvalues > tolerance, eigenvalues, 0.)
|
|
213
|
+
return eigenvectors * np.sqrt(retained)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _log_gamma_shape_rate(value, shape, rate):
|
|
217
|
+
# Normalizing constants cancel for a fixed Gamma prior in a scalar MH ratio.
|
|
218
|
+
if not np.isfinite(value) or value <= 0:
|
|
219
|
+
return -np.inf
|
|
220
|
+
return (shape - 1) * math.log(value) - rate * value
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _log_proposal(value, sd, rng):
|
|
224
|
+
log_value = math.log(value)
|
|
225
|
+
log_candidate = log_value + float(rng.normal(scale=sd))
|
|
226
|
+
with np.errstate(over='ignore', under='ignore', invalid='ignore'):
|
|
227
|
+
candidate = float(np.exp(log_candidate))
|
|
228
|
+
return candidate, log_candidate - log_value
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def fit_generalized(y, Z, X=None, *, family='poisson', draws=1000, warmup=1000,
|
|
232
|
+
chains=4, thin=1, seed=None, standardize=True, intercept=True,
|
|
233
|
+
selection='none', groups=None, prior=None, r_fixed=None, missing='raise',
|
|
234
|
+
exposure_names=None, covariate_names=None, initial=None, proposal_scales=None,
|
|
235
|
+
cluster=None, offset=None, event=None, baseline_breaks=None):
|
|
236
|
+
"""Fit Poisson, NB2 or piecewise-exponential PH regression with a GP term."""
|
|
237
|
+
start = time.perf_counter()
|
|
238
|
+
if family not in ('poisson', 'negative_binomial', 'survival'):
|
|
239
|
+
raise ValueError('generalized family must be poisson, negative_binomial or survival')
|
|
240
|
+
draws, warmup = _count(draws, 'draws', 2), _count(warmup, 'warmup', 0)
|
|
241
|
+
chains, thin = _count(chains, 'chains', 1), _count(thin, 'thin', 1)
|
|
242
|
+
if seed is not None:
|
|
243
|
+
seed = _count(seed, 'seed', 0)
|
|
244
|
+
if selection not in ('none', 'component', 'group'):
|
|
245
|
+
raise ValueError('selection must be none, component or group')
|
|
246
|
+
if (selection == 'group') != (groups is not None):
|
|
247
|
+
raise ValueError('groups are required only for group selection')
|
|
248
|
+
family_defaults = dict(lambda_mean=1., lambda_sd=.5, cluster_lambda_mean=1., cluster_lambda_sd=.5)
|
|
249
|
+
if prior is None:
|
|
250
|
+
prior = Prior(**family_defaults)
|
|
251
|
+
elif isinstance(prior, dict):
|
|
252
|
+
prior = Prior(**{**family_defaults, **prior})
|
|
253
|
+
if not isinstance(prior, Prior):
|
|
254
|
+
raise ValueError('prior must be Prior, a dictionary or None')
|
|
255
|
+
scales = {'r': .3, 'lambda': .3, 'dispersion': .3, 'cluster_lambda': .3}
|
|
256
|
+
if proposal_scales is not None:
|
|
257
|
+
if not isinstance(proposal_scales, dict) or set(proposal_scales) - set(scales):
|
|
258
|
+
raise ValueError('proposal_scales accepts r, lambda, dispersion and cluster_lambda')
|
|
259
|
+
if 'dispersion' in proposal_scales and family != 'negative_binomial':
|
|
260
|
+
raise ValueError('dispersion proposal scale applies only to negative_binomial')
|
|
261
|
+
if 'cluster_lambda' in proposal_scales and cluster is None:
|
|
262
|
+
raise ValueError('cluster_lambda proposal scale requires cluster labels')
|
|
263
|
+
scales.update(proposal_scales)
|
|
264
|
+
scales = {key: _positive(value, f'proposal_scales[{key}]') for key, value in scales.items()}
|
|
265
|
+
data = _prepare(y, Z, X, family, standardize, intercept, missing,
|
|
266
|
+
exposure_names, covariate_names, groups, cluster, offset, event, baseline_breaks)
|
|
267
|
+
n, m = data['Z'].shape
|
|
268
|
+
p, g = data['X'].shape[1], len(data['cluster_levels'])
|
|
269
|
+
messages = []
|
|
270
|
+
amplitude_priors = [('lambda', prior.lambda_mean, prior.lambda_sd)]
|
|
271
|
+
if g:
|
|
272
|
+
amplitude_priors.append(('lambda_cluster', prior.cluster_lambda_mean, prior.cluster_lambda_sd))
|
|
273
|
+
for name, mean, sd in amplitude_priors:
|
|
274
|
+
rate = mean / sd**2
|
|
275
|
+
if rate <= 2:
|
|
276
|
+
moment = 'first and second moments' if rate <= .5 else 'second moment'
|
|
277
|
+
messages.append(f'The {name} Gamma prior rate is {rate:g}; the prior-predictive exp-Gaussian {moment} may be infinite. Finite-chain response summaries do not establish finite posterior moments.')
|
|
278
|
+
if family == 'negative_binomial' and prior.dispersion_shape <= 1:
|
|
279
|
+
messages.append('The dispersion Gamma prior shape is at most one, so its reciprocal mean and the prior-predictive count second moment may be infinite. Finite-chain summaries do not establish finite posterior moments.')
|
|
280
|
+
if family == 'survival':
|
|
281
|
+
if not data['event'].any():
|
|
282
|
+
messages.append('No observed events: survival estimates depend strongly on the priors; this fit does not provide evidence of estimated event risk.')
|
|
283
|
+
if intercept:
|
|
284
|
+
messages.append('The survival intercept and baseline hazard share an overall scale; their separate estimates depend on their proper priors.')
|
|
285
|
+
for message in messages:
|
|
286
|
+
warnings.warn(message, RuntimeWarning, stacklevel=2)
|
|
287
|
+
j = data['durations'].shape[1] if family == 'survival' else 0
|
|
288
|
+
fixed = None
|
|
289
|
+
if r_fixed is not None:
|
|
290
|
+
fixed = _vector(r_fixed, m, 'r_fixed')
|
|
291
|
+
if np.any(fixed < 0) or selection != 'none':
|
|
292
|
+
raise ValueError('r_fixed must be nonnegative and disallows variable selection')
|
|
293
|
+
units = ([np.array([index]) for index in range(m)] if selection == 'component' else
|
|
294
|
+
[np.flatnonzero(np.asarray(data['groups']) == label) for label in dict.fromkeys(data['groups'])]
|
|
295
|
+
if selection == 'group' else [])
|
|
296
|
+
if 8 * (10 * n * n + n * j) > 512 * 1024**2:
|
|
297
|
+
raise ValueError('dense generalized working storage exceeds the 512 MiB safety budget')
|
|
298
|
+
saved_bytes = chains * draws * (8 * (p + m + 2 * n + g + j + 3 + bool(g)
|
|
299
|
+
+ (family == 'negative_binomial')) + m)
|
|
300
|
+
if saved_bytes > 1024**3:
|
|
301
|
+
raise ValueError('saved posterior arrays exceed the 1 GiB safety budget')
|
|
302
|
+
shape = (chains, draws)
|
|
303
|
+
samples = {name: np.empty(shape + (size,)) for name, size in
|
|
304
|
+
(('beta', p), ('r', m), ('h', n), ('latent_white', n))}
|
|
305
|
+
samples.update({name: np.empty(shape) for name in ('lambda', 'pi')})
|
|
306
|
+
samples['sigma2'] = np.ones(shape)
|
|
307
|
+
samples['delta'] = np.empty(shape + (m,), dtype=bool)
|
|
308
|
+
if g:
|
|
309
|
+
samples['u'] = np.empty(shape + (g,))
|
|
310
|
+
samples['lambda_cluster'] = np.empty(shape)
|
|
311
|
+
if family == 'negative_binomial':
|
|
312
|
+
samples['dispersion'] = np.empty(shape)
|
|
313
|
+
if family == 'survival':
|
|
314
|
+
samples['baseline_hazard'] = np.empty(shape + (j,))
|
|
315
|
+
sequence = np.random.SeedSequence(seed)
|
|
316
|
+
chain_info = []
|
|
317
|
+
for chain_index, child in enumerate(sequence.spawn(chains)):
|
|
318
|
+
rng = np.random.default_rng(child)
|
|
319
|
+
state = _initialize(initial, data, prior, family, selection, fixed, units, rng)
|
|
320
|
+
factor = _gp_factor(data['Z'], state['r'])
|
|
321
|
+
evaluations = dict(warmup=0, sampling=0)
|
|
322
|
+
keys = ['lambda', 'r', 'selection', 'ess']
|
|
323
|
+
if g:
|
|
324
|
+
keys.append('cluster_lambda')
|
|
325
|
+
if family == 'negative_binomial':
|
|
326
|
+
keys.append('dispersion')
|
|
327
|
+
counts = {key: dict(accepted=0, proposed=0, warmup_accepted=0, warmup_proposed=0) for key in keys}
|
|
328
|
+
|
|
329
|
+
def components(white, root, lam, cluster_lam):
|
|
330
|
+
with np.errstate(over='ignore', invalid='ignore'):
|
|
331
|
+
h = math.sqrt(lam) * (root @ white[:n])
|
|
332
|
+
beta = prior.beta_sd * white[n:n+p]
|
|
333
|
+
u = math.sqrt(cluster_lam) * white[n+p:] if g else np.empty(0)
|
|
334
|
+
eta = h + data['X'] @ beta
|
|
335
|
+
if g:
|
|
336
|
+
eta = eta + u[data['cluster_codes']]
|
|
337
|
+
return h, beta, u, eta
|
|
338
|
+
|
|
339
|
+
def likelihood(white, root=None, lam=None, cluster_lam=None, dispersion=None, baseline=None):
|
|
340
|
+
root = factor if root is None else root
|
|
341
|
+
lam = state['lambda_'] if lam is None else lam
|
|
342
|
+
cluster_lam = state['lambda_cluster'] if cluster_lam is None else cluster_lam
|
|
343
|
+
_, _, _, eta = components(white, root, lam, cluster_lam)
|
|
344
|
+
if family == 'survival':
|
|
345
|
+
return survival_loglik(data['event'], data['event_bin'], data['durations'], eta,
|
|
346
|
+
state['baseline'] if baseline is None else baseline)
|
|
347
|
+
with np.errstate(over='ignore', invalid='ignore'):
|
|
348
|
+
eta = eta + data['offset']
|
|
349
|
+
if family == 'poisson':
|
|
350
|
+
return poisson_loglik(data['y'], eta)
|
|
351
|
+
return negative_binomial_loglik(data['y'], eta,
|
|
352
|
+
state['dispersion'] if dispersion is None else dispersion)
|
|
353
|
+
|
|
354
|
+
current_ll = likelihood(state['white'])
|
|
355
|
+
if not np.isfinite(current_ll):
|
|
356
|
+
raise ValueError('initial generalized state has nonfinite likelihood; rescale inputs or provide finite initial parameters')
|
|
357
|
+
saved = 0
|
|
358
|
+
for iteration in range(warmup + draws * thin):
|
|
359
|
+
warm = iteration < warmup
|
|
360
|
+
phase = 'warmup' if warm else 'sampling'
|
|
361
|
+
evaluations[phase] += 1 if iteration == 0 else 0
|
|
362
|
+
accepted_key, proposed_key = ('warmup_accepted', 'warmup_proposed') if warm else ('accepted', 'proposed')
|
|
363
|
+
state['white'], current_ll, used = elliptical_slice(state['white'], likelihood, rng, current_loglik=current_ll)
|
|
364
|
+
evaluations[phase] += used
|
|
365
|
+
counts['ess'][proposed_key] += 1
|
|
366
|
+
counts['ess'][accepted_key] += 1
|
|
367
|
+
|
|
368
|
+
def accept(candidate_ll, adjustment, kind):
|
|
369
|
+
counts[kind][proposed_key] += 1
|
|
370
|
+
ratio = candidate_ll - current_ll + adjustment
|
|
371
|
+
if not np.isfinite(candidate_ll) or np.isnan(ratio):
|
|
372
|
+
return False
|
|
373
|
+
if math.log1p(-float(rng.random())) < min(0., ratio):
|
|
374
|
+
counts[kind][accepted_key] += 1
|
|
375
|
+
return True
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
# Under a log random walk, add log(candidate/current) to the MH ratio.
|
|
379
|
+
for name, scale_key, mean, sd in (
|
|
380
|
+
('lambda_', 'lambda', prior.lambda_mean, prior.lambda_sd),
|
|
381
|
+
*((('lambda_cluster', 'cluster_lambda', prior.cluster_lambda_mean, prior.cluster_lambda_sd),) if g else ()),
|
|
382
|
+
):
|
|
383
|
+
old = state[name]
|
|
384
|
+
candidate, jacobian = _log_proposal(old, scales[scale_key], rng)
|
|
385
|
+
adjustment = gamma_logpdf(candidate, mean, sd) - gamma_logpdf(old, mean, sd) + jacobian
|
|
386
|
+
if np.isfinite(candidate) and candidate > 0 and np.isfinite(adjustment):
|
|
387
|
+
kwargs = {'lam' if name == 'lambda_' else 'cluster_lam': candidate}
|
|
388
|
+
candidate_ll = likelihood(state['white'], **kwargs)
|
|
389
|
+
evaluations[phase] += 1
|
|
390
|
+
else:
|
|
391
|
+
candidate_ll = -np.inf
|
|
392
|
+
if accept(candidate_ll, adjustment, scale_key):
|
|
393
|
+
state[name], current_ll = candidate, candidate_ll
|
|
394
|
+
|
|
395
|
+
if fixed is None:
|
|
396
|
+
# Independence proposals from the complete conditional selection
|
|
397
|
+
# prior cancel both indicator and active-relevance prior ratios.
|
|
398
|
+
for members in units:
|
|
399
|
+
proposed_r, proposed_delta = state['r'].copy(), state['delta'].copy()
|
|
400
|
+
proposed_r[members], proposed_delta[members] = 0., False
|
|
401
|
+
if rng.random() < state['pi']:
|
|
402
|
+
member = int(rng.choice(members))
|
|
403
|
+
proposed_r[member] = prior.sample_r(rng)
|
|
404
|
+
proposed_delta[member] = True
|
|
405
|
+
try:
|
|
406
|
+
proposed_factor = _gp_factor(data['Z'], proposed_r)
|
|
407
|
+
candidate_ll = likelihood(state['white'], root=proposed_factor)
|
|
408
|
+
evaluations[phase] += 1
|
|
409
|
+
except (ValueError, FloatingPointError, np.linalg.LinAlgError):
|
|
410
|
+
candidate_ll = -np.inf
|
|
411
|
+
if accept(candidate_ll, 0., 'selection'):
|
|
412
|
+
state['r'], state['delta'], factor, current_ll = proposed_r, proposed_delta, proposed_factor, candidate_ll
|
|
413
|
+
for index in np.flatnonzero(state['delta']):
|
|
414
|
+
old = state['r'][index]
|
|
415
|
+
candidate, jacobian = _log_proposal(old, scales['r'], rng)
|
|
416
|
+
adjustment = prior.log_r(candidate) - prior.log_r(old) + jacobian
|
|
417
|
+
candidate_ll = -np.inf
|
|
418
|
+
if np.isfinite(candidate) and candidate > 0 and np.isfinite(adjustment):
|
|
419
|
+
proposed_r = state['r'].copy()
|
|
420
|
+
proposed_r[index] = candidate
|
|
421
|
+
try:
|
|
422
|
+
proposed_factor = _gp_factor(data['Z'], proposed_r)
|
|
423
|
+
candidate_ll = likelihood(state['white'], root=proposed_factor)
|
|
424
|
+
evaluations[phase] += 1
|
|
425
|
+
except (ValueError, FloatingPointError, np.linalg.LinAlgError):
|
|
426
|
+
candidate_ll = -np.inf
|
|
427
|
+
if accept(candidate_ll, adjustment, 'r'):
|
|
428
|
+
state['r'], factor, current_ll = proposed_r, proposed_factor, candidate_ll
|
|
429
|
+
if units:
|
|
430
|
+
active = sum(bool(state['delta'][members].any()) for members in units)
|
|
431
|
+
state['pi'] = float(rng.beta(prior.inclusion_alpha + active,
|
|
432
|
+
prior.inclusion_beta + len(units) - active))
|
|
433
|
+
if not 0 < state['pi'] < 1:
|
|
434
|
+
raise FloatingPointError('inclusion-probability update reached a floating-point boundary; rescale its prior')
|
|
435
|
+
if family == 'negative_binomial':
|
|
436
|
+
old = state['dispersion']
|
|
437
|
+
candidate, jacobian = _log_proposal(old, scales['dispersion'], rng)
|
|
438
|
+
adjustment = (_log_gamma_shape_rate(candidate, prior.dispersion_shape, prior.dispersion_rate)
|
|
439
|
+
- _log_gamma_shape_rate(old, prior.dispersion_shape, prior.dispersion_rate) + jacobian)
|
|
440
|
+
candidate_ll = -np.inf
|
|
441
|
+
if np.isfinite(candidate) and candidate > 0 and np.isfinite(adjustment):
|
|
442
|
+
candidate_ll = likelihood(state['white'], dispersion=candidate)
|
|
443
|
+
evaluations[phase] += 1
|
|
444
|
+
if accept(candidate_ll, adjustment, 'dispersion'):
|
|
445
|
+
state['dispersion'], current_ll = candidate, candidate_ll
|
|
446
|
+
if family == 'survival':
|
|
447
|
+
_, _, _, eta = components(state['white'], factor, state['lambda_'], state['lambda_cluster'])
|
|
448
|
+
shapes, rates = baseline_posterior_parameters(data['event'], data['event_bin'], data['durations'], eta,
|
|
449
|
+
prior.baseline_shape, prior.baseline_rate)
|
|
450
|
+
baseline = rng.gamma(shapes, 1 / rates)
|
|
451
|
+
if not np.isfinite(baseline).all() or np.any(baseline <= 0):
|
|
452
|
+
raise FloatingPointError('baseline Gamma update reached a floating-point boundary; rescale times or prior')
|
|
453
|
+
state['baseline'] = baseline
|
|
454
|
+
current_ll = likelihood(state['white'])
|
|
455
|
+
evaluations[phase] += 1
|
|
456
|
+
if not np.isfinite(current_ll):
|
|
457
|
+
raise FloatingPointError('baseline update produced nonfinite likelihood')
|
|
458
|
+
if not warm and (iteration - warmup + 1) % thin == 0:
|
|
459
|
+
h, beta, u, _ = components(state['white'], factor, state['lambda_'], state['lambda_cluster'])
|
|
460
|
+
values = dict(beta=beta, r=state['r'], h=h, latent_white=state['white'][:n],
|
|
461
|
+
delta=state['delta'], pi=state['pi'], lambda_=state['lambda_'])
|
|
462
|
+
values['lambda'] = values.pop('lambda_')
|
|
463
|
+
if g:
|
|
464
|
+
values.update(u=u, lambda_cluster=state['lambda_cluster'])
|
|
465
|
+
if family == 'negative_binomial':
|
|
466
|
+
values['dispersion'] = state['dispersion']
|
|
467
|
+
if family == 'survival':
|
|
468
|
+
values['baseline_hazard'] = state['baseline']
|
|
469
|
+
for name, value in values.items():
|
|
470
|
+
samples[name][chain_index, saved] = value
|
|
471
|
+
saved += 1
|
|
472
|
+
for record in counts.values():
|
|
473
|
+
for phase_prefix in ('', 'warmup_'):
|
|
474
|
+
denominator = record[phase_prefix + 'proposed']
|
|
475
|
+
record[phase_prefix + 'rate'] = record[phase_prefix + 'accepted'] / denominator if denominator else None
|
|
476
|
+
chain_info.append(dict(chain=chain_index, acceptance=counts, likelihood_evaluations=evaluations,
|
|
477
|
+
seed_spawn_key=list(child.spawn_key)))
|
|
478
|
+
settings = dict(family=family, draws=draws, warmup=warmup, chains=chains, thin=thin,
|
|
479
|
+
seed=seed, standardize=bool(standardize), intercept=bool(intercept),
|
|
480
|
+
selection=selection, prior=asdict(prior), proposal_scales=scales,
|
|
481
|
+
r_fixed=None if fixed is None else fixed.tolist(), clustered=bool(g),
|
|
482
|
+
offset_used=offset is not None, beta_prior='normal',
|
|
483
|
+
baseline_convention='right_closed' if family == 'survival' else None)
|
|
484
|
+
sampling_info = dict(runtime_seconds=time.perf_counter() - start, chains=chain_info,
|
|
485
|
+
algorithm='noncentered ESS/log-MH/Gibbs', proposal_adaptation=False,
|
|
486
|
+
seed_entropy=int(sequence.entropy), warnings=messages)
|
|
487
|
+
from .result import BKMRFit
|
|
488
|
+
return BKMRFit(data, samples, settings, sampling_info)
|
kernelmix/_input.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Array input checks and stored training transformations (no pandas dependency)."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
from ._clusters import cluster_mask, encode_clusters
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def matrix(value, name, *, columns=None):
|
|
8
|
+
"""Convert a numeric 2D input; enforce exact named column order if given."""
|
|
9
|
+
if columns is not None and hasattr(value, 'columns'):
|
|
10
|
+
actual = [str(x) for x in value.columns]
|
|
11
|
+
if actual != list(columns):
|
|
12
|
+
raise ValueError(f'{name} columns must match training names and order: {list(columns)}')
|
|
13
|
+
try:
|
|
14
|
+
array = np.asarray(value, dtype=float)
|
|
15
|
+
except (TypeError, ValueError) as exc:
|
|
16
|
+
raise ValueError(f'{name} must contain numeric values') from exc
|
|
17
|
+
if array.ndim != 2:
|
|
18
|
+
raise ValueError(f'{name} must be a two-dimensional matrix')
|
|
19
|
+
return array.copy()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _names(value, explicit, count, prefix):
|
|
23
|
+
supplied = list(value.columns) if hasattr(value, 'columns') else None
|
|
24
|
+
names = explicit if explicit is not None else supplied
|
|
25
|
+
if names is None:
|
|
26
|
+
names = [f'{prefix}{j + 1}' for j in range(count)]
|
|
27
|
+
if isinstance(names, str):
|
|
28
|
+
raise ValueError(f'{prefix} names must be a sequence')
|
|
29
|
+
names = [str(name) for name in names]
|
|
30
|
+
if len(names) != count or len(set(names)) != count or any(not x for x in names):
|
|
31
|
+
raise ValueError(f'{prefix} names must be unique nonempty names, one per column')
|
|
32
|
+
return names
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def normalize_groups(groups, m):
|
|
36
|
+
if groups is None:
|
|
37
|
+
return None
|
|
38
|
+
values = np.asarray(groups, dtype=object)
|
|
39
|
+
if values.shape != (m,):
|
|
40
|
+
raise ValueError('groups must have one label per exposure')
|
|
41
|
+
result = []
|
|
42
|
+
for label in values:
|
|
43
|
+
if label is None or (isinstance(label, (float, np.floating)) and not np.isfinite(label)):
|
|
44
|
+
raise ValueError('groups cannot contain missing/nonfinite labels')
|
|
45
|
+
if not isinstance(label, (str, int, float, np.integer, np.floating)):
|
|
46
|
+
raise ValueError('group labels must be strings or finite numbers')
|
|
47
|
+
result.append(str(label))
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def prepare_data(y, Z, X=None, *, family='gaussian', standardize=True,
|
|
52
|
+
intercept=True, missing='raise', exposure_names=None,
|
|
53
|
+
covariate_names=None, groups=None, cluster=None):
|
|
54
|
+
"""Return copied model data, with joint complete-case rows and ddof=1 scaling."""
|
|
55
|
+
if family not in ('gaussian', 'binomial'):
|
|
56
|
+
raise ValueError("family must be 'gaussian' or 'binomial'")
|
|
57
|
+
if missing not in ('raise', 'drop'):
|
|
58
|
+
raise ValueError("missing must be 'raise' or 'drop'")
|
|
59
|
+
if not isinstance(standardize, (bool, np.bool_)) or not isinstance(intercept, (bool, np.bool_)):
|
|
60
|
+
raise ValueError('standardize and intercept must be boolean')
|
|
61
|
+
z = matrix(Z, 'Z')
|
|
62
|
+
if z.shape[1] == 0:
|
|
63
|
+
raise ValueError('Z must contain at least one exposure')
|
|
64
|
+
outcome = np.asarray(y, dtype=float)
|
|
65
|
+
if outcome.ndim != 1 or len(outcome) != len(z):
|
|
66
|
+
raise ValueError('y must be a vector with one value per row of Z')
|
|
67
|
+
x = np.empty((len(z), 0)) if X is None else matrix(X, 'X')
|
|
68
|
+
if len(x) != len(z):
|
|
69
|
+
raise ValueError('X and Z must have the same number of rows')
|
|
70
|
+
znames = _names(Z, exposure_names, z.shape[1], 'Z')
|
|
71
|
+
xnames = _names(X, covariate_names, x.shape[1], 'X')
|
|
72
|
+
if intercept and 'Intercept' in xnames:
|
|
73
|
+
raise ValueError('covariate name Intercept is reserved when intercept=True')
|
|
74
|
+
original_n = len(z)
|
|
75
|
+
# NaN denotes missingness. Infinity is invalid even with complete-case drop.
|
|
76
|
+
if any(np.isinf(a).any() for a in (z, outcome, x)):
|
|
77
|
+
raise ValueError('inputs cannot contain infinity')
|
|
78
|
+
keep = np.isfinite(outcome) & np.isfinite(z).all(axis=1) & np.isfinite(x).all(axis=1)
|
|
79
|
+
keep &= cluster_mask(cluster, original_n)
|
|
80
|
+
if missing == 'raise' and not keep.all():
|
|
81
|
+
raise ValueError("missing input values; use missing='drop' for joint complete cases")
|
|
82
|
+
rows = np.flatnonzero(keep)
|
|
83
|
+
z, outcome, x = z[keep], outcome[keep], x[keep]
|
|
84
|
+
if len(z) < 2:
|
|
85
|
+
raise ValueError('at least two complete observations are required')
|
|
86
|
+
if family == 'binomial' and not np.isin(outcome, [0, 1]).all():
|
|
87
|
+
raise ValueError('binomial y must contain only 0 and 1')
|
|
88
|
+
center = z.mean(axis=0) if standardize else np.zeros(z.shape[1])
|
|
89
|
+
scale = z.std(axis=0, ddof=1) if standardize else np.ones(z.shape[1])
|
|
90
|
+
if not np.isfinite(center).all() or not np.isfinite(scale).all() or (scale <= 0).any():
|
|
91
|
+
raise ValueError('standardization requires finite, nonconstant exposures')
|
|
92
|
+
design = np.column_stack((np.ones(len(x)), x)) if intercept else x.copy()
|
|
93
|
+
if design.shape[1]:
|
|
94
|
+
# Diagnose rank after column scaling so a change of measurement units
|
|
95
|
+
# cannot make an otherwise independent covariate look rank deficient.
|
|
96
|
+
# Keep the actual model design in the caller's original units.
|
|
97
|
+
magnitudes = np.max(np.abs(design), axis=0)
|
|
98
|
+
if (magnitudes == 0).any() or len(design) <= design.shape[1]:
|
|
99
|
+
raise ValueError('X design must have full column rank and fewer columns than observations; do not supply a duplicate intercept')
|
|
100
|
+
diagnostic_design = design / magnitudes
|
|
101
|
+
if np.linalg.matrix_rank(diagnostic_design) < design.shape[1]:
|
|
102
|
+
raise ValueError('X design must have full column rank and fewer columns than observations; do not supply a duplicate intercept')
|
|
103
|
+
transformed = (z - center) / scale
|
|
104
|
+
if not np.isfinite(transformed).all():
|
|
105
|
+
raise ValueError('exposure transformation overflowed; rescale inputs')
|
|
106
|
+
cluster_codes, cluster_levels = encode_clusters(cluster, original_n, rows)
|
|
107
|
+
return dict(y=outcome.copy(), Z=transformed, Z_original=z.copy(), X=design,
|
|
108
|
+
X_original=x.copy(), z_center=center, z_scale=scale, rows=rows,
|
|
109
|
+
original_n=original_n, exposure_names=znames,
|
|
110
|
+
covariate_names=(['Intercept'] + xnames if intercept else xnames),
|
|
111
|
+
groups=normalize_groups(groups, z.shape[1]),
|
|
112
|
+
cluster_codes=cluster_codes, cluster_levels=cluster_levels)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def prediction_matrix(value, names, *, name='Znew'):
|
|
116
|
+
"""Check prediction matrix, including exact DataFrame column order."""
|
|
117
|
+
array = matrix(value, name, columns=names)
|
|
118
|
+
if array.shape[1] != len(names) or not np.isfinite(array).all() or array.shape[0] == 0:
|
|
119
|
+
raise ValueError(f'{name} must have nonempty finite rows and {len(names)} columns')
|
|
120
|
+
return array
|