dataset-splitter 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.
- dataset_splitter-0.1.0/LICENSE +21 -0
- dataset_splitter-0.1.0/PKG-INFO +121 -0
- dataset_splitter-0.1.0/README.md +94 -0
- dataset_splitter-0.1.0/pyproject.toml +54 -0
- dataset_splitter-0.1.0/src/dataset_splitter/__init__.py +16 -0
- dataset_splitter-0.1.0/src/dataset_splitter/_io.py +32 -0
- dataset_splitter-0.1.0/src/dataset_splitter/_util.py +141 -0
- dataset_splitter-0.1.0/src/dataset_splitter/cli.py +104 -0
- dataset_splitter-0.1.0/src/dataset_splitter/groups.py +169 -0
- dataset_splitter-0.1.0/src/dataset_splitter/report.py +397 -0
- dataset_splitter-0.1.0/src/dataset_splitter/splitter.py +588 -0
- dataset_splitter-0.1.0/tests/test_dedupe.py +51 -0
- dataset_splitter-0.1.0/tests/test_edge.py +123 -0
- dataset_splitter-0.1.0/tests/test_groups.py +106 -0
- dataset_splitter-0.1.0/tests/test_invariants.py +65 -0
- dataset_splitter-0.1.0/tests/test_quickstart.py +26 -0
- dataset_splitter-0.1.0/tests/test_report.py +52 -0
- dataset_splitter-0.1.0/tests/test_report_save_cli.py +151 -0
- dataset_splitter-0.1.0/tests/test_split.py +159 -0
- dataset_splitter-0.1.0/tests/test_stratify.py +110 -0
- dataset_splitter-0.1.0/tests/test_time.py +99 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pranay Mahendrakar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: dataset-splitter
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Leakage-safe train/validation/test splits in one call: stratified, grouped, time-aware, and checked
|
|
5
|
+
Project-URL: Homepage, https://pypi.org/project/dataset-splitter/
|
|
6
|
+
Project-URL: Author, https://pypi.org/user/pranaymahendrakar/
|
|
7
|
+
Author: Pranay Mahendrakar
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cross-validation,data-leakage,group-split,machine-learning,pandas,stratified,time-series-split,train-test-split
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: numpy>=1.23
|
|
20
|
+
Requires-Dist: pandas>=1.5
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pyarrow>=12; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
24
|
+
Provides-Extra: parquet
|
|
25
|
+
Requires-Dist: pyarrow>=12; extra == 'parquet'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# dataset-splitter
|
|
29
|
+
|
|
30
|
+
Train/validation/test splits that cannot leak: stratified, grouped, time-aware, and checked, in one call.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
pip install dataset-splitter
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quickstart
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import pandas as pd
|
|
42
|
+
from dataset_splitter import split
|
|
43
|
+
|
|
44
|
+
df = pd.DataFrame({"user_id": [i // 3 for i in range(60)], "x": range(60), "label": [i % 2 for i in range(60)]})
|
|
45
|
+
s = split(df, target="label", group="user_id", test_size=0.2, val_size=0.1)
|
|
46
|
+
print(s.train.shape, s.val.shape, s.test.shape)
|
|
47
|
+
print(s.report().summary())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The summary shows the sizes, the class balance of every part, and the leakage checks: no
|
|
51
|
+
`user_id` on two sides, no duplicate row on two sides, rows in exactly one part, `ok: True`.
|
|
52
|
+
|
|
53
|
+
## What it does
|
|
54
|
+
|
|
55
|
+
- **Stratified** on `target`: classes are balanced across train/val/test. Numeric targets are
|
|
56
|
+
stratified on quantile bins. Tiny classes are pooled, and a tiny dataset falls back to a plain
|
|
57
|
+
random split, both with a warning instead of an error.
|
|
58
|
+
- **Grouped** on `group`: every value of the column (or combination of columns) lands entirely on
|
|
59
|
+
one side, like scikit-learn's `GroupShuffleSplit` but with row-accurate sizes and stratification.
|
|
60
|
+
`group="auto"` detects id-like columns (`customer_id`, `userId`, `session_uuid`, `patient_no`, ...)
|
|
61
|
+
and, when several are found, links rows that share any of them.
|
|
62
|
+
- **Time-aware** on `time`: a chronological split with no shuffling; the oldest rows train, the
|
|
63
|
+
newest test. Combined with `group`, groups are ordered by their first timestamp and kept whole.
|
|
64
|
+
- **Duplicate-safe**: exact duplicate rows are grouped before splitting (`dedupe=True`), so the
|
|
65
|
+
same row can never sit in both train and test.
|
|
66
|
+
- **Checked**: `report()` recomputes everything from the output frames: sizes, class balance,
|
|
67
|
+
group overlap (must be 0), duplicate leakage (must be 0), time ordering, and an exact partition
|
|
68
|
+
check (no row lost or duplicated). `report().ok` is the single flag to look at.
|
|
69
|
+
- **Predictable**: the original index values are preserved and listed in `.indices`; row order
|
|
70
|
+
inside each part is the input order; `random_state` makes every split reproducible.
|
|
71
|
+
|
|
72
|
+
## API
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
split(df, *, target=None, group=None, time=None, test_size=0.2, val_size=0.1,
|
|
76
|
+
random_state=0, dedupe=True) -> Split
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`df` is a DataFrame or a path to a `.csv` / `.tsv` / `.parquet` file. `test_size` and `val_size`
|
|
80
|
+
are fractions (floats below 1) or absolute row counts (ints); `val_size=0` disables validation.
|
|
81
|
+
|
|
82
|
+
`Splitter(target=..., group=..., time=..., test_size=..., val_size=..., random_state=...,
|
|
83
|
+
dedupe=..., stratify="auto", n_bins=10, max_categories=20)` is the class underneath, for
|
|
84
|
+
control over stratification (`stratify=True/False`), the number of quantile bins for numeric
|
|
85
|
+
targets, and the cardinality below which a numeric target counts as categorical. `.split(df)`.
|
|
86
|
+
|
|
87
|
+
`Split`
|
|
88
|
+
|
|
89
|
+
- `.train`, `.val`, `.test` - DataFrames (`.val` is `None` when `val_size=0`)
|
|
90
|
+
- `.indices` - `{"train": [...], "val": [...], "test": [...]}` original index values
|
|
91
|
+
- `.strategy` - what was done (method, columns used, number of groups, ...); `.warnings`
|
|
92
|
+
- `.report()` - a `SplitReport`
|
|
93
|
+
- `.save(dir, format="csv" | "parquet")` - writes `train/val/test.<format>` and `report.json`,
|
|
94
|
+
returns the paths (parquet needs `pip install dataset-splitter[parquet]`)
|
|
95
|
+
|
|
96
|
+
`SplitReport`
|
|
97
|
+
|
|
98
|
+
- `.ok` - True when nothing leaks and the parts partition the input exactly
|
|
99
|
+
- `.sizes`, `.fractions`, `.class_balance`, `.class_counts`, `.balance_max_deviation`,
|
|
100
|
+
`.target_stats` (numeric targets)
|
|
101
|
+
- `.group_overlap`, `.duplicate_leakage`, `.time_ordering`, `.partition`, `.warnings`
|
|
102
|
+
- `.summary()` - human-readable text; `.to_dict()` - JSON-safe dict
|
|
103
|
+
|
|
104
|
+
`detect_id_columns(df)` returns the columns `group="auto"` would use; `load_table(path)` reads
|
|
105
|
+
a `.csv` / `.tsv` / `.parquet` file.
|
|
106
|
+
|
|
107
|
+
## CLI
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
dataset-splitter data.csv --target label --group customer_id
|
|
111
|
+
dataset-splitter data.csv --time timestamp --test-size 0.2 --val-size 0.1 --json
|
|
112
|
+
dataset-splitter data.csv --group auto --output splits/ --format parquet
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Prints the report (`--json` for `to_dict()` as JSON), writes the parts with `--output DIR`, and
|
|
116
|
+
exits with status 1 when the report is not ok, 2 on bad input. `dataset-splitter --help` lists
|
|
117
|
+
every option.
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# dataset-splitter
|
|
2
|
+
|
|
3
|
+
Train/validation/test splits that cannot leak: stratified, grouped, time-aware, and checked, in one call.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pip install dataset-splitter
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from dataset_splitter import split
|
|
16
|
+
|
|
17
|
+
df = pd.DataFrame({"user_id": [i // 3 for i in range(60)], "x": range(60), "label": [i % 2 for i in range(60)]})
|
|
18
|
+
s = split(df, target="label", group="user_id", test_size=0.2, val_size=0.1)
|
|
19
|
+
print(s.train.shape, s.val.shape, s.test.shape)
|
|
20
|
+
print(s.report().summary())
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The summary shows the sizes, the class balance of every part, and the leakage checks: no
|
|
24
|
+
`user_id` on two sides, no duplicate row on two sides, rows in exactly one part, `ok: True`.
|
|
25
|
+
|
|
26
|
+
## What it does
|
|
27
|
+
|
|
28
|
+
- **Stratified** on `target`: classes are balanced across train/val/test. Numeric targets are
|
|
29
|
+
stratified on quantile bins. Tiny classes are pooled, and a tiny dataset falls back to a plain
|
|
30
|
+
random split, both with a warning instead of an error.
|
|
31
|
+
- **Grouped** on `group`: every value of the column (or combination of columns) lands entirely on
|
|
32
|
+
one side, like scikit-learn's `GroupShuffleSplit` but with row-accurate sizes and stratification.
|
|
33
|
+
`group="auto"` detects id-like columns (`customer_id`, `userId`, `session_uuid`, `patient_no`, ...)
|
|
34
|
+
and, when several are found, links rows that share any of them.
|
|
35
|
+
- **Time-aware** on `time`: a chronological split with no shuffling; the oldest rows train, the
|
|
36
|
+
newest test. Combined with `group`, groups are ordered by their first timestamp and kept whole.
|
|
37
|
+
- **Duplicate-safe**: exact duplicate rows are grouped before splitting (`dedupe=True`), so the
|
|
38
|
+
same row can never sit in both train and test.
|
|
39
|
+
- **Checked**: `report()` recomputes everything from the output frames: sizes, class balance,
|
|
40
|
+
group overlap (must be 0), duplicate leakage (must be 0), time ordering, and an exact partition
|
|
41
|
+
check (no row lost or duplicated). `report().ok` is the single flag to look at.
|
|
42
|
+
- **Predictable**: the original index values are preserved and listed in `.indices`; row order
|
|
43
|
+
inside each part is the input order; `random_state` makes every split reproducible.
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
split(df, *, target=None, group=None, time=None, test_size=0.2, val_size=0.1,
|
|
49
|
+
random_state=0, dedupe=True) -> Split
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`df` is a DataFrame or a path to a `.csv` / `.tsv` / `.parquet` file. `test_size` and `val_size`
|
|
53
|
+
are fractions (floats below 1) or absolute row counts (ints); `val_size=0` disables validation.
|
|
54
|
+
|
|
55
|
+
`Splitter(target=..., group=..., time=..., test_size=..., val_size=..., random_state=...,
|
|
56
|
+
dedupe=..., stratify="auto", n_bins=10, max_categories=20)` is the class underneath, for
|
|
57
|
+
control over stratification (`stratify=True/False`), the number of quantile bins for numeric
|
|
58
|
+
targets, and the cardinality below which a numeric target counts as categorical. `.split(df)`.
|
|
59
|
+
|
|
60
|
+
`Split`
|
|
61
|
+
|
|
62
|
+
- `.train`, `.val`, `.test` - DataFrames (`.val` is `None` when `val_size=0`)
|
|
63
|
+
- `.indices` - `{"train": [...], "val": [...], "test": [...]}` original index values
|
|
64
|
+
- `.strategy` - what was done (method, columns used, number of groups, ...); `.warnings`
|
|
65
|
+
- `.report()` - a `SplitReport`
|
|
66
|
+
- `.save(dir, format="csv" | "parquet")` - writes `train/val/test.<format>` and `report.json`,
|
|
67
|
+
returns the paths (parquet needs `pip install dataset-splitter[parquet]`)
|
|
68
|
+
|
|
69
|
+
`SplitReport`
|
|
70
|
+
|
|
71
|
+
- `.ok` - True when nothing leaks and the parts partition the input exactly
|
|
72
|
+
- `.sizes`, `.fractions`, `.class_balance`, `.class_counts`, `.balance_max_deviation`,
|
|
73
|
+
`.target_stats` (numeric targets)
|
|
74
|
+
- `.group_overlap`, `.duplicate_leakage`, `.time_ordering`, `.partition`, `.warnings`
|
|
75
|
+
- `.summary()` - human-readable text; `.to_dict()` - JSON-safe dict
|
|
76
|
+
|
|
77
|
+
`detect_id_columns(df)` returns the columns `group="auto"` would use; `load_table(path)` reads
|
|
78
|
+
a `.csv` / `.tsv` / `.parquet` file.
|
|
79
|
+
|
|
80
|
+
## CLI
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
dataset-splitter data.csv --target label --group customer_id
|
|
84
|
+
dataset-splitter data.csv --time timestamp --test-size 0.2 --val-size 0.1 --json
|
|
85
|
+
dataset-splitter data.csv --group auto --output splits/ --format parquet
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Prints the report (`--json` for `to_dict()` as JSON), writes the parts with `--output DIR`, and
|
|
89
|
+
exits with status 1 when the report is not ok, 2 on bad input. `dataset-splitter --help` lists
|
|
90
|
+
every option.
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dataset-splitter"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Leakage-safe train/validation/test splits in one call: stratified, grouped, time-aware, and checked"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Pranay Mahendrakar" }]
|
|
14
|
+
keywords = [
|
|
15
|
+
"train-test-split",
|
|
16
|
+
"data-leakage",
|
|
17
|
+
"stratified",
|
|
18
|
+
"group-split",
|
|
19
|
+
"time-series-split",
|
|
20
|
+
"cross-validation",
|
|
21
|
+
"pandas",
|
|
22
|
+
"machine-learning",
|
|
23
|
+
]
|
|
24
|
+
classifiers = [
|
|
25
|
+
"Development Status :: 4 - Beta",
|
|
26
|
+
"Intended Audience :: Developers",
|
|
27
|
+
"Intended Audience :: Science/Research",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
30
|
+
"Operating System :: OS Independent",
|
|
31
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
32
|
+
]
|
|
33
|
+
dependencies = [
|
|
34
|
+
"pandas>=1.5",
|
|
35
|
+
"numpy>=1.23",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
# heavy or niche deps go here, never in `dependencies`
|
|
40
|
+
parquet = ["pyarrow>=12"]
|
|
41
|
+
dev = ["pytest>=7", "pyarrow>=12"]
|
|
42
|
+
|
|
43
|
+
[project.scripts]
|
|
44
|
+
dataset-splitter = "dataset_splitter.cli:main"
|
|
45
|
+
|
|
46
|
+
[project.urls]
|
|
47
|
+
Homepage = "https://pypi.org/project/dataset-splitter/"
|
|
48
|
+
Author = "https://pypi.org/user/pranaymahendrakar/"
|
|
49
|
+
|
|
50
|
+
[tool.hatch.build.targets.wheel]
|
|
51
|
+
packages = ["src/dataset_splitter"]
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""dataset-splitter: leakage-safe train/validation/test splits in one call."""
|
|
2
|
+
from ._io import load_table
|
|
3
|
+
from .groups import detect_id_columns
|
|
4
|
+
from .report import SplitReport
|
|
5
|
+
from .splitter import Split, Splitter, split
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
__all__ = [
|
|
9
|
+
"split",
|
|
10
|
+
"Splitter",
|
|
11
|
+
"Split",
|
|
12
|
+
"SplitReport",
|
|
13
|
+
"detect_id_columns",
|
|
14
|
+
"load_table",
|
|
15
|
+
"__version__",
|
|
16
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Loading tabular input: a DataFrame is passed through, a path is read."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_table(data: Any) -> pd.DataFrame:
|
|
12
|
+
"""Return `data` as a DataFrame; accepts a DataFrame or a path to .csv/.tsv/.parquet."""
|
|
13
|
+
if isinstance(data, pd.DataFrame):
|
|
14
|
+
return data
|
|
15
|
+
if isinstance(data, (str, os.PathLike)):
|
|
16
|
+
path = Path(data)
|
|
17
|
+
suffix = path.suffix.lower()
|
|
18
|
+
if suffix == ".csv":
|
|
19
|
+
return pd.read_csv(path)
|
|
20
|
+
if suffix == ".tsv":
|
|
21
|
+
return pd.read_csv(path, sep="\t")
|
|
22
|
+
if suffix in (".parquet", ".pq"):
|
|
23
|
+
try:
|
|
24
|
+
return pd.read_parquet(path)
|
|
25
|
+
except ImportError as exc:
|
|
26
|
+
raise ImportError(
|
|
27
|
+
"reading parquet needs pyarrow: pip install 'dataset-splitter[parquet]'"
|
|
28
|
+
) from exc
|
|
29
|
+
raise ValueError(f"unsupported file type {suffix!r}; expected .csv, .tsv or .parquet")
|
|
30
|
+
raise TypeError(
|
|
31
|
+
f"expected a pandas DataFrame or a path to a .csv/.parquet file, got {type(data).__name__}"
|
|
32
|
+
)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Small shared helpers: JSON conversion, time parsing, target kind and strata."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as _dt
|
|
5
|
+
import math
|
|
6
|
+
import warnings
|
|
7
|
+
from typing import Any, List, Tuple
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from pandas.api.types import (
|
|
12
|
+
is_bool_dtype,
|
|
13
|
+
is_datetime64_any_dtype,
|
|
14
|
+
is_numeric_dtype,
|
|
15
|
+
is_object_dtype,
|
|
16
|
+
is_string_dtype,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def jsonable(obj: Any) -> Any:
|
|
21
|
+
"""Recursively turn numpy/pandas scalars and containers into JSON-safe Python values."""
|
|
22
|
+
if obj is None or isinstance(obj, (bool, int, str)):
|
|
23
|
+
return obj
|
|
24
|
+
if isinstance(obj, float):
|
|
25
|
+
return obj if math.isfinite(obj) else None
|
|
26
|
+
if isinstance(obj, np.bool_):
|
|
27
|
+
return bool(obj)
|
|
28
|
+
if isinstance(obj, np.integer):
|
|
29
|
+
return int(obj)
|
|
30
|
+
if isinstance(obj, np.floating):
|
|
31
|
+
value = float(obj)
|
|
32
|
+
return value if math.isfinite(value) else None
|
|
33
|
+
if isinstance(obj, dict):
|
|
34
|
+
return {str(k): jsonable(v) for k, v in obj.items()}
|
|
35
|
+
if isinstance(obj, (list, tuple, set, frozenset, np.ndarray, pd.Index, pd.Series)):
|
|
36
|
+
return [jsonable(v) for v in list(obj)]
|
|
37
|
+
if obj is pd.NaT:
|
|
38
|
+
return None
|
|
39
|
+
if isinstance(obj, np.datetime64):
|
|
40
|
+
return None if np.isnat(obj) else pd.Timestamp(obj).isoformat()
|
|
41
|
+
if isinstance(obj, (pd.Timestamp, _dt.datetime, _dt.date)):
|
|
42
|
+
return obj.isoformat()
|
|
43
|
+
if isinstance(obj, (pd.Timedelta, _dt.timedelta, pd.Interval)):
|
|
44
|
+
return str(obj)
|
|
45
|
+
try:
|
|
46
|
+
if pd.isna(obj):
|
|
47
|
+
return None
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
pass
|
|
50
|
+
return str(obj)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_time(series: pd.Series, name: Any) -> pd.Series:
|
|
54
|
+
"""Return a time column as datetime64 or float64 values.
|
|
55
|
+
|
|
56
|
+
Raises ValueError when values are missing or cannot be interpreted as dates or numbers.
|
|
57
|
+
"""
|
|
58
|
+
if is_datetime64_any_dtype(series):
|
|
59
|
+
parsed = series
|
|
60
|
+
elif is_bool_dtype(series):
|
|
61
|
+
raise ValueError(f"time column {name!r} is boolean; expected dates or numbers")
|
|
62
|
+
elif is_numeric_dtype(series):
|
|
63
|
+
parsed = pd.to_numeric(series, errors="coerce").astype("float64")
|
|
64
|
+
else:
|
|
65
|
+
parsed = _to_datetime(series, name)
|
|
66
|
+
n_missing = int(parsed.isna().sum())
|
|
67
|
+
if n_missing:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"time column {name!r} has {n_missing} missing value(s); fill or drop them before splitting"
|
|
70
|
+
)
|
|
71
|
+
if is_datetime64_any_dtype(parsed) and getattr(parsed.dt, "tz", None) is not None:
|
|
72
|
+
parsed = parsed.dt.tz_convert("UTC").dt.tz_localize(None)
|
|
73
|
+
return parsed
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _to_datetime(series: pd.Series, name: Any) -> pd.Series:
|
|
77
|
+
values = series.astype(object) if not is_object_dtype(series) else series
|
|
78
|
+
with warnings.catch_warnings():
|
|
79
|
+
warnings.simplefilter("ignore")
|
|
80
|
+
try:
|
|
81
|
+
return pd.to_datetime(values, errors="raise")
|
|
82
|
+
except (ValueError, TypeError, OverflowError):
|
|
83
|
+
pass
|
|
84
|
+
try: # pandas >= 2.0 can parse per-element formats
|
|
85
|
+
return pd.to_datetime(values, errors="raise", format="mixed")
|
|
86
|
+
except (ValueError, TypeError, OverflowError) as exc:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"time column {name!r} could not be parsed as dates or numbers"
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def time_key(parsed: pd.Series) -> np.ndarray:
|
|
93
|
+
"""A sortable numeric array for a column returned by parse_time()."""
|
|
94
|
+
if is_datetime64_any_dtype(parsed):
|
|
95
|
+
return parsed.to_numpy(dtype="datetime64[ns]").astype("int64")
|
|
96
|
+
return parsed.to_numpy(dtype="float64")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def target_kind(y: pd.Series, max_categories: int = 20) -> str:
|
|
100
|
+
"""'categorical' for non-numeric or low-cardinality targets, otherwise 'numeric'."""
|
|
101
|
+
if (
|
|
102
|
+
isinstance(y.dtype, pd.CategoricalDtype)
|
|
103
|
+
or is_bool_dtype(y)
|
|
104
|
+
or is_object_dtype(y)
|
|
105
|
+
or is_string_dtype(y)
|
|
106
|
+
):
|
|
107
|
+
return "categorical"
|
|
108
|
+
if is_datetime64_any_dtype(y):
|
|
109
|
+
return "numeric"
|
|
110
|
+
if is_numeric_dtype(y):
|
|
111
|
+
return "categorical" if y.nunique(dropna=True) <= max_categories else "numeric"
|
|
112
|
+
return "categorical"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def strata(y: pd.Series, kind: str, n_bins: int) -> Tuple[np.ndarray, List[str]]:
|
|
116
|
+
"""Integer stratum code per row plus the label of each code.
|
|
117
|
+
|
|
118
|
+
Categorical targets use their classes; numeric targets use quantile bins.
|
|
119
|
+
Missing target values become a stratum of their own, labelled '<missing>'.
|
|
120
|
+
"""
|
|
121
|
+
if kind == "categorical":
|
|
122
|
+
codes, uniques = pd.factorize(y)
|
|
123
|
+
labels = [str(u) for u in uniques]
|
|
124
|
+
else:
|
|
125
|
+
categories = None
|
|
126
|
+
try:
|
|
127
|
+
binned = pd.qcut(y, q=max(1, int(n_bins)), duplicates="drop")
|
|
128
|
+
categories = list(binned.cat.categories)
|
|
129
|
+
except (ValueError, TypeError):
|
|
130
|
+
binned = None
|
|
131
|
+
if binned is None or not categories:
|
|
132
|
+
codes = np.zeros(len(y), dtype=np.int64)
|
|
133
|
+
labels = ["all"]
|
|
134
|
+
else:
|
|
135
|
+
codes = binned.cat.codes.to_numpy()
|
|
136
|
+
labels = [str(iv) for iv in categories]
|
|
137
|
+
codes = np.asarray(codes, dtype=np.int64)
|
|
138
|
+
if (codes < 0).any():
|
|
139
|
+
codes = np.where(codes < 0, len(labels), codes)
|
|
140
|
+
labels = labels + ["<missing>"]
|
|
141
|
+
return codes, labels
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Command line entry point: ``dataset-splitter data.csv --target y --group customer_id``."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Optional, Union
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .splitter import Splitter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _size(text: str) -> Union[int, float]:
|
|
14
|
+
"""'0.2' -> fraction, '100' -> absolute row count."""
|
|
15
|
+
if "." in text or "e" in text.lower():
|
|
16
|
+
return float(text)
|
|
17
|
+
return int(text)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="dataset-splitter",
|
|
23
|
+
description=(
|
|
24
|
+
"Leakage-safe train/validation/test splits: stratified, grouped, time-aware, "
|
|
25
|
+
"and checked. Prints the split report; exit status 1 when the report is not ok."
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("path", help="input table (.csv, .tsv or .parquet)")
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--target", metavar="COL", help="column to stratify on and report class balance for"
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--group",
|
|
34
|
+
metavar="COL",
|
|
35
|
+
nargs="+",
|
|
36
|
+
help="column(s) whose rows must stay on one side, or 'auto' to detect id-like columns",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--time", metavar="COL", help="chronological split on this column (oldest train, newest test)"
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--test-size", type=_size, default=0.2, help="fraction or row count (default 0.2)"
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
"--val-size", type=_size, default=0.1, help="fraction or row count (default 0.1)"
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument("--random-state", type=int, default=0, help="seed for shuffling (default 0)")
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--no-dedupe", action="store_true", help="do not keep exact duplicate rows on the same side"
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument("--json", action="store_true", help="print the report as JSON")
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--output", metavar="DIR", help="write train/val/test files and report.json here"
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--format", choices=("csv", "parquet"), default="csv", help="output file format"
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
59
|
+
return parser
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
63
|
+
"""Parse arguments, split the table, print the report. Returns the process exit status."""
|
|
64
|
+
for stream in (sys.stdout, sys.stderr):
|
|
65
|
+
if hasattr(stream, "reconfigure"):
|
|
66
|
+
try:
|
|
67
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
68
|
+
except (ValueError, OSError): # a stream that cannot be reconfigured
|
|
69
|
+
pass
|
|
70
|
+
args = build_parser().parse_args(argv)
|
|
71
|
+
group = None
|
|
72
|
+
if args.group:
|
|
73
|
+
if args.group == ["auto"]:
|
|
74
|
+
group = "auto"
|
|
75
|
+
elif len(args.group) == 1:
|
|
76
|
+
group = args.group[0]
|
|
77
|
+
else:
|
|
78
|
+
group = list(args.group)
|
|
79
|
+
try:
|
|
80
|
+
splitter = Splitter(
|
|
81
|
+
target=args.target,
|
|
82
|
+
group=group,
|
|
83
|
+
time=args.time,
|
|
84
|
+
test_size=args.test_size,
|
|
85
|
+
val_size=args.val_size,
|
|
86
|
+
random_state=args.random_state,
|
|
87
|
+
dedupe=not args.no_dedupe,
|
|
88
|
+
)
|
|
89
|
+
result = splitter.split(args.path)
|
|
90
|
+
report = result.report()
|
|
91
|
+
if args.output:
|
|
92
|
+
result.save(args.output, format=args.format)
|
|
93
|
+
except (ValueError, TypeError, ImportError, FileNotFoundError) as exc:
|
|
94
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
95
|
+
return 2
|
|
96
|
+
if args.json:
|
|
97
|
+
print(json.dumps(report.to_dict(), indent=2, ensure_ascii=False))
|
|
98
|
+
else:
|
|
99
|
+
print(report.summary())
|
|
100
|
+
return 0 if report.ok else 1
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__": # pragma: no cover
|
|
104
|
+
sys.exit(main())
|