Source code for pymixef.compare

"""Cross-platform parameter and objective comparison."""

from __future__ import annotations

import html
from collections.abc import Callable, Mapping
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import numpy as np

from ._contracts import CompatibilityIssue
from ._serialization import read_json, write_json
from .diagnostics import DiagnosticTable
from .interoperability.base import CompatibilityReport
from .results import FitResult


def _reference_values(reference: Any) -> tuple[dict[str, float], float | None]:
    if isinstance(reference, FitResult):
        return dict(reference.parameters), reference.objective
    if isinstance(reference, (str, Path)):
        path = Path(reference)
        if path.is_dir():
            candidates = (
                path / "result.json",
                path / "parameters.json",
                path / "reference.json",
            )
            existing = next((candidate for candidate in candidates if candidate.exists()), None)
            if existing is None:
                raise FileNotFoundError(
                    "Reference directory must contain result.json, parameters.json, "
                    "or reference.json."
                )
            value = read_json(existing)
        else:
            value = read_json(path)
    elif isinstance(reference, Mapping):
        value = reference
    else:
        raise TypeError("reference must be a FitResult, mapping, JSON file, or directory.")
    if isinstance(value, FitResult):
        return dict(value.parameters), value.objective
    if "parameters" in value:
        parameters = value["parameters"]
        objective = value.get("objective")
    else:
        parameters = value
        objective = None
    return (
        {str(name): float(number) for name, number in parameters.items()},
        None if objective is None else float(objective),
    )


[docs] @dataclass(frozen=True, slots=True) class ComparisonResult: """Aligned comparison plus its convention-compatibility report.""" table: DiagnosticTable compatibility: CompatibilityReport objective_difference: float | None conventions: Mapping[str, Any]
[docs] def to_dict(self) -> dict[str, Any]: return { "table": self.table.to_dict(), "compatibility": self.compatibility.to_dict(), "objective_difference": self.objective_difference, "conventions": dict(self.conventions), }
[docs] def assert_within(self, tolerances: Mapping[str, float]) -> None: absolute = np.asarray(self.table.columns["absolute_difference"], dtype=float) relative = np.asarray(self.table.columns["relative_difference"], dtype=float) if "parameters_abs" in tolerances and np.any(absolute > tolerances["parameters_abs"]): raise AssertionError( f"Absolute parameter difference {np.max(absolute):.6g} exceeds " f"{tolerances['parameters_abs']:.6g}." ) fixed_relative = tolerances.get("fixed_effects_rel", tolerances.get("parameters_rel")) if fixed_relative is not None and np.any(relative > fixed_relative): raise AssertionError( f"Relative parameter difference {np.max(relative):.6g} exceeds " f"{fixed_relative:.6g}." ) if ( "objective_abs" in tolerances and self.objective_difference is not None and abs(self.objective_difference) > tolerances["objective_abs"] ): raise AssertionError( f"Objective difference {self.objective_difference:.6g} exceeds " f"{tolerances['objective_abs']:.6g}." )
[docs] def write_report(self, path: str | Path) -> Path: destination = Path(path) if destination.suffix.lower() == ".json": return write_json(destination, self.to_dict()) if destination.suffix.lower() != ".html": raise ValueError("Comparison reports support .json and .html.") rows = "\n".join( "<tr>" + "".join( f"<td>{html.escape(str(self.table.columns[column][row]))}</td>" for column in self.table.columns ) + "</tr>" for row in range(len(self.table)) ) header = "".join(f"<th>{html.escape(column)}</th>" for column in self.table.columns) document = f"""<!doctype html> <html lang="en"><meta charset="utf-8"><title>PyMixEF comparison</title> <style>body{{font:16px system-ui;max-width:1000px;margin:2rem auto}} table{{border-collapse:collapse}}th,td{{padding:.4rem;border:1px solid #bbb}}</style> <h1>PyMixEF cross-platform comparison</h1> <p>Compatibility: {"supported" if self.compatibility.supported else "unsupported constructs present"}</p> <p>Objective difference: {html.escape(str(self.objective_difference))}</p> <table><thead><tr>{header}</tr></thead><tbody>{rows}</tbody></table> </html> """ destination.parent.mkdir(parents=True, exist_ok=True) destination.write_text(document, encoding="utf-8") return destination
[docs] @dataclass(frozen=True, slots=True) class ApproximationSensitivityResult: """Cross-setting refit comparisons with explicit failure accounting.""" table: DiagnosticTable fits: Mapping[str, FitResult] failures: tuple[Mapping[str, Any], ...] baseline: str settings: Mapping[str, Mapping[str, Any]] materiality: Mapping[str, float] @property def successful_scenarios(self) -> int: """Number of settings whose fit completed successfully.""" return len(self.fits) @property def failed_scenarios(self) -> int: """Number of settings whose fit failed or returned unusable output.""" return len(self.failures)
[docs] def to_dict(self) -> dict[str, Any]: """Return the comparison and scenario metadata without duplicating fits.""" return { "table": self.table.to_dict(), "failures": [dict(item) for item in self.failures], "baseline": self.baseline, "settings": {name: dict(value) for name, value in self.settings.items()}, "materiality": dict(self.materiality), "successful_scenarios": self.successful_scenarios, "failed_scenarios": self.failed_scenarios, }
def _aligned_standard_errors( fit: FitResult, parameter_names: tuple[str, ...], *, scenario: str, ) -> np.ndarray: covariance = fit.parameter_covariance if covariance is None: raise RuntimeError( f"Scenario {scenario!r} did not return parameter covariance " "required for standard-error sensitivity." ) matrix = np.asarray(covariance, dtype=float) expected = (len(parameter_names), len(parameter_names)) if matrix.shape != expected: raise RuntimeError( f"Scenario {scenario!r} parameter covariance has shape " f"{matrix.shape}; expected {expected}." ) if np.any(~np.isfinite(matrix)) or not np.allclose( matrix, matrix.T, rtol=1e-8, atol=1e-10, ): raise RuntimeError( f"Scenario {scenario!r} parameter covariance must be finite and symmetric." ) diagonal = np.diag(matrix) scale = max(float(np.max(np.abs(diagonal))), 1.0) if np.any(diagonal < -1e-10 * scale): raise RuntimeError(f"Scenario {scenario!r} parameter covariance has negative variances.") eigenvalues = np.linalg.eigvalsh((matrix + matrix.T) / 2.0) eigenvalue_scale = max(float(np.max(np.abs(eigenvalues))), 1.0) if float(eigenvalues[0]) < -1e-10 * eigenvalue_scale: raise RuntimeError( f"Scenario {scenario!r} parameter covariance must be positive semidefinite." ) return np.sqrt(np.maximum(diagonal, 0.0))
[docs] def approximation_sensitivity( fit_function: Callable[[Mapping[str, Any]], FitResult], scenarios: Mapping[str, Mapping[str, Any]], *, materiality: Mapping[str, float], baseline: str | None = None, ) -> ApproximationSensitivityResult: """Refit named approximation settings and compare aligned outputs. ``fit_function`` receives an independent deep copy of one settings mapping per scenario. The baseline must succeed; other failures are retained with scenario, exception type, and message. Successful fits must expose the same parameter names and an aligned parameter covariance matrix. The tidy result reports parameter, standard-error, and objective changes relative to the baseline. ``materiality`` must define nonnegative thresholds named ``parameter_relative``, ``standard_error_relative``, and ``objective_absolute``. Per-row flags preserve the caller's scientific decision rule instead of embedding an undocumented universal threshold. This callback-based contract supports sensitivity analyses such as Laplace optimizer tolerances, MMRM covariance/degree-of-freedom choices, or ODE solver tolerances without embedding engine-specific assumptions. """ copied_settings = {str(name): deepcopy(dict(settings)) for name, settings in scenarios.items()} if len(copied_settings) < 2: raise ValueError("Approximation sensitivity requires at least two named scenarios.") if len(copied_settings) != len(scenarios): raise ValueError("Scenario names must be unique after string normalization.") baseline_name = next(iter(copied_settings)) if baseline is None else str(baseline) if baseline_name not in copied_settings: raise KeyError(f"Baseline scenario {baseline_name!r} is absent.") required_materiality = { "parameter_relative", "standard_error_relative", "objective_absolute", } if set(materiality) != required_materiality: raise ValueError( "materiality must define exactly parameter_relative, " "standard_error_relative, and objective_absolute." ) materiality_thresholds = { name: float(materiality[name]) for name in sorted(required_materiality) } if any(not np.isfinite(value) or value < 0 for value in materiality_thresholds.values()): raise ValueError("materiality thresholds must be finite and nonnegative.") ordered_names = (baseline_name,) + tuple( name for name in copied_settings if name != baseline_name ) fits: dict[str, FitResult] = {} failures: list[dict[str, Any]] = [] for name in ordered_names: try: fit = fit_function(deepcopy(copied_settings[name])) if not isinstance(fit, FitResult): raise TypeError("fit_function must return a FitResult") if fit.convergence.status == "failed": raise RuntimeError("fit returned failed convergence status") if not fit.parameters or not all( np.isfinite(float(value)) for value in fit.parameters.values() ): raise RuntimeError("fit returned empty or non-finite parameters") if not np.isfinite(float(fit.objective)): raise RuntimeError("fit returned a non-finite objective") fits[name] = fit except Exception as error: failure = { "scenario": name, "type": type(error).__name__, "message": str(error), } failures.append(failure) if name == baseline_name: raise RuntimeError( f"Baseline scenario {baseline_name!r} failed: {error}" ) from error baseline_fit = fits[baseline_name] parameter_names = tuple(baseline_fit.parameters) baseline_values = np.asarray( [baseline_fit.parameters[name] for name in parameter_names], dtype=float, ) baseline_standard_errors = _aligned_standard_errors( baseline_fit, parameter_names, scenario=baseline_name, ) output: dict[str, list[Any]] = { "scenario": [], "parameter": [], "estimate": [], "baseline_estimate": [], "difference": [], "absolute_difference": [], "relative_difference": [], "standard_error": [], "baseline_standard_error": [], "standard_error_difference": [], "relative_standard_error_difference": [], "objective": [], "objective_difference": [], "material_parameter": [], "material_standard_error": [], "material_objective": [], "material": [], } for name in ordered_names: scenario_fit = fits.get(name) if scenario_fit is None: continue if set(scenario_fit.parameters) != set(parameter_names): failures.append( { "scenario": name, "type": "ParameterAlignmentError", "message": "parameter set differs from the baseline", } ) del fits[name] continue estimates = np.asarray( [scenario_fit.parameters[item] for item in parameter_names], dtype=float ) try: standard_errors = _aligned_standard_errors( scenario_fit, parameter_names, scenario=name, ) except (RuntimeError, ValueError) as error: failures.append( { "scenario": name, "type": type(error).__name__, "message": str(error), } ) del fits[name] continue differences = estimates - baseline_values relatives = np.divide( np.abs(differences), np.maximum.reduce( [ np.abs(baseline_values), np.abs(estimates), np.full(len(estimates), np.finfo(float).eps), ] ), ) standard_error_differences = standard_errors - baseline_standard_errors standard_error_relatives = np.divide( np.abs(standard_error_differences), np.maximum.reduce( [ np.abs(baseline_standard_errors), np.abs(standard_errors), np.full(len(standard_errors), np.finfo(float).eps), ] ), ) objective_difference = float(scenario_fit.objective - baseline_fit.objective) objective_material = ( abs(objective_difference) > materiality_thresholds["objective_absolute"] ) for position, parameter in enumerate(parameter_names): parameter_material = relatives[position] > materiality_thresholds["parameter_relative"] standard_error_material = ( standard_error_relatives[position] > materiality_thresholds["standard_error_relative"] ) output["scenario"].append(name) output["parameter"].append(parameter) output["estimate"].append(estimates[position]) output["baseline_estimate"].append(baseline_values[position]) output["difference"].append(differences[position]) output["absolute_difference"].append(abs(differences[position])) output["relative_difference"].append(relatives[position]) output["standard_error"].append(standard_errors[position]) output["baseline_standard_error"].append(baseline_standard_errors[position]) output["standard_error_difference"].append(standard_error_differences[position]) output["relative_standard_error_difference"].append(standard_error_relatives[position]) output["objective"].append(float(scenario_fit.objective)) output["objective_difference"].append(objective_difference) output["material_parameter"].append(parameter_material) output["material_standard_error"].append(standard_error_material) output["material_objective"].append(objective_material) output["material"].append( parameter_material or standard_error_material or objective_material ) return ApproximationSensitivityResult( table=DiagnosticTable( name="approximation_sensitivity", columns={name: np.asarray(values) for name, values in output.items()}, metadata={ "baseline": baseline_name, "scenario_order": list(ordered_names), "settings": copied_settings, "parameter_names": list(parameter_names), "materiality": materiality_thresholds, }, ), fits=fits, failures=tuple(failures), baseline=baseline_name, settings=copied_settings, materiality=materiality_thresholds, )
[docs] def compare( fit: FitResult, *, reference: Any, mapping: Mapping[str, str] | None = None, conventions: Mapping[str, Any] | None = None, ) -> ComparisonResult: """Compare a fit with a reference under matched objective conventions. Parameter estimates are always aligned independently. Objective differences are calculated only when the reference declares the same method, normalization-constant policy, and objective convention archived by the PyMixEF fit. """ reference_parameters, reference_objective = _reference_values(reference) parameter_mapping = dict(mapping or {}) current: list[float] = [] expected: list[float] = [] source_names: list[str] = [] target_names: list[str] = [] issues: list[CompatibilityIssue] = [] for source_name, source_value in fit.parameters.items(): target_name = parameter_mapping.get(source_name, source_name) if target_name not in reference_parameters: issues.append( CompatibilityIssue( source_name, "unsupported", f"No aligned reference parameter {target_name!r}.", ) ) continue source_names.append(source_name) target_names.append(target_name) current.append(float(source_value)) expected.append(float(reference_parameters[target_name])) issues.append( CompatibilityIssue( source_name, "exact" if source_name == target_name else "transformed", f"Aligned with {target_name!r}.", ) ) current_array = np.asarray(current) expected_array = np.asarray(expected) difference = current_array - expected_array relative = np.divide( np.abs(difference), np.maximum(np.abs(expected_array), np.finfo(float).eps), ) reference_conventions = dict(conventions or {}) fit_conventions = { "method": fit.method.lower(), "objective_convention": fit.extra.get("objective_convention"), "likelihood_includes_data_constants": fit.extra.get("likelihood_includes_data_constants"), } required = ( "method", "objective_convention", "likelihood_includes_data_constants", ) missing_fit = tuple(key for key in required if fit_conventions.get(key) is None) missing_reference = tuple(key for key in required if key not in reference_conventions) objective_compatible = True if missing_fit: objective_compatible = False issues.append( CompatibilityIssue( "PyMixEF objective conventions", "unsupported", "The fit did not archive calculation-defining fields: " + ", ".join(missing_fit) + ".", ) ) if missing_reference: objective_compatible = False issues.append( CompatibilityIssue( "reference objective conventions", "unsupported", "The reference omitted calculation-defining fields: " + ", ".join(missing_reference) + ".", ) ) if not missing_fit and not missing_reference: mismatches = { key: { "pymixef": fit_conventions[key], "reference": reference_conventions[key], } for key in required if fit_conventions[key] != reference_conventions[key] } if mismatches: objective_compatible = False issues.append( CompatibilityIssue( "objective conventions", "unsupported", f"Objective conventions differ: {mismatches}.", ) ) else: issues.append( CompatibilityIssue( "objective conventions", "exact", "Method, objective scale, and normalization constants match.", ) ) objective_difference = ( float(fit.objective - reference_objective) if reference_objective is not None and objective_compatible else None ) convention_values = { "pymixef": fit_conventions, "reference": reference_conventions, } table = DiagnosticTable( name="cross_platform_parameter_comparison", columns={ "pymixef_parameter": np.asarray(source_names), "reference_parameter": np.asarray(target_names), "pymixef_value": current_array, "reference_value": expected_array, "difference": difference, "absolute_difference": np.abs(difference), "relative_difference": relative, }, metadata={"conventions": convention_values}, ) return ComparisonResult( table=table, compatibility=CompatibilityReport( source_format="PyMixEF result", target_format="reference result", issues=tuple(issues), metadata={"mapping": parameter_mapping}, ), objective_difference=objective_difference, conventions=convention_values, )