graph-energy-matching / src /gem /analysis /rdkit_functions.py
multimodalart's picture
multimodalart HF Staff
Initial GEM demo
6fe4366 verified
Raw History Blame Contribute Delete
16.4 kB
import numpy as np
import torch
import re
import warnings
import pandas as pd
from tqdm import tqdm
from rdkit import Chem
allowed_bonds = {
"H": 1,
"C": 4,
"N": 3,
"O": 2,
"F": 1,
"B": 3,
"Al": 3,
"Si": 4,
"P": [3, 5],
"S": 4,
"Cl": 1,
"As": 3,
"Br": 1,
"I": 1,
"Hg": [1, 2],
"Bi": [3, 5],
"Se": [2, 4, 6],
}
bond_dict = [
None,
Chem.rdchem.BondType.SINGLE,
Chem.rdchem.BondType.DOUBLE,
Chem.rdchem.BondType.TRIPLE,
Chem.rdchem.BondType.AROMATIC,
]
ATOM_VALENCY = {6: 4, 7: 3, 8: 2, 9: 1, 15: 3, 16: 2, 17: 1, 35: 1, 53: 1}
class BasicMolecularMetrics(object):
def __init__(self, dataset_info, train_smiles=None, args=None):
self.atom_decoder = dataset_info.atom_decoder
self.dataset_info = dataset_info
self.args = args
default_mode = "vfm_relaxed"
validity_mode = default_mode
if args is not None:
try:
validity_mode = str(
getattr(getattr(args, "general", object()), "validity_mode", default_mode)
).lower()
except AttributeError:
validity_mode = default_mode
if validity_mode not in {"vfm_relaxed", "strict"}:
warnings.warn(
f"Unknown validity_mode='{validity_mode}', falling back to '{default_mode}'.",
stacklevel=2,
)
validity_mode = default_mode
self.validity_mode = validity_mode
self.validity_labels = {
"vfm_relaxed": "validity_VFM_relaxed",
"strict": "validity_strict",
}
self.primary_validity_label = self.validity_labels[self.validity_mode]
self._secondary_mode = "strict" if self.validity_mode == "vfm_relaxed" else "vfm_relaxed"
self.secondary_validity_label = self.validity_labels[self._secondary_mode]
self.latest_validity_values = {"strict": None, "vfm_relaxed": None}
self.latest_validity_labels = {
"primary": self.primary_validity_label,
"secondary": self.secondary_validity_label,
}
self.dataset_smiles_list = train_smiles
def compute_validity(self, generated):
"""generated: list of couples (positions, atom_types)"""
valid = []
num_components = []
all_smiles = []
all_smiles_without_test = []
for graph in tqdm(
generated, desc="Generated molecules validity check progress"
):
atom_types, edge_types = graph
mol = build_molecule(atom_types, edge_types, self.dataset_info.atom_decoder)
smiles = mol2smiles(mol)
all_smiles_without_test.append(mol2smilesWithNoSanitize(mol))
try:
mol_frags = Chem.rdmolops.GetMolFrags(
mol, asMols=True, sanitizeFrags=True
)
num_components.append(len(mol_frags))
except Exception:
pass
if smiles is not None:
try:
mol_frags = Chem.rdmolops.GetMolFrags(
mol, asMols=True, sanitizeFrags=True
)
largest_mol = max(
mol_frags, default=mol, key=lambda m: m.GetNumAtoms()
)
smiles = mol2smiles(largest_mol)
valid.append(smiles)
all_smiles.append(smiles)
except Chem.rdchem.AtomValenceException:
print("Valence error in GetmolFrags")
all_smiles.append(None)
except Chem.rdchem.KekulizeException:
print("Can't kekulize molecule")
all_smiles.append(None)
else:
all_smiles.append(None)
# Persist SMILES; optionally log depending on config
with open(r"final_smiles_all.txt", "w") as fp:
for smiles in all_smiles_without_test:
fp.write("%s\n" % smiles)
df = pd.DataFrame(all_smiles_without_test, columns=["SMILES"])
df.to_csv("final_smiles_all.csv", index=False)
if getattr(getattr(self.args, "sample", object()), "log_smiles", False):
print("All smiles saved")
print(all_smiles_without_test)
print("All SMILES saved to CSV")
return valid, len(valid) / len(generated), np.array(num_components), all_smiles
def compute_uniqueness(self, valid):
"""valid: list of SMILES strings."""
return list(set(valid)), len(set(valid)) / len(valid)
def compute_novelty(self, unique):
num_novel = 0
novel = []
if self.dataset_smiles_list is None:
print("Dataset smiles is None, novelty computation skipped")
return 1, 1
for smiles in tqdm(unique, desc="Unique molecules novelty check progress"):
if smiles not in self.dataset_smiles_list:
novel.append(smiles)
num_novel += 1
return novel, num_novel / len(unique)
def compute_relaxed_validity(self, generated):
valid = []
for graph in tqdm(
generated, desc="Generated molecules relaxed validity check progress"
):
atom_types, edge_types = graph
mol = build_molecule_with_partial_charges(
atom_types, edge_types, self.dataset_info.atom_decoder
)
smiles = mol2smiles(mol)
if smiles is not None:
try:
mol_frags = Chem.rdmolops.GetMolFrags(
mol, asMols=True, sanitizeFrags=True
)
largest_mol = max(
mol_frags, default=mol, key=lambda m: m.GetNumAtoms()
)
smiles = mol2smiles(largest_mol)
valid.append(smiles)
except Chem.rdchem.AtomValenceException:
print("Valence error in GetmolFrags")
except Chem.rdchem.KekulizeException:
print("Can't kekulize molecule")
return valid, len(valid) / len(generated)
def evaluate(self, generated, input_properties=None, test=False):
"""generated: list of pairs (positions: n x 3, atom_types: n [int])
the positions and atom types should already be masked."""
strict_valid, strict_validity, num_components, all_smiles = self.compute_validity(
generated
)
if test:
# Persist SMILES (optionally log depending on config)
with open(r"final_smiles.txt", "w") as fp:
for smiles in all_smiles:
fp.write("%s\n" % smiles)
df = pd.DataFrame(all_smiles, columns=["SMILES"])
df.to_csv("final_smiles.csv", index=False)
if getattr(getattr(self.args, "sample", object()), "log_smiles", False):
print("All smiles saved")
print(all_smiles)
print("All SMILES saved to CSV")
nc_mu = num_components.mean() if len(num_components) > 0 else 0
nc_min = num_components.min() if len(num_components) > 0 else 0
nc_max = num_components.max() if len(num_components) > 0 else 0
total_mols = len(generated)
strict_label = self.validity_labels["strict"]
relaxed_label = self.validity_labels["vfm_relaxed"]
print(f"{strict_label} over {total_mols} molecules: {strict_validity * 100 :.2f}%")
print(
f"Number of connected components of {total_mols} molecules: min:{nc_min:.2f} mean:{nc_mu:.2f} max:{nc_max:.2f}"
)
relaxed_valid, relaxed_validity = self.compute_relaxed_validity(generated)
print(f"{relaxed_label} over {total_mols} molecules: {relaxed_validity * 100 :.2f}%")
self.latest_validity_values["strict"] = strict_validity
self.latest_validity_values["vfm_relaxed"] = relaxed_validity
self.primary_validity_label = self.validity_labels[self.validity_mode]
self._secondary_mode = "strict" if self.validity_mode == "vfm_relaxed" else "vfm_relaxed"
self.secondary_validity_label = self.validity_labels[self._secondary_mode]
self.latest_validity_labels = {
"primary": self.primary_validity_label,
"secondary": self.secondary_validity_label,
}
if self.validity_mode == "vfm_relaxed":
primary_valid, primary_validity = relaxed_valid, relaxed_validity
secondary_validity = strict_validity
else:
primary_valid, primary_validity = strict_valid, strict_validity
secondary_validity = relaxed_validity
self.latest_validity_values["primary"] = primary_validity
self.latest_validity_values["secondary"] = secondary_validity
if input_properties is not None:
raise NotImplementedError(
"Property-conditioned molecular metrics are not included in this release."
)
cond_mae = cond_val = -1.0
if len(primary_valid) > 0:
unique, uniqueness = self.compute_uniqueness(primary_valid)
print(
f"Uniqueness over {len(primary_valid)} valid molecules: {uniqueness * 100 :.2f}%"
)
if self.dataset_smiles_list is not None:
_, novelty = self.compute_novelty(unique)
print(
f"Novelty over {len(unique)} unique valid molecules: {novelty * 100 :.2f}%"
)
else:
novelty = -1.0
else:
novelty = -1.0
uniqueness = 0.0
unique = []
return (
[primary_validity, secondary_validity, uniqueness, novelty],
unique,
dict(nc_min=nc_min, nc_max=nc_max, nc_mu=nc_mu),
all_smiles,
[cond_mae, cond_val],
)
def mol2smiles(mol):
try:
Chem.SanitizeMol(mol)
except ValueError:
return None
return Chem.MolToSmiles(mol)
def mol2smilesWithNoSanitize(mol):
return Chem.MolToSmiles(mol)
def build_molecule(atom_types, edge_types, atom_decoder, verbose=False):
if verbose:
print("building new molecule")
mol = Chem.RWMol()
for atom in atom_types:
a = Chem.Atom(atom_decoder[atom.item()])
mol.AddAtom(a)
if verbose:
print("Atom added: ", atom.item(), atom_decoder[atom.item()])
edge_types = torch.triu(edge_types)
edge_types[edge_types >= 5] = 0 # set edges in virtual state to non-bonded
all_bonds = torch.nonzero(edge_types)
for bond in all_bonds:
if bond[0].item() != bond[1].item():
mol.AddBond(
bond[0].item(),
bond[1].item(),
bond_dict[edge_types[bond[0], bond[1]].item()],
)
if verbose:
print(
"bond added:",
bond[0].item(),
bond[1].item(),
edge_types[bond[0], bond[1]].item(),
bond_dict[edge_types[bond[0], bond[1]].item()],
)
return mol
def build_molecule_with_partial_charges(
atom_types, edge_types, atom_decoder, verbose=False
):
if verbose:
print("\nbuilding new molecule")
mol = Chem.RWMol()
for atom in atom_types:
a = Chem.Atom(atom_decoder[atom.item()])
mol.AddAtom(a)
if verbose:
print("Atom added: ", atom.item(), atom_decoder[atom.item()])
edge_types = torch.triu(edge_types)
edge_types[edge_types >= 5] = 0 # set edges in virtual state to non-bonded
all_bonds = torch.nonzero(edge_types)
for bond in all_bonds:
if bond[0].item() != bond[1].item():
mol.AddBond(
bond[0].item(),
bond[1].item(),
bond_dict[edge_types[bond[0], bond[1]].item()],
)
if verbose:
print(
"bond added:",
bond[0].item(),
bond[1].item(),
edge_types[bond[0], bond[1]].item(),
bond_dict[edge_types[bond[0], bond[1]].item()],
)
# Add positive formal charges for supported over-valent atoms.
flag, atomid_valence = check_valency(mol)
if verbose:
print("flag, valence", flag, atomid_valence)
if flag:
continue
else:
assert len(atomid_valence) == 2
idx = atomid_valence[0]
v = atomid_valence[1]
an = mol.GetAtomWithIdx(idx).GetAtomicNum()
if verbose:
print("atomic num of atom with a large valence", an)
if an in (7, 8, 16) and (v - ATOM_VALENCY[an]) == 1:
mol.GetAtomWithIdx(idx).SetFormalCharge(1)
return mol
# Functions from GDSS
def check_valency(mol):
try:
Chem.SanitizeMol(mol, sanitizeOps=Chem.SanitizeFlags.SANITIZE_PROPERTIES)
return True, None
except ValueError as e:
e = str(e)
p = e.find("#")
e_sub = e[p:]
atomid_valence = list(map(int, re.findall(r"\d+", e_sub)))
return False, atomid_valence
def check_stability(
atom_types, edge_types, dataset_info, debug=False, atom_decoder=None
):
if atom_decoder is None:
atom_decoder = dataset_info.atom_decoder
n_bonds = np.zeros(len(atom_types), dtype="int")
for i in range(len(atom_types)):
for j in range(i + 1, len(atom_types)):
n_bonds[i] += abs((edge_types[i, j] + edge_types[j, i]) / 2)
n_bonds[j] += abs((edge_types[i, j] + edge_types[j, i]) / 2)
n_stable_bonds = 0
for atom_type, atom_n_bond in zip(atom_types, n_bonds):
possible_bonds = allowed_bonds[atom_decoder[atom_type]]
if type(possible_bonds) == int:
is_stable = possible_bonds == atom_n_bond
else:
is_stable = atom_n_bond in possible_bonds
if not is_stable and debug:
print(
"Invalid bonds for molecule %s with %d bonds"
% (atom_decoder[atom_type], atom_n_bond)
)
n_stable_bonds += int(is_stable)
molecule_stable = n_stable_bonds == len(atom_types)
return molecule_stable, n_stable_bonds, len(atom_types)
def compute_molecular_metrics(
molecule_list, train_smiles, dataset_info, labels, args=None, test=False
):
"""molecule_list: (dict)"""
if not dataset_info.remove_h:
print("Analyzing molecule stability...")
molecule_stable = 0
nr_stable_bonds = 0
n_atoms = 0
n_molecules = len(molecule_list)
for mol in tqdm(molecule_list, desc="Stability computation progress"):
atom_types, edge_types = mol
validity_results = check_stability(atom_types, edge_types, dataset_info)
molecule_stable += int(validity_results[0])
nr_stable_bonds += int(validity_results[1])
n_atoms += int(validity_results[2])
# Validity
fraction_mol_stable = molecule_stable / float(n_molecules)
fraction_atm_stable = nr_stable_bonds / float(n_atoms)
validity_dict = {
"mol_stable": fraction_mol_stable,
"atm_stable": fraction_atm_stable,
}
else:
validity_dict = {"mol_stable": -1, "atm_stable": -1}
metrics = BasicMolecularMetrics(dataset_info, train_smiles, args)
rdkit_metrics = metrics.evaluate(molecule_list, labels, test)
all_smiles = rdkit_metrics[-2]
nc = rdkit_metrics[-3]
primary_label = getattr(metrics, "primary_validity_label", "validity_VFM_relaxed")
secondary_label = getattr(metrics, "secondary_validity_label", "validity_strict")
dic = {
primary_label: rdkit_metrics[0][0],
secondary_label: rdkit_metrics[0][1],
"Uniqueness": rdkit_metrics[0][2],
"Novelty": rdkit_metrics[0][3],
"nc_min": nc["nc_min"],
"nc_max": nc["nc_max"],
"nc_mu": nc["nc_mu"],
"cond_mae": rdkit_metrics[-1][0],
"cond_val": rdkit_metrics[-1][1],
}
return validity_dict, rdkit_metrics, all_smiles, dic