Coverage for geometric_kernels/kernels/karhunen_loeve.py: 95%
81 statements
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-16 21:43 +0000
« prev ^ index » next coverage.py v7.11.3, created at 2025-11-16 21:43 +0000
1"""
2This module provides the :class:`MaternKarhunenLoeveKernel` kernel, the basic
3kernel for discrete spectrum spaces, subclasses of :class:`DiscreteSpectrumSpace`.
4"""
6import lab as B
7import numpy as np
8from beartype.typing import Dict, Optional
10from geometric_kernels.kernels.base import BaseGeometricKernel
11from geometric_kernels.lab_extras import from_numpy, is_complex
12from geometric_kernels.spaces import DiscreteSpectrumSpace
13from geometric_kernels.spaces.eigenfunctions import Eigenfunctions
14from geometric_kernels.utils.utils import _check_1_vector, _check_field_in_params
17class MaternKarhunenLoeveKernel(BaseGeometricKernel):
18 r"""
19 This class approximates Matérn kernel by its truncated Mercer decomposition,
20 in terms of the eigenfunctions & eigenvalues of the Laplacian on the space.
22 .. math:: k(x, x') = \sum_{l=0}^{L-1} S(\sqrt\lambda_l) \sum_{s=1}^{d_l} f_{ls}(x) f_{ls}(x'),
24 where $\lambda_l$ and $f_{ls}(\cdot)$ are the eigenvalues and
25 eigenfunctions of the Laplacian such that
26 $\Delta f_{ls} = \lambda_l f_{ls}$, and $S(\cdot)$ is the spectrum
27 of the Matérn kernel. The eigenvalues and eigenfunctions belong to the
28 :class:`~.spaces.DiscreteSpectrumSpace` instance.
30 We denote
32 .. math:: G_l(\cdot, \cdot') = \sum_{s=1}^{d_l} f_{ls}(\cdot) f_{ls}(\cdot')
34 and term the sets $[f_{ls}]_{s=1}^{d_l}$ *"levels"*.
36 For many spaces, like the sphere, we can employ addition
37 theorems to efficiently compute $G_l(\cdot, \cdot')$ without calculating
38 the individual $f_{ls}(\cdot)$. Note that $\lambda_l$ are not required to
39 be unique: it is possible that for some $l,l'$, $\lambda_l = \lambda_{l'}$.
40 In other words, the "levels" do not necessarily correspond to full
41 eigenspaces. A level may even correspond to a single eigenfunction.
43 .. note::
44 A brief introduction into the theory behind
45 :class:`MaternKarhunenLoeveKernel` can be found in
46 :doc:`this </theory/compact>` & :doc:`this </theory/addition_theorem>`
47 documentation pages.
49 :param space:
50 The space to define the kernel upon.
51 :param num_levels:
52 Number of levels to include in the summation.
53 :param normalize:
54 Whether to normalize kernel to have unit average variance.
55 :param eigenvalues_laplacian:
56 Allowing to pass the eigenvalues of the Laplacian directly, instead of
57 computing them from the space. If provided, `eigenfunctions` must also
58 be provided. Used for :class:`~.spaces.HodgeDiscreteSpectrumSpace`.
59 :param eigenfunctions:
60 Allowing to pass the eigenfunctions directly, instead of computing them
61 from the space. If provided, `eigenvalues_laplacian` must also be provided.
62 Used for :class:`~.spaces.HodgeDiscreteSpectrumSpace`.
63 """
65 def __init__(
66 self,
67 space: DiscreteSpectrumSpace,
68 num_levels: int,
69 normalize: bool = True,
70 eigenvalues_laplacian: Optional[B.Numeric] = None,
71 eigenfunctions: Optional[Eigenfunctions] = None,
72 ):
73 super().__init__(space)
74 self.num_levels = num_levels # in code referred to as `L`.
76 if eigenvalues_laplacian is None:
77 if eigenfunctions is not None:
78 raise ValueError(
79 "You must either provide both `eigenfunctions` and `eigenvalues_laplacian`, or none of the two."
80 )
81 eigenvalues_laplacian = self.space.get_eigenvalues(self.num_levels)
82 eigenfunctions = self.space.get_eigenfunctions(self.num_levels)
83 else:
84 if eigenfunctions is None:
85 raise ValueError(
86 "You must either provide both `eigenfunctions` and `eigenvalues_laplacian`, or none of the two."
87 )
88 if eigenvalues_laplacian.shape != (num_levels, 1):
89 raise ValueError(
90 f"Expected `eigenvalues_laplacian` to have shape [num_levels={num_levels}, 1] but got {eigenvalues_laplacian.shape}"
91 )
92 if eigenfunctions.num_levels != num_levels:
93 raise ValueError(
94 f"`num_levels` must coincide with `num_levels` in the provided `eigenfunctions`,"
95 f"but `num_levels`={num_levels} and `eigenfunctions.num_levels`={eigenfunctions.num_levels}"
96 )
98 self._eigenvalues_laplacian = eigenvalues_laplacian
99 self._eigenfunctions = eigenfunctions
100 self.normalize = normalize
102 @property
103 def space(self) -> DiscreteSpectrumSpace:
104 """
105 The space on which the kernel is defined.
106 """
107 self._space: DiscreteSpectrumSpace
108 return self._space
110 def init_params(self) -> Dict[str, B.NPNumeric]:
111 """
112 Initializes the dict of the trainable parameters of the kernel.
114 Returns `dict(nu=np.array([np.inf]), lengthscale=np.array([1.0]))`.
116 This dict can be modified and is passed around into such methods as
117 :meth:`~.K` or :meth:`~.K_diag`, as the `params` argument.
119 .. note::
120 The values in the returned dict are always of the NumPy array type.
121 Thus, if you want to use some other backend for internal
122 computations when calling :meth:`~.K` or :meth:`~.K_diag`, you
123 need to replace the values with the analogs typed as arrays of
124 the desired backend.
125 """
126 params = dict(nu=np.array([np.inf]), lengthscale=np.array([1.0]))
128 return params
130 @staticmethod
131 def spectrum(
132 s: B.Numeric, nu: B.Numeric, lengthscale: B.Numeric, dimension: int
133 ) -> B.Numeric:
134 """
135 Static method computing the spectrum of the Matérn kernel with
136 hyperparameters `nu` and `lengthscale` on the space with eigenvalues `s`
137 and dimension `dimension`.
139 :param s:
140 The eigenvalues of the space.
141 :param nu:
142 The smoothness parameter of the kernel.
143 :param lengthscale:
144 The length scale parameter of the kernel.
145 :param dimension:
146 The dimension of the space.
148 :return:
149 The spectrum of the Matérn kernel.
150 """
151 _check_1_vector(lengthscale, "lengthscale")
152 _check_1_vector(nu, "nu")
154 # Note: 1.0 in safe_nu can be replaced by any finite positive value
155 safe_nu = B.where(nu == np.inf, B.cast(B.dtype(lengthscale), np.r_[1.0]), nu)
157 # for nu == np.inf
158 spectral_values_nu_infinite = B.exp(
159 -(lengthscale**2) / 2.0 * B.cast(B.dtype(lengthscale), s)
160 )
162 # for nu < np.inf
163 power = -safe_nu - dimension / 2.0
164 base = 2.0 * safe_nu / lengthscale**2 + B.cast(B.dtype(safe_nu), s)
165 spectral_values_nu_finite = base**power
167 return B.where(
168 nu == np.inf, spectral_values_nu_infinite, spectral_values_nu_finite
169 )
171 @property
172 def eigenfunctions(self) -> Eigenfunctions:
173 """
174 Eigenfunctions of the kernel.
175 """
176 return self._eigenfunctions
178 @property
179 def eigenvalues_laplacian(self) -> B.Numeric:
180 """
181 Eigenvalues of the Laplacian.
182 """
183 return self._eigenvalues_laplacian
185 def eigenvalues(self, params: Dict[str, B.Numeric]) -> B.Numeric:
186 """
187 Eigenvalues of the kernel.
189 :param params:
190 Parameters of the kernel. Must contain keys `"lengthscale"` and
191 `"nu"`. The shapes of `params["lengthscale"]` and `params["nu"]`
192 are `(1,)`.
194 :return:
195 An [L, 1]-shaped array.
196 """
197 _check_field_in_params(params, "lengthscale")
198 _check_1_vector(params["lengthscale"], 'params["lengthscale"]')
200 _check_field_in_params(params, "nu")
201 _check_1_vector(params["nu"], 'params["nu"]')
203 spectral_values = self.spectrum(
204 self.eigenvalues_laplacian,
205 nu=params["nu"],
206 lengthscale=params["lengthscale"],
207 dimension=self.space.dimension,
208 )
210 if self.normalize:
211 normalizer = B.sum(
212 spectral_values
213 * B.cast(
214 B.dtype(spectral_values),
215 from_numpy(
216 spectral_values,
217 self.eigenfunctions.num_eigenfunctions_per_level,
218 )[:, None],
219 )
220 )
221 return spectral_values / normalizer
223 return spectral_values
225 def K(
226 self, params: Dict[str, B.Numeric], X: B.Numeric, X2: Optional[B.Numeric] = None, **kwargs # type: ignore
227 ) -> B.Numeric:
228 _check_field_in_params(params, "lengthscale")
229 _check_1_vector(params["lengthscale"], 'params["lengthscale"]')
231 _check_field_in_params(params, "nu")
232 _check_1_vector(params["nu"], 'params["nu"]')
234 weights = B.cast(B.dtype(params["nu"]), self.eigenvalues(params)) # [L, 1]
235 Phi = self.eigenfunctions
236 K = Phi.weighted_outerproduct(weights, X, X2, **kwargs) # [N, N2]
237 if is_complex(K):
238 return B.real(K)
239 else:
240 return K
242 def K_diag(self, params: Dict[str, B.Numeric], X: B.Numeric, **kwargs) -> B.Numeric:
243 _check_field_in_params(params, "lengthscale")
244 _check_1_vector(params["lengthscale"], 'params["lengthscale"]')
246 _check_field_in_params(params, "nu")
247 _check_1_vector(params["nu"], 'params["nu"]')
249 weights = B.cast(B.dtype(params["nu"]), self.eigenvalues(params)) # [L, 1]
250 Phi = self.eigenfunctions
251 K_diag = Phi.weighted_outerproduct_diag(weights, X, **kwargs) # [N,]
252 if is_complex(K_diag):
253 return B.real(K_diag)
254 else:
255 return K_diag