Entradas e saídas do Samplex
Introdução
Samplex é o tipo central da biblioteca samplomatic. Um samplex representa uma distribuição de probabilidade paramétrica sobre os parâmetros de algum circuito template, com outros campos de valores em array para usar no pós-processamento dos dados coletados da execução do circuito template vinculado. Sua interface central é o método sample(), que sorteia dessa distribuição para produzir uma coleção de arrays.
Este guia descreve como usar a interface sample(), como consultar e especificar as entradas necessárias e como inspecionar as saídas esperadas. Os arrays fornecidos a sample() e retornados por ele são fortemente tipados. Ou seja, para uma instância de um samplex, seus nomes, tipos e formatos são fixos e podem ser consultados antes de a amostragem ser feita. As entradas e saídas presentes em um samplex dependem de muitos fatores. Por exemplo, se há twirling de medição e se a injeção de ruído é necessária. Por esse motivo, em geral, não se espera que duas instâncias de samplex tenham as mesmas entradas e saídas.
Configuração
Primeiro, construa um circuito com caixas e transforme-o em um par samplex e template. Eles serão usados nos exemplos a seguir. Observe que, ao colocar o circuito em caixas manualmente, este guia usa os nomes alpha, beta, ref1, ref2, mod_ref1, mod_ref2, mod_ref3 e conclude. Além disso, o circuito tem quatro objetos Parameter. Este guia mostra como cada um deles desempenha um papel nas entradas e saídas do samplex. Consulte o guia do transpilador para obter informações sobre como colocar circuitos em caixas automaticamente.
import matplotlib.pyplot as plt
import numpy as np
from qiskit.circuit import ClassicalRegister, Parameter, QuantumCircuit, QuantumRegister
from qiskit.quantum_info import Operator, Pauli, PauliLindbladMap
from samplomatic import ChangeBasis, InjectNoise, Twirl, build
# our circuit has two classical registers named alpha and beta
circuit = QuantumCircuit(
QuantumRegister(4), alpha := ClassicalRegister(3, "alpha"), beta := ClassicalRegister(1, "beta")
)
# the first box is only twirled
with circuit.box([Twirl()]):
for idx in range(4):
circuit.rx(Parameter(f"a{idx}"), idx)
circuit.cz(0, 1)
circuit.cz(1, 2)
# the second box is twirled, and has noise injected
with circuit.box([Twirl(), InjectNoise(ref="ref1", modifier_ref="mod_ref1")]):
circuit.h(1)
circuit.cz(1, 2)
# the third box is twirled, and has different noise injected
with circuit.box([Twirl(), InjectNoise(ref="ref2", modifier_ref="mod_ref2")]):
circuit.rx(0.1, range(4))
circuit.cz(0, 1)
circuit.cz(1, 2)
# the fourth box is the same as the second, but with a different modifer ref
with circuit.box([Twirl(), InjectNoise(ref="ref1", modifier_ref="mod_ref3")]):
circuit.h(1)
circuit.cz(1, 2)
circuit.barrier()
# the final two boxes twirl, and add a basis change in one case
with circuit.box([Twirl(), ChangeBasis(ref="conclude")]):
circuit.measure(range(3), alpha)
with circuit.box([Twirl()]):
circuit.measure([3], beta)
circuit.draw("mpl")
Em seguida, chame build() no circuito com caixas para construir um par template e samplex. Você verá como cada uma das caixas é transformada em um “sanduíche” de barreiras que inclui fragmentos rz-sx-rz-sx-rz para implementar o dressing.
template, samplex = build(circuit)
template.draw("mpl", fold=100)
Plote o DAG do samplex e passe o mouse sobre os nós para ver o seguinte:
-
Todos os nós de amostragem (estrelas) são responsáveis por gerar randomizações para twirling, amostrar de modelos de ruído ou injetar mudanças de base.
-
Todos os nós de coleta (gravatas-borboleta) são responsáveis por renderizar fatias de saída, que são inversões de medição ou ângulos de parâmetros.
-
Todos os nós intermediários (círculos) são responsáveis por vários tipos de manipulação de dados.
samplex.draw()
Consultar as entradas necessárias e as saídas esperadas
A maneira mais fácil de ver as entradas necessárias e as saídas esperadas é imprimir o objeto Samplex. Os itens de array são formatados como '{name}' <{type}[{shape}...]>, e as entradas necessárias precedem as entradas opcionais.
print(samplex)
Samplex(<58 nodes>)
Inputs:
- 'basis_changes.conclude' <uint8[3]>: Basis changing gates, in the symplectic ordering
I=0, Z=1, X=2, and Y=3.
- 'parameter_values' <float64[4]>: Input parameter values to use during sampling.
- 'pauli_lindblad_maps.ref1' <PauliLindbladMap>: A PauliLindblad map acting on 2 qubits,
with 'num_terms_ref1' terms.
- 'pauli_lindblad_maps.ref2' <PauliLindbladMap>: A PauliLindblad map acting on 4 qubits,
with 'num_terms_ref2' terms.
- 'local_scales.mod_ref1' <float64['num_terms_ref1']>: (Optional) An array of factors by
which to scale individual rates of a Pauli Lindblad map, where the order should match the
corresponding list of terms.
- 'local_scales.mod_ref2' <float64['num_terms_ref2']>: (Optional) An array of factors by
which to scale individual rates of a Pauli Lindblad map, where the order should match the
corresponding list of terms.
- 'local_scales.mod_ref3' <float64['num_terms_ref1']>: (Optional) An array of factors by
which to scale individual rates of a Pauli Lindblad map, where the order should match the
corresponding list of terms.
- 'noise_scales.mod_ref1' <float64[]>: (Optional) A scalar factor by which to scale rates
of a Pauli Lindblad map.
- 'noise_scales.mod_ref2' <float64[]>: (Optional) A scalar factor by which to scale rates
of a Pauli Lindblad map.
- 'noise_scales.mod_ref3' <float64[]>: (Optional) A scalar factor by which to scale rates
of a Pauli Lindblad map.
Outputs:
* 'measurement_flips.alpha' <bool['num_randomizations', 1, 3]>: Bit-flip corrections
for measurement twirling.
* 'measurement_flips.beta' <bool['num_randomizations', 1, 1]>: Bit-flip corrections for
measurement twirling.
* 'parameter_values' <float32['num_randomizations', 48]>: Parameter values valid for an
associated template circuit.
* 'pauli_signs' <bool['num_randomizations', 3]>: Signs from sampled Pauli Lindblad
maps, where boolean values represent the parity of the number of non-trivial factors in the
sampled error that arise from negative rates. In other words, in order to implement basic
PEC, the sign used to correct expectation values should be ``(-1)**bool_value``. The order
matches the iteration order of boxes in the original circuit with noise injection
annotations.
As inputs() e outputs() do samplex, assim como informações mais detalhadas como seus nomes, formatos, tipos e descrições, também podem ser consultadas programaticamente. O tipo de retorno de ambos os métodos é um TensorInterface. Por exemplo, o código a seguir lista todas as especificações da interface de entrada:
samplex.inputs().specs
[TensorSpecification('basis_changes.conclude', (3,), dtype('uint8'), 'Basis changing gates, in the symplectic ordering I=0, Z=1, X=2, and Y=3.'),
TensorSpecification('local_scales.mod_ref1', ('num_terms_ref1',), dtype('float64'), 'An array of factors by which to scale individual rates of a Pauli Lindblad map, where the order should match the corresponding list of terms.', optional=True),
TensorSpecification('local_scales.mod_ref2', ('num_terms_ref2',), dtype('float64'), 'An array of factors by which to scale individual rates of a Pauli Lindblad map, where the order should match the corresponding list of terms.', optional=True),
TensorSpecification('local_scales.mod_ref3', ('num_terms_ref1',), dtype('float64'), 'An array of factors by which to scale individual rates of a Pauli Lindblad map, where the order should match the corresponding list of terms.', optional=True),
TensorSpecification('noise_scales.mod_ref1', (), dtype('float64'), 'A scalar factor by which to scale rates of a Pauli Lindblad map.', optional=True),
TensorSpecification('noise_scales.mod_ref2', (), dtype('float64'), 'A scalar factor by which to scale rates of a Pauli Lindblad map.', optional=True),
TensorSpecification('noise_scales.mod_ref3', (), dtype('float64'), 'A scalar factor by which to scale rates of a Pauli Lindblad map.', optional=True),
TensorSpecification('parameter_values', (4,), dtype('float64'), 'Input parameter values to use during sampling.'),
PauliLindbladMapSpecification('pauli_lindblad_maps.ref1', num_qubits=2, num_terms=num_terms_ref1),
PauliLindbladMapSpecification('pauli_lindblad_maps.ref2', num_qubits=4, num_terms=num_terms_ref2)]
O próximo exemplo imprime algumas informações sobre os especificadores de saída cujos nomes contêm a string "flips".
for spec in samplex.outputs().get_specs("flips"):
print(spec.name, spec.shape, spec.dtype)
measurement_flips.alpha ('num_randomizations', 1, 3) bool
measurement_flips.beta ('num_randomizations', 1, 1) bool
Vincular valores de entrada e amostrar
Para executar sample(), o samplex precisa de valores para pelo menos as inputs() marcadas como necessárias. Esses valores são vinculados primeiro a uma interface de entrada para que ela possa realizar toda a verificação de tipos necessária (ou coerção de tipos, em alguns casos) e a análise de formato. Se algo estiver errado com os tipos ou formatos, ou se um valor necessário estiver faltando, um erro detalhado é lançado.
Vincular a entrada
No exemplo a seguir, o método sample() recebe os valores de entrada necessários. Ele é então usado para sortear três randomizações do samplex. As vinculações necessárias dos dressings são os mapas de Pauli Lindblad para injeção de ruído e o array de mudança de base. Além disso, são necessários valores para os quatro parâmetros do circuito original com caixas, que neste caso são definidos como np.linspace(0, 1, 4). O samplex compõe esses parâmetros nos dressings, de modo que eles se tornam, na prática, modificações dos valores dos parâmetros de saída do samplex.
inputs = samplex.inputs().bind(
# bind() concatenates nested dicts with a '.' so that we can
# set 'pauli_lindblad_maps.noise0/1' as follows. note that the
# names 'ref1' and 'ref2' derive from the names in the InjectNoise
# annotation.
pauli_lindblad_maps={
"ref1": PauliLindbladMap.identity(2),
"ref2": PauliLindbladMap.identity(4),
},
# likewise, we can set all basis changes as follows, where
# the name 'conclude' comes from our basis change annotation
basis_changes={"conclude": [0, 1, 2]},
# we must provide values for all parameters in the original circuit
parameter_values=np.linspace(0, 1, 4),
)
outputs = samplex.sample(inputs, num_randomizations=3)
print(outputs)
SamplexOutput(<
- Dimension constraints: num_randomizations=3
* 'measurement_flips.alpha' <bool['num_randomizations', 1, 3]>: Bit-flip corrections for
measurement twirling.
* 'measurement_flips.beta' <bool['num_randomizations', 1, 1]>: Bit-flip corrections for
measurement twirling.
* 'parameter_values' <float32['num_randomizations', 48]>: Parameter values valid for an
associated template circuit.
* 'pauli_signs' <bool['num_randomizations', 3]>: Signs from sampled Pauli Lindblad maps,
where boolean values represent the parity of the number of non-trivial factors in the sampled
error that arise from negative rates. In other words, in order to implement basic PEC, the
sign used to correct expectation values should be ``(-1)**bool_value``. The order matches the
iteration order of boxes in the original circuit with noise injection annotations.
>)
As entradas podem ser vinculadas à interface por meio de várias chamadas a bind() ou por atribuição direta de itens. Apesar do formato de dicionário aninhado que bind() permite por conveniência, ela é um objeto de mapeamento plano e oferece suporte a toda a sintaxe usual de mapeamentos. Quando todas as entradas necessárias forem encontradas, fully_bound se torna verdadeiro.
inputs = (
samplex.inputs()
.bind(
pauli_lindblad_maps={
"ref1": PauliLindbladMap.identity(2),
"ref2": PauliLindbladMap.identity(4),
}
)
.bind(parameter_values=np.linspace(0, 1, 4))
)
# once the final requirement is bound, fully_bound becomes True
print("Fully bound?", inputs.fully_bound)
inputs["basis_changes.conclude"] = [0, 2, 3]
print("Fully bound?", inputs.fully_bound)
# setting optional values does not affect whether the interface is fully bound
inputs["noise_scales.mod_ref1"] = 3
print("Fully bound?", inputs.fully_bound)
Fully bound? False
Fully bound? True
Fully bound? True
Todos os objetos TensorInterface, incluindo a subclasse SamplexOutput, mapeiam objetos para os dados que foram vinculados a eles. Isso significa que você pode obter, por exemplo, os valores dos parâmetros do template da seguinte forma. Neste caso, ele tem formato (3, 48), pois três randomizações foram solicitadas e o circuito template tem 48 parâmetros.
print("(num_randomizations, num_template_params) =", outputs["parameter_values"].shape)
print(outputs["parameter_values"][0, :])
(num_randomizations, num_template_params) = (3, 48)
[ 0.0000000e+00 0.0000000e+00 0.0000000e+00 1.5707964e+00
3.3333334e-01 1.5707964e+00 -1.5707964e+00 2.4749260e+00
-1.5707964e+00 1.5707964e+00 1.0000000e+00 1.5707964e+00
0.0000000e+00 1.5707964e+00 0.0000000e+00 3.1415927e+00
0.0000000e+00 0.0000000e+00 1.5707964e+00 3.0415926e+00
-1.5707964e+00 -1.5707964e+00 3.0415926e+00 -1.5707964e+00
-1.5707964e+00 1.0000000e-01 1.5707964e+00 1.5707964e+00
1.0000000e-01 1.5707964e+00 3.1415927e+00 1.5707964e+00
0.0000000e+00 0.0000000e+00 0.0000000e+00 0.0000000e+00
0.0000000e+00 3.1415927e+00 0.0000000e+00 3.1415927e+00
0.0000000e+00 0.0000000e+00 3.1415927e+00 1.5707964e+00
0.0000000e+00 4.4408921e-16 3.1415927e+00 0.0000000e+00]
Especificar a entrada como um dicionário
Em vez de fornecer um TensorInterface totalmente vinculado, você pode especificar as entradas como um dicionário padrão, como mostrado na célula abaixo. Neste caso, o método sample() vincula internamente os itens do dicionário à interface de entrada do samplex para validar que todas as entradas necessárias estão presentes e são compatíveis.
inputs = {
"pauli_lindblad_maps": {
"ref1": PauliLindbladMap.identity(2),
"ref2": PauliLindbladMap.identity(4),
},
"basis_changes": {"conclude": [0, 1, 2]},
"parameter_values": np.linspace(0, 1, 4),
}
outputs = samplex.sample(inputs, num_randomizations=3)
Tipos de entrada
Atualmente, existem dois tipos de valor de interface:
-
Com valores em array: Eles têm um formato e um tipo de dado. Os valores vinculados são convertidos para o tipo de dado, se possível. Qualquer dimensão pode ser uma dimensão livre.
-
Mapas de Pauli Lindblad: Eles devem ser do tipo
qiskit.quantum_info.PauliLindbladMape devem atuar sobre o número prescrito de qubits. O número de termos no mapa é uma dimensão livre.
# up to precision loss, these are equivalent via automatic type coersion of array inputs
samplex.inputs()["parameter_values"] = [1, 2, 8, 9]
samplex.inputs()["parameter_values"] = np.array([1, 2, 8, 9], dtype=np.float16)
Dimensões livres
A classe TensorInterface tem a noção de dimensões livres, que são tamanhos inteiros nomeados cujos valores ficam indeterminados até que algum dado que os restrinja seja vinculado à interface. Todas as dimensões livres com o mesmo nome devem resolver para um tamanho consistente ao vincular dados, caso contrário um erro será lançado. Por exemplo, nas outputs() de qualquer samplex, o eixo do array correspondente às randomizações é um parâmetro livre que, por convenção, tem o nome "num_randomizations". Quando sample() é chamado, ele vincula valores às saídas
print("All free dimensions:", outputs.free_dimensions)
print("Current constraints:", outputs.bound_dimensions)
All free dimensions: {'num_randomizations'}
Current constraints: {'num_randomizations': 3}
Outro cenário comum de dimensão livre é o compartilhamento de restrições de termos entre as especificações de mapas de Pauli Lindblad e seus modificadores. Um qiskit.quantum_info.PauliLindbladMap é uma sequência ordenada de termos, em que cada termo contém uma taxa de ponto flutuante e uma representação de Pauli esparsa. A função build() adiciona um especificador "pauli_lindblad_maps.<ref>" para esse tipo para cada anotação InjectNoise e, se ela contiver um modifier_ref, um especificador "local_scales.<modifier_ref>" também é adicionado. Ambos compartilham a dimensão livre "num_terms_<ref>", que especifica quantos termos de Pauli Lindblad estão presentes no modelo de ruído, porque o último precisa ser combinado (zip) com as taxas do primeiro para escalá-las.
# both the PauliLindbladMap and the local scales imply 2 terms, so that the free dimension
# 'num_terms_noise2' is satisfied
inputs = samplex.inputs().bind(
pauli_lindblad_maps={"ref2": PauliLindbladMap.from_list([("XXYZ", 0.1), ("IIXX", 0.2)])},
local_scales={"mod_ref2": [1, 2]},
)
print("All free dimensions:", inputs.free_dimensions)
print("Current constraints:", inputs.bound_dimensions)
All free dimensions: {'num_terms_ref1', 'num_terms_ref2'}
Current constraints: {'num_terms_ref2': 2}
Convenção de ordenação de qubits
Samplexes construídos a partir de circuitos com caixas pela função build() podem exigir entradas com valores em array para sample(), em que um índice corresponde aos qubits na caixa de um circuito. Esta seção explica a convenção de ordenação que deve ser usada para esses eixos. Em resumo: use a ordem de qubits do circuito com caixas, restrita aos qubits da caixa.
Por exemplo, suponha que você queira descobrir como especificar um mapa de Pauli Lindblad para "noise1". Então a ordem dos qubits no mapa de ruído é em relação a estes qubits:
box_of_interest = circuit[1]
qubit_ordering_convention = [qubit for qubit in circuit.qubits if qubit in box_of_interest.qubits]
qubit_ordering_convention
[<Qubit register=(4, "q0"), index=1>, <Qubit register=(4, "q0"), index=2>]
Ou seja, se você quiser que <Qubit register=(4, "q0"), index=2> seja ruidoso com ruído X, então você deve definir um mapa de Pauli Lindblad como este, porque ele está no índice 1 de qubit_ordering_convention.
samplex.inputs().bind(
pauli_lindblad_maps={
"ref1": (noise1 := PauliLindbladMap.from_sparse_list([("X", [1], 0.12)], num_qubits=2))
}
)
noise1
<PauliLindbladMap with 1 term on 2 qubits: (0.12)L(X_1)>
O mesmo vale para mudanças de base. Se você quiser rotacionar para os autoestados +1 dos operadores X, Y e Z para os qubits 0, 1 e 2 de circuit, respectivamente, então você deve usar o array a seguir, lembrando que a convenção , , e é usada nesta biblioteca (e no Qiskit).
samplex.inputs().bind(basis_changes={"conclude": (basis_change := [2, 3, 1])})
basis_change
[2, 3, 1]
Observe que a convenção de ordenação de qubits não é a ordem definida por nenhum dos seguintes:
-
box_instruction.qubits, em quebox_instructioné umaqiskit.circuit.CircuitInstructioncuja operação é umqiskit.circuit.BoxOp, porque a ordem dessa lista de qubits pode depender da ordem em que as instruções foram adicionadas ao contexto da caixa, o que seria uma convenção confusa. Por exemplo,with box(): circuit.x([0, 1])ewith box(): circuit.x([1, 0])resultariam em ordenações diferentes. -
box_instruction.operation.body.qubits, porque esses qubits podem nem aparecer no circuito externo. Os qubits dentro do corpo real das instruçõesBoxOp(ou de qualquer outra operação de fluxo de controle comoIfElseOp) só têm significado consistente garantido dentro desse escopo. Essa seria uma convenção mal definida e difícil de usar em geral.
Testes locais com o circuito template
Se um Samplex for construído usando a função build(), os valores dos parâmetros de saída devem ser diretamente compatíveis com os parâmetros do circuito template associado. Por exemplo, você pode vincular ao template os valores de uma das randomizações para inspeção local.
inputs = samplex.inputs().bind(
pauli_lindblad_maps={
"ref1": PauliLindbladMap.identity(2),
"ref2": PauliLindbladMap.identity(4),
},
basis_changes={"conclude": [0, 0, 0]},
parameter_values=np.linspace(0, 1, 4),
)
outputs = samplex.sample(inputs, num_randomizations=3)
bound_template = template.assign_parameters(outputs["parameter_values"][2])
bound_template.draw("mpl", fold=1000)
Isso permite verificar se o samplex está produzindo as amostras esperadas. Por exemplo, o código a seguir converte o circuito vinculado em um objeto qiskit.quantum_info.Operator e o compõe com os Paulis de inversão de bit para inspecionar visualmente se as três randomizações são logicamente equivalentes ao circuito base.
from samplomatic.utils import unbox
# Operator requires that we unpack all of the boxes first
unboxed = unbox(circuit).assign_parameters(inputs["parameter_values"])
# We want to plot unitary matrix amplitudes, so we need to remove the non-unitary measurements
unboxed.remove_final_measurements()
unboxed_unitary = Operator(unboxed)
# Plot magnitudes of the unitary matrix represenation of the circuit
plt.subplot(1, 4, 1)
plt.imshow(np.abs(unboxed_unitary))
plt.title("Base Circuit")
# For each randomization we sampled, plot the unitary matrix representation
for idx in range(3):
bound_template = template.assign_parameters(outputs["parameter_values"][idx])
bound_template.remove_final_measurements()
# The example does measurement twirling by compiling random bitflip gates before the
# measurements, so we need to undo these at the operator level for each randomization.
# We do this by casting them to Paulis and composing with the bound circuit's unitary
alpha_flips = Pauli(([0, 0, 0], outputs["measurement_flips.alpha"][idx, 0]))
beta_flips = Pauli(([0], outputs["measurement_flips.beta"][idx, 0]))
flips = beta_flips ^ alpha_flips
bound_unitary = Operator(bound_template) & flips
# Plot magnitudes of the unitary matrix represenation of the circuit
plt.subplot(1, 4, idx + 2)
plt.imshow(np.abs(bound_unitary))
plt.title(f"Randomization {idx}")
plt.tight_layout()

De forma semelhante, o código a seguir passa os valores dos parâmetros de saída e o template a um sampler para simular 10.000 shots de todas as randomizações e compara os valores esperados resultantes (apenas do registrador alpha) com os valores esperados de uma simulação do circuito base.
from qiskit.primitives import StatevectorEstimator as Estimator
from qiskit.primitives import StatevectorSampler as Sampler
from qiskit.primitives.containers import BitArray
# simulate some expectation values direction from the base circuit
estimator_job = Estimator().run([(unboxed, ["IZII", "IIZI", "IIIZ"])])
evs = estimator_job.result()[0].data["evs"]
# get the sampler data from each randomization, and do bitflip correction
sampler_job = Sampler().run([(template, outputs["parameter_values"])], shots=10_000)
alpha_data = sampler_job.result()[0].data["alpha"]
alpha_data ^= BitArray.from_bool_array(outputs["measurement_flips.alpha"], "little")
# compare the results
print(" EVs from base circuit:", evs)
print("EVs from randomizations:", alpha_data.expectation_values(["ZII", "IZI", "IIZ"]))
EVs from base circuit: [0.7819611 0.93553883 0.99500417]
EVs from randomizations: [0.789 0.9366 0.995 ]

