Source code for skactiveml.pool._falcun

"""
Module implementing `Falcun`, which is a deep active learning strategy jointly
selecting uncertain and diverse samples.
"""

import numpy as np

from ..base import SingleAnnotatorPoolQueryStrategy, SkactivemlClassifier
from ..utils import (
    MISSING_LABEL,
    check_scalar,
)
from ..utils._validation import _canonicalize_multilabel_probas
from ._uncertainty_sampling import uncertainty_scores
from ._target import _fit_and_resolve_estimator_target_spec


[docs] class Falcun(SingleAnnotatorPoolQueryStrategy): """Fast Active Learning by Contrastive UNcertainty (FALCUN) This class implements the "Fast Active Learning by Contrastive UNcertainty" (FALCUN) query strategy [1]_, which selects a batch directly in probability space using a self-adjusting mix of uncertainty and diversity. By operating only on low-dimensional class-probability outputs rather than deep embeddings, it achieves fast acquisitions while retaining strong label efficiency. The distances in probability space are initialized with the uncertainty scores themselves (cf. Eq. (3) in [1]_), so the first sample of a batch is sampled with a probability proportional to `(2 * uncertainty) ** gamma` and thus carries no diversity information. At `batch_size=1`, the acquisition is therefore gamma-tempered probabilistic margin sampling. FALCUN was proposed for single-output classification. Multi-label support in this implementation is an extension and not part of the original proposal in [1]_. For resolved multi-label targets, the paper's top-two margin is applied to each label output independently, i.e., the per-label uncertainty of the label output `j` is the binary margin `1 - |2 * p_j - 1|` of its positive-class probability `p_j`, and `multilabel_aggregation_fn` reduces these per-label margins along the label axis to the uncertainty of one sample. The diversity term stays the L1 distance in probability space, which for multi-label targets is taken between the independent per-output positive-class probabilities, i.e., `sum_j |p_j(x) - p_j(x_query)|`. Correlations between label outputs therefore influence neither term. Parameters ---------- gamma : float > 0, default=10 Controls the randomness in the selection. A value of 0 corresponds to random sampling, while a value going to infinity corresponds to selecting the sample with the highest utility (relevance). 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. multilabel_aggregation_fn : callable, default=np.mean Callable reducing the per-label uncertainty scores of one sample to one uncertainty score. It is only used for resolved multi-label classification targets. It is called with the per-label scores of shape `(n_samples, n_outputs)` and the label axis passed as the `axis` keyword argument, and must return one score per sample within the range of that sample's per-label scores, e.g. `np.mean`, `np.average`, `np.median`, `np.min`, `np.max`, or a quantile. `np.sum` is not supported, because its result grows with the number of label outputs. Only the callability of the reduction is validated at runtime, so a violating reduction silently changes the acquisition scale. Here, an inflated uncertainty would dominate the diversity term, which is min-max normalized to `[0, 1]` from the second selection of a batch onward. target_type : "auto" or "single-output" or "multi-label", default="auto" Declared target type. The strategy supports single-output and multi-label classification. A fitted classifier's target specification is authoritative when available. References ---------- .. [1] S. Gilhuber, A. Beer, Y. Ma, and T. Seidl. FALCUN: A Simple and Efficient Deep Active Learning Strategy. In Joint Eur. Conf. Mach. Learn. Knowl. Discov. Databases, pages 421–439, 2024. """ def __init__( self, gamma=10, missing_label=MISSING_LABEL, random_state=None, multilabel_aggregation_fn=np.mean, target_type="auto", ): super().__init__( missing_label=missing_label, random_state=random_state, target_type=target_type, ) self.gamma = gamma self.multilabel_aggregation_fn = multilabel_aggregation_fn @property def _target_capabilities(self): return frozenset( { ("classification", "single-output", "single-annotator"), ("classification", "multi-label", "single-annotator"), } )
[docs] def query( self, X, y, clf, fit_clf=True, sample_weight=None, candidates=None, batch_size=1, return_utilities=False, ): """Query the next samples to be labeled. 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`). For multi-label targets, a row `y[i]` must either contain only observed labels or only `missing_label` values, i.e., no mixing within a row. In this case, only multilabel classification problems, i.e. multiple binary classification tasks, are supported. `predict_proba` must then return either shape `(n_samples, n_outputs)` or a list of binary probability matrices with shape `(n_samples, 2)` per output. clf : skactiveml.base.SkactivemlClassifier Classifier implementing the methods `fit` and `predict_proba`. fit_clf : bool, default=True Defines whether the classifier `clf` should be fitted on `X`, `y`, and `sample_weight`. sample_weight : array-like of shape (n_samples,) or \ (n_samples, n_outputs), default=None Weights of training samples in `X`. For two-dimensional `y`, one weight per sample is supported. Per-target weights are forwarded to `clf.fit` without additional validation and require estimator support. 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)`. - If `candidates` is of shape `(n_candidates, ...)`, the candidate samples are directly given in `candidates` (not necessarily contained 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. - If `candidates` is `None` or of shape `(n_candidates,)`, the indexing refers to the samples in `X`. - If `candidates` is of shape `(n_candidates, n_features)`, the indexing refers to the samples in `candidates`. utilities : numpy.ndarray of shape (batch_size, n_samples) 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`, the indexing refers to the samples in `X`. - If `candidates` is of shape `(n_candidates,)` and of type `int`, `utilities` refers to the samples in `X`. - If `candidates` is of shape `(n_candidates, ...)`, `utilities` refers to the indexing in `candidates`. """ # Resolve through the classifier before acquisition state is changed. clf, target_spec = _fit_and_resolve_estimator_target_spec( self, clf, X, y, fit_estimator=fit_clf, sample_weight=sample_weight, estimator_name="clf", fit_name="fit_clf", estimator_types=(SkactivemlClassifier,), ) # Check parameters. X, y, candidates, batch_size, return_utilities = self._validate_data( X, y, candidates, batch_size, return_utilities, reset=True, target_type=target_spec.target_type, ) # Determine candidate samples for selection. X_cand, mapping = self._transform_candidates( candidates=candidates, X=X, y=y, target_type=target_spec.target_type, ) check_scalar( self.gamma, "gamma", min_val=0, target_type=(float, int), min_inclusive=True, ) if not callable(self.multilabel_aggregation_fn): raise TypeError("`multilabel_aggregation_fn` must be callable.") # Compute uncertainties via margin sampling (cf. Eq. (1) in [1]). probas_cand = clf.predict_proba(X_cand) is_multilabel = target_spec.target_type == "multi-label" if is_multilabel: probas_cand = _canonicalize_multilabel_probas( probas_cand, n_samples=len(X_cand), n_outputs=y.shape[1], ) unc_cand = uncertainty_scores( probas=probas_cand, method="margin_sampling", is_multilabel=is_multilabel, multilabel_aggregation_fn=self.multilabel_aggregation_fn, ) # Initialize distances in probability space (cf. Eq. (3) in [1]). dist_cand = unc_cand.copy() query_indices = [] utilities_cand = np.full((batch_size, len(X_cand)), np.nan) cand_indices = np.arange(len(X_cand)) for b in range(batch_size): relevance_dist_cand = dist_cand if b > 0: # Update distances (diversity) values in the class probability # space (cf. Eqs. (2) and (4) in [1]). probas_q = probas_cand[[query_indices[int(b - 1)]]] dist_new = np.abs(probas_cand - probas_q).sum(axis=1) dist_cand = np.minimum(dist_new, dist_cand) dist_min = dist_cand.min() dist_range = dist_cand.max() - dist_min relevance_dist_cand = dist_cand - dist_min if dist_range > 0: relevance_dist_cand /= dist_range # Compute relevance scores for candidates (cf. Eq. (5) and # (6) in [1]). rel_cand = unc_cand + relevance_dist_cand rel_cand[query_indices] = 0 rel_cand_max = rel_cand.max() if self.gamma == 0: rel_cand = np.ones_like(rel_cand) rel_cand[query_indices] = 0 elif rel_cand_max > 0: rel_cand = (rel_cand / rel_cand_max) ** self.gamma rel_cand_sum = np.sum(rel_cand) if rel_cand_sum == 0: rel_cand = np.ones_like(rel_cand) rel_cand[query_indices] = 0 rel_cand = rel_cand / np.sum(rel_cand) # Sample instance to be labeled (cf. Eq. (6) in [1]). query_idx = self.random_state_.choice( cand_indices, p=rel_cand, size=1 ) rel_cand[query_indices] = np.nan utilities_cand[b] = rel_cand query_indices.append(query_idx[0]) if mapping is not None: query_indices = mapping[query_indices] utilities = np.full((batch_size, len(X)), np.nan) utilities[:, mapping] = utilities_cand else: utilities = utilities_cand query_indices = np.asarray(query_indices, dtype=int) if return_utilities: return query_indices, utilities else: return query_indices