# Copyright 2018 The Cornac Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ============================================================================
import warnings
from .base_method import BaseMethod
from .ratio_split import RatioSplit
from ..utils.common import safe_indexing
[docs]
class TimestampSplit(BaseMethod):
"""Splitting data into training, validation, and test sets chronologically by timestamp.
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
test: timestamp >= test_timestamp
Parameters
----------
data: array-like, required
Raw preference data in the quadruplet format [(user_id, item_id, rating_value, timestamp)].
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. Provide together with `test_timestamp`
to split by absolute cutoffs; leave as `None` to split by ratio instead.
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.
rating_threshold: float, optional, default: 1.0
Threshold used to binarize rating values into positive or negative feedback for
model evaluation using ranking metrics (rating metrics are not affected).
seed: int, optional, default: None
Random seed for reproducibility.
exclude_unknowns: bool, optional, default: True
If `True`, unknown users and items will be ignored during model evaluation.
verbose: bool, optional, default: False
Output running log.
"""
def __init__(
self,
data,
val_timestamp=None,
test_timestamp=None,
test_size=None,
val_size=None,
fmt="UIRT",
rating_threshold=1.0,
seed=None,
exclude_unknowns=True,
verbose=False,
**kwargs
):
super().__init__(
data=data,
fmt=fmt,
rating_threshold=rating_threshold,
seed=seed,
exclude_unknowns=exclude_unknowns,
verbose=verbose,
**kwargs
)
if fmt != "UIRT" or len(self.data[0]) != 4:
raise ValueError(
'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:
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(
"Provide either both val_timestamp and test_timestamp, or test_size "
"(optionally with val_size) to split by ratio."
)
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(
"test_size={} yields an empty test set.".format(test_size)
)
sorted_ts = sorted(row[3] for row in self.data)
# 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
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):
train_idx = []
val_idx = []
test_idx = []
for idx, row in enumerate(self.data):
ts = row[3]
if ts < self.val_timestamp:
train_idx.append(idx)
elif ts < self.test_timestamp:
val_idx.append(idx)
else:
test_idx.append(idx)
if len(train_idx) == 0:
raise ValueError(
"Training set is empty. val_timestamp may be too small."
)
if len(test_idx) == 0:
raise ValueError(
"Test set is empty. test_timestamp may be too large."
)
train_data = safe_indexing(self.data, train_idx)
test_data = safe_indexing(self.data, test_idx)
val_data = safe_indexing(self.data, val_idx) if len(val_idx) > 0 else None
self.build(train_data=train_data, test_data=test_data, val_data=val_data)