serverlex 1.0.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.
Evaluation.py ADDED
@@ -0,0 +1,191 @@
1
+ import logging
2
+ import numpy as np
3
+ import pandas as pd
4
+ import matplotlib.pyplot as plt
5
+
6
+ from sklearn.metrics import (
7
+ confusion_matrix,
8
+ classification_report,
9
+ roc_curve,
10
+ auc,
11
+ precision_recall_curve,
12
+ r2_score,
13
+ mean_squared_error,
14
+ mean_absolute_error,
15
+ mean_absolute_percentage_error
16
+ )
17
+
18
+ logger=logging.getLogger("Evaluator")
19
+ if not logger.handlers:
20
+ _h=logging.StreamHandler()
21
+ _h.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"))
22
+ logger.addHandler(_h)
23
+ logger.setLevel(logging.INFO)
24
+
25
+
26
+ class ModelEvaluator:
27
+ def __init__(self):
28
+ self.metrics={}
29
+ self.y_true=None
30
+ self.y_pred=None
31
+ self.y_prob=None
32
+ self.task=None
33
+
34
+ # ==============================================================
35
+ # 1. Classification Diagnostics
36
+ # ==============================================================
37
+ def plot_confusion_matrix(self, y_true, y_pred, labels=None, title="Confusion Matrix"):
38
+ try:
39
+ cm=confusion_matrix(y_true, y_pred, labels=labels)
40
+ fig, ax=plt.subplots(figsize=(6, 5))
41
+ cax=ax.imshow(cm, cmap="Blues")
42
+ fig.colorbar(cax, ax=ax)
43
+
44
+ tick_marks=range(len(cm))
45
+ display_labels=labels if labels is not None else [f"Class {i}" for i in tick_marks]
46
+ ax.set_xticks(tick_marks)
47
+ ax.set_yticks(tick_marks)
48
+ ax.set_xticklabels(display_labels, rotation=45, ha="right")
49
+ ax.set_yticklabels(display_labels)
50
+
51
+ thresh=cm.max() / 2.0
52
+ for i in range(len(cm)):
53
+ for j in range(len(cm)):
54
+ ax.text(j, i, f"{cm[i, j]}", ha="center", va="center",
55
+ color="white" if cm[i, j] > thresh else "black", fontsize=11, fontweight="bold")
56
+
57
+ ax.set_title(title, fontsize=12, fontweight="bold")
58
+ ax.set_ylabel("Actual Label")
59
+ ax.set_xlabel("Predicted Label")
60
+ plt.tight_layout()
61
+ plt.show()
62
+ return cm
63
+ except Exception as e:
64
+ logger.error("Error in plot_confusion_matrix: %s", e)
65
+
66
+ def plot_roc_curve(self, y_true, y_prob, title="ROC Curve"):
67
+ try:
68
+ fpr, tpr, _ = roc_curve(y_true, y_prob)
69
+ roc_auc=auc(fpr, tpr)
70
+
71
+ fig, ax=plt.subplots(figsize=(6, 5))
72
+ ax.plot(fpr, tpr, color="darkorange", lw=2, label=f"ROC (AUC = {roc_auc:.4f})")
73
+ ax.plot([0, 1], [0, 1], color="navy", lw=1.5, linestyle="--")
74
+ ax.set_xlim([0.0, 1.0])
75
+ ax.set_ylim([0.0, 1.05])
76
+ ax.set_xlabel("False Positive Rate")
77
+ ax.set_ylabel("True Positive Rate")
78
+ ax.set_title(title, fontsize=12, fontweight="bold")
79
+ ax.legend(loc="lower right")
80
+ ax.grid(True, linestyle="--", alpha=0.5)
81
+ plt.tight_layout()
82
+ plt.show()
83
+ logger.info("ROC-AUC score: %.4f", roc_auc)
84
+ return roc_auc
85
+ except Exception as e:
86
+ logger.error("Error in plot_roc_curve: %s", e)
87
+
88
+ def plot_precision_recall(self, y_true, y_prob, title="Precision-Recall Curve"):
89
+ try:
90
+ precision, recall, _ = precision_recall_curve(y_true, y_prob)
91
+ fig, ax=plt.subplots(figsize=(6, 5))
92
+ ax.plot(recall, precision, color="purple", lw=2)
93
+ ax.set_xlabel("Recall")
94
+ ax.set_ylabel("Precision")
95
+ ax.set_title(title, fontsize=12, fontweight="bold")
96
+ ax.grid(True, linestyle="--", alpha=0.5)
97
+ plt.tight_layout()
98
+ plt.show()
99
+ except Exception as e:
100
+ logger.error("Error in plot_precision_recall: %s", e)
101
+
102
+ # ==============================================================
103
+ # 2. Regression Diagnostics
104
+ # ==============================================================
105
+ def plot_actual_vs_predicted(self, y_true, y_pred, title="Actual vs. Predicted"):
106
+ try:
107
+ fig, ax=plt.subplots(figsize=(6, 5))
108
+ ax.scatter(y_true, y_pred, alpha=0.6, edgecolors="k", color="royalblue")
109
+
110
+ min_val=min(min(y_true), min(y_pred))
111
+ max_val=max(max(y_true), max(y_pred))
112
+ ax.plot([min_val, max_val], [min_val, max_val], color="crimson", lw=2, linestyle="--", label="Ideal Fit (y=x)")
113
+
114
+ ax.set_xlabel("Actual Values")
115
+ ax.set_ylabel("Predicted Values")
116
+ ax.set_title(title, fontsize=12, fontweight="bold")
117
+ ax.legend()
118
+ ax.grid(True, linestyle="--", alpha=0.5)
119
+ plt.tight_layout()
120
+ plt.show()
121
+ except Exception as e:
122
+ logger.error("Error in plot_actual_vs_predicted: %s", e)
123
+
124
+ def plot_residuals(self, y_true, y_pred, title="Residual Analysis"):
125
+ try:
126
+ residuals = np.array(y_true) - np.array(y_pred)
127
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4.5))
128
+
129
+ ax1.scatter(y_pred, residuals, alpha=0.6, edgecolors="k", color="darkcyan")
130
+ ax1.axhline(0, color="crimson", linestyle="--", lw=1.5)
131
+ ax1.set_xlabel("Predicted Values")
132
+ ax1.set_ylabel("Residuals")
133
+ ax1.set_title("Residuals vs. Predicted", fontsize=11, fontweight="bold")
134
+ ax1.grid(True, linestyle="--", alpha=0.5)
135
+
136
+ ax2.hist(residuals, bins=20, color="teal", edgecolor="black", alpha=0.7)
137
+ ax2.axvline(0, color="crimson", linestyle="--", lw=1.5)
138
+ ax2.set_xlabel("Residual Value")
139
+ ax2.set_ylabel("Count")
140
+ ax2.set_title("Distribution of Errors", fontsize=11, fontweight="bold")
141
+
142
+ plt.suptitle(title, fontsize=13, fontweight="bold")
143
+ plt.tight_layout()
144
+ plt.show()
145
+ except Exception as e:
146
+ logger.error("Error in plot_residuals: %s", e)
147
+
148
+ # ==============================================================
149
+ # 3. Deep End-to-End Diagnostics Runner
150
+ # ==============================================================
151
+ def evaluate(self, model, X, y):
152
+ try:
153
+ self.y_true = np.array(y)
154
+ self.y_pred = model.predict(X)
155
+
156
+ is_class = (
157
+ self.y_true.dtype == "object"
158
+ or str(self.y_true.dtype).startswith("cat")
159
+ or len(np.unique(self.y_true)) <= 20
160
+ )
161
+ self.task = "Classification" if is_class else "Regression"
162
+
163
+ logger.info("Evaluating model predictions (task=%s, samples=%d)", self.task, len(self.y_true))
164
+
165
+ if is_class:
166
+ logger.info("Classification report:\n%s", classification_report(self.y_true, self.y_pred))
167
+ self.plot_confusion_matrix(self.y_true, self.y_pred)
168
+
169
+ if len(np.unique(self.y_true)) == 2 and hasattr(model, "predict_proba"):
170
+ self.y_prob = model.predict_proba(X)[:, 1]
171
+ self.plot_roc_curve(self.y_true, self.y_prob)
172
+ self.plot_precision_recall(self.y_true, self.y_prob)
173
+
174
+ else:
175
+ r2 = r2_score(self.y_true, self.y_pred)
176
+ rmse = np.sqrt(mean_squared_error(self.y_true, self.y_pred))
177
+ mae = mean_absolute_error(self.y_true, self.y_pred)
178
+ mape = mean_absolute_percentage_error(self.y_true, self.y_pred)
179
+
180
+ n = len(self.y_true)
181
+ p = X.shape[1]
182
+ adj_r2 = 1 - (1 - r2) * (n - 1) / (n - p - 1) if (n - p - 1) > 0 else r2
183
+
184
+ logger.info("Regression metrics: R2=%.4f, Adj_R2=%.4f, RMSE=%.4f, MAE=%.4f, MAPE=%.2f%%",
185
+ r2, adj_r2, rmse, mae, mape * 100)
186
+
187
+ self.plot_actual_vs_predicted(self.y_true, self.y_pred)
188
+ self.plot_residuals(self.y_true, self.y_pred)
189
+
190
+ except Exception as e:
191
+ logger.error("Error in evaluate: %s", e)
Explainability.py ADDED
@@ -0,0 +1,89 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ import matplotlib.pyplot as plt
4
+ from sklearn.inspection import permutation_importance
5
+
6
+
7
+ class ModelExplainer:
8
+ """
9
+ Model Explainability & Feature Importance:
10
+ - Native feature importances (Tree-based & Linear models).
11
+ - Model-agnostic Permutation Importance.
12
+ - Publication-ready bar plots of top driving features.
13
+ """
14
+
15
+ def __init__(self, model, feature_names=None):
16
+ self.model = model
17
+ self.feature_names = list(feature_names) if feature_names is not None else None
18
+
19
+ def get_feature_importances(self):
20
+ """
21
+ Extracts native feature importances or linear coefficients.
22
+ Returns a sorted pandas DataFrame.
23
+ """
24
+ # 1. Tree-based models (RandomForest, DecisionTree, GradientBoosting)
25
+ if hasattr(self.model, "feature_importances_"):
26
+ importances = self.model.feature_importances_
27
+ names = self.feature_names or [f"Feature_{i}" for i in range(len(importances))]
28
+ df = pd.DataFrame({"Feature": names, "Importance": importances})
29
+ return df.sort_values(by="Importance", ascending=False).reset_index(drop=True)
30
+
31
+ # 2. Linear models (LogisticRegression, Ridge, LinearRegression)
32
+ elif hasattr(self.model, "coef_"):
33
+ coef = self.model.coef_
34
+ if coef.ndim > 1:
35
+ coef = np.mean(np.abs(coef), axis=0) # Multi-class or 2D coefs
36
+ else:
37
+ coef = np.abs(coef)
38
+ names = self.feature_names or [f"Feature_{i}" for i in range(len(coef))]
39
+ df = pd.DataFrame({"Feature": names, "Importance": coef})
40
+ return df.sort_values(by="Importance", ascending=False).reset_index(drop=True)
41
+
42
+ else:
43
+ raise AttributeError("Model does not have native feature_importances_ or coef_. Use permutation_importance() instead.")
44
+
45
+ def compute_permutation_importance(self, X, y, scoring=None, n_repeats=10, random_state=42):
46
+ """
47
+ Calculates Model-Agnostic Permutation Importance:
48
+ Measures the drop in model score when a feature's values are randomly shuffled.
49
+ """
50
+ print("Computing Permutation Importance across features...")
51
+ res = permutation_importance(
52
+ self.model,
53
+ X,
54
+ y,
55
+ scoring=scoring,
56
+ n_repeats=n_repeats,
57
+ random_state=random_state,
58
+ n_jobs=-1
59
+ )
60
+
61
+ names = self.feature_names or (X.columns.tolist() if hasattr(X, "columns") else [f"Feature_{i}" for i in range(X.shape[1])])
62
+ df = pd.DataFrame({
63
+ "Feature": names,
64
+ "Importance_Mean": res.importances_mean,
65
+ "Importance_Std": res.importances_std
66
+ }).sort_values(by="Importance_Mean", ascending=False).reset_index(drop=True)
67
+
68
+ return df
69
+
70
+ def plot_feature_importance(self, top_n=10, title="Top Feature Importances", save_path=None):
71
+ """Plots a horizontal bar chart of the top N most important features."""
72
+ try:
73
+ df = self.get_feature_importances().head(top_n)
74
+ except AttributeError:
75
+ print("Cannot extract native feature importance. Run compute_permutation_importance() first.")
76
+ return
77
+
78
+ fig, ax = plt.subplots(figsize=(8, max(4, top_n * 0.4)))
79
+ ax.barh(df["Feature"], df["Importance"], color="teal", edgecolor="black")
80
+ ax.invert_yaxis()
81
+ ax.set_title(title, fontsize=12, fontweight="bold")
82
+ ax.set_xlabel("Importance Score")
83
+ plt.tight_layout()
84
+
85
+ if save_path:
86
+ plt.savefig(save_path, dpi=150)
87
+ print(f"Feature importance plot saved to '{save_path}'")
88
+
89
+ plt.show()
FeatureSelector.py ADDED
@@ -0,0 +1,90 @@
1
+ import logging
2
+ import pandas as pd
3
+ import numpy as np
4
+
5
+ from sklearn.feature_selection import (
6
+ mutual_info_classif,
7
+ mutual_info_regression,
8
+ VarianceThreshold,
9
+ SelectKBest
10
+ )
11
+
12
+ logger=logging.getLogger("FeatureSelector")
13
+ if not logger.handlers:
14
+ _h=logging.StreamHandler()
15
+ _h.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"))
16
+ logger.addHandler(_h)
17
+ logger.setLevel(logging.INFO)
18
+
19
+
20
+ class FeatureSelector:
21
+ def __init__(self, k=10, variance_threshold=0.01, corr_threshold=0.90):
22
+ self.k=k
23
+ self.variance_threshold=variance_threshold
24
+ self.corr_threshold=corr_threshold
25
+ self.selected_features=[]
26
+ self.feature_scores={}
27
+ self.is_fitted=False
28
+
29
+ def fit(self, X, y):
30
+ try:
31
+ if not isinstance(X, pd.DataFrame):
32
+ X=pd.DataFrame(X)
33
+ if not isinstance(y, pd.Series):
34
+ y=pd.Series(y)
35
+
36
+ num_cols=X.select_dtypes(include='number').columns.tolist()
37
+ cur_X=X[num_cols].copy()
38
+
39
+ # 1. Prune near-zero variance features
40
+ var_selector=VarianceThreshold(threshold=self.variance_threshold)
41
+ var_selector.fit(cur_X)
42
+ non_zero_cols=cur_X.columns[var_selector.get_support()].tolist()
43
+ cur_X=cur_X[non_zero_cols]
44
+
45
+ # 2. Prune collinear features (redundant duplicates)
46
+ corr_matrix=cur_X.corr().abs()
47
+ upper_tri=corr_matrix.where(np.triu(np.ones(corr_matrix.shape), k=1).astype(bool))
48
+ cols_to_drop=[col for col in upper_tri.columns if any(upper_tri[col] > self.corr_threshold)]
49
+ cur_X=cur_X.drop(columns=cols_to_drop)
50
+
51
+ # 3. Mutual Information Feature Importance
52
+ is_class=y.dtype == 'object' or str(y.dtype).startswith('cat') or y.nunique() <= 20
53
+ mi_func=mutual_info_classif if is_class else mutual_info_regression
54
+
55
+ mi_scores=mi_func(cur_X, y, random_state=42)
56
+ score_series=pd.Series(mi_scores, index=cur_X.columns).sort_values(ascending=False)
57
+ self.feature_scores=score_series.to_dict()
58
+
59
+ # Pick top K features
60
+ k_val=min(self.k, len(score_series))
61
+ self.selected_features=score_series.head(k_val).index.tolist()
62
+ self.is_fitted=True
63
+
64
+ logger.info("Feature selection complete: %d features selected from %d initial features", len(self.selected_features), X.shape[1])
65
+ return self
66
+ except Exception as e:
67
+ logger.error("Error in FeatureSelector.fit: %s", e)
68
+ return self
69
+
70
+ def transform(self, X):
71
+ try:
72
+ if not self.is_fitted:
73
+ raise ValueError("FeatureSelector is not fitted yet. Call fit() first.")
74
+
75
+ if not isinstance(X, pd.DataFrame):
76
+ X=pd.DataFrame(X)
77
+
78
+ # Align to selected features (fill missing with 0)
79
+ return X.reindex(columns=self.selected_features, fill_value=0)
80
+ except Exception as e:
81
+ logger.error("Error in FeatureSelector.transform: %s", e)
82
+ return X
83
+
84
+ def fit_transform(self, X, y):
85
+ self.fit(X, y)
86
+ return self.transform(X)
87
+
88
+ def get_ranked_scores(self):
89
+ """Returns DataFrame of features ranked by Mutual Information."""
90
+ return pd.DataFrame(list(self.feature_scores.items()), columns=["Feature", "Mutual_Information_Score"])
ModelDeployer.py ADDED
@@ -0,0 +1,89 @@
1
+ import logging
2
+ import joblib
3
+ import pandas as pd
4
+ import numpy as np
5
+ from datetime import datetime
6
+
7
+ logger=logging.getLogger("ModelDeployer")
8
+ if not logger.handlers:
9
+ _h=logging.StreamHandler()
10
+ _h.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"))
11
+ logger.addHandler(_h)
12
+ logger.setLevel(logging.INFO)
13
+
14
+
15
+ class ModelDeployer:
16
+ """
17
+ Production Model Serving Engine:
18
+ - Bundles fitted MLPipeline + trained Model into a single deployable artifact.
19
+ - Saves and loads via joblib.
20
+ - Supports predict() directly on raw, uncleaned data (DataFrames or JSON/dicts).
21
+ """
22
+
23
+ def __init__(self, pipeline=None, model=None, metadata=None):
24
+ self.pipeline = pipeline
25
+ self.model = model
26
+ self.metadata = metadata or {}
27
+ if "created_at" not in self.metadata:
28
+ self.metadata["created_at"] = str(datetime.now())
29
+
30
+ def save(self, filepath="model_bundle.joblib"):
31
+ if self.pipeline is None or self.model is None:
32
+ raise ValueError("Both pipeline and model must be provided before saving.")
33
+
34
+ bundle = {
35
+ "pipeline": self.pipeline,
36
+ "model": self.model,
37
+ "metadata": self.metadata
38
+ }
39
+ joblib.dump(bundle, filepath)
40
+ logger.info("Saved model artifact bundle to '%s'", filepath)
41
+
42
+ @classmethod
43
+ def load(cls, filepath="model_bundle.joblib"):
44
+ bundle = joblib.load(filepath)
45
+ deployer = cls(
46
+ pipeline=bundle["pipeline"],
47
+ model=bundle["model"],
48
+ metadata=bundle.get("metadata", {})
49
+ )
50
+ logger.info("Loaded model artifact bundle from '%s'", filepath)
51
+ return deployer
52
+
53
+ def _prepare_input(self, raw_input):
54
+ if isinstance(raw_input, dict):
55
+ df = pd.DataFrame([raw_input])
56
+ elif isinstance(raw_input, list):
57
+ df = pd.DataFrame(raw_input)
58
+ elif isinstance(raw_input, pd.DataFrame):
59
+ df = raw_input.copy()
60
+ else:
61
+ raise TypeError("Input must be a pandas DataFrame, a dict (single sample), or a list of dicts.")
62
+ return df
63
+
64
+ def predict(self, raw_input):
65
+ df = self._prepare_input(raw_input)
66
+ transformed_res = self.pipeline.transform(df)
67
+
68
+ if isinstance(transformed_res, tuple):
69
+ X = transformed_res[0]
70
+ else:
71
+ X = transformed_res
72
+
73
+ predictions = self.model.predict(X)
74
+ return predictions
75
+
76
+ def predict_proba(self, raw_input):
77
+ if not hasattr(self.model, "predict_proba"):
78
+ raise AttributeError(f"Model '{type(self.model).__name__}' does not support predict_proba().")
79
+
80
+ df = self._prepare_input(raw_input)
81
+ transformed_res = self.pipeline.transform(df)
82
+
83
+ if isinstance(transformed_res, tuple):
84
+ X = transformed_res[0]
85
+ else:
86
+ X = transformed_res
87
+
88
+ probabilities = self.model.predict_proba(X)
89
+ return probabilities
ModelHyperparameter.py ADDED
@@ -0,0 +1,162 @@
1
+ import logging
2
+ import pandas as pd
3
+ import numpy as np
4
+
5
+ from sklearn.model_selection import GridSearchCV, cross_val_score, KFold, StratifiedKFold
6
+ from sklearn.linear_model import LogisticRegression, Ridge
7
+ from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor
8
+ from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor, GradientBoostingClassifier, GradientBoostingRegressor
9
+
10
+ logger=logging.getLogger("HyperparameterTuner")
11
+ if not logger.handlers:
12
+ _h=logging.StreamHandler()
13
+ _h.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s", "%Y-%m-%d %H:%M:%S"))
14
+ logger.addHandler(_h)
15
+ logger.setLevel(logging.INFO)
16
+
17
+
18
+ class ModelHyperparameter:
19
+ @staticmethod
20
+ def get_default_param_grid(model_name: str, task: str = "classification"):
21
+ name = model_name.lower().replace(" ", "_")
22
+
23
+ if "forest" in name or name == "rf":
24
+ return {
25
+ "n_estimators": [50, 100, 200],
26
+ "max_depth": [None, 5, 10],
27
+ "min_samples_split": [2, 5]
28
+ }
29
+ elif "tree" in name or name == "dt":
30
+ return {
31
+ "max_depth": [3, 5, 10, None],
32
+ "min_samples_split": [2, 5, 10]
33
+ }
34
+ elif "gradient" in name or "boost" in name or name == "gb":
35
+ return {
36
+ "n_estimators": [50, 100],
37
+ "learning_rate": [0.01, 0.1, 0.2],
38
+ "max_depth": [3, 5]
39
+ }
40
+ elif "logistic" in name:
41
+ return {
42
+ "C": [0.01, 0.1, 1.0, 10.0],
43
+ "penalty": ["l2"]
44
+ }
45
+ elif "ridge" in name or "linear" in name:
46
+ return {
47
+ "alpha": [0.01, 0.1, 1.0, 10.0, 100.0]
48
+ }
49
+ else:
50
+ raise ValueError(f"No default grid for '{model_name}'. Please provide a custom param_grid.")
51
+
52
+ # ==============================================================
53
+ # 1. Non-Nested Cross-Validation (Standard GridSearchCV)
54
+ # ==============================================================
55
+ def tune_non_nested(self, estimator, param_grid, X, y, cv=5, scoring=None):
56
+ logger.info("Running GridSearchCV (estimator=%s, cv=%d, param_combinations=%s)",
57
+ type(estimator).__name__, cv, list(param_grid.keys()))
58
+ grid = GridSearchCV(
59
+ estimator=estimator,
60
+ param_grid=param_grid,
61
+ cv=cv,
62
+ scoring=scoring,
63
+ n_jobs=-1
64
+ )
65
+ grid.fit(X, y)
66
+
67
+ logger.info("GridSearchCV complete. Best score: %.4f | Optimal params: %s",
68
+ grid.best_score_, grid.best_params_)
69
+
70
+ return {
71
+ "best_estimator": grid.best_estimator_,
72
+ "best_params": grid.best_params_,
73
+ "best_score": grid.best_score_,
74
+ "cv_results": pd.DataFrame(grid.cv_results_)
75
+ }
76
+
77
+ # ==============================================================
78
+ # 2. Nested Cross-Validation (Unbiased Generalization Estimate)
79
+ # ==============================================================
80
+ def tune_nested_cv(self, estimator, param_grid, X, y, outer_cv=5, inner_cv=3, scoring=None):
81
+ logger.info("Running Nested CV (estimator=%s, outer_cv=%d, inner_cv=%d)",
82
+ type(estimator).__name__, outer_cv, inner_cv)
83
+
84
+ is_classification = (
85
+ y.dtype == "object"
86
+ or str(y.dtype).startswith("cat")
87
+ or (isinstance(y, pd.Series) and y.nunique() <= 20)
88
+ or len(np.unique(y)) <= 20
89
+ )
90
+
91
+ if is_classification:
92
+ outer_splitter = StratifiedKFold(n_splits=outer_cv, shuffle=True, random_state=42)
93
+ inner_splitter = StratifiedKFold(n_splits=inner_cv, shuffle=True, random_state=42)
94
+ default_scoring = "accuracy"
95
+ else:
96
+ outer_splitter = KFold(n_splits=outer_cv, shuffle=True, random_state=42)
97
+ inner_splitter = KFold(n_splits=inner_cv, shuffle=True, random_state=42)
98
+ default_scoring = "r2"
99
+
100
+ active_scoring = scoring or default_scoring
101
+
102
+ inner_grid = GridSearchCV(
103
+ estimator=estimator,
104
+ param_grid=param_grid,
105
+ cv=inner_splitter,
106
+ scoring=active_scoring,
107
+ n_jobs=-1
108
+ )
109
+
110
+ nested_scores = cross_val_score(
111
+ inner_grid,
112
+ X,
113
+ y,
114
+ cv=outer_splitter,
115
+ scoring=active_scoring,
116
+ n_jobs=-1
117
+ )
118
+
119
+ logger.info("Nested CV outer fold scores: %s", np.round(nested_scores, 4).tolist())
120
+ logger.info("Nested CV generalization score: %.4f (+/- %.4f)", nested_scores.mean(), nested_scores.std())
121
+
122
+ final_grid = GridSearchCV(estimator=estimator, param_grid=param_grid, cv=inner_splitter, scoring=active_scoring, n_jobs=-1)
123
+ final_grid.fit(X, y)
124
+
125
+ return {
126
+ "nested_scores": nested_scores,
127
+ "nested_mean": nested_scores.mean(),
128
+ "nested_std": nested_scores.std(),
129
+ "best_estimator": final_grid.best_estimator_,
130
+ "best_params": final_grid.best_params_,
131
+ "metric": active_scoring
132
+ }
133
+
134
+ # ==============================================================
135
+ # 3. Side-by-Side Comparison: Nested vs Non-Nested
136
+ # ==============================================================
137
+ def compare_nested_vs_non_nested(self, estimator, param_grid, X, y, outer_cv=5, inner_cv=3, scoring=None):
138
+ logger.info("Executing comparison between Non-Nested and Nested CV")
139
+
140
+ non_nested_res = self.tune_non_nested(estimator, param_grid, X, y, cv=outer_cv, scoring=scoring)
141
+ nested_res = self.tune_nested_cv(estimator, param_grid, X, y, outer_cv=outer_cv, inner_cv=inner_cv, scoring=scoring)
142
+
143
+ comparison_df = pd.DataFrame([
144
+ {
145
+ "Method": "Non-Nested CV (GridSearchCV)",
146
+ "Score": f"{non_nested_res['best_score']:.4f}",
147
+ "Type": "Optimized deployment score"
148
+ },
149
+ {
150
+ "Method": "Nested CV (Outer + Inner)",
151
+ "Score": f"{nested_res['nested_mean']:.4f} (+/- {nested_res['nested_std']:.4f})",
152
+ "Type": "Unbiased generalization estimate"
153
+ }
154
+ ])
155
+
156
+ logger.info("Comparison summary:\n%s", comparison_df.to_string(index=False))
157
+
158
+ return {
159
+ "comparison": comparison_df,
160
+ "non_nested": non_nested_res,
161
+ "nested": nested_res
162
+ }