Principes de conception des représentations d'opérateurs
Ce guide explique comment un BosonOperator est organisé et comment le manipuler : comment en construire un, comment inspecter et combiner des opérateurs, et comment amener deux opérateurs sous une forme où ils peuvent être comparés. Commence ici lorsque tu écris un hamiltonien.
Un BosonOperator est une somme exacte de produits d'opérateurs bosoniques de création et d'annihilation à coefficients complexes. Il ne comporte aucune coupure d'occupation. Un mode bosonique contient un nombre arbitraire de bosons, donc son espace de Fock est de dimension infinie, et une troncature n'intervient que lorsque l'opérateur est appliqué sur des qubits (voir Tronquer l'espace de Fock). Tout ce qui figure dans ce guide se passe donc dans l'algèbre exacte des relations de commutation canoniques,
Deux conséquences façonnent l'interface. Un indice de mode peut se répéter dans un même terme, car est un opérateur valide qui crée deux bosons. De plus, réordonner deux facteurs n'introduit jamais de signe, bien que déplacer un opérateur d'annihilation au-delà d'un opérateur de création sur le même mode ajoute un terme, .
Stockage
Un opérateur conserve ses termes dans quatre tableaux plats, similaires au format de lignes creuses compressées d'une matrice creuse :
coeffs | Un coefficient complexe par terme. |
actions | Un booléen par facteur : True pour une création et False pour un opérateur d'annihilation. |
modes | Un indice de mode par facteur. |
boundaries | Les positions dans actions et modes où chaque terme commence et se termine. |
Le constructeur prend directement ces quatre tableaux. L'opérateur ci-dessous a trois termes, à savoir , et la constante , et tu peux relire les tableaux avec les méthodes get_* :
>>> from qiskit_bosons.operators import BosonOperator, ann, cre
>>> op = BosonOperator(
... coeffs=[2.0, -1.0j, 0.5],
... actions=[True, False, True, True],
... modes=[0, 1, 1, 1],
... boundaries=[0, 2, 4, 4],
... )
>>> op.get_coeffs()
[(2+0j), (-0-1j), (0.5+0j)]
>>> op.get_actions()
[True, False, True, True]
>>> op.get_modes()
[0, 1, 1, 1]
>>> op.get_boundaries()
[0, 2, 4, 4]
>>> len(op)
3
Le premier terme couvre les positions 0 à 2 de actions et modes, le deuxième couvre 2 à 4, et le troisième couvre 4 à 4, ce qui est la façon dont le terme constant, un terme sans facteurs, est stocké. Le mode répété 1 dans le deuxième terme est conservé tel qu'écrit ; un terme n'est jamais supprimé ni fusionné parce qu'un indice de mode apparaît deux fois.
Un opérateur construit avec from_dict() ne stocke pas ses termes dans l'ordre du dictionnaire, donc ses tableaux peuvent lister les termes dans un ordre différent d'une exécution à l'autre. L'opérateur lui-même est le même, et rien de ce qui suit qui compare ou affiche des opérateurs ne dépend de l'ordre de stockage.
Cette disposition rend la construction et l'arithmétique peu coûteuses, avec une conséquence : un opérateur peut contenir le même terme plusieurs fois à la fois. Les sommes et les produits ajoutent des termes sans les fusionner, donc appelle simplify() lorsque tu as besoin de termes uniques (voir Faire de l'arithmétique).
Construire des opérateurs
La façon la plus directe de construire un opérateur est from_dict(), qui prend un dictionnaire d'un tuple de facteurs vers un coefficient. Les fonctions cre() et ann() créent les facteurs, et le tuple vide est le terme constant :
>>> b = BosonOperator.from_dict({(ann(0),): 1.0})
>>> b_dag = BosonOperator.from_dict({(cre(0),): 1.0})
>>> number = BosonOperator.from_dict({(cre(0), ann(0)): 1.0})
>>> print(format(number))
1.000000e0 +0.000000e0j * (+0 -0)
Dans la forme affichée, +0 représente et -0 représente . Les constructeurs zero() et one() renvoient l'identité additive et l'identité multiplicative, et from_terms() reconstruit un opérateur à partir de l'itérateur de iter_terms().
La bibliothèque d'opérateurs fournit les quadratures adimensionnelles de position et d'impulsion,
sous les noms position() et momentum(). Avec les fonctions de commutateur du même module, tu peux vérifier directement l'algèbre :
>>> from qiskit_bosons.operators.library import commutator, momentum, position
>>> q, p = position(0), momentum(0)
>>> q.is_hermitian() and p.is_hermitian()
True
>>> commutator(q, p).equiv(1j * BosonOperator.one())
True
>>> commutator(b, b_dag).equiv(BosonOperator.one())
True
Le module fournit aussi anti_commutator() et double_commutator(). Les trois prennent des opérateurs du même type et renvoient un nouvel opérateur.
Attention à l'ordre d'un produit
Deux opérateurs peuvent être multipliés de deux façons, et on les confond facilement. L'opérateur @ est le produit mathématique, donc a @ b vaut . L'opérateur & est la composition, qui suit la convention de Qiskit de lecture de gauche à droite : a & b applique d'abord a puis b, donc il vaut . Les fonctions de commutateur utilisent @, donc commutator(a, b) vaut . Construire toi-même le crochet avec & le rend négatif sans avertissement :
>>> (b @ b_dag).equiv(b_dag @ b + BosonOperator.one())
True
>>> (b & b_dag).equiv(b_dag @ b)
True
>>> ((b @ b_dag) - (b_dag @ b)).equiv(BosonOperator.one())
True
>>> ((b & b_dag) - (b_dag & b)).equiv(-BosonOperator.one())
True
Inspecter un opérateur
Un opérateur n'est pas directement itérable, car ses termes se trouvent dans les tableaux plats ci-dessus. Utilise plutôt iter_terms(), qui fournit pour chaque terme une liste de paires (is_creation, mode) et un coefficient. L'itérateur renvoie des copies, donc les modifier ne change pas l'opérateur.
>>> hopping = BosonOperator.from_dict(
... {
... (cre(0), ann(1)): -1.0,
... (cre(1), ann(0)): -1.0,
... (cre(2), cre(2), ann(2), ann(2)): 0.5,
... }
... )
>>> len(hopping)
3
>>> sorted(hopping.iter_terms())
[([(True, 0), (False, 1)], (-1+0j)), ([(True, 1), (False, 0)], (-1+0j)), ([(True, 2), (True, 2), (False, 2), (False, 2)], (0.5+0j))]
Plusieurs méthodes résument la structure d'un opérateur :
>>> hopping.get_support()
{0, 1, 2}
>>> hopping.min_num_modes()
3
>>> hopping.max_rank()
4
get_support() est l'ensemble des modes sur lesquels l'opérateur agit, min_num_modes() est le plus petit registre pouvant le contenir, et max_rank() est la longueur de son terme le plus long.
Un mode est un indice abstrait qui désigne un degré de liberté bosonique. Il n'a pas de signification propre : c'est toi qui détermines quel site d'un réseau, quelle coordonnée vibrationnelle ou quel port optique un mode représente. Le BosonicCircuit utilise les mêmes indices (voir Travailler avec des circuits bosoniques), de sorte qu'un opérateur et les portes qui le portent s'accordent sur ce qu'est le mode 2 sans qu'aucun des deux ne sache ce qu'il représente. Rien dans l'opérateur ne fixe le nombre de niveaux d'un mode, ce qui explique aussi pourquoi la taille du registre n'est qu'une borne inférieure ; min_num_modes() est le plus petit registre qui contient l'opérateur, et non une propriété de celui-ci.
Les méthodes d'occupation indiquent combien de bosons un seul terme peut placer dans un mode. C'est une borne inférieure de la coupure dont une correspondance a besoin. Ce n'est pas une coupure suffisante, car des applications répétées de l'opérateur, comme dans une évolution temporelle, peuvent peupler des niveaux plus élevés (voir Tronquer l'espace de Fock) :
>>> hopping.mode_occupations()
[1, 1, 2]
>>> hopping.mode_occupations([2, 0])
[2, 1]
>>> hopping.max_occupation()
2
Deux autres vérifications portent sur les quantités conservées. Un opérateur conserve le nombre de particules lorsque chaque terme a autant d'opérateurs de création que d'annihilation. Passer une liste de tailles de blocs teste la même condition au sein de chaque bloc de modes consécutifs :
>>> hopping.conserves_particle_number()
True
>>> move = BosonOperator.from_dict({(cre(0), ann(2)): 1.0})
>>> move.conserves_sector([3])
True
>>> move.conserves_sector([2, 1])
False
Enfin, relabel_modes() renomme les modes : le mode i devient permutation[i]. La liste doit être assez longue pour indexer chaque mode de l'opérateur et ne doit pas répéter d'entrée :
>>> print(format(move.relabel_modes([5, 6, 7])))
1.000000e0 +0.000000e0j * (+5 -7)
Faire de l'arithmétique
Les opérateurs prennent en charge l'addition, la soustraction, la négation, ainsi que la multiplication et la division par un nombre, sous la forme usuelle ou sur place :
>>> total = 2 * b_dag + b_dag - 3j * b
>>> len(total)
3
>>> len(total.simplify())
2
>>> total.simplify().equiv(3 * b_dag - 3j * b)
True
Les trois termes de total n'ont jamais été fusionnés, donc l'opérateur contenait b_dag deux fois jusqu'à ce que simplify() les combine. La méthode additionne les coefficients des termes identiques et supprime ceux dont la grandeur totale est inférieure à une tolérance. C'est une opération structurelle : elle compare les facteurs écrits et n'applique pas les relations de commutation. La méthode apparentée ichop() supprime les petits coefficients sur place sans fusionner d'abord, donc appelle simplify() avant ichop() lorsqu'un opérateur peut contenir des termes en double.
Les produits utilisent @ ou & comme décrit ci-dessus, et ** élève un opérateur à une puissance entière :
>>> x = b_dag + b
>>> print(format((x ** 2).simplify()))
1.000000e0 +0.000000e0j * (-0 -0)
1.000000e0 +0.000000e0j * (-0 +0)
1.000000e0 +0.000000e0j * (+0 -0)
1.000000e0 +0.000000e0j * (+0 +0)
Le résultat liste les quatre ordonnancements tels qu'écrits. Les réduire à une forme unique nécessite les relations de commutation, que la section suivante applique.
L'adjoint inverse l'ordre des facteurs dans chaque terme, échange les opérateurs de création et d'annihilation, et conjugue les coefficients. La méthode is_hermitian() teste si un opérateur est égal à son adjoint :
>>> hop = BosonOperator.from_dict({(cre(0), ann(1)): 1j})
>>> print(format(hop.adjoint()))
0.000000e0 -1.000000e0j * (+1 -0)
>>> hop.is_hermitian()
False
>>> (hop + hop.adjoint()).is_hermitian()
True
Un terme de saut isolé n'est pas hermitien, ce qui compte lorsque tu regroupes des termes pour une évolution temporelle : chaque groupe doit être hermitien par lui-même (voir Regrouper les termes d'un opérateur : utiliser la structure de l'opérateur).
Ordre normal et comparaison d'opérateurs
Deux produits d'opérateurs d'échelle peuvent être le même opérateur et pourtant sembler différents, car et sont égaux dans l'algèbre. Des opérateurs égaux en ce sens ne sont pas forcément stockés de façon égale, donc les comparer nécessite les relations de commutation. La méthode normal_ordered() les applique : elle place chaque opérateur de création à gauche de chaque opérateur d'annihilation et ajoute un terme chaque fois qu'une paire sur le même mode est échangée.
>>> product = BosonOperator.from_dict({(ann(0), cre(0), ann(0), cre(0)): 1.0})
>>> ordered = product.normal_ordered()
>>> len(ordered)
5
>>> print(format(ordered.simplify()))
1.000000e0 +0.000000e0j * ()
3.000000e0 +0.000000e0j * (+0 -0)
1.000000e0 +0.000000e0j * (+0 +0 -0 -0)
L'opérateur ordonné est renvoyé sous forme de somme non fusionnée, avec cinq termes stockés qui se réduisent à trois après que simplify() les a fusionnés. L'ordre normal ne fait jamais disparaître un terme : et tout autre facteur répété restent.
L'argument sandwich définit la façon dont les modes à l'intérieur des deux groupes de facteurs sont disposés. Par défaut, les deux groupes sont triés par ordre décroissant. Avec sandwich=True, les opérateurs de création sont croissants et les opérateurs d'annihilation décroissants, ce qui place les indices les plus grands vers le milieu :
>>> pair = BosonOperator.from_dict({(cre(0), cre(1), ann(0), ann(1)): 1.0})
>>> list(pair.normal_ordered().iter_terms())
[([(True, 1), (True, 0), (False, 1), (False, 0)], (1+0j))]
>>> list(pair.normal_ordered(sandwich=True).iter_terms())
[([(True, 0), (True, 1), (False, 1), (False, 0)], (1+0j))]
L'opérateur == teste l'égalité exacte des termes stockés, sans tolérance ni simplification, donc il peut signaler une différence entre deux opérateurs mathématiquement égaux. La méthode equiv() met d'abord la différence des deux opérateurs sous ordre normal, puis teste que chaque coefficient est inférieur à une tolérance. C'est la comparaison que tu veux presque toujours :
>>> reordered = BosonOperator.from_dict({(cre(0), ann(0)): 1.0, (): 1.0})
>>> contracted = BosonOperator.from_dict({(ann(0), cre(0)): 1.0})
>>> contracted == reordered
False
>>> contracted.simplify() == reordered.simplify()
False
>>> contracted.equiv(reordered)
True
La méthode simplify seule laisse les deux formes séparées, car elle ne fusionne que les termes écrits identiques. De même, is_hermitian() compare un opérateur à son adjoint sous ordre normal, donc elle reconnaît comme hermitien sans travail supplémentaire.
L'ordre normal est une identité exacte dans l'algèbre non tronquée, et equiv s'y appuie. Ce n'est pas une identité après une troncature. Un opérateur appliqué dépend de la forme que tu appliques, donc les images de contracted et reordered ci-dessus diffèrent, uniquement sur le niveau retenu le plus élevé. Voir Tronquer l'espace de Fock pour la taille de la différence et la façon de choisir une forme.
Ordonner et filtrer les termes
Réordonner les termes d'un opérateur, par opposition aux facteurs à l'intérieur d'un terme, ne change pas l'opérateur mais peut changer le comportement d'algorithmes qui lisent les termes dans l'ordre, comme une formule de produit randomisée. Les fonctions de qiskit_bosons.operators.terms renvoient un nouvel opérateur et gardent tout indice de groupe attaché à ses termes.
canonical_order() trie les termes dans un ordre fixe qui ne dépend que des facteurs écrits, de sorte qu'un opérateur se lit de la même façon quelle que soit la manière dont il a été assemblé. order_terms() trie selon une clé que tu fournis, qui reçoit chaque terme sous la forme du tuple que produit iter_terms() :
>>> from qiskit_bosons.operators.terms import canonical_order, order_terms
>>> mixed = BosonOperator.from_dict({(cre(1), ann(0)): 1.0, (cre(0), ann(1)): 3.0, (): 2.0})
>>> list(canonical_order(mixed).iter_terms())
[([], (2+0j)), ([(True, 0), (False, 1)], (3+0j)), ([(True, 1), (False, 0)], (1+0j))]
>>> by_weight = order_terms(mixed, key=lambda term: abs(term[1]), reverse=True)
>>> [coeff for _, coeff in by_weight.iter_terms()]
[(3+0j), (2+0j), (1+0j)]
Si un opérateur porte des indices de groupe (voir Regrouper les termes d'un opérateur : utiliser la structure de l'opérateur), la fonction group_order() fait de chaque groupe une suite contiguë de termes :
>>> from qiskit_bosons.operators.terms import group_order
>>> grouped = BosonOperator.from_dict({(cre(1), ann(0)): 1.0, (cre(0), ann(1)): 3.0, (): 2.0})
>>> grouped.groups = [1, 0, 1]
>>> group_order(grouped).groups
[0, 1, 1]
Le dernier utilitaire supprime des termes au lieu de les réordonner. Un terme qui est un produit d'opérateurs nombre, , ne fait que multiplier chaque état de Fock par une phase, donc filter_diagonal_terms() peut supprimer la constante, les opérateurs nombre et leurs produits lorsque cette phase est sans importance pour ce que tu calcules. Elle modifie l'opérateur sur place et exige un opérateur sous ordre normal, ce qu'elle ne vérifie pas :
>>> from qiskit_bosons.operators.terms import filter_diagonal_terms
>>> model = BosonOperator.from_dict(
... {
... (cre(0), ann(1)): -1.0,
... (cre(1), ann(0)): -1.0,
... (cre(0), cre(0), ann(0), ann(0)): 0.5,
... (cre(0), ann(0)): 0.2,
... (): 0.1,
... }
... )
>>> filter_diagonal_terms(model)
>>> len(model)
2
>>> model.is_hermitian()
True
Seuls les deux termes de saut restent. L'interaction sur site et l'opérateur nombre sont diagonaux dans la base d'occupation, donc ils sont supprimés avec la constante. Le mode répété dans l'interaction est traité correctement : est diagonal, alors que ne le serait pas.
Étapes suivantes
-
Regrouper les termes d'un opérateur : utiliser la structure de l'opérateur montre comment étiqueter des termes avec des indices de groupe.
-
Faire correspondre les représentations d'opérateurs transforme un opérateur en opérateur de qubits, y compris le choix de l'encodage et l'effet d'une troncature.
-
Travailler avec des circuits bosoniques place un opérateur dans un circuit sous forme de porte
Evolution. -
Simuler la dynamique de Bose-Hubbard avec un choix d'encodage construit le hamiltonien d'une chaîne de Bose-Hubbard et la simule de bout en bout.
-
Les modules
qiskit_bosons.operatorsetqiskit_bosons.operators.termsdocumentent l'interface complète.