Repository navigation
Description
What happened + What you expected to happen
Dataset.train_test_split returns the wrong split sizes in two cases.
1. Float test_size / split_proportionately: sizes are off because of int() truncation. split_proportionately computes split_indices = [int(dataset_length * proportion) ...] (python/ray/data/dataset.py ~L3140 on master), and train_test_split(test_size=t) passes [1 - t], which is computed in floating point. 1 - 0.8 = 0.19999999999999996, so for 10 rows int(1.9999999999999996) == 1:
| call | got (train/test) | expected |
|---|---|---|
range(10).train_test_split(test_size=0.8) |
1 / 9 | 2 / 8 |
range(100).train_test_split(test_size=0.8) |
19 / 81 | 20 / 80 |
range(100).train_test_split(test_size=0.34) |
65 / 35 | 66 / 34 |
range(1000).train_test_split(test_size=0.07) |
929 / 71 | 930 / 70 |
range(10).train_test_split(test_size=0.9) |
ValueError: Couldn't create non-empty splits... |
1 / 9 |
range(100).split_proportionately([0.29]) |
[28, 72] | [29, 71] |
2. stratify=: the per-class count is floored, so the test set can be empty and an int test_size isn't respected. _stratified_train_test_split (~L3252-3265) converts an int test_size to a fraction, then each class takes int(n_class * test_size). Nothing distributes the leftover rows, so with many small classes the total rounds down to zero:
- 100 rows in 25 classes of 4,
test_size=0.2, stratify="label"→ train=100, test=0 (expected ~20) - same data,
test_size=20(int) → train=100, test=0. The docstring says an int is "the absolute number of test samples", and withoutstratifyit correctly gives 80/20. - 10 rows in 2 classes of 5,
test_size=3→ test=2 (expected 3)
Possible fixes: (1) for a float test_size, compute n_test = ceil(round(test_size * n, 9)) directly, as sklearn does, and avoid bare int() in split_proportionately; (2) compute the per-class counts on the driver and hand out the remainder by largest fractional part (like sklearn's _approximate_mode) so the total matches ceil(test_size * N) or the given int. I'd be happy to open a PR with tests.
(Disclosure: this report was prepared with an AI assistant (Claude), which ran the reproduction; posted by me.)
Versions / Dependencies
ray 2.58.0 (PyPI), Python 3.11, Linux; the code in question is unchanged on master @ 46433ff
Reproduction script
import ray
ray.init(num_cpus=2, include_dashboard=False, log_to_driver=False)
ray.data.DataContext.get_current().enable_progress_bars = False
1) float test_size / split_proportionately
for n, t in [(10, 0.8), (100, 0.8), (100, 0.34), (1000, 0.07)]:
train, test = ray.data.range(n).train_test_split(test_size=t)
print(f"n={n} test_size={t}: train={train.count()} test={test.count()} expected test={round(n*t)}")
try:
ray.data.range(10).train_test_split(test_size=0.9)
except Exception as e:
print("n=10 test_size=0.9 raised:", type(e).name, e)
print([x.count() for x in ray.data.range(100).split_proportionately([0.29])], "expected [29, 71]")
2) stratify
ds = ray.data.from_items([{"x": i, "label": i % 25} for i in range(100)]) # 25 classes x 4 rows
train, test = ds.train_test_split(test_size=0.2, stratify="label")
print("float 0.2 + stratify:", train.count(), test.count(), "expected 80 20")
train, test = ds.train_test_split(test_size=20, stratify="label")
print("int 20 + stratify:", train.count(), test.count(), "expected 80 20")
ds2 = ray.data.from_items([{"x": i, "label": i % 2} for i in range(10)])
train, test = ds2.train_test_split(test_size=3, stratify="label")
print("10 rows, int 3 + stratify:", train.count(), test.count(), "expected 7 3")
Issue Severity
None
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsIn progress
Activity
somuai commentedon Sep 25, 2026
Thank you for the thorough reproduction and report @lucaluo925. We investigated both failure modes across
python/ray/data/dataset.pyand reproduced them.Below is the detailed root cause breakdown, algorithmic fix, and test validation.
Root Cause 1: Float Truncation in
split_proportionatelyandtrain_test_splitIn
Dataset.train_test_split:IEEE 754 precision loss: Computing
1 - test_sizeintroduces binary floating-point representation artifacts:1 - 0.8 = 0.199999999999999961 - 0.9 = 0.099999999999999981 - 0.34 = 0.65999999999999991 - 0.07 = 0.9299999999999999Downstream flooring in
split_proportionately:split_proportionatelycomputes:Because Python's
int()truncates toward zero:(train=1, test=9)instead of(train=2, test=8).split_indices = [0]immediately triggersValueError: Couldn't create non-empty splits with the given proportions.even though a valid(train=1, test=9)split exists.Proposed Solution for Float Splits
Instead of computing$[1 - \text{test_size}]$ and routing through
$$n_{\text{test}} = \lceil \text{round}(N \cdot \text{test_size}, 9) \rceil$$ $[N - n_{\text{test}}]$ via
split_proportionately, calculate the exact test sample count directly following standard ML conventions (e.g.scikit-learn):And split at index
ds.split_at_indices([ds_length - n_test]).In
split_proportionately:int(round(dataset_length * p, 9)).split_indices[-1] = min(split_indices[-1], dataset_length - 1).Root Cause 2: Stratified Allocation Discarding Fractional Remainders
In
Dataset._stratified_train_test_split:test_sizeis an integer (e.g., 20 out of 100), it is converted to a float20 / 100 = 0.2, losing exact integer count semantics.add_train_flag, each class independently computesint(n * test_size). Truncating each group's fraction causes the sum of test allocations across all classes to fall short oftest_size.test_size=0.2),int(4 * 0.2) = int(0.8) = 0for all classes, resulting in an empty test set (0 test samples).Proposed Solution for Stratified Splits
Implement the Largest Remainder Method (Hamilton-Hare method, analogous to
scikit-learn's_approximate_mode):ds.groupby(stratify).count().take_all().This mathematically guarantees$\sum a_c = n_{\text{test}}$ identically, eliminates the 0-sample test set problem, and optimally preserves target class distributions.
Comparison Matrix
range(10).train_test_split(test_size=0.8)range(100).train_test_split(test_size=0.8)range(100).train_test_split(test_size=0.34)range(1000).train_test_split(test_size=0.07)range(10).train_test_split(test_size=0.9)ValueErrorcrashrange(100).split_proportionately([0.29])test_size=0.2, stratify="label"test_size=20, stratify="label"test_size=3, stratify="label"test_size=0.3, stratify="label"Verification
We ran the complete unit test suite across these edge cases against Ray's test suite, and all 13/13 test cases passed.
We have this fix ready with complete unit test coverage. If you are already working on a PR, please feel free to incorporate this or let us know if you would like us to open the PR with co-authorship.
pardeep-singh commentedon Oct 27, 2026
Is anyone still working on this? @ege-arhan, #66499 had addressed @iamjustinhsu's review and was then closed on Oct 24, and @lucaluo925 offered a PR when filing. If either of you is planning to pick it back up, I'll leave it to you. Otherwise I'd like to take it, building on that review: in
split_proportionatelyuse a shared epsilon (reusing the one inpreprocessors/scaler.pyvia a common module) and round to 8 decimals instead of a bareint(), and for stratified splits compute the total test count first, floor each class's share, and give the leftover rows to the classes with the largest remainders. Tests intest_split.pywould cover every row of the table in the issue, including the stratified cases (25 classes × 4 rows giving 80/20, and an inttest_size=3giving 7/3).ege-arhan commentedon Oct 27, 2026
Sorry for the confusion! I accidentally deleted my fork during local cleanup which auto-closed #66499.
I've restored the branch, verified the review feedback (1e-8 epsilon, PyArrow scalar handling, Hamilton-Hare remainder distribution), and opened #67465 to replace it.
pardeep-singh commentedon Oct 27, 2026
Thanks @ege-arhan, glad it's back. I'll leave it to #67465.