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.
@@ -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
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ build/
7
+ dist/
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,3 @@
1
+ from ._split import S_ATTP, NoValidSplitError, SplitResult, sattp_split
2
+
3
+ __all__ = ["NoValidSplitError", "SplitResult", "S_ATTP", "sattp_split"]
@@ -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)