Model selection¶
QSAR-aware data splitting, nested cross-validation and hyperparameter search.
How you split is a bigger decision than which model you fit. A random split of a QSAR dataset measures interpolation: public sets are dense with near-duplicate analogues, so random assignment scatters a congeneric series across both sides and scores the model on compounds whose close relatives it has memorized.
The splitters¶
Every splitter yields (train_idx, test_idx) and follows the
scikit-learn protocol, so they drop into cross_val_score unchanged:
>>> from qsarkit.model_selection import ScaffoldSplitter
>>> X, y = demo_fingerprints(256), DEMO_Y
>>> train, test = next(ScaffoldSplitter(test_size=0.25).split_mols(demo_mols, y))
>>> len(train), len(test)
(18, 6)
The guarantee is that no scaffold appears on both sides:
>>> from qsarkit.chemspace import bemis_murcko_smiles
>>> train_scaffolds = {bemis_murcko_smiles(demo_mols[i]) for i in train}
>>> test_scaffolds = {bemis_murcko_smiles(demo_mols[i]) for i in test}
>>> train_scaffolds & test_scaffolds
set()
split_mols takes molecules; split takes the matrix and accepts
molecules as groups for the chemistry-aware splitters:
>>> from qsarkit.model_selection import (
... ButinaClusterSplitter, KennardStoneSplitter, RandomSplitter)
>>> len(next(RandomSplitter(test_size=0.25, random_state=0).split(X, y))[1])
6
>>> len(next(ButinaClusterSplitter(test_size=0.25).split_mols(demo_mols, y))[1])
6
>>> len(next(KennardStoneSplitter(test_size=0.25).split(X, y))[1])
6
Which to use:
ScaffoldSplitterThe default hard split. Keeps whole chemotypes together, so the test set is chemistry the model has never seen. Deterministic — no seed.
StratifiedScaffoldSplitterThe same, preserving class balance. Use for classification when the actives are scarce.
ButinaClusterSplitter/SphereExclusionSplitterSplit by similarity cluster rather than scaffold. Catches analogue series that share no Bemis-Murcko framework.
KennardStoneSplitter/PerimeterSplitterDeterministic coverage-driven selection. Kennard-Stone puts the most spread-out compounds in training, which is what you want when training data is precious and the test set need only be representative.
TimeSplitterTrain on early compounds, test on later ones. The only split that simulates prospective use, and reliably the most pessimistic.
RandomSplitterIncluded for baselines and for the comparison that shows how much the others cost you.
Cross-validation and search¶
>>> from qsarkit.models import QSARRegressor
>>> from qsarkit.validation import CrossValidator
>>> report = CrossValidator(n_splits=3, random_state=0).evaluate(
... QSARRegressor("rf", random_state=0), X, y)
>>> round(report["q2"], 2)
0.61
Compare that with the training R² of 0.952 from Models: the gap is the size of the illusion, and it is the honest number.
>>> from qsarkit.model_selection import hyperparameter_search
>>> search = hyperparameter_search(
... QSARRegressor("rf", random_state=0),
... {"model_params": [{"n_estimators": 10}, {"n_estimators": 50}]},
... X, y, cv=3,
... )
>>> sorted(search.best_params_["model_params"])
['n_estimators']
NestedCV separates model selection from model assessment. Tuning
on the same folds you report scores from leaks the test set into the
choice of hyperparameters, and the reported figure is optimistic by an
amount nobody can estimate after the fact:
>>> from qsarkit.model_selection import NestedCV
>>> nested = NestedCV(
... QSARRegressor("ridge"),
... {"model_params": [{"alpha": 0.1}, {"alpha": 1.0}]},
... inner_cv=2, outer_cv=3,
... )
>>> result = nested.run(X, y)
>>> sorted(result)
['best_params', 'mean_score', 'scores', 'std_score']
>>> len(result["scores"]), len(result["best_params"])
(3, 3)
Each outer fold reports the hyperparameters chosen on its own inner folds, so disagreement between them is itself informative — it says the choice is not well determined by this much data:
>>> len({str(p) for p in result["best_params"]}) > 1
True
API¶
QSAR-aware data splitting and hyperparameter search.
A random split flatters a QSAR model: molecular datasets are dense with near-duplicate analogues, so random assignment puts close relatives on both sides and measures interpolation rather than generalization to new chemistry. The splitters here make the evaluation harder in specific, defensible ways – and the gap between a random and a scaffold split is the size of the illusion.
Examples
>>> from rdkit import Chem
>>> from qsarkit.model_selection import ScaffoldSplitter
>>> mols = [Chem.MolFromSmiles(s) for s in
... ("c1ccccc1C", "c1ccccc1CC", "c1ccncc1C", "CCO", "CCN")]
>>> train, test = next(ScaffoldSplitter(test_size=0.4).split_mols(mols))
>>> set(train) & set(test)
set()
References
Wu, Z. et al. (2018). “MoleculeNet: A Benchmark for Molecular Machine Learning.” Chem. Sci., 9, 513-530. https://doi.org/10.1039/C7SC02664A
Sheridan, R. P. (2013). “Time-Split Cross-Validation.” J. Chem. Inf. Model., 53(4), 783-790. https://doi.org/10.1021/ci400084k
- class qsarkit.model_selection.BaseSplitter(test_size=0.2, random_state=None)[source]¶
Bases:
ABCCommon machinery: one train/test split, in scikit-learn’s shape.
- Parameters:
References
Wu, Z. et al. (2018). “MoleculeNet: A Benchmark for Molecular Machine Learning.” Chem. Sci., 9, 513-530. https://doi.org/10.1039/C7SC02664A
Sheridan, R. P. (2013). “Time-Split Cross-Validation as a Method for Estimating the Goodness of Prospective Prediction.” J. Chem. Inf. Model., 53(4), 783-790. https://doi.org/10.1021/ci400084k
scikit-learn cross-validation documentation: https://scikit-learn.org/stable/modules/cross_validation.html
- split(X, y=None, groups=None)[source]¶
Yield one
(train_idx, test_idx)pair.- Parameters:
X (
Any) – Feature matrix, or molecules for structure-aware splitters.y (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str],None]) – Labels, used by the stratified splitters.groups (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str],None]) – Group labels, used byTimeSplitter.
- Yields:
train_idx, test_idx (
ndarrayofint)- Return type:
Iterator[Tuple[ndarray[tuple[Any, …], dtype[int64]], ndarray[tuple[Any, …], dtype[int64]]]]
- class qsarkit.model_selection.RandomSplitter(test_size=0.2, random_state=None)[source]¶
Bases:
BaseSplitterUniformly random split.
The baseline every other splitter should be compared against — and, on molecular data, almost always the optimistic one.
Examples
>>> import numpy as np >>> X = np.arange(20).reshape(10, 2) >>> train, test = next(RandomSplitter(random_state=0).split(X)) >>> len(train), len(test) (8, 2)
References
Pedregosa, F. et al. (2011). “Scikit-learn.” J. Mach. Learn. Res., 12, 2825-2830. https://jmlr.org/papers/v12/pedregosa11a.html
- class qsarkit.model_selection.ScaffoldSplitter(test_size=0.2, include_chirality=False, random_state=None)[source]¶
Bases:
BaseSplitterSplit by Bemis-Murcko scaffold, largest scaffold group first.
Guarantees that no scaffold appears on both sides, so the test set contains only chemistry the model has never seen. This is the standard hard split in molecular machine learning, and typically drops reported performance substantially relative to a random split — which is the point: the gap is the size of the illusion.
Assigning the largest scaffold groups to training first is deterministic (no seed needed) and keeps the rarest, most distinct chemotypes in the test set.
- Parameters:
Examples
>>> from rdkit import Chem >>> mols = [Chem.MolFromSmiles(s) for s in ... ("c1ccccc1C", "c1ccccc1CC", "c1ccncc1C", "CCO", "CCN")] >>> train, test = next(ScaffoldSplitter(test_size=0.4).split_mols(mols)) >>> set(train) & set(test) set()
References
Bemis, G. W. & Murcko, M. A. (1996). “The Properties of Known Drugs. 1. Molecular Frameworks.” J. Med. Chem., 39(15), 2887-2893. https://doi.org/10.1021/jm9602928
Wu, Z. et al. (2018). “MoleculeNet.” Chem. Sci., 9, 513-530. https://doi.org/10.1039/C7SC02664A
- class qsarkit.model_selection.StratifiedScaffoldSplitter(test_size=0.2, include_chirality=False, random_state=None)[source]¶
Bases:
ScaffoldSplitterScaffold split that also balances the label distribution.
Assigns scaffold groups greedily to whichever side is currently furthest from its target label mean (regression) or class balance (classification). Keeps the scaffold-disjointness guarantee while avoiding the common failure where the test set ends up composed entirely of inactives.
Examples
>>> from rdkit import Chem >>> mols = [Chem.MolFromSmiles(s) for s in ... ("c1ccccc1C", "c1ccncc1C", "CCO", "CCN", "c1ccccc1CC")] >>> y = [1, 0, 1, 0, 1] >>> train, test = next( ... StratifiedScaffoldSplitter(test_size=0.4).split_mols(mols, y) ... ) >>> set(train) & set(test) set()
References
Wu, Z. et al. (2018). “MoleculeNet.” Chem. Sci., 9, 513-530. https://doi.org/10.1039/C7SC02664A
Sheridan, R. P. (2013). J. Chem. Inf. Model., 53(4), 783-790. https://doi.org/10.1021/ci400084k
- class qsarkit.model_selection.ButinaClusterSplitter(test_size=0.2, cutoff=0.35, n_bits=2048, random_state=None)[source]¶
Bases:
BaseSplitterSplit by Taylor-Butina cluster, keeping whole clusters together.
A softer alternative to a scaffold split: it groups by overall fingerprint similarity rather than exact scaffold identity, so it also separates molecules that share no scaffold but are still very similar — which a scaffold split happily puts on opposite sides.
- Parameters:
Examples
>>> from rdkit import Chem >>> mols = [Chem.MolFromSmiles(s) for s in ... ("CCO", "CCN", "c1ccccc1", "c1ccccc1C", "CCCCCC")] >>> train, test = next(ButinaClusterSplitter(test_size=0.4).split_mols(mols)) >>> set(train) & set(test) set()
References
Butina, D. (1999). “Unsupervised Data Base Clustering Based on Daylight’s Fingerprint and Tanimoto Similarity.” J. Chem. Inf. Comput. Sci., 39(4), 747-750. https://doi.org/10.1021/ci9803381
- class qsarkit.model_selection.SphereExclusionSplitter(test_size=0.2, cutoff=0.35, n_bits=2048, random_state=None)[source]¶
Bases:
ButinaClusterSplitterSplit by sphere-exclusion cluster, keeping whole clusters together.
Like
ButinaClusterSplitterbut using leader-based sphere exclusion, which guarantees a minimum distance between cluster centres and scales to much larger libraries.Examples
>>> from rdkit import Chem >>> mols = [Chem.MolFromSmiles(s) for s in ("CCO", "CCN", "c1ccccc1", "CCCC")] >>> train, test = next(SphereExclusionSplitter(test_size=0.5).split_mols(mols)) >>> set(train) & set(test) set()
References
Hudson, B. D. et al. (1996). “Parameter Based Methods for Compound Selection from Chemical Databases.” Quant. Struct.-Act. Relat., 15(4), 285-289. https://doi.org/10.1002/qsar.19960150402
Gobbi, A. & Lee, M.-L. (2003). “DISE: Directed Sphere Exclusion.” J. Chem. Inf. Comput. Sci., 43(1), 317-323. https://doi.org/10.1021/ci025554v
- class qsarkit.model_selection.MaxMinSplitter(test_size=0.2, n_bits=2048, random_state=None)[source]¶
Bases:
BaseSplitterPut a maximally diverse subset in the training set.
Uses MaxMin picking to choose training compounds that span the chemical space as widely as possible, leaving the denser regions for testing. This is the split to use when the question is “how few compounds do I need to measure?” rather than “how well does this extrapolate?” — it is deliberately the optimistic structure-aware split, and pairs well with a scaffold split as the pessimistic bound.
- Parameters:
Examples
>>> from rdkit import Chem >>> mols = [Chem.MolFromSmiles(s) for s in ... ("CCO", "CCN", "c1ccccc1", "c1ccccc1C", "CCCCCC")] >>> train, test = next(MaxMinSplitter(test_size=0.4).split_mols(mols)) >>> len(train) + len(test) 5
References
Ashton, M. et al. (2002). “Identification of Diverse Database Subsets.” Quant. Struct.-Act. Relat., 21(6), 598-604. https://doi.org/10.1002/qsar.200290002
- class qsarkit.model_selection.TimeSplitter(test_size=0.2, random_state=None)[source]¶
Bases:
BaseSplitterSplit chronologically: earliest compounds train, latest test.
The most honest evaluation available, because it reproduces the real prospective task — predicting compounds that had not been made yet. Sheridan showed time-split validation gives markedly lower, and much more realistic, performance estimates than random or even scaffold-based splits.
- Parameters:
Examples
>>> import numpy as np >>> X = np.arange(20).reshape(10, 2) >>> dates = np.arange(10) >>> train, test = next(TimeSplitter(test_size=0.3).split(X, groups=dates)) >>> bool(dates[train].max() <= dates[test].min()) True
References
Sheridan, R. P. (2013). “Time-Split Cross-Validation as a Method for Estimating the Goodness of Prospective Prediction.” J. Chem. Inf. Model., 53(4), 783-790. https://doi.org/10.1021/ci400084k
- class qsarkit.model_selection.KennardStoneSplitter(test_size=0.2, metric='euclidean', random_state=None)[source]¶
Bases:
BaseSplitterKennard-Stone: training set covers the descriptor space uniformly.
Selects training points to be maximally far apart, starting from the two most distant compounds. Deterministic, and produces a training set whose convex hull encloses most of the test set — which makes it the natural companion to a leverage-based applicability domain, since almost every test compound ends up inside it.
- Parameters:
Examples
>>> import numpy as np >>> X = np.random.RandomState(0).normal(size=(20, 3)) >>> train, test = next(KennardStoneSplitter(test_size=0.25).split(X)) >>> len(train), len(test) (15, 5)
References
Kennard, R. W. & Stone, L. A. (1969). “Computer Aided Design of Experiments.” Technometrics, 11(1), 137-148. https://doi.org/10.1080/00401706.1969.10490666
Snee, R. D. (1977). “Validation of Regression Models: Methods and Examples.” Technometrics, 19(4), 415-428. https://doi.org/10.1080/00401706.1977.10489581
- class qsarkit.model_selection.PerimeterSplitter(test_size=0.2, random_state=None)[source]¶
Bases:
BaseSplitterPut the outermost compounds in the training set.
Selects the points furthest from the dataset centroid for training, leaving the interior for testing. The mirror image of a scaffold split: it makes every prediction an interpolation, giving the most favourable honest estimate of a model’s performance inside its own domain.
Examples
>>> import numpy as np >>> X = np.random.RandomState(0).normal(size=(20, 3)) >>> train, test = next(PerimeterSplitter(test_size=0.25).split(X)) >>> len(train), len(test) (15, 5)
References
Martin, T. M. et al. (2012). “Does Rational Selection of Training and Test Sets Improve the Outcome of QSAR Modeling?” J. Chem. Inf. Model., 52(10), 2570-2578. https://doi.org/10.1021/ci300338w
- class qsarkit.model_selection.NestedCV(estimator, param_grid, inner_cv=3, outer_cv=5, scoring=None, n_jobs=None)[source]¶
Bases:
objectNested cross-validation: unbiased performance for a tuned model.
Tuning hyperparameters and reporting the best cross-validated score from that same search is one of the most common ways QSAR papers overstate performance — the score is optimistically biased because the test folds were used to choose the model. Nested CV fixes it by tuning inside an inner loop and scoring on outer folds the tuning never saw.
- Parameters:
- Variables:
Examples
>>> from sklearn.datasets import make_regression >>> from sklearn.linear_model import Ridge >>> X, y = make_regression(n_samples=40, n_features=5, random_state=0) >>> ncv = NestedCV(Ridge(), {"alpha": [0.1, 1.0]}, inner_cv=2, outer_cv=2) >>> _ = ncv.run(X, y) >>> ncv.scores_.shape (2,)
References
Varma, S. & Simon, R. (2006). “Bias in Error Estimation when Using Cross-Validation for Model Selection.” BMC Bioinformatics, 7, 91. https://doi.org/10.1186/1471-2105-7-91
Cawley, G. C. & Talbot, N. L. C. (2010). “On Over-fitting in Model Selection and Subsequent Selection Bias in Performance Evaluation.” J. Mach. Learn. Res., 11, 2079-2107. https://jmlr.org/papers/v11/cawley10a.html
Baumann, D. & Baumann, K. (2014). “Reliable Estimation of Prediction Errors for QSAR Models under Model Uncertainty Using Double Cross-Validation.” J. Cheminform., 6, 47. https://doi.org/10.1186/s13321-014-0047-1
- run(X, y)[source]¶
Run the nested loop.
- Parameters:
X (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str]])y (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str]])
- Returns:
mean_score,std_score,scores(per outer fold), andbest_params(the winner in each outer fold).- Return type:
- qsarkit.model_selection.hyperparameter_search(estimator, param_grid, X, y, method='grid', cv=5, scoring=None, n_iter=20, n_jobs=None, random_state=None, **kwargs)[source]¶
Tune hyperparameters by grid, random or Bayesian search.
- Parameters:
estimator (
BaseEstimator) – The model to tune. Cloned, not modified.param_grid (
Union[Dict[str,Sequence[Any]],List[Dict[str,Sequence[Any]]]]) – Parameter name -> values to try, in scikit-learn’s format.X (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str]])y (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str]])method (
Literal['grid','random','optuna']) – Exhaustive grid, random sampling, or Optuna’s TPE sampler (requires the optionaloptunapackage). Random search beats grid search on the same budget whenever only a few parameters actually matter, which is the usual case.cv (
Any) – Pass aqsarkit.model_selectionsplitter to tune under a scaffold or time split rather than a random one.scoring (
Optional[str]) – scikit-learn scorer name. Defaults to the estimator’s own score.n_iter (
int) – Number of parameter settings sampled by the random and Optuna methods.**kwargs (
Any) – Forwarded to the underlying search object.
- Returns:
Fitted, exposing
best_estimator_,best_params_andbest_score_.- Return type:
Examples
>>> from sklearn.datasets import make_regression >>> from sklearn.ensemble import RandomForestRegressor >>> X, y = make_regression(n_samples=40, n_features=5, random_state=0) >>> search = hyperparameter_search( ... RandomForestRegressor(random_state=0), ... {"n_estimators": [5, 10]}, X, y, cv=2, ... ) >>> "n_estimators" in search.best_params_ True
References
Bergstra, J. & Bengio, Y. (2012). “Random Search for Hyper-Parameter Optimization.” J. Mach. Learn. Res., 13, 281-305. https://jmlr.org/papers/v13/bergstra12a.html
Akiba, T. et al. (2019). “Optuna: A Next-generation Hyperparameter Optimization Framework.” KDD 2019, 2623-2631. https://doi.org/10.1145/3292500.3330701
scikit-learn model selection documentation: https://scikit-learn.org/stable/modules/grid_search.html
References¶
Bemis, G. W. & Murcko, M. A. (1996). “The Properties of Known Drugs. 1. Molecular Frameworks.” J. Med. Chem., 39(15), 2887-2893. doi:10.1021/jm9602928
Sheridan, R. P. (2013). “Time-Split Cross-Validation as a Method for Estimating the Goodness of Prospective Prediction.” J. Chem. Inf. Model., 53(4), 783-790. doi:10.1021/ci400084k
Kennard, R. W. & Stone, L. A. (1969). “Computer Aided Design of Experiments.” Technometrics, 11(1), 137-148. doi:10.1080/00401706.1969.10490666
Wu, Z. et al. (2018). “MoleculeNet: A Benchmark for Molecular Machine Learning.” Chem. Sci., 9, 513-530. doi:10.1039/C7SC02664A
Cawley, G. C. & Talbot, N. L. C. (2010). “On Over-fitting in Model Selection and Subsequent Selection Bias in Performance Evaluation.” J. Mach. Learn. Res., 11, 2079-2107. https://jmlr.org/papers/v11/cawley10a.html