Source code for skactiveml.pool._uherding

"""
Module implementing `UHerding`, a deep active learning strategy combining
uncertainty and coverage.
"""

import numpy as np

from scipy.special import expit, softmax
from sklearn import clone
from sklearn.metrics import pairwise_distances, pairwise_kernels
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import normalize
from sklearn.utils.validation import column_or_1d

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


[docs] class UHerding(SingleAnnotatorPoolQueryStrategy): """Uncertainty Herding (UHerding) "Uncertainty Herding" (UHerding) is a query strategy [1]_ that greedily maximizes an uncertainty-weighted coverage objective in feature space. In addition to the greedy selection itself, the implementation follows the parameter adaptation scheme of the paper: - select a temperature based on calibration via train/validation splits of the currently labeled set, - adapt the Gaussian kernel radius to the current labeled feature space. UHerding 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 temperature-scaled probabilities are the per-output sigmoids `expit(logits / tau)` with the per-output temperature `tau` instead of a softmax, whenever logits are available, and the per-label uncertainty of the label output `j` is computed by `method` from its positive-class probability `p_j` alone (cf. `uncertainty_scores`). `multilabel_aggregation_fn` reduces these per-label scores along the label axis to the uncertainty weight of one sample, which then scales that sample's coverage gains. The coverage objective itself is unchanged, because it operates on the sample representations only. Correlations between label outputs therefore influence neither the uncertainty weight nor the coverage objective. Parameters ---------- method : 'least_confident' or 'margin_sampling' or 'entropy', \ default='margin_sampling' Uncertainty definition applied to temperature-scaled probabilities. predict_proba_dict : dict or None, default=None Optional keyword arguments forwarded to `clf.predict_proba` to request additional outputs such as logits and embeddings. If `predict_proba_parser is None`, optional outputs are interpreted by the default convention `(probas, logits, embeddings)`. Typical usage with `SkorchClassifier` is therefore:: predict_proba_dict={"extra_outputs": ["logits", "emb"]} If logits are not returned by `predict_proba`, `decision_function` is used as a fallback when available, e.g. for scikit-learn logistic regression models wrapped by `SklearnClassifier`. predict_proba_parser : callable or None, default=None Optional parser applied to the raw return value of `clf.predict_proba(X, **predict_proba_dict)`. The parser must return either `(probas, logits)` or `(probas, logits, embeddings)`. `probas` may be `None`, in which case they are computed from `logits` via softmax. `embeddings` may be `None`, in which case the original samples are used. If `None`, the default convention is used: - array output: treated as `probas`, - tuple output: treated as `(probas, logits, embeddings)`. temperatures : float or array-like of shape (n_temperatures,) or None, \ default=None Temperature specification for calibrating the uncertainty estimates. Two behaviors are distinguished: - A positive float or a length-one array-like is a fixed temperature shared by every output. It is used directly, i.e. no calibration train/validation split and no internal calibration refit take place. - An array-like with more than one entry is a shared candidate grid searched by expected calibration error on a validation split of the currently labeled set. For a single-output target, one temperature is selected. For a multi-label target, the grid is searched independently per label output, yielding one selected temperature per output. If `None`, the candidate grid `np.logspace(-1, 1, 49)` is used. validation_size : float or int, default=0.2 Validation size passed to the calibration train/validation split. n_ece_bins : int, default=15 Number of bins used for the expected calibration error. normalize_samples : bool, default=True Flag whether to normalize feature vectors to unit length before computing pairwise distances and kernels. metric : str or callable, default='rbf' Kernel used for the coverage objective. metric_dict : dict or None, default=None Optional keyword arguments passed to `pairwise_kernels`. adaptive_sigma : bool, default=True Flag whether to adapt the radius according to the minimum non-zero labeled pairwise distance. This option requires `metric='rbf'`. 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 weight. It is only used for resolved multi-label 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 and multi-label classification. A fitted classifier's target specification is authoritative when available. References ---------- .. [1] W. Bae, G. Oliveira, and D. J. Sutherland. "Uncertainty Herding: One Active Learning Method for All Label Budgets." In Int. Conf. Learn. Represent., 2025. """ def __init__( self, method="margin_sampling", predict_proba_dict=None, predict_proba_parser=None, temperatures=None, validation_size=0.2, n_ece_bins=15, normalize_samples=True, metric="rbf", metric_dict=None, adaptive_sigma=True, 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.method = method self.predict_proba_dict = predict_proba_dict self.predict_proba_parser = predict_proba_parser self.temperatures = temperatures self.validation_size = validation_size self.n_ece_bins = n_ece_bins self.normalize_samples = normalize_samples self.metric = metric self.metric_dict = metric_dict self.adaptive_sigma = adaptive_sigma 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, ): """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. clf : skactiveml.base.SkactivemlClassifier Classifier implementing `fit` and `predict_proba`. For temperature-scaled uncertainty estimation, the classifier should either provide logits via `predict_proba` extras or implement `decision_function`. Otherwise, the non-calibrated probabilities are used as fallback. For multilabel classification, `predict_proba` must return either shape `(n_samples, n_outputs)` or a list of binary probability matrices with shape `(n_samples, 2)` per output. fit_clf : bool, default=True Defines whether the classifier `clf` should be fitted on `X`, `y`, and `sample_weight` before evaluating the acquisition function. Independent of this flag, temporary cloned classifiers may still be fitted internally to select the temperature parameter. 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, ...), 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, ...)`, 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 or already selected candidates are 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_eval, 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,), ) is_multilabel = target_spec.target_type == "multi-label" # Determine candidate samples and validate 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, ) X_cand, mapping = self._transform_candidates( candidates, X, y, target_type=target_spec.target_type ) check_scalar(self.normalize_samples, "normalize_samples", bool) check_scalar(self.adaptive_sigma, "adaptive_sigma", bool) check_scalar(self.n_ece_bins, "n_ece_bins", int, min_val=1) if not callable(self.multilabel_aggregation_fn): raise TypeError("`multilabel_aggregation_fn` must be callable.") check_type( self.predict_proba_dict, "predict_proba_dict", (dict, type(None)) ) check_type( self.predict_proba_parser, "predict_proba_parser", type(None), indicator_funcs=[callable], ) check_type(self.metric_dict, "metric_dict", (dict, type(None))) metric_dict = ( {} if self.metric_dict is None else self.metric_dict.copy() ) if self.adaptive_sigma: if self.metric != "rbf": raise ValueError( "`adaptive_sigma=True` is only supported with " "`metric='rbf'`." ) elif "gamma" in metric_dict: raise ValueError( "`'gamma' cannot be part of the `metric_dict` " "with `adaptive_sigma=True`." ) if isinstance(self.validation_size, int): check_scalar( self.validation_size, "validation_size", int, min_val=1 ) else: check_scalar( self.validation_size, "validation_size", (float, np.floating), min_inclusive=False, max_inclusive=False, min_val=0.0, max_val=1.0, ) if self.temperatures is None: temperatures = np.logspace(-1, 1, 49) elif np.isscalar(self.temperatures): temperatures = float(self.temperatures) if temperatures <= 0 or np.isnan(temperatures): raise ValueError( "`temperatures` must contain only positive values." ) else: temperatures = column_or_1d(self.temperatures, dtype=float) if len(temperatures) == 0: raise ValueError( "`temperatures` must contain at least one entry." ) if np.any(temperatures <= 0) or np.isnan(temperatures).any(): raise ValueError( "`temperatures` must contain only positive values." ) # Calibrate classifier by selecting a corresponding temperature. tau = self._select_temperature( X=X, y=y, clf=clf, temperatures=temperatures, target_spec=target_spec, sample_weight=sample_weight, ) # Infer probabilities and if available logits as well as embeddings. n_outputs = len(target_spec.classes) if is_multilabel else None probas_cand, logits_cand, X_cand_repr = self._predict_with_extras( clf_eval, X_cand, is_multilabel=is_multilabel, n_outputs=n_outputs ) if X_cand_repr is None: X_cand_repr = X_cand if logits_cand is not None: if is_multilabel: probas_cand = expit(logits_cand / tau) else: probas_cand = softmax(logits_cand / tau, axis=1) # Compute uncertainty scores by either using the original probability # scores or the calibrated ones, if logits were available. unc_cand = uncertainty_scores( probas=probas_cand, method=self.method, is_multilabel=is_multilabel, multilabel_aggregation_fn=self.multilabel_aggregation_fn, ) if not np.all(np.isfinite(unc_cand)) or np.allclose(unc_cand, 0.0): # Fall back to pure coverage if the uncertainty model carries no # information, e.g. when only one class has been observed so far. unc_cand = np.ones_like(unc_cand) # Get embeddings for the labeled samples. labeled_idx = labeled_indices( y=y, missing_label=self.missing_label_, target_type=target_spec.target_type, ) X_labeled_repr = None if len(labeled_idx) > 0: _, _, X_labeled_repr = self._predict_with_extras( clf_eval, X[labeled_idx], is_multilabel=is_multilabel, n_outputs=n_outputs, ) if X_labeled_repr is None: X_labeled_repr = X[labeled_idx] # Normalize candidate and labeled samples to unit length. if self.normalize_samples: X_cand_repr = normalize(X_cand_repr, copy=True) if X_labeled_repr is not None: X_labeled_repr = normalize(X_labeled_repr, copy=True) # Compute kernel similarities, where the bandwidth is automatically # tuned if an RBF kernel is employed. metric_dict = self._resolve_metric_dict( X_cand_repr=X_cand_repr, X_labeled_repr=X_labeled_repr, metric_dict=metric_dict, ) K_cand = pairwise_kernels( X_cand_repr, metric=self.metric, **metric_dict ) if X_labeled_repr is not None and len(X_labeled_repr) > 0: K_cand_labeled = pairwise_kernels( X_cand_repr, X_labeled_repr, metric=self.metric, **metric_dict ) k_max = K_cand_labeled.max(axis=1) else: k_max = np.zeros(len(X_cand_repr), dtype=float) # Perform sequential batch selection. query_indices_cand = np.empty(batch_size, dtype=int) utilities_cand = np.empty((batch_size, len(X_cand_repr)), dtype=float) for b in range(batch_size): gains = np.maximum(K_cand - k_max[:, None], 0.0) utilities_cand[b] = np.mean(unc_cand[:, None] * gains, axis=0) utilities_cand[b][query_indices_cand[:b]] = np.nan query_indices_cand[b] = rand_argmax( utilities_cand[b], random_state=self.random_state_ )[0] k_max = np.maximum(k_max, K_cand[:, query_indices_cand[b]]) # Map queried indices and utilities back to the expected output. if mapping is None: query_indices = query_indices_cand utilities = utilities_cand else: query_indices = mapping[query_indices_cand] utilities = np.full((batch_size, len(X)), np.nan) utilities[:, mapping] = utilities_cand if return_utilities: return query_indices, utilities return query_indices
def _select_temperature( self, X, y, clf, temperatures, target_spec, sample_weight=None, ): is_multilabel = target_spec.target_type == "multi-label" n_outputs = len(target_spec.classes) if is_multilabel else None # A scalar or length-one `temperatures` is one fixed temperature shared # by every output, so no calibration split or refit is required. if np.isscalar(temperatures): return float(temperatures) if len(temperatures) == 1: return float(temperatures[0]) # Try to perform train-test split. If it not possilbe, return 1.0 as # temperature. labeled_idx = labeled_indices( y=y, missing_label=self.missing_label_, target_type=target_spec.target_type, ) if len(labeled_idx) < 2: return self._default_temperature(n_outputs) y_labeled = y[labeled_idx] split_kwargs = { "test_size": self.validation_size, "random_state": self.random_state_, "shuffle": True, } if not is_multilabel and len(np.unique(y_labeled)) > 1: split_kwargs["stratify"] = y_labeled try: train_idx, val_idx = train_test_split(labeled_idx, **split_kwargs) except ValueError: split_kwargs.pop("stratify", None) try: train_idx, val_idx = train_test_split( labeled_idx, **split_kwargs ) except ValueError: return self._default_temperature(n_outputs) if len(train_idx) == 0 or len(val_idx) == 0: return self._default_temperature(n_outputs) X_train = X[train_idx] y_train = y[train_idx] X_val = X[val_idx] y_val = y[val_idx] sw_train = ( None if sample_weight is None else np.asarray(sample_weight)[train_idx] ) try: if sw_train is None: clf_cal = clone(clf).fit(X_train, y_train) else: clf_cal = clone(clf).fit(X_train, y_train, sw_train) except Exception: return self._default_temperature(n_outputs) _, logits_val, _ = self._predict_with_extras( clf_cal, X_val, is_multilabel=is_multilabel, n_outputs=n_outputs, ) if logits_val is None: return self._default_temperature(n_outputs) # Select temperature by iterating over all candidates and selecting # the one with the lowest expected calibration error. For a multi-label # target, the shared candidate grid is searched independently per # output, yielding one selected temperature per output. if is_multilabel: y_val_encoded = self._encode_multilabel_targets( y_val, target_spec=target_spec ) best_tau = np.ones(n_outputs, dtype=float) for j in range(n_outputs): y_val_j = y_val_encoded[:, j] logits_val_j = np.asarray(logits_val[:, j], dtype=float) if ( not np.all(np.isfinite(logits_val_j)) or len(np.unique(y_val_j)) < 2 ): continue best_ece_j = np.inf for tau in temperatures: probas_j = expit(logits_val_j / tau) ece_j = self._binary_expected_calibration_error( probas=probas_j, y_true=y_val_j ) if ece_j < best_ece_j: best_tau[j] = float(tau) best_ece_j = ece_j return best_tau best_tau = float(temperatures[0]) best_ece = np.inf for tau in temperatures: probas = softmax(logits_val / tau, axis=1) ece = self._expected_calibration_error( probas=probas, y_true=y_val, classes=clf_cal.classes_ ) if ece < best_ece: best_tau = float(tau) best_ece = ece return best_tau def _encode_multilabel_targets(self, y_observed, target_spec): """Encode observed multi-label targets via the resolved vocabularies. Each label output may use its own binary class vocabulary, e.g. `[["no", "yes"], ["off", "on"]]`, so raw observations are not necessarily numeric. Calibration compares an observation to the positive-class probability of its output and therefore requires the encoded target, where `1` denotes the second class of the resolved, i.e. canonically ordered, vocabulary. That is exactly the class whose probability and logit column the acquisition logic consumes. Parameters ---------- y_observed : array-like of shape (n_observed_samples, n_outputs) Wholly observed multi-label rows, e.g. the calibration split. target_spec : skactiveml.utils.TargetSpec The resolved target specification providing one binary class vocabulary per label output. Returns ------- y_encoded : numpy.ndarray of shape (n_observed_samples, n_outputs) The encoded observations as floating point values. """ label_encoder = ExtLabelEncoder( classes=target_spec.classes, missing_label=self.missing_label_, target_type=target_spec.target_type, ) return label_encoder.fit_transform(y_observed).astype(float) def _resolve_metric_dict(self, X_cand_repr, X_labeled_repr, metric_dict): """ Computes adaptive sigma if required. """ # Keep the metric paramters unchanged if no adaptive sigma is requried. metric_dict = metric_dict.copy() if not self.adaptive_sigma: return metric_dict if X_labeled_repr is not None: # If there are labeled samples compute minimum distance as sigma. distances = self._nonzero_distances(X_labeled_repr) reduce_fn = np.min else: # If there are no labeled samples compute median distance between # candidate samples as sigma. distances = self._nonzero_distances(X_cand_repr) reduce_fn = np.median sigma = self._reduce_to_sigma(distances, reduce_fn) # Transform sigma to the gamma parameter expected by the RBF kernel # implementation in sklearn. metric_dict["gamma"] = 1.0 / (sigma**2) return metric_dict def _predict_with_extras( self, clf, X, is_multilabel=False, n_outputs=None ): """ Helper function to streamline required predictions. """ predict_proba_dict = ( {} if self.predict_proba_dict is None else self.predict_proba_dict.copy() ) out = clf.predict_proba(X, **predict_proba_dict) probas, logits, emb = self._parse_predict_output(out) if logits is None: logits = self._decision_function_logits( clf, X, is_multilabel=is_multilabel ) if is_multilabel: if ( logits is not None and n_outputs == 1 and np.asarray(logits).ndim == 1 ): logits = np.asarray(logits)[:, None] # Canonicalize both public multilabel probability formats before # the acquisition logic consumes them. probas = _canonicalize_multilabel_probas( probas, n_samples=len(X), n_outputs=n_outputs, allow_none=True, ) logits = _canonicalize_multilabel_probas( logits, n_samples=len(X), n_outputs=n_outputs, allow_none=True, validate_probabilities=False, ) if probas is None and logits is not None: probas = expit(logits) else: if logits is not None and np.asarray(logits).ndim == 1: logits = np.asarray(logits) logits = np.column_stack([np.zeros_like(logits), logits]) if probas is None and logits is not None: probas = softmax(logits, axis=1) return probas, logits, emb def _parse_predict_output(self, out): """ Helper function to streamline required predictions according to user information. """ if self.predict_proba_parser is not None: parsed = self.predict_proba_parser(out) if not isinstance(parsed, (tuple, list)): raise TypeError( "`predict_proba_parser` must return a tuple or list." ) if len(parsed) == 2: probas, logits = parsed emb = None elif len(parsed) == 3: probas, logits, emb = parsed else: raise ValueError( "`predict_proba_parser` must return " "`(probas, logits)` or `(probas, logits, embeddings)`." ) return probas, logits, emb if not isinstance(out, tuple): return out, None, None if len(out) == 0: raise ValueError("`predict_proba` returned an empty tuple.") if len(out) > 3: raise ValueError( "`predict_proba` returned more than three outputs. Pass " "`predict_proba_parser` to disambiguate them." ) probas = out[0] logits = out[1] if len(out) >= 2 else None emb = out[2] if len(out) >= 3 else None return probas, logits, emb def _expected_calibration_error(self, probas, y_true, classes): """ Computes expected calibration error for determining the temperature. """ confidences = np.max(probas, axis=1) pred_labels = classes[np.argmax(probas, axis=1)] accuracies = pred_labels == y_true bins = np.linspace(0.0, 1.0, self.n_ece_bins + 1) ece = 0.0 for left, right in zip(bins[:-1], bins[1:]): if right == 1.0: mask = (confidences >= left) & (confidences <= right) else: mask = (confidences >= left) & (confidences < right) if not np.any(mask): continue bin_weight = np.mean(mask) bin_acc = np.mean(accuracies[mask]) bin_conf = np.mean(confidences[mask]) ece += bin_weight * np.abs(bin_acc - bin_conf) return ece def _binary_expected_calibration_error(self, probas, y_true): """ Computes binary expected calibration error for multilabel targets. """ probas = np.asarray(probas, dtype=float) y_true = np.asarray(y_true, dtype=float) bins = np.linspace(0.0, 1.0, self.n_ece_bins + 1) ece = 0.0 for left, right in zip(bins[:-1], bins[1:]): if right == 1.0: mask = (probas >= left) & (probas <= right) else: mask = (probas >= left) & (probas < right) if not np.any(mask): continue bin_weight = np.mean(mask) bin_acc = np.mean(y_true[mask]) bin_conf = np.mean(probas[mask]) ece += bin_weight * np.abs(bin_acc - bin_conf) return ece @staticmethod def _decision_function_logits(clf, X, is_multilabel=False): """ Helper function to compute logits from the decision function as a common method in sklearn. """ if not hasattr(clf, "decision_function"): return None try: logits = clf.decision_function(X) except Exception: return None logits = np.asarray(logits) if logits.ndim == 1: if is_multilabel: logits = logits[:, None] else: logits = np.column_stack([np.zeros_like(logits), logits]) return logits @staticmethod def _default_temperature(n_outputs): """Return the uncalibrated temperature, i.e. one value per output.""" if n_outputs is None: return 1.0 return np.ones(n_outputs, dtype=float) @staticmethod def _reduce_to_sigma(distances, reduce_fn): """ Helper function reducing a collection of pairwise distances to a positive RBF scale `sigma`. Degenerate collections, i.e., `None`, empty ones, and ones without any finite positive entry, admit no estimable scale. Such pools, e.g., a cold start with a single candidate or identical representations, are a valid acquisition state and fall back to `sigma=1.0`. """ fallback = 1.0 if distances is None: return fallback distances = np.asarray(distances, dtype=float).ravel() # Also drops `nan` entries, since any comparison to `nan` is `False`. distances = distances[distances > 0] if len(distances) == 0: return fallback sigma = float(reduce_fn(distances)) return sigma if np.isfinite(sigma) else fallback @staticmethod def _nonzero_distances(X): """ Helper function for computing non-zero distances. """ if X is None or len(X) < 2: return None distances = pairwise_distances(X) distances = distances[np.triu_indices_from(distances, k=1)] distances = distances[distances > 0] return distances