Repository: PennyLaneAI/pennylane
Pull request #6770 (2025-01-07 00:11:02)

Add error if differentiating diff_method=None
**Context:**

If someone tries to differentiate a qnode that has `diff_method=None`, they may get less-than-useful error message.  We also assume that no interface parameters are used with `diff_method=None`

**Description of the Change:**

Uses the ML boundary when `diff_method=None` and raises an informative error if differentiation is requested.

**Benefits:**

ML parameters can be used with `diff_method=None`, and incorrect usage provides good error messages.

**Possible Drawbacks:**

**Related GitHub Issues:**

Solves #6769 [sc-81490]

PietropaoloFrisoni: @albi3ro Should this be part of the release?
albi3ro: > @albi3ro Should this be part of the release?

Nope. It's changing architectural components and core behavior.
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/6770?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 99.60%. Comparing base [(`0bed5e8`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/0bed5e81a8a8a15dbb74ce2051e54e6ba19e8602?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) to head [(`ebdf867`)](https://app.codecov.io/gh/PennyLaneAI/pennylane/commit/ebdf86769f345556aaee67e6662eef405c92baae?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #6770   +/-   ##
=======================================
  Coverage   99.60%   99.60%           
=======================================
  Files         476      476           
  Lines       45178    45189   +11     
=======================================
+ Hits        44999    45010   +11     
  Misses        179      179           
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/PennyLaneAI/pennylane/pull/6770?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI).


Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 4399efce5..963683936 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -15,6 +15,9 @@
 * The coefficients of observables now have improved differentiability.
   [(#6598)](https://github.com/PennyLaneAI/pennylane/pull/6598)
 
+* An informative error is raised when a `QNode` with `diff_method=None` is differentiated.
+  [(#6770)](https://github.com/PennyLaneAI/pennylane/pull/6770)
+
 <h3>Breaking changes 💔</h3>
 
 * The ``tape`` and ``qtape`` properties of ``QNode`` have been removed. 
diff --git a/pennylane/workflow/jacobian_products.py b/pennylane/workflow/jacobian_products.py
index 218b24d05..e42f3dea6 100644
--- a/pennylane/workflow/jacobian_products.py
+++ b/pennylane/workflow/jacobian_products.py
@@ -210,6 +210,26 @@ class JacobianProductCalculator(abc.ABC):
         """
 
 
+class NoGradients(JacobianProductCalculator):
+    """A jacobian product calculator that raises errors when a vjp or jvp is requested."""
+
+    error_msg = "Derivatives cannot be calculated with diff_method=None"
+
+    def compute_jacobian(self, tapes: QuantumScriptBatch) -> tuple:
+        raise qml.QuantumFunctionError(NoGradients.error_msg)
+
+    def compute_vjp(self, tapes: QuantumScriptBatch, dy: Sequence[Sequence[TensorLike]]) -> tuple:
+        raise qml.QuantumFunctionError(NoGradients.error_msg)
+
+    def execute_and_compute_jvp(
+        self, tapes: QuantumScriptBatch, tangents: Sequence[Sequence[TensorLike]]
+    ) -> tuple[ResultBatch, tuple]:
+        raise qml.QuantumFunctionError(NoGradients.error_msg)
+
+    def execute_and_compute_jacobian(self, tapes: QuantumScriptBatch) -> tuple[ResultBatch, tuple]:
+        raise qml.QuantumFunctionError(NoGradients.error_msg)
+
+
 class TransformJacobianProducts(JacobianProductCalculator):
     """Compute VJPs, JVPs and Jacobians via a gradient transform :class:`~.TransformDispatcher`.
 
diff --git a/pennylane/workflow/qnode.py b/pennylane/workflow/qnode.py
index 93a9d4087..1db50d2d0 100644
--- a/pennylane/workflow/qnode.py
+++ b/pennylane/workflow/qnode.py
@@ -550,9 +550,7 @@ class QNode:
         # input arguments
         self.func = func
         self.device = device
-        self._interface = (
-            Interface.NUMPY if diff_method is None else get_canonical_interface_name(interface)
-        )
+        self._interface = get_canonical_interface_name(interface)
         self.diff_method = diff_method
         mcm_config = qml.devices.MCMConfig(mcm_method=mcm_method, postselect_mode=postselect_mode)
         cache = (max_diff > 1) if cache == "auto" else cache
diff --git a/pennylane/workflow/run.py b/pennylane/workflow/run.py
index 9f1096ab3..b3fcf123e 100644
--- a/pennylane/workflow/run.py
+++ b/pennylane/workflow/run.py
@@ -30,6 +30,7 @@ from .jacobian_products import (
     DeviceDerivatives,
     DeviceJacobianProducts,
     JacobianProductCalculator,
+    NoGradients,
     TransformJacobianProducts,
 )
 
@@ -128,6 +129,9 @@ def _construct_ml_execution_pipeline(
 
     execute_fn = inner_execute
 
+    if config.gradient_method is None:
+        return NoGradients(), execute_fn
+
     if config.use_device_jacobian_product:
         return DeviceJacobianProducts(device, config), execute_fn
 
@@ -276,12 +280,7 @@ def run(
 
     # Exiting early if we do not need to deal with an interface boundary
     no_interface_boundary_required = (
-        config.interface == Interface.NUMPY
-        or config.gradient_method
-        in {
-            None,
-            "backprop",
-        }
+        config.interface == Interface.NUMPY or config.gradient_method == "backprop"
     )
     if no_interface_boundary_required:
         results = inner_execute(tapes)


Test patch:
diff --git a/tests/gradients/core/test_pulse_gradient.py b/tests/gradients/core/test_pulse_gradient.py
index d0aba1f45..c684d53b0 100644
--- a/tests/gradients/core/test_pulse_gradient.py
+++ b/tests/gradients/core/test_pulse_gradient.py
@@ -896,13 +896,15 @@ class TestStochPulseGrad:
         dev = qml.device("default.qubit", wires=1)
         # Effective rotation parameter
         p = params[0] * delta_t
-        r = qml.execute([tape], dev, None)
-        assert qml.math.isclose(r, jnp.cos(2 * p), atol=1e-4)
         tapes, fn = stoch_pulse_grad(tape, num_split_times=num_split_times)
         assert len(tapes) == num_split_times * 2
 
         res = fn(qml.execute(tapes, dev, None))
         assert qml.math.isclose(res, -2 * jnp.sin(2 * p) * delta_t)
+
+        # note that qml.execute changes trainable params
+        r = qml.execute([tape], dev, None)
+        assert qml.math.isclose(r, jnp.cos(2 * p), atol=1e-4)
         jax.clear_caches()
 
     def test_constant_ry_argnum(self):
@@ -924,14 +926,15 @@ class TestStochPulseGrad:
         dev = qml.device("default.qubit", wires=1)
         # Effective rotation parameter
         p = params[0] * t
-        r = qml.execute([tape], dev, None)
-        assert qml.math.isclose(r, jnp.cos(2 * p + y), atol=1e-4)
         num_split_times = 1
         tapes, fn = stoch_pulse_grad(tape, num_split_times=num_split_times, argnum=0)
         assert len(tapes) == num_split_times * 2
 
         res = fn(qml.execute(tapes, dev, None))
         assert qml.math.isclose(res, -2 * jnp.sin(2 * p + y) * t)
+
+        r = qml.execute([tape], dev, None)
+        assert qml.math.isclose(r, jnp.cos(2 * p + y), atol=1e-4)
         jax.clear_caches()
 
     @pytest.mark.parametrize("num_split_times", [1, 3])
@@ -956,13 +959,13 @@ class TestStochPulseGrad:
         prefactor = np.sqrt(0.85)
         # Effective rotation parameter
         p = params[0] * (delta_t := T[-1] - T[0]) * prefactor
-        r = qml.execute([tape], dev, None)
-        assert qml.math.isclose(r, jnp.cos(2 * p), atol=1e-4)
         tapes, fn = stoch_pulse_grad(tape, num_split_times=num_split_times)
         assert len(tapes) == num_split_times * 2
 
         res = fn(qml.execute(tapes, dev, None))
         assert qml.math.isclose(res, -2 * jnp.sin(2 * p) * delta_t * prefactor)
+        r = qml.execute([tape], dev, None)
+        assert qml.math.isclose(r, jnp.cos(2 * p), atol=1e-4)
         jax.clear_caches()
 
     @pytest.mark.parametrize("t", [0.02, (0.5, 0.6)])
@@ -991,8 +994,6 @@ class TestStochPulseGrad:
                 + x / y * (jnp.sin(y * T[1]) * T[1] - jnp.sin(y * T[0]) * T[0]),
             ]
         )
-        r = qml.execute([tape], dev, None)
-        assert qml.math.isclose(r, jnp.cos(2 * theta))
 
         num_split_times = 5
         tapes, fn = stoch_pulse_grad(tape, num_split_times=num_split_times)
@@ -1002,6 +1003,8 @@ class TestStochPulseGrad:
         exp_grad = -2 * jnp.sin(2 * theta) * theta_jac
         # classical Jacobian is being estimated with the Monte Carlo sampling -> coarse tolerance
         assert qml.math.allclose(res, exp_grad, atol=0.2)
+        r = qml.execute([tape], dev, None)
+        assert qml.math.isclose(r, jnp.cos(2 * theta))
         jax.clear_caches()
 
     @pytest.mark.parametrize("t", [0.02, (0.5, 0.6)])
@@ -1030,9 +1033,6 @@ class TestStochPulseGrad:
                 + x / y * (jnp.sin(y * T[1]) * T[1] - jnp.sin(y * T[0]) * T[0]),
             ]
         )
-        r = qml.execute([tape], dev, None)
-        exp_probs = jnp.array([jnp.cos(theta) ** 2, jnp.sin(theta) ** 2])
-        assert qml.math.allclose(r, exp_probs)
 
         num_split_times = 5
         tapes, fn = stoch_pulse_grad(tape, num_split_times=num_split_times)
@@ -1043,6 +1043,9 @@ class TestStochPulseGrad:
         exp_jac = jnp.tensordot(probs_jac, theta_jac, axes=0)
         # classical Jacobian is being estimated with the Monte Carlo sampling -> coarse tolerance
         assert qml.math.allclose(jac, exp_jac, atol=0.2)
+        r = qml.execute([tape], dev, None)
+        exp_probs = jnp.array([jnp.cos(theta) ** 2, jnp.sin(theta) ** 2])
+        assert qml.math.allclose(r, exp_probs)
         jax.clear_caches()
 
     @pytest.mark.parametrize("t", [0.02, (0.5, 0.6)])
@@ -1071,11 +1074,6 @@ class TestStochPulseGrad:
                 + x / y * (jnp.sin(y * T[1]) * T[1] - jnp.sin(y * T[0]) * T[0]),
             ]
         )
-        r = qml.execute([tape], dev, None)[0]
-        exp = (jnp.cos(2 * theta), jnp.array([jnp.cos(theta) ** 2, jnp.sin(theta) ** 2]))
-        assert isinstance(r, tuple) and len(r) == 2
-        assert qml.math.allclose(r[0], exp[0])
-        assert qml.math.allclose(r[1], exp[1])
 
         num_split_times = 5
         tapes, fn = stoch_pulse_grad(tape, num_split_times=num_split_times)
@@ -1088,6 +1086,12 @@ class TestStochPulseGrad:
         # classical Jacobian is being estimated with the Monte Carlo sampling -> coarse tolerance
         for j, e in zip(jac, exp_jac):
             assert qml.math.allclose(j, e, atol=0.2)
+
+        r = qml.execute([tape], dev, None)[0]
+        exp = (jnp.cos(2 * theta), jnp.array([jnp.cos(theta) ** 2, jnp.sin(theta) ** 2]))
+        assert isinstance(r, tuple) and len(r) == 2
+        assert qml.math.allclose(r[0], exp[0])
+        assert qml.math.allclose(r[1], exp[1])
         jax.clear_caches()
 
     @pytest.mark.parametrize("t", [0.02, (0.5, 0.6)])
@@ -1106,8 +1110,6 @@ class TestStochPulseGrad:
 
         # Effective rotation parameter
         p = jnp.mean(params[0]) * (T[1] - T[0])
-        r = qml.execute([tape], dev, None)
-        assert qml.math.isclose(r, jnp.cos(2 * p))
         num_split_times = 5
         tapes, fn = stoch_pulse_grad(tape, num_split_times=num_split_times, sampler_seed=seed)
         assert len(tapes) == 2 * num_split_times
@@ -1118,6 +1120,8 @@ class TestStochPulseGrad:
         assert qml.math.allclose(
             res, -2 * jnp.sin(2 * p) * (T[1] - T[0]) / len(params[0]), atol=0.01
         )
+        r = qml.execute([tape], dev, None)
+        assert qml.math.isclose(r, jnp.cos(2 * p))
         jax.clear_caches()
 
     @pytest.mark.parametrize("t", [2.0, 3, (0.5, 0.6)])
@@ -1136,10 +1140,7 @@ class TestStochPulseGrad:
         tape = qml.tape.QuantumScript([op], [qml.expval(qml.PauliZ(0) @ qml.PauliZ(1))])
 
         dev = qml.device("default.qubit", wires=2)
-        r = qml.execute([tape], dev, None)
-        # Effective rotation parameters
         p = [_p * (T[1] - T[0]) for _p in params]
-        assert qml.math.isclose(r, jnp.cos(2 * p[0]) * jnp.cos(2 * p[1]))
         tapes, fn = stoch_pulse_grad(tape)
         assert len(tapes) == 4
 
@@ -1149,6 +1150,10 @@ class TestStochPulseGrad:
             -2 * jnp.sin(2 * p[1]) * jnp.cos(2 * p[0]) * (T[1] - T[0]),
         ]
         assert qml.math.allclose(res, exp_grad)
+        r = qml.execute([tape], dev, None)
+        # Effective rotation parameters
+        exp = jnp.cos(2 * p[0]) * jnp.cos(2 * p[1])
+        assert qml.math.isclose(r, exp)
         jax.clear_caches()
 
     @pytest.mark.slow
diff --git a/tests/gradients/core/test_pulse_odegen.py b/tests/gradients/core/test_pulse_odegen.py
index 37e86692b..6afe26349 100644
--- a/tests/gradients/core/test_pulse_odegen.py
+++ b/tests/gradients/core/test_pulse_odegen.py
@@ -1016,13 +1016,14 @@ class TestPulseOdegenTape:
         op = qml.evolve(H)([x], t=t)
         tape = qml.tape.QuantumScript([op], [qml.expval(Z(0))], shots=shots)
 
-        val = qml.execute([tape], dev)
         theta = integral_of_polyval(x, t)
-        assert qml.math.allclose(val, jnp.cos(2 * theta), atol=tol)
 
         _tapes, fn = pulse_odegen(tape)
         assert len(_tapes) == 2
 
+        val = qml.execute([tape], dev)
+        assert qml.math.allclose(val, jnp.cos(2 * theta), atol=tol)
+
         grad = fn(qml.execute(_tapes, dev))
         par_jac = jax.jacobian(integral_of_polyval)(x, t)
         exp_grad = -2 * par_jac * jnp.sin(2 * theta)
@@ -1090,9 +1091,7 @@ class TestPulseOdegenTape:
         op = qml.evolve(H)([x, y], t=t)
         tape = qml.tape.QuantumScript([op], [qml.expval(Z(0))])
 
-        val = qml.execute([tape], dev)
         theta = integral_of_polyval(x, t) + y * (t[1] - t[0])
-        assert qml.math.allclose(val, jnp.cos(2 * theta))
 
         # Argnum=[0] or 0
         par_jac_0 = jax.jacobian(integral_of_polyval)(x, t)
@@ -1105,6 +1104,9 @@ class TestPulseOdegenTape:
         _tapes, fn = pulse_odegen(tape, argnum=argnum)
         assert len(_tapes) == 2
 
+        val = qml.execute([tape], dev)
+        assert qml.math.allclose(val, jnp.cos(2 * theta))
+
         grad = fn(qml.execute(_tapes, dev))
         assert isinstance(grad, tuple) and len(grad) == 2
         assert isinstance(grad[0], jnp.ndarray) and grad[0].shape == x.shape
diff --git a/tests/logging/test_logging_autograd.py b/tests/logging/test_logging_autograd.py
index c2315e94e..01470469c 100644
--- a/tests/logging/test_logging_autograd.py
+++ b/tests/logging/test_logging_autograd.py
@@ -69,7 +69,7 @@ class TestLogging:
                 return qml.expval(qml.PauliZ(0))
 
             circuit(params)
-        assert len(caplog.records) == 10
+        assert len(caplog.records) == 11
         log_records_expected = [
             (
                 "pennylane.workflow.qnode",
@@ -83,7 +83,7 @@ class TestLogging:
                 "pennylane.workflow.execution",
                 [
                     "device=<default.qubit device (wires=2)",
-                    "diff_method=None, interface=numpy",
+                    "diff_method=None, interface=Interface.AUTOGRAD",
                 ],
             ),
             (
@@ -169,7 +169,7 @@ class TestLogging:
 
             circuit(params)
 
-        assert len(caplog.records) == 8
+        assert len(caplog.records) == 9
 
         log_records_expected = [
             (
@@ -188,7 +188,7 @@ class TestLogging:
                 "pennylane.workflow.execution",
                 [
                     "device=<default.qutrit.mixed device (wires=2)",
-                    "diff_method=None, interface=None",
+                    "diff_method=None, interface=Interface.AUTOGRAD",
                 ],
             ),
         ]
diff --git a/tests/test_qnode.py b/tests/test_qnode.py
index 9529df3f6..7a58b4762 100644
--- a/tests/test_qnode.py
+++ b/tests/test_qnode.py
@@ -318,7 +318,7 @@ class TestValidation:
             qml.RX(x, wires=0)
             return qml.expval(qml.PauliZ(0))
 
-        assert circuit.interface == "numpy"
+        assert circuit.interface == "auto"
         assert circuit.device is dev
 
         # QNode can still be executed
@@ -1063,6 +1063,31 @@ class TestIntegration:
         with pytest.raises(qml.QuantumFunctionError, match="device_vjp=True is not supported"):
             circuit()
 
+    @pytest.mark.parametrize(
+        "interface",
+        (
+            pytest.param("autograd", marks=pytest.mark.autograd),
+            pytest.param("jax", marks=pytest.mark.jax),
+            pytest.param("torch", marks=pytest.mark.torch),
+            pytest.param("tensorflow", marks=pytest.mark.tf),
+        ),
+    )
+    def test_error_if_differentiate_diff_method_None(self, interface):
+        """Test that an error is raised if differentiating a qnode with diff_method=None"""
+
+        @qml.qnode(qml.device("reference.qubit", wires=1), diff_method=None)
+        def circuit(x):
+            qml.RX(x, 0)
+            return qml.expval(qml.Z(0))
+
+        x = qml.math.asarray(0.5, like=interface, requires_grad=True)
+
+        res = circuit(x)  # execution works fine
+        assert qml.math.allclose(res, np.cos(0.5))
+
+        with pytest.raises(qml.QuantumFunctionError, match="with diff_method=None"):
+            qml.math.grad(circuit)(x)
+
 
 class TestShots:
     """Unit tests for specifying shots per call."""
diff --git a/tests/workflow/interfaces/test_jacobian_products.py b/tests/workflow/interfaces/test_jacobian_products.py
index 8a3d67456..b324ec7a9 100644
--- a/tests/workflow/interfaces/test_jacobian_products.py
+++ b/tests/workflow/interfaces/test_jacobian_products.py
@@ -26,6 +26,7 @@ from pennylane.workflow.jacobian_products import (
     DeviceDerivatives,
     DeviceJacobianProducts,
     JacobianProductCalculator,
+    NoGradients,
     TransformJacobianProducts,
 )
 
@@ -79,6 +80,32 @@ def _tol_for_shots(shots):
     return 0.05 if shots else 1e-6
 
 
+def test_no_gradients():
+    """Test that errors are raised when derivatives are requested from `NoGradients`."""
+
+    jpc = NoGradients()
+
+    with pytest.raises(
+        qml.QuantumFunctionError, match="cannot be calculated with diff_method=None"
+    ):
+        jpc.compute_jacobian(())
+
+    with pytest.raises(
+        qml.QuantumFunctionError, match="cannot be calculated with diff_method=None"
+    ):
+        jpc.compute_vjp((), ())
+
+    with pytest.raises(
+        qml.QuantumFunctionError, match="cannot be calculated with diff_method=None"
+    ):
+        jpc.execute_and_compute_jvp((), ())
+
+    with pytest.raises(
+        qml.QuantumFunctionError, match="cannot be calculated with diff_method=None"
+    ):
+        jpc.execute_and_compute_jacobian(())
+
+
 # pylint: disable=too-few-public-methods
 class TestBasics:
     """Test initialization and repr for jacobian product calculator classes."""