Skip to main content
IBM Quantum Platform

그린버거-혼-자일링거 상태의 음영 단층 촬영

예상 소요 시간: Heron r2 프로세서 기준 10초 미만 (참고: 이는 단지 예상치일 뿐입니다.) (실행 시간은 다를 수 있습니다.)


학습 성과

  • 그림자 단층 촬영이 양자 상태의 고전적 그림자를 어떻게 재구성하는지, 그리고 왜 전체 상태 단층 촬영보다 지수적으로 적은 측정 횟수만 필요한지
  • Qiskit을 사용하여 단일 큐비트 자화, 2점 상관함수 및 2큐비트 축소 밀도 행렬을 추정하기 위한 국소 파울리 섀도 토모그래피 구현 방법
  • 대규모 하드웨어 실험에서 시공간 코드를 활용한 저오버헤드 오류 검출을 위해 qiskit-paulice를 사용하는 방법

전제조건


배경

NN -큐비트 시스템의 완전한 양자 상태 단층 촬영을 수행하려면, 밀도 행렬의 모든 4N−14^N -1 독립 매개변수를 추정하기 위해 지수적으로 많은 측정이 필요합니다. 예를 들어, 50 큐비트로 구성된 시스템의 경우, 이 과정에는 약 103010^{30} 회의 시도가 필요하며, 이는 관측 가능한 우주에 존재하는 별의 수보다 10억 배 이상 많은 수치입니다. 섀도 토모그래피 [1] 는 양자 상태의 “고전적 섀도”를 구성하는 양자 정보 프로토콜로, 이를 통해 관측량 개수에 대해 로그 비례하는 횟수의 측정만으로 대상 관측량을 추정할 수 있으며, 이는 시스템 규모와 무관하다 NN. 따라서 에너지 추정, 얽힘 엔트로피 및 상관 함수 계산, 양자 기계 학습에서의 커널 추정 등 양자 컴퓨팅의 다양한 분야에서 활용되고 있다.

이 튜토리얼에서는 섀도우 단층 촬영법을 사용하여, 고도로 얽힌 그린버거-혼-자일링거(GHZ) 상태의 큐비트 ii 에 대한 단일 큐비트 자화 ⟨Zi⟩\langle Z_i \rangle, 2점 상관 함수 ⟨ZiZj⟩\langle Z_i Z_j\rangle 및 2-큐비트 축소 밀도 행렬(2-RDM)과 같은 국소 관측량을 추정하는 방법을 설명합니다. 이 튜토리얼에서는 먼저 상태 벡터 시뮬레이션을 활용하여 10-큐비트 GHZ 상태에 대한 소규모 그림자 단층 촬영 워크플로우를 설명합니다. 이어서 실제 양자 하드웨어를 활용한 100 큐비트 시연이 소개됩니다.

음영 단층 촬영 프로토콜

음영 단층 촬영 프로토콜은 다음과 같은 단계로 구성됩니다:

  1. 초기 양자 상태에, 유니터리 앙상블에서 무작위로 추출한 임의의 유니터리 변환 UU 을 적용한다.
  2. 계산 기저에서 결과를 측정하여 비트열 ∣b⟩\ket{b} 을 얻습니다.
  3. 고전적 스냅샷인 ρ^=M−1(U†∣b⟩⟨b∣U)\hat{\rho} = \mathcal{M}^{-1}(U^{\dagger}\ket{b}\bra{b}U) 를 저장합니다. 여기서 M−1\mathcal{M}^{-1} 는 측정 채널 M(ρ)=E[U†∣b⟩⟨b∣U]\mathcal{M}(\rho)=\mathbb{E}[U^{\dagger}\ket{b}\bra{b}U] 의 역행렬입니다.
  4. 이 과정을 TT 번 반복합니다. 여기서 TT 스냅샷 모음 {ρ^t}\{\hat{\rho}_t\} 은 고전적인 섀도우 S(ρ,T)S(\rho, T) 를 형성합니다.
  5. 연산자 OO 의 관측 가능한 oo 을 1T∑t=1TTr(Oρ^t)\frac{1}{T}\sum_{t=1}^{T}\text{Tr}(O\hat{\rho}_t) 로 추정한다.

그림자 단층 촬영의 변형 기법

프로토콜의 1단계에서 단일 앙상블, 즉 기저를 선택하는 것은 주어진 섀도우 단층 촬영의 표본 복잡도, 추정치의 분산 및 정보 완전성에 영향을 미친다. 문헌에서는 다양한 앙상블 기법들이 검토되어 왔으며, 각 기법마다 복잡성과 관측 가능한 추정 효율성 사이에서 상충 관계가 존재한다. 가장 일반적으로 사용되는 변형들의 특성은 다음 표에 요약되어 있으나, 이 목록이 모든 것을 망라하는 것은 아닙니다.

변형
앙상블
샘플 복잡도
회로 깊이
적합한 관측량
국소 파울리 [1]단일 큐비트 파울리 {X,Y,Z}\{X, Y, Z\}O(3klogM/ε2)\mathcal{O}(3^k\text{log}M/\varepsilon^2)1지역/소수체
전역 클리포드 [1]NN -큐비트 클리포드 연산O(Tr(O2)logM/ε2)\mathcal{O}(\text{Tr}(O^2)\text{log}M/\varepsilon^2)O(NlogN)\mathcal{O}(N\text{log}N)저순위, 충실도
페르미온 가우시안 [2], [3]매치게이트 회로O((μk)1ε2)\mathcal{O}(\binom{\mu}{k}\frac{1}{\varepsilon^2})O(N2)\mathcal{O}(N^2)페르미온성
얕은 그림자 [4], [5]얕은 심도의 회로O(k2klogM/ε2)\mathcal{O}(k2^k\text{log}M/\varepsilon^2)O(logN)\mathcal{O}(\text{log}N)비국소적 (하드웨어 친화적)

표에 사용된 용어의 정의는 다음과 같습니다:

  • "샘플 복잡도"란 특정 섀도우 단층 촬영 기법에 필요한 측정 횟수를 의미합니다.
  • "회로 깊이"는 양자 하드웨어에서 해당 측정을 구현하는 데 드는 비용이 얼마나 큰지를 나타냅니다.
  • NN 는 큐비트의 개수입니다.
  • kk 이는 관측 가능한 양의 위치입니다(예를 들어, ⟨Zi⟩\langle Z_i \rangle 의 경우 k=1k=1 이며, ⟨ZiZj⟩\langle Z_i Z_j\rangle 의 경우 k=2k=2 입니다).
  • MM 는 동시에 추정해야 할 관측 변수의 개수입니다.
  • ε\varepsilon 는 가산 오차 허용 오차입니다.
  • μ\mu 페르미온의 개수입니다.

지역 파울리 앙상블이 하드웨어 구현에 가장 적합하지만, 3k3^k 의 샘플 복잡도 손실이 발생하기 때문에 비국소적 성질 추정에는 적합하지 않습니다. 보다 일반적으로, 국소 그림자 샘플 복잡도 상한은 4k4^k 의 비율로 증가하며, 텐서곱 관측량의 특수한 경우에는 3k3^k 로 개선된다.

반대로, 전역 클리포드 앙상블은 비국소 관측량을 추정하는 데 사용할 수 있지만, 회로 깊이가 바람직하지 않다. 페르미온 가우시안 섀도우는 다체 물리학, 화학 및 재료 과학 분야의 시뮬레이션에 유용하지만, 매치게이트 회로가 필요하며, 이는 범용 게이트 기반 장치에서 상당한 오버헤드를 초래할 수 있다.

‘얕은 그림자(Shallow shadows)’는 국소 파울리(Pauli)와 전역 클리포드(Clifford) 사이의 회로 깊이를 갖기 때문에 실용적인 구현에 있어 매우 유망한 변형으로 꼽히지만, 주어진 관측량에 대한 최적의 회로 깊이와 관련해서는 여전히 해결되지 않은 문제들이 남아 있다.

그림자 단층 촬영 연구의 영역이 지속적으로 확대됨에 따라, 해결해야 할 가장 중요한 과제들로는 대상 관측량 집합에 대한 측정 앙상블의 최적 선택, 표본 복잡도 상한의 개선, 그리고 시간 변화 관측량으로의 프로토콜 확장 등이 있다. 그럼에도 불구하고, 섀도우 토모그래피는 최소한의 측정 횟수로 양자 상태에서 최대한의 고전적 정보를 추출하는 주요 작업 흐름 중 하나로 여전히 자리 잡고 있으며, 이는 완전한 토모그래피와 고전적 시뮬레이션 모두 불가능한 대규모 양자 상태의 특성을 규명하는 데 이상적인 방법이다.

국소 파울리 앙상블

이 튜토리얼에서는 무작위 파울리 기저, 즉 단일 큐비트 클리포드 게이트를 사용하여 섀도우를 수집하고 GHZ 상태의 특성을 추정합니다. 파울리 측정은 국소적 관측량(스핀 밀도, 국소 상관 함수, 위치 확률 밀도 등)을 추정하는 데는 매우 적합하지만, 전역적 관측량(총 에너지, 운동량, 전하 등)을 추정하는 데는 적합하지 않다는 점에 유의해야 한다.

여기서는 각 큐비트에 대해 임의의 파울리 기저 {X,Y,Z}\{X, Y, Z\} 를 독립적으로 선택하고, 이에 상응하는 단일 큐비트 회전을 적용한 뒤 측정을 수행합니다:

U=U0⊗U1⊗⋯⊗Un−1,Uq∈{I,H,S†H}(1) \begin{equation} U = U_0 \otimes U_1 \otimes \cdots \otimes U_{n-1}, U_q \in \{I, H, S^{\dagger}H\} \end{equation} \tag{1}

이 앙상블의 경우, 측정 채널의 역함수는 다음과 같습니다:

ρ^q=3Uq†∣bq⟩⟨bq∣Uq−I(2) \begin{equation} \hat{\rho}_q=3U_q^{\dagger}\ket{b_q}\bra{b_q}U_q - I \end{equation} \tag{2}

33 의 인자는 세 개의 파울리 기저에 대한 평균을 구함으로써 얻어지며, 이는 매개변수 13\frac{1}{3} 를 갖는 탈분극 채널을 생성한다. 그러면 전체 추정량은 단일 큐비트 추정량의 곱으로 인수분해된다. 표에서 볼 수 있듯이, MM 관측량에 대해, 각 국소성 kk 에서 모든 관측량을 높은 확률로 가산 오차 ε\varepsilon 이내로 추정하는 데 필요한 파울리 섀도우의 수는 O(3klog(M)ε2)\mathcal{O}(\frac{3^k\text{log}(M)}{\varepsilon^2}) 이다. 관측량이 국소성을 유지하는 한 시스템 크기는 측정 비용에 영향을 미치지 않으므로, 로그 수준의 비용만으로 지수적으로 많은 관측량을 동시에 추정할 수 있다.

평균값의 중앙값

TT 스냅샷에 대한 단순 평균은 관측 가능한 변수의 단일 관측치 추정값의 분산을 1T\frac{1}{T} 로 줄여주지만, 이상치에 민감합니다. 이 문제를 해결하기 위해 평균의 중앙값을 사용할 수 있습니다. 이 프로세스에는 다음이 포함됩니다.

  • TT 스냅샷을 KK 크기가 동일한 그룹으로 분할하기
  • 각 그룹 내 평균값 계산
  • 그룹별 평균값에 대한 중앙값 반환

이 튜토리얼에서는 GHZ 상태의 특성을 추정하기 위해 국소 파울리 섀도우 단층 촬영을 수행하며, 먼저 10 큐비트 시뮬레이션 예제를 활용한 뒤 100 큐비트 하드웨어 실험 결과를 제시합니다.


요구사항

이 튜토리얼을 시작하기 전에 다음 항목이 설치되어 있는지 확인하십시오:

  • Qiskit SDK v2.0 또는 그 이후 버전이며, 시각화 기능을 지원합니다
  • IBM Quantum Compute v0.28 또는 그 이후 (pip install qiskit-ibm-runtime)
  • Qiskit Paulice (pip install qiskit-paulice)

설정

import numpy as np
import matplotlib.pyplot as plt
from collections import deque, defaultdict
from qiskit import QuantumCircuit, ClassicalRegister
from qiskit.quantum_info import (
    Statevector,
    partial_trace,
    Operator,
    Pauli,
    SparsePauliOp,
)
from qiskit.transpiler import generate_preset_pass_manager
from qiskit_ibm_runtime import QiskitRuntimeService
from qiskit_ibm_runtime import SamplerV2 as Sampler
from qiskit_paulice.layout import get_low_overhead_ancillas
seed = 42  # for reproducibility
rng = np.random.default_rng(seed)

이 튜토리얼 후반부에 소개될 대규모 하드웨어 예제에서는 다음과 같은 보조 함수들이 사용됩니다:

  • bad_cz, bad_readout, bad_coherence — 백엔드 보정 데이터를 조회하여 오류 또는 코히어런스 임계값을 초과하는 게이트, 큐비트 및 에지를 식별하고, 이를 회로 레이아웃에서 제외할 수 있도록 한다.
  • parallel_ghz — 결합 맵에 대해 폭우선 탐색을 수행하여 주어진 크기의 GHZ 회로를 구축하고, 회로 깊이를 최소화하기 위해 CNOT 층을 병렬로 배치합니다.
  • weighted_coverage, active_wires, z_trace_backward — [6] 에 제시된 역전파 기법을 사용하여, 주어진 시공간 패리티 검사 집합이 GHZ 회로의 오류 표면 중 어느 정도를 보호하는지 계산하라.
  • add_measurements — GHZ-큐비트와 체크-큐비트의 측정 결과를 별도의 고전적 레지스터에 추가하여, 이를 독립적으로 사후 선별할 수 있도록 한다.
  • build_shadow_circuits — 측정 전에 적절한 단일 큐비트 회전 연산을 추가함으로써, 무작위 파울리 기저마다 하나의 회로를 생성합니다.
  • parse_hw_results_w_checks — 샘플러 출력을 샷당 하나씩, (ghz_bits, check_bits, bases) 3개씩으로 분할합니다.
  • adapt_to_flat_bits, estimate_zz_nn_hw — GHZ 레지스터 비트를 전체 디바이스 인덱스 공간으로 다시 매핑하고, 평균의 중앙값 ⟨ZiZi+1⟩\langle Z_i Z_{i+1}\rangle 을 이용한 추정치를 계산한다.
def bad_cz(target, threshold: float = 0.01) -> list:
    "Return edges whose CZ error exceeds threshold."
    edges = []
    for edge in backend.target.build_coupling_map().get_edges():
        if (edge[1], edge[0]) not in edges:
            edges.append(edge)
    cz_errors = {edge: target["cz"][edge].error for edge in edges}
    worst = sorted(cz_errors.items(), key=lambda x: x[1], reverse=True)
    return [list(edge) for edge, error in worst if error > threshold]


def bad_readout(target, threshold: float = 0.01) -> list:
    "Return nodes whose measurement error exceeds threshold."
    meas_errors = {
        node: target["measure"][(node,)].error
        for node in range(backend.num_qubits)
    }
    worst = sorted(meas_errors.items(), key=lambda x: x[1], reverse=True)
    return [node for node, error in worst if error > threshold]


def bad_coherence(target, threshold: float = 60) -> list:
    "Return nodes whose T2 value is below threshold."
    t2s = {
        node: (target.qubit_properties[node].t2 or 0) * 1e6
        for node in range(backend.num_qubits)
    }
    return [
        node
        for node, val in sorted(t2s.items(), key=lambda x: x[1])
        if val < threshold
    ]


def parallel_ghz(
    root: int, num_qubits: int, backend, bad_edges: list, skip: list
) -> tuple:
    "Build a GHZ circuit of size num_qubits using BFS from root, one neighbor per layer."
    edges = [
        e
        for e in [list(e) for e in backend.coupling_map.get_edges()]
        if e not in bad_edges
        and [e[1], e[0]] not in bad_edges
        and e[0] not in skip
        and e[1] not in skip
    ]
    adj = defaultdict(list)
    for u, v in edges:
        adj[u].append(v)
        adj[v].append(u)

    qc = QuantumCircuit(backend.num_qubits)
    visited = [root]
    queue = deque([root])
    explored = defaultdict(set)
    layers = []
    qc.h(root)

    while queue and len(visited) < num_qubits:
        layer = []
        for node in list(queue):
            queue.popleft()
            unvisited = [
                nb
                for nb in adj[node]
                if nb not in visited and nb not in explored[node]
            ]
            if unvisited:
                nb = unvisited[0]
                visited.append(nb)
                queue.append(node)
                queue.append(nb)
                explored[node].add(nb)
                layer.append((node, nb))
                if len(visited) == num_qubits:
                    break
        if layer:
            layers.append(layer)
        else:
            break

    for layer in layers:
        for q1, q2 in layer:
            qc.cx(q1, q2)
        qc.barrier()

    return qc, visited, layers


def weighted_coverage(
    layers: list, parities: list, w_idle: float = 0.2, w_gate: float = 0.8
) -> float:
    "Compute weighted fraction of wires covered by at least one parity check."
    wires = active_wires(layers)
    covered_by_any = {n_layer: set() for n_layer in range(len(layers))}
    for parity in parities:
        for n_layer, qs in z_trace_backward(layers, parity).items():
            covered_by_any[n_layer] |= qs
    covered_weight = total_weight = 0
    for n_layer in range(len(layers)):
        idle = wires[n_layer]["idle"]
        gate = wires[n_layer]["gate"]
        total_weight += w_idle * len(idle) + w_gate * len(gate)
        covered_weight += w_idle * len(
            covered_by_any[n_layer] & idle
        ) + w_gate * len(covered_by_any[n_layer] & gate)
    return covered_weight / total_weight if total_weight > 0 else 0


def active_wires(layers: list) -> dict:
    """
    Returns per-layer dict with two sets:
    - 'idle': activated wires that are idle in this layer
    - 'gate': activated wires that are control/target of a CNOT at this layer
    """
    first_activation = {}
    for n_layer, layer in enumerate(layers):
        for c, t in layer:
            first_activation.setdefault(c, n_layer)
            first_activation.setdefault(t, n_layer)
    result = {}
    for n_layer in range(len(layers)):
        active = {
            q
            for q, n_layer0 in first_activation.items()
            if n_layer >= n_layer0
        }
        gate = {q for c, t in layers[n_layer] for q in (c, t)}
        result[n_layer] = {"idle": active - gate, "gate": gate}
    return result


def z_trace_backward(layers: list, initial_Zs: list) -> dict:
    """
    Backward propagate Zs with parity cancellation.
    Returns {layer: set of qubits with odd parity Z at that layer}.
    """
    wires = active_wires(layers)
    support = set(initial_Zs)
    trace = {}
    for n_layer in range(len(layers) - 1, -1, -1):
        active = wires[n_layer]["idle"] | wires[n_layer]["gate"]
        trace[n_layer] = support & active
        new_support = set()
        for q in support:
            hit = False
            for c, t in layers[n_layer]:
                if q == t:
                    new_support ^= {t, c}
                    hit = True
                    break
                elif q == c:
                    new_support ^= {c}
                    hit = True
                    break
            if not hit:
                new_support ^= {q}
        support = new_support
    return trace


def add_measurements(
    qc: QuantumCircuit, ghz_qubits: list, checks: list
) -> QuantumCircuit:
    "Add GHZ and check qubit measurements into separate classical registers."
    c1 = ClassicalRegister(len(ghz_qubits), "c1")
    qc.add_register(c1)
    for q, c in zip(ghz_qubits, c1):
        qc.measure(q, c)
    c2 = ClassicalRegister(len(checks), "c2")
    qc.add_register(c2)
    for q, c in zip(checks, c2):
        qc.measure(q, c)
    return qc


def build_shadow_circuits(
    base_circuit: QuantumCircuit,
    n_measure: int,
    ghz_qubits: list,
    checks: list,
) -> tuple:
    "Build n_measure circuits by appending random Pauli basis rotations to each GHZ qubit."
    n = base_circuit.num_qubits
    all_bases = rng.integers(0, 3, size=(n_measure, n))
    circuits = []
    for m in range(n_measure):
        qc = base_circuit.copy()
        qc.barrier()
        for q in ghz_qubits:
            b = all_bases[m, q]
            if b == 1:
                qc.h(q)
            elif b == 2:
                qc.sdg(q)
                qc.h(q)
        add_measurements(qc, ghz_qubits, checks)
        circuits.append(qc)
    return circuits, all_bases


def parse_hw_results_w_checks(
    result, bases: np.ndarray, ghz_qubits: list, checks: list
) -> list:
    "Parse hardware bitstrings into (ghz_bits, check_bits, bases) triples."
    snapshots = []
    n_ghz = len(ghz_qubits)
    n_checks = len(checks)
    for t, pub_result in enumerate(result):
        ghz_strings = (
            pub_result.data.c1.get_bitstrings()
        )  # one entry per shot
        chk_strings = pub_result.data.c2.get_bitstrings()
        for ghz_bitstring, check_bitstring in zip(ghz_strings, chk_strings):
            ghz_bits = [
                int(ghz_bitstring[n_ghz - 1 - i]) for i in range(n_ghz)
            ]
            check_bits = [
                int(check_bitstring[n_checks - 1 - i])
                for i in range(n_checks)
            ]
            snapshots.append((ghz_bits, check_bits, bases[t]))
    return snapshots


def adapt_to_flat_bits(
    ghz_bits: list, ghz_qubits: list, n_total: int
) -> list:
    "Map GHZ-register bits back to a full n_total-length bit vector."
    bits = [0] * n_total
    for i, q in enumerate(ghz_qubits):
        bits[q] = ghz_bits[i]
    return bits


def estimate_zz_nn_hw(
    snapshots: list, ghz_qubits: list, n_total: int, n_groups: int
) -> np.ndarray:
    "Estimate <ZiZi+1> for each neighboring GHZ qubit pair from flat-bit snapshots."
    pairs = list(zip(ghz_qubits[:-1], ghz_qubits[1:]))
    all_est = np.column_stack(
        [
            snapshot_estimate(
                ["Z" if q in pair else "I" for q in range(n_total)], snapshots
            )
            for pair in pairs
        ]
    )
    return mom(all_est, n_groups).real

소규모 시뮬레이터 예시

먼저, 상태 벡터 시뮬레이션을 사용하여 10-큐비트 GHZ 상태를 준비하고, 국소 파울리 섀도우를 활용해 그 특성을 추정합니다. GHZ 상태는 3개 이상의 큐비트로 구성된 최대 얽힘을 가진 양자 상태로, 다체 얽힘의 기준이 됩니다. 이는 양자 통신 및 암호화 프로토콜의 핵심 구성 요소입니다. 또한, GHZ 회로는 그 해석적 특성이 완전히 알려져 있어, 이 튜토리얼에서 검증하기에 이상적입니다.

1단계: 고전적 입력을 양자 문제에 매핑하기

이상적인 10-큐비트 GHZ 상태를 준비하십시오.

def ghz_statevector(num_qubits):
    qc = QuantumCircuit(num_qubits)
    qc.h(0)
    for q in range(num_qubits - 1):
        qc.cx(q, q + 1)
    return Statevector(qc)


num_qubits = 10  # number of qubits
psi = ghz_statevector(num_qubits)
psi.seed(seed)
print(
    f"{num_qubits}-qubit GHZ state ready (norm = {np.linalg.norm(psi):.6f})"
)

Output:

10-qubit GHZ state ready (norm = 1.000000)

2단계: 양자 하드웨어 실행에 최적화하기

다음으로, GHZ 상태를 무작위 파울리 기저로 회전시켜 섀도 스냅샷을 수집하는 함수를 정의합니다. 이는 대규모 예제에서 Sampler 프리미티브가 하드웨어에서 수행하는 동작을 반영합니다.

def basis_circuit(b):
    """0 -> Z basis (I), 1 -> X basis (H), 2 -> Y basis (Sdg;H)."""
    qc = QuantumCircuit(1)
    if b == 1:
        qc.h(0)
    elif b == 2:
        qc.sdg(0)
        qc.h(0)
    # b == 0: Z basis, no rotation
    return qc


_basis = {
    b: basis_circuit(b) for b in (0, 1, 2)
}  # built once, shared with the estimator's _U


def collect_shadow_snapshots(psi, n_snapshots, num_qubits):
    snapshots = []
    for _ in range(n_snapshots):
        bases = rng.integers(0, 3, size=num_qubits)
        rot = QuantumCircuit(num_qubits)
        for q, b in enumerate(bases):
            rot.compose(_basis[b], qubits=[q], inplace=True)
        evolved = psi.evolve(rot)
        evolved.seed(int(rng.integers(0, 2**31)))
        bitstr = next(
            iter(evolved.sample_counts(shots=1))
        )  # Born rule internal
        bits = [int(x) for x in bitstr[::-1]]  # little-endian -> qubit
        snapshots.append((bits, bases))
    return snapshots

3단계: Qiskit primitives를 사용하여 실행하기

이 시뮬레이션에서는 상태 벡터를 직접 사용하십시오.

n_snapshots = 5000  # number of shadow snapshots
n_groups = 20  # groups/batches for median-of-means

snapshots = collect_shadow_snapshots(psi, n_snapshots, num_qubits)
print(f"Collected {n_snapshots} snapshots.")

Output:

Collected 5000 snapshots.

4단계: 후처리를 수행하고 원하는 기존 형식으로 결과를 반환합니다

섀도 스냅샷을 수집하는 데 사용되는 무작위 측정 기준에 대해 섀도 추정량과 평균의 중앙값(MOM) 추정량을 정의하십시오. 이 함수들을 사용하여 수집된 측정값으로부터 단일 큐비트 자화, 2-큐비트 상관함수, 2-큐비트 RDM을 포함한 GHZ 상태의 특성을 추정하십시오. 그런 다음 추정값을 정확한 값과 비교해 보십시오.

_U = {
    b: Operator(basis_circuit(b)) for b in (0, 1, 2)
}  # rotation Uᵢ per basis
_P = {p: Operator(Pauli(p)) for p in "IXYZ"}  # single-qubit Paulis


def shadow_estimator(P, b, bit):  # P is a label char, e.g. "X"
    UPU = _U[b] @ _P[P] @ _U[b].adjoint()  # Uᵢ P Uᵢ†
    return (3 * UPU.data[bit, bit] - np.trace(_P[P].data)).real


def mom(estimates: np.ndarray, n_groups: int) -> np.ndarray:
    "Median-of-means outlier suppression."
    T = len(estimates)
    spg = T // n_groups  # shots per group
    trimmed = estimates[
        : spg * n_groups
    ]  # drop any leftover shots that don't cleanly fit into a group
    groups = trimmed.reshape(n_groups, spg, *estimates.shape[1:])
    means = groups.mean(axis=1)  # take the mean of each group
    return np.median(means.real, axis=0) + 1j * np.median(
        means.imag, axis=0
    )  # take the median of means for real and imaginary parts separately


def snapshot_estimate(
    pauli_string, snapshots
):  # full-width label, len == num_qubits
    out = np.empty(len(snapshots), dtype=complex)
    for t, (bits, bases) in enumerate(snapshots):
        e = 1.0 + 0j
        for q, P in enumerate(pauli_string):
            e *= shadow_estimator(P, bases[q], bits[q])
        out[t] = e
    return out


Z_est = np.column_stack(
    [
        snapshot_estimate(
            ["Z" if q == i else "I" for q in range(num_qubits)], snapshots
        )
        for i in range(num_qubits)
    ]
)
Z_vals = mom(Z_est, n_groups).real

ZZ_est = np.column_stack(
    [
        snapshot_estimate(
            ["Z" if q in (i, i + 1) else "I" for q in range(num_qubits)],
            snapshots,
        )
        for i in range(num_qubits - 1)
    ]
)
ZZ_nn = mom(ZZ_est, n_groups).real

ZZ_full = np.zeros((n_snapshots, num_qubits, num_qubits), dtype=complex)
for i in range(num_qubits):
    ZZ_full[:, i, i] = 1.0  # diagonal is always 1
    for j in range(i + 1, num_qubits):
        col = snapshot_estimate(
            ["Z" if q in (i, j) else "I" for q in range(num_qubits)],
            snapshots,
        )
        ZZ_full[:, i, j] = col
        ZZ_full[:, j, i] = col  # symmetric
ZZ_mat = mom(ZZ_full, n_groups).real

# 2-qubit reduced density matrix (RDM) for pair (qi, qj)
# the elementwise complex median (real/imag split) for the RDM coefficients generalizes the scalar median in Ref.[1]
qi, qj = 0, num_qubits - 1
terms = []
for a in "IXYZ":
    for b in "IXYZ":
        label = "".join({qi: a, qj: b}.get(q, "I") for q in range(num_qubits))
        c = mom(snapshot_estimate(label, snapshots).reshape(-1, 1), n_groups)[
            0
        ]
        terms.append((b + a, c / 4))
rho_shadow = SparsePauliOp.from_list(terms).to_matrix()

traced_out = [q for q in range(num_qubits) if q not in (qi, qj)]
rho_exact = partial_trace(
    psi, traced_out
).data  # diag(0.5, 0, 0, 0.5) for GHZ

print(f"{'Observable':<22} {'Estimated':>12} {'Ideal':>8} {'Max error':>12}")
print("-" * 57)
print(
    f"{'<Zi> (all qubits)':<22} {'-':>12} {'0':>8} {np.max(np.abs(Z_vals)):>12.4f}"
)
print(
    f"{'<ZiZi+1> mean':<22} {np.mean(ZZ_nn):>12.4f} {'1':>8} {np.max(np.abs(ZZ_nn - 1)):>12.4f}"
)
print(
    f"{'2-RDM Frobenius err':<22} {'-':>12} {'0':>8} {np.linalg.norm(rho_shadow - rho_exact):>12.4f}"
)

Output:

Observable                Estimated    Ideal    Max error
---------------------------------------------------------
<Zi> (all qubits)                 -        0       0.0540
<ZiZi+1> mean                1.0040        1       0.1520
2-RDM Frobenius err               -        0       0.1080

결과

시각화

첫 번째 그래프는 각 큐비트에 대한 추정된 단일 큐비트 자화 값을 보여줍니다. 두 번째 그래프는 모든 큐비트 쌍에 대해 추정된 2점 상관함수 값의 분포를 보여줍니다. 마지막 그래프는 2-큐비트 축소 밀도 행렬의 각 성분의 크기를 보여줍니다. 주어진 시스템 규모에 비해 상당히 많은 수의 스냅샷이 사용되었기 때문에, 결과는 정확한 값에 가깝습니다.

fig, axes = plt.subplots(
    1, 3, figsize=(14, 4), gridspec_kw={"width_ratios": [1, 1, 1]}
)

z_exact = psi.expectation_value(
    SparsePauliOp.from_sparse_list([("Z", [0], 1.0)], num_qubits)
).real  # 0.0
zz_exact = psi.expectation_value(
    SparsePauliOp.from_sparse_list([("ZZ", [0, 1], 1.0)], num_qubits)
).real  # 1.0

# <Zi>
axes[0].bar(range(num_qubits), Z_vals, color="#4a90d9", alpha=0.85)
axes[0].axhline(0, color="black", lw=0.8)
axes[0].set_ylim(-0.5, 0.5)
axes[0].set_title(r"$\langle Z_i \rangle$ (ideal = %0.2f)" % z_exact)
axes[0].set_xlabel("Qubit index")
axes[0].grid(axis="y", alpha=0.3)

# <ZiZj>
im = axes[1].imshow(ZZ_mat, vmin=-1, vmax=1, cmap="magma")
plt.colorbar(im, ax=axes[1])
axes[1].set_title(r"$\langle Z_i Z_j \rangle$ (ideal = %0.2f)" % zz_exact)
axes[1].set_xlabel("Qubit j")
axes[1].set_ylabel("Qubit i")

# 2-RDM magnitude
im2 = axes[2].imshow(np.abs(rho_shadow), vmin=0, vmax=0.5, cmap="Blues")
plt.colorbar(im2, ax=axes[2])
axes[2].set_title(r"$|\rho_2|$ for qubits (0, N-1)")
axes[2].set_xticks(range(4))
axes[2].set_yticks(range(4))
axes[2].set_xticklabels(["00", "01", "10", "11"])
axes[2].set_yticklabels(["00", "01", "10", "11"])

Output:

[Text(0, 0, '00'), Text(0, 1, '01'), Text(0, 2, '10'), Text(0, 3, '11')]
Output of the previous code cell

샷 스케일링 평가

이 튜토리얼에서는 관측 가능한 변수를 추정하기 위해 5,000개의 스냅샷을 사용합니다. 섀도우 단층 촬영의 샷 스케일링은 추정된 함수 중 하나의 평균 오차를 다양한 샷 예산에 대해 그래프로 나타냄으로써 평가한다. 2-큐비트 상관함수의 오차는 평균 추정량의 표준 통계적 수렴에서 예상되는 바와 같이 1/T1/\sqrt{T} 의 비율로 감소한다. 섀도우 단층 촬영법의 중요성은, 이 튜토리얼에서 살펴본 것과 같은 국소 관측량에 대해 1/T1/\sqrt{T} 앞의 상수 계수가 큐비트 수에 따라 증가하지 않는다는 점에 있습니다.

shot_counts = [100, 250, 500, 1000, 2000, 5000, 10000]
errors = []

for N_test in shot_counts:
    snaps = collect_shadow_snapshots(psi, N_test, num_qubits)
    zz_vals = []
    for i in range(num_qubits - 1):
        ests = []
        for bits, bases in snaps:
            e = 1.0 + 0j
            for q in range(num_qubits):
                op = "Z" if q in (i, i + 1) else "I"
                e *= shadow_estimator(op, bases[q], bits[q])
            ests.append(e.real)
        zz_vals.append(np.mean(ests))
    errors.append(float(np.mean(np.abs(np.array(zz_vals) - 1.0))))
    print(f"T={N_test:5d} error = {errors[-1]:.4f}")

fig, ax = plt.subplots(figsize=(6, 4))
T_arr = np.array(shot_counts, dtype=float)
ax.loglog(T_arr, errors, "o-", color="#4a90d9", lw=2, label="Shadow estimate")
ax.loglog(
    T_arr,
    errors[0] * np.sqrt(shot_counts[0] / T_arr),
    "k--",
    alpha=0.5,
    label=r"$1/\sqrt{T}$ reference",
)
ax.set_xlabel("Shots T")
ax.set_ylabel(r"Mean $|\langle Z_i Z_{i+1} \rangle - 1|$")
ax.set_title(f"Convergence for {num_qubits}-qubit GHZ")
ax.legend()
plt.tight_layout()
plt.show()

Output:

T=  100 error = 0.1856
T=  250 error = 0.1613
T=  500 error = 0.1027
T= 1000 error = 0.0827
T= 2000 error = 0.0513
T= 5000 error = 0.0311
T=10000 error = 0.0162
Output of the previous code cell

대규모 하드웨어 예시

앞서 수행한 시뮬레이션은, 잡음이 없는 상태 벡터에 대해 국소 파울리 섀도우 단층 촬영법이 올바른 관측량을 복원함을 확인해 준다. 실제 하드웨어에서 100개의 큐비트를 사용하여 동일한 프로토콜을 실행하면 시뮬레이션에서는 나타나지 않는 두 가지 문제가 발생합니다. 첫째, 깊은 GHZ 회로 전반에 걸쳐 게이트 오류와 판독 오류가 누적되며, 둘째, 최적의 큐비트 배치를 찾기 위해서는 수동으로 배치를 선택하는 대신 장치의 결합 그래프를 탐색해야 합니다.

첫 번째 과제를 해결하기 위해서는 시공간 코드 [6] 를 활용한 저오버헤드 오류 검출 방식을 사용하십시오. GHZ 트리에 인접한 소수의 보조 큐비트가 패리티 검사용으로 사용됩니다. 각 보조 양자 입자는 측정 전에 GHZ 얽힘을 형성한 두 이웃 입자와 얽혀 있으므로, 이웃 입자 중 하나에서 단 하나의 비트 반전 오류가 발생하면 보조 양자 입자의 결과가 에서 로 0 바뀝니다 1. ‘ancilla’가 ‘read’로 판정된 스냅샷은 모두 제외함으로써 1 , 더 정제된 부분 집합을 남기고, 이를 바탕으로 ⟨ZiZi+1⟩\langle Z_i Z_{i+1}\rangle 를 추정합니다.

두 번째 과제는 후보 GHZ 트리 레이아웃을 검색하여, 회로 깊이의 허용 가능한 증가 범위 내에서 패리티 체크 커버리지, 즉 적어도 하나의 체크에 의해 모니터링되는 회로 배선의 비율을 최대화하는 레이아웃을 선정함으로써 해결됩니다.

1~4단계 (단일 코드 블록 내에서 수행)

이제 실제 양자 하드웨어에서 100 큐비트 GHZ 상태에 동일한 4단계 워크플로우가 적용되며, 시뮬레이션에서는 나타나지 않는 두 가지 하드웨어 특유의 특징이 추가됩니다:

  • 1단계에서는 백엔드 노이즈(게이트 오류, 판독 오류, 및 T2 시간)를 특성 분석하여, 신뢰할 수 없는 큐비트와 에지를 회로 레이아웃에서 제외합니다.
  • 2단계에서는 허용 가능한 회로 깊이 내에서 패리티 체크 커버리지를 극대화하는 GHZ 트리 배치를 찾기 위해 가능한 모든 GHZ 트리 배치를 탐색하며, 이는 시뮬레이션에 사용된 단순한 체인을 대체합니다.
  • 3단계에서는 섀도 회로(무작위 파울리 기저당 하나씩)를 구축하고, 이를 하드웨어 전용 게이트로 변환한 뒤, ( shots=8 각 회로는 8개의 독립적인 비트열을 생성함) 샘플러 프리미티브에 제출합니다.
  • 4단계에서는 체크 큐비트의 판독값이 모두 0인(오류가 감지되지 않은) 스냅샷을 사후 선별한 다음, 해당 사후 선별을 적용한 경우와 적용하지 않은 경우의 ⟨ZiZi+1⟩\langle Z_i Z_{i+1}\rangle 를 각각 추정하여 오류 감지로 인한 성능 향상을 입증합니다.
# ── Step 1: Map classical inputs to a quantum problem ─────────────────────────
service = QiskitRuntimeService()
backend = service.least_busy(
    operational=True, simulator=False, min_num_qubits=133
)
coupling_map = backend.target.build_coupling_map()
n_total = backend.num_qubits

thresh_cz, thresh_meas, thresh_t2 = 0.025, 0.15, 10
bad_edges = bad_cz(backend.target, threshold=thresh_cz)
bad_nodes_readout = bad_readout(backend.target, threshold=thresh_meas)
dead_qubits = bad_readout(backend.target, threshold=0.4)
bad_nodes = list(
    set(bad_nodes_readout)
    | set(bad_coherence(backend.target, threshold=thresh_t2))
)

# ── Step 2: Optimize for quantum hardware execution ───────────────────────────
# Search over starting qubits and random node-skip sets to find the GHZ layout
# with the best parity-check coverage within an acceptable circuit depth.
ghz_size = 100
max_skips, shuffles, max_depth_increase = 10, 200, 10
w_idle, w_gate = 0.2, 0.8

# Find the root that yields the shallowest GHZ — exclude bad_nodes from the start
best_root = None
base_depth = float("inf")
for root in range(backend.num_qubits):
    if root in bad_nodes:
        continue
    qc, ghz_qubits, _ = parallel_ghz(
        root, ghz_size, backend, bad_edges, bad_nodes
    )
    if len(ghz_qubits) != ghz_size:
        continue
    depth = qc.depth(lambda x: x.operation.num_qubits == 2)
    if depth < base_depth:
        best_root, base_depth = root, depth
if best_root is None:
    raise Exception(
        "No qualifying GHZ root layout found. Relax error thresholds."
    )

qc, ghz_qubits, layers = parallel_ghz(
    best_root, ghz_size, backend, bad_edges, bad_nodes
)

degree_two_nodes = [
    i
    for i in ghz_qubits
    if all(n in ghz_qubits for n in coupling_map.neighbors(i))
    and len(coupling_map.neighbors(i)) >= 2
]

best_covered_fraction = -1
best_qc = best_checks = best_parities = best_layers = best_ghz_qubits = None

for num_skips in range(max_skips):
    for _ in range(shuffles):
        skip_trial = list(
            rng.choice(degree_two_nodes, num_skips, replace=False)
        )
        qc, ghz_qubits, layers = parallel_ghz(
            best_root, ghz_size, backend, bad_edges, skip_trial + bad_nodes
        )
        if len(ghz_qubits) != ghz_size:
            continue
        depth = qc.depth(lambda x: x.operation.num_qubits == 2)
        checks, parities = [], []
        for ancilla, neighbors in get_low_overhead_ancillas(
            coupling_map, ghz_qubits
        ).items():
            if ancilla in dead_qubits:
                continue
            good_neighbors = [
                n
                for n in neighbors
                if [ancilla, n] not in bad_edges
                and [n, ancilla] not in bad_edges
            ]
            if len(good_neighbors) < 2:
                continue
            checks.append(ancilla)
            parities.append((good_neighbors[0], good_neighbors[1]))
            qc.cx(good_neighbors[0], ancilla)
            qc.cx(good_neighbors[1], ancilla)
        covered_fraction = weighted_coverage(
            layers=layers, parities=parities, w_idle=w_idle, w_gate=w_gate
        )
        if (
            covered_fraction > best_covered_fraction
            and depth <= base_depth + max_depth_increase
        ):
            best_covered_fraction = covered_fraction
            (
                best_qc,
                best_ghz_qubits,
                best_checks,
                best_parities,
                best_layers,
            ) = qc, ghz_qubits, checks, parities, layers

qc, ghz_qubits, checks, parities, layers = (
    best_qc,
    best_ghz_qubits,
    best_checks,
    best_parities,
    best_layers,
)
if len(ghz_qubits) != ghz_size:
    raise Exception("No GHZ found. Relax error thresholds.")

print(f"GHZ qubits: {ghz_qubits} ({len(ghz_qubits)})")
print(f"Check qubits: {checks} ({len(checks)})")
print(
    "Covered fraction (no idle): ",
    weighted_coverage(
        layers=layers, parities=parities, w_idle=0.0, w_gate=1.0
    ),
)

# ── Step 3: Execute using Qiskit primitives ───────────────────────────────────
# Build one shadow circuit per random Pauli basis, each repeated for shots_per_basis
# independent bitstrings. Transpile to hardware-native gates, then submit using the
# Sampler primitive. Each circuit yields shots_per_basis snapshots in that basis.
n_hw = 1000
shots_per_basis = 8  # multiple shots per basis circuit
shadow_circs, hw_bases = build_shadow_circuits(qc, n_hw, ghz_qubits, checks)
sampler = Sampler(backend)
sampler.options.environment.job_tags = ["TUT_STGHZS"]
pm = generate_preset_pass_manager(optimization_level=1, backend=backend)
isa_circuit = pm.run(shadow_circs)
job = sampler.run(isa_circuit, shots=shots_per_basis)
result = job.result()

# ── Step 4: Post-process and return result in desired classical format ─────────
# Filter snapshots whose check qubits all read 0 (no detected error), then
# estimate <ZiZi+1> with and without the error-detection postselection.
snapshots_hw = parse_hw_results_w_checks(result, hw_bases, ghz_qubits, checks)
snapshots_kept = [
    (ghz_bits, check_bits, bases)
    for (ghz_bits, check_bits, bases) in snapshots_hw
    if all(b == 0 for b in check_bits)
]
snapshots_kept_adapted = [
    (adapt_to_flat_bits(ghz_bits, ghz_qubits, n_total), bases)
    for (ghz_bits, check_bits, bases) in snapshots_kept
]
snapshots_hw_adapted = [
    (adapt_to_flat_bits(ghz_bits, ghz_qubits, n_total), bases)
    for (ghz_bits, check_bits, bases) in snapshots_hw
]

print("Shadow snapshots collected:", len(snapshots_hw))
print("Shadow snapshots kept post error-detection:", len(snapshots_kept))

n_groups = min(5, len(snapshots_kept))  # groups for median-of-means
ZZ_hw = estimate_zz_nn_hw(snapshots_hw_adapted, ghz_qubits, n_total, n_groups)
ZZ_kept = estimate_zz_nn_hw(
    snapshots_kept_adapted, ghz_qubits, n_total, n_groups
)

# Plot <ZiZi+1> before and after error detection
fig, axes = plt.subplots(1, 2, figsize=(13, 4))

axes[0].plot(ZZ_hw, "o-", color="#e05c5c", markersize=4)
axes[0].axhline(1.0, color="gray", ls="--", alpha=0.7, label="Ideal")
axes[0].set_ylim(-0.6, 3.0)
axes[0].set_title(r"$\langle Z_i Z_{i+1} \rangle$ (without error detection)")
axes[0].set_xlabel("GHZ qubit pair")
axes[0].legend()
axes[0].grid(alpha=0.3)

axes[1].plot(ZZ_kept, "o-", color="#e05c5c", markersize=4)
axes[1].axhline(1.0, color="gray", ls="--", alpha=0.7, label="Ideal")
axes[1].set_ylim(-0.6, 3.0)
axes[1].set_title(r"$\langle Z_i Z_{i+1} \rangle$ (with error detection)")
axes[1].set_xlabel("GHZ qubit pair")
axes[1].legend()
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

print("Before detection: <ZiZi+1>= ", float(np.average(ZZ_hw)))
print("After detection: <ZiZi+1>= ", float(np.average(ZZ_kept)))

Output:

GHZ qubits: [57, 47, 67, 46, 66, 48, 45, 65, 49, 44, 64, 38, 43, 77, 29, 42, 85, 28, 56, 41, 84, 30, 27, 36, 86, 31, 21, 87, 32, 20, 88, 33, 22, 97, 89, 34, 23, 107, 78, 39, 35, 16, 106, 90, 69, 53, 19, 24, 3, 108, 105, 91, 70, 52, 15, 25, 2, 109, 104, 92, 71, 54, 51, 14, 4, 1, 110, 103, 98, 93, 58, 55, 13, 5, 0, 118, 96, 72, 59, 12, 6, 129, 102, 73, 11, 7, 128, 101, 74, 10, 8, 127, 100, 9, 126, 116, 125, 121, 124, 120] (100)
Check qubits: [68, 37, 50, 63, 83, 17, 26, 18, 117, 111, 75] (11)
Covered fraction (no idle):  0.5454545454545454
Shadow snapshots collected: 8000
Shadow snapshots kept post error-detection: 566
Output of the previous code cell
Before detection: <ZiZi+1>=  0.573749999999951
After detection: <ZiZi+1>=  0.8817377312951776

결과 해석하기

두 그래프는 100 큐비트 GHZ 체인 내의 인접한 큐비트 쌍 각각에 대해, 패리티 체크에 대한 사후 선별 전후로 섀도우 단층촬영을 통해 추정된 ⟨ZiZi+1⟩\langle Z_i Z_{i+1}\rangle 값을 보여줍니다.

이상적인 GHZ 상태에서, 모든 가장 가까운 이웃 상관 함수는 11 와 같아집니다. 하드웨어에서는 잡음으로 인해 원시 추정값이 저하되고 큐비트 간 산란이 발생합니다. 오류 검출 후, 감지 가능한 오류가 하나 이상 발생한 스냅샷은 제거되며, 남은 추정값들은 분산이 감소된 상태로 11 에 더 가깝게 조정되는데, 이는 더 깨끗한 유효 상태와 일치한다.

오류 탐지 후 나타난 개선 효과는 실제 오류를 탐지한 검사 결과와 일치합니다.


다음 단계

자세한 내용을 알아보려면 다음 자료를 확인해 보세요:


참조

© IBM Corp. 2026

이 페이지가 도움이 되었습니까?
GitHub에서 버그, 오타를 보고하거나 컨텐츠를 요청하십시오.