Repository: scikit-activeml/scikit-activeml (python)

Issue:
BUG: Propagate `random_state` to `cluster_algo` in `TypiClust` and `ProbCover`
### Describe the issue:

Currently, the `random_state` set as `__init__` parameter of the query strategies `TypiClust` and `ProbCover` is not explicitly propagated to the `cluster_algo` but the user must set its `random_state` via `cluster_algo_dict`.

### Reproduce the bug via a code example:

```python
- `TypiClust(random_state=0)` is not deterministic but only `TypiClust(random_state=0, cluster_algo_dict={"random_state": 0})`
- `ProbCover(random_state=0)` is not deterministic but only `ProbCover(random_state=0, cluster_algo_dict={"random_state": 0})`
```

### Error message:

```shell

```

### Context for the issue:

_No response_

Pull request description:
503 reproducibility
Closes #503 

**Additional context**
> Set `random_state` in `cluster_algo_dict`, if required.
> Extend reproducibility checks.
> Add `black` formatting to `pyproject.toml`.

Patch:
diff --git a/.github/workflows/external-pull-request.yml b/.github/workflows/external-pull-request.yml
index 684b1d22..b7be1667 100644
--- a/.github/workflows/external-pull-request.yml
+++ b/.github/workflows/external-pull-request.yml
@@ -86,7 +86,7 @@ jobs:
     - name: Black
       uses: psf/black@stable
       with:
-        options: "--check --line-length 79"
+        options: "--check"
         src: "./skactiveml"
     - name: Lint with flake8
       run: |
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index be17820f..a80f6803 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -87,7 +87,7 @@ jobs:
     - name: Black
       uses: psf/black@stable
       with:
-        options: "--check --line-length 79"
+        options: "--check"
         src: "./skactiveml"
     - name: Lint with flake8
       run: |
diff --git a/docs/contributing.rst b/docs/contributing.rst
index 5011e1b2..95c132e0 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -125,7 +125,7 @@ code using the following commands:
 
 .. code:: bash
 
-   black --line-length 79 example_file.py
+   black skactiveml
 
 Example for Code Contribution Cycle (C3) and Pull Requests
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/pyproject.toml b/pyproject.toml
index 39383175..2430498b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -55,7 +55,11 @@ exclude = [
   "skactiveml.*.tests.*",
 ]
 
-
 [project.urls]
 Homepage       = "https://scikit-activeml.github.io"
 Source         = "https://github.com/scikit-activeml/scikit-activeml"
+
+[tool.black]
+line-length = 79
+target-version = ['py311']
+include = '\.pyi?$'
\ No newline at end of file
diff --git a/skactiveml/pool/_prob_cover.py b/skactiveml/pool/_prob_cover.py
index 5dc96e5e..19941277 100644
--- a/skactiveml/pool/_prob_cover.py
+++ b/skactiveml/pool/_prob_cover.py
@@ -6,6 +6,9 @@ suited for low budgets.
 import numpy as np
 import warnings
 
+
+from copy import deepcopy
+from inspect import signature
 from sklearn.metrics import pairwise_distances
 from sklearn.cluster import KMeans
 from sklearn.utils.validation import column_or_1d
@@ -173,12 +176,19 @@ class ProbCover(SingleAnnotatorPoolQueryStrategy):
                 "Pass a dictionary with corresponding parameter names and "
                 "values according to the `init` function of `cluster_algo`."
             )
+        check_scalar(update, name="update", target_type=bool)
         cluster_algo_dict = (
             {}
             if self.cluster_algo_dict is None
             else self.cluster_algo_dict.copy()
         )
-        check_scalar(update, name="update", target_type=bool)
+
+        # Optionally, set random state.
+        cluster_algo_sig = signature(self.cluster_algo.__init__).parameters
+        algo_has_seed = "random_state" in cluster_algo_sig
+        dict_lacks_seed = "random_state" not in cluster_algo_dict
+        if self.random_state is not None and algo_has_seed and dict_lacks_seed:
+            cluster_algo_dict["random_state"] = deepcopy(self.random_state)
 
         if update or not hasattr(self, "delta_max_"):
             # Compute distances between each pair of observed samples.
@@ -192,6 +202,7 @@ class ProbCover(SingleAnnotatorPoolQueryStrategy):
                 cluster_obj = self.cluster_algo(**cluster_algo_dict)
                 y_cluster = cluster_obj.fit_predict(X)
                 is_impure = y_cluster[:, None] != y_cluster
+                print(np.mean(is_impure))
                 for delta in deltas:
                     edges = self.distances_ <= delta
                     purity = 1 - (edges * is_impure).any(axis=1).mean()
diff --git a/skactiveml/pool/_typi_clust.py b/skactiveml/pool/_typi_clust.py
index eb7c751f..90b8f450 100644
--- a/skactiveml/pool/_typi_clust.py
+++ b/skactiveml/pool/_typi_clust.py
@@ -10,6 +10,9 @@ import numpy as np
 
 from ..base import SingleAnnotatorPoolQueryStrategy
 from ..utils import MISSING_LABEL, labeled_indices, check_scalar, rand_argmax
+
+from copy import deepcopy
+from inspect import signature
 from sklearn.cluster import KMeans
 from sklearn.neighbors import NearestNeighbors
 
@@ -137,8 +140,18 @@ class TypiClust(SingleAnnotatorPoolQueryStrategy):
             y, missing_label=self.missing_label
         )
 
+        # Set number of clusters.
         n_clusters = len(labeled_sample_indices) + batch_size
         cluster_algo_dict[self.n_cluster_param_name] = n_clusters
+
+        # Optionally, set random state.
+        cluster_algo_sig = signature(self.cluster_algo.__init__).parameters
+        algo_has_seed = "random_state" in cluster_algo_sig
+        dict_lacks_seed = "random_state" not in cluster_algo_dict
+        if self.random_state is not None and algo_has_seed and dict_lacks_seed:
+            cluster_algo_dict["random_state"] = deepcopy(self.random_state)
+
+        # Create object for clustering with given parameters.
         cluster_obj = self.cluster_algo(**cluster_algo_dict)
 
         cluster_labels = cluster_obj.fit_predict(X)


Test patch:
diff --git a/skactiveml/pool/tests/test_prob_cover.py b/skactiveml/pool/tests/test_prob_cover.py
index 5372e125..30ef78b3 100644
--- a/skactiveml/pool/tests/test_prob_cover.py
+++ b/skactiveml/pool/tests/test_prob_cover.py
@@ -17,13 +17,18 @@ class TestProbCover(
 ):
     def setUp(self):
         query_default_params_clf = {
-            "X": np.linspace(0, 1, 20).reshape(10, 2),
-            "y": np.hstack([[0, 1], np.full(8, MISSING_LABEL)]),
+            "X": np.random.RandomState(0).uniform(
+                size=(1000, 10), low=-0.5, high=0.5
+            ),
+            "y": np.hstack([[0, 1], np.full(998, MISSING_LABEL)]),
         }
-        cluster_dict = {"random_state": 0, "n_init": 1}
         super().setUp(
             qs_class=ProbCover,
-            init_default_params={"cluster_algo_dict": cluster_dict},
+            init_default_params={
+                "random_state": 0,
+                "deltas": np.linspace(0.2, 2.2, 100),
+                "cluster_algo_dict": {"n_init": 10},
+            },
             query_default_params_clf=query_default_params_clf,
         )
 
diff --git a/skactiveml/pool/tests/test_typi_clust.py b/skactiveml/pool/tests/test_typi_clust.py
index aa501393..e4f8bd26 100644
--- a/skactiveml/pool/tests/test_typi_clust.py
+++ b/skactiveml/pool/tests/test_typi_clust.py
@@ -14,17 +14,23 @@ class TestTypiClust(
 ):
     def setUp(self):
         query_default_params_clf = {
-            "X": np.linspace(0, 1, 20).reshape(10, 2),
-            "y": np.hstack([[0, 1], np.full(8, MISSING_LABEL)]),
+            "X": np.random.RandomState(0).uniform(
+                size=(1000, 10), low=-0.5, high=0.5
+            ),
+            "y": np.hstack([[0, 1], np.full(998, MISSING_LABEL)]),
         }
         query_default_params_reg = {
-            "X": np.linspace(0, 1, 20).reshape(10, 2),
-            "y": np.hstack([[1.1, 2.1], np.full(8, MISSING_LABEL)]),
+            "X": np.random.RandomState(0).uniform(
+                size=(1000, 10), low=-0.5, high=0.5
+            ),
+            "y": np.hstack([[1.1, 2.1], np.full(998, MISSING_LABEL)]),
         }
-        cluster_dict = {"random_state": 0, "n_init": 1}
         super().setUp(
             qs_class=TypiClust,
-            init_default_params={"cluster_algo_dict": cluster_dict},
+            init_default_params={
+                "random_state": 0,
+                "cluster_algo_dict": {"n_init": 1},
+            },
             query_default_params_clf=query_default_params_clf,
             query_default_params_reg=query_default_params_reg,
         )
diff --git a/skactiveml/tests/template_query_strategy.py b/skactiveml/tests/template_query_strategy.py
index 77d92514..1a4d0023 100644
--- a/skactiveml/tests/template_query_strategy.py
+++ b/skactiveml/tests/template_query_strategy.py
@@ -498,9 +498,20 @@ class TemplatePoolQueryStrategy(TemplateQueryStrategy):
     def test_query_reproducibility(self):
         # checks if the results stays the same with same random state
         init_params = deepcopy(self.init_default_params)
-        init_params["random_state"] = np.random.RandomState(0)
 
-        qs = self.qs_class(**init_params)
+        def strip_random_state_inplace(obj):
+            if isinstance(obj, dict):
+                obj.pop("random_state", None)
+                for v in obj.values():
+                    strip_random_state_inplace(v)
+            elif isinstance(obj, (list, tuple)):
+                for item in obj:
+                    strip_random_state_inplace(item)
+
+        strip_random_state_inplace(init_params)
+        init_params["random_state"] = np.random.RandomState(0)
+        qs1 = self.qs_class(**init_params)
+        qs2 = self.qs_class(**init_params)
 
         for query_params in [
             self.query_default_params_clf,
@@ -509,10 +520,13 @@ class TemplatePoolQueryStrategy(TemplateQueryStrategy):
             if query_params is not None:
                 query_params = deepcopy(query_params)
                 query_params["return_utilities"] = True
-                id1, u1 = qs.query(**query_params)
-                id2, u2 = qs.query(**query_params)
+                id1, u1 = qs1.query(**query_params)
+                id1_again, u1_again = qs1.query(**query_params)
+                id2, u2 = qs2.query(**query_params)
 
                 self.assertEqual(len(u1[0]), len(query_params["X"]))
+                np.testing.assert_array_equal(id1, id1_again)
+                np.testing.assert_allclose(u1, u1_again)
                 np.testing.assert_array_equal(id1, id2)
                 np.testing.assert_allclose(u1, u2)
 
@@ -913,7 +927,7 @@ class TemplateSingleAnnotatorStreamQueryStrategy(TemplateQueryStrategy):
             for i in range(3):
                 qs_output2, utilities2 = qs2.query(**query_default_params)
 
-            # Test if all query strategie outputs and utilities are the same
+            # Test if all query strategy outputs and utilities are the same
             np.testing.assert_almost_equal(expected_utilities, utilities)
             self.assertFalse(isinstance(list, type(qs_output)))
             if len(expected_output) == 0:
@@ -994,8 +1008,18 @@ class TemplateSingleAnnotatorStreamQueryStrategy(TemplateQueryStrategy):
     def test_query_reproducibility(self):
         # checks if the results stays the same with same random state
         init_params = deepcopy(self.init_default_params)
-        init_params["random_state"] = np.random.RandomState(0)
 
+        def strip_random_state_inplace(obj):
+            if isinstance(obj, dict):
+                obj.pop("random_state", None)
+                for v in obj.values():
+                    strip_random_state_inplace(v)
+            elif isinstance(obj, (list, tuple)):
+                for item in obj:
+                    strip_random_state_inplace(item)
+
+        strip_random_state_inplace(init_params)
+        init_params["random_state"] = np.random.RandomState(0)
         qs = self.qs_class(**init_params)
 
         for query_params in [