import numpy as np
from sklearn.cluster import KMeans
from sklearn.neighbors import NearestNeighbors
from ..base import SingleAnnotatorPoolQueryStrategy
from ..utils import MISSING_LABEL, labeled_indices, check_scalar, rand_argmax
from ._clustering import _set_random_state_if_supported
[docs]
class TypiClust(SingleAnnotatorPoolQueryStrategy):
"""Typical Clustering (TypiClust)
This class implements the Typical Clustering (TypiClust) query strategy
[1]_, which clusters embeddings of both labeled and unlabeled data with
`n_clusters=n_labeled_samples + batch_size`, treating clusters that contain
labeled samples as covered. It then selects the most typical sample
(highest local density / smallest mean kNN distance) from up to
`batch_size` uncovered clusters, ensuring diversity while avoiding already
represented regions. Originally, this query strategy was only proposed for
classification tasks. Nevertheless, this implementation is task-agnostic
such that it can handle class labels, numerical targets, and multilabel
targets represented by a two-dimensional `y`.
Parameters
----------
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.
cluster_algo : ClusterMixin.__class__, default=KMeans
The cluster algorithm to be used.
cluster_algo_dict : dict, optional (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.
k : int, default=5
The number for k-nearest-neighbors for the computation of typicality.
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] G. Hacohen, A. Dekel, and D. Weinshall. Active Learning on a Budget:
Opposite Strategies Suit High and Low Budgets. In Int. Conf. Mach.
Learn., pages 8175–8195, 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,
missing_label=MISSING_LABEL,
random_state=None,
cluster_algo=KMeans,
cluster_algo_dict=None,
n_cluster_param_name="n_clusters",
k=5,
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.k = k
[docs]
def query(
self,
X,
y,
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,) 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 TypiClust
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. 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,
)
check_scalar(self.k, "k", target_type=int, min_val=1)
if not (
isinstance(self.cluster_algo_dict, dict)
or self.cluster_algo_dict is None
):
raise TypeError(
"Please pass a dictionary with corresponding parameter name "
"and value to the `init` function."
)
cluster_algo_dict = (
{}
if self.cluster_algo_dict is None
else self.cluster_algo_dict.copy()
)
if not isinstance(self.n_cluster_param_name, str):
raise TypeError("`n_cluster_param_name` supports only string.")
# Determine candidate samples for selection.
_, mapping = self._transform_candidates(
candidates=candidates,
X=X,
y=y,
enforce_mapping=True,
target_type=target_type,
)
# Determine already labeled samples.
labeled_sample_indices = labeled_indices(
y=y,
missing_label=self.missing_label,
target_type=target_type,
)
# Set number of clusters.
n_clusters = len(labeled_sample_indices) + batch_size
cluster_algo_dict[self.n_cluster_param_name] = n_clusters
_set_random_state_if_supported(
self.cluster_algo, cluster_algo_dict, self.random_state
)
# Create object for clustering with given parameters.
cluster_obj = self.cluster_algo(**cluster_algo_dict)
cluster_labels = cluster_obj.fit_predict(X)
# Determine number of samples per cluster and mask clusters with
# labeled samples.
cluster_sizes = np.zeros(n_clusters)
cluster_ids, cluster_ids_sizes = np.unique(
cluster_labels, return_counts=True
)
cluster_sizes[cluster_ids] = cluster_ids_sizes
covered_cluster = np.unique(
[cluster_labels[i] for i in labeled_sample_indices]
)
if len(covered_cluster) > 0:
cluster_sizes[covered_cluster] = 0
utilities = np.full(shape=(batch_size, X.shape[0]), fill_value=np.nan)
query_indices = []
for i in range(batch_size):
if cluster_sizes.max() == 0:
typicality = np.ones(len(X))
else:
cluster_id = rand_argmax(
cluster_sizes, random_state=self.random_state_
)
is_cluster = cluster_labels == cluster_id
uncovered_samples_mapping = np.where(is_cluster)[0]
typicality = _typicality(X, uncovered_samples_mapping, self.k)
utilities[i, mapping] = typicality[mapping]
utilities[i, query_indices] = np.nan
idx = rand_argmax(
typicality[mapping], random_state=self.random_state_
)
idx = mapping[idx[0]]
query_indices = np.append(query_indices, [idx]).astype(int)
cluster_sizes[cluster_id] = 0
if return_utilities:
return query_indices, utilities
else:
return query_indices
def _typicality(X, uncovered_samples_mapping, k, eps=1e-7):
"""
Calculation the typicality of samples `X` in uncovered clusters.
Parameters
----------
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,),
Index array that maps `candidates` to `X_for_cluster`.
k : int
k for computation of k nearst neighbors.
eps : float > 0, default=1e-7
Minimum distance sum to compute typicality.
Returns
-------
typicality : numpy.ndarray of shape (n_samples,)
The typicality of all uncovered samples in X
"""
typicality = np.full(shape=X.shape[0], fill_value=-np.inf)
if len(uncovered_samples_mapping) == 1:
typicality[uncovered_samples_mapping] = 1
return typicality
k = np.min((len(uncovered_samples_mapping) - 1, k))
nn = NearestNeighbors(n_neighbors=k + 1).fit(X[uncovered_samples_mapping])
dist_matrix_sort_inc, _ = nn.kneighbors(
X[uncovered_samples_mapping], n_neighbors=k + 1, return_distance=True
)
knn = np.sum(dist_matrix_sort_inc, axis=1) + eps
typi = ((1 / k) * knn) ** (-1)
typicality[uncovered_samples_mapping] = typi
return typicality