kernelmix 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kernelmix/__init__.py +36 -0
- kernelmix/_basis.py +299 -0
- kernelmix/_clusters.py +78 -0
- kernelmix/_generalized.py +488 -0
- kernelmix/_input.py +120 -0
- kernelmix/_kernel.py +132 -0
- kernelmix/_likelihoods.py +151 -0
- kernelmix/_prediction.py +43 -0
- kernelmix/_structure.py +326 -0
- kernelmix/_structured.py +886 -0
- kernelmix/_survival.py +358 -0
- kernelmix/diagnostics.py +77 -0
- kernelmix/effects.py +332 -0
- kernelmix/model.py +677 -0
- kernelmix/plots.py +205 -0
- kernelmix/priors.py +100 -0
- kernelmix/result.py +785 -0
- kernelmix/structured_result.py +1054 -0
- kernelmix-0.3.0.dist-info/METADATA +102 -0
- kernelmix-0.3.0.dist-info/RECORD +24 -0
- kernelmix-0.3.0.dist-info/WHEEL +5 -0
- kernelmix-0.3.0.dist-info/licenses/LICENSE +339 -0
- kernelmix-0.3.0.dist-info/licenses/NOTICE +23 -0
- kernelmix-0.3.0.dist-info/top_level.txt +1 -0
kernelmix/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)
|