Adding benchmarks¶
This guide will break down the process of adding a new benchmark into several steps:
Please ensure you use the appropriate issue and pull request templates when contributing a new benchmark. Full examples can be found by filtering by the example benchmark addition label.
A Jupyter Notebook tutorial introducing this process interactively can also be found in the Python tutorials documentation directory.
Identifying metrics¶
Having selected an application/system/property of interest to test, the first step is to identify quantifiable metrics of performance.
Often, this will be a comparison to a reference value, such as the error with respect to DFT predictions of energies.
Note
In future, we expect to support user-selection of error metrics, e.g. MAE, RMSE, or higher-order errors.
Reference data may also include experimental data, or higher-accuracy theoretical predictions, e.g. CCSD(T).
Each metric in metrics.yml should have a level_of_theory field identifying its reference
method. The app compares this string against the level_of_theory set in models.yml for each
MLIP, flagging mismatches using the traffic light system. An exact string match is required.
Each metric must also define good, bad, unit, and tooltip. A weight may also
be set to control the metric’s contribution to the benchmark score, which defaults to 1 if omitted.
Choose the good and bad thresholds based on scientific considerations rather than the
range of scores produced by the current models. Typically, good is an error below which
further improvement offers little benefit, such as the uncertainty of the reference method.
The bad threshold marks an error at which a prediction is no longer useful and may even give
incorrect qualitative trends. It is valid for every current model to receive a low score if none
meet these criteria.
Warning
Use the standard strings defined in Levels of theory.
A typo or inconsistent capitalisation (e.g. experiment instead of Experimental) will
cause incorrect warnings.
In some cases, metrics may also encode correct behaviour without a specific reference, such as by quantifying features of a known distribution (curvature, minima, etc.), or quantifying the stability of a simulation.
Recording citations and contributor credit¶
Every benchmark needs a citations.yml beside its calc_*.py:
implementers: # who wrote the ML-PEG benchmark implementation
- name: A. Implementer
github: aimplementer
email: aimplementer@example.org # optional
contributors: # other substantive contributions to the benchmark
- name: A. Contributor
github: acontributor
email: acontributor@example.org # optional
citations: # what a user of this benchmark must cite
- key: author-2025-benchmark
role: benchmark_method
title: The benchmark paper
authors:
- A. Author
- B. Author
year: 2025
doi: 10.1234/example
url: https://example.org/benchmark
github and email are optional for both credit groups. Use a GitHub handle
and a plain email address, such as name@example.org, rather than a profile
URL or mailto: URL. Either contact field may be omitted or null. Email
contacts appear as links in the app and beside names in the terminal citation guidance.
implementers lists the people who wrote the ML-PEG calculation, analysis, or
app implementation. contributors lists other substantive contributions,
such as benchmark design, input-data preparation, scientific validation, or
review that materially shaped the benchmark. Credit the author of an earlier
implementation as a contributor when someone else adapted it for ML-PEG.
List a person once, under implementers if they also made other contributions.
Use the original pull requests and their commits to check these roles; the latest
person to edit a file is not necessarily its original implementer. Routine
maintenance does not automatically make someone a benchmark contributor.
Published authors belong in each citation’s authors list, in publication
order. Implementers and contributors may also be authors of the cited work, but
these credits do not replace its citation. Leave unknown implementers or
references as empty lists until verified; an empty list does not claim that the
benchmark was devised for ML-PEG. An empty contributors list simply means no
additional contributors have been recorded.
role can be one of:
benchmark_method: the paper defining the benchmark protocol.inspired_by: work the benchmark builds on rather than is taken from.reference_data: the source of the reference dataset.reference_method: the method used to calculate the reference values.upstream_framework: the framework the benchmark was adapted from.
Registered source frameworks (type: framework) are added automatically to a
benchmark’s references using its framework tags. An existing reference with the
same key, title, or DOI/URL is kept instead of adding a duplicate. Paper tags are
not added automatically. Record their references explicitly in citations.yml.
year, doi, and url may be null. A DOI takes precedence over a URL
when both are supplied.
ml_peg calc prints what to cite for the benchmarks that ran, just like in
your favourite DFT code.
Running Calculations¶
Create a new directory in
ml_peg/calcs/[category]with a short, unique benchmark name.Write a script that will run the MLIP calculations of interest for each model being tested.
The file should be named calc_[benchmark_name].py,
and placed in ml_peg/calcs/[category]/[benchmark_name].
For consistency, write output files to
ml_peg/calcs/[category]/[benchmark_name]/outputs. Download input data at runtime rather than
committing it to the code repository.
Before implementing the calculation, check the following project conventions:
Select the calculator precision explicitly. Static calculations, geometry optimisations, NEBs, and phonons normally use
precision="high"and long molecular dynamics normally usesprecision="low"unless another choice is justified.Before evaluating a structure, ensure
atoms.info["charge"]andatoms.info["spin"](spin multiplicity) are set as integers. For inputs known to be neutral singlets, simply assignatoms.info["charge"] = 0andatoms.info["spin"] = 1. Otherwise, preserve the physically meaningful values supplied by the input.Catch failures for each independent structure, trajectory, or property, emit a warning with useful model/system context, and store
NaNor explicit failure metadata so the remaining systems can still run. Bound iterative calculations and record convergence from the optimiser or dynamics state.Save raw model outputs and useful structures or trajectories under
outputs/[model]in a standard ASE format. Retain the component quantities needed to reproduce derived observables. Calculate derived properties and metrics during analysis rather than in the calculation script.Download all input data inside the test or run function. Store it in the ML-PEG S3 bucket and use
download_s3_databy default. Usedownload_github_dataonly when needed, and pin GitHub data to an immutable release or commit. See Data for both helpers.Apply a D3 correction when it is consistent with the reference protocol.
Mark expensive tests with
@pytest.mark.slowor@pytest.mark.very_slow, add progress reporting for long loops, and document a rough GPU runtime using a representative model, for examplemace-mp-0a, and named hardware.
Calculations and analyses should also follow the failure and mock-calculator guidance in Element filtering.
The test contained in this file may be runnable as a standalone script,
but it should also be possible to run with pytest, e.g.:
pytest -v -s ml_peg/calcs/[category]/[benchmark_name]/calc_[benchmark_name].py
Note
The -s stops pytest intercepting stdout, so print statements are
printed to the console.
pytest will run any functions beginning with test_, enabling multiple types
of calculation to be defined, discovered, and run within the same benchmark.
Future examples will also demonstrate the use of fixtures, which allow calculations such as
relaxation to be reused by multiple tests within the module.
Current examples are implemented with two alternative approaches:
a. Defining a script that iterates over models (recommended)¶
For consistency, we use similar model definitions as mlipx. Models from
ml_peg/models/models.yml can be loaded using the load_models function, while
current_models allows a subset of those to be used for calculations, including
through a command-line input, --models, to pytest, or to ml_peg calc.
The get_calculator function should be used to load the ASE Calculator that can
be attached to an Atoms object. This requires setting the precision argument,
taking values of either “low” or “high”, corresponding to float32 and float64 in most
cases, respectively. For static calculations, “high” precision is highly recommended,
while “low” precision is recommended for molecular dynamics simulations.
Details about model definitions and loading are described in more detail in Adding models.
Using pytest parametrisation,
the same calculation is run for each model name-model pair:
Note
Some imports are not included in the following example for simplicity.
from ml_peg.calcs.utils.utils import download_s3_data
from ml_peg.models.get_models import load_models
from ml_peg.models import current_models
MODELS = load_models(current_models)
OUT_PATH = Path(__file__).parent / "outputs"
@pytest.mark.parametrize("mlip", MODELS.items())
def test_benchmark(mlip: tuple[str, Any]) -> None:
"""
Run calculations required for lithium diffusion along path B.
Parameters
----------
mlip
Name of model use and model to get calculator.
"""
model_name, model = mlip
calc = model.get_calculator(precision="high")
data_path = (
download_s3_data(
key="inputs/[category]/[benchmark_name]/[benchmark_name].zip",
filename="[benchmark_name].zip",
)
/ "[benchmark_name]"
)
struct = read(data_path / "struct.xyz")
struct.info["charge"] = 0
struct.info["spin"] = 1
struct.calc = calc
try:
struct.info["pred_energy"] = struct.get_potential_energy()
except Exception as exc:
warn(f"Error calculating {model_name}: {exc}", stacklevel=2)
struct.info["pred_energy"] = np.nan
write_dir = OUT_PATH / model_name
write_dir.mkdir(parents=True, exist_ok=True)
write(write_dir / "struct.extxyz", struct)
b. Defining a ZnTrack node to run via mlipx¶
The process of running these is largely as
described by mlipx,
including running dvc init in ml_peg/calcs/[category]/[benchmark_name].
Note
In general, this would also require running git init,
but the repository should already be tracked by git.
In this example, we create the NewBenchmark node,
which defines a run function to perform the calculation using each model,
which mlipx automatically sets via the zntrack.deps().
mlipx also sets model_name via zntrack.params(), which
we use to differentiate the output files.
We also define test_new_benchmark, which enables this benchmark to be automatically
run identified and run using pytest.
Note
Some imports are not included in the following example for simplicity.
S3_KEY = "inputs/[category]/[benchmark_name]/[benchmark_name].zip"
S3_FILENAME = "[benchmark_name].zip"
# Local directory to store output data
OUT_PATH = Path(__file__).parent / "outputs"
# New benchmark node
class NewBenchmark(zntrack.Node):
"""New benchmark."""
model: NodeWithCalculator = zntrack.deps()
model_name: str = zntrack.params()
def run(self):
"""Run new benchmark."""
# Read in data and attach calculator
calc = self.model.get_calculator(precision="high")
data_path = (
download_s3_data(key=S3_KEY, filename=S3_FILENAME)
/ "[benchmark_name]"
)
struct = read(data_path / "struct.xyz")
struct.info["charge"] = 0
struct.info["spin"] = 1
struct.calc = calc
# Run calculation
try:
struct.info["pred_energy"] = struct.get_potential_energy()
except Exception as exc:
warn(f"Error calculating {self.model_name}: {exc}", stacklevel=2)
struct.info["pred_energy"] = np.nan
write_dir = OUT_PATH / self.model_name
write_dir.mkdir(parents=True, exist_ok=True)
write(write_dir / "struct.extxyz", struct)
def build_project(repro: bool = False) -> None:
"""
Build mlipx project.
Parameters
----------
repro
Whether to call dvc repro -f after building.
"""
project = mlipx.Project()
benchmark_node_dict = {}
for model_name, model in MODELS.items():
with project.group(model_name):
benchmark = NewBenchmark(
model=model,
model_name=model_name,
)
benchmark_node_dict[model_name] = benchmark
if repro:
with chdir(Path(__file__).parent):
project.repro(build=True, force=True)
else:
project.build()
def test_new_benchmark():
"""Run new benchmark via pytest."""
build_project(repro=True)
Analysing Calculations¶
The output files created by Running Calculations must then be analysed to calculate the metrics as planned in Identifying metrics.
Analysis must tolerate models that were not run and individual calculations that failed. A failed
required result should normally propagate to the aggregate metric as NaN. Do not replace
failed values with zero or silently remove them and score only the successful subset, as this
rewards a model for failing difficult systems. Reference data should be loaded once and
independently of which model happens to have an output directory.
In principle, the exact form of this is flexible, as long as the outputs can be assembled as required in Build Dash components to build the new application tab.
However, we strongly recommend following the template described below, which enables automated creation of tables and scatter plots, as well as placing structures to be visualised in an appropriate directory to be accessed by the app.
As with the script created in Running Calculations, we create a new file to be run by pytest,
containing a function beginning with test_ to launch the analysis.
In this case, we name the file
ml_peg/analysis/[category]/[benchmark_name]/analyse_[benchmark_name].py,
such that it can be run using:
pytest -v -s ml_peg/analysis/[category]/[benchmark_name]/analyse_[benchmark_name].py
In order to automatically generate the components for our application, we will make use
of decorators, such as @build_table and @plot_parity, which use the value
returned by the function, in combination with any parameters set for the decorator.
This therefore requires the values returned by decorated functions to be of a
particular form.
For @build_table, the value returned should be of the form:
{
"metric_1": {"model_1": value_1, "model_2": value_2, ...},
"metric_2": {"model_1": value_3, "model_2": value_4, ...},
...
}
This will generate a table with columns for each metric, as well as “MLIP” and “Score”
columns. Tooltips for each column header can also be set by the decorator, as well as
the location to save the JSON file to be loaded when building the app, which typically
would be placed in ml_peg/app/data/[category]/[benchmark_name].
Every benchmark should have at least one of these tables, which includes
the score for each metric, and allowing the table to calculate an overall score for the
benchmark, and so often this decorated function is called as a fixture by the test_
function.
Benchmarks may also include other tables, which can be built similarly, although currently the scores from these cannot be straightforwardly combined into an overall table.
For @plot_parity, the value returned should be of the form:
{
"ref": ref_values_list,
"model_1": model_1_values_list,
"model_2": model_2_values_list,
...
}
This will generate a scatter plot of reference value against model value for each model,
as well as a dashed line representing y=x. Additional options can be set to specify
the plot title, axes labels, and hover data.
Hover data will always include x and y values, but additional labels for each point are set
via a dictionary of label names and lists of labels (corresponding to the same data points as
ref_values_list etc.):
{
"label_1": label_1_list,
"label_2": label_2_list,
...
}
Typically, functions like this that generate plots would also be fixtures that are passed to another function, which performs the aggregation needed to then pass the metric’s value to the function that generates the table for all metrics.
Further decorators will be added as required for common figures, including bar charts, and non-parity scatter plots.
While not essential, we can also make use of the @pytest.fixture decorator,
which allows the value returned by a function to be used directly as a parameter
for other functions.
If your benchmark contains structures to be visualised, or images to be loaded, these
should be saved to ml_peg/app/data/[category]/[benchmark_name], as they must
be added as assets to be loaded into the app.
All data needed by the app should be produced during analysis. App imports should not read directly
from calculation outputs or perform scientific analysis. Reuse the existing plot_from_* and
struct_from_* callback helpers where possible. Keep plot values, hover labels, filenames, and
structures in the same deterministic order. Ensure a clicked model trace displays that model’s
structure when geometries are model-dependent.
Absolute paths to ml_peg/app and ml_peg/calcs can be imported for
convenience.
Similarly to running calculations, we use imports from ml_peg.models to get the
model names that analysis will be run for. By default, this means all model names
defined in ml_peg/models/models.yml will be used, but when using pytest or our
CLI (ml_peg analyse), a subset can be used using the --models option.
In order to facilitate element filtering, it is also essential that the elemental compositions of the systems involved in the benchmark are saved during analysis. Please refer to element filtering for more details.
Note
Some imports are not included in the following example for simplicity.
from ml_peg.analysis.utils.decorators import build_table, plot_parity
from ml_peg.analysis.utils.utils import mae
from ml_peg.app import APP_ROOT
from ml_peg.calcs import CALCS_ROOT
from ml_peg.models.get_models import get_model_names
from ml_peg.models import current_models
MODELS = get_model_names(current_models)
CALC_PATH = CALCS_ROOT / [category] / [benchmark_name] / "outputs"
OUT_PATH = APP_ROOT / "data" / [category] / [benchmark_name]
REF_VALUES = {"path_b": 0.27, "path_c": 2.5}
INFO = get_struct_info(
calc_path=CALC_PATH,
glob_pattern="structs.xyz",
index=":",
write_info=True,
info_keys=["label"],
write_structs=True,
out_path=OUT_PATH,
include_filenames=True,
)
@pytest.fixture
@plot_parity(
filename=OUT_PATH / "figure_energies.json",
title="Relative energies",
x_label="Predicted energy / eV",
y_label="Reference energy / eV",
hoverdata={
"Labels": INFO["label"],
},
)
def energies() -> dict[str, list]:
"""
Get energies for all structures.
Returns
-------
dict[str, list]
Dictionary of all reference and predicted relative energies.
"""
results = {"ref": []} | {mlip: [] for mlip in MODELS}
for model_name in MODELS:
output_file = CALC_PATH / model_name / "structs.xyz"
# Skip models that were not run
if not output_file.exists():
continue
structs = read(output_file, index=":")
results[model_name] = [struct.get_potential_energy() for struct in structs]
# Store reference once, from whichever model has outputs
if not results["ref"]:
results["ref"] = [struct.info["ref_energy"] for struct in structs]
# Write structures for app
structs_dir = OUT_PATH / model_name
structs_dir.mkdir(parents=True, exist_ok=True)
write(structs_dir / "structs.xyz", structs)
# Keep missing models in the results as NaN
for model_name in MODELS:
if not results[model_name]:
results[model_name] = [np.nan] * len(results["ref"])
return results
@pytest.fixture
def metric_1(energies: dict[str, list]) -> dict[str, float]:
"""
Get metric 1.
Parameters
----------
energies
Reference and predicted energies for all structures.
Returns
-------
dict[str, float]
Dictionary of metric 1 values for each model.
"""
results = {}
for model_name in MODELS:
results[model_name] = mae(energies["ref"], energies[model_name])
return results
@pytest.fixture
def metric_2() -> dict[str, float]:
"""
Get metric 2.
Returns
-------
dict[str, float]
Dictionary of metric 2 values for each model.
"""
results = {}
for model_name in MODELS:
structs = read(CALC_PATH / model_name / "structs.xyz", index=":")
results[model_name] = mae(
pred_properties, [struct.info["property"] for struct in structs]
)
return results
@pytest.fixture
@build_table(
filename=OUT_PATH / "new_benchmark_metrics_table.json",
metric_tooltips={
"Model": "Name of the model",
"Metric 1": "Description for metric 1 (units)",
"Metric 2": "Description for metric 2 (units)",
},
)
def metrics(
metric_1: dict[str, float], metric_2: dict[str, float]
) -> dict[str, dict]:
"""
Get all new benchmark metrics.
Parameters
----------
metric_1
Metric 1 value for all models.
metric_2
Metric 2 value for all models.
Returns
-------
dict[str, dict]
Metric names and values for all models.
"""
return {
"Metric 1": metric_1,
"Metric 2": metric_2,
}
def test_new_benchmark(metrics: dict[str, dict]) -> None:
"""
Run new benchmark analysis.
Parameters
----------
metrics
All new benchmark metric names and dictionary of values for each model.
"""
return
Build Dash components¶
Any tables and figures to be added to the app should have been created and saved by running the test defined in Analysing Calculations.
The final step is to assemble these, by defining a layout, and set up any required
interactivity, by defining callback functions, for the Dash application.
Building those components and their interactivity should become increasingly automated, but less standard plots/interactions will need setting up.
For now, please contact us to help with this process.