איך לחשב חסמים מותאמים אישית
compute_forward_bounds ו-compute_backward_bounds הם עטיפות נוחות. שניהם בנויים מאותם שלושה רכיבים, שה-API הציבורי חושף:
-
פונקציית נורמה, שחוסמת איבר שגיאה בודד מסוג Pauli אחרי התפתחות
-
חרוט אור, שמזריע את מה שאיברי השגיאה מתפתחים לעברו
-
compute_bounds, שעובר על המעגל ומפזר את העבודה לכל איבר
הרכבה שלהם בעצמכם אומרת שאפשר לחסום דברים שהעטיפות לא מכסות. המדריך הזה עובר על מקרה אחד שבו זה שווה את המאמץ: מעגל מראה (mirror circuit), שבו חסם הקדימה-אחורה הרגיל רפוי שלא לצורך.
מה העטיפות עושות בפועל
שתי העטיפות נבדלות בשלושת המקומות שפורטו למעלה:
compute_forward_bounds | compute_backward_bounds | |
|---|---|---|
| מעגל | כפי שניתן | remove_measure(circuit).inverse() |
| פונקציית נורמה | time_evolved_norm_forward, מול האובזרבבל | time_evolved_norm_backward, מול המצב ההתחלתי |
| חרוט אור | LightCone.initialize_from_pauli, מוזרע מהאובזרבבל | LightCone.initialize_from_measurements, מוזרע מכל Qubit פעיל |
backwards | False | True |
חסם הקדימה שואל "עד כמה השגיאה הזו יכולה לשנות את האובזרבבל שאני עומד למדוד?" ועונה עליו בנורמה הספקטרלית של קומוטטור. חסם האחורה שואל "עד כמה השגיאה הזו יכולה לשנות את המצב שממנו התחלתי?" ועונה בנורמה גרעינית, בהנחה שהמצב הזה הוא מצב האפסים על כל Qubit פעיל. אלה שאלות שונות, וכל אחת מהן היא הנכונה לשגיאות בחצי המעגל שלה.
מקרה מעגל המראה
מעגל מראה מפעיל אוניטרי כלשהו ואז מבטל אותו: . מעגלים כאלה מופיעים בכל פעם שצריך לדעת במדויק מהי התוצאה האידיאלית (בדרך כלל הדים של Loschmidt, benchmarking אקראי וניסויי אימות).
למעגלי מראה יש תכונה שחסם האחורה הכללי לא יכול לנצל. מכיוון שהחצי השני הוא ההופכי של הראשון, התפתחות של שגיאה אחורה עד המצב ההתחלתי עוברת דרך אותם שערים כמו התפתחות שלה קדימה עד האובזרבבל. שתי השאלות חופפות, ולכן חסם בסגנון קדימה, החד יותר, תקף גם לכיוון אחורה.
# Added by doQumentation — installs the packages this notebook needs if they are missing
import importlib.util
_needed = {"numpy": "numpy", "qiskit": "qiskit", "qiskit_addon_slc": "qiskit-addon-slc", "samplomatic": "samplomatic"}
_missing = [pip for module, pip in _needed.items()
if importlib.util.find_spec(module) is None]
# One at a time, so a package that fails to install does not block the others
for _pip in _missing:
%pip install -q {_pip}
if not _missing:
print("\u2713 All required packages are installed")
import numpy as np
from qiskit import QuantumCircuit
from qiskit.quantum_info import Pauli
from qiskit_addon_slc.utils import generate_noise_model_paulis
from samplomatic.transpiler import generate_boxing_pass_manager
from samplomatic.utils import find_unique_box_instructions
def trotter_ising_circuit(num_qubits, num_steps, rx_angle, rzz_angle):
"""Trotterized transverse-field Ising evolution on a 1D chain."""
circuit = QuantumCircuit(num_qubits)
for _ in range(num_steps):
circuit.rx(rx_angle, range(num_qubits))
circuit.barrier()
for start in (0, 1): # even then odd bonds
for i in range(start, num_qubits - 1, 2):
circuit.rzz(rzz_angle, i, i + 1)
circuit.barrier()
return circuit
num_qubits = 10
# The circuit of the quickstart guide...
forward_half = trotter_ising_circuit(
num_qubits, num_steps=4, rx_angle=np.pi / 16, rzz_angle=-np.pi / 3
)
# ...followed by its inverse. The ideal result is the initial state.
circuit = forward_half.compose(forward_half.inverse())
observable = Pauli("I" * num_qubits).compose("Z", [num_qubits // 2])
boxing_pass = generate_boxing_pass_manager(
inject_noise_targets="all",
inject_noise_strategy="individual_modification",
inject_noise_site="after",
twirling_strategy="active",
remove_barriers="never",
)
boxed_circuit = boxing_pass.run(circuit)
noise_model_paulis = generate_noise_model_paulis(
find_unique_box_instructions(boxed_circuit)
)
print(f"mirror circuit: {num_qubits} qubits, depth {circuit.depth()}")
print(f"noisy layers: {len(noise_model_paulis)}")
mirror circuit: 10 qubits, depth 24
noisy layers: 4
הרכבת החסם המותאם אישית
המתכון שומר את מה ש-compute_backward_bounds עושה עם המעגל (מסיר את המדידות, הופך אותו, ועובר עליו עם backwards=True) ומחליף את שני הרכיבים האחרים: פונקציית הנורמה של קדימה, וחרוט אור שמוזרע מהאובזרבבל באמצעות LightCone.initialize_from_pauli, בדיוק כמו ש-compute_forward_bounds מזריע את שלו. כל החלפה היא שורה אחת.
from functools import partial
from qiskit_addon_slc.bounds import (
LightCone,
compute_bounds,
time_evolved_norm_forward,
)
from qiskit_addon_slc.utils import remove_measure
def compute_mirror_backward_bounds(
circuit,
noise_model_paulis,
/,
observable,
*,
evolution_max_terms=1_000_000,
**kwargs,
):
"""Bounds the backward direction of a mirror circuit with a forward-style bound.
Valid only for circuits whose second half inverts the first: the backward evolution then
traverses the same gates as the forward one, and reproduces the forward bounds.
"""
# As `compute_backward_bounds` does: strip measurements and invert, so that walking the circuit
# backwards from its end becomes walking the inverted circuit forwards.
circuit = remove_measure(circuit).inverse()
# The substitution: the forward norm function, bounding against the observable rather than
# against the initial state.
norm_fn = partial(
time_evolved_norm_forward,
observable=observable,
evolution_max_terms=evolution_max_terms,
)
# Seed the light cone from the observable, not from every active qubit.
light_cone = LightCone.initialize_from_pauli(circuit, observable)
return compute_bounds(
circuit,
noise_model_paulis,
light_cone,
norm_fn,
backwards=True,
**kwargs,
)
כמה זה מרוויח
השוואה מול חסם האחורה הרגיל על אותו מעגל, מסוכמת באותו אופן כמו במדריך התצורה: החסם הממוצע וסטיית התקן שלו, ואחריהם חלקם של האיברים שהחסם שלהם הוא 0, עם חסם מחושב בין לבין, ושנשארו עם 2.0 הטריוויאלי.
from qiskit_addon_slc.bounds import (
compute_backward_bounds,
compute_forward_bounds,
)
forward_bounds = compute_forward_bounds(
boxed_circuit, noise_model_paulis, observable
)
stock_backward = compute_backward_bounds(boxed_circuit, noise_model_paulis)
custom_backward = compute_mirror_backward_bounds(
boxed_circuit, noise_model_paulis, observable
)
def summarize(label, bounds):
"""Prints a one-line summary of a set of bounds."""
rates = np.concatenate([bound.rates for bound in bounds.values()])
# The bounds are floating-point results, so a term counts as bounded by 0, or as left at the
# trivial 2.0, when it is within rounding error of that value.
exact = np.isclose(rates, 0.0, rtol=0.0, atol=1e-12)
trivial = np.isclose(rates, 2.0, rtol=0.0, atol=1e-12)
computed = ~exact & ~trivial
print(
f"{label:18} mean {rates.mean():.4f} ± {rates.std():.3f} │ "
f"at 0: {exact.mean():4.0%} computed: {computed.mean():4.0%} "
f"at 2.0: {trivial.mean():4.0%}"
)
summarize("forward", forward_bounds)
summarize("backward (stock)", stock_backward)
summarize("backward (custom)", custom_backward)
forward mean 0.4323 ± 0.679 │ at 0: 38% computed: 61% at 2.0: 1%
backward (stock) mean 1.7218 ± 0.592 │ at 0: 0% computed: 95% at 2.0: 5%
backward (custom) mean 0.4323 ± 0.679 │ at 0: 38% computed: 61% at 2.0: 1%
חסם האחורה הרגיל נמצא קרוב לערך הטריוויאלי 2.0, שכמעט לא נושא מידע. במעגל מראה, כל שגיאה היא "קרובה למצב ההתחלתי" במובן שהחסם הזה מודד, ולכן הוא לא יכול להבחין בין איברים. החסם המותאם אישית הדוק פי כמה, וחוסם איברים רבים ב-0.
כדי לבדוק את המימוש, אפשר להשוות בין השניים תיבה אחר תיבה. בכל תיבה, חסם האחורה המותאם אישית יוצא זהה לחסם הקדימה של אותה תיבה, עד כדי שגיאת עיגול. שניהם חוסמים את אותה שגיאה באותה נקודה במעגל, ובמעגל מראה השערים שאחרי אותה נקודה מתבטלים להופכי של השערים שלפניה, כך ששני החישובים מעריכים את אותו גודל משני הקצוות:
matches = sum(
np.allclose(forward_bounds[box_id].rates, custom_backward[box_id].rates)
for box_id in forward_bounds
)
print(f"{matches} of {len(forward_bounds)} boxes match the forward bounds")
worst = max(
np.abs(forward_bounds[box_id].rates - custom_backward[box_id].rates).max()
for box_id in forward_bounds
)
print(f"largest disagreement across all terms: {worst:.2e}")
16 of 16 boxes match the forward bounds
largest disagreement across all terms: 6.00e-15
הערה: השוויון הזה הוא תכונה של מבנה המראה, לא של הטכניקה. במעגל שאינו הופכי לעצמו, אותה החלפה עדיין רצה אבל כבר לא מחשבת חסם תקף לכיוון אחורה: היא עונה על שאלת הקדימה לגבי חצי מעגל שבו השאלה הזו לא חלה. השתמשו בזה רק כשהחצי השני של המעגל שלכם הופך את הראשון.
הזנה לשאר זרימת העבודה
התוצאה היא מילון Bounds רגיל, ולכן כל מה שבא אחר כך לא משתנה.
במעגל מראה גם אין סיבה לחשב אף אחד מהחסמים לכל המעגל. חסמי הקדימה נחוצים רק לתיבות של , בספירה מהסוף, וחסמי האחורה רק לתיבות של , בספירה מההתחלה. לכן max_num_boxes מגביל כל אחד מהם למחצית מהתיבות הרועשות, ו-merge_bounds מחבר את שני החצאים.
כך משתמשים בחסמים בפועל, וזה אומר שהשוואת תיבה-מול-תיבה שלמעלה כבר לא זמינה: היא צריכה את שני החסמים לכל תיבה. זו בדיקה של המימוש לצורך המדריך הזה, לא שלב בזרימת העבודה.
from qiskit.quantum_info import PauliLindbladMap, QubitSparsePauliList
from qiskit_addon_slc.bounds import compute_local_scales, merge_bounds
# Stand-in for rates that a noise-learning experiment would measure on hardware.
rng = np.random.default_rng(42)
noise_rates = {
layer: PauliLindbladMap.from_components(
rng.random(len(terms)) * 5e-3,
QubitSparsePauliList.from_sparse_list(
terms.to_sparse_list(), terms.num_qubits
),
)
for layer, terms in noise_model_paulis.items()
}
# Compute each bound for its own half of the mirror circuit only. `forward_bounds` has an entry
# for every noisy box, which are the boxes `max_num_boxes` counts.
half = len(forward_bounds) // 2
half_forward = compute_forward_bounds(
boxed_circuit, noise_model_paulis, observable, max_num_boxes=half
)
half_stock = compute_backward_bounds(
boxed_circuit, noise_model_paulis, max_num_boxes=half
)
half_custom = compute_mirror_backward_bounds(
boxed_circuit, noise_model_paulis, observable, max_num_boxes=half
)
print(
f"each bound computed for {half} of {len(forward_bounds)} noisy boxes\n"
)
print(f"{'bounds':34} {'overhead':>12} {'residual bias':>14}")
for label, bounds in (
(
"forward + stock backward",
merge_bounds(boxed_circuit, half_forward, half_stock, noise_rates),
),
(
"forward + custom backward",
merge_bounds(boxed_circuit, half_forward, half_custom, noise_rates),
),
("forward only, whole circuit", forward_bounds),
):
_, cost, bias = compute_local_scales(
boxed_circuit, bounds, noise_rates, bias_tolerance=0.05
)
print(f"{label:34} {cost:12.4f} {bias:14.4f}")
Optimal spacetime partitioning not implemented! Just partitioning list of noisy boxes.
Optimal spacetime partitioning not implemented! Just partitioning list of noisy boxes.
each bound computed for 8 of 16 noisy boxes
bounds overhead residual bias
forward + stock backward 29848.0583 0.0498
forward + custom backward 465.4028 0.0498
forward only, whole circuit 465.4028 0.0498
עם חסמי האחורה הרגילים תקורת הדגימה גדולה בהרבה. merge_bounds משתמש בחסמי האחורה עד נקודת מעבר אחת במעגל ובחסמי הקדימה מאותה נקודה ואילך, ובוחר את הנקודה שממזערת את ההטיה הכוללת. כשכל חסם מחושב עבור חצי אחד, הנקודה הזו נופלת באמצע המעגל, ולכן תיבות מכוסות בחסמי אחורה בלבד. חסם האחורה הרגיל קרוב שם ל-2.0 הטריוויאלי, מה שמעלה את התקורה. החסם המותאם אישית הדוק כמו שחסם הקדימה היה, ולכן התקורה זהה לזו של חסמי הקדימה המלאים.
זה מעלה את השאלה המתבקשת למה לטרוח, כשחסמי הקדימה המלאים לבדם נותנים את אותה תוצאה. התשובה היא עלות. חסם קדימה לתיבה מוקדמת במעגל מפתח את השגיאה שלה דרך שאר ואחר כך דרך כל , וזו ההתפתחות הארוכה והרחבה ביותר במעגל. האופרטור המתפתח יכול לגדול אקספוננציאלית במספר השערים שאינם Clifford שהוא עובר, וחישוב הנורמה הספקטרלית של הקומוטטור נעשה יקר אקספוננציאלית ככל שחרוט האור מתרחב. בגישת חצי-חצי אף שגיאה לא מתפתחת דרך יותר ממחצית המעגל.
לכן החיסכון גדל עם עומק המעגל. חשוב מזה, החסמים המלאים של הקדימה מפסיקים להיות ניתנים לחישוב הרבה לפני שחסמי החצי עושים זאת. ברגע שההתפתחויות של התיבות המוקדמות חורגות מ-evolution_max_terms או מ-eigval_max_qubits, החסמים שלהן נחתכים או נופלים לאי-שוויון המשולש, ובסופו של דבר החישוב שלהם יקר מדי. בכל מקום שבו הנקודה הזו נמצאת, גישת חצי-חצי צריכה רק שהיא תיפול איפשהו בחצי הראשון של המעגל. חסמי הקדימה צריכים להגיע לא רחוק יותר מהאמצע, ובמעגל מראה, חסמי האחורה לתיבות של מפתחים את השגיאות שלהן אחורה עד ההתחלה דרך אותו חלק מהמעגל שדרכו חסמי הקדימה לתיבות המתאימות של מפתחים אותן עד הסוף, ולכן הם נגמרים באותו עומק.
הערה: למה לחשב חסמי אחורה לתיבות של , כשנראה שחסמי הקדימה של תיבות אמורים להחזיק את אותם מספרים? כי התיבות אינן תמונות מראה זו של זו. כל תיבה מזריעה את הרעש שלה בצד אחד של השערים שלה, והשכבות ההופכיות של הן תיבות נפרדות עם מודלי רעש משלהן, ולכן התיבה שמשקפת תיבה של רואה את השגיאות שלה בנקודה אחרת במעגל. לכן החסמים שלהן מסכימים רק בחלקם, ואת החסמים לתיבות של צריך לחשב עבור אותן תיבות.
כתיבת חסמים מותאמים אישית אחרים
אותן שלוש החלפות מכסות מקרים אחרים:
-
נורמה אחרת.
time_evolved_norm_forwardמקבלcomm_norm_order; העברת כל ערך שאינו2מדלגת על פותר הערכים העצמיים ומחשבת את הנורמה הזו ישירות. -
חרוט אור אחר.
LightCone.initialize_from_pauliמזריע חרוט אור מכל Pauli, והבנאי שלLightConeמקבל את ה-Qubit למעקב ואת הפעולות שמזריעות אותו, כך שאפשר לחשב חסם מול כל אופרטור שאפשר לבטא בדרך הזו. -
פונקציית נורמה אחרת לגמרי.
compute_boundsדורש רק פונקציה שניתנת לקריאה: שמקבלתPauliו-RotationGatesומחזירהCommutatorBounds. כל דבר שעומד בחתימה הזו עובד, כולל חסם בצורה סגורה הייחודי למשפחת המעגלים שלכם.
מה שלא תחליפו, אתם חייבים להוכיח שהתוצאה היא חסם עליון אמיתי. המעטה בהשפעה של איבר שגיאה לא גורמת לכשל נראה לעין. היא מפיקה ערך תוחלת שגוי בשקט, כי האיבר ייזרק ממודל הרעש המוקל.