Skip to content

Commit bee96fe

Browse files
ppegoloclaude
andcommitted
Support conservative forces and stress ensembles
Force and stress ensembles are obtained as explicit positions/strain gradients of the model's "energy_ensemble" output, requested through MetatomicCalculator.run_model. The Jacobian machinery lives inside the model (metatrain's LLPRUncertaintyModel, so no engine-side autograd setup and no private metatomic attributes are needed; the ensemble output key is resolved from the model's own capabilities, following the same variant convention as the non-conservative output keys. - get_forces_ensemble / get_stress_ensemble, and the corresponding uncertainties as the standard deviation over ensemble members - get_forces_and_stress_ensemble: both from a single backward pass - forces are recentered per ensemble member (translational invariance) - the last computed ensembles are cached and reused for unchanged atoms Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> EOF )
1 parent fd90af1 commit bee96fe

2 files changed

Lines changed: 408 additions & 2 deletions

File tree

src/upet/calculator.py

Lines changed: 258 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
import torch
77
from ase import Atoms
88
from metatomic.torch import ModelOutput
9-
from metatomic_ase import MetatomicCalculator, SymmetrizedCalculator
9+
from metatomic_ase import (
10+
MetatomicCalculator,
11+
SymmetrizedCalculator,
12+
)
13+
from metatomic_ase._calculator import _full_3x3_to_voigt_6_stress
1014
from packaging.version import Version
1115

1216
from ._models import (
@@ -40,6 +44,17 @@
4044
}
4145

4246

47+
def _stress_ensemble_to_voigt(stress_ensemble: np.ndarray) -> np.ndarray:
48+
"""Convert a [3, 3, n_ensemble] stress ensemble to Voigt [6, n_ensemble]."""
49+
n_ensemble = stress_ensemble.shape[2]
50+
return np.array(
51+
[
52+
_full_3x3_to_voigt_6_stress(stress_ensemble[:, :, i])
53+
for i in range(n_ensemble)
54+
]
55+
).T
56+
57+
4358
class UPETCalculator(ase.calculators.calculator.Calculator):
4459
"""
4560
ASE Calculator for universal MLIPs based on the PET architecture.
@@ -174,6 +189,24 @@ def __init__(
174189
loaded_model._capabilities.dtype = DTYPE_TO_STR[dtype]
175190
loaded_model = loaded_model.to(dtype=dtype, device=device)
176191

192+
self._non_conservative = non_conservative
193+
selected_variant = None if variants is None else variants.get("energy")
194+
variant_postfix = f"/{selected_variant}" if selected_variant else ""
195+
self._variant_postfix = variant_postfix
196+
# resolved here from the model's own outputs (same variant convention as the
197+
# non-conservative keys above) so that no private MetatomicCalculator
198+
# attribute is needed to request the ensemble
199+
energy_ensemble_key = "energy_ensemble" + variant_postfix
200+
self._energy_ensemble_key: Optional[str] = (
201+
energy_ensemble_key if energy_ensemble_key in model_outputs else None
202+
)
203+
# cache of the last conservative forces/stress ensemble computation, as
204+
# (atoms, forces_ensemble, stress_ensemble); avoids recomputing the
205+
# expensive Jacobian pass when called again for the same atoms.
206+
self._uq_cache: Optional[
207+
Tuple[Atoms, Optional[np.ndarray], Optional[np.ndarray]]
208+
] = None
209+
177210
self.calculator = MetatomicCalculator(
178211
loaded_model,
179212
extensions_directory=None,
@@ -274,9 +307,232 @@ def get_energy_ensemble(
274307
:param per_atom: Whether to return the energies per atom.
275308
:return: Energy uncertainty in numpy.ndarray format.
276309
"""
277-
key = self.calculator._energy_uq_key.replace("_uncertainty", "_ensemble")
310+
key = self._energy_ensemble_key
311+
if key is None:
312+
raise NotImplementedError(
313+
"Energy ensemble is not available for the selected model. For "
314+
"uncertainty estimates, please use one of the following models: "
315+
f"{UPET_UQ_SUPPORTED_MODELS}"
316+
)
278317
return self._run_uq(atoms=atoms, per_atom=per_atom, key=key)
279318

319+
def _run_forces_stress_uq(
320+
self,
321+
atoms: Optional[Atoms] = None,
322+
compute_forces: bool = True,
323+
compute_stress: bool = True,
324+
) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]:
325+
"""
326+
Compute force and/or stress ensembles via gradients of the energy ensemble.
327+
328+
Returns a tuple (forces_ensemble, stress_ensemble) where each is None if not
329+
requested. Forces ensemble has shape [n_atoms, 3, n_ensemble], stress ensemble
330+
has shape [3, 3, n_ensemble].
331+
"""
332+
if not self.calculator._calculate_uncertainty:
333+
raise NotImplementedError(
334+
"Forces/stress uncertainty and ensemble are not available for the "
335+
"selected model. For uncertainty estimates, please use one of the "
336+
f"following models: {UPET_UQ_SUPPORTED_MODELS}"
337+
)
338+
339+
if not compute_forces and not compute_stress:
340+
raise ValueError(
341+
"At least one of compute_forces or compute_stress must be True."
342+
)
343+
344+
if atoms is None:
345+
if self.atoms is None:
346+
raise ValueError(
347+
"No `atoms` provided and no previously calculated atoms found."
348+
)
349+
else:
350+
atoms = self.atoms
351+
352+
cached = self._uq_cache
353+
if cached is not None and cached[0] == atoms:
354+
cached_forces, cached_stress = cached[1], cached[2]
355+
if (not compute_forces or cached_forces is not None) and (
356+
not compute_stress or cached_stress is not None
357+
):
358+
return (
359+
cached_forces if compute_forces else None,
360+
cached_stress if compute_stress else None,
361+
)
362+
363+
calc = self.calculator
364+
# Unwrap SymmetrizedCalculator if present
365+
if isinstance(calc, SymmetrizedCalculator):
366+
calc = calc._calculator
367+
368+
ensemble_key = self._energy_ensemble_key
369+
if ensemble_key is None:
370+
raise NotImplementedError(
371+
"Energy ensemble is not available for the selected model. For "
372+
"uncertainty estimates, please use one of the following models: "
373+
f"{UPET_UQ_SUPPORTED_MODELS}"
374+
)
375+
376+
explicit_gradients = []
377+
if compute_forces:
378+
explicit_gradients.append("positions")
379+
if compute_stress:
380+
explicit_gradients.append("strain")
381+
382+
outputs_request = {
383+
ensemble_key: ModelOutput(
384+
unit="eV", sample_kind="system", explicit_gradients=explicit_gradients
385+
)
386+
}
387+
if calc._energy_key is not None:
388+
# some models require the base "energy" output to be requested
389+
# alongside the ensemble; this is cheap to compute (it is an
390+
# intermediate step of the ensemble itself) and its value is unused here
391+
outputs_request[calc._energy_key] = ModelOutput(
392+
unit="eV", sample_kind="system"
393+
)
394+
395+
block = calc.run_model(atoms, outputs_request)[ensemble_key].block()
396+
397+
forces_ensemble = None
398+
stress_ensemble = None
399+
400+
if compute_forces:
401+
# gradient shape: [n_atoms, 3, n_ensemble]
402+
forces_ensemble = (
403+
-block.gradient("positions").values.detach().cpu().double().numpy()
404+
)
405+
# remove the mean over atoms for each ensemble member to impose
406+
# translational invariance
407+
forces_ensemble = forces_ensemble - np.mean(
408+
forces_ensemble, axis=0, keepdims=True
409+
)
410+
411+
if compute_stress:
412+
# gradient shape: [1, 3, 3, n_ensemble] (single system)
413+
# -> [3, 3, n_ensemble]
414+
stress_ensemble = (
415+
block.gradient("strain").values.detach().cpu().double().numpy()[0]
416+
/ atoms.cell.volume
417+
)
418+
419+
# keep whichever of forces/stress was already cached for these atoms but
420+
# not recomputed here, instead of discarding it
421+
if cached is not None and cached[0] == atoms:
422+
forces_ensemble = forces_ensemble if compute_forces else cached[1]
423+
stress_ensemble = stress_ensemble if compute_stress else cached[2]
424+
self._uq_cache = (atoms.copy(), forces_ensemble, stress_ensemble)
425+
return (
426+
forces_ensemble if compute_forces else None,
427+
stress_ensemble if compute_stress else None,
428+
)
429+
430+
def get_forces_ensemble(self, atoms: Optional[Atoms] = None) -> np.ndarray:
431+
"""
432+
Get the ensemble of forces for a given :py:class:`ase.Atoms` object.
433+
434+
Forces are computed as the (negative) derivative of the energy ensemble
435+
with respect to positions (conservative forces).
436+
437+
:param atoms: ASE atoms object. If ``None``, the last calculated atoms will be
438+
used.
439+
:return: Forces ensemble as numpy.ndarray with shape [n_atoms, 3, n_ensemble],
440+
in eV/Angstrom.
441+
"""
442+
forces_ensemble, _ = self._run_forces_stress_uq(
443+
atoms=atoms, compute_forces=True, compute_stress=False
444+
)
445+
return forces_ensemble
446+
447+
def get_forces_uncertainty(self, atoms: Optional[Atoms] = None) -> np.ndarray:
448+
"""
449+
Get the forces uncertainty for a given :py:class:`ase.Atoms` object.
450+
451+
Uncertainty is computed as the standard deviation of the conservative
452+
forces ensemble (derived from the energy ensemble).
453+
454+
:param atoms: ASE atoms object. If ``None``, the last calculated atoms will be
455+
used.
456+
:return: Forces uncertainty as numpy.ndarray with shape [n_atoms, 3],
457+
in eV/Angstrom.
458+
"""
459+
forces_ensemble = self.get_forces_ensemble(atoms=atoms)
460+
return np.std(forces_ensemble, axis=2)
461+
462+
def get_stress_ensemble(
463+
self, atoms: Optional[Atoms] = None, voigt: bool = True
464+
) -> np.ndarray:
465+
"""
466+
Get the ensemble of stresses for a given :py:class:`ase.Atoms` object.
467+
468+
Stresses are computed as the derivative of the energy ensemble with
469+
respect to strain (conservative stresses).
470+
471+
:param atoms: ASE atoms object. If ``None``, the last calculated atoms will be
472+
used.
473+
:param voigt: If ``True`` (default), return stresses in Voigt notation with
474+
shape [6, n_ensemble] (xx, yy, zz, yz, xz, xy). If ``False``, return
475+
full 3x3 tensors with shape [3, 3, n_ensemble].
476+
:return: Stress ensemble in eV/Angstrom^3.
477+
"""
478+
_, stress_ensemble = self._run_forces_stress_uq(
479+
atoms=atoms, compute_forces=False, compute_stress=True
480+
)
481+
if voigt:
482+
assert stress_ensemble is not None # for mypy
483+
stress_ensemble = _stress_ensemble_to_voigt(stress_ensemble)
484+
return stress_ensemble
485+
486+
def get_stress_uncertainty(
487+
self, atoms: Optional[Atoms] = None, voigt: bool = True
488+
) -> np.ndarray:
489+
"""
490+
Get the stress uncertainty for a given :py:class:`ase.Atoms` object.
491+
492+
Uncertainty is computed as the standard deviation of the stress ensemble
493+
(conservative stresses derived from the energy ensemble).
494+
495+
:param atoms: ASE atoms object. If ``None``, the last calculated atoms will be
496+
used.
497+
:param voigt: If ``True`` (default), return uncertainty in Voigt notation with
498+
shape [6] (xx, yy, zz, yz, xz, xy). If ``False``, return full 3x3 tensor
499+
with shape [3, 3].
500+
:return: Stress uncertainty in eV/Angstrom^3.
501+
"""
502+
stress_ensemble = self.get_stress_ensemble(atoms=atoms, voigt=voigt)
503+
return np.std(stress_ensemble, axis=-1)
504+
505+
def get_forces_and_stress_ensemble(
506+
self, atoms: Optional[Atoms] = None, voigt: bool = True
507+
) -> Tuple[np.ndarray, np.ndarray]:
508+
"""
509+
Get force and stress ensembles together (conservative method only).
510+
511+
Equivalent to calling :py:meth:`get_forces_ensemble` and
512+
:py:meth:`get_stress_ensemble` separately, but cheaper: both are
513+
derived from the same backward pass through the energy ensemble.
514+
515+
:param atoms: ASE atoms object. If ``None``, the last calculated atoms will be
516+
used.
517+
:param voigt: If ``True`` (default), return stress in Voigt notation with
518+
shape [6, n_ensemble]. If ``False``, return full 3x3 tensors with shape
519+
[3, 3, n_ensemble].
520+
:return: Tuple ``(forces_ensemble, stress_ensemble)``. Forces ensemble has
521+
shape [n_atoms, 3, n_ensemble], in eV/Angstrom.
522+
"""
523+
if self._non_conservative:
524+
raise ValueError(
525+
"get_forces_and_stress_ensemble is not available when the "
526+
"calculator was initialized with non_conservative=True."
527+
)
528+
forces_ensemble, stress_ensemble = self._run_forces_stress_uq(
529+
atoms=atoms, compute_forces=True, compute_stress=True
530+
)
531+
if voigt:
532+
assert stress_ensemble is not None # for mypy
533+
stress_ensemble = _stress_ensemble_to_voigt(stress_ensemble)
534+
return forces_ensemble, stress_ensemble
535+
280536

281537
# For PET-MAD-DOS predictions
282538
ENERGY_INTERVAL = 0.05 # Interval of the energy grid for DOS

0 commit comments

Comments
 (0)