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/model.py ADDED
@@ -0,0 +1,677 @@
1
+ """Bayesian kernel machine regression (BKMR) fitting and sampler selection.
2
+
3
+ The default Gaussian/probit target follows bkmr 0.2.2, with different
4
+ Metropolis-Hastings proposals. Other supported inputs select the generalized
5
+ or structured sampler.
6
+ No global random state is used and chains run sequentially.
7
+ """
8
+
9
+ import math
10
+ import time
11
+ from dataclasses import asdict
12
+
13
+ import numpy as np
14
+ from scipy.linalg import cho_solve, solve_triangular
15
+ from scipy.stats import truncnorm
16
+
17
+ from ._input import prepare_data
18
+ from ._kernel import covariance_components
19
+ from .priors import Prior, gamma_logpdf
20
+
21
+
22
+ def _count(value, name, minimum):
23
+ if (
24
+ isinstance(value, (bool, np.bool_))
25
+ or not isinstance(value, (int, np.integer))
26
+ or value < minimum
27
+ ):
28
+ raise ValueError(f"{name} must be an integer >= {minimum}")
29
+ return int(value)
30
+
31
+
32
+ def _positive(value, name):
33
+ if (
34
+ isinstance(value, (bool, np.bool_))
35
+ or not np.isscalar(value)
36
+ or not np.isfinite(value)
37
+ or value <= 0
38
+ ):
39
+ raise ValueError(f"{name} must be finite and positive")
40
+ return float(value)
41
+
42
+
43
+ def _initial(initial, data, prior, family, selection, fixed, rng):
44
+ n, m = data["Z"].shape
45
+ p = data["X"].shape[1]
46
+ if initial is None:
47
+ initial = {}
48
+ if not isinstance(initial, dict):
49
+ raise ValueError("initial must be a dictionary shared by chains, or None")
50
+ allowed = {"beta", "r", "lambda", "sigma2", "delta", "pi", "ystar"}
51
+ if set(initial) - allowed:
52
+ raise ValueError(f"unknown initial parameters: {sorted(set(initial) - allowed)}")
53
+ delta = np.ones(m, dtype=bool)
54
+ if selection == "group":
55
+ delta[:] = False
56
+ for label in dict.fromkeys(data["groups"]):
57
+ members = np.flatnonzero(np.asarray(data["groups"]) == label)
58
+ delta[rng.choice(members)] = True
59
+ if "delta" in initial:
60
+ d = np.asarray(initial["delta"])
61
+ if d.shape != (m,) or not np.isin(d, [0, 1]).all():
62
+ raise ValueError("initial delta must be boolean/0-1 with one entry per exposure")
63
+ delta = d.astype(bool)
64
+ if selection == "none" and not delta.all():
65
+ raise ValueError("initial delta must be all true without selection")
66
+ if selection == "group":
67
+ for label in dict.fromkeys(data["groups"]):
68
+ if delta[np.asarray(data["groups"]) == label].sum() > 1:
69
+ raise ValueError("initial delta includes multiple exposures in one group")
70
+ if fixed is not None:
71
+ if "r" in initial and not np.array_equal(np.asarray(initial["r"]), fixed):
72
+ raise ValueError("initial r conflicts with r_fixed")
73
+ r = fixed.copy()
74
+ delta = r > 0
75
+ elif "r" in initial:
76
+ r = np.asarray(initial["r"], dtype=float).copy()
77
+ if r.shape != (m,) or not np.isfinite(r).all() or (r < 0).any():
78
+ raise ValueError("initial r must be a finite nonnegative exposure vector")
79
+ if (r[~delta] != 0).any() or any(not np.isfinite(prior.log_r(x)) for x in r[delta]):
80
+ raise ValueError(
81
+ "initial r must be zero when excluded, and inside the positive prior support when active"
82
+ )
83
+ else:
84
+ r = np.array([prior.sample_r(rng) if active else 0.0 for active in delta])
85
+ beta = np.asarray(initial.get("beta", np.zeros(p)), dtype=float).copy()
86
+ if beta.shape != (p,) or not np.isfinite(beta).all():
87
+ raise ValueError("initial beta must be finite with one entry per design column")
88
+ lambda_ = _positive(initial.get("lambda", prior.lambda_mean), "initial lambda")
89
+ default_variance = max(float(np.var(data["y"])), 0.1)
90
+ sigma2 = _positive(
91
+ initial.get("sigma2", 1.0 if family == "binomial" else default_variance), "initial sigma2"
92
+ )
93
+ if family == "binomial" and sigma2 != 1:
94
+ raise ValueError("binomial sigma2 is fixed at 1")
95
+ pi = _positive(initial.get("pi", 1.0 if selection == "none" else 0.5), "initial pi")
96
+ if selection == "none" and pi != 1 or selection != "none" and not pi < 1:
97
+ raise ValueError(
98
+ "initial pi must equal 1 without selection, or lie in (0,1) with selection"
99
+ )
100
+ latent = None
101
+ if family == "binomial":
102
+ latent = np.asarray(
103
+ initial.get("ystar", np.where(data["y"] == 1, 0.5, -0.5)), dtype=float
104
+ ).copy()
105
+ if (
106
+ latent.shape != (n,)
107
+ or not np.isfinite(latent).all()
108
+ or not np.all(np.where(data["y"] == 1, latent > 0, latent < 0))
109
+ ):
110
+ raise ValueError("initial ystar must be finite and have the observed binary signs")
111
+ elif "ystar" in initial:
112
+ raise ValueError("initial ystar is only supported for binomial outcomes")
113
+ return beta, r, lambda_, sigma2, delta, pi, latent
114
+
115
+
116
+ def _log_likelihood(lower, residual, sigma2):
117
+ whitened = solve_triangular(lower, residual, lower=True, check_finite=False)
118
+ return -np.log(np.diag(lower)).sum() - float(whitened @ whitened) / (2 * sigma2)
119
+
120
+
121
+ def _latent_sweep(y, latent, mean, lower, rng):
122
+ # Coordinate Gibbs for the marginal truncated multivariate normal. Independent
123
+ # univariate draws with marginal sd would discard the GP correlations.
124
+ precision = cho_solve((lower, True), np.eye(len(y)), check_finite=False)
125
+ residual = latent - mean
126
+ for i in rng.permutation(len(y)):
127
+ conditional_mean = (
128
+ mean[i] - (precision[i] @ residual - precision[i, i] * residual[i]) / precision[i, i]
129
+ )
130
+ sd = math.sqrt(1 / precision[i, i])
131
+ boundary = -conditional_mean / sd
132
+ a, b = (boundary, np.inf) if y[i] == 1 else (-np.inf, boundary)
133
+ latent[i] = truncnorm.rvs(a, b, loc=conditional_mean, scale=sd, random_state=rng)
134
+ residual[i] = latent[i] - mean[i]
135
+ return latent
136
+
137
+
138
+ def _check_probit_design(y, design):
139
+ """Reject separated flat-prior designs using a column-unit invariant LP.
140
+
141
+ Positive column rescaling preserves the set of attainable linear
142
+ predictors. Only this diagnostic uses rescaled columns; the model and its
143
+ coefficient parameterization retain the caller's original design.
144
+ """
145
+ if not design.shape[1]:
146
+ return
147
+ from scipy.optimize import linprog
148
+
149
+ scales = np.max(np.abs(design), axis=0)
150
+ if not np.isfinite(scales).all() or (scales == 0).any():
151
+ raise ValueError("probit adjustment design must have finite nonzero columns")
152
+ signed_design = (2 * y - 1)[:, None] * (design / scales)
153
+ # In the signed-margin cone, a nonzero predictor with nonnegative margins
154
+ # has positive summed margin. The coefficient box bounds the optimization
155
+ # without excluding such a direction. Scaling first prevents solver
156
+ # tolerances and the objective cutoff from depending on covariate units.
157
+ separation = linprog(
158
+ -signed_design.sum(axis=0),
159
+ A_ub=-signed_design,
160
+ b_ub=np.zeros(len(y)),
161
+ bounds=[(-1, 1)] * design.shape[1],
162
+ method="highs",
163
+ )
164
+ if not separation.success:
165
+ raise ValueError("could not establish nonseparation for the flat probit beta prior")
166
+ if -separation.fun > 1e-8:
167
+ raise ValueError(
168
+ "separated binomial adjustment design has an improper posterior under the flat beta prior"
169
+ )
170
+
171
+
172
+ def fit(
173
+ y,
174
+ Z,
175
+ X=None,
176
+ *,
177
+ family="gaussian",
178
+ draws=1000,
179
+ warmup=1000,
180
+ chains=4,
181
+ thin=1,
182
+ seed=None,
183
+ standardize=True,
184
+ intercept=True,
185
+ selection="none",
186
+ groups=None,
187
+ prior=None,
188
+ r_fixed=None,
189
+ missing="raise",
190
+ exposure_names=None,
191
+ covariate_names=None,
192
+ initial=None,
193
+ proposal_scales=None,
194
+ cluster=None,
195
+ offset=None,
196
+ event=None,
197
+ baseline_breaks=None,
198
+ kernel="gaussian",
199
+ kernel_alpha=1,
200
+ approximation="dense",
201
+ n_components=32,
202
+ knots=None,
203
+ random_slopes=None,
204
+ random_covariance=None,
205
+ random_scale=2,
206
+ correlation=None,
207
+ observation_time=None,
208
+ correlation_parameter=None,
209
+ variance_design=None,
210
+ entry=None,
211
+ upper=None,
212
+ subject=None,
213
+ n_causes=None,
214
+ ):
215
+ """Fit Bayesian kernel machine regression with chains run one after another.
216
+
217
+ Rows of Z are exposure profiles z; rows of X are adjustment covariates x.
218
+ The exposure function h can describe nonlinear associations and interactions;
219
+ beta gives the linear adjustment coefficients. A kernel describes similarity
220
+ between exposure profiles. A Gaussian process (GP) prior uses it to model h.
221
+ Relevance r_m controls exposure m's contribution
222
+ to that similarity. Group selection permits at most one active exposure in
223
+ each included group.
224
+
225
+ ``draws`` counts saved states per chain after ``warmup`` and ``thin``.
226
+ The probit model fixes residual variance at one. Dense calculations store
227
+ matrices with one row and column per retained observation and require roughly
228
+ cubic work in that observation count. A successful fit does not prove that
229
+ the chains have converged.
230
+
231
+ For the default Gaussian/probit sampler, ``initial`` accepts beta, r,
232
+ lambda, sigma2, delta and pi; probit also accepts ystar. ``proposal_scales``
233
+ sets fixed log-scale proposal standard deviations for r and lambda, each
234
+ 0.3 by default. Other samplers' keys and defaults are in the model guides.
235
+ ``cluster`` adds a random intercept shared by observations with the same
236
+ label. ``offset`` is a count log offset; survival uses event and baseline_breaks.
237
+
238
+ The paper's tau is the variance multiplying the kernel. The default
239
+ Gaussian/probit sampler stores ``lambda=tau/sigma2`` and ``lambda_cluster``
240
+ as the random-intercept variance divided by sigma2. Default count/survival
241
+ samplers store those variances directly and use normal coefficient priors.
242
+
243
+ Alternative kernels, finite GP bases, random slopes, residual correlation,
244
+ variance regression, extended survival inputs or nondefault structured
245
+ settings (such as random_scale) select the structured sampler. It stores
246
+ ``lambda=tau`` directly and uses separate priors. The structured-model guide
247
+ defines these inputs, initialization shapes and prediction requirements.
248
+ """
249
+ if not isinstance(kernel, str) or kernel not in ("gaussian", "matern32", "rational_quadratic"):
250
+ raise ValueError("kernel must be gaussian, matern32 or rational_quadratic")
251
+ _positive(kernel_alpha, "kernel_alpha")
252
+ if kernel != "rational_quadratic" and kernel_alpha != 1:
253
+ raise ValueError("kernel_alpha applies only to the rational-quadratic kernel")
254
+ if not isinstance(approximation, str) or approximation not in ("dense", "pp", "rff"):
255
+ raise ValueError("approximation must be dense, pp or rff")
256
+ _count(n_components, "n_components", 1)
257
+ _positive(random_scale, "random_scale")
258
+ if random_scale != 2 and cluster is None:
259
+ raise ValueError("random_scale requires cluster labels")
260
+ extended = (
261
+ kernel != "gaussian"
262
+ or kernel_alpha != 1
263
+ or approximation != "dense"
264
+ or n_components != 32
265
+ or random_scale != 2
266
+ or any(
267
+ value is not None
268
+ for value in (
269
+ knots,
270
+ random_slopes,
271
+ random_covariance,
272
+ correlation,
273
+ observation_time,
274
+ correlation_parameter,
275
+ variance_design,
276
+ entry,
277
+ upper,
278
+ subject,
279
+ n_causes,
280
+ )
281
+ )
282
+ )
283
+ if extended:
284
+ from ._structured import fit_structured
285
+
286
+ return fit_structured(
287
+ y,
288
+ Z,
289
+ X,
290
+ family=family,
291
+ draws=draws,
292
+ warmup=warmup,
293
+ chains=chains,
294
+ thin=thin,
295
+ seed=seed,
296
+ standardize=standardize,
297
+ intercept=intercept,
298
+ selection=selection,
299
+ groups=groups,
300
+ prior=prior,
301
+ r_fixed=r_fixed,
302
+ missing=missing,
303
+ exposure_names=exposure_names,
304
+ covariate_names=covariate_names,
305
+ initial=initial,
306
+ proposal_scales=proposal_scales,
307
+ cluster=cluster,
308
+ offset=offset,
309
+ event=event,
310
+ baseline_breaks=baseline_breaks,
311
+ kernel=kernel,
312
+ kernel_alpha=kernel_alpha,
313
+ approximation=approximation,
314
+ n_components=n_components,
315
+ knots=knots,
316
+ random_slopes=random_slopes,
317
+ random_covariance=random_covariance,
318
+ random_scale=random_scale,
319
+ correlation=correlation,
320
+ observation_time=observation_time,
321
+ correlation_parameter=correlation_parameter,
322
+ variance_design=variance_design,
323
+ entry=entry,
324
+ upper=upper,
325
+ subject=subject,
326
+ n_causes=n_causes,
327
+ )
328
+ if family in ("poisson", "negative_binomial", "survival"):
329
+ from ._generalized import fit_generalized
330
+
331
+ return fit_generalized(
332
+ y,
333
+ Z,
334
+ X,
335
+ family=family,
336
+ draws=draws,
337
+ warmup=warmup,
338
+ chains=chains,
339
+ thin=thin,
340
+ seed=seed,
341
+ standardize=standardize,
342
+ intercept=intercept,
343
+ selection=selection,
344
+ groups=groups,
345
+ prior=prior,
346
+ r_fixed=r_fixed,
347
+ missing=missing,
348
+ exposure_names=exposure_names,
349
+ covariate_names=covariate_names,
350
+ initial=initial,
351
+ proposal_scales=proposal_scales,
352
+ cluster=cluster,
353
+ offset=offset,
354
+ event=event,
355
+ baseline_breaks=baseline_breaks,
356
+ )
357
+ if offset is not None or event is not None or baseline_breaks is not None:
358
+ raise ValueError(
359
+ "offset is only for counts; event and baseline_breaks are only for survival"
360
+ )
361
+ start = time.perf_counter()
362
+ draws, warmup, chains, thin = (
363
+ _count(draws, "draws", 2),
364
+ _count(warmup, "warmup", 0),
365
+ _count(chains, "chains", 1),
366
+ _count(thin, "thin", 1),
367
+ )
368
+ if seed is not None:
369
+ seed = _count(seed, "seed", 0)
370
+ if selection not in ("none", "component", "group"):
371
+ raise ValueError("selection must be 'none', 'component', or 'group'")
372
+ if selection == "group" and groups is None:
373
+ raise ValueError("group selection requires groups")
374
+ if selection != "group" and groups is not None:
375
+ raise ValueError("groups may only be supplied with group selection")
376
+ if prior is None:
377
+ prior = Prior()
378
+ if isinstance(prior, dict):
379
+ prior = Prior(**prior)
380
+ if not isinstance(prior, Prior):
381
+ raise ValueError("prior must be a Prior, dictionary, or None")
382
+ clustered = cluster is not None
383
+ scales = {"r": 0.3, "lambda": 0.3}
384
+ if clustered:
385
+ scales["cluster_lambda"] = 0.3
386
+ if proposal_scales is not None:
387
+ if not isinstance(proposal_scales, dict) or set(proposal_scales) - set(scales):
388
+ raise ValueError(f"proposal_scales accepts only {list(scales)}")
389
+ scales.update(proposal_scales)
390
+ scales = {key: _positive(value, f"proposal_scales[{key}]") for key, value in scales.items()}
391
+ data = prepare_data(
392
+ y,
393
+ Z,
394
+ X,
395
+ family=family,
396
+ standardize=standardize,
397
+ intercept=intercept,
398
+ missing=missing,
399
+ exposure_names=exposure_names,
400
+ covariate_names=covariate_names,
401
+ groups=groups,
402
+ cluster=cluster,
403
+ )
404
+ n, m = data["Z"].shape
405
+ p = data["X"].shape[1]
406
+ fixed = None
407
+ if r_fixed is not None:
408
+ fixed = np.asarray(r_fixed, dtype=float).copy()
409
+ if fixed.shape != (m,) or not np.isfinite(fixed).all() or (fixed < 0).any():
410
+ raise ValueError(
411
+ "r_fixed must be a finite nonnegative vector with one entry per exposure"
412
+ )
413
+ if selection != "none":
414
+ raise ValueError("r_fixed disallows variable selection")
415
+ # A flat beta prior is improper for separated probit designs. Check the
416
+ # linear adjustment design; the Gaussian-process prior is proper.
417
+ if family == "binomial" and p:
418
+ _check_probit_design(data["y"], data["X"])
419
+ # Conservative allocation guards for the exact dense implementation.
420
+ if 8 * n * n * (9 if clustered else 8) > 512 * 1024**2:
421
+ raise ValueError(
422
+ "dense covariance working storage exceeds the 512 MiB safety budget; use fewer observations"
423
+ )
424
+ saved_bytes = (
425
+ chains * draws * (8 * (p + m + 3 + int(clustered) + (n if family == "binomial" else 0)) + m)
426
+ )
427
+ if saved_bytes > 1024**3:
428
+ raise ValueError(
429
+ "saved posterior arrays exceed the 1 GiB safety budget; reduce draws/chains or fit size"
430
+ )
431
+ shape = (chains, draws)
432
+ samples = dict(
433
+ beta=np.empty(shape + (p,)),
434
+ r=np.empty(shape + (m,)),
435
+ lambda_=np.empty(shape),
436
+ sigma2=np.empty(shape),
437
+ delta=np.empty(shape + (m,), dtype=bool),
438
+ pi=np.empty(shape),
439
+ )
440
+ samples["lambda"] = samples.pop("lambda_")
441
+ if clustered:
442
+ samples["lambda_cluster"] = np.empty(shape)
443
+ if family == "binomial":
444
+ samples["ystar"] = np.empty(shape + (n,))
445
+ selection_units = (
446
+ [np.array([j]) for j in range(m)]
447
+ if selection == "component"
448
+ else [
449
+ np.flatnonzero(np.asarray(data["groups"]) == label)
450
+ for label in dict.fromkeys(data["groups"])
451
+ ]
452
+ if selection == "group"
453
+ else []
454
+ )
455
+ seed_sequence = np.random.SeedSequence(seed)
456
+ children = seed_sequence.spawn(chains)
457
+ chain_info = []
458
+ for chain_index, child in enumerate(children):
459
+ rng = np.random.default_rng(child)
460
+ chain_initial = initial
461
+ lambda_cluster = None
462
+ if isinstance(initial, dict) and "lambda_cluster" in initial:
463
+ if not clustered:
464
+ raise ValueError("initial lambda_cluster requires cluster labels")
465
+ chain_initial = initial.copy()
466
+ lambda_cluster = _positive(
467
+ chain_initial.pop("lambda_cluster"), "initial lambda_cluster"
468
+ )
469
+ if clustered and lambda_cluster is None:
470
+ lambda_cluster = prior.cluster_lambda_mean
471
+ beta, r, lambda_, sigma2, delta, pi, latent = _initial(
472
+ chain_initial, data, prior, family, selection, fixed, rng
473
+ )
474
+ _, _, lower = covariance_components(
475
+ data["Z"],
476
+ r,
477
+ lambda_,
478
+ cluster_codes=data["cluster_codes"],
479
+ lambda_cluster=lambda_cluster,
480
+ )
481
+ count_keys = (
482
+ ("lambda", "r", "selection", "cluster_lambda")
483
+ if clustered
484
+ else ("lambda", "r", "selection")
485
+ )
486
+ counts = {
487
+ key: {"accepted": 0, "proposed": 0, "warmup_accepted": 0, "warmup_proposed": 0}
488
+ for key in count_keys
489
+ }
490
+ saved = 0
491
+ for iteration in range(warmup + draws * thin):
492
+ is_warmup = iteration < warmup
493
+ if family == "binomial":
494
+ latent = _latent_sweep(data["y"], latent, data["X"] @ beta, lower, rng)
495
+ continuous = latent
496
+ else:
497
+ continuous = data["y"]
498
+ if p:
499
+ solved_x = cho_solve((lower, True), data["X"], check_finite=False)
500
+ precision_beta = data["X"].T @ solved_x
501
+ beta_lower = np.linalg.cholesky((precision_beta + precision_beta.T) / 2)
502
+ beta_mean = cho_solve(
503
+ (beta_lower, True), solved_x.T @ continuous, check_finite=False
504
+ )
505
+ beta = beta_mean + math.sqrt(sigma2) * solve_triangular(
506
+ beta_lower.T, rng.normal(size=p), lower=False, check_finite=False
507
+ )
508
+ residual = continuous - data["X"] @ beta
509
+ if family == "gaussian":
510
+ white = solve_triangular(lower, residual, lower=True, check_finite=False)
511
+ rate = prior.sigma2_rate + float(white @ white) / 2
512
+ sigma2 = 1 / rng.gamma(prior.sigma2_shape + n / 2, 1 / rate)
513
+ if not np.isfinite(sigma2) or sigma2 <= 0:
514
+ raise FloatingPointError("sigma2 update overflowed; rescale the outcome/prior")
515
+ current_ll = _log_likelihood(lower, residual, sigma2)
516
+
517
+ def attempt(
518
+ candidate_r, candidate_lambda, log_adjustment, kind, candidate_cluster=None
519
+ ):
520
+ nonlocal r, lambda_, lambda_cluster, lower, current_ll
521
+ counter = counts[kind]
522
+ counter["warmup_proposed" if is_warmup else "proposed"] += 1
523
+ if not np.isfinite(log_adjustment):
524
+ return False
525
+ try:
526
+ candidate_cluster = (
527
+ lambda_cluster if candidate_cluster is None else candidate_cluster
528
+ )
529
+ _, _, candidate_lower = covariance_components(
530
+ data["Z"],
531
+ candidate_r,
532
+ candidate_lambda,
533
+ cluster_codes=data["cluster_codes"],
534
+ lambda_cluster=candidate_cluster,
535
+ )
536
+ candidate_ll = _log_likelihood(candidate_lower, residual, sigma2)
537
+ except (ValueError, FloatingPointError, np.linalg.LinAlgError):
538
+ return False
539
+ ratio = candidate_ll - current_ll + log_adjustment
540
+ if np.isnan(ratio):
541
+ return False
542
+ uniform = rng.random()
543
+ if uniform == 0 or math.log(uniform) < min(0.0, ratio):
544
+ r, lambda_, lower, current_ll = (
545
+ candidate_r.copy(),
546
+ candidate_lambda,
547
+ candidate_lower,
548
+ candidate_ll,
549
+ )
550
+ lambda_cluster = candidate_cluster
551
+ counter["warmup_accepted" if is_warmup else "accepted"] += 1
552
+ return True
553
+ return False
554
+
555
+ log_lambda = math.log(lambda_) + rng.normal(scale=scales["lambda"])
556
+ with np.errstate(over="ignore", under="ignore"):
557
+ candidate = float(np.exp(log_lambda))
558
+ if np.isfinite(candidate) and candidate > 0:
559
+ adjustment = (
560
+ gamma_logpdf(candidate, prior.lambda_mean, prior.lambda_sd)
561
+ - gamma_logpdf(lambda_, prior.lambda_mean, prior.lambda_sd)
562
+ + log_lambda
563
+ - math.log(lambda_)
564
+ )
565
+ attempt(r, candidate, adjustment, "lambda")
566
+ else:
567
+ attempt(r, lambda_, -np.inf, "lambda")
568
+ if clustered:
569
+ log_cluster = math.log(lambda_cluster) + rng.normal(scale=scales["cluster_lambda"])
570
+ with np.errstate(over="ignore", under="ignore"):
571
+ candidate_cluster = float(np.exp(log_cluster))
572
+ if np.isfinite(candidate_cluster) and candidate_cluster > 0:
573
+ adjustment = (
574
+ gamma_logpdf(
575
+ candidate_cluster, prior.cluster_lambda_mean, prior.cluster_lambda_sd
576
+ )
577
+ - gamma_logpdf(
578
+ lambda_cluster, prior.cluster_lambda_mean, prior.cluster_lambda_sd
579
+ )
580
+ + log_cluster
581
+ - math.log(lambda_cluster)
582
+ )
583
+ attempt(r, lambda_, adjustment, "cluster_lambda", candidate_cluster)
584
+ else:
585
+ attempt(r, lambda_, -np.inf, "cluster_lambda")
586
+ if selection != "none":
587
+ for unit in selection_units:
588
+ proposed_delta = delta.copy()
589
+ candidate_r = r.copy()
590
+ proposed_delta[unit] = False
591
+ candidate_r[unit] = 0
592
+ if rng.random() < pi:
593
+ member = int(rng.choice(unit))
594
+ proposed_delta[member] = True
595
+ candidate_r[member] = prior.sample_r(rng)
596
+ # Independence proposal is the exact conditional prior:
597
+ # Bernoulli(pi), uniform member, then relevance prior. Its
598
+ # forward/reverse densities cancel the entire prior ratio.
599
+ if attempt(candidate_r, lambda_, 0.0, "selection"):
600
+ delta = proposed_delta
601
+ included = sum(bool(delta[unit].any()) for unit in selection_units)
602
+ pi = float(
603
+ rng.beta(
604
+ prior.inclusion_alpha + included,
605
+ prior.inclusion_beta + len(selection_units) - included,
606
+ )
607
+ )
608
+ if not 0 < pi < 1:
609
+ raise FloatingPointError(
610
+ "inclusion update hit a floating-point boundary; use less extreme Beta hyperparameters"
611
+ )
612
+ if fixed is None:
613
+ for j in np.flatnonzero(delta):
614
+ log_r = math.log(r[j]) + rng.normal(scale=scales["r"])
615
+ candidate_r = r.copy()
616
+ with np.errstate(over="ignore", under="ignore"):
617
+ proposed_r = float(np.exp(log_r))
618
+ if np.isfinite(proposed_r) and proposed_r > 0:
619
+ candidate_r[j] = proposed_r
620
+ adjustment = (
621
+ prior.log_r(candidate_r[j]) - prior.log_r(r[j]) + log_r - math.log(r[j])
622
+ )
623
+ attempt(candidate_r, lambda_, adjustment, "r")
624
+ else:
625
+ attempt(candidate_r, lambda_, -np.inf, "r")
626
+ if not is_warmup and (iteration - warmup + 1) % thin == 0:
627
+ for key, value in (
628
+ ("beta", beta),
629
+ ("r", r),
630
+ ("lambda", lambda_),
631
+ ("sigma2", sigma2),
632
+ ("delta", delta),
633
+ ("pi", pi),
634
+ ):
635
+ samples[key][chain_index, saved] = value
636
+ if clustered:
637
+ samples["lambda_cluster"][chain_index, saved] = lambda_cluster
638
+ if family == "binomial":
639
+ samples["ystar"][chain_index, saved] = latent
640
+ saved += 1
641
+ for counter in counts.values():
642
+ counter["rate"] = (
643
+ counter["accepted"] / counter["proposed"] if counter["proposed"] else None
644
+ )
645
+ counter["warmup_rate"] = (
646
+ counter["warmup_accepted"] / counter["warmup_proposed"]
647
+ if counter["warmup_proposed"]
648
+ else None
649
+ )
650
+ chain_info.append(
651
+ {"chain": chain_index, "acceptance": counts, "seed_spawn_key": list(child.spawn_key)}
652
+ )
653
+ settings = dict(
654
+ family=family,
655
+ draws=draws,
656
+ warmup=warmup,
657
+ chains=chains,
658
+ thin=thin,
659
+ seed=seed,
660
+ standardize=bool(standardize),
661
+ intercept=bool(intercept),
662
+ selection=selection,
663
+ prior=asdict(prior),
664
+ proposal_scales=scales,
665
+ r_fixed=None if fixed is None else fixed.tolist(),
666
+ clustered=clustered,
667
+ )
668
+ sampling_info = dict(
669
+ runtime_seconds=time.perf_counter() - start,
670
+ chains=chain_info,
671
+ algorithm="dense marginal MH/Gibbs",
672
+ proposal_adaptation=False,
673
+ seed_entropy=int(seed_sequence.entropy),
674
+ )
675
+ from .result import BKMRFit
676
+
677
+ return BKMRFit(data, samples, settings, sampling_info)