연산자 표현 매핑하기
매퍼는 BosonOperator를 큐비트 연산자, 여기서는 SparseObservable로 바꿉니다. 연산자 자체는 큐비트에 대해 아무것도 모릅니다. 연산자는 모드에 대한 사다리 연산자를 저장하며, 보손 모드는 임의로 많은 입자를 담을 수 있습니다. 연산자를 유한하게 만드는 모든 것(점유 준위를 몇 개까지 유지할지, 그리고 그 준위들을 큐비트에 어떻게 배치할지)은 매핑할 때 결정됩니다.
라이브러리 매퍼 occupation_encoding()는 점유(Fock) 기저에서 동작합니다. 모드의 준위 은 보손이 개인 상태입니다. 이 매퍼는 연산자와, 절단과 인코딩을 담는 LevelSpec을 받습니다. 이 가이드는 그 두 가지 선택이 무엇을 뜻하며 각각 어떤 비용이 드는지 설명합니다. 이를 사용하는 전체 과정은 인코딩을 선택하여 Bose-Hubbard 동역학 시뮬레이션하기를, 트랜스파일러가 같은 선택을 회로 전체에 적용하는 방식은 보손 회로 트랜스파일하기를 참고하세요.
Fock 공간 절단하기
개의 준위로 유지되는 모드(여기서 는 그 max_occupation)는 더 이상 정준 교환 관계를 따르지 않습니다. 그 준위들로 절단된 소멸 연산자를 라고 쓰면,
따라서 이 관계는 가장 높은 준위를 제외한 모든 준위에서 성립합니다 [1]. 두 사다리 연산자의 교환자를 매핑하고 한 모드의 준위들에서 그 대각 성분을 읽으면 이를 확인할 수 있습니다. 다음 도우미 함수는 하나의 모드에 대해 매핑된 연산자의 밀집 행렬을 만드는데, 이는 아주 작은 레지스터에서만 합리적입니다.
>>> 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]
마지막 항목은 입니다. 이는 의 생성 공간이 교환자에 대해 닫혀 있지 않다는 뜻이므로, 어떤 인코딩도 대수의 동형 사상이 아닙니다. 인코딩은 상태를, 절단된 부분공간 안에서 작용하는 연산자와 함께 매핑합니다. 다음 두 가지 결과가 실제로 중요합니다.
절단 한계는 도달 가능한 점유수를 한정해야 합니다. 처음 시작하는 점유수만 한정하는 것으로는 충분하지 않습니다. 에 작용하는 빔 스플리터는 과 를 채우므로, 절단 한계가 1이면 아무 경고 없이 잘못된 상태가 나옵니다.
>>> 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)
절단 한계가 1이면, 동역학이 채울 두 상태가 존재하지 않기 때문에 에 머무를 확률이 1이 됩니다. 절단 한계가 2이면 그 확률은 사라지며, 이것이 Hong-Ou-Mandel 효과입니다. 이 때문에 max_occupation은 기본값이 없는 필수 인수입니다. 이는 모델링 결정이자, 직접 값을 바꿔 가며 확인해야 하는 수렴 매개변수입니다.
매핑은 정규 순서화와 교환되지 않습니다. 연산자 와 은 절단 전에는 같지만, 서로 다른 큐비트 연산자로 매핑됩니다.
>>> 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]
두 연산자는 가장 높은 준위를 제외한 모든 준위에서 일치합니다. 최상위 준위에서는 가 갈 곳이 없어 진폭이 잘려 나가므로 는 거기서 0인 반면, 은 를 줍니다. 해밀토니안이 정규 순서 형태로 정의되는지 한 번 정하고, 그 형태를 일관되게 매핑하세요.
절단 한계를 고르는 데 도움이 되는 세 가지 방법이 있으며, 모두 하한입니다. 연산자는 단일 항 안에서 각 모드에 적용하는 생성 연산자의 최대 개수를 알려 주고, 스펙은 이를 자신의 절단 한계와 비교합니다.
>>> 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]
0이 아닌 항목은 절단 한계가 하한보다 낮다는 뜻이며, 그 값은 얼마나 모자란지를 나타냅니다. 이 도우미 함수들은 동역학을 알지 못하므로, 검사를 통과했다고 해서 절단 한계가 충분하다고 할 수는 없습니다. 위의 빔 스플리터는 하한이 1이지만 절단 한계는 2가 필요합니다. 또한 유지된 준위에서 0이 되는 항에 대해서도 부족분을 보고할 수 있는데, 절단 한계가 1일 때의 가 그런 예입니다.
LevelSpec으로 절단 기술하기
LevelSpec은 모든 모드의 절단 한계를 인코딩과 함께 묶으며, 이로부터 각 모드가 차지하는 큐비트 수와 그 큐비트가 시작되는 위치를 도출합니다. 이에 관해 일치해야 하는 모든 것(매퍼, 트랜스파일러 레이아웃, 상태 준비)에는 동일한 스펙이 주어집니다. 인코딩은 필수 인수이며 절단 한계도 마찬가지로, 둘 다 기본값이 없습니다.
절단 한계는 모든 모드가 공유하는 정수 하나이거나, 모드마다 하나씩의 정수입니다.
>>> shared = LevelSpec(2, "gray")
>>> per_mode = LevelSpec([4, 2, 2], "gray")
>>> shared.num_modes is None, per_mode.num_modes
(True, 3)
단일 정수로 만든 스펙은 모드 수를 열어 두므로, 너비를 요청하기 전에 이를 확정해야 합니다. 시퀀스는 길이와 관계없이 절대 브로드캐스트되지 않으며, 모드 수를 확정합니다.
>>> 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
모드 은 큐비트 offsets[m]부터 offsets[m] + widths[m] - 1까지를 차지하므로, 절단 한계가 서로 다른 모드는 서로 다른 수의 큐비트를 사용할 수 있고, 오프셋은 너비의 누적합입니다. 매퍼에 모드 수를 전달할 필요는 없습니다. 매퍼는 이를 스펙에서 가져오거나, 절단 한계가 하나인 스펙의 경우 min_num_modes()를 통해 연산자에서 가져옵니다. 레지스터는 모드에 필요한 것보다 넓을 수 있으며, 남는 큐비트는 사용되지 않고 끝에 놓입니다.
>>> 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
모드별 스펙의 마지막 모드를 넘어서는 모드에 작용하는 연산자는 거부됩니다.
>>> 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
인코딩이 큐비트를 세는 방식에서 몇 가지 제한이 따라옵니다. 압축 인코딩에서는 준위가 하나뿐인 모드(max_occupation = 0)가 큐비트를 전혀 사용하지 않는데, 그 하나의 준위는 다른 준위와 구분하기 위해 아무것도 필요하지 않기 때문입니다. 이런 모드는 허용되며 그 뒤에 오는 모드의 큐비트를 옮기지 않지만, 모든 모드가 큐비트를 하나도 사용하지 않게 되는 스펙은 거부됩니다. 유나리 인코딩은 모드당 최대 128개의 준위를 담을 수 있으며, 더 큰 절단 한계는 압축 인코딩을 권하는 오류를 발생시킵니다.
>>> 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
인코딩 선택하기
LevelEncoding은 큐비트 블록의 어떤 기저 상태가 각 준위를 나타낼지 결정합니다. 인코딩은 네 가지가 있으며, 일반적으로 어느 하나가 가장 좋다고 할 수는 없습니다 [2]. 모드당 큐비트 수와 각 준위의 비트 패턴은 인코딩에 따라 정해집니다.
>>> 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']
비트 패턴은 비트 가 큐비트 의 값인 정수입니다. 유나리 인코딩은 준위마다 큐비트 하나를 쓰고 그중 하나를 설정합니다. 압축 인코딩은 개의 큐비트를 쓰며 준위 번호를 이진수로, 또는 이웃한 준위가 한 비트만 다른 Gray 코드로 저장합니다. 인코딩의 문자열 이름은 인코딩이 들어갈 수 있는 어느 곳에서나 허용됩니다.
트레이드오프는 큐비트 수 대 연산자 가중치입니다. 압축 인코딩은 지수적으로 적은 큐비트가 필요하지만, 사다리 연산자는 패턴이 여러 비트에서 다를 수 있는 준위들을 연결하므로 그 항이 더 많은 큐비트에 작용합니다. 이어지는 절에서는 그 비용을 살펴보며, 레이아웃은 같고 연산자만 다른 두 유나리 인코딩부터 시작합니다.
두 유나리 인코딩 비교하기
유나리 인코딩에서 사다리 연산자는 이웃한 두 큐비트 사이로 들뜸 하나를 옮깁니다. 이를 모드 전체에 대한 연산자로 쓰려면 나머지 큐비트를 어떻게 할지 정해야 합니다. 두 변형은 서로 다르게 선택합니다.
-
unary_projected는 모드의 다른 모든 큐비트에 사영 연산자 를 놓습니다. 이는 연산자 자체에서 원-핫 제약을 강제합니다. 즉 모드의 나머지가 비어 있을 때만 항이 작용합니다. -
unary_unprojected는 그 큐비트들을 그대로 두므로, 항은 그 큐비트들에 항등 연산으로 작용합니다.
에서 단일 를 두 방식으로 살펴보세요. 매퍼는 단일 큐비트 비트 항의 문자열로 이루어진 항을 가진 SparseObservable을 반환합니다. 다음은 각각의 처음 네 개 항과, 그 항이 작용하는 큐비트입니다.
>>> 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
둘 다 같은 계수를 가진 항이 열두 개입니다. 사영된 항은 사영되지 않은 항이 언급하지 않는 두 큐비트에 0 비트 항을 가지므로, 사영된 항은 각각 네 큐비트 모두에 작용하고 사영되지 않은 항은 각각 두 큐비트에 작용합니다. 일반적으로 사영된 항은 모드의 개 큐비트 모두에 작용하는데, 각각 또는 인 두 개의 인자와 나머지 모든 큐비트의 사영 연산자를 가지기 때문입니다.
두 연산자는 인코딩이 의도한 상태, 즉 원-핫 기저 상태(코드워드)에서는 같고 그 밖에서는 다릅니다. 확인하려면 에서 두 모드에 대한 작은 연산자를 잡고 아홉 개의 코드워드에서 행렬을 비교하세요.
>>> 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
둘 다 코드워드 부분공간을 불변으로 유지하므로, 코드워드에서 준비된 상태는 코드워드에 머뭅니다. 선택은 그 부분공간을 벗어나는 상태, 예를 들어 노이즈로 인한 경우에만 의미가 있습니다. 그 경우 사영된 연산자는 사영이 실패하기 때문에 발전을 억제하는 반면, 사영되지 않은 연산자는 계속 작용합니다. 어느 쪽 동작도 물리적 요구 사항은 아닙니다. 사영되지 않은 연산자는 원-핫 부분공간에서만 정의되므로, 이를 선택하는 것은 자유로운 최적화가 아니라 모델링 결정입니다.
사영 연산자의 비용은 게이트입니다. 하나의 모드에서 에르미트 를 짧은 시간 동안 발전시키고, Qiskit의 트랜스파일 후 2큐비트 게이트 수를 세어 보세요.
>>> 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
... }
정확한 개수는 Qiskit이 발전 게이트를 합성하는 방식에 따라 달라지고 이는 릴리스마다 바뀌므로, 그래프로 그려 보세요.
사영되지 않은 쪽의 개수는 에 따라 선형으로 늘어나는데, 개의 전이 각각이 2큐비트 회전이기 때문입니다. 사영된 쪽의 개수는 모든 항이 개의 큐비트 모두에 작용하므로 훨씬 빠르게 늘어납니다. 압축 인코딩은 큐비트가 더 적게 필요하지만, 그 항의 가중치는 이웃한 준위의 비트 패턴이 어떻게 다른지에 달려 있습니다. 한 비트만 다른 Gray 코드가 두 압축 인코딩 중 더 저렴하며, 표준 이진수는 올림으로 여러 비트가 한꺼번에 뒤집힐 때() 급등합니다. 압축 인코딩이 사영되지 않은 유나리 인코딩보다도 나은지는 와 Qiskit이 발전을 합성하는 방식에 달려 있으므로, 한 크기에서의 순위가 아니라 추세를 읽으세요.
사영 연산자는 매핑된 연산자를 파울리로 전개할 때도 중요한데, from_sparse_observable()은 각 사영 연산자를 두 파울리 연산자의 합으로 바꾸는 방식으로 이를 수행합니다. 사영 연산자가 개인 항은 개의 파울리 항이 됩니다. 다음은 작은 에서의 단일 이며, 이것이 이를 시도해 볼 만한 유일한 크기입니다.
>>> 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
두 변형 모두 저장하는 항의 수는 같지만, 사영된 쪽의 전개는 에 대해 지수적으로 늘어납니다. 장난감 예제보다 큰 경우에는 변환하지 말고, 위의 목록처럼 SparseObservable의 항을 직접 읽으세요.
압축 인코딩은 사영 연산자를 없앨 수 없습니다. 거기서는 0 비트가 준위 번호의 일부이므로, 를 제거하면 항이 연결하는 준위가 바뀌고, 연산자는 코드워드에서 더 이상 같지 않게 됩니다. 이 때문에 유나리 인코딩만 두 가지 변형을 가집니다.
사다리 연산자의 곱은 먼저 절단됩니다
대각 항의 비용에서 설계상의 선택이 하나 더 드러납니다. 매퍼는 각 사다리 연산자를 매핑한 뒤 결과를 곱하지 않습니다. 한 항이 하나의 모드에 적용하는 사다리 연산자들을 모아 Fock 기저에서 행렬로 곱하고, 그 곱을 한 번 절단한 뒤 결과를 인코딩합니다 [2]. 따라서 수 연산자 같은 곱은 대각으로 유지됩니다.
>>> 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
그 항은 0보다 높은 각 준위의 큐비트에 대한 사영 연산자 이며, 준위 번호로 가중됩니다(준위 0은 가중치가 0이므로 항이 없습니다). 이의 시간 발전은 단일 큐비트 회전의 집합이며 2큐비트 게이트가 필요하지 않습니다. 매핑된 두 사다리 연산자를 대신 곱하면 코드워드에서는 같은 연산자를 얻지만 그 밖에서는 다른 연산자가 되며, 게이트 비용이 드는 비대각 문자열이 생깁니다.
>>> 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
Bose-Hubbard 모델의 온사이트 상호작용 같은 대각 항이 바로 이런 곱이며, 이 선택이 중요해지는 곳입니다.
사용자 정의 매퍼 작성하기
라이브러리가 제공하지 않는 인코딩이 필요하다면, 단일 사다리 연산자의 상(image)으로부터 만드세요. map_boson_action_generators()는 하나의 BosonAction을 매핑하는 함수와 항등원을 반환하는 함수를 받아, 이들로부터 전체 연산자의 매퍼를 만듭니다. 각 항의 작용들의 상을 순서대로 곱하고, 계수로 스케일링한 다음, 항들을 합합니다. 출력은 스칼라 곱셈과 합성을 지원하는 어떤 타입이든 될 수 있습니다.
예로, 모드를 큐비트 하나에 저장하는 의 인코딩을 생각해 봅시다. 생성 연산자는 이며 Qiskit의 규약에서는 이고, 소멸 연산자는 그 수반 연산자입니다.
>>> 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
이 직접 작성한 매퍼는 매핑된 사다리 연산자를 곱하며, 여기서는 모든 모드가 두 개의 준위를 가지고 큐비트 하나가 모드 전체를 담기 때문에 라이브러리 매퍼와 일치합니다. 앞 절에서 보았듯이 가 더 크면, 매핑된 사다리 연산자를 합성하는 것과 곱을 먼저 절단하는 것은 코드워드 밖에서는 같은 연산자가 아닙니다.
이러한 매퍼를 회로에서 사용하려면 (operator, num_qubits) -> SparseObservable 시그니처를 가진 함수로 감싸서 MapperFnEvolutionSynthesis에 넘기세요. 이것은 모든 Evolution 게이트의 연산자를 그 함수로 매핑합니다. 트랜스파일러 파이프라인은 보손 회로 트랜스파일하기에 설명되어 있습니다.
>>> 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)
알아 둘 세부 사항이 두 가지 있습니다. 첫째, PauliEvolutionGate는 허수부를 거부하므로 큐비트 연산자의 계수는 실수여야 합니다. 에르미트 연산자는 실수 연산자로 매핑되지만, 상쇄되는 항들이 합쳐진 뒤에만 그렇습니다. 이 직접 작성한 매퍼는 그것들을 합치지 않으므로, 호핑 항과 그 수반 항은 결과를 단순화할 때까지 허수 계수를 남깁니다. 라이브러리 매퍼는 자신의 결과를 합치지만, 사용자 정의 매퍼는 직접 해야 하며, 예를 들어 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']
둘째, 곱 공식은 항을 전달받은 순서대로 합성하는데, 매퍼가 만들어 내는 순서가 안정적이라는 보장은 없습니다. 도우미 함수 simplify()와 group_wise()는 매퍼 함수를 감싸서 이를 제어합니다. simplify는 중복된 문자열을 합치고 항을 정규 순서로 정렬하며, group_wise는 연산자의 한 그룹씩 매핑해서 그룹의 항들이 인접하게 유지되도록 합니다. 정렬이 그룹화를 버리므로 둘을 함께 사용하지 마세요. 그룹화 가이드는 그룹을 할당하는 방법을, 합성 가이드는 순서가 회로에 미치는 영향을 보여 줍니다.
다음 단계
-
절단 한계를 바꿔 가며 인코딩을 정확한 동역학과 비교하는 전체 시뮬레이션은 인코딩을 선택하여 Bose-Hubbard 동역학 시뮬레이션하기를 따라 해 보세요.
-
동일한
LevelSpec이 보손 회로에 어떻게 적용되는지는 보손 회로 트랜스파일하기를 참고하세요. -
BosonOperator가 항을 어떻게 저장하고 정렬하는지, 그리고 위 정규 순서화 예제의 바탕이 되는 축약 규칙은 연산자 표현의 설계 원칙에서 읽어 보세요. -
보손을 모드 사이로 옮기는 게이트, 즉 도달 가능한 점유수에 대한 경고가 가장 중요한 부분은 보손 모드 회전하기에서 읽어 보세요.
-
qiskit_bosons.mappers와qiskit_bosons.mappers.library의 API 레퍼런스를 참고하세요.
[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.