Repository: Qiskit/qiskit-terra (python)

Issue:
Estimator fails when reused
### Environment

- **Qiskit Terra version**: 0.22
- **Python version**: 3.9
- **Operating system**:  Linux


### What is happening?

Estimated value can be incorrect when using an Estimator for different observables.

### How can we reproduce the issue?

Run this code. Uncomment the Estimator in the loop to see the alternating values it should produce

```
from qiskit.opflow.primitive_ops import PauliSumOp
from qiskit.primitives import Estimator
from qiskit.circuit import QuantumCircuit

qc = QuantumCircuit(4)
qc.ry(3, 0)
qc.rz(1.1, 0)
qc.cx(0, 1)
qc.h(1)
qc.cx(1, 2)
qc.rx(1.2, 2)
qc.rz(2.1, 3)
qc.cx(3, 0)

num_ops = 2
def get_op(num_op):
  if num_op == 0:
    op = PauliSumOp.from_list(
        [
            ("IXIX", 6.727086146309219),
            ("IXZX", -0.727086146309219),
            ("ZXZX", -1.727086146309219),
            ("ZXIX", 1.727086146309219),
        ]
    )
  else:
    op = PauliSumOp.from_list(
        [
            ("ZXIX", 0.727086146309219),
            ("ZXZX", -0.727086146309219),
            ("IXZX", -0.727086146309219),
            ("IXIX", 0.727086146309219),
        ]
  )
  return op

estimator = Estimator()

for j in range(10):
    # estimator = Estimator()
    num_op = j % num_ops
    job = estimator.run([qc], [get_op(num_op)])
    value = job.result().values[0]
    print(value)
```

### What should happen?

It should give the right values

### Any suggestions?

This is related to #8725 which cached and matched circuits by id(). The estimator is still doing this for observables and causes issues if Python reuses an id for different operator content.

Pull request:
Introduce observable key for Estimator
<!--
⚠️ If you do not respect this template, your pull request will be closed.
⚠️ Your pull request title should be short detailed and understandable for all.
⚠️ Also, please add a release note file using reno if the change needs to be
  documented in the release notes.
⚠️ If your pull request fixes an open issue, please link to the issue.

- [ ] I have added the tests to cover my changes.
- [ ] I have updated the documentation accordingly.
- [ ] I have read the CONTRIBUTING document.
-->

### Summary

Fixes #8836

### Details and comments




Patch:
diff --git a/qiskit/primitives/backend_estimator.py b/qiskit/primitives/backend_estimator.py
index 323fbe36f..210ec2f5c 100644
--- a/qiskit/primitives/backend_estimator.py
+++ b/qiskit/primitives/backend_estimator.py
@@ -34,7 +34,7 @@ from qiskit.result import Counts, Result
 from qiskit.tools.monitor import job_monitor
 from qiskit.transpiler import PassManager
 
-from .utils import _circuit_key, init_observable
+from .utils import _circuit_key, _observable_key, init_observable
 
 
 def _run_circuits(
@@ -245,13 +245,14 @@ class BackendEstimator(BaseEstimator):
                 self._parameters.append(circuit.parameters)
         observable_indices = []
         for observable in observables:
-            index = self._observable_ids.get(id(observable))
+            observable = init_observable(observable)
+            index = self._observable_ids.get(_observable_key(observable))
             if index is not None:
                 observable_indices.append(index)
             else:
                 observable_indices.append(len(self._observables))
-                self._observable_ids[id(observable)] = len(self._observables)
-                self._observables.append(init_observable(observable))
+                self._observable_ids[_observable_key(observable)] = len(self._observables)
+                self._observables.append(observable)
         job = PrimitiveJob(
             self._call, circuit_indices, observable_indices, parameter_values, **run_options
         )
diff --git a/qiskit/primitives/base_estimator.py b/qiskit/primitives/base_estimator.py
index 3c05a45c1..0d6cd4e58 100644
--- a/qiskit/primitives/base_estimator.py
+++ b/qiskit/primitives/base_estimator.py
@@ -96,7 +96,7 @@ from qiskit.quantum_info.operators.base_operator import BaseOperator
 from qiskit.utils.deprecation import deprecate_arguments, deprecate_function
 
 from .estimator_result import EstimatorResult
-from .utils import _circuit_key
+from .utils import _circuit_key, _observable_key, init_observable
 
 
 class BaseEstimator(ABC):
@@ -149,7 +149,7 @@ class BaseEstimator(ABC):
         # To guarantee that they exist as instance variable.
         # With only dynamic set, the python will not know if the attribute exists or not.
         self._circuit_ids: dict[tuple, int] = self._circuit_ids
-        self._observable_ids: dict[int, int] = self._observable_ids
+        self._observable_ids: dict[tuple, int] = self._observable_ids
 
         if parameters is None:
             self._parameters = [circ.parameters for circ in self._circuits]
@@ -190,9 +190,12 @@ class BaseEstimator(ABC):
             self._observable_ids = {}
         elif isinstance(observables, Iterable):
             observables = copy(observables)
-            self._observable_ids = {id(observable): i for i, observable in enumerate(observables)}
+            self._observable_ids = {
+                _observable_key(init_observable(observable)): i
+                for i, observable in enumerate(observables)
+            }
         else:
-            self._observable_ids = {id(observables): 0}
+            self._observable_ids = {_observable_key(init_observable(observables)): 0}
         return self
 
     @deprecate_function(
@@ -324,7 +327,7 @@ class BaseEstimator(ABC):
                 "initialize the session."
             )
         observables = [
-            self._observable_ids.get(id(observable))
+            self._observable_ids.get(_observable_key(observable))
             if not isinstance(observable, (int, np.integer))
             else observable
             for observable in observables
diff --git a/qiskit/primitives/estimator.py b/qiskit/primitives/estimator.py
index 121b12abe..bd4a040ef 100644
--- a/qiskit/primitives/estimator.py
+++ b/qiskit/primitives/estimator.py
@@ -29,7 +29,13 @@ from qiskit.quantum_info.operators.base_operator import BaseOperator
 from .base_estimator import BaseEstimator
 from .estimator_result import EstimatorResult
 from .primitive_job import PrimitiveJob
-from .utils import _circuit_key, bound_circuit_to_instruction, init_circuit, init_observable
+from .utils import (
+    _circuit_key,
+    _observable_key,
+    bound_circuit_to_instruction,
+    init_circuit,
+    init_observable,
+)
 
 
 class Estimator(BaseEstimator):
@@ -166,13 +172,14 @@ class Estimator(BaseEstimator):
                 self._parameters.append(circuit.parameters)
         observable_indices = []
         for observable in observables:
-            index = self._observable_ids.get(id(observable))
+            observable = init_observable(observable)
+            index = self._observable_ids.get(_observable_key(observable))
             if index is not None:
                 observable_indices.append(index)
             else:
                 observable_indices.append(len(self._observables))
-                self._observable_ids[id(observable)] = len(self._observables)
-                self._observables.append(init_observable(observable))
+                self._observable_ids[_observable_key(observable)] = len(self._observables)
+                self._observables.append(observable)
         job = PrimitiveJob(
             self._call, circuit_indices, observable_indices, parameter_values, **run_options
         )
diff --git a/qiskit/primitives/utils.py b/qiskit/primitives/utils.py
index e12e34286..b0cdedf93 100644
--- a/qiskit/primitives/utils.py
+++ b/qiskit/primitives/utils.py
@@ -143,6 +143,17 @@ def _circuit_key(circuit: QuantumCircuit, functional: bool = True) -> tuple:
     )
 
 
+def _observable_key(observable: SparsePauliOp) -> tuple:
+    """Private key function for SparsePauliOp.
+    Args:
+        observable: Input operator.
+
+    Returns:
+        Key for observables.
+    """
+    return tuple(observable.to_list())
+
+
 def bound_circuit_to_instruction(circuit: QuantumCircuit) -> Instruction:
     """Build an :class:`~qiskit.circuit.Instruction` object from
     a :class:`~qiskit.circuit.QuantumCircuit`


Test patch:
diff --git a/test/python/primitives/test_backend_estimator.py b/test/python/primitives/test_backend_estimator.py
index 2391d142a..93abd6dd8 100644
--- a/test/python/primitives/test_backend_estimator.py
+++ b/test/python/primitives/test_backend_estimator.py
@@ -20,7 +20,6 @@ from ddt import ddt
 
 from qiskit.circuit import QuantumCircuit
 from qiskit.circuit.library import RealAmplitudes
-from qiskit.opflow import PauliSumOp
 from qiskit.primitives import BackendEstimator, EstimatorResult
 from qiskit.providers import JobV1
 from qiskit.providers.fake_provider import FakeNairobi, FakeNairobiV2
@@ -37,7 +36,7 @@ class TestBackendEstimator(QiskitTestCase):
     def setUp(self):
         super().setUp()
         self.ansatz = RealAmplitudes(num_qubits=2, reps=2)
-        self.observable = PauliSumOp.from_list(
+        self.observable = SparsePauliOp.from_list(
             [
                 ("II", -1.052373245772859),
                 ("IZ", 0.39793742484318045),
diff --git a/test/python/primitives/test_estimator.py b/test/python/primitives/test_estimator.py
index bd2e8b41b..d0756ca3d 100644
--- a/test/python/primitives/test_estimator.py
+++ b/test/python/primitives/test_estimator.py
@@ -20,6 +20,7 @@ from qiskit.circuit import Parameter, QuantumCircuit
 from qiskit.circuit.library import RealAmplitudes
 from qiskit.opflow import PauliSumOp
 from qiskit.primitives import Estimator, EstimatorResult
+from qiskit.primitives.utils import _observable_key
 from qiskit.providers import JobV1
 from qiskit.quantum_info import Operator, SparsePauliOp
 from qiskit.test import QiskitTestCase
@@ -31,7 +32,7 @@ class TestEstimator(QiskitTestCase):
     def setUp(self):
         super().setUp()
         self.ansatz = RealAmplitudes(num_qubits=2, reps=2)
-        self.observable = PauliSumOp.from_list(
+        self.observable = SparsePauliOp.from_list(
             [
                 ("II", -1.052373245772859),
                 ("IZ", 0.39793742484318045),
@@ -637,6 +638,16 @@ class TestEstimator(QiskitTestCase):
             self.assertIsInstance(result, EstimatorResult)
             np.testing.assert_allclose(result.values, [-1.307397243478641])
 
+    def test_different_circuits(self):
+        """Test collision of quantum observables."""
+
+        def get_op(i):
+            op = SparsePauliOp.from_list([("IXIX", i)])
+            return op
+
+        keys = [_observable_key(get_op(i)) for i in range(5)]
+        self.assertEqual(len(keys), len(set(keys)))
+
 
 if __name__ == "__main__":
     unittest.main()