pet-jax
A clean JAX/Flax reimplementation of the uPET (Point-Edge Transformer) family of interatomic potentials, targeting numerical equivalence with the upstream metatrain PyTorch implementation while being efficient enough for production inference and geometry optimisation.
pet-jax loads metatrain checkpoints (e.g. PET-MAD), runs them on JAX, and exposes an ASE calculator (UPETCalculator) suitable as a drop-in replacement for the PyTorch reference in molecular dynamics, relaxations, and downstream property calculations.
What's in the box
src/petjax/
__init__.py # public API
model.py # UPET (Flax nn.Module)
calculator.py # UPETCalculator (ASE)
convert.py # metatrain .ckpt → Flax msgpack
cli.py # petjax-convert entrypoint
structure.py # host-side neighborlist build
select.py # in-JIT adaptive selection
predict.py # in-JIT forward + autodiff
utils.py # shared helpers
Installation
pet-jax needs Python ≥ 3.10. Install it from PyPI:
pip install pet-jax
This pulls in the inference stack (jax, flax, numpy, ase, vesin, marathon-train) — everything needed to load a checkpoint and run the calculator.
Converting upstream metatrain .ckpt files needs the optional convert extra (torch, metatomic-torch, metatrain), which is not required for inference:
pip install "pet-jax[convert]"
pet-jax is experimental and under active development, and releases lag behind main. For the latest state, install from GitHub:
pip install "git+https://github.com/lab-cosmo/pet-jax"
pip install "pet-jax[convert] @ git+https://github.com/lab-cosmo/pet-jax" # with the convert extra
Or from a checkout, which is the easiest way to also get the examples and tests:
git clone https://github.com/lab-cosmo/pet-jax
cd pet-jax
pip install -e ".[convert]" # drop [convert] for an inference-only install
Any environment manager works (pip, uv, conda, ...); the examples below use plain console scripts, and the uv + tox development workflow is in Development (uv sync --extra convert for the editable install).
Quick start
Fetch a PET-MAD checkpoint
petjax-convert pet-mad-xs --out checkpoints/pet-mad-xs
This downloads the current PET-MAD release (v1.6.0) from Hugging Face (lab-cosmo/upet) and converts it directly to pet-jax's Flax msgpack layout — no TorchScript intermediate. Pass --version for an older release (--version 1.5.0). The convert extra pulls in torch, metatomic-torch, and metatrain (needed only for conversion; not for inference).
petjax-convert also accepts arbitrary URLs or local .ckpt paths:
petjax-convert https://example.com/my-pet.ckpt --out checkpoints/my-pet
petjax-convert ~/runs/best.ckpt --out checkpoints/my-pet
Inference on a single structure
from ase.io import read
from petjax import UPETCalculator
calc = UPETCalculator.from_checkpoint("checkpoints/pet-mad-xs", stress=True)
atoms = read("my_structure.xyz")
atoms.calc = calc
energy = atoms.get_potential_energy() # eV
forces = atoms.get_forces() # eV/Å
stress = atoms.get_stress() # voigt, eV/ų
Relaxations and so on can be done with the same calculator!
Architecture: how the calculator handles adaptive cutoffs
The PET model itself operates on a "rectangular"-style neighborlist, i.e., shaped as [n_atoms, n_neighbors], since its main operation is edge-to-edge attention which (naively) requires an intermediate [n_atoms, n_neighbors, n_neighbors] attention matrix.1
The universal PET models wrap an adaptive cutoff procedure around the model, which selects a per-atom cutoff such that only an approximately fixed number of neighbors are considered for each atom. This avoids spikes in memory usage for dense systems, improves batching efficiency for diverse dataset during training, and also lets the model predict dimer curves better. At a high level, the procedure works as follows:
- Compute an initial large neighborlist with a big cutoff,
- Determine a cutoff for each atom that fits approximately the target number of neighbors,
- Compute per-pair cutoffs as average of both per-atom cutoffs,
- Apply a per-pair boolean mask — keep pair
(i, j)iffr_ijis at mostpair_cutoff_ij.
Step 2 involves the two cutoff widths of the model, which are easy to confuse. To keep the procedure differentiable, neighbors are counted softly, with the usual smooth bump function: n_i(r) = sum_j bump(d_ij; r, cutoff_width_adaptive) + N_target * (r / cutoff)^3. The per-atom cutoff is the radius where this count crosses the target; the cubic baseline makes sure there is exactly one crossing. So cutoff_width_adaptive is the blur radius of the neighbor counting, and nothing else — the separate cutoff_width is used later, to taper edge features to zero at the selected pair cutoffs. The blur is not cosmetic: a blurrier count crosses the target further out and selects more neighbors, so the model has to be run with the width it was trained with. The crossing itself is found either on a probe grid with Gaussian weighting (adaptive_cutoff_method: grid) or by Newton-bisection root finding (solver), again matching the checkpoint.
The subtlety here is that since the cutoffs depend on the position of atoms outside the cutoff, gradients have to flow through the procedure to some extent and it therefore has to be done inside the model's forward pass. So we need to make the procedure compatible with jax, so we can jit it and transform it with grad. The constraints: No data-dependent shapes, and few shape changes to avoid costly recompilations. This is made more complicated by the fact that smoothness dictates that the procedure is not exact: We can only target a certain number of neighbors, but we can't guarantee it.
The solution to this is a two-phase design: Outside of jax, we compute the initial big neighborlist and determine the final number, k_sel, of neighbors. We can round k_sel to something larger to avoid recompiles. We also have to pad the initial big neighborlist to a fixed shape, but this is easy to achieve. Inside jax (i.e., the jax.jit boundary), we then re-run the procedure and pack into k_sel, which we already know ahead of time due to step one. If we exceed k_sel, we return an overflow signal to tell the calculator that k_sel needs to be recomputed.
For neighbor lists already trimmed to a fixed neighbor count upstream, pack_edges is the selection-free sibling of truncate_edges: it packs to the same fixed-width layout, skipping the adaptive-cutoff selection (pair_cutoffs=None).
select_edges runs the adaptive selection on a structure dict host-side and returns what it produced: per-atom and per-pair cutoffs, the selected-pair mask, and the selected count per atom. The calculator sizes k_sel from it; consumers that need the model's actual adjacency rather than the raw neighbor ball (the Hessian sparsity pattern, graph-distance analyses) read the mask.
For the design rationale and more details, see src/petjax/README.md.
Performance knobs
UPETCalculator exposes a few keyword arguments that trade off accuracy, memory, JIT recompile frequency, and per-step cost. Maximum efficiency requires tuning them for your particular problem.
default_dtype(default"float32"): set to"float64"for demanding relaxations or precision-sensitive comparisons. fp32 is ~2× faster; PET-MAD checkpoints ship fp32, so fp64 just promotes the cached params. For most inference fp32 is fine.matmul_precision(default"high"): JAX fp32 matmul accumulation precision on accelerators ("default"/"high"/"highest"); a no-op on CPU. The"default"mode uses reduced-precision (TF32-style) accumulation that noticeably degrades NVE energy conservation;"high"restores it at essentially no extra cost, while"highest"(full fp32) is slower when compute-bound. Set toNoneto leave JAX's global config untouched (for callers managing it themselves). Likedefault_dtype's fp64 switch, this mutates global JAX state.skin(default0.5Å): Verlet-skin radius. Larger skin → fewer raw-NL rebuilds (good for long MD with small step sizes) at the cost of more padded pairs per step. Shrink ifvesin/raw-NL build dominates; grow if you see frequent skin-triggered rebuilds.stress(defaultTrue): set toFalseto skip the strain-derivative virial. Drops onevalue_and_gradargument; very slightly faster, nostresskey inresults.no_shadow(defaultFalse): cut gradients through the adaptive-cutoff function. Slightly faster, slightly different forces (drops the "shadow" contribution from the cutoff procedure). Energy is unchanged.direct_forces/direct_stress(defaultFalse, passed viafrom_checkpoint): read forces/stress straight from the checkpoint's non-conservative heads instead of differentiating the energy — skips the autodiff pass entirely (no double-backward), so cheaper, especially together. Trades accuracy and energy-conservation for speed (the net force is removed to avoid drift); see Conventions.bucket_strategy(default"multiples") and axis-specific overridesn_atoms_bucket_strategy,n_pair_bucket_strategy,k_sel_bucket_strategy: how shapes are rounded up to bucket sizes. Coarser bucketing → fewer JIT recompiles but more padded compute; finer → tighter shapes but more recompiles when shapes drift. The default is a reasonable compromise.extra_neighbors(default4): slack added tok_sel_actualso a step or two of neighbour growth doesn't trigger an overflow rebuild. Increase if you see frequent overflow retries during MD; decrease to save padded edge work.num_neighbors_adaptive(defaultNone): override how many neighbours per atom the adaptive cutoff aims to select;Noneuses the value the model was trained with. Raising it lets the model see more neighbours per atom — accuracy tends to improve (models are robust to this) for a modest increase in per-step cost. If you also set a tightcutoff_override, the wider reach makes its warning below more likely to fire.cutoff_override(defaultNone): narrow thevesinraw-NL query radius below the trainedconfig["cutoff"]. ⚠ Correctness risk: must stay above the largest per-atom adaptive cutoff that selection ever reaches, otherwise surviving pairs are silently dropped. Only set this when you know your system's adaptive cutoffs don't approachconfig["cutoff"]. The calculator measures the actual reach each rebuild (debug_stats["max_selected_cutoff"]) and emits awarnings.warnif your override falls below it — use that to find a safe value.debug(defaultFalse): emit a per-rebuild summary tostderr(padding waste on each axis,k_sel_actual → k_sel_padded, the measuredmax_selected_cutoffvs the raw-NL/trained cutoff, and whetherpredict_fnwill retrace). Keyed to NL rebuilds and overflow events, not per MD step, so long trajectories stay quiet. The same numbers are always available oncalc.debug_statsregardless of this flag; the too-small-cutoff_overridewarning above also fires independently of it.
Conventions
- Energy is total energy (eV), not per-atom.
- Stress returned by the calculator is in ASE's Voigt convention (eV/ų); internally the virial is the strain derivative
dU/dεin eV. - Scaling/shifting: raw model output is multiplied by
energy_scaleinside JIT (at params dtype). Per-element composition shifts are added post-JIT on the calculator side in Python fp64; they contribute zero to forces. - Adaptive cutoff: per-atom, recomputed inside the autograd graph each step (required for force correctness). The selection runs at the checkpoint's
cutoff_width_adaptive— recentmetatrainversions split it off the final-tapercutoff_width; checkpoints converted before the split fall back to the shared value, matching upstream's own migration rule. Both upstream selection algorithms are implemented and picked via the checkpoint'sadaptive_cutoff_method:grid(probe grid + Gaussian weights, what pre-split checkpoints trained with) andsolver(Newton-bisection root find, the upstream default for new trainings; gradients attach via an implicit-function-theorem step). - Charge and spin conditioning: a checkpoint trained with
system_conditioningembeds the total charge and the spin multiplicity of each structure and adds them to the node features after every GNN layer, so the same geometry can give different predictions for different electronic states. The calculator reads them fromatoms.info["charge"]andatoms.info["spin_multiplicity"], the keysmetatrain's readers use; a missing key means neutral (0) or singlet (1), as upstream defaults, and a change in either invalidates the cached results like a change in positions would. Values must be integers within the checkpoint'smax_charge/max_spin_multiplicityrange, checked host-side. Unconditioned checkpoints ignore both keys. - Non-conservative forces/stress: PET-MAD checkpoints also carry direct force/stress heads. Pass
direct_forces=True/direct_stress=Truetofrom_checkpointto read forces/stress straight from those heads (skipping autodiff — cheaper, no double-backward) instead of differentiating the energy. The default stays conservative (autodiff). Both reproduce the metatrain reference to fp32; for forces the per-structure mean is subtracted to remove the spurious net force the direct head would otherwise leave (matching metatrain's calculator).
Checkpoint format
pet-jax checkpoints are a directory with two files:
<ckpt_dir>/
model.msgpack # Flax parameter tree (nested dict of arrays)
metadata.yaml # config (architecture hypers), shifts
Use UPETCalculator.from_checkpoint("<ckpt_dir>") to load. Conversion from the upstream metatrain .ckpt format goes through petjax-convert, which reads the checkpoint directly (no TorchScript intermediate). Both published layouts are accepted: bare PET checkpoints (the pet-omat / pet-omad / … lines) and LLPR-wrapped ones (the PET-MAD releases), with PET checkpoint versions 10 through 16 — the between-version differences (including the v15 charge/spin conditioning hypers) are absorbed during conversion, mirroring metatrain's own upgrade rules. Older versions are rejected (run mtt upgrade on the source); newer ones are rejected until pet-jax catches up.
Not everything upstream changes is versioned, though: PET-MAD v1.6 renamed the direct-force readout target from non_conservative_forces to non_conservative_force while leaving the checkpoint version alone. Conversion therefore dispatches readout weights on the head name rather than the version, accepts both spellings, and refuses (rather than guesses) a head name it does not know.
Validation
tests/test_predictions.py compares the calculator's output against saved metatrain reference predictions on the mini CI dataset (and the larger test_s/m/l datasets under --run-extended). The mini suite runs against every release in conftest.MINI_RELEASES — currently pet-mad-xs at v1.5 and v1.6, one checkpoint per readout-head naming scheme, and the charge/spin conditioned pet-omol-s, scored on a copy of the mini set with a (charge, multiplicity) pair in each frame's atoms.info — in both conservative and non-conservative mode. References are .npz arrays — energies, forces, stresses, and the per-structure atom counts that tie them to the dataset — never geometry, which lives in the dataset file. Regenerate them with tests/generate_references.py (see its docstring; needs metatrain >= 2026.4 to read a v16 checkpoint through metatrain's own model classes).
tests/test_calculator.py additionally covers:
- Shift plumbing: composition shifts are added exactly once, in fp64, with zero leakage into forces.
- no_shadow: optional cut of adaptive-cutoff gradients preserves energy and produces finite, slightly shifted forces.
- Position-only update: small geometry changes do not trigger a re-JIT.
- Cell relaxation: BFGS + FrechetCellFilter over 30 steps on a small periodic structure — energy doesn't increase, forces stay finite, NL rebuilds stay below half the step count (the whole point of the Verlet-skin + bucketed-shapes machinery).
Working with the upstream PyTorch implementation
To run the original metatrain/metatomic-torch code without polluting the pet-jax environment:
uv run --with metatrain --with metatomic-torch python ...
Useful for cross-checks against ground truth.
Development
The canonical task runner is tox (with the tox-uv plugin so environments are created via uv). Envs declared in tox.ini:
tox -e lint—ruff check+ruff format --checktox -e tests—pytestmini suite (pass--run-extendedafter--for the extended local suite)tox -e fetch-checkpoints— converts the reference checkpoints the tests need intotests/assets/checkpoints/(one-off; uses theconvertextra)
Bootstrap:
uv tool install --with tox-uv tox
tox -e fetch-checkpoints # first time only
tox -e lint
tox -e tests
# extended local suite (test_s, test_m, test_l × pet-mad-xs, pet-mad-s; needs
# extra assets + the pet-mad-s checkpoint, all gitignored)
tox -e tests -- --run-extended
Extended tests skip individually when their input files are missing.
For tight inner-loop work you can bypass tox:
uv sync # install core + dev
uv run pytest tests/ -x
uv run ruff check src/ tests/
uv run ruff format src/ tests/
Dependencies
Core (pyproject.toml):
jax,jaxlib,flax— neural net + autodiffnumpy,ase— data structuresvesin— fast neighbourlist constructionmarathon-train— checkpoint I/O (msgpack/yaml) and bucket-size helpers
Optional (the convert extra — see Installation):
torch,metatomic-torch,metatrain— only needed when convertingmetatrain.ckptfiles. Inference itself runs on the JAX stack alone.
Contributing
Code conventions (ruff config, naming patterns, file layout, JIT placement rule, Markdown soft-wrap, internal NL/attention conventions) and the architecture deep-dive live in src/petjax/README.md. Read it before submitting non-trivial PRs.
PRs that change anything user-visible add a line to the Unreleased section of CHANGELOG.md.
Status
Working: inference, forces, stress, BFGS/FIRE relaxations, cell optimization, metatrain checkpoint conversion, charge/spin conditioning, parity with upstream PET.
Not yet: training (use metatrain directly), batched multi-structure inference in the calculator (single-structure only), GPU performance tuning.
License
MIT. See LICENSE.
-
Internally, the neighborlist is stored in flattened
[n_atoms*n_neighbors]form, but this is not relevant here. ↩
Metadata
Release files for pet-jax 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pet_jax-0.1.0.tar.gz | 99.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pet_jax-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 148.4 kB
Release files / pet_jax-0.1.0.tar.gz
| Download URL | pet_jax-0.1.0.tar.gz |
|---|---|
| Size | 99.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
eba2564adcf7f0a4939acc5930059b563ab583f200c25c62420c36313507abf4
|
|
BLAKE2b-256 checksum How to use checksums |
8efe8416911be69752d224f77fe212c3d5af5c5381a89a1e92743a9458e823d1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency logRelease files / pet_jax-0.1.0-py3-none-any.whl
| Download URL | pet_jax-0.1.0-py3-none-any.whl |
|---|---|
| Size | 48.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3ac4bc7047cc589f98157100327e917d6d752b9733e027c936c6bf0491375138
|
|
BLAKE2b-256 checksum How to use checksums |
d000c392bf1df93cb0be5bcb17a08f68a01cf2294fa8c0674ebf1e04695518c9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 1, 2026.
Transparency log