Repository: PennyLaneAI/pennylane

Issue:
More informative QNode `diff_method`
Currently, QNode's have an attribute `diff_method` that captures the user-provided `diff_method` keyword or the default `"best"`.  

If `diff_method=="best"`, then the class will determine which differentiation method to use based on a combination of various things like device and interface.

But the attribute `diff_method` is not updated with this choice. To find which method actually got used, we need to query `qnode.qtape.jacobian_options['method']`, which is unnecessarily complicated for a fairly central piece of information.

Example:

```python
dev = qml.device('default.qubit', wires=2)

@qml.qnode(dev)
def circ(x):
    qml.RX(x, wires=0)
    return qml.expval(qml.PauliZ(0))

print(circ.diff_method)
circ(0.1)
print(circ.qtape.jacobian_options['method'] )
```

```
best
backprop
```

I propose we update `QNode.diff_method` once a differentiation method is chosen. 

----

*List of tasks for completion:*

1. Update the logic of the QNode such that the `diff_method` attribute is updated after tape construction, once the `QNode.qtape.jacobian_options['method']` information is available;
2. Add unit and integration tests that test the new functionality and check multiple differentiation methods.


Discussion:
Great idea, this will also help a lot with testing and debugging
Hi! Can I work on this issue? :)
Hi @nahumsa, sure! :slightly_smiling_face: It looks that @ankit27kh was also giving this a go. If multiple PRs are submitted, then we accept the first that is merge-ready. Alternatively, you could also submit a solution as a team. Let us know if you have any questions!

Resolving patch:
diff --git a/pennylane/qnn/keras.py b/pennylane/qnn/keras.py
--- a/pennylane/qnn/keras.py
+++ b/pennylane/qnn/keras.py
@@ -216,7 +216,7 @@ def __init__(
 
         dtype = tf.float32 if tf.keras.backend.floatx() == tf.float32 else tf.float64
 
-        if self.qnode.diff_method != "backprop":
+        if self.qnode.diff_method != "backprop" or self.qnode.diff_method_change:
             self.qnode.to_tf(dtype=dtype)
 
         # Allows output_dim to be specified as an int or as a tuple, e.g, 5, (5,), (5, 2), [5, 2]
diff --git a/pennylane/qnode.py b/pennylane/qnode.py
--- a/pennylane/qnode.py
+++ b/pennylane/qnode.py
@@ -193,10 +193,15 @@ def __init__(
         self.qfunc_output = None
         # store the user-specified differentiation method
         self.diff_method = diff_method
+        self.diff_method_change = False
 
         self._tape, self.interface, self.device, tape_diff_options = self.get_tape(
             device, interface, diff_method
         )
+        # if diff_method is best, then set it to the actual diff method being used
+        if self.diff_method == "best":
+            self.diff_method_change = True
+            self.diff_method = self._get_best_diff_method(tape_diff_options)
 
         # The arguments to be passed to JacobianTape.jacobian
         self.diff_options = diff_options or {}
@@ -215,6 +220,19 @@ def __repr__(self):
             self.diff_method,
         )
 
+    @staticmethod
+    def _get_best_diff_method(tape_diff_options):
+        """Update diff_method to reflect which method has been selected"""
+        if tape_diff_options["method"] == "device":
+            method = "device"
+        elif tape_diff_options["method"] == "backprop":
+            method = "backprop"
+        elif tape_diff_options["method"] == "best":
+            method = "parameter-shift"
+        elif tape_diff_options["method"] == "numeric":
+            method = "finite-diff"
+        return method
+
     # pylint: disable=too-many-return-statements
     @staticmethod
     def get_tape(device, interface, diff_method="best"):
@@ -634,6 +652,12 @@ def __call__(self, *args, **kwargs):
         else:
             res = self.qtape.execute(device=self.device)
 
+        finite_diff = any(
+            getattr(x["op"], "grad_method", None) == "F" for x in self.qtape._par_info.values()
+        )
+        if finite_diff and self.diff_method_change:
+            self.diff_method = "finite-diff"
+
         # if shots was changed
         if original_shots != -1:
             # reinstate default on device
@@ -815,12 +839,7 @@ def circuit(x):
 
         info["num_device_wires"] = self.device.num_wires
         info["device_name"] = self.device.short_name
-
-        # TODO: use self.diff_method when that value gets updated
-        if self.diff_method != "best":
-            info["diff_method"] = self.diff_method
-        else:
-            info["diff_method"] = self.qtape.jacobian_options["method"]
+        info["diff_method"] = self.diff_method
 
         # tapes do not accurately track parameters for backprop
         # TODO: calculate number of trainable parameters in backprop
@@ -847,10 +866,16 @@ def to_tf(self, dtype=None):
 
             if self.interface != "tf" and self.interface is not None:
                 # Since the interface is changing, need to re-validate the tape class.
+                # if method was changed from "best", set it back to best
+                if self.diff_method_change:
+                    diff_method = "best"
+                else:
+                    diff_method = self.diff_method
                 self._tape, interface, self.device, diff_options = self.get_tape(
-                    self._original_device, "tf", self.diff_method
+                    self._original_device, "tf", diff_method
                 )
-
+                if self.diff_method_change:
+                    self.diff_method = self._get_best_diff_method(diff_options)
                 self.interface = interface
                 self.diff_options.update(diff_options)
             else:
@@ -887,10 +912,16 @@ def to_torch(self, dtype=None):
 
             if self.interface != "torch" and self.interface is not None:
                 # Since the interface is changing, need to re-validate the tape class.
+                # if method was changed from "best", set it back to best
+                if self.diff_method_change:
+                    diff_method = "best"
+                else:
+                    diff_method = self.diff_method
                 self._tape, interface, self.device, diff_options = self.get_tape(
-                    self._original_device, "torch", self.diff_method
+                    self._original_device, "torch", diff_method
                 )
-
+                if self.diff_method_change:
+                    self.diff_method = self._get_best_diff_method(diff_options)
                 self.interface = interface
                 self.diff_options.update(diff_options)
             else:
@@ -919,10 +950,16 @@ def to_autograd(self):
 
         if self.interface != "autograd" and self.interface is not None:
             # Since the interface is changing, need to re-validate the tape class.
+            # if method was changed from "best", set it back to best
+            if self.diff_method_change:
+                diff_method = "best"
+            else:
+                diff_method = self.diff_method
             self._tape, interface, self.device, diff_options = self.get_tape(
-                self._original_device, "autograd", self.diff_method
+                self._original_device, "autograd", diff_method
             )
-
+            if self.diff_method_change:
+                self.diff_method = self._get_best_diff_method(diff_options)
             self.interface = interface
             self.diff_options.update(diff_options)
         else:
@@ -947,10 +984,16 @@ def to_jax(self):
 
             if self.interface != "jax" and self.interface is not None:
                 # Since the interface is changing, need to re-validate the tape class.
+                # if method was changed from "best", set it back to best
+                if self.diff_method_change:
+                    diff_method = "best"
+                else:
+                    diff_method = self.diff_method
                 self._tape, interface, self.device, diff_options = self.get_tape(
-                    self._original_device, "jax", self.diff_method
+                    self._original_device, "jax", diff_method
                 )
-
+                if self.diff_method_change:
+                    self.diff_method = self._get_best_diff_method(diff_options)
                 self.interface = interface
                 self.diff_options.update(diff_options)
             else:


Test patch:
diff --git a/tests/tape/test_qnode.py b/tests/tape/test_qnode.py
--- a/tests/tape/test_qnode.py
+++ b/tests/tape/test_qnode.py
@@ -323,12 +323,217 @@ def func(x):
             qml.RX(x, wires=0)
             return qml.expval(qml.PauliZ(0))
 
-        qn = qml.QNode(func, dev)
+        qn = qml.QNode(func, dev, diff_method="finite-diff")
 
         assert (
             qn.__repr__()
-            == "<QNode: wires=1, device='default.qubit.autograd', interface='autograd', diff_method='best'>"
+            == "<QNode: wires=1, device='default.qubit', interface='autograd', diff_method='finite-diff'>"
         )
+        assert qn.diff_method_change == False
+
+    def test_qnode_best_diff_method_backprop(self):
+        """Test that selected "best" diff_method is correctly set to 'backprop'."""
+        dev = qml.device("default.qubit", wires=1)
+
+        def func(x):
+            qml.RX(x, wires=0)
+            return qml.expval(qml.PauliZ(0))
+
+        qn = qml.QNode(func, dev)
+
+        assert qn.diff_method == "backprop"
+        assert qn.diff_method_change
+
+    def test_qnode_best_diff_method_parameter_shift(self):
+        """Test that selected "best" diff_method is correctly set to 'parameter-shift'."""
+        dev = qml.device("default.mixed", wires=1)
+
+        def func(x):
+            qml.RX(x, wires=0)
+            return qml.expval(qml.PauliZ(0))
+
+        qn = qml.QNode(func, dev)
+
+        assert qn.diff_method == "parameter-shift"
+        assert qn.diff_method_change
+
+    def test_qnode_best_diff_method_device(self, monkeypatch):
+        """Test that selected "best" diff_method is correctly set to 'device'."""
+        dev = qml.device("default.qubit", wires=1)
+
+        def func(x):
+            qml.RX(x, wires=0)
+            return qml.expval(qml.PauliZ(0))
+
+        # Force the "best" method to be "device"
+        monkeypatch.setitem(dev._capabilities, "passthru_interface", "some_interface")
+        monkeypatch.setitem(dev._capabilities, "provides_jacobian", True)
+        qn = qml.QNode(func, dev)
+        assert qn.diff_method == "device"
+        assert qn.diff_method_change
+
+    def test_qnode_best_diff_method_finite_diff(self, monkeypatch):
+        """Test that selected "best" diff_method is correctly set to 'finite-diff'."""
+        dev = qml.device("default.qubit", wires=1)
+
+        def func(x):
+            qml.RX(x, wires=0)
+            return qml.expval(qml.PauliZ(0))
+
+        def capabilities(cls):
+            capabilities = cls._capabilities
+            capabilities.update(model="None")
+            return capabilities
+
+        # Force the "best" method to be "finite-diff"
+        monkeypatch.setitem(dev._capabilities, "provides_jacobian", False)
+        monkeypatch.setattr(qml.devices.DefaultQubit, "capabilities", capabilities)
+        qn = qml.QNode(func, dev)
+        assert qn.diff_method == "finite-diff"
+        assert qn.diff_method_change
+
+    def test_qnode_best_diff_method_finite_fallback(self):
+        """Test that selected "best" diff_method is correctly set to 'finite-diff'
+        in cases where other methods are not available."""
+
+        # Custom operation which has grad_method="finite_diff"
+        class MyRX(qml.operation.Operation):
+            num_params = 1
+            num_wires = 1
+            par_domain = "R"
+            is_composable_rotation = True
+            basis = "X"
+            grad_method = "F"
+
+            @classmethod
+            def _matrix(cls, *params):
+                return qml.RX._matrix(*params)
+
+        dev = qml.device("default.mixed", wires=3, shots=None)
+        dev.operations.add("MyRX")
+
+        def circuit(x):
+            MyRX(x, wires=1)
+            return qml.expval(qml.PauliZ(1))
+
+        qnode = qml.QNode(circuit, dev, diff_method="best")
+
+        # Before execution correctly show 'parameter-shift'
+        assert qnode.diff_method == "parameter-shift"
+
+        par = qml.numpy.array(0.3)
+        qml.grad(qnode)(par)
+
+        # After execution correctly show 'finite-diff'
+        assert qnode.diff_method == "finite-diff"
+
+    @pytest.mark.parametrize(
+        "method",
+        [
+            "best",
+            "parameter-shift",
+            "finite-diff",
+            "reversible",
+            "adjoint",
+            "backprop",
+        ],
+    )
+    def test_to_tf(self, method, mocker):
+        """Test if interface change is working"""
+        tf = pytest.importorskip("tensorflow")
+        dev = qml.device("default.qubit", wires=1)
+
+        def func(x):
+            qml.RX(x, wires=0)
+            return qml.expval(qml.PauliZ(0))
+
+        # Test if interface change works with different diff_methods
+        qn = qml.QNode(func, dev, interface="autograd", diff_method=method)
+        spy = mocker.spy(qn, "_get_best_diff_method")
+        qn.to_tf()
+        if method == "best":
+            spy.assert_called_once()
+
+    @pytest.mark.parametrize(
+        "method",
+        [
+            "best",
+            "parameter-shift",
+            "finite-diff",
+            "reversible",
+            "adjoint",
+            "backprop",
+        ],
+    )
+    def test_to_autograd(self, method, mocker):
+        """Test if interface change is working"""
+        dev = qml.device("default.qubit", wires=1)
+        tf = pytest.importorskip("tensorflow")
+
+        def func(x):
+            qml.RX(x, wires=0)
+            return qml.expval(qml.PauliZ(0))
+
+        # Test if interface change works with different diff_methods
+        qn = qml.QNode(func, dev, interface="tf", diff_method=method)
+        spy = mocker.spy(qn, "_get_best_diff_method")
+        qn.to_autograd()
+        if method == "best":
+            spy.assert_called_once()
+
+    @pytest.mark.parametrize(
+        "method",
+        [
+            "best",
+            "parameter-shift",
+            "finite-diff",
+            "reversible",
+            "adjoint",
+            "backprop",
+        ],
+    )
+    def test_to_torch(self, method, mocker):
+        """Test if interface change is working"""
+        dev = qml.device("default.qubit", wires=1)
+        torch = pytest.importorskip("torch")
+
+        def func(x):
+            qml.RX(x, wires=0)
+            return qml.expval(qml.PauliZ(0))
+
+        # Test if interface change works with different diff_methods
+        qn = qml.QNode(func, dev, interface="autograd", diff_method=method)
+        spy = mocker.spy(qn, "_get_best_diff_method")
+        qn.to_torch()
+        if method == "best":
+            spy.assert_called_once()
+
+    @pytest.mark.parametrize(
+        "method",
+        [
+            "best",
+            "parameter-shift",
+            "finite-diff",
+            "reversible",
+            "adjoint",
+            "backprop",
+        ],
+    )
+    def test_to_jax(self, method, mocker):
+        """Test if interface change is working"""
+        dev = qml.device("default.qubit", wires=1)
+        jax = pytest.importorskip("jax")
+
+        def func(x):
+            qml.RX(x, wires=0)
+            return qml.expval(qml.PauliZ(0))
+
+        # Test if interface change works with different diff_methods
+        qn = qml.QNode(func, dev, interface="autograd", diff_method=method)
+        spy = mocker.spy(qn, "_get_best_diff_method")
+        qn.to_jax()
+        if method == "best":
+            spy.assert_called_once()
 
     @pytest.mark.parametrize("par", [None, 1, 1.1, np.array(1.2)])
     def test_diff_method_none(self, par):