Repository: cirKITers/masKIT

Issue:
Support optimisers storing interim results
Some optimisers such as the ADAM optimiser have a memory to store recent results as part of their optimisation concept to improve on other methods such as Gradient Descent. However, currently our project does change the number of parameters relevant for optimisation during the training process. This in turn either creates false assumptions with optimisers having a memory as arrays don't map on each other or in the extreme case lead to an exception during training (e.g. out of bounds errors as length of arrays might mismatch).

To cope with this, we might either use `reset` functionality on the optimisers. Based on the assumption that our count of parameters only changes whenever the gradients are close to `0`, we might assume that interim results of optimisers are meaningless. However, this must be checked with the different implementations of optimisers.

Further, we might introduce the capability to *freeze* values to `0` when we initially wanted to remove them, so that the changing action from a given optimiser does not take place and is plainly ignored from our side. However, for this use case it might happen, that interim values represent another reality as the optimiser might assume that its requested change took place. Here we again have to check implementation of optimisers.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/cirKITers/masKIT/pull/30?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cirKITers) Report
> Merging [#30](https://codecov.io/gh/cirKITers/masKIT/pull/30?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cirKITers) (e138bf3) into [main](https://codecov.io/gh/cirKITers/masKIT/commit/ccaff9bd4a5d2733d9f111ac0cdd78a7c9730ae6?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cirKITers) (ccaff9b) will **decrease** coverage by `0.35%`.
> The diff coverage is `41.66%`.

[![Impacted file tree graph](https://codecov.io/gh/cirKITers/masKIT/pull/30/graphs/tree.svg?width=650&height=150&src=pr&token=8Or2uHjmFE&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cirKITers)](https://codecov.io/gh/cirKITers/masKIT/pull/30?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cirKITers)

```diff
@@            Coverage Diff             @@
##             main      #30      +/-   ##
==========================================
- Coverage   74.54%   74.18%   -0.36%     
==========================================
  Files          15       15              
  Lines        1096     1104       +8     
  Branches      105      108       +3     
==========================================
+ Hits          817      819       +2     
- Misses        275      278       +3     
- Partials        4        7       +3     
```


| [Impacted Files](https://codecov.io/gh/cirKITers/masKIT/pull/30?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cirKITers) | Coverage Δ | |
|---|---|---|
| [main.py](https://codecov.io/gh/cirKITers/masKIT/pull/30/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cirKITers#diff-bWFpbi5weQ==) | `0.00% <0.00%> (ø)` | |
| [maskit/masks.py](https://codecov.io/gh/cirKITers/masKIT/pull/30/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cirKITers#diff-bWFza2l0L21hc2tzLnB5) | `97.02% <45.45%> (-2.98%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/cirKITers/masKIT/pull/30?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cirKITers).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cirKITers)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/cirKITers/masKIT/pull/30?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cirKITers). Last update [0313002...e138bf3](https://codecov.io/gh/cirKITers/masKIT/pull/30?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cirKITers). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=cirKITers).


Patch:
diff --git a/main.py b/main.py
index 25b123d..0521fd0 100644
--- a/main.py
+++ b/main.py
@@ -45,7 +45,11 @@ def cost_iris(
 
 
 def init_parameters(
-    layers: int, current_layers: int, wires: int, default_value: Optional[float]
+    layers: int,
+    current_layers: int,
+    wires: int,
+    default_value: Optional[float],
+    dynamic_parameters: bool = True,
 ) -> MaskedCircuit:
     params_uniform = np.random.uniform(
         low=-np.pi, high=np.pi, size=(current_layers, wires)
@@ -57,6 +61,7 @@ def init_parameters(
         layers=layers,
         wires=wires,
         default_value=default_value,
+        dynamic_parameters=dynamic_parameters,
     )
     mc.layer_mask[current_layers:] = True
     return mc
@@ -121,7 +126,15 @@ def train(
             )
 
     # set up parameters
-    masked_circuit = init_parameters(layers, current_layers, wires, default_value)
+    masked_circuit = init_parameters(
+        layers,
+        current_layers,
+        wires,
+        default_value,
+        dynamic_parameters=False
+        if train_params["optimizer"] == ExtendedOptimizers.ADAM
+        else True,
+    )
 
     # -----------------------------
     # ======= TRAINING LOOP =======
diff --git a/maskit/masks.py b/maskit/masks.py
index bf9ef3f..45c0fb0 100644
--- a/maskit/masks.py
+++ b/maskit/masks.py
@@ -183,10 +183,17 @@ class MaskedCircuit(object):
     """
     A MaskedCircuit supports masking of different components including wires, layers,
     and parameters.
+    Masking naturally removes active parameters from a circuit. However, some optimisers
+    expect the array of parameters to remain stable across iteration steps;
+    use ``dynamic_parameters=False`` to force the mask to always yield the full set of
+    parameters in such cases.
+    The mask will still prevent modification of inactive parameters.
 
     :param parameters: Initial parameter set for circuit
     :param layers: Number of layers
     :param wires: Number of wires
+    :param dynamic_parameters: Whether the array of differentiable parameters may
+        change size/order
     :param default_value: Default value for gates that are added back in. In case of
         `None` that is also the default, the last known value is assumed
     :param parameter_mask: Initialization values of paramater mask, defaults to `None`
@@ -200,6 +207,7 @@ class MaskedCircuit(object):
         "_parameter_mask",
         "parameters",
         "default_value",
+        "_dynamic_parameters",
     )
 
     def __init__(
@@ -207,6 +215,7 @@ class MaskedCircuit(object):
         parameters: np.ndarray,
         layers: int,
         wires: int,
+        dynamic_parameters: bool = True,
         default_value: Optional[float] = None,
         parameter_mask: Optional[np.ndarray] = None,
         layer_mask: Optional[np.ndarray] = None,
@@ -225,11 +234,14 @@ class MaskedCircuit(object):
         self._layer_mask = Mask(shape=(layers,), parent=self, mask=layer_mask)
         self._wire_mask = Mask(shape=(wires,), parent=self, mask=wire_mask)
         self.default_value = default_value
+        self._dynamic_parameters = dynamic_parameters
 
     @property
     def differentiable_parameters(self) -> np.ndarray:
         """Subset of parameters that are not masked and therefore differentiable."""
-        return self.parameters[~self.mask]
+        if self._dynamic_parameters:
+            return self.parameters[~self.mask]
+        return self.parameters
 
     @differentiable_parameters.setter
     def differentiable_parameters(self, value) -> None:
@@ -237,7 +249,10 @@ class MaskedCircuit(object):
         Provides a setter for the differentiable parameters. It is ensured that the
         updated values are written into the underlying :py:attr:`~.parameters`.
         """
-        self.parameters[~self.mask] = value
+        if self._dynamic_parameters:
+            self.parameters[~self.mask] = value
+        else:
+            self.parameters[~self.mask] = value[~self.mask]
 
     @property
     def mask(self) -> np.ndarray:
@@ -357,6 +372,7 @@ class MaskedCircuit(object):
         clone._wire_mask = self._wire_mask.copy(clone)
         clone.parameters = self.parameters.copy()
         clone.default_value = self.default_value
+        clone._dynamic_parameters = self._dynamic_parameters
         return clone
 
     def expanded_parameters(self, changed_parameters: np.ndarray) -> np.ndarray:
@@ -373,7 +389,10 @@ class MaskedCircuit(object):
         :param changed_parameters: Current set of differentiable parameters
         """
         result = self.parameters.astype(object)
-        result[~self.mask] = changed_parameters.flatten()
+        if self._dynamic_parameters:
+            result[~self.mask] = changed_parameters.flatten()
+        else:
+            result[~self.mask] = changed_parameters[~self.mask]
         return result
 
     @staticmethod


Test patch:
diff --git a/tests/test_masks.py b/tests/test_masks.py
index 1c5cbca..2f4b8fc 100644
--- a/tests/test_masks.py
+++ b/tests/test_masks.py
@@ -235,6 +235,30 @@ class TestMaskedCircuits:
         mp.shrink(axis=PerturbationAxis.LAYERS)
         assert pnp.sum(mp.parameters == 0) == 6
 
+    def test_dynamic_parameters(self):
+        size = 3
+        circuit = self._create_circuit(size)
+        circuit.layer_mask[0] = True
+        assert circuit.differentiable_parameters.size == (size - 1) * size
+        assert (
+            circuit.expanded_parameters(
+                changed_parameters=circuit.differentiable_parameters
+            ).size
+            == size * size
+        )
+        # in this case, no wrong values can be written
+
+        circuit._dynamic_parameters = False  # disable dynamic parameters
+        assert circuit.differentiable_parameters.size == size * size
+        assert (
+            circuit.expanded_parameters(changed_parameters=circuit.parameters).size
+            == size * size
+        )
+        circuit.differentiable_parameters = pnp.ones((size, size))
+        # ensure that first layer has not been changed
+        for i in range(size):
+            assert circuit.parameters[0, i] != 1
+
     def _create_circuit(self, size):
         parameters = pnp.random.uniform(low=-pnp.pi, high=pnp.pi, size=(size, size))
         return MaskedCircuit(parameters=parameters, layers=size, wires=size)