Skip to main content

Stratified molecular dataset splitting for QSAR model development

Project description

stratosampler

stratosampler

Stratified molecular dataset splitting for QSAR model development.

Python 3.9+ License: MIT Tests


The problem with naive splits

When you build a QSAR model, the way you split your dataset determines how honestly you evaluate it.

A standard random train_test_split gives you a test set that happens to look like the training set — because it was drawn from the same distribution. This inflates your metrics. You think your model generalises to diverse chemical space. It doesn't.

Full dataset     →  logP range: -2 to 7,  MW range: 150–700
Random test set  →  logP range: -1.8 to 6.9,  MW range: 160–690  ← almost identical

The test set is not a real challenge. It's a mirror.

stratosampler fixes this by stratifying across multiple molecular properties simultaneously, so train and test sets each represent the full property distribution of your dataset — not a random slice of it.

Stratified test set  →  logP range: -1.9 to 6.8,  MW range: 155–685  ← intentionally representative
KS statistic (logP): 0.04 vs 0.18 for random split

Install

pip install stratosampler
# with RDKit (required for computing properties from SMILES):
pip install "stratosampler[rdkit]"

Quick start

from stratosampler import PropertyStratifiedSplitter

splitter = PropertyStratifiedSplitter(
    properties=["MolLogP", "MolWt", "TPSA"],
    n_bins=5,
    test_size=0.2,
    random_state=42,
)

# From SMILES (computes properties automatically)
train_idx, test_idx = splitter.split(df, smiles_col="SMILES")

# From pre-computed property columns
train_idx, test_idx = splitter.split(df, property_cols=["logP", "MW", "TPSA"])

# Three-way split
train_idx, val_idx, test_idx = splitter.split(
    df, smiles_col="SMILES"
)  # set val_size > 0 in constructor

# Get DataFrames directly
train_df, test_df = splitter.get_split_dataframes(df, smiles_col="SMILES")

Scaffold-aware mode

Prevents analogue leakage by keeping molecules sharing a Murcko scaffold together in the same split, while still preserving property distributions at the scaffold-cluster level.

splitter = PropertyStratifiedSplitter(
    properties=["MolLogP", "MolWt", "TPSA"],
    test_size=0.2,
    scaffold_aware=True,
    random_state=42,
)
train_idx, test_idx = splitter.split(df, smiles_col="SMILES")

Evaluate your split

from stratosampler import split_summary, plot_property_distributions

summary = split_summary(df, train_idx, test_idx, property_cols=["logP", "MW", "TPSA"])

print(f"Mean KS statistic:    {summary['mean_ks_stat']:.3f}")   # lower = better
print(f"Mean JS divergence:   {summary['mean_js_div']:.3f}")    # lower = better
print(f"Stratum coverage:     {summary['coverage_score']:.3f}") # 1.0 = all strata in both splits

# Visualise distributions
fig = plot_property_distributions(df, train_idx, test_idx, ["logP", "MW", "TPSA"])
fig.savefig("split_distributions.png", dpi=150, bbox_inches="tight")

Compare strategies

from stratosampler import PropertyStratifiedSplitter, split_summary, plot_split_comparison
import numpy as np

props = ["MolLogP", "MolWt", "TPSA"]

# Random split
rng = np.random.default_rng(42)
idx = np.arange(len(df)); rng.shuffle(idx)
n_test = int(0.2 * len(df))
random_results = split_summary(df, idx[n_test:], idx[:n_test], props)

# Stratified split
strat = PropertyStratifiedSplitter(test_size=0.2, random_state=42)
train, test = strat.split(df, smiles_col="SMILES")
strat_results = split_summary(df, train, test, props)

# Scaffold-aware split
sc_strat = PropertyStratifiedSplitter(test_size=0.2, scaffold_aware=True, random_state=42)
sc_train, sc_test = sc_strat.split(df, smiles_col="SMILES")
sc_results = split_summary(df, sc_train, sc_test, props)

fig = plot_split_comparison(
    {"random": random_results, "stratified": strat_results, "scaffold+strat": sc_results},
    props,
    metric="ks_stat",
)
fig.savefig("strategy_comparison.png", dpi=150, bbox_inches="tight")

Built-in properties

When passing properties to the splitter, these names are computed automatically from SMILES:

Name Description
MolLogP Wildman-Crippen LogP
MolWt Molecular weight
TPSA Topological polar surface area
NumHDonors H-bond donors
NumHAcceptors H-bond acceptors
NumRotBonds Rotatable bonds
NumRings Total ring count
NumAromaticRings Aromatic ring count
FractionCSP3 Fraction of sp3 carbons
NumHeavyAtoms Heavy atom count

Any valid rdkit.Chem.Descriptors attribute name also works.


Why this matters for QSAR

A well-stratified split catches real model failures:

  • A model trained on a random split of a logP-skewed dataset will look good on paper but fail on low-logP compounds in deployment.
  • A model evaluated on a scaffold-split-only test set may miss property distribution shift — your test compounds are structurally novel but chemically similar.
  • Coverage score tells you whether your test set actually challenges the model across the full property space.

The goal is not to make your model look worse — it's to catch problems before they reach the project team.


Real-world example: ChEMBL EGFR kinase inhibitors

The examples/chembl_egfr_stratified_sampling.py script fetches 362 EGFR IC50 records from ChEMBL, clusters them into chemical series via Murcko scaffold analysis, then draws a stratified sample across every (series × pIC50 range) stratum — guaranteeing at least 5 representatives per series.

EGFR stratified sample

Grey dots are the full population; coloured circles are the 103 sampled compounds (28.5%), one colour per scaffold series. Dashed lines mark pIC50 activity-range boundaries. The sample achieves KS = 0.064 on the pIC50 distribution — close to what a pure property-stratified split would give, while also guaranteeing chemical-series coverage.


Contributing

Issues and PRs welcome. Run the test suite with:

pip install -e ".[dev]"
pytest tests/ -v

License

MIT. See LICENSE.


Citation

If stratosampler is useful in your research:

Fino, R. (2026). stratosampler: Stratified molecular dataset splitting for QSAR.
GitHub: https://github.com/rubbs14/stratosampler

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

stratosampler-0.1.0.tar.gz (23.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

stratosampler-0.1.0-py3-none-any.whl (19.9 kB view details)

Uploaded Python 3

File details

Details for the file stratosampler-0.1.0.tar.gz.

File metadata

  • Download URL: stratosampler-0.1.0.tar.gz
  • Upload date:
  • Size: 23.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.12

File hashes

Hashes for stratosampler-0.1.0.tar.gz
Algorithm Hash digest
SHA256 c13fc6ded6449b91f243573099adc34889903ad5e7b225efb77d72307038a601
MD5 f2d0fc5f26836abab3ab1253a1d76d81
BLAKE2b-256 0eb20896f2f7220d2d0af7bc14c87a6f46e3c1bbc5709dc325a5214a04f47d18

See more details on using hashes here.

File details

Details for the file stratosampler-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: stratosampler-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 19.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.12

File hashes

Hashes for stratosampler-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 01c8c6d20e8425d1f7d1305494bffdd98c28028aaf65df71334704e8a21ae787
MD5 ee54a9110dff7bb3cf2be1503d86b06d
BLAKE2b-256 573b55eb8d788f80966db454071e46d0c451e61c2dca2e0c965dc3e83a575095

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page