Source code for prophys.export.model_package

"""Standardized model export format (`ModelPackage`).

This is the versioned interchange format for a compiled `prophys`
model. It carries no dependency on any downstream consumer, and consumers
implement their own import logic against this documented shape.

Format 1.1 adds two things to 1.0:

- a :class:`~prophys.export.manifest.RunManifest`, recording which prophys
  version, model structure, parameter values, seed and Monte Carlo settings
  produced the exported distributions, so a result can be traced and
  derived again rather than only read,
- a `unit_description` per attribute and optional `diagnostics`, so a reader
  learns what the numbers are measured in and how well the model that
  produced them was doing.

A 1.0 file loads unchanged: the new fields are optional and default to
absent.

Format 1.2 adds `surrogates`, one `SurrogateExport` per learned surrogate
of the model, carrying the native program or tree spec, weights, support
region, head posterior, training card and a parity checksum that the
standalone runtime library reproduces without Python.
"""

from __future__ import annotations

import dataclasses
import json
from typing import Any

import jax
import numpy as np

from .. import _ffi, units
from .manifest import (
    ReproducibilityError,
    RunManifest,
    build_manifest,
    manifest_from_dict,
)
from .manifest import verify as verify_manifest

#: Format version written by this prophys (the ``format_version`` field of
#: every exported package).
FORMAT_VERSION = "1.2"

#: Format versions this module can load. A file written by a newer prophys
#: is refused with its version named, rather than being read with fields
#: silently missing.
SUPPORTED_VERSIONS = frozenset({"1.0", "1.1", "1.2"})


[docs] @dataclasses.dataclass class AttributeExport: """The exported marginal distribution of one model attribute. Which of `grid`, `edges` and `samples` is set depends on `representation`. `probs` is set for ``"quantized"`` and ``"histogram"``. """ name: str """Attribute name.""" unit: str """Unit string of the attribute (e.g. ``"kW"``).""" domain: str """Free text domain description of the attribute, as declared in the model.""" representation: str # "quantized" | "histogram" | "sampler" """``"quantized"``, ``"histogram"`` or ``"sampler"``.""" support: tuple[float, float] """``(min, max)`` of the Monte Carlo draws the export was built from.""" # "quantized": grid (list[float]) + probs (list[float]) grid: list[float] | None = None """``"quantized"``: the support points (bin centres).""" probs: list[float] | None = None """``"quantized"`` and ``"histogram"``: probability of each point or bin, summing to one.""" # "histogram": edges (n+1,) + probs (n,) edges: list[float] | None = None """``"histogram"``: the ``n + 1`` bin edges, equally spaced.""" # "sampler": raw NumPy samples, JAX-free and picklable samples: list[float] | None = None """``"sampler"``: the raw Monte Carlo draws as plain floats.""" mean: float | None = None """Monte Carlo mean of the draws.""" # -- format 1.1 -------------------------------------------------------- unit_description: str | None = None """The attribute's unit expanded into its dimension and SI scale (see `prophys.units.describe`), so a consumer can check compatibility without reimplementing this package's unit parser.""" std_error: float | None = None """Monte Carlo standard error of `mean`, from the same draw the distribution was built from. `None` when the export did not compute it.""" n_samples: int | None = None """Draws the exported representation was built from."""
[docs] @dataclasses.dataclass class ModelPackage: """Versioned export of a compiled model's distributions as JSON, with provenance. Built by :func:`export_model` (or ``CompiledModel.export()``), written with :meth:`save` and read back with :meth:`load`. It carries each attribute's marginal distribution, the parameter values, a :class:`~prophys.export.RunManifest` recording what produced it, optional diagnostic reports, and learned surrogates in a form the standalone runtime library evaluates without Python. Example: >>> package = export_model(compiled, params=fit.raw_params, representation="histogram") >>> package.save("model.json") >>> ModelPackage.load("model.json").attribute("energy").mean """ format_version: str """Format version of the package, e.g. ``"1.2"``.""" model_name: str """Readable name of the source model.""" params: dict[str, Any] """Parameter values the package was exported at, in constrained space.""" attributes: list[AttributeExport] """One exported distribution per attribute.""" correlation: dict[str, Any] | None = None """Reserved for a dependence summary between attributes, not filled by :func:`export_model`.""" # -- format 1.1 -------------------------------------------------------- manifest: RunManifest | None = None """Provenance for this export: versions, structure hash, seed, licensing. `None` only for a package loaded from a 1.0 file.""" diagnostics: dict[str, Any] | None = None """Diagnostic reports attached to the export, keyed by name, typically the output of :func:`prophys.engine.scoring.report_card`.""" # -- format 1.2 -------------------------------------------------------- surrogates: list | None = None """Learned surrogates of the model as `prophys.export.surrogate` exports."""
[docs] def surrogate(self, name: str): """Return the exported surrogate called `name`. Raises: KeyError: If the package has no such surrogate. """ for s in self.surrogates or []: if s.name == name: return s raise KeyError(f"No surrogate {name!r} in this package")
[docs] def to_dict(self) -> dict[str, Any]: """Return the package as a dict ready for JSON (the content of the saved file).""" surr = self.surrogates self.surrogates = None data = dataclasses.asdict(self) self.surrogates = surr if self.manifest is not None: data["manifest"] = self.manifest.to_dict() if surr is not None: data["surrogates"] = [s.to_dict() for s in surr] return data
[docs] def verify(self, compiled, *, check_params: bool = True) -> None: """Check that `compiled` is the model this package came from. Raises :class:`~prophys.export.manifest.ReproducibilityError` when the structure hash or the parameters differ (see :func:`prophys.export.manifest.verify`), or when the package has no manifest because it was written in format 1.0. """ if self.manifest is None: raise ReproducibilityError( "This package carries no manifest (it was written in format " "1.0), so there is nothing to verify against." ) verify_manifest(self.manifest, compiled, check_params=check_params)
[docs] def attribute(self, name: str) -> AttributeExport: """Return the exported distribution of the attribute called `name`. Raises: KeyError: If the package has no such attribute. """ for attr in self.attributes: if attr.name == name: return attr raise KeyError(f"No attribute {name!r} in this package; it has {[a.name for a in self.attributes]}")
[docs] def to_json(self, indent: int = 2) -> str: """Serialize the package to a JSON string.""" return json.dumps(self.to_dict(), indent=indent)
[docs] def save(self, path: str) -> None: """Write the package to a JSON file at `path`.""" with open(path, "w") as f: f.write(self.to_json())
[docs] @classmethod def load(cls, path: str) -> "ModelPackage": """Read a package from a JSON file written by :meth:`save`. Files of format 1.0, 1.1 and 1.2 load, and fields this version does not know are ignored. Raises: ValueError: If the file's format version is not supported. """ with open(path) as f: data = json.load(f) version = data.get("format_version") if version not in SUPPORTED_VERSIONS: raise ValueError( f"{path} is format version {version!r}; this prophys " f"({FORMAT_VERSION}) reads {sorted(SUPPORTED_VERSIONS)}." ) # Unknown per-attribute fields are dropped rather than raising, so a # file written by a later prophys within a supported version still # loads with the fields this one understands. known = {f.name for f in dataclasses.fields(AttributeExport)} attrs = [AttributeExport(**{k: v for k, v in a.items() if k in known}) for a in data["attributes"]] data = dict(data) if data.get("manifest"): data["manifest"] = manifest_from_dict(data["manifest"]) data["attributes"] = attrs if data.get("surrogates"): from .surrogate import SurrogateExport data["surrogates"] = [SurrogateExport.from_dict(x) for x in data["surrogates"]] known_pkg = {f.name for f in dataclasses.fields(cls)} return cls(**{k: v for k, v in data.items() if k in known_pkg})
[docs] def export_model( compiled, attribute_names: list[str] | None = None, representation: str = "quantized", n_qubits: int = 8, n_samples: int = 4096, seed: int = 0, params: Any = None, observations: Any = None, diagnostics: Any = None, notes: str = "", ) -> ModelPackage: """Export a compiled model's attribute distributions as a :class:`ModelPackage`. Each attribute is sampled `n_samples` times and its marginal distribution serialized according to `representation`: * ``"quantized"``: PMF on the ``2**n_qubits`` centres of equally spaced bins over the sample range (for example for consumers in the style of quantum amplitude estimation). * ``"histogram"``: histogram with ``2**n_qubits`` equally wide bins. * ``"sampler"``: the raw Monte Carlo samples as plain floats, for consumers that just want data to resample from. Every attribute also records its mean with the Monte Carlo standard error of the same draw. Learned surrogates of the model are exported alongside, except those with `Physics` layers, which are skipped. Args: compiled: The compiled model. attribute_names: Attributes to export. Defaults to all. representation: ``"quantized"``, ``"histogram"`` or ``"sampler"``. n_qubits: Resolution: ``2**n_qubits`` points or bins. n_samples: Monte Carlo draws per attribute. seed: PRNG seed of the draws. params: Unconstrained parameter values to export at. Pass a fit's `raw_params` so the distributions and the manifest record the fitted model rather than its declared initial values. observations: Observations of a fit, recorded in the manifest as counts. diagnostics: Name to diagnostic report (e.g. the output of :func:`~prophys.engine.report_card`), serialized in the manifest and the package. notes: Free text recorded in the manifest. Returns: The :class:`ModelPackage`. Raises: ValueError: If `representation` is unknown. """ names = attribute_names or [a.name for a in compiled.model.attributes] key = jax.random.PRNGKey(seed) attr_exports = [] for name in names: key, subkey = jax.random.split(key) attr = compiled.model.attribute(name) samples = np.asarray(compiled.sample(name, subkey, (n_samples,), params)) lo, hi = float(np.min(samples)), float(np.max(samples)) mean = float(np.mean(samples)) # The exported mean is a Monte-Carlo estimate like any other, so it # is exported with the error of the same draw rather than as a bare # number a consumer would read as exact. error = float(np.asarray(_ffi.stats_mean_error(samples.ravel()))[1]) common = dict( unit_description=units.describe(attr.unit), std_error=error, n_samples=int(n_samples), ) if representation == "sampler": attr_exports.append( AttributeExport( name=name, unit=attr.unit, domain=attr.domain, representation="sampler", support=(lo, hi), samples=samples.tolist(), mean=mean, **common, ) ) continue n_bins = 2**n_qubits counts, edges = np.histogram(samples, bins=n_bins, range=(lo, hi)) probs = (counts / max(counts.sum(), 1)).tolist() if representation == "histogram": attr_exports.append( AttributeExport( name=name, unit=attr.unit, domain=attr.domain, representation="histogram", support=(lo, hi), edges=edges.tolist(), probs=probs, mean=mean, **common, ) ) elif representation == "quantized": grid = ((edges[:-1] + edges[1:]) / 2.0).tolist() attr_exports.append( AttributeExport( name=name, unit=attr.unit, domain=attr.domain, representation="quantized", support=(lo, hi), grid=grid, probs=probs, mean=mean, **common, ) ) else: raise ValueError(f"Unknown representation: {representation!r}") exported_params = {k: np.asarray(v).tolist() for k, v in compiled.constrained_params(params).items()} manifest = build_manifest( compiled, params=params, observations=observations, diagnostics=diagnostics, notes=notes, ) from ..nn.graph import find_surrogates from .surrogate import export_surrogate surrogates = [] for sg in find_surrogates(compiled.model).values(): if all(st.kind == "native" for st in getattr(sg, "stages", [])) and len(getattr(sg, "stages", [None])) <= 1: surrogates.append(export_surrogate(sg, compiled._full_raw_params(params))) return ModelPackage( format_version=FORMAT_VERSION, model_name=repr(compiled.model), params=exported_params, attributes=attr_exports, manifest=manifest, surrogates=surrogates or None, diagnostics=manifest.diagnostics or None, )