Skip to content

Commit b9c0388

Browse files
authored
Merge pull request #727 from yzhao062/development
v3.6.5: contributor bug fixes plus review follow-ups
2 parents 3d0169a + 03700bb commit b9c0388

9 files changed

Lines changed: 437 additions & 27 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 & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,15 @@ class CBLOF(BaseDetector):
9292
RandomState instance used by `np.random`.
9393
9494
n_jobs : int, optional (default=1)
95-
Accepted for API compatibility but currently unused: the value is
96-
neither stored on the estimator nor forwarded to the clustering
97-
step, so ``get_params()`` reports ``None`` for it and ``clone()``
98-
does not carry it over. See issue #713.
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.
99104
100105
Attributes
101106
----------
@@ -149,6 +154,7 @@ def __init__(self, n_clusters=8, contamination=0.1,
149154
self.use_weights = use_weights
150155
self.check_estimator = check_estimator
151156
self.random_state = random_state
157+
self.n_jobs = n_jobs
152158

153159
# noinspection PyIncorrectDocstring
154160
def fit(self, X, y=None):
@@ -168,6 +174,16 @@ def fit(self, X, y=None):
168174
Fitted estimator.
169175
"""
170176

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+
171187
# validate inputs X and y (optional)
172188
X = check_array(X)
173189
self._set_n_classes(y)
@@ -176,8 +192,7 @@ def fit(self, X, y=None):
176192
# check parameters
177193
# number of clusters are default to 8
178194
self._validate_estimator(default=KMeans(
179-
n_clusters=self.n_clusters,
180-
random_state=self.random_state))
195+
n_clusters=self.n_clusters, random_state=self.random_state))
181196

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

pyod/models/rod.py

Lines changed: 54 additions & 7 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,11 +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-
gammas, scaler1, scaler2 = scale_angles(
203-
np.arccos(np.clip(np.dot(_x, gm) / (v_norm * norm_), -1, 1)),
204-
scaler1=scaler1, scaler2=scaler2)
205-
# apply the ROD main equation to find the rotation costs
206-
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)))
207254
# apply MAD to calculate the decision scores
208255
decision_scores, median = mad(costs, median=median)
209256
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: 56 additions & 0 deletions
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
@@ -166,6 +168,60 @@ def test_predict_rank_normalized(self):
166168
def test_model_clone(self):
167169
clone_clf = clone(self.clf)
168170

171+
def test_n_jobs_stored(self):
172+
# n_jobs must be stored so get_params() and clone() carry it over
173+
clf = CBLOF(n_jobs=4)
174+
assert clf.n_jobs == 4
175+
assert clf.get_params()['n_jobs'] == 4
176+
177+
def test_n_jobs_clone(self):
178+
clf = CBLOF(n_jobs=4)
179+
cloned = clone(clf)
180+
assert cloned.n_jobs == 4
181+
182+
def test_n_jobs_fit(self):
183+
# CBLOF(n_jobs=4) must fit and produce results identical to n_jobs=1
184+
clf_single = CBLOF(contamination=self.contamination,
185+
random_state=42, n_jobs=1)
186+
clf_multi = CBLOF(contamination=self.contamination,
187+
random_state=42, n_jobs=4)
188+
clf_single.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)
194+
assert clf_multi.n_jobs == 4
195+
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)
224+
169225
def tearDown(self):
170226
pass
171227

pyod/test/test_data.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from numpy.testing import assert_allclose
1111
from numpy.testing import assert_equal
1212
from numpy.testing import assert_raises
13+
from sklearn.metrics import roc_auc_score
1314

1415
# temporary solution for relative imports in case pyod is not installed
1516
# if pyod is installed, no need to use the following line
@@ -84,6 +85,105 @@ def test_data_generate3(self):
8485
assert_allclose(y_train, y_train2)
8586
assert_allclose(y_test, y_test2)
8687

88+
def test_data_generate_outliers_have_spread(self):
89+
# Regression test for GH #141: for certain seeds the internal offset
90+
# was drawn as 0, which collapsed every outlier onto the origin
91+
# (uniform(-0, 0) == 0) and produced zero-variance outliers. Sweep a
92+
# range of seeds (41, 48 and 50 previously triggered the collapse)
93+
# and confirm outliers always keep a non-zero spread.
94+
for seed in range(60):
95+
X, y = generate_data(
96+
n_features=2,
97+
contamination=0.05,
98+
train_only=True,
99+
random_state=seed,
100+
)
101+
outliers = X[y == 1]
102+
assert outliers.var(axis=0).min() > 0, \
103+
"outliers collapsed to zero variance for random_state=%d" % seed
104+
105+
def test_data_generate_float_offset(self):
106+
# A float offset in (1, 2) used to raise ValueError, because randint
107+
# truncates its bounds and the redraw got low == high. It is now drawn
108+
# continuously with a floor of 1.0. Assert separation, not merely a
109+
# non-zero variance: a spread test alone would pass on data whose
110+
# labelled outliers sit inside the inlier cloud.
111+
for offset in (1.1, 1.5, np.nextafter(2.0, 1.0)):
112+
with self.subTest(offset=offset):
113+
for seed in range(5):
114+
X, y = generate_data(
115+
n_features=2,
116+
contamination=0.05,
117+
train_only=True,
118+
offset=offset,
119+
random_state=seed,
120+
)
121+
outliers = X[y == 1]
122+
assert outliers.var(axis=0).min() > 0
123+
centrality = np.linalg.norm(X - X.mean(axis=0), axis=1)
124+
assert roc_auc_score(y, centrality) > 0.7, \
125+
"outliers are not separated for offset=%r seed=%d" % (
126+
offset, seed)
127+
128+
def test_data_generate_redraw_branches_stable(self):
129+
# The other golden test pins three default-offset seeds whose first
130+
# draw is non-zero, so it exercises neither branch the GH #141 fix
131+
# actually touches. Both seeds below take the zero-then-redraw path
132+
# (the first randint returns 0): offset=1 hits the fixed offset_=1
133+
# branch, and offset=2 hits the integer redraw.
134+
#
135+
# Pin an array-wide sum alongside the first and last rows, with a
136+
# tolerance, rather than hashing the raw bytes. A byte-exact pin is
137+
# not portable: randn draws Gaussians by the polar method, whose log
138+
# is not required to be correctly rounded, so libm implementations
139+
# disagree in the last ulp and the same seed yields different low bits
140+
# on macOS than on Linux or Windows. The sum still covers every element,
141+
# so an inserted or reordered RNG draw, which moves values by O(1),
142+
# cannot hide inside this tolerance.
143+
golden = {
144+
(1, 0): (1888.646949950,
145+
[1.407737153, 1.853812935], [0.716955137, -0.505085628]),
146+
(2, 0): (1907.119693504,
147+
[1.070368485, 1.067679162], [-0.467865797, 0.251365755]),
148+
}
149+
for (offset, seed), (total, first, last) in golden.items():
150+
with self.subTest(offset=offset, seed=seed):
151+
X, _ = generate_data(n_features=2, contamination=0.05,
152+
train_only=True, offset=offset,
153+
random_state=seed)[:2]
154+
assert_equal(X.shape, (1000, 2))
155+
assert_allclose(X.sum(), total, rtol=0, atol=1e-6)
156+
assert_allclose(X[0], first, rtol=0, atol=1e-9)
157+
assert_allclose(X[-1], last, rtol=0, atol=1e-9)
158+
159+
def test_data_generate_offset_below_one_rejected(self):
160+
# An offset below 1 must keep raising. coef_, the inlier spread, is
161+
# drawn from [0.001, 1.001) independently of offset, so an outlier box
162+
# of half-width < 1 lands inside an O(1) inlier cloud and the labelled
163+
# outliers become the densest points in the sample.
164+
for offset in (0.01, 0.5, np.nextafter(1.0, 0.0)):
165+
with self.subTest(offset=offset):
166+
with self.assertRaises(ValueError):
167+
generate_data(train_only=True, offset=offset,
168+
random_state=0)
169+
170+
def test_data_generate_reproducibility(self):
171+
# Golden values pinned from the pre-fix implementation for seeds whose
172+
# offset was already non-zero. Redrawing only when the offset comes out
173+
# as 0 leaves these untouched, so this guards against a future change
174+
# silently altering long-standing fixed-seed output.
175+
golden = {
176+
0: ([5.059894904, 5.061739412], [-0.263919547, 3.009107520]),
177+
1: ([3.401186423, 3.524852969], [0.181525489, 3.650202520]),
178+
42: ([6.433658544, 5.509168303], [-3.206743915, -4.912722786]),
179+
}
180+
for seed, (first, last) in golden.items():
181+
X, _ = generate_data(n_train=10, n_test=5, n_features=2,
182+
contamination=0.2, train_only=True,
183+
random_state=seed)
184+
assert_allclose(X[0], first, rtol=0, atol=1e-9)
185+
assert_allclose(X[-1], last, rtol=0, atol=1e-9)
186+
87187
def test_data_generate_cluster(self):
88188
X_train, X_test, y_train, y_test = \
89189
generate_data_clusters(n_train=self.n_train,

0 commit comments

Comments
 (0)