3D Molecular Descriptors for ML/QSAR
This notebook demonstrates calculating 3D molecular descriptors from Auto3D-generated conformers for:
QSAR modeling - Quantitative Structure-Activity Relationships
Machine learning - Features for property prediction
Molecular similarity - 3D pharmacophore fingerprints
Drug design - Shape and property optimization
Descriptor Categories
Type |
Description |
Examples |
|---|---|---|
Constitutional |
Atom/bond counts |
MW, nAtoms, nRotBonds |
Topological |
2D connectivity |
TPSA, Chi indices |
Geometrical |
3D shape/size |
RadiusOfGyration, Asphericity |
Electronic |
Charge distribution |
Dipole, partial charges |
Quantum |
From NNP energies |
HOMO-LUMO gap, atomization energy |
The key advantage of 3D descriptors is capturing conformationally-dependent properties.
[ ]:
import os
import tempfile
from pathlib import Path
import numpy as np
import Auto3D
from Auto3D import Auto3DOptions, main
from rdkit import Chem
from rdkit.Chem import AllChem, Descriptors, Descriptors3D
from rdkit.Chem import rdMolDescriptors, Lipinski
from rdkit.Chem import rdFreeSASA
from rdkit.ML.Descriptors import MoleculeDescriptors
import pandas as pd
print(f"Auto3D version: {Auto3D.__version__}")
1. Generate 3D Structures
First, generate optimized 3D structures using Auto3D.
[ ]:
# Drug molecules for descriptor calculation
drug_dataset = {
"aspirin": "CC(=O)OC1=CC=CC=C1C(=O)O",
"ibuprofen": "CC(C)CC1=CC=C(C=C1)C(C)C(=O)O",
"acetaminophen": "CC(=O)NC1=CC=C(C=C1)O",
"caffeine": "CN1C=NC2=C1C(=O)N(C(=O)N2C)C",
"naproxen": "COC1=CC2=CC(C(C)C(=O)O)=CC=C2C=C1",
"diazepam": "CN1C(=O)CN=C(C2=CC=CC=C2)C3=CC=CC(Cl)=C31",
"fluoxetine": "CNCCC(C1=CC=CC=C1)OC2=CC=C(C=C2)C(F)(F)F",
"atorvastatin": "CC(C)C1=C(C(=C(N1CCC(CC(CC(=O)O)O)O)C2=CC=C(C=C2)F)C3=CC=CC=C3)C(=O)NC4=CC=CC=C4",
"metformin": "CN(C)C(=N)NC(=N)N",
"omeprazole": "COC1=CC2=NC(CS(=O)C3=NC4=CC=CC=C4N3)=NC2=CC1OC",
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.smi', delete=False) as f:
for name, smi in drug_dataset.items():
f.write(f"{smi} {name}\n")
input_file = f.name
print(f"Dataset: {len(drug_dataset)} molecules")
[ ]:
# Generate 3D structures
if __name__ == "__main__":
config = Auto3DOptions(
path=input_file,
k=1, # Lowest energy conformer
optimizing_engine="AIMNET",
use_gpu=True,
)
output_sdf = main(config)
print(f"Output: {output_sdf}")
2. 2D Descriptors (Constitutional & Topological)
These are calculated from the molecular graph (don’t require 3D).
[ ]:
def calculate_2d_descriptors(mol):
"""
Calculate key 2D molecular descriptors.
"""
return {
# Constitutional
"MW": Descriptors.MolWt(mol),
"nHeavyAtoms": mol.GetNumHeavyAtoms(),
"nRotBonds": Lipinski.NumRotatableBonds(mol),
"nRings": rdMolDescriptors.CalcNumRings(mol),
"nAromaticRings": rdMolDescriptors.CalcNumAromaticRings(mol),
"nHeteroatoms": rdMolDescriptors.CalcNumHeteroatoms(mol),
# Lipinski
"HBD": Lipinski.NumHDonors(mol),
"HBA": Lipinski.NumHAcceptors(mol),
"LogP": Descriptors.MolLogP(mol),
"TPSA": Descriptors.TPSA(mol),
# Electronic
"FormalCharge": Chem.GetFormalCharge(mol),
# Complexity
"FractionCSP3": rdMolDescriptors.CalcFractionCSP3(mol),
"NumAliphaticRings": rdMolDescriptors.CalcNumAliphaticRings(mol),
}
# Calculate for all molecules
if 'output_sdf' in dir():
mols = list(Chem.SDMolSupplier(output_sdf, removeHs=False))
data_2d = []
for mol in mols:
if mol is None:
continue
name = mol.GetProp("_Name")
desc = calculate_2d_descriptors(mol)
desc["Name"] = name
data_2d.append(desc)
df_2d = pd.DataFrame(data_2d)
df_2d = df_2d.set_index("Name")
print("2D Descriptors:")
print(df_2d.round(2).to_string())
3. 3D Descriptors (Geometrical)
These require 3D coordinates and describe molecular shape.
[ ]:
def calculate_3d_descriptors(mol):
"""
Calculate 3D geometrical descriptors.
These depend on the conformer and capture shape information.
"""
descriptors = {}
try:
# Shape descriptors
descriptors["RadiusOfGyration"] = rdMolDescriptors.CalcRadiusOfGyration(mol)
descriptors["Asphericity"] = rdMolDescriptors.CalcAsphericity(mol)
descriptors["Eccentricity"] = rdMolDescriptors.CalcEccentricity(mol)
descriptors["InertialShapeFactor"] = rdMolDescriptors.CalcInertialShapeFactor(mol)
descriptors["SpherocityIndex"] = rdMolDescriptors.CalcSpherocityIndex(mol)
# Principal moments of inertia
pmi = rdMolDescriptors.CalcPMI1(mol), rdMolDescriptors.CalcPMI2(mol), rdMolDescriptors.CalcPMI3(mol)
descriptors["PMI1"] = pmi[0]
descriptors["PMI2"] = pmi[1]
descriptors["PMI3"] = pmi[2]
# Normalized PMI ratios (useful for shape classification)
if pmi[2] > 0:
descriptors["NPR1"] = pmi[0] / pmi[2] # Rod-likeness
descriptors["NPR2"] = pmi[1] / pmi[2] # Disk-likeness
# Plane of best fit
descriptors["PBF"] = rdMolDescriptors.CalcPBF(mol)
except Exception as e:
print(f"Error calculating 3D descriptors: {e}")
return descriptors
if 'output_sdf' in dir():
data_3d = []
for mol in mols:
if mol is None:
continue
name = mol.GetProp("_Name")
desc = calculate_3d_descriptors(mol)
desc["Name"] = name
data_3d.append(desc)
df_3d = pd.DataFrame(data_3d)
df_3d = df_3d.set_index("Name")
print("\n3D Shape Descriptors:")
print(df_3d.round(3).to_string())
4. Surface Area Descriptors
Solvent-accessible surface area (SASA) is important for solubility and binding.
[ ]:
def calculate_surface_descriptors(mol):
"""
Calculate surface area descriptors.
"""
descriptors = {}
try:
# Need to compute radii first
radii = rdFreeSASA.classifyAtoms(mol)
# Total SASA
sasa = rdFreeSASA.CalcSASA(mol, radii)
descriptors["SASA_total"] = sasa
# LabuteASA (approximate)
descriptors["LabuteASA"] = rdMolDescriptors.CalcLabuteASA(mol)
# PEOE VSA descriptors (partial charge weighted)
peoe_vsa = rdMolDescriptors.PEOE_VSA_(mol)
descriptors["PEOE_VSA_sum"] = sum(peoe_vsa)
# SMR VSA descriptors (MR weighted)
smr_vsa = rdMolDescriptors.SMR_VSA_(mol)
descriptors["SMR_VSA_sum"] = sum(smr_vsa)
except Exception as e:
print(f"Error calculating surface descriptors: {e}")
return descriptors
if 'output_sdf' in dir():
data_surf = []
for mol in mols:
if mol is None:
continue
name = mol.GetProp("_Name")
desc = calculate_surface_descriptors(mol)
desc["Name"] = name
data_surf.append(desc)
df_surf = pd.DataFrame(data_surf)
df_surf = df_surf.set_index("Name")
print("\nSurface Area Descriptors:")
print(df_surf.round(1).to_string())
5. Energy-Based Descriptors from Auto3D
The neural network potentials provide quantum-mechanical-like properties.
[ ]:
def calculate_energy_descriptors(mol):
"""
Extract energy-based descriptors from Auto3D output.
"""
descriptors = {}
# Electronic energy from NNP
if mol.HasProp("E_hartree"):
e_hartree = float(mol.GetProp("E_hartree"))
descriptors["E_hartree"] = e_hartree
# Atomization energy (E / n_atoms) - rough measure of stability
n_atoms = mol.GetNumAtoms()
descriptors["E_per_atom"] = e_hartree * 627.509 / n_atoms # kcal/mol per atom
# Relative energy (if multiple conformers)
if mol.HasProp("E_rel"):
descriptors["E_rel"] = float(mol.GetProp("E_rel"))
# Thermodynamic properties (if calculated)
if mol.HasProp("G_hartree"):
descriptors["G_hartree"] = float(mol.GetProp("G_hartree"))
if mol.HasProp("H_hartree"):
descriptors["H_hartree"] = float(mol.GetProp("H_hartree"))
return descriptors
if 'output_sdf' in dir():
data_energy = []
for mol in mols:
if mol is None:
continue
name = mol.GetProp("_Name")
desc = calculate_energy_descriptors(mol)
desc["Name"] = name
data_energy.append(desc)
df_energy = pd.DataFrame(data_energy)
df_energy = df_energy.set_index("Name")
print("\nEnergy-Based Descriptors:")
print(df_energy.round(4).to_string())
6. Distance-Based Descriptors
Pharmacophore-relevant distances from 3D structures.
[ ]:
def calculate_distance_descriptors(mol):
"""
Calculate distance-based descriptors.
"""
conf = mol.GetConformer()
n_atoms = mol.GetNumAtoms()
# Get all pairwise distances
distances = []
for i in range(n_atoms):
for j in range(i+1, n_atoms):
pos_i = conf.GetAtomPosition(i)
pos_j = conf.GetAtomPosition(j)
dist = pos_i.Distance(pos_j)
distances.append(dist)
distances = np.array(distances)
return {
"MaxDist": distances.max(), # Molecular diameter
"MinDist": distances.min(), # Closest atoms
"MeanDist": distances.mean(), # Average distance
"StdDist": distances.std(), # Distance variability
}
if 'output_sdf' in dir():
data_dist = []
for mol in mols:
if mol is None:
continue
name = mol.GetProp("_Name")
desc = calculate_distance_descriptors(mol)
desc["Name"] = name
data_dist.append(desc)
df_dist = pd.DataFrame(data_dist)
df_dist = df_dist.set_index("Name")
print("\nDistance-Based Descriptors (Å):")
print(df_dist.round(2).to_string())
7. Combined Descriptor Table
Merge all descriptors for ML modeling.
[ ]:
if 'df_2d' in dir():
# Combine all descriptors
df_all = df_2d.copy()
if 'df_3d' in dir():
df_all = df_all.join(df_3d, how='left')
if 'df_surf' in dir():
df_all = df_all.join(df_surf, how='left')
if 'df_dist' in dir():
df_all = df_all.join(df_dist, how='left')
if 'df_energy' in dir():
df_all = df_all.join(df_energy, how='left')
print(f"\nComplete Descriptor Matrix: {df_all.shape[0]} molecules × {df_all.shape[1]} descriptors")
print("\nDescriptor columns:")
for i, col in enumerate(df_all.columns):
print(f" {i+1:2d}. {col}")
[ ]:
# Export to CSV for ML
if 'df_all' in dir():
output_csv = "/tmp/molecular_descriptors.csv"
df_all.to_csv(output_csv)
print(f"\nExported descriptors to: {output_csv}")
# Show summary statistics
print("\nDescriptor Statistics:")
print(df_all.describe().round(2).T[["mean", "std", "min", "max"]].to_string())
8. Molecular Fingerprints
3D pharmacophore fingerprints capture spatial arrangement of features.
[ ]:
from rdkit.Chem import AllChem
from rdkit.Chem.Pharm2D import Gobbi_Pharm2D, Generate
def calculate_fingerprints(mol):
"""
Calculate various molecular fingerprints.
"""
fps = {}
# Morgan (ECFP-like) - 2D
fp_morgan = AllChem.GetMorganFingerprintAsBitVect(mol, radius=2, nBits=1024)
fps["Morgan_OnBits"] = fp_morgan.GetNumOnBits()
# RDKit fingerprint - 2D
fp_rdkit = Chem.RDKFingerprint(mol)
fps["RDKit_OnBits"] = fp_rdkit.GetNumOnBits()
# 2D Pharmacophore
try:
factory = Gobbi_Pharm2D.factory
fp_pharm = Generate.Gen2DFingerprint(mol, factory)
fps["Pharm2D_OnBits"] = fp_pharm.GetNumOnBits()
except:
fps["Pharm2D_OnBits"] = np.nan
return fps
if 'output_sdf' in dir():
data_fp = []
for mol in mols:
if mol is None:
continue
name = mol.GetProp("_Name")
fps = calculate_fingerprints(mol)
fps["Name"] = name
data_fp.append(fps)
df_fp = pd.DataFrame(data_fp).set_index("Name")
print("\nFingerprint Summary:")
print(df_fp.to_string())
9. Descriptor Selection for QSAR
Best practices for selecting descriptors for ML models.
[ ]:
def select_descriptors(df, variance_threshold=0.01, correlation_threshold=0.95):
"""
Select descriptors for ML modeling.
Removes:
- Constant or near-constant descriptors
- Highly correlated descriptors
"""
# Remove columns with NaN
df_clean = df.dropna(axis=1)
# Remove low variance
variances = df_clean.var()
high_var = variances[variances > variance_threshold].index
df_clean = df_clean[high_var]
# Remove highly correlated (keep first of each pair)
corr_matrix = df_clean.corr().abs()
upper = corr_matrix.where(np.triu(np.ones(corr_matrix.shape), k=1).astype(bool))
to_drop = [col for col in upper.columns if any(upper[col] > correlation_threshold)]
df_selected = df_clean.drop(columns=to_drop)
return df_selected, {
"original": len(df.columns),
"after_nan": len(df_clean.columns) + len(to_drop),
"after_variance": len(high_var),
"after_correlation": len(df_selected.columns),
"removed_correlated": to_drop
}
if 'df_all' in dir():
df_selected, info = select_descriptors(df_all)
print("\nDescriptor Selection:")
print(f" Original: {info['original']} descriptors")
print(f" After removing NaN columns: {info['after_nan']}")
print(f" After variance filter: {info['after_variance']}")
print(f" After correlation filter: {info['after_correlation']}")
print(f"\nRemoved (highly correlated): {info['removed_correlated']}")
print(f"\nSelected descriptors: {list(df_selected.columns)}")
10. Best Practices
For QSAR/ML
Use 3D descriptors when conformation matters (binding, permeability)
Boltzmann-average for flexible molecules
Standardize descriptors before modeling (z-score)
Remove collinear descriptors to avoid overfitting
Descriptor Selection
Start with comprehensive set (~200 RDKit descriptors)
Remove constant/near-constant
Remove highly correlated (keep more interpretable)
Use feature importance from random forest for final selection
3D-Specific Considerations
Shape descriptors (PMI, asphericity) are conformer-dependent
Use lowest-energy conformer or Boltzmann average
Energy-based descriptors from NNPs can capture electronic effects
[ ]:
# Cleanup
if 'input_file' in dir() and os.path.exists(input_file):
os.unlink(input_file)
Summary
This tutorial covered:
2D descriptors - MW, LogP, TPSA, counts
3D shape descriptors - RadiusOfGyration, Asphericity, PMI
Surface descriptors - SASA, VSA-based descriptors
Energy descriptors - from Auto3D NNP calculations
Distance descriptors - molecular dimensions
Fingerprints - Morgan, RDKit, pharmacophore
Descriptor selection - removing correlated features
Key workflow:
Generate 3D with Auto3D
Calculate descriptors with RDKit
Filter and select features
Use for QSAR/ML modeling