File size: 3,482 Bytes
82ec586
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
"""
Does splitting a FIXED total measurement budget across more noise-scale
points actually help, or does the earlier "more points helps" finding
only look good because it secretly spent more measurements (more K per
point) than the 3-point baseline?

This is the fair comparison: same total number of Monte Carlo trajectories
across all configurations, just split differently -- 3 points x K=400
(1200 total) vs. 5 points x K=240 (1200 total) vs. 7 points x K=171
(~1200 total). Answers "what did we actually win, all else equal."

    python experiments/matrix_healing_fixed_budget.py
"""
import os
import sys

import numpy as np
import jax.numpy as jnp

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import dense_evolution as de
from dense_evolution.registry import NoiseModel
from dense_evolution.mitigation import (
    uhlmann_fidelity, richardson_extrapolate, project_to_physical, zne_density_matrix,
)

N_QUBITS = 4
BASE_P = 0.05
SEEDS = (0, 1, 2, 3, 4)
NOISE_MODELS = ("depolarizing", "bitflip", "phaseflip", "amplitude_damping", "combined")

CONFIGS = {
    "3pt K=400 (baseline, classic ZNE)": ((1.0, 2.0, 3.0), 400, "richardson"),
    "5pt K=240 (deg2 lstsq)":            ((1.0, 2.0, 3.0, 4.0, 5.0), 240, "lstsq2"),
    "7pt K=171 (deg2 lstsq)":            ((1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0), 171, "lstsq2"),
}


def ghz_sv(n):
    sim = de.DenseSVSimulator(n)
    ops = [("h", 0)] + [("cx", 0, i) for i in range(1, n)]
    sim.run_circuit(ops)
    return np.asarray(sim.get_statevector())


def noisy_rho(ideal_sv, model, p, k, rng):
    dim = len(ideal_sv)
    rho = np.zeros((dim, dim), dtype=np.complex128)
    for _ in range(k):
        sv = NoiseModel.apply_to_sv(ideal_sv.copy(), N_QUBITS, model, p, rng=rng)
        rho += np.outer(sv, sv.conj())
    rho /= k
    return jnp.asarray(rho, dtype=jnp.complex128)


def run_one(ideal_sv, rho_ideal, model, scales, k, method, seed):
    rng = np.random.default_rng(seed)
    rho_at_scales = jnp.stack([noisy_rho(ideal_sv, model, BASE_P * s, k, rng) for s in scales])
    raw = uhlmann_fidelity(rho_at_scales[0], rho_ideal)
    if method == "richardson":
        corrected_rho = project_to_physical(richardson_extrapolate(rho_at_scales, scales))
    else:
        corrected_rho = zne_density_matrix(rho_at_scales, scales, degree=2)
    corrected = uhlmann_fidelity(corrected_rho, rho_ideal)
    return raw, corrected


def main():
    ideal_sv = ghz_sv(N_QUBITS)
    rho_ideal = jnp.asarray(np.outer(ideal_sv, ideal_sv.conj()), dtype=jnp.complex128)

    print(f"n_qubits={N_QUBITS}, base_p={BASE_P}, ~1200 total trajectories per run regardless of config\n")
    summary = {}
    for label, (scales, k, method) in CONFIGS.items():
        actual_budget = k * len(scales)
        all_deltas = []
        for model in NOISE_MODELS:
            deltas = []
            for seed in SEEDS:
                raw, corrected = run_one(ideal_sv, rho_ideal, model, scales, k, method, seed)
                deltas.append(corrected - raw)
            all_deltas.extend(deltas)
        summary[label] = (np.array(all_deltas), actual_budget)

    print(f"{'config':>36} {'actual_budget':>14} {'mean_delta':>12} {'std_delta':>10} {'wins/total':>12}")
    print("-" * 90)
    for label, (arr, budget) in summary.items():
        print(f"{label:>36} {budget:>14} {arr.mean():>+12.4f} {arr.std():>10.4f} "
              f"{int(np.sum(arr>0))}/{len(arr):>10}")


if __name__ == "__main__":
    main()