Usage Guide¶
This guide introduces the core workflow of QC Executor — building a backend agnostic circuit and observable, creating an executor through the factory, and evaluating it — followed by a basic, runnable example for each backend.
Backend examples
Core building blocks¶
Every workflow uses three backend-independent objects from the package root:
QuantumCircuitA backend-agnostic circuit, constructed with a Qiskit-like gate API (
h(),cx(),ryy(), …). Gate angles may be plain numbers or symbolic parameter expressions. Theqiskit_circuitproperty exports the circuit to Qiskit, and QiskitParameterVectorelements are accepted as gate angles directly.QuantumOperatorAn observable expressed as a weighted sum of Pauli strings, e.g.
QuantumOperator(["ZI", "IZ"], [1.0, 1.0]). Coefficients may also be symbolic. Qubitqis characterqof the label, so"ZI"acts with Z on qubit 0 — see Qubit ordering.ParametersA named, indexable vector of free parameters used to build symbolic gate angles and observable coefficients.
Parameters("x", 2)createsx[0]andx[1]. Elements are SymPy symbols, so2 * x[0],p[0] * x[0]andsympy.sin(x[0])all work and stay differentiable.
None of these depends on a quantum framework. import qc_executor pulls in
nothing but NumPy and SymPy; a backend is imported the first time you ask for
one.
from qc_executor import QuantumCircuit, QuantumOperator, Parameters
x = Parameters("x", 1)
p = Parameters("p", 2)
qc = QuantumCircuit(2)
qc.h(0)
qc.ryy(0, 1, p[0] * x[0]) # symbolic gate angle
observable = QuantumOperator(["ZI", "IZ"], [1.0, 1.0])
The factory: Executor¶
Executor is a factory, not something you
instantiate. Calling Executor() raises TypeError. Instead, you create a
concrete backend executor with Executor.create:
from qc_executor import Executor
# Which backends are installed in this environment?
print(Executor.available_backends())
# e.g. ['pauli_propagation', 'pennylane', 'qiskit', 'qulacs']
# Create an executor by backend name
executor = Executor.create("qiskit", shots=1024, seed=42)
The first positional argument (target) is usually a backend name, but it can
also be a backend object — for example a Qiskit BackendV2 instance — in
which case the matching plugin is auto-detected:
from qiskit_ibm_runtime.fake_provider import FakeManilaV2
executor = Executor.create(FakeManilaV2(), shots=2048) # -> QiskitExecutor
An executor that already exists is passed through unchanged, so a function
can accept either a backend name or a ready executor and call
Executor.create on both.
All remaining keyword arguments are forwarded to the backend constructor. The
options shared by every backend (defined on
ExecutorBase) are:
Parameter |
Description |
|---|---|
|
Number of measurement shots. |
|
Random seed for reproducible sampling. |
|
Path to a log file. |
|
|
|
Enable in-memory caching of results. |
|
Directory used for caching. Defaults to |
|
Maximum number of cached entries. Defaults to |
Backend-specific options (such as the Qiskit execution_mode or the
PennyLane device backend name) are documented on each backend’s page and in
the API Reference.
Switching backends¶
An executor’s configuration can be transferred to another backend with
switch_backend, optionally
overriding individual settings:
qiskit_executor = Executor.create("qiskit", shots=1024, seed=42)
# Same shots/seed, different backend
pennylane_executor = qiskit_executor.switch_backend("pennylane")
# Switch and override
qulacs_executor = qiskit_executor.switch_backend("qulacs", shots=2048)
Common operations¶
Every executor exposes the same evaluation interface, regardless of backend.
Free parameters are supplied as keyword arguments, either in vector form
(x=[0.1, 0.2]) or indexed form (x[0]=0.1, x[1]=0.2).
Method |
Returns |
|---|---|
Expectation value(s) of the observable for the circuit. |
|
|
Gradient(s) of the expectation value w.r.t. the requested parameters. |
Measurement sample counts (requires |
|
Statevector of the circuit. |
|
Basis-state probabilities as |
|
Convert a generic circuit/operator into the backend-native form. |
executor = Executor.create("qiskit", seed=0, shots=10000)
value = executor.expectation_value(qc, observable, x=[0.1], p=[0.3])
grads = executor.expectation_value_derivatives(
qc, observable, "x", "p", x=[0.1], p=[0.3]
)
Passing a batch of parameter sets — one extra leading axis on a parameter’s
values, e.g. x=[[0.1], [0.2], [0.3]] — evaluates every set and returns the
results with a leading batch axis. All batched parameters must agree on the
batch size.
Derivatives with respect to a vector name are ordered by element index and
come last in the result: for a list of observables the gradient has shape
(n_observables, n_parameters), with a leading batch axis for a batch of
parameter sets. The shots property can be reassigned on every backend and
takes effect on the next execution.
Qubit ordering¶
One convention holds throughout, for Pauli labels, statevectors and sample bitstrings alike: qubit 0 comes first.
QuantumOperator(["ZI"], [1.0]) # Z on qubit 0, identity on qubit 1
QuantumOperator(["IZ"], [1.0]) # identity on qubit 0, Z on qubit 1
This is worth stating plainly because Qiskit itself renders labels the other way
round, writing qubit 0 rightmost. QuantumOperator labels are translated on
the way into the Qiskit backend, so the same label means the same thing on every
backend. A raw SparsePauliOp handed straight to QiskitOperator keeps
Qiskit’s own convention, since nothing about it was written by this package.
Working with native circuits and operators¶
Every backend’s circuit type is a QuantumCircuitBase and every backend’s
operator type is a QuantumOperatorBase, so the same API works on both. You
can build straight into a native type:
from qc_executor.qulacs import QulacsCircuit, QulacsOperator
circuit = QulacsCircuit(3)
circuit.h(0)
circuit.crz(1, 2, 2 * x[0]) # lowered on the way to Qulacs
observable = QulacsOperator(["ZII", "IZI"], [1.0, 0.5])
observable = observable.apply_layout([2, 0, 1]) # the shared algebra
Every evaluation method accepts a generic object or a backend-native one. A generic object is translated; a native one is used directly, so nothing is converted twice:
executor = Executor.create("pennylane")
native_circuit = executor.transpile_circuit(qc)
native_observable = executor.transpile_operator(observable)
# Both calls return the same value; the second skips translation.
executor.expectation_value(qc, observable, x=[0.1], p=[0.3])
executor.expectation_value(native_circuit, native_observable, x=[0.1], p=[0.3])
Translation is cached, so repeatedly passing the same generic circuit costs the conversion only once.
Several observables at once¶
Pass a list wherever one observable is accepted; the result gains a leading axis over the observables. This works for values and gradients on every backend:
observables = [
QuantumOperator(["ZI"], [1.0]),
QuantumOperator(["IZ"], [0.5]),
]
values = executor.expectation_value(qc, observables, x=[0.6])
grads = executor.expectation_value_derivatives(qc, observables, "x", x=[0.6])
Where a backend can evaluate the set in one pass it does: PennyLane measures them in a single QNode and differentiates it once, rather than looping.
Several circuits are supported everywhere too: for derivatives the base class evaluates them one at a time and stacks the results along a leading axis.
Composing circuits¶
compose
appends one circuit onto another in place, optionally onto a subset of qubits.
When both circuits use a parameter of the same name, the parameters are
re-indexed into one vector named after the receiving circuit’s first parameter:
its own parameters keep their positions and the appended circuit’s follow.
Building an ansatz by composing many blocks that each use theta[0] therefore
yields theta[0] ... theta[n-1] rather than a single shared parameter; pass
new_parameters=False to merge positionally instead. Circuits whose parameter
names are disjoint, such as features x and weights p, are composed
unchanged.
fixate_parameters
binds every free parameter in place.
block = QuantumCircuit(2)
block.ry(0, Parameters("theta", 1)[0])
block.cx(0, 1)
ansatz = QuantumCircuit(2)
for _ in range(3):
ansatz.compose(block)
ansatz.parameter_vector_names # ['theta'], elements theta[0..2]
Pauli evolution¶
pauli_evolution
appends exp(-i c t P) for a single-term QuantumOperator and
controlled_pauli_evolution
adds an optional control qubit with a control state of "0" or "1".
Both accept a list of operators to be applied together on disjoint working
qubits, sharing one basis-change and CNOT-ladder layer.
Mid-circuit measurement and classical control¶
Circuits can measure a qubit partway through, reset one, and gate a later instruction on the outcome:
qc = QuantumCircuit(2, 1) # 2 qubits, 1 classical bit
qc.h(0)
qc.measure(0, 0) # qubit 0 into classical bit 0
with qc.if_(0, 1): # only if classical bit 0 reads 1
qc.x(1)
qc.reset(0) # return qubit 0 to |0>
measure() allocates classical bits automatically when you do not name them,
and measure_all() measures every qubit into its own bit.
Backend support differs, and a backend that cannot express an operation raises
NotImplementedError rather than quietly ignoring it:
Backend |
Measure |
Reset |
Condition |
|---|---|---|---|
|
yes |
yes |
yes |
|
yes |
yes |
yes |
|
raises |
raises |
raises |
|
raises |
raises |
raises |
For Pauli propagation this is not a gap to be filled later: it propagates operators in the Heisenberg picture, where there is no measurement outcome to branch on.
Two practical notes:
Qiskit’s local primitives reject circuits containing measurements or control flow, so dynamic circuits need the Aer simulator from the
qiskit-fullextra.statevector()refuses a circuit containing a measurement or a reset. Both collapse the state at random, so the result would be one sample from a mixture rather than a state. Usesample()orexpectation_value()instead.
Gate support and lowering¶
A backend declares the gates it executes natively, and anything else is
rewritten into that set automatically. You can therefore use the full gate API
regardless of backend — ccx, cswap, ecr, iswap, crx and the
rest are lowered for backends that lack them. Global phase is dropped by the
rewrite, which is why statevectors agree only up to a global phase.
If a gate cannot be expressed at all, the backend raises
UnsupportedGateError (a subclass of NotImplementedError).
The pages that follow show a complete basic-usage example for each backend.
They mirror the runnable notebooks in the examples/ directory of the
repository.