Repository: PennyLaneAI/pennylane (python)

Issue:
Warn/raise in case shot noise and caching are enabled at the same time?
### Feature details

When `default.qubit` with `shots != None` but also `cache=1` is used then the same (noisy) result is returned again and again when a `QNode` is called. It is hard to imagine a case where this is what a user wants. 

Either they want a `QNode` to return a noisy result that is different each time it is called (they can get this by setting `cache=0`), or they want only a single noisy result, in which case caching doesn't provide any benefit.

I think it would be nice to raise an exception or at least a warning when a user makes a device with shot noise and caching enabled.

Alternatively the the device could go back to state caching, as it used to be in ancient times... This would give a speed benefit because the circuit would not need to be re-evaluated when the same shot-noisy `QNode` is called several times.
    

### Implementation

To demonstrate the "problem" consider this sample program:
```
import pennylane as qml
from pennylane import numpy as np

shots=76

dev = qml.device(
    'default.qubit',
    wires=2,
    shots=shots,
    cache=1,
)

np.random.seed(87)
params = np.random.randn(2, 2, 3)
observable_as_array = np.array([[ 1. +0.j,  0. +0.j,  0. +0.j,  0. +0.j],
                                [ 0. +0.j,  0.4+0.j, 21.6+0.j,  0. +0.j],
                                [ 0. +0.j, 21.6+0.j, -0.4+0.j,  0. +0.j],
                                [ 0. +0.j,  0. +0.j,  0. +0.j, -1. +0.j]])

@qml.qnode(dev, diff_method='parameter-shift')
def circuit(params):
    qml.templates.layers.StronglyEntanglingLayers(params, wires=range(dev.num_wires))
    return qml.expval(qml.Hermitian(observable_as_array, wires=range(dev.num_wires)))

for _ in range(10):
    print(circuit(params))
```

### How important would you say this feature is?

1: Not important. Would be nice to have.

### Additional information

_No response_

Pull request description:
Issue a warning about repeated cached executions with shot noise
Closes #2150

Raises the following warning when a QNode is executed repeatedly with caching turned on and shot noise enabled: 

```
UserWarning: Cached execution with finite shots detected: note that the shot noise will be the same on all results from executions with identical arguments.
```

Example:

```py
import pennylane as qml
from pennylane import numpy as np

params = np.random.randn(2, 2, 3)
observable_as_array = np.array([[ 1. +0.j,  0. +0.j,  0. +0.j,  0. +0.j],
                                [ 0. +0.j,  0.4+0.j, 21.6+0.j,  0. +0.j],
                                [ 0. +0.j, 21.6+0.j, -0.4+0.j,  0. +0.j],
                                [ 0. +0.j,  0. +0.j,  0. +0.j, -1. +0.j]])

dev = qml.device('default.qubit', wires=2, shots=42)
@qml.qnode(dev, diff_method='parameter-shift', cache={})
def circuit(params):
    qml.templates.layers.StronglyEntanglingLayers(params, wires=range(dev.num_wires))
    return qml.expval(qml.Hermitian(observable_as_array, wires=range(dev.num_wires)))

print(circuit(params))  # no warning on first execution
print(circuit(params))  # emit warning
```


Patch:
diff --git a/doc/releases/changelog-0.23.0.md b/doc/releases/changelog-0.23.0.md
index 268f34a96..7549a0f26 100644
--- a/doc/releases/changelog-0.23.0.md
+++ b/doc/releases/changelog-0.23.0.md
@@ -445,6 +445,9 @@
   accessed via the top-level `qml` namespace.
   [(#2396)](https://github.com/PennyLaneAI/pennylane/pull/2396)
 
+* Raise a warning where caching produces identical shot noise on execution results with finite shots.
+  [(#2478)](https://github.com/PennyLaneAI/pennylane/pull/2478)
+
 <h3>Deprecations</h3>
 
 * The `ObservableReturnTypes` `Sample`, `Variance`, `Expectation`, `Probability`, `State`, and `MidMeasure`
diff --git a/pennylane/interfaces/execution.py b/pennylane/interfaces/execution.py
index 26906b3df..e8e4e7479 100644
--- a/pennylane/interfaces/execution.py
+++ b/pennylane/interfaces/execution.py
@@ -21,6 +21,9 @@ devices with autodifferentiation support.
 # pylint: disable=import-outside-toplevel,too-many-arguments,too-many-branches,not-callable
 from functools import wraps
 import itertools
+import warnings
+import inspect
+from contextlib import _GeneratorContextManager
 from cachetools import LRUCache
 
 import pennylane as qml
@@ -119,6 +122,33 @@ def cache_execute(fn, cache, pass_kwargs=False, return_tuple=True, expand_fn=Non
             if hashes[i] in cache:
                 # Tape exists within the cache, store the cached result
                 cached_results[i] = cache[hashes[i]]
+
+                # Introspect the set_shots decorator of the input function:
+                #   warn the user in case of finite shots with cached results
+                finite_shots = False
+
+                closure = inspect.getclosurevars(fn).nonlocals
+                if "original_fn" in closure:  # deal with expand_fn wrapper above
+                    closure = inspect.getclosurevars(closure["original_fn"]).nonlocals
+
+                # retrieve the captured context manager instance (for set_shots)
+                if "self" in closure and isinstance(closure["self"], _GeneratorContextManager):
+                    # retrieve the shots from the arguments or device instance
+                    if closure["self"].func.__name__ == "set_shots":
+                        dev, shots = closure["self"].args
+                        shots = dev.shots if shots is False else shots
+                        finite_shots = isinstance(shots, int)
+
+                if finite_shots:
+                    warnings.warn(
+                        "Cached execution with finite shots detected!\n"
+                        "Note that samples as well as all noisy quantities computed via sampling "
+                        "will be identical across executions. This situation arises where tapes "
+                        "are executed with identical operations, measurements, and parameters.\n"
+                        "To avoid this behavior, provide 'cache=False' to the QNode or execution "
+                        "function.",
+                        UserWarning,
+                    )
             else:
                 # Tape does not exist within the cache, store the tape
                 # for execution via the execution function.


Test patch:
diff --git a/tests/test_qnode.py b/tests/test_qnode.py
index bebdfd233..c81677755 100644
--- a/tests/test_qnode.py
+++ b/tests/test_qnode.py
@@ -14,6 +14,7 @@
 """Unit tests for the QNode"""
 from collections import defaultdict
 import pytest
+import warnings
 import numpy as np
 from scipy.sparse import coo_matrix
 
@@ -1092,6 +1093,62 @@ class TestShots:
         assert len(res) == 2
         assert dev.shots == 3
 
+    def test_warning_finite_shots_dev(self):
+        """Tests that a warning is raised when caching is used with finite shots."""
+        dev = qml.device("default.qubit", wires=1, shots=5)
+
+        @qml.qnode(dev, cache={})
+        def circuit(x):
+            qml.RZ(x, wires=0)
+            return qml.expval(qml.PauliZ(0))
+
+        # no warning on the first execution
+        circuit(0.3)
+        with pytest.warns(UserWarning, match="Cached execution with finite shots detected"):
+            circuit(0.3)
+
+    def test_warning_finite_shots_override(self):
+        """Tests that a warning is raised when caching is used with finite shots."""
+        dev = qml.device("default.qubit", wires=1)
+
+        @qml.qnode(dev, cache={})
+        def circuit(x):
+            qml.RZ(x, wires=0)
+            return qml.expval(qml.PauliZ(0))
+
+        # no warning on the first execution
+        circuit(0.3)
+        with pytest.warns(UserWarning, match="Cached execution with finite shots detected"):
+            circuit(0.3, shots=5)
+
+    def test_warning_finite_shots_tape(self):
+        """Tests that a warning is raised when caching is used with finite shots."""
+        dev = qml.device("default.qubit", wires=1, shots=5)
+
+        with qml.tape.QuantumTape() as tape:
+            qml.RZ(0.3, wires=0)
+            qml.expval(qml.PauliZ(0))
+
+        # no warning on the first execution
+        cache = {}
+        qml.execute([tape], dev, None, cache=cache)
+        with pytest.warns(UserWarning, match="Cached execution with finite shots detected"):
+            qml.execute([tape], dev, None, cache=cache)
+
+    def test_no_warning_infinite_shots(self):
+        """Tests that no warning is raised when caching is used with infinite shots."""
+        dev = qml.device("default.qubit", wires=1)
+
+        @qml.qnode(dev, cache={})
+        def circuit(x):
+            qml.RZ(x, wires=0)
+            return qml.expval(qml.PauliZ(0))
+
+        with warnings.catch_warnings():
+            warnings.filterwarnings("error", message="Cached execution with finite shots detected")
+            circuit(0.3)
+            circuit(0.3)
+
 
 @pytest.mark.xfail
 class TestSpecs: