PEC avec SLC
La Probabilistic error cancellation (PEC) est une technique d'atténuation du bruit quantique dans le calcul de valeurs moyennes d'observables. Elle te permet de calculer une estimation non biaisée des valeurs moyennes. Cette technique consiste à exécuter un ensemble de circuits bruités dans lesquels on injecte l'inverse d'un modèle de bruit appris. Le modèle de bruit est ensuite approximé par un canal de Pauli grâce au Pauli twirling (également connu sous le nom de randomized compiling). De tels modèles de bruit peuvent être appris efficacement et atténués avec la PEC (voir Probabilistic error cancellation with sparse Pauli-Lindblad models on noisy quantum processors pour plus de détails).
La PEC exige un surcoût exponentiel en shots pour calculer une estimation non biaisée. Les Shaded Lightcones (SLC) sont une technique qui utilise des ressources classiques pour borner la sensibilité aux erreurs dans l'ensemble du circuit. Grâce à ces bornes, l'utilisateur peut régler de façon contrôlée le biais de l'estimation de l'observable.
Ce guide montre comment utiliser la PEC combinée aux SLC pour atténuer le bruit des Gates avec un surcoût en shots réduit par rapport à la PEC classique.
Le flux de travail est le suivant :
- Spécifier un circuit et une observable.
- Calculer les valeurs moyennes bruitées et non atténuées.
- Calculer les bornes avant et arrière sur le biais.
- Apprendre le bruit des couches d'intrication avec qiskit-noise-learning.
- Utiliser le bruit appris pour fusionner les bornes calculées en une matrice d'échelles locales.
- Choisir la borne de biais souhaitée selon le compromis entre surcoût en shots et biais.
- Préparer un programme PEC + SLC, exécuter le programme et post-traiter.
- Comparer les valeurs moyennes atténuées et le surcoût en shots.
Spécifier un circuit et une observable
Prépare un circuit miroir d'une évolution temporelle trotterisée d'un Hamiltonien de chaîne d'Ising 1D sur huit qubits. Applique le miroir au circuit pour annuler son unitaire et former un circuit identité, de sorte que la valeur moyenne idéale de chaque observable est connue et vaut 1 sans qu'il soit nécessaire de la calculer. De plus, le miroir double la profondeur du circuit, ce qui double à son tour l'effet du bruit.
Utilise l'angle pour rendre la dynamique non Clifford.
# Added by doQumentation — installs the packages this notebook needs if they are missing
import importlib.util
_needed = {"matplotlib": "matplotlib", "numpy": "numpy", "qiskit": "qiskit", "qiskit_addon_slc": "qiskit-addon-slc", "qiskit_aer": "qiskit-aer", "qiskit_ibm_runtime": "qiskit-ibm-runtime", "qiskit_mitigation": "qiskit-mitigation", "qiskit_noise_learning": "qiskit-noise-learning", "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 warnings
import numpy as np
from qiskit.circuit import QuantumCircuit
from qiskit.quantum_info import SparsePauliOp
from qiskit_mitigation.utils import get_measurement_bases
warnings.filterwarnings("ignore")
def construct_ising_circuit(
num_qubits: int,
num_trotter_steps: int,
rx_angle: float,
) -> QuantumCircuit:
circuit = QuantumCircuit(num_qubits)
for _ in range(num_trotter_steps):
circuit.rx(rx_angle, range(num_qubits))
for first_qubit in (1, 2):
for idx in range(first_qubit, num_qubits, 2):
# equivalent to Rzz(-pi/2):
circuit.sdg([idx - 1, idx])
circuit.cz(idx - 1, idx)
return circuit
def prepare_basis(
circuit: QuantumCircuit, basis: list[int]
) -> QuantumCircuit:
# basis is a list of integer values from 0 to 3. These map to the basis measurement as:
# 0 = I; 1 = Z; 2 = X; 3 = Y
assert len(basis) == circuit.num_qubits
out_circ = circuit.copy_empty_like()
for qb, bas in enumerate(basis):
if bas == 2:
out_circ.h(qb)
elif bas == 3:
out_circ.rx(-np.pi / 2, qb)
out_circ.barrier()
out_circ.compose(circuit, inplace=True)
return out_circ
def mirror_circuit(circuit: QuantumCircuit) -> QuantumCircuit:
mirror_circ = circuit.copy_empty_like()
mirror_circ.compose(circuit.inverse(), inplace=True)
mirror_circ.barrier()
mirror_circ.compose(circuit, inplace=True)
mirror_circ.measure_active()
return mirror_circ
# define observables
num_qubits = 8 # Width of the Ising chain
# The observable to measure: one X component (on qubit 2) and one Z component (on qubit 5)
target_obs_sparse = [("XZ", [2, 5], 1.0)]
observable = SparsePauliOp.from_sparse_list(
target_obs_sparse, num_qubits=num_qubits
)
# Determine the measurement basis each qubit requires for this observable.
bases, _ = get_measurement_bases(observable)
# Instantiate the mirrored circuit and prepend the basis-preparation gates
num_trotter_steps = 6
rx_angle = np.pi / 4
circuit = construct_ising_circuit(num_qubits, num_trotter_steps, rx_angle)
mirrored_circuit = mirror_circuit(circuit)
mirrored_circuit = prepare_basis(mirrored_circuit, bases[0])
print("Visualization of 1 mirrored Trotter step")
prepare_basis(
mirror_circuit(construct_ising_circuit(num_qubits, 1, rx_angle)), bases[0]
).draw("mpl", scale=0.6, fold=-1)
Visualization of 1 mirrored Trotter step
Calculer les valeurs moyennes bruitées et non atténuées
Un QuantumProgram contient un circuit quantique et un samplex dans un format adapté à la primitive Executor. MitigationTask.prepare construit un QuantumProgram à partir du circuit, des observables et des valeurs de paramètres, et place le circuit dans des boîtes selon les options de boxing données. Définis l'option de boxing measure_annotations sur "change_basis" pour faire pivoter les mesures vers les bases requises, et ajoute add_tags: "unique_box" afin que le simulateur puisse associer un modèle de bruit aux boîtes du circuit. Le programme est soumis par défaut au Pauli twirling, ce qui, sur le matériel, transforme le bruit cohérent en bruit de Pauli supposé par les méthodes d'atténuation. num_randomizations définit combien d'instances twirlées de chaque circuit sont échantillonnées et shots_per_randomization définit combien de shots reçoit chaque instance ; leur produit est donc la limite de shots par circuit.
Si tu as accès à un QPU, tu peux exécuter le programme avec un Executor construit à partir d'un Backend du QiskitRuntimeService. Cet exemple utilise un AerSimulator pour exécuter le programme localement. Son option de simulateur layer_noise_model injecte le bruit d'un modèle de bruit de Pauli-Lindblad donné sur chaque couche de Gates d'intrication. Ici, nous générons un modèle de bruit de Pauli-Lindblad aléatoire avec une erreur par Gate en couche (EPLG) de 0.01, presque un ordre de grandeur au-dessus des QPU Heron actuels typiques, afin d'amplifier l'effet de l'utilisation des SLC même sur un problème de petite taille.
from qiskit.circuit import CircuitInstruction
from qiskit.quantum_info import PauliLindbladMap, pauli_basis
from qiskit_aer import AerSimulator
from qiskit_ibm_runtime import Executor
from qiskit_mitigation import MitigationTask, find_combined_unique_layers
from samplomatic import Tag
from samplomatic.utils import get_annotation
def generate_random_pauli_lindblad_noise(
layers,
num_qubits: int,
eplg: float = 3e-3,
seed: int | None = None,
) -> list[tuple[CircuitInstruction, PauliLindbladMap]]:
"""Generate random Pauli-Lindblad noise over the full Pauli basis."""
layer_noise_model = []
for layer in layers:
layer_instructions = layer.operation.params[0]
contain_measurement = any(
inst.name.startswith("measure") for inst in layer_instructions
)
if contain_measurement: # do not add measurement noise
layer_noise = PauliLindbladMap.identity(num_qubits)
else:
edges = [
(inst.qubits[0]._index, inst.qubits[1]._index)
for inst in layer_instructions
] # extract the edges of the layer
basis_paulis = [p for p in pauli_basis(2) if np.sum(p.x + p.z)]
rng = np.random.default_rng(seed=seed)
num_generators = len(basis_paulis)
rates = rng.random(len(basis_paulis)) * eplg / num_generators
layer_noise = PauliLindbladMap.from_sparse_list(
[
(pauli.to_label(), edge, rate)
for pauli, rate in zip(basis_paulis, rates, strict=True)
for edge in edges
],
num_qubits=num_qubits,
)
layer_noise_model.append((layer, layer_noise))
return layer_noise_model
num_randomizations = 400
shots_per_randomization = 16
boxing_options = {"add_tags": "unique_box"}
# find the unique layers to generate the layered noise for
box_layers = find_combined_unique_layers(
[mirrored_circuit], custom_boxing_options=boxing_options
)
layer_noise_model = generate_random_pauli_lindblad_noise(
box_layers, num_qubits, eplg=1e-2, seed=42
)
# a local noisy simulator in place of a QPU backend
noisy_executor = Executor(
AerSimulator(),
options={"simulator": {"layer_noise_model": layer_noise_model}},
)
task = MitigationTask()
program = task.prepare(
circuit=mirrored_circuit,
observables=observable,
parameters=None,
num_randomizations=num_randomizations,
shots_per_randomization=shots_per_randomization,
custom_boxing_options=boxing_options,
)
program._semantic_role = "simulation" # flag the program for the simulator
job_unmit_res = noisy_executor.run(program).result()
unmitigated_res = task.postprocess(job_unmit_res)
print(f"unmitigated result: {unmitigated_res.data.evs[0]}")
unmitigated result: 0.63125
Calculer les bornes avant et arrière sur le biais
Avant de consacrer du temps de QPU à l'apprentissage du bruit, les SLC utilisent la simulation classique pour borner la contribution de chaque erreur potentielle au biais de l'observable. Cela se fait en deux passes complémentaires :
-
Propagation avant : chaque erreur de Pauli est propagée vers l'avant à travers le reste du circuit et son commutateur avec l'observable est évalué. Une erreur qui anticommute avec l'observable est le seul type d'erreur pouvant biaiser le résultat ; cette passe produit donc une borne supérieure de l'impact avant de chaque erreur.
-
Propagation arrière : chaque erreur est propagée vers l'arrière jusqu'au début du circuit et son commutateur avec l'état initial est évalué. Comme le circuit démarre dans l'état tout à zéro, les erreurs qui commutent avec lui ne peuvent pas affecter le résultat, ce qui resserre la borne dans l'autre direction.
Les deux bornes forment ensemble un encadrement serré de la contribution au pire cas de chaque erreur au biais. À l'étape suivante, ces bornes sont fusionnées avec les taux de bruit appris pour classer les erreurs qui valent la peine d'être corrigées et celles qui peuvent être laissées de côté sans risque. L'option de boxing inject_noise_strategy="individual_modification" est requise ici pour que chaque annotation InjectNoise du circuit placé en boîtes ait une identité unique à laquelle les bornes SLC puissent être rattachées.
Pour des problèmes de plus grande taille, ces calculs peuvent exiger des ressources de calcul classique substantielles.
from qiskit_addon_slc.bounds import (
compute_backward_bounds,
compute_forward_bounds,
tighten_with_speed_limit,
)
from qiskit_mitigation import PEC
slc_boxing_options = {
"add_tags": "unique_box",
"inject_noise_strategy": "individual_modification",
}
slc_pec_task = PEC()
boxed_circuit, noise_model_paulis = (
slc_pec_task.generate_slc_bounds_computation_input(
mirrored_circuit, custom_boxing_options=slc_boxing_options
)
)
forward_bounds = compute_forward_bounds(
boxed_circuit, noise_model_paulis, observable
)
tighten_forward_bounds = tighten_with_speed_limit(
forward_bounds, boxed_circuit, noise_model_paulis, observable
)
backward_bounds = compute_backward_bounds(boxed_circuit, noise_model_paulis)
Apprendre le bruit des couches d'intrication
Afin de calculer une estimation finale de l'impact de chaque erreur sur le biais de la valeur moyenne, ainsi que du coût de sa correction, nous apprenons les taux de bruit et les appliquons aux bornes de bruit prédites que nous avons calculées précédemment.
La PEC (ainsi que la PEC+SLC) suppose un modèle de bruit twirlé de chaque couche de Gates d'intrication du circuit. Si tu supposes un modèle de bruit markovien, la profondeur du circuit est supposée ne pas affecter le canal de bruit d'une couche d'intrication donnée. Par conséquent, seul le modèle de bruit de chaque couche unique de Gates d'intrication doit être appris. Le circuit utilisé dans ce guide peut être divisé en deux couches uniques. Concevoir des circuits avec un ensemble minimal de couches d'intrication uniques est important si tu veux apprendre un modèle de bruit, car les couches uniques doivent être apprises indépendamment. Si ton circuit contient trop de couches uniques, l'apprentissage du bruit peut devenir irréalisable en raison du coût d'échantillonnage de l'apprentissage de nombreuses couches.
qiskit-noise-learning est utilisé pour apprendre le bruit. Le module d'apprentissage répète chaque couche fragment_depths fois dans ses circuits d'apprentissage, applique le twirling à chaque circuit num_randomizations fois et ajuste la décroissance des fidélités mesurées à un modèle de Pauli-Lindblad. Ces paramètres peuvent affecter la précision du modèle appris. Plus le modèle de bruit appris est précis, plus l'annulation du bruit sera précise.
Le code suivant dessine le modèle de bruit appris avec les deux couches fusionnées en une seule carte. Chaque qubit est un diagramme circulaire de ses taux X, Y et Z de poids 1 additionnés sur les deux couches, et chaque arête indique les taux de poids 2 appris. Par exemple, un taux de 3e-4 signifie que l'erreur se produit environ trois fois sur 10 000 utilisations de la couche.
from qiskit.providers.fake_provider import GenericBackendV2
from qiskit.quantum_info import PauliList
from qiskit_ibm_runtime.results import LayerError, PauliLindbladError
from qiskit_ibm_runtime.visualization.embeddings import Embedding
from qiskit_noise_learning.aer_executor import AerExecutor
from qiskit_noise_learning.noise_learner import LearningOptions, NoiseLearner
from samplomatic import InjectNoise
line_coupling = [(i, i + 1) for i in range(num_qubits - 1)]
backend = GenericBackendV2(
num_qubits=num_qubits,
basis_gates=["id", "rz", "sx", "x", "cz"],
coupling_map=line_coupling,
)
# first learn only the layers PEC will inject noise into, i.e. not the measurement layer
pec_layers = [
layer
for layer in slc_pec_task.find_unique_layers(
mirrored_circuit, slc_boxing_options
)
if get_annotation(layer.operation, InjectNoise)
]
layers = slc_pec_task.find_unique_layers(mirrored_circuit, slc_boxing_options)
learning_options = LearningOptions(
fragment_depths=[2, 4, 8, 16],
num_randomizations=250,
shots_per_randomizations=16,
)
# qiskit-noise-learning is not compatible with the Executor in local mode,
# and requires the simulator present in the package for running simulation
noise_dict = {
get_annotation(layer.operation, Tag).ref: noise_layer
for layer, noise_layer in layer_noise_model
}
noise_learner_simulator = AerExecutor(
qasm_simulator=AerSimulator(), noise_dict=noise_dict
)
learner = NoiseLearner(
backend, options=learning_options, executor=noise_learner_simulator
)
learned_noise = (
learner.run(pec_layers).result().to_dict()
) # keyed by InjectNoise reference
# Draw the learned model on the line, laid out as a line with the qubits in order.
# Composing the two layers' maps sums their rates, giving one map over all the edges.
embedding = Embedding(
[(0, 0), (0, 2), (0, 4), (2, 4), (4, 4), (4, 2), (4, 0), (2, 0)],
line_coupling,
)
first, second = learned_noise.values()
learned_model = first.compose(second).simplify()
error = PauliLindbladError(
PauliList(
learned_model.get_qubit_sparse_pauli_list_copy().to_pauli_list()
),
learned_model.rates,
)
fig = LayerError(circuit, list(range(num_qubits)), error).draw_map(
embedding, radius=0.4, height=460, width=620
)
fig.update_traces(
marker={
"cmin": 0,
"cmax": max(learned_model.rates),
"colorbar": {"tickformat": ".1e", "title": "rate"},
},
selector={"mode": "markers"},
)
fig.show()
Utiliser le bruit appris pour fusionner les bornes calculées en une matrice d'échelles locales
Avec les bornes classiques (de l'étape précédente) et les taux de bruit appris en main, nous pouvons maintenant calculer une matrice d'échelles locales — un poids par erreur qui détermine avec quelle intensité la PEC doit annuler chaque terme de bruit individuel.
L'idée clé des SLC est que toutes les erreurs n'ont pas besoin d'être entièrement annulées. Une erreur dont les bornes avant et arrière sont toutes deux faibles a un effet négligeable sur l'observable quel que soit son taux ; elle peut donc être laissée partiellement ou totalement non atténuée. merge_bounds fusionne les bornes classiques avec les taux de Pauli-Lindblad appris, puis compute_local_scales attribue à chaque erreur une échelle comprise entre 0 (complètement ignorée) et 1 (entièrement annulée), sous réserve d'un bias_tolerance spécifié par l'utilisateur. Les erreurs dont la contribution estimée au biais est grande reçoivent une échelle proche de 1 (traitement PEC complet), tandis que celles dont la contribution est faible reçoivent une échelle plus basse, ce qui réduit le surcoût d'échantillonnage qu'elles entraînent.
Les SLC offrent un réglage continu entre les deux extrêmes — la PEC classique (biais nul, surcoût le plus élevé) et l'absence totale d'atténuation (surcoût nul, biais de bruit complet). Le paramètre bias_tolerance contrôle ce réglage : le fixer à 0 rétablit la PEC complète, tandis que des valeurs plus grandes autorisent davantage de biais résiduel en échange d'un surcoût d'échantillonnage γ² plus faible.
Cette étape balaie bias_tolerance sur une plage de valeurs et calcule le surcoût d'échantillonnage résultant pour chacune. Tracer ces points par rapport au surcoût de la PEC complète t'aide à choisir un point de fonctionnement compatible avec ton budget de shots. Par exemple, tolérer un biais résiduel de 5 % peut réduire le surcoût d'échantillonnage d'un ordre de grandeur par rapport à la PEC complète, rendant le calcul faisable sur les QPU actuels alors qu'il ne le serait pas autrement.
import matplotlib.pyplot as plt
from qiskit_addon_slc.bounds import compute_local_scales, merge_bounds
merged_bounds = merge_bounds(
boxed_circuit,
tighten_forward_bounds,
backward_bounds,
noise_rates=learned_noise,
)
biases = []
costs = []
for bias in [0.0, *np.arange(0.01, 0.11, 0.01).tolist()]:
_, cost_, bias_ = compute_local_scales(
boxed_circuit,
merged_bounds,
noise_rates=learned_noise,
bias_tolerance=bias,
)
biases.append(bias_)
costs.append(cost_)
# Compute tha full PEC overhead and compare it with the overhead and bias of different SLC configurations
vanilla_gamma = slc_pec_task.calculate_gamma(
slc_pec_task.boxed_circuit, learned_noise, 1
)
xticks = np.arange(0, 11, 2)
fig, ax = plt.subplots()
ax.scatter(
[0], [vanilla_gamma**2], marker="D", c="tab:orange", label="full PEC"
)
ax.plot(
100 * np.array(biases),
np.array(costs),
"o-",
c="tab:blue",
label="PEC+SLC",
)
ax.set_xticks(xticks, [f"{x:.2f}" for x in xticks])
ax.set_xlabel("Remaining bias [%]")
ax.set_ylabel(r"Sampling overhead, $\gamma^2$")
ax.grid()
ax.legend()
fig.suptitle("PEC sampling overhead reduction due to SLC")
Optimal spacetime partitioning not implemented! Just partitioning list of noisy boxes.
Text(0.5, 0.98, 'PEC sampling overhead reduction due to SLC')
Choisir la borne de biais souhaitée selon le compromis entre surcoût en shots et biais
Dans ce guide, nous choisissons une tolérance de biais de 5 %. Note que ce calcul de biais repose sur le modèle de bruit appris, qui peut ne pas être parfaitement aligné avec le bruit réel de l'appareil.
chosen_bias_threshold = 0.05
local_scales, sampling_cost, bias = compute_local_scales(
boxed_circuit, merged_bounds, learned_noise, bias_tolerance=bias
)
slc_pec_task.set_slc_local_scales(
local_scales, float(np.sqrt(sampling_cost)), bias
)
Préparer un programme PEC + SLC, exécuter le programme et post-traiter
Une fois les échelles locales choisies, nous pouvons construire le programme d'exécution PEC+SLC complet. L'appel PEC.prepare construit un QuantumProgram qui encode la stratégie d'injection d'anti-bruit dans les annotations de boîtes du circuit. Passer noise_gain="slc_local_scales" indique à l'exécuteur d'utiliser les poids par erreur définis par set_slc_local_scales, plutôt que d'appliquer le même gain de bruit inverse complet à chaque erreur comme le ferait la PEC classique.
Pour produire une comparaison équitable, une seconde tâche PEC est préparée avec noise_gain=0, ce qui correspond à l'annulation PEC complète sans marge de biais SLC. Les deux programmes sont ajoutés au même QuantumProgram afin de partager un seul job d'exécution — leurs circuits sont entrelacés et exécutés ensemble, ce qui signifie que toute dérive du matériel affecte les deux de la même façon.
Une fois le job terminé, postprocess convertit les chaînes de bits de mesure brutes en valeurs moyennes, en appliquant le facteur de remise à l'échelle PEC γ approprié pour chaque tâche.
program_pec = slc_pec_task.prepare(
circuit=mirrored_circuit,
observables=[observable],
parameters=None,
num_randomizations=num_randomizations,
shots_per_randomization=shots_per_randomization,
custom_boxing_options=slc_boxing_options,
noise_maps=learned_noise, # PEC option
noise_gain="slc_local_scales", # PEC option. Defines the usage of SLC
max_sampling_overhead=100, # PEC option
)
full_pec_task = PEC()
program_pec = full_pec_task.prepare(
circuit=mirrored_circuit,
observables=[observable],
parameters=None,
num_randomizations=num_randomizations,
shots_per_randomization=shots_per_randomization,
custom_boxing_options=boxing_options,
quantum_program=program_pec,
noise_maps=learned_noise, # PEC option
noise_gain=0, # PEC option
max_sampling_overhead=100, # PEC option
)
# Execute
program_pec._semantic_role = (
"simulation" # flag the program for the simulator
)
job_pec_results = noisy_executor.run(program_pec).result()
# post-processing
pec_slc_res = slc_pec_task.postprocess(job_pec_results)
full_pec_res = full_pec_task.postprocess(job_pec_results)
Comparer les valeurs moyennes atténuées et le surcoût en shots
La valeur moyenne idéale du circuit miroir est 1 par construction ; le biais de toute estimation est donc simplement |1 - result|. Cette dernière cellule affiche le biais et le nombre total de shots pour trois scénarios côte à côte :
-
Non atténué : la valeur moyenne bruitée brute, sans aucune annulation d'erreur appliquée.
-
PEC complète : annulation d'erreur à biais nul utilisant le modèle de bruit inverse complet sur chaque couche, au surcoût d'échantillonnage γ² complet.
-
PEC+SLC : annulation d'erreur guidée par les échelles locales des shaded lightcones, tolérant le
bias_tolerancechoisi en échange d'un nombre de shots nettement plus faible.
La comparaison des trois lignes montre l'avantage concret des SLC : le biais atténué de la PEC+SLC reste bien en dessous du biais non atténué, tandis que le nombre total de shots est nettement inférieur à ce que la PEC complète exigeait.
print(
f"Unmitigated result bias: {np.abs(1 - unmitigated_res.data.evs[0]) * 100:.2f}%, \
total shots ran: {job_unmit_res[0]['_meas'].shape[0] * job_unmit_res[0]['_meas'].shape[2]}"
)
print(
f"Full PEC final result bias: {np.abs(1 - full_pec_res.data.evs[0]) * 100:.2f}%, \
total shots ran: {job_pec_results[1]['_meas'].shape[0] * job_pec_results[1]['_meas'].shape[2]}"
)
print(
f"PEC+SLC final result bias: {np.abs(1 - pec_slc_res.data.evs[0]) * 100:.2f}%, \
total shots ran: {job_pec_results[0]['_meas'].shape[0] * job_pec_results[0]['_meas'].shape[2]}"
)
Unmitigated result bias: 36.88%, total shots ran: 6400
Full PEC final result bias: 0.97%, total shots ran: 48528
PEC+SLC final result bias: 1.46%, total shots ran: 24480