Qiskit Primitives

The existing Qiskit interface to backends (backend.run()) was originally designed to accept a list of circuits and return counts for every job. Over time, it became clear that users have diverse purposes for quantum computing, and therefore the ways in which they define the requirements for their computing jobs are expanding.

To address this issue, Qiskit introduced the qiskit.primitives module in version 0.35.0 (Qiskit Terra 0.20.0) to provide a simplified way for end users to compute outputs of interest from a QuantumCircuit and to use backends. Qiskit’s primitives provide methods that make it easier to build modular algorithms and other higher-order programs. Rather than simply returning counts, they return more immediately meaningful information.

The two currently available primitives are the Sampler and the Estimator. The first one simulates a circuit and returns the measurement counts, while the second one calculates and interprets expectation values of quantum operators that are required for many near-term quantum algorithms.

DDSIM provides its own version of these Qiskit primitives that leverage the default circuit simulator based on decision diagrams, while preserving the methods and functionality of the original Qiskit primitives.

Sampler

The Sampler implements the Qiskit BaseSamplerV2 interface. It takes a list of QuantumCircuit objects and simulates them using the CircuitSimulator from MQT DDSIM, returning the measurement counts. The results are encapsulated within a PrimitiveResult object that also contains the job’s metadata.

Furthermore, the Sampler also handles compilation and parameter binding when working with parametrized circuits.

Here, we show an example on how to use this submodule:

 1import numpy as np
 2from qiskit import QuantumCircuit
 3from qiskit.circuit import Parameter
 4
 5from mqt.ddsim.primitives import Sampler
 6
 7# Parametrized circuit to create an X-gate
 8theta_a = Parameter("theta_a")
 9circ = QuantumCircuit(1)
10circ.ry(theta_a, 0)
11circ.measure_all()
12
13# Show circuit
14circ.draw("mpl")
_images/c36847c9e81200dd0984ea242a84cc7dfd9d556778b9bd27cb2c85adab768880.svg
1# Initialize sampler
2sampler = Sampler()

The run() method accepts an iterable of PUB-like objects, where PUB stands for primitive unified bloc. In the example below, the PUB comprises the circuit and an array of parameter values, where the latter is optional. For more information, see Qiskit’s StatevectorSampler.run(). The number of shots can be set as an additional argument. If not set, the default value is 1024.

1pubs = [(circ, [np.pi])]
2shots = int(1e4)
3job = sampler.run(pubs, shots=shots)
4result = job.result()

The result() method of the job returns a PrimitiveResult object. This object contains a SamplerPubResult, which, in turn, contains the measurement counts. It furthermore contains job metadata.

1counts = result[0].data["meas"].get_counts()
2
3print(f">>> {result}")
4print(f"  > Counts: {counts}")
>>> PrimitiveResult([SamplerPubResult(data=DataBin(meas=BitArray(<shape=(1,), num_shots=10000, num_bits=1>)), metadata={'shots': 10000, 'circuit_metadata': {}})], metadata={'version': 2})
  > Counts: {'1': 10000}

It is also possible to simulate multiple circuits in one job:

1# Create a second circuit
2circ_2 = QuantumCircuit(1)
3circ_2.measure_all()
4circ_2.draw("mpl")
_images/258893cbfff0a6c43797bc1fa7239d0da1e8642c36c34a29ca3ff3bdb9c6275a.svg

In this case, we have two PUB-like objects.

1job = sampler.run([(circ, [np.pi]), circ_2], shots=int(1e4))
2result = job.result()
3
4counts_1 = result[0].data["meas"].get_counts()
5counts_2 = result[1].data["meas"].get_counts()
6
7print(f">>> {result}")
8print(f"  > Counts for circuit 1: {counts_1}")
9print(f"  > Counts for circuit 2: {counts_2}")
>>> PrimitiveResult([SamplerPubResult(data=DataBin(meas=BitArray(<shape=(1,), num_shots=10000, num_bits=1>)), metadata={'shots': 10000, 'circuit_metadata': {}}), SamplerPubResult(data=DataBin(meas=BitArray(<shape=(1,), num_shots=10000, num_bits=1>)), metadata={'shots': 10000, 'circuit_metadata': {}})], metadata={'version': 2})
  > Counts for circuit 1: {'1': 10000}
  > Counts for circuit 2: {'0': 10000}

Now, let us use the Sampler on a more complex circuit. For this example, we will test Grover’s algorithm on a 4-qubit circuit where the marked states in the binary representation are “0110” and “1010”.

A similar implementation of Grover’s algorithm using Qiskit’s sampler can be found here: https://learning.quantum.ibm.com/tutorial/grovers-algorithm

 1# Built-in imports
 2import math
 3
 4# Qiskit imports
 5from qiskit.circuit.library import MCMTGate, GroverOperator, ZGate
 6from qiskit.visualization import plot_distribution
 7
 8# Define an oracle circuit that marks the input states, which are represented as a list of bitstrings
 9def grover_oracle(marked_states: list) -> QuantumCircuit:
10    if not isinstance(marked_states, list):
11        marked_states = [marked_states]
12    # Compute the number of qubits in circuit
13    num_qubits = len(marked_states[0])
14
15    qc = QuantumCircuit(num_qubits)
16    # Mark each target state in the input list
17    for target in marked_states:
18        # Flip target bit-string to match Qiskit bit-ordering
19        rev_target = target[::-1]
20        # Find the indices of all the '0' elements in bit-string
21        zero_inds = [ind for ind in range(num_qubits) if rev_target.startswith("0", ind)]
22        # Add a multi-controlled Z-gate with pre- and post-applied X-gates (open-controls)
23        # where the target bit-string has a '0' entry
24        qc.x(zero_inds)
25        qc.compose(MCMTGate(ZGate(), num_qubits - 1, 1), inplace=True)
26        qc.x(zero_inds)
27    return qc
28
29# Enter the states to be marked
30marked_states = ["0110", "1010"]
31oracle = grover_oracle(marked_states)
32oracle.draw("mpl")
_images/abe1fcef2a19bd75475391829efad13819d4a881a470a9f5b0ce7f2d379718be.svg
1# Build circuit that amplifies the marked states by the oracle
2grover_op = GroverOperator(oracle)
3grover_op.decompose().draw("mpl")
/tmp/ipykernel_3088/2386812682.py:2: DeprecationWarning: The class ``qiskit.circuit.library.grover_operator.GroverOperator`` is deprecated as of Qiskit 2.1. It will be removed in Qiskit 3.0. Use qiskit.circuit.library.grover_operator instead.
  grover_op = GroverOperator(oracle)
_images/7114af04a70de22b593cce5dcd547abb71e069760d21185f10f9a64e3fab71ae.svg
 1# Compute the optimal number of repetitions of the GroverOperator to amplify the probabilities of the marked states
 2optimal_num_iterations = math.floor(math.pi / 4 * math.sqrt(2**grover_op.num_qubits / len(marked_states)))
 3
 4# Build the full Grover circuit
 5qc = QuantumCircuit(grover_op.num_qubits)
 6# Create even superposition of all basis states
 7qc.h(range(grover_op.num_qubits))
 8# Apply Grover operator the optimal number of times
 9qc.compose(grover_op.power(optimal_num_iterations), inplace=True)
10# Measure all qubits
11qc.measure_all()
12qc.draw("mpl")
_images/8d6a3bd7c8a379c4a79b636ce0122cb224ac0caa34f43becdd9a9b66565b26e8.svg

After having built the circuit, use the Sampler to get the measurement counts.

1result = sampler.run([qc], shots=int(1e4)).result()
2counts = result[0].data["meas"].get_counts()
3plot_distribution(counts)
_images/d22879e90681b0213d928ad0dc478190be3266f743b75a99d8a6c33fe3b15070.svg

Estimator

The Estimator calculates the expectation value of an observable with respect to a certain quantum state (described by a quantum circuit). It implements the Qiskit BaseEstimatorV2 interface. However, in contrast to Qiskit’s estimator, the DDSIM version exactly computes the expectation value using its simulator based on decision diagrams instead of sampling.

The Estimator also handles parameter binding when dealing with parametrized circuits.

Here, we show an example on how to use it:

First, we build the observable and the quantum state. The observable is given as a SparsePauliOp object, while the quantum state is described by a QuantumCircuit.

In this example, our observable is the Pauli matrix \(\sigma_{x}\) and the quantum state is \(\frac{1}{\sqrt{2}}(|0\rangle + |1\rangle)\).

 1import numpy as np
 2from qiskit import QuantumCircuit
 3from qiskit.circuit import Parameter
 4from qiskit.quantum_info import Pauli
 5
 6from mqt.ddsim.primitives.estimator import Estimator
 7
 8# Build quantum state
 9circ = QuantumCircuit(1)
10circ.ry(np.pi / 2, 0)
11circ.measure_all()
12
13# Build observable
14pauli_x = Pauli("X")
15
16# Show circuit
17circ.draw("mpl")
_images/82226a63169360b70c1f9686e88ccaaee1e6066cb90741cf5115fee763b8722a.svg
1# Initialize estimator
2estimator = Estimator()

The next step involves running the estimation using the run() method. Just like the run() method of the Sampler, it accepts an iterable of PUB-like objects. In the example below, the QuantumCircuit object representing the quantum state and an array of SparsePauliOp objects representing the observable. If the circuit is parametrized, we would also pass an array of parameters. For more information, see Qiskit’s StatevectorEstimator.run().

1# Enter observable and circuit as a sequence
2job = estimator.run([(circ, [pauli_x])])
3result = job.result()

The result() method of the job returns a PrimitiveResult object. This object contains a PubResult, which, in turn, contains the computed expectation values.

1expectation_values = result[0].data["evs"]
2
3print(f">>> {result}")
4print(f"  > Expectation values: {expectation_values}")
>>> PrimitiveResult([PubResult(data=DataBin(evs=np.ndarray(<shape=(1,), dtype=float64>), stds=np.ndarray(<shape=(1,), dtype=float64>), shape=(1,)))], metadata={'version': 2})
  > Expectation values: [1.]

Now we explore how the Estimator works with multiple circuits and observables. For this example, we will calculate the expectation value of \(\sigma_{x}\) and \(\sigma_{y}\) with respect to the quantum state \(|1\rangle\). Since our observable list has a length of 2, we need to enter two copies of the QuantumCircuit object representing \(|1\rangle\) as a sequence:

 1# Build quantum state
 2circ = QuantumCircuit(1)
 3circ.ry(np.pi, 0)
 4circ.measure_all()
 5
 6# Build observables
 7pauli_x = Pauli("X")
 8pauli_y = Pauli("Y")
 9
10# Construct pub
11pub = (circ, [pauli_x, pauli_y])
12
13# Run estimator
14job = estimator.run([pub])
15result = job.result()
1expectation_values = result[0].data["evs"]
2
3print(f">>> {result}")
4print(f"  > Expectation values: {expectation_values}")
>>> PrimitiveResult([PubResult(data=DataBin(evs=np.ndarray(<shape=(2,), dtype=float64>), stds=np.ndarray(<shape=(2,), dtype=float64>), shape=(2,)))], metadata={'version': 2})
  > Expectation values: [0. 0.]

The first and second entries of the list of values are the expectation value of \(\sigma_{x}\) and \(\sigma_{y}\), respectively.

Let us now calculate the expectation values of \(\sigma_{x}\) with respect to \(\frac{1}{\sqrt{2}}(|0\rangle + |1\rangle)\) and \(\frac{1}{\sqrt{2}}(|0\rangle - |1\rangle)\). For this example we will use parametrized circuits to build the quantum state.

1theta = Parameter("theta")
2circ_2 = QuantumCircuit(1)
3circ_2.ry(theta, 0)
4circ_2.measure_all()
5
6# Show circuit
7circ_2.draw("mpl")
_images/6ee1898b7c5b93a3b98ffb98ce3a79c8dbf3f9b6435fbddcde1f4d2885a0573d.svg
1# Construct pub
2pub = (circ_2, [pauli_x], [[np.pi / 2], [-np.pi / 2]])
3
4# Run estimator
5job = estimator.run([pub])
6result = job.result()
1expectation_values = result[0].data["evs"]
2
3print(f">>> {result}")
4print(f"  > Expectation values: {expectation_values}")
>>> PrimitiveResult([PubResult(data=DataBin(evs=np.ndarray(<shape=(2,), dtype=float64>), stds=np.ndarray(<shape=(2,), dtype=float64>), shape=(2,)))], metadata={'version': 2})
  > Expectation values: [ 1. -1.]