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

연산자 표현 매핑

매퍼는 수학적 동등성을 유지하면서 연산자를 한 표현에서 다른 표현으로 변환하는 루틴입니다. 다양한 연산자 대수에 걸쳐 유연하게 작업하고 양자 회로 실행을 위해 연산자를 준비하는 데 사용하세요.

사용자 정의 매퍼 구현​

매퍼 모듈을 사용하면 사용자 정의 매핑을 간단히 구현할 수 있습니다. 매퍼는 연산자를 구성하는 기본 작용(생성, 소멸, 마요라나 등)을 변환한 다음, 대상 표현의 규칙에 따라 변환된 작용들을 결합하는 방식으로 동작합니다.

구체적인 예로, map_majorana_action_generators()를 사용해 MajoranaOperator를 큐비트 연산자로 변환하는 Jordan-Wigner 변환을 살펴보겠습니다.

>>> from qiskit.quantum_info import SparseObservable
>>> from qiskit_fermions.mappers import map_majorana_action_generators
>>> from qiskit_fermions.operators import MajoranaOperator
>>>
>>> # Create a Majorana operator
>>> maj_op = MajoranaOperator.from_dict({(0, 1): 1.0, (2,): 0.5})
>>>
>>> # Define how each Majorana action maps to Pauli strings
>>> num_qubits = 2
>>> def map_action(mode: int) -> SparseObservable:
... idx = mode // 2
... qubits = list(range(idx + 1))
... pauli = "Y" if mode % 2 else "X"
... return SparseObservable.from_sparse_list(
... [("Z" * idx + pauli, qubits, 1.0)],
... num_qubits=num_qubits,
... )
>>>
>>> # Apply the mapping to transform the operator
>>> custom_observable = map_majorana_action_generators(
... maj_op,
... map_action,
... identity=lambda: SparseObservable.identity(num_qubits),
... compose=SparseObservable.compose,
... )
>>> print(custom_observable.simplify())
<SparseObservable with 2 terms on 2 qubits: (0+1j)(Z_0) + (0.5+0j)(X_1 Z_0)>

사용자 정의 매퍼를 구현하는 핵심 단계는 다음과 같습니다.

  1. 행동 변환 정의하기: 각 행동(모드로 인덱싱됨)이 대상 표현으로 어떻게 매핑되는지 지정합니다. 위 예제에서는 Majorana 행동이 Jordan-Wigner 규칙에 따라 Pauli 문자열로 매핑됩니다.

  2. 제공된 매핑 함수 중 하나 사용하기: mappers 모듈은 operators 모듈이 제공하는 연산자 표현에 대해, 연산자 항을 순회하고 사용자 정의 행동 맵을 이어서 적용하는 작업을 처리하는 유틸리티 함수를 제공합니다. 예를 들어 map_majorana_action_generators()는 Majorana 연산자와 함께 동작합니다.

  3. 결과 얻기: 매핑 함수는 모든 항에 변환이 일관되게 적용된, 대상 표현의 연산자를 반환합니다.

이러한 설계 덕분에 내부 연산자 구조에 대한 깊은 지식 없이도 사용자 정의 매핑을 쉽게 프로토타이핑할 수 있습니다. 변환은 수학적 동등성을 유지하면서 연산자의 모든 항에 자동으로 적용됩니다.

힌트

제공된 반복자 함수를 사용하는 대신 연산자의 항을 직접 순회할 수도 있습니다. 자세한 내용은 연산자 가이드의 항 순회 섹션을 참조하세요.

라이브러리 구현​

qiskit_fermions.mappers.library 모듈은 일반적인 매핑의 효율적이고 철저히 검증된 구현을 제공합니다. 이 구현들은 사용자 정의 매퍼와 동일한 기본 패턴을 따르지만 프로덕션 사용에 맞게 최적화되어 있으며, Python API와 C API 모두에서 사용할 수 있습니다.

아래 예제는 사용자 정의 매퍼 섹션과 동일한 MajoranaOperator에 라이브러리 매퍼를 적용하여 동등한 결과를 보여줍니다.

>>> from qiskit_fermions.mappers.library import majorana_to_fermion, jordan_wigner
>>>
>>> # Convert MajoranaOperator to FermionOperator using library mapper
>>> ferm_op = majorana_to_fermion(maj_op)
>>>
>>> # Apply Jordan-Wigner mapper to get qubit operator
>>> sparse_observable = jordan_wigner(ferm_op, num_qubits=2)
>>> difference = sparse_observable - custom_observable
>>> print(difference.simplify() == SparseObservable.zero(num_qubits))
True
참고

위의 2단계 방식은 두 라이브러리 매퍼가 합성되는 모습을 보여주기 위해 풀어서 작성한 것입니다. 실제로는 majorana_jordan_wigner()(C API에서는 qf_maj_op_jordan_wigner())로 MajoranaOperator를 한 단계로 매핑하게 되며, 다른 연산자 유형에도 마찬가지입니다. edge_vertex_jordan_wigner()와 transfer_vertex_jordan_wigner()(qf_edge_op_jordan_wigner(), qf_transfer_op_jordan_wigner())가 그 예입니다. FermionOperator를 거치면 중간 결과도 부풀어 오릅니다. 이러한 대수의 모든 생성자는 하나의 Pauli 문자열로 매핑되는 반면, 각 페르미온 행동은 두 항의 합으로 매핑되므로, 여러 생성자로 이루어진 항은 확장되었다가 다시 병합되어 줄어들게 됩니다. 이 절감 효과는 항의 길이에 따라 커지며, 호핑 해밀토니안의 단일 생성자 항에서는 두 경로의 비용이 거의 같습니다.

Python API에서는 이러한 라이브러리 매퍼 중 일부를 타입에 무관한 편의 매퍼를 통해서도 사용할 수 있습니다. fermion_operator()와 majorana_operator()는 majorana_to_fermion()와 fermion_to_majorana() 등의 앞단에 위치하며, 사용자가 올바른 함수를 직접 고르지 않아도 전달받은 객체의 타입에 따라 내부적으로 적절한 라이브러리 구현에 위임합니다. jordan_wigner()와 (아래에서 소개하는) ternary_tree()에도 같은 방식이 적용되며, 각각 전달받은 연산자에 맞는 직접 구현으로 디스패치합니다. 이 디스패치가 내부적으로 어떻게 구현되는지는 qiskit_fermions.protocols를 참조하세요.

삼진 트리로 인코딩 선택하기​

Jordan-Wigner가 유일한 페르미온-큐비트 인코딩은 아니며, 가장 저렴한 것도 아닙니다. ZZ 문자열은 모드 인덱스에 따라 길어지므로, nn개 모드 중 마지막 모드에 대한 Majorana 연산자는 가중치 nn의 Pauli 문자열로 매핑됩니다. 패리티 정보를 사슬 대신 트리에 걸쳐 분산하는 인코딩은 점근적으로 더 나은 성능을 보입니다.

nn개 노드를 가진 삼진 트리는 이 전체 계열을 포괄합니다. 각 노드는 X, Y, Z로 레이블이 붙은 세 개의 하향 링크를 가진 큐비트입니다. 링크는 자식 노드로 내려가거나(에지) 끝납니다(레그). 각 레그에서 루트로 돌아가는 경로는 Pauli 문자열을 이룹니다. 노드 uu에서 내려가는 레이블 PP의 링크를 지나면 큐비트 uu에 PP가 기여합니다. 이러한 모든 트리는 유효한 인코딩을 만들며, 익숙한 인코딩들은 단지 특정한 모양일 뿐입니다. Z를 따라가는 사슬은 Jordan-Wigner이고, 노드 수가 허용하는 한 모든 노드가 세 개의 자식을 가지는 트리는 균형 트리입니다.

>>> from qiskit_fermions.mappers import TernaryTree, TernaryTreeEncoding
>>>
>>> # Jordan-Wigner is a chain descending along Z, with X and Y as legs at every node.
>>> TernaryTree.chain(13, "Z").max_weight
13
>>> # The balanced tree spreads the same information over depth log_3(2n+1).
>>> TernaryTree.breadth_first(13, 3).max_weight
3

이 최대 가중치는 최적입니다. Jiang et al.은 nn개 모드의 모든 페르미온-큐비트 매핑에서 Majorana 연산자의 평균 Pauli 가중치가 최소 log⁡3(2n)\log_3(2n) 이상임을 증명했으며, 따라서 어떤 매핑도 가장 큰 가중치를 ⌈log⁡3(2n+1)⌉\lceil \log_3(2n+1) \rceil 아래로 낮출 수 없습니다. 두 트리 모두 정확히 nn개의 큐비트를 사용하므로, 절감되는 것은 큐비트 수가 아니라 Pauli 가중치입니다.

연산자를 매핑하려면 트리를 인코딩으로 컴파일한 다음, 결과의 큐비트 수와 함께 매퍼에 전달합니다. 컴파일은 트리의 레그를 Majorana 연산자로 짝지은 뒤 그 Pauli 문자열을 한 번만 구성하므로, 동일한 인코딩을 여러 연산자에 재사용해야 합니다. 큐비트 수는 인코딩이 아닌 매핑 호출에 속하므로 하나의 인코딩으로 서로 다른 폭의 레지스터를 처리할 수 있으며, 트리의 노드 수보다 큰 값은 항등 연산으로 채워집니다.

>>> from qiskit_fermions.mappers.library import fermion_ternary_tree
>>> from qiskit_fermions.operators import FermionOperator
>>>
>>> hamiltonian = FermionOperator.from_dict(
... {((True, 0), (False, 3)): 1.0, ((True, 3), (False, 0)): 1.0}
... )
>>> encoding = TernaryTreeEncoding(TernaryTree.breadth_first(4, 3))
>>> fermion_ternary_tree(hamiltonian, encoding, 4).simplify()
<SparseObservable with 2 terms on 4 qubits: (0.5+0j)(Y_3 Z_1 Y_0) + (0.5+0j)(X_3 Z_2 X_0)>

Z를 따라가는 사슬을 통한 매핑은 fermion_jordan_wigner()(C API에서는 qf_ferm_op_jordan_wigner())를 정확히 재현하며, 이것이 이 방식이 Jordan-Wigner를 일반화한다는 의미입니다.

>>> from qiskit_fermions.mappers.library import fermion_jordan_wigner
>>> chain = TernaryTreeEncoding(TernaryTree.chain(4, "Z"))
>>> direct = fermion_jordan_wigner(hamiltonian, 4)
>>> (fermion_ternary_tree(hamiltonian, chain, 4) - direct).simplify().num_terms
0

모든 연산자 표현에는 Jordan-Wigner 집합과 대응되는 삼진 트리 매퍼가 있습니다. 전체 목록은 qiskit_fermions.mappers.library를, C API는 Mapper Library를 참조하세요. Python API에서는 jordan_wigner()와 마찬가지로, 전달받은 연산자에 맞는 매퍼를 골라 주는 타입에 무관한 래퍼인 ternary_tree()를 통해서도 사용할 수 있습니다.

>>> from qiskit_fermions.mappers.library import ternary_tree
>>> ternary_tree(hamiltonian, encoding, 4).simplify()
<SparseObservable with 2 terms on 4 qubits: (0.5+0j)(Y_3 Z_1 Y_0) + (0.5+0j)(X_3 Z_2 X_0)>
참고

Jordan-Wigner에서 성립하는 일부 항등식은 삼진 트리 전반이 아니라 사슬의 성질입니다. 수 연산자가 nj=(1−Zj)/2n_j = (1 - Z_j)/2인 것은 사슬에서만 그렇고, 다른 트리에서는 ZjZ_j가 일련의 큐비트에 걸친 ZZ 문자열이 됩니다. 마찬가지로 꼭짓점 연산자 VjV_j는 두 Majorana 문자열의 ZZ 사슬이 상쇄되는 사슬에서만 가중치 1의 Pauli ZjZ_j입니다.

모든 수 연산자는 여전히 하나의 ZZ 문자열로 매핑되므로, 어떤 삼진 트리에서도 Fock 상태는 계산 기저 상태이지만 비트 qq가 더 이상 점유 qq를 나타내지는 않습니다. 대신 점유 jj는 njn_j의 상(image)의 지지집합에 있는 측정 비트들의 패리티입니다. 어떤 모양이나 모드 순열에서도 디코딩이 올바르게 유지되도록, 이 지지집합은 트리에서 유도하지 말고 매퍼에서 읽어 오세요.

>>> for mode in range(4):
... number = FermionOperator.from_dict({((True, mode), (False, mode)): 1.0})
... print(mode, ternary_tree(number, encoding, 4).simplify())
0 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_2 Z_1 Z_0)>
1 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_1)>
2 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_2)>
3 <SparseObservable with 2 terms on 4 qubits: (0.5+0j)() + (-0.5+0j)(Z_3)>

이 균형 트리에서는 큐비트 0, 1, 2가 홀수 개의 1을 가질 때 모드 0이 점유되므로, 이를 준비하려면 큐비트 0만 뒤집으면 되는 반면, 측정된 비트열을 디코딩하려면 세 비트 모두의 패리티를 구해야 합니다. 따라서 큐비트 1의 단일 비트 플립 오류는 모드 0과 모드 1의 점유를 모두 손상시키며, 이는 노이즈가 있는 샘플에서 점유를 읽는 모든 워크플로에서 중요합니다.

Evolution을 이러한 매퍼로 낮출 때는 매퍼를 group_wise()로 감싸세요(페르미온 공간에서 Evolution 게이트 합성하기 참조). 호핑 항은 서로 교환 가능한 두 Pauli 문자열로 매핑되며, 각각은 단독으로는 입자 수를 보존하지 않고 evolution에서 두 문자열의 곱만이 보존합니다. Jordan-Wigner에서는 두 문자열이 같은 큐비트에 작용하므로 단순화된 항 순서에서는 둘이 인접하게 유지됩니다. 다른 트리에서는 지지집합이 서로 다르고(앞서 나온 Y_3 Z_1 Y_0과 X_3 Z_2 X_0처럼), 원래 순서와 단순화된 순서 모두 다른 항의 상과 둘을 교차시키므로, 큐비트 측의 곱 공식은 입자 수를 누출하게 됩니다.

임의의 트리 구성하기​

사슬 및 너비 우선 생성자는 균일한 계열을 다루지만, 일반 인터페이스는 노드마다 하나의 (parent, label) 쌍을 받으며 루트에는 None을 사용합니다(C API는 대신 루트의 인덱스를 받습니다). 탐색할 가치가 있는 트리는 대개 사슬도 균일 트리도 아니기 때문에 이것이 중요합니다. 예를 들어 Bonsai 알고리즘은 장치의 커플링 그래프의 신장 트리를 키워 가므로, 그 분기가 해당 연결성을 따릅니다.

>>> # Node 0 branches three ways, node 1 twice, and node 4 carries a single child.
>>> tree = TernaryTree([None, (0, "X"), (0, "Y"), (0, "Z"), (1, "X"), (1, "Z"), (4, "Y")])
>>> tree.num_nodes, tree.num_legs, tree.max_weight
(7, 15, 4)
>>> tree.children(0)
{'X': 1, 'Y': 2, 'Z': 3}

레그의 개수에 주목하세요. 모양에 관계없이 항상 2n+12n+1개입니다. 자식이 더 적은 노드가 더 적은 Pauli 문자열을 생성하지는 않습니다. 사용되지 않은 링크가 레그가 되고, 레그는 에지의 자손과 마찬가지로 문자열을 이루기 때문입니다. 이 2n+12n+1개의 문자열 중 2n2n개는 짝지어 nn개 모드의 Majorana 연산자가 되고, 정확히 하나가 남습니다.

전체 패리티​

그 남은 레그가 전체 페르미온 패리티 ∏j(1−2nj)\prod_j (1 - 2 n_j)입니다. 이는 어떤 연산자의 매핑이 아니라 인코딩 자체의 성질이므로, 인코딩의 메서드로 제공됩니다.

>>> TernaryTreeEncoding(TernaryTree.chain(4, "Z")).total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3 Z_2 Z_1 Z_0)>
>>> TernaryTreeEncoding(TernaryTree.breadth_first(4, 3)).total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3 Z_0)>

균형 트리의 경우에서 보듯이, 이것은 일반적으로 모든 ZZ로 이루어진 문자열이 아닙니다. 모든 ZZ 경로로 도달하는 레그이며, 그 경로에 없는 모든 노드에는 항등 연산으로 작용합니다.

중요

여기서 전체 패리티는 측정할 관측량이지 부과할 제약 조건이 아닙니다. 두 고유값 모두 물리적이므로 상태를 +1+1 섹터로 사영하지 마세요. 삼진 트리 인코딩은 모드당 하나의 큐비트를 사용하며 전체 2n2^n차원 공간 위로의 전사(onto) 대응이므로, 사영해서 제거할 비물리적 부분 공간이 없습니다.

실전 예제: Bravyi-Kitaev 트리​

실제 인코딩의 트리는 손으로 쓰기에는 거의 항상 너무 크기 때문에, 사양은 직접 입력하기보다 계산으로 구하는 경우가 더 많습니다. Bravyi-Kitaev는 좋은 예입니다. 이 계열에 속하지만 트리가 균일하지 않아 어떤 편의 생성자도 이를 다루지 못합니다.

이 트리는 모드당 하나씩인 노드에 대한 재귀적 분할로 구성됩니다. 노드 범위 [l,r][l, r]에서 hh를 들어맞는 가장 큰 2의 거듭제곱이라고 하면, 해당 범위에 배치되는 노드는 그 블록의 마지막인 l+h−1l + h - 1입니다. 이 노드의 X 링크는 그 앞 블록으로 만든 트리로, Z 링크는 그 뒤 나머지로 만든 트리로 내려가며, Y 링크는 레그로 남습니다.

>>> def bravyi_kitaev_tree(num_modes: int) -> TernaryTree:
... """Builds the ternary tree of the Bravyi-Kitaev encoding on ``num_modes`` modes."""
... spec: list[tuple[int, str] | None] = [None] * num_modes
... def build(left: int, right: int) -> int | None:
... if left > right:
... return None # an empty range: the parent's link stays a leg
... block = 1
... while 2 * block <= right - left + 1:
... block *= 2
... node = left + block - 1
... for child, label in (
... (build(left, node - 1), "X"), # the block before this node
... (build(node + 1, right), "Z"), # the remainder after it
... ):
... if child is not None:
... spec[child] = (node, label)
... return node
... build(0, num_modes - 1)
... return TernaryTree(spec)

4개 모드에서는 전체 범위가 하나의 블록이므로 가장 먼저 배치되는 노드는 3이고, 그 앞에 [0,2][0, 2]가 있으며 뒤에는 아무것도 없습니다. 이 하위 범위에서는 단일 노드 0과 2 위에 노드 1이 놓입니다. 트리를 컴파일해 보면 이 인코딩이 단지 같은 모양 부류의 트리가 아니라 Bravyi-Kitaev임을 확인할 수 있습니다. 첫 번째 Majorana 상은 X0X1X3X_0 X_1 X_3입니다.

>>> tree = bravyi_kitaev_tree(4)
>>> tree.children(1)
{'X': 0, 'Z': 2}
>>> encoding = TernaryTreeEncoding(tree)
>>> encoding.majorana_image(0)
[(0, 'X'), (1, 'X'), (3, 'X')]

이 트리의 전체 패리티는 이전 섹션의 요점을 구체적으로 보여줍니다. 남는 레그는 모든 ZZ 경로로 도달하는 레그이고 여기서는 그 경로가 루트에서 멈추므로, 관측량은 네 큐비트 전체에 걸친 문자열이 아니라 단일 큐비트 Z3Z_3입니다.

>>> encoding.total_parity(4)
<SparseObservable with 1 term on 4 qubits: (1+0j)(Z_3)>

규모를 키우는 데는 더 큰 인수만 있으면 됩니다. 어떤 노드도 Y 링크를 에지에 쓰지 않으므로 각 노드는 최대 두 갈래로만 분기하며, 따라서 Pauli 가중치는 선형이 아니라 로그 방식으로 증가합니다.

>>> [bravyi_kitaev_tree(n).max_weight for n in (4, 8, 16, 32, 64)]
[3, 4, 5, 6, 7]