Rthur2003 commited on
Commit
a04f1c8
·
1 Parent(s): 2a59987

feat: add dataset statistics and class balance visualizations to enhance analysis capabilities

Browse files
Files changed (1) hide show
  1. app/training/visualize_results.py +381 -0
app/training/visualize_results.py CHANGED
@@ -556,6 +556,387 @@ def _load_features_with_names(
556
  return X, y, feature_cols
557
 
558
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
559
  # ═══════════════════════════════════════════════════════════════════════
560
  # Main entry point
561
  # ═══════════════════════════════════════════════════════════════════════
 
556
  return X, y, feature_cols
557
 
558
 
559
+ def _load_manifest(manifest_csv: Path) -> list[dict]:
560
+ """Load manifest.csv rows as dicts."""
561
+ if not manifest_csv.exists():
562
+ return []
563
+ with open(manifest_csv, "r", encoding="utf-8") as f:
564
+ return list(csv.DictReader(f))
565
+
566
+
567
+ # ═══════════════════════════════════════════════════════════════════════
568
+ # Figure 9: Dataset Statistics
569
+ # ═══════════════════════════════════════════════════════════════════════
570
+
571
+ def plot_dataset_statistics(
572
+ manifest_csv: Path,
573
+ output_dir: Path,
574
+ ) -> None:
575
+ """Dataset composition: class distribution, source distribution, duration."""
576
+ rows = _load_manifest(manifest_csv)
577
+ if not rows:
578
+ print(" Skipping dataset stats — no manifest")
579
+ return
580
+
581
+ from collections import Counter
582
+
583
+ labels = [r.get("label", "unknown") for r in rows]
584
+ sources = [r.get("generator", "unknown") for r in rows]
585
+ durations = []
586
+ for r in rows:
587
+ try:
588
+ durations.append(float(r.get("duration_sec", 0)))
589
+ except ValueError:
590
+ continue
591
+
592
+ label_counts = Counter(labels)
593
+ source_counts = Counter(sources)
594
+
595
+ fig, axes = plt.subplots(1, 3, figsize=(16, 5))
596
+
597
+ # (a) Class distribution
598
+ ax = axes[0]
599
+ colors = [AURIS_BLUE if l == "human" else AURIS_RED for l in label_counts.keys()]
600
+ bars = ax.bar(label_counts.keys(), label_counts.values(),
601
+ color=colors, edgecolor="black", linewidth=0.5)
602
+ for bar, val in zip(bars, label_counts.values()):
603
+ pct = val / sum(label_counts.values()) * 100
604
+ ax.text(bar.get_x() + bar.get_width() / 2,
605
+ bar.get_height() + max(label_counts.values()) * 0.02,
606
+ f"{val:,}\n({pct:.1f}%)",
607
+ ha="center", va="bottom", fontsize=10)
608
+ ax.set_title("Class Distribution")
609
+ ax.set_ylabel("Samples")
610
+ ax.set_ylim([0, max(label_counts.values()) * 1.18])
611
+
612
+ # (b) Source distribution
613
+ ax = axes[1]
614
+ sorted_sources = sorted(source_counts.items(), key=lambda x: x[1], reverse=True)
615
+ src_names = [s[0] for s in sorted_sources]
616
+ src_values = [s[1] for s in sorted_sources]
617
+ bars = ax.barh(src_names, src_values, color=plt.cm.tab10.colors[:len(src_names)])
618
+ for bar, val in zip(bars, src_values):
619
+ ax.text(bar.get_width() + max(src_values) * 0.01,
620
+ bar.get_y() + bar.get_height() / 2,
621
+ f"{val:,}", ha="left", va="center", fontsize=9)
622
+ ax.set_title("Dataset Source Distribution")
623
+ ax.set_xlabel("Samples")
624
+ ax.set_xlim([0, max(src_values) * 1.15])
625
+ ax.invert_yaxis()
626
+
627
+ # (c) Duration histogram
628
+ ax = axes[2]
629
+ if durations:
630
+ ax.hist(durations, bins=40, color=AURIS_BLUE, edgecolor="white", alpha=0.8)
631
+ ax.axvline(np.median(durations), color="red", linestyle="--",
632
+ label=f"Median: {np.median(durations):.1f}s")
633
+ ax.axvline(np.mean(durations), color="orange", linestyle="--",
634
+ label=f"Mean: {np.mean(durations):.1f}s")
635
+ ax.legend()
636
+ ax.set_title("Audio Duration Distribution")
637
+ ax.set_xlabel("Duration (seconds)")
638
+ ax.set_ylabel("Count")
639
+
640
+ fig.suptitle(
641
+ f"AURIS Dataset — {sum(label_counts.values()):,} samples",
642
+ fontsize=14, y=1.02,
643
+ )
644
+ fig.tight_layout()
645
+ _save_fig(fig, output_dir, "fig9_dataset_statistics")
646
+
647
+
648
+ # ═══════════════════════════════════════════════════════════════════════
649
+ # Figure 10: Per-Source Class Balance
650
+ # ═══════════════════════════════════════════════════════════════════════
651
+
652
+ def plot_per_source_balance(
653
+ manifest_csv: Path,
654
+ output_dir: Path,
655
+ ) -> None:
656
+ """Stacked bar: AI vs Human count per source."""
657
+ rows = _load_manifest(manifest_csv)
658
+ if not rows:
659
+ return
660
+
661
+ from collections import defaultdict
662
+ source_label_counts: dict[str, dict[str, int]] = defaultdict(
663
+ lambda: {"ai": 0, "human": 0}
664
+ )
665
+ for r in rows:
666
+ src = r.get("generator", "unknown")
667
+ lbl = r.get("label", "unknown")
668
+ if lbl in ("ai", "human"):
669
+ source_label_counts[src][lbl] += 1
670
+
671
+ sources = sorted(source_label_counts.keys(),
672
+ key=lambda s: sum(source_label_counts[s].values()),
673
+ reverse=True)
674
+ ai_counts = [source_label_counts[s]["ai"] for s in sources]
675
+ human_counts = [source_label_counts[s]["human"] for s in sources]
676
+
677
+ fig, ax = plt.subplots(figsize=(10, 6))
678
+ x = np.arange(len(sources))
679
+ width = 0.38
680
+
681
+ b1 = ax.bar(x - width/2, human_counts, width,
682
+ label="Human", color=AURIS_BLUE, edgecolor="black", linewidth=0.3)
683
+ b2 = ax.bar(x + width/2, ai_counts, width,
684
+ label="AI", color=AURIS_RED, edgecolor="black", linewidth=0.3)
685
+
686
+ for bars in (b1, b2):
687
+ for bar in bars:
688
+ h = bar.get_height()
689
+ if h > 0:
690
+ ax.text(bar.get_x() + bar.get_width() / 2, h + 20,
691
+ f"{int(h)}", ha="center", va="bottom", fontsize=8)
692
+
693
+ ax.set_xticks(x)
694
+ ax.set_xticklabels(sources, rotation=20, ha="right")
695
+ ax.set_ylabel("Samples")
696
+ ax.set_title("Class Balance per Source")
697
+ ax.legend(loc="upper right")
698
+
699
+ fig.tight_layout()
700
+ _save_fig(fig, output_dir, "fig10_per_source_balance")
701
+
702
+
703
+ # ═══════════════════════════════════════════════════════════════════════
704
+ # Figure 11: t-SNE / PCA Feature Embedding
705
+ # ═══════════════════════════════════════════════════════════════════════
706
+
707
+ def plot_feature_embedding(
708
+ features_csv: Path,
709
+ output_dir: Path,
710
+ max_samples: int = 2000,
711
+ ) -> None:
712
+ """2D embedding (PCA + t-SNE) of features colored by class."""
713
+ try:
714
+ from sklearn.decomposition import PCA
715
+ from sklearn.manifold import TSNE
716
+ from sklearn.preprocessing import StandardScaler
717
+ except ImportError:
718
+ print(" Skipping embedding — sklearn not available")
719
+ return
720
+
721
+ X, y, _ = _load_features_with_names(features_csv)
722
+
723
+ if len(X) > max_samples:
724
+ rng = np.random.default_rng(42)
725
+ idx = rng.choice(len(X), max_samples, replace=False)
726
+ X = X[idx]
727
+ y = y[idx]
728
+
729
+ X_scaled = StandardScaler().fit_transform(X)
730
+
731
+ fig, axes = plt.subplots(1, 2, figsize=(14, 6))
732
+
733
+ # PCA
734
+ pca = PCA(n_components=2, random_state=42)
735
+ X_pca = pca.fit_transform(X_scaled)
736
+ ax = axes[0]
737
+ for cls, color, label in [(0, AURIS_BLUE, "Human"), (1, AURIS_RED, "AI")]:
738
+ mask = y == cls
739
+ ax.scatter(X_pca[mask, 0], X_pca[mask, 1],
740
+ c=color, s=12, alpha=0.5, edgecolors="none", label=label)
741
+ var_exp = pca.explained_variance_ratio_
742
+ ax.set_xlabel(f"PC1 ({var_exp[0] * 100:.1f}%)")
743
+ ax.set_ylabel(f"PC2 ({var_exp[1] * 100:.1f}%)")
744
+ ax.set_title(f"PCA Projection (total var = {sum(var_exp) * 100:.1f}%)")
745
+ ax.legend(loc="best")
746
+
747
+ # t-SNE
748
+ try:
749
+ perplexity = min(30, max(5, len(X) // 20))
750
+ tsne = TSNE(n_components=2, perplexity=perplexity,
751
+ random_state=42, max_iter=500, init="pca")
752
+ X_tsne = tsne.fit_transform(X_scaled)
753
+ ax = axes[1]
754
+ for cls, color, label in [(0, AURIS_BLUE, "Human"), (1, AURIS_RED, "AI")]:
755
+ mask = y == cls
756
+ ax.scatter(X_tsne[mask, 0], X_tsne[mask, 1],
757
+ c=color, s=12, alpha=0.5, edgecolors="none", label=label)
758
+ ax.set_xlabel("t-SNE 1")
759
+ ax.set_ylabel("t-SNE 2")
760
+ ax.set_title(f"t-SNE Projection (perplexity={perplexity})")
761
+ ax.legend(loc="best")
762
+ except Exception as e:
763
+ print(f" t-SNE failed: {e}")
764
+ axes[1].set_visible(False)
765
+
766
+ fig.suptitle("Feature Space Visualization — AI vs Human", fontsize=14, y=1.01)
767
+ fig.tight_layout()
768
+ _save_fig(fig, output_dir, "fig11_feature_embedding")
769
+
770
+
771
+ # ═══════════════════════════════════════════════════════════════════════
772
+ # Figure 12: Probability Calibration
773
+ # ═══════════════════════════════════════════════════════════════════════
774
+
775
+ def plot_calibration_curves(
776
+ results: dict[str, Any],
777
+ output_dir: Path,
778
+ n_bins: int = 10,
779
+ ) -> None:
780
+ """Reliability diagram: predicted probability vs observed frequency."""
781
+ try:
782
+ from sklearn.calibration import calibration_curve
783
+ except ImportError:
784
+ return
785
+
786
+ fig, ax = plt.subplots(figsize=(7, 6))
787
+
788
+ for name, data in results.items():
789
+ if name.startswith("_"):
790
+ continue
791
+ y_true = np.array(data.get("y_true", []))
792
+ y_prob = np.array(data.get("y_prob", []))
793
+ if len(y_true) == 0:
794
+ continue
795
+ try:
796
+ frac_pos, mean_pred = calibration_curve(y_true, y_prob, n_bins=n_bins)
797
+ ax.plot(mean_pred, frac_pos, "o-",
798
+ color=_get_color(name), label=name, linewidth=1.5, markersize=5)
799
+ except Exception:
800
+ continue
801
+
802
+ ax.plot([0, 1], [0, 1], "k--", alpha=0.5, label="Perfect calibration")
803
+ ax.set_xlabel("Mean Predicted Probability")
804
+ ax.set_ylabel("Fraction of Positives")
805
+ ax.set_title("Probability Calibration (Reliability Diagram)")
806
+ ax.legend(loc="lower right", fontsize=8)
807
+ ax.set_xlim([0, 1])
808
+ ax.set_ylim([0, 1])
809
+ ax.set_aspect("equal")
810
+
811
+ fig.tight_layout()
812
+ _save_fig(fig, output_dir, "fig12_calibration")
813
+
814
+
815
+ # ═══════════════════════════════════════════════════════════════════════
816
+ # Figure 13: Prediction Score Distribution
817
+ # ═══════════════════════════════════════════════════════════════════════
818
+
819
+ def plot_score_distribution(
820
+ results: dict[str, Any],
821
+ output_dir: Path,
822
+ ) -> None:
823
+ """Best model's predicted probability distribution per class."""
824
+ best_model = results.get("_best_model", "")
825
+ if not best_model or best_model not in results:
826
+ candidates = [k for k in results if not k.startswith("_")]
827
+ if not candidates:
828
+ return
829
+ best_model = candidates[0]
830
+
831
+ data = results[best_model]
832
+ y_true = np.array(data.get("y_true", []))
833
+ y_prob = np.array(data.get("y_prob", []))
834
+ if len(y_true) == 0:
835
+ return
836
+
837
+ fig, ax = plt.subplots(figsize=(9, 5))
838
+
839
+ ax.hist(y_prob[y_true == 0], bins=40, alpha=0.6,
840
+ color=AURIS_BLUE, label="Human (true class)", edgecolor="white")
841
+ ax.hist(y_prob[y_true == 1], bins=40, alpha=0.6,
842
+ color=AURIS_RED, label="AI (true class)", edgecolor="white")
843
+ ax.axvline(0.5, color="black", linestyle="--", linewidth=1, alpha=0.7,
844
+ label="Decision threshold (0.5)")
845
+
846
+ ax.set_xlabel(f"Predicted P(AI) — {best_model}")
847
+ ax.set_ylabel("Count")
848
+ ax.set_title("Prediction Score Distribution — Best Model")
849
+ ax.legend(loc="upper center")
850
+
851
+ fig.tight_layout()
852
+ _save_fig(fig, output_dir, "fig13_score_distribution")
853
+
854
+
855
+ # ═══════════════════════════════════════════════════════════════════════
856
+ # Extended Summary Statistics Table (Markdown)
857
+ # ═══════════════════════════════════════════════════════════════════════
858
+
859
+ def generate_extended_summary(
860
+ results: dict[str, Any],
861
+ manifest_csv: Path,
862
+ features_csv: Path,
863
+ output_dir: Path,
864
+ ) -> None:
865
+ """Comprehensive markdown summary for the academic report."""
866
+ rows = _load_manifest(manifest_csv)
867
+ X, y, feature_cols = _load_features_with_names(features_csv)
868
+
869
+ from collections import Counter
870
+ labels = Counter(r.get("label", "?") for r in rows)
871
+ sources = Counter(r.get("generator", "?") for r in rows)
872
+
873
+ model_names = [k for k in results if not k.startswith("_")]
874
+ best_model = results.get("_best_model", "")
875
+
876
+ lines = []
877
+ lines.append("# AURIS — Dataset & Results Summary\n")
878
+
879
+ lines.append("## Dataset\n")
880
+ lines.append(f"- **Total samples:** {len(rows):,}")
881
+ lines.append(f"- **Features extracted:** {len(feature_cols)}")
882
+ lines.append(f"- **Samples with extracted features:** {len(X):,}")
883
+ lines.append("")
884
+ lines.append("### Class distribution")
885
+ lines.append("| Class | Count | % |")
886
+ lines.append("|-------|-------|---|")
887
+ total = sum(labels.values())
888
+ for lbl in sorted(labels):
889
+ pct = labels[lbl] / total * 100 if total else 0
890
+ lines.append(f"| {lbl} | {labels[lbl]:,} | {pct:.1f}% |")
891
+ lines.append("")
892
+
893
+ lines.append("### Source distribution")
894
+ lines.append("| Source | Count | % |")
895
+ lines.append("|--------|-------|---|")
896
+ for src in sorted(sources, key=lambda s: sources[s], reverse=True):
897
+ pct = sources[src] / total * 100 if total else 0
898
+ lines.append(f"| {src} | {sources[src]:,} | {pct:.1f}% |")
899
+ lines.append("")
900
+
901
+ lines.append("## Results\n")
902
+ lines.append(f"- **Best model:** {best_model}")
903
+ n_folds = results.get("_n_folds", "?")
904
+ lines.append(f"- **Cross-validation folds:** {n_folds}")
905
+ lines.append("")
906
+
907
+ lines.append("### Model performance")
908
+ lines.append("| Model | Accuracy | Precision | Recall | F1 | ROC-AUC |")
909
+ lines.append("|-------|----------|-----------|--------|-----|---------|")
910
+ for name in model_names:
911
+ d = results[name]
912
+ bold = "**" if name == best_model else ""
913
+ lines.append(
914
+ f"| {bold}{name}{bold} | "
915
+ f"{d.get('accuracy', 0):.4f} | "
916
+ f"{d.get('precision', 0):.4f} | "
917
+ f"{d.get('recall', 0):.4f} | "
918
+ f"{d.get('f1', 0):.4f} | "
919
+ f"{d.get('roc_auc', 0):.4f} |"
920
+ )
921
+ lines.append("")
922
+
923
+ if "_feature_importance" in results:
924
+ lines.append("### Top-15 Feature Importances")
925
+ lines.append("| Rank | Feature | Importance |")
926
+ lines.append("|------|---------|------------|")
927
+ sorted_imp = sorted(
928
+ results["_feature_importance"].items(),
929
+ key=lambda x: x[1], reverse=True,
930
+ )[:15]
931
+ for i, (feat, imp) in enumerate(sorted_imp, 1):
932
+ lines.append(f"| {i} | {feat} | {imp:.4f} |")
933
+ lines.append("")
934
+
935
+ output_path = output_dir / "REPORT_SUMMARY.md"
936
+ output_path.write_text("\n".join(lines), encoding="utf-8")
937
+ print(f" Saved: REPORT_SUMMARY.md")
938
+
939
+
940
  # ═══════════════════════════════════════════════════════════════════════
941
  # Main entry point
942
  # ═══════════════════════════════════════════════════════════════════════