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 ?

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,

[bi,bj†]=δij,[bi,bj]=[bi†,bj†]=0 .[b_i, b_j^\dagger] = \delta_{ij}, \qquad [b_i, b_j] = [b_i^\dagger, b_j^\dagger] = 0 \, .

Deux conséquences façonnent l'interface. Un indice de mode peut se répéter dans un même terme, car bi†bi†b_i^\dagger b_i^\dagger 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, bibi†=bi†bi+1b_i b_i^\dagger = b_i^\dagger b_i + 1.

Stockage​

Un opérateur conserve ses termes dans quatre tableaux plats, similaires au format de lignes creuses compressées d'une matrice creuse :

coeffsUn coefficient complexe par terme.
actionsUn booléen par facteur : True pour une création et False pour un opérateur d'annihilation.
modesUn indice de mode par facteur.
boundariesLes 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 2b0†b12 b_0^\dagger b_1, −ib1†b1†-i b_1^\dagger b_1^\dagger et la constante 0.50.5, 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.

Remarque

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 b0†b_0^\dagger et -0 représente b0b_0. 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,

q^=b†+b2,p^=i(b†−b)2 ,\hat{q} = \frac{b^\dagger + b}{\sqrt{2}}, \qquad \hat{p} = \frac{i \left(b^\dagger - b\right)}{\sqrt{2}} \, ,

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 ABAB. 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 BABA. Les fonctions de commutateur utilisent @, donc commutator(a, b) vaut [A,B][A, B]. 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.

Remarque

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 b b†b\,b^\dagger et b†b+1b^\dagger b + 1 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 : b†b†b^\dagger b^\dagger 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 b b†+b†bb\,b^\dagger + b^\dagger b comme hermitien sans travail supplémentaire.

Remarque

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, bi†bib_i^\dagger b_i, 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 : b0†b0†b0b0b_0^\dagger b_0^\dagger b_0 b_0 est diagonal, alors que b0†b0†b0b_0^\dagger b_0^\dagger b_0 ne le serait pas.

Étapes suivantes​