r"""Wrapper functions that make use of `torch.autograd`
Computation of forces, hessians, and vibrational frequencies.
"""
import math
import typing as tp
from torch import Tensor
import torch
from torchani.arch import ANI, ANIq
from torchani.potentials import Potential
from torchani.tuples import (
EnergiesForcesHessians,
EnergiesForces,
VibAnalysis,
ForcesHessians,
)
from torchani.units import mhessian2fconst, sqrt_mhessian2invcm, sqrt_mhessian2milliev
__all__ = [
"single_point",
"vibrational_analysis",
"forces_for_training",
# Convenience fn
"energies_and_forces",
"forces",
"grads",
"hessians",
"forces_and_hessians",
"energies_forces_and_hessians",
"calc_forces", # alias
"calc_grads", # alias
"calc_hessians", # alias
"calc_forces_and_hessians", # alias
]
Model = tp.Union[Potential, ANI, ANIq]
def forces(
energies: Tensor,
coordinates: Tensor,
retain_graph: tp.Optional[bool] = None,
create_graph: bool = False,
) -> Tensor:
if not coordinates.requires_grad:
raise ValueError(
"'coordinates' passed to `torchani.grad.forces` must require grad"
)
if not coordinates.is_leaf:
raise ValueError(
"'coordinates' passed to `torchani.grad` functions must be a 'leaf' Tensor"
"(i.e. must not have been modified prior to being used as an input)."
)
grads = torch.autograd.grad(
[energies.sum()],
[coordinates],
retain_graph=retain_graph,
create_graph=create_graph,
)[0]
assert grads is not None # JIT
return -grads
# Alias for convenience, just flips - sign
def grads(
scalars: Tensor,
coords: Tensor,
retain_graph: tp.Optional[bool] = None,
create_graph: bool = False,
) -> Tensor:
return -forces(scalars, coords, retain_graph, create_graph)
calc_forces = forces
calc_grads = grads
# Convenience function, for training create_graph=True and retain_graph=True
def forces_for_training(energies: Tensor, coordinates: Tensor) -> Tensor:
return forces(energies, coordinates, retain_graph=True, create_graph=True)
def forces_and_hessians(
energies: Tensor,
coordinates: Tensor,
retain_graph: tp.Optional[bool] = None,
) -> ForcesHessians:
_forces = forces(
energies,
coordinates,
retain_graph=True,
create_graph=True,
)
_hessians = hessians(
_forces,
coordinates,
retain_graph=retain_graph,
)
return ForcesHessians(_forces, _hessians)
calc_forces_and_hessians = forces_and_hessians
def hessians(
forces: Tensor,
coordinates: Tensor,
retain_graph: tp.Optional[bool] = None,
) -> Tensor:
if not coordinates.requires_grad:
raise ValueError(
"'coordinates' passed to `torchani.grad.hessians` must require grad"
)
if not coordinates.is_leaf:
raise ValueError(
"'coordinates' passed to `torchani.grad` functions must be a 'leaf' Tensor"
"(i.e. must not have been modified prior to being used as an input)."
)
num_molecules, num_atoms, num_dim = forces.shape
num_components = num_atoms * num_dim
flat_forces = forces.view(num_molecules, num_components)
# 3A Tensors, each of shape (C,)
flat_forces_tuple = flat_forces.unbind(dim=1)
result_list = []
_retain_graph: tp.Optional[bool] = True
for j, component in enumerate(flat_forces_tuple):
# For the last component we retain the graph only if instructed to
if j == (num_components - 1):
_retain_graph = retain_graph
_grads = torch.autograd.grad(
[component.sum()],
[coordinates],
retain_graph=_retain_graph,
)[0]
assert _grads is not None # JIT
_grads = _grads.view(num_molecules, 1, num_components) # shape (C, 1, 3A)
result_list.append(_grads)
_hessians = -torch.cat(result_list, dim=1) # shape (C, 3A, 3A)
return _hessians
calc_hessians = hessians
ModeKind = tp.Literal["mdu", "mdn", "mwn"]
UnitKind = tp.Literal["cm^-1", "meV"]
[docs]
def vibrational_analysis(
masses: Tensor,
hessian: Tensor,
mode_kind: ModeKind = "mdu",
unit: UnitKind = "cm^-1",
):
"""Computing the vibrational wavenumbers from hessian.
Note that normal modes in many popular software packages such as
Gaussian and ORCA are output as mass deweighted normalized (MDN).
Normal modes in ASE are output as mass deweighted unnormalized (MDU).
Some packages such as Psi4 let ychoose different normalizations.
Force constants and reduced masses are calculated as in Gaussian.
mode_kind should be one of:
- MWN (mass weighted normalized)
- MDU (mass deweighted unnormalized)
- MDN (mass deweighted normalized)
MDU modes are not orthogonal, and not normalized,
MDN modes are not orthogonal, and normalized.
MWN modes are orthonormal, but they correspond
to mass weighted cartesian coordinates (x' = sqrt(m)x).
Imaginary frequencies are output as negative numbers.
Very small negative or positive frequencies may correspond to
translational, and rotational modes.
"""
if unit == "meV":
converter = sqrt_mhessian2milliev
elif unit == "cm^-1":
converter = sqrt_mhessian2invcm
else:
raise ValueError("Only meV and cm^-1 are supported right now")
assert (
hessian.shape[0] == 1
), "Currently only supporting computing one molecule a time"
# Solving the eigenvalue problem: Hq = w^2 * T q
# where H is the Hessian matrix, q is the normal coordinates,
# T = diag(m1, m1, m1, m2, m2, m2, ....) is the mass
# We solve this eigenvalue problem through Lowdin diagnolization:
# Hq = w^2 * Tq ==> Hq = w^2 * T^(1/2) T^(1/2) q
# Letting q' = T^(1/2) q, we then have
# T^(-1/2) H T^(-1/2) q' = w^2 * q'
inv_sqrt_mass = (1 / masses.sqrt()).repeat_interleave(3, dim=1) # shape (C, 3 * A)
mass_scaled_hessian = (
hessian * inv_sqrt_mass.unsqueeze(1) * inv_sqrt_mass.unsqueeze(2)
)
if mass_scaled_hessian.shape[0] != 1:
raise ValueError("The input should contain only one molecule")
mass_scaled_hessian = mass_scaled_hessian.squeeze(0)
eigenvalues, eigenvectors = torch.linalg.eigh(mass_scaled_hessian)
signs = torch.sign(eigenvalues)
# Note that the normal modes are the COLUMNS of the eigenvectors matrix
mw_normalized = eigenvectors.t()
md_unnormalized = mw_normalized * inv_sqrt_mass
norm_factors = 1 / torch.linalg.norm(md_unnormalized, dim=1) # units are sqrt(AMU)
rmasses = norm_factors**2 # units are AMU
# The conversion factor for Ha/(AMU*A^2) to mDyne/(A*AMU) is about 4.3597482
fconstants = mhessian2fconst(eigenvalues) * rmasses # units are mDyne/A
if mode_kind.lower() in ["mdn", "mass-deweighted-normalized"]:
modes = (md_unnormalized * norm_factors.unsqueeze(1)).reshape(
eigenvalues.numel(), -1, 3
)
elif mode_kind.lower() in ["mdu", "mass-deweighted-unnormalized"]:
modes = (md_unnormalized).reshape(eigenvalues.numel(), -1, 3)
elif mode_kind.lower() in ["mwn", "mass-weighted-normalized"]:
modes = (mw_normalized).reshape(eigenvalues.numel(), -1, 3)
else:
raise ValueError(f"Incorrect mode kind {mode_kind}")
# Converting from sqrt(hartree / (amu * angstrom^2)) to cm^-1 or meV
angular_frequencies = eigenvalues.abs().sqrt()
frequencies = angular_frequencies / (2 * math.pi)
frequencies = frequencies * signs
frequencies = converter(frequencies)
return VibAnalysis(
frequencies,
modes,
fconstants,
rmasses,
)
def energies_forces_and_hessians(
model: Model,
species: Tensor,
coordinates: Tensor,
retain_graph: bool = False,
) -> EnergiesForcesHessians:
saved_requires_grad = coordinates.requires_grad
coordinates.requires_grad_(True)
energies, forces = energies_and_forces(
model,
species,
coordinates,
retain_graph=True,
create_graph=True,
)
_hessians = hessians(
forces,
coordinates,
retain_graph=retain_graph,
)
coordinates.requires_grad_(saved_requires_grad)
return EnergiesForcesHessians(energies, forces, _hessians)
def energies_and_forces(
model: Model,
species: Tensor,
coordinates: Tensor,
cell: tp.Optional[Tensor] = None,
pbc: tp.Optional[Tensor] = None,
retain_graph: tp.Optional[bool] = None,
create_graph: bool = False,
) -> EnergiesForces:
saved_requires_grad = coordinates.requires_grad
coordinates.requires_grad_(True)
if not coordinates.is_leaf:
raise ValueError(
"'coordinates' passed to `torchani.grad` functions must be a 'leaf' Tensor"
"(i.e. must not have been modified prior to being used as an input)."
)
if isinstance(model, Potential):
energies = model(species, coordinates, cell, pbc)
else:
energies = model((species, coordinates), cell, pbc).energies
_forces = forces(
energies,
coordinates,
retain_graph=retain_graph,
create_graph=create_graph,
)
coordinates.requires_grad_(saved_requires_grad)
return EnergiesForces(energies, _forces)
[docs]
def single_point(
model: tp.Union[ANI, ANIq],
species: Tensor,
coordinates: Tensor,
cell: tp.Optional[Tensor] = None,
pbc: tp.Optional[Tensor] = None,
charge: int = 0,
forces: bool = False,
hessians: bool = False,
atomic_energies: bool = False,
atomic_charges: bool = False,
atomic_charges_grad: bool = False,
ensemble_values: bool = False,
keep_vars: bool = False,
) -> tp.Dict[str, Tensor]:
r"""Calculate properties for a batch of molecules
This is the main entrypoint of ANI-style models
Args:
species: |atomic_nums|
coordinates: |coords|
cell: |cell|
pbc: |pbc|
charge: The total charge of the molecules. Only
the scalar 0 is currently supported.
forces: Calculate the associated forces. Shape ``(molecules, atoms, 3)``
hessians: Calculate the hessians. Shape is
``(molecules, atoms * 3, atoms * 3)``
atomic_energies: Perform atomic decoposition of the energies
atomic_charges: Only for models that support it, output atomic charges.
Shape ``(molecules, atoms)``
atomic_charges_grad: Only for models that support it, output atomic charge
gradients. Shape ``(molecules, atoms, 3)``.
ensemble_values: Differentiate values of different models of the ensemble
Also output ensemble standard deviation and qbc factors
keep_vars: The output scalars are detached from the graph unless
``keep_vars=True``.
Returns:
Result of the single point calculation. Dictionary that maps strings to
various result tensors.
"""
saved_requires_grad = coordinates.requires_grad
if forces or hessians or atomic_charges_grad:
coordinates.requires_grad_(True)
result = model(
species_coordinates=(species, coordinates),
cell=cell,
pbc=pbc,
charge=charge,
atomic=atomic_energies,
ensemble_values=ensemble_values,
)
energies = result.energies
out: tp.Dict[str, Tensor] = {}
if atomic_charges:
if not hasattr(result, "atomic_charges"):
raise ValueError("Model doesn't support atomic charges")
out["atomic_charges"] = result.atomic_charges
if atomic_charges_grad:
retain = forces or hessians
out["atomic_charges_grad"] = calc_grads(
result.atomic_charges, coordinates, retain_graph=retain
)
if ensemble_values:
if atomic_energies:
out["atomic_energies"] = energies.mean(dim=0)
_values = energies.sum(dim=-1)
else:
_values = energies
out["energies"] = _values.mean(dim=0)
if _values.shape[0] == 1:
out["ensemble_std"] = _values.new_zeros(energies.shape)
else:
out["ensemble_std"] = _values.std(dim=0, unbiased=True)
out["ensemble_values"] = _values
if _values.shape[0] == 1:
qbc_factors = _values.new_zeros(_values.shape).squeeze(0)
else:
# std is taken across ensemble members
qbc_factors = _values.std(0, unbiased=True)
# rho's (qbc factors) are weighted by dividing by the square root of
# the number of atoms in each molecule
num_atoms = (species >= 0).sum(dim=1, dtype=energies.dtype)
qbc_factors = qbc_factors / num_atoms.sqrt()
assert qbc_factors.shape == out["energies"].shape
out["qbcs"] = qbc_factors
else:
if atomic_energies:
out["energies"] = energies.sum(dim=-1)
out["atomic_energies"] = energies
else:
out["energies"] = energies
if hessians:
_forces, _hessians = calc_forces_and_hessians(out["energies"], coordinates)
out["forces"], out["hessians"] = _forces, _hessians
if forces:
out["forces"] = _forces
elif forces:
out["forces"] = calc_forces(out["energies"], coordinates)
coordinates.requires_grad_(saved_requires_grad)
if not keep_vars:
out = {k: v.detach() for k, v in out.items()}
return out