Note
This blog post may contain outdated information. We have improved custom operators in PennyLane by a large margin since it was first published — please visit our docs pages to learn how to add new operators and create custom templates.
Implementing your own gates in PennyLane does not require deep manipulation of the code base but is as easy as listing some core properties of the operation to be added. Even easier, you can construct your own templates to create structured special purpose circuits and simplify your workflow.
Adding a custom gate
We will build up an example of a custom gate, beginning with a minimal callable Python object and successively adding information to it.
The bare minimum
Let’s start by looking at a minimal callable implementation of a one-parameter two-qubit gate:
from pennylane.operation import Operation class IncognitoGate(Operation): num_params = 1 num_wires = 2 par_domain = "R"
As we can see, from a programming perspective, we don’t need much to define a new custom gate in PennyLane (although it is not particularly useful at this stage). The minimal information required to form a valid, callable subclass of Operation
is the following:
-
How many (variational) parameters does the gate take as input? (
num_params
) -
On how many wires does the gate act? (
num_wires
) -
From which domain are the parameters of the gate chosen? (
par_domain
)The available domains are:
- “N”: natural numbers and 0.
- “R”: real numbers, i.e. floating point numbers.
- “A”: arrays of real or complex values.
- “L”: lists of arrays of real or complex values.
None
: there are no parameters. Note thatpar_domain
has to be provided explicitly in this case (as it is an abstract method).
A working example
While it forms a well-defined gate, the IncognitoGate
does not do anything yet and in addition it will of course not be supported on any PennyLane device. Let us therefore promote it to a meaningful operation, for example a Pauli rotation generated by the Pauli word X\otimes X, which can be implemented via PauliRot
:
import pennylane as qml from pennylane.operation import Operation from pennylane import numpy as np class RXX(Operation): num_params = 1 num_wires = 2 par_domain = "R" @staticmethod def decomposition(theta, wires): return [qml.PauliRot(theta, 'XX', wires=wires)]
We add the method decomposition
to relate our new gate to the gates that PennyLane already knows. This suffices to make the gate useful and usable in a quantum function:
dev = qml.device('default.qubit', wires=2, shots=100) @qml.qnode(dev) def circuit(theta): RXX(theta, wires=[0, 1]) qml.Hadamard(1) return qml.expval(qml.PauliZ(0) @ qml.PauliY(1)) >>> circuit(0.3) tensor(-0.12, requires_grad=True)
Notice how we did not have to implement the action of the gate in detail. The support for our brand-new gate RXX
was simply inherited and all devices supporting qml.PauliRot
- either directly or by decomposing it - will also support RXX
.
Adding details
Now that we know the bare minimum required to create a useful gate, let’s consider some optional information we may want to provide about our gate:
Gradient details
As our new gate is parametrized by a real-valued parameter, we may want to tell PennyLane that there is a parameter-shift rule to compute its gradient analytically. This can be done by providing the subclass with the method grad_method
. Available methods for differentiation are:
- “A”: Analytic differentiation via the parameter shift rule. The particular rule is stored in the
grad_recipe
of the gate and defaults to the two-term parameter shift rule. - “F”: Numeric differentiation via finite difference. The order of the rule (1 for forward finite difference or 2 for central finite difference) and the shift size h can be passed to
qml.qnode
. None
: No differentiation of the operation is possible/available.
If we set grad_method = "A"
but the applicable parameter shift rule is not the default two-term rule, provide a grad_recipe
to the gate explicitly. An example would be the four-term rule available as four_term_grad_recipe
in PennyLane. Take a look at one of the gotchas regarding which gradient rule is used.
Matrix representation details
In addition to an implicit definition via decomposition
it is often useful to provide the matrix of the gate explicitly. We do this via the class method _matrix
which generates the class attribute matrix
whenever we instantiate the new gate. Unless we register a new gate as supported by a specific device, the matrix
will not be used internally, but it can be very useful in other contexts. For example, below we actually make use of the matrix
attribute of another operator to define the generator property. (Also see a gotcha about only providing _matrix
.)
Instead of _matrix
, we may define the instance attribute matrix
directly, using the property self.parameters
(and self.wires
if needed). For our rotation gate, this would take the following form:
@property def matrix(self): c = np.cos(0.5 * self.parameters[0]) s = np.sin(0.5 * self.parameters[0]) return np.array( [ [c, 0, 0, -s], [0, c, -s, 0], [0, -s, c, 0], [-s, 0, 0, c] ] )
Furthermore, for some optimizers the generating operator for the gate is used. For our gate, we provide PennyLane with this information via generator = [(qml.PauliX(0) @ qml.PauliX(1)).matrix, -0.5]
, where the first list entry is the operator and the second entry is the scaling prefactor. Alternatively, we could pass a NumPy array of the correct size as first entry, in which case we may include the scaling in the generator directly.
Other details
For self-adjoint operators or rotation gates like RXX
we may want to tell PennyLane directly how to obtain the adjoint by implementing the adjoint
class method.
The full gate
To sum it up, here is the full definition of our custom rotation gate:
import pennylane as qml from pennylane.operation import Operation from pennylane import numpy as np class RXX(Operation): num_params = 1 num_wires = 2 par_domain = "R" grad_method = "A" grad_recipe = None # This is the default but we write it down explicitly here. generator = [(qml.PauliX(0) @ qml.PauliX(1)).matrix, -0.5] @staticmethod def decomposition(theta, wires): return [qml.PauliRot(theta, 'XX', wires=wires)] @staticmethod def _matrix(*params): theta = params[0] c = np.cos(0.5 * theta) s = np.sin(0.5 * theta) return np.array( [ [c, 0, 0, -s], [0, c, -s, 0], [0, -s, c, 0], [-s, 0, 0, c] ] ) def adjoint(self): return RXX(-self.data[0], wires=self.wires)
This new gate now can be used just like any other operation in the PennyLane stack, and will be supported on a wide range of devices.
Gotchas
Before we move on to creating custom templates in PennyLane, let us look at some gotchas when adding a custom gate:
-
If a gate has multiple parameters (i.e.
num_params > 1
), the parameter domain (par_domain
) and the differentiation method (grad_method
) of all parameters has to coincide. -
The
grad_recipe
method has to be given per parameter. That means for a two-parameter gate that satisfies the two-term (four-term) rule with respect to the first (second) parameter, we would writegrad_recipe = ( [ [0.5, 1, 0.5*np.pi], [-0.5, 1, -0.5*np.pi], ], qml.ops.qubit.four_term_grad_recipe[0], )
As we can see, the structure is a tuple of single gradient recipes, which in turn are lists of entries. Each of these entries finally consists of the prefactor of the term, c_i, a prefactor for the gate parameter, a_i, and the parameter shift, s_i. These are used to compute
Regarding the stored four-term grad recipe, note that we had to extract the first (and only) entry of the tuple qml.ops.qubit.four_term_grad_recipe
.
-
One could think about providing a custom gate with
_matrix
only, fully defining its action on a quantum state, and skipdecomposition
. However, this typically will fail because PennyLane devices do not know how to execute the gate yet. There are two ways to fix this:First, creating a new, special-purpose device would allow us to use the implementation via _matrix directly. Second, adding a custom gate to the native gate set of the
default.qubit
device simple and makes the gate available even without adecomposition
:dev = qml.device('default.qubit', wires=3) dev.operations.add("RXX")
Note that extending the native gate set in general is more complicated or might even be impossible (for example on quantum hardware devices).
-
If we do not register a custom gate with the device we are using (e.g. by creating a custom device), PennyLane will make use of the decomposition. When computing a derivative with respect to the gate parameter, the decomposition will be done first and therefore
grad_method
andgrad_recipe
of the custom gate will not be used at all. -
For gates that do not use
diff_method = "A"
, PennyLane will not allow the definition of agrad_recipe
to make clear that it would not be used anyways. -
In the future, the abstract method
par_domain
will be removed. This would not break our gates defined above but would make the definition ofpar_domain
superfluous.
Adding a custom template
Let’s now look at how to build templates in PennyLane. Templates make our lives much easier by avoiding code duplication and simplifying the structure of our programs. For a guideline and technical details, visit the PennyLane documentation.
A minimal template
Let’s first look at a very simple template that prepares Bell states on neighbouring qubits.
Inheriting from Operation
Creating your own custom template in PennyLane and even adding it to the built-in template library can be done easily by implementing a qml.operation.Operation
subclass, just like we did for the custom gates above. We will implement the action of the template within the class method expand
. This method will be used internally by the PennyLane device to obtain a tape with known operations that then can be executed. This is the recommended way to construct templates which you plan on using often or even adding to the PennyLane library.
import pennylane as qml from pennylane.operation import Operation, AnyWires class BellPairLayer(Operation): num_params = 0 # The template does not take any parameters. num_wires = AnyWires # The template works for any number of wires. par_domain = None # We will not pass parameters to the template. def expand(self): # Create the tape with all operations. with qml.tape.QuantumTape() as tape: # Iterate over all qubit pairs for i in range(len(self.wires)//2): qml.Hadamard(wires=[self.wires[2*i]]) qml.CNOT(wires=[self.wires[2*i], self.wires[2*i+1]]) return tape
We can use the new template like any PennyLane Operation
. For example, let’s prepare the Bell states and sum over all Pauli Z measurements.
from pennylane import numpy as np num_wires = 8 # Instantiate a qubit device with num_wires=8 wires and 100 shots per expectation value. dev = qml.device('default.qubit', wires=num_wires, shots=100) # Simply add an expectation value measurement to the template and create a QNode. @qml.qnode(dev) def circuit(): # As we want to call the template on all wires, we may use dev.wires. BellPairLayer(wires=dev.wires) return [qml.expval(qml.PauliZ(wire)) for wire in dev.wires] # Sum the expectation values into a cost function. def cost(): return np.sum(circuit()) >>> cost() tensor(-0.12, requires_grad=False)
We see that adding a template to PennyLane does not require much more than spelling out the used operations in the class method expand. The only additional information that is required are the abstract methods
num_params
, which often will be 0 as above or 1 because we unpack parameters withinexpand
,num_wires
, which usually will be set to the placeholderAnyWires
to allow for any number of wires, andpar_domain
, which often will be"A"
(or None fornum_params=0
), but does not play a major role and will be deprecated in future releases.
Using the template
decorator
For simple cases like the Bell pair template above, there is an even easier way of defining the templates, namely as quantum function decorated with template
:
import pennylane as qml @qml.template def bell_pair_layer(wires): # Iterate over all qubit pairs for i in range(len(wires)//2): qml.Hadamard(wires=[wires[2*i]]) qml.CNOT(wires=[wires[2*i], wires[2*i+1]]) num_wires = 8 # Instantiate a default qubit device with num_wires=8 wires and 100 shots per expectation value dev = qml.device('default.qubit', wires=num_wires, shots=100) # Simply add an expectation value measurement to the template and create a QNode. @qml.qnode(dev) def circuit(): # As we want to call the template on all wires, we may use dev.wires. bell_pair_layer(wires=dev.wires) return [qml.expval(qml.PauliZ(wire)) for wire in dev.wires] # Sum the expectation values into a cost function. def cost(): return np.sum(circuit())
As we can see, the quantum function does not construct and return a tape like BellPairLayer.expand()
did above, but calls the required operations directly. In addition, the template
decorator allows us to apply built-ins like qml.inv()
to the template.
Using the template afterwards is a piece of cake 🍰!
While the decorator method for defining a template is simpler for small circuits like the one shown here, it provides less capabilities and flexibility than the subclassing approach. This makes subclassing the preferred approach to provide templates to others and share it via the PennyLane template library. For more information on contributing your template, please refer to the corresponding technical guideline within the PennyLane documentation.
About the author
David Wierichs
I like to think about differentiation and representations of quantum programs, and I enjoy coding up research ideas and useful features for anyone to use in PennyLane.