Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
93 changes: 93 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# CI: test on every push/PR; build wheels + sdist on version tags.
#
# NOTE: scaffolding added with the 0.3.0 Cython port — review before first use.
# Publishing to PyPI uses trusted publishing (https://docs.pypi.org/trusted-publishers/);
# configure the "pypi" environment in the repo settings, or replace the publish
# job with a twine upload using an API token secret.

name: CI

on:
push:
branches: [main]
tags: ["v*"]
pull_request:

jobs:
tests:
name: pytest (${{ matrix.os }}, py${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12", "3.13"] # Consolidated versions
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install package (builds the Cython extension)
run: python -m pip install -e . pytest pytest-cov hypothesis
- name: Run tests
run: python -m pytest tests/ --cov=distclassipy --cov-report=xml
- name: Upload coverage report to codecov
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: sidchaini/DistClassiPy

sdist:
name: Build and test sdist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install build
- run: python -m build --sdist
# Installing from the sdist in a clean env catches MANIFEST/build-requires bugs
- name: Install from sdist and smoke-test
run: |
python -m pip install dist/*.tar.gz
python -c "import distclassipy, numpy as np; print(distclassipy.cdist(np.eye(3), np.ones((2, 3)), 'clark'))"
- uses: actions/upload-artifact@v4
with:
name: sdist
path: dist/*.tar.gz

wheels:
name: Build wheels (${{ matrix.os }})
runs-on: ${{ matrix.os }}
if: startsWith(github.ref, 'refs/tags/v')
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- name: Build wheels
uses: pypa/cibuildwheel@v2
env:
CIBW_BUILD: "cp310-* cp311-* cp312-* cp313-*"
CIBW_TEST_REQUIRES: pytest hypothesis
CIBW_TEST_COMMAND: python -m pytest {project}/tests -q
- uses: actions/upload-artifact@v4
with:
name: wheels-${{ matrix.os }}
path: wheelhouse/*.whl

publish:
name: Publish to PyPI
needs: [tests, sdist, wheels]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
environment: pypi
permissions:
id-token: write # trusted publishing
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- uses: pypa/gh-action-pypi-publish@release/v1
37 changes: 0 additions & 37 deletions .github/workflows/publish-to-pypi.yml

This file was deleted.

2 changes: 1 addition & 1 deletion .github/workflows/testing-and-coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.10", "3.11", "3.12", "3.13"]

runs-on: ${{ matrix.os }}

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

# Cython-generated C sources (built from the .pyx; never commit)
distclassipy/_cdistances.c

# Custom
distclassipy/__pycache__/
.specstory
Expand Down
3 changes: 3 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
include distclassipy/*.pyx
include distclassipy/*.pyi
include distclassipy/py.typed
102 changes: 102 additions & 0 deletions benchmarks/bench_cdist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Benchmark: Cython custom-metric kernels vs the old Python-callable path.

Compares, for every compiled custom metric:
- the old path: scipy.spatial.distance.cdist with a Python callable
- the new path: distclassipy._cdistances.cdist (compiled kernel)
against scipy's native C "euclidean" as the performance-parity baseline.

Also times DistanceAnomaly.decision_function end-to-end, before (callable
wrappers forcing the slow path) vs after (default string dispatch).

Run inside the dcpy environment:
python benchmarks/bench_cdist.py
"""

import time

import numpy as np
import scipy.spatial.distance

from distclassipy import distances
from distclassipy._cdistances import CYTHON_METRICS, cdist as cy_cdist


def best_of(fn, repeats=5):
times = []
for _ in range(repeats):
t0 = time.perf_counter()
fn()
times.append(time.perf_counter() - t0)
return min(times)


def main():
rng = np.random.default_rng(0)
XA = rng.uniform(0.01, 10.0, size=(10_000, 20))
XB = rng.uniform(0.01, 10.0, size=(5, 20))

t_scipy_euclidean = best_of(
lambda: scipy.spatial.distance.cdist(XA, XB, metric="euclidean")
)
print(f"Pairwise benchmark: XA={XA.shape}, XB={XB.shape}")
print(f"scipy 'euclidean' (C baseline): {t_scipy_euclidean * 1e3:8.2f} ms\n")

header = (
f"{'metric':<26}{'callable (ms)':>14}{'cython (ms)':>13}"
f"{'speedup':>9}{'vs scipy C':>11}"
)
print(header)
print("-" * len(header))

for name in sorted(CYTHON_METRICS):
ref = getattr(distances, name)

def slow(u, v, _f=ref):
return _f(u.copy(), v.copy()) # copies: some references mutate

t_old = best_of(
lambda: scipy.spatial.distance.cdist(XA, XB, metric=slow), repeats=2
)
t_new = best_of(lambda: cy_cdist(XA, XB, name))
print(
f"{name:<26}{t_old * 1e3:>14.1f}{t_new * 1e3:>13.2f}"
f"{t_old / t_new:>8.0f}x{t_new / t_scipy_euclidean:>10.1f}x"
)

# ----- end-to-end DistanceAnomaly -----
from distclassipy.anomaly import DistanceAnomaly
from distclassipy.distances import _UNIQUE_METRICS

X = rng.uniform(0.01, 10.0, size=(5_000, 10))
y = rng.integers(0, 4, size=5_000)

det = DistanceAnomaly()
det.fit(X, y)

t_fast = best_of(lambda: det.decision_function(X), repeats=3)

def wrap(name):
f = getattr(distances, name)

def slow(u, v, _f=f):
return _f(u.copy(), v.copy())

return slow

# Callables with unregistered names force the old scipy-callable path
slow_metrics = [
wrap(m) if m.lower() in CYTHON_METRICS else m for m in _UNIQUE_METRICS
]
det_slow = DistanceAnomaly(metrics=slow_metrics)
det_slow.fit(X, y)
t_slow = best_of(lambda: det_slow.decision_function(X), repeats=1)

print(f"\nDistanceAnomaly.decision_function on X={X.shape}, 4 classes,")
print(f"{len(_UNIQUE_METRICS)} default metrics:")
print(f" old (Python callables): {t_slow:8.2f} s")
print(f" new (Cython dispatch): {t_fast:8.2f} s")
print(f" speedup: {t_slow / t_fast:8.0f}x")


if __name__ == "__main__":
main()
15 changes: 11 additions & 4 deletions distclassipy/__init__.py
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
"""A module for using distance metrics for classification.
"""A module for using distance metrics for classification and anomaly detection.

Classes:
DistanceMetricClassifier - A classifier that uses a specified distance metric for
classification.
Distance - A class that provides various distance metrics for use in classification.
EnsembleDistanceClassifier - An ensemble classifier across distance metrics.
DistanceAnomaly - A multi-metric distance-based anomaly detector.

Functions:
cdist - scipy-style pairwise distances, supporting all custom metrics at C speed.


Copyright (C) 2024 Siddharth Chaini
Expand All @@ -22,18 +26,21 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""

from ._dispatch import cdist
from .anomaly import DistanceAnomaly
from .classifier import (
DistanceMetricClassifier,
EnsembleDistanceClassifier,
)
from .distances import _ALL_METRICS, _UNIQUE_METRICS

__version__ = "0.2.3"
__version__ = "0.4.0"

__all__ = [
"DistanceMetricClassifier",
"EnsembleDistanceClassifier",
"Distance",
"DistanceAnomaly",
"cdist",
"_ALL_METRICS",
"_UNIQUE_METRICS",
]
Loading
Loading