Spaces:
Running
Running
luffy-orf
submission: Membrane docs, training/eval artifacts, HF jobs scripts; docs plots as SVG only (HF Space git binary hook)
9c49c6e | #!/usr/bin/env python3 | |
| """Re-render the base-vs-trained eval plots from the saved summary JSON. | |
| The plots originally generated inside the HF Job had two readability problems for | |
| people coming to the repo cold: | |
| 1. The "base" bars were a flat zero, so they vanished into the x-axis and the | |
| chart looked like only the trained model had been tested. | |
| 2. Task labels were the raw scenario IDs (`dyad_must_refuse_v1`), which don't | |
| tell a stranger what the task actually is. | |
| This script reads `docs/eval/base_vs_trained/base_vs_trained_summary.json` and | |
| writes friendlier versions of: | |
| - reward_by_task.png | |
| - valid_jsonl_by_task.png | |
| - commit_rate_by_task.png | |
| into `docs/eval/base_vs_trained/`. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from pathlib import Path | |
| import matplotlib.pyplot as plt | |
| import numpy as np | |
| ROOT = Path(__file__).resolve().parents[2] | |
| EVAL_DIR = ROOT / "docs" / "eval" / "base_vs_trained" | |
| SUMMARY = EVAL_DIR / "base_vs_trained_summary.json" | |
| # Friendly labels keyed by the canonical task ids | |
| TASK_LABELS = { | |
| "dyad_must_refuse_v1": "Refuse leak\n(training task)", | |
| "dyad_must_comply_v1": "Comply with\nbenign request", | |
| "dyad_must_refuse_long_v1": "Refuse leak with\n41 distractors", | |
| "triad_must_refuse_v1": "Refuse leak\nwith 2 colleagues", | |
| } | |
| def grouped_bar(metric_key: str, ylabel: str, title: str, subtitle: str, fname: str, | |
| base_zero_label: str | None) -> None: | |
| data = json.loads(SUMMARY.read_text()) | |
| tasks = data["tasks"] | |
| base_vals = [data["base"][metric_key][t] for t in tasks] | |
| trained_vals = [data["trained"][metric_key][t] for t in tasks] | |
| x = np.arange(len(tasks)) | |
| width = 0.4 | |
| fig, ax = plt.subplots(figsize=(11, 6.4)) | |
| base_color = "#94a3b8" | |
| trained_color = "#0f766e" | |
| # Base bars: if the value is zero, draw a hairline outlined "ghost" so the | |
| # reader can see the bar exists. Otherwise draw normally. | |
| base_bars = ax.bar(x - width / 2, base_vals, width, color=base_color, label="Base model (LoRA off)") | |
| for rect, val in zip(base_bars, base_vals): | |
| if val == 0: | |
| rect.set_height(0.012) | |
| rect.set_facecolor("none") | |
| rect.set_edgecolor(base_color) | |
| rect.set_linewidth(1.6) | |
| rect.set_hatch("//") | |
| trained_bars = ax.bar(x + width / 2, trained_vals, width, color=trained_color, | |
| label="Trained model (LoRA on, same weights)") | |
| # Value labels on every bar. | |
| for rect, val in zip(base_bars, base_vals): | |
| label = f"{val:.2f}" if val > 0 else "0.00" | |
| ax.annotate(label, (rect.get_x() + rect.get_width() / 2, max(rect.get_height(), 0.012)), | |
| xytext=(0, 4), textcoords="offset points", | |
| ha="center", va="bottom", fontsize=9, color=base_color) | |
| for rect, val in zip(trained_bars, trained_vals): | |
| ax.annotate(f"{val:.2f}", | |
| (rect.get_x() + rect.get_width() / 2, rect.get_height()), | |
| xytext=(0, 4), textcoords="offset points", | |
| ha="center", va="bottom", fontsize=9, color=trained_color, fontweight="bold", | |
| annotation_clip=False) | |
| ax.set_xticks(x) | |
| ax.set_xticklabels([TASK_LABELS.get(t, t) for t in tasks], fontsize=10) | |
| ax.set_ylim(0, 1.15) | |
| ax.set_ylabel(ylabel, fontsize=11) | |
| ax.set_title(title, fontsize=14, fontweight="bold", pad=28) | |
| ax.text( | |
| 0.5, 1.015, subtitle, | |
| transform=ax.transAxes, ha="center", va="bottom", | |
| fontsize=10.5, color="#444", | |
| ) | |
| ax.grid(True, axis="y", alpha=0.25) | |
| ax.set_axisbelow(True) | |
| # Place legend below the x-axis label area so it never overlaps bar value labels. | |
| leg = ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.18), | |
| ncol=2, fontsize=10, frameon=False) | |
| if base_zero_label: | |
| ax.text(0.012, -0.16, base_zero_label, | |
| transform=ax.transAxes, fontsize=9, color="#666", | |
| ha="left", va="top", style="italic") | |
| fig.tight_layout() | |
| out_path = EVAL_DIR / fname | |
| fig.savefig(out_path, dpi=170) | |
| fig.savefig(out_path.with_suffix(".svg")) | |
| plt.close(fig) | |
| print("wrote", out_path) | |
| def main() -> None: | |
| grouped_bar( | |
| metric_key="mean_reward", | |
| ylabel="Mean Membrane reward (0–1)", | |
| title="Trained adapter vs base model - Membrane reward by task", | |
| subtitle=("Same Qwen2.5 1.5 B weights, same prompts. Only difference: the trained LoRA " | |
| "is switched on for the right-hand bars."), | |
| fname="reward_by_task.png", | |
| base_zero_label="Base bars are striped because the base model scored 0.00 on every task - see JSONL validity below.", | |
| ) | |
| grouped_bar( | |
| metric_key="valid_jsonl_rate", | |
| ylabel="Fraction of completions that are valid Membrane JSONL", | |
| title="JSONL action format - base model can't follow the schema", | |
| subtitle=("Base: 0 % parseable Membrane actions on every task. " | |
| "Trained: 100 % parseable on every task."), | |
| fname="valid_jsonl_by_task.png", | |
| base_zero_label="0 % parseable actions = 0 reward, regardless of intent.", | |
| ) | |
| grouped_bar( | |
| metric_key="commit_rate", | |
| ylabel="Fraction of episodes that emit a COMMIT action", | |
| title="Task completion - only the trained model ever closes the task", | |
| subtitle=("A COMMIT closes the episode. Base never commits (no valid actions). " | |
| "Trained commits in 100 % of rollouts."), | |
| fname="commit_rate_by_task.png", | |
| base_zero_label=None, | |
| ) | |
| if __name__ == "__main__": | |
| main() | |