Explainable Stacking Ensemble for Software Defect Prediction¶
1. Setup and configuration¶
A single fixed random seed is used everywhere (splits, SMOTE, every stochastic model) so the
whole notebook is reproducible. Change DATASETS here if your CSV files live elsewhere.
In [2]:
import os, warnings, json, time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.stats import wilcoxon
from sklearn.model_selection import StratifiedKFold, cross_validate
from sklearn.preprocessing import MinMaxScaler
from sklearn.impute import SimpleImputer
from sklearn.feature_selection import RFE
from sklearn.ensemble import RandomForestClassifier, StackingClassifier
from sklearn.svm import SVC
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
f1_score, roc_auc_score, matthews_corrcoef)
from imblearn.over_sampling import SMOTE
from imblearn.pipeline import Pipeline as ImbPipeline # NOTE: imblearn Pipeline, not sklearn's
from xgboost import XGBClassifier
warnings.filterwarnings("ignore")
sns.set_style("whitegrid")
plt.rcParams["figure.dpi"] = 120
plt.rcParams["savefig.dpi"] = 200
plt.rcParams["font.size"] = 11
RANDOM_STATE = 42
N_FOLDS = 10
N_FEATURES = 15 # number of features RFE keeps; tune in Section 8 if desired
OUTDIR = "outputs"
os.makedirs(OUTDIR, exist_ok=True)
# dataset file -> label column name. The four core datasets from the proposal, plus KC2.
DATASETS = {
"CM1": ("D:/thesis/nabil/report/code/data/cm1.csv", "defects"),
"KC1": ("D:/thesis/nabil/report/code/data/kc1.csv", "defects"),
"PC1": ("D:/thesis/nabil/report/code/data/pc1.csv", "defects"),
"JM1": ("D:/thesis/nabil/report/code/data/jm1.csv", "defects"),
}
CORE = ["CM1", "KC1", "PC1", "JM1"] # headline results reported on these four
np.random.seed(RANDOM_STATE)
print("Setup complete. Output directory:", os.path.abspath(OUTDIR))
Setup complete. Output directory: d:\thesis\nabil\report\code\outputs
2. Data loading, label normalisation and validation¶
In [3]:
def load_dataset(path, label_col):
df = pd.read_csv(path)
y_raw = df[label_col]
# normalise a variety of encodings to 1/0
def to_binary(v):
s = str(v).strip().lower()
return 1 if s in ("true", "yes", "y", "1") else 0
y = y_raw.map(to_binary).astype(int)
X = df.drop(columns=[label_col])
# coerce features to numeric (any stray '?' becomes NaN and is imputed later)
X = X.apply(pd.to_numeric, errors="coerce")
return X, y
summ = []
data = {}
for name, (path, label) in DATASETS.items():
X, y = load_dataset(path, label)
data[name] = (X, y)
pos = int(y.sum()); n = len(y)
summ.append({
"Dataset": name, "Modules": n, "Features": X.shape[1],
"Defective": pos, "Clean": n - pos,
"Defect ratio (%)": round(pos / n * 100, 1),
"Missing cells": int(X.isnull().sum().sum()),
"Duplicate rows": int(X.duplicated().sum()),
})
summary_df = pd.DataFrame(summ).set_index("Dataset")
summary_df.to_csv(f"{OUTDIR}/table_dataset_summary.csv")
summary_df
Out[3]:
| Modules | Features | Defective | Clean | Defect ratio (%) | Missing cells | Duplicate rows | |
|---|---|---|---|---|---|---|---|
| Dataset | |||||||
| CM1 | 498 | 21 | 49 | 449 | 9.8 | 0 | 57 |
| KC1 | 2109 | 21 | 326 | 1783 | 15.5 | 0 | 917 |
| PC1 | 1109 | 21 | 77 | 1032 | 6.9 | 0 | 161 |
| JM1 | 13204 | 21 | 2103 | 11101 | 15.9 | 0 | 4384 |
In [4]:
DROP_DUPLICATES = False # set True to run a duplicate-free sensitivity check
if DROP_DUPLICATES:
for name in list(data.keys()):
X, y = data[name]
mask = ~X.duplicated().values
data[name] = (X[mask].reset_index(drop=True), y[mask].reset_index(drop=True))
print("Duplicates dropped.")
else:
print("Duplicates kept (default).")
Duplicates kept (default).
3. Exploratory data analysis¶
Three EDA outputs, each saved to outputs/:
- class-balance bar chart across datasets (motivates SMOTE),
- a feature-correlation heatmap on the largest dataset (motivates RFE),
- metric distribution summary.
In [5]:
# 3a. Class balance across datasets
fig, ax = plt.subplots(figsize=(8, 4.5))
ratios = summary_df.loc[list(DATASETS.keys()), "Defect ratio (%)"]
bars = ax.bar(ratios.index, ratios.values, color="#c2185b", edgecolor="black")
ax.set_ylabel("Defective modules (%)")
ax.set_title("Class imbalance across NASA PROMISE datasets")
for b, v in zip(bars, ratios.values):
ax.text(b.get_x() + b.get_width()/2, v + 0.3, f"{v}%", ha="center", fontsize=10)
plt.tight_layout()
plt.savefig(f"{OUTDIR}/fig_class_balance.png", bbox_inches="tight")
plt.show()
In [6]:
# 3b. Correlation heatmap on JM1 (largest dataset) — shows redundancy RFE will trim
Xj, yj = data["JM1"]
corr = Xj.corr()
fig, ax = plt.subplots(figsize=(11, 9))
sns.heatmap(corr, cmap="coolwarm", center=0, square=True,
cbar_kws={"shrink": .7}, linewidths=.3, ax=ax)
ax.set_title("Feature correlation heatmap (JM1)")
plt.tight_layout()
plt.savefig(f"{OUTDIR}/fig_correlation_jm1.png", bbox_inches="tight")
plt.show()
In [7]:
# 3c. Descriptive statistics table (JM1) for the appendix
desc = Xj.describe().T[["mean", "std", "min", "50%", "max"]].round(2)
desc.to_csv(f"{OUTDIR}/table_jm1_describe.csv")
desc.head(22)
Out[7]:
| mean | std | min | 50% | max | |
|---|---|---|---|---|---|
| loc | 39.68 | 71.90 | 1.0 | 22.00 | 3442.00 |
| v(g) | 6.02 | 12.19 | 1.0 | 3.00 | 470.00 |
| ev(g) | 3.21 | 6.47 | 1.0 | 1.00 | 165.00 |
| iv(g) | 3.82 | 8.48 | 1.0 | 2.00 | 402.00 |
| n | 108.86 | 234.81 | 0.0 | 47.00 | 8441.00 |
| v | 633.89 | 1804.95 | 0.0 | 206.58 | 80843.08 |
| l | 0.14 | 0.17 | 0.0 | 0.08 | 1.30 |
| d | 13.86 | 18.45 | 0.0 | 8.90 | 418.20 |
| i | 28.76 | 32.50 | 0.0 | 21.52 | 569.78 |
| e | 33656.99 | 401839.48 | 0.0 | 1877.33 | 31079782.27 |
| b | 0.21 | 0.60 | 0.0 | 0.07 | 26.95 |
| t | 1869.83 | 22324.42 | 0.0 | 104.30 | 1726654.57 |
| lOCode | 25.10 | 55.69 | 0.0 | 12.00 | 2824.00 |
| lOComment | 2.60 | 8.69 | 0.0 | 0.00 | 344.00 |
| lOBlank | 4.43 | 9.70 | 0.0 | 2.00 | 447.00 |
| locCodeAndComment | 0.35 | 1.81 | 0.0 | 0.00 | 108.00 |
| uniq_Op | 11.02 | 9.54 | 0.0 | 10.00 | 411.00 |
| uniq_Opnd | 16.15 | 24.92 | 0.0 | 11.00 | 1026.00 |
| total_Op | 64.78 | 142.73 | 0.0 | 28.00 | 5420.00 |
| total_Opnd | 44.20 | 94.31 | 0.0 | 19.00 | 3021.00 |
| branchCount | 10.67 | 21.29 | 1.0 | 5.00 | 826.00 |
4. Building the leak-free pipeline¶
In [8]:
def make_base_learners():
rf = RandomForestClassifier(n_estimators=200, random_state=RANDOM_STATE, n_jobs=-1)
xgb = XGBClassifier(n_estimators=200, learning_rate=0.1, max_depth=4,
subsample=0.9, colsample_bytree=0.9, eval_metric="logloss",
random_state=RANDOM_STATE, n_jobs=-1)
svm = SVC(kernel="rbf", probability=True, random_state=RANDOM_STATE)
return [("rf", rf), ("xgb", xgb), ("svm", svm)]
def make_stacking():
return StackingClassifier(
estimators=make_base_learners(),
final_estimator=LogisticRegression(max_iter=1000, random_state=RANDOM_STATE),
stack_method="predict_proba", cv=5, n_jobs=-1,
)
def make_pipeline(model):
"""Wrap a classifier in the full leak-free preprocessing pipeline."""
rfe = RFE(estimator=RandomForestClassifier(n_estimators=100, random_state=RANDOM_STATE),
n_features_to_select=N_FEATURES)
return ImbPipeline([
("impute", SimpleImputer(strategy="median")),
("scale", MinMaxScaler()),
("smote", SMOTE(random_state=RANDOM_STATE)),
("rfe", rfe),
("model", model),
])
# the proposed artefact plus the single-classifier baselines, all through the SAME pipeline
MODELS = {
"Proposed (Stacking)": make_stacking,
"Random Forest": lambda: RandomForestClassifier(n_estimators=200, random_state=RANDOM_STATE, n_jobs=-1),
"XGBoost": lambda: XGBClassifier(n_estimators=200, learning_rate=0.1, max_depth=4,
eval_metric="logloss", random_state=RANDOM_STATE, n_jobs=-1),
"SVM": lambda: SVC(kernel="rbf", probability=True, random_state=RANDOM_STATE),
"Logistic Regression": lambda: LogisticRegression(max_iter=1000, random_state=RANDOM_STATE),
}
print("Pipeline factory ready. Models:", list(MODELS.keys()))
Pipeline factory ready. Models: ['Proposed (Stacking)', 'Random Forest', 'XGBoost', 'SVM', 'Logistic Regression']
5. Evaluation protocol (10-fold stratified CV)¶
In [9]:
SCORERS = {
"Accuracy": "accuracy",
"Precision": "precision",
"Recall": "recall",
"F1": "f1",
"AUC-ROC": "roc_auc",
"MCC": "matthews_corrcoef",
}
def evaluate_model(model_factory, X, y):
"""Return a dict metric -> array of per-fold scores."""
pipe = make_pipeline(model_factory())
cv = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=RANDOM_STATE)
res = cross_validate(pipe, X, y, cv=cv, scoring=SCORERS, n_jobs=-1, error_score="raise")
return {m: res[f"test_{m}"] for m in SCORERS}
print("Evaluation helpers ready.")
Evaluation helpers ready.
6. Run all models on all datasets¶
In [10]:
per_fold = {} # per_fold[dataset][model][metric] = array of fold scores
mean_rows = [] # tidy table of mean±std
t0 = time.time()
for ds in DATASETS:
X, y = data[ds]
per_fold[ds] = {}
for mname, factory in MODELS.items():
scores = evaluate_model(factory, X, y)
per_fold[ds][mname] = scores
row = {"Dataset": ds, "Model": mname}
for m in SCORERS:
row[m] = round(float(np.mean(scores[m])), 4)
row[m + "_std"] = round(float(np.std(scores[m])), 4)
mean_rows.append(row)
print(f"{ds:5} | {mname:20} | F1={row['F1']:.3f} AUC={row['AUC-ROC']:.3f} MCC={row['MCC']:.3f}")
print(f"\nDone in {time.time()-t0:.0f}s")
results_df = pd.DataFrame(mean_rows)
results_df.to_csv(f"{OUTDIR}/table_all_results.csv", index=False)
results_df
CM1 | Proposed (Stacking) | F1=0.169 AUC=0.724 MCC=0.094 CM1 | Random Forest | F1=0.172 AUC=0.743 MCC=0.090 CM1 | XGBoost | F1=0.228 AUC=0.712 MCC=0.148 CM1 | SVM | F1=0.350 AUC=0.756 MCC=0.284 CM1 | Logistic Regression | F1=0.358 AUC=0.790 MCC=0.308 KC1 | Proposed (Stacking) | F1=0.445 AUC=0.804 MCC=0.367 KC1 | Random Forest | F1=0.466 AUC=0.819 MCC=0.367 KC1 | XGBoost | F1=0.437 AUC=0.791 MCC=0.340 KC1 | SVM | F1=0.437 AUC=0.788 MCC=0.332 KC1 | Logistic Regression | F1=0.434 AUC=0.793 MCC=0.320 PC1 | Proposed (Stacking) | F1=0.383 AUC=0.880 MCC=0.369 PC1 | Random Forest | F1=0.429 AUC=0.869 MCC=0.397 PC1 | XGBoost | F1=0.417 AUC=0.868 MCC=0.384 PC1 | SVM | F1=0.303 AUC=0.838 MCC=0.283 PC1 | Logistic Regression | F1=0.261 AUC=0.814 MCC=0.209 JM1 | Proposed (Stacking) | F1=0.408 AUC=0.806 MCC=0.334 JM1 | Random Forest | F1=0.444 AUC=0.811 MCC=0.358 JM1 | XGBoost | F1=0.359 AUC=0.743 MCC=0.271 JM1 | SVM | F1=0.403 AUC=0.726 MCC=0.268 JM1 | Logistic Regression | F1=0.386 AUC=0.707 MCC=0.248 Done in 946s
Out[10]:
| Dataset | Model | Accuracy | Accuracy_std | Precision | Precision_std | Recall | Recall_std | F1 | F1_std | AUC-ROC | AUC-ROC_std | MCC | MCC_std | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | CM1 | Proposed (Stacking) | 0.8555 | 0.0354 | 0.1786 | 0.1969 | 0.1650 | 0.1761 | 0.1694 | 0.1819 | 0.7243 | 0.1069 | 0.0944 | 0.1939 |
| 1 | CM1 | Random Forest | 0.8414 | 0.0351 | 0.1521 | 0.1726 | 0.2050 | 0.2534 | 0.1724 | 0.1997 | 0.7432 | 0.0980 | 0.0905 | 0.2168 |
| 2 | CM1 | XGBoost | 0.8515 | 0.0201 | 0.2114 | 0.1335 | 0.2500 | 0.1857 | 0.2278 | 0.1549 | 0.7120 | 0.1383 | 0.1485 | 0.1624 |
| 3 | CM1 | SVM | 0.7692 | 0.0444 | 0.2436 | 0.0771 | 0.6300 | 0.2002 | 0.3498 | 0.1074 | 0.7560 | 0.1077 | 0.2840 | 0.1451 |
| 4 | CM1 | Logistic Regression | 0.7492 | 0.0476 | 0.2427 | 0.0666 | 0.7150 | 0.2025 | 0.3583 | 0.0900 | 0.7902 | 0.1014 | 0.3082 | 0.1252 |
| 5 | KC1 | Proposed (Stacking) | 0.8483 | 0.0113 | 0.5189 | 0.0518 | 0.3982 | 0.0711 | 0.4448 | 0.0476 | 0.8036 | 0.0360 | 0.3668 | 0.0422 |
| 6 | KC1 | Random Forest | 0.8260 | 0.0259 | 0.4553 | 0.0748 | 0.4903 | 0.0772 | 0.4657 | 0.0491 | 0.8189 | 0.0362 | 0.3673 | 0.0611 |
| 7 | KC1 | XGBoost | 0.8269 | 0.0207 | 0.4507 | 0.0614 | 0.4384 | 0.0802 | 0.4371 | 0.0446 | 0.7909 | 0.0324 | 0.3403 | 0.0486 |
| 8 | KC1 | SVM | 0.6932 | 0.0324 | 0.3063 | 0.0327 | 0.7665 | 0.0522 | 0.4371 | 0.0381 | 0.7876 | 0.0345 | 0.3321 | 0.0525 |
| 9 | KC1 | Logistic Regression | 0.7198 | 0.0286 | 0.3167 | 0.0327 | 0.6927 | 0.0546 | 0.4341 | 0.0383 | 0.7930 | 0.0333 | 0.3196 | 0.0522 |
| 10 | PC1 | Proposed (Stacking) | 0.9252 | 0.0189 | 0.5195 | 0.2477 | 0.3500 | 0.1631 | 0.3831 | 0.1494 | 0.8804 | 0.0472 | 0.3689 | 0.1513 |
| 11 | PC1 | Random Forest | 0.9189 | 0.0184 | 0.4424 | 0.1572 | 0.4554 | 0.1765 | 0.4293 | 0.1330 | 0.8694 | 0.0657 | 0.3972 | 0.1411 |
| 12 | PC1 | XGBoost | 0.9180 | 0.0218 | 0.4426 | 0.1525 | 0.4286 | 0.1559 | 0.4171 | 0.1242 | 0.8684 | 0.0580 | 0.3839 | 0.1319 |
| 13 | PC1 | SVM | 0.7719 | 0.0407 | 0.1953 | 0.0416 | 0.7036 | 0.1127 | 0.3031 | 0.0525 | 0.8382 | 0.0451 | 0.2829 | 0.0663 |
| 14 | PC1 | Logistic Regression | 0.7989 | 0.0382 | 0.1775 | 0.0701 | 0.5054 | 0.1953 | 0.2608 | 0.0997 | 0.8140 | 0.0693 | 0.2089 | 0.1308 |
| 15 | JM1 | Proposed (Stacking) | 0.8450 | 0.0099 | 0.5204 | 0.0504 | 0.3362 | 0.0371 | 0.4082 | 0.0417 | 0.8060 | 0.0123 | 0.3342 | 0.0472 |
| 16 | JM1 | Random Forest | 0.8432 | 0.0078 | 0.5101 | 0.0331 | 0.3932 | 0.0300 | 0.4438 | 0.0295 | 0.8112 | 0.0129 | 0.3585 | 0.0335 |
| 17 | JM1 | XGBoost | 0.8282 | 0.0088 | 0.4433 | 0.0392 | 0.3024 | 0.0314 | 0.3590 | 0.0325 | 0.7434 | 0.0220 | 0.2707 | 0.0363 |
| 18 | JM1 | SVM | 0.7253 | 0.0096 | 0.3084 | 0.0148 | 0.5839 | 0.0453 | 0.4033 | 0.0220 | 0.7262 | 0.0242 | 0.2679 | 0.0296 |
| 19 | JM1 | Logistic Regression | 0.7495 | 0.0062 | 0.3168 | 0.0132 | 0.4959 | 0.0352 | 0.3864 | 0.0195 | 0.7067 | 0.0219 | 0.2482 | 0.0240 |
6.1 Headline results table (core four datasets, mean over folds)¶
In [11]:
core_df = results_df[results_df["Dataset"].isin(CORE)]
headline = core_df.pivot_table(index=["Dataset", "Model"],
values=list(SCORERS.keys()))
headline = headline.reindex(columns=list(SCORERS.keys()))
headline.to_csv(f"{OUTDIR}/table_headline_core.csv")
headline.round(3)
Out[11]:
| Accuracy | Precision | Recall | F1 | AUC-ROC | MCC | ||
|---|---|---|---|---|---|---|---|
| Dataset | Model | ||||||
| CM1 | Logistic Regression | 0.749 | 0.243 | 0.715 | 0.358 | 0.790 | 0.308 |
| Proposed (Stacking) | 0.856 | 0.179 | 0.165 | 0.169 | 0.724 | 0.094 | |
| Random Forest | 0.841 | 0.152 | 0.205 | 0.172 | 0.743 | 0.090 | |
| SVM | 0.769 | 0.244 | 0.630 | 0.350 | 0.756 | 0.284 | |
| XGBoost | 0.852 | 0.211 | 0.250 | 0.228 | 0.712 | 0.148 | |
| JM1 | Logistic Regression | 0.750 | 0.317 | 0.496 | 0.386 | 0.707 | 0.248 |
| Proposed (Stacking) | 0.845 | 0.520 | 0.336 | 0.408 | 0.806 | 0.334 | |
| Random Forest | 0.843 | 0.510 | 0.393 | 0.444 | 0.811 | 0.358 | |
| SVM | 0.725 | 0.308 | 0.584 | 0.403 | 0.726 | 0.268 | |
| XGBoost | 0.828 | 0.443 | 0.302 | 0.359 | 0.743 | 0.271 | |
| KC1 | Logistic Regression | 0.720 | 0.317 | 0.693 | 0.434 | 0.793 | 0.320 |
| Proposed (Stacking) | 0.848 | 0.519 | 0.398 | 0.445 | 0.804 | 0.367 | |
| Random Forest | 0.826 | 0.455 | 0.490 | 0.466 | 0.819 | 0.367 | |
| SVM | 0.693 | 0.306 | 0.766 | 0.437 | 0.788 | 0.332 | |
| XGBoost | 0.827 | 0.451 | 0.438 | 0.437 | 0.791 | 0.340 | |
| PC1 | Logistic Regression | 0.799 | 0.178 | 0.505 | 0.261 | 0.814 | 0.209 |
| Proposed (Stacking) | 0.925 | 0.520 | 0.350 | 0.383 | 0.880 | 0.369 | |
| Random Forest | 0.919 | 0.442 | 0.455 | 0.429 | 0.869 | 0.397 | |
| SVM | 0.772 | 0.195 | 0.704 | 0.303 | 0.838 | 0.283 | |
| XGBoost | 0.918 | 0.443 | 0.429 | 0.417 | 0.868 | 0.384 |
6.2 Proposed vs best baseline grouped bar chart per dataset¶
In [12]:
metrics_to_plot = ["F1", "AUC-ROC", "MCC"]
for ds in CORE:
sub = results_df[results_df["Dataset"] == ds].set_index("Model")
fig, ax = plt.subplots(figsize=(8, 4.5))
sub[metrics_to_plot].plot(kind="bar", ax=ax, edgecolor="black")
ax.set_title(f"{ds}: proposed ensemble vs baselines")
ax.set_ylabel("Score"); ax.set_ylim(0, 1)
ax.legend(title="Metric", loc="lower right")
plt.xticks(rotation=20, ha="right")
plt.tight_layout()
plt.savefig(f"{OUTDIR}/fig_bars_{ds}.png", bbox_inches="tight")
plt.show()
7. Statistical significance (Wilcoxon signed-rank)¶
In [13]:
sig_rows = []
PROP = "Proposed (Stacking)"
for ds in DATASETS:
for mname in MODELS:
if mname == PROP:
continue
for metric in ["F1", "AUC-ROC", "MCC"]:
a = per_fold[ds][PROP][metric]
b = per_fold[ds][mname][metric]
try:
stat, p = wilcoxon(a, b)
except ValueError:
p = np.nan # happens if all differences are zero
sig_rows.append({
"Dataset": ds, "Baseline": mname, "Metric": metric,
"Proposed_mean": round(float(np.mean(a)), 4),
"Baseline_mean": round(float(np.mean(b)), 4),
"p_value": round(float(p), 4) if p == p else np.nan,
"Significant(<0.05)": ("yes" if (p == p and p < 0.05) else "no"),
})
sig_df = pd.DataFrame(sig_rows)
sig_df.to_csv(f"{OUTDIR}/table_wilcoxon.csv", index=False)
sig_df[sig_df["Dataset"].isin(CORE)]
Out[13]:
| Dataset | Baseline | Metric | Proposed_mean | Baseline_mean | p_value | Significant(<0.05) | |
|---|---|---|---|---|---|---|---|
| 0 | CM1 | Random Forest | F1 | 0.1694 | 0.1724 | 1.0000 | no |
| 1 | CM1 | Random Forest | AUC-ROC | 0.7243 | 0.7432 | 0.0840 | no |
| 2 | CM1 | Random Forest | MCC | 0.0944 | 0.0905 | 0.6406 | no |
| 3 | CM1 | XGBoost | F1 | 0.1694 | 0.2278 | 0.3906 | no |
| 4 | CM1 | XGBoost | AUC-ROC | 0.7243 | 0.7120 | 0.9219 | no |
| 5 | CM1 | XGBoost | MCC | 0.0944 | 0.1485 | 0.4258 | no |
| 6 | CM1 | SVM | F1 | 0.1694 | 0.3498 | 0.0410 | yes |
| 7 | CM1 | SVM | AUC-ROC | 0.7243 | 0.7560 | 0.6250 | no |
| 8 | CM1 | SVM | MCC | 0.0944 | 0.2840 | 0.0645 | no |
| 9 | CM1 | Logistic Regression | F1 | 0.1694 | 0.3583 | 0.0137 | yes |
| 10 | CM1 | Logistic Regression | AUC-ROC | 0.7243 | 0.7902 | 0.0273 | yes |
| 11 | CM1 | Logistic Regression | MCC | 0.0944 | 0.3082 | 0.0098 | yes |
| 12 | KC1 | Random Forest | F1 | 0.4448 | 0.4657 | 0.2754 | no |
| 13 | KC1 | Random Forest | AUC-ROC | 0.8036 | 0.8189 | 0.0020 | yes |
| 14 | KC1 | Random Forest | MCC | 0.3668 | 0.3673 | 1.0000 | no |
| 15 | KC1 | XGBoost | F1 | 0.4448 | 0.4371 | 0.7695 | no |
| 16 | KC1 | XGBoost | AUC-ROC | 0.8036 | 0.7909 | 0.0371 | yes |
| 17 | KC1 | XGBoost | MCC | 0.3668 | 0.3403 | 0.1309 | no |
| 18 | KC1 | SVM | F1 | 0.4448 | 0.4371 | 0.3223 | no |
| 19 | KC1 | SVM | AUC-ROC | 0.8036 | 0.7876 | 0.1934 | no |
| 20 | KC1 | SVM | MCC | 0.3668 | 0.3321 | 0.1055 | no |
| 21 | KC1 | Logistic Regression | F1 | 0.4448 | 0.4341 | 0.4922 | no |
| 22 | KC1 | Logistic Regression | AUC-ROC | 0.8036 | 0.7930 | 0.0840 | no |
| 23 | KC1 | Logistic Regression | MCC | 0.3668 | 0.3196 | 0.0645 | no |
| 24 | PC1 | Random Forest | F1 | 0.3831 | 0.4293 | 0.0781 | no |
| 25 | PC1 | Random Forest | AUC-ROC | 0.8804 | 0.8694 | 0.2324 | no |
| 26 | PC1 | Random Forest | MCC | 0.3689 | 0.3972 | 0.2031 | no |
| 27 | PC1 | XGBoost | F1 | 0.3831 | 0.4171 | 0.3223 | no |
| 28 | PC1 | XGBoost | AUC-ROC | 0.8804 | 0.8684 | 0.2422 | no |
| 29 | PC1 | XGBoost | MCC | 0.3689 | 0.3839 | 0.6250 | no |
| 30 | PC1 | SVM | F1 | 0.3831 | 0.3031 | 0.1602 | no |
| 31 | PC1 | SVM | AUC-ROC | 0.8804 | 0.8382 | 0.0840 | no |
| 32 | PC1 | SVM | MCC | 0.3689 | 0.2829 | 0.1055 | no |
| 33 | PC1 | Logistic Regression | F1 | 0.3831 | 0.2608 | 0.0273 | yes |
| 34 | PC1 | Logistic Regression | AUC-ROC | 0.8804 | 0.8140 | 0.0273 | yes |
| 35 | PC1 | Logistic Regression | MCC | 0.3689 | 0.2089 | 0.0195 | yes |
| 36 | JM1 | Random Forest | F1 | 0.4082 | 0.4438 | 0.0020 | yes |
| 37 | JM1 | Random Forest | AUC-ROC | 0.8060 | 0.8112 | 0.0273 | yes |
| 38 | JM1 | Random Forest | MCC | 0.3342 | 0.3585 | 0.0098 | yes |
| 39 | JM1 | XGBoost | F1 | 0.4082 | 0.3590 | 0.0098 | yes |
| 40 | JM1 | XGBoost | AUC-ROC | 0.8060 | 0.7434 | 0.0020 | yes |
| 41 | JM1 | XGBoost | MCC | 0.3342 | 0.2707 | 0.0059 | yes |
| 42 | JM1 | SVM | F1 | 0.4082 | 0.4033 | 0.6250 | no |
| 43 | JM1 | SVM | AUC-ROC | 0.8060 | 0.7262 | 0.0020 | yes |
| 44 | JM1 | SVM | MCC | 0.3342 | 0.2679 | 0.0059 | yes |
| 45 | JM1 | Logistic Regression | F1 | 0.4082 | 0.3864 | 0.0645 | no |
| 46 | JM1 | Logistic Regression | AUC-ROC | 0.8060 | 0.7067 | 0.0020 | yes |
| 47 | JM1 | Logistic Regression | MCC | 0.3342 | 0.2482 | 0.0039 | yes |
8. Explainability (SHAP global + LIME local)¶
The pipeline is fitted once on a chosen dataset, then SHAP gives the global ranking of the most
influential defect-predictive metrics and LIME explains individual module predictions. To keep
SHAP tractable, it is applied to the fitted Random Forest base learner on the RFE-selected,
resampled feature space (the tree explainer is fast and exact for RF). Change XAI_DATASET to
run the analysis on a different dataset.
In [14]:
import shap
from lime.lime_tabular import LimeTabularExplainer
from sklearn.model_selection import train_test_split
XAI_DATASET = "KC1"
Xx, yx = data[XAI_DATASET]
# Fit the full leak-free pipeline on a train split, explain on it
X_tr, X_te, y_tr, y_te = train_test_split(Xx, yx, test_size=0.3,
stratify=yx, random_state=RANDOM_STATE)
xai_pipe = make_pipeline(RandomForestClassifier(n_estimators=200, random_state=RANDOM_STATE, n_jobs=-1))
xai_pipe.fit(X_tr, y_tr)
# recover the transformed feature space and selected feature names
Xt = X_tr.copy()
Xt = pd.DataFrame(xai_pipe.named_steps["impute"].transform(Xt), columns=Xx.columns)
Xt = pd.DataFrame(xai_pipe.named_steps["scale"].transform(Xt), columns=Xx.columns)
# SMOTE only changes rows, not columns; RFE selects columns:
rfe = xai_pipe.named_steps["rfe"]
selected = Xx.columns[rfe.support_].tolist()
print(f"RFE selected {len(selected)} features on {XAI_DATASET}:")
print(selected)
RFE selected 15 features on KC1: ['loc', 'iv(g)', 'n', 'v', 'l', 'd', 'i', 'e', 'b', 't', 'lOCode', 'lOBlank', 'uniq_Opnd', 'total_Op', 'total_Opnd']
In [15]:
# SHAP global on the fitted RF base learner over the selected feature space
rf_final = xai_pipe.named_steps["model"]
X_tr_sel = Xt[selected]
explainer = shap.TreeExplainer(rf_final)
Xsample = X_tr_sel.sample(min(800, len(X_tr_sel)), random_state=RANDOM_STATE)
sv = explainer.shap_values(Xsample)
# take the positive (defective) class; handle both shap output formats
if isinstance(sv, list):
sv_pos = sv[1]
elif sv.ndim == 3:
sv_pos = sv[:, :, 1]
else:
sv_pos = sv
# bar plot (global importance)
plt.figure(figsize=(8, 6))
shap.summary_plot(sv_pos, Xsample, plot_type="bar", show=False, max_display=15)
plt.title(f"SHAP global feature importance ({XAI_DATASET})")
plt.tight_layout()
plt.savefig(f"{OUTDIR}/fig_shap_bar_{XAI_DATASET}.png", dpi=200, bbox_inches="tight")
plt.show()
# beeswarm (direction of effect)
plt.figure(figsize=(8, 6))
shap.summary_plot(sv_pos, Xsample, show=False, max_display=15)
plt.title(f"SHAP summary beeswarm ({XAI_DATASET})")
plt.tight_layout()
plt.savefig(f"{OUTDIR}/fig_shap_beeswarm_{XAI_DATASET}.png", dpi=200, bbox_inches="tight")
plt.show()
In [ ]:
In [16]:
# LIME local explanation for one defective and one clean module
lime_exp = LimeTabularExplainer(
training_data=X_tr_sel.values,
feature_names=selected,
class_names=["clean", "defective"],
mode="classification", random_state=RANDOM_STATE,
)
# transform the test set the same way, restrict to selected features
X_te_t = pd.DataFrame(xai_pipe.named_steps["impute"].transform(X_te), columns=Xx.columns)
X_te_t = pd.DataFrame(xai_pipe.named_steps["scale"].transform(X_te_t), columns=Xx.columns)[selected]
def explain_instance(idx, tag):
exp = lime_exp.explain_instance(X_te_t.iloc[idx].values, rf_final.predict_proba,
num_features=10)
fig = exp.as_pyplot_figure()
fig.set_size_inches(7, 4)
plt.title(f"LIME local explanation ({XAI_DATASET}, {tag} module)")
plt.tight_layout()
plt.savefig(f"{OUTDIR}/fig_lime_{XAI_DATASET}_{tag}.png", bbox_inches="tight")
plt.show()
# pick a predicted-defective and a predicted-clean instance
preds = rf_final.predict(X_te_t.values)
def_idx = int(np.where(preds == 1)[0][0]) if (preds == 1).any() else 0
cln_idx = int(np.where(preds == 0)[0][0]) if (preds == 0).any() else 1
explain_instance(def_idx, "defective")
explain_instance(cln_idx, "clean")
9. Leak-free vs leaky preprocessing the study's own contribution¶
In [17]:
def evaluate_leaky(model_factory, X, y):
"""WRONG-ON-PURPOSE: preprocess the whole dataset, THEN cross-validate the model alone."""
Xi = SimpleImputer(strategy="median").fit_transform(X)
Xs = MinMaxScaler().fit_transform(Xi)
# SMOTE on everything (leak), then RFE on everything (leak)
Xr, yr = SMOTE(random_state=RANDOM_STATE).fit_resample(Xs, y)
rfe = RFE(RandomForestClassifier(n_estimators=100, random_state=RANDOM_STATE),
n_features_to_select=N_FEATURES).fit(Xr, yr)
Xsel = rfe.transform(Xr)
cv = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=RANDOM_STATE)
res = cross_validate(model_factory(), Xsel, yr, cv=cv, scoring=SCORERS, n_jobs=-1)
return {m: res[f"test_{m}"] for m in SCORERS}
leak_rows = []
for ds in CORE:
X, y = data[ds]
leaky = evaluate_leaky(make_stacking, X, y)
correct = per_fold[ds][PROP]
for m in SCORERS:
leak_rows.append({
"Dataset": ds, "Metric": m,
"Leak-free (correct)": round(float(np.mean(correct[m])), 4),
"Leaky (inflated)": round(float(np.mean(leaky[m])), 4),
"Inflation": round(float(np.mean(leaky[m]) - np.mean(correct[m])), 4),
})
leak_df = pd.DataFrame(leak_rows)
leak_df.to_csv(f"{OUTDIR}/table_leakage_ablation.csv", index=False)
leak_df
Out[17]:
| Dataset | Metric | Leak-free (correct) | Leaky (inflated) | Inflation | |
|---|---|---|---|---|---|
| 0 | CM1 | Accuracy | 0.8555 | 0.9354 | 0.0800 |
| 1 | CM1 | Precision | 0.1786 | 0.9171 | 0.7385 |
| 2 | CM1 | Recall | 0.1650 | 0.9578 | 0.7928 |
| 3 | CM1 | F1 | 0.1694 | 0.9366 | 0.7672 |
| 4 | CM1 | AUC-ROC | 0.7243 | 0.9880 | 0.2636 |
| 5 | CM1 | MCC | 0.0944 | 0.8725 | 0.7781 |
| 6 | KC1 | Accuracy | 0.8483 | 0.9136 | 0.0654 |
| 7 | KC1 | Precision | 0.5189 | 0.9206 | 0.4018 |
| 8 | KC1 | Recall | 0.3982 | 0.9058 | 0.5076 |
| 9 | KC1 | F1 | 0.4448 | 0.9129 | 0.4681 |
| 10 | KC1 | AUC-ROC | 0.8036 | 0.9674 | 0.1638 |
| 11 | KC1 | MCC | 0.3668 | 0.8277 | 0.4609 |
| 12 | PC1 | Accuracy | 0.9252 | 0.9700 | 0.0448 |
| 13 | PC1 | Precision | 0.5195 | 0.9696 | 0.4501 |
| 14 | PC1 | Recall | 0.3500 | 0.9710 | 0.6210 |
| 15 | PC1 | F1 | 0.3831 | 0.9700 | 0.5869 |
| 16 | PC1 | AUC-ROC | 0.8804 | 0.9954 | 0.1150 |
| 17 | PC1 | MCC | 0.3689 | 0.9404 | 0.5715 |
| 18 | JM1 | Accuracy | 0.8450 | 0.9199 | 0.0749 |
| 19 | JM1 | Precision | 0.5204 | 0.9332 | 0.4128 |
| 20 | JM1 | Recall | 0.3362 | 0.9045 | 0.5683 |
| 21 | JM1 | F1 | 0.4082 | 0.9186 | 0.5104 |
| 22 | JM1 | AUC-ROC | 0.8060 | 0.9710 | 0.1650 |
| 23 | JM1 | MCC | 0.3342 | 0.8402 | 0.5060 |
In [18]:
# Visualise the inflation for AUC-ROC and MCC
for metric in ["AUC-ROC", "MCC", "F1"]:
sub = leak_df[leak_df["Metric"] == metric].set_index("Dataset")
fig, ax = plt.subplots(figsize=(8, 4.5))
sub[["Leak-free (correct)", "Leaky (inflated)"]].plot(kind="bar", ax=ax,
color=["#2e7d32", "#c62828"], edgecolor="black")
ax.set_title(f"Effect of data leakage on {metric} (proposed ensemble)")
ax.set_ylabel(metric); ax.set_ylim(0, 1)
plt.xticks(rotation=0)
plt.tight_layout()
plt.savefig(f"{OUTDIR}/fig_leakage_{metric.replace('-', '')}.png", bbox_inches="tight")
plt.show()
10. Export summary¶
In [19]:
produced = sorted(os.listdir(OUTDIR))
print(f"{len(produced)} files written to ./{OUTDIR}/:\n")
for f in produced:
print(" ", f)
# also dump a machine-readable roll-up of all per-fold scores for the appendix / reproducibility
roll = {ds: {m: {k: list(map(float, v)) for k, v in per_fold[ds][m].items()}
for m in per_fold[ds]} for ds in per_fold}
with open(f"{OUTDIR}/per_fold_scores.json", "w") as fh:
json.dump(roll, fh, indent=2)
print("\nAll per-fold scores saved to per_fold_scores.json")
20 files written to ./outputs/: fig_bars_CM1.png fig_bars_JM1.png fig_bars_KC1.png fig_bars_PC1.png fig_class_balance.png fig_correlation_jm1.png fig_leakage_AUCROC.png fig_leakage_F1.png fig_leakage_MCC.png fig_lime_KC1_clean.png fig_lime_KC1_defective.png fig_shap_bar_KC1.png fig_shap_beeswarm_KC1.png per_fold_scores.json table_all_results.csv table_dataset_summary.csv table_headline_core.csv table_jm1_describe.csv table_leakage_ablation.csv table_wilcoxon.csv All per-fold scores saved to per_fold_scores.json