""" Copyright (c) Meta, Inc. and its affiliates. This source code is licensed under the MIT license found in the LICENSE file in the root directory of this source tree. """ from __future__ import annotations import os import torch import torch.nn as nn from onescience.models.UMA.base import BackboneInterface, HeadInterface from onescience.models.esen.graph import GraphModelMixin from onescience.utils.uma.common.registry import registry from onescience.utils.uma.common.utils import conditional_grad from onescience.modules.layer.esen.common.rotation import ( init_edge_rot_mat, rotation_to_wigner, ) from onescience.modules.layer.esen.common.so3 import ( CoefficientMapping, SO3_Grid, ) from onescience.modules.layer.esen.esen_block import eSEN_Block from onescience.modules.layer.esen.nn.embedding import EdgeDegreeEmbedding from onescience.modules.layer.esen.nn.layer_norm import ( EquivariantLayerNormArray, EquivariantLayerNormArraySphericalHarmonics, EquivariantRMSNormArraySphericalHarmonicsV2, get_normalization_layer, ) from onescience.modules.layer.esen.nn.radial import EnvelopedBesselBasis, GaussianSmearing from onescience.modules.layer.esen.nn.so3_layers import SO3_Linear from onescience.modules.func_utils.esen_path_utils import resolve_esen_jd_path @registry.register_model("esen_backbone") class eSEN_Backbone(nn.Module, GraphModelMixin): def __init__( self, max_num_elements: int = 100, sphere_channels: int = 128, lmax: int = 2, mmax: int = 2, grid_resolution: int | None = None, otf_graph: bool = False, max_neighbors: int = 300, use_pbc: bool = True, use_pbc_single: bool = False, cutoff: float = 5.0, edge_channels: int = 128, distance_function: str = "gaussian", num_distance_basis: int = 512, direct_forces: bool = True, regress_forces: bool = True, regress_stress: bool = False, # escnmd specific num_layers: int = 2, hidden_channels: int = 128, norm_type: str = "rms_norm_sh", act_type: str = "s2", mlp_type: str = "grid", use_envelope: bool = False, activation_checkpointing: bool = False, jd_path: str | None = None, ): super().__init__() self.max_num_elements = max_num_elements self.lmax = lmax self.mmax = mmax self.sphere_channels = sphere_channels self.grid_resolution = grid_resolution self.regress_forces = regress_forces self.direct_forces = direct_forces self.regress_stress = regress_stress self.otf_graph = otf_graph self.max_neighbors = max_neighbors self.use_pbc = use_pbc self.use_pbc_single = use_pbc_single self.enforce_max_neighbors_strictly = False self.activation_checkpointing = activation_checkpointing self.mlp_type = mlp_type self.use_envelope = use_envelope # rotation utils Jd_list = torch.load(resolve_esen_jd_path(jd_path)) for l in range(self.lmax + 1): self.register_buffer(f"Jd_{l}", Jd_list[l]) self.sph_feature_size = int((self.lmax + 1) ** 2) self.mappingReduced = CoefficientMapping(self.lmax, self.mmax) # lmax_lmax for node, lmax_mmax for edge self.SO3_grid = nn.ModuleDict() self.SO3_grid["lmax_lmax"] = SO3_Grid( self.lmax, self.lmax, resolution=grid_resolution, rescale=True ) self.SO3_grid["lmax_mmax"] = SO3_Grid( self.lmax, self.mmax, resolution=grid_resolution, rescale=True ) # atom embedding self.sphere_embedding = nn.Embedding( self.max_num_elements, self.sphere_channels ) # edge distance embedding self.cutoff = cutoff self.edge_channels = edge_channels self.distance_function = distance_function self.num_distance_basis = num_distance_basis if self.distance_function == "gaussian": self.distance_expansion = GaussianSmearing( 0.0, self.cutoff, self.num_distance_basis, 2.0, ) elif self.distance_function == "bessel": self.distance_expansion = EnvelopedBesselBasis( num_radial=self.num_distance_basis, cutoff=cutoff, ) self.distance_expansion.offset = [self.cutoff] self.distance_expansion.num_output = self.num_distance_basis else: raise ValueError("Unknown distance function") # equivariant initial embedding self.source_embedding = nn.Embedding(self.max_num_elements, self.edge_channels) self.target_embedding = nn.Embedding(self.max_num_elements, self.edge_channels) nn.init.uniform_(self.source_embedding.weight.data, -0.001, 0.001) nn.init.uniform_(self.target_embedding.weight.data, -0.001, 0.001) self.edge_channels_list = [ self.num_distance_basis + 2 * self.edge_channels, self.edge_channels, self.edge_channels, ] self.edge_degree_embedding = EdgeDegreeEmbedding( sphere_channels=self.sphere_channels, lmax=self.lmax, mmax=self.mmax, max_num_elements=self.max_num_elements, edge_channels_list=self.edge_channels_list, rescale_factor=5.0, cutoff=self.cutoff, mappingReduced=self.mappingReduced, out_mask=self.SO3_grid["lmax_lmax"].mapping.coefficient_idx( self.lmax, self.mmax ), use_envelope=use_envelope, ) self.num_layers = num_layers self.hidden_channels = hidden_channels self.norm_type = norm_type self.act_type = act_type # Initialize the blocks for each layer self.blocks = nn.ModuleList() for _ in range(self.num_layers): block = eSEN_Block( self.sphere_channels, self.hidden_channels, self.lmax, self.mmax, self.mappingReduced, self.SO3_grid, self.edge_channels_list, self.cutoff, self.norm_type, self.act_type, self.mlp_type, self.use_envelope, ) self.blocks.append(block) self.norm = get_normalization_layer( self.norm_type, lmax=self.lmax, num_channels=self.sphere_channels, ) def get_rotmat_and_wigner(self, edge_distance_vecs): edge_rot_mat = init_edge_rot_mat( edge_distance_vecs, rot_clip=(not self.direct_forces) ) Jd_buffers = [ getattr(self, f"Jd_{l}").type(edge_rot_mat.dtype) for l in range(self.lmax + 1) ] wigner = rotation_to_wigner( edge_rot_mat, 0, self.lmax, Jd_buffers, rot_clip=(not self.direct_forces), ) wigner_inv = torch.transpose(wigner, 1, 2).contiguous() return edge_rot_mat, wigner, wigner_inv def generate_graph(self, *args, **kwargs): graph = super().generate_graph(*args, **kwargs) return { "edge_index": graph.edge_index, "edge_distance": graph.edge_distance, "edge_distance_vec": graph.edge_distance_vec, "cell_offsets": graph.cell_offsets, "offset_distances": None, "neighbors": None, "node_offset": 0, "batch_full": graph.batch_full, "atomic_numbers_full": graph.atomic_numbers_full, } @conditional_grad(torch.enable_grad()) def forward(self, data_dict) -> dict[str, torch.Tensor]: ############################################################### # gradient-based forces/stress ############################################################### data_dict["atomic_numbers"] = data_dict["atomic_numbers"].long() displacement = None orig_cell = None if self.regress_stress and not self.direct_forces: displacement = torch.zeros( (3, 3), dtype=data_dict["pos"].dtype, device=data_dict["pos"].device, ) # num_batch = data_dict["num_graphs"] num_batch = data_dict.get("num_graphs", len(data_dict["natoms"])) displacement = displacement.view(-1, 3, 3).expand(num_batch, 3, 3) displacement.requires_grad_(True) symmetric_displacement = 0.5 * ( displacement + displacement.transpose(-1, -2) ) data_dict["pos"].requires_grad_(True) data_dict["pos"] = data_dict["pos"] + torch.bmm( data_dict["pos"].unsqueeze(-2), torch.index_select(symmetric_displacement, 0, data_dict["batch"]), ).squeeze(-2) orig_cell = data_dict["cell"] data_dict["cell"] = data_dict["cell"] + torch.bmm( data_dict["cell"], symmetric_displacement ) if not self.regress_stress and self.regress_forces and not self.direct_forces: data_dict["pos"].requires_grad_(True) if self.otf_graph: graph_dict = self.generate_graph(data_dict) else: cell_per_edge = data_dict["cell"].repeat_interleave( data_dict["nedges"], dim=0 ) shifts = torch.einsum( "ij,ijk->ik", data_dict["cell_offsets"].to(cell_per_edge.dtype), cell_per_edge, ) edge_distance_vec = ( data_dict["pos"][data_dict["edge_index"][0]] - data_dict["pos"][data_dict["edge_index"][1]] + shifts ) # pylint: disable=E1102 edge_distance = torch.linalg.norm(edge_distance_vec, dim=-1, keepdim=False) graph_dict = { "atomic_numbers_full": data_dict["atomic_numbers_full"], "batch_full": data_dict["batch_full"], "edge_index": data_dict["edge_index"], "edge_distance": edge_distance, "edge_distance_vec": edge_distance_vec, "node_offset": 0, } _, wigner, wigner_inv = self.get_rotmat_and_wigner( graph_dict["edge_distance_vec"] ) ############################################################### # Initialize node embeddings ############################################################### x_message = torch.zeros( data_dict["pos"].shape[0], self.sph_feature_size, self.sphere_channels, device=data_dict["pos"].device, dtype=data_dict["pos"].dtype, ) x_message[:, 0, :] = self.sphere_embedding(data_dict["atomic_numbers"]) # edge degree embedding edge_distance_embedding = self.distance_expansion(graph_dict["edge_distance"]) source_embedding = self.source_embedding( data_dict["atomic_numbers"][graph_dict["edge_index"][0]] ) target_embedding = self.target_embedding( data_dict["atomic_numbers"][graph_dict["edge_index"][1]] ) x_edge = torch.cat( (edge_distance_embedding, source_embedding, target_embedding), dim=1 ) x_message = self.edge_degree_embedding( x_message, x_edge, graph_dict["edge_distance"], graph_dict["edge_index"], wigner_inv, ) ############################################################### # Update spherical node embeddings ############################################################### if graph_dict["edge_index"].shape[1] != 0: for i in range(self.num_layers): if self.activation_checkpointing: x_message = torch.utils.checkpoint.checkpoint( self.blocks[i], x_message, x_edge, graph_dict["edge_distance"], graph_dict["edge_index"], wigner, wigner_inv, graph_dict["node_offset"], use_reentrant=False, ) else: x_message = self.blocks[i]( x_message, x_edge, graph_dict["edge_distance"], graph_dict["edge_index"], wigner, wigner_inv, node_offset=graph_dict["node_offset"], ) # Final layer norm x_message = self.norm(x_message) out = { "node_embedding": x_message, "displacement": displacement, "orig_cell": orig_cell, } out.update(graph_dict) return out @property def num_params(self): return sum(p.numel() for p in self.parameters()) @torch.jit.ignore def no_weight_decay(self) -> set: no_wd_list = [] named_parameters_list = [name for name, _ in self.named_parameters()] for module_name, module in self.named_modules(): if isinstance( module, ( torch.nn.Linear, SO3_Linear, torch.nn.LayerNorm, EquivariantLayerNormArray, EquivariantLayerNormArraySphericalHarmonics, EquivariantRMSNormArraySphericalHarmonicsV2, ), ): for parameter_name, _ in module.named_parameters(): if ( isinstance(module, (torch.nn.Linear, SO3_Linear)) and "weight" in parameter_name ): continue global_parameter_name = module_name + "." + parameter_name assert global_parameter_name in named_parameters_list no_wd_list.append(global_parameter_name) return set(no_wd_list) @registry.register_model("esen_mlp_efs_head") class MLP_EFS_Head(nn.Module, HeadInterface): def __init__(self, backbone): super().__init__() backbone.energy_block = None backbone.force_block = None self.regress_stress = backbone.regress_stress self.regress_forces = backbone.regress_forces self.sphere_channels = backbone.sphere_channels self.hidden_channels = backbone.hidden_channels self.energy_block = nn.Sequential( nn.Linear(self.sphere_channels, self.hidden_channels, bias=True), nn.SiLU(), nn.Linear(self.hidden_channels, self.hidden_channels, bias=True), nn.SiLU(), nn.Linear(self.hidden_channels, 1, bias=True), ) backbone.direct_forces = False @conditional_grad(torch.enable_grad()) def forward(self, data, emb: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]: energy_key = "energy" forces_key = "forces" stress_key = "stress" outputs = {} node_energy = self.energy_block( emb["node_embedding"].narrow(1, 0, 1).squeeze() ).view(-1, 1, 1) energy = torch.zeros( len(data["natoms"]), device=data["pos"].device, dtype=node_energy.dtype ) energy.index_add_(0, data["batch"], node_energy.view(-1)) outputs[energy_key] = energy if self.regress_stress: grads = torch.autograd.grad( [energy.sum()], [data["pos"], emb["displacement"]], create_graph=self.training, ) forces = torch.neg(grads[0]) virial = grads[1].view(-1, 3, 3) volume = torch.det(data["cell"]).abs().unsqueeze(-1) stress = virial / volume.view(-1, 1, 1) virial = torch.neg(virial) outputs[forces_key] = forces outputs[stress_key] = stress.view(-1, 9) data["cell"] = emb["orig_cell"] elif self.regress_forces: forces = ( -1 * torch.autograd.grad( energy.sum(), data["pos"], create_graph=self.training )[0] ) outputs[forces_key] = forces return outputs @registry.register_model("esen_mlp_energy_head") class MLP_Energy_Head(nn.Module, HeadInterface): def __init__(self, backbone, reduce: str = "sum"): super().__init__() self.reduce = reduce self.sphere_channels = backbone.sphere_channels self.hidden_channels = backbone.hidden_channels self.energy_block = nn.Sequential( nn.Linear(self.sphere_channels, self.hidden_channels, bias=True), nn.SiLU(), nn.Linear(self.hidden_channels, self.hidden_channels, bias=True), nn.SiLU(), nn.Linear(self.hidden_channels, 1, bias=True), ) def forward(self, data_dict, emb: dict[str, torch.Tensor]): node_energy = self.energy_block( emb["node_embedding"].narrow(1, 0, 1).squeeze() ).view(-1, 1, 1) energy = torch.zeros( len(data_dict["natoms"]), device=node_energy.device, dtype=node_energy.dtype, ) energy.index_add_(0, data_dict["batch"], node_energy.view(-1)) if self.reduce == "sum": return {"energy": energy} elif self.reduce == "mean": return {"energy": energy / data_dict["natoms"]} else: raise ValueError( f"reduce can only be sum or mean, user provided: {self.reduce}" ) @registry.register_model("esen_linear_force_head") class Linear_Force_Head(nn.Module, HeadInterface): def __init__(self, backbone): super().__init__() self.linear = SO3_Linear(backbone.sphere_channels, 1, lmax=1) def forward(self, data_dict, emb: dict[str, torch.Tensor]): forces = self.linear(emb["node_embedding"].narrow(1, 0, 4)) forces = forces.narrow(1, 1, 3) forces = forces.view(-1, 3).contiguous() return {"forces": forces}