qnmlab 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qnmlab/__init__.py +23 -0
- qnmlab/_core/__init__.py +0 -0
- qnmlab/_core/grouping.py +177 -0
- qnmlab/_core/hardware.py +405 -0
- qnmlab/_core/hardware_run.py +724 -0
- qnmlab/_core/hyperboloidal.py +246 -0
- qnmlab/_core/hyperboloidal_metric.py +243 -0
- qnmlab/_core/leaver.py +67 -0
- qnmlab/_core/metrics.py +98 -0
- qnmlab/_core/mitigation.py +134 -0
- qnmlab/_core/pencil.py +100 -0
- qnmlab/_core/pipeline.py +186 -0
- qnmlab/_core/quantum.py +155 -0
- qnmlab/_core/rayleigh.py +415 -0
- qnmlab/_core/recurrence.py +169 -0
- qnmlab/_core/scaling.py +69 -0
- qnmlab/_core/search2d.py +293 -0
- qnmlab/_core/ultra_rayleigh.py +292 -0
- qnmlab/benchmarks.py +61 -0
- qnmlab/cli.py +61 -0
- qnmlab/data/__init__.py +0 -0
- qnmlab/data/emerald_2026.json +389 -0
- qnmlab/hardware.py +74 -0
- qnmlab/models.py +75 -0
- qnmlab/operators.py +87 -0
- qnmlab/readout.py +139 -0
- qnmlab/reference.py +32 -0
- qnmlab/search.py +93 -0
- qnmlab-0.1.0.dist-info/METADATA +143 -0
- qnmlab-0.1.0.dist-info/RECORD +34 -0
- qnmlab-0.1.0.dist-info/WHEEL +5 -0
- qnmlab-0.1.0.dist-info/entry_points.txt +2 -0
- qnmlab-0.1.0.dist-info/licenses/LICENSE +22 -0
- qnmlab-0.1.0.dist-info/top_level.txt +1 -0
qnmlab/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
qnmlab -- black-hole quasinormal modes with quantum algorithms.
|
|
3
|
+
|
|
4
|
+
import qnmlab as ql
|
|
5
|
+
bh = ql.KazakovSolodukhin(a=0.5)
|
|
6
|
+
ql.reference_modes(bh) # classical ground truth
|
|
7
|
+
op = ql.readout_operator(bh, qubits=3) # modes are its eigenvalues
|
|
8
|
+
r = ql.measure_mode(bh, qubits=3, noise=ql.NoiseModel.emerald_random(), shots=800)
|
|
9
|
+
res = ql.find_modes(ql.Schwarzschild(), qubits=4) # search with no starting guess
|
|
10
|
+
"""
|
|
11
|
+
from . import benchmarks, hardware
|
|
12
|
+
from .models import BlackHole, Hayward, KazakovSolodukhin, ReissnerNordstrom, Schwarzschild, available_models
|
|
13
|
+
from .operators import readout_operator, search_pencil
|
|
14
|
+
from .readout import NoiseModel, measure_mode, phase_readout, prepare_state
|
|
15
|
+
from .reference import reference_modes
|
|
16
|
+
from .search import find_modes
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = ["BlackHole", "Schwarzschild", "KazakovSolodukhin", "ReissnerNordstrom", "Hayward",
|
|
21
|
+
"available_models", "reference_modes", "readout_operator", "search_pencil", "NoiseModel",
|
|
22
|
+
"prepare_state", "phase_readout", "measure_mode", "find_modes", "benchmarks", "hardware",
|
|
23
|
+
"__version__"]
|
qnmlab/_core/__init__.py
ADDED
|
File without changes
|
qnmlab/_core/grouping.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
grouping.py -- fewer measurement circuits via GENERAL-commuting (GC) grouping.
|
|
3
|
+
|
|
4
|
+
The hardware run used qubit-wise-commuting (QWC) groups: 28 circuits for the 63
|
|
5
|
+
Pauli strings of H1 and H2 at 3 qubits. QWC cannot do better than 27 here,
|
|
6
|
+
because every one of the 27 weight-3 strings needs its own local basis.
|
|
7
|
+
|
|
8
|
+
General commutation is weaker than qubit-wise commutation, so groups can be much
|
|
9
|
+
larger. For n qubits all 4^n - 1 non-identity Paulis partition into 2^n + 1
|
|
10
|
+
commuting families of 2^n - 1 (mutually unbiased bases): 9 circuits instead of
|
|
11
|
+
28 at 3 qubits. The price is a short Clifford circuit that rotates each family
|
|
12
|
+
onto Z-strings, which adds two-qubit gates (found by search, minimising CX count).
|
|
13
|
+
|
|
14
|
+
Measured trade-off: see the accompanying paper (noise and mitigation section).
|
|
15
|
+
"""
|
|
16
|
+
import heapq
|
|
17
|
+
import itertools
|
|
18
|
+
import math
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .hardware_run import _cx_matrix, _embed, _mat1
|
|
23
|
+
|
|
24
|
+
PAULI = {"I": np.eye(2), "X": np.array([[0, 1], [1, 0]]),
|
|
25
|
+
"Y": np.array([[0, -1j], [1j, 0]]), "Z": np.diag([1.0, -1.0])}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pmat(label):
|
|
29
|
+
M = np.array([[1.0 + 0j]])
|
|
30
|
+
for ch in label:
|
|
31
|
+
M = np.kron(M, PAULI[ch])
|
|
32
|
+
return M
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def symp(label):
|
|
36
|
+
"""(x bits, z bits) of a Pauli label."""
|
|
37
|
+
x = tuple(int(c in "XY") for c in label)
|
|
38
|
+
z = tuple(int(c in "ZY") for c in label)
|
|
39
|
+
return x, z
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def commute(a, b):
|
|
43
|
+
xa, za = symp(a)
|
|
44
|
+
xb, zb = symp(b)
|
|
45
|
+
return (sum(i * j for i, j in zip(xa, zb)) + sum(i * j for i, j in zip(za, xb))) % 2 == 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def gc_partition(labels):
|
|
49
|
+
"""
|
|
50
|
+
Partition Pauli labels into mutually commuting families with as few families
|
|
51
|
+
as possible. Exact-cover search over maximal commuting sets (fine for n <= 3;
|
|
52
|
+
falls back to greedy colouring above that).
|
|
53
|
+
"""
|
|
54
|
+
labels = list(labels)
|
|
55
|
+
n = len(labels[0])
|
|
56
|
+
if n <= 3:
|
|
57
|
+
alls = ["".join(t) for t in itertools.product("IXYZ", repeat=n)][1:]
|
|
58
|
+
fams = set()
|
|
59
|
+
for gens in itertools.combinations(alls, n):
|
|
60
|
+
if all(commute(a, b) for a, b in itertools.combinations(gens, 2)):
|
|
61
|
+
grp = {_mul_label(c) for c in _span(gens)} - {"I" * n, None}
|
|
62
|
+
if len(grp) == 2 ** n - 1:
|
|
63
|
+
fams.add(frozenset(grp))
|
|
64
|
+
fams = list(fams)
|
|
65
|
+
target = set(labels)
|
|
66
|
+
best = [None]
|
|
67
|
+
|
|
68
|
+
def search(rem, chosen):
|
|
69
|
+
if best[0] is not None and len(chosen) >= len(best[0]):
|
|
70
|
+
return
|
|
71
|
+
if not rem:
|
|
72
|
+
best[0] = list(chosen)
|
|
73
|
+
return
|
|
74
|
+
p = min(rem)
|
|
75
|
+
for f in fams:
|
|
76
|
+
if p in f and not (f & set().union(*chosen) if chosen else False):
|
|
77
|
+
search(rem - f, chosen + [f])
|
|
78
|
+
search(target, [])
|
|
79
|
+
return [sorted(set(f) & target) for f in best[0]]
|
|
80
|
+
groups = []
|
|
81
|
+
for lab in sorted(labels, key=lambda s: -sum(c != "I" for c in s)):
|
|
82
|
+
for g in groups:
|
|
83
|
+
if all(commute(lab, o) for o in g):
|
|
84
|
+
g.append(lab)
|
|
85
|
+
break
|
|
86
|
+
else:
|
|
87
|
+
groups.append([lab])
|
|
88
|
+
return groups
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _span(gens):
|
|
92
|
+
out = []
|
|
93
|
+
for bits in itertools.product((0, 1), repeat=len(gens)):
|
|
94
|
+
out.append(tuple(g for g, b in zip(gens, bits) if b))
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _mul_label(factors):
|
|
99
|
+
n = len(factors[0]) if factors else 0
|
|
100
|
+
if not factors:
|
|
101
|
+
return None
|
|
102
|
+
x = [0] * n
|
|
103
|
+
z = [0] * n
|
|
104
|
+
for f in factors:
|
|
105
|
+
fx, fz = symp(f)
|
|
106
|
+
x = [a ^ b for a, b in zip(x, fx)]
|
|
107
|
+
z = [a ^ b for a, b in zip(z, fz)]
|
|
108
|
+
return "".join("I" if (a, b) == (0, 0) else "X" if (a, b) == (1, 0)
|
|
109
|
+
else "Z" if (a, b) == (0, 1) else "Y" for a, b in zip(x, z))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------- diagonalising Cliffords
|
|
113
|
+
def _apply_sym(gate, vecs, n):
|
|
114
|
+
out = []
|
|
115
|
+
for x, z in vecs:
|
|
116
|
+
x, z = list(x), list(z)
|
|
117
|
+
if gate[0] == "h":
|
|
118
|
+
q = gate[1]; x[q], z[q] = z[q], x[q]
|
|
119
|
+
elif gate[0] == "s":
|
|
120
|
+
q = gate[1]; z[q] ^= x[q]
|
|
121
|
+
elif gate[0] == "cx":
|
|
122
|
+
c, t = gate[1], gate[2]; x[t] ^= x[c]; z[c] ^= z[t]
|
|
123
|
+
out.append((tuple(x), tuple(z)))
|
|
124
|
+
return tuple(out)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def diagonalising_circuit(family, n):
|
|
128
|
+
"""Cheapest {H, S, CX} circuit (CX cost 1, single-qubit 0.01) mapping every
|
|
129
|
+
member of a commuting family onto a Z-string."""
|
|
130
|
+
gens = [symp(l) for l in family]
|
|
131
|
+
moves = [("h", q) for q in range(n)] + [("s", q) for q in range(n)] + \
|
|
132
|
+
[("cx", c, t) for c in range(n) for t in range(n) if c != t]
|
|
133
|
+
start = tuple(gens)
|
|
134
|
+
pq = [(0.0, 0, start, ())]
|
|
135
|
+
seen = {start: 0.0}
|
|
136
|
+
k = 0
|
|
137
|
+
while pq:
|
|
138
|
+
cost, _, st, path = heapq.heappop(pq)
|
|
139
|
+
if all(not any(x) for x, _ in st):
|
|
140
|
+
return list(path)
|
|
141
|
+
if cost > 6:
|
|
142
|
+
break
|
|
143
|
+
for m in moves:
|
|
144
|
+
nst = _apply_sym(m, st, n)
|
|
145
|
+
nc = cost + (1.0 if m[0] == "cx" else 0.01)
|
|
146
|
+
if nst not in seen or nc < seen[nst] - 1e-12:
|
|
147
|
+
seen[nst] = nc
|
|
148
|
+
k += 1
|
|
149
|
+
heapq.heappush(pq, (nc, k, nst, path + (m,)))
|
|
150
|
+
raise RuntimeError("no diagonalising circuit found")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def to_sim_gates(path):
|
|
154
|
+
"""Map {h, s, cx} to the simulator's gate set (S = RZ(pi/2) up to global phase)."""
|
|
155
|
+
return [("h", g[1]) if g[0] == "h" else ("rz", g[1], math.pi / 2) if g[0] == "s"
|
|
156
|
+
else ("cx", g[1], g[2]) for g in path]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def unitary(gates, n):
|
|
160
|
+
U = np.eye(2 ** n, dtype=complex)
|
|
161
|
+
for g in gates:
|
|
162
|
+
if g[0] == "cx":
|
|
163
|
+
G = _cx_matrix(g[1], g[2], n)
|
|
164
|
+
else:
|
|
165
|
+
G = _embed(_mat1(g[0], g[2] if len(g) > 2 else None), g[1], n)
|
|
166
|
+
U = G @ U
|
|
167
|
+
return U
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def z_image(label, U, n):
|
|
171
|
+
"""U P U^dag = sign * Z-string. Returns (sign, zlabel)."""
|
|
172
|
+
M = U @ pmat(label) @ U.conj().T
|
|
173
|
+
for zl in ("".join(t) for t in itertools.product("IZ", repeat=n)):
|
|
174
|
+
c = np.trace(pmat(zl) @ M) / 2 ** n
|
|
175
|
+
if abs(abs(c) - 1) < 1e-9:
|
|
176
|
+
return (1.0 if np.real(c) > 0 else -1.0), zl
|
|
177
|
+
raise RuntimeError(f"{label} is not diagonalised")
|
qnmlab/_core/hardware.py
ADDED
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hardware.py -- the path from statevector demo to real quantum hardware.
|
|
3
|
+
|
|
4
|
+
This module adds everything the pure-statevector solver (quantum.py) lacks
|
|
5
|
+
for a realistic hardware run:
|
|
6
|
+
|
|
7
|
+
1. PAULI DECOMPOSITION H = sum_k c_k P_k (P_k = tensor products of
|
|
8
|
+
I,X,Y,Z). Real devices can only measure Pauli
|
|
9
|
+
strings, so this is how any Hermitian observable
|
|
10
|
+
is actually estimated on hardware.
|
|
11
|
+
|
|
12
|
+
2. NOISY SIMULATION a density-matrix simulator with a depolarizing
|
|
13
|
+
error channel after every gate -- the standard
|
|
14
|
+
first-order model of real-device noise.
|
|
15
|
+
|
|
16
|
+
3. SHOT SAMPLING expectation values estimated from a finite
|
|
17
|
+
measurement budget, allocated across Pauli terms.
|
|
18
|
+
|
|
19
|
+
4. SPSA OPTIMIZER the optimizer of choice on noisy hardware:
|
|
20
|
+
it tolerates noisy energies (unlike L-BFGS,
|
|
21
|
+
which chokes on them) and needs only 2 energy
|
|
22
|
+
evaluations per step regardless of the number
|
|
23
|
+
of parameters.
|
|
24
|
+
|
|
25
|
+
5. ZERO-NOISE EXTRAPOLATION (ZNE) -- the flagship error-MITIGATION method:
|
|
26
|
+
run the same circuit at amplified noise levels
|
|
27
|
+
(1x, 2x, 3x), fit the trend, extrapolate to the
|
|
28
|
+
zero-noise limit. No extra qubits needed.
|
|
29
|
+
|
|
30
|
+
6. DILATION ENCODING VQE on H(w) = [[0,P],[P^dag,0]] instead of
|
|
31
|
+
R = P^dag P. The ground eigenvalue is
|
|
32
|
+
-sigma_min(P) instead of sigma_min(P)^2, so the
|
|
33
|
+
signal is SQRT-larger -- crucial when noise
|
|
34
|
+
floors are the binding constraint.
|
|
35
|
+
|
|
36
|
+
Terminology check (honesty): these are error *mitigation* techniques. True
|
|
37
|
+
error *correction* (surface codes, logical qubits) needs thousands of physical
|
|
38
|
+
qubits per logical qubit and is not available on today's devices.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import numpy as np
|
|
42
|
+
from scipy import linalg
|
|
43
|
+
|
|
44
|
+
from .quantum import ansatz_state # reuse the exact-statevector ansatz
|
|
45
|
+
|
|
46
|
+
# ----------------------------------------------------------------------
|
|
47
|
+
# 1. Pauli decomposition
|
|
48
|
+
# ----------------------------------------------------------------------
|
|
49
|
+
_PAULIS = {
|
|
50
|
+
"I": np.eye(2, dtype=complex),
|
|
51
|
+
"X": np.array([[0, 1], [1, 0]], dtype=complex),
|
|
52
|
+
"Y": np.array([[0, -1j], [1j, 0]], dtype=complex),
|
|
53
|
+
"Z": np.array([[1, 0], [0, -1]], dtype=complex),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pauli_matrix(label):
|
|
58
|
+
M = _PAULIS[label[0]]
|
|
59
|
+
for ch in label[1:]:
|
|
60
|
+
M = np.kron(M, _PAULIS[ch])
|
|
61
|
+
return M
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def pauli_decompose(H, tol=1e-12):
|
|
65
|
+
"""
|
|
66
|
+
Decompose a 2^n x 2^n Hermitian H into Pauli strings:
|
|
67
|
+
|
|
68
|
+
H = sum_k c_k P_k , c_k = Tr(P_k H) / 2^n (real for Hermitian H)
|
|
69
|
+
|
|
70
|
+
Returns a list of (coeff, label) with |coeff| > tol. This is what gets
|
|
71
|
+
measured on a real device: each P_k needs its own measurement basis.
|
|
72
|
+
"""
|
|
73
|
+
n = int(np.log2(H.shape[0]))
|
|
74
|
+
assert 2 ** n == H.shape[0]
|
|
75
|
+
labels = ["".join(s) for s in _product("IXYZ", n)]
|
|
76
|
+
terms = []
|
|
77
|
+
for lab in labels:
|
|
78
|
+
c = np.trace(_pauli_matrix(lab) @ H).real / 2 ** n
|
|
79
|
+
if abs(c) > tol:
|
|
80
|
+
terms.append((c, lab))
|
|
81
|
+
return terms
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _product(alphabet, repeat):
|
|
85
|
+
if repeat == 1:
|
|
86
|
+
for ch in alphabet:
|
|
87
|
+
yield (ch,)
|
|
88
|
+
else:
|
|
89
|
+
for ch in alphabet:
|
|
90
|
+
for rest in _product(alphabet, repeat - 1):
|
|
91
|
+
yield (ch,) + rest
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def pauli_reconstruct(terms, n):
|
|
95
|
+
"""Rebuild the matrix from its Pauli terms (for validation)."""
|
|
96
|
+
H = np.zeros((2 ** n, 2 ** n), dtype=complex)
|
|
97
|
+
for c, lab in terms:
|
|
98
|
+
H += c * _pauli_matrix(lab)
|
|
99
|
+
return H
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ----------------------------------------------------------------------
|
|
103
|
+
# 2. Noisy density-matrix simulator
|
|
104
|
+
# ----------------------------------------------------------------------
|
|
105
|
+
def _RY(t):
|
|
106
|
+
return np.array([[np.cos(t / 2), -np.sin(t / 2)],
|
|
107
|
+
[np.sin(t / 2), np.cos(t / 2)]], dtype=complex)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _RZ(t):
|
|
111
|
+
return np.array([[np.exp(-1j * t / 2), 0],
|
|
112
|
+
[0, np.exp(1j * t / 2)]], dtype=complex)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _embed_1q(U, q, n):
|
|
116
|
+
ops = [np.eye(2, dtype=complex)] * n
|
|
117
|
+
ops[q] = U
|
|
118
|
+
M = ops[0]
|
|
119
|
+
for o in ops[1:]:
|
|
120
|
+
M = np.kron(M, o)
|
|
121
|
+
return M
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _cnot_matrix(c, t, n):
|
|
125
|
+
dim = 2 ** n
|
|
126
|
+
M = np.zeros((dim, dim), dtype=complex)
|
|
127
|
+
for i in range(dim):
|
|
128
|
+
bits = [(i >> (n - 1 - k)) & 1 for k in range(n)]
|
|
129
|
+
if bits[c] == 1:
|
|
130
|
+
bits[t] ^= 1
|
|
131
|
+
j = 0
|
|
132
|
+
for b in bits:
|
|
133
|
+
j = (j << 1) | b
|
|
134
|
+
M[j, i] = 1.0
|
|
135
|
+
return M
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _depolarize(rho, qubits, p, n):
|
|
139
|
+
"""Depolarizing channel with probability p on the given qubits."""
|
|
140
|
+
if p <= 0:
|
|
141
|
+
return rho
|
|
142
|
+
out = (1 - p) * rho
|
|
143
|
+
for q in qubits:
|
|
144
|
+
for lab in ("X", "Y", "Z"):
|
|
145
|
+
K = _embed_1q(_PAULIS[lab], q, n)
|
|
146
|
+
out += (p / (3 * len(qubits))) * (K @ rho @ K.conj().T)
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class NoisyAnsatzSim:
|
|
151
|
+
"""
|
|
152
|
+
Density-matrix simulation of the (RY,RZ)+CNOT ansatz with depolarizing
|
|
153
|
+
noise after every gate.
|
|
154
|
+
|
|
155
|
+
p1 : error probability per single-qubit gate (typical hardware: ~1e-4..1e-3)
|
|
156
|
+
p2 : error probability per CNOT (typical hardware: ~1e-3..1e-2)
|
|
157
|
+
|
|
158
|
+
`noise_scale` multiplies both -- that is the knob ZNE turns.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(self, n_qubits, layers, p1=1e-3, p2=1e-2):
|
|
162
|
+
self.n = n_qubits
|
|
163
|
+
self.L = layers
|
|
164
|
+
self.p1 = p1
|
|
165
|
+
self.p2 = p2
|
|
166
|
+
# pre-build the CNOT matrices once
|
|
167
|
+
self._cnots = [_cnot_matrix(q, q + 1, n_qubits)
|
|
168
|
+
for q in range(n_qubits - 1)]
|
|
169
|
+
|
|
170
|
+
def density_matrix(self, theta, noise_scale=1.0):
|
|
171
|
+
n, L = self.n, self.L
|
|
172
|
+
theta = np.asarray(theta).reshape(L + 1, n, 2)
|
|
173
|
+
p1 = min(self.p1 * noise_scale, 1.0)
|
|
174
|
+
p2 = min(self.p2 * noise_scale, 1.0)
|
|
175
|
+
dim = 2 ** n
|
|
176
|
+
rho = np.zeros((dim, dim), dtype=complex)
|
|
177
|
+
rho[0, 0] = 1.0
|
|
178
|
+
for l in range(L):
|
|
179
|
+
for q in range(n):
|
|
180
|
+
U = _embed_1q(_RY(theta[l, q, 0]), q, n)
|
|
181
|
+
rho = U @ rho @ U.conj().T
|
|
182
|
+
rho = _depolarize(rho, [q], p1, n)
|
|
183
|
+
U = _embed_1q(_RZ(theta[l, q, 1]), q, n)
|
|
184
|
+
rho = U @ rho @ U.conj().T
|
|
185
|
+
rho = _depolarize(rho, [q], p1, n)
|
|
186
|
+
for q in range(n - 1):
|
|
187
|
+
U = self._cnots[q]
|
|
188
|
+
rho = U @ rho @ U.conj().T
|
|
189
|
+
rho = _depolarize(rho, [q, q + 1], p2, n)
|
|
190
|
+
for q in range(n):
|
|
191
|
+
U = _embed_1q(_RY(theta[L, q, 0]), q, n)
|
|
192
|
+
rho = U @ rho @ U.conj().T
|
|
193
|
+
rho = _depolarize(rho, [q], p1, n)
|
|
194
|
+
U = _embed_1q(_RZ(theta[L, q, 1]), q, n)
|
|
195
|
+
rho = U @ rho @ U.conj().T
|
|
196
|
+
rho = _depolarize(rho, [q], p1, n)
|
|
197
|
+
return rho
|
|
198
|
+
|
|
199
|
+
def energy(self, theta, H, noise_scale=1.0, shots=None, rng=None):
|
|
200
|
+
"""
|
|
201
|
+
<H> = Tr(rho H), optionally with shot noise.
|
|
202
|
+
|
|
203
|
+
With `shots`, the observable is measured term-by-term in its Pauli
|
|
204
|
+
decomposition with the budget split evenly; each term's estimate gets
|
|
205
|
+
binomial sampling error sqrt((1 - <P>^2)/shots_per_term).
|
|
206
|
+
"""
|
|
207
|
+
rho = self.density_matrix(theta, noise_scale)
|
|
208
|
+
if shots is None:
|
|
209
|
+
return float(np.real(np.trace(rho @ H)))
|
|
210
|
+
terms = pauli_decompose(H)
|
|
211
|
+
# identity term costs no measurements
|
|
212
|
+
meas_terms = [(c, l) for c, l in terms if set(l) != {"I"}]
|
|
213
|
+
ident = sum(c for c, l in terms if set(l) == {"I"})
|
|
214
|
+
shots_per = max(1, shots // max(1, len(meas_terms)))
|
|
215
|
+
rng = rng or np.random.default_rng()
|
|
216
|
+
total = ident
|
|
217
|
+
for c, lab in meas_terms:
|
|
218
|
+
P = _pauli_matrix(lab)
|
|
219
|
+
mean = float(np.real(np.trace(rho @ P)))
|
|
220
|
+
mean = np.clip(mean, -1.0, 1.0)
|
|
221
|
+
sampled = mean + rng.normal(0.0, np.sqrt(max(0.0, 1 - mean ** 2) / shots_per))
|
|
222
|
+
total += c * sampled
|
|
223
|
+
return float(total)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ----------------------------------------------------------------------
|
|
227
|
+
# 3. SPSA -- the noise-tolerant optimizer used on real hardware
|
|
228
|
+
# ----------------------------------------------------------------------
|
|
229
|
+
def spsa_minimize(fun, x0, n_iter=300, a=0.2, c=0.15, seed=0,
|
|
230
|
+
A=None, alpha=0.602, gamma=0.101):
|
|
231
|
+
"""
|
|
232
|
+
Simultaneous Perturbation Stochastic Approximation.
|
|
233
|
+
|
|
234
|
+
Each iteration estimates the FULL gradient from just TWO function
|
|
235
|
+
evaluations by perturbing all parameters simultaneously with a random
|
|
236
|
+
+/-1 vector. Gain sequences follow Spall's standard schedule. Because
|
|
237
|
+
it never trusts any single (noisy) evaluation too much, it converges
|
|
238
|
+
where line-search methods (L-BFGS) fail on sampled energies.
|
|
239
|
+
"""
|
|
240
|
+
rng = np.random.default_rng(seed)
|
|
241
|
+
x = np.array(x0, dtype=float)
|
|
242
|
+
A = A or n_iter // 10
|
|
243
|
+
best_x, best_f = x.copy(), np.inf
|
|
244
|
+
for k in range(n_iter):
|
|
245
|
+
ak = a / (k + 1 + A) ** alpha
|
|
246
|
+
ck = c / (k + 1) ** gamma
|
|
247
|
+
delta = rng.choice([-1.0, 1.0], size=x.shape)
|
|
248
|
+
f_plus = fun(x + ck * delta)
|
|
249
|
+
f_minus = fun(x - ck * delta)
|
|
250
|
+
ghat = (f_plus - f_minus) / (2 * ck) * delta
|
|
251
|
+
x = x - ak * ghat
|
|
252
|
+
f_now = min(f_plus, f_minus)
|
|
253
|
+
if f_now < best_f:
|
|
254
|
+
best_f, best_x = f_now, x.copy()
|
|
255
|
+
# final unbiased evaluation at the best point
|
|
256
|
+
return best_x, fun(best_x)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ----------------------------------------------------------------------
|
|
260
|
+
# 4. Zero-noise extrapolation (ZNE)
|
|
261
|
+
# ----------------------------------------------------------------------
|
|
262
|
+
def zne_energy(sim, theta, H, scales=(1.0, 2.0, 3.0), shots=None, rng=None,
|
|
263
|
+
order=2):
|
|
264
|
+
"""
|
|
265
|
+
Error-mitigated energy by Richardson extrapolation.
|
|
266
|
+
|
|
267
|
+
Evaluate the SAME circuit at artificially amplified noise levels (on
|
|
268
|
+
hardware this is done by gate folding G -> G G^dag G; in our simulator we
|
|
269
|
+
simply scale the depolarizing probability), then fit a polynomial in the
|
|
270
|
+
noise scale and read off the value at scale 0.
|
|
271
|
+
|
|
272
|
+
Returns (mitigated_energy, raw_energies_at_each_scale).
|
|
273
|
+
"""
|
|
274
|
+
es = [sim.energy(theta, H, noise_scale=s, shots=shots, rng=rng)
|
|
275
|
+
for s in scales]
|
|
276
|
+
coeffs = np.polyfit(scales, es, deg=min(order, len(scales) - 1))
|
|
277
|
+
e0 = float(np.polyval(coeffs, 0.0))
|
|
278
|
+
return e0, es
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ----------------------------------------------------------------------
|
|
282
|
+
# 5. VQE runners: noisy, mitigated, and the dilation encoding
|
|
283
|
+
# ----------------------------------------------------------------------
|
|
284
|
+
def vqe_noisy(H, n_qubits, layers=3, p1=1e-3, p2=1e-2, shots=None,
|
|
285
|
+
n_iter=300, restarts=3, seed=0, mitigate=False,
|
|
286
|
+
scales=(1.0, 2.0, 3.0), theta0=None):
|
|
287
|
+
"""
|
|
288
|
+
Full noisy VQE: SPSA optimises the (noisy, sampled) energy; the final
|
|
289
|
+
answer is either the raw noisy energy or the ZNE-mitigated one.
|
|
290
|
+
|
|
291
|
+
theta0 : optional warm-start parameters. Standard hardware practice is
|
|
292
|
+
to pre-optimise on a classical simulator and only evaluate (or
|
|
293
|
+
briefly refine) on the device; pass those parameters here. With
|
|
294
|
+
theta0 given and n_iter=0, the function performs pure noisy
|
|
295
|
+
EVALUATION at theta0 (no optimisation) -- the cleanest way to
|
|
296
|
+
isolate "what does noise do to the answer" from "can the
|
|
297
|
+
optimiser survive noise".
|
|
298
|
+
|
|
299
|
+
Returns dict with raw / mitigated energies and the optimal parameters.
|
|
300
|
+
"""
|
|
301
|
+
sim = NoisyAnsatzSim(n_qubits, layers, p1=p1, p2=p2)
|
|
302
|
+
nrm = np.linalg.norm(H, 2)
|
|
303
|
+
Hn = H / nrm
|
|
304
|
+
rng = np.random.default_rng(seed)
|
|
305
|
+
|
|
306
|
+
if theta0 is not None and n_iter == 0:
|
|
307
|
+
theta = np.asarray(theta0, dtype=float)
|
|
308
|
+
else:
|
|
309
|
+
best = {"f": np.inf, "x": None}
|
|
310
|
+
starts = ([np.asarray(theta0, dtype=float)] if theta0 is not None else
|
|
311
|
+
[rng.uniform(0, 2 * np.pi, (layers + 1) * n_qubits * 2)
|
|
312
|
+
for _ in range(restarts)])
|
|
313
|
+
for r, x0 in enumerate(starts):
|
|
314
|
+
obj = lambda th: sim.energy(th, Hn, noise_scale=1.0,
|
|
315
|
+
shots=shots, rng=rng)
|
|
316
|
+
x, f = spsa_minimize(obj, x0, n_iter=n_iter, seed=seed + 17 * r)
|
|
317
|
+
if f < best["f"]:
|
|
318
|
+
best = {"f": f, "x": x}
|
|
319
|
+
theta = best["x"]
|
|
320
|
+
raw = sim.energy(theta, Hn, noise_scale=1.0, shots=shots, rng=rng) * nrm
|
|
321
|
+
out = {"raw": raw, "theta": theta, "norm": nrm}
|
|
322
|
+
if mitigate:
|
|
323
|
+
e0, es = zne_energy(sim, theta, Hn, scales=scales, shots=shots, rng=rng)
|
|
324
|
+
out["mitigated"] = e0 * nrm
|
|
325
|
+
out["scale_energies"] = [e * nrm for e in es]
|
|
326
|
+
# noiseless reference with the same parameters (what mitigation aims for)
|
|
327
|
+
st = ansatz_state(theta, n_qubits, layers)
|
|
328
|
+
out["noiseless_same_theta"] = float(np.real(st.conj() @ (H @ st)))
|
|
329
|
+
return out
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def vqe_dilation(P, layers=3, beta=0.05, p1=1e-3, p2=1e-2, shots=None,
|
|
333
|
+
n_iter=300, restarts=3, seed=0, mitigate=False,
|
|
334
|
+
scales=(1.0, 2.0, 3.0), theta0=None):
|
|
335
|
+
"""
|
|
336
|
+
Estimate sigma_min(P) via VQE on the Hermitian dilation
|
|
337
|
+
|
|
338
|
+
H = [[0, P], [P^dag, 0]] (2N x 2N -> one extra qubit),
|
|
339
|
+
|
|
340
|
+
whose spectrum is {+/- sigma_i(P)}. The pair nearest zero is
|
|
341
|
+
+/- sigma_min.
|
|
342
|
+
|
|
343
|
+
How we target it variationally
|
|
344
|
+
------------------------------
|
|
345
|
+
Minimise the "folded + pinned" objective
|
|
346
|
+
|
|
347
|
+
F(theta) = <H^2> + beta * <H> , beta small > 0.
|
|
348
|
+
|
|
349
|
+
<H^2> alone is minimised by BOTH +/- sigma_min eigenstates (folded
|
|
350
|
+
spectrum at 0); the small linear pin term breaks the tie in favour of the
|
|
351
|
+
-sigma_min eigenstate. At the optimum, therefore,
|
|
352
|
+
|
|
353
|
+
sigma_min = - <H> (a LINEAR readout).
|
|
354
|
+
|
|
355
|
+
Why this beats the residual encoding under noise
|
|
356
|
+
------------------------------------------------
|
|
357
|
+
With R = P^dag P the reported number is <R> = sigma_min^2: to resolve a
|
|
358
|
+
sigma_min of 1e-4 you must measure an observable at the 1e-8 level. With
|
|
359
|
+
the dilation the reported number is <H> = -sigma_min itself: you only
|
|
360
|
+
need 1e-4 resolution. The shot cost improves from O(1/sigma^4) to
|
|
361
|
+
O(1/sigma^2) -- exactly the regime where Block C of run_hybrid_demo.py
|
|
362
|
+
showed the residual encoding drowning in noise.
|
|
363
|
+
|
|
364
|
+
Returns dict with: sigma_est (from -<H>), raw/mitigated versions, theta.
|
|
365
|
+
"""
|
|
366
|
+
N = P.shape[0]
|
|
367
|
+
Z = np.zeros_like(P)
|
|
368
|
+
H = np.block([[Z, P], [P.conj().T, Z]])
|
|
369
|
+
n_qubits = int(np.log2(H.shape[0]))
|
|
370
|
+
assert 2 ** n_qubits == H.shape[0], "dilation size must be a power of two"
|
|
371
|
+
|
|
372
|
+
nrm = np.linalg.norm(H, 2)
|
|
373
|
+
Hn = H / nrm
|
|
374
|
+
H2n = Hn @ Hn
|
|
375
|
+
sim = NoisyAnsatzSim(n_qubits, layers, p1=p1, p2=p2)
|
|
376
|
+
rng = np.random.default_rng(seed)
|
|
377
|
+
|
|
378
|
+
def objective(th):
|
|
379
|
+
e2 = sim.energy(th, H2n, noise_scale=1.0, shots=shots, rng=rng)
|
|
380
|
+
e1 = sim.energy(th, Hn, noise_scale=1.0, shots=shots, rng=rng)
|
|
381
|
+
return e2 + beta * e1
|
|
382
|
+
|
|
383
|
+
if theta0 is not None and n_iter == 0:
|
|
384
|
+
theta = np.asarray(theta0, dtype=float)
|
|
385
|
+
else:
|
|
386
|
+
best = {"f": np.inf, "x": None}
|
|
387
|
+
starts = ([np.asarray(theta0, dtype=float)] if theta0 is not None else
|
|
388
|
+
[rng.uniform(0, 2 * np.pi, (layers + 1) * n_qubits * 2)
|
|
389
|
+
for _ in range(restarts)])
|
|
390
|
+
for r, x0 in enumerate(starts):
|
|
391
|
+
x, f = spsa_minimize(objective, x0, n_iter=n_iter, seed=seed + 17 * r)
|
|
392
|
+
if f < best["f"]:
|
|
393
|
+
best = {"f": f, "x": x}
|
|
394
|
+
theta = best["x"]
|
|
395
|
+
e1_raw = sim.energy(theta, Hn, noise_scale=1.0, shots=shots, rng=rng)
|
|
396
|
+
out = {"sigma_est": -e1_raw * nrm, "raw_H": e1_raw * nrm,
|
|
397
|
+
"theta": theta, "norm": nrm}
|
|
398
|
+
if mitigate:
|
|
399
|
+
e0, es = zne_energy(sim, theta, Hn, scales=scales, shots=shots, rng=rng)
|
|
400
|
+
out["sigma_mitigated"] = -e0 * nrm
|
|
401
|
+
out["scale_energies"] = [e * nrm for e in es]
|
|
402
|
+
# noiseless reference with the same parameters
|
|
403
|
+
st = ansatz_state(theta, n_qubits, layers)
|
|
404
|
+
out["noiseless_same_theta"] = -float(np.real(st.conj() @ (H @ st)))
|
|
405
|
+
return out
|