# Copyright 2024 Llamole Team # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import torch import torch.nn as nn import torch.nn.functional as F from torch_geometric.nn import global_add_pool, global_mean_pool, global_max_pool from torch_geometric.nn import MessagePassing import os import json from collections import defaultdict from rdchiral.main import rdchiralRunText import pandas as pd import numpy as np from rdkit import Chem from rdkit.Chem import AllChem def modulate(x, shift, scale): return x * (1 + scale) + shift class GraphPredictor(nn.Module): def __init__( self, num_layer, hidden_size, drop_ratio, out_dim, model_config, label_to_template, available=None, ): super().__init__() self.model_config = model_config self.text_input_size = model_config.get("text_input_size", 768) self.available = available self.text_drop = drop_ratio # Process label_to_template if isinstance(label_to_template, pd.DataFrame): self.label_to_template = dict( zip( label_to_template["rule_label"], label_to_template["retro_templates"], ) ) else: self.label_to_template = label_to_template self.predictor = GNNRetrosynthsizer( num_layer, hidden_size, self.text_input_size, drop_ratio, out_dim ) self.neural_cost = None def save_pretrained(self, output_dir): """ Save the predictor model, model_config, label_to_template, and available to the output directory. """ if not os.path.exists(output_dir): os.makedirs(output_dir) model_path = os.path.join(output_dir, "model.pt") config_path = os.path.join(output_dir, "model_config.json") label_to_template_path = os.path.join(output_dir, "label_to_template.csv.gz") available_path = os.path.join(output_dir, "available.csv.gz") # Save predictor model torch.save(self.predictor.state_dict(), model_path) # Save cost model if self.neural_cost is not None: neural_cost_path = os.path.join(output_dir, "cost_model.pt") torch.save(self.neural_cost.state_dict(), neural_cost_path) # Save model_config to JSON file with open(config_path, "w") as f: json.dump(self.model_config, f, indent=2) # Save label_to_template to gzipped CSV file label_to_template_df = pd.DataFrame( list(self.label_to_template.items()), columns=["rule_label", "retro_templates"], ) label_to_template_df.to_csv( label_to_template_path, index=False, compression="gzip" ) # Save available to gzipped CSV file if it's not None if self.available is not None: if isinstance(self.available, list): available_df = pd.DataFrame(self.available, columns=["smiles"]) elif isinstance(self.available, pd.DataFrame): available_df = self.available else: raise ValueError( "available must be either a list of SMILES strings or a pandas DataFrame" ) available_df.to_csv(available_path, index=False, compression="gzip") def disable_grads(self): """ Disable gradients for all parameters in the model. """ for param in self.predictor.parameters(): param.requires_grad = False def init_neural_cost(self, model_path, verbose=False): model_file = os.path.join(model_path, "cost_model.pt") if os.path.exists(model_file): self.neural_cost = CostMLP( n_layers=1, fp_dim=2048, latent_dim=128, dropout_rate=0.1 ) self.neural_cost.load_state_dict(torch.load(model_file, map_location="cpu", weights_only=True)) else: raise FileNotFoundError(f"Model file not found: {model_file}") for param in self.neural_cost.parameters(): param.requires_grad = False if verbose: print("Neural Cost Model initialized.") print("Neural Cost Model:\n", self.neural_cost) def init_model(self, model_path, verbose=False): model_file = os.path.join(model_path, "model.pt") if os.path.exists(model_file): self.predictor.load_state_dict(torch.load(model_file, map_location="cpu", weights_only=True)) else: raise FileNotFoundError(f"Model file not found: {model_file}") if verbose: print("GraphPredictor Model initialized.") print("Predictor model:\n", self.predictor) def forward(self, x, edge_index, edge_attr, batch, c): return self.predictor(x, edge_index, edge_attr, batch, c) def estimate_cost(self, smiles): if self.neural_cost is None: raise ValueError("Cost model is not initialized.") fp = self.neural_cost.smiles_to_fp(smiles) dtype, device = ( next(self.neural_cost.parameters()).dtype, next(self.neural_cost.parameters()).device, ) fp = torch.tensor(fp, dtype=dtype, device=device).view(1, -1) return self.neural_cost(fp).squeeze().item() def sample_templates(self, product_graph, c, product_smiles, topk=10): x, edge_index, edge_attr = ( product_graph.x, product_graph.edge_index, product_graph.edge_attr, ) batch = torch.zeros(x.size(0), dtype=torch.long, device=x.device) # Sample from main predictor logits_main = self.predictor(x, edge_index, edge_attr, batch, c) logits_drop = self.predictor(x, edge_index, edge_attr, batch, None) probs_main = logits_main + logits_drop * self.text_drop probs_main = F.softmax(logits_main, dim=1) topk_probs, topk_indices = torch.topk(probs_main, k=topk, dim=1) # Convert to numpy for easier handling topk_probs = topk_probs.float().cpu().numpy() topk_indices = topk_indices.cpu().numpy() # Get the corresponding templates templates = [] for idx in topk_indices[0]: templates.append(self.label_to_template[idx]) reactants_d = defaultdict(list) for prob, template in zip(topk_probs[0], templates): try: outcomes = rdchiralRunText(template, product_smiles) if len(outcomes) == 0: continue outcomes = sorted(outcomes) for reactant in outcomes: if "." in reactant: str_list = sorted(reactant.strip().split(".")) reactants_d[".".join(str_list)].append( (prob.item() / len(outcomes), template) ) else: reactants_d[reactant].append( (prob.item() / len(outcomes), template) ) except Exception: pass if len(reactants_d) == 0: return [], [], [] def merge(reactant_d): ret = [] for reactant, l in reactant_d.items(): ss, ts = zip(*l) ret.append((reactant, sum(ss), list(ts)[0])) reactants, scores, templates = zip( *sorted(ret, key=lambda item: item[1], reverse=True) ) return list(reactants), list(scores), list(templates) reactants, scores, templates = merge(reactants_d) total = sum(scores) scores = [s / total for s in scores] return reactants, scores, templates class GNNRetrosynthsizer(torch.nn.Module): def __init__(self, num_layer, hidden_size, text_input_size, drop_ratio, out_dim): super(GNNRetrosynthsizer, self).__init__() self.num_layer = num_layer self.drop_ratio = drop_ratio self.text_input_size = text_input_size if self.num_layer < 2: raise ValueError("Number of GNN layers must be greater than 1.") self.atom_encoder = nn.Embedding(118, hidden_size) ### set the initial virtual node embedding to 0. self.virtualnode_embedding = nn.Embedding(1, hidden_size) nn.init.constant_(self.virtualnode_embedding.weight.data, 0) ### List of GNNs self.convs = nn.ModuleList() self.norms = nn.ModuleList() self.adapters = nn.ModuleList() self.mlp_virtualnode_list = nn.ModuleList() self.text_dropping = nn.Embedding(1, text_input_size) for layer in range(num_layer): self.convs.append(GINConv(hidden_size, drop_ratio)) self.adapters.append( nn.Sequential( nn.SiLU(), nn.Linear(self.text_input_size, 3 * hidden_size, bias=True), ) ) self.norms.append(nn.LayerNorm(hidden_size, elementwise_affine=False)) if layer < num_layer - 1: self.mlp_virtualnode_list.append( nn.Sequential( nn.Linear(hidden_size, 4 * hidden_size), nn.LayerNorm(4 * hidden_size), nn.GELU(), nn.Dropout(drop_ratio), nn.Linear(4 * hidden_size, hidden_size), ) ) self.decoder = nn.Sequential( nn.Linear(hidden_size, 4 * hidden_size), nn.LayerNorm(4 * hidden_size), nn.GELU(), nn.Dropout(drop_ratio), nn.Linear(4 * hidden_size, out_dim), ) def initialize_weights(self): # Initialize transformer layers: def _basic_init(module): if isinstance(module, nn.Linear): torch.nn.init.xavier_uniform_(module.weight) if module.bias is not None: nn.init.constant_(module.bias, 0) def _constant_init(module, i): if isinstance(module, nn.Linear): nn.init.constant_(module.weight, i) if module.bias is not None: nn.init.constant_(module.bias, i) self.apply(_basic_init) for adapter in self.adapters: _constant_init(adapter[-1], 0) def disable_grads(self): """ Disable gradients for all parameters in the model. """ for param in self.parameters(): param.requires_grad = False def forward(self, x, edge_index, edge_attr, batch, c): ### virtual node embeddings for graphs virtualnode_embedding = self.virtualnode_embedding( torch.zeros(batch[-1].item() + 1).to(edge_index.dtype).to(edge_index.device) ) h_list = [self.atom_encoder(x)] if c is None: c = self.text_dropping.weight.expand(batch.max().item() + 1, -1) for layer in range(self.num_layer): ### add message from virtual nodes to graph nodes h_list[layer] = h_list[layer] + virtualnode_embedding[batch] shift, scale, gate = self.adapters[layer](c).chunk(3, dim=1) # B = batch.max().item() + 1 node_counts = torch.bincount(batch, minlength=batch.max().item() + 1) shift = shift.repeat_interleave(node_counts, dim=0) scale = scale.repeat_interleave(node_counts, dim=0) gate = gate.repeat_interleave(node_counts, dim=0) ### Message passing among graph nodes h = self.convs[layer](h_list[layer], edge_index, edge_attr) # h = self.norms[layer](h) h = modulate(self.norms[layer](h), shift, scale) if layer < self.num_layer - 1: h = F.gelu(h) h = F.dropout(h, self.drop_ratio, training=self.training) h = gate * h + h_list[layer] h_list.append(h) if layer < self.num_layer - 1: ### add message from graph nodes to virtual nodes virtual_pool = global_max_pool(h_list[layer], batch) virtualnode_embedding = virtualnode_embedding + F.dropout( self.mlp_virtualnode_list[layer](virtual_pool), self.drop_ratio, training=self.training, ) h_node = h_list[-1] h_graph = global_add_pool(h_node, batch) output = self.decoder(h_graph) return output class CostMLP(nn.Module): def __init__(self, n_layers, fp_dim, latent_dim, dropout_rate): super(CostMLP, self).__init__() self.n_layers = n_layers self.fp_dim = fp_dim self.latent_dim = latent_dim self.dropout_rate = dropout_rate layers = [] layers.append(nn.Linear(fp_dim, latent_dim)) layers.append(nn.ReLU()) layers.append(nn.Dropout(self.dropout_rate)) for _ in range(self.n_layers - 1): layers.append(nn.Linear(latent_dim, latent_dim)) layers.append(nn.ReLU()) layers.append(nn.Dropout(self.dropout_rate)) layers.append(nn.Linear(latent_dim, 1)) self.layers = nn.Sequential(*layers) def smiles_to_fp(self, smiles: str, fp_dim: int = 2048) -> np.ndarray: mol = Chem.MolFromSmiles(smiles) if mol is None: raise ValueError(f"Invalid SMILES string: {smiles}") fp = AllChem.GetMorganFingerprintAsBitVect(mol, 2, nBits=fp_dim) onbits = list(fp.GetOnBits()) arr = np.zeros(fp.GetNumBits(), dtype=bool) arr[onbits] = 1 return arr def forward(self, fps): x = fps x = self.layers(x) x = torch.log(1 + torch.exp(x)) return x class GINConv(MessagePassing): def __init__(self, hidden_size, drop_ratio): """ hidden_size (int) """ super(GINConv, self).__init__(aggr="add") self.mlp = nn.Sequential( nn.Linear(hidden_size, 4 * hidden_size), nn.LayerNorm(4 * hidden_size), nn.GELU(), nn.Dropout(drop_ratio), nn.Linear(4 * hidden_size, hidden_size), ) self.eps = torch.nn.Parameter(torch.Tensor([0])) self.bond_encoder = nn.Embedding(5, hidden_size) def forward(self, x, edge_index, edge_attr): edge_embedding = self.bond_encoder(edge_attr) out = self.mlp( (1 + self.eps) * x + self.propagate(edge_index, x=x, edge_attr=edge_embedding) ) return out def message(self, x_j, edge_attr): return F.gelu(x_j + edge_attr) def update(self, aggr_out): return aggr_out