abinazebinoy commited on
Commit
da8a50a
·
1 Parent(s): 72cf455

feat(ml): evaluation pipeline and improved XGBoost training from diagnostic report

Browse files
Files changed (2) hide show
  1. scripts/evaluate_model.py +228 -0
  2. scripts/train_ensemble.py +83 -42
scripts/evaluate_model.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Model Evaluation and Benchmarking Script.
3
+
4
+ Implements diagnostics from the ML accuracy report:
5
+ - Confusion matrix, precision, recall, F1, AUROC
6
+ - Class balance analysis
7
+ - Data leakage check (train/val/test path overlap)
8
+ - RandomizedSearchCV hyperparameter search
9
+
10
+ Usage:
11
+ python scripts/evaluate_model.py
12
+ python scripts/evaluate_model.py --hparam-search
13
+ """
14
+ import csv
15
+ import json
16
+ import logging
17
+ import argparse
18
+ import numpy as np
19
+ from pathlib import Path
20
+
21
+ logging.basicConfig(
22
+ level=logging.INFO,
23
+ format="%(asctime)s %(levelname)s %(message)s",
24
+ datefmt="%H:%M:%S",
25
+ )
26
+ logger = logging.getLogger(__name__)
27
+
28
+ ROOT = Path(__file__).parents[1]
29
+ MANIFEST = ROOT / "data" / "manifest.csv"
30
+ FEATURES = ROOT / "data" / "features.csv"
31
+ RESULTS_OUT = ROOT / "data" / "reference" / "eval_results.json"
32
+
33
+
34
+ def check_class_balance(manifest_path: Path) -> dict:
35
+ counts = {}
36
+ with open(manifest_path, newline="", encoding="utf-8") as f:
37
+ for row in csv.DictReader(f):
38
+ label = row.get("label", "")
39
+ counts[label] = counts.get(label, 0) + 1
40
+
41
+ total = sum(counts.values())
42
+ balance_ratio = min(counts.values()) / max(counts.values(), 1)
43
+ imbalanced = balance_ratio < 0.30
44
+
45
+ logger.info(f"Class balance: {counts} (ratio={balance_ratio:.3f})")
46
+ if imbalanced:
47
+ logger.warning(
48
+ f"Class imbalance detected (ratio={balance_ratio:.3f}). "
49
+ "Consider resampling or scale_pos_weight."
50
+ )
51
+ return {"counts": counts, "total": total,
52
+ "balance_ratio": round(balance_ratio, 4), "imbalanced": imbalanced}
53
+
54
+
55
+ def check_data_leakage(manifest_path: Path) -> dict:
56
+ split_paths: dict = {}
57
+ with open(manifest_path, newline="", encoding="utf-8") as f:
58
+ for row in csv.DictReader(f):
59
+ split = row.get("split", "train")
60
+ path = row.get("path", "")
61
+ if split not in split_paths:
62
+ split_paths[split] = set()
63
+ split_paths[split].add(path)
64
+
65
+ overlaps = {}
66
+ splits = list(split_paths.keys())
67
+ for i in range(len(splits)):
68
+ for j in range(i + 1, len(splits)):
69
+ a, b = splits[i], splits[j]
70
+ overlap = split_paths[a] & split_paths[b]
71
+ if overlap:
72
+ key = f"{a}_vs_{b}"
73
+ overlaps[key] = len(overlap)
74
+ logger.warning(f"Data leakage: {len(overlap)} duplicate paths between {a} and {b}")
75
+
76
+ leakage = len(overlaps) > 0
77
+ if not leakage:
78
+ logger.info("No path-based data leakage detected.")
79
+ return {"split_sizes": {k: len(v) for k, v in split_paths.items()},
80
+ "leakage_detected": leakage, "overlapping_paths": overlaps}
81
+
82
+
83
+ def evaluate_xgboost(features_path: Path, threshold: float = 0.5) -> dict:
84
+ import pickle
85
+ from sklearn.model_selection import StratifiedShuffleSplit
86
+ from sklearn.metrics import (
87
+ accuracy_score, precision_score, recall_score,
88
+ f1_score, roc_auc_score, confusion_matrix, classification_report,
89
+ )
90
+
91
+ model_path = ROOT / "data" / "reference" / "ensemble_xgb.pkl"
92
+ if not model_path.exists():
93
+ logger.warning("ensemble_xgb.pkl not found. Run scripts/train_ensemble.py first.")
94
+ return {"error": "Model not found"}
95
+
96
+ with open(model_path, "rb") as f:
97
+ pkg = pickle.load(f)
98
+ model = pkg["model"]
99
+ feature_names = pkg["feature_names"]
100
+
101
+ rows, labels = [], []
102
+ with open(features_path, newline="", encoding="utf-8") as f:
103
+ for row in csv.DictReader(f):
104
+ labels.append(int(row["label"]))
105
+ rows.append([float(row.get(k, 0.5)) for k in feature_names])
106
+
107
+ X = np.array(rows)
108
+ y = np.array(labels)
109
+
110
+ sss = StratifiedShuffleSplit(n_splits=1, test_size=0.20, random_state=42)
111
+ _, test_idx = next(sss.split(X, y))
112
+ X_test, y_test = X[test_idx], y[test_idx]
113
+
114
+ y_pred = (model.predict_proba(X_test)[:, 1] >= threshold).astype(int)
115
+ y_score = model.predict_proba(X_test)[:, 1]
116
+
117
+ acc = float(accuracy_score(y_test, y_pred))
118
+ prec = float(precision_score(y_test, y_pred, zero_division=0))
119
+ rec = float(recall_score(y_test, y_pred, zero_division=0))
120
+ f1 = float(f1_score(y_test, y_pred, zero_division=0))
121
+ auroc = float(roc_auc_score(y_test, y_score))
122
+ cm = confusion_matrix(y_test, y_pred).tolist()
123
+
124
+ alarms = []
125
+ if acc < 0.90: alarms.append(f"Accuracy {acc:.3f} below threshold 0.90")
126
+ if prec < 0.85: alarms.append(f"Precision {prec:.3f} below threshold 0.85")
127
+ if rec < 0.80: alarms.append(f"Recall {rec:.3f} below threshold 0.80")
128
+ if f1 < 0.83: alarms.append(f"F1 {f1:.3f} below threshold 0.83")
129
+ if auroc < 0.92: alarms.append(f"AUROC {auroc:.3f} below threshold 0.92")
130
+
131
+ logger.info(f"Evaluation on {len(y_test)} held-out samples:")
132
+ logger.info(f" Accuracy: {acc:.4f}")
133
+ logger.info(f" Precision: {prec:.4f}")
134
+ logger.info(f" Recall: {rec:.4f}")
135
+ logger.info(f" F1: {f1:.4f}")
136
+ logger.info(f" AUROC: {auroc:.4f}")
137
+ for alarm in alarms:
138
+ logger.warning(f"ALARM: {alarm}")
139
+ if not alarms:
140
+ logger.info("All metrics above alarm thresholds.")
141
+
142
+ return {
143
+ "n_test_samples": len(y_test), "threshold": threshold,
144
+ "accuracy": round(acc, 4), "precision_ai": round(prec, 4),
145
+ "recall_ai": round(rec, 4), "f1_ai": round(f1, 4),
146
+ "auroc": round(auroc, 4), "confusion_matrix": cm,
147
+ "classification_report": classification_report(
148
+ y_test, y_pred, target_names=["real", "ai"], output_dict=True),
149
+ "alarms": alarms,
150
+ "targets": {"accuracy": 0.95, "precision_ai": 0.90,
151
+ "recall_ai": 0.88, "f1_ai": 0.89, "auroc": 0.97},
152
+ "thresholds": {"accuracy": 0.90, "precision_ai": 0.85,
153
+ "recall_ai": 0.80, "f1_ai": 0.83, "auroc": 0.92},
154
+ }
155
+
156
+
157
+ def hyperparameter_search(features_path: Path, n_iter: int = 20) -> dict:
158
+ import xgboost as xgb
159
+ from sklearn.model_selection import RandomizedSearchCV, StratifiedKFold
160
+
161
+ rows, labels, feature_names = [], [], None
162
+ with open(features_path, newline="", encoding="utf-8") as f:
163
+ for row in csv.DictReader(f):
164
+ if feature_names is None:
165
+ feature_names = [k for k in row if k not in ("label", "path")]
166
+ labels.append(int(row["label"]))
167
+ rows.append([float(row[k]) for k in feature_names])
168
+
169
+ X = np.array(rows)
170
+ y = np.array(labels)
171
+
172
+ param_grid = {
173
+ "learning_rate": [0.01, 0.05, 0.10, 0.15, 0.20],
174
+ "max_depth": [3, 4, 5, 6],
175
+ "n_estimators": [100, 200, 300, 400],
176
+ "subsample": [0.6, 0.7, 0.8, 0.9],
177
+ "colsample_bytree": [0.6, 0.7, 0.8, 0.9],
178
+ "min_child_weight": [1, 3, 5],
179
+ "gamma": [0, 0.1, 0.2, 0.5],
180
+ }
181
+
182
+ model = xgb.XGBClassifier(eval_metric="logloss", random_state=42)
183
+ cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
184
+ logger.info(f"Running RandomizedSearchCV with {n_iter} iterations")
185
+ search = RandomizedSearchCV(model, param_grid, n_iter=n_iter,
186
+ cv=cv, scoring="roc_auc",
187
+ random_state=42, n_jobs=-1, verbose=1)
188
+ search.fit(X, y)
189
+ logger.info(f"Best params: {search.best_params_}")
190
+ logger.info(f"Best CV AUC: {search.best_score_:.4f}")
191
+ return {"best_params": search.best_params_,
192
+ "best_cv_auc": round(search.best_score_, 4), "n_iterations": n_iter}
193
+
194
+
195
+ def main():
196
+ parser = argparse.ArgumentParser()
197
+ parser.add_argument("--threshold", type=float, default=0.5)
198
+ parser.add_argument("--hparam-search", action="store_true")
199
+ parser.add_argument("--n-iter", type=int, default=20)
200
+ args = parser.parse_args()
201
+
202
+ results = {}
203
+
204
+ if MANIFEST.exists():
205
+ logger.info("=== CLASS BALANCE CHECK ===")
206
+ results["class_balance"] = check_class_balance(MANIFEST)
207
+ logger.info("=== DATA LEAKAGE CHECK ===")
208
+ results["leakage_check"] = check_data_leakage(MANIFEST)
209
+ else:
210
+ logger.warning("manifest.csv not found")
211
+
212
+ if FEATURES.exists():
213
+ logger.info("=== MODEL EVALUATION ===")
214
+ results["evaluation"] = evaluate_xgboost(FEATURES, threshold=args.threshold)
215
+ if args.hparam_search:
216
+ logger.info("=== HYPERPARAMETER SEARCH ===")
217
+ results["hparam_search"] = hyperparameter_search(FEATURES, n_iter=args.n_iter)
218
+ else:
219
+ logger.warning("features.csv not found — run scripts/extract_features.py first")
220
+
221
+ RESULTS_OUT.parent.mkdir(parents=True, exist_ok=True)
222
+ with open(RESULTS_OUT, "w") as f:
223
+ json.dump(results, f, indent=2, default=str)
224
+ logger.info(f"Saved to {RESULTS_OUT}")
225
+
226
+
227
+ if __name__ == "__main__":
228
+ main()
scripts/train_ensemble.py CHANGED
@@ -1,11 +1,23 @@
1
- import sys
 
 
 
 
 
 
2
  import csv
3
  import pickle
4
  import logging
 
5
  import numpy as np
6
  from pathlib import Path
 
7
 
8
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
 
 
 
 
9
  logger = logging.getLogger(__name__)
10
 
11
  ROOT = Path(__file__).parents[1]
@@ -17,16 +29,19 @@ RESULTS_OUT = ROOT / "data" / "reference" / "ensemble_results.json"
17
  def main():
18
  import xgboost as xgb
19
  import shap
20
- from sklearn.model_selection import StratifiedKFold, cross_validate
21
- from sklearn.metrics import roc_auc_score, f1_score
22
- import json
 
 
 
 
23
 
24
  logger.info("Loading feature matrix")
25
  rows, labels, feature_names = [], [], None
26
 
27
  with open(FEATURES, newline="", encoding="utf-8") as f:
28
- reader = csv.DictReader(f)
29
- for row in reader:
30
  if feature_names is None:
31
  feature_names = [k for k in row if k not in ("label", "path")]
32
  labels.append(int(row["label"]))
@@ -36,41 +51,62 @@ def main():
36
  y = np.array(labels)
37
  logger.info(f"Feature matrix: {X.shape} | Positives: {y.sum()}/{len(y)}")
38
 
39
- model = xgb.XGBClassifier(
40
- n_estimators=300,
41
- max_depth=4,
42
- learning_rate=0.05,
43
- subsample=0.8,
44
- colsample_bytree=0.8,
45
- eval_metric="logloss",
46
- random_state=42,
 
 
 
 
 
 
 
47
  )
48
 
49
  logger.info("Cross-validating (5-fold stratified)")
50
- cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
51
- scores = cross_validate(
52
- model, X, y, cv=cv,
53
- scoring=["roc_auc", "f1"],
54
- return_train_score=True,
 
 
 
 
 
55
  )
56
 
57
- auc = scores["test_roc_auc"].mean()
58
- f1 = scores["test_f1"].mean()
59
- logger.info(f"CV AUC: {auc:.4f} +/- {scores['test_roc_auc'].std():.4f}")
60
- logger.info(f"CV F1: {f1:.4f} +/- {scores['test_f1'].std():.4f}")
 
 
 
 
 
 
 
 
61
 
62
- logger.info("Fitting final model on all data")
63
- model.fit(X, y)
 
 
 
 
64
 
65
  logger.info("Computing SHAP values")
66
- explainer = shap.TreeExplainer(model)
67
- shap_values = explainer.shap_values(X)
68
- mean_shap = np.abs(shap_values).mean(axis=0)
69
-
70
- signal_importance = sorted(
71
- zip(feature_names, mean_shap.tolist()),
72
- key=lambda x: x[1], reverse=True
73
- )
74
 
75
  logger.info("Top 10 signals by SHAP importance:")
76
  for name, imp in signal_importance[:10]:
@@ -78,22 +114,27 @@ def main():
78
 
79
  MODEL_OUT.parent.mkdir(parents=True, exist_ok=True)
80
  with open(MODEL_OUT, "wb") as f:
81
- pickle.dump({"model": model, "feature_names": feature_names, "explainer": explainer}, f)
 
82
  logger.info(f"Model saved to {MODEL_OUT}")
83
 
84
  results = {
85
- "cv_auc_mean": round(auc, 4),
86
- "cv_auc_std": round(scores["test_roc_auc"].std(), 4),
87
- "cv_f1_mean": round(f1, 4),
88
- "cv_f1_std": round(scores["test_f1"].std(), 4),
89
- "n_features": len(feature_names),
90
- "n_samples": len(y),
 
 
 
 
 
91
  "feature_importance": {k: round(v, 6) for k, v in signal_importance},
92
  }
93
  with open(RESULTS_OUT, "w") as f:
94
  json.dump(results, f, indent=2)
95
  logger.info(f"Results saved to {RESULTS_OUT}")
96
- logger.info("Phase 4 complete.")
97
 
98
 
99
  if __name__ == "__main__":
 
1
+ """
2
+ Train XGBoost ensemble classifier with ML accuracy improvements:
3
+ - scale_pos_weight for class imbalance
4
+ - Early stopping on validation AUC
5
+ - Regularization: gamma, min_child_weight
6
+ - Separate held-out test evaluation
7
+ """
8
  import csv
9
  import pickle
10
  import logging
11
+ import argparse
12
  import numpy as np
13
  from pathlib import Path
14
+ import json
15
 
16
+ logging.basicConfig(
17
+ level=logging.INFO,
18
+ format="%(asctime)s %(levelname)s %(message)s",
19
+ datefmt="%H:%M:%S",
20
+ )
21
  logger = logging.getLogger(__name__)
22
 
23
  ROOT = Path(__file__).parents[1]
 
29
  def main():
30
  import xgboost as xgb
31
  import shap
32
+ from sklearn.model_selection import StratifiedKFold, cross_validate, train_test_split
33
+ from sklearn.metrics import accuracy_score, f1_score, roc_auc_score
34
+
35
+ parser = argparse.ArgumentParser()
36
+ parser.add_argument("--test-size", type=float, default=0.15)
37
+ parser.add_argument("--early-stop", type=int, default=20)
38
+ args = parser.parse_args()
39
 
40
  logger.info("Loading feature matrix")
41
  rows, labels, feature_names = [], [], None
42
 
43
  with open(FEATURES, newline="", encoding="utf-8") as f:
44
+ for row in csv.DictReader(f):
 
45
  if feature_names is None:
46
  feature_names = [k for k in row if k not in ("label", "path")]
47
  labels.append(int(row["label"]))
 
51
  y = np.array(labels)
52
  logger.info(f"Feature matrix: {X.shape} | Positives: {y.sum()}/{len(y)}")
53
 
54
+ neg_count = (y == 0).sum()
55
+ pos_count = y.sum()
56
+ scale_pos_weight = neg_count / max(pos_count, 1)
57
+ logger.info(f"scale_pos_weight: {scale_pos_weight:.3f}")
58
+
59
+ X_dev, X_test, y_dev, y_test = train_test_split(
60
+ X, y, test_size=args.test_size, stratify=y, random_state=42
61
+ )
62
+
63
+ cv_model = xgb.XGBClassifier(
64
+ n_estimators=300, max_depth=4, learning_rate=0.05,
65
+ subsample=0.8, colsample_bytree=0.8,
66
+ min_child_weight=3, gamma=0.1,
67
+ scale_pos_weight=scale_pos_weight,
68
+ eval_metric="logloss", random_state=42,
69
  )
70
 
71
  logger.info("Cross-validating (5-fold stratified)")
72
+ cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
73
+ scores = cross_validate(cv_model, X_dev, y_dev, cv=cv,
74
+ scoring=["roc_auc", "f1"], return_train_score=True)
75
+ auc_cv = scores["test_roc_auc"].mean()
76
+ f1_cv = scores["test_f1"].mean()
77
+ logger.info(f"CV AUC: {auc_cv:.4f} +/- {scores['test_roc_auc'].std():.4f}")
78
+ logger.info(f"CV F1: {f1_cv:.4f} +/- {scores['test_f1'].std():.4f}")
79
+
80
+ X_tr, X_val, y_tr, y_val = train_test_split(
81
+ X_dev, y_dev, test_size=0.15, stratify=y_dev, random_state=0
82
  )
83
 
84
+ model = xgb.XGBClassifier(
85
+ n_estimators=500, max_depth=4, learning_rate=0.05,
86
+ subsample=0.8, colsample_bytree=0.8,
87
+ min_child_weight=3, gamma=0.1,
88
+ scale_pos_weight=scale_pos_weight,
89
+ eval_metric="auc",
90
+ early_stopping_rounds=args.early_stop,
91
+ random_state=42,
92
+ )
93
+ logger.info("Fitting final model with early stopping")
94
+ model.fit(X_tr, y_tr, eval_set=[(X_val, y_val)], verbose=50)
95
+ logger.info(f"Best iteration: {model.best_iteration}")
96
 
97
+ y_score = model.predict_proba(X_test)[:, 1]
98
+ y_pred = (y_score >= 0.5).astype(int)
99
+ test_auc = float(roc_auc_score(y_test, y_score))
100
+ test_f1 = float(f1_score(y_test, y_pred, zero_division=0))
101
+ test_acc = float(accuracy_score(y_test, y_pred))
102
+ logger.info(f"Test AUC: {test_auc:.4f} F1: {test_f1:.4f} Acc: {test_acc:.4f}")
103
 
104
  logger.info("Computing SHAP values")
105
+ explainer = shap.TreeExplainer(model)
106
+ shap_values = explainer.shap_values(X_dev)
107
+ mean_shap = np.abs(shap_values).mean(axis=0)
108
+ signal_importance = sorted(zip(feature_names, mean_shap.tolist()),
109
+ key=lambda x: x[1], reverse=True)
 
 
 
110
 
111
  logger.info("Top 10 signals by SHAP importance:")
112
  for name, imp in signal_importance[:10]:
 
114
 
115
  MODEL_OUT.parent.mkdir(parents=True, exist_ok=True)
116
  with open(MODEL_OUT, "wb") as f:
117
+ pickle.dump({"model": model, "feature_names": feature_names,
118
+ "explainer": explainer}, f)
119
  logger.info(f"Model saved to {MODEL_OUT}")
120
 
121
  results = {
122
+ "cv_auc_mean": round(auc_cv, 4),
123
+ "cv_auc_std": round(scores["test_roc_auc"].std(), 4),
124
+ "cv_f1_mean": round(f1_cv, 4),
125
+ "cv_f1_std": round(scores["test_f1"].std(), 4),
126
+ "test_auc": round(test_auc, 4),
127
+ "test_f1": round(test_f1, 4),
128
+ "test_accuracy": round(test_acc, 4),
129
+ "best_iteration": int(model.best_iteration),
130
+ "scale_pos_weight": round(float(scale_pos_weight), 4),
131
+ "n_features": len(feature_names),
132
+ "n_samples": len(y),
133
  "feature_importance": {k: round(v, 6) for k, v in signal_importance},
134
  }
135
  with open(RESULTS_OUT, "w") as f:
136
  json.dump(results, f, indent=2)
137
  logger.info(f"Results saved to {RESULTS_OUT}")
 
138
 
139
 
140
  if __name__ == "__main__":