Data quality¶
Dataset curation following Fourches, Muratov and Tropsha: duplicate detection, activity outliers, structure validation, and a pipeline that reports what it removed and why.
A QSAR model is bounded by the quality of its activity data. Curation is not tidying — it is the difference between a model of the chemistry and a model of the database’s accumulated errors.
Duplicates¶
Duplicates are detected by structural identity, not by string equality: the same compound registered as a salt, a tautomer or with different stereo annotation is one record.
>>> from qsarkit.data_quality import DuplicateDetector
>>> groups = DuplicateDetector().find_duplicates(
... [demo_mols[0], demo_mols[0], demo_mols[1]])
>>> len(groups), groups[0].indices
(1, [0, 1])
With activities attached, a group also reports whether its measurements agree. A duplicate pair two log units apart is not a duplicate to be merged — it is a data problem to be investigated:
>>> groups = DuplicateDetector(activity_tolerance=0.5).find_duplicates(
... [demo_mols[0], demo_mols[0]], [5.1, 8.4])
>>> round(groups[0].spread, 2), groups[0].consistent
(3.3, False)
Outliers¶
>>> import numpy as np
>>> from qsarkit.data_quality import ActivityOutlierDetector
>>> ActivityOutlierDetector().detect(np.array([5.0, 5.1, 5.2, 9.9])).tolist()
[False, False, False, True]
The default is the modified z-score, which uses the median and MAD rather than the mean and standard deviation. That matters here: a single extreme value inflates the standard deviation enough to mask itself, so a plain z-score is least reliable exactly when you need it.
Structure validation¶
>>> from rdkit import Chem
>>> from qsarkit.data_quality import StructureValidator
>>> mols = [Chem.MolFromSmiles(s) for s in ("CCO", "[Na+].[Cl-]", "O")]
>>> sorted({issue.code for issue in StructureValidator().validate(mols)})
['inorganic', 'mixture', 'no_carbon', 'too_small']
The charged check looks at net charge, so a zwitterion is not
mistaken for a record that escaped neutralization:
>>> validator = StructureValidator()
>>> [i.code for i in validator.validate([Chem.MolFromSmiles("[NH3+]CC(=O)[O-]")])]
[]
>>> [i.code for i in validator.validate([Chem.MolFromSmiles("CC(=O)[O-]")])]
['charged']
The whole pipeline¶
>>> from qsarkit.data_quality import DataCurationPipeline
>>> mols_in = demo_mols[:6] + [demo_mols[0]] # one deliberate duplicate
>>> activities = list(DEMO_Y[:6]) + [5.1]
>>> mols, y, report = DataCurationPipeline().run(mols_in, activities)
>>> len(mols), len(y)
(6, 6)
>>> print(report.summary())
Curation: 7 -> 6 records (85.7% retained)
standardize 7 -> 7 (0 removed)
validate 7 -> 7 (0 removed)
deduplicate 7 -> 6 (1 removed)
The report is the audit trail OECD Principle 2 asks for — every removal, attributed to the stage that made it:
>>> report.n_input, report.n_output, round(report.retention, 3)
(7, 6, 0.857)
>>> frame = report.to_dataframe()
>>> len(frame) >= 1
True
Unit checking¶
>>> from qsarkit.data_quality import check_activity_units
>>> report = check_activity_units([5.1, 6.2, 7.3], endpoint="IC50")
>>> report["looks_logarithmic"], report["warnings"]
(True, [])
A raw nanomolar column spanning six decades is caught:
>>> report = check_activity_units([1.0, 10.0, 1000.0, 1e6], unit="nM")
>>> report["looks_logarithmic"], round(report["log_range"], 1)
(False, 6.0)
>>> print(report["warnings"][0])
Values span 6.0 orders of magnitude, which suggests a raw concentration scale. Convert to pActivity (-log10 molar) before modeling.
So is the subtler case: molar values spanning little numeric range. They look narrow, but a pActivity of 1e-9 would mean an IC50 near 1 M:
>>> report = check_activity_units([1e-9, 5e-8], endpoint="IC50")
>>> report["looks_logarithmic"]
False
Mixing molar and p-scale values in one column produces a model that learns nothing, and nothing in the numbers themselves announces the mistake — which is why this check exists.
API¶
Dataset curation: duplicates, structural validity and activity sanity.
Curation changes QSAR model performance more than the choice of algorithm does. This module implements the protocol of Fourches, Muratov and Tropsha, and records what it removed so the result is auditable.
Examples
>>> from rdkit import Chem
>>> from qsarkit.data_quality import DataCurationPipeline
>>> mols = [Chem.MolFromSmiles(s) for s in ("CCO", "OCC", "[Na+].[Cl-]")]
>>> curated, y, report = DataCurationPipeline().run(mols, [5.0, 5.2, 1.0])
>>> report.n_output < report.n_input
True
References
Fourches, D., Muratov, E. & Tropsha, A. (2010). “Trust, But Verify: On the Importance of Chemical Structure Curation in Cheminformatics and QSAR Modeling Research.” J. Chem. Inf. Model., 50(7), 1189-1204. https://doi.org/10.1021/ci100176x
Fourches, D., Muratov, E. & Tropsha, A. (2016). “Trust, but Verify II.” J. Chem. Inf. Model., 56(7), 1243-1252. https://doi.org/10.1021/acs.jcim.6b00129
Tropsha, A. (2010). “Best Practices for QSAR Model Development, Validation, and Exploitation.” Mol. Inform., 29(6-7), 476-488. https://doi.org/10.1002/minf.201000061
- class qsarkit.data_quality.DuplicateDetector(level='inchikey', activity_tolerance=1.0)[source]¶
Bases:
objectFind duplicate structures and check whether their activities agree.
Duplicates are endemic in public activity data: the same compound is re-measured across papers, deposited under different salt forms, or drawn with different tautomers. Removing them blindly discards replicate information; keeping them inflates cross-validated performance, because the same structure lands in both the training and test folds.
The useful question is not “are these duplicates?” but “do the duplicates agree?” A pair reported as 5 nM and 50 uM is not a replicate — it is a data error, an assay difference, or a wrong structure, and averaging the two produces a value that describes neither.
- Parameters:
level (
Literal['inchikey','smiles','connectivity','scaffold']) – What counts as the same structure."connectivity"ignores stereochemistry and charge;"scaffold"collapses whole Bemis-Murcko series and is a deliberately blunt instrument.activity_tolerance (
float) – Maximum activity spread, in the units supplied, for a duplicate group to be called consistent. On a log scale 1.0 means a ten-fold disagreement.
Examples
>>> from rdkit import Chem >>> mols = [Chem.MolFromSmiles(s) for s in ("CCO", "OCC", "c1ccccc1")] >>> groups = DuplicateDetector().find_duplicates(mols, [5.0, 5.2, 7.0]) >>> len(groups) 1 >>> groups[0].indices [0, 1]
References
Fourches, D., Muratov, E. & Tropsha, A. (2010). “Trust, But Verify: On the Importance of Chemical Structure Curation in Cheminformatics and QSAR Modeling Research.” J. Chem. Inf. Model., 50(7), 1189-1204. https://doi.org/10.1021/ci100176x
Fourches, D., Muratov, E. & Tropsha, A. (2016). “Trust, but Verify II: A Practical Guide to Chemogenomics Data Curation.” J. Chem. Inf. Model., 56(7), 1243-1252. https://doi.org/10.1021/acs.jcim.6b00129
Heller, S. R. et al. (2015). “InChI, the IUPAC International Chemical Identifier.” J. Cheminform., 7, 23. https://doi.org/10.1186/s13321-015-0068-4
Kramer, C. et al. (2012). “The Experimental Uncertainty of Heterogeneous Public Ki Data.” J. Med. Chem., 55(11), 5165-5173. https://doi.org/10.1021/jm300131x
- find_duplicates(mols, activities=None)[source]¶
Group records that share a structure.
- Parameters:
mols (
Sequence[Any]) – Molecules to check.Noneentries are skipped.activities (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str],None]) – Parallel activity values. When given, each group reports its spread and whether it is consistent.
- Returns:
Only groups with two or more members, ordered by first appearance.
- Return type:
- report(mols, activities=None)[source]¶
Summarize the duplicate content of a dataset.
- Parameters:
- Returns:
n_records,n_unique,n_duplicate_groups,n_duplicate_records(records that are not the first of their group),duplicate_fraction,n_inconsistent(groups whose activities disagree beyond tolerance) andmax_spread.- Return type:
- to_dataframe(groups)[source]¶
Render duplicate groups as a table.
- Parameters:
groups (
Sequence[DuplicateGroup])- Returns:
Columns
key,n_records,indices,activities,spread,consistent.- Return type:
- class qsarkit.data_quality.DuplicateGroup(key, indices, activities=<factory>, spread=0.0, consistent=True)[source]¶
Bases:
objectOne set of records judged to be the same structure.
- Variables:
key (
str) – The identity key the members share.indices (
listofint) – Positions of the members in the input sequence.activities (
listoffloat) – Their activity values, where supplied.spread (
float) –max - minof the activities;0.0when fewer than two are known.consistent (
bool) – Whetherspreadis within the detector’s tolerance.
- qsarkit.data_quality.merge_replicates(mols, activities, level='inchikey', method='median', max_spread=1.0, log_scale=True)[source]¶
Collapse replicate measurements into one value per structure.
- Parameters:
activities (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str]]) – Activity values, one per record.level (
Literal['inchikey','smiles','connectivity','scaffold']) – Identity level used to group replicates.method (
Literal['mean','median','geometric_mean','min','max']) – How to combine agreeing replicates. The median is the safer default: activity data carries occasional order-of-magnitude transcription errors, and one of those moves a mean far more than it moves a median.max_spread (
Optional[float]) – Discard groups disagreeing by more than this.Nonekeeps all.log_scale (
bool) – Whetheractivitiesare already logarithmic (pIC50 and the like).geometric_meanrequires linear, positive values, so it is refused when this is True — averaging log values arithmetically already is the geometric mean.
- Return type:
Tuple[List[Any],ndarray[tuple[Any,...],dtype[double]],Dict[str,Any]]- Returns:
Examples
>>> from rdkit import Chem >>> mols = [Chem.MolFromSmiles(s) for s in ("CCO", "OCC", "c1ccccc1")] >>> merged, y, report = merge_replicates(mols, [5.0, 5.4, 7.0]) >>> len(merged), report["n_merged"] (2, 1)
References
Fourches, D., Muratov, E. & Tropsha, A. (2010). J. Chem. Inf. Model., 50(7), 1189-1204. https://doi.org/10.1021/ci100176x
Kramer, C. et al. (2012). “The Experimental Uncertainty of Heterogeneous Public Ki Data.” J. Med. Chem., 55(11), 5165-5173. https://doi.org/10.1021/jm300131x
Kalliokoski, T. et al. (2013). “Comparability of Mixed IC50 Data.” PLoS ONE, 8(4), e61007. https://doi.org/10.1371/journal.pone.0061007
- class qsarkit.data_quality.StructureValidator(allow_inorganic=False, allow_mixtures=False, allow_isotopes=False, min_heavy_atoms=3, max_heavy_atoms=150, require_carbon=True)[source]¶
Bases:
objectFlag records that are not usable molecules for QSAR modeling.
Runs after standardization and before modeling. Each check corresponds to a category of record that routinely appears in public datasets and quietly degrades a model: unparseable structures, mixtures whose activity cannot be attributed to one component, inorganics and organometallics outside the applicability of organic descriptors, isotopically labelled tracers, and molecules far outside the size range the descriptors were designed for.
- Parameters:
allow_inorganic (
bool) – Keep molecules containing elements outside common organic chemistry.allow_mixtures (
bool) – Keep multi-component records. These are usually salts that survived desalting, or genuine mixtures whose activity belongs to no single structure.allow_isotopes (
bool) – Keep isotopically labelled molecules.min_heavy_atoms (
int) – Smallest acceptable molecule. Fragments below this carry almost no descriptor signal.max_heavy_atoms (
int) – Largest acceptable molecule, excluding peptides and polymers that most descriptor sets were never calibrated on.require_carbon (
bool) – Require at least one carbon atom.
Examples
>>> from rdkit import Chem >>> mols = [Chem.MolFromSmiles(s) for s in ("CCO", "[Na+].[Cl-]", "O")] >>> validator = StructureValidator() >>> issues = validator.validate(mols) >>> sorted({i.code for i in issues}) ['inorganic', 'mixture', 'no_carbon', 'too_small']
Ethanol passes; sodium chloride is a mixture of inorganic ions and water is too small to carry descriptor signal.
The
chargedcheck looks at net charge, so a zwitterion is not mistaken for a record that escaped neutralization:>>> glycine = Chem.MolFromSmiles("[NH3+]CC(=O)[O-]") >>> [i.code for i in validator.validate([glycine])] [] >>> acetate = Chem.MolFromSmiles("CC(=O)[O-]") >>> [i.code for i in validator.validate([acetate])] ['charged']
References
Fourches, D., Muratov, E. & Tropsha, A. (2010). “Trust, But Verify.” J. Chem. Inf. Model., 50(7), 1189-1204. https://doi.org/10.1021/ci100176x
Fourches, D., Muratov, E. & Tropsha, A. (2016). “Trust, but Verify II.” J. Chem. Inf. Model., 56(7), 1243-1252. https://doi.org/10.1021/acs.jcim.6b00129
Young, D. et al. (2008). “Are the Chemical Structures in Your QSAR Correct?” QSAR Comb. Sci., 27(11-12), 1337-1345. https://doi.org/10.1002/qsar.200810084
- validate(mols)[source]¶
Check every molecule and return all issues found.
- Parameters:
- Returns:
Ordered by record index; a record may raise several issues.
- Return type:
- to_dataframe(issues)[source]¶
Render issues as a table.
- Parameters:
issues (
Sequence[ValidationIssue])- Returns:
Columns
index,code,message,fatal.- Return type:
- class qsarkit.data_quality.ValidationIssue(index, code, message, fatal=True)[source]¶
Bases:
objectOne problem found with one record.
- Variables:
- class qsarkit.data_quality.ActivityOutlierDetector(method='modified_zscore', threshold=3.5, n_neighbors=5)[source]¶
Bases:
objectFlag activity values that look like errors rather than chemistry.
Distinguishes two different things that both get called “outliers”:
Distributional outliers — values far from the rest of the dataset, found by z-score, modified z-score or the IQR rule.
Structure-activity outliers — values far from what the compound’s nearest structural neighbours would predict. These are the interesting ones, but note that a genuine activity cliff looks exactly like a data error from this angle, so use
qsarkit.sarto tell them apart before deleting anything.
- Parameters:
method (
Literal['zscore','modified_zscore','iqr','neighbor']) – Detection rule. The modified z-score uses the median and MAD, so a few extreme values cannot inflate the scale and hide themselves — which is exactly what happens with a plain z-score.threshold (
float) – Cutoff. 3.5 is the conventional modified-z-score limit; use ~3 for"zscore"and 1.5 for"iqr".n_neighbors (
int) – Neighbours used bymethod="neighbor".
Examples
>>> import numpy as np >>> y = np.concatenate([np.full(20, 5.0), [50.0]]) >>> detector = ActivityOutlierDetector() >>> bool(detector.detect(y)[-1]) True
References
Iglewicz, B. & Hoaglin, D. C. (1993). “How to Detect and Handle Outliers.” ASQC Quality Press. (modified z-score, MAD-based)
Tukey, J. W. (1977). “Exploratory Data Analysis.” Addison-Wesley. (the IQR rule)
Fourches, D., Muratov, E. & Tropsha, A. (2010). J. Chem. Inf. Model., 50(7), 1189-1204. https://doi.org/10.1021/ci100176x
Maggiora, G. M. (2006). “On Outliers and Activity Cliffs.” J. Chem. Inf. Model., 46(4), 1535. https://doi.org/10.1021/ci060117s
- qsarkit.data_quality.check_activity_units(activities, unit=None, endpoint=None)[source]¶
Sanity-check an activity column before modeling.
Catches the most common and most damaging data error in QSAR: a column that mixes units, or is on a linear concentration scale when the modeling assumes a logarithmic one. A dataset spanning six orders of magnitude in raw nM will be dominated by its largest values, and the resulting model is fitted almost entirely to the inactives.
- Parameters:
- Returns:
n,n_missing,n_non_positive,min,max,median,log_range(orders of magnitude spanned),looks_logarithmicandwarnings(a list of plain-language problems found).- Return type:
Examples
A raw nanomolar column spanning six decades is flagged:
>>> report = check_activity_units([1.0, 10.0, 1000.0, 1e6], unit="nM") >>> report["looks_logarithmic"] False >>> bool(report["warnings"]) True
A pActivity column is recognised and passes clean:
>>> report = check_activity_units([5.1, 6.2, 7.3], endpoint="IC50") >>> report["looks_logarithmic"], report["warnings"] (True, [])
Molar values spanning little range are not mistaken for a p-scale column, even though their numeric range is narrow – a pActivity of 1e-9 would mean an IC50 near 1 M:
>>> report = check_activity_units([1e-9, 5e-8], endpoint="IC50") >>> report["looks_logarithmic"] False >>> report["warnings"][0].startswith("All values are below 1") True
References
Fourches, D., Muratov, E. & Tropsha, A. (2016). “Trust, but Verify II.” J. Chem. Inf. Model., 56(7), 1243-1252. https://doi.org/10.1021/acs.jcim.6b00129
Kalliokoski, T. et al. (2013). “Comparability of Mixed IC50 Data.” PLoS ONE, 8(4), e61007. https://doi.org/10.1371/journal.pone.0061007
- class qsarkit.data_quality.DataCurationPipeline(standardize=True, validate_structures=True, remove_duplicates=True, remove_outliers=False, duplicate_level='inchikey', merge_method='median', max_spread=1.0, outlier_method='modified_zscore', outlier_threshold=3.5, validator=None)[source]¶
Bases:
objectStandardize, validate, de-duplicate and screen a QSAR dataset.
Runs the curation protocol of Fourches, Muratov and Tropsha in the order that matters: standardization first (so that duplicate detection sees comparable structures), then structural validation, then replicate merging, then activity outliers. Running de-duplication before standardization is the classic mistake — the same compound stored as a salt and as a free base will not be recognised as a duplicate, and both copies survive into the model.
Every stage records what it removed, so the output is auditable.
- Parameters:
standardize (
bool) – RunMolecularStandardizerfirst.validate_structures (
bool) – ApplyStructureValidator.remove_duplicates (
bool) – Merge replicate structures.remove_outliers (
bool) – Drop activity outliers. Off by default, because on a congeneric series a genuine activity cliff is indistinguishable from a data error by statistics alone — seeqsarkit.sarbefore enabling this.duplicate_level (
str) – Passed toDuplicateDetector.merge_method (
str) – Passed tomerge_replicates().max_spread (
Optional[float]) – Replicate groups disagreeing by more than this are discarded.outlier_method (
str) – Passed toActivityOutlierDetector.outlier_threshold (
float) – Passed toActivityOutlierDetector.validator (
Optional[StructureValidator]) – Custom validator.
Examples
>>> from rdkit import Chem >>> mols = [Chem.MolFromSmiles(s) for s in ... ("CC(=O)Oc1ccccc1C(=O)[O-].[Na+]", "CCO", "OCC", "[Na+].[Cl-]")] >>> curated, y, report = DataCurationPipeline().run(mols, [5.0, 6.0, 6.2, 1.0]) >>> report.n_input 4 >>> report.n_output < report.n_input True
References
Fourches, D., Muratov, E. & Tropsha, A. (2010). “Trust, But Verify: On the Importance of Chemical Structure Curation in Cheminformatics and QSAR Modeling Research.” J. Chem. Inf. Model., 50(7), 1189-1204. https://doi.org/10.1021/ci100176x
Fourches, D., Muratov, E. & Tropsha, A. (2016). “Trust, but Verify II: A Practical Guide to Chemogenomics Data Curation.” J. Chem. Inf. Model., 56(7), 1243-1252. https://doi.org/10.1021/acs.jcim.6b00129
Tropsha, A. (2010). “Best Practices for QSAR Model Development, Validation, and Exploitation.” Mol. Inform., 29(6-7), 476-488. https://doi.org/10.1002/minf.201000061
OECD (2007). Guidance Document No. 69, ENV/JM/MONO(2007)2. https://doi.org/10.1787/9789264085442-en
- run(mols, activities=None, unit=None, endpoint=None)[source]¶
Curate a dataset.
- Parameters:
- Return type:
Tuple[List[Any],Optional[ndarray[tuple[Any,...],dtype[double]]],CurationReport]- Returns:
mols (
listofMol) – The curated molecules.activities (
ndarrayorNone) – The curated activities.report (
CurationReport) – What happened at each stage.
- class qsarkit.data_quality.CurationReport(n_input=0, n_output=0, stages=<factory>, removed=<factory>, activity_check=<factory>, warnings=<factory>)[source]¶
Bases:
objectWhat curation did, and why.
A curated dataset is only trustworthy if the curation is auditable — OECD principle 1 asks for a defined endpoint and a documented dataset, and “we cleaned it” is not documentation.
- Variables:
n_input (
int) – Records supplied.n_output (
int) – Records surviving.stages (
listofdict) – One entry per stage: its name, the counts before and after, and the indices it removed.removed (
dict) – Original index -> reason it was removed.activity_check (
dict) – Output ofcheck_activity_units().warnings (
listofstr) – Problems worth a human’s attention.
References¶
Fourches, D., Muratov, E. & Tropsha, A. (2010). “Trust, But Verify: On the Importance of Chemical Structure Curation in Cheminformatics and QSAR Modeling Research.” J. Chem. Inf. Model., 50(7), 1189-1204. doi:10.1021/ci100176x
Fourches, D., Muratov, E. & Tropsha, A. (2016). “Trust, but Verify II.” J. Chem. Inf. Model., 56(7), 1243-1252. doi:10.1021/acs.jcim.6b00129
Kalliokoski, T. et al. (2013). “Comparability of Mixed IC50 Data.” PLoS ONE, 8(4), e61007. doi:10.1371/journal.pone.0061007