Repository: unitaryfoundation/mitiq

Issue:
get_scale_factors method not working in LinearFactory
<!-- Before submitting an issue please make sure you are: -->
<!-- running the latest version of mitiq -->
<!-- checked to make sure this bug has not already been reported -->


## Issue Description
<!-- A short description of the bug here, along with what you expected the behavior to be. -->
I am trying to pass `scale_factors` in the `execute_with_zne` method through the `LinearFactory` Object as `execute_with_zne` doesn't have a parameter for `scale_factors` directly. But while doing it, I could not get the `scale_factors` list using the method `get_scale_factors`. The function returns an empty list.

<!-- Thanks for helping us improve mitiq! 🙂 -->

## How to Reproduce

```
import mitiq

from mitiq import benchmarks
import numpy as np
from cirq import DensityMatrixSimulator, depolarize
from mitiq.interface import convert_to_mitiq
from mitiq import zne
from mitiq.zne.inference import LinearFactory

frontend = "cirq"
circuit = benchmarks.generate_rb_circuits(
  n_qubits=1, num_cliffords=2, return_type = frontend, trials=1,
)

def execute(circuit, noise_level=0.01):
    """Returns Tr[ρ |0⟩⟨0|] where ρ is the state prepared by the circuit
    executed with depolarizing noise.
    """
    # Replace with code based on your frontend and backend.
    mitiq_circuit, _ = convert_to_mitiq(circuit)
    noisy_circuit = mitiq_circuit.with_noise(depolarize(p=noise_level))
    rho = DensityMatrixSimulator().simulate(noisy_circuit).final_density_matrix
    return rho[0, 0].real

linear_fac = LinearFactory(scale_factors=[1.0, 2.0, 3.0])

print(linear_fac.get_scale_factors()).  ##this line should give scale factors list

mitigated_result = zne.execute_with_zne(circuit, execute, factory=linear_fac)
```

## Expected behavior
<!-- A clear and concise description of what you expected to happen. -->

The print statement should give the scale factors, but it is giving an empty list.

# Note
If I change the order of the last two lines in the above code like:

```
mitigated_result = zne.execute_with_zne(circuit, execute, factory=linear_fac)
print(linear_fac.get_scale_factors()).  ##this line should give scale factors list
```

I do get the scale factors printed. But I don't understand why it didn't work in the previous case.

---

<details>
<summary>Environment Context</summary>

Use the `about()` function to summarize information on operating system, python version and dependencies.
```python
from mitiq import about
about()
```

```
Mitiq: A Python toolkit for implementing error mitigation on quantum computers
==============================================================================
Authored by: Mitiq team, 2020 & later (https://github.com/unitaryfund/mitiq)

Mitiq Version:	0.43.0

Core Dependencies
-----------------
Cirq Version:	1.4.1
NumPy Version:	1.26.4
SciPy Version:	1.15.2

Optional Dependencies
---------------------
PyQuil Version:	4.11.0
Qiskit Version:	Not installed
Braket Version:	1.91.0

Python Version:	3.10.15
Platform Info:	Darwin (x86_64)
```

Additional Python Environment Details (`pip freeze` or `conda list`):

```
Copy and paste the output of `pip freeze` or `conda list` here.
```

</details>

Discussion:
@natestemen: I agree this is confusing behavior that should change, but there is a reason behind it. In some extrapolation techniques the scale factors are calculated adaptively, and hence not known after the object has been initialized (e.g. the [`AdaExpFactory`](https://github.com/unitaryfoundation/mitiq/blob/d86effc774489bfedd9cc499117af0cca6fdece9/mitiq/zne/inference.py#L1475)). Since it's not known for all factories, I believe it was left generic so as to not require custom implementations for each factory.

Since the most common factories **do** have known scale factors post-initialization I think it makes sense to have custom `get_scale_factors` methods for them.@natestemen: So next steps would be to identify which factories have known scale-factors post-initialization, and then implement new `get_scale_factors` methods in their respective classes.@bdg221: @natestemen The `BatchedFactory` abstract class takes `scaled_factors` when being initialized. `get_scale_factors` only pulls from `self._instack` of the `Factory` object. 

`self._instack` is populated in 2 ways:

1. When the `run()` or `run_classical()` is called on the BatchedFactory object, the following is called which clears the `self._instack` and then populates it:
```
        self.reset()
        self._batch_populate_instack()
```
2. If a user calls [push](https://mitiq.readthedocs.io/en/stable/apidoc.html#mitiq.zne.inference.Factory.push), then a `scale_factor` can be added to the factory. This can be seen in multiple examples including [this one](https://mitiq.readthedocs.io/en/stable/examples/ggi_summer_school_unsolved.html#step-2-inference-of-the-ideal-result-via-zero-noise-extrapolation):
```
# Initialize a Richardson extrapolation object
richardson_factory = zne.RichardsonFactory(scale_factors)

# Load the previously measured data
for s, val in zip(scale_factors, noise_scaled_vals):
    richardson_factory.push({"scale_factor": s}, val)

print("The Richardson zero-noise extrapolation is:", richardson_factory.reduce())
_ = richardson_factory.plot_fit()
``` 

I do not understand the logic behind option 2. When you create the factory, you are passing in the scale_factors, but if you want to use those before calling `run` you have to manually add them??? 

The [test_interference.py](https://github.com/unitaryfoundation/mitiq/blob/9d071cd9529f4802d59dec9a5b66230a6969faae/mitiq/zne/tests/test_inference.py#L740) reveals the strangeness with the first portion of `test_push_after_already_reduced_warning()`:
```
    fac = LinearFactory([1, 2])
    fac.push({"scale_factor": 1.0}, 1.0)
    fac.push({"scale_factor": 2.0}, 2.0)
```

You can see in creating the LinearFactory the scale factors are passed in .... and then the user has to push them to the factory again???


With this in mind, I have two proposed solutions for this issue:

1.  In the abstract class `BatchedFactory` after the factory is created with `super(BatchedFactory, self).__init__()`, we would make a call to `self._batch_populate_instack()`. This would add the scale_factors and shots to the `self._instack` from the start. When `run` or `run_classical` is called, the `self._instack` would still be wiped clean and repopulated from `self._scale_factors`.
2. In `get_scale_factors`, we could check if `self._instack` returns a list of scale factors and if not, we could check `self._scale_factors` and return that if it contains the data.

Are you aware of any scenarios where a user would pass scale factor values in with the factory initialization, manually change them, and then use the factory for functionality other than `run` or `classical_run`? This seems very unlikely to me and therefore I would recommend going with option 1 and correcting the unintuitive code in `test_push_after_already_reduced_warning()` and any examples using `push` in this manner. @bdg221: After looking more at the API docs and examples, it appears that if you have no intention of using `run` or `run_classical` for a Factory because you already have the expectation values at the different scale factors, then you could use `push()` to load that into a Factory for extrapolation. 

It still seems quite odd that `get_scale_factors()` is looking only in `self._instack` especially when scale factor values may be passed in. 

Since there are scenarios that you would not want the `self._instack` to be auto-populated with the scale factor values, it may be best to modify `get_scale_factors()` to check `self._scale_factors` to see if it exists if `self._instack` does not return scale factors.

@natestemen: > You can see in creating the LinearFactory the scale factors are passed in .... and then the user has to push them to the factory again???

Well, the user is _really_ pushing expectation values, and you need to tell the factory which scale factor the expectation value corresponds to.

Solution patch:
diff --git a/mitiq/zne/inference.py b/mitiq/zne/inference.py
index bb0440bb..d5d4dc19 100644
--- a/mitiq/zne/inference.py
+++ b/mitiq/zne/inference.py
@@ -223,10 +223,15 @@ class Factory(ABC):
         self._options: Dict[str, Optional[float]] = {}
 
     def get_scale_factors(self) -> List[float]:
-        """Returns the scale factors at which the factory has computed
-        expectation values.
+        """Returns the scale factors that were either passed in to the factory
+        or at which the factory has computed expectation values.
         """
-        return [params.get("scale_factor", 0.0) for params in self._instack]
+        scale_factors = [
+            params.get("scale_factor", 0.0) for params in self._instack
+        ]
+        if not scale_factors and hasattr(self, "_scale_factors"):
+            return [scale_factor for scale_factor in self._scale_factors]
+        return scale_factors
 
     def get_expectation_values(self) -> List[float]:
         """Returns the expectation values computed by the factory."""
diff --git a/requirements/requirements-dev.txt b/requirements/requirements-dev.txt
index d1412c0b..fdd4ab4e 100644
--- a/requirements/requirements-dev.txt
+++ b/requirements/requirements-dev.txt
@@ -21,7 +21,7 @@ sphinx-gallery==0.15.0
 nbsphinx==0.9.3
 matplotlib==3.8.1
 pandas==2.1.3
-pyscf==2.8.0; sys_platform != 'win32'
+pyscf==2.9.0; sys_platform != 'win32'
 openfermion==1.7.0; sys_platform != 'win32'
 openfermionpyscf==0.5; sys_platform != 'win32'
 bqskit==1.1.1


Test patch:
diff --git a/mitiq/zne/tests/test_inference.py b/mitiq/zne/tests/test_inference.py
index 51b8fd6b..581ca67c 100644
--- a/mitiq/zne/tests/test_inference.py
+++ b/mitiq/zne/tests/test_inference.py
@@ -17,6 +17,7 @@ from pytest import mark, raises, warns
 
 from mitiq.zne.inference import (
     AdaExpFactory,
+    AdaptiveFactory,
     ConvergenceWarning,
     ExpFactory,
     ExtrapolationError,
@@ -148,7 +149,11 @@ def test_get_scale_factors_static_factories(factory):
         fac = factory(scale_factors=scale_factors)
 
     # Expectation values haven't been computed at any scale factors yet
-    assert not fac.get_scale_factors()
+    if isinstance(fac, AdaptiveFactory):
+        assert not fac.get_scale_factors()
+    else:
+        assert isinstance(fac.get_scale_factors(), list)
+        assert np.allclose(fac.get_scale_factors(), scale_factors)
 
     # Compute expectation values at all the scale factors
     fac.run_classical(apply_seed_to_func(f_lin, seed=1))