time-split 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
time_split/_support.py ADDED
@@ -0,0 +1,14 @@
1
+ from typing import Protocol, TypeVar, runtime_checkable
2
+
3
+ T_co = TypeVar("T_co", covariant=True)
4
+
5
+
6
+ @runtime_checkable
7
+ class _Computable(Protocol[T_co]):
8
+ def compute(self) -> T_co:
9
+ """Compute results of a delayed Dask object."""
10
+
11
+
12
+ def handle_dask(arg: T_co | _Computable[T_co]) -> T_co:
13
+ """Run Dask compute function on a delayed object."""
14
+ return arg.compute() if isinstance(arg, _Computable) else arg
@@ -0,0 +1 @@
1
+ """Convenience functions and classes for common libraries."""
@@ -0,0 +1,25 @@
1
+ import logging
2
+ from collections.abc import Iterable
3
+ from typing import Any, Union
4
+
5
+ from .. import log_split_progress
6
+ from .._docstrings import add_docstrings
7
+ from ..types import DatetimeSplitBounds, DatetimeSplits
8
+
9
+ LogProgressArg = Union[str, bool, dict[str, Any], logging.Logger, logging.LoggerAdapter] # type: ignore[type-arg]
10
+
11
+ add_docstrings(log_progress="Controls logging of fold progress. See :func:`~.log_split_progress` for details.")
12
+
13
+
14
+ def handle_log_progress_arg(
15
+ log_progress: LogProgressArg, *, splits: DatetimeSplits
16
+ ) -> Iterable[DatetimeSplitBounds] | None:
17
+ """Wrapper function for integrations."""
18
+ if log_progress is True:
19
+ return log_split_progress(splits)
20
+ elif isinstance(log_progress, (str, logging.Logger, logging.LoggerAdapter)):
21
+ return log_split_progress(splits, logger=log_progress)
22
+ elif isinstance(log_progress, dict):
23
+ return log_split_progress(splits, **log_progress)
24
+
25
+ return None
@@ -0,0 +1,42 @@
1
+ r"""Integration with the Pandas library.
2
+
3
+ Examples:
4
+ Splitting a ``pandas.Series`` with :func:`split_pandas`.
5
+
6
+ >>> import pandas as pd
7
+ >>> index = pd.date_range("2022", "2022-1-10", freq="h")
8
+ >>> series = pd.Series(range(len(index)), index=index)
9
+ >>> series.sample(3, random_state=1999)
10
+ 2022-01-04 00:00:00 72
11
+ 2022-01-03 21:00:00 69
12
+ 2022-01-04 14:00:00 86
13
+ dtype: int64
14
+
15
+ Series may only be split on the index. The `schedule` keyword argument is required, but `log_progress` is not.
16
+
17
+ >>> for fold in split_pandas(
18
+ ... series, schedule="1d", log_progress="progress"
19
+ ... ): # doctest: +SKIP
20
+ ... print(
21
+ ... f"Summary of fold {tuple(map(pd.Timestamp.isoformat, fold.bounds))}:"
22
+ ... f"\n {fold.data.mean()=}"
23
+ ... f"\n {fold.future_data.mean()=}",
24
+ ... )
25
+ INFO:progress:Begin fold 1/2: ('2022-01-01' <= [schedule: '2022-01-08' (Saturday)] < '2022-01-09').
26
+ Summary of fold ('2022-01-01T00:00:00', '2022-01-08T00:00:00', '2022-01-09T00:00:00'):
27
+ fold.data.mean()=83.5
28
+ fold.future_data.mean()=179.5
29
+ INFO:progress:Finished fold 1/2 [schedule: '2022-01-08' (Saturday)] after 1ms. # doctest: +SKIP
30
+ INFO:progress:Begin fold 2/2: ('2022-01-02' <= [schedule: '2022-01-09' (Sunday)] < '2022-01-10').
31
+ Summary of fold ('2022-01-02T00:00:00', '2022-01-09T00:00:00', '2022-01-10T00:00:00'):
32
+ fold.data.mean()=107.5
33
+ fold.future_data.mean()=203.5
34
+ INFO:progress:Finished fold 2/2 [schedule: '2022-01-09' (Sunday)] after 873μs.
35
+
36
+ When splitting dataframes, you may optionally pass a `time_column` argument as well. By default, both frames and
37
+ series are split along the index.
38
+ """
39
+
40
+ from ._impl import PandasT, split_pandas
41
+
42
+ __all__ = ["PandasT", "split_pandas"]
@@ -0,0 +1,81 @@
1
+ from collections.abc import Hashable, Iterable
2
+ from datetime import date, datetime
3
+ from typing import Generic, TypeVar, Unpack
4
+
5
+ from pandas import DataFrame, DatetimeIndex, Series, Timestamp
6
+ from rics.misc import tname
7
+
8
+ from ..._docstrings import docs
9
+ from ...types import DatetimeIndexSplitterKwargs
10
+ from .._log_progress import LogProgressArg
11
+ from ..split_data import DatetimeSplit, split_data
12
+
13
+ PandasT = TypeVar("PandasT", Series, DataFrame)
14
+ """A splittable pandas type."""
15
+
16
+
17
+ @docs
18
+ def split_pandas(
19
+ data: PandasT,
20
+ time_column: Hashable = None,
21
+ *,
22
+ log_progress: LogProgressArg = False,
23
+ **kwargs: Unpack[DatetimeIndexSplitterKwargs],
24
+ ) -> Iterable[DatetimeSplit[PandasT]]:
25
+ """Split a pandas type.
26
+
27
+ This function splits indexed data (i.e. ``Series`` and ``DataFrame``, not the index itself. Use
28
+ :func:`time_split.split <time_split.split>` for pandas ``Index`` types, setting ``available=data.index``.
29
+
30
+ Args:
31
+ data: A pandas data container type to split; either ``Series`` or a ``DataFrame``.
32
+ time_column: A column in `data` to split on. Use ``data`.index`` if ``None``.
33
+ log_progress: {log_progress}
34
+ **kwargs: {DatetimeIndexSplitterKwargs}
35
+
36
+ {USER_GUIDE}
37
+
38
+ Yields:
39
+ Tuples ``(data, future_data, bounds)``.
40
+
41
+ Raises:
42
+ TypeError: If `time_column` does not denote a datetime index-like field.
43
+
44
+ """
45
+ indexer = _Indexer(time_column)
46
+
47
+ yield from split_data(
48
+ data,
49
+ log_progress=log_progress,
50
+ as_available=indexer.as_available,
51
+ select=indexer.select,
52
+ **kwargs,
53
+ )
54
+
55
+
56
+ class _Indexer(Generic[PandasT]):
57
+ def __init__(self, time_column: Hashable | None) -> None:
58
+ self.time_column = time_column
59
+
60
+ def as_available(self, data: PandasT) -> Series | DatetimeIndex:
61
+ time = self._get_time(data)
62
+
63
+ first = time.iloc[0] if hasattr(time, "iloc") else time[0]
64
+ if isinstance(first, date):
65
+ return time
66
+
67
+ type_str = "data.index" if self.time_column is None else f"data[{self.time_column!r}]"
68
+ msg = f"Elements of {type_str} element are {tname(first)}, expected datetime-like."
69
+ raise TypeError(msg)
70
+
71
+ def _get_time(self, data: DataFrame) -> Series:
72
+ return data.index if self.time_column is None else data[self.time_column]
73
+
74
+ def select(self, data: PandasT, left: datetime, right: datetime) -> PandasT:
75
+ """Select data based on the given bounds."""
76
+ time = self._get_time(data)
77
+ if isinstance(time, Series):
78
+ return data[time.between(left, right, inclusive="left")]
79
+ else:
80
+ # Index slicing is a lot faster than boolean masks (empirically, seems to be a factor ~10).
81
+ return data[left : right - Timestamp.resolution] # type: ignore[misc]
@@ -0,0 +1,57 @@
1
+ r"""Integration with the Polars library.
2
+
3
+ Examples:
4
+ Splitting a ``polars.DataFrame`` with :func:`split_polars`.
5
+
6
+ >>> import polars as pl
7
+ >>> from datetime import date
8
+ >>> ts = pl.datetime_range(
9
+ ... date.fromisoformat("2022-01-01"),
10
+ ... date.fromisoformat("2022-01-10"),
11
+ ... interval="1h",
12
+ ... eager=True,
13
+ ... )
14
+ >>> df = pl.DataFrame({"timestamp": ts, "ints": range(len(ts))})
15
+ >>> df.sample(5, seed=1999)
16
+ shape: (5, 2)
17
+ ┌─────────────────────┬──────┐
18
+ │ timestamp ┆ ints │
19
+ │ --- ┆ --- │
20
+ │ datetime[μs] ┆ i64 │
21
+ ╞═════════════════════╪══════╡
22
+ │ 2022-01-06 16:00:00 ┆ 136 │
23
+ │ 2022-01-08 17:00:00 ┆ 185 │
24
+ │ 2022-01-01 06:00:00 ┆ 6 │
25
+ │ 2022-01-01 05:00:00 ┆ 5 │
26
+ │ 2022-01-03 08:00:00 ┆ 56 │
27
+ └─────────────────────┴──────┘
28
+
29
+ To split the frame, pass it to ``split_polars`` along with the column to split on. The `schedule` keyword argument
30
+ is required, but `log_progress` is not.
31
+
32
+ >>> for fold in split_polars(
33
+ ... df, schedule="1d", log_progress="progress", time_column="timestamp"
34
+ ... ): # doctest: +SKIP
35
+ ... print(
36
+ ... f"Summary of fold {tuple(map(pd.Timestamp.isoformat, fold.bounds))}:"
37
+ ... f"\n {fold.data['ints'].mean()=}"
38
+ ... f"\n {fold.future_data['ints'].mean()=}",
39
+ ... )
40
+ INFO:progress:Begin fold 1/2: '2022-01-01' <= [schedule: '2022-01-08' (Saturday)] < '2022-01-09'.
41
+ Summary of fold ('2022-01-01T00:00:00', '2022-01-08T00:00:00', '2022-01-09T00:00:00'):
42
+ fold.data['ints'].mean()=83.5
43
+ fold.future_data['ints'].mean()=179.5
44
+ INFO:progress:Finished fold 1/2: [schedule: '2022-01-08' (Saturday)] after 542μs.
45
+ INFO:progress:Begin fold 2/2: '2022-01-02' <= [schedule: '2022-01-09' (Sunday)] < '2022-01-10'.
46
+ Summary of fold ('2022-01-02T00:00:00', '2022-01-09T00:00:00', '2022-01-10T00:00:00'):
47
+ fold.data['ints'].mean()=107.5
48
+ fold.future_data['ints'].mean()=203.5
49
+ INFO:progress:Finished fold 2/2: [schedule: '2022-01-09' (Sunday)] after 433μs.
50
+
51
+ """
52
+
53
+ from ._impl import split_polars
54
+
55
+ __all__ = [
56
+ "split_polars",
57
+ ]
@@ -0,0 +1,69 @@
1
+ from collections.abc import Iterable
2
+ from datetime import date, datetime
3
+ from typing import Unpack
4
+
5
+ from polars import DataFrame, Series
6
+ from rics.misc import tname
7
+
8
+ from ..._docstrings import docs
9
+ from ...types import DatetimeIndexSplitterKwargs
10
+ from .._log_progress import LogProgressArg
11
+ from ..split_data import DatetimeSplit, split_data
12
+
13
+
14
+ @docs
15
+ def split_polars(
16
+ data: DataFrame,
17
+ time_column: str,
18
+ *,
19
+ log_progress: LogProgressArg = False,
20
+ **kwargs: Unpack[DatetimeIndexSplitterKwargs],
21
+ ) -> Iterable[DatetimeSplit[DataFrame]]:
22
+ """Split a polars frame.
23
+
24
+ Args:
25
+ data: A ``polars.DataFrame``.
26
+ time_column: A column to split on.
27
+ log_progress: {log_progress}
28
+ **kwargs: {DatetimeIndexSplitterKwargs}
29
+
30
+ {USER_GUIDE}
31
+
32
+ Yields:
33
+ Tuples ``(data, future_data, bounds)``.
34
+
35
+ Raises:
36
+ TypeError: If `time_column` does not denote a datetime index-like field.
37
+
38
+ """
39
+ indexer = _Indexer(time_column)
40
+
41
+ yield from split_data(
42
+ data,
43
+ log_progress=log_progress,
44
+ as_available=indexer.as_available,
45
+ select=indexer.select,
46
+ **kwargs,
47
+ )
48
+
49
+
50
+ class _Indexer:
51
+ def __init__(self, time_column: str) -> None:
52
+ self.time_column = time_column
53
+
54
+ def as_available(self, data: DataFrame) -> Series:
55
+ time = self._get_time(data)
56
+
57
+ if isinstance(time[0], date):
58
+ return time
59
+
60
+ msg = f"Elements of data[{self.time_column!r}] element are {tname(time[0])}, expected datetime-like."
61
+ raise TypeError(msg)
62
+
63
+ def _get_time(self, data: DataFrame) -> Series:
64
+ return data[self.time_column]
65
+
66
+ def select(self, data: DataFrame, left: datetime, right: datetime) -> DataFrame:
67
+ """Select data based on the given bounds."""
68
+ time = self._get_time(data)
69
+ return data.filter(time.is_between(left, right, closed="left"))
@@ -0,0 +1,7 @@
1
+ """Integration with the scikit-learn library."""
2
+
3
+ from ._impl import ScikitLearnSplitter
4
+
5
+ __all__ = [
6
+ "ScikitLearnSplitter",
7
+ ]
@@ -0,0 +1,170 @@
1
+ from collections.abc import Iterable, Sequence
2
+ from typing import Any, cast, get_args
3
+
4
+ from numpy import array, datetime64, logical_and, ndarray, nonzero
5
+ from numpy.typing import NDArray
6
+
7
+ from ..._backend import DatetimeIndexSplitter
8
+ from ..._docstrings import docs
9
+ from ...types import (
10
+ DatetimeIterable,
11
+ DatetimeSplits,
12
+ DatetimeTypes,
13
+ Flex,
14
+ Schedule,
15
+ Span,
16
+ )
17
+ from .._log_progress import LogProgressArg, handle_log_progress_arg
18
+
19
+ try:
20
+ from sklearn.model_selection import BaseCrossValidator # type: ignore[import-untyped]
21
+
22
+ except ModuleNotFoundError:
23
+ BaseCrossValidator = object
24
+
25
+ IndexTuple = tuple[Sequence[int], Sequence[int]]
26
+
27
+
28
+ @docs
29
+ class ScikitLearnSplitter(BaseCrossValidator): # type: ignore[misc]
30
+ """A scikit-learn compatible datetime splitter.
31
+
32
+ This class may be used to create temporal folds from heterogeneous/unaggregated data, typically used for training
33
+ models (e.g. on raw transaction data). If your data is a well-formed time series, consider using the
34
+ `TimeSeriesSplit`_ class from scikit-learn instead.
35
+
36
+ .. _TimeSeriesSplit: https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html
37
+
38
+ If a ``pandas`` type is passed to the :meth:`ScikitLearnSplitter.split`-method, the index will be used.
39
+
40
+ Args:
41
+ schedule: {schedule}
42
+ before: {before}
43
+ after: {after}
44
+ step: {step}
45
+ n_splits: {n_splits}
46
+ flex: {flex}
47
+ log_progress: {log_progress}
48
+ verify_xy: If ``True``, split X and y independently and verify that they are equal.
49
+
50
+ {USER_GUIDE}
51
+
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ schedule: Schedule,
57
+ *,
58
+ before: Span = "7d",
59
+ after: Span = 1,
60
+ n_splits: int | None = None,
61
+ flex: Flex = "auto",
62
+ step: int = 1,
63
+ log_progress: LogProgressArg = False,
64
+ verify_xy: bool = True,
65
+ ) -> None:
66
+ super().__init__()
67
+ self._splitter = DatetimeIndexSplitter(
68
+ schedule,
69
+ before=before,
70
+ after=after,
71
+ step=step,
72
+ n_splits=n_splits,
73
+ flex=flex,
74
+ )
75
+ self.log_progress = log_progress
76
+ self.verify_xy = verify_xy
77
+
78
+ def get_n_splits(
79
+ self, X: DatetimeIterable | None = None, y: DatetimeIterable | None = None, groups: Any = None
80
+ ) -> int:
81
+ """Returns the number of splitting iterations in the cross-validator.
82
+
83
+ Equivalent to ``len(list(split(X, y, groups))``.
84
+
85
+ Args:
86
+ X: Training data (features).
87
+ y: Target variable.
88
+ groups: Always ignored, exists for compatibility.
89
+
90
+ Returns:
91
+ Number of splits with given arguments.
92
+
93
+ Raises:
94
+ ValueError: If both `X` and `y` are ``None``.
95
+ ValueError: If splits of `X` and `y` are not equal when ``verify_xy=True``.
96
+
97
+ """
98
+ _, splits = self._get_splits(X, y)
99
+ return len(splits)
100
+
101
+ def split(
102
+ self,
103
+ X: DatetimeIterable | None = None,
104
+ y: DatetimeIterable | None = None,
105
+ groups: Any = None,
106
+ ) -> Iterable[IndexTuple]:
107
+ """Generate indices to split data into training and test set.
108
+
109
+ Args:
110
+ X: Training data (features).
111
+ y: Target variable.
112
+ groups: Always ignored, exists for compatibility.
113
+
114
+ Yields:
115
+ The training/test set indices for that split.
116
+
117
+ Raises:
118
+ ValueError: If both `X` and `y` are ``None``.
119
+ ValueError: If splits of `X` and `y` are not equal when ``verify_xy=True``.
120
+ TypeError: If `X` or `y` have an ``index``-attribute, but index elements are not datetime-like.
121
+
122
+ """
123
+ index, splits = self._get_splits(self._handle_pandas(X, "X"), self._handle_pandas(y, "y"))
124
+ for fold in handle_log_progress_arg(self.log_progress, splits=splits) or splits:
125
+ yield cast(
126
+ IndexTuple,
127
+ (
128
+ nonzero(logical_and(fold.start <= index, index < fold.mid))[0],
129
+ nonzero(logical_and(fold.mid <= index, index < fold.end))[0],
130
+ ),
131
+ )
132
+
133
+ @staticmethod
134
+ def _handle_pandas(arg: DatetimeIterable | None, name: str) -> DatetimeIterable | None:
135
+ if arg is None or not hasattr(arg, "index"):
136
+ return arg
137
+
138
+ index = arg.index
139
+
140
+ if type(arg.index[0]) in get_args(DatetimeTypes):
141
+ return cast(DatetimeIterable, index)
142
+
143
+ raise TypeError(f"{name}.index does not appear to be a datetime-like iterable.")
144
+
145
+ def _get_splits(
146
+ self,
147
+ X: DatetimeIterable | None = None,
148
+ y: DatetimeIterable | None = None,
149
+ ) -> tuple[NDArray[datetime64], DatetimeSplits]:
150
+ splits: DatetimeSplits | None = None
151
+ y_splits: DatetimeSplits | None = None
152
+ timestamps: Any | None = None
153
+
154
+ if X is not None:
155
+ splits = self._splitter.get_splits(X)
156
+ timestamps = X
157
+ if y is not None:
158
+ y_splits = self._splitter.get_splits(y)
159
+ timestamps = y
160
+ if self.verify_xy and splits and y_splits and splits != y_splits:
161
+ raise ValueError("Splits of X and y are not equal.")
162
+
163
+ # Cast should not be needed, but MyPy things this is nullable.
164
+ if splits is None and y_splits is None:
165
+ raise ValueError("At least one of (X, y) must be given.")
166
+
167
+ timestamps = timestamps if isinstance(timestamps, ndarray) else array(timestamps)
168
+ if len(timestamps.shape) > 1:
169
+ raise NotImplementedError(f"shape {timestamps.shape} not supported")
170
+ return timestamps, cast(DatetimeSplits, splits or y_splits)
@@ -0,0 +1,72 @@
1
+ """Base implementations for splitting generic data types.
2
+
3
+ Users may implement splitting of any data type by implementing suitable ``as_available`` and ``select`` functions.
4
+ """
5
+
6
+ import typing as _t
7
+ from datetime import datetime as _datetime
8
+
9
+ from .. import _frontend, types
10
+ from .._docstrings import docs as _docs
11
+ from . import _log_progress
12
+
13
+ DataT = _t.TypeVar("DataT")
14
+ """Type of data to split."""
15
+
16
+ DataAsAvailableFn = _t.Callable[[DataT], types.DatetimeIterable]
17
+ """A callable ``(data: DataT) -> DatetimeIterable``."""
18
+ DataSelectFn = _t.Callable[[DataT, _datetime, _datetime], DataT]
19
+ """A callable ``(data: DataT, left_inclusive: datetime, end_exclusive: datetime) -> DataT)``."""
20
+
21
+
22
+ class DatetimeSplit(_t.NamedTuple, _t.Generic[DataT]):
23
+ """Time-based split of a generic data type."""
24
+
25
+ data: DataT
26
+ """Data before ``bounds.mid``."""
27
+ future_data: DataT
28
+ """Data after ``bounds.mid``."""
29
+ bounds: types.DatetimeSplitBounds
30
+ """The underlying bounds that produced this split."""
31
+
32
+
33
+ @_docs
34
+ def split_data(
35
+ data: DataT,
36
+ *,
37
+ log_progress: _log_progress.LogProgressArg = False,
38
+ as_available: DataAsAvailableFn[DataT],
39
+ select: DataSelectFn[DataT],
40
+ **kwargs: _t.Unpack[types.DatetimeIndexSplitterKwargs],
41
+ ) -> _t.Iterable[DatetimeSplit[DataT]]:
42
+ """Base implementation for splitting integrated `data` types.
43
+
44
+ The required ``as_available`` and ``select`` callables provided perform the actual integration.
45
+
46
+ Args:
47
+ data: The data to split.
48
+ log_progress: {log_progress}
49
+ as_available: A callable ``(data: DataT) -> DatetimeIterable``.
50
+ select: A callable ``(data: DataT, left_inclusive: datetime, end_exclusive: datetime) -> DataT)``.
51
+ **kwargs: Keyword arguments for :func:`.split`-function.
52
+
53
+ Yields:
54
+ Tuples ``(data, future_data, bounds)``.
55
+
56
+ See Also:
57
+ To get started with your own integration, copy :func:`~time_split.integration.pandas.split_pandas` or
58
+ :func:`~time_split.integration.polars.split_polars` and use it as the baseline (click ``[source]``) on
59
+ the linked function.
60
+
61
+ """
62
+ available = as_available(data)
63
+ splits = _frontend.split(**kwargs, available=available)
64
+
65
+ tracked_splits = _log_progress.handle_log_progress_arg(log_progress, splits=splits)
66
+
67
+ for bounds in splits if tracked_splits is None else tracked_splits:
68
+ yield DatetimeSplit(
69
+ select(data, bounds.start, bounds.mid),
70
+ future_data=select(data, bounds.mid, bounds.end),
71
+ bounds=bounds,
72
+ )
time_split/py.typed ADDED
File without changes