Optimiser la préparation de déterminants de Slater
Les concepts de ce guide ne sont actuellement disponibles que dans l'API Python. Des fonctionnalités équivalentes seront disponibles dans l'API C dans une prochaine version.
Une porte InitializeModes déclare une occupation de modes de référence ; une porte OrbitalRotation fait tourner la base à une particule. Placées l'une derrière l'autre, les deux portes préparent un déterminant de Slater ; les modes partent du déterminant de référence puis sont tournés vers les orbitales cibles. La porte PrepareSlaterDeterminant capture cette composition et, comme elle porte l'occupation de référence, sa transpilation peut utiliser la givens_decomposition_slater() rectangulaire, qui ne réalise que les orbitales occupées, au lieu de la décomposition carrée complète qu'exige une OrbitalRotation seule. C'est une synthèse strictement moins coûteuse (voir le gain à la fin de ce guide).
La passe de transpiler MergeSlaterDeterminantPreparation détecte le motif InitializeModes puis OrbitalRotation dans un FermionicCircuit et le réécrit en portes PrepareSlaterDeterminant, de sorte qu'une étape de synthèse ultérieure reprend automatiquement la décomposition réduite. En simulation, la réécriture n'a aucun effet sur l'état : PrepareSlaterDeterminant est valider puis tourner (elle valide l'occupation de référence puis applique la rotation, exactement comme les deux portes séparées), si bien que la fusion débloque la synthèse moins coûteuse sans modifier l'état préparé.
Tout au long de ce guide, chaque circuit est dessiné avant et après la passe, côte à côte : l'entrée à gauche, et le résultat de la passe appliquée à celle-ci à droite. Deux fonctions auxiliaires s'en chargent. merge exécute la passe sur une copie du circuit (via son FermionicDAGCircuit) et reconvertit le résultat en un FermionicCircuit dessinable, et draw_merge dessine les deux moitiés dans une seule figure avant/après :
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>>
>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import InitializeModes, OrbitalRotation
>>> from qiskit_fermions.transpiler import FermionicCircuitToDAG
>>> from qiskit_fermions.transpiler.converters import FermionicDAGToCircuit
>>> from qiskit_fermions.transpiler.passes import MergeSlaterDeterminantPreparation
>>>
>>> def merge(circuit):
... dag = FermionicCircuitToDAG().run(circuit)
... merged = MergeSlaterDeterminantPreparation().run(dag)
... return FermionicDAGToCircuit().run(merged)
>>>
>>> def natural_size(circuit):
... # the figure size Qiskit picks for a circuit on its own (discard the probe figure)
... probe = circuit.draw("mpl")
... size = probe.get_size_inches()
... plt.close(probe)
... return size
>>>
>>> def draw_merge(circuit):
... merged = merge(circuit)
... # let Qiskit size each half naturally, then lay them out side by side
... (bw, bh), (aw, ah) = natural_size(circuit), natural_size(merged)
... fig, (before, after) = plt.subplots(1, 2, figsize=(bw + aw, max(bh, ah)))
... circuit.draw("mpl", ax=before)
... merged.draw("mpl", ax=after)
... before.set_title("before")
... after.set_title("after")
... return fig
Ce qui est fusionné
La passe reconnaît trois motifs, tous basés sur la convention de modes par blocs de spin qu'utilisent InitializeModes et OrbitalRotation. Pour un système à norb orbitales spatiales, les modes 0..norb forment le secteur de spin alpha, et norb..2*norb le secteur de spin bêta. La préparation d'un déterminant de Slater se fait par secteur de spin, car chaque secteur a sa propre forme (électrons, orbitales).
Registre complet (ou sans spin)
Le motif le plus simple consiste à utiliser InitializeModes, immédiatement suivi d'une OrbitalRotation sur les mêmes modes. C'est le cas sans spin (un déterminant sur tous les modes), mais aussi celui d'un circuit avec spin dont la rotation couvre les deux secteurs à la fois. Les deux portes fusionnent en une seule PrepareSlaterDeterminant :
>>> circuit = FermionicCircuit(4)
>>> circuit.append(InitializeModes([1, 1, 0, 0]), circuit.modes)
>>> circuit.append(OrbitalRotation(np.eye(4)), circuit.modes)
>>> draw_merge(circuit)
<Figure size ... with 2 Axes>
Par secteur de spin
C'est la même forme, mais restreinte à une moitié de spin. Ici, deux paires indépendantes initialisation-puis-rotation, une par secteur, fusionnent chacune de leur côté en une PrepareSlaterDeterminant :
>>> circuit = FermionicCircuit(6) # norb = 3: alpha modes 0..3, beta modes 3..6
>>> circuit.append(InitializeModes([1, 1, 0]), circuit.modes[:3])
>>> circuit.append(OrbitalRotation(np.eye(3)), circuit.modes[:3])
>>> circuit.append(InitializeModes([1, 0, 0]), circuit.modes[3:])
>>> circuit.append(OrbitalRotation(np.eye(3)), circuit.modes[3:])
>>> draw_merge(circuit)
<Figure size ... with 2 Axes>
Initialisation globale, rotations par spin
Cela suppose une seule InitializeModes sur le registre complet (2 * norb) suivie de deux actions OrbitalRotation, une sur chaque moitié de spin contiguë. La passe scinde l'occupation globale à la frontière des secteurs et émet deux portes PrepareSlaterDeterminant. Les deux rotations peuvent apparaître dans n'importe quel ordre.
C'est la forme produite par le flux de travail LUCJ (local unitary cluster Jastrow) (voir le guide LUCJ) ; l'utilisateur place une InitializeModes (typiquement la référence Hartree-Fock obtenue avec from_hartree_fock()) au début du circuit et ajoute un ansatz UCJ dont les deux premières rotations d'orbitales par spin suivent directement l'initialisation une fois l'ansatz décomposé.
>>> circuit = FermionicCircuit(6) # norb = 3, nelec = (2, 1)
>>> circuit.append(InitializeModes.from_hartree_fock(3, (2, 1)), circuit.modes)
>>> circuit.append(OrbitalRotation(np.eye(3)), circuit.modes[:3])
>>> circuit.append(OrbitalRotation(np.eye(3)), circuit.modes[3:])
>>> draw_merge(circuit)
<Figure size ... with 2 Axes>
Ce motif se déclenche aussi lorsque seule une moitié de spin est tournée par une porte suivant directement l'initialisation, forme produite quand la rotation d'orbitales d'un seul secteur de spin se trouve au début du circuit. La moitié tournée devient une PrepareSlaterDeterminant avec sa rotation, et l'autre moitié est préparée avec une rotation identité. La préparation identité se synthétise en les portes X de référence que l'InitializeModes aurait de toute façon émises pour cette moitié ; la compléter ne coûte donc aucune porte supplémentaire tout en débloquant la synthèse de Slater réduite sur la moitié tournée :
>>> circuit = FermionicCircuit(6)
>>> circuit.append(InitializeModes.from_hartree_fock(3, (2, 1)), circuit.modes)
>>> circuit.append(OrbitalRotation(np.eye(3)), circuit.modes[:3])
>>> draw_merge(circuit)
<Figure size ... with 2 Axes>
Ce qui reste intact
La passe est volontairement prudente. Elle ne fusionne que lorsque les deux portes se composent réellement en une préparation de déterminant de Slater, et recopie tout le reste sans changement. « Immédiatement suivi » s'entend sur le graphe de flux de données du circuit. L'OrbitalRotation ne fusionne que si l'InitializeModes est son seul prédécesseur sur tous ses modes. Dans les figures ci-dessous, l'avant et l'après sont identiques : rien n'a fusionné.
Une opération intercalée
Si une porte touche les modes entre l'initialisation et la rotation, elles ne se composent plus en une préparation pure, et rien n'est donc fusionné :
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.operators import FermionOperator, ann, cre
>>>
>>> circuit = FermionicCircuit(4)
>>> circuit.append(InitializeModes([1, 1, 0, 0]), circuit.modes)
>>> number_op = FermionOperator.from_dict({(cre(0), ann(0)): 1.0})
>>> circuit.append(Evolution(4, number_op, time=0.5), circuit.modes)
>>> circuit.append(OrbitalRotation(np.eye(4)), circuit.modes)
>>> draw_merge(circuit)
<Figure size ... with 2 Axes>
Une rotation partielle qui n'est pas une moitié de spin
Une rotation couvrant exactement une moitié de spin contiguë d'une initialisation sur le registre complet fusionne bel et bien (voir la section précédente). Mais une rotation sur tout autre ensemble partiel de modes (moins de modes que l'initialisation et qui n'est pas l'une de ses deux moitiés de spin, par exemple une sous-plage à cheval sur la frontière des secteurs) n'est pas une préparation par secteur. La fusionner ferait disparaître silencieusement le rôle de l'initialisation sur les modes qu'elle laisse de côté ; la passe laisse donc les deux portes en place :
>>> circuit = FermionicCircuit(4) # norb = 2: the only spin halves are modes [0, 1] and [2, 3]
>>> circuit.append(InitializeModes([1, 1, 0, 0]), circuit.modes)
>>> circuit.append(OrbitalRotation(np.eye(3)), circuit.modes[:3])
>>> draw_merge(circuit)
<Figure size ... with 2 Axes>
Pourquoi c'est important : le gain de synthèse
La fusion est utile car PrepareSlaterDeterminant se synthétise en beaucoup moins de portes que l'OrbitalRotation qu'elle absorbe. Un déterminant à électrons sur orbitales nécessite au plus rotations à deux qubits et aucune porte de phase diagonale, contre rotations plus phases pour la rotation d'orbitales carrée complète.
Le pass manager prédéfini de Jordan-Wigner exécute MergeSlaterDeterminantPreparation dans son étape d'optimisation ; cette réduction se produit donc automatiquement. Transpile une préparation à deux électrons et six orbitales jusqu'à un QuantumCircuit et compte ses portes :
Le preset exécute MergeOrbitalRotations juste avant MergeSlaterDeterminantPreparation. Cela importe lorsqu'une suite de portes OrbitalRotation consécutives suit l'initialisation. La passe antérieure commence par réduire la suite à une seule rotation, que MergeSlaterDeterminantPreparation contracte ensuite avec l'initialisation en une seule PrepareSlaterDeterminant. Sans cet ordre, seule la première rotation de la suite serait absorbée et les autres se synthétiseraient avec la décomposition carrée complète.
>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> # a reproducible random 6 x 6 orbital rotation
>>> rng = np.random.default_rng(1)
>>> z = rng.standard_normal((6, 6)) + 1j * rng.standard_normal((6, 6))
>>> q, r = np.linalg.qr(z)
>>> rotation = q * (np.diagonal(r) / np.abs(np.diagonal(r)))
>>>
>>> circuit = FermionicCircuit(6)
>>> circuit.append(InitializeModes([1, 1, 0, 0, 0, 0]), circuit.modes)
>>> circuit.append(OrbitalRotation(rotation), circuit.modes)
>>>
>>> pm = generate_preset_jw_pass_manager()
>>> prepared = pm.run(circuit)
>>> print(dict(sorted(prepared.count_ops().items())))
{'x': 2, 'xx_plus_yy': 8}
>>> prepared.draw("mpl", fold=-1)
<Figure size ... with 1 Axes>
Compare avec la synthèse de la même rotation d'orbitales seule. Sans initialisation préalable, il n'y a rien à fusionner ; le preset retombe donc sur la décomposition carrée complète, plus profonde et dotée de portes de phase diagonales :
>>> reference = FermionicCircuit(6)
>>> reference.append(OrbitalRotation(rotation), reference.modes)
>>> reference_prepared = pm.run(reference)
>>> print(dict(sorted(reference_prepared.count_ops().items())))
{'rz': 6, 'xx_plus_yy': 15}
>>> reference_prepared.draw("mpl", fold=-1)
<Figure size ... with 1 Axes>

La décomposition réduite supprime les six portes de phase et réduit presque de moitié le nombre de rotations à deux qubits. Le déterminant préparé est correct à une phase globale près (physiquement sans importance pour la préparation d'état), ce que les portes de phase auraient fixé.
Deux façons de décomposer la porte
Une PrepareSlaterDeterminant peut être décomposée de deux façons différentes, qui ne sont pas interchangeables :
decompose()
Développe la porte en sa définition : l'InitializeModes de la référence suivie de l'OrbitalRotation complète. Cela équivaut à la porte en tant qu'opérateur, pour tout état d'entrée.
PrepareSlaterDeterminant.synthesis, appliquée par F2FSynthesis
Remplace la porte par la décomposition réduite de ce guide. Cela n'équivaut à la porte qu'en tant que préparation d'état : elle produit le même déterminant, à une phase globale près, lorsque les modes partent du vide. Elle est moins coûteuse précisément parce qu'elle suppose cela.
Utilise la synthèse lorsque la porte prépare un état à partir du vide, ce pour quoi elle est faite et ce que le transpiler fait pour toi. Utilise decompose() lorsque la porte doit continuer d'agir comme un opérateur, par exemple parce que d'autres portes la précèdent sur les mêmes modes, ou lorsque tu veux inspecter ou simuler les portes simples. La passe F2FSynthesis ne peut pas savoir à quel cas elle a affaire ; c'est donc au circuit que tu lui fournis de préparer le déterminant à partir du vide.
Simuler un circuit avec ffsim n'exige ni l'une ni l'autre : chaque porte applique son unitaire complet, de sorte qu'un circuit contenant une PrepareSlaterDeterminant est simulé tel quel, et reste sur la voie rapide pour les portes OrbitalRotation qui l'entourent. Décomposer uniquement la porte de Slater avec circuit.decompose("PrepareSlaterDeterminant") laisse aussi ces rotations intactes.
PrepareSlaterDeterminant, InitializeModes, OrbitalRotation, MergeOrbitalRotations, F2FSynthesis, FermionicSlaterGivensDecomposition, et le guide LUCJ pour le flux de travail qui produit le motif d'initialisation globale.
