Representation

Every representation is a scikit-learn transformer taking Iterable[rdkit.Chem.Mol] and returning a NumPy array, with get_feature_names_out() for traceability where the features have names.

Choosing a representation matters more than choosing a model. A random forest on good features beats a tuned network on bad ones, and no amount of hyperparameter search recovers information the representation discarded.

Fingerprints

>>> from qsarkit.representation import MorganFingerprint
>>> MorganFingerprint(radius=2, n_bits=2048).transform(demo_mols).shape
(24, 2048)

ECFP4 (radius=2) is the default worth reaching for first. The radius is the substantive choice: 2 captures functional groups, 3 captures larger motifs at the cost of sparsity.

>>> ecfp6 = MorganFingerprint(radius=3, n_bits=2048).transform(demo_mols)
>>> fcfp4 = MorganFingerprint(radius=2, use_features=True).transform(demo_mols)
>>> counts = MorganFingerprint(radius=2, use_counts=True).transform(demo_mols)
>>> ecfp6.shape, fcfp4.shape, counts.shape
((24, 2048), (24, 2048), (24, 2048))

Use counts when substructure frequency matters; binary otherwise. A count vector holds values above 1 where a binary one saturates:

>>> import numpy as np
>>> binary = MorganFingerprint(radius=2).transform(demo_mols)
>>> float(binary.max()), bool(counts.max() > 1)
(1.0, True)

Fingerprints are sparse. Most bits never fire on a small dataset — that is what makes the representation general, but it does mean a variance filter is nearly free:

>>> int((binary.sum(axis=0) > 0).sum())
138

The other families answer different questions:

>>> from qsarkit.representation import (
...     AtomPairFingerprint, MACCSKeysFingerprint, RDKitFingerprint,
...     TopologicalTorsionFingerprint)
>>> MACCSKeysFingerprint().transform(demo_mols).shape
(24, 167)
>>> RDKitFingerprint(n_bits=512).transform(demo_mols).shape
(24, 512)
>>> AtomPairFingerprint(n_bits=512).transform(demo_mols).shape
(24, 512)
>>> TopologicalTorsionFingerprint(n_bits=512).transform(demo_mols).shape
(24, 512)

MACCS keys are 166 hand-curated substructure questions: interpretable and fixed-length, but far less expressive than a hashed fingerprint. Atom pairs and topological torsions are count vectors by design.

Combining fingerprints takes (name, transformer) pairs, so each block stays identifiable in the output:

>>> from qsarkit.representation import FingerprintCombiner
>>> combined = FingerprintCombiner([
...     ("morgan", MorganFingerprint(n_bits=256)),
...     ("maccs", MACCSKeysFingerprint()),
... ])
>>> combined.transform(demo_mols).shape
(24, 423)

Descriptors

Where fingerprints answer “what substructures are present”, descriptors answer “what is this molecule like”.

>>> from qsarkit.representation import (
...     Descriptors3D, PhysicochemicalDescriptors, RDKitDescriptors)
>>> RDKitDescriptors().transform(demo_mols).shape        # all of them, NaN-safe
(24, 217)
>>> block = PhysicochemicalDescriptors()
>>> list(block.get_feature_names_out()[:4])
['MolWt', 'MolLogP', 'TPSA', 'NumHDonors']
>>> Descriptors3D().transform(demo_mols[:2]).shape       # embeds conformers first
(2, 10)

Descriptors are interpretable in a way fingerprints are not — which matters for OECD principle 5.

Warning

They are also on wildly different scales. Molecular weight is in the hundreds and logP in single digits, so any distance-based or regularized model needs them scaled:

>>> features = block.transform(demo_mols)
>>> features.std(axis=0).round(1)[:3]        # MolWt, MolLogP, TPSA
array([17.3,  0.6, 12.8])
>>> from qsarkit.transform import DescriptorScaler
>>> scaled = DescriptorScaler(method="standard").fit_transform(features)
>>> bool(abs(scaled.mean()) < 1e-9), scaled.std().round(3)
(True, np.float64(1.0))

Fingerprints, being binary and already on one scale, do not.

Learned embeddings

Mol2VecTransformer and ChemBERTaTransformer need the embeddings and nlp extras respectively. They are lazily imported, so import qsarkit stays cheap:

from qsarkit.representation import ChemBERTaTransformer, Mol2VecTransformer

Mol2VecTransformer().fit(corpus_mols).transform(mols)   # needs [embeddings]
ChemBERTaTransformer(pooling="mean").transform(mols)    # needs [nlp]

A missing extra raises an error naming it rather than an ImportError you have to interpret.

Note

Graph neural network representations (MPNN, D-MPNN, GCN, GAT, SchNet) are not included. They are a different engineering problem — training loops, GPU management, batching — and shipping a half-hearted version would be worse than pointing at the projects that do it properly. Use Chemprop or DeepChem, and bring the learned embeddings back here as a plain feature matrix: everything downstream in qsarkit takes an array.

Composing

MoleculeFeatureUnion concatenates blocks of different kinds — a fingerprint beside a descriptor set:

>>> from qsarkit.transform import MoleculeFeatureUnion
>>> union = MoleculeFeatureUnion([
...     ("maccs", MACCSKeysFingerprint()),
...     ("physchem", PhysicochemicalDescriptors()),
... ])
>>> union.fit_transform(demo_mols).shape
(24, 176)

Unlike the individual transformers it requires fit before transform, because it has to learn each branch’s width to know where the blocks join.

The whole thing composes into an ordinary pipeline:

>>> from sklearn.pipeline import Pipeline
>>> from qsarkit.models import QSARRegressor
>>> from qsarkit.transform import NaNHandler
>>> pipeline = Pipeline([
...     ("features", union),
...     ("nan", NaNHandler(strategy="median")),
...     ("model", QSARRegressor("rf", random_state=0)),
... ])
>>> pipeline.fit(demo_mols, DEMO_Y).predict(demo_mols).shape
(24,)

NaNHandler belongs before any estimator in a descriptor pipeline: RDKit emits NaN for undefined quantities — a 3D descriptor with no conformer, a ratio with a zero denominator — and most estimators refuse to fit on them.

make_qsar_pipeline() assembles the common case, and can take SMILES directly so structure parsing happens inside the cross-validation fold:

>>> from qsarkit.transform import make_qsar_pipeline
>>> pipeline = make_qsar_pipeline(
...     MorganFingerprint(n_bits=512),
...     QSARRegressor("rf", random_state=0),
...     from_smiles=True,
... )
>>> [name for name, _ in pipeline.steps]
['smiles_to_mol', 'representation', 'nan', 'model']
>>> pipeline.fit(DEMO_SMILES, DEMO_Y).predict(DEMO_SMILES[:3]).shape
(3,)

Or in the pipe notation, where featurize() accepts any of the transformers above:

>>> from qsarkit.functional import describe, featurize, fingerprint, molecules
>>> (molecules(DEMO_SMILES) >> fingerprint("morgan", n_bits=512)).shape
(24, 512)
>>> (molecules(DEMO_SMILES) >> describe("lipinski")).shape
(24, 9)
>>> (molecules(DEMO_SMILES) >> featurize(union)).shape
(24, 176)

References

  • Rogers, D. & Hahn, M. (2010). “Extended-Connectivity Fingerprints.” J. Chem. Inf. Model., 50(5), 742-754. doi:10.1021/ci100050t

  • Durant, J. L. et al. (2002). “Reoptimization of MDL Keys for Use in Drug Discovery.” J. Chem. Inf. Comput. Sci., 42(6), 1273-1280. doi:10.1021/ci010132r

  • Todeschini, R. & Consonni, V. (2009). Molecular Descriptors for Chemoinformatics. Wiley. doi:10.1002/9783527628766

  • Jaeger, S., Fulle, S. & Turk, S. (2018). “Mol2vec.” J. Chem. Inf. Model., 58(1), 27-35. doi:10.1021/acs.jcim.7b00616

  • Chithrananda, S., Grand, G. & Ramsundar, B. (2020). “ChemBERTa.” arXiv:2010.09885

  • Yang, K. et al. (2019). “Analyzing Learned Molecular Representations for Property Prediction.” J. Chem. Inf. Model., 59(8), 3370-3388. doi:10.1021/acs.jcim.9b00237

  • Gilmer, J. et al. (2017). “Neural Message Passing for Quantum Chemistry.” arXiv:1704.01212