"""QSAR model reports in Markdown, HTML and JSON, including QMRF format."""
from __future__ import annotations
import base64
import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union
import numpy as np
if TYPE_CHECKING: # pragma: no cover
import plotly.graph_objects as go
__all__ = ["ReportSection", "QSARReport", "OECDReportBuilder"]
PathLike = Union[str, Path]
def _jsonable(value: Any) -> Any:
"""Convert numpy and other non-JSON types into serializable ones."""
if isinstance(value, (np.integer,)):
return int(value)
if isinstance(value, (np.floating,)):
return None if not np.isfinite(value) else float(value)
if isinstance(value, np.ndarray):
return [_jsonable(v) for v in value.tolist()]
if isinstance(value, dict):
return {str(k): _jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_jsonable(v) for v in value]
if isinstance(value, float) and not np.isfinite(value):
return None
return value
[docs]
@dataclass
class ReportSection:
"""One titled section of a report.
Attributes
----------
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] = field(default_factory=dict)
text: str = ""
figures: List[Any] = field(default_factory=list)
table: Any = None
[docs]
def to_dict(self) -> Dict[str, Any]:
"""JSON-serializable form, excluding figures."""
payload: Dict[str, Any] = {
"title": self.title,
"content": _jsonable(self.content),
}
if self.text:
payload["text"] = self.text
if self.table is not None:
payload["table"] = _jsonable(self.table.to_dict(orient="records"))
return payload
[docs]
class QSARReport:
"""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
"""
def __init__(
self,
title: str = "QSAR model report",
author: str = "",
endpoint: str = "",
) -> None:
self.title = title
self.author = author
self.endpoint = endpoint
self.created = datetime.now(timezone.utc).isoformat(timespec="seconds")
self.sections: List[ReportSection] = []
[docs]
def add_section(
self,
title: str,
content: Optional[Dict[str, Any]] = None,
text: str = "",
figures: Optional[Sequence[Any]] = None,
table: Any = None,
) -> "QSARReport":
"""Append a section.
Parameters
----------
title : str
Section heading.
content : dict, optional
Key-value pairs rendered as a table.
text : str
Prose placed above the table.
figures : sequence, optional
Plotly figures, embedded in HTML output only.
table : pandas.DataFrame, optional
Tabular data rendered after the content.
Returns
-------
QSARReport
``self``, so calls can be chained.
"""
self.sections.append(
ReportSection(
title=title,
content=dict(content) if content else {},
text=text,
figures=list(figures) if figures else [],
table=table,
)
)
return self
[docs]
def add_dataset_section(
self,
n_compounds: int,
n_train: Optional[int] = None,
n_test: Optional[int] = None,
source: str = "",
curation: Optional[Any] = None,
) -> "QSARReport":
"""Add the dataset section, optionally including a curation log.
Parameters
----------
n_compounds : int
Compounds after curation.
n_train, n_test : int, optional
Split sizes.
source : str
Where the data came from.
curation : CurationReport, optional
Output of
:class:`~qsarkit.data_quality.DataCurationPipeline`, whose
per-stage counts are what make the dataset auditable.
Returns
-------
QSARReport
"""
content: Dict[str, Any] = {"n_compounds": n_compounds}
if n_train is not None:
content["n_train"] = n_train
if n_test is not None:
content["n_test"] = n_test
if source:
content["source"] = source
table = None
if curation is not None:
content["n_input_records"] = curation.n_input
content["n_removed"] = curation.n_removed
content["retention"] = round(curation.retention, 4)
table = curation.to_dataframe()
return self.add_section("Dataset", content, table=table)
[docs]
def add_model_section(
self,
model: Any,
descriptors: str = "",
hyperparameters: Optional[Dict[str, Any]] = None,
) -> "QSARReport":
"""Add the algorithm section (OECD principle 2).
Parameters
----------
model : estimator
The fitted model; its class name and parameters are recorded.
descriptors : str
Description of the representation used.
hyperparameters : dict, optional
Overrides what is read from the model.
Returns
-------
QSARReport
"""
params = hyperparameters
if params is None:
params = (
model.get_params() if hasattr(model, "get_params") else {}
)
content: Dict[str, Any] = {"algorithm": type(model).__name__}
if descriptors:
content["descriptors"] = descriptors
content.update({f"param.{k}": v for k, v in sorted(params.items())})
return self.add_section("Algorithm", content)
[docs]
def add_validation_section(
self,
metrics: Dict[str, Any],
y_scrambling: Optional[Dict[str, Any]] = None,
figures: Optional[Sequence[Any]] = None,
) -> "QSARReport":
"""Add the validation section (OECD principle 4).
Parameters
----------
metrics : dict
Goodness-of-fit, robustness and predictivity measures.
y_scrambling : dict, optional
Output of a y-randomization run, which is the evidence that
the fit is not chance correlation.
figures : sequence, optional
Diagnostic plots.
Returns
-------
QSARReport
"""
content = dict(metrics)
if y_scrambling:
content.update(
{f"scrambling.{k}": v for k, v in y_scrambling.items()}
)
return self.add_section("Validation", content, figures=figures)
[docs]
def add_applicability_section(
self,
domain: Any,
coverage: Optional[float] = None,
report: Optional[Dict[str, Any]] = None,
figures: Optional[Sequence[Any]] = None,
) -> "QSARReport":
"""Add the applicability-domain section (OECD principle 3).
Parameters
----------
domain : estimator
The fitted domain; its class name and threshold are recorded.
coverage : float, optional
Fraction of the evaluation set inside the domain.
report : dict, optional
Output of :meth:`~qsarkit.applicability.ADAnalyzer.report`.
figures : sequence, optional
Returns
-------
QSARReport
"""
content: Dict[str, Any] = {"method": type(domain).__name__}
threshold = getattr(domain, "threshold_", None)
if threshold is not None:
content["threshold"] = threshold
if coverage is not None:
content["coverage"] = coverage
if report:
content.update(report)
return self.add_section(
"Applicability domain", content, figures=figures
)
[docs]
def to_dict(self) -> Dict[str, Any]:
"""The whole report as a JSON-serializable dictionary."""
return {
"title": self.title,
"author": self.author,
"endpoint": self.endpoint,
"created": self.created,
"sections": [s.to_dict() for s in self.sections],
}
[docs]
def to_json(self, path: Optional[PathLike] = None, indent: int = 2) -> str:
"""Render as JSON, optionally writing it to ``path``.
Parameters
----------
path : str or Path, optional
indent : int, default 2
Returns
-------
str
The JSON text.
"""
text = json.dumps(self.to_dict(), indent=indent)
if path is not None:
Path(path).write_text(text, encoding="utf-8")
return text
[docs]
def to_markdown(
self,
path: Optional[PathLike] = None,
include_figures: bool = True,
) -> str:
"""Render as Markdown, optionally writing it to ``path``.
Parameters
----------
path : str or Path, optional
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, default True
Embed the report's figures. Requires ``kaleido``
(``pip install qsarkit-learn[reporting]``); pass ``False`` for a
text-and-tables document without it.
Returns
-------
str
The Markdown text.
Raises
------
OptionalDependencyError
If ``include_figures`` is set, the report has figures, and
``kaleido`` is missing.
"""
figures = [f for section in self.sections for f in section.figures]
images: Dict[int, bytes] = {}
asset_dir: Optional[Path] = None
if include_figures and figures:
images = _rasterize_figures(figures)
if path is not None:
asset_dir = Path(path).with_suffix("").parent / (
Path(path).stem + "_figures"
)
asset_dir.mkdir(parents=True, exist_ok=True)
figure_index = 0
lines = [f"# {self.title}", ""]
header = [
("Endpoint", self.endpoint),
("Author", self.author),
("Created", self.created),
]
for label, value in header:
if value:
lines.append(f"**{label}:** {value} ")
lines.append("")
for section in self.sections:
lines.extend([f"## {section.title}", ""])
if section.text:
lines.extend([section.text, ""])
if section.content:
lines.extend(["| Property | Value |", "|---|---|"])
for key, value in section.content.items():
lines.append(f"| {key} | {_format_value(value)} |")
lines.append("")
if section.table is not None:
lines.extend([_dataframe_to_markdown(section.table), ""])
for figure in section.figures:
caption = _figure_title(figure)
png = images.get(figure_index)
figure_index += 1
if png is None:
lines.extend([f"_[figure: {caption}]_", ""])
elif asset_dir is not None:
name = f"figure_{figure_index:02d}.png"
(asset_dir / name).write_bytes(png)
lines.extend([f"", ""])
else:
# No file to sit beside, so inline it. Data URIs render
# in most Markdown viewers, though not on GitHub.
encoded = base64.b64encode(png).decode("ascii")
lines.extend(
[f"", ""]
)
if path is not None:
Path(path).write_text("\n".join(lines), encoding="utf-8")
return "\n".join(lines)
[docs]
def to_html(
self, path: Optional[PathLike] = None, include_plotlyjs: str = "cdn"
) -> str:
"""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 : str or Path, optional
include_plotlyjs : str, default "cdn"
``"cdn"`` keeps the file small; ``True`` inlines Plotly so
the report works with no network.
Returns
-------
str
The HTML document.
"""
from qsarkit.reporting._plots import figure_to_html
blocks: List[str] = []
for section in self.sections:
block = [f"<section><h2>{_escape(section.title)}</h2>"]
if section.text:
block.append(f"<p>{_escape(section.text)}</p>")
if section.content:
block.append(
"<table><thead><tr><th>Property</th><th>Value</th></tr>"
"</thead><tbody>"
)
for key, value in section.content.items():
block.append(
f"<tr><td>{_escape(str(key))}</td>"
f"<td>{_escape(_format_value(value))}</td></tr>"
)
block.append("</tbody></table>")
if section.table is not None:
block.append(section.table.to_html(index=False, border=0))
for i, figure in enumerate(section.figures):
# Plotly's library is included once, with the first figure.
first = not blocks and i == 0
block.append(
figure_to_html(
figure,
include_plotlyjs=include_plotlyjs if first else False,
)
)
block.append("</section>")
blocks.append("\n".join(block))
header = "".join(
f"<p><strong>{label}:</strong> {_escape(value)}</p>"
for label, value in (
("Endpoint", self.endpoint),
("Author", self.author),
("Created", self.created),
)
if value
)
body = f"<h1>{_escape(self.title)}</h1>{header}" + "\n".join(blocks)
html = _HTML_TEMPLATE.format(title=_escape(self.title), body=body)
if path is not None:
Path(path).write_text(html, encoding="utf-8")
return html
[docs]
def to_text(self, path: Optional[PathLike] = None, width: int = 78) -> str:
"""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 : str or Path, optional
Write the text here as well as returning it.
width : int, default 78
Column width for rules and wrapping.
Returns
-------
str
The plain-text report.
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: ...
<BLANKLINE>
Dataset
----------------------------------------
n_compounds 24
n_train 18
n_test 6
<BLANKLINE>
"""
import textwrap
lines: List[str] = ["=" * width, self.title, "=" * width]
for label, value in (
("Endpoint", self.endpoint),
("Author", self.author),
("Created", self.created),
):
if value:
lines.append(f"{label}: {value}")
lines.append("")
for section in self.sections:
lines.extend([section.title, "-" * width])
if section.text:
lines.extend(textwrap.wrap(section.text, width=width) + [""])
if section.content:
pairs = [
(str(key), _format_value(value))
for key, value in section.content.items()
]
key_width = max((len(k) for k, _ in pairs), default=0)
for key, value in pairs:
lines.append(f" {key.ljust(key_width)} {value}")
lines.append("")
if section.table is not None:
lines.extend([section.table.to_string(index=False), ""])
for figure in section.figures:
lines.append(f" [figure: {_figure_title(figure)}]")
if section.figures:
lines.append("")
text = "\n".join(lines)
if path is not None:
Path(path).write_text(text, encoding="utf-8")
return text
[docs]
def to_pdf(
self,
path: PathLike,
include_figures: bool = True,
page_size: str = "A4",
) -> str:
"""Render as a PDF with tables and embedded plots.
Parameters
----------
path : str or 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, default True
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 : {"A4", "letter"}, default "A4"
Returns
-------
str
The path written.
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
----------
- ReportLab documentation:
https://docs.reportlab.com/
"""
from qsarkit.base import require
require("reportlab")
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, letter
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import (
Image,
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
sizes = {"A4": A4, "letter": letter}
if page_size not in sizes:
raise ValueError(
f"page_size must be one of {sorted(sizes)}, got {page_size!r}."
)
figures = [f for s in self.sections for f in s.figures]
images: Dict[int, bytes] = {}
if include_figures and figures:
images = _rasterize_figures(figures)
styles = getSampleStyleSheet()
body = ParagraphStyle(
"qsarkit-body", parent=styles["BodyText"], spaceAfter=6, leading=13
)
story: List[Any] = [
Paragraph(_escape(self.title), styles["Title"]),
]
for label, value in (
("Endpoint", self.endpoint),
("Author", self.author),
("Created", self.created),
):
if value:
story.append(
Paragraph(f"<b>{label}:</b> {_escape(value)}", body)
)
story.append(Spacer(1, 6 * mm))
table_style = TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8EEF7")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#1F2933")),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 8),
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#B0B7C3")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
])
figure_index = 0
for section in self.sections:
story.append(Paragraph(_escape(section.title), styles["Heading2"]))
if section.text:
story.append(Paragraph(_escape(section.text), body))
if section.content:
rows = [["Property", "Value"]] + [
[
Paragraph(_escape(str(key)), body),
Paragraph(_escape(_format_value(value)), body),
]
for key, value in section.content.items()
]
table = Table(rows, colWidths=[55 * mm, 105 * mm], repeatRows=1)
table.setStyle(table_style)
story.extend([table, Spacer(1, 4 * mm)])
if section.table is not None:
frame = section.table
rows = [[str(c) for c in frame.columns]] + [
[_format_value(v) for v in row]
for row in frame.itertuples(index=False)
]
table = Table(rows, repeatRows=1)
table.setStyle(table_style)
story.extend([table, Spacer(1, 4 * mm)])
for _ in section.figures:
png = images.get(figure_index)
figure_index += 1
if png is None:
continue
import io
story.extend([
Image(io.BytesIO(png), width=150 * mm, height=90 * mm,
kind="proportional"),
Spacer(1, 4 * mm),
])
document = SimpleDocTemplate(
str(path),
pagesize=sizes[page_size],
title=self.title,
author=self.author or None,
leftMargin=20 * mm,
rightMargin=20 * mm,
topMargin=18 * mm,
bottomMargin=18 * mm,
)
document.build(story)
return str(path)
def __repr__(self) -> str: # pragma: no cover - display only
return f"<QSARReport {self.title!r}: {len(self.sections)} sections>"
def _figure_title(figure: Any) -> str:
"""Best available name for a Plotly figure, for text listings."""
try:
title = figure.layout.title.text
except AttributeError:
title = None
return str(title) if title else "untitled"
def _rasterize_figures(figures: Sequence[Any]) -> Dict[int, bytes]:
"""Render Plotly figures to PNG bytes, keyed by position.
Raises
------
OptionalDependencyError
If kaleido is unavailable. Raising is deliberate: a report that
silently lost its plots looks complete and is not.
"""
from qsarkit.base.exceptions import OptionalDependencyError
images: Dict[int, bytes] = {}
for i, figure in enumerate(figures):
try:
images[i] = figure.to_image(format="png", scale=2)
except Exception as exc:
# Deliberately broad: a missing or misconfigured kaleido surfaces
# as ImportError, ValueError, RuntimeError or a Plotly-internal
# type depending on version, and all mean the same thing here.
# The original is chained, so nothing is hidden.
raise OptionalDependencyError("kaleido", "reporting") from exc
return images
def _dataframe_to_markdown(frame: Any) -> str:
"""Render a DataFrame as a Markdown table.
Written out rather than delegating to ``DataFrame.to_markdown``,
which requires the optional ``tabulate`` package -- an undeclared
hard dependency for anyone whose report contains a table.
"""
columns = [str(c) for c in frame.columns]
rows = [
[_format_value(value) for value in record]
for record in frame.itertuples(index=False)
]
lines = [
"| " + " | ".join(columns) + " |",
"|" + "|".join("---" for _ in columns) + "|",
]
lines.extend("| " + " | ".join(row) + " |" for row in rows)
return "\n".join(lines)
def _format_value(value: Any) -> str:
"""Render a value for a report table.
Nested dicts are flattened into ``key=value`` pairs rather than
printed as a Python repr: a validation report routinely nests one
(the Golbraikh-Tropsha criteria, for instance), and a raw repr in a
table cell is unreadable in every output format.
"""
if isinstance(value, bool):
# Checked before float, since bool is a subclass of int.
return "yes" if value else "no"
if isinstance(value, float):
if not np.isfinite(value):
return "n/a"
return f"{value:.4g}"
if isinstance(value, dict):
return "; ".join(
f"{key}={_format_value(item)}"
for key, item in value.items()
if item is not None
)
if isinstance(value, (list, tuple, np.ndarray)):
items = list(value)
shown = ", ".join(_format_value(v) for v in items[:5])
return shown + (f", ... ({len(items)} items)" if len(items) > 5 else "")
if value is None:
return "n/a"
return str(value)
def _escape(text: str) -> str:
"""Minimal HTML escaping for report text."""
from html import escape
return escape(str(text))
_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
<title>{title}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
max-width: 60rem; margin: 2rem auto; padding: 0 1rem; line-height: 1.5;
color: #222; }}
h1 {{ border-bottom: 2px solid #2874a6; padding-bottom: .3rem; }}
h2 {{ margin-top: 2rem; color: #2874a6; }}
table {{ border-collapse: collapse; margin: 1rem 0; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: .4rem .6rem; text-align: left; }}
th {{ background: #f4f6f7; }}
section {{ margin-bottom: 2rem; }}
</style></head><body>
{body}
</body></html>"""
[docs]
class OECDReportBuilder:
"""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]
:meth:`build` returns a :class:`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
----------
- 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
- OECD (2004). "The Report from the Expert Group on (Q)SARs on the
Principles for the Validation of (Q)SARs." ENV/JM/MONO(2004)24.
- European Commission Joint Research Centre. "QSAR Model Reporting
Format (QMRF)."
https://joint-research-centre.ec.europa.eu/scientific-tools-databases/qsar-toolbox_en
"""
PRINCIPLES = (
(1, "A defined endpoint"),
(2, "An unambiguous algorithm"),
(3, "A defined domain of applicability"),
(
4,
"Appropriate measures of goodness-of-fit, robustness and "
"predictivity",
),
(5, "A mechanistic interpretation, if possible"),
)
def __init__(
self,
title: str = "QMRF report",
author: str = "",
endpoint: str = "",
) -> None:
self.title = title
self.author = author
self.endpoint = endpoint
self._evidence: Dict[int, Dict[str, Any]] = {}
[docs]
def add_evidence(
self, principle: int, addressed: bool, details: Dict[str, Any]
) -> "OECDReportBuilder":
"""Record how one principle was addressed.
Parameters
----------
principle : int
1 to 5.
addressed : bool
Whether the principle is satisfied.
details : dict
Supporting values.
Returns
-------
OECDReportBuilder
``self``, so calls can be chained.
"""
if principle not in {n for n, _ in self.PRINCIPLES}:
raise ValueError(
f"principle must be 1-5, got {principle}."
)
self._evidence[principle] = {
"addressed": bool(addressed),
**_jsonable(details),
}
return self
[docs]
def from_validation(self, validation: Dict[str, Any]) -> "OECDReportBuilder":
"""Populate principles 3 and 4 from a validation report.
Parameters
----------
validation : dict
A mapping carrying validation metrics, e.g. from
:mod:`qsarkit.validation`.
Returns
-------
OECDReportBuilder
"""
metrics = {
k: v for k, v in validation.items() if isinstance(v, (int, float, str))
}
return self.add_evidence(4, bool(metrics), metrics)
[docs]
def build(self) -> QSARReport:
"""Assemble the report.
Returns
-------
QSARReport
With one section per OECD principle, each marked addressed or
not.
"""
report = QSARReport(
title=self.title, author=self.author, endpoint=self.endpoint
)
report.add_section(
"OECD validation principles",
text=(
"This report follows the QSAR Model Reporting Format (QMRF) "
"and documents the model against the five OECD validation "
"principles. A principle marked not addressed is an explicit "
"gap, not an omission."
),
content={
f"Principle {number}": (
"addressed"
if self._evidence.get(number, {}).get("addressed")
else "NOT ADDRESSED"
)
for number, _ in self.PRINCIPLES
},
)
for number, name in self.PRINCIPLES:
evidence = dict(self._evidence.get(number, {"addressed": False}))
addressed = evidence.pop("addressed", False)
report.add_section(
f"Principle {number}: {name}",
content={"status": "addressed" if addressed else "NOT ADDRESSED",
**evidence},
)
return report
@property
def unaddressed(self) -> List[int]:
"""Principles with no recorded evidence.
Returns
-------
list of int
The principle numbers a reviewer will ask about.
"""
return [
number
for number, _ in self.PRINCIPLES
if not self._evidence.get(number, {}).get("addressed")
]