time-split 0.2.1__tar.gz → 0.4.0__tar.gz
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-0.2.1 → time_split-0.4.0}/PKG-INFO +2 -2
- {time_split-0.2.1 → time_split-0.4.0}/README.md +1 -1
- {time_split-0.2.1 → time_split-0.4.0}/pyproject.toml +7 -6
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/__init__.py +1 -1
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/_limits.py +26 -22
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/_process_available.py +7 -5
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/_schedule.py +3 -3
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/_splitter.py +7 -6
- time_split-0.4.0/src/time_split/_compat.py +49 -0
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_docstrings.py +18 -8
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_frontend/__init__.py +2 -1
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_frontend/_plot.py +8 -7
- time_split-0.4.0/src/time_split/_frontend/_progress.py +245 -0
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_frontend/_split.py +4 -4
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_frontend/_to_string.py +1 -1
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/_log_progress.py +4 -4
- time_split-0.2.1/src/time_split/integration/split_data.py → time_split-0.4.0/src/time_split/integration/base.py +1 -1
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/pandas/_impl.py +3 -3
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/polars/_impl.py +3 -3
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/sklearn/_impl.py +6 -5
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/settings.py +12 -10
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/support/__init__.py +2 -1
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/types.py +42 -3
- time_split-0.2.1/src/time_split/_frontend/_progress.py +0 -125
- {time_split-0.2.1 → time_split-0.4.0}/LICENSE.md +0 -0
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/__init__.py +0 -0
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/_datetime_index_like.py +0 -0
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_backend/_span.py +0 -0
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_frontend/_weight.py +0 -0
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/_support.py +0 -0
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/__init__.py +0 -0
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/pandas/__init__.py +0 -0
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/polars/__init__.py +0 -0
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/integration/sklearn/__init__.py +0 -0
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/py.typed +0 -0
- {time_split-0.2.1 → time_split-0.4.0}/src/time_split/support/types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: time-split
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Time-based k-fold validation splits for heterogeneous data.
|
|
5
5
|
Home-page: https://github.com/rsundqvist/time-split
|
|
6
6
|
Author: Richard Sundqvist
|
|
@@ -58,7 +58,7 @@ strong non-stationary characteristics.
|
|
|
58
58
|
a CRON-expression, or a pre-defined list.
|
|
59
59
|
- [Data selection](https://time-split.readthedocs.io/en/stable/guide/spans.html) based on a timedelta, or the splitting
|
|
60
60
|
schedule itself.
|
|
61
|
-
- Automatically extract and [normalize](https://time-split.readthedocs.io/en/stable/guide/
|
|
61
|
+
- Automatically extract and [normalize](https://time-split.readthedocs.io/en/stable/guide/expand_limits.html) data limits for
|
|
62
62
|
supported data types.
|
|
63
63
|
- [Plotting function](https://time-split.readthedocs.io/en/stable/generated/time_split.html#time_split.plot) for
|
|
64
64
|
visualization of folds. Display fold sizes (hour/row count), or
|
|
@@ -27,7 +27,7 @@ strong non-stationary characteristics.
|
|
|
27
27
|
a CRON-expression, or a pre-defined list.
|
|
28
28
|
- [Data selection](https://time-split.readthedocs.io/en/stable/guide/spans.html) based on a timedelta, or the splitting
|
|
29
29
|
schedule itself.
|
|
30
|
-
- Automatically extract and [normalize](https://time-split.readthedocs.io/en/stable/guide/
|
|
30
|
+
- Automatically extract and [normalize](https://time-split.readthedocs.io/en/stable/guide/expand_limits.html) data limits for
|
|
31
31
|
supported data types.
|
|
32
32
|
- [Plotting function](https://time-split.readthedocs.io/en/stable/generated/time_split.html#time_split.plot) for
|
|
33
33
|
visualization of folds. Display fold sizes (hour/row count), or
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "time-split"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Time-based k-fold validation splits for heterogeneous data."
|
|
5
5
|
authors = ["Richard Sundqvist <richard.sundqvist@live.se>"]
|
|
6
6
|
|
|
@@ -46,8 +46,9 @@ plotting = ["matplotlib", "seaborn"]
|
|
|
46
46
|
[tool.poetry.group.manual-extras.dependencies]
|
|
47
47
|
# Extras that must be available for docs + tests go here. Doc/test-only deps go in their respective groups.
|
|
48
48
|
croniter = "^2.0.3"
|
|
49
|
-
pyarrow = "
|
|
49
|
+
pyarrow = ">=15.0.2,<17.0.0"
|
|
50
50
|
polars = { version = "^0.20.19", extras = ["pandas"] }
|
|
51
|
+
numpy = "<2" # TODO: numpy 2.0
|
|
51
52
|
|
|
52
53
|
[tool.poetry.group.docs]
|
|
53
54
|
[tool.poetry.group.docs.dependencies]
|
|
@@ -55,9 +56,9 @@ polars = { version = "^0.20.19", extras = ["pandas"] }
|
|
|
55
56
|
# description: https://github.com/astropy/astropy/issues/11723
|
|
56
57
|
# https://github.com/astropy/astropy/pull/12270
|
|
57
58
|
sphinx = "^7.2.6"
|
|
58
|
-
sphinx-gallery = "^0.
|
|
59
|
+
sphinx-gallery = "^0.16.0"
|
|
59
60
|
pydata-sphinx-theme = "^0.15.2"
|
|
60
|
-
myst-parser = "
|
|
61
|
+
myst-parser = ">=2,<4"
|
|
61
62
|
nbsphinx = "^0.9.3"
|
|
62
63
|
ipython = "^8.23.0"
|
|
63
64
|
|
|
@@ -80,12 +81,12 @@ scikit-learn = "^1.4.2"
|
|
|
80
81
|
# dask.datasets.timeseries() warns:
|
|
81
82
|
# UserWarning: dask_expr does not support the DataFrameIOFunction protocol for column projection
|
|
82
83
|
# on dask == 2024.3.1
|
|
83
|
-
dask = { version = "==2024.
|
|
84
|
+
dask = { version = "==2024.5.0", extras = ["dataframe"] }
|
|
84
85
|
|
|
85
86
|
[tool.poetry.group.devops.dependencies]
|
|
86
87
|
invoke = "^2.2.0"
|
|
87
88
|
mypy = "^1.9.0"
|
|
88
|
-
ruff = "
|
|
89
|
+
ruff = ">=0.3.7,<0.5.0"
|
|
89
90
|
|
|
90
91
|
safety = "^3.1.0"
|
|
91
92
|
codespell = "^2.2.6"
|
|
@@ -4,8 +4,8 @@ from typing import NamedTuple
|
|
|
4
4
|
|
|
5
5
|
from pandas import Timedelta, Timestamp
|
|
6
6
|
|
|
7
|
-
from ..settings import
|
|
8
|
-
from ..types import
|
|
7
|
+
from ..settings import auto_expand_limits
|
|
8
|
+
from ..types import ExpandLimits, TimedeltaTypes
|
|
9
9
|
|
|
10
10
|
LimitsTuple = tuple[Timestamp, Timestamp]
|
|
11
11
|
LevelTuple = tuple[TimedeltaTypes, TimedeltaTypes, TimedeltaTypes]
|
|
@@ -17,17 +17,21 @@ class _TimedeltaTuple(NamedTuple):
|
|
|
17
17
|
tolerance: Timedelta
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def expand_limits(
|
|
20
|
+
def expand_limits(
|
|
21
|
+
limits: LimitsTuple,
|
|
22
|
+
spec: ExpandLimits | LevelTuple | Iterable[LevelTuple] = "auto",
|
|
23
|
+
) -> LimitsTuple:
|
|
21
24
|
"""Derive the `"real"` bounds of `limits`.
|
|
22
25
|
|
|
23
26
|
Args:
|
|
24
27
|
limits: A tuple ``(min, max)`` of timestamps.
|
|
25
|
-
|
|
26
|
-
``[(start_at, round_to, tolerance)...]``. Passing
|
|
27
|
-
|
|
28
|
+
spec: Expansion spec as described in the :ref:`User guide`. Also supports level-tuples
|
|
29
|
+
``[(start_at, round_to, tolerance)...]``. Passing
|
|
30
|
+
``expand_limits=[settings.auto_expand_limits.day, settings.auto_expand_limits.hour]``
|
|
31
|
+
is equivalent to ``expand_limits='auto'``.
|
|
28
32
|
|
|
29
33
|
Returns:
|
|
30
|
-
Limits rounded according to the
|
|
34
|
+
Limits rounded according to the given specification.
|
|
31
35
|
|
|
32
36
|
Raises:
|
|
33
37
|
ValueError: For invalid limits.
|
|
@@ -38,23 +42,23 @@ def expand_limits(limits: LimitsTuple, *, flex: Flex | LevelTuple | Iterable[Lev
|
|
|
38
42
|
|
|
39
43
|
Basic usage.
|
|
40
44
|
|
|
41
|
-
>>> expand_limits(limits,
|
|
45
|
+
>>> expand_limits(limits, "d")
|
|
42
46
|
(Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-12 00:00:00'))
|
|
43
47
|
|
|
44
48
|
You may specify a maximum "distance" that limits may be expanded.
|
|
45
49
|
|
|
46
|
-
>>> expand_limits(limits,
|
|
50
|
+
>>> expand_limits(limits, "d<1h")
|
|
47
51
|
(Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-11 22:05:30'))
|
|
48
52
|
|
|
49
53
|
Limits will never be rounded in the "wrong" direction...
|
|
50
54
|
|
|
51
55
|
>>> limits = Timestamp("2019-05-11"), Timestamp("2019-05-11 11:05:30")
|
|
52
|
-
>>> expand_limits(limits,
|
|
56
|
+
>>> expand_limits(limits, "d")
|
|
53
57
|
(Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-11 11:05:30'))
|
|
54
58
|
|
|
55
59
|
...even if you make the tolerance large enough.
|
|
56
60
|
|
|
57
|
-
>>> expand_limits(limits,
|
|
61
|
+
>>> expand_limits(limits, "d<14h")
|
|
58
62
|
(Timestamp('2019-05-11 00:00:00'), Timestamp('2019-05-11 11:05:30'))
|
|
59
63
|
|
|
60
64
|
"""
|
|
@@ -62,20 +66,20 @@ def expand_limits(limits: LimitsTuple, *, flex: Flex | LevelTuple | Iterable[Lev
|
|
|
62
66
|
msg = f"Bad limits. Expected limits[1] > limits[0], but got {limits=}."
|
|
63
67
|
raise ValueError(msg)
|
|
64
68
|
|
|
65
|
-
if
|
|
69
|
+
if spec is False:
|
|
66
70
|
return limits
|
|
67
71
|
|
|
68
|
-
if
|
|
72
|
+
if spec is True or spec == "auto":
|
|
69
73
|
return _from_levels(limits)
|
|
70
74
|
|
|
71
|
-
if isinstance(
|
|
72
|
-
round_to, _, tolerance =
|
|
75
|
+
if isinstance(spec, str):
|
|
76
|
+
round_to, _, tolerance = spec.partition("<")
|
|
73
77
|
level = _make_level(None, round_to=round_to.strip(), tolerance=tolerance.strip())
|
|
74
78
|
return _apply(limits, level=level)
|
|
75
|
-
if isinstance(
|
|
76
|
-
return _from_levels(limits, levels=[_make_level(*
|
|
79
|
+
if isinstance(spec, tuple):
|
|
80
|
+
return _from_levels(limits, levels=[_make_level(*spec)])
|
|
77
81
|
|
|
78
|
-
return _from_levels(limits, levels=starmap(_make_level,
|
|
82
|
+
return _from_levels(limits, levels=starmap(_make_level, spec))
|
|
79
83
|
|
|
80
84
|
|
|
81
85
|
def _from_levels(limits: LimitsTuple, *, levels: Iterable[_TimedeltaTuple] | None = None) -> LimitsTuple:
|
|
@@ -106,7 +110,7 @@ def _apply(limits: LimitsTuple, *, level: _TimedeltaTuple) -> LimitsTuple:
|
|
|
106
110
|
if abs(hi_ceil - hi) > level.tolerance:
|
|
107
111
|
return limits
|
|
108
112
|
|
|
109
|
-
if
|
|
113
|
+
if auto_expand_limits.SANITY_CHECK:
|
|
110
114
|
if lo.round(level.round_to) != lo_floor:
|
|
111
115
|
return limits
|
|
112
116
|
if hi.round(level.round_to) != hi_ceil:
|
|
@@ -116,12 +120,12 @@ def _apply(limits: LimitsTuple, *, level: _TimedeltaTuple) -> LimitsTuple:
|
|
|
116
120
|
|
|
117
121
|
|
|
118
122
|
def _levels_from_settings() -> list[_TimedeltaTuple]:
|
|
119
|
-
day, hour = _make_level(*
|
|
123
|
+
day, hour = _make_level(*auto_expand_limits.day), _make_level(*auto_expand_limits.hour)
|
|
120
124
|
if day.round_to != Timedelta(days=1):
|
|
121
|
-
msg = f"Invalid settings: {
|
|
125
|
+
msg = f"Invalid settings: {auto_expand_limits.day=} must have round_to=1 day."
|
|
122
126
|
raise ValueError(msg)
|
|
123
127
|
if hour.round_to != Timedelta(hours=1):
|
|
124
|
-
msg = f"Invalid settings: {
|
|
128
|
+
msg = f"Invalid settings: {auto_expand_limits.hour=} must have round_to=1 hour."
|
|
125
129
|
raise ValueError(msg)
|
|
126
130
|
return [day, hour]
|
|
127
131
|
|
|
@@ -3,9 +3,10 @@ from typing import NamedTuple
|
|
|
3
3
|
from pandas import DatetimeIndex, Timestamp, isna
|
|
4
4
|
|
|
5
5
|
from .._support import handle_dask
|
|
6
|
-
from ..types import DatetimeIterable,
|
|
6
|
+
from ..types import DatetimeIterable, ExpandLimits
|
|
7
7
|
from ._datetime_index_like import DatetimeIndexLike
|
|
8
|
-
from ._limits import LimitsTuple
|
|
8
|
+
from ._limits import LimitsTuple
|
|
9
|
+
from ._limits import expand_limits as expand
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class ProcessAvailableResult(NamedTuple):
|
|
@@ -16,12 +17,13 @@ class ProcessAvailableResult(NamedTuple):
|
|
|
16
17
|
expanded_limits: LimitsTuple
|
|
17
18
|
|
|
18
19
|
|
|
19
|
-
def process_available(available: DatetimeIterable, *,
|
|
20
|
+
def process_available(available: DatetimeIterable, *, expand_limits: ExpandLimits) -> ProcessAvailableResult:
|
|
20
21
|
"""Process a user-given `available` argument.
|
|
21
22
|
|
|
22
23
|
Args:
|
|
23
24
|
available: Available data from user. May be ``None``
|
|
24
|
-
|
|
25
|
+
expand_limits: Expansion spec as described in the :ref:`User guide`. Determines how much (if at all) to expand
|
|
26
|
+
limits.
|
|
25
27
|
|
|
26
28
|
Returns:
|
|
27
29
|
A tuple ``(available, limits)``. Note that `available` will be ``None``, it has not been iterated over. This
|
|
@@ -49,7 +51,7 @@ def process_available(available: DatetimeIterable, *, flex: Flex) -> ProcessAvai
|
|
|
49
51
|
# (croniter 1.4.1 + numpy 1.25.2): croniter cannot handle numpy.datetime64
|
|
50
52
|
limits = Timestamp(min_dt), Timestamp(max_dt)
|
|
51
53
|
|
|
52
|
-
expanded_limits =
|
|
54
|
+
expanded_limits = expand(limits, spec=expand_limits)
|
|
53
55
|
return ProcessAvailableResult(available_retval, limits=limits, expanded_limits=expanded_limits)
|
|
54
56
|
|
|
55
57
|
|
|
@@ -2,7 +2,7 @@ from typing import NamedTuple, cast, get_args
|
|
|
2
2
|
|
|
3
3
|
from pandas import DatetimeIndex, NaT, Timedelta, Timestamp, date_range
|
|
4
4
|
|
|
5
|
-
from ..types import DatetimeIterable,
|
|
5
|
+
from ..types import DatetimeIterable, ExpandLimits, Schedule, TimedeltaTypes
|
|
6
6
|
from ._limits import LimitsTuple
|
|
7
7
|
from ._process_available import ProcessAvailableResult, process_available
|
|
8
8
|
|
|
@@ -17,7 +17,7 @@ class MaterializedSchedule(NamedTuple):
|
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def materialize_schedule(
|
|
20
|
-
schedule: Schedule,
|
|
20
|
+
schedule: Schedule, expand_limits: ExpandLimits, *, available: DatetimeIterable | None = None
|
|
21
21
|
) -> MaterializedSchedule:
|
|
22
22
|
"""Materialize user schedule based on available data."""
|
|
23
23
|
if available is None:
|
|
@@ -29,7 +29,7 @@ def materialize_schedule(
|
|
|
29
29
|
except TypeError as e:
|
|
30
30
|
raise ValueError("Schedule must be explicit when not bounded by an available range.") from e
|
|
31
31
|
|
|
32
|
-
available_metadata = process_available(available,
|
|
32
|
+
available_metadata = process_available(available, expand_limits=expand_limits)
|
|
33
33
|
min_dt, max_dt = available_metadata.expanded_limits
|
|
34
34
|
|
|
35
35
|
if isinstance(schedule, str) and _cron_like(schedule):
|
|
@@ -4,7 +4,8 @@ from typing import cast, get_args
|
|
|
4
4
|
|
|
5
5
|
from pandas import Timedelta, Timestamp
|
|
6
6
|
from rics.misc import format_kwargs
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
from time_split._compat import fmt_sec
|
|
8
9
|
|
|
9
10
|
from ..settings import misc
|
|
10
11
|
from ..types import (
|
|
@@ -12,7 +13,7 @@ from ..types import (
|
|
|
12
13
|
DatetimeIterable,
|
|
13
14
|
DatetimeSplitBounds,
|
|
14
15
|
DatetimeSplits,
|
|
15
|
-
|
|
16
|
+
ExpandLimits,
|
|
16
17
|
Schedule,
|
|
17
18
|
Span,
|
|
18
19
|
TimedeltaTypes,
|
|
@@ -33,7 +34,7 @@ class DatetimeIndexSplitter:
|
|
|
33
34
|
after: Span
|
|
34
35
|
step: int
|
|
35
36
|
n_splits: int
|
|
36
|
-
|
|
37
|
+
expand_limits: ExpandLimits
|
|
37
38
|
|
|
38
39
|
def get_splits(self, available: DatetimeIterable | None = None) -> DatetimeSplits:
|
|
39
40
|
"""Compute a split of given user data."""
|
|
@@ -48,7 +49,7 @@ class DatetimeIndexSplitter:
|
|
|
48
49
|
return splits, ms
|
|
49
50
|
|
|
50
51
|
def _materialize_schedule(self, available: DatetimeIterable | None = None) -> MaterializedSchedule:
|
|
51
|
-
ms = materialize_schedule(self.schedule, self.
|
|
52
|
+
ms = materialize_schedule(self.schedule, self.expand_limits, available=available)
|
|
52
53
|
if not ms.schedule.sort_values().equals(ms.schedule):
|
|
53
54
|
raise ValueError(f"schedule must be sorted in ascending order; schedule={self.schedule!r} is not valid.")
|
|
54
55
|
|
|
@@ -137,10 +138,10 @@ class DatetimeIndexSplitter:
|
|
|
137
138
|
if old == new:
|
|
138
139
|
return retval + "<no change>"
|
|
139
140
|
diff = (new - old).total_seconds()
|
|
140
|
-
return retval + f"{_PrettyTimestamp(new).auto} ({'+' if diff > 0 else '-'}{
|
|
141
|
+
return retval + f"{_PrettyTimestamp(new).auto} ({'+' if diff > 0 else '-'}{fmt_sec(abs(diff))})"
|
|
141
142
|
|
|
142
143
|
LOGGER.info(
|
|
143
|
-
f"Available data limits have been expanded (since
|
|
144
|
+
f"Available data limits have been expanded (since expand_limits={self.expand_limits!r}):\n"
|
|
144
145
|
f" start: {stringify(original[0], new=expanded[0])}\n"
|
|
145
146
|
f" end: {stringify(original[1], new=expanded[1])}"
|
|
146
147
|
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Vendored functions."""
|
|
2
|
+
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Callable, ParamSpec, TypeVar
|
|
5
|
+
from warnings import warn
|
|
6
|
+
|
|
7
|
+
from rics.misc import tname
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from rics import strings
|
|
11
|
+
|
|
12
|
+
fmt_perf = strings.format_perf_counter
|
|
13
|
+
fmt_sec = strings.format_seconds
|
|
14
|
+
except ImportError:
|
|
15
|
+
from rics import performance
|
|
16
|
+
|
|
17
|
+
# TODO(0.5.0): Drop this.
|
|
18
|
+
fmt_perf = performance.format_perf_counter
|
|
19
|
+
fmt_sec = performance.format_seconds
|
|
20
|
+
|
|
21
|
+
P = ParamSpec("P")
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
24
|
+
PARAMS = (("expand_limits", "flex"),)
|
|
25
|
+
WARNED: set[str] = set() # Minimize the amount of warnings - once per param is enough.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def deprecated_params(__func: Callable[P, T], /) -> Callable[P, T]:
|
|
29
|
+
@wraps(__func)
|
|
30
|
+
def wrap(*args: P.args, **kwargs: P.kwargs) -> T:
|
|
31
|
+
for new, old in PARAMS:
|
|
32
|
+
if old in kwargs:
|
|
33
|
+
if new in kwargs:
|
|
34
|
+
name = tname(__func, prefix_classname=True)
|
|
35
|
+
msg = f"Ambiguous call: {name}() does not accept both `{old}` and `{new}`."
|
|
36
|
+
raise TypeError(msg)
|
|
37
|
+
|
|
38
|
+
if old not in WARNED:
|
|
39
|
+
WARNED.add(old)
|
|
40
|
+
|
|
41
|
+
name = tname(__func, prefix_classname=True)
|
|
42
|
+
msg = f"{name}(): The `{old}` parameter is deprecated; use `{new}` instead."
|
|
43
|
+
warn(message=msg, category=DeprecationWarning, stacklevel=2)
|
|
44
|
+
|
|
45
|
+
kwargs[new] = kwargs.pop(old)
|
|
46
|
+
|
|
47
|
+
return __func(*args, **kwargs)
|
|
48
|
+
|
|
49
|
+
return wrap
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
from typing import TypeVar
|
|
1
|
+
from typing import Callable, ParamSpec, TypeVar
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
from time_split._compat import deprecated_params
|
|
4
|
+
|
|
5
|
+
P = ParamSpec("P")
|
|
6
|
+
T = TypeVar("T")
|
|
4
7
|
|
|
5
8
|
|
|
6
9
|
def add_docstrings(**kwargs: str) -> None:
|
|
@@ -8,11 +11,17 @@ def add_docstrings(**kwargs: str) -> None:
|
|
|
8
11
|
_DOCSTRINGS.update(kwargs)
|
|
9
12
|
|
|
10
13
|
|
|
11
|
-
def docs(
|
|
14
|
+
def docs(__func: Callable[P, T], /) -> Callable[P, T]:
|
|
12
15
|
"""Add info the docstring of a function."""
|
|
13
|
-
assert
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
assert __func.__doc__ is not None # noqa: S101
|
|
17
|
+
__func.__doc__ = __func.__doc__.format(**_DOCSTRINGS)
|
|
18
|
+
|
|
19
|
+
if isinstance(__func, type):
|
|
20
|
+
__func.__init__ = deprecated_params(__func.__init__)
|
|
21
|
+
else:
|
|
22
|
+
__func = deprecated_params(__func)
|
|
23
|
+
|
|
24
|
+
return __func
|
|
16
25
|
|
|
17
26
|
|
|
18
27
|
_OFFSET = "pandas :ref:`offset alias <pandas:timeseries.offset_aliases>`"
|
|
@@ -27,9 +36,10 @@ _DOCSTRINGS = {
|
|
|
27
36
|
"step": "Select a subset of folds, preferring folds later in the schedule.",
|
|
28
37
|
"n_splits": "Maximum number of folds, preferring folds later in the schedule.",
|
|
29
38
|
"available": "Binds `schedule` to a range.",
|
|
30
|
-
"
|
|
39
|
+
"expand_limits": f'A {_OFFSET} used to expand `available` data to its likely `"true"` limits. Pass ``False`` to disable.',
|
|
31
40
|
"USER_GUIDE": (
|
|
32
|
-
"For more information about the `schedule`, `before/after` and `
|
|
41
|
+
"For more information about the `schedule`, `before/after` and `expand_limits`-arguments"
|
|
42
|
+
", see the :ref:`User guide`."
|
|
33
43
|
),
|
|
34
44
|
"OFFSET": _OFFSET,
|
|
35
45
|
"DatetimeIndexSplitterKwargs": "See :func:`~time_split.split`. The `available` keyword is managed by the integration.",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from ._plot import plot
|
|
2
|
-
from ._progress import log_split_progress
|
|
2
|
+
from ._progress import default_metrics_formatter, log_split_progress
|
|
3
3
|
from ._split import split
|
|
4
4
|
from ._to_string import to_string
|
|
5
5
|
from ._weight import fold_weight
|
|
@@ -7,6 +7,7 @@ from ._weight import fold_weight
|
|
|
7
7
|
__all__ = [
|
|
8
8
|
"fold_weight",
|
|
9
9
|
"log_split_progress",
|
|
10
|
+
"default_metrics_formatter",
|
|
10
11
|
"plot",
|
|
11
12
|
"split",
|
|
12
13
|
"to_string",
|
|
@@ -4,7 +4,8 @@ from typing import TYPE_CHECKING, Any, Literal
|
|
|
4
4
|
import pandas as pd
|
|
5
5
|
from pandas import Timestamp
|
|
6
6
|
from rics.misc import format_kwargs, get_public_module
|
|
7
|
-
|
|
7
|
+
|
|
8
|
+
from time_split._compat import fmt_sec
|
|
8
9
|
|
|
9
10
|
from .._backend import DatetimeIndexSplitter
|
|
10
11
|
from .._backend._datetime_index_like import DatetimeIndexLike
|
|
@@ -16,7 +17,7 @@ from ..types import (
|
|
|
16
17
|
DatetimeIterable,
|
|
17
18
|
DatetimeSplitBounds,
|
|
18
19
|
DatetimeSplits,
|
|
19
|
-
|
|
20
|
+
ExpandLimits,
|
|
20
21
|
Schedule,
|
|
21
22
|
Span,
|
|
22
23
|
)
|
|
@@ -64,7 +65,7 @@ def plot(
|
|
|
64
65
|
step: int = 1,
|
|
65
66
|
n_splits: int = 0,
|
|
66
67
|
available: DatetimeIterable | None = None,
|
|
67
|
-
|
|
68
|
+
expand_limits: ExpandLimits = "auto",
|
|
68
69
|
# Split plot args
|
|
69
70
|
bar_labels: str | Rows | list[tuple[str, str]] | bool = True,
|
|
70
71
|
show_removed: bool = False,
|
|
@@ -84,7 +85,7 @@ def plot(
|
|
|
84
85
|
n_splits: {n_splits}
|
|
85
86
|
available: {available} If `bar_labels` is given but is not a ``list``,
|
|
86
87
|
this data will be used to compute fold sizes.
|
|
87
|
-
|
|
88
|
+
expand_limits: {expand_limits}
|
|
88
89
|
bar_labels: Labels to draw on the bars. If you pass a string, it will be interpreted as a time unit (see
|
|
89
90
|
:ref:`pandas:timeseries.offset_aliases` for valid frequency strings). Bars will show the number of units
|
|
90
91
|
contained. Pass `'rows'` to simply count the numbers of elements in `data` (if given). To write custom
|
|
@@ -114,7 +115,7 @@ def plot(
|
|
|
114
115
|
after=after,
|
|
115
116
|
step=step,
|
|
116
117
|
n_splits=n_splits,
|
|
117
|
-
|
|
118
|
+
expand_limits=expand_limits,
|
|
118
119
|
)
|
|
119
120
|
plot_data = _get_plot_data(available, splitter, row_count_bin=row_count_bin, show_removed=show_removed)
|
|
120
121
|
|
|
@@ -207,10 +208,10 @@ def _plot_row_counts(ax: "Axes", row_count_bin: str | pd.Series, row_counts: pd.
|
|
|
207
208
|
from numpy import diff, timedelta64
|
|
208
209
|
|
|
209
210
|
row_counts = row_count_bin.sort_index()
|
|
210
|
-
pretty =
|
|
211
|
+
pretty = fmt_sec(diff(row_counts.index).min() / timedelta64(1, "s"))
|
|
211
212
|
else:
|
|
212
213
|
row_counts = row_counts.sort_index()
|
|
213
|
-
pretty =
|
|
214
|
+
pretty = fmt_sec(pd.Timedelta(row_count_bin).total_seconds())
|
|
214
215
|
|
|
215
216
|
row_counts = row_counts * (max(ax.get_yticks()) / row_counts.max()) # Normalize to fold number yaxis
|
|
216
217
|
ax.fill_between(
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Callable, Iterable, MutableMapping, Sequence
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from time import perf_counter
|
|
5
|
+
from typing import Any, Generic
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
from rics.collections.dicts import flatten_dict
|
|
9
|
+
|
|
10
|
+
from time_split._compat import fmt_sec
|
|
11
|
+
|
|
12
|
+
from ..settings import log_split_progress as settings
|
|
13
|
+
from ..types import DatetimeSplitBounds, FormatMetrics, GetMetrics, LoggerArg, MetricsType, SplitProgressExtras
|
|
14
|
+
from ._to_string import _PrettyTimestamp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def log_split_progress(
|
|
18
|
+
splits: Sequence[DatetimeSplitBounds],
|
|
19
|
+
*,
|
|
20
|
+
logger: LoggerArg = "time_split",
|
|
21
|
+
start_level: int = logging.INFO,
|
|
22
|
+
end_level: int = logging.INFO,
|
|
23
|
+
extra: dict[str, Any] | None = None,
|
|
24
|
+
get_metrics: GetMetrics[MetricsType] | None = None,
|
|
25
|
+
) -> Iterable[DatetimeSplitBounds]:
|
|
26
|
+
"""Log iteration progress.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
splits: Splits to iterate over.
|
|
30
|
+
logger: Logger or logger name to use.
|
|
31
|
+
start_level: Log level to use for the :attr:`fold-begin message <.settings.log_split_progress.START_MESSAGE>`.
|
|
32
|
+
end_level: Log level to use for the :attr:`fold-end message <.settings.log_split_progress.END_MESSAGE>`.
|
|
33
|
+
extra: Immutable, user-defined `extra`-arguments to use when logging, merged with progress-related extras (see
|
|
34
|
+
:class:`~time_split.types.SplitProgressExtras`).
|
|
35
|
+
get_metrics: A callable ``(training_date) -> fold_metrics | str`` (see :attr:`~.DatetimeSplit.training_date`).
|
|
36
|
+
If given, metrics are added to the :attr:`fold-end message <.settings.log_split_progress.END_MESSAGE>`. The
|
|
37
|
+
message is formatted using the :func:`default formatter <.support.default_metrics_formatter>` unless
|
|
38
|
+
:attr:`~.settings.log_split_progress.FORMAT_METRICS` is set. If this callback returns a ``str`` argument,
|
|
39
|
+
the :func:`default formatter <.support.default_metrics_formatter>` will assume that the metrics are
|
|
40
|
+
pre-formatted, simply appending the formatted metrics to the
|
|
41
|
+
:attr:`fold-end message <.settings.log_split_progress.END_MESSAGE>` as-is.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
An iterable over `splits`.
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
Configuring the `logger` name and
|
|
48
|
+
:attr:`fold-begin message <.settings.log_split_progress.START_MESSAGE>` log level.
|
|
49
|
+
|
|
50
|
+
>>> from time_split import split, log_split_progress
|
|
51
|
+
>>> schedule = ["2023-08-16", "2023-08-17 12", "2023-08-19"]
|
|
52
|
+
>>> tracked_splits = log_split_progress(
|
|
53
|
+
... split(schedule),
|
|
54
|
+
... logger="progress",
|
|
55
|
+
... start_level=logging.DEBUG,
|
|
56
|
+
... )
|
|
57
|
+
>>> list(splits) # doctest: +SKIP
|
|
58
|
+
[progress:DEBUG] Begin fold 1/2: '2023-08-09' <= [schedule: '2023-08-16' (Wednesday)] < '2023-08-17 12:00:00'.
|
|
59
|
+
[progress:INFO] Finished fold 1/2 [schedule: '2023-08-16' (Wednesday)] after 5m 18s.
|
|
60
|
+
[progress:DEBUG] Begin fold 2/2: '2023-08-10 12:00:00' <= [schedule: '2023-08-17 12:00:00' (Thursday)] < '2023-08-19'.
|
|
61
|
+
[progress:INFO] Finished fold 2/2 [schedule: '2023-08-17 12:00:00' (Thursday)] after 4m 3s.
|
|
62
|
+
|
|
63
|
+
Using the `get_metrics` callback argument.
|
|
64
|
+
|
|
65
|
+
>>> metrics = {
|
|
66
|
+
... "2023-08-16 00:00:00": {"rmse": {"train": 0.11, "test": 0.5}},
|
|
67
|
+
... "2023-08-17 12:00:00": {"rmse": {"test": 0.5, "future": 20.19}},
|
|
68
|
+
... }
|
|
69
|
+
>>> tracked_splits = log_split_progress(
|
|
70
|
+
... split(schedule),
|
|
71
|
+
... get_metrics=lambda key: metrics[str(key)],
|
|
72
|
+
... )
|
|
73
|
+
>>> list(tracked_splits) # doctest: +SKIP
|
|
74
|
+
[time_split:INFO] Begin fold 1/2: '2023-08-09' <= [schedule: '2023-08-16' (Wednesday)] < '2023-08-17 12:00:00'.
|
|
75
|
+
[time_split:INFO] Finished fold 1/2 [schedule: '2023-08-16' (Wednesday)] after 5m 18s. Fold metrics:
|
|
76
|
+
rmse.train 0.11
|
|
77
|
+
rmse.test 0.5
|
|
78
|
+
[time_split:INFO] Begin fold 2/2: '2023-08-10 12:00:00' <= [schedule: '2023-08-17 12:00:00' (Thursday)] < '2023-08-19'.
|
|
79
|
+
[time_split:INFO] Finished fold 2/2 [schedule: '2023-08-17 12:00:00' (Thursday)] after 4m 3s. Fold metrics:
|
|
80
|
+
rmse.test 0.5
|
|
81
|
+
rmse.future 20.19
|
|
82
|
+
|
|
83
|
+
Formatting was done using the :func:`default formatter <.support.default_metrics_formatter>`, since the
|
|
84
|
+
:attr:`~.settings.log_split_progress.FORMAT_METRICS` setting is ``None``.
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
logger = logging.getLogger(logger) if isinstance(logger, str) else logger
|
|
88
|
+
|
|
89
|
+
if isinstance(logger, logging.LoggerAdapter) and not hasattr(logger, "merge_extra"):
|
|
90
|
+
# Backport of https://github.com/python/cpython/pull/107292
|
|
91
|
+
logger = _MergingLoggerAdapter(logger.logger, logger.extra)
|
|
92
|
+
|
|
93
|
+
track = _ProgressTracker(
|
|
94
|
+
logger=logger,
|
|
95
|
+
fold_format=settings.FOLD_FORMAT,
|
|
96
|
+
start_level=start_level,
|
|
97
|
+
start_message=settings.START_MESSAGE,
|
|
98
|
+
end_level=end_level,
|
|
99
|
+
end_message=settings.END_MESSAGE,
|
|
100
|
+
seconds_formatter=settings.SECONDS_FORMATTER or fmt_sec,
|
|
101
|
+
user_extra={} if extra is None else extra.copy(), # Not actually immutable; deepcopy can be very expensive.
|
|
102
|
+
get_metrics=get_metrics,
|
|
103
|
+
format_metrics=settings.FORMAT_METRICS or default_metrics_formatter,
|
|
104
|
+
)
|
|
105
|
+
return track(splits)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class _ProgressTracker(Generic[MetricsType]):
|
|
110
|
+
logger: logging.Logger | logging.LoggerAdapter # type: ignore[type-arg]
|
|
111
|
+
fold_format: str
|
|
112
|
+
start_level: int
|
|
113
|
+
start_message: str
|
|
114
|
+
end_level: int
|
|
115
|
+
end_message: str
|
|
116
|
+
seconds_formatter: Callable[[float], str]
|
|
117
|
+
user_extra: dict[str, Any]
|
|
118
|
+
get_metrics: GetMetrics[MetricsType] | None
|
|
119
|
+
format_metrics: FormatMetrics[MetricsType]
|
|
120
|
+
|
|
121
|
+
def __call__(self, splits: Sequence[DatetimeSplitBounds]) -> Iterable[DatetimeSplitBounds]:
|
|
122
|
+
n_splits = len(splits)
|
|
123
|
+
|
|
124
|
+
for n, split in enumerate(splits, start=1):
|
|
125
|
+
default_extras = SplitProgressExtras(
|
|
126
|
+
n=n,
|
|
127
|
+
n_splits=n_splits,
|
|
128
|
+
start=split.start.isoformat(),
|
|
129
|
+
mid=split.mid.isoformat(),
|
|
130
|
+
end=split.end.isoformat(),
|
|
131
|
+
)
|
|
132
|
+
extra = {**self.user_extra, **default_extras}
|
|
133
|
+
kwargs: dict[str, Any] = dict(
|
|
134
|
+
n=n,
|
|
135
|
+
n_splits=n_splits,
|
|
136
|
+
start=_PrettyTimestamp(split.start),
|
|
137
|
+
mid=_PrettyTimestamp(split.mid),
|
|
138
|
+
end=_PrettyTimestamp(split.end),
|
|
139
|
+
**self.user_extra,
|
|
140
|
+
)
|
|
141
|
+
kwargs.update(fold=self.fold_format.format(**kwargs))
|
|
142
|
+
|
|
143
|
+
self.logger.log(self.start_level, self.start_message.format(**kwargs), extra=extra)
|
|
144
|
+
|
|
145
|
+
# Yield split and count user time.
|
|
146
|
+
start = perf_counter()
|
|
147
|
+
yield split
|
|
148
|
+
seconds = round(perf_counter() - start, 6)
|
|
149
|
+
|
|
150
|
+
kwargs.update(
|
|
151
|
+
seconds=seconds,
|
|
152
|
+
formatted_seconds=self.seconds_formatter(seconds),
|
|
153
|
+
)
|
|
154
|
+
msg = self.end_message.format(**kwargs)
|
|
155
|
+
|
|
156
|
+
if self.get_metrics is not None:
|
|
157
|
+
extra["metrics"] = self.get_metrics(split.mid)
|
|
158
|
+
msg = self.format_metrics(msg, extra["metrics"])
|
|
159
|
+
|
|
160
|
+
extra.update(seconds=seconds)
|
|
161
|
+
self.logger.log(self.end_level, msg, extra=extra)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def default_metrics_formatter(end_message: str, metrics: dict[Any, Any] | pd.Series | pd.DataFrame | str | Any) -> str:
|
|
165
|
+
"""Default formatting implementation.
|
|
166
|
+
|
|
167
|
+
Format using an appropriate pandas ``to_string()``-method if `metrics` is a ``dict`` or a pandas type. Nested
|
|
168
|
+
dictionaries are flattened using :func:`~rics.collections.dicts.flatten_dict` if `metrics` is a dict-of-dicts.
|
|
169
|
+
|
|
170
|
+
Metrics of type ``str`` are assumed to be preformatted, and are appended to `end_message` as-is.
|
|
171
|
+
|
|
172
|
+
If any other types are given, fall back to
|
|
173
|
+
``f"{end_message} Metrics: {metrics}"``.
|
|
174
|
+
|
|
175
|
+
Examples:
|
|
176
|
+
Formatting a nested dict.
|
|
177
|
+
|
|
178
|
+
>>> metrics = {"rmse": {"train": 0.11, "test": 0.5, "future": 20.19}}
|
|
179
|
+
|
|
180
|
+
>>> print(default_metrics_formatter("End message.", metrics))
|
|
181
|
+
End message. Fold metrics:
|
|
182
|
+
train test future
|
|
183
|
+
rmse 0.11 0.5 20.19
|
|
184
|
+
|
|
185
|
+
Formatting a :class:`pandas.DataFrame`.
|
|
186
|
+
|
|
187
|
+
>>> metrics = {"me": [0.1, 0.2, 0.3], "rmse": [0.11, 0.5, 20.19]}
|
|
188
|
+
>>> df = pd.DataFrame(metrics, index=["train", "test", "future"])
|
|
189
|
+
>>> print(default_metrics_formatter("End message.", df))
|
|
190
|
+
End message. Fold metrics:
|
|
191
|
+
me rmse
|
|
192
|
+
train 0.1 0.11
|
|
193
|
+
test 0.2 0.50
|
|
194
|
+
future 0.3 20.19
|
|
195
|
+
|
|
196
|
+
The index printed unless` it is a :class:`pandas.RangeIndex`.
|
|
197
|
+
"""
|
|
198
|
+
if isinstance(metrics, dict):
|
|
199
|
+
metrics = _convert_dict(metrics)
|
|
200
|
+
|
|
201
|
+
if isinstance(metrics, (pd.Series, pd.DataFrame)):
|
|
202
|
+
if isinstance(metrics, pd.DataFrame):
|
|
203
|
+
with_index = not isinstance(metrics, pd.RangeIndex)
|
|
204
|
+
else:
|
|
205
|
+
with_index = True
|
|
206
|
+
return f"{end_message} Fold metrics:\n{metrics.round(3).to_string(index=with_index)}"
|
|
207
|
+
elif isinstance(metrics, str):
|
|
208
|
+
return f"{end_message} {metrics}"
|
|
209
|
+
else:
|
|
210
|
+
return f"{end_message} Metrics: {metrics}"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class _MergingLoggerAdapter(logging.LoggerAdapter[Any]):
|
|
214
|
+
# TODO(3.13): Use merge_extra=True init arg
|
|
215
|
+
def process(self, msg: Any, kwargs: MutableMapping[str, Any]) -> tuple[Any, MutableMapping[str, Any]]:
|
|
216
|
+
"""See https://github.com/python/cpython/pull/107292."""
|
|
217
|
+
kwargs["extra"] = {**self.extra, **kwargs["extra"]} if "extra" in kwargs and self.extra else self.extra
|
|
218
|
+
return msg, kwargs
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _convert_dict(metrics: dict[Any, Any]) -> dict[Any, Any] | pd.DataFrame | pd.Series:
|
|
222
|
+
original = metrics
|
|
223
|
+
|
|
224
|
+
scalar_leaves: bool = False
|
|
225
|
+
|
|
226
|
+
if all(isinstance(v, dict) for v in metrics.values()):
|
|
227
|
+
flat = flatten_dict(metrics)
|
|
228
|
+
|
|
229
|
+
# A single dot usually indicates nesting one level deep; can be formatted as a DataFrame.
|
|
230
|
+
if any(k.count(".") != 1 for k in flat) and all(pd.api.types.is_scalar(v) for v in flat.values()):
|
|
231
|
+
metrics = flat
|
|
232
|
+
elif all(pd.api.types.is_scalar(v) for v in original.values()):
|
|
233
|
+
scalar_leaves = True
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
if scalar_leaves:
|
|
237
|
+
return pd.Series(metrics)
|
|
238
|
+
|
|
239
|
+
df = pd.DataFrame(metrics)
|
|
240
|
+
except Exception:
|
|
241
|
+
return original
|
|
242
|
+
|
|
243
|
+
if len(df) > len(df.columns):
|
|
244
|
+
df = df.T # Prefer wider horizontal output
|
|
245
|
+
return df
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from .._backend import DatetimeIndexSplitter
|
|
2
2
|
from .._docstrings import docs
|
|
3
|
-
from ..types import DatetimeIterable, DatetimeSplits,
|
|
3
|
+
from ..types import DatetimeIterable, DatetimeSplits, ExpandLimits, Schedule, Span
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
@docs
|
|
@@ -12,7 +12,7 @@ def split(
|
|
|
12
12
|
step: int = 1,
|
|
13
13
|
n_splits: int = 0,
|
|
14
14
|
available: DatetimeIterable | None = None,
|
|
15
|
-
|
|
15
|
+
expand_limits: ExpandLimits = "auto",
|
|
16
16
|
) -> DatetimeSplits:
|
|
17
17
|
"""Create time-based cross-validation splits.
|
|
18
18
|
|
|
@@ -25,7 +25,7 @@ def split(
|
|
|
25
25
|
step: {step}
|
|
26
26
|
n_splits: {n_splits}
|
|
27
27
|
available: {available} Passing a tuple ``(min, max)`` is enough.
|
|
28
|
-
|
|
28
|
+
expand_limits: {expand_limits}
|
|
29
29
|
|
|
30
30
|
{USER_GUIDE}
|
|
31
31
|
|
|
@@ -41,5 +41,5 @@ def split(
|
|
|
41
41
|
after=after,
|
|
42
42
|
step=step,
|
|
43
43
|
n_splits=n_splits,
|
|
44
|
-
|
|
44
|
+
expand_limits=expand_limits,
|
|
45
45
|
).get_splits(available)
|
|
@@ -29,7 +29,7 @@ def to_string(
|
|
|
29
29
|
|
|
30
30
|
|
|
31
31
|
def to_string(
|
|
32
|
-
bounds:
|
|
32
|
+
bounds: DatetimeTypes | DatetimeSplitBounds | tuple[DatetimeTypes, DatetimeTypes, DatetimeTypes],
|
|
33
33
|
mid: DatetimeTypes | None = None,
|
|
34
34
|
end: DatetimeTypes | None = None,
|
|
35
35
|
/,
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from collections.abc import Iterable
|
|
3
|
-
from typing import Any
|
|
3
|
+
from typing import Any
|
|
4
4
|
|
|
5
5
|
from .. import log_split_progress
|
|
6
6
|
from .._docstrings import add_docstrings
|
|
7
|
-
from ..types import DatetimeSplitBounds, DatetimeSplits
|
|
7
|
+
from ..types import DatetimeSplitBounds, DatetimeSplits, LogSplitProgressKwargs, MetricsType
|
|
8
8
|
|
|
9
|
-
LogProgressArg =
|
|
9
|
+
LogProgressArg = str | bool | logging.Logger | logging.LoggerAdapter[Any] | LogSplitProgressKwargs[MetricsType]
|
|
10
10
|
|
|
11
11
|
add_docstrings(log_progress="Controls logging of fold progress. See :func:`~.log_split_progress` for details.")
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def handle_log_progress_arg(
|
|
15
|
-
log_progress: LogProgressArg, *, splits: DatetimeSplits
|
|
15
|
+
log_progress: LogProgressArg[MetricsType], *, splits: DatetimeSplits
|
|
16
16
|
) -> Iterable[DatetimeSplitBounds] | None:
|
|
17
17
|
"""Wrapper function for integrations."""
|
|
18
18
|
if log_progress is True:
|
|
@@ -49,7 +49,7 @@ class DatetimeSplit(_t.NamedTuple, _t.Generic[DataT]):
|
|
|
49
49
|
def split_data(
|
|
50
50
|
data: DataT,
|
|
51
51
|
*,
|
|
52
|
-
log_progress: _log_progress.LogProgressArg = False,
|
|
52
|
+
log_progress: _log_progress.LogProgressArg[_tst.MetricsType] = False,
|
|
53
53
|
as_available: DataAsAvailableFn[DataT],
|
|
54
54
|
select: DataSelectFn[DataT],
|
|
55
55
|
**kwargs: _t.Unpack[_tst.DatetimeIndexSplitterKwargs],
|
|
@@ -6,9 +6,9 @@ from pandas import DataFrame, DatetimeIndex, Series, Timestamp
|
|
|
6
6
|
from rics.misc import tname
|
|
7
7
|
|
|
8
8
|
from ..._docstrings import docs
|
|
9
|
-
from ...types import DatetimeIndexSplitterKwargs
|
|
9
|
+
from ...types import DatetimeIndexSplitterKwargs, MetricsType
|
|
10
10
|
from .._log_progress import LogProgressArg
|
|
11
|
-
from ..
|
|
11
|
+
from ..base import DatetimeSplit, split_data
|
|
12
12
|
|
|
13
13
|
PandasT = TypeVar("PandasT", Series, DataFrame)
|
|
14
14
|
"""A splittable pandas type."""
|
|
@@ -19,7 +19,7 @@ def split_pandas(
|
|
|
19
19
|
data: PandasT,
|
|
20
20
|
time_column: Hashable = None,
|
|
21
21
|
*,
|
|
22
|
-
log_progress: LogProgressArg = False,
|
|
22
|
+
log_progress: LogProgressArg[MetricsType] = False,
|
|
23
23
|
**kwargs: Unpack[DatetimeIndexSplitterKwargs],
|
|
24
24
|
) -> Iterable[DatetimeSplit[PandasT]]:
|
|
25
25
|
"""Split a pandas type.
|
|
@@ -6,9 +6,9 @@ from polars import DataFrame, Series
|
|
|
6
6
|
from rics.misc import tname
|
|
7
7
|
|
|
8
8
|
from ..._docstrings import docs
|
|
9
|
-
from ...types import DatetimeIndexSplitterKwargs
|
|
9
|
+
from ...types import DatetimeIndexSplitterKwargs, MetricsType
|
|
10
10
|
from .._log_progress import LogProgressArg
|
|
11
|
-
from ..
|
|
11
|
+
from ..base import DatetimeSplit, split_data
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@docs
|
|
@@ -16,7 +16,7 @@ def split_polars(
|
|
|
16
16
|
data: DataFrame,
|
|
17
17
|
time_column: str,
|
|
18
18
|
*,
|
|
19
|
-
log_progress: LogProgressArg = False,
|
|
19
|
+
log_progress: LogProgressArg[MetricsType] = False,
|
|
20
20
|
**kwargs: Unpack[DatetimeIndexSplitterKwargs],
|
|
21
21
|
) -> Iterable[DatetimeSplit[DataFrame]]:
|
|
22
22
|
"""Split a polars frame.
|
|
@@ -10,7 +10,8 @@ from ...types import (
|
|
|
10
10
|
DatetimeIterable,
|
|
11
11
|
DatetimeSplits,
|
|
12
12
|
DatetimeTypes,
|
|
13
|
-
|
|
13
|
+
ExpandLimits,
|
|
14
|
+
MetricsType,
|
|
14
15
|
Schedule,
|
|
15
16
|
Span,
|
|
16
17
|
)
|
|
@@ -43,7 +44,7 @@ class ScikitLearnSplitter(BaseCrossValidator): # type: ignore[misc]
|
|
|
43
44
|
after: {after}
|
|
44
45
|
step: {step}
|
|
45
46
|
n_splits: {n_splits}
|
|
46
|
-
|
|
47
|
+
expand_limits: {expand_limits}
|
|
47
48
|
log_progress: {log_progress}
|
|
48
49
|
verify_xy: If ``True``, split X and y independently and verify that they are equal.
|
|
49
50
|
|
|
@@ -58,9 +59,9 @@ class ScikitLearnSplitter(BaseCrossValidator): # type: ignore[misc]
|
|
|
58
59
|
before: Span = "7d",
|
|
59
60
|
after: Span = 1,
|
|
60
61
|
n_splits: int = 0,
|
|
61
|
-
|
|
62
|
+
expand_limits: ExpandLimits = "auto",
|
|
62
63
|
step: int = 1,
|
|
63
|
-
log_progress: LogProgressArg = False,
|
|
64
|
+
log_progress: LogProgressArg[MetricsType] = False,
|
|
64
65
|
verify_xy: bool = True,
|
|
65
66
|
) -> None:
|
|
66
67
|
super().__init__()
|
|
@@ -70,7 +71,7 @@ class ScikitLearnSplitter(BaseCrossValidator): # type: ignore[misc]
|
|
|
70
71
|
after=after,
|
|
71
72
|
step=step,
|
|
72
73
|
n_splits=n_splits,
|
|
73
|
-
|
|
74
|
+
expand_limits=expand_limits,
|
|
74
75
|
)
|
|
75
76
|
self.log_progress = log_progress
|
|
76
77
|
self.verify_xy = verify_xy
|
|
@@ -5,14 +5,14 @@ import typing as _t
|
|
|
5
5
|
from . import types as _tst
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
class
|
|
9
|
-
"""Configuration for the `'auto'` :attr:`~time_split.types.
|
|
8
|
+
class auto_expand_limits: # noqa: N801
|
|
9
|
+
"""Configuration for the `'auto'` :attr:`~time_split.types.ExpandLimits` logic.
|
|
10
10
|
|
|
11
|
-
This class determines how ``(min, max)``-tuples are expanded when
|
|
11
|
+
This class determines how ``(min, max)``-tuples are expanded when expand_limitsible bounds are enabled.
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
SANITY_CHECK: bool = True
|
|
15
|
-
"""If ``True``, use original limits if
|
|
15
|
+
"""If ``True``, use original limits if expand_limitsed limits do not pass sanity checks."""
|
|
16
16
|
|
|
17
17
|
@classmethod
|
|
18
18
|
def set_level(
|
|
@@ -23,7 +23,7 @@ class auto_flex: # noqa: N801
|
|
|
23
23
|
round_to: _tst.TimedeltaTypes,
|
|
24
24
|
tolerance: _tst.TimedeltaTypes,
|
|
25
25
|
) -> None:
|
|
26
|
-
"""Set a :class:`Level` used by the auto-
|
|
26
|
+
"""Set a :class:`Level` used by the auto-expand_limits logic.
|
|
27
27
|
|
|
28
28
|
Inputs are not verified until an actual split is made.
|
|
29
29
|
|
|
@@ -43,7 +43,7 @@ class auto_flex: # noqa: N801
|
|
|
43
43
|
setattr(cls, level, level_tuple)
|
|
44
44
|
|
|
45
45
|
class Level(_t.NamedTuple):
|
|
46
|
-
"""Level type used by :attr:`
|
|
46
|
+
"""Level type used by :attr:`auto_expand_limits`."""
|
|
47
47
|
|
|
48
48
|
start_at: _tst.TimedeltaTypes
|
|
49
49
|
"""Span size at which this level starts."""
|
|
@@ -113,11 +113,10 @@ class log_split_progress: # noqa: N801
|
|
|
113
113
|
'2021-12-30' <= [schedule: '2022-01-04' (Tuesday)] < '2022-01-04 18:00:00'
|
|
114
114
|
"""
|
|
115
115
|
|
|
116
|
-
SECONDS_FORMATTER:
|
|
117
|
-
"""A callable ``(seconds) -> formatted_seconds``.
|
|
116
|
+
SECONDS_FORMATTER: _t.Callable[[float], str] | None = None
|
|
117
|
+
"""A callable ``(seconds) -> formatted_seconds``. Use :func:`rics.strings.format_seconds` if ``None``.
|
|
118
118
|
|
|
119
|
-
Both ``seconds`` and ``formatted_seconds`` will be available to the :attr:`END_MESSAGE` message.
|
|
120
|
-
given, the actual callable will be resolved using :func:`rics.misc.get_by_full_name`.
|
|
119
|
+
Both ``seconds`` and ``formatted_seconds`` will be available to the :attr:`END_MESSAGE` message.
|
|
121
120
|
"""
|
|
122
121
|
|
|
123
122
|
START_MESSAGE: str = "Begin fold {n}/{n_splits}: {fold}."
|
|
@@ -156,6 +155,9 @@ class log_split_progress: # noqa: N801
|
|
|
156
155
|
AUTO_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
157
156
|
"""Long-form timestamp format_spec used by ``<key>.auto``."""
|
|
158
157
|
|
|
158
|
+
FORMAT_METRICS: _tst.FormatMetrics[_t.Any] | None = None
|
|
159
|
+
"""A callable ``(end_message, metrics) -> str``. Use the :func:`default formatter <.support.default_metrics_formatter>` if ``None``."""
|
|
160
|
+
|
|
159
161
|
|
|
160
162
|
class misc: # noqa: N801
|
|
161
163
|
"""Miscellaneous settings that don't fit in any other namespace."""
|
|
@@ -12,12 +12,13 @@ dependencies if you need to use the ``support`` module.
|
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
14
|
from .._backend import DatetimeIndexSplitter, expand_limits, process_available
|
|
15
|
-
from .._frontend import fold_weight, to_string
|
|
15
|
+
from .._frontend import default_metrics_formatter, fold_weight, to_string
|
|
16
16
|
|
|
17
17
|
__all__ = [
|
|
18
18
|
"DatetimeIndexSplitter",
|
|
19
19
|
"expand_limits",
|
|
20
20
|
"process_available",
|
|
21
|
+
"default_metrics_formatter",
|
|
21
22
|
"fold_weight",
|
|
22
23
|
"to_string",
|
|
23
24
|
]
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Types related to splitting data."""
|
|
2
2
|
|
|
3
3
|
import datetime as _dt
|
|
4
|
+
import logging as _logging
|
|
4
5
|
import typing as _t
|
|
5
6
|
|
|
6
7
|
import numpy as _np
|
|
@@ -17,8 +18,8 @@ Schedule: _t.TypeAlias = _pd.DatetimeIndex | DatetimeIterable | TimedeltaTypes
|
|
|
17
18
|
"""User schedule type."""
|
|
18
19
|
Span = int | _t.Literal["all"] | TimedeltaTypes
|
|
19
20
|
"""User span type. Used to determine limits from the timestamps given by a :attr:`Schedule`."""
|
|
20
|
-
|
|
21
|
-
"""
|
|
21
|
+
ExpandLimits = bool | _t.Literal["auto"] | str
|
|
22
|
+
"""Limits flexibility spec for ``floor/ceil``. Pass ``False`` to disable."""
|
|
22
23
|
|
|
23
24
|
|
|
24
25
|
class DatetimeSplitBounds(_t.NamedTuple):
|
|
@@ -61,4 +62,42 @@ class DatetimeIndexSplitterKwargs(_t.TypedDict, total=False):
|
|
|
61
62
|
after: Span
|
|
62
63
|
step: int
|
|
63
64
|
n_splits: int
|
|
64
|
-
|
|
65
|
+
expand_limits: ExpandLimits
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
LoggerArg = _logging.Logger | _logging.LoggerAdapter[_t.Any] | str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
MetricsType = _t.TypeVar("MetricsType")
|
|
72
|
+
"""Metrics argument type."""
|
|
73
|
+
GetMetrics = _t.Callable[[_pd.Timestamp], MetricsType]
|
|
74
|
+
"""A callable ``(Timestamp) -> MetricsType``."""
|
|
75
|
+
FormatMetrics = _t.Callable[[str, MetricsType], str]
|
|
76
|
+
"""A callable ``(end_message, metrics) -> str``."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class LogSplitProgressKwargs(_t.TypedDict, _t.Generic[MetricsType], total=False):
|
|
80
|
+
"""Keyword arguments for by :func:`.log_split_progress`."""
|
|
81
|
+
|
|
82
|
+
logger: LoggerArg
|
|
83
|
+
start_level: int
|
|
84
|
+
end_level: int
|
|
85
|
+
extra: dict[str, _t.Any] | None
|
|
86
|
+
get_metrics: GetMetrics[MetricsType]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SplitProgressExtras(_t.TypedDict):
|
|
90
|
+
"""Named `extras` used for messages logged by :func:`.log_split_progress`."""
|
|
91
|
+
|
|
92
|
+
n: int
|
|
93
|
+
"""Current split number (starting at 1)."""
|
|
94
|
+
n_splits: int
|
|
95
|
+
"""Total split count."""
|
|
96
|
+
start: str
|
|
97
|
+
"""An :meth:`ISO-formatted <pandas.Timestamp.isoformat>` timestamp (see :attr:`.DatetimeSplitBounds.start`)."""
|
|
98
|
+
mid: str
|
|
99
|
+
"""An :meth:`ISO-formatted <pandas.Timestamp.isoformat>` timestamp (see :attr:`.DatetimeSplitBounds.mid`)."""
|
|
100
|
+
end: str
|
|
101
|
+
"""An :meth:`ISO-formatted <pandas.Timestamp.isoformat>` timestamp (see :attr:`.DatetimeSplitBounds.end`)."""
|
|
102
|
+
seconds: _t.NotRequired[float]
|
|
103
|
+
"""User time for the fold. Available only for the :attr:`fold-end message <.settings.log_split_progress.END_MESSAGE>`."""
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import logging
|
|
2
|
-
from collections.abc import Callable, Iterable, MutableMapping, Sequence
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
from time import perf_counter
|
|
5
|
-
from typing import Any, Union
|
|
6
|
-
|
|
7
|
-
from rics.misc import get_by_full_name
|
|
8
|
-
|
|
9
|
-
from ..settings import log_split_progress as settings
|
|
10
|
-
from ..types import DatetimeSplitBounds
|
|
11
|
-
from ._to_string import _PrettyTimestamp
|
|
12
|
-
|
|
13
|
-
LoggerArg = Union[logging.Logger, logging.LoggerAdapter, str] # type: ignore[type-arg]
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def log_split_progress(
|
|
17
|
-
splits: Sequence[DatetimeSplitBounds],
|
|
18
|
-
*,
|
|
19
|
-
logger: LoggerArg = "time_split",
|
|
20
|
-
start_level: int = logging.INFO,
|
|
21
|
-
end_level: int = logging.INFO,
|
|
22
|
-
extra: dict[str, Any] | None = None,
|
|
23
|
-
) -> Iterable[DatetimeSplitBounds]:
|
|
24
|
-
"""Log iteration progress over `splits` using `logger`.
|
|
25
|
-
|
|
26
|
-
Args:
|
|
27
|
-
splits: Splits to iterate over.
|
|
28
|
-
logger: Logger or logger name to use.
|
|
29
|
-
start_level: Log level to use for the :attr:`fold-begin message <.settings.log_split_progress.START_MESSAGE>`.
|
|
30
|
-
end_level: Log level to use for the :attr:`fold-end message <.settings.log_split_progress.END_MESSAGE>`.
|
|
31
|
-
extra: User-defined `extra`-arguments to use when logging, merged with progress-related extras. Will be
|
|
32
|
-
available to all messages as well as the ``fold`` key. This argument is mutable; changes made to `extra`
|
|
33
|
-
will be reflected in logged records.
|
|
34
|
-
|
|
35
|
-
Returns:
|
|
36
|
-
An iterable over `splits`.
|
|
37
|
-
|
|
38
|
-
Examples:
|
|
39
|
-
Basic usage.
|
|
40
|
-
|
|
41
|
-
>>> from time_split import split, log_split_progress
|
|
42
|
-
>>> splits = split("36h", available=("2023-08-10", "2023-08-19"))
|
|
43
|
-
>>> tracked_splits = log_split_progress(
|
|
44
|
-
... splits, logger="progress", start_level=logging.DEBUG
|
|
45
|
-
... )
|
|
46
|
-
>>> list(tracked_splits) # doctest: +SKIP
|
|
47
|
-
[progress:DEBUG] Begin fold 1/2: ('2023-08-11' <= [schedule: '2023-08-16' (Wednesday)] < '2023-08-17 12:00:00').
|
|
48
|
-
[progress:INFO] Finished fold 1/2 [schedule: '2023-08-16' (Wednesday)] after 5m 18s.
|
|
49
|
-
[progress:DEBUG] Begin fold 2/2: ('2023-08-12 12:00:00' <= [schedule: '2023-08-17 12:00:00' (Thursday)] < '2023-08-19').
|
|
50
|
-
[progress:INFO] Finished fold 2/2 [schedule: '2023-08-17 12:00:00' (Thursday)] after 4m 3s.
|
|
51
|
-
"""
|
|
52
|
-
logger = logging.getLogger(logger) if isinstance(logger, str) else logger
|
|
53
|
-
|
|
54
|
-
if isinstance(logger, logging.LoggerAdapter) and not hasattr(logger, "merge_extra"):
|
|
55
|
-
# Backport of https://github.com/python/cpython/pull/107292
|
|
56
|
-
logger = _MergingLoggerAdapter(logger.logger, logger.extra)
|
|
57
|
-
|
|
58
|
-
track = _ProgressTracker(
|
|
59
|
-
logger=logger,
|
|
60
|
-
fold_format=settings.FOLD_FORMAT,
|
|
61
|
-
start_level=start_level,
|
|
62
|
-
start_message=settings.START_MESSAGE,
|
|
63
|
-
end_level=end_level,
|
|
64
|
-
end_message=settings.END_MESSAGE,
|
|
65
|
-
seconds_formatter=settings.SECONDS_FORMATTER
|
|
66
|
-
if callable(settings.SECONDS_FORMATTER)
|
|
67
|
-
else get_by_full_name(settings.SECONDS_FORMATTER),
|
|
68
|
-
user_extra=extra or {},
|
|
69
|
-
)
|
|
70
|
-
return track(splits)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
@dataclass(frozen=True)
|
|
74
|
-
class _ProgressTracker:
|
|
75
|
-
logger: logging.Logger | logging.LoggerAdapter # type: ignore[type-arg]
|
|
76
|
-
fold_format: str
|
|
77
|
-
start_level: int
|
|
78
|
-
start_message: str
|
|
79
|
-
end_level: int
|
|
80
|
-
end_message: str
|
|
81
|
-
seconds_formatter: Callable[[float], str]
|
|
82
|
-
user_extra: dict[str, Any]
|
|
83
|
-
|
|
84
|
-
def __call__(self, splits: Sequence[DatetimeSplitBounds]) -> Iterable[DatetimeSplitBounds]:
|
|
85
|
-
n_splits = len(splits)
|
|
86
|
-
|
|
87
|
-
for n, split in enumerate(splits, start=1):
|
|
88
|
-
extra: dict[str, str | float | int | bool] = dict(
|
|
89
|
-
n=n,
|
|
90
|
-
n_splits=n_splits,
|
|
91
|
-
start=split.start.isoformat(),
|
|
92
|
-
mid=split.mid.isoformat(),
|
|
93
|
-
end=split.end.isoformat(),
|
|
94
|
-
**self.user_extra,
|
|
95
|
-
)
|
|
96
|
-
kwargs: dict[str, Any] = dict(
|
|
97
|
-
n=n,
|
|
98
|
-
n_splits=n_splits,
|
|
99
|
-
start=_PrettyTimestamp(split.start),
|
|
100
|
-
mid=_PrettyTimestamp(split.mid),
|
|
101
|
-
end=_PrettyTimestamp(split.end),
|
|
102
|
-
**self.user_extra,
|
|
103
|
-
)
|
|
104
|
-
kwargs.update(fold=self.fold_format.format(**kwargs))
|
|
105
|
-
|
|
106
|
-
self.logger.log(self.start_level, self.start_message.format(**kwargs), extra=extra)
|
|
107
|
-
|
|
108
|
-
# Yield split and count user time.
|
|
109
|
-
start = perf_counter()
|
|
110
|
-
yield split
|
|
111
|
-
seconds = round(perf_counter() - start, 6)
|
|
112
|
-
|
|
113
|
-
kwargs.update(
|
|
114
|
-
seconds=seconds,
|
|
115
|
-
formatted_seconds=self.seconds_formatter(seconds),
|
|
116
|
-
)
|
|
117
|
-
extra.update(seconds=seconds)
|
|
118
|
-
self.logger.log(self.end_level, self.end_message.format(**kwargs), extra=extra)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
class _MergingLoggerAdapter(logging.LoggerAdapter): # type: ignore[type-arg]
|
|
122
|
-
def process(self, msg: Any, kwargs: MutableMapping[str, Any]) -> tuple[Any, MutableMapping[str, Any]]:
|
|
123
|
-
"""See https://github.com/python/cpython/pull/107292."""
|
|
124
|
-
kwargs["extra"] = {**self.extra, **kwargs["extra"]} if "extra" in kwargs and self.extra else self.extra
|
|
125
|
-
return msg, kwargs
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|