"""Shared contracts and numerical helpers for reference estimation backends.
Backends accept either a mapping or an object (including the compiler's
``DesignMatrices`` object). If present, ``to_backend_data()`` is called first.
The common numeric fields are:
``response`` / ``y``
One-dimensional response vector.
``fixed`` / ``X``
Two-dimensional fixed-effect design matrix.
``fixed_names``
Optional names matching the columns of ``X``.
``random_blocks``
A sequence of mappings/objects. Each block supplies ``matrix``/``Z`` and
may supply row-level ``groups``/``group_codes``, ``group_levels``,
``term_names``, ``correlated``, and ``name``. A row-level random design is
expanded into one block of columns per group. An already-expanded design
can be marked with ``expanded=True``.
``residual_covariance``
Optional explicit positive-definite covariance or correlation matrix.
LMM treats it as a correlation template and estimates a common scale unless
``residual_covariance_fixed=True``.
``weights`` / ``offset`` / ``trials``
Optional observation vectors used by applicable engines.
MMRM additionally consumes subject/group labels, visit labels, visit times, and
a covariance structure; those details are documented in ``mmrm.py``.
Every backend returns the backend-neutral mapping specified in ARCHITECTURE.md:
parameters, unconstrained_parameters, parameter_covariance, fitted_values,
residuals, random_effects, objective, log_likelihood, method, engine,
convergence, diagnostic_data, and extra.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import Any, Protocol, runtime_checkable
import numpy as np
from numpy.typing import ArrayLike, NDArray
from scipy import linalg
try: # The shared semantic error layer may be imported independently.
from ..errors import EngineCompatibilityError, PyMixEFError # type: ignore[attr-defined]
except (ImportError, AttributeError):
class PyMixEFError(Exception):
"""Fallback used only while the shared error module is unavailable."""
class EngineCompatibilityError(PyMixEFError):
"""Fallback compatibility error."""
[docs]
class BackendError(PyMixEFError):
"""A stable, machine-readable backend input or numerical error."""
def __init__(
self,
message: str,
*,
code: str = "BACKEND-001",
details: Mapping[str, Any] | None = None,
suggested_engines: Sequence[str] = (),
) -> None:
self.code = code
self.message = message
self.details = dict(details or {})
self.suggested_engines = tuple(suggested_engines)
super().__init__(f"{code}: {message}")
[docs]
def to_dict(self) -> dict[str, Any]:
return {
"code": self.code,
"message": self.message,
"details": dict(self.details),
"suggested_engines": list(self.suggested_engines),
}
[docs]
class BackendUnsupportedError(EngineCompatibilityError):
"""The requested model is scientifically unsupported by this engine."""
def __init__(
self,
message: str,
*,
details: Mapping[str, Any] | None = None,
suggested_engines: Sequence[str] = (),
) -> None:
self.code = "ENGINE-UNSUPPORTED-001"
self.message = message
self.details = dict(details or {})
self.suggested_engines = tuple(suggested_engines)
try:
super().__init__(
message,
code=self.code,
details=self.details,
suggested_engines=self.suggested_engines,
)
except TypeError: # pragma: no cover - only the isolated fallback
super().__init__(f"{self.code}: {message}")
[docs]
def to_dict(self) -> dict[str, Any]:
parent = getattr(super(), "to_dict", None)
if callable(parent):
result = parent()
result["suggested_engines"] = list(self.suggested_engines)
return result
return {
"code": self.code,
"message": self.message,
"details": dict(self.details),
"suggested_engines": list(self.suggested_engines),
}
[docs]
class BackendNumericalError(BackendError):
"""A numerical calculation failed before a valid payload could be made."""
def __init__(self, message: str, *, details: Mapping[str, Any] | None = None) -> None:
super().__init__(message, code="BACKEND-NUMERICAL-001", details=details)
[docs]
@runtime_checkable
class Backend(Protocol):
"""Protocol implemented by all estimation engines."""
name: str
[docs]
def fit(self, data: Any, **options: Any) -> dict[str, Any]:
"""Fit a compiled numeric model and return the shared payload."""
_MISSING = object()
[docs]
def field(source: Any, *names: str, default: Any = _MISSING) -> Any:
"""Retrieve the first matching mapping key or object attribute."""
for name in names:
if isinstance(source, Mapping) and name in source:
return source[name]
if hasattr(source, name):
return getattr(source, name)
if default is _MISSING:
joined = "/".join(names)
raise BackendInputError(f"required backend field {joined!r} is missing")
return default
[docs]
def backend_mapping(data: Any) -> Any:
"""Resolve an optional ``to_backend_data`` conversion."""
converter = getattr(data, "to_backend_data", None)
if callable(converter):
data = converter()
return data
def _matrix(value: ArrayLike, name: str, *, rows: int | None = None) -> NDArray[np.float64]:
result = np.asarray(value, dtype=float)
if result.ndim == 1:
result = result[:, None]
if result.ndim != 2:
raise BackendInputError(f"{name} must be a two-dimensional numeric matrix")
if rows is not None and result.shape[0] != rows:
raise BackendInputError(
f"{name} has {result.shape[0]} rows; expected {rows}",
details={"actual_rows": result.shape[0], "expected_rows": rows},
)
if np.any(~np.isfinite(result)):
raise BackendInputError(f"{name} contains non-finite values")
return result
def _vector(value: ArrayLike, name: str, *, length: int | None = None) -> NDArray[np.float64]:
result = np.asarray(value, dtype=float)
if result.ndim != 1:
result = np.ravel(result)
if length is not None and result.size != length:
raise BackendInputError(
f"{name} has length {result.size}; expected {length}",
details={"actual_length": result.size, "expected_length": length},
)
if np.any(~np.isfinite(result)):
raise BackendInputError(f"{name} contains non-finite values")
return result
[docs]
def factorize(values: ArrayLike) -> tuple[NDArray[np.int64], tuple[Any, ...]]:
"""Factorize values in first-observed order without a pandas dependency."""
array = np.asarray(values)
if array.ndim != 1:
array = np.ravel(array)
levels: list[Any] = []
lookup: dict[Any, int] = {}
codes = np.empty(array.size, dtype=np.int64)
for index, raw in enumerate(array.tolist()):
key = raw.item() if isinstance(raw, np.generic) else raw
try:
code = lookup[key]
except KeyError:
code = len(levels)
lookup[key] = code
levels.append(key)
codes[index] = code
return codes, tuple(levels)
[docs]
@dataclass(slots=True)
class RandomBlockData:
"""One expanded random-effect design block."""
name: str
row_design: NDArray[np.float64]
design: NDArray[np.float64]
group_codes: NDArray[np.int64]
group_levels: tuple[Any, ...]
term_names: tuple[str, ...]
correlated: bool
terms_per_group: int
@property
def n_groups(self) -> int:
return len(self.group_levels)
@property
def coefficient_count(self) -> int:
return self.design.shape[1]
[docs]
def coefficient_labels(self) -> list[str]:
labels: list[str] = []
for level in self.group_levels:
labels.extend(f"{self.name}[{level!s}].{term}" for term in self.term_names)
return labels
[docs]
def prepare_random_block(raw: Any, n_rows: int, index: int) -> RandomBlockData:
"""Validate and expand one compiler random block."""
row_design = _matrix(
field(raw, "matrix", "Z", "design"), f"random_blocks[{index}].Z", rows=n_rows
)
name = str(field(raw, "name", "group_name", default=f"random{index + 1}"))
explicit_codes = field(raw, "group_codes", default=None)
groups = (
explicit_codes
if explicit_codes is not None
else field(raw, "groups", "group_labels", "group", default=None)
)
expanded = bool(field(raw, "expanded", "is_expanded", default=False))
raw_term_names = field(raw, "term_names", "column_names", default=None)
correlated = bool(field(raw, "correlated", default=True))
if groups is None:
codes = np.zeros(n_rows, dtype=np.int64)
levels = tuple(field(raw, "group_levels", default=("all",)))
if expanded:
# Without row-level group codes, an expanded design is one covariance
# block. Its columns are separate terms.
terms_per_group = row_design.shape[1]
levels = ("all",)
else:
terms_per_group = row_design.shape[1]
else:
raw_groups = np.asarray(groups)
if raw_groups.ndim != 1 or raw_groups.size != n_rows:
raise BackendInputError(
f"random block {name!r} group labels must have one entry per observation"
)
# Integer group_codes may arrive with a separate level vector.
supplied_levels = field(raw, "group_levels", "levels", default=None)
if explicit_codes is not None:
codes = raw_groups.astype(np.int64)
levels = (
tuple(np.asarray(supplied_levels).tolist())
if supplied_levels is not None
else tuple(range(int(codes.max()) + 1))
)
if np.any(codes < 0) or np.any(codes >= len(levels)):
raise BackendInputError(f"random block {name!r} contains invalid group codes")
else:
codes, levels = factorize(raw_groups)
if expanded:
if row_design.shape[1] % len(levels):
raise BackendInputError(
f"expanded random block {name!r} columns are not divisible by group count"
)
terms_per_group = row_design.shape[1] // len(levels)
else:
terms_per_group = row_design.shape[1]
if raw_term_names is None:
term_names = tuple(
"intercept" if terms_per_group == 1 and j == 0 else f"term{j + 1}"
for j in range(terms_per_group)
)
else:
term_names = tuple(str(item) for item in raw_term_names)
if len(term_names) == row_design.shape[1] and expanded and len(levels) > 1:
term_names = term_names[:terms_per_group]
if len(term_names) != terms_per_group:
raise BackendInputError(
f"random block {name!r} has {len(term_names)} term names but "
f"{terms_per_group} terms per group"
)
if expanded:
design = row_design
else:
design = np.zeros((n_rows, len(levels) * terms_per_group), dtype=float)
rows = np.arange(n_rows)
for term in range(terms_per_group):
design[rows, codes * terms_per_group + term] = row_design[:, term]
return RandomBlockData(
name=name,
row_design=row_design,
design=design,
group_codes=codes,
group_levels=levels,
term_names=term_names,
correlated=correlated,
terms_per_group=terms_per_group,
)
[docs]
@dataclass(slots=True)
class CompiledData:
"""Normalized fields shared by LMM and GLMM."""
source: Any
y: NDArray[np.float64]
X: NDArray[np.float64]
fixed_names: tuple[str, ...]
random_blocks: tuple[RandomBlockData, ...]
offset: NDArray[np.float64]
weights: NDArray[np.float64]
trials: NDArray[np.float64] | None
residual_covariance: Any
residual_covariance_fixed: bool
@property
def n_obs(self) -> int:
return self.y.size
@property
def n_fixed(self) -> int:
return self.X.shape[1]
@property
def random_design(self) -> NDArray[np.float64]:
if not self.random_blocks:
return np.zeros((self.n_obs, 0), dtype=float)
return np.concatenate([block.design for block in self.random_blocks], axis=1)
[docs]
def prepare_data(data: Any, *, require_random: bool = False) -> CompiledData:
"""Normalize common compiler output for a numerical backend."""
source = backend_mapping(data)
y = _vector(field(source, "response", "y"), "response")
X = _matrix(field(source, "fixed", "X"), "fixed", rows=y.size)
raw_names = field(source, "fixed_names", "column_names", default=None)
if raw_names is None:
fixed_names = tuple(f"beta[{j}]" for j in range(X.shape[1]))
else:
fixed_names = tuple(str(item) for item in raw_names)
if len(fixed_names) != X.shape[1]:
raise BackendInputError("fixed_names length does not match fixed design columns")
raw_blocks = field(source, "random_blocks", "random", default=())
if raw_blocks is None:
raw_blocks = ()
blocks = tuple(prepare_random_block(item, y.size, j) for j, item in enumerate(raw_blocks))
if require_random and not blocks:
raise BackendInputError("this engine requires at least one random-effects block")
offset_value = field(source, "offset", default=None)
offset = _vector(
np.zeros(y.size) if offset_value is None else offset_value,
"offset",
length=y.size,
)
weights_value = field(source, "weights", default=None)
weights = _vector(
np.ones(y.size) if weights_value is None else weights_value,
"weights",
length=y.size,
)
if np.any(weights <= 0):
raise BackendInputError("weights must be strictly positive")
trials_raw = field(source, "trials", "binomial_trials", default=None)
trials = None if trials_raw is None else _vector(trials_raw, "trials", length=y.size)
return CompiledData(
source=source,
y=y,
X=X,
fixed_names=fixed_names,
random_blocks=blocks,
offset=offset,
weights=weights,
trials=trials,
residual_covariance=field(source, "residual_covariance", "residual", "R", default=None),
residual_covariance_fixed=bool(
field(source, "residual_covariance_fixed", "R_fixed", default=False)
),
)
[docs]
@dataclass(slots=True)
class CovarianceParameterization:
"""Positive-definite random covariance parameterization."""
block: RandomBlockData
@property
def size(self) -> int:
q = self.block.terms_per_group
return q if not self.block.correlated else q * (q + 1) // 2
[docs]
def initial(self, scale: float) -> NDArray[np.float64]:
q = self.block.terms_per_group
if not self.block.correlated:
return np.full(q, np.log(max(scale, 1e-3)))
result: list[float] = []
for row in range(q):
for col in range(row + 1):
result.append(np.log(max(scale, 1e-3)) if row == col else 0.0)
return np.asarray(result)
[docs]
def matrix(self, theta: ArrayLike) -> NDArray[np.float64]:
theta = np.asarray(theta, dtype=float)
q = self.block.terms_per_group
if theta.size != self.size:
raise ValueError("wrong covariance parameter vector length")
if not self.block.correlated:
return np.diag(np.exp(2 * theta))
chol = np.zeros((q, q), dtype=float)
cursor = 0
for row in range(q):
for col in range(row + 1):
chol[row, col] = np.exp(theta[cursor]) if row == col else theta[cursor]
cursor += 1
return chol @ chol.T
[docs]
def expanded_matrix(self, theta: ArrayLike) -> NDArray[np.float64]:
return np.kron(np.eye(self.block.n_groups), self.matrix(theta))
[docs]
def names_and_values(self, theta: ArrayLike) -> dict[str, float]:
covariance = self.matrix(theta)
values: dict[str, float] = {}
q = self.block.terms_per_group
std = np.sqrt(np.diag(covariance))
for j, term in enumerate(self.block.term_names):
values[f"sd({self.block.name}:{term})"] = float(std[j])
if self.block.correlated:
for row in range(1, q):
for col in range(row):
values[
f"corr({self.block.name}:{self.block.term_names[row]},"
f"{self.block.term_names[col]})"
] = float(covariance[row, col] / (std[row] * std[col]))
return values
[docs]
def unconstrained_names(self) -> list[str]:
q = self.block.terms_per_group
if not self.block.correlated:
return [f"log_sd({self.block.name}:{term})" for term in self.block.term_names]
result: list[str] = []
for row in range(q):
for col in range(row + 1):
if row == col:
result.append(f"log_chol({self.block.name}:{self.block.term_names[row]})")
else:
result.append(
f"chol({self.block.name}:{self.block.term_names[row]},"
f"{self.block.term_names[col]})"
)
return result
[docs]
def covariance_slices(
blocks: Sequence[RandomBlockData],
) -> tuple[tuple[CovarianceParameterization, slice], ...]:
"""Build covariance parameterizations and their theta slices."""
output: list[tuple[CovarianceParameterization, slice]] = []
cursor = 0
for block in blocks:
parameterization = CovarianceParameterization(block)
output.append((parameterization, slice(cursor, cursor + parameterization.size)))
cursor += parameterization.size
return tuple(output)
[docs]
def random_covariance(
parameterizations: Sequence[tuple[CovarianceParameterization, slice]],
theta: ArrayLike,
) -> NDArray[np.float64]:
"""Assemble block-diagonal covariance for all expanded coefficients."""
theta = np.asarray(theta, dtype=float)
matrices = [item.expanded_matrix(theta[section]) for item, section in parameterizations]
if not matrices:
return np.zeros((0, 0), dtype=float)
return linalg.block_diag(*matrices)
[docs]
def safe_cholesky(
matrix: ArrayLike, *, jitter_scale: float = 1e-10, max_tries: int = 5
) -> tuple[NDArray[np.float64], float]:
"""Cholesky factor a symmetric matrix, adding only reported tiny jitter."""
matrix = np.asarray(matrix, dtype=float)
matrix = (matrix + matrix.T) / 2
scale = max(float(np.max(np.abs(np.diag(matrix)))), 1.0)
jitter = 0.0
for attempt in range(max_tries):
try:
return linalg.cholesky(matrix + jitter * np.eye(matrix.shape[0]), lower=True), jitter
except linalg.LinAlgError:
jitter = jitter_scale * scale * (10**attempt)
raise linalg.LinAlgError("matrix is not positive definite after bounded jitter")
[docs]
def cho_solve(cholesky: NDArray[np.float64], value: ArrayLike) -> NDArray[np.float64]:
"""Solve against a lower Cholesky factor."""
return linalg.cho_solve((cholesky, True), np.asarray(value, dtype=float), check_finite=False)
[docs]
def logdet_from_cholesky(cholesky: NDArray[np.float64]) -> float:
"""Return the log determinant represented by a lower Cholesky factor.
``cholesky`` is assumed to have a positive diagonal; this helper does not
validate that the factor came from a positive-definite matrix.
"""
return float(2 * np.log(np.diag(cholesky)).sum())
[docs]
def finite_hessian(
function: Any,
point: ArrayLike,
*,
relative_step: float = 2e-4,
) -> NDArray[np.float64]:
"""Central finite-difference Hessian for small reference problems."""
x = np.asarray(point, dtype=float)
n = x.size
h = relative_step * np.maximum(1.0, np.abs(x))
result = np.empty((n, n), dtype=float)
f0 = float(function(x))
for i in range(n):
ei = np.zeros(n)
ei[i] = h[i]
result[i, i] = (float(function(x + ei)) - 2 * f0 + float(function(x - ei))) / h[i] ** 2
for j in range(i):
ej = np.zeros(n)
ej[j] = h[j]
value = (
float(function(x + ei + ej))
- float(function(x + ei - ej))
- float(function(x - ei + ej))
+ float(function(x - ei - ej))
) / (4 * h[i] * h[j])
result[i, j] = result[j, i] = value
return (result + result.T) / 2
[docs]
def finite_gradient(
function: Any,
point: ArrayLike,
*,
relative_step: float = 1e-6,
) -> NDArray[np.float64]:
"""Central finite-difference gradient used for termination verification."""
x = np.asarray(point, dtype=float)
step = relative_step * np.maximum(1.0, np.abs(x))
result = np.empty(x.size)
for position in range(x.size):
delta = np.zeros(x.size)
delta[position] = step[position]
result[position] = (float(function(x + delta)) - float(function(x - delta))) / (
2 * step[position]
)
return result
[docs]
def select_optimizer_result(
refined: Any,
rescue: Any,
*,
objective_tolerance: float,
) -> Any:
"""Prefer successful finite termination before comparing objective values.
A line-search refinement can improve an objective by floating-point noise
while still reporting an abnormal termination. In that case, retaining a
successfully terminated derivative-free rescue is more informative than
replacing it with the failed refinement.
"""
refined_objective = float(getattr(refined, "fun", np.nan))
rescue_objective = float(getattr(rescue, "fun", np.nan))
refined_finite = bool(np.isfinite(refined_objective))
rescue_finite = bool(np.isfinite(rescue_objective))
refined_converged = bool(getattr(refined, "success", False) and refined_finite)
rescue_converged = bool(getattr(rescue, "success", False) and rescue_finite)
if refined_converged != rescue_converged:
converged = refined if refined_converged else rescue
failed = rescue if refined_converged else refined
converged_objective = refined_objective if refined_converged else rescue_objective
failed_objective = rescue_objective if refined_converged else refined_objective
if np.isfinite(failed_objective) and (
failed_objective + objective_tolerance < converged_objective
):
return failed
return converged
if refined_finite != rescue_finite:
return refined if refined_finite else rescue
if refined_finite and refined_objective <= rescue_objective + objective_tolerance:
return refined
return rescue
[docs]
def optimizer_covariance(
function: Any,
point: ArrayLike,
result: Any,
*,
compute_hessian: bool,
finite_difference_limit: int,
) -> tuple[NDArray[np.float64], bool | None, str]:
"""Return auditable optimizer-scale covariance at the selected optimum."""
location = np.asarray(point, dtype=float)
if location.ndim != 1:
raise ValueError("An optimizer point must be one-dimensional.")
if location.size == 0:
return np.zeros((0, 0)), True, "not-required"
def observed_covariance(source: str) -> tuple[NDArray[np.float64], bool, str]:
try:
hessian = finite_hessian(function, location)
covariance, positive = covariance_from_hessian(hessian)
except (ValueError, FloatingPointError, linalg.LinAlgError) as exc:
raise BackendNumericalError(
"could not calculate finite covariance at the selected optimizer result",
details={"curvature_source": source, "parameters": int(location.size)},
) from exc
return covariance, positive, source
if compute_hessian and location.size <= finite_difference_limit:
return observed_covariance("observed-finite-difference")
inverse = getattr(result, "hess_inv", None)
try:
dense_inverse = inverse.todense() if hasattr(inverse, "todense") else inverse
covariance = np.asarray(dense_inverse, dtype=float)
valid_inverse = bool(
covariance.shape == (location.size, location.size)
and np.all(np.isfinite(covariance))
and np.allclose(covariance, covariance.T, rtol=1e-8, atol=1e-10)
)
except (TypeError, ValueError):
valid_inverse = False
covariance = np.empty((0, 0))
if valid_inverse:
return (covariance + covariance.T) / 2.0, None, "optimizer-inverse-hessian"
if location.size <= finite_difference_limit:
return observed_covariance("observed-finite-difference-fallback")
raise BackendNumericalError(
"the selected optimizer result did not provide a valid inverse Hessian",
details={
"parameters": int(location.size),
"finite_difference_limit": int(finite_difference_limit),
},
)
[docs]
def covariance_from_hessian(hessian: ArrayLike) -> tuple[NDArray[np.float64], bool]:
"""Return a symmetric generalized inverse and definiteness flag."""
hessian = np.asarray(hessian, dtype=float)
eigenvalues, eigenvectors = np.linalg.eigh((hessian + hessian.T) / 2)
threshold = max(np.max(np.abs(eigenvalues)), 1.0) * 1e-9
positive = bool(np.all(eigenvalues > threshold))
inverse_values = np.where(eigenvalues > threshold, 1.0 / eigenvalues, 0.0)
return (eigenvectors * inverse_values) @ eigenvectors.T, positive
[docs]
def convergence_mapping(
*,
success: bool,
message: str,
iterations: int,
function_evaluations: int,
gradient: ArrayLike | None,
hessian_positive_definite: bool | None,
singular: bool,
boundary_parameters: Sequence[str] = (),
warnings: Sequence[Mapping[str, Any]] = (),
extra: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Create a rich convergence mapping accepted by the public result layer."""
warning_records = [dict(item) for item in warnings]
warning_codes = {str(item.get("code", "")) for item in warning_records}
if gradient is None:
gradient_norm = None
else:
gradient_array = np.asarray(gradient, dtype=float)
gradient_norm = 0.0 if gradient_array.size == 0 else float(np.max(np.abs(gradient_array)))
if (
gradient_norm is not None
and (not np.isfinite(gradient_norm) or gradient_norm > 1e-4)
and "NUM-GRADIENT-LARGE-001" not in warning_codes
):
warning_records.append(
{
"code": "NUM-GRADIENT-LARGE-001",
"severity": "review",
"message": "The scaled gradient remains larger than its convergence tolerance.",
"component": "optimization",
"remediation": "Rescale parameters, tighten controls, and compare "
"independent starts or engines.",
"details": {"scaled_gradient_inf_norm": gradient_norm},
}
)
if hessian_positive_definite is False and "NUM-HESSIAN-INDEFINITE-001" not in warning_codes:
warning_records.append(
{
"code": "NUM-HESSIAN-INDEFINITE-001",
"severity": "review",
"message": "The observed Hessian is not positive definite.",
"component": "inference",
"remediation": "Do not use naive Wald uncertainty; inspect gradients, "
"profiles, and alternative starts.",
}
)
if singular and "COV-SINGULAR-001" not in warning_codes:
warning_records.append(
{
"code": "COV-SINGULAR-001",
"severity": "review",
"message": "An estimated covariance block is singular or near-singular.",
"component": "covariance",
"remediation": "Review the singularity report and random-effect design.",
}
)
if boundary_parameters and "COV-BOUNDARY-001" not in warning_codes:
warning_records.append(
{
"code": "COV-BOUNDARY-001",
"severity": "review",
"message": "One or more variance parameters are on a numerical boundary.",
"component": "covariance",
"details": {"parameters": list(boundary_parameters)},
}
)
numerical_suspect = bool(
(gradient_norm is not None and (not np.isfinite(gradient_norm) or gradient_norm > 1e-4))
or hessian_positive_definite is False
)
warning_state = (
singular or bool(boundary_parameters) or bool(warning_records) or numerical_suspect
)
status = "failed" if not success else ("warning" if warning_state else "converged")
result: dict[str, Any] = {
"status": status,
"optimizer_terminated": bool(success),
"message": str(message),
"iterations": int(iterations),
"function_evaluations": int(function_evaluations),
"scaled_gradient_inf_norm": gradient_norm,
"gradient_inf_norm": gradient_norm,
"hessian_positive_definite": hessian_positive_definite,
"singular": bool(singular),
"boundary_parameters": list(boundary_parameters),
"warnings": warning_records,
}
result.update(dict(extra or {}))
return result
_PAYLOAD_KEYS = (
"parameters",
"unconstrained_parameters",
"parameter_covariance",
"fitted_values",
"residuals",
"random_effects",
"objective",
"log_likelihood",
"method",
"engine",
"convergence",
"diagnostic_data",
"extra",
)
[docs]
def make_payload(**values: Any) -> dict[str, Any]:
"""Validate and order the shared backend payload."""
missing = [key for key in _PAYLOAD_KEYS if key not in values]
if missing:
raise BackendNumericalError(
f"backend implementation omitted payload fields: {', '.join(missing)}"
)
return validate_payload({key: values[key] for key in _PAYLOAD_KEYS})
[docs]
def validate_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
"""Validate the backend-neutral result contract and return canonical key order.
The validator is shared by built-in and third-party backends. It checks
presence, numeric finiteness, observation alignment, covariance shape,
convergence state, and container types without imposing an
engine-specific parameterization.
"""
missing = [key for key in _PAYLOAD_KEYS if key not in payload]
if missing:
raise BackendNumericalError(
f"backend implementation omitted payload fields: {', '.join(missing)}"
)
parameters = payload["parameters"]
unconstrained = payload["unconstrained_parameters"]
if not isinstance(parameters, Mapping) or not parameters:
raise BackendNumericalError("backend parameters must be a non-empty mapping")
if not isinstance(unconstrained, Mapping):
raise BackendNumericalError("backend unconstrained_parameters must be a mapping")
for label, values in (
("parameters", parameters.values()),
("unconstrained_parameters", unconstrained.values()),
):
try:
numeric = np.asarray([float(value) for value in values], dtype=float)
except (TypeError, ValueError) as error:
raise BackendNumericalError(f"backend {label} must be numeric") from error
if np.any(~np.isfinite(numeric)):
raise BackendNumericalError(f"backend {label} contains non-finite values")
try:
fitted = np.asarray(payload["fitted_values"], dtype=float)
residuals = np.asarray(payload["residuals"], dtype=float)
except (TypeError, ValueError) as error:
raise BackendNumericalError(
"backend fitted_values and residuals must be numeric"
) from error
if fitted.ndim != 1 or residuals.ndim != 1 or fitted.shape != residuals.shape:
raise BackendNumericalError(
"backend fitted_values and residuals must be aligned one-dimensional arrays"
)
if np.any(~np.isfinite(fitted)) or np.any(~np.isfinite(residuals)):
raise BackendNumericalError("backend fitted_values or residuals contain non-finite values")
try:
objective = float(payload["objective"])
except (TypeError, ValueError) as error:
raise BackendNumericalError("backend objective must be numeric") from error
log_likelihood = payload["log_likelihood"]
if not np.isfinite(objective):
raise BackendNumericalError("backend objective must be finite")
if log_likelihood is not None:
try:
numeric_log_likelihood = float(log_likelihood)
except (TypeError, ValueError) as error:
raise BackendNumericalError("backend log_likelihood must be numeric or None") from error
if not np.isfinite(numeric_log_likelihood):
raise BackendNumericalError("backend log_likelihood must be finite or None")
covariance = payload["parameter_covariance"]
if covariance is not None:
try:
covariance_array = np.asarray(covariance, dtype=float)
except (TypeError, ValueError) as error:
raise BackendNumericalError("backend parameter_covariance must be numeric") from error
if covariance_array.ndim != 2 or covariance_array.shape[0] != covariance_array.shape[1]:
raise BackendNumericalError("backend parameter_covariance must be square")
if np.any(~np.isfinite(covariance_array)):
raise BackendNumericalError("backend parameter_covariance contains non-finite values")
if not np.allclose(covariance_array, covariance_array.T, rtol=1e-8, atol=1e-10):
raise BackendNumericalError("backend parameter_covariance must be symmetric")
convergence = payload["convergence"]
if not isinstance(convergence, Mapping):
raise BackendNumericalError("backend convergence must be a mapping")
if convergence.get("status") not in {"converged", "warning", "failed"}:
raise BackendNumericalError(
"backend convergence status must be converged, warning, or failed"
)
if (
not isinstance(payload["method"], str)
or not payload["method"].strip()
or not isinstance(payload["engine"], str)
or not payload["engine"].strip()
):
raise BackendNumericalError("backend method and engine must be non-empty")
if not isinstance(payload["diagnostic_data"], Mapping):
raise BackendNumericalError("backend diagnostic_data must be a mapping")
if not isinstance(payload["extra"], Mapping):
raise BackendNumericalError("backend extra must be a mapping")
return {key: payload[key] for key in _PAYLOAD_KEYS}
__all__ = [
"Backend",
"BackendError",
"BackendInputError",
"BackendNumericalError",
"BackendUnsupportedError",
"CompiledData",
"CovarianceParameterization",
"RandomBlockData",
"backend_mapping",
"cho_solve",
"convergence_mapping",
"covariance_from_hessian",
"covariance_slices",
"factorize",
"field",
"finite_gradient",
"finite_hessian",
"logdet_from_cholesky",
"make_payload",
"optimizer_covariance",
"prepare_data",
"prepare_random_block",
"random_covariance",
"safe_cholesky",
"select_optimizer_result",
"validate_payload",
]