Skip to content

Make the metrics suite complete and usable from one object - #79

Open
mkeeler43 wants to merge 7 commits into
mainfrom
feat/metrics-suite
Open

mkeeler43 wants to merge 7 commits into
mainfrom
feat/metrics-suite

Conversation

@mkeeler43

Copy link
Copy Markdown
Contributor

What this is

After #75 the evaluation layer on main registered eight metrics and no way to score several models at once without building an EvaluationContext by hand. The full suite lived on feat/dataset-sensitivity-fields as ~42 separately maintained functions with prefixed names (pca_mmd, lv_vendi, iog_median, ...). This PR brings the suite to main on a contract simple enough to explain in one sentence, and adds the object you use it through.

Nothing here touches the LVAE, the baselines, or the pool tooling. Those are separate PRs.

How you use it

from engiopt.evaluation import Board, METRICS

METRICS.explain()                                     # every metric: the question it answers, direction, cost

board = Board(problem, reference=REF, train=TRAIN)    # saved designs
board.evaluate(models, space="pca", aggregation="median")
board.explain()                                       # who each column picks, and what real designs score
board.rank("mmd")

Board.load("beams2d", ["cgan_cnn_2d", "vqgan"], expensive=True)   # published checkpoints by name

example_metrics_suite.ipynb at the repo root walks all of this on a toy problem in ten code cells. It runs in seconds with no downloads.

The contract

A metric is a function of an EvaluationContext plus five declarations: name, family, cost, direction, and (rarely) pixel_only. The first line of its docstring is the question the column answers, and that line is what explain() and --list-metrics print. higher_is_better=None means a diagnostic: read it beside the other columns, never rank on it.

Two things that used to be baked into metric names are now arguments to Board.evaluate:

  • space — where the question is asked. pixel and pca are built in; a learned latent space registers one projection function with register_space. Metrics that need the actual designs (a constraint check, a copy corpus) declare pixel_only and are skipped elsewhere. This replaces the pca_*/lv_*/lvoff_*/pixel_* prefixes.
  • aggregation — how one-value-per-design metrics collapse to a number. mean or median, declared on the spec and recorded in every row. This replaces the *_median columns.

The metrics

Twenty, down from forty-two. Renamed so the name says what is measured:

Family Metrics
distribution mmd, coverage
diversity vendi, dpp
conditions cond_sens, per_condition_distance (was paired_distance), volume_error (was cond_err)
memorization train_distance, copy_rate (together, was novelty)
feasibility viol
performance iog, cog, fog, calls_to_settle, gap_after_calls, reaches_reference_rate, first_call_gain
cost generation_seconds, n_parameters, train_minutes

dpp keeps its name but is now the n-th root of the kernel determinant, bounded in (0, 1]. The raw determinant that v1 rows hold reads around 1e-20 on every real board and cannot be compared with this column. calls_to_parity is gone: parity is measured against the reference optimum, so a copier reaches it in zero calls.

The board also scores one random half of the reference designs against the other half and shows that as a row. That is what real, correct designs score on each column on this problem, and it is the only comparison point that is measured rather than assumed.

Specs

All four problems get a v2.json with the full metric list and the aggregation policy; v2 is now the default. v1.json stays committed so rows published under it can be read. A v1 spec still loads, but evaluating under it raises Unknown metric 'novelty' at metric selection, because that metric no longer exists. Nothing on the IDEALLab/engiopt-leaderboard dataset references v1 (it has no rows).

Also in here

  • OptimizationResults.trajectories (each design's gap at every optimizer call), EvaluationContext.reduce, model_params, train_minutes, train_designs.
  • register_metric silently registered into the global registry when handed an empty custom one, because MetricRegistry is a Mapping and an empty mapping is falsy. Fixed.
  • "brief" is replaced by "conditions" throughout, including three docstrings from Generator contract, shared evaluation layer, and HF-hosted leaderboard #75.
  • LEADERBOARD.md, README.md and CONTRIBUTING_A_MODEL.md describe the suite that exists now.

What I checked

  • pytest tests/ -m "not network": 298 passed, 0 skipped, 1 failed. The failure is test_every_generator_directory_has_an_adapter, which trips on six leftover __pycache__-only directories in my working copy from branch switching. They are not in git and cannot exist in CI.
  • python -m engiopt.evaluate --list-metrics runs as documented and prints the table above.
  • The notebook executes end to end with no errors or warnings.
  • Every pre-commit hook (pyright, codespell, ruff) passed on each commit.
  • New tests: tests/test_metrics_suite.py (19), tests/test_board.py (8), tests/test_metric_registry.py (4). The trajectory metrics are checked against hand-built paths with known answers.

What I did not check

  • Nothing was run against a real EngiBench problem or the Hub. Board.load calls Evaluator.for_problem and Generator.from_pretrained with the signatures they have on main, but has not been exercised end to end. The notebook is a toy problem by design.

Follow-ups

  • Bootstrap confidence intervals beside each value (evaluate(bootstrap=...)). Planned, small, not in this PR.
  • Baselines (knn_retrieval, deconv_regression, the planted constructions): separate PR.
  • space="lv": registered by the LVAE PR once the Lipschitz fixes land.

🤖 Generated with Claude Code

mkeeler43 and others added 7 commits September 25, 2026 11:04
A registered metric is now five fields: name, function, family, cost and
direction. The first line of its docstring is the question the column
answers, written so it reads without the metric's name. Direction None
means a diagnostic -- read beside the other columns, never ranked on --
which is what `dpp` becomes: three monotone transforms of one determinant
that collapses toward underflow before the models stop differing.

Where a metric is computed and how per-design values collapse to one
number are no longer properties of the metric. `Board.evaluate` takes
`space` (pixel, or a PCA of the reference designs) and `aggregation`
(mean or median), so every metric is written once; the spec records the
aggregation in each row and the `*_median` columns are retired. The board
also scores half of the reference designs against the other half, which
is the only honest measurement of what real designs score on a column.

Port the math layer from the workshop branch (vendi, geometric-mean DPP,
median-sigma, PRDC), fix `register_metric` silently targeting the global
registry when handed an empty one, and replace "brief" with "conditions"
throughout, including three PR #75 docstrings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Ten code cells, one call each: what metrics exist, score five models,
read the board, rank, change the aggregation, change the space, register
a metric. Two of the models are constructions with known right answers;
the one that returns the correct withheld designs under permuted
conditions scores a perfect MMD, which is the lesson.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Twenty metrics, each one question: the set-level ones (mmd, coverage,
vendi, dpp), the per-condition ones (per_condition_distance, volume_error),
feasibility, memorization (train_distance, copy_rate), cond_sens, the
optimality gaps, four call-budget metrics that price a defect in optimizer
calls (calls_to_settle, gap_after_calls, reaches_reference_rate,
first_call_gain), and three cost columns.

Names say what they measure. `novelty` split into `train_distance` (nearest
training design) and `copy_rate` (the submission gate, against everything
the model could have copied); `cond_err` became `volume_error`, since it was
never about conditions in general; `recovery` became `gap_after_calls`, and
`parity_rate` became `reaches_reference_rate`. `calls_to_parity` is gone: a
copier reaches parity in zero calls. `dpp` keeps its name and becomes the
n-th-root form, since the raw determinant reads 1e-20 on every real board.

The context now carries each design's re-optimization trajectory, the
generator's parameter count and training time, so the new metrics have what
they read. Specs move to v2 with the full list and the aggregation policy;
v1 stays committed so published rows can be read under the protocol that
produced them, and only the current spec must name live metrics.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`Board.evaluate(space=...)` looks the space up in a registry: `pixel` and
`pca` are built in, and a learned latent space registers one projection
function. Metrics that need the actual designs declare `pixel_only` and are
skipped elsewhere. The kernel bandwidth defaults to the median pairwise
distance of the reference designs in the chosen space, so the diversity
columns can see anything on a problem the spec never pinned a value for.

`Board.from_generators` scores loaded models through an `Evaluator`, which
is what cond_sens, the cost columns and the physics need, and `Board.load`
pulls published checkpoints by name -- the workshop in one call.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The docs named `novelty`, listed the old default pass, and pointed at
spec v1. They now name the columns that exist, say what --list-metrics
prints, and send readers to the notebook for the rest. `Board` is exported
from `engiopt.evaluation` as the front door.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ruff format --check` covers .ipynb files and the notebook had never been
through the formatter. Re-executed after formatting so the stored outputs
match the source.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CI runs `ruff check .` over notebooks too. The first cell set pandas
display options before importing the registry, two cells had imports out
of the repo's order, and the toy problem's helpers lacked docstrings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant