From 34ad82c27792e26ca6de34740081d8464373768d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Th=C6=B0=20Uy=C3=AAn?= Date: Sat, 18 Jul 2026 09:18:00 +0700 Subject: [PATCH 1/2] Enhance TimestampSplit to support ratio-based data splitting --- cornac/eval_methods/timestamp_split.py | 91 +++++++++++++++---- docs/source/api_ref/eval_methods.rst | 5 + .../eval_methods/test_timestamp_split.py | 59 ++++++++++++ 3 files changed, 139 insertions(+), 16 deletions(-) diff --git a/cornac/eval_methods/timestamp_split.py b/cornac/eval_methods/timestamp_split.py index 27404e84b..b254a6a27 100644 --- a/cornac/eval_methods/timestamp_split.py +++ b/cornac/eval_methods/timestamp_split.py @@ -14,13 +14,21 @@ # ============================================================================ from .base_method import BaseMethod +from .ratio_split import RatioSplit from ..utils.common import safe_indexing class TimestampSplit(BaseMethod): - """Splitting data into training, validation, and test sets by absolute timestamp cutoffs. + """Splitting data into training, validation, and test sets chronologically by timestamp. - Given two timestamps `val_timestamp` and `test_timestamp`, interactions are partitioned as: + The split point can be given in two mutually-exclusive ways: + + 1. **Absolute cutoffs** — provide `val_timestamp` and `test_timestamp` directly. + 2. **Ratios** — provide `test_size` (and optionally `val_size`), and the cutoff + timestamps are computed automatically so that (approximately) that proportion of + interactions falls into each set. + + In both cases interactions are partitioned as: train: timestamp < val_timestamp validation: val_timestamp <= timestamp < test_timestamp @@ -31,13 +39,28 @@ class TimestampSplit(BaseMethod): data: array-like, required Raw preference data in the quadruplet format [(user_id, item_id, rating_value, timestamp)]. - val_timestamp: int or float, required + val_timestamp: int or float, optional, default: None Cutoff between training and validation sets. Interactions with timestamp strictly - less than this value go into the training set. + less than this value go into the training set. Provide together with `test_timestamp` + to split by absolute cutoffs; leave as `None` to split by ratio instead. - test_timestamp: int or float, required + test_timestamp: int or float, optional, default: None Cutoff between validation and test sets. Interactions with timestamp greater than or equal to this value go into the test set. Must be greater than `val_timestamp`. + Provide together with `val_timestamp` to split by absolute cutoffs; leave as `None` + to split by ratio instead. + + test_size: float, optional, default: None + The proportion of the (chronologically latest) test set, counted by number of + interactions. If > 1 it is treated as an absolute number of interactions. Used + only when `val_timestamp`/`test_timestamp` are not given. Because the split keeps + all interactions sharing a boundary timestamp on the same side (to avoid temporal + leakage), the realized proportion is approximate when timestamps are tied. + + val_size: float, optional, default: None + The proportion of the validation set (the interactions immediately preceding the + test set), counted by number of interactions. If > 1 it is treated as an absolute + number of interactions. Only used together with `test_size`. fmt: str, optional, default: 'UIRT' Format of the input data. Must be 'UIRT' since timestamps are required. @@ -60,8 +83,10 @@ class TimestampSplit(BaseMethod): def __init__( self, data, - val_timestamp, - test_timestamp, + val_timestamp=None, + test_timestamp=None, + test_size=None, + val_size=None, fmt="UIRT", rating_threshold=1.0, seed=None, @@ -84,22 +109,56 @@ def __init__( 'Input data must be in "UIRT" format for splitting by timestamp.' ) - if val_timestamp is None or test_timestamp is None: + if val_timestamp is not None and test_timestamp is not None: + # Absolute-cutoff mode. + if val_timestamp >= test_timestamp: + raise ValueError( + "val_timestamp ({}) must be strictly less than test_timestamp ({}).".format( + val_timestamp, test_timestamp + ) + ) + self.val_timestamp = val_timestamp + self.test_timestamp = test_timestamp + elif test_size is not None: + # Ratio mode: derive cutoffs from the requested proportions. + self.val_timestamp, self.test_timestamp = self._cutoffs_from_ratio( + test_size=test_size, val_size=val_size + ) + else: raise ValueError( - "Both val_timestamp and test_timestamp are required." + "Provide either both val_timestamp and test_timestamp, or test_size " + "(optionally with val_size) to split by ratio." ) - if val_timestamp >= test_timestamp: + self._split() + + def _cutoffs_from_ratio(self, test_size, val_size): + """Convert requested proportions into (val_timestamp, test_timestamp) cutoffs. + + Ratios are interpreted by interaction count: the chronologically latest + ``test_size`` fraction of interactions forms the test set, and the fraction + immediately before it forms the validation set. Returns cutoff timestamps to be + consumed by :meth:`_split`; ties are handled there via `<`/`>=` thresholds. + """ + data_size = len(self.data) + train_count, val_count, test_count = RatioSplit.validate_size( + val_size=val_size, test_size=test_size, data_size=data_size + ) + + if test_count == 0: raise ValueError( - "val_timestamp ({}) must be strictly less than test_timestamp ({}).".format( - val_timestamp, test_timestamp - ) + "test_size={} yields an empty test set.".format(test_size) ) - self.val_timestamp = val_timestamp - self.test_timestamp = test_timestamp + sorted_ts = sorted(row[3] for row in self.data) - self._split() + # Interactions from index (train_count + val_count) onward go to test. + test_timestamp = sorted_ts[train_count + val_count] + # Validation starts at index train_count; with no validation set the window is + # empty (val_timestamp == test_timestamp). + val_timestamp = sorted_ts[train_count] if val_count > 0 else test_timestamp + + return val_timestamp, test_timestamp def _split(self): train_idx = [] diff --git a/docs/source/api_ref/eval_methods.rst b/docs/source/api_ref/eval_methods.rst index 79e6ee79f..d482f66da 100644 --- a/docs/source/api_ref/eval_methods.rst +++ b/docs/source/api_ref/eval_methods.rst @@ -27,3 +27,8 @@ Stratified Split ---------------- .. automodule:: cornac.eval_methods.stratified_split :members: + +Timestamp Split +--------------- +.. automodule:: cornac.eval_methods.timestamp_split + :members: diff --git a/tests/cornac/eval_methods/test_timestamp_split.py b/tests/cornac/eval_methods/test_timestamp_split.py index 303c5f97f..d69415a79 100644 --- a/tests/cornac/eval_methods/test_timestamp_split.py +++ b/tests/cornac/eval_methods/test_timestamp_split.py @@ -84,6 +84,65 @@ def test_empty_test(self): with self.assertRaises(ValueError): TimestampSplit(self.data, val_timestamp=10, test_timestamp=100) + def test_ratio_split(self): + # 36 rows with unique timestamps 0..35 -> ratios are exact + eval_method = TimestampSplit( + self.data, + test_size=1 / 3, + val_size=1 / 3, + exclude_unknowns=False, + verbose=True, + ) + # cutoffs computed from the requested proportions + self.assertEqual(eval_method.val_timestamp, 12) + self.assertEqual(eval_method.test_timestamp, 24) + self.assertEqual(eval_method.train_set.num_ratings, 12) + self.assertEqual(eval_method.val_set.num_ratings, 12) + self.assertEqual(eval_method.test_set.num_ratings, 12) + + def test_ratio_no_val(self): + eval_method = TimestampSplit( + self.data, + test_size=0.25, + exclude_unknowns=False, + verbose=True, + ) + self.assertEqual(eval_method.test_timestamp, 27) + self.assertEqual(eval_method.val_timestamp, eval_method.test_timestamp) + self.assertEqual(eval_method.train_set.num_ratings, 27) + self.assertIsNone(eval_method.val_set) + self.assertEqual(eval_method.test_set.num_ratings, 9) + + def test_ratio_size_as_count(self): + # test_size > 1 is treated as an absolute number of interactions + eval_method = TimestampSplit( + self.data, + test_size=6, + exclude_unknowns=False, + ) + self.assertEqual(eval_method.test_set.num_ratings, 6) + self.assertEqual(eval_method.train_set.num_ratings, 30) + + def test_ratio_ties_kept_together(self): + # Tied timestamps must stay on one side (no temporal leakage), so the + # realized test proportion is approximate. + data = [ + ("u{}".format(idx), "i{}".format(idx), 1, ts) + for idx, ts in enumerate([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]) + ] + eval_method = TimestampSplit( + data, + test_size=0.2, # asks for ~2 rows, but all ts==1 rows go to test + exclude_unknowns=False, + ) + self.assertEqual(eval_method.test_timestamp, 1) + self.assertEqual(eval_method.test_set.num_ratings, 5) + self.assertEqual(eval_method.train_set.num_ratings, 5) + + def test_missing_all_split_args(self): + with self.assertRaises(ValueError): + TimestampSplit(self.data) + if __name__ == "__main__": unittest.main() From 4da7700b64b6607539e751036640e379c03bfd5b Mon Sep 17 00:00:00 2001 From: hieuddo Date: Sat, 18 Jul 2026 13:44:01 +0800 Subject: [PATCH 2/2] improve timestamp split validation logic --- cornac/eval_methods/timestamp_split.py | 26 ++++++++++++++++-- .../eval_methods/test_timestamp_split.py | 27 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/cornac/eval_methods/timestamp_split.py b/cornac/eval_methods/timestamp_split.py index b254a6a27..754c9266b 100644 --- a/cornac/eval_methods/timestamp_split.py +++ b/cornac/eval_methods/timestamp_split.py @@ -13,6 +13,8 @@ # limitations under the License. # ============================================================================ +import warnings + from .base_method import BaseMethod from .ratio_split import RatioSplit from ..utils.common import safe_indexing @@ -52,14 +54,14 @@ class TimestampSplit(BaseMethod): test_size: float, optional, default: None The proportion of the (chronologically latest) test set, counted by number of - interactions. If > 1 it is treated as an absolute number of interactions. Used + interactions. If >= 1 it is treated as an absolute number of interactions. Used only when `val_timestamp`/`test_timestamp` are not given. Because the split keeps all interactions sharing a boundary timestamp on the same side (to avoid temporal leakage), the realized proportion is approximate when timestamps are tied. val_size: float, optional, default: None The proportion of the validation set (the interactions immediately preceding the - test set), counted by number of interactions. If > 1 it is treated as an absolute + test set), counted by number of interactions. If >= 1 it is treated as an absolute number of interactions. Only used together with `test_size`. fmt: str, optional, default: 'UIRT' @@ -109,6 +111,14 @@ def __init__( 'Input data must be in "UIRT" format for splitting by timestamp.' ) + if (val_timestamp is not None or test_timestamp is not None) and ( + test_size is not None or val_size is not None + ): + raise ValueError( + "Provide either val_timestamp/test_timestamp or test_size/val_size, " + "not a mix of both." + ) + if val_timestamp is not None and test_timestamp is not None: # Absolute-cutoff mode. if val_timestamp >= test_timestamp: @@ -158,6 +168,18 @@ def _cutoffs_from_ratio(self, test_size, val_size): # empty (val_timestamp == test_timestamp). val_timestamp = sorted_ts[train_count] if val_count > 0 else test_timestamp + if val_timestamp == sorted_ts[0]: + raise ValueError( + "Training set is empty: the earliest timestamps are tied across the " + "requested train boundary. Use a smaller test_size/val_size or split " + "by absolute cutoffs instead." + ) + if val_count > 0 and val_timestamp == test_timestamp: + warnings.warn( + "Validation window collapsed due to tied timestamps at the requested " + "boundary; val_set will be None." + ) + return val_timestamp, test_timestamp def _split(self): diff --git a/tests/cornac/eval_methods/test_timestamp_split.py b/tests/cornac/eval_methods/test_timestamp_split.py index d69415a79..1c4333ed1 100644 --- a/tests/cornac/eval_methods/test_timestamp_split.py +++ b/tests/cornac/eval_methods/test_timestamp_split.py @@ -143,6 +143,33 @@ def test_missing_all_split_args(self): with self.assertRaises(ValueError): TimestampSplit(self.data) + def test_mixed_args_raise(self): + with self.assertRaises(ValueError): + TimestampSplit( + self.data, val_timestamp=12, test_timestamp=24, test_size=0.2 + ) + with self.assertRaises(ValueError): + TimestampSplit(self.data, val_timestamp=12, test_size=0.2) + + def test_ratio_collapsed_val_warns(self): + # ties at the boundary swallow the requested validation window + data = [ + ("u{}".format(idx), "i{}".format(idx), 1, ts) + for idx, ts in enumerate([0, 0, 1, 1, 1]) + ] + with self.assertWarns(UserWarning): + eval_method = TimestampSplit( + data, test_size=0.4, val_size=0.2, exclude_unknowns=False + ) + self.assertIsNone(eval_method.val_set) + self.assertEqual(eval_method.test_set.num_ratings, 3) + + def test_ratio_tied_train_boundary(self): + # all timestamps identical -> train would be empty + data = [("u{}".format(idx), "i{}".format(idx), 1, 5) for idx in range(10)] + with self.assertRaisesRegex(ValueError, "tied"): + TimestampSplit(data, test_size=0.2, exclude_unknowns=False) + if __name__ == "__main__": unittest.main()