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.1.tar.gz (23.5 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.1-py3-none-any.whl (19.9 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: stratosampler-0.1.1.tar.gz
  • Upload date:
  • Size: 23.5 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.1.tar.gz
Algorithm Hash digest
SHA256 c093ca5396874342edbf0646b12c9fb866aacae858b2a2c7f6f3bdd221225f36
MD5 88e1c952b8ae64a5250858bdd9f5f3a1
BLAKE2b-256 e3c6dcc1f57490e7969bea6a3fd1a38ac0ea3e1af466f24681024ea2116393d0

See more details on using hashes here.

File details

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

File metadata

  • Download URL: stratosampler-0.1.1-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.1-py3-none-any.whl
Algorithm Hash digest
SHA256 468b42b391f94484ca307511944ac05da960b7bafab5d9e2f214ff87b9951a50
MD5 5c17e502effd745603bbe3d2f4c56ed9
BLAKE2b-256 5a89a711c21f9253b2edcc9ca4eab693e84600302942db4b6a5211b0db3e65fd

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