Source code for xnn.dnn.models.physnet

"""PhysNet (Unke & Meuwly 2019): message-passing HDNN with explicit physics.

A faithful, self-contained PyTorch translation of the original TensorFlow
implementation (https://github.com/MMunibas/PhysNet,
``neural_network/NeuralNetwork.py`` and ``layers/``) on the xnn abstractions:
it subclasses :class:`~xnn.common.models.base.InteratomicPotential` directly
(PhysNet is a message-passing high-dimensional NN -- it has no hand-crafted
descriptor, so :class:`~xnn.dnn.models.base.DescriptorPotential` does not
apply) and gets forces/stress from the shared
:class:`~xnn.common.models.outputs.ForceStressOutput`. Given the same weights
it reproduces the original TensorFlow graph to machine precision
(``tests/test_physnet.py`` and the block-by-block notebook).

Architecture (paper eqs 3-15, J. Chem. Theory Comput. 15, 3678, 2019):

* nuclear charges are embedded into ``F``-vectors (a 95-row table indexed by
  ``Z`` directly -- all elements up to Pu, no species list needed; eq 3);
* distances are expanded in ``K`` radial basis functions
  ``g_k(r) = phi(r) exp(-beta_k (exp(-r) - mu_k)^2)`` with learnable centers
  and widths (softplus-reparametrized for positivity) and the smooth cutoff
  ``phi`` (eqs 7-8);
* ``num_blocks`` modules refine the features: an interaction layer computes
  the message ``v`` from gated features and the distance-based attention mask
  ``G g(r_ij)`` (eqs 5-6), followed by pre-activation residual blocks (eq 4);
* every module feeds an output block whose zero-initialized linear head
  predicts per-atom energy and partial-charge contributions; module outputs
  are summed and scaled/shifted per element (eqs 9-10);
* predicted charges are corrected to the exact total charge (eq 14) and enter
  a damped/switched Coulomb term (eqs 12-13 -- the code form: shielded
  ``1/sqrt(r^2+1)`` below ``sr_cut/2``, smoothstep-switched to ``1/r``, and
  force-shifted at ``lr_cutoff`` when one is set);
* Grimme D3(BJ) dispersion (:mod:`~xnn.common.models.d3`, an independent
  implementation verified against the upstream TF module, tables included)
  with optionally learnable ``s6/s8/a1/a2`` completes the total energy
  (eq 12).

Upstream conventions preserved: shifted-softplus activation, semi-orthogonal
Glorot weight init with zero biases, zero-initialized ``k2f``/output heads,
per-element scale/shift tables of length 95, ``kehalf`` Coulomb constant in
eV*Angstrom units, and the non-hierarchicality penalty returned as
``"nh_loss"``. Dropout (upstream ``keep_prob``, default 1.0 = off) is not
implemented.

The neighbor-list radius (``self.cutoff``) is ``lr_cutoff`` when set,
otherwise ``sr_cut``: radial-basis features vanish identically beyond
``sr_cut`` because of the ``phi`` envelope, so feeding the longer-range edge
list to the interaction blocks is mathematically identical to upstream's
separate short-range index list. Without ``lr_cutoff`` upstream evaluates
electrostatics/dispersion over *all* pairs; in xnn the pair list is the
graph's, so set ``lr_cutoff`` (or a large ``cutoff``) to capture long-range
terms explicitly.
"""
from __future__ import annotations

import math

import numpy as np
import torch
from torch import Tensor, nn
from torch.nn import functional as F

from xnn.common.data import AtomicGraph
from xnn.common.models.base import InteratomicPotential
from xnn.common.models.ops import scatter_sum, shifted_softplus
from xnn.common.models.registry import register_model
from ...common.models import d3

MAX_Z = 95  # element-indexed tables cover Z = 0..94 (through Pu)
KEHALF = 7.199822675975274  # ke/2 in eV*A/e^2; halved since edges come in pairs


[docs] def softplus_inverse(x): """Return ``y`` such that ``softplus(y) = x``. Evaluated as ``x + log(1 - exp(-x))`` (i.e. ``log(expm1(x))`` rearranged so the exponential never overflows for large ``x``). """ return x + np.log(-np.expm1(-x))
# PhysNet's activation ``log(exp(x) + 1) - log(2)`` is the shared exact # shifted softplus (matches TF's softplus bit-for-bit); re-exported here so # ``from xnn.dnn.models.physnet import shifted_softplus`` keeps working.
[docs] def semi_orthogonal_glorot_weights(n_in: int, n_out: int, scale: float = 2.0) -> Tensor: """Random (semi-)orthogonal weights rescaled to Glorot variance. Port of upstream ``layers/util.py``: a random orthogonal matrix (QR of a standard-normal matrix) cropped to ``(n_in, n_out)`` and rescaled so its entries have variance ``scale / (n_in + n_out)``. Returns ------- Tensor Weight matrix of shape ``(n_in, n_out)`` in the default dtype. """ dim = max(n_in, n_out) q, r = torch.linalg.qr(torch.randn(dim, dim, dtype=torch.float64)) q = q * torch.sign(torch.diagonal(r)) # uniform over the orthogonal group w = q[:n_in, :n_out] w = w * torch.sqrt(scale / ((n_in + n_out) * w.var())) return w.to(torch.get_default_dtype())
class _Dense(nn.Module): """Upstream ``DenseLayer``: linear with semi-orthogonal Glorot init, zero bias, and an optional activation applied after.""" def __init__(self, n_in: int, n_out: int, activation: bool = False, use_bias: bool = True, zero_init: bool = False): super().__init__() if zero_init: weight = torch.zeros(n_in, n_out) else: weight = semi_orthogonal_glorot_weights(n_in, n_out) self.weight = nn.Parameter(weight) # (n_in, n_out), upstream layout self.bias = nn.Parameter(torch.zeros(n_out)) if use_bias else None self.activation = activation def forward(self, x: Tensor) -> Tensor: y = x @ self.weight if self.bias is not None: y = y + self.bias if self.activation: y = shifted_softplus(y) return y class _Residual(nn.Module): """Pre-activation residual block ``x + W2 act(W1 act(x) + b1) + b2`` (paper eq 4, upstream ``ResidualLayer``).""" def __init__(self, n_features: int): super().__init__() self.dense = _Dense(n_features, n_features, activation=True) self.residual = _Dense(n_features, n_features, activation=False) def forward(self, x: Tensor) -> Tensor: return x + self.residual(self.dense(shifted_softplus(x))) class _RBF(nn.Module): """Radial basis ``g_k(r) = phi(r) exp(-beta_k (exp(-r) - mu_k)^2)`` (paper eqs 7-8, upstream ``RBFLayer``). Centers ``mu_k`` (equally spaced on ``[exp(-cutoff), 1]``) and the shared width are stored pre-softplus so positivity is guaranteed while training. """ def __init__(self, n_rbf: int, cutoff: float): super().__init__() self.n_rbf = n_rbf self.cutoff = cutoff # initialization convention: the (post-softplus) centers tile # [exp(-cutoff), 1], the range exp(-r) sweeps on [0, cutoff], on a # uniform grid; all basis functions start from one shared width # beta = 1/(2 delta)^2 with delta = (1 - exp(-cutoff)) / n_rbf, so # neighboring Gaussians overlap at about half height grid = np.linspace(1.0, np.exp(-cutoff), n_rbf) delta = (1.0 - np.exp(-cutoff)) / n_rbf self.centers = nn.Parameter(torch.as_tensor( softplus_inverse(grid), dtype=torch.get_default_dtype())) self.widths = nn.Parameter(torch.full( (n_rbf,), float(softplus_inverse((0.5 / delta) ** 2)), dtype=torch.get_default_dtype())) def cutoff_fn(self, r: Tensor) -> Tensor: """Smooth cutoff ``phi(r) = 1 - 6x^5 + 15x^4 - 10x^3`` (paper eq 8).""" x = r / self.cutoff env = 1 - 6 * x ** 5 + 15 * x ** 4 - 10 * x ** 3 return torch.where(x < 1, env, torch.zeros_like(x)) def forward(self, r: Tensor) -> Tensor: """Expand distances ``(E,)`` into ``(E, n_rbf)``.""" r = r.unsqueeze(-1) g = torch.exp(-F.softplus(self.widths) * (torch.exp(-r) - F.softplus(self.centers)) ** 2) return self.cutoff_fn(r) * g class _InteractionLayer(nn.Module): """The message computation (paper eqs 5-6, upstream ``InteractionLayer``). ``x' = u * x + W act(m)``, where the proto-message sums the transformed central-atom features and the attention-masked neighbor features ``m~ = act(W_I act(x_i) + b_I) + sum_j G g(r_ij) * act(W_J act(x_j) + b_J)`` and is refined by residual blocks. ``G`` (``k2f``) is zero-initialized so messages initially see only the central atom. """ def __init__(self, n_rbf: int, n_features: int, num_residual: int): super().__init__() self.k2f = _Dense(n_rbf, n_features, use_bias=False, zero_init=True) self.dense_i = _Dense(n_features, n_features, activation=True) self.dense_j = _Dense(n_features, n_features, activation=True) self.residuals = nn.ModuleList( [_Residual(n_features) for _ in range(num_residual)]) self.dense = _Dense(n_features, n_features) self.u = nn.Parameter(torch.ones(n_features)) def forward(self, x: Tensor, rbf: Tensor, idx_i: Tensor, idx_j: Tensor) -> Tensor: xa = shifted_softplus(x) g = self.k2f(rbf) m = self.dense_i(xa) + scatter_sum( g * self.dense_j(xa)[idx_j], idx_i, x.shape[0]) for residual in self.residuals: m = residual(m) return self.u * x + self.dense(shifted_softplus(m)) class _InteractionBlock(nn.Module): """Interaction layer + atom-wise residual refinements (paper fig 1B/C).""" def __init__(self, n_rbf: int, n_features: int, num_residual_atomic: int, num_residual_interaction: int): super().__init__() self.interaction = _InteractionLayer(n_rbf, n_features, num_residual_interaction) self.residuals = nn.ModuleList( [_Residual(n_features) for _ in range(num_residual_atomic)]) def forward(self, x: Tensor, rbf: Tensor, idx_i: Tensor, idx_j: Tensor) -> Tensor: x = self.interaction(x, rbf, idx_i, idx_j) for residual in self.residuals: x = residual(x) return x class _OutputBlock(nn.Module): """Residual refinements + zero-initialized linear head predicting the per-atom ``(energy, charge)`` contribution of one module (paper eq 9).""" def __init__(self, n_features: int, num_residual: int): super().__init__() self.residuals = nn.ModuleList( [_Residual(n_features) for _ in range(num_residual)]) self.dense = _Dense(n_features, 2, use_bias=False, zero_init=True) def forward(self, x: Tensor) -> Tensor: for residual in self.residuals: x = residual(x) return self.dense(shifted_softplus(x))
[docs] @register_model("physnet") class PhysNet(InteratomicPotential): """Faithful PhysNet (Unke & Meuwly 2019): energies, forces, and charges. See the module docstring for the architecture walk-through. All options are read from ``ModelConfig.extra`` (see :meth:`from_config`); upstream ``train.py`` spellings are translated by :mod:`xnn.common.config.translate`. Parameters ---------- cutoff : float, optional Short-range cutoff ``sr_cut`` of the neural-network interactions and the radial basis, by default 10.0 (the paper's value). lr_cutoff : float or None, optional Long-range cutoff for the electrostatic/dispersion terms (upstream ``lr_cut``); the Coulomb term is force-shifted so energy and forces vanish smoothly there. ``None`` (default) evaluates the long-range terms un-damped on the graph's edge list. n_features : int, optional Feature-space width ``F``, by default 128. n_rbf : int, optional Number of radial basis functions ``K``, by default 64. num_blocks : int, optional Number of stacked module blocks, by default 5 (the paper; the upstream code default is 3). num_residual_atomic : int, optional Residual blocks for atom-wise refinements, by default 2. num_residual_interaction : int, optional Residual blocks refining the proto-message, by default 3 (the paper; the upstream code default is 2). num_residual_output : int, optional Residual blocks in the output blocks, by default 1. use_electrostatics : bool, optional Add the switched/shielded Coulomb energy of the predicted partial charges (paper eqs 12-13), by default ``True``. use_dispersion : bool, optional Add Grimme D3(BJ) dispersion, by default ``True``. s6, s8, a1, a2 : float or None, optional D3(BJ) parameters. ``None`` (default) makes them learnable (softplus-reparametrized, initialized to the HF values), a number fixes them. d3_references : str, optional D3 reference systems: ``"2010"`` (default; Grimme's original tables, as in upstream PhysNet) or ``"2024"`` (the current ``simple-dftd3`` references, which re-parametrize Fr-Pu). Identical for Z <= 86; see :func:`xnn.common.models.d3.legacy_c6_table`. energy_shift, energy_scale : float, optional Initial value of the per-element energy shift/scale tables (upstream ``Eshift``/``Escale``), by default 0 and 1. charge_shift, charge_scale : float, optional Initial value of the per-element charge shift/scale tables, by default 0 and 1. species : list of int or None, optional Only used to interpret ``atomic_energies``/``atomic_scales``; the model itself handles all elements up to Z = 94. atomic_energies : array-like or None, optional Per-species reference energies loaded into ``Eshift`` (aligned with ``species``), like upstream's dataset-regression initialization. atomic_scales : array-like or None, optional Per-species initial ``Escale`` values (aligned with ``species``). Notes ----- ``forward`` additionally returns ``"charges"`` (corrected partial charges, summing exactly to the total charge -- 0 unless the graph carries a ``total_charge`` attribute), ``"dipole"`` (eq 15) and ``"nh_loss"`` (the non-hierarchicality penalty, paper eq 18/19) for training-loop use. """ def __init__( self, cutoff: float = 10.0, lr_cutoff: float | None = None, n_features: int = 128, n_rbf: int = 64, num_blocks: int = 5, num_residual_atomic: int = 2, num_residual_interaction: int = 3, num_residual_output: int = 1, use_electrostatics: bool = True, use_dispersion: bool = True, s6: float | None = None, s8: float | None = None, a1: float | None = None, a2: float | None = None, d3_references: str = "2010", energy_shift: float = 0.0, energy_scale: float = 1.0, charge_shift: float = 0.0, charge_scale: float = 1.0, species=None, atomic_energies=None, atomic_scales=None, ): super().__init__() if num_blocks < 1: raise ValueError("num_blocks must be >= 1") self.sr_cut = cutoff self.lr_cut = lr_cutoff # neighbor-list radius: long-range cutoff when set (rbf features # vanish beyond sr_cut anyway, see module docstring) self.cutoff = lr_cutoff if lr_cutoff is not None else cutoff self.n_features = n_features self.node_feature_dim = n_features # invariant features (for e.g. LES) self.use_electrostatics = use_electrostatics self.use_dispersion = use_dispersion self.kehalf = KEHALF self.embeddings = nn.Parameter( torch.empty(MAX_Z, n_features).uniform_(-math.sqrt(3), math.sqrt(3))) self.rbf_layer = _RBF(n_rbf, cutoff) self.interaction_blocks = nn.ModuleList([ _InteractionBlock(n_rbf, n_features, num_residual_atomic, num_residual_interaction) for _ in range(num_blocks)]) self.output_blocks = nn.ModuleList([ _OutputBlock(n_features, num_residual_output) for _ in range(num_blocks)]) self.Eshift = nn.Parameter(torch.full((MAX_Z,), float(energy_shift))) self.Escale = nn.Parameter(torch.full((MAX_Z,), float(energy_scale))) self.Qshift = nn.Parameter(torch.full((MAX_Z,), float(charge_shift))) self.Qscale = nn.Parameter(torch.full((MAX_Z,), float(charge_scale))) # D3 parameters: learnable through a softplus unless fixed for name, value, default in [("s6", s6, d3.d3_s6), ("s8", s8, d3.d3_s8), ("a1", a1, d3.d3_a1), ("a2", a2, d3.d3_a2)]: if value is None: setattr(self, f"_{name}", nn.Parameter(torch.tensor( float(softplus_inverse(default))))) else: self.register_buffer(f"_{name}", torch.tensor(float(value))) setattr(self, f"_{name}_learnable", value is None) if use_dispersion: dt = torch.get_default_dtype() self.register_buffer("_d3_c6ab", d3.legacy_c6_table(str(d3_references)).to(dt), persistent=False) self.register_buffer("_d3_rcov", d3.d3_rcov.to(dt), persistent=False) self.register_buffer("_d3_r2r4", d3.d3_r2r4.to(dt), persistent=False) if atomic_energies is not None or atomic_scales is not None: if species is None: raise ValueError( "species is required to map atomic_energies/atomic_scales") with torch.no_grad(): if atomic_energies is not None: ae = torch.as_tensor(atomic_energies, dtype=self.Eshift.dtype) self.Eshift[torch.tensor(list(species))] = ae if atomic_scales is not None: sc = torch.as_tensor(atomic_scales, dtype=self.Escale.dtype) self.Escale[torch.tensor(list(species))] = sc # -- D3 parameters (softplus-positive when learnable, as upstream) ------ def _d3_param(self, name: str) -> Tensor: raw = getattr(self, f"_{name}") return F.softplus(raw) if getattr(self, f"_{name}_learnable") else raw @property def s6(self) -> Tensor: """Effective D3 ``s6`` coefficient.""" return self._d3_param("s6") @property def s8(self) -> Tensor: """Effective D3 ``s8`` coefficient.""" return self._d3_param("s8") @property def a1(self) -> Tensor: """Effective D3 ``a1`` coefficient.""" return self._d3_param("a1") @property def a2(self) -> Tensor: """Effective D3 ``a2`` coefficient.""" return self._d3_param("a2")
[docs] def atomic_properties(self, atomic_numbers: Tensor, edge_index: Tensor, edge_vec: Tensor): """Scaled atomic energies/charges before the long-range terms. Parameters ---------- atomic_numbers : Tensor Per-atom atomic numbers, shape ``(N,)``. edge_index : Tensor Edge index ``(2, E)``; row 0 is the neighbor ``j``, row 1 the center ``i`` (upstream ``idx_j``/``idx_i``). edge_vec : Tensor Edge vectors ``pos[i] - pos[j]``, shape ``(E, 3)``. Returns ------- tuple of Tensor Per-atom energies ``(N,)``, raw (uncorrected) per-atom charges ``(N,)``, edge distances ``(E,)``, the scalar non-hierarchicality penalty, and the final per-atom feature vectors ``(N, n_features)``. """ idx_j, idx_i = edge_index[0], edge_index[1] Dij = edge_vec.norm(dim=-1) rbf = self.rbf_layer(Dij) x = self.embeddings[atomic_numbers] # every module contributes an additive (energy, charge) pair per atom energy = x.new_zeros(x.shape[0]) charge = x.new_zeros(x.shape[0]) head_sq = [] for interaction, head in zip(self.interaction_blocks, self.output_blocks): x = interaction(x, rbf, idx_i, idx_j) eq = head(x) # (N, 2): energy column 0, charge column 1 energy = energy + eq[:, 0] charge = charge + eq[:, 1] head_sq.append(eq ** 2) # non-hierarchicality penalty (paper eqs 18/19): push every module to # contribute less than its predecessor; the small constant keeps the # ratio finite when both contributions vanish (zero-init heads) nh_loss = x.new_zeros(()) for prev_sq, cur_sq in zip(head_sq, head_sq[1:]): nh_loss = nh_loss + torch.mean(cur_sq / (cur_sq + prev_sq + 1e-7)) energy = self.Escale[atomic_numbers] * energy + self.Eshift[atomic_numbers] charge = self.Qscale[atomic_numbers] * charge + self.Qshift[atomic_numbers] return energy, charge, Dij, nh_loss, x
[docs] def scaled_charges(self, Qa: Tensor, batch: Tensor, num_graphs: int, total_charge: Tensor | None = None) -> Tensor: """Correct the raw charges to the exact total charge (paper eq 14).""" n_per = torch.bincount(batch, minlength=num_graphs).to(Qa.dtype) if total_charge is None: total_charge = torch.zeros(num_graphs, dtype=Qa.dtype, device=Qa.device) q_sum = scatter_sum(Qa, batch, num_graphs) return Qa + ((total_charge - q_sum) / n_per)[batch]
def _switch(self, Dij: Tensor) -> Tensor: """Weight of the bare Coulomb kernel: quintic smoothstep in the distance, rising from 0 at ``r = 0`` to exactly 1 at ``sr_cut / 2`` and beyond (where electrostatics are purely ``1/r``).""" half = self.sr_cut / 2 y = Dij / half ramp = y ** 3 * (y * (6.0 * y - 15.0) + 10.0) return torch.where(Dij < half, ramp, torch.ones_like(Dij))
[docs] def electrostatic_energy_per_atom(self, Dij: Tensor, Qa: Tensor, idx_i: Tensor, idx_j: Tensor) -> Tensor: """Switched, shielded Coulomb energy per atom (paper eqs 12-13). At short range the divergent ``1/r`` is traded for the bounded ``1/sqrt(r^2 + 1)``; :meth:`_switch` blends the two so the kernel is smooth everywhere. When ``lr_cut`` is set, both kernels are force-shifted (value and slope zero at the cutoff) and pairs beyond it are dropped. """ q_pair = self.kehalf * Qa[idx_i] * Qa[idx_j] r_bound = torch.sqrt(Dij * Dij + 1.0) w = self._switch(Dij) w_bar = 1.0 - w if self.lr_cut is None: kernel_bare = 1.0 / Dij kernel_bound = 1.0 / r_bound e_pair = q_pair * (w_bar * kernel_bound + w * kernel_bare) else: rc = self.lr_cut rc_sq = rc * rc kernel_bare = 1.0 / Dij + Dij / rc_sq - 2.0 / rc kernel_bound = 1.0 / r_bound + r_bound / rc_sq - 2.0 / rc e_pair = q_pair * (w_bar * kernel_bound + w * kernel_bare) e_pair = torch.where(Dij <= rc, e_pair, torch.zeros_like(e_pair)) return scatter_sum(e_pair, idx_i, Qa.shape[0])
[docs] def dispersion_energy_per_atom(self, atomic_numbers: Tensor, Dij: Tensor, idx_i: Tensor, idx_j: Tensor) -> Tensor: """Grimme D3(BJ) dispersion per atom, in eV (paper eq 12).""" cutoff = self.lr_cut / d3.d3_autoang if self.lr_cut is not None else None return d3.d3_autoev * d3.edisp( atomic_numbers, Dij / d3.d3_autoang, idx_i, idx_j, cutoff=cutoff, s6=self.s6, s8=self.s8, a1=self.a1, a2=self.a2, c6ab=self._d3_c6ab, rcov=self._d3_rcov, r2r4=self._d3_r2r4)
[docs] def forward(self, data: AtomicGraph) -> dict[str, Tensor]: """Predict energies, corrected charges, and the dipole for a graph. Parameters ---------- data : xnn.common.data.AtomicGraph The input atomic graph. If it carries a ``total_charge`` attribute (per-structure tensor), charges are corrected to it; otherwise neutral structures are assumed. Returns ------- dict of str to torch.Tensor ``"node_energy"`` ``(N,)`` and ``"energy"`` ``(B,)`` as for every xnn model, plus ``"charges"`` ``(N,)`` (corrected partial charges), ``"dipole"`` ``(B, 3)`` (paper eq 15) and ``"nh_loss"`` (scalar regularization term). """ idx_j, idx_i = data.edge_index[0], data.edge_index[1] Ea, Qa, Dij, nh_loss, features = self.atomic_properties( data.atomic_numbers, data.edge_index, data.edge_vectors()) Qa = self.scaled_charges(Qa, data.batch, data.num_graphs, getattr(data, "total_charge", None)) if self.use_electrostatics: Ea = Ea + self.electrostatic_energy_per_atom(Dij, Qa, idx_i, idx_j) if self.use_dispersion: Ea = Ea + self.dispersion_energy_per_atom( data.atomic_numbers, Dij, idx_i, idx_j) dipole = scatter_sum(Qa.unsqueeze(-1) * data.pos, data.batch, data.num_graphs) return {"node_energy": Ea, "energy": self.aggregate_energy(Ea, data), "charges": Qa, "dipole": dipole, "nh_loss": nh_loss, "node_features": features}
[docs] @classmethod def from_config(cls, cfg) -> "PhysNet": """Construct a :class:`PhysNet` from a core model config. Core fields: ``cfg.cutoff`` -> ``sr_cut``, ``cfg.n_features`` -> ``F``, ``cfg.n_rbf`` -> ``K``, ``cfg.n_interactions`` -> ``num_blocks``. Everything else is read from ``cfg.extra``; upstream ``train.py`` spellings are translated by :mod:`xnn.common.config.translate`. Parameters ---------- cfg : xnn.common.config.schema.ModelConfig The core model config. Returns ------- PhysNet The instantiated model. """ from xnn.common.config.coerce import coerce_per_species, coerce_species extra = dict(cfg.extra or {}) species = (coerce_species(extra.get("species")) if extra.get("species") is not None else None) lr_cutoff = extra.get("lr_cutoff") def opt_float(key): v = extra.get(key) return None if v is None else float(v) return cls( cutoff=cfg.cutoff, lr_cutoff=None if lr_cutoff is None else float(lr_cutoff), n_features=cfg.n_features, n_rbf=cfg.n_rbf, num_blocks=cfg.n_interactions, num_residual_atomic=int(extra.get("num_residual_atomic", 2)), num_residual_interaction=int( extra.get("num_residual_interaction", 3)), num_residual_output=int(extra.get("num_residual_output", 1)), use_electrostatics=bool(extra.get("use_electrostatics", True)), use_dispersion=bool(extra.get("use_dispersion", True)), s6=opt_float("s6"), s8=opt_float("s8"), a1=opt_float("a1"), a2=opt_float("a2"), d3_references=str(extra.get("d3_references", "2010")), energy_shift=float(extra.get("energy_shift", 0.0)), energy_scale=float(extra.get("energy_scale", 1.0)), charge_shift=float(extra.get("charge_shift", 0.0)), charge_scale=float(extra.get("charge_scale", 1.0)), species=species, atomic_energies=coerce_per_species( extra.get("atomic_energies"), species or [], "atomic_energies"), atomic_scales=coerce_per_species( extra.get("atomic_scales"), species or [], "atomic_scales"), )