#!/usr/bin/env python3
"""Plot signature-to-background accuracy from generated validation JSONL files."""
from __future__ import annotations
import argparse
import csv
import json
import re
import textwrap
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
SECTIONS = ("dominant", "irreducible", "reducible")
STEP_RE = re.compile(r"step(\d+)")
HEADER_RE = re.compile(r"^\s*(dominant|irreducible|reducible)\s*:\s*$", re.IGNORECASE)
THINK_BLOCK_RE = re.compile(r"(?is)(.*?)")
ANSWER_BLOCK_RE = re.compile(r"(?is)(.*?)")
@dataclass
class Catalog:
id_to_label: dict[str, str]
normalized_label_to_id: dict[str, str]
@dataclass
class ParsedResponse:
raw_sections: dict[str, list[str]]
canonical_sections: dict[str, set[str]]
has_think: bool
has_answer: bool
think: str
answer: str
def normalize_text(value: str) -> str:
value = value.strip().lower().replace("->", " to ").replace("→", " to ")
value = re.sub(r"^[\-*\d.)\s]+", "", value)
value = re.sub(r"[`\"']", "", value)
value = re.sub(r"\s+", " ", value)
return value.strip(" .;:")
def load_catalog(path: Path) -> Catalog:
payload = json.loads(path.read_text())
id_to_label = {str(item["id"]): str(item["label"]) for item in payload["processes"]}
normalized_label_to_id = {normalize_text(label): process_id for process_id, label in id_to_label.items()}
return Catalog(id_to_label=id_to_label, normalized_label_to_id=normalized_label_to_id)
def canonicalize(item: str, catalog: Catalog) -> str:
cleaned = item.strip()
if cleaned in catalog.id_to_label:
return cleaned
normalized = normalize_text(cleaned)
return catalog.normalized_label_to_id.get(normalized, normalized)
def display_item(item: str, catalog: Catalog) -> str:
return catalog.id_to_label.get(item, item)
def strip_bullet(line: str) -> str:
return re.sub(r"^[\-*\d.)\s]+", "", line.strip()).strip()
def answer_section_items(answer: str) -> dict[str, list[str]] | None:
stripped = answer.strip()
if not stripped:
return None
try:
parsed = json.loads(stripped)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, dict):
sections = {section: [] for section in SECTIONS}
dominant = parsed.get("dominant")
if isinstance(dominant, str) and dominant.strip():
sections["dominant"].append(dominant.strip())
for section in ("irreducible", "reducible"):
values = parsed.get(section, [])
if isinstance(values, list):
sections[section].extend(str(value).strip() for value in values if str(value).strip())
return sections
lines = [strip_bullet(line) for line in stripped.splitlines() if strip_bullet(line)]
if len(lines) == 1:
return {"dominant": [lines[0]], "irreducible": [], "reducible": []}
return None
def parse_sections(text: str) -> dict[str, list[str]]:
sections = {section: [] for section in SECTIONS}
current: str | None = None
for raw_line in text.splitlines():
line = raw_line.strip()
header_match = HEADER_RE.match(line)
if header_match:
current = header_match.group(1).lower()
continue
if current and line.startswith("- "):
item = strip_bullet(line)
if item:
sections[current].append(item)
return sections
def parse_response(text: object, catalog: Catalog) -> ParsedResponse:
text = str(text or "")
think_match = THINK_BLOCK_RE.search(text)
answer_match = ANSWER_BLOCK_RE.search(text)
sections = None
if answer_match:
sections = answer_section_items(answer_match.group(1))
if sections is None:
sections = parse_sections(text)
canonical_sections = {
section: {canonicalize(item, catalog) for item in sections[section] if item}
for section in SECTIONS
}
return ParsedResponse(
raw_sections=sections,
canonical_sections=canonical_sections,
has_think=think_match is not None,
has_answer=answer_match is not None,
think=think_match.group(1).strip() if think_match else "",
answer=answer_match.group(1).strip() if answer_match else "",
)
def is_none_item(item: str, catalog: Catalog) -> bool:
return normalize_text(display_item(item, catalog)).startswith("none ")
def union_backgrounds(parsed: ParsedResponse, catalog: Catalog) -> set[str]:
values = set().union(*(parsed.canonical_sections[section] for section in SECTIONS))
return {item for item in values if not is_none_item(item, catalog)}
def precision_recall_f1(predicted: set[str], expected: set[str]) -> tuple[float, float, float]:
if not predicted and not expected:
return 1.0, 1.0, 1.0
if not predicted or not expected:
return 0.0, 0.0, 0.0
true_positive = len(predicted & expected)
precision = true_positive / len(predicted)
recall = true_positive / len(expected)
f1 = 0.0 if precision + recall == 0 else 2 * precision * recall / (precision + recall)
return precision, recall, f1
def join_display(items: set[str], catalog: Catalog) -> str:
return "; ".join(display_item(item, catalog) for item in sorted(items))
def evaluate_row(row: dict, source: Path, catalog: Catalog) -> dict[str, object]:
predicted = parse_response(row.get("prediction", ""), catalog)
expected = parse_response(row.get("reference", ""), catalog)
pred_dom = predicted.canonical_sections["dominant"]
exp_dom = expected.canonical_sections["dominant"]
pred_all = union_backgrounds(predicted, catalog)
exp_all = union_backgrounds(expected, catalog)
dominant_exact = bool(exp_dom) and pred_dom == exp_dom
dominant_hit = bool(exp_dom & pred_dom)
dominant_present_anywhere = bool(exp_dom & pred_all)
if dominant_exact:
category = "dominant exact"
elif dominant_hit:
category = "dominant plus extra"
elif dominant_present_anywhere:
category = "right background, wrong section"
elif not pred_dom:
category = "no dominant parsed"
else:
category = "dominant missing"
metrics: dict[str, object] = {
"source": source.name,
"id": row.get("id"),
"loss": row.get("loss"),
"expected_dominant": join_display(exp_dom, catalog),
"predicted_dominant": join_display(pred_dom, catalog),
"category": category,
"dominant_exact": dominant_exact,
"dominant_hit": dominant_hit,
"dominant_present_anywhere": dominant_present_anywhere,
"all_exact": pred_all == exp_all,
"missing_expected_count": len(exp_all - pred_all),
"extra_predicted_count": len(pred_all - exp_all),
"expected_background_count": len(exp_all),
"predicted_background_count": len(pred_all),
"prediction_has_think": predicted.has_think,
"prediction_has_answer": predicted.has_answer,
"reference_has_think": expected.has_think,
"reference_has_answer": expected.has_answer,
"prediction_think": predicted.think,
"prediction_answer": predicted.answer,
}
for section in SECTIONS:
precision, recall, f1 = precision_recall_f1(
predicted.canonical_sections[section],
expected.canonical_sections[section],
)
metrics[f"{section}_precision"] = precision
metrics[f"{section}_recall"] = recall
metrics[f"{section}_f1"] = f1
all_precision, all_recall, all_f1 = precision_recall_f1(pred_all, exp_all)
metrics["all_precision"] = all_precision
metrics["all_recall"] = all_recall
metrics["all_f1"] = all_f1
return metrics
def load_jsonl(path: Path) -> list[dict]:
rows = []
with path.open() as handle:
for line in handle:
if line.strip():
rows.append(json.loads(line))
return rows
def checkpoint_sort_key(path: Path) -> tuple[int, int]:
match = STEP_RE.search(path.name)
if match:
return (1, int(match.group(1)))
return (0, -1)
def checkpoint_label(path: Path) -> str:
match = STEP_RE.search(path.name)
return f"step {match.group(1)}" if match else "base"
def mean(values: list[float]) -> float:
return sum(values) / len(values) if values else 0.0
def summarize(rows: list[dict[str, object]]) -> dict[str, object]:
total = len(rows)
categories = Counter(str(row["category"]) for row in rows)
return {
"examples": total,
"dominant_exact": sum(bool(row["dominant_exact"]) for row in rows),
"dominant_exact_rate": mean([float(bool(row["dominant_exact"])) for row in rows]),
"dominant_present_anywhere": sum(bool(row["dominant_present_anywhere"]) for row in rows),
"dominant_present_anywhere_rate": mean([float(bool(row["dominant_present_anywhere"])) for row in rows]),
"all_exact": sum(bool(row["all_exact"]) for row in rows),
"all_exact_rate": mean([float(bool(row["all_exact"])) for row in rows]),
"mean_missing_expected": mean([float(row["missing_expected_count"]) for row in rows]),
"mean_extra_predicted": mean([float(row["extra_predicted_count"]) for row in rows]),
"mean_dominant_f1": mean([float(row["dominant_f1"]) for row in rows]),
"mean_irreducible_f1": mean([float(row["irreducible_f1"]) for row in rows]),
"mean_reducible_f1": mean([float(row["reducible_f1"]) for row in rows]),
"mean_all_f1": mean([float(row["all_f1"]) for row in rows]),
"prediction_think_rate": mean([float(bool(row["prediction_has_think"])) for row in rows]),
"prediction_answer_tag_rate": mean([float(bool(row["prediction_has_answer"])) for row in rows]),
"categories": dict(categories),
}
def annotate_bars(ax: plt.Axes, bars, total: int | None = None) -> None:
for bar in bars:
height = bar.get_height()
label = f"{height:.0f}"
if total:
label += f"\n{height / total:.0%}"
ax.annotate(
label,
xy=(bar.get_x() + bar.get_width() / 2, height),
xytext=(0, 4),
textcoords="offset points",
ha="center",
va="bottom",
fontsize=9,
)
def missing_bucket(value: object) -> str:
count = int(value)
return "4+" if count >= 4 else str(count)
def plot_label_text(label: object) -> str:
text = str(label)
text = text.replace(r"$\\bar{t}$", "tbar").replace(r"$\bar{t}$", "tbar")
text = text.replace(r"\\bar{t}", "tbar").replace(r"\bar{t}", "tbar")
text = text.replace("$", "").replace("\\", "").replace("{", "").replace("}", "")
return re.sub(r"\s+", " ", text).strip()
def wrapped(labels: list[str], width: int = 18) -> list[str]:
wrapped_labels = []
for label in labels:
text = plot_label_text(label)
wrapped_labels.append("\n".join(textwrap.wrap(text, width=width)) or text)
return wrapped_labels
def plot_latest(rows: list[dict[str, object]], output: Path, title: str, catalog: Catalog) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
total = len(rows)
fig, axes = plt.subplots(2, 2, figsize=(17, 11))
fig.suptitle(title, fontsize=16, y=0.985)
ax = axes[0][0]
category_order = [
"dominant exact",
"dominant plus extra",
"right background, wrong section",
"dominant missing",
"no dominant parsed",
]
counts = Counter(str(row["category"]) for row in rows)
values = [counts.get(category, 0) for category in category_order]
bars = ax.bar(
wrapped(category_order, 14),
values,
color=["tab:green", "tab:olive", "tab:cyan", "tab:red", "tab:gray"],
)
annotate_bars(ax, bars, total)
ax.set_title("Dominant Background Outcome")
ax.set_ylabel("examples")
ax.grid(True, axis="y", alpha=0.25)
ax = axes[0][1]
bucket_order = ["0", "1", "2", "3", "4+"]
missing_counts = Counter(missing_bucket(row["missing_expected_count"]) for row in rows)
bars = ax.bar(bucket_order, [missing_counts.get(bucket, 0) for bucket in bucket_order], color="tab:orange")
annotate_bars(ax, bars, total)
ax.set_title("Missing Expected Backgrounds")
ax.set_xlabel("expected backgrounds absent from prediction")
ax.set_ylabel("examples")
ax.grid(True, axis="y", alpha=0.25)
ax = axes[1][0]
metric_sections = ["dominant", "irreducible", "reducible", "all"]
x = list(range(len(metric_sections)))
width = 0.24
for offset, metric, color in [
(-width, "precision", "tab:blue"),
(0.0, "recall", "tab:purple"),
(width, "f1", "tab:green"),
]:
values = [mean([float(row[f"{section}_{metric}"]) for row in rows]) for section in metric_sections]
ax.bar([idx + offset for idx in x], values, width=width, label=metric, color=color, alpha=0.85)
ax.set_xticks(x)
ax.set_xticklabels(metric_sections)
ax.set_ylim(0, 1.05)
ax.set_title("Mean Set-Matching Metrics")
ax.set_ylabel("score")
ax.grid(True, axis="y", alpha=0.25)
ax.legend()
ax = axes[1][1]
expected_labels = sorted({str(row["expected_dominant"]) for row in rows})
predicted_labels = sorted({str(row["predicted_dominant"]) or "" for row in rows})
matrix = []
for expected_label in expected_labels:
matrix.append(
[
sum(
1
for row in rows
if str(row["expected_dominant"]) == expected_label
and (str(row["predicted_dominant"]) or "") == predicted_label
)
for predicted_label in predicted_labels
]
)
image = ax.imshow(matrix, cmap="Blues", aspect="auto")
ax.set_title("Dominant Confusion Matrix")
ax.set_xlabel("predicted dominant")
ax.set_ylabel("expected dominant")
ax.set_xticks(range(len(predicted_labels)))
ax.set_xticklabels(wrapped(predicted_labels, 12), rotation=45, ha="right", fontsize=8)
ax.set_yticks(range(len(expected_labels)))
ax.set_yticklabels(wrapped(expected_labels, 18), fontsize=8)
for y, row_values in enumerate(matrix):
for x_idx, value in enumerate(row_values):
if value:
ax.text(x_idx, y, str(value), ha="center", va="center", fontsize=8)
fig.colorbar(image, ax=ax, fraction=0.046, pad=0.04)
summary = summarize(rows)
fig.tight_layout(rect=[0, 0.055, 1, 0.955])
fig.text(
0.01,
0.014,
(
f"examples {total} | dominant exact {summary['dominant_exact_rate']:.1%} | "
f"dominant present anywhere {summary['dominant_present_anywhere_rate']:.1%} | "
f"all-background F1 {summary['mean_all_f1']:.3f}"
),
ha="left",
va="bottom",
family="monospace",
fontsize=9,
)
fig.savefig(output, dpi=180)
plt.close(fig)
def plot_trend(summaries: list[dict[str, object]], output: Path, title: str) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
labels = [str(row["label"]) for row in summaries]
x = list(range(len(labels)))
fig, axes = plt.subplots(2, 2, figsize=(16, 10))
fig.suptitle(title, fontsize=16, y=0.985)
ax = axes[0][0]
ax.plot(x, [float(row["dominant_exact_rate"]) for row in summaries], marker="o", label="dominant exact")
ax.plot(
x,
[float(row["dominant_present_anywhere_rate"]) for row in summaries],
marker="o",
label="dominant present anywhere",
)
ax.plot(x, [float(row["all_exact_rate"]) for row in summaries], marker="o", label="all exact")
ax.set_ylim(0, 1.05)
ax.set_title("Exact Accuracy")
ax.set_ylabel("rate")
ax.set_xticks(x)
ax.set_xticklabels(labels)
ax.grid(True, alpha=0.25)
ax.legend()
ax = axes[0][1]
for key, label in [
("mean_dominant_f1", "dominant"),
("mean_irreducible_f1", "irreducible"),
("mean_reducible_f1", "reducible"),
("mean_all_f1", "all"),
]:
ax.plot(x, [float(row[key]) for row in summaries], marker="o", label=label)
ax.set_ylim(0, 1.05)
ax.set_title("Mean F1")
ax.set_ylabel("F1")
ax.set_xticks(x)
ax.set_xticklabels(labels)
ax.grid(True, alpha=0.25)
ax.legend()
ax = axes[1][0]
bucket_order = ["0", "1", "2", "3", "4+"]
bottoms = [0] * len(labels)
colors = ["tab:green", "tab:olive", "tab:orange", "tab:red", "tab:gray"]
for bucket, color in zip(bucket_order, colors):
values = [int(row.get(f"missing_{bucket}", 0)) for row in summaries]
ax.bar(x, values, bottom=bottoms, label=bucket, color=color, alpha=0.85)
bottoms = [a + b for a, b in zip(bottoms, values)]
ax.set_title("Missing Expected Backgrounds")
ax.set_ylabel("examples")
ax.set_xticks(x)
ax.set_xticklabels(labels)
ax.grid(True, axis="y", alpha=0.25)
ax.legend(title="missing")
ax = axes[1][1]
ax.plot(x, [float(row["mean_missing_expected"]) for row in summaries], marker="o", label="missing")
ax.plot(x, [float(row["mean_extra_predicted"]) for row in summaries], marker="o", label="extra")
ax.set_title("Mean Set Difference Size")
ax.set_ylabel("backgrounds/example")
ax.set_xticks(x)
ax.set_xticklabels(labels)
ax.grid(True, alpha=0.25)
ax.legend()
fig.tight_layout(rect=[0, 0.04, 1, 0.955])
fig.savefig(output, dpi=180)
plt.close(fig)
def write_csv(rows: list[dict[str, object]], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = [
"source",
"id",
"loss",
"expected_dominant",
"predicted_dominant",
"category",
"dominant_exact",
"dominant_present_anywhere",
"all_exact",
"missing_expected_count",
"extra_predicted_count",
"dominant_precision",
"dominant_recall",
"dominant_f1",
"irreducible_precision",
"irreducible_recall",
"irreducible_f1",
"reducible_precision",
"reducible_recall",
"reducible_f1",
"all_precision",
"all_recall",
"all_f1",
"prediction_has_think",
"prediction_has_answer",
]
with path.open("w", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
writer.writerow({field: row.get(field) for field in fieldnames})
def write_summary_csv(rows: list[dict[str, object]], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = [
"source",
"label",
"step",
"examples",
"dominant_exact",
"dominant_exact_rate",
"dominant_present_anywhere",
"dominant_present_anywhere_rate",
"all_exact",
"all_exact_rate",
"mean_dominant_f1",
"mean_irreducible_f1",
"mean_reducible_f1",
"mean_all_f1",
"mean_missing_expected",
"mean_extra_predicted",
"prediction_think_rate",
"prediction_answer_tag_rate",
"missing_0",
"missing_1",
"missing_2",
"missing_3",
"missing_4+",
]
with path.open("w", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
writer.writerow({field: row.get(field) for field in fieldnames})
def write_traces(rows: list[dict[str, object]], source_rows: list[dict], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
rows_by_id = {str(row["id"]): row for row in rows}
with path.open("w") as handle:
for source_row in source_rows:
row_id = str(source_row.get("id"))
metrics = rows_by_id.get(row_id, {})
record = {
"id": row_id,
"category": metrics.get("category"),
"expected_dominant": metrics.get("expected_dominant"),
"predicted_dominant": metrics.get("predicted_dominant"),
"prediction_think": metrics.get("prediction_think", ""),
"prediction_answer": metrics.get("prediction_answer", ""),
"prompt": source_row.get("prompt"),
"prediction": source_row.get("prediction"),
"reference": source_row.get("reference"),
}
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--inputs",
nargs="*",
type=Path,
default=sorted(Path("data/hep_sft/checkpoint_eval").glob("qwen2_5_7b*_val_outputs.jsonl")),
help="Generated validation JSONL files. Defaults to qwen2.5 7B checkpoint eval outputs.",
)
parser.add_argument("--latest", type=Path, help="Latest checkpoint JSONL. Defaults to highest step among inputs.")
parser.add_argument("--catalog", type=Path, default=Path("dataset/config/process_catalog.v1.json"))
parser.add_argument("--output-dir", type=Path, default=Path("plotting"))
parser.add_argument(
"--trend-stem",
default="qwen2_5_7b_signature_background_checkpoint_accuracy",
help="Filename stem for the across-checkpoint trend plot and CSV.",
)
return parser.parse_args()
def main() -> None:
args = parse_args()
if not args.inputs:
raise SystemExit("No input JSONL files found.")
catalog = load_catalog(args.catalog)
inputs = sorted(args.inputs, key=checkpoint_sort_key)
latest = args.latest or max((path for path in inputs if STEP_RE.search(path.name)), key=checkpoint_sort_key)
all_summaries: list[dict[str, object]] = []
for path in inputs:
evaluated = [evaluate_row(row, path, catalog) for row in load_jsonl(path)]
summary = summarize(evaluated)
missing_counts = Counter(missing_bucket(row["missing_expected_count"]) for row in evaluated)
summary.update({f"missing_{bucket}": missing_counts.get(bucket, 0) for bucket in ["0", "1", "2", "3", "4+"]})
summary["source"] = path.name
summary["label"] = checkpoint_label(path)
sort_group, sort_step = checkpoint_sort_key(path)
summary["sort_group"] = sort_group
summary["step"] = sort_step if sort_group else None
all_summaries.append(summary)
latest_source_rows = load_jsonl(latest)
latest_rows = [evaluate_row(row, latest, catalog) for row in latest_source_rows]
latest_stem = latest.name.replace("_val_outputs.jsonl", "")
latest_plot = args.output_dir / f"{latest_stem}_signature_background_accuracy.png"
latest_csv = args.output_dir / f"{latest_stem}_signature_background_examples.csv"
latest_summary = args.output_dir / f"{latest_stem}_signature_background_summary.json"
latest_traces = args.output_dir / f"{latest_stem}_signature_background_traces.jsonl"
trend_plot = args.output_dir / f"{args.trend_stem}.png"
trend_csv = args.output_dir / f"{args.trend_stem}.csv"
plot_latest(
latest_rows,
latest_plot,
title=f"{latest_stem} Signature-Background Accuracy",
catalog=catalog,
)
write_csv(latest_rows, latest_csv)
latest_summary.write_text(json.dumps(summarize(latest_rows), indent=2, sort_keys=True) + "\n")
write_traces(latest_rows, latest_source_rows, latest_traces)
plot_trend(all_summaries, trend_plot, title="Qwen2.5 7B Signature-Background Accuracy by Checkpoint")
write_summary_csv(all_summaries, trend_csv)
print(f"Wrote {latest_plot}")
print(f"Wrote {latest_csv}")
print(f"Wrote {latest_summary}")
print(f"Wrote {latest_traces}")
print(f"Wrote {trend_plot}")
print(f"Wrote {trend_csv}")
if __name__ == "__main__":
main()