i-attp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- i_attp-0.1.0/.github/workflows/publish.yml +40 -0
- i_attp-0.1.0/.gitignore +7 -0
- i_attp-0.1.0/PKG-INFO +117 -0
- i_attp-0.1.0/README.md +88 -0
- i_attp-0.1.0/pyproject.toml +56 -0
- i_attp-0.1.0/sample.py +15 -0
- i_attp-0.1.0/src/attp/__init__.py +3 -0
- i_attp-0.1.0/src/attp/_split.py +326 -0
- i_attp-0.1.0/src/attp/py.typed +0 -0
- i_attp-0.1.0/tests/test_split.py +103 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
permissions: {}
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.x"
|
|
19
|
+
- run: python -m pip install build
|
|
20
|
+
- run: python -m build
|
|
21
|
+
- uses: actions/upload-artifact@v4
|
|
22
|
+
with:
|
|
23
|
+
name: python-package-distributions
|
|
24
|
+
path: dist/
|
|
25
|
+
|
|
26
|
+
publish:
|
|
27
|
+
needs: build
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
environment:
|
|
30
|
+
name: pypi
|
|
31
|
+
url: https://pypi.org/p/i-attp
|
|
32
|
+
permissions:
|
|
33
|
+
id-token: write
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/download-artifact@v4
|
|
36
|
+
with:
|
|
37
|
+
name: python-package-distributions
|
|
38
|
+
path: dist/
|
|
39
|
+
merge-multiple: true
|
|
40
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
i_attp-0.1.0/.gitignore
ADDED
i_attp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: i-attp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: S-ATTP: cluster-stratified train/test splitting on an LDA-reduced feature space.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Jabualani/ATTP
|
|
6
|
+
Project-URL: Repository, https://github.com/Jabualani/ATTP
|
|
7
|
+
Project-URL: Issues, https://github.com/Jabualani/ATTP/issues
|
|
8
|
+
Author-email: Jabulani Monchwe <MonchweJ.25@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: kmeans,lda,machine-learning,stratification,train-test-split
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: numpy>=1.23
|
|
23
|
+
Requires-Dist: pandas>=1.5
|
|
24
|
+
Requires-Dist: scikit-learn>=1.2
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
27
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# sattp
|
|
31
|
+
|
|
32
|
+
Cluster-stratified train/test splitting on an LDA-reduced feature space (S-ATTP).
|
|
33
|
+
|
|
34
|
+
Instead of stratifying on the class label alone, `sattp` also stratifies on *where a row sits in
|
|
35
|
+
feature space*, so the test set covers the same sub-populations as the train set.
|
|
36
|
+
|
|
37
|
+
## How it works
|
|
38
|
+
|
|
39
|
+
1. Reduce the features with **LDA**, using the fewest components that reach 85% of the explained
|
|
40
|
+
variance (configurable).
|
|
41
|
+
2. For each candidate cluster count, run **K-Means** once on the LDA components and reuse those
|
|
42
|
+
clusters across candidate train fractions (`MiniBatchKMeans` above 50,000 rows).
|
|
43
|
+
3. Build strata from `class × cluster` and do a stratified split. Strata with a single member
|
|
44
|
+
can't be split, so those rows always go to the train set.
|
|
45
|
+
4. Score a classifier on every `(train fraction, n_clusters)` pair and return the split with the
|
|
46
|
+
best test score.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install i-attp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Or install directly from GitHub:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install git+https://github.com/Jabualani/ATTP.git
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
For development:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
git clone https://github.com/Jabualani/ATTP.git
|
|
64
|
+
cd ATTP
|
|
65
|
+
pip install -e ".[dev]"
|
|
66
|
+
pytest
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Usage
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from attp import sattp_split
|
|
73
|
+
|
|
74
|
+
result = sattp_split(
|
|
75
|
+
X, y,
|
|
76
|
+
splits=[0.7, 0.75, 0.8], # candidate train fractions
|
|
77
|
+
min_clusters=2,
|
|
78
|
+
max_clusters=10,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
X_train, y_train, X_test, y_test = result # unpacks like a 4-tuple
|
|
82
|
+
print(result.best_split, result.best_n_clusters, result.n_lda_components)
|
|
83
|
+
print(result.metrics.head()) # every candidate, best first
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Row labels of the original `X`/`y` are preserved in the outputs.
|
|
87
|
+
|
|
88
|
+
Useful options: `model=` (any scikit-learn classifier used for scoring; defaults to LDA),
|
|
89
|
+
`scoring=` (custom `(fitted_model, X, y) -> float`), `variance_threshold=`, `random_state=`,
|
|
90
|
+
`verbose=True`. Set `DR_Comps_Min=` and `DR_Comps_Max=` to choose the LDA component range.
|
|
91
|
+
|
|
92
|
+
### Original function signature
|
|
93
|
+
|
|
94
|
+
`S_ATTP` keeps the original call signature for existing code:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from attp import S_ATTP
|
|
98
|
+
|
|
99
|
+
X_tr, y_tr, X_te, y_te = S_ATTP(
|
|
100
|
+
X_df, y_df, "target_col", [0.7, 0.8], 2, 10, 1, 3, None, "frames", False, False
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
`return_type="indices"` returns `(train_index, test_index)`, and
|
|
105
|
+
`return_split_metadata=True` appends the metrics DataFrame. If no valid split exists it returns
|
|
106
|
+
`None`s, like the original.
|
|
107
|
+
|
|
108
|
+
## Caveats
|
|
109
|
+
|
|
110
|
+
- LDA is fitted on the **whole** dataset (using all labels) before splitting, and the winning
|
|
111
|
+
split is chosen by its **test** score. Both make the reported test score optimistic. Treat it as
|
|
112
|
+
a way to build a representative split, not as an unbiased performance estimate.
|
|
113
|
+
- Features must be numeric with no missing values.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
i_attp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# sattp
|
|
2
|
+
|
|
3
|
+
Cluster-stratified train/test splitting on an LDA-reduced feature space (S-ATTP).
|
|
4
|
+
|
|
5
|
+
Instead of stratifying on the class label alone, `sattp` also stratifies on *where a row sits in
|
|
6
|
+
feature space*, so the test set covers the same sub-populations as the train set.
|
|
7
|
+
|
|
8
|
+
## How it works
|
|
9
|
+
|
|
10
|
+
1. Reduce the features with **LDA**, using the fewest components that reach 85% of the explained
|
|
11
|
+
variance (configurable).
|
|
12
|
+
2. For each candidate cluster count, run **K-Means** once on the LDA components and reuse those
|
|
13
|
+
clusters across candidate train fractions (`MiniBatchKMeans` above 50,000 rows).
|
|
14
|
+
3. Build strata from `class × cluster` and do a stratified split. Strata with a single member
|
|
15
|
+
can't be split, so those rows always go to the train set.
|
|
16
|
+
4. Score a classifier on every `(train fraction, n_clusters)` pair and return the split with the
|
|
17
|
+
best test score.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install i-attp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or install directly from GitHub:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install git+https://github.com/Jabualani/ATTP.git
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
For development:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
git clone https://github.com/Jabualani/ATTP.git
|
|
35
|
+
cd ATTP
|
|
36
|
+
pip install -e ".[dev]"
|
|
37
|
+
pytest
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from attp import sattp_split
|
|
44
|
+
|
|
45
|
+
result = sattp_split(
|
|
46
|
+
X, y,
|
|
47
|
+
splits=[0.7, 0.75, 0.8], # candidate train fractions
|
|
48
|
+
min_clusters=2,
|
|
49
|
+
max_clusters=10,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
X_train, y_train, X_test, y_test = result # unpacks like a 4-tuple
|
|
53
|
+
print(result.best_split, result.best_n_clusters, result.n_lda_components)
|
|
54
|
+
print(result.metrics.head()) # every candidate, best first
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Row labels of the original `X`/`y` are preserved in the outputs.
|
|
58
|
+
|
|
59
|
+
Useful options: `model=` (any scikit-learn classifier used for scoring; defaults to LDA),
|
|
60
|
+
`scoring=` (custom `(fitted_model, X, y) -> float`), `variance_threshold=`, `random_state=`,
|
|
61
|
+
`verbose=True`. Set `DR_Comps_Min=` and `DR_Comps_Max=` to choose the LDA component range.
|
|
62
|
+
|
|
63
|
+
### Original function signature
|
|
64
|
+
|
|
65
|
+
`S_ATTP` keeps the original call signature for existing code:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from attp import S_ATTP
|
|
69
|
+
|
|
70
|
+
X_tr, y_tr, X_te, y_te = S_ATTP(
|
|
71
|
+
X_df, y_df, "target_col", [0.7, 0.8], 2, 10, 1, 3, None, "frames", False, False
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`return_type="indices"` returns `(train_index, test_index)`, and
|
|
76
|
+
`return_split_metadata=True` appends the metrics DataFrame. If no valid split exists it returns
|
|
77
|
+
`None`s, like the original.
|
|
78
|
+
|
|
79
|
+
## Caveats
|
|
80
|
+
|
|
81
|
+
- LDA is fitted on the **whole** dataset (using all labels) before splitting, and the winning
|
|
82
|
+
split is chosen by its **test** score. Both make the reported test score optimistic. Treat it as
|
|
83
|
+
a way to build a representative split, not as an unbiased performance estimate.
|
|
84
|
+
- Features must be numeric with no missing values.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "i-attp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "S-ATTP: cluster-stratified train/test splitting on an LDA-reduced feature space."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Jabulani Monchwe", email ="MonchweJ.25@gmail.com" }]
|
|
13
|
+
keywords = ["machine-learning", "train-test-split", "stratification", "lda", "kmeans"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Science/Research",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Scientific/Engineering",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = [
|
|
27
|
+
"numpy>=1.23",
|
|
28
|
+
"pandas>=1.5",
|
|
29
|
+
"scikit-learn>=1.2",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=7.4",
|
|
35
|
+
"ruff>=0.5",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/Jabualani/ATTP"
|
|
40
|
+
Repository = "https://github.com/Jabualani/ATTP"
|
|
41
|
+
Issues = "https://github.com/Jabualani/ATTP/issues"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/attp"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
addopts = "-ra"
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 100
|
|
52
|
+
target-version = "py310"
|
|
53
|
+
src = ["src", "tests"]
|
|
54
|
+
|
|
55
|
+
[tool.ruff.lint]
|
|
56
|
+
select = ["E", "F", "I", "UP", "B"]
|
i_attp-0.1.0/sample.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from sklearn.datasets import load_iris
|
|
2
|
+
from attp import sattp_split
|
|
3
|
+
|
|
4
|
+
data = load_iris(as_frame=True)
|
|
5
|
+
result = sattp_split(
|
|
6
|
+
data.data,
|
|
7
|
+
data.target,
|
|
8
|
+
splits=[0.7],
|
|
9
|
+
min_clusters=2,
|
|
10
|
+
max_clusters=4,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
x_train, y_train, x_test, y_test = result
|
|
14
|
+
print(x_train.shape, x_test.shape, y_train.shape, y_test.shape)
|
|
15
|
+
print("test score:", result.test_score)
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from sklearn.base import ClassifierMixin, clone
|
|
11
|
+
from sklearn.cluster import KMeans, MiniBatchKMeans
|
|
12
|
+
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
|
|
13
|
+
from sklearn.model_selection import train_test_split
|
|
14
|
+
|
|
15
|
+
_LOGGER = logging.getLogger(__name__)
|
|
16
|
+
ScoringFunction = Callable[[Any, pd.DataFrame, pd.Series], float]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NoValidSplitError(ValueError):
|
|
20
|
+
"""Raised when no candidate split can be evaluated."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class SplitResult:
|
|
25
|
+
"""The selected train/test data and scores for an S-ATTP split."""
|
|
26
|
+
|
|
27
|
+
X_train: pd.DataFrame
|
|
28
|
+
y_train: pd.Series
|
|
29
|
+
X_test: pd.DataFrame
|
|
30
|
+
y_test: pd.Series
|
|
31
|
+
best_split: float
|
|
32
|
+
best_n_clusters: int
|
|
33
|
+
n_lda_components: int
|
|
34
|
+
train_score: float
|
|
35
|
+
test_score: float
|
|
36
|
+
metrics: pd.DataFrame
|
|
37
|
+
train_indices: pd.Index
|
|
38
|
+
test_indices: pd.Index
|
|
39
|
+
|
|
40
|
+
def __iter__(self) -> Iterator[pd.DataFrame | pd.Series]:
|
|
41
|
+
yield self.X_train
|
|
42
|
+
yield self.y_train
|
|
43
|
+
yield self.X_test
|
|
44
|
+
yield self.y_test
|
|
45
|
+
|
|
46
|
+
def __len__(self) -> int:
|
|
47
|
+
return 4
|
|
48
|
+
|
|
49
|
+
def __getitem__(self, index: int | slice) -> Any:
|
|
50
|
+
return (self.X_train, self.y_train, self.X_test, self.y_test)[index]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _as_features(X: pd.DataFrame | np.ndarray) -> pd.DataFrame:
|
|
54
|
+
if isinstance(X, pd.DataFrame):
|
|
55
|
+
features = X.copy()
|
|
56
|
+
else:
|
|
57
|
+
array = np.asarray(X)
|
|
58
|
+
if array.ndim != 2:
|
|
59
|
+
raise ValueError("X must be a two-dimensional array or DataFrame.")
|
|
60
|
+
features = pd.DataFrame(array)
|
|
61
|
+
|
|
62
|
+
if features.empty or features.shape[1] == 0:
|
|
63
|
+
raise ValueError("X must contain at least one row and one feature.")
|
|
64
|
+
try:
|
|
65
|
+
values = features.to_numpy(dtype=float)
|
|
66
|
+
except (TypeError, ValueError) as exc:
|
|
67
|
+
raise ValueError("X must contain only numeric feature values.") from exc
|
|
68
|
+
if not np.isfinite(values).all():
|
|
69
|
+
raise ValueError("X must not contain missing or infinite values.")
|
|
70
|
+
return features
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _as_target(
|
|
74
|
+
y: pd.Series | pd.DataFrame | Sequence[Any] | np.ndarray,
|
|
75
|
+
index: pd.Index,
|
|
76
|
+
) -> pd.Series:
|
|
77
|
+
if isinstance(y, pd.DataFrame):
|
|
78
|
+
if y.shape[1] != 1:
|
|
79
|
+
raise ValueError("y must be a Series or a DataFrame with exactly one column.")
|
|
80
|
+
target = y.iloc[:, 0].copy()
|
|
81
|
+
elif isinstance(y, pd.Series):
|
|
82
|
+
target = y.copy()
|
|
83
|
+
else:
|
|
84
|
+
target = pd.Series(y)
|
|
85
|
+
|
|
86
|
+
if len(target) != len(index):
|
|
87
|
+
raise ValueError("X and y must contain the same number of rows.")
|
|
88
|
+
target.index = index
|
|
89
|
+
if target.isna().any():
|
|
90
|
+
raise ValueError("y must not contain missing values.")
|
|
91
|
+
if target.nunique() < 2:
|
|
92
|
+
raise ValueError("y must contain at least two classes.")
|
|
93
|
+
return target
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _fit_lda(
|
|
97
|
+
X: pd.DataFrame,
|
|
98
|
+
y: pd.Series,
|
|
99
|
+
min_components: int,
|
|
100
|
+
max_components: int,
|
|
101
|
+
variance_threshold: float,
|
|
102
|
+
) -> tuple[LinearDiscriminantAnalysis, int]:
|
|
103
|
+
max_possible = min(X.shape[1], y.nunique() - 1)
|
|
104
|
+
if max_possible < 1:
|
|
105
|
+
raise ValueError("LDA requires at least two target classes and one feature.")
|
|
106
|
+
|
|
107
|
+
first = max(1, min(min_components, max_possible))
|
|
108
|
+
last = min(max_components, max_possible)
|
|
109
|
+
if first > last:
|
|
110
|
+
first = last
|
|
111
|
+
|
|
112
|
+
selected: LinearDiscriminantAnalysis | None = None
|
|
113
|
+
selected_components = 0
|
|
114
|
+
for n_components in range(first, last + 1):
|
|
115
|
+
candidate = LinearDiscriminantAnalysis(n_components=n_components)
|
|
116
|
+
candidate.fit(X, y)
|
|
117
|
+
selected = candidate
|
|
118
|
+
selected_components = n_components
|
|
119
|
+
if candidate.explained_variance_ratio_.sum() >= variance_threshold:
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
if selected is None:
|
|
123
|
+
raise ValueError("No valid LDA component count is available.")
|
|
124
|
+
return selected, selected_components
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def sattp_split(
|
|
128
|
+
X: pd.DataFrame | np.ndarray,
|
|
129
|
+
y: pd.Series | pd.DataFrame | Sequence[Any] | np.ndarray,
|
|
130
|
+
*,
|
|
131
|
+
splits: Sequence[float] = (0.7, 0.8),
|
|
132
|
+
min_clusters: int = 2,
|
|
133
|
+
max_clusters: int = 10,
|
|
134
|
+
DR_Comps_Min: int = 1,
|
|
135
|
+
DR_Comps_Max: int = 3,
|
|
136
|
+
model: ClassifierMixin | None = None,
|
|
137
|
+
scoring: ScoringFunction | None = None,
|
|
138
|
+
variance_threshold: float = 0.85,
|
|
139
|
+
random_state: int | None = 0,
|
|
140
|
+
verbose: bool = False,
|
|
141
|
+
) -> SplitResult:
|
|
142
|
+
"""Find a high-scoring cluster-stratified train/test split.
|
|
143
|
+
|
|
144
|
+
LDA and clustering are fitted on the full input before splitting, so the
|
|
145
|
+
returned score is useful for selecting a representative split, not for
|
|
146
|
+
unbiased model evaluation.
|
|
147
|
+
"""
|
|
148
|
+
features = _as_features(X)
|
|
149
|
+
target = _as_target(y, features.index)
|
|
150
|
+
|
|
151
|
+
if len(splits) == 0:
|
|
152
|
+
raise ValueError("splits must contain at least one train fraction.")
|
|
153
|
+
if any(not 0.0 < split < 1.0 for split in splits):
|
|
154
|
+
raise ValueError("Each split must be a train fraction strictly between 0 and 1.")
|
|
155
|
+
if min_clusters < 1 or max_clusters < min_clusters:
|
|
156
|
+
raise ValueError("Cluster bounds must satisfy 1 <= min_clusters <= max_clusters.")
|
|
157
|
+
if max_clusters > len(features):
|
|
158
|
+
raise ValueError("max_clusters cannot exceed the number of input rows.")
|
|
159
|
+
if DR_Comps_Min < 1 or DR_Comps_Max < DR_Comps_Min:
|
|
160
|
+
raise ValueError(
|
|
161
|
+
"LDA component bounds must satisfy "
|
|
162
|
+
"1 <= DR_Comps_Min <= DR_Comps_Max."
|
|
163
|
+
)
|
|
164
|
+
if not 0.0 <= variance_threshold <= 1.0:
|
|
165
|
+
raise ValueError("variance_threshold must be between 0 and 1.")
|
|
166
|
+
|
|
167
|
+
lda, n_components = _fit_lda(
|
|
168
|
+
features,
|
|
169
|
+
target,
|
|
170
|
+
DR_Comps_Min,
|
|
171
|
+
DR_Comps_Max,
|
|
172
|
+
variance_threshold,
|
|
173
|
+
)
|
|
174
|
+
reduced = lda.transform(features)
|
|
175
|
+
candidate_model = model if model is not None else LinearDiscriminantAnalysis()
|
|
176
|
+
n_samples = len(features)
|
|
177
|
+
use_minibatch = n_samples > 50_000
|
|
178
|
+
records: list[dict[str, Any]] = []
|
|
179
|
+
|
|
180
|
+
for n_clusters in range(min_clusters, max_clusters + 1):
|
|
181
|
+
if use_minibatch:
|
|
182
|
+
clusterer = MiniBatchKMeans(
|
|
183
|
+
n_clusters=n_clusters,
|
|
184
|
+
random_state=random_state,
|
|
185
|
+
batch_size=4096,
|
|
186
|
+
n_init="auto",
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
clusterer = KMeans(
|
|
190
|
+
n_clusters=n_clusters,
|
|
191
|
+
random_state=random_state,
|
|
192
|
+
n_init="auto",
|
|
193
|
+
)
|
|
194
|
+
cluster_labels = clusterer.fit_predict(reduced)
|
|
195
|
+
strata = pd.Series(
|
|
196
|
+
target.astype(str).to_numpy() + "\x1f" + cluster_labels.astype(str)
|
|
197
|
+
)
|
|
198
|
+
counts = strata.value_counts()
|
|
199
|
+
singleton_positions = np.flatnonzero(strata.map(counts).to_numpy() == 1)
|
|
200
|
+
valid_positions = np.flatnonzero(strata.map(counts).to_numpy() > 1)
|
|
201
|
+
valid_strata = strata.iloc[valid_positions]
|
|
202
|
+
n_valid = len(valid_positions)
|
|
203
|
+
n_groups = valid_strata.nunique()
|
|
204
|
+
|
|
205
|
+
for split in splits:
|
|
206
|
+
n_train = int(np.floor(n_valid * split))
|
|
207
|
+
n_test = n_valid - n_train
|
|
208
|
+
if n_groups == 0 or n_train < n_groups or n_test < n_groups:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
train_valid, test_positions = train_test_split(
|
|
212
|
+
valid_positions,
|
|
213
|
+
train_size=split,
|
|
214
|
+
random_state=random_state,
|
|
215
|
+
stratify=valid_strata,
|
|
216
|
+
)
|
|
217
|
+
train_positions = np.concatenate((train_valid, singleton_positions))
|
|
218
|
+
X_train = features.iloc[train_positions]
|
|
219
|
+
y_train = target.iloc[train_positions]
|
|
220
|
+
X_test = features.iloc[test_positions]
|
|
221
|
+
y_test = target.iloc[test_positions]
|
|
222
|
+
|
|
223
|
+
fitted_model = clone(candidate_model)
|
|
224
|
+
fitted_model.fit(X_train, y_train)
|
|
225
|
+
if scoring is None:
|
|
226
|
+
train_score = float(fitted_model.score(X_train, y_train))
|
|
227
|
+
test_score = float(fitted_model.score(X_test, y_test))
|
|
228
|
+
else:
|
|
229
|
+
train_score = float(scoring(fitted_model, X_train, y_train))
|
|
230
|
+
test_score = float(scoring(fitted_model, X_test, y_test))
|
|
231
|
+
if not np.isfinite(train_score) or not np.isfinite(test_score):
|
|
232
|
+
raise ValueError("The scoring function must return finite numeric scores.")
|
|
233
|
+
|
|
234
|
+
records.append(
|
|
235
|
+
{
|
|
236
|
+
"Split": split,
|
|
237
|
+
"n_clusters": n_clusters,
|
|
238
|
+
"train_score": round(train_score, 3),
|
|
239
|
+
"test_score": round(test_score, 3),
|
|
240
|
+
"train_indices": features.index.take(train_positions),
|
|
241
|
+
"test_indices": features.index.take(test_positions),
|
|
242
|
+
"_train_positions": train_positions,
|
|
243
|
+
"_test_positions": test_positions,
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if not records:
|
|
248
|
+
raise NoValidSplitError(
|
|
249
|
+
"No valid candidate split was found. Try different train fractions or cluster bounds."
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
metrics = pd.DataFrame.from_records(records).sort_values(
|
|
253
|
+
["test_score", "n_clusters"], ascending=[False, False], ignore_index=True
|
|
254
|
+
)
|
|
255
|
+
best = metrics.iloc[0]
|
|
256
|
+
train_indices = best["train_indices"]
|
|
257
|
+
test_indices = best["test_indices"]
|
|
258
|
+
train_positions = best["_train_positions"]
|
|
259
|
+
test_positions = best["_test_positions"]
|
|
260
|
+
metrics = metrics.drop(columns=["_train_positions", "_test_positions"])
|
|
261
|
+
|
|
262
|
+
result = SplitResult(
|
|
263
|
+
X_train=features.iloc[train_positions].copy(),
|
|
264
|
+
y_train=target.iloc[train_positions].copy(),
|
|
265
|
+
X_test=features.iloc[test_positions].copy(),
|
|
266
|
+
y_test=target.iloc[test_positions].copy(),
|
|
267
|
+
best_split=float(best["Split"]),
|
|
268
|
+
best_n_clusters=int(best["n_clusters"]),
|
|
269
|
+
n_lda_components=n_components,
|
|
270
|
+
train_score=float(best["train_score"]),
|
|
271
|
+
test_score=float(best["test_score"]),
|
|
272
|
+
metrics=metrics,
|
|
273
|
+
train_indices=train_indices.copy(),
|
|
274
|
+
test_indices=test_indices.copy(),
|
|
275
|
+
)
|
|
276
|
+
if verbose:
|
|
277
|
+
_LOGGER.info(
|
|
278
|
+
"Selected split %.3f with %d clusters (test score %.3f).",
|
|
279
|
+
result.best_split,
|
|
280
|
+
result.best_n_clusters,
|
|
281
|
+
result.test_score,
|
|
282
|
+
)
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def S_ATTP(
|
|
287
|
+
Input_X_df: pd.DataFrame,
|
|
288
|
+
Input_y_df: pd.DataFrame,
|
|
289
|
+
Target_Var_: str,
|
|
290
|
+
Splits: Sequence[float],
|
|
291
|
+
Min_clusters: int,
|
|
292
|
+
Max_Clusters: int,
|
|
293
|
+
DR_Comps_Min: int,
|
|
294
|
+
DR_Comps_Max: int,
|
|
295
|
+
accuracy_model: ClassifierMixin | None,
|
|
296
|
+
return_type: str,
|
|
297
|
+
return_split_metadata: bool,
|
|
298
|
+
verbose: bool,
|
|
299
|
+
) -> tuple[Any, ...]:
|
|
300
|
+
"""Compatibility wrapper for the original S_ATTP function signature."""
|
|
301
|
+
if Target_Var_ not in Input_y_df.columns:
|
|
302
|
+
raise ValueError(f"Target_Var_ {Target_Var_!r} is not a column in Input_y_df.")
|
|
303
|
+
if return_type not in {"frames", "indices"}:
|
|
304
|
+
raise ValueError("return_type must be either 'frames' or 'indices'.")
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
result = sattp_split(
|
|
308
|
+
Input_X_df,
|
|
309
|
+
Input_y_df[Target_Var_],
|
|
310
|
+
splits=Splits,
|
|
311
|
+
min_clusters=Min_clusters,
|
|
312
|
+
max_clusters=Max_Clusters,
|
|
313
|
+
DR_Comps_Min=DR_Comps_Min,
|
|
314
|
+
DR_Comps_Max=DR_Comps_Max,
|
|
315
|
+
model=accuracy_model,
|
|
316
|
+
verbose=verbose,
|
|
317
|
+
)
|
|
318
|
+
except NoValidSplitError:
|
|
319
|
+
output = (None, None) if return_type == "indices" else (None, None, None, None)
|
|
320
|
+
return output + (None,) if return_split_metadata else output
|
|
321
|
+
|
|
322
|
+
if return_type == "indices":
|
|
323
|
+
output = (result.train_indices, result.test_indices)
|
|
324
|
+
else:
|
|
325
|
+
output = (result.X_train, result.y_train, result.X_test, result.y_test)
|
|
326
|
+
return output + (result.metrics,) if return_split_metadata else output
|
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import pytest
|
|
4
|
+
from sklearn.cluster import KMeans
|
|
5
|
+
from sklearn.linear_model import LogisticRegression
|
|
6
|
+
|
|
7
|
+
from attp import S_ATTP, NoValidSplitError, sattp_split
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def classification_data():
|
|
12
|
+
rng = np.random.default_rng(12)
|
|
13
|
+
labels = np.repeat(["a", "b", "c"], 40)
|
|
14
|
+
features = rng.normal(size=(len(labels), 5)) + np.repeat(
|
|
15
|
+
np.array([[-2, 0, 0, 0, 0], [0, 2, 0, 0, 0], [2, 0, 0, 0, 0]]),
|
|
16
|
+
40,
|
|
17
|
+
axis=0,
|
|
18
|
+
)
|
|
19
|
+
index = pd.Index([f"sample-{i}" for i in range(len(labels))])
|
|
20
|
+
return pd.DataFrame(features, columns=list("abcde"), index=index), pd.Series(
|
|
21
|
+
labels, index=index, name="label"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_split_result_preserves_indices_and_unpacks(classification_data):
|
|
26
|
+
X, y = classification_data
|
|
27
|
+
result = sattp_split(
|
|
28
|
+
X,
|
|
29
|
+
y,
|
|
30
|
+
splits=[0.7],
|
|
31
|
+
min_clusters=2,
|
|
32
|
+
max_clusters=2,
|
|
33
|
+
DR_Comps_Min=1,
|
|
34
|
+
DR_Comps_Max=3,
|
|
35
|
+
model=LogisticRegression(max_iter=500),
|
|
36
|
+
random_state=4,
|
|
37
|
+
)
|
|
38
|
+
X_train, y_train, X_test, y_test = result
|
|
39
|
+
|
|
40
|
+
assert X_train.index.equals(y_train.index)
|
|
41
|
+
assert X_test.index.equals(y_test.index)
|
|
42
|
+
assert set(result.train_indices).isdisjoint(result.test_indices)
|
|
43
|
+
assert set(result.train_indices) | set(result.test_indices) == set(X.index)
|
|
44
|
+
assert result.best_split == 0.7
|
|
45
|
+
assert len(result.metrics) == 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_clusters_are_fitted_once_for_each_cluster_count(classification_data, monkeypatch):
|
|
49
|
+
X, y = classification_data
|
|
50
|
+
fitted_cluster_counts = []
|
|
51
|
+
original_fit_predict = KMeans.fit_predict
|
|
52
|
+
|
|
53
|
+
def count_fit_predict(self, *args, **kwargs):
|
|
54
|
+
fitted_cluster_counts.append(self.n_clusters)
|
|
55
|
+
return original_fit_predict(self, *args, **kwargs)
|
|
56
|
+
|
|
57
|
+
monkeypatch.setattr(KMeans, "fit_predict", count_fit_predict)
|
|
58
|
+
result = sattp_split(
|
|
59
|
+
X,
|
|
60
|
+
y,
|
|
61
|
+
splits=[0.65, 0.75],
|
|
62
|
+
min_clusters=2,
|
|
63
|
+
max_clusters=3,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
assert fitted_cluster_counts == [2, 3]
|
|
67
|
+
assert len(result.metrics) == 4
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_legacy_wrapper_returns_frames_indices_and_metadata(classification_data):
|
|
71
|
+
X, y = classification_data
|
|
72
|
+
target = y.to_frame()
|
|
73
|
+
target.index = target.index[::-1]
|
|
74
|
+
target = target.reset_index(drop=True)
|
|
75
|
+
X = X.reset_index(drop=True)
|
|
76
|
+
|
|
77
|
+
frames = S_ATTP(X, target, "label", [0.7], 2, 2, 1, 2, None, "frames", False, False)
|
|
78
|
+
indices = S_ATTP(X, target, "label", [0.7], 2, 2, 1, 2, None, "indices", True, False)
|
|
79
|
+
|
|
80
|
+
assert len(frames) == 4
|
|
81
|
+
assert len(indices) == 3
|
|
82
|
+
assert isinstance(indices[2], pd.DataFrame)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize(
|
|
86
|
+
("X", "y", "kwargs", "message"),
|
|
87
|
+
[
|
|
88
|
+
([1, 2], [0, 1], {}, "two-dimensional"),
|
|
89
|
+
([[1, np.nan], [2, 3]], [0, 1], {}, "missing or infinite"),
|
|
90
|
+
([[1], [2]], [0], {}, "same number of rows"),
|
|
91
|
+
([[1], [2]], [1, 1], {}, "at least two classes"),
|
|
92
|
+
([[1], [2]], [0, 1], {"splits": [1.0]}, "strictly between"),
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
def test_invalid_input_raises_clear_error(X, y, kwargs, message):
|
|
96
|
+
with pytest.raises(ValueError, match=message):
|
|
97
|
+
sattp_split(X, y, **kwargs)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_no_candidate_split_raises_specific_error(classification_data):
|
|
101
|
+
X, y = classification_data
|
|
102
|
+
with pytest.raises(NoValidSplitError, match="No valid candidate"):
|
|
103
|
+
sattp_split(X, y, splits=[0.99], min_clusters=2, max_clusters=2)
|