Mapper les représentations d'opérateurs
Un mapper transforme un BosonOperator en un opérateur de qubits, ici un SparseObservable. L'opérateur lui-même ne sait rien des qubits : il stocke des opérateurs d'échelle sur des modes, et un mode bosonique peut contenir un nombre arbitraire de particules. Tout ce qui rend l'opérateur fini (le nombre de niveaux d'occupation que tu conserves et la façon dont ces niveaux sont disposés sur les qubits) est décidé au moment du mapping.
Le mapper de la bibliothèque occupation_encoding() travaille dans la base d'occupation (Fock) : le niveau d'un mode est l'état à bosons. Il prend l'opérateur et un LevelSpec, qui contient la troncature et l'encodage. Ce guide explique ce que signifient ces deux choix et ce que chacun coûte. Pour un parcours de bout en bout qui les utilise, consulte Simuler la dynamique de Bose-Hubbard avec un choix d'encodage ; pour voir comment le transpileur applique les mêmes choix à un circuit entier, consulte Transpiler des circuits bosoniques.
Tronquer l'espace de Fock
Un mode limité à niveaux, où est son max_occupation, n'obéit plus à la relation de commutation canonique. En notant l'opérateur d'annihilation tronqué à ces niveaux,
la relation est donc vérifiée à tous les niveaux sauf le plus élevé [1]. Tu peux le constater en mappant le commutateur des deux opérateurs d'échelle et en lisant sa diagonale sur les niveaux d'un mode. La fonction auxiliaire suivante construit la matrice dense d'un opérateur mappé pour un seul mode, ce qui n'est raisonnable que pour de très petits registres :
>>> import numpy as np
>>> from qiskit.quantum_info import SparsePauliOp
>>> from qiskit_bosons.mappers import LevelEncoding, LevelSpec
>>> from qiskit_bosons.mappers.library import occupation_encoding
>>> from qiskit_bosons.operators import BosonOperator, ann, cre
>>>
>>> def level_diagonal(operator, max_occupation, encoding):
... """The diagonal of a mapped one-mode operator on the codewords of the levels."""
... spec = LevelSpec(max_occupation, encoding)
... matrix = SparsePauliOp.from_sparse_observable(occupation_encoding(operator, spec)).to_matrix()
... codewords = [spec.encoding.level_to_bits(level) for level in range(max_occupation + 1)]
... return [round(float(matrix[c, c].real), 6) for c in codewords]
>>>
>>> b = BosonOperator.from_dict({(ann(0),): 1.0})
>>> b_dagger = BosonOperator.from_dict({(cre(0),): 1.0})
>>> commutator = (b @ b_dagger) - (b_dagger @ b)
>>> level_diagonal(commutator, 2, "unary_unprojected") # d = 3
[1.0, 1.0, -2.0]
La dernière entrée vaut . Comme cela signifie que le sous-espace engendré par n'est pas fermé sous le commutateur, aucun encodage n'est un isomorphisme d'algèbres. Un encodage met en correspondance des états, ainsi que les opérateurs qui agissent dans le sous-espace tronqué. Les deux conséquences suivantes comptent en pratique.
La coupure doit borner l'occupation atteignable. Il ne suffit pas de borner l'occupation de départ. Un séparateur de faisceau (beamsplitter) agissant sur peuple et , de sorte qu'une coupure à un produit silencieusement un état faux :
>>> from scipy.linalg import expm
>>>
>>> beamsplitter = BosonOperator.from_dict({(cre(0), ann(1)): 1.0, (cre(1), ann(0)): 1.0})
>>>
>>> def stays_in_11(max_occupation):
... """Probability to remain in |1, 1> after a 50:50 beamsplitter."""
... spec = LevelSpec(max_occupation, "gray").resolve(2)
... observable = occupation_encoding(beamsplitter, spec, spec.total_width)
... matrix = SparsePauliOp.from_sparse_observable(observable).to_matrix()
... unitary = expm(-1j * (np.pi / 4) * matrix)
... index = sum(spec.encoding.level_to_bits(1) << offset for offset in spec.offsets)
... return round(float(abs(unitary[index, index]) ** 2), 6)
>>>
>>> stays_in_11(1), stays_in_11(2)
(1.0, 0.0)
Avec une coupure à un, la probabilité de rester dans vaut un, car les deux états que la dynamique peuplerait n'existent pas. Avec une coupure à deux, elle s'annule, c'est l'effet Hong-Ou-Mandel. C'est pourquoi max_occupation est un argument obligatoire sans valeur par défaut : c'est une décision de modélisation et un paramètre de convergence que tu es censé balayer.
Le mapping ne commute pas avec la mise en ordre normal. Les opérateurs et sont égaux avant la troncature, mais ils sont mappés vers des opérateurs de qubits différents :
>>> product = BosonOperator.from_dict({(ann(0), cre(0)): 1.0})
>>> ordered = BosonOperator.from_dict({(cre(0), ann(0)): 1.0}) + BosonOperator.one()
>>> bool(product.equiv(ordered))
True
>>> level_diagonal(product, 3, "unary_unprojected") # d = 4
[1.0, 2.0, 3.0, 0.0]
>>> level_diagonal(ordered, 3, "unary_unprojected")
[1.0, 2.0, 3.0, 4.0]
Les deux concordent à tous les niveaux sauf le plus élevé. Appliqué au niveau supérieur, n'a nulle part où aller et l'amplitude est écrêtée, de sorte que y est nul, alors que donne . Décide une fois pour toutes si ton hamiltonien est défini sous forme normalement ordonnée, et mappe cette forme de manière cohérente.
Trois méthodes t'aident à choisir une coupure, et toutes donnent des bornes inférieures. L'opérateur indique le plus grand nombre d'opérateurs de création qu'il applique à chaque mode au sein d'un même terme, et un spec compare cela à ses coupures :
>>> quartic = BosonOperator.from_dict({(cre(0), cre(0), ann(0), ann(0)): 1.0})
>>> quartic.mode_occupations(), quartic.max_occupation()
([2], 2)
>>> LevelSpec(1, "gray").truncation(quartic) # one level short
[1]
>>> LevelSpec(2, "gray").truncation(quartic)
[0]
Une entrée non nulle signifie que la coupure est inférieure à la borne, et la valeur indique de combien. Ces fonctions auxiliaires ne connaissent pas la dynamique : réussir la vérification ne rend donc pas une coupure suffisante ; le séparateur de faisceau ci-dessus a une borne de un mais exige une coupure de deux. Elles peuvent aussi signaler un déficit pour un terme nul sur les niveaux conservés, comme l'est pour une coupure de un.
Décrire la troncature avec un LevelSpec
Un LevelSpec regroupe la coupure de chaque mode avec l'encodage, et en déduit combien de qubits chaque mode occupe et où commencent ses qubits. Tout ce qui doit être cohérent sur ce point (le mapper, la disposition du transpileur, la préparation d'état) reçoit le même spec. L'encodage est un argument obligatoire, tout comme la coupure : aucun des deux n'a de valeur par défaut.
La coupure est soit un entier unique, partagé par tous les modes, soit un entier par mode :
>>> shared = LevelSpec(2, "gray")
>>> per_mode = LevelSpec([4, 2, 2], "gray")
>>> shared.num_modes is None, per_mode.num_modes
(True, 3)
Un spec construit à partir d'un seul entier laisse ouvert le nombre de modes ; tu dois donc le résoudre avant de demander les largeurs. Une séquence n'est jamais diffusée, quelle que soit sa longueur, et elle fixe le nombre de modes :
>>> per_mode.widths, per_mode.offsets, per_mode.total_width
([3, 2, 2], [0, 3, 5], 7)
>>> shared.resolve(3).widths
[2, 2, 2]
>>> try:
... shared.widths
... except ValueError as error:
... print(error)
this spec was built from a single cutoff, so its number of modes is open; call `resolve(num_modes)` first
Le mode occupe les qubits offsets[m] à offsets[m] + widths[m] - 1, de sorte que des modes ayant des coupures différentes peuvent occuper des nombres de qubits différents, et les décalages sont la somme cumulée des largeurs. Tu n'as pas besoin de passer le nombre de modes au mapper. Il le prend du spec ou, pour un spec à coupure unique, de l'opérateur via min_num_modes(). Le registre peut être plus large que ce dont les modes ont besoin ; les qubits excédentaires sont inactifs et se trouvent à la fin :
>>> number = BosonOperator.from_dict({(cre(0), ann(0)): 1.0})
>>> number.min_num_modes()
1
>>> occupation_encoding(number, LevelSpec(1, "gray")).num_qubits
1
>>> occupation_encoding(number, LevelSpec(1, "gray"), 4).num_qubits
4
Un opérateur qui agit sur un mode situé au-delà du dernier d'un spec par mode est refusé :
>>> far = BosonOperator.from_dict({(cre(5), ann(5)): 1.0})
>>> try:
... occupation_encoding(far, LevelSpec([1, 1], "gray"))
... except ValueError as error:
... print(error)
the operator acts on 6 modes, but the spec covers only 2
Quelques limites découlent de la façon dont les encodages comptent les qubits. Avec un encodage compact, un mode à un seul niveau (max_occupation = 0) n'occupe aucun qubit, car son unique niveau n'a besoin de rien pour être distingué d'un autre. Un tel mode est autorisé et ne déplace pas les qubits des modes qui le suivent, mais un spec dans lequel chaque mode n'occuperait aucun qubit est rejeté. Les encodages unaires contiennent au plus 128 niveaux par mode, et une coupure plus grande lève une erreur qui suggère un encodage compact :
>>> LevelSpec([0, 1], "gray").resolve(2).widths
[0, 1]
>>> try:
... LevelSpec(0, "gray")
... except ValueError as error:
... print(str(error).split(":")[0])
the spec would span no qubits
>>> try:
... LevelSpec(128, "unary_projected")
... except ValueError as error:
... print(str(error).split(" (")[0])
max_occupation = 128 is too large for the unary encodings, which hold at most 128 levels per mode
Choisir un encodage
Un LevelEncoding décide quel état de base d'un bloc de qubits représente chaque niveau. Il en existe quatre, et aucun n'est le meilleur en général [2]. Le nombre de qubits par mode et le motif de bits de chaque niveau découlent de l'encodage :
>>> encodings = (
... LevelEncoding.UNARY_PROJECTED,
... LevelEncoding.UNARY_UNPROJECTED,
... LevelEncoding.STANDARD_BINARY,
... LevelEncoding.GRAY,
... )
>>> rows = ["encoding qubits for d = 1, 2, 3, 4, 5, 8 level 0 to 4 as bit patterns"]
>>> for encoding in encodings:
... widths = [encoding.num_qubits(levels) for levels in (1, 2, 3, 4, 5, 8)]
... patterns = [f"{encoding.level_to_bits(level):05b}" for level in range(5)]
... rows.append(f"{encoding!s:18s} {widths!s:34s} {patterns}")
>>> print("\n".join(rows))
encoding qubits for d = 1, 2, 3, 4, 5, 8 level 0 to 4 as bit patterns
unary_projected [1, 2, 3, 4, 5, 8] ['00001', '00010', '00100', '01000', '10000']
unary_unprojected [1, 2, 3, 4, 5, 8] ['00001', '00010', '00100', '01000', '10000']
standard_binary [0, 1, 2, 2, 3, 3] ['00000', '00001', '00010', '00011', '00100']
gray [0, 1, 2, 2, 3, 3] ['00000', '00001', '00011', '00010', '00110']
Les motifs de bits sont des entiers dans lesquels le bit est la valeur du qubit . Les encodages unaires dépensent un qubit par niveau et en mettent un à un. Les encodages compacts dépensent qubits et stockent le numéro du niveau, en binaire ou sous forme de code de Gray dans lequel des niveaux voisins diffèrent d'un seul bit. Le nom sous forme de chaîne d'un encodage est accepté partout où l'encodage l'est.
Le compromis oppose les qubits au poids de l'opérateur. Les encodages compacts exigent exponentiellement moins de qubits, mais un opérateur d'échelle relie des niveaux dont les motifs peuvent différer de plusieurs bits, si bien que ses termes agissent sur plus de qubits. Les sections suivantes examinent ce que cela coûte, en commençant par les deux encodages unaires, qui ont des dispositions identiques et ne diffèrent que par l'opérateur.
Comparer les deux encodages unaires
Dans l'encodage unaire, un opérateur d'échelle déplace une excitation entre deux qubits voisins. Pour l'écrire comme un opérateur sur le mode entier, tu dois choisir ce qu'on fait des autres qubits. Les deux variantes choisissent différemment :
-
unary_projectedplace un projecteur sur chaque autre qubit du mode. Cela impose la contrainte one-hot dans l'opérateur lui-même : le terme n'agit que si le reste du mode est vide. -
unary_unprojectedlaisse ces qubits tranquilles, de sorte que le terme agit comme l'identité sur eux.
Examine un seul pour dans les deux cas. Le mapper renvoie un SparseObservable, dont les termes sont des chaînes de termes de bits à un qubit. Voici les quatre premiers termes de chacun, avec les qubits sur lesquels ils agissent :
>>> for encoding in ("unary_projected", "unary_unprojected"):
... observable = occupation_encoding(b_dagger, LevelSpec(3, encoding))
... terms = observable.to_sparse_list()
... print(encoding, "->", len(terms), "terms on", observable.num_qubits, "qubits")
... for label, qubits, coefficient in terms[:4]:
... print(f" {label:5s} on qubits {qubits} coefficient {complex(coefficient):.4f}")
unary_projected -> 12 terms on 4 qubits
XX00 on qubits [0, 1, 2, 3] coefficient 0.2500+0.0000j
XY00 on qubits [0, 1, 2, 3] coefficient 0.0000-0.2500j
YX00 on qubits [0, 1, 2, 3] coefficient 0.0000+0.2500j
YY00 on qubits [0, 1, 2, 3] coefficient 0.2500+0.0000j
unary_unprojected -> 12 terms on 4 qubits
XX on qubits [0, 1] coefficient 0.2500+0.0000j
XY on qubits [0, 1] coefficient 0.0000-0.2500j
YX on qubits [0, 1] coefficient 0.0000+0.2500j
YY on qubits [0, 1] coefficient 0.2500+0.0000j
Les deux ont douze termes avec les mêmes coefficients. Les termes projetés portent un terme de bit 0 sur les deux qubits que les termes non projetés ne mentionnent pas ; ainsi, chaque terme projeté touche les quatre qubits et chaque terme non projeté en touche deux. En général, un terme projeté touche les qubits du mode, car il porte deux facteurs, chacun ou , et un projecteur sur chaque autre qubit.
Les deux opérateurs sont égaux sur les états auxquels l'encodage est destiné, les états de base one-hot (les mots de code), et diffèrent en dehors d'eux. Pour le vérifier, prends un petit opérateur sur deux modes pour et compare les matrices sur les neuf mots de code :
>>> hamiltonian = BosonOperator.from_dict({
... (cre(0), ann(1)): 1.0, (cre(1), ann(0)): 1.0,
... (cre(0), ann(0)): 0.5, (cre(1), cre(1), ann(1), ann(1)): 0.25,
... })
>>> matrices = {}
>>> for encoding in ("unary_projected", "unary_unprojected"):
... observable = occupation_encoding(hamiltonian, LevelSpec(2, encoding))
... matrices[encoding] = SparsePauliOp.from_sparse_observable(observable).to_matrix()
>>> unary = LevelEncoding.UNARY_PROJECTED
>>> codewords = sorted(
... unary.level_to_bits(first) | (unary.level_to_bits(second) << 3)
... for first in range(3) for second in range(3)
... )
>>> projected, unprojected = matrices["unary_projected"], matrices["unary_unprojected"]
>>> bool(np.allclose(projected[np.ix_(codewords, codewords)], unprojected[np.ix_(codewords, codewords)]))
True
>>> bool(np.allclose(projected, unprojected))
False
Les deux laissent aussi invariant le sous-espace des mots de code, de sorte qu'un état préparé sur un mot de code reste sur des mots de code. Le choix n'importe que pour les états qui quittent ce sous-espace, par exemple à cause du bruit. Là, l'opérateur projeté supprime l'évolution, car ses projecteurs échouent, alors que l'opérateur non projeté continue d'agir. Aucun de ces comportements n'est une exigence physique. L'opérateur non projeté n'est défini que sur le sous-espace one-hot ; le choisir est donc une décision de modélisation et non une optimisation gratuite.
Ce que coûtent les projecteurs, ce sont des portes. Fais évoluer un hermitien sur un mode pendant un court instant, et compte les portes à deux qubits après la transpilation de Qiskit :
>>> from qiskit import QuantumCircuit, transpile
>>> from qiskit.circuit.library import PauliEvolutionGate
>>>
>>> def cx_count(observable):
... circuit = QuantumCircuit(observable.num_qubits)
... circuit.append(PauliEvolutionGate(observable, time=0.1), range(observable.num_qubits))
... basis = ["cx", "rz", "sx", "x"]
... return transpile(circuit, basis_gates=basis, optimization_level=1).count_ops().get("cx", 0)
>>>
>>> hermitian = b + b_dagger
>>> levels = (3, 4, 5, 6)
>>> counts = {
... str(encoding): [
... cx_count(occupation_encoding(hermitian, LevelSpec(d - 1, encoding))) for d in levels
... ]
... for encoding in encodings
... }
Les décomptes exacts dépendent de la façon dont Qiskit synthétise la porte d'évolution, ce qui change d'une version à l'autre ; trace-les donc sur un graphique :
Le décompte non projeté croît linéairement avec , car chacune des transitions est une rotation à deux qubits. Le décompte projeté croît beaucoup plus vite, car chaque terme agit sur les qubits. Les encodages compacts exigent moins de qubits, mais le poids de leurs termes dépend de la façon dont les motifs de bits des niveaux voisins diffèrent : le code de Gray, dans lequel ils diffèrent d'un bit, est le moins coûteux des deux encodages compacts, et le binaire standard fait un bond quand une retenue inverse plusieurs bits à la fois (). Savoir si un encodage compact bat aussi l'encodage unaire non projeté dépend de et de la façon dont Qiskit synthétise l'évolution ; lis donc la tendance et non le classement à une taille donnée.
Les projecteurs comptent aussi quand tu développes un opérateur mappé en Paulis, ce que fait from_sparse_observable() en remplaçant chaque projecteur par une somme de deux opérateurs de Pauli. Un terme à projecteurs devient termes de Pauli. Voici un seul pour un petit , qui est la seule taille à laquelle tu devrais essayer cela :
>>> rows = [f"{'d':>2s} {'stored (both)':>14s} {'expanded, projected':>20s} {'expanded, unprojected':>22s}"]
>>> for levels in (3, 4, 5):
... stored = len(occupation_encoding(b_dagger, LevelSpec(levels - 1, "unary_projected")))
... expanded = [
... len(SparsePauliOp.from_sparse_observable(
... occupation_encoding(b_dagger, LevelSpec(levels - 1, encoding))).simplify())
... for encoding in ("unary_projected", "unary_unprojected")
... ]
... rows.append(f"{levels:2d} {stored:14d} {expanded[0]:20d} {expanded[1]:22d}")
>>> print("\n".join(rows))
d stored (both) expanded, projected expanded, unprojected
3 8 16 8
4 12 48 12
5 16 128 16
Les deux variantes stockent le même nombre de termes, mais le développement de la variante projetée croît exponentiellement en . Pour tout ce qui dépasse un exemple jouet, lis directement les termes du SparseObservable, comme le fait le listing ci-dessus, au lieu de le convertir.
Les encodages compacts ne peuvent pas abandonner leurs projecteurs. Ici, les bits à zéro font partie du numéro de niveau ; retirer un change donc les niveaux qu'un terme relie, et l'opérateur n'est plus le même sur les mots de code. C'est pourquoi seul l'encodage unaire existe en deux variantes.
Un produit d'opérateurs d'échelle est tronqué d'abord
Un autre choix de conception se manifeste dans le coût des termes diagonaux. Le mapper ne mappe pas chaque opérateur d'échelle pour en multiplier ensuite les résultats. Il rassemble les opérateurs d'échelle qu'un terme applique à un mode, les multiplie sous forme de matrice dans la base de Fock, tronque ce produit une seule fois, puis encode le résultat [2]. Un produit tel que l'opérateur nombre reste donc diagonal :
>>> number_observable = occupation_encoding(number, LevelSpec(3, "unary_unprojected"))
>>> [(label, qubits) for label, qubits, _ in number_observable.to_sparse_list()]
[('1', [1]), ('1', [2]), ('1', [3])]
>>> cx_count(number_observable)
0
Ses termes sont les projecteurs sur le qubit de chaque niveau supérieur à zéro, pondérés par le numéro du niveau (le niveau zéro a un poids nul et n'a donc pas de terme). Son évolution temporelle est un ensemble de rotations à un qubit et n'exige aucune porte à deux qubits. Multiplier à la place les deux opérateurs d'échelle mappés donne le même opérateur sur les mots de code, mais un opérateur différent en dehors d'eux, avec des chaînes hors diagonale qui coûtent des portes :
>>> unprojected = LevelSpec(3, "unary_unprojected")
>>> as_pauli = lambda operator: SparsePauliOp.from_sparse_observable(
... occupation_encoding(operator, unprojected, 4)).simplify()
>>> composed = as_pauli(b_dagger).dot(as_pauli(b)).simplify()
>>> direct = as_pauli(number)
>>> len(direct), len(composed)
(4, 16)
>>> cx_count(direct)
0
>>> bool(cx_count(composed) > 0)
True
>>> codewords = [LevelEncoding.UNARY_UNPROJECTED.level_to_bits(level) for level in range(4)]
>>> bool(np.allclose(direct.to_matrix()[np.ix_(codewords, codewords)],
... composed.to_matrix()[np.ix_(codewords, codewords)]))
True
>>> bool(np.allclose(direct.to_matrix(), composed.to_matrix()))
False
Les termes diagonaux, comme l'interaction sur site du modèle de Bose-Hubbard, sont de tels produits, et c'est là que ce choix compte.
Écrire un mapper personnalisé
Si tu veux un encodage que la bibliothèque ne fournit pas, construis-le à partir de l'image d'un seul opérateur d'échelle. map_boson_action_generators() prend une fonction qui mappe une seule BosonAction, une fonction qui renvoie l'identité, et construit à partir d'elles le mapper d'opérateurs entiers. Elle multiplie dans l'ordre les images des actions de chaque terme, met à l'échelle par le coefficient et somme les termes. La sortie peut être de tout type qui prend en charge la multiplication par un scalaire et la composition.
À titre d'exemple, prenons l'encodage pour qui stocke un mode dans un qubit. Un opérateur de création est , soit dans la convention de Qiskit, et un opérateur d'annihilation est son adjoint :
>>> from qiskit_bosons.mappers import map_boson_action_generators
>>> from qiskit_bosons.operators import BosonAction
>>>
>>> num_qubits = 2
>>>
>>> def map_action(action: BosonAction) -> SparsePauliOp:
... is_creation, mode = action
... sign = -1j if is_creation else 1j
... return SparsePauliOp.from_sparse_list(
... [("X", [mode], 0.5), ("Y", [mode], 0.5 * sign)], num_qubits=num_qubits
... )
>>>
>>> def identity() -> SparsePauliOp:
... return SparsePauliOp.from_sparse_list([("", [], 1.0)], num_qubits)
>>>
>>> model = BosonOperator.from_dict({
... (cre(0), ann(1)): -1.0, (cre(1), ann(0)): -1.0, (ann(0), cre(0)): 0.7, (cre(1), ann(1)): 0.3,
... })
>>> custom = map_boson_action_generators(model, map_action, identity)
>>> library = SparsePauliOp.from_sparse_observable(occupation_encoding(model, LevelSpec(1, "gray")))
>>> bool(np.allclose(custom.to_matrix(), library.to_matrix()))
True
Ce mapper écrit à la main multiplie les opérateurs d'échelle mappés, et il concorde ici avec le mapper de la bibliothèque parce que chaque mode a deux niveaux et qu'un qubit contient tout le mode. Pour un plus grand, comme l'a montré la section précédente, composer les opérateurs d'échelle mappés et tronquer d'abord le produit ne donnent pas le même opérateur en dehors des mots de code.
Pour utiliser un tel mapper dans un circuit, enveloppe-le dans une fonction de signature (operator, num_qubits) -> SparseObservable et donne-la à MapperFnEvolutionSynthesis, qui mappe avec elle l'opérateur de chaque porte Evolution. Le pipeline du transpileur est décrit dans Transpiler des circuits bosoniques.
>>> from qiskit.quantum_info import SparseObservable
>>> from qiskit_bosons.transpiler.passes import MapperFnEvolutionSynthesis
>>>
>>> def mapper_fn(operator, num_qubits):
... return SparseObservable.from_sparse_pauli_op(
... map_boson_action_generators(operator, map_action, identity)
... )
>>>
>>> synthesis = MapperFnEvolutionSynthesis(mapper_fn)
Deux détails méritent d'être connus. Premièrement, l'opérateur de qubits doit avoir des coefficients réels, car PauliEvolutionGate rejette une partie imaginaire. Un opérateur hermitien est mappé vers un opérateur réel, mais seulement une fois que les termes qui s'annulent ont été fusionnés. Ce mapper écrit à la main ne les fusionne pas ; un terme de saut et son adjoint laissent donc des coefficients imaginaires tant que tu ne simplifies pas le résultat. Le mapper de la bibliothèque fusionne son propre résultat ; un mapper personnalisé doit le faire, par exemple en l'enveloppant dans simplify() :
>>> hopping = BosonOperator.from_dict({(cre(0), ann(1)): 1.0})
>>> hermitian_hopping = hopping + hopping.adjoint()
>>> raw = mapper_fn(hermitian_hopping, 2)
>>> bool(np.allclose(np.asarray(raw.coeffs).imag, 0))
False
>>> merged = raw.simplify()
>>> bool(np.allclose(np.asarray(merged.coeffs).imag, 0))
True
>>> sorted(label for label, _, _ in merged.to_sparse_list())
['XX', 'YY']
Deuxièmement, une formule de produit synthétise les termes dans l'ordre où elle les reçoit, et l'ordre produit par un mapper n'est pas garanti stable. Les fonctions auxiliaires simplify() et group_wise() enveloppent une fonction de mapper pour le contrôler : simplify fusionne les chaînes en double et trie les termes dans un ordre canonique, tandis que group_wise mappe un groupe de l'opérateur à la fois, de sorte que les termes d'un groupe restent adjacents. Ne combine pas les deux, car le tri détruit le regroupement. Le guide du regroupement montre comment attribuer des groupes, et le guide de la synthèse montre comment l'ordre affecte le circuit.
Étapes suivantes
-
Suis Simuler la dynamique de Bose-Hubbard avec un choix d'encodage pour une simulation complète qui balaie la coupure et compare les encodages à la dynamique exacte.
-
Consulte Transpiler des circuits bosoniques pour voir comment le même
LevelSpecest appliqué à un circuit bosonique. -
Lis Principes de conception des représentations d'opérateurs pour savoir comment un
BosonOperatorstocke et ordonne ses termes, y compris la règle de contraction derrière l'exemple de mise en ordre normal ci-dessus. -
Lis Faire pivoter les modes bosoniques pour la porte qui déplace des bosons entre modes, là où l'avertissement sur l'occupation atteignable compte le plus.
-
Consulte la référence de l'API de
qiskit_bosons.mapperset deqiskit_bosons.mappers.library.
[1]
R. Somma, G. Ortiz, E. Knill, and J. Gubernatis, Quantum Simulations of Physics Problems (2003), arXiv:quant-ph/0304063, Sec. 4, Eq. 22.
N. P. D. Sawaya, T. Menke, T. H. Kyaw, S. Johri, A. Aspuru-Guzik, and G. G. Guerreschi, Resource-efficient digital quantum simulation of d-level systems for photonic, vibrational, and spin-s Hamiltonians, npj Quantum Information 6, 49 (2020), arXiv:1909.12847.