Defining a custom Decoder#
The Decoder is the part of the model in charge of transforming back the quantum information contained in a quantum state, to some classical representation consumable by classical calculators. This, in practice, translates to: obtaining the final quantum state, first, and, second, performing any suitable postprocessing onto it.
A very simple decoder, for instance, is the qiboml.models.decoding.Probabilities
, which extracts the probabilities from the final state. Similarly, qiboml.models.decoding.Samples
and qiboml.models.decoding.Expectation
respectively reconstruct the measured samples and calculate the expectation value of an observable on the final state.
Hence, the decoder is a function \(f_d: C \rightarrow \mathbf{y}\in\mathbb{R}^n\), that expects as input a qibo.Circuit
, executes it and finally perform some operation on the obtained final state to recover some classical output data \(\mathbf{y}\) in the form of a float array.
qiboml
provides an abstract qiboml.models.decoding.QuantumDecoding
object which can be subclassed to define custom decoding layers. Let’s say, for instance, that we would like to calculate the expectation values of two different observables:
on the final state of our system, and measure how close the expectation values are:
To do this we only need to create a decoding layer that constructs the two observables upon initialization and, when called, executes the circuit and calculates the distance \(d\):
import numpy as np
from qiboml.models.decoding import QuantumDecoding
from qibo import Circuit
from qibo.symbols import Z
from qibo.hamiltonians import SymbolicHamiltonian
class MyCustomDecoder(QuantumDecoding):
def __init__(self, nqubits: int):
super().__init__(nqubits)
# build the observables using qibo's SymbolicHamiltonian
self.o_even = SymbolicHamiltonian(Z(0)*Z(2), nqubits=nqubits)
self.o_odd = SymbolicHamiltonian(Z(1)*Z(3), nqubits=nqubits)
def __call__(self, x: Circuit):
# execute the circuit and collect the final state
final_state = super().__call__(x).state()
# calculate the expectation values
exp_even = self.o_even.expectation(final_state)
exp_odd = self.o_odd.expectation(final_state)
# use numpy to calculate the distance
return np.abs(exp_even - exp_odd)
# specify the shape of the output
@property
def output_shape(self) -> tuple[int]:
return (1, 1)
Note
It is important to also specify what is the expected output shape of the decoder, for example as in this case we are just dealing with expectation values and, thus, scalars, we are going to set it to \((1,1)\).
The super().__init__
and super().__call__
calls here are useful to simplify the implementation of the custom decoder. The super().__init__
sets up the initial features needed, i.e. mainly an empty nqubits
qibo.Circuit
with a measurement appended on each qubit. Whereas, the super().__call__
takes care of executing the qibo.Circuit
passed as input x
and returns a qibo.result
object, hence one in (QuantumState, MeasurementOutcomes, CircuitResult)
.
In case you needed an even more fine-grained customization, you could always get rid of them and fully customize the initialization and call of the decoder. However, keep in mind that in order for a decoder to correctly work inside a qiboml
pipeline, some components should be defined:
A
qibo
compatibleBackend
:if not manually specified, the
qiboml.models.decoding.QuantumDecoding.__init__()
prepares the globally-set backend and assigns it to its attributeqiboml.models.decoding.QuantumDecoding.backend
, which is then used to execute the circuit inside ofqiboml.models.decoding.QuantumDecoding.__call__()
. Therefore, make sure that at all times your custom decoder is provided with a validBackend
through its.backend
attribute, and, moreover, that this backend choice is consistent in all the elements that care about it. For instance, in this example, even the observables allow for backend specification and a mismatch between the decoder’s and observables backends may result in several problems.
class MyCustomDecoderWithCustomBackend(QuantumDecoding):
# always use my custom backend for execution and
# expectation value calculation
def __init__(self, nqubits: int):
self.backend = MyCustomBackend()
# the backends should match!
self.o_even = SymbolicHamiltonian(Z(0)*Z(2), nqubits=nqubits, backend=self.backend)
self.o_odd = SymbolicHamiltonian(Z(1)*Z(3), nqubits=nqubits, backend=self.backend)
def __call__(self, x: Circuit):
final_state = self.backend.execute_circuit(x).state()
exp_even = self.o_even.expectation(final_state)
exp_odd = self.o_odd.expectation(final_state)
return np.abs(exp_even - exp_odd)
A boolean
analytic
property:for differentiation purposes, it is important to know whether the decoding step is analytically differentiable, i.e. if any sampling is involved in practice. If no sampling is involved, all the operations can be easily tracked and the gradients can be analitically calculated via standard differentiation methods (native
pytorch
orjax
for example). Otherwise, we must recurr to different ways for obtaining the gradients, such as theqiboml.operations.differentiation.PSR
. For this purpose, each decoding object has aanalytic
property that is set toTrue
by default:
class MyCustomDecoder(QuantumDecoding):
@property
def analytic(self,) -> bool:
if is_my_custom_decoder_differentiable:
return True
return False