---
title: "Numerical Stability in Gaussian-Process Computation"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Numerical Stability in Gaussian-Process Computation}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r setup, include=FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>")
library(gaussianprocesses)
```

# Why numerical stability matters

Gaussian-process regression repeatedly solves systems involving

\[
C=K+\Sigma_\varepsilon.
\]

When inputs are duplicated, almost duplicated, or connected by a kernel with a
very long length scale, \(C\) can become poorly conditioned.

The package treats numerical stabilization as part of the implementation,
rather than silently replacing the statistical model.

# Cholesky rather than explicit inversion

The core computation is

\[
C = R^\top R.
\]

To compute

\[
\alpha=C^{-1}y,
\]

the implementation solves

\[
R^\top z=y,
\qquad
R\alpha=z.
\]

No explicit matrix inverse is required.

This is both more stable and usually more efficient.

# Jitter is not observation noise

If Cholesky factorization fails, the numerical layer can try

\[
C_\delta = C + \delta I.
\]

The scalar \(\delta\) is **jitter**. It is a numerical device.

Observation noise,

\[
\sigma_\varepsilon^2,
\]

is part of the statistical model.

They are stored separately.

Consider duplicated observations with zero statistical noise:

```{r}
model <- fit_gp(
  x = c(0, 0),
  y = c(1, 1),
  kernel = rbf_kernel(),
  noise_variance = 0,
  fallback_jitter = 1e-10
)

model$noise_variance
model$jitter
model$cholesky_attempts
```

The statistical model still has zero observation noise even though the
factorization required numerical stabilization.

# Jitter is relative to the covariance scale

The jitter is not a fixed number. It is a multiple of the covariance scale,
the mean prior variance

\[
s = \frac{1}{n}\operatorname{tr}(C),
\qquad
\delta = \epsilon\, s,
\]

where the relative jitter \(\epsilon\) climbs the ladder \(0\), then
`fallback_jitter` \(= 10^{-10}\), multiplied by `jitter_multiplier`
\(= 10\) after each failure.

Because \(\delta\) is proportional to \(C\), results do not depend on the
units of \(y\). Multiplying \(y\) by \(c\) and every variance parameter by
\(c^2\) multiplies \(C\), the jitter, and every posterior variance by
\(c^2\), and the posterior mean by \(c\):

```{r}
# 40 nearly coincident inputs without noise make C numerically singular, so
# every fit needs jitter.
x <- seq(0, 1, length.out = 40)

fit_in_units <- function(c) {
  fit_gp(
    x,
    c * sin(3 * x),
    kernel = rbf_kernel(variance = c^2, length_scale = 0.5),
    noise_variance = 0
  )
}

unit <- fit_in_units(1)
small <- fit_in_units(1e-4)

# The absolute jitter scales with C; the relative jitter is the same.
c(unit = unit$jitter, small = small$jitter)
c(
  unit = gp_numerical_diagnostics(unit)$relative_jitter,
  small = gp_numerical_diagnostics(small)$relative_jitter
)

# Relative difference of the posterior means after undoing the scaling.
x_new <- c(0.25, 0.5, 1.5)
unit_mean <- predict_gp(unit, x_new)$mean
small_mean <- predict_gp(small, x_new)$mean / 1e-4
max(abs(small_mean - unit_mean)) / max(abs(unit_mean))
```

Earlier versions used the same ladder as absolute values. Then the jitter
was negligible for a covariance of scale \(10^8\) and dominant for one of
scale \(10^{-8}\). The study in `inst/benchmarks/jitter-scaling.R`
rescales problems that need jitter by \(c = 10^{-4}\) and \(c = 10^{4}\):

| Policy | Log marginal likelihood | Posterior mean and variance | Function draws |
|---|---|---|---|
| Absolute jitter | off by 0.4 to 1.7 | off by up to 37% | off by up to 110% |
| Relative jitter | within \(7 \times 10^{-8}\) | within \(2 \times 10^{-8}\) | within \(1.2 \times 10^{-6}\) |

The table gives relative differences after undoing the scaling. The
remaining differences under relative jitter come from rounding: scaling by
\(c^2 = 10^{\pm 8}\) is not exact in binary arithmetic, and a nearly singular
covariance amplifies the rounding.

# The trace term of the likelihood gradient

The gradient of the log marginal likelihood with respect to a log parameter
\(\eta\) is

\[
\frac12\left(\alpha^\top D \alpha - \operatorname{tr}(C^{-1}D)\right),
\qquad
D = \frac{\partial C}{\partial \eta}.
\]

The trace needs \(C^{-1}\) in some form. `log_marginal_likelihood_gradient()`
computes it once from the Cholesky factor with `chol2inv()`, and each
parameter then costs \(O(n^2)\). The alternative uses two triangular solves
per parameter and never forms \(C^{-1}\), but each parameter then costs
\(O(n^3)\).

`inst/benchmarks/gradient-numerics.R` compares the two. A periodic kernel on
a regular grid gives a circulant \(C\) whose eigenvalues are sums of Bessel
functions, so the exact gradient is known to near machine precision even
when \(C\) is ill-conditioned:

| Condition number of \(C\) | `chol2inv()` | Triangular solves |
|---|---|---|
| \(10^4\) | \(1.5 \times 10^{-13}\) | \(1.5 \times 10^{-13}\) |
| \(10^8\) | \(6.0 \times 10^{-11}\) | \(6.0 \times 10^{-11}\) |
| \(10^{12}\) | \(4.8 \times 10^{-7}\) | \(4.8 \times 10^{-7}\) |

These are the largest relative errors of the gradient. The two methods are
equally accurate, and both lose accuracy in proportion to the conditioning
of \(C\), as any method in double precision must. The solves were 4 to 18
times slower, and the gap grows with the number of parameters, so the
package keeps `chol2inv()`.

# Near-singular scenario

The benchmark suite contains a reproducible near-singular design:

```{r}
simulation <- gp_simulation_scenario(
  "near_singular",
  n = 30,
  seed = 1729
)

near_singular_model <- fit_gp(
  simulation$x,
  simulation$observed,
  kernel = simulation$kernel,
  noise_variance = simulation$noise_variance
)

gp_numerical_diagnostics(
  near_singular_model
)
```

The diagnostic output separates:

- observation-noise variance;
- numerical jitter, absolute and relative to the covariance scale;
- Cholesky attempts;
- reciprocal condition number;
- condition-number flag.

# Posterior variances

Analytically, posterior variances cannot be negative. Numerically, expressions
such as

\[
K_{**}-K_{*f}C^{-1}K_{f*}
\]

can produce tiny negative diagonal values due to floating-point cancellation.

The package distinguishes two cases.

For a value of floating-point scale,

\[
-\varepsilon < 0,
\]

the marginal variance is clipped to zero.

For a materially negative value, the computation stops with a numerical error.

This prevents an instability from being silently converted into an apparently
valid uncertainty estimate.

# Benchmarking stability separately from speed

```{r}
suite <- run_gp_benchmark_suite(
  scenarios = "near_singular",
  n = 20,
  n_inducing = 6,
  repeats = 1,
  seed = 42
)

suite$stability
suite$performance
suite$accuracy
```

Those are different tables intentionally.

A method can be faster but less accurate. A model can fit the latent truth well
while still requiring numerical stabilization. A small reciprocal condition
number does not by itself prove statistical misspecification.

Keeping these concepts separate is one of the central design rules of the
package.
