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.
@@ -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