Let
\[ f \sim \mathcal{GP}(m,k), \]
where \(m(x)\) is a mean function and \(k(x,x')\) is a positive semidefinite covariance kernel.
For training inputs
\[ X = (x_1,\ldots,x_n)^\top \]
and observations
\[ y_i = f(x_i) + \varepsilon_i, \qquad \varepsilon_i \sim \mathcal N(0,\sigma_i^2), \]
define
\[ K_{ff} = K(X,X), \qquad \Sigma_\varepsilon = \operatorname{diag}(\sigma_1^2,\ldots,\sigma_n^2). \]
Then
\[ y \sim \mathcal N\left( m(X), K_{ff}+\Sigma_\varepsilon \right). \]
The package represents the same objects explicitly:
| Mathematics | Package object |
|---|---|
| \(m\) | zero_mean(), constant_mean() |
| \(k\) | kernel specification such as rbf_kernel() |
| \(K(X,X)\) | evaluate_kernel(kernel, X) |
| \(\Sigma_\varepsilon\) | noise_variance in fit_gp() |
| fitted conditional model | fit_gp() |
A scalar noise_variance gives the usual homoscedastic
model. A vector gives one known observation variance per training
point.
At prediction inputs \(X_*\), define
\[ K_{f*} = K(X,X_*), \qquad K_{**} = K(X_*,X_*). \]
Write
\[ C = K_{ff}+\Sigma_\varepsilon. \]
The posterior latent mean is
\[ \mu_* = m(X_*) + K_{*f}C^{-1}\left(y-m(X)\right), \]
and the latent covariance is
\[ \Sigma_* = K_{**} - K_{*f}C^{-1}K_{f*}. \]
The implementation never forms \(C^{-1}\). It computes a Cholesky factorization
\[ C = R^\top R \]
and solves triangular systems.
The stored vector
\[ \alpha = C^{-1}\left(y-m(X)\right) \]
corresponds directly to model$alpha.
x <- c(-1.5, -0.5, 0.5, 1.5)
y <- c(0.9, -0.1, 0.2, 1.0)
kernel <- rbf_kernel(
variance = 1.2,
length_scale = 0.7
)
model <- fit_gp(
x,
y,
kernel = kernel,
noise_variance = 0.05
)
model
#> Exact Gaussian-process regression model
#> observations: 4
#> input dimensions: 1
#> mean: ZeroMean()
#> noise structure: homoscedastic
#> noise variance: 0.05
#> numerical jitter: 0
#> kernel:
#> RBF(variance=1.2, length_scale=0.7)The fitted object stores the Cholesky factor \(R\) and the vector \(\alpha\). The covariance matrices are not stored, because the kernel recomputes them exactly, and \(R^\top R\) reproduces \(C\) plus any numerical jitter (here none):
K <- evaluate_kernel(model$kernel, model$x)
C <- K + diag(model$training_noise_variance)
C
#> [,1] [,2] [,3] [,4]
#> [1,] 1.2500000000 0.43253735 0.02025586 0.0001232431
#> [2,] 0.4325373463 1.25000000 0.43253735 0.0202558610
#> [3,] 0.0202558610 0.43253735 1.25000000 0.4325373463
#> [4,] 0.0001232431 0.02025586 0.43253735 1.2500000000
model$cholesky
#> [,1] [,2] [,3] [,4]
#> [1,] 1.118034 0.3868732 0.01811739 0.000110232
#> [2,] 0.000000 1.0489658 0.40566454 0.019269662
#> [3,] 0.000000 0.0000000 1.04168519 0.407722346
#> [4,] 0.000000 0.0000000 0.00000000 1.040860777
all.equal(crossprod(model$cholesky), C + diag(model$jitter, nrow(C)))
#> [1] TRUE
model$alpha
#> [1] 0.854689487 -0.389576989 0.002244874 0.805451913?gaussianprocesses_model describes every field of a
fitted model, how models are versioned, and how to save them.
x_new <- seq(-2, 2, length.out = 80)
prediction <- predict_gp(
model,
x_new,
interval_level = 0.95
)
head(
data.frame(
x = x_new,
mean = prediction$mean,
latent_sd = prediction$latent_sd,
observation_sd = prediction$observation_sd
)
)
#> x mean latent_sd observation_sd
#> 1 -2.000000 0.7476407 0.6836559 0.7192950
#> 2 -1.949367 0.7798487 0.6305943 0.6690659
#> 3 -1.898734 0.8085496 0.5750144 0.6169615
#> 4 -1.848101 0.8331786 0.5176677 0.5638970
#> 5 -1.797468 0.8532069 0.4595633 0.5110758
#> 6 -1.746835 0.8681562 0.4020650 0.4600611The package reports two uncertainty layers.
The latent variance is
\[ \operatorname{Var}\left[f(x_*)\mid y\right]. \]
The observation variance is
\[ \operatorname{Var}\left[y_*\mid y\right] = \operatorname{Var}\left[f(x_*)\mid y\right] + \sigma_*^2. \]
They are deliberately not conflated.
plot(
x_new,
prediction$mean,
type = "l",
xlab = "x",
ylab = "response",
ylim = range(prediction$prediction_interval)
)
polygon(
c(x_new, rev(x_new)),
c(
prediction$prediction_interval[, "lower"],
rev(prediction$prediction_interval[, "upper"])
),
border = NA,
density = 15
)
lines(x_new, prediction$mean)
points(x, y)A mean and an interval summarize each input separately. Draws show whole functions, including how values at nearby inputs move together.
A draw from \(\mathcal N(\mu, \Sigma)\) is
\[ f = \mu + R^\top z, \qquad \Sigma = R^\top R, \qquad z \sim \mathcal N(0, I), \]
where \(R\) is the Cholesky factor
of the covariance. No inverse is needed. For the prior, \(\mu = m(X_*)\) and \(\Sigma = K_{**}\). For the latent
posterior, \(\mu\) and \(\Sigma\) are the posterior mean and
covariance from predict_gp(). Closely spaced inputs make
\(\Sigma\) numerically singular, so the
factorization uses the package’s jitter policy and reports the
jitter.
prior_draws <- sample_gp_prior(x_new, kernel, n_draws = 5, seed = 1)
posterior_draws <- sample_gp_posterior(model, x_new, n_draws = 5, seed = 1)
old_par <- par(mfrow = c(1, 2), mar = c(4, 4, 2, 1))
matplot(
x_new, prior_draws$latent,
type = "l", lty = 1, col = "grey40",
xlab = "x", ylab = "f(x)", main = "Prior draws"
)
matplot(
x_new, posterior_draws$latent,
type = "l", lty = 1, col = "grey40",
xlab = "x", ylab = "f(x)", main = "Posterior draws"
)
points(x, y, pch = 19)The posterior draws agree near the observations and spread apart away from them, which is what the pointwise intervals summarize.
The 80 closely spaced inputs make both covariance matrices numerically singular, so both factorizations needed a small jitter:
c(prior = prior_draws$jitter, posterior = posterior_draws$jitter)
#> prior posterior
#> 1.200000e-10 1.281765e-11Latent draws are values of the function \(f\). A future observation also includes
noise, \(y_* = f_* + \varepsilon_*\).
With type = "observation", each noisy draw is generated
from a latent draw plus independent noise, and both are returned:
noisy <- sample_gp_posterior(
model,
x_new,
n_draws = 1,
type = "observation",
seed = 2
)
plot(
x_new, noisy$observation[, 1],
pch = 20, cex = 0.6, col = "grey50",
xlab = "x", ylab = "response"
)
lines(x_new, noisy$latent[, 1])
points(x, y, pch = 19)Over many draws, the latent draws reproduce the posterior covariance and the observation draws reproduce that covariance plus the noise variance:
many <- sample_gp_posterior(
model,
c(-1, 0.5),
n_draws = 5000,
type = "observation",
seed = 3
)
round(
rbind(
latent_sample = apply(many$latent, 1, var),
latent_theory = diag(many$latent_covariance),
observation_sample = apply(many$observation, 1, var),
observation_theory = diag(many$latent_covariance) +
many$observation_noise_variance
),
4
)
#> [,1] [,2]
#> latent_sample 0.1578 0.0471
#> latent_theory 0.1553 0.0473
#> observation_sample 0.2085 0.0978
#> observation_theory 0.2053 0.0973Exact conditioning also supports a known diagonal observation covariance:
known_noise <- c(0.02, 0.04, 0.08, 0.16)
heterogeneous_fit <- fit_gp(
x,
y,
kernel = kernel,
noise_variance = known_noise
)
heterogeneous_fit$training_noise_variance
#> [1] 0.02 0.04 0.08 0.16For unknown input-dependent noise, see the package’s heteroscedastic GP approximation. The exact model above is still the underlying conditioning mechanism once a diagonal noise estimate has been supplied.