From 7d188178fd97f316e9b4fe67c897519fdf2eac7a Mon Sep 17 00:00:00 2001 From: JulienAu <16043912+JulienAu@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:14:37 +0200 Subject: [PATCH] Add LocalOutlierFactor to cuml.neighbors LOF is computed as pure Python post-processing of the GPU neighbor search from cuml.neighbors.NearestNeighbors: k-distance, reachability, local reachability density, then the density ratio. Both sklearn modes are supported, outlier detection and novelty detection, with the same mode guards. Self-neighbor removal handles exact duplicates, where the sample is not guaranteed to come back first in the tied block. Signed-off-by: JulienAu <16043912+JulienAu@users.noreply.github.com> --- python/cuml/cuml/neighbors/__init__.py | 3 +- .../cuml/neighbors/local_outlier_factor.py | 267 ++++++++++++++++++ .../cuml/tests/test_local_outlier_factor.py | 151 ++++++++++ 3 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 python/cuml/cuml/neighbors/local_outlier_factor.py create mode 100644 python/cuml/tests/test_local_outlier_factor.py diff --git a/python/cuml/cuml/neighbors/__init__.py b/python/cuml/cuml/neighbors/__init__.py index 8a896f831b..354f2fb0cb 100644 --- a/python/cuml/cuml/neighbors/__init__.py +++ b/python/cuml/cuml/neighbors/__init__.py @@ -1,11 +1,12 @@ # -# SPDX-FileCopyrightText: Copyright (c) 2019-2025, NVIDIA CORPORATION. +# SPDX-FileCopyrightText: Copyright (c) 2019-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # from cuml.neighbors.kernel_density import VALID_KERNELS, KernelDensity from cuml.neighbors.kneighbors_classifier import KNeighborsClassifier from cuml.neighbors.kneighbors_regressor import KNeighborsRegressor +from cuml.neighbors.local_outlier_factor import LocalOutlierFactor from cuml.neighbors.nearest_neighbors import NearestNeighbors, kneighbors_graph VALID_METRICS = { diff --git a/python/cuml/cuml/neighbors/local_outlier_factor.py b/python/cuml/cuml/neighbors/local_outlier_factor.py new file mode 100644 index 0000000000..4781158f68 --- /dev/null +++ b/python/cuml/cuml/neighbors/local_outlier_factor.py @@ -0,0 +1,267 @@ +# +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# + +"""Local Outlier Factor for GPU-accelerated anomaly detection.""" + +import cupy as cp + +from cuml.internals.base import Base +from cuml.internals.outputs import ReflectedAttr, mlfunc +from cuml.internals.validation import check_inputs, check_is_fitted +from cuml.neighbors.nearest_neighbors import NearestNeighbors + +# Matches the constant scikit-learn adds to local reachability densities to +# avoid dividing by zero on duplicated training points. +_LRD_EPS = 1e-10 + + +class LocalOutlierFactor(Base): + """Unsupervised outlier detection using the Local Outlier Factor. + + The anomaly score of each sample measures how isolated it is from its + neighborhood: the local reachability density of a sample is compared to + the densities of its ``n_neighbors`` nearest neighbors, and samples with + a substantially lower density are considered outliers. + + The nearest-neighbor search runs on GPU through + :class:`cuml.neighbors.NearestNeighbors`; the factor computation is + vectorized post-processing of the returned distances and indices. + + Parameters + ---------- + n_neighbors : int, default=20 + Number of neighbors to use for the density estimate. Clamped to + ``n_samples - 1`` when the training set is smaller. + metric : str, default="euclidean" + Distance metric, forwarded to + :class:`cuml.neighbors.NearestNeighbors`. Only metrics supported by + the underlying nearest-neighbor primitive are available. + p : int, default=2 + Parameter of the Minkowski metric when ``metric="minkowski"``. + contamination : "auto" or float, default="auto" + The expected proportion of outliers, used to set ``offset_``. + ``"auto"`` uses the original paper's threshold of -1.5; a float in + (0, 0.5] sets the threshold at the matching quantile of the training + scores. + novelty : bool, default=False + When False (outlier detection), only ``fit_predict`` and the fitted + attributes are available. When True (novelty detection), + ``predict``, ``decision_function`` and ``score_samples`` operate on + new data and ``fit_predict`` is unavailable, matching scikit-learn. + verbose : int or boolean, default=False + Sets logging level. + output_type : {'input', 'array', 'dataframe', 'series', 'df_obj', \ + 'numba', 'cupy', 'numpy', 'cudf', 'pandas'}, default=None + Return type of array outputs. + + Attributes + ---------- + negative_outlier_factor_ : array of shape (n_samples,) + The opposite of the local outlier factor of the training samples. + The lower, the more abnormal. + n_neighbors_ : int + The effective number of neighbors used. + offset_ : float + Threshold on ``negative_outlier_factor_`` separating inliers from + outliers. + n_samples_fit_ : int + Number of samples in the fitted data. + effective_metric_ : str + The metric used for the neighbor search. + + Notes + ----- + When several training points are equidistant from a query, the selected + neighbor set can differ from scikit-learn's, which may shift the factor + of the affected points. This is inherent to nearest-neighbor ties and + is bounded by the distance ties themselves. + """ + + negative_outlier_factor_ = ReflectedAttr() + + _cpu_estimator_import_path = "sklearn.neighbors.LocalOutlierFactor" + + @classmethod + def _get_param_names(cls): + return super()._get_param_names() + [ + "n_neighbors", + "metric", + "p", + "contamination", + "novelty", + ] + + def __init__( + self, + *, + n_neighbors=20, + metric="euclidean", + p=2, + contamination="auto", + novelty=False, + verbose=False, + output_type=None, + ): + super().__init__(verbose=verbose, output_type=output_type) + self.n_neighbors = n_neighbors + self.metric = metric + self.p = p + self.contamination = contamination + self.novelty = novelty + + def _check_novelty(self, method, expected): + if bool(self.novelty) != expected: + state = "novelty=True" if expected else "novelty=False" + raise AttributeError( + f"{method} is only available when {state}. Set the novelty " + "parameter accordingly before calling fit." + ) + + @mlfunc(set_input_type=True) + def fit(self, X, y=None) -> "LocalOutlierFactor": + """Fit the local outlier factor detector from the training data. + + Parameters + ---------- + X : array-like of shape (n_samples, n_features) + Training data. + y : Ignored + Not used, present for API consistency. + + Returns + ------- + self : LocalOutlierFactor + The fitted estimator. + """ + if isinstance(self.contamination, str): + if self.contamination != "auto": + raise ValueError( + "contamination must be 'auto' or a float in (0, 0.5]." + ) + elif not 0.0 < float(self.contamination) <= 0.5: + raise ValueError( + "contamination must be 'auto' or a float in (0, 0.5]." + ) + if int(self.n_neighbors) < 1: + raise ValueError("n_neighbors must be a positive integer.") + + X_m = check_inputs( + self, + X, + dtype=("float32", "float64"), + reset=True, + ) + n_samples = X_m.shape[0] + if n_samples < 2: + raise ValueError( + "LocalOutlierFactor requires at least 2 training samples." + ) + self.n_samples_fit_ = n_samples + self.n_neighbors_ = min(int(self.n_neighbors), n_samples - 1) + self.effective_metric_ = self.metric + + nn = NearestNeighbors( + n_neighbors=self.n_neighbors_ + 1, + metric=self.metric, + p=self.p, + output_type="cupy", + ).fit(X_m) + dist, idx = nn.kneighbors( + X_m, self.n_neighbors_ + 1, return_distance=True + ) + + # Drop each sample from its own neighborhood. The sample usually + # comes back first at distance zero, but under exact duplicates it + # can sit anywhere in the tied block, or be pushed out entirely. + k = self.n_neighbors_ + rows = cp.arange(n_samples) + self_pos = idx == rows[:, None] + has_self = self_pos.any(axis=1) + drop = cp.where(has_self, self_pos.argmax(axis=1), k) + keep = cp.ones_like(self_pos, dtype=cp.bool_) + keep[rows, drop] = False + dist = dist[keep].reshape(n_samples, k) + idx = idx[keep].reshape(n_samples, k) + + k_dist = dist[:, -1] + reach = cp.maximum(k_dist[idx], dist) + lrd = 1.0 / (reach.mean(axis=1) + _LRD_EPS) + nof = -(lrd[idx].mean(axis=1) / lrd) + + self._nn = nn + self._distances_fit_X_ = dist + self._k_dist_fit_ = k_dist + self._lrd = lrd + self.negative_outlier_factor_ = nof + + if isinstance(self.contamination, str): + self.offset_ = -1.5 + else: + self.offset_ = float( + cp.percentile(nof, 100.0 * float(self.contamination)) + ) + return self + + @mlfunc(set_input_type=True) + def fit_predict(self, X, y=None): + """Fit the detector and return training-sample labels. + + Only available when ``novelty=False``. + + Returns + ------- + labels : array of shape (n_samples,) + 1 for inliers, -1 for outliers. + """ + self._check_novelty("fit_predict", expected=False) + self.fit(X) + labels = cp.where( + self.negative_outlier_factor_ < self.offset_, -1, 1 + ).astype(cp.int64) + return labels + + def _score_samples(self, X): + check_is_fitted(self) + X_m = check_inputs( + self, + X, + dtype=("float32", "float64"), + reset=False, + ) + dist, idx = self._nn.kneighbors( + X_m, self.n_neighbors_, return_distance=True + ) + reach = cp.maximum(self._k_dist_fit_[idx], dist) + lrd_x = 1.0 / (reach.mean(axis=1) + _LRD_EPS) + return -(self._lrd[idx].mean(axis=1) / lrd_x) + + @mlfunc + def score_samples(self, X): + """Opposite of the local outlier factor of X (novelty mode only). + + The lower, the more abnormal. + """ + self._check_novelty("score_samples", expected=True) + return self._score_samples(X) + + @mlfunc + def decision_function(self, X): + """Shifted opposite of the local outlier factor of X (novelty mode + only). Negative values are outliers.""" + self._check_novelty("decision_function", expected=True) + return self._score_samples(X) - self.offset_ + + @mlfunc + def predict(self, X): + """Predict labels of X (novelty mode only). + + Returns + ------- + labels : array of shape (n_samples,) + 1 for inliers, -1 for outliers. + """ + self._check_novelty("predict", expected=True) + scores = self._score_samples(X) - self.offset_ + return cp.where(scores < 0, -1, 1).astype(cp.int64) diff --git a/python/cuml/tests/test_local_outlier_factor.py b/python/cuml/tests/test_local_outlier_factor.py new file mode 100644 index 0000000000..7be4f6565f --- /dev/null +++ b/python/cuml/tests/test_local_outlier_factor.py @@ -0,0 +1,151 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# + +"""Tests for cuML's LocalOutlierFactor implementation.""" + +import pickle + +import numpy as np +import pytest +from sklearn.neighbors import LocalOutlierFactor as skLocalOutlierFactor + +from cuml.neighbors import LocalOutlierFactor as cuLocalOutlierFactor + + +@pytest.fixture(scope="module") +def outlier_data(): + """Gaussian bulk with a shifted cluster of outliers.""" + rng = np.random.RandomState(7) + X = rng.randn(4000, 8).astype(np.float32) + X[:40] += 5.0 + return X + + +@pytest.fixture(scope="module") +def query_data(): + rng = np.random.RandomState(11) + X = rng.randn(500, 8).astype(np.float32) + X[:5] += 5.0 + return X + + +@pytest.mark.parametrize("dtype", [np.float32, np.float64]) +@pytest.mark.parametrize("n_neighbors", [5, 20, 50]) +def test_negative_outlier_factor_matches_sklearn( + outlier_data, dtype, n_neighbors +): + X = outlier_data.astype(dtype) + sk_model = skLocalOutlierFactor(n_neighbors=n_neighbors).fit(X) + cu_model = cuLocalOutlierFactor( + n_neighbors=n_neighbors, output_type="numpy" + ).fit(X) + + np.testing.assert_allclose( + cu_model.negative_outlier_factor_, + sk_model.negative_outlier_factor_, + atol=1e-4, + ) + assert cu_model.n_neighbors_ == sk_model.n_neighbors_ + assert cu_model.n_samples_fit_ == sk_model.n_samples_fit_ + assert cu_model.offset_ == sk_model.offset_ == -1.5 + + +def test_fit_predict_matches_sklearn(outlier_data): + sk_labels = skLocalOutlierFactor(n_neighbors=15).fit_predict(outlier_data) + cu_labels = cuLocalOutlierFactor( + n_neighbors=15, output_type="numpy" + ).fit_predict(outlier_data) + np.testing.assert_array_equal(cu_labels, sk_labels) + + +def test_contamination_offset_matches_sklearn(outlier_data): + sk_model = skLocalOutlierFactor(n_neighbors=15, contamination=0.02).fit( + outlier_data + ) + cu_model = cuLocalOutlierFactor( + n_neighbors=15, contamination=0.02, output_type="numpy" + ).fit(outlier_data) + np.testing.assert_allclose(cu_model.offset_, sk_model.offset_, atol=1e-4) + + +@pytest.mark.parametrize("contamination", [0.0, -0.1, 0.6, "invalid"]) +def test_invalid_contamination_raises(outlier_data, contamination): + model = cuLocalOutlierFactor(contamination=contamination) + with pytest.raises(ValueError, match="contamination"): + model.fit(outlier_data) + + +def test_novelty_scoring_matches_sklearn(outlier_data, query_data): + sk_model = skLocalOutlierFactor(n_neighbors=15, novelty=True).fit( + outlier_data + ) + cu_model = cuLocalOutlierFactor( + n_neighbors=15, novelty=True, output_type="numpy" + ).fit(outlier_data) + + np.testing.assert_allclose( + cu_model.score_samples(query_data), + sk_model.score_samples(query_data), + atol=1e-4, + ) + np.testing.assert_allclose( + cu_model.decision_function(query_data), + sk_model.decision_function(query_data), + atol=1e-4, + ) + np.testing.assert_array_equal( + cu_model.predict(query_data), sk_model.predict(query_data) + ) + + +def test_mode_guards_match_sklearn_semantics(outlier_data): + outlier_model = cuLocalOutlierFactor(n_neighbors=10).fit(outlier_data) + with pytest.raises(AttributeError, match="novelty=True"): + outlier_model.predict(outlier_data) + with pytest.raises(AttributeError, match="novelty=True"): + outlier_model.score_samples(outlier_data) + + novelty_model = cuLocalOutlierFactor(n_neighbors=10, novelty=True) + with pytest.raises(AttributeError, match="novelty=False"): + novelty_model.fit_predict(outlier_data) + + +def test_duplicated_points(outlier_data): + """Exact duplicates exercise the self-removal path and the lrd epsilon.""" + X = np.vstack([outlier_data[:200]] * 3).astype(np.float64) + sk_model = skLocalOutlierFactor(n_neighbors=10).fit(X) + cu_model = cuLocalOutlierFactor(n_neighbors=10, output_type="numpy").fit(X) + np.testing.assert_allclose( + cu_model.negative_outlier_factor_, + sk_model.negative_outlier_factor_, + atol=1e-3, + ) + + +def test_n_neighbors_clamped_to_n_samples(outlier_data): + X = outlier_data[:10] + cu_model = cuLocalOutlierFactor(n_neighbors=50, output_type="numpy").fit(X) + sk_model = skLocalOutlierFactor(n_neighbors=50).fit(X) + assert cu_model.n_neighbors_ == sk_model.n_neighbors_ == 9 + + +def test_pickle_roundtrip(outlier_data, query_data): + cu_model = cuLocalOutlierFactor( + n_neighbors=15, novelty=True, output_type="numpy" + ).fit(outlier_data) + loaded = pickle.loads(pickle.dumps(cu_model)) + np.testing.assert_allclose( + loaded.score_samples(query_data), + cu_model.score_samples(query_data), + ) + assert loaded.get_params() == cu_model.get_params() + + +def test_get_set_params_roundtrip(): + model = cuLocalOutlierFactor(n_neighbors=7, contamination=0.1) + params = model.get_params() + assert params["n_neighbors"] == 7 + assert params["contamination"] == 0.1 + clone = cuLocalOutlierFactor(**params) + assert clone.get_params() == params