주 콘텐츠로 건너뛰기
이 프로젝트는 베타 단계입니다. 버그를 발견하셨나요? GitHub 이슈 만들기. 질문이나 아이디어가 있으신가요? Discussions에서 질문하기 — 피드백을 기다립니다!
이 페이지의 번역 품질은 어떻습니까?

페르미온 공간에서 Evolution 게이트 합성하기

중요

이 가이드의 개념은 현재 Python API에서만 사용할 수 있습니다. 동등한 기능은 향후 릴리스에서 C API로도 제공될 예정입니다.

Evolution 게이트는 연산자 전체의 시간 발전 e−itHe^{-itH}를 나타냅니다. 페르미온-페르미온 합성은 이 게이트를 더 작은 페르미온 게이트로 이루어진 FermionicCircuit으로 다시 씁니다. 다시 쓰기의 양쪽이 모두 페르미온 공간에 머문다는 점이 뒤이어 나오는 페르미온-큐비트 합성과 구별되는 특징입니다. 연산자의 groups(연산자 항 그룹화: 연산자 구조 활용하기 참고)는 이 과정에서 유지되지만, 페르미온-큐비트 매핑은 그룹화 개념이 없는 SparseObservable을 반환합니다.

방법은 두 가지 설정으로 선택합니다. Evolution.synthesis는 게이트 하나에 대해, F2FSynthesis 트랜스파일러 패스는 회로 전체에 대해 방법을 지정합니다. 둘 다 이러한 다시 쓰기가 구현하는 인터페이스인 F2FSynthesisPlugin을 받습니다. 이 패키지가 현재 발전에 대해 제공하는 방법은 곱 공식이며, 이 가이드의 나머지 부분에서도 이를 사용합니다. 1차 공식(기본값)에는 FermionicLieTrotter가, 더 높은 짝수 차수에는 FermionicSuzukiTrotter가 사용됩니다. 이 인터페이스를 구현하는 다른 것도 같은 방식으로 선택하며, 두 설정에 관한 섹션의 내용이 그대로 적용됩니다.

참고

이 페르미온-페르미온 단계는 선택 사항입니다. Evolution 게이트는 항이 몇 개든 그대로 페르미온-큐비트 단계로 넘어가 통째로 매핑되고 합성될 수 있습니다. 먼저 나누는 것은 하나의 선택입니다. 그 결과 게이트가 각각 더 저렴해지거나, 나누는 과정에서 이후 단계가 활용할 수 있는 구조가 드러나기 때문입니다.

두 단계는 조합할 수 있으므로, 발전은 어느 한 수준에서, 두 수준 모두에서, 또는 어디에서도 근사되지 않을 수 있습니다. 곱 공식은 인자들이 서로 교환하지 않는 한 근사이고, 합성 방법은 전혀 근사하지 않을 수도 있습니다.

참고

이 단계를 건너뛰더라도 그룹화를 완전히 잃을 필요는 없습니다. 매퍼를 group_wise()로 감싸면 연산자를 한 번에 한 그룹씩 매핑하고 결과를 합산하므로, 매핑된 Pauli 항이 뒤섞이지 않고 그룹별로 묶여서 나옵니다. 큐비트 측 곱 공식은 받은 순서대로 이를 합성하므로, 인접하게 놓인 교환 가능한 항들을 함께 스케줄링할 수 있습니다.

아래 해밀토니안에서는 이것만으로 나누기의 2큐비트 깊이 이점을 모두 되찾을 수 있습니다. 게이트 수는 그대로이면서 30 대신 18이 됩니다. 차이는 구조가 어디에서 표현되느냐에 있습니다. 페르미온 공간에서 나누면 각 그룹이 별도의 게이트가 되어 이후 모든 단계에서 이를 보고 활용할 수 있습니다. 반면 group_wise는 게이트를 하나로 두고 그 안의 항 순서만 바꾸므로, 스케줄링에는 충분하지만 페르미온 패스가 활용할 것은 남지 않습니다. 발전이 하나의 게이트로 유지되어야 할 때 사용하고, 그렇지 않다면 페르미온 곱 공식을 사용하세요.

그룹화된 해밀토니안 만들기​

두 곱 공식은 모두 연산자를 인자로 나누며, 그룹화된 연산자에서 이 인자들은 곧 그룹입니다. 따라서 그룹화가 결과의 비용과 정확도를 모두 결정합니다. 항들이 서로 교환하는 방식으로 그룹화한 6사이트 Fermi-Hubbard 체인을 예로 들어 보겠습니다. 짝수 결합의 호핑 항, 홀수 결합의 호핑 항, 그리고 대각 상호작용으로 나눕니다.

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

a0†a1a^\dagger_0 a_1을 그 짝 a1†a0a^\dagger_1 a_0과 분리하면 이 성질이 깨집니다. 연산자를 그룹화하지 않은 경우에도 마찬가지입니다. 이때는 두 공식 모두 연산자를 항 단위로 나누고, 단독의 ai†aja^\dagger_i a_j는 에르미트가 아니기 때문입니다.

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

이러한 인자도 유효한 페르미온 연산자이고 이 패키지는 이에 대한 게이트를 만들어 주므로, 매핑된 연산자가 실수 계수를 요구하며 ValueError: Operator contains complex coefficients, which are not supported를 발생시키는 PauliEvolutionGate에 도달하기 전까지는 어떤 경고도 나오지 않습니다. 그룹화를 미리 검사하면 이렇게 뒤늦게 발생하는 실패를 회로를 만들기 전에 알 수 있는 답으로 바꿀 수 있습니다.

게이트 하나에 방법 지정하기​

게이트에 합성 방법을 전달한 다음 분해하세요. 그 결과로 나오는 각 게이트는 각각 별도의 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

1차는 그룹마다 인자 하나를 냅니다. 2차는 대칭적인 회문 형태를 내므로 바깥쪽 두 그룹이 각각 절반의 시간으로 두 번씩 나타나 모두 다섯 개가 됩니다. 이를 네 번 반복하면 스무 개가 되지만, 각 반복은 다음 반복이 시작하는 것과 같은 그룹으로 끝납니다. 이렇게 이웃한 게이트는 같은 연산자로 발전하므로 시간을 합산해 하나의 게이트로 융합되며, 이음매마다 게이트 하나가 절약됩니다. 교과서적인 순서를 유지하려면 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를 전달하면 방법만 선택하고 전개는 다른 것에 맡깁니다. 예를 들어 Qiskit의 Decompose를 사용할 수 있습니다(게이트 이름을 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를 참조하지 않고 매핑됩니다. 이 때문에 방법을 비교할 때 쉽게 실수하게 됩니다. 모든 방법이 동일한 출력을 내므로 비교가 조용히 아무것도 측정하지 못하기 때문입니다. 위의 해밀토니안에서 2차 게이트를 전개하지 않은 채 두면, 어떤 방법을 선택했든 아래의 훨씬 큰 회로가 매핑된 발전 하나의 비용으로 줄어듭니다.

이득 측정하기​

차수가 높아지면 깊이가 늘어나므로, 문제는 더 정확한지가 아니라 게이트당 더 정확한지입니다. 방법마다 두 가지 수치가 필요합니다. 생성된 상태의 오차와, 그 상태를 만든 회로의 2큐비트 비용입니다.

오차는 FermionicCircuit을 직접 시뮬레이션하는 ffsim으로 측정합니다(이 패키지와 ffsim의 관계 참고). 페르미온-큐비트 매핑이 관여하지 않으므로, 이 비교는 매핑이나 큐비트 측 합성이 기여할 수 있는 요소와 무관하게 페르미온 곱 공식만을 분리해 보여 줍니다. 또한 입자 수가 고정된 섹터에서 동작하는데, 이 공간은 전체 2N2^N 공간보다 작을 뿐 아니라 발전이 실제로 머무는 공간이기도 합니다.

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

2큐비트 비용은 그 표에서 의도적으로 제외했습니다. 이 비용은 Qiskit이 선택하는 큐비트 측 합성에 따라 달라지고, 이는 릴리스마다 개선되므로, 여기에 출력된 수치는 곱 공식의 속성이 아니라 한 시점의 스냅샷일 뿐입니다. 대신 아래 그림에 이를 그렸으며, 비교의 근거가 되는 내용은 관계식으로 단언합니다.

Grouped bars of CX count and depth per method, with order two at four repetitions clearly shorter than order one at ten.

마지막 두 방법을 함께 읽어 보세요. 이것이 중요한 비교이기 때문입니다. 반복 네 번의 2차는 반복 열 번의 1차보다 대략 열 배 더 정확하며, 그림에서 보듯이 더 많은 얽힘 게이트가 아니라 더 적은 얽힘 게이트로 이를 달성합니다. 여기서는 게이트 예산을 1차 스텝을 늘리는 데 쓰는 것보다 더 높은 차수에 쓰는 편이 낫습니다.

이 결과가 보편적인 것은 아닙니다. reps를 늘리는 것과 order를 늘리는 것은 같은 종류의 비용에 대해 서로 다른 속도로 오차를 줄이며, 어느 쪽이 유리한지는 연산자와 발전시키는 시간에 따라 달라집니다. 주어진 문제에서 이를 알아보는 방법이 위의 두 행입니다.

참고

페르미온 공식은 페르미온-큐비트 단계에서 선택한 공식과 조합됩니다(MapperFnEvolutionSynthesis.product_formula 참고). 두 곳 모두 근사하는 경우 결과의 정확도는 둘 중 더 약한 쪽에 의해 좌우되므로, 큐비트 측 공식이 1차로 유지되는 상태에서 여기서 차수를 올리는 것은 얻는 것이 거의 없습니다.

이득이 있는 게이트로 패스 제한하기​

높은 차수는 인자들이 교환하지 않을 때만 도움이 됩니다. 인자가 모두 교환하면 1차 공식이 이미 정확하며 추가 인자는 순전히 오버헤드입니다. 숫자 연산자들은 서로 교환하므로, 순수한 대각 연산자가 이를 구체적으로 보여 줍니다.

>>> 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의 대각 Coulomb 연산자가 이런 형태이며, 그래서 F2FSynthesis.filter가 존재합니다. 이는 DAGOpNode에 대한 조건 함수를 받고 거부된 게이트는 건드리지 않으므로, 하나의 패스로 이득이 있는 게이트의 차수는 높이고 나머지는 1차로 유지할 수 있습니다.

두 종류의 발전을 모두 담은 회로를 보면 이것으로 얼마나 절약되는지 알 수 있습니다. 아래의 조건 함수는 연산자가 모드 사이에서 입자를 이동시킬 때만 게이트를 받아들입니다. 바로 그때가 그룹이 교환하지 않아 높은 차수로 얻을 것이 있는 경우입니다.

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

필터가 절약하는 게이트는 2차가 대각 발전에 쓴 게이트와 정확히 같으며, 위의 대각 비교에서 이는 여덟 개(18 대 10)로 계산됩니다. 해당 발전은 1차에서 이미 정확하므로 거기서는 아무 이득이 없었고, 따라서 이를 없애도 정확도는 떨어지지 않습니다.

참고

두 2차 회로는 전개되지 않은 회로가 아니라 서로 비교하세요. 두 게이트를 모두 전개하지 않은 채 두면 각각 통째로 페르미온-큐비트 단계로 보내지며, 이는 위의 apply=False 참고에서 설명한 실수입니다. 이 경우 필터링된 회로와 얽힘 게이트 수가 같게 나올 수 있지만 깊이가 다른 별개의 회로이므로, 그 수치로 필터의 이점을 읽어 내는 것은 아무것도 측정하지 못합니다.

주의

어느 설정도 인자가 에르미트인지 검증하지 않으며, 잘못 그룹화된 연산자는 대칭화가 오차를 부분적으로 상쇄하기 때문에 틀렸는데도 1차보다 짝수 차수에서 더 좋아 보일 수 있습니다. 위의 fermi_hubbard_chain처럼 켤레 짝을 함께 그룹화하세요.

다음 단계​