Skip to content

Commit 181bb43

Browse files
committed
Add fold-safe early stopping for XGBoost ranking
1 parent ddc13de commit 181bb43

8 files changed

Lines changed: 210 additions & 5 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,12 @@ model selection; they do not by themselves prove that a model will perform bette
271271
this expensive profile for feature/schema changes and periodic audits. Ordinary daily runs can use
272272
the validated focused ranges with smaller trial budgets; every run retains purged temporal folds.
273273

274+
XGBoost trials use fold-safe early stopping. The final 21 dates inside each fold's training block
275+
form an inner stopping window; the outer validation fold remains untouched for model scoring.
276+
Training stops after 50 rounds without improvement, and the final all-history refit uses the median
277+
effective tree count learned across the temporal folds. The tuning CSV records requested trees,
278+
best iteration, and effective trees so runtime savings and boundary hits remain auditable.
279+
274280
The [live-forward operating guide](docs/LIVE_FORWARD_MONITOR.md) documents the close-data guard,
275281
frozen-snapshot contract, dashboard timestamps, model-overlap metrics, and generated artifacts.
276282
The [GitHub Pages deployment guide](docs/GITHUB_PAGES_DEPLOYMENT.md) documents the public-data

configs/experiments/sparse_rank_v3_fnspid_2018_2023.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ residual_rank_v2:
4646
xgb_reg_lambda_max: 20.0
4747
xgb_gamma_min: 0.0
4848
xgb_gamma_max: 1.5
49+
xgb_early_stopping_rounds: 50
50+
xgb_early_stopping_validation_days: 21
4951
nested_validation:
5052
outer_min_train_weeks: 104
5153
outer_test_weeks: 26

docs/LIVE_FORWARD_MONITOR.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ may use fewer trials inside the same validated ranges; repeat the five-fold audi
6161
feature/data change or on a predeclared schedule. Any future warm-start or pruning optimization must
6262
preserve the same temporal validation and frozen-snapshot rules.
6363

64+
XGBoost also uses fold-safe early stopping:
65+
66+
1. the last 21 dates of each fold's training block are reserved as an inner stopping window;
67+
2. the ranker stops after 50 rounds without validation-NDCG improvement;
68+
3. the outer temporal validation block remains untouched for trial scoring;
69+
4. the final all-data refit uses the median effective tree count learned across folds.
70+
71+
`model_selection.csv` records the requested estimator cap, best iteration, effective estimator
72+
count, and stopping-window settings for each fold. The aggregate row records the median and range
73+
of effective trees. Early stopping reduces unnecessary boosting rounds without using the live
74+
decision date or its future return.
75+
6476
## Point-in-time and leakage controls
6577

6678
- A portfolio is evaluated only after its holdings have been frozen.

docs/experiments/2026-07-24-sector-split-data-repair.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,13 @@ The full five-fold profile is appropriate after a feature/data change and for pe
115115
is too expensive to repeat unchanged every day. Routine daily runs should use smaller trial budgets
116116
inside the validated ranges; persisted Optuna studies, pruning, and cached fold matrices are the
117117
next compute improvements.
118+
119+
## Daily XGBoost early stopping
120+
121+
After the July 27 routine retrain still spent approximately one hour in the nonlinear stage,
122+
fold-safe early stopping was activated. Each fold now reserves the last 21 training dates for
123+
stopping only, uses 50-round patience, and keeps the outer validation period untouched for scoring.
124+
The final model is refit on all eligible training rows with the median best tree count from the
125+
folds. Requested and effective estimator counts are persisted in the tuning report. The next daily
126+
run must confirm the realized tree reduction and wall-clock saving; no performance or speed claim
127+
is made from the implementation alone.

src/experiments/live_forward_test.py

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,13 @@ def _select_and_fit_live_model(
753753
if not splits:
754754
raise ValueError("Not enough labeled dates for live model selection")
755755
exclude_prefixes = rank_cfg.get("features", {}).get("exclude_prefixes", [])
756+
live_tuning = rank_cfg.get("live_tuning", {})
757+
xgb_early_stopping_rounds = int(
758+
live_tuning.get("xgb_early_stopping_rounds", 0)
759+
)
760+
xgb_early_stopping_validation_days = int(
761+
live_tuning.get("xgb_early_stopping_validation_days", 21)
762+
)
756763
feature_choices = _candidate_feature_counts(rank_cfg)
757764
if None not in feature_choices:
758765
feature_choices.append(None)
@@ -788,10 +795,28 @@ def objective(trial: Any) -> float:
788795
cached_features,
789796
) in enumerate(fold_inputs):
790797
features = cached_features[feature_count]
791-
model = _make_live_model(model_name, model_params).fit(
792-
tuning_train[features],
793-
tuning_train["target"],
794-
)
798+
fit_train = tuning_train
799+
stopping_validation: pd.DataFrame | None = None
800+
fitted_params = dict(model_params)
801+
if model_name == "xgb_ranker" and xgb_early_stopping_rounds > 0:
802+
fit_train, stopping_validation = _split_xgb_early_stopping_window(
803+
tuning_train,
804+
validation_days=xgb_early_stopping_validation_days,
805+
)
806+
fitted_params["early_stopping_rounds"] = xgb_early_stopping_rounds
807+
model = _make_live_model(model_name, fitted_params)
808+
if stopping_validation is None:
809+
model.fit(
810+
fit_train[features],
811+
fit_train["target"],
812+
)
813+
else:
814+
model.fit(
815+
fit_train[features],
816+
fit_train["target"],
817+
stopping_validation[features],
818+
stopping_validation["target"],
819+
)
795820
scores = pd.Series(
796821
model.predict(validation[features]),
797822
index=validation.index,
@@ -813,6 +838,16 @@ def objective(trial: Any) -> float:
813838
"validation_start": validation_dates.min().strftime("%Y-%m-%d"),
814839
"validation_end": validation_dates.max().strftime("%Y-%m-%d"),
815840
})
841+
if model_name == "xgb_ranker":
842+
row.update({
843+
"requested_n_estimators": int(model_params["n_estimators"]),
844+
"best_iteration": model.best_iteration_,
845+
"effective_n_estimators": model.effective_n_estimators_,
846+
"early_stopping_rounds": xgb_early_stopping_rounds,
847+
"early_stopping_validation_days": (
848+
xgb_early_stopping_validation_days
849+
),
850+
})
816851
fold_rows.append(row)
817852
aggregate = _aggregate_trial_rows(fold_rows, trial.number)
818853
trial_rows.extend(fold_rows)
@@ -851,6 +886,11 @@ def objective(trial: Any) -> float:
851886
best_feature_count = None if pd.isna(best["feature_count"]) else int(best["feature_count"])
852887
final_features = _top_features(train, best_feature_count, exclude_prefixes)
853888
final_params = json.loads(best["model_params_json"])
889+
if str(best["model"]) == "xgb_ranker":
890+
effective_estimators = best.get("median_effective_n_estimators")
891+
if effective_estimators is not None and pd.notna(effective_estimators):
892+
final_params["n_estimators"] = max(1, int(round(effective_estimators)))
893+
final_params.pop("early_stopping_rounds", None)
854894
final_model = _make_live_model(str(best["model"]), final_params).fit(
855895
train[final_features],
856896
train["target"],
@@ -862,6 +902,31 @@ def objective(trial: Any) -> float:
862902
return final_model, str(best["model"]), final_params, final_features, table
863903

864904

905+
def _split_xgb_early_stopping_window(
906+
training: pd.DataFrame,
907+
*,
908+
validation_days: int,
909+
min_fit_days: int = 126,
910+
) -> tuple[pd.DataFrame, pd.DataFrame]:
911+
"""Reserve a trailing inner window without touching the outer validation fold."""
912+
dates = pd.DatetimeIndex(
913+
training.index.get_level_values("date").unique()
914+
).sort_values()
915+
stopping_days = max(1, int(validation_days))
916+
if len(dates) < min_fit_days + stopping_days:
917+
raise ValueError(
918+
"Not enough training dates for the XGBoost early-stopping window"
919+
)
920+
stopping_dates = dates[-stopping_days:]
921+
fit_dates = dates[:-stopping_days]
922+
date_index = training.index.get_level_values("date")
923+
fit = training.loc[date_index.isin(fit_dates)]
924+
stopping = training.loc[date_index.isin(stopping_dates)]
925+
if fit.empty or stopping.empty:
926+
raise ValueError("XGBoost early-stopping split produced an empty frame")
927+
return fit, stopping
928+
929+
865930
def _candidate_feature_counts(rank_cfg: dict[str, Any]) -> list[int | None]:
866931
raw = rank_cfg["models"].get("feature_counts", ["all"])
867932
counts: list[int | None] = []
@@ -1015,6 +1080,27 @@ def _aggregate_trial_rows(rows: list[dict[str, Any]], trial: int) -> dict[str, A
10151080
"validation_start": str(frame["validation_start"].min()),
10161081
"validation_end": str(frame["validation_end"].max()),
10171082
}
1083+
if first["model"] == "xgb_ranker" and "effective_n_estimators" in frame:
1084+
effective = pd.to_numeric(
1085+
frame["effective_n_estimators"],
1086+
errors="coerce",
1087+
).dropna()
1088+
best_iterations = pd.to_numeric(
1089+
frame["best_iteration"],
1090+
errors="coerce",
1091+
).dropna()
1092+
if not effective.empty:
1093+
aggregate.update({
1094+
"requested_n_estimators": int(first["requested_n_estimators"]),
1095+
"median_best_iteration": float(best_iterations.median()),
1096+
"median_effective_n_estimators": float(effective.median()),
1097+
"min_effective_n_estimators": int(effective.min()),
1098+
"max_effective_n_estimators": int(effective.max()),
1099+
"early_stopping_rounds": int(first["early_stopping_rounds"]),
1100+
"early_stopping_validation_days": int(
1101+
first["early_stopping_validation_days"]
1102+
),
1103+
})
10181104
return aggregate
10191105

10201106

src/models/rank_models.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ def __init__(self, **params: Any) -> None:
6363
self.params = params
6464
self.model: Any = None
6565
self.columns: list[str] = []
66+
self.best_iteration_: int | None = None
67+
self.effective_n_estimators_: int | None = None
6668

6769
def fit(
6870
self,
@@ -119,6 +121,17 @@ def fit(
119121
"eval_group": [_date_groups(validation.index)],
120122
})
121123
self.model.fit(x_numeric, y, **fit_kwargs)
124+
configured_estimators = int(defaults.get("n_estimators", 200))
125+
best_iteration = getattr(self.model, "best_iteration", None)
126+
if best_iteration is None:
127+
self.best_iteration_ = configured_estimators - 1
128+
self.effective_n_estimators_ = configured_estimators
129+
else:
130+
self.best_iteration_ = int(best_iteration)
131+
self.effective_n_estimators_ = min(
132+
configured_estimators,
133+
self.best_iteration_ + 1,
134+
)
122135
return self
123136

124137
def predict(self, x: pd.DataFrame) -> np.ndarray:

tests/test_double_ensemble_v10.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,13 @@ def test_xgb_ranker_supports_more_than_31_cross_sectional_grades() -> None:
5151
features = pd.DataFrame({"signal": values}, index=index)
5252
target = pd.Series(values, index=index)
5353
model = XGBRankModel(
54-
n_estimators=5, max_depth=1, tree_method="hist", n_jobs=1
54+
n_estimators=20,
55+
max_depth=1,
56+
tree_method="hist",
57+
n_jobs=1,
58+
early_stopping_rounds=3,
5559
).fit(features, target, features, target)
5660
assert np.unique(model.predict(features)).size > 1
61+
assert model.best_iteration_ is not None
62+
assert model.effective_n_estimators_ is not None
63+
assert 1 <= model.effective_n_estimators_ <= 20

tests/test_live_model_selection.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import pandas as pd
2+
3+
from src.experiments.live_forward_test import (
4+
_aggregate_trial_rows,
5+
_split_xgb_early_stopping_window,
6+
)
7+
8+
9+
def test_xgb_early_stopping_window_precedes_outer_validation() -> None:
10+
dates = pd.date_range("2024-01-02", periods=170, freq="B")
11+
index = pd.MultiIndex.from_product(
12+
[dates, ["A", "B"]],
13+
names=["date", "ticker"],
14+
)
15+
training = pd.DataFrame(
16+
{
17+
"signal": range(len(index)),
18+
"target": range(len(index)),
19+
},
20+
index=index,
21+
)
22+
23+
fit, stopping = _split_xgb_early_stopping_window(
24+
training,
25+
validation_days=21,
26+
)
27+
28+
fit_dates = pd.DatetimeIndex(fit.index.get_level_values("date").unique())
29+
stopping_dates = pd.DatetimeIndex(
30+
stopping.index.get_level_values("date").unique()
31+
)
32+
assert len(stopping_dates) == 21
33+
assert fit_dates.max() < stopping_dates.min()
34+
assert len(fit_dates) == 149
35+
36+
37+
def test_xgb_trial_aggregation_records_median_effective_tree_count() -> None:
38+
rows = []
39+
for fold, effective in enumerate([120, 160, 140, 180, 100]):
40+
rows.append(
41+
{
42+
"model": "xgb_ranker",
43+
"model_params_json": '{"n_estimators": 700}',
44+
"feature_count": 22,
45+
"resolved_feature_count": 22,
46+
"rank_ic": 0.01,
47+
"positive_ic_rate": 0.6,
48+
"top40_raw_forward_return": 0.002,
49+
"universe_raw_forward_return": 0.001,
50+
"top40_raw_excess": 0.001,
51+
"top40_target_mean": 0.01,
52+
"universe_target_mean": 0.0,
53+
"top40_target_excess": 0.01,
54+
"validation_start": f"2025-0{fold + 1}-01",
55+
"validation_end": f"2025-0{fold + 1}-28",
56+
"requested_n_estimators": 700,
57+
"best_iteration": effective - 1,
58+
"effective_n_estimators": effective,
59+
"early_stopping_rounds": 50,
60+
"early_stopping_validation_days": 21,
61+
}
62+
)
63+
64+
aggregate = _aggregate_trial_rows(rows, trial=3)
65+
66+
assert aggregate["median_effective_n_estimators"] == 140.0
67+
assert aggregate["median_best_iteration"] == 139.0
68+
assert aggregate["min_effective_n_estimators"] == 100
69+
assert aggregate["max_effective_n_estimators"] == 180

0 commit comments

Comments
 (0)