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/_kernel.py ADDED
@@ -0,0 +1,132 @@
1
+ """Dense Gaussian kernels and conditional latent-function moments."""
2
+ import numpy as np
3
+ from scipy.linalg import cho_solve
4
+
5
+
6
+ def _matrix(value, name):
7
+ a = np.asarray(value, dtype=float)
8
+ if a.ndim != 2 or not np.isfinite(a).all() or not a.shape[0] or not a.shape[1]:
9
+ raise ValueError(f'{name} must be a nonempty finite matrix')
10
+ return a
11
+
12
+
13
+ def gaussian_kernel(Z1, Z2=None, r=None):
14
+ """Return the Gaussian kernel between rows of Z1 and Z2.
15
+
16
+ For exposure profiles z and z', the kernel is
17
+ exp{-sum_m r_m * (z_m - z'_m)**2}. Each nonnegative r_m controls how much
18
+ exposure m changes similarity. If all r_m are zero, every kernel entry is one.
19
+ Inputs are used directly, without training standardization.
20
+ """
21
+ a = _matrix(Z1, 'Z1')
22
+ b = a if Z2 is None else _matrix(Z2, 'Z2')
23
+ if a.shape[1] != b.shape[1]:
24
+ raise ValueError('kernel inputs must have equal column counts')
25
+ weights = np.ones(a.shape[1]) if r is None else np.asarray(r, dtype=float)
26
+ if weights.shape != (a.shape[1],) or not np.isfinite(weights).all() or (weights < 0).any():
27
+ raise ValueError('r must be a finite nonnegative vector, one value per exposure')
28
+ # Accumulation avoids allocating an n1*n2*M tensor; direct differences
29
+ # avoid cancellation from the squared-norm identity for near-duplicate rows.
30
+ distance = np.zeros((len(a), len(b)))
31
+ with np.errstate(over='ignore', invalid='ignore'):
32
+ for j in np.flatnonzero(weights):
33
+ distance += weights[j] * np.square(a[:, j, None] - b[None, :, j])
34
+ kernel = np.exp(-distance)
35
+ if not np.isfinite(kernel).all():
36
+ raise ValueError('kernel calculation overflowed; rescale inputs')
37
+ return kernel
38
+
39
+
40
+ def _cluster_codes(value, n, name):
41
+ codes = np.asarray(value)
42
+ if (codes.shape != (n,) or not np.issubdtype(codes.dtype, np.integer)
43
+ or (codes < 0).any()):
44
+ raise ValueError(f'{name} must be a nonnegative integer vector with one entry per row')
45
+ return codes
46
+
47
+
48
+ def covariance_components(Z, r, lambda_, *, cluster_codes=None, lambda_cluster=None):
49
+ """Return kernel, marginal covariance and lower Cholesky, without jitter.
50
+
51
+ Covariance is I + lambda*K, plus lambda_cluster*J when cluster codes are
52
+ supplied, where J marks pairs of observations in the same cluster.
53
+ """
54
+ if not np.isscalar(lambda_) or not np.isfinite(lambda_) or lambda_ <= 0:
55
+ raise ValueError('lambda must be finite and positive')
56
+ kernel = gaussian_kernel(Z, r=r)
57
+ covariance = lambda_ * kernel
58
+ covariance.flat[::len(kernel) + 1] += 1.0
59
+ if cluster_codes is not None:
60
+ codes = _cluster_codes(cluster_codes, len(kernel), 'cluster_codes')
61
+ if (not np.isscalar(lambda_cluster) or not np.isfinite(lambda_cluster)
62
+ or lambda_cluster <= 0):
63
+ raise ValueError('lambda_cluster must be finite and positive for clustered covariance')
64
+ covariance += lambda_cluster * (codes[:, None] == codes[None, :])
65
+ elif lambda_cluster is not None:
66
+ raise ValueError('lambda_cluster requires cluster_codes')
67
+ if not np.isfinite(covariance).all():
68
+ raise ValueError('covariance overflowed')
69
+ try:
70
+ lower = np.linalg.cholesky(covariance)
71
+ except np.linalg.LinAlgError as exc:
72
+ raise np.linalg.LinAlgError('marginal covariance lost positive definiteness at floating-point precision; rescale or reduce variance amplitudes') from exc
73
+ return kernel, covariance, lower
74
+
75
+
76
+ def psd_factor(covariance):
77
+ """Eigenfactor for PSD draws; clip roundoff only, reject real indefiniteness."""
78
+ value = np.asarray(covariance, dtype=float)
79
+ if value.ndim != 2 or value.shape[0] != value.shape[1] or not np.isfinite(value).all():
80
+ raise ValueError('covariance must be a finite square matrix')
81
+ value = (value + value.T) / 2
82
+ eig, vectors = np.linalg.eigh(value)
83
+ tolerance = 100 * np.finfo(float).eps * max(1, len(value)) * max(1.0, np.max(np.abs(eig), initial=0))
84
+ if np.min(eig, initial=0) < -tolerance:
85
+ raise np.linalg.LinAlgError('conditional covariance is materially indefinite')
86
+ return vectors * np.sqrt(np.maximum(eig, 0))
87
+
88
+
89
+ def conditional_h(Z, Znew, y, X, beta, r, lambda_, sigma2, *,
90
+ cluster_codes=None, lambda_cluster=None, cluster_new_codes=None):
91
+ """Conditional h moments, or h+u when ``cluster_new_codes`` is supplied.
92
+
93
+ New nonnegative integer codes share a random intercept exactly when equal.
94
+ Codes absent from training have zero prior cross covariance with training.
95
+ Omitting new codes conditions on clustered training but predicts h alone.
96
+ """
97
+ z = _matrix(Z, 'Z')
98
+ new = _matrix(Znew, 'Znew')
99
+ outcome, design, coefficients = np.asarray(y, dtype=float), np.asarray(X, dtype=float), np.asarray(beta, dtype=float)
100
+ if outcome.shape != (len(z),) or design.ndim != 2 or design.shape[0] != len(z) or coefficients.shape != (design.shape[1],):
101
+ raise ValueError('y, X, and beta have incompatible shapes')
102
+ if any(not np.isfinite(v).all() for v in (outcome, design, coefficients)):
103
+ raise ValueError('y, X, and beta must be finite')
104
+ if not np.isscalar(sigma2) or not np.isfinite(sigma2) or sigma2 <= 0:
105
+ raise ValueError('sigma2 must be finite and positive')
106
+ _, _, lower = covariance_components(z, r, lambda_, cluster_codes=cluster_codes,
107
+ lambda_cluster=lambda_cluster)
108
+ cross = gaussian_kernel(new, z, r)
109
+ if cluster_new_codes is None:
110
+ # Preserve the original no-cluster arithmetic and seed behavior.
111
+ solved = cho_solve((lower, True), cross.T, check_finite=False)
112
+ mean = lambda_ * cross @ cho_solve((lower, True), outcome - design @ coefficients, check_finite=False)
113
+ covariance = sigma2 * lambda_ * (gaussian_kernel(new, r=r) - lambda_ * cross @ solved)
114
+ else:
115
+ if cluster_codes is None:
116
+ raise ValueError('cluster_new_codes requires clustered training data')
117
+ train_codes = _cluster_codes(cluster_codes, len(z), 'cluster_codes')
118
+ new_codes = _cluster_codes(cluster_new_codes, len(new), 'cluster_new_codes')
119
+ cross = lambda_ * cross + lambda_cluster * (new_codes[:, None] == train_codes[None, :])
120
+ prior = (lambda_ * gaussian_kernel(new, r=r)
121
+ + lambda_cluster * (new_codes[:, None] == new_codes[None, :]))
122
+ solved = cho_solve((lower, True), cross.T, check_finite=False)
123
+ mean = cross @ cho_solve((lower, True), outcome - design @ coefficients, check_finite=False)
124
+ covariance = sigma2 * (prior - cross @ solved)
125
+ covariance = (covariance + covariance.T) / 2
126
+ # Check here as well as at sampling, so moment-only prediction cannot hide
127
+ # a materially indefinite result. Retain eigen-projected roundoff repair.
128
+ factor = psd_factor(covariance)
129
+ covariance = factor @ factor.T
130
+ if not np.isfinite(mean).all() or not np.isfinite(covariance).all():
131
+ raise FloatingPointError('conditional moments overflowed')
132
+ return mean, covariance
@@ -0,0 +1,151 @@
1
+ """Exact count and piecewise-exponential survival log likelihoods.
2
+
3
+ Count predictors are log means (including any offset). Survival predictors
4
+ are log relative hazards. All likelihoods retain parameter-dependent
5
+ normalization terms; nonfinite candidate predictors return minus infinity.
6
+ """
7
+
8
+ import numpy as np
9
+ from scipy.special import betaln, gammaln
10
+
11
+
12
+ def _vector(value, name, n=None):
13
+ a = np.asarray(value, dtype=float)
14
+ if a.ndim != 1 or (n is not None and len(a) != n):
15
+ raise ValueError(f'{name} must be a vector with one value per observation')
16
+ return a
17
+
18
+
19
+ def _counts(y):
20
+ values = _vector(y, 'y')
21
+ if not np.isfinite(values).all() or np.any(values < 0) or np.any(values != np.floor(values)):
22
+ raise ValueError('counts must be finite nonnegative integers')
23
+ return values
24
+
25
+
26
+ def _finite_sum(value):
27
+ total = float(np.sum(value))
28
+ return total if np.isfinite(total) else -np.inf
29
+
30
+
31
+ def poisson_loglik(y, eta):
32
+ """Sum log Poisson PMFs for a log mean, including log-factorials."""
33
+ y = _counts(y)
34
+ eta = _vector(eta, 'eta', len(y))
35
+ if not np.isfinite(eta).all():
36
+ return -np.inf
37
+ with np.errstate(over='ignore', invalid='ignore', under='ignore'):
38
+ return _finite_sum(y * eta - np.exp(eta) - gammaln(y + 1))
39
+
40
+
41
+ def negative_binomial_loglik(y, eta, dispersion):
42
+ """NB2 log PMF with mean exp(eta) and variance mu + mu**2/dispersion.
43
+
44
+ The beta-function identity avoids subtracting two large log gamma terms.
45
+ Log-add-exp expressions avoid exponentiating the count mean.
46
+ """
47
+ y = _counts(y)
48
+ eta = _vector(eta, 'eta', len(y))
49
+ if not np.isscalar(dispersion) or not np.isfinite(dispersion) or dispersion <= 0:
50
+ return -np.inf
51
+ if not np.isfinite(eta).all():
52
+ return -np.inf
53
+ log_d = np.log(dispersion)
54
+ with np.errstate(over='ignore', invalid='ignore', under='ignore', divide='ignore'):
55
+ normalizer = -betaln(dispersion, y + 1) - np.logaddexp(log_d, np.log(y))
56
+ terms = (normalizer - dispersion * np.logaddexp(0, eta - log_d)
57
+ - y * np.logaddexp(0, log_d - eta))
58
+ return _finite_sum(terms)
59
+
60
+
61
+ def _breaks(value):
62
+ cuts = np.asarray(value, dtype=float)
63
+ if cuts.ndim != 1 or not np.isfinite(cuts).all() or np.any(cuts <= 0) or np.any(np.diff(cuts) <= 0):
64
+ raise ValueError('baseline_breaks must be finite positive strictly increasing cutpoints')
65
+ return cuts.copy()
66
+
67
+
68
+ def interval_durations(times, breaks):
69
+ """Time spent in intervals (0,b1], ..., (bJ,infinity).
70
+
71
+ Zero times are allowed for prediction. Times are never sorted or clipped.
72
+ """
73
+ times = _vector(times, 'times')
74
+ if not np.isfinite(times).all() or np.any(times < 0):
75
+ raise ValueError('times must be finite and nonnegative')
76
+ cuts = _breaks(breaks)
77
+ if 8 * len(times) * (len(cuts) + 1) > 512 * 1024**2:
78
+ raise ValueError('interval durations exceed the 512 MiB allocation budget')
79
+ left = np.concatenate(([0.], cuts))
80
+ right = np.concatenate((cuts, [np.inf]))
81
+ return np.maximum(np.minimum(times[:, None], right) - left, 0.)
82
+
83
+
84
+ def prepare_survival(time, event, baseline_breaks=None):
85
+ """Create interval data; boundary events belong to the left interval.
86
+
87
+ Default cutpoints are unique event-time deciles below the largest retained
88
+ observed time. No observed events gives one exponential interval.
89
+ """
90
+ time = _vector(time, 'time')
91
+ event = _vector(event, 'event', len(time))
92
+ if not len(time) or not np.isfinite(time).all() or np.any(time <= 0):
93
+ raise ValueError('survival times must be positive and finite')
94
+ if not np.isfinite(event).all() or not np.isin(event, [0, 1]).all():
95
+ raise ValueError('event must contain only zero and one')
96
+ if baseline_breaks is None:
97
+ events = time[event == 1]
98
+ cuts = np.unique(np.quantile(events, np.arange(1, 10) / 10)) if len(events) else np.empty(0)
99
+ cuts = cuts[(cuts > 0) & (cuts < time.max())]
100
+ else:
101
+ cuts = _breaks(baseline_breaks)
102
+ return dict(event=event.astype(int), baseline_breaks=cuts,
103
+ durations=interval_durations(time, cuts),
104
+ event_bin=np.searchsorted(cuts, time, side='left'))
105
+
106
+
107
+ def _survival_arrays(event, event_bin, durations, eta, baseline_hazard=None):
108
+ event = _vector(event, 'event')
109
+ eta = _vector(eta, 'eta', len(event))
110
+ bins = np.asarray(event_bin)
111
+ durations = np.asarray(durations, dtype=float)
112
+ if durations.ndim != 2 or durations.shape[0] != len(event) or durations.shape[1] < 1:
113
+ raise ValueError('durations must have one row per observation and one column per interval')
114
+ if not np.isfinite(durations).all() or np.any(durations < 0):
115
+ raise ValueError('durations must be finite and nonnegative')
116
+ if not np.isfinite(event).all() or not np.isin(event, [0, 1]).all():
117
+ raise ValueError('event must contain only zero and one')
118
+ if (bins.shape != event.shape or not np.issubdtype(bins.dtype, np.integer)
119
+ or np.any(bins < 0) or np.any(bins >= durations.shape[1])):
120
+ raise ValueError('event_bin must index one valid interval per observation')
121
+ if baseline_hazard is not None:
122
+ hazard = _vector(baseline_hazard, 'baseline_hazard', durations.shape[1])
123
+ if not np.isfinite(hazard).all() or np.any(hazard <= 0):
124
+ raise ValueError('baseline_hazard must be positive and finite')
125
+ return event, bins, durations, eta
126
+
127
+
128
+ def survival_loglik(event, event_bin, durations, eta, baseline_hazard):
129
+ """Full right-censored piecewise PH log likelihood, not partial likelihood."""
130
+ event, bins, durations, eta = _survival_arrays(event, event_bin, durations, eta, baseline_hazard)
131
+ if not np.isfinite(eta).all():
132
+ return -np.inf
133
+ hazard = np.asarray(baseline_hazard, dtype=float)
134
+ with np.errstate(over='ignore', invalid='ignore', under='ignore'):
135
+ terms = event * (np.log(hazard[bins]) + eta) - np.exp(eta) * (durations @ hazard)
136
+ return _finite_sum(terms)
137
+
138
+
139
+ def baseline_posterior_parameters(event, event_bin, durations, eta, shape, rate):
140
+ """Independent conditional Gamma shapes/rates for baseline hazard Gibbs draws."""
141
+ event, bins, durations, eta = _survival_arrays(event, event_bin, durations, eta)
142
+ if not np.isfinite(shape) or not np.isfinite(rate) or min(shape, rate) <= 0:
143
+ raise ValueError('baseline Gamma shape and rate must be positive and finite')
144
+ if not np.isfinite(eta).all():
145
+ raise FloatingPointError('nonfinite survival predictor in baseline update')
146
+ counts = np.bincount(bins, weights=event, minlength=durations.shape[1])
147
+ with np.errstate(over='ignore', invalid='ignore', under='ignore'):
148
+ rates = rate + durations.T @ np.exp(eta)
149
+ if not np.isfinite(rates).all() or np.any(rates <= 0):
150
+ raise FloatingPointError('baseline Gamma rates overflowed; rescale times or predictors')
151
+ return shape + counts, rates
@@ -0,0 +1,43 @@
1
+ """Conditional GP calculations for explicitly saved latent functions."""
2
+ import numpy as np
3
+
4
+ from ._kernel import gaussian_kernel, psd_factor
5
+
6
+
7
+ def _check_support(h, basis):
8
+ residual = h - basis @ (basis.T @ h)
9
+ if np.max(np.abs(residual), initial=0) > 1e-8 * max(1., np.max(np.abs(h), initial=0)):
10
+ raise ValueError('saved h lies outside the numerical training-kernel support')
11
+
12
+
13
+ def conditional_saved_h(training, new, h, r, amplitude):
14
+ """Condition a Gaussian process on saved training h, including PSD kernels.
15
+
16
+ Eigenvalues at most 100*n*machine_epsilon*max(1, largest_eigenvalue)
17
+ define numerical null directions, where n is the training row count.
18
+ No diagonal jitter is added. Observed training profiles
19
+ retain their saved h exactly. This is floating-point rank handling, not a
20
+ low-rank approximation selected for computation speed.
21
+ """
22
+ kernel = gaussian_kernel(training, r=r)
23
+ eig, vectors = np.linalg.eigh(kernel)
24
+ tolerance = 100 * len(kernel) * np.finfo(float).eps * max(1., eig.max())
25
+ if eig.min() < -tolerance:
26
+ raise np.linalg.LinAlgError('training kernel is materially indefinite')
27
+ keep = eig > tolerance
28
+ basis = vectors[:, keep]
29
+ _check_support(h, basis)
30
+ cross = gaussian_kernel(new, training, r=r)
31
+ projected = cross @ basis
32
+ mean = (projected / eig[keep]) @ (basis.T @ h)
33
+ covariance = amplitude * (gaussian_kernel(new, r=r) - (projected/eig[keep]) @ projected.T)
34
+ covariance = (covariance + covariance.T)/2
35
+ active = np.asarray(r) > 0
36
+ for j, profile in enumerate(new):
37
+ matches = np.flatnonzero(np.all(training[:, active] == profile[active], axis=1))
38
+ if len(matches):
39
+ mean[j] = h[matches[0]]
40
+ covariance[j, :] = 0
41
+ covariance[:, j] = 0
42
+ factor = psd_factor(covariance)
43
+ return mean, factor @ factor.T
@@ -0,0 +1,326 @@
1
+ """Gaussian block correlations, log-variance scaling and residual prediction.
2
+
3
+ Correlation operates only within observation clusters. The covariance is
4
+ sigma2 * D R D, where D has diagonal exp(log_variance/2). No diagonal jitter
5
+ is added. Conditional prediction returns a residual mean and covariance;
6
+ the caller controls random draws.
7
+ """
8
+
9
+ import numpy as np
10
+ from scipy.linalg import cho_solve, solve_triangular
11
+
12
+ _LIMIT = 512 * 1024**2
13
+
14
+
15
+ def _budget(elements):
16
+ if 8 * int(elements) > _LIMIT:
17
+ raise ValueError('Gaussian residual working arrays exceed the 512 MiB budget')
18
+
19
+
20
+ def _vector(value, name, n=None):
21
+ result = np.asarray(value, dtype=float)
22
+ if result.ndim != 1 or (n is not None and len(result) != n) or not np.isfinite(result).all():
23
+ raise ValueError(f'{name} must be a finite vector with one entry per observation')
24
+ return result
25
+
26
+
27
+ def _codes(value, name, n=None):
28
+ codes = np.asarray(value)
29
+ if (codes.ndim != 1 or (n is not None and len(codes) != n)
30
+ or not np.issubdtype(codes.dtype, np.integer) or np.any(codes < 0)):
31
+ raise ValueError(f'{name} must be a nonnegative integer cluster-code vector')
32
+ return codes
33
+
34
+
35
+ def _kind(kind, matrix):
36
+ if isinstance(kind, np.ndarray):
37
+ if matrix is not None:
38
+ raise ValueError('supply a correlation matrix only once')
39
+ matrix, kind = kind, 'fixed'
40
+ if matrix is not None:
41
+ if kind not in (None, 'fixed', 'matrix'):
42
+ raise ValueError('matrix cannot be combined with a parametric correlation')
43
+ kind = 'fixed'
44
+ if kind in (None, 'independent'):
45
+ return None, matrix
46
+ if kind not in ('ar1', 'exponential', 'fixed', 'matrix'):
47
+ raise ValueError('correlation kind must be ar1, exponential, fixed or None')
48
+ return 'fixed' if kind == 'matrix' else kind, matrix
49
+
50
+
51
+ def _parameter(kind, parameter):
52
+ if kind not in ('ar1', 'exponential'):
53
+ if parameter is not None:
54
+ raise ValueError('correlation parameter applies only to ar1 or exponential')
55
+ return None
56
+ if (isinstance(parameter, (bool, np.bool_)) or not np.isscalar(parameter)
57
+ or not np.isfinite(parameter)):
58
+ raise ValueError('correlation parameter must be finite')
59
+ if kind == 'ar1' and not -1 < parameter < 1:
60
+ raise ValueError('AR1 correlation must lie strictly between -1 and one')
61
+ if kind == 'exponential' and parameter <= 0:
62
+ raise ValueError('exponential correlation range must be positive')
63
+ return float(parameter)
64
+
65
+
66
+ def _times(times, n, kind, name):
67
+ times = _vector(times, name, n)
68
+ if kind == 'ar1' and np.any(times != np.floor(times)):
69
+ raise ValueError('AR1 observation times must be integer valued')
70
+ return times
71
+
72
+
73
+ def _parametric(a, b, kind, parameter):
74
+ with np.errstate(over='ignore', invalid='ignore', under='ignore'):
75
+ distance = np.abs(a[:, None] - b[None, :])
76
+ if kind == 'ar1':
77
+ result = np.power(parameter, distance)
78
+ else:
79
+ result = np.exp(-distance / parameter)
80
+ if not np.isfinite(result).all():
81
+ raise FloatingPointError('residual correlation calculation overflowed')
82
+ return result
83
+
84
+
85
+ def _correlation_matrix(value, n, codes, name, *, positive_definite=True):
86
+ matrix = np.asarray(value, dtype=float)
87
+ if matrix.shape != (n, n) or not np.isfinite(matrix).all():
88
+ raise ValueError(f'{name} must be a finite square matrix matching its observations')
89
+ _budget(4*n*n)
90
+ if not np.allclose(matrix, matrix.T, rtol=0, atol=1e-12):
91
+ raise ValueError(f'{name} must be symmetric')
92
+ if not np.allclose(np.diag(matrix), 1., rtol=0, atol=1e-12):
93
+ raise ValueError(f'{name} must have unit diagonal')
94
+ if np.any(np.abs(matrix) > 1+1e-12):
95
+ raise ValueError(f'{name} entries must lie between minus one and one')
96
+ # Check block separation without allocating a second n-by-n identity mask.
97
+ for group in np.unique(codes):
98
+ rows = np.flatnonzero(codes == group)
99
+ other = np.flatnonzero(codes != group)
100
+ if len(other) and np.any(np.abs(matrix[np.ix_(rows, other)]) > 1e-12):
101
+ raise ValueError(f'{name} must be zero between clusters')
102
+ block = (matrix[np.ix_(rows, rows)] + matrix[np.ix_(rows, rows)].T) / 2
103
+ if positive_definite:
104
+ try:
105
+ np.linalg.cholesky(block)
106
+ except np.linalg.LinAlgError as exc:
107
+ raise ValueError(f'{name} must be positive definite within each cluster') from exc
108
+ else:
109
+ _psd(block, name)
110
+ return matrix
111
+
112
+
113
+ def _psd(covariance, name):
114
+ covariance = (covariance + covariance.T) / 2
115
+ if not np.isfinite(covariance).all():
116
+ raise FloatingPointError(f'{name} is nonfinite')
117
+ if not len(covariance):
118
+ return covariance
119
+ values, vectors = np.linalg.eigh(covariance)
120
+ scale = max(1., float(np.max(np.abs(values), initial=0)))
121
+ tolerance = 100 * len(covariance) * np.finfo(float).eps * scale
122
+ if values.min(initial=0) < -tolerance:
123
+ raise ValueError(f'{name} is materially indefinite')
124
+ # Remove negative roundoff only; retain legitimate small positive variance.
125
+ return (vectors * np.maximum(values, 0.)) @ vectors.T
126
+
127
+
128
+ def correlation_blocks(codes, times=None, kind=None, parameter=None, matrix=None):
129
+ """Return (indices, R, lower-Cholesky) for each correlated cluster.
130
+
131
+ Independent errors use an empty fast path. AR1 requires distinct integer
132
+ times within each training cluster; exponential correlation requires
133
+ distinct finite times. A fixed matrix must be positive definite and zero
134
+ between clusters. Inputs refer to already retained rows.
135
+ """
136
+ kind, matrix = _kind(kind, matrix)
137
+ parameter = _parameter(kind, parameter)
138
+ if kind is None:
139
+ return []
140
+ if codes is None:
141
+ raise ValueError('residual correlation requires observation clusters')
142
+ codes = _codes(codes, 'codes')
143
+ if not len(codes):
144
+ raise ValueError('correlation requires at least one observation')
145
+ sizes = [np.count_nonzero(codes == group) for group in np.unique(codes)]
146
+ _budget(5*sum(int(size)**2 for size in sizes))
147
+ if kind == 'fixed':
148
+ if matrix is None:
149
+ raise ValueError('fixed correlation requires a matrix')
150
+ matrix = _correlation_matrix(matrix, len(codes), codes, 'matrix')
151
+ else:
152
+ times = _times(times, len(codes), kind, 'times')
153
+ result = []
154
+ for group in np.unique(codes):
155
+ indices = np.flatnonzero(codes == group)
156
+ if kind == 'fixed':
157
+ block = matrix[np.ix_(indices, indices)].copy()
158
+ else:
159
+ local = times[indices]
160
+ if len(np.unique(local)) != len(local):
161
+ raise ValueError('duplicate within-cluster times give singular residual correlation')
162
+ block = _parametric(local, local, kind, parameter)
163
+ try:
164
+ lower = np.linalg.cholesky((block + block.T) / 2)
165
+ except np.linalg.LinAlgError as exc:
166
+ raise ValueError('within-cluster residual correlation is not positive definite') from exc
167
+ result.append((indices, block, lower))
168
+ return result
169
+
170
+
171
+ def _standardized_residual(y, eta, sigma2, log_variance):
172
+ with np.errstate(over='ignore', invalid='ignore', divide='ignore', under='ignore'):
173
+ residual = y - eta
174
+ exponent = np.full_like(residual, -np.inf)
175
+ np.log(np.abs(residual), out=exponent, where=residual != 0)
176
+ standardized = np.sign(residual) * np.exp(exponent - .5*(np.log(sigma2)+log_variance))
177
+ return standardized
178
+
179
+
180
+ def gaussian_loglik(y, eta, sigma2, log_variance, blocks):
181
+ """Normalized Gaussian log density with block correlation and log variance.
182
+
183
+ Invalid candidate scales/predictors return minus infinity. Covariances are
184
+ factored as D R D, so large variance ratios do not require a scaled dense
185
+ covariance factorization. Zero residuals remain zero even at tiny scales.
186
+ """
187
+ y = _vector(y, 'y')
188
+ eta = np.asarray(eta, dtype=float)
189
+ lv = np.asarray(log_variance, dtype=float)
190
+ if eta.shape != y.shape or lv.shape != y.shape:
191
+ raise ValueError('eta and log_variance must have one entry per observation')
192
+ if (isinstance(sigma2, (bool, np.bool_)) or not np.isscalar(sigma2)
193
+ or not np.isfinite(sigma2) or sigma2 <= 0
194
+ or not np.isfinite(eta).all() or not np.isfinite(lv).all()):
195
+ return -np.inf
196
+ standardized = _standardized_residual(y, eta, sigma2, lv)
197
+ if not np.isfinite(standardized).all():
198
+ return -np.inf
199
+ assigned = np.zeros(len(y), dtype=bool)
200
+ quadratic, logdet = 0., 0.
201
+ with np.errstate(over='ignore', invalid='ignore'):
202
+ for indices, correlation, lower in blocks:
203
+ indices = np.asarray(indices)
204
+ if (indices.ndim != 1 or not np.issubdtype(indices.dtype, np.integer)
205
+ or np.any(indices < 0) or np.any(indices >= len(y))
206
+ or len(np.unique(indices)) != len(indices) or assigned[indices].any()):
207
+ raise ValueError('correlation blocks must contain disjoint valid indices')
208
+ if np.shape(correlation) != (len(indices), len(indices)) or np.shape(lower) != np.shape(correlation):
209
+ raise ValueError('correlation block factors have incompatible shapes')
210
+ assigned[indices] = True
211
+ local = solve_triangular(lower, standardized[indices], lower=True, check_finite=False)
212
+ quadratic += float(local @ local)
213
+ logdet += 2*float(np.log(np.diag(lower)).sum())
214
+ quadratic += float(standardized[~assigned] @ standardized[~assigned])
215
+ total = -.5*(len(y)*np.log(2*np.pi) + len(y)*np.log(sigma2) + lv.sum() + logdet + quadratic)
216
+ return float(total) if np.isfinite(total) else -np.inf
217
+
218
+
219
+ def residual_prediction(y, eta_train, sigma2, logvar_train, logvar_new,
220
+ train_codes, new_codes, train_time, new_time, kind, parameter, rng,
221
+ *, matrix=None, cross=None, new_matrix=None, observation=True):
222
+ """Return joint conditional residual (mean, covariance), without drawing.
223
+
224
+ ``cross`` is a new-by-training correlation matrix; ``new_matrix`` is a
225
+ new-by-new correlation matrix. For fixed training correlations, omitted
226
+ cross correlations mean independent new residuals. Equal known cluster/time
227
+ points share the conditioned residual process. ``observation=False`` keeps
228
+ the conditional mean and returns zero covariance. ``rng`` is accepted for
229
+ interface consistency and is not consumed.
230
+ """
231
+ y = _vector(y, 'y')
232
+ eta = _vector(eta_train, 'eta_train', len(y))
233
+ lv_train = _vector(logvar_train, 'logvar_train', len(y))
234
+ lv_new = _vector(logvar_new, 'logvar_new')
235
+ if not isinstance(observation, (bool, np.bool_)):
236
+ raise ValueError('observation must be boolean')
237
+ if isinstance(sigma2, (bool, np.bool_)) or not np.isscalar(sigma2) or not np.isfinite(sigma2) or sigma2 <= 0:
238
+ raise ValueError('sigma2 must be finite and positive')
239
+ n, count = len(y), len(lv_new)
240
+ _budget(3*count*count + 5*(n+count))
241
+ kind, matrix = _kind(kind, matrix)
242
+ parameter = _parameter(kind, parameter)
243
+ with np.errstate(over='ignore', under='ignore', invalid='ignore'):
244
+ scale_new = np.exp(.5*(np.log(sigma2)+lv_new))
245
+ if not np.isfinite(scale_new).all() or np.any(scale_new <= 0):
246
+ raise FloatingPointError('new residual scales exceed floating-point range')
247
+ standardized = _standardized_residual(y, eta, sigma2, lv_train)
248
+ if not np.isfinite(standardized).all():
249
+ raise FloatingPointError('training standardized residuals exceed floating-point range')
250
+ mean = np.zeros(count)
251
+ covariance = np.zeros((count, count))
252
+ if kind is None:
253
+ if cross is not None or new_matrix is not None:
254
+ raise ValueError('custom residual correlation inputs require fixed correlation')
255
+ if observation:
256
+ np.fill_diagonal(covariance, scale_new**2)
257
+ if not np.isfinite(covariance).all():
258
+ raise FloatingPointError('new residual covariance overflowed')
259
+ return mean, covariance
260
+ if train_codes is None or new_codes is None:
261
+ raise ValueError('correlated prediction requires training and new cluster codes')
262
+ train_codes = _codes(train_codes, 'train_codes', n)
263
+ new_codes = _codes(new_codes, 'new_codes', count)
264
+ training = correlation_blocks(train_codes, train_time, kind, parameter, matrix=matrix)
265
+ factors = {int(train_codes[indices[0]]): (indices, block, lower) for indices, block, lower in training}
266
+ stored_elements = 2*sum(len(indices)**2 for indices, _, _ in training)
267
+ if kind != 'fixed':
268
+ if cross is not None or new_matrix is not None:
269
+ raise ValueError('custom correlation matrices require fixed training correlation')
270
+ train_time = _times(train_time, n, kind, 'train_time')
271
+ new_time = _times(new_time, count, kind, 'new_time')
272
+ else:
273
+ if cross is None:
274
+ cross = None
275
+ else:
276
+ cross = np.asarray(cross, dtype=float)
277
+ if cross.shape != (count, n) or not np.isfinite(cross).all():
278
+ raise ValueError('cross must be a finite new-by-training correlation matrix')
279
+ _budget(stored_elements + 3*count*count + 2*count*n)
280
+ for code in np.unique(new_codes):
281
+ rows = np.flatnonzero(new_codes == code)
282
+ other = np.flatnonzero(train_codes != code)
283
+ if len(other) and np.any(np.abs(cross[np.ix_(rows, other)]) > 1e-12):
284
+ raise ValueError('cross must be zero between different clusters')
285
+ if new_matrix is not None:
286
+ new_matrix = _correlation_matrix(new_matrix, count, new_codes, 'new_matrix', positive_definite=False)
287
+ for code in np.unique(new_codes):
288
+ rows = np.flatnonzero(new_codes == code)
289
+ size = len(rows)
290
+ if int(code) in factors:
291
+ columns, _, lower = factors[int(code)]
292
+ else:
293
+ columns, lower = np.empty(0, dtype=int), None
294
+ _budget(stored_elements + 3*count*count + 10*(len(columns)+size)**2)
295
+ if kind == 'fixed':
296
+ prior = np.eye(size) if new_matrix is None else new_matrix[np.ix_(rows, rows)]
297
+ shared = np.zeros((size, len(columns))) if cross is None else cross[np.ix_(rows, columns)]
298
+ else:
299
+ prior = _parametric(new_time[rows], new_time[rows], kind, parameter)
300
+ shared = _parametric(new_time[rows], train_time[columns], kind, parameter)
301
+ conditional = prior.copy()
302
+ if len(columns):
303
+ whitened = solve_triangular(lower, shared.T, lower=True, check_finite=False)
304
+ conditional -= whitened.T @ whitened
305
+ local_mean = shared @ cho_solve((lower, True), standardized[columns], check_finite=False)
306
+ mean[rows] = scale_new[rows] * local_mean
307
+ conditional = _psd(conditional, 'joint residual correlation Schur complement')
308
+ if len(columns):
309
+ for local_row, row in enumerate(rows):
310
+ # Correlation exactly one identifies the same standardized residual.
311
+ # Preserve that identity instead of sampling Schur roundoff.
312
+ matches = (np.flatnonzero(shared[local_row] == 1.) if kind == 'fixed'
313
+ else np.flatnonzero(new_time[row] == train_time[columns]))
314
+ if len(matches):
315
+ mean[row] = scale_new[row] * standardized[columns[matches[0]]]
316
+ conditional[local_row, :] = 0.
317
+ conditional[:, local_row] = 0.
318
+ if observation:
319
+ with np.errstate(over='ignore', invalid='ignore'):
320
+ local_covariance = scale_new[rows, None] * conditional * scale_new[None, rows]
321
+ if not np.isfinite(local_covariance).all():
322
+ raise FloatingPointError('conditional residual covariance overflowed')
323
+ covariance[np.ix_(rows, rows)] = local_covariance
324
+ if not np.isfinite(mean).all():
325
+ raise FloatingPointError('conditional residual mean overflowed')
326
+ return mean, covariance