Coverage for geometric_kernels / spaces / hypercube_graph.py: 94%

90 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2025-12-15 13:41 +0000

1""" 

2This module provides the :class:`HypercubeGraph` space and the respective 

3:class:`~.eigenfunctions.Eigenfunctions` subclass :class:`WalshFunctions`. 

4""" 

5 

6from itertools import combinations 

7from math import comb 

8 

9import lab as B 

10import numpy as np 

11from beartype.typing import List, Optional 

12 

13from geometric_kernels.lab_extras import dtype_double, float_like 

14from geometric_kernels.spaces.base import DiscreteSpectrumSpace 

15from geometric_kernels.spaces.eigenfunctions import ( 

16 Eigenfunctions, 

17 EigenfunctionsWithAdditionTheorem, 

18) 

19from geometric_kernels.utils.special_functions import ( 

20 generalized_kravchuk_normalized, 

21 walsh_function, 

22) 

23from geometric_kernels.utils.utils import chain, hamming_distance, log_binomial 

24 

25 

26class WalshFunctions(EigenfunctionsWithAdditionTheorem): 

27 r""" 

28 Eigenfunctions of graph Laplacian on the hypercube graph $C^d$ whose nodes 

29 are index by binary vectors in $\{0, 1\}^d$ are the Walsh 

30 functions $w_T: C^d \to \{-1, 1\}$ given by 

31 

32 .. math:: w_T(x_0, .., x_{d-1}) = (-1)^{\sum_{i \in T} x_i}, 

33 

34 enumerated by all possible subsets $T$ of the set $\{0, .., d-1\}$. 

35 

36 Levels are the whole eigenspaces, comprising all Walsh functions $w_T$ with 

37 the same cardinality of $T$. The addition theorem for these is based on 

38 certain discrete orthogonal polynomials called Kravchuk polynomials. 

39 

40 :param dim: 

41 Dimension $d$ of the hypercube graph. 

42 

43 :param num_levels: 

44 Specifies the number of levels of the Walsh functions. 

45 """ 

46 

47 def __init__(self, dim: int, num_levels: int) -> None: 

48 if num_levels > dim + 1: 

49 raise ValueError("The number of levels should be at most `dim`+1.") 

50 self.dim = dim 

51 self._num_levels = num_levels 

52 self._num_eigenfunctions: Optional[int] = None # To be computed when needed. 

53 

54 def __call__(self, X: B.Bool, **kwargs) -> B.Float: 

55 return B.stack( 

56 *[ 

57 walsh_function(self.dim, list(cur_combination), X) 

58 for level in range(self.num_levels) 

59 for cur_combination in combinations(range(self.dim), level) 

60 ], 

61 axis=1, 

62 ) 

63 

64 def _addition_theorem( 

65 self, X: B.Numeric, X2: Optional[B.Numeric] = None, **kwargs 

66 ) -> B.Numeric: 

67 

68 if X2 is None: 

69 X2 = X 

70 

71 hamming_distances = hamming_distance(X, X2) 

72 

73 values = [] 

74 

75 kravchuk_normalized_j_minus_1, kravchuk_normalized_j_minus_2 = None, None 

76 for level in range(self.num_levels): 

77 cur_kravchuk_normalized = generalized_kravchuk_normalized( 

78 self.dim, 

79 level, 

80 hamming_distances, 

81 2, 

82 kravchuk_normalized_j_minus_1, 

83 kravchuk_normalized_j_minus_2, 

84 ) # [N, N2] 

85 kravchuk_normalized_j_minus_2 = kravchuk_normalized_j_minus_1 

86 kravchuk_normalized_j_minus_1 = cur_kravchuk_normalized 

87 

88 values.append( 

89 comb(self.dim, level) * cur_kravchuk_normalized[..., None] # [N, N2, 1] 

90 ) 

91 

92 return B.concat(*values, axis=-1) # [N, N2, L] 

93 

94 def _addition_theorem_diag(self, X: B.Numeric, **kwargs) -> B.Numeric: 

95 """ 

96 These are certain easy to compute constants. 

97 """ 

98 values = [ 

99 comb(self.dim, level) * B.ones(float_like(X), *X.shape[:-1], 1) # [N, 1] 

100 for level in range(self.num_levels) 

101 ] 

102 return B.concat(*values, axis=1) # [N, L] 

103 

104 def weighted_outerproduct( 

105 self, 

106 weights: B.Numeric, 

107 X: B.Numeric, 

108 X2: Optional[B.Numeric] = None, # type: ignore 

109 **kwargs, 

110 ) -> B.Numeric: 

111 if X2 is None: 

112 X2 = X 

113 

114 hamming_distances = hamming_distance(X, X2) 

115 

116 result = B.zeros(B.dtype(weights), X.shape[0], X2.shape[0]) # [N, N2] 

117 kravchuk_normalized_j_minus_1, kravchuk_normalized_j_minus_2 = None, None 

118 for level in range(self.num_levels): 

119 cur_kravchuk_normalized = generalized_kravchuk_normalized( 

120 self.dim, 

121 level, 

122 hamming_distances, 

123 2, 

124 kravchuk_normalized_j_minus_1, 

125 kravchuk_normalized_j_minus_2, 

126 ) 

127 kravchuk_normalized_j_minus_2 = kravchuk_normalized_j_minus_1 

128 kravchuk_normalized_j_minus_1 = cur_kravchuk_normalized 

129 

130 # Instead of multiplying weights by binomial coefficients, we sum their 

131 # logs and then exponentiate the result for numerical stability. 

132 # Furthermore, we save the computed Kravchuk polynomials for next iterations. 

133 result += ( 

134 B.exp(B.log(weights[level]) + log_binomial(self.dim, level)) 

135 * cur_kravchuk_normalized 

136 ) 

137 

138 return result # [N, N2] 

139 

140 def weighted_outerproduct_diag( 

141 self, weights: B.Numeric, X: B.Numeric, **kwargs 

142 ) -> B.Numeric: 

143 

144 # Instead of multiplying weights by binomial coefficients, we sum their 

145 # logs and then exponentiate the result for numerical stability. 

146 result = sum( 

147 B.exp(B.log(weights[level]) + log_binomial(self.dim, level)) 

148 * B.ones(float_like(X), *X.shape[:-1], 1) 

149 for level in range(self.num_levels) 

150 ) # [N, 1] 

151 

152 return B.reshape(result, *result.shape[:-1]) # [N,] 

153 

154 @property 

155 def num_eigenfunctions(self) -> int: 

156 if self._num_eigenfunctions is None: 

157 self._num_eigenfunctions = sum(self.num_eigenfunctions_per_level) 

158 return self._num_eigenfunctions 

159 

160 @property 

161 def num_levels(self) -> int: 

162 return self._num_levels 

163 

164 @property 

165 def num_eigenfunctions_per_level(self) -> List[int]: 

166 return [comb(self.dim, level) for level in range(self.num_levels)] 

167 

168 

169class HypercubeGraph(DiscreteSpectrumSpace): 

170 r""" 

171 The GeometricKernels space representing the d-dimensional hypercube graph 

172 $C^d = \{0, 1\}^d$, the combinatorial space of binary vectors of length $d$. 

173 

174 The elements of this space are represented by d-dimensional boolean vectors. 

175 

176 Levels are the whole eigenspaces. 

177 

178 .. note:: 

179 A tutorial on how to use this space is available in the 

180 :doc:`HypercubeGraph.ipynb </examples/HypercubeGraph>` notebook. 

181 

182 .. note:: 

183 Since the degree matrix is a constant multiple of the identity, all 

184 types of the graph Laplacian coincide on the hypercube graph up to a 

185 constant, we choose the normalized Laplacian for numerical stability. 

186 

187 :param dim: 

188 Dimension $d$ of the hypercube graph $C^d$, a positive integer. 

189 

190 .. admonition:: Citation 

191 

192 If you use this GeometricKernels space in your research, please consider 

193 citing :cite:t:`borovitskiy2023`. 

194 """ 

195 

196 def __init__(self, dim: int): 

197 if dim < 1: 

198 raise ValueError("dim must be a positive integer.") 

199 self.dim = dim 

200 

201 def __str__(self): 

202 return f"HypercubeGraph({self.dim})" 

203 

204 @property 

205 def dimension(self) -> int: 

206 """ 

207 Returns d, the `dim` parameter that was passed down to `__init__`. 

208 

209 .. note: 

210 Although this is a graph, and graphs are generally treated as 

211 0-dimensional throughout GeometricKernels, we make an exception for 

212 HypercubeGraph. This is because it helps maintain good behavior of 

213 Matérn kernels with the usual values of the smoothness parameter 

214 nu, i.e. nu = 1/2, nu = 3/2, nu = 5/2. 

215 """ 

216 return self.dim 

217 

218 def get_eigenfunctions(self, num: int) -> Eigenfunctions: 

219 """ 

220 Returns the :class:`~.WalshFunctions` object with `num` levels. 

221 

222 :param num: 

223 Number of levels. 

224 """ 

225 return WalshFunctions(self.dim, num) 

226 

227 def get_eigenvalues(self, num: int) -> B.Numeric: 

228 eigenvalues = np.array( 

229 [ 

230 2 

231 * level 

232 / self.dim # we assume normalized Laplacian (for numerical stability) 

233 for level in range(num) 

234 ] 

235 ) 

236 return B.reshape(eigenvalues, -1, 1) # [num, 1] 

237 

238 def get_repeated_eigenvalues(self, num: int) -> B.Numeric: 

239 eigenvalues_per_level = self.get_eigenvalues(num) 

240 

241 eigenfunctions = WalshFunctions(self.dim, num) 

242 eigenvalues = chain( 

243 B.squeeze(eigenvalues_per_level), 

244 eigenfunctions.num_eigenfunctions_per_level, 

245 ) # [J,] 

246 return B.reshape(eigenvalues, -1, 1) # [J, 1] 

247 

248 def random(self, key: B.RandomState, number: int) -> B.Numeric: 

249 r""" 

250 Sample uniformly random points on the hypercube graph $C^d$. 

251 

252 Always returns [N, D] boolean array of the `key`'s backend. 

253 

254 :param key: 

255 Either `np.random.RandomState`, `tf.random.Generator`, 

256 `torch.Generator` or `jax.tensor` (representing random state). 

257 :param number: 

258 Number N of samples to draw. 

259 

260 :return: 

261 An array of `number` uniformly random samples on the space. 

262 """ 

263 key, random_points = B.random.rand( 

264 key, dtype_double(key), number, self.dimension 

265 ) 

266 

267 random_points = random_points < 0.5 

268 

269 return key, random_points 

270 

271 @property 

272 def element_shape(self): 

273 """ 

274 :return: 

275 [d]. 

276 """ 

277 return [self.dimension] 

278 

279 @property 

280 def element_dtype(self): 

281 """ 

282 :return: 

283 B.Bool. 

284 """ 

285 return B.Bool