Repository: Qiskit/qiskit-terra (python)

Issue:
``ScipyOptimizer`` options are overriden by default values
<!-- ⚠️ If you do not respect this template, your issue will be closed -->
<!-- ⚠️ Make sure to browse the opened and closed issues -->

### Information

- **Qiskit Terra version**: 
- **Python version**: 3.7.9
- **Operating system**: macOS

### What is the current behavior?

The `ScipyOptimizer`s allow two ways to specify settings of an optimizer:
* directly as named arguments (e.g. `L_BFGS_B(maxiter=10)`)
* via an `options` argument (e.g. `L_BFGS_B(options={"maxiter": 10})`)

However using the second method via `options`, does not actually set the options since they are overriden by the default value.

### Steps to reproduce the problem

```python
from qiskit.algorithms.optimizers import L_BFGS_B

opt = L_BFGS_B(options={"maxiter": 2})
print(opt._options)
```
prints
```
{'maxiter': 15000, 'maxfun': 1000, 'ftol': 2.220446049250313e-15, 'iprint': -1, 'eps': 1e-08}
```


### What is the expected behavior & suggested solution

I think it would be cleaner to not have the `options` in the initializer and let the user just use named arguments. We could still keep `_options` under the hood in the `SciPyOptimizer`, just not expose it.

Alternatively we could make sure all named arguments are `None` by default and then only set the value in the options if they are not none. However that is a bit cumbersome and less nice to read in the docs, so I think just removing `options` from the initializer would be cleaner.






Pull request:
Fix `Optimizer` reconstruction from `settings`
<!--
⚠️ 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

Fix `Optimizer` reconstruction from `settings`. Fixes Qiskit/qiskit-ibm-runtime#407  and supersedes #8343 for the bugfix release.

Also fixes #6682 where this has been described a while ago.

### Details and comments

Fix a bug where creating a new instance of a SciPy optimizer has overwritten the
settings of a previously created instance of the same type. For example, previously:

```python
from qiskit.algorithms.optimmizers import COBYLA
optimizer = COBYLA(maxiter=1)
reconstructed = COBYLA(**optimizer.settings)
````

Created a new `COBYLA` instance with the wrong value `maxiter=1000` (= the default value) and _additionally_ reset
the iterations of the original `optimizer` instance to 1000 as well.


Patch:
diff --git a/qiskit/algorithms/optimizers/scipy_optimizer.py b/qiskit/algorithms/optimizers/scipy_optimizer.py
index d1a41111e..aa8349d03 100644
--- a/qiskit/algorithms/optimizers/scipy_optimizer.py
+++ b/qiskit/algorithms/optimizers/scipy_optimizer.py
@@ -86,11 +86,23 @@ class SciPyOptimizer(Optimizer):
 
     @property
     def settings(self) -> Dict[str, Any]:
-        settings = {
-            "max_evals_grouped": self._max_evals_grouped,
-            "options": self._options,
-            **self._kwargs,
-        }
+        options = self._options.copy()
+        if hasattr(self, "_OPTIONS"):
+            # all _OPTIONS should be keys in self._options, but add a failsafe here
+            attributes = [
+                option
+                for option in self._OPTIONS  # pylint: disable=no-member
+                if option in options.keys()
+            ]
+
+            settings = {attr: options.pop(attr) for attr in attributes}
+        else:
+            settings = {}
+
+        settings["max_evals_grouped"] = self._max_evals_grouped
+        settings["options"] = options
+        settings.update(self._kwargs)
+
         # the subclasses don't need the "method" key as the class type specifies the method
         if self.__class__ == SciPyOptimizer:
             settings["method"] = self._method
diff --git a/releasenotes/notes/fix-optimizer-settings-881585bfa8130cb7.yaml b/releasenotes/notes/fix-optimizer-settings-881585bfa8130cb7.yaml
new file mode 100644
index 000000000..ca0b42f96
--- /dev/null
+++ b/releasenotes/notes/fix-optimizer-settings-881585bfa8130cb7.yaml
@@ -0,0 +1,15 @@
+---
+fixes:
+  - |
+    Fix a bug in the :class:`~.Optimizer` classes where re-constructing a new optimizer instance
+    from a previously exisiting :attr:`~.Optimizer.settings` reset both the new and previous 
+    optimizer settings to the defaults. This notably led to a bug if :class:`~.Optimizer` objects
+    were send as input to Qiskit Runtime programs.
+
+    Now optimizer objects are correctly reconstructed::
+
+      >>> from qiskit.algorithms.optimizers import COBYLA
+      >>> original = COBYLA(maxiter=1)
+      >>> reconstructed = COBYLA(**original.settings)
+      >>> reconstructed._options["maxiter"]
+      1  # used to be 1000!


Test patch:
diff --git a/test/python/algorithms/optimizers/test_optimizers.py b/test/python/algorithms/optimizers/test_optimizers.py
index 0bfe1d14c..5ce8e87cb 100644
--- a/test/python/algorithms/optimizers/test_optimizers.py
+++ b/test/python/algorithms/optimizers/test_optimizers.py
@@ -226,6 +226,31 @@ class TestOptimizerSerialization(QiskitAlgorithmsTestCase):
         self.assertEqual(from_dict._method, method.lower())
         self.assertEqual(from_dict._options, options)
 
+    def test_independent_reconstruction(self):
+        """Test the SciPyOptimizers don't reset all settings upon creating a new instance.
+
+        COBYLA is used as representative example here."""
+
+        kwargs = {"coffee": "without sugar"}
+        options = {"tea": "with milk"}
+        optimizer = COBYLA(maxiter=1, options=options, **kwargs)
+        serialized = optimizer.settings
+        from_dict = COBYLA(**serialized)
+
+        with self.subTest(msg="test attributes"):
+            self.assertEqual(from_dict.settings["maxiter"], 1)
+
+        with self.subTest(msg="test options"):
+            # options should only contain values that are *not* already in the initializer
+            # (e.g. should not contain maxiter)
+            self.assertEqual(from_dict.settings["options"], {"tea": "with milk"})
+
+        with self.subTest(msg="test kwargs"):
+            self.assertEqual(from_dict.settings["coffee"], "without sugar")
+
+        with self.subTest(msg="option ids differ"):
+            self.assertNotEqual(id(serialized["options"]), id(from_dict.settings["options"]))
+
     def test_adam(self):
         """Test ADAM is serializable."""