Zum Hauptinhalt springen
Dieses Projekt befindet sich in der Beta-Phase. Einen Fehler gefunden? GitHub Issue erstellen. Frage oder Idee? In Discussions fragen — wir freuen uns über dein Feedback!
Wie ist die Übersetzungsqualität auf dieser Seite?

Entwurfsprinzipien von Operatordarstellungen

Dieser Leitfaden erklärt, wie ein BosonOperator aufgebaut ist und wie du damit arbeitest: wie du einen erstellst, wie du Operatoren untersuchst und kombinierst und wie du zwei Operatoren in eine Form bringst, in der sie verglichen werden können. Beginne hier, wenn du einen Hamiltonoperator aufschreibst.

Ein BosonOperator ist eine exakte Summe von Produkten bosonischer Erzeugungs- und Vernichtungsoperatoren mit komplexen Koeffizienten. Er enthält keinen Besetzungs-Cutoff. Eine bosonische Mode kann beliebig viele Bosonen enthalten, ihr Fock-Raum ist also unendlichdimensional, und eine Abschneidung tritt erst auf, wenn der Operator auf Qubits abgebildet wird (siehe Den Fock-Raum abschneiden). Alles in diesem Leitfaden geschieht daher in der exakten Algebra der kanonischen Vertauschungsrelationen,

[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 \, .

Zwei Folgerungen prägen die Schnittstelle. Ein Modenindex kann innerhalb eines einzelnen Terms wiederkehren, weil bi†bi†b_i^\dagger b_i^\dagger ein gültiger Operator ist, der zwei Bosonen erzeugt. Außerdem führt das Vertauschen zweier Faktoren nie ein Vorzeichen ein, obwohl das Verschieben eines Vernichtungsoperators an einem Erzeugungsoperator derselben Mode vorbei einen Term hinzufügt, bibi†=bi†bi+1b_i b_i^\dagger = b_i^\dagger b_i + 1.

Speicherung​

Ein Operator hält seine Terme in vier flachen Arrays, ähnlich dem Compressed-Sparse-Row-Format einer dünnbesetzten Matrix:

coeffsEin komplexer Koeffizient pro Term.
actionsEin Boolescher Wert pro Faktor: True für einen Erzeugungs- und False für einen Vernichtungsoperator.
modesEin Modenindex pro Faktor.
boundariesDie Positionen in actions und modes, an denen jeder Term beginnt und endet.

Der Konstruktor nimmt diese vier Arrays direkt entgegen. Der folgende Operator hat drei Terme, nämlich 2b0†b12 b_0^\dagger b_1, −ib1†b1†-i b_1^\dagger b_1^\dagger und die Konstante 0.50.5, und du kannst die Arrays mit den get_*-Methoden zurücklesen:

>>> 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

Der erste Term umfasst die Positionen 0 bis 2 von actions und modes, der zweite 2 bis 4 und der dritte 4 bis 4; so wird der konstante Term, ein Term ohne Faktoren, gespeichert. Die wiederholte Mode 1 im zweiten Term bleibt wie geschrieben erhalten; ein Term wird nie verworfen oder zusammengeführt, nur weil ein Modenindex doppelt vorkommt.

Hinweis

Ein mit from_dict() erstellter Operator speichert seine Terme nicht in der Reihenfolge des Dictionarys, daher können seine Arrays die Terme von Lauf zu Lauf in anderer Reihenfolge auflisten. Der Operator selbst ist derselbe, und nichts unten, was Operatoren vergleicht oder ausgibt, hängt von der gespeicherten Reihenfolge ab.

Das Layout macht Konstruktion und Arithmetik günstig, mit einer Folge: Ein Operator kann denselben Term mehrfach gleichzeitig enthalten. Summen und Produkte hängen Terme an und führen sie nicht zusammen. Rufe daher simplify() auf, wenn du eindeutige Terme brauchst (siehe Arithmetik durchführen).

Operatoren erstellen​

Der direkteste Weg, einen Operator zu erstellen, ist from_dict(), das ein Dictionary von einem Tupel von Faktoren auf einen Koeffizienten entgegennimmt. Die Funktionen cre() und ann() erzeugen die Faktoren, und das leere Tupel ist der konstante Term:

>>> 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)

In der ausgegebenen Form steht +0 für b0†b_0^\dagger und -0 für b0b_0. Die Konstruktoren zero() und one() liefern das additive bzw. das multiplikative neutrale Element, und from_terms() baut einen Operator aus dem Iterator von iter_terms() wieder auf.

Die Operatorbibliothek stellt die dimensionslosen Orts- und Impulsquadraturen bereit,

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}} \, ,

als position() und momentum(). Zusammen mit den Kommutatorfunktionen desselben Moduls kannst du die Algebra direkt prüfen:

>>> 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

Das Modul stellt außerdem anti_commutator() und double_commutator() bereit. Alle drei nehmen Operatoren desselben Typs entgegen und liefern einen neuen Operator zurück.

Auf die Reihenfolge eines Produkts achten​

Zwei Operatoren lassen sich auf zwei Arten multiplizieren, und man verwechselt sie leicht. Der Operator @ ist das mathematische Produkt, also ist a @ b gleich ABAB. Der Operator & ist die Komposition, die Qiskits Konvention folgt, von links nach rechts zu lesen: a & b wendet zuerst a und dann b an, ist also BABA. Die Kommutatorfunktionen verwenden @, also ist commutator(a, b) gleich [A,B][A, B]. Wenn du die Klammer selbst mit & baust, kehrst du stillschweigend ihr Vorzeichen um:

>>> (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

Einen Operator untersuchen​

Ein Operator ist nicht direkt iterierbar, weil seine Terme in den oben genannten flachen Arrays liegen. Verwende stattdessen iter_terms(), das für jeden Term eine Liste von (is_creation, mode)-Paaren und einen Koeffizienten liefert. Der Iterator gibt Kopien zurück, sodass eine Änderung daran den Operator nicht verändert.

>>> 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))]

Mehrere Methoden fassen die Struktur eines Operators zusammen:

>>> hopping.get_support()
{0, 1, 2}
>>> hopping.min_num_modes()
3
>>> hopping.max_rank()
4

get_support() ist die Menge der Moden, auf die der Operator wirkt, min_num_modes() ist das kleinste Register, das ihn aufnehmen kann, und max_rank() ist die Länge seines längsten Terms.

Hinweis

Eine Mode ist ein abstrakter Index, der einen bosonischen Freiheitsgrad kennzeichnet. Sie hat keine eigene Bedeutung: Du bestimmst, für welchen Gitterplatz, welche Schwingungskoordinate oder welchen optischen Port eine Mode steht. Der BosonicCircuit verwendet dieselben Indizes (siehe Mit bosonischen Circuits arbeiten), sodass ein Operator und die Gates, die ihn tragen, sich darüber einig sind, was Mode 2 ist, ohne dass einer von beiden weiß, was sie darstellt. Nichts im Operator legt fest, wie viele Niveaus eine Mode hat, weshalb die Registergröße auch nur eine untere Schranke ist; min_num_modes() ist das kleinste Register, das den Operator aufnimmt, und keine Eigenschaft von ihm.

Die Besetzungsmethoden geben an, wie viele Bosonen ein einzelner Term in einer Mode erzeugen kann. Das ist eine untere Schranke für den Cutoff, den eine Abbildung braucht. Sie ist kein ausreichender Cutoff, weil wiederholte Anwendungen des Operators, etwa bei einer Zeitentwicklung, höhere Niveaus besetzen können (siehe Den Fock-Raum abschneiden):

>>> hopping.mode_occupations()
[1, 1, 2]
>>> hopping.mode_occupations([2, 0])
[2, 1]
>>> hopping.max_occupation()
2

Zwei weitere Prüfungen betrachten Erhaltungsgrößen. Ein Operator erhält die Teilchenzahl, wenn jeder Term ebenso viele Erzeugungs- wie Vernichtungsoperatoren hat. Übergibt man eine Liste von Blockgrößen, wird dieselbe Bedingung innerhalb jedes Blocks aufeinanderfolgender Moden geprüft:

>>> 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

Schließlich benennt relabel_modes() Moden um: Mode i wird zu permutation[i]. Die Liste muss lang genug sein, um jede Mode des Operators zu indizieren, und darf keinen Eintrag wiederholen:

>>> print(format(move.relabel_modes([5, 6, 7])))
1.000000e0 +0.000000e0j * (+5 -7)

Arithmetik durchführen​

Operatoren unterstützen Addition, Subtraktion, Negation sowie Multiplikation und Division mit einer Zahl, in der üblichen Operatorform oder an Ort und Stelle:

>>> total = 2 * b_dag + b_dag - 3j * b
>>> len(total)
3
>>> len(total.simplify())
2
>>> total.simplify().equiv(3 * b_dag - 3j * b)
True

Die drei Terme von total wurden nie zusammengeführt, der Operator enthielt also b_dag zweimal, bis simplify() sie zusammenfasste. Die Methode summiert die Koeffizienten identischer Terme und verwirft diejenigen, deren Gesamtbetrag unter einer Toleranz liegt. Es ist eine strukturelle Operation: Sie vergleicht die geschriebenen Faktoren und wendet die Vertauschungsrelationen nicht an. Die verwandte Methode ichop() entfernt kleine Koeffizienten an Ort und Stelle, ohne vorher zusammenzuführen. Rufe daher simplify() vor ichop() auf, wenn ein Operator doppelte Terme enthalten kann.

Produkte verwenden @ oder & wie oben beschrieben, und ** potenziert einen Operator mit einer ganzen Zahl:

>>> 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)

Das Ergebnis listet die vier Anordnungen wie geschrieben auf. Sie auf eine einzige Form zu reduzieren, erfordert die Vertauschungsrelationen, die der nächste Abschnitt anwendet.

Die Adjungierte kehrt die Reihenfolge der Faktoren in jedem Term um, vertauscht Erzeugungs- und Vernichtungsoperatoren und konjugiert die Koeffizienten. Die Methode is_hermitian() prüft, ob ein Operator gleich seiner Adjungierten ist:

>>> 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

Ein einzelner Hopping-Term ist nicht hermitesch, was relevant ist, wenn du Terme für eine Zeitentwicklung gruppierst: Jede Gruppe muss für sich hermitesch sein (siehe Operatorterme gruppieren: die Operatorstruktur nutzen).

Normalordnung und Operatoren vergleichen​

Zwei Produkte von Leiteroperatoren können derselbe Operator sein und trotzdem verschieden aussehen, weil b b†b\,b^\dagger und b†b+1b^\dagger b + 1 in der Algebra gleich sind. In diesem Sinne gleiche Operatoren müssen nicht gleich gespeichert sein, daher braucht ihr Vergleich die Vertauschungsrelationen. Die Methode normal_ordered() wendet sie an: Sie verschiebt jeden Erzeugungsoperator links von jeden Vernichtungsoperator und fügt jedes Mal einen Term hinzu, wenn ein Paar derselben Mode vertauscht wird.

>>> 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)

Der geordnete Operator wird als nicht zusammengeführte Summe zurückgegeben, mit fünf gespeicherten Termen, die nach dem Zusammenführen durch simplify() auf drei schrumpfen. Die Normalordnung lässt nie einen Term verschwinden: b†b†b^\dagger b^\dagger und jeder andere wiederholte Faktor bleiben erhalten.

Das Argument sandwich legt fest, wie die Moden innerhalb der beiden Faktorgruppen angeordnet werden. Standardmäßig werden beide Gruppen absteigend sortiert. Mit sandwich=True sind die Erzeugungsoperatoren aufsteigend und die Vernichtungsoperatoren absteigend sortiert, wodurch die größeren Indizes zur Mitte hin liegen:

>>> 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))]

Der Operator == prüft die exakte Gleichheit der gespeicherten Terme, ohne Toleranz und ohne Vereinfachung, sodass er einen Unterschied zwischen zwei mathematisch gleichen Operatoren melden kann. Die Methode equiv() bringt zuerst die Differenz der beiden Operatoren in Normalordnung und prüft dann, ob jeder Koeffizient unter einer Toleranz liegt. Es ist der Vergleich, den du fast immer willst:

>>> 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

Die Methode simplify allein hält die beiden Formen auseinander, weil sie nur identische geschriebene Terme zusammenführt. Ebenso vergleicht is_hermitian() einen Operator mit seiner Adjungierten in Normalordnung, erkennt also b b†+b†bb\,b^\dagger + b^\dagger b ohne weiteren Aufwand als hermitesch.

Hinweis

Die Normalordnung ist in der nicht abgeschnittenen Algebra eine exakte Identität, und equiv stützt sich darauf. Nach einer Abschneidung ist sie keine Identität. Ein abgebildeter Operator hängt von der Form ab, die du abbildest, sodass sich die abgebildeten Bilder von contracted und reordered oben unterscheiden, und zwar nur im höchsten beibehaltenen Niveau. Siehe Den Fock-Raum abschneiden für die Größe des Unterschieds und die Wahl einer Form.

Terme ordnen und filtern​

Das Umordnen der Terme eines Operators, im Gegensatz zu den Faktoren innerhalb eines Terms, ändert den Operator nicht, kann aber das Verhalten von Algorithmen ändern, die die Terme der Reihe nach lesen, etwa eine randomisierte Produktformel. Die Funktionen von qiskit_bosons.operators.terms geben einen neuen Operator zurück und lassen alle Gruppenindizes an ihren Termen haften.

canonical_order() sortiert die Terme in eine feste Reihenfolge, die nur von den geschriebenen Faktoren abhängt, sodass ein Operator unabhängig davon, wie er zusammengesetzt wurde, gleich gelesen wird. order_terms() sortiert nach einem Schlüssel, den du angibst und der jeden Term als das Tupel erhält, das iter_terms() liefert:

>>> 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)]

Wenn ein Operator Gruppenindizes trägt (siehe Operatorterme gruppieren: die Operatorstruktur nutzen), macht die Funktion group_order() jede Gruppe zu einer zusammenhängenden Folge von Termen:

>>> 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]

Der letzte Helfer entfernt Terme, statt sie umzuordnen. Ein Term, der ein Produkt von Teilchenzahloperatoren bi†bib_i^\dagger b_i ist, multipliziert jeden Fock-Zustand nur mit einer Phase, sodass filter_diagonal_terms() die Konstante, die Teilchenzahloperatoren und ihre Produkte verwerfen kann, wenn diese Phase für das, was du berechnest, irrelevant ist. Es verändert den Operator an Ort und Stelle und setzt einen normalgeordneten Operator voraus, was es nicht prüft:

>>> 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

Nur die beiden Hopping-Terme bleiben übrig. Die Wechselwirkung am Gitterplatz und der Teilchenzahloperator sind in der Besetzungsbasis diagonal, daher werden sie zusammen mit der Konstante entfernt. Die wiederholte Mode in der Wechselwirkung wird korrekt behandelt: b0†b0†b0b0b_0^\dagger b_0^\dagger b_0 b_0 ist diagonal, während b0†b0†b0b_0^\dagger b_0^\dagger b_0 es nicht wäre.

Nächste Schritte​