Repository: PennyLaneAI/pennylane

Issue:
Add function decomposing Hamiltonian to Pauli observables
We should add a function that allows users to easily decompose their Hamiltonian into Pauli operators. This is implemented in the following function:

```python
from functools import reduce
from itertools import product
from operator import matmul

import numpy as np
import pennylane as qml


def decompose_hamiltonian(H):
    N = int(np.log2(len(H)))
    paulis = [qml.Identity, qml.PauliX, qml.PauliY, qml.PauliZ]

    obs = []
    coeffs = []

    for term in product(paulis, repeat=N):
        matrices = [i._matrix() for i in term]
        coeff = np.trace(reduce(np.kron, matrices) @ H) / (2**N)

        if not np.allclose(coeff, 0):
            coeffs.append(coeff)

            if not all(t is qml.Identity for t in term):
                obs.append(reduce(matmul, [t(i) for i, t in enumerate(term) if t is not qml.Identity]))
            else:
                obs.append(reduce(matmul, [t(i) for i, t in enumerate(term)]))

    return coeffs, obs
```


Discussion:
At the moment, this function should likely go in `pennylane/utils.py`, with the tests in `tests/test_utils.py`.
Hey Josh,
I was reading the code and I have a small doubt about the second if condition. When only 1 matrix is identity the 'obs' variable is appended by the respective Pauli matrix,  but would this not create a dimension mismatch?
The appended PauliX would be (let's say for N=2) would be (2,2) but the Identity*Identity would be (4,4).
Am I missing something?
Hi @rahul1704! There is no dimension mismatch, because what we're doing is converting from a numeric/matrix representation to a _symbolic_ representation, using PennyLane observables. Since the output is symbolic, PennyLane has internal logic for interpreting the observable list.

For example, consider the following output of the above function, to be measured on a circuit with 3 wires:

```python
obs_list = [qml.PauliX(0), qml.PauliY(0) @ qml.PauliZ(2), qml.Identity(2)]
```

PennyLane can correctly interpret this observable list, since it knows that the quantum circuit has 3 wires. So identities are implicitly placed where needed. In fact, the observable list above is equivalent to the following, if we were to explicitly include identities:
```python
obs_list = [
    qml.PauliX(0) @ qml.Identity(1) @ qml.Identity(2),
    qml.PauliY(0) @ qml.Identity(1) @ qml.PauliZ(2),
    qml.Identity(0) @ qml.Identity(1) @ qml.Identity(2),
]
```

The reason why we have this if statement, instead of simply explicitly adding all identity matrices? There is a cost associated with each term in the tensor product --- the fewer terms in the tensor product (e.g., by dropping identities where not needed), the less processing overhead required :)

---

Edit: another thing to note --- since this is a symbolic computation, the `@` or `matmul` operator is not doing any numeric matrix multiplication here. Instead, it is just symbolic, instructing PennyLane to compute the tensor product when needed.
I figured that it was being done to save time, but the mismatch was confusing me. Thanks for your reply @josh146.

Resolving patch:
diff --git a/pennylane/utils.py b/pennylane/utils.py
--- a/pennylane/utils.py
+++ b/pennylane/utils.py
@@ -19,9 +19,11 @@
 from collections.abc import Iterable
 from collections import OrderedDict
 import copy
-import numbers
 import functools
 import inspect
+import itertools
+import numbers
+from operator import matmul
 
 import numpy as np
 
@@ -29,6 +31,71 @@
 from pennylane.variable import Variable
 
 
+def decompose_hamiltonian(H):
+    """Decomposes a Hermitian matrix into a linear combination of Pauli operators.
+
+    Args:
+        H (array[complex]): an Hermitian matrix of dimension :math:`2^N\times 2^N`
+
+    Returns:
+        tuple[list[float], list[~.Observable]]: Returns a list of tensor products of PennyLane Pauli observables, as
+        well as the corresponding coefficients for each tensor product.
+
+    **Example:**
+
+    We can use this function to compute the Pauli operator decomposition of an arbitrary Hermitian
+    matrix:
+
+    >>> A = np.array([[-2, -2+1j, -2, -2], [-2-1j,  0,  0, -1], [-2,  0, -2, -1], [-2, -1, -1,  0]])
+    >>>  coeffs, obs_list = decompose_hamiltonian(A)
+    >>> coeffs
+    [-1.0, -1.5, -0.5, -1.0, -1.5, -1.0, -0.5, 1.0, -0.5, -0.5]
+
+    We can use the output coefficients and tensor Pauli terms to construct a :class:`~.Hamiltonian`:
+    >>> H = qml.Hamiltonian(coeffs, obs_list)
+    >>> print(H)
+    (-1.0) [I0 I1]
+    + (-1.5) [X1]
+    + (-0.5) [Y1]
+    + (-1.0) [Z1]
+    + (-1.5) [X0]
+    + (-1.0) [X0 X1]
+    + (-0.5) [X0 Z1]
+    + (1.0) [Y0 Y1]
+    + (-0.5) [Z0 X1]
+    + (-0.5) [Z0 Y1]
+
+    This Hamiltonian can then be used in defining VQE problems using :class:`~VQECost`.
+    """
+    N = int(np.log2(len(H)))
+
+    if len(H) - 2 ** N != 0:
+        raise ValueError("Hamiltonian should be in the form (n^2 x n^2), for any n>=1")
+
+    paulis = [qml.Identity, qml.PauliX, qml.PauliY, qml.PauliZ]
+    obs = []
+    coeffs = []
+
+    for term in itertools.product(paulis, repeat=N):
+        matrices = [i._matrix() for i in term]
+        coeff = np.trace(functools.reduce(np.kron, matrices) @ H) / (2 ** N)
+        coeff = np.real_if_close(coeff).item()
+
+        if not np.allclose(coeff, 0):
+            coeffs.append(coeff)
+
+            if not all(t is qml.Identity for t in term):
+                obs.append(
+                    functools.reduce(
+                        matmul, [t(i) for i, t in enumerate(term) if t is not qml.Identity]
+                    )
+                )
+            else:
+                obs.append(functools.reduce(matmul, [t(i) for i, t in enumerate(term)]))
+
+    return coeffs, obs
+
+
 def _flatten(x):
     """Iterate recursively through an arbitrarily nested structure in depth-first order.
 


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -15,15 +15,17 @@
 Unit tests for the :mod:`pennylane.utils` module.
 """
 # pylint: disable=no-self-use,too-many-arguments,protected-access
-import pytest
+import operator
+import functools
+import itertools
 from unittest.mock import MagicMock
+import pytest
 
 import numpy as np
 
 import pennylane as qml
 import pennylane.utils as pu
-import functools
-import itertools
+
 
 flat_dummy_array = np.linspace(-1, 1, 64)
 test_shapes = [
@@ -63,6 +65,33 @@
 U_toffoli[6:8, 6:8] = np.array([[0, 1], [1, 0]])
 
 
+test_Hamiltonians = [np.array([[2.5, -0.5], [-0.5, 2.5]]), np.array(np.diag([0, 0, 0, 1]))]
+
+
+class TestDecomposition:
+    """Tests the decompose_hamiltonian function"""
+
+    @pytest.mark.parametrize("H", test_Hamiltonians)
+    def test_decomp(self, H):
+        """Tests that decompose_hamiltonian successfully decomposes Hamiltonians into Pauli matrices"""
+        N = int(np.log2(len(H)))
+        coeff, combs = pu.decompose_hamiltonian(H)
+        result = np.zeros((2 ** N, 2 ** N))
+        matrices = []
+        for term in combs:
+            base = [qml.Identity] * N
+            if term.num_wires == N:
+                matrices.append(term.matrix)
+            else:
+                base[term.wires[0]] = term.__class__
+                matrices.append(
+                    functools.reduce(operator.matmul, [t(i) for i, t in enumerate(base)]).matrix
+                )
+        for term in zip(coeff, matrices):
+            result = result + term[0] * term[1]
+        assert (H == result).all
+
+
 class TestFlatten:
     """Tests the flatten and unflatten functions"""