Aller au contenu principal
Ce projet est en phase bêta. Tu as trouvé un bug ? Ouvrir un ticket GitHub. Une question ou une idée ? Pose ta question dans Discussions — tes retours sont les bienvenus !
Quelle est la qualité de la traduction sur cette page ?

Synthétiser une porte Evolution dans l'espace fermionique

Important

Les concepts de ce guide sont actuellement disponibles uniquement dans l'API Python. Une fonctionnalité équivalente sera disponible dans l'API C dans une prochaine version.

Une porte Evolution porte l'évolution temporelle e−itHe^{-itH} d'un opérateur entier. Une synthèse fermion-vers-fermion réécrit cette porte en un FermionicCircuit de portes fermioniques plus petites. Les deux côtés de la réécriture restent dans l'espace fermionique, ce qui la distingue de la synthèse fermion-vers-qubit qui suit : les groups de l'opérateur (voir Regrouper les termes d'un opérateur : exploiter la structure de l'opérateur) y survivent, alors qu'un mapping fermion-vers-qubit retourne un SparseObservable, qui n'a aucune notion de regroupement.

Deux réglages sélectionnent la méthode. Evolution.synthesis la définit pour une seule porte, et la passe de transpileur F2FSynthesis la définit pour un circuit entier. Les deux acceptent un F2FSynthesisPlugin, l'interface qu'implémente toute réécriture de ce type. Les méthodes que ce paquet fournit actuellement pour une évolution sont des formules de produit, que le reste de ce guide utilise : FermionicLieTrotter pour une formule du premier ordre (par défaut) et FermionicSuzukiTrotter pour un ordre pair plus élevé. Tout autre élément qui implémente l'interface se sélectionne de la même façon, et les sections sur les deux réglages s'y appliquent sans changement.

Remarque

Cette étape fermion-vers-fermion est facultative. Une porte Evolution peut aller directement à l'étape fermion-vers-qubit, qui la mappe et la synthétise en entier, quel que soit le nombre de termes qu'elle contient. La scinder d'abord est un choix, fait parce que les portes obtenues sont individuellement moins coûteuses ou parce que la scission expose une structure qu'une étape ultérieure peut exploiter.

Les deux étapes se composent, donc une évolution peut finir approximée à l'un ou l'autre niveau, aux deux, ou à aucun. Une formule de produit est une approximation, sauf si ses facteurs commutent ; une méthode de synthèse n'a pas besoin d'approximer du tout.

Remarque

Sauter cette étape ne signifie pas forcément perdre entièrement le regroupement. Envelopper le mapper dans group_wise() mappe l'opérateur un groupe à la fois et additionne les résultats, de sorte que les termes de Pauli mappés arrivent regroupés plutôt qu'entrelacés. Une formule de produit côté qubits les synthétise dans l'ordre où elle les reçoit, donc des termes qui commutent et qui se retrouvent adjacents peuvent être ordonnancés ensemble.

Sur le hamiltonien ci-dessous, cela récupère tout le gain de profondeur à deux qubits obtenu par la scission : 18 au lieu de 30, avec un nombre de portes inchangé. La différence tient à l'endroit où la structure est exprimée. Scinder dans l'espace fermionique fait de chaque groupe une porte distincte, que chaque étape ultérieure peut voir et exploiter ; group_wise laisse une seule porte et réordonne seulement les termes qu'elle contient, ce qui suffit pour l'ordonnancement mais ne laisse rien sur quoi une passe fermionique puisse travailler. Utilise-le quand l'évolution doit rester une seule porte, et préfère une formule de produit fermionique sinon.

Construire un hamiltonien groupé​

Les deux formules de produit scindent un opérateur en facteurs, et pour un opérateur groupé ces facteurs sont ses groupes. Le regroupement détermine donc à la fois le coût et la précision du résultat. Prenons une chaîne de Fermi-Hubbard à six sites, groupée selon la façon dont ses termes commutent : les termes de saut sur les liaisons paires, les termes de saut sur les liaisons impaires, et l'interaction diagonale.

>>> from qiskit_fermions.operators import FermionOperator, cre, ann
>>>
>>> def fermi_hubbard_chain(num_sites, tunneling, interaction):
... """A 1D Fermi-Hubbard chain grouped into even bonds, odd bonds, and the interaction."""
... terms, groups = [], []
... for offset in (0, 1):
... for j in range(offset, num_sites - 1, 2):
... terms += [
... ([cre(j), ann(j + 1)], -tunneling),
... ([cre(j + 1), ann(j)], -tunneling),
... ]
... groups += [offset, offset]
... for j in range(num_sites - 1):
... terms.append(([cre(j), ann(j), cre(j + 1), ann(j + 1)], interaction))
... groups.append(2)
... operator = FermionOperator.from_terms(terms)
... operator.groups = groups
... return operator
>>>
>>> num_sites = 6
>>> hamiltonian = fermi_hubbard_chain(num_sites, tunneling=1.0, interaction=2.0)
>>> hamiltonian.num_groups()
3

Quinze termes en trois groupes. Chaque groupe de sauts associe chaque terme à son partenaire conjugué, ce qui compte : un facteur doit être hermitien pour que son exponentielle soit unitaire, et cela ne découle pas du fait que la somme est hermitienne. groups_are_hermitian() vérifie cette convention au lieu de s'en remettre à l'inspection :

>>> from qiskit_fermions.operators.terms.grouping import groups_are_hermitian
>>>
>>> groups_are_hermitian(hamiltonian)
[True, True, True]

Séparer a0†a1a^\dagger_0 a_1 de son partenaire a1†a0a^\dagger_1 a_0 la briserait, et laisser l'opérateur non groupé a le même effet, puisque les deux formules le scindent alors terme par terme et qu'un ai†aja^\dagger_i a_j isolé n'est pas hermitien :

>>> ungrouped = fermi_hubbard_chain(num_sites, tunneling=1.0, interaction=2.0)
>>> ungrouped.groups = range(len(list(ungrouped.iter_terms()))) # one group per term
>>> all(groups_are_hermitian(ungrouped))
False

Un tel facteur est un opérateur fermionique valide, et ce paquet construira la porte correspondante, donc rien ne se plaint jusqu'à ce que l'opérateur mappé atteigne PauliEvolutionGate, qui exige des coefficients réels et lève ValueError: Operator contains complex coefficients, which are not supported. Vérifier le regroupement en amont transforme cet échec tardif en une réponse disponible avant la construction de tout circuit.

Définir la méthode pour une seule porte​

Passe une méthode de synthèse à la porte, puis décompose-la. Chaque porte qu'elle émet revient sous forme de sa propre porte Evolution :

>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.circuit.library.synthesis import (
... FermionicLieTrotter,
... FermionicSuzukiTrotter,
... )
>>>
>>> total_time = 1.0
>>>
>>> def factors(synthesis):
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(num_sites, hamiltonian, time=total_time, synthesis=synthesis),
... circuit.modes,
... )
... return circuit.decompose().count_ops()["Evolution"]
>>>
>>> factors(FermionicLieTrotter())
3
>>> factors(FermionicSuzukiTrotter(order=2))
5
>>> factors(FermionicSuzukiTrotter(order=2, reps=4))
17
>>> factors(FermionicSuzukiTrotter(order=2, reps=4, merge_adjacent=False))
20

Le premier ordre émet un facteur par groupe. L'ordre deux émet le palindrome symétrique, donc les deux groupes extérieurs apparaissent deux fois avec la moitié du temps chacun, ce qui donne cinq. Quatre répétitions de cela en donneraient vingt, mais chaque répétition se termine sur le même groupe que celui par lequel commence la suivante. Ces portes voisines évoluent sous le même opérateur, donc elles sont fusionnées en une seule porte avec le temps cumulé, ce qui économise une porte par jointure. Passe merge_adjacent=False pour conserver la séquence de référence des manuels. Chaque ordre scinde l'opérateur de la même façon ; ce qui change, c'est l'ordre des facteurs et leurs temps d'évolution, pas la partition.

Remarque

Les facteurs sont marqués atomic, donc une nouvelle décomposition les laisse en place au lieu de les scinder davantage. La décomposition a un point fixe, ce qui permet d'appeler decompose() en toute sécurité de façon répétée.

Définir la méthode pour tout un pipeline​

Définir synthesis porte par porte oblige à transmettre le choix à tout ce qui en construit une, y compris UCC et UCJ, qui construisent leurs portes en interne. La passe F2FSynthesis applique à la place une méthode à chaque Evolution d'un circuit, donc le choix est fait une seule fois pour le pipeline. Elle se configure par nom de porte, comme F2QSynthesis, et une porte qui n'est pas nommée est laissée telle quelle :

>>> from qiskit_fermions.transpiler import FermionicPassManager
>>> from qiskit_fermions.transpiler.passes import F2FSynthesis
>>>
>>> circuit = FermionicCircuit(num_sites)
>>> circuit.append(Evolution(num_sites, hamiltonian, time=total_time), circuit.modes)
>>>
>>> expanded = FermionicPassManager(
... F2FSynthesis({"Evolution": ("SuzukiTrotter", (), {"order": 2, "reps": 4})})
... ).run(circuit)
>>> expanded.count_ops()["Evolution"]
17

C'est le même nombre que celui que donne decompose() pour cette méthode plus haut. La passe MergeEvolutions applique la même fusion aux portes voisines qui ne proviennent pas d'une même synthèse, comme les jointures entre circuits consécutifs. La passe exécute elle-même la formule, donc aucune étape d'expansion séparée n'est nécessaire, et l'exécuter deux fois ne change rien, puisque les facteurs qu'elle émet sont atomic et ne portent donc aucune définition propre.

Remarque

Passe apply=False pour seulement sélectionner la méthode et laisser l'expansion à autre chose, comme Decompose de Qiskit (nommer la porte avec Decompose("Evolution") la restreint aux évolutions, en laissant les autres portes fermioniques à leurs propres plugins de synthèse).

C'est utile pour choisir la méthode de tout un circuit dès le départ tout en reportant l'expansion au moment voulu, par exemple pour inspecter d'abord le circuit :

>>> selected = FermionicPassManager(
... F2FSynthesis({"Evolution": ("SuzukiTrotter", (), {"order": 2})}, apply=False)
... ).run(circuit)
>>> selected.count_ops()["Evolution"] # selected, not yet expanded
1
>>> selected.decompose().count_ops()["Evolution"] # expanded on demand
5

Sois toutefois conscient de ce que fait une porte non étendue : elle atteint l'étape fermion-vers-qubit en entier, où elle est mappée sans que synthesis soit consulté. Cela favorise une erreur facile quand on compare des méthodes, car chacune produit alors une sortie identique et la comparaison ne mesure silencieusement rien. Sur le hamiltonien ci-dessus, laisser la porte d'ordre deux non étendue ramène le circuit bien plus grand ci-dessous au coût d'une seule évolution mappée, quelle que soit la méthode sélectionnée.

Mesurer le gain​

Un ordre plus élevé coûte de la profondeur, donc la question n'est pas de savoir s'il est plus précis, mais s'il est plus précis par porte. Deux nombres sont nécessaires pour chaque méthode : l'erreur de l'état qu'elle produit, et le coût en portes à deux qubits du circuit qui l'a produit.

L'erreur est mesurée avec ffsim, qui simule un FermionicCircuit directement (voir Comment ce paquet se rapporte à ffsim). Aucun mapping fermion-vers-qubit n'intervient, donc la comparaison isole la formule de produit fermionique de tout ce que le mapping ou la synthèse côté qubits pourraient apporter. Elle fonctionne aussi dans le secteur à nombre de particules fixe, qui est à la fois plus petit que l'espace complet 2N2^N et l'espace dans lequel l'évolution reste réellement.

>>> import ffsim
>>> import numpy as np
>>> from scipy.sparse.linalg import expm_multiply
>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> norb, nelec = num_sites, (3, 0) # three spinless fermions on six modes
>>> initial = ffsim.hartree_fock_state(norb, nelec)
>>>
>>> hamiltonian_operator = ffsim.linear_operator(hamiltonian, norb=norb, nelec=nelec)
>>> exact = expm_multiply(
... -1j * total_time * hamiltonian_operator,
... initial,
... traceA=-1j * total_time * ffsim.trace(hamiltonian, norb=norb, nelec=nelec),
... )
>>>
>>> def cost_and_error(synthesis):
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(num_sites, hamiltonian, time=total_time, synthesis=synthesis),
... circuit.modes,
... )
... # the method is set on the gate, so decompose() is what expands it here; a pipeline
... # would reach for F2FSynthesis instead (see the note above)
... circuit = circuit.decompose()
... evolved = ffsim.apply_unitary(initial, circuit, norb=norb, nelec=nelec)
... error = 1 - abs(np.vdot(evolved, exact)) ** 2
... qubit_circuit = generate_preset_jw_pass_manager().run(circuit).decompose(reps=6)
... two_qubit = lambda instruction: len(instruction.qubits) == 2
... return error, qubit_circuit.count_ops()["cx"], qubit_circuit.depth(two_qubit)
>>>
>>> methods = [
... ("order 1, reps 1 ", FermionicLieTrotter()),
... ("order 2, reps 1 ", FermionicSuzukiTrotter(order=2)),
... ("order 1, reps 10", FermionicLieTrotter(reps=10)),
... ("order 2, reps 4 ", FermionicSuzukiTrotter(order=2, reps=4)),
... ]
>>> costs = {label: cost_and_error(synthesis) for label, synthesis in methods}
>>> labels, counts, depths = [], [], []
>>> for label, (error, gates, depth) in costs.items():
... labels.append(label.strip().replace(", ", "\n"))
... counts.append(gates)
... depths.append(depth)
... print(f"{label} error {error:.6f}")
order 1, reps 1 error 0.515997
order 2, reps 1 error 0.180958
order 1, reps 10 error 0.004026
order 2, reps 4 error 0.000421

Le coût en portes à deux qubits est volontairement omis de ce tableau. Il dépend de la synthèse côté qubits que Qiskit sélectionne, qui s'améliore d'une version à l'autre, donc les nombres affichés ici seraient un instantané plutôt qu'une propriété de la formule de produit. La figure ci-dessous les trace à la place, et ce sur quoi repose la comparaison est affirmé sous forme de relation :

Grouped bars of CX count and depth per method, with order two at four repetitions clearly shorter than order one at ten.

Lis ensemble les deux dernières méthodes, car c'est la comparaison qui compte. L'ordre deux avec quatre répétitions est environ dix fois plus précis que l'ordre un avec dix, et il y parvient avec moins de portes intriquantes plutôt qu'avec plus, comme le montre la figure. Dépenser un budget de portes sur un ordre plus élevé est ici préférable à le dépenser sur davantage de pas du premier ordre.

Ce résultat n'est pas universel. Augmenter reps et augmenter order réduisent l'erreur à des rythmes différents pour un même type de coût, et celui qui l'emporte dépend de l'opérateur et du temps d'évolution. Les deux lignes ci-dessus montrent comment le déterminer pour un problème donné.

Remarque

La formule fermionique se compose avec celle choisie pour l'étape fermion-vers-qubit (voir MapperFnEvolutionSynthesis.product_formula). Lorsque les deux approximent, la précision du résultat est dictée par la plus faible des deux, donc augmenter l'ordre ici alors que la formule côté qubits reste du premier ordre apporte peu.

Restreindre la passe aux portes qui en profitent​

Un ordre plus élevé n'aide que lorsque les facteurs ne commutent pas. S'ils commutent tous, la formule du premier ordre est déjà exacte et les facteurs supplémentaires sont du pur surcoût. Un opérateur purement diagonal le rend concret, puisque les opérateurs nombre commutent entre eux :

>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> diagonal = FermionOperator.from_terms(
... [([cre(j), ann(j), cre(j + 1), ann(j + 1)], 1.0 + 0.1 * j) for j in range(num_sites - 1)]
... )
>>> diagonal.groups = list(range(num_sites - 1))
>>>
>>> def diagonal_cost(synthesis):
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(num_sites, diagonal, time=total_time, synthesis=synthesis), circuit.modes
... )
... transpiled = generate_preset_jw_pass_manager().run(circuit.decompose())
... return transpiled.decompose(reps=6).count_ops()["cx"]
>>>
>>> diagonal_cost(FermionicLieTrotter())
10
>>> diagonal_cost(FermionicSuzukiTrotter(order=2))
18

Les deux circuits sont exacts, donc le second dépense 80 % de portes intriquantes en plus pour rien. Les opérateurs de Coulomb diagonaux d'un UCJ ont cette forme, ce qui explique l'existence de F2FSynthesis.filter : il prend un prédicat sur le DAGOpNode et laisse intactes les portes rejetées, de sorte qu'une seule passe peut augmenter l'ordre pour les portes qui en profitent et laisser les autres au premier ordre.

Un circuit qui contient les deux types d'évolution montre ce que cela économise. Le prédicat ci-dessous n'accepte une porte que lorsque son opérateur déplace des particules entre modes, ce qui est exactement le cas où les groupes ne commutent pas et où un ordre plus élevé a quelque chose à apporter :

>>> def is_diagonal(operator):
... """Whether every term only counts particles, rather than moving them between modes."""
... return all(
... sorted(mode for created, mode in actions if created)
... == sorted(mode for created, mode in actions if not created)
... for actions, _ in operator.iter_terms()
... )
>>>
>>> def mixed_circuit():
... circuit = FermionicCircuit(num_sites)
... circuit.append(Evolution(num_sites, hamiltonian, time=total_time), circuit.modes)
... circuit.append(Evolution(num_sites, diagonal, time=total_time), circuit.modes)
... return circuit
>>>
>>> def two_qubit_cost(circuit):
... transpiled = generate_preset_jw_pass_manager().run(circuit).decompose(reps=6)
... two_qubit = lambda instruction: len(instruction.qubits) == 2
... return transpiled.count_ops()["cx"], transpiled.depth(two_qubit)
>>>
>>> order_2 = FermionicSuzukiTrotter(order=2)
>>>
>>> everywhere = two_qubit_cost(
... FermionicPassManager(F2FSynthesis({"Evolution": order_2})).run(mixed_circuit())
... )
>>> restricted = two_qubit_cost(
... FermionicPassManager(
... F2FSynthesis(
... {"Evolution": order_2}, filter=lambda node: not is_diagonal(node.op.operator)
... )
... ).run(mixed_circuit())
... )
>>> everywhere[0] - restricted[0] # the gates the filter saved
8
>>> restricted[0] < everywhere[0] and restricted[1] < everywhere[1]
True

Les portes que le filtre économise sont exactement celles que l'ordre deux a dépensées pour l'évolution diagonale, que la comparaison diagonale ci-dessus chiffre à huit (18 contre 10). Elles n'y apportaient rien, car cette évolution est déjà exacte au premier ordre, donc les supprimer ne coûte aucune précision.

Remarque

Compare les deux circuits d'ordre deux entre eux, pas avec celui qui n'est pas étendu. Laisser les deux portes non étendues envoie chacune à l'étape fermion-vers-qubit en entier, ce qui est l'erreur décrite par la remarque sur apply=False plus haut : cela peut aboutir au même nombre de portes intriquantes que le circuit filtré tout en étant un circuit différent à une profondeur différente, donc lire le bénéfice du filtre sur ce nombre ne mesure rien.

Attention

Aucun des deux réglages ne vérifie que les facteurs sont hermitiens, et un opérateur mal groupé peut sembler meilleur à un ordre pair qu'au premier ordre tout en étant faux, car la symétrisation annule partiellement l'erreur. Regroupe les partenaires conjugués ensemble, comme le fait fermi_hubbard_chain plus haut.

Étapes suivantes​