collablearn-int396 / src /output.py
Cyril-36's picture
Deploy CollabLearn Streamlit demo via Docker
d81f51d verified
Raw
History Blame Contribute Delete
25.8 kB
"""Persist pipeline outputs for reports and the Streamlit demo."""
from __future__ import annotations
import json
import math
from pathlib import Path
from typing import Any
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from .adapters.base import DatasetSchema
from .config import DEMO_CACHE, FIGURES, GROUP_SIZE, RESULTS, TABLES, ensure_dirs
from .plot_style import (
ACCENT,
CENTROID_COLOR,
CLUSTER_FILLS,
CLUSTER_MARKERS,
INK,
INK_FAINT,
INK_MUTED,
RULE,
SURFACE,
apply_style,
use_mono_ticks,
)
# Apply project rcParams once at module import. Idempotent.
apply_style()
def _json_safe(value: Any) -> Any:
if value is None or value is pd.NA:
return None
if isinstance(value, (np.integer,)):
return int(value)
if isinstance(value, (np.floating, float)):
value = float(value)
if not math.isfinite(value):
return None
return value
if isinstance(value, (np.bool_)):
return bool(value)
if isinstance(value, np.ndarray):
return _json_safe(value.tolist())
if isinstance(value, pd.Series):
return {key: _json_safe(val) for key, val in value.to_dict().items()}
if isinstance(value, dict):
return {str(key): _json_safe(val) for key, val in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(item) for item in value]
try:
if pd.isna(value):
return None
except (TypeError, ValueError):
pass
return value
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.write_text(json.dumps(_json_safe(payload), indent=2, allow_nan=False), encoding="utf-8")
def groups_to_frame(
ids: np.ndarray,
labels: np.ndarray,
groups: list[list[int]],
features: pd.DataFrame,
schema: DatasetSchema | None = None,
) -> pd.DataFrame:
rows = []
feature_lookup = features.reset_index(drop=True)
if schema is not None:
display_cols = [col for col in schema.display_cols if col in feature_lookup.columns]
else:
display_cols = [
col for col in [
"total_clicks",
"weighted_score",
"imd_band_ord",
"collaborative_clicks",
"collaboration_click_ratio",
"final_result",
]
if col in feature_lookup.columns
]
for group_id, members in enumerate(groups, start=1):
for idx in members:
row = {
"id_student": ids[idx],
"group_id": group_id,
"cluster": int(labels[idx]),
}
for col in display_cols:
row[col] = feature_lookup.loc[idx, col]
rows.append(row)
return pd.DataFrame(rows)
def write_pipeline_diagram(path: Path) -> None:
labels = [
"Learner dataset",
"Adapter",
"Features",
"Preprocess",
"Reducers",
"Clusterers",
"Stability",
"Selector",
"Groups",
"Evaluation",
]
fig, ax = plt.subplots(figsize=(12, 2.4))
ax.axis("off")
xs = np.linspace(0.05, 0.95, len(labels))
for x, label in zip(xs, labels):
ax.text(
x,
0.55,
label,
ha="center",
va="center",
bbox=dict(
boxstyle="round,pad=0.35",
facecolor=SURFACE,
edgecolor=INK_MUTED,
linewidth=0.8,
),
fontsize=9,
color=INK,
)
for x1, x2 in zip(xs[:-1], xs[1:]):
ax.annotate(
"",
xy=(x2 - 0.035, 0.55),
xytext=(x1 + 0.035, 0.55),
arrowprops=dict(arrowstyle="->", color=INK_MUTED, linewidth=0.6),
)
fig.tight_layout()
fig.savefig(path, dpi=200, bbox_inches="tight")
plt.close(fig)
def _fig7_validity_vs_stability(
metrics_df: pd.DataFrame,
stability_df: pd.DataFrame,
winner: pd.Series,
) -> None:
"""Validity (silhouette) vs Stability (bootstrap-ARI) scatter, primary configs."""
joined = metrics_df.drop(columns=["labels"], errors="ignore").merge(
stability_df[["config_id", "bootstrap_ari_mean"]],
on="config_id",
how="left",
)
primary = joined[joined["clusterer"].ne("hdbscan")]
if primary.empty:
return
fig, ax = plt.subplots(figsize=(8, 5.5))
# All points in ink; differentiate clusterers by marker shape only
clusterer_markers = {"kmeans": "o", "gmm": "s", "agglo": "^"}
for clusterer, sub in primary.groupby("clusterer"):
ax.scatter(
sub["silhouette"],
sub["bootstrap_ari_mean"],
s=64,
marker=clusterer_markers.get(clusterer, "o"),
facecolor=INK,
edgecolor="white",
linewidth=0.8,
label=clusterer,
alpha=0.85,
)
# Stability threshold reference line
ax.axhline(0.40, linestyle="--", color=INK_MUTED, linewidth=0.8)
ax.text(
0.005, 0.40, "stability floor (ARI = 0.40)",
transform=ax.get_yaxis_transform(),
ha="left", va="bottom",
fontsize=8, color=INK_FAINT, style="italic",
)
# Highlight the winner — terracotta diamond, leader line, label
winner_id = winner.get("config_id")
win_row = primary[primary["config_id"] == winner_id]
if not win_row.empty:
wx = float(win_row["silhouette"].iloc[0])
wy = float(win_row["bootstrap_ari_mean"].iloc[0])
ax.scatter(
wx, wy, marker="D", s=120,
facecolor=ACCENT, edgecolor="white", linewidth=1.2, zorder=10,
)
ax.annotate(
f"{winner_id} (winner)",
xy=(wx, wy),
xytext=(wx + 0.04, wy - 0.06),
fontsize=10, color=INK, weight=600,
arrowprops=dict(arrowstyle="-", color=INK_MUTED, linewidth=0.6),
)
# Tiny config_id labels on non-winner points
for _, row in primary.iterrows():
if row["config_id"] == winner_id:
continue
ax.annotate(
row["config_id"],
xy=(row["silhouette"], row["bootstrap_ari_mean"]),
xytext=(5, 4),
textcoords="offset points",
fontsize=8, color=INK_FAINT,
)
ax.set_xlabel("silhouette (higher is better)")
ax.set_ylabel("bootstrap-ARI mean (higher is better)")
ax.grid(axis="x", visible=False)
ax.legend(loc="lower right", title=None)
use_mono_ticks(ax)
fig.tight_layout()
fig.savefig(FIGURES / "fig7_scatter.png", dpi=200)
plt.close(fig)
def _fig8_embedding(
labels: np.ndarray,
X_vis: np.ndarray,
winner: pd.Series,
) -> None:
"""UMAP 2D embedding — the hero figure.
Cluster differentiation by SHAPE (per design.md §5.3 amendment), not colour.
All centroids in terracotta accent — signals "structural finding," not "this
cluster is the bad one."
"""
if X_vis.shape[1] < 2:
return
labels_arr = np.asarray(labels)
fig, ax = plt.subplots(figsize=(8, 8))
for cluster_id in sorted(set(int(l) for l in labels_arr)):
mask = labels_arr == cluster_id
if not mask.any():
continue
ax.scatter(
X_vis[mask, 0], X_vis[mask, 1],
s=18, alpha=0.7,
marker=CLUSTER_MARKERS.get(cluster_id, "o"),
facecolor=CLUSTER_FILLS.get(cluster_id, INK_MUTED),
edgecolor="white", linewidth=0.5,
)
# Centroid (skip noise cluster -1)
if cluster_id != -1:
cx, cy = X_vis[mask, 0].mean(), X_vis[mask, 1].mean()
ax.scatter(
cx, cy, marker="D", s=90,
facecolor=CENTROID_COLOR, edgecolor="white",
linewidth=1.2, zorder=10,
)
# In-cluster size annotation near each centroid; whitespace, no box
ax.annotate(
f"cluster {cluster_id} n={int(mask.sum())}",
xy=(cx, cy),
xytext=(10, 10),
textcoords="offset points",
fontsize=10, color=INK, weight=600,
)
ax.set_xlabel("umap-1")
ax.set_ylabel("umap-2")
ax.set_aspect("equal", adjustable="datalim")
ax.grid(axis="x", visible=False)
use_mono_ticks(ax)
# Caption sourced from data — never hard-code n
ax.text(
0.99, 0.01,
f"n = {len(labels_arr)} · winner = {winner.get('config_id', '—')}",
transform=ax.transAxes,
ha="right", va="bottom",
fontsize=9, color=INK_FAINT, family="monospace",
)
fig.tight_layout()
fig.savefig(FIGURES / "fig8_embedding.png", dpi=200)
plt.close(fig)
def _fig10_group_metrics(group_metrics: pd.DataFrame) -> None:
"""Group metrics by strategy — 2×3 small-multiples grid.
One metric per panel. Mode B is the only terracotta bar; everything else
rides the ink ramp. Reading order: top-row = compactness/separation,
bottom-row = social/equity metrics.
"""
if group_metrics.empty:
return
metric_order = [
"intra_group_distance",
"inter_group_variance",
"complementarity",
"engagement_balance",
"demographic_fairness",
"cluster_coverage",
]
strategies = ["random", "stratified", "mode_a", "mode_b"]
strategy_labels = ["random", "stratified", "mode A", "mode B"]
bar_colors = [INK_FAINT, INK_MUTED, INK, ACCENT]
fig, axes = plt.subplots(2, 3, figsize=(13, 7.5))
for ax, metric in zip(axes.flat, metric_order):
if metric not in group_metrics.columns:
ax.set_visible(False)
continue
vals = []
present_labels = []
present_colors = []
for s, lbl, c in zip(strategies, strategy_labels, bar_colors):
row = group_metrics.loc[group_metrics["strategy"] == s, metric]
if row.empty:
continue
vals.append(float(row.iloc[0]))
present_labels.append(lbl)
present_colors.append(c)
ax.bar(
range(len(vals)),
vals,
color=present_colors,
edgecolor="white",
linewidth=0.5,
)
ax.set_xticks(range(len(vals)))
ax.set_xticklabels(present_labels, fontsize=10)
ax.set_title(metric.replace("_", " "), fontsize=11, loc="left", color=INK)
ax.tick_params(labelsize=9)
use_mono_ticks(ax)
# Drop x-grid; small-multiples already have visual rhythm from the grid
ax.grid(axis="x", visible=False)
fig.suptitle("")
fig.tight_layout()
fig.savefig(FIGURES / "fig10_group_metrics.png", dpi=200)
plt.close(fig)
def _fig2_feature_families(columns: list[str]) -> None:
"""Horizontal bar chart showing feature count by family."""
families = {
"Demographic": [
"age_band_ord", "imd_band_ord", "highest_education_ord",
"disability_bin", "gender_M", "num_prev_attempts", "studied_credits",
"registration_day",
],
"Engagement": [
"total_clicks", "active_days", "first_click_day", "last_click_day",
"mean_clicks_per_active_day", "engagement_span", "click_std",
"click_cv", "weekend_ratio",
],
"Collaboration": [
"collaborative_clicks", "forum_clicks", "live_collab_clicks",
"collaborative_active_days", "collaboration_click_ratio",
],
"VLE activity": [
"clicks_oucontent", "clicks_homepage", "clicks_subpage",
"clicks_url", "clicks_resource", "clicks_dataplus", "clicks_glossary",
],
"Performance": [
"mean_tma_score", "weighted_score", "n_assessments_submitted",
"mean_submission_lateness", "score_trajectory_slope", "no_submissions",
],
}
col_set = set(columns)
family_names = []
family_counts = []
for name, members in families.items():
count = sum(1 for m in members if m in col_set)
if count > 0:
family_names.append(name)
family_counts.append(count)
# Any unclassified columns
classified = set()
for members in families.values():
classified.update(members)
unclassified = col_set - classified
if unclassified:
family_names.append("Other")
family_counts.append(len(unclassified))
fig, ax = plt.subplots(figsize=(8, 4))
bars = ax.barh(
range(len(family_names)), family_counts,
color=[ACCENT if n == "Collaboration" else INK_MUTED for n in family_names],
edgecolor="white", linewidth=0.5,
)
ax.set_yticks(range(len(family_names)))
ax.set_yticklabels(family_names)
ax.set_xlabel("number of features")
ax.invert_yaxis()
ax.grid(axis="y", visible=False)
# Count labels on bars
for bar, count in zip(bars, family_counts):
ax.text(
bar.get_width() + 0.15, bar.get_y() + bar.get_height() / 2,
str(count), va="center", fontsize=10, color=INK,
)
use_mono_ticks(ax)
fig.tight_layout()
fig.savefig(FIGURES / "fig2_feature_families.png", dpi=200)
plt.close(fig)
def _fig3_config_heatmap(
metrics_df: pd.DataFrame,
stability_df: pd.DataFrame,
winner: pd.Series,
) -> None:
"""12-config matrix heatmap — composite metrics, winner row ringed."""
joined = metrics_df.drop(columns=["labels"], errors="ignore").merge(
stability_df[["config_id", "bootstrap_ari_mean"]],
on="config_id", how="left",
)
display_metrics = [
"silhouette", "davies_bouldin", "calinski_harabasz", "bootstrap_ari_mean",
]
available = [m for m in display_metrics if m in joined.columns]
if not available:
return
matrix = joined.set_index("config_id")[available].copy()
# Normalise each column to [0, 1] for visual comparability
for col in available:
mn, mx = matrix[col].min(), matrix[col].max()
if mx > mn:
# DBI is lower-is-better: invert
if col == "davies_bouldin":
matrix[col] = 1.0 - (matrix[col] - mn) / (mx - mn)
else:
matrix[col] = (matrix[col] - mn) / (mx - mn)
else:
matrix[col] = 0.5
fig, ax = plt.subplots(figsize=(9, 6))
im = ax.imshow(matrix.values, aspect="auto", cmap="Greys", vmin=0, vmax=1)
ax.set_xticks(range(len(available)))
ax.set_xticklabels([m.replace("_", "\n") for m in available], fontsize=9)
ax.set_yticks(range(len(matrix)))
ax.set_yticklabels(matrix.index, fontsize=10)
# Annotate cells with raw values from joined
raw = joined.set_index("config_id")[available]
for i, config_id in enumerate(matrix.index):
for j, col in enumerate(available):
val = raw.loc[config_id, col]
fmt = f"{val:.3f}" if abs(val) < 10 else f"{val:.1f}"
text_color = "white" if matrix.values[i, j] > 0.6 else INK
ax.text(j, i, fmt, ha="center", va="center", fontsize=8, color=text_color)
# Ring the winner row
winner_id = winner.get("config_id")
if winner_id in matrix.index:
row_idx = list(matrix.index).index(winner_id)
ax.add_patch(plt.Rectangle(
(-0.5, row_idx - 0.5), len(available), 1,
fill=False, edgecolor=ACCENT, linewidth=2.0, zorder=10,
))
ax.set_title("Normalised validity metrics (higher = better)", loc="left", fontsize=12)
use_mono_ticks(ax)
fig.colorbar(im, ax=ax, shrink=0.6, label="normalised score")
fig.tight_layout()
fig.savefig(FIGURES / "fig3_config_heatmap.png", dpi=200)
plt.close(fig)
def _fig9_bootstrap_boxplot(
stability_df: pd.DataFrame,
winner: pd.Series | None = None,
) -> None:
"""Bootstrap-ARI distribution boxplot, sorted by mean ARI descending."""
if stability_df.empty or "bootstrap_ari_dist" not in stability_df.columns:
return
# Sort by mean ARI descending
ordered = stability_df.sort_values("bootstrap_ari_mean", ascending=False)
config_ids = ordered["config_id"].tolist()
distributions = []
for _, row in ordered.iterrows():
dist = row["bootstrap_ari_dist"]
if isinstance(dist, (list, np.ndarray)):
distributions.append(np.asarray(dist))
else:
distributions.append(np.array([row.get("bootstrap_ari_mean", 0)]))
fig, ax = plt.subplots(figsize=(10, 5.5))
bp = ax.boxplot(
distributions,
vert=True,
patch_artist=True,
labels=config_ids,
widths=0.6,
)
# Style boxes — highlight the actual winner, not just highest ARI
winner_id = winner.get("config_id") if winner is not None else None
for i, (box, median) in enumerate(zip(bp["boxes"], bp["medians"])):
if config_ids[i] == winner_id:
box.set(facecolor=ACCENT, alpha=0.4)
median.set(color=ACCENT, linewidth=1.5)
else:
box.set(facecolor=SURFACE, alpha=0.8)
median.set(color=INK, linewidth=1.0)
box.set(edgecolor=INK_MUTED, linewidth=0.8)
for whisker in bp["whiskers"]:
whisker.set(color=INK_MUTED, linewidth=0.6)
for cap in bp["caps"]:
cap.set(color=INK_MUTED, linewidth=0.6)
for flier in bp["fliers"]:
flier.set(marker="o", markersize=3, markerfacecolor=INK_FAINT,
markeredgecolor="none", alpha=0.6)
# Stability floor
ax.axhline(0.40, linestyle="--", color=INK_MUTED, linewidth=0.8)
ax.text(
len(distributions) + 0.3, 0.40, "stability floor",
va="bottom", fontsize=8, color=INK_FAINT, style="italic",
)
ax.set_ylabel("pairwise Adjusted Rand Index")
ax.set_xlabel("configuration (sorted by mean ARI)")
ax.tick_params(axis="x", labelrotation=45)
ax.grid(axis="x", visible=False)
use_mono_ticks(ax)
fig.tight_layout()
fig.savefig(FIGURES / "fig9_bootstrap_boxplot.png", dpi=200)
plt.close(fig)
def write_figures(
metrics_df: pd.DataFrame,
stability_df: pd.DataFrame,
labels: np.ndarray,
X_vis: np.ndarray,
group_metrics: pd.DataFrame,
winner: pd.Series,
columns: list[str] | None = None,
) -> None:
"""Write all matplotlib figures. Pass 1 (fig7/8/10) + Pass 2 (fig2/3/9).
All use Variant B tokens via plot_style.
"""
FIGURES.mkdir(parents=True, exist_ok=True)
_fig7_validity_vs_stability(metrics_df, stability_df, winner)
_fig8_embedding(labels, X_vis, winner)
_fig10_group_metrics(group_metrics)
# Pass 2 figures
if columns is not None:
_fig2_feature_families(columns)
_fig3_config_heatmap(metrics_df, stability_df, winner)
_fig9_bootstrap_boxplot(stability_df, winner=winner)
def write_report(
path: Path,
winner: pd.Series,
ranked: pd.DataFrame,
group_metrics: pd.DataFrame,
constraints: dict[str, Any],
) -> None:
lines = [
"# Pipeline Run Report",
"",
f"Selected config: {winner['config_id']} ({winner['reducer']} + {winner['clusterer']})",
"",
"## Top Ranked Configurations",
"",
"```text",
ranked.head(12).to_string(index=False),
"```",
"",
"## Group Metrics",
"",
"```text",
group_metrics.to_string(index=False),
"```",
"",
"## Constraint Summary",
"",
"```json",
json.dumps(_json_safe(constraints), indent=2, allow_nan=False),
"```",
"",
]
path.write_text("\n".join(lines), encoding="utf-8")
def write(
ids: np.ndarray,
features: pd.DataFrame,
X_scaled: np.ndarray,
reductions: dict[str, np.ndarray],
labels_by_config: dict[str, np.ndarray],
metrics_df: pd.DataFrame,
stability_df: pd.DataFrame,
winner: pd.Series,
ranked: pd.DataFrame,
winner_labels: np.ndarray,
groups_a: list[list[int]],
groups_b: list[list[int]],
group_metrics: pd.DataFrame,
random_baseline_metrics: pd.DataFrame | None,
group_significance: pd.DataFrame | None,
cluster_summary: pd.DataFrame | None,
constraints: dict[str, Any],
run_metadata: dict[str, Any] | None = None,
cache_dir: Path = DEMO_CACHE,
columns: list[str] | None = None,
schema: DatasetSchema | None = None,
) -> None:
ensure_dirs()
cache_dir.mkdir(parents=True, exist_ok=True)
write_global_artifacts = cache_dir.resolve() == DEMO_CACHE.resolve()
features.to_parquet(cache_dir / "features.parquet", index=False)
np.save(cache_dir / "X_scaled.npy", X_scaled)
for name, values in reductions.items():
np.save(cache_dir / f"reduced_{name}.npy", values)
if "umap_2d" in reductions:
np.save(cache_dir / "reduced_umap_2d.npy", reductions["umap_2d"])
elif "pca" in reductions:
np.save(cache_dir / "reduced_umap_2d.npy", reductions["pca"][:, :2])
cluster_labels = pd.DataFrame({"id_student": ids})
for cid, labels in labels_by_config.items():
cluster_labels[cid] = labels
cluster_labels.to_parquet(cache_dir / "cluster_labels.parquet", index=False)
metrics_clean = metrics_df.drop(columns=["labels"], errors="ignore")
metrics_clean.to_parquet(cache_dir / "config_metrics.parquet", index=False)
if write_global_artifacts:
metrics_clean.to_csv(TABLES / "config_metrics.csv", index=False)
stability_df.to_parquet(cache_dir / "stability.parquet", index=False)
if write_global_artifacts:
stability_df.drop(columns=["bootstrap_ari_dist"], errors="ignore").to_csv(
TABLES / "stability.csv",
index=False,
)
ranked.to_parquet(cache_dir / "ranked_configs.parquet", index=False)
_write_json(cache_dir / "winner.json", winner.to_dict())
groups_a_df = groups_to_frame(ids, winner_labels, groups_a, features, schema)
groups_b_df = groups_to_frame(ids, winner_labels, groups_b, features, schema)
groups_a_df.to_parquet(cache_dir / "groups_mode_a.parquet", index=False)
groups_b_df.to_parquet(cache_dir / "groups_mode_b.parquet", index=False)
if write_global_artifacts:
groups_a_df.to_csv(TABLES / "groups_mode_a.csv", index=False)
groups_b_df.to_csv(TABLES / "groups_mode_b.csv", index=False)
group_metrics.to_parquet(cache_dir / "group_metrics.parquet", index=False)
if write_global_artifacts:
group_metrics.to_csv(TABLES / "group_metrics.csv", index=False)
if random_baseline_metrics is not None:
random_baseline_metrics.to_parquet(cache_dir / "random_baseline_metrics.parquet", index=False)
if write_global_artifacts:
random_baseline_metrics.to_csv(TABLES / "random_baseline_metrics.csv", index=False)
if group_significance is not None:
group_significance.to_parquet(cache_dir / "group_significance.parquet", index=False)
if write_global_artifacts:
group_significance.to_csv(TABLES / "group_significance.csv", index=False)
if cluster_summary is not None:
cluster_summary.to_parquet(cache_dir / "cluster_summary.parquet", index=False)
if write_global_artifacts:
cluster_summary.to_csv(TABLES / "cluster_summary.csv", index=False)
if schema is not None:
_write_json(cache_dir / "schema.json", schema.to_dict())
dataset_name = schema.dataset_name if schema is not None else winner.get("dataset_name", "")
adapter_name = schema.adapter_name if schema is not None else winner.get("adapter_name", "")
meta = {
"n_learners": int(len(ids)),
"n_features": int(len(columns) if columns is not None else len([col for col in features.columns if col != "id_student"])),
"n_groups": int(len(groups_b)),
"group_size": GROUP_SIZE,
"dataset_name": dataset_name,
"adapter_name": adapter_name,
"presentation": f"{winner.get('presentation_module', '')}_{winner.get('presentation_code', '')}".strip("_"),
"winner_config": winner.get("config_id"),
"winner_reducer": winner.get("reducer"),
"winner_clusterer": winner.get("clusterer"),
}
if schema is not None:
meta.update(
{
"source_id_col": schema.source_id_col,
"id_col": schema.id_col,
"fairness_cols": schema.fairness_cols,
"engagement_col": schema.engagement_col,
"performance_col": schema.performance_col,
"outcome_col": schema.outcome_col,
"stratification_col": schema.stratification_col,
"display_cols": schema.display_cols,
}
)
if run_metadata:
meta.update(run_metadata)
_write_json(cache_dir / "meta.json", meta)
_write_json(cache_dir / "constraints.json", constraints)
# Persist feature columns for regen_figures.py
if columns is not None:
_write_json(cache_dir / "columns.json", columns)
write_pipeline_diagram(cache_dir / "pipeline_diagram.png")
if write_global_artifacts:
write_figures(
metrics_clean,
stability_df,
winner_labels,
reductions.get("umap_2d", reductions.get("pca", X_scaled[:, :2])),
group_metrics,
winner,
columns=columns,
)
# Graphviz diagrams (fig1/4/5/6)
try:
from .diagrams import render_all
render_all(FIGURES)
except ImportError:
pass # graphviz not installed — skip flowcharts
write_report(RESULTS / "pipeline_report.md", winner, ranked, group_metrics, constraints)