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 ADDED
@@ -0,0 +1,36 @@
1
+ """Bayesian kernel machine regression (BKMR) for exposure mixtures.
2
+
3
+ The exposure function h describes how an outcome varies with an exposure
4
+ profile z, allowing nonlinear associations and interactions. Linear adjustment
5
+ uses covariates x and coefficients beta. These are associations; fitting alone
6
+ does not identify causal effects. Posterior draws describe uncertainty after
7
+ using the training data.
8
+ """
9
+ from ._basis import kernel_matrix
10
+ from ._kernel import gaussian_kernel
11
+ from .diagnostics import diagnose
12
+ from .effects import (
13
+ CurveResult,
14
+ EffectResult,
15
+ SurfaceResult,
16
+ bivariate_response,
17
+ interaction_effects,
18
+ overall_effect,
19
+ single_exposure_effects,
20
+ univariate_response,
21
+ )
22
+ from .model import fit
23
+ from .plots import plot_overall, plot_pips, plot_response, plot_surface, plot_survival, plot_trace
24
+ from .priors import Prior
25
+ from .result import BKMRFit, Prediction, SurvivalPrediction
26
+ from .structured_result import CompetingRiskPrediction, RandomCoefficientPrediction, StructuredFit
27
+
28
+ __version__ = "0.3.0"
29
+ __all__ = [
30
+ "StructuredFit", "RandomCoefficientPrediction", "CompetingRiskPrediction", "kernel_matrix",
31
+ "fit", "Prior", "BKMRFit", "Prediction", "gaussian_kernel", "diagnose",
32
+ "EffectResult", "CurveResult", "SurfaceResult", "overall_effect",
33
+ "univariate_response", "bivariate_response", "single_exposure_effects",
34
+ "interaction_effects", "plot_trace", "plot_pips", "plot_response",
35
+ "plot_overall", "plot_surface", "plot_survival", "SurvivalPrediction",
36
+ ]
kernelmix/_basis.py ADDED
@@ -0,0 +1,299 @@
1
+ """Dense and finite-feature Gaussian process (GP) representations.
2
+
3
+ Predictive-process (PP) and random Fourier feature (RFF) paths use fixed basis
4
+ randomness and form only row-by-feature and feature-by-feature arrays.
5
+ PP omits the parent GP's residual variance;
6
+ RFF and PP are model approximations, not exact dense-GP computations.
7
+ """
8
+
9
+ from numbers import Integral
10
+
11
+ import numpy as np
12
+
13
+ _LIMIT = 512 * 1024**2
14
+
15
+
16
+ def _budget(elements):
17
+ if 8 * int(elements) > _LIMIT:
18
+ raise ValueError('basis working arrays exceed the 512 MiB memory budget')
19
+
20
+
21
+ def _matrix(value, name, columns=None):
22
+ a = np.asarray(value, dtype=float)
23
+ if a.ndim != 2 or not a.shape[0] or not a.shape[1] or not np.isfinite(a).all():
24
+ raise ValueError(f'{name} must be a nonempty finite numeric matrix')
25
+ if columns is not None and a.shape[1] != columns:
26
+ raise ValueError(f'{name} must have {columns} columns')
27
+ return a
28
+
29
+
30
+ def _settings(kernel, alpha):
31
+ if kernel not in ('gaussian', 'matern32', 'rational_quadratic'):
32
+ raise ValueError('kernel must be gaussian, matern32 or rational_quadratic')
33
+ if (isinstance(alpha, (bool, np.bool_)) or not np.isscalar(alpha)
34
+ or not np.isfinite(alpha) or alpha <= 0):
35
+ raise ValueError('alpha must be finite and positive')
36
+ return float(alpha)
37
+
38
+
39
+ def _relevance(r, columns):
40
+ r = np.ones(columns) if r is None else np.asarray(r, dtype=float)
41
+ if r.shape != (columns,) or not np.isfinite(r).all() or np.any(r < 0):
42
+ raise ValueError('r must be a finite nonnegative vector, one value per exposure')
43
+ return r
44
+
45
+
46
+ def kernel_matrix(Z1, Z2=None, r=None, *, kernel='gaussian', alpha=1):
47
+ """Return Gaussian, Matérn 3/2 or rational-quadratic kernel values.
48
+
49
+ A kernel assigns similarity to pairs of exposure profiles. Here each profile
50
+ has similarity one with itself. Let q be sum_m r_m * (z_m - z'_m)**2.
51
+ The three kernels are exp(-q), (1+sqrt(6q))*exp(-sqrt(6q)), and
52
+ (1+q/alpha)**(-alpha). Relevance r_m is nonnegative; alpha is positive.
53
+ Inputs are used directly, without training standardization.
54
+ """
55
+ a = _matrix(Z1, 'Z1')
56
+ b = a if Z2 is None else _matrix(Z2, 'Z2', a.shape[1])
57
+ alpha = _settings(kernel, alpha)
58
+ weights = _relevance(r, a.shape[1])
59
+ _budget(4 * len(a) * len(b))
60
+ distance = np.zeros((len(a), len(b)))
61
+ with np.errstate(over='ignore', invalid='ignore', under='ignore', divide='ignore'):
62
+ for j in np.flatnonzero(weights):
63
+ distance += weights[j] * np.square(a[:, j, None] - b[None, :, j])
64
+ if kernel == 'gaussian':
65
+ result = np.exp(-distance)
66
+ elif kernel == 'matern32':
67
+ root = np.sqrt(distance) * np.sqrt(6.)
68
+ # exp(log1p(root)-root) avoids inf*0 at very large distances.
69
+ result = np.exp(np.log1p(root) - root)
70
+ result[np.isinf(root)] = 0.
71
+ else:
72
+ # Log-add-exp also handles q/alpha overflowing at tiny alpha.
73
+ log_q = np.full_like(distance, -np.inf)
74
+ np.log(distance, out=log_q, where=distance > 0)
75
+ exponent = alpha * np.logaddexp(0., log_q - np.log(alpha))
76
+ result = np.exp(-exponent)
77
+ if not np.isfinite(result).all():
78
+ raise FloatingPointError('kernel calculation overflowed; rescale exposures or relevance')
79
+ return result
80
+
81
+
82
+ def _eigen(kernel):
83
+ n = len(kernel)
84
+ _budget(10 * n * n)
85
+ values, vectors = np.linalg.eigh((kernel + kernel.T) / 2)
86
+ largest = max(0., float(values.max(initial=0)))
87
+ tolerance = 100 * n * np.finfo(float).eps * largest
88
+ if values.min(initial=0) < -tolerance:
89
+ raise np.linalg.LinAlgError('kernel is materially indefinite')
90
+ values = np.where(values > tolerance, values, 0.)
91
+ return values, vectors
92
+
93
+
94
+ def _inverse_root(kernel):
95
+ values, vectors = _eigen(kernel)
96
+ inverse = np.zeros_like(values)
97
+ np.divide(1., np.sqrt(values), out=inverse, where=values > 0)
98
+ return (vectors * inverse) @ vectors.T
99
+
100
+
101
+ def _maxmin(Z, count):
102
+ """Deterministic max-min knots, using length-n distances rather than n²."""
103
+ chosen = []
104
+ nearest = np.full(len(Z), np.inf)
105
+ current = 0
106
+ for _ in range(min(count, len(Z))):
107
+ chosen.append(current)
108
+ distance = np.zeros(len(Z))
109
+ with np.errstate(over='ignore', invalid='ignore'):
110
+ for column in range(Z.shape[1]):
111
+ distance += np.square(Z[:, column] - Z[current, column])
112
+ if not np.isfinite(distance).all():
113
+ raise FloatingPointError('knot distance overflowed; rescale exposures')
114
+ nearest = np.minimum(nearest, distance)
115
+ nearest[chosen] = 0.
116
+ current = int(np.argmax(nearest))
117
+ if nearest[current] == 0:
118
+ break
119
+ return Z[chosen].copy(), np.asarray(chosen, dtype=int)
120
+
121
+
122
+ def prepare_basis(Z, *, approximation='dense', kernel='gaussian', alpha=1,
123
+ n_components=32, knots=None, rng=None):
124
+ """Prepare serializable arrays/scalars for a fixed GP representation.
125
+
126
+ RFF components count frequency pairs, hence there are twice as many
127
+ features. Its base frequencies are frozen and do not depend on relevance.
128
+ PP automatically chooses at most n distinct max-min knots when omitted.
129
+ Explicit knots are already in the caller's transformed exposure units.
130
+ """
131
+ z = _matrix(Z, 'Z')
132
+ alpha = _settings(kernel, alpha)
133
+ if approximation not in ('dense', 'pp', 'rff'):
134
+ raise ValueError('approximation must be dense, pp or rff')
135
+ if isinstance(n_components, (bool, np.bool_)) or not isinstance(n_components, Integral) or n_components < 1:
136
+ raise ValueError('n_components must be a positive integer')
137
+ n_components = int(n_components)
138
+ if knots is not None and approximation != 'pp':
139
+ raise ValueError('knots apply only to the predictive-process approximation')
140
+ result = dict(approximation=approximation, kernel=kernel, alpha=alpha,
141
+ n_exposures=z.shape[1], n_training=len(z),
142
+ numerical_rank_rule='eigenvalue > 100*n*eps*largest',
143
+ version=1)
144
+ if approximation == 'dense':
145
+ _budget(10 * len(z)**2 + z.size)
146
+ result.update(train_Z=z.copy(), n_features=len(z))
147
+ elif approximation == 'pp':
148
+ if knots is None:
149
+ count = min(n_components, len(z))
150
+ _budget(5*len(z)*count + 10*count*count + z.size)
151
+ chosen, indices = _maxmin(z, count)
152
+ else:
153
+ chosen = _matrix(knots, 'knots', z.shape[1]).copy()
154
+ count = len(chosen)
155
+ _budget(5*len(z)*count + 10*count*count + chosen.size)
156
+ indices = np.empty(0, dtype=int)
157
+ result.update(knots=chosen, knot_indices=indices, n_features=len(chosen),
158
+ residual_correction=False, knot_selection='maxmin' if knots is None else 'explicit')
159
+ else:
160
+ _budget(5*len(z)*n_components + 3*n_components*z.shape[1])
161
+ if rng is None:
162
+ rng = np.random.default_rng()
163
+ if not isinstance(rng, np.random.Generator):
164
+ raise ValueError('rng must be a NumPy Generator or None')
165
+ base = rng.normal(size=(n_components, z.shape[1]))
166
+ if kernel == 'matern32':
167
+ scales = np.sqrt(3. / rng.chisquare(3., size=n_components))
168
+ elif kernel == 'rational_quadratic':
169
+ scales = np.sqrt(rng.gamma(alpha, 1/alpha, size=n_components))
170
+ else:
171
+ scales = np.ones(n_components)
172
+ if not np.isfinite(base).all() or not np.isfinite(scales).all() or np.any(scales <= 0):
173
+ raise FloatingPointError('spectral frequency draw left its finite positive range')
174
+ result.update(base_frequencies=base, frequency_scales=scales,
175
+ n_components=n_components, n_features=2*n_components,
176
+ bit_generator=type(rng.bit_generator).__name__)
177
+ return result
178
+
179
+
180
+ def basis_matrix(Z, r, basis):
181
+ """Train/new features; PP and RFF do not construct a training n-by-n matrix."""
182
+ z = _matrix(Z, 'Z', int(basis['n_exposures']))
183
+ weights = _relevance(r, z.shape[1])
184
+ kernel, alpha = basis['kernel'], basis['alpha']
185
+ _settings(kernel, alpha)
186
+ mode = basis['approximation']
187
+ if mode == 'rff':
188
+ base = _matrix(basis['base_frequencies'], 'base_frequencies', z.shape[1])
189
+ scales = np.asarray(basis['frequency_scales'], dtype=float)
190
+ if scales.shape != (len(base),) or not np.isfinite(scales).all() or np.any(scales <= 0):
191
+ raise ValueError('invalid frozen frequency scales')
192
+ _budget(5*len(z)*len(base) + 2*base.size)
193
+ with np.errstate(over='ignore', invalid='ignore'):
194
+ frequency = base * scales[:, None] * (np.sqrt(2.)*np.sqrt(weights))[None, :]
195
+ phase = z @ frequency.T
196
+ result = np.concatenate((np.cos(phase), np.sin(phase)), axis=1) / np.sqrt(len(base))
197
+ if not np.isfinite(result).all():
198
+ raise FloatingPointError('RFF features overflowed; rescale exposures or relevance')
199
+ return result
200
+ if mode == 'pp':
201
+ knots = _matrix(basis['knots'], 'knots', z.shape[1])
202
+ _budget(5*len(z)*len(knots) + 10*len(knots)**2)
203
+ root_inverse = _inverse_root(kernel_matrix(knots, r=weights, kernel=kernel, alpha=alpha))
204
+ return kernel_matrix(z, knots, weights, kernel=kernel, alpha=alpha) @ root_inverse
205
+ if mode != 'dense':
206
+ raise ValueError('unknown approximation in saved basis')
207
+ train = _matrix(basis['train_Z'], 'train_Z', z.shape[1])
208
+ _budget(5*len(z)*len(train) + 10*len(train)**2)
209
+ values, vectors = _eigen(kernel_matrix(train, r=weights, kernel=kernel, alpha=alpha))
210
+ inverse = np.zeros_like(values)
211
+ np.divide(1., np.sqrt(values), out=inverse, where=values > 0)
212
+ if z.shape == train.shape and np.array_equal(z, train):
213
+ return vectors * np.sqrt(values)
214
+ return kernel_matrix(z, train, weights, kernel=kernel, alpha=alpha) @ (vectors * inverse)
215
+
216
+
217
+ def _supported_values(values, eigenvalues, vectors, name):
218
+ values = np.asarray(values, dtype=float)
219
+ if values.shape != (len(eigenvalues),) or not np.isfinite(values).all():
220
+ raise ValueError(f'{name} must contain one finite value per anchor')
221
+ retained = eigenvalues > 0
222
+ projection = vectors[:, retained] @ (vectors[:, retained].T @ values)
223
+ tolerance = 1000 * len(values) * np.finfo(float).eps * max(1., float(np.linalg.norm(values)))
224
+ if np.linalg.norm(values-projection) > tolerance:
225
+ raise ValueError(f'{name} is outside the saved kernel numerical support')
226
+ return values
227
+
228
+
229
+ def basis_predict(Znew, r, basis, weights, lambda_, rng, training_h=None):
230
+ """Joint h draws given one state, using saved anchor values when available.
231
+
232
+ For dense models ``training_h`` means saved training h; for PP it means
233
+ saved knot h, including the amplitude. These anchors avoid eigensystem
234
+ coordinate changes across serialization/platforms. PP/RFF predictions are
235
+ deterministic given their finite-basis state; no parent-GP residual is added.
236
+ """
237
+ if isinstance(lambda_, (bool, np.bool_)) or not np.isscalar(lambda_) or not np.isfinite(lambda_) or lambda_ <= 0:
238
+ raise ValueError('lambda_ must be finite and positive')
239
+ z = _matrix(Znew, 'Znew', int(basis['n_exposures']))
240
+ r = _relevance(r, z.shape[1])
241
+ w = np.asarray(weights, dtype=float)
242
+ if w.shape != (int(basis['n_features']),) or not np.isfinite(w).all():
243
+ raise ValueError('weights must be finite with one value per basis feature')
244
+ mode, kernel, alpha = basis['approximation'], basis['kernel'], basis['alpha']
245
+ if mode == 'rff':
246
+ with np.errstate(over='ignore', invalid='ignore'):
247
+ result = np.sqrt(lambda_) * (basis_matrix(z, r, basis) @ w)
248
+ if not np.isfinite(result).all():
249
+ raise FloatingPointError('RFF prediction overflowed')
250
+ return result
251
+ if mode not in ('pp', 'dense'):
252
+ raise ValueError('unknown approximation in saved basis')
253
+ anchors = _matrix(basis['knots'] if mode == 'pp' else basis['train_Z'], 'anchors', z.shape[1])
254
+ _budget(10*len(anchors)**2 + 6*len(z)*len(anchors) + (10*len(z)**2 if mode == 'dense' else 0))
255
+ values, vectors = _eigen(kernel_matrix(anchors, r=r, kernel=kernel, alpha=alpha))
256
+ if training_h is None:
257
+ root = ((vectors * np.sqrt(values)) @ vectors.T if mode == 'pp'
258
+ else vectors * np.sqrt(values))
259
+ h = np.sqrt(lambda_) * (root @ w)
260
+ else:
261
+ h = _supported_values(training_h, values, vectors, 'training_h')
262
+ if not np.isfinite(h).all():
263
+ raise FloatingPointError('anchor GP values overflowed')
264
+ inverse = np.zeros_like(values)
265
+ np.divide(1., values, out=inverse, where=values > 0)
266
+ pinv = (vectors * inverse) @ vectors.T
267
+ cross = kernel_matrix(z, anchors, r, kernel=kernel, alpha=alpha)
268
+ mean = cross @ (pinv @ h)
269
+ if not np.isfinite(mean).all():
270
+ raise FloatingPointError('conditional GP mean overflowed')
271
+ if mode == 'pp':
272
+ return mean
273
+ if mode != 'dense':
274
+ raise ValueError('unknown approximation in saved basis')
275
+ if not isinstance(rng, np.random.Generator):
276
+ raise ValueError('dense prediction requires a NumPy Generator')
277
+ covariance = lambda_ * (kernel_matrix(z, r=r, kernel=kernel, alpha=alpha) - cross @ pinv @ cross.T)
278
+ covariance = (covariance + covariance.T) / 2
279
+ if not np.isfinite(covariance).all():
280
+ raise FloatingPointError('conditional GP covariance overflowed')
281
+ eig, evec = np.linalg.eigh(covariance)
282
+ # The parent covariance sets the scale even when the conditional variance
283
+ # vanishes. This clips cancellation roundoff, never material negative modes.
284
+ tolerance = 100 * (len(z)+len(anchors)) * np.finfo(float).eps * lambda_
285
+ if eig.min(initial=0) < -tolerance:
286
+ raise np.linalg.LinAlgError('dense conditional GP covariance is materially indefinite')
287
+ eig = np.where(eig > tolerance, eig, 0.)
288
+ result = mean + (evec * np.sqrt(eig)) @ rng.normal(size=len(z))
289
+ # Already conditioned anchors and identical active profiles are the same
290
+ # process value; preserve those identities exactly after numerical solves.
291
+ active = r > 0
292
+ for row in range(len(z)):
293
+ matches = np.flatnonzero(np.all(z[row, active] == anchors[:, active], axis=1))
294
+ if len(matches):
295
+ result[row] = h[matches[0]]
296
+ _, first, inverse_rows = np.unique(z[:, active], axis=0, return_index=True, return_inverse=True)
297
+ if not np.isfinite(result).all():
298
+ raise FloatingPointError('joint GP prediction overflowed')
299
+ return result[first][inverse_rows]
kernelmix/_clusters.py ADDED
@@ -0,0 +1,78 @@
1
+ """Typed observation-cluster labels and retained-row encoding.
2
+
3
+ Exposure-selection groups are handled separately from these observation labels.
4
+ """
5
+ import numpy as np
6
+
7
+
8
+ def canonical_label(value):
9
+ """Return a hashable typed identity; integer 1 differs from string '1'.
10
+
11
+ Accept only integers (excluding booleans) and nonempty strings. Missing
12
+ values are handled by ``cluster_mask``, never treated as an actual level.
13
+ """
14
+ if isinstance(value, (bool, np.bool_)):
15
+ raise ValueError('cluster labels must be integers or nonempty strings, not booleans')
16
+ if isinstance(value, (int, np.integer)):
17
+ return ('int', int(value))
18
+ if isinstance(value, (str, np.str_)) and len(value) > 0:
19
+ return ('str', str(value))
20
+ raise ValueError('cluster labels must be integers or nonempty strings')
21
+
22
+
23
+ def _labels(values, n):
24
+ labels = np.asarray(values, dtype=object)
25
+ if labels.shape != (n,):
26
+ raise ValueError('cluster must have one label per original observation')
27
+ return labels
28
+
29
+
30
+ def cluster_mask(values, n):
31
+ """Return nonmissing mask; reject unsupported labels even on dropped rows.
32
+
33
+ None or floating NaN entries denote missingness. An absent cluster vector
34
+ (``values is None``) returns an all-true mask. Finite floating labels,
35
+ infinities, booleans, empty strings and unhashable labels are invalid.
36
+ """
37
+ if values is None:
38
+ return np.ones(n, dtype=bool)
39
+ labels = _labels(values, n)
40
+ valid = np.ones(n, dtype=bool)
41
+ for i, value in enumerate(labels):
42
+ if value is None or (isinstance(value, (float, np.floating)) and np.isnan(value)):
43
+ valid[i] = False
44
+ else:
45
+ canonical_label(value)
46
+ return valid
47
+
48
+
49
+ def encode_clusters(values, n, rows=None):
50
+ """Return retained integer codes and original typed levels in first-use order.
51
+
52
+ ``n`` is the original row count; ``rows`` optionally selects retained rows.
53
+ All unsupported original labels fail. Missing labels are allowed only
54
+ outside retained rows. For absent clustering return ``(None, [])``.
55
+ """
56
+ if values is None:
57
+ return None, []
58
+ labels = _labels(values, n)
59
+ valid = cluster_mask(labels, n)
60
+ if rows is None:
61
+ retained = np.arange(n)
62
+ else:
63
+ retained = np.asarray(rows)
64
+ if retained.ndim != 1 or not np.issubdtype(retained.dtype, np.integer):
65
+ raise ValueError('cluster retained rows must be an integer vector')
66
+ if (retained < 0).any() or (retained >= n).any():
67
+ raise ValueError('cluster retained row index is out of bounds')
68
+ if not valid[retained].all():
69
+ raise ValueError('cluster labels contain missing values in retained rows')
70
+ mapping, levels = {}, []
71
+ codes = np.empty(len(retained), dtype=int)
72
+ for i, row in enumerate(retained):
73
+ identity = canonical_label(labels[row])
74
+ if identity not in mapping:
75
+ mapping[identity] = len(levels)
76
+ levels.append(identity[1])
77
+ codes[i] = mapping[identity]
78
+ return codes, levels