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