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/__init__.py +16 -0
- time_split/_backend/__init__.py +4 -0
- time_split/_backend/_available.py +57 -0
- time_split/_backend/_datetime_index_like.py +19 -0
- time_split/_backend/_limits.py +151 -0
- time_split/_backend/_schedule.py +66 -0
- time_split/_backend/_span.py +128 -0
- time_split/_backend/_splitter.py +162 -0
- time_split/_docstrings.py +36 -0
- time_split/_frontend/__init__.py +13 -0
- time_split/_frontend/_plot.py +355 -0
- time_split/_frontend/_progress.py +125 -0
- time_split/_frontend/_split.py +45 -0
- time_split/_frontend/_to_string.py +103 -0
- time_split/_frontend/_weight.py +66 -0
- time_split/_support.py +14 -0
- time_split/integration/__init__.py +1 -0
- time_split/integration/_log_progress.py +25 -0
- time_split/integration/pandas/__init__.py +42 -0
- time_split/integration/pandas/_impl.py +81 -0
- time_split/integration/polars/__init__.py +57 -0
- time_split/integration/polars/_impl.py +69 -0
- time_split/integration/sklearn/__init__.py +7 -0
- time_split/integration/sklearn/_impl.py +170 -0
- time_split/integration/split_data.py +72 -0
- time_split/py.typed +0 -0
- time_split/settings.py +172 -0
- time_split/support/__init__.py +21 -0
- time_split/types.py +61 -0
- time_split-0.1.0.dist-info/LICENSE.md +21 -0
- time_split-0.1.0.dist-info/METADATA +97 -0
- time_split-0.1.0.dist-info/RECORD +34 -0
- time_split-0.1.0.dist-info/WHEEL +4 -0
- time_split-0.1.0.dist-info/entry_points.txt +0 -0
time_split/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Create temporal k-folds for cross-validation with heterogeneous data."""
|
|
2
|
+
|
|
3
|
+
import logging as _logging
|
|
4
|
+
|
|
5
|
+
from ._frontend import log_split_progress, plot, split
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"log_split_progress",
|
|
9
|
+
"plot",
|
|
10
|
+
"split",
|
|
11
|
+
"__version__", # Make MyPy happy
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
_logging.getLogger(__name__).addHandler(_logging.NullHandler())
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from typing import NamedTuple
|
|
2
|
+
|
|
3
|
+
from pandas import DatetimeIndex, Timestamp, isna
|
|
4
|
+
|
|
5
|
+
from .._support import handle_dask
|
|
6
|
+
from ..types import DatetimeIterable, Flex
|
|
7
|
+
from ._datetime_index_like import DatetimeIndexLike
|
|
8
|
+
from ._limits import LimitsTuple, expand_limits
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AvailableMetadata(NamedTuple):
|
|
12
|
+
"""Output of :func:`process_available`."""
|
|
13
|
+
|
|
14
|
+
available_as_index: DatetimeIndexLike | None
|
|
15
|
+
limits: LimitsTuple
|
|
16
|
+
expanded_limits: LimitsTuple
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def process_available(available: DatetimeIterable, *, flex: Flex) -> AvailableMetadata:
|
|
20
|
+
"""Process a user-given `available` argument.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
available: Available data from user. May be ``None``
|
|
24
|
+
flex: Flex-argument. Determines how much (if at all) to expand limits.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A tuple ``(available, limits)``. Note that `available` will be ``None``, it has not been iterated over. This
|
|
28
|
+
assures that iterables are not consumed unless needed.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: For invalid `available` arguments.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
if isinstance(available, DatetimeIndexLike):
|
|
35
|
+
# Avoids conversion, which may be expensive (or impossible on the current machine),
|
|
36
|
+
# and leverages additional non-Pandas types that do vectorized min/max.
|
|
37
|
+
limits = _compute_data_limits(available)
|
|
38
|
+
available_retval = available
|
|
39
|
+
else:
|
|
40
|
+
available_retval = DatetimeIndex(available)
|
|
41
|
+
limits = _compute_data_limits(available_retval)
|
|
42
|
+
|
|
43
|
+
min_dt, max_dt = limits
|
|
44
|
+
if min_dt == max_dt or isna(min_dt) or isna(max_dt):
|
|
45
|
+
raise ValueError("Not enough data in 'available'; at least two unique elements are required.")
|
|
46
|
+
|
|
47
|
+
if not (isinstance(min_dt, Timestamp) and isinstance(max_dt, Timestamp)):
|
|
48
|
+
# Enforce pandas types; we might have a numpy.datetime64
|
|
49
|
+
# (croniter 1.4.1 + numpy 1.25.2): croniter cannot handle numpy.datetime64
|
|
50
|
+
limits = Timestamp(min_dt), Timestamp(max_dt)
|
|
51
|
+
|
|
52
|
+
expanded_limits = expand_limits(limits, flex=flex)
|
|
53
|
+
return AvailableMetadata(available_retval, limits=limits, expanded_limits=expanded_limits)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _compute_data_limits(available: DatetimeIndexLike) -> LimitsTuple:
|
|
57
|
+
return handle_dask(available.min()), handle_dask(available.max())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from collections.abc import Iterable, Iterator
|
|
2
|
+
from typing import Protocol, runtime_checkable
|
|
3
|
+
|
|
4
|
+
from pandas import Series
|
|
5
|
+
|
|
6
|
+
from ..types import DatetimeTypes
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@runtime_checkable
|
|
10
|
+
class DatetimeIndexLike(Protocol, Iterable[DatetimeTypes]):
|
|
11
|
+
"""A type that behaves (sort of) like a pandas DatetimeIndex."""
|
|
12
|
+
|
|
13
|
+
def __iter__(self) -> Iterator[DatetimeTypes]: ...
|
|
14
|
+
|
|
15
|
+
def value_counts(self) -> Series: ...
|
|
16
|
+
|
|
17
|
+
def min(self) -> DatetimeTypes: ...
|
|
18
|
+
|
|
19
|
+
def max(self) -> DatetimeTypes: ...
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
from itertools import starmap
|
|
3
|
+
from typing import NamedTuple
|
|
4
|
+
|
|
5
|
+
from pandas import Timedelta, Timestamp
|
|
6
|
+
|
|
7
|
+
from ..settings import auto_flex
|
|
8
|
+
from ..types import Flex, TimedeltaTypes
|
|
9
|
+
|
|
10
|
+
LimitsTuple = tuple[Timestamp, Timestamp]
|
|
11
|
+
LevelTuple = tuple[TimedeltaTypes, TimedeltaTypes, TimedeltaTypes]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _TimedeltaTuple(NamedTuple):
|
|
15
|
+
start_at: Timedelta
|
|
16
|
+
round_to: Timedelta
|
|
17
|
+
tolerance: Timedelta
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def expand_limits(limits: LimitsTuple, *, flex: Flex | LevelTuple | Iterable[LevelTuple] = "auto") -> LimitsTuple:
|
|
21
|
+
"""Derive the `"real"` bounds of `limits`.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
limits: A tuple ``(lo, hi)`` of timestamps.
|
|
25
|
+
flex: Flex arguments as described in the :ref:`User guide`. Also supports level-tuples
|
|
26
|
+
``[(start_at, round_to, tolerance)...]``. Passing ``flex=[settings.auto_flex.day, settings.auto_flex.hour]``
|
|
27
|
+
is equivalent to ``flex='auto'``.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Limits rounded according to the `flex`-argument.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: For invalid limits.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
>>> from pandas import Timestamp
|
|
37
|
+
>>> limits = Timestamp("2019-05-11"), Timestamp("2019-05-11 22:05:30")
|
|
38
|
+
|
|
39
|
+
Basic usage.
|
|
40
|
+
|
|
41
|
+
>>> expand_limits(limits, flex="d")
|
|
42
|
+
(Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-12 00:00:00'))
|
|
43
|
+
|
|
44
|
+
You may specify a maximum "distance" that limits may be expanded.
|
|
45
|
+
|
|
46
|
+
>>> expand_limits(limits, flex="d<1h")
|
|
47
|
+
(Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-11 22:05:30'))
|
|
48
|
+
|
|
49
|
+
Limits will never be rounded in the "wrong" direction...
|
|
50
|
+
|
|
51
|
+
>>> limits = Timestamp("2019-05-11"), Timestamp("2019-05-11 11:05:30")
|
|
52
|
+
>>> expand_limits(limits, flex="d")
|
|
53
|
+
(Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-11 11:05:30'))
|
|
54
|
+
|
|
55
|
+
...even if you make the tolerance large enough.
|
|
56
|
+
|
|
57
|
+
>>> expand_limits(limits, flex="d<14h")
|
|
58
|
+
(Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-11 11:05:30'))
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
if limits[0] >= limits[1]:
|
|
62
|
+
msg = f"Bad limits. Expected limits[1] > limits[0], but got {limits=}."
|
|
63
|
+
raise ValueError(msg)
|
|
64
|
+
|
|
65
|
+
if flex is False:
|
|
66
|
+
return limits
|
|
67
|
+
|
|
68
|
+
if flex is True or flex == "auto":
|
|
69
|
+
return _from_levels(limits)
|
|
70
|
+
|
|
71
|
+
if isinstance(flex, str):
|
|
72
|
+
round_to, _, tolerance = flex.partition("<")
|
|
73
|
+
level = _make_level(None, round_to=round_to.strip(), tolerance=tolerance.strip())
|
|
74
|
+
return _apply(limits, level=level)
|
|
75
|
+
if isinstance(flex, tuple):
|
|
76
|
+
return _from_levels(limits, levels=[_make_level(*flex)])
|
|
77
|
+
|
|
78
|
+
return _from_levels(limits, levels=starmap(_make_level, flex))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _from_levels(limits: LimitsTuple, *, levels: Iterable[_TimedeltaTuple] | None = None) -> LimitsTuple:
|
|
82
|
+
if levels is None:
|
|
83
|
+
levels = _levels_from_settings()
|
|
84
|
+
else:
|
|
85
|
+
levels = sorted(levels, reverse=True, key=lambda level: level[0])
|
|
86
|
+
|
|
87
|
+
lo, hi = limits
|
|
88
|
+
diff = hi - lo
|
|
89
|
+
|
|
90
|
+
for level in levels:
|
|
91
|
+
start_at, _round_to, _tolerance = level
|
|
92
|
+
if diff >= start_at:
|
|
93
|
+
return _apply(limits, level=level)
|
|
94
|
+
|
|
95
|
+
return limits
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _apply(limits: LimitsTuple, *, level: _TimedeltaTuple) -> LimitsTuple:
|
|
99
|
+
lo, hi = limits
|
|
100
|
+
|
|
101
|
+
lo_floor = lo.floor(level.round_to)
|
|
102
|
+
if abs(lo_floor - lo) > level.tolerance:
|
|
103
|
+
return limits
|
|
104
|
+
|
|
105
|
+
hi_ceil = hi.ceil(level.round_to)
|
|
106
|
+
if abs(hi_ceil - hi) > level.tolerance:
|
|
107
|
+
return limits
|
|
108
|
+
|
|
109
|
+
if auto_flex.SANITY_CHECK:
|
|
110
|
+
if lo.round(level.round_to) != lo_floor:
|
|
111
|
+
return limits
|
|
112
|
+
if hi.round(level.round_to) != hi_ceil:
|
|
113
|
+
return limits
|
|
114
|
+
|
|
115
|
+
return lo_floor, hi_ceil
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _levels_from_settings() -> list[_TimedeltaTuple]:
|
|
119
|
+
day, hour = _make_level(*auto_flex.day), _make_level(*auto_flex.hour)
|
|
120
|
+
if day.round_to != Timedelta(days=1):
|
|
121
|
+
msg = f"Invalid settings: {auto_flex.day=} must have round_to=1 day."
|
|
122
|
+
raise ValueError(msg)
|
|
123
|
+
if hour.round_to != Timedelta(hours=1):
|
|
124
|
+
msg = f"Invalid settings: {auto_flex.hour=} must have round_to=1 hour."
|
|
125
|
+
raise ValueError(msg)
|
|
126
|
+
return [day, hour]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _make_level(
|
|
130
|
+
start_at: TimedeltaTypes | None,
|
|
131
|
+
round_to: TimedeltaTypes,
|
|
132
|
+
tolerance: TimedeltaTypes,
|
|
133
|
+
) -> _TimedeltaTuple:
|
|
134
|
+
start_at = Timedelta(start_at) # Will be NaT if None, making all ifs False.
|
|
135
|
+
if start_at <= Timedelta(0):
|
|
136
|
+
raise ValueError(f"Bad {start_at=}; must be non-negative.")
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
round_to = Timedelta(1, unit=round_to)
|
|
140
|
+
except ValueError as e:
|
|
141
|
+
raise ValueError(f"Got {round_to=}, which is not a valid frequency.") from e
|
|
142
|
+
|
|
143
|
+
tolerance = Timedelta(tolerance)
|
|
144
|
+
if tolerance <= Timedelta(0):
|
|
145
|
+
raise ValueError(f"Bad {tolerance=}; must be non-negative.")
|
|
146
|
+
|
|
147
|
+
if start_at < round_to:
|
|
148
|
+
raise ValueError(f"{start_at=} < {round_to=}")
|
|
149
|
+
if round_to < tolerance:
|
|
150
|
+
raise ValueError(f"{round_to=} < {tolerance=}")
|
|
151
|
+
return _TimedeltaTuple(start_at, round_to, tolerance)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from typing import NamedTuple, cast, get_args
|
|
2
|
+
|
|
3
|
+
from pandas import DatetimeIndex, NaT, Timedelta, Timestamp, date_range
|
|
4
|
+
|
|
5
|
+
from ..types import DatetimeIterable, Flex, Schedule, TimedeltaTypes
|
|
6
|
+
from ._available import AvailableMetadata, process_available
|
|
7
|
+
from ._limits import LimitsTuple
|
|
8
|
+
|
|
9
|
+
NO_LIMITS = cast(LimitsTuple, (NaT, NaT))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MaterializedSchedule(NamedTuple):
|
|
13
|
+
"""An explicit schedule."""
|
|
14
|
+
|
|
15
|
+
schedule: DatetimeIndex
|
|
16
|
+
available_metadata: AvailableMetadata
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def materialize_schedule(
|
|
20
|
+
schedule: Schedule, flex: Flex, *, available: DatetimeIterable | None = None
|
|
21
|
+
) -> MaterializedSchedule:
|
|
22
|
+
"""Materialize user schedule based on available data."""
|
|
23
|
+
if available is None:
|
|
24
|
+
try:
|
|
25
|
+
return MaterializedSchedule(
|
|
26
|
+
DatetimeIndex(schedule),
|
|
27
|
+
available_metadata=AvailableMetadata(None, NO_LIMITS, NO_LIMITS),
|
|
28
|
+
)
|
|
29
|
+
except TypeError as e:
|
|
30
|
+
raise ValueError("Schedule must be explicit when not bounded by an available range.") from e
|
|
31
|
+
|
|
32
|
+
available_metadata = process_available(available, flex=flex)
|
|
33
|
+
min_dt, max_dt = available_metadata.expanded_limits
|
|
34
|
+
|
|
35
|
+
if isinstance(schedule, str) and _cron_like(schedule):
|
|
36
|
+
schedule = _handle_cron(schedule, min_dt, max_dt)
|
|
37
|
+
elif isinstance(schedule, get_args(TimedeltaTypes)):
|
|
38
|
+
schedule = _from_timedelta(schedule, available_metadata.expanded_limits)
|
|
39
|
+
else:
|
|
40
|
+
if not isinstance(schedule, DatetimeIndex):
|
|
41
|
+
schedule = DatetimeIndex(schedule)
|
|
42
|
+
|
|
43
|
+
schedule = schedule.tz_localize(min_dt.tz) if schedule.tz is None else schedule.tz_convert(min_dt.tz)
|
|
44
|
+
schedule = schedule[(min_dt <= schedule) & (schedule <= max_dt)]
|
|
45
|
+
return MaterializedSchedule(schedule, available_metadata=available_metadata)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _cron_like(schedule: str) -> bool:
|
|
49
|
+
# CroniterBadCronError: Exactly 5 or 6 columns has to be specified for iterator expression.
|
|
50
|
+
return len(schedule.split()) > 4 or schedule[0] == "@" # noqa: PLR2004
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _from_timedelta(schedule: TimedeltaTypes, limits: LimitsTuple) -> DatetimeIndex:
|
|
54
|
+
timedelta = Timedelta(schedule)
|
|
55
|
+
if timedelta <= Timedelta(0):
|
|
56
|
+
raise ValueError(f"unbounded {schedule=} must be greater than zero.")
|
|
57
|
+
return date_range(*limits, freq=timedelta, inclusive="both")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _handle_cron(expr: str, min_dt: Timestamp, max_dt: Timestamp) -> DatetimeIndex:
|
|
61
|
+
try:
|
|
62
|
+
from croniter import croniter_range
|
|
63
|
+
|
|
64
|
+
return DatetimeIndex(croniter_range(min_dt, max_dt, expr))
|
|
65
|
+
except ModuleNotFoundError as e:
|
|
66
|
+
raise ModuleNotFoundError(f"Install 'croniter' to parse cron expressions such such as '{expr}'.") from e
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from datetime import timedelta
|
|
3
|
+
from typing import Literal, TypeAlias
|
|
4
|
+
|
|
5
|
+
from pandas import DatetimeIndex, Timedelta, Timestamp
|
|
6
|
+
|
|
7
|
+
from ..types import Span
|
|
8
|
+
from ._limits import LimitsTuple
|
|
9
|
+
from ._schedule import NO_LIMITS
|
|
10
|
+
|
|
11
|
+
StrictSpan: TypeAlias = int | Timedelta | Literal["all"]
|
|
12
|
+
SpanArgumentName = Literal["before", "after"]
|
|
13
|
+
FuncType = Callable[[int], Timestamp | None]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidSpanError(ValueError):
|
|
17
|
+
"""Raised when offsets values for 'before' and 'after' arguments are invalid."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
span: Span,
|
|
22
|
+
*,
|
|
23
|
+
name: SpanArgumentName,
|
|
24
|
+
reason: str = "must be greater than zero",
|
|
25
|
+
) -> None:
|
|
26
|
+
super().__init__(f"Bad '{name}'-argument; offset {span!r} {reason}.")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _to_timedelta(span: str | timedelta | Timedelta, *, name: SpanArgumentName) -> Timedelta:
|
|
30
|
+
retval = Timedelta(span)
|
|
31
|
+
if retval <= Timedelta(0):
|
|
32
|
+
raise InvalidSpanError(span, name=name)
|
|
33
|
+
return retval
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def to_strict_span(span: Span, *, name: SpanArgumentName) -> StrictSpan:
|
|
37
|
+
"""Convert `span` to a strict span.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
span: User span.
|
|
41
|
+
name: Name of user input. User for error reporting.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
A valid span with reduced types.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
TypeError: For bad `span` types.
|
|
48
|
+
InvalidSpanError: For invalid `span` arguments.
|
|
49
|
+
|
|
50
|
+
"""
|
|
51
|
+
if span == "all":
|
|
52
|
+
return "all"
|
|
53
|
+
|
|
54
|
+
if isinstance(span, (str, timedelta, Timedelta)):
|
|
55
|
+
return _to_timedelta(span, name=name)
|
|
56
|
+
|
|
57
|
+
if span is False or isinstance(span, (int, float)):
|
|
58
|
+
retval = int(span)
|
|
59
|
+
if retval <= 0:
|
|
60
|
+
raise InvalidSpanError(retval, name=name)
|
|
61
|
+
return retval
|
|
62
|
+
|
|
63
|
+
raise TypeError(f"cannot parse '{name}'-argument of type {type(span).__name__}: {span!r}.")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class OffsetCalculator:
|
|
67
|
+
"""Utility class for computing before/after offsets from the `schedule`."""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
span: Span,
|
|
72
|
+
schedule: DatetimeIndex,
|
|
73
|
+
limits: LimitsTuple,
|
|
74
|
+
*,
|
|
75
|
+
name: SpanArgumentName,
|
|
76
|
+
) -> None:
|
|
77
|
+
span = to_strict_span(span, name=name)
|
|
78
|
+
is_before = name == "before"
|
|
79
|
+
|
|
80
|
+
self.func: FuncType
|
|
81
|
+
if span == "all":
|
|
82
|
+
if limits == NO_LIMITS:
|
|
83
|
+
raise InvalidSpanError(
|
|
84
|
+
span,
|
|
85
|
+
name=name,
|
|
86
|
+
reason="requires available data to bound the schedule",
|
|
87
|
+
)
|
|
88
|
+
self.func = self._make_all(is_before, limits)
|
|
89
|
+
elif isinstance(span, int):
|
|
90
|
+
self.func = self._make_int(is_before, span, schedule)
|
|
91
|
+
else: # Timedelta
|
|
92
|
+
self.func = self._make_timedelta(is_before, span, schedule)
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _make_all(is_before: bool, limits: LimitsTuple) -> FuncType:
|
|
96
|
+
retval = Timestamp(limits[0 if is_before else 1])
|
|
97
|
+
|
|
98
|
+
def func(_: int) -> Timestamp | None:
|
|
99
|
+
return retval
|
|
100
|
+
|
|
101
|
+
return func
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _make_int(is_before: bool, offset: int, schedule: DatetimeIndex) -> FuncType:
|
|
105
|
+
def func(i: int) -> Timestamp | None:
|
|
106
|
+
i = i - offset if is_before else i + offset
|
|
107
|
+
if 0 <= i < len(schedule):
|
|
108
|
+
return schedule[i]
|
|
109
|
+
else:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
return func
|
|
113
|
+
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _make_timedelta(is_before: bool, offset: Timedelta, schedule: DatetimeIndex) -> FuncType:
|
|
116
|
+
if is_before:
|
|
117
|
+
schedule = schedule - offset
|
|
118
|
+
else:
|
|
119
|
+
schedule = schedule + offset
|
|
120
|
+
|
|
121
|
+
def func(i: int) -> Timestamp | None:
|
|
122
|
+
return schedule[i]
|
|
123
|
+
|
|
124
|
+
return func
|
|
125
|
+
|
|
126
|
+
def __call__(self, i: int) -> Timestamp | None:
|
|
127
|
+
"""Get start/end timestamp."""
|
|
128
|
+
return self.func(i)
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from dataclasses import asdict, dataclass
|
|
3
|
+
from typing import cast, get_args
|
|
4
|
+
|
|
5
|
+
from pandas import Timedelta, Timestamp
|
|
6
|
+
from rics.misc import format_kwargs
|
|
7
|
+
from rics.performance import format_seconds
|
|
8
|
+
|
|
9
|
+
from ..settings import misc
|
|
10
|
+
from ..types import (
|
|
11
|
+
DatetimeIndexSplitterKwargs,
|
|
12
|
+
DatetimeIterable,
|
|
13
|
+
DatetimeSplitBounds,
|
|
14
|
+
DatetimeSplits,
|
|
15
|
+
Flex,
|
|
16
|
+
Schedule,
|
|
17
|
+
Span,
|
|
18
|
+
TimedeltaTypes,
|
|
19
|
+
)
|
|
20
|
+
from ._limits import LimitsTuple
|
|
21
|
+
from ._schedule import MaterializedSchedule, materialize_schedule
|
|
22
|
+
from ._span import OffsetCalculator, to_strict_span
|
|
23
|
+
|
|
24
|
+
LOGGER = logging.getLogger("time_split")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class DatetimeIndexSplitter:
|
|
29
|
+
"""Backend interface for splitting user data."""
|
|
30
|
+
|
|
31
|
+
schedule: Schedule
|
|
32
|
+
before: Span
|
|
33
|
+
after: Span
|
|
34
|
+
step: int
|
|
35
|
+
n_splits: int | None
|
|
36
|
+
flex: Flex
|
|
37
|
+
|
|
38
|
+
def get_splits(self, available: DatetimeIterable | None = None) -> DatetimeSplits:
|
|
39
|
+
"""Compute a split of given user data."""
|
|
40
|
+
ms = self._materialize_schedule(available)
|
|
41
|
+
self._log_expansion(ms.available_metadata.limits, expanded=ms.available_metadata.expanded_limits)
|
|
42
|
+
return self._make_bounds_list(ms)
|
|
43
|
+
|
|
44
|
+
def get_plot_data(self, available: DatetimeIterable | None = None) -> tuple[DatetimeSplits, MaterializedSchedule]:
|
|
45
|
+
"""Returns additional data needed to visualize folds."""
|
|
46
|
+
ms = self._materialize_schedule(available)
|
|
47
|
+
splits = self._make_bounds_list(ms)
|
|
48
|
+
return splits, ms
|
|
49
|
+
|
|
50
|
+
def _materialize_schedule(self, available: DatetimeIterable | None = None) -> MaterializedSchedule:
|
|
51
|
+
ms = materialize_schedule(self.schedule, self.flex, available=available)
|
|
52
|
+
if not ms.schedule.sort_values().equals(ms.schedule):
|
|
53
|
+
raise ValueError(f"schedule must be sorted in ascending order; schedule={self.schedule!r} is not valid.")
|
|
54
|
+
|
|
55
|
+
types = get_args(TimedeltaTypes)
|
|
56
|
+
if (
|
|
57
|
+
misc.snap_to_end
|
|
58
|
+
and self.after != "all"
|
|
59
|
+
and isinstance(self.schedule, types)
|
|
60
|
+
and isinstance(self.after, types)
|
|
61
|
+
):
|
|
62
|
+
ms = self._snap_to_end(ms)
|
|
63
|
+
|
|
64
|
+
return ms
|
|
65
|
+
|
|
66
|
+
def _snap_to_end(self, ms: MaterializedSchedule) -> MaterializedSchedule:
|
|
67
|
+
schedule_frequency = ms.schedule.freq
|
|
68
|
+
if schedule_frequency is None:
|
|
69
|
+
return ms
|
|
70
|
+
|
|
71
|
+
from_end = ms.available_metadata.expanded_limits[1] - (ms.schedule[-1] + Timedelta(self.after))
|
|
72
|
+
from_end = from_end.floor(schedule_frequency.base)
|
|
73
|
+
|
|
74
|
+
return ms._replace(schedule=ms.schedule + from_end)
|
|
75
|
+
|
|
76
|
+
def _make_bounds_list(self, ms: MaterializedSchedule) -> DatetimeSplits:
|
|
77
|
+
get_start = OffsetCalculator(
|
|
78
|
+
self.before,
|
|
79
|
+
ms.schedule,
|
|
80
|
+
ms.available_metadata.expanded_limits,
|
|
81
|
+
name="before",
|
|
82
|
+
)
|
|
83
|
+
get_end = OffsetCalculator(self.after, ms.schedule, ms.available_metadata.expanded_limits, name="after")
|
|
84
|
+
|
|
85
|
+
min_start, max_end = ms.available_metadata.expanded_limits
|
|
86
|
+
|
|
87
|
+
retval = []
|
|
88
|
+
for i, mid in enumerate(ms.schedule):
|
|
89
|
+
start, end = get_start(i), get_end(i)
|
|
90
|
+
if start is None or start < min_start or start >= mid:
|
|
91
|
+
continue
|
|
92
|
+
if end is None or end > max_end or end <= mid:
|
|
93
|
+
continue
|
|
94
|
+
retval.append(DatetimeSplitBounds(start, mid, end))
|
|
95
|
+
|
|
96
|
+
if not retval:
|
|
97
|
+
limits_info = f"limits={tuple(map(str, ms.available_metadata.limits))} and "
|
|
98
|
+
msg = f"No valid splits with {limits_info}split params: ({format_kwargs(self.as_dict())})"
|
|
99
|
+
raise ValueError(msg)
|
|
100
|
+
|
|
101
|
+
return self._filter(retval)
|
|
102
|
+
|
|
103
|
+
def _filter(self, splits: DatetimeSplits) -> DatetimeSplits:
|
|
104
|
+
"""Apply splitting arguments.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
splits: Splits to filter.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Filtered splits.
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
if self.step != 1:
|
|
114
|
+
step = abs(self.step)
|
|
115
|
+
splits = [s for i, s in enumerate(reversed(splits)) if i % step == 0]
|
|
116
|
+
splits.reverse()
|
|
117
|
+
|
|
118
|
+
if self.n_splits:
|
|
119
|
+
splits = splits[-self.n_splits :]
|
|
120
|
+
|
|
121
|
+
if self.step < 0:
|
|
122
|
+
splits.reverse()
|
|
123
|
+
|
|
124
|
+
return splits
|
|
125
|
+
|
|
126
|
+
def _log_expansion(self, original: LimitsTuple, *, expanded: LimitsTuple) -> None:
|
|
127
|
+
if original == expanded:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if not LOGGER.isEnabledFor(logging.INFO):
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
def stringify(old: Timestamp, *, new: Timestamp) -> str:
|
|
134
|
+
from .._frontend._to_string import _PrettyTimestamp
|
|
135
|
+
|
|
136
|
+
retval = f"{old} -> "
|
|
137
|
+
if old == new:
|
|
138
|
+
return retval + "<no change>"
|
|
139
|
+
diff = (new - old).total_seconds()
|
|
140
|
+
return retval + f"{_PrettyTimestamp(new).auto} ({'+' if diff > 0 else '-'}{format_seconds(abs(diff))})"
|
|
141
|
+
|
|
142
|
+
LOGGER.info(
|
|
143
|
+
f"Available data limits have been expanded (since flex={self.flex!r}):\n"
|
|
144
|
+
f" start: {stringify(original[0], new=expanded[0])}\n"
|
|
145
|
+
f" end: {stringify(original[1], new=expanded[1])}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def __post_init__(self) -> None:
|
|
149
|
+
# Verify n_splits
|
|
150
|
+
if self.n_splits is not None and self.n_splits < 1:
|
|
151
|
+
raise ValueError(f"Expected n_splits >= 1, but got n_splits={self.n_splits!r}.")
|
|
152
|
+
|
|
153
|
+
# Verify before/after
|
|
154
|
+
to_strict_span(self.before, name="before")
|
|
155
|
+
to_strict_span(self.after, name="after")
|
|
156
|
+
|
|
157
|
+
if self.step == 0:
|
|
158
|
+
raise ValueError(f"Bad argument step={self.step}; must be a non-zero integer.")
|
|
159
|
+
|
|
160
|
+
def as_dict(self) -> DatetimeIndexSplitterKwargs:
|
|
161
|
+
"""Returns the splitter as a ``dict``."""
|
|
162
|
+
return cast(DatetimeIndexSplitterKwargs, asdict(self))
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from typing import TypeVar
|
|
2
|
+
|
|
3
|
+
FuncType = TypeVar("FuncType") # No proper way to type this?
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def add_docstrings(**kwargs: str) -> None:
|
|
7
|
+
"""Add a documented argument to the ``@docs`` decorator."""
|
|
8
|
+
_DOCSTRINGS.update(kwargs)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def docs(function: FuncType) -> FuncType:
|
|
12
|
+
"""Add info the docstring of a function."""
|
|
13
|
+
assert function.__doc__ is not None # noqa: S101
|
|
14
|
+
function.__doc__ = function.__doc__.format(**_DOCSTRINGS)
|
|
15
|
+
return function
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_OFFSET = "pandas :ref:`offset alias <pandas:timeseries.offset_aliases>`"
|
|
19
|
+
_SPAN = (
|
|
20
|
+
f"Range {{arg}} `schedule` timestamps. Either a {_OFFSET}, an integer (`schedule`-based offsets), "
|
|
21
|
+
f"or `'all'` (requires `available` data)."
|
|
22
|
+
)
|
|
23
|
+
_DOCSTRINGS = {
|
|
24
|
+
"schedule": f"A collection of timestamps, a {_OFFSET}, or a cron expression.",
|
|
25
|
+
"before": _SPAN.format(arg="before"),
|
|
26
|
+
"after": _SPAN.format(arg="after"),
|
|
27
|
+
"step": "Select a subset of folds, preferring folds later in the schedule.",
|
|
28
|
+
"n_splits": "Maximum number of folds, preferring folds later in the schedule.",
|
|
29
|
+
"available": "Binds `schedule` to a range.",
|
|
30
|
+
"flex": f'A {_OFFSET} used to expand `available` data to its likely `"true"` limits. Pass ``False`` to disable.',
|
|
31
|
+
"USER_GUIDE": (
|
|
32
|
+
"For more information about the `schedule`, `before/after` and `flex`-arguments" ", see the :ref:`User guide`."
|
|
33
|
+
),
|
|
34
|
+
"OFFSET": _OFFSET,
|
|
35
|
+
"DatetimeIndexSplitterKwargs": "See :func:`~time_split.split`. The `available` keyword is managed by the integration.",
|
|
36
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from ._plot import plot
|
|
2
|
+
from ._progress import log_split_progress
|
|
3
|
+
from ._split import split
|
|
4
|
+
from ._to_string import to_string
|
|
5
|
+
from ._weight import fold_weight
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"fold_weight",
|
|
9
|
+
"log_split_progress",
|
|
10
|
+
"plot",
|
|
11
|
+
"split",
|
|
12
|
+
"to_string",
|
|
13
|
+
]
|