CIPHER / eval /plot_results.py
Itachi-42's picture
Upload folder using huggingface_hub
8c159ba verified
Raw
History Blame Contribute Delete
13.7 kB
"""Generate the README plots from training metrics + episode trajectories.
Reads:
outputs/metrics/grpo_run.jsonl (per-step Trainer logs from JSONLLoggerCallback)
outputs/trajectories/run_*.jsonl (per-episode reward records from trajectory_logger)
Writes:
figures/reward_curve.png — training-step vs mean reward (with baseline lines)
figures/loss_curve.png — training-step vs GRPO loss
figures/before_after.png — bar chart: refuse / reveal / smart / base / trained
Run after a Colab training run + local eval:
python -m privacy_game.eval.plot_results \\
--metrics outputs/metrics/grpo_run.jsonl \\
--trajectories outputs/trajectories/ \\
--out figures/
All plots have labelled axes, units, and a one-line caption embedded in the
title — judging criterion #28 from the OpenEnv hackathon deck explicitly
calls this out.
"""
from __future__ import annotations
import argparse
import json
import statistics
import sys
from collections import defaultdict
from pathlib import Path
from typing import Optional
# Sanity-gate baselines (from server/baselines.py, pareto_it mode, n=200 each).
# These render as horizontal reference lines on the reward curve so judges
# see the trained model's progress relative to the hand-crafted policies.
BASELINE_REWARDS = {
"always_refuse": -0.001,
"always_reveal": +0.774,
"smart_generalize": +0.833,
"random": +0.764,
}
# ──────────────────────────────────────────────────────────────────────────────
# JSONL loaders
def _load_metrics(path: Path) -> list[dict]:
rows: list[dict] = []
if not path.exists():
return rows
with path.open(encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
continue
return rows
def _load_trajectories(traj_dir: Path) -> list[dict]:
rows: list[dict] = []
if not traj_dir.exists():
return rows
for f in sorted(traj_dir.glob("*.jsonl")):
with f.open(encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except json.JSONDecodeError:
continue
return rows
# ──────────────────────────────────────────────────────────────────────────────
# Plot helpers
def _ensure_matplotlib():
try:
import matplotlib # type: ignore[import-not-found]
matplotlib.use("Agg") # headless — works in Colab + CI + local CLI
import matplotlib.pyplot as plt # type: ignore[import-not-found]
return plt
except ImportError:
print("matplotlib not installed. `pip install matplotlib`", file=sys.stderr)
sys.exit(1)
def _collect_step_series(metrics: list[dict], key: str) -> tuple[list[int], list[float]]:
"""Pull (step, value) for a given key from the metrics log, ignoring rows
where the key is missing. TRL logs different sets of keys at different
cadences — train vs eval — so we filter rather than zip-fail."""
steps, values = [], []
for r in metrics:
if key in r and r[key] is not None and "step" in r:
try:
v = float(r[key])
except (ValueError, TypeError):
continue
steps.append(int(r["step"]))
values.append(v)
return steps, values
# ──────────────────────────────────────────────────────────────────────────────
# Plot 1 — reward curve over training steps
def plot_reward_curve(metrics: list[dict], out: Path) -> Path:
plt = _ensure_matplotlib()
fig, ax = plt.subplots(figsize=(8, 4.8), dpi=120)
# TRL logs reward as either "reward" or "rewards/_disclosure_reward/mean" depending on version.
candidates = [
"rewards/_disclosure_reward/mean",
"reward",
"train/reward",
]
plotted = False
for k in candidates:
steps, values = _collect_step_series(metrics, k)
if values:
ax.plot(steps, values, "-", linewidth=1.6, color="#7aa2ff",
label=f"trained model (mean per step)", marker="o", markersize=3)
plotted = True
break
# Eval reward — a separate slower-cadence series, if present
eval_candidates = [
"eval_rewards/_disclosure_reward/mean",
"eval_reward",
"eval/reward",
]
for k in eval_candidates:
steps, values = _collect_step_series(metrics, k)
if values:
ax.plot(steps, values, "-", linewidth=2.0, color="#c19cff",
label="held-out eval (mean)", marker="s", markersize=5)
break
# Baseline reference lines (full-episode rewards from baselines.py, pareto_it)
line_styles = {
"smart_generalize": ("--", "#5ed6c4", 1.4),
"always_reveal": ("--", "#f5b34a", 1.2),
"always_refuse": ("--", "#ff5e5e", 1.2),
}
for name, (ls, color, lw) in line_styles.items():
ax.axhline(BASELINE_REWARDS[name], linestyle=ls, color=color, linewidth=lw,
alpha=0.85, label=f"{name} baseline ({BASELINE_REWARDS[name]:+.3f})")
ax.set_xlabel("training step")
ax.set_ylabel("mean episode reward (Pareto-multiplicative)")
ax.set_title("GRPO learning curve — Contextual-Integrity Disclosure Game\n"
"Training reward rises toward the smart-policy upper bound.")
ax.set_ylim(-0.1, 1.05)
ax.grid(True, alpha=0.25, linestyle=":")
ax.legend(loc="lower right", fontsize=8, framealpha=0.95)
if not plotted:
ax.text(0.5, 0.5, "(no training reward column found in metrics JSONL —\n"
"did the run finish? did TRL log it?)",
ha="center", va="center", transform=ax.transAxes,
color="#ff5e5e", fontsize=11)
out.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out, bbox_inches="tight")
plt.close(fig)
return out
# ──────────────────────────────────────────────────────────────────────────────
# Plot 2 — loss curve
def plot_loss_curve(metrics: list[dict], out: Path) -> Path:
plt = _ensure_matplotlib()
fig, ax = plt.subplots(figsize=(8, 4.0), dpi=120)
steps, loss = _collect_step_series(metrics, "loss")
if loss:
ax.plot(steps, loss, "-", linewidth=1.4, color="#ff5e5e",
marker="o", markersize=3, label="GRPO loss")
# KL term, if present
steps_kl, kl = _collect_step_series(metrics, "kl")
if kl:
ax2 = ax.twinx()
ax2.plot(steps_kl, kl, "-", linewidth=1.0, color="#7aa2ff",
alpha=0.7, label="KL(policy ‖ ref)")
ax2.set_ylabel("KL divergence (nats)", color="#7aa2ff")
ax2.tick_params(axis="y", labelcolor="#7aa2ff")
ax.set_xlabel("training step")
ax.set_ylabel("GRPO loss", color="#ff5e5e")
ax.tick_params(axis="y", labelcolor="#ff5e5e")
ax.set_title("GRPO loss + KL divergence over training")
ax.grid(True, alpha=0.25, linestyle=":")
# Only show legend if we actually plotted something with a label.
if loss or kl:
ax.legend(loc="upper right", fontsize=9)
if not loss:
ax.text(0.5, 0.5, "(no 'loss' column found in metrics JSONL)",
ha="center", va="center", transform=ax.transAxes,
color="#ff5e5e", fontsize=11)
out.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out, bbox_inches="tight")
plt.close(fig)
return out
# ──────────────────────────────────────────────────────────────────────────────
# Plot 3 — before/after bar chart
# Pulls trained-model rewards from the trajectory JSONLs. Episodes whose
# rubric_breakdown contains a tag indicating the producing policy are bucketed;
# untagged trajectories fall under the "trained" bucket.
def _bucket_trajectories(trajectories: list[dict]) -> dict[str, list[float]]:
"""Bucket episodes by the policy that produced them. We detect this from
the trajectory record's `policy_label` field if our pilot eval wrote one;
otherwise we lump everything as "trained"."""
buckets: dict[str, list[float]] = defaultdict(list)
for t in trajectories:
label = t.get("policy_label") or t.get("rubric_breakdown", {}).get("policy_label") or "trained"
r = t.get("reward")
if r is not None:
buckets[label].append(float(r))
return buckets
def plot_before_after(trajectories: list[dict], out: Path) -> Path:
plt = _ensure_matplotlib()
buckets = _bucket_trajectories(trajectories)
# Always include the four scripted baselines as reference, even if not in trajectories.
display = []
for name, ref in BASELINE_REWARDS.items():
if name in buckets and buckets[name]:
display.append((name, statistics.mean(buckets[name]), len(buckets[name]), False))
else:
display.append((name, ref, 200, True)) # 200 = baseline n from sanity gate
# Add the trained model bucket(s)
for label, rewards in buckets.items():
if label in BASELINE_REWARDS or not rewards:
continue
display.append((label, statistics.mean(rewards), len(rewards), False))
# Sort by mean reward
display.sort(key=lambda d: d[1])
labels = [d[0] for d in display]
means = [d[1] for d in display]
ns = [d[2] for d in display]
is_ref = [d[3] for d in display]
colors = []
for label, ref in zip(labels, is_ref):
if label == "always_refuse":
colors.append("#ff5e5e")
elif label == "always_reveal":
colors.append("#f5b34a")
elif label == "smart_generalize":
colors.append("#5ed6c4")
elif label == "random":
colors.append("#888888")
else:
# trained model — gradient highlight
colors.append("#c19cff" if not ref else "#aaaaaa")
fig, ax = plt.subplots(figsize=(8, 4.8), dpi=120)
bars = ax.bar(range(len(labels)), means, color=colors, edgecolor="black", linewidth=0.8)
for i, (bar, mean, n) in enumerate(zip(bars, means, ns)):
ax.text(bar.get_x() + bar.get_width() / 2, mean + 0.02,
f"{mean:+.3f}\n(n={n})",
ha="center", va="bottom", fontsize=9)
ax.set_xticks(range(len(labels)))
ax.set_xticklabels(labels, rotation=20, ha="right")
ax.set_ylabel("mean episode reward (Pareto-multiplicative)")
ax.set_ylim(-0.15, 1.10)
ax.axhline(0, color="black", linewidth=0.6)
ax.set_title("Before vs after — mean reward across policies\n"
"Trained model lifts above naive baselines (reveal / random) "
"toward the smart upper bound.")
ax.grid(True, axis="y", alpha=0.25, linestyle=":")
out.parent.mkdir(parents=True, exist_ok=True)
fig.tight_layout()
fig.savefig(out, bbox_inches="tight")
plt.close(fig)
return out
# ──────────────────────────────────────────────────────────────────────────────
# Top-level
def plot_all(
metrics_path: Path,
trajectory_dir: Path,
out_dir: Path,
) -> dict[str, Path]:
"""Convenience entry — produces all three figures into out_dir."""
metrics = _load_metrics(metrics_path)
trajectories = _load_trajectories(trajectory_dir)
out_dir.mkdir(parents=True, exist_ok=True)
print(f" metrics rows: {len(metrics)}")
print(f" trajectory rows: {len(trajectories)}")
paths = {
"reward_curve": plot_reward_curve(metrics, out_dir / "reward_curve.png"),
"loss_curve": plot_loss_curve(metrics, out_dir / "loss_curve.png"),
"before_after": plot_before_after(trajectories, out_dir / "before_after.png"),
}
for name, p in paths.items():
print(f" ✅ wrote {name}{p}")
return paths
def main(argv: Optional[list[str]] = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--metrics", type=Path,
default=Path("outputs/metrics/grpo_run.jsonl"),
help="Trainer metrics JSONL")
p.add_argument("--trajectories", type=Path,
default=Path("outputs/trajectories"),
help="Trajectory JSONL dir")
p.add_argument("--out", type=Path, default=Path("figures"),
help="Output dir for PNGs")
args = p.parse_args(argv)
plot_all(args.metrics, args.trajectories, args.out)
return 0
if __name__ == "__main__":
sys.exit(main())