crowncode-backend / app /training /build_master_dataset.py
Rthur2003's picture
feat: implement automated training diagnostics, dataset bias analysis, and revision figure generation pipelines
906c392
Raw History Blame Contribute Delete
6.45 kB
"""
Joins the raw feature matrix with source/genre/generator metadata.
The training pipeline (train_classifier.py) reads DataSet/features.csv, which
contains only the 47 acoustic features plus file_path/label_int. It has no
notion of which AI generator or genre a sample came from, so none of the
reviewer-requested analyses (Leave-One-Generator-Out, dataset/genre bias,
segment-leakage checks) can be run against it directly.
DataSet/metadata.csv carries that provenance (source=generator, genre,
ai_model) but was produced by a separate pass and only covers a subset of
files. This script joins the two by filename and writes
DataSet/features_with_meta.csv, reporting anything that fails to join so the
gap is visible rather than silently dropped.
Usage:
python -m app.training.build_master_dataset
"""
from __future__ import annotations
import csv
import re
import sys
from pathlib import Path
DATASET_DIR = Path("D:/CrownCode/DataSet")
FEATURES_CSV = DATASET_DIR / "features.csv"
METADATA_CSV = DATASET_DIR / "metadata.csv"
OUTPUT_CSV = DATASET_DIR / "features_with_meta.csv"
UNMATCHED_REPORT_CSV = DATASET_DIR / "features_with_meta_unmatched_report.csv"
LABEL_MISMATCH_CSV = DATASET_DIR / "features_with_meta_label_mismatches.csv"
# Files under DataSet/*/ai/echoes_<generator>_*.{wav,mp3} have no row in
# metadata.csv, but the generator is recoverable from the filename prefix.
_ECHOES_PREFIX = re.compile(r"^echoes_([a-zA-Z0-9]+)_", re.IGNORECASE)
def _filename(path: str) -> str:
return path.replace("\\", "/").rsplit("/", 1)[-1]
def _infer_generator_from_filename(filename: str, label_int: int) -> tuple[str, str] | None:
"""Best-effort (generator, ai_model) recovery for files absent from metadata.csv."""
m = _ECHOES_PREFIX.match(filename)
if m:
return "echoes", f"echoes_{m.group(1).lower()}"
if label_int == 0:
return "archive_org", ""
return None
def build() -> None:
with open(FEATURES_CSV, "r", encoding="utf-8") as f:
reader = csv.DictReader(f)
feature_fieldnames = reader.fieldnames or []
feature_rows = list(reader)
with open(METADATA_CSV, "r", encoding="utf-8") as f:
meta_rows = list(csv.DictReader(f))
meta_by_filename: dict[str, dict] = {}
for row in meta_rows:
meta_by_filename[_filename(row["path"])] = row
matched_direct = 0
matched_inferred = 0
truly_unmatched: list[dict] = []
label_mismatches: list[dict] = []
out_fieldnames = feature_fieldnames + [
"genre", "generator", "ai_model", "meta_sample_rate", "meta_duration_sec",
"match_source",
]
with open(OUTPUT_CSV, "w", newline="", encoding="utf-8") as f_out:
writer = csv.DictWriter(f_out, fieldnames=out_fieldnames)
writer.writeheader()
for row in feature_rows:
fname = _filename(row["file_path"])
meta = meta_by_filename.get(fname)
if meta is not None:
meta_label_int = 1 if meta["label"] == "ai" else 0
if int(row["label_int"]) != meta_label_int:
mismatch_row = dict(row)
mismatch_row["features_label_int"] = row["label_int"]
mismatch_row["metadata_label"] = meta["label"]
mismatch_row["generator"] = meta["source"]
mismatch_row["genre"] = meta["genre"]
label_mismatches.append(mismatch_row)
# features.csv (the file the training pipeline actually
# reads) is treated as the ground-truth label; metadata's
# label disagreement is recorded but does not override it.
out_row = dict(row)
out_row["genre"] = meta["genre"]
out_row["generator"] = meta["source"]
out_row["ai_model"] = meta["ai_model"]
out_row["meta_sample_rate"] = meta["sample_rate"]
out_row["meta_duration_sec"] = meta["duration_sec"]
out_row["match_source"] = "metadata_csv"
writer.writerow(out_row)
matched_direct += 1
continue
inferred = _infer_generator_from_filename(fname, int(row["label_int"]))
if inferred is not None:
generator, ai_model = inferred
out_row = dict(row)
out_row["genre"] = ""
out_row["generator"] = generator
out_row["ai_model"] = ai_model
out_row["meta_sample_rate"] = ""
out_row["meta_duration_sec"] = ""
out_row["match_source"] = "filename_inferred"
writer.writerow(out_row)
matched_inferred += 1
continue
truly_unmatched.append(row)
if truly_unmatched:
with open(UNMATCHED_REPORT_CSV, "w", newline="", encoding="utf-8") as f_out:
writer = csv.DictWriter(f_out, fieldnames=feature_fieldnames)
writer.writeheader()
writer.writerows(truly_unmatched)
if label_mismatches:
mismatch_fieldnames = feature_fieldnames + [
"features_label_int", "metadata_label", "generator", "genre",
]
with open(LABEL_MISMATCH_CSV, "w", newline="", encoding="utf-8") as f_out:
writer = csv.DictWriter(f_out, fieldnames=mismatch_fieldnames)
writer.writeheader()
writer.writerows(label_mismatches)
total_matched = matched_direct + matched_inferred
print(f"features.csv rows: {len(feature_rows)}")
print(f"metadata.csv rows: {len(meta_rows)}")
print(f"matched via metadata.csv: {matched_direct}")
print(f"matched via filename inference: {matched_inferred}")
print(f"total matched: {total_matched} / {len(feature_rows)}")
print(f"truly unmatched: {len(truly_unmatched)}")
print(f"label mismatches (features.csv vs metadata.csv label): {len(label_mismatches)}")
print(f"\nOutput: {OUTPUT_CSV}")
if truly_unmatched:
print(f"Unmatched report: {UNMATCHED_REPORT_CSV}")
if label_mismatches:
print(f"Label mismatches: {LABEL_MISMATCH_CSV}")
gens = {}
for m in label_mismatches:
gens[m["generator"]] = gens.get(m["generator"], 0) + 1
print(f" by generator: {gens}")
if __name__ == "__main__":
build()