diff --git a/docs/tutorials/_toc.json b/docs/tutorials/_toc.json
index 01e117d2065..e63172954fe 100644
--- a/docs/tutorials/_toc.json
+++ b/docs/tutorials/_toc.json
@@ -221,6 +221,10 @@
"title": "Combine error mitigation options with the Estimator primitive",
"url": "/docs/tutorials/combine-error-mitigation-techniques"
},
+ {
+ "title": "Improving expectation values with propagated noise absorption (PNA)",
+ "url": "/docs/tutorials/propagated-noise-absorption"
+ },
{
"title": "Real-time benchmarking for qubit selection",
"url": "/docs/tutorials/real-time-benchmarking-for-qubit-selection"
diff --git a/docs/tutorials/index.mdx b/docs/tutorials/index.mdx
index e085cd74905..147918095b2 100644
--- a/docs/tutorials/index.mdx
+++ b/docs/tutorials/index.mdx
@@ -157,6 +157,8 @@ Error mitigation addresses the challenge of noise without full fault tolerance b
* [Combine error mitigation options with the Estimator primitive](/docs/tutorials/combine-error-mitigation-techniques)
+* [Improving expectation values with propagated noise absorption (PNA)](/docs/tutorials/propagated-noise-absorption)
+
* [Real-time benchmarking for qubit selection](/docs/tutorials/real-time-benchmarking-for-qubit-selection)
diff --git a/docs/tutorials/propagated-noise-absorption.ipynb b/docs/tutorials/propagated-noise-absorption.ipynb
new file mode 100644
index 00000000000..be74b204b66
--- /dev/null
+++ b/docs/tutorials/propagated-noise-absorption.ipynb
@@ -0,0 +1,1118 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "727f8133-a32e-4bd9-a6c3-1a1c580e06bd",
+ "metadata": {},
+ "source": [
+ "---\n",
+ "title: Improving expectation values with propagated noise absorption (PNA)\n",
+ "description: Use propagated noise absorption (PNA) with samplomatic and the Executor primitive to mitigate two-qubit gate noise in expectation value estimation\n",
+ "---\n",
+ "\n",
+ "{/* cspell:ignore samplomatic samplex Trotterized Lindblad pstr expvals postsel undress reverser broadcastable kicked qwc Samplomatic anti pauli unmitigated PauliLindblad NoiseLearner magnetization fontsize borderaxespad reweighting */}\n",
+ "\n",
+ "# Improving expectation values with propagated noise absorption (PNA)\n",
+ "\n",
+ "*Usage estimate: 10 minutes on an IBM Heron processor (NOTE: This is an estimate only. Your runtime might vary.)*"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a1b2c3d4-0001-4000-8000-000000000001",
+ "metadata": {},
+ "source": [
+ "## Learning outcomes\n",
+ "After going through this tutorial, users should understand:\n",
+ "- What propagated noise absorption (PNA) is, and how it mitigates two-qubit gate noise by absorbing learned inverse noise channels into the measured observable\n",
+ "- How to use [`samplomatic`](https://github.com/Qiskit/samplomatic) to box and annotate circuit layers for twirling, basis changes, and noise injection\n",
+ "- How to learn layer noise with `NoiseLearnerV3` and propagate it into a noise-mitigating observable with [`qiskit-addon-pna`](https://github.com/Qiskit/qiskit-addon-pna)\n",
+ "- How to sample randomized circuits with the `QuantumProgram` and `Executor` classes in Qiskit Runtime, and combine PNA with TREX and post-selection\n",
+ "\n",
+ "## Prerequisites\n",
+ "We suggest that users are familiar with the following topics before going through this tutorial:\n",
+ "- The [Qiskit patterns](/docs/guides/intro-to-patterns) workflow\n",
+ "- Using the [Estimator](/docs/api/qiskit-ibm-runtime/estimator-v2) primitive to calculate expectation values of an observable\n",
+ "- Error mitigation techniques such as Pauli twirling and TREX, covered in [Combine error mitigation options with the Estimator primitive](/docs/tutorials/combine-error-mitigation-techniques)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a1b2c3d4-0002-4000-8000-000000000002",
+ "metadata": {},
+ "source": [
+ "## Background\n",
+ "\n",
+ "In this tutorial, we demonstrate how to leverage advanced error-mitigation tools in Qiskit to improve expectation value estimation in noisy quantum experiments.\n",
+ "\n",
+ "### What is propagated noise absorption (PNA)?\n",
+ "\n",
+ "*Propagated noise absorption is a technique for mitigating gate errors by propagating the observable through the inverse noise channel affecting two-qubit gates, resulting in a noise-mitigating observable.*\n",
+ "\n",
+ "Quantum error mitigation enables us to extract useful expectation values from noisy quantum hardware without requiring full fault tolerance. PNA specifically focuses on absorbing noise effects into the observable itself rather than modifying the circuit operations.\n",
+ "\n",
+ "Each noisy gate in a quantum circuit can be modeled as an ideal gate followed by a noise channel. PNA learns or characterizes these noise channels and defines their inverses. Instead of inserting the inverse operations into the hardware execution (which is typically infeasible), PNA propagates the inverse noise channels forward through the circuit and applies them to the observable. This process transforms the observable $O$ into a new operator $\\tilde{O}$, such that measuring $\\tilde{O}$ on the noisy circuit yields the same expectation value as measuring $O$ on an ideal, noise-free circuit.\n",
+ "\n",
+ "This can be summarized as:\n",
+ "1. Model each noisy gate $u_i$ as an ideal operation followed by a noise channel $\\Lambda_i$.\n",
+ "2. Learn or estimate each $\\Lambda_i$ using noise characterization tools.\n",
+ "3. Define and propagate the inverse noise maps $\\Lambda_i^{-1}$ forward through the circuit using Pauli transfer techniques.\n",
+ "4. Absorb these inverses into the observable, resulting in a noise-mitigated operator $\\tilde{O}$.\n",
+ "\n",
+ "When the noise is described as a Pauli channel (or more generally as a sparse Pauli-Lindblad channel), this propagation can be performed efficiently using Pauli propagation. Pauli propagation provides a framework for approximating how inverse noise channels transform as they pass through layers of Clifford and non-Clifford operations, while controlling the computational complexity.\n",
+ "\n",
+ "By shifting the mitigation into the observable, PNA avoids the large sampling overhead that would otherwise come from inserting physical correction operations into the circuit. Instead, the original noisy circuit is executed, while the observable is transformed into a new operator $\\tilde{O}$ whose expectation value cancels the effects of noise.\n",
+ "\n",
+ "### Visualizing the PNA workflow\n",
+ "\n",
+ "The process can be understood through the following conceptual stages. The first schematic illustrates a standard noisy experiment.\n",
+ "\n",
+ "\n",
+ "\n",
+ "If we learn the noise model, we can apply its inverse and cancel the noise.\n",
+ "\n",
+ "\n",
+ "\n",
+ "Instead of implementing the inverse noise channel by sampling it on the QPU as in probabilistic error cancellation (PEC), we apply it classically to the measured observable using Pauli propagation. The resulting observable effectively mitigates the learned gate noise when measured.\n",
+ "\n",
+ "\n",
+ "\n",
+ "### Modular error mitigation with samplomatic and the Executor\n",
+ "\n",
+ "The PNA approach builds upon a modular error-mitigation architecture in Qiskit. This architecture uses the [`samplomatic`](https://github.com/Qiskit/samplomatic) library together with the `QuantumProgram` and `Executor` classes (added to Qiskit Runtime in `qiskit-ibm-runtime` 0.47.0) to make techniques such as propagated noise absorption and Pauli twirling composable and reusable across different experiments.\n",
+ "\n",
+ "Rather than embedding the mitigation logic inside the circuit definition itself, the mitigation is expressed declaratively through `samplomatic` annotations and managed programmatically through the `Executor`, which controls how randomized circuits are generated, executed, and post-processed.\n",
+ "\n",
+ "In this tutorial we implement a [Qiskit pattern](/docs/guides/intro-to-patterns) to demonstrate how PNA can propagate inverse Pauli noise channels and modify the observable accordingly, enabling improved estimation of expectation values on noisy QPUs.\n",
+ "\n",
+ "### Workflow overview\n",
+ "\n",
+ "- **Step 1: Map to the quantum problem**\n",
+ " - Construct a mirrored Trotterized kicked-Ising model and a target observable.\n",
+ "- **Step 2: Characterize and propagate noise**\n",
+ " - Use `samplomatic` to identify and annotate unique two-qubit layers and measurements in the circuit.\n",
+ " - Learn the noise affecting each unique layer using `NoiseLearnerV3`.\n",
+ " - Map each `InjectNoise` annotation to its corresponding learned noise model.\n",
+ " - Use `qiskit-addon-pna` to propagate the inverse noise channels forward through the circuit and absorb them into the target observable.\n",
+ "- **Step 3: Execute quantum experiments**\n",
+ " - Define a `QuantumProgram` to specify randomized sampling via `samplex` and execute the experiments on the backend using the `Executor`.\n",
+ "- **Step 4: Reconstruct and analyze results**\n",
+ " - Compare mitigation strategies (PNA, PNA+TREX, PNA+PS, PNA+PS+TREX) and visualize the improvement over unmitigated results."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "3b64ad83-0000-4000-8000-000000000003",
+ "metadata": {},
+ "source": [
+ "## Requirements\n",
+ "Before starting this tutorial, be sure you have the following installed:\n",
+ "\n",
+ "- Qiskit SDK v2.2 or later, with [visualization](/docs/api/qiskit/visualization) support (`pip install 'qiskit[visualization]'`)\n",
+ "- Qiskit Runtime v0.47 or later (`pip install qiskit-ibm-runtime`)\n",
+ "- Samplomatic v0.13 or later (`pip install samplomatic`)\n",
+ "- PNA Qiskit addon (`pip install qiskit-addon-pna`)\n",
+ "- Qiskit addon utils (`pip install qiskit-addon-utils`)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "28ad8a65-0000-4000-8000-000000000004",
+ "metadata": {},
+ "source": [
+ "## Setup"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "3a851a58-0000-4000-8000-000000000005",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from qiskit import QuantumCircuit\n",
+ "from qiskit.quantum_info import Pauli, SparsePauliOp\n",
+ "from qiskit.transpiler import generate_preset_pass_manager, PassManager\n",
+ "from qiskit_ibm_runtime import (\n",
+ " QiskitRuntimeService,\n",
+ " QuantumProgram,\n",
+ " Executor,\n",
+ " NoiseLearnerV3,\n",
+ ")\n",
+ "from qiskit_addon_utils.exp_vals.measurement_bases import (\n",
+ " get_measurement_bases,\n",
+ ")\n",
+ "from qiskit_addon_utils.exp_vals.expectation_values import (\n",
+ " executor_expectation_values,\n",
+ ")\n",
+ "from qiskit_addon_utils.noise_management import trex_factors\n",
+ "from qiskit_addon_utils.noise_management.post_selection import PostSelector\n",
+ "from qiskit_addon_utils.noise_management.post_selection.transpiler.passes import (\n",
+ " AddPostSelectionMeasures,\n",
+ " AddSpectatorMeasures,\n",
+ ")\n",
+ "from qiskit_addon_pna import generate_noise_mitigating_observable\n",
+ "import samplomatic\n",
+ "from samplomatic.transpiler import generate_boxing_pass_manager\n",
+ "from samplomatic.annotations import InjectNoise\n",
+ "from samplomatic.utils import get_annotation, find_unique_box_instructions\n",
+ "import numpy as np\n",
+ "import matplotlib.pyplot as plt\n",
+ "\n",
+ "\n",
+ "# Selects a connected chain of low-error qubits on the target backend.\n",
+ "# The mirrored kicked-Ising circuit is a 1D chain, so we only need a line\n",
+ "# of connected physical qubits; the helper walks the backend's coupling map\n",
+ "# and grows a chain along the lowest-error two-qubit edges, so it works for\n",
+ "# any backend rather than relying on a hardcoded layout.\n",
+ "def find_qubit_chain(backend, length):\n",
+ " \"\"\"Find a connected chain of ``length`` physical qubits on ``backend``.\n",
+ "\n",
+ " The chain is grown greedily along the lowest-error two-qubit edges, so it\n",
+ " favors better-performing qubits. Because the mirrored kicked-Ising circuit\n",
+ " is a 1D chain, a connected line is all we need.\n",
+ " \"\"\"\n",
+ " target = backend.target\n",
+ "\n",
+ " # Identify the native two-qubit gate and build a per-edge error lookup.\n",
+ " two_qubit_gate = next(\n",
+ " name\n",
+ " for name in target.operation_names\n",
+ " if target[name]\n",
+ " and all(q is not None and len(q) == 2 for q in target[name])\n",
+ " )\n",
+ " edge_error = {\n",
+ " frozenset(qargs): (\n",
+ " 1.0 if props is None or props.error is None else props.error\n",
+ " )\n",
+ " for qargs, props in target[two_qubit_gate].items()\n",
+ " }\n",
+ "\n",
+ " graph = backend.coupling_map.graph.to_undirected(multigraph=False)\n",
+ " neighbors = {n: list(graph.neighbors(n)) for n in graph.node_indices()}\n",
+ "\n",
+ " def first_chain_from(start):\n",
+ " path, visited = [start], {start}\n",
+ "\n",
+ " def grow():\n",
+ " if len(path) == length:\n",
+ " return True\n",
+ " node = path[-1]\n",
+ " order = sorted(\n",
+ " neighbors[node],\n",
+ " key=lambda m: edge_error.get(frozenset((node, m)), 1.0),\n",
+ " )\n",
+ " for nxt in order:\n",
+ " if nxt not in visited:\n",
+ " visited.add(nxt)\n",
+ " path.append(nxt)\n",
+ " if grow():\n",
+ " return True\n",
+ " path.pop()\n",
+ " visited.remove(nxt)\n",
+ " return False\n",
+ "\n",
+ " return path if grow() else None\n",
+ "\n",
+ " def chain_cost(path):\n",
+ " return sum(\n",
+ " edge_error.get(frozenset((path[i], path[i + 1])), 1.0)\n",
+ " for i in range(len(path) - 1)\n",
+ " )\n",
+ "\n",
+ " # Try low-degree qubits first (the natural ends of long chains) and keep\n",
+ " # the lowest-error chain found.\n",
+ " best_path, best_cost = None, float(\"inf\")\n",
+ " for start in sorted(neighbors, key=lambda n: len(neighbors[n])):\n",
+ " chain = first_chain_from(start)\n",
+ " if chain is not None and (cost := chain_cost(chain)) < best_cost:\n",
+ " best_path, best_cost = chain, cost\n",
+ "\n",
+ " if best_path is None:\n",
+ " raise ValueError(\n",
+ " f\"Could not find a connected chain of {length} qubits \"\n",
+ " f\"on '{backend.name}'.\"\n",
+ " )\n",
+ " return best_path"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a1b2c3d4-0006-4000-8000-000000000006",
+ "metadata": {},
+ "source": [
+ "## Small-scale simulator example\n",
+ "\n",
+ "PNA mitigates the *physical* two-qubit gate noise of a specific quantum processor. The workflow depends on two hardware services that have no meaningful analog on an ideal simulator:\n",
+ "\n",
+ "- `NoiseLearnerV3` experimentally characterizes the sparse Pauli-Lindblad noise channel attached to each unique two-qubit layer of the transpiled circuit. On a noiseless simulator there is no noise to learn, and the propagated inverse channel would be the identity.\n",
+ "- The `Executor` primitive samples the twirled, randomized circuits generated by `samplomatic` on a backend.\n",
+ "\n",
+ "In principle you could substitute a synthetic noise model. For example, you could attach `PauliLindbladError` instructions with [Qiskit Aer](https://github.com/Qiskit/qiskit-aer) and pass the resulting noisy circuit directly to `generate_noise_mitigating_observable`. However, this only validates the classical bookkeeping against noise you injected yourself, and obscures the point of the technique. For that reason we skip the simulator example and demonstrate the full PNA workflow directly on hardware, with each step of the Qiskit pattern broken out below."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "306f6858-0000-4000-8000-000000000007",
+ "metadata": {},
+ "source": [
+ "## Large-scale hardware example\n",
+ "\n",
+ "We now run the complete PNA workflow on a 30-site kicked Ising model executed on IBM Quantum hardware, following the four steps of a Qiskit pattern.\n",
+ "\n",
+ "### Step 1: Map to a quantum problem\n",
+ "\n",
+ "#### Generate the mirrored Trotter circuit and observable\n",
+ "\n",
+ "For this experiment, we will study the time dynamics of a 30-site kicked Ising model on a 1D spin chain. The Hamiltonian considered is:\n",
+ "\n",
+ "$H = -J\\sum\\limits_{\\langle i,j \\rangle} Z_iZ_j + h\\sum\\limits_iX_i$,\n",
+ "\n",
+ "where $J>0$ describes the coupling of nearest-neighbor spins, $i"
+ ]
+ },
+ "execution_count": 2,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "num_qubits = 30\n",
+ "num_trotter_steps = 10\n",
+ "rx_angle = np.pi / 8\n",
+ "\n",
+ "# Avg single-site magnetization\n",
+ "id_pauli = Pauli(\"I\" * num_qubits)\n",
+ "observable = (\n",
+ " SparsePauliOp([id_pauli.dot(Pauli(\"Z\"), [i]) for i in range(num_qubits)])\n",
+ " / num_qubits\n",
+ ")\n",
+ "\n",
+ "# Implement Trotterized kicked-Ising model\n",
+ "circuit = QuantumCircuit(num_qubits)\n",
+ "for _step in range(num_trotter_steps):\n",
+ " circuit.rx(rx_angle, range(num_qubits))\n",
+ " for first_qubit in (1, 2):\n",
+ " for idx in range(first_qubit, num_qubits, 2):\n",
+ " # equivalent to Rzz(-pi/2):\n",
+ " circuit.sdg([idx - 1, idx])\n",
+ " circuit.cz(idx - 1, idx)\n",
+ "# Append the inverse circuit to complete the mirroring\n",
+ "circuit.compose(circuit.inverse(), inplace=True)\n",
+ "circuit.measure_active()\n",
+ "circuit.draw(\"mpl\", fold=-1)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c4216992-0000-4000-8000-000000000008",
+ "metadata": {},
+ "source": [
+ "### Step 2: Optimize the problem for hardware execution\n",
+ "\n",
+ "The next step is to prepare our mirrored Trotter circuit for execution on real IBM Quantum hardware. Running on a QPU requires more than just constructing the abstract circuit, as we need to optimize it so that:\n",
+ "\n",
+ "1. **It respects the backend's native gate set and connectivity.**\n",
+ " Transpilation maps the logical circuit into an ISA circuit supported by the target backend. This ensures every gate and qubit interaction is physically realizable.\n",
+ "\n",
+ "2. **We can characterize the noise at the level of circuit layers.**\n",
+ " PNA relies on learning and propagating inverse noise channels. To do this efficiently, we break the transpiled circuit into unique two-qubit \"boxed\" layers. These boxes allow us to associate each layer of the circuit with its own learned noise model.\n",
+ "\n",
+ "3. **We can feed realistic noise models into PNA.**\n",
+ " Once the circuit has been boxed, we use the `NoiseLearnerV3` service to experimentally learn the Pauli noise channels affecting each unique two-qubit layer. These learned models are then linked back to the circuit through `samplomatic`'s annotations.\n",
+ "\n",
+ "Altogether, this step bridges the gap between an idealized mirrored circuit and a hardware-ready circuit with learned noise models. With this setup, PNA can propagate inverse noise channels through the circuit and adjust the observable accordingly.\n",
+ "\n",
+ "#### Connect to the backend and transpile to an ISA circuit\n",
+ "\n",
+ "We first initialize the Qiskit Runtime service and select a backend. Authenticate with your own account by following the [instructions to save your credentials](/docs/guides/cloud-setup), after which `QiskitRuntimeService()` will pick them up automatically."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5c6ee966-0000-4000-8000-000000000009",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Selected backend: ibm_pittsburgh\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Initialize the Qiskit Runtime service using your saved credentials\n",
+ "service = QiskitRuntimeService()\n",
+ "\n",
+ "backend = service.least_busy(\n",
+ " operational=True, simulator=False, min_num_qubits=num_qubits\n",
+ ")\n",
+ "# Re-fetch with fractional gates enabled (least_busy does not forward this)\n",
+ "# Fractional gates are enabled so the non-Clifford Rx rotations are supported natively.\n",
+ "backend = service.backend(backend.name, use_fractional_gates=True)\n",
+ "print(f\"Selected backend: {backend.name}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "bd2cea49-0000-4000-8000-000000000010",
+ "metadata": {},
+ "source": [
+ "Next, we choose a connected chain of qubits on the backend and transpile the circuit onto it.\n",
+ "\n",
+ "Using the `find_qubit_chain` helper defined in the Setup section, we select a line of `num_qubits` connected physical qubits. We then transpile with `optimization_level=0` and pin this chain as the `initial_layout`, which preserves the two-qubit-gate layer structure of the mirrored circuit exactly. That structure is what the boxing and noise-learning steps rely on, so a higher optimization level (which would cancel the mirrored gates) must be avoided here."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "8c9afe5e-6fa3-427d-a672-becdb14490b8",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 4,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# Find a connected, low-error chain of qubits on the chosen backend\n",
+ "layout = find_qubit_chain(backend, num_qubits)\n",
+ "\n",
+ "# Transpile the circuit for the target backend, pinning the chain as the layout.\n",
+ "# optimization_level=0 preserves the mirrored two-qubit-gate layers that the\n",
+ "# boxing and noise-learning steps rely on.\n",
+ "pm = generate_preset_pass_manager(\n",
+ " backend=backend, optimization_level=0, initial_layout=layout\n",
+ ")\n",
+ "isa_circuit = pm.run(circuit)\n",
+ "isa_observable = observable.apply_layout(isa_circuit.layout)\n",
+ "isa_circuit.draw(\"mpl\", fold=-1)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5d7f16f6-0000-4000-8000-000000000011",
+ "metadata": {},
+ "source": [
+ "### Twirl the two-qubit gate layers and measurements, and find unique layers\n",
+ "\n",
+ "We use `samplomatic` to box the circuit and identify unique two-qubit layers. A box is a construct that groups instructions together so that specific intents or annotations can later be applied uniformly to all gates within the same box.\n",
+ "\n",
+ "Here, we call the method `generate_boxing_pass_manager`, which goes beyond simply identifying two-qubit layers. It performs several key tasks:\n",
+ "\n",
+ "- Groups all two-qubit layers in the circuit,\n",
+ "- Applies the `Twirl` and `ChangeBasis` annotations to those layers,\n",
+ "- Groups measurement operations into their own boxed sections, and\n",
+ "- Applies the `InjectNoise` annotation to each two-qubit layer.\n",
+ "\n",
+ "These annotations define how noise, basis changes, and twirling are handled throughout the circuit. They also establish the structure used later for learning and mitigating noise.\n",
+ "\n",
+ "The key configuration options are:\n",
+ "\n",
+ "- `enable_gates`/`enable_measures: True`: Box all two-qubit gate layers and terminal measurements. Single-qubit gates are left-dressed within the boxes.\n",
+ "- `measure_annotations: all`: Include `Twirl` and `ChangeBasis` annotations on the measurement box.\n",
+ "- `twirling_strategy: active`: Twirl all active qubits in each box containing entangling gates.\n",
+ "- `inject_noise_targets: gates`: Add `InjectNoise` annotations to all `Twirl`-annotated boxes containing entangling gates.\n",
+ "- `inject_noise_strategy: uniform_modification`: Scale all noise layers equivalently across the circuit."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "d4364c0b-2ddc-473d-8171-fd39b30a1e2d",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Box up circuit with Twirl and InjectNoise annotations\n",
+ "pm = generate_boxing_pass_manager(\n",
+ " enable_gates=True,\n",
+ " enable_measures=True,\n",
+ " measure_annotations=\"all\",\n",
+ " twirling_strategy=\"active\",\n",
+ " inject_noise_targets=\"gates\",\n",
+ " inject_noise_strategy=\"uniform_modification\",\n",
+ ")\n",
+ "boxed_circuit = pm.run(isa_circuit)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "9c911a29-553c-4ffe-94de-1442f25e3116",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_circ = QuantumCircuit(boxed_circuit.num_qubits)\n",
+ "draw_circ.append(boxed_circuit.data[0], qargs=boxed_circuit.data[0].qubits)\n",
+ "draw_circ.append(boxed_circuit.data[1], qargs=boxed_circuit.data[1].qubits)\n",
+ "draw_circ.draw(\"mpl\", fold=-1, scale=0.3, idle_wires=False)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "cc283451-0000-4000-8000-000000000014",
+ "metadata": {},
+ "source": [
+ "Generate the template circuit and `samplex`, which define how the circuit will be sampled.\n",
+ "\n",
+ "Here we also add spectator and post-selection measurements, which are needed to perform post-selection on the samples output from the `Executor`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "a0ca9fbb-c572-4d49-bdef-e056c1bc82e3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Build template circuit and samplex for later use with the \"Executor\"\n",
+ "template_circuit, samplex = samplomatic.build(boxed_circuit)\n",
+ "\n",
+ "# Add post-selection instructions to the template circuit\n",
+ "post_selection_pm = PassManager(\n",
+ " [\n",
+ " AddSpectatorMeasures(backend.coupling_map),\n",
+ " AddPostSelectionMeasures(x_pulse_type=\"rx\"),\n",
+ " ]\n",
+ ")\n",
+ "template_circuit = post_selection_pm.run(template_circuit)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "8d46f7b1-f0e9-4bc3-ba0f-5cc96090a945",
+ "metadata": {
+ "jupyter": {
+ "source_hidden": true
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "draw_circ = template_circuit.copy_empty_like()\n",
+ "draw_circ.data = template_circuit.data[:324]\n",
+ "draw_circ.draw(\"mpl\", fold=-1, scale=0.3, idle_wires=False)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a45820c6-0000-4000-8000-000000000015",
+ "metadata": {},
+ "source": [
+ "### Learn the noise using `NoiseLearnerV3`\n",
+ "\n",
+ "Before we can apply PNA for error mitigation, we first need to characterize the noise acting on each unique two-qubit layer and the measurement layer in our circuit. To do this, we use the `NoiseLearnerV3` program to experimentally learn noise models for each layer identified earlier. The learner performs benchmarking-style experiments that estimate the noise channel affecting each layer and returns a result object containing the learned model.\n",
+ "\n",
+ "We begin by identifying the unique layers in our circuit using `find_unique_box_instructions` from `samplomatic`. This ensures that we only learn the noise once per distinct layer type, minimizing the number of experiments and total shot cost. The resulting list of layers is passed to the noise learner.\n",
+ "\n",
+ "There are a few key parameters that control how the noise is learned:\n",
+ "\n",
+ "- `num_randomizations`: Number of random circuits used per learning configuration.\n",
+ "- `shots_per_randomization`: Number of shots taken per random learning circuit.\n",
+ "- `layer_pair_depths`: The circuit depths (measured in number of pairs) to use in learning experiments.\n",
+ "- `post_selection`: Enables edge-based post-selection using `rx` gates to apply post-measurement pulses."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "24670819-0000-4000-8000-000000000016",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Noise learning parameters\n",
+ "num_randomizations_nl = 64\n",
+ "shots_per_randomization_nl = 128\n",
+ "\n",
+ "# Find the unique instructions (layers) from the boxed-up circuit\n",
+ "unique_2q_layers_and_meas = find_unique_box_instructions(\n",
+ " boxed_circuit, normalize_annotations=None, undress_boxes=True\n",
+ ")\n",
+ "\n",
+ "# Configure and run the noise learner on the unique layers.\n",
+ "# Options can be passed directly as a dictionary.\n",
+ "noise_learner_options = {\n",
+ " \"num_randomizations\": num_randomizations_nl,\n",
+ " \"shots_per_randomization\": shots_per_randomization_nl,\n",
+ " \"layer_pair_depths\": [1, 2, 4, 8, 12, 16, 24, 32, 40, 48],\n",
+ " \"post_selection\": {\n",
+ " \"enable\": True,\n",
+ " \"strategy\": \"edge\",\n",
+ " \"x_pulse_type\": \"rx\",\n",
+ " },\n",
+ " \"environment\": {\"job_tags\": [\"TUT_PNA\"]},\n",
+ "}\n",
+ "\n",
+ "noise_learner = NoiseLearnerV3(backend, noise_learner_options)\n",
+ "noise_learner_job = noise_learner.run(unique_2q_layers_and_meas)\n",
+ "noise_learner_result = noise_learner_job.result()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "aa248719-0000-4000-8000-000000000017",
+ "metadata": {},
+ "source": [
+ "### Visualize the learned noise rates\n",
+ "\n",
+ "After learning the noise models, we can inspect the distribution of the inferred error rates for both one-qubit and two-qubit operations. The code below extracts the Pauli-Lindblad representations from the learned noise results and collects the corresponding noise rates.\n",
+ "\n",
+ "For each learned layer:\n",
+ "- We convert the noise model to a sparse list of `(pstr, qubits, rate)` tuples, where `pstr` is the Pauli string acting on the given qubits and `rate` is the associated error rate.\n",
+ "- We separate the rates into one-qubit (`len(pstr) == 1`) and two-qubit (`len(pstr) == 2`) terms.\n",
+ "- The lists of rates are then sorted, and their median values are computed.\n",
+ "\n",
+ "We plot the one-qubit (red) and two-qubit (blue) noise-rate distributions on a logarithmic scale, with their median values marked by vertical lines. This lets us compare the relative magnitudes of the learned Pauli-Lindblad generators. The ordering of the one- and two-qubit rates depends on the device and on the specific layers being characterized; in this run, the single-qubit (weight-1) generators carry the larger median rate."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "b1a8ad48-2875-41e0-92c4-c28b34083e6b",
+ "metadata": {
+ "jupyter": {
+ "source_hidden": true
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ ""
+ ]
+ },
+ "execution_count": 10,
+ "metadata": {},
+ "output_type": "execute_result"
+ },
+ {
+ "data": {
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "hw_rates_1q = []\n",
+ "hw_rates_2q = []\n",
+ "for nlr in noise_learner_result[:2]:\n",
+ " plm_list = nlr.to_pauli_lindblad_map().to_sparse_list()\n",
+ " hw_rates_1q += [\n",
+ " rate for (pstr, qubits, rate) in plm_list if len(pstr) == 1\n",
+ " ]\n",
+ " hw_rates_2q += [\n",
+ " rate for (pstr, qubits, rate) in plm_list if len(pstr) == 2\n",
+ " ]\n",
+ "hw_rates_1q = sorted(hw_rates_1q)\n",
+ "hw_rates_2q = sorted(hw_rates_2q)\n",
+ "median_1q = hw_rates_1q[len(hw_rates_1q) // 2]\n",
+ "median_2q = hw_rates_2q[len(hw_rates_2q) // 2]\n",
+ "fig, ax = plt.subplots(1, 1, figsize=(14, 5))\n",
+ "ax.scatter(\n",
+ " (hw_rates_1q),\n",
+ " [(i) / (len(hw_rates_1q) - 1) for i in range(len(hw_rates_1q))],\n",
+ " color=\"red\",\n",
+ " label=\"1q rates\",\n",
+ ")\n",
+ "ax.set_xscale(\"log\")\n",
+ "ax.set_ylim(0, 1.1)\n",
+ "ax.vlines(median_1q, 0, 1, color=\"red\")\n",
+ "ax.text(median_1q * 1.1, 0.1, f\"{median_1q:.2e}\")\n",
+ "ax.scatter(\n",
+ " (hw_rates_2q),\n",
+ " [(i) / (len(hw_rates_2q) - 1) for i in range(len(hw_rates_2q))],\n",
+ " color=\"blue\",\n",
+ " label=\"2q rates\",\n",
+ ")\n",
+ "ax.set_xscale(\"log\")\n",
+ "ax.set_ylim(0, 1.1)\n",
+ "ax.vlines(median_2q, 0, 1, color=\"blue\")\n",
+ "ax.text(median_2q * 1.1, 0.2, f\"{median_2q:.2e}\")\n",
+ "ax.set_title(\"Learned noise rates\")\n",
+ "ax.set_xlabel(\"Noise rate\")\n",
+ "ax.set_yticks([])\n",
+ "plt.legend()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f8a91705-0000-4000-8000-000000000018",
+ "metadata": {},
+ "source": [
+ "### Associate circuit boxes with learned noise\n",
+ "\n",
+ "Once we have obtained noise models for each unique two-qubit layer, we need to link them to the corresponding `InjectNoise` annotations within the boxed circuit.\n",
+ "\n",
+ "The `InjectNoise` directive is a `samplomatic` annotation that uses the single-qubit dressers to inject noise into the circuit in a controlled and configurable way. It enables modular noise modeling across different layers.\n",
+ "\n",
+ "Each `InjectNoise` annotation includes:\n",
+ "- `InjectNoise.ref` - a unique identifier for the annotation. This is used by the `samplex` object to correctly assign the corresponding noise model.\n",
+ "- `InjectNoise.modifier_ref` *(optional)* - a secondary reference that allows scaling the assigned noise model by a multiplicative factor.\n",
+ "\n",
+ "In this step, we build a mapping from each `InjectNoise.ref` to its corresponding learned noise model (`PauliLindbladMap`). This mapping ensures that each entangling gate layer in the circuit is paired with the appropriate noise model, enabling accurate application of noise effects during sampling and subsequent noise-mitigation steps."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "e172d227-7a04-48ba-8566-d5f3007ef7bc",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# map inject noise refs to pauli lindblad maps\n",
+ "refs_to_noise_models = {}\n",
+ "for instruction, result in zip(\n",
+ " unique_2q_layers_and_meas, noise_learner_result, strict=False\n",
+ "):\n",
+ " if inject_noise_annot := get_annotation(\n",
+ " instruction.operation, InjectNoise\n",
+ " ):\n",
+ " refs_to_noise_models[inject_noise_annot.ref] = (\n",
+ " result.to_pauli_lindblad_map()\n",
+ " )"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7ab22f68-0000-4000-8000-000000000019",
+ "metadata": {},
+ "source": [
+ "### Propagate the observable through the learned anti-noise\n",
+ "\n",
+ "As discussed above, this is done in two steps. First, we propagate an anti-noise generator to the end of the circuit. After that, we propagate the observable through that evolved generator. This process is repeated for each anti-noise generator in the circuit. In this implementation, each generator in a given layer is propagated to the end of the circuit in parallel. Additionally, Python multiprocessing is used to perform both the forward-propagation of the anti-noise as well as the back-propagation of the observable in parallel. This prevents a pile-up of evolved generators in memory and also maximizes compute resources.\n",
+ "\n",
+ "When running PNA, you will always need to provide a noisy circuit and observable. If your noisy circuit is a boxed circuit with `InjectNoise` annotations, you will need to provide the mapping we created in the step above. One can also pass a non-boxed circuit containing `PauliLindbladError` instructions from `qiskit-aer`. In that case, `refs_to_noise_models` does not have to be provided. In addition to the primary inputs, users will want to consider:\n",
+ "\n",
+ "- `max_err_terms`: The number of terms to keep in each anti-noise generator as it is forward-propagated. Allowing this to be larger generally increases accuracy, but this behavior is not guaranteed to be monotonic.\n",
+ "- `max_obs_terms`: The number of terms to keep in the noise-mitigating observable, $\\tilde{O}$, as it is back-propagated through the evolved anti-noise. Larger values generally increase accuracy, but it is not guaranteed to do so monotonically.\n",
+ "- `num_processes`: The number of cores to dedicate to the process. Remember, the generators are forward-propagated and applied to the observable in parallel.\n",
+ "- `search_step`: The back-propagation step uses a greedy method to approximately conjugate two operators in the Pauli basis. This method can be sped up by increasing `search_step`. See the [`pauli-prop` docs](https://qiskit.github.io/pauli-prop/) for more information.\n",
+ "- `num_to_measure`: While this variable isn't an input to `generate_noise_mitigating_observable`, we use it to control how many terms from $\\tilde{O}$ we actually want to measure. Here we will only measure the top 30 terms, which are the original terms in our observable. The terms have now been re-scaled such that measuring them has the effect of mitigating the learned gate noise. Although we only measure 30 terms from $\\tilde{O}$, it is often still useful to allow it to grow large, as that increases the precision of the leading terms' scaling factors."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "id": "19407dc3-eec0-4323-9a8a-47e949fb6ae2",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Finished! 13740 / 13740 generators propagated. "
+ ]
+ }
+ ],
+ "source": [
+ "# PNA parameters\n",
+ "num_processes = 8\n",
+ "max_err_terms = 10_000\n",
+ "max_obs_terms = 10_000\n",
+ "num_to_measure = num_qubits\n",
+ "\n",
+ "obs_tilde_isa = generate_noise_mitigating_observable(\n",
+ " boxed_circuit,\n",
+ " isa_observable,\n",
+ " refs_to_noise_models,\n",
+ " max_err_terms=max_err_terms,\n",
+ " max_obs_terms=max_obs_terms,\n",
+ " num_processes=num_processes,\n",
+ " print_progress=True,\n",
+ " search_step=8,\n",
+ ")\n",
+ "p_2_v = {p: v for v, p in enumerate(layout)}\n",
+ "obs_tilde_virtual = SparsePauliOp.from_sparse_list(\n",
+ " [\n",
+ " (pstr, [p_2_v[p] for p in p_qubits], coeff)\n",
+ " for (pstr, p_qubits, coeff) in obs_tilde_isa.to_sparse_list()\n",
+ " ],\n",
+ " num_qubits=num_qubits,\n",
+ ")\n",
+ "obs_tilde_virtual = obs_tilde_virtual[\n",
+ " np.argsort(np.abs(obs_tilde_virtual.coeffs))[::-1]\n",
+ "][:num_to_measure]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "id": "77246aef-1892-4a27-bbf0-128b12206581",
+ "metadata": {
+ "jupyter": {
+ "source_hidden": true
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "[]"
+ ]
+ },
+ "execution_count": 13,
+ "metadata": {},
+ "output_type": "execute_result"
+ },
+ {
+ "data": {
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "obs_tilde_isa = obs_tilde_isa[np.argsort(np.abs(obs_tilde_isa.coeffs))][::-1]\n",
+ "plt.xscale(\"log\")\n",
+ "plt.yscale(\"log\")\n",
+ "plt.title(r\"$\\tilde{O}$ coeff magnitudes\")\n",
+ "plt.ylabel(\"Magnitude\")\n",
+ "plt.xlabel(\"Pauli term index\")\n",
+ "plt.plot(np.abs(obs_tilde_isa.coeffs), \".\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e02700bb-0000-4000-8000-000000000020",
+ "metadata": {},
+ "source": [
+ "### Transform the measurement bases to canonical form\n",
+ "\n",
+ "Next, we will find a minimal set of bases to measure such that we can fully cover every Pauli term in the measured observable (*many observables may be measured simultaneously if they commute qubit-wise*). Since we are only measuring the terms in our original observable, which is the sum of all single-`Z` Paulis, a single basis is needed -- the all-`Z` basis.\n",
+ "\n",
+ "In addition to finding a set of Pauli measurement bases, we need to map these Pauli terms to the canonical form expected by the `Executor`. For more information on canonical qubit ordering, visit the [samplomatic docs](https://qiskit.github.io/samplomatic/guides/samplex_io.html#qubit-ordering-convention)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 14,
+ "id": "a0ff4edd-434a-4aa5-8175-075e1472c81e",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "meas_box = boxed_circuit.data[-1]\n",
+ "canonical_qubits = [\n",
+ " idx\n",
+ " for idx, qubit in enumerate(boxed_circuit.qubits)\n",
+ " if qubit in meas_box.qubits\n",
+ "]\n",
+ "c_2_p = {\n",
+ " c: p for c, p in enumerate(canonical_qubits)\n",
+ "} # canonical -> physical\n",
+ "p_2_v = {p: v for v, p in enumerate(layout)} # physical -> virtual\n",
+ "c_2_v = {c: p_2_v[p] for c, p in c_2_p.items()} # canonical -> virtual\n",
+ "meas_bases, bases_reverser = get_measurement_bases(obs_tilde_virtual)\n",
+ "meas_bases_canonical = [\n",
+ " np.array([base[c_2_v[c]] for c in range(num_qubits)], dtype=np.uint8)\n",
+ " for base in meas_bases\n",
+ "]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "24c95aca-0000-4000-8000-000000000021",
+ "metadata": {},
+ "source": [
+ "### Step 3: Execute quantum experiments\n",
+ "\n",
+ "#### Specify how to sample in the `QuantumProgram`\n",
+ "\n",
+ "We now set up the `QuantumProgram`, which serves as the central container for all circuits and sampling configurations that will be executed by the `Executor`. This object defines how randomized circuit instances are generated, batched, and executed to produce the measurement results used in PNA.\n",
+ "\n",
+ "A `QuantumProgram` can contain multiple items, each consisting of a template circuit and a corresponding `samplex` object that defines how randomizations are applied. This abstraction allows the `Executor` to handle the entire workflow -- from randomized circuit generation to shot collection and aggregation -- as a single, modular program.\n",
+ "\n",
+ "In this step, we create a `QuantumProgram` that runs our PNA experiment using the template circuit and `samplex` we constructed earlier. The configuration includes the following elements:\n",
+ "\n",
+ "- `template_circuit`: The circuit containing all the gates necessary to implement all desired randomizations (from twirling randomizations, parameters, and so on).\n",
+ "- `samplex`: An object defining a probability distribution over all possible circuit randomizations from which to sample.\n",
+ "- `samplex_arguments`: Bindings necessary to fully define the `samplex`\n",
+ " - `basis_changes`: Here is where we specify a set of bases to measure that will cover all of the Pauli terms in the measured observable.\n",
+ " - `noise_scales.ref`: We set each noise layer's scale to `0.0` to prevent any additional noise from being injected into our samples.\n",
+ " - `pauli_lindblad_maps`: Required if `noise_scales` are passed. This just maps noise layers to the associated noise model.\n",
+ "- `shape`: A shape tuple to extend the implicit shape defined by `samplex_arguments`. Non-trivial axes introduced by this extension enumerate randomizations."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 15,
+ "id": "7958cdd5-37b9-48ab-a1b1-5bf2a6bcf13e",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Control the # of shots during execution\n",
+ "shots_per_randomization_exec = 64\n",
+ "num_randomizations_exec = 6144\n",
+ "\n",
+ "# Zero out the noise to prevent noise from being injected during execution.\n",
+ "# We only added InjectNoise annotations so PNA could associate the noise\n",
+ "# to layers in the circuit\n",
+ "samplex_inputs = {f\"noise_scales.{ref}\": 0.0 for ref in refs_to_noise_models}\n",
+ "samplex_inputs |= {\"pauli_lindblad_maps\": refs_to_noise_models}\n",
+ "\n",
+ "# Specify the bases to measure. The samplex exposes one basis-change input per\n",
+ "# ChangeBasis-annotated box; here a single all-Z basis covers every term. We\n",
+ "# look up the basis-change interface name rather than hardcoding an index, since\n",
+ "# the name depends on the circuit's box structure.\n",
+ "bases_broadcastable = np.expand_dims(np.array(meas_bases_canonical), axis=1)\n",
+ "samplex_inputs |= {\n",
+ " spec.name: bases_broadcastable\n",
+ " for spec in samplex.inputs().get_specs(r\"^basis_changes\\.\")\n",
+ "}\n",
+ "\n",
+ "# Convert samplex_inputs into a dict to pass to QuantumProgram\n",
+ "samplex_arguments = (\n",
+ " samplex.inputs().make_broadcastable().bind(**samplex_inputs)\n",
+ ")\n",
+ "\n",
+ "# Instantiate the QuantumProgram with the specified parameters\n",
+ "program = QuantumProgram(shots=shots_per_randomization_exec)\n",
+ "program.append_samplex_item(\n",
+ " circuit=template_circuit,\n",
+ " samplex=samplex,\n",
+ " samplex_arguments=samplex_arguments,\n",
+ " shape=(num_randomizations_exec,),\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b7caba08-0000-4000-8000-000000000022",
+ "metadata": {},
+ "source": [
+ "#### Sample the circuit using the `Executor`\n",
+ "\n",
+ "Now that we have defined our `QuantumProgram`, executing the experiment is straightforward. We simply instantiate the `Executor` object, provide it the backend, and run the program."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 16,
+ "id": "44202d02-0000-4000-8000-000000000023",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Execute (sample) the circuit\n",
+ "executor = Executor(backend)\n",
+ "job_exec = executor.run(program)\n",
+ "exec_results = job_exec.result()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "bb61dcec-0000-4000-8000-000000000024",
+ "metadata": {},
+ "source": [
+ "### Step 4: Reconstruct and analyze results\n",
+ "\n",
+ "To calculate an error-mitigated expectation value, we will:\n",
+ "\n",
+ "- Calculate the TREX scaling factors based on the learned noise affecting the measurements,\n",
+ "- Generate a mask for keeping only post-selected samples, and\n",
+ "- Use the `executor_expectation_values` function from `qiskit-addon-utils` for combining all of the data into an error-mitigated expectation value."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 17,
+ "id": "1ba3b057-a809-402d-8fd6-4314956dc98e",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Computing the TREX factors\n",
+ "measurement_noise_map = noise_learner_result[2].to_pauli_lindblad_map()\n",
+ "trex_rescale_factors = trex_factors(measurement_noise_map, bases_reverser)\n",
+ "\n",
+ "# Post-select the results\n",
+ "post_selector = PostSelector.from_circuit(\n",
+ " circuit=template_circuit, coupling_map=backend.coupling_map\n",
+ ")\n",
+ "\n",
+ "# Compute the ps mask for filtering results\n",
+ "mask = post_selector.compute_mask(exec_results[0], strategy=\"edge\")\n",
+ "\n",
+ "# Compute expvals using post selected results\n",
+ "results = executor_expectation_values(\n",
+ " exec_results[0][\"meas\"],\n",
+ " bases_reverser,\n",
+ " meas_basis_axis=0,\n",
+ " avg_axis=1,\n",
+ " measurement_flips=exec_results[0][\"measurement_flips.meas\"],\n",
+ " pauli_signs=exec_results[0].get(\"pauli_signs\", None),\n",
+ " postselect_mask=mask,\n",
+ " rescale_factors=trex_rescale_factors,\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "aed4c6f9-0000-4000-8000-000000000025",
+ "metadata": {},
+ "source": [
+ "#### Compare mitigation strategies: PNA, PNA+TREX, PNA+PS, PNA+PS+TREX\n",
+ "\n",
+ "We will compute and visualize expectation values for several mitigation variants using the `Executor` results."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 18,
+ "id": "8df9366c-d456-416b-80ab-3b941a07a935",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "bases_reverser_unmit = {Pauli(\"Z\" * num_qubits): [observable]}\n",
+ "args = [\n",
+ " (bases_reverser_unmit, None, None),\n",
+ " (bases_reverser, None, None),\n",
+ " (bases_reverser, None, trex_rescale_factors),\n",
+ " (bases_reverser, mask, None),\n",
+ " (bases_reverser, mask, trex_rescale_factors),\n",
+ "]\n",
+ "\n",
+ "evs = []\n",
+ "for reverser, postsel_mask, factors in args:\n",
+ " # Compute expvals using post selected results\n",
+ " res_ps = executor_expectation_values(\n",
+ " exec_results[0][\"meas\"],\n",
+ " reverser,\n",
+ " meas_basis_axis=0,\n",
+ " avg_axis=1,\n",
+ " measurement_flips=exec_results[0][\"measurement_flips.meas\"],\n",
+ " pauli_signs=exec_results[0].get(\"pauli_signs\", None),\n",
+ " postselect_mask=postsel_mask,\n",
+ " rescale_factors=factors,\n",
+ " )\n",
+ " res_ps = np.array(res_ps)\n",
+ " evs.append(res_ps[:, 0][0])\n",
+ "\n",
+ "experiments = [\"PNA\", \"PNA+TREX\", \"PNA+PS\", \"PNA+PS+TREX\"]\n",
+ "colors = [\"#d9d9d9\", \"#b0b0b0\", \"#7f7f7f\", \"#4c4c4c\"]\n",
+ "plt.bar(experiments, evs[1:], color=colors)\n",
+ "plt.axhline(y=1, color=\"green\", linestyle=\"--\", linewidth=2, label=\"Ideal\")\n",
+ "plt.axhline(\n",
+ " y=evs[0], color=\"red\", linestyle=\"--\", linewidth=2, label=\"Unmitigated\"\n",
+ ")\n",
+ "plt.ylabel(\"Expectation value\", fontsize=14)\n",
+ "\n",
+ "plt.title(\n",
+ " r\"30q Mirrored Ising, 10 Trotter steps, $\\theta_{rx}=\\frac{\\pi}{8}$\",\n",
+ " fontsize=14,\n",
+ ")\n",
+ "plt.legend(loc=\"upper left\", bbox_to_anchor=(1.05, 1), borderaxespad=0.0)\n",
+ "plt.xticks(rotation=45)\n",
+ "plt.tight_layout()\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9589eae8-0000-4000-8000-000000000026",
+ "metadata": {},
+ "source": [
+ "The results demonstrate the cumulative benefits of combining different error-mitigation techniques. The plain PNA approach already restores the expectation value close to the ideal benchmark, indicating that propagating inverse noise channels into the observable effectively compensates for two-qubit gate errors.\n",
+ "\n",
+ "- Adding TREX reweighting (PNA+TREX) slightly improves the estimate by correcting for sampling imbalance in the randomized circuits.\n",
+ "- Post-selection (PNA+PS) provides a more noticeable boost by filtering out inconsistent measurement outcomes that likely result from residual errors.\n",
+ "- Finally, combining both (PNA+PS+TREX) yields the most accurate result, nearly matching the ideal value, showing how these mitigation strategies reinforce each other.\n",
+ "\n",
+ "Overall, the comparison highlights that PNA serves as a robust foundation for noise-aware expectation value estimation, while TREX and post-selection offer complementary refinements for further accuracy gains."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a1b2c3d4-9999-4000-8000-000000000099",
+ "metadata": {},
+ "source": [
+ "## Next steps\n",
+ "If you found this work interesting, you might be interested in the following material:\n",
+ "\n",
+ "- [Combine error mitigation options with the Estimator primitive](/docs/tutorials/combine-error-mitigation-techniques)\n",
+ "- [Utility-scale error mitigation with probabilistic error amplification](/docs/tutorials/probabilistic-error-amplification)\n",
+ "- [Operator backpropagation (OBP) for estimation of expectation values](/docs/tutorials/operator-back-propagation)\n",
+ "- The [`samplomatic`](https://github.com/Qiskit/samplomatic) and [`pauli-prop`](https://github.com/Qiskit/pauli-prop) documentation\n",
+ ""
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "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"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/77246aef-1892-4a27-bbf0-128b12206581-1.avif b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/77246aef-1892-4a27-bbf0-128b12206581-1.avif
new file mode 100644
index 00000000000..b36a5e1e41f
Binary files /dev/null and b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/77246aef-1892-4a27-bbf0-128b12206581-1.avif differ
diff --git a/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/8c9afe5e-6fa3-427d-a672-becdb14490b8-0.avif b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/8c9afe5e-6fa3-427d-a672-becdb14490b8-0.avif
new file mode 100644
index 00000000000..96691d60f3b
Binary files /dev/null and b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/8c9afe5e-6fa3-427d-a672-becdb14490b8-0.avif differ
diff --git a/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/8d46f7b1-f0e9-4bc3-ba0f-5cc96090a945-0.avif b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/8d46f7b1-f0e9-4bc3-ba0f-5cc96090a945-0.avif
new file mode 100644
index 00000000000..212f3c31c1d
Binary files /dev/null and b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/8d46f7b1-f0e9-4bc3-ba0f-5cc96090a945-0.avif differ
diff --git a/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/8df9366c-d456-416b-80ab-3b941a07a935-0.avif b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/8df9366c-d456-416b-80ab-3b941a07a935-0.avif
new file mode 100644
index 00000000000..d9340cb501c
Binary files /dev/null and b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/8df9366c-d456-416b-80ab-3b941a07a935-0.avif differ
diff --git a/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/8fcb5af8-64f0-498a-821f-11bcd7aea203-0.avif b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/8fcb5af8-64f0-498a-821f-11bcd7aea203-0.avif
new file mode 100644
index 00000000000..ead68871293
Binary files /dev/null and b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/8fcb5af8-64f0-498a-821f-11bcd7aea203-0.avif differ
diff --git a/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/9c911a29-553c-4ffe-94de-1442f25e3116-0.avif b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/9c911a29-553c-4ffe-94de-1442f25e3116-0.avif
new file mode 100644
index 00000000000..aca75dd9e8a
Binary files /dev/null and b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/9c911a29-553c-4ffe-94de-1442f25e3116-0.avif differ
diff --git a/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/b1a8ad48-2875-41e0-92c4-c28b34083e6b-1.avif b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/b1a8ad48-2875-41e0-92c4-c28b34083e6b-1.avif
new file mode 100644
index 00000000000..119aa7b5fa5
Binary files /dev/null and b/public/docs/images/tutorials/propagated-noise-absorption/extracted-outputs/b1a8ad48-2875-41e0-92c4-c28b34083e6b-1.avif differ
diff --git a/public/docs/images/tutorials/propagated-noise-absorption/noise_mitigated_expt.avif b/public/docs/images/tutorials/propagated-noise-absorption/noise_mitigated_expt.avif
new file mode 100644
index 00000000000..4eef6fe574f
Binary files /dev/null and b/public/docs/images/tutorials/propagated-noise-absorption/noise_mitigated_expt.avif differ
diff --git a/public/docs/images/tutorials/propagated-noise-absorption/noisy_expt.avif b/public/docs/images/tutorials/propagated-noise-absorption/noisy_expt.avif
new file mode 100644
index 00000000000..75b23ac2588
Binary files /dev/null and b/public/docs/images/tutorials/propagated-noise-absorption/noisy_expt.avif differ
diff --git a/public/docs/images/tutorials/propagated-noise-absorption/pna_overview.avif b/public/docs/images/tutorials/propagated-noise-absorption/pna_overview.avif
new file mode 100644
index 00000000000..1dc97bd48c0
Binary files /dev/null and b/public/docs/images/tutorials/propagated-noise-absorption/pna_overview.avif differ
diff --git a/qiskit_bot.yaml b/qiskit_bot.yaml
index b5dfc9e49d1..228130b64c5 100644
--- a/qiskit_bot.yaml
+++ b/qiskit_bot.yaml
@@ -686,6 +686,8 @@ notifications:
"docs/tutorials/combine-error-mitigation-techniques":
- "`@nathanearnestnoble`"
- "`@ibrahim-shehzad`"
+ "docs/tutorials/propagated-noise-absorption":
+ - "@henryzou50"
"docs/tutorials/nishimori-phase-transition":
- "`@nathanearnestnoble`"
- "@kevinsung"
diff --git a/scripts/config/notebook-testing.toml b/scripts/config/notebook-testing.toml
index 555510e41e7..a5ccf0101b2 100644
--- a/scripts/config/notebook-testing.toml
+++ b/scripts/config/notebook-testing.toml
@@ -204,6 +204,7 @@ notebooks = [
"docs/tutorials/wire-cutting.ipynb",
"docs/tutorials/krylov-quantum-diagonalization.ipynb",
"docs/tutorials/probabilistic-error-amplification.ipynb",
+ "docs/tutorials/propagated-noise-absorption.ipynb",
"docs/tutorials/sample-based-quantum-diagonalization.ipynb",
"docs/tutorials/pauli-correlation-encoding-for-qaoa.ipynb",
"docs/tutorials/nishimori-phase-transition.ipynb",