Optymalizacja przygotowania wyznacznika Slatera
Pojęcia z tego przewodnika są obecnie dostępne tylko w API Pythona. Równoważna funkcjonalność zostanie udostępniona w API C w przyszłym wydaniu.
Bramka InitializeModes deklaruje referencyjne obsadzenie trybów; bramka OrbitalRotation obraca bazę jednocząstkową. Umieszczone jedna po drugiej, obie bramki przygotowują wyznacznik Slatera; tryby startują w wyznaczniku referencyjnym, a następnie są obracane do orbitali docelowych. Bramka PrepareSlaterDeterminant ujmuje tę kompozycję, a ponieważ niesie referencyjne obsadzenie, jej transpilacja może użyć prostokątnej givens_decomposition_slater(), która realizuje tylko orbitale obsadzone, zamiast pełnego kwadratowego rozkładu, którego wymaga sama bramka OrbitalRotation. Jest to ściśle tańsza synteza (zobacz korzyść na końcu tego przewodnika).
Przebieg transpilera MergeSlaterDeterminantPreparation wykrywa w FermionicCircuit wzorzec InitializeModes a potem OrbitalRotation i przepisuje go na bramki PrepareSlaterDeterminant, dzięki czemu późniejszy etap syntezy automatycznie korzysta ze zredukowanego rozkładu. W symulacji przepisanie nie zmienia stanu: PrepareSlaterDeterminant działa w trybie sprawdź, a potem obróć (sprawdza referencyjne obsadzenie, a następnie stosuje rotację, tak jak dwie osobne bramki), więc scalenie odblokowuje tańszą syntezę bez zmiany przygotowanego stanu.
W całym tym przewodniku każdy obwód jest rysowany przed przebiegiem i po nim, obok siebie: wejście po lewej, a wynik uruchomienia przebiegu po prawej. Robią to dwie funkcje pomocnicze. merge uruchamia przebieg na kopii obwodu (przez jego FermionicDAGCircuit) i konwertuje wynik z powrotem na rysowalny FermionicCircuit, a draw_merge rysuje obie połowy na jednym rysunku przed/po:
>>> import matplotlib.pyplot as plt
>>> import numpy as np
>>>
>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import InitializeModes, OrbitalRotation
>>> from qiskit_fermions.transpiler import FermionicCircuitToDAG
>>> from qiskit_fermions.transpiler.converters import FermionicDAGToCircuit
>>> from qiskit_fermions.transpiler.passes import MergeSlaterDeterminantPreparation
>>>
>>> def merge(circuit):
... dag = FermionicCircuitToDAG().run(circuit)
... merged = MergeSlaterDeterminantPreparation().run(dag)
... return FermionicDAGToCircuit().run(merged)
>>>
>>> def natural_size(circuit):
... # the figure size Qiskit picks for a circuit on its own (discard the probe figure)
... probe = circuit.draw("mpl")
... size = probe.get_size_inches()
... plt.close(probe)
... return size
>>>
>>> def draw_merge(circuit):
... merged = merge(circuit)
... # let Qiskit size each half naturally, then lay them out side by side
... (bw, bh), (aw, ah) = natural_size(circuit), natural_size(merged)
... fig, (before, after) = plt.subplots(1, 2, figsize=(bw + aw, max(bh, ah)))
... circuit.draw("mpl", ax=before)
... merged.draw("mpl", ax=after)
... before.set_title("before")
... after.set_title("after")
... return fig
Co zostaje scalone
Przebieg rozpoznaje trzy wzorce, wszystkie oparte na konwencji trybów z blokami spinowymi, której używają InitializeModes i OrbitalRotation. Dla układu z norb orbitalami przestrzennymi tryby 0..norb to sektor spinu alfa, a norb..2*norb to sektor spinu beta. Przygotowanie wyznacznika Slatera wykonuje się osobno dla każdego sektora spinowego, ponieważ każdy sektor ma własny kształt (elektrony, orbitale).
Pełny rejestr (lub bezspinowy)
Najprostszy wzorzec to użycie InitializeModes, po którym od razu następuje OrbitalRotation na tych samych trybach. Jest to przypadek bezspinowy (jeden wyznacznik na wszystkich trybach), a także obwód ze spinem, którego rotacja obejmuje oba sektory naraz. Obie bramki łączą się w pojedynczą PrepareSlaterDeterminant:
>>> circuit = FermionicCircuit(4)
>>> circuit.append(InitializeModes([1, 1, 0, 0]), circuit.modes)
>>> circuit.append(OrbitalRotation(np.eye(4)), circuit.modes)
>>> draw_merge(circuit)
<Figure size ... with 2 Axes>
Na sektor spinowy
Jest to ten sam kształt, ale ograniczony do jednej połowy spinowej. Tutaj dwie niezależne pary inicjalizacja-rotacja, po jednej na sektor, łączą się osobno w PrepareSlaterDeterminant:
>>> circuit = FermionicCircuit(6) # norb = 3: alpha modes 0..3, beta modes 3..6
>>> circuit.append(InitializeModes([1, 1, 0]), circuit.modes[:3])
>>> circuit.append(OrbitalRotation(np.eye(3)), circuit.modes[:3])
>>> circuit.append(InitializeModes([1, 0, 0]), circuit.modes[3:])
>>> circuit.append(OrbitalRotation(np.eye(3)), circuit.modes[3:])
>>> draw_merge(circuit)
<Figure size ... with 2 Axes>
Inicjalizacja globalna, rotacje na spin
Oznacza to pojedynczy InitializeModes na pełnym rejestrze (2 * norb), po którym następują dwie akcje OrbitalRotationactions, po jednej na każdą ciągłą połowę spinową. Przebieg dzieli globalne obsadzenie na granicy sektorów i emituje dwie bramki PrepareSlaterDeterminant. Obie rotacje mogą wystąpić w dowolnej kolejności.
Taki kształt powstaje w przepływie pracy lokalnego unitarnego klastra Jastrowa (LUCJ) (zobacz przewodnik LUCJ); użytkownik umieszcza InitializeModes (zwykle referencję Hartree-Focka, używając from_hartree_fock()) na początku obwodu i dołącza ansatz UCJ, którego pierwsze dwie rotacje orbitali na spin następują bezpośrednio po inicjalizacji po rozłożeniu ansatzu.
>>> circuit = FermionicCircuit(6) # norb = 3, nelec = (2, 1)
>>> circuit.append(InitializeModes.from_hartree_fock(3, (2, 1)), circuit.modes)
>>> circuit.append(OrbitalRotation(np.eye(3)), circuit.modes[:3])
>>> circuit.append(OrbitalRotation(np.eye(3)), circuit.modes[3:])
>>> draw_merge(circuit)
<Figure size ... with 2 Axes>
Ten wzorzec działa także wtedy, gdy tylko jedna połowa spinowa jest obracana przez bramkę następującą bezpośrednio po inicjalizacji – taki kształt powstaje, gdy rotacja orbitali tylko jednego sektora spinowego znajduje się na początku obwodu. Obrócona połowa staje się bramką PrepareSlaterDeterminant ze swoją rotacją, a druga połowa jest przygotowywana z rotacją tożsamościową. Przygotowanie tożsamościowe syntezuje się do referencyjnych bramek X, które InitializeModes i tak wyemitowałby dla tej połowy, więc dopełnienie nie kosztuje dodatkowych bramek, a nadal odblokowuje zredukowaną syntezę Slatera na obróconej połowie:
>>> circuit = FermionicCircuit(6)
>>> circuit.append(InitializeModes.from_hartree_fock(3, (2, 1)), circuit.modes)
>>> circuit.append(OrbitalRotation(np.eye(3)), circuit.modes[:3])
>>> draw_merge(circuit)
<Figure size ... with 2 Axes>
Co pozostaje nietknięte
Przebieg jest celowo ostrożny. Łączy bramki tylko wtedy, gdy rzeczywiście składają się w przygotowanie wyznacznika Slatera, a wszystko inne kopiuje bez zmian. „Bezpośrednio po” rozumiane jest na grafie przepływu danych obwodu. OrbitalRotation łączy się tylko wtedy, gdy InitializeModes jest jej jedynym poprzednikiem na wszystkich jej trybach. Na poniższych rysunkach stan przed i po jest identyczny: nic nie zostało scalone.
Operacja pomiędzy
Jeśli jakakolwiek bramka dotyka trybów między inicjalizacją a rotacją, nie składają się już w czyste przygotowanie, więc nic nie jest scalane:
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.operators import FermionOperator, ann, cre
>>>
>>> circuit = FermionicCircuit(4)
>>> circuit.append(InitializeModes([1, 1, 0, 0]), circuit.modes)
>>> number_op = FermionOperator.from_dict({(cre(0), ann(0)): 1.0})
>>> circuit.append(Evolution(4, number_op, time=0.5), circuit.modes)
>>> circuit.append(OrbitalRotation(np.eye(4)), circuit.modes)
>>> draw_merge(circuit)
<Figure size ... with 2 Axes>
Rotacja częściowa, która nie jest połową spinową
Rotacja obejmująca dokładnie jedną ciągłą połowę spinową inicjalizacji pełnego rejestru łączy się (zobacz poprzednią sekcję). Ale rotacja na dowolnym innym częściowym zbiorze trybów (mniej trybów niż w inicjalizacji i niebędącym żadną z jej dwóch połów spinowych, na przykład podzakres przekraczający granicę sektorów) nie jest przygotowaniem na sektor. Scalenie go po cichu odrzuciłoby rolę inicjalizacji na pominiętych trybach, więc przebieg zostawia obie bramki na miejscu:
>>> circuit = FermionicCircuit(4) # norb = 2: the only spin halves are modes [0, 1] and [2, 3]
>>> circuit.append(InitializeModes([1, 1, 0, 0]), circuit.modes)
>>> circuit.append(OrbitalRotation(np.eye(3)), circuit.modes[:3])
>>> draw_merge(circuit)
<Figure size ... with 2 Axes>
Dlaczego to ważne: korzyść z syntezy
Scalenie się opłaca, ponieważ PrepareSlaterDeterminant syntezuje się do znacznie mniejszej liczby bramek niż pochłaniana przez nią OrbitalRotation. Wyznacznik elektronów na orbitalach wymaga co najwyżej rotacji dwukubitowych i żadnych diagonalnych bramek fazowych, w porównaniu z rotacjami plus fazami pełnej kwadratowej rotacji orbitali.
Preset Jordana-Wignera pass managera uruchamia MergeSlaterDeterminantPreparation na etapie optymalizacji, więc ta redukcja następuje automatycznie. Przetranspiluj przygotowanie dwóch elektronów na sześciu orbitalach aż do QuantumCircuit i policz jego bramki:
Preset uruchamia MergeOrbitalRotations bezpośrednio przed MergeSlaterDeterminantPreparation. Ma to znaczenie, gdy po inicjalizacji następuje ciąg kolejnych bramek OrbitalRotation. Wcześniejszy przebieg najpierw zwija ciąg do pojedynczej rotacji, którą MergeSlaterDeterminantPreparation następnie kontrahuje z inicjalizacją w jedną PrepareSlaterDeterminant. Bez takiej kolejności pochłonięta zostałaby tylko pierwsza rotacja z ciągu, a reszta byłaby syntezowana pełnym rozkładem kwadratowym.
>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> # a reproducible random 6 x 6 orbital rotation
>>> rng = np.random.default_rng(1)
>>> z = rng.standard_normal((6, 6)) + 1j * rng.standard_normal((6, 6))
>>> q, r = np.linalg.qr(z)
>>> rotation = q * (np.diagonal(r) / np.abs(np.diagonal(r)))
>>>
>>> circuit = FermionicCircuit(6)
>>> circuit.append(InitializeModes([1, 1, 0, 0, 0, 0]), circuit.modes)
>>> circuit.append(OrbitalRotation(rotation), circuit.modes)
>>>
>>> pm = generate_preset_jw_pass_manager()
>>> prepared = pm.run(circuit)
>>> print(dict(sorted(prepared.count_ops().items())))
{'x': 2, 'xx_plus_yy': 8}
>>> prepared.draw("mpl", fold=-1)
<Figure size ... with 1 Axes>
Porównaj to z syntezą tej samej rotacji orbitali samodzielnie. Bez poprzedzającej inicjalizacji nie ma czego scalać, więc preset wraca do pełnego rozkładu kwadratowego, który jest głębszy i zawiera diagonalne bramki fazowe:
>>> reference = FermionicCircuit(6)
>>> reference.append(OrbitalRotation(rotation), reference.modes)
>>> reference_prepared = pm.run(reference)
>>> print(dict(sorted(reference_prepared.count_ops().items())))
{'rz': 6, 'xx_plus_yy': 15}
>>> reference_prepared.draw("mpl", fold=-1)
<Figure size ... with 1 Axes>

Zredukowany rozkład eliminuje wszystkie sześć bramek fazowych i prawie o połowę zmniejsza liczbę rotacji dwukubitowych. Przygotowany wyznacznik jest poprawny z dokładnością do fazy globalnej (fizycznie nieistotnej przy przygotowaniu stanu), którą naprawiłyby bramki fazowe.
Dwa sposoby rozłożenia bramki
PrepareSlaterDeterminant można rozłożyć na dwa różne sposoby i nie są one wymienne:
decompose()
Rozwija bramkę do jej definicji: InitializeModes referencji, a po nim pełna OrbitalRotation. Jest to równoważne bramce jako operatorowi, dla dowolnego stanu wejściowego.
PrepareSlaterDeterminant.synthesis, stosowane przez F2FSynthesis
Zastępuje bramkę zredukowanym rozkładem z tego przewodnika. Jest to równoważne bramce tylko jako przygotowaniu stanu: daje ten sam wyznacznik, z dokładnością do fazy globalnej, gdy tryby startują w próżni. Jest tańsze właśnie dlatego, że to zakłada.
Używaj syntezy, gdy bramka przygotowuje stan na próżni, do czego służy i co transpiler robi za ciebie. Używaj decompose(), gdy bramka ma nadal działać jako operator, na przykład dlatego, że inne bramki poprzedzają ją na tych samych trybach, albo gdy chcesz sprawdzić lub zasymulować zwykłe bramki. Przebieg F2FSynthesis nie może wiedzieć, z którym przypadkiem ma do czynienia, więc to od przekazanego mu obwodu zależy, czy przygotowuje wyznacznik z próżni.
Symulacja obwodu za pomocą ffsim nie wymaga żadnego z nich: każda bramka stosuje całą swoją unitarność, więc obwód zawierający PrepareSlaterDeterminant jest symulowany w całości i pozostaje na szybkiej ścieżce dla otaczających go bramek OrbitalRotation. Rozłożenie tylko bramki Slatera przez circuit.decompose("PrepareSlaterDeterminant") również zostawia te rotacje w całości.
PrepareSlaterDeterminant, InitializeModes, OrbitalRotation, MergeOrbitalRotations, F2FSynthesis, FermionicSlaterGivensDecomposition oraz przewodnik LUCJ dotyczący przepływu pracy, który tworzy wzorzec inicjalizacji globalnej.
