Gaussian Processes with a fixed kernel#
This notebook introduces Gaussian Processes and their implementation in NIFTy. Gaussian Processes are a powerful tool for modeling smooth functions. In the context of NIFTy, we commonly use Gaussian Processes as a prior model. The Gaussian Process prior encodes the assumption that smooth functions are a priori more likely than functions with wild variations. If you are not familiar with NIFTy, please read the notebooks Models and Inference first.
Before starting with the actual notebook, let’s import NIFTy and the relevant libraries.
import nifty.re as jft
import jax
import jax.numpy as jnp
import jax.random as random
%matplotlib inline
import matplotlib.pyplot as plt
plt.rcParams["figure.dpi"] = 100
jax.config.update("jax_enable_x64", True)
seed = 42
key = random.PRNGKey(seed)
Motivation#
As with many concepts, Gaussian Processes are best introduced with an example. In this notebook, let’s consider modeling the temperature in a room as a function of time. We assume that the average temperature in the room is \(21\) degrees and that it fluctuates with a standard deviation of \(2\) degrees. Our goal is to model these fluctuations around \(21\) degrees.In this example, the fluctuations of the temperature around \(21\) degrees will be our signal \(s\), which we want to reconstruct. The simplest statistical model for \(s\), encoding this prior knowledge, would be a Gaussian distribution with a mean of \(0\) and a standard deviation of \(2\).
The first notebook 0_models.py already introduced a prior model for Gaussian
random numbers in NIFTy, namely jft.NormalPrior. While 0_models.py used
this model for a single number, we now need a model for multiple numbers,
specifically the temperature at different time steps. Using the shape
argument, multiple random numbers can be generated at once. Assuming we want
to model the temperature at \(100\) time steps, the code below implements the
prior model for a vector of \(100\) Gaussian random numbers with a mean of \(0\)
and a standard deviation of \(2\).
s = jft.NormalPrior(mean=0, std=2, shape=(100))
Let’s draw a sample of this prior model and visualize it with matplotlib.
key, subkey = random.split(key)
latent_sample = jft.random_like(subkey, s.domain)
sample = s(latent_sample)
plt.plot(sample)
plt.xlabel("Time")
plt.ylabel("Temperature Deviation from mean")
plt.show()

As expected, the values of the s sample fluctuate around \(0\) with a standard
deviation of about \(2\).
Neighboring entries in the random sample from \(s\) are uncorrelated. Thus, in this prior sample, the temperature deviations from \(21\) degrees jump wildly from one time step to the next. Expressed as a formula, this means that the correlation between the temperature deviations at time steps \(i\) and \(j\) is zero, \(\left<s_x s_y\right> = 0\) if \(x \neq y\). If the neighboring time steps are close to each other, this is unphysical. From physical considerations, we expect the temperature to change gradually as a function of time. Thus, we expect that the values of the temperature at nearby time steps are strongly correlated (\(\left<s_x x_y\right> > 0\) for \(x \approx y\)). A technique to model such correlations between neighboring points and impose them as a prior on possible functions is the use of Gaussian Processes. In the next section, we will introduce the mathematical background of Gaussian Processes before showcasing their implementation in NIFTy.
Mathematical background#
In the code above, we modeled each time step \(s_i\) with an a priori independent Gaussian random variable \(\xi_i\), known as a latent space parameter. Instead of thinking of \(s\) as a collection of independent Gaussian random variables, we can also view \(s\) as a whole as a multivariate Gaussian random variable. The probability density function of the multivariate normal distribution for the vector \(s\) is given by
where \(S\) is the covariance matrix. In the code above, \(S\) was a diagonal matrix with \(\left< s_i s_i \right> = 2^2 = 4\) on the diagonal, while the off-diagonal elements, which encode correlations between time steps, were zero. To include correlations between time steps in the prior model, we need to develop a generative model that samples from a multivariate Gaussian with a non-diagonal covariance matrix.
The most straightforward generative model for \(s\) is to multiply the latent space standard normally distributed vector \(\xi\) with the matrix square root of \(S\):
We can easily verify that an \(s\) generated by this procedure has the covariance \(S\):
In the second-to-last step, we used the fact that the covariance of the latent parameter vector \(\xi\) is the identity matrix, since all latent parameters are a priori independent and standard normally distributed.
The covariance \(S\) is an \(n \times n\) square matrix, where \(n\) is the number of entries in the vector \(s\). In our example, \(n = 100\), so \(S\) is a \(100 \times 100\) matrix. However, NIFTy is designed to work with much larger parameter spaces. For example, astronomical imaging applications often deal with images containing a million or more pixels. If \(s\) has a million entries, then \(S\) would have \((10^6)^2 = 10^{12}\) entries. This is computationally infeasible, as \(10^{12}\) floating-point numbers with 64-bit precision would require 8 terabytes of memory. Therefore, to enable applications with large parameter spaces, we need to circumvent the quadratic scaling and cannot directly work with \(S\) or \(\sqrt{S}\).
Wiener-Khintchin Theorem#
To circumvent the quadratic scaling, we a priori assume statistical homogeneity and isotropy of the signal \(s\), meaning translation invariance and directional independence of the covariance matrix \(S\). In terms of our example, this means that we assume a priori that the correlation between temperature fluctuations at two time steps depends only on the distance between them. Expressed as a formula, the entries of the covariance matrix depend only on the distance between the time steps:
Under the assumptions of statistical homogeneity and isotropy, the Wiener–Khinchin theorem states that the covariance becomes diagonal in harmonic space:
where \(\tilde{S}\) is the harmonic space covariance matrix, and the diagonal is populated by the one-dimensional power spectrum.
NIFTy Gaussian Process Model#
The assumptions of statistical homogeneity and isotropy, combined with the Wiener–Khinchin theorem, are the central ingredients of the Gaussian Process model in NIFTy. Through the Wiener–Khinchin theorem, the covariance matrix can be fully represented by the power spectrum, avoiding quadratic scaling. To map latent independent standard Gaussian random variables \(\vec{\xi}\) to a multivariate Gaussian random variable \(\vec{s}\), NIFTy uses the following procedure. It starts with a standard normal random vector \(\vec{\xi}\), where each entry in the vector corresponds to one harmonic frequency \(k\) of the signal we want to generate. Next, NIFTy multiplies \(\vec{\xi}\) by the square root of the harmonic space covariance matrix \(\sqrt{\tilde{S}_{kk'}}\) and then applies a harmonic transform to the result. Expressed as a formula, the procedure is:
The square root of \(S_{kk'}\) can easily be computed since \(S_{kk'}\) is a diagonal matrix. We can verify that the covariance of \(s\) is indeed \(S\) as desired:
This is the idea of the fixed power spectrum Gaussian process model in NIFTy. In the remainder of the notebook, we will explain the implementation of this model.
Implementation in NIFTy#
We begin the implementation by defining the grid on which our signal lives.
For this, we specify dims=100 to indicate that we have \(100\) time steps.
Furthermore, we specify the distance between the time steps with
distances = 0.01. In this example, the units of the time distances are
arbitrary. However, in your applications, you should, of course, always be
aware of the units.
dims = 100
distances = 0.01
With this information on the number of pixels and the distances between them,
we now initialize a NIFTy grid containing this information. To do this, we use
a utility function from jft.correlated_field. jft.correlated_field is a
model for generating Gaussian processes with unknown power spectra, which you
will learn more about in later notebooks. Here, we focus on generating a
Gaussian process model with a known power spectrum.
To initialize the grid, we pass to the jft.correlated_field.make_grid
function, besides dims and distances, information about the harmonic
counterpart of the space in which our signal lives. This is necessary because
we need to perform a harmonic transformation to generate a Gaussian process
via the Wiener–Khinchin theorem. Here, we set harmonic_type="fourier". If
our signal lived on a sphere instead of a regular Cartesian grid, we would set
it to "spherical".
grid = jft.correlated_field.make_grid(
dims, distances=distances, harmonic_type="fourier"
)
Next, we define a Python function for the power spectrum—or more precisely,
for the square root of the power spectrum, which we refer to as the amplitude
spectrum. The amplitude_spectrum function takes as input the length of the
harmonic factor k and evaluates the amplitude spectrum for this \(k\)-mode.
def amplitude_spectrum(k):
return 2.5 / (5 + k**2)
After defining the Python function for the power spectrum, we can initialize
the array containing the square root of the signal covariance in harmonic
space. To do this, we first evaluate the amplitude spectrum function at all
lengths of the Fourier vectors in our grid. The grid property
grid.harmonic_grid.mode_lengths returns a vector containing all unique
lengths of \(k\)-vectors in our Fourier grid. We evaluate the amplitude spectrum
function at these \(k\)-vector lengths.
k_lengths = grid.harmonic_grid.mode_lengths
amplitudes = amplitude_spectrum(k_lengths)
print("k_length: ", k_lengths)
print("amplitudes: ", amplitudes)
k_length: [ 0. 1. 2. 3. 4. 5. 6. 7. 8. 9. 10. 11. 12. 13. 14. 15. 16. 17.
18. 19. 20. 21. 22. 23. 24. 25. 26. 27. 28. 29. 30. 31. 32. 33. 34. 35.
36. 37. 38. 39. 40. 41. 42. 43. 44. 45. 46. 47. 48. 49. 50.]
amplitudes: [0.5 0.41666667 0.27777778 0.17857143 0.11904762 0.08333333
0.06097561 0.0462963 0.03623188 0.02906977 0.02380952 0.01984127
0.01677852 0.01436782 0.01243781 0.01086957 0.00957854 0.0085034
0.00759878 0.0068306 0.00617284 0.00560538 0.00511247 0.00468165
0.00430293 0.00396825 0.00367107 0.00340599 0.00316857 0.00295508
0.00276243 0.00258799 0.00242954 0.00228519 0.00215332 0.00203252
0.0019216 0.00181951 0.00172533 0.00163827 0.00155763 0.0014828
0.00141323 0.00134844 0.001288 0.00123153 0.00117869 0.00112918
0.00108272 0.00103907 0.000998 ]
We now have the corresponding amplitudes for all unique \(k\)-vector lengths in our grid. From these amplitudes, we need to construct the diagonal of the Fourier space representation of the square root of the covariance matrix. This is somewhat tedious, as multiple Fourier vectors can share the same length. In our 1D example, there is the zero mode—i.e., the \(k\)-vector with length 0—but then there are two next longer \(k\)-vectors: \(k=0.01\) and \(k=-0.01\). In higher-dimensional spaces, even more Fourier vectors share the same length.
To simplify mapping from the amplitude array to the diagonal of the harmonic
space square root covariance matrix, the grid contains the property
grid.harmonic_grid.power_distributor. The power_distributor is an array
containing, for each element of the diagonal of the harmonic space covariance
matrix, the index of the corresponding \(k\)-vector length in the
grid.harmonic_grid.mode_lengths array. Using this index array, we can easily
obtain the diagonal of the square root covariance from the amplitude array.
sqrt_harmonic_cov = amplitudes[grid.harmonic_grid.power_distributor]
Now we have all the components to initialize a NIFTy model that transforms a
standard normally distributed \(\xi\) into a multivariate Gaussian with the
power spectrum we defined. To the constructor of FixedPowerCorrelatedField,
we pass sqrt_harmonic_cov and the grid. Inside the constructor, we store
the harmonic transformation ht as an attribute of the class. For the
harmonic transformation, we use the jft.correlated_field.hartley function.
The Hartley transformation is closely related to the Fourier transformation,
but for our application, it has the advantage of transforming real-valued
functions to real-valued functions. To avoid dealing with complex numbers, we
use the Hartley transformation instead of a normal Fourier transformation.
Furthermore, we store a volume scaling factor harmonic_dvol as a property.
In the __call__ method, we scale the result of the Hartley transformation
with this factor to comply with the NIFTy convention.
The __call__ method performs the mapping from the latent parameters to the
Gaussian process realizations. Following the formulas in the mathematical
background section, we multiply the input vector by the square root of the
harmonic space covariance matrix sqrt_harmonic_cov and then apply a harmonic
transformation to the result.
class FixedPowerCorrelatedField(jft.Model):
def __init__(self, sqrt_harmonic_cov, grid):
self.sqrt_harmonic_cov = sqrt_harmonic_cov
self.ht = jft.correlated_field.hartley
self.harmonic_dvol = 1 / grid.total_volume
super().__init__(
domain=jax.ShapeDtypeStruct(shape=grid.shape, dtype=jnp.float64)
)
def __call__(self, x):
return self.harmonic_dvol * self.ht(self.sqrt_harmonic_cov * x)
s = FixedPowerCorrelatedField(sqrt_harmonic_cov, grid)
To visualize our fixed-power spectrum Gaussian process model, we draw \(10\) standard normal distributed input vectors, map them through our model to a correlated multivariate Gaussian sample, and plot the results. As expected, the samples are now no longer wildly jumping from one time step to the next, but smoothly fluctuate.
for i in range(10):
key, subkey = random.split(key)
latent_sample = jft.random_like(subkey, s.domain)
sample = s(latent_sample)
plt.plot(sample)
plt.xlabel(r"$x$")
plt.ylabel(r"$s$")
plt.show()

Summary#
This notebook introduced a model for Gaussian processes and its implementation
in NIFTy. By utilizing the Wiener–Khinchin theorem and the assumptions of a
priori statistical homogeneity and isotropy, the implementation does not
explicitly parameterize the covariance matrix of the Gaussian process or any
other quantity that scales quadratically with the number of pixels. This makes
the model applicable even in settings with a large number of pixels. In the
next notebook, Wiener Filter, we will continue with the Gaussian
process model presented here and showcase some reconstructions. Thereby, we
will introduce an alternative to the variational inference algorithm presented
in the inference notebook.
The Gaussian process model presented in this notebook assumes that the power spectrum of the signal is known. However, in many real-world applications, this is not the case. A more flexible model applicable to settings where the underlying power spectrum is unknown will be presented in this notebook.