How to tighten bounds with a speed limit¶
Information in a circuit of two-qubit gates cannot spread arbitrarily fast. An error occurring several layers before the end, on a qubit far from the observable’s support, has no path to reach it. tighten_with_speed_limit turns that geometric argument (the same intuition behind the Lieb-Robinson bounds) into tighter forward bounds.
The step is cheap, needs nothing beyond what the forward bounds already required, and can only make bounds tighter. There is no accuracy trade-off to weigh.
The question worth answering first is “when does it help?”, because it doesn’t often help on a fully converged set of forward bounds.
Setup¶
Use the same Trotterized Ising circuit as the quickstart guide, with the observable on a single qubit in the middle of the chain. The 1D geometry makes the effect easy to reason about: an influence has to traverse the chain one bond per layer.
[1]:
import numpy as np
from qiskit import QuantumCircuit
from qiskit.quantum_info import Pauli
from qiskit_addon_slc.bounds import compute_forward_bounds, tighten_with_speed_limit
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
circuit = trotter_ising_circuit(num_qubits, num_steps=4, rx_angle=np.pi / 16, rzz_angle=-np.pi / 3)
# A single-qubit observable in the middle of the chain.
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))
def compare(label, bounds):
"""Tightens a set of bounds and prints what changed."""
tightened = tighten_with_speed_limit(bounds, boxed_circuit, noise_model_paulis, observable)
before = np.concatenate([bound.rates for bound in bounds.values()])
after = np.concatenate([bound.rates for bound in tightened.values()])
print(
f"{label:30} mean {before.mean():.4f} -> {after.mean():.4f} "
f"tightened {np.count_nonzero(after < before - 1e-12):4} of {len(before)} "
f"loosened {np.count_nonzero(after > before + 1e-12)}"
)
print(f"observable: Z on qubit {num_qubits // 2}, circuit depth {circuit.depth()}")
observable: Z on qubit 5, circuit depth 12
When it helps, and when it does not¶
tighten_with_speed_limit takes the bounds you already have and returns a tightened copy; your original bounds are never modified. How much it gains depends entirely on how loose the input was.
[2]:
# 1. Fully computed forward bounds.
full_bounds = compute_forward_bounds(boxed_circuit, noise_model_paulis, observable)
compare("fully computed", full_bounds)
# 2. Bounds where the eigenvalue step was skipped, leaving the looser triangle inequality.
loose_bounds = compute_forward_bounds(
boxed_circuit, noise_model_paulis, observable, eigval_max_qubits=0
)
compare("triangle inequality only", loose_bounds)
# 3. Bounds computed only for the first few boxes, the rest left at the trivial bound of 2.0.
shallow_bounds = compute_forward_bounds(
boxed_circuit, noise_model_paulis, observable, max_num_boxes=2
)
compare("max_num_boxes=2", shallow_bounds)
fully computed mean 0.3639 -> 0.3639 tightened 0 of 792 loosened 0
triangle inequality only mean 0.5081 -> 0.4842 tightened 105 of 792 loosened 0
max_num_boxes=2 mean 1.5707 -> 0.5284 tightened 450 of 792 loosened 0
The first line is the one to notice. On fully computed forward bounds, the speed limit changes nothing. That is not a failure. It means the bounds were already at least as tight as the geometric argument can prove, because the operator evolution has effectively discovered the light cone for itself.
The gains appear where the bounds were not fully computed:
eigval_max_qubits=0forces the triangle-inequality fallback, so the bounds no longer reflect the commutator’s actual spectral norm. The speed limit recovers part of that loss.max_num_boxes=2leaves most layers at the trivial bound of 2.0 (a bound that encodes no information). Here, the speed limit is helpful, cutting the mean by roughly a factor of three.
In every case the “loosened” column is zero. No term is ever made worse, which is the guarantee that makes the step safe to apply unconditionally.
What it buys in sampling overhead¶
Tighter bounds mean there are fewer error terms that need mitigating for a given bias tolerance, which is the number that matters on hardware.
[3]:
from qiskit.quantum_info import PauliLindbladMap, QubitSparsePauliList
from qiskit_addon_slc.bounds import compute_local_scales
# 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()
}
tightened_shallow = tighten_with_speed_limit(
shallow_bounds, boxed_circuit, noise_model_paulis, observable
)
print(f"{'bounds':30} {'overhead':>9} {'mitigated':>10} {'residual bias':>14}")
for label, bounds in (
("max_num_boxes=2", shallow_bounds),
("max_num_boxes=2 + speed limit", tightened_shallow),
("fully computed", full_bounds),
):
scales, cost, bias = compute_local_scales(
boxed_circuit, bounds, noise_rates, bias_tolerance=0.05
)
mitigated = sum(int(np.count_nonzero(scale)) for scale in scales.values())
total = sum(len(scale) for scale in scales.values())
print(f"{label:30} {cost:9.4f} {f'{mitigated}/{total}':>10} {bias:>14.4f}")
bounds overhead mitigated residual bias
max_num_boxes=2 420.2823 617/792 0.0490
max_num_boxes=2 + speed limit 7.8511 226/792 0.0485
fully computed 5.4031 188/792 0.0494
When to use it¶
Apply it to forward bounds whenever those bounds are not fully converged (essentially, whenever you have traded accuracy for speed by using any of the options in the configuration guide). It is cheap compared to the bounds computation, cannot make anything worse, and recovers much of what a max_num_boxes or eigval_max_qubits limit gave away.
On fully computed bounds it is a no-op, so there is no harm in applying it unconditionally.
Two limitations:
It applies to forward bounds only. Backward bounds concern the error’s effect on the initial state rather than on the observable, so the argument does not carry over.
The observable must be a single Pauli term. For an observable with several terms, call this once per Pauli and combine the results yourself. If the observable is not a single Pauli term, it raises a
NotImplementedError.
In the standard workflow, it belongs between computing the forward bounds and merging them with the backward bounds, as the quickstart guide shows.