Files
giant/giant/training/plots.py
T
lars fc19934ba6 Remove the hand-rolled analysis submit path (gitea #83)
b2luigi's AnalysisComputeTask now submits the per-(plot, chunk) jobs, so the
bespoke submit-file generator has nothing left to do:

- giant/analysis/condor.py -> giant/analysis/run.py, dropping SubmitConfig,
  the wrapper/submit-description templates, _job_walltimes and
  _resolve_giant_executable. What stays is the actual logic — prep,
  RunMeta, the rollout-YAML loading, compute_reduced/compute_one and
  merge_one/merge_all — and the module no longer submits anything, hence
  the name.
- `giant analyze submit` is gone; prep / compute-one / merge-one / list /
  render / metrics remain as the single-step primitives the workflow calls.
- tests/test_condor.py -> tests/test_analysis_run.py, minus the
  submit-description cases.

CLAUDE.md and README.md document the workflow package, the new `workflow`
extra, and — for whenever condor-gpu-train-rollout is merged — that its
train-submit/rollout-submit commands are deliberately superseded and must
not be revived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 12:36:59 +02:00

357 lines
13 KiB
Python

"""Training-progress plots from `<run_dir>/metrics.csv` (gitea #75).
`MetricsCollector` (`giant.training.metrics`) writes one row per epoch with a
column set that varies by run — flow/ddpm vs wgan, routed vs not (see the
`MetricSpec` declarations in `giant.training.trainers`). This module reads
that header dynamically rather than hardcoding a column list, buckets columns
by the fixed naming convention `MetricsCollector` itself documents
(`<stage>/train/<key>`, `<stage>/val/<key>`, `<stage>/router/<key>`,
`<stage>/<key>` for point-in-time values, and an unprefixed run-level tail —
see `giant.training.metrics`'s module docstring), and renders one PDF per
applicable figure with the same `plotstyle` conventions
`giant.analysis.render` uses, for visual consistency with the
rollout-vs-reference plots.
Unlike `giant.analysis`, there is no reduce/chunk/condor split here — the CSV
is tiny and this always runs as one local pass — but the CLI entry point
still lives under `giant analyze` (`analyze metrics`) as the shared home for
plotstyle-rendered diagnostics, and shares its `analysis_runs/` output
convention (see `derive_metrics_dir`) so training-progress plots don't get
written into the training run directory itself.
"""
from __future__ import annotations
import csv
import math
from dataclasses import dataclass
from pathlib import Path
# Stage names are always exactly these two — hardcoded in
# `giant.training.trainers.build_stage_trainers` — so a column belongs to a
# stage iff it's prefixed by one of these, and everything else (bar `epoch`)
# is run-level. This is what makes dynamic header parsing tractable without
# needing to know the per-run metric keys themselves.
_STAGE_NAMES = ("stage1", "stage2")
_ACC_KEYS = {"nsec_acc", "stop_acc", "type_acc"}
_WGAN_BALANCE_KEYS = {"d_loss", "g_loss", "wasserstein", "gp_loss"}
_ROUTER_KEYS = ("entropy", "util_min", "util_max", "util_std")
@dataclass
class MetricsTable:
"""`<run_dir>/metrics.csv`, parsed with no hardcoded column list."""
epochs: list[int]
columns: dict[str, list[float]]
@classmethod
def load(cls, path: str | Path) -> "MetricsTable":
with open(path, newline="") as f:
rows = list(csv.DictReader(f))
epochs = [int(float(r["epoch"])) for r in rows]
fieldnames = rows[0].keys() if rows else []
columns = {name: [float(r[name]) for r in rows] for name in fieldnames if name != "epoch"}
return cls(epochs=epochs, columns=columns)
def best_epochs(self) -> list[int]:
is_best = self.columns.get("is_best")
if not is_best:
return []
return [epoch for epoch, flag in zip(self.epochs, is_best) if flag]
# --- column classification --------------------------------------------------
def _stages(columns: dict) -> list[str]:
return [s for s in _STAGE_NAMES if any(name.startswith(f"{s}/") for name in columns)]
def _split(columns: dict, stage: str, split: str) -> dict[str, str]:
prefix = f"{stage}/{split}/"
return {name[len(prefix) :]: name for name in columns if name.startswith(prefix)}
def _point_in_time(columns: dict, stage: str) -> dict[str, str]:
prefix = f"{stage}/"
out = {}
for name in columns:
if not name.startswith(prefix):
continue
rest = name[len(prefix) :]
head = rest.split("/", 1)[0]
if head not in ("train", "val", "router"):
out[rest] = name
return out
def _router(columns: dict, stage: str) -> dict[str, str]:
prefix = f"{stage}/router/"
return {name[len(prefix) :]: name for name in columns if name.startswith(prefix)}
def _run_level(columns: dict) -> dict[str, str]:
known_prefixes = tuple(f"{s}/" for s in _STAGE_NAMES)
return {name: name for name in columns if not name.startswith(known_prefixes)}
def _loss_keys(train: dict[str, str], val: dict[str, str]) -> list[str]:
keys = {k for k in train if k not in _ACC_KEYS and k not in _WGAN_BALANCE_KEYS and k != "grad_norm"}
keys |= {k for k in val if k not in _ACC_KEYS and k not in _WGAN_BALANCE_KEYS and k != "grad_norm"}
return sorted(keys)
# --- output location ---------------------------------------------------------
def derive_metrics_dir(
run_dir: str | Path,
out_dir: str | Path | None = None,
default_base: str | Path | None = None,
) -> Path:
"""Plots output directory.
Precedence: an explicit `out_dir` always wins. Otherwise
`default_base / f"metrics_{run_dir.name}"` (the CLI passes the repo's
gitignored `analysis_runs/`, matching `giant.analysis.run.derive_run_dir`'s
convention) — training-progress plots live alongside rollout-vs-reference
analysis runs, not inside the training run directory itself.
"""
if out_dir is not None:
return Path(out_dir)
base = Path(default_base) if default_base is not None else Path.cwd() / "analysis_runs"
return base / f"metrics_{Path(run_dir).name}"
# --- figures ------------------------------------------------------------------
def _mark_best(ax, table: MetricsTable) -> None:
for epoch in table.best_epochs():
ax.axvline(epoch, color="grey", linestyle="--", linewidth=0.8, alpha=0.7)
def _overview_figure(table: MetricsTable):
import plotstyle as ps
run_level = _run_level(table.columns)
if "val/loss" not in run_level:
return None
fig, ax = ps.new_figure("thesis-single", title="training overview")
ax.plot(table.epochs, table.columns["val/loss"], label="val/loss")
if "val/marginal_kl" in run_level:
kl = table.columns["val/marginal_kl"]
if any(math.isfinite(v) for v in kl):
ax.plot(table.epochs, kl, label="val/marginal_kl")
_mark_best(ax, table)
best = table.best_epochs()
if best:
idx = table.epochs.index(best[-1])
ax.annotate(
f"best: epoch {best[-1]}\nval/loss={table.columns['val/loss'][idx]:.4g}",
xy=(best[-1], table.columns["val/loss"][idx]),
xytext=(0.98, 0.95),
textcoords="axes fraction",
ha="right",
va="top",
fontsize=8,
)
ax.set_xlabel("epoch")
ax.set_ylabel("loss")
ps.style_legend(ax, title="series")
return fig
def _loss_figure(table: MetricsTable, stage: str):
import plotstyle as ps
train = _split(table.columns, stage, "train")
val = _split(table.columns, stage, "val")
keys = _loss_keys(train, val)
if not keys:
return None
n = len(keys)
ncols = min(3, n)
nrows = (n + ncols - 1) // ncols
fig, axes = ps.new_figure(
"slide-16x9",
title=f"{stage} loss",
nrows=nrows,
ncols=ncols,
squeeze=False,
)
flat = axes.ravel()
for ax, key in zip(flat, keys):
if key in train:
ax.plot(table.epochs, table.columns[train[key]], label="train")
if key in val:
ax.plot(table.epochs, table.columns[val[key]], label="val")
ax.set_yscale("log")
ax.set_title(key, fontsize=8)
ax.set_xlabel("epoch")
for j in range(n, len(flat)):
flat[j].set_visible(False)
ps.style_legend(flat[0], title="series")
return fig
def _lr_figure(table: MetricsTable):
import plotstyle as ps
series: dict[str, str] = {}
for stage in _stages(table.columns):
for key, col in _point_in_time(table.columns, stage).items():
series[f"{stage}/{key}"] = col
if not series:
return None
fig, ax = ps.new_figure("thesis-single", title="learning rate schedule")
for label, col in series.items():
ax.plot(table.epochs, table.columns[col], label=label)
ax.set_xlabel("epoch")
ax.set_ylabel("learning rate")
ps.style_legend(ax, title="series")
return fig
def _accuracy_figure(table: MetricsTable, stage: str):
import plotstyle as ps
train = _split(table.columns, stage, "train")
val = _split(table.columns, stage, "val")
keys = sorted((set(train) | set(val)) & _ACC_KEYS)
if not keys:
return None
n = len(keys)
fig, axes = ps.new_figure("slide-16x9", title=f"{stage} accuracy", nrows=1, ncols=n, squeeze=False)
flat = axes.ravel()
for ax, key in zip(flat, keys):
if key in train:
ax.plot(table.epochs, table.columns[train[key]], label="train")
if key in val:
ax.plot(table.epochs, table.columns[val[key]], label="val")
ax.set_title(key, fontsize=8)
ax.set_xlabel("epoch")
ax.set_ylim(0, 1)
ps.style_legend(flat[0], title="series")
return fig
def _grad_norm_figure(table: MetricsTable):
import plotstyle as ps
run_level = _run_level(table.columns)
if "grad_norm" not in run_level:
return None
fig, ax = ps.new_figure("thesis-single", title="gradient norm")
ax.plot(table.epochs, table.columns["grad_norm"], label="grad_norm")
for stage in _stages(table.columns):
train = _split(table.columns, stage, "train")
for key in ("grad_norm_d", "grad_norm_g", "grad_norm_type_slice", "grad_norm_cont_slice"):
if key in train:
ax.plot(table.epochs, table.columns[train[key]], label=f"{stage}/{key}")
ax.set_yscale("log")
ax.set_xlabel("epoch")
ax.set_ylabel("grad norm")
ps.style_legend(ax, title="series")
return fig
def _router_figure(table: MetricsTable, stage: str):
import plotstyle as ps
router = _router(table.columns, stage)
if "entropy" not in router:
return None
fig, ax = ps.new_figure("thesis-single", title=f"{stage} router health")
ax.plot(table.epochs, table.columns[router["entropy"]], label="entropy", color="black")
ax.set_xlabel("epoch")
ax.set_ylabel("entropy [bits]")
ax2 = ax.twinx()
for key in ("util_min", "util_max", "util_std"):
if key in router:
ax2.plot(table.epochs, table.columns[router[key]], label=key, linestyle="--")
ax2.set_ylabel("expert utilization")
ax2.set_ylim(0, 1)
lines1, labels1 = ax.get_legend_handles_labels()
lines2, labels2 = ax2.get_legend_handles_labels()
ax.legend(lines1 + lines2, labels1 + labels2, loc="upper right", frameon=False, fontsize=7)
return fig
def _wgan_balance_figure(table: MetricsTable, stage: str):
import plotstyle as ps
train = _split(table.columns, stage, "train")
keys = [k for k in _WGAN_BALANCE_KEYS if k in train]
if not keys:
return None
fig, ax = ps.new_figure("thesis-single", title=f"{stage} WGAN critic/generator balance")
for key in sorted(keys):
ax.plot(table.epochs, table.columns[train[key]], label=key)
ax.set_xlabel("epoch")
ax.set_ylabel("value")
ps.style_legend(ax, title="series")
return fig
def _throughput_figure(table: MetricsTable):
import plotstyle as ps
run_level = _run_level(table.columns)
keys = [k for k in ("samples_per_sec", "gpu_mem_mb", "epoch_time_s") if k in run_level]
if not keys:
return None
fig, axes = ps.new_figure("slide-16x9", title="throughput / resources", nrows=1, ncols=len(keys), squeeze=False)
flat = axes.ravel()
for ax, key in zip(flat, keys):
ax.plot(table.epochs, table.columns[key])
_mark_best(ax, table)
ax.set_title(key, fontsize=8)
ax.set_xlabel("epoch")
return fig
# --- entry point ---------------------------------------------------------
def render_metrics(
run_dir: str | Path,
out_dir: str | Path | None = None,
default_base: str | Path | None = None,
) -> list[Path]:
"""`<run_dir>/metrics.csv` -> `<plots dir>/<name>.pdf`.
See `derive_metrics_dir` for how the plots directory is resolved.
"""
import matplotlib.pyplot as plt
import plotstyle as ps
ps.use()
table = MetricsTable.load(Path(run_dir) / "metrics.csv")
plots_dir = derive_metrics_dir(run_dir, out_dir, default_base)
plots_dir.mkdir(parents=True, exist_ok=True)
figures = [("overview", _overview_figure(table))]
for stage in _stages(table.columns):
figures.append((f"{stage}_loss", _loss_figure(table, stage)))
figures.append(("lr", _lr_figure(table)))
for stage in _stages(table.columns):
figures.append((f"{stage}_accuracy", _accuracy_figure(table, stage)))
figures.append(("grad_norm", _grad_norm_figure(table)))
for stage in _stages(table.columns):
figures.append((f"{stage}_router", _router_figure(table, stage)))
figures.append((f"{stage}_wgan_balance", _wgan_balance_figure(table, stage)))
figures.append(("throughput", _throughput_figure(table)))
paths: list[Path] = []
for name, fig in figures:
if fig is None:
continue
path = plots_dir / name
ps.savefig(fig, str(path), formats=("pdf",))
plt.close(fig)
paths.append(path.with_suffix(".pdf"))
return paths