| import numpy as np |
| import torch |
| import torch_geometric |
| import torch_scatter |
|
|
| |
| |
| |
| SUPPORTED_ELEMENTS = frozenset({1, 6, 7, 8, 9, 16, 17}) |
|
|
|
|
| def yield_data(solute_atomic_numbers, geometries, atomic_numbers, shieldings = None, N_atoms_per_solvent = 3, solvent_distance_threshold = None, atom_type = 'H'): |
| data = torch_geometric.data.Data( |
| x = torch.as_tensor(atomic_numbers, dtype = torch.long), |
| ) |
| if shieldings is None: |
| data.shieldings = torch.zeros(data.x.shape[0], dtype = torch.float) |
| else: |
| data.shieldings = torch.as_tensor(shieldings, dtype = torch.float) |
|
|
| data.pos = torch.as_tensor(geometries, dtype = torch.float) |
| |
| solute = np.zeros(len(atomic_numbers), dtype = int) |
| solute[0:len(solute_atomic_numbers)] = 1 |
| data.solute = torch.as_tensor(solute, dtype = torch.long) |
| |
| if atom_type == 'H': |
| atom_type_mask = (atomic_numbers == 1) & (solute == 1) |
| elif atom_type == 'C': |
| atom_type_mask = (atomic_numbers == 6) & (solute == 1) |
| data.atom_type_mask = torch.as_tensor(atom_type_mask, dtype = torch.bool) |
| |
| data.batch = torch.zeros(data.x.shape[0], dtype = torch.long) |
| data.natoms = torch.unique_consecutive(data.batch, return_counts = True)[1] |
| |
| N_solute_atoms = sum(data.solute).item() |
| N_solvent_atoms = data.x.shape[0] - N_solute_atoms |
| N_solvent_molecules = N_solvent_atoms // N_atoms_per_solvent |
| unique_molecule_batch = np.zeros(N_solute_atoms, dtype = int) |
| if N_solvent_molecules > 0: |
| unique_molecule_batch = np.concatenate([ |
| unique_molecule_batch, |
| np.concatenate([np.zeros(N_atoms_per_solvent, dtype = int) + 1 + i for i in range(N_solvent_molecules)], axis = 0), |
| ], axis = 0) |
| data.unique_molecule_batch = torch.as_tensor(unique_molecule_batch, dtype = torch.long) |
| |
| if solvent_distance_threshold is not None: |
| min_distance_to_solute_peratom = torch.linalg.norm(data.pos - (data.pos[data.solute > 0])[:, None, ...], dim = -1).min(axis = 0).values |
| min_distance_to_solute_permolecule = torch_scatter.scatter_min(min_distance_to_solute_peratom, data.unique_molecule_batch)[0] |
| remove_atom = np.array([m in torch.where((min_distance_to_solute_permolecule > solvent_distance_threshold))[0] for m in data.unique_molecule_batch]) |
| |
| data.x = data.x[~remove_atom] |
| if shieldings is None: |
| data.shieldings = data.shieldings[~remove_atom] |
| data.pos = data.pos[~remove_atom] |
| data.solute = data.solute[~remove_atom] |
| data.atom_type_mask = data.atom_type_mask[~remove_atom] |
| data.batch = torch.zeros(data.x.shape[0], dtype = torch.long) |
| data.natoms = torch.unique_consecutive(data.batch, return_counts = True)[1] |
| N_solute_atoms = sum(data.solute).item() |
| N_solvent_atoms = data.x.shape[0] - N_solute_atoms |
| N_solvent_molecules = N_solvent_atoms // N_atoms_per_solvent |
| unique_molecule_batch = np.zeros(N_solute_atoms, dtype = int) |
| if N_solvent_molecules > 0: |
| unique_molecule_batch = np.concatenate([ |
| unique_molecule_batch, |
| np.concatenate([np.zeros(N_atoms_per_solvent, dtype = int) + 1 + i for i in range(N_solvent_molecules)], axis = 0), |
| ], axis = 0) |
| data.unique_molecule_batch = torch.as_tensor(unique_molecule_batch, dtype = torch.long) |
| |
| return data |
|
|
|
|
| def _predict_once(model_H, model_C, solute_atomic_numbers, geometry, atomic_numbers, N_atoms_per_solvent, solvent_distance_threshold, device): |
| |
| for atom_type in ['H', 'C']: |
| data = yield_data( |
| solute_atomic_numbers = solute_atomic_numbers, |
| geometries = geometry, |
| atomic_numbers = atomic_numbers, |
| shieldings = None, |
| N_atoms_per_solvent = N_atoms_per_solvent if N_atoms_per_solvent is not None else 3, |
| solvent_distance_threshold = solvent_distance_threshold, |
| atom_type = atom_type, |
| ) |
| data = data.to(device) |
|
|
| with torch.no_grad(): |
| if atom_type == 'H': |
| y_pred = model_H.forward(data).cpu() |
| elif atom_type == 'C': |
| y_pred = model_C.forward(data).cpu() |
| |
| |
| atom_type_mask = data.atom_type_mask.cpu() |
| solute = data.solute.cpu() |
| y_pred[~atom_type_mask] = 0. |
| y_pred = y_pred[solute == 1].numpy() |
|
|
| |
| if atom_type == 'H': |
| y_pred_combined = y_pred |
| elif atom_type == 'C': |
| y_pred_combined[y_pred != 0.0] = y_pred[y_pred != 0.0] |
|
|
| return y_pred_combined |
|
|
|
|
| def predict_shieldings(model_H, model_C, solute_atomic_numbers, geometry, atomic_numbers = None, N_atoms_per_solvent = None, solvent_distance_threshold = None, device = 'cpu', n_passes = 1, symmetrize = False): |
| """Predict 1H/13C shieldings for the solute atoms. |
| |
| A single forward pass is NOT deterministic: each edge picks a random local |
| reference frame (eqV2/edge_rot_mat.py), so with a finite spherical-harmonic grid |
| the output varies by ~0.01 ppm (13C) between passes. Set n_passes (e.g. 20, as in |
| the MagNET paper) to average that frame noise away. |
| |
| symmetrize=True also averages the prediction over the molecule and its mirror image. |
| Isotropic shielding is parity-even (a molecule and its reflection have identical |
| shieldings), but the SO(3)-only model does not enforce this and can disagree by |
| ~0.3 ppm on 13C for large molecules; symmetrizing removes that spurious error. |
| """ |
| solute_atomic_numbers = np.asarray(solute_atomic_numbers) |
| if atomic_numbers is None: |
| atomic_numbers = solute_atomic_numbers |
| else: |
| |
| |
| atomic_numbers = np.asarray(atomic_numbers) |
|
|
| |
| |
| |
| unsupported = sorted(set(np.unique(atomic_numbers).tolist()) - SUPPORTED_ELEMENTS) |
| if unsupported: |
| raise ValueError( |
| f"MagNET supports only the elements {sorted(SUPPORTED_ELEMENTS)} " |
| f"(H, C, N, O, F, S, Cl); the input contains unsupported atomic numbers {unsupported}. " |
| f"Exclude molecules with these elements before predicting.") |
|
|
| geometry = np.asarray(geometry, dtype=float) |
| geometries = [geometry] |
| if symmetrize: |
| reflected = geometry.copy() |
| reflected[..., 0] = -reflected[..., 0] |
| geometries.append(reflected) |
|
|
| preds = [] |
| for geom in geometries: |
| for _ in range(n_passes): |
| preds.append(np.squeeze(_predict_once(model_H, model_C, solute_atomic_numbers, geom, |
| atomic_numbers, N_atoms_per_solvent, |
| solvent_distance_threshold, device))) |
| |
| return np.atleast_1d(np.mean(preds, axis=0)) |
|
|