Repository: PennyLaneAI/pennylane

Issue:
[BUG] Loading hybrid quantum neuronal networks does not work with TensorFlow
### Expected behavior

When using the tensorflow function `model.save` und `tf.keras.models.load_model` it is expected that the model load all layers and its ways and recreates the saved model to include all weights etc.


### Actual behavior

When using  `model.save` and `tf.keras.models.load_model` with a hybrid model including both classical layers and `qml.qnn.KerasLayerl` the network including the quantum layer is reloaded without error, however the weights of the quantum layer are not. 
This issue exists independent of the order of quantum and classical layers.

### Additional information

This issue was discussed in the pennylane support threat 
https://discuss.pennylane.ai/t/error-reloading-circuit-from-qasm-string/3679
This threat already gave rise to issue https://github.com/PennyLaneAI/pennylane/issues/4856 to which this issue is possibly related

### Source code

```shell
import tensorflow as tf
import pennylane as qml
import numpy as np

dev = qml.device("default.qubit")
n_qubits = 2

def create_model():
    @qml.qnode(dev)
    def circuit(inputs, weights):
        qml.AngleEmbedding(inputs, wires=range(n_qubits))
        qml.RX(weights[0], 0)
        qml.RX(weights[1], 1)
        return [qml.expval(qml.PauliZ(0)), qml.expval(qml.PauliZ(1))]

    weight_shapes = {"weights": (n_qubits,)}
    quantum_layer = qml.qnn.KerasLayer(circuit, weight_shapes, output_dim=2)

    model = tf.keras.models.Sequential()
    model.add(quantum_layer)
    model.add(tf.keras.layers.Dense(2, activation=tf.nn.softmax, input_shape=(2,)))

    model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'])
    return model

model = create_model()

num_points = 5
dummy_input_data = np.random.uniform(0, np.pi, size=(num_points, 2))
dummy_output_data = np.random.randint(2, size=(num_points, 2))

model.fit(dummy_input_data, dummy_output_data, epochs=1, batch_size=0)

model.save("model")

loaded_model = tf.keras.models.load_model("model")

print("")
print("saved weights:", model.layers[0].weights)
print("")
print("Loaded weights:", loaded_model.layers[0].weights)
```


### Tracebacks

```shell
WARNING:tensorflow:You are casting an input of type complex128 to an incompatible dtype float32.  This will discard the imaginary part and may not be what you intended.
WARNING:tensorflow:You are casting an input of type complex128 to an incompatible dtype float32.  This will discard the imaginary part and may not be what you intended.
WARNING:tensorflow:You are casting an input of type complex128 to an incompatible dtype float32.  This will discard the imaginary part and may not be what you intended.
WARNING:tensorflow:You are casting an input of type complex128 to an incompatible dtype float32.  This will discard the imaginary part and may not be what you intended.
1/1 [==============================] - 0s 349ms/step - loss: 0.5533 - accuracy: 0.8000
WARNING:tensorflow:AutoGraph could not transform <function validate_device_wires at 0x0000027642280F70> and will run it as-is.
Please report this to the TensorFlow team. When filing the bug, set the verbosity to 10 (on Linux, `export AUTOGRAPH_VERBOSITY=10`) and attach the full output.
Cause: 'NoneType' object has no attribute '_fields'
To silence this warning, decorate the function with @tf.autograph.experimental.do_not_convert
WARNING: AutoGraph could not transform <function validate_device_wires at 0x0000027642280F70> and will run it as-is.
Please report this to the TensorFlow team. When filing the bug, set the verbosity to 10 (on Linux, `export AUTOGRAPH_VERBOSITY=10`) and attach the full output.
Cause: 'NoneType' object has no attribute '_fields'
To silence this warning, decorate the function with @tf.autograph.experimental.do_not_convert
WARNING:tensorflow:AutoGraph could not transform <function _gcd_import at 0x000002762FE83490> and will run it as-is.
Cause: Unable to locate the source code of <function _gcd_import at 0x000002762FE83490>. Note that functions defined in certain environments, like the interactive Python shell, do not expose their source code. If that is the case, you should define them in a .py source file. If you are certain the code is graph-compatible, wrap the call using @tf.autograph.experimental.do_not_convert. Original error: could not get source code
To silence this warning, decorate the function with @tf.autograph.experimental.do_not_convert
WARNING: AutoGraph could not transform <function _gcd_import at 0x000002762FE83490> and will run it as-is.
Cause: Unable to locate the source code of <function _gcd_import at 0x000002762FE83490>. Note that functions defined in certain environments, like the interactive Python shell, do not expose their source code. If that is the case, you should define them in a .py source file. If you are certain the code is graph-compatible, wrap the call using @tf.autograph.experimental.do_not_convert. Original error: could not get source code
To silence this warning, decorate the function with @tf.autograph.experimental.do_not_convert
INFO:tensorflow:Assets written to: model\assets
INFO:tensorflow:Assets written to: model\assets

saved weights: [<tf.Variable 'weights:0' shape=(2,) dtype=float32, numpy=array([ 0.49809802, -0.9938641 ], dtype=float32)>]

Loaded weights: []
```


### System information

```shell
Platform info:           Windows-10-10.0.19045-SP0
Python version:          3.10.12
Numpy version:           1.26.0
Scipy version:           1.11.2
Installed devices:
- default.gaussian (PennyLane-0.33.1)
- default.mixed (PennyLane-0.33.1)
- default.qubit (PennyLane-0.33.1)
- default.qubit.autograd (PennyLane-0.33.1)
- default.qubit.jax (PennyLane-0.33.1)
- default.qubit.legacy (PennyLane-0.33.1)
- default.qubit.tf (PennyLane-0.33.1)
- default.qubit.torch (PennyLane-0.33.1)
- default.qutrit (PennyLane-0.33.1)
- null.qubit (PennyLane-0.33.1)
- lightning.qubit (PennyLane-Lightning-0.33.1)
- strawberryfields.fock (PennyLane-SF-0.29.0)
- strawberryfields.gaussian (PennyLane-SF-0.29.0)
- strawberryfields.gbs (PennyLane-SF-0.29.0)
- strawberryfields.remote (PennyLane-SF-0.29.0)
- strawberryfields.tf (PennyLane-SF-0.29.0)
- qiskit.aer (PennyLane-qiskit-0.32.0)
- qiskit.basicaer (PennyLane-qiskit-0.32.0)
- qiskit.ibmq (PennyLane-qiskit-0.32.0)
- qiskit.ibmq.circuit_runner (PennyLane-qiskit-0.32.0)
- qiskit.ibmq.sampler (PennyLane-qiskit-0.32.0)
- qiskit.remote (PennyLane-qiskit-0.32.0)

The issue is also visible in the Tracebacks displayed in https://github.com/PennyLaneAI/pennylane/issues/4856 and therefore also reproducible the system configuration displayed there.
```


### Existing GitHub issues

- [X] I have searched existing GitHub issues to make sure the issue does not already exist.


Discussion:

Resolving patch:
diff --git a/pennylane/qnn/keras.py b/pennylane/qnn/keras.py
--- a/pennylane/qnn/keras.py
+++ b/pennylane/qnn/keras.py
@@ -384,6 +384,7 @@ def call(self, inputs):
 
         # reshape to the correct number of batch dims
         if has_batch_dim:
+            # pylint:disable=unexpected-keyword-arg,no-value-for-parameter
             new_shape = tf.concat([batch_dims, tf.shape(results)[1:]], axis=0)
             results = tf.reshape(results, new_shape)
 
@@ -438,17 +439,14 @@ def __getattr__(self, item):
         if self._initialized and hasattr(self.qnode, item):
             return getattr(self.qnode, item)
 
-        try:
-            return self.__dict__[item]
-        except KeyError as exc:
-            raise AttributeError(item) from exc
+        return super().__getattr__(item)
 
     def __setattr__(self, item, val):
         """If the given attribute does not exist in the class, try to set it in the wrapped QNode."""
         if self._initialized and hasattr(self.qnode, item):
             setattr(self.qnode, item, val)
         else:
-            self.__dict__[item] = val
+            super().__setattr__(item, val)
 
     def compute_output_shape(self, input_shape):
         """Computes the output shape after passing data of shape ``input_shape`` through the


Test patch:
diff --git a/tests/qnn/test_keras.py b/tests/qnn/test_keras.py
--- a/tests/qnn/test_keras.py
+++ b/tests/qnn/test_keras.py
@@ -920,3 +920,40 @@ def circuit(inputs, w1, w2):
     assert info["num_trainable_params"] == 2
     assert info["interface"] == "tf"
     assert info["device_name"] == "default.qubit"
+
+
+def test_save_and_load_preserves_weights(tmpdir):
+    """
+    Test that saving and loading a model doesn't lose the weights. This particular
+    test is important aside from `test_save_whole_model` because a bug caused issues
+    when the name of (at least one of) your weights is 'weights'.
+    """
+
+    dev = qml.device("default.qubit")
+    n_qubits = 2
+
+    @qml.qnode(dev)
+    def circuit(inputs, weights):
+        qml.AngleEmbedding(inputs, wires=range(n_qubits))
+        qml.RX(weights[0], 0)
+        qml.RX(weights[1], 1)
+        return [qml.expval(qml.PauliZ(0)), qml.expval(qml.PauliZ(1))]
+
+    weight_shapes = {"weights": (n_qubits,)}
+    quantum_layer = qml.qnn.KerasLayer(circuit, weight_shapes, output_dim=2)
+
+    model0 = tf.keras.models.Sequential()
+    model0.add(quantum_layer)
+    model0.add(tf.keras.layers.Dense(2, activation=tf.nn.softmax, input_shape=(2,)))
+
+    model0.compile(optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"])
+
+    num_points = 5
+    dummy_input_data = np.random.uniform(0, np.pi, size=(num_points, 2))
+    dummy_output_data = np.random.randint(2, size=(num_points, 2))
+
+    model0.fit(dummy_input_data, dummy_output_data, epochs=1, batch_size=0)
+    file = str(tmpdir) + "/model"
+    model0.save(file)
+    loaded_model = tf.keras.models.load_model(file)
+    assert np.array_equal(model0.layers[0].weights, loaded_model.layers[0].weights)