membrane-temp / scripts /analysis /build_warmstart_ablation_plot.py
luffy-orf
submission: Membrane docs, training/eval artifacts, HF jobs scripts; docs plots as SVG only (HF Space git binary hook)
9c49c6e
Raw
History Blame Contribute Delete
14 kB
#!/usr/bin/env python3
"""Build the aggregate showcase plot + summary table comparing every Membrane GRPO run we have metrics for.
Outputs:
docs/plots/grpo_warmstart_ablation.png
docs/plots/grpo_warmstart_ablation.svg
docs/plots/grpo_warmstart_summary.csv
docs/plots/grpo_warmstart_summary.md
"""
from __future__ import annotations
import csv
import json
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from huggingface_hub import hf_hub_download
ROOT = Path(__file__).resolve().parents[2]
PLOTS = ROOT / "docs" / "plots"
PLOTS.mkdir(parents=True, exist_ok=True)
def read_csv_curve(path: Path) -> tuple[list[int], list[float]]:
steps, rewards = [], []
with path.open() as fp:
for row in csv.DictReader(fp):
steps.append(int(row["step"]))
rewards.append(float(row["reward_mean"]))
return steps, rewards
def load_hero() -> tuple[list[int], list[float]]:
p = hf_hub_download(
"Tejasghatule/membrane-qwen25-1p5b-grpo-lora",
"membrane_grpo_existing_checkpoint_1000/final_adapter/trainer_state.json",
)
log = json.load(open(p))["log_history"]
steps = [r["step"] for r in log if "reward" in r]
rewards = [r["reward"] for r in log if "reward" in r]
return steps, rewards
COLD_DIR = ROOT / "docs" / "hf_runs"
WARM_DIR = COLD_DIR / "continue_warm_start"
cold_runs = [
("cold-deep / seed 3408 (collapsed)", COLD_DIR / "repeat_seed_3408" / "training_metrics.csv"),
("cold-deep / seed 3409 (collapsed)", COLD_DIR / "repeat_seed_3409" / "training_metrics.csv"),
("cold-multi / seed 3410 (collapsed)", COLD_DIR / "multi_seed_3410" / "training_metrics.csv"),
]
warm_runs = [
# (label_for_summary, csv_path, plot_label, color, lw)
("warm-deep / lr=2e-6 (conservative)",
WARM_DIR / "continue_deep_seed_5821_lr2e-6" / "training_metrics.csv",
"Warm start, single task, slow LR → best run",
"#1f77b4", 2.4),
("warm-deep / lr=5e-6 (aggressive, saturates)",
WARM_DIR / "continue_deep_seed_5823_lr5e-6" / "training_metrics.csv",
"Warm start, single task, fast LR → saturates",
"#9ec5e8", 1.9),
("warm-multi / lr=3e-6 (conservative, 7 tasks)",
WARM_DIR / "continue_multifull_seed_5822_lr3e-6" / "training_metrics.csv",
"Warm start, all 7 tasks, slow LR",
"#d62728", 2.0),
("warm-multi / lr=5e-6 (aggressive, 7 tasks)",
WARM_DIR / "continue_multifull_seed_5825_lr5e-6" / "training_metrics.csv",
"Warm start, all 7 tasks, fast LR",
"#f4a6a4", 1.7),
]
def smooth(xs: list[float], w: int = 3) -> list[float]:
if len(xs) < w:
return xs
pad = w // 2
out = []
for i in range(len(xs)):
lo = max(0, i - pad)
hi = min(len(xs), i + pad + 1)
out.append(sum(xs[lo:hi]) / (hi - lo))
return out
def build_plot() -> None:
fig, ax = plt.subplots(1, 1, figsize=(12, 7))
# ---- Cold-start: collapse all 3 into a single shaded band, single legend entry.
cold_curves = []
for _, csv_path in cold_runs:
if csv_path.exists():
cold_curves.append(read_csv_curve(csv_path))
if cold_curves:
all_steps = sorted({s for steps, _ in cold_curves for s in steps})
if all_steps:
arr = np.full((len(cold_curves), len(all_steps)), np.nan)
for i, (steps, rewards) in enumerate(cold_curves):
for s, r in zip(steps, rewards):
arr[i, all_steps.index(s)] = r
band_lo = np.nanmin(arr, axis=0)
band_hi = np.nanmax(arr, axis=0)
band_mid = np.nanmean(arr, axis=0)
ax.fill_between(all_steps, band_lo, band_hi, color="#cccccc", alpha=0.55, lw=0,
label="3 cold-start runs → stuck below 0.02")
ax.plot(all_steps, band_mid, color="#888888", lw=1.0, ls="--", alpha=0.85)
# ---- Hero
h_steps, h_rewards = load_hero()
ax.plot(h_steps, smooth(h_rewards), color="black", lw=2.4,
label="Colab hero → cold start that converged (lr=5e-6, 1000 steps)", zorder=10)
ax.scatter([h_steps[-1]], [h_rewards[-1]], color="black", zorder=11)
# ---- Warm runs
best_warm_step = None
best_warm_rew = -1.0
sat_steps = sat_rew = None
for _, csv_path, plot_label, color, lw in warm_runs:
if not csv_path.exists():
continue
s, r = read_csv_curve(csv_path)
rs = smooth(r)
ax.plot(s, rs, color=color, lw=lw, label=plot_label)
# Track best warm-deep / lr=2e-6 peak (the headline)
if plot_label.startswith("Warm start, single task, slow LR"):
best_idx = int(np.argmax(rs))
best_warm_step, best_warm_rew = s[best_idx], rs[best_idx]
# Track saturating warm-deep aggressive descent point
if plot_label.startswith("Warm start, single task, fast LR"):
# Find the descent - peak then a later lower point
peak_idx = int(np.argmax(rs))
if peak_idx < len(rs) - 5:
sat_steps, sat_rew = s[-1], rs[-1]
sat_peak_step, sat_peak_rew = s[peak_idx], rs[peak_idx]
# ---- Hero vs warm reference lines
ax.axhline(1.0, color="#bbbbbb", lw=0.7, ls=":")
ax.text(2010, 1.0, " perfect = 1.0", fontsize=8.5, color="#888", va="center")
ax.axhline(0.974, color="black", lw=0.6, ls=":", alpha=0.5)
ax.text(2010, 0.974, " hero peak 0.974", fontsize=8.5, color="black", va="center", alpha=0.7)
# ---- Annotations on the plot itself
if best_warm_step is not None:
ax.annotate(
f"Warm start beats hero\n(peak {best_warm_rew:.3f})",
xy=(best_warm_step, best_warm_rew),
xytext=(700, 0.42),
fontsize=10.5, fontweight="bold", color="#1f3a8a",
ha="center",
arrowprops=dict(arrowstyle="->", color="#1f3a8a", lw=1.2,
connectionstyle="arc3,rad=-0.15"),
)
if sat_steps is not None:
ax.annotate(
"Fast LR → policy saturates,\nadvantage signal disappears,\nreward drifts down",
xy=(sat_steps, sat_rew),
xytext=(1450, 0.30),
fontsize=10, color="#3a3a3a",
ha="center",
arrowprops=dict(arrowstyle="->", color="#3a3a3a", lw=1.0,
connectionstyle="arc3,rad=0.15"),
)
# Annotate the cold-start band so the takeaway is on the chart, not just the legend
ax.annotate(
"Cold-start runs never escape zero -\n"
"Membrane's reward is sparse, so a\n"
"fresh policy almost never gets a\n"
"non-zero signal to learn from.",
xy=(900, 0.012),
xytext=(60, 0.16),
fontsize=9.5, color="#555", ha="left",
arrowprops=dict(arrowstyle="->", color="#888", lw=0.9,
connectionstyle="arc3,rad=-0.25"),
)
# ---- Labels and legend
ax.set_xlabel("Training step", fontsize=11)
ax.set_ylabel("Membrane reward (0 = invalid / leak, 1 = perfect refusal + commit)", fontsize=11)
ax.set_ylim(-0.05, 1.08)
ax.set_xlim(0, 2200)
ax.grid(True, alpha=0.25)
leg = ax.legend(loc="lower right", fontsize=9.5, framealpha=0.97, title="Run", title_fontsize=10)
leg.get_title().set_fontweight("bold")
# Title + subtitle as separate axes-level objects so they stack cleanly.
ax.set_title("How a 1.5 B Qwen learns Membrane", fontsize=15, fontweight="bold", pad=34)
ax.text(
0.5, 1.015,
"Cold start collapses near zero. The Colab hero converges. Warm starts loaded from the hero adapter\n"
"climb above it on the training task, and lift onto the 7-task curriculum.",
transform=ax.transAxes, ha="center", va="bottom", fontsize=10.5, color="#444",
)
fig.tight_layout()
fig.savefig(PLOTS / "grpo_warmstart_ablation.png", dpi=170)
fig.savefig(PLOTS / "grpo_warmstart_ablation.svg")
plt.close(fig)
print("wrote", PLOTS / "grpo_warmstart_ablation.png")
def write_summary() -> None:
rows = []
h_steps, h_rewards = load_hero()
rows.append({
"run": "Colab hero (cold start, lr=5e-6, 1000 steps)",
"category": "hero",
"first_reward": round(h_rewards[0], 4),
"final_reward": round(h_rewards[-1], 4),
"best_reward": round(max(h_rewards), 4),
"best_step": h_steps[h_rewards.index(max(h_rewards))],
"steps_logged": len(h_rewards),
})
def _summarize(name: str, csv_path: Path, category: str) -> dict | None:
if not csv_path.exists():
return None
s, r = read_csv_curve(csv_path)
return {
"run": name,
"category": category,
"first_reward": round(r[0], 4),
"final_reward": round(r[-1], 4),
"best_reward": round(max(r), 4),
"best_step": s[r.index(max(r))],
"steps_logged": len(r),
}
for name, p in cold_runs:
d = _summarize(name, p, "cold-start")
if d:
rows.append(d)
for entry in warm_runs:
name, p = entry[0], entry[1]
d = _summarize(name, p, "warm-start")
if d:
rows.append(d)
csv_path = PLOTS / "grpo_warmstart_summary.csv"
with csv_path.open("w", newline="") as fp:
w = csv.DictWriter(fp, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
print("wrote", csv_path)
md_lines = [
"# Membrane GRPO - warm-start ablation",
"",
"Aggregate of every Membrane GRPO run that has produced metrics:",
"",
"- 1 Colab run (the hero) that converged from a cold start.",
"- 3 cold-start Hugging Face Job runs that did not converge.",
"- 4 warm-start Hugging Face Job runs that loaded the hero adapter as initial",
" weights and continued training. The four form a 2 × 2 ablation across",
" learning rate (conservative vs aggressive) and task mix (single task vs",
" full 7-scenario curriculum).",
"",
"| run | category | first reward | final reward | best reward | best step | steps logged |",
"|---|---|---|---|---|---|---|",
]
for r in rows:
md_lines.append(
f"| {r['run']} | {r['category']} | {r['first_reward']:.3f} | "
f"{r['final_reward']:.3f} | **{r['best_reward']:.3f}** | "
f"{r['best_step']} | {r['steps_logged']} |"
)
md_lines += [
"",
"## Findings",
"",
"1. **Cold-start GRPO does not learn Membrane in the budgets we tested.** Three",
" independent HF Job runs (single-task seeds 3408 / 3409 at 900 steps,",
" multi-task seed 3410 at 1400 steps) all stay below 0.02 mean reward.",
" Membrane's reward is sparse on purpose - any malformed JSONL action zeroes",
" the episode - so a freshly-initialised policy almost never produces a",
" non-zero advantage signal long enough for GRPO to bootstrap.",
"",
"2. **The Colab T4 run converged with the same recipe that collapsed on the",
" A10G HF Job runs.** Same script, same hyperparameters, same seed family.",
" The only difference is the RNG stream from a different GPU and the",
" Unsloth/TRL versions that the version pin now reproduces. This is why",
" warm-starting was worth doing: a known-good policy whose weights could be",
" redeployed as initial conditions on the HF compute backend.",
"",
"3. **Warm-starting from the hero adapter beats the hero on the same task.**",
" `warm-deep / lr=2e-6` lifts mean reward from 0.880 (the hero adapter's",
" starting score) to **0.971 final / 0.988 peak**, surpassing both the",
" hero's 0.935 final and 0.974 peak. The conservative learning rate is the",
" key - see finding 4.",
"",
"4. **Aggressive learning rate saturates GRPO on a single task.**",
" `warm-deep / lr=5e-6` (the *hero's* learning rate) climbs from 0.849 to a",
" peak of **0.988 by step 240**, then drifts back down to 0.959 by",
" step 1500. This is not a model failure: `frac_reward_zero_std` rises",
" from 0.2 to ≥ 0.7, meaning ≥ 70 % of GRPO prompt groups produce identical",
" rewards across all 4 completions. With zero per-group advantage there is",
" no gradient signal, and `grad_norm` falls to exactly 0.0. Conservative",
" lr=2e-6 keeps per-group variance alive longer and continues improving.",
"",
"5. **A single-task warm-start transfers to the full 7-scenario curriculum.**",
" `warm-multi / lr=3e-6` (must-refuse, must-comply, long, triad,",
" round-robin, and two more held-out scenarios) starts at 0.495 - the model",
" has never seen 6 of those 7 tasks during the original Colab training -",
" and climbs to **0.793** by step 2000 without collapsing. The aggressive",
" variant peaks higher (0.854 at step 800) but slides to 0.785 as it",
" over-fits the task it was warm-started on.",
"",
"## Source data",
"",
"- Application source: <https://github.com/CodeMaverick2/membrane>",
"- Per-run metrics: `docs/hf_runs/<run>/training_metrics.csv` and",
" `docs/hf_runs/<run>/run_summary.json`.",
"- Aggregate plot: `grpo_warmstart_ablation.png` / `.svg`.",
"- Headline CSV: `grpo_warmstart_summary.csv`.",
"- All adapters:",
" <https://huggingface.co/Tejasghatule/membrane-qwen25-1p5b-grpo-lora>.",
"",
]
md_path = PLOTS / "grpo_warmstart_summary.md"
md_path.write_text("\n".join(md_lines))
print("wrote", md_path)
def main() -> None:
build_plot()
write_summary()
if __name__ == "__main__":
main()