@@ -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+
865930def _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
0 commit comments