Skip to content
About
Main content

Random split versus group split versus hash split

Measure your machineNeeds Python with scikit-learn

Problem

A random train/test split puts rows of the same entity on both sides, so a model that memorizes entities (here, couriers with personal biases) scores well on test rows it has effectively seen. Splitting by entity (GroupKFold) tests on unseen entities. A split by hash of the entity id does the same and is stable: adding data never moves an existing row between train and test, while a random split reshuffles.

Generate 50,000 rows for 500 couriers, where each courier has a personal bias in delivery time and the features are weak. Train a deep decision tree with courier_id as a feature. Evaluate with (a) a random split, (b) GroupKFold by courier, (c) a split where hash(courier_id) % 10 == 0 goes to test. Then add 10% more rows and recompute (a) and (c): count how many old test rows change sides.

The question. How does the score differ between random and entity-aware splits, and how stable is each split when data is added?


Predict

After adding 10% more rows and re-drawing the random split, what share of the old test rows move into training?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    random-split-versus-group-split-versus.py58 linesDownload
    View the code
    random-split-versus-group-split-versus.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["numpy", "scikit-learn"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Random split against group split against hash split.
    
    50,000 deliveries for 500 couriers, each with a personal bias and weak
    features. A deep decision tree that may memorise courier_id, evaluated on
    (a) a random split, (b) GroupKFold by courier and (c) a split where
    hash(courier_id) % 10 == 0 goes to test. Then 10% more rows are added and the
    test rows that move to training are counted for (a) and (c).
    """
    import argparse, hashlib, json
    import numpy as np
    from sklearn.metrics import mean_absolute_error
    from sklearn.model_selection import GroupKFold, train_test_split
    from sklearn.tree import DecisionTreeRegressor
    
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("--rows", type=int, default=50_000, help="Rows.")
    p.add_argument("--couriers", type=int, default=500, help="Couriers.")
    p.add_argument("--seed", type=int, default=0, help="Random seed.")
    p.add_argument("--json", help="Also write the results to this JSON file.")
    args = p.parse_args()
    
    rng = np.random.default_rng(args.seed)
    def make(n, start=0):
        cid = rng.integers(0, args.couriers, n); bias = np.random.default_rng(1).normal(0, 5, args.couriers)
        x = rng.normal(0, 1, (n, 3)); y = 30 + bias[cid] + 0.5 * x[:, 0] + rng.normal(0, 3, n)
        return np.column_stack([cid, x]), y, cid, np.arange(start, start + n)
    X, y, g, ids = make(args.rows)
    model = lambda: DecisionTreeRegressor(min_samples_leaf=5, random_state=0)
    def is_test(key): return int(hashlib.md5(str(key).encode()).hexdigest(), 16) % 10 == 0
    rows = []
    tr, te = train_test_split(np.arange(args.rows), test_size=0.1, random_state=0)
    m = model().fit(X[tr], y[tr]); rows.append({"split": "(a) random rows", "test_mae": float(mean_absolute_error(y[te], m.predict(X[te])))})
    maes = []
    for tri, tei in GroupKFold(n_splits=10).split(X, y, g):
        m = model().fit(X[tri], y[tri]); maes.append(mean_absolute_error(y[tei], m.predict(X[tei])))
    rows.append({"split": "(b) GroupKFold by courier", "test_mae": float(np.mean(maes))})
    ht = np.array([is_test(c) for c in g]); m = model().fit(X[~ht], y[~ht])
    rows.append({"split": "(c) hash(courier_id) % 10 == 0", "test_mae": float(mean_absolute_error(y[ht], m.predict(X[ht])))})
    X2, y2, g2, ids2 = make(args.rows // 10, start=args.rows)
    all_ids = np.concatenate([ids, ids2]); all_g = np.concatenate([g, g2])
    tr2, te2 = train_test_split(np.arange(len(all_ids)), test_size=0.1, random_state=0)
    old_test = set(ids[te].tolist()); moved_random = len(old_test - set(all_ids[te2].tolist()))
    ht2 = np.array([is_test(c) for c in all_g]); moved_hash = int((ht & ~ht2[:args.rows]).sum())
    summary = {"random_moved": moved_random, "random_test_rows": len(old_test), "random_moved_pct": moved_random / len(old_test) * 100,
               "hash_moved": moved_hash, "hash_test_rows": int(ht.sum())}
    for r in rows: print(f"{r['split']:<34} test MAE {r['test_mae']:.2f}")
    print(f"after adding 10% more rows: random split moved {moved_random:,} of {len(old_test):,} old test rows into training; hash split moved {moved_hash}")
    
    if args.json:
        with open(args.json, "w") as f:
            json.dump({"summary": summary, "tables": {"splits": rows}}, f)
    
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run random-split-versus-group-split-versus.py
    uv run random-split-versus-group-split-versus.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--rows50,000 Rows.
--couriers500 Couriers.
--seed0 Random seed.

Our run

Test error by split

Test error by split
SplitTest MAE
(a) random rows2.88
(b) GroupKFold by courier6.13
(c) hash(courier_id) % 10 == 05.94
Reference runApple M2 · 8 cores · 8 GB
Output · uv run random-split-versus-group-split-versus.py
(a) random rows                    test MAE 2.88
(b) GroupKFold by courier          test MAE 6.13
(c) hash(courier_id) % 10 == 0     test MAE 5.94
after adding 10% more rows: random split moved 3,774 of 5,000 old test rows into training; hash split moved 0
macOS 26.6.2 · Python 3.13.3 · numpy 2.5.4 · scikit-learn 1.9.1 · run 2026-10-25

What we found

The random split reported a test MAE of 2.88; GroupKFold by courier, 6.13; the hash split, 5.94: the random split was far more optimistic, because the tree learned each courier's bias from rows on the training side. After adding 10% more rows, re-drawing the random split moved 3.77K of 5K old test rows into training; the hash split moved 0.


Read it

Split along the axis the model will meet new data on: new couriers, new customers, new days. A hash split makes that split reproducible and stable as data grows, so scores across retrainings compare like with like.