Spaces:
Running
Running
feat: implement automated training diagnostics, dataset bias analysis, and revision figure generation pipelines
Browse files- app/training/ablation_flatness.py +133 -0
- app/training/bias_analysis.py +306 -0
- app/training/build_master_dataset.py +158 -0
- app/training/calibration_analysis.py +202 -0
- app/training/dump_dl_oof.py +72 -0
- app/training/ensemble_model.py +208 -0
- app/training/evaluate.py +16 -0
- app/training/generate_revision_figures.py +274 -0
- app/training/hyperparameter_table.py +125 -0
- app/training/logo_eval.py +203 -0
- app/training/nested_cv.py +237 -0
app/training/ablation_flatness.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Spectral flatness ablation study (reviewer priority #3).
|
| 3 |
+
|
| 4 |
+
The paper reports spectral_flatness_std as the single most important
|
| 5 |
+
LightGBM feature and attributes AI/human separability to it. The reviewer
|
| 6 |
+
asks for a direct ablation: does performance meaningfully depend on this one
|
| 7 |
+
feature, or would the model do just as well without it (in which case the
|
| 8 |
+
"AI music has flatter spectra" narrative should be stated more cautiously)?
|
| 9 |
+
|
| 10 |
+
Three configurations, each evaluated with 5-fold stratified CV + LightGBM:
|
| 11 |
+
(a) all 47 features
|
| 12 |
+
(b) 46 features, spectral_flatness_std removed
|
| 13 |
+
(c) spectral_flatness_std alone (1 feature)
|
| 14 |
+
|
| 15 |
+
Usage:
|
| 16 |
+
python -m app.training.ablation_flatness
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import csv
|
| 22 |
+
import sys
|
| 23 |
+
import warnings
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
|
| 28 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 29 |
+
|
| 30 |
+
import lightgbm as lgb
|
| 31 |
+
from sklearn.exceptions import ConvergenceWarning
|
| 32 |
+
from sklearn.metrics import balanced_accuracy_score, f1_score, roc_auc_score, roc_curve
|
| 33 |
+
from sklearn.model_selection import StratifiedKFold, cross_val_predict
|
| 34 |
+
from sklearn.pipeline import Pipeline
|
| 35 |
+
from sklearn.preprocessing import StandardScaler
|
| 36 |
+
|
| 37 |
+
from app.training.evaluate import load_features_csv
|
| 38 |
+
|
| 39 |
+
FEATURES_CSV = Path("D:/CrownCode/DataSet/features.csv")
|
| 40 |
+
OUTPUT_CSV = Path(__file__).resolve().parents[3] / "docs/academic/paper/real_tables/flatness_ablation.csv"
|
| 41 |
+
|
| 42 |
+
_EXCLUDED_COLUMNS = {"file_path", "label_int", "duration_sec", "sample_rate"}
|
| 43 |
+
TARGET_FEATURE = "spectral_flatness_std"
|
| 44 |
+
|
| 45 |
+
_LGBM_PARAMS = dict(
|
| 46 |
+
n_estimators=300, max_depth=-1, learning_rate=0.05, num_leaves=31,
|
| 47 |
+
subsample=0.8, colsample_bytree=0.8, min_child_samples=20,
|
| 48 |
+
reg_alpha=0.1, reg_lambda=1.0, class_weight="balanced",
|
| 49 |
+
random_state=42, verbose=-1,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _feature_columns() -> list[str]:
|
| 54 |
+
with open(FEATURES_CSV, "r", encoding="utf-8") as f:
|
| 55 |
+
reader = csv.DictReader(f)
|
| 56 |
+
return [c for c in (reader.fieldnames or []) if c not in _EXCLUDED_COLUMNS]
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _optimal_threshold(y_true: np.ndarray, y_prob: np.ndarray) -> float:
|
| 60 |
+
fpr, tpr, thresholds = roc_curve(y_true, y_prob)
|
| 61 |
+
return float(thresholds[np.argmax(tpr - fpr)])
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _evaluate_feature_set(X: np.ndarray, y: np.ndarray, n_folds: int = 5) -> dict:
|
| 65 |
+
cv = StratifiedKFold(n_splits=n_folds, shuffle=True, random_state=42)
|
| 66 |
+
pipeline = Pipeline([
|
| 67 |
+
("scaler", StandardScaler()),
|
| 68 |
+
("model", lgb.LGBMClassifier(**_LGBM_PARAMS)),
|
| 69 |
+
])
|
| 70 |
+
with warnings.catch_warnings():
|
| 71 |
+
warnings.simplefilter("ignore", category=ConvergenceWarning)
|
| 72 |
+
y_prob = cross_val_predict(pipeline, X, y, cv=cv, method="predict_proba")[:, 1]
|
| 73 |
+
|
| 74 |
+
threshold = _optimal_threshold(y, y_prob)
|
| 75 |
+
y_pred = (y_prob >= threshold).astype(int)
|
| 76 |
+
|
| 77 |
+
return {
|
| 78 |
+
"roc_auc": round(float(roc_auc_score(y, y_prob)), 4),
|
| 79 |
+
"f1": round(float(f1_score(y, y_pred, zero_division=0)), 4),
|
| 80 |
+
"balanced_accuracy": round(float(balanced_accuracy_score(y, y_pred)), 4),
|
| 81 |
+
"threshold": round(threshold, 4),
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def run() -> None:
|
| 86 |
+
X_full, y = load_features_csv(FEATURES_CSV)
|
| 87 |
+
X_full = np.nan_to_num(X_full, nan=0.0, posinf=1.0, neginf=-1.0)
|
| 88 |
+
feature_cols = _feature_columns()
|
| 89 |
+
|
| 90 |
+
if TARGET_FEATURE not in feature_cols:
|
| 91 |
+
raise RuntimeError(f"{TARGET_FEATURE} not found in {FEATURES_CSV} columns: {feature_cols}")
|
| 92 |
+
|
| 93 |
+
target_idx = feature_cols.index(TARGET_FEATURE)
|
| 94 |
+
keep_idx = [i for i in range(len(feature_cols)) if i != target_idx]
|
| 95 |
+
|
| 96 |
+
configs = {
|
| 97 |
+
"47_features_all": X_full,
|
| 98 |
+
"46_features_without_flatness_std": X_full[:, keep_idx],
|
| 99 |
+
"1_feature_flatness_std_only": X_full[:, [target_idx]],
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
results = []
|
| 103 |
+
for name, X in configs.items():
|
| 104 |
+
print(f"\nEvaluating: {name} ({X.shape[1]} features)")
|
| 105 |
+
metrics = _evaluate_feature_set(X, y)
|
| 106 |
+
metrics["config"] = name
|
| 107 |
+
metrics["n_features"] = X.shape[1]
|
| 108 |
+
results.append(metrics)
|
| 109 |
+
print(f" AUC={metrics['roc_auc']:.4f} F1={metrics['f1']:.4f} "
|
| 110 |
+
f"BalAcc={metrics['balanced_accuracy']:.4f}")
|
| 111 |
+
|
| 112 |
+
OUTPUT_CSV.parent.mkdir(parents=True, exist_ok=True)
|
| 113 |
+
fieldnames = ["config", "n_features", "roc_auc", "f1", "balanced_accuracy", "threshold"]
|
| 114 |
+
with open(OUTPUT_CSV, "w", newline="", encoding="utf-8") as f:
|
| 115 |
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
| 116 |
+
writer.writeheader()
|
| 117 |
+
for r in results:
|
| 118 |
+
writer.writerow({k: r[k] for k in fieldnames})
|
| 119 |
+
|
| 120 |
+
print(f"\nOutput: {OUTPUT_CSV}")
|
| 121 |
+
|
| 122 |
+
full_auc = next(r["roc_auc"] for r in results if r["config"] == "47_features_all")
|
| 123 |
+
no_flatness_auc = next(r["roc_auc"] for r in results if r["config"] == "46_features_without_flatness_std")
|
| 124 |
+
flatness_only_auc = next(r["roc_auc"] for r in results if r["config"] == "1_feature_flatness_std_only")
|
| 125 |
+
print("\n" + "=" * 60)
|
| 126 |
+
print(f" 47 features: AUC={full_auc:.4f}")
|
| 127 |
+
print(f" 46 features (no flatness): AUC={no_flatness_auc:.4f} (diff={full_auc - no_flatness_auc:+.4f})")
|
| 128 |
+
print(f" flatness_std alone: AUC={flatness_only_auc:.4f}")
|
| 129 |
+
print("=" * 60)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
if __name__ == "__main__":
|
| 133 |
+
run()
|
app/training/bias_analysis.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dataset and genre bias analysis for AURIS (reviewer priority #2), plus a
|
| 3 |
+
segment/track-level leakage check that feeds into the nested-CV / independent
|
| 4 |
+
test split design (reviewer priority #4).
|
| 5 |
+
|
| 6 |
+
Three outputs:
|
| 7 |
+
1. dataset_bias_table.csv — per-source composition (n, class, genre mix,
|
| 8 |
+
duration stats, sample rate) so a reader can
|
| 9 |
+
see whether sources differ in ways unrelated
|
| 10 |
+
to AI-vs-human content.
|
| 11 |
+
2. genre_bias_test.csv — LightGBM performance on the full (genre-
|
| 12 |
+
imbalanced) test set vs. a genre-balanced
|
| 13 |
+
subsample, to check for a genre-as-shortcut
|
| 14 |
+
effect.
|
| 15 |
+
3. segment_leakage_report.txt — whether any track appears to be split
|
| 16 |
+
across multiple audio files (same base name
|
| 17 |
+
with numbered suffixes), which would leak
|
| 18 |
+
information across CV folds.
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
python -m app.training.bias_analysis
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import csv
|
| 27 |
+
import re
|
| 28 |
+
import sys
|
| 29 |
+
import warnings
|
| 30 |
+
from collections import Counter, defaultdict
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
|
| 33 |
+
import numpy as np
|
| 34 |
+
|
| 35 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 36 |
+
|
| 37 |
+
import lightgbm as lgb
|
| 38 |
+
from sklearn.exceptions import ConvergenceWarning
|
| 39 |
+
from sklearn.metrics import balanced_accuracy_score, f1_score, roc_auc_score, roc_curve
|
| 40 |
+
from sklearn.model_selection import train_test_split
|
| 41 |
+
from sklearn.preprocessing import StandardScaler
|
| 42 |
+
|
| 43 |
+
DATASET_DIR = Path("D:/CrownCode/DataSet")
|
| 44 |
+
FEATURES_WITH_META = DATASET_DIR / "features_with_meta.csv"
|
| 45 |
+
TABLES_DIR = Path(__file__).resolve().parents[3] / "docs/academic/paper/real_tables"
|
| 46 |
+
|
| 47 |
+
_EXCLUDED_COLUMNS = {
|
| 48 |
+
"file_path", "label_int", "duration_sec", "sample_rate",
|
| 49 |
+
"genre", "generator", "ai_model", "meta_sample_rate", "meta_duration_sec",
|
| 50 |
+
"match_source",
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
_LGBM_PARAMS = dict(
|
| 54 |
+
n_estimators=300, max_depth=-1, learning_rate=0.05, num_leaves=31,
|
| 55 |
+
subsample=0.8, colsample_bytree=0.8, min_child_samples=20,
|
| 56 |
+
reg_alpha=0.1, reg_lambda=1.0, class_weight="balanced",
|
| 57 |
+
random_state=42, verbose=-1,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _load_rows() -> list[dict]:
|
| 62 |
+
with open(FEATURES_WITH_META, "r", encoding="utf-8") as f:
|
| 63 |
+
return list(csv.DictReader(f))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def dataset_bias_table(rows: list[dict]) -> None:
|
| 67 |
+
"""Per-source composition: n, class, genre mix, duration, sample rate."""
|
| 68 |
+
by_source: dict[str, list[dict]] = defaultdict(list)
|
| 69 |
+
for r in rows:
|
| 70 |
+
by_source[r["generator"]].append(r)
|
| 71 |
+
|
| 72 |
+
out_rows = []
|
| 73 |
+
for source, source_rows in sorted(by_source.items()):
|
| 74 |
+
durations = [float(r["duration_sec"]) for r in source_rows]
|
| 75 |
+
sample_rates = Counter(r["sample_rate"] for r in source_rows)
|
| 76 |
+
genres = Counter(r["genre"] for r in source_rows if r["genre"])
|
| 77 |
+
labels = Counter(r["label_int"] for r in source_rows)
|
| 78 |
+
top_genre = genres.most_common(1)[0] if genres else ("n/a", 0)
|
| 79 |
+
|
| 80 |
+
out_rows.append({
|
| 81 |
+
"source": source,
|
| 82 |
+
"n": len(source_rows),
|
| 83 |
+
"label": "AI" if labels.get("1", 0) > labels.get("0", 0) else "Human",
|
| 84 |
+
"n_ai": labels.get("1", 0),
|
| 85 |
+
"n_human": labels.get("0", 0),
|
| 86 |
+
"n_distinct_genres": len(genres),
|
| 87 |
+
"top_genre": top_genre[0],
|
| 88 |
+
"top_genre_frac": round(top_genre[1] / len(source_rows), 3) if source_rows else 0,
|
| 89 |
+
"duration_mean_sec": round(float(np.mean(durations)), 2),
|
| 90 |
+
"duration_std_sec": round(float(np.std(durations)), 2),
|
| 91 |
+
"duration_min_sec": round(float(np.min(durations)), 2),
|
| 92 |
+
"duration_max_sec": round(float(np.max(durations)), 2),
|
| 93 |
+
"sample_rate_modes": ";".join(f"{sr}={n}" for sr, n in sample_rates.most_common(3)),
|
| 94 |
+
})
|
| 95 |
+
|
| 96 |
+
out_path = TABLES_DIR / "dataset_bias_table.csv"
|
| 97 |
+
with open(out_path, "w", newline="", encoding="utf-8") as f:
|
| 98 |
+
writer = csv.DictWriter(f, fieldnames=list(out_rows[0].keys()))
|
| 99 |
+
writer.writeheader()
|
| 100 |
+
writer.writerows(out_rows)
|
| 101 |
+
|
| 102 |
+
print(f"Dataset bias table written: {out_path}")
|
| 103 |
+
for r in out_rows:
|
| 104 |
+
print(
|
| 105 |
+
f" {r['source']:25s} n={r['n']:5d} label={r['label']:5s} "
|
| 106 |
+
f"dur={r['duration_mean_sec']:.1f}±{r['duration_std_sec']:.1f}s "
|
| 107 |
+
f"genres={r['n_distinct_genres']:2d} top={r['top_genre']}({r['top_genre_frac']})"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def genre_bias_test(rows: list[dict]) -> None:
|
| 112 |
+
"""
|
| 113 |
+
Compare LightGBM performance on the natural (genre-imbalanced) test split
|
| 114 |
+
vs. a genre-balanced test split, to check whether the model is exploiting
|
| 115 |
+
genre as a shortcut for the AI/human label rather than learning
|
| 116 |
+
AI-vs-human acoustic differences.
|
| 117 |
+
"""
|
| 118 |
+
genre_rows = [r for r in rows if r["genre"]]
|
| 119 |
+
print(f"\nRows with genre metadata: {len(genre_rows)} / {len(rows)}")
|
| 120 |
+
|
| 121 |
+
feature_cols = [c for c in rows[0].keys() if c not in _EXCLUDED_COLUMNS]
|
| 122 |
+
|
| 123 |
+
def to_xy(row_subset: list[dict]) -> tuple[np.ndarray, np.ndarray]:
|
| 124 |
+
X = np.array([[float(r[c]) for c in feature_cols] for r in row_subset], dtype=np.float32)
|
| 125 |
+
X = np.nan_to_num(X, nan=0.0, posinf=1.0, neginf=-1.0)
|
| 126 |
+
y = np.array([int(r["label_int"]) for r in row_subset], dtype=np.int32)
|
| 127 |
+
return X, y
|
| 128 |
+
|
| 129 |
+
# Train on everything EXCEPT genre_rows' test portion — use a standard
|
| 130 |
+
# stratified split of genre_rows only, so genre is controlled for on
|
| 131 |
+
# both sides.
|
| 132 |
+
train_rows, test_rows = train_test_split(
|
| 133 |
+
genre_rows, test_size=0.3, stratify=[r["label_int"] for r in genre_rows], random_state=42,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
X_train, y_train = to_xy(train_rows)
|
| 137 |
+
scaler = StandardScaler()
|
| 138 |
+
X_train_scaled = scaler.fit_transform(X_train)
|
| 139 |
+
|
| 140 |
+
model = lgb.LGBMClassifier(**_LGBM_PARAMS)
|
| 141 |
+
with warnings.catch_warnings():
|
| 142 |
+
warnings.simplefilter("ignore", category=ConvergenceWarning)
|
| 143 |
+
model.fit(X_train_scaled, y_train)
|
| 144 |
+
|
| 145 |
+
def eval_subset(subset_rows: list[dict], label: str) -> dict:
|
| 146 |
+
if len(subset_rows) < 10 or len(set(r["label_int"] for r in subset_rows)) < 2:
|
| 147 |
+
return {"subset": label, "n": len(subset_rows), "note": "too small / single class"}
|
| 148 |
+
X, y = to_xy(subset_rows)
|
| 149 |
+
X_scaled = scaler.transform(X)
|
| 150 |
+
y_prob = model.predict_proba(X_scaled)[:, 1]
|
| 151 |
+
fpr, tpr, thr = roc_curve(y, y_prob)
|
| 152 |
+
threshold = float(thr[np.argmax(tpr - fpr)])
|
| 153 |
+
y_pred = (y_prob >= threshold).astype(int)
|
| 154 |
+
return {
|
| 155 |
+
"subset": label,
|
| 156 |
+
"n": len(subset_rows),
|
| 157 |
+
"roc_auc": round(float(roc_auc_score(y, y_prob)), 4),
|
| 158 |
+
"f1": round(float(f1_score(y, y_pred, zero_division=0)), 4),
|
| 159 |
+
"balanced_accuracy": round(float(balanced_accuracy_score(y, y_pred)), 4),
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
results = [eval_subset(test_rows, "natural_test_split")]
|
| 163 |
+
|
| 164 |
+
# Genre-balanced subsample: for each genre, cap AI and human counts to
|
| 165 |
+
# min(n_ai, n_human) in that genre so the AI/human ratio is ~1:1 within
|
| 166 |
+
# every genre — removes genre as a usable shortcut.
|
| 167 |
+
by_genre_label: dict[tuple[str, str], list[dict]] = defaultdict(list)
|
| 168 |
+
for r in test_rows:
|
| 169 |
+
by_genre_label[(r["genre"], r["label_int"])].append(r)
|
| 170 |
+
|
| 171 |
+
balanced_rows: list[dict] = []
|
| 172 |
+
rng = np.random.RandomState(42)
|
| 173 |
+
genres_present = set(g for g, _ in by_genre_label.keys())
|
| 174 |
+
for genre in genres_present:
|
| 175 |
+
ai_rows = by_genre_label.get((genre, "1"), [])
|
| 176 |
+
human_rows = by_genre_label.get((genre, "0"), [])
|
| 177 |
+
cap = min(len(ai_rows), len(human_rows))
|
| 178 |
+
if cap == 0:
|
| 179 |
+
continue
|
| 180 |
+
ai_sample = list(rng.choice(len(ai_rows), size=cap, replace=False))
|
| 181 |
+
human_sample = list(rng.choice(len(human_rows), size=cap, replace=False))
|
| 182 |
+
balanced_rows.extend(ai_rows[i] for i in ai_sample)
|
| 183 |
+
balanced_rows.extend(human_rows[i] for i in human_sample)
|
| 184 |
+
|
| 185 |
+
results.append(eval_subset(balanced_rows, "genre_balanced_test_split"))
|
| 186 |
+
|
| 187 |
+
out_path = TABLES_DIR / "genre_bias_test.csv"
|
| 188 |
+
fieldnames = ["subset", "n", "roc_auc", "f1", "balanced_accuracy", "note"]
|
| 189 |
+
with open(out_path, "w", newline="", encoding="utf-8") as f:
|
| 190 |
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
| 191 |
+
writer.writeheader()
|
| 192 |
+
for r in results:
|
| 193 |
+
writer.writerow({k: r.get(k) for k in fieldnames})
|
| 194 |
+
|
| 195 |
+
print(f"\nGenre bias test written: {out_path}")
|
| 196 |
+
for r in results:
|
| 197 |
+
print(f" {r}")
|
| 198 |
+
|
| 199 |
+
# Per-genre breakdown for the paper's genre table
|
| 200 |
+
per_genre_path = TABLES_DIR / "genre_distribution.csv"
|
| 201 |
+
genre_label_counts = Counter((r["genre"], r["label_int"]) for r in genre_rows)
|
| 202 |
+
genres_sorted = sorted(set(g for g, _ in genre_label_counts.keys()))
|
| 203 |
+
with open(per_genre_path, "w", newline="", encoding="utf-8") as f:
|
| 204 |
+
writer = csv.DictWriter(f, fieldnames=["genre", "n_human", "n_ai", "ai_fraction"])
|
| 205 |
+
writer.writeheader()
|
| 206 |
+
for genre in genres_sorted:
|
| 207 |
+
n_human = genre_label_counts.get((genre, "0"), 0)
|
| 208 |
+
n_ai = genre_label_counts.get((genre, "1"), 0)
|
| 209 |
+
total = n_human + n_ai
|
| 210 |
+
writer.writerow({
|
| 211 |
+
"genre": genre, "n_human": n_human, "n_ai": n_ai,
|
| 212 |
+
"ai_fraction": round(n_ai / total, 3) if total else 0,
|
| 213 |
+
})
|
| 214 |
+
print(f"Genre distribution table written: {per_genre_path}")
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
_SEGMENT_PATTERN = re.compile(
|
| 218 |
+
r"^(?P<base>.+?)[_\-](?:part|seg|segment|chunk|p)[_\-]?0*(?P<idx>\d+)$",
|
| 219 |
+
re.IGNORECASE,
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def segment_leakage_report(rows: list[dict]) -> None:
|
| 224 |
+
"""
|
| 225 |
+
Detect filenames that look like numbered segments of the same source
|
| 226 |
+
track (e.g. track_part1.wav, track_part2.wav) — if such groups exist and
|
| 227 |
+
the current pipeline does group-unaware stratified CV, segments of the
|
| 228 |
+
same track could land in both train and validation folds.
|
| 229 |
+
"""
|
| 230 |
+
stems = []
|
| 231 |
+
for r in rows:
|
| 232 |
+
fname = Path(r["file_path"]).stem
|
| 233 |
+
stems.append(fname)
|
| 234 |
+
|
| 235 |
+
groups: dict[str, list[str]] = defaultdict(list)
|
| 236 |
+
ungrouped = 0
|
| 237 |
+
for stem in stems:
|
| 238 |
+
m = _SEGMENT_PATTERN.match(stem)
|
| 239 |
+
if m:
|
| 240 |
+
groups[m.group("base")].append(stem)
|
| 241 |
+
else:
|
| 242 |
+
ungrouped += 1
|
| 243 |
+
|
| 244 |
+
multi_segment_groups = {k: v for k, v in groups.items() if len(v) > 1}
|
| 245 |
+
|
| 246 |
+
lines = []
|
| 247 |
+
lines.append("Segment/track-level leakage check")
|
| 248 |
+
lines.append("=" * 50)
|
| 249 |
+
lines.append(f"Total files: {len(stems)}")
|
| 250 |
+
lines.append(f"Files matching a segment-numbering pattern (base_part1, base_seg2, ...): "
|
| 251 |
+
f"{sum(len(v) for v in groups.values())}")
|
| 252 |
+
lines.append(f"Distinct base tracks with >1 segment file: {len(multi_segment_groups)}")
|
| 253 |
+
lines.append(f"Files with no recognizable segment suffix: {ungrouped}")
|
| 254 |
+
lines.append("")
|
| 255 |
+
if multi_segment_groups:
|
| 256 |
+
lines.append("Sample multi-segment groups (up to 10):")
|
| 257 |
+
for base, members in list(multi_segment_groups.items())[:10]:
|
| 258 |
+
lines.append(f" {base}: {members}")
|
| 259 |
+
lines.append("")
|
| 260 |
+
lines.append(
|
| 261 |
+
"FINDING: multi-segment groups exist. The current pipeline uses "
|
| 262 |
+
"StandardScaler + StratifiedKFold with no GroupKFold — segments "
|
| 263 |
+
"of the same base track can be split across train/validation, "
|
| 264 |
+
"which would leak track-level information (recording conditions, "
|
| 265 |
+
"mastering, etc.) across the CV boundary. This affects the "
|
| 266 |
+
"independent-test-set design in nested_cv.py, which groups by "
|
| 267 |
+
"base track before splitting."
|
| 268 |
+
)
|
| 269 |
+
else:
|
| 270 |
+
lines.append(
|
| 271 |
+
"FINDING: no filename-based segment groups detected. Filenames "
|
| 272 |
+
"appear to be one-file-per-track (e.g. gtzan_blues_0000.wav), "
|
| 273 |
+
"so standard stratified splitting does not leak track identity "
|
| 274 |
+
"via segment numbering. This does not rule out leakage from "
|
| 275 |
+
"duplicate or near-duplicate source audio uploaded under "
|
| 276 |
+
"different filenames, which is not filename-detectable."
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
out_path = TABLES_DIR / "segment_leakage_report.txt"
|
| 280 |
+
out_path.write_text("\n".join(lines), encoding="utf-8")
|
| 281 |
+
print(f"\nSegment leakage report written: {out_path}")
|
| 282 |
+
print("\n".join(lines))
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def run() -> None:
|
| 286 |
+
rows = _load_rows()
|
| 287 |
+
TABLES_DIR.mkdir(parents=True, exist_ok=True)
|
| 288 |
+
|
| 289 |
+
print("=" * 70)
|
| 290 |
+
print("STEP 1/3 — Dataset bias table (per-source composition)")
|
| 291 |
+
print("=" * 70)
|
| 292 |
+
dataset_bias_table(rows)
|
| 293 |
+
|
| 294 |
+
print("\n" + "=" * 70)
|
| 295 |
+
print("STEP 2/3 — Genre bias test (natural vs. genre-balanced)")
|
| 296 |
+
print("=" * 70)
|
| 297 |
+
genre_bias_test(rows)
|
| 298 |
+
|
| 299 |
+
print("\n" + "=" * 70)
|
| 300 |
+
print("STEP 3/3 — Segment/track leakage check")
|
| 301 |
+
print("=" * 70)
|
| 302 |
+
segment_leakage_report(rows)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
if __name__ == "__main__":
|
| 306 |
+
run()
|
app/training/build_master_dataset.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Joins the raw feature matrix with source/genre/generator metadata.
|
| 3 |
+
|
| 4 |
+
The training pipeline (train_classifier.py) reads DataSet/features.csv, which
|
| 5 |
+
contains only the 47 acoustic features plus file_path/label_int. It has no
|
| 6 |
+
notion of which AI generator or genre a sample came from, so none of the
|
| 7 |
+
reviewer-requested analyses (Leave-One-Generator-Out, dataset/genre bias,
|
| 8 |
+
segment-leakage checks) can be run against it directly.
|
| 9 |
+
|
| 10 |
+
DataSet/metadata.csv carries that provenance (source=generator, genre,
|
| 11 |
+
ai_model) but was produced by a separate pass and only covers a subset of
|
| 12 |
+
files. This script joins the two by filename and writes
|
| 13 |
+
DataSet/features_with_meta.csv, reporting anything that fails to join so the
|
| 14 |
+
gap is visible rather than silently dropped.
|
| 15 |
+
|
| 16 |
+
Usage:
|
| 17 |
+
python -m app.training.build_master_dataset
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import csv
|
| 23 |
+
import re
|
| 24 |
+
import sys
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
DATASET_DIR = Path("D:/CrownCode/DataSet")
|
| 28 |
+
FEATURES_CSV = DATASET_DIR / "features.csv"
|
| 29 |
+
METADATA_CSV = DATASET_DIR / "metadata.csv"
|
| 30 |
+
OUTPUT_CSV = DATASET_DIR / "features_with_meta.csv"
|
| 31 |
+
UNMATCHED_REPORT_CSV = DATASET_DIR / "features_with_meta_unmatched_report.csv"
|
| 32 |
+
LABEL_MISMATCH_CSV = DATASET_DIR / "features_with_meta_label_mismatches.csv"
|
| 33 |
+
|
| 34 |
+
# Files under DataSet/*/ai/echoes_<generator>_*.{wav,mp3} have no row in
|
| 35 |
+
# metadata.csv, but the generator is recoverable from the filename prefix.
|
| 36 |
+
_ECHOES_PREFIX = re.compile(r"^echoes_([a-zA-Z0-9]+)_", re.IGNORECASE)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _filename(path: str) -> str:
|
| 40 |
+
return path.replace("\\", "/").rsplit("/", 1)[-1]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _infer_generator_from_filename(filename: str, label_int: int) -> tuple[str, str] | None:
|
| 44 |
+
"""Best-effort (generator, ai_model) recovery for files absent from metadata.csv."""
|
| 45 |
+
m = _ECHOES_PREFIX.match(filename)
|
| 46 |
+
if m:
|
| 47 |
+
return "echoes", f"echoes_{m.group(1).lower()}"
|
| 48 |
+
if label_int == 0:
|
| 49 |
+
return "archive_org", ""
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def build() -> None:
|
| 54 |
+
with open(FEATURES_CSV, "r", encoding="utf-8") as f:
|
| 55 |
+
reader = csv.DictReader(f)
|
| 56 |
+
feature_fieldnames = reader.fieldnames or []
|
| 57 |
+
feature_rows = list(reader)
|
| 58 |
+
|
| 59 |
+
with open(METADATA_CSV, "r", encoding="utf-8") as f:
|
| 60 |
+
meta_rows = list(csv.DictReader(f))
|
| 61 |
+
|
| 62 |
+
meta_by_filename: dict[str, dict] = {}
|
| 63 |
+
for row in meta_rows:
|
| 64 |
+
meta_by_filename[_filename(row["path"])] = row
|
| 65 |
+
|
| 66 |
+
matched_direct = 0
|
| 67 |
+
matched_inferred = 0
|
| 68 |
+
truly_unmatched: list[dict] = []
|
| 69 |
+
label_mismatches: list[dict] = []
|
| 70 |
+
out_fieldnames = feature_fieldnames + [
|
| 71 |
+
"genre", "generator", "ai_model", "meta_sample_rate", "meta_duration_sec",
|
| 72 |
+
"match_source",
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
with open(OUTPUT_CSV, "w", newline="", encoding="utf-8") as f_out:
|
| 76 |
+
writer = csv.DictWriter(f_out, fieldnames=out_fieldnames)
|
| 77 |
+
writer.writeheader()
|
| 78 |
+
|
| 79 |
+
for row in feature_rows:
|
| 80 |
+
fname = _filename(row["file_path"])
|
| 81 |
+
meta = meta_by_filename.get(fname)
|
| 82 |
+
|
| 83 |
+
if meta is not None:
|
| 84 |
+
meta_label_int = 1 if meta["label"] == "ai" else 0
|
| 85 |
+
if int(row["label_int"]) != meta_label_int:
|
| 86 |
+
mismatch_row = dict(row)
|
| 87 |
+
mismatch_row["features_label_int"] = row["label_int"]
|
| 88 |
+
mismatch_row["metadata_label"] = meta["label"]
|
| 89 |
+
mismatch_row["generator"] = meta["source"]
|
| 90 |
+
mismatch_row["genre"] = meta["genre"]
|
| 91 |
+
label_mismatches.append(mismatch_row)
|
| 92 |
+
# features.csv (the file the training pipeline actually
|
| 93 |
+
# reads) is treated as the ground-truth label; metadata's
|
| 94 |
+
# label disagreement is recorded but does not override it.
|
| 95 |
+
|
| 96 |
+
out_row = dict(row)
|
| 97 |
+
out_row["genre"] = meta["genre"]
|
| 98 |
+
out_row["generator"] = meta["source"]
|
| 99 |
+
out_row["ai_model"] = meta["ai_model"]
|
| 100 |
+
out_row["meta_sample_rate"] = meta["sample_rate"]
|
| 101 |
+
out_row["meta_duration_sec"] = meta["duration_sec"]
|
| 102 |
+
out_row["match_source"] = "metadata_csv"
|
| 103 |
+
writer.writerow(out_row)
|
| 104 |
+
matched_direct += 1
|
| 105 |
+
continue
|
| 106 |
+
|
| 107 |
+
inferred = _infer_generator_from_filename(fname, int(row["label_int"]))
|
| 108 |
+
if inferred is not None:
|
| 109 |
+
generator, ai_model = inferred
|
| 110 |
+
out_row = dict(row)
|
| 111 |
+
out_row["genre"] = ""
|
| 112 |
+
out_row["generator"] = generator
|
| 113 |
+
out_row["ai_model"] = ai_model
|
| 114 |
+
out_row["meta_sample_rate"] = ""
|
| 115 |
+
out_row["meta_duration_sec"] = ""
|
| 116 |
+
out_row["match_source"] = "filename_inferred"
|
| 117 |
+
writer.writerow(out_row)
|
| 118 |
+
matched_inferred += 1
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
truly_unmatched.append(row)
|
| 122 |
+
|
| 123 |
+
if truly_unmatched:
|
| 124 |
+
with open(UNMATCHED_REPORT_CSV, "w", newline="", encoding="utf-8") as f_out:
|
| 125 |
+
writer = csv.DictWriter(f_out, fieldnames=feature_fieldnames)
|
| 126 |
+
writer.writeheader()
|
| 127 |
+
writer.writerows(truly_unmatched)
|
| 128 |
+
|
| 129 |
+
if label_mismatches:
|
| 130 |
+
mismatch_fieldnames = feature_fieldnames + [
|
| 131 |
+
"features_label_int", "metadata_label", "generator", "genre",
|
| 132 |
+
]
|
| 133 |
+
with open(LABEL_MISMATCH_CSV, "w", newline="", encoding="utf-8") as f_out:
|
| 134 |
+
writer = csv.DictWriter(f_out, fieldnames=mismatch_fieldnames)
|
| 135 |
+
writer.writeheader()
|
| 136 |
+
writer.writerows(label_mismatches)
|
| 137 |
+
|
| 138 |
+
total_matched = matched_direct + matched_inferred
|
| 139 |
+
print(f"features.csv rows: {len(feature_rows)}")
|
| 140 |
+
print(f"metadata.csv rows: {len(meta_rows)}")
|
| 141 |
+
print(f"matched via metadata.csv: {matched_direct}")
|
| 142 |
+
print(f"matched via filename inference: {matched_inferred}")
|
| 143 |
+
print(f"total matched: {total_matched} / {len(feature_rows)}")
|
| 144 |
+
print(f"truly unmatched: {len(truly_unmatched)}")
|
| 145 |
+
print(f"label mismatches (features.csv vs metadata.csv label): {len(label_mismatches)}")
|
| 146 |
+
print(f"\nOutput: {OUTPUT_CSV}")
|
| 147 |
+
if truly_unmatched:
|
| 148 |
+
print(f"Unmatched report: {UNMATCHED_REPORT_CSV}")
|
| 149 |
+
if label_mismatches:
|
| 150 |
+
print(f"Label mismatches: {LABEL_MISMATCH_CSV}")
|
| 151 |
+
gens = {}
|
| 152 |
+
for m in label_mismatches:
|
| 153 |
+
gens[m["generator"]] = gens.get(m["generator"], 0) + 1
|
| 154 |
+
print(f" by generator: {gens}")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
if __name__ == "__main__":
|
| 158 |
+
build()
|
app/training/calibration_analysis.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Calibration analysis for LightGBM (reviewer priority — Brier alone is not
|
| 3 |
+
enough evidence of good calibration).
|
| 4 |
+
|
| 5 |
+
Produces:
|
| 6 |
+
1. calibration_diagnostics.csv — ECE, calibration slope, calibration
|
| 7 |
+
intercept, Brier score, alongside the reliability-diagram bins.
|
| 8 |
+
2. extended_metrics_table.csv — Table 3 (all 11 models) extended with
|
| 9 |
+
PR-AUC, Balanced Accuracy, and MCC, computed from the same OOF
|
| 10 |
+
predictions stored in models/training_results.json (no retraining).
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
python -m app.training.calibration_analysis
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import csv
|
| 19 |
+
import json
|
| 20 |
+
import sys
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 26 |
+
|
| 27 |
+
from sklearn.calibration import calibration_curve
|
| 28 |
+
from sklearn.linear_model import LogisticRegression
|
| 29 |
+
from sklearn.metrics import (
|
| 30 |
+
average_precision_score,
|
| 31 |
+
balanced_accuracy_score,
|
| 32 |
+
matthews_corrcoef,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
|
| 36 |
+
TABLES_DIR = Path(__file__).resolve().parents[3] / "docs/academic/paper/real_tables"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _expected_calibration_error(y_true: np.ndarray, y_prob: np.ndarray, n_bins: int = 10) -> float:
|
| 40 |
+
bin_edges = np.linspace(0.0, 1.0, n_bins + 1)
|
| 41 |
+
bin_indices = np.digitize(y_prob, bin_edges[1:-1])
|
| 42 |
+
ece = 0.0
|
| 43 |
+
n = len(y_true)
|
| 44 |
+
for b in range(n_bins):
|
| 45 |
+
mask = bin_indices == b
|
| 46 |
+
if not np.any(mask):
|
| 47 |
+
continue
|
| 48 |
+
bin_conf = float(np.mean(y_prob[mask]))
|
| 49 |
+
bin_acc = float(np.mean(y_true[mask]))
|
| 50 |
+
ece += (np.sum(mask) / n) * abs(bin_conf - bin_acc)
|
| 51 |
+
return ece
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _calibration_slope_intercept(y_true: np.ndarray, y_prob: np.ndarray) -> tuple[float, float]:
|
| 55 |
+
"""Logistic recalibration: y ~ sigmoid(slope * logit(p) + intercept).
|
| 56 |
+
slope=1, intercept=0 is perfect calibration."""
|
| 57 |
+
eps = 1e-6
|
| 58 |
+
p_clipped = np.clip(y_prob, eps, 1 - eps)
|
| 59 |
+
logit_p = np.log(p_clipped / (1 - p_clipped)).reshape(-1, 1)
|
| 60 |
+
lr = LogisticRegression()
|
| 61 |
+
lr.fit(logit_p, y_true)
|
| 62 |
+
slope = float(lr.coef_[0][0])
|
| 63 |
+
intercept = float(lr.intercept_[0])
|
| 64 |
+
return slope, intercept
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def run() -> None:
|
| 68 |
+
results_path = MODELS_DIR / "training_results.json"
|
| 69 |
+
with open(results_path, "r", encoding="utf-8") as f:
|
| 70 |
+
training_results = json.load(f)
|
| 71 |
+
|
| 72 |
+
# ── Extended metrics table (Table 3 + PR-AUC/BalAcc/MCC) ──
|
| 73 |
+
# training_results.json's per-model entries were saved without the
|
| 74 |
+
# y_true/y_pred/y_prob arrays (train_classifier.py strips those before
|
| 75 |
+
# writing JSON) — so PR-AUC/BalAcc/MCC must come from re-running
|
| 76 |
+
# cross_val_predict, matching evaluate_predictions()'s new metrics.
|
| 77 |
+
# Simpler and fully consistent: reuse train_classifier's train() output
|
| 78 |
+
# in-process is out of scope here; instead recompute from the model
|
| 79 |
+
# pickles + features.csv using the same OOF fold logic as ensemble_model.py.
|
| 80 |
+
print("Computing extended metrics (PR-AUC, Balanced Accuracy, MCC) for all 11 models...")
|
| 81 |
+
from sklearn.model_selection import StratifiedKFold
|
| 82 |
+
from sklearn.preprocessing import StandardScaler
|
| 83 |
+
from sklearn.base import clone
|
| 84 |
+
from sklearn.metrics import roc_auc_score, f1_score, accuracy_score, roc_curve
|
| 85 |
+
import pickle
|
| 86 |
+
import warnings
|
| 87 |
+
from sklearn.exceptions import ConvergenceWarning
|
| 88 |
+
|
| 89 |
+
from app.training.evaluate import load_features_csv
|
| 90 |
+
|
| 91 |
+
FEATURES_CSV = Path("D:/CrownCode/DataSet/features.csv")
|
| 92 |
+
DL_OOF_NPZ = MODELS_DIR / "dl_oof_probs.npz"
|
| 93 |
+
X, y = load_features_csv(FEATURES_CSV)
|
| 94 |
+
X = np.nan_to_num(X, nan=0.0, posinf=1.0, neginf=-1.0)
|
| 95 |
+
|
| 96 |
+
ml_files = {
|
| 97 |
+
"Logistic Regression": "model_logistic_regression.pkl",
|
| 98 |
+
"Random Forest": "model_random_forest.pkl",
|
| 99 |
+
"Gradient Boosting": "model_gradient_boosting.pkl",
|
| 100 |
+
"SVM (RBF)": "model_svm_rbf.pkl",
|
| 101 |
+
"MLP Neural Network": "model_mlp_neural_network.pkl",
|
| 102 |
+
"XGBoost": "model_xgboost.pkl",
|
| 103 |
+
"LightGBM": "model_lightgbm.pkl",
|
| 104 |
+
}
|
| 105 |
+
dl_npz_keys = {
|
| 106 |
+
"Deep MLP (512-256-128-64)": "Deep_MLP_512_256_128_64",
|
| 107 |
+
"1D-CNN": "1D_CNN",
|
| 108 |
+
"Residual MLP (3 blocks)": "Residual_MLP_3_blocks",
|
| 109 |
+
"Attention MLP": "Attention_MLP",
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
if not DL_OOF_NPZ.exists():
|
| 113 |
+
raise RuntimeError(f"{DL_OOF_NPZ} not found — run dump_dl_oof.py first.")
|
| 114 |
+
dl_npz = np.load(DL_OOF_NPZ)
|
| 115 |
+
if not np.array_equal(dl_npz["y"], y):
|
| 116 |
+
raise RuntimeError("dl_oof_probs.npz label order does not match features.csv load order.")
|
| 117 |
+
|
| 118 |
+
ml_models_raw = {}
|
| 119 |
+
for name, fname in ml_files.items():
|
| 120 |
+
with open(MODELS_DIR / fname, "rb") as f:
|
| 121 |
+
ml_models_raw[name] = pickle.load(f)
|
| 122 |
+
|
| 123 |
+
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
|
| 124 |
+
fold_assignments = list(cv.split(X, y))
|
| 125 |
+
n = len(y)
|
| 126 |
+
oof_probs: dict[str, np.ndarray] = {name: np.zeros(n) for name in list(ml_files) + list(dl_npz_keys)}
|
| 127 |
+
for name, npz_key in dl_npz_keys.items():
|
| 128 |
+
oof_probs[name] = dl_npz[npz_key]
|
| 129 |
+
|
| 130 |
+
for fold_idx, (train_idx, test_idx) in enumerate(fold_assignments, start=1):
|
| 131 |
+
print(f" Fold {fold_idx}/5 (ML models) ...")
|
| 132 |
+
X_train, y_train = X[train_idx], y[train_idx]
|
| 133 |
+
X_test, y_test = X[test_idx], y[test_idx]
|
| 134 |
+
|
| 135 |
+
scaler = StandardScaler()
|
| 136 |
+
X_train_scaled = scaler.fit_transform(X_train)
|
| 137 |
+
X_test_scaled = scaler.transform(X_test)
|
| 138 |
+
|
| 139 |
+
for name in ml_files:
|
| 140 |
+
model = clone(ml_models_raw[name])
|
| 141 |
+
with warnings.catch_warnings():
|
| 142 |
+
warnings.simplefilter("ignore", category=ConvergenceWarning)
|
| 143 |
+
model.fit(X_train_scaled, y_train)
|
| 144 |
+
oof_probs[name][test_idx] = model.predict_proba(X_test_scaled)[:, 1]
|
| 145 |
+
|
| 146 |
+
extended_rows = []
|
| 147 |
+
for name, probs in oof_probs.items():
|
| 148 |
+
fpr, tpr, thr = roc_curve(y, probs)
|
| 149 |
+
threshold = float(thr[np.argmax(tpr - fpr)])
|
| 150 |
+
y_pred = (probs >= threshold).astype(int)
|
| 151 |
+
extended_rows.append({
|
| 152 |
+
"model": name,
|
| 153 |
+
"roc_auc": round(float(roc_auc_score(y, probs)), 4),
|
| 154 |
+
"pr_auc": round(float(average_precision_score(y, probs)), 4),
|
| 155 |
+
"accuracy": round(float(accuracy_score(y, y_pred)), 4),
|
| 156 |
+
"f1": round(float(f1_score(y, y_pred, zero_division=0)), 4),
|
| 157 |
+
"balanced_accuracy": round(float(balanced_accuracy_score(y, y_pred)), 4),
|
| 158 |
+
"mcc": round(float(matthews_corrcoef(y, y_pred)), 4),
|
| 159 |
+
"threshold": round(threshold, 4),
|
| 160 |
+
})
|
| 161 |
+
|
| 162 |
+
extended_rows.sort(key=lambda r: -r["roc_auc"])
|
| 163 |
+
TABLES_DIR.mkdir(parents=True, exist_ok=True)
|
| 164 |
+
ext_path = TABLES_DIR / "extended_metrics_table.csv"
|
| 165 |
+
with open(ext_path, "w", newline="", encoding="utf-8") as f:
|
| 166 |
+
writer = csv.DictWriter(f, fieldnames=list(extended_rows[0].keys()))
|
| 167 |
+
writer.writeheader()
|
| 168 |
+
writer.writerows(extended_rows)
|
| 169 |
+
print(f"\nExtended metrics table written: {ext_path}")
|
| 170 |
+
for r in extended_rows:
|
| 171 |
+
print(f" {r['model']:28s} AUC={r['roc_auc']:.4f} PR-AUC={r['pr_auc']:.4f} "
|
| 172 |
+
f"BalAcc={r['balanced_accuracy']:.4f} MCC={r['mcc']:.4f}")
|
| 173 |
+
|
| 174 |
+
# ── Calibration diagnostics for LightGBM specifically ──
|
| 175 |
+
lgbm_probs = oof_probs["LightGBM"]
|
| 176 |
+
prob_true, prob_pred = calibration_curve(y, lgbm_probs, n_bins=10, strategy="uniform")
|
| 177 |
+
ece = _expected_calibration_error(y, lgbm_probs, n_bins=10)
|
| 178 |
+
slope, intercept = _calibration_slope_intercept(y, lgbm_probs)
|
| 179 |
+
brier = float(np.mean((lgbm_probs - y) ** 2))
|
| 180 |
+
|
| 181 |
+
calib_path = TABLES_DIR / "calibration_diagnostics.csv"
|
| 182 |
+
with open(calib_path, "w", newline="", encoding="utf-8") as f:
|
| 183 |
+
writer = csv.writer(f)
|
| 184 |
+
writer.writerow(["metric", "value"])
|
| 185 |
+
writer.writerow(["brier_score", round(brier, 4)])
|
| 186 |
+
writer.writerow(["ece_10bin", round(ece, 4)])
|
| 187 |
+
writer.writerow(["calibration_slope", round(slope, 4)])
|
| 188 |
+
writer.writerow(["calibration_intercept", round(intercept, 4)])
|
| 189 |
+
writer.writerow([])
|
| 190 |
+
writer.writerow(["bin_mean_predicted", "bin_fraction_positive"])
|
| 191 |
+
for pp, pt in zip(prob_pred, prob_true):
|
| 192 |
+
writer.writerow([round(float(pp), 4), round(float(pt), 4)])
|
| 193 |
+
|
| 194 |
+
print(f"\nCalibration diagnostics written: {calib_path}")
|
| 195 |
+
print(f" Brier score: {brier:.4f}")
|
| 196 |
+
print(f" ECE (10-bin): {ece:.4f}")
|
| 197 |
+
print(f" Calibration slope: {slope:.4f} (1.0 = perfect)")
|
| 198 |
+
print(f" Calibration intercept: {intercept:.4f} (0.0 = perfect)")
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
if __name__ == "__main__":
|
| 202 |
+
run()
|
app/training/dump_dl_oof.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Minimal script: run the DL 5-fold CV exactly as train_deep_classifiers.py
|
| 3 |
+
does, but also save the raw out-of-fold probability array per model to
|
| 4 |
+
models/dl_oof_probs.npz, so ensemble_model.py can build a real ensemble
|
| 5 |
+
without retraining DL models a second time.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python -m app.training.dump_dl_oof
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import sys
|
| 14 |
+
import time
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 20 |
+
|
| 21 |
+
from sklearn.metrics import roc_auc_score
|
| 22 |
+
from sklearn.model_selection import StratifiedKFold
|
| 23 |
+
|
| 24 |
+
from app.training.train_deep_classifiers import (
|
| 25 |
+
DeepMLP, Conv1DClassifier, ResidualMLP, AttentionMLP,
|
| 26 |
+
load_data, train_one_fold, set_seed, SEED, N_FOLDS, DEVICE,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
|
| 30 |
+
FEATURES_CSV = Path("D:/CrownCode/DataSet/features.csv")
|
| 31 |
+
|
| 32 |
+
MODEL_CLASSES = {
|
| 33 |
+
"Deep MLP (512-256-128-64)": DeepMLP,
|
| 34 |
+
"1D-CNN": Conv1DClassifier,
|
| 35 |
+
"Residual MLP (3 blocks)": ResidualMLP,
|
| 36 |
+
"Attention MLP": AttentionMLP,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def main() -> None:
|
| 41 |
+
print(f"Device: {DEVICE}", flush=True)
|
| 42 |
+
X, y, feature_cols = load_data(FEATURES_CSV)
|
| 43 |
+
print(f"Samples: {len(y)}, Features: {X.shape[1]}", flush=True)
|
| 44 |
+
|
| 45 |
+
cv = StratifiedKFold(n_splits=N_FOLDS, shuffle=True, random_state=SEED)
|
| 46 |
+
fold_assignments = list(cv.split(X, y))
|
| 47 |
+
|
| 48 |
+
oof: dict[str, np.ndarray] = {}
|
| 49 |
+
for name, cls in MODEL_CLASSES.items():
|
| 50 |
+
print(f"\n{'='*60}\n {name}\n{'='*60}", flush=True)
|
| 51 |
+
t0 = time.time()
|
| 52 |
+
all_probs = np.zeros(len(y))
|
| 53 |
+
for fold, (train_idx, val_idx) in enumerate(fold_assignments):
|
| 54 |
+
set_seed(SEED + fold)
|
| 55 |
+
model = cls(X.shape[1])
|
| 56 |
+
fold_t0 = time.time()
|
| 57 |
+
auc, probs = train_one_fold(model, X[train_idx], y[train_idx], X[val_idx], y[val_idx])
|
| 58 |
+
all_probs[val_idx] = probs
|
| 59 |
+
print(f" Fold {fold+1}/{N_FOLDS}: AUC={auc:.4f} ({time.time()-fold_t0:.1f}s)", flush=True)
|
| 60 |
+
oof[name] = all_probs
|
| 61 |
+
total_auc = roc_auc_score(y, all_probs)
|
| 62 |
+
print(f" => Overall OOF AUC={total_auc:.4f} ({time.time()-t0:.1f}s total)", flush=True)
|
| 63 |
+
|
| 64 |
+
out_path = MODELS_DIR / "dl_oof_probs.npz"
|
| 65 |
+
np.savez(out_path, y=y, **{name.replace(" ", "_").replace("(", "").replace(")", "").replace("-", "_"): probs
|
| 66 |
+
for name, probs in oof.items()})
|
| 67 |
+
print(f"\nSaved: {out_path}", flush=True)
|
| 68 |
+
print("Keys:", list(np.load(out_path).keys()))
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
if __name__ == "__main__":
|
| 72 |
+
main()
|
app/training/ensemble_model.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Real ensemble construction (reviewer priority #5).
|
| 3 |
+
|
| 4 |
+
The paper's title claims "Ensemble Learning" but the reported result is a
|
| 5 |
+
single best model (LightGBM) chosen from eleven independently-trained
|
| 6 |
+
candidates — no prediction combination happens. The reviewer explicitly asks
|
| 7 |
+
that this either be fixed (build an actual ensemble and report its score
|
| 8 |
+
against LightGBM) or the terminology be revised.
|
| 9 |
+
|
| 10 |
+
This script loads the saved ML models (models/model_*.pkl, scaled input) and
|
| 11 |
+
DL models (models/model_dl_*.pkl, TorchSklearnWrapper — raw input, wrapper
|
| 12 |
+
scales internally), gets out-of-fold-style predictions via a fresh 5-fold CV
|
| 13 |
+
using the SAME fold assignment as train_classifier.py (StratifiedKFold,
|
| 14 |
+
random_state=42), and combines them two ways:
|
| 15 |
+
- soft voting (unweighted mean of probabilities)
|
| 16 |
+
- stacking (logistic regression meta-learner on the base probabilities)
|
| 17 |
+
|
| 18 |
+
Both are compared against the best single model (LightGBM) on identical
|
| 19 |
+
folds, so the comparison is apples-to-apples.
|
| 20 |
+
|
| 21 |
+
Usage:
|
| 22 |
+
python -m app.training.ensemble_model
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import csv
|
| 28 |
+
import sys
|
| 29 |
+
import warnings
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
|
| 32 |
+
import numpy as np
|
| 33 |
+
|
| 34 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 35 |
+
|
| 36 |
+
import pickle
|
| 37 |
+
from sklearn.exceptions import ConvergenceWarning
|
| 38 |
+
from sklearn.linear_model import LogisticRegression
|
| 39 |
+
from sklearn.metrics import (
|
| 40 |
+
accuracy_score,
|
| 41 |
+
balanced_accuracy_score,
|
| 42 |
+
f1_score,
|
| 43 |
+
matthews_corrcoef,
|
| 44 |
+
roc_auc_score,
|
| 45 |
+
roc_curve,
|
| 46 |
+
)
|
| 47 |
+
from sklearn.model_selection import StratifiedKFold
|
| 48 |
+
from sklearn.preprocessing import StandardScaler
|
| 49 |
+
|
| 50 |
+
from app.training.evaluate import load_features_csv
|
| 51 |
+
|
| 52 |
+
FEATURES_CSV = Path("D:/CrownCode/DataSet/features.csv")
|
| 53 |
+
MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
|
| 54 |
+
TABLES_DIR = Path(__file__).resolve().parents[3] / "docs/academic/paper/real_tables"
|
| 55 |
+
DL_OOF_NPZ = MODELS_DIR / "dl_oof_probs.npz"
|
| 56 |
+
|
| 57 |
+
# DL out-of-fold probabilities come from dump_dl_oof.py, which retrains each
|
| 58 |
+
# of the 4 architectures per fold with the SAME StratifiedKFold(random_state
|
| 59 |
+
# =42) split used below — so they are genuinely held-out and directly
|
| 60 |
+
# comparable to the ML models' per-fold-refit predictions.
|
| 61 |
+
_DL_NPZ_KEYS = {
|
| 62 |
+
"Deep MLP": "Deep_MLP_512_256_128_64",
|
| 63 |
+
"1D-CNN": "1D_CNN",
|
| 64 |
+
"Residual MLP": "Residual_MLP_3_blocks",
|
| 65 |
+
"Attention MLP": "Attention_MLP",
|
| 66 |
+
}
|
| 67 |
+
_ML_MODEL_FILES = {
|
| 68 |
+
"Logistic Regression": "model_logistic_regression.pkl",
|
| 69 |
+
"Random Forest": "model_random_forest.pkl",
|
| 70 |
+
"Gradient Boosting": "model_gradient_boosting.pkl",
|
| 71 |
+
"SVM (RBF)": "model_svm_rbf.pkl",
|
| 72 |
+
"MLP Neural Network": "model_mlp_neural_network.pkl",
|
| 73 |
+
"XGBoost": "model_xgboost.pkl",
|
| 74 |
+
"LightGBM": "model_lightgbm.pkl",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _load_model(filename: str):
|
| 79 |
+
with open(MODELS_DIR / filename, "rb") as f:
|
| 80 |
+
return pickle.load(f)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _optimal_threshold(y_true: np.ndarray, y_prob: np.ndarray) -> float:
|
| 84 |
+
fpr, tpr, thresholds = roc_curve(y_true, y_prob)
|
| 85 |
+
return float(thresholds[np.argmax(tpr - fpr)])
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _metrics(y_true: np.ndarray, y_prob: np.ndarray) -> dict:
|
| 89 |
+
threshold = _optimal_threshold(y_true, y_prob)
|
| 90 |
+
y_pred = (y_prob >= threshold).astype(int)
|
| 91 |
+
return {
|
| 92 |
+
"roc_auc": round(float(roc_auc_score(y_true, y_prob)), 4),
|
| 93 |
+
"accuracy": round(float(accuracy_score(y_true, y_pred)), 4),
|
| 94 |
+
"f1": round(float(f1_score(y_true, y_pred, zero_division=0)), 4),
|
| 95 |
+
"balanced_accuracy": round(float(balanced_accuracy_score(y_true, y_pred)), 4),
|
| 96 |
+
"mcc": round(float(matthews_corrcoef(y_true, y_pred)), 4),
|
| 97 |
+
"threshold": round(threshold, 4),
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def run() -> None:
|
| 102 |
+
X, y = load_features_csv(FEATURES_CSV)
|
| 103 |
+
X = np.nan_to_num(X, nan=0.0, posinf=1.0, neginf=-1.0)
|
| 104 |
+
|
| 105 |
+
if not DL_OOF_NPZ.exists():
|
| 106 |
+
raise RuntimeError(
|
| 107 |
+
f"{DL_OOF_NPZ} not found — run `python -m app.training.dump_dl_oof` first "
|
| 108 |
+
"to generate genuinely held-out DL predictions."
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
print("ML models are refit per fold below (fast). DL out-of-fold predictions")
|
| 112 |
+
print(f"are loaded from {DL_OOF_NPZ.name} (produced by dump_dl_oof.py, which")
|
| 113 |
+
print("retrains each DL architecture per fold on the identical fold split).\n")
|
| 114 |
+
|
| 115 |
+
ml_models_raw = {name: _load_model(fname) for name, fname in _ML_MODEL_FILES.items()}
|
| 116 |
+
all_names = list(_ML_MODEL_FILES.keys()) + list(_DL_NPZ_KEYS.keys())
|
| 117 |
+
|
| 118 |
+
dl_npz = np.load(DL_OOF_NPZ)
|
| 119 |
+
y_npz = dl_npz["y"]
|
| 120 |
+
|
| 121 |
+
# Uses the SAME StratifiedKFold(random_state=42) as train_classifier.py's
|
| 122 |
+
# 5-fold CV and the SAME SEED=42 as train_deep_classifiers.py / dump_dl_oof.py,
|
| 123 |
+
# so this fold assignment matches the folds the DL OOF array was computed on.
|
| 124 |
+
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
|
| 125 |
+
fold_assignments = list(cv.split(X, y))
|
| 126 |
+
|
| 127 |
+
n = len(y)
|
| 128 |
+
if not np.array_equal(y_npz, y):
|
| 129 |
+
raise RuntimeError(
|
| 130 |
+
"Label array in dl_oof_probs.npz does not match the current "
|
| 131 |
+
"features.csv load order — DL OOF predictions would be "
|
| 132 |
+
"misaligned with ML predictions. Re-run dump_dl_oof.py."
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
oof_probs = {name: np.zeros(n) for name in all_names}
|
| 136 |
+
for name, npz_key in _DL_NPZ_KEYS.items():
|
| 137 |
+
oof_probs[name] = dl_npz[npz_key]
|
| 138 |
+
|
| 139 |
+
for fold_idx, (train_idx, test_idx) in enumerate(fold_assignments, start=1):
|
| 140 |
+
print(f"Fold {fold_idx}/5 (ML models) ...")
|
| 141 |
+
X_train, y_train = X[train_idx], y[train_idx]
|
| 142 |
+
X_test, y_test = X[test_idx], y[test_idx]
|
| 143 |
+
|
| 144 |
+
scaler = StandardScaler()
|
| 145 |
+
X_train_scaled = scaler.fit_transform(X_train)
|
| 146 |
+
X_test_scaled = scaler.transform(X_test)
|
| 147 |
+
|
| 148 |
+
from sklearn.base import clone
|
| 149 |
+
for name, fname in _ML_MODEL_FILES.items():
|
| 150 |
+
base_model = ml_models_raw[name]
|
| 151 |
+
model = clone(base_model)
|
| 152 |
+
with warnings.catch_warnings():
|
| 153 |
+
warnings.simplefilter("ignore", category=ConvergenceWarning)
|
| 154 |
+
model.fit(X_train_scaled, y_train)
|
| 155 |
+
oof_probs[name][test_idx] = model.predict_proba(X_test_scaled)[:, 1]
|
| 156 |
+
|
| 157 |
+
print("\nPer-model OOF metrics (all models refit per fold, held-out predictions):")
|
| 158 |
+
per_model_results = []
|
| 159 |
+
for name in all_names:
|
| 160 |
+
m = _metrics(y, oof_probs[name])
|
| 161 |
+
m["model"] = name
|
| 162 |
+
per_model_results.append(m)
|
| 163 |
+
print(f" {name:25s} AUC={m['roc_auc']:.4f} F1={m['f1']:.4f}")
|
| 164 |
+
|
| 165 |
+
# ── Soft voting: unweighted mean of all 11 base-model probabilities ──
|
| 166 |
+
prob_matrix = np.column_stack([oof_probs[name] for name in all_names])
|
| 167 |
+
soft_vote_prob = prob_matrix.mean(axis=1)
|
| 168 |
+
soft_vote_metrics = _metrics(y, soft_vote_prob)
|
| 169 |
+
soft_vote_metrics["model"] = "Ensemble (soft voting, 11 models)"
|
| 170 |
+
print(f"\n {'Ensemble (soft voting)':25s} AUC={soft_vote_metrics['roc_auc']:.4f} "
|
| 171 |
+
f"F1={soft_vote_metrics['f1']:.4f}")
|
| 172 |
+
|
| 173 |
+
# ── Stacking: logistic regression meta-learner, 5-fold on base OOF probs ──
|
| 174 |
+
meta_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=7)
|
| 175 |
+
stack_prob = np.zeros(n)
|
| 176 |
+
for train_idx, test_idx in meta_cv.split(prob_matrix, y):
|
| 177 |
+
meta = LogisticRegression(max_iter=1000)
|
| 178 |
+
meta.fit(prob_matrix[train_idx], y[train_idx])
|
| 179 |
+
stack_prob[test_idx] = meta.predict_proba(prob_matrix[test_idx])[:, 1]
|
| 180 |
+
stack_metrics = _metrics(y, stack_prob)
|
| 181 |
+
stack_metrics["model"] = "Ensemble (stacking, LR meta-learner)"
|
| 182 |
+
print(f" {'Ensemble (stacking)':25s} AUC={stack_metrics['roc_auc']:.4f} "
|
| 183 |
+
f"F1={stack_metrics['f1']:.4f}")
|
| 184 |
+
|
| 185 |
+
best_single = max(per_model_results, key=lambda r: r["roc_auc"])
|
| 186 |
+
print("\n" + "=" * 70)
|
| 187 |
+
print(f" Best single model: {best_single['model']} AUC={best_single['roc_auc']:.4f}")
|
| 188 |
+
print(f" Ensemble (soft voting): AUC={soft_vote_metrics['roc_auc']:.4f} "
|
| 189 |
+
f"(diff={soft_vote_metrics['roc_auc'] - best_single['roc_auc']:+.4f})")
|
| 190 |
+
print(f" Ensemble (stacking): AUC={stack_metrics['roc_auc']:.4f} "
|
| 191 |
+
f"(diff={stack_metrics['roc_auc'] - best_single['roc_auc']:+.4f})")
|
| 192 |
+
print("=" * 70)
|
| 193 |
+
|
| 194 |
+
TABLES_DIR.mkdir(parents=True, exist_ok=True)
|
| 195 |
+
out_path = TABLES_DIR / "ensemble_comparison.csv"
|
| 196 |
+
fieldnames = ["model", "roc_auc", "accuracy", "f1", "balanced_accuracy", "mcc", "threshold"]
|
| 197 |
+
all_results = per_model_results + [soft_vote_metrics, stack_metrics]
|
| 198 |
+
with open(out_path, "w", newline="", encoding="utf-8") as f:
|
| 199 |
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
| 200 |
+
writer.writeheader()
|
| 201 |
+
for r in sorted(all_results, key=lambda r: -r["roc_auc"]):
|
| 202 |
+
writer.writerow({k: r[k] for k in fieldnames})
|
| 203 |
+
|
| 204 |
+
print(f"\nOutput: {out_path}")
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
if __name__ == "__main__":
|
| 208 |
+
run()
|
app/training/evaluate.py
CHANGED
|
@@ -24,6 +24,9 @@ try:
|
|
| 24 |
recall_score,
|
| 25 |
f1_score,
|
| 26 |
roc_auc_score,
|
|
|
|
|
|
|
|
|
|
| 27 |
confusion_matrix,
|
| 28 |
classification_report,
|
| 29 |
)
|
|
@@ -92,12 +95,16 @@ def evaluate_predictions(
|
|
| 92 |
prec = precision_score(y_true, y_pred, zero_division=0)
|
| 93 |
rec = recall_score(y_true, y_pred, zero_division=0)
|
| 94 |
f1 = f1_score(y_true, y_pred, zero_division=0)
|
|
|
|
|
|
|
| 95 |
|
| 96 |
metrics = {
|
| 97 |
"accuracy": round(acc, 4),
|
| 98 |
"precision": round(prec, 4),
|
| 99 |
"recall": round(rec, 4),
|
| 100 |
"f1_score": round(f1, 4),
|
|
|
|
|
|
|
| 101 |
}
|
| 102 |
|
| 103 |
if y_prob is not None:
|
|
@@ -106,6 +113,11 @@ def evaluate_predictions(
|
|
| 106 |
metrics["roc_auc"] = round(auc, 4)
|
| 107 |
except ValueError:
|
| 108 |
metrics["roc_auc"] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
cm = confusion_matrix(y_true, y_pred)
|
| 111 |
|
|
@@ -117,8 +129,12 @@ def evaluate_predictions(
|
|
| 117 |
print(f" Precision: {prec:.4f}")
|
| 118 |
print(f" Recall: {rec:.4f}")
|
| 119 |
print(f" F1 Score: {f1:.4f}")
|
|
|
|
|
|
|
| 120 |
if "roc_auc" in metrics and metrics["roc_auc"] is not None:
|
| 121 |
print(f" ROC-AUC: {metrics['roc_auc']:.4f}")
|
|
|
|
|
|
|
| 122 |
|
| 123 |
print(f"\n Confusion Matrix:")
|
| 124 |
print(f" Predicted")
|
|
|
|
| 24 |
recall_score,
|
| 25 |
f1_score,
|
| 26 |
roc_auc_score,
|
| 27 |
+
average_precision_score,
|
| 28 |
+
balanced_accuracy_score,
|
| 29 |
+
matthews_corrcoef,
|
| 30 |
confusion_matrix,
|
| 31 |
classification_report,
|
| 32 |
)
|
|
|
|
| 95 |
prec = precision_score(y_true, y_pred, zero_division=0)
|
| 96 |
rec = recall_score(y_true, y_pred, zero_division=0)
|
| 97 |
f1 = f1_score(y_true, y_pred, zero_division=0)
|
| 98 |
+
bal_acc = balanced_accuracy_score(y_true, y_pred)
|
| 99 |
+
mcc = matthews_corrcoef(y_true, y_pred) if len(set(y_pred.tolist())) > 1 else 0.0
|
| 100 |
|
| 101 |
metrics = {
|
| 102 |
"accuracy": round(acc, 4),
|
| 103 |
"precision": round(prec, 4),
|
| 104 |
"recall": round(rec, 4),
|
| 105 |
"f1_score": round(f1, 4),
|
| 106 |
+
"balanced_accuracy": round(bal_acc, 4),
|
| 107 |
+
"mcc": round(mcc, 4),
|
| 108 |
}
|
| 109 |
|
| 110 |
if y_prob is not None:
|
|
|
|
| 113 |
metrics["roc_auc"] = round(auc, 4)
|
| 114 |
except ValueError:
|
| 115 |
metrics["roc_auc"] = None
|
| 116 |
+
try:
|
| 117 |
+
pr_auc = average_precision_score(y_true, y_prob)
|
| 118 |
+
metrics["pr_auc"] = round(pr_auc, 4)
|
| 119 |
+
except ValueError:
|
| 120 |
+
metrics["pr_auc"] = None
|
| 121 |
|
| 122 |
cm = confusion_matrix(y_true, y_pred)
|
| 123 |
|
|
|
|
| 129 |
print(f" Precision: {prec:.4f}")
|
| 130 |
print(f" Recall: {rec:.4f}")
|
| 131 |
print(f" F1 Score: {f1:.4f}")
|
| 132 |
+
print(f" Balanced Accuracy: {bal_acc:.4f}")
|
| 133 |
+
print(f" MCC: {mcc:.4f}")
|
| 134 |
if "roc_auc" in metrics and metrics["roc_auc"] is not None:
|
| 135 |
print(f" ROC-AUC: {metrics['roc_auc']:.4f}")
|
| 136 |
+
if "pr_auc" in metrics and metrics["pr_auc"] is not None:
|
| 137 |
+
print(f" PR-AUC: {metrics['pr_auc']:.4f}")
|
| 138 |
|
| 139 |
print(f"\n Confusion Matrix:")
|
| 140 |
print(f" Predicted")
|
app/training/generate_revision_figures.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reviewer-revision experiments için TÜRKÇE ve makalenin mevcut görsel stiliyle
|
| 3 |
+
BİREBİR uyumlu figürler üretir.
|
| 4 |
+
|
| 5 |
+
Stil kaynak: docs/academic/paper/regenerate_tr_figures.py — aynı renk
|
| 6 |
+
paleti (GOLD/HUMAN/AIRED), aynı font (Times New Roman), aynı saydam arka
|
| 7 |
+
plan (BG="none", savefig.transparent=True), aynı grid.alpha=0.15.
|
| 8 |
+
|
| 9 |
+
Kullanım:
|
| 10 |
+
python -m app.training.generate_revision_figures
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import csv
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
import matplotlib
|
| 23 |
+
matplotlib.use("Agg")
|
| 24 |
+
import matplotlib.pyplot as plt
|
| 25 |
+
except ImportError:
|
| 26 |
+
print("matplotlib gerekli")
|
| 27 |
+
sys.exit(1)
|
| 28 |
+
|
| 29 |
+
TABLES = Path(__file__).resolve().parents[3] / "docs/academic/paper/real_tables"
|
| 30 |
+
OUT = Path(__file__).resolve().parents[3] / "docs/academic/figures"
|
| 31 |
+
OUT.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
|
| 33 |
+
# ── Stil — regenerate_tr_figures.py ile birebir aynı ──
|
| 34 |
+
GOLD = "#C99347"
|
| 35 |
+
BG = "none"
|
| 36 |
+
HUMAN = "#3cb44b"
|
| 37 |
+
AIRED = "#e6194b"
|
| 38 |
+
BLUE = "#4363d8"
|
| 39 |
+
ORANGE = "#f58231"
|
| 40 |
+
PURPLE = "#911eb4"
|
| 41 |
+
CYAN = "#42d4f4"
|
| 42 |
+
BROWN = "#a64b3c"
|
| 43 |
+
DARKGOLD = "#6b4a1e"
|
| 44 |
+
|
| 45 |
+
plt.rcParams.update({
|
| 46 |
+
"font.family": "Times New Roman",
|
| 47 |
+
"font.size": 10,
|
| 48 |
+
"font.weight": "normal",
|
| 49 |
+
"axes.labelweight": "normal",
|
| 50 |
+
"axes.titleweight": "normal",
|
| 51 |
+
"figure.dpi": 150,
|
| 52 |
+
"savefig.dpi": 300,
|
| 53 |
+
"savefig.bbox": "tight",
|
| 54 |
+
"savefig.transparent": True,
|
| 55 |
+
"axes.grid": True,
|
| 56 |
+
"grid.alpha": 0.15,
|
| 57 |
+
"grid.linewidth": 0.5,
|
| 58 |
+
"axes.spines.top": False,
|
| 59 |
+
"axes.spines.right": False,
|
| 60 |
+
"axes.edgecolor": "black",
|
| 61 |
+
"text.color": "black",
|
| 62 |
+
"axes.labelcolor": "black",
|
| 63 |
+
"xtick.color": "black",
|
| 64 |
+
"ytick.color": "black",
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
# Model adları — makalenin Tablo 2/3'ündeki TAM Türkçe karşılıklar
|
| 68 |
+
MODEL_TR = {
|
| 69 |
+
"Logistic Regression": "Lojistik Regresyon",
|
| 70 |
+
"Random Forest": "Rastgele Orman",
|
| 71 |
+
"Gradient Boosting": "Gradyan Artırma",
|
| 72 |
+
"SVM (RBF)": "SVM-RBF",
|
| 73 |
+
"MLP Neural Network": "ÇKA Sinir Ağı",
|
| 74 |
+
"XGBoost": "XGBoost",
|
| 75 |
+
"LightGBM": "LightGBM",
|
| 76 |
+
"Deep MLP": "Derin ÇKA",
|
| 77 |
+
"Deep MLP (512-256-128-64)": "Derin ÇKA",
|
| 78 |
+
"1D-CNN": "1B-ESA",
|
| 79 |
+
"Residual MLP": "Artık ÇKA",
|
| 80 |
+
"Residual MLP (3 blocks)": "Artık ÇKA",
|
| 81 |
+
"Attention MLP": "Dikkat ÇKA",
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
GENERATOR_TR = {
|
| 85 |
+
"aime": "AImE",
|
| 86 |
+
"deepfake_audio": "Deepfake Seti",
|
| 87 |
+
"deepfake_audio_dataset": "Deepfake Veri Kümesi",
|
| 88 |
+
"echoes": "Echoes",
|
| 89 |
+
"suno_audio": "Suno",
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _read_csv(name: str) -> list[dict]:
|
| 94 |
+
with open(TABLES / name, "r", encoding="utf-8") as f:
|
| 95 |
+
return list(csv.DictReader(f))
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _save(fig, name: str):
|
| 99 |
+
for ext in ("png", "pdf"):
|
| 100 |
+
p = OUT / f"{name}.{ext}"
|
| 101 |
+
fig.savefig(p)
|
| 102 |
+
plt.close(fig)
|
| 103 |
+
print(f" {name}.png / .pdf")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def fig_logo() -> None:
|
| 107 |
+
rows = _read_csv("logo_results.csv")
|
| 108 |
+
names = [GENERATOR_TR.get(r["held_out_generator"], r["held_out_generator"]) for r in rows]
|
| 109 |
+
recalls = [float(r["recall"]) for r in rows]
|
| 110 |
+
f1s = [float(r["f1"]) for r in rows]
|
| 111 |
+
bal_accs = [float(r["balanced_accuracy"]) for r in rows]
|
| 112 |
+
|
| 113 |
+
x = np.arange(len(names))
|
| 114 |
+
width = 0.25
|
| 115 |
+
fig, ax = plt.subplots(figsize=(8, 4.5))
|
| 116 |
+
fig.patch.set_facecolor(BG)
|
| 117 |
+
ax.set_facecolor(BG)
|
| 118 |
+
ax.bar(x - width, recalls, width, label="Duyarlılık (YZ sınıfı)", color=BLUE)
|
| 119 |
+
ax.bar(x, f1s, width, label="F1", color=ORANGE)
|
| 120 |
+
ax.bar(x + width, bal_accs, width, label="Dengeli Doğruluk", color=HUMAN)
|
| 121 |
+
ax.axhline(0.9548, color="#666666", linestyle="--", linewidth=1, label="Aynı Dağılım İçi ÇD AUC (referans)")
|
| 122 |
+
ax.set_xticks(x)
|
| 123 |
+
ax.set_xticklabels(names, rotation=20, ha="right")
|
| 124 |
+
ax.set_ylabel("Skor")
|
| 125 |
+
ax.set_title("Leave-One-Generator-Out: görülmemiş üreticilerdeki performans")
|
| 126 |
+
ax.set_ylim(0, 1.05)
|
| 127 |
+
ax.legend(loc="upper right", framealpha=0.9)
|
| 128 |
+
fig.tight_layout()
|
| 129 |
+
_save(fig, "revision_logo_results")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def fig_flatness_ablation() -> None:
|
| 133 |
+
rows = _read_csv("flatness_ablation.csv")
|
| 134 |
+
labels = ["47 Öznitelik\n(Tümü)", "46 Öznitelik\n(Flatness Std Hariç)", "1 Öznitelik\n(Yalnızca Flatness Std)"]
|
| 135 |
+
aucs = [float(r["roc_auc"]) for r in rows]
|
| 136 |
+
f1s = [float(r["f1"]) for r in rows]
|
| 137 |
+
bal_accs = [float(r["balanced_accuracy"]) for r in rows]
|
| 138 |
+
|
| 139 |
+
x = np.arange(len(labels))
|
| 140 |
+
width = 0.25
|
| 141 |
+
fig, ax = plt.subplots(figsize=(7, 4.5))
|
| 142 |
+
fig.patch.set_facecolor(BG)
|
| 143 |
+
ax.set_facecolor(BG)
|
| 144 |
+
ax.bar(x - width, aucs, width, label="ROC-AUC", color=GOLD)
|
| 145 |
+
ax.bar(x, f1s, width, label="F1", color=HUMAN)
|
| 146 |
+
ax.bar(x + width, bal_accs, width, label="Dengeli Doğruluk", color=AIRED)
|
| 147 |
+
ax.set_xticks(x)
|
| 148 |
+
ax.set_xticklabels(labels)
|
| 149 |
+
ax.set_ylabel("Skor")
|
| 150 |
+
ax.set_title("Spectral flatness ablasyonu (LightGBM, 5-katlı ÇD)")
|
| 151 |
+
ax.set_ylim(0, 1.05)
|
| 152 |
+
ax.legend(loc="upper right", framealpha=0.9)
|
| 153 |
+
fig.tight_layout()
|
| 154 |
+
_save(fig, "revision_flatness_ablation")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def fig_ensemble_comparison() -> None:
|
| 158 |
+
rows = _read_csv("ensemble_comparison.csv")
|
| 159 |
+
rows_sorted = sorted(rows, key=lambda r: -float(r["roc_auc"]))
|
| 160 |
+
names = []
|
| 161 |
+
for r in rows_sorted:
|
| 162 |
+
m = r["model"]
|
| 163 |
+
if "stacking" in m.lower():
|
| 164 |
+
names.append("Yığınlama Topluluğu")
|
| 165 |
+
elif "voting" in m.lower():
|
| 166 |
+
names.append("Ağırlıksız Ortalama Topluluğu")
|
| 167 |
+
else:
|
| 168 |
+
names.append(MODEL_TR.get(m, m))
|
| 169 |
+
aucs = [float(r["roc_auc"]) for r in rows_sorted]
|
| 170 |
+
colors = [AIRED if "Topluluğu" in n else GOLD for n in names]
|
| 171 |
+
|
| 172 |
+
fig, ax = plt.subplots(figsize=(8, 5.5))
|
| 173 |
+
fig.patch.set_facecolor(BG)
|
| 174 |
+
ax.set_facecolor(BG)
|
| 175 |
+
y_pos = np.arange(len(names))
|
| 176 |
+
ax.barh(y_pos, aucs, color=colors)
|
| 177 |
+
ax.set_yticks(y_pos)
|
| 178 |
+
ax.set_yticklabels(names, fontsize=9)
|
| 179 |
+
ax.invert_yaxis()
|
| 180 |
+
ax.set_xlabel("ROC-AUC")
|
| 181 |
+
ax.set_xlim(0.8, 1.0)
|
| 182 |
+
ax.set_title("Topluluk yöntemleri ile tekil modellerin karşılaştırılması (5-katlı OOF)")
|
| 183 |
+
for i, v in enumerate(aucs):
|
| 184 |
+
ax.text(v + 0.002, i, f"{v:.4f}".replace(".", ","), va="center", fontsize=8)
|
| 185 |
+
fig.tight_layout()
|
| 186 |
+
_save(fig, "revision_ensemble_comparison")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def fig_calibration_reliability() -> None:
|
| 190 |
+
rows = []
|
| 191 |
+
with open(TABLES / "calibration_diagnostics.csv", "r", encoding="utf-8") as f:
|
| 192 |
+
lines = f.read().splitlines()
|
| 193 |
+
bin_start = lines.index("bin_mean_predicted,bin_fraction_positive") + 1
|
| 194 |
+
for line in lines[bin_start:]:
|
| 195 |
+
if not line.strip():
|
| 196 |
+
continue
|
| 197 |
+
pred, actual = line.split(",")
|
| 198 |
+
rows.append((float(pred), float(actual)))
|
| 199 |
+
|
| 200 |
+
fig, ax = plt.subplots(figsize=(5.5, 5.5))
|
| 201 |
+
fig.patch.set_facecolor(BG)
|
| 202 |
+
ax.set_facecolor(BG)
|
| 203 |
+
ax.plot([0, 1], [0, 1], color="#333333", linestyle="--", linewidth=1, label="Mükemmel kalibrasyon")
|
| 204 |
+
preds = [r[0] for r in rows]
|
| 205 |
+
actuals = [r[1] for r in rows]
|
| 206 |
+
ax.plot(preds, actuals, "o-", color=GOLD, linewidth=2.5, markersize=8, label="LightGBM (OOF)")
|
| 207 |
+
ax.set_xlabel("Ortalama Tahmin Olasılığı")
|
| 208 |
+
ax.set_ylabel("Pozitif Oranı")
|
| 209 |
+
ax.set_title("Güvenilirlik Diyagramı (10 Kutu)")
|
| 210 |
+
ax.set_xlim(0, 1)
|
| 211 |
+
ax.set_ylim(0, 1)
|
| 212 |
+
ax.legend(loc="upper left")
|
| 213 |
+
fig.tight_layout()
|
| 214 |
+
_save(fig, "revision_reliability_diagram")
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def fig_nested_cv_boxplot() -> None:
|
| 218 |
+
rows = _read_csv("nested_cv_results.csv")
|
| 219 |
+
nested_aucs = [float(r["roc_auc"]) for r in rows]
|
| 220 |
+
plain_cv_auc = 0.9548
|
| 221 |
+
|
| 222 |
+
fig, ax = plt.subplots(figsize=(5, 4.5))
|
| 223 |
+
fig.patch.set_facecolor(BG)
|
| 224 |
+
ax.set_facecolor(BG)
|
| 225 |
+
bp = ax.boxplot([nested_aucs], positions=[1], widths=0.5, tick_labels=["Nested ÇD\n(dış=5, iç=3)"],
|
| 226 |
+
patch_artist=True)
|
| 227 |
+
for box in bp["boxes"]:
|
| 228 |
+
box.set_facecolor(GOLD)
|
| 229 |
+
box.set_alpha(0.5)
|
| 230 |
+
ax.axhline(plain_cv_auc, color=AIRED, linestyle="--", linewidth=1.2,
|
| 231 |
+
label=f"Düz 5-katlı ÇD (referans) = {plain_cv_auc:.4f}".replace(".", ","))
|
| 232 |
+
ax.scatter([1] * len(nested_aucs), nested_aucs, color=BLUE, alpha=0.8, zorder=3)
|
| 233 |
+
ax.set_ylabel("ROC-AUC")
|
| 234 |
+
ax.set_title("Nested ÇD ile düz ÇD karşılaştırması (LightGBM)")
|
| 235 |
+
y_min, y_max = ax.get_ylim()
|
| 236 |
+
ax.set_ylim(y_min - (y_max - y_min) * 0.18, y_max)
|
| 237 |
+
ax.legend(loc="lower center", fontsize=8, framealpha=0.95)
|
| 238 |
+
fig.tight_layout()
|
| 239 |
+
_save(fig, "revision_nested_cv")
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def fig_dataset_bias() -> None:
|
| 243 |
+
rows = _read_csv("dataset_bias_table.csv")
|
| 244 |
+
names = [GENERATOR_TR.get(r["source"], r["source"]) if r["source"] not in ("fma", "gtzan", "sleepyjesse", "archive_org")
|
| 245 |
+
else {"fma": "FMA Small", "gtzan": "GTZAN", "sleepyjesse": "SleepyJesse", "archive_org": "Diğer İnsan"}[r["source"]]
|
| 246 |
+
for r in rows]
|
| 247 |
+
ns = [int(r["n"]) for r in rows]
|
| 248 |
+
colors = [AIRED if r["label"] == "AI" else HUMAN for r in rows]
|
| 249 |
+
|
| 250 |
+
fig, ax = plt.subplots(figsize=(8, 4.5))
|
| 251 |
+
fig.patch.set_facecolor(BG)
|
| 252 |
+
ax.set_facecolor(BG)
|
| 253 |
+
ax.bar(names, ns, color=colors)
|
| 254 |
+
ax.set_xticks(range(len(names)))
|
| 255 |
+
ax.set_xticklabels(names, rotation=30, ha="right")
|
| 256 |
+
ax.set_ylabel("Örnek Sayısı")
|
| 257 |
+
ax.set_title("Kaynak bazlı veri kümesi kompozisyonu (yeşil=İnsan, kırmızı=YZ)")
|
| 258 |
+
fig.tight_layout()
|
| 259 |
+
_save(fig, "revision_dataset_bias")
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def run() -> None:
|
| 263 |
+
print("Revizyon figürleri üretiliyor (Türkçe, saydam arka plan, Times New Roman)...")
|
| 264 |
+
fig_logo()
|
| 265 |
+
fig_flatness_ablation()
|
| 266 |
+
fig_ensemble_comparison()
|
| 267 |
+
fig_calibration_reliability()
|
| 268 |
+
fig_nested_cv_boxplot()
|
| 269 |
+
fig_dataset_bias()
|
| 270 |
+
print(f"\nTüm figürler yazıldı: {OUT}")
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
if __name__ == "__main__":
|
| 274 |
+
run()
|
app/training/hyperparameter_table.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hyperparameter search table (reviewer priority #6 supporting evidence).
|
| 3 |
+
|
| 4 |
+
The reviewer questions whether all 11 models received equally disciplined
|
| 5 |
+
hyperparameter optimization, or whether some were left at library defaults
|
| 6 |
+
while others (implicitly LightGBM) were tuned. train_classifier.py's
|
| 7 |
+
_build_candidate_families() already runs a per-family holdout search for
|
| 8 |
+
every ML model family (see _select_best_candidates), and
|
| 9 |
+
training_results.json records each model's selected_params and
|
| 10 |
+
validation_auc. This script turns that into a single audit table showing,
|
| 11 |
+
per model: how many candidates were tried, what the search space was, and
|
| 12 |
+
which configuration was selected — so the answer is verifiable rather than
|
| 13 |
+
asserted.
|
| 14 |
+
|
| 15 |
+
Usage:
|
| 16 |
+
python -m app.training.hyperparameter_table
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import csv
|
| 22 |
+
import json
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
MODELS_DIR = Path(__file__).resolve().parents[2] / "models"
|
| 26 |
+
TABLES_DIR = Path(__file__).resolve().parents[3] / "docs/academic/paper/real_tables"
|
| 27 |
+
|
| 28 |
+
# Number of hand-specified candidates per family, taken directly from
|
| 29 |
+
# _build_candidate_families() in train_classifier.py.
|
| 30 |
+
_N_CANDIDATES = {
|
| 31 |
+
"Logistic Regression": 4, # C in (0.25, 0.5, 1.0, 2.0)
|
| 32 |
+
"Random Forest": 3,
|
| 33 |
+
"Gradient Boosting": 3,
|
| 34 |
+
"SVM (RBF)": 4,
|
| 35 |
+
"MLP Neural Network": 3,
|
| 36 |
+
"XGBoost": 3,
|
| 37 |
+
"LightGBM": 3,
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
_SEARCH_SPACE_SUMMARY = {
|
| 41 |
+
"Logistic Regression": "C in {0.25, 0.5, 1.0, 2.0}; class_weight=balanced fixed",
|
| 42 |
+
"Random Forest": "n_estimators in {300,450,500}; max_depth in {12,18,None}; "
|
| 43 |
+
"max_features in {sqrt,sqrt,log2}",
|
| 44 |
+
"Gradient Boosting": "n_estimators in {180,200,260}; max_depth in {2,3,4}; "
|
| 45 |
+
"learning_rate in {0.04,0.05,0.07}",
|
| 46 |
+
"SVM (RBF)": "C in {1.0,3.0,6.0,10.0}; gamma in {scale,scale,0.02,0.05}",
|
| 47 |
+
"MLP Neural Network": "hidden_layer_sizes in {(128,64),(192,96,32),(256,128)}; "
|
| 48 |
+
"alpha in {5e-4,1e-3,2e-3}",
|
| 49 |
+
"XGBoost": "n_estimators in {240,300,500}; max_depth in {3,4,5}; "
|
| 50 |
+
"learning_rate in {0.03,0.05,0.06}",
|
| 51 |
+
"LightGBM": "n_estimators in {220,300,500}; max_depth in {-1,6,8}; "
|
| 52 |
+
"num_leaves in {18,24,31}; learning_rate in {0.03,0.05,0.07}",
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
# DL models: architecture is fixed per model (no per-family candidate search
|
| 56 |
+
# like the ML side), but all four share identical training hyperparameters
|
| 57 |
+
# (optimizer, LR, epochs, early stopping) — recorded here for the same
|
| 58 |
+
# fairness audit.
|
| 59 |
+
_DL_SHARED_TRAINING = {
|
| 60 |
+
"optimizer": "AdamW",
|
| 61 |
+
"learning_rate": "1e-3",
|
| 62 |
+
"weight_decay": "1e-4",
|
| 63 |
+
"lr_scheduler": "ReduceLROnPlateau(mode=max, factor=0.5, patience=5)",
|
| 64 |
+
"loss": "BCEWithLogitsLoss(pos_weight=n_neg/n_pos)",
|
| 65 |
+
"epochs_max": "100",
|
| 66 |
+
"early_stopping_patience": "10",
|
| 67 |
+
"batch_size": "64",
|
| 68 |
+
"seed": "42 (+fold index)",
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def run() -> None:
|
| 73 |
+
with open(MODELS_DIR / "training_results.json", "r", encoding="utf-8") as f:
|
| 74 |
+
results = json.load(f)
|
| 75 |
+
|
| 76 |
+
rows = []
|
| 77 |
+
for name in _N_CANDIDATES:
|
| 78 |
+
entry = results.get(name, {})
|
| 79 |
+
rows.append({
|
| 80 |
+
"model": name,
|
| 81 |
+
"n_candidates_evaluated": _N_CANDIDATES[name],
|
| 82 |
+
"selection_method": "stratified holdout (80/20), best validation AUC wins",
|
| 83 |
+
"validation_auc": entry.get("validation_auc"),
|
| 84 |
+
"selected_params": json.dumps(entry.get("selected_params", {}), ensure_ascii=False),
|
| 85 |
+
"selection_time_sec": entry.get("selection_time_sec"),
|
| 86 |
+
"search_space": _SEARCH_SPACE_SUMMARY[name],
|
| 87 |
+
})
|
| 88 |
+
|
| 89 |
+
TABLES_DIR.mkdir(parents=True, exist_ok=True)
|
| 90 |
+
ml_path = TABLES_DIR / "hyperparameter_search_table.csv"
|
| 91 |
+
with open(ml_path, "w", newline="", encoding="utf-8") as f:
|
| 92 |
+
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
| 93 |
+
writer.writeheader()
|
| 94 |
+
writer.writerows(rows)
|
| 95 |
+
|
| 96 |
+
print(f"ML hyperparameter search table written: {ml_path}")
|
| 97 |
+
for r in rows:
|
| 98 |
+
print(f" {r['model']:22s} candidates={r['n_candidates_evaluated']} "
|
| 99 |
+
f"val_auc={r['validation_auc']} time={r['selection_time_sec']}s")
|
| 100 |
+
|
| 101 |
+
dl_path = TABLES_DIR / "dl_training_hyperparameters.csv"
|
| 102 |
+
with open(dl_path, "w", newline="", encoding="utf-8") as f:
|
| 103 |
+
writer = csv.writer(f)
|
| 104 |
+
writer.writerow(["hyperparameter", "value"])
|
| 105 |
+
writer.writerow(["note", "shared across all 4 DL architectures (Deep MLP, 1D-CNN, "
|
| 106 |
+
"Residual MLP, Attention MLP) — architecture differs, "
|
| 107 |
+
"training recipe does not"])
|
| 108 |
+
for k, v in _DL_SHARED_TRAINING.items():
|
| 109 |
+
writer.writerow([k, v])
|
| 110 |
+
|
| 111 |
+
print(f"\nDL shared training hyperparameters written: {dl_path}")
|
| 112 |
+
print(
|
| 113 |
+
"\nNOTE: DL models do not go through a per-family candidate search "
|
| 114 |
+
"like the ML models — each of the 4 DL architectures is trained "
|
| 115 |
+
"once with an identical, fixed training recipe (see dl_training_"
|
| 116 |
+
"hyperparameters.csv). This is architecturally different fairness "
|
| 117 |
+
"(same recipe, different architecture) vs. the ML side (same "
|
| 118 |
+
"architecture family, tuned hyperparameters) — both are internally "
|
| 119 |
+
"consistent, but the paper should state this distinction explicitly "
|
| 120 |
+
"rather than implying all 11 models went through identical tuning."
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
if __name__ == "__main__":
|
| 125 |
+
run()
|
app/training/logo_eval.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Leave-One-Generator-Out (LOGO) evaluation for AURIS.
|
| 3 |
+
|
| 4 |
+
The reviewer's #1 priority: standard 5-fold CV shuffles all samples together,
|
| 5 |
+
so it never tests whether the model generalizes to an AI generator it has
|
| 6 |
+
never seen during training. This script holds out one AI generator at a time,
|
| 7 |
+
trains on everything else (all human sources + all remaining AI generators),
|
| 8 |
+
and reports how the model performs on the unseen generator.
|
| 9 |
+
|
| 10 |
+
Human samples are never held out — they remain in the training set for every
|
| 11 |
+
fold, since the reviewer's concern is specifically about generator-level
|
| 12 |
+
generalization on the AI side.
|
| 13 |
+
|
| 14 |
+
Usage:
|
| 15 |
+
python -m app.training.logo_eval
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import csv
|
| 21 |
+
import json
|
| 22 |
+
import sys
|
| 23 |
+
import warnings
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
|
| 29 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 30 |
+
|
| 31 |
+
import lightgbm as lgb
|
| 32 |
+
from sklearn.exceptions import ConvergenceWarning
|
| 33 |
+
from sklearn.metrics import (
|
| 34 |
+
accuracy_score,
|
| 35 |
+
average_precision_score,
|
| 36 |
+
balanced_accuracy_score,
|
| 37 |
+
f1_score,
|
| 38 |
+
matthews_corrcoef,
|
| 39 |
+
precision_score,
|
| 40 |
+
recall_score,
|
| 41 |
+
roc_auc_score,
|
| 42 |
+
roc_curve,
|
| 43 |
+
)
|
| 44 |
+
from sklearn.preprocessing import StandardScaler
|
| 45 |
+
|
| 46 |
+
DATASET_DIR = Path("D:/CrownCode/DataSet")
|
| 47 |
+
FEATURES_WITH_META = DATASET_DIR / "features_with_meta.csv"
|
| 48 |
+
OUTPUT_CSV = Path(__file__).resolve().parents[3] / "docs/academic/paper/real_tables/logo_results.csv"
|
| 49 |
+
|
| 50 |
+
_EXCLUDED_COLUMNS = {
|
| 51 |
+
"file_path", "label_int", "duration_sec", "sample_rate",
|
| 52 |
+
"genre", "generator", "ai_model", "meta_sample_rate", "meta_duration_sec",
|
| 53 |
+
"match_source",
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
# LightGBM config matching the best-performing candidate from train_classifier.py
|
| 57 |
+
_LGBM_PARAMS = dict(
|
| 58 |
+
n_estimators=300,
|
| 59 |
+
max_depth=-1,
|
| 60 |
+
learning_rate=0.05,
|
| 61 |
+
num_leaves=31,
|
| 62 |
+
subsample=0.8,
|
| 63 |
+
colsample_bytree=0.8,
|
| 64 |
+
min_child_samples=20,
|
| 65 |
+
reg_alpha=0.1,
|
| 66 |
+
reg_lambda=1.0,
|
| 67 |
+
class_weight="balanced",
|
| 68 |
+
random_state=42,
|
| 69 |
+
verbose=-1,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _load() -> tuple[np.ndarray, np.ndarray, list[str], list[str]]:
|
| 74 |
+
with open(FEATURES_WITH_META, "r", encoding="utf-8") as f:
|
| 75 |
+
reader = csv.DictReader(f)
|
| 76 |
+
fieldnames = reader.fieldnames or []
|
| 77 |
+
feature_cols = [c for c in fieldnames if c not in _EXCLUDED_COLUMNS]
|
| 78 |
+
rows = list(reader)
|
| 79 |
+
|
| 80 |
+
X = np.array([[float(r[c]) for c in feature_cols] for r in rows], dtype=np.float32)
|
| 81 |
+
X = np.nan_to_num(X, nan=0.0, posinf=1.0, neginf=-1.0)
|
| 82 |
+
y = np.array([int(r["label_int"]) for r in rows], dtype=np.int32)
|
| 83 |
+
generators = [r["generator"] for r in rows]
|
| 84 |
+
|
| 85 |
+
return X, y, generators, feature_cols
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _optimal_threshold(y_true: np.ndarray, y_prob: np.ndarray) -> float:
|
| 89 |
+
fpr, tpr, thresholds = roc_curve(y_true, y_prob)
|
| 90 |
+
j_scores = tpr - fpr
|
| 91 |
+
return float(thresholds[np.argmax(j_scores)])
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _metrics(y_true: np.ndarray, y_prob: np.ndarray, threshold: float) -> dict[str, float]:
|
| 95 |
+
y_pred = (y_prob >= threshold).astype(int)
|
| 96 |
+
out: dict[str, float] = {
|
| 97 |
+
"n_test": int(len(y_true)),
|
| 98 |
+
"n_ai_test": int(np.sum(y_true == 1)),
|
| 99 |
+
"n_human_test": int(np.sum(y_true == 0)),
|
| 100 |
+
"accuracy": round(float(accuracy_score(y_true, y_pred)), 4),
|
| 101 |
+
"precision": round(float(precision_score(y_true, y_pred, zero_division=0)), 4),
|
| 102 |
+
"recall": round(float(recall_score(y_true, y_pred, zero_division=0)), 4),
|
| 103 |
+
"f1": round(float(f1_score(y_true, y_pred, zero_division=0)), 4),
|
| 104 |
+
"balanced_accuracy": round(float(balanced_accuracy_score(y_true, y_pred)), 4),
|
| 105 |
+
"mcc": round(float(matthews_corrcoef(y_true, y_pred)), 4) if len(set(y_pred)) > 1 else 0.0,
|
| 106 |
+
}
|
| 107 |
+
# ROC-AUC / PR-AUC require both classes present in the held-out generator's
|
| 108 |
+
# test fold; a single-class generator fold cannot report them.
|
| 109 |
+
if len(set(y_true.tolist())) > 1:
|
| 110 |
+
out["roc_auc"] = round(float(roc_auc_score(y_true, y_prob)), 4)
|
| 111 |
+
out["pr_auc"] = round(float(average_precision_score(y_true, y_prob)), 4)
|
| 112 |
+
else:
|
| 113 |
+
out["roc_auc"] = None
|
| 114 |
+
out["pr_auc"] = None
|
| 115 |
+
return out
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def run() -> dict[str, Any]:
|
| 119 |
+
X, y, generators, feature_cols = _load()
|
| 120 |
+
generators_arr = np.array(generators)
|
| 121 |
+
|
| 122 |
+
ai_generators = sorted(set(g for g, label in zip(generators, y) if label == 1))
|
| 123 |
+
print(f"AI generators found: {ai_generators}")
|
| 124 |
+
print(f"Total samples: {len(y)} (AI={int(np.sum(y == 1))}, Human={int(np.sum(y == 0))})")
|
| 125 |
+
|
| 126 |
+
results: dict[str, dict] = {}
|
| 127 |
+
|
| 128 |
+
for held_out in ai_generators:
|
| 129 |
+
test_mask = generators_arr == held_out
|
| 130 |
+
train_mask = ~test_mask
|
| 131 |
+
|
| 132 |
+
X_train, y_train = X[train_mask], y[train_mask]
|
| 133 |
+
X_test, y_test = X[test_mask], y[test_mask]
|
| 134 |
+
|
| 135 |
+
if len(set(y_train.tolist())) < 2:
|
| 136 |
+
print(f" Skipping {held_out}: training set has only one class after holdout")
|
| 137 |
+
continue
|
| 138 |
+
|
| 139 |
+
scaler = StandardScaler()
|
| 140 |
+
X_train_scaled = scaler.fit_transform(X_train)
|
| 141 |
+
X_test_scaled = scaler.transform(X_test)
|
| 142 |
+
|
| 143 |
+
model = lgb.LGBMClassifier(**_LGBM_PARAMS)
|
| 144 |
+
with warnings.catch_warnings():
|
| 145 |
+
warnings.simplefilter("ignore", category=ConvergenceWarning)
|
| 146 |
+
model.fit(X_train_scaled, y_train)
|
| 147 |
+
|
| 148 |
+
y_prob_train = model.predict_proba(X_train_scaled)[:, 1]
|
| 149 |
+
threshold = _optimal_threshold(y_train, y_prob_train)
|
| 150 |
+
|
| 151 |
+
y_prob_test = model.predict_proba(X_test_scaled)[:, 1]
|
| 152 |
+
metrics = _metrics(y_test, y_prob_test, threshold)
|
| 153 |
+
metrics["held_out_generator"] = held_out
|
| 154 |
+
metrics["threshold_from_train"] = round(threshold, 4)
|
| 155 |
+
metrics["n_train"] = int(len(y_train))
|
| 156 |
+
|
| 157 |
+
results[held_out] = metrics
|
| 158 |
+
|
| 159 |
+
auc_str = f"{metrics['roc_auc']:.4f}" if metrics["roc_auc"] is not None else "N/A (single class)"
|
| 160 |
+
print(
|
| 161 |
+
f" Held out: {held_out:25s} n_test={metrics['n_test']:4d} "
|
| 162 |
+
f"AUC={auc_str} F1={metrics['f1']:.4f} "
|
| 163 |
+
f"BalAcc={metrics['balanced_accuracy']:.4f} MCC={metrics['mcc']:.4f} "
|
| 164 |
+
f"Recall={metrics['recall']:.4f}"
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# ── Aggregate: mean/std across generators with a valid AUC ──
|
| 168 |
+
valid_aucs = [r["roc_auc"] for r in results.values() if r["roc_auc"] is not None]
|
| 169 |
+
summary = {
|
| 170 |
+
"mean_roc_auc": round(float(np.mean(valid_aucs)), 4) if valid_aucs else None,
|
| 171 |
+
"std_roc_auc": round(float(np.std(valid_aucs)), 4) if valid_aucs else None,
|
| 172 |
+
"n_generators_evaluated": len(results),
|
| 173 |
+
"reference_5fold_cv_auc": 0.9548, # from training_results.json, same-distribution CV
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
print("\n" + "=" * 70)
|
| 177 |
+
print("LOGO SUMMARY")
|
| 178 |
+
print("=" * 70)
|
| 179 |
+
print(f" Mean ROC-AUC across held-out generators: {summary['mean_roc_auc']}")
|
| 180 |
+
print(f" Std ROC-AUC across held-out generators: {summary['std_roc_auc']}")
|
| 181 |
+
print(f" Reference (in-distribution 5-fold CV): {summary['reference_5fold_cv_auc']}")
|
| 182 |
+
|
| 183 |
+
# ── Write CSV ──
|
| 184 |
+
OUTPUT_CSV.parent.mkdir(parents=True, exist_ok=True)
|
| 185 |
+
fieldnames = [
|
| 186 |
+
"held_out_generator", "n_train", "n_test", "n_ai_test", "n_human_test",
|
| 187 |
+
"threshold_from_train", "accuracy", "precision", "recall", "f1",
|
| 188 |
+
"balanced_accuracy", "mcc", "roc_auc", "pr_auc",
|
| 189 |
+
]
|
| 190 |
+
with open(OUTPUT_CSV, "w", newline="", encoding="utf-8") as f:
|
| 191 |
+
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
| 192 |
+
writer.writeheader()
|
| 193 |
+
for gen in ai_generators:
|
| 194 |
+
if gen in results:
|
| 195 |
+
writer.writerow({k: results[gen].get(k) for k in fieldnames})
|
| 196 |
+
|
| 197 |
+
print(f"\nOutput: {OUTPUT_CSV}")
|
| 198 |
+
|
| 199 |
+
return {"per_generator": results, "summary": summary}
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
if __name__ == "__main__":
|
| 203 |
+
run()
|
app/training/nested_cv.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Nested cross-validation and an independent held-out test set (reviewer
|
| 3 |
+
priority #4).
|
| 4 |
+
|
| 5 |
+
Two separate concerns the reviewer raised:
|
| 6 |
+
|
| 7 |
+
1. The reported hyperparameters were selected on a single stratified
|
| 8 |
+
holdout, then the SAME hyperparameters were evaluated with plain 5-fold
|
| 9 |
+
CV. That lets a lucky hyperparameter choice leak into the reported CV
|
| 10 |
+
score. Nested CV (outer loop for evaluation, inner loop for hyperparameter
|
| 11 |
+
selection, refit per outer fold) removes that leakage.
|
| 12 |
+
|
| 13 |
+
2. The reviewer explicitly warns that if the Youden J threshold is tuned on
|
| 14 |
+
the same data used to report performance, "test information" has
|
| 15 |
+
implicitly entered the model. This script fixes that: split
|
| 16 |
+
train/val/test 70/15/15 (stratified), select the threshold on train+val
|
| 17 |
+
only, then apply that FIXED threshold to the untouched test set and report
|
| 18 |
+
test-set metrics.
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
python -m app.training.nested_cv
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import csv
|
| 27 |
+
import sys
|
| 28 |
+
import warnings
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
|
| 33 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
| 34 |
+
|
| 35 |
+
import lightgbm as lgb
|
| 36 |
+
from sklearn.exceptions import ConvergenceWarning
|
| 37 |
+
from sklearn.metrics import (
|
| 38 |
+
accuracy_score,
|
| 39 |
+
average_precision_score,
|
| 40 |
+
balanced_accuracy_score,
|
| 41 |
+
f1_score,
|
| 42 |
+
matthews_corrcoef,
|
| 43 |
+
precision_score,
|
| 44 |
+
recall_score,
|
| 45 |
+
roc_auc_score,
|
| 46 |
+
roc_curve,
|
| 47 |
+
)
|
| 48 |
+
from sklearn.model_selection import StratifiedKFold, train_test_split
|
| 49 |
+
from sklearn.preprocessing import StandardScaler
|
| 50 |
+
|
| 51 |
+
from app.training.evaluate import load_features_csv
|
| 52 |
+
|
| 53 |
+
FEATURES_CSV = Path("D:/CrownCode/DataSet/features.csv")
|
| 54 |
+
TABLES_DIR = Path(__file__).resolve().parents[3] / "docs/academic/paper/real_tables"
|
| 55 |
+
|
| 56 |
+
# Same search space as train_classifier.py's LightGBM candidates, used here
|
| 57 |
+
# for the inner-loop hyperparameter search.
|
| 58 |
+
_LGBM_CANDIDATES = [
|
| 59 |
+
dict(n_estimators=300, max_depth=-1, learning_rate=0.05, num_leaves=31,
|
| 60 |
+
subsample=0.8, colsample_bytree=0.8, min_child_samples=20,
|
| 61 |
+
reg_alpha=0.1, reg_lambda=1.0),
|
| 62 |
+
dict(n_estimators=500, max_depth=8, learning_rate=0.03, num_leaves=24,
|
| 63 |
+
subsample=0.9, colsample_bytree=0.8, min_child_samples=30,
|
| 64 |
+
reg_alpha=0.2, reg_lambda=1.2),
|
| 65 |
+
dict(n_estimators=220, max_depth=6, learning_rate=0.07, num_leaves=18,
|
| 66 |
+
subsample=0.75, colsample_bytree=0.75, min_child_samples=24,
|
| 67 |
+
reg_alpha=0.3, reg_lambda=1.5),
|
| 68 |
+
]
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _fit_lgbm(params: dict, X: np.ndarray, y: np.ndarray) -> lgb.LGBMClassifier:
|
| 72 |
+
model = lgb.LGBMClassifier(**params, class_weight="balanced", random_state=42, verbose=-1)
|
| 73 |
+
with warnings.catch_warnings():
|
| 74 |
+
warnings.simplefilter("ignore", category=ConvergenceWarning)
|
| 75 |
+
model.fit(X, y)
|
| 76 |
+
return model
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _optimal_threshold(y_true: np.ndarray, y_prob: np.ndarray) -> float:
|
| 80 |
+
fpr, tpr, thresholds = roc_curve(y_true, y_prob)
|
| 81 |
+
return float(thresholds[np.argmax(tpr - fpr)])
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _full_metrics(y_true: np.ndarray, y_prob: np.ndarray, threshold: float) -> dict:
|
| 85 |
+
y_pred = (y_prob >= threshold).astype(int)
|
| 86 |
+
return {
|
| 87 |
+
"accuracy": round(float(accuracy_score(y_true, y_pred)), 4),
|
| 88 |
+
"precision": round(float(precision_score(y_true, y_pred, zero_division=0)), 4),
|
| 89 |
+
"recall": round(float(recall_score(y_true, y_pred, zero_division=0)), 4),
|
| 90 |
+
"f1": round(float(f1_score(y_true, y_pred, zero_division=0)), 4),
|
| 91 |
+
"balanced_accuracy": round(float(balanced_accuracy_score(y_true, y_pred)), 4),
|
| 92 |
+
"mcc": round(float(matthews_corrcoef(y_true, y_pred)), 4),
|
| 93 |
+
"roc_auc": round(float(roc_auc_score(y_true, y_prob)), 4),
|
| 94 |
+
"pr_auc": round(float(average_precision_score(y_true, y_prob)), 4),
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def nested_cv(X: np.ndarray, y: np.ndarray, outer_folds: int = 5, inner_folds: int = 3) -> list[dict]:
|
| 99 |
+
"""Outer loop evaluates; inner loop selects hyperparameters per outer fold."""
|
| 100 |
+
outer_cv = StratifiedKFold(n_splits=outer_folds, shuffle=True, random_state=42)
|
| 101 |
+
results = []
|
| 102 |
+
|
| 103 |
+
for fold_idx, (train_idx, test_idx) in enumerate(outer_cv.split(X, y), start=1):
|
| 104 |
+
X_train_outer, y_train_outer = X[train_idx], y[train_idx]
|
| 105 |
+
X_test_outer, y_test_outer = X[test_idx], y[test_idx]
|
| 106 |
+
|
| 107 |
+
scaler_outer = StandardScaler()
|
| 108 |
+
X_train_outer_scaled = scaler_outer.fit_transform(X_train_outer)
|
| 109 |
+
X_test_outer_scaled = scaler_outer.transform(X_test_outer)
|
| 110 |
+
|
| 111 |
+
# Inner loop: pick the best candidate params by mean inner-fold AUC
|
| 112 |
+
inner_cv = StratifiedKFold(n_splits=inner_folds, shuffle=True, random_state=fold_idx)
|
| 113 |
+
best_params = None
|
| 114 |
+
best_inner_auc = -1.0
|
| 115 |
+
|
| 116 |
+
for params in _LGBM_CANDIDATES:
|
| 117 |
+
inner_aucs = []
|
| 118 |
+
for inner_train_idx, inner_val_idx in inner_cv.split(X_train_outer_scaled, y_train_outer):
|
| 119 |
+
model = _fit_lgbm(
|
| 120 |
+
params,
|
| 121 |
+
X_train_outer_scaled[inner_train_idx],
|
| 122 |
+
y_train_outer[inner_train_idx],
|
| 123 |
+
)
|
| 124 |
+
y_prob_inner = model.predict_proba(X_train_outer_scaled[inner_val_idx])[:, 1]
|
| 125 |
+
inner_aucs.append(roc_auc_score(y_train_outer[inner_val_idx], y_prob_inner))
|
| 126 |
+
mean_inner_auc = float(np.mean(inner_aucs))
|
| 127 |
+
if mean_inner_auc > best_inner_auc:
|
| 128 |
+
best_inner_auc = mean_inner_auc
|
| 129 |
+
best_params = params
|
| 130 |
+
|
| 131 |
+
# Refit on the full outer-train split with the winning params
|
| 132 |
+
final_model = _fit_lgbm(best_params, X_train_outer_scaled, y_train_outer)
|
| 133 |
+
y_prob_train = final_model.predict_proba(X_train_outer_scaled)[:, 1]
|
| 134 |
+
threshold = _optimal_threshold(y_train_outer, y_prob_train)
|
| 135 |
+
|
| 136 |
+
y_prob_test = final_model.predict_proba(X_test_outer_scaled)[:, 1]
|
| 137 |
+
metrics = _full_metrics(y_test_outer, y_prob_test, threshold)
|
| 138 |
+
metrics["outer_fold"] = fold_idx
|
| 139 |
+
metrics["inner_selected_n_estimators"] = best_params["n_estimators"]
|
| 140 |
+
metrics["inner_selected_max_depth"] = best_params["max_depth"]
|
| 141 |
+
metrics["inner_val_auc"] = round(best_inner_auc, 4)
|
| 142 |
+
results.append(metrics)
|
| 143 |
+
|
| 144 |
+
print(
|
| 145 |
+
f" Outer fold {fold_idx}: inner-selected params -> "
|
| 146 |
+
f"n_estimators={best_params['n_estimators']}, max_depth={best_params['max_depth']} "
|
| 147 |
+
f"| outer-test AUC={metrics['roc_auc']:.4f} F1={metrics['f1']:.4f}"
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
return results
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def independent_test_split(X: np.ndarray, y: np.ndarray) -> dict:
|
| 154 |
+
"""
|
| 155 |
+
70/15/15 stratified train/val/test split. Threshold is selected on
|
| 156 |
+
train+val ONLY, then applied as a fixed value to the untouched test set.
|
| 157 |
+
"""
|
| 158 |
+
X_temp, X_test, y_temp, y_test = train_test_split(
|
| 159 |
+
X, y, test_size=0.15, stratify=y, random_state=42,
|
| 160 |
+
)
|
| 161 |
+
X_train, X_val, y_train, y_val = train_test_split(
|
| 162 |
+
X_temp, y_temp, test_size=0.15 / 0.85, stratify=y_temp, random_state=42,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
print(f"\n Split sizes: train={len(y_train)} val={len(y_val)} test={len(y_test)}")
|
| 166 |
+
|
| 167 |
+
scaler = StandardScaler()
|
| 168 |
+
X_train_scaled = scaler.fit_transform(X_train)
|
| 169 |
+
X_val_scaled = scaler.transform(X_val)
|
| 170 |
+
X_test_scaled = scaler.transform(X_test)
|
| 171 |
+
|
| 172 |
+
# Train on train split, select threshold on val split only
|
| 173 |
+
model = _fit_lgbm(_LGBM_CANDIDATES[0], X_train_scaled, y_train)
|
| 174 |
+
y_prob_val = model.predict_proba(X_val_scaled)[:, 1]
|
| 175 |
+
threshold = _optimal_threshold(y_val, y_prob_val)
|
| 176 |
+
print(f" Threshold selected on VAL split only: theta* = {threshold:.4f}")
|
| 177 |
+
|
| 178 |
+
# Refit on train+val (standard practice once threshold is frozen),
|
| 179 |
+
# evaluate once on the untouched test split with the frozen threshold.
|
| 180 |
+
X_trainval_scaled = np.vstack([X_train_scaled, X_val_scaled])
|
| 181 |
+
y_trainval = np.concatenate([y_train, y_val])
|
| 182 |
+
final_model = _fit_lgbm(_LGBM_CANDIDATES[0], X_trainval_scaled, y_trainval)
|
| 183 |
+
|
| 184 |
+
y_prob_test = final_model.predict_proba(X_test_scaled)[:, 1]
|
| 185 |
+
metrics = _full_metrics(y_test, y_prob_test, threshold)
|
| 186 |
+
metrics["threshold_source"] = "train+val only (frozen before touching test)"
|
| 187 |
+
metrics["n_train"] = len(y_train)
|
| 188 |
+
metrics["n_val"] = len(y_val)
|
| 189 |
+
metrics["n_test"] = len(y_test)
|
| 190 |
+
metrics["threshold"] = round(threshold, 4)
|
| 191 |
+
|
| 192 |
+
print(f" Independent test-set metrics: {metrics}")
|
| 193 |
+
return metrics
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def run() -> None:
|
| 197 |
+
X, y = load_features_csv(FEATURES_CSV)
|
| 198 |
+
X = np.nan_to_num(X, nan=0.0, posinf=1.0, neginf=-1.0)
|
| 199 |
+
TABLES_DIR.mkdir(parents=True, exist_ok=True)
|
| 200 |
+
|
| 201 |
+
print("=" * 70)
|
| 202 |
+
print("STEP 1/2 — Nested cross-validation (outer=5, inner=3)")
|
| 203 |
+
print("=" * 70)
|
| 204 |
+
nested_results = nested_cv(X, y)
|
| 205 |
+
|
| 206 |
+
nested_fieldnames = [
|
| 207 |
+
"outer_fold", "inner_selected_n_estimators", "inner_selected_max_depth",
|
| 208 |
+
"inner_val_auc", "accuracy", "precision", "recall", "f1",
|
| 209 |
+
"balanced_accuracy", "mcc", "roc_auc", "pr_auc",
|
| 210 |
+
]
|
| 211 |
+
nested_path = TABLES_DIR / "nested_cv_results.csv"
|
| 212 |
+
with open(nested_path, "w", newline="", encoding="utf-8") as f:
|
| 213 |
+
writer = csv.DictWriter(f, fieldnames=nested_fieldnames)
|
| 214 |
+
writer.writeheader()
|
| 215 |
+
for r in nested_results:
|
| 216 |
+
writer.writerow({k: r[k] for k in nested_fieldnames})
|
| 217 |
+
|
| 218 |
+
aucs = [r["roc_auc"] for r in nested_results]
|
| 219 |
+
print(f"\n Nested CV mean AUC: {np.mean(aucs):.4f} +/- {np.std(aucs):.4f}")
|
| 220 |
+
print(f" (reference: plain 5-fold CV AUC = 0.9548)")
|
| 221 |
+
print(f" Output: {nested_path}")
|
| 222 |
+
|
| 223 |
+
print("\n" + "=" * 70)
|
| 224 |
+
print("STEP 2/2 — Independent 70/15/15 train/val/test split")
|
| 225 |
+
print("=" * 70)
|
| 226 |
+
independent_metrics = independent_test_split(X, y)
|
| 227 |
+
|
| 228 |
+
independent_path = TABLES_DIR / "independent_test_results.csv"
|
| 229 |
+
with open(independent_path, "w", newline="", encoding="utf-8") as f:
|
| 230 |
+
writer = csv.DictWriter(f, fieldnames=list(independent_metrics.keys()))
|
| 231 |
+
writer.writeheader()
|
| 232 |
+
writer.writerow(independent_metrics)
|
| 233 |
+
print(f"\n Output: {independent_path}")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
if __name__ == "__main__":
|
| 237 |
+
run()
|