Skip to content

Commit cb5e5c9

Browse files
committed
Support direct (non-conservative) forces ensemble and uncertainty
1 parent 306d523 commit cb5e5c9

2 files changed

Lines changed: 189 additions & 13 deletions

File tree

src/upet/calculator.py

Lines changed: 161 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ def __init__(
200200
self._energy_ensemble_key: Optional[str] = (
201201
energy_ensemble_key if energy_ensemble_key in model_outputs else None
202202
)
203+
self._direct_ensemble_key_BASE = "mtt::aux::non_conservative_forces_ensemble"
204+
self._direct_uncertainty_key_BASE = (
205+
"mtt::aux::non_conservative_forces_uncertainty"
206+
)
203207
# cache of the last conservative forces/stress ensemble computation, as
204208
# (atoms, forces_ensemble, stress_ensemble); avoids recomputing the
205209
# expensive Jacobian pass when called again for the same atoms.
@@ -427,36 +431,180 @@ def _run_forces_stress_uq(
427431
stress_ensemble if compute_stress else None,
428432
)
429433

430-
def get_forces_ensemble(self, atoms: Optional[Atoms] = None) -> np.ndarray:
434+
@property
435+
def _direct_ensemble_key(self) -> str:
436+
return self._direct_ensemble_key_BASE + self._variant_postfix
437+
438+
@property
439+
def _direct_uncertainty_key(self) -> str:
440+
return self._direct_uncertainty_key_BASE + self._variant_postfix
441+
442+
def _run_direct_forces_uq(self, atoms: Optional[Atoms] = None) -> np.ndarray:
431443
"""
432-
Get the ensemble of forces for a given :py:class:`ase.Atoms` object.
444+
Get the direct forces ensemble from the model.
445+
446+
:return: Forces ensemble as numpy.ndarray with shape [n_atoms, 3, n_ensemble].
447+
"""
448+
if atoms is None:
449+
if self.atoms is None:
450+
raise ValueError(
451+
"No `atoms` provided and no previously calculated atoms found."
452+
)
453+
else:
454+
atoms = self.atoms
455+
456+
calc = self.calculator
457+
if isinstance(calc, SymmetrizedCalculator):
458+
calc = calc._calculator
459+
460+
model_outputs = calc._model.capabilities().outputs
461+
if self._direct_ensemble_key not in model_outputs:
462+
raise NotImplementedError(
463+
f"Direct forces ensemble ({self._direct_ensemble_key}) is not "
464+
"available for the selected model."
465+
)
466+
467+
outputs = calc.run_model(
468+
atoms,
469+
outputs={
470+
self._direct_ensemble_key: ModelOutput(
471+
quantity="force", unit="eV/Angstrom", per_atom=True
472+
)
473+
},
474+
)
475+
476+
# shape: [n_atoms, 3 * n_ensemble] -> [n_atoms, 3, n_ensemble]
477+
values = (
478+
outputs[self._direct_ensemble_key].block().values.detach().cpu().numpy()
479+
)
480+
return values.reshape(len(atoms), 3, -1)
481+
482+
def _run_direct_forces_uncertainty(
483+
self, atoms: Optional[Atoms] = None
484+
) -> np.ndarray:
485+
"""
486+
Get the built-in direct forces uncertainty from the model.
487+
488+
:return: Forces uncertainty as numpy.ndarray with shape [n_atoms, 3].
489+
"""
490+
if atoms is None:
491+
if self.atoms is None:
492+
raise ValueError(
493+
"No `atoms` provided and no previously calculated atoms found."
494+
)
495+
else:
496+
atoms = self.atoms
433497

434-
Forces are computed as the (negative) derivative of the energy ensemble
435-
with respect to positions (conservative forces).
498+
calc = self.calculator
499+
if isinstance(calc, SymmetrizedCalculator):
500+
calc = calc._calculator
501+
502+
model_outputs = calc._model.capabilities().outputs
503+
if self._direct_uncertainty_key not in model_outputs:
504+
raise NotImplementedError(
505+
f"Direct forces uncertainty ({self._direct_uncertainty_key}) is not "
506+
"available for the selected model."
507+
)
508+
509+
outputs = calc.run_model(
510+
atoms,
511+
outputs={
512+
self._direct_uncertainty_key: ModelOutput(
513+
quantity="force", unit="eV/Angstrom", per_atom=True
514+
)
515+
},
516+
)
517+
518+
# shape: [n_atoms, 3, 1] -> [n_atoms, 3]
519+
values = (
520+
outputs[self._direct_uncertainty_key].block().values.detach().cpu().numpy()
521+
)
522+
return values.squeeze(-1)
523+
524+
def _resolve_forces_method(self, method: Optional[str]) -> str:
525+
"""Resolve the default forces method based on the calculator mode."""
526+
if method is None:
527+
return "direct" if self._non_conservative else "conservative"
528+
return method
529+
530+
def get_forces_ensemble(
531+
self, atoms: Optional[Atoms] = None, method: Optional[str] = None
532+
) -> np.ndarray:
533+
"""
534+
Get the ensemble of forces for a given :py:class:`ase.Atoms` object.
436535
437536
:param atoms: ASE atoms object. If ``None``, the last calculated atoms will be
438537
used.
538+
:param method: Method to compute the forces ensemble. One of:
539+
540+
- ``"conservative"`` (default when ``non_conservative=False``): forces
541+
derived from the energy ensemble via automatic differentiation.
542+
- ``"direct"`` (default when ``non_conservative=True``): forces ensemble
543+
predicted directly by the model. When the calculator
544+
is in conservative mode (``non_conservative=False``), the ensemble is
545+
shifted so its mean matches the conservative forces.
546+
439547
:return: Forces ensemble as numpy.ndarray with shape [n_atoms, 3, n_ensemble],
440548
in eV/Angstrom.
441549
"""
442-
forces_ensemble, _ = self._run_forces_stress_uq(
443-
atoms=atoms, compute_forces=True, compute_stress=False
444-
)
445-
return forces_ensemble
550+
method = self._resolve_forces_method(method)
551+
552+
if method == "conservative":
553+
if self._non_conservative:
554+
raise ValueError(
555+
"method='conservative' is not available when the calculator was "
556+
"initialized with non_conservative=True."
557+
)
558+
forces_ensemble, _ = self._run_forces_stress_uq(
559+
atoms=atoms, compute_forces=True, compute_stress=False
560+
)
561+
return forces_ensemble
446562

447-
def get_forces_uncertainty(self, atoms: Optional[Atoms] = None) -> np.ndarray:
563+
elif method == "direct":
564+
direct_ensemble = self._run_direct_forces_uq(atoms=atoms)
565+
566+
if not self._non_conservative:
567+
# Shift ensemble so its mean matches conservative forces
568+
if atoms is None:
569+
atoms = self.atoms
570+
atoms.calc = self
571+
conservative_forces = atoms.get_forces() # [n_atoms, 3]
572+
573+
direct_mean = np.mean(direct_ensemble, axis=2) # [n_atoms, 3]
574+
shift = conservative_forces - direct_mean # [n_atoms, 3]
575+
direct_ensemble = direct_ensemble + shift[:, :, np.newaxis]
576+
577+
return direct_ensemble
578+
579+
else:
580+
raise ValueError(
581+
f"Unknown method '{method}'. Must be one of: 'conservative', 'direct'."
582+
)
583+
584+
def get_forces_uncertainty(
585+
self, atoms: Optional[Atoms] = None, method: Optional[str] = None
586+
) -> np.ndarray:
448587
"""
449588
Get the forces uncertainty for a given :py:class:`ase.Atoms` object.
450589
451-
Uncertainty is computed as the standard deviation of the conservative
452-
forces ensemble (derived from the energy ensemble).
453-
454590
:param atoms: ASE atoms object. If ``None``, the last calculated atoms will be
455591
used.
592+
:param method: Method to compute the forces uncertainty. One of:
593+
594+
- ``"conservative"``: standard deviation of the conservative forces
595+
ensemble (derived from energy ensemble via autograd).
596+
- ``"direct"``: built-in uncertainty from the model
597+
(``mtt::aux::non_conservative_forces_uncertainty``).
598+
456599
:return: Forces uncertainty as numpy.ndarray with shape [n_atoms, 3],
457600
in eV/Angstrom.
458601
"""
459-
forces_ensemble = self.get_forces_ensemble(atoms=atoms)
602+
method = self._resolve_forces_method(method)
603+
604+
if method == "direct":
605+
return self._run_direct_forces_uncertainty(atoms=atoms)
606+
607+
forces_ensemble = self.get_forces_ensemble(atoms=atoms, method=method)
460608
return np.std(forces_ensemble, axis=2)
461609

462610
def get_stress_ensemble(

tests/upet/test_uncertainty_quantification.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,34 @@ def test_forces_ensemble_cache():
188188
assert not np.allclose(forces_ensemble_moved, forces_ensemble)
189189

190190

191+
def test_forces_method_defaults():
192+
"""Test that the default method depends on the non_conservative flag."""
193+
atoms = bulk("Si", cubic=True, a=5.43, crystalstructure="diamond")
194+
195+
# Conservative calculator defaults to method="conservative"
196+
calc_cons = UPETCalculator(model="pet-mad-s", version="1.5.0")
197+
forces_ensemble = calc_cons.get_forces_ensemble(atoms)
198+
assert forces_ensemble.ndim == 3
199+
assert forces_ensemble.shape[:2] == (len(atoms), 3)
200+
201+
# method="conservative" works explicitly
202+
forces_ensemble_2 = calc_cons.get_forces_ensemble(atoms, method="conservative")
203+
assert np.allclose(forces_ensemble, forces_ensemble_2, atol=1e-6)
204+
205+
# Non-conservative calculator: method="conservative" should raise
206+
calc_nc = UPETCalculator(model="pet-mad-s", version="1.5.0", non_conservative=True)
207+
with pytest.raises(ValueError, match="method='conservative' is not available"):
208+
calc_nc.get_forces_ensemble(atoms, method="conservative")
209+
210+
# method="direct" requires non_conservative_forces_ensemble in model
211+
with pytest.raises(NotImplementedError, match="non_conservative_forces_ensemble"):
212+
calc_cons.get_forces_ensemble(atoms, method="direct")
213+
214+
# Invalid method
215+
with pytest.raises(ValueError, match="Unknown method"):
216+
calc_cons.get_forces_ensemble(atoms, method="invalid")
217+
218+
191219
def test_error_model_not_evaluated():
192220
atoms = bulk("Si", cubic=True, a=5.43, crystalstructure="diamond")
193221
calc = UPETCalculator(

0 commit comments

Comments
 (0)