Adding new operators¶
The following steps will help you to create custom operators, and to potentially add them to PennyLane.
Note that in PennyLane, a circuit ansatz consisting of multiple gates is also an operator — one whose
action is defined by specifying a representation as a combination of other operators.
For historical reasons, you find circuit ansaetze in the pennylane/template/
folder,
while all other operations are found in pennylane/ops/
.
The base classes to construct new operators, Operator
and
corresponding subclasses, are found in pennylane/operations.py
.
Note
Check qml.measurements for documentation on how to create new measurements.
Abstraction¶
Operators in quantum mechanics are maps that act on vector spaces, and in differentiable quantum computing, these
maps can depend on a set of trainable parameters. The Operator
class
serves as the main abstraction of such objects, and all operators (such as gates, channels, observables)
inherit from it.
>>> from jax import numpy as jnp
>>> op = qml.Rot(jnp.array(0.1), jnp.array(0.2), jnp.array(0.3), wires=["a"])
>>> isinstance(op, qml.operation.Operator)
True
The basic components of operators are the following:
The name of the operator (
Operator.name
), which may have a canonical, universally known interpretation (such as a “Hadamard” gate), or could be a name specific to PennyLane.>>> op.name Rot
The subsystems that the operator addresses (
Operator.wires
), which mathematically speaking defines the subspace that it acts on.>>> op.wires Wires(['a'])
Trainable parameters (
Operator.parameters
) that the map depends on, such as a rotation angle, which can be fed to the operator as tensor-like objects. For example, since we used jax arrays to specify the three rotation angles ofop
, the parameters are jaxArrays
.>>> op.parameters [Array(0.1, dtype=float32, weak_type=True), Array(0.2, dtype=float32, weak_type=True), Array(0.3, dtype=float32, weak_type=True)]
Non-trainable hyperparameters (
Operator.hyperparameters
) that influence the action of the operator. Not every operator has hyperparameters.>>> op.hyperparameters {}
Possible symbolic or numerical representations of the operator, which can be used by PennyLane’s devices to interpret the map. Examples are:
Representation as a product of operators (
Operator.decomposition()
):>>> op = qml.Rot(0.1, 0.2, 0.3, wires=["a"]) >>> op.decomposition() [RZ(0.1, wires=['a']), RY(0.2, wires=['a']), RZ(0.3, wires=['a'])]
Representation as a linear combination of operators (
Operator.terms()
):>>> op = qml.Hamiltonian([1., 2.], [qml.PauliX(0), qml.PauliZ(0)]) >>> op.terms() ((1.0, 2.0), [PauliX(wires=[0]), PauliZ(wires=[0])])
Representation via the eigenvalue decomposition specified by eigenvalues (for the diagonal matrix,
Operator.eigvals()
) and diagonalizing gates (for the unitariesOperator.diagonalizing_gates()
):>>> op = qml.PauliX(0) >>> op.diagonalizing_gates() [Hadamard(wires=[0])] >>> op.eigvals() [ 1 -1]
Representation as a matrix (
Operator.matrix()
), as specified by a global wire order that tells us where the wires are found on a register:>>> op = qml.PauliRot(0.2, "X", wires=["b"]) >>> op.matrix(wire_order=["a", "b"]) [[9.95e-01-2.26e-18j 2.72e-17-9.98e-02j, 0+0j, 0+0j] [2.72e-17-9.98e-02j 9.95e-01-2.26e-18j, 0+0j, 0+0j] [0+0j, 0+0j, 9.95e-01-2.26e-18j 2.72e-17-9.98e-02j] [0+0j, 0+0j, 2.72e-17-9.98e-02j 9.95e-01-2.26e-18j]]
Representation as a sparse matrix (
Operator.sparse_matrix()
):>>> from scipy.sparse.coo import coo_matrix >>> row = np.array([0, 1]) >>> col = np.array([1, 0]) >>> data = np.array([1, -1]) >>> mat = coo_matrix((data, (row, col)), shape=(4, 4)) >>> op = qml.SparseHamiltonian(mat, wires=["a"]) >>> op.sparse_matrix(wire_order=["a"]) (0, 1) 1 (1, 0) - 1
New operators can be created by applying arithmetic functions to operators, such as addition, scalar multiplication, multiplication, taking the adjoint, or controlling an operator. At the moment, such arithmetic is only implemented for specific subclasses.
Operators inheriting from
Observable
support addition and scalar multiplication:>>> op = qml.PauliX(0) + 0.1 * qml.PauliZ(0) >>> op.name Hamiltonian >>> op (0.1) [Z0] + (1.0) [X0]
Operators may define a hermitian conjugate:
>>> qml.RX(1., wires=0).adjoint() RX(-1.0, wires=[0])
Creating custom operators¶
A custom operator can be created by inheriting from Operator
or one of its subclasses.
The following is an example for a custom gate that possibly flips a qubit and then rotates another qubit.
The custom operator defines a decomposition, which the devices can use (since it is unlikely that a device
knows a native implementation for FlipAndRotate
). It also defines an adjoint operator.
import pennylane as qml
class FlipAndRotate(qml.operation.Operation):
# Define how many wires the operator acts on in total.
# In our case this may be one or two, which is why we
# use the AnyWires Enumeration to indicate a variable number.
num_wires = qml.operation.AnyWires
# This attribute tells PennyLane what differentiation method to use. Here
# we request parameter-shift (or "analytic") differentiation.
grad_method = "A"
def __init__(self, angle, wire_rot, wire_flip=None, do_flip=False, id=None):
# checking the inputs --------------
if do_flip and wire_flip is None:
raise ValueError("Expected a wire to flip; got None.")
# note: we use the framework-agnostic math library since
# trainable inputs could be tensors of different types
shape = qml.math.shape(angle)
if len(shape) > 1:
raise ValueError(f"Expected a scalar angle; got angle of shape {shape}.")
#------------------------------------
# do_flip is not trainable but influences the action of the operator,
# which is why we define it to be a hyperparameter
self._hyperparameters = {
"do_flip": do_flip
}
# we extract all wires that the operator acts on,
# relying on the Wire class arithmetic
all_wires = qml.wires.Wires(wire_rot) + qml.wires.Wires(wire_flip)
# The parent class expects all trainable parameters to be fed as positional
# arguments, and all wires acted on fed as a keyword argument.
# The id keyword argument allows users to give their instance a custom name.
super().__init__(angle, wires=all_wires, id=id)
@property
def num_params(self):
# if it is known before creation, define the number of parameters to expect here,
# which makes sure an error is raised if the wrong number was passed
return 1
@staticmethod
def compute_decomposition(angle, wires, do_flip): # pylint: disable=arguments-differ
# Overwriting this method defines the decomposition of the new gate, as it is
# called by Operator.decomposition().
# The general signature of this function is (*parameters, wires, **hyperparameters).
op_list = []
if do_flip:
op_list.append(qml.PauliX(wires=wires[1]))
op_list.append(qml.RX(angle, wires=wires[0]))
return op_list
def adjoint(self):
# the adjoint operator of this gate simply negates the angle
return FlipAndRotate(-self.parameters[0], self.wires[0], self.wires[1], do_flip=self.hyperparameters["do_flip"])
@classmethod
def _unflatten(cls, data, metadata):
# as the class differs from the standard `__init__` call signature of
# (*data, wires=wires, **hyperparameters), the _unflatten method that
# must be defined as well
# _unflatten recreates a opeartion from the serialized data and metadata of ``Operator._flatten``
# copied_op = type(op)._unflatten(*op._flatten())
wires = metadata[0]
hyperparams = dict(metadata[1])
return cls(data[0], wire_rot=wires[0], wire_flip=wires[1], do_flip=hyperparams['do_flip'])
The new gate can now be created as follows:
>>> op = FlipAndRotate(0.1, wire_rot="q3", wire_flip="q1", do_flip=True)
>>> op
FlipAndRotate(0.1, wires=['q3', 'q1'])
>>> op.decomposition()
[PauliX(wires=['q1']), RX(0.1, wires=['q3'])]
>>> op.adjoint()
FlipAndRotate(-0.1, wires=['q3', 'q1'])
Once the class has been created, you can run a suite of validation checks using ops.functions.assert_valid()
.
This function will warn you of some common errors in custom operators.
>>> qml.ops.functions.assert_valid(op)
If the above operator omitted the _unflatten
custom definition, it would raise:
TypeError: FlipAndRotate.__init__() got an unexpected keyword argument 'wires'
The above exception was the direct cause of the following exception:
AssertionError: FlipAndRotate._unflatten must be able to reproduce the original operation
from (0.1,) and (Wires(['q3', 'q1']), (('do_flip', True),)). You may need to override
either the _unflatten or _flatten method.
For local testing, try type(op)._unflatten(*op._flatten())
The new gate can be used with PennyLane devices. Device support for an operation can be checked via
dev.stopping_condition(op)
. If True
, then the device supports the operation.
DefaultQubitLegacy
first checks if the operator has a matrix using the has_matrix
property.
If the Operator doesn’t have a matrix, the device then checks if the name of the Operator is explicitly specified in
operations
or observables
.
Other devices that do not inherit from DefaultQubitLegacy
only check if the name is explicitly specified in the operations
property.
If the device registers support for an operation with the same name, PennyLane leaves the gate implementation up to the device. The device might have a hardcoded implementation, or it may refer to one of the numerical representations of the operator (such as
Operator.matrix()
).If the device does not support an operation, PennyLane will automatically decompose the gate using
Operator.decomposition()
.
from pennylane import numpy as np
dev = qml.device("default.qubit", wires=["q1", "q2", "q3"])
@qml.qnode(dev)
def circuit(angle):
FlipAndRotate(angle, wire_rot="q1", wire_flip="q1")
return qml.expval(qml.PauliZ("q1"))
>>> a = np.array(3.14)
>>> circuit(a)
-0.9999987318946099
If all gates used in the decomposition have gradient recipes defined, we can even compute gradients of circuits that use the new gate without any extra effort.
>>> qml.grad(circuit)(a)
-0.0015926529164868282
Note
The example of FlipAndRotate
is simple enough that one could write a function
def FlipAndRotate(angle, wire_rot, wire_flip=None, do_flip=False):
if do_flip:
qml.PauliX(wires=wire_flip)
qml.RX(angle, wires=wire_rot)
and call it in the quantum function as if it was a gate. However, classes allow much more functionality, such as defining the adjoint gate above, defining the shape expected for the trainable parameter(s), or specifying gradient rules.
Defining special properties of an operator¶
Apart from the main Operator
class, operators with special methods or representations
are implemented as subclasses Operation
, Observable
, Channel
,
CVOperation
and CVObservable
.
However, unlike many other frameworks, PennyLane does not use class inheritance to define fine-grained properties of operators, such as whether it is its own self-inverse, if it is diagonal, or whether it can be decomposed into Pauli rotations. This avoids changing the inheritance structure every time an application needs to query a new property.
Instead, PennyLane uses “attributes”, which are bookkeeping classes that list operators which fulfill a specific property.
For example, we can create a new attribute, pauli_ops
, like so:
>>> from pennylane.ops.qubits.attributes import Attribute
>>> pauli_ops = Attribute(["PauliX", "PauliY", "PauliZ"])
We can check either a string or an Operation for inclusion in this set:
>>> qml.PauliX(0) in pauli_ops
True
>>> "Hadamard" in pauli_ops
False
We can also dynamically add operators to the sets at runtime. This is useful
for adding custom operations to the attributes such as composable_rotations
and self_inverses
that are used in compilation transforms. For example,
suppose you have created a new operation MyGate
, which you know to be its
own inverse. Adding it to the set, like so
>>> from pennylane.ops.qubits.attributes import self_inverses
>>> self_inverses.add("MyGate")
Attributes can also be queried by devices to use special tricks that allow more efficient implementations. The onus is on the contributors of new operators to add them to the right attributes.
Note
The attributes for qubit gates are currently found in pennylane/ops/qubit/attributes.py
.
Included attributes are listed in the Operation
documentation.
Adding your new operator to PennyLane¶
If you want PennyLane to natively support your new operator, you have to make a Pull Request that adds it
to the appropriate folder in pennylane/ops/
. The
tests are added to a file of a similar name and location in tests/ops/
. If your operator defines an
ansatz, add it to the appropriate subfolder in pennylane/templates/
.
The new operation may have to be imported in the module’s __init__.py
file in order to be imported correctly.
Make sure that all hyperparameters and errors are tested, and that the parameters can be passed as tensors from all supported autodifferentiation frameworks.
Don’t forget to also add the new operator to the documentation in the docs/introduction/operations.rst
file, or to
the template gallery if it is an ansatz. The latter is done by adding a gallery-item
to the correct section in doc/introduction/templates.rst
:
.. gallery-item::
:link: ../code/api/pennylane.templates.<templ_type>.MyNewTemplate.html
:description: MyNewTemplate
:figure: ../_static/templates/<templ_type>/my_new_template.png
Note
This loads the image of the template added to doc/_static/templates/test_<templ_type>/
. Make sure that
this image has the same dimensions and style as other template icons in the folder.
Here are a few more tips for adding operators:
Choose the name carefully. Good names tell the user what the operator is used for, or what architecture it implements. Ask yourself if a gate of a similar name could be added soon in a different context.
Write good docstrings. Explain what your operator does in a clear docstring with ample examples. You find more about Pennylane standards in the guidelines on Documentation.
Efficient representations. Try to implement representations as efficiently as possible, since they may be constructed several times.
Input checks. Checking the inputs of the operation introduces an overhead and clashes with tools like just-in-time compilation. Find a balance of adding meaningful sanity checks (such as for the shape of tensors), but keeping them to a minimum.