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])