Coverage for qml_essentials / coefficients.py: 97%

684 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-08-21 08:21 +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, 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, jnp.ndarray]: The fourier 

1148 fingerprint, the corresponding frequency indices and the 

1149 corresponding coefficients. If `trim_redundant` is True the 

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

1151 labels the two (redundancy-trimmed) matrix axes and the 

1152 coefficients as a matching `(row_coeffs, col_coeffs)` tuple whose 

1153 rows align with those frequencies; otherwise the full frequency 

1154 vector and full coefficient array are returned. 

1155 """ 

1156 _, coeffs, freqs = cls._calculate_coefficients( 

1157 model, n_samples, random_key, scale, **kwargs 

1158 ) 

1159 

1160 # Memory-efficient fast path 

1161 if trim_redundant and not weight: 

1162 pos_idx = cls._calculate_mask(freqs) 

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

1164 

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

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

1167 # matching this reshape. 

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

1169 coeffs_sub = coeffs_flat[pos_idx] 

1170 

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

1172 

1173 if nan_to_one: 

1174 fourier_fingerprint = jnp.where( 

1175 jnp.isnan(fourier_fingerprint), 1.0, fourier_fingerprint 

1176 ) 

1177 

1178 M = fourier_fingerprint.shape[0] 

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

1180 fourier_fingerprint = jnp.where( 

1181 lower_tri_mask, fourier_fingerprint, jnp.nan 

1182 ) 

1183 

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

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

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

1187 

1188 return ( 

1189 fourier_fingerprint, 

1190 (pos_freqs[row_mask], pos_freqs[col_mask]), 

1191 (coeffs_sub[row_mask], coeffs_sub[col_mask]), 

1192 ) 

1193 

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

1195 

1196 if nan_to_one: 

1197 # set nan to 1 

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

1199 

1200 # perform weighting if requested 

1201 fourier_fingerprint = ( 

1202 cls._weighting_mean(fourier_fingerprint, coeffs) 

1203 if weight 

1204 else fourier_fingerprint 

1205 ) 

1206 

1207 if trim_redundant: 

1208 pos_idx = cls._calculate_mask(freqs) 

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

1210 coeffs_sub = coeffs.reshape(-1, coeffs.shape[-1])[pos_idx] 

1211 

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

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

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

1215 fourier_fingerprint = fourier_fingerprint[pos_idx][:, pos_idx] 

1216 

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

1218 M = fourier_fingerprint.shape[0] 

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

1220 fourier_fingerprint = jnp.where( 

1221 lower_tri_mask, fourier_fingerprint, jnp.nan 

1222 ) 

1223 

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

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

1226 

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

1228 

1229 return ( 

1230 fourier_fingerprint, 

1231 (pos_freqs[row_mask], pos_freqs[col_mask]), 

1232 (coeffs_sub[row_mask], coeffs_sub[col_mask]), 

1233 ) 

1234 

1235 return fourier_fingerprint, freqs, coeffs 

1236 

1237 @classmethod 

1238 def calculate_fcc( 

1239 cls, 

1240 fourier_fingerprint: jnp.ndarray, 

1241 ) -> float: 

1242 """ 

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

1244 Calculate absolute and then the average over this matrix. 

1245 The Fingerprint can be obtained via `get_fourier_fingerprint` 

1246 

1247 Args: 

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

1249 Returns: 

1250 float: The FCC 

1251 """ 

1252 # apply the mask on the fingerprint 

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

1254 

1255 @classmethod 

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

1257 """ 

1258 Determine the flat indices of the Fourier correlation matrix 

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

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

1261 these indices select the entries of the correlation matrix 

1262 that survive the redundancy filter applied in 

1263 `get_fourier_fingerprint`: 

1264 

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

1266 discarded (they are the complex-conjugate redundancies of 

1267 their positive counterparts); 

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

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

1270 the diagonal, contains either duplicates from symmetry or 

1271 self-correlations). 

1272 

1273 Args: 

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

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

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

1277 frequency vectors. 

1278 

1279 Returns: 

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

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

1282 """ 

1283 freqs_arr = jnp.asarray(freqs) 

1284 

1285 if freqs_arr.ndim == 1: 

1286 pos_flat = freqs_arr >= 0 

1287 else: 

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

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

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

1291 # the upstream coefficient/correlation pipeline. 

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

1293 expanded = [] 

1294 n_axes = len(axes_pos) 

1295 for i, p in enumerate(axes_pos): 

1296 shape = [1] * n_axes 

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

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

1299 nd_pos = reduce(jnp.logical_and, expanded) 

1300 pos_flat = nd_pos.flatten() 

1301 

1302 return jnp.where(pos_flat)[0] 

1303 

1304 @classmethod 

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

1306 """ 

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

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

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

1310 

1311 Args: 

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

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

1314 vectors (multi-dim input). 

1315 

1316 Returns: 

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

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

1319 tuples (multi-dim input). 

1320 """ 

1321 fa = jnp.asarray(freqs) 

1322 if fa.ndim == 1: 

1323 return fa 

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

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

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

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

1328 

1329 @classmethod 

1330 def _calculate_coefficients( 

1331 cls, 

1332 model: Model, 

1333 n_samples: int, 

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

1335 scale: bool = False, 

1336 **kwargs: Any, 

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

1338 """ 

1339 Calculates the Fourier coefficients of a given model 

1340 using `n_samples`. 

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

1342 

1343 Args: 

1344 model (Model): The QFM model 

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

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

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

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

1349 Defaults to False. 

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

1351 

1352 Returns: 

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

1354 """ 

1355 if n_samples > 0: 

1356 if scale: 

1357 total_samples = int( 

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

1359 ) 

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

1361 else: 

1362 total_samples = n_samples 

1363 model.initialize_params(random_key, repeat=total_samples) 

1364 else: 

1365 total_samples = 1 

1366 

1367 coeffs, freqs = Coefficients.get_spectrum( 

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

1369 ) 

1370 

1371 return model.params, coeffs, freqs 

1372 

1373 @classmethod 

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

1375 """ 

1376 Correlates two arrays using `method`. 

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

1378 are supported. 

1379 

1380 Args: 

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

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

1383 

1384 Raises: 

1385 ValueError: If the method is not supported. 

1386 

1387 Returns: 

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

1389 """ 

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

1391 

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

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

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

1395 # negative coefficients later. 

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

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

1398 # such that after correlation, all positive indexed coefficients 

1399 # will be in the bottom right quadrant 

1400 if method == "pearson": 

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

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

1403 elif method == "complex_pearson": 

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

1405 elif method == "spearman": 

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

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

1408 elif method == "covariance": 

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

1410 else: 

1411 raise ValueError( 

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

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

1414 ) 

1415 

1416 return result 

1417 

1418 @classmethod 

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

1420 """ 

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

1422 permitting missing values (NaN or ±Inf). 

1423 

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

1425 finite in both columns, as 

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

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

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

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

1430 covariance strength and relative phase. 

1431 

1432 

1433 Args: 

1434 mat : array_like, shape (N, K) 

1435 Input data. 

1436 minp : int, optional 

1437 Minimum number of paired observations required to form a 

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

1439 the result is NaN. 

1440 

1441 Returns: 

1442 cov : ndarray, shape (K, K) 

1443 Sample covariance matrix. 

1444 """ 

1445 mat = jnp.asarray(mat) 

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

1447 

1448 mask = jnp.isfinite(mat) 

1449 fmask = mask.astype(real_dtype) 

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

1451 

1452 nobs = fmask.T @ fmask 

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

1454 

1455 sum_x = safe.T @ fmask 

1456 sum_y = fmask.T @ safe 

1457 

1458 masked = safe * fmask 

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

1460 

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

1462 

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

1464 result = sxy / denom 

1465 

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

1467 

1468 return result 

1469 

1470 @classmethod 

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

1472 """ 

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

1474 permitting missing values (NaN or ±Inf). 

1475 

1476 This uses the Hermitian normalized covariance 

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

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

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

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

1481 

1482 Args: 

1483 mat : array_like, shape (N, K) 

1484 Input data. 

1485 minp : int, optional 

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

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

1488 

1489 Returns: 

1490 corr : ndarray, shape (K, K) 

1491 Complex Pearson correlation matrix. 

1492 """ 

1493 mat = jnp.asarray(mat) 

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

1495 

1496 mask = jnp.isfinite(mat) 

1497 fmask = mask.astype(real_dtype) 

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

1499 

1500 nobs = fmask.T @ fmask 

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

1502 

1503 sum_x = safe.T @ fmask 

1504 sum_y = fmask.T @ safe 

1505 

1506 masked = safe * fmask 

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

1508 

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

1510 sum_abs_x2 = safe_abs_sq.T @ fmask 

1511 sum_abs_y2 = fmask.T @ safe_abs_sq 

1512 

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

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

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

1516 

1517 denom = jnp.sqrt(ssx * ssy) 

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

1519 magnitude = jnp.abs(result) 

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

1521 

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

1523 

1524 return result 

1525 

1526 @classmethod 

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

1528 """ 

1529 Compute Pearson correlation between columns of `mat`, 

1530 permitting missing values (NaN or ±Inf). 

1531 

1532 The Pearson correlation is the normalized covariance, 

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

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

1535 standard deviations. 

1536 

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

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

1539 without discarding information. 

1540 

1541 Args: 

1542 mat : array_like, shape (N, K) 

1543 Input data. 

1544 minp : int, optional 

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

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

1547 

1548 Returns: 

1549 corr : ndarray, shape (K, K) 

1550 Pearson correlation matrix. 

1551 """ 

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

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

1554 # reduces to the ordinary real sample covariance. 

1555 if jnp.iscomplexobj(mat): 

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

1557 

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

1559 

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

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

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

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

1564 

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

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

1567 

1568 return result 

1569 

1570 @classmethod 

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

1572 """ 

1573 Based on Pandas correlation method as implemented here: 

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

1575 

1576 Compute Spearman correlation between columns of `mat`, 

1577 permitting missing values (NaN or ±Inf). 

1578 

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

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

1581 without discarding information. 

1582 

1583 Args: 

1584 mat : array_like, shape (N, K) 

1585 Input data. 

1586 minp : int, optional 

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

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

1589 

1590 Returns: 

1591 corr : ndarray, shape (K, K) 

1592 Spearman correlation matrix. 

1593 """ 

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

1595 if jnp.iscomplexobj(mat): 

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

1597 

1598 mat = jnp.asarray(mat) 

1599 N, K = mat.shape 

1600 

1601 # trivial all-NaN answer if too few rows 

1602 if N < minp: 

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

1604 

1605 # mask of finite entries 

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

1607 

1608 # precompute ranks column-wise ignoring NaNs 

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

1610 for j in range(K): 

1611 valid = mask[:, j] 

1612 if valid.any(): 

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

1614 

1615 ranks = jnp.asarray(ranks) 

1616 

1617 # Vectorised Pearson on the ranks 

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

1619 rank_mask = jnp.isfinite(ranks) 

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

1621 

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

1623 fmask = rank_mask.astype(ranks.dtype) 

1624 nobs = fmask.T @ fmask 

1625 

1626 # Pairwise sums over mutually-valid rows 

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

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

1629 

1630 # Pairwise products 

1631 masked_ranks = safe_ranks * fmask # same as safe_ranks 

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

1633 

1634 safe_sq = safe_ranks**2 

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

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

1637 

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

1639 ssx = sum_x2 - sum_x**2 / nobs_safe 

1640 ssy = sum_y2 - sum_y**2 / nobs_safe 

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

1642 

1643 denom = jnp.sqrt(ssx * ssy) 

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

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

1646 

1647 # Enforce minp 

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

1649 

1650 return result 

1651 

1652 @classmethod 

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

1654 """ 

1655 Performs weighting on the given correlation matrix. 

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

1657 

1658 Args: 

1659 fourier_fingerprint (jnp.ndarray): Correlation matrix 

1660 """ 

1661 assert ( 

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

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

1664 ), ( 

1665 "Correlation matrix must have odd dimensions. \ 

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

1667 ) 

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

1669 "Correlation matrix must be square." 

1670 ) 

1671 

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

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

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

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

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

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

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

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

1680 N = fourier_fingerprint.shape[0] 

1681 center = N // 2 

1682 k = jnp.arange(N) 

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

1684 

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

1686 

1687 @classmethod 

1688 def _weighting_mean( 

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

1690 ) -> jnp.ndarray: 

1691 """ 

1692 Performs weighting on the given correlation matrix. 

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

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

1695 

1696 Args: 

1697 fourier_fingerprint (jnp.ndarray): Correlation matrix 

1698 coeffs (jnp.ndarray): Fourier coefficients 

1699 """ 

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

1701 "Correlation matrix must be square." 

1702 ) 

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

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

1705 ) 

1706 

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

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

1709 

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

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

1712 ) 

1713 

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

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

1716 return ( 

1717 fourier_fingerprint 

1718 * coefficient_means[:, None] 

1719 * coefficient_means[None, :] 

1720 ) 

1721 

1722 

1723class Datasets: 

1724 @classmethod 

1725 def generate_fourier_series( 

1726 cls, 

1727 random_key: random.PRNGKey, 

1728 model: Model, 

1729 coefficients_min: float = 0.0, 

1730 coefficients_max: float = 1.0, 

1731 zero_centered: bool = False, 

1732 ) -> jnp.ndarray: 

1733 """ 

1734 Generates the Fourier series representation of a function. 

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

1736 information. This ensures that the resulting Fourier series is 

1737 compatible with the model. 

1738 

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

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

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

1742 

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

1744 

1745 Args: 

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

1747 model (Model): The quantum circuit model. 

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

1749 Defaults to 0.0. 

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

1751 Defaults to 1.0. 

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

1753 Defaults to False. 

1754 

1755 Returns: 

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

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

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

1759 

1760 """ 

1761 # TODO: the following code can be considered to 

1762 # capturing a truly random spectrum. 

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

1764 

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

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

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

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

1769 domain_samples_per_input_dim = cls.construct_domain_samples(model) 

1770 

1771 frequencies = cls.construct_frequencies(model) 

1772 

1773 coefficients = cls.construct_coefficients( 

1774 random_key, model, coefficients_min, coefficients_max, zero_centered 

1775 ) 

1776 

1777 values = cls.calculate_values( 

1778 domain_samples_per_input_dim, frequencies, coefficients 

1779 ) 

1780 

1781 # return all the information we have 

1782 return [ 

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

1784 values.reshape(model.degree), 

1785 coefficients.reshape(model.degree), 

1786 ] 

1787 

1788 @classmethod 

1789 def construct_domain_samples(cls, model: Model) -> jnp.ndarray: 

1790 """ 

1791 Builds the input-domain sample grid for the model spectrum. 

1792 

1793 Going from $[0, 2\\pi]$ with the resolution required for the highest 

1794 frequency, permuted with the input dimensionality to get an n-d grid 

1795 of domain samples (a "coordinate system"). 

1796 

1797 Args: 

1798 model (Model): The quantum circuit model. 

1799 

1800 Returns: 

1801 jnp.ndarray: Domain samples with shape 

1802 ($\\prod$ degree, n_input_feat). 

1803 """ 

1804 return jnp.stack( 

1805 jnp.meshgrid( 

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

1807 ) 

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

1809 

1810 @classmethod 

1811 def construct_frequencies(cls, model: Model) -> jnp.ndarray: 

1812 """ 

1813 Builds the frequency-index grid for the model spectrum. 

1814 

1815 This has the same shape as the domain samples returned by 

1816 `construct_domain_samples`. 

1817 

1818 Args: 

1819 model (Model): The quantum circuit model. 

1820 

1821 Returns: 

1822 jnp.ndarray: Frequency indices with shape 

1823 ($\\prod$ degree, n_input_feat). 

1824 """ 

1825 return jnp.stack(jnp.meshgrid(*model.frequencies)).T.reshape( 

1826 -1, model.n_input_feat 

1827 ) 

1828 

1829 @classmethod 

1830 def construct_coefficients( 

1831 cls, 

1832 random_key: random.PRNGKey, 

1833 model: Model, 

1834 coefficients_min: float = 0.0, 

1835 coefficients_max: float = 1.0, 

1836 zero_centered: bool = False, 

1837 ) -> jnp.ndarray: 

1838 """ 

1839 Samples the conjugate-symmetric Fourier coefficient vector. 

1840 

1841 Coefficients are drawn from a uniform circle (see `uniform_circle`). 

1842 The offset coefficient (first entry) is either zeroed or made real, 

1843 then the spectrum is mirrored to enforce conjugate symmetry. 

1844 

1845 Args: 

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

1847 model (Model): The quantum circuit model. 

1848 coefficients_min (float, optional): Minimum value for the 

1849 coefficients. Defaults to 0.0. 

1850 coefficients_max (float, optional): Maximum value for the 

1851 coefficients. Defaults to 1.0. 

1852 zero_centered (bool, optional): Whether to zero-center the 

1853 coefficients. Defaults to False. 

1854 

1855 Returns: 

1856 jnp.ndarray: Conjugate-symmetric coefficient vector of size 

1857 $\\prod$ degree. 

1858 """ 

1859 coefficients = cls.uniform_circle( 

1860 random_key, 

1861 low=coefficients_min, 

1862 high=coefficients_max, 

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

1864 ) 

1865 

1866 # zero center (first coeff = 0) 

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

1868 # with a non-symmetric spectrum here 

1869 if zero_centered: 

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

1871 else: 

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

1873 

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

1875 # giving us the full coefficients vector 

1876 return jnp.concat( 

1877 [ 

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

1879 coefficients, 

1880 ], 

1881 axis=-1, 

1882 ) 

1883 

1884 @classmethod 

1885 def calculate_values( 

1886 cls, 

1887 domain_samples: jnp.ndarray, 

1888 frequencies: jnp.ndarray, 

1889 coefficients: jnp.ndarray, 

1890 ) -> jnp.ndarray: 

1891 """ 

1892 Evaluates the real-valued Fourier series on the domain grid. 

1893 

1894 Vectorized version of 

1895 $f(x) = \\sum_{n=0}^{N-1} c_n e^{i \\omega_n x}$ that takes the input 

1896 dimension into account, normalized by the number of coefficients. 

1897 

1898 Args: 

1899 domain_samples (jnp.ndarray): Domain samples with shape 

1900 (n_points, n_input_feat). 

1901 frequencies (jnp.ndarray): Frequency indices with shape 

1902 (n_freqs, n_input_feat). 

1903 coefficients (jnp.ndarray): Fourier coefficients with shape 

1904 (n_freqs,). 

1905 

1906 Returns: 

1907 jnp.ndarray: Real-valued Fourier series samples with shape 

1908 (n_points,). 

1909 """ 

1910 return jnp.real( 

1911 (jnp.exp(1j * (domain_samples @ frequencies.T)) * coefficients).sum(axis=1) 

1912 / coefficients.size 

1913 ) 

1914 

1915 @classmethod 

1916 def uniform_circle( 

1917 cls, 

1918 random_key: random.PRNGKey, 

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

1920 low=0.0, 

1921 high=1.0, 

1922 ): 

1923 """ 

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

1925 

1926 Args: 

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

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

1929 the first dimension will be the number of dimensions. 

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

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

1932 

1933 Returns 

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

1935 """ 

1936 

1937 if isinstance(size, int): 

1938 size = jnp.array([size]) 

1939 

1940 random_key, random_key1 = random.split(random_key) 

1941 return jnp.sqrt( 

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

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