Synteza bramki Evolution w przestrzeni fermionowej
Koncepcje opisane w tym przewodniku są obecnie dostępne tylko w API Pythona. Równoważna funkcjonalność zostanie udostępniona w API C w przyszłym wydaniu.
Bramka Evolution przenosi ewolucję czasową całego operatora. Synteza fermion–fermion przepisuje tę bramkę na FermionicCircuit złożony z mniejszych bramek fermionowych. Obie strony przepisania pozostają w przestrzeni fermionowej, co odróżnia je od następującej później syntezy fermion–kubit: groups operatora (zobacz Grupowanie wyrazów operatora: wykorzystanie struktury operatora) przetrwają ją, podczas gdy mapowanie fermion–kubit zwraca SparseObservable, który nie zna pojęcia grupowania.
Metodę wybierają dwa pokrętła. Evolution.synthesis ustawia ją dla jednej bramki, a przebieg transpilatora F2FSynthesis ustawia ją dla całego obwodu. Oba przyjmują F2FSynthesisPlugin, czyli interfejs, który implementuje każde takie przepisanie. Metody, które ten pakiet obecnie udostępnia dla ewolucji, to wzory produktowe, z których korzysta reszta tego przewodnika: FermionicLieTrotter dla wzoru pierwszego rzędu (domyślny) oraz FermionicSuzukiTrotter dla wyższego rzędu parzystego. Cokolwiek innego implementującego ten interfejs wybiera się w ten sam sposób, a sekcje o dwóch pokrętłach stosują się do tego bez zmian.
Ten krok fermion–fermion jest opcjonalny. Bramka Evolution może przejść prosto do etapu fermion–kubit, który mapuje i syntetyzuje ją w całości, niezależnie od liczby zawartych wyrazów. Wcześniejszy podział to wybór, dokonywany, ponieważ powstałe bramki są pojedynczo tańsze lub ponieważ podział ujawnia strukturę, z której może skorzystać późniejszy etap.
Te dwa kroki się składają, więc ewolucja może zostać przybliżona na jednym poziomie, na obu albo na żadnym. Wzór produktowy jest przybliżeniem, chyba że jego czynniki komutują; metoda syntezy wcale nie musi przybliżać.
Pominięcie tego kroku nie musi oznaczać całkowitej utraty grupowania. Opakowanie mappera w group_wise() mapuje operator grupa po grupie i sumuje wyniki, więc zmapowane wyrazy Pauliego docierają pogrupowane, a nie przemieszane. Wzór produktowy po stronie kubitów syntetyzuje je w kolejności, w jakiej je otrzymuje, więc komutujące wyrazy, które znajdą się obok siebie, można zaplanować razem.
Na poniższym hamiltonianie odzyskuje to całą korzyść z głębokości dwukubitowej wynikającą z podziału: 18 zamiast 30, przy niezmienionej liczbie bramek. Różnica polega na tym, gdzie wyrażona jest struktura. Podział w przestrzeni fermionowej czyni każdą grupę osobną bramką, którą widzi i na której może działać każdy późniejszy etap; group_wise zostawia jedną bramkę i tylko zmienia kolejność wyrazów w jej wnętrzu, co wystarcza do planowania, ale nie zostawia nic do pracy dla przebiegu fermionowego. Użyj go, gdy ewolucja musi pozostać pojedynczą bramką, a w pozostałych przypadkach sięgnij po fermionowy wzór produktowy.
Budowanie pogrupowanego hamiltonianu
Oba wzory produktowe dzielą operator na czynniki, a dla operatora pogrupowanego tymi czynnikami są jego grupy. Grupowanie decyduje więc zarówno o koszcie, jak i o dokładności wyniku. Weźmy łańcuch Fermiego-Hubbarda o sześciu węzłach, pogrupowany tak, jak komutują jego wyrazy: wyrazy przeskoku na wiązaniach parzystych, wyrazy przeskoku na wiązaniach nieparzystych oraz oddziaływanie diagonalne.
>>> from qiskit_fermions.operators import FermionOperator, cre, ann
>>>
>>> def fermi_hubbard_chain(num_sites, tunneling, interaction):
... """A 1D Fermi-Hubbard chain grouped into even bonds, odd bonds, and the interaction."""
... terms, groups = [], []
... for offset in (0, 1):
... for j in range(offset, num_sites - 1, 2):
... terms += [
... ([cre(j), ann(j + 1)], -tunneling),
... ([cre(j + 1), ann(j)], -tunneling),
... ]
... groups += [offset, offset]
... for j in range(num_sites - 1):
... terms.append(([cre(j), ann(j), cre(j + 1), ann(j + 1)], interaction))
... groups.append(2)
... operator = FermionOperator.from_terms(terms)
... operator.groups = groups
... return operator
>>>
>>> num_sites = 6
>>> hamiltonian = fermi_hubbard_chain(num_sites, tunneling=1.0, interaction=2.0)
>>> hamiltonian.num_groups()
3
Piętnaście wyrazów w trzech grupach. Każda grupa przeskoków łączy każdy wyraz z jego sprzężonym partnerem, co ma znaczenie: czynnik musi być hermitowski, aby jego wykładnik był unitarny, a to nie wynika z hermitowskości sumy. groups_are_hermitian() sprawdza tę konwencję, zamiast pozostawiać ją oględzinom:
>>> from qiskit_fermions.operators.terms.grouping import groups_are_hermitian
>>>
>>> groups_are_hermitian(hamiltonian)
[True, True, True]
Rozdzielenie od jego partnera złamałoby ją, a pozostawienie operatora niepogrupowanego ma ten sam skutek, ponieważ oba wzory dzielą go wtedy wyraz po wyrazie, a pojedyncze nie jest hermitowskie:
>>> ungrouped = fermi_hubbard_chain(num_sites, tunneling=1.0, interaction=2.0)
>>> ungrouped.groups = range(len(list(ungrouped.iter_terms()))) # one group per term
>>> all(groups_are_hermitian(ungrouped))
False
Taki czynnik jest poprawnym operatorem fermionowym i ten pakiet zbuduje dla niego bramkę, więc nic nie zgłasza problemu, dopóki zmapowany operator nie dotrze do PauliEvolutionGate, który wymaga współczynników rzeczywistych i zgłasza ValueError: Operator contains complex coefficients, which are not supported. Sprawdzenie grupowania z góry zamienia ten późny błąd w odpowiedź dostępną przed zbudowaniem jakiegokolwiek obwodu.
Ustawianie metody dla jednej bramki
Przekaż metodę syntezy do bramki, a następnie ją zdekomponuj. Każda bramka, którą emituje, wraca jako osobna bramka Evolution:
>>> from qiskit_fermions.circuit import FermionicCircuit
>>> from qiskit_fermions.circuit.library import Evolution
>>> from qiskit_fermions.circuit.library.synthesis import (
... FermionicLieTrotter,
... FermionicSuzukiTrotter,
... )
>>>
>>> total_time = 1.0
>>>
>>> def factors(synthesis):
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(num_sites, hamiltonian, time=total_time, synthesis=synthesis),
... circuit.modes,
... )
... return circuit.decompose().count_ops()["Evolution"]
>>>
>>> factors(FermionicLieTrotter())
3
>>> factors(FermionicSuzukiTrotter(order=2))
5
>>> factors(FermionicSuzukiTrotter(order=2, reps=4))
17
>>> factors(FermionicSuzukiTrotter(order=2, reps=4, merge_adjacent=False))
20
Pierwszy rząd emituje jeden czynnik na grupę. Rząd drugi emituje symetryczny palindrom, więc dwie zewnętrzne grupy pojawiają się dwukrotnie, każda na połowę czasu, co daje pięć. Cztery powtórzenia dałyby dwadzieścia, ale każde powtórzenie kończy się na tej samej grupie, od której zaczyna się następne. Te sąsiednie bramki ewoluują pod działaniem tego samego operatora, więc są łączone w jedną bramkę o zsumowanym czasie, co oszczędza jedną bramkę na złączenie. Przekaż merge_adjacent=False, aby zachować podręcznikową sekwencję. Każdy rząd dzieli operator w ten sam sposób; zmienia się kolejność czynników i ich czasy ewolucji, a nie podział.
Czynniki są oznaczone jako atomic, więc ponowna dekompozycja zostawia je na miejscu zamiast dzielić dalej. Dekompozycja ma punkt stały, dzięki czemu można bezpiecznie wywoływać decompose() wielokrotnie.
Ustawianie metody dla całego potoku
Ustawianie synthesis dla każdej bramki z osobna oznacza przeprowadzanie wyboru przez wszystko, co je buduje, w tym przez UCC i UCJ, które konstruują swoje bramki wewnętrznie. Przebieg F2FSynthesis stosuje natomiast jedną metodę do każdej bramki Evolution w obwodzie, więc wyboru dokonuje się raz dla całego potoku. Konfiguruje się go według nazwy bramki, tak samo jak F2QSynthesis, a bramka, która nie została nazwana, pozostaje bez zmian:
>>> from qiskit_fermions.transpiler import FermionicPassManager
>>> from qiskit_fermions.transpiler.passes import F2FSynthesis
>>>
>>> circuit = FermionicCircuit(num_sites)
>>> circuit.append(Evolution(num_sites, hamiltonian, time=total_time), circuit.modes)
>>>
>>> expanded = FermionicPassManager(
... F2FSynthesis({"Evolution": ("SuzukiTrotter", (), {"order": 2, "reps": 4})})
... ).run(circuit)
>>> expanded.count_ops()["Evolution"]
17
Jest to ta sama liczba, którą daje decompose() dla tej metody powyżej. Przebieg MergeEvolutions stosuje to samo łączenie do sąsiednich bramek, które nie pochodzą z jednej syntezy, na przykład na złączeniach między kolejnymi obwodami. Przebieg sam realizuje wzór, więc nie jest potrzebny osobny krok rozwijania, a jego dwukrotne uruchomienie niczego nie zmienia, ponieważ emitowane przez niego czynniki są atomic i nie mają własnej definicji.
Przekaż apply=False, aby jedynie wybrać metodę, a rozwinięcie pozostawić czemuś innemu, na przykład Decompose z Qiskit (nazwanie bramki jako Decompose("Evolution") ogranicza go do ewolucji, a pozostałe bramki fermionowe zostawia ich własnym wtyczkom syntezy).
Jest to przydatne, aby wybrać metodę dla całego obwodu z góry, odkładając rozwinięcie do chwili, gdy jest potrzebne, na przykład aby najpierw sprawdzić obwód:
>>> selected = FermionicPassManager(
... F2FSynthesis({"Evolution": ("SuzukiTrotter", (), {"order": 2})}, apply=False)
... ).run(circuit)
>>> selected.count_ops()["Evolution"] # selected, not yet expanded
1
>>> selected.decompose().count_ops()["Evolution"] # expanded on demand
5
Pamiętaj jednak, co robi nierozwinięta bramka: trafia w całości do etapu fermion–kubit, gdzie jest mapowana bez sprawdzania synthesis. Łatwo wtedy o błąd przy porównywaniu metod, ponieważ każda z nich daje identyczny wynik, a porównanie po cichu nic nie mierzy. Na powyższym hamiltonianie pozostawienie bramki drugiego rzędu nierozwiniętej sprowadza znacznie większy obwód poniżej do kosztu pojedynczej zmapowanej ewolucji, niezależnie od wybranej metody.
Pomiar korzyści
Wyższy rząd kosztuje głębokość, więc pytanie nie brzmi, czy jest dokładniejszy, ale czy jest dokładniejszy na bramkę. Dla każdej metody potrzebne są dwie liczby: błąd wytworzonego stanu oraz koszt dwukubitowy obwodu, który go wytworzył.
Błąd jest mierzony za pomocą ffsim, który symuluje FermionicCircuit bezpośrednio (zobacz Relacja tego pakietu do ffsim). Nie występuje żadne mapowanie fermion–kubit, więc porównanie wyodrębnia fermionowy wzór produktowy od wszystkiego, co mogłoby wnieść mapowanie lub synteza po stronie kubitów. Działa też w sektorze stałej liczby cząstek, który jest zarówno mniejszy niż pełna przestrzeń , jak i jest przestrzenią, w której ewolucja faktycznie pozostaje.
>>> import ffsim
>>> import numpy as np
>>> from scipy.sparse.linalg import expm_multiply
>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> norb, nelec = num_sites, (3, 0) # three spinless fermions on six modes
>>> initial = ffsim.hartree_fock_state(norb, nelec)
>>>
>>> hamiltonian_operator = ffsim.linear_operator(hamiltonian, norb=norb, nelec=nelec)
>>> exact = expm_multiply(
... -1j * total_time * hamiltonian_operator,
... initial,
... traceA=-1j * total_time * ffsim.trace(hamiltonian, norb=norb, nelec=nelec),
... )
>>>
>>> def cost_and_error(synthesis):
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(num_sites, hamiltonian, time=total_time, synthesis=synthesis),
... circuit.modes,
... )
... # the method is set on the gate, so decompose() is what expands it here; a pipeline
... # would reach for F2FSynthesis instead (see the note above)
... circuit = circuit.decompose()
... evolved = ffsim.apply_unitary(initial, circuit, norb=norb, nelec=nelec)
... error = 1 - abs(np.vdot(evolved, exact)) ** 2
... qubit_circuit = generate_preset_jw_pass_manager().run(circuit).decompose(reps=6)
... two_qubit = lambda instruction: len(instruction.qubits) == 2
... return error, qubit_circuit.count_ops()["cx"], qubit_circuit.depth(two_qubit)
>>>
>>> methods = [
... ("order 1, reps 1 ", FermionicLieTrotter()),
... ("order 2, reps 1 ", FermionicSuzukiTrotter(order=2)),
... ("order 1, reps 10", FermionicLieTrotter(reps=10)),
... ("order 2, reps 4 ", FermionicSuzukiTrotter(order=2, reps=4)),
... ]
>>> costs = {label: cost_and_error(synthesis) for label, synthesis in methods}
>>> labels, counts, depths = [], [], []
>>> for label, (error, gates, depth) in costs.items():
... labels.append(label.strip().replace(", ", "\n"))
... counts.append(gates)
... depths.append(depth)
... print(f"{label} error {error:.6f}")
order 1, reps 1 error 0.515997
order 2, reps 1 error 0.180958
order 1, reps 10 error 0.004026
order 2, reps 4 error 0.000421
Koszt dwukubitowy został celowo pominięty w tej tabeli. Zależy on od syntezy po stronie kubitów wybieranej przez Qiskit, która poprawia się między wydaniami, więc wypisane tu liczby byłyby migawką, a nie właściwością wzoru produktowego. Zamiast tego przedstawia je poniższy rysunek, a to, na czym opiera się porównanie, jest potwierdzane jako relacja:
Przeczytaj razem dwie ostatnie metody, ponieważ to właśnie one stanowią istotne porównanie. Rząd drugi z czterema powtórzeniami jest mniej więcej dziesięć razy dokładniejszy niż rząd pierwszy z dziesięcioma, a osiąga to przy mniejszej, a nie większej liczbie bramek splatających, jak pokazuje rysunek. Wydanie budżetu bramek na wyższy rząd wypada tu lepiej niż wydanie go na więcej kroków pierwszego rzędu.
Ten wynik nie jest uniwersalny. Zwiększanie reps i zwiększanie order zmniejszają błąd w różnym tempie przy tym samym rodzaju kosztu, a to, co wygrywa, zależy od operatora i od czasu ewolucji. Dwa powyższe wiersze pokazują, jak to sprawdzić dla danego problemu.
Wzór fermionowy składa się ze wzorem wybranym dla etapu fermion–kubit (zobacz MapperFnEvolutionSynthesis.product_formula). Tam, gdzie oba przybliżają, o dokładności wyniku decyduje słabszy z nich, więc podniesienie rzędu tutaj, gdy wzór po stronie kubitów pozostaje pierwszego rzędu, daje niewiele.
Ograniczanie przebiegu do bramek, które na tym zyskują
Wyższy rząd pomaga tylko wtedy, gdy czynniki nie komutują. Jeśli wszystkie komutują, wzór pierwszego rzędu jest już dokładny, a dodatkowe czynniki to czysty narzut. Konkretnie pokazuje to operator czysto diagonalny, ponieważ operatory liczby cząstek komutują ze sobą:
>>> from qiskit_fermions.transpiler.presets import generate_preset_jw_pass_manager
>>>
>>> diagonal = FermionOperator.from_terms(
... [([cre(j), ann(j), cre(j + 1), ann(j + 1)], 1.0 + 0.1 * j) for j in range(num_sites - 1)]
... )
>>> diagonal.groups = list(range(num_sites - 1))
>>>
>>> def diagonal_cost(synthesis):
... circuit = FermionicCircuit(num_sites)
... circuit.append(
... Evolution(num_sites, diagonal, time=total_time, synthesis=synthesis), circuit.modes
... )
... transpiled = generate_preset_jw_pass_manager().run(circuit.decompose())
... return transpiled.decompose(reps=6).count_ops()["cx"]
>>>
>>> diagonal_cost(FermionicLieTrotter())
10
>>> diagonal_cost(FermionicSuzukiTrotter(order=2))
18
Oba obwody są dokładne, więc drugi zużywa o 80% więcej bramek splatających na nic. Diagonalne operatory Coulomba w UCJ mają taką postać, dlatego istnieje F2FSynthesis.filter: przyjmuje predykat nad DAGOpNode i pozostawia odrzucone bramki nietknięte, więc jeden przebieg może podnieść rząd dla bramek, które na tym zyskują, a resztę zostawić na pierwszym rzędzie.
Obwód zawierający oba rodzaje ewolucji pokazuje, ile to oszczędza. Poniższy predykat akceptuje bramkę tylko wtedy, gdy jej operator przenosi cząstki między modami, a właśnie wtedy grupy nie komutują i wyższy rząd ma co zyskać:
>>> def is_diagonal(operator):
... """Whether every term only counts particles, rather than moving them between modes."""
... return all(
... sorted(mode for created, mode in actions if created)
... == sorted(mode for created, mode in actions if not created)
... for actions, _ in operator.iter_terms()
... )
>>>
>>> def mixed_circuit():
... circuit = FermionicCircuit(num_sites)
... circuit.append(Evolution(num_sites, hamiltonian, time=total_time), circuit.modes)
... circuit.append(Evolution(num_sites, diagonal, time=total_time), circuit.modes)
... return circuit
>>>
>>> def two_qubit_cost(circuit):
... transpiled = generate_preset_jw_pass_manager().run(circuit).decompose(reps=6)
... two_qubit = lambda instruction: len(instruction.qubits) == 2
... return transpiled.count_ops()["cx"], transpiled.depth(two_qubit)
>>>
>>> order_2 = FermionicSuzukiTrotter(order=2)
>>>
>>> everywhere = two_qubit_cost(
... FermionicPassManager(F2FSynthesis({"Evolution": order_2})).run(mixed_circuit())
... )
>>> restricted = two_qubit_cost(
... FermionicPassManager(
... F2FSynthesis(
... {"Evolution": order_2}, filter=lambda node: not is_diagonal(node.op.operator)
... )
... ).run(mixed_circuit())
... )
>>> everywhere[0] - restricted[0] # the gates the filter saved
8
>>> restricted[0] < everywhere[0] and restricted[1] < everywhere[1]
True
Bramki, które oszczędza filtr, to dokładnie te, które rząd drugi zużył na ewolucję diagonalną, co powyższe porównanie diagonalne wycenia na osiem (18 wobec 10). Niczego tam nie przyniosły, ponieważ ta ewolucja jest już dokładna w pierwszym rzędzie, więc ich pominięcie nie kosztuje dokładności.
Porównuj dwa obwody drugiego rzędu ze sobą, a nie z nierozwiniętym. Pozostawienie obu bramek nierozwiniętych kieruje każdą w całości do etapu fermion–kubit, co jest błędem opisanym w uwadze o apply=False powyżej: może to dać tę samą liczbę bramek splatających co obwód przefiltrowany, będąc jednocześnie innym obwodem o innej głębokości, więc odczytywanie korzyści z filtra z tej liczby niczego nie mierzy.
Żadne z pokręteł nie weryfikuje, czy czynniki są hermitowskie, a niepoprawnie pogrupowany operator może wyglądać lepiej w rzędzie parzystym niż w pierwszym, będąc nadal błędnym, ponieważ symetryzacja częściowo znosi błąd. Grupuj sprzężonych partnerów razem, tak jak robi to powyżej fermi_hubbard_chain.
Kolejne kroki
-
Przeczytaj Grupowanie wyrazów operatora: wykorzystanie struktury operatora, aby dowiedzieć się, jak przypisywane są grupy, na których dzielą te wzory produktowe.
-
Przeczytaj Transpilacja obwodów fermionowych, aby poznać etapy, przez które przechodzi
FermionicCircuit. -
Zobacz Symulacja dynamiki 1D Fermiego-Hubbarda za pomocą zbiorów przepływu, aby poznać praktyczny przypadek, w którym wzór fermionowy dostarcza liczbę kroków, jakiej nie zapewni niestandardowa synteza po stronie kubitów.
-
Zajrzyj do
synthesis, aby poznać same metody syntezy, oraz doFermionicQDrift, aby poznać randomizowaną alternatywę.