fathom-code / scripts /parse_log_to_plots.py
23f2002275
docs(D): wire training evidence - link plots from HF model repo, add W&B run
707d9ee
Raw
History Blame Contribute Delete
6.98 kB
"""Render training-curve PNGs from a saved HF Job log file.
Why this exists: the in-job `make_plots.py` failed because the venue Docker
image (`pytorch/pytorch:2.6.0-cuda12.4-cudnn9-devel`) doesn't ship matplotlib
and we omitted it from `job_train.sh` to save install time. The job's
trainer_state.json was lost when the container shut down. But every TRL log
line was streamed to the job log, so we can recover the same series by
parsing those lines.
Usage:
python scripts/parse_log_to_plots.py job9_full.log
Outputs:
outputs/plots/sft_loss.png
outputs/plots/sft_token_accuracy.png
outputs/plots/grpo_reward.png
outputs/plots/grpo_completion_length.png
outputs/plots/grpo_entropy.png
outputs/plots/training_summary.png
"""
from __future__ import annotations
import ast
import re
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
PLOTS = Path("outputs/plots")
PLOTS.mkdir(parents=True, exist_ok=True)
# TRL prints metrics as a python-dict literal on a single line like:
# {'loss': 3.19, 'grad_norm': 1.45, ...}
_DICT_RE = re.compile(r"\{'loss': [^\n]*'epoch': [^\}]*\}")
def parse_log(log_path: Path) -> tuple[list[dict], list[dict]]:
"""Return (sft_rows, grpo_rows) — each row is the parsed dict.
SFT rows have `mean_token_accuracy` and no `reward`.
GRPO rows have `reward` and `completions/mean_length`.
"""
# PowerShell `>` redirection writes UTF-16-LE with BOM. Detect via BOM.
raw = log_path.read_bytes()
if raw[:2] == b"\xff\xfe":
text = raw.decode("utf-16-le", errors="replace")
elif raw[:2] == b"\xfe\xff":
text = raw.decode("utf-16-be", errors="replace")
elif raw[:3] == b"\xef\xbb\xbf":
text = raw[3:].decode("utf-8", errors="replace")
else:
text = raw.decode("utf-8", errors="replace")
raw_dicts = _DICT_RE.findall(text)
rows: list[dict] = []
for raw in raw_dicts:
try:
rows.append(ast.literal_eval(raw))
except (SyntaxError, ValueError):
continue
# De-dup: HF Jobs replays log chunks, so we see each step multiple times.
# Identity is (epoch, loss) — a (epoch, loss) pair is unique per step
# within a phase.
seen = set()
deduped = []
for r in rows:
key = (r.get("epoch"), r.get("loss"), r.get("num_tokens"))
if key in seen:
continue
seen.add(key)
deduped.append(r)
sft = [r for r in deduped if "mean_token_accuracy" in r and "reward" not in r]
grpo = [r for r in deduped if "reward" in r]
return sft, grpo
def _line(ax, ys: list[float], xs: list[int], color: str, label: str) -> None:
ax.plot(xs, ys, marker=".", linewidth=2, color=color, label=label)
ax.grid(True, alpha=0.3)
def plot_one(metric_key: str, rows: list[dict], title: str, ylabel: str, outfile: Path, color: str = "#1f77b4") -> bool:
if not rows or metric_key not in rows[0]:
# try the last row in case keys differ
if not any(metric_key in r for r in rows):
print(f"[skip] no series for {metric_key}")
return False
xs, ys = [], []
for i, r in enumerate(rows, start=1):
if metric_key in r and isinstance(r[metric_key], (int, float)):
xs.append(i)
ys.append(float(r[metric_key]))
if len(ys) < 2:
print(f"[skip] {metric_key} has <2 points")
return False
fig, ax = plt.subplots(figsize=(8, 5), dpi=120)
_line(ax, ys, xs, color, ylabel)
ax.set_xlabel("Logging step")
ax.set_ylabel(ylabel)
ax.set_title(title)
fig.tight_layout()
fig.savefig(outfile)
plt.close(fig)
print(f"[ok] {outfile}")
return True
def plot_summary(sft: list[dict], grpo: list[dict], outfile: Path) -> None:
fig, axes = plt.subplots(2, 2, figsize=(13, 9), dpi=120)
panels = [
(axes[0][0], sft, "loss", "SFT loss (Qwen 0.5B + LoRA on Claude traces)", "loss", "#1f77b4"),
(axes[0][1], sft, "mean_token_accuracy", "SFT token accuracy", "accuracy", "#2ca02c"),
(axes[1][0], grpo, "completions/mean_length", "GRPO mean completion length", "tokens", "#ff7f0e"),
(axes[1][1], grpo, "entropy", "GRPO completion entropy", "entropy", "#d62728"),
]
for ax, rows, key, title, ylabel, color in panels:
if not rows or not any(key in r and isinstance(r[key], (int, float)) for r in rows):
ax.set_title(f"{title} (no data)")
ax.axis("off")
continue
xs, ys = zip(*[(i + 1, float(r[key])) for i, r in enumerate(rows) if key in r])
ax.plot(xs, ys, marker=".", linewidth=2, color=color)
ax.set_xlabel("Logging step")
ax.set_ylabel(ylabel)
ax.set_title(title)
ax.grid(True, alpha=0.3)
fig.suptitle(
"FATHOM training summary — Qwen 2.5 Coder 0.5B (smoke), HF Jobs A10G\n"
"SFT 63 steps converges; GRPO 50 steps validates pipeline (vLLM rollouts + reward callback wired)",
fontsize=11,
)
fig.tight_layout()
fig.savefig(outfile)
plt.close(fig)
print(f"[ok] {outfile}")
def main(argv: list[str]) -> int:
if len(argv) < 2:
print("usage: python scripts/parse_log_to_plots.py <job_log_file>")
return 1
log_path = Path(argv[1])
if not log_path.exists():
print(f"ERROR: log file {log_path} not found")
return 1
sft, grpo = parse_log(log_path)
print(f"Parsed: {len(sft)} SFT rows, {len(grpo)} GRPO rows from {log_path}")
plot_one("loss", sft, "SFT training loss", "loss", PLOTS / "sft_loss.png", "#1f77b4")
plot_one("mean_token_accuracy", sft, "SFT mean token accuracy", "accuracy", PLOTS / "sft_token_accuracy.png", "#2ca02c")
plot_one("entropy", sft, "SFT entropy (per-step)", "entropy", PLOTS / "sft_entropy.png", "#9467bd")
plot_one("reward", grpo, "GRPO composite reward (smoke run, depth-1, format-gated)", "reward", PLOTS / "grpo_reward.png", "#ff7f0e")
plot_one("completions/mean_length", grpo, "GRPO mean completion length", "tokens", PLOTS / "grpo_completion_length.png", "#ff7f0e")
plot_one("entropy", grpo, "GRPO completion entropy", "entropy", PLOTS / "grpo_entropy.png", "#d62728")
plot_one("kl", grpo, "GRPO KL divergence (β=0.04 floor)", "KL", PLOTS / "grpo_kl.png", "#8c564b")
plot_summary(sft, grpo, PLOTS / "training_summary.png")
# README expects these filenames specifically:
# outputs/plots/reward_curve.png
# outputs/plots/loss_curve.png
# We emit those as aliases of the most-relevant single-panel plot.
import shutil
if (PLOTS / "grpo_reward.png").exists():
shutil.copyfile(PLOTS / "grpo_reward.png", PLOTS / "reward_curve.png")
if (PLOTS / "sft_loss.png").exists():
shutil.copyfile(PLOTS / "sft_loss.png", PLOTS / "loss_curve.png")
print(f"\nWrote PNGs to {PLOTS}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))