기댓값 추정을 위한 연산자 역전파(OBP)
사용 시간 예상: Heron r3 프로세서에서 4분 (참고: 이는 추정치이며 실제 실행 시간은 다를 수 있습니다.)
학습 목표
이 튜토리얼을 완료한 후 사용자는 다음을 이해할 수 있습니다.
qiskit-addon-obp를 사용하여 회로 실행 횟수 증가를 감수하면서 양자 회로의 깊이를 줄이는 방법qiskit-addon-utils를 사용하여 XYZ 해밀토니안과 그 시간 진화 회로를 구성하는 방법
사전 요구 사항
이 튜토리얼을 진행하기 전에 다음 주제를 숙지할 것을 권장합니다.
- 관측 가능량의 기댓값을 계산하기 위한 Estimator 프리미티브 사용법
배경
연산자 역전파(Operator backpropagation)는 양자 회로의 끝에서 연산을 측정 대상 관측 가능량(observable)에 흡수하는 기법으로, 일반적으로 관측 가능량의 항이 늘어나는 대신 회로의 깊이를 줄일 수 있습니다. 목표는 관측 가능량이 지나치게 커지지 않는 범위에서 최대한 많은 회로를 역전파하는 것입니다. Qiskit 기반 구현은 OBP Qiskit 애드온에서 제공됩니다. 자세한 내용은 해당 문서를 참고하세요.
관측 가능량 를 측정해야 하는 예시 회로를 생각해봅시다. 여기서 는 Pauli 연산자이고 는 계수입니다. 회로를 단일 유니터리 로 나타내면 아래 그림과 같이 로 논리적으로 분할할 수 있습니다.

연산자 역전파는 유니터리 를 와 같이 관측 가능량에 진화시켜 흡수합니다. 즉, 계산의 일부가 관측 가능량을 에서 로 진화시키는 고전적 방법을 통해 수행됩니다. 이제 원래 문제는 유니터리가 인 더 낮은 깊이의 회로에서 관측 가능량 를 측정하는 것으로 재구성할 수 있습니다.
유니터리 는 여러 슬라이스 으로 표현됩니다. 슬라이스를 정의하는 방법은 여러 가지가 있습니다. 예를 들어, 위 예시 회로에서 각 레이어와 각 게이트 레이어를 개별 슬라이스로 볼 수 있습니다. 역전파는 를 고전적으로 계산하는 과정을 포함합니다. 각 슬라이스 는 로 표현할 수 있으며, 여기서 는 -큐비트 Pauli이고 는 스칼라입니다. 다음이 성립함을 쉽게 확인할 수 있습니다.
위 예시에서 이면 기댓값을 계산하기 위해 하나가 아닌 두 개의 양자 회로를 실행해야 합니다. 따라서 역전파는 관측 가능량의 항 수를 늘려 더 많은 회로 실행이 필요할 수 있습니다. 연산자가 너무 커지지 않으면서 더 깊이 역전파하는 방법 중 하나는 연산자에 항을 추가하는 대신 계수가 작은 항을 잘라내는(truncate) 것입니다. 예를 들어, 위 예시에서 가 충분히 작다면 를 포함하는 항을 잘라낼 수 있습니다. 항을 잘라내면 실행해야 할 양자 회로 수가 줄어들지만, 그 대가로 잘라낸 항의 계수 크기에 비례하는 오차가 최종 기댓값 계산에 발생합니다.
요구 사항
이 튜토리얼을 시작하기 전에 다음이 설치되어 있는지 확인하세요.
- Qiskit SDK v2.0 이상, 시각화 지원 포함
- Qiskit Runtime v0.22 이상 (
pip install qiskit-ibm-runtime) - OBP Qiskit 애드온 0.3 이상 (
pip install qiskit-addon-obp) - Qiskit 애드온 유틸리티 0.3 이상 (
pip install qiskit-addon-utils)
설정
# Added by doQumentation — required packages for this notebook
!pip install -q matplotlib numpy qiskit qiskit-addon-obp qiskit-addon-utils qiskit-ibm-runtime rustworkx
import numpy as np
import matplotlib.pyplot as plt
from qiskit.primitives import StatevectorEstimator
from qiskit.transpiler.preset_passmanagers import generate_preset_pass_manager
from qiskit.quantum_info import SparsePauliOp
from qiskit.transpiler import CouplingMap
from qiskit.synthesis import LieTrotter
from qiskit_addon_utils.problem_generators import generate_xyz_hamiltonian
from qiskit_addon_utils.problem_generators import (
generate_time_evolution_circuit,
)
from qiskit_addon_utils.slicing import slice_by_depth, combine_slices
from qiskit_addon_obp.utils.simplify import OperatorBudget
from qiskit_addon_obp import backpropagate
from qiskit_addon_obp.utils.truncating import setup_budget
from rustworkx.visualization import graphviz_draw
from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_ibm_runtime import EstimatorV2, EstimatorOptions
소규모 시뮬레이터 예시
이 튜토리얼은 OBP Qiskit 애드온을 사용하여 하이젠베르크 스핀 체인의 양자 동역학을 시뮬레이션하는 Qiskit 패턴을 구현합니다. 노이즈 없는 시뮬레이터에서는 역전파 유무에 관계없이 동일한 기댓값을 얻을 수 있습니다.
1단계: 고전적 입력을 양자 문제로 매핑하기
양자 하이젠베르크 모델의 시간 진화를 양자 실험으로 매핑하기
먼저 qiskit-addon-utils의 generate_xyz_hamiltonian 함수를 사용하여 주어진 연결 그래프에서 하이젠베르크 유사 해밀토니안을 생성합니다. 이 그래프는 rustworkx.PyGraph 또는 CouplingMap일 수 있습니다. 다음에서는 10개의 Qubit으로 이루어진 선형 체인 CouplingMap을 사용합니다.
num_qubits = 10
layout = [(i - 1, i) for i in range(1, num_qubits)]
# Instantiate a CouplingMap object
coupling_map = CouplingMap(layout)
graphviz_draw(coupling_map.graph, method="circo")
다음으로, 하이젠베르크 XYZ 해밀토니안을 모델링하는 Pauli 연산자를 생성합니다.
여기서 는 커플링 맵의 그래프입니다. 이 튜토리얼에서는 를 각각 로, 를 각각 로 설정하였습니다.
# Get a qubit operator describing the Heisenberg XYZ model
hamiltonian = generate_xyz_hamiltonian(
coupling_map,
coupling_constants=(np.pi / 8, np.pi / 4, np.pi / 2),
ext_magnetic_field=(np.pi / 3, np.pi / 6, np.pi / 9),
)
print(hamiltonian)
SparsePauliOp(['IIIIIIIXXI', 'IIIIIIIYYI', 'IIIIIIIZZI', 'IIIIIXXIII', 'IIIIIYYIII', 'IIIIIZZIII', 'IIIXXIIIII', 'IIIYYIIIII', 'IIIZZIIIII', 'IXXIIIIIII', 'IYYIIIIIII', 'IZZIIIIIII', 'IIIIIIIIXX', 'IIIIIIIIYY', 'IIIIIIIIZZ', 'IIIIIIXXII', 'IIIIIIYYII', 'IIIIIIZZII', 'IIIIXXIIII', 'IIIIYYIIII', 'IIIIZZIIII', 'IIXXIIIIII', 'IIYYIIIIII', 'IIZZIIIIII', 'XXIIIIIIII', 'YYIIIIIIII', 'ZZIIIIIIII', 'IIIIIIIIIX', 'IIIIIIIIIY', 'IIIIIIIIIZ', 'IIIIIIIIXI', 'IIIIIIIIYI', 'IIIIIIIIZI', 'IIIIIIIXII', 'IIIIIIIYII', 'IIIIIIIZII', 'IIIIIIXIII', 'IIIIIIYIII', 'IIIIIIZIII', 'IIIIIXIIII', 'IIIIIYIIII', 'IIIIIZIIII', 'IIIIXIIIII', 'IIIIYIIIII', 'IIIIZIIIII', 'IIIXIIIIII', 'IIIYIIIIII', 'IIIZIIIIII', 'IIXIIIIIII', 'IIYIIIIIII', 'IIZIIIIIII', 'IXIIIIIIII', 'IYIIIIIIII', 'IZIIIIIIII', 'XIIIIIIIII', 'YIIIIIIIII', 'ZIIIIIIIII'],
coeffs=[0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j, 0.39269908+0.j,
0.78539816+0.j, 1.57079633+0.j, 0.39269908+0.j, 0.78539816+0.j,
1.57079633+0.j, 0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j,
0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j, 0.39269908+0.j,
0.78539816+0.j, 1.57079633+0.j, 0.39269908+0.j, 0.78539816+0.j,
1.57079633+0.j, 0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j,
0.39269908+0.j, 0.78539816+0.j, 1.57079633+0.j, 1.04719755+0.j,
0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j,
0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j, 0.34906585+0.j,
1.04719755+0.j, 0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j,
0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j,
0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j, 0.34906585+0.j,
1.04719755+0.j, 0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j,
0.52359878+0.j, 0.34906585+0.j, 1.04719755+0.j, 0.52359878+0.j,
0.34906585+0.j])
큐비트 연산자로부터 시간 진화를 모델링하는 양자 회로를 생성할 수 있습니다. generate_time_evolution_circuit을 Lie Trotter 분해와 함께 사용하여 시간 진화 회로를 구성하였습니다.
circuit = generate_time_evolution_circuit(
hamiltonian,
time=0.2,
synthesis=LieTrotter(reps=2),
)
circuit.draw("mpl", style="iqp", fold=-1)

2단계: 양자 하드웨어 실행을 위한 문제 최적화
역전파할 회로 슬라이스 생성하기
backpropagate 함수는 한 번에 전체 회로 슬라이스를 역전파합니다. 따라서 슬라이싱 방법의 선택은 주어진 문제에서 역전파 성능에 영향을 줄 수 있습니다. 여기서는 slice_by_depth 함수를 사용하여 같은 유형의 게이트를 슬라이스로 묶겠습니다.
회로 슬라이싱에 대한 더 자세한 논의는 qiskit-addon-utils 패키지의 사용 방법 가이드를 참고하세요.
slices = slice_by_depth(circuit, max_slice_depth=1)
print(f"Separated the circuit into {len(slices)} slices.")
Separated the circuit into 18 slices.
역전파 중 연산자 증가 크기 제한하기
역전파 중 연산자의 항 수는 일반적으로 이 슬라이스 수일 때 에 빠르게 근접합니다. 두 항이 큐비트 단위로 교환되지 않으면(non-commuting), 해당 항들의 기댓값을 얻기 위해 별도의 회로가 필요합니다. 예를 들어, 2-큐비트 관측 가능량 가 있다면, 이므로 이 두 항의 기댓값을 계산하는 데 단일 기저 측정으로 충분합니다. 하지만 는 나머지 두 항과 반교환(anti-commute)하므로 의 기댓값을 계산하려면 별도의 기저 측정이 필요합니다. 즉, 을 계산하는 데 하나가 아닌 두 개의 회로가 필요합니다. 연산자의 항 수가 증가할수록 필요한 회로 실행 횟수도 증가할 가능성이 있습니다.
연산자의 크기는 backpropagate 함수의 operator_budget 키워드 인수를 지정하여 제한할 수 있으며, 이 인수는 OperatorBudget 인스턴스를 받습니다.
추가 리소스(회로 실행 횟수, 즉 필요한 QPU 시간) 할당량을 제어하기 위해, 역전파된 관측 가능량이 가질 수 있는 큐비트 단위 교환 Pauli 그룹의 최대 수를 제한합니다. 여기서는 연산자의 큐비트 단위 교환 Pauli 그룹 수가 여덟을 초과하면 역전파를 중단하도록 지정합니다.
op_budget = OperatorBudget(max_qwc_groups=8)
회로에서 슬라이스 역전파하기
먼저 관측 가능량을 로 지정합니다. 여기서 은 Qubit의 수입니다. 관측 가능량의 항들이 더 이상 여덟 개 이하의 큐비트 단위 교환 Pauli 그룹으로 결합될 수 없을 때까지 시간 진화 회로에서 슬라이스를 역전파합니다.
observable = SparsePauliOp.from_sparse_list(
[("Z", [i], 1 / num_qubits) for i in range(num_qubits)],
num_qubits=num_qubits,
)
observable
SparsePauliOp(['IIIIIIIIIZ', 'IIIIIIIIZI', 'IIIIIIIZII', 'IIIIIIZIII', 'IIIIIZIIII', 'IIIIZIIIII', 'IIIZIIIIII', 'IIZIIIIIII', 'IZIIIIIIII', 'ZIIIIIIIII'],
coeffs=[0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j, 0.1+0.j,
0.1+0.j, 0.1+0.j])
아래에서 6개의 슬라이스가 역전파되었으며, 항들이 여덟이 아닌 여섯 개의 그룹으로 결합된 것을 볼 수 있습니다. 이는 한 슬라이스를 더 역전파하면 Pauli 그룹 수가 여덟을 초과한다는 것을 의미합니다. 반환된 메타데이터를 검사하여 이를 확인할 수 있습니다. 또한 이 부분에서 회로 변환은 정확합니다. 즉, 새로운 관측 가능량 의 어떤 항도 잘리지 않았습니다. 역전파된 회로와 역전파된 연산자는 원래 회로와 연산자와 정확히 동일한 결과를 제공합니다.
# Backpropagate slices onto the observable
bp_obs, remaining_slices, metadata = backpropagate(
observable, slices, operator_budget=op_budget
)
# Recombine the slices remaining after backpropagation
bp_circuit = combine_slices(remaining_slices)
print(f"Backpropagated {metadata.num_backpropagated_slices} slices.")
print(
f"New observable has {len(bp_obs.paulis)} terms, which can be combined into "
f"{len(bp_obs.group_commuting(qubit_wise=True))} groups."
)
print(
f"Note that backpropagating one more slice would result in "
f"{metadata.backpropagation_history[-1].num_paulis[0]} terms "
f"across {metadata.backpropagation_history[-1].num_qwc_groups} groups."
)
print("The remaining circuit after backpropagation looks as follows:")
bp_circuit.draw("mpl", fold=-1, scale=0.6)
Backpropagated 6 slices.
New observable has 60 terms, which can be combined into 6 groups.
Note that backpropagating one more slice would result in 114 terms across 12 groups.
The remaining circuit after backpropagation looks as follows:
소규모 시뮬레이터 예시에서는 잘라내기를 사용하지 않겠습니다. 노이즈가 없는 환경에서는 역전파 유무에 관계없이 동일한 결과가 나오며, 잘라내기는 근사 오차를 추가하여 결과를 악화시키기 때문입니다.
기저 게이트 집합으로 회로 트랜스파일하기
이제 원본 회로와 역전파된 회로를 모두 백엔드의 기저 게이트로 트랜스파일합니다. 소규모 인스턴스는 시뮬레이터에서 실행할 것이므로 실제 백엔드에서 트랜스파일할 필요는 없습니다.
service = QiskitRuntimeService()
backend = service.least_busy(
operational=True, simulator=False, min_num_qubits=133
)
print(backend)
<IBMBackend('ibm_kingston')>
pm_basis = generate_preset_pass_manager(
optimization_level=3, basis_gates=backend.configuration().basis_gates
)
isa_circuit = pm_basis.run(circuit)
isa_bp_circuit = pm_basis.run(bp_circuit)
3단계: Qiskit 프리미티브를 사용하여 실행
먼저 원본 회로와 역전파된 회로에 해당하는 두 개의 Primitive Unified Bloc (PUB)을 생성합니다. 그런 다음 이상적인 Estimator에서 PUB를 실행하여 기댓값을 얻습니다.
pubs = [(isa_circuit, observable), (isa_bp_circuit, bp_obs)]
rng = np.random.default_rng()
estimator = StatevectorEstimator(seed=rng)
job = estimator.run(pubs)
4단계: 후처리하여 원하는 고전 형식으로 결과 반환
이제 원본 회로와 역전파된 회로의 기댓값을 구합니다.
primitive_result = job.result()
circuit_expval = primitive_result[0].data.evs.item()
bp_circuit_expval = primitive_result[1].data.evs.item()
methods = [
"No backpropagation",
"Backpropagation",
]
values = [circuit_expval, bp_circuit_expval]
ax = plt.gca()
plt.bar(methods, values, color="#a56eff", width=0.4, edgecolor="#8a3ffc")
ax.set_ylim([0.6, 0.92])
ax.set_ylabel(r"$M_Z$", fontsize=12)
Text(0, 0.5, '$M_Z$')
예상대로 두 기댓값은 일치합니다. 노이즈 없는 상태 벡터 시뮬레이터에서 실행하기 때문에, 역전파는 회로-관측량 쌍의 정확한 변환이므로 원본 워크플로와 역전파 워크플로는 동일한 값을 산출해야 합니다. 역전파의 이점은 노이즈가 있는 하드웨어에서만 나타나며, 이 경우 더 짧은 역전파 회로는 오차가 적게 누적됩니다. 이는 아래의 대규모 하드웨어 예시에서 확인할 수 있습니다.
대규모 하드웨어 예시
실험을 개발할 때는 시각화와 시뮬레이션을 쉽게 하기 위해 소규모 회로로 시작하는 것이 유용합니다. 이제 동일한 , 파라미터와 동일한 관측량 를 사용하되, 4개의 Trotter 스텝으로 50큐비트 하이젠베르크 해밀토니안에 대한 연산자 역전파를 살펴봅니다. 이 규모에서의 이상적인 기댓값은 단순 계산으로는 구할 수 없으므로, 텐서 네트워크를 사용하여 이상적인 기댓값이 임을 구합니다.
역전파와 함께, 이 대규모 예시에서는 잘라내기를 적용한 역전파도 소개합니다. 이상적으로는 유효 회로의 깊이를 줄이기 위해 최대한 많이 역전파하고 싶습니다. 하지만 이는 업데이트된 관측량에 교환되지 않는 항의 수를 증가시켜 양자 오버헤드를 키울 수 있습니다. 따라서 잘라내기를 통해 업데이트된 관측량의 항 수를 줄임으로써 계수가 작은 관측량 항을 제거할 수 있습니다. 잘라내기는 항 수를 줄여 더 깊은 전파를 가능하게 하지만 일부 근사를 도입합니다. 따라서 더 깊은 역전파로 인한 노이즈 감소 효과가 잘라내기 근사 오차에 압도되지 않도록 잘라내기를 일정 범위 내로 제한하는 것이 필요합니다.
잘라내기 양을 제한하기 위해, setup_budget 함수를 사용하여 각 슬라이스와 전체 역전파 회로에 대한 오차 예산을 할당합니다. 이를 통해 슬라이스 단위 및 전체 회로에 걸쳐 잘라내기가 제어됩니다. 예산 할당의 다른 방법은 이 가이드를 참고하세요.
num_qubits = 50
layout = [(i - 1, i) for i in range(1, num_qubits)]
# Instantiate a CouplingMap object
coupling_map = CouplingMap(layout)
hamiltonian = generate_xyz_hamiltonian(
coupling_map,
coupling_constants=(np.pi / 8, np.pi / 4, np.pi / 2),
ext_magnetic_field=(np.pi / 3, np.pi / 6, np.pi / 9),
)
# Generate a time evolution circuit for the Hamiltonian
circuit = generate_time_evolution_circuit(
hamiltonian,
time=0.2,
synthesis=LieTrotter(reps=4),
)
# Define the observable to measure
observable = SparsePauliOp.from_sparse_list(
[("Z", [i], 1 / num_qubits) for i in range(num_qubits)],
num_qubits,
)
slices = slice_by_depth(circuit, max_slice_depth=1)
# Define the maximum number of qwc groups allowed in the
# backpropagated observable,
# and the truncation error budget
op_budget = OperatorBudget(max_qwc_groups=15)
truncation_error_budget = setup_budget(
max_error_total=0.03, max_error_per_slice=0.005
)
# First backpropagation without truncation
bp_obs, remaining_slices, metadata = backpropagate(
observable, slices, operator_budget=op_budget
)
bp_circuit = combine_slices(remaining_slices)
# Now backpropagate with truncation, using the same operator budget and
# the defined truncation error budget
bp_obs_trunc, remaining_slices_trunc, metadata = backpropagate(
observable,
slices,
operator_budget=op_budget,
truncation_error_budget=truncation_error_budget,
)
bp_circuit_trunc = combine_slices(
remaining_slices_trunc, include_barriers=False
)
# Now we transpile the original circuit and the two backpropagated circuits,
# and apply the layout to the corresponding observables
pm = generate_preset_pass_manager(optimization_level=3, backend=backend)
isa_circuit = pm.run(circuit)
isa_bp_circuit = pm.run(bp_circuit)
isa_bp_circuit_trunc = pm.run(bp_circuit_trunc)
isa_observable = observable.apply_layout(isa_circuit.layout)
isa_bp_observable = bp_obs.apply_layout(isa_bp_circuit.layout)
isa_bp_observable_trunc = bp_obs_trunc.apply_layout(
isa_bp_circuit_trunc.layout
)
# Compare the 2-qubit depth of each transpiled circuit to see how much
# depth backpropagation saved
print(
f"2-qubit depth without backpropagation: "
f"{isa_circuit.depth(lambda x: x.operation.num_qubits == 2)}"
)
print(
f"2-qubit depth with backpropagation: "
f"{isa_bp_circuit.depth(lambda x: x.operation.num_qubits == 2)}"
)
print(
f"2-qubit depth with backpropagation and truncation: "
f"{isa_bp_circuit_trunc.depth(lambda x: x.operation.num_qubits == 2)}"
)
pubs = [
(isa_circuit, isa_observable),
(isa_bp_circuit, isa_bp_observable),
(isa_bp_circuit_trunc, isa_bp_observable_trunc),
]
# Now we instantiate the Estimator primitive for the hardware with
# ZNE and measurement error
# mitigation and compute the three circuits and observables
options = EstimatorOptions()
options.default_precision = 0.01
options.resilience_level = 2
options.resilience.zne.noise_factors = [1, 1.2, 1.4]
options.resilience.zne.extrapolator = ["linear"]
estimator = EstimatorV2(mode=backend, options=options)
estimator.options.environment.job_tags = ["TUT_OBP"]
job = estimator.run(pubs)
# Retrieve the results and the standard deviations
result_no_bp = job.result()[0].data.evs.item()
result_bp = job.result()[1].data.evs.item()
result_bp_trunc = job.result()[2].data.evs.item()
std_no_bp = job.result()[0].data.stds.item()
std_bp = job.result()[1].data.stds.item()
std_bp_trunc = job.result()[2].data.stds.item()
2-qubit depth without backpropagation: 24
2-qubit depth with backpropagation: 20
2-qubit depth with backpropagation and truncation: 18
print(f"Expectation value without backpropagation: {result_no_bp}")
print(f"Backpropagated expectation value: {result_bp}")
print(f"Backpropagated expectation value with truncation: {result_bp_trunc}")
Expectation value without backpropagation: 0.9543907942381811
Backpropagated expectation value: 0.9445337385406468
Backpropagated expectation value with truncation: 0.934050286970965
# Plot the results
methods = [
"No backpropagation",
"Backpropagation",
"Backpropagation w/ truncation",
]
values = [result_no_bp, result_bp, result_bp_trunc]
error_bars = [std_no_bp, std_bp, std_bp_trunc]
ax = plt.gca()
plt.bar(methods, values, color="#a56eff", width=0.4, edgecolor="#8a3ffc")
plt.errorbar(methods, values, yerr=error_bars, fmt="o", color="r", capsize=5)
plt.axhline(0.89)
ax.set_ylim([0.8, 0.98])
plt.text(0.25, 0.895, "Exact result")
ax.set_ylabel(r"$M_Z$", fontsize=12)
Text(0, 0.5, '$M_Z$')
다음 단계
이 내용이 흥미로우셨다면 다음 자료를 참고해 보세요.
- 시간 진화 회로를 위한 근사 양자 컴파일
- 트로터 오차를 줄이는 멀티 프로덕트 공식
pauli-prop: Rust 가속 Pauli 전파 패키지로, OBP, 고전 기댓값 추정, 노이즈 시뮬레이션을 다루는 튜토리얼이 포함되어 있습니다.