The observable splitter plugin¶

In the QLM API, it is possible to submit jobs that contain a quantum circuit and some observable to sample on the output quantum state.

my_job = circuit.to_job(observable=my_obs)

These jobs are atomic computation tasks from the API point of view. In some cases, however, it can happen that some QPU does not natively supports observable evaluation.

The ObservableSplitter plugin is here to fill this gap and allow any stack containing a "sampling only" QPU to be able to evaluate observables. The nice thing is that the algorithmic mechanics behind computing an observable using solely computational basis samples is entirely handled by the plugin, transparently for the user.

Brief overview¶

Lets see how the plugin works:

In [1]:
# To write circuits
from qat.lang.AQASM import *
# To define an observable
from qat.core import Observable, Term
# our Plugin
from qat.plugins import ObservableSplitter
# and a QPU
from qat.qpus import get_default_qpu
In [2]:
# Our circuit:
prog = Program()
qbits = prog.qalloc(2)
prog.apply(H, qbits[0])
prog.apply(CNOT, qbits)
bell = prog.to_circ()
In [3]:
# Our observable: it counts the parity of the quantum state
obs = Observable(2, pauli_terms=[Term(-0.5, "ZZ", [0, 1])],
                 constant_coeff=0.5)
print("Observable:\n", obs)
my_job = bell.to_job(observable=obs)
# We can always use our default qpu to directly run this job:
result = get_default_qpu().submit(my_job)
print("Result:", result.value)
Observable:
 0.5 * I^2 +
-0.5 * (ZZ|[0, 1])
Result: 1.1102230246251565e-16

This is however not realistic. If our QPU were to be a proper quantum device, or maybe just another simulator, it might not be able to handle observable sampling natively. For this purpose, we can use the ObservableSplitter plugin, like so:

In [4]:
stack = ObservableSplitter() | get_default_qpu()
print("Result:", stack.submit(my_job).value)
Result: 1.1102230246251565e-16

Sampling strategies¶

The plugin comes with two distinct algorithms to sample some observable:

  • By default, the plugin will generate one new sampling job per term in the observable. This is what we call "naive" splitting. This method is interesting in the case where measuring many qubits at the end of computation implies a degradation of the quality of the results. In that case, one might want to limit the number of sampled qubits.
  • Another method is also available that group terms of the observable into groups of (trivially) commutating terms. The Plugin then generates a new sampling job per group of trivially commutating terms. This method generates less jobs than the previous one and should be privileged when simulating quantum circuits. The groups of commutating terms are found using a greedy graph coloring algorithms. We call this method "coloring".
In [5]:
# This observable has 4 terms that can be grouped into 2 groups of commutating terms.
obs = Observable(3, pauli_terms=[Term(1., "ZZZ", [0,1,2]),
                                 Term(1., "X", [0]), Term(1., "X", [1]), Term(1., "X", [2])])
print(obs)
# We will use a dummy circuit:
prog = Program()
qbits = prog.qalloc(3)
circuit = prog.to_circ()
job = circuit.to_job(observable=obs)
from qat.core import Batch
batch = Batch(jobs=[job])
1.0 * (ZZZ|[0, 1, 2]) +
1.0 * (X|[0]) +
1.0 * (X|[1]) +
1.0 * (X|[2])
In [6]:
plugin_naive = ObservableSplitter(splitting_method="naive")
naive_batch = plugin_naive.compile(batch, None)
print("We need to sample", len(naive_batch.jobs), "circuits")
We need to sample 4 circuits
In [7]:
plugin_naive = ObservableSplitter(splitting_method="coloring")
coloring_batch = plugin_naive.compile(batch, None)
print("We need to sample", len(coloring_batch.jobs), "circuits")
We need to sample 2 circuits

[Advanced] Custom basis change¶

In order to generate the sampling jobs, the Plugin needs to inject basis change instructions at the end of the initial circuit.

For instance, if one need to sample a $X$ operator, the plugin will append a $H$ gate at the end of the circuit, and sample the corresponding qubit in the computational basis ($Z$).

However, some hardware might not support $H$ gates. Luckily, the ObservableSplitter allow us to provide any subcircuit performing the appropriate basis change. Lets have a look at its constructor:

In [8]:
help(ObservableSplitter)
Help on class ObservableSplitter in module qat.plugins.observable_splitter:

class ObservableSplitter(qat.core.plugins.AbstractPlugin, qat.core.plugins.OffloadedPlugin)
 |  ObservableSplitter(splitting_method='naive', x_basis_change=None, y_basis_change=None, **kwargs)
 |
 |  A QPU Plugin that splits jobs that require observable sampling into jobs
 |  that only require computational basis measurements.
 |
 |  Args:
 |      splitting_method (optional, str): Specify the splitting algorithm to be used (see below).
 |          Defaults to "naive".
 |      x_basis_change (optional, callable): a (qbit, nbqbits) -> QRoutine
 |          method performing a Z -> X basis change on qubit `qbit`. The
 |          routine must have arity `nbqbits`.
 |          Defaults to a single H gate.
 |      y_basis_change (optional, callable): a (qbit, nbqbits) -> QRoutine
 |          method performing a Z -> Y basis change on qubit `qbit`. The
 |          routine must have arity `nbqbits`.
 |          Defaults to  [PH(pi/2); RY(pi/2)].
 |
 |  The plugin implements three different approaches for the splitting:
 |
 |      - **"naive"**: Each term of the observable will be individually sampled
 |        using an individual circuit.
 |
 |      - **"coloring"**: Terms will be grouped into trivially commuting subsets using
 |        a greedy graph coloring method. Each group will be sampled using a single circuit.
 |
 |      - **"clifford"**: Terms will grouped into (non-trivially) commuting subsets using
 |        a greedy graph coloring method. Each group is then co-diagonalized using
 |        a Clifford circuit and sampled. This method might add entangling gates
 |        to the circuit.
 |
 |  Notes:
 |
 |      As of today, the **"clifford"** co-diagonalization method ignores the
 |      `x_basis_change` and `y_basis_change` arguments.
 |
 |  The three methods coincide when no two terms commute.
 |
 |  Method resolution order:
 |      ObservableSplitter
 |      qat.core.plugins.AbstractPlugin
 |      abc.ABC
 |      qat.core.arg_parser.BaseParse
 |      qat.core.contexts.PluginContext
 |      qat.core.contexts._ServiceContext
 |      qat.core.services.QaptivaService
 |      qat.core.plugins.OffloadedPlugin
 |      qat.core.services.OffloadedService
 |      builtins.object
 |
 |  Methods defined here:
 |
 |  __init__(self, splitting_method='naive', x_basis_change=None, y_basis_change=None, **kwargs)
 |
 |  compile(self, batch, _specs)
 |      Performs the splitting of all the jobs inside task.
 |
 |  do_post_processing(self)
 |      Returns True iff we need post-processing of the results
 |
 |  get_fresh_key(self)
 |      Returns a fresh key.
 |
 |  get_specs(self, specs)
 |      Update the get_specs method. This function returns a HardwareSpecs objects having
 |      a All-to-All topology
 |
 |  post_process(self, batch_results)
 |      Performs the post processing of `results`. Using `meta_data` to find
 |      the key we set up before and retrieve the list of observables we stored
 |      in self.keys.
 |
 |      Returns:
 |          BatchResult: batch of results. Each result holds the estimated
 |          observable value in the "value" field. The value_data field is
 |          a dictionary which may contain an "err" key whose value corresponds
 |          to the estimated standard value on the mean.
 |
 |  ----------------------------------------------------------------------
 |  Class methods defined here:
 |
 |  addargs(parser)
 |      Add arguments to a parser
 |
 |  ----------------------------------------------------------------------
 |  Data and other attributes defined here:
 |
 |  __abstractmethods__ = frozenset()
 |
 |  ----------------------------------------------------------------------
 |  Methods inherited from qat.core.plugins.AbstractPlugin:
 |
 |  __getattribute__(self, attr)
 |      Override __getattribute__
 |      Unlike __getattr__, this method is called even if the current attribute is known. This method
 |      is used to wrap some attributes, including attributes of the subclasses of AbstractPlugin
 |
 |      Args:
 |          attr (str): attribute name
 |
 |  __or__(self, plugin2)
 |      Simple composition of plugin - plugin or plugin - qproc or
 |      plugin - qpu.
 |
 |  __str__(self)
 |
 |  serve(self, port, host_ip='localhost', server_type=None, ssl_cert: 'str' = None, ssl_key: 'str' = None, ssl_ca: 'str' = None)
 |      Runs the plugin inside a server
 |
 |      Args:
 |          port(int): the port on which to listen
 |          host_ip(str): the url on which to publish the API. Optional.
 |              Defaults to 'localhost'.
 |          server_type (str, optional): type of server. The different
 |              types of server are:
 |
 |                  - "simple": single-thread server, accepts one connection
 |                    at a time (default server type)
 |                  - "threaded": multi-thread server, each connection
 |                    starts a new thread
 |                  - "pool": multi-thread server, each connection runs
 |                    in a thread, with a maximum of 10 running threads
 |                  - "fork": multi-process server, each connection runs
 |                    in a new process (UNIX-only)
 |                  - "stoppable": custom server based on "fork", but server processes will be killed
 |                    after a keepalive timeout is reached (UNIX-only)
 |          ssl_cert (str, optional): path to the server SSL certificate (mandatory for SSL)
 |              Default: None
 |          ssl_key (str, optional): path to the server SSL key (mandatory for SSL)
 |              Default: None
 |          ssl_ca (str, optional): path to the server SSL certificate authority
 |              (only serves requests with signed certificates)
 |              Default: None
 |
 |  wrapper_post_process(self, results)
 |      Call post process function and wrap the result in a
 |      PostProcessResult object
 |
 |  ----------------------------------------------------------------------
 |  Data descriptors inherited from qat.core.plugins.AbstractPlugin:
 |
 |  __dict__
 |      dictionary for instance variables
 |
 |  __weakref__
 |      list of weak references to the object
 |
 |  ----------------------------------------------------------------------
 |  Methods inherited from qat.core.contexts._ServiceContext:
 |
 |  __enter__(self)
 |
 |  __exit__(self, *args, **kwargs)

The constructor requires 2 functions : x_basis_change and y_basis_change.

This functions take as parameter the index of the qubit to rotate and the total number of qubits, and should return a QRoutine of arity equal to the number of qubits (this is just to encompass the most general sceneari).

For instance, if our hardware does not supports Hadamard gates, one can imagine performing a sequence of $R_x(\pi/2)Rz(\pi/2)Rx(\pi/2)$:

In [9]:
import numpy as np
def my_x_basis_change(index, nbqbits):
    rout = QRoutine()
    wires = rout.new_wires(nbqbits)
    rout.apply(RX(np.pi/2), wires[index])
    rout.apply(RZ(np.pi/2), wires[index])
    rout.apply(RX(np.pi/2), wires[index])
    return rout

plugin_custom = ObservableSplitter(splitting_method="coloring", x_basis_change=my_x_basis_change)
plugin = ObservableSplitter(splitting_method="coloring")
default_batch = plugin.compile(batch, None)
custom_batch = plugin_custom.compile(batch, None)

print("Default plugin:")
for ind, job in enumerate(default_batch.jobs):
    print("Circuit", ind)
    for op in job.circuit.iterate_simple():
        print(op)
        
print("Custom plugin:")
for ind, job in enumerate(custom_batch.jobs):
    print("Circuit", ind)
    for op in job.circuit.iterate_simple():
        print(op)
Default plugin:
Circuit 0
Circuit 1
('H', [], [0])
('H', [], [1])
('H', [], [2])
Custom plugin:
Circuit 0
Circuit 1
('RX', [1.5707963267948966], [0])
('RZ', [1.5707963267948966], [0])
('RX', [1.5707963267948966], [0])
('RX', [1.5707963267948966], [1])
('RZ', [1.5707963267948966], [1])
('RX', [1.5707963267948966], [1])
('RX', [1.5707963267948966], [2])
('RZ', [1.5707963267948966], [2])
('RX', [1.5707963267948966], [2])
In [ ]: