Instructions to use vishwr/claim_drafter with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- PEFT
How to use vishwr/claim_drafter with PEFT:
from peft import PeftModel from transformers import AutoModelForCausalLM base_model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3.5-9B") model = PeftModel.from_pretrained(base_model, "vishwr/claim_drafter") - Notebooks
- Google Colab
- Kaggle
| #!/usr/bin/env python3 | |
| """Turn the logs from every stage into charts, once all three have finished. | |
| python3 scripts/plot_runs.py [--runs runs] [--out runs/graphs] | |
| Reads two files per stage: | |
| runs/<stage>/metrics.jsonl the cookbook's learning curves | |
| runs/<stage>/progress.jsonl wall-clock and throughput (progress.py) | |
| Stages that have not run yet are skipped with a note rather than an error, so | |
| this is safe to run mid-pipeline -- you just get fewer panels. | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import sys | |
| STAGES = ("sft", "dpo", "rl") | |
| # Candidate metric keys per panel, best first. The cookbook names the training | |
| # loss differently per stage, so each panel takes the first key that exists. | |
| LOSS_KEYS = ("train_mean_nll", "dpo_loss", "loss", "train_mean_bpb") | |
| HOLDOUT_KEYS = ("test/nll", "test/bpb") | |
| REWARD_KEYS = ("env/all/reward/total", "env/all/reward/mean", "reward/mean", "train_mean_reward") | |
| # Palette roles, from the validated reference palette. Categorical slots are | |
| # assigned in fixed order and never cycled; the diverging pair is blue<->red | |
| # with a neutral gray midpoint. Text never wears a series colour. | |
| C_SERIES_1 = "#2a78d6" # blue -- the default single series | |
| C_SERIES_2 = "#008300" # green -- second series when two are genuinely needed | |
| C_POS = "#2a78d6" # diverging: improvement | |
| C_NEG = "#e34948" # diverging: regression | |
| C_MID = "#b8b7b2" # diverging midpoint / recessive rule | |
| C_INK = "#0b0b0b" | |
| C_INK_2 = "#52514e" | |
| C_WARN = "#eb6834" # status: marks the wasted region, always with a label | |
| # The evaluator emits these per slice, per eval round. | |
| SLICE_METRICS = ("parse_rate", "reward", "numbering", "dependency", | |
| "n_claims_mae", "antecedent_gap") | |
| def eval_rounds(records): | |
| """Records that carry evaluator output, in step order.""" | |
| out = [r for r in records | |
| if any(k.startswith("overall/") for k in r)] | |
| return sorted(out, key=lambda r: r.get("step", 0)) | |
| def slice_names(records): | |
| names = set() | |
| for r in records: | |
| for k in r: | |
| if "/" in k and not k.startswith("overall/"): | |
| head, tail = k.rsplit("/", 1) | |
| if tail in SLICE_METRICS: | |
| names.add(head) | |
| return sorted(names) | |
| def read_jsonl(path): | |
| if not os.path.exists(path): | |
| return [] | |
| out = [] | |
| with open(path) as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| out.append(json.loads(line)) | |
| except json.JSONDecodeError: | |
| continue # a run killed mid-write leaves a partial line | |
| return out | |
| def series(records, key, step_key="step"): | |
| """(steps, values) for `key`, skipping records that lack it.""" | |
| xs, ys = [], [] | |
| for r in records: | |
| v = r.get(key) | |
| if isinstance(v, (int, float)) and v == v: | |
| xs.append(r.get(step_key, len(xs))) | |
| ys.append(float(v)) | |
| return xs, ys | |
| def first_series(records, keys): | |
| for k in keys: | |
| xs, ys = series(records, k) | |
| if ys: | |
| return k, xs, ys | |
| return None, [], [] | |
| def load(runs_dir): | |
| data = {} | |
| for stage in STAGES: | |
| d = os.path.join(runs_dir, stage) | |
| metrics = read_jsonl(os.path.join(d, "metrics.jsonl")) | |
| prog = read_jsonl(os.path.join(d, "progress.jsonl")) | |
| if metrics or prog: | |
| data[stage] = {"metrics": metrics, "progress": prog, "dir": d} | |
| return data | |
| def plot_learning_curves(plt, data, out): | |
| stages = [s for s in STAGES if data.get(s)] | |
| if not stages: | |
| return None | |
| fig, axes = plt.subplots(1, len(stages), figsize=(5.5 * len(stages), 4), | |
| squeeze=False) | |
| for ax, stage in zip(axes[0], stages): | |
| m = data[stage]["metrics"] | |
| # RL optimises reward, the other two minimise a loss -- but fall through | |
| # to the loss keys so a stage still charts if the primary key is absent. | |
| wanted = (REWARD_KEYS + LOSS_KEYS) if stage == "rl" else (LOSS_KEYS + REWARD_KEYS) | |
| key, xs, ys = first_series(m, wanted) | |
| if ys: | |
| ax.plot(xs, ys, lw=1.2, label=key) | |
| hkey, hxs, hys = first_series(m, HOLDOUT_KEYS) | |
| if hys: | |
| # Held-out on the same axes: the gap opening up is the overfitting | |
| # signal, and it only reads as a gap if both lines share a scale. | |
| ax.plot(hxs, hys, lw=1.6, marker="o", ms=3, label=hkey) | |
| ax.set_title("%s" % stage.upper()) | |
| ax.set_xlabel("step") | |
| ax.grid(alpha=0.3) | |
| if ys or hys: | |
| ax.legend(fontsize=8) | |
| else: | |
| ax.text(0.5, 0.5, "no loss series", ha="center", transform=ax.transAxes) | |
| fig.suptitle("Learning curves") | |
| return save(fig, out, "01_learning_curves.png") | |
| def plot_eval_quality(plt, data, out): | |
| """overall/* from the slice evaluator, across every stage on one timeline.""" | |
| panels = ["overall/reward", "overall/parse_rate", "overall/antecedent_gap"] | |
| fig, axes = plt.subplots(1, 3, figsize=(15, 4)) | |
| any_data = False | |
| offset = 0 | |
| for stage in STAGES: | |
| if stage not in data: | |
| continue | |
| m = data[stage]["metrics"] | |
| for ax, key in zip(axes, panels): | |
| xs, ys = series(m, key) | |
| if ys: | |
| any_data = True | |
| ax.plot([x + offset for x in xs], ys, marker="o", ms=4, label=stage) | |
| steps = [r.get("step", 0) for r in m] | |
| offset += (max(steps) + 1) if steps else 0 | |
| for ax, key in zip(axes, panels): | |
| ax.set_title(key) | |
| ax.set_xlabel("cumulative step (SFT -> DPO -> RL)") | |
| ax.grid(alpha=0.3) | |
| if any_data: | |
| ax.legend(fontsize=8) | |
| if not any_data: | |
| axes[0].text(0.5, 0.5, "no slice-evaluator rounds logged yet", | |
| ha="center", transform=axes[0].transAxes) | |
| fig.suptitle("Claim quality across the pipeline") | |
| return save(fig, out, "02_eval_quality.png") | |
| def plot_slice_breakdown(plt, data, out): | |
| """Per-slice reward at the last eval of each stage -- where the model is weak.""" | |
| rows = {} | |
| for stage in STAGES: | |
| if stage not in data: | |
| continue | |
| for r in data[stage]["metrics"]: | |
| slices = {k.rsplit("/", 1)[0]: v for k, v in r.items() | |
| if k.endswith("/reward") and not k.startswith("overall/") | |
| and isinstance(v, (int, float))} | |
| if slices: | |
| rows[stage] = slices # keep overwriting -> ends up the last | |
| if not rows: | |
| return None | |
| names = sorted(next(iter(rows.values())).keys()) | |
| fig, ax = plt.subplots(figsize=(max(9, 0.75 * len(names) * len(rows)), 4.5)) | |
| width = 0.8 / len(rows) | |
| for i, (stage, slices) in enumerate(rows.items()): | |
| ax.bar([j + i * width for j in range(len(names))], | |
| [slices.get(n, 0.0) for n in names], width=width, label=stage.upper()) | |
| ax.set_xticks([j + 0.4 - width / 2 for j in range(len(names))]) | |
| ax.set_xticklabels(names, rotation=30, ha="right", fontsize=8) | |
| ax.set_ylabel("mean reward") | |
| ax.set_title("Final reward by validation slice") | |
| ax.grid(alpha=0.3, axis="y") | |
| ax.legend() | |
| return save(fig, out, "03_slice_breakdown.png") | |
| def plot_timing(plt, data, out): | |
| fig, axes = plt.subplots(1, 2, figsize=(11, 4)) | |
| ax_rate, ax_wall = axes | |
| for stage in STAGES: | |
| if stage not in data: | |
| continue | |
| p = data[stage]["progress"] | |
| xs, ys = series(p, "sec_per_step") | |
| if ys: | |
| ax_rate.plot(xs, ys, lw=1, label=stage) | |
| exs, eys = series(p, "elapsed_s") | |
| if eys: | |
| ax_wall.plot(exs, [y / 60.0 for y in eys], lw=1.2, label=stage) | |
| ax_rate.set_title("Seconds per step") | |
| ax_rate.set_xlabel("step") | |
| ax_wall.set_title("Wall clock (minutes)") | |
| ax_wall.set_xlabel("step") | |
| for ax in axes: | |
| ax.grid(alpha=0.3) | |
| if ax.get_legend_handles_labels()[0]: | |
| ax.legend(fontsize=8) | |
| fig.suptitle("Throughput and wall clock") | |
| return save(fig, out, "04_timing.png") | |
| def plot_slice_trajectories(plt, data, out, metric="parse_rate"): | |
| """One small multiple per slice. 11 slices is past any categorical palette's | |
| capacity, so this facets instead of drawing 11 lines in one axes.""" | |
| if "sft" not in data: | |
| return None | |
| rounds = eval_rounds(data["sft"]["metrics"]) | |
| names = slice_names(data["sft"]["metrics"]) | |
| if len(rounds) < 2 or not names: | |
| return None | |
| cols = 4 | |
| rows = -(-len(names) // cols) | |
| fig, axes = plt.subplots(rows, cols, figsize=(3.1 * cols, 2.4 * rows), | |
| sharex=True, sharey=True) | |
| axes = axes.ravel() if hasattr(axes, "ravel") else [axes] | |
| for ax, name in zip(axes, names): | |
| xs = [r.get("step", 0) for r in rounds if (name + "/" + metric) in r] | |
| ys = [r[name + "/" + metric] for r in rounds if (name + "/" + metric) in r] | |
| # A single series per panel: the panel title carries identity, so no legend. | |
| ax.plot(xs, ys, color=C_SERIES_1, linewidth=2, marker="o", markersize=5) | |
| if ys: | |
| # Direct-label the end point only, never every point. | |
| ax.annotate("%.2f" % ys[-1], (xs[-1], ys[-1]), textcoords="offset points", | |
| xytext=(4, 2), fontsize=8, color=C_INK_2) | |
| ax.set_title(name.replace("domain_", "").replace("_", " "), fontsize=9, color=C_INK) | |
| ax.set_ylim(-0.05, 1.08) | |
| ax.grid(alpha=0.25) | |
| ax.tick_params(labelsize=8, colors=C_INK_2) | |
| for ax in axes[len(names):]: | |
| ax.set_visible(False) | |
| fig.suptitle("%s per validation slice (1.0 = every generation parsed)" | |
| % metric.replace("_", " "), color=C_INK) | |
| fig.supxlabel("training step", fontsize=9, color=C_INK_2) | |
| return save(fig, out, "05_slice_trajectories.png") | |
| def plot_convergence(plt, data, out): | |
| """Where the held-out loss stopped improving, and how much run remained after. | |
| This is the panel that answers 'should I have trained this long', which the | |
| aggregate learning curve does not. The second panel measures the run after | |
| convergence in wall-clock time. | |
| """ | |
| if "sft" not in data: | |
| return None | |
| m = data["sft"]["metrics"] | |
| _, xs, ys = first_series(m, HOLDOUT_KEYS) | |
| if len(ys) < 3: | |
| return None | |
| # Convergence = first point within 2% of the best value achieved. | |
| best = min(ys) | |
| spread = max(ys) - best | |
| thresh = best + 0.02 * spread if spread else best | |
| conv_i = next((i for i, v in enumerate(ys) if v <= thresh), len(ys) - 1) | |
| conv_step = xs[conv_i] | |
| prog = data["sft"]["progress"] | |
| t_x = [r.get("step", 0) for r in prog if r.get("elapsed_s") is not None] | |
| t_y = [r["elapsed_s"] / 60.0 for r in prog if r.get("elapsed_s") is not None] | |
| total_min = t_y[-1] if t_y else 0.0 | |
| min_at_conv = 0.0 | |
| for sx, sy in zip(t_x, t_y): | |
| if sx <= conv_step: | |
| min_at_conv = sy | |
| fig, axes = plt.subplots(1, 2, figsize=(12.5, 4.2)) | |
| ax = axes[0] | |
| ax.plot(xs, ys, color=C_SERIES_1, linewidth=2, marker="o", markersize=6) | |
| if conv_i < len(xs) - 1: | |
| ax.axvspan(conv_step, xs[-1], color=C_WARN, alpha=0.10) | |
| ax.annotate("no further gain\nafter step %d" % conv_step, | |
| xy=(conv_step + (xs[-1] - conv_step) * 0.45, | |
| best + (max(ys) - best) * 0.55), | |
| fontsize=9, color=C_WARN, ha="center") | |
| ax.axvline(conv_step, color=C_MID, linewidth=2, linestyle="--") | |
| ax.set_title("Held-out loss: converged at step %d of %d" % (conv_step, xs[-1]), color=C_INK) | |
| ax.set_xlabel("training step", color=C_INK_2) | |
| ax.set_ylabel("held-out NLL", color=C_INK_2) | |
| ax.grid(alpha=0.25) | |
| ax = axes[1] | |
| if t_y: | |
| ax.plot(t_x, t_y, color=C_SERIES_1, linewidth=2) | |
| ax.axvline(conv_step, color=C_MID, linewidth=2, linestyle="--") | |
| wasted = max(0.0, total_min - min_at_conv) | |
| ax.fill_between([x for x in t_x if x >= conv_step], | |
| [min_at_conv] * sum(1 for x in t_x if x >= conv_step), | |
| [y for x, y in zip(t_x, t_y) if x >= conv_step], | |
| color=C_WARN, alpha=0.18) | |
| ax.annotate("%.0f min after convergence\n(of %.0f min total)" % (wasted, total_min), | |
| xy=(0.42, 0.18), xycoords="axes fraction", | |
| fontsize=10, color=C_WARN) | |
| ax.set_title("Cumulative wall clock", color=C_INK) | |
| ax.set_xlabel("training step", color=C_INK_2) | |
| ax.set_ylabel("minutes", color=C_INK_2) | |
| ax.grid(alpha=0.25) | |
| fig.suptitle("Did the run earn its length?", color=C_INK) | |
| return save(fig, out, "06_convergence.png") | |
| def plot_slice_deltas(plt, data, out, metric="reward"): | |
| """Change from the first eval to the last, per slice. | |
| Change has polarity, so this is the diverging case: blue for improvement, | |
| red for regression, sorted so the worst regression reads first. | |
| """ | |
| if "sft" not in data: | |
| return None | |
| rounds = eval_rounds(data["sft"]["metrics"]) | |
| names = slice_names(data["sft"]["metrics"]) | |
| if len(rounds) < 2 or not names: | |
| return None | |
| deltas = [] | |
| for n in names: | |
| key = n + "/" + metric | |
| vals = [r[key] for r in rounds if key in r] | |
| if len(vals) >= 2: | |
| deltas.append((n, vals[0], vals[-1], vals[-1] - vals[0])) | |
| if not deltas: | |
| return None | |
| deltas.sort(key=lambda t: t[3]) | |
| fig, ax = plt.subplots(figsize=(9.5, 0.42 * len(deltas) + 2.2)) | |
| labels = [d[0].replace("domain_", "").replace("_", " ") for d in deltas] | |
| vals = [d[3] for d in deltas] | |
| colors = [C_NEG if v < 0 else C_POS for v in vals] | |
| ax.barh(labels, vals, color=colors, height=0.62) | |
| ax.axvline(0, color=C_MID, linewidth=2) | |
| for i, (n, a, b, d) in enumerate(deltas): | |
| ax.annotate("%.2f -> %.2f" % (a, b), | |
| (d, i), textcoords="offset points", | |
| xytext=(6 if d >= 0 else -6, 0), va="center", | |
| ha="left" if d >= 0 else "right", | |
| fontsize=8, color=C_INK_2) | |
| ax.set_title("Change in %s from first eval to last (blue = better, red = worse)" | |
| % metric, color=C_INK) | |
| ax.set_xlabel("change", color=C_INK_2) | |
| ax.grid(alpha=0.25, axis="x") | |
| pad = max(abs(min(vals)), abs(max(vals))) * 0.35 + 0.02 | |
| ax.set_xlim(min(vals) - pad, max(vals) + pad) | |
| return save(fig, out, "07_slice_deltas.png") | |
| def plot_generation_health(plt, data, out): | |
| """Is the model still producing well-shaped claim sets? | |
| n_claims_mae is the early-warning signal for runaway generation: it moves | |
| long before the reward aggregate does, and a value far above the reference | |
| claim count means the model is not terminating. | |
| """ | |
| if "sft" not in data: | |
| return None | |
| rounds = eval_rounds(data["sft"]["metrics"]) | |
| names = slice_names(data["sft"]["metrics"]) | |
| if len(rounds) < 2 or not names: | |
| return None | |
| fig, axes = plt.subplots(1, 2, figsize=(13, 4.3)) | |
| ax = axes[0] | |
| xs, ys = [], [] | |
| for r in rounds: | |
| vals = [r[n + "/n_claims_mae"] for n in names if (n + "/n_claims_mae") in r] | |
| if vals: | |
| xs.append(r.get("step", 0)) | |
| ys.append(sum(vals) / len(vals)) | |
| ax.plot(xs, ys, color=C_SERIES_1, linewidth=2, marker="o", markersize=6) | |
| ax.axhline(16, color=C_MID, linewidth=2, linestyle="--") | |
| ax.annotate("reference set is ~16 claims;\nan error this size means the\nmodel is not terminating", | |
| xy=(0.04, 0.62), xycoords="axes fraction", fontsize=9, color=C_INK_2) | |
| if ys: | |
| ax.annotate("%.1f" % ys[-1], (xs[-1], ys[-1]), textcoords="offset points", | |
| xytext=(5, 0), fontsize=9, color=C_INK_2) | |
| ax.set_title("Claim-count error (mean absolute)", color=C_INK) | |
| ax.set_xlabel("training step", color=C_INK_2) | |
| ax.set_ylabel("claims off vs reference", color=C_INK_2) | |
| ax.grid(alpha=0.25) | |
| ax = axes[1] | |
| last = rounds[-1] | |
| pr = [(n, last.get(n + "/parse_rate")) for n in names if (n + "/parse_rate") in last] | |
| pr.sort(key=lambda t: t[1]) | |
| lbl = [p[0].replace("domain_", "").replace("_", " ") for p in pr] | |
| val = [p[1] for p in pr] | |
| # Sequential magnitude: one hue, light->dark. Low parse rate reads lightest. | |
| ramp = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95"] | |
| cols = [ramp[min(len(ramp) - 1, int(v * len(ramp)))] for v in val] | |
| ax.barh(lbl, val, color=cols, height=0.62) | |
| for i, v in enumerate(val): | |
| ax.annotate("%.2f" % v, (v, i), textcoords="offset points", xytext=(5, 0), | |
| va="center", fontsize=8, color=C_INK_2) | |
| ax.set_xlim(0, 1.12) | |
| ax.set_title("parse_rate by slice at the final eval", color=C_INK) | |
| ax.set_xlabel("fraction of generations that parsed", color=C_INK_2) | |
| ax.grid(alpha=0.25, axis="x") | |
| fig.suptitle("Generation health", color=C_INK) | |
| return save(fig, out, "08_generation_health.png") | |
| def plot_rl_health(plt, data, out): | |
| """GRPO's own signals: did the training reward climb, and did the policy move? | |
| Three single-series panels (different scales, so never one axes): | |
| - env reward: the objective GRPO maximises. A jump then a plateau near the | |
| ceiling means the reward saturated and advantages went to ~0 -- there was | |
| little left for RL to optimise. | |
| - KL from the reference: how far the policy actually moved. Near-zero means | |
| the updates were tiny (the flip side of a saturated reward). | |
| - entropy: a collapse toward 0 would signal mode collapse / repetition. | |
| """ | |
| if "rl" not in data: | |
| return None | |
| m = data["rl"]["metrics"] | |
| rx, reward = first_series(m, REWARD_KEYS)[1:] | |
| if len(reward) < 3: | |
| return None | |
| kx, kl = series(m, "optim/kl_sample_train_v1") | |
| ex, ent = series(m, "optim/entropy") | |
| panels = [("env reward (GRPO objective)", rx, reward, C_SERIES_1, None), | |
| ("KL from reference policy", kx, kl, C_SERIES_1, "near 0 = policy barely moved"), | |
| ("policy entropy", ex, ent, C_SERIES_1, "stable = no mode collapse")] | |
| panels = [(t, x, y, c, n) for t, x, y, c, n in panels if y] | |
| fig, axes = plt.subplots(1, len(panels), figsize=(4.4 * len(panels), 4)) | |
| axes = axes if hasattr(axes, "__len__") else [axes] | |
| for ax, (title, xs, ys, col, note) in zip(axes, panels): | |
| ax.plot(xs, ys, color=col, linewidth=2, marker="o", markersize=5) | |
| if note: | |
| ax.annotate(note, xy=(0.05, 0.06), xycoords="axes fraction", | |
| fontsize=9, color=C_INK_2) | |
| ax.set_title(title, color=C_INK) | |
| ax.set_xlabel("training step", color=C_INK_2) | |
| ax.grid(alpha=0.25) | |
| fig.suptitle("GRPO: reward saturated, so the policy moved little", color=C_INK) | |
| return save(fig, out, "10_rl_health.png") | |
| def plot_dpo_health(plt, data, out): | |
| """DPO's own objective: does the chosen/rejected reward gap widen? | |
| accuracy (fraction of pairs the policy already prefers correctly) and the | |
| implicit chosen/rejected rewards are the signals that show whether DPO | |
| learned the preference at all -- the sampled-output panels cannot, because | |
| they measure form, not scope. chosen_reward and rejected_reward share a | |
| scale (both are beta * log-prob ratios), so they belong on ONE axes; the gap | |
| between them IS the margin. accuracy is a different scale, so it is its own | |
| panel rather than a second y-axis. | |
| """ | |
| if "dpo" not in data: | |
| return None | |
| m = data["dpo"]["metrics"] | |
| ax_xs, acc = series(m, "accuracy") | |
| if len(acc) < 3: | |
| return None | |
| fig, axes = plt.subplots(1, 2, figsize=(12.5, 4.2)) | |
| ax = axes[0] | |
| ax.plot(ax_xs, acc, color=C_SERIES_1, linewidth=2) | |
| ax.axhline(0.5, color=C_MID, linewidth=1.5, linestyle="--") | |
| ax.annotate("0.5 = no preference learned", (ax_xs[len(ax_xs)//2], 0.5), | |
| textcoords="offset points", xytext=(0, 6), fontsize=8, color=C_INK_2) | |
| ax.set_ylim(0.45, 1.02) | |
| ax.set_title("Preference accuracy: %.2f -> %.2f" % (acc[0], acc[-1]), color=C_INK) | |
| ax.set_xlabel("training step", color=C_INK_2) | |
| ax.set_ylabel("fraction of pairs ranked correctly", color=C_INK_2) | |
| ax.grid(alpha=0.25) | |
| ax = axes[1] | |
| cx, cr = series(m, "chosen_reward") | |
| rx, rr = series(m, "rejected_reward") | |
| if cr and rr: | |
| # Two series -> a legend is present; both carry text-token labels, not | |
| # the series colour. | |
| ax.plot(cx, cr, color=C_SERIES_1, linewidth=2, label="chosen (granted)") | |
| ax.plot(rx, rr, color=C_NEG, linewidth=2, label="rejected (as-filed)") | |
| ax.fill_between(cx, cr, rr[:len(cr)], color=C_SERIES_1, alpha=0.08) | |
| ax.legend(fontsize=9, loc="upper right") | |
| ax.annotate("the gap is the margin;\nwider = stronger preference", | |
| xy=(0.05, 0.4), xycoords="axes fraction", fontsize=9, color=C_INK_2) | |
| ax.set_title("Implicit rewards pull apart", color=C_INK) | |
| ax.set_xlabel("training step", color=C_INK_2) | |
| ax.set_ylabel("implicit reward (beta x logprob ratio)", color=C_INK_2) | |
| ax.grid(alpha=0.25) | |
| fig.suptitle("DPO learned the examiner preference", color=C_INK) | |
| return save(fig, out, "09_dpo_health.png") | |
| def save(fig, out, name): | |
| fig.tight_layout() | |
| path = os.path.join(out, name) | |
| fig.savefig(path, dpi=130) | |
| import matplotlib.pyplot as plt | |
| plt.close(fig) | |
| return path | |
| def verdict(data): | |
| """State what the numbers say, so the summary is readable without the charts. | |
| Deliberately mechanical: it reports movements and thresholds, and stops short | |
| of claiming the model is good or bad. Reward here measures claim FORM, not | |
| drafting quality -- an untuned base model that emits generic well-formed | |
| claims scores near 1.0 -- so a fall in reward means "worse at emitting clean | |
| parseable claim sets", not "worse at patent drafting". | |
| """ | |
| if "sft" not in data: | |
| return [] | |
| m = data["sft"]["metrics"] | |
| rounds = eval_rounds(m) | |
| lines = ["", "## Read of this run", ""] | |
| if len(rounds) < 2: | |
| return lines + ["Only %d eval round(s) -- not enough to read a trend." % len(rounds)] | |
| def overall(r, k): | |
| return r.get("overall/" + k) | |
| first, last = rounds[0], rounds[-1] | |
| notes = [] | |
| # 1. Did held-out loss converge early? | |
| _, xs, ys = first_series(m, HOLDOUT_KEYS) | |
| if len(ys) >= 3: | |
| best = min(ys) | |
| spread = max(ys) - best | |
| thresh = best + 0.02 * spread if spread else best | |
| conv_i = next((i for i, v in enumerate(ys) if v <= thresh), len(ys) - 1) | |
| frac = xs[conv_i] / float(xs[-1]) if xs[-1] else 1.0 | |
| if frac < 0.5: | |
| notes.append( | |
| "- **Held-out loss converged at step %d of %d** (%.0f%% of the run). " | |
| "The remaining steps did not improve it, so a shorter run would " | |
| "have reached the same place." % (xs[conv_i], xs[-1], frac * 100)) | |
| # 2. Did sampled-generation quality move? | |
| for key, label in (("parse_rate", "parse rate"), ("reward", "reward")): | |
| a, b = overall(first, key), overall(last, key) | |
| if a is None or b is None: | |
| continue | |
| if b < a - 0.05: | |
| notes.append("- **overall/%s fell %.2f -> %.2f.** Sampled output got worse " | |
| "even where the loss did not." % (key, a, b)) | |
| elif b > a + 0.05: | |
| notes.append("- overall/%s rose %.2f -> %.2f." % (key, a, b)) | |
| # 3. Runaway generation? | |
| names = slice_names(m) | |
| maes = [last[n + "/n_claims_mae"] for n in names if (n + "/n_claims_mae") in last] | |
| if maes: | |
| mae = sum(maes) / len(maes) | |
| if mae > 16: | |
| notes.append( | |
| "- **Claim-count error averages %.1f against a ~16-claim reference.** " | |
| "That is the signature of a model that is not terminating -- most " | |
| "likely repetition under greedy decoding, since eval samples at " | |
| "temperature 0. Check actual generations before concluding the " | |
| "model regressed." % mae) | |
| # 4. Baseline comparison -- was the first eval already good? | |
| a = overall(first, "reward") | |
| if a is not None and a > 0.95: | |
| notes.append( | |
| "- The **first eval already scored %.2f**, before training had " | |
| "meaningfully changed the model. This metric cannot see what SFT " | |
| "added; it measures form, and the base model already had the form." % a) | |
| # 5. Worst slices at the end | |
| prs = sorted(((n, last[n + "/parse_rate"]) for n in names if (n + "/parse_rate") in last), | |
| key=lambda t: t[1])[:3] | |
| if prs and prs[0][1] < 0.8: | |
| notes.append("- Weakest slices at the final eval: %s." | |
| % ", ".join("%s %.2f" % (n.replace("domain_", ""), v) for n, v in prs)) | |
| if "dpo" in data: | |
| dm = data["dpo"]["metrics"] | |
| _, acc = series(dm, "accuracy") | |
| _, marg = series(dm, "margin") | |
| drounds = eval_rounds(dm) | |
| if acc: | |
| notes.append("- **DPO preference accuracy reached %.2f** (margin %.1f -> %.1f). " | |
| "By its own objective the preference was learned cleanly." | |
| % (acc[-1], marg[0] if marg else 0, marg[-1] if marg else 0)) | |
| if len(drounds) >= 2: | |
| a = drounds[0].get("overall/parse_rate") | |
| b = drounds[-1].get("overall/parse_rate") | |
| if a is not None and b is not None: | |
| verb = "barely moved" if abs(b - a) < 0.05 else ("rose" if b > a else "fell") | |
| notes.append("- On sampled output, DPO's parse_rate %s (%.2f -> %.2f): the " | |
| "preference signal did not fix the decoding-time parse failures, " | |
| "which are a separate problem." % (verb, a, b)) | |
| # per-domain winners/losers under DPO | |
| dom_deltas = [] | |
| for n in slice_names(dm): | |
| key = n + "/reward" | |
| vals = [r[key] for r in drounds if key in r] | |
| if len(vals) >= 2: | |
| dom_deltas.append((n.replace("domain_", ""), vals[-1] - vals[0])) | |
| dom_deltas.sort(key=lambda t: t[1]) | |
| if dom_deltas: | |
| worst = dom_deltas[0]; best = dom_deltas[-1] | |
| notes.append("- DPO was uneven across domains: %s %+.2f (worst), " | |
| "%s %+.2f (best)." % (worst[0], worst[1], best[0], best[1])) | |
| if "rl" in data: | |
| rm = data["rl"]["metrics"] | |
| rx, reward = first_series(rm, REWARD_KEYS)[1:] | |
| _, kl = series(rm, "optim/kl_sample_train_v1") | |
| rr = eval_rounds(rm) | |
| if len(reward) >= 3: | |
| jump = reward[1] - reward[0] if len(reward) > 1 else 0 | |
| notes.append("- **GRPO reward jumped %.2f -> %.2f in the first step, then held ~%.2f.** " | |
| "The reward saturated immediately, so group advantages went to ~0 and " | |
| "there was little left for RL to optimise -- as expected when SFT+DPO " | |
| "already scored near the ceiling." % (reward[0], reward[0] + jump, reward[-1])) | |
| if kl: | |
| notes.append("- KL from the reference stayed ~%.4f: the policy barely moved, " | |
| "the flip side of a saturated reward." % (sum(kl) / len(kl))) | |
| if len(rr) >= 2: | |
| a = rr[0].get("overall/parse_rate"); b = rr[-1].get("overall/parse_rate") | |
| if a is not None and b is not None: | |
| notes.append("- On held-out slices during RL, parse_rate went %.2f -> %.2f " | |
| "(steps %s-%s)." % (a, b, rr[0].get("step"), rr[-1].get("step"))) | |
| return lines + (notes or ["Nothing anomalous in the logged metrics."]) | |
| def summarise(data): | |
| lines = ["# Run summary", ""] | |
| total_time = 0.0 | |
| lines.append("| stage | steps | wall clock | eval rounds |") | |
| lines.append("|---|---|---|---|") | |
| for stage in STAGES: | |
| if stage not in data: | |
| lines.append("| %s | _not run_ | | |" % stage.upper()) | |
| continue | |
| p = data[stage]["progress"] | |
| if not p: | |
| lines.append("| %s | ? | ? | ? |" % stage.upper()) | |
| continue | |
| last = p[-1] | |
| secs = last.get("elapsed_s") or 0.0 | |
| total_time += secs | |
| lines.append("| %s | %d | %dh%02dm | %d |" | |
| % (stage.upper(), last.get("step", 0) + 1, | |
| int(secs // 3600), int(secs % 3600 // 60), | |
| sum(1 for r in p if r.get("is_eval")))) | |
| lines += ["", "**Total wall clock** %dh%02dm" | |
| % (int(total_time // 3600), int(total_time % 3600 // 60))] | |
| lines += verdict(data) | |
| return "\n".join(lines) + "\n" | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--runs", default="runs") | |
| ap.add_argument("--out", default="runs/graphs") | |
| args = ap.parse_args() | |
| try: | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| except ImportError: | |
| sys.exit("matplotlib is not installed. Run: uv pip install -r requirements.txt") | |
| data = load(args.runs) | |
| if not data: | |
| sys.exit("No logs under %s/. Run a training stage first." % args.runs) | |
| missing = [s for s in STAGES if s not in data] | |
| if missing: | |
| print("note: no logs for %s -- plotting what exists" % ", ".join(missing)) | |
| os.makedirs(args.out, exist_ok=True) | |
| written = [ | |
| plot_learning_curves(plt, data, args.out), | |
| plot_eval_quality(plt, data, args.out), | |
| plot_slice_breakdown(plt, data, args.out), | |
| plot_timing(plt, data, args.out), | |
| plot_slice_trajectories(plt, data, args.out), | |
| plot_convergence(plt, data, args.out), | |
| plot_slice_deltas(plt, data, args.out), | |
| plot_generation_health(plt, data, args.out), | |
| plot_dpo_health(plt, data, args.out), | |
| plot_rl_health(plt, data, args.out), | |
| ] | |
| summary_path = os.path.join(args.out, "summary.md") | |
| with open(summary_path, "w") as f: | |
| f.write(summarise(data)) | |
| print("\nWrote %d charts to %s/" % (sum(1 for w in written if w), args.out)) | |
| for w in written: | |
| if w: | |
| print(" " + w) | |
| print(" " + summary_path) | |
| print() | |
| print(summarise(data)) | |
| if __name__ == "__main__": | |
| main() | |