Spaces:
Sleeping
Sleeping
feat: add dataset statistics and class balance visualizations to enhance analysis capabilities
Browse files
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 |
# ═══════════════════════════════════════════════════════════════════════
|