Rthur2003 commited on
Commit
906c392
·
1 Parent(s): c1fe190

feat: implement automated training diagnostics, dataset bias analysis, and revision figure generation pipelines

Browse files
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()