Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d9c90e5
Merge pull request #87 from vc1492a/dev
vc1492a May 26, 2026
098983b
Merge pull request #88 from vc1492a/dev
vc1492a Jun 13, 2026
f8137df
Merge pull request #89 from vc1492a/dev
vc1492a Jun 13, 2026
0247fbe
Merge pull request #90 from vc1492a/dev
vc1492a Jun 13, 2026
9165c7a
feat: adopt scikit-learn API conventions for 1.0.0
vc1492a Jun 16, 2026
5eeeff5
refactor: split loop.py into focused modules using mixins
vc1492a Jun 16, 2026
b6d1814
fix: clear stale inputs on re-fit and accept DataFrame in type checker
vc1492a Jun 16, 2026
ad98c18
include scikit learn estimator-check in unit tests
SaritNike Jun 26, 2026
c852bac
Refactor LocalOutlierProbability for full scikit-learn compliance
SaritNike Jul 26, 2026
f92516e
Complete sklearn API compliance with optional dependency.
vc1492a Jul 28, 2026
fa903ec
Add sparse matrix support tests and document scipy soft dependency.
vc1492a Jul 28, 2026
80c8f44
Fix stream() return type to float and document the change.
vc1492a Aug 25, 2026
9f74189
Prevent 2-d label y from being hijacked as a legacy distance matrix.
vc1492a Aug 25, 2026
5983473
Update examples and docs for the sklearn-style fit API.
vc1492a Aug 25, 2026
71e3007
updated member variable names in api doc
SaritNike Sep 4, 2026
0ef6867
add public API consistency test
SaritNike Sep 4, 2026
522ac3f
Fix F821 for optional pandas type hints in _validation.py.
vc1492a Sep 16, 2026
86e83db
Update docs examples to pass data to fit() instead of the constructor.
vc1492a Sep 16, 2026
54c1c63
Re-export exception classes from PyNomaly.loop for backward compatibi…
vc1492a Sep 16, 2026
7082a5f
Run CI on all branches and PRs and make the flake8 error gate fail th…
vc1492a Sep 16, 2026
1444ef4
Exclude worktrees from pytest and flake8 and ignore it in git.
vc1492a Sep 16, 2026
d9f764f
Skip the check_estimator test on scikit-learn versions without expect…
vc1492a Sep 16, 2026
6a1b95f
Drop Python 3.8 support and require Python 3.9 or newer.
vc1492a Sep 17, 2026
868a381
Remove the scikit-learn version skip from the check_estimator test.
vc1492a Sep 17, 2026
67c98c2
Add a regression test that fit(X, y) ignores y of any label shape.
vc1492a Sep 22, 2026
08b05a6
Merge branch 'sklearn-api-compliance' into feat/api-regression-test
vc1492a Sep 22, 2026
30b4c28
Update the public API consistency test to the 1.0.0 fit-based API.
vc1492a Sep 22, 2026
43ea6e4
Merge pull request #93 from vc1492a/feat/api-regression-test
vc1492a Sep 22, 2026
a35b62e
Merge pull request #92 from vc1492a/sklearn-api-compliance
vc1492a Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ name: tests

on:
push:
branches: [ "main", "dev" ]
branches: [ "**" ]
pull_request:
branches: [ "main", "dev" ]

jobs:
test:
Expand All @@ -16,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]

steps:
- uses: actions/checkout@v4
Expand All @@ -33,7 +32,7 @@ jobs:
- name: Lint with flake8
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --exit-zero --select=E9,F63,F7,F82 --show-source --statistics
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,5 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

# Pane worktrees (local only)
worktrees/
6 changes: 6 additions & 0 deletions PyNomaly/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

from PyNomaly.loop import (
LocalOutlierProbability,
LoOP,
__version__,
)
from PyNomaly.exceptions import (
PyNomalyError,
ValidationError,
ClusterSizeError,
Expand All @@ -11,8 +15,10 @@

__all__ = [
"LocalOutlierProbability",
"LoOP",
"PyNomalyError",
"ValidationError",
"ClusterSizeError",
"MissingValuesError",
"__version__",
]
198 changes: 198 additions & 0 deletions PyNomaly/_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
# Authors: Valentino Constantinou <vc@valentino.io>
# License: Apache 2.0

"""
Optional scikit-learn integration.

When scikit-learn is installed, LocalOutlierProbability inherits from
``BaseEstimator`` and ``OutlierMixin`` for full ecosystem compatibility
(pipelines, grid search, ``check_estimator``).

When it is not installed, lightweight fallbacks provide the same public
surface (``get_params``, ``set_params``, ``fit_predict``, ``check_is_fitted``)
so PyNomaly works as a standalone package.

Install the optional dependency with::

pip install PyNomaly[sklearn]
"""

from __future__ import annotations

import inspect

try:
from sklearn.base import BaseEstimator, OutlierMixin
from sklearn.exceptions import NotFittedError
from sklearn.utils.validation import check_array, check_is_fitted

_SKLEARN_AVAILABLE = True
except ImportError: # pragma: no cover - exercised only without sklearn
_SKLEARN_AVAILABLE = False

class NotFittedError(ValueError, AttributeError):
"""Raised when an estimator method is used before fit."""

class BaseEstimator:
"""Minimal BaseEstimator providing get_params / set_params."""

def get_params(self, deep=True):
params = {}
for name, param in inspect.signature(self.__init__).parameters.items():
if name == "self":
continue
params[name] = getattr(self, name, param.default)
return params

def set_params(self, **params):
if not params:
return self
valid = self.get_params(deep=True)
for key, value in params.items():
if key not in valid:
raise ValueError(
f"Invalid parameter {key!r} for estimator "
f"{type(self).__name__}. Valid parameters are: "
f"{sorted(valid)!r}."
)
setattr(self, key, value)
return self

def __repr__(self):
params = self.get_params(deep=False)
filtered = {
k: v
for k, v in params.items()
if v is not inspect.Parameter.empty
}
inner = ", ".join(f"{k}={v!r}" for k, v in filtered.items())
return f"{type(self).__name__}({inner})"

class OutlierMixin:
"""Minimal OutlierMixin providing fit_predict."""

def fit_predict(self, X, y=None, **fit_params):
return self.fit(X, y, **fit_params).predict(X)

def check_is_fitted(estimator, attributes=None, *, msg=None):
if attributes is None:
fitted = [
a
for a in vars(estimator)
if a.endswith("_") and not a.startswith("__")
]
if not fitted:
raise NotFittedError(
msg
or (
f"This {type(estimator).__name__} instance is not "
f"fitted yet. Call 'fit' with appropriate arguments "
f"before using this estimator."
)
)
return

if isinstance(attributes, str):
attributes = [attributes]
missing = [a for a in attributes if not hasattr(estimator, a)]
if missing:
raise NotFittedError(
msg
or (
f"This {type(estimator).__name__} instance is not fitted "
f"yet. Call 'fit' with appropriate arguments before using "
f"this estimator."
)
)

def check_array(
array,
*,
accept_sparse=False,
dtype="numeric",
ensure_2d=True,
ensure_min_samples=1,
ensure_min_features=1,
copy=False,
**kwargs,
):
"""Fallback input validator approximating sklearn.utils.check_array."""
import numpy as np

try:
from scipy.sparse import issparse
except ImportError:

def issparse(x):
return False

if issparse(array):
if accept_sparse is False or accept_sparse == []:
raise TypeError(
"A sparse matrix was passed, but dense data is required. "
"Use X.toarray() to convert to a dense numpy array."
)
array = array.toarray()

if hasattr(array, "values") and hasattr(array, "columns"):
array = array.values

arr = np.asarray(array)
if copy:
arr = np.array(arr, copy=True)

if ensure_2d and arr.ndim == 1:
raise ValueError(
"Expected 2D array, got 1D array instead:\n"
f"array={arr}.\n"
"Reshape your data either using array.reshape(-1, 1) if "
"your data has a single feature or array.reshape(1, -1) if "
"it contains a single sample."
)
if ensure_2d and arr.ndim != 2:
raise ValueError(
f"Expected 2D array, got array with ndim={arr.ndim} instead."
)

if arr.size == 0:
n_samples = arr.shape[0] if arr.ndim >= 1 else 0
n_features = arr.shape[1] if arr.ndim >= 2 else 0
if n_samples < ensure_min_samples:
raise ValueError(
f"Found array with {n_samples} sample(s) "
f"(shape={arr.shape}) while a minimum of "
f"{ensure_min_samples} is required."
)
if n_features < ensure_min_features:
raise ValueError(
f"Found array with {n_features} feature(s) "
f"(shape={arr.shape}) while a minimum of "
f"{ensure_min_features} is required."
)

if np.iscomplexobj(arr):
raise ValueError("Complex data not supported")

if dtype == "numeric":
try:
arr = arr.astype(float, copy=False)
except (ValueError, TypeError) as exc:
raise ValueError(
"Unable to convert array of strings to floating numbers."
) from exc

if ensure_2d:
if arr.shape[0] < ensure_min_samples:
raise ValueError(
f"Found array with {arr.shape[0]} sample(s) "
f"(shape={arr.shape}) while a minimum of "
f"{ensure_min_samples} is required."
)
if arr.shape[1] < ensure_min_features:
raise ValueError(
f"Found array with {arr.shape[1]} feature(s) "
f"(shape={arr.shape}) while a minimum of "
f"{ensure_min_features} is required."
)

return arr
Loading
Loading