Transpiler un ansatz LUCJ
Les concepts de ce guide ne sont disponibles que dans l'API Python.
L'ansatz de Jastrow à clusters unitaires locaux (LUCJ) est une paramétrisation compacte et efficace pour le matériel d'une fonction d'onde électronique corrélée. C'est un membre de la famille plus générale de Jastrow à clusters unitaires (UCJ) et il prend la forme
où est un état de référence (typiquement Hartree-Fock), chaque est une rotation d'orbitales, et chaque est un opérateur de Coulomb diagonal
avec l'opérateur nombre sur l'orbitale spatiale de spin .
ffsim construit l'opérateur d'ansatz ; ce paquet le transforme en circuit et abaisse ce circuit sur des qubits. Ce guide porte sur la seconde moitié de cette phrase. La raison de faire le détour est l'abaissement lui-même : les portes Qiskit propres à ffsim sont spécifiques à la transformation de Jordan-Wigner, alors qu'un FermionicCircuit ne fait aucune hypothèse sur son encodage fermion-vers-qubit, de sorte que le même ansatz peut être synthétisé avec l'encodage qui convient au dispositif cible.
L'opérateur d'ansatz arrive donc ici tout prêt, et tout ce qui suit décrit ce qui lui arrive ensuite : en quoi la porte se décompose, comment elle s'abaisse via Jordan-Wigner, comment elle est adaptée à la carte de couplage d'un dispositif, et ce dont un abaissement non Jordan-Wigner aurait encore besoin. Pour construire, paramétrer ou simuler un ansatz UCJ en premier lieu, consulte les guides propres de ffsim.
Le guide de la relation avec ffsim pour savoir comment les deux paquets se partagent le travail, et le guide de transpilation pour les étapes du pipeline utilisées ci-dessous.
1. Obtenir un opérateur LUCJ depuis ffsim
L'ansatz est initialisé à partir des amplitudes d'un calcul coupled-cluster avec simples et doubles (CCSD). Exécute un Hartree-Fock restreint suivi d'un CCSD pour une molécule d'hydrogène dans la base 6-31g, en utilisant PySCF pour la chimie quantique, puis passe les amplitudes à from_t_amplitudes() de ffsim. Cette fonction effectue une double factorisation des amplitudes pour obtenir les matrices de Coulomb diagonales et les rotations d'orbitales de chaque couche, et déduit une rotation d'orbitales finale facultative à partir des amplitudes . Le nombre de répétitions est ce que donne cette factorisation ; n_reps le tronque, échangeant de la précision contre un circuit moins profond.
>>> import ffsim
>>> import pyscf
>>> import pyscf.cc
>>>
>>> # build the molecule and run Hartree-Fock
>>> mol = pyscf.gto.Mole()
>>> mol.build(
... atom=[["H", (0, 0, 0)], ["H", (0, 0, 0.74)]],
... basis="6-31g",
... symmetry="Dooh",
... verbose=0,
... )
<pyscf.gto.mole.Mole object at ...>
>>> scf = pyscf.scf.RHF(mol).run()
>>>
>>> norb = scf.mo_coeff.shape[1]
>>> nelec = (mol.nelec[0], mol.nelec[1])
>>>
>>> # run CCSD for the t-amplitudes, then factorize them into an ansatz operator
>>> ccsd = pyscf.cc.CCSD(scf).run()
>>> t1, t2 = ccsd.t1, ccsd.t2
>>>
>>> ucj_op = ffsim.UCJOpSpinBalanced.from_t_amplitudes(t2, t1=t1, n_reps=2)
C'est ici que l'ansatz cesse d'être une affaire de ffsim. ffsim propose ici des choix que ce paquet ne voit pas et n'a pas besoin de connaître, comme la factorisation optimisée de façon variationnelle (« compressée ») derrière optimize=True, ou l'empaquetage du vecteur de paramètres que pilote un optimiseur variationnel. Tous produisent le même type d'opérateur, et tout ce qui suit fonctionne sans changement sur n'importe lequel.
2. Transformer l'opérateur en circuit fermionique
La porte UCJ enveloppe l'opérateur et l'exprime comme un circuit sur des modes fermioniques. La porte est un unitaire pur sans référence propre, donc ajoute en tête une porte InitializeModes (construite avec from_hartree_fock()) pour fournir la référence Hartree-Fock à laquelle l'ansatz est appliqué.
>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import InitializeModes, UCJ
>>>
>>> ansatz = UCJ(ucj_op)
>>>
>>> circuit = FermionicCircuit(2 * norb)
>>> circuit.append(InitializeModes.from_hartree_fock(norb, nelec), circuit.modes)
>>> circuit.append(ansatz, circuit.modes)
Décomposer le circuit révèle son anatomie, et c'est la représentation sur laquelle opère le reste du guide. La porte InitializeModes prépare le déterminant de référence, et chaque couche de l'ansatz apporte une OrbitalRotation , puis (une Evolution de l'opérateur de Coulomb diagonal ), puis , avec une OrbitalRotation finale à la fin. Les rotations d'orbitales agissent par secteur de spin, chacune est donc placée indépendamment sur les modes alpha 0..norb et les modes bêta norb..2*norb.
>>> circuit.decompose().draw("mpl", fold=-1)
<Figure size ... with 1 Axes>

Chaque porte de cette décomposition est encore fermionique : OrbitalRotation et Evolution sont définies sur des modes, et aucun opérateur de qubit ou de Pauli n'est encore apparu. C'est ce qui laisse l'encodage ouvert, et c'est l'état dans lequel l'ansatz reste jusqu'à ce que l'étape de synthèse en choisisse un.
Chaque couche se termine par et la suivante commence par , de sorte que des portes OrbitalRotation adjacentes pourraient être fusionnées en une seule rotation. Une passe de transpilation réalisant cette fusion est un développement futur prévu.
3. Choisir un encodage fermion-vers-qubit
Le circuit est encore fermionique, et c'est le point où ce paquet gagne sa place dans le workflow. Un FermionicCircuit ne fait aucune hypothèse sur la façon dont les modes deviennent des qubits, l'encodage est donc un choix de l'étape de synthèse plutôt que quelque chose d'intégré à l'ansatz. C'est ce que ce paquet apporte par rapport aux portes Qiskit propres à ffsim, qui sont spécifiques à la transformation de Jordan-Wigner.
Pour Jordan-Wigner seul, tu n'aurais pas besoin de ce détour : ffsim fournit UCJOpSpinBalancedJW et ses équivalents, qui transforment seuls un opérateur UCJ en circuit de qubits. La raison de faire passer l'ansatz par un circuit fermionique est tout ce que d'autre un encodage peut t'apporter. Les encodages locaux, par exemple, dépensent des qubits supplémentaires pour borner le poids de Pauli de chaque terme, ce que les guides d'ensembles de flux 1D et 2D utilisent pour rendre la profondeur à deux qubits d'un pas de Trotter indépendante de la taille du système.
Pour un ansatz UCJ en particulier, l'étape de synthèse doit couvrir deux types de portes :
-
L'évolution de Coulomb diagonale.
Evolutionest déjà indépendante de l'encodage :MapperFnEvolutionSynthesisprend une fonction de mapper, de sorte que les couches s'abaissent via tout encodage que tu peux exprimer ainsi. Les guides d'ensembles de flux montrent comment en écrire une. -
Les rotations d'orbitales et la préparation du déterminant de Slater. Ni une
OrbitalRotationni laPrepareSlaterDeterminantdans laquelle fusionnent l'InitializeModesinitiale et la rotation (voir plus bas) ne sont mappées telles quelles sur des qubits. La passeF2FSynthesisles décompose d'abord, dans l'espace fermionique, en portesEvolutionsur un ou deux modes chacune (et, pour la préparation, uneInitializeModesde la référence), de sorte que tout encodage capable de mapper uneEvolutionles gère. Sous Jordan-Wigner, les facteurs entre modes adjacents deviennent des objetsXXPlusYYGateà l'étape des qubits. -
L'initialisation. Le plugin pour
InitializeModesn'est pas paramétré par un encodage : il documente son hypothèse d'un encodage en base d'occupation avec une correspondance 1-pour-1 entre modes et qubits, et émet directement des portes dans cette base. Un autre encodage nécessite son propre plugin, et aucun n'est encore fourni.
La façon de transpiler des circuits UCJ sous un encodage autre que Jordan-Wigner est une question de recherche ouverte, et ce paquet fournit le cadre dans lequel développer un tel pipeline. Le reste de ce guide construit, à titre d'exemple, le pipeline de transpilation pour l'encodage de Jordan-Wigner, équivalent à celui produit par generate_preset_jw_pass_manager().
4. L'abaisser via Jordan-Wigner
L'abaissement a lieu dans F2QSynthesis, qui fonctionne comme la HighLevelSynthesis de Qiskit : elle parcourt les instructions fermioniques et confie chacune à un plugin enregistré pour ce nom de porte. Son attribut F2QSynthesis.methods contient un F2QSynthesisPlugin par nom de porte, donc construire l'étape revient à choisir un plugin pour chaque porte fermionique que contient le circuit :
>>> from qiskit_fermions.mappers.library import jordan_wigner
>>> from qiskit_fermions.transpiler.passes import F2QSynthesis
>>> from qiskit_fermions.transpiler.passes.synthesis import (
... MapperFnEvolutionSynthesis,
... TrivialOccupationInitializeModesSynthesis,
... )
>>>
>>> synthesis = F2QSynthesis()
>>> synthesis.methods["Evolution"] = MapperFnEvolutionSynthesis(jordan_wigner)
>>> synthesis.methods["InitializeModes"] = TrivialOccupationInitializeModesSynthesis()
Affecter des instances de cette façon est aussi la manière d'intégrer au pipeline un plugin que tu as écrit toi-même, sans avoir à l'enregistrer au préalable.
C'est ici que l'encodage est fixé, des deux façons décrites à la section 3 : explicitement, en passant jordan_wigner() à MapperFnEvolutionSynthesis, et implicitement, par l'autre plugin qui le suppose déjà.
Cette étape s'insère ensuite dans un MultiStagePassManager dont les étapes s'exécutent dans un ordre fixe :
>>> from qiskit.passmanager import MultiStagePassManager
>>> from qiskit.transpiler import generate_preset_pass_manager
>>> from qiskit.transpiler.passes.synthesis.high_level_synthesis import HLSConfig
>>>
>>> from qiskit_fermions.transpiler import (
... FermionicCircuitToDAG,
... FermionicPassManager,
... QuantumDAGToCircuit,
... )
>>> from qiskit_fermions.transpiler.passes import (
... F2FSynthesis,
... HopEvolutionSynthesis,
... MergeOrbitalRotations,
... MergeSlaterDeterminantPreparation,
... TrivialF2QLayout,
... )
>>>
>>> pm = MultiStagePassManager(
... input=FermionicCircuitToDAG(),
... optimization=FermionicPassManager(
... [
... MergeOrbitalRotations(),
... MergeSlaterDeterminantPreparation(),
... F2FSynthesis(
... {"OrbitalRotation": "Givens", "PrepareSlaterDeterminant": "Givens"}
... ),
... ]
... ),
... layout=FermionicPassManager(TrivialF2QLayout()),
... synthesis=synthesis,
... qubit=generate_preset_pass_manager(
... hls_config=HLSConfig(PauliEvolution=[HopEvolutionSynthesis(), "default"])
... ),
... output=QuantumDAGToCircuit(),
... )
L'étape d'optimisation s'exécute avant la synthèse, sur le circuit fermionique, ce qui la rend précieuse : elle réécrit les portes tant qu'elles sont encore fermioniques et indépendantes de l'encodage, de sorte que les gains se transmettent à l'encodage qui suit. MergeOrbitalRotations réduit chaque suite de portes OrbitalRotation consécutives à une seule, ce qui compte pour un ansatz UCJ car chaque couche se termine par et la suivante commence par . MergeSlaterDeterminantPreparation fusionne ensuite l'InitializeModes initiale et la rotation qui la suit en une seule PrepareSlaterDeterminant, que la synthèse abaisse via la décomposition de Slater réduite, moins coûteuse, au lieu d'une rotation carrée complète. L'ordre compte : fusionner d'abord les rotations expose la rotation unique immédiatement après l'initialisation, que la fusion de Slater absorbe ensuite. F2FSynthesis vient en dernier, car elle découpe toute rotation restante, et le faire plus tôt ne laisserait rien à fusionner aux deux passes de fusion.
Les étapes restantes sont de la tuyauterie. input et output convertissent entre circuits et DAG, layout affecte les modes fermioniques aux qubits (TrivialF2QLayout associe le mode i au qubit i), et qubit est un pass manager Qiskit ordinaire qui prend le relais une fois qu'il ne reste plus rien de fermionique.
L'exécuter demande une étape préparatoire : la porte composite UCJ doit d'abord être décomposée en ses portes primitives, afin que l'étape d'optimisation puisse voir les rotations et évolutions individuelles.
>>> transpiled = pm.run(circuit.decompose())
>>> print(dict(sorted(transpiled.count_ops().items())))
{'rz': 14, 'rzz': 12, 'x': 2, 'xx_plus_yy': 28}
Sans dispositif cible, cela se mappe sur 2 * norb qubits avec une connectivité supposée totale ; les rotations d'orbitales se synthétisent en objets XXPlusYYGateet les évolutions de Coulomb diagonales en objets RZZGate:
>>> transpiled.draw("mpl", fold=-1)
<Figure size ... with 1 Axes>
Ce pipeline est exactement ce qu'assemble generate_preset_jw_pass_manager(), utilise donc le preset en pratique et construis les étapes à la main lorsque tu as besoin d'en modifier une :
>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> preset = generate_preset_jw_pass_manager()
>>> preset.run(circuit.decompose()).count_ops() == transpiled.count_ops()
True
5. Adapter à la topologie du dispositif
Un dispositif réel a une carte de couplage de qubits restreinte, et l'ansatz LUCJ est conçu pour s'y adapter. Les interactions de même spin (pairs_aa) forment deux chaînes linéaires et les interactions alpha-bêta (pairs_ab) les relient. generate_lucj_pass_manager() de ffsim construit un pipeline de qubits adapté au dispositif pour cette structure, et renvoie le sous-ensemble de pairs_ab que le matériel peut réellement accueillir. Insère ce pipeline dans l'étape qubit du preset tout en conservant la synthèse fermion-vers-qubit propre au paquet :
>>> from ffsim.qiskit import generate_lucj_pass_manager
>>> from qiskit.providers.fake_provider import GenericBackendV2
>>> from qiskit.transpiler import CouplingMap
>>>
>>> # a heavy-hex device coupling map (any BackendV2 works, e.g. a real fake_provider backend)
>>> coupling_map = CouplingMap.from_heavy_hex(5)
>>> backend = GenericBackendV2(
... num_qubits=coupling_map.size(),
... basis_gates=["cp", "xx_plus_yy", "p", "x", "swap"],
... coupling_map=coupling_map,
... )
>>>
>>> # nearest-neighbor same-spin chain; let the pass manager choose the alpha-beta pairs
>>> pairs_aa = [(p, p + 1) for p in range(norb - 1)]
>>>
>>> pm = generate_preset_jw_pass_manager()
>>> hls_config = HLSConfig(PauliEvolution=[HopEvolutionSynthesis(), "default"])
>>> pm.qubit, allowed_pairs_ab = generate_lucj_pass_manager(
... backend,
... norb,
... "heavy-hex",
... (pairs_aa, None),
... optimization_level=3,
... seed_transpiler=0,
... hls_config=hls_config,
... )
>>>
>>> # the alpha-beta interactions the heavy-hex connectivity can implement
>>> print(allowed_pairs_ab)
[(0, 0)]
Cette composition est le point essentiel : l'étape qubit est adaptée au dispositif, tandis que l'étape de synthèse fermion-vers-qubit qui la précède reste celle de ce paquet, et reste donc remplaçable. Remplacer l'étape qubit supprime bien ce que le preset y enregistre, à savoir la HopEvolutionSynthesis qui transforme les facteurs de Givens d'une rotation d'orbitales en objets XXPlusYYGate. Transmets-la via hls_config comme plus haut, sinon la base du dispositif doit être atteinte sans elle.
Avec pm.qubit maintenant réglé sur le pipeline adapté au dispositif, l'exécution du pass manager place le circuit sur les qubits du backend et le route vers la carte de couplage. Pour l'ansatz non restreint de l'étape 1, dont l'opérateur de Coulomb diagonal contient encore des termes alpha-bêta que le matériel ne peut pas atteindre directement, le routeur doit insérer de nombreuses portes SWAP pour les relier :
>>> naive = pm.run(circuit.decompose())
>>> naive.num_qubits # laid out on the full heavy-hex device register
57
>>> naive_swaps = naive.count_ops()["swap"]
>>> naive_swaps # many SWAPs to bridge the unreachable alpha-beta interactions
33
Restreindre l'ansatz aux interactions réalisables par le matériel
La solution consiste à réinjecter allowed_pairs_ab dans la construction de l'ansatz, via l'argument interaction_pairs de from_t_amplitudes(), afin que l'opérateur de Coulomb diagonal ne contienne que des termes alpha-bêta que la carte de couplage peut implémenter directement. L'ansatz correspond alors à la topologie du dispositif et le routeur n'a presque rien à y toucher :
>>> restricted = UCJ(
... ffsim.UCJOpSpinBalanced.from_t_amplitudes(
... t2, t1=t1, n_reps=2, interaction_pairs=(pairs_aa, allowed_pairs_ab)
... )
... )
>>>
>>> circuit = FermionicCircuit(2 * norb)
>>> circuit.append(InitializeModes.from_hartree_fock(norb, nelec), circuit.modes)
>>> circuit.append(restricted, circuit.modes)
>>>
>>> transpiled = pm.run(circuit.decompose())
>>> restricted_swaps = transpiled.count_ops()["swap"]
>>> restricted_swaps # far fewer routing SWAPs than the unrestricted ansatz
4
Dessiner uniquement les qubits actifs (idle_wires=False) montre le circuit restreint aux deux chaînes de spin et au pont alpha-bêta, exprimé dans les portes de base du dispositif. Le placement et le routage dispersent les modes logiques sur les qubits physiques du dispositif, passe donc un wire_order tiré du placement final du circuit (final_index_layout() liste le qubit physique sur lequel chaque qubit d'entrée a fini, dans l'ordre des qubits d'entrée) pour redessiner les fils dans l'ordre d'origine des modes :
>>> wire_order = transpiled.layout.final_index_layout(filter_ancillas=False)
>>> transpiled.draw("mpl", idle_wires=False, fold=-1, wire_order=wire_order)
<Figure size ... with 1 Axes>

Le nombre exact de portes et la profondeur après placement dépendent des passes de routage/optimisation et du dispositif choisi, ils ne sont donc pas reproduits ici. Le point clé est la co-conception ; exprimer l'ansatz avec une chaîne pairs_aa de plus proches voisins et des ponts pairs_ab filtrés par le matériel garde le circuit synthétisé proche de la topologie du dispositif, minimisant la surcharge de routage (portes SWAP insérées). Voir FermionicSlaterGivensDecomposition pour un choix de synthèse apparenté (minimize_2q_gate_count), qui échange le nombre de portes à deux qubits contre la profondeur routée.
Étapes suivantes
-
Découvre le fonctionnement des portes individuelles dans la documentation de
qiskit_fermions.circuit.libraryet dans le guide du circuit fermionique. -
Explore le guide d'explication des opérateurs pour comprendre comment construire directement des opérateurs fermioniques et des Hamiltoniens.
-
Vois comment un circuit fermionique est mappé sur des qubits dans le guide de transpilation.
-
Lis la documentation de
F2QSynthesispour l'autre façon de configurer l'étape de synthèse : un dictionnaireconfigqui sélectionne les plugins par les noms courts sous lesquels ils s'enregistrent dans leur point d'entréeqiskit_fermions.transpiler.synthesis, plutôt que par instance. -
Lis le guide de la relation avec ffsim pour comprendre comment les deux paquets se partagent le travail, et quels protocoles permettent à ffsim de simuler les circuits de ce paquet.
