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.
@@ -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
+ ]
@@ -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")
@@ -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 ""