PNA — Propagated Noise Absorption

qc/mitigation qc/noise

Propagated Noise Absorption (PNA) mitigates gate noise by rewriting the observable rather than the circuit. It propagates the inverse of each layer’s learned Pauli-Lindblad noise forward through the circuit and absorbs it into the measurement observable, producing a noise mitigating observable .

Core idea

  • Circuit: unchanged
  • Observable: rewritten from to

Measuring on the noisy circuit gives the same expectation value as measuring on the ideal (noiseless) circuit.

This works because Pauli noise channels compose and propagate efficiently through Clifford gates via the commutation rules from section 1.2.

PNA vs PEC

PNAPEC
CircuitUnchangedRewritten (anti-noise sampling)
ObservableRewritten to Unchanged
CostMore observable terms to measureSampling overhead
BiasExact (up to truncation)Exact (with accurate model)

Boxing strategy

PNA uses inject_noise_strategy="uniform_modification". All layers share a global noise_scales slot, set to 0 — this leaves the sampled circuits untouched while still associating each layer with its learned model so the model can be propagated into the observable.

pna_boxing_pm = generate_boxing_pass_manager(
    enable_gates=True,
    enable_measures=True,
    measure_annotations="all",       # adds ChangeBasis annotation too
    twirling_strategy="active",
    inject_noise_targets="gates",
    inject_noise_strategy="uniform_modification",
)

measure_annotations="all" adds both Twirl and ChangeBasis to the measurement box, because will contain non-Z terms that require measuring in different bases.

Four-step PNA workflow

1. Box the circuit

boxed_circuit_pna = pna_boxing_pm.run(mirror_isa_pna)
unique_layers_pna = find_unique_box_instructions(
    boxed_circuit_pna, normalize_annotations=None, undress_boxes=True
)

2. Learn the noise

refs_to_noise_models_pna = noise_result_pna.to_dict(unique_layers_pna, require_refs=False)

3. Compute the noise mitigating observable

from qiskit_addon_pna import generate_noise_mitigating_observable
 
# Define target observable
target_obs = SparsePauliOp.from_sparse_list(
    [("ZZ", [4, 5], 1.0)],   # e.g. Z₄Z₅
    num_qubits=10,
)
target_obs_isa = target_obs.apply_layout(mirror_isa_pna.layout)
 
# Generate noise mitigating observable
obs_tilde = generate_noise_mitigating_observable(
    boxed_circuit_pna,
    target_obs_isa,
    refs_to_noise_models_pna,
    max_err_terms=10000,
    max_obs_terms=10000,
    num_processes=8,
)

The function propagates the inverse of each layer’s noise channel through the circuit using Clifford conjugation, then folds those corrections into to produce .

Truncation: max_err_terms and max_obs_terms keep only the dominant terms so remains measurable. More terms → better accuracy, but more measurement bases required.

4. Run with Executor

# Use ChangeBasis because obs_tilde has non-Z terms
samplex_args = (
    samplex.inputs()
    .make_broadcastable()
    .bind(
        pauli_lindblad_maps=refs_to_noise_models_pna,
        basis_changes=get_measurement_bases(obs_tilde),
        noise_scales={ref: 0 for ref in refs_to_noise_models_pna},
    )
)

What looks like

For a ZZ observable on the middle pair of a 10-qubit chain:

  • The original ZZ term at magnitude 1 remains the dominant term (slightly amplified above 1)
  • Many new Pauli terms appear — these are the anti-noise corrections PNA propagated from the learned noise model
  • Results vary by QPU and calibration date

Adding TREX on top of PNA

PNA mitigates gate noise; TREX handles readout errors. They compose:

from qiskit_addon_utils.noise_management import trex_factors
 
rescale = trex_factors(
    meas_results,
    obs_tilde,
    measurement_flips=flips,
)
# Pass rescale_factors=rescale to executor_expectation_values

PNA + TREX consistently outperforms either alone on hardware.

Exercise 4 — Magnetization observable

Build for the magnetization on a 10-qubit chain:

target_observable_ex4 = SparsePauliOp.from_sparse_list(
    [("Z", [i], 1.0) for i in range(10)],
    num_qubits=10,
)
target_observable_ex4_isa = target_observable_ex4.apply_layout(mirror_isa_pna.layout)
 
obs_tilde_ex4 = generate_noise_mitigating_observable(
    boxed_circuit_pna,
    target_observable_ex4_isa,
    refs_to_noise_models_pna,
    max_err_terms=10000,
    max_obs_terms=10000,
    num_processes=8,
)

Ideal value: (all qubits in give ).

Self-Check

  • Could you explain to someone how PNA mitigates noise without touching the circuit at all?
  • Why does PNA need uniform_modification specifically, with noise_scales set to 0?
  • Why does end up needing more measurement bases than the original observable ?