tuning_metadata = {
"requested": bool(RUN_TUNING),
"performed": False,
"cv": "3-fold expanding-window TimeSeriesSplit",
"scoring": "average_precision",
}
if RUN_TUNING:
tuning_estimators = {
"Logistic regression": (
LogisticRegression(
class_weight="balanced", solver="saga", max_iter=300,
tol=1e-3, random_state=RANDOM_STATE,
),
{"C": np.logspace(-2, 1, 8), "l1_ratio": [0.0, 1.0]},
),
"Decision tree": (
DecisionTreeClassifier(class_weight="balanced", random_state=RANDOM_STATE),
{
"max_depth": [5, 8, 12, 16, None],
"min_samples_leaf": [10, 25, 50, 100, 250],
"criterion": ["gini", "entropy"],
},
),
"Random forest": (
RandomForestClassifier(
class_weight="balanced_subsample", n_jobs=N_JOBS,
random_state=RANDOM_STATE,
),
{
"n_estimators": [200, 350, 500],
"max_depth": [10, 16, 24, None],
"min_samples_leaf": [2, 5, 10, 25],
"max_features": ["sqrt", 0.3, 0.5],
},
),
}
if "XGBoost" in fitted_models:
negative, positive = np.bincount(y_fit)
tuning_estimators["XGBoost"] = (
XGBClassifier(
objective="binary:logistic", eval_metric="aucpr",
scale_pos_weight=negative / positive, tree_method="hist",
n_jobs=N_JOBS, random_state=RANDOM_STATE,
),
{
"n_estimators": [250, 400, 550],
"max_depth": [4, 6, 8],
"learning_rate": [0.03, 0.05, 0.08],
"min_child_weight": [3, 5, 10],
"subsample": [0.70, 0.85, 1.0],
"colsample_bytree": [0.70, 0.85, 1.0],
"reg_lambda": [1.0, 5.0, 10.0],
},
)
if champion_name not in tuning_estimators:
print(f"No tuning grid specified for {champion_name}; keep the fitted champion.")
else:
estimator, parameter_distributions = tuning_estimators[champion_name]
cv = TimeSeriesSplit(n_splits=3)
search = RandomizedSearchCV(
estimator=estimator,
param_distributions=parameter_distributions,
n_iter=8 if not QUICK_RUN else 3,
scoring="average_precision",
cv=cv,
n_jobs=N_JOBS,
verbose=2,
random_state=RANDOM_STATE,
refit=True,
return_train_score=True,
)
search.fit(X_fit, y_fit)
tuned_risk_score = positive_class_score(search.best_estimator_, X_validation)
tuned_metrics = metric_row(y_validation, tuned_risk_score, threshold=0.50)
tuning_metadata.update({
"performed": True,
"best_cv_average_precision": float(search.best_score_),
"best_parameters": search.best_params_,
})
print(f"Best time-aware CV AP: {search.best_score_:.4f}")
print("Best parameters:", search.best_params_)
display(pd.DataFrame([tuned_metrics]).style.format(precision=4))
current_ap = average_precision_score(
y_validation, champion_validation_risk_score
)
if tuned_metrics["Average precision"] > current_ap:
original_name = champion_name
champion_model = search.best_estimator_
champion_validation_risk_score = tuned_risk_score
champion_name = f"{original_name} (tuned)"
thresholds = threshold_table(y_validation, champion_validation_risk_score)
f2_threshold = float(thresholds.loc[thresholds["F2"].idxmax(), "Threshold"])
tuned_row = {**tuned_metrics, "Fit seconds": np.nan}
validation_results.loc[champion_name] = tuned_row
validation_results = validation_results.sort_values(
"Average precision", ascending=False
)
tuning_metadata["selected_after_external_validation"] = True
print("The tuned model becomes the champion; threshold recomputed on validation.")
else:
tuning_metadata["selected_after_external_validation"] = False
print("Tuning did not improve validation AP; retaining the original champion.")
else:
print("Tuning skipped.")