Source code for skactiveml.pool._prob_cover

import warnings

import numpy as np
from sklearn.cluster import KMeans
from sklearn.metrics import pairwise_distances
from sklearn.utils.validation import column_or_1d

from ..base import SingleAnnotatorPoolQueryStrategy
from ..utils import (
    MISSING_LABEL,
    rand_argmax,
    check_scalar,
    is_labeled,
)
from ._clustering import _set_random_state_if_supported


[docs] class ProbCover(SingleAnnotatorPoolQueryStrategy): """Probability Coverage (ProbCover) This class implements the Probability Coverage (ProbCover) query strategy [1]_, which selects `batch_size` unlabeled points to maximize empirical coverage under a fixed radius `delta` in the embedding space, treating points within `delta` of any labeled sample as already covered and greedily adding the candidate samples that covers the most new samples at each step. It chooses `delta` via a purity criterion estimated from unlabeled data, prioritizes dense regions, and does not use predictive uncertainty. Originally, this query strategy was only proposed for classification tasks. Nevertheless, this implementation can handle class labels and multilabel targets represented by a two-dimensional `y`. Multi-label support in this implementation is an extension and not part of the original proposal in [1]_. Coverage is computed in the embedding space and is therefore independent of the target structure, but the `delta` default depends on a class count. For resolved multi-label targets with `n_classes=None`, that count is the number of distinct observed label rows, i.e., the number of distinct multi-label combinations. Parameters ---------- n_classes : None or int, default=None This parameter is used to determine the delta value. If `n_classes=None`, the number of classes is extracted from the given labels. For one-dimensional `y`, this is the number of unique observed labels. For two-dimensional `y`, this is the number of unique observed label rows, i.e., distinct multilabel combinations. If this extracted number of classes is below 2, `n_classes=2` is used as a fallback. deltas : None or array-like of shape (n_deltas,), default=None List of deltas (ball radii) to be tested for finding the maximum value satisfying a sample coverage >= `alpha`. If no value in `deltas` satisfies this constraint, a warning is raised where the minimum `delta` value is used. If `deltas=None`, the values `np.arange(0.1, 2.1, 0.1)` are used. alpha : float in (0, 1), alpha=0.95 Minimum coverage as a constraint for the `delta` selection. cluster_algo : ClusterMixin.__class__, default=sklearn.cluster.KMeans The cluster algorithm to be used for determining the best delta value. cluster_algo_dict : dict, default=None The parameters passed to the clustering algorithm `cluster_algo`, excluding the parameter for the number of clusters. n_cluster_param_name : string, default="n_clusters" The name of the parameter for the number of clusters. distance_func : callable, default=sklearn.metrics.pairwise_distances Takes as input `X` to compute the distances between each pair of samples. This function can also only return the precomputed distances of each pair in `X` for speedup. missing_label : scalar or string or np.nan or None, default=np.nan Value to represent a missing label. random_state : None or int or np.random.RandomState, default=None The random state to use. target_type : "auto" or "single-output" or "multi-label", default="auto" Declared target structure. Automatic resolution accepts only unambiguous one-dimensional targets; two-dimensional multi-label targets must be declared explicitly. References ---------- .. [1] O. Yehuda, A. Dekel, G. Hacohen, and D. Weinshall. Active Learning Through a Covering Lens. In Adv. Neural Inf. Process. Syst., 2022. """ @property def _target_capabilities(self): return frozenset( { ("classification", "single-output", "single-annotator"), ("classification", "multi-label", "single-annotator"), ("regression", "single-output", "single-annotator"), } ) def __init__( self, n_classes=None, deltas=None, alpha=0.95, cluster_algo=KMeans, cluster_algo_dict=None, n_cluster_param_name="n_clusters", distance_func=pairwise_distances, missing_label=MISSING_LABEL, random_state=None, target_type="auto", ): super().__init__( missing_label=missing_label, random_state=random_state, target_type=target_type, ) self.deltas = deltas self.alpha = alpha self.n_classes = n_classes self.cluster_algo = cluster_algo self.cluster_algo_dict = cluster_algo_dict self.n_cluster_param_name = n_cluster_param_name self.distance_func = distance_func
[docs] def query( self, X, y, candidates=None, batch_size=1, return_utilities=False, update=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,) or (n_samples, n_outputs) Labels of the training data set (possibly including unlabeled ones indicated by `self.missing_label`). If `y` is two-dimensional, a row `y[i]` must either contain only observed labels or only `missing_label` values, i.e., no mixing within a row. candidates : None or array-like of shape (n_candidates), dtype=int or \ array-like of shape (n_candidates, n_features), 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)`. - Candidate samples passed directly with shape `(n_candidates, n_features)` are not supported because ProbCover requires a mapping to samples in `X`. batch_size : int, default=1 The number of samples to be selected in one AL cycle. return_utilities : bool, 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 be queried, e.g., `query_indices[0]` indicates the first selected sample. The indexing refers to the samples in `X`. 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 the samples in `X`. """ target_type = self._resolve_query_target_type(y) # Validate parameters. X, y, candidates, batch_size, return_utilities = self._validate_data( X=X, y=y, candidates=candidates, batch_size=batch_size, return_utilities=return_utilities, reset=True, target_type=target_type, ) # Determine candidate samples for selection. _, mapping = self._transform_candidates( candidates=candidates, X=X, y=y, enforce_mapping=True, target_type=target_type, ) # Infer number of classes, which must have a minimum of 2. is_candidate = np.full(len(X), fill_value=False) is_candidate[mapping] = True n_classes = self.n_classes if n_classes is None: is_lbld = is_labeled( y=y, missing_label=self.missing_label_, target_type=target_type, ) y_labeled = y[is_lbld] if target_type == "multi-label": n_classes = len( {tuple(np.asarray(row).tolist()) for row in y_labeled} ) else: n_classes = len(np.unique(y_labeled)) n_classes = max(n_classes, 2) check_scalar( n_classes, "n_classes", min_val=2, min_inclusive=True, target_type=int, ) # Check parameters for determining the ball radius delta. if self.deltas is None: deltas = np.arange(0.2, 2.2, 0.2) else: deltas = column_or_1d(self.deltas, dtype=float) deltas = np.sort(deltas) if (deltas < 0).any(): raise ValueError("`deltas` must contain non-negative floats.") check_scalar( self.alpha, "alpha", min_val=0, max_val=1, min_inclusive=False, max_inclusive=False, target_type=float, ) if not ( isinstance(self.cluster_algo_dict, dict) or self.cluster_algo_dict is None ): raise TypeError( "Pass a dictionary with corresponding parameter names and " "values according to the `init` function of `cluster_algo`." ) check_scalar(update, name="update", target_type=bool) # Set up clustering algorithm. cluster_algo_dict = ( {} if self.cluster_algo_dict is None else self.cluster_algo_dict.copy() ) _set_random_state_if_supported( self.cluster_algo, cluster_algo_dict, self.random_state ) if update or not hasattr(self, "delta_max_"): # Compute distances between each pair of observed samples. self.distances_ = self.distance_func(X) # Compute the maximum `delta` value satisfying a purity >= `alpha`. self.delta_max_ = deltas[0] max_purity = -1 if len(deltas) > 1: cluster_algo_dict[self.n_cluster_param_name] = n_classes cluster_obj = self.cluster_algo(**cluster_algo_dict) y_cluster = cluster_obj.fit_predict(X) is_impure = y_cluster[:, None] != y_cluster for delta in deltas: edges = self.distances_ <= delta purity = 1 - (edges * is_impure).any(axis=1).mean() max_purity = max(max_purity, purity) if purity < self.alpha: break self.delta_max_ = delta # Check whether condition defined by `alpha` was satisfied. if max_purity < self.alpha: warnings.warn( f"The maximum purity was {max_purity} being smaller " f"than the required value `alpha={self.alpha}`. You must" f"provide smaller values in `deltas` to avoid " f"this warning." ) # Compute edges of the graph with the samples as vertices. edges = self.distances_ <= self.delta_max_ # Perform sample-wise selection of the batch. query_indices = np.full(batch_size, fill_value=-1, dtype=int) utilities = np.full((batch_size, len(X)), fill_value=np.nan) for b in range(batch_size): # Step (ii) in [1]: Remove incoming edges for covered samples. is_covered = edges[~is_candidate].any(axis=0) edges[:, is_covered] = False # Step (i) in [1]: Query the sample with the highest out-degree. utilities[b][is_candidate] = edges[is_candidate].sum(axis=1) idx = rand_argmax(utilities[b], random_state=self.random_state_)[0] is_candidate[idx] = False query_indices[b] = idx if return_utilities: return query_indices, utilities else: return query_indices