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,886 @@
1
+ """Noncentered inference for structured residuals, survival histories and GP bases."""
2
+
3
+ import math
4
+ import time
5
+ import warnings
6
+ from dataclasses import asdict
7
+
8
+ import numpy as np
9
+ from scipy.linalg import solve_triangular
10
+ from scipy.special import log_ndtr, ndtr
11
+
12
+ from ._basis import basis_matrix, prepare_basis
13
+ from ._clusters import canonical_label, cluster_mask
14
+ from ._generalized import _count, _log_proposal, _positive, elliptical_slice
15
+ from ._input import matrix, prepare_data
16
+ from ._likelihoods import negative_binomial_loglik, poisson_loglik
17
+ from ._structure import _standardized_residual, correlation_blocks, gaussian_loglik
18
+ from ._survival import extended_survival_loglik, prepare_extended_survival
19
+ from .priors import Prior, gamma_logpdf
20
+
21
+
22
+ def _original(value, n, name, *, two_dimensional=False, infinity=False):
23
+ a = np.asarray(value, dtype=float)
24
+ if two_dimensional and a.ndim == 1:
25
+ a = a[:, None]
26
+ shape_ok = a.ndim == 2 and len(a) == n if two_dimensional else a.shape == (n,)
27
+ if not shape_ok or (not infinity and np.isinf(a).any()) or np.isneginf(a).any():
28
+ raise ValueError(f"{name} must have one numeric row per original observation")
29
+ return a.copy()
30
+
31
+
32
+ def _rank(a, name, *, no_constant=False):
33
+ if not a.shape[1]:
34
+ return
35
+ check = np.column_stack((np.ones(len(a)), a)) if no_constant else a
36
+ magnitude = np.max(np.abs(check), axis=0)
37
+ if (magnitude == 0).any() or np.linalg.matrix_rank(check / magnitude) != check.shape[1]:
38
+ suffix = " and must not contain an intercept in its column span" if no_constant else ""
39
+ raise ValueError(f"{name} columns must be linearly independent{suffix}")
40
+
41
+
42
+ def _prepare(
43
+ y,
44
+ Z,
45
+ X,
46
+ *,
47
+ family,
48
+ standardize,
49
+ intercept,
50
+ missing,
51
+ exposure_names,
52
+ covariate_names,
53
+ groups,
54
+ cluster,
55
+ offset,
56
+ event,
57
+ baseline_breaks,
58
+ random_slopes,
59
+ variance_design,
60
+ correlation,
61
+ observation_time,
62
+ entry,
63
+ upper,
64
+ subject,
65
+ n_causes,
66
+ ):
67
+ outcome = np.asarray(y, dtype=float)
68
+ if outcome.ndim != 1:
69
+ raise ValueError("y must be a one-dimensional vector")
70
+ n = len(outcome)
71
+ z = matrix(Z, "Z")
72
+ x = np.empty((n, 0)) if X is None else matrix(X, "X")
73
+ if len(z) != n or len(x) != n or np.isinf(outcome).any():
74
+ raise ValueError("outcome, exposures and covariates must have matching finite rows")
75
+ extra = {}
76
+ for name, value, is_matrix, infinity in (
77
+ ("random_slopes", random_slopes, True, False),
78
+ ("variance_design", variance_design, True, False),
79
+ ("offset", offset, False, False),
80
+ ("event", event, False, False),
81
+ ("entry", entry, False, False),
82
+ ("upper", upper, False, True),
83
+ ("observation_time", observation_time, False, False),
84
+ ):
85
+ if value is not None:
86
+ extra[name] = _original(value, n, name, two_dimensional=is_matrix, infinity=infinity)
87
+ if family != "survival" and any(
88
+ v is not None for v in (entry, upper, subject, n_causes, event, baseline_breaks)
89
+ ):
90
+ raise ValueError('survival inputs require family="survival"')
91
+ if family not in ("poisson", "negative_binomial") and offset is not None:
92
+ raise ValueError("offset applies only to count models")
93
+ if family != "gaussian" and (
94
+ variance_design is not None or correlation is not None or observation_time is not None
95
+ ):
96
+ raise ValueError("variance_design and residual correlation apply only to Gaussian outcomes")
97
+ if random_slopes is not None and cluster is None:
98
+ raise ValueError("random_slopes requires cluster labels")
99
+ if correlation is not None and cluster is None:
100
+ raise ValueError("residual correlation requires cluster labels")
101
+ if correlation is None and observation_time is not None:
102
+ raise ValueError("observation_time requires a residual correlation model")
103
+ keep = np.isfinite(outcome) & np.isfinite(z).all(axis=1) & np.isfinite(x).all(axis=1)
104
+ keep &= cluster_mask(cluster, n)
105
+ for a in extra.values():
106
+ available = ~np.isnan(a)
107
+ keep &= available.all(axis=1) if a.ndim == 2 else available
108
+ dropped_subjects = []
109
+ if subject is not None:
110
+ labels = np.asarray(subject, dtype=object)
111
+ keep &= cluster_mask(labels, n)
112
+ by_subject = {}
113
+ for i, label in enumerate(labels):
114
+ if label is None or isinstance(label, (float, np.floating)) and np.isnan(label):
115
+ continue
116
+ by_subject.setdefault(canonical_label(label), []).append(i)
117
+ for identity, rows in by_subject.items():
118
+ if not keep[rows].all():
119
+ keep[rows] = False
120
+ dropped_subjects.append(identity[1])
121
+ if missing not in ("raise", "drop"):
122
+ raise ValueError("missing must be raise or drop")
123
+ if missing == "raise" and not keep.all():
124
+ raise ValueError(
125
+ 'missing input values; missing="drop" removes whole incomplete subject histories'
126
+ )
127
+ masked = outcome.copy()
128
+ masked[~keep] = np.nan
129
+ data = prepare_data(
130
+ masked,
131
+ Z,
132
+ X,
133
+ family="gaussian",
134
+ standardize=standardize,
135
+ intercept=intercept,
136
+ missing=missing,
137
+ exposure_names=exposure_names,
138
+ covariate_names=covariate_names,
139
+ groups=groups,
140
+ cluster=cluster,
141
+ )
142
+ rows = data["rows"]
143
+ data["y"] = outcome[rows].copy()
144
+ extra = {key: a[rows].copy() for key, a in extra.items()}
145
+ data["dropped_subject_levels"] = dropped_subjects
146
+ data["variance_design"] = extra.get("variance_design", np.empty((len(rows), 0)))
147
+ _rank(data["variance_design"], "variance_design", no_constant=True)
148
+ slopes = extra.get("random_slopes", np.empty((len(rows), 0)))
149
+ data["random_design"] = (
150
+ np.column_stack((np.ones(len(rows)), slopes))
151
+ if cluster is not None
152
+ else np.empty((len(rows), 0))
153
+ )
154
+ _rank(data["random_design"], "random coefficient design")
155
+ if family == "binomial" and not np.isin(data["y"], [0, 1]).all():
156
+ raise ValueError("binomial outcomes must be zero or one")
157
+ if family in ("poisson", "negative_binomial"):
158
+ if (data["y"] < 0).any() or (data["y"] != np.floor(data["y"])).any():
159
+ raise ValueError("count outcomes must be nonnegative integers")
160
+ data["offset"] = extra.get("offset", np.zeros(len(rows)))
161
+ if family == "survival":
162
+ if event is None:
163
+ raise ValueError("survival outcomes require event indicators or cause codes")
164
+ labels = None if subject is None else np.asarray(subject, dtype=object)[rows]
165
+ data.update(
166
+ prepare_extended_survival(
167
+ data["y"],
168
+ extra["event"],
169
+ entry=extra.get("entry"),
170
+ upper=extra.get("upper"),
171
+ subject=labels,
172
+ n_causes=n_causes,
173
+ baseline_breaks=baseline_breaks,
174
+ )
175
+ )
176
+ data["observation_time"] = extra.get("observation_time")
177
+ if correlation is None:
178
+ data["correlation_matrix"], kind = None, "independent"
179
+ elif isinstance(correlation, str):
180
+ if correlation not in ("ar1", "exponential"):
181
+ raise ValueError("correlation must be ar1, exponential, a matrix, or None")
182
+ data["correlation_matrix"], kind = None, correlation
183
+ if observation_time is None:
184
+ raise ValueError("parametric residual correlation requires observation_time")
185
+ else:
186
+ corr = np.asarray(correlation, dtype=float)
187
+ if corr.shape != (n, n) or not np.isfinite(corr).all():
188
+ raise ValueError("fixed correlation must be a finite original-row square matrix")
189
+ data["correlation_matrix"], kind = corr[np.ix_(rows, rows)].copy(), "fixed"
190
+ return data, kind
191
+
192
+
193
+ def _random_factor(scale_raw, correlation_raw, bound):
194
+ """Proper induced correlation prior and bounded uniform standard deviations."""
195
+ k, q = scale_raw.shape
196
+ result = np.zeros((k, q, q))
197
+ for cause in range(k):
198
+ lower = np.eye(q)
199
+ lower[np.tril_indices(q, -1)] = correlation_raw[cause]
200
+ lower /= np.linalg.norm(lower, axis=1)[:, None]
201
+ sd = bound * ndtr(scale_raw[cause])
202
+ if not np.isfinite(sd).all() or (sd <= 0).any():
203
+ raise FloatingPointError("random-effect scales reached a floating-point boundary")
204
+ result[cause] = sd[:, None] * lower
205
+ return result
206
+
207
+
208
+ class _Layout:
209
+ def __init__(self):
210
+ self.parts = {}
211
+ self.size = 0
212
+
213
+ def add(self, name, shape):
214
+ size = math.prod(shape)
215
+ self.parts[name] = (slice(self.size, self.size + size), tuple(shape))
216
+ self.size += size
217
+
218
+ def get(self, white, name):
219
+ sl, shape = self.parts[name]
220
+ return white[sl].reshape(shape)
221
+
222
+
223
+ def fit_structured(
224
+ y,
225
+ Z,
226
+ X=None,
227
+ *,
228
+ family="gaussian",
229
+ draws=1000,
230
+ warmup=1000,
231
+ chains=4,
232
+ thin=1,
233
+ seed=None,
234
+ standardize=True,
235
+ intercept=True,
236
+ selection="none",
237
+ groups=None,
238
+ prior=None,
239
+ r_fixed=None,
240
+ missing="raise",
241
+ exposure_names=None,
242
+ covariate_names=None,
243
+ initial=None,
244
+ proposal_scales=None,
245
+ cluster=None,
246
+ offset=None,
247
+ event=None,
248
+ baseline_breaks=None,
249
+ kernel="gaussian",
250
+ kernel_alpha=1,
251
+ approximation="dense",
252
+ n_components=32,
253
+ knots=None,
254
+ random_slopes=None,
255
+ random_covariance=None,
256
+ random_scale=2,
257
+ correlation=None,
258
+ observation_time=None,
259
+ correlation_parameter=None,
260
+ variance_design=None,
261
+ entry=None,
262
+ upper=None,
263
+ subject=None,
264
+ n_causes=None,
265
+ ):
266
+ """Fit the documented structured model with a dense or finite-basis GP."""
267
+ started = time.perf_counter()
268
+ if family not in ("gaussian", "binomial", "poisson", "negative_binomial", "survival"):
269
+ raise ValueError("unknown outcome family")
270
+ draws, warmup = _count(draws, "draws", 2), _count(warmup, "warmup", 0)
271
+ chains, thin = _count(chains, "chains", 1), _count(thin, "thin", 1)
272
+ if seed is not None:
273
+ seed = _count(seed, "seed", 0)
274
+ if selection not in ("none", "component", "group") or (
275
+ (selection == "group") != (groups is not None)
276
+ ):
277
+ raise ValueError(
278
+ "selection must be none/component/group; groups are required only for group selection"
279
+ )
280
+ defaults = dict(
281
+ lambda_mean=1.0,
282
+ lambda_sd=0.5,
283
+ cluster_lambda_mean=1.0,
284
+ cluster_lambda_sd=0.5,
285
+ sigma2_shape=2.0,
286
+ sigma2_rate=1.0,
287
+ )
288
+ if prior is None:
289
+ prior = Prior(**defaults)
290
+ elif isinstance(prior, dict):
291
+ prior = Prior(**{**defaults, **prior})
292
+ if not isinstance(prior, Prior):
293
+ raise ValueError("prior must be Prior, a dictionary or None")
294
+ data, corr_kind = _prepare(
295
+ y,
296
+ Z,
297
+ X,
298
+ family=family,
299
+ standardize=standardize,
300
+ intercept=intercept,
301
+ missing=missing,
302
+ exposure_names=exposure_names,
303
+ covariate_names=covariate_names,
304
+ groups=groups,
305
+ cluster=cluster,
306
+ offset=offset,
307
+ event=event,
308
+ baseline_breaks=baseline_breaks,
309
+ random_slopes=random_slopes,
310
+ variance_design=variance_design,
311
+ correlation=correlation,
312
+ observation_time=observation_time,
313
+ entry=entry,
314
+ upper=upper,
315
+ subject=subject,
316
+ n_causes=n_causes,
317
+ )
318
+ n, m = data["Z"].shape
319
+ p = data["X"].shape[1]
320
+ k = data.get("n_causes", 1)
321
+ g = len(data["cluster_levels"])
322
+ q = data["random_design"].shape[1]
323
+ random_scale = _positive(random_scale, "random_scale")
324
+ if random_covariance is not None and not g:
325
+ raise ValueError("random_covariance requires cluster labels")
326
+ fixed_random = None
327
+ if random_covariance is not None:
328
+ fixed_random = np.asarray(random_covariance, dtype=float)
329
+ if (
330
+ fixed_random.shape != (q, q)
331
+ or not np.isfinite(fixed_random).all()
332
+ or not np.allclose(fixed_random, fixed_random.T, rtol=0, atol=1e-12)
333
+ ):
334
+ raise ValueError(
335
+ "random_covariance must be a finite symmetric matrix matching the random design"
336
+ )
337
+ try:
338
+ np.linalg.cholesky(fixed_random)
339
+ except np.linalg.LinAlgError as exc:
340
+ raise ValueError("random_covariance must be positive definite") from exc
341
+ data["fixed_random_covariance"] = None if fixed_random is None else fixed_random.copy()
342
+ corr_inferred = corr_kind in ("ar1", "exponential") and correlation_parameter is None
343
+ if corr_kind in ("independent", "fixed") and correlation_parameter is not None:
344
+ raise ValueError("correlation_parameter applies only to ar1/exponential correlation")
345
+ initial_corr = 0.0 if corr_kind == "ar1" else 1.0 if corr_kind == "exponential" else None
346
+ if correlation_parameter is not None:
347
+ if (
348
+ isinstance(correlation_parameter, (bool, np.bool_))
349
+ or not np.isscalar(correlation_parameter)
350
+ or not np.isfinite(correlation_parameter)
351
+ ):
352
+ raise ValueError("correlation_parameter must be a finite scalar")
353
+ initial_corr = float(correlation_parameter)
354
+ if corr_kind != "independent":
355
+ correlation_blocks(
356
+ data["cluster_codes"],
357
+ data["observation_time"],
358
+ corr_kind,
359
+ initial_corr,
360
+ data["correlation_matrix"],
361
+ )
362
+ sequence = np.random.SeedSequence(seed)
363
+ streams = sequence.spawn(chains + 1)
364
+ transformed_knots = None
365
+ if knots is not None:
366
+ raw = np.asarray(knots, dtype=float)
367
+ if raw.ndim != 2 or raw.shape[1] != m or not np.isfinite(raw).all():
368
+ raise ValueError("knots must have finite rows in original exposure units")
369
+ transformed_knots = (raw - data["z_center"]) / data["z_scale"]
370
+ basis = prepare_basis(
371
+ data["Z"],
372
+ approximation=approximation,
373
+ kernel=kernel,
374
+ alpha=kernel_alpha,
375
+ n_components=n_components,
376
+ knots=transformed_knots,
377
+ rng=np.random.default_rng(streams[0]),
378
+ )
379
+ b = basis["n_features"]
380
+ mode = basis["approximation"]
381
+ data["basis_meta"] = {
382
+ name: value for name, value in basis.items() if not isinstance(value, np.ndarray)
383
+ }
384
+ for name, value in basis.items():
385
+ if isinstance(value, np.ndarray):
386
+ data["basis_" + name] = value.copy()
387
+ anchor_rows = n if mode == "dense" else len(basis["knots"]) if mode == "pp" else 0
388
+ layout = _Layout()
389
+ layout.add("gp", (k, b))
390
+ layout.add("beta", (k, p))
391
+ layout.add("random", (k, g, q))
392
+ layout.add("gamma", (data["variance_design"].shape[1],))
393
+ if g and fixed_random is None:
394
+ layout.add("random_scale", (k, q))
395
+ layout.add("random_correlation", (k, q * (q - 1) // 2))
396
+ if corr_inferred:
397
+ layout.add("correlation", (1,))
398
+ shapes = {
399
+ "beta": (k, p),
400
+ "r": (m,),
401
+ "lambda": (k,),
402
+ "sigma2": (),
403
+ "delta": (m,),
404
+ "pi": (),
405
+ "gp_weight": (k, b),
406
+ }
407
+ if anchor_rows:
408
+ shapes["gp_anchor"] = (k, anchor_rows)
409
+ if g:
410
+ shapes.update(random_coeff=(k, g, q), random_covariance=(k, q, q))
411
+ if data["variance_design"].shape[1]:
412
+ shapes["gamma"] = (data["variance_design"].shape[1],)
413
+ if corr_kind in ("ar1", "exponential"):
414
+ shapes["correlation_parameter"] = ()
415
+ if family == "negative_binomial":
416
+ shapes["dispersion"] = ()
417
+ if family == "survival":
418
+ shapes["baseline_hazard"] = (k, len(data["baseline_breaks"]) + 1)
419
+ saved_elements = chains * draws * sum(math.prod(shape) for shape in shapes.values())
420
+ if 8 * (saved_elements + 8 * n * b + 4 * layout.size) > 512 * 1024**2:
421
+ raise ValueError(
422
+ "structured fit exceeds the 512 MiB working-memory limit; reduce saved states or basis size"
423
+ )
424
+ samples = {
425
+ name: np.empty((chains, draws) + shape, dtype=bool if name == "delta" else float)
426
+ for name, shape in shapes.items()
427
+ }
428
+ fixed = None if r_fixed is None else np.asarray(r_fixed, dtype=float)
429
+ if fixed is not None and (
430
+ fixed.shape != (m,)
431
+ or not np.isfinite(fixed).all()
432
+ or (fixed < 0).any()
433
+ or selection != "none"
434
+ ):
435
+ raise ValueError(
436
+ "r_fixed must be nonnegative with one entry per exposure and disallows selection"
437
+ )
438
+ units = (
439
+ [np.array([j]) for j in range(m)]
440
+ if selection == "component"
441
+ else [
442
+ np.flatnonzero(np.asarray(data["groups"]) == label)
443
+ for label in dict.fromkeys(data["groups"])
444
+ ]
445
+ if selection == "group"
446
+ else []
447
+ )
448
+ scales = {"r": 0.3, "lambda": 0.3}
449
+ if family == "negative_binomial":
450
+ scales["dispersion"] = 0.3
451
+ if family == "survival":
452
+ scales["baseline_hazard"] = 0.3
453
+ if proposal_scales is not None:
454
+ if not isinstance(proposal_scales, dict) or set(proposal_scales) - set(scales):
455
+ raise ValueError(f"proposal_scales accepts only {list(scales)} for this model")
456
+ scales.update(proposal_scales)
457
+ scales = {name: _positive(value, f"proposal_scales[{name}]") for name, value in scales.items()}
458
+ if initial is None:
459
+ initial = {}
460
+ allowed = {"beta", "gp_weight", "r", "delta", "pi", "lambda", "sigma2"}
461
+ if family == "negative_binomial":
462
+ allowed.add("dispersion")
463
+ if family == "survival":
464
+ allowed.add("baseline_hazard")
465
+ if not isinstance(initial, dict) or set(initial) - allowed:
466
+ raise ValueError(f"initial accepts only {sorted(allowed)} for this model")
467
+ messages = []
468
+ if prior.lambda_mean / prior.lambda_sd**2 <= 2:
469
+ messages.append(
470
+ "The supplied GP amplitude prior does not guarantee finite second exp-Gaussian moments; finite-chain means do not prove moment existence."
471
+ )
472
+ if family == "gaussian" and prior.sigma2_shape <= 1:
473
+ messages.append("The supplied inverse-gamma residual prior has no finite mean.")
474
+ if family == "survival":
475
+ if intercept:
476
+ messages.append(
477
+ "Survival intercept and baseline scale are separated by their priors; consider intercept=False."
478
+ )
479
+ if not (data["event"] != 0).any():
480
+ messages.append("No observed events; survival estimates depend strongly on the priors.")
481
+ if (data["entry"] > 0).any():
482
+ messages.append(
483
+ "Latent priors describe the included entrant cohort, not an inception-population frailty distribution."
484
+ )
485
+ if g and (g < q + 1 or all(np.sum(data["cluster_codes"] == j) == 1 for j in range(g))):
486
+ messages.append(
487
+ "Few repeated clusters for random covariance estimation; variance components may be weakly identified."
488
+ )
489
+ if mode != "dense":
490
+ messages.append(
491
+ "Inference conditions on the selected finite GP basis; it is not exact dense-GP inference."
492
+ )
493
+ for message in messages:
494
+ warnings.warn(message, RuntimeWarning, stacklevel=2)
495
+ chain_info = []
496
+ for chain, stream in enumerate(streams[1:]):
497
+ rng = np.random.default_rng(stream)
498
+ delta = np.ones(m, dtype=bool)
499
+ if selection != "none":
500
+ delta[:] = False
501
+ for members in units:
502
+ if rng.random() < 0.5:
503
+ delta[rng.choice(members)] = True
504
+ if "delta" in initial:
505
+ supplied = np.asarray(initial["delta"])
506
+ if supplied.shape != (m,) or not np.isin(supplied, [0, 1]).all():
507
+ raise ValueError("initial delta must be zero/one per exposure")
508
+ delta = supplied.astype(bool)
509
+ if fixed is not None:
510
+ if "delta" in initial and not np.array_equal(delta, fixed > 0):
511
+ raise ValueError("initial delta conflicts with r_fixed")
512
+ delta = fixed > 0
513
+ if selection == "none" and fixed is None and not delta.all():
514
+ raise ValueError("all exposures must be active without selection")
515
+ if selection == "group" and any(delta[u].sum() > 1 for u in units):
516
+ raise ValueError("at most one exposure per group may be active")
517
+ r = (
518
+ fixed.copy()
519
+ if fixed is not None
520
+ else np.array([prior.sample_r(rng) if active else 0.0 for active in delta])
521
+ )
522
+ if "r" in initial:
523
+ supplied = np.asarray(initial["r"], dtype=float)
524
+ if fixed is not None and not np.array_equal(supplied, fixed):
525
+ raise ValueError("initial r conflicts with r_fixed")
526
+ r = supplied.copy()
527
+ if (
528
+ r.shape != (m,)
529
+ or not np.isfinite(r).all()
530
+ or (r[~delta] != 0).any()
531
+ or (fixed is None and any(not np.isfinite(prior.log_r(v)) for v in r[delta]))
532
+ ):
533
+ raise ValueError("initial relevance is inconsistent with selection or its prior")
534
+ white = np.zeros(layout.size)
535
+ for name, target, scale in [("beta", "beta", prior.beta_sd), ("gp_weight", "gp", 1.0)]:
536
+ if name in initial:
537
+ a = np.asarray(initial[name], dtype=float)
538
+ sl, shape = layout.parts[target]
539
+ if k == 1 and a.shape == shape[1:]:
540
+ a = a[None, :]
541
+ if a.shape != shape or not np.isfinite(a).all():
542
+ raise ValueError(f"initial {name} has an invalid shape")
543
+ white[sl] = a.ravel() / scale
544
+ amplitude = np.asarray(initial.get("lambda", np.full(k, prior.lambda_mean)), dtype=float)
545
+ if amplitude.ndim == 0:
546
+ amplitude = np.full(k, float(amplitude))
547
+ if amplitude.shape != (k,) or not np.isfinite(amplitude).all() or (amplitude <= 0).any():
548
+ raise ValueError("initial lambda must be positive per cause")
549
+ sigma2 = _positive(
550
+ initial.get(
551
+ "sigma2", max(float(np.var(data["y"])), 0.1) if family == "gaussian" else 1.0
552
+ ),
553
+ "initial sigma2",
554
+ )
555
+ if family != "gaussian" and sigma2 != 1:
556
+ raise ValueError("non-Gaussian sigma2 is fixed at one")
557
+ phi = (
558
+ _positive(
559
+ initial.get("dispersion", prior.dispersion_shape / prior.dispersion_rate),
560
+ "initial dispersion",
561
+ )
562
+ if family == "negative_binomial"
563
+ else None
564
+ )
565
+ baseline = np.asarray(
566
+ initial.get(
567
+ "baseline_hazard",
568
+ np.full(
569
+ shapes.get("baseline_hazard", (0, 0)),
570
+ prior.baseline_shape / prior.baseline_rate,
571
+ ),
572
+ ),
573
+ dtype=float,
574
+ )
575
+ if family == "survival" and (
576
+ baseline.shape != shapes["baseline_hazard"]
577
+ or not np.isfinite(baseline).all()
578
+ or (baseline <= 0).any()
579
+ ):
580
+ raise ValueError(
581
+ "initial baseline_hazard must be positive with shape (causes, intervals)"
582
+ )
583
+ pi = float(initial.get("pi", 1.0 if selection == "none" else 0.5))
584
+ if (
585
+ not np.isfinite(pi)
586
+ or (selection == "none" and pi != 1)
587
+ or (selection != "none" and not 0 < pi < 1)
588
+ ):
589
+ raise ValueError("initial pi is inconsistent with selection")
590
+ factor = basis_matrix(data["Z"], r, basis)
591
+
592
+ def components(w, f=None, amp=None):
593
+ f = factor if f is None else f
594
+ amp = amplitude if amp is None else amp
595
+ gp = (f @ layout.get(w, "gp").T) * np.sqrt(amp)[None, :]
596
+ beta = layout.get(w, "beta") * prior.beta_sd
597
+ eta = gp + data["X"] @ beta.T
598
+ cov = np.empty((k, 0, 0))
599
+ coeff = np.empty((k, 0, 0))
600
+ if g:
601
+ lower = (
602
+ np.repeat(np.linalg.cholesky(fixed_random)[None], k, axis=0)
603
+ if fixed_random is not None
604
+ else _random_factor(
605
+ layout.get(w, "random_scale"),
606
+ layout.get(w, "random_correlation"),
607
+ random_scale,
608
+ )
609
+ )
610
+ coeff = np.einsum("kgq,krq->kgr", layout.get(w, "random"), lower)
611
+ cov = lower @ lower.transpose(0, 2, 1)
612
+ eta += np.einsum(
613
+ "nkq,nq->nk",
614
+ coeff[:, data["cluster_codes"], :].transpose(1, 0, 2),
615
+ data["random_design"],
616
+ )
617
+ gamma = layout.get(w, "gamma")
618
+ logvar = data["variance_design"] @ gamma
619
+ corr = (
620
+ (
621
+ float(np.tanh(layout.get(w, "correlation")[0]))
622
+ if corr_kind == "ar1"
623
+ else math.exp(float(layout.get(w, "correlation")[0]))
624
+ )
625
+ if corr_inferred
626
+ else initial_corr
627
+ )
628
+ blocks = (
629
+ correlation_blocks(
630
+ data["cluster_codes"],
631
+ data["observation_time"],
632
+ corr_kind,
633
+ corr,
634
+ data["correlation_matrix"],
635
+ )
636
+ if corr_kind != "independent"
637
+ else []
638
+ )
639
+ return gp, beta, eta, coeff, cov, gamma, logvar, corr, blocks
640
+
641
+ def likelihood(w, f=None, amp=None):
642
+ try:
643
+ _, _, eta, _, _, _, logvar, _, blocks = components(w, f, amp)
644
+ if not np.isfinite(eta).all() or not np.isfinite(logvar).all():
645
+ return -np.inf
646
+ if family == "gaussian":
647
+ return gaussian_loglik(data["y"], eta[:, 0], sigma2, logvar, blocks)
648
+ if family == "binomial":
649
+ return float(log_ndtr((2 * data["y"] - 1) * eta[:, 0]).sum())
650
+ if family == "poisson":
651
+ return poisson_loglik(data["y"], eta[:, 0] + data["offset"])
652
+ if family == "negative_binomial":
653
+ return negative_binomial_loglik(data["y"], eta[:, 0] + data["offset"], phi)
654
+ return extended_survival_loglik(eta, baseline, data)
655
+ except (FloatingPointError, OverflowError, ValueError, np.linalg.LinAlgError):
656
+ return -np.inf
657
+
658
+ current = likelihood(white)
659
+ if not np.isfinite(current):
660
+ raise ValueError("initial structured state has a nonfinite likelihood")
661
+ counters = {
662
+ name: dict(proposed=0, accepted=0, warmup_proposed=0, warmup_accepted=0)
663
+ for name in [*scales, "selection"]
664
+ }
665
+ evaluations = 1
666
+ saved = 0
667
+
668
+ def accept(name, old_ll, new_ll, extra, warm):
669
+ record = counters[name]
670
+ prefix = "warmup_" if warm else ""
671
+ record[prefix + "proposed"] += 1
672
+ ok = np.isfinite(new_ll) and math.log1p(-float(rng.random())) < new_ll - old_ll + extra
673
+ if ok:
674
+ record[prefix + "accepted"] += 1
675
+ return ok
676
+
677
+ for iteration in range(warmup + draws * thin):
678
+ warm = iteration < warmup
679
+ white, current, calls = elliptical_slice(white, likelihood, rng, current_loglik=current)
680
+ evaluations += calls
681
+ for cause in range(k):
682
+ candidate, jacobian = _log_proposal(amplitude[cause], scales["lambda"], rng)
683
+ if np.isfinite(candidate) and candidate > 0:
684
+ proposed = amplitude.copy()
685
+ proposed[cause] = candidate
686
+ new_ll = likelihood(white, amp=proposed)
687
+ evaluations += 1
688
+ extra = (
689
+ gamma_logpdf(candidate, prior.lambda_mean, prior.lambda_sd)
690
+ - gamma_logpdf(amplitude[cause], prior.lambda_mean, prior.lambda_sd)
691
+ + jacobian
692
+ )
693
+ if accept("lambda", current, new_ll, extra, warm):
694
+ amplitude, current = proposed, new_ll
695
+ if fixed is None:
696
+ for j in np.flatnonzero(delta):
697
+ candidate, jacobian = _log_proposal(r[j], scales["r"], rng)
698
+ if not np.isfinite(prior.log_r(candidate)):
699
+ continue
700
+ proposed = r.copy()
701
+ proposed[j] = candidate
702
+ try:
703
+ new_factor = basis_matrix(data["Z"], proposed, basis)
704
+ except (FloatingPointError, OverflowError, np.linalg.LinAlgError):
705
+ continue
706
+ new_ll = likelihood(white, f=new_factor)
707
+ evaluations += 1
708
+ if accept(
709
+ "r",
710
+ current,
711
+ new_ll,
712
+ prior.log_r(candidate) - prior.log_r(r[j]) + jacobian,
713
+ warm,
714
+ ):
715
+ r, factor, current = proposed, new_factor, new_ll
716
+ for members in units:
717
+ proposed_delta = delta.copy()
718
+ proposed_delta[members] = False
719
+ proposed_r = r.copy()
720
+ proposed_r[members] = 0
721
+ if rng.random() < pi:
722
+ j = int(rng.choice(members))
723
+ proposed_delta[j] = True
724
+ proposed_r[j] = prior.sample_r(rng)
725
+ if np.array_equal(proposed_r, r):
726
+ continue
727
+ new_factor = basis_matrix(data["Z"], proposed_r, basis)
728
+ new_ll = likelihood(white, f=new_factor)
729
+ evaluations += 1
730
+ # The proposal draws this unit from its conditional selection/relevance prior.
731
+ if accept("selection", current, new_ll, 0.0, warm):
732
+ r, delta, factor, current = proposed_r, proposed_delta, new_factor, new_ll
733
+ if units:
734
+ active = sum(bool(delta[u].any()) for u in units)
735
+ pi = float(
736
+ rng.beta(
737
+ prior.inclusion_alpha + active, prior.inclusion_beta + len(units) - active
738
+ )
739
+ )
740
+ if family == "gaussian":
741
+ _, _, eta, _, _, _, logvar, _, blocks = components(white)
742
+ residual = _standardized_residual(data["y"], eta[:, 0], 1.0, logvar)
743
+ if blocks:
744
+ quadratic = 0.0
745
+ for indices, _, lower in blocks:
746
+ transformed = solve_triangular(
747
+ lower, residual[indices], lower=True, check_finite=False
748
+ )
749
+ quadratic += float(transformed @ transformed)
750
+ else:
751
+ quadratic = float(residual @ residual)
752
+ sigma2 = 1 / float(
753
+ rng.gamma(prior.sigma2_shape + n / 2, 1 / (prior.sigma2_rate + quadratic / 2))
754
+ )
755
+ current = likelihood(white)
756
+ evaluations += 1
757
+ if family == "negative_binomial":
758
+ candidate, jacobian = _log_proposal(phi, scales["dispersion"], rng)
759
+ old = phi
760
+ phi = candidate
761
+ new_ll = likelihood(white)
762
+ evaluations += 1
763
+ extra = (
764
+ (
765
+ (prior.dispersion_shape - 1) * (math.log(candidate) - math.log(old))
766
+ - prior.dispersion_rate * (candidate - old)
767
+ + jacobian
768
+ )
769
+ if np.isfinite(candidate) and candidate > 0
770
+ else -np.inf
771
+ )
772
+ if accept("dispersion", current, new_ll, extra, warm):
773
+ current = new_ll
774
+ else:
775
+ phi = old
776
+ if family == "survival":
777
+ for cause in range(k):
778
+ for j in range(baseline.shape[1]):
779
+ old = baseline[cause, j]
780
+ candidate, jacobian = _log_proposal(old, scales["baseline_hazard"], rng)
781
+ baseline[cause, j] = candidate
782
+ new_ll = likelihood(white)
783
+ evaluations += 1
784
+ extra = (
785
+ (
786
+ (prior.baseline_shape - 1) * (math.log(candidate) - math.log(old))
787
+ - prior.baseline_rate * (candidate - old)
788
+ + jacobian
789
+ )
790
+ if np.isfinite(candidate) and candidate > 0
791
+ else -np.inf
792
+ )
793
+ if accept("baseline_hazard", current, new_ll, extra, warm):
794
+ current = new_ll
795
+ else:
796
+ baseline[cause, j] = old
797
+ if not np.isfinite(current):
798
+ raise FloatingPointError("structured state left finite likelihood support")
799
+ if not warm and (iteration - warmup + 1) % thin == 0:
800
+ gp, beta, _, coeff, covariance, gamma, _, corr, _ = components(white)
801
+ values = dict(
802
+ beta=beta,
803
+ r=r,
804
+ delta=delta,
805
+ pi=pi,
806
+ sigma2=sigma2,
807
+ gp_weight=layout.get(white, "gp"),
808
+ )
809
+ values["lambda"] = amplitude
810
+ if anchor_rows:
811
+ anchor = (
812
+ gp.T
813
+ if mode == "dense"
814
+ else (
815
+ basis_matrix(basis["knots"], r, basis)
816
+ @ layout.get(white, "gp").T
817
+ * np.sqrt(amplitude)[None, :]
818
+ ).T
819
+ )
820
+ values["gp_anchor"] = anchor
821
+ if g:
822
+ values.update(random_coeff=coeff, random_covariance=covariance)
823
+ if gamma.size:
824
+ values["gamma"] = gamma
825
+ if corr_kind in ("ar1", "exponential"):
826
+ values["correlation_parameter"] = corr
827
+ if family == "negative_binomial":
828
+ values["dispersion"] = phi
829
+ if family == "survival":
830
+ values["baseline_hazard"] = baseline
831
+ for name, value in values.items():
832
+ samples[name][chain, saved] = value
833
+ saved += 1
834
+ for counter in counters.values():
835
+ for prefix in ("", "warmup_"):
836
+ count = counter[prefix + "proposed"]
837
+ counter[prefix + "rate"] = counter[prefix + "accepted"] / count if count else None
838
+ chain_info.append(
839
+ dict(
840
+ chain=chain,
841
+ acceptance=counters,
842
+ likelihood_evaluations=evaluations,
843
+ seed_spawn_key=list(stream.spawn_key),
844
+ )
845
+ )
846
+ settings = dict(
847
+ engine="structured",
848
+ family=family,
849
+ draws=draws,
850
+ warmup=warmup,
851
+ chains=chains,
852
+ thin=thin,
853
+ seed=seed,
854
+ standardize=bool(standardize),
855
+ intercept=bool(intercept),
856
+ selection=selection,
857
+ prior=asdict(prior),
858
+ r_fixed=None if fixed is None else fixed.tolist(),
859
+ clustered=bool(g),
860
+ random_scale=random_scale,
861
+ random_covariance_fixed=fixed_random is not None,
862
+ correlation=corr_kind,
863
+ correlation_fixed=not corr_inferred,
864
+ correlation_parameter=correlation_parameter,
865
+ kernel=kernel,
866
+ kernel_alpha=float(kernel_alpha),
867
+ approximation=mode,
868
+ n_causes=k,
869
+ active_cause=1,
870
+ amplitude_convention="direct_tau",
871
+ offset_used=offset is not None,
872
+ proposal_scales=scales,
873
+ beta_prior="normal",
874
+ entrant_cohort_conditional=True,
875
+ )
876
+ info = dict(
877
+ runtime_seconds=time.perf_counter() - started,
878
+ chains=chain_info,
879
+ algorithm="noncentered ESS/log-MH/Gaussian-variance-Gibbs",
880
+ proposal_adaptation=False,
881
+ seed_entropy=int(sequence.entropy),
882
+ warnings=messages,
883
+ )
884
+ from .structured_result import StructuredFit
885
+
886
+ return StructuredFit(data, samples, settings, info)