File size: 12,763 Bytes
906c392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
"""
Dataset and genre bias analysis for AURIS (reviewer priority #2), plus a
segment/track-level leakage check that feeds into the nested-CV / independent
test split design (reviewer priority #4).

Three outputs:
  1. dataset_bias_table.csv   — per-source composition (n, class, genre mix,
                                 duration stats, sample rate) so a reader can
                                 see whether sources differ in ways unrelated
                                 to AI-vs-human content.
  2. genre_bias_test.csv      — LightGBM performance on the full (genre-
                                 imbalanced) test set vs. a genre-balanced
                                 subsample, to check for a genre-as-shortcut
                                 effect.
  3. segment_leakage_report.txt — whether any track appears to be split
                                 across multiple audio files (same base name
                                 with numbered suffixes), which would leak
                                 information across CV folds.

Usage:
    python -m app.training.bias_analysis
"""

from __future__ import annotations

import csv
import re
import sys
import warnings
from collections import Counter, defaultdict
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

import lightgbm as lgb
from sklearn.exceptions import ConvergenceWarning
from sklearn.metrics import balanced_accuracy_score, f1_score, roc_auc_score, roc_curve
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

DATASET_DIR = Path("D:/CrownCode/DataSet")
FEATURES_WITH_META = DATASET_DIR / "features_with_meta.csv"
TABLES_DIR = Path(__file__).resolve().parents[3] / "docs/academic/paper/real_tables"

_EXCLUDED_COLUMNS = {
    "file_path", "label_int", "duration_sec", "sample_rate",
    "genre", "generator", "ai_model", "meta_sample_rate", "meta_duration_sec",
    "match_source",
}

_LGBM_PARAMS = dict(
    n_estimators=300, max_depth=-1, learning_rate=0.05, num_leaves=31,
    subsample=0.8, colsample_bytree=0.8, min_child_samples=20,
    reg_alpha=0.1, reg_lambda=1.0, class_weight="balanced",
    random_state=42, verbose=-1,
)


def _load_rows() -> list[dict]:
    with open(FEATURES_WITH_META, "r", encoding="utf-8") as f:
        return list(csv.DictReader(f))


def dataset_bias_table(rows: list[dict]) -> None:
    """Per-source composition: n, class, genre mix, duration, sample rate."""
    by_source: dict[str, list[dict]] = defaultdict(list)
    for r in rows:
        by_source[r["generator"]].append(r)

    out_rows = []
    for source, source_rows in sorted(by_source.items()):
        durations = [float(r["duration_sec"]) for r in source_rows]
        sample_rates = Counter(r["sample_rate"] for r in source_rows)
        genres = Counter(r["genre"] for r in source_rows if r["genre"])
        labels = Counter(r["label_int"] for r in source_rows)
        top_genre = genres.most_common(1)[0] if genres else ("n/a", 0)

        out_rows.append({
            "source": source,
            "n": len(source_rows),
            "label": "AI" if labels.get("1", 0) > labels.get("0", 0) else "Human",
            "n_ai": labels.get("1", 0),
            "n_human": labels.get("0", 0),
            "n_distinct_genres": len(genres),
            "top_genre": top_genre[0],
            "top_genre_frac": round(top_genre[1] / len(source_rows), 3) if source_rows else 0,
            "duration_mean_sec": round(float(np.mean(durations)), 2),
            "duration_std_sec": round(float(np.std(durations)), 2),
            "duration_min_sec": round(float(np.min(durations)), 2),
            "duration_max_sec": round(float(np.max(durations)), 2),
            "sample_rate_modes": ";".join(f"{sr}={n}" for sr, n in sample_rates.most_common(3)),
        })

    out_path = TABLES_DIR / "dataset_bias_table.csv"
    with open(out_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=list(out_rows[0].keys()))
        writer.writeheader()
        writer.writerows(out_rows)

    print(f"Dataset bias table written: {out_path}")
    for r in out_rows:
        print(
            f"  {r['source']:25s} n={r['n']:5d} label={r['label']:5s} "
            f"dur={r['duration_mean_sec']:.1f}±{r['duration_std_sec']:.1f}s "
            f"genres={r['n_distinct_genres']:2d} top={r['top_genre']}({r['top_genre_frac']})"
        )


def genre_bias_test(rows: list[dict]) -> None:
    """
    Compare LightGBM performance on the natural (genre-imbalanced) test split
    vs. a genre-balanced test split, to check whether the model is exploiting
    genre as a shortcut for the AI/human label rather than learning
    AI-vs-human acoustic differences.
    """
    genre_rows = [r for r in rows if r["genre"]]
    print(f"\nRows with genre metadata: {len(genre_rows)} / {len(rows)}")

    feature_cols = [c for c in rows[0].keys() if c not in _EXCLUDED_COLUMNS]

    def to_xy(row_subset: list[dict]) -> tuple[np.ndarray, np.ndarray]:
        X = np.array([[float(r[c]) for c in feature_cols] for r in row_subset], dtype=np.float32)
        X = np.nan_to_num(X, nan=0.0, posinf=1.0, neginf=-1.0)
        y = np.array([int(r["label_int"]) for r in row_subset], dtype=np.int32)
        return X, y

    # Train on everything EXCEPT genre_rows' test portion — use a standard
    # stratified split of genre_rows only, so genre is controlled for on
    # both sides.
    train_rows, test_rows = train_test_split(
        genre_rows, test_size=0.3, stratify=[r["label_int"] for r in genre_rows], random_state=42,
    )

    X_train, y_train = to_xy(train_rows)
    scaler = StandardScaler()
    X_train_scaled = scaler.fit_transform(X_train)

    model = lgb.LGBMClassifier(**_LGBM_PARAMS)
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", category=ConvergenceWarning)
        model.fit(X_train_scaled, y_train)

    def eval_subset(subset_rows: list[dict], label: str) -> dict:
        if len(subset_rows) < 10 or len(set(r["label_int"] for r in subset_rows)) < 2:
            return {"subset": label, "n": len(subset_rows), "note": "too small / single class"}
        X, y = to_xy(subset_rows)
        X_scaled = scaler.transform(X)
        y_prob = model.predict_proba(X_scaled)[:, 1]
        fpr, tpr, thr = roc_curve(y, y_prob)
        threshold = float(thr[np.argmax(tpr - fpr)])
        y_pred = (y_prob >= threshold).astype(int)
        return {
            "subset": label,
            "n": len(subset_rows),
            "roc_auc": round(float(roc_auc_score(y, y_prob)), 4),
            "f1": round(float(f1_score(y, y_pred, zero_division=0)), 4),
            "balanced_accuracy": round(float(balanced_accuracy_score(y, y_pred)), 4),
        }

    results = [eval_subset(test_rows, "natural_test_split")]

    # Genre-balanced subsample: for each genre, cap AI and human counts to
    # min(n_ai, n_human) in that genre so the AI/human ratio is ~1:1 within
    # every genre — removes genre as a usable shortcut.
    by_genre_label: dict[tuple[str, str], list[dict]] = defaultdict(list)
    for r in test_rows:
        by_genre_label[(r["genre"], r["label_int"])].append(r)

    balanced_rows: list[dict] = []
    rng = np.random.RandomState(42)
    genres_present = set(g for g, _ in by_genre_label.keys())
    for genre in genres_present:
        ai_rows = by_genre_label.get((genre, "1"), [])
        human_rows = by_genre_label.get((genre, "0"), [])
        cap = min(len(ai_rows), len(human_rows))
        if cap == 0:
            continue
        ai_sample = list(rng.choice(len(ai_rows), size=cap, replace=False))
        human_sample = list(rng.choice(len(human_rows), size=cap, replace=False))
        balanced_rows.extend(ai_rows[i] for i in ai_sample)
        balanced_rows.extend(human_rows[i] for i in human_sample)

    results.append(eval_subset(balanced_rows, "genre_balanced_test_split"))

    out_path = TABLES_DIR / "genre_bias_test.csv"
    fieldnames = ["subset", "n", "roc_auc", "f1", "balanced_accuracy", "note"]
    with open(out_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        for r in results:
            writer.writerow({k: r.get(k) for k in fieldnames})

    print(f"\nGenre bias test written: {out_path}")
    for r in results:
        print(f"  {r}")

    # Per-genre breakdown for the paper's genre table
    per_genre_path = TABLES_DIR / "genre_distribution.csv"
    genre_label_counts = Counter((r["genre"], r["label_int"]) for r in genre_rows)
    genres_sorted = sorted(set(g for g, _ in genre_label_counts.keys()))
    with open(per_genre_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=["genre", "n_human", "n_ai", "ai_fraction"])
        writer.writeheader()
        for genre in genres_sorted:
            n_human = genre_label_counts.get((genre, "0"), 0)
            n_ai = genre_label_counts.get((genre, "1"), 0)
            total = n_human + n_ai
            writer.writerow({
                "genre": genre, "n_human": n_human, "n_ai": n_ai,
                "ai_fraction": round(n_ai / total, 3) if total else 0,
            })
    print(f"Genre distribution table written: {per_genre_path}")


_SEGMENT_PATTERN = re.compile(
    r"^(?P<base>.+?)[_\-](?:part|seg|segment|chunk|p)[_\-]?0*(?P<idx>\d+)$",
    re.IGNORECASE,
)


def segment_leakage_report(rows: list[dict]) -> None:
    """
    Detect filenames that look like numbered segments of the same source
    track (e.g. track_part1.wav, track_part2.wav) — if such groups exist and
    the current pipeline does group-unaware stratified CV, segments of the
    same track could land in both train and validation folds.
    """
    stems = []
    for r in rows:
        fname = Path(r["file_path"]).stem
        stems.append(fname)

    groups: dict[str, list[str]] = defaultdict(list)
    ungrouped = 0
    for stem in stems:
        m = _SEGMENT_PATTERN.match(stem)
        if m:
            groups[m.group("base")].append(stem)
        else:
            ungrouped += 1

    multi_segment_groups = {k: v for k, v in groups.items() if len(v) > 1}

    lines = []
    lines.append("Segment/track-level leakage check")
    lines.append("=" * 50)
    lines.append(f"Total files: {len(stems)}")
    lines.append(f"Files matching a segment-numbering pattern (base_part1, base_seg2, ...): "
                 f"{sum(len(v) for v in groups.values())}")
    lines.append(f"Distinct base tracks with >1 segment file: {len(multi_segment_groups)}")
    lines.append(f"Files with no recognizable segment suffix: {ungrouped}")
    lines.append("")
    if multi_segment_groups:
        lines.append("Sample multi-segment groups (up to 10):")
        for base, members in list(multi_segment_groups.items())[:10]:
            lines.append(f"  {base}: {members}")
        lines.append("")
        lines.append(
            "FINDING: multi-segment groups exist. The current pipeline uses "
            "StandardScaler + StratifiedKFold with no GroupKFold — segments "
            "of the same base track can be split across train/validation, "
            "which would leak track-level information (recording conditions, "
            "mastering, etc.) across the CV boundary. This affects the "
            "independent-test-set design in nested_cv.py, which groups by "
            "base track before splitting."
        )
    else:
        lines.append(
            "FINDING: no filename-based segment groups detected. Filenames "
            "appear to be one-file-per-track (e.g. gtzan_blues_0000.wav), "
            "so standard stratified splitting does not leak track identity "
            "via segment numbering. This does not rule out leakage from "
            "duplicate or near-duplicate source audio uploaded under "
            "different filenames, which is not filename-detectable."
        )

    out_path = TABLES_DIR / "segment_leakage_report.txt"
    out_path.write_text("\n".join(lines), encoding="utf-8")
    print(f"\nSegment leakage report written: {out_path}")
    print("\n".join(lines))


def run() -> None:
    rows = _load_rows()
    TABLES_DIR.mkdir(parents=True, exist_ok=True)

    print("=" * 70)
    print("STEP 1/3 — Dataset bias table (per-source composition)")
    print("=" * 70)
    dataset_bias_table(rows)

    print("\n" + "=" * 70)
    print("STEP 2/3 — Genre bias test (natural vs. genre-balanced)")
    print("=" * 70)
    genre_bias_test(rows)

    print("\n" + "=" * 70)
    print("STEP 3/3 — Segment/track leakage check")
    print("=" * 70)
    segment_leakage_report(rows)


if __name__ == "__main__":
    run()