Temporal split

q.dataset.TemporalSplit creates one fixed chronological split. Partitions can be defined either by row counts or proportions, or by inclusive index boundaries. This notebook starts with a proportional train/validation/test split, then demonstrates exact boundaries, a bounded test period, and a custom split name.

Create the smallest useful dataset

A temporal split needs only an ordered Dataset. Targets, sample weights, and row metadata are optional. This example creates one-session-ahead labels and keeps the latest 12 labelable SPY sessions so the split sizes remain easy to inspect.

metadata is reserved for row-aligned context that is not a model input, such as a label’s end time, symbol, or event identifier. TemporalSplit does not read it; it partitions rows from the dataset index. Metadata becomes necessary for operations such as PurgedTimeSeriesSplit, which uses label end times to remove overlapping training labels.

import qrt as q
spy = q.data.datasets.load("spy")
labels = q.label.fixed_horizon(spy["close"], 1)

dataset = q.dataset.Dataset(
    X=spy.loc[labels.index, ["close"]],
    y=labels,
)

assert dataset.index.is_monotonic_increasing # We ensure that the index is sorted in increasing order, which is important for temporal datasets.

labels
end_time return threshold label
event_time
2000-01-03 2000-01-04 -0.039106 0.0 -1
2000-01-04 2000-01-05 0.001789 0.0 1
2000-01-05 2000-01-06 -0.016071 0.0 -1
2000-01-06 2000-01-07 0.058076 0.0 1
2000-01-07 2000-01-10 0.003430 0.0 1
... ... ... ... ...
2026-07-16 2026-07-17 -0.009897 0.0 -1
2026-07-17 2026-07-20 -0.001614 0.0 -1
2026-07-20 2026-07-21 0.008341 0.0 1
2026-07-21 2026-07-22 -0.001163 0.0 -1
2026-07-22 2026-07-23 -0.012349 0.0 -1

6677 rows × 4 columns

1. Split by proportion or row count

Use size arguments when the desired partition lengths matter more than exact dates. Floats represent proportions of the full dataset; integers represent exact row counts. Partitions are contiguous, preserve chronological order, and consume every row.

Parameter Default Effect
train_size None Training proportion or row count
validation_size None Validation proportion or row count; None omits validation
test_size None Test proportion or row count

Float sizes must sum to 1, and integer sizes must sum to the dataset length. One of train_size or test_size may be omitted to receive the remainder. Size arguments cannot be combined with boundary arguments.

Proportional boundaries are rounded to whole rows while preserving the requested cumulative allocation. For this 12-row dataset, 70/15/15 produces 8 train, 2 validation, and 2 test rows.

proportional = dataset.split(
    q.dataset.TemporalSplit(
        train_size=0.70,
        validation_size=0.15,
        test_size=0.15,
    )
)

q.dataset.split_diagnostics(proportional, "default")
role rows proportion start end
partition
train fit 4673 0.699865 2000-01-03 2018-07-30
validation evaluate 1002 0.150067 2018-07-31 2022-07-22
test holdout 1002 0.150067 2022-07-25 2026-07-22
excluded excluded 0 0.000000 NaT NaT

2. Split with train_end

Use inclusive index boundaries when partition cutoffs must align with specific dates or index values. In boundary mode, train_end is required and the row at that index remains in train. The defaults provide the simplest train/test holdout:

Parameter Default Effect
train_end required Last row assigned to train
validation_end None Omits the validation partition
test_end None Extends test through the final dataset row
name "default" Names the split inside the default scheme
basic = dataset.split(q.dataset.TemporalSplit(train_end="2000-01-05"))

membership = basic.splits["default"].split().membership
basic.X.assign(partition=membership)
close partition
event_time
2000-01-03 91.132759 train
2000-01-04 87.568886 train
2000-01-05 87.725533 train
2000-01-06 86.315674 test
2000-01-07 91.328545 test
... ... ...
2026-07-16 750.719971 test
2026-07-17 743.289978 test
2026-07-20 742.090027 test
2026-07-21 748.280029 test
2026-07-22 747.409973 test

6677 rows × 2 columns

Inspect partition views

The minimum call creates one fixed train/test assignment. train_end belongs to train; because validation_end=None, the next row begins test; because test_end=None, test continues through the final row.

flowchart LR
    TRAIN["train<br/>rows 1-6<br/>includes train_end"] -->|next row| TEST["test<br/>rows 7-12<br/>through dataset end"]
    TEST -.-> EXCLUDED["excluded<br/>0 rows"]
    classDef train fill:#d8f3dc,stroke:#2d6a4f,color:#16382a
    classDef test fill:#ffefc1,stroke:#9c6b00,color:#4d3500
    classDef excluded fill:#eceff1,stroke:#6b7280,color:#374151
    class TRAIN train
    class TEST test
    class EXCLUDED excluded

Splitting returns a new dataset and leaves the original unsplit. Conventional attributes and named indexing return lazy partition views over the split dataset. Diagnostics summarize every partition, including the empty excluded partition.

q.dataset.split_diagnostics(basic, "default")
role rows proportion start end
partition
train fit 3 0.000449 2000-01-03 2000-01-05
test holdout 6674 0.999551 2000-01-06 2026-07-22
excluded excluded 0 0.000000 NaT NaT

3. Add validation_end

Set validation_end to create a three-way boundary split. This boundary is also inclusive: validation begins immediately after train_end and ends at validation_end; test begins on the next row.

validation_end = dataset.index[7]
three_way = dataset.split(
    q.dataset.TemporalSplit(
        train_end=dataset.index[4],
        validation_end=validation_end,
    )
)

q.dataset.split_diagnostics(three_way, "default")
role rows proportion start end
partition
train fit 5 0.000749 2000-01-03 2000-01-07
validation evaluate 3 0.000449 2000-01-10 2000-01-12
test holdout 6669 0.998802 2000-01-13 2026-07-22
excluded excluded 0 0.000000 NaT NaT

4. Bound the test period with test_end

By default, test consumes every row after the preceding boundary. Set test_end when later observations must remain outside this experiment. Rows after that inclusive boundary are retained in the dataset and assigned to excluded.

test_end = dataset.index[9]
bounded = dataset.split(
    q.dataset.TemporalSplit(
        train_end=dataset.index[3],
        validation_end=dataset.index[6],
        test_end=test_end,
    )
)

q.dataset.split_diagnostics(bounded, "default")
role rows proportion start end
partition
train fit 4 0.000599 2000-01-03 2000-01-06
validation evaluate 3 0.000449 2000-01-07 2000-01-11
test holdout 3 0.000449 2000-01-12 2000-01-14
excluded excluded 6667 0.998502 2000-01-18 2026-07-22

5. Give the split a descriptive name

A TemporalSplit is attached as the dataset’s default scheme because it represents one fixed holdout design. Changing name identifies the split within that scheme, which is useful when recording the purpose or date of a holdout.

named = dataset.split(
    q.dataset.TemporalSplit(
        train_end=dataset.index[7],
        name="final_holdout",
    )
)

assert tuple(named.splits) == ("default",)
assert named.splits["default"].split().name == "final_holdout"
q.dataset.split_diagnostics(named, "default", "final_holdout")
role rows proportion start end
partition
train fit 8 0.001198 2000-01-03 2000-01-12
test holdout 6669 0.998802 2000-01-13 2026-07-22
excluded excluded 0 0.000000 NaT NaT

Split rules

Choose TemporalSplit when one fixed chronological holdout is sufficient. The dataset index must be monotonic increasing, and size arguments cannot be combined with boundary arguments.

In size mode, use either all proportions or all row counts. Requested partitions must be non-empty and consume the full dataset. In boundary mode, validation_end must be later than train_end, and test_end must be later than the preceding boundary.

Use TimeSeriesSplit when evaluation must repeat across multiple walk-forward folds, or PurgedTimeSeriesSplit when overlapping label horizons require leakage controls.

Back to top