Repository: Qiskit/qiskit

Issue:
Equality of `SparsePauliOp`s with different orders
### What is the expected enhancement?

Two `SparsePauliOp`s with different orders are judges as "not equal" as follows.

```python
from qiskit.quantum_info import SparsePauliOp

a = SparsePauliOp.from_list([('X', 1), ('Y', 2)])
b = SparsePauliOp.from_list([('Y', 2), ('X', 1)])
print(a == b)
```

output
```
False
```

Is it better to return `True` for this comparison or mention the order dependency in the docsting of `__eq__`?

@ikkoham also told me another case as follow. A redundant operator (coeff is 0) makes the comparison "not equal".
```python
from qiskit.quantum_info import SparsePauliOp

a = SparsePauliOp.from_list([('X', 1)])
b = SparsePauliOp.from_list([('X', 1), ('Y', 0)])
print(a == b)
```
```
False
```

If we need to compare two operator with different order and/or redundant operators, I think we need to call as follows. Is there any simple alternatives?
```python
np.allclose((a - b).simplify().coeffs, [0])
```

### Update
I updated `SparsePauliOp` in #7656 as follows.
- Leave `__eq__` as is (elementary-wise comparison)
- Add `equiv` for equivalence check


Discussion:

Resolving patch:
diff --git a/qiskit/__init__.py b/qiskit/__init__.py
--- a/qiskit/__init__.py
+++ b/qiskit/__init__.py
@@ -27,6 +27,7 @@
 sys.modules["qiskit._accelerate.stochastic_swap"] = qiskit._accelerate.stochastic_swap
 sys.modules["qiskit._accelerate.pauli_expval"] = qiskit._accelerate.pauli_expval
 sys.modules["qiskit._accelerate.dense_layout"] = qiskit._accelerate.dense_layout
+sys.modules["qiskit._accelerate.sparse_pauli_op"] = qiskit._accelerate.sparse_pauli_op
 
 # qiskit errors operator
 from qiskit.exceptions import QiskitError, MissingOptionalLibraryError
diff --git a/qiskit/opflow/primitive_ops/pauli_sum_op.py b/qiskit/opflow/primitive_ops/pauli_sum_op.py
--- a/qiskit/opflow/primitive_ops/pauli_sum_op.py
+++ b/qiskit/opflow/primitive_ops/pauli_sum_op.py
@@ -159,13 +159,11 @@ def equals(self, other: OperatorBase) -> bool:
         if isinstance(self_reduced.coeff, ParameterExpression) or isinstance(
             other_reduced.coeff, ParameterExpression
         ):
-            return (
-                self_reduced.coeff == other_reduced.coeff
-                and self_reduced.primitive == other_reduced.primitive
+            return self_reduced.coeff == other_reduced.coeff and self_reduced.primitive.equiv(
+                other_reduced.primitive
             )
-        return (
-            len(self_reduced) == len(other_reduced)
-            and self_reduced.primitive == other_reduced.primitive
+        return len(self_reduced) == len(other_reduced) and self_reduced.primitive.equiv(
+            other_reduced.primitive
         )
 
     def _expand_dim(self, num_qubits: int) -> "PauliSumOp":
diff --git a/qiskit/quantum_info/operators/symplectic/sparse_pauli_op.py b/qiskit/quantum_info/operators/symplectic/sparse_pauli_op.py
--- a/qiskit/quantum_info/operators/symplectic/sparse_pauli_op.py
+++ b/qiskit/quantum_info/operators/symplectic/sparse_pauli_op.py
@@ -28,6 +28,7 @@
 from qiskit.quantum_info.operators.symplectic.pauli_table import PauliTable
 from qiskit.quantum_info.operators.symplectic.pauli_utils import pauli_basis
 from qiskit.utils.deprecation import deprecate_function
+from qiskit._accelerate.sparse_pauli_op import unordered_unique  # pylint: disable=import-error
 
 
 class SparsePauliOp(LinearOp):
@@ -127,13 +128,27 @@ def __repr__(self):
         )
 
     def __eq__(self, other):
-        """Check if two SparsePauliOp operators are equal"""
+        """Entrywise comparison of two SparsePauliOp operators"""
         return (
             super().__eq__(other)
+            and self.coeffs.shape == other.coeffs.shape
             and np.allclose(self.coeffs, other.coeffs)
             and self.paulis == other.paulis
         )
 
+    def equiv(self, other):
+        """Check if two SparsePauliOp operators are equivalent.
+
+        Args:
+            other (SparsePauliOp): an operator object.
+
+        Returns:
+            bool: True if the operator is equivalent to ``self``.
+        """
+        if not super().__eq__(other):
+            return False
+        return np.allclose((self - other).simplify().coeffs, [0])
+
     @property
     def settings(self) -> Dict:
         """Return settings."""
@@ -388,11 +403,22 @@ def simplify(self, atol=None, rtol=None):
         if rtol is None:
             rtol = self.rtol
 
+        # Filter non-zero coefficients
+        non_zero = np.logical_not(np.isclose(self.coeffs, 0, atol=atol, rtol=rtol))
+        paulis_x = self.paulis.x[non_zero]
+        paulis_z = self.paulis.z[non_zero]
+        nz_coeffs = self.coeffs[non_zero]
+
         # Pack bool vectors into np.uint8 vectors by np.packbits
-        array = np.packbits(self.paulis.x, axis=1) * 256 + np.packbits(self.paulis.z, axis=1)
-        _, indexes, inverses = np.unique(array, return_index=True, return_inverse=True, axis=0)
+        array = np.packbits(paulis_x, axis=1) * 256 + np.packbits(paulis_z, axis=1)
+        indexes, inverses = unordered_unique(array)
+
+        if np.all(non_zero) and indexes.shape[0] == array.shape[0]:
+            # No zero operator or duplicate operator
+            return self.copy()
+
         coeffs = np.zeros(indexes.shape[0], dtype=complex)
-        np.add.at(coeffs, inverses, self.coeffs)
+        np.add.at(coeffs, inverses, nz_coeffs)
         # Delete zero coefficient rows
         is_zero = np.isclose(coeffs, 0, atol=atol, rtol=rtol)
         # Check edge case that we deleted all Paulis
@@ -404,8 +430,8 @@ def simplify(self, atol=None, rtol=None):
         else:
             non_zero = np.logical_not(is_zero)
             non_zero_indexes = indexes[non_zero]
-            x = self.paulis.x[non_zero_indexes]
-            z = self.paulis.z[non_zero_indexes]
+            x = paulis_x[non_zero_indexes]
+            z = paulis_z[non_zero_indexes]
             coeffs = coeffs[non_zero]
 
         return SparsePauliOp(


Test patch:
diff --git a/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py b/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py
--- a/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py
+++ b/test/python/quantum_info/operators/symplectic/test_sparse_pauli_op.py
@@ -484,6 +484,19 @@ def test_simplify2(self, num_qubits, num_adds):
         np.testing.assert_array_equal(spp_op.paulis.phase, np.zeros(spp_op.size))
         np.testing.assert_array_equal(simplified_op.paulis.phase, np.zeros(simplified_op.size))
 
+    @combine(num_qubits=[1, 2, 3, 4])
+    def test_simplify_zero(self, num_qubits):
+        """Test simplify method for {num_qubits} qubits with zero operators."""
+        spp_op = self.random_spp_op(num_qubits, 2**num_qubits)
+        zero_op = spp_op - spp_op
+        simplified_op = zero_op.simplify()
+        value = Operator(simplified_op)
+        target = Operator(zero_op)
+        self.assertEqual(value, target)
+        np.testing.assert_array_equal(simplified_op.coeffs, [0])
+        np.testing.assert_array_equal(zero_op.paulis.phase, np.zeros(zero_op.size))
+        np.testing.assert_array_equal(simplified_op.paulis.phase, np.zeros(simplified_op.size))
+
     def test_chop(self):
         """Test chop, which individually truncates real and imaginary parts of the coeffs."""
         eps = 1e-10
@@ -526,6 +539,60 @@ def test_sum_error(self):
         with self.assertRaises(QiskitError):
             SparsePauliOp.sum([1, 2])
 
+    @combine(num_qubits=[1, 2, 3, 4])
+    def test_eq(self, num_qubits):
+        """Test __eq__ method for {num_qubits} qubits."""
+        spp_op1 = self.random_spp_op(num_qubits, 2**num_qubits)
+        spp_op2 = self.random_spp_op(num_qubits, 2**num_qubits)
+        spp_op3 = self.random_spp_op(num_qubits, 2**num_qubits)
+        zero = spp_op3 - spp_op3
+        self.assertEqual(spp_op1, spp_op1)
+        self.assertEqual(spp_op2, spp_op2)
+        self.assertNotEqual(spp_op1, spp_op1 + zero)
+        self.assertNotEqual(spp_op2, spp_op2 + zero)
+        if spp_op1 != spp_op2:
+            self.assertNotEqual(spp_op1 + spp_op2, spp_op2 + spp_op1)
+
+    @combine(num_qubits=[1, 2, 3, 4])
+    def test_equiv(self, num_qubits):
+        """Test equiv method for {num_qubits} qubits."""
+        spp_op1 = self.random_spp_op(num_qubits, 2**num_qubits)
+        spp_op2 = self.random_spp_op(num_qubits, 2**num_qubits)
+        spp_op3 = self.random_spp_op(num_qubits, 2**num_qubits)
+        spp_op4 = self.random_spp_op(num_qubits, 2**num_qubits)
+        zero = spp_op3 - spp_op3
+        zero2 = spp_op4 - spp_op4
+        self.assertTrue(spp_op1.equiv(spp_op1))
+        self.assertTrue(spp_op1.equiv(spp_op1 + zero))
+        self.assertTrue(spp_op2.equiv(spp_op2))
+        self.assertTrue(spp_op2.equiv(spp_op2 + zero))
+        self.assertTrue(zero.equiv(zero2))
+        self.assertTrue((zero + zero2).equiv(zero2 + zero))
+        self.assertTrue((zero2 + zero).equiv(zero + zero2))
+        self.assertTrue((spp_op1 + spp_op2).equiv(spp_op2 + spp_op1))
+        self.assertTrue((spp_op2 + spp_op1).equiv(spp_op1 + spp_op2))
+        self.assertTrue((spp_op1 - spp_op1).equiv(spp_op2 - spp_op2))
+        self.assertTrue((2 * spp_op1).equiv(spp_op1 + spp_op1))
+        self.assertTrue((2 * spp_op2).equiv(spp_op2 + spp_op2))
+        if not spp_op1.equiv(zero):
+            self.assertFalse(spp_op1.equiv(spp_op1 + spp_op1))
+        if not spp_op2.equiv(zero):
+            self.assertFalse(spp_op2.equiv(spp_op2 + spp_op2))
+
+    def test_eq_equiv(self):
+        """Test __eq__ and equiv methods with some specific cases."""
+        with self.subTest("shuffled"):
+            spp_op1 = SparsePauliOp.from_list([("X", 1), ("Y", 2)])
+            spp_op2 = SparsePauliOp.from_list([("Y", 2), ("X", 1)])
+            self.assertNotEqual(spp_op1, spp_op2)
+            self.assertTrue(spp_op1.equiv(spp_op2))
+
+        with self.subTest("w/ zero"):
+            spp_op1 = SparsePauliOp.from_list([("X", 1), ("Y", 1)])
+            spp_op2 = SparsePauliOp.from_list([("X", 1), ("Y", 1), ("Z", 0)])
+            self.assertNotEqual(spp_op1, spp_op2)
+            self.assertTrue(spp_op1.equiv(spp_op2))
+
 
 if __name__ == "__main__":
     unittest.main()