Skip to content

Commit 11e3b9b

Browse files
committed
move tests from unittest to pytest
1 parent 34b24b8 commit 11e3b9b

29 files changed

Lines changed: 4450 additions & 4545 deletions

.github/copilot-instructions.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# scikit-matter Development Guide
2+
3+
## Overview
4+
scikit-matter is a scikit-learn-compatible toolbox of methods from computational chemistry and materials science. All estimators follow sklearn API conventions (fit/transform/predict) and inherit from sklearn base classes.
5+
6+
## Architecture
7+
8+
### Core Selection Framework
9+
The codebase centers on a unique **dual-selection architecture** split across `feature_selection/` and `sample_selection/` with shared implementation in `_selection.py`:
10+
11+
- **`_selection.py`**: Contains base classes (`GreedySelector`, `_CUR`, `_FPS`, `_PCovCUR`, `_PCovFPS`) that implement core algorithms independent of axis
12+
- **`feature_selection/_base.py`**: Thin wrappers that instantiate base classes with `selection_type="feature"` and inherit from `SelectorMixin` (enables `transform()`)
13+
- **`sample_selection/_base.py`**: Thin wrappers with `selection_type="sample"` - return indices via `selected_idx_` attribute (no `transform()`)
14+
15+
Example: `FPS` (Farthest Point Sampling) exists as both `feature_selection.FPS` and `sample_selection.FPS`, sharing the same `_FPS` implementation but differing only in which axis they select along.
16+
17+
### Module Organization
18+
- **`decomposition/`**: PCovR (Principal Covariates Regression) and variants - supervised dimensionality reduction combining PCA-like and regression objectives
19+
- **`linear_model/`**: `OrthogonalRegression`, `Ridge2FoldCV` (custom 2-fold CV for efficiency)
20+
- **`metrics/`**: Reconstruction measures (GRE, GRD, LRE) and prediction rigidities (LPR, CPR)
21+
- **`preprocessing/`**: `StandardFlexibleScaler`, `SparseKernelCenterer` with column-wise scaling options
22+
- **`utils/`**: Orthogonalizers, PCovR utilities, progress bar helpers
23+
- **`datasets/`**: Chemistry/materials datasets (CSD-1000r, CH4 manifolds, etc.)
24+
25+
## Development Workflows
26+
27+
### Testing
28+
```bash
29+
# Run all tests with coverage
30+
tox -e tests
31+
32+
# Run specific test file
33+
tox -e tests -- tests/test_feature_simple_cur.py
34+
35+
# Run tests against sklearn dev version
36+
tox -e tests-dev
37+
```
38+
39+
Tests use pytest-style assertions and fixtures. Common patterns:
40+
- Use `@pytest.fixture` for test data setup
41+
- Use `assert` statements instead of `self.assertEqual()`
42+
- Use `pytest.raises()` for exception testing
43+
- Use `pytest.warns()` for warning testing
44+
- Use `pytest.mark.parametrize` for parameterized tests
45+
- Tests often load datasets via `skmatter.datasets.load_*()`
46+
47+
### Linting & Formatting
48+
```bash
49+
# Check only (CI mode)
50+
tox -e lint
51+
52+
# Auto-format code
53+
tox -e format
54+
55+
# More aggressive fixes (review changes carefully)
56+
tox -e format-unsafe
57+
```
58+
59+
Uses `ruff` for both formatting and linting. Configuration in `pyproject.toml` ignores F401 (unused imports in `__init__.py`).
60+
61+
### Building Docs
62+
```bash
63+
tox -e docs # Builds HTML docs, runs examples via sphinx-gallery
64+
```
65+
66+
Documentation uses Sphinx with `.rst` format. Examples in `examples/` are executed during doc builds.
67+
68+
### Building Package
69+
```bash
70+
tox -e build # Builds wheel and sdist, runs check-manifest and twine check
71+
```
72+
73+
Uses `setuptools_scm` for versioning from git tags. Version file auto-generated at `src/skmatter/_version.py`.
74+
75+
## Key Conventions
76+
77+
### scikit-learn Compliance
78+
- All estimators inherit from appropriate sklearn mixins (`RegressorMixin`, `TransformerMixin`, `SelectorMixin`)
79+
- Use `validate_data()` (not deprecated `check_X_y()`) for input validation
80+
- Implement `fit()` returning `self`, store fitted attributes with trailing underscore (`selected_idx_`, `n_selected_`)
81+
- Support `warm_start` parameter in selectors to continue from previous fit
82+
83+
### Selection Methods Patterns
84+
```python
85+
# Feature selection (returns transformed X)
86+
from skmatter.feature_selection import CUR, FPS, PCovCUR, PCovFPS
87+
selector = CUR(n_to_select=10, progress_bar=True)
88+
X_reduced = selector.fit(X).transform(X)
89+
90+
# Sample selection (returns indices)
91+
from skmatter.sample_selection import CUR
92+
selector = CUR(n_to_select=10)
93+
selector.fit(X)
94+
X_subset = X[selector.selected_idx_]
95+
```
96+
97+
### PCovR Methods
98+
Always center and scale inputs (`StandardFlexibleScaler`) before using PCovR/PCovC - results change drastically near α→0 or α→1 otherwise. Use `column_wise=True` when features are comparable.
99+
100+
### Progress Bars
101+
Optional `tqdm` progress bar via `progress_bar=True` parameter. Implementation uses utility functions `get_progress_bar()` / `no_progress_bar()` from `utils/`.
102+
103+
## Dependencies & Python Support
104+
- **Python**: 3.11+ (as of v0.3.3)
105+
- **Core**: scikit-learn 1.8.x, scipy ≥1.15
106+
- **Optional**: matplotlib, pandas, tqdm (for examples)
107+
- **Testing**: Requires Python 3.11 and 3.14 on Ubuntu, macOS, Windows
108+
109+
## Pull Request Requirements
110+
- Update tests for new features/bugfixes
111+
- Update documentation for new features
112+
- Reference issue numbers in PR description
113+
- Reviewer updates CHANGELOG for important changes (not contributor)
114+
115+
## Common Pitfalls
116+
- Don't use deprecated sklearn APIs - check sklearn version in `pyproject.toml`
117+
- Selection methods: feature vs sample selection use same algorithms but different interfaces
118+
- PCovR requires pre-scaled data - document this in examples
119+
- Test files use pytest - use fixtures, `assert`, `pytest.raises()`, not unittest classes

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,6 @@ convention = "numpy"
122122
"D205",
123123
"D400",
124124
]
125+
"tests/**" = [
126+
"D103",
127+
]

tests/test_clustering.py

Lines changed: 68 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,73 @@
1-
import unittest
2-
31
import numpy as np
2+
import pytest
43

54
from skmatter.clustering import QuickShift
65

76

8-
class QuickShiftTests(unittest.TestCase):
9-
@classmethod
10-
def setUpClass(cls) -> None:
11-
cls.points = np.array(
12-
[
13-
[-1.72779275, -1.32763554],
14-
[-4.44991964, -2.13474901],
15-
[0.54817734, -2.43319467],
16-
[3.19881307, -0.49547222],
17-
[-1.1335991, 2.33478428],
18-
[0.55437388, 0.18745963],
19-
]
20-
)
21-
cls.cuts = np.array(
22-
[6.99485011, 8.80292681, 7.68486852, 9.5115009, 8.07736919, 6.22057056]
23-
)
24-
cls.weights = np.array(
25-
[
26-
-3.94008092,
27-
-12.68095664,
28-
-7.07512499,
29-
-9.03064023,
30-
-8.26529849,
31-
-2.61132267,
32-
]
33-
)
34-
cls.qs_labels_ = np.array([0, 0, 0, 5, 5, 5])
35-
cls.qs_cluster_centers_idx_ = np.array([0, 5])
36-
cls.gabriel_labels_ = np.array([5, 5, 5, 5, 5, 5])
37-
cls.gabriel_cluster_centers_idx_ = np.array([5])
38-
cls.cell = [3, 3]
39-
cls.gabriel_shell = 2
40-
41-
def test_fit_qs(self):
42-
model = QuickShift(dist_cutoff_sq=self.cuts)
43-
model.fit(self.points, samples_weight=self.weights)
44-
self.assertTrue(np.all(model.labels_ == self.qs_labels_))
45-
self.assertTrue(
46-
np.all(model.cluster_centers_idx_ == self.qs_cluster_centers_idx_)
47-
)
48-
49-
def test_fit_garbriel(self):
50-
model = QuickShift(gabriel_shell=self.gabriel_shell)
51-
model.fit(self.points, samples_weight=self.weights)
52-
self.assertTrue(np.all(model.labels_ == self.gabriel_labels_))
53-
self.assertTrue(
54-
np.all(model.cluster_centers_idx_ == self.gabriel_cluster_centers_idx_)
55-
)
56-
57-
def test_dimension_check(self):
58-
model = QuickShift(self.cuts, metric_params={"cell_length": self.cell})
59-
self.assertRaises(ValueError, model.fit, np.array([[2]]))
7+
@pytest.fixture(scope="module")
8+
def test_data():
9+
points = np.array(
10+
[
11+
[-1.72779275, -1.32763554],
12+
[-4.44991964, -2.13474901],
13+
[0.54817734, -2.43319467],
14+
[3.19881307, -0.49547222],
15+
[-1.1335991, 2.33478428],
16+
[0.55437388, 0.18745963],
17+
]
18+
)
19+
cuts = np.array(
20+
[6.99485011, 8.80292681, 7.68486852, 9.5115009, 8.07736919, 6.22057056]
21+
)
22+
weights = np.array(
23+
[
24+
-3.94008092,
25+
-12.68095664,
26+
-7.07512499,
27+
-9.03064023,
28+
-8.26529849,
29+
-2.61132267,
30+
]
31+
)
32+
qs_labels_ = np.array([0, 0, 0, 5, 5, 5])
33+
qs_cluster_centers_idx_ = np.array([0, 5])
34+
gabriel_labels_ = np.array([5, 5, 5, 5, 5, 5])
35+
gabriel_cluster_centers_idx_ = np.array([5])
36+
cell = [3, 3]
37+
gabriel_shell = 2
38+
39+
return {
40+
"points": points,
41+
"cuts": cuts,
42+
"weights": weights,
43+
"qs_labels_": qs_labels_,
44+
"qs_cluster_centers_idx_": qs_cluster_centers_idx_,
45+
"gabriel_labels_": gabriel_labels_,
46+
"gabriel_cluster_centers_idx_": gabriel_cluster_centers_idx_,
47+
"cell": cell,
48+
"gabriel_shell": gabriel_shell,
49+
}
50+
51+
52+
def test_fit_qs(test_data):
53+
model = QuickShift(dist_cutoff_sq=test_data["cuts"])
54+
model.fit(test_data["points"], samples_weight=test_data["weights"])
55+
assert np.all(model.labels_ == test_data["qs_labels_"])
56+
assert np.all(model.cluster_centers_idx_ == test_data["qs_cluster_centers_idx_"])
57+
58+
59+
def test_fit_garbriel(test_data):
60+
model = QuickShift(gabriel_shell=test_data["gabriel_shell"])
61+
model.fit(test_data["points"], samples_weight=test_data["weights"])
62+
assert np.all(model.labels_ == test_data["gabriel_labels_"])
63+
assert np.all(
64+
model.cluster_centers_idx_ == test_data["gabriel_cluster_centers_idx_"]
65+
)
66+
67+
68+
def test_dimension_check(test_data):
69+
model = QuickShift(
70+
test_data["cuts"], metric_params={"cell_length": test_data["cell"]}
71+
)
72+
with pytest.raises(ValueError):
73+
model.fit(np.array([[2]]))

0 commit comments

Comments
 (0)