연산자 표현의 설계 원칙
이 가이드는 BosonOperator가 어떻게 구성되고 어떻게 다루는지 설명합니다. 연산자를 만드는 방법, 연산자를 살펴보고 결합하는 방법, 두 연산자를 비교할 수 있는 형태로 만드는 방법을 다룹니다. 해밀토니안을 작성할 때 여기서 시작하세요.
BosonOperator는 복소 계수를 가진 보손 생성 및 소멸 연산자의 곱들의 정확한 합입니다. 점유 컷오프는 없습니다. 보손 모드는 임의의 많은 보손을 담을 수 있으므로 포크 공간은 무한 차원이며, 절단은 연산자를 큐비트에 매핑할 때에만 들어옵니다(포크 공간 절단하기 참조). 따라서 이 가이드의 모든 내용은 정준 교환 관계의 정확한 대수 안에서 이루어집니다.
두 가지 결과가 인터페이스를 결정합니다. 모드 인덱스는 하나의 항 안에서 반복될 수 있습니다. 는 보손 두 개를 생성하는 유효한 연산자이기 때문입니다. 또한 두 인자의 순서를 바꿔도 부호가 생기지는 않지만, 같은 모드에서 소멸 연산자를 생성 연산자 너머로 옮기면 처럼 항이 하나 추가됩니다.
저장 방식
연산자는 희소 행렬의 압축 희소 행(CSR) 형식과 비슷하게 항을 네 개의 평탄한 배열에 보관합니다.
coeffs | 항마다 하나의 복소 계수. |
actions | 인자마다 하나의 불리언: 생성이면 True, 소멸 연산자이면 False. |
modes | 인자마다 하나의 모드 인덱스. |
boundaries | actions와 modes에서 각 항이 시작하고 끝나는 위치. |
생성자는 이 네 배열을 직접 받습니다. 아래 연산자에는 , , 상수 의 세 항이 있으며, get_* 메서드로 배열을 다시 읽을 수 있습니다.
>>> from qiskit_bosons.operators import BosonOperator, ann, cre
>>> op = BosonOperator(
... coeffs=[2.0, -1.0j, 0.5],
... actions=[True, False, True, True],
... modes=[0, 1, 1, 1],
... boundaries=[0, 2, 4, 4],
... )
>>> op.get_coeffs()
[(2+0j), (-0-1j), (0.5+0j)]
>>> op.get_actions()
[True, False, True, True]
>>> op.get_modes()
[0, 1, 1, 1]
>>> op.get_boundaries()
[0, 2, 4, 4]
>>> len(op)
3
첫 번째 항은 actions와 modes의 위치 0에서 2까지, 두 번째 항은 2에서 4까지, 세 번째 항은 4에서 4까지이며, 이것이 인자가 없는 항인 상수항이 저장되는 방식입니다. 두 번째 항에서 반복되는 모드 1은 쓰인 그대로 보존됩니다. 모드 인덱스가 두 번 나타난다고 해서 항이 삭제되거나 병합되는 일은 없습니다.
from_dict()로 만든 연산자는 항을 딕셔너리의 순서대로 저장하지 않으므로, 배열에 나열되는 항의 순서가 실행할 때마다 다를 수 있습니다. 연산자 자체는 같으며, 아래에서 연산자를 비교하거나 출력하는 어떤 것도 저장된 순서에 의존하지 않습니다.
이 구조 덕분에 생성과 산술이 저렴하지만, 한 가지 결과가 따릅니다. 연산자가 같은 항을 여러 번 동시에 가질 수 있다는 점입니다. 합과 곱은 항을 병합하지 않고 덧붙이므로, 고유한 항이 필요할 때는 simplify()를 호출하세요(산술 연산하기 참조).
연산자 만들기
연산자를 만드는 가장 직접적인 방법은 인자들의 튜플에서 계수로 가는 딕셔너리를 받는 from_dict()입니다. 함수 cre()와 ann()이 인자를 만들고, 빈 튜플은 상수항입니다.
>>> b = BosonOperator.from_dict({(ann(0),): 1.0})
>>> b_dag = BosonOperator.from_dict({(cre(0),): 1.0})
>>> number = BosonOperator.from_dict({(cre(0), ann(0)): 1.0})
>>> print(format(number))
1.000000e0 +0.000000e0j * (+0 -0)
출력 형태에서 +0은 를, -0은 를 나타냅니다. 생성자 zero()와 one()은 각각 덧셈 항등원과 곱셈 항등원을 반환하며, from_terms()는 iter_terms()의 이터레이터로부터 연산자를 다시 만듭니다.
연산자 라이브러리는 무차원 위치 및 운동량 구적(quadrature)을 제공합니다.
position()과 momentum()으로 제공됩니다. 같은 모듈의 교환자 함수와 함께 사용하면 대수를 직접 확인할 수 있습니다.
>>> from qiskit_bosons.operators.library import commutator, momentum, position
>>> q, p = position(0), momentum(0)
>>> q.is_hermitian() and p.is_hermitian()
True
>>> commutator(q, p).equiv(1j * BosonOperator.one())
True
>>> commutator(b, b_dag).equiv(BosonOperator.one())
True
이 모듈은 anti_commutator()와 double_commutator()도 제공합니다. 세 함수 모두 같은 타입의 연산자를 받아 새 연산자를 반환합니다.
곱의 순서에 주의하기
두 연산자는 두 가지 방식으로 곱할 수 있으며 헷갈리기 쉽습니다. @ 연산자는 수학적 곱이므로 a @ b는 입니다. & 연산자는 왼쪽에서 오른쪽으로 읽는 Qiskit의 관례를 따르는 합성이므로, a & b는 먼저 a를 적용한 다음 b를 적용하여 입니다. 교환자 함수는 @를 사용하므로 commutator(a, b)는 입니다. 괄호 연산을 &로 직접 만들면 조용히 부호가 뒤집힙니다.
>>> (b @ b_dag).equiv(b_dag @ b + BosonOperator.one())
True
>>> (b & b_dag).equiv(b_dag @ b)
True
>>> ((b @ b_dag) - (b_dag @ b)).equiv(BosonOperator.one())
True
>>> ((b & b_dag) - (b_dag & b)).equiv(-BosonOperator.one())
True
연산자 살펴보기
연산자는 항이 위의 평탄한 배열에 있기 때문에 직접 순회할 수 없습니다. 대신 iter_terms()를 사용하세요. 이 메서드는 각 항마다 (is_creation, mode) 쌍의 리스트와 계수를 내줍니다. 이터레이터는 복사본을 반환하므로 이를 바꿔도 연산자는 바뀌지 않습니다.
>>> hopping = BosonOperator.from_dict(
... {
... (cre(0), ann(1)): -1.0,
... (cre(1), ann(0)): -1.0,
... (cre(2), cre(2), ann(2), ann(2)): 0.5,
... }
... )
>>> len(hopping)
3
>>> sorted(hopping.iter_terms())
[([(True, 0), (False, 1)], (-1+0j)), ([(True, 1), (False, 0)], (-1+0j)), ([(True, 2), (True, 2), (False, 2), (False, 2)], (0.5+0j))]
여러 메서드가 연산자의 구조를 요약해 줍니다.
>>> hopping.get_support()
{0, 1, 2}
>>> hopping.min_num_modes()
3
>>> hopping.max_rank()
4
get_support()는 연산자가 작용하는 모드의 집합이고, min_num_modes()는 연산자를 담을 수 있는 가장 작은 레지스터이며, max_rank()는 가장 긴 항의 길이입니다.
모드는 하나의 보손 자유도를 가리키는 추상적인 인덱스입니다. 모드 자체에는 의미가 없으며, 모드가 격자의 어느 사이트인지, 어느 진동 좌표인지, 어느 광학 포트인지는 사용자가 정합니다. BosonicCircuit도 같은 인덱스를 사용하므로(보손 회로 다루기 참조), 연산자와 이를 담는 게이트는 어느 쪽도 모드 2가 무엇을 나타내는지 몰라도 그것이 무엇인지에 합의합니다. 연산자는 모드가 몇 개의 레벨을 갖는지도 정하지 않으며, 레지스터 크기가 하한일 뿐인 이유도 이 때문입니다. min_num_modes()는 연산자를 담는 가장 작은 레지스터이지, 연산자의 속성이 아닙니다.
점유 메서드는 하나의 항이 한 모드에 놓을 수 있는 보손의 수를 알려 줍니다. 이는 매핑에 필요한 컷오프의 하한입니다. 시간 발전처럼 연산자를 반복해서 적용하면 더 높은 레벨이 채워질 수 있으므로 충분한 컷오프는 아닙니다(포크 공간 절단하기 참조).
>>> hopping.mode_occupations()
[1, 1, 2]
>>> hopping.mode_occupations([2, 0])
[2, 1]
>>> hopping.max_occupation()
2
두 가지 추가 검사는 보존량을 살핍니다. 연산자는 모든 항에서 생성 연산자와 소멸 연산자의 수가 같을 때 입자 수를 보존합니다. 블록 크기의 리스트를 전달하면 연속된 모드의 각 블록 안에서 같은 조건을 검사합니다.
>>> hopping.conserves_particle_number()
True
>>> move = BosonOperator.from_dict({(cre(0), ann(2)): 1.0})
>>> move.conserves_sector([3])
True
>>> move.conserves_sector([2, 1])
False
마지막으로 relabel_modes()는 모드의 이름을 바꿉니다. 모드 i는 permutation[i]가 됩니다. 리스트는 연산자의 모든 모드를 인덱싱할 수 있을 만큼 길어야 하며 항목을 반복해서는 안 됩니다.
>>> print(format(move.relabel_modes([5, 6, 7])))
1.000000e0 +0.000000e0j * (+5 -7)
산술 연산하기
연산자는 덧셈, 뺄셈, 부호 반전, 수에 의한 곱셈과 나눗셈을 일반적인 연산자 형태나 제자리(in place) 형태로 지원합니다.
>>> total = 2 * b_dag + b_dag - 3j * b
>>> len(total)
3
>>> len(total.simplify())
2
>>> total.simplify().equiv(3 * b_dag - 3j * b)
True
total의 세 항은 병합된 적이 없어서, simplify()가 합치기 전까지 연산자는 b_dag를 두 번 가지고 있었습니다. 이 메서드는 동일한 항의 계수를 합하고 총 크기가 허용 오차보다 작은 항을 버립니다. 이는 구조적인 연산으로, 쓰인 인자를 비교할 뿐 교환 관계를 적용하지 않습니다. 관련 메서드 ichop()은 먼저 병합하지 않고 작은 계수를 제자리에서 제거하므로, 연산자가 중복된 항을 가질 수 있을 때는 ichop() 전에 simplify()를 호출하세요.
곱은 위에서 설명한 대로 @ 또는 &를 사용하며, **는 연산자를 정수 거듭제곱합니다.
>>> x = b_dag + b
>>> print(format((x ** 2).simplify()))
1.000000e0 +0.000000e0j * (-0 -0)
1.000000e0 +0.000000e0j * (-0 +0)
1.000000e0 +0.000000e0j * (+0 -0)
1.000000e0 +0.000000e0j * (+0 +0)
결과는 네 가지 순서를 쓰인 그대로 나열합니다. 이를 하나의 형태로 줄이려면 교환 관계가 필요하며, 다음 섹션에서 이를 적용합니다.
수반(adjoint)은 각 항에서 인자의 순서를 뒤집고, 생성 연산자와 소멸 연산자를 서로 바꾸며, 계수를 켤레복소수로 만듭니다. is_hermitian() 메서드는 연산자가 자신의 수반과 같은지 검사합니다.
>>> hop = BosonOperator.from_dict({(cre(0), ann(1)): 1j})
>>> print(format(hop.adjoint()))
0.000000e0 -1.000000e0j * (+1 -0)
>>> hop.is_hermitian()
False
>>> (hop + hop.adjoint()).is_hermitian()
True
단독 호핑 항은 에르미트가 아니며, 시간 발전을 위해 항을 그룹화할 때 이 점이 중요합니다. 각 그룹은 그 자체로 에르미트여야 합니다(연산자 항 그룹화하기: 연산자 구조 활용 참조).
정규 순서화와 연산자 비교
사다리 연산자의 두 곱은 같은 연산자이면서도 다르게 보일 수 있습니다. 와 은 대수에서 같기 때문입니다. 이런 의미에서 같은 연산자라도 같게 저장되어 있지는 않을 수 있으므로, 비교하려면 교환 관계가 필요합니다. normal_ordered() 메서드가 이를 적용합니다. 모든 생성 연산자를 모든 소멸 연산자의 왼쪽으로 옮기고, 같은 모드의 쌍을 교환할 때마다 항을 추가합니다.
>>> product = BosonOperator.from_dict({(ann(0), cre(0), ann(0), cre(0)): 1.0})
>>> ordered = product.normal_ordered()
>>> len(ordered)
5
>>> print(format(ordered.simplify()))
1.000000e0 +0.000000e0j * ()
3.000000e0 +0.000000e0j * (+0 -0)
1.000000e0 +0.000000e0j * (+0 +0 -0 -0)
정렬된 연산자는 병합되지 않은 합으로 반환되며, 저장된 다섯 개의 항은 simplify()가 병합한 뒤 세 개로 줄어듭니다. 정규 순서화는 항을 사라지게 만들지 않습니다. 와 그 밖의 반복된 인자는 그대로 남습니다.
sandwich 인자는 두 인자 그룹 안에서 모드를 어떻게 배열할지 정합니다. 기본적으로 두 그룹 모두 내림차순으로 정렬됩니다. sandwich=True이면 생성 연산자는 오름차순, 소멸 연산자는 내림차순이 되어 더 큰 인덱스가 가운데로 모입니다.
>>> pair = BosonOperator.from_dict({(cre(0), cre(1), ann(0), ann(1)): 1.0})
>>> list(pair.normal_ordered().iter_terms())
[([(True, 1), (True, 0), (False, 1), (False, 0)], (1+0j))]
>>> list(pair.normal_ordered(sandwich=True).iter_terms())
[([(True, 0), (True, 1), (False, 1), (False, 0)], (1+0j))]
== 연산자는 저장된 항의 정확한 동일성을 허용 오차나 단순화 없이 검사하므로, 수학적으로 같은 두 연산자의 차이를 보고할 수 있습니다. equiv() 메서드는 두 연산자의 차를 먼저 정규 순서로 만든 다음 모든 계수가 허용 오차보다 작은지 검사합니다. 대부분의 경우 원하는 비교가 바로 이것입니다.
>>> reordered = BosonOperator.from_dict({(cre(0), ann(0)): 1.0, (): 1.0})
>>> contracted = BosonOperator.from_dict({(ann(0), cre(0)): 1.0})
>>> contracted == reordered
False
>>> contracted.simplify() == reordered.simplify()
False
>>> contracted.equiv(reordered)
True
simplify 메서드만으로는 두 형태가 합쳐지지 않습니다. 쓰인 항이 동일한 것만 병합하기 때문입니다. 마찬가지로 is_hermitian()은 연산자를 정규 순서의 수반과 비교하므로, 추가 작업 없이 를 에르미트로 인식합니다.
정규 순서화는 절단되지 않은 대수에서 정확한 항등식이며 equiv는 이에 의존합니다. 절단 후에는 항등식이 아닙니다. 매핑된 연산자는 매핑하는 형태에 따라 달라지므로, 위의 contracted와 reordered의 매핑 결과는 유지된 가장 높은 레벨에서만 다릅니다. 차이의 크기와 형태를 고르는 방법은 포크 공간 절단하기를 참조하세요.
항 정렬과 필터링
항 안의 인자가 아니라 연산자의 항을 재정렬해도 연산자는 바뀌지 않지만, 무작위 곱 공식처럼 항을 순서대로 읽는 알고리즘의 동작은 달라질 수 있습니다. qiskit_bosons.operators.terms의 함수는 새 연산자를 반환하며 항에 붙은 그룹 인덱스를 그대로 유지합니다.
canonical_order()는 쓰인 인자에만 의존하는 고정된 순서로 항을 정렬하므로, 연산자가 어떻게 조립되었든 같은 모습으로 읽힙니다. order_terms()는 사용자가 제공하는 키로 정렬하며, 키는 각 항을 iter_terms()가 내주는 튜플로 받습니다.
>>> from qiskit_bosons.operators.terms import canonical_order, order_terms
>>> mixed = BosonOperator.from_dict({(cre(1), ann(0)): 1.0, (cre(0), ann(1)): 3.0, (): 2.0})
>>> list(canonical_order(mixed).iter_terms())
[([], (2+0j)), ([(True, 0), (False, 1)], (3+0j)), ([(True, 1), (False, 0)], (1+0j))]
>>> by_weight = order_terms(mixed, key=lambda term: abs(term[1]), reverse=True)
>>> [coeff for _, coeff in by_weight.iter_terms()]
[(3+0j), (2+0j), (1+0j)]
연산자가 그룹 인덱스를 가지고 있다면(연산자 항 그룹화하기: 연산자 구조 활용 참조), group_order() 함수는 각 그룹이 항의 연속된 구간이 되도록 만듭니다.
>>> from qiskit_bosons.operators.terms import group_order
>>> grouped = BosonOperator.from_dict({(cre(1), ann(0)): 1.0, (cre(0), ann(1)): 3.0, (): 2.0})
>>> grouped.groups = [1, 0, 1]
>>> group_order(grouped).groups
[0, 1, 1]
마지막 도우미는 항을 재정렬하는 대신 제거합니다. 수 연산자의 곱인 항 는 각 포크 상태에 위상만 곱하므로, 계산하려는 값에서 그 위상이 무관하다면 filter_diagonal_terms()로 상수, 수 연산자, 그리고 그 곱을 버릴 수 있습니다. 이 함수는 연산자를 제자리에서 바꾸며 정규 순서의 연산자를 요구하지만, 이를 검사하지는 않습니다.
>>> from qiskit_bosons.operators.terms import filter_diagonal_terms
>>> model = BosonOperator.from_dict(
... {
... (cre(0), ann(1)): -1.0,
... (cre(1), ann(0)): -1.0,
... (cre(0), cre(0), ann(0), ann(0)): 0.5,
... (cre(0), ann(0)): 0.2,
... (): 0.1,
... }
... )
>>> filter_diagonal_terms(model)
>>> len(model)
2
>>> model.is_hermitian()
True
두 개의 호핑 항만 남습니다. 온사이트 상호작용과 수 연산자는 점유 기저에서 대각이므로 상수와 함께 제거됩니다. 상호작용에서 반복된 모드도 올바르게 처리됩니다. 는 대각이지만 는 그렇지 않습니다.
다음 단계
-
연산자 항 그룹화하기: 연산자 구조 활용에서는 항에 그룹 인덱스를 붙이는 방법을 보여 줍니다.
-
연산자 표현 매핑하기에서는 인코딩 선택과 절단의 효과를 포함하여 연산자를 큐비트 연산자로 바꾸는 방법을 다룹니다.
-
인코딩을 선택하여 보스-허바드 동역학 시뮬레이션하기에서는 보스-허바드 체인의 해밀토니안을 만들고 처음부터 끝까지 시뮬레이션합니다.
-
qiskit_bosons.operators와qiskit_bosons.operators.terms모듈에 전체 인터페이스가 문서화되어 있습니다.