Node encoder: periodic_table_embedding
Role
The node encoder produces the initial state \(\bm h_i^{(0)}\) of every
atom from its chemical species. Because the species is a label, the initial
state is invariant (0e channels only); directional information enters
later through edges.
Built-in members of this kind: species_irrep_embedding (one learned
vector per species, the MANDALA default), embedding and one_hot
followed by irrep_linear (DeepH-E3, MACE-H presets).
The new layer
A plain embedding table learns every element independently. Chemistry is not independent: elements in the same group share valence configurations and elements in the same period share core size. The periodic-table embedding factorizes the initial state accordingly,
In a multicomponent material such as ZnCu2Sn(SeS)2, S and Se share the group-16 vector and Zn and Cu share the period-4 vector, so information learned from one element transfers to its neighbours in the table. The species-specific table keeps the full flexibility of a plain embedding.
The species list is resolved from the training data and passed through the
recipe as elements: ${species.order}, so the layer always agrees with the
index convention of node_type_idx.
Symmetry. The output depends only on species labels: it is invariant under every element of E(3) and equivariant under permutations of atoms.
Implementation
def period_and_group(atomic_number: int) -> tuple[int, int]:
"""Period (1-7) and IUPAC group (1-18) of an element.
Lanthanides and actinides are assigned to group 3.
"""
period_ends = (2, 10, 18, 36, 54, 86, 118)
period = next(i + 1 for i, end in enumerate(period_ends) if atomic_number <= end)
position = atomic_number - (period_ends[period - 2] if period > 1 else 0)
if period == 1:
return period, 1 if position == 1 else 18
if period in (2, 3):
return period, position if position <= 2 else position + 10
if period in (4, 5):
return period, position
if position <= 2:
return period, position
if position <= 17:
return period, 3
return period, position - 14
class PeriodicTableEmbedding(nn.Module):
r"""``h_i = E_species[z_i] + E_period[period(z_i)] + E_group[group(z_i)]``.
Elements sharing a row or column of the periodic table share part of their
embedding, so chemically related species start from related features.
"""
def __init__(self, elements, width):
super().__init__()
from ase.data import atomic_numbers
rows = [period_and_group(atomic_numbers[symbol]) for symbol in elements]
self.register_buffer("period", torch.tensor([row[0] - 1 for row in rows]))
self.register_buffer("group", torch.tensor([row[1] - 1 for row in rows]))
self.species = nn.Embedding(len(elements), width)
self.periods = nn.Embedding(7, width)
self.groups = nn.Embedding(18, width)
for table in (self.species, self.periods, self.groups):
nn.init.normal_(table.weight, std=0.2)
def forward(self, species_index):
return (
self.species(species_index)
+ self.periods(self.period[species_index])
+ self.groups(self.group[species_index])
)
The lookup tables of period and group are buffers: they move with the model between devices and are stored in checkpoints, but are not trained.
@register_transform("periodic_table_embedding")
def _periodic_table_embedding(options, inputs, mapper):
(species,) = inputs
require_index(species, "node")
module = PeriodicTableEmbedding(**options)
width = options["width"]
return module, [FeatureSpec("node", irreps=f"{width}x0e", width=width)]
Usage
- id: node_encoder
role: encoder
transform: periodic_table_embedding
inputs: [node_type_idx]
outputs: [node_0]
options: {elements: '${species.order}', width: 32}
Variations
Add embeddings of tabulated invariant descriptors (electronegativity, covalent radius, valence-electron count) through a small MLP.
Encode the basis set of each species (its shell list
${species.shells}), which distinguishes the same element computed with different orbital sets.