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

Issue:
Implementation of RT-AL
Implementation [Regression tree‑based active learning](https://link.springer.com/article/10.1007/s10618-023-00951-7) (RT-AL)


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 3d4deb23..2566b95e 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -42,7 +42,7 @@ jobs:
         # stop the build if there are Python syntax errors or undefined names
         flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
         # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
-        flake8 . --count --exit-zero --statistics
+        flake8 . --ignore=E203,W503 --count --exit-zero --statistics
     - name: Test with pytest
       run: |
         pytest --cov-config=.coveragerc --cov=./skactiveml --cov-report=xml
diff --git a/docs/examples/pool/_regression_tree_based_al.json b/docs/examples/pool/_regression_tree_based_al.json
new file mode 100644
index 00000000..8a41f7b2
--- /dev/null
+++ b/docs/examples/pool/_regression_tree_based_al.json
@@ -0,0 +1,53 @@
+[
+  {
+    "class" : "RegressionTreeBasedAL",
+    "package" : "pool",
+    "method" : "Regression Tree Based Active Learning (RT-AL) with Random Selection",
+    "template" : "examples/template_pool_regression_batch.py",
+    "tags" : ["pool",  "regression", "single-annotator"],
+    "title" : "Regression Tree Based Active Learning (RT-AL) with Random Selection",
+    "refs" : ["jose2023regression"],
+    "sequence" : ["title", "text_0", "plot", "refs"],
+    "text_0" : "",
+    "import_reg" : ["from sklearn.tree import DecisionTreeRegressor", "from skactiveml.regressor import SklearnRegressor"],
+    "import_misc" : ["from skactiveml.pool import RegressionTreeBasedAL"],
+    "init_reg" : "SklearnRegressor(DecisionTreeRegressor(min_samples_leaf=2, random_state=random_state), random_state=random_state)",
+    "init_qs" : "RegressionTreeBasedAL(random_state=random_state)",
+    "query_params" : "X=X, y=y, reg=reg, return_utilities=True",
+    "batch_size" : "3"
+  },
+  {
+    "class" : "RegressionTreeBasedAL",
+    "package" : "pool",
+    "method" : "Regression Tree Based Active Learning (RT-AL) with Diversity Selection",
+    "template" : "examples/template_pool_regression_batch.py",
+    "tags" : ["pool",  "regression", "single-annotator"],
+    "title" : "Regression Tree Based Active Learning (RT-AL) with Diversity Selection",
+    "refs" : ["jose2023regression"],
+    "sequence" : ["title", "text_0", "plot", "refs"],
+    "text_0" : "",
+    "import_reg" : ["from sklearn.tree import DecisionTreeRegressor", "from skactiveml.regressor import SklearnRegressor"],
+    "import_misc" : ["from skactiveml.pool import RegressionTreeBasedAL"],
+    "init_reg" : "SklearnRegressor(DecisionTreeRegressor(min_samples_leaf=2, random_state=random_state), random_state=random_state)",
+    "init_qs" : "RegressionTreeBasedAL(method='diversity', random_state=random_state)",
+    "query_params" : "X=X, y=y, reg=reg, return_utilities=True",
+    "batch_size" : "3"
+  },
+  {
+    "class" : "RegressionTreeBasedAL",
+    "package" : "pool",
+    "method" : "Regression Tree Based Active Learning (RT-AL) with Representativity Selection",
+    "template" : "examples/template_pool_regression_batch.py",
+    "tags" : ["pool",  "regression", "single-annotator"],
+    "title" : "Regression Tree Based Active Learning (RT-AL) with Representativity Selection",
+    "refs" : ["jose2023regression"],
+    "sequence" : ["title", "text_0", "plot", "refs"],
+    "text_0" : "",
+    "import_reg" : ["from sklearn.tree import DecisionTreeRegressor", "from skactiveml.regressor import SklearnRegressor"],
+    "import_misc" : ["from skactiveml.pool import RegressionTreeBasedAL"],
+    "init_reg" : "SklearnRegressor(DecisionTreeRegressor(min_samples_leaf=2, random_state=random_state), random_state=random_state)",
+    "init_qs" : "RegressionTreeBasedAL(method='representativity', random_state=random_state)",
+    "query_params" : "X=X, y=y, reg=reg, return_utilities=True",
+    "batch_size" : "3"
+  }
+]
\ No newline at end of file
diff --git a/docs/examples/template_pool_regression_batch.py b/docs/examples/template_pool_regression_batch.py
new file mode 100644
index 00000000..d73afac6
--- /dev/null
+++ b/docs/examples/template_pool_regression_batch.py
@@ -0,0 +1,99 @@
+# %%
+# .. note::
+#
+#    The generated animation can be found at the bottom of the page.
+import numpy as np
+from matplotlib import pyplot as plt, animation
+from scipy.stats import uniform
+
+from skactiveml.utils import MISSING_LABEL, labeled_indices, is_labeled
+
+"$import_reg|from skactiveml.regressor import NICKernelRegressor"
+"$import_misc"
+
+random_state = np.random.RandomState(0)
+
+
+def true_function(X_):
+    return (X_**3 + 2 * X_**2 + X_ - 1).flatten()
+
+n_samples = "$n_samples|100"
+X = np.concatenate(
+        [uniform.rvs(0, 1.5, 9 * n_samples // 10, random_state=random_state),
+         uniform.rvs(1.5, 0.5, n_samples // 10, random_state=random_state)]
+    ).reshape(-1, 1)
+
+noise = np.vectorize(lambda x : random_state.rand() * 1.5 if x < 1
+                                else random_state.rand() * 0.5)
+
+# Build a dataset.
+y_true = true_function(X) + noise(X).flatten()
+y = np.full(shape=y_true.shape, fill_value=MISSING_LABEL)
+X_test = np.linspace(0, 2, num="$res|1000").reshape(-1, 1)
+
+# Initialise the classifier.
+reg = "$init_reg|NICKernelRegressor(random_state=random_state, metric_dict={'gamma': 15.0})"
+# Initialise the query strategy.
+qs = "$init_qs"
+"$preproc"
+
+# Preparation for plotting.
+fig, (ax_1, ax_2) = plt.subplots(2, 1, sharex=True)
+artists = []
+
+# The active learning cycle:
+n_cycles = "$n_cycles|20"
+batch_size = "$batch_size|1"
+for c in range(n_cycles):
+    # Fit the classifier.
+    reg.fit(X, y)
+
+    # Get labeled instances.
+    X_labeled = X[labeled_indices(y)]
+
+    # Query the next instance/s.
+    query_idx, utilities = qs.query("$query_params", batch_size=batch_size)
+
+    # Plot the labeled data.
+    coll_old = list(ax_1.collections) + list(ax_2.collections)
+    title = ax_1.text(
+        0.5,
+        1.05,
+        f"Prediction after acquring {c} labels",
+        size=plt.rcParams["axes.titlesize"],
+        ha="center",
+        transform=ax_1.transAxes,
+    )
+
+    sort_mask = np.argsort(X.flatten())
+    X_plot = X.flatten()[sort_mask]
+    utilities_plot = utilities[0][sort_mask]/batch_size
+    utilities_plot[np.isnan(utilities_plot)] = 0
+
+    is_lbld = is_labeled(y)
+
+    (utility_line,) = ax_2.plot(X_plot, utilities_plot, c="green")
+    utility_fill = plt.fill_between(
+        X_plot, utilities_plot, color="green", alpha=0.3
+    )
+
+    ax_1.scatter(X[~is_lbld], y_true[~is_lbld], c="lightblue")
+    ax_1.scatter(X[is_lbld], y[is_lbld], c="orange")
+
+    y_pred = reg.predict(X_test)
+    (prediction_line,) = ax_1.plot(X_test, y_pred, c="black")
+
+    coll_new = list(ax_1.collections) + list(ax_2.collections)
+    coll_new.append(title)
+    artists.append(
+        [x for x in coll_new if (x not in coll_old)]
+        + [utility_line, utility_fill, prediction_line]
+    )
+
+    # Label the queried instances.
+    y[query_idx] = y_true[query_idx]
+
+ani = animation.ArtistAnimation(fig, artists, interval=1000, blit=True)
+
+# %%
+# .. image:: ../../examples/pool_regression_legend.png
diff --git a/docs/refs.bib b/docs/refs.bib
index 0da75a73..a049c572 100644
--- a/docs/refs.bib
+++ b/docs/refs.bib
@@ -273,3 +273,11 @@ @article{zliobaite2014active
   number={1},
   pages={27-39},
 }
+@article{jose2023regression,
+  title={Regression tree-based active learning},
+  author={Jose, Ashna and de Mendon{\c{c}}a, Jo{\~a}o Paulo Almeida and Devijver, Emilie and Jakse, No{\"e}l and Monbet, Val{\'e}rie and Poloni, Roberta},
+  journal={Data Mining and Knowledge Discovery},
+  pages={420--460},
+  year={2023},
+  publisher={Springer}
+}
\ No newline at end of file
diff --git a/skactiveml/base.py b/skactiveml/base.py
index 17861153..8d7fab31 100644
--- a/skactiveml/base.py
+++ b/skactiveml/base.py
@@ -1354,8 +1354,8 @@ class SkactivemlRegressor(BaseEstimator, RegressorMixin, ABC):
     (default=skactiveml.utils.MISSING_LABEL)
         Value to represent a missing label.
     random_state : int, RandomState or None, optional (default=None)
-        Determines random number for 'fit' and 'predict' method. Pass an int for
-        reproducible results across multiple method calls.
+        Determines random number for 'fit' and 'predict' method. Pass an int
+        for reproducible results across multiple method calls.
     """
 
     def __init__(self, missing_label=MISSING_LABEL, random_state=None):
diff --git a/skactiveml/classifier/multiannotator/_annotator_logistic_regression.py b/skactiveml/classifier/multiannotator/_annotator_logistic_regression.py
index e0759575..b6285d5c 100644
--- a/skactiveml/classifier/multiannotator/_annotator_logistic_regression.py
+++ b/skactiveml/classifier/multiannotator/_annotator_logistic_regression.py
@@ -240,7 +240,7 @@ def fit(self, X, y, sample_weight=None):
         n_features = X.shape[1]
         n_classes = len(self.classes_)
         self.n_annotators_ = y.shape[1]
-        I = np.eye(n_classes)
+        identity_matrix = np.eye(n_classes)
 
         # Convert Gamma to matrix, if it is a number:
         Gamma = self.weights_prior * np.eye(n_features)
@@ -394,7 +394,9 @@ def hessian(w):
                 P_W = softmax(X @ W, axis=1)
                 for k in range(n_classes):
                     for j in range(n_classes):
-                        diagonal = P_W[:, j] * (I[k, j] - P_W[:, k])
+                        diagonal = P_W[:, j] * (
+                            identity_matrix[k, j] - P_W[:, k]
+                        )
                         D = np.diag(diagonal)
                         H_kj = X.T @ D @ X + Gamma
                         H[
diff --git a/skactiveml/pool/__init__.py b/skactiveml/pool/__init__.py
index 391d3880..173472cd 100644
--- a/skactiveml/pool/__init__.py
+++ b/skactiveml/pool/__init__.py
@@ -26,6 +26,7 @@
 )
 from ._quire import Quire
 from ._random_sampling import RandomSampling
+from ._regression_tree_based_al import RegressionTreeBasedAL
 from ._uncertainty_sampling import (
     UncertaintySampling,
     uncertainty_scores,
@@ -68,6 +69,7 @@
     "TypiClust",
     "Badge",
     "GreedyBALD",
+    "RegressionTreeBasedAL",
     "SubSamplingWrapper",
     "ParallelUtilityEstimationWrapper",
 ]
diff --git a/skactiveml/pool/_badge.py b/skactiveml/pool/_badge.py
index 888148bb..3e562396 100644
--- a/skactiveml/pool/_badge.py
+++ b/skactiveml/pool/_badge.py
@@ -72,8 +72,8 @@ def query(
             Training data set, usually complete, i.e. including the labeled and
             unlabeled samples.
         y : array-like of shape (n_samples, )
-            Labels of the training data set (possibly including unlabeled samples,
-            indicated by self.missing_label).
+            Labels of the training data set (possibly including unlabeled
+            samples, indicated by self.missing_label).
         clf : skactiveml.base.SkactivemlClassifier
             Model implementing the methods `fit` and `predict_proba`.
         fit_clf : bool, optional (default=True)
@@ -112,8 +112,8 @@ def query(
             e.g., `utilities[0]` indicates the utilities used for selecting
             the first sample (with index `query_indices[0]`) of the batch.
             Utilities for labeled samples will be set to np.nan.
-            For the case where the samples are uniformly randomly selected from the set,
-            the sum of all utility of samples will be 1.
+            For the case where the samples are uniformly randomly selected from
+            the set, the sum of all utility of samples will be 1.
             The utilities represent here the probabilities of samples being
             chosen.
             If candidates is None or of shape (n_candidates), the indexing
diff --git a/skactiveml/pool/_core_set.py b/skactiveml/pool/_core_set.py
index c9b112f1..315928b3 100644
--- a/skactiveml/pool/_core_set.py
+++ b/skactiveml/pool/_core_set.py
@@ -67,14 +67,15 @@ def query(
            Labels of the training data set (possibly including unlabeled ones
            indicated by self.missing_label)
         candidates : None or array-like of shape (n_candidates), dtype = int or
-           array-like of shape (n_candidates, n_features),
-           optional (default=None)
-           If candidates is None, the unlabeled samples from (X,y) are considered
-           as candidates
-           If candidates is of shape (n_candidates) and of type int,
-           candidates is considered as a list of the indices of the samples in (X,y).
-           If candidates is of shape (n_candidates, n_features), the candidates are
-           directly given in the input candidates (not necessarily contained in X)
+        array-like of shape (n_candidates, n_features), optional (default=None)
+            If candidates is None, the unlabeled samples from (X,y) are
+            considered as candidates
+            If candidates is of shape (n_candidates) and of type int,
+            candidates is considered as a list of the indices of the samples in
+            (X, y).
+            If candidates is of shape (n_candidates, n_features), the
+            candidates are directly given in the input candidates (not
+            necessarily contained in X).
         batch_size : int, optional(default=1)
            The number of samples to be selects in one AL cycle.
         return_utilities : bool, optional(default=False)
@@ -91,9 +92,10 @@ def query(
            If candidates is of shape (n_candidates, n_features), the indexing
            refers to samples in candidates.
         utilities : numpy.ndarray of shape (batch_size, n_samples) or
-           numpy.ndarray of shape (batch_size, n_candidates)
+        numpy.ndarray of shape (batch_size, n_candidates)
            The utilities of samples for selecting each sample of the batch.
-           Here, utilities means the distance between each data point and its nearest center.
+           Here, utilities means the distance between each data point and its
+           nearest center.
            If candidates is None or of shape (n_candidates), the indexing
            refers to samples in X.
            If candidates is of shape (n_candidates, n_features), the indexing
@@ -162,16 +164,16 @@ def k_greedy_center(
        Training data set, usually complete, i.e. including the labeled and
        unlabeled samples
     y : np.ndarray of shape (n_selected_samples, )
-       index of datapoints already selects
+       Index of datapoints already selected.
     batch_size : int, optional (default=1)
        The number of samples to be selected in one AL cycle.
     random_state : int | np.random.RandomState, optional (default=None)
        Random state for candidate selection.
-    missing_label : scalar or string or np.nan or None, optional (default=np.nan)
-       Value to represent a missing label
+    missing_label : scalar or string or np.nan or None, optional
+    (default=np.nan)
+       Value to represent a missing label.
     mapping : np.ndarray of shape (n_candidates, ), optional (default=None)
-       Index array that maps `candidates` to `X`.
-       (`candidates = X[mapping]`)
+       Index array that maps `candidates` to `X` (`candidates = X[mapping]`).
     n_new_cand : int or None, optional (default=None)
        The number of new candidates that are additionally added to X.
        Only used for the case, that in the query function with the
@@ -284,8 +286,8 @@ def _update_distances(X, cluster_centers, mapping, latest_distance=None):
         distance between each data point and its nearest center after
         each selected sample of the batch. In the case of cluster center the
         value will be 'np.nan'.
-        For the case, that indices aren't in 'mapping', the corresponding value in
-        'result-dist' will be also 'np.nan'.
+        For the case, that indices aren't in 'mapping', the corresponding value
+        in 'result-dist' will be also 'np.nan'.
     """
     dist = np.zeros(shape=X.shape[0])
 
diff --git a/skactiveml/pool/_expected_model_change_maximization.py b/skactiveml/pool/_expected_model_change_maximization.py
index b6d141fd..67a7e6c2 100644
--- a/skactiveml/pool/_expected_model_change_maximization.py
+++ b/skactiveml/pool/_expected_model_change_maximization.py
@@ -37,9 +37,9 @@ class ExpectedModelChangeMaximization(SingleAnnotatorPoolQueryStrategy):
         The Norm to measure the gradient. Argument will be passed to
         `np.linalg.norm`.
     feature_map : callable, optional (default=None)
-        The feature map of the linear regressor. Takes in the feature data. Must
-        output a np.array of dimension 2. The default value is the identity
-        function. An example feature map is
+        The feature map of the linear regressor. Takes in the feature data.
+        Must output a np.array of dimension 2. The default value is the
+        identity function. An example feature map is
         `sklearn.preprocessing.PolynomialFeatures().fit_transform`.
     missing_label : scalar or string or np.nan or None,
     (default=skactiveml.utils.MISSING_LABEL)
@@ -99,7 +99,8 @@ def query(
         fit_reg : bool, optional (default=True)
             Defines whether the regressor should be fitted on `X`, `y`, and
             `sample_weight`.
-        sample_weight : array-like of shape (n_samples), optional (default=None)
+        sample_weight : array-like of shape (n_samples), optional
+        (default=None)
             Weights of training samples in `X`.
         candidates : None or array-like of shape (n_candidates), dtype=int or
             array-like of shape (n_candidates, n_features),
diff --git a/skactiveml/pool/_expected_model_output_change.py b/skactiveml/pool/_expected_model_output_change.py
index 9515cec8..7995f2e6 100644
--- a/skactiveml/pool/_expected_model_output_change.py
+++ b/skactiveml/pool/_expected_model_output_change.py
@@ -1,5 +1,3 @@
-from functools import partial
-
 import numpy as np
 from sklearn import clone
 from sklearn.utils import check_array
@@ -94,7 +92,8 @@ def query(
         fit_reg : bool, optional (default=True)
             Defines whether the regressor should be fitted on `X`, `y`, and
             `sample_weight`.
-        sample_weight : array-like of shape (n_samples), optional (default=None)
+        sample_weight : array-like of shape (n_samples), optional
+        (default=None)
             Weights of training samples in `X`.
         candidates : None or array-like of shape (n_candidates), dtype=int or
             array-like of shape (n_candidates, n_features),
diff --git a/skactiveml/pool/_expected_model_variance.py b/skactiveml/pool/_expected_model_variance.py
index bc0f7c44..87e9e96a 100644
--- a/skactiveml/pool/_expected_model_variance.py
+++ b/skactiveml/pool/_expected_model_variance.py
@@ -74,7 +74,8 @@ def query(
         fit_reg : bool, optional (default=True)
             Defines whether the regressor should be fitted on `X`, `y`, and
             `sample_weight`.
-        sample_weight : array-like of shape (n_samples), optional (default=None)
+        sample_weight : array-like of shape (n_samples), optional
+        (default=None)
             Weights of training samples in `X`.
         candidates : None or array-like of shape (n_candidates), dtype=int or
             array-like of shape (n_candidates, n_features),
diff --git a/skactiveml/pool/_greedy_sampling.py b/skactiveml/pool/_greedy_sampling.py
index 30452d47..65d9ec72 100644
--- a/skactiveml/pool/_greedy_sampling.py
+++ b/skactiveml/pool/_greedy_sampling.py
@@ -19,9 +19,9 @@
 class GreedySamplingX(SingleAnnotatorPoolQueryStrategy):
     """Greedy Sampling on the feature space.
 
-    This class implements greedy sampling on the feature space. A query strategy
-    that tries to select those samples that increase the diversity of the
-    feature space the most.
+    This class implements greedy sampling on the feature space. A query
+    strategy that tries to select those samples that increase the diversity of
+    the feature space the most.
 
     Parameters
     ----------
@@ -40,8 +40,9 @@ class GreedySamplingX(SingleAnnotatorPoolQueryStrategy):
 
     References
     ----------
-    [1] Wu, Dongrui, Chin-Teng Lin, and Jian Huang. Active learning for
-    regression using greedy sampling, Information Sciences, pages 90--105, 2019.
+    [1] Wu, Dongrui, Chin-Teng Lin, and Jian Huang. Active Learning for
+        Regression Using Greedy Sampling, Information Sciences, pages 90--105,
+        2019.
 
     """
 
@@ -192,9 +193,9 @@ class GreedySamplingTarget(SingleAnnotatorPoolQueryStrategy):
 
     References
     ----------
-    [1] Wu, Dongrui, Chin-Teng Lin, and Jian Huang. Active learning for
-    regression using greedy sampling, Information Sciences, pages 90--105, 2019.
-
+    [1] Wu, Dongrui, Chin-Teng Lin, and Jian Huang. Active Learning for
+        Regression using Greedy Sampling, Information Sciences, pages 90--105,
+        2019.
     """
 
     def __init__(
diff --git a/skactiveml/pool/_information_gain_maximization.py b/skactiveml/pool/_information_gain_maximization.py
index 0d8635ed..bd0b9243 100644
--- a/skactiveml/pool/_information_gain_maximization.py
+++ b/skactiveml/pool/_information_gain_maximization.py
@@ -1,6 +1,5 @@
 import numpy as np
 from sklearn import clone
-from sklearn.utils import check_array
 
 from skactiveml.base import (
     SingleAnnotatorPoolQueryStrategy,
@@ -47,8 +46,8 @@ class KLDivergenceMaximization(SingleAnnotatorPoolQueryStrategy):
     References
     ----------
     [1] Elreedy, Dina and F Atiya, Amir and I Shaheen, Samir. A novel active
-        learning regression framework for balancing the exploration-exploitation
-        trade-off, page 651 and subsequently, 2019.
+        learning regression framework for balancing the
+        exploration-exploitation trade-off, page 651 and subsequently, 2019.
 
     """
 
@@ -92,7 +91,8 @@ def query(
         fit_reg : bool, optional (default=True)
             Defines whether the regressor should be fitted on `X`, `y`, and
             `sample_weight`.
-        sample_weight : array-like of shape (n_samples), optional (default=None)
+        sample_weight : array-like of shape (n_samples), optional
+        (default=None)
             Weights of training samples in `X`.
         candidates : None or array-like of shape (n_candidates), dtype=int or
             array-like of shape (n_candidates, n_features),
@@ -208,7 +208,8 @@ def _kullback_leibler_divergence(
         y : array-like of shape (n_samples)
             Labels of the training data set (possibly including unlabeled ones
             indicated by `self.missing_label`).
-        sample_weight: array-like of shape (n_samples,), optional (default=None)
+        sample_weight: array-like of shape (n_samples,), optional
+        (default=None)
             Weights of training samples in `X`.
 
         Returns
diff --git a/skactiveml/pool/_query_by_committee.py b/skactiveml/pool/_query_by_committee.py
index 837f9eee..b661a115 100644
--- a/skactiveml/pool/_query_by_committee.py
+++ b/skactiveml/pool/_query_by_committee.py
@@ -96,12 +96,12 @@ def query(
             indicated by self.MISSING_LABEL.)
         ensemble : list or tuple of SkactivemlClassifier or list or tuple of
         SkactivemlRegressor, SkactivemlClassifier or SkactivemlRegressor
-            If `ensemble` is a `SkactivemlClassifier` or a `SkactivemlRegressor`
-            , it must have `n_estimators` and `estimators_` after fitting as
-            attribute. Then, its estimators will be used as committee. If
-            `ensemble` is array-like, each element of this list must be
-            `SkactivemlClassifier` or a `SkactivemlRegressor` and will be used
-            as committee member.
+            If `ensemble` is a `SkactivemlClassifier` or a
+            `SkactivemlRegressor`, it must have `n_estimators` and
+            `estimators_` after fitting as attribute. Then, its estimators will
+            be used as committee. If `ensemble` is array-like, each element of
+            this list must be `SkactivemlClassifier` or a `SkactivemlRegressor`
+            and will be used as committee member.
         fit_ensemble : bool, default=True
             Defines whether the ensemble should be fitted on `X`, `y`, and
             `sample_weight`.
@@ -204,19 +204,20 @@ def query(
         )
 
     def _aggregate_predict_probas(self, X_cand, ensemble, est_arr):
-        """Aggregate the predicted probabilities across all ensemble members and
-        ensure that all classes are mapped correctly.
+        """Aggregate the predicted probabilities across all ensemble members
+        and ensure that all classes are mapped correctly.
 
         Parameters
         ----------
-        X : array-like of shape (n_samples, n_features)
+        X_cand : array-like of shape (n_samples, n_features)
             Samples whose probabilities are to be predicted.
-        ensemble : list or tuple of SkactivemlClassifier or SkactivemlClassifier
+        ensemble : SkactivemlClassifier or list or tuple of
+        SkactivemlClassifier
             If `ensemble` is a `SkactivemlClassifier`, it must have
             `n_estimators` and `estimators_` after fitting as attribute. Then,
             its estimators will be used as committee. If `ensemble` is
-            array-like, each element of this list must be `SkactivemlClassifier`
-            and will be used as committee member.
+            array-like, each element of this list must be
+            `SkactivemlClassifier` and will be used as committee member.
         est_arr : list or tuple of SkactivemlClassifier
             List of ensemble members contained in `ensemble`.
 
diff --git a/skactiveml/pool/_random_sampling.py b/skactiveml/pool/_random_sampling.py
index 923f6942..97472c62 100644
--- a/skactiveml/pool/_random_sampling.py
+++ b/skactiveml/pool/_random_sampling.py
@@ -79,14 +79,15 @@ def query(
         X_cand, mapping = self._transform_candidates(candidates, X, y)
 
         if mapping is None:
-            utilities = self.random_state_.random_sample(len(X_cand))
+            utilities = np.ones(len(X_cand))
         else:
             utilities = np.full(len(X), np.nan)
-            utilities[mapping] = self.random_state_.random_sample(len(mapping))
+            utilities[mapping] = np.ones(len(mapping))
 
         return simple_batch(
             utilities,
             self.random_state_,
             batch_size=batch_size,
             return_utilities=return_utilities,
+            method="proportional",
         )
diff --git a/skactiveml/pool/_regression_tree_based_al.py b/skactiveml/pool/_regression_tree_based_al.py
new file mode 100644
index 00000000..0ce7c099
--- /dev/null
+++ b/skactiveml/pool/_regression_tree_based_al.py
@@ -0,0 +1,426 @@
+import warnings
+
+import numpy as np
+from sklearn import clone
+from sklearn.cluster import KMeans
+from sklearn.metrics import (
+    pairwise_distances_argmin_min,
+    pairwise_distances_argmin,
+    pairwise_distances,
+)
+from sklearn.tree import DecisionTreeRegressor
+
+from skactiveml.base import SingleAnnotatorPoolQueryStrategy
+from skactiveml.regressor import SklearnRegressor
+from skactiveml.utils import (
+    MISSING_LABEL,
+    check_type,
+    check_equal_missing_label,
+    is_labeled,
+    simple_batch,
+    rand_argmax,
+    check_scalar,
+    labeled_indices,
+)
+
+
+class RegressionTreeBasedAL(SingleAnnotatorPoolQueryStrategy):
+    """Regression Tree-based Active Learning
+
+    This strategy is based on a regression tree and selects the number `n_k` of
+    samples to be selected from each leaf `k` given a certain `batch size`. It
+    than uses one of the three methods 'random', 'diversity', or
+    'representativity' to select `n_k` samples from each leaf `k`.
+
+    Parameters
+    ----------
+    method : str, default='random'
+        Possible values are 'random', 'diversity', and 'representativity'.
+    missing_label : scalar or string or np.nan or None,
+      default=skactiveml.utils.MISSING_LABEL
+        Value to represent a missing label.
+    random_state : int or np.random.RandomState, default=None
+        Random state for candidate selection. Ensure that
+    max_iter_representativity : int, default=5
+        Maximum number of optimisation iterations.
+        Only used if `method='representativity'`.
+
+    References
+    ----------
+    [1] Jose, Ashna, João Paulo Almeida de Mendonça, Emilie Devijver,
+        Noël Jakse, Valérie Monbet, and Roberta Poloni. "Regression tree-based
+        active learning." Data Mining and Knowledge Discovery (2023): 420-460.
+    """
+
+    def __init__(
+        self,
+        method="random",
+        missing_label=MISSING_LABEL,
+        random_state=None,
+        max_iter_representativity=5,
+    ):
+        super().__init__(
+            random_state=random_state, missing_label=missing_label
+        )
+        self.method = method
+        self.max_iter_representativity = max_iter_representativity
+
+    def query(
+        self,
+        X,
+        y,
+        reg,
+        fit_reg=True,
+        sample_weight=None,
+        candidates=None,
+        batch_size=1,
+        return_utilities=False,
+    ):
+        """Determines for which candidate samples labels are to be queried.
+
+        Parameters
+        ----------
+        X : array-like of shape (n_samples, n_features)
+            Training data set, usually complete, i.e. including the labeled and
+            unlabeled samples.
+        y : array-like of shape (n_samples)
+            Labels of the training data set (possibly including unlabeled ones
+            indicated by self.MISSING_LABEL).
+        reg : SkactivemlRegressor
+            The regressor must be `sklearn.tree.DecisionTreeRegressor` to
+            predict the data. Ensure that the number of samples in the leaf is
+            greater than 1. For example, by setting `min_samples_leaf >= 2` or
+            by restricting the tree's depth.
+        fit_reg : bool, optional (default=True)
+            Defines whether the regressor should be fitted on `X`, `y`, and
+            `sample_weight`.
+        sample_weight : array-like of shape (n_samples), default=None
+            Weights of training samples in `X`.
+        candidates : None or array-like of shape (n_candidates), dtype=int or
+            array-like of shape (n_candidates, n_features),
+            optional (default=None)
+            If candidates is None, the unlabeled samples from (X,y) are
+            considered as candidates.
+            If candidates is of shape (n_candidates) and of type int,
+            candidates is considered as the indices of the samples in (X,y).
+            If candidates is of shape (n_candidates, n_features), the
+            candidates are directly given in candidates (not necessarily
+            contained in X).
+        batch_size : int, optional (default=1)
+            The number of samples to be selected in one AL cycle. Originally,
+            this query strategy is developed for `batch_sizes > 1`.
+        return_utilities : bool, optional (default=False)
+            If true, also return the utilities based on the query strategy.
+
+        Returns
+        -------
+        query_indices : numpy.ndarray of shape (batch_size)
+            The query_indices indicate for which candidate sample a label is
+            to queried, e.g., `query_indices[0]` indicates the first selected
+            sample.
+            If candidates is None or of shape (n_candidates), the indexing
+            refers to samples in X.
+            If candidates is of shape (n_candidates, n_features), the indexing
+            refers to samples in candidates.
+        utilities : numpy.ndarray of shape (batch_size, n_samples) or
+            numpy.ndarray of shape (batch_size, n_candidates)
+            The utilities of samples after each selected sample of the batch,
+            e.g., `utilities[0]` indicates the utilities used for selecting
+            the first sample (with index `query_indices[0]`) of the batch.
+            Utilities for labeled samples will be set to np.nan.
+            If candidates is None or of shape (n_candidates), the indexing
+            refers to samples in X.
+            If candidates is of shape (n_candidates, n_features), the indexing
+            refers to samples in candidates.
+        """
+
+        # Validate input parameters.
+        X, y, candidates, batch_size, return_utilities = self._validate_data(
+            X, y, candidates, batch_size, return_utilities, reset=True
+        )
+        if batch_size == 1:
+            warnings.warn(
+                "This query strategy was originally developed for "
+                "`batch_sizes > 1`."
+            )
+        X_cand, mapping = self._transform_candidates(candidates, X, y)
+        labeled_idxs = labeled_indices(y, self.missing_label_)
+
+        # Validate regressor type.
+        check_type(reg, "reg", SklearnRegressor)
+        check_type(reg.estimator, "reg.estimator", DecisionTreeRegressor)
+        check_equal_missing_label(reg.missing_label, self.missing_label_)
+
+        # Validate boolean flag
+        check_type(fit_reg, "fit_reg", bool)
+
+        # Validate method type.
+        check_type(self.method, "method", str)
+
+        # Validate max_iter_representativity type.
+        check_scalar(
+            self.max_iter_representativity,
+            "max_iter_representativity",
+            int,
+            min_val=1,
+        )
+
+        # Fallback to random sampling if no sample is labeled.
+        if len(labeled_idxs) <= 1:
+            warnings.warn("No sample is labeled. Fallback to random sampling.")
+            if mapping is None:
+                utilities = np.full(len(X_cand), fill_value=1 / len(X_cand))
+            else:
+                utilities = np.full(len(X), np.nan)
+                utilities[mapping] = np.full(
+                    len(mapping), fill_value=1 / len(mapping)
+                )
+
+            return simple_batch(
+                utilities,
+                self.random_state_,
+                batch_size=batch_size,
+                return_utilities=return_utilities,
+                method="proportional",
+            )
+
+        # Fit the regressor.
+        if fit_reg:
+            reg = clone(reg).fit(X, y, sample_weight)
+
+        # Calculate the number of samples to be selected from each leaf k.
+        n_k = _calc_acquisitions_per_leaf(
+            X, y, reg, missing_label=self.missing_label_, batch_size=batch_size
+        )
+
+        # Calculate the number of candidates per leaf.
+        leaf_indices_cand = reg.apply(X_cand)
+        n_cand_per_leaf = np.bincount(leaf_indices_cand, minlength=len(n_k))
+        if (n_cand_per_leaf <= 1).any():
+            warnings.warn(
+                "Please ensure that each leaf has at least two samples, "
+                "e.g., by setting `min_samples_leaf >= 2` in case of the "
+                "`DecisionTreeRegressor` or by restricting the tree's depth."
+            )
+
+        n_k_discrete = _discretize_acquisitions_per_leaf(
+            n_k, self.random_state_
+        )
+        if self.method == "random":
+            batch_utilities_cand = np.full((batch_size, len(X_cand)), -np.inf)
+            query_indices = []
+
+            for leaf_idx in np.unique(leaf_indices_cand):
+                for _ in range(n_k_discrete[leaf_idx]):
+                    batch_utilities_cand[
+                        len(query_indices), leaf_indices_cand == leaf_idx
+                    ] = 1
+                    batch_utilities_cand[len(query_indices), query_indices] = (
+                        np.nan
+                    )
+                    query_indices.append(
+                        rand_argmax(
+                            batch_utilities_cand[len(query_indices)],
+                            random_state=self.random_state_,
+                        )
+                    )
+
+        elif self.method == "diversity":
+            batch_utilities_cand = np.full((batch_size, len(X_cand)), -np.inf)
+            query_indices = []
+
+            X_labeled = X[labeled_idxs]
+            leaf_indices_labeled = reg.apply(X_labeled)
+            for leaf_idx in np.unique(leaf_indices_cand):
+                X_cand_leaf = X_cand[leaf_indices_cand == leaf_idx]
+                X_labeled_leaf = X_labeled[leaf_indices_labeled == leaf_idx]
+
+                # Calculate the L2 distance of each unlabeled sample in leaf k
+                # to all the labeled samples using equation (6) and (7).
+                # Compute the shortest distance from x_j to all labeled
+                # samples using equation (7).
+                _, d_min = pairwise_distances_argmin_min(
+                    X_cand_leaf, X_labeled_leaf, axis=1
+                )
+                for _ in range(n_k_discrete[leaf_idx]):
+                    batch_utilities_cand[
+                        len(query_indices), leaf_indices_cand == leaf_idx
+                    ] = d_min
+                    batch_utilities_cand[len(query_indices), query_indices] = (
+                        np.nan
+                    )
+                    query_indices.append(
+                        rand_argmax(
+                            batch_utilities_cand[len(query_indices)],
+                            random_state=self.random_state_,
+                        )
+                    )
+
+        elif self.method == "representativity":
+            # Perform a k-means clustering in leaf k with n_k clusters.
+            query_indices = np.empty(shape=batch_size, dtype=int)
+            l_cand = np.full(len(X_cand), fill_value=-1, dtype=int)
+            for leaf in np.argwhere(n_k_discrete != 0).flatten():
+                X_cand_leaf = X_cand[leaf_indices_cand == leaf]
+                kmeans = KMeans(
+                    n_k_discrete[leaf], random_state=self.random_state_
+                ).fit(X_cand_leaf)
+
+                l_cand[leaf_indices_cand == leaf] = kmeans.predict(
+                    X_cand_leaf
+                ) + np.sum(n_k_discrete[0:leaf])
+
+                centroids = kmeans.cluster_centers_
+                query_indices[
+                    np.sum(n_k_discrete[0:leaf]) + range(n_k_discrete[leaf])
+                ] = pairwise_distances_argmin(centroids, X_cand, axis=1)
+
+            # Calculate R using Eq. (9)
+            R_cand = np.zeros(len(X_cand))
+            for l_idx in np.unique(l_cand):
+                C_l = X_cand[l_cand == l_idx]
+                if l_idx != -1 and len(C_l) > 1:
+                    R_cand[l_cand == l_idx] = pairwise_distances(C_l, C_l).sum(
+                        axis=1
+                    ) / (len(C_l) - 1)
+
+            batch_utilities_cand = np.full((batch_size, len(X_cand)), -np.inf)
+            for i in range(self.max_iter_representativity):
+                prev_best_indices = query_indices
+                for l_idx in range(batch_size):
+                    # Update DELTA using the current centroids.
+                    X_M = X[labeled_idxs]
+                    X_M = np.append(X_M, X_cand[query_indices[:l_idx]], axis=0)
+                    X_M = np.append(
+                        X_M, X_cand[query_indices[l_idx + 1 :]], axis=0
+                    )
+                    X_cand_l = X_cand[l_cand == l_idx]
+                    _, delta_l = pairwise_distances_argmin_min(
+                        X_cand_l, X_M, axis=1
+                    )  # Equation (10)
+
+                    # Use Eq. (8) to find the next sample to be labeled.
+                    R_l = R_cand[l_cand == l_idx]
+                    batch_utilities_cand[l_idx, l_cand == l_idx] = (
+                        delta_l - R_l
+                    )
+                    query_indices[l_idx] = rand_argmax(
+                        batch_utilities_cand[l_idx],
+                        random_state=self.random_state_,
+                    )[0]
+
+                if np.all(prev_best_indices == query_indices):
+                    break
+            for l_idx in range(batch_size):
+                batch_utilities_cand[l_idx, query_indices[:l_idx]] = np.nan
+        else:
+            raise ValueError(
+                f'The given method "{self.method}" is not valid. Supported '
+                f'methods are "random", "diversity", and "representativity".'
+            )
+
+        if mapping is None:
+            batch_utilities = batch_utilities_cand
+        else:
+            batch_utilities = np.full((batch_size, len(X)), np.nan)
+            batch_utilities[:, mapping] = batch_utilities_cand
+            query_indices = mapping[query_indices]
+
+        # Check whether utilities are to be returned.
+        if return_utilities:
+            return query_indices, batch_utilities
+        else:
+            return query_indices
+
+
+def _discretize_acquisitions_per_leaf(n_k, random_state):
+    """
+    Discretizes a given array of non-negative floats corresponding to the
+    number of acquisitions per leaf of the regression tree. Guarantees that we
+    acquire a minimum number (i.e., floored floats) per leaf.
+
+    Parameters
+    ----------
+    n_k : numpy.ndarray of shape (n_leafs,)
+        Float acquisitions per leaf of the regression tree.
+    random_state : np.random.RandomState
+        Random state for reproducibility.
+
+    Returns
+    -------
+    n_k_discrete : numpy.ndarray of shape (n_leafs,)
+        Integer acquisitions per leaf of the regression tree.
+    """
+    n_k_rest, n_k_discrete = np.modf(n_k)
+    rest_size = n_k_rest.sum()
+    leaf_indices = np.arange(len(n_k))
+    if rest_size > 0:
+        sampled_leaf_indices = random_state.choice(
+            leaf_indices,
+            p=n_k_rest / rest_size,
+            size=int(rest_size),
+            replace=False,
+        )
+        add_leaf_indices, add_leaf_counts = np.unique(
+            sampled_leaf_indices, return_counts=True
+        )
+        n_k_discrete[add_leaf_indices] += add_leaf_counts
+    return n_k_discrete.astype(int)
+
+
+def _calc_acquisitions_per_leaf(X, y, reg, missing_label, batch_size=1):
+    """Regression Tree-based Active Learning
+
+    Computes the number of samples to be selected from each leaf of the
+    regression tree.
+
+    Parameters
+    ----------
+    X : array-like of shape (n_samples, n_features)
+        Training data set, usually complete, i.e. including the labeled and
+        unlabeled samples.
+    y : array-like of shape (n_samples)
+        Labels of the training data set (possibly including unlabeled ones
+        indicated by `self.missing_label`).
+    missing_label : scalar or string or np.nan or None
+        Value to represent a missing label.
+    reg: SkactivemlRegressor
+        Fitted regressor to predict the data.
+    batch_size : int, default=1
+        The number of samples to be selected in one AL cycle.
+
+    Returns
+    -------
+    n_samples_per_leaf : numpy.ndarray of shape (n_leafs)
+    """
+    is_lbld = is_labeled(y, missing_label=missing_label)
+
+    # Compute the variance v_k on labeled samples in leaf k.
+    leaf_labeled = reg.apply(X[is_lbld])
+    y_labeled = y[is_lbld]
+    v_k = np.empty(reg.tree_.node_count)
+    for leaf in range(len(v_k)):
+        v_k[leaf] = np.var(y_labeled[leaf_labeled == leaf], ddof=1)
+
+    v_k[np.isnan(v_k)] = 0
+    if 0 in v_k[np.unique(leaf_labeled)]:
+        warnings.warn(
+            "There are leaves with less than two labeled samples, "
+            "which causes a variance of zero. To avoid this, set "
+            "parameter `min_samples_leaf` of `reg` to >= 2."
+        )
+
+    # Compute the probability p_k that an unlabeled sample belongs to leaf k.
+    leaf_unlabeled = reg.apply(X[~is_lbld])
+    samples_per_leaf = np.bincount(leaf_unlabeled, minlength=len(v_k))
+    p_k = samples_per_leaf / sum(~is_lbld)
+
+    # Compute the number of sample to be selected from each leaf of the
+    # regression tree.
+    n_k = np.sqrt(p_k * v_k)
+    if np.sum(n_k) == 0:
+        n_k = np.full_like(n_k, fill_value=batch_size / reg.tree_.node_count)
+    else:
+        n_k = batch_size * n_k / np.sum(n_k)
+
+    return n_k
diff --git a/skactiveml/pool/_typi_clust.py b/skactiveml/pool/_typi_clust.py
index 58a47360..35ee6d43 100644
--- a/skactiveml/pool/_typi_clust.py
+++ b/skactiveml/pool/_typi_clust.py
@@ -17,8 +17,9 @@
 class TypiClust(SingleAnnotatorPoolQueryStrategy):
     """Typical Clustering Selection
 
-    This class implements the Typical Clustering (TypiClust) query strategy [1], which considers
-    both diversity and typicality (representativeness) of the samples.
+    This class implements the Typical Clustering (TypiClust) query strategy
+    [1], which considers both diversity and typicality (representativeness) of
+    the samples.
 
     Parameters
     ----------
@@ -76,13 +77,15 @@ def query(
             Labels of the training data set (possibly including unlabeled ones
             indicated by self.missing_label)
         candidates : None or array-like of shape (n_candidates), dtype = int or
-            array-like of shape (n_candidates, n_features), optional (default=None)
-            If candidates is None, the unlabeled samples from (X,y) are considered
-            as candidates
+        array-like of shape (n_candidates, n_features), optional (default=None)
+            If candidates is None, the unlabeled samples from (X, y)
+            are considered as candidates.
             If candidates is of shape (n_candidates) and of type int,
-            candidates is considered as a list of the indices of the samples in (X,y).
-            If candidates is of shape (n_candidates, n_features), the candidates are
-            directly given in the input candidates (not necessarily contained in X)
+            candidates is considered as a list of the indices of the samples in
+            (X, y).
+            If candidates is of shape (n_candidates, n_features), the
+            candidates are directly given in the input candidates (not
+            necessarily contained in X).
         batch_size : int, optional(default=1)
             The number of samples to be selects in one AL cycle.
         return_utilities : bool, optional(default=False)
@@ -92,7 +95,8 @@ def query(
         ----------
         query_indices : numpy.ndarry of shape (batch_size)
             The query_indices indicate for which candidate sample a label is
-            to queried, e.g., `query_indices[0]` indicates the first selected sample.
+            to queried, e.g., `query_indices[0]` indicates the first selected
+            sample.
             If candidates in None or of shape (n_candidates), the indexing
             refers to samples in X.
             If candidates is of shape (n_candidates, n_features), the indexing
@@ -120,7 +124,8 @@ def query(
             or self.cluster_algo_dict is None
         ):
             raise TypeError(
-                "Please pass a dictionary with corresponding parameter name and value in the init function."
+                "Please pass a dictionary with corresponding parameter name "
+                "and value in the `init` function."
             )
         cluster_algo_dict = (
             {}
@@ -179,15 +184,12 @@ def query(
             typicality = _typicality(X, uncovered_samples_mapping, self.k)
             utilities[i, mapping] = typicality[np.arange(len(mapping))]
             utilities[i, query_indices] = np.nan
-            idx = np.argmax(typicality)
+            idx = np.argmax(typicality[np.arange(len(mapping))])
             idx = mapping[idx]
 
             query_indices = np.append(query_indices, [idx]).astype(int)
             cluster_sizes[cluster_id] = 0
 
-        if utilities.shape[1] == X.shape[0]:
-            utilities[:, labeled_sample_indices] = np.nan
-
         if return_utilities:
             return query_indices, utilities
         else:
@@ -202,8 +204,9 @@ def _typicality(X, uncovered_samples_mapping, k):
     ----------
     X : array-like of shape (n_samples, n_features)
         Training data set, usually complete, i.e. including the labeled and
-        unlabeled samples
-    uncovered_samples_mapping : np.ndarray of shape (n_candidates, ) (default=None)
+        unlabeled samples.
+    uncovered_samples_mapping : np.ndarray of shape (n_candidates,),
+    default=None
        Index array that maps `candidates` to `X_for_cluster`.
     k : int
         k for computation of k nearst neighbors
@@ -212,7 +215,7 @@ def _typicality(X, uncovered_samples_mapping, k):
     typicality : numpy.ndarray of shape (n_X)
         The typicality of all uncovered samples in X
     """
-    typicality = np.zeros(shape=X.shape[0])
+    typicality = np.full(shape=X.shape[0], fill_value=-np.inf)
     if len(uncovered_samples_mapping) == 1:
         typicality[uncovered_samples_mapping] = 1
         return typicality
diff --git a/skactiveml/pool/_uncertainty_sampling.py b/skactiveml/pool/_uncertainty_sampling.py
index 7428e927..6b35a6ad 100644
--- a/skactiveml/pool/_uncertainty_sampling.py
+++ b/skactiveml/pool/_uncertainty_sampling.py
@@ -298,8 +298,8 @@ def uncertainty_scores(probas, cost_matrix=None, method="least_confident"):
                 return np.nansum(-probas * np.log(probas), axis=1)
         else:
             raise ValueError(
-                f"Method `entropy` does not support cost matrices but "
-                f"`cost_matrix` was not None."
+                "Method `entropy` does not support cost matrices but "
+                "`cost_matrix` was not None."
             )
     else:
         raise ValueError(
diff --git a/skactiveml/pool/multiannotator/_interval_estimation_threshold.py b/skactiveml/pool/multiannotator/_interval_estimation_threshold.py
index 7c7e1f99..44ee1d17 100644
--- a/skactiveml/pool/multiannotator/_interval_estimation_threshold.py
+++ b/skactiveml/pool/multiannotator/_interval_estimation_threshold.py
@@ -390,8 +390,8 @@ def query(
         # Determine actual batch size.
         if isinstance(batch_size, str) and batch_size != "adaptive":
             raise ValueError(
-                f"If `batch_size` is of type `string`, "
-                f"it must equal `'adaptive'`."
+                "If `batch_size` is of type `string`, "
+                "it must equal `'adaptive'`."
             )
         elif batch_size == "adaptive":
             required_perf = self.epsilon * np.max(A_perf)
diff --git a/skactiveml/pool/multiannotator/_wrapper.py b/skactiveml/pool/multiannotator/_wrapper.py
index 908a488a..c3d39132 100644
--- a/skactiveml/pool/multiannotator/_wrapper.py
+++ b/skactiveml/pool/multiannotator/_wrapper.py
@@ -210,14 +210,19 @@ def query(
             & np.isnan(self.missing_label)
         ):
             raise ValueError(
-                f"`self.missing_label` must equal `self.strategy.missing_label`, but"
+                f"`self.missing_label` must equal "
+                f"`self.strategy.missing_label`, but "
                 f"`self.missing_label` equals {self.missing_label} and"
-                f"`self.strategy.missing_label` equals {self.strategy.missing_label}."
+                f"`self.strategy.missing_label` equals "
+                f"{self.strategy.missing_label}."
             )
 
         # aggregate y
         if self.y_aggregate is None:
-            y_aggregate = lambda y: majority_vote(y, random_state=random_state)
+
+            def y_aggregate(y):
+                return majority_vote(y, random_state=random_state)
+
         else:
             y_aggregate = self.y_aggregate
 
@@ -322,8 +327,8 @@ def query(
             else:
                 raise ValueError(
                     f"`A_perf` is of shape {A_perf.shape}, but must be of "
-                    f"shape ({n_selectable_candidates}, {n_annotators}) or of shape "
-                    f"({n_annotators},)."
+                    f"shape ({n_selectable_candidates}, {n_annotators}) or of "
+                    f"shape ({n_annotators},)."
                 )
         else:
             raise TypeError(
@@ -332,7 +337,7 @@ def query(
             )
 
         candidates_sq = mapping if mapping is not None else X_cand
-        _, w_utilities = self.strategy.query(
+        qs_indices, w_utilities = self.strategy.query(
             X=X,
             y=y_sq,
             candidates=candidates_sq,
@@ -343,8 +348,12 @@ def query(
 
         if mapping is None:
             sample_utilities = w_utilities
+            sample_indices = qs_indices
         else:
             sample_utilities = w_utilities[:, mapping]
+            sample_indices = np.array(
+                [np.argwhere(mapping == i)[0, 0] for i in qs_indices]
+            )
 
         re_val = self._query_annotators(
             A_cand,
@@ -353,6 +362,7 @@ def query(
             annotator_utilities,
             return_utilities,
             pref_n_annotators,
+            sample_indices,
         )
 
         if mapping is None:
@@ -380,6 +390,7 @@ def _query_annotators(
         annotator_utilities,
         return_utilities,
         pref_n_annotators,
+        qs_indices,
     ):
         random_state = check_random_state(self.random_state)
 
@@ -387,7 +398,7 @@ def _query_annotators(
         n_samples = A_cand.shape[0]
 
         s_indices, s_utilities = self._get_order_preserving_s_query(
-            A_cand, sample_utilities, annotator_utilities
+            A_cand, sample_utilities, annotator_utilities, qs_indices
         )
 
         n_as_annotators = self._n_to_assign_annotators(
@@ -422,20 +433,22 @@ def _query_annotators(
 
     @staticmethod
     def _get_order_preserving_s_query(
-        A, candidate_utilities, annotator_utilities
+        A, candidate_utilities, annotator_utilities, sample_indices
     ):
         nan_indices = np.argwhere(np.isnan(candidate_utilities))
 
         candidate_utilities[nan_indices[:, 0], nan_indices[:, 1]] = -np.inf
 
+        # force selected sample indices to have the maximum utility
+        for i in range(len(sample_indices)):
+            max_utility_i = np.nanmax(candidate_utilities[i]) + 1
+            candidate_utilities[i, sample_indices[i]] = max_utility_i
+
         # prepare candidate_utilities
         candidate_utilities = rankdata(
             candidate_utilities, method="ordinal", axis=1
         ).astype(float)
 
-        # calculate indices of maximum sample
-        indices = np.argmax(candidate_utilities, axis=1)
-
         candidate_utilities[nan_indices[:, 0], nan_indices[:, 1]] = np.nan
 
         annotator_utilities[:, ~A] = np.nan
@@ -443,7 +456,7 @@ def _get_order_preserving_s_query(
         # combine utilities by addition
         utilities = candidate_utilities[:, :, np.newaxis] + annotator_utilities
 
-        return indices, utilities
+        return sample_indices, utilities
 
     @staticmethod
     def _n_to_assign_annotators(batch_size, A, s_indices, pref_n_annotators):
diff --git a/skactiveml/pool/utils.py b/skactiveml/pool/utils.py
index 52446515..893135ef 100644
--- a/skactiveml/pool/utils.py
+++ b/skactiveml/pool/utils.py
@@ -14,7 +14,6 @@
 from ..base import (
     SkactivemlClassifier,
     ProbabilisticRegressor,
-    SkactivemlRegressor,
 )
 from ..classifier import ParzenWindowClassifier
 from ..utils import (
@@ -851,52 +850,53 @@ def expected_target_val(X, target_func, reg, **kwargs):
 
     Other Parameters
     ----------------
-    method: string, optional, optional (default='gauss_hermite')
+    method : string, optional, optional (default='gauss_hermite')
         The method by which the expectation is computed.
-        -'assume_linear' assumes E[func(Y)|X=x_eval] ~= func(E[Y|X=x_eval]) and
-          thereby only takes the function value at the expected y value.
-        -'monte_carlo' Basic monte carlo integration. Taking the average
+        - 'assume_linear' assumes E[func(Y)|X=x_eval] ~= func(E[Y|X=x_eval])
+          and thereby only takes the function value at the expected y value.
+        - 'monte_carlo' Basic monte carlo integration. Taking the average
           of randomly drawn samples. `n_integration_samples` specifies the
           number of monte carlo samples.
-        -'quantile' Uses the quantile function to transform the integration
+        - 'quantile' Uses the quantile function to transform the integration
           space into the interval from 0 to 1 and than uses the method from
-          'quantile_method' to calculate the integral. The number of integration
-          points is specified by `n_integration_samples`.
-        -'gauss_hermite' Uses Gauss-Hermite quadrature. This assumes Y | X
+          'quantile_method' to calculate the integral. The number of
+          integration points is specified by `n_integration_samples`.
+        - 'gauss_hermite' Uses Gauss-Hermite quadrature. This assumes Y | X
           to be gaussian distributed. The number of evaluation  points is given
           by `n_integration_samples`.
-        -'dynamic_quad' uses `scipy's` function `expect` on the `rv_continuous`
-          random variable of `reg`, which in turn uses a dynamic gaussian
-          quadrature routine for calculating the integral. Performance is worse
-          using a vector function.
-    quantile_method: string, optional (default='quadrature')
+        - 'dynamic_quad' uses `scipy's` function `expect` on the
+          `rv_continuous` random variable of `reg`, which in turn uses a
+          dynamic gaussian quadrature routine for calculating the integral.
+          Performance is worse using a vector function.
+    quantile_method : string, optional (default='quadrature')
         Specifies the integration methods used after the quantile
         transformation.
-        -'trapezoid' Trapezoidal method for integration using evenly spaced
+        - 'trapezoid' Trapezoidal method for integration using evenly spaced
           samples.
-        -'simpson' Simpson method for integration using evenly spaced samples.
-        -'average' Taking the average value for integration using evenly spaced
-          samples.
-        -'romberg' Romberg method for integration. If `n_integration_samples` is
-          not equal to `2**k + 1` for a natural number k, the number of
-          samples used for integration is put to the smallest such number greater
-          than `n_integration_samples`.
-        -'quadrature' Gaussian quadrature method for integration.
-    n_integration_samples: int, optional (default=10)
+        - 'simpson' Simpson method for integration using evenly spaced samples.
+        - 'average' Taking the average value for integration using evenly
+          spaced samples.
+        - 'romberg' Romberg method for integration. If `n_integration_samples`
+          is not equal to `2**k + 1` for a natural number k, the number of
+          samples used for integration is put to the smallest such number
+          greater than `n_integration_samples`.
+        - 'quadrature' Gaussian quadrature method for integration.
+    n_integration_samples : int, optional (default=10)
         The number of integration samples used in 'quantile', 'monte_carlo' and
         'gauss-hermite'.
-    quad_dict: dict, optional (default=None)
+    quad_dict : dict, optional (default=None)
         Further arguments for using `scipy's` `expect`
     random_state : int | np.random.RandomState, optional (default=None)
         Random state for fixing the number generation.
     target_func : bool
         If `True` only the target values will be passed to `func`.
     vector_func : bool or str, optional (default=False)
-        If `vector_func` is `True`, the integration values are passed as a whole
-        to the function `func`. If `vector_func` is 'both', the integration
-        values might or might not be passed as a whole. The integration values
-        if passed as a whole are of the form (n_samples, n_integration), where
-        n_integration denotes the number of integration values.
+        If `vector_func` is `True`, the integration values are passed as a
+        whole to the function `func`. If `vector_func` is 'both', the
+        integration values might or might not be passed as a whole. The
+        integration values if passed as a whole are of the form (n_samples,
+        n_integration), where n_integration denotes the number of integration
+        values.
 
     Returns
     -------
@@ -948,8 +948,8 @@ def _conditional_expect(
           number of monte carlo samples.
         -'quantile' Uses the quantile function to transform the integration
           space into the interval from 0 to 1 and than uses the method from
-          'quantile_method' to calculate the integral. The number of integration
-          points is specified by `n_integration_samples`.
+          'quantile_method' to calculate the integral. The number of
+          integration points is specified by `n_integration_samples`.
         -'gauss_hermite' Uses Gauss-Hermite quadrature. This assumes Y | X
           to be gaussian distributed. The number of evaluation  points is given
           by `n_integration_samples`.
@@ -957,23 +957,23 @@ def _conditional_expect(
           random variable of `reg`, which in turn uses a dynamic gaussian
           quadrature routine for calculating the integral. Performance is worse
           using a vector function.
-    quantile_method: string, optional (default='quadrature')
+    quantile_method : string, optional (default='quadrature')
         Specifies the integration methods used after the quantile
         transformation.
-        -'trapezoid' Trapezoidal method for integration using evenly spaced
-          samples.
-        -'simpson' Simpson method for integration using evenly spaced samples.
-        -'average' Taking the average value for integration using evenly spaced
+        - 'trapezoid' Trapezoidal method for integration using evenly spaced
           samples.
-        -'romberg' Romberg method for integration. If `n_integration_samples` is
-          not equal to `2**k + 1` for a natural number k, the number of
+        - 'simpson' Simpson method for integration using evenly spaced samples.
+        - 'average' Taking the average value for integration using evenly
+          spaced samples.
+        - 'romberg' Romberg method for integration. If `n_integration_samples`
+          is not equal to `2**k + 1` for a natural number k, the number of
           samples used for integration is put to the smallest such number
           greater than `n_integration_samples`.
         -'quadrature' Gaussian quadrature method for integration.
-    n_integration_samples: int, optional (default=10)
+    n_integration_samples : int, optional (default=10)
         The number of integration samples used in 'quantile', 'monte_carlo' and
         'gauss-hermite'.
-    quad_dict: dict, optional (default=None)
+    quad_dict : dict, optional (default=None)
         Further arguments for using `scipy's` `expect`
     random_state : int | np.random.RandomState, optional (default=None)
         Random state for fixing the number generation.
@@ -983,8 +983,8 @@ def _conditional_expect(
         integration values might or might not be passed in vectorized form,
         depending what is more efficient. The integration values
         are passed in vectorized form, means that in a call like
-        `func(y, x, idx)` `y` is of the form (n_samples, n_integration_samples),
-         `x` equals `X` and `idx` is an index map of `X.
+        `func(y, x, idx)` `y` is of the form (n_samples,
+        n_integration_samples), `x` equals `X` and `idx` is an index map of `X.
 
 
 
diff --git a/skactiveml/regressor/_nic_kernel_regressor.py b/skactiveml/regressor/_nic_kernel_regressor.py
index b26faf89..c07ccd6a 100644
--- a/skactiveml/regressor/_nic_kernel_regressor.py
+++ b/skactiveml/regressor/_nic_kernel_regressor.py
@@ -198,9 +198,9 @@ def _combine_params(prior_params, update_params):
 class NadarayaWatsonRegressor(NICKernelRegressor):
     """NadarayaWatsonRegressor
 
-    The Nadaraya Watson Regressor predicts the target value by taking a weighted
-    average based on a kernel. It is implemented asa `NICKernelRegressor` with
-    different prior values.
+    The Nadaraya Watson Regressor predicts the target value by taking a
+    weighted average based on a kernel. It is implemented as a
+    `NICKernelRegressor` with different prior values.
 
     Parameters
     __________
diff --git a/skactiveml/utils/_functions.py b/skactiveml/utils/_functions.py
index dfe95760..d46ef780 100644
--- a/skactiveml/utils/_functions.py
+++ b/skactiveml/utils/_functions.py
@@ -1,7 +1,6 @@
 import inspect
 from types import MethodType
 from makefun import with_signature
-from functools import update_wrapper
 
 
 def call_func(
diff --git a/skactiveml/utils/_selection.py b/skactiveml/utils/_selection.py
index b2cf5111..7708503e 100644
--- a/skactiveml/utils/_selection.py
+++ b/skactiveml/utils/_selection.py
@@ -81,7 +81,11 @@ def rand_argmax(a, random_state=None, **argmax_kwargs):
 
 
 def simple_batch(
-    utilities, random_state=None, batch_size=1, return_utilities=False
+    utilities,
+    random_state=None,
+    batch_size=1,
+    return_utilities=False,
+    method="max",
 ):
     """Generates a batch by selecting the highest values in the 'utilities'.
     If utilities is an ND-array, the returned utilities will be an
@@ -95,17 +99,21 @@ def simple_batch(
     random_state : int | np.random.RandomState (default=None)
         The random state to use. If `random_state is None` random
         `random_state` is used.
-    batch_size : int, optional (default=1)
+    batch_size : int, default=1
         The number of samples to be selected in one AL cycle.
-    return_utilities : bool (default=False)
+    return_utilities : bool, default=False
         If True, the utilities are returned.
+    method : str, default='max'
+        Determines how to select 'best_indices'. 'max' selects the indices with
+        the maximum utilities. 'proportional' randomly choose the
+        'best_indices' with the probabilities proportional to 'utilities'.
 
     Returns
     -------
-    best_indices : np.ndarray, shape (batch_size) if ndim == 1
-    (batch_size, ndim) else
+    best_indices : np.ndarray of shape (batch_size) if utilities.ndim == 1
+    else (batch_size, utilities.ndim)
         The index of the batch instance.
-    batch_utilities : np.ndarray,  shape (batch_size, len(utilities))
+    batch_utilities : np.ndarray of shape (batch_size, len(utilities))
         The utilities of the batch (if return_utilities=True).
 
     """
@@ -127,19 +135,41 @@ def simple_batch(
             )
         )
         batch_size = max_batch_size
-    # generate batch
 
-    batch_utilities = np.empty((batch_size,) + utilities.shape)
+    check_type(method, "method", str)
+
+    # generate batch
     best_indices = np.empty((batch_size, utilities.ndim), dtype=int)
+    if method == "max":
+        batch_utilities = np.empty((batch_size,) + utilities.shape)
+        for i in range(batch_size):
+            best_indices[i] = rand_argmax(utilities, random_state=random_state)
+            batch_utilities[i] = utilities
+            utilities[tuple(best_indices[i])] = np.nan
+    elif method == "proportional":
+        random_state = check_random_state(random_state)
+        p = utilities / np.nansum(utilities)
+        p[np.isnan(p)] = 0
+        best_indices = random_state.choice(
+            len(utilities),
+            size=batch_size,
+            p=p,
+            replace=False,
+        )
+
+        batch_utilities = np.repeat([utilities], batch_size, axis=0)
+        for i in range(batch_size):
+            batch_utilities[i, best_indices[:i]] = np.nan
+    else:
+        raise ValueError(
+            f'"method" has to be either "max" or "proportional" '
+            f"but {method} was given."
+        )
 
-    for i in range(batch_size):
-        best_indices[i] = rand_argmax(utilities, random_state=random_state)
-        batch_utilities[i] = utilities
-        utilities[tuple(best_indices[i])] = np.nan
-    # Check whether utilities are to be returned.
     if utilities.ndim == 1:
         best_indices = best_indices.flatten()
 
+    # Check whether utilities are to be returned.
     if return_utilities:
         return best_indices, batch_utilities
     else:
@@ -148,10 +178,10 @@ def simple_batch(
 
 def combine_ranking(*iter_ranking, rank_method=None, rank_per_batch=False):
     """Combine different rankings hierarchically to one ranking assignment.
-    A ranking index i is ranked higher than index j iff ranking[i] > ranking[j].
-    For the combined ranking it will hold that the first ranking of iter_ranking
-    always determines the ranking position at an index, and only when
-    two ranking assignments are equal the second ranking will determine
+    A ranking index i is ranked higher than index j iff ranking[i]>ranking[j].
+    For the combined ranking it will hold that the first ranking of
+    `iter_ranking` always determines the ranking position at an index, and only
+    when two ranking assignments are equal the second ranking will determine
     the ranking position and so forth.
 
     Parameters
@@ -164,7 +194,8 @@ def combine_ranking(*iter_ranking, rank_method=None, rank_per_batch=False):
         The method by which the utilities are ranked. See `scipy.rankdata`s
         argument `method` for details.
     rank_per_batch : bool, optional (default = False)
-        Whether the first index determines the batch and is not used for ranking
+        Whether the first index determines the batch and is not used for
+        ranking.
 
     Returns
     -------
diff --git a/skactiveml/utils/_validation.py b/skactiveml/utils/_validation.py
index 2a8ff909..2917663c 100644
--- a/skactiveml/utils/_validation.py
+++ b/skactiveml/utils/_validation.py
@@ -544,8 +544,8 @@ def check_indices(indices, A, dim="adaptive", unique=True):
             n_unique_indices = len(np.unique(indices, axis=0))
         if n_unique_indices < len(indices):
             raise ValueError(
-                f"`indices` contains two different indices of the "
-                f"same value."
+                "`indices` contains two different indices of the "
+                "same value."
             )
     elif unique:
         if indices.ndim == 1:
@@ -630,7 +630,7 @@ def check_type(
     if wrong_type and wrong_value and wrong_index:
         error_str = f"`{name}` "
         if len(target_types) == 0 and len(target_vals) == 0:
-            error_str += f" must"
+            error_str += " must"
         if len(target_vals) == 0 and len(target_types) > 0:
             error_str += f" has type `{type(obj)}`, but must"
         elif len(target_vals) > 0 and len(target_types) == 0:


Test patch:
diff --git a/skactiveml/classifier/tests/test_parzen_window_classifier.py b/skactiveml/classifier/tests/test_parzen_window_classifier.py
index 0247d6b1..e2dbd189 100644
--- a/skactiveml/classifier/tests/test_parzen_window_classifier.py
+++ b/skactiveml/classifier/tests/test_parzen_window_classifier.py
@@ -131,7 +131,10 @@ def test_predict_freq(self):
         self.assertRaises(ValueError, pwc.predict_freq, X=[[1], [0]])
         F = pwc.predict_freq(X=[[1, 0, 0]])
         np.testing.assert_array_equal([[0, 0, 2]], F)
-        rbf_kernel = lambda x, y, gamma: np.exp(-gamma * np.sum((x - y) ** 2))
+
+        def rbf_kernel(x, y, gamma):
+            return np.exp(-gamma * np.sum((x - y) ** 2))
+
         pwc = ParzenWindowClassifier(
             classes=["tokyo", "paris"],
             missing_label="nan",
diff --git a/skactiveml/classifier/tests/test_wrapper.py b/skactiveml/classifier/tests/test_wrapper.py
index a3d73e9f..d833a7cf 100644
--- a/skactiveml/classifier/tests/test_wrapper.py
+++ b/skactiveml/classifier/tests/test_wrapper.py
@@ -474,15 +474,6 @@ def test_fit_param_y(self, test_cases=None):
             replace_fit_params=replace_fit_params,
         )
 
-    def test_partial_fit_param_X(
-        self, test_cases=None, replace_init_params=None
-    ):
-        replace_init_params = {
-            "classes": ["tokyo", "paris"],
-            "missing_label": "nan",
-            "estimator": SklearnClassifier(GaussianNB(), missing_label="nan"),
-        }
-
     def test_partial_fit_param_y(self, test_cases=None):
         test_cases = [] if test_cases is None else test_cases
         test_cases += [
diff --git a/skactiveml/pool/multiannotator/tests/test_wrapper.py b/skactiveml/pool/multiannotator/tests/test_wrapper.py
index 43863cc5..2914d65b 100644
--- a/skactiveml/pool/multiannotator/tests/test_wrapper.py
+++ b/skactiveml/pool/multiannotator/tests/test_wrapper.py
@@ -89,7 +89,8 @@ def test_init_param_y_aggregate(self):
             return_utilities=True,
         )
 
-        dummy_function = lambda x, y: majority_vote(x)
+        def dummy_function(x, y):
+            return majority_vote(x)
 
         random = RandomSampling(random_state=self.random_state)
         wrapper = SingleAnnotatorWrapper(
@@ -515,7 +516,7 @@ def test_query_per_sample_too_large(self):
         self.check_max(best_cand_indices, utilities)
 
     def test_query_unavailable_annotators(self):
-        random = RandomSampling(random_state=self.random_state)
+        random = RandomSampling(random_state=0)
 
         X_cand = np.array([[7, 1], [9, 1], [3, 5], [2, 7]])
 
diff --git a/skactiveml/pool/tests/test_badge.py b/skactiveml/pool/tests/test_badge.py
index 39d2d1f1..8fc18d9a 100644
--- a/skactiveml/pool/tests/test_badge.py
+++ b/skactiveml/pool/tests/test_badge.py
@@ -1,5 +1,4 @@
 import unittest
-from copy import deepcopy
 
 import numpy as np
 from sklearn.linear_model import LogisticRegression
diff --git a/skactiveml/pool/tests/test_core_set.py b/skactiveml/pool/tests/test_core_set.py
index da73af8a..f30f5e2f 100644
--- a/skactiveml/pool/tests/test_core_set.py
+++ b/skactiveml/pool/tests/test_core_set.py
@@ -78,9 +78,9 @@ def test_query(self):
         )
         self.assertEqual(5, utilities_5.shape[1])
 
-        # test case 6: remove already unlabeled samples `X[unlbld_indices]` from `X`
-        # and add them as `candidates=X[unlbld_indices]` and check whether the utilities
-        # are the same.
+        # test case 6: remove already unlabeled samples `X[unlbld_indices]`
+        # from `X` and add them as `candidates=X[unlbld_indices]` and check
+        # whether the utilities are the same.
         core_set_6 = CoreSet(random_state=42)
 
         X = random_state.choice(5, size=(10, 2))
diff --git a/skactiveml/pool/tests/test_discriminative_al.py b/skactiveml/pool/tests/test_discriminative_al.py
index 040b3c8c..910e237f 100644
--- a/skactiveml/pool/tests/test_discriminative_al.py
+++ b/skactiveml/pool/tests/test_discriminative_al.py
@@ -1,9 +1,6 @@
 import unittest
 
 import numpy as np
-from sklearn.datasets import load_breast_cancer
-from sklearn.gaussian_process import GaussianProcessClassifier
-from sklearn.preprocessing import StandardScaler
 from sklearn.svm import SVC
 
 from skactiveml.classifier import ParzenWindowClassifier
diff --git a/skactiveml/pool/tests/test_epistemic_uncertainty_sampling.py b/skactiveml/pool/tests/test_epistemic_uncertainty_sampling.py
index 29469061..e9230a6b 100644
--- a/skactiveml/pool/tests/test_epistemic_uncertainty_sampling.py
+++ b/skactiveml/pool/tests/test_epistemic_uncertainty_sampling.py
@@ -3,7 +3,6 @@
 
 import numpy as np
 from sklearn.linear_model import LogisticRegression
-from sklearn.svm import SVC
 from sklearn.tree import DecisionTreeClassifier
 
 from skactiveml.classifier import SklearnClassifier, ParzenWindowClassifier
@@ -234,8 +233,7 @@ def test_epistemic_uncertainty_logreg(self):
         clf = SklearnClassifier(LogisticRegression(), classes=[0, 1])
         clf.fit(X, y)
         utils = _epistemic_uncertainty_logreg(X_cand, X, y, clf, probas)
-        # TODO
-        # np.testing.assert_array_equal(utils_expected, utils)
+        np.testing.assert_array_equal([0], utils)
 
     def test_query(self):
         query_params = deepcopy(self.query_default_params_clf)
diff --git a/skactiveml/pool/tests/test_expected_error_reduction.py b/skactiveml/pool/tests/test_expected_error_reduction.py
index 63ea9850..acae89cc 100644
--- a/skactiveml/pool/tests/test_expected_error_reduction.py
+++ b/skactiveml/pool/tests/test_expected_error_reduction.py
@@ -2,7 +2,6 @@
 from copy import deepcopy
 
 import numpy as np
-from sklearn.gaussian_process import GaussianProcessClassifier
 from sklearn.naive_bayes import GaussianNB
 from sklearn.svm import SVC
 
diff --git a/skactiveml/pool/tests/test_regression_tree_based_al.py b/skactiveml/pool/tests/test_regression_tree_based_al.py
new file mode 100644
index 00000000..75176d19
--- /dev/null
+++ b/skactiveml/pool/tests/test_regression_tree_based_al.py
@@ -0,0 +1,173 @@
+import unittest
+
+import numpy as np
+from sklearn.metrics import pairwise_distances_argmin
+
+from skactiveml.pool import RegressionTreeBasedAL
+from skactiveml.pool._regression_tree_based_al import (
+    _calc_acquisitions_per_leaf,
+    _discretize_acquisitions_per_leaf,
+)
+from skactiveml.regressor import NICKernelRegressor, SklearnRegressor
+from skactiveml.tests.template_query_strategy import (
+    TemplateSingleAnnotatorPoolQueryStrategy,
+)
+from skactiveml.utils import MISSING_LABEL
+from sklearn.tree import DecisionTreeRegressor, ExtraTreeRegressor
+
+
+class TestRegressionTreeBasedAL(
+    TemplateSingleAnnotatorPoolQueryStrategy, unittest.TestCase
+):
+    def setUp(self):
+        self.reg = SklearnRegressor(
+            DecisionTreeRegressor(min_samples_leaf=2, random_state=0)
+        )
+
+        query_default_params_reg = {
+            "X": np.array([[1, 2], [5, 8], [8, 4], [5, 4]]),
+            "y": np.array([1.5, -1.2, MISSING_LABEL, MISSING_LABEL]),
+            "reg": self.reg,
+        }
+        super().setUp(
+            qs_class=RegressionTreeBasedAL,
+            init_default_params={},
+            query_default_params_reg=query_default_params_reg,
+        )
+
+    def test_init_param_method(self, test_cases=None):
+        test_cases = test_cases or []
+        test_cases += [
+            (1, TypeError),
+            ("string", ValueError),
+            ("random", None),
+            ("diversity", None),
+            ("representativity", None),
+        ]
+        self._test_param("init", "method", test_cases)
+
+    def test_init_param_max_iter_representativity(self, test_cases=None):
+        test_cases = test_cases or []
+        test_cases += [
+            (-1, ValueError),
+            ("string", TypeError),
+            (1, None),
+            (10, None),
+        ]
+        self._test_param(
+            "init",
+            "max_iter_representativity",
+            test_cases,
+            replace_init_params={"method": "representativity"},
+        )
+
+    def test_query_param_reg(self, test_cases=None):
+        test_cases = test_cases or []
+        test_cases += [
+            (SklearnRegressor(NICKernelRegressor()), TypeError),
+            (DecisionTreeRegressor(), TypeError),
+            (SklearnRegressor(DecisionTreeRegressor()), None),
+            (SklearnRegressor(ExtraTreeRegressor()), None),
+        ]
+        self._test_param("query", "reg", test_cases)
+
+    def test__calc_acquisitions_per_leaf(self):
+        reg = SklearnRegressor(_DummyRegressor())
+        X = np.array([0, 2, 10, 12, 20, 22, 1, 11, 21]).reshape(-1, 1)
+        y = np.append([0, 2, 10, 12, 20, 22], np.full(3, MISSING_LABEL))
+        np.testing.assert_allclose(
+            _calc_acquisitions_per_leaf(X, y, reg, MISSING_LABEL),
+            np.full(3, 1 / 3),
+        )
+
+    def test__discretize_acquisitions_per_leaf(self):
+        n_k = np.array([2.5, 4.0, 3.9, 7.3, 9.6])
+        n_k_discrete = _discretize_acquisitions_per_leaf(
+            n_k, np.random.RandomState(0)
+        )
+        # Ensures the correct `batch_size`, i.e., number of acquisitions.
+        self.assertEqual(n_k_discrete.sum(), np.floor(n_k.sum()))
+
+        # Ensures the correct minimum acquisitions per leaf.
+        self.assertTrue((np.abs(n_k_discrete - n_k) <= 1).all())
+
+        # Checks reproducibility.
+        for _ in range(5):
+            n_k_discrete_new = _discretize_acquisitions_per_leaf(
+                n_k, np.random.RandomState(0)
+            )
+            np.testing.assert_array_equal(n_k_discrete, n_k_discrete_new)
+
+        # Checks that different random states can lead to different results.
+        n_k = np.array([0.9] * 100)
+        n_k_discrete = _discretize_acquisitions_per_leaf(
+            n_k, np.random.RandomState(0)
+        )
+        n_k_discrete_new = _discretize_acquisitions_per_leaf(
+            n_k, np.random.RandomState(2)
+        )
+        self.assertTrue((n_k_discrete != n_k_discrete_new).any())
+
+    def test_query(self):
+        X = np.linspace(-2, 2, 100).reshape(-1, 1)
+        y = np.sin(X.ravel())
+        y[30:70] = MISSING_LABEL
+        batch_size = 10
+
+        # Test varying methods.
+        for method in ["diversity", "representativity"]:
+            for candidates in [None, range(44, 56), X[range(44, 56)]]:
+                qs = self.qs_class(random_state=0, method=method)
+                idxs, utilities = qs.query(
+                    X,
+                    y,
+                    self.reg,
+                    batch_size=batch_size,
+                    return_utilities=True,
+                    candidates=candidates,
+                )
+                self.reg.fit(X, y)
+                u_neg_inf = np.isneginf(utilities)
+                u_neg_inf_sum = u_neg_inf.sum(axis=1)
+                if method in ["random", "diversity"]:
+                    u_method_test = (
+                        utilities == 1
+                        if method == "random"
+                        else utilities >= 0
+                    )
+                    if u_method_test is not None:
+                        self.assertTrue(
+                            (
+                                u_neg_inf + np.isnan(utilities) + u_method_test
+                            ).all()
+                        )
+
+                    if candidates is None:
+                        self.assertTrue(
+                            (
+                                (10 <= u_neg_inf_sum) & (u_neg_inf_sum <= 20)
+                            ).all()
+                        )
+                    else:
+                        self.assertTrue(
+                            ((0 <= u_neg_inf_sum) & (u_neg_inf_sum <= 6)).all()
+                        )
+                else:
+                    n_candidates = (-np.inf < utilities).sum()
+                    if candidates is None:
+                        self.assertEqual(n_candidates, 40)
+                    else:
+                        self.assertEqual(n_candidates, 12)
+
+
+class _DummyRegressor(DecisionTreeRegressor):
+    centers = np.array([1, 11, 21]).reshape(-1, 1)
+    node_count = 3
+
+    def apply(self, X):
+        return pairwise_distances_argmin(X, self.centers, axis=1)
+
+    def __getattr__(self, item):
+        if item == "tree_":
+            return self
+        raise AttributeError
diff --git a/skactiveml/pool/tests/test_typi_clust.py b/skactiveml/pool/tests/test_typi_clust.py
index b8df7ed0..d050a0b2 100644
--- a/skactiveml/pool/tests/test_typi_clust.py
+++ b/skactiveml/pool/tests/test_typi_clust.py
@@ -99,13 +99,11 @@ def test_query(self):
         )
         for u in utilities:
             for i in u:
-                if not np.isnan(i):
-                    self.assertGreaterEqual(i, 0)
-                else:
-                    self.assertTrue(np.isnan(i))
+                if not np.isnan(i) and i < 0:
+                    self.assertTrue(np.isneginf(i))
 
-        # test case 3: for an uncovered cluster with 2 samples, the utilities with k=1 is for
-        # all samples are the same
+        # test case 3: for an uncovered cluster with 2 samples, the utilities
+        # with k=1 is for all samples are the same
         X_3 = np.array([[1, 2], [3, 4]])
         y_3 = np.full(2, MISSING_LABEL)
         typi_clust_3 = TypiClust(random_state=42, k=1)
@@ -125,11 +123,9 @@ def test_query(self):
             X, y_1, batch_size=1, candidates=candidates, return_utilities=True
         )
         for u in utilities_4:
-            for idx, value in enumerate(u):
-                if idx in candidates:
-                    self.assertGreaterEqual(value, 0)
-                else:
-                    self.assertTrue(np.isnan(value))
+            for i in u:
+                if not np.isnan(i) and i < 0:
+                    self.assertTrue(np.isneginf(i))
         self.assertEqual(10, utilities_4.shape[1])
         self.assertEqual(1, utilities_4.shape[0])
 
diff --git a/skactiveml/pool/tests/test_uncertainty_sampling.py b/skactiveml/pool/tests/test_uncertainty_sampling.py
index 94bdacd2..27370374 100644
--- a/skactiveml/pool/tests/test_uncertainty_sampling.py
+++ b/skactiveml/pool/tests/test_uncertainty_sampling.py
@@ -21,12 +21,10 @@ def setUp(self):
         self.classes = [0, 1]
         query_default_params_clf = {
             "X": np.array([[1, 2], [5, 8], [8, 4], [5, 4]]),
-            #'y': np.array([0, 1, MISSING_LABEL, MISSING_LABEL]),
             "clf": ParzenWindowClassifier(
                 random_state=0, classes=self.classes
             ),
             "y": np.array([0, 0, MISSING_LABEL, MISSING_LABEL]),
-            #'clf': SklearnClassifier(SVC(probability=True), random_state=0, classes=[0, 1]),
         }
         super().setUp(
             qs_class=UncertaintySampling,
diff --git a/skactiveml/pool/tests/test_utils.py b/skactiveml/pool/tests/test_utils.py
index fd3a5d67..0c5cc1a1 100644
--- a/skactiveml/pool/tests/test_utils.py
+++ b/skactiveml/pool/tests/test_utils.py
@@ -679,8 +679,10 @@ def setUp(self):
         self.random_state = 0
 
     def test_conditional_expectation_params(self):
-        dummy_func_1 = lambda y: np.zeros_like(y)
-        dummy_func_2 = lambda x, y: np.zeros_like(y)
+        dummy_func_1 = np.zeros_like
+
+        def dummy_func_2(x, y):
+            return np.zeros_like(y)
 
         X = np.arange(4 * 2).reshape(4, 2)
         y = np.arange(4, dtype=float)
diff --git a/skactiveml/regressor/tests/test_nic_kernel_regressor.py b/skactiveml/regressor/tests/test_nic_kernel_regressor.py
index e8dd080e..d0f7bbf8 100644
--- a/skactiveml/regressor/tests/test_nic_kernel_regressor.py
+++ b/skactiveml/regressor/tests/test_nic_kernel_regressor.py
@@ -9,7 +9,6 @@
 )
 from skactiveml.utils import MISSING_LABEL
 from scipy.stats import norm
-from copy import deepcopy
 
 from skactiveml.tests.template_estimator import TemplateProbabilisticRegressor
 
diff --git a/skactiveml/stream/tests/test_density_uncertainty.py b/skactiveml/stream/tests/test_density_uncertainty.py
index f267df36..0835b542 100644
--- a/skactiveml/stream/tests/test_density_uncertainty.py
+++ b/skactiveml/stream/tests/test_density_uncertainty.py
@@ -1,7 +1,6 @@
 import unittest
 
 import numpy as np
-from sklearn.metrics import pairwise_distances
 from sklearn.metrics.pairwise import pairwise_distances
 from skactiveml.utils import MISSING_LABEL
 from skactiveml.classifier import SklearnClassifier, ParzenWindowClassifier
diff --git a/skactiveml/stream/tests/test_stream_probabilistic_al.py b/skactiveml/stream/tests/test_stream_probabilistic_al.py
index 564bc3f6..301a7bb1 100644
--- a/skactiveml/stream/tests/test_stream_probabilistic_al.py
+++ b/skactiveml/stream/tests/test_stream_probabilistic_al.py
@@ -1,7 +1,6 @@
 import unittest
 
 import numpy as np
-from sklearn.datasets import make_classification
 from sklearn.naive_bayes import GaussianNB
 from skactiveml.utils import MISSING_LABEL
 from skactiveml.classifier import ParzenWindowClassifier, SklearnClassifier
diff --git a/skactiveml/tests/template_query_strategy.py b/skactiveml/tests/template_query_strategy.py
index e0349b3c..ad0d1e39 100644
--- a/skactiveml/tests/template_query_strategy.py
+++ b/skactiveml/tests/template_query_strategy.py
@@ -1,5 +1,4 @@
 import inspect
-import unittest
 import warnings
 from copy import deepcopy
 
@@ -58,11 +57,11 @@ def setUp(
             and self.query_default_params_reg is None
         ):
             raise ValueError(
-                f"The query strategies must support either "
-                f"classification or regression. Hence, at least "
-                f"one parameter of `query_default_params_clf` "
-                f"and `query_default_params_reg` must be not None. "
-                f"Use emtpy dictionary to use default values."
+                "The query strategies must support either "
+                "classification or regression. Hence, at least "
+                "one parameter of `query_default_params_clf` "
+                "and `query_default_params_reg` must be not None. "
+                "Use emtpy dictionary to use default values."
             )
         if self.query_default_params_clf is not None:
             check_positional_args(
@@ -575,12 +574,12 @@ def test_query_batch_variation(self):
                 self.assertEqual(sum(np.isnan(utils[0])), n_labeled)
 
                 query_params["batch_size"] = max_batch_size + 1
-                query_params["return_utilities"] = False
+                query_params["return_utilities"] = True
                 self.assertWarns(Warning, qs.query, **query_params)
 
                 with warnings.catch_warnings():
                     warnings.filterwarnings("ignore")
-                    ids = qs.query(**query_params)
+                    ids, utilities = qs.query(**query_params)
                     self.assertEqual(len(ids), max_batch_size)
 
     def test_query_candidate_variation(self):
diff --git a/skactiveml/tests/utils.py b/skactiveml/tests/utils.py
index a59af3a7..0a69c6e5 100644
--- a/skactiveml/tests/utils.py
+++ b/skactiveml/tests/utils.py
@@ -20,13 +20,13 @@ def check_positional_args(func, func_name, param_dict, kwargs_name=None):
             ):
                 if kwargs_name in None:
                     raise ValueError(
-                        f"Missing positional argument `{key}` of `{func_name}` in "
-                        f"`{func_name}_default_kwargs`."
+                        f"Missing positional argument `{key}` of `{func_name}`"
+                        f" in `{func_name}_default_kwargs`."
                     )
                 else:
                     raise ValueError(
-                        f"Missing positional argument `{key}` of `{func_name}` in "
-                        f"`{kwargs_name}`."
+                        f"Missing positional argument `{key}` of `{func_name}`"
+                        f" in `{kwargs_name}`."
                     )
 
 
diff --git a/skactiveml/utils/tests/test_selection.py b/skactiveml/utils/tests/test_selection.py
index 1160a92c..4a07684a 100644
--- a/skactiveml/utils/tests/test_selection.py
+++ b/skactiveml/utils/tests/test_selection.py
@@ -1,6 +1,8 @@
 import unittest
 
 import numpy as np
+from scipy.stats import norm
+from sklearn.neighbors import KernelDensity
 
 from skactiveml.utils import rand_argmin, rand_argmax, simple_batch
 from skactiveml.utils._selection import combine_ranking
@@ -115,6 +117,48 @@ def test_simple_batch(self):
         )
         np.testing.assert_equal((0, 2), indices.shape)
 
+        batch_size = 10
+        idx, utils = simple_batch(
+            np.arange(100),
+            batch_size=batch_size,
+            return_utilities=True,
+            method="proportional",
+        )
+        self.assertEqual(batch_size, len(idx))
+        self.assertEqual(
+            np.sum(np.isnan(utils)), np.sum(np.arange(batch_size))
+        )
+        for i in range(batch_size):
+            np.testing.assert_array_equal(
+                np.argwhere(np.isnan(utils[i])).flatten(), np.sort(idx[:i])
+            )
+
+        # test proportional method
+        N = 1000
+        X = np.linspace(-5, 10, N)
+        true_dens = 0.3 * norm(0, 1).pdf(X.reshape(-1, 1)) + 0.7 * norm(
+            5, 1
+        ).pdf(X.reshape(-1, 1))
+        true_dens += np.mean(true_dens)
+        true_dens = true_dens / np.sum(true_dens)
+        sel = np.empty(100000)
+        for i in range(100000):
+            sel[i] = X[
+                simple_batch(
+                    true_dens[:, 0], method="proportional", random_state=i
+                )[0]
+            ]
+        density = KernelDensity(kernel="gaussian", bandwidth=0.2).fit(
+            sel.reshape(-1, 1)
+        )
+        est_dens = np.exp(density.score_samples(X.reshape(-1, 1))).flatten()
+        est_dens /= np.sum(est_dens)
+        np.testing.assert_allclose(
+            true_dens.flatten()[20:-20], est_dens[20:-20], rtol=0.1
+        )
+
+        self.assertRaises(ValueError, simple_batch, utils, method="string")
+
     def test_combine_ranking(self):
         self.assertRaises(
             ValueError,
diff --git a/skactiveml/visualization/tests/images/dec_bound_wo_cand_expected.pdf b/skactiveml/visualization/tests/images/dec_bound_wo_cand_expected.pdf
index 1affbe43..9f4eda00 100644
Binary files a/skactiveml/visualization/tests/images/dec_bound_wo_cand_expected.pdf and b/skactiveml/visualization/tests/images/dec_bound_wo_cand_expected.pdf differ