Explainability¶
SHAP, LIME, permutation importance, per-atom contribution maps, fragment analysis, partial dependence and counterfactuals.
For a QSAR model the interesting question is not “which feature index mattered” but “which part of this molecule mattered”, which is what the atom-level tools answer.
Permutation importance¶
Model-agnostic, and the only importance measure here that reflects the model’s actual predictive reliance rather than its internal structure:
>>> from qsarkit.explainability import PermutationImportance
>>> from qsarkit.models import QSARRegressor
>>> X, y = demo_fingerprints(256), DEMO_Y
>>> model = QSARRegressor("rf", random_state=0).fit(X, y)
>>> importance = PermutationImportance(n_repeats=5, random_state=0).fit(model, X, y)
>>> importance.importances_mean_.shape
(256,)
Measure it on held-out data. Permuting a feature on the training set reports how much the model memorized through it, which is not the same quantity and is usually larger.
Per-atom contributions¶
The chemist-facing explanation: mask each atom in turn, re-predict, and attribute the change to that atom.
>>> from qsarkit.explainability import AtomicContributionMap
>>> from qsarkit.representation import MorganFingerprint
>>> explainer = AtomicContributionMap(model, MorganFingerprint(n_bits=256))
>>> result = explainer.explain(demo_mols[0])
>>> result.weights.shape
(9,)
One weight per heavy atom, ready for RDKit’s similarity maps.
Note
Masking one atom of a symmetric ring changes little, because the remaining atoms set the same bits. That is not a defect of the method — it is a true statement about the model: no single one of those atoms is necessary, because the others are redundant with it. Read flat weights across a ring as “this ring matters as a unit”.
SHAP and LIME¶
Both need the explainability extra:
from qsarkit.explainability import LIMEExplainer, SHAPExplainer
shap_values = SHAPExplainer(model).explain(X) # needs qsarkit-learn[explainability]
SHAP’s TreeExplainer is exact and fast for tree ensembles;
KernelExplainer is model-agnostic and slow enough that you will want
to subsample. LIME fits a local surrogate, which makes it cheap and
local-only — a LIME explanation says nothing about a different molecule.
Fragments and partial dependence¶
>>> from qsarkit.explainability import FragmentContributionAnalyzer, PartialDependence
>>> analyzer = FragmentContributionAnalyzer(model, MorganFingerprint(n_bits=256))
>>> hasattr(analyzer, "analyze")
True
>>> pd = PartialDependence(model, grid_resolution=10)
>>> hasattr(pd, "compute")
True
Fragment contributions aggregate atom-level attributions over chemically meaningful groups, which is usually more actionable than either the atom or the bit.
API¶
Model interpretation – OECD validation principle 5.
Two families, answering different questions:
Feature attribution (
SHAPExplainer,PermutationImportance,LIMEExplainer,PartialDependence) works on the descriptor matrix and says which columns the model uses.Structural attribution (
AtomicContributionMap,FragmentContributionAnalyzer,CounterfactualExplainer) maps the explanation back onto the molecule, which is what makes it actionable for a chemist.
For a fingerprint model the second family is the one that matters: 2048 anonymous bits are not an explanation, but “the nitro group costs you a log unit” is.
Examples
>>> from rdkit import Chem
>>> from sklearn.ensemble import RandomForestRegressor
>>> from qsarkit.explainability import AtomicContributionMap
>>> from qsarkit.representation import MorganFingerprint
>>> fp = MorganFingerprint(n_bits=64)
>>> mols = [Chem.MolFromSmiles(s) for s in ("CCO", "CCN", "c1ccccc1", "CCC")]
>>> model = RandomForestRegressor(n_estimators=5, random_state=0).fit(
... fp.transform(mols), [1.0, 2.0, 3.0, 4.0]
... )
>>> AtomicContributionMap(model, fp).explain(mols[0]).weights.shape
(3,)
References
Lundberg, S. M. & Lee, S.-I. (2017). “A Unified Approach to Interpreting Model Predictions.” NeurIPS 2017. https://arxiv.org/abs/1705.07874
Riniker, S. & Landrum, G. A. (2013). “Similarity Maps - A Visualization Strategy for Molecular Fingerprints and Machine-Learning Methods.” J. Cheminform., 5, 43. https://doi.org/10.1186/1758-2946-5-43
Ribeiro, M. T., Singh, S. & Guestrin, C. (2016). “Why Should I Trust You?” KDD 2016, 1135-1144. https://doi.org/10.1145/2939672.2939778
OECD (2007). Guidance Document No. 69, ENV/JM/MONO(2007)2. https://doi.org/10.1787/9789264085442-en
- class qsarkit.explainability.AttributionAtomMapper(fingerprint, distribution='uniform')[source]¶
Bases:
objectTurn a SHAP or LIME explainer’s output into atom-level weights.
Wraps any per-feature attribution and the fingerprint that produced those features, so a model explanation can be shown on the structure rather than as bit indices.
- Parameters:
fingerprint (
Any) – The transformer whose bits the attributions refer to. Itsradius,n_bitsand (where present)use_featuresattributes are read so the bit numbering matches – typically aMorganFingerprint.distribution (
Literal['uniform','center','radius_weighted']) – Passed tobit_weights_to_atom_weights().
- Variables:
Examples
>>> import numpy as np >>> from qsarkit.explainability import AttributionAtomMapper >>> from qsarkit.models import QSARRegressor >>> from qsarkit.representation import MorganFingerprint >>> from rdkit import Chem >>> mols = [Chem.MolFromSmiles(s) for s in ... ("CC(=O)Nc1ccccc1", "CC(=O)Nc1ccc(Cl)cc1", "CCO", "CCN")] >>> fingerprint = MorganFingerprint(radius=2, n_bits=256) >>> X = fingerprint.transform(mols) >>> model = QSARRegressor("rf", random_state=0).fit(X, [6.2, 6.7, 5.0, 5.1]) >>> mapper = AttributionAtomMapper(fingerprint)
Any per-bit vector maps onto atoms. Here we use permutation importance, which works for every backend and needs no optional dependency:
>>> from qsarkit.explainability import PermutationImportance >>> importance = PermutationImportance(n_repeats=2, random_state=0).fit( ... model, X, [6.2, 6.7, 5.0, 5.1]) >>> weights = mapper.atom_weights(mols[1], importance.importances_mean_) >>> weights.shape == (mols[1].GetNumAtoms(),) True
Symmetry-equivalent atoms receive identical weight, which is a useful correctness check on the mapping:
>>> chlorobenzene = Chem.MolFromSmiles("Clc1ccccc1") >>> import numpy as np >>> bits = np.ones(256) >>> symmetric = mapper.atom_weights(chlorobenzene, bits) >>> bool(np.isclose(symmetric[2], symmetric[6])) True
The collision rate says how much to trust the picture:
>>> rate = mapper.collision_rate(mols[1]) >>> 0.0 <= rate <= 1.0 True
References
Riniker, S. & Landrum, G. A. (2013). J. Cheminform., 5, 43. https://doi.org/10.1186/1758-2946-5-43
Polishchuk, P. (2017). J. Chem. Inf. Model., 57(11), 2618-2639. https://doi.org/10.1021/acs.jcim.7b00274
- from_shap(mol, explainer, X, index=0)[source]¶
Atom weights from a
SHAPExplainer.- Parameters:
mol (
Any) – The molecule corresponding to rowindexofX.explainer (
Any) – A fitted explainer.X (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str]]) – The feature matrix the explanation is computed on.index (
int) – Which row ofXto explain.
- Return type:
- Raises:
OptionalDependencyError – If
shapis not installed.
- qsarkit.explainability.bit_atom_environments(mol, radius=2, n_bits=2048, use_features=False)[source]¶
Which atom environments set each bit of a molecule’s Morgan fingerprint.
- Parameters:
- Returns:
Bit index -> tuple of
(central_atom_index, radius)pairs, one per environment in this molecule that sets that bit.- Return type:
Examples
>>> from rdkit import Chem >>> from qsarkit.explainability import bit_atom_environments >>> mol = Chem.MolFromSmiles("CCO") >>> environments = bit_atom_environments(mol, n_bits=256) >>> len(environments) > 0 True >>> all(isinstance(bit, int) for bit in environments) True
Every reported central atom is a real atom of the molecule:
>>> centres = {atom for envs in environments.values() for atom, _ in envs} >>> max(centres) < mol.GetNumAtoms() True
References
RDKit, “Explaining bits from Morgan fingerprints”: https://www.rdkit.org/docs/GettingStartedInPython.html#explaining-bits-from-morgan-fingerprints
- qsarkit.explainability.bit_weights_to_atom_weights(mol, bit_weights, radius=2, n_bits=2048, use_features=False, distribution='uniform')[source]¶
Spread per-bit attributions over the atoms that produced each bit.
- Parameters:
mol (
Any) – The molecule the attributions were computed for.bit_weights (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str]]) – One attribution per fingerprint bit – a row of SHAP values, a LIME coefficient vector, or any per-feature importance.radius (
int) – Morgan radius used to computebit_weights.n_bits (
int) – Fingerprint length used to computebit_weights.use_features (
bool) – Whether those were FCFP rather than ECFP bits.distribution (
Literal['uniform','center','radius_weighted']) –How a bit’s weight is divided among its environment’s atoms:
"uniform"Split equally. Neutral, and the right default.
"center"All of it to the central atom. Sharper pictures, but it overstates the centre of a large environment.
"radius_weighted"Split equally, then divide by
radius + 1, so a bit describing a tight environment carries more weight per atom than one describing a diffuse environment.
- Returns:
Per-atom weight, ready for
draw_atom_weights().- Return type:
- Raises:
ValueError – If
bit_weightsis not one-dimensional of lengthn_bits.
Examples
>>> import numpy as np >>> from rdkit import Chem >>> from qsarkit.explainability import ( ... bit_atom_environments, bit_weights_to_atom_weights) >>> mol = Chem.MolFromSmiles("CC(=O)Nc1ccc(Cl)cc1") >>> weights = np.zeros(256) >>> environments = bit_atom_environments(mol, n_bits=256) >>> chlorine = [a.GetIdx() for a in mol.GetAtoms() if a.GetSymbol() == "Cl"][0] >>> # Attribute to one bit centred on the chlorine, radius 0. >>> for bit, envs in environments.items(): ... if (chlorine, 0) in envs: ... weights[bit] = 1.0 >>> atom_weights = bit_weights_to_atom_weights(mol, weights, n_bits=256) >>> int(np.argmax(atom_weights)) == chlorine True
Only the atoms in that environment receive weight:
>>> int((atom_weights != 0).sum()) 1
References
Riniker, S. & Landrum, G. A. (2013). J. Cheminform., 5, 43. https://doi.org/10.1186/1758-2946-5-43
- qsarkit.explainability.draw_atom_weights(mol, atom_weights, size=(400, 400), fmt='svg', normalize=True, contour_lines=10)[source]¶
Render atom weights on the structure as an RDKit similarity map.
The standard cheminformatics depiction: a green-to-pink field over the 2D structure, positive contributions in one colour and negative in the other.
- Parameters:
mol (
Any) – The molecule. A 2D conformer is computed if it has none.atom_weights (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str]]) – Per-atom weight, e.g. fromAttributionAtomMapper.fmt (
Literal['svg','png']) –"svg"returns a string, which renders inline in a notebook and embeds in HTML."png"returns bytes.normalize (
bool) – Scale the weights so the largest absolute value maps to the end of the colour scale. Keeps the picture readable regardless of the attribution’s units; turn it off to compare two molecules on one absolute scale.contour_lines (
int) – Number of contour lines drawn.
- Returns:
SVG text, or PNG bytes.
- Return type:
- Raises:
ValueError – If
atom_weightsdoes not have one entry per atom, orfmtis not recognized.
Examples
>>> import numpy as np >>> from rdkit import Chem >>> from qsarkit.explainability import draw_atom_weights >>> mol = Chem.MolFromSmiles("CC(=O)Nc1ccc(Cl)cc1") >>> weights = np.linspace(-1, 1, mol.GetNumAtoms()) >>> svg = draw_atom_weights(mol, weights) >>> svg.lstrip().startswith("<?xml") or svg.lstrip().startswith("<svg") True >>> isinstance(draw_atom_weights(mol, weights, fmt="png"), bytes) True
A weight per atom is required, so a mismatched vector is caught rather than silently misaligned:
>>> draw_atom_weights(mol, [0.1, 0.2]) Traceback (most recent call last): ... ValueError: atom_weights has 2 entries but the molecule has 11 atoms.
References
Riniker, S. & Landrum, G. A. (2013). “Similarity Maps – A Visualization Strategy for Molecular Fingerprints and Machine-Learning Methods.” J. Cheminform., 5, 43. https://doi.org/10.1186/1758-2946-5-43
RDKit
Chem.Draw.SimilarityMapsdocumentation: https://www.rdkit.org/docs/source/rdkit.Chem.Draw.SimilarityMaps.html
- class qsarkit.explainability.PermutationImportance(n_repeats=10, scoring=None, random_state=None, n_jobs=None)[source]¶
Bases:
objectFeature importance by measuring the damage from shuffling a column.
Model-agnostic and honest: a feature matters exactly to the degree that destroying its relationship with the target degrades held-out performance. Unlike a tree model’s built-in
feature_importances_, which is computed on training data and is biased toward high-cardinality features, this is measured on data the model has not seen.The caveat worth knowing: with correlated descriptors — which is the normal situation in QSAR — permuting one of a correlated pair leaves the model able to recover the signal from its partner, so both look unimportant. Cluster correlated descriptors before interpreting, or read the result as “importance given the others are present”.
- Parameters:
- Variables:
importances_mean (
ndarray) – Mean drop in score per feature.importances_std (
ndarray) – Standard deviation across repeats.
Examples
>>> from sklearn.datasets import make_regression >>> from sklearn.ensemble import RandomForestRegressor >>> X, y = make_regression(n_samples=60, n_features=5, n_informative=2, ... random_state=0) >>> model = RandomForestRegressor(n_estimators=10, random_state=0).fit(X, y) >>> imp = PermutationImportance(random_state=0).fit(model, X, y) >>> imp.importances_mean_.shape (5,)
References
Breiman, L. (2001). “Random Forests.” Mach. Learn., 45, 5-32. https://doi.org/10.1023/A:1010933404324
Fisher, A., Rudin, C. & Dominici, F. (2019). “All Models Are Wrong, but Many Are Useful: Learning a Variable’s Importance.” J. Mach. Learn. Res., 20(177), 1-81. https://jmlr.org/papers/v20/18-760.html
Strobl, C. et al. (2008). “Conditional Variable Importance for Random Forests.” BMC Bioinformatics, 9, 307. https://doi.org/10.1186/1471-2105-9-307
scikit-learn permutation importance documentation: https://scikit-learn.org/stable/modules/permutation_importance.html
- fit(estimator, X, y, feature_names=None)[source]¶
Measure importance on the supplied (ideally held-out) data.
- Parameters:
estimator (
Any)X (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str]]) – Evaluation data. Use a test set, not the training set.y (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str]])
- Return type:
- class qsarkit.explainability.SHAPExplainer(model, explainer_type='auto', background=None, n_background=100, random_state=None)[source]¶
Bases:
objectSHAP values: the game-theoretic attribution of a prediction.
SHAP assigns each feature the payoff it contributes to a prediction, averaged over all orderings in which features could be added. That construction gives it the properties ad-hoc attributions lack — the contributions sum exactly to the prediction minus the base value (local accuracy), and a feature the model ignores always gets zero.
The explainer is chosen from the model type:
TreeExplainerfor forests and boosted trees (exact and fast),LinearExplainerfor linear models, andKernelExplainerotherwise (model-agnostic but slow, so it samples the background set).Requires the
explainabilityextra.- Parameters:
model (
Any)explainer_type (
Literal['auto','tree','linear','kernel']) – Which SHAP explainer to use.background (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str],None]) – Background dataset for the kernel/linear explainers. A sample of the training data; 100 rows is usually enough.n_background (
int) – How many background rows to sample whenbackgroundis a full training set.
Examples
>>> import pytest >>> shap = pytest.importorskip("shap")
References
Lundberg, S. M. & Lee, S.-I. (2017). “A Unified Approach to Interpreting Model Predictions.” NeurIPS 2017. https://arxiv.org/abs/1705.07874
Lundberg, S. M. et al. (2020). “From Local Explanations to Global Understanding with Explainable AI for Trees.” Nat. Mach. Intell., 2, 56-67. https://doi.org/10.1038/s42256-019-0138-9
Shapley, L. S. (1953). “A Value for n-Person Games.” Contributions to the Theory of Games, 2(28), 307-317. https://doi.org/10.1515/9781400881970-018
Rodriguez-Perez, R. & Bajorath, J. (2020). “Interpretation of Machine Learning Models Using Shapley Values.” J. Comput. Aided Mol. Des., 34, 1013-1026. https://doi.org/10.1007/s10822-020-00314-0
- global_importance(X, feature_names=None)[source]¶
Mean absolute SHAP value per feature — a global ranking.
- explain_one(x, feature_names=None, top_n=10)[source]¶
Per-feature contributions to a single prediction.
- Parameters:
- Returns:
Columns
feature,value,shap_value, ordered by absolute contribution.- Return type:
- class qsarkit.explainability.LIMEExplainer(model, training_data, feature_names=None, mode='regression', n_samples=5000, random_state=None)[source]¶
Bases:
objectLIME: explain one prediction with a local surrogate model.
Perturbs the molecule’s descriptors, records what the model predicts for each perturbation, and fits a sparse linear model to that local neighbourhood. The surrogate’s coefficients are the explanation.
Compared with SHAP, LIME is faster and easier to read but its explanations are not guaranteed to be self-consistent: the answer depends on the perturbation distribution and kernel width, and re-running can give a different story. Prefer SHAP where the attribution has to be defensible.
Requires the
explainabilityextra.- Parameters:
model (
Any)training_data (
Union[Buffer,_SupportsArray[dtype[Any]],_NestedSequence[_SupportsArray[dtype[Any]]],complex,bytes,str,_NestedSequence[complex|bytes|str]]) – Data defining the perturbation distribution.mode (
Literal['regression','classification'])n_samples (
int) – Perturbations per explanation.
Examples
>>> import pytest >>> lime = pytest.importorskip("lime")
References
Ribeiro, M. T., Singh, S. & Guestrin, C. (2016). “Why Should I Trust You?: Explaining the Predictions of Any Classifier.” KDD 2016, 1135-1144. https://doi.org/10.1145/2939672.2939778
Alvarez-Melis, D. & Jaakkola, T. S. (2018). “On the Robustness of Interpretability Methods.” arXiv:1806.08049. https://arxiv.org/abs/1806.08049
- class qsarkit.explainability.PartialDependence(model, grid_resolution=50)[source]¶
Bases:
objectPartial dependence: the model’s average response to one descriptor.
Sweeps a descriptor across its range, averaging the model’s prediction over the observed distribution of the others. The resulting curve shows the shape of the model’s dependence — whether logP acts linearly, saturates, or has an optimum — which a single importance number cannot express.
Its known blind spot is extrapolation: averaging over the marginal distribution evaluates the model at descriptor combinations that never occur (a molecule with MW 100 and 40 rotatable bonds), so read the curve only across the range where the data are dense.
Examples
>>> from sklearn.datasets import make_regression >>> from sklearn.ensemble import RandomForestRegressor >>> X, y = make_regression(n_samples=50, n_features=4, random_state=0) >>> model = RandomForestRegressor(n_estimators=5, random_state=0).fit(X, y) >>> grid, avg = PartialDependence(model).compute(X, feature=0) >>> grid.shape == avg.shape True
References
Friedman, J. H. (2001). “Greedy Function Approximation: A Gradient Boosting Machine.” Ann. Stat., 29(5), 1189-1232. https://doi.org/10.1214/aos/1013203451
Apley, D. W. & Zhu, J. (2020). “Visualizing the Effects of Predictor Variables in Black Box Supervised Learning Models.” J. R. Stat. Soc. B, 82(4), 1059-1086. https://doi.org/10.1111/rssb.12377
scikit-learn partial dependence documentation: https://scikit-learn.org/stable/modules/partial_dependence.html
- compute(X, feature)[source]¶
Partial-dependence curve for one feature.
- class qsarkit.explainability.AtomicContribution(weights, prediction, smiles='')[source]¶
Bases:
objectPer-atom attribution for one molecule.
- Variables:
- class qsarkit.explainability.AtomicContributionMap(model, fingerprint, use_proba=False)[source]¶
Bases:
objectAttribute a prediction to individual atoms by fingerprint masking.
Implements the similarity-map algorithm of Riniker and Landrum: for each atom, the fingerprint is recomputed with that atom removed from the environment, and the change in prediction is the atom’s contribution. Every bit set by an atom is switched off, so the difference measures exactly what that atom’s substructures were worth to the model.
This is what turns a fingerprint model — otherwise a black box over 2048 anonymous bits — into something a chemist can act on: the output maps directly onto the structure, showing which part of the molecule the model is actually responding to. It is the practical route to OECD principle 5 for fingerprint QSAR.
One property of the method is worth knowing: on a symmetric molecule every atom receives the same weight, and on a redundant one the weights are near zero. Masking a single carbon of benzene leaves the other five still generating aromatic bits, so the prediction barely moves and no atom looks responsible. That is the honest answer — the signal is carried by the ring as a whole, not by any one atom — but it means a flat weight vector indicates redundancy rather than irrelevance. Use
FragmentContributionAnalyzerwhen the question is about a group rather than an atom.- Parameters:
model (
Any) – Must exposepredict, andpredict_probaifuse_proba=True.fingerprint (
Any) – MapsIterable[Mol] -> ndarray. Anyqsarkit.representationfingerprint works.use_proba (
bool) – Explainpredict_proba(...)[:, 1]rather thanpredict, which gives a smoothly varying signal for classifiers instead of a step function.
Examples
>>> from rdkit import Chem >>> from sklearn.ensemble import RandomForestRegressor >>> from qsarkit.representation import MorganFingerprint >>> fp = MorganFingerprint(n_bits=64) >>> mols = [Chem.MolFromSmiles(s) for s in ("CCO", "CCN", "c1ccccc1", "CCC")] >>> model = RandomForestRegressor(n_estimators=5, random_state=0).fit( ... fp.transform(mols), [1.0, 2.0, 3.0, 4.0] ... ) >>> result = AtomicContributionMap(model, fp).explain(mols[0]) >>> result.weights.shape (3,)
References
Riniker, S. & Landrum, G. A. (2013). “Similarity Maps - A Visualization Strategy for Molecular Fingerprints and Machine- Learning Methods.” J. Cheminform., 5, 43. https://doi.org/10.1186/1758-2946-5-43
Rogers, D. & Hahn, M. (2010). “Extended-Connectivity Fingerprints.” J. Chem. Inf. Model., 50(5), 742-754. https://doi.org/10.1021/ci100050t
RDKit
Chem.Draw.SimilarityMapsdocumentation: https://www.rdkit.org/docs/source/rdkit.Chem.Draw.SimilarityMaps.html
- explain(mol)[source]¶
Compute per-atom contributions for one molecule.
- Parameters:
mol (
Any)- Return type:
- Raises:
ValueError – If
molis None.
- class qsarkit.explainability.FragmentContributionAnalyzer(model, fingerprint, fragments=None, use_proba=False)[source]¶
Bases:
objectAttribute predictions to chemically meaningful fragments.
Per-atom weights answer “which atoms matter”; chemists think in groups. This aggregates atomic contributions over substructures — a supplied SMARTS list, or the molecule’s own BRICS fragments — so the output is “the nitro group contributes -1.2 log units” rather than a list of atom indices.
- Parameters:
Examples
>>> from rdkit import Chem >>> from sklearn.ensemble import RandomForestRegressor >>> from qsarkit.representation import MorganFingerprint >>> fp = MorganFingerprint(n_bits=64) >>> mols = [Chem.MolFromSmiles(s) for s in ("CCO", "CCN", "c1ccccc1O", "CCC")] >>> model = RandomForestRegressor(n_estimators=5, random_state=0).fit( ... fp.transform(mols), [1.0, 2.0, 3.0, 4.0] ... ) >>> analyzer = FragmentContributionAnalyzer( ... model, fp, fragments={"hydroxyl": "[OX2H]"} ... ) >>> df = analyzer.analyze(mols[0]) >>> "hydroxyl" in list(df["fragment"]) True
References
Riniker, S. & Landrum, G. A. (2013). J. Cheminform., 5, 43. https://doi.org/10.1186/1758-2946-5-43
Degen, J. et al. (2008). “On the Art of Compiling and Using ‘Drug-Like’ Chemical Fragment Spaces.” ChemMedChem, 3(10), 1503-1507. https://doi.org/10.1002/cmdc.200800178
Sheridan, R. P. (2019). “Interpretation of QSAR Models by Coloring Atoms According to Changes in Predicted Activity.” J. Chem. Inf. Model., 59(4), 1324-1337. https://doi.org/10.1021/acs.jcim.8b00825
- class qsarkit.explainability.CounterfactualExplainer(model, fingerprint, delta=1.0, min_similarity=0.4, use_proba=False)[source]¶
Bases:
objectExplain a prediction by finding the nearest molecule the model scores differently.
Answers the question a chemist actually asks — “what would I have to change to fix this?” — by searching a candidate set for the structurally closest molecule whose prediction differs by at least a given margin. Unlike an importance ranking, the result is an actionable, synthesizable alternative rather than an abstraction.
- Parameters:
model (
Any)fingerprint (
Any) – Used both for prediction and for the similarity search.delta (
float) – Minimum prediction difference for a molecule to count as a counterfactual. On a pActivity scale, 1.0 is a ten-fold change.min_similarity (
float) – Minimum Tanimoto similarity, so the counterfactual is a recognisable relative rather than an unrelated molecule.use_proba (
bool)
Examples
>>> from rdkit import Chem >>> from sklearn.ensemble import RandomForestRegressor >>> from qsarkit.representation import MorganFingerprint >>> fp = MorganFingerprint(n_bits=256) >>> library = [Chem.MolFromSmiles(s) for s in ... ("CCO", "CCN", "CCC", "c1ccccc1", "c1ccccc1O")] >>> model = RandomForestRegressor(n_estimators=5, random_state=0).fit( ... fp.transform(library), [1.0, 1.1, 1.2, 8.0, 8.1] ... ) >>> explainer = CounterfactualExplainer(model, fp, delta=2.0, ... min_similarity=0.0) >>> result = explainer.explain(library[0], library) >>> result is None or "counterfactual" in result True
References
Wachter, S., Mittelstadt, B. & Russell, C. (2018). “Counterfactual Explanations without Opening the Black Box.” Harvard J. Law & Tech., 31(2), 841-887. https://doi.org/10.2139/ssrn.3063289
Wellawatte, G. P., Seshadri, A. & White, A. D. (2022). “Model Agnostic Generation of Counterfactual Explanations for Molecules.” Chem. Sci., 13, 3697-3705. https://doi.org/10.1039/D1SC05259D
Hussain, J. & Rea, C. (2010). “Computationally Efficient Algorithm to Identify Matched Molecular Pairs.” J. Chem. Inf. Model., 50(3), 339-348. https://doi.org/10.1021/ci900450m
- explain(mol, candidates)[source]¶
Find the most similar candidate the model scores differently.
- Parameters:
- Returns:
counterfactual(Mol),smiles,similarity,prediction,original_prediction,delta, andtransformation(the matched-pair change, when the two form a matched molecular pair).Nonewhen no candidate meets both thresholds.- Return type:
References¶
Lundberg, S. M. & Lee, S.-I. (2017). “A Unified Approach to Interpreting Model Predictions.” NeurIPS 2017, 4765-4774. https://papers.nips.cc/paper/7062
Ribeiro, M. T., Singh, S. & Guestrin, C. (2016). “Why Should I Trust You?” KDD 2016, 1135-1144. doi:10.1145/2939672.2939778
Breiman, L. (2001). “Random Forests.” Machine Learning, 45(1), 5-32. doi:10.1023/A:1010933404324
Riniker, S. & Landrum, G. A. (2013). “Similarity Maps — A Visualization Strategy for Molecular Fingerprints and Machine-Learning Methods.” J. Cheminform., 5, 43. doi:10.1186/1758-2946-5-43
Polishchuk, P. et al. (2016). “Universal Approach for Structural Interpretation of QSAR/QSPR Models.” Mol. Inform., 32(9-10), 843-853. doi:10.1002/minf.201300029