Mapper les représentations d'opérateurs
Les mappers sont des routines qui transforment des opérateurs d'une représentation à une autre tout en préservant l'équivalence mathématique. Utilise-les pour travailler avec souplesse sur différentes algèbres d'opérateurs et pour préparer des opérateurs à l'exécution de circuits quantiques.
Implémenter des mappers personnalisés
Le module des mappers permet d'implémenter facilement des mappings personnalisés. Les mappers fonctionnent en transformant les actions fondamentales (création, annihilation, Majorana, etc.) qui composent un opérateur, puis en combinant ces actions transformées selon les règles de la représentation cible.
À titre d'exemple concret, considère une transformation de Jordan-Wigner qui associe un MajoranaOperator à un opérateur de qubits à l'aide de map_majorana_action_generators() :
- PYTHON
- C
>>> from qiskit.quantum_info import SparseObservable
>>> from qiskit_fermions.mappers import map_majorana_action_generators
>>> from qiskit_fermions.operators import MajoranaOperator
>>>
>>> # Create a Majorana operator
>>> maj_op = MajoranaOperator.from_dict({(0, 1): 1.0, (2,): 0.5})
>>>
>>> # Define how each Majorana action maps to Pauli strings
>>> num_qubits = 2
>>> def map_action(mode: int) -> SparseObservable:
... idx = mode // 2
... qubits = list(range(idx + 1))
... pauli = "Y" if mode % 2 else "X"
... return SparseObservable.from_sparse_list(
... [("Z" * idx + pauli, qubits, 1.0)],
... num_qubits=num_qubits,
... )
>>>
>>> # Apply the mapping to transform the operator
>>> custom_observable = map_majorana_action_generators(
... maj_op,
... map_action,
... identity=lambda: SparseObservable.identity(num_qubits),
... compose=SparseObservable.compose,
... )
>>> print(custom_observable.simplify())
<SparseObservable with 2 terms on 2 qubits: (0+1j)(Z_0) + (0.5+0j)(X_1 Z_0)>
// The C API provides direct mapping functions for common transformations.
// Custom mapper prototyping as shown in Python is primarily a Python feature.
// For custom mappings in C, you will need to perform the iteration and
// conversion logic entirely by yourself.
Les étapes clés pour implémenter un mapper personnalisé sont les suivantes :
-
Définir la transformation des actions : précise comment chaque action (indexée par mode) est associée à la représentation cible. Dans l'exemple ci-dessus, les actions de Majorana sont associées à des chaînes de Pauli selon les règles de Jordan-Wigner.
-
Utiliser l'une des fonctions de mapping fournies : le module
mappersfournit des fonctions utilitaires qui gèrent l'itération sur les termes de l'opérateur, puis l'application de la correspondance d'actions personnalisée pour les représentations d'opérateurs fournies par le moduleoperators. Par exemple,map_majorana_action_generators()fonctionne avec les opérateurs de Majorana. -
Obtenir le résultat : la fonction de mapping renvoie un opérateur dans la représentation cible, avec la transformation appliquée de façon cohérente à tous les termes.
Cette conception facilite le prototypage de mappings personnalisés sans connaître en profondeur les structures internes des opérateurs. La transformation est appliquée automatiquement à chaque terme de l'opérateur tout en préservant l'équivalence mathématique.
Tu peux aussi itérer manuellement sur les termes d'un opérateur au lieu d'utiliser l'une des fonctions d'itération fournies. Consulte la section sur l'itération sur les termes dans le guide des opérateurs pour plus de détails.
Implémentations de la bibliothèque
Le module qiskit_fermions.mappers.library fournit des implémentations efficaces et rigoureusement testées des mappings courants. Elles suivent le même schéma sous-jacent que les mappers personnalisés, mais sont optimisées pour un usage en production et disponibles à la fois dans les API Python et C.
L'exemple ci-dessous applique un mapper de la bibliothèque au même MajoranaOperator que dans la section sur les mappers personnalisés, et montre des résultats équivalents :
- PYTHON
- C
>>> from qiskit_fermions.mappers.library import majorana_to_fermion, jordan_wigner
>>>
>>> # Convert MajoranaOperator to FermionOperator using library mapper
>>> ferm_op = majorana_to_fermion(maj_op)
>>>
>>> # Apply Jordan-Wigner mapper to get qubit operator
>>> sparse_observable = jordan_wigner(ferm_op, num_qubits=2)
>>> difference = sparse_observable - custom_observable
>>> print(difference.simplify() == SparseObservable.zero(num_qubits))
True
#include <qiskit_fermions.h>
// Create a MajoranaOperator with the same terms as maj_op above
uint64_t num_terms = 2;
uint64_t num_modes = 3;
uint32_t modes[3] = {0, 1, 2};
QkComplex64 coeffs[2] = {{1.0, 0.0}, {0.5, 0.0}};
uint32_t boundaries[3] = {0, 2, 3};
QfMajoranaOperator *maj_op = qf_maj_op_new(num_terms, num_modes, coeffs, modes, boundaries);
// Convert MajoranaOperator to FermionOperator using library mapper
QfFermionOperator *ferm_op = qf_majorana_to_fermion(maj_op);
// Apply Jordan-Wigner mapper to get qubit operator
QkObs *qubit_op;
QfExitCode exit = qf_ferm_op_jordan_wigner(ferm_op, 2, &qubit_op);
assert(exit == QfExitCode_Success);
// Clean up
qf_ferm_op_free(ferm_op);
qf_maj_op_free(maj_op);
qk_obs_free(qubit_op);
Les deux étapes ci-dessus sont détaillées pour montrer la composition de deux mappers de la bibliothèque. En pratique, tu associerais plutôt un MajoranaOperator en une seule étape, avec majorana_jordan_wigner() (qf_maj_op_jordan_wigner() dans l'API C), et de même pour les autres types d'opérateurs – edge_vertex_jordan_wigner() et transfer_vertex_jordan_wigner() (qf_edge_op_jordan_wigner(), qf_transfer_op_jordan_wigner()). Passer par un FermionOperator gonfle aussi le résultat intermédiaire : chaque générateur de ces algèbres est associé à une seule chaîne de Pauli, alors que chaque action fermionique est associée à une somme de deux termes, de sorte qu'un terme construit à partir de plusieurs générateurs se développe pour être ensuite refusionné. Ce gain augmente avec la longueur des termes ; pour les termes à générateur unique d'un hamiltonien de saut, les deux voies coûtent à peu près autant.
Dans l'API Python, certains de ces mappers de la bibliothèque sont également accessibles via des mappers pratiques indépendants du type. fermion_operator() et majorana_operator() se placent devant majorana_to_fermion() et fermion_to_majorana() (entre autres), en déléguant en interne à l'implémentation de bibliothèque appropriée selon le type d'objet reçu, au lieu de te demander de choisir toi-même la bonne fonction. Il en va de même pour jordan_wigner() et pour ternary_tree() (présenté plus bas), qui redirigent chacun vers l'implémentation directe correspondant à l'opérateur reçu. Consulte qiskit_fermions.protocols pour savoir comment ce dispatch est implémenté en interne.
Choisir un encodage avec des arbres ternaires
Jordan-Wigner n'est pas le seul encodage fermion-vers-qubit, et ce n'est pas le moins coûteux. Ses chaînes s'allongent avec l'indice du mode, de sorte qu'un opérateur de Majorana sur le dernier de modes est associé à une chaîne de Pauli de poids . Les encodages qui répartissent l'information de parité dans un arbre plutôt que dans une chaîne font asymptotiquement mieux.
Un arbre ternaire à nœuds capture toute cette famille. Chaque nœud est un qubit portant trois liens descendants étiquetés X, Y et Z ; un lien descend soit vers un nœud enfant (une arête), soit se termine (une feuille). Le chemin de chaque feuille jusqu'à la racine épelle une chaîne de Pauli : traverser le lien étiqueté qui descend depuis le nœud contribue par sur le qubit . Chaque arbre de ce type donne un encodage valide, et les encodages familiers ne sont que des formes particulières – une chaîne le long de Z est Jordan-Wigner, tandis qu'un arbre dans lequel chaque nœud a trois enfants, autant que le nombre de nœuds le permet, est équilibré.
- PYTHON
- C
>>> from qiskit_fermions.mappers import TernaryTree, TernaryTreeEncoding
>>>
>>> # Jordan-Wigner is a chain descending along Z, with X and Y as legs at every node.
>>> TernaryTree.chain(13, "Z").max_weight
13
>>> # The balanced tree spreads the same information over depth log_3(2n+1).
>>> TernaryTree.breadth_first(13, 3).max_weight
3
#include <qiskit_fermions.h>
QfTernaryTree *chain;
qf_ternary_tree_chain(13, QfPauliLabel_Z, &chain);
QfPauliLabel order[3] = {QfPauliLabel_X, QfPauliLabel_Y, QfPauliLabel_Z};
QfTernaryTree *balanced;
qf_ternary_tree_breadth_first(13, 3, order, &balanced);
assert(qf_ternary_tree_max_weight(chain) == 13);
assert(qf_ternary_tree_max_weight(balanced) == 3);
qf_ternary_tree_free(chain);
qf_ternary_tree_free(balanced);
Ce poids maximal est optimal. Jiang et al. démontrent que les opérateurs de Majorana de n'importe quel mapping fermion-vers-qubit de modes ont un poids de Pauli moyen d'au moins , de sorte qu'aucun mapping ne peut ramener le plus grand d'entre eux en dessous de . Les deux arbres utilisent exactement qubits : le gain porte donc sur le poids de Pauli, pas sur le nombre de qubits.
Pour associer un opérateur, compile un arbre en un encodage et transmets-le à un mapper avec le nombre de qubits du résultat. La compilation apparie les feuilles de l'arbre en opérateurs de Majorana et construit leurs chaînes de Pauli une seule fois ; le même encodage doit donc être réutilisé pour de nombreux opérateurs. Le nombre de qubits reste attaché à l'appel de mapping plutôt qu'à l'encodage, afin qu'un même encodage serve des registres de largeurs différentes ; un nombre supérieur au nombre de nœuds de l'arbre complète avec l'identité.
- PYTHON
- C
>>> from qiskit_fermions.mappers.library import fermion_ternary_tree
>>> from qiskit_fermions.operators import FermionOperator
>>>
>>> hamiltonian = FermionOperator.from_dict(
... {((True, 0), (False, 3)): 1.0, ((True, 3), (False, 0)): 1.0}
... )
>>> encoding = TernaryTreeEncoding(TernaryTree.breadth_first(4, 3))
>>> fermion_ternary_tree(hamiltonian, encoding, 4).simplify()
<SparseObservable with 2 terms on 4 qubits: (0.5+0j)(Y_3 Z_1 Y_0) + (0.5+0j)(X_3 Z_2 X_0)>
// Create the hopping Hamiltonian a^dagger_0 a_3 + a^dagger_3 a_0
QfFermionOperator *hamiltonian = qf_ferm_op_zero();
QkComplex64 coeffs[2] = {{1.0, 0.0}, {1.0, 0.0}};
bool actions[4] = {true, false, true, false};
uint32_t indices[4] = {0, 3, 3, 0};
for (int i = 0; i < 2; i++) {
qf_ferm_op_add_term(hamiltonian, 2, actions + 2 * i, indices + 2 * i, &coeffs[i]);
}
QfPauliLabel order[3] = {QfPauliLabel_X, QfPauliLabel_Y, QfPauliLabel_Z};
QfTernaryTree *tree;
qf_ternary_tree_breadth_first(4, 3, order, &tree);
// Compile the tree once, then reuse the encoding for every operator mapped through it.
QfTernaryTreeEncoding *encoding;
qf_ternary_tree_encoding_new(tree, NULL, &encoding);
QkObs *qubit_op;
QfExitCode exit = qf_ferm_op_ternary_tree(hamiltonian, encoding, 4, &qubit_op);
assert(exit == QfExitCode_Success);
// Clean up
qf_ferm_op_free(hamiltonian);
qf_ternary_tree_encoding_free(encoding);
qf_ternary_tree_free(tree);
qk_obs_free(qubit_op);
Le mapping à travers une chaîne le long de Z reproduit exactement fermion_jordan_wigner() (qf_ferm_op_jordan_wigner() dans l'API C), ce qui est le sens dans lequel il le généralise :
>>> from qiskit_fermions.mappers.library import fermion_jordan_wigner
>>> chain = TernaryTreeEncoding(TernaryTree.chain(4, "Z"))
>>> direct = fermion_jordan_wigner(hamiltonian, 4)
>>> (fermion_ternary_tree(hamiltonian, chain, 4) - direct).simplify().num_terms
0
Chaque représentation d'opérateur dispose d'un mapper à arbre ternaire, qui reflète l'ensemble Jordan-Wigner ; consulte qiskit_fermions.mappers.library pour la liste complète, ainsi que Mapper Library dans l'API C. Dans l'API Python, ils sont aussi accessibles via ternary_tree(), l'enveloppe indépendante du type qui choisit le bon mapper pour l'opérateur reçu, exactement comme le fait jordan_wigner() :
>>> from qiskit_fermions.mappers.library import ternary_tree
>>> ternary_tree(hamiltonian, encoding, 4).simplify()
<SparseObservable with 2 terms on 4 qubits: (0.5+0j)(Y_3 Z_1 Y_0) + (0.5+0j)(X_3 Z_2 X_0)>
Certaines identités valables sous Jordan-Wigner sont des propriétés de la chaîne et non des arbres ternaires. L'opérateur nombre vaut uniquement sur la chaîne ; sur un autre arbre, le devient une chaîne sur un ensemble de qubits. De même, un opérateur de sommet est le Pauli de poids 1 uniquement sur la chaîne, où les chaînes des deux chaînes de Majorana s'annulent.
Comme chaque opérateur nombre est toujours associé à une seule chaîne , un état de Fock reste un état de la base computationnelle sous n'importe quel arbre ternaire, mais le bit ne correspond plus à l'occupation . L'occupation est en fait la parité des bits mesurés sur le support de l'image de . Lis ces supports à partir du mapper plutôt que de les déduire de l'arbre, afin que le décodage reste correct pour toute forme ou permutation de modes :
>>> for mode in range(4):
... number = FermionOperator.from_dict({((True, mode), (False, mode)): 1.0})
... print(mode, ternary_tree(number, encoding, 4).simplify())
0 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_2 Z_1 Z_0)>
1 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_1)>
2 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_2)>
3 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_3)>
Sur cet arbre équilibré, le mode 0 est occupé lorsque les qubits 0, 1 et 2 contiennent un nombre impair de uns ; le préparer revient donc à inverser le qubit 0 seul, alors que décoder une chaîne de bits mesurée demande la parité sur les trois. Une seule erreur d'inversion de bit sur le qubit 1 corrompt alors les occupations du mode 0 et du mode 1, ce qui importe pour tout flux de travail qui lit des occupations à partir d'échantillons bruités.
Lorsqu'une Evolution est abaissée via un tel mapper, enveloppe le mapper dans group_wise() (voir Synthétiser une porte Evolution dans l'espace fermionique). Un terme de saut est associé à deux chaînes de Pauli qui commutent, dont aucune ne conserve le nombre de particules à elle seule ; seul leur produit dans l'évolution le conserve. Sous Jordan-Wigner, les deux chaînes agissent sur les mêmes qubits, de sorte qu'un ordre de termes simplifié les garde adjacentes. Sous d'autres arbres, leurs supports diffèrent (comme pour Y_3 Z_1 Y_0 et X_3 Z_2 X_0 plus haut), les ordres brut et simplifié les entrelacent tous deux avec les images d'autres termes, et une formule de produit côté qubits fait alors fuir le nombre de particules.
Construire un arbre arbitraire
Les constructeurs de chaîne et de parcours en largeur couvrent les familles uniformes, mais l'interface générale prend une paire (parent, label) par nœud, avec None pour la racine (l'API C prend à la place l'indice de la racine). Cela compte, car les arbres qui valent la peine d'être recherchés ne sont généralement ni une chaîne ni uniformes – l'algorithme Bonsai, par exemple, fait croître un arbre couvrant du graphe de couplage d'un dispositif, de sorte que ses ramifications suivent cette connectivité.
- PYTHON
- C
>>> # Node 0 branches three ways, node 1 twice, and node 4 carries a single child.
>>> tree = TernaryTree([None, (0, "X"), (0, "Y"), (0, "Z"), (1, "X"), (1, "Z"), (4, "Y")])
>>> tree.num_nodes, tree.num_legs, tree.max_weight
(7, 15, 4)
>>> tree.children(0)
{'X': 1, 'Y': 2, 'Z': 3}
// Node 0, the root, branches three ways, node 1 twice, and node 4 carries a single child.
// The root has no parent and no incoming link, so its entries are never read: any value works.
#define IGNORED 0
uint32_t parents[7] = {IGNORED, 0, 0, 0, 1, 1, 4};
QfPauliLabel labels[7] = {
IGNORED, QfPauliLabel_X, QfPauliLabel_Y, QfPauliLabel_Z,
QfPauliLabel_X, QfPauliLabel_Z, QfPauliLabel_Y,
};
QfTernaryTree *tree;
QfExitCode exit = qf_ternary_tree_new(7, 0, parents, labels, &tree);
assert(exit == QfExitCode_Success);
assert(qf_ternary_tree_num_nodes(tree) == 7);
assert(qf_ternary_tree_num_legs(tree) == 15);
assert(qf_ternary_tree_max_weight(tree) == 4);
qf_ternary_tree_free(tree);
Remarque le nombre de feuilles : il vaut toujours , quelle que soit la forme. Un nœud avec moins d'enfants ne génère pas moins de chaînes de Pauli – ses liens inutilisés sont des feuilles, et une feuille épelle une chaîne tout comme les descendants d'une arête. Parmi ces chaînes, s'apparient en opérateurs de Majorana des modes, et il en reste exactement une.
Parité totale
Cette feuille restante est la parité fermionique totale . C'est une propriété de l'encodage plutôt que le mapping d'un opérateur quelconque ; c'est donc une méthode de l'encodage lui-même :
- PYTHON
- C
>>> TernaryTreeEncoding(TernaryTree.chain(4, "Z")).total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3 Z_2 Z_1 Z_0)>
>>> TernaryTreeEncoding(TernaryTree.breadth_first(4, 3)).total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3 Z_0)>
QfTernaryTree *tree;
qf_ternary_tree_chain(4, QfPauliLabel_Z, &tree);
QfTernaryTreeEncoding *encoding;
qf_ternary_tree_encoding_new(tree, NULL, &encoding);
// On the chain this is the full Z string; on the balanced tree it is not.
QkObs *parity;
qf_ternary_tree_encoding_total_parity(encoding, 4, &parity);
qf_ternary_tree_encoding_free(encoding);
qf_ternary_tree_free(tree);
qk_obs_free(parity);
Comme le montre le cas équilibré, ce n'est pas en général la chaîne entièrement en : c'est la feuille atteinte par le chemin entièrement en , qui agit comme l'identité sur tout nœud ne se trouvant pas sur ce chemin.
La parité totale est ici une observable à mesurer, et non une contrainte à imposer. Ses deux valeurs propres sont physiques ; ne projette donc pas un état sur le secteur : un encodage à arbre ternaire dépense un qubit par mode et est surjectif sur tout l'espace de dimension , sans laisser de sous-espace non physique à écarter par projection.
Exemple détaillé : un arbre de Bravyi-Kitaev
Les spécifications sont plus souvent calculées que saisies à la main, car l'arbre d'un encodage réel est rarement assez petit pour être écrit manuellement. Bravyi-Kitaev en est un bon exemple : il appartient à cette famille, mais son arbre n'est pas uniforme, si bien qu'aucun constructeur pratique ne le couvre.
Son arbre est construit par une scission récursive sur les nœuds, un par mode. Dans une plage de nœuds , soit la plus grande puissance de deux qui tient ; le nœud placé pour cette plage est , le dernier de ce bloc. Son lien X descend vers l'arbre construit à partir du bloc qui le précède et son lien Z vers l'arbre construit à partir du reste qui le suit, son lien Y restant une feuille :
>>> def bravyi_kitaev_tree(num_modes: int) -> TernaryTree:
... """Builds the ternary tree of the Bravyi-Kitaev encoding on ``num_modes`` modes."""
... spec: list[tuple[int, str] | None] = [None] * num_modes
... def build(left: int, right: int) -> int | None:
... if left > right:
... return None # an empty range: the parent's link stays a leg
... block = 1
... while 2 * block <= right - left + 1:
... block *= 2
... node = left + block - 1
... for child, label in (
... (build(left, node - 1), "X"), # the block before this node
... (build(node + 1, right), "Z"), # the remainder after it
... ):
... if child is not None:
... spec[child] = (node, label)
... return node
... build(0, num_modes - 1)
... return TernaryTree(spec)
Avec quatre modes, toute la plage forme un seul bloc ; le nœud placé en premier est donc le 3, avec avant lui et rien après ; cette sous-plage donne le nœud 1 au-dessus des nœuds isolés 0 et 2. La compilation de l'arbre confirme que l'encodage est bien Bravyi-Kitaev, et pas seulement un arbre de la même classe de forme – sa première image de Majorana est :
>>> tree = bravyi_kitaev_tree(4)
>>> tree.children(1)
{'X': 0, 'Z': 2}
>>> encoding = TernaryTreeEncoding(tree)
>>> encoding.majorana_image(0)
[(0, 'X'), (1, 'X'), (3, 'X')]
Sa parité totale rend concret le propos de la section précédente : la feuille restante est celle atteinte par le chemin entièrement en , et ici ce chemin s'arrête à la racine, de sorte que l'observable est le à un seul qubit plutôt qu'une chaîne sur les quatre qubits :
>>> encoding.total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3)>
Passer à l'échelle ne demande rien d'autre qu'un argument plus grand. Comme aucun nœud ne dépense jamais son lien Y sur une arête, chacun se ramifie en deux au plus, de sorte que le poids de Pauli croît de façon logarithmique plutôt que linéaire :
>>> [bravyi_kitaev_tree(n).max_weight for n in (4, 8, 16, 32, 64)]
[3, 4, 5, 6, 7]