Spaces:
Running
Running
Download app/training/visualize_results.py from Rthur2003/crowncode-backend: direct link, hf CLI and curl.
- Browser
- Download file 40.6 kB
-
https://huggingface.co/spaces/Rthur2003/crowncode-backend/resolve/main/app/training/visualize_results.py
- Command line
-
hf download hf://spaces/Rthur2003/crowncode-backend/app/training/visualize_results.py
-
curl -L -o visualize_results.py https://huggingface.co/spaces/Rthur2003/crowncode-backend/resolve/main/app/training/visualize_results.py
40.6 kB
| """ | |
| Publication-quality visualization pipeline for AURIS. | |
| Generates all figures required for an academic paper / conference | |
| submission on AI-generated music detection: | |
| 1. ROC curves (per-model overlay) | |
| 2. Precision-Recall curves (per-model overlay) | |
| 3. Confusion matrices (heatmap per model) | |
| 4. Model comparison bar chart (Accuracy, F1, AUC side-by-side) | |
| 5. Feature importance (top-N horizontal bar) | |
| 6. Feature correlation heatmap | |
| 7. Feature distribution violin plots (AI vs Human) | |
| 8. Training summary table (LaTeX-ready) | |
| Usage: | |
| python -m app.training.visualize_results \\ | |
| --results models/training_results.json \\ | |
| --features data/training/features.csv \\ | |
| --output figures/ | |
| All figures are saved at 300 DPI in both PNG and PDF formats | |
| for direct inclusion in LaTeX / Word documents. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import json | |
| import sys | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| try: | |
| import matplotlib | |
| matplotlib.use("Agg") # Non-interactive backend for server/CI | |
| import matplotlib.pyplot as plt | |
| import matplotlib.ticker as mticker | |
| from matplotlib.colors import LinearSegmentedColormap | |
| except ImportError: | |
| print("ERROR: matplotlib required. pip install matplotlib") | |
| sys.exit(1) | |
| try: | |
| import seaborn as sns | |
| HAS_SEABORN = True | |
| except ImportError: | |
| HAS_SEABORN = False | |
| from sklearn.metrics import ( | |
| auc, | |
| confusion_matrix, | |
| precision_recall_curve, | |
| roc_curve, | |
| ) | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Style configuration — academic paper quality | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Color palette — distinct, colorblind-friendly | |
| MODEL_COLORS = { | |
| "Logistic Regression": "#4363d8", | |
| "Random Forest": "#3cb44b", | |
| "Gradient Boosting": "#e6194b", | |
| "SVM (RBF)": "#f58231", | |
| "MLP Neural Network": "#911eb4", | |
| "XGBoost": "#42d4f4", | |
| "LightGBM": "#f032e6", | |
| "Heuristic (no vocals)": "#808080", | |
| "Heuristic + Vocals": "#a9a9a9", | |
| } | |
| AURIS_BLUE = "#1a73e8" | |
| AURIS_RED = "#e8431a" | |
| plt.rcParams.update({ | |
| "font.family": "serif", | |
| "font.size": 11, | |
| "axes.titlesize": 13, | |
| "axes.labelsize": 12, | |
| "xtick.labelsize": 10, | |
| "ytick.labelsize": 10, | |
| "legend.fontsize": 9, | |
| "figure.dpi": 150, | |
| "savefig.dpi": 300, | |
| "savefig.bbox": "tight", | |
| "savefig.pad_inches": 0.1, | |
| "axes.grid": True, | |
| "grid.alpha": 0.3, | |
| "axes.spines.top": False, | |
| "axes.spines.right": False, | |
| }) | |
| def _save_fig(fig: plt.Figure, output_dir: Path, name: str) -> None: | |
| """Save figure in both PNG and PDF formats.""" | |
| fig.savefig(output_dir / f"{name}.png", format="png") | |
| fig.savefig(output_dir / f"{name}.pdf", format="pdf") | |
| plt.close(fig) | |
| print(f" Saved: {name}.png / .pdf") | |
| def _get_color(name: str) -> str: | |
| return MODEL_COLORS.get(name, "#333333") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Figure 1: ROC Curves | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def plot_roc_curves( | |
| results: dict[str, Any], | |
| output_dir: Path, | |
| ) -> None: | |
| """Plot ROC curves for all models on the same axes.""" | |
| fig, ax = plt.subplots(figsize=(7, 6)) | |
| for name, data in results.items(): | |
| if name.startswith("_"): | |
| continue | |
| y_true = np.array(data["y_true"]) | |
| y_prob = np.array(data["y_prob"]) | |
| fpr, tpr, _ = roc_curve(y_true, y_prob) | |
| roc_auc = auc(fpr, tpr) | |
| ax.plot( | |
| fpr, tpr, | |
| color=_get_color(name), | |
| linewidth=2, | |
| label=f"{name} (AUC = {roc_auc:.3f})", | |
| ) | |
| # Diagonal reference | |
| ax.plot([0, 1], [0, 1], "k--", linewidth=1, alpha=0.5, label="Random (AUC = 0.500)") | |
| ax.set_xlim([-0.02, 1.02]) | |
| ax.set_ylim([-0.02, 1.02]) | |
| ax.set_xlabel("False Positive Rate") | |
| ax.set_ylabel("True Positive Rate") | |
| ax.set_title("ROC Curves — AURIS Model Comparison") | |
| ax.legend(loc="lower right", framealpha=0.9) | |
| ax.set_aspect("equal") | |
| _save_fig(fig, output_dir, "fig1_roc_curves") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Figure 2: Precision-Recall Curves | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def plot_pr_curves( | |
| results: dict[str, Any], | |
| output_dir: Path, | |
| ) -> None: | |
| """Plot Precision-Recall curves for all models.""" | |
| fig, ax = plt.subplots(figsize=(7, 6)) | |
| for name, data in results.items(): | |
| if name.startswith("_"): | |
| continue | |
| y_true = np.array(data["y_true"]) | |
| y_prob = np.array(data["y_prob"]) | |
| precision, recall, _ = precision_recall_curve(y_true, y_prob) | |
| pr_auc = auc(recall, precision) | |
| ax.plot( | |
| recall, precision, | |
| color=_get_color(name), | |
| linewidth=2, | |
| label=f"{name} (AP = {pr_auc:.3f})", | |
| ) | |
| # Baseline: proportion of positives | |
| all_y = [] | |
| for name, data in results.items(): | |
| if not name.startswith("_"): | |
| all_y = data["y_true"] | |
| break | |
| baseline = np.mean(all_y) if all_y else 0.5 | |
| ax.axhline(y=baseline, color="k", linestyle="--", linewidth=1, alpha=0.5, | |
| label=f"Baseline ({baseline:.2f})") | |
| ax.set_xlim([-0.02, 1.02]) | |
| ax.set_ylim([-0.02, 1.05]) | |
| ax.set_xlabel("Recall") | |
| ax.set_ylabel("Precision") | |
| ax.set_title("Precision-Recall Curves — AURIS Model Comparison") | |
| ax.legend(loc="lower left", framealpha=0.9) | |
| _save_fig(fig, output_dir, "fig2_pr_curves") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Figure 3: Confusion Matrices | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def plot_confusion_matrices( | |
| results: dict[str, Any], | |
| output_dir: Path, | |
| ) -> None: | |
| """Plot confusion matrix heatmap for each model.""" | |
| model_names = [k for k in results if not k.startswith("_")] | |
| n_models = len(model_names) | |
| cols = min(3, n_models) | |
| rows = (n_models + cols - 1) // cols | |
| fig, axes = plt.subplots(rows, cols, figsize=(5 * cols, 4.5 * rows)) | |
| if n_models == 1: | |
| axes = np.array([axes]) | |
| axes = axes.flatten() | |
| cmap = LinearSegmentedColormap.from_list("auris", ["#ffffff", AURIS_BLUE]) | |
| for idx, name in enumerate(model_names): | |
| ax = axes[idx] | |
| data = results[name] | |
| y_true = np.array(data["y_true"]) | |
| y_pred = np.array(data["y_pred"]) | |
| cm = confusion_matrix(y_true, y_pred) | |
| cm_norm = cm.astype(float) / cm.sum(axis=1, keepdims=True) | |
| im = ax.imshow(cm_norm, interpolation="nearest", cmap=cmap, vmin=0, vmax=1) | |
| # Annotate cells with count and percentage | |
| for i in range(2): | |
| for j in range(2): | |
| color = "white" if cm_norm[i, j] > 0.6 else "black" | |
| ax.text(j, i, f"{cm[i, j]}\n({cm_norm[i, j]:.1%})", | |
| ha="center", va="center", fontsize=12, color=color, | |
| fontweight="bold") | |
| ax.set_xticks([0, 1]) | |
| ax.set_yticks([0, 1]) | |
| ax.set_xticklabels(["Human", "AI"]) | |
| ax.set_yticklabels(["Human", "AI"]) | |
| ax.set_xlabel("Predicted") | |
| ax.set_ylabel("Actual") | |
| ax.set_title(name, fontsize=11) | |
| # Hide unused axes | |
| for idx in range(n_models, len(axes)): | |
| axes[idx].set_visible(False) | |
| fig.suptitle("Confusion Matrices — AURIS Model Comparison", fontsize=14, y=1.02) | |
| fig.tight_layout() | |
| _save_fig(fig, output_dir, "fig3_confusion_matrices") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Figure 4: Model Comparison Bar Chart | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def plot_model_comparison( | |
| results: dict[str, Any], | |
| output_dir: Path, | |
| ) -> None: | |
| """Bar chart comparing Accuracy, F1, Precision, Recall, AUC across models.""" | |
| model_names = [k for k in results if not k.startswith("_")] | |
| metrics = ["accuracy", "precision", "recall", "f1", "roc_auc"] | |
| metric_labels = ["Accuracy", "Precision", "Recall", "F1 Score", "ROC-AUC"] | |
| x = np.arange(len(model_names)) | |
| width = 0.15 | |
| metric_colors = ["#4363d8", "#3cb44b", "#e6194b", "#f58231", "#911eb4"] | |
| fig, ax = plt.subplots(figsize=(max(10, len(model_names) * 2), 6)) | |
| for i, (metric, label, color) in enumerate(zip(metrics, metric_labels, metric_colors)): | |
| values = [] | |
| for name in model_names: | |
| val = results[name].get(metric, 0) | |
| values.append(val if val is not None else 0) | |
| bars = ax.bar(x + i * width, values, width, label=label, color=color, alpha=0.85) | |
| # Value labels on bars | |
| for bar, val in zip(bars, values): | |
| ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01, | |
| f"{val:.2f}", ha="center", va="bottom", fontsize=7) | |
| ax.set_xlabel("Model") | |
| ax.set_ylabel("Score") | |
| ax.set_title("Model Performance Comparison — AURIS") | |
| ax.set_xticks(x + width * 2) | |
| ax.set_xticklabels(model_names, rotation=25, ha="right") | |
| ax.set_ylim([0, 1.12]) | |
| ax.legend(loc="upper right", ncol=5, framealpha=0.9) | |
| ax.yaxis.set_major_formatter(mticker.PercentFormatter(1.0)) | |
| fig.tight_layout() | |
| _save_fig(fig, output_dir, "fig4_model_comparison") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Figure 5: Feature Importance | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def plot_feature_importance( | |
| results: dict[str, Any], | |
| output_dir: Path, | |
| top_n: int = 20, | |
| ) -> None: | |
| """Horizontal bar chart of top-N feature importances.""" | |
| importance = results.get("_feature_importance") | |
| if not importance: | |
| print(" Skipping feature importance — no data available") | |
| return | |
| # Sort and take top N | |
| sorted_features = sorted(importance.items(), key=lambda x: x[1], reverse=True)[:top_n] | |
| names = [f[0] for f in reversed(sorted_features)] | |
| values = [f[1] for f in reversed(sorted_features)] | |
| fig, ax = plt.subplots(figsize=(8, max(6, top_n * 0.35))) | |
| colors = plt.cm.Blues(np.linspace(0.3, 0.9, len(names))) | |
| bars = ax.barh(names, values, color=colors, edgecolor="white", linewidth=0.5) | |
| # Value labels | |
| for bar, val in zip(bars, values): | |
| ax.text(bar.get_width() + 0.002, bar.get_y() + bar.get_height() / 2, | |
| f"{val:.4f}", ha="left", va="center", fontsize=9) | |
| ax.set_xlabel("Relative Importance") | |
| ax.set_title(f"Top {top_n} Feature Importances — AURIS ({results.get('_best_model', '')})") | |
| ax.set_xlim([0, max(values) * 1.15]) | |
| fig.tight_layout() | |
| _save_fig(fig, output_dir, "fig5_feature_importance") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Figure 6: Feature Correlation Heatmap | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def plot_correlation_heatmap( | |
| features_csv: Path, | |
| output_dir: Path, | |
| ) -> None: | |
| """Correlation heatmap of all features.""" | |
| X, y, feature_cols = _load_features_with_names(features_csv) | |
| corr = np.corrcoef(X.T) | |
| fig, ax = plt.subplots(figsize=(max(12, len(feature_cols) * 0.4), | |
| max(10, len(feature_cols) * 0.35))) | |
| cmap = "RdBu_r" if HAS_SEABORN else "coolwarm" | |
| if HAS_SEABORN: | |
| sns.heatmap( | |
| corr, xticklabels=feature_cols, yticklabels=feature_cols, | |
| cmap=cmap, center=0, vmin=-1, vmax=1, | |
| square=True, linewidths=0.5, ax=ax, | |
| cbar_kws={"shrink": 0.8, "label": "Pearson Correlation"}, | |
| ) | |
| else: | |
| im = ax.imshow(corr, cmap=cmap, vmin=-1, vmax=1, aspect="auto") | |
| ax.set_xticks(range(len(feature_cols))) | |
| ax.set_yticks(range(len(feature_cols))) | |
| ax.set_xticklabels(feature_cols, rotation=90, fontsize=7) | |
| ax.set_yticklabels(feature_cols, fontsize=7) | |
| fig.colorbar(im, ax=ax, shrink=0.8, label="Pearson Correlation") | |
| ax.set_title("Feature Correlation Matrix — AURIS", fontsize=14, pad=20) | |
| fig.tight_layout() | |
| _save_fig(fig, output_dir, "fig6_correlation_heatmap") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Figure 7: Feature Distribution (Violin / Box Plots) | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def plot_feature_distributions( | |
| features_csv: Path, | |
| output_dir: Path, | |
| results: dict[str, Any] | None = None, | |
| top_n: int = 12, | |
| ) -> None: | |
| """Violin plots showing feature distributions for AI vs Human.""" | |
| X, y, feature_cols = _load_features_with_names(features_csv) | |
| # Select top features by importance, or first N | |
| if results and "_feature_importance" in results: | |
| sorted_feats = sorted( | |
| results["_feature_importance"].items(), | |
| key=lambda x: x[1], reverse=True, | |
| ) | |
| selected = [f[0] for f in sorted_feats[:top_n] if f[0] in feature_cols] | |
| else: | |
| selected = feature_cols[:top_n] | |
| n_features = len(selected) | |
| cols = 3 | |
| rows = (n_features + cols - 1) // cols | |
| fig, axes = plt.subplots(rows, cols, figsize=(5 * cols, 3.5 * rows)) | |
| axes = axes.flatten() | |
| for idx, feat_name in enumerate(selected): | |
| ax = axes[idx] | |
| col_idx = feature_cols.index(feat_name) | |
| human_vals = X[y == 0, col_idx] | |
| ai_vals = X[y == 1, col_idx] | |
| if HAS_SEABORN: | |
| data_list = [] | |
| for val in human_vals: | |
| data_list.append({"Feature": feat_name, "Value": val, "Class": "Human"}) | |
| for val in ai_vals: | |
| data_list.append({"Feature": feat_name, "Value": val, "Class": "AI"}) | |
| import pandas as pd | |
| df = pd.DataFrame(data_list) | |
| sns.violinplot( | |
| data=df, x="Class", y="Value", | |
| palette={"Human": AURIS_BLUE, "AI": AURIS_RED}, | |
| ax=ax, inner="quartile", linewidth=1, | |
| ) | |
| else: | |
| parts = ax.violinplot( | |
| [human_vals, ai_vals], | |
| positions=[0, 1], | |
| showmeans=True, showmedians=True, | |
| ) | |
| ax.set_xticks([0, 1]) | |
| ax.set_xticklabels(["Human", "AI"]) | |
| ax.set_title(feat_name, fontsize=10) | |
| ax.set_xlabel("") | |
| for idx in range(n_features, len(axes)): | |
| axes[idx].set_visible(False) | |
| fig.suptitle("Feature Distributions — AI vs Human", fontsize=14, y=1.01) | |
| fig.tight_layout() | |
| _save_fig(fig, output_dir, "fig7_feature_distributions") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Figure 8: Training Summary Table (LaTeX) | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def generate_latex_table( | |
| results: dict[str, Any], | |
| output_dir: Path, | |
| ) -> None: | |
| """Generate LaTeX-ready comparison table.""" | |
| model_names = [k for k in results if not k.startswith("_")] | |
| best_model = results.get("_best_model", "") | |
| lines = [ | |
| r"\begin{table}[htbp]", | |
| r"\centering", | |
| r"\caption{AURIS Model Performance Comparison}", | |
| r"\label{tab:model-comparison}", | |
| r"\begin{tabular}{lccccc}", | |
| r"\toprule", | |
| r"Model & Accuracy & Precision & Recall & F1 & ROC-AUC \\", | |
| r"\midrule", | |
| ] | |
| for name in model_names: | |
| data = results[name] | |
| acc = data.get("accuracy", 0) | |
| prec = data.get("precision", 0) | |
| rec = data.get("recall", 0) | |
| f1 = data.get("f1", 0) | |
| roc = data.get("roc_auc", 0) | |
| # Bold the best model | |
| prefix = r"\textbf{" if name == best_model else "" | |
| suffix = "}" if name == best_model else "" | |
| row = ( | |
| f" {prefix}{name}{suffix} & " | |
| f"{prefix}{acc:.4f}{suffix} & " | |
| f"{prefix}{prec:.4f}{suffix} & " | |
| f"{prefix}{rec:.4f}{suffix} & " | |
| f"{prefix}{f1:.4f}{suffix} & " | |
| f"{prefix}{roc:.4f}{suffix} \\\\" | |
| ) | |
| lines.append(row) | |
| lines.extend([ | |
| r"\bottomrule", | |
| r"\end{tabular}", | |
| r"\end{table}", | |
| ]) | |
| latex_content = "\n".join(lines) | |
| table_path = output_dir / "table1_model_comparison.tex" | |
| table_path.write_text(latex_content, encoding="utf-8") | |
| print(f" Saved: table1_model_comparison.tex") | |
| # Also save as markdown for README | |
| md_lines = [ | |
| "| Model | Accuracy | Precision | Recall | F1 | ROC-AUC |", | |
| "|-------|----------|-----------|--------|-----|---------|", | |
| ] | |
| for name in model_names: | |
| data = results[name] | |
| bold = "**" if name == best_model else "" | |
| md_lines.append( | |
| f"| {bold}{name}{bold} | " | |
| f"{data.get('accuracy', 0):.4f} | " | |
| f"{data.get('precision', 0):.4f} | " | |
| f"{data.get('recall', 0):.4f} | " | |
| f"{data.get('f1', 0):.4f} | " | |
| f"{data.get('roc_auc', 0):.4f} |" | |
| ) | |
| md_path = output_dir / "table1_model_comparison.md" | |
| md_path.write_text("\n".join(md_lines), encoding="utf-8") | |
| print(f" Saved: table1_model_comparison.md") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Utilities | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def _load_features_with_names( | |
| features_csv: Path, | |
| ) -> tuple[np.ndarray, np.ndarray, list[str]]: | |
| """Load features CSV returning X, y, and column names.""" | |
| rows = [] | |
| labels = [] | |
| with open(features_csv, "r", encoding="utf-8") as f: | |
| reader = csv.DictReader(f) | |
| feature_cols = [ | |
| c for c in reader.fieldnames | |
| if c not in ("file_path", "label_int") | |
| ] | |
| for row in reader: | |
| feat_values = [] | |
| for col in feature_cols: | |
| try: | |
| feat_values.append(float(row[col])) | |
| except (ValueError, KeyError): | |
| feat_values.append(0.0) | |
| rows.append(feat_values) | |
| labels.append(int(row["label_int"])) | |
| X = np.nan_to_num(np.array(rows, dtype=np.float32), nan=0.0) | |
| y = np.array(labels, dtype=np.int32) | |
| return X, y, feature_cols | |
| def _load_manifest(manifest_csv: Path) -> list[dict]: | |
| """Load manifest.csv rows as dicts.""" | |
| if not manifest_csv.exists(): | |
| return [] | |
| with open(manifest_csv, "r", encoding="utf-8") as f: | |
| return list(csv.DictReader(f)) | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Figure 9: Dataset Statistics | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def plot_dataset_statistics( | |
| manifest_csv: Path, | |
| output_dir: Path, | |
| ) -> None: | |
| """Dataset composition: class distribution, source distribution, duration.""" | |
| rows = _load_manifest(manifest_csv) | |
| if not rows: | |
| print(" Skipping dataset stats — no manifest") | |
| return | |
| from collections import Counter | |
| labels = [r.get("label", "unknown") for r in rows] | |
| sources = [r.get("generator", "unknown") for r in rows] | |
| durations = [] | |
| for r in rows: | |
| try: | |
| durations.append(float(r.get("duration_sec", 0))) | |
| except ValueError: | |
| continue | |
| label_counts = Counter(labels) | |
| source_counts = Counter(sources) | |
| fig, axes = plt.subplots(1, 3, figsize=(16, 5)) | |
| # (a) Class distribution | |
| ax = axes[0] | |
| colors = [AURIS_BLUE if l == "human" else AURIS_RED for l in label_counts.keys()] | |
| bars = ax.bar(label_counts.keys(), label_counts.values(), | |
| color=colors, edgecolor="black", linewidth=0.5) | |
| for bar, val in zip(bars, label_counts.values()): | |
| pct = val / sum(label_counts.values()) * 100 | |
| ax.text(bar.get_x() + bar.get_width() / 2, | |
| bar.get_height() + max(label_counts.values()) * 0.02, | |
| f"{val:,}\n({pct:.1f}%)", | |
| ha="center", va="bottom", fontsize=10) | |
| ax.set_title("Class Distribution") | |
| ax.set_ylabel("Samples") | |
| ax.set_ylim([0, max(label_counts.values()) * 1.18]) | |
| # (b) Source distribution | |
| ax = axes[1] | |
| sorted_sources = sorted(source_counts.items(), key=lambda x: x[1], reverse=True) | |
| src_names = [s[0] for s in sorted_sources] | |
| src_values = [s[1] for s in sorted_sources] | |
| bars = ax.barh(src_names, src_values, color=plt.cm.tab10.colors[:len(src_names)]) | |
| for bar, val in zip(bars, src_values): | |
| ax.text(bar.get_width() + max(src_values) * 0.01, | |
| bar.get_y() + bar.get_height() / 2, | |
| f"{val:,}", ha="left", va="center", fontsize=9) | |
| ax.set_title("Dataset Source Distribution") | |
| ax.set_xlabel("Samples") | |
| ax.set_xlim([0, max(src_values) * 1.15]) | |
| ax.invert_yaxis() | |
| # (c) Duration histogram | |
| ax = axes[2] | |
| if durations: | |
| ax.hist(durations, bins=40, color=AURIS_BLUE, edgecolor="white", alpha=0.8) | |
| ax.axvline(np.median(durations), color="red", linestyle="--", | |
| label=f"Median: {np.median(durations):.1f}s") | |
| ax.axvline(np.mean(durations), color="orange", linestyle="--", | |
| label=f"Mean: {np.mean(durations):.1f}s") | |
| ax.legend() | |
| ax.set_title("Audio Duration Distribution") | |
| ax.set_xlabel("Duration (seconds)") | |
| ax.set_ylabel("Count") | |
| fig.suptitle( | |
| f"AURIS Dataset — {sum(label_counts.values()):,} samples", | |
| fontsize=14, y=1.02, | |
| ) | |
| fig.tight_layout() | |
| _save_fig(fig, output_dir, "fig9_dataset_statistics") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Figure 10: Per-Source Class Balance | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def plot_per_source_balance( | |
| manifest_csv: Path, | |
| output_dir: Path, | |
| ) -> None: | |
| """Stacked bar: AI vs Human count per source.""" | |
| rows = _load_manifest(manifest_csv) | |
| if not rows: | |
| return | |
| from collections import defaultdict | |
| source_label_counts: dict[str, dict[str, int]] = defaultdict( | |
| lambda: {"ai": 0, "human": 0} | |
| ) | |
| for r in rows: | |
| src = r.get("generator", "unknown") | |
| lbl = r.get("label", "unknown") | |
| if lbl in ("ai", "human"): | |
| source_label_counts[src][lbl] += 1 | |
| sources = sorted(source_label_counts.keys(), | |
| key=lambda s: sum(source_label_counts[s].values()), | |
| reverse=True) | |
| ai_counts = [source_label_counts[s]["ai"] for s in sources] | |
| human_counts = [source_label_counts[s]["human"] for s in sources] | |
| fig, ax = plt.subplots(figsize=(10, 6)) | |
| x = np.arange(len(sources)) | |
| width = 0.38 | |
| b1 = ax.bar(x - width/2, human_counts, width, | |
| label="Human", color=AURIS_BLUE, edgecolor="black", linewidth=0.3) | |
| b2 = ax.bar(x + width/2, ai_counts, width, | |
| label="AI", color=AURIS_RED, edgecolor="black", linewidth=0.3) | |
| for bars in (b1, b2): | |
| for bar in bars: | |
| h = bar.get_height() | |
| if h > 0: | |
| ax.text(bar.get_x() + bar.get_width() / 2, h + 20, | |
| f"{int(h)}", ha="center", va="bottom", fontsize=8) | |
| ax.set_xticks(x) | |
| ax.set_xticklabels(sources, rotation=20, ha="right") | |
| ax.set_ylabel("Samples") | |
| ax.set_title("Class Balance per Source") | |
| ax.legend(loc="upper right") | |
| fig.tight_layout() | |
| _save_fig(fig, output_dir, "fig10_per_source_balance") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Figure 11: t-SNE / PCA Feature Embedding | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def plot_feature_embedding( | |
| features_csv: Path, | |
| output_dir: Path, | |
| max_samples: int = 2000, | |
| ) -> None: | |
| """2D embedding (PCA + t-SNE) of features colored by class.""" | |
| try: | |
| from sklearn.decomposition import PCA | |
| from sklearn.manifold import TSNE | |
| from sklearn.preprocessing import StandardScaler | |
| except ImportError: | |
| print(" Skipping embedding — sklearn not available") | |
| return | |
| X, y, _ = _load_features_with_names(features_csv) | |
| if len(X) > max_samples: | |
| rng = np.random.default_rng(42) | |
| idx = rng.choice(len(X), max_samples, replace=False) | |
| X = X[idx] | |
| y = y[idx] | |
| X_scaled = StandardScaler().fit_transform(X) | |
| fig, axes = plt.subplots(1, 2, figsize=(14, 6)) | |
| # PCA | |
| pca = PCA(n_components=2, random_state=42) | |
| X_pca = pca.fit_transform(X_scaled) | |
| ax = axes[0] | |
| for cls, color, label in [(0, AURIS_BLUE, "Human"), (1, AURIS_RED, "AI")]: | |
| mask = y == cls | |
| ax.scatter(X_pca[mask, 0], X_pca[mask, 1], | |
| c=color, s=12, alpha=0.5, edgecolors="none", label=label) | |
| var_exp = pca.explained_variance_ratio_ | |
| ax.set_xlabel(f"PC1 ({var_exp[0] * 100:.1f}%)") | |
| ax.set_ylabel(f"PC2 ({var_exp[1] * 100:.1f}%)") | |
| ax.set_title(f"PCA Projection (total var = {sum(var_exp) * 100:.1f}%)") | |
| ax.legend(loc="best") | |
| # t-SNE | |
| try: | |
| perplexity = min(30, max(5, len(X) // 20)) | |
| tsne = TSNE(n_components=2, perplexity=perplexity, | |
| random_state=42, max_iter=500, init="pca") | |
| X_tsne = tsne.fit_transform(X_scaled) | |
| ax = axes[1] | |
| for cls, color, label in [(0, AURIS_BLUE, "Human"), (1, AURIS_RED, "AI")]: | |
| mask = y == cls | |
| ax.scatter(X_tsne[mask, 0], X_tsne[mask, 1], | |
| c=color, s=12, alpha=0.5, edgecolors="none", label=label) | |
| ax.set_xlabel("t-SNE 1") | |
| ax.set_ylabel("t-SNE 2") | |
| ax.set_title(f"t-SNE Projection (perplexity={perplexity})") | |
| ax.legend(loc="best") | |
| except Exception as e: | |
| print(f" t-SNE failed: {e}") | |
| axes[1].set_visible(False) | |
| fig.suptitle("Feature Space Visualization — AI vs Human", fontsize=14, y=1.01) | |
| fig.tight_layout() | |
| _save_fig(fig, output_dir, "fig11_feature_embedding") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Figure 12: Probability Calibration | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def plot_calibration_curves( | |
| results: dict[str, Any], | |
| output_dir: Path, | |
| n_bins: int = 10, | |
| ) -> None: | |
| """Reliability diagram: predicted probability vs observed frequency.""" | |
| try: | |
| from sklearn.calibration import calibration_curve | |
| except ImportError: | |
| return | |
| fig, ax = plt.subplots(figsize=(7, 6)) | |
| for name, data in results.items(): | |
| if name.startswith("_"): | |
| continue | |
| y_true = np.array(data.get("y_true", [])) | |
| y_prob = np.array(data.get("y_prob", [])) | |
| if len(y_true) == 0: | |
| continue | |
| try: | |
| frac_pos, mean_pred = calibration_curve(y_true, y_prob, n_bins=n_bins) | |
| ax.plot(mean_pred, frac_pos, "o-", | |
| color=_get_color(name), label=name, linewidth=1.5, markersize=5) | |
| except Exception: | |
| continue | |
| ax.plot([0, 1], [0, 1], "k--", alpha=0.5, label="Perfect calibration") | |
| ax.set_xlabel("Mean Predicted Probability") | |
| ax.set_ylabel("Fraction of Positives") | |
| ax.set_title("Probability Calibration (Reliability Diagram)") | |
| ax.legend(loc="lower right", fontsize=8) | |
| ax.set_xlim([0, 1]) | |
| ax.set_ylim([0, 1]) | |
| ax.set_aspect("equal") | |
| fig.tight_layout() | |
| _save_fig(fig, output_dir, "fig12_calibration") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Figure 13: Prediction Score Distribution | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def plot_score_distribution( | |
| results: dict[str, Any], | |
| output_dir: Path, | |
| ) -> None: | |
| """Best model's predicted probability distribution per class.""" | |
| best_model = results.get("_best_model", "") | |
| if not best_model or best_model not in results: | |
| candidates = [k for k in results if not k.startswith("_")] | |
| if not candidates: | |
| return | |
| best_model = candidates[0] | |
| data = results[best_model] | |
| y_true = np.array(data.get("y_true", [])) | |
| y_prob = np.array(data.get("y_prob", [])) | |
| if len(y_true) == 0: | |
| return | |
| fig, ax = plt.subplots(figsize=(9, 5)) | |
| ax.hist(y_prob[y_true == 0], bins=40, alpha=0.6, | |
| color=AURIS_BLUE, label="Human (true class)", edgecolor="white") | |
| ax.hist(y_prob[y_true == 1], bins=40, alpha=0.6, | |
| color=AURIS_RED, label="AI (true class)", edgecolor="white") | |
| ax.axvline(0.5, color="black", linestyle="--", linewidth=1, alpha=0.7, | |
| label="Decision threshold (0.5)") | |
| ax.set_xlabel(f"Predicted P(AI) — {best_model}") | |
| ax.set_ylabel("Count") | |
| ax.set_title("Prediction Score Distribution — Best Model") | |
| ax.legend(loc="upper center") | |
| fig.tight_layout() | |
| _save_fig(fig, output_dir, "fig13_score_distribution") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Extended Summary Statistics Table (Markdown) | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def generate_extended_summary( | |
| results: dict[str, Any], | |
| manifest_csv: Path, | |
| features_csv: Path, | |
| output_dir: Path, | |
| ) -> None: | |
| """Comprehensive markdown summary for the academic report.""" | |
| rows = _load_manifest(manifest_csv) | |
| X, y, feature_cols = _load_features_with_names(features_csv) | |
| from collections import Counter | |
| labels = Counter(r.get("label", "?") for r in rows) | |
| sources = Counter(r.get("generator", "?") for r in rows) | |
| model_names = [k for k in results if not k.startswith("_")] | |
| best_model = results.get("_best_model", "") | |
| lines = [] | |
| lines.append("# AURIS — Dataset & Results Summary\n") | |
| lines.append("## Dataset\n") | |
| lines.append(f"- **Total samples:** {len(rows):,}") | |
| lines.append(f"- **Features extracted:** {len(feature_cols)}") | |
| lines.append(f"- **Samples with extracted features:** {len(X):,}") | |
| lines.append("") | |
| lines.append("### Class distribution") | |
| lines.append("| Class | Count | % |") | |
| lines.append("|-------|-------|---|") | |
| total = sum(labels.values()) | |
| for lbl in sorted(labels): | |
| pct = labels[lbl] / total * 100 if total else 0 | |
| lines.append(f"| {lbl} | {labels[lbl]:,} | {pct:.1f}% |") | |
| lines.append("") | |
| lines.append("### Source distribution") | |
| lines.append("| Source | Count | % |") | |
| lines.append("|--------|-------|---|") | |
| for src in sorted(sources, key=lambda s: sources[s], reverse=True): | |
| pct = sources[src] / total * 100 if total else 0 | |
| lines.append(f"| {src} | {sources[src]:,} | {pct:.1f}% |") | |
| lines.append("") | |
| lines.append("## Results\n") | |
| lines.append(f"- **Best model:** {best_model}") | |
| n_folds = results.get("_n_folds", "?") | |
| lines.append(f"- **Cross-validation folds:** {n_folds}") | |
| lines.append("") | |
| lines.append("### Model performance") | |
| lines.append("| Model | Accuracy | Precision | Recall | F1 | ROC-AUC |") | |
| lines.append("|-------|----------|-----------|--------|-----|---------|") | |
| for name in model_names: | |
| d = results[name] | |
| bold = "**" if name == best_model else "" | |
| lines.append( | |
| f"| {bold}{name}{bold} | " | |
| f"{d.get('accuracy', 0):.4f} | " | |
| f"{d.get('precision', 0):.4f} | " | |
| f"{d.get('recall', 0):.4f} | " | |
| f"{d.get('f1', 0):.4f} | " | |
| f"{d.get('roc_auc', 0):.4f} |" | |
| ) | |
| lines.append("") | |
| if "_feature_importance" in results: | |
| lines.append("### Top-15 Feature Importances") | |
| lines.append("| Rank | Feature | Importance |") | |
| lines.append("|------|---------|------------|") | |
| sorted_imp = sorted( | |
| results["_feature_importance"].items(), | |
| key=lambda x: x[1], reverse=True, | |
| )[:15] | |
| for i, (feat, imp) in enumerate(sorted_imp, 1): | |
| lines.append(f"| {i} | {feat} | {imp:.4f} |") | |
| lines.append("") | |
| output_path = output_dir / "REPORT_SUMMARY.md" | |
| output_path.write_text("\n".join(lines), encoding="utf-8") | |
| print(f" Saved: REPORT_SUMMARY.md") | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| # Main entry point | |
| # ═══════════════════════════════════════════════════════════════════════ | |
| def generate_all_figures( | |
| results_path: str | Path, | |
| features_csv: str | Path, | |
| output_dir: str | Path = "figures", | |
| manifest_csv: str | Path | None = None, | |
| ) -> None: | |
| """Generate all publication-quality figures.""" | |
| results_path = Path(results_path) | |
| features_csv = Path(features_csv) | |
| output_dir = Path(output_dir) | |
| output_dir.mkdir(parents=True, exist_ok=True) | |
| if manifest_csv is None: | |
| manifest_csv = features_csv.parent / "manifest.csv" | |
| manifest_csv = Path(manifest_csv) | |
| print(f"\nLoading results from {results_path}...", flush=True) | |
| with open(results_path, "r") as f: | |
| saved_results = json.load(f) | |
| has_arrays = any( | |
| "y_true" in v for k, v in saved_results.items() | |
| if isinstance(v, dict) and not k.startswith("_") | |
| ) | |
| print(f"\nGenerating figures in {output_dir}/...\n", flush=True) | |
| if has_arrays: | |
| print("[1/13] ROC Curves", flush=True) | |
| plot_roc_curves(saved_results, output_dir) | |
| print("[2/13] Precision-Recall Curves", flush=True) | |
| plot_pr_curves(saved_results, output_dir) | |
| print("[3/13] Confusion Matrices", flush=True) | |
| plot_confusion_matrices(saved_results, output_dir) | |
| else: | |
| print("[1-3/13] Skipping ROC/PR/CM — no per-sample predictions stored", flush=True) | |
| print("[4/13] Model Comparison Bar Chart", flush=True) | |
| plot_model_comparison(saved_results, output_dir) | |
| print("[5/13] Feature Importance", flush=True) | |
| plot_feature_importance(saved_results, output_dir) | |
| print("[6/13] Feature Correlation Heatmap", flush=True) | |
| plot_correlation_heatmap(features_csv, output_dir) | |
| print("[7/13] Feature Distributions (AI vs Human)", flush=True) | |
| plot_feature_distributions(features_csv, output_dir, saved_results) | |
| print("[8/13] LaTeX / Markdown Tables", flush=True) | |
| generate_latex_table(saved_results, output_dir) | |
| print("[9/13] Dataset Statistics", flush=True) | |
| plot_dataset_statistics(manifest_csv, output_dir) | |
| print("[10/13] Per-Source Class Balance", flush=True) | |
| plot_per_source_balance(manifest_csv, output_dir) | |
| print("[11/13] Feature Space Embedding (PCA + t-SNE)", flush=True) | |
| plot_feature_embedding(features_csv, output_dir) | |
| if has_arrays: | |
| print("[12/13] Calibration Curves", flush=True) | |
| plot_calibration_curves(saved_results, output_dir) | |
| print("[13/13] Prediction Score Distribution", flush=True) | |
| plot_score_distribution(saved_results, output_dir) | |
| else: | |
| print("[12-13/13] Skipping calibration/score dist — no probabilities stored", flush=True) | |
| print("\n[EXTRA] Report Summary (Markdown)", flush=True) | |
| generate_extended_summary(saved_results, manifest_csv, features_csv, output_dir) | |
| print(f"\nAll figures saved to {output_dir}/", flush=True) | |
| if __name__ == "__main__": | |
| parser = argparse.ArgumentParser(description="AURIS Visualization Pipeline") | |
| parser.add_argument( | |
| "--results", default="models/training_results.json", | |
| help="Path to training_results.json", | |
| ) | |
| parser.add_argument( | |
| "--features", default="data/training/features.csv", | |
| help="Path to features.csv", | |
| ) | |
| parser.add_argument( | |
| "--output", default="figures", | |
| help="Output directory for figures", | |
| ) | |
| args = parser.parse_args() | |
| generate_all_figures(args.results, args.features, args.output) | |