Синтез гейта Evolution у ферміонному просторі
Концепції в цьому посібнику наразі доступні лише в Python API. Еквівалентна функціональність стане доступною в C API в одному з майбутніх релізів.
Гейт Evolution несе часову еволюцію усього оператора. Ферміон-ферміонний синтез переписує цей гейт у FermionicCircuit з менших ферміонних гейтів. Обидві сторони переписування залишаються у ферміонному просторі, що відрізняє його від ферміон-кубітного синтезу, який іде далі: groups оператора (див. Групування доданків оператора: використання структури оператора) переживають його, тоді як ферміон-кубітне відображення повертає SparseObservable, у якого немає поняття групування.
Метод обирають два перемикачі. Evolution.synthesis задає його для одного гейта, а прохід транспілятора F2FSynthesis задає його для всього кола. Обидва приймають F2FSynthesisPlugin — інтерфейс, який реалізує будь-яке таке переписування. Методи, які цей пакет наразі надає для еволюції, — це формули добутку, що їх використовує решта цього посібника: FermionicLieTrotter для формули першого порядку (за замовчуванням) і FermionicSuzukiTrotter для вищого парного порядку. Будь-що інше, що реалізує інтерфейс, обирається так само, і розділи про два перемикачі застосовуються до нього без змін.
Цей ферміон-ферміонний крок є необов'язковим. Гейт Evolution може одразу перейти на ферміон-кубітний етап, який відображає та синтезує його цілком, скільки б доданків він не містив. Розбиття спочатку — це вибір, зроблений тому, що отримані гейти окремо дешевші або що розбиття виявляє структуру, яку може використати пізніший етап.
Ці два кроки комбінуються, тож еволюція може опинитися наближеною на будь-якому з рівнів, на обох або ні на одному. Формула добутку є наближенням, якщо її множники не комутують; метод синтезу може взагалі не бути наближенням.
Пропуск цього кроку не обов'язково означає повну втрату групування. Обгортання мапера в group_wise() відображає оператор по одній групі та сумує результати, тож відображені доданки Паулі надходять згрупованими, а не перемішаними. Формула добутку на боці кубітів синтезує їх у порядку отримання, тож доданки, що комутують і опинилися поруч, можна планувати разом.
На наведеному нижче гамільтоніані це відновлює весь виграш у двокубітній глибині від розбиття: 18 замість 30 за незмінної кількості гейтів. Різниця в тому, де виражається структура. Розбиття у ферміонному просторі робить кожну групу окремим гейтом, який бачить і на який може впливати кожен подальший етап; group_wise залишає один гейт і лише переупорядковує доданки всередині нього, чого досить для планування, але не лишає нічого, з чим міг би працювати ферміонний прохід. Використовуй його, коли еволюція має залишитися одним гейтом, а в інших випадках звертайся до ферміонної формули добутку.
Побудова згрупованого гамільтоніана
Обидві формули добутку розбивають оператор на множники, і для згрупованого оператора цими множниками є його групи. Тому саме групування визначає і вартість, і точність результату. Візьмемо шестивузловий ланцюжок Фермі-Хаббарда, згрупований за комутативністю його доданків: перескоки на парних зв'язках, перескоки на непарних зв'язках і діагональна взаємодія.
>>> 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
П'ятнадцять доданків у трьох групах. Кожна група перескоків поєднує кожен доданок із його спряженим партнером, і це важливо: множник має бути ермітовим, щоб його експонента була унітарною, а це не випливає з того, що сума ермітова. groups_are_hermitian() перевіряє цю умову, а не залишає її на огляд:
>>> from qiskit_fermions.operators.terms.grouping import groups_are_hermitian
>>>
>>> groups_are_hermitian(hamiltonian)
[True, True, True]
Відокремлення від його партнера порушило б її, і те саме дає незгрупований оператор, оскільки обидві формули тоді розбивають його доданок за доданком, а окремий не є ермітовим:
>>> 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
Такий множник є коректним ферміонним оператором, і цей пакет побудує для нього гейт, тож ніщо не скаржиться, доки відображений оператор не дійде до PauliEvolutionGate, який вимагає дійсних коефіцієнтів і викликає ValueError: Operator contains complex coefficients, which are not supported. Перевірка групування заздалегідь перетворює цю пізню помилку на відповідь, доступну ще до побудови будь-якого кола.
Задання методу для одного гейта
Передай метод синтезу гейту, а потім декомпозуй його. Кожен гейт, який він видає, повертається як окремий гейт 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
Перший порядок видає один множник на групу. Другий порядок видає симетричний паліндром, тож дві зовнішні групи з'являються двічі з половиною часу кожна, що дає п'ять. Чотири повторення цього дали б двадцять, але кожне повторення закінчується тією самою групою, з якої починається наступне. Ці сусідні гейти еволюціонують під дією того самого оператора, тому їх об'єднано в один гейт із сумарним часом, що економить один гейт на кожному шві. Передай merge_adjacent=False, щоб зберегти підручникову послідовність. Кожен порядок розбиває оператор однаково; змінюється порядок множників і їхній час еволюції, а не розбиття.
Множники позначені як atomic, тому повторна декомпозиція залишає їх на місці, а не розбиває далі. Декомпозиція має нерухому точку, завдяки чому decompose() безпечно викликати повторно.
Задання методу для всього конвеєра
Задання synthesis для кожного гейта окремо означає протягування вибору крізь усе, що будує такі гейти, зокрема UCC і UCJ, які конструюють свої гейти всередині. Натомість прохід F2FSynthesis застосовує один метод до кожного Evolution у колі, тож вибір робиться один раз для всього конвеєра. Він налаштовується за назвою гейта, так само як F2QSynthesis, а гейт, якого не названо, залишається як є:
>>> 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
Це та сама кількість, яку дає decompose() для цього методу вище. Прохід MergeEvolutions застосовує те саме злиття до сусідніх гейтів, які не походять з одного синтезу, наприклад до швів між послідовними колами. Прохід сам виконує формулу, тож окремий крок розгортання не потрібен, а повторний запуск нічого не змінює, оскільки множники, які він видає, є atomic і тому не мають власного визначення.
Передай apply=False, щоб лише обрати метод і залишити розгортання чомусь іншому, наприклад Decompose з Qiskit (назва гейта як Decompose("Evolution") обмежує його еволюціями, залишаючи інші ферміонні гейти їхнім власним плагінам синтезу).
Це корисно, щоб обрати метод для всього кола заздалегідь, відклавши розгортання до моменту, коли воно потрібне, наприклад щоб спершу оглянути коло:
>>> 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
Але пам'ятай, що робить нерозгорнутий гейт: він потрапляє на ферміон-кубітний етап цілим, де відображається без звернення до synthesis. Це легко призводить до помилки під час порівняння методів, бо кожен із них тоді дає однаковий результат, і порівняння мовчки нічого не вимірює. На наведеному вище гамільтоніані залишення гейта другого порядку нерозгорнутим згортає набагато більше коло нижче до вартості однієї відображеної еволюції, незалежно від того, який метод було обрано.
Вимірювання віддачі
Вищий порядок коштує глибини, тож питання не в тому, чи він точніший, а в тому, чи він точніший на гейт. Для кожного методу потрібні два числа: похибка стану, який він дає, і двокубітна вартість кола, що його створило.
Похибку вимірюють за допомогою ffsim, який симулює FermionicCircuit безпосередньо (див. Як цей пакет пов'язаний з ffsim). Ферміон-кубітне відображення не задіяне, тож порівняння ізолює ферміонну формулу добутку від усього, що могли б внести відображення чи кубітний синтез. Воно також працює в секторі з фіксованою кількістю частинок, який і менший за повний простір , і є простором, у якому еволюція насправді залишається.
>>> 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
Двокубітну вартість навмисно не включено до цієї таблиці. Вона залежить від кубітного синтезу, який обирає Qiskit і який покращується між релізами, тож наведені тут числа були б знімком, а не властивістю формули добутку. Натомість їх показано на рисунку нижче, а те, на чому тримається порівняння, стверджується як співвідношення:
Прочитай останні два методи разом, бо саме вони становлять важливе порівняння. Другий порядок із чотирма повторенями приблизно в десять разів точніший за перший порядок із десятьма, і досягає цього з меншою, а не більшою кількістю заплутувальних гейтів, як показує рисунок. Витрачати бюджет гейтів на вищий порядок тут вигідніше, ніж на більше кроків першого порядку.
Цей результат не універсальний. Збільшення reps і збільшення order зменшують похибку з різною швидкістю за того самого виду вартості, і те, що виграє, залежить від оператора та часу еволюції. Два рядки вище показують, як це з'ясувати для конкретної задачі.
Ферміонна формула комбінується з тією, що обрана для ферміон-кубітного етапу (див. MapperFnEvolutionSynthesis.product_formula). Якщо обидві є наближеннями, точність результату визначає слабша з двох, тож підвищення порядку тут при першому порядку формули на боці кубітів дає мало.
Обмеження проходу гейтами, яким це вигідно
Вищий порядок допомагає лише тоді, коли множники не комутують. Якщо всі вони комутують, формула першого порядку вже точна, а додаткові множники — суцільні накладні витрати. Суто діагональний оператор робить це наочним, оскільки оператори числа частинок комутують між собою:
>>> 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
Обидва кола точні, тож друге витрачає на 80% більше заплутувальних гейтів дарма. Діагональні оператори Кулона в UCJ мають саме таку форму, тому й існує F2FSynthesis.filter: він приймає предикат над DAGOpNode і не чіпає відхилені гейти, тож один прохід може підвищити порядок для гейтів, яким це вигідно, а решту залишити першого порядку.
Коло, що містить обидва види еволюції, показує, скільки це заощаджує. Наведений нижче предикат приймає гейт лише тоді, коли його оператор переміщує частинки між модами, а це саме той випадок, коли групи не комутують і вищий порядок має що купити:
>>> 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
Гейти, які заощаджує фільтр, — це саме ті, що другий порядок витратив на діагональну еволюцію, яку порівняння діагонального випадку вище оцінює у вісім (18 проти 10). Там вони нічого не дали, бо ця еволюція вже точна на першому порядку, тож їх відкидання не коштує точності.
Порівнюй два кола другого порядку між собою, а не з нерозгорнутим. Залишення обох гейтів нерозгорнутими відправляє кожен на ферміон-кубітний етап цілим, а це помилка, яку описує примітка про apply=False вище: кількість заплутувальних гейтів може збігтися з такою в відфільтрованому колі, хоча це інше коло з іншою глибиною, тож читання вигоди фільтра з цього числа нічого не вимірює.
Жоден із перемикачів не перевіряє, що множники ермітові, а неправильно згрупований оператор може виглядати краще на парному порядку, ніж на першому, залишаючись при цьому хибним, бо симетризація частково скасовує похибку. Групуй спряжених партнерів разом, як це робить fermi_hubbard_chain вище.
Наступні кроки
-
Прочитай Групування доданків оператора: використання структури оператора, щоб дізнатися, як призначаються групи, за якими ці формули добутку виконують розбиття.
-
Прочитай Транспіляція ферміонних кіл, щоб дізнатися про етапи, крізь які проходить
FermionicCircuit. -
Див. Симуляція одновимірної динаміки Фермі-Хаббарда з flow sets — розібраний випадок, де ферміонна формула дає кількість кроків, якої не може дати власний кубітний синтез.
-
Зверніться до
synthesisщодо самих методів синтезу, а доFermionicQDriftщодо рандомізованої альтернативи.