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/effects.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Exposure associations summarized from posterior draws in original units.
|
|
2
|
+
|
|
3
|
+
Each summary varies selected exposures while holding other inputs fixed;
|
|
4
|
+
this is a conditional association, not an identified causal effect. ``latent``
|
|
5
|
+
uses the exposure function h. ``response`` uses outcome means, probabilities
|
|
6
|
+
or relative hazards, holding adjustment covariates at ``X`` or their retained
|
|
7
|
+
training means. Pointwise intervals apply to each location separately, with
|
|
8
|
+
equal posterior probability below and above the reported limits.
|
|
9
|
+
Calls are limited to 4096 output locations because joint prediction and
|
|
10
|
+
contrast matrices are dense; smaller grids usually give clearer summaries.
|
|
11
|
+
"""
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from itertools import combinations
|
|
14
|
+
from numbers import Integral
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
__all__ = ['EffectResult', 'CurveResult', 'SurfaceResult', 'overall_effect',
|
|
19
|
+
'univariate_response', 'bivariate_response', 'single_exposure_effects',
|
|
20
|
+
'interaction_effects']
|
|
21
|
+
|
|
22
|
+
# Contrasts and joint GP prediction use dense matrices. Bound allocations.
|
|
23
|
+
_MAX_LOCATIONS = 4096
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _bounded(count):
|
|
27
|
+
if count > _MAX_LOCATIONS:
|
|
28
|
+
raise ValueError(f'at most {_MAX_LOCATIONS} effect locations are supported per call; use smaller grids or fewer exposures')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class EffectResult:
|
|
34
|
+
"""Draws of exposure associations and summaries at each requested location.
|
|
35
|
+
|
|
36
|
+
``draws`` has axes (chain, saved draw, location). Each saved draw keeps the
|
|
37
|
+
locations together, preserving their dependence. ``locations`` describes the
|
|
38
|
+
last axis; ``profiles`` stores original-unit exposure rows. ``coefficients``
|
|
39
|
+
maps their joint predictions to differences or other reported summaries.
|
|
40
|
+
SD is the sample standard deviation when more than one draw is available.
|
|
41
|
+
"""
|
|
42
|
+
draws: np.ndarray
|
|
43
|
+
mean: np.ndarray
|
|
44
|
+
sd: np.ndarray
|
|
45
|
+
lower: np.ndarray
|
|
46
|
+
upper: np.ndarray
|
|
47
|
+
locations: list
|
|
48
|
+
profiles: np.ndarray
|
|
49
|
+
coefficients: np.ndarray
|
|
50
|
+
scale: str
|
|
51
|
+
probability: float
|
|
52
|
+
kind: str
|
|
53
|
+
metadata: dict = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
def to_records(self):
|
|
56
|
+
"""Return plain dictionaries; pandas is not needed."""
|
|
57
|
+
return [dict(location, mean=float(self.mean[i]), sd=float(self.sd[i]),
|
|
58
|
+
lower=float(self.lower[i]), upper=float(self.upper[i]),
|
|
59
|
+
scale=self.scale, probability=self.probability)
|
|
60
|
+
for i, location in enumerate(self.locations)]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class CurveResult(EffectResult):
|
|
65
|
+
"""One or more curves, with variable and exposure value in locations."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class SurfaceResult(EffectResult):
|
|
70
|
+
"""A flattened surface, ordered with the second grid changing fastest."""
|
|
71
|
+
variables: tuple = ()
|
|
72
|
+
grids: tuple = ()
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def shape(self):
|
|
76
|
+
return tuple(len(grid) for grid in self.grids)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _training(fit):
|
|
80
|
+
z = np.asarray(fit.data['Z_original'], dtype=float)
|
|
81
|
+
if z.ndim != 2 or not z.size or not np.isfinite(z).all():
|
|
82
|
+
raise ValueError('fit must contain finite retained original exposures')
|
|
83
|
+
return z
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _quantiles(value, name, *, pair=False):
|
|
87
|
+
q = np.asarray(value, dtype=float)
|
|
88
|
+
if q.ndim != 1 or not q.size or not np.isfinite(q).all() or np.any((q < 0) | (q > 1)):
|
|
89
|
+
raise ValueError(f'{name} must be a nonempty sequence in [0, 1]')
|
|
90
|
+
if pair and (q.size != 2 or q[0] > q[1]):
|
|
91
|
+
raise ValueError(f'{name} must contain two ordered quantiles (low, high)')
|
|
92
|
+
return q
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _q(value, name):
|
|
96
|
+
a = np.asarray(value, dtype=float)
|
|
97
|
+
if a.ndim != 0 or not np.isfinite(a) or not 0 <= float(a) <= 1:
|
|
98
|
+
raise ValueError(f'{name} must be a finite number in [0, 1]')
|
|
99
|
+
return float(a)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _variable(fit, variable):
|
|
103
|
+
names = list(fit.exposure_names)
|
|
104
|
+
if isinstance(variable, str):
|
|
105
|
+
if variable not in names:
|
|
106
|
+
raise ValueError(f'unknown exposure {variable!r}')
|
|
107
|
+
return names.index(variable)
|
|
108
|
+
if isinstance(variable, (bool, np.bool_)) or not isinstance(variable, Integral):
|
|
109
|
+
raise ValueError('variables must be exposure names or integer column indices')
|
|
110
|
+
if not 0 <= variable < len(names):
|
|
111
|
+
raise ValueError('exposure index is out of range')
|
|
112
|
+
return int(variable)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _grid(z, variable, grid, points):
|
|
116
|
+
if grid is None:
|
|
117
|
+
if isinstance(points, (bool, np.bool_)) or not isinstance(points, Integral) or points < 2:
|
|
118
|
+
raise ValueError('points must be an integer of at least two')
|
|
119
|
+
_bounded(points)
|
|
120
|
+
return np.linspace(z[:, variable].min(), z[:, variable].max(), points)
|
|
121
|
+
g = np.asarray(grid, dtype=float)
|
|
122
|
+
if g.ndim != 1 or not g.size or not np.isfinite(g).all():
|
|
123
|
+
raise ValueError('grid must be a nonempty finite one-dimensional sequence')
|
|
124
|
+
_bounded(g.size)
|
|
125
|
+
if np.any(np.diff(g) <= 0):
|
|
126
|
+
raise ValueError('grid must be strictly increasing')
|
|
127
|
+
return g.copy()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _evaluate(fit, profiles, coefficients, locations, *, scale, X, probability,
|
|
131
|
+
max_draws, seed, kind, cls=EffectResult, metadata=None, **extra):
|
|
132
|
+
if scale not in ('latent', 'response'):
|
|
133
|
+
raise ValueError("scale must be 'latent' or 'response'")
|
|
134
|
+
p = np.asarray(probability, dtype=float)
|
|
135
|
+
if p.ndim != 0 or not np.isfinite(p) or not 0 < float(p) < 1:
|
|
136
|
+
raise ValueError('probability must lie strictly between zero and one')
|
|
137
|
+
profiles = np.asarray(profiles, dtype=float)
|
|
138
|
+
coefficients = np.asarray(coefficients, dtype=float)
|
|
139
|
+
# Predict once, jointly. Repeated profiles share the exact same random draw.
|
|
140
|
+
unique, inverse = np.unique(profiles, axis=0, return_inverse=True)
|
|
141
|
+
weights = np.zeros((len(locations), len(unique)))
|
|
142
|
+
for column, target in enumerate(inverse):
|
|
143
|
+
weights[:, target] += coefficients[:, column]
|
|
144
|
+
options = dict(probability=float(p), max_draws=max_draws, seed=seed)
|
|
145
|
+
covariates = None
|
|
146
|
+
if scale == 'latent':
|
|
147
|
+
if X is not None:
|
|
148
|
+
raise ValueError('X only applies to response-scale summaries')
|
|
149
|
+
prediction = fit.predict_h(unique, **options)
|
|
150
|
+
else:
|
|
151
|
+
raw = np.asarray(fit.data['X_original'], dtype=float)
|
|
152
|
+
covariates = raw.mean(axis=0) if X is None else np.asarray(X, dtype=float)
|
|
153
|
+
if covariates.ndim != 1 or covariates.size != raw.shape[1] or not np.isfinite(covariates).all():
|
|
154
|
+
raise ValueError('X must be a finite vector of raw adjustment covariates')
|
|
155
|
+
if fit.family in ('poisson', 'negative_binomial'):
|
|
156
|
+
options['offset'] = np.zeros(len(unique))
|
|
157
|
+
prediction = fit.predict(unique, Xnew=np.tile(covariates, (len(unique), 1)),
|
|
158
|
+
kind='mean', scale='response', **options)
|
|
159
|
+
d = np.asarray(prediction.draws)
|
|
160
|
+
if d.ndim != 3 or d.shape[-1] != len(unique) or not np.isfinite(d).all():
|
|
161
|
+
raise ValueError('prediction must provide finite joint draws (chain, draw, row)')
|
|
162
|
+
values = np.einsum('cdp,lp->cdl', d, weights)
|
|
163
|
+
flat = values.reshape(-1, len(locations))
|
|
164
|
+
tail = (1 - float(p)) / 2
|
|
165
|
+
meta = {} if metadata is None else dict(metadata)
|
|
166
|
+
meta['covariates'] = None if covariates is None else covariates.copy()
|
|
167
|
+
meta['family'] = fit.family
|
|
168
|
+
if scale == 'response' and fit.family in ('poisson', 'negative_binomial', 'survival'):
|
|
169
|
+
meta['random_intercept'] = 'zero'
|
|
170
|
+
meta['response_quantity'] = 'relative_hazard' if fit.family == 'survival' else 'unit-exposure count mean'
|
|
171
|
+
return cls(draws=values, mean=flat.mean(axis=0),
|
|
172
|
+
sd=flat.std(axis=0, ddof=1 if len(flat) > 1 else 0),
|
|
173
|
+
lower=np.quantile(flat, tail, axis=0),
|
|
174
|
+
upper=np.quantile(flat, 1 - tail, axis=0), locations=locations,
|
|
175
|
+
profiles=unique.copy(), coefficients=weights, scale=scale,
|
|
176
|
+
probability=float(p), kind=kind, metadata=meta, **extra)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def overall_effect(fit, quantiles=(.25, .5, .75), reference=.5, scale='latent',
|
|
180
|
+
X=None, probability=.95, max_draws=None, seed=None):
|
|
181
|
+
"""Compare profiles where all exposures are at their own specified quantiles.
|
|
182
|
+
|
|
183
|
+
Subtract the profile at ``reference`` from each profile in ``quantiles``.
|
|
184
|
+
A quantile is taken separately for each exposure, so a combined profile need
|
|
185
|
+
not have been observed. The default scale compares the exposure function h.
|
|
186
|
+
"""
|
|
187
|
+
z = _training(fit)
|
|
188
|
+
qs = _quantiles(quantiles, 'quantiles')
|
|
189
|
+
_bounded(len(qs))
|
|
190
|
+
ref = _q(reference, 'reference')
|
|
191
|
+
profiles = np.vstack([np.quantile(z, qs, axis=0), np.quantile(z, ref, axis=0)])
|
|
192
|
+
weights = np.column_stack([np.eye(len(qs)), -np.ones(len(qs))])
|
|
193
|
+
locations = [dict(quantile=float(q), reference=ref) for q in qs]
|
|
194
|
+
return _evaluate(fit, profiles, weights, locations, scale=scale, X=X,
|
|
195
|
+
probability=probability, max_draws=max_draws, seed=seed,
|
|
196
|
+
kind='overall', metadata={'reference': ref})
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def univariate_response(fit, variable=None, grid=None, points=50,
|
|
200
|
+
fixed_quantile=.5, center=True, scale='latent', X=None,
|
|
201
|
+
probability=.95, max_draws=None, seed=None):
|
|
202
|
+
"""Vary one exposure on a grid, holding other exposures at fixed_quantile.
|
|
203
|
+
|
|
204
|
+
All grid values use original exposure units. ``variable=None`` returns all
|
|
205
|
+
curves together, sharing ``grid`` when supplied. With ``center=True``,
|
|
206
|
+
subtract the profile with every exposure at its median from each saved draw,
|
|
207
|
+
even when fixed_quantile differs from .5. R bkmr instead subtracts the
|
|
208
|
+
average fitted grid value.
|
|
209
|
+
"""
|
|
210
|
+
z = _training(fit)
|
|
211
|
+
q = _q(fixed_quantile, 'fixed_quantile')
|
|
212
|
+
if not isinstance(center, (bool, np.bool_)):
|
|
213
|
+
raise ValueError('center must be boolean')
|
|
214
|
+
variables = range(z.shape[1]) if variable is None else [_variable(fit, variable)]
|
|
215
|
+
profiles, locations = [], []
|
|
216
|
+
background = np.quantile(z, q, axis=0)
|
|
217
|
+
for j in variables:
|
|
218
|
+
current_grid = _grid(z, j, grid, points)
|
|
219
|
+
_bounded(len(locations) + len(current_grid))
|
|
220
|
+
for value in current_grid:
|
|
221
|
+
profile = background.copy()
|
|
222
|
+
profile[j] = value
|
|
223
|
+
profiles.append(profile)
|
|
224
|
+
locations.append(dict(variable=fit.exposure_names[j], variable_index=j, value=float(value)))
|
|
225
|
+
n = len(profiles)
|
|
226
|
+
weights = np.eye(n)
|
|
227
|
+
if center:
|
|
228
|
+
profiles.append(np.quantile(z, .5, axis=0))
|
|
229
|
+
weights = np.column_stack([weights, -np.ones(n)])
|
|
230
|
+
return _evaluate(fit, profiles, weights, locations, scale=scale, X=X,
|
|
231
|
+
probability=probability, max_draws=max_draws, seed=seed,
|
|
232
|
+
kind='univariate', cls=CurveResult,
|
|
233
|
+
metadata={'center': bool(center), 'fixed_quantile': q, 'reference_quantile': .5 if center else None})
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def bivariate_response(fit, variables=(0, 1), grids=None, points=20,
|
|
237
|
+
fixed_quantile=.5, center=True, scale='latent', X=None,
|
|
238
|
+
probability=.95, max_draws=None, seed=None):
|
|
239
|
+
"""Vary two exposures over all combinations of their grid values.
|
|
240
|
+
|
|
241
|
+
Other exposures stay at fixed_quantile. With ``center=True``, subtract the
|
|
242
|
+
profile with all exposures at their medians. Grids use original units.
|
|
243
|
+
"""
|
|
244
|
+
z = _training(fit)
|
|
245
|
+
if len(variables) != 2:
|
|
246
|
+
raise ValueError('variables must contain exactly two distinct exposures')
|
|
247
|
+
j, k = [_variable(fit, v) for v in variables]
|
|
248
|
+
if j == k:
|
|
249
|
+
raise ValueError('variables must be distinct')
|
|
250
|
+
if not isinstance(center, (bool, np.bool_)):
|
|
251
|
+
raise ValueError('center must be boolean')
|
|
252
|
+
q = _q(fixed_quantile, 'fixed_quantile')
|
|
253
|
+
if grids is None:
|
|
254
|
+
grids = (None, None)
|
|
255
|
+
if len(grids) != 2:
|
|
256
|
+
raise ValueError('grids must contain two grid sequences')
|
|
257
|
+
g1, g2 = (_grid(z, j, grids[0], points), _grid(z, k, grids[1], points))
|
|
258
|
+
_bounded(len(g1) * len(g2))
|
|
259
|
+
profiles, locations = [], []
|
|
260
|
+
background = np.quantile(z, q, axis=0)
|
|
261
|
+
for a in g1:
|
|
262
|
+
for b in g2:
|
|
263
|
+
profile = background.copy()
|
|
264
|
+
profile[j], profile[k] = a, b
|
|
265
|
+
profiles.append(profile)
|
|
266
|
+
locations.append(dict(variable1=fit.exposure_names[j], variable2=fit.exposure_names[k], value1=float(a), value2=float(b)))
|
|
267
|
+
n = len(profiles)
|
|
268
|
+
weights = np.eye(n)
|
|
269
|
+
if center:
|
|
270
|
+
profiles.append(np.quantile(z, .5, axis=0))
|
|
271
|
+
weights = np.column_stack([weights, -np.ones(n)])
|
|
272
|
+
return _evaluate(fit, profiles, weights, locations, scale=scale, X=X,
|
|
273
|
+
probability=probability, max_draws=max_draws, seed=seed,
|
|
274
|
+
kind='bivariate', cls=SurfaceResult,
|
|
275
|
+
metadata={'center': bool(center), 'fixed_quantile': q, 'reference_quantile': .5 if center else None},
|
|
276
|
+
variables=(fit.exposure_names[j], fit.exposure_names[k]), grids=(g1, g2))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def single_exposure_effects(fit, quantiles=(.25, .75),
|
|
280
|
+
fixed_quantiles=(.25, .5, .75), scale='latent', X=None,
|
|
281
|
+
probability=.95, max_draws=None, seed=None):
|
|
282
|
+
"""Compare high and low quantiles of each exposure.
|
|
283
|
+
|
|
284
|
+
Hold every other exposure at each requested background quantile in turn.
|
|
285
|
+
Return the high prediction minus the low prediction.
|
|
286
|
+
"""
|
|
287
|
+
z = _training(fit)
|
|
288
|
+
low, high = _quantiles(quantiles, 'quantiles', pair=True)
|
|
289
|
+
fixed = _quantiles(fixed_quantiles, 'fixed_quantiles')
|
|
290
|
+
_bounded(len(fixed) * z.shape[1])
|
|
291
|
+
profiles, locations = [], []
|
|
292
|
+
limits = np.quantile(z, [low, high], axis=0)
|
|
293
|
+
for q in fixed:
|
|
294
|
+
for j, name in enumerate(fit.exposure_names):
|
|
295
|
+
pair = np.tile(np.quantile(z, q, axis=0), (2, 1))
|
|
296
|
+
pair[:, j] = limits[:, j]
|
|
297
|
+
profiles.extend(pair)
|
|
298
|
+
locations.append(dict(variable=name, variable_index=j, fixed_quantile=float(q), low_quantile=float(low), high_quantile=float(high), low=float(limits[0, j]), high=float(limits[1, j])))
|
|
299
|
+
weights = np.kron(np.eye(len(locations)), [[-1., 1.]])
|
|
300
|
+
return _evaluate(fit, profiles, weights, locations, scale=scale, X=X,
|
|
301
|
+
probability=probability, max_draws=max_draws, seed=seed, kind='single_exposure')
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def interaction_effects(fit, variables=None, quantiles=(.25, .75),
|
|
305
|
+
fixed_quantile=.5, scale='latent', X=None,
|
|
306
|
+
probability=.95, max_draws=None, seed=None):
|
|
307
|
+
"""Compare high-minus-low changes in one exposure at two levels of another.
|
|
308
|
+
|
|
309
|
+
For each selected pair, return (HH-LH) - (HL-LL), where H and L are the
|
|
310
|
+
high and low quantiles. The first letter gives the first exposure's level.
|
|
311
|
+
``variables`` selects exposures by index or name; every unordered pair is
|
|
312
|
+
returned once. Other exposures stay at fixed_quantile. R bkmr instead uses
|
|
313
|
+
an interaction summary that changes all background exposures together.
|
|
314
|
+
"""
|
|
315
|
+
z = _training(fit)
|
|
316
|
+
low, high = _quantiles(quantiles, 'quantiles', pair=True)
|
|
317
|
+
q = _q(fixed_quantile, 'fixed_quantile')
|
|
318
|
+
selected = list(range(z.shape[1])) if variables is None else [_variable(fit, v) for v in variables]
|
|
319
|
+
if len(selected) < 2 or len(set(selected)) != len(selected):
|
|
320
|
+
raise ValueError('variables must select at least two distinct exposures')
|
|
321
|
+
_bounded(len(selected) * (len(selected) - 1) // 2)
|
|
322
|
+
limits = np.quantile(z, [low, high], axis=0)
|
|
323
|
+
profiles, locations = [], []
|
|
324
|
+
for j, k in combinations(selected, 2):
|
|
325
|
+
for a, b in ((0, 0), (1, 0), (0, 1), (1, 1)):
|
|
326
|
+
profile = np.quantile(z, q, axis=0)
|
|
327
|
+
profile[j], profile[k] = limits[a, j], limits[b, k]
|
|
328
|
+
profiles.append(profile)
|
|
329
|
+
locations.append(dict(variable1=fit.exposure_names[j], variable2=fit.exposure_names[k], fixed_quantile=q, low_quantile=float(low), high_quantile=float(high), low1=float(limits[0, j]), high1=float(limits[1, j]), low2=float(limits[0, k]), high2=float(limits[1, k])))
|
|
330
|
+
weights = np.kron(np.eye(len(locations)), [[1., -1., -1., 1.]])
|
|
331
|
+
return _evaluate(fit, profiles, weights, locations, scale=scale, X=X,
|
|
332
|
+
probability=probability, max_draws=max_draws, seed=seed, kind='interaction')
|