Source code for pymixef.results

"""Stable backend-neutral fit result and archival format."""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import numpy as np

from ._contracts import WarningRecord
from ._serialization import read_json, stable_hash, write_json
from .convergence import ConvergenceReport
from .diagnostics import DiagnosticTable, residual_table, vpc_table
from .errors import UnsupportedCapabilityError, ValidationError
from .provenance import RunManifest, fingerprint_model_ir
from .random import RandomStreamManager

_RESULT_SCHEMA_VERSION = "1.0.0"


def _as_array(value: Any, *, ndim: int | None = None) -> np.ndarray:
    array = np.asarray(value, dtype=float)
    if ndim is not None and array.ndim != ndim:
        raise ValueError(f"Expected a {ndim}-dimensional array; got shape {array.shape}.")
    return array


[docs] @dataclass(slots=True) class FitResult: """Result contract shared by frequentist, stochastic, and Bayesian engines.""" model_ir: Any parameters: Mapping[str, float] unconstrained_parameters: Mapping[str, float] parameter_covariance: np.ndarray | None fitted_values: np.ndarray residuals: np.ndarray random_effects: Mapping[str, Any] objective: float log_likelihood: float | None method: str engine: str convergence: ConvergenceReport manifest: RunManifest warnings: tuple[WarningRecord, ...] = () diagnostic_data: Mapping[str, DiagnosticTable] = field(default_factory=dict) extra: Mapping[str, Any] = field(default_factory=dict) result_schema_version: str = _RESULT_SCHEMA_VERSION def __post_init__(self) -> None: self.parameters = {str(key): float(value) for key, value in self.parameters.items()} self.unconstrained_parameters = { str(key): float(value) for key, value in self.unconstrained_parameters.items() } self.parameter_covariance = ( None if self.parameter_covariance is None else _as_array(self.parameter_covariance, ndim=2) ) self.fitted_values = _as_array(self.fitted_values, ndim=1) self.residuals = _as_array(self.residuals, ndim=1) if self.fitted_values.shape != self.residuals.shape: raise ValueError("Fitted values and residuals must align.") self.warnings = tuple(self.warnings) self.diagnostic_data = { str(name): ( table if isinstance(table, DiagnosticTable) else DiagnosticTable.from_dict(table) ) for name, table in self.diagnostic_data.items() } @property def success(self) -> bool: """A compatibility convenience; inspect ``convergence`` for real detail.""" return self.convergence.trustworthy @property def n_observations(self) -> int: return int(self.fitted_values.size)
[docs] def prediction(self, *, mode: str = "conditional") -> np.ndarray: """Return an explicitly named prediction mode for the analysis rows.""" if mode == "conditional": return self.fitted_values.copy() if mode == "population": population = self.extra.get("population_fitted_values") if population is None: if not self.random_effects: return self.fitted_values.copy() raise ValueError("Population predictions were not archived by this backend.") return np.asarray(population, dtype=float).copy() if mode == "new-subject": population = self.extra.get("population_fitted_values") if population is None: raise ValueError("New-subject predictions require archived population predictions.") return np.asarray(population, dtype=float).copy() raise ValueError("Prediction mode must be conditional, population, or new-subject.")
[docs] def diagnostic(self, name: str) -> DiagnosticTable: try: return self.diagnostic_data[name] except KeyError as error: raise KeyError( f"Diagnostic table {name!r} is unavailable; choose from " f"{sorted(self.diagnostic_data)}." ) from error
[docs] def residual_diagnostics( self, *, observed: Sequence[float] | None = None, variance: Sequence[float] | float | None = None, ) -> DiagnosticTable: if observed is None: observed_array = self.fitted_values + self.residuals else: observed_array = np.asarray(observed, dtype=float) return residual_table( observed_array, self.fitted_values, variance=variance, row_ids=self.extra.get("row_ids"), groups=self.extra.get("groups"), )
[docs] def simulate( self, *, n_replicates: int = 1, seed: int | None = None, parameter_uncertainty: str = "none", random_effects: bool = True, residual_error: bool = True, output: str = "numpy", design: Any = None, ) -> np.ndarray | DiagnosticTable: """Simulate from archived Gaussian calculations or a backend simulator. The arguments follow PyMixEF's public simulation contract. A backend may archive a callable simulator for an in-memory result, but archival reloads use the standardized Gaussian fallback only when its assumptions are explicit. """ if n_replicates < 1: raise ValueError("n_replicates must be positive.") if parameter_uncertainty not in {"none", "asymptotic"}: raise ValueError("parameter_uncertainty must be 'none' or 'asymptotic'.") simulator = self.extra.get("_simulator") if callable(simulator): values = np.asarray( simulator( design=design, n_replicates=n_replicates, seed=seed, parameter_uncertainty=parameter_uncertainty, random_effects=random_effects, residual_error=residual_error, ) ) else: if design is not None: raise UnsupportedCapabilityError( "Portable simulation for a new design was not archived by this backend.", code="SIM-DESIGN-UNSUPPORTED-001", remediation="Refit and simulate through a backend with a portable design compiler.", ) family = str(self.extra.get("family", "gaussian")).lower() if family not in {"gaussian", "normal"}: raise NotImplementedError( "This reloaded result has no archived simulator for family " f"{family!r}; refit with a backend supporting portable simulation." ) rng = RandomStreamManager( 0 if seed is None else int(seed), "pymixef-result-simulation" ).generator("observations") archived_population = self.extra.get("population_fitted_values") if archived_population is None: if self.random_effects: raise UnsupportedCapabilityError( "This result did not archive population predictions required " "for new-replicate simulation.", code="SIM-RANDOM-EFFECTS-UNSUPPORTED-001", ) mean = self.fitted_values.copy() else: mean = np.asarray(archived_population, dtype=float).reshape(-1) if mean.shape != self.fitted_values.shape: raise ValidationError( "Archived population predictions do not align with analysis rows.", code="RESULT-SIMULATION-SHAPE-001", ) values = np.broadcast_to(mean, (n_replicates, mean.size)).copy() simulation_covariance = np.zeros((mean.size, mean.size), dtype=float) if random_effects: random_covariance = self.extra.get("random_effect_observation_covariance") if random_covariance is None and self.random_effects: raise UnsupportedCapabilityError( "Random-effect simulation covariance was not archived.", code="SIM-RANDOM-EFFECTS-UNSUPPORTED-001", ) if random_covariance is not None: simulation_covariance += _as_array(random_covariance, ndim=2) if residual_error: residual_covariance = self.extra.get("residual_covariance") if residual_covariance is None: scale = float( self.extra.get( "residual_scale", ( np.sqrt(np.mean(np.square(self.residuals))) if self.residuals.size else 0.0 ), ) ) simulation_covariance += np.eye(mean.size) * scale**2 else: simulation_covariance += _as_array(residual_covariance, ndim=2) if simulation_covariance.shape != (mean.size, mean.size): raise ValidationError( "Archived simulation covariance does not align with analysis rows.", code="RESULT-SIMULATION-SHAPE-001", ) if np.any(simulation_covariance): simulation_covariance = (simulation_covariance + simulation_covariance.T) / 2.0 values += rng.multivariate_normal( np.zeros(mean.size), simulation_covariance, size=n_replicates, check_valid="raise", ) if parameter_uncertainty == "asymptotic": fixed_design = self.extra.get("fixed_design") fixed_covariance = self.extra.get("fixed_effect_covariance") if fixed_design is None or fixed_covariance is None: raise UnsupportedCapabilityError( "Asymptotic prediction uncertainty requires archived fixed-" "effect design and covariance matrices.", code="SIM-PARAMETER-UNCERTAINTY-UNSUPPORTED-001", ) design_matrix = _as_array(fixed_design, ndim=2) covariance = _as_array(fixed_covariance, ndim=2) if design_matrix.shape[0] != mean.size or covariance.shape != ( design_matrix.shape[1], design_matrix.shape[1], ): raise ValidationError( "Archived fixed-effect uncertainty matrices are inconsistent.", code="RESULT-SIMULATION-SHAPE-001", ) draws = rng.multivariate_normal( np.zeros(design_matrix.shape[1]), covariance, n_replicates, check_valid="raise", ) values += draws @ design_matrix.T if output == "numpy": return values if output == "table": replicate, observation = np.indices(values.shape) return DiagnosticTable( name="simulation", columns={ "replicate": replicate.ravel(), "observation": observation.ravel(), "value": values.ravel(), }, metadata={ "seed": seed, "parameter_uncertainty": parameter_uncertainty, "random_effects": random_effects, "residual_error": residual_error, "design": "analysis-design" if design is None else "provided", "simulation_mode": "new-replicate", }, ) raise ValueError("output must be 'numpy' or 'table'.")
[docs] def vpc( self, *, data: Sequence[float] | None = None, independent: Sequence[float] | None = None, bins: str | int | Sequence[float] = "adaptive", prediction_corrected: bool = False, simulations: int = 1000, seed: int | None = None, ) -> DiagnosticTable: observed = ( self.fitted_values + self.residuals if data is None else np.asarray(data, dtype=float) ) simulated = self.simulate(n_replicates=simulations, seed=seed) assert isinstance(simulated, np.ndarray) return vpc_table( observed, simulated, independent=independent, bins=bins, prediction_corrected=prediction_corrected, seed=seed, )
[docs] def to_dict(self) -> dict[str, Any]: extra = {key: value for key, value in self.extra.items() if not callable(value)} return { "result_schema_version": self.result_schema_version, "model_ir": ( self.model_ir.to_dict() if hasattr(self.model_ir, "to_dict") else self.model_ir ), "parameters": dict(self.parameters), "unconstrained_parameters": dict(self.unconstrained_parameters), "parameter_covariance": self.parameter_covariance, "fitted_values": self.fitted_values, "residuals": self.residuals, "random_effects": dict(self.random_effects), "objective": self.objective, "log_likelihood": self.log_likelihood, "method": self.method, "engine": self.engine, "convergence": self.convergence.to_dict(), "manifest": self.manifest.to_dict(), "warnings": [warning.to_dict() for warning in self.warnings], "diagnostic_data": { name: table.to_dict() for name, table in self.diagnostic_data.items() }, "extra": extra, }
[docs] def save(self, path: str | Path) -> Path: """Save the full result as versioned JSON; never pickle.""" destination = write_json(path, self.to_dict()) digest_path = destination.with_suffix(destination.suffix + ".sha256") digest_path.write_text(stable_hash(self.to_dict()) + "\n", encoding="ascii") return destination
[docs] @classmethod def from_dict(cls, value: Mapping[str, Any]) -> FitResult: version = str(value.get("result_schema_version", "")) if version != _RESULT_SCHEMA_VERSION: raise ValueError( f"Unsupported result schema {version!r}; this release supports " f"{_RESULT_SCHEMA_VERSION!r}." ) return cls( model_ir=value["model_ir"], parameters=value.get("parameters", {}), unconstrained_parameters=value.get("unconstrained_parameters", {}), parameter_covariance=value.get("parameter_covariance"), fitted_values=np.asarray(value.get("fitted_values", []), dtype=float), residuals=np.asarray(value.get("residuals", []), dtype=float), random_effects=value.get("random_effects", {}), objective=float(value["objective"]), log_likelihood=( None if value.get("log_likelihood") is None else float(value["log_likelihood"]) ), method=str(value["method"]), engine=str(value["engine"]), convergence=ConvergenceReport.from_dict(value["convergence"]), manifest=RunManifest.from_dict(value["manifest"]), warnings=tuple( WarningRecord( code=str(item["code"]), severity=str(item["severity"]), message=str(item["message"]), component=item.get("component"), remediation=item.get("remediation"), details=item.get("details", {}), ) for item in value.get("warnings", ()) ), diagnostic_data={ name: DiagnosticTable.from_dict(table) for name, table in value.get("diagnostic_data", {}).items() }, extra=value.get("extra", {}), result_schema_version=version, )
[docs] @classmethod def load( cls, path: str | Path, *, verify_integrity: bool = True, require_sidecar: bool = False, ) -> FitResult: """Load an archived result and verify its hash sidecar when available. Legacy or externally produced JSON may omit the sidecar. Set ``require_sidecar=True`` when the calling workflow requires an integrity record. Integrity verification can be disabled only explicitly. """ source = Path(path) value = read_json(source) digest_path = source.with_suffix(source.suffix + ".sha256") if require_sidecar and not digest_path.is_file(): raise ValidationError( f"Required result integrity sidecar is missing: {digest_path}.", code="RESULT-INTEGRITY-MISSING-001", remediation="Restore the .sha256 sidecar or load with require_sidecar=False.", ) if verify_integrity and digest_path.is_file(): expected = digest_path.read_text(encoding="ascii").strip() observed = stable_hash(value) if expected != observed: raise ValidationError( "Archived result does not match its integrity sidecar.", code="RESULT-INTEGRITY-001", remediation="Restore an untampered result and matching .sha256 sidecar.", details={"expected": expected, "observed": observed}, ) result = cls.from_dict(value) if verify_integrity: try: observed_model_ir_hash = fingerprint_model_ir(result.model_ir) except (TypeError, ValueError) as error: raise ValidationError( "Archived result contains an invalid model IR payload.", code="RESULT-MODEL-IR-INTEGRITY-001", remediation="Restore an untampered result with a valid model IR mapping.", details={"error": str(error)}, ) from error expected_model_ir_hash = result.manifest.model_ir_hash if ( not isinstance(expected_model_ir_hash, str) or expected_model_ir_hash != observed_model_ir_hash ): raise ValidationError( "Archived model IR does not match the run manifest.", code="RESULT-MODEL-IR-INTEGRITY-001", remediation="Restore an untampered model IR and matching run manifest.", details={ "expected": expected_model_ir_hash, "observed": observed_model_ir_hash, }, ) if verify_integrity and result.manifest.output_hashes: output_values = { "parameters": result.parameters, "objective": result.objective, "fitted_values": result.fitted_values, "residuals": result.residuals, } failures = { name: { "expected": expected, "observed": ( stable_hash(output_values[name]) if name in output_values else "<unknown output>" ), } for name, expected in result.manifest.output_hashes.items() if name not in output_values or stable_hash(output_values[name]) != expected } if failures: raise ValidationError( "Archived result outputs do not match the run manifest.", code="RESULT-MANIFEST-INTEGRITY-001", details={"failures": failures}, ) return result
[docs] def summary(self) -> str: """Return a concise text summary separating estimates and convergence.""" width = max((len(name) for name in self.parameters), default=9) parameter_lines = [ f" {name:<{width}} {value: .8g}" for name, value in self.parameters.items() ] lines = [ f"PyMixEF fit ({self.engine}, {self.method})", f"Observations: {self.n_observations}", f"Objective: {self.objective:.10g}", ( "Log likelihood: unavailable" if self.log_likelihood is None else f"Log likelihood: {self.log_likelihood:.10g}" ), f"Convergence: {self.convergence.status}", "Parameters:", *(parameter_lines or [" <none>"]), ] if self.convergence.warnings: lines.append("Warnings:") lines.extend( f" [{warning.code}] {warning.message}" for warning in self.convergence.warnings ) return "\n".join(lines)
def __repr__(self) -> str: return self.summary()