Como calcular limites personalizados
compute_forward_bounds e compute_backward_bounds são wrappers de conveniência. Ambos são construídos a partir
das mesmas três peças, que a API pública expõe:
-
Uma função de norma, que limita um único termo de erro de Pauli evoluído
-
Um cone de luz, que define para onde os termos de erro são evoluídos
-
compute_bounds, que itera o circuito e distribui o trabalho por termo
Montá-las você mesmo significa poder limitar algo que os wrappers não cobrem. Este guia percorre um caso em que isso vale a pena: um circuito espelho, em que o limite inverso usual é desnecessariamente frouxo.
O que os wrappers realmente fazem
Os dois wrappers diferem nos três pontos listados acima:
compute_forward_bounds | compute_backward_bounds | |
|---|---|---|
| circuito | como fornecido | remove_measure(circuit).inverse() |
| função de norma | time_evolved_norm_forward, em relação ao observável | time_evolved_norm_backward, em relação ao estado inicial |
| cone de luz | LightCone.initialize_from_pauli, definido a partir do observável | LightCone.initialize_from_measurements, definido a partir de cada qubit ativo |
backwards | False | True |
O limite direto pergunta "Quanto esse erro pode alterar o observável que estou prestes a medir?" e responde com a norma espectral de um comutador. O limite inverso pergunta "Quanto esse erro pode alterar o estado de que parti?" e responde com uma norma nuclear, tomando esse estado como o estado todo zero em cada qubit ativo. São perguntas diferentes, e cada uma é a certa para os erros em sua própria metade do circuito.
O caso do circuito espelho
Um circuito espelho aplica um unitário e depois o desfaz: . Eles aparecem sempre que o resultado ideal precisa ser conhecido exatamente (em geral ecos de Loschmidt, benchmarking randomizado e experimentos de verificação).
Circuitos espelho têm uma propriedade que o limite inverso genérico não consegue explorar. Como a segunda metade é a inversa da primeira, evoluir um erro para trás até o estado inicial percorre as mesmas portas que evoluí-lo para frente até o observável. As duas perguntas coincidem, então o limite mais afiado do tipo direto também é válido para a direção inversa.
# Added by doQumentation — installs the packages this notebook needs if they are missing
import importlib.util
_needed = {"numpy": "numpy", "qiskit": "qiskit", "qiskit_addon_slc": "qiskit-addon-slc", "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 numpy as np
from qiskit import QuantumCircuit
from qiskit.quantum_info import Pauli
from qiskit_addon_slc.utils import generate_noise_model_paulis
from samplomatic.transpiler import generate_boxing_pass_manager
from samplomatic.utils import find_unique_box_instructions
def trotter_ising_circuit(num_qubits, num_steps, rx_angle, rzz_angle):
"""Trotterized transverse-field Ising evolution on a 1D chain."""
circuit = QuantumCircuit(num_qubits)
for _ in range(num_steps):
circuit.rx(rx_angle, range(num_qubits))
circuit.barrier()
for start in (0, 1): # even then odd bonds
for i in range(start, num_qubits - 1, 2):
circuit.rzz(rzz_angle, i, i + 1)
circuit.barrier()
return circuit
num_qubits = 10
# The circuit of the quickstart guide...
forward_half = trotter_ising_circuit(
num_qubits, num_steps=4, rx_angle=np.pi / 16, rzz_angle=-np.pi / 3
)
# ...followed by its inverse. The ideal result is the initial state.
circuit = forward_half.compose(forward_half.inverse())
observable = Pauli("I" * num_qubits).compose("Z", [num_qubits // 2])
boxing_pass = generate_boxing_pass_manager(
inject_noise_targets="all",
inject_noise_strategy="individual_modification",
inject_noise_site="after",
twirling_strategy="active",
remove_barriers="never",
)
boxed_circuit = boxing_pass.run(circuit)
noise_model_paulis = generate_noise_model_paulis(
find_unique_box_instructions(boxed_circuit)
)
print(f"mirror circuit: {num_qubits} qubits, depth {circuit.depth()}")
print(f"noisy layers: {len(noise_model_paulis)}")
mirror circuit: 10 qubits, depth 24
noisy layers: 4
Monte o limite personalizado
A receita mantém o que compute_backward_bounds faz com o circuito (remover as medições,
invertê-lo e iterá-lo com backwards=True) e substitui as outras duas peças: a função de
norma direta e um cone de luz definido a partir do observável com LightCone.initialize_from_pauli,
exatamente como compute_forward_bounds define o seu. Cada substituição é uma linha.
from functools import partial
from qiskit_addon_slc.bounds import (
LightCone,
compute_bounds,
time_evolved_norm_forward,
)
from qiskit_addon_slc.utils import remove_measure
def compute_mirror_backward_bounds(
circuit,
noise_model_paulis,
/,
observable,
*,
evolution_max_terms=1_000_000,
**kwargs,
):
"""Bounds the backward direction of a mirror circuit with a forward-style bound.
Valid only for circuits whose second half inverts the first: the backward evolution then
traverses the same gates as the forward one, and reproduces the forward bounds.
"""
# As `compute_backward_bounds` does: strip measurements and invert, so that walking the circuit
# backwards from its end becomes walking the inverted circuit forwards.
circuit = remove_measure(circuit).inverse()
# The substitution: the forward norm function, bounding against the observable rather than
# against the initial state.
norm_fn = partial(
time_evolved_norm_forward,
observable=observable,
evolution_max_terms=evolution_max_terms,
)
# Seed the light cone from the observable, not from every active qubit.
light_cone = LightCone.initialize_from_pauli(circuit, observable)
return compute_bounds(
circuit,
noise_model_paulis,
light_cone,
norm_fn,
backwards=True,
**kwargs,
)
Quanto ele ganha
Comparando com o limite inverso padrão no mesmo circuito, resumido da mesma forma que no guia de configuração: o limite médio e seu desvio padrão, seguidos da fração de termos limitados por 0, com um limite calculado entre os dois e deixados no 2.0 trivial.
from qiskit_addon_slc.bounds import (
compute_backward_bounds,
compute_forward_bounds,
)
forward_bounds = compute_forward_bounds(
boxed_circuit, noise_model_paulis, observable
)
stock_backward = compute_backward_bounds(boxed_circuit, noise_model_paulis)
custom_backward = compute_mirror_backward_bounds(
boxed_circuit, noise_model_paulis, observable
)
def summarize(label, bounds):
"""Prints a one-line summary of a set of bounds."""
rates = np.concatenate([bound.rates for bound in bounds.values()])
# The bounds are floating-point results, so a term counts as bounded by 0, or as left at the
# trivial 2.0, when it is within rounding error of that value.
exact = np.isclose(rates, 0.0, rtol=0.0, atol=1e-12)
trivial = np.isclose(rates, 2.0, rtol=0.0, atol=1e-12)
computed = ~exact & ~trivial
print(
f"{label:18} mean {rates.mean():.4f} ± {rates.std():.3f} │ "
f"at 0: {exact.mean():4.0%} computed: {computed.mean():4.0%} "
f"at 2.0: {trivial.mean():4.0%}"
)
summarize("forward", forward_bounds)
summarize("backward (stock)", stock_backward)
summarize("backward (custom)", custom_backward)
forward mean 0.4323 ± 0.679 │ at 0: 38% computed: 61% at 2.0: 1%
backward (stock) mean 1.7218 ± 0.592 │ at 0: 0% computed: 95% at 2.0: 5%
backward (custom) mean 0.4323 ± 0.679 │ at 0: 38% computed: 61% at 2.0: 1%
O limite inverso padrão fica perto do valor trivial de 2.0, que quase não traz informação. Em um circuito espelho, todo erro está "perto do estado inicial" no sentido que esse limite mede, então ele não consegue discriminar entre os termos. O limite personalizado é várias vezes mais justo e limita muitos dos termos por 0.
Para verificar a implementação, os dois podem ser comparados caixa a caixa. Para cada caixa, o limite inverso personalizado sai idêntico ao limite direto dessa mesma caixa, a menos de erro de arredondamento. Ambos limitam o mesmo erro no mesmo ponto do circuito, e em um circuito espelho as portas depois desse ponto se cancelam na inversa das portas anteriores, então os dois cálculos avaliam a mesma grandeza a partir de extremidades opostas:
matches = sum(
np.allclose(forward_bounds[box_id].rates, custom_backward[box_id].rates)
for box_id in forward_bounds
)
print(f"{matches} of {len(forward_bounds)} boxes match the forward bounds")
worst = max(
np.abs(forward_bounds[box_id].rates - custom_backward[box_id].rates).max()
for box_id in forward_bounds
)
print(f"largest disagreement across all terms: {worst:.2e}")
16 of 16 boxes match the forward bounds
largest disagreement across all terms: 6.00e-15
Nota: Essa igualdade é uma propriedade da estrutura espelho, não da técnica. Em um circuito que não é autoinversível, a mesma substituição ainda executa, mas deixa de calcular um limite válido para a direção inversa: ela responde à pergunta direta sobre uma metade do circuito em que essa pergunta não se aplica. Use isso somente quando a segunda metade do seu circuito inverte a primeira.
Leve-o ao restante do fluxo de trabalho
O resultado é um dicionário Bounds comum, então tudo o que vem depois permanece inalterado.
Em um circuito espelho, também não há motivo para calcular qualquer limite para o circuito inteiro. Os
limites diretos só são necessários para as caixas de , contadas a partir do fim, e os limites
inversos apenas para as caixas de , contadas a partir do início. max_num_boxes limita, portanto, cada um
deles à metade das caixas ruidosas, e merge_bounds une as duas metades.
É assim que os limites são usados na prática, e isso significa que a comparação caixa a caixa acima não está mais disponível: ela precisa dos dois limites para cada caixa. É uma verificação da implementação para este guia, não uma etapa do fluxo de trabalho.
from qiskit.quantum_info import PauliLindbladMap, QubitSparsePauliList
from qiskit_addon_slc.bounds import compute_local_scales, merge_bounds
# Stand-in for rates that a noise-learning experiment would measure on hardware.
rng = np.random.default_rng(42)
noise_rates = {
layer: PauliLindbladMap.from_components(
rng.random(len(terms)) * 5e-3,
QubitSparsePauliList.from_sparse_list(
terms.to_sparse_list(), terms.num_qubits
),
)
for layer, terms in noise_model_paulis.items()
}
# Compute each bound for its own half of the mirror circuit only. `forward_bounds` has an entry
# for every noisy box, which are the boxes `max_num_boxes` counts.
half = len(forward_bounds) // 2
half_forward = compute_forward_bounds(
boxed_circuit, noise_model_paulis, observable, max_num_boxes=half
)
half_stock = compute_backward_bounds(
boxed_circuit, noise_model_paulis, max_num_boxes=half
)
half_custom = compute_mirror_backward_bounds(
boxed_circuit, noise_model_paulis, observable, max_num_boxes=half
)
print(
f"each bound computed for {half} of {len(forward_bounds)} noisy boxes\n"
)
print(f"{'bounds':34} {'overhead':>12} {'residual bias':>14}")
for label, bounds in (
(
"forward + stock backward",
merge_bounds(boxed_circuit, half_forward, half_stock, noise_rates),
),
(
"forward + custom backward",
merge_bounds(boxed_circuit, half_forward, half_custom, noise_rates),
),
("forward only, whole circuit", forward_bounds),
):
_, cost, bias = compute_local_scales(
boxed_circuit, bounds, noise_rates, bias_tolerance=0.05
)
print(f"{label:34} {cost:12.4f} {bias:14.4f}")
Optimal spacetime partitioning not implemented! Just partitioning list of noisy boxes.
Optimal spacetime partitioning not implemented! Just partitioning list of noisy boxes.
each bound computed for 8 of 16 noisy boxes
bounds overhead residual bias
forward + stock backward 29848.0583 0.0498
forward + custom backward 465.4028 0.0498
forward only, whole circuit 465.4028 0.0498
Com os limites inversos padrão, o overhead de amostragem é bem maior. merge_bounds usa os
limites inversos até um ponto de troca no circuito e os limites diretos a partir dali,
escolhendo o ponto que minimiza o viés total. Com cada limite calculado para uma metade, esse ponto
cai no meio do circuito, então as caixas de são cobertas apenas por limites inversos. O
limite inverso padrão está perto do 2.0 trivial ali, o que eleva o overhead. O personalizado
é tão justo quanto o limite direto seria, então o overhead coincide com o dos limites diretos
completos.
Isso levanta a pergunta óbvia de por que se dar ao trabalho, quando os limites diretos completos sozinhos dão o mesmo resultado. A resposta é o custo. Um limite direto para uma caixa no início do circuito evolui seu erro pelo restante de e depois por todo , que é a evolução mais longa e mais larga do circuito. O operador evoluído pode crescer exponencialmente no número de portas não Clifford pelas quais passa, e calcular a norma espectral do comutador fica exponencialmente mais caro à medida que o cone de luz se alarga. Na abordagem meio a meio, nenhum erro é evoluído por mais da metade do circuito.
A economia, portanto, cresce com a profundidade do circuito. Mais importante, os limites diretos completos
deixam de ser calculáveis muito antes dos de meio circuito. Quando as evoluções das primeiras
caixas excedem evolution_max_terms ou eigval_max_qubits, seus limites são truncados ou recorrem
à desigualdade triangular, e por fim custam demais para calcular. Onde quer que esse ponto
esteja, a abordagem meio a meio só precisa que ele caia em algum lugar da primeira metade do circuito.
Os limites diretos não precisam ir além do meio, e em um circuito espelho, os limites inversos
para as caixas de evoluem seus erros de volta ao início por tanto circuito quanto os limites diretos
para as caixas correspondentes de percorrem até o fim, então ambos se esgotam
na mesma profundidade.
Nota: Por que calcular limites inversos para as caixas de , se os limites diretos das caixas de parecem conter os mesmos números? Porque as caixas não são imagens espelhadas umas das outras. Cada caixa injeta seu ruído em um lado de suas portas, e as camadas inversas de são caixas separadas com modelos de ruído próprios, então a caixa que espelha uma caixa de vê seus erros em um ponto diferente do circuito. Por isso, seus limites só concordam em parte, e os limites das caixas de precisam ser calculados para essas caixas.
Escrevendo outros limites personalizados
As mesmas três substituições cobrem outros casos:
-
Uma norma diferente.
time_evolved_norm_forwardaceitacomm_norm_order; passar qualquer valor diferente de2pula o autossolucionador e calcula essa norma diretamente. -
Um cone de luz diferente.
LightCone.initialize_from_paulidefine um cone de luz a partir de qualquer Pauli, e o construtor deLightConerecebe os qubits a rastrear e as operações que o definem, de modo que um limite pode ser calculado em relação a qualquer operador que você consiga expressar dessa forma. -
Uma função de norma totalmente diferente.
compute_boundsexige apenas um callable: que receba umPaulieRotationGatese retorne umCommutatorBounds. Qualquer coisa que satisfaça essa assinatura funciona, incluindo um limite em forma fechada específico da sua família de circuitos.
O que quer que você substitua, é preciso provar que o resultado é um limite superior genuíno. Subestimar o efeito de um termo de erro não produz uma falha visível. Produz um valor esperado silenciosamente errado, porque o termo terá sido removido do modelo de ruído mitigado.