diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md
index 77d4d423bdb..96c6beea384 100644
--- a/.github/CHANGELOG.md
+++ b/.github/CHANGELOG.md
@@ -2,6 +2,39 @@
New features since last release
+* PennyLane QNodes can now be converted into Keras layers, allowing for creation of quantum and
+ hybrid models using the Keras API.
+ [(#529)](https://github.com/XanaduAI/pennylane/pull/529)
+
+ ```python
+ n_qubits = 2
+ dev = qml.device("default.qubit", wires=n_qubits)
+
+ @qml.qnode(dev)
+ def qnode(inputs, weights_0, weight_1):
+ qml.RX(inputs[0], wires=0)
+ qml.RX(inputs[1], wires=1)
+ qml.Rot(*weights_0, wires=0)
+ qml.RY(weight_1, wires=1)
+ qml.CNOT(wires=[0, 1])
+ return qml.expval(qml.PauliZ(0)), qml.expval(qml.PauliZ(1))
+ ```
+
+ The above QNode can be converted into a Keras layer using the `KerasLayer` class:
+
+ ```python
+ from pennylane.qnn import KerasLayer
+
+ weight_shapes = {"weights_0": 3, "weight_1": 1}
+ qlayer = qml.qnn.KerasLayer(qnode, weight_shapes, output_dim=2)
+ ```
+
+ A hybrid model can then be easily constructed:
+
+ ```python
+ model = tf.keras.models.Sequential([qlayer, tf.keras.layers.Dense(2)])
+ ```
+
* Added the gate `PauliRot(angle, pauli_word)` that performs an arbitrary
Pauli rotation specified by the Pauli word in string form and the gate
`MultiRZ(angle)` that performs a rotation generated by a tensor product
@@ -55,12 +88,12 @@
```
* Added the ``SimplifiedTwoDesign`` template, which implements the circuit
- design of `Cerezo et al. (2020) `_.
+ design of [Cerezo et al. (2020)]().
[(#556)](https://github.com/XanaduAI/pennylane/pull/556)
-
+
* Added the ``BasicEntanglerLayers`` template, which is a simple layer architecture
of rotations and CNOT nearest-neighbour entanglers.
[(#555)](https://github.com/XanaduAI/pennylane/pull/555)
diff --git a/doc/code/qml_qnn.rst b/doc/code/qml_qnn.rst
new file mode 100644
index 00000000000..72b15dbb892
--- /dev/null
+++ b/doc/code/qml_qnn.rst
@@ -0,0 +1,9 @@
+qml.qnn
+=======
+
+.. currentmodule:: pennylane.qnn
+
+.. automodapi:: pennylane.qnn
+ :no-heading:
+ :no-inheritance-diagram:
+ :no-inherited-members:
diff --git a/doc/index.rst b/doc/index.rst
index a72ad88b513..1e1c268c3b5 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -203,6 +203,7 @@ PennyLane is **free** and **open source**, released under the Apache License, Ve
code/qml_operation
code/qml_plugins
code/qml_qchem
+ code/qml_qnn
code/qml_qnodes
code/qml_templates
code/qml_utils
diff --git a/pennylane/__init__.py b/pennylane/__init__.py
index b645fab34c2..6d14866bc79 100644
--- a/pennylane/__init__.py
+++ b/pennylane/__init__.py
@@ -29,6 +29,7 @@
import pennylane.init
import pennylane.templates
+import pennylane.qnn
from pennylane.templates import template, broadcast
from pennylane.about import about
from pennylane.vqe import Hamiltonian, VQECost
diff --git a/pennylane/interfaces/tf.py b/pennylane/interfaces/tf.py
index 28dc2710483..bea2458307f 100644
--- a/pennylane/interfaces/tf.py
+++ b/pennylane/interfaces/tf.py
@@ -32,11 +32,13 @@
from tensorflow import Variable # pylint: disable=unused-import,ungrouped-imports
-def to_tf(qnode):
+def to_tf(qnode, dtype=None):
"""Function that accepts a :class:`~.QNode`, and returns a TensorFlow eager-execution-compatible QNode.
Args:
qnode (~pennylane.qnode.QNode): a PennyLane QNode
+ dtype (tf.DType): target output type of QNode; uses the TensorFlow equivalent of the
+ QNode output type if ``dtype`` is not specified
Returns:
function: the QNode as a TensorFlow function
@@ -67,6 +69,7 @@ def __repr__(self):
jacobian = qnode.jacobian
metric_tensor = qnode.metric_tensor
draw = qnode.draw
+ func = qnode.func
@qnode_str
@tf.custom_gradient
@@ -110,17 +113,17 @@ def grad(grad_output, **tfkwargs):
grad_input = unflatten(temp.flat, args)
if isinstance(grad_input, list):
- grad_input = [tf.convert_to_tensor(i) for i in grad_input]
+ grad_input = [tf.convert_to_tensor(i, dtype=dtype) for i in grad_input]
elif isinstance(grad_input, tuple):
- grad_input = tuple(tf.convert_to_tensor(i) for i in grad_input)
+ grad_input = tuple(tf.convert_to_tensor(i, dtype=dtype) for i in grad_input)
else:
- grad_input = tf.convert_to_tensor(grad_input)
+ grad_input = tf.convert_to_tensor(grad_input, dtype=dtype)
if variables is not None:
return grad_input, variables
return grad_input
- return res, grad
+ return tf.convert_to_tensor(res, dtype=dtype), grad
return _TFQNode
diff --git a/pennylane/interfaces/torch.py b/pennylane/interfaces/torch.py
index 13802b61a7f..ffdc9f1a0e6 100644
--- a/pennylane/interfaces/torch.py
+++ b/pennylane/interfaces/torch.py
@@ -199,6 +199,7 @@ def __repr__(self):
jacobian = qnode.jacobian
metric_tensor = qnode.metric_tensor
draw = qnode.draw
+ func = qnode.func
@qnode_str
def custom_apply(*args, **kwargs):
diff --git a/pennylane/qnn/__init__.py b/pennylane/qnn/__init__.py
new file mode 100644
index 00000000000..90db27feebf
--- /dev/null
+++ b/pennylane/qnn/__init__.py
@@ -0,0 +1,16 @@
+# Copyright 2018-2020 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""This module contains classes and functions for constructing quantum neural networks from QNodes."""
+
+from .keras import KerasLayer
diff --git a/pennylane/qnn/keras.py b/pennylane/qnn/keras.py
new file mode 100644
index 00000000000..52f22c50bbc
--- /dev/null
+++ b/pennylane/qnn/keras.py
@@ -0,0 +1,306 @@
+# Copyright 2018-2020 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""This module contains the classes and functions for integrating QNodes with the Keras Layer API."""
+import functools
+import inspect
+from collections.abc import Iterable
+from typing import Optional
+
+try:
+ import tensorflow as tf
+ from tensorflow.keras.layers import Layer
+ from pennylane.interfaces.tf import to_tf
+
+ CORRECT_TF_VERSION = int(tf.__version__.split(".")[0]) > 1
+except ImportError:
+ # The following allows this module to be imported even if TensorFlow is not installed. Users
+ # will instead see an ImportError when instantiating the KerasLayer.
+ from abc import ABC
+
+ Layer = ABC
+ CORRECT_TF_VERSION = False
+
+
+class KerasLayer(Layer):
+ """Converts a :func:`~.QNode` to a Keras
+ `Layer `__.
+
+ The result can be used within the Keras
+ `Sequential `__ or
+ `Model `__ classes for
+ creating quantum and hybrid models.
+
+ Args:
+ qnode (qml.QNode): the PennyLane QNode to be converted into a Keras Layer_
+ weight_shapes (dict[str, tuple]): a dictionary mapping from all weights used in the QNode to
+ their corresponding shapes
+ output_dim (int): the output dimension of the QNode
+ weight_specs (dict[str, dict]): An optional dictionary for users to provide additional
+ specifications for weights used in the QNode, such as the method of parameter
+ initialization. This specification is provided as a dictionary with keys given by the
+ arguments of the `add_weight()
+ `__.
+ method and values being the corresponding specification.
+ **kwargs: additional keyword arguments passed to the Layer_ base class
+
+ **Example**
+
+ .. code-block:: python
+
+ qlayer = qml.qnn.KerasLayer(qnode, weight_shapes, output_dim=2)
+ clayer = tf.keras.layers.Dense(2)
+ model = tf.keras.models.Sequential([qlayer, clayer])
+
+ The signature of the QNode **must** contain an ``inputs`` named argument for input data,
+ with all other arguments to be treated as internal weights. A valid ``qnode`` for the example
+ above would be:
+
+ .. code-block:: python
+
+ n_qubits = 2
+ dev = qml.device("default.qubit", wires=n_qubits)
+
+ @qml.qnode(dev)
+ def qnode(inputs, weights_0, weight_1):
+ qml.RX(inputs[0], wires=0)
+ qml.RX(inputs[1], wires=1)
+ qml.Rot(*weights_0, wires=0)
+ qml.RY(weight_1, wires=1)
+ qml.CNOT(wires=[0, 1])
+ return qml.expval(qml.PauliZ(0)), qml.expval(qml.PauliZ(1))
+
+ The internal weights of the QNode are automatically initialized within the
+ :class:`~.KerasLayer` and must have their shapes specified in a ``weight_shapes`` dictionary.
+ For example:
+
+ .. code-block::
+
+ weight_shapes = {"weights_0": 3, "weight_1": 1}
+
+ .. UsageDetails::
+
+ The QNode must have a signature that satisfies the following conditions:
+
+ - Contain an ``inputs`` named argument for input data.
+ - All other arguments must accept an array or tensor and are treated as internal
+ weights of the QNode.
+ - All other arguments must have no default value.
+ - The ``inputs`` argument is permitted to have a default value provided the gradient with
+ respect to ``inputs`` is not required.
+ - There cannot be a variable number of positional or keyword arguments, e.g., no ``*args``
+ or ``**kwargs`` present in the signature.
+
+ The optional ``weight_specs`` argument allows for a more fine-grained
+ specification of the QNode weights, such as the method of initialization and any
+ regularization or constraints. For example, the initialization method of the ``weights``
+ argument in the example above could be specified by:
+
+ .. code-block::
+
+ weight_specs = {"weights": {"initializer": "random_uniform"}}
+
+ The values of ``weight_specs`` are dictionaries with keys given by arguments of
+ the Keras
+ `add_weight() `__
+ method. For the ``"initializer"`` argument, one can specify a string such as
+ ``"random_uniform"`` or an instance of an `Initializer
+ `__ class, such as
+ `tf.keras.initializers.RandomUniform `__.
+
+ If ``weight_specs`` is not specified, weights will be added using the Keras default
+ initialization and without any regularization or constraints.
+
+ **Additional example**
+
+ The code block below shows how a circuit composed of templates from the
+ :doc:`/code/qml_templates` module can be combined with classical
+ `Dense `__ layers to learn
+ the two-dimensional `moons `__ dataset.
+
+ .. code-block:: python
+
+ import pennylane as qml
+ import tensorflow as tf
+ import sklearn.datasets
+
+ n_qubits = 2
+ dev = qml.device("default.qubit", wires=n_qubits)
+
+ @qml.qnode(dev)
+ def qnode(inputs, weights):
+ qml.templates.AngleEmbedding(inputs, wires=range(n_qubits))
+ qml.templates.StronglyEntanglingLayers(weights, wires=range(n_qubits))
+ return qml.expval(qml.PauliZ(0)), qml.expval(qml.PauliZ(1))
+
+ weight_shapes = {"weights": (3, n_qubits, 3)}
+
+ qlayer = qml.qnn.KerasLayer(qnode, weight_shapes, output_dim=2)
+ clayer1 = tf.keras.layers.Dense(2)
+ clayer2 = tf.keras.layers.Dense(2, activation="softmax")
+ model = tf.keras.models.Sequential([clayer1, qlayer, clayer2])
+
+ data = sklearn.datasets.make_moons()
+ X = tf.constant(data[0])
+ Y = tf.one_hot(data[1], depth=2)
+
+ opt = tf.keras.optimizers.SGD(learning_rate=0.5)
+ model.compile(opt, loss='mae')
+
+ The model can be trained using:
+
+ >>> model.fit(X, Y, epochs=8, batch_size=5)
+ Train on 100 samples
+ Epoch 1/8
+ 100/100 [==============================] - 9s 90ms/sample - loss: 0.3524
+ Epoch 2/8
+ 100/100 [==============================] - 9s 87ms/sample - loss: 0.2441
+ Epoch 3/8
+ 100/100 [==============================] - 9s 87ms/sample - loss: 0.1908
+ Epoch 4/8
+ 100/100 [==============================] - 9s 87ms/sample - loss: 0.1832
+ Epoch 5/8
+ 100/100 [==============================] - 9s 88ms/sample - loss: 0.1596
+ Epoch 6/8
+ 100/100 [==============================] - 9s 87ms/sample - loss: 0.1637
+ Epoch 7/8
+ 100/100 [==============================] - 9s 86ms/sample - loss: 0.1613
+ Epoch 8/8
+ 100/100 [==============================] - 9s 87ms/sample - loss: 0.1474
+
+ .. _Layer: https://www.tensorflow.org/api_docs/python/tf/keras/layers/Layer
+ """
+
+ def __init__(
+ self, qnode, weight_shapes: dict, output_dim, weight_specs: Optional[dict] = None, **kwargs
+ ):
+ if not CORRECT_TF_VERSION:
+ raise ImportError("KerasLayer requires TensorFlow version 2 and above")
+
+ self.sig = qnode.func.sig
+
+ if self.input_arg not in self.sig:
+ raise TypeError(
+ "QNode must include an argument with name {} for inputting data".format(
+ self.input_arg
+ )
+ )
+
+ if self.input_arg in set(weight_shapes.keys()):
+ raise ValueError(
+ "{} argument should not have its dimension specified in "
+ "weight_shapes".format(self.input_arg)
+ )
+
+ if set(weight_shapes.keys()) | {self.input_arg} != set(self.sig.keys()):
+ raise ValueError("Must specify a shape for every non-input parameter in the QNode")
+
+ if qnode.func.var_pos:
+ raise TypeError("Cannot have a variable number of positional arguments")
+
+ if qnode.func.var_keyword:
+ raise TypeError("Cannot have a variable number of keyword arguments")
+
+ self.qnode = to_tf(qnode, dtype=tf.keras.backend.floatx())
+ self.weight_shapes = {
+ weight: (tuple(size) if isinstance(size, Iterable) else (size,) if size > 1 else ())
+ for weight, size in weight_shapes.items()
+ }
+
+ # Allows output_dim to be specified as an int, e.g., 5, or as a length-1 tuple, e.g., (5,)
+ self.output_dim = output_dim[0] if isinstance(output_dim, Iterable) else output_dim
+
+ defaults = {
+ name for name, sig in self.sig.items() if sig.par.default != inspect.Parameter.empty
+ }
+ self.input_is_default = self.input_arg in defaults
+ if defaults - {self.input_arg} != set():
+ raise TypeError(
+ "Only the argument {} is permitted to have a default".format(self.input_arg)
+ )
+
+ self.weight_specs = weight_specs if weight_specs is not None else {}
+
+ self.qnode_weights = {}
+
+ super().__init__(dynamic=True, **kwargs)
+
+ def build(self, input_shape):
+ """Initializes the QNode weights.
+
+ Args:
+ input_shape (tuple or tf.TensorShape): shape of input data
+ """
+ for weight, size in self.weight_shapes.items():
+ spec = self.weight_specs.get(weight, {})
+ self.qnode_weights[weight] = self.add_weight(name=weight, shape=size, **spec)
+
+ super().build(input_shape)
+
+ def call(self, inputs):
+ """Evaluates the QNode on input data using the initialized weights.
+
+ Args:
+ inputs (tensor): data to be processed
+
+ Returns:
+ tensor: output data
+ """
+ outputs = []
+ for x in inputs: # iterate over batch
+
+ # The QNode can require some passed arguments to be positional and others to be keyword.
+ # The following loops through input arguments in order and uses functools.partial to
+ # bind the argument to the QNode.
+ qnode = self.qnode
+
+ for arg in self.sig:
+ if arg is not self.input_arg: # Non-input arguments must always be positional
+ w = self.qnode_weights[arg]
+ qnode = functools.partial(qnode, w)
+ else:
+ if self.input_is_default: # The input argument can be positional or keyword
+ qnode = functools.partial(qnode, **{self.input_arg: x})
+ else:
+ qnode = functools.partial(qnode, x)
+ outputs.append(qnode())
+
+ return tf.stack(outputs)
+
+ def compute_output_shape(self, input_shape):
+ """Computes the output shape after passing data of shape ``input_shape`` through the
+ QNode.
+
+ Args:
+ input_shape (tuple or tf.TensorShape): shape of input data
+
+ Returns:
+ tf.TensorShape: shape of output data
+ """
+ return tf.TensorShape([input_shape[0], self.output_dim])
+
+ def __str__(self):
+ detail = ""
+ return detail.format(self.qnode.func.__name__)
+
+ __repr__ = __str__
+
+ _input_arg = "inputs"
+
+ @property
+ def input_arg(self):
+ """Name of the argument to be used as the input to the Keras
+ `Layer `__. Set to
+ ``"inputs"``."""
+ return self._input_arg
diff --git a/tests/qnn/test_keras.py b/tests/qnn/test_keras.py
new file mode 100644
index 00000000000..1c0b5ad4aa6
--- /dev/null
+++ b/tests/qnn/test_keras.py
@@ -0,0 +1,446 @@
+# Copyright 2018-2020 Xanadu Quantum Technologies Inc.
+
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+
+# http://www.apache.org/licenses/LICENSE-2.0
+
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""
+Tests for the pennylane.qnn.keras module.
+"""
+import numpy as np
+import pytest
+
+import pennylane as qml
+from pennylane.qnn.keras import KerasLayer
+
+tf = pytest.importorskip("tensorflow", minversion="2")
+
+
+@pytest.fixture
+def get_circuit(n_qubits, output_dim, interface):
+ """Fixture for getting a sample quantum circuit with a controllable qubit number and output
+ dimension. Returns both the circuit and the shape of the weights."""
+
+ dev = qml.device("default.qubit", wires=n_qubits)
+ weight_shapes = {
+ "w1": (3, n_qubits, 3),
+ "w2": (1,),
+ "w3": 1,
+ "w4": [3],
+ "w5": (2, n_qubits, 3),
+ "w6": 3,
+ "w7": 0,
+ }
+
+ @qml.qnode(dev, interface=interface)
+ def circuit(inputs, w1, w2, w3, w4, w5, w6, w7):
+ """A circuit that embeds data using the AngleEmbedding and then performs a variety of
+ operations. The output is a PauliZ measurement on the first output_dim qubits. One set of
+ parameters, w5, are specified as non-trainable."""
+ qml.templates.AngleEmbedding(inputs, wires=list(range(n_qubits)))
+ qml.templates.StronglyEntanglingLayers(w1, wires=list(range(n_qubits)))
+ qml.RX(w2[0], wires=0 % n_qubits)
+ qml.RX(w3, wires=1 % n_qubits)
+ qml.Rot(*w4, wires=2 % n_qubits)
+ qml.templates.StronglyEntanglingLayers(w5, wires=list(range(n_qubits)))
+ qml.Rot(*w6, wires=3 % n_qubits)
+ qml.RX(w7, wires=4 % n_qubits)
+ return [qml.expval(qml.PauliZ(i)) for i in range(output_dim)]
+
+ return circuit, weight_shapes
+
+
+@pytest.mark.usefixtures("get_circuit")
+@pytest.fixture
+def model(get_circuit, n_qubits, output_dim):
+ """Fixture for creating a hybrid Keras model. The model is composed of KerasLayers sandwiched
+ between Dense layers."""
+ c, w = get_circuit
+ layer1 = KerasLayer(c, w, output_dim)
+ layer2 = KerasLayer(c, w, output_dim)
+
+ model = tf.keras.models.Sequential(
+ [
+ tf.keras.layers.Dense(n_qubits),
+ layer1,
+ tf.keras.layers.Dense(n_qubits),
+ layer2,
+ tf.keras.layers.Dense(output_dim),
+ ]
+ )
+
+ return model
+
+
+def indicies_up_to(n_max):
+ """Returns an iterator over the number of qubits and output dimension, up to value n_max.
+ The output dimension never exceeds the number of qubits."""
+ a, b = np.tril_indices(n_max)
+ return zip(*[a + 1, b + 1])
+
+
+@pytest.mark.usefixtures("get_circuit")
+class TestKerasLayer:
+ """Unit tests for the pennylane.qnn.keras.KerasLayer class."""
+
+ @pytest.mark.parametrize("interface", ["tf"]) # required for the get_circuit fixture
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(1))
+ def test_bad_tf_version(self, get_circuit, output_dim, monkeypatch):
+ """Test if an ImportError is raised when instantiated with an incorrect version of
+ TensorFlow"""
+ c, w = get_circuit
+ with monkeypatch.context() as m:
+ m.setattr(qml.qnn.keras, "CORRECT_TF_VERSION", False)
+ with pytest.raises(ImportError, match="KerasLayer requires TensorFlow version 2"):
+ KerasLayer(c, w, output_dim)
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(1))
+ def test_no_input(self, get_circuit, output_dim):
+ """Test if a TypeError is raised when instantiated with a QNode that does not have an
+ argument with name equal to the input_arg class attribute of KerasLayer"""
+ c, w = get_circuit
+ del c.func.sig[qml.qnn.keras.KerasLayer._input_arg]
+ with pytest.raises(TypeError, match="QNode must include an argument with name"):
+ KerasLayer(c, w, output_dim)
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(1))
+ def test_input_in_weight_shapes(self, get_circuit, n_qubits, output_dim):
+ """Test if a ValueError is raised when instantiated with a weight_shapes dictionary that
+ contains the shape of the input argument given by the input_arg class attribute of
+ KerasLayer"""
+ c, w = get_circuit
+ w[qml.qnn.keras.KerasLayer._input_arg] = n_qubits
+ with pytest.raises(
+ ValueError,
+ match="{} argument should not have its dimension".format(
+ qml.qnn.keras.KerasLayer._input_arg
+ ),
+ ):
+ KerasLayer(c, w, output_dim)
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(1))
+ def test_weight_shape_unspecified(self, get_circuit, output_dim):
+ """Test if a ValueError is raised when instantiated with a weight missing from the
+ weight_shapes dictionary"""
+ c, w = get_circuit
+ del w["w1"]
+ with pytest.raises(ValueError, match="Must specify a shape for every non-input parameter"):
+ KerasLayer(c, w, output_dim)
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(1))
+ def test_var_pos(self, get_circuit, monkeypatch, output_dim):
+ """Test if a TypeError is raised when instantiated with a variable number of positional
+ arguments"""
+ c, w = get_circuit
+
+ class FuncPatch:
+ """Patch for variable number of keyword arguments"""
+
+ sig = c.func.sig
+ var_pos = True
+ var_keyword = False
+
+ with monkeypatch.context() as m:
+ m.setattr(c, "func", FuncPatch)
+
+ with pytest.raises(TypeError, match="Cannot have a variable number of positional"):
+ KerasLayer(c, w, output_dim)
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(1))
+ def test_var_keyword(self, get_circuit, monkeypatch, output_dim):
+ """Test if a TypeError is raised when instantiated with a variable number of keyword
+ arguments"""
+ c, w = get_circuit
+
+ class FuncPatch:
+ """Patch for variable number of keyword arguments"""
+
+ sig = c.func.sig
+ var_pos = False
+ var_keyword = True
+
+ with monkeypatch.context() as m:
+ m.setattr(c, "func", FuncPatch)
+
+ with pytest.raises(TypeError, match="Cannot have a variable number of keyword"):
+ KerasLayer(c, w, output_dim)
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits", [1])
+ @pytest.mark.parametrize("output_dim", zip(*[[[1], (1,), 1], [1, 1, 1]]))
+ def test_output_dim(self, get_circuit, output_dim):
+ """Test if the output_dim is correctly processed, i.e., that an iterable is mapped to
+ its first element while an int is left unchanged."""
+ c, w = get_circuit
+ layer = KerasLayer(c, w, output_dim[0])
+ assert layer.output_dim == output_dim[1]
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(2))
+ def test_weight_shapes(self, get_circuit, output_dim, n_qubits):
+ """Test if the weight_shapes input argument is correctly processed to be a dictionary
+ with values that are tuples."""
+ c, w = get_circuit
+ layer = KerasLayer(c, w, output_dim)
+ assert layer.weight_shapes == {
+ "w1": (3, n_qubits, 3),
+ "w2": (1,),
+ "w3": (),
+ "w4": (3,),
+ "w5": (2, n_qubits, 3),
+ "w6": (3,),
+ "w7": (),
+ }
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(1))
+ def test_non_input_defaults(self, get_circuit, output_dim, n_qubits):
+ """Test if a TypeError is raised when default arguments that are not the input argument are
+ present in the QNode"""
+ c, w = get_circuit
+
+ @qml.qnode(qml.device("default.qubit", wires=n_qubits), interface="tf")
+ def c_dummy(inputs, w1, w2, w3, w4, w5, w6, w7, w8=None):
+ """Dummy version of the circuit with a default argument"""
+ return c(inputs, w1, w2, w3, w4, w5, w6, w7)
+
+ with pytest.raises(
+ TypeError,
+ match="Only the argument {} is permitted".format(qml.qnn.keras.KerasLayer._input_arg),
+ ):
+ KerasLayer(c_dummy, {**w, **{"w8": 1}}, output_dim)
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(2))
+ def test_qnode_weights(self, get_circuit, n_qubits, output_dim):
+ """Test if the build() method correctly initializes the weights in the qnode_weights
+ dictionary, i.e., that each value of the dictionary has correct shape and name."""
+ c, w = get_circuit
+ layer = KerasLayer(c, w, output_dim)
+ layer.build(input_shape=(10, n_qubits))
+
+ for weight, shape in layer.weight_shapes.items():
+ assert layer.qnode_weights[weight].shape == shape
+ assert layer.qnode_weights[weight].name[:-2] == weight
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(1))
+ def test_qnode_weights_with_spec(self, get_circuit, monkeypatch, output_dim, n_qubits):
+ """Test if the build() method correctly passes on user specified weight_specs to the
+ inherited add_weight() method. This is done by monkeypatching add_weight() so that it
+ simply returns its input keyword arguments. The qnode_weights dictionary should then have
+ values that are the input keyword arguments, and we check that the specified weight_specs
+ keywords are there."""
+
+ def add_weight_dummy(*args, **kwargs):
+ """Dummy function for mocking out the add_weight method to simply return the input
+ keyword arguments"""
+ return kwargs
+
+ weight_specs = {
+ "w1": {"initializer": "random_uniform", "trainable": False},
+ "w2": {"initializer": tf.keras.initializers.RandomNormal(mean=0, stddev=0.5)},
+ "w3": {},
+ "w4": {},
+ "w5": {},
+ "w6": {},
+ "w7": {},
+ }
+
+ with monkeypatch.context() as m:
+ m.setattr(tf.keras.layers.Layer, "add_weight", add_weight_dummy)
+ c, w = get_circuit
+ layer = KerasLayer(c, w, output_dim, weight_specs=weight_specs)
+ layer.build(input_shape=(10, n_qubits))
+
+ for weight in layer.weight_shapes:
+ assert all(
+ item in layer.qnode_weights[weight].items()
+ for item in weight_specs[weight].items()
+ )
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(3))
+ @pytest.mark.parametrize("input_shape", [(10, 4), (8, 3)])
+ def test_compute_output_shape(self, get_circuit, output_dim, input_shape):
+ """Test if the compute_output_shape() method performs correctly, i.e., that it replaces
+ the last element in the input_shape tuple with the specified output_dim and that the
+ output shape is of type tf.TensorShape"""
+ c, w = get_circuit
+ layer = KerasLayer(c, w, output_dim)
+
+ assert layer.compute_output_shape(input_shape) == (input_shape[0], output_dim)
+ assert isinstance(layer.compute_output_shape(input_shape), tf.TensorShape)
+
+ @pytest.mark.parametrize("interface", qml.qnodes.decorator.ALLOWED_INTERFACES)
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(4))
+ @pytest.mark.parametrize("batch_size", [5, 10, 15])
+ def test_call(self, get_circuit, output_dim, batch_size, n_qubits):
+ """Test if the call() method performs correctly, i.e., that it outputs with shape
+ (batch_size, output_dim) with results that agree with directly calling the QNode"""
+ c, w = get_circuit
+ layer = KerasLayer(c, w, output_dim)
+ x = np.ones((batch_size, n_qubits), dtype=np.float32)
+
+ layer_out = layer(x)
+ weights = [w.numpy() for w in layer.qnode_weights.values()]
+ assert layer_out.shape == (batch_size, output_dim)
+ assert np.allclose(layer_out[0], c(x[0], *weights))
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(1))
+ @pytest.mark.parametrize("batch_size", [5])
+ def test_call_shuffled_args(self, get_circuit, output_dim, batch_size, n_qubits):
+ """Test if the call() method performs correctly when the inputs argument is not the first
+ positional argument, i.e., that it outputs with shape (batch_size, output_dim) with
+ results that agree with directly calling the QNode"""
+ c, w = get_circuit
+
+ @qml.qnode(qml.device("default.qubit", wires=n_qubits), interface="tf")
+ def c_shuffled(w1, inputs, w2, w3, w4, w5, w6, w7):
+ """Version of the circuit with a shuffled signature"""
+ qml.templates.AngleEmbedding(inputs, wires=list(range(n_qubits)))
+ qml.templates.StronglyEntanglingLayers(w1, wires=list(range(n_qubits)))
+ qml.RX(w2[0], wires=0)
+ qml.RX(w3, wires=0)
+ qml.Rot(*w4, wires=0)
+ qml.templates.StronglyEntanglingLayers(w5, wires=list(range(n_qubits)))
+ qml.Rot(*w6, wires=0)
+ qml.RX(w7, wires=0)
+ return [qml.expval(qml.PauliZ(i)) for i in range(output_dim)]
+
+ layer = KerasLayer(c_shuffled, w, output_dim)
+ x = tf.ones((batch_size, n_qubits))
+
+ layer_out = layer(x)
+ weights = [w.numpy() for w in layer.qnode_weights.values()]
+
+ assert layer_out.shape == (batch_size, output_dim)
+ assert np.allclose(layer_out[0], c(x[0], *weights))
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(1))
+ @pytest.mark.parametrize("batch_size", [5])
+ def test_call_default_input(self, get_circuit, output_dim, batch_size, n_qubits):
+ """Test if the call() method performs correctly when the inputs argument is a default
+ argument, i.e., that it outputs with shape (batch_size, output_dim) with results that
+ agree with directly calling the QNode"""
+ c, w = get_circuit
+
+ @qml.qnode(qml.device("default.qubit", wires=n_qubits), interface="tf")
+ def c_default(w1, w2, w3, w4, w5, w6, w7, inputs=None):
+ """Version of the circuit with inputs as a default argument"""
+ qml.templates.AngleEmbedding(inputs, wires=list(range(n_qubits)))
+ qml.templates.StronglyEntanglingLayers(w1, wires=list(range(n_qubits)))
+ qml.RX(w2[0], wires=0)
+ qml.RX(w3, wires=0)
+ qml.Rot(*w4, wires=0)
+ qml.templates.StronglyEntanglingLayers(w5, wires=list(range(n_qubits)))
+ qml.Rot(*w6, wires=0)
+ qml.RX(w7, wires=0)
+ return [qml.expval(qml.PauliZ(i)) for i in range(output_dim)]
+
+ layer = KerasLayer(c_default, w, output_dim)
+ x = tf.ones((batch_size, n_qubits))
+
+ layer_out = layer(x)
+ weights = [w.numpy() for w in layer.qnode_weights.values()]
+
+ assert layer_out.shape == (batch_size, output_dim)
+ assert np.allclose(layer_out[0], c(x[0], *weights))
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(1))
+ def test_str_repr(self, get_circuit, output_dim):
+ """Test the __str__ and __repr__ representations"""
+ c, w = get_circuit
+ layer = KerasLayer(c, w, output_dim)
+
+ assert layer.__str__() == ""
+ assert layer.__repr__() == ""
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(1))
+ def test_gradients(self, get_circuit, output_dim, n_qubits):
+ """Test if the gradients of the KerasLayer are equal to the gradients of the circuit when
+ taken with respect to the trainable variables"""
+ c, w = get_circuit
+ layer = KerasLayer(c, w, output_dim)
+ x = tf.ones((1, n_qubits))
+
+ with tf.GradientTape() as tape:
+ out_layer = layer(x)
+
+ g_layer = tape.gradient(out_layer, layer.trainable_variables)
+
+ with tf.GradientTape() as tape:
+ out_circuit = c(x[0], *layer.trainable_variables)
+
+ g_circuit = tape.gradient(out_circuit, layer.trainable_variables)
+
+ for i in range(len(out_layer)):
+ assert np.allclose(g_layer[i], g_circuit[i])
+
+
+@pytest.mark.usefixtures("get_circuit", "model")
+class TestKerasLayerIntegration:
+ """Integration tests for the pennylane.qnn.keras.KerasLayer class."""
+
+ @pytest.mark.parametrize("interface", qml.qnodes.decorator.ALLOWED_INTERFACES)
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(2))
+ @pytest.mark.parametrize("batch_size", [5, 10])
+ def test_train_model(self, model, batch_size, n_qubits, output_dim):
+ """Test if a model can train using the KerasLayer. The model is composed of a single
+ KerasLayer sandwiched between two Dense layers, and the dataset is simply input and output
+ vectors of zeros."""
+
+ x = np.zeros((5, n_qubits))
+ y = np.zeros((5, output_dim))
+
+ model.compile(optimizer="sgd", loss="mse")
+
+ model.fit(x, y, batch_size=batch_size, verbose=0)
+
+ @pytest.mark.parametrize("interface", qml.qnodes.decorator.ALLOWED_INTERFACES)
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(2))
+ def test_model_gradients(self, model, output_dim, n_qubits):
+ """Test if a gradient can be calculated with respect to all of the trainable variables in
+ the model"""
+ x = tf.zeros((5, n_qubits))
+ y = tf.zeros((5, output_dim))
+
+ with tf.GradientTape() as tape:
+ out = model(x)
+ loss = tf.keras.losses.mean_squared_error(out, y)
+
+ gradients = tape.gradient(loss, model.trainable_variables)
+ assert all([g.dtype == tf.keras.backend.floatx() for g in gradients])
+
+ @pytest.mark.parametrize("interface", ["tf"])
+ @pytest.mark.parametrize("n_qubits, output_dim", indicies_up_to(2))
+ def test_model_save_weights(self, model, n_qubits, tmpdir):
+ """Test if the model can be successfully saved and reloaded using the get_weights()
+ method"""
+ prediction = model.predict(np.ones(n_qubits))
+ weights = model.get_weights()
+ file = str(tmpdir) + "/model"
+ model.save_weights(file)
+ model.load_weights(file)
+ prediction_loaded = model.predict(np.ones(n_qubits))
+ weights_loaded = model.get_weights()
+
+ assert np.allclose(prediction, prediction_loaded)
+ for i, w in enumerate(weights):
+ assert np.allclose(w, weights_loaded[i])