Coverage for qml_essentials / coefficients.py: 97%

671 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-08-18 14:58 +0000

1from __future__ import annotations 

2import sys 

3import math 

4import warnings 

5import itertools 

6from collections import defaultdict 

7import jax.numpy as jnp 

8from jax import random 

9import numpy as np 

10from scipy.stats import rankdata 

11from functools import reduce, lru_cache 

12from typing import List, Tuple, Optional, Any, Dict, Union 

13 

14from qml_essentials.model import Model 

15from qml_essentials.pauli import PauliCircuit 

16from qml_essentials.operations import PauliWord 

17 

18import logging 

19 

20log = logging.getLogger(__name__) 

21 

22 

23class Coefficients: 

24 @classmethod 

25 def get_spectrum( 

26 cls, 

27 model: Model, 

28 mfs: int = 1, 

29 mts: int = 1, 

30 shift=False, 

31 trim=False, 

32 numerical_cap: Optional[float] = -1, 

33 **kwargs, 

34 ) -> Tuple[jnp.ndarray, jnp.ndarray]: 

35 """ 

36 Extracts the coefficients of a given model using a FFT (jnp-fft). 

37 

38 Note that the coefficients are complex numbers, but the imaginary part 

39 of the coefficients should be very close to zero, since the expectation 

40 values of the Pauli operators are real numbers. 

41 

42 It can perform oversampling in both the frequency and time domain 

43 using the `mfs` and `mts` arguments. 

44 

45 Args: 

46 model (Model): The model to sample. 

47 mfs (int): Multiplicator for the highest frequency. Default is 2. 

48 mts (int): Multiplicator for the number of time samples. Default is 1. 

49 shift (bool): Whether to apply jnp-fftshift. Default is False. 

50 trim (bool): Whether to remove the Nyquist frequency if spectrum is even. 

51 Default is False. 

52 numerical_cap (Optional[float]): Numerical cap for the coefficients. 

53 If positive, coefficients with magnitude below the cap are 

54 zeroed and, for a single input feature, frequencies that 

55 vanish entirely are removed from both `coeffs` and `freqs`. 

56 kwargs (Any): Additional keyword arguments for the model function. 

57 

58 Returns: 

59 Tuple[jnp.ndarray, jnp.ndarray]: Tuple containing the coefficients 

60 and frequencies. 

61 """ 

62 kwargs.setdefault("force_mean", True) 

63 kwargs.setdefault("execution_type", "expval") 

64 

65 coeffs, freqs = cls._fourier_transform(model, mfs=mfs, mts=mts, **kwargs) 

66 

67 if not jnp.isclose(jnp.sum(coeffs).imag, 0.0, atol=1.0e-6): 

68 raise ValueError( 

69 f"Spectrum is not real. Imaginary part of coefficients is:\ 

70 {jnp.sum(coeffs).imag}" 

71 ) 

72 

73 if trim: 

74 for ax in range(model.n_input_feat): 

75 if coeffs.shape[ax] % 2 == 0: 

76 coeffs = np.delete(coeffs, len(coeffs) // 2, axis=ax) 

77 freqs = [np.delete(freq, len(freq) // 2, axis=ax) for freq in freqs] 

78 

79 if shift: 

80 coeffs = jnp.fft.fftshift(coeffs, axes=list(range(model.n_input_feat))) 

81 freqs = np.fft.fftshift(freqs) 

82 

83 if numerical_cap > 0: 

84 # set coeffs below threshold to zero 

85 coeffs = jnp.where( 

86 jnp.abs(coeffs) < numerical_cap, 

87 jnp.zeros_like(coeffs), 

88 coeffs, 

89 ) 

90 

91 # Drop frequencies whose coefficients vanish entirely after 

92 # capping, so the returned spectrum reflects only the surviving 

93 # frequencies. Well-defined only for a single (1-D) frequency 

94 # axis; for multi-dim input the rectangular grid is left intact. 

95 if model.n_input_feat == 1: 

96 if coeffs.ndim == 1: 

97 surviving = coeffs != 0 

98 else: 

99 surviving = jnp.any(coeffs != 0, axis=tuple(range(1, coeffs.ndim))) 

100 coeffs = coeffs[surviving] 

101 freqs = [freqs[0][surviving]] 

102 

103 if len(freqs) == 1: 

104 freqs = freqs[0] 

105 

106 return coeffs, freqs 

107 

108 @classmethod 

109 def _fourier_transform( 

110 cls, model: Model, mfs: int, mts: int, **kwargs: Any 

111 ) -> jnp.ndarray: 

112 # Create a frequency vector with as many frequencies as model degrees, 

113 # oversampled by mfs 

114 n_freqs: jnp.ndarray = jnp.array( 

115 [mfs * model.degree[i] for i in range(model.n_input_feat)] 

116 ) 

117 

118 start, stop, step = 0, 2 * mts * jnp.pi, 2 * jnp.pi / n_freqs 

119 # Stretch according to the number of frequencies 

120 inputs: List = [ 

121 jnp.arange(start, stop, step[i]) for i in range(model.n_input_feat) 

122 ] 

123 

124 # permute with input dimensionality 

125 nd_inputs = jnp.array( 

126 jnp.meshgrid(*[inputs[i] for i in range(model.n_input_feat)]) 

127 ).T.reshape(-1, model.n_input_feat) 

128 

129 # Output vector is not necessarily the same length as input 

130 outputs = model(inputs=nd_inputs, **kwargs) 

131 outputs = outputs.reshape( 

132 *[inputs[i].shape[0] for i in range(model.n_input_feat)], -1 

133 ).squeeze() 

134 

135 coeffs = jnp.fft.fftn(outputs, axes=list(range(model.n_input_feat))) 

136 

137 freqs = [ 

138 jnp.fft.fftfreq(int(mts * n_freqs[i]), 1 / n_freqs[i]) 

139 for i in range(model.n_input_feat) 

140 ] 

141 # freqs = jnp.fft.fftfreq(mts * n_freqs, 1 / n_freqs) 

142 

143 # TODO: this could cause issues with multidim input 

144 # FIXME: account for different frequencies in multidim input scenarios 

145 # Run the fft and rearrange + 

146 # normalize the output (using product if multidim) 

147 return ( 

148 coeffs / math.prod(outputs.shape[0 : model.n_input_feat]), 

149 freqs, 

150 ) 

151 

152 @classmethod 

153 def get_psd(cls, coeffs: jnp.ndarray) -> jnp.ndarray: 

154 """ 

155 Calculates the power spectral density (PSD) from given Fourier coefficients. 

156 

157 Args: 

158 coeffs (jnp.ndarray): The Fourier coefficients. 

159 

160 Returns: 

161 jnp.ndarray: The power spectral density. 

162 """ 

163 # TODO: if we apply trim=True in advance, this will be slightly wrong.. 

164 

165 def abs2(x): 

166 return x.real**2 + x.imag**2 

167 

168 scale = 2.0 / (len(coeffs) ** 2) 

169 return scale * abs2(coeffs) 

170 

171 @classmethod 

172 def evaluate_Fourier_series( 

173 cls, 

174 coefficients: jnp.ndarray, 

175 frequencies: jnp.ndarray, 

176 inputs: Union[jnp.ndarray, list, float], 

177 ) -> float: 

178 """ 

179 Evaluate the function value of a Fourier series at one point. 

180 

181 Args: 

182 coefficients (jnp.ndarray): Coefficients of the Fourier series. 

183 frequencies (jnp.ndarray): Corresponding frequencies. 

184 inputs (jnp.ndarray): Point at which to evaluate the function. 

185 Returns: 

186 float: The function value at the input point. 

187 """ 

188 coefficients = jnp.asarray(coefficients) 

189 

190 def flatten_grid(freq_axes): 

191 freq_axes = [jnp.asarray(freq) for freq in freq_axes] 

192 freq_grid = jnp.stack(jnp.meshgrid(*freq_axes, indexing="ij"), axis=-1) 

193 flat_frequencies = freq_grid.reshape(-1, len(freq_axes)) 

194 flat_coefficients = coefficients.reshape( 

195 flat_frequencies.shape[0], *coefficients.shape[len(freq_axes) :] 

196 ) 

197 return flat_coefficients, flat_frequencies 

198 

199 if isinstance(frequencies, list): 

200 flat_coefficients, flat_frequencies = flatten_grid(frequencies) 

201 else: 

202 frequencies = jnp.asarray(frequencies) 

203 if frequencies.ndim == 1: 

204 flat_frequencies = frequencies[:, jnp.newaxis] 

205 flat_coefficients = coefficients.reshape( 

206 flat_frequencies.shape[0], *coefficients.shape[1:] 

207 ) 

208 else: 

209 n_features, n_axis_freqs = frequencies.shape 

210 is_axis_frequencies = ( 

211 coefficients.shape[:n_features] == (n_axis_freqs,) * n_features 

212 ) 

213 

214 if is_axis_frequencies: 

215 flat_coefficients, flat_frequencies = flatten_grid(frequencies) 

216 else: 

217 flat_frequencies = frequencies 

218 flat_coefficients = coefficients.reshape( 

219 flat_frequencies.shape[0], *coefficients.shape[1:] 

220 ) 

221 

222 inputs = jnp.asarray(inputs) 

223 if inputs.ndim == 0: 

224 inputs = inputs.reshape(1, 1) 

225 elif inputs.ndim == 1: 

226 if flat_frequencies.shape[1] == 1: 

227 inputs = inputs[:, jnp.newaxis] 

228 elif inputs.shape[0] == flat_frequencies.shape[1]: 

229 inputs = inputs[jnp.newaxis, :] 

230 else: 

231 inputs = jnp.repeat( 

232 inputs[:, jnp.newaxis], flat_frequencies.shape[1], axis=1 

233 ) 

234 exponents = jnp.exp(1j * (inputs @ flat_frequencies.T)) 

235 exp = jnp.tensordot(exponents, flat_coefficients, axes=([1], [0])) 

236 

237 return jnp.squeeze(jnp.real(exp)) 

238 

239 

240class FourierTree: 

241 """ 

242 Sine-cosine tree representation for the algorithm by Nemkov et al. 

243 

244 Computes the analytical Fourier coefficients/frequencies of a Pauli-Clifford 

245 circuit. The symbolic structure of the tree (which Pauli rotations 

246 contribute sine/cosine factors to which leaf, and the leaf observables) is 

247 built once in NumPy; the parameter-dependent coefficients are then obtained 

248 with a small number of vectorised JAX operations, so the result remains 

249 jittable / differentiable with respect to the model parameters. 

250 

251 The resulting spectrum is the d-dimensional set of frequency vectors, 

252 where $d$ is the input dimensionality. 

253 

254 **Usage**: 

255 ``` 

256 model = Model(...) 

257 tree = FourierTree(model) 

258 exp = tree() # expectation value 

259 coeff_list, freq_list = tree.get_spectrum() 

260 ``` 

261 """ 

262 

263 def __init__(self, model: Model): 

264 """ 

265 Tree initialisation, based on the Pauli-Clifford representation of a 

266 model. 

267 

268 Args: 

269 model (Model): The Model, for which to build the tree. 

270 """ 

271 self.model = model 

272 self.n_qubits = model.n_qubits 

273 

274 # A single (de-batched) parameter set drives the whole tree. 

275 self._params = self._single_param_set(model.params) 

276 

277 # Canonical Pauli-Clifford structure, recorded once at a fixed base 

278 # input. The base value is irrelevant to the structure (it only sets 

279 # the rotation angles, not which Pauli words appear). 

280 base_inputs = np.ones(model.n_input_feat) 

281 operations, observables = self._build_canonical_tape(self._params, base_inputs) 

282 

283 self.parameters = [ 

284 jnp.squeeze(p) for p in PauliCircuit.get_parameters(operations) 

285 ] 

286 self.n_params = len(self.parameters) 

287 

288 # Pauli generators of the (canonical) rotations, as symbolic words. 

289 self.pauli_words: List[PauliWord] = [ 

290 PauliWord.from_operation(op, self.n_qubits) for op in operations 

291 ] 

292 

293 # Cumulative X/Y support of the rotations[0..k] (for light-cone early 

294 # stopping). cumulative_xy[k] is True on every qubit touched by an X/Y 

295 # generator in any rotation up to index k. 

296 self.cumulative_xy: List[np.ndarray] = [] 

297 running = np.zeros(self.n_qubits, dtype=bool) 

298 for pw in self.pauli_words: 

299 running = np.logical_or(running, pw.xy_mask) 

300 self.cumulative_xy.append(running.copy()) 

301 

302 # Observable Pauli words (one tree root each). 

303 self.observable_words: List[PauliWord] = [ 

304 PauliWord.from_operation(obs, self.n_qubits) for obs in observables 

305 ] 

306 

307 # Identify the input-encoding columns, their feature, and integer 

308 # frequency scaling directly from the tape (no per-gate tagging). Sets 

309 # ``input_indices``, ``all_input_indices``, ``input_scaling``, 

310 # ``var_positions`` and ``features``. 

311 self._detect_inputs(base_inputs) 

312 

313 # The explicit leaf structure is built lazily: for deep circuits the 

314 # number of tree paths explodes combinatorially, while the canonical 

315 # form above (and the merged-state support DP) remain cheap. 

316 self._structure_built = False 

317 

318 def _ensure_structure(self) -> None: 

319 """Build the explicit leaf/spectrum structure on first use.""" 

320 if not self._structure_built: 

321 # Symbolic structure: per root (S, C, terms) leaf arrays ... 

322 self._build_leaf_arrays() 

323 # ... and the parameter-independent frequency/weight structure. 

324 self._build_spectrum_structure() 

325 self._structure_built = True 

326 

327 def _single_param_set(self, params) -> jnp.ndarray: 

328 """De-batch the model parameters to the single set the tree describes. 

329 

330 Models can carry batched parameters (e.g. after FCC sampling); the tree 

331 is defined for one set, so fall back to the first and warn. 

332 """ 

333 params = jnp.asarray(params) 

334 if params.ndim > 2 and params.shape[0] > 1: 

335 warnings.warn( 

336 f"FourierTree supports a single parameter set; using the first " 

337 f"of {params.shape[0]} batched parameter sets.", 

338 UserWarning, 

339 ) 

340 params = params[0] 

341 return params 

342 

343 def _build_canonical_tape(self, params, inputs): 

344 """Record the circuit and transform it to Pauli-Clifford normal form. 

345 

346 Returns the ``(operations, observables)`` of the canonical circuit 

347 (see :meth:`PauliCircuit.from_parameterised_circuit`). 

348 """ 

349 params = self._single_param_set(params) 

350 inputs = self.model._inputs_validation(inputs) 

351 raw_tape = self.model.script._record(params=params, inputs=inputs) 

352 _, obs_list = self.model._build_obs() 

353 return PauliCircuit.from_parameterised_circuit( 

354 raw_tape, observables=obs_list, n_qubits=self.n_qubits 

355 ) 

356 

357 def _canonical_parameters(self, inputs) -> np.ndarray: 

358 """Recorded canonical rotation angles (1-D float array) for ``inputs``.""" 

359 operations, _ = self._build_canonical_tape(self._params, inputs) 

360 return np.array( 

361 [float(jnp.squeeze(p)) for p in PauliCircuit.get_parameters(operations)] 

362 ) 

363 

364 def _detect_inputs(self, base_inputs: np.ndarray) -> None: 

365 r"""Infer the input-encoding columns directly from the tape (tag-free). 

366 

367 Each encoding rotation applies an angle :math:`\omega_k\,x_f` that is 

368 linear in a single input feature :math:`x_f`, and Clifford commutation 

369 only multiplies a rotation generator by :math:`\pm 1`. Every canonical 

370 rotation angle is therefore an affine function of the inputs, so 

371 perturbing one feature at a time and differencing the recorded angles 

372 isolates exactly the columns that depend on it, together with the 

373 signed scaling :math:`\omega_k` (rational for diagonal-Hamiltonian 

374 encodings such as Golomb). 

375 

376 Sets :attr:`input_indices` (``{feature: [columns]}``), 

377 :attr:`all_input_indices`, :attr:`input_scaling` (per column, ``1`` for 

378 variational columns), :attr:`var_positions`, and :attr:`features`. 

379 

380 Raises: 

381 NotImplementedError: If a rotation depends on more than one feature 

382 (the tree requires single-feature encodings). 

383 """ 

384 tol = 1e-6 

385 d = self.model.n_input_feat 

386 base = np.asarray(base_inputs, dtype=float) 

387 p_base = np.array([float(p) for p in self.parameters]) 

388 

389 # response[f, k] = d(angle_k) / d(x_f), the linear response of column k. 

390 response = np.zeros((d, self.n_params)) 

391 for f in range(d): 

392 step = base.copy() 

393 step[f] += 1.0 

394 response[f] = self._canonical_parameters(step) - p_base 

395 

396 input_indices: Dict[int, list] = defaultdict(list) 

397 all_input_indices: List[int] = [] 

398 scaling = np.ones(self.n_params, dtype=np.float64) 

399 for k in range(self.n_params): 

400 feats = np.flatnonzero(np.abs(response[:, k]) > tol) 

401 if feats.size == 0: 

402 continue # variational column 

403 if feats.size > 1: 

404 raise NotImplementedError( 

405 f"Rotation {k} depends on multiple input features " 

406 f"{feats.tolist()}; the Fourier tree requires each encoding " 

407 "rotation to be linear in a single feature." 

408 ) 

409 f = int(feats[0]) 

410 omega = float(response[f, k]) 

411 # Scalings may be rational (e.g. diagonal-Hamiltonian / Golomb 

412 # encodings, whose per-Pauli-string scalings are dyadic rationals); 

413 # the integer-frequency model spectrum is recovered downstream. 

414 input_indices[f].append(k) 

415 all_input_indices.append(k) 

416 scaling[k] = omega 

417 

418 self.input_indices = input_indices 

419 self.all_input_indices = all_input_indices 

420 self.input_scaling = scaling 

421 input_set = set(all_input_indices) 

422 self.var_positions = np.array( 

423 [i for i in range(self.n_params) if i not in input_set], dtype=np.int64 

424 ) 

425 # Ordered list of input feature keys (d-dimensional spectrum). 

426 self.features = sorted(input_indices.keys()) 

427 

428 # Symbolic tree construction (NumPy) 

429 def _build_leaf_arrays(self) -> None: 

430 """Collect the tree leaves for every root into integer count matrices. 

431 

432 For each root (observable) this produces: 

433 - ``S``: (n_leaves, n_params) sine-factor counts per parameter, 

434 - ``C``: (n_leaves, n_params) cosine-factor counts per parameter, 

435 - ``terms``: (n_leaves,) complex leaf constants ``<0|O_leaf|0>``. 

436 """ 

437 self.leaf_arrays: List[Tuple[np.ndarray, np.ndarray, np.ndarray]] = [] 

438 for obs_word in self.observable_words: 

439 leaves: List[Tuple[np.ndarray, np.ndarray, complex]] = [] 

440 zeros = np.zeros(self.n_params, dtype=np.int64) 

441 self._collect_leaves( 

442 obs_word, self.n_params - 1, zeros.copy(), zeros.copy(), leaves 

443 ) 

444 if leaves: 

445 S = np.stack([leaf[0] for leaf in leaves]) 

446 C = np.stack([leaf[1] for leaf in leaves]) 

447 terms = np.array([leaf[2] for leaf in leaves], dtype=np.complex128) 

448 else: 

449 S = np.zeros((0, self.n_params), dtype=np.int64) 

450 C = np.zeros((0, self.n_params), dtype=np.int64) 

451 terms = np.zeros(0, dtype=np.complex128) 

452 self.leaf_arrays.append((S, C, terms)) 

453 

454 def _collect_leaves( 

455 self, 

456 observable: PauliWord, 

457 pauli_idx: int, 

458 sin_counts: np.ndarray, 

459 cos_counts: np.ndarray, 

460 leaves: List[Tuple[np.ndarray, np.ndarray, complex]], 

461 ) -> None: 

462 """Recursively enumerate the leaves of the coefficient tree. 

463 

464 The incoming sine/cosine factor (from the parent edge) is already 

465 accumulated into ``sin_counts``/``cos_counts``. This fuses the tree 

466 construction and leaf traversal of the original implementation into a 

467 single NumPy pass (no per-node JAX scatter updates). 

468 """ 

469 if self._early_stopping_possible(pauli_idx, observable): 

470 return 

471 

472 # Skip trailing Pauli rotations that commute with the observable. 

473 while pauli_idx >= 0: 

474 last = self.pauli_words[pauli_idx] 

475 if not observable.commutes_with(last): 

476 break 

477 pauli_idx -= 1 

478 else: # leaf reached 

479 term = observable.zero_expectation() 

480 if term != 0: 

481 leaves.append((sin_counts, cos_counts, term)) 

482 return 

483 

484 last = self.pauli_words[pauli_idx] 

485 

486 # Left child: cosine factor for this parameter, same observable. 

487 cos_left = cos_counts.copy() 

488 cos_left[pauli_idx] += 1 

489 self._collect_leaves( 

490 observable, pauli_idx - 1, sin_counts.copy(), cos_left, leaves 

491 ) 

492 

493 # Right child: sine factor, observable becomes P . O. 

494 sin_right = sin_counts.copy() 

495 sin_right[pauli_idx] += 1 

496 self._collect_leaves( 

497 last.compose(observable), 

498 pauli_idx - 1, 

499 sin_right, 

500 cos_counts.copy(), 

501 leaves, 

502 ) 

503 

504 def _early_stopping_possible(self, pauli_idx: int, observable: PauliWord) -> bool: 

505 """Whether a node can be discarded (all reachable expectations vanish). 

506 

507 Mirrors the criterion of Nemkov et al. (light cone): a qubit on which 

508 the observable carries an X/Y must be covered by an X/Y generator of 

509 some remaining rotation (rotations[0..pauli_idx]); otherwise that X/Y can 

510 never be rotated into a diagonal term and the whole node contributes 

511 zero. Equivalently, the node survives iff every qubit is either I/Z in 

512 the observable or covered by the cumulative rotation X/Y support. 

513 """ 

514 obs_iz = np.logical_not(observable.xy_mask) 

515 combined = np.logical_or(obs_iz, self.cumulative_xy[pauli_idx]).all() 

516 return not bool(combined) 

517 

518 # Frequency / weight structure (NumPy, parameter independent) 

519 def _build_spectrum_structure(self) -> None: 

520 """Build, per root, the frequency vectors and the (n_freq, n_leaves) 

521 weight matrix ``W`` such that ``coeffs = W @ (terms * variational)``. 

522 """ 

523 self.freqs_per_root: List[np.ndarray] = [] 

524 self.weights_per_root: List[np.ndarray] = [] 

525 d = len(self.features) 

526 

527 for S, C, _ in self.leaf_arrays: 

528 n_leaves = S.shape[0] 

529 freq_to_col: Dict[tuple, np.ndarray] = defaultdict( 

530 lambda: np.zeros(n_leaves, dtype=np.complex128) 

531 ) 

532 for leaf in range(n_leaves): 

533 # One expansion factor per *active* input column, each carrying 

534 # its feature axis and integer frequency scaling. Per leaf a 

535 # column contributes at most one sin/cos factor (square-free), 

536 # but different columns of the same feature may carry different 

537 # scalings, so they are expanded individually and convolved 

538 # rather than aggregating counts (which would assume a common 

539 # unit scaling). 

540 col_factors: List[List[Tuple[int, float, float]]] = [] 

541 half_exp = 0 

542 for axis, feat in enumerate(self.features): 

543 for k in self.input_indices[feat]: 

544 s = int(S[leaf, k]) 

545 c = int(C[leaf, k]) 

546 if s == 0 and c == 0: 

547 continue 

548 half_exp += s + c 

549 w_k = float(self.input_scaling[k]) 

550 col_factors.append( 

551 [ 

552 (axis, o * w_k, wt) 

553 for o, wt in self._binomial_terms(s, c) 

554 ] 

555 ) 

556 half = 0.5**half_exp 

557 

558 if d == 0: 

559 freq_to_col[(0,)][leaf] += half 

560 continue 

561 

562 if not col_factors: 

563 freq_to_col[(0,) * d][leaf] += half 

564 continue 

565 

566 for combo in itertools.product(*col_factors): 

567 omega = [0.0] * d 

568 weight = half 

569 for axis, o, wt in combo: 

570 omega[axis] += o 

571 weight *= wt 

572 # Snap to a tolerance so dyadic-rational contributions that 

573 # are numerically equal share a single frequency key. 

574 key = tuple(round(v, 9) for v in omega) 

575 freq_to_col[key][leaf] += weight 

576 

577 if freq_to_col: 

578 omegas = sorted(freq_to_col.keys()) 

579 W = np.stack([freq_to_col[o] for o in omegas]) # (n_freq, n_leaves) 

580 freqs = np.array(omegas, dtype=float) # (n_freq, d) 

581 # Diagonal-Hamiltonian (Golomb) encodings produce rational 

582 # per-rotation scalings but integer model frequencies; snap to 

583 # int64 when every entry is integral so integer-encoding 

584 # consumers are unchanged, otherwise keep the rational floats. 

585 rounded = np.rint(freqs) 

586 if np.all(np.abs(freqs - rounded) < 1e-6): 

587 freqs = rounded.astype(np.int64) 

588 else: 

589 freqs = np.zeros((1, max(d, 1)), dtype=np.int64) 

590 W = np.zeros((1, n_leaves), dtype=np.complex128) 

591 

592 # Collapse to 1-D frequency array for the single-feature case. 

593 if freqs.shape[1] == 1: 

594 freqs = freqs[:, 0] 

595 self.freqs_per_root.append(freqs) 

596 # Keep W in NumPy complex128: its entries are dyadic rationals 

597 # (binomial weights x 0.5^k x i^m), which are exact in float64 -- 

598 # this allows exact symbolic zero-tests in get_exact_support. 

599 self.weights_per_root.append(W) 

600 

601 @staticmethod 

602 def _binomial_terms(s: int, c: int) -> List[Tuple[int, float]]: 

603 """Expand ``cos^c (i sin)^s`` in ``e^{i omega x}`` (without the 0.5 factor). 

604 

605 Returns a list of ``(omega, weight)`` with 

606 ``omega = 2a + 2b - s - c`` and ``weight = C(s,a) C(c,b) (-1)^{s-a}``. 

607 """ 

608 terms = [] 

609 for a in range(s + 1): 

610 for b in range(c + 1): 

611 weight = math.comb(s, a) * math.comb(c, b) * (-1) ** (s - a) 

612 terms.append((2 * a + 2 * b - s - c, float(weight))) 

613 return terms 

614 

615 # Vectorised numeric evaluation (JAX) 

616 @staticmethod 

617 def _safe_pow(base: jnp.ndarray, exp: jnp.ndarray) -> jnp.ndarray: 

618 """Elementwise ``base ** exp`` for real base and non-negative integer 

619 exponents, correct for negative bases (avoids ``log`` of negatives). 

620 

621 Args: 

622 base: real array of shape ``(n,)``. 

623 exp: integer array of shape ``(n_leaves, n)``. 

624 """ 

625 mag = jnp.abs(base)[None, :] ** exp 

626 sign = jnp.where(exp % 2 == 0, 1.0, jnp.sign(base)[None, :]) 

627 return sign * mag 

628 

629 _I_POW = None # set lazily to jnp.array([1, 1j, -1, -1j]) 

630 

631 def _leaf_factors( 

632 self, S: np.ndarray, C: np.ndarray, columns: np.ndarray 

633 ) -> jnp.ndarray: 

634 """Per-leaf product ``prod_i cos(theta_i)^{C} (i sin(theta_i))^{S}`` over 

635 the given parameter ``columns`` (vectorised over leaves). 

636 """ 

637 if FourierTree._I_POW is None: 

638 FourierTree._I_POW = jnp.array([1, 1j, -1, -1j]) 

639 

640 if S.shape[0] == 0: 

641 return jnp.zeros(0, dtype=jnp.complex64) 

642 

643 theta = jnp.stack([self.parameters[i] for i in columns]) 

644 S_sub = jnp.asarray(S[:, columns]) 

645 C_sub = jnp.asarray(C[:, columns]) 

646 

647 cos_part = self._safe_pow(jnp.cos(theta), C_sub) 

648 sin_mag = self._safe_pow(jnp.sin(theta), S_sub) 

649 i_part = FourierTree._I_POW[S_sub % 4] 

650 return jnp.prod(cos_part * sin_mag * i_part, axis=1) 

651 

652 def __call__( 

653 self, 

654 params: Optional[jnp.ndarray] = None, 

655 inputs: Optional[jnp.ndarray] = None, 

656 **kwargs, 

657 ) -> jnp.ndarray: 

658 """ 

659 Evaluate the expectation value(s) of the model's observables via the 

660 sine-cosine tree (equivalent to the circuit expectation). 

661 

662 Args: 

663 params (Optional[jnp.ndarray]): Model parameters. Defaults to the 

664 model's parameters. 

665 inputs (Optional[jnp.ndarray]): Inputs to the circuit. Defaults to 1. 

666 

667 Returns: 

668 jnp.ndarray: Expectation value per observable (or their mean if 

669 ``force_mean`` is set). 

670 

671 Raises: 

672 NotImplementedError: For execution types other than "expval" or when 

673 noise is requested. 

674 """ 

675 params = ( 

676 self.model._params_validation(params) 

677 if params is not None 

678 else self.model.params 

679 ) 

680 inputs = ( 

681 self.model._inputs_validation(inputs) 

682 if inputs is not None 

683 else self.model._inputs_validation(1.0) 

684 ) 

685 

686 if kwargs.get("execution_type", "expval") != "expval": 

687 raise NotImplementedError( 

688 f'Currently, only "expval" execution type is supported when ' 

689 f"building FourierTree. Got {kwargs.get('execution_type', 'expval')}." 

690 ) 

691 if kwargs.get("noise_params", None) is not None: 

692 raise NotImplementedError( 

693 "Currently, noise is not supported when building FourierTree." 

694 ) 

695 

696 # Re-derive the (canonical) parameter values for the requested inputs; 

697 # the tree structure (leaf arrays) is unchanged. 

698 operations, _ = self._build_canonical_tape(params, inputs) 

699 self.parameters = [ 

700 jnp.squeeze(p) for p in PauliCircuit.get_parameters(operations) 

701 ] 

702 

703 self._ensure_structure() 

704 all_columns = np.arange(self.n_params, dtype=np.int64) 

705 results = [] 

706 for S, C, terms in self.leaf_arrays: 

707 factors = self._leaf_factors(S, C, all_columns) 

708 results.append(jnp.real(jnp.sum(jnp.asarray(terms) * factors))) 

709 results = jnp.array(results) 

710 

711 if kwargs.get("force_mean", False): 

712 return jnp.mean(results) 

713 return results 

714 

715 def get_spectrum( 

716 self, force_mean: bool = False 

717 ) -> Tuple[List[jnp.ndarray], List[jnp.ndarray]]: 

718 """ 

719 Compute the Fourier spectrum (coefficients and frequencies) of the tree. 

720 

721 Args: 

722 force_mean (bool, optional): Average the coefficients over all 

723 observables (roots). Defaults to False. 

724 

725 Returns: 

726 Tuple[List[jnp.ndarray], List[jnp.ndarray]]: 

727 - List of coefficients, one entry per observable (root). 

728 - List of corresponding frequencies, one entry per root. 

729 When ``force_mean`` is set, both lists have a single entry. 

730 """ 

731 self._ensure_structure() 

732 per_root_coeffs: List[jnp.ndarray] = [] 

733 for (S, C, terms), W in zip(self.leaf_arrays, self.weights_per_root): 

734 leaf_const = jnp.asarray(terms) * self._leaf_factors( 

735 S, C, self.var_positions 

736 ) 

737 per_root_coeffs.append(jnp.asarray(W) @ leaf_const) 

738 

739 return self._combine_roots(per_root_coeffs, self.freqs_per_root, force_mean) 

740 

741 def get_exact_support(self, method: str = "tree") -> List[np.ndarray]: 

742 r"""Symbolically derive the exact frequency support (no sampling). 

743 

744 A frequency :math:`\omega` belongs to the exact spectrum iff its 

745 coefficient :math:`c_\omega(\theta) = \sum_l W_{\omega l}\, 

746 \text{term}_l\, v_l(\theta)` is not identically zero in the 

747 variational parameters :math:`\theta`. 

748 

749 Two methods are available: 

750 

751 - ``"tree"`` (default, fully exact): enumerates the explicit tree 

752 leaves. Because the branch index strictly decreases along every tree 

753 path, each parameter contributes **at most one** sine *or* cosine 

754 factor per leaf (:math:`S_{li}, C_{li} \in \{0, 1\}`). Every 

755 variational leaf factor :math:`v_l` is therefore a *square-free* 

756 monomial over :math:`\{1, \cos\theta_i, i\sin\theta_i\}`, and 

757 monomials with distinct signatures are linearly independent functions 

758 (no :math:`\cos^2 + \sin^2` identities can arise without squares). 

759 Hence 

760 

761 .. math:: 

762 c_\omega \equiv 0 \iff \sum_{l \in g} W_{\omega l}\,\text{term}_l 

763 = 0 \quad \text{for every signature group } g. 

764 

765 Since all involved quantities are dyadic rationals times 

766 :math:`\{\pm 1, \pm i\}`, the group sums are exact in float64 and the 

767 zero-test is exact. The number of leaves can however grow 

768 exponentially with circuit depth. 

769 

770 - ``"dp"`` (scalable): merges tree nodes with identical 

771 ``(rotation index, observable)`` — at most ``n_params * 4^n_qubits`` 

772 states — and tracks, per state, the achievable per-feature sine/cosine 

773 count vectors ``(s_f, c_f)`` as a mixed-radix bitmask. Each feature's 

774 support is the union of the (exact) expansion supports of 

775 :math:`\cos^{c_f} x_f\, (i \sin x_f)^{s_f}`, and the model support is 

776 their Cartesian product across features. This is exact per tree path 

777 (including interior zero coefficients of the expansions), but unlike 

778 ``"tree"`` it cannot detect coefficients that cancel identically 

779 *across* paths with identical variational signatures (e.g. directly 

780 repeated encodings). It therefore yields a tight superset in such 

781 corner cases. Supports any number of input features, but requires 

782 unit-magnitude input scaling: per-gate :math:`|\omega| \neq 1` 

783 scalings (e.g. Golomb encodings) are rejected — use ``"tree"``. 

784 

785 Args: 

786 method (str): ``"tree"`` (fully exact) or ``"dp"`` (scalable). 

787 

788 Returns: 

789 List[np.ndarray]: For each observable (root), the frequency vectors 

790 with not-identically-zero coefficient — shape ``(n_freq,)`` for a 

791 single input feature, ``(n_freq, n_features)`` otherwise. 

792 """ 

793 if method == "dp": 

794 return self._support_dp() 

795 if method != "tree": 

796 raise ValueError(f"Unknown method '{method}'. Use 'tree' or 'dp'.") 

797 

798 self._ensure_structure() 

799 supports = [] 

800 for (S, C, terms), W, freqs in zip( 

801 self.leaf_arrays, self.weights_per_root, self.freqs_per_root 

802 ): 

803 freqs = np.asarray(freqs) 

804 n_leaves = S.shape[0] 

805 if n_leaves == 0: 

806 supports.append(freqs[:0]) 

807 continue 

808 

809 # Group leaves by their variational sine/cosine signature. 

810 signature = np.hstack([S[:, self.var_positions], C[:, self.var_positions]]) 

811 _, groups = np.unique(signature, axis=0, return_inverse=True) 

812 n_groups = int(groups.max()) + 1 

813 

814 # Per-group sums of W[omega, l] * term_l, accumulated exactly. 

815 contrib = (W * terms[None, :]).T # (n_leaves, n_freq) 

816 group_sums = np.zeros((n_groups, W.shape[0]), dtype=np.complex128) 

817 np.add.at(group_sums, groups, contrib) 

818 

819 mask = (np.abs(group_sums) > 1e-12).any(axis=0) # (n_freq,) 

820 supports.append(freqs[mask]) 

821 return supports 

822 

823 def _support_dp(self) -> List[np.ndarray]: 

824 """Merged-state dynamic program for the frequency support. 

825 

826 Instead of enumerating all (worst-case exponentially many) tree paths, 

827 nodes are merged on ``(rotation index, bare observable)``. Each state 

828 stores the set of achievable per-axis count vectors 

829 ``(c_0, s_0, ..., c_{d-1}, s_{d-1})`` as a mixed-radix bitmask (one 

830 digit per feature sine/cosine count), so transitions are O(1) big-int 

831 operations. See :meth:`get_exact_support` for semantics and 

832 limitations. 

833 """ 

834 # Count aggregation is valid as long as every input rotation has 

835 # unit-magnitude scaling. A Clifford-commutation sign flip (scaling -1) 

836 # leaves the frequency support unchanged -- cos is even and sin odd, so 

837 # the sign only flips the coefficient, which the support ignores -- but a 

838 # genuine per-gate scaling (|omega| != 1, e.g. Golomb / heterogeneous 

839 # frequencies) cannot be represented by sin/cos counts. 

840 if self.all_input_indices and np.any( 

841 np.abs(self.input_scaling[self.all_input_indices]) != 1 

842 ): 

843 raise NotImplementedError( 

844 "The 'dp' support method does not support non-unit input " 

845 "frequency scaling (it aggregates sin/cos counts and cannot " 

846 "represent per-gate scalings); use method='tree'." 

847 ) 

848 

849 n = self.n_qubits 

850 d = len(self.features) 

851 # Pack the achievable per-axis sine/cosine counts into one big-int as a 

852 # mixed-radix bitmask over (c_0, s_0, ..., c_{d-1}, s_{d-1}). Axis a's 

853 # counts range 0..n_a (n_a input rotations encode feature a), so each 

854 # digit has radix n_a + 1; a left-shift by a digit's place value 

855 # increments that count, OR is set-union. (For d == 1 this reduces to 

856 # shift_c = 1, shift_s = n_inp + 1, i.e. the flat (s, c) layout.) 

857 ranges = [len(self.input_indices[self.features[a]]) + 1 for a in range(d)] 

858 shift_c = [0] * d 

859 shift_s = [0] * d 

860 place = 1 

861 for a in range(d): 

862 shift_c[a] = place 

863 place *= ranges[a] 

864 shift_s[a] = place 

865 place *= ranges[a] 

866 # Feature axis of each input rotation (-1 if variational), in the same 

867 # enumerate(self.features) order the tree method uses for its axes. 

868 axis_of_col = np.full(self.n_params, -1, dtype=np.int64) 

869 for a in range(d): 

870 for k in self.input_indices[self.features[a]]: 

871 axis_of_col[k] = a 

872 

873 def encode(word: PauliWord) -> Tuple[int, int]: 

874 x = z = 0 

875 for q in range(n): 

876 x |= int(word.x[q]) << q 

877 z |= int(word.z[q]) << q 

878 return x, z 

879 

880 paulis = [encode(w) for w in self.pauli_words] 

881 cum_xy = [] 

882 running = 0 

883 for xp, _ in paulis: 

884 running |= xp 

885 cum_xy.append(running) 

886 

887 def parity(v: int) -> int: 

888 return bin(v).count("1") & 1 

889 

890 def dp(idx: int, xo: int, zo: int, memo: dict) -> int: 

891 # Light-cone early stopping (cf. _early_stopping_possible). 

892 if idx >= 0 and (xo & ~cum_xy[idx]): 

893 return 0 

894 # Skip trailing rotations that commute with the observable. 

895 while idx >= 0: 

896 xp, zp = paulis[idx] 

897 if parity(xo & zp) ^ parity(zo & xp): 

898 break 

899 idx -= 1 

900 else: # leaf: counts (s=0, c=0) iff the observable is diagonal 

901 return 1 if xo == 0 else 0 

902 key = (idx, xo, zo) 

903 hit = memo.get(key) 

904 if hit is not None: 

905 return hit 

906 xp, zp = paulis[idx] 

907 cos_child = dp(idx - 1, xo, zo, memo) 

908 sin_child = dp(idx - 1, xo ^ xp, zo ^ zp, memo) 

909 a = int(axis_of_col[idx]) 

910 if a >= 0: 

911 # Active input gate: cosine increments c_a, sine increments s_a. 

912 val = (cos_child << shift_c[a]) | (sin_child << shift_s[a]) 

913 else: 

914 val = cos_child | sin_child 

915 memo[key] = val 

916 return val 

917 

918 # Recursion depth is bounded by the number of rotations. 

919 old_limit = sys.getrecursionlimit() 

920 sys.setrecursionlimit(max(old_limit, self.n_params + 1000)) 

921 try: 

922 supports = [] 

923 for obs in self.observable_words: 

924 memo: dict = {} 

925 xo, zo = encode(obs) 

926 mask = dp(self.n_params - 1, xo, zo, memo) 

927 supports.append(self._dp_mask_to_support(mask, d, ranges)) 

928 finally: 

929 sys.setrecursionlimit(old_limit) 

930 return supports 

931 

932 def _dp_mask_to_support(self, mask: int, d: int, ranges: List[int]) -> np.ndarray: 

933 """Decode a count bitmask (see :meth:`_support_dp`) into a frequency 

934 support. Each set bit is a per-axis count vector ``(c_a, s_a)``; the 

935 per-axis expansion supports are combined across axes (Cartesian 

936 product) and unioned over all bits. 

937 

938 Returns an ``(n_freq, d)`` array, collapsed to 1-D for ``d <= 1`` to 

939 match :meth:`get_exact_support` with ``method="tree"`` (``d == 0`` keeps 

940 only the DC term). 

941 """ 

942 tupleset: set = set() 

943 while mask: 

944 bit = mask & -mask 

945 i = bit.bit_length() - 1 

946 rem = i 

947 axis_freqs = [] 

948 for a in range(d): 

949 c_a = rem % ranges[a] 

950 rem //= ranges[a] 

951 s_a = rem % ranges[a] 

952 rem //= ranges[a] 

953 axis_freqs.append(sorted(self._expansion_support(s_a, c_a))) 

954 tupleset.update(itertools.product(*axis_freqs)) 

955 mask ^= bit 

956 

957 if d >= 2: 

958 if not tupleset: 

959 return np.empty((0, d), dtype=np.int64) 

960 return np.array(sorted(tupleset), dtype=np.int64) 

961 # d in {0, 1}: collapse to a 1-D frequency array. 

962 flat = sorted(t[0] for t in tupleset) if d == 1 else ([0] if tupleset else []) 

963 return np.array(flat, dtype=np.int64) 

964 

965 @staticmethod 

966 @lru_cache(maxsize=None) 

967 def _expansion_support(s: int, c: int) -> frozenset: 

968 r"""Frequencies with non-zero coefficient in :math:`\cos^c x (i\sin x)^s`. 

969 

970 Computed exactly with integer arithmetic via the polynomial 

971 :math:`(t - 1)^s (t + 1)^c` (with :math:`t = e^{2ix}` up to a shift); 

972 interior coefficients can vanish, e.g. :math:`\cos x \sin x` only 

973 contains :math:`\pm 2`. 

974 """ 

975 coeffs = [1] 

976 for _ in range(s): # multiply by (t - 1) 

977 new = [0] * (len(coeffs) + 1) 

978 for i, a in enumerate(coeffs): 

979 new[i + 1] += a 

980 new[i] -= a 

981 coeffs = new 

982 for _ in range(c): # multiply by (t + 1) 

983 new = [0] * (len(coeffs) + 1) 

984 for i, a in enumerate(coeffs): 

985 new[i + 1] += a 

986 new[i] += a 

987 coeffs = new 

988 m = s + c 

989 return frozenset(2 * k - m for k, a in enumerate(coeffs) if a != 0) 

990 

991 def _combine_roots( 

992 self, 

993 per_root_coeffs: List[jnp.ndarray], 

994 per_root_freqs: List[np.ndarray], 

995 force_mean: bool, 

996 ) -> Tuple[List[jnp.ndarray], List[jnp.ndarray]]: 

997 """Assemble the per-root spectra, optionally averaging over roots.""" 

998 if not force_mean: 

999 coefficients = [jnp.asarray(c) for c in per_root_coeffs] 

1000 frequencies = [jnp.asarray(f) for f in per_root_freqs] 

1001 return coefficients, frequencies 

1002 

1003 # Average over roots on the union of all frequency vectors. Keys are 

1004 # snapped to a tolerance so rational frequencies dedup robustly. 

1005 accum: Dict[tuple, complex] = defaultdict(complex) 

1006 for coeffs, freqs in zip(per_root_coeffs, per_root_freqs): 

1007 freqs_np = np.asarray(freqs) 

1008 for k in range(freqs_np.shape[0]): 

1009 key = ( 

1010 (round(float(freqs_np[k]), 9),) 

1011 if freqs_np.ndim == 1 

1012 else tuple(round(float(v), 9) for v in freqs_np[k]) 

1013 ) 

1014 accum[key] += complex(coeffs[k]) 

1015 n_roots = max(len(per_root_coeffs), 1) 

1016 keys = sorted(accum.keys()) 

1017 mean_coeffs = jnp.array([accum[k] / n_roots for k in keys]) 

1018 freq_arr = np.array(keys, dtype=float) 

1019 rounded = np.rint(freq_arr) 

1020 if np.all(np.abs(freq_arr - rounded) < 1e-6): 

1021 freq_arr = rounded.astype(np.int64) 

1022 if freq_arr.shape[1] == 1: 

1023 freq_arr = freq_arr[:, 0] 

1024 return [mean_coeffs], [jnp.asarray(freq_arr)] 

1025 

1026 

1027class FCC: 

1028 @classmethod 

1029 def get_fcc( 

1030 cls, 

1031 model: Model, 

1032 n_samples: int, 

1033 random_key: Optional[random.PRNGKey] = None, 

1034 method: Optional[str] = "pearson", 

1035 scale: Optional[bool] = False, 

1036 weight: Optional[bool] = False, 

1037 trim_redundant: Optional[bool] = True, 

1038 **kwargs, 

1039 ) -> float: 

1040 """ 

1041 Shortcut method to get just the FCC. 

1042 This includes 

1043 1. What is done in `get_fourier_fingerprint`: 

1044 1. Calculating the coefficients (using `n_samples`) 

1045 2. Correlating the result from 1) using `method` 

1046 3. Weighting the correlation matrix (if `weight` is True) 

1047 4. Remove redundancies 

1048 2. What is done in `calculate_fcc`: 

1049 1. Absolute of the fingerprint 

1050 2. Average 

1051 

1052 Args: 

1053 model (Model): The QFM model 

1054 n_samples (int): Number of samples to calculate average of coefficients 

1055 random_key (Optional[random.PRNGKey]): JAX random key for parameter 

1056 initialization. If None, uses the model's internal random key. 

1057 method (Optional[str], optional): Correlation method. Supported values are 

1058 "pearson", "complex_pearson", "spearman", and "covariance". 

1059 Defaults to "pearson". 

1060 scale (Optional[bool], optional): Whether to scale the number of samples. 

1061 Defaults to False. 

1062 weight (Optional[bool], optional): Whether to weight the correlation matrix. 

1063 Defaults to False. 

1064 trim_redundant (Optional[bool], optional): Whether to remove redundant 

1065 correlations. Defaults to False. 

1066 **kwargs (Any): Additional keyword arguments for the model function. 

1067 

1068 Returns: 

1069 float: The FCC 

1070 """ 

1071 

1072 # Memory-efficient fast path 

1073 if trim_redundant and not weight: 

1074 _, coeffs, freqs = cls._calculate_coefficients( 

1075 model, n_samples, random_key, scale, **kwargs 

1076 ) 

1077 pos_idx = cls._calculate_mask(freqs) 

1078 coeffs_flat = coeffs.reshape(-1, coeffs.shape[-1]) 

1079 coeffs_sub = coeffs_flat[pos_idx] 

1080 

1081 fp = cls._correlate(coeffs_sub.transpose(), method=method) 

1082 abs_fp = jnp.abs(fp) 

1083 diag = jnp.abs(jnp.diagonal(fp)) 

1084 

1085 total_sum = jnp.nansum(abs_fp) 

1086 total_count = jnp.sum(jnp.isfinite(abs_fp)) 

1087 diag_sum = jnp.nansum(diag) 

1088 diag_count = jnp.sum(jnp.isfinite(diag)) 

1089 

1090 lower_sum = (total_sum - diag_sum) / 2.0 

1091 lower_count = (total_count - diag_count) / 2.0 

1092 return lower_sum / lower_count 

1093 

1094 fourier_fingerprint, _ = cls.get_fourier_fingerprint( 

1095 model, 

1096 n_samples, 

1097 random_key, 

1098 method, 

1099 scale, 

1100 weight, 

1101 trim_redundant=trim_redundant, 

1102 **kwargs, 

1103 ) 

1104 

1105 return cls.calculate_fcc(fourier_fingerprint) 

1106 

1107 @classmethod 

1108 def get_fourier_fingerprint( 

1109 cls, 

1110 model: Model, 

1111 n_samples: int, 

1112 random_key: Optional[random.PRNGKey] = None, 

1113 method: Optional[str] = "pearson", 

1114 scale: Optional[bool] = False, 

1115 weight: Optional[bool] = False, 

1116 trim_redundant: Optional[bool] = True, 

1117 nan_to_one: Optional[bool] = False, 

1118 **kwargs: Any, 

1119 ) -> Tuple[jnp.ndarray, jnp.ndarray]: 

1120 """ 

1121 Shortcut method to get just the fourier fingerprint. 

1122 This includes 

1123 1. Calculating the coefficients (using `n_samples`) 

1124 2. Correlating the result from 1) using `method` 

1125 3. Weighting the correlation matrix (if `weight` is True) 

1126 4. Remove redundancies (if `trim_redundant` is True) 

1127 

1128 Args: 

1129 model (Model): The QFM model 

1130 n_samples (int): Number of samples to calculate average of coefficients 

1131 random_key (Optional[random.PRNGKey]): JAX random key for parameter 

1132 initialization. If None, uses the model's internal random key. 

1133 method (Optional[str], optional): Correlation method. Supported values are 

1134 "pearson", "complex_pearson", "spearman", and "covariance". 

1135 Defaults to "pearson". 

1136 scale (Optional[bool], optional): Whether to scale the number of samples. 

1137 Defaults to False. 

1138 weight (Optional[bool], optional): Whether to weight the correlation matrix. 

1139 Defaults to False. 

1140 trim_redundant (Optional[bool], optional): Whether to remove redundant 

1141 correlations. Defaults to True. 

1142 nan_to_one (Optional[bool], optional): Whether to set nan to 1. 

1143 Defaults to False. 

1144 **kwargs: Additional keyword arguments for the model function. 

1145 

1146 Returns: 

1147 Tuple[jnp.ndarray, jnp.ndarray]: The fourier fingerprint and the 

1148 corresponding frequency indices. If `trim_redundant` is True the 

1149 frequencies are returned as a `(row_freqs, col_freqs)` tuple that 

1150 labels the two (redundancy-trimmed) matrix axes; otherwise the 

1151 full frequency vector is returned. 

1152 """ 

1153 _, coeffs, freqs = cls._calculate_coefficients( 

1154 model, n_samples, random_key, scale, **kwargs 

1155 ) 

1156 

1157 # Memory-efficient fast path 

1158 if trim_redundant and not weight: 

1159 pos_idx = cls._calculate_mask(freqs) 

1160 pos_freqs = cls._flat_frequencies(freqs)[pos_idx] 

1161 

1162 # Flatten all frequency axes; the last axis is the sample 

1163 # axis. `_calculate_mask` returns flat indices in C order, 

1164 # matching this reshape. 

1165 coeffs_flat = coeffs.reshape(-1, coeffs.shape[-1]) 

1166 coeffs_sub = coeffs_flat[pos_idx] 

1167 

1168 fourier_fingerprint = cls._correlate(coeffs_sub.transpose(), method=method) 

1169 

1170 if nan_to_one: 

1171 fourier_fingerprint = jnp.where( 

1172 jnp.isnan(fourier_fingerprint), 1.0, fourier_fingerprint 

1173 ) 

1174 

1175 M = fourier_fingerprint.shape[0] 

1176 lower_tri_mask = jnp.tri(M, k=-1, dtype=bool) 

1177 fourier_fingerprint = jnp.where( 

1178 lower_tri_mask, fourier_fingerprint, jnp.nan 

1179 ) 

1180 

1181 row_mask = jnp.any(jnp.isfinite(fourier_fingerprint), axis=1) 

1182 col_mask = jnp.any(jnp.isfinite(fourier_fingerprint), axis=0) 

1183 fourier_fingerprint = fourier_fingerprint[row_mask][:, col_mask] 

1184 

1185 return fourier_fingerprint, (pos_freqs[row_mask], pos_freqs[col_mask]) 

1186 

1187 fourier_fingerprint = cls._correlate(coeffs.transpose(), method=method) 

1188 

1189 if nan_to_one: 

1190 # set nan to 1 

1191 fourier_fingerprint[jnp.isnan(fourier_fingerprint)] = 1.0 

1192 

1193 # perform weighting if requested 

1194 fourier_fingerprint = ( 

1195 cls._weighting_mean(fourier_fingerprint, coeffs) 

1196 if weight 

1197 else fourier_fingerprint 

1198 ) 

1199 

1200 if trim_redundant: 

1201 pos_idx = cls._calculate_mask(freqs) 

1202 pos_freqs = cls._flat_frequencies(freqs)[pos_idx] 

1203 

1204 # restrict to the positive-frequency sub-block (M x M with 

1205 # M = number of non-negative flat-frequencies) instead of 

1206 # building a full N x N mask. This avoids the O(N^2) float 

1207 fourier_fingerprint = fourier_fingerprint[pos_idx][:, pos_idx] 

1208 

1209 # keep only the strict lower triangle; the rest -> nan 

1210 M = fourier_fingerprint.shape[0] 

1211 lower_tri_mask = jnp.tri(M, k=-1, dtype=bool) 

1212 fourier_fingerprint = jnp.where( 

1213 lower_tri_mask, fourier_fingerprint, jnp.nan 

1214 ) 

1215 

1216 row_mask = jnp.any(jnp.isfinite(fourier_fingerprint), axis=1) 

1217 col_mask = jnp.any(jnp.isfinite(fourier_fingerprint), axis=0) 

1218 

1219 fourier_fingerprint = fourier_fingerprint[row_mask][:, col_mask] 

1220 

1221 return fourier_fingerprint, (pos_freqs[row_mask], pos_freqs[col_mask]) 

1222 

1223 return fourier_fingerprint, freqs 

1224 

1225 @classmethod 

1226 def calculate_fcc( 

1227 cls, 

1228 fourier_fingerprint: jnp.ndarray, 

1229 ) -> float: 

1230 """ 

1231 Method to calculate the FCC based on an existing correlation matrix. 

1232 Calculate absolute and then the average over this matrix. 

1233 The Fingerprint can be obtained via `get_fourier_fingerprint` 

1234 

1235 Args: 

1236 fourier_fingerprint (jnp.ndarray): Correlation matrix of coefficients 

1237 Returns: 

1238 float: The FCC 

1239 """ 

1240 # apply the mask on the fingerprint 

1241 return jnp.nanmean(jnp.abs(fourier_fingerprint)) 

1242 

1243 @classmethod 

1244 def _calculate_mask(cls, freqs: jnp.ndarray) -> jnp.ndarray: 

1245 """ 

1246 Determine the flat indices of the Fourier correlation matrix 

1247 that lie on a non-negative-frequency row/column. Together with 

1248 the strict-lower-triangle condition (handled by the caller), 

1249 these indices select the entries of the correlation matrix 

1250 that survive the redundancy filter applied in 

1251 `get_fourier_fingerprint`: 

1252 

1253 - rows/columns whose flat frequency component is negative are 

1254 discarded (they are the complex-conjugate redundancies of 

1255 their positive counterparts); 

1256 - of the remaining positive-frequency sub-block, only the 

1257 strict lower triangle is kept (the upper triangle, including 

1258 the diagonal, contains either duplicates from symmetry or 

1259 self-correlations). 

1260 

1261 Args: 

1262 freqs (jnp.ndarray): Array of frequencies. Either a 1-D 

1263 vector (single input feature) or a 2-D array of shape 

1264 ``(n_input_feat, K)`` whose rows are the per-axis 

1265 frequency vectors. 

1266 

1267 Returns: 

1268 jnp.ndarray: 1-D int array of flat indices selecting the 

1269 non-negative-frequency rows/cols of the fingerprint. 

1270 """ 

1271 freqs_arr = jnp.asarray(freqs) 

1272 

1273 if freqs_arr.ndim == 1: 

1274 pos_flat = freqs_arr >= 0 

1275 else: 

1276 # N-D case: build the per-axis non-negativity masks and 

1277 # combine them via broadcasting (no float `jnp.outer`!), 

1278 # then flatten to match the row-major flattening used by 

1279 # the upstream coefficient/correlation pipeline. 

1280 axes_pos = [freqs_arr[i] >= 0 for i in range(freqs_arr.shape[0])] 

1281 expanded = [] 

1282 n_axes = len(axes_pos) 

1283 for i, p in enumerate(axes_pos): 

1284 shape = [1] * n_axes 

1285 shape[i] = p.shape[0] 

1286 expanded.append(p.reshape(shape)) 

1287 nd_pos = reduce(jnp.logical_and, expanded) 

1288 pos_flat = nd_pos.flatten() 

1289 

1290 return jnp.where(pos_flat)[0] 

1291 

1292 @classmethod 

1293 def _flat_frequencies(cls, freqs: jnp.ndarray) -> jnp.ndarray: 

1294 """ 

1295 Build the per-coefficient flat frequency labels in the same 

1296 C-order used to flatten the coefficient/correlation pipeline, so 

1297 they can be indexed by the flat indices from `_calculate_mask`. 

1298 

1299 Args: 

1300 freqs (jnp.ndarray): Either a 1-D vector (single input feature) 

1301 or a ``(n_input_feat, K)`` stack / list of per-axis frequency 

1302 vectors (multi-dim input). 

1303 

1304 Returns: 

1305 jnp.ndarray: 1-D frequency vector (single input feature) or a 

1306 ``(N, n_input_feat)`` array of per-coefficient frequency 

1307 tuples (multi-dim input). 

1308 """ 

1309 fa = jnp.asarray(freqs) 

1310 if fa.ndim == 1: 

1311 return fa 

1312 # Multi-dim: per-axis vectors -> flat grid of frequency tuples in the 

1313 # same C-order used by `_calculate_mask` and the coefficient reshape. 

1314 grids = jnp.meshgrid(*[fa[i] for i in range(fa.shape[0])], indexing="ij") 

1315 return jnp.stack(grids, axis=-1).reshape(-1, fa.shape[0]) 

1316 

1317 @classmethod 

1318 def _calculate_coefficients( 

1319 cls, 

1320 model: Model, 

1321 n_samples: int, 

1322 random_key: Optional[random.PRNGKey] = None, 

1323 scale: bool = False, 

1324 **kwargs: Any, 

1325 ) -> Tuple[jnp.ndarray, jnp.ndarray]: 

1326 """ 

1327 Calculates the Fourier coefficients of a given model 

1328 using `n_samples`. 

1329 Optionally, `noise_params` can be passed to perform noisy simulation. 

1330 

1331 Args: 

1332 model (Model): The QFM model 

1333 n_samples (int): Number of samples to calculate average of coefficients 

1334 random_key (Optional[random.PRNGKey]): JAX random key for parameter 

1335 initialization. If None, uses the model's internal random key. 

1336 scale (bool, optional): Whether to scale the number of samples. 

1337 Defaults to False. 

1338 **kwargs: Additional keyword arguments for the model function. 

1339 

1340 Returns: 

1341 Tuple[jnp.ndarray, jnp.ndarray]: Parameters and Coefficients of size NxK 

1342 """ 

1343 if n_samples > 0: 

1344 if scale: 

1345 total_samples = int( 

1346 jnp.power(2, model.n_qubits) * n_samples * model.n_input_feat 

1347 ) 

1348 log.info(f"Using {total_samples} samples.") 

1349 else: 

1350 total_samples = n_samples 

1351 model.initialize_params(random_key, repeat=total_samples) 

1352 else: 

1353 total_samples = 1 

1354 

1355 coeffs, freqs = Coefficients.get_spectrum( 

1356 model, shift=True, trim=True, **kwargs 

1357 ) 

1358 

1359 return model.params, coeffs, freqs 

1360 

1361 @classmethod 

1362 def _correlate(cls, mat: jnp.ndarray, method: str = "pearson") -> jnp.ndarray: 

1363 """ 

1364 Correlates two arrays using `method`. 

1365 Currently, `pearson`, `complex_pearson`, `spearman`, and `covariance` 

1366 are supported. 

1367 

1368 Args: 

1369 mat (jnp.ndarray): Array of shape (N, K) 

1370 method (str, optional): Correlation method. Defaults to "pearson". 

1371 

1372 Raises: 

1373 ValueError: If the method is not supported. 

1374 

1375 Returns: 

1376 jnp.ndarray: Correlation matrix of `a` and `b`. 

1377 """ 

1378 assert len(mat.shape) >= 2, "Input matrix must have at least 2 dimensions" 

1379 

1380 # Note that for the general n-D case, we have to flatten along 

1381 # the first axis (last one is batch). 

1382 # Note that the order here is important so we can easily filter out 

1383 # negative coefficients later. 

1384 # Consider the following example: [[1,2,3],[4,5,6],[7,8,9]] 

1385 # we want to get [1, 4, 7, 2, 5, 8, 3, 6, 9] 

1386 # such that after correlation, all positive indexed coefficients 

1387 # will be in the bottom right quadrant 

1388 if method == "pearson": 

1389 result = cls._pearson(mat.reshape(mat.shape[0], -1)) 

1390 # result = cls._pearson(mat.reshape(mat.shape[-1], -1, order="F")) 

1391 elif method == "complex_pearson": 

1392 result = cls._complex_pearson(mat.reshape(mat.shape[0], -1)) 

1393 elif method == "spearman": 

1394 result = cls._spearman(mat.reshape(mat.shape[0], -1)) 

1395 # result = cls._spearman(mat.reshape(mat.shape[-1], -1, order="F")) 

1396 elif method == "covariance": 

1397 result = cls._covariance(mat.reshape(mat.shape[0], -1)) 

1398 else: 

1399 raise ValueError( 

1400 f"Unknown correlation method: {method}. Must be 'pearson', \ 

1401 'complex_pearson', 'spearman' or 'covariance'." 

1402 ) 

1403 

1404 return result 

1405 

1406 @classmethod 

1407 def _covariance(cls, mat: jnp.ndarray, minp: Optional[int] = 1) -> jnp.ndarray: 

1408 """ 

1409 Compute the Hermitian sample covariance between columns of `mat`, 

1410 permitting missing values (NaN or ±Inf). 

1411 

1412 For each pair (i, j) the covariance is computed over the rows that are 

1413 finite in both columns, as 

1414 sum(conj(x_i - mean_i) * (x_j - mean_j)) / (nobs - 1), 

1415 so it computes `X.conj().T @ X`. 

1416 Real input collapses to the ordinary real sample covariance; complex 

1417 input yields a complex matrix whose magnitude and angle carry the 

1418 covariance strength and relative phase. 

1419 

1420 

1421 Args: 

1422 mat : array_like, shape (N, K) 

1423 Input data. 

1424 minp : int, optional 

1425 Minimum number of paired observations required to form a 

1426 covariance. If the number of valid pairs for (i, j) is < minp, 

1427 the result is NaN. 

1428 

1429 Returns: 

1430 cov : ndarray, shape (K, K) 

1431 Sample covariance matrix. 

1432 """ 

1433 mat = jnp.asarray(mat) 

1434 real_dtype = jnp.asarray(mat.real).dtype 

1435 

1436 mask = jnp.isfinite(mat) 

1437 fmask = mask.astype(real_dtype) 

1438 safe = jnp.where(mask, mat, 0.0) 

1439 

1440 nobs = fmask.T @ fmask 

1441 nobs_safe = jnp.where(nobs > 0, nobs, 1.0) 

1442 

1443 sum_x = safe.T @ fmask 

1444 sum_y = fmask.T @ safe 

1445 

1446 masked = safe * fmask 

1447 sum_conj_xy = jnp.conj(masked).T @ masked 

1448 

1449 sxy = sum_conj_xy - (jnp.conj(sum_x) * sum_y) / nobs_safe 

1450 

1451 denom = jnp.where(nobs > 1, nobs - 1, jnp.nan) 

1452 result = sxy / denom 

1453 

1454 result = jnp.where(nobs < minp, jnp.nan, result) 

1455 

1456 return result 

1457 

1458 @classmethod 

1459 def _complex_pearson(cls, mat: jnp.ndarray, minp: Optional[int] = 1) -> jnp.ndarray: 

1460 """ 

1461 Compute the complex Pearson correlation between columns of `mat`, 

1462 permitting missing values (NaN or ±Inf). 

1463 

1464 This uses the Hermitian normalized covariance 

1465 sum(conj(x_i - mean_i) * (x_j - mean_j)) / 

1466 sqrt(sum(abs(x_i - mean_i)**2) * sum(abs(x_j - mean_j)**2)). 

1467 Consequently, if column j is exp(1j * phi) times column i, then 

1468 abs(corr[i, j]) is 1 and angle(corr[i, j]) is phi. 

1469 

1470 Args: 

1471 mat : array_like, shape (N, K) 

1472 Input data. 

1473 minp : int, optional 

1474 Minimum number of paired observations required to form a correlation. 

1475 If the number of valid pairs for (i, j) is < minp, the result is NaN. 

1476 

1477 Returns: 

1478 corr : ndarray, shape (K, K) 

1479 Complex Pearson correlation matrix. 

1480 """ 

1481 mat = jnp.asarray(mat) 

1482 real_dtype = jnp.asarray(mat.real).dtype 

1483 

1484 mask = jnp.isfinite(mat) 

1485 fmask = mask.astype(real_dtype) 

1486 safe = jnp.where(mask, mat, 0.0) 

1487 

1488 nobs = fmask.T @ fmask 

1489 nobs_safe = jnp.where(nobs > 0, nobs, 1.0) 

1490 

1491 sum_x = safe.T @ fmask 

1492 sum_y = fmask.T @ safe 

1493 

1494 masked = safe * fmask 

1495 sum_conj_xy = jnp.conj(masked).T @ masked 

1496 

1497 safe_abs_sq = jnp.abs(safe) ** 2 

1498 sum_abs_x2 = safe_abs_sq.T @ fmask 

1499 sum_abs_y2 = fmask.T @ safe_abs_sq 

1500 

1501 ssx = sum_abs_x2 - jnp.abs(sum_x) ** 2 / nobs_safe 

1502 ssy = sum_abs_y2 - jnp.abs(sum_y) ** 2 / nobs_safe 

1503 sxy = sum_conj_xy - (jnp.conj(sum_x) * sum_y) / nobs_safe 

1504 

1505 denom = jnp.sqrt(ssx * ssy) 

1506 result = jnp.where(denom > 0, sxy / denom, jnp.nan) 

1507 magnitude = jnp.abs(result) 

1508 result = jnp.where(magnitude > 1.0, result / magnitude, result) 

1509 

1510 result = jnp.where(nobs < minp, jnp.nan, result) 

1511 

1512 return result 

1513 

1514 @classmethod 

1515 def _pearson(cls, mat: jnp.ndarray, minp: Optional[int] = 1) -> jnp.ndarray: 

1516 """ 

1517 Compute Pearson correlation between columns of `mat`, 

1518 permitting missing values (NaN or ±Inf). 

1519 

1520 The Pearson correlation is the normalized covariance, 

1521 corr[i, j] = cov[i, j] / sqrt(cov[i, i] * cov[j, j]), 

1522 so it is obtained by normalizing `_covariance` by the per-column 

1523 standard deviations. 

1524 

1525 If the input is complex, real and imaginary parts are stacked along 

1526 the sample axis so that both components contribute to the correlation 

1527 without discarding information. 

1528 

1529 Args: 

1530 mat : array_like, shape (N, K) 

1531 Input data. 

1532 minp : int, optional 

1533 Minimum number of paired observations required to form a correlation. 

1534 If the number of valid pairs for (i, j) is < minp, the result is NaN. 

1535 

1536 Returns: 

1537 corr : ndarray, shape (K, K) 

1538 Pearson correlation matrix. 

1539 """ 

1540 # Preserve complex information by splitting into real / imag samples. 

1541 # After stacking the data is real, so the Hermitian `_covariance` 

1542 # reduces to the ordinary real sample covariance. 

1543 if jnp.iscomplexobj(mat): 

1544 mat = jnp.concatenate([mat.real, mat.imag], axis=0) 

1545 

1546 cov = cls._covariance(mat, minp=minp) 

1547 

1548 # corr[i, j] = cov[i, j] / (std_i * std_j) with std_i = sqrt(cov[i, i]) 

1549 std = jnp.sqrt(jnp.diagonal(cov)) 

1550 denom = std[:, None] * std[None, :] 

1551 result = jnp.where(denom > 0, cov / denom, jnp.nan) 

1552 

1553 # clip numerical drift to [-1, 1] 

1554 result = jnp.clip(jnp.real(result), -1.0, 1.0) 

1555 

1556 return result 

1557 

1558 @classmethod 

1559 def _spearman(cls, mat: jnp.ndarray, minp: Optional[int] = 1) -> jnp.ndarray: 

1560 """ 

1561 Based on Pandas correlation method as implemented here: 

1562 https://github.com/pandas-dev/pandas/blob/main/pandas/_libs/algos.pyx 

1563 

1564 Compute Spearman correlation between columns of `mat`, 

1565 permitting missing values (NaN or ±Inf). 

1566 

1567 If the input is complex, real and imaginary parts are stacked along 

1568 the sample axis so that both components contribute to the correlation 

1569 without discarding information. 

1570 

1571 Args: 

1572 mat : array_like, shape (N, K) 

1573 Input data. 

1574 minp : int, optional 

1575 Minimum number of paired observations required to form a correlation. 

1576 If the number of valid pairs for (i, j) is < minp, the result is NaN. 

1577 

1578 Returns: 

1579 corr : ndarray, shape (K, K) 

1580 Spearman correlation matrix. 

1581 """ 

1582 # Preserve complex information by splitting into real / imag samples 

1583 if jnp.iscomplexobj(mat): 

1584 mat = jnp.concatenate([mat.real, mat.imag], axis=0) 

1585 

1586 mat = jnp.asarray(mat) 

1587 N, K = mat.shape 

1588 

1589 # trivial all-NaN answer if too few rows 

1590 if N < minp: 

1591 return jnp.full((K, K), jnp.nan) 

1592 

1593 # mask of finite entries 

1594 mask = jnp.isfinite(mat) # shape (N, K), dtype=bool 

1595 

1596 # precompute ranks column-wise ignoring NaNs 

1597 ranks = np.full((N, K), np.nan) 

1598 for j in range(K): 

1599 valid = mask[:, j] 

1600 if valid.any(): 

1601 ranks[valid, j] = rankdata(mat[valid, j], method="average") 

1602 

1603 ranks = jnp.asarray(ranks) 

1604 

1605 # Vectorised Pearson on the ranks 

1606 # Replace NaN ranks with 0; use mask to track validity. 

1607 rank_mask = jnp.isfinite(ranks) 

1608 safe_ranks = jnp.where(rank_mask, ranks, 0.0) 

1609 

1610 # Pairwise valid-observation counts (K, K) 

1611 fmask = rank_mask.astype(ranks.dtype) 

1612 nobs = fmask.T @ fmask 

1613 

1614 # Pairwise sums over mutually-valid rows 

1615 sum_x = safe_ranks.T @ fmask # (K, K) 

1616 sum_y = fmask.T @ safe_ranks # (K, K) 

1617 

1618 # Pairwise products 

1619 masked_ranks = safe_ranks * fmask # same as safe_ranks 

1620 sum_xy = masked_ranks.T @ masked_ranks # (K, K) 

1621 

1622 safe_sq = safe_ranks**2 

1623 sum_x2 = safe_sq.T @ fmask # (K, K) 

1624 sum_y2 = fmask.T @ safe_sq # (K, K) 

1625 

1626 nobs_safe = jnp.where(nobs > 0, nobs, 1.0) 

1627 ssx = sum_x2 - sum_x**2 / nobs_safe 

1628 ssy = sum_y2 - sum_y**2 / nobs_safe 

1629 sxy = sum_xy - (sum_x * sum_y) / nobs_safe 

1630 

1631 denom = jnp.sqrt(ssx * ssy) 

1632 result = jnp.where(denom > 0, sxy / denom, jnp.nan) 

1633 result = jnp.clip(result, -1.0, 1.0) 

1634 

1635 # Enforce minp 

1636 result = jnp.where(nobs < minp, jnp.nan, result) 

1637 

1638 return result 

1639 

1640 @classmethod 

1641 def _weighting_linear(cls, fourier_fingerprint: jnp.ndarray) -> jnp.ndarray: 

1642 """ 

1643 Performs weighting on the given correlation matrix. 

1644 Here, low-frequent coefficients are weighted more heavily. 

1645 

1646 Args: 

1647 fourier_fingerprint (jnp.ndarray): Correlation matrix 

1648 """ 

1649 assert ( 

1650 fourier_fingerprint.shape[0] % 2 != 0 

1651 and fourier_fingerprint.shape[1] % 2 != 0 

1652 ), ( 

1653 "Correlation matrix must have odd dimensions. \ 

1654 Hint: use `trim` argument when calling `get_spectrum`." 

1655 ) 

1656 assert fourier_fingerprint.shape[0] == fourier_fingerprint.shape[1], ( 

1657 "Correlation matrix must be square." 

1658 ) 

1659 

1660 # The weight matrix produced by the previous quadrant-mirror 

1661 # construction has a closed form: it is a "tent" sum along the 

1662 # two axes. Concretely, with N = fourier_fingerprint.shape[0] 

1663 # (odd) and center = N // 2, 

1664 # W[i, j] = u[i] + u[j] 

1665 # where u[k] = (center - |k - center|) / (2 * center) 

1666 # is a triangular weighting peaking at the centre (the zero 

1667 # frequency) and decaying linearly to 0 at the spectrum edges. 

1668 N = fourier_fingerprint.shape[0] 

1669 center = N // 2 

1670 k = jnp.arange(N) 

1671 u = (center - jnp.abs(k - center)) / (2 * center) 

1672 

1673 return fourier_fingerprint * (u[:, None] + u[None, :]) 

1674 

1675 @classmethod 

1676 def _weighting_mean( 

1677 cls, fourier_fingerprint: jnp.ndarray, coeffs: jnp.ndarray 

1678 ) -> jnp.ndarray: 

1679 """ 

1680 Performs weighting on the given correlation matrix. 

1681 Here, we use the product of the mean of the coefficients as weights. 

1682 This suppresses correlations where the mean of the coefficients is near zero. 

1683 

1684 Args: 

1685 fourier_fingerprint (jnp.ndarray): Correlation matrix 

1686 coeffs (jnp.ndarray): Fourier coefficients 

1687 """ 

1688 assert fourier_fingerprint.shape[0] == fourier_fingerprint.shape[1], ( 

1689 "Correlation matrix must be square." 

1690 ) 

1691 assert len(coeffs.shape) >= 2, ( 

1692 "Coefficient matrix must contain coefficient axes and a sample axis." 

1693 ) 

1694 

1695 coefficient_means = jnp.abs(jnp.mean(coeffs, axis=-1)) 

1696 coefficient_means = coefficient_means.T.reshape(-1) 

1697 

1698 assert fourier_fingerprint.shape[0] == coefficient_means.shape[0], ( 

1699 "Correlation matrix size must match the number of Fourier coefficients." 

1700 ) 

1701 

1702 # Apply the rank-1 weight w[i] * w[j] via broadcasting instead 

1703 # of materialising an explicit `jnp.outer` N x N intermediate. 

1704 return ( 

1705 fourier_fingerprint 

1706 * coefficient_means[:, None] 

1707 * coefficient_means[None, :] 

1708 ) 

1709 

1710 

1711class Datasets: 

1712 @classmethod 

1713 def generate_fourier_series( 

1714 cls, 

1715 random_key: random.PRNGKey, 

1716 model: Model, 

1717 coefficients_min: float = 0.0, 

1718 coefficients_max: float = 1.0, 

1719 zero_centered: bool = False, 

1720 ) -> jnp.ndarray: 

1721 """ 

1722 Generates the Fourier series representation of a function. 

1723 It uses the `model.frequencies` property to retrieve the frequency 

1724 information. This ensures that the resulting Fourier series is 

1725 compatible with the model. 

1726 

1727 This function is capable of generating $D$-dimensional Fourier series 

1728 (again defined by `model.n_input_feat`). 

1729 The highest frequency $N$ is retrieved per dimension. 

1730 

1731 Samples of the Fourier coefficients are drawn from a uniform circle. 

1732 

1733 Args: 

1734 random_key (random.PRNGKey): Random number key for JAX. 

1735 model (Model): The quantum circuit model. 

1736 coefficients_min (float, optional): Minimum value for the coefficients. 

1737 Defaults to 0.0. 

1738 coefficients_max (float, optional): Maximum value for the coefficients. 

1739 Defaults to 1.0. 

1740 zero_centered (bool, optional): Whether to zero-center the coefficients. 

1741 Defaults to False. 

1742 

1743 Returns: 

1744 jnp.ndarray: Input domain samples with shape ((N,)*D, D) 

1745 jnp.ndarray: Fourier series values with shape ((N,)*D) 

1746 jnp.ndarray: Fourier coefficients with shape ((N,)*D) 

1747 

1748 """ 

1749 # TODO: the following code can be considered to 

1750 # capturing a truly random spectrum. 

1751 # add some constraints on the spectrum, i.e. not fully 

1752 

1753 # Note: one key observation for understanding the following code is, 

1754 # that instead of wrapping your head around symmetries in multi- 

1755 # dimensional coefficient matrices, one can simply look at the flattened 

1756 # version of such a matrix and reshape later. It just works out. 

1757 

1758 # going from [0, 2pi] with the resolution required for highest frequency 

1759 # permute with input dimensionality to get an n-d grid of domain samples 

1760 # the output shape comes from the fact that want to create a "coordinate system" 

1761 domain_samples_per_input_dim = jnp.stack( 

1762 jnp.meshgrid( 

1763 *[jnp.arange(0, 2 * jnp.pi, 2 * jnp.pi / d) for d in model.degree] 

1764 ) 

1765 ).T.reshape(-1, model.n_input_feat) 

1766 

1767 # generate the frequency indices for each dimension. 

1768 # this will have the same shape as the domain samples 

1769 frequencies = jnp.stack(jnp.meshgrid(*model.frequencies)).T.reshape( 

1770 -1, model.n_input_feat 

1771 ) 

1772 

1773 # using the frequency information, sample coefficients for each dimension 

1774 # shape: (input_dims, n_freqs_per_input_dim // 2 + 1) 

1775 

1776 coefficients = cls.uniform_circle( 

1777 random_key, 

1778 low=coefficients_min, 

1779 high=coefficients_max, 

1780 size=math.prod(model.degree) // 2 + 1, 

1781 ) 

1782 

1783 # zero center (first coeff = 0) 

1784 # we can assume the first coeff is the offset, because we're dealing 

1785 # with a non-symmetric spectrum here 

1786 if zero_centered: 

1787 coefficients = coefficients.at[0].set(0.0) 

1788 else: 

1789 coefficients = coefficients.at[0].set(coefficients[0].real) 

1790 

1791 # ensure symmetry (here, non_negative_ is removed!), 

1792 # giving us the full coefficients vector 

1793 coefficients = jnp.concat( 

1794 [ 

1795 jnp.flip(coefficients[..., 1:]).conjugate(), 

1796 coefficients, 

1797 ], 

1798 axis=-1, 

1799 ) 

1800 

1801 # Vectorized version of $f(x) = \sum_{n=0}^{N-1} c_n * e^{i * \omega_n * x}$ 

1802 # it takes into account the input dimension, i.e. the output is a matrix 

1803 # normalization uses the n_freqs component of the coefficients 

1804 values = jnp.real( 

1805 ( 

1806 jnp.exp(1j * (domain_samples_per_input_dim @ frequencies.T)) 

1807 * coefficients 

1808 ).sum(axis=1) 

1809 / coefficients.size 

1810 ) 

1811 

1812 # return all the information we have 

1813 return [ 

1814 domain_samples_per_input_dim.reshape(*model.degree, -1), 

1815 values.reshape(model.degree), 

1816 coefficients.reshape(model.degree), 

1817 ] 

1818 

1819 @classmethod 

1820 def uniform_circle( 

1821 cls, 

1822 random_key: random.PRNGKey, 

1823 size: Union[jnp.ndarray, List, int], 

1824 low=0.0, 

1825 high=1.0, 

1826 ): 

1827 """ 

1828 Random number generator for complex numbers sampled inside the unit circle 

1829 

1830 Args: 

1831 random_key (random.PRNGKey): Random number key for JAX. 

1832 size (Union[jnp.ndarray, int]): Number of samples. If a 2D array is passed, 

1833 the first dimension will be the number of dimensions. 

1834 low (float, optional): Minimum Radius. Defaults to 0.0. 

1835 high (float, optional): Maximum Radius. Defaults to 1.0. 

1836 

1837 Returns 

1838 jnp.ndarray: Array of complex numbers with shape of `size` 

1839 """ 

1840 

1841 if isinstance(size, int): 

1842 size = jnp.array([size]) 

1843 

1844 random_key, random_key1 = random.split(random_key) 

1845 return jnp.sqrt( 

1846 random.uniform(random_key, size, minval=low, maxval=high) 

1847 ) * jnp.exp(2j * jnp.pi * random.uniform(random_key1, size))