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.

Core building blocks

Every workflow uses three backend-independent objects from the package root:

QuantumCircuit

A backend-agnostic circuit, constructed with a Qiskit-like gate API (h(), cx(), ryy(), …). Gate angles may be plain numbers or symbolic parameter expressions. The qiskit_circuit property exports the circuit to Qiskit, and Qiskit ParameterVector elements are accepted as gate angles directly.

QuantumOperator

An observable expressed as a weighted sum of Pauli strings, e.g. QuantumOperator(["ZI", "IZ"], [1.0, 1.0]). Coefficients may also be symbolic. Qubit q is character q of the label, so "ZI" acts with Z on qubit 0 — see Qubit ordering.

Parameters

A named, indexable vector of free parameters used to build symbolic gate angles and observable coefficients. Parameters("x", 2) creates x[0] and x[1]. Elements are SymPy symbols, so 2 * x[0], p[0] * x[0] and sympy.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

shots

Number of measurement shots. None (default) means exact/analytic.

seed

Random seed for reproducible sampling.

log_file

Path to a log file. None disables file logging.

log_level

"DEBUG" / "INFO" / "WARNING" (default) / "ERROR".

caching

Enable in-memory caching of results.

cache_dir

Directory used for caching. Defaults to "cache".

max_cache_size

Maximum number of cached entries. Defaults to 4096; None makes the caches unbounded.

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(circuit, observable, **params)

Expectation value(s) of the observable for the circuit.

expectation_value_derivatives(circuit, observable, *wrt, **params)

Gradient(s) of the expectation value w.r.t. the requested parameters.

sample(circuit, **params)

Measurement sample counts (requires shots).

statevector(circuit, **params)

Statevector of the circuit.

probabilities(circuit, cutoff=0.0, **params)

Basis-state probabilities as {index: probability}; exact for shots=None, estimated from counts otherwise.

transpile_circuit(circuit) / transpile_operator(operator)

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

qiskit

yes

yes

yes

pennylane

yes

yes

yes

qulacs

raises

raises

raises

pauli_propagation

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-full extra.

  • 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. Use sample() or expectation_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.