Work with small molecules¶
Ligand libraries arrive messy: salts and counter-ions tagged along, charges written however the vendor felt like it, the same compound drawn three different ways, and the odd structure that no toolkit will accept. Before docking, scoring, or featurizing anything you usually want to bring the set to a consistent form and throw out what you can't use.
molforge reads small molecules as first-class
Molecule objects — chemistry preserved (bonds,
formal charges, aromaticity, stereochemistry), not the bond-less point
cloud the coordinate-only path produces — then cleans, deduplicates, and
filters them. This recipe takes a raw library from a file to a clean,
drug-like, duplicate-free set.
Everything here is RDKit-backed, so it needs the chem extra:
Every operation is lazy about RDKit — importing molforge never pulls it
in, and an operation without it raises RDKitNotInstalledError with an
install hint rather than failing obscurely.
Read molecules from a file¶
read_molecules reads SDF (.sdf / .mol) and SMILES (.smi /
.smiles) into a list of Molecule, chemistry intact:
from molforge.io import read_molecules
ligands = read_molecules("vendor_library.sdf")
print(len(ligands), "molecules")
lig = ligands[0]
print(lig.name) # the SDF title
print(lig.smiles) # canonical isomeric SMILES
print(lig.formula) # e.g. "C17H19N3O"
print(lig.molecular_weight) # g/mol
print(lig.formal_charge, lig.n_heavy_atoms)
Each molecule records where it came from in metadata["source"], and
takes its name from the record — the SDF title, or the name column of a
SMILES file. Records RDKit can't parse are skipped rather than sinking
the whole read, so one malformed entry in a big SDF doesn't cost you the
rest.
The coordinate-only read_sdf (which returns a Protein) is untouched
and still there when you want atoms, not chemistry — read_molecules is
the chemistry-aware path.
Build molecules from SMILES¶
A single SMILES string:
from molforge.core import Molecule
aspirin = Molecule.from_smiles("CC(=O)Oc1ccccc1C(=O)O", name="aspirin")
print(aspirin.inchikey) # BSYNRYMUTXBXSQ-UHFFFAOYSA-N — a stable structural id
Or a whole block, one SMILES [name] per line (blanks and # comments
skipped):
from molforge.io import read_smiles
mols = read_smiles(
"""
CCO ethanol
CC(=O)O acetic_acid
c1ccccc1 benzene
"""
)
Standardize (clean)¶
standardize runs a sensible cleaning pipeline — sanitize, keep the
largest fragment (drops salts and solvents), and neutralize charges where
chemically reasonable:
from molforge.chem import standardize
clean = standardize(aspirin)
print(clean.metadata["standardized"]) # ['cleanup', 'largest_fragment', 'neutralize']
The input is never mutated: standardize returns a new Molecule,
preserves its name, and records the steps it applied under
metadata["standardized"]. Two flags tune the pipeline, and a canonical
tautomer step is available but off by default (it's the slowest and
occasionally surprising):
standardize(mol, desalt=False) # keep every fragment
standardize(mol, tautomer=True) # also pick the canonical tautomer
When you want just one step, the granular functions are exported too —
cleanup, largest_fragment, neutralize, and canonical_tautomer —
each returning a new Molecule and recording its step the same way.
Drop invalid structures and duplicates¶
is_valid reports whether a molecule passes RDKit sanitization (valence,
aromaticity, kekulization), checked on a copy so nothing is mutated — a
predicate you can filter on:
unique removes structural duplicates, keeping the first occurrence so
input order is preserved. It compares on InChIKey by default (or
key="smiles"):
from molforge.chem import unique
distinct = unique(good) # by InChIKey
distinct = unique(good, key="smiles") # or by canonical SMILES
The MoleculeDataset pipeline¶
MoleculeDataset chains these steps into one lazy pipeline. Each
combinator returns a new dataset and nothing runs until you iterate or
collect, so a filter that discards most of the library never pays to
standardize what it drops:
from molforge.io import read_molecules
from molforge.chem import MoleculeDataset, standardize
from molforge.validation import Criterion
drug_like = Criterion.lt("molecular_weight", 500) & Criterion.le("formal_charge", 0)
hits = (
MoleculeDataset(read_molecules("vendor_library.sdf"))
.map(standardize) # clean every molecule
.valid() # drop anything RDKit rejects
.dedup() # remove duplicates by InChIKey, keep first
.filter(drug_like) # keep small, non-cationic molecules
.take(1000) # at most 1000
.collect() # materialize to a list
)
The combinators:
map(fn)applies anyMolecule -> Moleculetransform (herestandardize).valid()keeps only molecules that sanitize cleanly.dedup(key="inchikey")drops structural duplicates, streaming with a running set of seen identities — only the identities, not the molecules, stay in memory.filter(criterion)keeps molecules whose descriptors satisfy a criterion (next section).take(n)keeps the firstn; it short-circuits, so it's safe over an unbounded source.collect()runs the whole pipeline and returns alist.
A dataset is re-iterable exactly when its source is: built over a list it can be traversed repeatedly; built over a one-shot iterator (see Stream a library larger than memory) it is single-pass, the same contract as a generator.
Filter on molecular properties¶
filter reuses a Criterion — the same
declarative comparison the validation subsystem uses for design metrics —
as a molecule filter. A criterion is built from a metric name, a
comparison, and a threshold, and composes with &, |, and ~:
from molforge.validation import Criterion
fragment_like = (
Criterion.le("molecular_weight", 300)
& Criterion.le("n_heavy_atoms", 22)
& Criterion.eq("formal_charge", 0)
)
fragments = MoleculeDataset(ligands).filter(fragment_like).collect()
The metric names a criterion may reference are the molecule descriptors
in DESCRIPTOR_NAMES — molecular_weight, formal_charge, n_atoms,
and n_heavy_atoms. filter validates the referenced names up front
(a typo like Criterion.lt("mw", 500) raises immediately with the
available names) and computes only the descriptors a given criterion
actually uses.
The mapping is public, so you can score a molecule directly:
from molforge.chem import molecule_descriptors, DESCRIPTOR_NAMES
molecule_descriptors(aspirin)
# {'molecular_weight': 180.16, 'formal_charge': 0, 'n_atoms': 13, 'n_heavy_atoms': 13}
molecule_descriptors(aspirin, names=["molecular_weight"]) # compute just one
sorted(DESCRIPTOR_NAMES)
n_atoms counts the atoms actually in the graph, so for a freshly parsed
molecule it matches n_heavy_atoms; it only rises above the heavy-atom
count once hydrogens are made explicit.
Stream a library larger than memory¶
read_molecules materializes the whole file; its lazy counterpart
iter_molecules yields one Molecule at a time (SDF via RDKit's
ForwardSDMolSupplier, SMILES line by line), so a multi-gigabyte library
never has to fit in RAM. Feed it straight into a dataset and let take
stop the stream once you have enough:
from molforge.io import iter_molecules
from molforge.chem import MoleculeDataset, standardize
from molforge.validation import Criterion
first_hits = (
MoleculeDataset(iter_molecules("enormous_library.sdf"))
.map(standardize)
.valid()
.filter(Criterion.lt("molecular_weight", 500))
.take(50)
.collect() # reads only far enough into the file to find 50
)
Because the source is a one-shot iterator, this dataset is single-pass —
collect() it once. Wrap the readers' output in a list first if you
need to traverse the set more than once.
iter_smiles is the streaming counterpart to read_smiles for SMILES
blocks. Both readers resolve the file format eagerly (a bad extension
raises right away) but parse records lazily, so the first molecule comes
back without waiting for the last.