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/result.py ADDED
@@ -0,0 +1,785 @@
1
+ """Posterior summaries, joint predictions, and portable fitted objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ import zipfile
9
+ from copy import deepcopy
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ from scipy.special import ndtr
15
+
16
+ from .diagnostics import diagnose
17
+
18
+
19
+ def _probability(value):
20
+ if isinstance(value, (bool, np.bool_)) or not np.isscalar(value):
21
+ raise ValueError("probability must be between zero and one")
22
+ value = float(value)
23
+ if not np.isfinite(value) or not 0 < value < 1:
24
+ raise ValueError("probability must be between zero and one")
25
+ return value
26
+
27
+
28
+ def _integer(value, name, minimum=1):
29
+ if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)):
30
+ raise ValueError(f"{name} must be an integer at least {minimum}")
31
+ if value < minimum:
32
+ raise ValueError(f"{name} must be an integer at least {minimum}")
33
+ return int(value)
34
+
35
+
36
+ def _freeze(array):
37
+ copied = np.array(array, copy=True)
38
+ copied.setflags(write=False)
39
+ return copied
40
+
41
+
42
+ def _matrix(value, columns, names, name):
43
+ if hasattr(value, "columns"):
44
+ if list(map(str, value.columns)) != list(names):
45
+ raise ValueError(f"{name} columns must match the training names and order")
46
+ try:
47
+ result = np.asarray(value, dtype=float)
48
+ except (ValueError, TypeError) as exc:
49
+ raise ValueError(f"{name} must contain numeric values") from exc
50
+ if result.ndim == 1:
51
+ result = result.reshape(1, -1)
52
+ if result.ndim != 2 or result.shape[1] != columns or len(result) == 0:
53
+ raise ValueError(f"{name} must have shape (rows, {columns}) with at least one row")
54
+ if not np.isfinite(result).all():
55
+ raise ValueError(f"{name} must contain finite values")
56
+ return result
57
+
58
+
59
+ def _normal_draw(mean, covariance, rng):
60
+ covariance = (covariance + covariance.T) / 2
61
+ values, vectors = np.linalg.eigh(covariance)
62
+ tolerance = 1e-9 * max(1.0, np.max(np.abs(values)))
63
+ if values.min(initial=0) < -tolerance:
64
+ raise np.linalg.LinAlgError("predictive covariance is materially indefinite")
65
+ return mean + vectors @ (np.sqrt(np.maximum(values, 0)) * rng.normal(size=len(mean)))
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Prediction:
70
+ """Posterior predictions with intervals for each requested row.
71
+
72
+ ``draws`` has axes (chain, saved draw, prediction row). Rows are sampled
73
+ together so their dependence is retained. ``covariance`` is computed from
74
+ these joint draws. When h is predicted, its uncertainty at each saved state
75
+ is included. Mean predictions exclude new observation noise; observation predictions include it. Intervals
76
+ have equal posterior probability below and above their limits and apply to
77
+ each row separately.
78
+ """
79
+
80
+ draws: np.ndarray
81
+ mean: np.ndarray
82
+ sd: np.ndarray
83
+ lower: np.ndarray
84
+ upper: np.ndarray
85
+ covariance: np.ndarray
86
+ probability: float
87
+ scale: str
88
+ kind: str
89
+
90
+ def to_records(self):
91
+ return [
92
+ {"row": i, "mean": float(self.mean[i]), "sd": float(self.sd[i]),
93
+ "lower": float(self.lower[i]), "upper": float(self.upper[i])}
94
+ for i in range(len(self.mean))
95
+ ]
96
+
97
+
98
+ def _prediction(draws, probability, scale, kind):
99
+ probability = _probability(probability)
100
+ flat = np.asarray(draws).reshape(-1, draws.shape[-1])
101
+ if len(flat) < 2 or not np.isfinite(flat).all():
102
+ raise ValueError("prediction needs at least two finite posterior draws")
103
+ tail = (1 - probability) / 2
104
+ lower, upper = np.quantile(flat, [tail, 1 - tail], axis=0)
105
+ return Prediction(
106
+ *map(_freeze, (draws, flat.mean(axis=0), flat.std(axis=0, ddof=1), lower, upper,
107
+ np.atleast_2d(np.cov(flat, rowvar=False, ddof=1)))),
108
+ probability, scale, kind,
109
+ )
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class SurvivalPrediction:
114
+ """Survival probabilities, hazards or cumulative incidences over time.
115
+
116
+ ``draws`` has axes (chain, saved draw, row, time). Intervals apply to each
117
+ row/time separately. A hazard is an event rate among those still at risk;
118
+ a cumulative incidence is the probability of a specified cause by a time.
119
+ """
120
+ draws: np.ndarray
121
+ mean: np.ndarray
122
+ sd: np.ndarray
123
+ lower: np.ndarray
124
+ upper: np.ndarray
125
+ times: np.ndarray
126
+ probability: float
127
+ kind: str
128
+
129
+ def to_records(self):
130
+ return [{"row": row, "time": float(time), "mean": float(self.mean[row, j]),
131
+ "sd": float(self.sd[row, j]), "lower": float(self.lower[row, j]),
132
+ "upper": float(self.upper[row, j]), "kind": self.kind}
133
+ for row in range(self.mean.shape[0]) for j, time in enumerate(self.times)]
134
+
135
+
136
+ def _survival_prediction(draws, times, probability, kind):
137
+ probability = _probability(probability)
138
+ flat = draws.reshape((-1,) + draws.shape[2:])
139
+ tail = (1-probability)/2
140
+ lower, upper = np.quantile(flat, [tail, 1-tail], axis=0)
141
+ return SurvivalPrediction(*map(_freeze, (draws, flat.mean(axis=0), flat.std(axis=0, ddof=1),
142
+ lower, upper, times)), probability, kind)
143
+
144
+
145
+ class BKMRFit:
146
+ """Saved BKMR chains, data transformations, summaries, and predictions.
147
+
148
+ Fit objects retain the training data. Treat a saved object as research data
149
+ when deciding where to store or share it. Arrays are copied and read-only;
150
+ sample-extraction methods return independent copies.
151
+ """
152
+
153
+ def __init__(self, data, samples, settings, sampling_info):
154
+ self.data = {k: _freeze(v) if isinstance(v, np.ndarray) else deepcopy(v)
155
+ for k, v in data.items()}
156
+ self.samples = {k: _freeze(v) for k, v in samples.items()}
157
+ self.settings = deepcopy(settings)
158
+ self.sampling_info = deepcopy(sampling_info)
159
+ self._validate()
160
+
161
+ def _validate(self):
162
+ required_data = {"y", "Z", "Z_original", "X", "X_original", "z_center", "z_scale",
163
+ "rows", "original_n", "exposure_names", "covariate_names", "groups"}
164
+ if not required_data.issubset(self.data):
165
+ raise ValueError("fit data are incomplete")
166
+ required_samples = {"beta", "r", "lambda", "sigma2", "delta", "pi"}
167
+ if not required_samples.issubset(self.samples):
168
+ raise ValueError("posterior samples are incomplete")
169
+ if self.family not in ("gaussian", "binomial", "poisson", "negative_binomial", "survival"):
170
+ raise ValueError("unknown outcome family")
171
+ y, z, x = self.data["y"], self.data["Z"], self.data["X"]
172
+ if y.ndim != 1 or z.ndim != 2 or x.ndim != 2 or len(z) != len(y) or len(x) != len(y):
173
+ raise ValueError("incompatible training data shapes")
174
+ n, m, p = len(y), z.shape[1], x.shape[1]
175
+ chains = _integer(self.settings["chains"], "chains")
176
+ draws = _integer(self.settings["draws"], "draws", 2)
177
+ shapes = {"beta": (chains, draws, p), "r": (chains, draws, m),
178
+ "lambda": (chains, draws), "sigma2": (chains, draws),
179
+ "delta": (chains, draws, m), "pi": (chains, draws)}
180
+ if self.family == "binomial":
181
+ shapes["ystar"] = (chains, draws, n)
182
+ if not np.isin(y, [0, 1]).all():
183
+ raise ValueError("binary outcomes must be zero or one")
184
+ if self.family in ("poisson", "negative_binomial", "survival"):
185
+ shapes["h"] = (chains, draws, n)
186
+ shapes["latent_white"] = (chains, draws, n)
187
+ if not np.all(self.samples["sigma2"] == 1):
188
+ raise ValueError("generalized models use unit latent variance convention")
189
+ if self.settings.get("clustered", False):
190
+ codes = self.data.get("cluster_codes")
191
+ levels = self.data.get("cluster_levels", [])
192
+ if codes is None or codes.shape != (n,) or not levels:
193
+ raise ValueError("cluster codes and levels are required")
194
+ if not np.issubdtype(codes.dtype, np.integer) or np.any(codes < 0) or np.any(codes >= len(levels)):
195
+ raise ValueError("invalid cluster codes")
196
+ from ._clusters import canonical_label
197
+ if len(set(map(canonical_label, levels))) != len(levels):
198
+ raise ValueError("cluster levels must be distinct typed labels")
199
+ shapes["lambda_cluster"] = (chains, draws)
200
+ if self.family in ("poisson", "negative_binomial", "survival"):
201
+ shapes["u"] = (chains, draws, len(levels))
202
+ if self.family in ("poisson", "negative_binomial"):
203
+ if np.any(y < 0) or np.any(y != np.floor(y)):
204
+ raise ValueError("count outcomes must be nonnegative integers")
205
+ offset = self.data.get("offset")
206
+ if offset is None or offset.shape != (n,) or not np.isfinite(offset).all():
207
+ raise ValueError("invalid count offsets")
208
+ if self.family == "negative_binomial":
209
+ shapes["dispersion"] = (chains, draws)
210
+ if self.family == "survival":
211
+ from ._likelihoods import interval_durations
212
+ breaks = self.data.get("baseline_breaks")
213
+ durations = interval_durations(y, breaks)
214
+ event = self.data.get("event")
215
+ if event is None or event.shape != (n,) or not np.isin(event, [0, 1]).all() or np.any(y <= 0):
216
+ raise ValueError("survival requires positive times and binary event indicators")
217
+ if not np.array_equal(self.data.get("event_bin"), np.searchsorted(breaks, y, side="left")):
218
+ raise ValueError("survival event bins must use right-closed intervals")
219
+ if not np.allclose(self.data.get("durations"), durations, rtol=1e-12, atol=0):
220
+ raise ValueError("survival durations do not match observed times")
221
+ shapes["baseline_hazard"] = (chains, draws, len(breaks) + 1)
222
+ for key, shape in shapes.items():
223
+ if key not in self.samples or self.samples[key].shape != shape:
224
+ raise ValueError(f"invalid shape for posterior {key}")
225
+ for key, value in self.samples.items():
226
+ if not np.issubdtype(value.dtype, np.number) and value.dtype != bool:
227
+ raise ValueError(f"posterior {key} must be numeric")
228
+ if not np.isfinite(value).all():
229
+ raise ValueError(f"posterior {key} contains nonfinite values")
230
+ for key in ("lambda", "sigma2", "lambda_cluster", "dispersion", "baseline_hazard"):
231
+ if key in self.samples and np.any(self.samples[key] <= 0):
232
+ raise ValueError(f"posterior {key} must be positive")
233
+ if np.any(self.samples["r"] < 0) or np.any((self.samples["pi"] < 0) | (self.samples["pi"] > 1)):
234
+ raise ValueError("invalid relevance or inclusion probability")
235
+ if not np.isin(self.samples["delta"], [0, 1]).all():
236
+ raise ValueError("inclusion indicators must be zero or one")
237
+ for key in ("y", "Z", "Z_original", "X", "X_original", "z_center", "z_scale"):
238
+ if not np.isfinite(self.data[key]).all():
239
+ raise ValueError(f"training {key} contains nonfinite values")
240
+ if self.family == "binomial":
241
+ if not np.all(self.samples["sigma2"] == 1):
242
+ raise ValueError("probit residual variance must be one")
243
+ latent = self.samples["ystar"]
244
+ if np.any(np.where(y == 1, latent <= 0, latent >= 0)):
245
+ raise ValueError("latent outcomes must have the observed outcome signs")
246
+ if self.data["groups"] is not None:
247
+ groups = np.asarray(self.data["groups"])
248
+ if groups.shape != (m,):
249
+ raise ValueError("groups must match exposure columns")
250
+ if self.settings.get("selection") == "group":
251
+ for group in np.unique(groups):
252
+ if np.any(self.samples["delta"][..., groups == group].sum(axis=-1) > 1):
253
+ raise ValueError("at most one exposure may be selected per group")
254
+ if self.data["X_original"].ndim != 2:
255
+ raise ValueError("original covariates must be a matrix")
256
+ if self.data["Z_original"].shape != z.shape or self.data["X_original"].shape[0] != n:
257
+ raise ValueError("incompatible original training arrays")
258
+ if self.data["z_center"].shape != (m,) or self.data["z_scale"].shape != (m,):
259
+ raise ValueError("invalid exposure transformation")
260
+ if np.any(self.data["z_scale"] <= 0):
261
+ raise ValueError("exposure scales must be positive")
262
+ if len(self.exposure_names) != m or len(self.covariate_names) != p:
263
+ raise ValueError("training names do not match matrix columns")
264
+ if self.family in ("poisson", "negative_binomial", "survival"):
265
+ from ._kernel import gaussian_kernel
266
+ from ._prediction import _check_support
267
+ last_r, basis = None, None
268
+ for chain in range(chains):
269
+ for draw in range(draws):
270
+ r = self.samples["r"][chain, draw]
271
+ if last_r is None or not np.array_equal(r, last_r):
272
+ eig, vectors = np.linalg.eigh(gaussian_kernel(z, r=r))
273
+ tolerance = 100*n*np.finfo(float).eps*max(1., eig.max())
274
+ basis = vectors[:, eig > tolerance]
275
+ last_r = r.copy()
276
+ # Support is basis invariant. Do not reconstruct h from saved
277
+ # white coordinates: eigenvector signs/rotations can differ
278
+ # across valid linear-algebra implementations.
279
+ _check_support(self.samples["h"][chain, draw], basis)
280
+
281
+ @property
282
+ def family(self):
283
+ return self.settings["family"]
284
+
285
+ @property
286
+ def exposure_names(self):
287
+ return list(self.data["exposure_names"])
288
+
289
+ @property
290
+ def covariate_names(self):
291
+ return list(self.data["covariate_names"])
292
+
293
+ def __repr__(self):
294
+ return (f"BKMRFit(family={self.family!r}, n={len(self.data['y'])}, "
295
+ f"exposures={self.data['Z'].shape[1]}, chains={self.settings['chains']}, "
296
+ f"draws={self.settings['draws']})")
297
+
298
+ def posterior_samples(self, parameter=None, combine_chains=False):
299
+ """Return saved post-warmup samples, preserving chains by default."""
300
+ if not isinstance(combine_chains, (bool, np.bool_)):
301
+ raise ValueError("combine_chains must be boolean")
302
+
303
+ def copy(value):
304
+ return value.reshape((-1,) + value.shape[2:]).copy() if combine_chains else value.copy()
305
+
306
+ if parameter is None:
307
+ return {k: copy(v) for k, v in self.samples.items()}
308
+ if parameter not in self.samples:
309
+ raise ValueError(f"unknown parameter: {parameter}")
310
+ return copy(self.samples[parameter])
311
+
312
+ def _parameters(self):
313
+ for key in ("lambda", "sigma2", "r", "beta", "pi", "delta", "lambda_cluster", "dispersion", "baseline_hazard", "u"):
314
+ if key not in self.samples or (key == "sigma2" and self.family in ("poisson", "negative_binomial", "survival")):
315
+ continue
316
+ if key in ("pi", "delta") and self.settings.get("selection") == "none":
317
+ continue
318
+ values = self.samples[key]
319
+ if values.ndim == 2:
320
+ yield key, values
321
+ else:
322
+ labels = (self.exposure_names if key in ("r", "delta") else
323
+ [f"interval{j+1}" for j in range(values.shape[-1])] if key == "baseline_hazard" else
324
+ [repr(label) for label in self.data["cluster_levels"]] if key == "u" else self.covariate_names)
325
+ for j, label in enumerate(labels):
326
+ yield f"{key}[{label}]", values[:, :, j]
327
+
328
+ def summary_table(self, probability=0.95):
329
+ """Return parameter means, standard deviations and credible interval limits.
330
+
331
+ Each interval has equal posterior probability below and above its limits.
332
+ """
333
+ probability = _probability(probability)
334
+ tail = (1 - probability) / 2
335
+ rows = []
336
+ for name, values in self._parameters():
337
+ flat = values.astype(float, copy=False).ravel()
338
+ lower, upper = np.quantile(flat, [tail, 1 - tail])
339
+ rows.append({"parameter": name, "mean": float(flat.mean()),
340
+ "sd": float(flat.std(ddof=1)), "lower": float(lower),
341
+ "upper": float(upper)})
342
+ return rows
343
+
344
+ def summary(self, probability=0.95):
345
+ """Return a compact text summary without printing or refitting."""
346
+ rows = self.summary_table(probability)
347
+ width = max(12, max(len(row["parameter"]) for row in rows))
348
+ lines = [repr(self), f"{100 * probability:g}% pointwise credible intervals",
349
+ f"{'Parameter':<{width}} {'Mean':>10} {'SD':>10} {'Lower':>10} {'Upper':>10}"]
350
+ for row in rows:
351
+ lines.append(f"{row['parameter']:<{width}} " + " ".join(
352
+ f"{row[k]:>10.4g}" for k in ("mean", "sd", "lower", "upper")))
353
+ if self.settings.get("selection") != "none":
354
+ lines.append("Posterior inclusion probabilities")
355
+ for row in self.inclusion_probabilities():
356
+ lines.append(f" {row['variable']}: {row['pip']:.3f}")
357
+ lines.extend(self.sampling_info.get("warnings", []))
358
+ lines.extend(self.diagnostics()["messages"])
359
+ return "\n".join(lines)
360
+
361
+ def inclusion_probabilities(self):
362
+ """Return posterior inclusion probabilities (PIPs) for exposure selection.
363
+
364
+ An exposure PIP is the proportion of saved states where it is selected.
365
+ Group selection also returns the group's PIP and each exposure's PIP among
366
+ states where its group is selected. This conditional value is undefined
367
+ when the group is never selected.
368
+ """
369
+ if self.settings.get("selection") == "none":
370
+ raise ValueError("inclusion probabilities require a variable-selection fit")
371
+ delta = self.samples["delta"].astype(bool)
372
+ pips = delta.mean(axis=(0, 1))
373
+ rows = []
374
+ groups = self.data["groups"]
375
+ for j, name in enumerate(self.exposure_names):
376
+ row = {"variable": name, "pip": float(pips[j])}
377
+ if groups is not None and self.settings.get("selection") == "group":
378
+ selected = np.asarray(groups) == groups[j]
379
+ group_pip = float(delta[:, :, selected].any(axis=2).mean())
380
+ row.update(group=groups[j], group_pip=group_pip,
381
+ conditional_pip=float(pips[j] / group_pip) if group_pip else np.nan)
382
+ rows.append(row)
383
+ return rows
384
+
385
+ def diagnostics(self):
386
+ """Return R-hat, effective sample sizes and sampling messages.
387
+
388
+ R-hat compares chains. Effective sample size accounts for dependence between
389
+ draws, using both the center and tails of their distribution. Favorable
390
+ values do not prove convergence.
391
+ """
392
+ rows, messages = [], []
393
+ for name, values in self._parameters():
394
+ fixed = (name == "sigma2" and self.family == "binomial") or (
395
+ name.startswith("r[") and self.settings.get("r_fixed") is not None)
396
+ if fixed:
397
+ continue
398
+ if np.all(values == values.flat[0]):
399
+ messages.append(f"{name}: constant saved draws; mixing cannot be assessed.")
400
+ rows.append({"parameter": name, **diagnose(values)})
401
+ if self.settings["chains"] < 2:
402
+ messages.append("One chain: between-chain convergence has not been assessed.")
403
+ if self.settings["draws"] < 100:
404
+ messages.append("Few saved draws: diagnostic and tail estimates are imprecise.")
405
+ if any(row["rhat"] > 1.01 for row in rows):
406
+ messages.append("Some R-hat values exceed 1.01; inspect mixing before inference.")
407
+ if any(np.isfinite(row["ess_bulk"]) and row["ess_bulk"] < 100 for row in rows):
408
+ messages.append("Some bulk effective sample sizes are below 100.")
409
+ messages.append("Diagnostics do not establish convergence or validate model assumptions.")
410
+ return {"parameters": rows, "messages": messages,
411
+ "method": "rank-normalized/folded split R-hat; Geyer bulk/tail ESS capped at draw count"}
412
+
413
+ def _indices(self, max_draws):
414
+ n = self.settings["draws"]
415
+ if max_draws is None:
416
+ return np.arange(n)
417
+ maximum = _integer(max_draws, "max_draws", 2)
418
+ return np.linspace(0, n - 1, min(n, maximum), dtype=int)
419
+
420
+ def _new_exposures(self, Znew):
421
+ if Znew is None:
422
+ return self.data["Z"].copy()
423
+ raw = _matrix(Znew, len(self.exposure_names), self.exposure_names, "Znew")
424
+ return (raw - self.data["z_center"]) / self.data["z_scale"]
425
+
426
+ def _new_design(self, Xnew, rows, training):
427
+ k = self.data["X_original"].shape[1]
428
+ intercept = self.settings["intercept"]
429
+ if Xnew is None:
430
+ if training:
431
+ return self.data["X"].copy()
432
+ if k:
433
+ raise ValueError("Xnew is required for new exposures when adjustment covariates were fitted")
434
+ raw = np.empty((rows, 0))
435
+ else:
436
+ names = self.covariate_names[int(intercept):]
437
+ raw = _matrix(Xnew, k, names, "Xnew")
438
+ if len(raw) != rows:
439
+ raise ValueError("Xnew and Znew must have the same number of rows")
440
+ return np.column_stack((np.ones(rows), raw)) if intercept else raw
441
+
442
+ def _condition(self, standardized, chain, draw, cluster_new_codes=None):
443
+ from ._kernel import conditional_h
444
+
445
+ if self.family in ("poisson", "negative_binomial", "survival"):
446
+ from ._prediction import conditional_saved_h
447
+ return conditional_saved_h(self.data["Z"], standardized,
448
+ self.samples["h"][chain, draw],
449
+ self.samples["r"][chain, draw],
450
+ self.samples["lambda"][chain, draw])
451
+ ycont = self.samples["ystar"][chain, draw] if self.family == "binomial" else self.data["y"]
452
+ return conditional_h(
453
+ self.data["Z"], standardized, ycont, self.data["X"],
454
+ self.samples["beta"][chain, draw], self.samples["r"][chain, draw],
455
+ self.samples["lambda"][chain, draw], self.samples["sigma2"][chain, draw],
456
+ cluster_codes=self.data.get("cluster_codes"),
457
+ lambda_cluster=self.samples["lambda_cluster"][chain, draw] if "lambda_cluster" in self.samples else None,
458
+ cluster_new_codes=cluster_new_codes,
459
+ )
460
+
461
+ def _prediction_budget(self, rows, saved, *, moments=False):
462
+ # Account for dense covariance/eigen work, retained output and summaries.
463
+ n = len(self.data["y"])
464
+ output = 0 if moments else 3 * self.settings["chains"] * saved * rows
465
+ estimated = 8 * (output + 8 * rows * rows + 4 * n * rows)
466
+ if estimated > 512 * 1024 * 1024:
467
+ raise ValueError("prediction exceeds the 512 MiB working-memory limit; "
468
+ "use fewer profiles or max_draws")
469
+
470
+ def _h_draws(self, standardized, indices, rng, cluster_new_codes=None):
471
+ if cluster_new_codes is not None:
472
+ return self._clustered_joint_draws(standardized, indices, rng, cluster_new_codes)
473
+ self._prediction_budget(len(standardized), len(indices))
474
+ unique, inverse = np.unique(standardized, axis=0, return_inverse=True)
475
+ result = np.empty((self.settings["chains"], len(indices), len(standardized)))
476
+ for chain in range(self.settings["chains"]):
477
+ for position, draw in enumerate(indices):
478
+ active = self.samples["r"][chain, draw] > 0
479
+ # A selected-out exposure cannot change h. Collapse profiles
480
+ # identical on active dimensions before any floating-point draw.
481
+ if active.any():
482
+ _, representatives, local_inverse = np.unique(
483
+ unique[:, active], axis=0, return_index=True, return_inverse=True)
484
+ else:
485
+ representatives = np.array([0])
486
+ local_inverse = np.zeros(len(unique), dtype=int)
487
+ mean, covariance = self._condition(unique[representatives], chain, draw)
488
+ local = _normal_draw(mean, covariance, rng)[local_inverse]
489
+ result[chain, position] = local[inverse]
490
+ return result
491
+
492
+ def predict_h(self, Znew=None, *, probability=0.95, max_draws=None, seed=None):
493
+ """Draw the exposure function h at profiles supplied in original units.
494
+
495
+ Draw all requested rows together to retain their dependence. Exclude linear
496
+ adjustment, random effects and observation noise. Training standardization
497
+ is reused. ``max_draws`` selects evenly spaced saved states per chain;
498
+ it does not refit or extend the chain.
499
+ """
500
+ _probability(probability)
501
+ znew = self._new_exposures(Znew)
502
+ values = self._h_draws(znew, self._indices(max_draws), np.random.default_rng(seed))
503
+ return _prediction(values, probability, "h", "mean")
504
+
505
+ def _new_cluster_codes(self, cluster_new, rows):
506
+ if cluster_new is None:
507
+ return None
508
+ if not self.settings.get("clustered", False):
509
+ raise ValueError("cluster_new requires a clustered fit")
510
+ from ._clusters import canonical_label
511
+ values = np.asarray(cluster_new, dtype=object)
512
+ if values.shape != (rows,):
513
+ raise ValueError("cluster_new must have one label per prediction row")
514
+ mapping = {canonical_label(label): j for j, label in enumerate(self.data["cluster_levels"])}
515
+ codes = []
516
+ for label in values:
517
+ key = canonical_label(label)
518
+ if key not in mapping:
519
+ mapping[key] = len(mapping)
520
+ codes.append(mapping[key])
521
+ return np.asarray(codes, dtype=int)
522
+
523
+ def _clustered_joint_draws(self, standardized, indices, rng, codes):
524
+ self._prediction_budget(len(standardized), len(indices))
525
+ values = np.empty((self.settings["chains"], len(indices), len(standardized)))
526
+ for chain in range(self.settings["chains"]):
527
+ for position, draw in enumerate(indices):
528
+ active = self.samples["r"][chain, draw] > 0
529
+ keys = np.column_stack((standardized[:, active], codes))
530
+ _, representatives, inverse = np.unique(keys, axis=0, return_index=True, return_inverse=True)
531
+ mean, covariance = self._condition(standardized[representatives], chain, draw,
532
+ cluster_new_codes=codes[representatives])
533
+ values[chain, position] = _normal_draw(mean, covariance, rng)[inverse]
534
+ return values
535
+
536
+ def _generalized_u(self, codes, indices, rng):
537
+ values = np.zeros((self.settings["chains"], len(indices), len(codes)))
538
+ known = len(self.data["cluster_levels"])
539
+ unique, inverse = np.unique(codes, return_inverse=True)
540
+ for chain in range(self.settings["chains"]):
541
+ for position, draw in enumerate(indices):
542
+ local = np.empty(len(unique))
543
+ for j, code in enumerate(unique):
544
+ local[j] = (self.samples["u"][chain, draw, code] if code < known else
545
+ rng.normal(scale=np.sqrt(self.samples["lambda_cluster"][chain, draw])))
546
+ values[chain, position] = local[inverse]
547
+ return values
548
+
549
+ def random_intercepts(self, cluster_new=None, *, probability=.95, max_draws=None, seed=None):
550
+ """Draw one random intercept per training cluster or supplied label.
551
+
552
+ Known clusters use their posterior draws. Each unseen label receives a new
553
+ intercept; equal labels share it. Omitted labels use the training-cluster
554
+ order in ``data['cluster_levels']``.
555
+ """
556
+ if not self.settings.get("clustered", False):
557
+ raise ValueError("random_intercepts requires a clustered fit")
558
+ labels = self.data["cluster_levels"] if cluster_new is None else cluster_new
559
+ codes = self._new_cluster_codes(labels, len(labels))
560
+ indices = self._indices(max_draws)
561
+ self._prediction_budget(len(codes), len(indices))
562
+ rng = np.random.default_rng(seed)
563
+ if self.family in ("poisson", "negative_binomial", "survival"):
564
+ values = self._generalized_u(codes, indices, rng)
565
+ else:
566
+ from scipy.linalg import cho_solve
567
+
568
+ from ._kernel import covariance_components
569
+ unique, inverse = np.unique(codes, return_inverse=True)
570
+ train = self.data["cluster_codes"]
571
+ cross = (unique[:, None] == train[None, :]).astype(float)
572
+ prior = (unique[:, None] == unique[None, :]).astype(float)
573
+ values = np.empty((self.settings["chains"], len(indices), len(codes)))
574
+ for chain in range(self.settings["chains"]):
575
+ for position, draw in enumerate(indices):
576
+ amplitude = self.samples["lambda_cluster"][chain, draw]
577
+ _, _, lower = covariance_components(self.data["Z"], self.samples["r"][chain, draw],
578
+ self.samples["lambda"][chain, draw], cluster_codes=train, lambda_cluster=amplitude)
579
+ ycont = self.samples["ystar"][chain, draw] if self.family == "binomial" else self.data["y"]
580
+ residual = ycont - self.data["X"] @ self.samples["beta"][chain, draw]
581
+ mean = amplitude * cross @ cho_solve((lower, True), residual)
582
+ covariance = self.samples["sigma2"][chain, draw] * (
583
+ amplitude*prior-amplitude**2*cross@cho_solve((lower, True), cross.T))
584
+ values[chain, position] = _normal_draw(mean, covariance, rng)[inverse]
585
+ return _prediction(values, probability, "random_intercept", "mean")
586
+
587
+ def predict(self, Znew=None, Xnew=None, *, kind="mean", scale="response",
588
+ probability=0.95, max_draws=None, seed=None, offset=None, cluster_new=None):
589
+ """Draw outcome means or new observations at requested profiles.
590
+
591
+ ``scale='latent'`` returns the predictor before the outcome transformation,
592
+ such as probit or exponentiation. ``scale='response'`` returns outcome
593
+ means, probabilities or relative hazards.
594
+ Omitted ``cluster_new`` sets the random intercept to zero rather than
595
+ averaging over possible clusters. Known labels use their posterior;
596
+ equal unseen labels share one new intercept. Count offsets are log observation
597
+ exposures. Survival means are relative hazards, meaning hazard divided by
598
+ baseline hazard; use ``predict_survival`` for survival or hazard curves.
599
+ """
600
+ if kind not in ("mean", "observation") or scale not in ("response", "latent"):
601
+ raise ValueError("kind must be mean/observation and scale response/latent")
602
+ if self.family == "survival" and kind == "observation":
603
+ raise ValueError("survival observation draws are not supported; use predict_survival")
604
+ count_family = self.family in ("poisson", "negative_binomial")
605
+ if count_family and kind == "observation" and scale == "latent":
606
+ raise ValueError("count observations require scale='response'")
607
+ if offset is not None and not count_family:
608
+ raise ValueError("offset only applies to count predictions")
609
+ _probability(probability)
610
+ znew = self._new_exposures(Znew)
611
+ xnew = self._new_design(Xnew, len(znew), Znew is None)
612
+ codes = self._new_cluster_codes(cluster_new, len(znew))
613
+ indices = self._indices(max_draws)
614
+ rng = np.random.default_rng(seed)
615
+ generalized = self.family in ("poisson", "negative_binomial", "survival")
616
+ values = self._h_draws(znew, indices, rng, None if generalized else codes)
617
+ if generalized and codes is not None:
618
+ values += self._generalized_u(codes, indices, rng)
619
+ values += np.einsum("cdp,np->cdn", self.samples["beta"][:, indices], xnew)
620
+ if count_family:
621
+ if offset is None:
622
+ if Znew is None:
623
+ offsets = self.data["offset"]
624
+ elif self.settings.get("offset_used", False):
625
+ raise ValueError("offset is required for new rows because offsets were used in fitting")
626
+ else:
627
+ offsets = np.zeros(len(znew))
628
+ else:
629
+ offsets = np.asarray(offset, dtype=float)
630
+ if offsets.shape != (len(znew),) or not np.isfinite(offsets).all():
631
+ raise ValueError("offset must be a finite vector with one entry per prediction row")
632
+ values += offsets
633
+ if self.family == "binomial" and scale == "response":
634
+ values = ndtr(values)
635
+ if kind == "observation":
636
+ values = rng.binomial(1, values).astype(float)
637
+ elif generalized and scale == "response":
638
+ with np.errstate(over="ignore"):
639
+ values = np.exp(values)
640
+ if not np.isfinite(values).all():
641
+ raise FloatingPointError("log-link predictions overflowed; inspect priors and fitted draws")
642
+ if kind == "observation":
643
+ try:
644
+ if self.family == "poisson":
645
+ values = rng.poisson(values).astype(float)
646
+ else:
647
+ dispersion = self.samples["dispersion"][:, indices, None]
648
+ values = rng.negative_binomial(dispersion, dispersion/(dispersion+values)).astype(float)
649
+ except ValueError as exc:
650
+ raise FloatingPointError("predictive counts exceed the random generator's numeric range") from exc
651
+ elif kind == "observation":
652
+ sigma = np.sqrt(self.samples["sigma2"][:, indices, None])
653
+ values += rng.normal(size=values.shape) * sigma
654
+ return _prediction(values, probability, scale, kind)
655
+
656
+ def predict_survival(self, Znew=None, Xnew=None, *, times, kind="survival", cluster_new=None,
657
+ probability=.95, max_draws=None, seed=None):
658
+ """Return survival probabilities or hazards for right-censored survival fits.
659
+
660
+ The proportional-hazards model multiplies a shared baseline hazard by each
661
+ profile's relative hazard. A hazard is an event rate among those still at
662
+ risk. Beyond observed follow-up, prediction extends the final constant
663
+ baseline hazard; that assumption needs scientific justification.
664
+ """
665
+ if self.family != "survival":
666
+ raise ValueError("predict_survival requires a survival fit")
667
+ if kind not in ("survival", "hazard"):
668
+ raise ValueError("kind must be survival or hazard")
669
+ times = np.asarray(times, dtype=float)
670
+ if times.ndim != 1 or not len(times) or not np.isfinite(times).all() or np.any(times < 0) or np.any(np.diff(times) <= 0):
671
+ raise ValueError("times must be finite, nonnegative and strictly increasing")
672
+ indices = self._indices(max_draws)
673
+ rows = len(self.data["y"]) if Znew is None else len(np.atleast_2d(Znew))
674
+ estimated = 8 * 6 * self.settings["chains"] * len(indices) * rows * len(times)
675
+ if estimated > 512 * 1024 * 1024:
676
+ raise ValueError("survival prediction exceeds the 512 MiB working-memory limit")
677
+ eta = self.predict(Znew, Xnew, scale="latent", kind="mean", cluster_new=cluster_new,
678
+ probability=probability, max_draws=max_draws, seed=seed).draws
679
+ baseline = self.samples["baseline_hazard"][:, indices]
680
+ breaks = self.data["baseline_breaks"]
681
+ from ._likelihoods import interval_durations
682
+ cumulative = np.einsum("cdj,tj->cdt", baseline, interval_durations(times, breaks))
683
+ if kind == "survival":
684
+ log_cumulative = np.full_like(cumulative, -np.inf)
685
+ np.log(cumulative, out=log_cumulative, where=cumulative > 0)
686
+ with np.errstate(over="ignore", under="ignore"):
687
+ values = np.exp(-np.exp(eta[..., None] + log_cumulative[:, :, None, :]))
688
+ else:
689
+ selected = baseline[..., np.searchsorted(breaks, times, side="left")]
690
+ with np.errstate(over="ignore"):
691
+ values = np.exp(eta[..., None] + np.log(selected[:, :, None, :]))
692
+ if not np.isfinite(values).all():
693
+ raise FloatingPointError("hazard predictions overflowed")
694
+ return _survival_prediction(values, times, probability, kind)
695
+
696
+ def posterior_moments_h(self, Znew=None, max_draws=None):
697
+ """Return the mean and covariance of h averaged over selected saved states.
698
+
699
+ At each state, average over the uncertainty in h given the training data.
700
+ Then combine these means and covariances across states, using the state
701
+ count as the denominator for their covariance. R's finite-sample exact
702
+ summary uses one less than that count. Both estimate the same posterior
703
+ moment as sampling error decreases.
704
+ """
705
+ znew = self._new_exposures(Znew)
706
+ indices = self._indices(max_draws)
707
+ self._prediction_budget(len(znew), len(indices), moments=True)
708
+ average = np.zeros(len(znew))
709
+ scatter = np.zeros((len(znew), len(znew)))
710
+ conditional = np.zeros_like(scatter)
711
+ count = 0
712
+ for chain in range(self.settings["chains"]):
713
+ for draw in indices:
714
+ mean, covariance = self._condition(znew, chain, draw)
715
+ count += 1
716
+ difference = mean - average
717
+ average += difference / count
718
+ scatter += np.outer(difference, mean - average)
719
+ conditional += (covariance - conditional) / count
720
+ covariance = conditional + scatter / count
721
+ return {"mean": average, "covariance": (covariance + covariance.T) / 2}
722
+
723
+ def save(self, path):
724
+ """Write a compressed, non-pickle archive, replacing the file atomically."""
725
+ path = Path(path)
726
+ arrays = {"data__" + k: v for k, v in self.data.items() if isinstance(v, np.ndarray)}
727
+ arrays.update({"sample__" + k: v for k, v in self.samples.items()})
728
+ metadata = {"schema": 1, "data": {k: v for k, v in self.data.items()
729
+ if not isinstance(v, np.ndarray)},
730
+ "settings": self.settings, "sampling_info": self.sampling_info}
731
+
732
+ def convert(value):
733
+ if isinstance(value, np.ndarray):
734
+ return value.tolist()
735
+ if isinstance(value, np.generic):
736
+ return value.item()
737
+ raise TypeError(f"unsupported metadata type: {type(value).__name__}")
738
+
739
+ arrays["metadata"] = np.asarray(json.dumps(metadata, default=convert, allow_nan=False))
740
+ descriptor, name = tempfile.mkstemp(prefix=".kernelmix-", dir=path.parent)
741
+ try:
742
+ with os.fdopen(descriptor, "wb") as output:
743
+ np.savez_compressed(output, **arrays)
744
+ os.replace(name, path)
745
+ finally:
746
+ if os.path.exists(name):
747
+ os.unlink(name)
748
+
749
+ @classmethod
750
+ def load(cls, path, *, max_bytes=512 * 1024 * 1024):
751
+ """Load a validated non-pickle archive; limit uncompressed size by default."""
752
+ maximum = _integer(max_bytes, "max_bytes")
753
+ with zipfile.ZipFile(path) as archive:
754
+ members = archive.infolist()
755
+ if sum(item.file_size for item in members) > maximum:
756
+ raise ValueError("fit archive exceeds max_bytes")
757
+ if len({item.filename for item in members}) != len(members):
758
+ raise ValueError("fit archive contains duplicate members")
759
+ with np.load(path, allow_pickle=False) as stored:
760
+ if "metadata" not in stored:
761
+ raise ValueError("fit archive has no metadata")
762
+ metadata = json.loads(str(stored["metadata"].item()))
763
+ if not isinstance(metadata, dict):
764
+ raise ValueError("fit archive metadata must be an object")
765
+ if metadata.get("schema") != 1:
766
+ raise ValueError("unsupported fit archive schema")
767
+ data = metadata["data"]
768
+ samples = {}
769
+ for key in stored.files:
770
+ if key.startswith("data__"):
771
+ data[key[6:]] = stored[key]
772
+ elif key.startswith("sample__"):
773
+ samples[key[8:]] = stored[key]
774
+ elif key != "metadata":
775
+ raise ValueError("unknown fit archive member")
776
+ if not isinstance(metadata.get("settings"), dict) or not isinstance(
777
+ metadata.get("sampling_info"), dict):
778
+ raise ValueError("fit archive settings or sampling information are missing")
779
+ try:
780
+ if metadata["settings"].get("engine") == "structured":
781
+ from .structured_result import StructuredFit
782
+ return StructuredFit(data, samples, metadata["settings"], metadata["sampling_info"])
783
+ return cls(data, samples, metadata["settings"], metadata["sampling_info"])
784
+ except (KeyError, TypeError, AttributeError, IndexError) as exc:
785
+ raise ValueError("invalid fit archive structure") from exc