Skip to content

Commit f886cb0

Browse files
committed
Release v3.6.5: contributor bug fixes plus review follow-ups
Merges #706 (generate_data zero-offset collapse, Mohit-Ak), #719 (CBLOF n_jobs, bishtashish708) and #722 (ROD divide-by-zero, bishtashish708), then fixes the defects a two-panel review and two rounds of Codex review found in them. - generate_data: redraw only a zero offset; accept float offsets in (1, 2); reject offset < 1 explicitly; coerce randint bounds with int() - CBLOF: store n_jobs, never forward it to KMeans, deprecate it from fit() - ROD: exclude undefined angles from the scaler fit; handle an origin geometric median explicitly; keep list input working on both paths - conftest: a broken torch import skips torch modules locally and re-raises under CI instead of silently dropping 25 modules Scores change only for inputs that were previously degenerate or NaN; CHANGES.txt discloses every user-visible behavior change.
1 parent a31698d commit f886cb0

9 files changed

Lines changed: 344 additions & 51 deletions

File tree

CHANGES.txt

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

pyod/models/cblof.py

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
# License: BSD 2 clause
77

88

9-
import inspect
109
import warnings
1110

1211
import numpy as np
@@ -93,14 +92,15 @@ class CBLOF(BaseDetector):
9392
RandomState instance used by `np.random`.
9493
9594
n_jobs : int, optional (default=1)
96-
Number of parallel jobs for the default KMeans backend.
97-
Only forwarded to ``KMeans`` when set to a value other than 1
98-
*and* the installed scikit-learn version supports it (the
99-
parameter was deprecated in 0.23 and removed in 0.25).
100-
On sklearn >= 0.25, or when left at the default of 1, this value
101-
is stored for ``get_params()`` / ``clone()`` compatibility only.
102-
Has no effect when a custom ``clustering_estimator`` is provided;
103-
set ``n_jobs`` on that estimator directly.
95+
Deprecated compatibility parameter. ``KMeans.n_jobs`` was deprecated
96+
in scikit-learn 0.23 and removed in 1.0, so this value is stored for
97+
``get_params()`` / ``clone()`` compatibility but is not forwarded.
98+
Values other than 1 emit a ``FutureWarning`` during ``fit()`` and
99+
support will be removed in PyOD v4.0.0. Control the default KMeans
100+
parallelism with the
101+
``OMP_NUM_THREADS`` environment variable or ``threadpoolctl``. For a
102+
custom ``clustering_estimator``, configure parallelism on that
103+
estimator directly.
104104
105105
Attributes
106106
----------
@@ -174,24 +174,25 @@ def fit(self, X, y=None):
174174
Fitted estimator.
175175
"""
176176

177+
if self.n_jobs != 1:
178+
warnings.warn(
179+
"The 'n_jobs' parameter is deprecated and will be removed "
180+
"in PyOD v4.0.0. It has no effect on the default KMeans "
181+
"estimator. Control KMeans parallelism with the "
182+
"OMP_NUM_THREADS environment variable or threadpoolctl, or "
183+
"configure parallelism on a custom clustering_estimator.",
184+
FutureWarning,
185+
stacklevel=2)
186+
177187
# validate inputs X and y (optional)
178188
X = check_array(X)
179189
self._set_n_classes(y)
180190
n_samples, n_features = X.shape
181191

182192
# check parameters
183193
# number of clusters are default to 8
184-
_kmeans_kwargs = dict(
185-
n_clusters=self.n_clusters, random_state=self.random_state)
186-
# n_jobs was removed from KMeans in sklearn 0.25; on 0.23/0.24 it is
187-
# deprecated and emits FutureWarning for any concrete value, including
188-
# the default 1. Only forward when the user explicitly requested more
189-
# than one job so that plain CBLOF().fit(X) stays warning-free.
190-
if (self.clustering_estimator is None
191-
and self.n_jobs != 1
192-
and "n_jobs" in inspect.signature(KMeans.__init__).parameters):
193-
_kmeans_kwargs["n_jobs"] = self.n_jobs
194-
self._validate_estimator(default=KMeans(**_kmeans_kwargs))
194+
self._validate_estimator(default=KMeans(
195+
n_clusters=self.n_clusters, random_state=self.random_state))
195196

196197
self.clustering_estimator_.fit(X=X, y=y)
197198
# Get the labels of the clustering results

pyod/models/rod.py

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77

88
import multiprocessing
9+
import warnings
910
from itertools import combinations as com
1011
from multiprocessing import Pool
1112

@@ -182,15 +183,24 @@ def rod_3D(x, gm=None, median=None, scaler1=None, scaler2=None):
182183
183184
Parameters
184185
----------
185-
x : array-like, 3D data points.
186+
x : array-like of shape (n_samples, 3), n_samples >= 1
187+
3D data points. A zero-row input raises from ``geometric_median``.
186188
gm: list (default=None), the geometric median
187189
median: float (default=None), MAD median
188190
scaler1: obj (default=None), MinMaxScaler of Angles group 1
189191
scaler2: obj (default=None), MinMaxScaler of Angles group 2
190192
191193
Returns
192194
-------
193-
decision_scores, gm, scaler1, scaler2
195+
decision_scores, gm, median, scaler1, scaler2
196+
197+
Warns
198+
-----
199+
RuntimeWarning
200+
When the geometric median falls on the coordinate origin, no rotation
201+
angle is defined for any row. The subspace then uses one constant
202+
angle, so its costs are driven only by the distance from the geometric
203+
median.
194204
"""
195205
# find the geometric median if it is not already fit
196206
gm = geometric_median(x) if gm is None else gm
@@ -199,19 +209,48 @@ def rod_3D(x, gm=None, median=None, scaler1=None, scaler2=None):
199209
_x = x - gm
200210
# calculate the scaled angles between the geometric median and each data point vector
201211
v_norm = np.linalg.norm(_x, axis=1)
202-
# Avoid divide-by-zero when a point coincides with the geometric median
203-
# (v_norm == 0) or the median is at the origin (norm_ == 0).
204-
# In both cases the angle is undefined; treat it as π/2 so the rotation
205-
# cost (v_norm³ · cos · sin²) collapses to 0 and does not contaminate MAD.
206-
safe_denom = v_norm * norm_
207-
cos_vals = np.where(safe_denom > 0,
208-
np.dot(_x, gm) / np.where(safe_denom > 0, safe_denom, 1.0),
209-
0.0)
210-
gammas, scaler1, scaler2 = scale_angles(
211-
np.arccos(np.clip(cos_vals, -1, 1)),
212-
scaler1=scaler1, scaler2=scaler2)
213-
# apply the ROD main equation to find the rotation costs
214-
costs = np.power(v_norm, 3) * np.cos(gammas) * np.square(np.sin(gammas))
212+
if norm_ == 0:
213+
warnings.warn(
214+
"The geometric median is at the coordinate origin "
215+
"(norm_ == 0), so no rotation angle is defined for this ROD "
216+
"subspace. Its scores fall back to being driven only by the "
217+
"distance from the geometric median.",
218+
RuntimeWarning, stacklevel=2)
219+
# There is no reference direction, so use one constant angle for the
220+
# whole subspace. Its trigonometric factor is then constant, reducing
221+
# the rotation cost to a constant times v_norm**3. Note that MAD still
222+
# scores each row by how far its cost sits from the median cost, so
223+
# the result is not a plain ordering by distance.
224+
# Size from v_norm, not x: this helper documents x as array-like and a
225+
# plain list works on the ordinary path, because x - gm broadcasts
226+
# through gm. Reading x.shape here would break list callers on exactly
227+
# the degenerate inputs these branches exist to serve.
228+
gammas, scaler1, scaler2 = scale_angles(
229+
np.full(v_norm.shape[0], np.pi / 2.),
230+
scaler1=scaler1, scaler2=scaler2)
231+
costs = (np.power(v_norm, 3) * np.cos(gammas) *
232+
np.square(np.sin(gammas)))
233+
else:
234+
denominator = v_norm * norm_
235+
valid = denominator > 0
236+
if np.all(valid):
237+
# Preserve the original array-wide operations for ordinary data.
238+
gammas, scaler1, scaler2 = scale_angles(
239+
np.arccos(np.clip(np.dot(_x, gm) / denominator, -1, 1)),
240+
scaler1=scaler1, scaler2=scaler2)
241+
costs = (np.power(v_norm, 3) * np.cos(gammas) *
242+
np.square(np.sin(gammas)))
243+
else:
244+
gammas, scaler1, scaler2 = scale_angles(
245+
np.arccos(np.clip(
246+
np.dot(_x[valid], gm) / denominator[valid], -1, 1)),
247+
scaler1=scaler1, scaler2=scaler2)
248+
# A zero-radius row has no displacement direction. Exclude its
249+
# undefined angle from scaler fitting and assign its limiting
250+
# cost 0.
251+
costs = np.zeros(v_norm.shape[0])
252+
costs[valid] = (np.power(v_norm[valid], 3) * np.cos(gammas) *
253+
np.square(np.sin(gammas)))
215254
# apply MAD to calculate the decision scores
216255
decision_scores, median = mad(costs, median=median)
217256
return decision_scores, list(gm), median, scaler1, scaler2

pyod/test/conftest.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Pytest configuration for PyOD tests.
22
33
Conditional collection skip for torch-dependent test modules when
4-
torch is not installed.
4+
torch is absent, or when it is installed but fails to import in a
5+
local run. Under CI a broken install re-raises instead, so a job that
6+
promised full torch coverage cannot pass green while silently
7+
skipping all of it.
58
69
Rationale: on macOS CI we deliberately do NOT install PyTorch because
710
of the upstream NNPACK slowdown on Apple Silicon
@@ -25,11 +28,37 @@
2528
fixed PyTorch wheel is released.
2629
"""
2730

31+
import os
32+
import warnings
33+
2834
collect_ignore_glob = []
2935

3036
try:
3137
import torch # noqa: F401
32-
except ImportError:
38+
except (ImportError, OSError) as _torch_exc:
39+
# Absence and breakage are different states and must not be conflated.
40+
# Only a top-level ModuleNotFoundError for "torch" proves the package is
41+
# not installed; a broken install raises OSError (on Windows a partially
42+
# installed wheel fails with "[WinError 127] ... shm.dll") or a plain
43+
# ImportError from inside torch._C. Catching only ImportError used to abort
44+
# collection of the entire suite rather than skipping the torch-dependent
45+
# modules this guard exists to skip.
46+
#
47+
# Locally, a broken install degrades to a skip plus a loud warning. Under
48+
# CI it re-raises: testing.yml and testing-cron.yml install the full
49+
# dependency set on Linux and Windows and then run pytest with no torch
50+
# preflight, so a silent skip there would let a job that promised full
51+
# torch coverage pass green while running none of it.
52+
_torch_absent = (isinstance(_torch_exc, ModuleNotFoundError)
53+
and _torch_exc.name == "torch")
54+
if not _torch_absent:
55+
if os.environ.get("CI") == "true":
56+
raise
57+
warnings.warn(
58+
"torch is installed but failed to import ({!r}); skipping the "
59+
"torch-dependent test modules.".format(_torch_exc),
60+
RuntimeWarning,
61+
)
3362
# Test modules that import torch (or torch_geometric) at module
3463
# load time. Keep this list in sync with torch-dependent tests.
3564
collect_ignore_glob = [

pyod/test/test_cblof.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import os
44
import sys
55
import unittest
6+
import warnings
67

78
# noinspection PyProtectedMember
89
from numpy.testing import assert_allclose
10+
from numpy.testing import assert_array_equal
911
from numpy.testing import assert_array_less
1012
from numpy.testing import assert_equal
1113
from numpy.testing import assert_raises
@@ -184,9 +186,41 @@ def test_n_jobs_fit(self):
184186
clf_multi = CBLOF(contamination=self.contamination,
185187
random_state=42, n_jobs=4)
186188
clf_single.fit(self.X_train)
187-
clf_multi.fit(self.X_train)
189+
# Match the stable clause, not the removal version, so bumping the
190+
# announced removal release does not require touching this test.
191+
with self.assertWarnsRegex(
192+
FutureWarning, r"'n_jobs' parameter is deprecated"):
193+
clf_multi.fit(self.X_train)
188194
assert clf_multi.n_jobs == 4
189195
assert_equal(len(clf_multi.decision_scores_), self.X_train.shape[0])
196+
# Exact, not allclose: CHANGES.txt claims bit identity, and n_jobs
197+
# reaches nothing, so any drift at all would be a real defect.
198+
assert_array_equal(clf_multi.decision_scores_,
199+
clf_single.decision_scores_)
200+
201+
def test_n_jobs_default_no_warning(self):
202+
# Match only CBLOF's own message. Promoting every FutureWarning would
203+
# turn an unrelated future scikit-learn deprecation surfacing from
204+
# KMeans.fit into a red test whose failure names nothing about n_jobs.
205+
with warnings.catch_warnings():
206+
warnings.filterwarnings(
207+
'error', message="The 'n_jobs' parameter is deprecated",
208+
category=FutureWarning)
209+
clf = CBLOF(contamination=self.contamination,
210+
random_state=42, n_jobs=1)
211+
clf.fit(self.X_train)
212+
213+
def test_n_jobs_init_does_not_warn(self):
214+
# scikit-learn requires __init__ to only store its arguments, so the
215+
# deprecation must come from fit(). Without this, moving the warn into
216+
# __init__ leaves every other test green while breaking clone().
217+
with warnings.catch_warnings():
218+
warnings.filterwarnings(
219+
'error', message="The 'n_jobs' parameter is deprecated",
220+
category=FutureWarning)
221+
clf = CBLOF(contamination=self.contamination,
222+
random_state=42, n_jobs=4)
223+
clone(clf)
190224

191225
def tearDown(self):
192226
pass

pyod/test/test_data.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# -*- coding: utf-8 -*-
22

33

4+
import hashlib
45
import os
56
import sys
67
import unittest
@@ -10,6 +11,7 @@
1011
from numpy.testing import assert_allclose
1112
from numpy.testing import assert_equal
1213
from numpy.testing import assert_raises
14+
from sklearn.metrics import roc_auc_score
1315

1416
# temporary solution for relative imports in case pyod is not installed
1517
# if pyod is installed, no need to use the following line
@@ -98,9 +100,71 @@ def test_data_generate_outliers_have_spread(self):
98100
random_state=seed,
99101
)
100102
outliers = X[y == 1]
101-
assert outliers.var() > 0, \
103+
assert outliers.var(axis=0).min() > 0, \
102104
"outliers collapsed to zero variance for random_state=%d" % seed
103105

106+
def test_data_generate_float_offset(self):
107+
# A float offset in (1, 2) used to raise ValueError, because randint
108+
# truncates its bounds and the redraw got low == high. It is now drawn
109+
# continuously with a floor of 1.0. Assert separation, not merely a
110+
# non-zero variance: a spread test alone would pass on data whose
111+
# labelled outliers sit inside the inlier cloud.
112+
for offset in (1.1, 1.5, np.nextafter(2.0, 1.0)):
113+
with self.subTest(offset=offset):
114+
for seed in range(5):
115+
X, y = generate_data(
116+
n_features=2,
117+
contamination=0.05,
118+
train_only=True,
119+
offset=offset,
120+
random_state=seed,
121+
)
122+
outliers = X[y == 1]
123+
assert outliers.var(axis=0).min() > 0
124+
centrality = np.linalg.norm(X - X.mean(axis=0), axis=1)
125+
assert roc_auc_score(y, centrality) > 0.7, \
126+
"outliers are not separated for offset=%r seed=%d" % (
127+
offset, seed)
128+
129+
def test_data_generate_redraw_branches_bit_identical(self):
130+
# The existing golden test pins three default-offset seeds whose first
131+
# draw is non-zero, so it exercises neither branch this patch actually
132+
# touches. Both seeds below take the zero-then-redraw path (the first
133+
# randint returns 0): offset=1 hits the fixed offset_=1 branch, and
134+
# offset=2 hits the integer redraw. Hash the whole array rather than
135+
# comparing first and last rows with a tolerance, because the claim is
136+
# bit identity, and an inserted RNG draw would otherwise slip through.
137+
golden = {
138+
(1, 0): '674149faa5f4c205',
139+
(2, 0): 'd6fb10e1aba45e19',
140+
}
141+
for (offset, seed), digest in golden.items():
142+
with self.subTest(offset=offset, seed=seed):
143+
X, _ = generate_data(n_features=2, contamination=0.05,
144+
train_only=True, offset=offset,
145+
random_state=seed)[:2]
146+
# '<f8' is the canonical encoding for this pin: little-endian
147+
# float64. ascontiguousarray alone normalizes layout but keeps
148+
# native byte order, which would make the digest differ on a
149+
# big-endian host and reject a correct build.
150+
canonical = np.ascontiguousarray(X, dtype='<f8')
151+
actual = hashlib.sha256(
152+
canonical.tobytes(order='C')).hexdigest()[:16]
153+
assert actual == digest, (
154+
'generate_data(offset=%r, random_state=%r) changed: %s '
155+
'!= %s' % (offset, seed, actual, digest))
156+
157+
def test_data_generate_offset_below_one_rejected(self):
158+
# An offset below 1 must keep raising. coef_, the inlier spread, is
159+
# drawn from [0.001, 1.001) independently of offset, so an outlier box
160+
# of half-width < 1 lands inside an O(1) inlier cloud and the labelled
161+
# outliers become the densest points in the sample.
162+
for offset in (0.01, 0.5, np.nextafter(1.0, 0.0)):
163+
with self.subTest(offset=offset):
164+
with self.assertRaises(ValueError):
165+
generate_data(train_only=True, offset=offset,
166+
random_state=0)
167+
104168
def test_data_generate_reproducibility(self):
105169
# Golden values pinned from the pre-fix implementation for seeds whose
106170
# offset was already non-zero. Redrawing only when the offset comes out

0 commit comments

Comments
 (0)