ho22joshua's picture
Upload folder using huggingface_hub
587d4ca verified
Raw
History Blame Contribute Delete
6.85 kB
#!/usr/bin/env python3
"""Plot VERL SFT training metrics from captured console logs."""
from __future__ import annotations
import argparse
import csv
import math
import re
from collections import defaultdict
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
STEP_RE = re.compile(r"\bstep:(\d+)\b")
METRIC_RE = re.compile(
r"(?P<key>[A-Za-z0-9_./()]+):(?P<value>[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)",
re.IGNORECASE,
)
def strip_ansi(text: str) -> str:
return ANSI_RE.sub("", text)
def parse_log(path: Path) -> list[dict[str, float]]:
"""Parse lines like `step:30 - train/loss:... - val/loss:...`."""
by_step: dict[int, dict[str, float]] = defaultdict(dict)
for raw_line in path.read_text(errors="replace").splitlines():
line = strip_ansi(raw_line)
step_match = STEP_RE.search(line)
if not step_match:
continue
step = int(step_match.group(1))
row = by_step[step]
row["step"] = float(step)
for match in METRIC_RE.finditer(line):
key = match.group("key")
if key == "step":
continue
try:
row[key] = float(match.group("value"))
except ValueError:
continue
return [by_step[step] for step in sorted(by_step)]
def write_csv(rows: list[dict[str, float]], path: Path) -> None:
keys = sorted({key for row in rows for key in row}, key=lambda k: (k != "step", k))
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=keys)
writer.writeheader()
for row in rows:
writer.writerow(row)
def finite_xy(rows: list[dict[str, float]], key: str) -> tuple[list[float], list[float]]:
xs: list[float] = []
ys: list[float] = []
for row in rows:
value = row.get(key)
step = row.get("step")
if value is None or step is None or not math.isfinite(value):
continue
xs.append(step)
ys.append(value)
return xs, ys
def plot_keys(
ax: plt.Axes,
rows: list[dict[str, float]],
keys: list[str],
title: str,
ylabel: str | None = None,
) -> bool:
plotted = False
for key in keys:
xs, ys = finite_xy(rows, key)
if not xs:
continue
ax.plot(xs, ys, marker="o", linewidth=1.8, markersize=4, label=key)
plotted = True
ax.set_title(title)
ax.set_xlabel("step")
if ylabel:
ax.set_ylabel(ylabel)
ax.grid(True, alpha=0.25)
if plotted and len([key for key in keys if finite_xy(rows, key)[0]]) > 1:
ax.legend(fontsize=8)
return plotted
def build_summary_text(rows: list[dict[str, float]], log_path: Path) -> str:
last = rows[-1]
best_val = None
for row in rows:
if "val/loss" not in row:
continue
pair = (row["val/loss"], int(row["step"]))
if best_val is None or pair[0] < best_val[0]:
best_val = pair
lines = [
f"log: {log_path}",
f"steps parsed: {len(rows)}",
f"first step: {int(rows[0]['step'])}",
f"last step: {int(last['step'])}",
]
if "train/loss" in last:
lines.append(f"last train/loss: {last['train/loss']:.4g}")
if best_val is not None:
lines.append(f"best val/loss: {best_val[0]:.4g} at step {best_val[1]}")
if "perf/max_memory_allocated_gb" in last:
lines.append(f"last max GPU alloc: {last['perf/max_memory_allocated_gb']:.2f} GB")
if "perf/cpu_memory_used_gb" in last:
lines.append(f"last CPU memory: {last['perf/cpu_memory_used_gb']:.2f} GB")
return " | ".join(lines)
def make_plot(rows: list[dict[str, float]], log_path: Path, output: Path, title: str) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
fig, axes = plt.subplots(4, 2, figsize=(15, 16))
fig.suptitle(title, fontsize=16, y=0.985)
panels = [
(
axes[0][0],
["train/loss", "val/loss"],
"Loss",
"loss",
),
(
axes[0][1],
["train/grad_norm"],
"Gradient Norm",
"norm",
),
(
axes[1][0],
["perf/max_memory_allocated_gb", "perf/max_memory_reserved_gb"],
"GPU Memory",
"GB",
),
(
axes[1][1],
["perf/cpu_memory_used_gb"],
"CPU Memory",
"GB",
),
(
axes[2][0],
["train/global_tokens"],
"Global Tokens per Step",
"tokens",
),
(
axes[2][1],
["train/total_tokens(B)"],
"Cumulative Tokens",
None,
),
(
axes[3][0],
["train/lr"],
"Learning Rate",
"lr",
),
(
axes[3][1],
["train/mfu"],
"MFU",
"mfu",
),
]
for ax, keys, panel_title, ylabel in panels:
plotted = plot_keys(ax, rows, keys, panel_title, ylabel)
if not plotted:
ax.set_title(panel_title)
ax.axis("off")
ax.text(0.5, 0.5, "metric not found", ha="center", va="center")
fig.tight_layout(rect=[0, 0.035, 1, 0.965])
fig.text(
0.01,
0.01,
build_summary_text(rows, log_path),
ha="left",
va="bottom",
family="monospace",
fontsize=8,
)
fig.savefig(output, dpi=180)
plt.close(fig)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Plot VERL SFT training metrics from a stdout/stderr log captured with tee."
)
parser.add_argument("--log", required=True, type=Path, help="Captured training log.")
parser.add_argument("--output", required=True, type=Path, help="Output PNG path.")
parser.add_argument("--title", default=None, help="Figure title.")
parser.add_argument("--csv", type=Path, help="Optional parsed metrics CSV path.")
return parser.parse_args()
def main() -> None:
args = parse_args()
rows = parse_log(args.log)
if not rows:
raise SystemExit(
f"No VERL metric lines found in {args.log}. Capture stdout/stderr with tee, for example:\n"
f" ... bash run_verl_sft.sh ... 2>&1 | tee plotting/logs/run.log"
)
title = args.title or args.log.stem.replace("_", " ")
make_plot(rows, args.log, args.output, title)
if args.csv:
write_csv(rows, args.csv)
print(f"Wrote {args.output}")
if args.csv:
print(f"Wrote {args.csv}")
if __name__ == "__main__":
main()