Bring Your Own Model
MAPLE is not limited to its shipped backends (UMA, ANI, AIMNet2, MACE). You can plug in your own machine-learning potential — trained in your group, exported as a TorchScript .pt/.jpt or an ordinary PyTorch .pt/.model checkpoint — without editing MAPLE's source code. A custom backend is just a small Python class that exposes a few methods; once registered, it works with every task (single point, optimization, frequency, TS search, IRC, scan, MD).
Before you start
This is the hands-on tutorial. The reference contract (full capability matrix, every class attribute, plugin-discovery internals) lives on the Calculator Backends page. A complete, copy-runnable example ships in the repository at examples/calculator_plugin/.
How it works
Two header keys do all the wiring:
module=— the import path of your plugin file/package. MAPLE imports it, which runs the@register_calculatordecorator and adds your model name to the registry.model_path=— the checkpoint file MAPLE validates (it must exist) and hands to your class. Your code decides how to load it — this is where your.pt/.jpt/.modelcomes in.
#model=mymodel(module=my_lab.maple_plugin, model_path=/abs/path/model.jpt)
#sp
#device=cpu
Step 1 — Write the backend class
A backend inherits CalcABC and declares its capabilities as class attributes. You fill in three things: (a) the capability attributes, (b) how to load your model in __init__, and (c) the forward pass in _forward. Everything else — unit conversion, implicit-solvent hook, results bookkeeping, the analytic-Hessian loop — is handled for you.
import numpy as np
import torch
from ase.calculators.calculator import all_changes
from maple.function.calculator.calculator_base import (
CalcABC, EV2HARTREE, hessian_via_double_autograd, register_calculator,
)
@register_calculator
class MyCalculator(CalcABC):
# (a) Capability declaration, read by the factory before construction
MODEL_NAMES = ('mymodel',) # input-header routing keys, lowercase
MODEL_ENERGY_UNIT = 'eV' # 'eV' or 'hartree' -- declare honestly
SUPPORTED_HESSIAN_MODES = ('analytic', 'numerical')
SUPPORTS_CHARGE_MULT = False
SUPPORTS_PBC = False # fail-fast on periodic atoms
CHECKPOINT_FILENAME = None # no HuggingFace auto-download
REQUIRES_LOCAL_MODEL_FILE = False
OPTION_KEYS = () # extra model_options keys you accept
MODEL_PATH_OPTION = 'model_path' # kwarg that consumes model_path
implemented_properties = ['energy', 'forces', 'free_energy', 'hessian']
@classmethod
def build_kwargs_from_options(cls, model, options, *, resolved_model_path=None):
# Route the factory-resolved checkpoint path into __init__ kwargs
kwargs = {}
if resolved_model_path is not None:
kwargs['model_path'] = resolved_model_path
return kwargs
def __init__(self, device, model='mymodel', *, model_path=None,
implicit='none', solvent='none', **_ignored):
super().__init__()
self.device = device
self.dtype = torch.float64
self.hessian = 'analytic'
# (b) Load YOUR model here -- see Step 2
self.model = load_my_model(model_path, device)
# Keep this hook (no-op unless implicit='gbsa')
self.implicit_solv_init(implicit=implicit, solvent=solvent)
# (c) Single private forward: atoms -> scalar energy (in MODEL_ENERGY_UNIT)
def _forward(self, atoms, *, requires_grad):
positions = torch.tensor(
atoms.get_positions(), dtype=self.dtype, device=self.device,
requires_grad=requires_grad,
)
numbers = torch.tensor(atoms.get_atomic_numbers(), dtype=torch.long, device=self.device)
energy_eV = self.model(numbers, positions) # YOUR forward signature
return energy_eV, positions
def calculate(self, atoms=None, properties=['energy'], system_changes=all_changes):
properties = self._normalize_properties(properties)
atoms = super().calculate(atoms, properties, system_changes) # PBC/solvent guards
needs_forces = 'forces' in properties
energy_eV, positions = self._forward(atoms, requires_grad=needs_forces)
forces_np = None
if needs_forces:
forces = -torch.autograd.grad(energy_eV, positions)[0]
forces_np = forces.detach().cpu().numpy()
hessian = None
if 'hessian' in properties:
hessian = self.get_hessian(atoms)
# _finalize_results owns eV->Hartree + solvent + writing self.results.
# Do NOT multiply by EV2HARTREE yourself in this path.
self._finalize_results(atoms, energy=energy_eV.item(), forces=forces_np, hessian=hessian)
def _analytic_hessian(self, atoms) -> np.ndarray:
# The Hessian path converts units itself: multiply by EV2HARTREE here.
energy_eV, positions = self._forward(atoms, requires_grad=True)
return hessian_via_double_autograd(lambda: energy_eV * EV2HARTREE, positions)
The unit rule
Declare MODEL_ENERGY_UNIT honestly and let _finalize_results convert the calculate() path to Hartree. Never multiply by EV2HARTREE yourself in calculate(). The Hessian path is the one exception — it converts internally, so you multiply by EV2HARTREE inside _analytic_hessian.
Step 2 — Load your model file
MAPLE hands you a validated, existing file path; how you turn it into a model object is entirely yours. The two common formats:
TorchScript (.pt / .jpt)
If your model was saved with torch.jit.script/trace, load it directly — this matches MAPLE's shipped ANI/MACE/AIMNet2 backends:
def load_my_model(model_path, device):
model = torch.jit.load(model_path, map_location=device).eval()
for p in model.parameters():
p.requires_grad_(False)
return model
Plain checkpoint / state_dict (.pt / .model)
For an ordinary (non-scripted) PyTorch model, rebuild the architecture and load weights:
def load_my_model(model_path, device):
ckpt = torch.load(model_path, map_location=device, weights_only=False)
net = MyNetwork(**ckpt['hparams']) # your nn.Module
net.load_state_dict(ckpt['state_dict'])
net = net.to(device).double().eval()
for p in net.parameters():
p.requires_grad_(False)
return net
Step 3 — Run it
Make your plugin importable (put its directory on PYTHONPATH or install it as a package), then point an input file at it. The example below mirrors examples/calculator_plugin/sp_jit.inp:
#model=mymodel(module=my_calculator_plugin, model_path=model.jpt)
#sp
#device=cpu
0 1
O 0.000 0.000 0.000
H 0.960 0.000 0.000
H -0.240 0.930 0.000
export PYTHONPATH="$PWD:$PYTHONPATH"
maple sp.inp # -> Energy: ... Hartree
The same backend now works for any task — just change the task header:
#model=mymodel(module=my_calculator_plugin, model_path=model.jpt)
#opt(method=lbfgs)
#device=gpu0
XYZ /path/to/molecule.xyz
Alternative: register process-wide
Instead of repeating module= in every input, register the plugin once via an environment variable. MAPLE imports each listed module at start-up:
export MAPLE_CALCULATOR_PLUGINS=my_calculator_plugin,other_lab.plugin
After this, #model=mymodel(model_path=...) resolves without the module= key.
Capability checklist
Set each attribute to what your model actually supports — MAPLE enforces them so misuse fails loudly instead of producing wrong numbers:
| Attribute | Set to |
|---|---|
MODEL_NAMES | Your lowercase header keyword(s), e.g. ('mymodel',) |
MODEL_ENERGY_UNIT | 'eV' or 'hartree' — your model's native energy unit |
SUPPORTS_PBC | True only if you build a validated periodic graph; otherwise False (periodic atoms fail fast) |
SUPPORTS_CHARGE_MULT | True if you honor atoms.info['charge'] / ['mult'] |
SUPPORTED_HESSIAN_MODES | ('analytic', 'numerical'), ('numerical',), etc. |
MODEL_PATH_OPTION | 'model_path' so an explicit checkpoint path is accepted |
Numerical Hessian for free
If your model cannot autodiff a Hessian, set SUPPORTED_HESSIAN_MODES = ('numerical',) and omit _analytic_hessian. CalcABC provides a finite-difference Hessian that only needs your calculate() forces. Frequency and P-RFO still work.
See also
- Runnable example:
examples/calculator_plugin/— two complete backends (TorchScript andtorch.load), a toy-model builder, and ready input files. - Calculator Backends — the full developer contract, capability matrix, and plugin-discovery layers.
maple/function/calculator/AUTHORING.mdandCALCULATOR_REVIEW.mdin the source tree.
