moeforecast 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.
- moeforecast/__init__.py +57 -0
- moeforecast/_plotting.py +35 -0
- moeforecast/analysis.py +267 -0
- moeforecast/classification.py +227 -0
- moeforecast/clustering.py +454 -0
- moeforecast/data.py +174 -0
- moeforecast/datasets.py +27 -0
- moeforecast/experts.py +294 -0
- moeforecast/features.py +157 -0
- moeforecast/gating.py +132 -0
- moeforecast/metrics.py +52 -0
- moeforecast/model_selection.py +39 -0
- moeforecast/persistence.py +32 -0
- moeforecast/pipeline.py +576 -0
- moeforecast/tuning.py +121 -0
- moeforecast-0.1.0.dist-info/METADATA +262 -0
- moeforecast-0.1.0.dist-info/RECORD +19 -0
- moeforecast-0.1.0.dist-info/WHEEL +4 -0
- moeforecast-0.1.0.dist-info/licenses/LICENSE +21 -0
moeforecast/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
moeforecast: Mixture-of-Experts time series forecasting.
|
|
3
|
+
|
|
4
|
+
The library provides the building blocks to create a Mixture-of-Experts (MoE)
|
|
5
|
+
forecaster from a single time series:
|
|
6
|
+
|
|
7
|
+
1. ``prepare_series`` checks and regularizes the input series.
|
|
8
|
+
2. ``SeriesAnalyzer`` and ``feature_relevance`` help choose seasonal periods,
|
|
9
|
+
lags and predictors.
|
|
10
|
+
3. ``FeatureBuilder`` creates causal calendar, holiday, Fourier, lag and
|
|
11
|
+
rolling-window features.
|
|
12
|
+
4. ``ProfileClusterer`` groups seasonal profiles (e.g. days, weeks) into regimes.
|
|
13
|
+
5. ``RegimeClassifier`` predicts the regime of each time step from its features.
|
|
14
|
+
6. ``ExpertSelector`` chooses and trains one regression model (expert) per regime.
|
|
15
|
+
7. ``HardGating``, ``SoftGating`` and ``AdaptiveGating`` combine the experts.
|
|
16
|
+
8. ``MoEForecaster`` ties every step together with ``fit``, ``predict`` and
|
|
17
|
+
``forecast``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from moeforecast.analysis import SeriesAnalyzer, feature_relevance
|
|
21
|
+
from moeforecast.classification import RegimeClassifier, compare_classifiers, default_classifiers
|
|
22
|
+
from moeforecast.clustering import ProfileClusterer
|
|
23
|
+
from moeforecast.data import DataReport, fill_gaps, prepare_series
|
|
24
|
+
from moeforecast.experts import ExpertSelector, default_candidates
|
|
25
|
+
from moeforecast.features import FeatureBuilder
|
|
26
|
+
from moeforecast.gating import AdaptiveGating, HardGating, SoftGating
|
|
27
|
+
from moeforecast.metrics import mape
|
|
28
|
+
from moeforecast.model_selection import temporal_train_test_split
|
|
29
|
+
from moeforecast.persistence import load, save
|
|
30
|
+
from moeforecast.pipeline import MoEForecaster
|
|
31
|
+
from moeforecast.tuning import default_search_spaces
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"AdaptiveGating",
|
|
37
|
+
"DataReport",
|
|
38
|
+
"ExpertSelector",
|
|
39
|
+
"FeatureBuilder",
|
|
40
|
+
"HardGating",
|
|
41
|
+
"MoEForecaster",
|
|
42
|
+
"ProfileClusterer",
|
|
43
|
+
"RegimeClassifier",
|
|
44
|
+
"SeriesAnalyzer",
|
|
45
|
+
"SoftGating",
|
|
46
|
+
"compare_classifiers",
|
|
47
|
+
"default_classifiers",
|
|
48
|
+
"default_candidates",
|
|
49
|
+
"default_search_spaces",
|
|
50
|
+
"feature_relevance",
|
|
51
|
+
"fill_gaps",
|
|
52
|
+
"load",
|
|
53
|
+
"mape",
|
|
54
|
+
"prepare_series",
|
|
55
|
+
"save",
|
|
56
|
+
"temporal_train_test_split",
|
|
57
|
+
]
|
moeforecast/_plotting.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Shared, local styling for moeforecast diagnostic plots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
PALETTE = (
|
|
6
|
+
"#66c2a5",
|
|
7
|
+
"#fc8d62",
|
|
8
|
+
"#8da0cb",
|
|
9
|
+
"#e78ac3",
|
|
10
|
+
"#a6d854",
|
|
11
|
+
"#ffd92f",
|
|
12
|
+
"#e5c494",
|
|
13
|
+
"#b3b3b3",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def style_axis(ax, grid_axis: str = "both"):
|
|
18
|
+
"""Apply the serif, light-grid style used throughout the reference notebooks."""
|
|
19
|
+
ax.set_facecolor("white")
|
|
20
|
+
ax.grid(
|
|
21
|
+
True,
|
|
22
|
+
axis=grid_axis,
|
|
23
|
+
color="#cbd5df",
|
|
24
|
+
alpha=0.55,
|
|
25
|
+
linewidth=0.65,
|
|
26
|
+
)
|
|
27
|
+
ax.set_axisbelow(True)
|
|
28
|
+
ax.spines["top"].set_visible(False)
|
|
29
|
+
ax.spines["right"].set_visible(False)
|
|
30
|
+
ax.spines["left"].set_color("#aab7c4")
|
|
31
|
+
ax.spines["bottom"].set_color("#aab7c4")
|
|
32
|
+
ax.tick_params(colors="#34495e")
|
|
33
|
+
ax.xaxis.label.set_fontfamily("serif")
|
|
34
|
+
ax.yaxis.label.set_fontfamily("serif")
|
|
35
|
+
ax.title.set_fontfamily("serif")
|
moeforecast/analysis.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Exploratory analysis aimed at configuring the model.
|
|
3
|
+
|
|
4
|
+
``SeriesAnalyzer`` suggests seasonal periods and lags from the autocorrelation
|
|
5
|
+
structure; ``feature_relevance`` ranks candidate features. Both only make
|
|
6
|
+
recommendations: the user decides what goes into ``FeatureBuilder``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from sklearn.feature_selection import mutual_info_regression
|
|
16
|
+
from sklearn.inspection import permutation_importance
|
|
17
|
+
from statsmodels.tsa.stattools import acf, pacf
|
|
18
|
+
|
|
19
|
+
from moeforecast._plotting import PALETTE, style_axis
|
|
20
|
+
from moeforecast.data import step_of
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SeriesAnalyzer:
|
|
24
|
+
"""
|
|
25
|
+
Autocorrelation-based recommendations for seasonal periods and lags.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
max_lag : int, optional
|
|
30
|
+
Largest lag analysed. Defaults to two weeks of steps for sub-daily
|
|
31
|
+
data and 400 steps otherwise.
|
|
32
|
+
n_periods : int
|
|
33
|
+
Maximum number of seasonal periods to suggest.
|
|
34
|
+
n_lags : int
|
|
35
|
+
Maximum number of lags to suggest from the partial autocorrelation.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, max_lag: int | None = None, n_periods: int = 3, n_lags: int = 8):
|
|
39
|
+
self.max_lag = max_lag
|
|
40
|
+
self.n_periods = n_periods
|
|
41
|
+
self.n_lags = n_lags
|
|
42
|
+
|
|
43
|
+
def fit(self, y: pd.Series):
|
|
44
|
+
values = y.interpolate(limit_area="inside").dropna()
|
|
45
|
+
step = step_of(y.index.freqstr)
|
|
46
|
+
steps_per_day = pd.Timedelta("1D") / step
|
|
47
|
+
default = int(14 * steps_per_day) if steps_per_day > 1 else 400
|
|
48
|
+
self.max_lag_ = min(self.max_lag or default, len(values) // 3)
|
|
49
|
+
self.y_ = y
|
|
50
|
+
self.acf_ = pd.Series(acf(values, nlags=self.max_lag_, fft=True), name="acf")
|
|
51
|
+
# "ywm" (Yule-Walker with the biased autocovariance) keeps |PACF| <= 1 at long lags
|
|
52
|
+
self.pacf_ = pd.Series(
|
|
53
|
+
pacf(values, nlags=min(self.max_lag_, len(values) // 2 - 1), method="ywm"), name="pacf"
|
|
54
|
+
)
|
|
55
|
+
self.confidence_bound_ = 1.96 / np.sqrt(len(values))
|
|
56
|
+
|
|
57
|
+
# Seasonal periods: local maxima of the ACF, strongest first
|
|
58
|
+
a = self.acf_.to_numpy()
|
|
59
|
+
peaks = [k for k in range(2, len(a) - 1) if a[k] > a[k - 1] and a[k] >= a[k + 1] and a[k] > 0]
|
|
60
|
+
peaks = sorted(peaks, key=lambda k: a[k], reverse=True)
|
|
61
|
+
periods: list[int] = []
|
|
62
|
+
for k in peaks: # skip near-duplicates and multiples of a stronger period
|
|
63
|
+
if all(abs(k - p) > 2 and k % p not in (0, 1, p - 1) for p in periods):
|
|
64
|
+
periods.append(k)
|
|
65
|
+
if len(periods) == self.n_periods:
|
|
66
|
+
break
|
|
67
|
+
self.suggested_periods_ = sorted(periods)
|
|
68
|
+
|
|
69
|
+
# Lags: strongest significant partial autocorrelations, plus each period and its neighbours
|
|
70
|
+
p = self.pacf_.iloc[1:]
|
|
71
|
+
significant = p[p.abs() > self.confidence_bound_].abs().sort_values(ascending=False)
|
|
72
|
+
lags = set(significant.index[: self.n_lags].astype(int))
|
|
73
|
+
for period in self.suggested_periods_:
|
|
74
|
+
lags.update({period - 1, period, period + 1})
|
|
75
|
+
self.suggested_lags_ = sorted(lag for lag in lags if 1 <= lag <= self.max_lag_)
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
def summary(self) -> str:
|
|
79
|
+
return (
|
|
80
|
+
f"Suggested seasonal periods (steps): {self.suggested_periods_}\n"
|
|
81
|
+
f"Suggested lags (steps): {self.suggested_lags_}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# ----------------------------------------------------------------- plotting
|
|
85
|
+
def plot_acf_pacf(self, axes=None, max_lag: int = 30):
|
|
86
|
+
"""Plot at most ``max_lag`` ACF/PACF steps (30 by default)."""
|
|
87
|
+
import matplotlib.pyplot as plt
|
|
88
|
+
|
|
89
|
+
if max_lag < 1:
|
|
90
|
+
raise ValueError("max_lag must be a positive integer.")
|
|
91
|
+
if axes is None:
|
|
92
|
+
_, axes = plt.subplots(2, 1, figsize=(12, 6), sharex=True, layout="constrained")
|
|
93
|
+
for ax, series, title in (
|
|
94
|
+
(axes[0], self.acf_, "Autocorrelation (ACF)"),
|
|
95
|
+
(axes[1], self.pacf_, "Partial autocorrelation (PACF)"),
|
|
96
|
+
):
|
|
97
|
+
visible = series.iloc[: max_lag + 1]
|
|
98
|
+
ax.axhspan(
|
|
99
|
+
-self.confidence_bound_,
|
|
100
|
+
self.confidence_bound_,
|
|
101
|
+
color=PALETTE[2],
|
|
102
|
+
alpha=0.22,
|
|
103
|
+
label="95% bound",
|
|
104
|
+
)
|
|
105
|
+
ax.vlines(visible.index, 0, visible.to_numpy(), color=PALETTE[0], linewidth=1.2)
|
|
106
|
+
ax.scatter(visible.index, visible.to_numpy(), color=PALETTE[0], s=15, zorder=3)
|
|
107
|
+
ax.axhline(0, color="#404040", linewidth=0.8)
|
|
108
|
+
ax.set_xlim(0, max_lag)
|
|
109
|
+
ax.set_ylabel("Correlation")
|
|
110
|
+
ax.set_title(title, loc="left", fontweight="bold")
|
|
111
|
+
style_axis(ax)
|
|
112
|
+
for period in self.suggested_periods_:
|
|
113
|
+
if period <= max_lag:
|
|
114
|
+
axes[0].axvline(period, color="#b85c5c", linestyle="--", linewidth=1.2)
|
|
115
|
+
axes[1].set_xlabel("Lag (steps)")
|
|
116
|
+
axes[0].set_xlabel("Lag (steps)")
|
|
117
|
+
axes[0].legend(frameon=False, loc="upper right")
|
|
118
|
+
return axes
|
|
119
|
+
|
|
120
|
+
def plot_seasonal_profiles(self, axes=None):
|
|
121
|
+
"""Distribution of the series by hour of day, day of week and month."""
|
|
122
|
+
import matplotlib.pyplot as plt
|
|
123
|
+
|
|
124
|
+
y = self.y_.dropna()
|
|
125
|
+
groups = {
|
|
126
|
+
"Hour of day": (y.index.hour, [f"{h:02d}" for h in range(24)]),
|
|
127
|
+
"Day of week": (y.index.dayofweek, ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]),
|
|
128
|
+
"Month": (
|
|
129
|
+
y.index.month - 1,
|
|
130
|
+
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
|
131
|
+
),
|
|
132
|
+
}
|
|
133
|
+
if pd.Timedelta("1D") / step_of(self.y_.index.freqstr) <= 1:
|
|
134
|
+
groups.pop("Hour of day")
|
|
135
|
+
if axes is None:
|
|
136
|
+
_, axes = plt.subplots(1, len(groups), figsize=(5.5 * len(groups), 4), layout="constrained")
|
|
137
|
+
for ax, (title, (key, tick_labels)) in zip(np.atleast_1d(axes), groups.items(), strict=True):
|
|
138
|
+
frame = pd.DataFrame({"y": y.to_numpy(), "g": key}, index=y.index)
|
|
139
|
+
present = sorted(frame["g"].unique())
|
|
140
|
+
box = ax.boxplot(
|
|
141
|
+
[frame.loc[frame["g"] == group, "y"].to_numpy() for group in present],
|
|
142
|
+
patch_artist=True,
|
|
143
|
+
showfliers=False,
|
|
144
|
+
medianprops={"color": "#17324d", "linewidth": 1.8},
|
|
145
|
+
whiskerprops={"color": "#566573"},
|
|
146
|
+
capprops={"color": "#566573"},
|
|
147
|
+
)
|
|
148
|
+
for i, patch in enumerate(box["boxes"]):
|
|
149
|
+
patch.set_facecolor(PALETTE[i % len(PALETTE)])
|
|
150
|
+
patch.set_alpha(0.8)
|
|
151
|
+
medians = frame.groupby("g", sort=True)["y"].median().reindex(present)
|
|
152
|
+
ax.plot(
|
|
153
|
+
range(1, len(present) + 1),
|
|
154
|
+
medians.to_numpy(),
|
|
155
|
+
color="#c44536",
|
|
156
|
+
marker="o",
|
|
157
|
+
linewidth=2,
|
|
158
|
+
markersize=4,
|
|
159
|
+
label="Group median",
|
|
160
|
+
zorder=4,
|
|
161
|
+
)
|
|
162
|
+
ax.set_xticks(range(1, len(present) + 1), [tick_labels[i] for i in present])
|
|
163
|
+
ax.set_title(title, loc="left", fontweight="bold")
|
|
164
|
+
ax.set_xlabel("")
|
|
165
|
+
ax.set_ylabel(str(y.name or "Value"))
|
|
166
|
+
style_axis(ax, grid_axis="y")
|
|
167
|
+
ax.legend(frameon=False)
|
|
168
|
+
return axes
|
|
169
|
+
|
|
170
|
+
def plot_decomposition(self, periods: Sequence[int] | None = None):
|
|
171
|
+
"""Plot an MSTL decomposition as aligned, clearly labelled components.
|
|
172
|
+
|
|
173
|
+
A separate panel is drawn for each seasonal period. The plot is built
|
|
174
|
+
explicitly rather than using ``MSTL.plot()`` so component labels and
|
|
175
|
+
colors do not depend on statsmodels' internal axes ordering.
|
|
176
|
+
"""
|
|
177
|
+
import matplotlib.dates as mdates
|
|
178
|
+
import matplotlib.pyplot as plt
|
|
179
|
+
from statsmodels.tsa.seasonal import MSTL
|
|
180
|
+
|
|
181
|
+
periods = list(self.suggested_periods_[:2] if periods is None else periods)
|
|
182
|
+
y = self.y_.interpolate(limit_area="inside").dropna()
|
|
183
|
+
if not periods:
|
|
184
|
+
raise ValueError("At least one seasonal period is required for decomposition.")
|
|
185
|
+
if any(not isinstance(period, (int, np.integer)) or period < 2 for period in periods):
|
|
186
|
+
raise ValueError("Seasonal periods must be integers greater than or equal to 2.")
|
|
187
|
+
if len(set(periods)) != len(periods):
|
|
188
|
+
raise ValueError("Seasonal periods must be unique.")
|
|
189
|
+
if len(y) < 2 * max(periods):
|
|
190
|
+
raise ValueError("MSTL needs at least two complete cycles of the largest seasonal period.")
|
|
191
|
+
|
|
192
|
+
result = MSTL(y, periods=periods).fit()
|
|
193
|
+
components = [("Observed", y, "#315b7d"), ("Trend", result.trend, "#d17a22")]
|
|
194
|
+
seasonal = result.seasonal
|
|
195
|
+
if isinstance(seasonal, pd.Series):
|
|
196
|
+
seasonal = seasonal.to_frame(name=str(periods[0]))
|
|
197
|
+
for index, period in enumerate(periods):
|
|
198
|
+
column = seasonal.columns[index]
|
|
199
|
+
components.append((f"Seasonal - {period} steps", seasonal[column], PALETTE[index % len(PALETTE)]))
|
|
200
|
+
components.append(("Residual", result.resid, "#a34d4d"))
|
|
201
|
+
|
|
202
|
+
fig, axes = plt.subplots(
|
|
203
|
+
len(components), 1, figsize=(13, 2.35 * len(components)), sharex=True, layout="constrained"
|
|
204
|
+
)
|
|
205
|
+
axes = np.atleast_1d(axes)
|
|
206
|
+
for ax, (label, values, color) in zip(axes, components, strict=True):
|
|
207
|
+
ax.plot(values.index, values.to_numpy(), color=color, linewidth=1.15)
|
|
208
|
+
ax.set_title(label, loc="left", fontsize=10, fontweight="bold", pad=5)
|
|
209
|
+
ax.set_ylabel(str(y.name or "Value") if label == "Observed" else "")
|
|
210
|
+
style_axis(ax, grid_axis="y")
|
|
211
|
+
locator = mdates.AutoDateLocator(minticks=4, maxticks=9)
|
|
212
|
+
axes[-1].xaxis.set_major_locator(locator)
|
|
213
|
+
axes[-1].xaxis.set_major_formatter(mdates.ConciseDateFormatter(locator))
|
|
214
|
+
axes[-1].set_xlabel("Date")
|
|
215
|
+
fig.suptitle(f"{y.name or 'Series'} - MSTL decomposition", x=0.01, ha="left", fontweight="bold")
|
|
216
|
+
return fig
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def feature_relevance(
|
|
220
|
+
X: pd.DataFrame,
|
|
221
|
+
y: pd.Series,
|
|
222
|
+
method: str = "mutual_info",
|
|
223
|
+
estimator=None,
|
|
224
|
+
test_fraction: float = 0.2,
|
|
225
|
+
random_state: int = 0,
|
|
226
|
+
) -> pd.DataFrame:
|
|
227
|
+
"""
|
|
228
|
+
Rank candidate features by their relevance for the target.
|
|
229
|
+
|
|
230
|
+
Parameters
|
|
231
|
+
----------
|
|
232
|
+
method : {"mutual_info", "permutation"}
|
|
233
|
+
"mutual_info" measures non-linear dependence of each feature with the
|
|
234
|
+
target on its own. "permutation" fits ``estimator`` on the first part of
|
|
235
|
+
the data and measures the loss of R² on the last ``test_fraction`` when
|
|
236
|
+
each feature is shuffled; it accounts for redundancy between features.
|
|
237
|
+
estimator : regressor, optional
|
|
238
|
+
Model for the permutation method (default: HistGradientBoostingRegressor).
|
|
239
|
+
|
|
240
|
+
Returns
|
|
241
|
+
-------
|
|
242
|
+
DataFrame with columns ``feature`` and ``importance``, sorted descending.
|
|
243
|
+
"""
|
|
244
|
+
mask = X.notna().all(axis=1).to_numpy() & y.notna().to_numpy()
|
|
245
|
+
Xv, yv = X[mask], y[mask]
|
|
246
|
+
if method == "mutual_info":
|
|
247
|
+
scores = mutual_info_regression(Xv, yv, random_state=random_state)
|
|
248
|
+
out = pd.DataFrame({"feature": X.columns, "importance": scores})
|
|
249
|
+
elif method == "permutation":
|
|
250
|
+
from sklearn.ensemble import HistGradientBoostingRegressor
|
|
251
|
+
|
|
252
|
+
split = int(len(Xv) * (1 - test_fraction))
|
|
253
|
+
model = estimator or HistGradientBoostingRegressor(random_state=random_state)
|
|
254
|
+
model.fit(Xv.iloc[:split], yv.iloc[:split])
|
|
255
|
+
result = permutation_importance(
|
|
256
|
+
model, Xv.iloc[split:], yv.iloc[split:], n_repeats=5, random_state=random_state
|
|
257
|
+
)
|
|
258
|
+
out = pd.DataFrame(
|
|
259
|
+
{
|
|
260
|
+
"feature": X.columns,
|
|
261
|
+
"importance": result.importances_mean,
|
|
262
|
+
"importance_std": result.importances_std,
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
else:
|
|
266
|
+
raise ValueError("method must be 'mutual_info' or 'permutation'")
|
|
267
|
+
return out.sort_values("importance", ascending=False, ignore_index=True)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Classifiers that predict the regime (cluster) of each time step from its features."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from sklearn.base import BaseEstimator, ClassifierMixin, clone
|
|
10
|
+
from sklearn.ensemble import HistGradientBoostingClassifier, RandomForestClassifier
|
|
11
|
+
from sklearn.linear_model import LogisticRegression
|
|
12
|
+
from sklearn.metrics import accuracy_score, f1_score
|
|
13
|
+
from sklearn.model_selection import cross_validate
|
|
14
|
+
from sklearn.pipeline import make_pipeline
|
|
15
|
+
from sklearn.preprocessing import StandardScaler
|
|
16
|
+
|
|
17
|
+
from moeforecast.tuning import classification_time_series_splits, default_search_spaces, search_estimator
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def default_classifiers(
|
|
21
|
+
random_state: int = 0,
|
|
22
|
+
*,
|
|
23
|
+
exclude: Sequence[str] = (),
|
|
24
|
+
extra: Mapping[str, BaseEstimator] | None = None,
|
|
25
|
+
) -> dict[str, BaseEstimator]:
|
|
26
|
+
"""Return standard probability-capable classifiers with add/remove options."""
|
|
27
|
+
classifiers = {
|
|
28
|
+
"logistic": make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000)),
|
|
29
|
+
"random_forest": RandomForestClassifier(
|
|
30
|
+
n_estimators=300, min_samples_leaf=5, n_jobs=-1, random_state=random_state
|
|
31
|
+
),
|
|
32
|
+
"hist_gbm": HistGradientBoostingClassifier(random_state=random_state),
|
|
33
|
+
}
|
|
34
|
+
classifiers.update(extra or {})
|
|
35
|
+
for name in exclude:
|
|
36
|
+
classifiers.pop(name, None)
|
|
37
|
+
return classifiers
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RegimeClassifier(ClassifierMixin, BaseEstimator):
|
|
41
|
+
"""
|
|
42
|
+
Predict regime probabilities for the gating network.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
estimator : classifier, optional
|
|
47
|
+
Any scikit-learn classifier with ``predict_proba``. Defaults to
|
|
48
|
+
``HistGradientBoostingClassifier``.
|
|
49
|
+
features : sequence of str, optional
|
|
50
|
+
Feature columns to use. Defaults to all columns.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
estimator: BaseEstimator | None = None,
|
|
56
|
+
features: Sequence[str] | None = None,
|
|
57
|
+
search: str | None = None,
|
|
58
|
+
search_space: Mapping[str, object] | None = None,
|
|
59
|
+
n_iter: int = 20,
|
|
60
|
+
n_splits: int = 3,
|
|
61
|
+
random_state: int = 0,
|
|
62
|
+
):
|
|
63
|
+
self.estimator = estimator
|
|
64
|
+
self.features = features
|
|
65
|
+
self.search = search
|
|
66
|
+
self.search_space = search_space
|
|
67
|
+
self.n_iter = n_iter
|
|
68
|
+
self.n_splits = n_splits
|
|
69
|
+
self.random_state = random_state
|
|
70
|
+
|
|
71
|
+
def fit(self, X: pd.DataFrame, y: pd.Series):
|
|
72
|
+
base = self.estimator if self.estimator is not None else HistGradientBoostingClassifier()
|
|
73
|
+
if not hasattr(base, "predict_proba"):
|
|
74
|
+
raise TypeError("The regime classifier must implement predict_proba.")
|
|
75
|
+
if self.search not in {None, "grid", "random"}:
|
|
76
|
+
raise ValueError("search must be None, 'grid', or 'random'.")
|
|
77
|
+
self.features_ = list(self.features) if self.features is not None else list(X.columns)
|
|
78
|
+
mask = y.notna().to_numpy() & X[self.features_].notna().all(axis=1).to_numpy()
|
|
79
|
+
y_fit = y[mask].astype(int)
|
|
80
|
+
X_fit = X.loc[mask, self.features_]
|
|
81
|
+
if self.search is None:
|
|
82
|
+
self.estimator_ = clone(base).fit(X_fit, y_fit)
|
|
83
|
+
self.search_ = None
|
|
84
|
+
else:
|
|
85
|
+
name = _classifier_search_name(base)
|
|
86
|
+
space = self.search_space
|
|
87
|
+
if space is None:
|
|
88
|
+
try:
|
|
89
|
+
space = default_search_spaces()[name]
|
|
90
|
+
except KeyError as exc:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"No default search space for {type(base).__name__}; pass search_space explicitly."
|
|
93
|
+
) from exc
|
|
94
|
+
self.search_ = search_estimator(
|
|
95
|
+
clone(base),
|
|
96
|
+
space,
|
|
97
|
+
X_fit,
|
|
98
|
+
y_fit,
|
|
99
|
+
strategy=self.search,
|
|
100
|
+
n_splits=self.n_splits,
|
|
101
|
+
n_iter=self.n_iter,
|
|
102
|
+
scoring="f1_macro",
|
|
103
|
+
random_state=self.random_state,
|
|
104
|
+
cv=classification_time_series_splits(y_fit, self.n_splits),
|
|
105
|
+
)
|
|
106
|
+
self.estimator_ = self.search_.best_estimator_
|
|
107
|
+
self.classes_ = np.asarray(self.estimator_.classes_)
|
|
108
|
+
self.n_samples_fit_ = int(mask.sum())
|
|
109
|
+
self.class_counts_ = y_fit.value_counts().sort_index().reindex(self.classes_, fill_value=0)
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def predict_proba(self, X: pd.DataFrame) -> np.ndarray:
|
|
113
|
+
return self.estimator_.predict_proba(X[self.features_])
|
|
114
|
+
|
|
115
|
+
def predict(self, X: pd.DataFrame) -> np.ndarray:
|
|
116
|
+
return self.classes_[np.argmax(self.predict_proba(X), axis=1)]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def compare_classifiers(
|
|
120
|
+
X: pd.DataFrame,
|
|
121
|
+
y: pd.Series,
|
|
122
|
+
candidates: Mapping[str, BaseEstimator] | None = None,
|
|
123
|
+
feature_sets: Mapping[str, Sequence[str]] | None = None,
|
|
124
|
+
n_splits: int = 5,
|
|
125
|
+
gap: int = 0,
|
|
126
|
+
candidate_mode: str = "replace",
|
|
127
|
+
search: str | None = None,
|
|
128
|
+
search_spaces: Mapping[str, Mapping[str, object]] | None = None,
|
|
129
|
+
n_iter: int = 20,
|
|
130
|
+
random_state: int = 0,
|
|
131
|
+
) -> pd.DataFrame:
|
|
132
|
+
"""
|
|
133
|
+
Cross-validate regime classifiers with forward-chaining (time-ordered) splits.
|
|
134
|
+
|
|
135
|
+
Folds never shuffle time, so hours of the same day or week cannot appear in
|
|
136
|
+
both the training and the validation fold.
|
|
137
|
+
|
|
138
|
+
Returns a DataFrame with mean and std of accuracy and macro F1 per
|
|
139
|
+
(feature set, classifier), sorted by macro F1. When ``search`` is enabled,
|
|
140
|
+
HPO is nested inside each outer validation fold to avoid scoring a model on
|
|
141
|
+
the same folds used to choose its hyperparameters.
|
|
142
|
+
"""
|
|
143
|
+
if candidate_mode not in {"replace", "extend"}:
|
|
144
|
+
raise ValueError("candidate_mode must be 'replace' or 'extend'.")
|
|
145
|
+
if candidates is None:
|
|
146
|
+
candidates = default_classifiers()
|
|
147
|
+
elif candidate_mode == "extend":
|
|
148
|
+
candidates = {**default_classifiers(), **candidates}
|
|
149
|
+
if not candidates:
|
|
150
|
+
raise ValueError("At least one classifier candidate is required.")
|
|
151
|
+
if search not in {None, "grid", "random"}:
|
|
152
|
+
raise ValueError("search must be None, 'grid', or 'random'.")
|
|
153
|
+
spaces = default_search_spaces()
|
|
154
|
+
spaces.update(search_spaces or {})
|
|
155
|
+
feature_sets = feature_sets or {"all": list(X.columns)}
|
|
156
|
+
rows = []
|
|
157
|
+
for set_name, cols in feature_sets.items():
|
|
158
|
+
mask = y.notna().to_numpy() & X[list(cols)].notna().all(axis=1).to_numpy()
|
|
159
|
+
Xs, ys = X.loc[mask, list(cols)], y[mask].astype(int)
|
|
160
|
+
cv = classification_time_series_splits(ys, n_splits=n_splits, gap=gap)
|
|
161
|
+
for clf_name, clf in candidates.items():
|
|
162
|
+
params = []
|
|
163
|
+
if search is not None:
|
|
164
|
+
space = spaces.get(clf_name)
|
|
165
|
+
if space is None:
|
|
166
|
+
inferred = _classifier_search_name(clf)
|
|
167
|
+
space = spaces.get(inferred)
|
|
168
|
+
if space is None:
|
|
169
|
+
raise ValueError(f"No search space configured for classifier '{clf_name}'.")
|
|
170
|
+
accuracy_scores, f1_scores = [], []
|
|
171
|
+
for fold, (train_idx, test_idx) in enumerate(cv):
|
|
172
|
+
inner_splits = min(n_splits, len(train_idx) - 1)
|
|
173
|
+
if inner_splits < 2:
|
|
174
|
+
raise ValueError("Not enough training samples for nested classifier HPO.")
|
|
175
|
+
inner_cv = classification_time_series_splits(ys.iloc[train_idx], n_splits=inner_splits)
|
|
176
|
+
optimized = search_estimator(
|
|
177
|
+
clone(clf),
|
|
178
|
+
space,
|
|
179
|
+
Xs.iloc[train_idx],
|
|
180
|
+
ys.iloc[train_idx],
|
|
181
|
+
strategy=search,
|
|
182
|
+
n_splits=inner_splits,
|
|
183
|
+
n_iter=n_iter,
|
|
184
|
+
scoring="f1_macro",
|
|
185
|
+
random_state=random_state + fold,
|
|
186
|
+
cv=inner_cv,
|
|
187
|
+
)
|
|
188
|
+
prediction = optimized.best_estimator_.predict(Xs.iloc[test_idx])
|
|
189
|
+
accuracy_scores.append(accuracy_score(ys.iloc[test_idx], prediction))
|
|
190
|
+
f1_scores.append(f1_score(ys.iloc[test_idx], prediction, average="macro"))
|
|
191
|
+
params.append(optimized.best_params_)
|
|
192
|
+
scores = {
|
|
193
|
+
"test_accuracy": np.asarray(accuracy_scores),
|
|
194
|
+
"test_f1_macro": np.asarray(f1_scores),
|
|
195
|
+
}
|
|
196
|
+
else:
|
|
197
|
+
scores = cross_validate(clone(clf), Xs, ys, cv=cv, scoring=("accuracy", "f1_macro"))
|
|
198
|
+
rows.append(
|
|
199
|
+
{
|
|
200
|
+
"feature_set": set_name,
|
|
201
|
+
"classifier": clf_name,
|
|
202
|
+
"n_features": len(cols),
|
|
203
|
+
"n_folds": len(cv),
|
|
204
|
+
"accuracy_mean": scores["test_accuracy"].mean(),
|
|
205
|
+
"accuracy_std": scores["test_accuracy"].std(),
|
|
206
|
+
"f1_macro_mean": scores["test_f1_macro"].mean(),
|
|
207
|
+
"f1_macro_std": scores["test_f1_macro"].std(),
|
|
208
|
+
"best_params_by_fold": params,
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
return pd.DataFrame(rows).sort_values("f1_macro_mean", ascending=False, ignore_index=True)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _classifier_search_name(estimator: BaseEstimator) -> str:
|
|
215
|
+
"""Map built-in classifier types to the public default search-space keys."""
|
|
216
|
+
if isinstance(estimator, RandomForestClassifier):
|
|
217
|
+
return "random_forest_classifier"
|
|
218
|
+
if isinstance(estimator, HistGradientBoostingClassifier):
|
|
219
|
+
return "hist_gbm_classifier"
|
|
220
|
+
if isinstance(estimator, LogisticRegression):
|
|
221
|
+
return "logistic_estimator"
|
|
222
|
+
if hasattr(estimator, "named_steps"):
|
|
223
|
+
final = list(estimator.named_steps.values())[-1]
|
|
224
|
+
if isinstance(final, LogisticRegression):
|
|
225
|
+
return "logistic"
|
|
226
|
+
return _classifier_search_name(final)
|
|
227
|
+
return ""
|