Source code for skactiveml.pool._cost_embedding_al

"""
This module implementing Active Learning with Cost Embedding
(`CostEmbeddingAL`) is a modification of:

https://github.com/ntucllab/libact/blob/master/libact.
Copyright (c) 2014, National Taiwan University All rights reserved.
BSD-2-Clause license. The full text is distributed with scikit-activeml
in `LICENSES/libact/LICENSE`.
"""

import warnings

import numpy as np
from joblib import Parallel, delayed
from sklearn import clone
from sklearn.base import BaseEstimator, RegressorMixin
from sklearn.isotonic import IsotonicRegression
from sklearn.metrics import euclidean_distances
from sklearn.neighbors import NearestNeighbors
from sklearn.svm import SVR
from sklearn.utils import check_array, check_symmetric

from ..base import SingleAnnotatorPoolQueryStrategy
from ..utils import (
    simple_batch,
    check_classifier_params,
    MISSING_LABEL,
    check_scalar,
    check_random_state,
    check_X_y,
    is_labeled,
    ExtLabelEncoder,
)


[docs] class CostEmbeddingAL(SingleAnnotatorPoolQueryStrategy): """Active Learning with Cost Embedding (ALCE) The Active Learning with Cost Embeddings (ALCE) [1]_ embeds the misclassification cost matrix into a hidden space via non-metric multidimensional scaling (with a mirroring trick for asymmetric costs) and trains a multi-target regressor to predict each sample's hidden point. It then defines cost-sensitive uncertainty as the distance from the predicted hidden point to the nearest class point and selects the unlabeled instance with the largest distance, directly prioritizing high-cost confusions. This implementation is based on libact [2]_. Parameters ---------- classes: array-like of shape (n_classes,) List of all classes that can occur. base_regressor : sklearn.base.RegressorMixin, default=None An sklearn regression model implementing the methods `fit` and `predict`. cost_matrix: array-like of shape (n_classes, n_classes), default=None Cost matrix with `cost_matrix[i,j]` defining the cost of predicting class `j` for a sample with the actual class `i`. embed_dim : int, optional (default=None) If it is `None`, `embed_dim=n_classes` is used. mds_params : dict, default=None Parameters passed to `sklearn.manifold.MDS`. nn_params : dict, default=None Parameters passed to `sklearn.neighbors.NearestNeighbors`. missing_label : scalar or string or np.nan or None, default=np.nan Value to represent a missing label. random_state : int or np.random.RandomState, default=None Random state for annotator selection. References ---------- .. [1] K.-H. Huang and H.-T. Lin. A Novel Uncertainty Sampling Algorithm for Cost-Sensitive Multiclass Active Learning. In IEEE Int. Conf. Data Min., pages 925–930, 2016. .. [2] Y.-Y. Yang, S.-C. Lee, Y.-A. Chung, T.-E. Wu, S.-A. Chen, and H.-T. Lin. libact: Pool-based Active Learning in Python. arXiv:1710.00379, 2017. """ def __init__( self, classes, base_regressor=None, cost_matrix=None, embed_dim=None, mds_params=None, nn_params=None, missing_label=MISSING_LABEL, random_state=None, ): super().__init__( missing_label=missing_label, random_state=random_state ) self.classes = classes self.base_regressor = base_regressor self.cost_matrix = cost_matrix self.embed_dim = embed_dim self.missing_label = missing_label self.random_state = random_state self.mds_params = mds_params self.nn_params = nn_params
[docs] def query( self, X, y, 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`). 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), 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`). This is not supported by all query strategies. 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) 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`. - If `candidates` is of shape `(n_candidates, n_features)`, the indexing refers to the samples in `candidates`. """ # Check standard parameters. ( X, y, candidates, batch_size, return_utilities, ) = super()._validate_data( X=X, y=y, candidates=candidates, batch_size=batch_size, return_utilities=return_utilities, reset=True, ) # Obtain candidates plus mapping. X_cand, mapping = self._transform_candidates(candidates, X, y) util_cand = _alce( X_cand, X, y, self.base_regressor, self.cost_matrix, self.classes, self.embed_dim, sample_weight, self.missing_label, self.random_state_, self.mds_params, self.nn_params, ) if mapping is None: utilities = util_cand else: utilities = np.full(len(X), np.nan) utilities[mapping] = util_cand return simple_batch( utilities, self.random_state_, batch_size=batch_size, return_utilities=return_utilities, )
def _alce( X_cand, X, y, base_regressor, cost_matrix, classes, embed_dim, sample_weight, missing_label, random_state, mds_params, nn_params, ): """Compute the ALCE score for the candidate samples. Parameters ---------- X_cand : array-like of shape (n_candidates, n_features) Unlabeled candidate samples. X : array-like of shape (n_samples, n_features) Complete data set. y : array-like of shape (n_samples) Labels of the data set. base_regressor : RegressorMixin Regressor used for the embedding. cost_matrix : array-like of shape (n_classes, n_classes), default=None Cost matrix with `cost_matrix[i,j]` defining the cost of predicting class `j` for a sample with the actual class `i`. classes : array-like of shape (n_classes,) List of all classes that can occur. sample_weight : array-like, shape (n_samples) Weights for uncertain annotators. missing_label : scalar or string or np.nan or None, default=np.nan Value to represent a missing label. random_state : int or np.random.RandomState, default=None Random state for annotator selection. base_regressor : sklearn.base.RegressorMixin, default=None An sklearn regression model implementing the methods `fit` and `predict`. embed_dim : int, optional (default=None) If it is `None`, `embed_dim=n_classes` is used. mds_params : dict, default=None Parameters passed to `sklearn.manifold.MDS`. nn_params : dict, default=None Parameters passed to `sklearn.neighbors.NearestNeighbors`. Returns ------- utilities : np.ndarray of shape (n_candidates,) The utilities of all candidate samples. """ # Check base regressor if base_regressor is None: base_regressor = SVR() if not isinstance(base_regressor, RegressorMixin): raise TypeError("'base_regressor' must be an sklearn regressor") check_classifier_params(classes, missing_label, cost_matrix) if cost_matrix is None: cost_matrix = 1 - np.eye(len(classes)) if np.count_nonzero(cost_matrix) == 0: raise ValueError( "The cost matrix must contain at least one positive " "number." ) # Check the given data X, y, X_cand, sample_weight, sample_weight_cand = check_X_y( X, y, X_cand, sample_weight, ensure_all_finite=False, missing_label=missing_label, ) labeled = is_labeled(y, missing_label=missing_label) y = ExtLabelEncoder(classes, missing_label).fit_transform(y) X = X[labeled] y = y[labeled].astype(int) sample_weight = sample_weight[labeled] # If all samples are unlabeled, the strategy randomly selects a sample if len(X) == 0: warnings.warn( "There are no labeled samples. The strategy selects " "one random sample." ) return np.ones(len(X_cand)) # Check embedding dimension embed_dim = len(classes) if embed_dim is None else embed_dim check_scalar(embed_dim, "embed_dim", int, min_val=1) # Update mds parameters mds_params_default = { "metric": False, "n_components": embed_dim, "n_uq": len(classes), "max_iter": 300, "eps": 1e-6, "dissimilarity": "precomputed", "n_init": 8, "n_jobs": 1, "random_state": random_state, } if mds_params is not None: if type(mds_params) is not dict: raise TypeError("'mds_params' must be a dictionary or None") mds_params_default.update(mds_params) mds_params = mds_params_default # Update nearest neighbor parameters nn_params = {} if nn_params is None else nn_params if type(nn_params) is not dict: raise TypeError("'nn_params' must be a dictionary or None") regressors = [clone(base_regressor) for _ in range(embed_dim)] n_classes = len(classes) dissimilarities = np.zeros((2 * n_classes, 2 * n_classes)) dissimilarities[:n_classes, n_classes:] = cost_matrix dissimilarities[n_classes:, :n_classes] = cost_matrix.T W = np.zeros((2 * n_classes, 2 * n_classes)) W[:n_classes, n_classes:] = 1 W[n_classes:, :n_classes] = 1 mds = MDSP(**mds_params) embedding = mds.fit(dissimilarities).embedding_ class_embed = embedding[:n_classes, :] nn = NearestNeighbors(n_neighbors=1, **nn_params) nn.fit(embedding[n_classes:, :]) pred_embed = np.zeros((len(X_cand), embed_dim)) for i in range(embed_dim): regressors[i].fit(X, class_embed[y, i], sample_weight) pred_embed[:, i] = regressors[i].predict(X_cand) dist, _ = nn.kneighbors(pred_embed) utilities = dist[:, 0] return utilities """ Implementation of Multi-dimensional Scaling Partial (MDSP), which is a modification of: https://github.com/ntucllab/libact/blob/master/libact/query_strategies/multiclass/mdsp.py written by Kuan-Hao Huang (BSD-2-Clause license. Copyright (c) 2014, National Taiwan University All rights reserved.) and https://github.com/scikit-learn/scikit-learn/blob/14031f6/sklearn/manifold/mds.py. written by Nelle Varoquaux <nelle.varoquaux@gmail.com> (BSD-3-Clause license. Copyright (c) 2007–2016 The scikit-learn developers. All rights reserved.). The full license texts are distributed with scikit-activeml in `LICENSES/libact/LICENSE` and `LICENSES/scikit-learn/COPYING-14031f6`. """ def _smacof_single_p( similarities, n_uq, metric=True, n_components=2, init=None, max_iter=300, verbose=0, eps=1e-3, random_state=None, ): """Computes multidimensional scaling using the SMACOF algorithm. Parameters ---------- similarities : symmetric np.ndarray of shape (n * n,) Similarities between the points. n_uq : int Number of unique values. metric : boolean, default=True Compute metric or nonmetric SMACOF algorithm. n_components : int default=2 Number of dimension in which to immerse the similarities overwritten if initial array is provided. init : None or np.ndarray, default=None - If `init=None`, randomly chooses the initial configuration. - if `init` is np.ndarray, initialize SMACOF algorithm with this array. max_iter : int, default=300 Maximum number of iterations of the SMACOF algorithm for a single run. verbose : int, default=0 Level of verbosity. eps : float, default=1e-6 Relative tolerance w.r.t stress to declare converge. random_state : integer or np.RandomState, default=None The generator used to initialize the centers. If an integer is given, it fixes the seed. Defaults to the global numpy random number generator. Returns ------- X : np.ndarray of (n_samples, n_components) Coordinates of the n_samples points in a n_components-space. _stress : float The final value of the stress (sum of squared distance of the disparities and the distances for all constrained points). it : int Number of iterations run. """ similarities = check_symmetric(similarities, raise_exception=True) n_samples = similarities.shape[0] random_state = check_random_state(random_state) W = np.ones((n_samples, n_samples)) W[:n_uq, :n_uq] = 0.0 W[n_uq:, n_uq:] = 0.0 V = -W V[np.arange(len(V)), np.arange(len(V))] = W.sum(axis=1) e = np.ones((n_samples, 1)) Vp = ( np.linalg.inv(V + np.dot(e, e.T) / n_samples) - np.dot(e, e.T) / n_samples ) sim_flat = similarities.ravel() sim_flat_w = sim_flat[sim_flat != 0] if init is None: # Randomly choose initial configuration. X = random_state.rand(n_samples * n_components) X = X.reshape((n_samples, n_components)) else: # Overrides the parameter `p`. n_components = init.shape[1] if n_samples != init.shape[0]: raise ValueError( "init matrix should be of shape (%d, %d)" % (n_samples, n_components) ) X = init old_stress = None ir = IsotonicRegression() for it in range(max_iter): # Compute distance and monotonic regression. dis = euclidean_distances(X) if metric: disparities = similarities else: dis_flat = dis.ravel() # Similarities with 0 are considered as missing values. dis_flat_w = dis_flat[sim_flat != 0] # Compute the disparities using a monotonic regression. disparities_flat = ir.fit_transform(sim_flat_w, dis_flat_w) disparities = dis_flat.copy() disparities[sim_flat != 0] = disparities_flat disparities = disparities.reshape((n_samples, n_samples)) disparities *= np.sqrt( (n_samples * (n_samples - 1) / 2) / (disparities**2).sum() ) disparities[similarities == 0] = 0 # Compute stress. _stress = ( W.ravel() * ((dis.ravel() - disparities.ravel()) ** 2) ).sum() _stress /= 2 # Update X using the Guttman transform. dis[dis == 0] = 1e-5 ratio = disparities / dis _B = -W * ratio _B[np.arange(len(_B)), np.arange(len(_B))] += (W * ratio).sum(axis=1) X = np.dot(Vp, np.dot(_B, X)) dis = np.sqrt((X**2).sum(axis=1)).sum() if verbose >= 2: print("it: %d, stress %s" % (it, _stress)) if old_stress is not None: if (old_stress - _stress / dis) < eps: if verbose: print(f"breaking at iteration {it} with stress {_stress}") break old_stress = _stress / dis return X, _stress, it + 1 def smacof_p( similarities, n_uq, metric=True, n_components=2, init=None, n_init=8, n_jobs=1, max_iter=300, verbose=0, eps=1e-3, random_state=None, return_n_iter=False, ): """Computes multidimensional scaling using SMACOF (Scaling by Majorizing a Complicated Function) algorithm. The SMACOF algorithm is a multidimensional scaling algorithm: it minimizes a objective function, the *stress*, using a majorization technique. The Stress Majorization, also known as the Guttman Transform, guarantees a monotone convergence of Stress, and is more powerful than traditional techniques such as gradient descent. The SMACOF algorithm for metric MDS can summarized by the following steps: 1. Set an initial start configuration, randomly or not. 2. Compute the stress 3. Compute the Guttman Transform 4. Iterate 2 and 3 until convergence. The nonmetric algorithm adds a monotonic regression steps before computing the stress. Parameters ---------- similarities : symmetric np.ndarray of shape (n_samples, n_samples) Similarities between the points. n_uq : int Number of unique values. metric : boolean, default=True Compute metric or nonmetric SMACOF algorithm. n_components : int, default=2 Number of dimension in which to immerse the similarities overridden if initial array is provided. init : None or ndarray of shape (n_samples, n_components), default=None - If `init=None`, randomly chooses the initial configuration. - if `init` is np.ndarray, initialize SMACOF algorithm with this array. n_init : int, default=8 Number of times the smacof_p algorithm will be run with different initialisations. The final results will be the best output of the n_init consecutive runs in terms of stress. n_jobs : int, default=1 The number of jobs to use for the computation. This works by breaking down the pairwise matrix into n_jobs even slices and computing them in parallel. - If -1 all CPUs are used. - If 1 is given, no parallel computing code is used at all, which is useful for debugging. - For `n_jobs` below -1, `(n_cpus + 1 + n_jobs)` are used. Thus, for `n_jobs=-2, all CPUs but one are used. max_iter : int, default=300 Maximum number of iterations of the SMACOF algorithm for a single run. verbose : int, default=0 Level of verbosity. eps : float, default=1e-6 Relative tolerance w.r.t stress to declare converge. random_state : integer or numpy.RandomState, default=None The generator used to initialize the centers. If an integer is given, it fixes the seed. Defaults to the global numpy random number generator. return_n_iter : bool Whether or not to return the number of iterations. Returns ------- X : np.ndarray of shape (n_samples, n_components) Coordinates of the n_samples points in a n_components-space. stress : float The final value of the stress (sum of squared distance of the disparities and the distances for all constrained points). n_iter : int The number of iterations corresponding to the best stress. Returned only if `return_n_iter` is set to True. References ---------- .. [1] "Modern Multidimensional Scaling - Theory and Applications" Borg, I., Groenen P. Springer Series in Statistics (1997) .. [2] "Nonmetric multidimensional scaling: a numerical method" Kruskal, J. Psychometrika, 29 (1964) .. [3] "Multidimensional scaling by optimizing goodness of fit to a nonmetric hypothesis" Kruskal, J. Psychometrika, 29, (1964) """ similarities = check_array(similarities) random_state = check_random_state(random_state) if hasattr(init, "__array__"): init = np.asarray(init).copy() if not n_init == 1: warnings.warn( "Explicit initial positions passed: " "performing only one init of the MDS instead of %d" % n_init ) n_init = 1 best_pos, best_stress = None, None if n_jobs == 1: for it in range(n_init): pos, stress, n_iter_ = _smacof_single_p( similarities, n_uq, metric=metric, n_components=n_components, init=init, max_iter=max_iter, verbose=verbose, eps=eps, random_state=random_state, ) if best_stress is None or stress < best_stress: best_stress = stress best_pos = pos.copy() best_iter = n_iter_ else: seeds = random_state.randint(np.iinfo(np.int32).max, size=n_init) results = Parallel(n_jobs=n_jobs, verbose=max(verbose - 1, 0))( delayed(_smacof_single_p)( similarities, n_uq, metric=metric, n_components=n_components, init=init, max_iter=max_iter, verbose=verbose, eps=eps, random_state=seed, ) for seed in seeds ) positions, stress, n_iters = zip(*results) best = np.argmin(stress) best_stress = stress[best] best_pos = positions[best] best_iter = n_iters[best] if return_n_iter: return best_pos, best_stress, best_iter else: return best_pos, best_stress class MDSP(BaseEstimator): """Multidimensional Scaling Partial (MDSP) Parameters ---------- metric : boolean, default=True compute metric or nonmetric SMACOF (Scaling by Majorizing a Complicated Function) algorithm. n_uq : int, default=1 Number of unique values. n_components : int, default=2 Number of dimension in which to immerse the similarities overridden if initial array is provided. n_init : int, default=4 Number of time the smacof_p algorithm will be run with different initialisation. The final results will be the best output of the n_init consecutive runs in terms of stress. max_iter : int, default=300 Maximum number of iterations of the SMACOF algorithm for a single run. verbose : int, default=0 Level of verbosity. eps : float, default=1e-6 Relative tolerance w.r.t stress to declare converge. n_jobs : int, default=1 The number of jobs to use for the computation. This works by breaking down the pairwise matrix into n_jobs even slices and computing them in parallel. - If -1 all CPUs are used. - If 1 is given, no parallel computing code is used at all, which is useful for debugging. - For `n_jobs` below -1, `(n_cpus + 1 + n_jobs)` are used. Thus, for `n_jobs=-2, all CPUs but one are used. random_state : integer or numpy.RandomState, default=None The generator used to initialize the centers. If an integer is given, it fixes the seed. Defaults to the global numpy random number generator. dissimilarity : string Which dissimilarity measure to use. Supported are 'euclidean' and 'precomputed'. Attributes ---------- embedding_ : array-like of shape (n_components, n_samples) Stores the position of the dataset in the embedding space stress_ : float The final value of the stress (sum of squared distance of the disparities and the distances for all constrained points). References ---------- .. [1] "Modern Multidimensional Scaling - Theory and Applications" Borg, I., Groenen P. Springer Series in Statistics (1997) .. [2] "Nonmetric multidimensional scaling: a numerical method" Kruskal, J. Psychometrika, 29 (1964) .. [3] "Multidimensional scaling by optimizing goodness of fit to a nonmetric hypothesis" Kruskal, J. Psychometrika, 29, (1964) """ def __init__( self, n_components=2, n_uq=1, metric=True, n_init=4, max_iter=300, verbose=0, eps=1e-3, n_jobs=1, random_state=None, dissimilarity="euclidean", ): self.n_components = n_components self.n_uq = n_uq self.dissimilarity = dissimilarity self.metric = metric self.n_init = n_init self.max_iter = max_iter self.eps = eps self.verbose = verbose self.n_jobs = n_jobs self.random_state = random_state def fit(self, X, y=None, init=None): """Compute the position of the points in the embedding space. Parameters ---------- X : array-like of shape (n_samples, n_features), or \ (n_samples, n_samples) if dissimilarity='precomputed' Input data. init : None or ndarray of shape (n_samples,), default=None - If `None`, randomly chooses the initial configuration. - If `np.ndarray`, initialize the SMACOF algorithm with this array. """ self.fit_transform(X, init=init) return self def fit_transform(self, X, y=None, init=None): """Fit the data from X, and returns the embedded coordinates. Parameters ---------- X : array-like of shape (n_samples, n_features), or \ (n_samples, n_samples) if dissimilarity='precomputed' Input data. init : None or ndarray of shape (n_samples,), default=None - If `None`, randomly chooses the initial configuration. - If `np.ndarray`, initialize the SMACOF algorithm with this array. """ X = check_array(X) if X.shape[0] == X.shape[1] and self.dissimilarity != "precomputed": warnings.warn( "The MDS API has changed. `fit` now constructs an" " dissimilarity matrix from data. To use a custom " "dissimilarity matrix, set " "`dissimilarity=precomputed`." ) if self.dissimilarity == "precomputed": self.dissimilarity_matrix_ = X elif self.dissimilarity == "euclidean": self.dissimilarity_matrix_ = euclidean_distances(X) else: raise ValueError( "Proximity must be 'precomputed' or 'euclidean'." " Got %s instead" % str(self.dissimilarity) ) self.embedding_, self.stress_, self.n_iter_ = smacof_p( self.dissimilarity_matrix_, self.n_uq, metric=self.metric, n_components=self.n_components, init=init, n_init=self.n_init, n_jobs=self.n_jobs, max_iter=self.max_iter, verbose=self.verbose, eps=self.eps, random_state=self.random_state, return_n_iter=True, ) return self.embedding_