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