{ "cells": [ { "cell_type": "markdown", "id": "cell-00", "metadata": {}, "source": [ "# How to tighten bounds with a speed limit\n", "\n", "Information in a circuit of two-qubit gates cannot spread arbitrarily fast. An error occurring\n", "several layers before the end, on a qubit far from the observable's support, has no path to reach\n", "it. `tighten_with_speed_limit` turns that geometric argument (the same intuition behind the\n", "Lieb-Robinson bounds) into tighter forward bounds.\n", "\n", "The step is cheap, needs nothing beyond what the forward bounds already required, and can only make\n", "bounds tighter. There is no accuracy trade-off to weigh.\n", "\n", "The question worth answering first is *\"when does it help?\"*, because it doesn't often help on a fully converged set of forward\n", "bounds." ] }, { "cell_type": "markdown", "id": "cell-01", "metadata": {}, "source": [ "## Setup\n", "\n", "Use the same Trotterized Ising circuit as the [quickstart guide](quickstart.ipynb), with the observable\n", "on a single qubit in the middle of the chain. The 1D geometry makes the effect easy to reason about: an influence has\n", "to traverse the chain one bond per layer." ] }, { "cell_type": "code", "execution_count": 1, "id": "cell-02", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "observable: Z on qubit 5, circuit depth 12\n" ] } ], "source": [ "import numpy as np\n", "from qiskit import QuantumCircuit\n", "from qiskit.quantum_info import Pauli\n", "from qiskit_addon_slc.bounds import compute_forward_bounds, tighten_with_speed_limit\n", "from qiskit_addon_slc.utils import generate_noise_model_paulis\n", "from samplomatic.transpiler import generate_boxing_pass_manager\n", "from samplomatic.utils import find_unique_box_instructions\n", "\n", "\n", "def trotter_ising_circuit(num_qubits, num_steps, rx_angle, rzz_angle):\n", " \"\"\"Trotterized transverse-field Ising evolution on a 1D chain.\"\"\"\n", " circuit = QuantumCircuit(num_qubits)\n", " for _ in range(num_steps):\n", " circuit.rx(rx_angle, range(num_qubits))\n", " circuit.barrier()\n", " for start in (0, 1): # even then odd bonds\n", " for i in range(start, num_qubits - 1, 2):\n", " circuit.rzz(rzz_angle, i, i + 1)\n", " circuit.barrier()\n", " return circuit\n", "\n", "\n", "num_qubits = 10\n", "circuit = trotter_ising_circuit(num_qubits, num_steps=4, rx_angle=np.pi / 16, rzz_angle=-np.pi / 3)\n", "\n", "# A single-qubit observable in the middle of the chain.\n", "observable = Pauli(\"I\" * num_qubits).compose(\"Z\", [num_qubits // 2])\n", "\n", "boxing_pass = generate_boxing_pass_manager(\n", " inject_noise_targets=\"all\",\n", " inject_noise_strategy=\"individual_modification\",\n", " inject_noise_site=\"after\",\n", " twirling_strategy=\"active\",\n", " remove_barriers=\"never\",\n", ")\n", "boxed_circuit = boxing_pass.run(circuit)\n", "noise_model_paulis = generate_noise_model_paulis(find_unique_box_instructions(boxed_circuit))\n", "\n", "\n", "def compare(label, bounds):\n", " \"\"\"Tightens a set of bounds and prints what changed.\"\"\"\n", " tightened = tighten_with_speed_limit(bounds, boxed_circuit, noise_model_paulis, observable)\n", " before = np.concatenate([bound.rates for bound in bounds.values()])\n", " after = np.concatenate([bound.rates for bound in tightened.values()])\n", " print(\n", " f\"{label:30} mean {before.mean():.4f} -> {after.mean():.4f} \"\n", " f\"tightened {np.count_nonzero(after < before - 1e-12):4} of {len(before)} \"\n", " f\"loosened {np.count_nonzero(after > before + 1e-12)}\"\n", " )\n", "\n", "\n", "print(f\"observable: Z on qubit {num_qubits // 2}, circuit depth {circuit.depth()}\")" ] }, { "cell_type": "markdown", "id": "cell-03", "metadata": {}, "source": [ "## When it helps, and when it does not\n", "\n", "`tighten_with_speed_limit` takes the bounds you already have and returns a tightened copy; your\n", "original bounds are never modified. How much it gains depends entirely on how loose the input was." ] }, { "cell_type": "code", "execution_count": 2, "id": "cell-04", "metadata": { "scrolled": true }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "fully computed mean 0.3639 -> 0.3639 tightened 0 of 792 loosened 0\n", "triangle inequality only mean 0.5081 -> 0.4842 tightened 105 of 792 loosened 0\n", "max_num_boxes=2 mean 1.5707 -> 0.5284 tightened 450 of 792 loosened 0\n" ] } ], "source": [ "# 1. Fully computed forward bounds.\n", "full_bounds = compute_forward_bounds(boxed_circuit, noise_model_paulis, observable)\n", "compare(\"fully computed\", full_bounds)\n", "\n", "# 2. Bounds where the eigenvalue step was skipped, leaving the looser triangle inequality.\n", "loose_bounds = compute_forward_bounds(\n", " boxed_circuit, noise_model_paulis, observable, eigval_max_qubits=0\n", ")\n", "compare(\"triangle inequality only\", loose_bounds)\n", "\n", "# 3. Bounds computed only for the first few boxes, the rest left at the trivial bound of 2.0.\n", "shallow_bounds = compute_forward_bounds(\n", " boxed_circuit, noise_model_paulis, observable, max_num_boxes=2\n", ")\n", "compare(\"max_num_boxes=2\", shallow_bounds)" ] }, { "cell_type": "markdown", "id": "cell-05", "metadata": {}, "source": [ "The first line is the one to notice. On fully computed forward bounds, the speed limit changes\n", "nothing. That is not a failure. It means the bounds were already at least as tight as the geometric\n", "argument can prove, because the operator evolution has effectively discovered the light cone for\n", "itself.\n", "\n", "The gains appear where the bounds were *not* fully computed:\n", "\n", "- `eigval_max_qubits=0` forces the triangle-inequality fallback, so the bounds no longer reflect the\n", " commutator's actual spectral norm. The speed limit recovers part of that loss.\n", "- `max_num_boxes=2` leaves most layers at the trivial bound of 2.0 (a bound that encodes no\n", " information). Here, the speed limit is helpful, cutting the mean by roughly\n", " a factor of three.\n", "\n", "In every case the \"loosened\" column is zero. No term is ever made worse, which is the guarantee that\n", "makes the step safe to apply unconditionally." ] }, { "cell_type": "markdown", "id": "cell-09", "metadata": {}, "source": [ "## What it buys in sampling overhead\n", "\n", "Tighter bounds mean there are fewer error terms that need mitigating for a given bias tolerance, which is the\n", "number that matters on hardware." ] }, { "cell_type": "code", "execution_count": 3, "id": "cell-10", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "bounds overhead mitigated residual bias\n", "max_num_boxes=2 420.2823 617/792 0.0490\n", "max_num_boxes=2 + speed limit 7.8511 226/792 0.0485\n", "fully computed 5.4031 188/792 0.0494\n" ] } ], "source": [ "from qiskit.quantum_info import PauliLindbladMap, QubitSparsePauliList\n", "from qiskit_addon_slc.bounds import compute_local_scales\n", "\n", "# Stand-in for rates that a noise-learning experiment would measure on hardware.\n", "rng = np.random.default_rng(42)\n", "noise_rates = {\n", " layer: PauliLindbladMap.from_components(\n", " rng.random(len(terms)) * 5e-3,\n", " QubitSparsePauliList.from_sparse_list(terms.to_sparse_list(), terms.num_qubits),\n", " )\n", " for layer, terms in noise_model_paulis.items()\n", "}\n", "\n", "tightened_shallow = tighten_with_speed_limit(\n", " shallow_bounds, boxed_circuit, noise_model_paulis, observable\n", ")\n", "\n", "print(f\"{'bounds':30} {'overhead':>9} {'mitigated':>10} {'residual bias':>14}\")\n", "for label, bounds in (\n", " (\"max_num_boxes=2\", shallow_bounds),\n", " (\"max_num_boxes=2 + speed limit\", tightened_shallow),\n", " (\"fully computed\", full_bounds),\n", "):\n", " scales, cost, bias = compute_local_scales(\n", " boxed_circuit, bounds, noise_rates, bias_tolerance=0.05\n", " )\n", " mitigated = sum(int(np.count_nonzero(scale)) for scale in scales.values())\n", " total = sum(len(scale) for scale in scales.values())\n", " print(f\"{label:30} {cost:9.4f} {f'{mitigated}/{total}':>10} {bias:>14.4f}\")" ] }, { "cell_type": "markdown", "id": "cell-11", "metadata": {}, "source": [ "## When to use it\n", "\n", "Apply it to forward bounds whenever those bounds are not fully converged (essentially, \n", "whenever you have traded accuracy for speed by using any of the options in the\n", "[configuration guide](configuration_options.ipynb)). It is cheap compared to the bounds computation,\n", "cannot make anything worse, and recovers much of what a `max_num_boxes` or `eigval_max_qubits` limit\n", "gave away.\n", "\n", "On fully computed bounds it is a no-op, so there is no harm in applying it unconditionally.\n", "\n", "Two limitations:\n", "\n", "- It applies to **forward** bounds only. Backward bounds concern the error's effect on the initial\n", " state rather than on the observable, so the argument does not carry over.\n", "- The observable must be a **single Pauli term**. For an observable with several terms, call this\n", " once per Pauli and combine the results yourself. If the observable is not a single Pauli term, it raises a `NotImplementedError`.\n", "\n", "In the standard workflow, it belongs between computing the forward bounds and merging them with the\n", "backward bounds, as the [quickstart guide](quickstart.ipynb) shows." ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.13.15" } }, "nbformat": 4, "nbformat_minor": 5 }