Repository: PennyLaneAI/pennylane
Pull request #3388 (2022-11-18 14:57:47)

CMP #10: Add ClassicalShadow MP class
- Add `CustomMeasurement` class, which takes a tape and a device and returns the measurement value.
- Change name from `ShadowMeasurementProcess` to `ClassicalShadow`, to be more consistent with the function `qml.classical_shadow`
- Add class `_ShadowExpval`, which contains the measurement process related to `qml.shadow_expval`
- Fix `qml.equal` to work with this new class.
- Deprecate `seed_recipes` in `qml.classical_shadow` and `qml.shadow_expval`

github-actions[bot]: Hello. You may have forgotten to update the changelog!
Please edit [`doc/releases/changelog-dev.md`](/PennyLaneAI/pennylane/blob/master/doc/releases/changelog-dev.md) with:
* A one-to-two sentence description of the change. You may include a small working example for new features.
* A link back to this PR.
* Your name (or GitHub username) in the contributors section.
codecov[bot]: # [Codecov](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3388?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
> Merging [#3388](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3388?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) (3ba4b2c) into [master](https://codecov.io/gh/PennyLaneAI/pennylane/commit/cd2a21136a7c853a9e7b6da3c9242af9020041eb?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) (cd2a211) will **decrease** coverage by `0.10%`.
> The diff coverage is `31.70%`.

```diff
@@            Coverage Diff             @@
##           master    #3388      +/-   ##
==========================================
- Coverage   99.70%   99.59%   -0.11%     
==========================================
  Files         298      298              
  Lines       26172    26203      +31     
==========================================
+ Hits        26094    26097       +3     
- Misses         78      106      +28     
```


| [Impacted Files](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3388?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) | Coverage Δ | |
|---|---|---|
| [pennylane/\_qubit\_device.py](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3388/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL19xdWJpdF9kZXZpY2UucHk=) | `99.66% <ø> (ø)` | |
| [pennylane/measurements/classical\_shadow.py](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3388/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL21lYXN1cmVtZW50cy9jbGFzc2ljYWxfc2hhZG93LnB5) | `65.00% <20.00%> (-35.00%)` | :arrow_down: |
| [pennylane/devices/default\_qubit.py](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3388/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL2RldmljZXMvZGVmYXVsdF9xdWJpdC5weQ==) | `100.00% <100.00%> (ø)` | |
| [pennylane/measurements/\_\_init\_\_.py](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3388/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL21lYXN1cmVtZW50cy9fX2luaXRfXy5weQ==) | `100.00% <100.00%> (ø)` | |
| [pennylane/ops/functions/equal.py](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3388/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL29wcy9mdW5jdGlvbnMvZXF1YWwucHk=) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI)

AlbertMitjans: Hi @eddddddy, I added a deprecation cycle for the breaking change in `qml.classical_shadow` and `qml.shadow_expval`. Can you quickly review it?

Patch:
diff --git a/doc/development/deprecations.rst b/doc/development/deprecations.rst
index 8f4331d92..401494006 100644
--- a/doc/development/deprecations.rst
+++ b/doc/development/deprecations.rst
@@ -6,6 +6,13 @@ Deprecations
 Pending deprecations
 --------------------
 
+* The ``seed_recipes`` argument in ``qml.classical_shadow`` and ``qml.shadow_expval`` is deprecated.
+  A new argument ``seed`` has been added, which defaults to ``None`` and can contain an integer with the 
+  wanted seed.
+
+  - Still accessible in v0.27
+  - Will be removed in v0.28
+
 * The ``grouping`` module is deprecated. The functionality has been moved and
   reorganized in the new ``pauli`` module under ``pauli/utils.py`` or ``pauli/grouping/``.
 
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 2a8ff8bcc..af7d81d75 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -5,6 +5,7 @@
 <h3>New features since last release</h3>
 
 * Add the controlled CZ gate: CCZ.
+
   ```pycon
   >>> ccz = qml.CCZ(wires=[0, 1, 2])
   >>> matrix = ccz.compute_matrix()
@@ -17,6 +18,7 @@
    [ 0  0  0  0  0  0  1  0]
    [ 0  0  0  0  0  0  0 -1]]
   ```
+
   [#3408](https://github.com/PennyLaneAI/pennylane/pull/3408)
 
 * Add the controlled Hadamard gate.
@@ -33,9 +35,10 @@
   [#3408](https://github.com/PennyLaneAI/pennylane/pull/3408)
 
 * Support custom measurement processes:
-  * `SampleMeasurement` and `StateMeasurement` classes have been added. They contain an abstract
-    method to process samples/quantum state.
+  * `SampleMeasurement`, `StateMeasurement` and `CustomMeasurement` classes have been added.
+    They contain an abstract method to process samples/quantum state/quantum script.
     [#3286](https://github.com/PennyLaneAI/pennylane/pull/3286)
+    [#3388](https://github.com/PennyLaneAI/pennylane/pull/3388)
 
   * Add `_Expectation` class.
     [#3343](https://github.com/PennyLaneAI/pennylane/pull/3343)
@@ -61,6 +64,12 @@
   * Add `_MutualInfo` class.
     [#3327](https://github.com/PennyLaneAI/pennylane/pull/3327)
 
+  * Add `ClassicalShadow` class.
+    [#3388](https://github.com/PennyLaneAI/pennylane/pull/3388)
+
+  * Add `_ShadowExpval` class.
+    [#3388](https://github.com/PennyLaneAI/pennylane/pull/3388)
+
 * Functionality for fetching symbols and geometry of a compound from the PubChem Database using `qchem.mol_data`.
   [(#3289)](https://github.com/PennyLaneAI/pennylane/pull/3289)
   [(#3378)](https://github.com/PennyLaneAI/pennylane/pull/3378)
@@ -231,6 +240,7 @@
   [#3400](https://github.com/PennyLaneAI/pennylane/pull/3400)
 
   Example with a single measurement:
+
   ```python
   dev = qml.device("default.qubit", wires=1, shots=[1000, 2000, 3000])
 
@@ -240,6 +250,7 @@
       qml.RX(0.2, wires=0)
       return qml.expval(qml.PauliZ(0))
   ```
+
   ```
   >>> qml.enable_return()
   >>> a = tf.Variable(0.4)
@@ -252,7 +263,9 @@
   >>> tape.jacobian(res, a)
   <tf.Tensor: shape=(3,), dtype=float64, numpy=array([-0.365     , -0.3765    , -0.37533333])>
   ```
+
   Example with multiple measurements:
+
   ```python
   dev = qml.device("default.qubit", wires=2, shots=[1000, 2000, 3000])
 
@@ -263,6 +276,7 @@
       qml.CNOT(wires=[0, 1])
       return qml.expval(qml.PauliZ(0)), qml.probs([0, 1])
   ```
+
   ```
   >>> with tf.GradientTape() as tape:
   ...     res = circuit(a)
@@ -297,6 +311,10 @@
   class which inherits from `AnnotatedQueue`.
   [(#3401)](https://github.com/PennyLaneAI/pennylane/pull/3401)
 
+* Change class name `ShadowMeasurementProcess` to `ClassicalShadow`, to be consistent with the
+  `qml.classical_shadow` function name.
+  [#3388](https://github.com/PennyLaneAI/pennylane/pull/3388)
+
 * The method `qml.Operation.get_parameter_shift` is removed. The `gradients` module should be used
   for general parameter-shift rules instead.
   [(#3419)](https://github.com/PennyLaneAI/pennylane/pull/3419)
@@ -333,6 +351,11 @@ Deprecations cycles are tracked at [doc/developement/deprecations.rst](https://d
 * `qml.transforms.measurement_grouping` has been deprecated. Use `qml.transforms.hamiltonian_expand` instead.
   [(#3417)](https://github.com/PennyLaneAI/pennylane/pull/3417)
 
+* The `seed_recipes` argument in `qml.classical_shadow` and `qml.shadow_expval` is deprecated.
+  A new argument `seed` has been added, which defaults to None and can contain an integer with the
+  wanted seed.
+  [(#3388)](https://github.com/PennyLaneAI/pennylane/pull/3388)
+
 <h3>Documentation</h3>
 
 * Adds developer documentation for the queuing module.
diff --git a/pennylane/_qubit_device.py b/pennylane/_qubit_device.py
index 132db4fa1..9f173b3fe 100644
--- a/pennylane/_qubit_device.py
+++ b/pennylane/_qubit_device.py
@@ -817,7 +817,7 @@ class QubitDevice(Device):
                         "Classical shadows cannot be returned in combination"
                         " with other return types"
                     )
-                results.append(self.classical_shadow(obs, circuit=circuit))
+                results.append(self.classical_shadow(obs, circuit))
 
             elif obs.return_type is ShadowExpval:
                 if len(observables) > 1:
@@ -1250,7 +1250,7 @@ class QubitDevice(Device):
         .. seealso:: :func:`~.classical_shadow`
 
         Args:
-            obs (~.pennylane.measurements.ShadowMeasurementProcess): The classical shadow measurement process
+            obs (~.pennylane.measurements.ClassicalShadow): The classical shadow measurement process
             circuit (~.tapes.QuantumTape): The quantum tape that is being executed
 
         Returns:
@@ -1269,13 +1269,10 @@ class QubitDevice(Device):
             n_qubits = len(wires)
             mapped_wires = np.array(self.map_wires(wires))
 
-            if seed is not None:
-                # seed the random measurement generation so that recipes
-                # are the same for different executions with the same seed
-                rng = np.random.RandomState(seed)
-                recipes = rng.randint(0, 3, size=(n_snapshots, n_qubits))
-            else:
-                recipes = np.random.randint(0, 3, size=(n_snapshots, n_qubits))
+            # seed the random measurement generation so that recipes
+            # are the same for different executions with the same seed
+            rng = np.random.RandomState(seed)
+            recipes = rng.randint(0, 3, size=(n_snapshots, n_qubits))
             obs_list = [qml.PauliX, qml.PauliY, qml.PauliZ]
 
             outcomes = np.zeros((n_snapshots, n_qubits))
@@ -1303,7 +1300,7 @@ class QubitDevice(Device):
         Please refer to :func:`~.pennylane.shadow_expval` for detailed documentation.
 
         Args:
-            obs (~.pennylane.measurements.ShadowMeasurementProcess): The classical shadow expectation
+            obs (~.pennylane.measurements.ClassicalShadow): The classical shadow expectation
                 value measurement process
             circuit (~.tapes.QuantumTape): The quantum tape that is being executed
 
diff --git a/pennylane/devices/default_qubit.py b/pennylane/devices/default_qubit.py
index 1d33b9512..7ec1384c4 100644
--- a/pennylane/devices/default_qubit.py
+++ b/pennylane/devices/default_qubit.py
@@ -18,17 +18,18 @@ It implements the necessary :class:`~pennylane._device.Device` methods as well a
 :mod:`qubit operations <pennylane.ops.qubit>`, and provides a very simple pure state
 simulation of a qubit-based quantum circuit architecture.
 """
-import itertools
 import functools
+import itertools
 from string import ascii_letters as ABC
 
 import numpy as np
 from scipy.sparse import csr_matrix
 
 import pennylane as qml
-from pennylane import QubitDevice, DeviceError, QubitStateVector, BasisState, Snapshot
+from pennylane import BasisState, DeviceError, QubitDevice, QubitStateVector, Snapshot
 from pennylane.ops.qubit.attributes import diagonal_in_z_basis
 from pennylane.wires import WireError
+
 from .._version import __version__
 
 ABC_ARRAY = np.array(list(ABC))
@@ -943,7 +944,7 @@ class DefaultQubit(QubitDevice):
         .. seealso:: :func:`~.classical_shadow`
 
         Args:
-            obs (~.pennylane.measurements.ShadowMeasurementProcess): The classical shadow measurement process
+            obs (~.pennylane.measurements.ClassicalShadow): The classical shadow measurement process
             circuit (~.tapes.QuantumTape): The quantum tape that is being executed
 
         Returns:
@@ -958,13 +959,10 @@ class DefaultQubit(QubitDevice):
         device_qubits = len(self.wires)
         mapped_wires = np.array(self.map_wires(wires))
 
-        if seed is not None:
-            # seed the random measurement generation so that recipes
-            # are the same for different executions with the same seed
-            rng = np.random.RandomState(seed)
-            recipes = rng.randint(0, 3, size=(n_snapshots, n_qubits))
-        else:
-            recipes = np.random.randint(0, 3, size=(n_snapshots, n_qubits))
+        # seed the random measurement generation so that recipes
+        # are the same for different executions with the same seed
+        rng = np.random.RandomState(seed)
+        recipes = rng.randint(0, 3, size=(n_snapshots, n_qubits))
 
         obs_list = self._stack(
             [
diff --git a/pennylane/measurements/__init__.py b/pennylane/measurements/__init__.py
index 0ef458262..5bef0e3ba 100644
--- a/pennylane/measurements/__init__.py
+++ b/pennylane/measurements/__init__.py
@@ -14,13 +14,7 @@
 """
 This module contains measurements supported by PennyLane.
 """
-from .classical_shadow import (
-    Shadow,
-    ShadowExpval,
-    ShadowMeasurementProcess,
-    classical_shadow,
-    shadow_expval,
-)
+from .classical_shadow import ClassicalShadow, Shadow, ShadowExpval, classical_shadow, shadow_expval
 from .counts import AllCounts, Counts, counts
 from .expval import Expectation, expval
 from .measurements import (
diff --git a/pennylane/measurements/classical_shadow.py b/pennylane/measurements/classical_shadow.py
index 2fc6837bf..3f584bad8 100644
--- a/pennylane/measurements/classical_shadow.py
+++ b/pennylane/measurements/classical_shadow.py
@@ -15,6 +15,8 @@
 """
 This module contains the qml.classical_shadow measurement.
 """
+import copy
+import warnings
 from collections.abc import Iterable
 
 import numpy as np
@@ -22,10 +24,10 @@ import numpy as np
 import pennylane as qml
 from pennylane.wires import Wires
 
-from .measurements import MeasurementProcess, MeasurementShapeError, Shadow, ShadowExpval
+from .measurements import CustomMeasurement, MeasurementShapeError, Shadow, ShadowExpval
 
 
-def shadow_expval(H, k=1, seed_recipes=True):
+def shadow_expval(H, k=1, seed=None, seed_recipes=True):
     r"""Compute expectation values using classical shadows in a differentiable manner.
 
     The canonical way of computing expectation values is to simply average the expectation values for each local snapshot, :math:`\langle O \rangle = \sum_t \text{tr}(\rho^{(t)}O) / T`.
@@ -36,11 +38,10 @@ def shadow_expval(H, k=1, seed_recipes=True):
         H (Union[Iterable, :class:`~.pennylane.Hamiltonian`, :class:`~.pennylane.operation.Tensor`]): Observable or
             iterable of observables to compute the expectation value over.
         k (int): Number of equal parts to split the shadow's measurements to compute the median of means. ``k=1`` (default) corresponds to simply taking the mean over all measurements.
-        seed_recipes (bool): If True, a seed will be generated that
-            is used for the randomly sampled Pauli measurements. This is to
-            ensure that the same recipes are used when a tape containing this
-            measurement is copied. Different seeds are still generated for
-            different constructed tapes.
+        seed (Union[None, int]):  Seed used to randomly sample Pauli measurements during the
+            classical shadows protocol. If None, a random seed will be generated. If a tape with
+            a ``shadow_expval`` measurement is copied, the seed will also be copied.
+            Different seeds are still generated for different constructed tapes.
 
     Returns:
         float: expectation value estimate.
@@ -83,11 +84,16 @@ def shadow_expval(H, k=1, seed_recipes=True):
     >>> qml.jacobian(qnode)(x, Hs)
     [-0.48312, -0.00198, -0.00375,  0.00168]
     """
-    seed = np.random.randint(2**30) if seed_recipes else None
-    return ShadowMeasurementProcess(ShadowExpval, H=H, seed=seed, k=k)
+    if seed_recipes is False:
+        warnings.warn(
+            "Using ``seed_recipes`` is deprecated. Please use ``seed`` instead.",
+            UserWarning,
+        )
+    seed = seed or np.random.randint(2**30)
+    return _ShadowExpval(ShadowExpval, H=H, seed=seed, k=k)
 
 
-def classical_shadow(wires, seed_recipes=True):
+def classical_shadow(wires, seed=None, seed_recipes=True):
     """
     The classical shadow measurement protocol.
 
@@ -108,11 +114,10 @@ def classical_shadow(wires, seed_recipes=True):
 
     Args:
         wires (Sequence[int]): the wires to perform Pauli measurements on
-        seed_recipes (bool): If True, a seed will be generated that
-            is used for the randomly sampled Pauli measurements. This is to
-            ensure that the same recipes are used when a tape containing this
-            measurement is copied. Different seeds are still generated for
-            different constructed tapes.
+        seed (Union[None, int]):  Seed used to randomly sample Pauli measurements during the
+            classical shadows protocol. If None, a random seed will be generated. If a tape with
+            a ``classical_shadow`` measurement is copied, the seed will also be copied.
+            Different seeds are still generated for different constructed tapes.
 
     **Example**
 
@@ -203,13 +208,17 @@ def classical_shadow(wires, seed_recipes=True):
         >>> np.all(bits1 == bits2)
         False
     """
+    if seed_recipes is False:
+        warnings.warn(
+            "Using ``seed_recipes`` is deprecated. Please use ``seed`` instead.",
+            UserWarning,
+        )
     wires = Wires(wires)
+    seed = seed or np.random.randint(2**30)
+    return ClassicalShadow(Shadow, wires=wires, seed=seed)
 
-    seed = np.random.randint(2**30) if seed_recipes else None
-    return ShadowMeasurementProcess(Shadow, wires=wires, seed=seed)
 
-
-class ShadowMeasurementProcess(MeasurementProcess):
+class ClassicalShadow(CustomMeasurement):
     """Represents a classical shadow measurement process occurring at the end of a
     quantum variational circuit.
 
@@ -219,50 +228,85 @@ class ShadowMeasurementProcess(MeasurementProcess):
     Args:
         args (tuple[Any]): Positional arguments passed to :class:`~.pennylane.measurements.MeasurementProcess`
         seed (Union[int, None]): The seed used to generate the random measurements
-        H (:class:`~.pennylane.Hamiltonian` or :class:`~.pennylane.operation.Tensor`): Observable
-            to compute the expectation value over. Only used when ``return_type`` is ``ShadowExpval``.
-        k (int): Number of equal parts to split the shadow's measurements to compute the median of means.
-            ``k=1`` corresponds to simply taking the mean over all measurements. Only used
-            when ``return_type`` is ``ShadowExpval``.
         kwargs (dict[Any, Any]): Additional keyword arguments passed to :class:`~.pennylane.measurements.MeasurementProcess`
     """
 
-    def __init__(self, *args, seed=None, H=None, k=1, **kwargs):
+    def __init__(self, *args, seed=None, **kwargs):
         self.seed = seed
-        self.H = H
-        self.k = k
         super().__init__(*args, **kwargs)
 
-    @property
-    def numeric_type(self):
-        """The Python numeric type of the measurement result.
-
-        Returns:
-            type: This is ``int`` when the return type is ``Shadow``,
-            and ``float`` when the return type is ``ShadowExpval``.
+    def process(self, tape, device):
         """
-        return int if self.return_type is Shadow else float
+        Returns the measured bits and recipes in the classical shadow protocol.
 
-    def shape(self, device=None):
-        """The expected output shape of the ShadowMeasurementProcess.
+        The protocol is described in detail in the `classical shadows paper <https://arxiv.org/abs/2002.08953>`_.
+        This measurement process returns the randomized Pauli measurements (the ``recipes``)
+        that are performed for each qubit and snapshot as an integer:
+
+        - 0 for Pauli X,
+        - 1 for Pauli Y, and
+        - 2 for Pauli Z.
+
+        It also returns the measurement results (the ``bits``); 0 if the 1 eigenvalue
+        is sampled, and 1 if the -1 eigenvalue is sampled.
+
+        The device shots are used to specify the number of snapshots. If ``T`` is the number
+        of shots and ``n`` is the number of qubits, then both the measured bits and the
+        Pauli measurements have shape ``(T, n)``.
+
+        This implementation is device-agnostic and works by executing single-shot
+        tapes containing randomized Pauli observables. Devices should override this
+        if they can offer cleaner or faster implementations.
+
+        .. seealso:: :func:`~.classical_shadow`
 
         Args:
-            device (.Device): a PennyLane device to use for determining the shape
+            tape (QuantumScript): the tape to be processed
+            device (Device): the device used to process the tape
 
         Returns:
-            tuple: the output shape; this is ``(2, T, n)`` when the return type
-            is ``Shadow``, where ``T`` is the number of device shots and ``n`` is
-            the number of measured wires, and is a scalar when the return type
-            is ``ShadowExpval``
-
-        Raises:
-            MeasurementShapeError: when a device is not provided and the return
-            type is ``Shadow``, since the output shape is dependent on the device.
+            tensor_like[int]: A tensor with shape ``(2, T, n)``, where the first row represents
+            the measured bits and the second represents the recipes used.
         """
-        # the return value of expval is always a scalar
-        if self.return_type is ShadowExpval:
-            return (1,)
+        wires = self.wires
+        n_snapshots = device.shots
+        seed = self.seed
 
+        with qml.interfaces.set_shots(device, shots=1):
+            # slow implementation but works for all devices
+            n_qubits = len(wires)
+            mapped_wires = np.array(device.map_wires(wires))
+
+            # seed the random measurement generation so that recipes
+            # are the same for different executions with the same seed
+            rng = np.random.RandomState(seed)
+            recipes = rng.randint(0, 3, size=(n_snapshots, n_qubits))
+            obs_list = [qml.PauliX, qml.PauliY, qml.PauliZ]
+
+            outcomes = np.zeros((n_snapshots, n_qubits))
+
+            for t in range(n_snapshots):
+                # compute rotations for the Pauli measurements
+                rotations = [
+                    rot
+                    for wire_idx, wire in enumerate(wires)
+                    for rot in obs_list[recipes[t][wire_idx]].compute_diagonalizing_gates(
+                        wires=wire
+                    )
+                ]
+
+                device.reset()
+                device.apply(tape.operations, rotations=tape.diagonalizing_gates + rotations)
+
+                outcomes[t] = device.generate_samples()[0][mapped_wires]
+
+        return qml.math.cast(qml.math.stack([outcomes, recipes]), dtype=np.int8)
+
+    @property
+    def numeric_type(self):
+        return int
+
+    def shape(self, device=None):
         # otherwise, the return type requires a device
         if device is None:
             raise MeasurementShapeError(
@@ -274,15 +318,67 @@ class ShadowMeasurementProcess(MeasurementProcess):
         # and the second indicate the indices of the unitaries used
         return (1, 2, device.shots, len(self.wires))
 
+    def __copy__(self):
+        return self.__class__(
+            self.return_type,
+            obs=copy.copy(self.obs),
+            seed=self.seed,
+            wires=self._wires,
+            eigvals=self._eigvals,
+        )
+
+
+class _ShadowExpval(CustomMeasurement):
+    """Measures the expectation value of an operator using the classical shadow measurement process.
+
+    This has the same arguments as the base class MeasurementProcess, plus other additional
+    arguments specific to the classical shadow protocol.
+
+    Args:
+        args (tuple[Any]): Positional arguments passed to :class:`~.pennylane.measurements.MeasurementProcess`
+        seed (Union[int, None]): The seed used to generate the random measurements
+        H (:class:`~.pennylane.Hamiltonian` or :class:`~.pennylane.operation.Tensor`): Observable
+            to compute the expectation value over.
+        k (int): Number of equal parts to split the shadow's measurements to compute the median of means.
+            ``k=1`` corresponds to simply taking the mean over all measurements.
+        kwargs (dict[Any, Any]): Additional keyword arguments passed to :class:`~.pennylane.measurements.MeasurementProcess`
+    """
+
+    def __init__(self, *args, H, seed=None, k=1, **kwargs):
+        self.seed = seed
+        self.H = H
+        self.k = k
+        super().__init__(*args, **kwargs)
+
+    def process(self, tape, device):
+        """Compute expectation values using classical shadows in a differentiable manner.
+
+        Please refer to :func:`~.pennylane.shadow_expval` for detailed documentation.
+
+        Args:
+            tape (QuantumScript): the tape to be processed
+            device (Device): the device used to process the tape
+
+        Returns:
+            float: expectation value estimate.
+        """
+        bits, recipes = qml.classical_shadow(wires=self.wires, seed=self.seed).process(tape, device)
+        shadow = qml.shadows.ClassicalShadow(bits, recipes, wire_map=self.wires.tolist())
+        return shadow.expval(self.H, self.k)
+
+    @property
+    def numeric_type(self):
+        return float
+
+    def shape(self, device=None):
+        return (1,)
+
     @property
     def wires(self):
         r"""The wires the measurement process acts on.
 
         This is the union of all the Wires objects of the measurement.
         """
-        if self.return_type is Shadow:
-            return self._wires
-
         if isinstance(self.H, Iterable):
             return Wires.all_wires([h.wires for h in self.H])
 
@@ -291,19 +387,23 @@ class ShadowMeasurementProcess(MeasurementProcess):
     def queue(self, context=qml.QueuingManager):
         """Append the measurement process to an annotated queue, making sure
         the observable is not queued"""
-        if self.H is not None:
-            Hs = [self.H] if not isinstance(self.H, Iterable) else self.H
-            for H in Hs:
-                context.update_info(H, owner=self)
-            context.append(self, owns=Hs)
-        else:
-            context.append(self)
+        Hs = self.H if isinstance(self.H, Iterable) else [self.H]
+        for H in Hs:
+            context.update_info(H, owner=self)
+        context.append(self, owns=Hs)
 
         return self
 
     def __copy__(self):
-        obj = super().__copy__()
-        obj.seed = self.seed
-        obj.H = self.H
-        obj.k = self.k
-        return obj
+        H_copy = (
+            [copy.copy(H) for H in self.H] if isinstance(self.H, Iterable) else copy.copy(self.H)
+        )
+        return self.__class__(
+            self.return_type,
+            H=H_copy,
+            k=self.k,
+            seed=self.seed,
+            obs=copy.copy(self.obs),
+            wires=self._wires,
+            eigvals=self._eigvals,
+        )
diff --git a/pennylane/measurements/measurements.py b/pennylane/measurements/measurements.py
index 7d3dba20c..a540db0f5 100644
--- a/pennylane/measurements/measurements.py
+++ b/pennylane/measurements/measurements.py
@@ -784,3 +784,15 @@ class StateMeasurement(MeasurementProcess, ABC):
             wire_order (Wires): wires determining the subspace that ``state`` acts on; a matrix of
                 dimension :math:`2^n` acts on a subspace of :math:`n` wires
         """
+
+
+class CustomMeasurement(MeasurementProcess, ABC):
+    """Custom measurement process.
+
+    Any class inheriting from this class should define its own ``process`` method, which takes a
+    device instance and a tape and returns the result of the measurement process.
+    """
+
+    @abstractmethod
+    def process(self, tape, device):
+        """Process the given tape."""
diff --git a/pennylane/ops/functions/equal.py b/pennylane/ops/functions/equal.py
index 7a7c15711..1dc8a4694 100644
--- a/pennylane/ops/functions/equal.py
+++ b/pennylane/ops/functions/equal.py
@@ -14,14 +14,17 @@
 """
 This module contains the qml.equal function.
 """
-# pylint: disable=too-many-arguments,too-many-return-statements, too-many-branches
+# pylint: disable=too-many-arguments,too-many-return-statements
+from functools import singledispatch
 from typing import Union
 
-from functools import singledispatch
 import pennylane as qml
-from pennylane.measurements import MeasurementProcess, ShadowMeasurementProcess
-from pennylane.operation import Operator, Observable, Tensor
-from pennylane.ops.qubit.hamiltonian import Hamiltonian
+from pennylane.measurements import MeasurementProcess
+from pennylane.measurements.classical_shadow import _ShadowExpval
+from pennylane.measurements.mutual_info import _MutualInfo
+from pennylane.measurements.vn_entropy import _VnEntropy
+from pennylane.operation import Observable, Operator, Tensor
+from pennylane.ops import Hamiltonian
 
 
 def equal(
@@ -233,23 +236,32 @@ def _equal_measurements(op1: MeasurementProcess, op2: MeasurementProcess, **kwar
         observables_match = op1.obs == op2.obs
     wires_match = op1.wires == op2.wires
     eigvals_match = qml.math.allequal(op1.eigvals(), op2.eigvals())
-    log_base_match = getattr(op1, "log_base", None) == getattr(op2, "log_base", None)
-
-    return (
-        return_types_match
-        and observables_match
-        and wires_match
-        and eigvals_match
-        and log_base_match
-    )
+
+    return return_types_match and observables_match and wires_match and eigvals_match
 
 
 @_equal.register
 # pylint: disable=unused-argument
-def _equal_shadow_measurements(
-    op1: ShadowMeasurementProcess, op2: ShadowMeasurementProcess, **kwargs
-):
-    """Determine whether two ShadowMeasurementProcess objects are equal"""
+def _(op1: _VnEntropy, op2: _VnEntropy, **kwargs):
+    """Determine whether two MeasurementProcess objects are equal"""
+    eq_m = _equal_measurements(op1, op2)
+    log_base_match = op1.log_base == op2.log_base
+    return eq_m and log_base_match
+
+
+@_equal.register
+# pylint: disable=unused-argument
+def _(op1: _MutualInfo, op2: _MutualInfo, **kwargs):
+    """Determine whether two MeasurementProcess objects are equal"""
+    eq_m = _equal_measurements(op1, op2)
+    log_base_match = op1.log_base == op2.log_base
+    return eq_m and log_base_match
+
+
+@_equal.register
+# pylint: disable=unused-argument
+def _equal_shadow_measurements(op1: _ShadowExpval, op2: _ShadowExpval, **kwargs):
+    """Determine whether two ClassicalShadow objects are equal"""
 
     return_types_match = op1.return_type == op2.return_type
     wires_match = op1.wires == op2.wires


Test patch:
diff --git a/tests/shadow/test_shadow_expval.py b/tests/shadow/test_shadow_expval.py
index 04e0792ad..c11de57e3 100644
--- a/tests/shadow/test_shadow_expval.py
+++ b/tests/shadow/test_shadow_expval.py
@@ -13,11 +13,13 @@
 # limitations under the License.
 """Unit tests for the classical shadows expval measurement process"""
 
-import pytest
 import copy
 
+import pytest
+
 import pennylane as qml
 from pennylane import numpy as np
+from pennylane.measurements.classical_shadow import _ShadowExpval
 
 
 def hadamard_circuit(wires, shots=10000, interface="autograd"):
@@ -102,7 +104,7 @@ class TestExpvalMeasurement:
         copied_res = copy.copy(res)
         assert type(copied_res) == type(res)
         assert copied_res.return_type == res.return_type
-        assert copied_res.H == res.H
+        assert qml.equal(copied_res.H, res.H)
         assert copied_res.k == res.k
         assert copied_res.seed == res.seed
 
@@ -140,7 +142,15 @@ class TestExpvalMeasurement:
         assert len(tape.operations) == 1
         assert tape.operations[0].name == "PauliY"
         assert len(tape.measurements) == 1
-        assert isinstance(tape.measurements[0], qml.measurements.ShadowMeasurementProcess)
+        assert isinstance(tape.measurements[0], _ShadowExpval)
+
+    def test_seed_recipes_deprecated(self):
+        """Test that using the ``seed_recipes`` argument is deprecated."""
+        with pytest.warns(
+            UserWarning,
+            match="Using ``seed_recipes`` is deprecated. Please use ``seed`` instead",
+        ):
+            qml.shadow_expval(H=qml.PauliX(0), seed_recipes=False)
 
 
 obs_hadamard = [
@@ -201,10 +211,12 @@ class TestExpvalForward:
         superposition of qubits"""
         circuit = hadamard_circuit(3, shots=100000)
         actual = circuit(obs, k=k)
+        new_actual = circuit.tape.measurements[0].process(circuit.tape, circuit.device)
 
         assert actual.shape == (len(obs_hadamard),)
         assert actual.dtype == np.float64
         assert qml.math.allclose(actual, expected, atol=1e-1)
+        assert qml.math.allclose(new_actual, expected, atol=1e-1)
 
     def test_max_entangled_expval(
         self, k=1, obs=obs_max_entangled, expected=expected_max_entangled
@@ -213,10 +225,12 @@ class TestExpvalForward:
         entangled state"""
         circuit = max_entangled_circuit(3, shots=100000)
         actual = circuit(obs, k=k)
+        new_actual = circuit.tape.measurements[0].process(circuit.tape, circuit.device)
 
         assert actual.shape == (len(obs_max_entangled),)
         assert actual.dtype == np.float64
         assert qml.math.allclose(actual, expected, atol=1e-1)
+        assert qml.math.allclose(new_actual, expected, atol=1e-1)
 
     def test_non_pauli_error(self):
         """Test that an error is raised when a non-Pauli observable is passed"""
@@ -236,10 +250,12 @@ class TestExpvalForwardInterfaces:
 
         circuit = qft_circuit(3, shots=100000, interface=interface)
         actual = circuit(obs, k=k)
+        new_actual = circuit.tape.measurements[0].process(circuit.tape, circuit.device)
 
         assert actual.shape == (len(obs_qft),)
         assert actual.dtype == torch.float64 if interface == "torch" else np.float64
         assert qml.math.allclose(actual, expected, atol=1e-1)
+        assert qml.math.allclose(new_actual, expected, atol=1e-1)
 
 
 obs_strongly_entangled = [
diff --git a/tests/shadow/test_shadow_measurement.py b/tests/shadow/test_shadow_measurement.py
index d6b4c982a..7f0e7dc91 100644
--- a/tests/shadow/test_shadow_measurement.py
+++ b/tests/shadow/test_shadow_measurement.py
@@ -14,10 +14,12 @@
 """Unit tests for the classical shadows measurement process"""
 # pylint:disable=no-self-use, import-outside-toplevel, redefined-outer-name, unpacking-non-sequence, too-few-public-methods, not-an-iterable, inconsistent-return-statements
 import copy
+
 import pytest
 
 import pennylane as qml
 from pennylane import numpy as np
+from pennylane.measurements import ClassicalShadow
 
 
 def get_circuit(wires, shots, seed_recipes, interface="autograd", device="default.qubit"):
@@ -40,7 +42,7 @@ def get_circuit(wires, shots, seed_recipes, interface="autograd", device="defaul
         for target in range(1, wires):
             qml.CNOT(wires=[0, target])
 
-        return qml.classical_shadow(wires=range(wires), seed_recipes=seed_recipes)
+        return qml.classical_shadow(wires=range(wires), seed=seed_recipes)
 
     return circuit
 
@@ -111,16 +113,16 @@ wires_list = [1, 3]
 
 
 @pytest.mark.parametrize("wires", wires_list)
-class TestShadowMeasurement:
+class TestClassicalShadow:
     """Unit tests for classical_shadow measurement"""
 
     shots_list = [1, 100]
-    seed_recipes_list = [True, False]
+    seed_recipes_list = [None, 74]  # random seed
 
     @pytest.mark.parametrize("seed", seed_recipes_list)
     def test_measurement_process_numeric_type(self, wires, seed):
         """Test that the numeric type of the MeasurementProcess instance is correct"""
-        res = qml.classical_shadow(wires=range(wires), seed_recipes=seed)
+        res = qml.classical_shadow(wires=range(wires), seed=seed)
         assert res.numeric_type == int
 
     @pytest.mark.parametrize("shots", shots_list)
@@ -128,7 +130,7 @@ class TestShadowMeasurement:
     def test_measurement_process_shape(self, wires, shots, seed):
         """Test that the shape of the MeasurementProcess instance is correct"""
         dev = qml.device("default.qubit", wires=wires, shots=shots)
-        res = qml.classical_shadow(wires=range(wires), seed_recipes=seed)
+        res = qml.classical_shadow(wires=range(wires), seed=seed)
         assert res.shape(device=dev) == (1, 2, shots, wires)
 
         # test an error is raised when device is None
@@ -153,10 +155,10 @@ class TestShadowMeasurement:
     def test_measurement_process_copy(self, wires, seed):
         """Test that the attributes of the MeasurementProcess instance are
         correctly copied"""
-        res = qml.classical_shadow(wires=range(wires), seed_recipes=seed)
+        res = qml.classical_shadow(wires=range(wires), seed=seed)
 
         copied_res = copy.copy(res)
-        assert type(copied_res) == type(res)
+        assert isinstance(copied_res, ClassicalShadow)
         assert copied_res.return_type == res.return_type
         assert copied_res.wires == res.wires
         assert copied_res.seed == res.seed
@@ -208,23 +210,34 @@ class TestShadowMeasurement:
 
         circuit = circuit_fn(wires, shots=shots, interface=interface, device=device)
         bits, recipes = circuit()
+        new_bits, new_recipes = circuit.tape.measurements[0].process(circuit.tape, circuit.device)
 
         # test that the recipes follow a rough uniform distribution
         ratios = np.unique(recipes, return_counts=True)[1] / (wires * shots)
         assert np.allclose(ratios, 1 / 3, atol=1e-1)
+        new_ratios = np.unique(new_recipes, return_counts=True)[1] / (wires * shots)
+        assert np.allclose(new_ratios, 1 / 3, atol=1e-1)
 
         # test that the bit is 0 for all X measurements
         assert qml.math.allequal(bits[recipes == basis_recipe], 0)
+        assert qml.math.allequal(new_bits[new_recipes == basis_recipe], 0)
 
         # test that the bits are uniformly distributed for all Y and Z measurements
         bits1 = bits[recipes == (basis_recipe + 1) % 3]
         ratios1 = np.unique(bits1, return_counts=True)[1] / bits1.shape[0]
         assert np.allclose(ratios1, 1 / 2, atol=1e-1)
+        new_bits1 = new_bits[new_recipes == (basis_recipe + 1) % 3]
+        new_ratios1 = np.unique(new_bits1, return_counts=True)[1] / new_bits1.shape[0]
+        assert np.allclose(new_ratios1, 1 / 2, atol=1e-1)
 
         bits2 = bits[recipes == (basis_recipe + 2) % 3]
         ratios2 = np.unique(bits2, return_counts=True)[1] / bits2.shape[0]
         assert np.allclose(ratios2, 1 / 2, atol=1e-1)
 
+        new_bits2 = new_bits[new_recipes == (basis_recipe + 2) % 3]
+        new_ratios2 = np.unique(new_bits2, return_counts=True)[1] / new_bits2.shape[0]
+        assert np.allclose(new_ratios2, 1 / 2, atol=1e-1)
+
     @pytest.mark.parametrize("seed", seed_recipes_list)
     def test_shots_none_error(self, wires, seed):
         """Test that an error is raised when a device with shots=None is used
@@ -233,7 +246,7 @@ class TestShadowMeasurement:
 
         msg = "The number of shots has to be explicitly set on the device when using sample-based measurements"
         with pytest.raises(qml.QuantumFunctionError, match=msg):
-            shadow = circuit()
+            circuit()
 
     @pytest.mark.parametrize("shots", shots_list)
     def test_multi_measurement_error(self, wires, shots):
@@ -252,4 +265,12 @@ class TestShadowMeasurement:
 
         msg = "Classical shadows cannot be returned in combination with other return types"
         with pytest.raises(qml.QuantumFunctionError, match=msg):
-            shadow = circuit()
+            circuit()
+
+    def test_seed_recipes_deprecated(self, wires):
+        """Test that using the ``seed_recipes`` argument is deprecated."""
+        with pytest.warns(
+            UserWarning,
+            match="Using ``seed_recipes`` is deprecated. Please use ``seed`` instead",
+        ):
+            qml.classical_shadow(wires=wires, seed_recipes=False)
diff --git a/tests/shadow/test_transforms.py b/tests/shadow/test_transforms.py
index f20bcaddd..9f97511d7 100644
--- a/tests/shadow/test_transforms.py
+++ b/tests/shadow/test_transforms.py
@@ -15,10 +15,12 @@
 """Unit tests for the classical shadows transforms"""
 
 import builtins
+
 import pytest
 
 import pennylane as qml
 from pennylane import numpy as np
+from pennylane.measurements.classical_shadow import _ShadowExpval
 
 
 def hadamard_circuit(wires, shots=10000, interface="autograd"):
@@ -295,7 +297,7 @@ class TestExpvalTransform:
 
         assert all(qml.equal(qml.Hadamard(i), tape.operations[i]) for i in range(3))
         assert len(tape.observables) == 1
-        assert isinstance(tape.observables[0], qml.measurements.ShadowMeasurementProcess)
+        assert isinstance(tape.observables[0], _ShadowExpval)
         assert tape.observables[0].H == obs
 
     def test_hadamard_forward(self):