Repository: PennyLaneAI/pennylane
Pull request #1075 (2021-02-08 13:54:42)

Shots refactor I of III
**Context:**

The shots refactor is a long-planned change that makes the execution of circuits with varying numbers of shots more flexible. 

The first change adds an optional kwarg to the qnode's `__call__` function which temporarily changes the number of shots that the device is executed with.

**Description of the Change:**

* Temporarily set the number of shots in qnode's  `__call__` function.

* Add a test.

* Change docs and changelog.

**Benefits:**

In every execution one can now overwrite the device's default shot argument.

``` python
dev = qml.device('default.qubit', wires=1, shots=10)

@qml.qnode(dev)
def circuit(a):
    qml.RX(a, wires=0)
    return qml.sample(qml.PauliZ(wires=0))

print(circuit(0.8))  # [ 1  1  1 -1 -1  1  1  1  1  1]
print(circuit(0.8, shots=3))  # [ 1  1  1]
print(circuit(0.8))  # [ 1  1  1 -1 -1  1  1  1  1  1]
```
**Possible Drawbacks:**

If a user creates a qfunc with a custom `shots` keyword argument used in the circuit, the kwarg will be popped from the argument list and is never fed into the circuit. This leads to unexpected behaviour, such as

``` python

dev = qml.device('default.qubit', wires=2, shots=10)

@qml.qnode(dev)
def circuit(a, shots=1):
    qml.RX(a, wires=shots)
    return qml.sample(qml.PauliZ(wires=0))

print(circuit(0.8, shots=0)) 
>>> "pennylane._device.DeviceError: The specified number of shots needs to be at least 1. Got 0."
```
In some sense, we make `shots` a reserved argument. 

codecov[bot]: # [Codecov](https://codecov.io/gh/PennyLaneAI/pennylane/pull/1075?src=pr&el=h1) Report
> Merging [#1075](https://codecov.io/gh/PennyLaneAI/pennylane/pull/1075?src=pr&el=desc) (152bd58) into [master](https://codecov.io/gh/PennyLaneAI/pennylane/commit/85dd19333a71907f2b5bfd9e1e928ecefd9ab7d4?el=desc) (85dd193) will **increase** coverage by `0.00%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/PennyLaneAI/pennylane/pull/1075/graphs/tree.svg?width=650&height=150&src=pr&token=9S3YzlUgGk)](https://codecov.io/gh/PennyLaneAI/pennylane/pull/1075?src=pr&el=tree)

```diff
@@           Coverage Diff           @@
##           master    #1075   +/-   ##
=======================================
  Coverage   97.74%   97.74%           
=======================================
  Files         153      153           
  Lines       11579    11585    +6     
=======================================
+ Hits        11318    11324    +6     
  Misses        261      261           
```


| [Impacted Files](https://codecov.io/gh/PennyLaneAI/pennylane/pull/1075?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pennylane/\_\_init\_\_.py](https://codecov.io/gh/PennyLaneAI/pennylane/pull/1075/diff?src=pr&el=tree#diff-cGVubnlsYW5lL19faW5pdF9fLnB5) | `97.10% <ø> (ø)` | |
| [pennylane/tape/qnode.py](https://codecov.io/gh/PennyLaneAI/pennylane/pull/1075/diff?src=pr&el=tree#diff-cGVubnlsYW5lL3RhcGUvcW5vZGUucHk=) | `99.25% <100.00%> (+0.01%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/PennyLaneAI/pennylane/pull/1075?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/PennyLaneAI/pennylane/pull/1075?src=pr&el=footer). Last update [85dd193...152bd58](https://codecov.io/gh/PennyLaneAI/pennylane/pull/1075?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

mariaschuld: [ch4089]
mariaschuld: @josh146 and @Thenerdstation, thanks for the comments!

I made a few more changes which ensure backwards compatibility. So we have:

* If users have a shots argument or keyword argument in the qfunc, the per-call-shot-mutation is switched off and their argument gets precedence. However, they will get a deprecation warning that discourages the use of shots args/kwargs.
* If they do not have a shots arg/kwarg we interpret `shots=8` as a temporary overwrite to the device's shots argument

Patch:
diff --git a/.github/CHANGELOG.md b/.github/CHANGELOG.md
index 087f4a117..768953b4a 100644
--- a/.github/CHANGELOG.md
+++ b/.github/CHANGELOG.md
@@ -2,6 +2,29 @@
 
 <h3>New features since last release</h3>
 
+- The number of shots can now be specified on a temporary basis when evaluating a QNode.
+  [(#1075)](https://github.com/PennyLaneAI/pennylane/pull/1075)
+
+  ```python
+  dev = qml.device('default.qubit', wires=1, shots=10) # default is 10
+  
+  @qml.qnode(dev)
+  def circuit(a):
+      qml.RX(a, wires=0)
+      return qml.sample(qml.PauliZ(wires=0))
+  ```
+  
+  For this, the qnode is called with an additional `shots` keyword argument:
+  
+  ```pycon
+  >>> circuit(0.8)  
+  [ 1  1  1 -1 -1  1  1  1  1  1]
+  >>> circuit(0.8, shots=3)
+  [ 1  1  1] 
+  >>> circuit(0.8)  
+  [ 1  1  1 -1 -1  1  1  1  1  1]
+  ```
+
 - The JAX interface now supports all devices.
   [(#1076)](https://github.com/PennyLaneAI/pennylane/pull/1076)
 
@@ -62,6 +85,10 @@
 
 <h3>Breaking changes</h3>
 
+* If creating a QNode from a quantum function with an argument named `shots`,
+  a `DeprecationWarning` is raised, warning the user that this is a reserved 
+  argument to change the number of shots on a per-call basis.
+
 <h3>Bug fixes</h3>
 
 * Fixes a bug where inverse operations could not be differentiated
@@ -79,7 +106,7 @@
 
 This release contains contributions from (in alphabetical order):
 
-Thomas Bromley, Josh Izaac, Daniel Polatajko, Chase Roberts.
+Thomas Bromley, Josh Izaac, Daniel Polatajko, Chase Roberts, Maria Schuld
 
 # Release 0.14.0 (current release)
 
diff --git a/doc/introduction/circuits.rst b/doc/introduction/circuits.rst
index 31fc0b1a1..9cfd494be 100644
--- a/doc/introduction/circuits.rst
+++ b/doc/introduction/circuits.rst
@@ -114,7 +114,9 @@ devices, the options are:
   and variances analytically. Only possible with simulator devices. Defaults to ``True``.
 
 * ``shots`` (*int*): How many times the circuit should be evaluated (or sampled) to estimate
-  the expectation values. Defaults to 1000 if not specified.
+  the expectation values. Defaults to 1000 if not specified. This argument can be temporarily overwritten
+  when a QNode is called; for example ``my_qnode(shots=3)`` will temporarily evaluate ``my_qnode``
+  using three shots.
 
 For a plugin device, refer to the plugin documentation for available device options.
 
diff --git a/pennylane/__init__.py b/pennylane/__init__.py
index 0911e56f7..8ed9758be 100644
--- a/pennylane/__init__.py
+++ b/pennylane/__init__.py
@@ -137,7 +137,7 @@ def device(name, *args, **kwargs):
       of qubit-based quantum circuit architectures which allows
       automatic differentiation through the simulation via python's autograd library.
 
-    In addition, additional devices are supported through plugins — see
+    Additional devices are supported through plugins — see
     the  `available plugins <https://pennylane.ai/plugins.html>`_ for more
     details.
 
@@ -169,6 +169,26 @@ def device(name, *args, **kwargs):
            qml.CNOT(wires=['q12', -1] )
            ...
 
+    Most devices accept a ``shots`` argument which specifies how many circuit executions
+    are used to estimate stochastic return values. In particular, ``qml.sample()`` measurements
+    will return as many samples as specified in the shots argument. The shots argument can be
+    changed on a per-call basis using the built-in ``shots`` keyword argument.
+
+    .. code-block:: python
+
+        dev = qml.device('default.qubit', wires=1, shots=10)
+
+        @qml.qnode(dev)
+        def circuit(a):
+          qml.RX(a, wires=0)
+          return qml.sample(qml.PauliZ(wires=0))
+
+    >>> circuit(0.8)  # 10 samples are returned
+    [ 1  1  1 -1 -1  1  1  1  1  1]
+    >>> circuit(0.8, shots=3))  # default is overwritten for this call
+    [1 1 1]
+    >>> circuit(0.8)  # back to default of 10 samples
+    [ 1  1  1 -1 -1  1  1  1  1  1]
 
     Some devices may accept additional arguments. For instance,
     ``default.gaussian`` accepts the keyword argument ``hbar``, to set
diff --git a/pennylane/tape/qnode.py b/pennylane/tape/qnode.py
index 1d231af65..020b888bf 100644
--- a/pennylane/tape/qnode.py
+++ b/pennylane/tape/qnode.py
@@ -18,6 +18,7 @@ This module contains the QNode class and qnode decorator.
 from collections.abc import Sequence
 from functools import lru_cache, update_wrapper, wraps
 import warnings
+import inspect
 
 import numpy as np
 
@@ -151,6 +152,17 @@ class QNode:
                 "Invalid device. Device must be a valid PennyLane device."
             )
 
+        if "shots" in inspect.signature(func).parameters:
+            warnings.warn(
+                "Detected 'shots' as an argument to the given quantum function. "
+                "The 'shots' argument name is reserved for overriding the number of shots "
+                "taken by the device. Its use outside of this context should be avoided.",
+                DeprecationWarning,
+            )
+            self._qfunc_uses_shots_arg = True
+        else:
+            self._qfunc_uses_shots_arg = False
+
         self.mutable = mutable
         self.func = func
         self._original_device = device
@@ -525,6 +537,16 @@ class QNode:
             )
 
     def __call__(self, *args, **kwargs):
+
+        # If shots specified in call but not in qfunc signature,
+        # interpret it as device shots value for this call.
+        # TODO: make this more functional by passing shots as qtape.execute(.., shots=shots).
+        original_shots = None
+        if "shots" in kwargs and not self._qfunc_uses_shots_arg:
+            original_shots = self.device.shots  # remember device shots
+            # remove shots from kwargs and temporarily change on device
+            self.device.shots = kwargs.pop("shots", None)
+
         if self.mutable or self.qtape is None:
             # construct the tape
             self.construct(args, kwargs)
@@ -532,6 +554,10 @@ class QNode:
         # execute the tape
         res = self.qtape.execute(device=self.device)
 
+        if original_shots is not None:
+            # reinstate default on device
+            self.device.shots = original_shots
+
         # FIX: If the qnode swapped the device, increase the num_execution value on the original device.
         # In the long run, we should make sure that the user's device is the one
         # actually run so she has full control. This could be done by changing the class


Test patch:
diff --git a/tests/tape/tapes/test_qnode.py b/tests/tape/tapes/test_qnode.py
index af74dea27..ce8de947b 100644
--- a/tests/tape/tapes/test_qnode.py
+++ b/tests/tape/tapes/test_qnode.py
@@ -20,6 +20,10 @@ from pennylane import QNodeCollection
 from pennylane.tape import JacobianTape, QNode, draw, qnode, QubitParamShiftTape, CVParamShiftTape
 
 
+def dummyfunc():
+    return None
+
+
 class TestValidation:
     """Tests for QNode creation and validation"""
 
@@ -33,12 +37,12 @@ class TestValidation:
         )
 
         with pytest.raises(qml.QuantumFunctionError, match=expected_error):
-            QNode(None, dev, interface="something")
+            QNode(dummyfunc, dev, interface="something")
 
     def test_invalid_device(self):
         """Test that an exception is raised for an invalid device"""
         with pytest.raises(qml.QuantumFunctionError, match="Invalid device"):
-            QNode(None, None)
+            QNode(dummyfunc, None)
 
     def test_validate_device_method(self, monkeypatch):
         """Test that the method for validating the device diff method
@@ -199,28 +203,28 @@ class TestValidation:
         mock_device = mocker.patch("pennylane.tape.QNode._validate_device_method")
         mock_device.return_value = 7, 8, 9, {"method": "device"}
 
-        qn = QNode(None, dev, diff_method="best")
+        qn = QNode(dummyfunc, dev, diff_method="best")
         assert qn._tape == mock_best.return_value[0]
         assert qn.interface == mock_best.return_value[1]
         assert qn.diff_options["method"] == mock_best.return_value[3]["method"]
 
-        qn = QNode(None, dev, diff_method="backprop")
+        qn = QNode(dummyfunc, dev, diff_method="backprop")
         assert qn._tape == mock_backprop.return_value[0]
         assert qn.interface == mock_backprop.return_value[1]
         assert qn.diff_options["method"] == mock_backprop.return_value[3]["method"]
         mock_backprop.assert_called_once()
 
-        qn = QNode(None, dev, diff_method="device")
+        qn = QNode(dummyfunc, dev, diff_method="device")
         assert qn._tape == mock_device.return_value[0]
         assert qn.interface == mock_device.return_value[1]
         assert qn.diff_options["method"] == mock_device.return_value[3]["method"]
         mock_device.assert_called_once()
 
-        qn = QNode(None, dev, diff_method="finite-diff")
+        qn = QNode(dummyfunc, dev, diff_method="finite-diff")
         assert qn._tape == JacobianTape
         assert qn.diff_options["method"] == "numeric"
 
-        qn = QNode(None, dev, diff_method="parameter-shift")
+        qn = QNode(dummyfunc, dev, diff_method="parameter-shift")
         assert qn._tape == QubitParamShiftTape
         assert qn.diff_options["method"] == "analytic"
 
@@ -234,7 +238,7 @@ class TestValidation:
         with pytest.raises(
             qml.QuantumFunctionError, match="Differentiation method hello not recognized"
         ):
-            QNode(None, dev, diff_method="hello")
+            QNode(dummyfunc, dev, diff_method="hello")
 
     def test_validate_adjoint_invalid_device(self):
         """Test if a ValueError is raised when an invalid device is provided to
@@ -915,3 +919,87 @@ class TestMutability:
         # test differentiability. The circuit will assume an RZ gate
         grad = qml.grad(circuit)(-0.5)
         np.testing.assert_allclose(grad, 0, atol=tol, rtol=0)
+
+
+class TestShots:
+    """Unittests for specifying shots per call."""
+
+    def test_specify_shots_per_call(self):
+        """Tests that shots can be set per call."""
+        dev = qml.device('default.qubit', wires=1, shots=10)
+
+        @qml.qnode(dev)
+        def circuit(a):
+            qml.RX(a, wires=0)
+            return qml.sample(qml.PauliZ(wires=0))
+
+        assert len(circuit(0.8)) == 10
+        assert len(circuit(0.8, shots=2)) == 2
+        assert len(circuit(0.8, shots=3178)) == 3178
+        assert len(circuit(0.8)) == 10
+
+    def test_no_shots_per_call_if_user_has_shots_qfunc_kwarg(self):
+        """Tests that the per-call shots overwriting is suspended if user
+        has a shots keyword argument, but a warning is raised."""
+
+        dev = qml.device('default.qubit', wires=2, shots=10)
+
+        def circuit(a, shots=0):
+            qml.RX(a, wires=shots)
+            return qml.sample(qml.PauliZ(wires=0))
+
+        with pytest.warns(DeprecationWarning, match="The 'shots' argument name is reserved for overriding"):
+            circuit = qml.QNode(circuit, dev)
+
+        assert len(circuit(0.8)) == 10
+        assert circuit.qtape.operations[0].wires.labels == (0,)
+
+        assert len(circuit(0.8, shots=1)) == 10
+        assert circuit.qtape.operations[0].wires.labels == (1,)
+
+        assert len(circuit(0.8, shots=0)) == 10
+        assert circuit.qtape.operations[0].wires.labels == (0,)
+
+    def test_no_shots_per_call_if_user_has_shots_qfunc_arg(self):
+        """Tests that the per-call shots overwriting is suspended
+        if user has a shots argument, but a warning is raised."""
+
+        # Todo: use standard creation of qnode below for both asserts once we do not parse args to tensors any more
+        dev = qml.device('default.qubit', wires=[qml.numpy.array(0), qml.numpy.array(1)], shots=10)
+
+        def circuit(a, shots):
+            qml.RX(a, wires=shots)
+            return qml.sample(qml.PauliZ(wires=qml.numpy.array(0)))
+
+        # assert that warning is still raised
+        with pytest.warns(DeprecationWarning, match="The 'shots' argument name is reserved for overriding"):
+            circuit = qml.QNode(circuit, dev)
+
+        assert len(circuit(0.8, 1)) == 10
+        assert circuit.qtape.operations[0].wires.labels == (1,)
+
+        dev = qml.device('default.qubit', wires=2, shots=10)
+
+        @qml.qnode(dev)
+        def circuit(a, shots):
+            qml.RX(a, wires=shots)
+            return qml.sample(qml.PauliZ(wires=0))
+
+        assert len(circuit(0.8, shots=0)) == 10
+        assert circuit.qtape.operations[0].wires.labels == (0,)
+
+    @pytest.mark.parametrize("diff_method", ["backprop", "parameter-shift"])
+    def test_shots_setting_does_not_mutate_device(self, diff_method):
+        """Tests that per-call shots setting does not change the number of shots in the device."""
+
+        dev = qml.device('default.qubit', wires=1, shots=3)
+
+        @qml.qnode(dev)
+        def circuit(a):
+            qml.RX(a, wires=0)
+            return qml.sample(qml.PauliZ(wires=0))
+
+        assert dev.shots == 3
+        res = circuit(0.8, shots=2)
+        assert len(res) == 2
+        assert dev.shots == 3