Mapowanie reprezentacji operatorów
Mapper zamienia BosonOperator w operator kubitowy, tutaj SparseObservable. Sam operator nic nie wie o kubitach: przechowuje operatory drabinkowe na modach, a mod bozonowy może zawierać dowolnie wiele cząstek. Wszystko, co czyni operator skończonym (ile poziomów obsadzenia zachowujesz i jak te poziomy są rozmieszczone na kubitach), jest ustalane podczas mapowania.
Mapper biblioteczny occupation_encoding() działa w bazie obsadzeń (Focka): poziom modu to stan z bozonami. Przyjmuje operator oraz LevelSpec, który zawiera obcięcie i kodowanie. Ten przewodnik wyjaśnia, co oznaczają te dwa wybory i ile każdy z nich kosztuje. Kompletną ścieżkę, która z nich korzysta, znajdziesz w Symulacja dynamiki Bosego-Hubbarda z wyborem kodowania; o tym, jak transpilator stosuje te same wybory do całego obwodu, przeczytasz w Transpilacja obwodów bozonowych.
Obcięcie przestrzeni Focka
Mod zachowany do poziomów, gdzie to jego max_occupation, nie spełnia już kanonicznej relacji komutacji. Oznaczając przez operator anihilacji obcięty do tych poziomów,
relacja zachodzi na każdym poziomie z wyjątkiem najwyższego [1]. Możesz to zobaczyć, mapując komutator obu operatorów drabinkowych i odczytując jego przekątną na poziomach jednego modu. Poniższa funkcja pomocnicza buduje gęstą macierz zmapowanego operatora dla pojedynczego modu, co ma sens tylko dla bardzo małych rejestrów:
>>> import numpy as np
>>> from qiskit.quantum_info import SparsePauliOp
>>> from qiskit_bosons.mappers import LevelEncoding, LevelSpec
>>> from qiskit_bosons.mappers.library import occupation_encoding
>>> from qiskit_bosons.operators import BosonOperator, ann, cre
>>>
>>> def level_diagonal(operator, max_occupation, encoding):
... """The diagonal of a mapped one-mode operator on the codewords of the levels."""
... spec = LevelSpec(max_occupation, encoding)
... matrix = SparsePauliOp.from_sparse_observable(occupation_encoding(operator, spec)).to_matrix()
... codewords = [spec.encoding.level_to_bits(level) for level in range(max_occupation + 1)]
... return [round(float(matrix[c, c].real), 6) for c in codewords]
>>>
>>> b = BosonOperator.from_dict({(ann(0),): 1.0})
>>> b_dagger = BosonOperator.from_dict({(cre(0),): 1.0})
>>> commutator = (b @ b_dagger) - (b_dagger @ b)
>>> level_diagonal(commutator, 2, "unary_unprojected") # d = 3
[1.0, 1.0, -2.0]
Ostatni wpis to . Ponieważ oznacza to, że rozpiętość nie jest zamknięta względem komutatora, żadne kodowanie nie jest izomorfizmem algebr. Kodowanie odwzorowuje stany wraz z operatorami działającymi w obciętej podprzestrzeni. Poniższe dwie konsekwencje mają znaczenie w praktyce.
Obcięcie musi ograniczać osiągalne obsadzenie. Nie wystarczy ograniczyć obsadzenie, od którego zaczynasz. Dzielnik wiązki działający na obsadza i , więc obcięcie równe jeden po cichu daje błędny stan:
>>> from scipy.linalg import expm
>>>
>>> beamsplitter = BosonOperator.from_dict({(cre(0), ann(1)): 1.0, (cre(1), ann(0)): 1.0})
>>>
>>> def stays_in_11(max_occupation):
... """Probability to remain in |1, 1> after a 50:50 beamsplitter."""
... spec = LevelSpec(max_occupation, "gray").resolve(2)
... observable = occupation_encoding(beamsplitter, spec, spec.total_width)
... matrix = SparsePauliOp.from_sparse_observable(observable).to_matrix()
... unitary = expm(-1j * (np.pi / 4) * matrix)
... index = sum(spec.encoding.level_to_bits(1) << offset for offset in spec.offsets)
... return round(float(abs(unitary[index, index]) ** 2), 6)
>>>
>>> stays_in_11(1), stays_in_11(2)
(1.0, 0.0)
Przy obcięciu równym jeden prawdopodobieństwo pozostania w wynosi jeden, ponieważ dwa stany, które dynamika by obsadziła, nie istnieją. Przy obcięciu równym dwa znika ono, co jest efektem Honga-Oua-Mandela. Dlatego max_occupation jest argumentem wymaganym, który nigdy nie ma wartości domyślnej: to decyzja modelowa i parametr zbieżności, który masz przeskanować.
Mapowanie nie komutuje z uporządkowaniem normalnym. Operatory i są równe przed obcięciem, ale mapują się na różne operatory kubitowe:
>>> product = BosonOperator.from_dict({(ann(0), cre(0)): 1.0})
>>> ordered = BosonOperator.from_dict({(cre(0), ann(0)): 1.0}) + BosonOperator.one()
>>> bool(product.equiv(ordered))
True
>>> level_diagonal(product, 3, "unary_unprojected") # d = 4
[1.0, 2.0, 3.0, 0.0]
>>> level_diagonal(ordered, 3, "unary_unprojected")
[1.0, 2.0, 3.0, 4.0]
Oba zgadzają się na każdym poziomie oprócz najwyższego. Zastosowany do najwyższego poziomu nie ma dokąd pójść i amplituda zostaje obcięta, więc jest tam zerem, podczas gdy daje . Zdecyduj raz, czy twój hamiltonian jest zdefiniowany w postaci uporządkowanej normalnie, i mapuj tę postać konsekwentnie.
Trzy metody pomagają wybrać obcięcie i wszystkie z nich dają ograniczenia dolne. Operator zgłasza największą liczbę operatorów kreacji, które stosuje do każdego modu w obrębie pojedynczego członu, a specyfikacja porównuje to ze swoimi obcięciami:
>>> quartic = BosonOperator.from_dict({(cre(0), cre(0), ann(0), ann(0)): 1.0})
>>> quartic.mode_occupations(), quartic.max_occupation()
([2], 2)
>>> LevelSpec(1, "gray").truncation(quartic) # one level short
[1]
>>> LevelSpec(2, "gray").truncation(quartic)
[0]
Niezerowy wpis oznacza, że obcięcie jest poniżej ograniczenia, a wartość mówi, o ile. Te funkcje pomocnicze nie znają dynamiki, więc pomyślne sprawdzenie nie czyni obcięcia wystarczającym; dzielnik wiązki powyżej ma ograniczenie równe jeden, a mimo to wymaga obcięcia równego dwa. Mogą też zgłosić niedobór dla członu, który jest zerem na zachowanych poziomach, jak dla obcięcia równego jeden.
Opisz obcięcie za pomocą LevelSpec
LevelSpec łączy obcięcie każdego modu z kodowaniem i na tej podstawie wyprowadza, ile kubitów zajmuje każdy mod i gdzie zaczynają się jego kubity. Wszystko, co musi być w tej sprawie zgodne (mapper, układ transpilatora, przygotowanie stanu), otrzymuje tę samą specyfikację. Kodowanie jest argumentem wymaganym, tak samo jak obcięcie: żadne nie ma wartości domyślnej.
Obcięcie to albo jedna liczba całkowita wspólna dla wszystkich modów, albo jedna liczba całkowita na mod:
>>> shared = LevelSpec(2, "gray")
>>> per_mode = LevelSpec([4, 2, 2], "gray")
>>> shared.num_modes is None, per_mode.num_modes
(True, 3)
Specyfikacja zbudowana z pojedynczej liczby całkowitej pozostawia liczbę modów otwartą, więc musisz ją rozstrzygnąć, zanim zapytasz o szerokości. Sekwencja nigdy nie jest rozgłaszana, niezależnie od długości, i ustala liczbę modów:
>>> per_mode.widths, per_mode.offsets, per_mode.total_width
([3, 2, 2], [0, 3, 5], 7)
>>> shared.resolve(3).widths
[2, 2, 2]
>>> try:
... shared.widths
... except ValueError as error:
... print(error)
this spec was built from a single cutoff, so its number of modes is open; call `resolve(num_modes)` first
Mod zajmuje kubity od offsets[m] do offsets[m] + widths[m] - 1, więc mody z różnymi obcięciami mogą zajmować różną liczbę kubitów, a przesunięcia są sumą bieżącą szerokości. Nie musisz podawać mapperowi liczby modów. Bierze ją ze specyfikacji lub, dla specyfikacji z pojedynczym obcięciem, z operatora za pomocą min_num_modes(). Rejestr może być szerszy, niż potrzebują mody; nadmiarowe kubity są bezczynne i leżą na końcu:
>>> number = BosonOperator.from_dict({(cre(0), ann(0)): 1.0})
>>> number.min_num_modes()
1
>>> occupation_encoding(number, LevelSpec(1, "gray")).num_qubits
1
>>> occupation_encoding(number, LevelSpec(1, "gray"), 4).num_qubits
4
Operator działający na mod poza ostatnim w specyfikacji per mod zostaje odrzucony:
>>> far = BosonOperator.from_dict({(cre(5), ann(5)): 1.0})
>>> try:
... occupation_encoding(far, LevelSpec([1, 1], "gray"))
... except ValueError as error:
... print(error)
the operator acts on 6 modes, but the spec covers only 2
Kilka ograniczeń wynika ze sposobu, w jaki kodowania liczą kubity. W kodowaniu zwartym mod z pojedynczym poziomem (max_occupation = 0) nie zajmuje żadnego kubitu, ponieważ jego jeden poziom nie potrzebuje niczego, by odróżnić go od innego. Taki mod jest dozwolony i nie przesuwa kubitów kolejnych modów, ale specyfikacja, w której każdy mod nie zajmowałby żadnego kubitu, jest odrzucana. Kodowania unarne przechowują co najwyżej 128 poziomów na mod, a większe obcięcie zgłasza błąd z sugestią kodowania zwartego:
>>> LevelSpec([0, 1], "gray").resolve(2).widths
[0, 1]
>>> try:
... LevelSpec(0, "gray")
... except ValueError as error:
... print(str(error).split(":")[0])
the spec would span no qubits
>>> try:
... LevelSpec(128, "unary_projected")
... except ValueError as error:
... print(str(error).split(" (")[0])
max_occupation = 128 is too large for the unary encodings, which hold at most 128 levels per mode
Wybierz kodowanie
LevelEncoding decyduje, który stan bazowy bloku kubitów reprezentuje każdy poziom. Są cztery i żadne z nich nie jest ogólnie najlepsze [2]. Liczba kubitów na mod i wzorzec bitowy każdego poziomu wynikają z kodowania:
>>> encodings = (
... LevelEncoding.UNARY_PROJECTED,
... LevelEncoding.UNARY_UNPROJECTED,
... LevelEncoding.STANDARD_BINARY,
... LevelEncoding.GRAY,
... )
>>> rows = ["encoding qubits for d = 1, 2, 3, 4, 5, 8 level 0 to 4 as bit patterns"]
>>> for encoding in encodings:
... widths = [encoding.num_qubits(levels) for levels in (1, 2, 3, 4, 5, 8)]
... patterns = [f"{encoding.level_to_bits(level):05b}" for level in range(5)]
... rows.append(f"{encoding!s:18s} {widths!s:34s} {patterns}")
>>> print("\n".join(rows))
encoding qubits for d = 1, 2, 3, 4, 5, 8 level 0 to 4 as bit patterns
unary_projected [1, 2, 3, 4, 5, 8] ['00001', '00010', '00100', '01000', '10000']
unary_unprojected [1, 2, 3, 4, 5, 8] ['00001', '00010', '00100', '01000', '10000']
standard_binary [0, 1, 2, 2, 3, 3] ['00000', '00001', '00010', '00011', '00100']
gray [0, 1, 2, 2, 3, 3] ['00000', '00001', '00011', '00010', '00110']
Wzorce bitowe to liczby całkowite, w których bit jest wartością kubitu . Kodowania unarne zużywają jeden kubit na poziom i ustawiają jeden z nich. Kodowania zwarte zużywają kubitów i przechowują numer poziomu, w systemie binarnym lub jako kod Graya, w którym sąsiednie poziomy różnią się jednym bitem. Nazwa kodowania w postaci łańcucha jest akceptowana wszędzie tam, gdzie samo kodowanie.
Kompromis to kubity wobec wagi operatora. Kodowania zwarte potrzebują wykładniczo mniej kubitów, ale operator drabinkowy łączy poziomy, których wzorce mogą różnić się kilkoma bitami, więc jego człony działają na większej liczbie kubitów. Kolejne sekcje przyglądają się temu kosztowi, zaczynając od dwóch kodowań unarnych, które mają identyczne układy i różnią się tylko operatorem.
Porównanie dwóch kodowań unarnych
W kodowaniu unarnym operator drabinkowy przenosi jedno wzbudzenie między dwoma sąsiednimi kubitami. Aby zapisać to jako operator na całym modzie, musisz wybrać, co zrobić z pozostałymi kubitami. Dwa warianty wybierają inaczej:
-
unary_projectednakłada projektor na każdy inny kubit modu. To wymusza ograniczenie one-hot w samym operatorze: człon działa tylko wtedy, gdy reszta modu jest pusta. -
unary_unprojectedzostawia te kubity w spokoju, więc człon działa na nich jak identyczność.
Spójrz na pojedyncze przy w obu wariantach. Mapper zwraca SparseObservable, którego człony to łańcuchy jednokubitowych członów bitowych. Oto pierwsze cztery człony każdego z nich wraz z kubitami, na które działają:
>>> for encoding in ("unary_projected", "unary_unprojected"):
... observable = occupation_encoding(b_dagger, LevelSpec(3, encoding))
... terms = observable.to_sparse_list()
... print(encoding, "->", len(terms), "terms on", observable.num_qubits, "qubits")
... for label, qubits, coefficient in terms[:4]:
... print(f" {label:5s} on qubits {qubits} coefficient {complex(coefficient):.4f}")
unary_projected -> 12 terms on 4 qubits
XX00 on qubits [0, 1, 2, 3] coefficient 0.2500+0.0000j
XY00 on qubits [0, 1, 2, 3] coefficient 0.0000-0.2500j
YX00 on qubits [0, 1, 2, 3] coefficient 0.0000+0.2500j
YY00 on qubits [0, 1, 2, 3] coefficient 0.2500+0.0000j
unary_unprojected -> 12 terms on 4 qubits
XX on qubits [0, 1] coefficient 0.2500+0.0000j
XY on qubits [0, 1] coefficient 0.0000-0.2500j
YX on qubits [0, 1] coefficient 0.0000+0.2500j
YY on qubits [0, 1] coefficient 0.2500+0.0000j
Oba mają dwanaście członów z tymi samymi współczynnikami. Człony projektowane niosą człon bitowy 0 na dwóch kubitach, których człony nieprojektowane nie wspominają, więc każdy człon projektowany dotyka wszystkich czterech kubitów, a każdy nieprojektowany dwóch. Ogólnie człon projektowany dotyka wszystkich kubitów modu, ponieważ niesie dwa czynniki, każdy lub , oraz projektor na każdym innym kubicie.
Oba operatory są równe na stanach, dla których kodowanie jest przeznaczone, czyli stanach bazowych one-hot (słowach kodowych), a poza nimi się różnią. Aby to sprawdzić, weź mały operator na dwóch modach przy i porównaj macierze na dziewięciu słowach kodowych:
>>> hamiltonian = BosonOperator.from_dict({
... (cre(0), ann(1)): 1.0, (cre(1), ann(0)): 1.0,
... (cre(0), ann(0)): 0.5, (cre(1), cre(1), ann(1), ann(1)): 0.25,
... })
>>> matrices = {}
>>> for encoding in ("unary_projected", "unary_unprojected"):
... observable = occupation_encoding(hamiltonian, LevelSpec(2, encoding))
... matrices[encoding] = SparsePauliOp.from_sparse_observable(observable).to_matrix()
>>> unary = LevelEncoding.UNARY_PROJECTED
>>> codewords = sorted(
... unary.level_to_bits(first) | (unary.level_to_bits(second) << 3)
... for first in range(3) for second in range(3)
... )
>>> projected, unprojected = matrices["unary_projected"], matrices["unary_unprojected"]
>>> bool(np.allclose(projected[np.ix_(codewords, codewords)], unprojected[np.ix_(codewords, codewords)]))
True
>>> bool(np.allclose(projected, unprojected))
False
Oba też zachowują niezmienniczość podprzestrzeni słów kodowych, więc stan przygotowany na słowie kodowym pozostaje na słowach kodowych. Wybór ma znaczenie tylko dla stanów, które opuszczają tę podprzestrzeń, na przykład przez szum. Tam operator projektowany tłumi ewolucję, ponieważ jego projektory zawodzą, podczas gdy nieprojektowany nadal działa. Żadne z tych zachowań nie jest wymogiem fizycznym. Operator nieprojektowany jest zdefiniowany tylko na podprzestrzeni one-hot, więc jego wybór jest decyzją modelową, a nie darmową optymalizacją.
Koszt projektorów to bramki. Wyewoluuj hermitowskie na jednym modzie przez krótki czas i policz bramki dwukubitowe po transpilacji Qiskit:
>>> from qiskit import QuantumCircuit, transpile
>>> from qiskit.circuit.library import PauliEvolutionGate
>>>
>>> def cx_count(observable):
... circuit = QuantumCircuit(observable.num_qubits)
... circuit.append(PauliEvolutionGate(observable, time=0.1), range(observable.num_qubits))
... basis = ["cx", "rz", "sx", "x"]
... return transpile(circuit, basis_gates=basis, optimization_level=1).count_ops().get("cx", 0)
>>>
>>> hermitian = b + b_dagger
>>> levels = (3, 4, 5, 6)
>>> counts = {
... str(encoding): [
... cx_count(occupation_encoding(hermitian, LevelSpec(d - 1, encoding))) for d in levels
... ]
... for encoding in encodings
... }
Dokładne liczby zależą od tego, jak Qiskit syntezuje bramkę ewolucji, co zmienia się między wydaniami, więc zrób wykres:
Liczba bramek dla wariantu nieprojektowanego rośnie liniowo z , ponieważ każde z przejść jest rotacją dwukubitową. Liczba dla wariantu projektowanego rośnie znacznie szybciej, ponieważ każdy człon działa na wszystkich kubitach. Kodowania zwarte potrzebują mniej kubitów, ale waga ich członów zależy od tego, jak różnią się wzorce bitowe sąsiednich poziomów: kod Graya, w którym różnią się jednym bitem, jest tańszy z dwóch kodowań zwartych, a standardowe binarne skacze, gdy przeniesienie zmienia kilka bitów naraz (). To, czy kodowanie zwarte pokonuje też unarne nieprojektowane, zależy od i od sposobu syntezy ewolucji przez Qiskit, więc czytaj trend, a nie ranking przy jednym rozmiarze.
Projektory mają też znaczenie, gdy rozwijasz zmapowany operator na operatory Pauliego, co robi from_sparse_observable(), zastępując każdy projektor sumą dwóch operatorów Pauliego. Człon z projektorami staje się członami Pauliego. Oto pojedyncze przy małym , czyli jedynym rozmiarze, przy którym warto to próbować:
>>> rows = [f"{'d':>2s} {'stored (both)':>14s} {'expanded, projected':>20s} {'expanded, unprojected':>22s}"]
>>> for levels in (3, 4, 5):
... stored = len(occupation_encoding(b_dagger, LevelSpec(levels - 1, "unary_projected")))
... expanded = [
... len(SparsePauliOp.from_sparse_observable(
... occupation_encoding(b_dagger, LevelSpec(levels - 1, encoding))).simplify())
... for encoding in ("unary_projected", "unary_unprojected")
... ]
... rows.append(f"{levels:2d} {stored:14d} {expanded[0]:20d} {expanded[1]:22d}")
>>> print("\n".join(rows))
d stored (both) expanded, projected expanded, unprojected
3 8 16 8
4 12 48 12
5 16 128 16
Oba warianty przechowują tę samą liczbę członów, ale rozwinięcie projektowanego rośnie wykładniczo względem . Dla czegokolwiek większego niż przykład zabawkowy czytaj człony SparseObservable bezpośrednio, jak w powyższym wydruku, zamiast go konwertować.
Kodowania zwarte nie mogą porzucić swoich projektorów. Tam bity zerowe są częścią numeru poziomu, więc usunięcie zmienia to, które poziomy łączy człon, i operator nie jest już taki sam na słowach kodowych. Dlatego tylko kodowanie unarne występuje w dwóch wariantach.
Iloczyn operatorów drabinkowych jest najpierw obcinany
Jeszcze jeden wybór projektowy widać w koszcie członów diagonalnych. Mapper nie mapuje każdego operatora drabinkowego, by pomnożyć wyniki. Zbiera operatory drabinkowe, które człon stosuje do jednego modu, mnoży je jako macierz w bazie Focka, obcina ten iloczyn raz i koduje wynik [2]. Iloczyn taki jak operator liczby cząstek pozostaje więc diagonalny:
>>> number_observable = occupation_encoding(number, LevelSpec(3, "unary_unprojected"))
>>> [(label, qubits) for label, qubits, _ in number_observable.to_sparse_list()]
[('1', [1]), ('1', [2]), ('1', [3])]
>>> cx_count(number_observable)
0
Jego człony to projektory na kubicie każdego poziomu powyżej zera, ważone numerem poziomu (poziom zero ma wagę zero, więc nie ma członu). Jego ewolucja w czasie to zestaw rotacji jednokubitowych i nie wymaga żadnej bramki dwukubitowej. Pomnożenie zamiast tego dwóch zmapowanych operatorów drabinkowych daje ten sam operator na słowach kodowych, ale inny poza nimi, z pozadiagonalnymi łańcuchami, które kosztują bramki:
>>> unprojected = LevelSpec(3, "unary_unprojected")
>>> as_pauli = lambda operator: SparsePauliOp.from_sparse_observable(
... occupation_encoding(operator, unprojected, 4)).simplify()
>>> composed = as_pauli(b_dagger).dot(as_pauli(b)).simplify()
>>> direct = as_pauli(number)
>>> len(direct), len(composed)
(4, 16)
>>> cx_count(direct)
0
>>> bool(cx_count(composed) > 0)
True
>>> codewords = [LevelEncoding.UNARY_UNPROJECTED.level_to_bits(level) for level in range(4)]
>>> bool(np.allclose(direct.to_matrix()[np.ix_(codewords, codewords)],
... composed.to_matrix()[np.ix_(codewords, codewords)]))
True
>>> bool(np.allclose(direct.to_matrix(), composed.to_matrix()))
False
Człony diagonalne, takie jak oddziaływanie lokalne modelu Bosego-Hubbarda, to właśnie takie iloczyny i tu ten wybór ma znaczenie.
Napisz własny mapper
Jeśli chcesz kodowania, którego biblioteka nie zapewnia, zbuduj je z obrazu pojedynczego operatora drabinkowego. map_boson_action_generators() przyjmuje funkcję, która mapuje jedno BosonAction, oraz funkcję zwracającą identyczność, i z nich buduje mapper całych operatorów. Mnoży obrazy akcji każdego członu po kolei, skaluje przez współczynnik i sumuje człony. Wynik może być dowolnego typu, który obsługuje mnożenie przez skalar i składanie.
Jako przykład weź kodowanie dla , które przechowuje mod w jednym kubicie. Operator kreacji to , czyli w konwencji Qiskit, a operator anihilacji to jego sprzężenie:
>>> from qiskit_bosons.mappers import map_boson_action_generators
>>> from qiskit_bosons.operators import BosonAction
>>>
>>> num_qubits = 2
>>>
>>> def map_action(action: BosonAction) -> SparsePauliOp:
... is_creation, mode = action
... sign = -1j if is_creation else 1j
... return SparsePauliOp.from_sparse_list(
... [("X", [mode], 0.5), ("Y", [mode], 0.5 * sign)], num_qubits=num_qubits
... )
>>>
>>> def identity() -> SparsePauliOp:
... return SparsePauliOp.from_sparse_list([("", [], 1.0)], num_qubits)
>>>
>>> model = BosonOperator.from_dict({
... (cre(0), ann(1)): -1.0, (cre(1), ann(0)): -1.0, (ann(0), cre(0)): 0.7, (cre(1), ann(1)): 0.3,
... })
>>> custom = map_boson_action_generators(model, map_action, identity)
>>> library = SparsePauliOp.from_sparse_observable(occupation_encoding(model, LevelSpec(1, "gray")))
>>> bool(np.allclose(custom.to_matrix(), library.to_matrix()))
True
Ten ręcznie napisany mapper mnoży zmapowane operatory drabinkowe i zgadza się tu z mapperem bibliotecznym, ponieważ każdy mod ma dwa poziomy, a kubit mieści cały mod. Przy większym , jak pokazała poprzednia sekcja, składanie zmapowanych operatorów drabinkowych i wcześniejsze obcięcie iloczynu nie dają tego samego operatora poza słowami kodowymi.
Aby użyć takiego mappera w obwodzie, opakuj go w funkcję o sygnaturze (operator, num_qubits) -> SparseObservable i przekaż ją do MapperFnEvolutionSynthesis, który mapuje nią operator każdej bramki Evolution. Potok transpilatora opisano w Transpilacja obwodów bozonowych.
>>> from qiskit.quantum_info import SparseObservable
>>> from qiskit_bosons.transpiler.passes import MapperFnEvolutionSynthesis
>>>
>>> def mapper_fn(operator, num_qubits):
... return SparseObservable.from_sparse_pauli_op(
... map_boson_action_generators(operator, map_action, identity)
... )
>>>
>>> synthesis = MapperFnEvolutionSynthesis(mapper_fn)
Warto znać dwa szczegóły. Po pierwsze, operator kubitowy musi mieć rzeczywiste współczynniki, ponieważ PauliEvolutionGate odrzuca część urojoną. Operator hermitowski mapuje się na rzeczywisty, ale dopiero po scaleniu członów, które się znoszą. Ten ręcznie napisany mapper ich nie scala, więc człon przeskoku i jego sprzężenie zostawiają urojone współczynniki, dopóki nie uprościsz wyniku. Mapper biblioteczny scala własny wynik; własny mapper musi to zrobić sam, na przykład opakowując go w simplify():
>>> hopping = BosonOperator.from_dict({(cre(0), ann(1)): 1.0})
>>> hermitian_hopping = hopping + hopping.adjoint()
>>> raw = mapper_fn(hermitian_hopping, 2)
>>> bool(np.allclose(np.asarray(raw.coeffs).imag, 0))
False
>>> merged = raw.simplify()
>>> bool(np.allclose(np.asarray(merged.coeffs).imag, 0))
True
>>> sorted(label for label, _, _ in merged.to_sparse_list())
['XX', 'YY']
Po drugie, formuła produktowa syntezuje człony w kolejności, w jakiej je otrzymuje, a kolejność produkowana przez mapper nie jest gwarantowana jako stabilna. Funkcje pomocnicze simplify() i group_wise() opakowują funkcję mappera, aby ją kontrolować: simplify scala zduplikowane łańcuchy i sortuje człony w kanonicznej kolejności, a group_wise mapuje jedną grupę operatora naraz, tak aby człony grupy pozostały sąsiadujące. Nie łącz obu, ponieważ sortowanie odrzuca grupowanie. Przewodnik po grupowaniu pokazuje, jak przypisywać grupy, a przewodnik po syntezie pokazuje, jak kolejność wpływa na obwód.
Następne kroki
-
Przejdź przez Symulacja dynamiki Bosego-Hubbarda z wyborem kodowania, aby zobaczyć pełną symulację, która przeskanowuje obcięcie i porównuje kodowania z dynamiką dokładną.
-
Zobacz Transpilacja obwodów bozonowych, aby dowiedzieć się, jak ten sam
LevelSpecjest stosowany do obwodu bozonowego. -
Przeczytaj Zasady projektowe reprezentacji operatorów, aby dowiedzieć się, jak
BosonOperatorprzechowuje i porządkuje swoje człony, w tym o regule kontrakcji stojącej za powyższym przykładem uporządkowania normalnego. -
Przeczytaj Rotacja modów bozonowych, aby poznać bramkę, która przenosi bozony między modami i w przypadku której ostrzeżenie o osiągalnym obsadzeniu ma największe znaczenie.
-
Zajrzyj do dokumentacji API
qiskit_bosons.mappersiqiskit_bosons.mappers.library.
[1]
R. Somma, G. Ortiz, E. Knill, and J. Gubernatis, Quantum Simulations of Physics Problems (2003), arXiv:quant-ph/0304063, Sec. 4, Eq. 22.
N. P. D. Sawaya, T. Menke, T. H. Kyaw, S. Johri, A. Aspuru-Guzik, and G. G. Guerreschi, Resource-efficient digital quantum simulation of d-level systems for photonic, vibrational, and spin-s Hamiltonians, npj Quantum Information 6, 49 (2020), arXiv:1909.12847.