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/plots.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Optional Matplotlib plots. All functions return axes and leave display to you.
|
|
2
|
+
|
|
3
|
+
Matplotlib is imported only when a plot is requested. No function saves files,
|
|
4
|
+
shows figures, or changes global styles. Posterior intervals are pointwise;
|
|
5
|
+
they are not simultaneous uncertainty bands. Values remain available through
|
|
6
|
+
result ``to_records()`` methods for captions and accessible data tables.
|
|
7
|
+
"""
|
|
8
|
+
from numbers import Integral
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
__all__ = ['plot_trace', 'plot_pips', 'plot_response', 'plot_overall', 'plot_surface', 'plot_survival']
|
|
13
|
+
|
|
14
|
+
_COLORS = ('#0072B2', '#D55E00', '#009E73', '#CC79A7', '#000000')
|
|
15
|
+
_STYLES = ('-', '--', '-.', ':')
|
|
16
|
+
_MARKERS = ('o', 's', '^', 'D', 'v')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _axis(ax):
|
|
20
|
+
if ax is None:
|
|
21
|
+
import matplotlib.pyplot as plt
|
|
22
|
+
_, ax = plt.subplots(layout='constrained')
|
|
23
|
+
return ax
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _interval_label(result):
|
|
27
|
+
return f'{100 * result.probability:g}% pointwise posterior interval'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _response_label(result):
|
|
31
|
+
centered = result.metadata.get('center', False)
|
|
32
|
+
if result.scale == 'latent':
|
|
33
|
+
return 'Change in h' if centered or result.kind not in ('univariate', 'bivariate') else 'h'
|
|
34
|
+
probability = result.metadata.get('family') == 'binomial'
|
|
35
|
+
label = ('Relative hazard' if result.metadata.get('family') == 'survival' else
|
|
36
|
+
'Predicted probability' if probability else 'Predicted count mean' if
|
|
37
|
+
result.metadata.get('family') in ('poisson', 'negative_binomial') else 'Predicted outcome mean')
|
|
38
|
+
return ('Change in ' + label.lower()) if centered or result.kind not in ('univariate', 'bivariate') else label
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def plot_trace(fit, parameter='lambda', indices=None, ax=None):
|
|
42
|
+
"""Plot one parameter's saved draws for each chain.
|
|
43
|
+
|
|
44
|
+
``indices`` selects one component of an array-valued parameter. Use one
|
|
45
|
+
integer per parameter axis, for example ``indices=0`` for default-sampler
|
|
46
|
+
beta or ``indices=(0, 0)`` for structured beta. Only saved draws are shown;
|
|
47
|
+
warmup draws are excluded. A trace does not establish convergence.
|
|
48
|
+
"""
|
|
49
|
+
values = np.asarray(fit.posterior_samples(parameter))
|
|
50
|
+
if values.ndim < 2:
|
|
51
|
+
raise ValueError('parameter must have chain and draw dimensions')
|
|
52
|
+
if values.ndim > 2:
|
|
53
|
+
if indices is None:
|
|
54
|
+
raise ValueError('indices are required for a nonscalar parameter')
|
|
55
|
+
trailing = (indices,) if isinstance(indices, Integral) else tuple(indices)
|
|
56
|
+
if len(trailing) != values.ndim - 2 or any(isinstance(i, (bool, np.bool_)) or not isinstance(i, Integral) for i in trailing):
|
|
57
|
+
raise ValueError('indices must specify one integer per parameter dimension')
|
|
58
|
+
if any(i < 0 or i >= size for i, size in zip(trailing, values.shape[2:])):
|
|
59
|
+
raise ValueError('parameter index is out of range')
|
|
60
|
+
values = values[(slice(None), slice(None)) + trailing]
|
|
61
|
+
elif indices is not None:
|
|
62
|
+
raise ValueError('indices do not apply to a scalar parameter')
|
|
63
|
+
ax = _axis(ax)
|
|
64
|
+
x = np.arange(1, values.shape[1] + 1)
|
|
65
|
+
for chain, row in enumerate(values):
|
|
66
|
+
ax.plot(x, row, color=_COLORS[chain % len(_COLORS)],
|
|
67
|
+
linestyle=_STYLES[chain % len(_STYLES)], linewidth=1,
|
|
68
|
+
label=f'Chain {chain + 1}')
|
|
69
|
+
suffix = '' if indices is None else f'[{indices}]'
|
|
70
|
+
ax.set(xlabel='Saved draw', ylabel=f'{parameter}{suffix}', title='Posterior trace (warmup excluded)')
|
|
71
|
+
ax.legend()
|
|
72
|
+
return ax
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def plot_pips(fit, ax=None):
|
|
76
|
+
"""Plot each exposure's posterior inclusion probability (PIP) from zero to one.
|
|
77
|
+
|
|
78
|
+
A PIP is the proportion of saved states in which an exposure is selected.
|
|
79
|
+
Group selection also shows group PIPs and exposure PIPs among states where
|
|
80
|
+
the group is selected. Marker shapes distinguish these quantities; they are
|
|
81
|
+
probabilities, not uncertainty intervals. A selection fit is required.
|
|
82
|
+
"""
|
|
83
|
+
rows = fit.inclusion_probabilities()
|
|
84
|
+
if not rows:
|
|
85
|
+
raise ValueError('fit has no inclusion probabilities to plot')
|
|
86
|
+
ax = _axis(ax)
|
|
87
|
+
y = np.arange(len(rows))
|
|
88
|
+
ax.scatter([row['pip'] for row in rows], y, marker='o', color=_COLORS[0], label='Exposure PIP')
|
|
89
|
+
if 'group_pip' in rows[0]:
|
|
90
|
+
ax.scatter([row['group_pip'] for row in rows], y + .15, marker='s',
|
|
91
|
+
facecolors='none', edgecolors=_COLORS[1], label='Group PIP')
|
|
92
|
+
ax.scatter([row['conditional_pip'] for row in rows], y - .15, marker='^',
|
|
93
|
+
color=_COLORS[2], label='Within-group conditional PIP')
|
|
94
|
+
ax.set_yticks(y, [row['variable'] for row in rows])
|
|
95
|
+
ax.set(xlim=(0, 1), xlabel='Posterior inclusion probability', ylabel='Exposure')
|
|
96
|
+
ax.legend()
|
|
97
|
+
return ax
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def plot_response(curve, ax=None):
|
|
101
|
+
"""Plot exposure curves with a separate credible interval at each grid point.
|
|
102
|
+
|
|
103
|
+
Intervals have equal posterior probability below and above their limits.
|
|
104
|
+
Multiple exposures share one axis; use separate calls when their units
|
|
105
|
+
differ. Colors, line styles, markers and labels identify each curve.
|
|
106
|
+
"""
|
|
107
|
+
if curve.kind != 'univariate':
|
|
108
|
+
raise ValueError('plot_response requires a univariate_response result')
|
|
109
|
+
ax = _axis(ax)
|
|
110
|
+
names = list(dict.fromkeys(location['variable'] for location in curve.locations))
|
|
111
|
+
for index, name in enumerate(names):
|
|
112
|
+
selected = np.array([i for i, row in enumerate(curve.locations) if row['variable'] == name])
|
|
113
|
+
x = np.array([curve.locations[i]['value'] for i in selected])
|
|
114
|
+
order = np.argsort(x)
|
|
115
|
+
selected, x = selected[order], x[order]
|
|
116
|
+
color = _COLORS[index % len(_COLORS)]
|
|
117
|
+
ax.plot(x, curve.mean[selected], label=name, color=color,
|
|
118
|
+
linestyle=_STYLES[index % len(_STYLES)],
|
|
119
|
+
marker=_MARKERS[index % len(_MARKERS)], markevery=max(1, len(x) // 8), markersize=3)
|
|
120
|
+
ax.fill_between(x, curve.lower[selected], curve.upper[selected], color=color, alpha=.16,
|
|
121
|
+
label=_interval_label(curve) if index == 0 else None)
|
|
122
|
+
if curve.metadata.get('center', False):
|
|
123
|
+
ax.axhline(0, color='#555555', linewidth=.8, linestyle=':')
|
|
124
|
+
ax.set(xlabel=f'{names[0]} (original units)' if len(names) == 1 else 'Exposure value (original units)',
|
|
125
|
+
ylabel=_response_label(curve))
|
|
126
|
+
ax.legend()
|
|
127
|
+
return ax
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def plot_overall(effect, ax=None):
|
|
131
|
+
"""Plot profile differences from overall_effect, with intervals at each quantile."""
|
|
132
|
+
if effect.kind != 'overall':
|
|
133
|
+
raise ValueError('plot_overall requires an overall_effect result')
|
|
134
|
+
ax = _axis(ax)
|
|
135
|
+
x = np.array([row['quantile'] for row in effect.locations])
|
|
136
|
+
order = np.argsort(x)
|
|
137
|
+
ax.vlines(x, effect.lower, effect.upper, color=_COLORS[0], label=_interval_label(effect))
|
|
138
|
+
ax.plot(x[order], effect.mean[order], color=_COLORS[0], marker='o', label='Posterior mean')
|
|
139
|
+
ax.axhline(0, color='#555555', linewidth=.8, linestyle=':')
|
|
140
|
+
ax.set(xlabel='Quantile of every exposure', ylabel=_response_label(effect),
|
|
141
|
+
title=f"Reference: all exposures at quantile {effect.metadata['reference']:g}")
|
|
142
|
+
ax.legend()
|
|
143
|
+
return ax
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def plot_surface(surface, ax=None):
|
|
147
|
+
"""Plot a posterior mean surface; uncertainty remains in the result.
|
|
148
|
+
|
|
149
|
+
The color map shows values over the two exposure grids. Centered surfaces
|
|
150
|
+
use different colors for negative and positive values, with zero at the
|
|
151
|
+
middle of the scale. Uncentered surfaces use one ordered color scale.
|
|
152
|
+
The colorbar labels the posterior mean. Use result.lower/upper or
|
|
153
|
+
to_records() for uncertainty.
|
|
154
|
+
"""
|
|
155
|
+
if surface.kind != 'bivariate':
|
|
156
|
+
raise ValueError('plot_surface requires a bivariate_response result')
|
|
157
|
+
if min(surface.shape) < 2:
|
|
158
|
+
raise ValueError('surface plotting requires at least two values per grid')
|
|
159
|
+
ax = _axis(ax)
|
|
160
|
+
values = surface.mean.reshape(surface.shape).T
|
|
161
|
+
options = {}
|
|
162
|
+
if surface.metadata.get('center', False):
|
|
163
|
+
from matplotlib.colors import Normalize
|
|
164
|
+
limit = max(float(np.max(np.abs(values))), np.finfo(float).eps)
|
|
165
|
+
options.update(cmap='RdBu_r', norm=Normalize(vmin=-limit, vmax=limit))
|
|
166
|
+
else:
|
|
167
|
+
options['cmap'] = 'cividis'
|
|
168
|
+
artist = ax.pcolormesh(surface.grids[0], surface.grids[1], values,
|
|
169
|
+
shading='nearest', **options)
|
|
170
|
+
ax.figure.colorbar(artist, ax=ax, label=f'Posterior mean: {_response_label(surface).lower()}')
|
|
171
|
+
ax.set(xlabel=f'{surface.variables[0]} (original units)',
|
|
172
|
+
ylabel=f'{surface.variables[1]} (original units)',
|
|
173
|
+
title='Posterior mean surface (uncertainty not shown)')
|
|
174
|
+
return ax
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def plot_survival(prediction, row=0, ax=None):
|
|
179
|
+
"""Plot survival, cumulative incidence or hazards with pointwise intervals.
|
|
180
|
+
|
|
181
|
+
Survival is the probability of no event by a time. Cumulative incidence is
|
|
182
|
+
the probability of a specified cause by a time, accounting for other causes.
|
|
183
|
+
Hazards are event rates among those still at risk and are plotted only at
|
|
184
|
+
evaluated times. Intervals apply to each time separately.
|
|
185
|
+
"""
|
|
186
|
+
if prediction.kind not in ('survival', 'hazard', 'cumulative_incidence') or prediction.mean.ndim != 2:
|
|
187
|
+
raise ValueError('plot_survival requires a SurvivalPrediction')
|
|
188
|
+
if isinstance(row, (bool, np.bool_)) or not isinstance(row, Integral) or not 0 <= row < prediction.mean.shape[0]:
|
|
189
|
+
raise ValueError('row must index a prediction profile')
|
|
190
|
+
ax = _axis(ax)
|
|
191
|
+
if prediction.kind in ('survival', 'cumulative_incidence'):
|
|
192
|
+
ax.plot(prediction.times, prediction.mean[row], color=_COLORS[0], label='Posterior mean')
|
|
193
|
+
ax.fill_between(prediction.times, prediction.lower[row], prediction.upper[row],
|
|
194
|
+
color=_COLORS[0], alpha=.16, label=_interval_label(prediction))
|
|
195
|
+
ax.set(xlabel='Time', ylabel=r'$S(t)$' if prediction.kind=='survival' else r'$F_k(t)$', ylim=(0, 1))
|
|
196
|
+
else:
|
|
197
|
+
# A sparse time grid need not include every baseline cut. Show only
|
|
198
|
+
# evaluated hazards, rather than implying a false step location.
|
|
199
|
+
ax.vlines(prediction.times, prediction.lower[row], prediction.upper[row],
|
|
200
|
+
color=_COLORS[0], label=_interval_label(prediction))
|
|
201
|
+
ax.plot(prediction.times, prediction.mean[row], color=_COLORS[0],
|
|
202
|
+
marker='o', linestyle='none', label='Posterior mean')
|
|
203
|
+
ax.set(xlabel='Time', ylabel='Hazard (per time unit)')
|
|
204
|
+
ax.legend()
|
|
205
|
+
return ax
|
kernelmix/priors.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Scalar priors for Bayesian kernel machine regression (BKMR).
|
|
2
|
+
|
|
3
|
+
The default Gaussian/probit sampler follows bkmr 0.2.2 prior conventions.
|
|
4
|
+
Generalized and structured samplers use separate amplitude defaults.
|
|
5
|
+
Bounds for ``invunif`` apply to 1/r.
|
|
6
|
+
"""
|
|
7
|
+
import math
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Prior:
|
|
15
|
+
"""Prior settings for exposure relevance, model variances and outcome models.
|
|
16
|
+
|
|
17
|
+
A prior describes a parameter before the outcome data are used. Relevance r_m
|
|
18
|
+
controls how exposure m changes the kernel. Paired ``*_mean``/``*_sd``
|
|
19
|
+
fields give gamma means and standard deviations. ``beta_sd`` instead gives
|
|
20
|
+
the normal coefficient-prior standard deviation. ``*_shape``/``*_rate``
|
|
21
|
+
fields give gamma shape and rate; residual variance uses an inverse-gamma.
|
|
22
|
+
|
|
23
|
+
``invunif`` means 1/r_m ~ Uniform(r_lower, r_upper); ``unif`` means
|
|
24
|
+
r_m ~ Uniform(r_lower, r_upper). Excluded exposures have r_m=0.
|
|
25
|
+
For the default Gaussian/probit sampler, ``lambda`` is tau/sigma2;
|
|
26
|
+
for count/survival and structured samplers, it is the GP variance tau directly.
|
|
27
|
+
Here tau is the variance multiplying the kernel and sigma2 is residual variance.
|
|
28
|
+
"""
|
|
29
|
+
r_distribution: str = 'invunif'
|
|
30
|
+
r_mean: float = 5.0
|
|
31
|
+
r_sd: float = 5.0
|
|
32
|
+
r_lower: float = 0.0
|
|
33
|
+
r_upper: float = 100.0
|
|
34
|
+
lambda_mean: float = 10.0
|
|
35
|
+
lambda_sd: float = 10.0
|
|
36
|
+
sigma2_shape: float = 0.001
|
|
37
|
+
sigma2_rate: float = 0.001
|
|
38
|
+
inclusion_alpha: float = 1.0
|
|
39
|
+
inclusion_beta: float = 1.0
|
|
40
|
+
cluster_lambda_mean: float = 10.0
|
|
41
|
+
cluster_lambda_sd: float = 10.0
|
|
42
|
+
beta_sd: float = 5.0
|
|
43
|
+
dispersion_shape: float = 2.0
|
|
44
|
+
dispersion_rate: float = 0.1
|
|
45
|
+
baseline_shape: float = 0.1
|
|
46
|
+
baseline_rate: float = 0.1
|
|
47
|
+
|
|
48
|
+
def __post_init__(self):
|
|
49
|
+
if self.r_distribution not in ('gamma', 'unif', 'invunif'):
|
|
50
|
+
raise ValueError("r_distribution must be 'gamma', 'unif', or 'invunif'")
|
|
51
|
+
for name in ('r_mean', 'r_sd', 'lambda_mean', 'lambda_sd',
|
|
52
|
+
'sigma2_shape', 'sigma2_rate', 'inclusion_alpha', 'inclusion_beta',
|
|
53
|
+
'cluster_lambda_mean', 'cluster_lambda_sd', 'beta_sd',
|
|
54
|
+
'dispersion_shape', 'dispersion_rate', 'baseline_shape', 'baseline_rate'):
|
|
55
|
+
value = getattr(self, name)
|
|
56
|
+
if isinstance(value, (bool, np.bool_)) or not np.isscalar(value) or not np.isfinite(value) or value <= 0:
|
|
57
|
+
raise ValueError(f'{name} must be a finite positive scalar')
|
|
58
|
+
for name in ('r_lower', 'r_upper'):
|
|
59
|
+
value = getattr(self, name)
|
|
60
|
+
if isinstance(value, (bool, np.bool_)) or not np.isscalar(value) or not np.isfinite(value):
|
|
61
|
+
raise ValueError(f'{name} must be finite')
|
|
62
|
+
if not 0 <= self.r_lower < self.r_upper:
|
|
63
|
+
raise ValueError('r bounds must satisfy 0 <= r_lower < r_upper')
|
|
64
|
+
# Reject gamma parameters that overflow/underflow before any fit starts.
|
|
65
|
+
for mean, sd in ((self.r_mean, self.r_sd), (self.lambda_mean, self.lambda_sd),
|
|
66
|
+
(self.cluster_lambda_mean, self.cluster_lambda_sd)):
|
|
67
|
+
shape, rate = (mean / sd) ** 2, mean / sd ** 2
|
|
68
|
+
if not np.isfinite(shape) or not np.isfinite(rate) or min(shape, rate) <= 0:
|
|
69
|
+
raise ValueError('gamma mean and sd imply unusable shape/rate')
|
|
70
|
+
|
|
71
|
+
def sample_r(self, rng):
|
|
72
|
+
if self.r_distribution == 'gamma':
|
|
73
|
+
value = rng.gamma((self.r_mean / self.r_sd) ** 2, self.r_sd ** 2 / self.r_mean)
|
|
74
|
+
else:
|
|
75
|
+
value = rng.uniform(self.r_lower, self.r_upper)
|
|
76
|
+
if self.r_distribution == 'invunif':
|
|
77
|
+
value = np.inf if value == 0 else 1.0 / value
|
|
78
|
+
if not np.isfinite(value) or value <= 0:
|
|
79
|
+
raise FloatingPointError('prior draw is outside finite positive range; rescale prior')
|
|
80
|
+
return float(value)
|
|
81
|
+
|
|
82
|
+
def log_r(self, value):
|
|
83
|
+
if not np.isfinite(value) or value <= 0:
|
|
84
|
+
return -np.inf
|
|
85
|
+
if self.r_distribution == 'gamma':
|
|
86
|
+
return gamma_logpdf(value, self.r_mean, self.r_sd)
|
|
87
|
+
transformed = 1.0 / value if self.r_distribution == 'invunif' else value
|
|
88
|
+
if not self.r_lower <= transformed <= self.r_upper:
|
|
89
|
+
return -np.inf
|
|
90
|
+
return -math.log(self.r_upper - self.r_lower) - (2 * math.log(value) if self.r_distribution == 'invunif' else 0)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def gamma_logpdf(value, mean, sd):
|
|
94
|
+
if not np.isfinite(value) or value <= 0:
|
|
95
|
+
return -np.inf
|
|
96
|
+
shape, rate = (mean / sd) ** 2, mean / sd ** 2
|
|
97
|
+
return shape * math.log(rate) - math.lgamma(shape) + (shape - 1) * math.log(value) - rate * value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
PriorConfig = Prior
|