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.
@@ -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())