Repository: PennyLaneAI/pennylane
Pull request #3597 (2023-01-05 15:51:05)

Add batching support to `SymbolicOp` classes
- Add `ScalarSymbolicOp` class, which has:
  - A method to detect if batching is used.
  - A `matrix` method that takes into account broadcasting.
  - A `_matrix` abstract method to compute the matrix without broadcasting.

codecov[bot]: # [Codecov](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3597?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) Report
> Merging [#3597](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3597?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) (be0c6fd) into [master](https://codecov.io/gh/PennyLaneAI/pennylane/commit/2c595a67d541dab4da8461ce8b6336fe25ad298e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) (2c595a6) will **decrease** coverage by `0.00%`.
> The diff coverage is `100.00%`.

```diff
@@            Coverage Diff             @@
##           master    #3597      +/-   ##
==========================================
- Coverage   99.86%   99.86%   -0.01%     
==========================================
  Files         314      314              
  Lines       28051    28046       -5     
==========================================
- Hits        28013    28008       -5     
  Misses         38       38              
```


| [Impacted Files](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3597?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI) | Coverage Δ | |
|---|---|---|
| [pennylane/operation.py](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3597?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL29wZXJhdGlvbi5weQ==) | `97.38% <100.00%> (-0.06%)` | :arrow_down: |
| [pennylane/ops/op\_math/exp.py](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3597?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL29wcy9vcF9tYXRoL2V4cC5weQ==) | `100.00% <100.00%> (ø)` | |
| [pennylane/ops/qubit/hamiltonian.py](https://codecov.io/gh/PennyLaneAI/pennylane/pull/3597?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=PennyLaneAI#diff-cGVubnlsYW5lL29wcy9xdWJpdC9oYW1pbHRvbmlhbi5weQ==) | `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: [sc-31247]
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.
AlbertMitjans: >  Could you elaborate a bit more on how you concluded that you should make this abstract class that's a child of SymbolicOp?

Right now there are 3 `SymbolicOp` that also have a scalar: `Exp`, `SProd` and `Pow`. When allowing batching, we have three possibilities:
1. base is batched
2. scalar is batched
3. both are batched (with the same batching dimension)

Thus when computing the matrix, we must take into account these 3 possibilities (add 3 `if` statements). Without the `ScalarSymbolicOp` class, we would need to check these 3 possibilities in every matrix method of every `SymbolicOp` that contains a scalar (3 for now, maybe more in the future?)

Moreover, all `ScalarSymbolicOp` children don't need to worry about batching; everything is taken care of by its parent class.
AlbertMitjans: > should we test other interfaces? in general i'm not concerned, but there is a method explicitly checking for torch compatibility issues...

Good point! Will add checks with other interfaces

Patch:
diff --git a/doc/releases/changelog-dev.md b/doc/releases/changelog-dev.md
index 6bb05a3f6..d7614a733 100644
--- a/doc/releases/changelog-dev.md
+++ b/doc/releases/changelog-dev.md
@@ -379,6 +379,9 @@
 * The `StatePrep` class has been added as an interface that state-prep operators must implement.
   [(#3654)](https://github.com/PennyLaneAI/pennylane/pull/3654)
 
+* Allow batching in all `SymbolicOp` operators, which include `Exp`, `Pow` and `SProd`.
+  [(#3597)](https://github.com/PennyLaneAI/pennylane/pull/3597)
+
 * `qml.pauli.is_pauli_word` now supports `Prod` and `SProd` operators, and it returns `False` when a
   `Hamiltonian` contains more than one term.
   [(#3692)](https://github.com/PennyLaneAI/pennylane/pull/3692)
diff --git a/pennylane/operation.py b/pennylane/operation.py
index 0175f5262..8b71fdf1e 100644
--- a/pennylane/operation.py
+++ b/pennylane/operation.py
@@ -221,10 +221,10 @@ import warnings
 from enum import IntEnum
 from typing import List
 
+import autoray
 import numpy as np
 from numpy.linalg import multi_dot
 from scipy.sparse import coo_matrix, eye, kron
-import autoray
 import pennylane as qml
 from pennylane.math import expand_matrix
 from pennylane.queuing import QueuingManager
@@ -1412,9 +1412,9 @@ class Operator(abc.ABC):
         if isinstance(other, qml.pulse.ParametrizedHamiltonian):
             return other.__add__(self)
         backend = autoray.infer_backend(other)
-        if (backend == "builtins" and isinstance(other, numbers.Number)) or (
-            backend in SUPPORTED_INTERFACES and qml.math.shape(other) == ()
-        ):
+        if (
+            backend == "builtins" and isinstance(other, numbers.Number)
+        ) or backend in SUPPORTED_INTERFACES:
             return qml.sum(self, qml.s_prod(scalar=other, operator=qml.Identity(self.wires)))
         return NotImplemented
 
@@ -1425,9 +1425,9 @@ class Operator(abc.ABC):
         if callable(other):
             return qml.pulse.ParametrizedHamiltonian([other], [self])
         backend = autoray.infer_backend(other)
-        if (backend == "builtins" and isinstance(other, numbers.Number)) or (
-            backend in SUPPORTED_INTERFACES and qml.math.shape(other) == ()
-        ):
+        if (
+            backend == "builtins" and isinstance(other, numbers.Number)
+        ) or backend in SUPPORTED_INTERFACES:
             return qml.s_prod(scalar=other, operator=self)
         return NotImplemented
 
@@ -1453,7 +1453,10 @@ class Operator(abc.ABC):
 
     def __pow__(self, other):
         r"""The power operation of an Operator object."""
-        if isinstance(other, numbers.Number):
+        backend = autoray.infer_backend(other)
+        if (
+            backend == "builtins" and isinstance(other, numbers.Number)
+        ) or backend in SUPPORTED_INTERFACES:
             return qml.pow(self, z=other)
         return NotImplemented
 
diff --git a/pennylane/ops/op_math/__init__.py b/pennylane/ops/op_math/__init__.py
index e3585159a..cd5dc5e74 100644
--- a/pennylane/ops/op_math/__init__.py
+++ b/pennylane/ops/op_math/__init__.py
@@ -49,6 +49,7 @@ Symbolic Classes
     ~Sum
     ~SProd
     ~SymbolicOp
+    ~ScalarSymbolicOp
 
 Controlled Operator Classes
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -68,8 +69,8 @@ from .exp import Evolution, Exp, exp
 from .pow import Pow, pow
 from .prod import Prod, prod
 from .sprod import SProd, s_prod
-from .sum import Sum, sum, op_sum
-from .symbolicop import SymbolicOp
+from .sum import Sum, op_sum, sum
+from .symbolicop import ScalarSymbolicOp, SymbolicOp
 
 controlled_qubit_ops = {
     "ControlledQubitUnitary",
diff --git a/pennylane/ops/op_math/adjoint.py b/pennylane/ops/op_math/adjoint.py
index e2720ab20..faa021aef 100644
--- a/pennylane/ops/op_math/adjoint.py
+++ b/pennylane/ops/op_math/adjoint.py
@@ -46,6 +46,20 @@ def adjoint(fn, lazy=True):
 
         The adjoint and inverse are identical for unitary gates, but not in general. For example, quantum channels and observables may have different adjoint and inverse operators.
 
+    .. note::
+
+        This function supports a batched operator:
+
+        >>> op = qml.adjoint(qml.RX([1, 2, 3], wires=0))
+        >>> qml.matrix(op).shape
+        (3, 2, 2)
+
+        But it doesn't support batching of operators:
+
+        >>> op = qml.adjoint([qml.RX(1, wires=0), qml.RX(2, wires=0)])
+        ValueError: The object [RX(1, wires=[0]), RX(2, wires=[0])] of type <class 'list'> is not callable.
+        This error might occur if you apply adjoint to a list of operations instead of a function or template.
+
     .. seealso:: :class:`~.ops.op_math.Adjoint` and :meth:`.Operator.adjoint`
 
     **Example**
diff --git a/pennylane/ops/op_math/composite.py b/pennylane/ops/op_math/composite.py
index 6d564dedf..333334c57 100644
--- a/pennylane/ops/op_math/composite.py
+++ b/pennylane/ops/op_math/composite.py
@@ -65,16 +65,16 @@ class CompositeOp(Operator):
         if do_queue:
             self.queue()
 
-        self._set_batch_size()
+        self._batch_size = self._check_and_compute_batch_size()
 
-    def _set_batch_size(self):
+    def _check_and_compute_batch_size(self):
         batch_sizes = {op.batch_size for op in self if op.batch_size is not None}
         if len(batch_sizes) > 1:
             raise ValueError(
                 "Broadcasting was attempted but the broadcasted dimensions "
                 f"do not match: {batch_sizes}."
             )
-        self._batch_size = batch_sizes.pop() if batch_sizes else None
+        return batch_sizes.pop() if batch_sizes else None
 
     def __repr__(self):
         return f" {self._op_symbol} ".join(
diff --git a/pennylane/ops/op_math/exp.py b/pennylane/ops/op_math/exp.py
index da44c8efc..52628a37f 100644
--- a/pennylane/ops/op_math/exp.py
+++ b/pennylane/ops/op_math/exp.py
@@ -16,22 +16,23 @@ This submodule defines the symbolic operation that stands for an exponential of
 """
 from copy import copy
 from warnings import warn
-from scipy.sparse.linalg import expm as sparse_expm
+
 import numpy as np
+from scipy.sparse.linalg import expm as sparse_expm
 
 import pennylane as qml
 from pennylane import math
 from pennylane.operation import (
     DecompositionUndefinedError,
-    expand_matrix,
-    OperatorPropertyUndefined,
     GeneratorUndefinedError,
+    Operation,
+    OperatorPropertyUndefined,
     Tensor,
+    expand_matrix,
 )
 from pennylane.wires import Wires
-from pennylane.operation import Operation
 
-from .symbolicop import SymbolicOp
+from .symbolicop import ScalarSymbolicOp
 
 
 def exp(op, coeff=1, id=None):
@@ -44,6 +45,25 @@ def exp(op, coeff=1, id=None):
     Returns:
        :class:`Exp`: A :class`~.operation.Operator` representing an operator exponential.
 
+    .. note::
+
+        This operator supports a batched base, a batched coefficient and a combination of both:
+
+        >>> op = qml.exp(qml.RX([1, 2, 3], wires=0), coeff=4)
+        >>> qml.matrix(op).shape
+        (3, 2, 2)
+        >>> op = qml.exp(qml.RX(1, wires=0), coeff=[1, 2, 3])
+        >>> qml.matrix(op).shape
+        (3, 2, 2)
+        >>> op = qml.exp(qml.RX([1, 2, 3], wires=0), coeff=[4, 5, 6])
+        >>> qml.matrix(op).shape
+        (3, 2, 2)
+
+        But it doesn't support batching of operators:
+
+        >>> op = qml.exp([qml.RX(1, wires=0), qml.RX(2, wires=0)], coeff=4)
+        AttributeError: 'list' object has no attribute 'batch_size'
+
     **Example**
 
     This symbolic operator can be used to make general rotation operators:
@@ -85,7 +105,7 @@ def exp(op, coeff=1, id=None):
     return Exp(op, coeff, id=id)
 
 
-class Exp(SymbolicOp, Operation):
+class Exp(ScalarSymbolicOp, Operation):
     """A symbolic operator representating the exponential of a operator.
 
     Args:
@@ -132,10 +152,10 @@ class Exp(SymbolicOp, Operation):
     """
 
     control_wires = Wires([])
+    _name = "Exp"
 
     def __init__(self, base=None, coeff=1, do_queue=True, id=None):
-        super().__init__(base, do_queue=do_queue, id=id)
-        self._name = "Exp"
+        super().__init__(base, scalar=coeff, do_queue=do_queue, id=id)
         self._data = [[coeff], self.base.data]
         self.grad_recipe = [None]
 
@@ -174,7 +194,7 @@ class Exp(SymbolicOp, Operation):
     @property
     def coeff(self):
         """The numerical coefficient of the operator in the exponent."""
-        return self.data[0][0]
+        return self.scalar
 
     @property
     def num_params(self):
@@ -182,11 +202,11 @@ class Exp(SymbolicOp, Operation):
 
     @property
     def is_hermitian(self):
-        return self.base.is_hermitian and math.imag(self.coeff) == 0
+        return self.base.is_hermitian and math.allequal(math.imag(self.coeff), 0)
 
     @property
     def _queue_category(self):
-        if self.base.is_hermitian and math.real(self.coeff) == 0:
+        if self.base.is_hermitian and math.allequal(math.real(self.coeff), 0):
             return "_ops"
         return None
 
@@ -227,20 +247,23 @@ class Exp(SymbolicOp, Operation):
         return [qml.PauliRot(new_coeff, string_base, wires=self.wires)]
 
     def matrix(self, wire_order=None):
-
-        coeff_interface = math.get_interface(self.coeff)
-        if coeff_interface == "autograd" and math.requires_grad(self.coeff):
+        coeff_interface = math.get_interface(self.scalar)
+        if coeff_interface == "autograd" and math.requires_grad(self.scalar):
             # math.expm is not differentiable with autograd
             # So we try to do a differentiable construction if possible
             #
             # This won't catch situations when the base matrix is autograd,
             # but at least this provides as much trainablility as possible
             try:
+                eigvals = self.eigvals()
+                eigvals_mat = (
+                    math.stack(math.diag(e) for e in eigvals)
+                    if qml.math.ndim(self.scalar) > 0
+                    else math.diag(eigvals)
+                )
                 if len(self.diagonalizing_gates()) == 0:
-                    eigvals_mat = math.diag(self.eigvals())
                     return expand_matrix(eigvals_mat, wires=self.wires, wire_order=wire_order)
                 diagonalizing_mat = qml.matrix(self.diagonalizing_gates, wire_order=self.wires)()
-                eigvals_mat = math.diag(self.eigvals())
                 mat = diagonalizing_mat.conj().T @ eigvals_mat @ diagonalizing_mat
                 return expand_matrix(mat, wires=self.wires, wire_order=wire_order)
             except OperatorPropertyUndefined:
@@ -249,15 +272,11 @@ class Exp(SymbolicOp, Operation):
                     "Use a different interface if you need backpropagation.",
                     UserWarning,
                 )
-        base_mat = (
-            qml.matrix(self.base) if isinstance(self.base, qml.Hamiltonian) else self.base.matrix()
-        )
-        if coeff_interface == "torch":
-            # other wise get `RuntimeError: Can't call numpy() on Tensor that requires grad.`
-            base_mat = math.convert_like(base_mat, self.coeff)
-        mat = math.expm(self.coeff * base_mat)
+        return super().matrix(wire_order=wire_order)
 
-        return expand_matrix(mat, wires=self.wires, wire_order=wire_order)
+    @staticmethod
+    def _matrix(scalar, mat):
+        return math.expm(scalar * mat)
 
     # pylint: disable=arguments-differ
     def sparse_matrix(self, wire_order=None, format="csr"):
@@ -292,6 +311,9 @@ class Exp(SymbolicOp, Operation):
         """
         base_eigvals = math.convert_like(self.base.eigvals(), self.coeff)
         base_eigvals = math.cast_like(base_eigvals, self.coeff)
+        if qml.math.ndim(self.scalar) > 0:
+            # exp coeff is broadcasted
+            return qml.math.stack([qml.math.exp(c * base_eigvals) for c in self.coeff])
         return qml.math.exp(self.coeff * base_eigvals)
 
     def label(self, decimals=None, base_label=None, cache=None):
@@ -381,9 +403,11 @@ class Evolution(Exp):
 
     """
 
+    _name = "Evolution"
+    num_params = 1
+
     def __init__(self, generator, param, do_queue=True, id=None):
         super().__init__(generator, coeff=1j * param, do_queue=do_queue, id=id)
-        self._name = "Evolution"
         self._data = [param]
 
     @property
@@ -395,10 +419,6 @@ class Evolution(Exp):
     def coeff(self):
         return 1j * self.data[0]
 
-    @property
-    def num_params(self):
-        return 1
-
     def label(self, decimals=None, base_label=None, cache=None):
         param = (
             self.data[0]
diff --git a/pennylane/ops/op_math/pow.py b/pennylane/ops/op_math/pow.py
index 0ce114668..a794cc487 100644
--- a/pennylane/ops/op_math/pow.py
+++ b/pennylane/ops/op_math/pow.py
@@ -30,9 +30,8 @@ from pennylane.operation import (
 )
 from pennylane.ops.identity import Identity
 from pennylane.queuing import QueuingManager, apply
-from pennylane.wires import Wires
 
-from .symbolicop import SymbolicOp
+from .symbolicop import ScalarSymbolicOp, SymbolicOp
 
 _superscript = str.maketrans("0123456789.+-", "⁰¹²³⁴⁵⁶⁷⁸⁹⋅⁺⁻")
 
@@ -55,6 +54,25 @@ def pow(base, z=1, lazy=True, do_queue=True, id=None):
     Returns:
         Operator
 
+    .. note::
+
+        This operator supports a batched base, a batched coefficient and a combination of both:
+
+        >>> op = qml.pow(qml.RX([1, 2, 3], wires=0), z=4)
+        >>> qml.matrix(op).shape
+        (3, 2, 2)
+        >>> op = qml.pow(qml.RX(1, wires=0), z=[1, 2, 3])
+        >>> qml.matrix(op).shape
+        (3, 2, 2)
+        >>> op = qml.pow(qml.RX([1, 2, 3], wires=0), z=[4, 5, 6])
+        >>> qml.matrix(op).shape
+        (3, 2, 2)
+
+        But it doesn't support batching of operators:
+
+        >>> op = qml.pow([qml.RX(1, wires=0), qml.RX(2, wires=0)], z=4)
+        AttributeError: 'list' object has no attribute 'name'
+
     .. seealso:: :class:`~.Pow`, :meth:`~.Operator.pow`.
 
     **Example**
@@ -128,7 +146,7 @@ class PowOperation(Operation):
         return self.base.control_wires
 
 
-class Pow(SymbolicOp):
+class Pow(ScalarSymbolicOp):
     """Symbolic operator denoting an operator raised to a power.
 
     Args:
@@ -197,7 +215,7 @@ class Pow(SymbolicOp):
         self.hyperparameters["z"] = z
         self._name = f"{base.name}**{z}"
 
-        super().__init__(base, do_queue=do_queue, id=id)
+        super().__init__(base, scalar=z, do_queue=do_queue, id=id)
 
         if isinstance(self.z, int) and self.z > 0:
             if (
@@ -237,21 +255,11 @@ class Pow(SymbolicOp):
             f"({base_label}){z_string}" if self.base.arithmetic_depth > 0 else base_label + z_string
         )
 
-    def matrix(self, wire_order=None):
-        if isinstance(self.base, qml.Hamiltonian):
-            base_matrix = qml.matrix(self.base)
-        else:
-            base_matrix = self.base.matrix()
-
-        if isinstance(self.z, int):
-            mat = qmlmath.linalg.matrix_power(base_matrix, self.z)
-        else:
-            mat = fractional_matrix_power(base_matrix, self.z)
-
-        if wire_order is None or self.wires == Wires(wire_order):
-            return mat
-
-        return qml.math.expand_matrix(mat, wires=self.wires, wire_order=wire_order)
+    @staticmethod
+    def _matrix(scalar, mat):
+        if isinstance(scalar, int):
+            return qmlmath.linalg.matrix_power(mat, scalar)
+        return fractional_matrix_power(mat, scalar)
 
     # pylint: disable=arguments-differ
     @staticmethod
diff --git a/pennylane/ops/op_math/prod.py b/pennylane/ops/op_math/prod.py
index 13d19a3b7..99e64518a 100644
--- a/pennylane/ops/op_math/prod.py
+++ b/pennylane/ops/op_math/prod.py
@@ -61,6 +61,19 @@ def prod(*ops, do_queue=True, id=None, lazy=True):
     Returns:
         ~ops.op_math.Prod: the operator representing the product.
 
+    .. note::
+
+        This operator supports batched operands:
+
+        >>> op = qml.prod(qml.RX(np.array([1, 2, 3]), wires=0), qml.PauliX(1))
+        >>> op.matrix().shape
+        (3, 4, 4)
+
+        But it doesn't support batching of operators:
+
+        >>> op = qml.prod(np.array([qml.RX(0.5, 0), qml.RZ(0.3, 0)]), qml.PauliZ(0))
+        AttributeError: 'numpy.ndarray' object has no attribute 'wires'
+
     .. seealso:: :class:`~.ops.op_math.Prod`
 
     **Example**
@@ -99,17 +112,6 @@ class Prod(CompositeOp):
         do_queue (bool): determines if the product operator will be queued. Default is True.
         id (str or None): id for the product operator. Default is None.
 
-    .. note::
-
-        This operator supports batched operands:
-        >>> op = qml.prod(qml.RX(np.array([1, 2, 3]), wires=0), qml.PauliX(1))
-        >>> op.matrix().shape
-        (3, 4, 4)
-
-        But it doesn't support batching of operators:
-        >>> op = qml.prod(np.array([qml.RX(0.5, 0), qml.RZ(0.3, 0)]), qml.PauliZ(0))
-        AttributeError: 'numpy.ndarray' object has no attribute 'wires'
-
     .. seealso:: :func:`~.ops.op_math.prod`
 
     **Example**
diff --git a/pennylane/ops/op_math/sprod.py b/pennylane/ops/op_math/sprod.py
index fa23d55f3..bef8a48d0 100644
--- a/pennylane/ops/op_math/sprod.py
+++ b/pennylane/ops/op_math/sprod.py
@@ -17,7 +17,6 @@ computing the scalar product of operations.
 """
 from typing import Union
 
-
 import pennylane as qml
 import pennylane.math as qnp
 from pennylane.operation import Operator
@@ -25,7 +24,7 @@ from pennylane.ops.op_math.pow import Pow
 from pennylane.ops.op_math.sum import Sum
 from pennylane.queuing import QueuingManager
 
-from .symbolicop import SymbolicOp
+from .symbolicop import ScalarSymbolicOp
 
 
 def s_prod(scalar, operator, lazy=True, do_queue=True, id=None):
@@ -43,6 +42,25 @@ def s_prod(scalar, operator, lazy=True, do_queue=True, id=None):
     Returns:
         ~ops.op_math.SProd: The operator representing the scalar product.
 
+    .. note::
+
+        This operator supports a batched base, a batched coefficient and a combination of both:
+
+        >>> op = qml.s_prod(scalar=4, operator=qml.RX([1, 2, 3], wires=0))
+        >>> qml.matrix(op).shape
+        (3, 2, 2)
+        >>> op = qml.s_prod(scalar=[1, 2, 3], operator=qml.RX(1, wires=0))
+        >>> qml.matrix(op).shape
+        (3, 2, 2)
+        >>> op = qml.s_prod(scalar=[4, 5, 6], operator=qml.RX([1, 2, 3], wires=0))
+        >>> qml.matrix(op).shape
+        (3, 2, 2)
+
+        But it doesn't support batching of operators:
+
+        >>> op = qml.s_prod(scalar=4, operator=[qml.RX(1, wires=0), qml.RX(2, wires=0)])
+        AttributeError: 'list' object has no attribute 'batch_size'
+
     .. seealso:: :class:`~.ops.op_math.SProd` and :class:`~.ops.op_math.SymbolicOp`
 
     **Example**
@@ -65,7 +83,7 @@ def s_prod(scalar, operator, lazy=True, do_queue=True, id=None):
     return sprod_op
 
 
-class SProd(SymbolicOp):
+class SProd(ScalarSymbolicOp):
     r"""Arithmetic operator representing the scalar product of an
     operator with the given scalar.
 
@@ -117,13 +135,10 @@ class SProd(SymbolicOp):
     _name = "SProd"
 
     def __init__(self, scalar: Union[int, float, complex], base: Operator, do_queue=True, id=None):
-        self.scalar = scalar
-        super().__init__(base=base, do_queue=do_queue, id=id)
+        super().__init__(base=base, scalar=scalar, do_queue=do_queue, id=id)
 
         if base_pauli_rep := getattr(self.base, "_pauli_rep", None):
-            pr = {}
-            for pw, coeff in base_pauli_rep.items():
-                pr[pw] = qnp.dot(coeff, self.scalar)  # to support dispatching over interfaces
+            pr = {pw: qnp.dot(coeff, self.scalar) for pw, coeff in base_pauli_rep.items()}
             self._pauli_rep = qml.pauli.PauliSentence(pr)
         else:
             self._pauli_rep = None
@@ -218,30 +233,12 @@ class SProd(SymbolicOp):
     def has_matrix(self):
         return isinstance(self.base, qml.Hamiltonian) or self.base.has_matrix
 
-    def matrix(self, wire_order=None):
-        r"""Representation of the operator as a matrix in the computational basis.
-
-        If ``wire_order`` is provided, the numerical representation considers the position of the
-        operator's wires in the global wire order. Otherwise, the wire order defaults to the
-        operator's wires.
-
-        If the matrix depends on trainable parameters, the result
-        will be cast in the same autodifferentiation framework as the parameters.
-
-        A ``MatrixUndefinedError`` is raised if the base matrix representation has not been defined.
-
-        .. seealso:: :meth:`~.Operator.compute_matrix`
-
-        Args:
-            wire_order (Iterable): global wire order, must contain all wire labels from the
-            operator's wires
-
-        Returns:
-            tensor_like: matrix representation
-        """
-        if isinstance(self.base, qml.Hamiltonian):
-            return self.scalar * qml.matrix(self.base, wire_order=wire_order)
-        return self.scalar * self.base.matrix(wire_order=wire_order)
+    @staticmethod
+    def _matrix(scalar, mat):
+        if qml.math.get_interface(scalar) == "tensorflow":
+            # we must cast ``scalar`` to complex to avoid an error
+            scalar = qml.math.cast_like(scalar, mat)
+        return scalar * mat
 
     @property
     def _queue_category(self):  # don't queue scalar prods as they might not be Unitary!
diff --git a/pennylane/ops/op_math/sum.py b/pennylane/ops/op_math/sum.py
index a9833e505..a89902463 100644
--- a/pennylane/ops/op_math/sum.py
+++ b/pennylane/ops/op_math/sum.py
@@ -57,6 +57,19 @@ def sum(*summands, do_queue=True, id=None, lazy=True):
     Returns:
         ~ops.op_math.Sum: The operator representing the sum of summands.
 
+    .. note::
+
+        This operator supports batched operands:
+
+        >>> op = qml.op_sum(qml.RX(np.array([1, 2, 3]), wires=0), qml.PauliX(1))
+        >>> op.matrix().shape
+        (3, 4, 4)
+
+        But it doesn't support batching of operators:
+
+        >>> op = qml.op_sum(np.array([qml.RX(0.4, 0), qml.RZ(0.3, 0)]), qml.PauliZ(0))
+        AttributeError: 'numpy.ndarray' object has no attribute 'wires'
+
     .. seealso:: :class:`~.ops.op_math.Sum`
 
     **Example**
diff --git a/pennylane/ops/op_math/symbolicop.py b/pennylane/ops/op_math/symbolicop.py
index 1b8b6bfd7..2713fd9ca 100644
--- a/pennylane/ops/op_math/symbolicop.py
+++ b/pennylane/ops/op_math/symbolicop.py
@@ -14,8 +14,12 @@
 """
 This submodule defines a base class for symbolic operations representing operator math.
 """
+from abc import abstractmethod
 from copy import copy
 
+import numpy as np
+
+import pennylane as qml
 from pennylane.operation import Operator
 from pennylane.queuing import QueuingManager
 
@@ -24,14 +28,14 @@ class SymbolicOp(Operator):
     """Developer-facing base class for single-operator symbolic operators.
 
     Args:
-        base (~.operation.Operator): The base operation that is modified symbolicly
+        base (~.operation.Operator): the base operation that is modified symbolicly
         do_queue (bool): indicates whether the operator should be
             recorded when created in a tape context
         id (str): custom label given to an operator instance,
             can be useful for some applications where the instance has to be identified
 
     This *developer-facing* class can serve as a parent to single base symbolic operators, such as
-    :class:`~.ops.op_math.Adjoint` and :class:`~.ops.op_math.Pow`.
+    :class:`~.ops.op_math.Adjoint`.
 
     New symbolic operators can inherit from this class to receive some common default behavior, such
     as deferring properties to the base class, copying the base class during a shallow copy, and
@@ -62,7 +66,7 @@ class SymbolicOp(Operator):
         return copied_op
 
     # pylint: disable=super-init-not-called
-    def __init__(self, base=None, do_queue=True, id=None):
+    def __init__(self, base, do_queue=True, id=None):
         self.hyperparameters["base"] = base
         self._id = id
         self.queue_idx = None
@@ -136,3 +140,111 @@ class SymbolicOp(Operator):
         new_op = copy(self)
         new_op.hyperparameters["base"] = self.base.map_wires(wire_map=wire_map)
         return new_op
+
+
+class ScalarSymbolicOp(SymbolicOp):
+    """Developer-facing base class for single-operator symbolic operators that contain a
+    scalar coefficient.
+
+    Args:
+        base (~.operation.Operator): the base operation that is modified symbolicly
+        scalar (float): the scalar coefficient
+        do_queue (bool): indicates whether the operator should be recorded when created in a tape
+            context
+        id (str): custom label given to an operator instance, can be useful for some applications
+            where the instance has to be identified
+
+    This *developer-facing* class can serve as a parent to single base symbolic operators, such as
+    :class:`~.ops.op_math.SProd` and :class:`~.ops.op_math.Pow`.
+    """
+
+    _name = "ScalarSymbolicOp"
+
+    def __init__(self, base, scalar: float, do_queue=True, id=None):
+        self.scalar = np.array(scalar) if isinstance(scalar, list) else scalar
+        super().__init__(base, do_queue=do_queue, id=id)
+        self._batch_size = self._check_and_compute_batch_size(scalar)
+
+    @property
+    def batch_size(self):
+        return self._batch_size
+
+    def _check_and_compute_batch_size(self, scalar):
+        batched_scalar = qml.math.ndim(scalar) > 0
+        scalar_size = qml.math.size(scalar)
+        if not batched_scalar:
+            # coeff is not batched
+            return self.base.batch_size
+        # coeff is batched
+        if self.base.batch_size is not None and self.base.batch_size != scalar_size:
+            raise ValueError(
+                "Broadcasting was attempted but the broadcasted dimensions "
+                f"do not match: {scalar_size}, {self.base.batch_size}."
+            )
+        return scalar_size
+
+    @staticmethod
+    @abstractmethod
+    def _matrix(scalar, mat):
+        """Scalar-matrix operation that doesn't take into account batching.
+
+        ``ScalarSymbolicOp.matrix`` will call this method to compute the matrix for a single scalar
+        and base matrix.
+
+        Args:
+            scalar (Union[int, float]): non-broadcasted scalar
+            mat (ndarray): non-broadcasted matrix
+        """
+
+    def matrix(self, wire_order=None):
+        r"""Representation of the operator as a matrix in the computational basis.
+
+        If ``wire_order`` is provided, the numerical representation considers the position of the
+        operator's wires in the global wire order. Otherwise, the wire order defaults to the
+        operator's wires.
+
+        If the matrix depends on trainable parameters, the result
+        will be cast in the same autodifferentiation framework as the parameters.
+
+        A ``MatrixUndefinedError`` is raised if the base matrix representation has not been defined.
+
+        .. seealso:: :meth:`~.Operator.compute_matrix`
+
+        Args:
+            wire_order (Iterable): global wire order, must contain all wire labels from the
+            operator's wires
+
+        Returns:
+            tensor_like: matrix representation
+        """
+        # compute base matrix
+        if isinstance(self.base, qml.Hamiltonian):
+            base_matrix = qml.matrix(self.base)
+        else:
+            base_matrix = self.base.matrix()
+
+        scalar_interface = qml.math.get_interface(self.scalar)
+        if scalar_interface == "torch":
+            # otherwise get `RuntimeError: Can't call numpy() on Tensor that requires grad.`
+            base_matrix = qml.math.convert_like(base_matrix, self.scalar)
+        elif scalar_interface == "tensorflow" and not qml.math.iscomplex(self.scalar):
+            # cast scalar to complex to avoid an error
+            self.scalar = qml.math.cast_like(self.scalar, base_matrix)
+
+        # compute scalar operation on base matrix taking batching into account
+        scalar_size = qml.math.size(self.scalar)
+        if scalar_size != 1:
+            if scalar_size == self.base.batch_size:
+                # both base and scalar are broadcasted
+                mat = qml.math.stack([self._matrix(s, m) for s, m in zip(self.scalar, base_matrix)])
+            else:
+                # only scalar is broadcasted
+                mat = qml.math.stack([self._matrix(s, base_matrix) for s in self.scalar])
+        elif self.base.batch_size is not None:
+            # only base is broadcasted
+            mat = qml.math.stack([self._matrix(self.scalar, ar2) for ar2 in base_matrix])
+        else:
+            # none are broadcasted
+            mat = self._matrix(self.scalar, base_matrix)
+
+        return qml.math.expand_matrix(mat, wires=self.wires, wire_order=wire_order)
diff --git a/pennylane/pulse/parametrized_evolution.py b/pennylane/pulse/parametrized_evolution.py
index 573ca91de..03d6aa136 100644
--- a/pennylane/pulse/parametrized_evolution.py
+++ b/pennylane/pulse/parametrized_evolution.py
@@ -183,12 +183,12 @@ class ParametrizedEvolution(Operation):
         if t is None:
             self.t = None
         else:
-            self.t = jnp.array([0, t] if qml.math.size(t) == 1 else t, dtype=float)
+            self.t = jnp.array([0, t] if qml.math.ndim(t) == 0 else t, dtype=float)
         super().__init__(wires=H.wires, do_queue=do_queue, id=id)
 
     def __call__(self, params, t, **odeint_kwargs):
         self.params = params
-        self.t = jnp.array([0, t] if qml.math.size(t) == 1 else t, dtype=float)
+        self.t = jnp.array([0, t] if qml.math.ndim(t) == 0 else t, dtype=float)
         if odeint_kwargs:
             self.odeint_kwargs.update(odeint_kwargs)
         return self


Test patch:
diff --git a/tests/ops/op_math/test_adjoint.py b/tests/ops/op_math/test_adjoint.py
index 33ad75703..5144f6530 100644
--- a/tests/ops/op_math/test_adjoint.py
+++ b/tests/ops/op_math/test_adjoint.py
@@ -28,6 +28,7 @@ class TestInheritanceMixins:
         """Test when base directly inherits from Operator, Adjoint only inherits
         from Adjoint and Operator."""
 
+        # pylint: disable=too-few-public-methods
         class Tester(qml.operation.Operator):
             num_wires = 1
 
@@ -46,7 +47,7 @@ class TestInheritanceMixins:
     def test_operation(self):
         """When the operation inherits from `Operation`, the `AdjointOperation` mixin is
         added and the Adjoint has Operation functionality."""
-
+        # pylint: disable=too-few-public-methods
         class CustomOp(qml.operation.Operation):
             num_wires = 1
             num_params = 1
@@ -66,7 +67,7 @@ class TestInheritanceMixins:
 
     def test_observable(self):
         """Test that when the base is an Observable, Adjoint will also inherit from Observable."""
-
+        # pylint: disable=too-few-public-methods
         class CustomObs(qml.operation.Observable):
             num_wires = 1
             num_params = 0
@@ -211,7 +212,7 @@ class TestProperties:
     def test_has_decomposition_true_via_base_decomposition(self):
         """Test `has_decomposition` property is activated because the base operation defines a
         `decomposition` method."""
-
+        # pylint: disable=too-few-public-methods
         class MyOp(qml.operation.Operation):
             num_wires = 1
 
@@ -226,7 +227,7 @@ class TestProperties:
     def test_has_decomposition_false(self):
         """Test `has_decomposition` property is not activated if the base neither
         `has_adjoint` nor `has_decomposition`."""
-
+        # pylint: disable=too-few-public-methods
         class MyOp(qml.operation.Operation):
             num_wires = 1
 
@@ -237,7 +238,7 @@ class TestProperties:
 
     def test_has_adjoint_true_always(self):
         """Test `has_adjoint` property to always be true, irrespective of the base."""
-
+        # pylint: disable=too-few-public-methods
         class MyOp(qml.operation.Operation):
             """Operation that does not define `adjoint` and hence has `has_adjoint=False`."""
 
@@ -266,7 +267,7 @@ class TestProperties:
     def test_has_diagonalizing_gates_false(self):
         """Test `has_diagonalizing_gates` property is not activated if the base neither
         `has_adjoint` nor `has_diagonalizing_gates`."""
-
+        # pylint: disable=too-few-public-methods
         class MyOp(qml.operation.Operation):
             num_wires = 1
             has_diagonalizing_gates = False
@@ -278,17 +279,17 @@ class TestProperties:
     def test_queue_category(self):
         """Test that the queue category `"_ops"` carries over."""
         op = Adjoint(qml.PauliX(0))
-        assert op._queue_category == "_ops"
+        assert op._queue_category == "_ops"  # pylint: disable=protected-access
 
     def test_queue_category_None(self):
         """Test that the queue category `None` for some observables carries over."""
         op = Adjoint(qml.PauliX(0) @ qml.PauliY(1))
-        assert op._queue_category is None
+        assert op._queue_category is None  # pylint: disable=protected-access
 
     @pytest.mark.parametrize("value", (True, False))
     def test_is_hermitian(self, value):
         """Test `is_hermitian` property mirrors that of the base."""
-
+        # pylint: disable=too-few-public-methods
         class DummyOp(qml.operation.Operator):
             num_wires = 1
             is_hermitian = value
@@ -304,9 +305,6 @@ class TestProperties:
         assert op.batch_size == 3
         assert op.ndim_params == (0,)
 
-        op._check_batching([np.array([1.2, 2.3])])
-        assert op.batch_size == base.batch_size == 2
-
 
 class TestSimplify:
     """Test Adjoint simplify method and depth property."""
@@ -512,7 +510,7 @@ class TestQueueing:
 
         with qml.queuing.AnnotatedQueue() as q:
             base = qml.Rot(1.2345, 2.3456, 3.4567, wires="b")
-            op = Adjoint(base)
+            _ = Adjoint(base)
 
         assert base not in q.queue
         assert len(q) == 1
@@ -531,7 +529,7 @@ class TestQueueing:
         """Test that when `do_queue` is specified, the operation is not queued."""
         base = qml.PauliX(0)
         with qml.queuing.AnnotatedQueue() as q:
-            op = Adjoint(base, do_queue=False)
+            _ = Adjoint(base, do_queue=False)
 
         assert len(q) == 0
 
@@ -656,7 +654,9 @@ class TestEigvals:
         adj = Adjoint(base)
         compare = qml.RX(-x, 0)
 
-        assert qml.math.allclose(base.eigvals(), compare.eigvals())
+        # eigvals might have different orders
+        assert qml.math.allclose(adj.eigvals()[:, 0], compare.eigvals()[:, 1])
+        assert qml.math.allclose(adj.eigvals()[:, 1], compare.eigvals()[:, 0])
 
     def test_no_matrix_defined_eigvals(self):
         """Test that if the base does not define eigvals, The Adjoint raises the same error."""
@@ -1072,7 +1072,9 @@ class TestAdjointConstructorIntegration:
         expected_res = np.sin(x)
         expected_grad = np.cos(x)
         assert qml.math.allclose(circ(x), expected_res)
-        assert qml.math.allclose(autograd.grad(circ)(x), expected_grad)
+        assert qml.math.allclose(
+            autograd.grad(circ)(x), expected_grad  # pylint: disable=no-value-for-parameter
+        )
 
     @pytest.mark.jax
     @pytest.mark.parametrize("diff_method", ("backprop", "adjoint", "parameter-shift"))
diff --git a/tests/ops/op_math/test_exp.py b/tests/ops/op_math/test_exp.py
index f1b1b83e0..0249fdf04 100644
--- a/tests/ops/op_math/test_exp.py
+++ b/tests/ops/op_math/test_exp.py
@@ -11,9 +11,10 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
+"""Unit tests for the ``Exp`` class"""
+import copy
 
 import pytest
-from copy import copy
 
 import pennylane as qml
 from pennylane import numpy as np
@@ -22,7 +23,7 @@ from pennylane.operation import (
     GeneratorUndefinedError,
     ParameterFrequenciesUndefinedError,
 )
-from pennylane.ops.op_math import Exp, Evolution
+from pennylane.ops.op_math import Evolution, Exp
 
 
 @pytest.mark.parametrize("constructor", (qml.exp, Exp))
@@ -87,6 +88,7 @@ class TestInitialization:
     def test_has_diagonalizing_gates(self, value, constructor):
         """Test that Exp defers has_diagonalizing_gates to base operator."""
 
+        # pylint: disable=too-few-public-methods
         class DummyOp(qml.operation.Operator):
             num_wires = 1
             has_diagonalizing_gates = value
@@ -109,6 +111,7 @@ class TestProperties:
 
         assert op.data == [[coeff], [phi]]
 
+    # pylint: disable=protected-access
     def test_queue_category_ops(self):
         """Test the _queue_category property."""
         assert Exp(qml.PauliX(0), -1.234j)._queue_category == "_ops"
@@ -125,10 +128,106 @@ class TestProperties:
 
         assert not Exp(qml.RX(1.2, wires=0)).is_hermitian
 
+    def test_batching_properties(self):
+        """Test the batching properties and methods."""
+
+        # base is batched
+        base = qml.RX(np.array([1.2, 2.3, 3.4]), 0)
+        op = Exp(base)
+        assert op.batch_size == 3
+
+        # coeff is batched
+        base = qml.RX(1, 0)
+        op = Exp(base, coeff=np.array([1.2, 2.3, 3.4]))
+        assert op.batch_size == 3
+
+        # both are batched
+        base = qml.RX(np.array([1.2, 2.3, 3.4]), 0)
+        op = Exp(base, coeff=np.array([1.2, 2.3, 3.4]))
+        assert op.batch_size == 3
+
+    def test_different_batch_sizes_raises_error(self):
+        """Test that using different batch sizes for base and scalar raises an error."""
+        base = qml.RX(np.array([1.2, 2.3, 3.4]), 0)
+        with pytest.raises(
+            ValueError, match="Broadcasting was attempted but the broadcasted dimensions"
+        ):
+            _ = Exp(base, np.array([0.1, 1.2, 2.3, 3.4]))
+
 
 class TestMatrix:
     """Test the matrix method."""
 
+    def test_base_batching_support(self):
+        """Test that Exp matrix has base batching support."""
+        x = np.array([-1, -2, -3])
+        op = Exp(qml.RX(x, 0), 3)
+        mat = op.matrix()
+        true_mat = qml.math.stack([Exp(qml.RX(i, 0), 3).matrix() for i in x])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+
+    def test_coeff_batching_support(self):
+        """Test that Exp matrix has coeff batching support."""
+        x = np.array([-1, -2, -3])
+        op = Exp(qml.PauliX(0), x)
+        mat = op.matrix()
+        true_mat = qml.math.stack([Exp(qml.PauliX(0), i).matrix() for i in x])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+
+    def test_base_and_coeff_batching_support(self):
+        """Test that Exp matrix has base and coeff batching support."""
+        x = np.array([-1, -2, -3])
+        y = np.array([1, 2, 3])
+        op = Exp(qml.RX(x, 0), y)
+        mat = op.matrix()
+        true_mat = qml.math.stack([Exp(qml.RX(i, 0), j).matrix() for i, j in zip(x, y)])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+
+    @pytest.mark.jax
+    def test_batching_jax(self):
+        """Test that Exp matrix has batching support with the jax interface."""
+        import jax.numpy as jnp
+
+        x = jnp.array([-1, -2, -3])
+        y = jnp.array([1, 2, 3])
+        op = Exp(qml.RX(x, 0), y)
+        mat = op.matrix()
+        true_mat = qml.math.stack([Exp(qml.RX(i, 0), j).matrix() for i, j in zip(x, y)])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+        assert isinstance(mat, jnp.ndarray)
+
+    @pytest.mark.torch
+    def test_batching_torch(self):
+        """Test that Exp matrix has batching support with the torch interface."""
+        import torch
+
+        x = torch.tensor([-1, -2, -3])
+        y = torch.tensor([1, 2, 3])
+        op = Exp(qml.RX(x, 0), y)
+        mat = op.matrix()
+        true_mat = qml.math.stack([Exp(qml.RX(i, 0), j).matrix() for i, j in zip(x, y)])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+        assert isinstance(mat, torch.Tensor)
+
+    @pytest.mark.tf
+    def test_batching_tf(self):
+        """Test that Exp matrix has batching support with the tensorflow interface."""
+        import tensorflow as tf
+
+        x = tf.constant([-1.0, -2.0, -3.0])
+        y = tf.constant([1.0, 2.0, 3.0])
+        op = Exp(qml.RX(x, 0), y)
+        mat = op.matrix()
+        true_mat = qml.math.stack([Exp(qml.RX(i, 0), j).matrix() for i, j in zip(x, y)])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+        assert isinstance(mat, tf.Tensor)
+
     def test_tensor_base_isingxx(self):
         """Test that isingxx can be created with a tensor base."""
         phi = -0.46
@@ -379,7 +478,6 @@ class TestMiscMethods:
     def test_simplify_sprod(self):
         """Test that simplify merges SProd into the coefficent."""
         base = qml.adjoint(qml.PauliX(0))
-        coeff1 = 2.0
         s_op = qml.s_prod(2.0, base)
 
         op = Exp(s_op, 3j)
@@ -410,7 +508,7 @@ class TestMiscMethods:
     def test_copy(self):
         """Tests making a copy."""
         op = Exp(qml.CNOT([0, 1]), 2)
-        copied_op = op.__copy__()
+        copied_op = copy.copy(op)
 
         assert qml.equal(op.base, copied_op.base)
         assert op.data == copied_op.data
@@ -418,7 +516,7 @@ class TestMiscMethods:
 
         for attr, value in vars(copied_op).items():
             if (
-                not attr == "_hyperparameters"
+                attr != "_hyperparameters"
             ):  # hyperparameters contains base, which can't be compared via ==
                 assert vars(op)[attr] == value
 
@@ -467,7 +565,9 @@ class TestIntegration:
         phi_grad = tape.gradient(res, phi)
 
         assert qml.math.allclose(res, tf.cos(phi))
-        assert qml.math.allclose(phi_grad, -tf.sin(phi))
+        assert qml.math.allclose(
+            phi_grad, -tf.sin(phi)  # pylint: disable=invalid-unary-operand-type
+        )
 
     @pytest.mark.torch
     def test_torch_qnode(self):
@@ -618,9 +718,23 @@ class TestIntegration:
             Exp(qml.PauliX(0), -0.5j * phi)
 
         tape = qml.tape.QuantumScript.from_queue(q)
-        qml.drawer.tape_text(tape)
 
-        assert "0: ──Exp─┤  "
+        assert qml.drawer.tape_text(tape) == "0: ──Exp(-0.6j X)─┤  "
+
+    def test_exp_batching(self):
+        """Test execution of a batched Exp operator."""
+        dev = qml.device("default.qubit", wires=1)
+
+        @qml.qnode(dev)
+        def circuit(x):
+            Exp(qml.PauliX(0), 0.5j * x)
+            return qml.expval(qml.PauliY(0))
+
+        x = qml.numpy.array([1.234, 2.34, 3.456])
+        res = circuit(x)
+
+        expected = np.sin(x)
+        assert qml.math.allclose(res, expected)
 
 
 class TestDifferentiation:
@@ -655,7 +769,7 @@ class TestDifferentiation:
         with pytest.raises(GeneratorUndefinedError):
             op.generator()
         with pytest.raises(ParameterFrequenciesUndefinedError):
-            op.parameter_frequencies
+            _ = op.parameter_frequencies
 
     def test_parameter_frequency_with_parameters_in_base_operator(self):
         """Test that parameter_frequency raises an error for the Exp class, but not the
diff --git a/tests/ops/op_math/test_pow_op.py b/tests/ops/op_math/test_pow_op.py
index 6a7ac5b0a..a822e4d5d 100644
--- a/tests/ops/op_math/test_pow_op.py
+++ b/tests/ops/op_math/test_pow_op.py
@@ -25,13 +25,15 @@ from pennylane.ops.op_math.controlled import ControlledOp
 from pennylane.ops.op_math.pow import Pow, PowOperation
 
 
+# pylint: disable=too-few-public-methods
 class TempOperator(qml.operation.Operator):
     """Dummy operator"""
 
     num_wires = 1
 
 
-def pow_using_dunder_method(base, z, do_queue=True, id=None):
+# pylint: disable=unused-argument
+def pow_using_dunder_method(base, z, do_queue=False, id=None):
     """Helper function which computes the base raised to the power invoking the __pow__ dunder
     method."""
     return base**z
@@ -66,10 +68,11 @@ class TestConstructor:
         """Test that when the simplification method returns a list of multiple operators,
         pow returns a list of multiple operators."""
 
+        # pylint: disable=too-few-public-methods
         class Temp(qml.operation.Operator):
             num_wires = 1
 
-            def pow(self, z):
+            def pow(self, z):  # pylint: disable=unused-argument
                 return [qml.S(0), qml.T(0)]
 
         new_op = qml.pow(Temp(0), 2, lazy=False)
@@ -83,7 +86,7 @@ class TestConstructor:
 
         with qml.queuing.AnnotatedQueue() as q:
             original_op = qml.PauliX(0)
-            new_op = qml.pow(original_op, 0.5, lazy=False)
+            _ = qml.pow(original_op, 0.5, lazy=False)
 
         assert original_op not in q.queue
 
@@ -333,29 +336,42 @@ class TestProperties:
     def test_queue_category(self, power_method):
         """Test that the queue category `"_ops"` carries over."""
         op: Pow = power_method(base=qml.PauliX(0), z=3.5)
-        assert op._queue_category == "_ops"
+        assert op._queue_category == "_ops"  # pylint: disable=protected-access
 
     def test_queue_category_None(self, power_method):
         """Test that the queue category `None` for some observables carries over."""
         op: Pow = power_method(base=qml.PauliX(0) @ qml.PauliY(1), z=-1.1)
-        assert op._queue_category is None
+        assert op._queue_category is None  # pylint: disable=protected-access
 
     def test_batching_properties(self, power_method):
-        """Test that Pow batching behavior mirrors that of the base."""
+        """Test the batching properties and methods."""
 
-        class DummyOp(qml.operation.Operator):
-            ndim_params = (0, 2)
-            num_wires = 1
-
-        param1 = [0.3] * 3
-        param2 = [[[0.3, 1.2]]] * 3
+        # base is batched
+        base = qml.RX(np.array([1.2, 2.3, 3.4]), 0)
+        op = power_method(base, z=1)
+        assert op.ndim_params == base.ndim_params
+        assert op.batch_size == 3
 
-        base = DummyOp(param1, param2, wires=0)
-        op: Pow = power_method(base=base, z=3)
+        # coeff is batched
+        base = qml.RX(1, 0)
+        op = power_method(base, z=np.array([1.2, 2.3, 3.4]))
+        assert op.ndim_params == base.ndim_params
+        assert op.batch_size == 3
 
-        assert op.ndim_params == (0, 2)
+        # both are batched
+        base = qml.RX(np.array([1.2, 2.3, 3.4]), 0)
+        op = power_method(base, z=np.array([1.2, 2.3, 3.4]))
+        assert op.ndim_params == base.ndim_params
         assert op.batch_size == 3
 
+    def test_different_batch_sizes_raises_error(self, power_method):
+        """Test that using different batch sizes for base and scalar raises an error."""
+        base = qml.RX(np.array([1.2, 2.3, 3.4]), 0)
+        with pytest.raises(
+            ValueError, match="Broadcasting was attempted but the broadcasted dimensions"
+        ):
+            _ = power_method(base, np.array([0.1, 1.2, 2.3, 3.4]))
+
     op_pauli_reps = (
         (qml.PauliZ(wires=0), 1, qml.pauli.PauliSentence({qml.pauli.PauliWord({0: "Z"}): 1})),
         (qml.PauliX(wires=1), 2, qml.pauli.PauliSentence({qml.pauli.PauliWord({}): 1})),  # identity
@@ -366,7 +382,7 @@ class TestProperties:
     def test_pauli_rep(self, base, exp, rep, power_method):
         """Test the pauli rep is produced as expected."""
         op = power_method(base, exp)
-        assert op._pauli_rep == rep
+        assert op._pauli_rep == rep  # pylint: disable=protected-access
 
     def test_pauli_rep_is_none_for_bad_exponents(self, power_method):
         """Test that the _pauli_rep is None if the exponent is not positive or non integer."""
@@ -375,13 +391,13 @@ class TestProperties:
 
         for exponent in exponents:
             op = power_method(base, z=exponent)
-            assert op._pauli_rep is None
+            assert op._pauli_rep is None  # pylint: disable=protected-access
 
     def test_pauli_rep_none_if_base_pauli_rep_none(self, power_method):
         """Test that None is produced if the base op does not have a pauli rep"""
         base = qml.RX(1.23, wires=0)
         op = power_method(base, z=2)
-        assert op._pauli_rep is None
+        assert op._pauli_rep is None  # pylint: disable=protected-access
 
 
 class TestSimplify:
@@ -577,7 +593,7 @@ class TestQueueing:
         """Test that when `do_queue` is specified, the operation is not queued."""
         base = qml.PauliX(0)
         with qml.queuing.AnnotatedQueue() as q:
-            op = Pow(base, 4.5, do_queue=False)
+            _ = Pow(base, 4.5, do_queue=False)
 
         assert len(q) == 0
 
@@ -585,6 +601,73 @@ class TestQueueing:
 class TestMatrix:
     """Test the matrix method for the power operator."""
 
+    def test_base_batching_support(self):
+        """Test that Pow matrix has base batching support."""
+        x = np.array([-1, -2, -3])
+        op = Pow(qml.RX(x, 0), z=3)
+        mat = op.matrix()
+        true_mat = qml.math.stack([Pow(qml.RX(i, 0), z=3).matrix() for i in x])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+
+    def test_coeff_batching_support(self):
+        """Test that Pow matrix has coeff batching support."""
+        x = np.array([-1, -2, -3])
+        op = Pow(qml.PauliX(0), z=x)
+        mat = op.matrix()
+        true_mat = qml.math.stack([Pow(qml.PauliX(0), i).matrix() for i in x])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+
+    def test_base_and_coeff_batching_support(self):
+        """Test that Pow matrix has base and coeff batching support."""
+        x = np.array([-1, -2, -3])
+        y = np.array([1, 2, 3])
+        op = Pow(qml.RX(x, 0), z=y)
+        mat = op.matrix()
+        true_mat = qml.math.stack([Pow(qml.RX(i, 0), j).matrix() for i, j in zip(x, y)])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+
+    @pytest.mark.jax
+    def test_batching_jax(self):
+        """Test that Pow matrix has batching support with the jax interface."""
+        import jax.numpy as jnp
+
+        x = jnp.array([-1, -2, -3])
+        y = jnp.array([1, 2, 3])
+        op = Pow(qml.RX(x, 0), y)
+        mat = op.matrix()
+        true_mat = qml.math.stack([Pow(qml.RX(i, 0), j).matrix() for i, j in zip(x, y)])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+
+    @pytest.mark.torch
+    def test_batching_torch(self):
+        """Test that Pow matrix has batching support with the torch interface."""
+        import torch
+
+        x = torch.tensor([-1, -2, -3])
+        y = torch.tensor([1, 2, 3])
+        op = Pow(qml.RX(x, 0), y)
+        mat = op.matrix()
+        true_mat = qml.math.stack([Pow(qml.RX(i, 0), j).matrix() for i, j in zip(x, y)])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+
+    @pytest.mark.tf
+    def test_batching_tf(self):
+        """Test that Pow matrix has batching support with the tensorflow interface."""
+        import tensorflow as tf
+
+        x = tf.constant([-1.0, -2.0, -3.0])
+        y = tf.constant([1.0, 2.0, 3.0])
+        op = Pow(qml.RX(x, 0), y)
+        mat = op.matrix()
+        true_mat = qml.math.stack([Pow(qml.RX(i, 0), j).matrix() for i, j in zip(x, y)])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+
     def check_matrix(self, param, z):
         """Interface-independent helper function that checks that the matrix of a power op
         of an IsingZZ is the same as the matrix for its decomposition."""
diff --git a/tests/ops/op_math/test_sprod.py b/tests/ops/op_math/test_sprod.py
index 672791087..0a6f2e8e1 100644
--- a/tests/ops/op_math/test_sprod.py
+++ b/tests/ops/op_math/test_sprod.py
@@ -140,10 +140,10 @@ class TestInitialization:
     @pytest.mark.parametrize("scalar, op", ops)
     def test_terms(self, op, scalar):
         sprod_op = SProd(scalar, op)
-        coeff, ops = sprod_op.terms()
+        coeff, op2 = sprod_op.terms()
 
         assert coeff == [scalar]
-        for op1, op2 in zip(ops, [op]):
+        for op1, op2 in zip(op2, [op]):
             assert qml.equal(op1, op2)
 
     def test_decomposition_raises_error(self):
@@ -171,7 +171,7 @@ class TestMscMethods:
         """Test the repr dunder method."""
         scalar, op = op_scalar_tup
         sprod_op = SProd(scalar, op)
-        assert op_rep == sprod_op.__repr__()
+        assert op_rep == repr(sprod_op)
 
     @pytest.mark.parametrize("op_scalar_tup", ops)
     def test_copy(self, op_scalar_tup):
@@ -212,6 +212,7 @@ class TestMscMethods:
         """Test that a scalar product with an operator that has `has_matrix=False`
         has `has_matrix=True` as well."""
 
+        # pylint: disable=too-few-public-methods
         class MyOp(qml.RX):
             """Variant of qml.RX that claims to not have `adjoint` or a matrix defined."""
 
@@ -224,6 +225,7 @@ class TestMscMethods:
     def test_has_diagonalizing_gates(self, value):
         """Test that SProd defers has_diagonalizing_gates to base operator."""
 
+        # pylint: disable=too-few-public-methods
         class DummyOp(qml.operation.Operator):
             num_wires = 1
             has_diagonalizing_gates = value
@@ -235,6 +237,76 @@ class TestMscMethods:
 class TestMatrix:
     """Tests of the matrix of a SProd class."""
 
+    def test_base_batching_support(self):
+        """Test that SProd matrix has base batching support."""
+        x = np.array([-1, -2, -3])
+        op = qml.s_prod(3, qml.RX(x, 0))
+        mat = op.matrix()
+        true_mat = qml.math.stack([qml.s_prod(3, qml.RX(i, 0)).matrix() for i in x])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+
+    def test_coeff_batching_support(self):
+        """Test that SProd matrix has coeff batching support."""
+        x = np.array([-1, -2, -3])
+        op = qml.s_prod(x, qml.PauliX(0))
+        mat = op.matrix()
+        true_mat = qml.math.stack([qml.s_prod(i, qml.PauliX(0)).matrix() for i in x])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+
+    def test_base_and_coeff_batching_support(self):
+        """Test that SProd matrix has base and coeff batching support."""
+        x = np.array([-1, -2, -3])
+        y = np.array([1, 2, 3])
+        op = qml.s_prod(y, qml.RX(x, 0))
+        mat = op.matrix()
+        true_mat = qml.math.stack([qml.s_prod(j, qml.RX(i, 0)).matrix() for i, j in zip(x, y)])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+
+    @pytest.mark.jax
+    def test_batching_jax(self):
+        """Test that SProd matrix has batching support with the jax interface."""
+        import jax.numpy as jnp
+
+        x = jnp.array([-1, -2, -3])
+        y = jnp.array([1, 2, 3])
+        op = qml.s_prod(y, qml.RX(x, 0))
+        mat = op.matrix()
+        true_mat = qml.math.stack([qml.s_prod(j, qml.RX(i, 0)).matrix() for i, j in zip(x, y)])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+        assert isinstance(mat, jnp.ndarray)
+
+    @pytest.mark.torch
+    def test_batching_torch(self):
+        """Test that SProd matrix has batching support with the torch interface."""
+        import torch
+
+        x = torch.tensor([-1, -2, -3])
+        y = torch.tensor([1, 2, 3])
+        op = qml.s_prod(y, qml.RX(x, 0))
+        mat = op.matrix()
+        true_mat = qml.math.stack([qml.s_prod(j, qml.RX(i, 0)).matrix() for i, j in zip(x, y)])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+        assert isinstance(mat, torch.Tensor)
+
+    @pytest.mark.tf
+    def test_batching_tf(self):
+        """Test that SProd matrix has batching support with the tensorflow interface."""
+        import tensorflow as tf
+
+        x = tf.constant([-1.0, -2.0, -3.0])
+        y = tf.constant([1.0, 2.0, 3.0])
+        op = qml.s_prod(y, qml.RX(x, 0))
+        mat = op.matrix()
+        true_mat = qml.math.stack([qml.s_prod(j, qml.RX(i, 0)).matrix() for i, j in zip(x, y)])
+        assert qml.math.allclose(mat, true_mat)
+        assert mat.shape == (3, 2, 2)
+        assert isinstance(mat, tf.Tensor)
+
     @pytest.mark.parametrize("scalar", scalars)
     @pytest.mark.parametrize("op, mat", param_ops + non_param_ops)
     def test_various_ops(self, scalar, op, mat):
@@ -359,7 +431,7 @@ class TestMatrix:
         expected_sparse_matrix = csr_matrix(expected_sparse_matrix)
         expected_sparse_matrix.sort_indices()
 
-        assert type(sparse_matrix) == type(expected_sparse_matrix)
+        assert isinstance(sparse_matrix, type(expected_sparse_matrix))
         assert all(sparse_matrix.data == expected_sparse_matrix.data)
         assert all(sparse_matrix.indices == expected_sparse_matrix.indices)
 
@@ -442,11 +514,11 @@ class TestMatrix:
 
 class TestProperties:
     @pytest.mark.parametrize("op_scalar_tup", ops)
-    def test_queue_catagory(self, op_scalar_tup):
-        """Test queue_catagory property is always None."""  # currently not supporting queuing SProd
+    def test_queue_category(self, op_scalar_tup):
+        """Test queue_category property is always None."""  # currently not supporting queuing SProd
         scalar, op = op_scalar_tup
         sprod_op = SProd(scalar, op)
-        assert sprod_op._queue_category is None
+        assert sprod_op._queue_category is None  # pylint: disable=protected-access
 
     def test_eigvals(self):
         """Test that the eigvals of the scalar product op are correct."""
@@ -547,13 +619,39 @@ class TestProperties:
     @pytest.mark.parametrize("op, rep", op_pauli_reps)
     def test_pauli_rep(self, op, rep):
         """Test the pauli rep is produced as expected."""
-        assert op._pauli_rep == rep
+        assert op._pauli_rep == rep  # pylint: disable=protected-access
 
     def test_pauli_rep_none_if_base_pauli_rep_none(self):
         """Test that None is produced if the base op does not have a pauli rep"""
         base = qml.RX(1.23, wires=0)
         op = qml.s_prod(2, base)
-        assert op._pauli_rep is None
+        assert op._pauli_rep is None  # pylint: disable=protected-access
+
+    def test_batching_properties(self):
+        """Test the batching properties and methods."""
+
+        # base is batched
+        base = qml.RX(np.array([1.2, 2.3, 3.4]), 0)
+        op = qml.s_prod(0.5, base)
+        assert op.batch_size == 3
+
+        # coeff is batched
+        base = qml.RX(1, 0)
+        op = qml.s_prod(np.array([1.2, 2.3, 3.4]), base)
+        assert op.batch_size == 3
+
+        # both are batched
+        base = qml.RX(np.array([1.2, 2.3, 3.4]), 0)
+        op = qml.s_prod(np.array([1.2, 2.3, 3.4]), base)
+        assert op.batch_size == 3
+
+    def test_different_batch_sizes_raises_error(self):
+        """Test that using different batch sizes for base and scalar raises an error."""
+        base = qml.RX(np.array([1.2, 2.3, 3.4]), 0)
+        with pytest.raises(
+            ValueError, match="Broadcasting was attempted but the broadcasted dimensions"
+        ):
+            _ = qml.s_prod(np.array([0.1, 1.2, 2.3, 3.4]), base)
 
 
 class TestSimplify:
@@ -816,7 +914,7 @@ class TestIntegration:
 
     @pytest.mark.jax
     @pytest.mark.parametrize("diff_method", ("parameter-shift", "backprop"))
-    def test_torch(self, diff_method):
+    def test_jax(self, diff_method):
         """Test that interface parameters can be unwrapped to numpy. This will occur when parameter-shift
         is requested for a given interface."""
 
diff --git a/tests/ops/op_math/test_sum.py b/tests/ops/op_math/test_sum.py
index b83606cb5..c35ba5ce3 100644
--- a/tests/ops/op_math/test_sum.py
+++ b/tests/ops/op_math/test_sum.py
@@ -84,7 +84,8 @@ def _get_pw(w, pauli_op):
     return qml.pauli.PauliWord({w: pauli_op})
 
 
-def sum_using_dunder_method(*summands, do_queue=True, id=None):
+# pylint: disable=unused-argument
+def sum_using_dunder_method(*summands, do_queue=False, id=None):
     """Helper function which computes the sum of all the summands to invoke the
     __add__ dunder method."""
     return sum(summands)
@@ -166,7 +167,7 @@ class TestInitialization:
         eig_vecs = eig_decomp["eigvec"]
         eig_vals = eig_decomp["eigval"]
 
-        eigs_cache = diag_sum_op._eigs[diag_sum_op.hash]
+        eigs_cache = diag_sum_op._eigs[diag_sum_op.hash]  # pylint: disable=protected-access
         cached_vecs = eigs_cache["eigvec"]
         cached_vals = eigs_cache["eigval"]
 
@@ -457,6 +458,7 @@ class TestMatrix:
         """Test that an error is raised when the sparse matrix method
         is undefined for any of the factors."""
 
+        # pylint: disable=too-few-public-methods
         class DummyOp(qml.operation.Operation):
             num_wires = 1
 
@@ -486,10 +488,10 @@ class TestProperties:
 
     @pytest.mark.parametrize("sum_method", [sum_using_dunder_method, qml.sum])
     @pytest.mark.parametrize("ops_lst", ops)
-    def test_queue_catagory(self, ops_lst, sum_method):
-        """Test queue_catagory property is always None."""  # currently not supporting queuing Sum
+    def test_queue_category(self, ops_lst, sum_method):
+        """Test queue_category property is always None."""  # currently not supporting queuing Sum
         sum_op = sum_method(*ops_lst)
-        assert sum_op._queue_category is None
+        assert sum_op._queue_category is None  # pylint: disable=protected-access
 
     def test_eigendecompostion(self):
         """Test that the computed Eigenvalues and Eigenvectors are correct."""
@@ -535,12 +537,12 @@ class TestProperties:
     @pytest.mark.parametrize("op, rep", op_pauli_reps)
     def test_pauli_rep(self, op, rep):
         """Test that the pauli rep gives the expected result."""
-        assert op._pauli_rep == rep
+        assert op._pauli_rep == rep  # pylint: disable=protected-access
 
     def test_pauli_rep_none(self):
         """Test that None is produced if any of the summands don't have a _pauli_rep."""
         op = qml.sum(qml.PauliX(wires=0), qml.RX(1.23, wires=1))
-        assert op._pauli_rep is None
+        assert op._pauli_rep is None  # pylint: disable=protected-access
 
     op_pauli_reps_nested = (
         (
@@ -635,7 +637,7 @@ class TestProperties:
     @pytest.mark.parametrize("op, rep", op_pauli_reps_nested)
     def test_pauli_rep_nested(self, op, rep):
         """Test that the pauli rep gives the expected result."""
-        assert op._pauli_rep == rep
+        assert op._pauli_rep == rep  # pylint: disable=protected-access
 
 
 class TestSimplify:
@@ -740,7 +742,7 @@ class TestSortWires:
             qml.PauliZ(3),
             qml.PauliX(5),
         ]
-        sorted_list = Sum._sort(op_list)
+        sorted_list = Sum._sort(op_list)  # pylint: disable=protected-access
         final_list = [
             qml.PauliY(0),
             qml.PauliY(1),
@@ -767,7 +769,7 @@ class TestSortWires:
             qml.PauliZ(3),
             qml.CRY(1, [1, 2]),
         )
-        sorted_list = Sum._sort(op_tuple)
+        sorted_list = Sum._sort(op_tuple)  # pylint: disable=protected-access
         final_list = [
             qml.PauliY(0),
             qml.CRX(1, [0, 2]),
@@ -796,7 +798,9 @@ class TestSortWires:
             qml.PauliZ("three"),
             qml.CRY(1, ["test", 2]),
         ]
-        sorted_list = Sum._sort(op_list, wire_map={0: 0, "test": 1, 2: 2, "three": 3, 4: 4, 5: 5})
+        sorted_list = Sum._sort(  # pylint: disable=protected-access
+            op_list, wire_map={0: 0, "test": 1, 2: 2, "three": 3, 4: 4, 5: 5}
+        )
         final_list = [
             qml.PauliY(0),
             qml.CRX(1, ["test", 2]),
@@ -987,6 +991,7 @@ class TestIntegration:
             my_circ()
 
 
+# pylint: disable=too-few-public-methods
 class TestArithmetic:
     """Test arithmetic decomposition methods."""
 
diff --git a/tests/tests_passing_pylint.txt b/tests/tests_passing_pylint.txt
index ee0afb709..a2818339d 100644
--- a/tests/tests_passing_pylint.txt
+++ b/tests/tests_passing_pylint.txt
@@ -5,6 +5,11 @@ tests/qchem/test_givens_rotations.py
 tests/templates/test_subroutines/test_basis_rotation.py
 tests/ops/op_math/test_controlled_ops.py
 tests/ops/op_math/test_controlled.py
+tests/ops/op_math/test_sprod.py
+tests/ops/op_math/test_sum.py
+tests/ops/op_math/test_pow_op.py
+tests/ops/op_math/test_adjoint.py
+tests/ops/op_math/test_exp.py
 tests/ops/functions/test_dot.py
 tests/ops/functions/test_evolve.py
 tests/ops/qutrit/test_qutrit_observables.py