ptgp 0.1.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.
ptgp/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ import logging
2
+ import sys
3
+
4
+ from ptgp import (
5
+ gp,
6
+ idata,
7
+ inducing,
8
+ kernels,
9
+ likelihoods,
10
+ mean,
11
+ objectives,
12
+ optim,
13
+ rewrites, # noqa: F401 (registers PyTensor rewrites at import)
14
+ utils,
15
+ )
16
+ from ptgp.inducing_fourier import FourierFeatures1D
17
+ from ptgp.optim.api import FitResult, fit, predict
18
+
19
+
20
+ class _StdoutHandler(logging.StreamHandler):
21
+ """Handler that resolves sys.stdout at emit time, not at init time."""
22
+
23
+ def __init__(self):
24
+ super().__init__()
25
+
26
+ @property
27
+ def stream(self):
28
+ return sys.stdout
29
+
30
+ @stream.setter
31
+ def stream(self, _):
32
+ pass
33
+
34
+
35
+ _logger = logging.getLogger("ptgp")
36
+ if not _logger.handlers:
37
+ _handler = _StdoutHandler()
38
+ _handler.setFormatter(logging.Formatter("%(message)s"))
39
+ _logger.addHandler(_handler)
40
+ _logger.setLevel(logging.INFO)
41
+
42
+ __all__ = [
43
+ "FourierFeatures1D",
44
+ "FitResult",
45
+ "fit",
46
+ "gp",
47
+ "idata",
48
+ "inducing",
49
+ "kernels",
50
+ "likelihoods",
51
+ "mean",
52
+ "objectives",
53
+ "optim",
54
+ "predict",
55
+ "utils",
56
+ ]
57
+
58
+
59
+ try:
60
+ from ptgp._version import __version__
61
+ except ImportError:
62
+ __version__ = "0.0.0+unknown"
ptgp/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
ptgp/conditionals.py ADDED
@@ -0,0 +1,72 @@
1
+ import pytensor.assumptions as pta
2
+ import pytensor.tensor as pt
3
+
4
+ # Diagonal jitter added to Kmm before Cholesky / inversion, to keep it PSD
5
+ # under floating-point noise. Matches GPflow / GPJax / PyMC defaults of 1e-6.
6
+ _DEFAULT_JITTER = 1e-6
7
+
8
+
9
+ def conditional_whitened(A_white, Knn, f, q_sqrt=None, full_cov=False):
10
+ """Posterior conditional in whitened parameterisation.
11
+
12
+ A_white : (M, N), satisfies A_white = R^{-1} @ Kmn where R @ R.T = Kmm.
13
+ Knn : (N,) prior diagonal if ``full_cov=False``, else (N, N) prior covariance.
14
+ f : (M,) variational mean (whitened: prior on v is N(0, I)).
15
+ q_sqrt : (M, K), optional.
16
+ full_cov: if True, return full (N, N) covariance; else (N,) marginals.
17
+ """
18
+ fmean = A_white.T @ f
19
+ if full_cov:
20
+ fvar = Knn - A_white.T @ A_white
21
+ else:
22
+ fvar = Knn - pt.sum(A_white**2, axis=0)
23
+ if q_sqrt is not None:
24
+ B = A_white.T @ q_sqrt
25
+ if full_cov:
26
+ fvar = fvar + B @ B.T
27
+ else:
28
+ fvar = fvar + pt.sum(B**2, axis=1)
29
+ return fmean, fvar
30
+
31
+
32
+ def conditional_unwhitened(A, Kmn, Knn, f, q_sqrt=None, full_cov=False):
33
+ """Posterior conditional in unwhitened parameterisation.
34
+
35
+ A : (M, N), satisfies A = Kmm^{-1} @ Kmn.
36
+ Kmn : (M, N) cross-covariance.
37
+ Knn : (N,) prior diagonal if ``full_cov=False``, else (N, N) prior covariance.
38
+ f : (M,) variational mean (prior on u is N(0, Kmm)).
39
+ full_cov: if True, return full (N, N) covariance; else (N,) marginals.
40
+ """
41
+ fmean = A.T @ f
42
+ if full_cov:
43
+ fvar = Knn - A.T @ Kmn
44
+ else:
45
+ fvar = Knn - pt.sum(A * Kmn, axis=0)
46
+ if q_sqrt is not None:
47
+ B = A.T @ q_sqrt
48
+ if full_cov:
49
+ fvar = fvar + B @ B.T
50
+ else:
51
+ fvar = fvar + pt.sum(B**2, axis=1)
52
+ return fmean, fvar
53
+
54
+
55
+ def base_conditional(Kmn, Kmm, Knn, f, q_sqrt=None, white=False, full_cov=False):
56
+ """Back-compat wrapper. Materialises Kmm; use the helpers above directly
57
+ when you have a structured Kuu_solve / Kuu_sqrt_solve."""
58
+ # Add jitter to keep Kmm PSD under float noise — matches GPflow / PyMC default.
59
+ # Re-annotate after the addition: PyTensor canonicalizes ``Kmm + c·I`` into a
60
+ # ``set_subtensor`` on the diagonal, which our PSD-inference rules don't see
61
+ # through. The mathematical identity (PSD + c·I PSD ⇒ PSD) is sound.
62
+ Kmm = pta.assume(
63
+ Kmm + _DEFAULT_JITTER * pt.eye(Kmm.shape[-1], dtype=Kmm.dtype),
64
+ positive_definite=True,
65
+ symmetric=True,
66
+ )
67
+ if white:
68
+ L = pt.linalg.cholesky(Kmm)
69
+ A_white = pt.linalg.solve(L, Kmn)
70
+ return conditional_whitened(A_white, Knn, f, q_sqrt, full_cov=full_cov)
71
+ A = pt.linalg.inv(Kmm) @ Kmn
72
+ return conditional_unwhitened(A, Kmn, Knn, f, q_sqrt, full_cov=full_cov)
ptgp/gp/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ from ptgp.gp.svgp import SVGP, VariationalParams, init_variational_params
2
+ from ptgp.gp.unapproximated import Unapproximated
3
+ from ptgp.gp.vfe import VFE
4
+
5
+ __all__ = [
6
+ "Unapproximated",
7
+ "VFE",
8
+ "SVGP",
9
+ "VariationalParams",
10
+ "init_variational_params",
11
+ ]
ptgp/gp/svgp.py ADDED
@@ -0,0 +1,299 @@
1
+ import dataclasses
2
+
3
+ import numpy as np
4
+ import pytensor.assumptions as pta
5
+ import pytensor.tensor as pt
6
+
7
+ from ptgp.conditionals import conditional_unwhitened, conditional_whitened
8
+ from ptgp.kl import gauss_kl, gauss_kl_structured
9
+ from ptgp.mean import Zero
10
+ from ptgp.objectives import elbo
11
+
12
+
13
+ def _softplus_lower_triangular(flat, M):
14
+ """Build M×M lower-triangular matrix from a flat vector, with softplus on the diagonal.
15
+
16
+ The flat vector has length M·(M+1)/2 in row-major lower-triangular layout
17
+ (matching ``np.tril_indices(M)``). Diagonal entries are passed through
18
+ ``pt.softplus`` so the result is a lower-triangular Cholesky factor with
19
+ strictly positive diagonal at every optimizer step.
20
+ """
21
+ rows, cols = np.tril_indices(M)
22
+ L = pt.set_subtensor(pt.zeros((M, M))[rows, cols], flat)
23
+ diag_idx = np.arange(M)
24
+ L = pt.set_subtensor(L[diag_idx, diag_idx], pt.softplus(L[diag_idx, diag_idx]))
25
+ return L
26
+
27
+
28
+ def _matrix_to_softplus_flat_init(L_init, M):
29
+ """Inverse of ``_softplus_lower_triangular`` at init time.
30
+
31
+ Given an M×M lower-triangular Cholesky factor with strictly positive
32
+ diagonal, return the flat vector ``flat`` such that
33
+ ``_softplus_lower_triangular(flat, M)`` evaluates to ``L_init``.
34
+ """
35
+ if L_init.shape != (M, M):
36
+ raise ValueError(f"L_init must have shape ({M}, {M}); got {L_init.shape}.")
37
+ diag_vals = np.diag(L_init)
38
+ if np.any(diag_vals <= 0):
39
+ raise ValueError(
40
+ "L_init must have strictly positive diagonal (it represents a "
41
+ "Cholesky factor under softplus parameterization)."
42
+ )
43
+ rows, cols = np.tril_indices(M)
44
+ flat = L_init[rows, cols].astype(np.float64)
45
+ diag_positions = np.cumsum(np.arange(1, M + 1)) - 1
46
+ flat[diag_positions] = np.log(np.expm1(diag_vals.astype(np.float64)))
47
+ return flat
48
+
49
+
50
+ @dataclasses.dataclass(frozen=True)
51
+ class VariationalParams:
52
+ """Symbolic variational parameters for SVGP.
53
+
54
+ Attributes
55
+ ----------
56
+ q_mu : TensorVariable
57
+ Symbolic variational mean, shape ``(M,)``. Pass to ``SVGP`` via
58
+ ``variational_params=...``.
59
+ q_sqrt : TensorVariable
60
+ Symbolic Cholesky factor of the variational covariance, shape ``(M, M)``,
61
+ annotated ``lower_triangular=True``. Pass to ``SVGP`` via
62
+ ``variational_params=...``.
63
+ extra_vars : tuple of TensorVariable
64
+ Trainable leaves underlying ``q_mu`` and ``q_sqrt``.
65
+ extra_init : tuple of ndarray
66
+ Initial values for ``extra_vars``, in matching order.
67
+ """
68
+
69
+ q_mu: object
70
+ q_sqrt: object
71
+ extra_vars: tuple = ()
72
+ extra_init: tuple = ()
73
+
74
+
75
+ def init_variational_params(M, q_mu_init=None, q_sqrt_init=None):
76
+ """Build symbolic variational parameters with GPJax-style parameterization.
77
+
78
+ ``q_sqrt`` is stored as a flat vector of length ``M·(M+1)/2`` and
79
+ materialised by filling a lower-triangular matrix and applying
80
+ ``pt.softplus`` to the diagonal — guarantees a true Cholesky factor
81
+ (lower-triangular with strictly positive diagonal) at every optimizer step.
82
+ ``q_mu`` is stored as a length-``M`` flat vector.
83
+
84
+ Parameters
85
+ ----------
86
+ M : int
87
+ Number of inducing points.
88
+ q_mu_init : ndarray of shape (M,), optional
89
+ Initial variational mean. Defaults to zeros.
90
+ q_sqrt_init : ndarray of shape (M, M), optional
91
+ Initial Cholesky factor. Must be lower-triangular with positive diagonal.
92
+ Defaults to the identity matrix.
93
+
94
+ Returns
95
+ -------
96
+ VariationalParams
97
+
98
+ Examples
99
+ --------
100
+ >>> vp = init_variational_params(M=8)
101
+ >>> svgp = SVGP(kernel=..., likelihood=..., inducing_variable=..., variational_params=vp)
102
+ >>> train_step, _, _ = compile_training_step(
103
+ ... elbo,
104
+ ... svgp,
105
+ ... X,
106
+ ... y,
107
+ ... model=model,
108
+ ... extra_vars=vp.extra_vars,
109
+ ... extra_init=vp.extra_init,
110
+ ... )
111
+ """
112
+ if q_mu_init is None:
113
+ q_mu_init = np.zeros(M, dtype=np.float64)
114
+ else:
115
+ q_mu_init = np.asarray(q_mu_init, dtype=np.float64)
116
+ if q_mu_init.shape != (M,):
117
+ raise ValueError(f"q_mu_init must have shape ({M},); got {q_mu_init.shape}.")
118
+ if q_sqrt_init is None:
119
+ q_sqrt_init = np.eye(M, dtype=np.float64)
120
+ else:
121
+ q_sqrt_init = np.asarray(q_sqrt_init, dtype=np.float64)
122
+
123
+ n_lower = M * (M + 1) // 2
124
+ q_mu = pt.vector("q_mu", shape=(M,), dtype="float64")
125
+ q_sqrt_flat = pt.vector("q_sqrt_flat", shape=(n_lower,), dtype="float64")
126
+ q_sqrt = pta.assume(
127
+ _softplus_lower_triangular(q_sqrt_flat, M),
128
+ lower_triangular=True,
129
+ )
130
+ flat_init = _matrix_to_softplus_flat_init(q_sqrt_init, M)
131
+ return VariationalParams(
132
+ q_mu=q_mu,
133
+ q_sqrt=q_sqrt,
134
+ extra_vars=(q_mu, q_sqrt_flat),
135
+ extra_init=(q_mu_init, flat_init),
136
+ )
137
+
138
+
139
+ class SVGP:
140
+ """Stochastic Variational Gaussian Process.
141
+
142
+ Parameters
143
+ ----------
144
+ kernel : Kernel
145
+ Covariance function.
146
+ mean : callable, optional
147
+ Mean function (default: Zero()).
148
+ likelihood : Likelihood
149
+ Observation likelihood.
150
+ inducing_variable : InducingVariables
151
+ Inducing point locations (or structured inducing variables).
152
+ variational_params : VariationalParams
153
+ Symbolic variational parameters and their backing trainable
154
+ leaves. Construct via :func:`init_variational_params` (recommended)
155
+ or build a :class:`VariationalParams` directly for custom
156
+ parameterizations. ``vp.extra_vars`` / ``vp.extra_init`` should be
157
+ passed to ``compile_training_step``.
158
+ whiten : bool
159
+ If True, use whitened variational parameterization (default True).
160
+ """
161
+
162
+ default_objective = staticmethod(elbo)
163
+ predict_needs_data = False
164
+
165
+ def __init__(
166
+ self,
167
+ kernel,
168
+ mean=None,
169
+ likelihood=None,
170
+ inducing_variable=None,
171
+ variational_params=None,
172
+ whiten=True,
173
+ ):
174
+ if variational_params is None:
175
+ raise ValueError(
176
+ "SVGP requires variational_params. Construct via "
177
+ "ptgp.gp.init_variational_params(M) and pass as "
178
+ "variational_params=..., or build a VariationalParams directly."
179
+ )
180
+ self.kernel = kernel
181
+ self.mean = mean if mean is not None else Zero()
182
+ self.likelihood = likelihood
183
+ self.inducing_variable = inducing_variable
184
+ self.whiten = whiten
185
+ self.variational_params = variational_params
186
+ self.q_mu = variational_params.q_mu
187
+ self.q_sqrt = variational_params.q_sqrt
188
+
189
+ @property
190
+ def extra_vars(self):
191
+ return (
192
+ *self.variational_params.extra_vars,
193
+ *self.inducing_variable.extra_vars,
194
+ )
195
+
196
+ @property
197
+ def extra_init(self):
198
+ return (
199
+ *self.variational_params.extra_init,
200
+ *self.inducing_variable.extra_init,
201
+ )
202
+
203
+ def predict_marginal(self, X, incl_lik=False):
204
+ """Posterior marginal mean and variance at each point in X.
205
+
206
+ Returns the per-point posterior — correlations between test
207
+ points are discarded. Use ``predict_joint`` for the full (N, N)
208
+ covariance or ``predict_f_samples`` to draw smooth function samples.
209
+
210
+ Parameters
211
+ ----------
212
+ X : tensor, shape (N, D)
213
+ incl_lik : bool
214
+ If True, push through the likelihood's predictive mean/var
215
+ (observation-space uncertainty).
216
+
217
+ Returns
218
+ -------
219
+ mean : tensor, shape (N,)
220
+ var : tensor, shape (N,)
221
+ """
222
+ ind, kernel = self.inducing_variable, self.kernel
223
+ Kmn = ind.K_uf(kernel, X)
224
+ Knn_diag = kernel.diag(X)
225
+ if self.whiten:
226
+ A_w = ind.Kuu_sqrt_solve(kernel, Kmn)
227
+ fmean, fvar = conditional_whitened(A_w, Knn_diag, self.q_mu, self.q_sqrt)
228
+ else:
229
+ A = ind.Kuu_solve(kernel, Kmn)
230
+ fmean, fvar = conditional_unwhitened(A, Kmn, Knn_diag, self.q_mu, self.q_sqrt)
231
+ fmean = fmean + self.mean(X)
232
+ if incl_lik:
233
+ return self.likelihood.predict_mean_and_var(fmean, fvar)
234
+ return fmean, fvar
235
+
236
+ def predict_joint(self, X):
237
+ """Posterior joint mean and full covariance of the latent f at X.
238
+
239
+ The diagonal of the returned covariance equals the variance from
240
+ ``predict_marginal``; the off-diagonals capture correlations
241
+ between test points, which are needed to draw smooth samples.
242
+
243
+ Parameters
244
+ ----------
245
+ X : tensor, shape (N, D)
246
+
247
+ Returns
248
+ -------
249
+ mean : tensor, shape (N,)
250
+ cov : tensor, shape (N, N)
251
+ """
252
+ ind, kernel = self.inducing_variable, self.kernel
253
+ Kmn = ind.K_uf(kernel, X)
254
+ Knn = kernel(X)
255
+ if self.whiten:
256
+ A_w = ind.Kuu_sqrt_solve(kernel, Kmn)
257
+ fmean, fcov = conditional_whitened(A_w, Knn, self.q_mu, self.q_sqrt, full_cov=True)
258
+ else:
259
+ A = ind.Kuu_solve(kernel, Kmn)
260
+ fmean, fcov = conditional_unwhitened(A, Kmn, Knn, self.q_mu, self.q_sqrt, full_cov=True)
261
+ fmean = fmean + self.mean(X)
262
+ return fmean, fcov
263
+
264
+ def predict_f_samples(self, X, epsilon, jitter=1e-6):
265
+ """Draw samples of the latent f at X from the joint posterior.
266
+
267
+ Samples are produced via a Cholesky transform of caller-supplied
268
+ iid-standard-normal noise. The caller owns the RNG — the function
269
+ itself is deterministic.
270
+
271
+ Parameters
272
+ ----------
273
+ X : tensor, shape (N, D)
274
+ epsilon : tensor, shape (S, N)
275
+ iid N(0, 1) draws. ``S`` is the number of samples.
276
+ jitter : float
277
+ Added to the diagonal of the posterior covariance before
278
+ Cholesky, for numerical stability (default 1e-6).
279
+
280
+ Returns
281
+ -------
282
+ samples : tensor, shape (S, N)
283
+ """
284
+ fmean, fcov = self.predict_joint(X)
285
+ N = X.shape[0]
286
+ L = pt.linalg.cholesky(fcov + jitter * pt.eye(N))
287
+ return fmean[None, :] + epsilon @ L.T
288
+
289
+ def prior_kl(self):
290
+ """KL divergence KL[q(u) || p(u)]."""
291
+ if self.whiten:
292
+ return gauss_kl(self.q_mu, self.q_sqrt, K=None)
293
+ ind, kernel = self.inducing_variable, self.kernel
294
+ return gauss_kl_structured(
295
+ self.q_mu,
296
+ self.q_sqrt,
297
+ K_solve=lambda rhs: ind.Kuu_solve(kernel, rhs),
298
+ K_logdet=ind.Kuu_logdet(kernel),
299
+ )
@@ -0,0 +1,69 @@
1
+ import pytensor.tensor as pt
2
+
3
+ from ptgp.likelihoods import Gaussian
4
+ from ptgp.mean import Zero
5
+ from ptgp.objectives import marginal_log_likelihood
6
+
7
+
8
+ class Unapproximated:
9
+ """Exact (unapproximated) Gaussian process.
10
+
11
+ The observation model is Gaussian; parameterize the noise via ``sigma``.
12
+
13
+ Parameters
14
+ ----------
15
+ kernel : Kernel
16
+ Covariance function.
17
+ mean : callable, optional
18
+ Mean function (default: ``Zero()``).
19
+ sigma : tensor or PyMC random variable
20
+ Observation noise standard deviation.
21
+ """
22
+
23
+ extra_vars = ()
24
+ extra_init = ()
25
+ default_objective = staticmethod(marginal_log_likelihood)
26
+ predict_needs_data = True
27
+
28
+ def __init__(self, kernel, mean=None, sigma=None):
29
+ """Store the kernel and mean; build a Gaussian likelihood from sigma."""
30
+ self.kernel = kernel
31
+ self.mean = mean if mean is not None else Zero()
32
+ self.likelihood = Gaussian(sigma)
33
+
34
+ def predict_marginal(self, X_new, X_train, y_train, incl_lik=False):
35
+ """Posterior marginal mean and variance at each point in X_new.
36
+
37
+ Returns the per-point posterior; correlations between test points
38
+ are discarded.
39
+
40
+ Parameters
41
+ ----------
42
+ X_new : tensor, shape (N*, D)
43
+ X_train : tensor, shape (N, D)
44
+ y_train : tensor, shape (N,)
45
+ incl_lik : bool
46
+ If True, include likelihood noise in the predictions.
47
+
48
+ Returns
49
+ -------
50
+ mean : tensor, shape (N*,)
51
+ var : tensor, shape (N*,)
52
+ """
53
+ Knn = self.kernel(X_train)
54
+ sigma = self.likelihood.sigma
55
+ sigma2_train = sigma**2 * pt.ones(X_train.shape[0]) # (N,); scalar broadcasts
56
+ Knn_noisy = Knn + pt.diag(sigma2_train)
57
+ Kns = self.kernel(X_train, X_new) # (N, N*)
58
+ Kss_diag = self.kernel.diag(X_new)
59
+
60
+ Knn_inv = pt.linalg.inv(Knn_noisy)
61
+
62
+ mu_train = self.mean(X_train)
63
+ fmean = self.mean(X_new) + Kns.T @ Knn_inv @ (y_train - mu_train)
64
+ fvar = Kss_diag - pt.sum(Kns * (Knn_inv @ Kns), axis=0)
65
+
66
+ if incl_lik:
67
+ sigma_new = self.likelihood.sigma_at(X_train, X_new)
68
+ return fmean, fvar + sigma_new**2
69
+ return fmean, fvar
ptgp/gp/vfe.py ADDED
@@ -0,0 +1,92 @@
1
+ import pytensor.tensor as pt
2
+
3
+ from ptgp.likelihoods import Gaussian
4
+ from ptgp.mean import Zero
5
+ from ptgp.objectives import collapsed_elbo
6
+
7
+
8
+ class VFE:
9
+ """Variational Free Energy (SGPR) sparse Gaussian Process.
10
+
11
+ Uses Titsias' collapsed bound — inducing variables are analytically
12
+ integrated out. The observation model is Gaussian; parameterize the noise
13
+ via ``sigma``.
14
+
15
+ Parameters
16
+ ----------
17
+ kernel : Kernel
18
+ Covariance function.
19
+ mean : callable, optional
20
+ Mean function (default: ``Zero()``).
21
+ sigma : tensor or PyMC random variable
22
+ Observation noise standard deviation.
23
+ inducing_variable : InducingVariables
24
+ Inducing point locations.
25
+ """
26
+
27
+ default_objective = staticmethod(collapsed_elbo)
28
+ predict_needs_data = True
29
+
30
+ def __init__(self, kernel, mean=None, sigma=None, inducing_variable=None):
31
+ """Store the kernel, mean, and inducing variable; build a Gaussian likelihood from sigma."""
32
+ if not hasattr(inducing_variable, "Z"):
33
+ raise TypeError(
34
+ f"VFE requires inducing variables with a .Z attribute "
35
+ f"(got {type(inducing_variable).__name__}). "
36
+ f"Use SVGP for structured inducing variables like FourierFeatures1D."
37
+ )
38
+ self.kernel = kernel
39
+ self.mean = mean if mean is not None else Zero()
40
+ self.likelihood = Gaussian(sigma)
41
+ self.inducing_variable = inducing_variable
42
+
43
+ @property
44
+ def extra_vars(self):
45
+ return tuple(self.inducing_variable.extra_vars)
46
+
47
+ @property
48
+ def extra_init(self):
49
+ return tuple(self.inducing_variable.extra_init)
50
+
51
+ def predict_marginal(self, X_new, X_train, y_train, incl_lik=False):
52
+ """Posterior marginal mean and variance at each point in X_new.
53
+
54
+ Returns the per-point posterior; correlations between test points
55
+ are discarded.
56
+
57
+ Parameters
58
+ ----------
59
+ X_new : tensor, shape (N*, D)
60
+ X_train : tensor, shape (N, D)
61
+ y_train : tensor, shape (N,)
62
+ incl_lik : bool
63
+ If True, include likelihood noise in the predictions.
64
+
65
+ Returns
66
+ -------
67
+ mean : tensor, shape (N*,)
68
+ var : tensor, shape (N*,)
69
+ """
70
+ Z = self.inducing_variable.Z
71
+ sigma = self.likelihood.sigma
72
+ sigma2_vec = sigma**2 * pt.ones(X_train.shape[0]) # (N,); scalar broadcasts
73
+
74
+ Kuu = self.kernel(Z) # (M, M)
75
+ Kuf = self.kernel(Z, X_train) # (M, N)
76
+ Kus = self.kernel(Z, X_new) # (M, N*)
77
+ Kss_diag = self.kernel.diag(X_new)
78
+
79
+ diff = y_train - self.mean(X_train)
80
+ Kuf_laminv = Kuf / sigma2_vec[None, :] # each column ÷ σᵢ²
81
+ Sigma = Kuu + Kuf_laminv @ Kuf.T
82
+ Sigma_inv = pt.linalg.inv(Sigma)
83
+ alpha = Sigma_inv @ Kuf_laminv @ diff
84
+
85
+ fmean = self.mean(X_new) + Kus.T @ alpha
86
+ Kuu_inv = pt.linalg.inv(Kuu)
87
+ fvar = Kss_diag - pt.sum(Kus * ((Kuu_inv - Sigma_inv) @ Kus), axis=0)
88
+
89
+ if incl_lik:
90
+ sigma_new = self.likelihood.sigma_at(X_train, X_new)
91
+ return fmean, fvar + sigma_new**2
92
+ return fmean, fvar