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 ?

Générer des circuits SqDRIFT

Avec la diagonalisation quantique par échantillonnage (SQD), tu dois choisir un ansatz à partir duquel échantillonner des chaînes de bits. La variante SqDRIFT utilise à la place un ensemble de circuits d'évolution temporelle construits directement à partir de l'hamiltonien cible. Cela s'obtient en sous-échantillonnant de plus petits opérateurs d'évolution temporelle de l'hamiltonien d'après ses coefficients, ce qu'on appelle la méthode de Trotterisation qDRIFT.

Ce guide de prise en main montre comment générer un ensemble de tels circuits randomisés.

1. Configuration de l'hamiltonien​

Pour les besoins de ce guide, charge l'hamiltonien de structure électronique de N2 depuis un fichier FCIDUMP. Il existe d'autres moyens de construire le FermionOperator. Consulte sa documentation, ainsi que qiskit_fermions.operators.library.

>>> from qiskit_fermions.operators.library import FCIDump
>>> from qiskit_fermions.operators import FermionOperator
>>>
>>> fcidump = FCIDump.from_file("docs/guides/n2.fcidump")
>>> num_modes = 2 * fcidump.norb
>>> hamil = FermionOperator.from_fcidump(fcidump)

2. Regrouper les termes de l'hamiltonien​

Exploite les nombreuses symétries présentes dans l'hamiltonien de structure électronique en regroupant les termes liés qui ont des coefficients identiques. Cette action modifie la distribution des coefficients de l'opérateur à partir de laquelle le protocole qDRIFT échantillonne, mais n'affecte pas ses garanties de convergence. Surtout, regrouper les termes liés par symétrie entraîne une annulation favorable des termes de Pauli, d'où une profondeur de circuit globalement plus courte lors de l'évolution temporelle d'un état sous leur action.

Le module qiskit_fermions.operators.terms.grouping fournit des fonctions pratiques pour regrouper les termes d'un opérateur. Cela est expliqué plus en détail dans ce guide.

Attention

L'implémentation de group_terms_by_electronic_structure() suppose que les termes de l'hamiltonien sont en ordre normal !

>>> from qiskit_fermions.operators.terms.grouping import group_terms_by_electronic_structure
>>> from qiskit_fermions.operators.terms.ordering import canonical_order
>>>
>>> canon = canonical_order(hamil.normal_ordered().simplify(atol=1e-16))
>>> exit_code = group_terms_by_electronic_structure(canon, num_modes, two_body_physicist_order=False)
>>> assert exit_code is None
>>> print(canon.groups) # the groups attribute now contains some list of group indices
[0, ...]
Astuce

L'hamiltonien de structure électronique complet contient certains termes dont l'inclusion dans un circuit d'évolution temporelle n'a aucun effet sur les chaînes de bits perçues et n'entraîne donc qu'un surcoût d'échantillonnage accru. Il est donc recommandé de filtrer ces termes de l'hamiltonien à ce stade, avant de construire la porte Evolution à l'étape suivante.

Les termes correspondant à cette description sont ceux qui sont diagonaux dans la base des nombres d'occupation, c'est-à-dire les produits d'opérateurs nombre (ai†aia^\dagger_i a_i). Cela inclut le décalage d'énergie constant, dont l'évolution temporelle n'introduit qu'une phase globale dans le circuit, les opérateurs nombre individuels dont l'évolution temporelle revient à des rotations Z à un qubit, ainsi que des produits d'ordre supérieur tels que ninjn_i n_j. Aucun d'eux ne peut modifier l'occupation d'un mode ; les supprimer ne coûte donc aucun contenu d'excitation. Contrairement au rejet par tirage de l'étape 5, ce filtrage a lieu une seule fois, sur l'hamiltonien lui-même ; la distribution des coefficients dont le protocole échantillonne est donc reconstruite de façon cohérente à partir de l'opérateur filtré, au lieu d'être renormalisée en cours de tirage.

La fonction filter_diagonal_terms() supprime ces termes d'un opérateur sur place :

>>> from qiskit_fermions.operators.terms.filtering import filter_diagonal_terms
>>>
>>> filter_diagonal_terms(canon)

Filtrer ici, une seule fois, est considérablement moins coûteux que filtrer de manière répétée. FermionicQDrift s'exécute une fois par circuit transpilé ; filtrer l'hamiltonien au départ, plutôt qu'à chaque appel, évite donc de refaire le travail pour chaque circuit généré à partir de lui.

Astuce

Pour la même raison, il est avantageux d'ordonner ici les termes de l'hamiltonien par indice de groupe. Les indices de groupe sont une étiquette par terme qui ne dit rien de la position de ces termes ; les termes d'un groupe sont donc en général dispersés dans l'opérateur : le regroupement appliqué plus haut attribue les indices dans l'ordre de première rencontre, ce qui laisse le deuxième terme d'un groupe loin du premier.

FermionicQDrift échantillonne des groupes entiers et recherche chaque groupe tiré avec split_out_groups(). Trouver un groupe parmi des termes dispersés oblige à les parcourir tous, de sorte que cette recherche coûte autant qu'on tire un groupe ou quarante. group_order() rassemble chaque groupe en une séquence contiguë, après quoi une recherche est une recherche binaire sur les frontières des groupes et coûte ce que coûtent les groupes tirés plutôt que les termes détenus. La recherche elle-même devient considérablement moins chère ; la part que cela représente dans une transpilation donnée dépend de ce que fait d'autre le pipeline, car le mapping et la synthèse dominent une fois le parcours supprimé :

>>> from qiskit_fermions.operators.terms.ordering import group_order
>>>
>>> canon = group_order(canon)
>>> print(canon.groups[:4])
[0, 0, ...]

Cela réordonne les termes sans changer la valeur de l'opérateur, et n'a besoin d'avoir lieu qu'une seule fois, quel que soit le nombre de randomisations de circuit tirées à partir de lui.

3. Préparer le circuit d'évolution temporelle​

Prépare le circuit d'évolution temporelle de l'hamiltonien et le circuit de base à partir duquel tirer les échantillons. Le module qiskit_fermions.circuit.library contient tous les composants nécessaires pour cela, conformément aux conventions de Qiskit.

Le protocole qDRIFT est la méthode de synthèse FermionicQDrift, et la porte Evolution la porte comme sa synthesis. La méthode introduit les quelques paramètres que tu peux utiliser pour personnaliser les circuits à générer :

  • La longueur de chaque circuit en nombre de groupes d'excitations : num_groups

  • Facultativement, une graine pour le générateur de nombres aléatoires : rng

  • Facultativement, des poids d'échantillonnage précalculés, si la transpilation elle-même devient un goulot d'étranglement : weights (voir (Facultatif) Précalculer les poids d'échantillonnage ci-dessous)

Comme la méthode est portée par la porte, tout ce qui décrit l'opérateur de la porte, comme ces poids, se trouve juste à côté de cet opérateur.

>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.circuit.library.synthesis import FermionicQDrift
>>>
>>> time = 1.0 # you can choose a desired scaling factor here
>>> num_groups = 10
>>> qdrift = FermionicQDrift(num_groups, rng=19)
>>> evo_gate = Evolution(num_modes, canon, time, synthesis=qdrift)
>>>
>>> circ = FermionicCircuit(num_modes)
>>> circ.append(evo_gate, circ.modes)
Note

Cet exemple n'initialise pas les modes fermioniques avec des particules et ne mesure pas non plus leur état final.

4. Transpiler le circuit​

Le module qiskit_fermions.transpiler s'intègre directement au pipeline de transpilation de Qiskit, ce qui permet de transpiler directement en QuantumCircuit le FermionicCircuit construit plus haut.

Utilise le mapping fermion-vers-qubit jordan_wigner() pour convertir l'hamiltonien exprimé en fermions en chaînes de Pauli. Cela peut se faire directement dans le processus de transpilation grâce à la passe de transpilation F2QSynthesis. Utilise generate_preset_jw_pass_manager() pour construire FermionicStagedPassManager, qui garantit que l'encodage de Jordan-Wigner est utilisé de façon cohérente pour toutes les instructions du circuit.

Surtout, ajoute la passe Decompose de Qiskit à l'étape optimization du pipeline de transpilation. Elle décompose la porte Evolution avec la méthode de synthèse que porte la porte, c'est-à-dire la méthode qDRIFT préparée plus haut. Cela garantit que le circuit n'utilise pas l'évolution temporelle de l'hamiltonien entier, dont la profondeur dépasserait les capacités du matériel de calcul quantique actuellement disponible.

À la place, elle sous-échantillonne un nombre fixe de groups de termes de l'hamiltonien pour chaque circuit, à chaque transpilation du circuit. Ainsi, tu peux générer plusieurs randomisations de circuit, comme l'exige l'algorithme SqDRIFT, en exécutant à plusieurs reprises le pipeline de transpilation. Le nombre de circuits à générer est num_sqdrift_randomizations.

>>> from qiskit.transpiler.passes import Decompose
>>> from qiskit_fermions.transpiler import FermionicPassManager
>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> pm = generate_preset_jw_pass_manager()
>>> pm.optimization = FermionicPassManager([Decompose(["Evolution"])])
>>>
>>> num_sqdrift_randomizations = 10
>>> sqdrift_circuits = [
... pm.run(circ) for _ in range(num_sqdrift_randomizations)
... ]
Note

Les exemples précédents fixent la graine rng de la méthode de synthèse FermionicQDrift, afin que la séquence de randomisations puisse être reproduite.

Astuce

Le même pipeline accepte la méthode qDRIFT à deux autres endroits. La passe F2FSynthesis prend une méthode pour chaque porte Evolution par son nom, F2FSynthesis({"Evolution": FermionicQDrift(num_groups)}), ce qui suffit quand aucune porte ne détient rien de spécifique à son opérateur. L'étape suivante utilise le troisième endroit, la passe QDriftTrotterization.

5. Filtrer les excitations tirées selon l'occupation​

Outre les termes diagonaux filtrés à l'étape précédente, une excitation tirée peut agir entièrement au sein d'un ensemble de modes dont l'occupation est déjà fixée (tous occupés ou tous inoccupés), où elle ne peut pas déplacer une particule d'un côté à l'autre. Une telle excitation ne t'apprend rien sur les chaînes de bits échantillonnées ; un emplacement dépensé pour elle est donc un emplacement gaspillé. La passe QDriftTrotterization, qui enveloppe la même méthode FermionicQDrift, rejette ces excitations au moment du tirage et en tire une de remplacement, de sorte que chacun des num_groups emplacements du circuit obtenu porte une excitation qui couple les deux côtés.

Avertissement

Ce filtrage n'est pas gratuit, et c'est pourquoi il s'agit d'une passe distincte. Ce qu'il conserve, c'est le budget de num_groups emplacements échantillonnés, pas la profondeur du circuit : ce budget étant fixe, une excitation rejetée est remplacée et non supprimée, et une excitation peu coûteuse cède la place à une excitation de couplage qui coûte plus cher à synthétiser. Attends-toi à ce qu'un circuit filtré soit plus profond qu'un circuit non filtré tiré à partir de la même graine.

Renormaliser le tirage sur les excitations acceptées modifie la distribution dont le protocole qDRIFT échantillonne et, contrairement au regroupement de l'étape 2, brise aussi la garantie de convergence du protocole : le produit échantillonné ne moyenne plus à l'évolution sous l'hamiltonien que tu as fourni. Chaque excitation retenue finit pondérée par l'inverse de la probabilité d'acceptation, les excitations rejetées ne contribuent à rien, et ni la magnitude totale des coefficients ni le temps d'évolution par porte ne sont ajustés pour compenser.

La règle d'acceptation est aussi une sur-approximation. Elle ne compare que les supports de modes, elle ne porte donc aucune information d'amplitude, et les ensembles suivis ne font que croître : une excitation confinée à l'ensemble occupé est véritablement inerte sur un état produit, mais le même raisonnement s'affaiblit pour les tirages suivants, une fois que les excitations acceptées précédemment ont intriqué l'état et marqué la plupart des modes comme « incertains ».

Utilise le filtrage pour le workflow d'échantillonnage de chaînes de bits que décrit ce guide, où les circuits alimentent le post-traitement SQD et où seules les chaînes de bits échantillonnées comptent. Ne l'utilise pas si tu comptes estimer une valeur moyenne à partir de ces circuits, ou t'appuyer d'une autre façon sur la borne d'erreur de qDRIFT : ces résultats portent un biais que la passe ne corrige pas.

Ce filtrage doit savoir quels modes sont initialement occupés. Il exige donc une porte InitializeModes précédant les portes Evolution dans le circuit ; InitializeModes.from_hartree_fock() est un moyen pratique d'en construire une. Ajoutes-en une ici pour la référence de Hartree-Fock de N2 (sept électrons alpha et sept électrons bêta dans 14 orbitales spatiales) et compare les excitations échantillonnées avec et sans filtrage :

>>> from qiskit_fermions.circuit.library import InitializeModes
>>> from qiskit_fermions.transpiler.passes import F2FSynthesis, QDriftTrotterization
>>>
>>> init = InitializeModes.from_hartree_fock(fcidump.norb, (7, 7))
>>>
>>> hf_circ = FermionicCircuit(num_modes)
>>> hf_circ.append(init, hf_circ.modes)
>>> hf_circ.append(Evolution(num_modes, canon, time), hf_circ.modes)
>>>
>>> num_groups = 5
>>> qdrift_unfiltered = F2FSynthesis(
... {"Evolution": FermionicQDrift(num_groups, rng=3480)}
... )
>>> qdrift_trivial = QDriftTrotterization(
... FermionicQDrift(num_groups, rng=3480)
... )
>>>
>>> for instruction in FermionicPassManager(qdrift_unfiltered).run(hf_circ)._inner.data:
... if instruction.operation.name == "Evolution":
... print(sorted(instruction.operation.operator.get_support()))
[2, 4]
[41, 45, 52]
[15, 45, 55]
[41, 52, 53]
[10, 16, 37, 38]
>>>
>>> for instruction in FermionicPassManager(qdrift_trivial).run(hf_circ)._inner.data:
... if instruction.operation.name == "Evolution":
... print(sorted(instruction.operation.operator.get_support()))
[0, 1, 6, 7]
[0, 1, 28, 29]
[4, 13, 55]
[13, 20, 40, 41]
[0, 1, 28, 29]

Aucune des excitations tirées sans filtrage ne touche l'ensemble occupé (0-6 et 28-34), si bien qu'aucune ne peut déplacer une particule entre un mode occupé et un mode inoccupé. Avec le filtrage, les cinq sont rejetées et remplacées par des excitations qui couplent bien un mode occupé à un mode inoccupé. Par exemple, la première excitation acceptée [0, 1, 6, 7] déplace une particule entre les modes occupés 0, 1 et 6 et le mode inoccupé 7.

Dès qu'une excitation est acceptée, chaque mode de son support devient « incertain » et peut donc jouer l'un ou l'autre rôle pour les échantillons suivants ; les ensembles de modes occupés et inoccupés continuent ainsi de croître à mesure que d'autres excitations sont acceptées. C'est ce qui rend acceptable la deuxième excitation, [0, 1, 28, 29]. Ses quatre modes figurent parmi ceux qui étaient à l'origine occupés, elle ne se couple donc à aucun mode initialement inoccupé. Elle n'est acceptée que parce que les modes 0 et 1 sont devenus incertains (et donc éligibles comme côté « inoccupé » du couplage) une fois touchés par la première excitation.

Cette croissance fait de la règle une sur-approximation plutôt qu'un test : à mesure que davantage de modes deviennent incertains, moins de tirages sont rejetés, et c'est sur les premiers tirages que le filtrage agit le plus sur la distribution d'échantillonnage.

Pour juger de combien le filtrage a déplacé la distribution, lis les comptes de rejets que la passe enregistre dans les métadonnées du circuit. Une entrée est stockée par porte Evolution filtrée, et le rapport acceptées sur total estime la probabilité d'acceptation, qui est le facteur par lequel les excitations retenues ont été surpondérées :

>>> # a freshly seeded pass, so the counts do not depend on earlier draws
>>> qdrift_counted = QDriftTrotterization(
... FermionicQDrift(num_groups, rng=3480)
... )
>>> filtered = FermionicPassManager(qdrift_counted).run(hf_circ)
>>> discarded = filtered.metadata.get("filter_trivial.discarded")
>>> emitted = filtered.metadata.get("filter_trivial.emitted")
>>> print(f"{emitted[0]} kept, {discarded[0]} discarded")
5 kept, 7 discarded
>>> print(f"acceptance probability: {emitted[0] / (emitted[0] + discarded[0]):.2f}")
acceptance probability: 0.42
Note

Le filtrage a besoin d'informations d'occupation sur lesquelles filtrer. Une porte PrepareSlaterDeterminant les fournit tout comme InitializeModes, et toute OrbitalRotation marque comme « incertain » chaque mode sur lequel elle agit. En l'absence de toutes ces portes, ou lorsque les modes initialisés tombent tous du même côté (tous occupés, ou tous inoccupés), la passe émet un UserWarning et laisse l'échantillonnage non filtré pour cette porte Evolution. Sur un hamiltonien dont les termes restants ne peuvent plus coupler les deux ensembles suivis, la passe lève à la place une RuntimeError après QDriftTrotterization.MAX_SAMPLE_RETRIES rejets consécutifs.

(Facultatif) Précalculer les poids d'échantillonnage​

N'y recours que si la transpilation elle-même est un goulot d'étranglement. Cela ne change rien aux circuits que tu obtiens.

FermionicQDrift tire chaque groupe avec une probabilité proportionnelle à la magnitude de son coefficient, et déduit ces magnitudes de l'hamiltonien à chaque appel : une valeur par terme de l'hamiltonien, réduite à une par groupe. Comme la méthode est sans état, elle répète cette réduction pour chaque randomisation, bien que le résultat soit identique à chaque fois. L'argument weights te permet de la calculer une seule fois avec group_coeff_means() et de transmettre le résultat, ce qui sort le coût de la boucle tout en laissant la méthode exempte de tout état mis en cache dépendant de l'hamiltonien.

C'est la contrepartie de l'ordonnancement des termes de l'étape 2. Celui-ci sort de la boucle la recherche de groupe, celui-ci la réduction des poids, et chacun n'est sensible que dans la mesure où l'autre est absent ; il vaut donc la peine de les appliquer ensemble. Puisque canon a déjà été réordonné plus haut, les deux se composent directement.

Les poids décrivent les groupes d'un opérateur particulier ; ils appartiennent donc à la porte Evolution de cet opérateur : construis la méthode pour cette porte et transmets-la comme synthesis de la porte, exactement comme à l'étape 3.

>>> from qiskit_fermions.operators.terms.grouping import group_coeff_means
>>>
>>> weights = group_coeff_means(canon) # `canon` is group-ordered, from step 2
>>> qdrift_weighted = FermionicQDrift(10, rng=19, weights=weights)
>>> weighted_gate = Evolution(num_modes, canon, time, synthesis=qdrift_weighted)
>>>
>>> weighted_circ = FermionicCircuit(num_modes)
>>> weighted_circ.append(weighted_gate, weighted_circ.modes)
>>>
>>> weighted_circuits = [
... pm.run(weighted_circ) for _ in range(num_sqdrift_randomizations)
... ]
>>> len(weighted_circuits)
10

Comme ce sont exactement les poids que la passe aurait calculés, les circuits sont les mêmes qu'avant ; seule la réduction répétée a disparu.

Une entrée est attendue par groupe, et non par terme, chaque fois que l'opérateur porte des groupes, puisqu'un opérateur groupé est échantillonné groupe par groupe. Les entrées doivent être non négatives : un poids est une magnitude, car le protocole qDRIFT décompose H=∑jhjHjH = \sum_j h_j H_j avec des hjh_j positifs, et le signe d'un coefficient appartient à HjH_j, où la passe le lit directement sur l'opérateur.

Attention

Les poids sont des magnitudes absolues, pas des préférences relatives. Leur somme fixe aussi le temps d'évolution, de sorte que multiplier chaque entrée par γ\gamma échantillonne de façon identique mais évolue pendant γt\gamma t au lieu de tt. Passer autre chose que les magnitudes de coefficients propres à l'hamiltonien change donc l'évolution que l'ensemble approxime, c'est pourquoi il s'agit d'un outil de performance et non d'un moyen de régler l'échantillonnage.

Comme les poids appartiennent à la porte dont ils décrivent l'opérateur, un circuit peut contenir plusieurs portes Evolution, chacune avec les poids de son propre opérateur. La passe QDriftTrotterization de l'étape 5 les respecte de la même façon : elle tire pour une porte avec le FermionicQDrift que porte cette porte, et se rabat sur celui avec lequel elle a été construite pour les portes qui n'en portent aucun. Un autre type de méthode de synthèse sur une porte n'est pas une déclaration sur qDRIFT, la passe l'ignore donc. Donner la méthode à une passe par nom de porte à la place, comme dans F2FSynthesis({"Evolution": ...}), applique une seule méthode à chaque porte Evolution, de sorte que des poids passés ainsi n'ont de sens que pour un circuit n'en comportant qu'une. La longueur des poids est vérifiée par rapport à chaque porte à laquelle ils sont appliqués.

(Facultatif) Optimiser l'indexation des modes fermioniques​

Tu peux ajouter au pipeline de transpilation une étape d'optimisation supplémentaire qui minimise la distance des étendues d'excitation fermioniques en réétiquetant les indices des modes fermioniques. Cette optimisation a été introduite dans l'article SqDRIFT et est implémentée par build_excitation_span_minimization_model(). Elle peut être facilement insérée dans le pipeline du transpileur grâce à la passe RelabelModes :

>>> from pyomo.environ import SolverFactory
>>> from qiskit_fermions.transpiler.passes import RelabelModes
>>>
>>> solver = SolverFactory("appsi_highs")
>>> solver.options["time_limit"] = 10
>>>
>>> qdrift = F2FSynthesis({"Evolution": FermionicQDrift(5, rng=19)})
>>> relabel = RelabelModes(solver=solver)
>>>
>>> pm.optimization = FermionicPassManager([qdrift, relabel])
>>>
>>> relabeled_circ = pm.run(circ)
>>> # if the automatic mode relabeling was successful, the circuit's
>>> # metadata will contain the mode `permutation` information
Note

L'utilisation de l'optimisation automatique dans RelabelModes (qui s'appuie sur build_excitation_span_minimization_model()) nécessite la dépendance facultative gérée par HAS_PYOMO.

Important

Le réétiquetage change le mode que porte un qubit donné ; la diagonalisation de sous-espace n'obtient donc les bonnes chaînes de bits que si cela est pris en compte. L'endroit où cette prise en compte a lieu dépend de l'endroit où tu mesures.

Ajoute les mesures au FermionicCircuit avec FermionicCircuit.measure_all() avant la transpilation, et il n'y a rien à faire : chaque mesure nomme le mode qu'elle lit, le réétiquetage la porte donc avec lui et les chaînes de bits échantillonnées sont indexées par le mode d'origine.

Si tu mesures plutôt le QuantumCircuit transpilé, les chaînes de bits doivent être post-traitées à l'aide de la permutation enregistrée dans les métadonnées du circuit. Consulte la documentation de RelabelModes pour la marche à suivre.

Prochaines étapes​

Maintenant que tu as généré avec succès un ensemble de circuits, tu peux en échantillonner des chaînes de bits. Pour cela, les circuits doivent être exécutés sur du matériel. Consulte la documentation Qiskit pour des instructions détaillées.

Une fois les échantillons de chaînes de bits obtenus, ils peuvent être utilisés avec les coefficients de l'hamiltonien pour effectuer le post-traitement SQD, comme expliqué dans les tutoriels de l'addon SQD.