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 description:
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()