From 1c691cf5980ee2d8f00e71097076f3058c921345 Mon Sep 17 00:00:00 2001 From: busysofa Date: Thu, 6 Aug 2026 19:53:49 +0530 Subject: [PATCH 01/15] FIX: Prevent false-positive Claude Code detection in pyod info (#715) --- pyod/cli.py | 3 ++- pyod/test/test_cli.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pyod/cli.py b/pyod/cli.py index d1441eaa..3330611a 100644 --- a/pyod/cli.py +++ b/pyod/cli.py @@ -18,6 +18,7 @@ from __future__ import annotations import argparse +import shutil import importlib.util import sys from collections import Counter @@ -101,7 +102,7 @@ def _cmd_info(args: argparse.Namespace) -> int: user_installed = user_skill_path.is_file() project_installed = project_skill_path.is_file() agents_detected: list[str] = [] - if claude_dir.is_dir(): + if shutil.which("claude") is not None or (Path.home() / ".claude.json").exists(): agents_detected.append("Claude Code") if codex_dir.is_dir(): agents_detected.append("Codex") diff --git a/pyod/test/test_cli.py b/pyod/test/test_cli.py index 47b90462..0e7907b3 100644 --- a/pyod/test/test_cli.py +++ b/pyod/test/test_cli.py @@ -175,7 +175,8 @@ def test_pyod_info_codex_and_claude_both_detected(tmp_path): user knows which option fits their workflow. """ fake_home = tmp_path / "fake_home" - (fake_home / ".claude").mkdir(parents=True) + fake_home.mkdir(parents=True, exist_ok=True) + (fake_home / ".claude.json").touch() (fake_home / ".codex").mkdir(parents=True) work = tmp_path / "work" From 6635e76a49ca2bf5614e52ca77343eff303737c3 Mon Sep 17 00:00:00 2001 From: busysofa Date: Thu, 6 Aug 2026 20:17:41 +0530 Subject: [PATCH 02/15] test: mock shutil.which in tests for hermetic execution --- pyod/test/test_cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyod/test/test_cli.py b/pyod/test/test_cli.py index 0e7907b3..a8b8ebaa 100644 --- a/pyod/test/test_cli.py +++ b/pyod/test/test_cli.py @@ -168,12 +168,13 @@ def test_pyod_info_codex_detected_no_skill(tmp_path): assert "no agent stacks detected" not in result.stdout -def test_pyod_info_codex_and_claude_both_detected(tmp_path): +def test_pyod_info_codex_and_claude_both_detected(tmp_path,monkeypatch): """Both ~/.claude/ and ~/.codex/ present, neither skill installed. Output must list both agents and show both install commands so the user knows which option fits their workflow. """ + monkeypatch.setattr("shutil.which", lambda cmd: None) fake_home = tmp_path / "fake_home" fake_home.mkdir(parents=True, exist_ok=True) (fake_home / ".claude.json").touch() From 83130096748daa075a009849aaf568a9f148f19d Mon Sep 17 00:00:00 2001 From: Venish Paneliya <141703684+VenishPaneliya@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:31:39 +0530 Subject: [PATCH 03/15] Respect verbose in DeepSVDD training output (#735) * Respect verbose in DeepSVDD training output DeepSVDD documents `verbose` ("Verbosity mode", default 1) and stores it on the estimator, but the training loop prints the per-epoch loss unconditionally. `self.verbose` is never read, so `DeepSVDD(verbose=0)` still writes one line per epoch - 100 lines at the default `epochs`, for every fit in a benchmark loop. With epochs=3: verbose=0 -> 3 epoch lines printed verbose=1 -> 3 epoch lines printed The print is now gated on `self.verbose == 1`, which is how AnoGAN and LUNAR already gate their per-epoch output. The default is 1, so nothing changes unless the caller asked for silence. * Print epoch logs for any non-zero verbose Gating on `verbose == 1` made `DeepSVDD(verbose=2)` quieter than `verbose=1`, which inverts the usual meaning of the setting. Suppress the per-epoch line only when verbosity is 0, and cover mode 2 in the test. --- pyod/models/deep_svdd.py | 3 ++- pyod/test/test_deepsvdd.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/pyod/models/deep_svdd.py b/pyod/models/deep_svdd.py index c4090a6f..5fc1ee4b 100644 --- a/pyod/models/deep_svdd.py +++ b/pyod/models/deep_svdd.py @@ -426,7 +426,8 @@ def fit(self, X, y=None): # place and leave the final weights here instead of the best # ones. Copy it. best_model_dict = copy.deepcopy(self.model_.state_dict()) - print(f"Epoch {epoch + 1}/{self.epochs}, Loss: {epoch_loss}") + if self.verbose >= 1: + print(f"Epoch {epoch + 1}/{self.epochs}, Loss: {epoch_loss}") self.best_model_dict = best_model_dict if self.best_model_dict is not None: self.model_.load_state_dict(self.best_model_dict) diff --git a/pyod/test/test_deepsvdd.py b/pyod/test/test_deepsvdd.py index 74e083d8..dafdc60d 100644 --- a/pyod/test/test_deepsvdd.py +++ b/pyod/test/test_deepsvdd.py @@ -4,6 +4,8 @@ import os import sys import unittest +from contextlib import redirect_stdout +from io import StringIO import numpy as np import torch @@ -279,6 +281,24 @@ def test_model_clone(self): clone_clf = clone(self.clf) clone_clf = clone(self.clf_ae) + def test_verbose_controls_epoch_logging(self): + # verbose was documented and stored but never consulted, so the + # per-epoch loss was printed even with verbose=0. + X = np.random.RandomState(42).randn(50, 10) + + def epoch_lines(verbose): + buffer = StringIO() + with redirect_stdout(buffer): + DeepSVDD(n_features=10, epochs=3, hidden_neurons=[8, 4], + verbose=verbose, random_state=2021).fit(X) + return [line for line in buffer.getvalue().splitlines() + if line.startswith('Epoch')] + + assert_equal(len(epoch_lines(0)), 0) + assert_equal(len(epoch_lines(1)), 3) + # a higher verbosity must not be quieter than a lower one + assert_equal(len(epoch_lines(2)), 3) + def tearDown(self): pass From c8af9927961e300bb4165c4199ede073f428cdce Mon Sep 17 00:00:00 2001 From: Venish Paneliya <141703684+VenishPaneliya@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:31:46 +0530 Subject: [PATCH 04/15] Drop the sample_weight entry from GMM.fit's docstring (#741) `GMM.fit` is `fit(self, X, y=None)`, but its docstring documents a third argument: sample_weight : array-like, shape (n_samples,) Per-sample weights. Rescale C per sample. Higher weights force the classifier to put more emphasis on these points. Passing it raises `TypeError: GMM.fit() got an unexpected keyword argument 'sample_weight'`. The text is copied from `OCSVM.fit`, which does take the argument - "C" is the SVM penalty parameter and has no counterpart in a Gaussian mixture. It also cannot simply be forwarded: `sklearn.mixture .GaussianMixture.fit` is `fit(self, X, y)` and has no `sample_weight`. Only the stale documentation is removed; OCSVM is untouched. --- pyod/models/gmm.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pyod/models/gmm.py b/pyod/models/gmm.py index cdd977c5..0ba1b308 100644 --- a/pyod/models/gmm.py +++ b/pyod/models/gmm.py @@ -158,10 +158,6 @@ def fit(self, X, y=None): y : Ignored Not used, present for API consistency by convention. - sample_weight : array-like, shape (n_samples,) - Per-sample weights. Rescale C per sample. Higher weights - force the classifier to put more emphasis on these points. - Returns ------- self : object From 6eef29a7394da066ed149282dd6327ceac3f5518 Mon Sep 17 00:00:00 2001 From: Zhu yizhang <95731595+godarrenw@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:01:53 +0800 Subject: [PATCH 05/15] docs(utility): correct score_to_label return description (#733) Signed-off-by: Zhu yizhang <95731595+godarrenw@users.noreply.github.com> --- pyod/utils/utility.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyod/utils/utility.py b/pyod/utils/utility.py index 645374de..a89a920b 100644 --- a/pyod/utils/utility.py +++ b/pyod/utils/utility.py @@ -171,7 +171,7 @@ def standardizer(X, X_t=None, keep_scalar=False): def score_to_label(pred_scores, outliers_fraction=0.1): - """Turn raw outlier outlier scores to binary labels (0 or 1). + """Turn raw outlier scores to binary labels (0 or 1). Parameters ---------- @@ -186,8 +186,7 @@ def score_to_label(pred_scores, outliers_fraction=0.1): outlier_labels : numpy array of shape (n_samples,) For each observation, tells whether or not it should be considered as an outlier according to the - fitted model. Return the outlier probability, ranging - in [0,1]. + fitted model. Returns 0 for inliers and 1 for outliers. """ # check input values pred_scores = column_or_1d(pred_scores) From 4958473403d31f14dd898b759ac61872a73392be Mon Sep 17 00:00:00 2001 From: Zhu yizhang <95731595+godarrenw@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:02:00 +0800 Subject: [PATCH 06/15] docs: fix dead reference links in KNN/LOF/LUNAR docstrings (#732) Signed-off-by: Zhu yizhang <95731595+godarrenw@users.noreply.github.com> --- pyod/models/knn.py | 2 +- pyod/models/lof.py | 2 +- pyod/models/lunar.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyod/models/knn.py b/pyod/models/knn.py index 17fd4176..26ca4438 100644 --- a/pyod/models/knn.py +++ b/pyod/models/knn.py @@ -108,7 +108,7 @@ class KNN(BaseDetector): sklearn.metrics.pairwise.pairwise_distances. When p = 1, this is equivalent to using manhattan_distance (l1), and euclidean_distance (l2) for p = 2. For arbitrary p, minkowski_distance (l_p) is used. - See http://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise.pairwise_distances + See https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise_distances.html metric_params : dict, optional (default = None) Additional keyword arguments for the metric function. diff --git a/pyod/models/lof.py b/pyod/models/lof.py index 1a6538f1..d20b6d25 100644 --- a/pyod/models/lof.py +++ b/pyod/models/lof.py @@ -90,7 +90,7 @@ class LOF(BaseDetector): sklearn.metrics.pairwise.pairwise_distances. When p = 1, this is equivalent to using manhattan_distance (l1), and euclidean_distance (l2) for p = 2. For arbitrary p, minkowski_distance (l_p) is used. - See http://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise.pairwise_distances + See https://scikit-learn.org/stable/modules/generated/sklearn.metrics.pairwise_distances.html metric_params : dict, optional (default = None) Additional keyword arguments for the metric function. diff --git a/pyod/models/lunar.py b/pyod/models/lunar.py index 69a0498c..0292656b 100644 --- a/pyod/models/lunar.py +++ b/pyod/models/lunar.py @@ -110,7 +110,7 @@ def forward(self, x): class LUNAR(BaseDetector): """ - LUNAR class for outlier detection. See https://www.aaai.org/AAAI22Papers/AAAI-51.GoodgeA.pdf for details. + LUNAR class for outlier detection. See https://ojs.aaai.org/index.php/AAAI/article/view/20629 for details. For an observation, its ordered list of distances to its k nearest neighbours is input to a neural network, with one of the following outputs: From 109bdfd14d337875c707ce33c50d168dc84d756c Mon Sep 17 00:00:00 2001 From: dishasharma23-prog Date: Wed, 16 Sep 2026 10:32:08 +0530 Subject: [PATCH 07/15] docs: clarify ECOD/COPOD relationship in docstrings (#655) (#725) --- pyod/models/copod.py | 12 ++++++++++-- pyod/models/ecod.py | 14 +++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/pyod/models/copod.py b/pyod/models/copod.py index 2aebee50..81f95aef 100644 --- a/pyod/models/copod.py +++ b/pyod/models/copod.py @@ -50,11 +50,19 @@ def _parallel_ecdf(n_dims, X): class COPOD(BaseDetector): - """COPOD class for Copula Based Outlier Detector. - COPOD is a parameter-free, highly interpretable outlier detection algorithm + """COPOD is a parameter-free, highly interpretable outlier detection algorithm based on empirical copula models. See :cite:`li2020copod` for details. + .. note:: + COPOD and :class:`~pyod.models.ecod.ECOD` are closely related: ECOD is + an extended, renamed formulation of COPOD, developed by the same + authors with a more refined mathematical presentation (see the + discussion in `GitHub issue #655 + `_). We generally + recommend using ECOD, which is better documented and explained. + COPOD is retained for backward compatibility and availability. + Parameters ---------- contamination : float in (0., 0.5), optional (default=0.1) diff --git a/pyod/models/ecod.py b/pyod/models/ecod.py index 74478f78..e40f4908 100644 --- a/pyod/models/ecod.py +++ b/pyod/models/ecod.py @@ -51,12 +51,20 @@ def _parallel_ecdf(n_dims, X): class ECOD(BaseDetector): - """ECOD class for Unsupervised Outlier Detection Using Empirical - Cumulative Distribution Functions (ECOD) - ECOD is a parameter-free, highly interpretable outlier detection algorithm + """ECOD is a parameter-free, highly interpretable outlier detection algorithm based on empirical CDF functions. See :cite:`li2021ecod` for details. + .. note:: + ECOD is an extended, renamed version of :class:`~pyod.models.copod.COPOD`, + developed by the same authors with a more refined presentation (see + the discussion in `GitHub issue #655 + `_). ECOD is the + recommended choice of the two; COPOD is kept in the library mainly + for availability and backward compatibility. + + + Parameters ---------- contamination : float in (0., 0.5), optional (default=0.1) From 19fa9827b4b0fd5dea12b82cc45dbed00d2e7a1a Mon Sep 17 00:00:00 2001 From: Venish Paneliya <141703684+VenishPaneliya@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:19:52 +0530 Subject: [PATCH 08/15] Return self from BaseDeepLearningDetector.fit (#740) `BaseDetector.fit` documents its contract as Returns ------- self : object but the deep learning base ends `fit` on `_process_decision_scores()` without returning, so every detector built on it returns None: VAE().fit(X) -> None AutoEncoder().fit(X) -> None DeepSVDD().fit(X) -> DeepSVDD (defines its own fit) IForest().fit(X) -> IForest That breaks the chained form these estimators otherwise support - `clf.fit(X).predict(X)` raises AttributeError on None - and any sklearn utility that relies on `fit` returning the estimator. Adds the missing return and the Returns section the base class already documents. --- pyod/models/base_dl.py | 7 +++++++ pyod/test/test_base_dl.py | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/pyod/models/base_dl.py b/pyod/models/base_dl.py index 35fc3a5f..ce8e9bb3 100644 --- a/pyod/models/base_dl.py +++ b/pyod/models/base_dl.py @@ -174,6 +174,11 @@ def fit(self, X, y=None): y : numpy array of shape (n_samples,), optional (default=None) The ground truth of input samples. Not used in unsupervised methods. + + Returns + ------- + self : object + Fitted estimator. """ # validate inputs X and y (optional) X = check_array(X) @@ -202,6 +207,8 @@ def fit(self, X, y=None): self.decision_scores_ = self.decision_function(X) self._process_decision_scores() + return self + def training_prepare(self): self.model = self.model.to(self.device) diff --git a/pyod/test/test_base_dl.py b/pyod/test/test_base_dl.py index e49fad1b..24a7c6aa 100644 --- a/pyod/test/test_base_dl.py +++ b/pyod/test/test_base_dl.py @@ -170,6 +170,18 @@ def test_fit_decision_function(self): # dummy_clf.fit(self.X_train) # self.assertEqual(dummy_clf.decision_scores_.all(), zero_scores.all()) + def test_fit_returns_self(self): + # BaseDetector.fit documents "Returns: self : object", but the deep + # learning base returned None, so `clf.fit(X).predict(X)` raised + # AttributeError for every detector built on it. + dummy_clf = DummyDetector() + returned = dummy_clf.fit(self.X_train) + + self.assertIs(returned, dummy_clf) + # the chained form users expect from an sklearn-style estimator + labels = DummyDetector().fit(self.X_train).predict(self.X_test) + self.assertEqual(labels.shape, (self.n_test,)) + def test_fit_with_y_is_ignored(self): zero_scores = np.zeros(self.n_train) From fc5bbf2bd4d7c54b7be41b341425d0c51b54e302 Mon Sep 17 00:00:00 2001 From: Venish Paneliya <141703684+VenishPaneliya@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:19:59 +0530 Subject: [PATCH 09/15] Keep logvar_clip as given so VAE can be cloned (#738) `VAE.__init__` stored the validator's return value rather than the argument: self.logvar_clip = self._validate_logvar_clip(logvar_clip) and `_validate_logvar_clip` ends with `return float(lower), float(upper)`, so the attribute is a freshly built tuple. sklearn's `clone()` checks that the constructor left its parameters untouched using an identity comparison, so this fails outright: RuntimeError: Cannot clone object VAE(...), as the constructor either does not set or modifies parameter logvar_clip which means `VAE` cannot be used with anything that clones - GridSearchCV, cross_val_score, or a Pipeline being refit. The argument is now stored as given. Validation still happens in `__init__`, so an invalid `logvar_clip` is still rejected at construction time and the existing config tests are unaffected. The normalised floats move to `self.logvar_clip_`, derived in `build_model`, which is what the model and the training loss now read. --- pyod/models/vae.py | 11 ++++++++--- pyod/test/test_vae.py | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/pyod/models/vae.py b/pyod/models/vae.py index fe6e574d..19014279 100644 --- a/pyod/models/vae.py +++ b/pyod/models/vae.py @@ -251,9 +251,14 @@ def __init__(self, contamination=0.1, preprocessing=True, self.output_activation_name = output_activation_name self.batch_norm = batch_norm self.dropout_rate = dropout_rate - self.logvar_clip = self._validate_logvar_clip(logvar_clip) + # Validate eagerly, but keep the argument exactly as it was given: + # sklearn's clone() requires __init__ to store its parameters + # unmodified. + self._validate_logvar_clip(logvar_clip) + self.logvar_clip = logvar_clip def build_model(self): + self.logvar_clip_ = self._validate_logvar_clip(self.logvar_clip) self.model = VAEModel(self.feature_size, encoder_neuron_list=self.encoder_neuron_list, decoder_neuron_list=self.decoder_neuron_list, @@ -262,7 +267,7 @@ def build_model(self): output_activation_name=self.output_activation_name, batch_norm=self.batch_norm, dropout_rate=self.dropout_rate, - logvar_clip=self.logvar_clip) + logvar_clip=self.logvar_clip_) def training_forward(self, batch_data): x = batch_data @@ -271,7 +276,7 @@ def training_forward(self, batch_data): x_recon, z_mu, z_logvar = self.model(x) loss = self.criterion(x, x_recon, z_mu, z_logvar, beta=self.beta, capacity=self.capacity, - logvar_clip=self.logvar_clip) + logvar_clip=self.logvar_clip_) loss.backward() self.optimizer.step() return loss.item() diff --git a/pyod/test/test_vae.py b/pyod/test/test_vae.py index 5e94cd4a..11c018a8 100644 --- a/pyod/test/test_vae.py +++ b/pyod/test/test_vae.py @@ -8,6 +8,7 @@ import numpy as np # noinspection PyProtectedMember +from sklearn.base import clone from sklearn.metrics import roc_auc_score # temporary solution for relative imports in case pyod is not installed @@ -33,6 +34,26 @@ def test_custom_logvar_clip(self): clf = VAE(epoch_num=1, logvar_clip=(-10, 5)) self.assertEqual(clf.logvar_clip, (-10.0, 5.0)) + def test_clone(self): + # __init__ used to store the validator's rebuilt tuple, so sklearn's + # identity check failed and VAE could not be used with any estimator + # that clones (GridSearchCV, cross_val_score, ...). + for logvar_clip in [(-30.0, 20.0), (-10, 5), None]: + clf = VAE(epoch_num=1, logvar_clip=logvar_clip) + cloned = clone(clf) + self.assertEqual(cloned.get_params()['logvar_clip'], logvar_clip) + self.assertIs(clf.get_params()['logvar_clip'], logvar_clip) + + def test_logvar_clip_normalised_at_fit(self): + X = np.random.RandomState(42).randn(64, 5) + clf = VAE(epoch_num=1, logvar_clip=(-10, 5), contamination=0.1, + verbose=0) + clf.fit(X) + # the argument is untouched, the normalised floats live on the + # trailing-underscore attribute + self.assertEqual(clf.logvar_clip, (-10, 5)) + self.assertEqual(clf.logvar_clip_, (-10.0, 5.0)) + def test_invalid_logvar_clip(self): self.assertRaises(ValueError, VAE, epoch_num=1, logvar_clip=(1,)) self.assertRaises(ValueError, VAE, epoch_num=1, From 59f195bc643543f25db05cdf5e30ebe489e317a5 Mon Sep 17 00:00:00 2001 From: Venish Paneliya <141703684+VenishPaneliya@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:20:06 +0530 Subject: [PATCH 10/15] Stop Sampling.fit from overwriting subset_size (#737) `Sampling.fit` resolves a fractional `subset_size` by writing the absolute count back onto the estimator: self.subset_size = int(self.subset_size * n_samples) so the constructor argument is destroyed on the first fit. Two consequences: * refitting on a differently sized dataset silently reuses the stale count. With `subset_size=0.2`, fitting on 100 rows samples 20, and a refit on 400 rows samples 20 again instead of 80; * `get_params()["subset_size"]` returns 20 rather than the 0.2 that was passed, so a `clone()` taken after a fit is not the estimator the user configured. sklearn requires `__init__` parameters to be left as they were given. The resolved value is now a local, and `self.subset_size` is left alone. While rewriting the surrounding lines, the `ValueError` for an out-of-range fraction was missing its `%` operand, so it reported the literal "subset_size=%r must be between 0.0 and 1.0". It now interpolates the value, like the integer branch just above it already does. --- pyod/models/sampling.py | 11 ++++++++--- pyod/test/test_sampling.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/pyod/models/sampling.py b/pyod/models/sampling.py index ec487596..a3a207eb 100644 --- a/pyod/models/sampling.py +++ b/pyod/models/sampling.py @@ -140,13 +140,18 @@ def fit(self, X, y=None): ) if isinstance(self.subset_size, float) is True: if 0.0 < self.subset_size <= 1.0: - self.subset_size = int(self.subset_size * n_samples) + subset_size = int(self.subset_size * n_samples) else: - raise ValueError("subset_size=%r must be between 0.0 and 1.0") + raise ValueError( + "subset_size=%r must be between 0.0 and 1.0" + % self.subset_size + ) + else: + subset_size = self.subset_size random_indices = self.random_state.choice( n_samples, - size=self.subset_size, + size=subset_size, replace=False, ) self.subset = X[random_indices, :] diff --git a/pyod/test/test_sampling.py b/pyod/test/test_sampling.py index 0e1bf6a2..c1119ecb 100644 --- a/pyod/test/test_sampling.py +++ b/pyod/test/test_sampling.py @@ -190,6 +190,39 @@ def test_fit(self): with assert_raises(ValueError): self.clf_int_lower.fit(self.X_train) + def test_fractional_subset_size_not_mutated(self): + # fit() used to overwrite self.subset_size with the absolute count, + # so a refit on a differently sized dataset reused the stale value + # and get_params() stopped returning what the user passed. + clf = Sampling( + subset_size=0.2, contamination=self.contamination, random_state=42 + ) + + clf.fit(self.X_train) + assert_equal(clf.get_params()["subset_size"], 0.2) + assert_equal(len(clf.subset), int(0.2 * len(self.X_train))) + + bigger = np.repeat(self.X_train, 2, axis=0) + clf.fit(bigger) + assert_equal(clf.get_params()["subset_size"], 0.2) + assert_equal(len(clf.subset), int(0.2 * len(bigger))) + + def test_integer_subset_size_not_mutated(self): + clf = Sampling( + subset_size=25, contamination=self.contamination, random_state=42 + ) + clf.fit(self.X_train) + assert_equal(clf.get_params()["subset_size"], 25) + assert_equal(len(clf.subset), 25) + + def test_invalid_fractional_subset_size_reports_the_value(self): + # the message used to contain a literal "%r" - the value was never + # interpolated into it. + clf = Sampling(subset_size=1.5, contamination=self.contamination) + with self.assertRaises(ValueError) as ctx: + clf.fit(self.X_train) + assert "1.5" in str(ctx.exception) + def tearDown(self): pass From 033b9644cb45b56c3530bba048a84821276cd896 Mon Sep 17 00:00:00 2001 From: Venish Paneliya <141703684+VenishPaneliya@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:20:13 +0530 Subject: [PATCH 11/15] Forward n_components in PyODKernelPCA (#730) `PyODKernelPCA.__init__` takes `n_components` but does not pass it to `super().__init__()`, so the inner `KernelPCA` keeps its default of `None`. It is the only one of the sixteen parameters that is not forwarded. `KPCA` exposes the argument publicly and documents it, so `KPCA(n_components=3)` silently computes every component instead of 3: KPCA(n_components=3).fit(X) # X is (420, 8) inner n_components -> None components computed -> 419 transform(X).shape -> (420, 419) The scores are unaffected, because `decision_function` slices `x_transformed[:, :n_selected_components_]` afterwards and the surplus components contribute nothing. What it costs is time and memory: at n=2000 with `n_components=3` the fit goes from 8.60s to 0.50s and the transform matrix from 2000x1999 (32.0 MB) to 2000x3, because with the inner value left at `None` sklearn always takes the dense `eigh` path. `remove_zero_eig=False` is also ineffective today, since sklearn forces zero-eigenvalue removal when `n_components is None`. Checked `decision_scores_` before and after across seven configurations (`n_components` 1/3/100, `n_selected_components`, `eigen_solver="arpack"`, `remove_zero_eig=True`, `kernel="linear"`, and defaults): the largest difference is 6.9e-14. --- pyod/models/kpca.py | 1 + pyod/test/test_kpca.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/pyod/models/kpca.py b/pyod/models/kpca.py index 1bf46438..931f7750 100644 --- a/pyod/models/kpca.py +++ b/pyod/models/kpca.py @@ -35,6 +35,7 @@ def __init__( random_state=None, ): super().__init__( + n_components=n_components, kernel=kernel, gamma=gamma, degree=degree, diff --git a/pyod/test/test_kpca.py b/pyod/test/test_kpca.py index ec543b9c..e958de19 100644 --- a/pyod/test/test_kpca.py +++ b/pyod/test/test_kpca.py @@ -210,6 +210,12 @@ def setUp(self): n_components=10, n_selected_components=0, random_state=42 ) + def test_n_components_is_forwarded(self): + clf = KPCA(n_components=5, random_state=42) + clf.fit(self.X_train) + assert clf.kpca.n_components == 5 + assert clf.kpca.eigenvectors_.shape[1] == 5 + def test_bound(self): self.clf.fit(self.X_train) with assert_raises(ValueError): From 8d2eae3e16e12608fd43442628db6760d5194faa Mon Sep 17 00:00:00 2001 From: s4kura <1761015783@qq.com> Date: Wed, 16 Sep 2026 13:50:20 +0800 Subject: [PATCH 12/15] fix: support cosine distance in KNN (#729) --- pyod/models/knn.py | 16 ++++------------ pyod/test/test_knn.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/pyod/models/knn.py b/pyod/models/knn.py index 26ca4438..13ee112e 100644 --- a/pyod/models/knn.py +++ b/pyod/models/knn.py @@ -8,7 +8,6 @@ from warnings import warn import numpy as np -from sklearn.neighbors import BallTree from sklearn.neighbors import NearestNeighbors from sklearn.utils import check_array from sklearn.utils.validation import check_is_fitted @@ -189,20 +188,13 @@ def fit(self, X, y=None): self.neigh_.fit(X) - # In certain cases, _tree does not exist for NearestNeighbors - # See Issue #158 (https://github.com/yzhao062/pyod/issues/158) - # n_neighbors = 100 + # Brute-force backends do not expose an internal tree. Reuse the + # fitted estimator in that case instead of rebuilding a BallTree, + # which rejects otherwise supported metrics such as cosine. if self.neigh_._tree is not None: self.tree_ = self.neigh_._tree - else: - if self.metric_params is not None: - self.tree_ = BallTree(X, leaf_size=self.leaf_size, - metric=self.metric, - **self.metric_params) - else: - self.tree_ = BallTree(X, leaf_size=self.leaf_size, - metric=self.metric) + self.tree_ = self.neigh_ dist_arr, _ = self.neigh_.kneighbors(n_neighbors=self.n_neighbors, return_distance=True) diff --git a/pyod/test/test_knn.py b/pyod/test/test_knn.py index cb7578f8..c92079f4 100644 --- a/pyod/test/test_knn.py +++ b/pyod/test/test_knn.py @@ -15,6 +15,7 @@ from scipy.stats import rankdata from sklearn.base import clone from sklearn.metrics import roc_auc_score +from sklearn.neighbors import NearestNeighbors # temporary solution for relative imports in case pyod is not installed # if pyod is installed, no need to use the following line @@ -372,6 +373,28 @@ def test_decision_function_uses_batch_kneighbors(self): assert_equal(scores.shape[0], self.X_test.shape[0]) + def test_cosine_metric_uses_brute_backend(self): + X_train = np.array([ + [1.0, 0.0], + [0.0, 1.0], + [1.0, 1.0], + [1.0, -1.0], + ]) + X_test = np.array([[2.0, 1.0], [-1.0, 1.0]]) + + clf = KNN(n_neighbors=2, metric='cosine') + clf.fit(X_train) + + reference = NearestNeighbors( + n_neighbors=2, algorithm='auto', metric='cosine') + reference.fit(X_train) + train_distances, _ = reference.kneighbors(n_neighbors=2) + test_distances, _ = reference.kneighbors(X_test, n_neighbors=2) + + assert_allclose(clf.decision_scores_, train_distances[:, -1]) + assert_allclose(clf.decision_function(X_test), + test_distances[:, -1]) + class TestKNNKwargsRejection(unittest.TestCase): """Regression test for issue #685: KNN must not forward arbitrary kwargs From e357c5b9e6bb83c9315a90ba6585aeae11cb2e5f Mon Sep 17 00:00:00 2001 From: Mohit Arvind Khakharia Date: Wed, 16 Sep 2026 03:06:20 -0400 Subject: [PATCH 13/15] Make LOCI honor contamination and keep k as a parameter (#707) LOCI stored the constructor argument k directly into self.threshold_ and never called _process_decision_scores(), so contamination was accepted but completely ignored when deriving labels_. Store k on the instance, use it in the score loop where it belongs, and let the base class compute threshold_ from contamination like every other detector. Fixes #194 --- pyod/models/loci.py | 13 ++++--------- pyod/test/test_loci.py | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/pyod/models/loci.py b/pyod/models/loci.py index 080339c6..9639be09 100644 --- a/pyod/models/loci.py +++ b/pyod/models/loci.py @@ -140,13 +140,13 @@ class LOCI(BaseDetector): ... contamination=contamination, random_state=42) >>> clf = LOCI() >>> clf.fit(X_train) - LOCI(alpha=0.5, contamination=0.1, k=None) + LOCI(alpha=0.5, contamination=0.1, k=3) """ def __init__(self, contamination=0.1, alpha=0.5, k=3): super(LOCI, self).__init__(contamination=contamination) self.alpha = alpha - self.threshold_ = k + self.k = k def _get_alpha_n(self, dist_matrix, indices, r): """Computes the alpha neighbourhood points. @@ -208,7 +208,7 @@ def _calculate_decision_score(self, X): sigma_mdef = np.std(n_values) / n_hat if n_hat >= 20: outlier_scores[p_ix] = mdef / sigma_mdef - if mdef > (self.threshold_ * sigma_mdef): + if mdef > (self.k * sigma_mdef): break return np.asarray(outlier_scores) @@ -231,13 +231,8 @@ def fit(self, X, y=None): X = check_array(X) self._set_n_classes(y) self.decision_scores_ = self._calculate_decision_score(X) - self.labels_ = (self.decision_scores_ > self.threshold_).astype( - 'int').ravel() + self._process_decision_scores() - # calculate for predict_proba() - - self._mu = np.mean(self.decision_scores_) - self._sigma = np.std(self.decision_scores_) return self def decision_function(self, X): diff --git a/pyod/test/test_loci.py b/pyod/test/test_loci.py index 1086c428..666c3ec9 100644 --- a/pyod/test/test_loci.py +++ b/pyod/test/test_loci.py @@ -148,6 +148,32 @@ def test_predict_rank_normalized(self): def test_model_clone(self): clone_clf = clone(self.clf) + def test_k_is_stored_as_parameter(self): + # `k` must survive as a constructor attribute so that get_params / + # clone round-trip it (issue #194). + clf = LOCI(contamination=self.contamination, k=5) + assert_equal(clf.k, 5) + assert_equal(clone(clf).k, 5) + + def test_contamination_controls_labels(self): + # `threshold_` is documented as being derived from `contamination`, + # so a larger contamination must flag at least as many outliers + # (issue #194). + fractions = [] + for contamination in (0.05, 0.25, 0.45): + clf = LOCI(contamination=contamination) + clf.fit(self.X_train) + fractions.append(clf.labels_.mean()) + assert_array_less(fractions[0], fractions[1]) + assert_array_less(fractions[1], fractions[2]) + + def test_threshold_matches_contamination(self): + # labels_ must select ~contamination of the training samples. + contamination = 0.25 + clf = LOCI(contamination=contamination) + clf.fit(self.X_train) + assert_allclose(clf.labels_.mean(), contamination, atol=0.02) + def tearDown(self): pass From 87c9499c08459d1b665d6bad40df180256f91077 Mon Sep 17 00:00:00 2001 From: Venish Paneliya <141703684+VenishPaneliya@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:37:04 +0530 Subject: [PATCH 14/15] Iterate over rows in the unweighted branch of pearsonr_mat (#731) `pearsonr_mat` builds an `(n_row, n_row)` matrix and fills it pairwise, but the unweighted branch runs its outer loop over `n_col`: if w is not None: for cx in range(n_row): ... else: for cx in range(n_col): The weighted branch directly above uses `n_row`, and the docstring describes the result as "Row-wise pearson score matrix" of shape (n_samples, n_samples). When there are fewer columns than rows, every pair with `cx >= n_col` is skipped and those cells keep the value the matrix was initialised with, so the function reports a correlation of exactly 1.0 for pairs it never looked at. On a (50, 4) matrix, 2070 of 2500 entries are wrong, with errors up to 2.0. Output is unchanged whenever `n_col >= n_row - 1`, and the weighted branch is untouched. `n_col` has no remaining reader, so it goes too. The existing test uses a (10, 20) matrix, which is in the unaffected range, and only checks the shape; the added test compares every entry against `scipy.stats.pearsonr` on an (8, 3) matrix. --- pyod/test/test_stat_models.py | 13 +++++++++++++ pyod/utils/stat_models.py | 3 +-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/pyod/test/test_stat_models.py b/pyod/test/test_stat_models.py index d0535d02..c5bacc85 100644 --- a/pyod/test/test_stat_models.py +++ b/pyod/test/test_stat_models.py @@ -17,6 +17,7 @@ from pyod.utils.stat_models import pairwise_distances_no_broadcast from pyod.utils.stat_models import wpearsonr +from scipy.stats import pearsonr from pyod.utils.stat_models import pearsonr_mat from pyod.utils.stat_models import column_ecdf import statsmodels.distributions @@ -63,6 +64,18 @@ def test_pearsonr_mat(self): pear_mat = pearsonr_mat(self.mat, self.w_mat) assert_equal(pear_mat.shape, (10, 10)) + def test_pearsonr_mat_fewer_features_than_samples(self): + # Every pair of rows must be correlated, including when the matrix has + # fewer columns than rows. + mat = np.random.rand(8, 3) + pear_mat = pearsonr_mat(mat) + + assert_equal(pear_mat.shape, (8, 8)) + for i in range(8): + for j in range(8): + expected = 1.0 if i == j else pearsonr(mat[i, :], mat[j, :])[0] + assert_allclose(pear_mat[i, j], expected) + assert (np.min(pear_mat) >= -1) assert (np.max(pear_mat) <= 1) diff --git a/pyod/utils/stat_models.py b/pyod/utils/stat_models.py index d9f588a7..85f1991c 100644 --- a/pyod/utils/stat_models.py +++ b/pyod/utils/stat_models.py @@ -166,7 +166,6 @@ def pearsonr_mat(mat, w=None): """ mat = check_array(mat) n_row = mat.shape[0] - n_col = mat.shape[1] pear_mat = np.full([n_row, n_row], 1).astype(float) if w is not None: @@ -176,7 +175,7 @@ def pearsonr_mat(mat, w=None): pear_mat[cx, cy] = curr_pear pear_mat[cy, cx] = curr_pear else: - for cx in range(n_col): + for cx in range(n_row): for cy in range(cx + 1, n_row): curr_pear = pearsonr(mat[cx, :], mat[cy, :])[0] pear_mat[cx, cy] = curr_pear From 97fa2b4db9ba6e3037d3a36f0ee88d50472104db Mon Sep 17 00:00:00 2001 From: Yue Zhao Date: Wed, 16 Sep 2026 00:52:33 -0700 Subject: [PATCH 15/15] Prepare v3.6.6 maintenance release --- CHANGES.txt | 1 + pyod/models/loci.py | 5 +-- pyod/test/test_cli.py | 57 ++++++++++++++++++++++++++++++----- pyod/test/test_stat_models.py | 4 ++- pyod/version.py | 2 +- 5 files changed, 58 insertions(+), 11 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 9632827e..f0e16664 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -234,3 +234,4 @@ v<3.6.2>, <07/20/2026> -- Security hardening for model persistence (CVE-2026-155 v<3.6.3>, <08/01/2026> -- DeepSVDD correctness fixes, contributor bug fixes, and project infrastructure. DeepSVDD hypersphere-collapse fix (builds on #704 by Devashish Moghe; closes #606 and #641): the training loop had `loss.backward()` commented out, so no gradient ever reached the weights and scores came from the randomly initialized network. #704 restored the backward pass, recomputing the L2 term `w_d` inside the batch loop so each step gets a fresh graph, moving the best-model bookkeeping out of the batch loop, and deep-copying `state_dict()` (which otherwise aliases the live parameters). Enabling training then exposed three pre-existing defects that the commented-out line had been masking. First, `fit()` assigned the estimator's center `self.c = 0.0` and let `_init_c()` write the computed center to the inner module, which the loss and scoring never read; for a bias-free ReLU network the all-zero weights map every input to 0, so `c = 0` is exactly the trivial solution of Ruff et al., ICML 2018, Proposition 1, and training converged to a collapsed hypersphere. On a 17-dataset ODDS benchmark (3 seeds, 30 epochs) the collapsed configuration returned a single distinct score on 10 of 17 datasets -- a detector that is silently useless while still meeting a ROC floor, because ordering can be induced by floating-point noise as small as 1e-24. The center is now taken from `_init_c()`, detached, and stored where the objective reads it. Second, the optimizer was constructed without a learning rate (Adam's default 1e-3) and passed `l2_regularizer=0.1` as `weight_decay`, five orders of magnitude above the 5e-7 used by the reference implementation; that decay drives the weights toward zero and compounds the collapse. `l2_regularizer` now defaults to 5e-7 and a new `learning_rate` parameter defaults to 1e-4 (both match the reference implementation; the `l2_regularizer` default change is API-visible). Third, `best_model_dict` was stored but never loaded, so scoring used final-epoch weights; the snapshot is now applied before scoring. Fourth, `fit()` wrote the computed center into the constructor parameter `c`, which leaked fitted state through `get_params()`, made `clone()` start pre-seeded, and made a refit train a newly built network against the previous fit's center; the fitted center now lives in `c_`, `c` stays configuration, and a user-supplied center is validated before training: a scalar is expanded to the network output width, a vector must match that width (`hidden_neurons[-1]`, or `n_features` when `use_ae=True`), non-finite values are rejected, an all-zero center is rejected with a `ValueError` because it is exactly the Proposition 1 trivial-solution condition, and the tensor is copied so a later mutation of the caller's array cannot change the fitted center. Measured effect on the 17-dataset benchmark: mean ROC AUC rises from 0.601 (collapsed) to 0.748, with score collapse eliminated on all 17 datasets. Calibration note: 0.748 is level with the 0.746 obtained by the untrained network, so these changes restore DeepSVDD to its baseline rather than improving on it. Deep SVDD assumes a clean one-class training set, while PyOD fits it on contaminated data unsupervised; in a controlled comparison, training on genuinely normal samples only reaches 0.879 and trimming the most anomalous 10% before training reaches 0.800, so a substantial part of the gap tracks the mismatch between the method's one-class assumption and unsupervised use on contaminated data. These two diagnostics do not rule out further defects or tuning gains. Tests: `test_scores_are_not_constant` asserts score diversity on both the standard and autoencoder paths and `test_center_is_valid` asserts the center is a detached, finite, non-scalar tensor; both fail on the collapsed code. `test_fit_changes_parameters` pins the original backward-pass defect only, and does not detect collapse. The LOCI and generate_data contributor PRs remain under review and are not included here. Contributor PRs: local encoder paths now fall back to the HuggingFace backend when sentence-transformers is absent (#701, Sunny Guntuka; follow-up to #696), so a `pyod[huggingface]`-only install no longer raises `ImportError` before the existing fallback can run; save/load round-trip coverage now spans 23 detectors (#708, Jayesh Suryavanshi; closes #269), up from 2, while 21 of them also gain clone assertions for unfitted equivalence and refit score reproduction, strengthening per-detector `test_model_clone` methods that previously only checked that `clone()` did not raise. Test fix (#710): `test_resolve_st_instance_no_download` built its no-download model with `SentenceTransformer(modules=[])`, which sentence-transformers 5.6 rejects; it now passes a single trivial `torch.nn.Identity` module instead, which keeps the test network-free without depending on `sentence_transformers.models` keyword arguments that have been renamed across 5.x. Project infrastructure (#705): pyod.dev website badge in the README (using an absolute image URL so it renders on the PyPI project page), brand assets under `brand/` plus Sphinx `docs/_static/` copies, and a `SECURITY.md` vulnerability-reporting policy. v<3.6.4>, <08/02/2026> -- Documentation accuracy pass; no runtime behavior changes. A parallel audit compared every constructor signature against its numpydoc block, attempted all 72 example scripts, and built the Sphinx site; the in-scope findings were then repaired and checked again. Docstrings (83 fixes across 43 modules in `pyod/models/`): removed seven documented constructor parameters that do not exist and raise `TypeError` when passed (`GMM.verbose`, `GMM.verbose_interval`, `RGraph.active_support_params`, `RGraph.random_state`, `SUOD.cost_forecast_loc_fit`, `SUOD.cost_forecast_loc_pred`, and `LUNAR.n_neighbors`, whose real keyword is the British spelling `n_neighbours`; the entry is renamed and notes the alternative spelling); corrected documented defaults that disagreed with the signature (among them `ABOD.n_neighbors` 10 -> 5, `ALAD.epochs` 500 -> 200, `ALAD.preprocessing` True -> False, `ALAD.learning_rate_gen`/`learning_rate_disc` 0.001 -> 0.0001, `AnoGAN.learning_rate_query` 0.001 -> 0.01, `RGraph.transition_steps` 20 -> 10, `RGraph.n_nonzero` 50 -> 10, `SUOD.n_jobs` 1 -> None, and `DIF.hidden_neurons`, documented as [64, 32] while the constructor substitutes [500, 100] for the `None` default); documented public constructor parameters that had no entry (`ALAD.latent_dim`, `add_disc_zz_loss`, `spectral_normalization`; `AnoGAN.latent_dim_G`, `device`); corrected `labels_` in `BaseDetector` and every detector that copied the wording, which described an `int` where the attribute is a numpy array of shape (n_samples,); and corrected `XGBOD.labels_`, documented as `threshold_` applied to `decision_scores_` when `fit()` never sets `threshold_` and the labels come from the fitted XGBoost classifier. An executable-AST comparison against the previous commit confirms every one of those 43 files changed only docstrings. Sphinx (`docs/conf.py`, `docs/pyod.models.tabular.rst`): enabled `sphinx.ext.napoleon`, absent since the numpydoc style was adopted, so every `Parameters`/`Attributes`/`Examples` heading was parsed as an RST section title rather than a field list; a full build goes from 310 warnings and 242 `class="problematic"` spans to 41 and 5, and the `:attr:` links for `decision_scores_` and `labels_` on the landing page and API cheat sheet resolve for the first time. Removed the `pyod.models.auto_encoder_torch` section, dead since the module was deleted in 2024 (a duplicated `:exclude-members:` option made the directive raise `DuplicateOptionError`, which Sphinx stripped from the output, so the page silently rendered a heading with no body), and de-duplicated the `pyod.models.base` automodule so `api_cc.rst` is its canonical home, clearing 13 duplicate-object warnings. Enabling Napoleon also exposed five docstrings it could not parse, which had been inert text before: `AnoGAN` emitted 1 of 16 parameters and `RGraph` 5 of 16 (consecutive blank lines and `name:` headers missing the space before the colon terminated the block), `DIF` emitted 30 fields for an 11-parameter constructor (comma-separated headers such as `hidden_neurons, list` split into bogus names), `XGBOD` turned a commented-out `missing` block into four bogus parameters, and the `SO_GAAL` in `so_gaal_new.py` had an entirely empty `Parameters` section for its 12 arguments. All five are repaired; a sweep over all 62 detectors now parses every documented parameter with no bogus or missing entries. Entry points: the README quick start called `clf.fit(X_train)` without ever defining `X_train` and used `visualize` without importing it, so the block on the GitHub landing page and the PyPI description raised `NameError` when pasted; it now carries the seeded prelude and the sample output measured from it. `docs/install.rst` documented a `pytorch` extra that does not exist (pip treats an unknown extra as a warning, so the command succeeded while installing none of the PyTorch stack), omitted nine real extras and the `audio` extra entirely, and never mentioned `pip install pyod[all]`, a string that appeared nowhere in the repository; the table is now keyed on the extras defined in `pyproject.toml`. The same file claimed the MCP server registers seven tools when it registers ten, omitting the three that perform detection, and contradicted `README.rst`. Removed the claim that `pyod install skill` supports Claude Desktop, which no code path targets, and routed Desktop users to the MCP path. Examples: fixed runtime failures in `mad_example.py`, which generated two features for a detector that requires one, and `qmcd_example.py`, which appended ground-truth labels to the feature matrix before calling `predict` (raising, and leaking test labels). `examples/data/mat_file_conversion.py` now byte-compiles after removal of mid-file Python 2 `__future__` imports; end-to-end conversion still requires its optional dependencies and external source datasets. Flagged for a separate decision, not changed here: `CBLOF.n_jobs` is accepted but unused, `DevNet` has no constructor docstring and exposes arguments that are unused or overridden, and `pyod/cli.py` infers "Claude Code detected" from a directory that `pyod install skill` itself creates. v<3.6.5>, <08/16/2026> -- Three contributor bug fixes plus maintainer follow-ups from a two-panel review. generate_data zero-offset collapse (#706 by Mohit-Ak, closes GH #141): the internal offset was drawn with randint(low=offset), which returns 0 about one time in ten for the default offset=10, and uniform(-0, 0) then placed every labelled outlier exactly on the origin. Measured over seeds 0 to 500, 46 of 501 integer random_state values (9.18%) produced an outlier block whose per-feature variance was exactly zero, so those seeds silently generated a benchmark with no anomalies to find; KNN roc_auc was exactly 0.0 on seeds 41, 48, 50 and 85. The offset is now redrawn only when it comes out as 0. Data generated from any seed that was not degenerate is unchanged, verified by an RNG call-sequence differential over 11,280 offset and seed combinations covering integer, float, bool and numpy scalar offsets, which found zero regressions. That differential compares the parent and patched code on one machine, which is the claim being made; note that generated values have never been bit-identical across platforms, because randn draws Gaussians through a log that libm implementations round differently in the last ulp. Data from the 46 degenerate seeds does change, necessarily, since its previous form was unusable. A float offset in the open interval (1, 2) is now accepted, drawn continuously with a floor of 1.0, where randint previously raised ValueError because it truncates its bounds to low == high. An offset below 1 still raises, deliberately: the inlier spread coef_ is drawn from [0.001, 1.001) independently of offset, so an outlier box of half-width below 1 would sit inside the inlier cloud and make the labels anti-correlated with outlierness (measured IForest roc_auc 0.0027 at offset=0.01). That rejection is now an explicit ValueError raised before any random draw, rather than an incidental error from numpy.random, and randint bounds are coerced with int() rather than relying on NumPy's undocumented truncation of float bounds. One undocumented narrowing follows: offset values that were never scalars but which NumPy used to coerce, namely integer strings such as '2', bytes, and one-element sequences or arrays, now raise TypeError. Those forms were outside both the previous 'offset : int' documentation and the current 'int or float' documentation, and no supported call is affected. CBLOF.n_jobs (#719 by bishtashish708, closes #713, and resolves the item flagged in 3.6.4): the argument was never assigned to self, so get_params() reported None and sklearn.base.clone() dropped it. It is now stored unmodified, restoring the estimator contract. Behavior change: fit() now emits a FutureWarning when a non-default n_jobs is supplied, announcing removal in PyOD v4.0.0, and the warning repeats on each fit call rather than once per process, because scikit-learn's internal use of catch_warnings invalidates the caller's warning registry. The value is no longer forwarded to KMeans under any scikit-learn version; scikit-learn deprecated KMeans.n_jobs in 0.23, where it already had no effect after the move to OpenMP, and removed it in 1.0. The docstring previously described behavior on scikit-learn 0.25, a release that never existed, and now points users at OMP_NUM_THREADS, threadpoolctl, or configuring a custom clustering_estimator. Detector output is unaffected: CBLOF(n_jobs=1) and CBLOF(n_jobs=8) produce bit-identical decision_scores_. ROD divide-by-zero (#722 by bishtashish708, closes #523): a sample coinciding with the geometric median gave it a zero-length displacement vector, so the rotation angle was undefined and the score came back as nan with a RuntimeWarning. The undefined angle is now excluded from the MinMaxScaler fit and the row is assigned its limiting cost of 0 directly. Behavior change: decision_scores_ and therefore labels_ change for any input containing a point at the geometric median. The earlier approach of substituting pi/2 for the undefined angle stretched the scaler and distorted the scores of the well-defined rows in the same dataset, by up to 16.9% on a 7-point fixture and up to 5.0% at n between 43 and 165. Scores on data with no such point are bit-identical. A separate case is now handled explicitly: when the geometric median falls on the coordinate origin no rotation angle is defined for any row, and that subspace emits a RuntimeWarning and falls back to costs driven only by the distance from the geometric median. Test infrastructure: pyod/test/conftest.py guarded its torch import with except ImportError, but a broken rather than absent install raises OSError (on Windows, WinError 127 loading shm.dll), which aborted collection of the entire suite instead of skipping the 25 torch-dependent modules the guard exists to skip. The guard now also catches OSError and announces a broken install rather than skipping silently. Still open from the 3.6.4 list: DevNet has no constructor docstring and exposes unused arguments (#714), and pyod/cli.py infers Claude Code from a directory that pyod install skill itself creates (#715). +v<3.6.6>, <09/16/2026> -- Maintenance release with estimator-contract, numerical, CLI and documentation fixes. LOCI stores k as a constructor parameter so clone-then-fit works, keeps k as the radius-scan early-stop sensitivity, and derives threshold_ and labels_ from contamination (#707, closes #194); fixed-k raw scores are unchanged, but binary labels can change. The unweighted pearsonr_mat utility now computes every row pair on tall inputs instead of leaving some entries at the initial value 1 (#731); follow-up tests restore weighted-output range assertions and seed the new fixture. BaseDeepLearningDetector.fit returns self (#740). VAE preserves the original logvar_clip constructor argument for sklearn.clone and uses a validated logvar_clip_ during model construction and training (#738). Sampling.fit no longer overwrites fractional subset_size, so subsequent fits recompute the subset size for the new sample count (#737). PyODKernelPCA forwards n_components instead of silently ignoring it (#730); fitted components and anomaly scores can change when a component limit is requested. KNN reuses its fitted NearestNeighbors for brute-force backends instead of constructing an incompatible BallTree, allowing cosine distance (#729). DeepSVDD respects verbose=0 for epoch-loss output (#735). pyod info no longer treats an installed skill directory alone as evidence that Claude Code is installed; it checks the executable or user configuration, with hermetic CLI tests (#723, closes #715). Documentation removes unsupported GMM.fit sample_weight (#741), corrects score_to_label output semantics (#733), repairs KNN/LOF/LUNAR reference links (#732), and clarifies the ECOD/COPOD relationship (#725, addresses #655). Contributor-workflow housekeeping includes ignore rules and the todo/ drop box; CITATION.cff is already present on master and is retained for the release. Thanks to Mohit-Ak, VenishPaneliya, Iams4kura, busysofa15, godarrenw and dishasharma23-prog. No new detector or dependency is introduced. The ROD duplicate-weighting proposal (#746) is not included. diff --git a/pyod/models/loci.py b/pyod/models/loci.py index 9639be09..184a8ba3 100644 --- a/pyod/models/loci.py +++ b/pyod/models/loci.py @@ -107,8 +107,9 @@ class LOCI(BaseDetector): should be considered "local". k: int, default = 3 - An outlier cutoff threshold for determine whether or not a point - should be considered an outlier. + Sensitivity of the radius-scan early stop. Scanning stops when MDEF + exceeds k times sigma_MDEF. Binary labels use the threshold derived + from ``contamination``, not k. Attributes ---------- diff --git a/pyod/test/test_cli.py b/pyod/test/test_cli.py index a8b8ebaa..582a51ec 100644 --- a/pyod/test/test_cli.py +++ b/pyod/test/test_cli.py @@ -84,6 +84,18 @@ def test_pyod_info_does_not_exit_without_mcp(): _REPO_ROOT = str(Path(__file__).resolve().parents[2]) +def _isolated_info_command(claude_executable=None): + # Parent-process mocks do not affect executable discovery in a child. + code = ( + "from unittest.mock import patch\n" + "from pyod.cli import main\n" + "with patch('pyod.cli.shutil.which', " + f"return_value={claude_executable!r}):\n" + " raise SystemExit(main(['info']))\n" + ) + return [sys.executable, "-c", code] + + def _isolated_home_env(home: Path) -> dict: """Return an env dict with HOME/USERPROFILE pointing at a clean directory. @@ -126,7 +138,7 @@ def test_pyod_info_project_local_only(tmp_path): fake_home.mkdir() result = subprocess.run( - [sys.executable, "-m", "pyod.cli", "info"], + _isolated_info_command(), capture_output=True, text=True, cwd=str(tmp_path), env=_isolated_home_env(fake_home), @@ -154,7 +166,7 @@ def test_pyod_info_codex_detected_no_skill(tmp_path): work.mkdir() result = subprocess.run( - [sys.executable, "-m", "pyod.cli", "info"], + _isolated_info_command(), capture_output=True, text=True, cwd=str(work), env=_isolated_home_env(fake_home), @@ -168,13 +180,12 @@ def test_pyod_info_codex_detected_no_skill(tmp_path): assert "no agent stacks detected" not in result.stdout -def test_pyod_info_codex_and_claude_both_detected(tmp_path,monkeypatch): - """Both ~/.claude/ and ~/.codex/ present, neither skill installed. +def test_pyod_info_codex_and_claude_both_detected(tmp_path): + """Both ~/.claude.json and ~/.codex/ present, neither skill installed. Output must list both agents and show both install commands so the user knows which option fits their workflow. """ - monkeypatch.setattr("shutil.which", lambda cmd: None) fake_home = tmp_path / "fake_home" fake_home.mkdir(parents=True, exist_ok=True) (fake_home / ".claude.json").touch() @@ -184,7 +195,7 @@ def test_pyod_info_codex_and_claude_both_detected(tmp_path,monkeypatch): work.mkdir() result = subprocess.run( - [sys.executable, "-m", "pyod.cli", "info"], + _isolated_info_command(), capture_output=True, text=True, cwd=str(work), env=_isolated_home_env(fake_home), @@ -199,6 +210,38 @@ def test_pyod_info_codex_and_claude_both_detected(tmp_path,monkeypatch): assert "`pyod install skill --project`" in result.stdout +def test_pyod_info_detects_claude_executable(tmp_path): + """A Claude executable is sufficient without a user configuration file.""" + fake_home = tmp_path / "fake_home" + fake_home.mkdir() + result = subprocess.run( + _isolated_info_command(claude_executable="mock-claude"), + capture_output=True, text=True, + cwd=str(tmp_path), + env=_isolated_home_env(fake_home), + ) + assert result.returncode == 0, f"stderr={result.stderr}" + assert "Claude Code (user-global): run `pyod install skill`" in result.stdout + assert "Codex (project-local):" not in result.stdout + + +def test_pyod_info_claude_dir_alone_is_not_detection(tmp_path): + """A skill-created Claude directory is not evidence of an installation.""" + fake_home = tmp_path / "fake_home" + (fake_home / ".claude" / "skills").mkdir(parents=True) + work = tmp_path / "work" + work.mkdir() + result = subprocess.run( + _isolated_info_command(), + capture_output=True, text=True, + cwd=str(work), + env=_isolated_home_env(fake_home), + ) + assert result.returncode == 0, f"stderr={result.stderr}" + assert "NOT INSTALLED (no agent stacks detected)" in result.stdout + assert "Claude Code" not in result.stdout + + def test_pyod_info_user_global_claude_plus_codex_detected(tmp_path): """User-global Claude install + Codex detected + no project install. @@ -219,7 +262,7 @@ def test_pyod_info_user_global_claude_plus_codex_detected(tmp_path): work.mkdir() result = subprocess.run( - [sys.executable, "-m", "pyod.cli", "info"], + _isolated_info_command(), capture_output=True, text=True, cwd=str(work), env=_isolated_home_env(fake_home), diff --git a/pyod/test/test_stat_models.py b/pyod/test/test_stat_models.py index c5bacc85..0ecb32b8 100644 --- a/pyod/test/test_stat_models.py +++ b/pyod/test/test_stat_models.py @@ -63,11 +63,13 @@ def test_pearsonr_mat(self): pear_mat = pearsonr_mat(self.mat, self.w_mat) assert_equal(pear_mat.shape, (10, 10)) + assert (np.min(pear_mat) >= -1) + assert (np.max(pear_mat) <= 1) def test_pearsonr_mat_fewer_features_than_samples(self): # Every pair of rows must be correlated, including when the matrix has # fewer columns than rows. - mat = np.random.rand(8, 3) + mat = np.random.RandomState(42).rand(8, 3) pear_mat = pearsonr_mat(mat) assert_equal(pear_mat.shape, (8, 8)) diff --git a/pyod/version.py b/pyod/version.py index c7f73317..6f52b7fa 100644 --- a/pyod/version.py +++ b/pyod/version.py @@ -20,4 +20,4 @@ # Dev branch marker is: 'X.Y.dev' or 'X.Y.devN' where N is an integer. # 'X.Y.dev0' is the canonical version of 'X.Y.dev' # -__version__ = '3.6.5' # pragma: no cover +__version__ = '3.6.6' # pragma: no cover