Reporting

QSAR reports in Markdown, HTML and JSON, QMRF-style OECD reporting, and the Plotly figures that go in them.

Note

All plotting returns plotly.graph_objects.Figure. Nothing here calls .show() or writes a file, so the same figure composes into a notebook, a dashboard and an HTML report. Static export (fig.write_image) needs kaleido — pip install qsarkit-learn[reporting].

Figures

>>> from qsarkit.models import QSARRegressor
>>> from qsarkit.reporting import plot_predicted_vs_observed, plot_residuals
>>> X, y = demo_fingerprints(256), DEMO_Y
>>> y_pred = QSARRegressor("rf", random_state=0).fit(X, y).predict(X)
>>> figure = plot_predicted_vs_observed(y, y_pred)
>>> type(figure).__name__
'Figure'
>>> type(plot_residuals(y, y_pred)).__name__
'Figure'

The Williams plot is the standard OECD-facing diagnostic, putting leverage against standardized residual so influential points and outliers are visible in one picture:

>>> import numpy as np
>>> from qsarkit.applicability import LeverageAD
>>> from qsarkit.reporting import plot_williams
>>> leverage = LeverageAD().fit(X).score_samples(X)
>>> type(plot_williams(leverage, y, y_pred)).__name__
'Figure'

Embedding figures in a page needs Plotly’s JavaScript exactly once, which figure_to_html() handles:

>>> from qsarkit.reporting import figure_to_html
>>> first = figure_to_html(figure)                       # includes plotly.js
>>> rest = figure_to_html(figure, include_plotlyjs=False)  # subsequent figures
>>> len(first) > len(rest)
True

Model reports

>>> from qsarkit.metrics import qsar_regression_report
>>> from qsarkit.reporting import QSARReport
>>> report = (
...     QSARReport(title="Demo model", endpoint="pIC50")
...     .add_dataset_section(n_compounds=24, n_train=18, n_test=6)
...     .add_model_section(QSARRegressor("rf"), descriptors="Morgan r=2, 256 bits")
...     .add_validation_section(qsar_regression_report(y, y_pred))
... )
>>> markdown = report.to_markdown()
>>> markdown.splitlines()[0]
'# Demo model'
>>> "pIC50" in markdown
True

Five output formats

The same report object renders every way you might need it, and each carries the tables:

>>> print(report.to_text(width=44).splitlines()[1])
Demo model
>>> report.to_html().startswith("<!DOCTYPE html>")
True
>>> set(report.to_dict()) >= {"title", "sections"}
True

Plain text for a terminal or an email; Markdown for a repository; HTML for sharing; JSON for a downstream system; PDF for a submission:

>>> import os, tempfile
>>> out = os.path.join(tempfile.mkdtemp(), "report.pdf")
>>> _ = report.to_pdf(out)
>>> os.path.getsize(out) > 0
True

to_pdf needs reportlab; embedding plots into PDF or Markdown additionally needs kaleido (pip install qsarkit-learn[reporting]).

Figures and tables

A section can carry prose, a property table, a DataFrame and figures, and all four survive into every format:

>>> import pandas as pd
>>> _ = report.add_section(
...     "Outliers",
...     content={"n_flagged": 2},
...     text="Two compounds sit outside the applicability domain.",
...     table=pd.DataFrame({"compound": ["a", "b"], "residual": [1.4, -1.9]}),
...     figures=[figure],
... )
>>> text = report.to_text()
>>> "n_flagged" in text and "residual" in text and "[figure:" in text
True

Nested values are flattened rather than printed as a Python repr, which matters because a validation report routinely contains one:

>>> from qsarkit.reporting import QSARReport
>>> small = QSARReport().add_section("S", {"criteria": {"passed": True, "r2": 0.95}})
>>> "passed=yes; r2=0.95" in small.to_text()
True

OECD reporting

OECDReportBuilder structures a report around the five validation principles:

>>> from qsarkit.reporting import OECDReportBuilder
>>> builder = OECDReportBuilder(title="QMRF for demo model", endpoint="pIC50")
>>> [p[0] for p in builder.PRINCIPLES]
[1, 2, 3, 4, 5]

Its most useful behaviour is refusing to be quiet about what you skipped:

>>> builder.add_evidence(1, True, {"endpoint": "pIC50, cell-based assay"})
...
<qsarkit.reporting...OECDReportBuilder object at ...>
>>> builder.unaddressed
[2, 3, 4, 5]

A submission fails review over a principle nobody noticed was missing, so the builder tracks them explicitly rather than letting an omission look like an absence of problems.

API

QSAR model reports in Markdown, HTML and JSON.

A model is reproducible only if the record says what data it was built on, how that data was curated, which descriptors and algorithm were used, how it was validated and where it applies. OECDReportBuilder structures that against the five OECD validation principles, and marks explicitly any principle that has not been addressed.

All plots return Plotly figures and embed directly in the HTML output.

Examples

>>> from qsarkit.reporting import QSARReport
>>> report = QSARReport(title="EGFR pIC50 model", endpoint="pIC50")
>>> _ = report.add_section("Dataset", {"n_compounds": 1200})
>>> "EGFR" in report.to_markdown()
True

References

qsarkit.reporting.plot_calibration_curve(y_true, y_prob, n_bins=10, strategy='uniform', title='Calibration (reliability diagram)')[source]

Reliability diagram: observed frequency against predicted probability.

A perfectly calibrated classifier lies on the diagonal. Above it the model is under-confident, below it over-confident. Bubble size shows how many compounds each point rests on, because a bin holding three compounds says very little.

Parameters:
Return type:

Figure

Examples

>>> import numpy as np
>>> from qsarkit.reporting import plot_calibration_curve
>>> rng = np.random.default_rng(0)
>>> p = rng.uniform(size=500)
>>> y = (rng.uniform(size=500) < p).astype(int)
>>> figure = plot_calibration_curve(y, p)
>>> type(figure).__name__
'Figure'

Examples

>>> from qsarkit.reporting import figure_to_html, plot_roc_curve
>>> import numpy as np
>>> figure = plot_roc_curve(np.array([0, 0, 1, 1]), np.array([0.1, 0.2, 0.8, 0.9]))
>>> html = figure_to_html(figure)
>>> "plotly" in html
True

Plotly’s JavaScript must be included exactly once per page, so pass include_plotlyjs=False for every figure after the first:

>>> first = figure_to_html(figure)
>>> rest = figure_to_html(figure, include_plotlyjs=False)
>>> len(first) > len(rest)
True

References

qsarkit.reporting.plot_qq(residuals, title='Normal Q-Q plot of residuals')[source]

Normal Q-Q plot, for checking the assumption behind every RMSE.

Points on the line mean normal residuals. An S-shape means heavy tails; a bend at one end means skew, usually from a few badly mispredicted compounds that \(R^2\) will not name.

Parameters:
Return type:

Figure

Examples

>>> import numpy as np
>>> from qsarkit.reporting import plot_qq
>>> rng = np.random.default_rng(0)
>>> figure = plot_qq(rng.normal(size=200))
>>> type(figure).__name__
'Figure'

References

qsarkit.reporting.plot_threshold_sweep(y_true, y_score, criteria=None, title='Criterion against decision threshold', pos_label=None)[source]

How each selection criterion varies with the decision threshold.

Shows what the conventional 0.5 cut costs, and whether the optimum is a sharp peak or a broad plateau – a broad one means the exact threshold hardly matters, which is worth knowing before tuning it.

Parameters:
Return type:

Figure

Raises:

ValueError – If a requested criterion is not produced by the sweep.

Examples

>>> import numpy as np
>>> from qsarkit.reporting import plot_threshold_sweep
>>> rng = np.random.default_rng(0)
>>> y = np.zeros(300, dtype=int); y[:30] = 1
>>> scores = rng.beta(2, 8, size=300) + y * 0.3
>>> figure = plot_threshold_sweep(y, scores)
>>> type(figure).__name__
'Figure'

References

qsarkit.reporting.plot_precision_recall(y_true, y_score, title='Precision-recall curve', pos_label=None)[source]

Precision-recall curve, with the base rate as the honest baseline.

Preferable to ROC on imbalanced data: ROC’s specificity axis is dominated by the inactive majority, so a model can look excellent while its top-ranked compounds are mostly false positives. The baseline here is the base rate, which is what random ranking achieves.

Parameters:
Return type:

Figure

Examples

>>> import numpy as np
>>> from qsarkit.reporting import plot_precision_recall
>>> rng = np.random.default_rng(0)
>>> y = np.zeros(300, dtype=int); y[:30] = 1
>>> scores = rng.beta(2, 8, size=300) + y * 0.3
>>> figure = plot_precision_recall(y, scores)
>>> type(figure).__name__
'Figure'

References

qsarkit.reporting.plot_atom_contributions(mol, atom_weights, title='Per-atom contributions', size=(450, 450))[source]

Draw atom-level attributions on the structure, as an RDKit SVG.

A thin wrapper over draw_atom_weights(), provided here so a report can assemble every figure from one module. Unlike the other plotting functions this returns SVG text rather than a Plotly figure, because the depiction is a chemical drawing and RDKit draws those properly.

Parameters:
Returns:

SVG text, ready to embed in an HTML report or display in a notebook.

Return type:

str

Examples

>>> import numpy as np
>>> from rdkit import Chem
>>> from qsarkit.reporting import plot_atom_contributions
>>> mol = Chem.MolFromSmiles("CC(=O)Nc1ccc(Cl)cc1")
>>> svg = plot_atom_contributions(mol, np.linspace(-1, 1, mol.GetNumAtoms()))
>>> "<svg" in svg
True

References

class qsarkit.reporting.QSARReport(title='QSAR model report', author='', endpoint='')[source]

Bases: object

Assemble a complete, auditable report for a QSAR model.

A model is only reproducible if the record says what data it was built on, how that data was curated, which descriptors and algorithm were used, how it was validated and where it applies. This collects all of that into one object that renders to Markdown, HTML or JSON.

Sections are added in whatever order suits the model; the renderers preserve that order.

Parameters:
  • title (str) – Report title.

  • author (str) – Who built the model.

  • endpoint (str) – What is predicted, e.g. "pIC50 (CHEMBL204, IC50)". OECD principle 1 asks for exactly this.

Examples

>>> report = QSARReport(title="EGFR pIC50 model", endpoint="pIC50")
>>> _ = report.add_section("Dataset", {"n_compounds": 1200})
>>> "EGFR" in report.to_markdown()
True

References

  • OECD (2007). “Guidance Document on the Validation of (Quantitative) Structure-Activity Relationship [(Q)SAR] Models.” OECD Series on Testing and Assessment No. 69, ENV/JM/MONO(2007)2. https://doi.org/10.1787/9789264085442-en

  • 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

  • Patlewicz, G. et al. (2008). “An Evaluation of the Implementation of the OECD (Q)SAR Application Toolbox.” SAR QSAR Environ. Res., 19(5-6), 397-412. https://doi.org/10.1080/10629360802083848

add_section(title, content=None, text='', figures=None, table=None)[source]

Append a section.

Parameters:
  • title (str) – Section heading.

  • content (Optional[Dict[str, Any]]) – Key-value pairs rendered as a table.

  • text (str) – Prose placed above the table.

  • figures (Optional[Sequence[Any]]) – Plotly figures, embedded in HTML output only.

  • table (Any) – Tabular data rendered after the content.

Returns:

self, so calls can be chained.

Return type:

QSARReport

add_dataset_section(n_compounds, n_train=None, n_test=None, source='', curation=None)[source]

Add the dataset section, optionally including a curation log.

Parameters:
Return type:

QSARReport

add_model_section(model, descriptors='', hyperparameters=None)[source]

Add the algorithm section (OECD principle 2).

Parameters:
  • model (Any) – The fitted model; its class name and parameters are recorded.

  • descriptors (str) – Description of the representation used.

  • hyperparameters (Optional[Dict[str, Any]]) – Overrides what is read from the model.

Return type:

QSARReport

add_validation_section(metrics, y_scrambling=None, figures=None)[source]

Add the validation section (OECD principle 4).

Parameters:
  • metrics (Dict[str, Any]) – Goodness-of-fit, robustness and predictivity measures.

  • y_scrambling (Optional[Dict[str, Any]]) – Output of a y-randomization run, which is the evidence that the fit is not chance correlation.

  • figures (Optional[Sequence[Any]]) – Diagnostic plots.

Return type:

QSARReport

add_applicability_section(domain, coverage=None, report=None, figures=None)[source]

Add the applicability-domain section (OECD principle 3).

Parameters:
Return type:

QSARReport

to_dict()[source]

The whole report as a JSON-serializable dictionary.

Return type:

Dict[str, Any]

to_json(path=None, indent=2)[source]

Render as JSON, optionally writing it to path.

Parameters:
Returns:

The JSON text.

Return type:

str

to_markdown(path=None, include_figures=True)[source]

Render as Markdown, optionally writing it to path.

Parameters:
  • path (Union[str, Path, None]) – Write the Markdown here as well as returning it. When given, figures are written as PNG files in a sibling directory and linked relatively, which is what a Markdown file in a repository needs.

  • include_figures (bool) – Embed the report’s figures. Requires kaleido (pip install qsarkit-learn[reporting]); pass False for a text-and-tables document without it.

Returns:

The Markdown text.

Return type:

str

Raises:

OptionalDependencyError – If include_figures is set, the report has figures, and kaleido is missing.

to_html(path=None, include_plotlyjs='cdn')[source]

Render as a self-describing HTML document with embedded figures.

Assembled directly rather than through a template engine, so the core report has no optional dependency at all.

Parameters:
  • path (Union[str, Path, None])

  • include_plotlyjs (str) – "cdn" keeps the file small; True inlines Plotly so the report works with no network.

Returns:

The HTML document.

Return type:

str

to_text(path=None, width=78)[source]

Render as plain text, optionally writing it to path.

The format for a terminal, a log, or an email: no markup, ASCII tables, and figures listed by title rather than dropped silently.

Parameters:
  • path (Union[str, Path, None]) – Write the text here as well as returning it.

  • width (int) – Column width for rules and wrapping.

Returns:

The plain-text report.

Return type:

str

Examples

>>> from qsarkit.reporting import QSARReport
>>> report = QSARReport(title="Demo", endpoint="pIC50")
>>> _ = report.add_dataset_section(n_compounds=24, n_train=18, n_test=6)
>>> print(report.to_text(width=40))
========================================
Demo
========================================
Endpoint: pIC50
Created: ...

Dataset
----------------------------------------
  n_compounds  24
  n_train      18
  n_test       6
to_pdf(path, include_figures=True, page_size='A4')[source]

Render as a PDF with tables and embedded plots.

Parameters:
  • path (Union[str, Path]) – Output file. Unlike the other renderers this one is file-only: a PDF is binary and there is nothing useful to return as a string.

  • include_figures (bool) – Rasterize and embed the report’s Plotly figures. Requires kaleido (pip install qsarkit-learn[reporting]). Pass False to produce a tables-and-text PDF without it.

  • page_size (str)

Returns:

The path written.

Return type:

str

Raises:
  • OptionalDependencyError – If reportlab is missing, or if include_figures is set, the report has figures, and kaleido is missing. The second case raises rather than quietly dropping the plots: a report silently missing its evidence is worse than no report.

  • ValueError – If page_size is not recognized.

Examples

>>> import tempfile, os
>>> from qsarkit.reporting import QSARReport
>>> report = QSARReport(title="Demo", endpoint="pIC50")
>>> _ = report.add_dataset_section(n_compounds=24)
>>> out = os.path.join(tempfile.mkdtemp(), "report.pdf")
>>> _ = report.to_pdf(out)
>>> os.path.getsize(out) > 0
True

References

class qsarkit.reporting.ReportSection(title, content=<factory>, text='', figures=<factory>, table=None)[source]

Bases: object

One titled section of a report.

Variables:
  • title (str) – Section heading.

  • content (dict) – Key-value pairs rendered as a definition table.

  • text (str) – Free-form prose placed above the table.

  • figures (list) – Plotly figures embedded in the HTML rendering.

  • table (Any) – An optional pandas DataFrame rendered after the content.

title: str
content: Dict[str, Any]
text: str
figures: List[Any]
table: Any
to_dict()[source]

JSON-serializable form, excluding figures.

Return type:

Dict[str, Any]

class qsarkit.reporting.OECDReportBuilder(title='QMRF report', author='', endpoint='')[source]

Bases: object

Build a QMRF-style report against the five OECD principles.

The QSAR Model Reporting Format is the structure regulators expect, and its sections map onto the five validation principles. This assembles one from the artefacts the rest of the package produces, and — importantly — records explicitly when a principle has not been addressed, since a silent omission is what makes a submission fail review.

Parameters:
  • title (str) – Model name.

  • author (str) – Who built it.

  • endpoint (str) – The defined endpoint (principle 1).

Examples

>>> builder = OECDReportBuilder(title="EGFR model", endpoint="pIC50")
>>> report = builder.build()
>>> "OECD" in report.to_markdown()
True

Principles you have not addressed are tracked, and appear in the report as explicit gaps:

>>> builder.unaddressed
[1, 2, 3, 4, 5]
>>> _ = builder.add_evidence(1, True, {"endpoint": "pIC50, CHEMBL204"})
>>> builder.unaddressed
[2, 3, 4, 5]

build() returns a QSARReport, which renders to plain text, Markdown, HTML, JSON and PDF – tables and plots included:

>>> report = builder.build()
>>> print(report.to_text(width=48).splitlines()[1])
EGFR model
>>> report.to_html().startswith("<!DOCTYPE html>")
True
>>> sorted(report.to_dict())
['author', 'created', 'endpoint', 'sections', 'title']

References

PRINCIPLES
add_evidence(principle, addressed, details)[source]

Record how one principle was addressed.

Parameters:
  • principle (int) – 1 to 5.

  • addressed (bool) – Whether the principle is satisfied.

  • details (Dict[str, Any]) – Supporting values.

Returns:

self, so calls can be chained.

Return type:

OECDReportBuilder

from_validation(validation)[source]

Populate principles 3 and 4 from a validation report.

Parameters:

validation (Dict[str, Any]) – A mapping carrying validation metrics, e.g. from qsarkit.validation.

Return type:

OECDReportBuilder

build()[source]

Assemble the report.

Returns:

With one section per OECD principle, each marked addressed or not.

Return type:

QSARReport

property unaddressed: List[int]

Principles with no recorded evidence.

Returns:

The principle numbers a reviewer will ask about.

Return type:

list of int

qsarkit.reporting.plot_predicted_vs_observed(y_true, y_pred, title='Predicted vs observed', labels=None)[source]

Scatter predictions against observations, with the identity line.

The first plot to look at. A good model’s points hug the diagonal; systematic curvature, fanning, or a slope visibly different from 1 are all visible here and invisible in a single R² number.

Parameters:
Return type:

Figure

Examples

>>> import numpy as np
>>> fig = plot_predicted_vs_observed([1.0, 2.0, 3.0], [1.1, 1.9, 3.2])
>>> len(fig.data)
2

References

qsarkit.reporting.plot_residuals(y_true, y_pred, title='Residuals', standardized=True)[source]

Plot residuals against predicted values.

Where the predicted-vs-observed plot shows whether the model is right, this shows how it is wrong. Residuals should look like a structureless band around zero; a funnel means the error grows with potency, and a curve means a missing non-linear term.

Parameters:
Return type:

Figure

Examples

>>> import numpy as np
>>> from qsarkit.reporting import plot_residuals
>>> rng = np.random.default_rng(0)
>>> truth = rng.normal(size=50)
>>> figure = plot_residuals(truth, truth + rng.normal(scale=0.2, size=50))
>>> type(figure).__name__
'Figure'

Standardized residuals put the conventional +/-3 sigma lines on a fixed scale, so an outlier is visible without knowing the endpoint’s units:

>>> raw = plot_residuals(truth, truth, standardized=False)
>>> raw.layout.yaxis.title.text is not None
True

References

qsarkit.reporting.plot_williams(leverage, y_true, y_pred, h_star=None, residual_limit=3.0, title='Williams plot')[source]

Williams plot: standardized residuals against leverage.

The standard regulatory diagnostic, and the one plot that separates the two distinct ways a prediction can be untrustworthy. Points to the right of h* are structural outliers the model is extrapolating to; points outside +/-3 sigma are response outliers the model simply gets wrong. A point in both regions should not be reported at all.

Parameters:
Return type:

Figure

Examples

>>> import numpy as np
>>> from qsarkit.applicability import LeverageAD
>>> from qsarkit.reporting import plot_williams
>>> rng = np.random.default_rng(0)
>>> X = rng.normal(size=(40, 4))
>>> truth = X[:, 0] * 2 + rng.normal(scale=0.2, size=40)
>>> leverage = LeverageAD().fit(X).score_samples(X)
>>> figure = plot_williams(leverage, truth, truth + rng.normal(scale=0.2, size=40))
>>> type(figure).__name__
'Figure'

The two lines are what make it a Williams plot: h_star marks the leverage threshold and residual_limit the residual one, so a point beyond either is influential, an outlier, or both.

>>> figure = plot_williams(leverage, truth, truth, h_star=0.3, residual_limit=2.5)
>>> len(figure.layout.shapes) >= 2
True

References

qsarkit.reporting.plot_roc_curve(y_true, y_score, title='ROC curve')[source]

Receiver operating characteristic curve with its AUC.

Parameters:
Return type:

Figure

Examples

>>> import numpy as np
>>> from qsarkit.reporting import plot_roc_curve
>>> scores = np.linspace(1.0, 0.0, 100)
>>> labels = np.zeros(100); labels[:10] = 1     # actives ranked first
>>> figure = plot_roc_curve(labels, scores)
>>> type(figure).__name__
'Figure'
>>> figure.data[1].name                          # AUC shown in the legend
'ROC (AUC = 1.000)'

On an imbalanced screening set prefer plot_precision_recall(): ROC’s specificity axis is dominated by the inactive majority, so a model can look excellent while its top-ranked compounds are mostly false positives.

References

qsarkit.reporting.plot_learning_curve(train_sizes, train_scores, test_scores, title='Learning curve')[source]

Plot training and validation score against training-set size.

Answers whether more data would help. A gap that stays wide as the curves flatten means the model is over-fitting and needs regularization, not compounds; curves still rising means measuring more compounds is the better investment.

Parameters:
Return type:

Figure

Examples

>>> import numpy as np
>>> from qsarkit.reporting import plot_learning_curve
>>> sizes = np.array([10, 20, 40, 80])
>>> train = np.array([[0.99, 0.98], [0.97, 0.96], [0.95, 0.94], [0.93, 0.92]])
>>> test = np.array([[0.40, 0.45], [0.55, 0.58], [0.68, 0.70], [0.74, 0.75]])
>>> figure = plot_learning_curve(sizes, train, test)
>>> type(figure).__name__
'Figure'

Read the gap, not the level. A training score far above the validation score that stays apart as data is added means the model is memorizing; converging curves mean more data would help.

References

qsarkit.reporting.plot_feature_importance(names, importances, errors=None, top_n=20, title='Feature importance')[source]

Horizontal bar chart of the most important descriptors.

Parameters:
Return type:

Figure

Examples

>>> import numpy as np
>>> from qsarkit.reporting import plot_feature_importance
>>> names = ["MolWt", "MolLogP", "TPSA", "NumHDonors"]
>>> figure = plot_feature_importance(names, [0.4, 0.3, 0.2, 0.1])
>>> type(figure).__name__
'Figure'

Error bars turn a ranking into a claim you can judge. Two features whose intervals overlap are not distinguishable by this much data:

>>> figure = plot_feature_importance(
...     names, [0.4, 0.3, 0.2, 0.1], errors=[0.05, 0.08, 0.06, 0.03])
>>> len(figure.data)
1

top_n truncates a long list, which is the usual case with fingerprints:

>>> many = [f"bit_{i}" for i in range(500)]
>>> figure = plot_feature_importance(many, np.linspace(0, 1, 500), top_n=10)
>>> len(figure.data[0].y)
10

References

qsarkit.reporting.figure_to_html(figure, include_plotlyjs='cdn')[source]

Render a figure as an embeddable HTML fragment.

Parameters:
  • figure (Figure)

  • include_plotlyjs (Union[str, bool]) – Passed to Plotly. "cdn" keeps reports small; True inlines the library so the report works offline; False omits it, which is what every figure after the first in a document wants.

Returns:

An HTML <div> fragment.

Return type:

str

References

  • OECD (2007). “Guidance Document on the Validation of (Quantitative) Structure-Activity Relationship [(Q)SAR] Models,” ENV/JM/MONO(2007)2. doi:10.1787/9789264085442-en

  • European Chemicals Agency (2016). “Practical Guide: How to Use and Report (Q)SARs.” doi:10.2823/81818

  • Gramatica, P. (2007). “Principles of QSAR Models Validation: Internal and External.” QSAR Comb. Sci., 26(5), 694-701. doi:10.1002/qsar.200610151