Persistence¶
Saving a model so it still works next year, and so someone else can load it safely.
Why not pickle¶
pickle is the obvious choice and the wrong one for a model you intend
to keep:
It embeds the exact class layout of every object, so a file written under one scikit-learn or NumPy release can fail to load — or load into a subtly different object — under the next.
Loading one executes arbitrary code, which makes a shared model file a security problem rather than a data file.
qsarkit writes a directory bundle instead:
model.qsar/
manifest.json what is in here, and what wrote it
metadata.json endpoint, task, feature names, provenance
estimator.skops the fitted estimator, in skops' inspectable format
pipeline.skops the preprocessing pipeline, when there is one
Everything except the estimator is plain JSON, readable without importing qsarkit at all. The estimator uses skops, the format scikit-learn recommends: it stores parameters as data rather than as a pickled object graph, and refuses to reconstruct types that were not explicitly trusted.
Saving and loading¶
>>> import os, tempfile
>>> from qsarkit.models import QSARRegressor
>>> from qsarkit.persistence import ModelMetadata, load_model, save_model
>>> X, y = demo_fingerprints(256), DEMO_Y
>>> model = QSARRegressor("rf", random_state=0).fit(X, y)
>>> path = save_model(
... model,
... os.path.join(tempfile.mkdtemp(), "demo"),
... metadata=ModelMetadata(
... name="benzoic acid pIC50",
... endpoint="pIC50 (-log10 M)",
... task="regression",
... n_training_samples=len(y),
... ),
... )
>>> path.endswith(".qsar")
True
The reloaded model predicts identically:
>>> import numpy as np
>>> bundle = load_model(path)
>>> bool(np.allclose(bundle.predict(X), model.predict(X)))
True
>>> bundle.metadata.endpoint
'pIC50 (-log10 M)'
Predicting from molecules¶
Save the transformer alongside the estimator and the bundle can go straight from structures to predictions — which also removes the commonest way to misuse a saved model, feeding it features from a different fingerprint:
>>> from qsarkit.representation import MorganFingerprint
>>> fingerprint = MorganFingerprint(radius=2, n_bits=256)
>>> path = save_model(
... model,
... os.path.join(tempfile.mkdtemp(), "with_pipeline"),
... pipeline=fingerprint,
... metadata=ModelMetadata(name="demo", endpoint="pIC50 (-log10 M)"),
... )
>>> bundle = load_model(path)
>>> bool(np.allclose(bundle.predict_mols(demo_mols), model.predict(X)))
True
Without a pipeline that is refused rather than guessed at:
>>> bare = load_model(save_model(model, os.path.join(tempfile.mkdtemp(), "bare")))
>>> bare.predict_mols(demo_mols)
Traceback (most recent call last):
...
ValueError: This bundle has no pipeline, so molecules cannot be featurized...
Guardrails¶
A feature-width mismatch is the failure that otherwise produces confident nonsense, so it is checked:
>>> bundle.predict(np.zeros((2, 64)))
Traceback (most recent call last):
...
ValueError: This model expects 256 features but was given 64...
And a model loaded under different package versions says so, because it is not guaranteed to reproduce its original predictions:
>>> import warnings
>>> bundle.metadata.environment["scikit-learn"] = "0.1"
>>> from qsarkit.persistence import save_model as _save
>>> stale = _save(bundle, os.path.join(tempfile.mkdtemp(), "stale"))
>>> with warnings.catch_warnings(record=True) as caught:
... warnings.simplefilter("always")
... _ = load_model(stale)
>>> "scikit-learn" in str(caught[0].message)
True
Inspecting before loading¶
The safe first step with a model from someone else. It reports what the bundle contains without reconstructing anything:
>>> from qsarkit.persistence import inspect_bundle
>>> report = inspect_bundle(path)
>>> report["manifest"]["estimator_class"]
'qsarkit.models._facades.QSARRegressor'
>>> report["untrusted"]
[]
untrusted lists the third-party types skops would need permission to
build. qsarkit’s own classes are trusted automatically — that is no more
dangerous than import qsarkit, since the class comes from the
installed package and the file supplies only attribute values. Anything
else you must recognize and name:
bundle = load_model(path, trusted=["mypackage.MyTransformer"])
Warning
allow_pickle_fallback=True exists for estimators skops cannot
represent, and reintroduces exactly the version fragility and
arbitrary-code-execution risk this module avoids. It warns when used.
Prefer wrapping the object in a scikit-learn-compatible estimator.
Provenance¶
>>> from qsarkit.persistence import environment_summary
>>> summary = environment_summary()
>>> sorted(summary)[:3]
['numpy', 'platform', 'python']
Recording this is what OECD principle 2 — an unambiguous algorithm — asks for in practice: a year later, the metadata is how you establish what the model was and what its numbers meant.
API¶
Robust, pickle-free persistence for QSAR models and their pipelines.
pickle is the obvious way to save a model and the wrong one for a
model you intend to keep: it embeds the exact class layout of every
object, so a file written under one scikit-learn release can fail to load
under the next, and loading one executes arbitrary code.
This package writes a directory bundle instead – JSON metadata beside a skops representation of the estimator, which stores parameters as data and refuses to reconstruct types that were not explicitly trusted.
Examples
>>> import numpy as np, os, tempfile
>>> from sklearn.linear_model import Ridge
>>> from qsarkit.persistence import ModelMetadata, load_model, save_model
>>> rng = np.random.default_rng(0)
>>> X, y = rng.normal(size=(40, 5)), rng.normal(size=40)
>>> path = save_model(
... Ridge().fit(X, y),
... os.path.join(tempfile.mkdtemp(), "demo"),
... metadata=ModelMetadata(name="demo", endpoint="pIC50 (-log10 M)"),
... )
>>> bundle = load_model(path)
>>> bundle.metadata.endpoint
'pIC50 (-log10 M)'
>>> bool(np.allclose(bundle.predict(X), Ridge().fit(X, y).predict(X)))
True
References
skops documentation, “Secure persistence with skops”: https://skops.readthedocs.io/en/stable/persistence.html
scikit-learn, “Model persistence”: https://scikit-learn.org/stable/model_persistence.html
- class qsarkit.persistence.ModelBundle(estimator, pipeline=None, metadata=None)[source]¶
Bases:
objectA fitted model, its preprocessing, and the provenance to interpret it.
The unit qsarkit saves and loads. Holding the three together is the point: an estimator without its representation is not a model, and a model without its endpoint and feature layout cannot be used safely a year later.
- Parameters:
estimator (
Any) – The fitted model. Any scikit-learn-compatible estimator.pipeline (
Optional[Any]) – The transformer (orsklearn.pipeline.Pipeline) that turns molecules into the feature matrixestimatorexpects. Supply it whenever you have one: it is what makespredict_mols()possible, and what stops a reloaded model being fed the wrong features.metadata (
Optional[ModelMetadata]) – Provenance. A default is created if omitted, but an emptyendpointis worth filling in.
- Variables:
estimator (
object)metadata (
ModelMetadata)
Examples
>>> import numpy as np >>> from sklearn.linear_model import Ridge >>> from qsarkit.persistence import ModelBundle, ModelMetadata >>> rng = np.random.default_rng(0) >>> X, y = rng.normal(size=(40, 5)), rng.normal(size=40) >>> bundle = ModelBundle( ... Ridge().fit(X, y), ... metadata=ModelMetadata(name="demo", endpoint="pIC50 (-log10 M)"), ... ) >>> bundle.predict(X).shape (40,)
Round-tripping through a directory preserves the predictions exactly:
>>> import tempfile, os >>> path = os.path.join(tempfile.mkdtemp(), "demo.qsar") >>> _ = bundle.save(path) >>> reloaded = ModelBundle.load(path) >>> bool(np.allclose(reloaded.predict(X), bundle.predict(X))) True >>> reloaded.metadata.endpoint 'pIC50 (-log10 M)'
References
skops documentation: https://skops.readthedocs.io/en/stable/persistence.html
- predict(X)[source]¶
Predict from an already-featurized matrix.
- Parameters:
X (
Any)- Return type:
- Raises:
ValueError – If
Xhas a different number of columns than the model was fitted on. Checked rather than passed through, because a width mismatch otherwise produces confident nonsense.
- predict_proba(X)[source]¶
Class probabilities, for a classification bundle.
- Parameters:
X (
Any)- Return type:
- Raises:
AttributeError – If the underlying estimator has no
predict_proba.
- predict_mols(mols)[source]¶
Predict straight from molecules, using the stored pipeline.
- Parameters:
mols (
Sequence[Any]) – RDKit molecules, or SMILES if the stored pipeline begins with a SMILES parser.- Return type:
- Raises:
ValueError – If the bundle carries no pipeline, so there is no way to know which representation the estimator expects.
- save(path, allow_pickle_fallback=False, overwrite=True)[source]¶
Write the bundle to a directory. See
save_model().- Return type:
- classmethod load(path, trusted=None, warn_on_environment_change=True)[source]¶
Read a bundle from a directory. See
load_model().- Return type:
- class qsarkit.persistence.ModelMetadata(name='', endpoint='', task='regression', qsarkit_version='', format_version='1', created='', environment=<factory>, n_features=None, feature_names=None, n_training_samples=None, description='', extra=<factory>)[source]¶
Bases:
objectWhat a saved model needs to carry to remain interpretable.
- Variables:
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 ofenvironment_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 (
listofstr, 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
- classmethod from_dict(data)[source]¶
Rebuild from
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:
- Return type:
- environment_differences()[source]¶
Packages whose current version differs from the recorded one.
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
- qsarkit.persistence.save_model(bundle, path, pipeline=None, metadata=None, allow_pickle_fallback=False, overwrite=True)[source]¶
Write a model to a qsarkit bundle directory.
- Parameters:
bundle (
Union[ModelBundle,Any]) – A prepared bundle, or a bare fitted estimator – in which casepipelineandmetadataare used to build one.path (
Union[str,Path]) – Destination directory..qsaris appended when the path has no suffix, purely as a convention.pipeline (
Optional[Any]) – Only used whenbundleis a bare estimator.metadata (
Optional[ModelMetadata]) – Only used whenbundleis a bare estimator.allow_pickle_fallback (
bool) – If skops cannot represent an object, fall back tojoblib(which pickles). Off by default: the fallback reintroduces exactly the fragility and the arbitrary-code-execution risk this module exists to avoid, so it has to be asked for.overwrite (
bool) – Replace an existing bundle atpath. WithFalse, an existing directory raises.
- Returns:
The directory written.
- Return type:
- Raises:
FileExistsError – If
pathexists andoverwriteis False.OptionalDependencyError – If
skopsis not installed andallow_pickle_fallbackis False.
Examples
>>> import numpy as np, tempfile, os >>> from sklearn.linear_model import Ridge >>> from qsarkit.persistence import load_model, save_model >>> rng = np.random.default_rng(0) >>> X, y = rng.normal(size=(30, 4)), rng.normal(size=30) >>> out = os.path.join(tempfile.mkdtemp(), "ridge") >>> written = save_model(Ridge().fit(X, y), out) >>> written.endswith(".qsar") True >>> sorted(p.name for p in __import__("pathlib").Path(written).iterdir()) ['estimator.skops', 'manifest.json', 'metadata.json']
References
- qsarkit.persistence.load_model(path, trusted=None, warn_on_environment_change=True)[source]¶
Read a qsarkit bundle written by
save_model().- Parameters:
trusted (
Optional[List[str]]) – Extra type names to allow skops to reconstruct, beyond what it trusts by default. Inspect a bundle first withinspect_bundle()and pass only what you recognize – this is the mechanism that makes loading a third-party model safe, so blanket-trusting everything defeats it.warn_on_environment_change (
bool) – Warn when the current package versions differ from those recorded at save time. A model is not guaranteed to reproduce its original predictions across versions, and silence would hide that.
- Return type:
- Raises:
FileNotFoundError – If
pathis not a bundle directory.ValueError – If the manifest is missing, unreadable, or written in a bundle format this version does not understand.
Examples
>>> import numpy as np, tempfile, os >>> from sklearn.linear_model import Ridge >>> from qsarkit.persistence import load_model, save_model >>> rng = np.random.default_rng(0) >>> X, y = rng.normal(size=(30, 4)), rng.normal(size=30) >>> out = save_model(Ridge().fit(X, y), os.path.join(tempfile.mkdtemp(), "m")) >>> bundle = load_model(out) >>> bundle.metadata.task 'regression' >>> bundle.predict(X).shape (30,)
- qsarkit.persistence.inspect_bundle(path)[source]¶
Describe a bundle without reconstructing any object from it.
The safe first step with a model from someone else: it reports the manifest, the metadata and the list of types skops would need to build, so you can decide what to trust before anything is executed.
- Parameters:
- Returns:
manifest,metadata,untrustedandauto_trusted.untrustedholds the type names that would block a load until they are passed toload_model(trusted=...);auto_trustedholds the ones qsarkit accepts on your behalf – its own classes, plus the scikit-learn internals its estimator menu produces (see_AUTO_TRUSTED_PREFIXES). An emptyuntrustedmeans the bundle loads as it stands, not that nothing in it is executable, so inspectauto_trustedtoo for a bundle you did not produce.- Return type:
- Raises:
FileNotFoundError – If
pathis not a directory.ValueError – If the manifest is missing.
Examples
>>> import numpy as np, tempfile, os >>> from sklearn.linear_model import Ridge >>> from qsarkit.persistence import inspect_bundle, save_model >>> rng = np.random.default_rng(0) >>> X, y = rng.normal(size=(30, 4)), rng.normal(size=30) >>> out = save_model(Ridge().fit(X, y), os.path.join(tempfile.mkdtemp(), "m")) >>> report = inspect_bundle(out) >>> report["manifest"]["estimator_class"] 'sklearn.linear_model._ridge.Ridge' >>> report["untrusted"] []
References
skops, “Visualize and trust”: https://skops.readthedocs.io/en/stable/persistence.html
- qsarkit.persistence.environment_summary()[source]¶
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.
Examples
>>> from qsarkit.persistence import environment_summary >>> summary = environment_summary() >>> "qsarkit" in summary and "python" in summary True
References¶
skops documentation, “Secure persistence with skops”: https://skops.readthedocs.io/en/stable/persistence.html
scikit-learn, “Model persistence”: https://scikit-learn.org/stable/model_persistence.html
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