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/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
|