Source code for qsarkit.persistence._metadata

"""Provenance metadata stored beside every serialized model.

A model file that does not record what produced it is unusable a year
later: you cannot tell which qsarkit version wrote it, which descriptor
block it expects, or what the numbers meant. This is the part of
persistence that matters for OECD principle 2 (an unambiguous algorithm),
and it is plain JSON so it can be read without importing anything.

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
"""

from __future__ import annotations

import platform
import sys
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

__all__ = ["ModelMetadata", "environment_summary"]

#: Format version of the qsarkit bundle layout itself. Bumped only when the
#: on-disk structure changes in a way an older reader could not handle.
BUNDLE_FORMAT_VERSION = "1"


[docs] def environment_summary() -> Dict[str, str]: """Versions of the packages a saved model's behaviour depends on. Recorded at save time so a later load can warn about a mismatch rather than silently predicting something different. Returns ------- dict Package name -> version, plus ``python`` and ``platform``. Examples -------- >>> from qsarkit.persistence import environment_summary >>> summary = environment_summary() >>> "qsarkit" in summary and "python" in summary True """ import numpy import sklearn import qsarkit versions: Dict[str, str] = { "qsarkit": qsarkit.__version__, "python": ".".join(str(v) for v in sys.version_info[:3]), "platform": platform.system(), "numpy": numpy.__version__, "scikit-learn": sklearn.__version__, } # Optional, and only interesting when actually installed. for name, module in (("rdkit", "rdkit"), ("scipy", "scipy")): try: versions[name] = __import__(module).__version__ except Exception: # pragma: no cover - import guard pass return versions
[docs] @dataclass class ModelMetadata: """What a saved model needs to carry to remain interpretable. Attributes ---------- name : str Human-readable model name. endpoint : str What the model predicts, and in what units -- ``"pIC50 (-log10 M)"`` rather than ``"activity"``. task : str ``"regression"`` or ``"classification"``. qsarkit_version : str Version that wrote the file. format_version : str Version of the bundle layout. created : str UTC ISO-8601 timestamp. environment : dict Output of :func:`environment_summary` at save time. n_features : int, optional Expected width of the feature matrix. Checked on load, because a width mismatch is the failure that otherwise produces confident nonsense. feature_names : list of str, optional Column names, where the representation provides them. n_training_samples : int, optional How many compounds the model was fitted on. description : str Free text. extra : dict Anything else worth recording -- dataset DOI, assay, curation settings, validation scores. Examples -------- >>> from qsarkit.persistence import ModelMetadata >>> meta = ModelMetadata(name="demo", endpoint="pIC50", task="regression") >>> meta.task 'regression' >>> restored = ModelMetadata.from_dict(meta.to_dict()) >>> restored.name == meta.name True """ name: str = "" endpoint: str = "" task: str = "regression" qsarkit_version: str = "" format_version: str = BUNDLE_FORMAT_VERSION created: str = "" environment: Dict[str, str] = field(default_factory=dict) n_features: Optional[int] = None feature_names: Optional[List[str]] = None n_training_samples: Optional[int] = None description: str = "" extra: Dict[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: if self.task not in ("regression", "classification"): raise ValueError( f"task must be 'regression' or 'classification', got {self.task!r}." ) if not self.created: self.created = datetime.now(timezone.utc).isoformat(timespec="seconds") if not self.environment: self.environment = environment_summary() if not self.qsarkit_version: self.qsarkit_version = self.environment.get("qsarkit", "")
[docs] def to_dict(self) -> Dict[str, Any]: """Render as a JSON-serializable dict.""" return asdict(self)
[docs] @classmethod def from_dict(cls, data: Dict[str, Any]) -> "ModelMetadata": """Rebuild from :meth:`to_dict` output, ignoring unknown keys. Unknown keys are dropped rather than raising, so a bundle written by a newer qsarkit that added a field still loads here. Parameters ---------- data : dict Returns ------- ModelMetadata """ known = {f for f in cls.__dataclass_fields__} return cls(**{k: v for k, v in data.items() if k in known})
[docs] def environment_differences(self) -> Dict[str, str]: """Packages whose current version differs from the recorded one. Returns ------- dict Package name -> ``"saved X, now Y"`` for each mismatch. Examples -------- >>> from qsarkit.persistence import ModelMetadata >>> meta = ModelMetadata(name="demo") >>> meta.environment_differences() # same session, so none {} >>> meta.environment["numpy"] = "0.0.1" >>> "numpy" in meta.environment_differences() True """ current = environment_summary() return { package: f"saved {saved}, now {current[package]}" for package, saved in self.environment.items() if package in current and current[package] != saved }