"""
Module implementing Clustering Uncertainty-weighted Embeddings (CLUE).
CLUE is a deep active learning strategy, which performs a clustering with
uncertainties as sample weights.
"""
import numpy as np
from sklearn.cluster import KMeans
from ..base import (
SingleAnnotatorPoolQueryStrategy,
SkactivemlClassifier,
SkactivemlRegressor,
)
from ..pool import uncertainty_scores
from ..utils import (
MISSING_LABEL,
rand_argmax,
check_type,
)
from ..utils._validation import _canonicalize_multilabel_probas
from ._clustering import _set_random_state_if_supported
from ._target import _fit_and_resolve_estimator_target_spec
[docs]
class Clue(SingleAnnotatorPoolQueryStrategy):
"""Clustering Uncertainty-weighted Embeddings (CLUE)
This class implements the Clustering Uncertainty-weighted Embeddings (CLUE)
query strategy [1]_ clusters latent embeddings while weighting samples by
predictive uncertainty, then picks samples near the cluster centers. The
result is a diverse set biased toward uncertain regions of representation
space.
The original `Clue` query strategy was proposed for single-output
classification tasks only and did not include regression or multi-label
variants. Multi-label support in this implementation is an extension and
not part of the original proposal in [1]_. For resolved multi-label
targets, the per-label score of the label output `j` is computed by
`method` from its positive-class probability `p_j` alone (cf.
`uncertainty_scores`), and `multilabel_aggregation_fn` reduces these
per-label scores along the label axis to the clustering sample weight of
one sample. This per-output decomposition ignores correlations between
label outputs. Support for regression is likewise an extension and relies
on user-provided sample-wise uncertainty estimates.
Parameters
----------
predict_dict : dict or None, default=None
Optional keyword arguments passed to the estimator's prediction
method in order to obtain sample embeddings and/or uncertainties as
additional outputs.
* For classification, `Clue` calls::
out = estimator.predict_proba(X, **predict_dict)
* For regression, `Clue` calls::
out = estimator.predict(X, **predict_dict)
If `out` is a tuple, its additional elements are inferred by shape:
sample-wise uncertainties must be a 1D `numpy.ndarray`, and
sample embeddings must be a 2D `numpy.ndarray`.
In the classification case, returning uncertainties is optional,
because they can be derived from the predicted class probabilities
(see the documentation of the `method` parameter). In the regression
case, providing uncertainties as an additional output is mandatory.
method : 'least_confident' or 'margin_sampling' or 'entropy', \
default="entropy"
Fallback uncertainty measure used in the classification case when
the classifier does not provide explicit uncertainties.
- `method='least_confident'` queries the sample whose maximal posterior
probability is minimal.
- `method='margin_sampling'` queries the sample whose posterior
probability gap between the most and the second most probable class
label is minimal.
- `method='entropy'` queries the sample whose posterior's have the
maximal entropy.
cluster_algo : ClusterMixin.__class__, default=KMeans
The cluster algorithm to be used. It must implement a `fit_transform`
method, which takes samples `X` and `sample_weight` as inputs, e.g.,
`sklearn.clustering.KMeans` and `sklearn.clustering.MiniBatchKMeans`.
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.
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 clustering sample weight. 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.
target_type : "auto" or "single-output" or "multi-label", default="auto"
Declared target type. The strategy supports single-output
classification and regression, and multi-label classification. A
fitted estimator's target specification is authoritative when
available.
References
----------
.. [1] V. Prabhu, A. Chandrasekaran, K. Saenko, and J. Hoffman. Active
domain adaptation via clustering uncertainty-weighted embeddings. In
IEEE/CVF Int. Conf. Comput. Vis., pages 8505–8514, 2021.
"""
def __init__(
self,
predict_dict=None,
method="entropy",
cluster_algo=KMeans,
cluster_algo_dict=None,
n_cluster_param_name="n_clusters",
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.cluster_algo = cluster_algo
self.cluster_algo_dict = cluster_algo_dict
self.n_cluster_param_name = n_cluster_param_name
self.method = method
self.predict_dict = predict_dict
self.multilabel_aggregation_fn = multilabel_aggregation_fn
@property
def _target_capabilities(self):
return frozenset(
{
("classification", "single-output", "single-annotator"),
("classification", "multi-label", "single-annotator"),
("regression", "single-output", "single-annotator"),
}
)
[docs]
def query(
self,
X,
y,
estimator,
fit_estimator=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, ...)
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.
estimator : skactiveml.base.SkactivemlClassifier\
or skactiveml.base.SkactivemlRegressor
Estimator implementing the methods `fit` and
`predict_proba` (classification) or `predict` (regression).
For multilabel classification, `predict_proba` may either return
one probability per label with shape `(n_samples, n_outputs)` or a
list of binary probability matrices with shape
`(n_samples, 2)` per output as commonly returned by
multioutput scikit-learn estimators.
fit_estimator : bool, default=True
Defines whether the `estimator` 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 `estimator.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, ...), 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, ...)` are not supported because `Clue`
requires a mapping to the 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. The indexing
refers to the samples in `X`.
"""
# Resolve through the estimator before acquisition state is changed.
estimator, target_spec = _fit_and_resolve_estimator_target_spec(
self,
estimator,
X,
y,
fit_estimator=fit_estimator,
sample_weight=sample_weight,
estimator_name="estimator",
fit_name="fit_estimator",
estimator_types=(SkactivemlClassifier, SkactivemlRegressor),
)
# Check `__init__` and `query` 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_spec.target_type,
)
X_cand, mapping = self._transform_candidates(
candidates=candidates,
X=X,
y=y,
enforce_mapping=True,
target_type=target_spec.target_type,
)
if not callable(self.multilabel_aggregation_fn):
raise TypeError("`multilabel_aggregation_fn` must be callable.")
check_type(
self.cluster_algo_dict, "cluster_algo_dict", (dict, type(None))
)
cluster_algo_dict = (
{}
if self.cluster_algo_dict is None
else self.cluster_algo_dict.copy()
)
check_type(self.n_cluster_param_name, "n_cluster_param_name", str)
predict_dict = {} if self.predict_dict is None else self.predict_dict
check_type(predict_dict, "predict_dict", dict)
if self.method not in [
"least_confident",
"margin_sampling",
"entropy",
]:
raise ValueError(
f"`method` must be 'least_confident' or 'margin_sampling'"
f"or 'entropy'. Got {self.method} instead."
)
# Compute predictions plus optional embeddings and/or uncertainties.
is_clf = target_spec.task == "classification"
if is_clf:
out = estimator.predict_proba(X_cand, **predict_dict)
else:
out = estimator.predict(X_cand, **predict_dict)
if not isinstance(out, tuple):
out = (out,)
main = out[0]
emb = None
uncertainties = None
for out_element in out[1:]:
if out_element.ndim == 1 and uncertainties is None:
uncertainties = out_element
elif out_element.ndim == 2 and emb is None:
emb = out_element
else:
raise ValueError(
"The optional outputs when calling `predict_proba` or"
"`predict` must either be a 1D `np.ndarray` for the "
"uncertainties or a 2D `np.ndarray` for the sample "
"embeddings."
)
# Use original samples as a fallback.
n_cand = len(X_cand)
X_cand = X_cand if emb is None else emb
is_multilabel = target_spec.target_type == "multi-label"
if is_clf and uncertainties is None:
if is_multilabel:
# Canonicalize both public multilabel probability formats
# before the uncertainties are computed.
main = _canonicalize_multilabel_probas(
main, n_samples=n_cand, n_outputs=y.shape[1]
)
# Compute uncertainties as a fallback in the classification case.
uncertainties = uncertainty_scores(
probas=main,
method=self.method,
is_multilabel=is_multilabel,
multilabel_aggregation_fn=self.multilabel_aggregation_fn,
)
elif not is_clf and uncertainties is None:
raise ValueError(
"For regression, `predict` must return uncertainties."
)
# Implement a fallback, if all uncertainties are zero.
if np.nansum(uncertainties) == 0:
uncertainties = np.ones_like(uncertainties)
# Perform clustering to get centroids.
cluster_algo_dict[self.n_cluster_param_name] = batch_size
_set_random_state_if_supported(
self.cluster_algo, cluster_algo_dict, self.random_state
)
cluster_obj = self.cluster_algo(**cluster_algo_dict)
dist = cluster_obj.fit_transform(
X_cand, y=None, sample_weight=uncertainties
)
# Determine `query_indices` of the samples being closest to the
# respective centroids.
query_indices = []
utilities = np.full((batch_size, len(X)), fill_value=np.nan)
for b in range(batch_size):
utilities[b][mapping] = -dist[:, b]
utilities[b][query_indices] = np.nan
idx_b = rand_argmax(utilities[b], random_state=self.random_state_)
query_indices.append(idx_b[0])
query_indices = np.array(query_indices, dtype=int)
if return_utilities:
return query_indices, utilities
else:
return query_indices