Coverage for qml_essentials / coefficients.py: 97%

747 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-09-02 14:50 +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 jaqsi.paulis 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 1. 

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 Note: 

63 The FFT grid is built from the nominal `model.degree`, which does 

64 not account for frequency scaling via `enc_params` or 

65 `enc_pulse_params`. For a model whose effective frequencies exceed 

66 the nominal degree by a factor $s > 1$, choose `mfs` at least 

67 $\\lceil s \\rceil$ (e.g. `mfs=2` covers scalings up to 2), 

68 otherwise the scaled components alias. 

69 """ 

70 kwargs.setdefault("force_mean", True) 

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

72 

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

74 

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

76 raise ValueError( 

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

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

79 ) 

80 

81 if trim: 

82 for ax in range(model.n_input_feat): 

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

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

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

86 

87 if shift: 

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

89 freqs = np.fft.fftshift(freqs) 

90 

91 if numerical_cap > 0: 

92 # set coeffs below threshold to zero 

93 coeffs = jnp.where( 

94 jnp.abs(coeffs) < numerical_cap, 

95 jnp.zeros_like(coeffs), 

96 coeffs, 

97 ) 

98 

99 # Drop frequencies whose coefficients vanish entirely after 

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

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

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

103 if model.n_input_feat == 1: 

104 if coeffs.ndim == 1: 

105 surviving = coeffs != 0 

106 else: 

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

108 coeffs = coeffs[surviving] 

109 freqs = [freqs[0][surviving]] 

110 

111 if len(freqs) == 1: 

112 freqs = freqs[0] 

113 

114 return coeffs, freqs 

115 

116 @classmethod 

117 def _fourier_transform( 

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

119 ) -> jnp.ndarray: 

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

121 # oversampled by mfs 

122 n_freqs: jnp.ndarray = jnp.array( 

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

124 ) 

125 

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

127 # Stretch according to the number of frequencies 

128 inputs: List = [ 

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

130 ] 

131 

132 # permute with input dimensionality 

133 nd_inputs = jnp.array( 

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

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

136 

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

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

139 outputs = outputs.reshape( 

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

141 ).squeeze() 

142 

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

144 

145 freqs = [ 

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

147 for i in range(model.n_input_feat) 

148 ] 

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

150 

151 # TODO: this could cause issues with multidim input 

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

153 # Run the fft and rearrange + 

154 # normalize the output (using product if multidim) 

155 return ( 

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

157 freqs, 

158 ) 

159 

160 @classmethod 

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

162 """ 

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

164 

165 Args: 

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

167 

168 Returns: 

169 jnp.ndarray: The power spectral density. 

170 """ 

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

172 

173 def abs2(x): 

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

175 

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

177 return scale * abs2(coeffs) 

178 

179 @classmethod 

180 def evaluate_Fourier_series( 

181 cls, 

182 coefficients: jnp.ndarray, 

183 frequencies: jnp.ndarray, 

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

185 ) -> float: 

186 """ 

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

188 

189 Args: 

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

191 frequencies (jnp.ndarray): Corresponding frequencies. 

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

193 Returns: 

194 float: The function value at the input point. 

195 """ 

196 coefficients = jnp.asarray(coefficients) 

197 

198 def flatten_grid(freq_axes): 

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

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

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

202 flat_coefficients = coefficients.reshape( 

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

204 ) 

205 return flat_coefficients, flat_frequencies 

206 

207 if isinstance(frequencies, list): 

208 flat_coefficients, flat_frequencies = flatten_grid(frequencies) 

209 else: 

210 frequencies = jnp.asarray(frequencies) 

211 if frequencies.ndim == 1: 

212 flat_frequencies = frequencies[:, jnp.newaxis] 

213 flat_coefficients = coefficients.reshape( 

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

215 ) 

216 else: 

217 n_features, n_axis_freqs = frequencies.shape 

218 is_axis_frequencies = ( 

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

220 ) 

221 

222 if is_axis_frequencies: 

223 flat_coefficients, flat_frequencies = flatten_grid(frequencies) 

224 else: 

225 flat_frequencies = frequencies 

226 flat_coefficients = coefficients.reshape( 

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

228 ) 

229 

230 inputs = jnp.asarray(inputs) 

231 if inputs.ndim == 0: 

232 inputs = inputs.reshape(1, 1) 

233 elif inputs.ndim == 1: 

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

235 inputs = inputs[:, jnp.newaxis] 

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

237 inputs = inputs[jnp.newaxis, :] 

238 else: 

239 inputs = jnp.repeat( 

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

241 ) 

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

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

244 

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

246 

247 

248class FourierTree: 

249 """ 

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

251 

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

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

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

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

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

257 jittable / differentiable with respect to the model parameters. 

258 

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

260 where $d$ is the input dimensionality. 

261 

262 **Usage**: 

263 ``` 

264 model = Model(...) 

265 tree = FourierTree(model) 

266 exp = tree() # expectation value 

267 coeff_list, freq_list = tree.get_spectrum() 

268 ``` 

269 """ 

270 

271 def __init__(self, model: Model): 

272 """ 

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

274 model. 

275 

276 Args: 

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

278 """ 

279 self.model = model 

280 self.n_qubits = model.n_qubits 

281 

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

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

284 

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

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

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

288 base_inputs = np.ones(model.n_input_feat) 

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

290 

291 self.parameters = [ 

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

293 ] 

294 self.n_params = len(self.parameters) 

295 

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

297 self.pauli_words: List[PauliWord] = [ 

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

299 ] 

300 

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

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

303 # generator in any rotation up to index k. 

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

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

306 for pw in self.pauli_words: 

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

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

309 

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

311 self.observable_words: List[PauliWord] = [ 

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

313 ] 

314 

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

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

317 # ``input_indices``, ``all_input_indices``, ``input_scaling``, 

318 # ``var_positions`` and ``features``. 

319 self._detect_inputs(base_inputs) 

320 

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

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

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

324 self._structure_built = False 

325 

326 def _ensure_structure(self) -> None: 

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

328 if not self._structure_built: 

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

330 self._build_leaf_arrays() 

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

332 self._build_spectrum_structure() 

333 self._structure_built = True 

334 

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

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

337 

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

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

340 """ 

341 params = jnp.asarray(params) 

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

343 warnings.warn( 

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

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

346 UserWarning, 

347 ) 

348 params = params[0] 

349 return params 

350 

351 def _build_canonical_tape(self, params, inputs): 

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

353 

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

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

356 """ 

357 params = self._single_param_set(params) 

358 inputs = self.model._inputs_validation(inputs) 

359 raw_tape = self.model.script.record(params=params, inputs=inputs) 

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

361 return PauliCircuit.from_parameterised_circuit( 

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

363 ) 

364 

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

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

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

368 return np.array( 

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

370 ) 

371 

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

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

374 

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

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

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

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

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

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

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

382 encodings such as Golomb). 

383 

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

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

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

387 

388 Raises: 

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

390 (the tree requires single-feature encodings). 

391 """ 

392 tol = 1e-6 

393 d = self.model.n_input_feat 

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

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

396 

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

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

399 for f in range(d): 

400 step = base.copy() 

401 step[f] += 1.0 

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

403 

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

405 all_input_indices: List[int] = [] 

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

407 for k in range(self.n_params): 

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

409 if feats.size == 0: 

410 continue # variational column 

411 if feats.size > 1: 

412 raise NotImplementedError( 

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

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

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

416 ) 

417 f = int(feats[0]) 

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

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

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

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

422 input_indices[f].append(k) 

423 all_input_indices.append(k) 

424 scaling[k] = omega 

425 

426 self.input_indices = input_indices 

427 self.all_input_indices = all_input_indices 

428 self.input_scaling = scaling 

429 input_set = set(all_input_indices) 

430 self.var_positions = np.array( 

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

432 ) 

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

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

435 

436 # Symbolic tree construction (NumPy) 

437 def _build_leaf_arrays(self) -> None: 

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

439 

440 For each root (observable) this produces: 

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

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

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

444 """ 

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

446 for obs_word in self.observable_words: 

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

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

449 self._collect_leaves( 

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

451 ) 

452 if leaves: 

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

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

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

456 else: 

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

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

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

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

461 

462 def _collect_leaves( 

463 self, 

464 observable: PauliWord, 

465 pauli_idx: int, 

466 sin_counts: np.ndarray, 

467 cos_counts: np.ndarray, 

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

469 ) -> None: 

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

471 

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

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

474 construction and leaf traversal of the original implementation into a 

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

476 """ 

477 if self._early_stopping_possible(pauli_idx, observable): 

478 return 

479 

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

481 while pauli_idx >= 0: 

482 last = self.pauli_words[pauli_idx] 

483 if not observable.commutes_with(last): 

484 break 

485 pauli_idx -= 1 

486 else: # leaf reached 

487 term = observable.zero_expectation() 

488 if term != 0: 

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

490 return 

491 

492 last = self.pauli_words[pauli_idx] 

493 

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

495 cos_left = cos_counts.copy() 

496 cos_left[pauli_idx] += 1 

497 self._collect_leaves( 

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

499 ) 

500 

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

502 sin_right = sin_counts.copy() 

503 sin_right[pauli_idx] += 1 

504 self._collect_leaves( 

505 last.compose(observable), 

506 pauli_idx - 1, 

507 sin_right, 

508 cos_counts.copy(), 

509 leaves, 

510 ) 

511 

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

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

514 

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

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

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

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

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

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

521 """ 

522 obs_iz = np.logical_not(observable.xy_mask) 

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

524 return not bool(combined) 

525 

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

527 def _build_spectrum_structure(self) -> None: 

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

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

530 """ 

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

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

533 d = len(self.features) 

534 

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

536 n_leaves = S.shape[0] 

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

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

539 ) 

540 for leaf in range(n_leaves): 

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

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

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

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

545 # scalings, so they are expanded individually and convolved 

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

547 # unit scaling). 

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

549 half_exp = 0 

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

551 for k in self.input_indices[feat]: 

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

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

554 if s == 0 and c == 0: 

555 continue 

556 half_exp += s + c 

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

558 col_factors.append( 

559 [ 

560 (axis, o * w_k, wt) 

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

562 ] 

563 ) 

564 half = 0.5**half_exp 

565 

566 if d == 0: 

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

568 continue 

569 

570 if not col_factors: 

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

572 continue 

573 

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

575 omega = [0.0] * d 

576 weight = half 

577 for axis, o, wt in combo: 

578 omega[axis] += o 

579 weight *= wt 

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

581 # are numerically equal share a single frequency key. 

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

583 freq_to_col[key][leaf] += weight 

584 

585 if freq_to_col: 

586 omegas = sorted(freq_to_col.keys()) 

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

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

589 # Diagonal-Hamiltonian (Golomb) encodings produce rational 

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

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

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

593 rounded = np.rint(freqs) 

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

595 freqs = rounded.astype(np.int64) 

596 else: 

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

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

599 

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

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

602 freqs = freqs[:, 0] 

603 self.freqs_per_root.append(freqs) 

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

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

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

607 self.weights_per_root.append(W) 

608 

609 @staticmethod 

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

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

612 

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

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

615 """ 

616 terms = [] 

617 for a in range(s + 1): 

618 for b in range(c + 1): 

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

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

621 return terms 

622 

623 # Vectorised numeric evaluation (JAX) 

624 @staticmethod 

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

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

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

628 

629 Args: 

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

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

632 """ 

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

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

635 return sign * mag 

636 

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

638 

639 def _leaf_factors( 

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

641 ) -> jnp.ndarray: 

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

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

644 """ 

645 if FourierTree._I_POW is None: 

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

647 

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

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

650 

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

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

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

654 

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

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

657 i_part = FourierTree._I_POW[S_sub % 4] 

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

659 

660 def __call__( 

661 self, 

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

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

664 **kwargs, 

665 ) -> jnp.ndarray: 

666 """ 

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

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

669 

670 Args: 

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

672 model's parameters. 

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

674 

675 Returns: 

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

677 ``force_mean`` is set). 

678 

679 Raises: 

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

681 noise is requested. 

682 """ 

683 params = ( 

684 self.model._params_validation(params) 

685 if params is not None 

686 else self.model.params 

687 ) 

688 inputs = ( 

689 self.model._inputs_validation(inputs) 

690 if inputs is not None 

691 else self.model._inputs_validation(1.0) 

692 ) 

693 

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

695 raise NotImplementedError( 

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

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

698 ) 

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

700 raise NotImplementedError( 

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

702 ) 

703 

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

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

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

707 self.parameters = [ 

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

709 ] 

710 

711 self._ensure_structure() 

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

713 results = [] 

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

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

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

717 results = jnp.array(results) 

718 

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

720 return jnp.mean(results) 

721 return results 

722 

723 def get_spectrum( 

724 self, force_mean: bool = False 

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

726 """ 

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

728 

729 Args: 

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

731 observables (roots). Defaults to False. 

732 

733 Returns: 

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

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

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

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

738 """ 

739 self._ensure_structure() 

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

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

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

743 S, C, self.var_positions 

744 ) 

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

746 

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

748 

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

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

751 

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

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

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

755 variational parameters :math:`\theta`. 

756 

757 Two methods are available: 

758 

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

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

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

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

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

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

765 monomials with distinct signatures are linearly independent functions 

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

767 Hence 

768 

769 .. math:: 

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

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

772 

773 Since all involved quantities are dyadic rationals times 

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

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

776 exponentially with circuit depth. 

777 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

792 

793 Args: 

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

795 

796 Returns: 

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

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

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

800 """ 

801 if method == "dp": 

802 return self._support_dp() 

803 if method != "tree": 

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

805 

806 self._ensure_structure() 

807 supports = [] 

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

809 self.leaf_arrays, self.weights_per_root, self.freqs_per_root 

810 ): 

811 freqs = np.asarray(freqs) 

812 n_leaves = S.shape[0] 

813 if n_leaves == 0: 

814 supports.append(freqs[:0]) 

815 continue 

816 

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

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

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

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

821 

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

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

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

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

826 

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

828 supports.append(freqs[mask]) 

829 return supports 

830 

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

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

833 

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

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

836 stores the set of achievable per-axis count vectors 

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

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

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

840 limitations. 

841 """ 

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

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

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

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

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

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

848 if self.all_input_indices and np.any( 

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

850 ): 

851 raise NotImplementedError( 

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

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

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

855 ) 

856 

857 n = self.n_qubits 

858 d = len(self.features) 

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

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

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

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

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

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

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

866 shift_c = [0] * d 

867 shift_s = [0] * d 

868 place = 1 

869 for a in range(d): 

870 shift_c[a] = place 

871 place *= ranges[a] 

872 shift_s[a] = place 

873 place *= ranges[a] 

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

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

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

877 for a in range(d): 

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

879 axis_of_col[k] = a 

880 

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

882 x = z = 0 

883 for q in range(n): 

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

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

886 return x, z 

887 

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

889 cum_xy = [] 

890 running = 0 

891 for xp, _ in paulis: 

892 running |= xp 

893 cum_xy.append(running) 

894 

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

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

897 

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

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

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

901 return 0 

902 # Skip trailing rotations that commute with the observable. 

903 while idx >= 0: 

904 xp, zp = paulis[idx] 

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

906 break 

907 idx -= 1 

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

909 return 1 if xo == 0 else 0 

910 key = (idx, xo, zo) 

911 hit = memo.get(key) 

912 if hit is not None: 

913 return hit 

914 xp, zp = paulis[idx] 

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

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

917 a = int(axis_of_col[idx]) 

918 if a >= 0: 

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

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

921 else: 

922 val = cos_child | sin_child 

923 memo[key] = val 

924 return val 

925 

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

927 old_limit = sys.getrecursionlimit() 

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

929 try: 

930 supports = [] 

931 for obs in self.observable_words: 

932 memo: dict = {} 

933 xo, zo = encode(obs) 

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

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

936 finally: 

937 sys.setrecursionlimit(old_limit) 

938 return supports 

939 

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

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

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

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

944 product) and unioned over all bits. 

945 

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

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

948 only the DC term). 

949 """ 

950 tupleset: set = set() 

951 while mask: 

952 bit = mask & -mask 

953 i = bit.bit_length() - 1 

954 rem = i 

955 axis_freqs = [] 

956 for a in range(d): 

957 c_a = rem % ranges[a] 

958 rem //= ranges[a] 

959 s_a = rem % ranges[a] 

960 rem //= ranges[a] 

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

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

963 mask ^= bit 

964 

965 if d >= 2: 

966 if not tupleset: 

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

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

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

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

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

972 

973 @staticmethod 

974 @lru_cache(maxsize=None) 

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

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

977 

978 Computed exactly with integer arithmetic via the polynomial 

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

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

981 contains :math:`\pm 2`. 

982 """ 

983 coeffs = [1] 

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

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

986 for i, a in enumerate(coeffs): 

987 new[i + 1] += a 

988 new[i] -= a 

989 coeffs = new 

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

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

992 for i, a in enumerate(coeffs): 

993 new[i + 1] += a 

994 new[i] += a 

995 coeffs = new 

996 m = s + c 

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

998 

999 def _combine_roots( 

1000 self, 

1001 per_root_coeffs: List[jnp.ndarray], 

1002 per_root_freqs: List[np.ndarray], 

1003 force_mean: bool, 

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

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

1006 if not force_mean: 

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

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

1009 return coefficients, frequencies 

1010 

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

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

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

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

1015 freqs_np = np.asarray(freqs) 

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

1017 key = ( 

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

1019 if freqs_np.ndim == 1 

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

1021 ) 

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

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

1024 keys = sorted(accum.keys()) 

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

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

1027 rounded = np.rint(freq_arr) 

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

1029 freq_arr = rounded.astype(np.int64) 

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

1031 freq_arr = freq_arr[:, 0] 

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

1033 

1034 

1035class FCC: 

1036 @classmethod 

1037 def get_fcc( 

1038 cls, 

1039 model: Model, 

1040 n_samples: int, 

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

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

1043 scale: Optional[bool] = False, 

1044 weight: Optional[bool] = False, 

1045 trim_redundant: Optional[bool] = True, 

1046 **kwargs, 

1047 ) -> float: 

1048 """ 

1049 Shortcut method to get just the FCC. 

1050 This includes 

1051 1. What is done in `get_fourier_fingerprint`: 

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

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

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

1055 4. Remove redundancies 

1056 2. What is done in `calculate_fcc`: 

1057 1. Absolute of the fingerprint 

1058 2. Average 

1059 

1060 Args: 

1061 model (Model): The QFM model 

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

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

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

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

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

1067 Defaults to "pearson". 

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

1069 Defaults to False. 

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

1071 Defaults to False. 

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

1073 correlations. Defaults to False. 

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

1075 

1076 Returns: 

1077 float: The FCC 

1078 """ 

1079 

1080 # Memory-efficient fast path 

1081 if trim_redundant and not weight: 

1082 _, coeffs, freqs = cls._calculate_coefficients( 

1083 model, n_samples, random_key, scale, **kwargs 

1084 ) 

1085 pos_idx = cls._calculate_mask(freqs) 

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

1087 coeffs_sub = coeffs_flat[pos_idx] 

1088 

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

1090 abs_fp = jnp.abs(fp) 

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

1092 

1093 total_sum = jnp.nansum(abs_fp) 

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

1095 diag_sum = jnp.nansum(diag) 

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

1097 

1098 lower_sum = (total_sum - diag_sum) / 2.0 

1099 lower_count = (total_count - diag_count) / 2.0 

1100 return lower_sum / lower_count 

1101 

1102 fourier_fingerprint, _, _ = cls.get_fourier_fingerprint( 

1103 model, 

1104 n_samples, 

1105 random_key, 

1106 method, 

1107 scale, 

1108 weight, 

1109 trim_redundant=trim_redundant, 

1110 **kwargs, 

1111 ) 

1112 

1113 return cls.calculate_fcc(fourier_fingerprint) 

1114 

1115 @classmethod 

1116 def get_fourier_fingerprint( 

1117 cls, 

1118 model: Model, 

1119 n_samples: int, 

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

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

1122 scale: Optional[bool] = False, 

1123 weight: Optional[bool] = False, 

1124 trim_redundant: Optional[bool] = True, 

1125 nan_to_one: Optional[bool] = False, 

1126 **kwargs: Any, 

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

1128 """ 

1129 Shortcut method to get just the fourier fingerprint. 

1130 This includes 

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

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

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

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

1135 

1136 Args: 

1137 model (Model): The QFM model 

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

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

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

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

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

1143 Defaults to "pearson". 

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

1145 Defaults to False. 

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

1147 Defaults to False. 

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

1149 correlations. Defaults to True. 

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

1151 Defaults to False. 

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

1153 

1154 Returns: 

1155 Tuple[jnp.ndarray, jnp.ndarray, jnp.ndarray]: The fourier 

1156 fingerprint, the corresponding frequency indices and the 

1157 corresponding coefficients. If `trim_redundant` is True the 

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

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

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

1161 rows align with those frequencies; otherwise the full frequency 

1162 vector and full coefficient array are returned. 

1163 """ 

1164 _, coeffs, freqs = cls._calculate_coefficients( 

1165 model, n_samples, random_key, scale, **kwargs 

1166 ) 

1167 

1168 # Memory-efficient fast path 

1169 if trim_redundant and not weight: 

1170 pos_idx = cls._calculate_mask(freqs) 

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

1172 

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

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

1175 # matching this reshape. 

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

1177 coeffs_sub = coeffs_flat[pos_idx] 

1178 

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

1180 

1181 if nan_to_one: 

1182 fourier_fingerprint = jnp.where( 

1183 jnp.isnan(fourier_fingerprint), 1.0, fourier_fingerprint 

1184 ) 

1185 

1186 M = fourier_fingerprint.shape[0] 

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

1188 fourier_fingerprint = jnp.where( 

1189 lower_tri_mask, fourier_fingerprint, jnp.nan 

1190 ) 

1191 

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

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

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

1195 

1196 return ( 

1197 fourier_fingerprint, 

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

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

1200 ) 

1201 

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

1203 

1204 if nan_to_one: 

1205 # set nan to 1 

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

1207 

1208 # perform weighting if requested 

1209 fourier_fingerprint = ( 

1210 cls._weighting_mean(fourier_fingerprint, coeffs) 

1211 if weight 

1212 else fourier_fingerprint 

1213 ) 

1214 

1215 if trim_redundant: 

1216 pos_idx = cls._calculate_mask(freqs) 

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

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

1219 

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

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

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

1223 fourier_fingerprint = fourier_fingerprint[pos_idx][:, pos_idx] 

1224 

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

1226 M = fourier_fingerprint.shape[0] 

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

1228 fourier_fingerprint = jnp.where( 

1229 lower_tri_mask, fourier_fingerprint, jnp.nan 

1230 ) 

1231 

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

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

1234 

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

1236 

1237 return ( 

1238 fourier_fingerprint, 

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

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

1241 ) 

1242 

1243 return fourier_fingerprint, freqs, coeffs 

1244 

1245 @classmethod 

1246 def calculate_fcc( 

1247 cls, 

1248 fourier_fingerprint: jnp.ndarray, 

1249 ) -> float: 

1250 """ 

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

1252 Calculate absolute and then the average over this matrix. 

1253 The Fingerprint can be obtained via `get_fourier_fingerprint` 

1254 

1255 Args: 

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

1257 Returns: 

1258 float: The FCC 

1259 """ 

1260 # apply the mask on the fingerprint 

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

1262 

1263 @classmethod 

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

1265 """ 

1266 Determine the flat indices of the Fourier correlation matrix 

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

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

1269 these indices select the entries of the correlation matrix 

1270 that survive the redundancy filter applied in 

1271 `get_fourier_fingerprint`: 

1272 

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

1274 discarded (they are the complex-conjugate redundancies of 

1275 their positive counterparts); 

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

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

1278 the diagonal, contains either duplicates from symmetry or 

1279 self-correlations). 

1280 

1281 Args: 

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

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

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

1285 frequency vectors. 

1286 

1287 Returns: 

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

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

1290 """ 

1291 freqs_arr = jnp.asarray(freqs) 

1292 

1293 if freqs_arr.ndim == 1: 

1294 pos_flat = freqs_arr >= 0 

1295 else: 

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

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

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

1299 # the upstream coefficient/correlation pipeline. 

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

1301 expanded = [] 

1302 n_axes = len(axes_pos) 

1303 for i, p in enumerate(axes_pos): 

1304 shape = [1] * n_axes 

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

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

1307 nd_pos = reduce(jnp.logical_and, expanded) 

1308 pos_flat = nd_pos.flatten() 

1309 

1310 return jnp.where(pos_flat)[0] 

1311 

1312 @classmethod 

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

1314 """ 

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

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

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

1318 

1319 Args: 

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

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

1322 vectors (multi-dim input). 

1323 

1324 Returns: 

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

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

1327 tuples (multi-dim input). 

1328 """ 

1329 fa = jnp.asarray(freqs) 

1330 if fa.ndim == 1: 

1331 return fa 

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

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

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

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

1336 

1337 @classmethod 

1338 def _calculate_coefficients( 

1339 cls, 

1340 model: Model, 

1341 n_samples: int, 

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

1343 scale: bool = False, 

1344 **kwargs: Any, 

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

1346 """ 

1347 Calculates the Fourier coefficients of a given model 

1348 using `n_samples`. 

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

1350 

1351 Args: 

1352 model (Model): The QFM model 

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

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

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

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

1357 Defaults to False. 

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

1359 

1360 Returns: 

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

1362 """ 

1363 if n_samples > 0: 

1364 if scale: 

1365 total_samples = int( 

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

1367 ) 

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

1369 else: 

1370 total_samples = n_samples 

1371 model.initialize_params(random_key, repeat=total_samples) 

1372 else: 

1373 total_samples = 1 

1374 

1375 coeffs, freqs = Coefficients.get_spectrum( 

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

1377 ) 

1378 

1379 return model.params, coeffs, freqs 

1380 

1381 @classmethod 

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

1383 """ 

1384 Correlates two arrays using `method`. 

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

1386 are supported. 

1387 

1388 Args: 

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

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

1391 

1392 Raises: 

1393 ValueError: If the method is not supported. 

1394 

1395 Returns: 

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

1397 """ 

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

1399 

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

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

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

1403 # negative coefficients later. 

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

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

1406 # such that after correlation, all positive indexed coefficients 

1407 # will be in the bottom right quadrant 

1408 if method == "pearson": 

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

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

1411 elif method == "complex_pearson": 

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

1413 elif method == "spearman": 

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

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

1416 elif method == "covariance": 

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

1418 else: 

1419 raise ValueError( 

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

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

1422 ) 

1423 

1424 return result 

1425 

1426 @classmethod 

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

1428 """ 

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

1430 permitting missing values (NaN or ±Inf). 

1431 

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

1433 finite in both columns, as 

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

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

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

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

1438 covariance strength and relative phase. 

1439 

1440 

1441 Args: 

1442 mat : array_like, shape (N, K) 

1443 Input data. 

1444 minp : int, optional 

1445 Minimum number of paired observations required to form a 

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

1447 the result is NaN. 

1448 

1449 Returns: 

1450 cov : ndarray, shape (K, K) 

1451 Sample covariance matrix. 

1452 """ 

1453 mat = jnp.asarray(mat) 

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

1455 

1456 mask = jnp.isfinite(mat) 

1457 fmask = mask.astype(real_dtype) 

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

1459 

1460 nobs = fmask.T @ fmask 

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

1462 

1463 sum_x = safe.T @ fmask 

1464 sum_y = fmask.T @ safe 

1465 

1466 masked = safe * fmask 

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

1468 

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

1470 

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

1472 result = sxy / denom 

1473 

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

1475 

1476 return result 

1477 

1478 @classmethod 

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

1480 """ 

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

1482 permitting missing values (NaN or ±Inf). 

1483 

1484 This uses the Hermitian normalized covariance 

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

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

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

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

1489 

1490 Args: 

1491 mat : array_like, shape (N, K) 

1492 Input data. 

1493 minp : int, optional 

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

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

1496 

1497 Returns: 

1498 corr : ndarray, shape (K, K) 

1499 Complex Pearson correlation matrix. 

1500 """ 

1501 mat = jnp.asarray(mat) 

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

1503 

1504 mask = jnp.isfinite(mat) 

1505 fmask = mask.astype(real_dtype) 

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

1507 

1508 nobs = fmask.T @ fmask 

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

1510 

1511 sum_x = safe.T @ fmask 

1512 sum_y = fmask.T @ safe 

1513 

1514 masked = safe * fmask 

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

1516 

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

1518 sum_abs_x2 = safe_abs_sq.T @ fmask 

1519 sum_abs_y2 = fmask.T @ safe_abs_sq 

1520 

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

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

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

1524 

1525 denom = jnp.sqrt(ssx * ssy) 

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

1527 magnitude = jnp.abs(result) 

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

1529 

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

1531 

1532 return result 

1533 

1534 @classmethod 

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

1536 """ 

1537 Compute Pearson correlation between columns of `mat`, 

1538 permitting missing values (NaN or ±Inf). 

1539 

1540 The Pearson correlation is the normalized covariance, 

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

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

1543 standard deviations. 

1544 

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

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

1547 without discarding information. 

1548 

1549 Args: 

1550 mat : array_like, shape (N, K) 

1551 Input data. 

1552 minp : int, optional 

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

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

1555 

1556 Returns: 

1557 corr : ndarray, shape (K, K) 

1558 Pearson correlation matrix. 

1559 """ 

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

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

1562 # reduces to the ordinary real sample covariance. 

1563 if jnp.iscomplexobj(mat): 

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

1565 

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

1567 

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

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

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

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

1572 

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

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

1575 

1576 return result 

1577 

1578 @classmethod 

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

1580 """ 

1581 Based on Pandas correlation method as implemented here: 

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

1583 

1584 Compute Spearman correlation between columns of `mat`, 

1585 permitting missing values (NaN or ±Inf). 

1586 

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

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

1589 without discarding information. 

1590 

1591 Args: 

1592 mat : array_like, shape (N, K) 

1593 Input data. 

1594 minp : int, optional 

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

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

1597 

1598 Returns: 

1599 corr : ndarray, shape (K, K) 

1600 Spearman correlation matrix. 

1601 """ 

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

1603 if jnp.iscomplexobj(mat): 

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

1605 

1606 mat = jnp.asarray(mat) 

1607 N, K = mat.shape 

1608 

1609 # trivial all-NaN answer if too few rows 

1610 if N < minp: 

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

1612 

1613 # mask of finite entries 

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

1615 

1616 # precompute ranks column-wise ignoring NaNs 

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

1618 for j in range(K): 

1619 valid = mask[:, j] 

1620 if valid.any(): 

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

1622 

1623 ranks = jnp.asarray(ranks) 

1624 

1625 # Vectorised Pearson on the ranks 

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

1627 rank_mask = jnp.isfinite(ranks) 

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

1629 

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

1631 fmask = rank_mask.astype(ranks.dtype) 

1632 nobs = fmask.T @ fmask 

1633 

1634 # Pairwise sums over mutually-valid rows 

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

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

1637 

1638 # Pairwise products 

1639 masked_ranks = safe_ranks * fmask # same as safe_ranks 

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

1641 

1642 safe_sq = safe_ranks**2 

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

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

1645 

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

1647 ssx = sum_x2 - sum_x**2 / nobs_safe 

1648 ssy = sum_y2 - sum_y**2 / nobs_safe 

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

1650 

1651 denom = jnp.sqrt(ssx * ssy) 

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

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

1654 

1655 # Enforce minp 

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

1657 

1658 return result 

1659 

1660 @classmethod 

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

1662 """ 

1663 Performs weighting on the given correlation matrix. 

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

1665 

1666 Args: 

1667 fourier_fingerprint (jnp.ndarray): Correlation matrix 

1668 """ 

1669 assert ( 

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

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

1672 ), ( 

1673 "Correlation matrix must have odd dimensions. \ 

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

1675 ) 

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

1677 "Correlation matrix must be square." 

1678 ) 

1679 

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

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

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

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

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

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

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

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

1688 N = fourier_fingerprint.shape[0] 

1689 center = N // 2 

1690 k = jnp.arange(N) 

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

1692 

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

1694 

1695 @classmethod 

1696 def _weighting_mean( 

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

1698 ) -> jnp.ndarray: 

1699 """ 

1700 Performs weighting on the given correlation matrix. 

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

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

1703 

1704 Args: 

1705 fourier_fingerprint (jnp.ndarray): Correlation matrix 

1706 coeffs (jnp.ndarray): Fourier coefficients 

1707 """ 

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

1709 "Correlation matrix must be square." 

1710 ) 

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

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

1713 ) 

1714 

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

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

1717 

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

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

1720 ) 

1721 

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

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

1724 return ( 

1725 fourier_fingerprint 

1726 * coefficient_means[:, None] 

1727 * coefficient_means[None, :] 

1728 ) 

1729 

1730 

1731class Datasets: 

1732 @classmethod 

1733 def generate_fourier_series( 

1734 cls, 

1735 random_key: random.PRNGKey, 

1736 model: Model, 

1737 coefficients_min: float = 0.0, 

1738 coefficients_max: float = 1.0, 

1739 zero_centered: bool = False, 

1740 ) -> jnp.ndarray: 

1741 """ 

1742 Generates the Fourier series representation of a function. 

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

1744 information. This ensures that the resulting Fourier series is 

1745 compatible with the model. 

1746 

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

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

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

1750 

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

1752 

1753 Args: 

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

1755 model (Model): The quantum circuit model. 

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

1757 Defaults to 0.0. 

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

1759 Defaults to 1.0. 

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

1761 Defaults to False. 

1762 

1763 Returns: 

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

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

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

1767 

1768 """ 

1769 # TODO: the following code can be considered to 

1770 # capturing a truly random spectrum. 

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

1772 

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

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

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

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

1777 domain_samples_per_input_dim = cls.construct_domain_samples(model) 

1778 

1779 frequencies = cls.construct_frequencies(model) 

1780 

1781 coefficients = cls.construct_coefficients( 

1782 random_key, model, coefficients_min, coefficients_max, zero_centered 

1783 ) 

1784 

1785 values = cls.calculate_values( 

1786 domain_samples_per_input_dim, frequencies, coefficients 

1787 ) 

1788 

1789 # return all the information we have 

1790 return [ 

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

1792 values.reshape(model.degree), 

1793 coefficients.reshape(model.degree), 

1794 ] 

1795 

1796 @classmethod 

1797 def construct_domain_samples( 

1798 cls, model: Model, mts: int = 1, mfs: int = 1 

1799 ) -> jnp.ndarray: 

1800 """ 

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

1802 

1803 Going from $[0, 2 \\pi \\, \\mathrm{mts}]$ with the resolution required 

1804 for the highest frequency, permuted with the input dimensionality to get 

1805 an n-d grid of domain samples (a "coordinate system"). 

1806 

1807 The grid follows the same convention as 

1808 `Coefficients._fourier_transform`, so a dataset built here lands on the 

1809 bins that `Coefficients.get_spectrum` analyses with the same `mts` and 

1810 `mfs`. A target component at $k + j/r$ has period $2 \\pi r$, hence 

1811 `mts` should be at least $r$ to cover a full period. 

1812 

1813 Args: 

1814 model (Model): The quantum circuit model. 

1815 mts (int, optional): Domain oversampling, i.e. the number of 

1816 periods covered. Defaults to 1. 

1817 mfs (int, optional): Frequency oversampling, i.e. the sample 

1818 density per period. Defaults to 1. 

1819 

1820 Returns: 

1821 jnp.ndarray: Domain samples with shape 

1822 (mts $\\cdot$ mfs $\\cdot$ $\\prod$ degree, n_input_feat). 

1823 """ 

1824 return jnp.stack( 

1825 jnp.meshgrid( 

1826 *[ 

1827 jnp.arange(0, 2 * mts * jnp.pi, 2 * jnp.pi / (mfs * d)) 

1828 for d in model.degree 

1829 ] 

1830 ) 

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

1832 

1833 @classmethod 

1834 def construct_frequencies( 

1835 cls, 

1836 model: Model, 

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

1838 offgrid_mode: str = "none", 

1839 offgrid_prob: float = 0.0, 

1840 offgrid_resolution: int = 2, 

1841 ) -> jnp.ndarray: 

1842 """ 

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

1844 

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

1846 `construct_domain_samples`. 

1847 

1848 By default the grid is the model's own comb, so the dataset is exactly 

1849 representable. The off-grid modes move a controllable fraction of the 

1850 components off that comb. 

1851 Offsets are always multiples of $1/r$ for the given resolution $r$. 

1852 

1853 Args: 

1854 model (Model): The quantum circuit model. 

1855 random_key (Optional[random.PRNGKey]): Random number key for JAX. 

1856 Required unless `offgrid_mode` is "none". 

1857 offgrid_mode (str, optional): How to displace components off the 

1858 model comb. "none" keeps the model comb. "index" perturbs each 

1859 frequency independently, which spans arbitrary combs that are 

1860 in general not exactly reachable. "generator" perturbs the 

1861 per-gate generator frequencies and rebuilds the comb as their 

1862 Minkowski sum, which stays exactly reachable by an encoding 

1863 pulse configuration. Defaults to "none". 

1864 offgrid_prob (float, optional): Probability that a single component 

1865 ("index") or generator ("generator") is displaced. Defaults to 

1866 0.0, which reproduces the model comb in every mode. Note that 

1867 this is the fraction of components that end up off the comb 

1868 only in "index" mode: a sum of displaced generators can land 

1869 back on an integer, so "generator" mode displaces noticeably 

1870 fewer components than asked for and saturates well below one. 

1871 offgrid_resolution (int, optional): Denominator $r$ of the offset 

1872 grid, i.e. offsets are drawn from $\\{\\pm j/r\\}$ with 

1873 $j = 1 \\dots r-1$. Defaults to 2, giving half-integer offsets. 

1874 

1875 Returns: 

1876 jnp.ndarray: Frequency indices with shape 

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

1878 """ 

1879 if offgrid_mode == "none": 

1880 frequencies = model.frequencies 

1881 else: 

1882 if random_key is None: 

1883 raise ValueError(f"offgrid_mode={offgrid_mode!r} requires a random_key") 

1884 if offgrid_resolution < 2: 

1885 raise ValueError( 

1886 f"offgrid_resolution must be at least 2, " 

1887 f"got {offgrid_resolution}. There is no non-integer offset " 

1888 "on a grid of resolution 1." 

1889 ) 

1890 if offgrid_mode == "index": 

1891 displace = cls._displace_indices 

1892 elif offgrid_mode == "generator": 

1893 displace = cls._displace_generators 

1894 else: 

1895 raise ValueError( 

1896 f"Unknown offgrid_mode: {offgrid_mode!r}. Use one of " 

1897 "'none', 'index', 'generator'." 

1898 ) 

1899 

1900 frequencies = [] 

1901 for i in range(model.n_input_feat): 

1902 random_key, sub_key = random.split(random_key) 

1903 frequencies.append( 

1904 displace(model, i, sub_key, offgrid_prob, offgrid_resolution) 

1905 ) 

1906 

1907 return jnp.stack(jnp.meshgrid(*frequencies)).T.reshape(-1, model.n_input_feat) 

1908 

1909 @classmethod 

1910 def _offsets( 

1911 cls, 

1912 random_key: random.PRNGKey, 

1913 shape: Tuple[int, ...], 

1914 prob: float, 

1915 resolution: int, 

1916 ) -> jnp.ndarray: 

1917 """ 

1918 Draws signed offsets on the $1/r$ grid, zero where not displaced. 

1919 

1920 Args: 

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

1922 shape (Tuple[int, ...]): Shape of the offset array. 

1923 prob (float): Probability that an entry is displaced. 

1924 resolution (int): Denominator $r$ of the offset grid. 

1925 

1926 Returns: 

1927 jnp.ndarray: Offsets drawn from $\\{0\\} \\cup \\{\\pm j/r\\}$ with 

1928 $j = 1 \\dots r-1$. 

1929 """ 

1930 move_key, magnitude_key, sign_key = random.split(random_key, 3) 

1931 magnitude = random.randint(magnitude_key, shape, 1, resolution) / resolution 

1932 return ( 

1933 random.bernoulli(move_key, prob, shape) 

1934 * random.rademacher(sign_key, shape) 

1935 * magnitude 

1936 ) 

1937 

1938 @classmethod 

1939 def _displace_indices( 

1940 cls, 

1941 model: Model, 

1942 feature: int, 

1943 random_key: random.PRNGKey, 

1944 prob: float, 

1945 resolution: int, 

1946 ) -> jnp.ndarray: 

1947 """ 

1948 Displaces individual frequencies of one input feature off the comb. 

1949 

1950 Each positive frequency is displaced independently, the result is 

1951 re-sorted and mirrored so that the comb stays antisymmetric. This is 

1952 what `construct_coefficients` relies on to enforce conjugate symmetry, 

1953 and in turn what keeps the series real-valued. The comb never leaves 

1954 the model's frequency range: an offset that would push a component past 

1955 the highest frequency has its sign flipped rather than being clipped, 

1956 which would put the component back on the comb. 

1957 

1958 Args: 

1959 model (Model): The quantum circuit model. 

1960 feature (int): Index of the input feature. 

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

1962 prob (float): Probability that a component is displaced. 

1963 resolution (int): Denominator $r$ of the offset grid. 

1964 

1965 Returns: 

1966 jnp.ndarray: Displaced comb, same size as the model comb. 

1967 """ 

1968 nominal = jnp.asarray(model.frequencies[feature]) 

1969 positive = nominal[nominal > 0] 

1970 limit = positive[-1] 

1971 

1972 offsets = cls._offsets(random_key, positive.shape, prob, resolution) 

1973 # the smallest positive frequency is 1 and offsets are below 1, so only 

1974 # the upper end of the range can be overshot 

1975 offsets = jnp.where(positive + offsets > limit, -offsets, offsets) 

1976 

1977 # ponytail: two components can collide (1 + 0.5 and 2 - 0.5), in which 

1978 # case their coefficients simply add. Deduplicating would change the 

1979 # number of components, which is the one thing the study holds fixed. 

1980 positive = jnp.sort(positive + offsets) 

1981 

1982 return jnp.concatenate([-jnp.flip(positive), jnp.zeros(1), positive]) 

1983 

1984 @classmethod 

1985 def _displace_generators( 

1986 cls, 

1987 model: Model, 

1988 feature: int, 

1989 random_key: random.PRNGKey, 

1990 prob: float, 

1991 resolution: int, 

1992 ) -> jnp.ndarray: 

1993 """ 

1994 Displaces the generator frequencies of one input feature off the comb. 

1995 

1996 Mirrors `Encoding.get_spectrum`, but scales each encoding gate's 

1997 generator by a displaced $\\eta$ before taking the Minkowski sum, which 

1998 is exactly what an encoding pulse scaler does to the gate it drives. 

1999 The reachable comb then grows past the model degree in both count and 

2000 range, so each model frequency claims the closest reachable one that is 

2001 still inside the model's range. Staying in range matters: a component 

2002 beyond the highest model frequency would be unreachable. 

2003 Two model frequencies may end up claiming the same 

2004 reachable one, as in `_displace_indices`. 

2005 

2006 Args: 

2007 model (Model): The quantum circuit model. 

2008 feature (int): Index of the input feature. 

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

2010 prob (float): Probability that a generator is displaced. 

2011 resolution (int): Denominator $r$ of the offset grid. 

2012 

2013 Returns: 

2014 jnp.ndarray: Displaced comb, same size as the model comb. 

2015 """ 

2016 # the offset draw and in-range flip live in _generator_etas, so the 

2017 # scalers exposed by generator_etas cannot desync from this comb 

2018 eta = cls._generator_etas(model, feature, random_key, prob, resolution) 

2019 

2020 base = {"hamming": 1, "binary": 2, "ternary": 3}[model._enc._strategy] 

2021 nominal = np.asarray(model.frequencies[feature]) 

2022 limit = nominal.max() 

2023 mask = np.asarray(model.data_reupload[..., feature], dtype=bool) 

2024 scale = base ** np.arange(mask.shape[1]) 

2025 

2026 # Minkowski sum over the displaced per-gate generators. Rounded before 

2027 # deduplication, which is exact for a power-of-two resolution. 

2028 reach = {0.0} 

2029 for layer, qubit in zip(*np.nonzero(mask)): 

2030 generator = scale[qubit] * eta[layer, qubit] 

2031 reach = { 

2032 round(a + s * generator, 9) for a in reach for s in (-1.0, 0.0, 1.0) 

2033 } 

2034 reachable = sorted(v for v in reach if 0 < v <= limit) 

2035 

2036 # claim the closest reachable frequency, so the displaced comb tracks 

2037 # the original one 

2038 positive = jnp.sort( 

2039 jnp.asarray( 

2040 [ 

2041 min(reachable, key=lambda v: abs(v - frequency)) 

2042 for frequency in nominal[nominal > 0] 

2043 ], 

2044 dtype=float, 

2045 ) 

2046 ) 

2047 

2048 return jnp.concatenate([-jnp.flip(positive), jnp.zeros(1), positive]) 

2049 

2050 @classmethod 

2051 def _generator_etas( 

2052 cls, 

2053 model: Model, 

2054 feature: int, 

2055 random_key: random.PRNGKey, 

2056 prob: float, 

2057 resolution: int, 

2058 ) -> np.ndarray: 

2059 """ 

2060 The per-gate scalers $\\eta = 1 + \\text{offset}$ applied to the 

2061 encoding generators of one input feature in `offgrid_mode='generator'`. 

2062 

2063 This is the offset draw and in-range flip shared with 

2064 `_displace_generators`; the returned array has the shape of the 

2065 data-reupload mask ($n_\\text{layers}, n_\\text{qubits}$) and holds 

2066 exactly the encoding pulse amplitude scalers that make the generator 

2067 comb reachable. 

2068 

2069 Args: 

2070 model (Model): The quantum circuit model. 

2071 feature (int): Index of the input feature. 

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

2073 prob (float): Probability that a generator is displaced. 

2074 resolution (int): Denominator $r$ of the offset grid. 

2075 

2076 Returns: 

2077 np.ndarray: Amplitude scalers $\\eta$, shape 

2078 ($n_\\text{layers}, n_\\text{qubits}$). 

2079 """ 

2080 base = {"hamming": 1, "binary": 2, "ternary": 3}.get(model._enc._strategy) 

2081 if base is None: 

2082 raise ValueError( 

2083 f"offgrid_mode='generator' does not support the " 

2084 f"{model._enc._strategy!r} encoding strategy, which has no " 

2085 "per-gate pulse parametrization to displace." 

2086 ) 

2087 

2088 nominal = np.asarray(model.frequencies[feature]) 

2089 limit = nominal.max() 

2090 mask = np.asarray(model.data_reupload[..., feature], dtype=bool) 

2091 scale = base ** np.arange(mask.shape[1]) 

2092 offsets = np.asarray(cls._offsets(random_key, mask.shape, prob, resolution)) 

2093 offsets = np.where(scale * (1.0 + offsets) > limit, -offsets, offsets) 

2094 return 1.0 + offsets 

2095 

2096 @classmethod 

2097 def generator_etas( 

2098 cls, 

2099 model: Model, 

2100 random_key: random.PRNGKey, 

2101 offgrid_prob: float, 

2102 offgrid_resolution: int, 

2103 ) -> List[np.ndarray]: 

2104 """ 

2105 The encoding pulse amplitude scalers `construct_frequencies` applies in 

2106 `offgrid_mode='generator'`, one ($n_\\text{layers}, n_\\text{qubits}$) 

2107 array per input feature. 

2108 

2109 Call with the same `random_key` passed to `construct_frequencies` to 

2110 recover the encoding pulse configuration that makes the off-grid target 

2111 reachable, e.g. to oracle-initialize or score trained scalers against 

2112 it. The per-feature key split mirrors `construct_frequencies`. 

2113 

2114 Args: 

2115 model (Model): The quantum circuit model. 

2116 random_key (random.PRNGKey): The key passed to 

2117 `construct_frequencies`. 

2118 offgrid_prob (float): Probability that a generator is displaced. 

2119 offgrid_resolution (int): Denominator $r$ of the offset grid. 

2120 

2121 Returns: 

2122 List[np.ndarray]: Amplitude scalers $\\eta$ per input feature. 

2123 """ 

2124 etas = [] 

2125 for i in range(model.n_input_feat): 

2126 random_key, sub_key = random.split(random_key) 

2127 etas.append( 

2128 cls._generator_etas(model, i, sub_key, offgrid_prob, offgrid_resolution) 

2129 ) 

2130 return etas 

2131 

2132 @classmethod 

2133 def construct_coefficients( 

2134 cls, 

2135 random_key: random.PRNGKey, 

2136 model: Model, 

2137 coefficients_min: float = 0.0, 

2138 coefficients_max: float = 1.0, 

2139 zero_centered: bool = False, 

2140 ) -> jnp.ndarray: 

2141 """ 

2142 Samples the conjugate-symmetric Fourier coefficient vector. 

2143 

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

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

2146 then the spectrum is mirrored to enforce conjugate symmetry. 

2147 

2148 Args: 

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

2150 model (Model): The quantum circuit model. 

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

2152 coefficients. Defaults to 0.0. 

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

2154 coefficients. Defaults to 1.0. 

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

2156 coefficients. Defaults to False. 

2157 

2158 Returns: 

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

2160 $\\prod$ degree. 

2161 """ 

2162 coefficients = cls.uniform_circle( 

2163 random_key, 

2164 low=coefficients_min, 

2165 high=coefficients_max, 

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

2167 ) 

2168 

2169 # zero center (first coeff = 0) 

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

2171 # with a non-symmetric spectrum here 

2172 if zero_centered: 

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

2174 else: 

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

2176 

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

2178 # giving us the full coefficients vector 

2179 return jnp.concat( 

2180 [ 

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

2182 coefficients, 

2183 ], 

2184 axis=-1, 

2185 ) 

2186 

2187 @classmethod 

2188 def calculate_values( 

2189 cls, 

2190 domain_samples: jnp.ndarray, 

2191 frequencies: jnp.ndarray, 

2192 coefficients: jnp.ndarray, 

2193 ) -> jnp.ndarray: 

2194 """ 

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

2196 

2197 Vectorized version of 

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

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

2200 

2201 Args: 

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

2203 (n_points, n_input_feat). 

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

2205 (n_freqs, n_input_feat). 

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

2207 (n_freqs,). 

2208 

2209 Returns: 

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

2211 (n_points,). 

2212 """ 

2213 return jnp.real( 

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

2215 / coefficients.size 

2216 ) 

2217 

2218 @classmethod 

2219 def uniform_circle( 

2220 cls, 

2221 random_key: random.PRNGKey, 

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

2223 low=0.0, 

2224 high=1.0, 

2225 ): 

2226 """ 

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

2228 

2229 Args: 

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

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

2232 the first dimension will be the number of dimensions. 

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

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

2235 

2236 Returns 

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

2238 """ 

2239 

2240 if isinstance(size, int): 

2241 size = jnp.array([size]) 

2242 

2243 random_key, random_key1 = random.split(random_key) 

2244 return jnp.sqrt( 

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

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