| |
| """Plot checkpoint evaluation summaries produced by evaluate_sft_loss.py.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import csv |
| import json |
| import re |
| from pathlib import Path |
|
|
| import matplotlib |
|
|
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
|
|
|
|
| STEP_RE = re.compile(r"step(\d+)") |
|
|
|
|
| def step_from_path(path: Path) -> int: |
| match = STEP_RE.search(path.name) |
| if not match: |
| raise ValueError(f"Could not infer checkpoint step from {path}") |
| return int(match.group(1)) |
|
|
|
|
| def load_jsonl(path: Path) -> list[dict]: |
| rows = [] |
| if not path.exists(): |
| return rows |
| with path.open() as handle: |
| for line in handle: |
| line = line.strip() |
| if line: |
| rows.append(json.loads(line)) |
| return rows |
|
|
|
|
| def word_count(text: object) -> int: |
| return len(str(text or "").split()) |
|
|
|
|
| def read_eval(summary_paths: list[Path], examples_dir: Path, generations_dir: Path) -> list[dict]: |
| rows = [] |
| for summary_path in sorted(summary_paths, key=step_from_path): |
| step = step_from_path(summary_path) |
| summary = json.loads(summary_path.read_text()) |
|
|
| examples_path = examples_dir / summary_path.name.replace("_val_loss.json", "_val_loss_examples.jsonl") |
| examples = [row for row in load_jsonl(examples_path) if row.get("status") == "scored"] |
| losses = [float(row["loss"]) for row in examples if "loss" in row] |
|
|
| generations_path = generations_dir / summary_path.name.replace("_val_loss.json", "_val_outputs.jsonl") |
| generations = load_jsonl(generations_path) |
| prediction_words = [word_count(row.get("prediction")) for row in generations] |
| reference_words = [word_count(row.get("reference")) for row in generations] |
|
|
| pred_ref_ratio = None |
| if prediction_words and reference_words and sum(reference_words) > 0: |
| pred_ref_ratio = sum(prediction_words) / sum(reference_words) |
|
|
| rows.append( |
| { |
| "step": step, |
| "loss": float(summary["loss"]), |
| "perplexity": float(summary["perplexity"]), |
| "examples_scored": int(summary.get("examples_scored", 0)), |
| "examples_skipped": int(summary.get("examples_skipped", 0)), |
| "assistant_tokens": int(summary.get("assistant_tokens", 0)), |
| "per_example_losses": losses, |
| "mean_prediction_words": sum(prediction_words) / len(prediction_words) if prediction_words else None, |
| "mean_reference_words": sum(reference_words) / len(reference_words) if reference_words else None, |
| "prediction_reference_word_ratio": pred_ref_ratio, |
| } |
| ) |
| if not rows: |
| raise SystemExit("No evaluation summaries found.") |
| return rows |
|
|
|
|
| def write_csv(rows: list[dict], path: Path) -> None: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| fieldnames = [ |
| "step", |
| "loss", |
| "perplexity", |
| "examples_scored", |
| "examples_skipped", |
| "assistant_tokens", |
| "mean_prediction_words", |
| "mean_reference_words", |
| "prediction_reference_word_ratio", |
| ] |
| with path.open("w", newline="") as handle: |
| writer = csv.DictWriter(handle, fieldnames=fieldnames) |
| writer.writeheader() |
| for row in rows: |
| writer.writerow({key: row.get(key) for key in fieldnames}) |
|
|
|
|
| def plot(rows: list[dict], output: Path, title: str) -> None: |
| output.parent.mkdir(parents=True, exist_ok=True) |
| steps = [row["step"] for row in rows] |
|
|
| fig, axes = plt.subplots(2, 2, figsize=(14, 10)) |
| fig.suptitle(title, fontsize=16, y=0.985) |
|
|
| ax = axes[0][0] |
| ax.plot(steps, [row["loss"] for row in rows], marker="o", linewidth=2) |
| best = min(rows, key=lambda row: row["loss"]) |
| ax.scatter([best["step"]], [best["loss"]], s=80, zorder=3, label=f"best step {best['step']}") |
| ax.set_title("Validation Loss") |
| ax.set_xlabel("checkpoint step") |
| ax.set_ylabel("assistant-token NLL") |
| ax.grid(True, alpha=0.25) |
| ax.legend() |
|
|
| ax = axes[0][1] |
| ax.plot(steps, [row["perplexity"] for row in rows], marker="o", color="tab:orange", linewidth=2) |
| ax.set_title("Validation Perplexity") |
| ax.set_xlabel("checkpoint step") |
| ax.set_ylabel("perplexity") |
| ax.grid(True, alpha=0.25) |
|
|
| ax = axes[1][0] |
| loss_lists = [row["per_example_losses"] for row in rows] |
| if any(loss_lists): |
| ax.boxplot(loss_lists, tick_labels=[str(step) for step in steps], showmeans=True) |
| ax.set_title("Per-Example Loss Distribution") |
| ax.set_xlabel("checkpoint step") |
| ax.set_ylabel("loss") |
| ax.grid(True, axis="y", alpha=0.25) |
|
|
| ax = axes[1][1] |
| pred_lengths = [row["mean_prediction_words"] for row in rows] |
| ref_lengths = [row["mean_reference_words"] for row in rows] |
| if any(value is not None for value in pred_lengths): |
| ax.plot(steps, pred_lengths, marker="o", label="prediction words", linewidth=2) |
| if any(value is not None for value in ref_lengths): |
| ax.plot(steps, ref_lengths, marker="o", label="reference words", linewidth=2) |
| ratio = [row["prediction_reference_word_ratio"] for row in rows] |
| if any(value is not None for value in ratio): |
| ax2 = ax.twinx() |
| ax2.plot(steps, ratio, marker="s", linestyle="--", color="tab:green", label="pred/ref ratio") |
| ax2.set_ylabel("prediction/reference word ratio") |
| ax2.legend(loc="lower right") |
| ax.set_title("Generated Answer Length") |
| ax.set_xlabel("checkpoint step") |
| ax.set_ylabel("mean words") |
| ax.grid(True, alpha=0.25) |
| ax.legend(loc="upper left") |
|
|
| summary = ( |
| f"best loss {best['loss']:.4f} at step {best['step']} | " |
| f"perplexity {best['perplexity']:.4f} | " |
| f"examples {best['examples_scored']} | assistant tokens {best['assistant_tokens']}" |
| ) |
| fig.tight_layout(rect=[0, 0.045, 1, 0.955]) |
| fig.text(0.01, 0.012, summary, ha="left", va="bottom", family="monospace", fontsize=9) |
| fig.savefig(output, dpi=180) |
| plt.close(fig) |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser() |
| parser.add_argument("--summary-pattern", help="Glob for *_val_loss.json files.") |
| parser.add_argument("--summaries", nargs="*", type=Path, help="Explicit *_val_loss.json files to plot.") |
| parser.add_argument( |
| "--examples-dir", |
| type=Path, |
| default=Path("data/hep_sft/checkpoint_loss"), |
| help="Directory containing per-example loss JSONL files.", |
| ) |
| parser.add_argument( |
| "--generations-dir", |
| type=Path, |
| default=Path("data/hep_sft/checkpoint_eval"), |
| help="Directory containing generated output JSONL files.", |
| ) |
| parser.add_argument("--output", required=True, type=Path) |
| parser.add_argument("--csv", type=Path) |
| parser.add_argument("--title", default="Checkpoint Evaluation") |
| return parser.parse_args() |
|
|
|
|
| def main() -> None: |
| args = parse_args() |
| if args.summaries: |
| summary_paths = args.summaries |
| elif args.summary_pattern: |
| summary_paths = sorted(Path().glob(args.summary_pattern), key=step_from_path) |
| else: |
| raise SystemExit("Pass either --summaries or --summary-pattern.") |
| rows = read_eval(summary_paths, args.examples_dir, args.generations_dir) |
| plot(rows, args.output, args.title) |
| print(f"Wrote {args.output}") |
| if args.csv: |
| write_csv(rows, args.csv) |
| print(f"Wrote {args.csv}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|