Source code for pymixef.pharmacometrics.estimation

"""Population-NLME estimation building blocks.

This module intentionally separates implemented numerical primitives from
production estimator claims.  It provides an interaction-aware conditional
mode objective, conditional-mode optimization, and a Laplace contribution
that can be consumed by a future FOCEI engine.  ``fit_focei`` is not silently
approximated and raises a stable unsupported-engine error.

A callback-based SAEM kernel is available with an explicit experimental label.
It implements random-walk Metropolis simulation and Robbins--Monro stochastic
approximation, while requiring callers to provide the model-specific
sufficient statistics and exact M-step.  This is a transparent algorithmic
kernel, not a claim of general NONMEM/Monolix equivalence.
"""

from __future__ import annotations

from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from math import isfinite, log, pi
from types import MappingProxyType
from typing import Any

import numpy as np
from numpy.typing import ArrayLike, NDArray
from scipy.optimize import minimize
from scipy.special import log_ndtr

from .pk import ObservationError

_CONDITIONAL_GRADIENT_TOLERANCE = 1e-4


[docs] class EstimationError(RuntimeError): """Base class for pharmacometric estimation failures.""" code = "ESTIMATION-FAILED-001"
[docs] class UnsupportedEstimatorError(EstimationError): """Stable error for declared but not production-ready estimators.""" code = "ENGINE-UNSUPPORTED-001" def __init__(self, engine: str, *, compatible: Sequence[str] = ()) -> None: self.engine = engine self.compatible = tuple(compatible) alternatives = ( "" if not self.compatible else f"; available components: {', '.join(self.compatible)}" ) super().__init__( f"{engine!r} is not implemented as a production fitting engine{alternatives}" )
[docs] class ConditionalModeError(EstimationError): """Raised for invalid or failed subject-level conditional objectives.""" code = "NLME-CONDITIONAL-MODE-001"
[docs] class SAEMError(EstimationError): """Raised when the experimental SAEM kernel encounters an invalid callback.""" code = "SAEM-EXPERIMENTAL-FAILED-001"
def _readonly(value: ArrayLike, *, ndim: int | None = None) -> NDArray[np.float64]: array = np.array(value, dtype=float, copy=True) if ndim is not None and array.ndim != ndim: raise ValueError(f"expected {ndim} dimensions; got shape {array.shape}") array.setflags(write=False) return array def _positive_definite( covariance: ArrayLike, *, name: str ) -> tuple[NDArray[np.float64], NDArray[np.float64], float]: matrix = np.asarray(covariance, dtype=float) if matrix.ndim != 2 or matrix.shape[0] != matrix.shape[1] or matrix.shape[0] == 0: raise ValueError(f"{name} must be a non-empty square matrix") if np.any(~np.isfinite(matrix)): raise ValueError(f"{name} must contain finite values") if not np.allclose(matrix, matrix.T, rtol=1e-10, atol=1e-12): raise ValueError(f"{name} must be symmetric") try: cholesky = np.linalg.cholesky(matrix) except np.linalg.LinAlgError as exc: raise ValueError(f"{name} must be positive definite") from exc log_determinant = 2.0 * float(np.log(np.diag(cholesky)).sum()) return matrix, cholesky, log_determinant
[docs] def apply_random_effects( typical_values: Mapping[str, float], eta: Mapping[str, float] | ArrayLike, *, names: Sequence[str] | None = None, relationships: Mapping[str, str] | None = None, ) -> Mapping[str, float]: """Apply common random-effect relationships to typical values. The default relationship is exponential, ``individual = typical*exp(eta)``. Per-parameter alternatives are ``additive`` and ``logit``. The latter expects a typical value strictly between zero and one and adds ``eta`` on the logit scale. """ typical = {str(name): float(value) for name, value in typical_values.items()} if isinstance(eta, Mapping): effects = {str(name): float(value) for name, value in eta.items()} else: if names is None: names = tuple(typical) eta_array = np.asarray(eta, dtype=float) if eta_array.shape != (len(names),): raise ValueError("eta length must match names") effects = dict(zip((str(name) for name in names), eta_array, strict=True)) relation_map = {} if relationships is None else dict(relationships) individual: dict[str, float] = {} for name, value in typical.items(): effect = effects.get(name, 0.0) relationship = relation_map.get(name, "exponential") if relationship == "exponential": result = value * np.exp(effect) elif relationship == "additive": result = value + effect elif relationship == "logit": if not 0 < value < 1: raise ValueError(f"logit relationship for {name!r} requires 0 < typical < 1") logit = np.log(value / (1.0 - value)) result = 1.0 / (1.0 + np.exp(-(logit + effect))) else: raise ValueError(f"unsupported relationship {relationship!r} for parameter {name!r}") if not isfinite(float(result)): raise ValueError(f"random-effect transformation for {name!r} is non-finite") individual[name] = float(result) return MappingProxyType(individual)
[docs] def omega_from_standard_deviations( standard_deviations: ArrayLike, correlation: ArrayLike | None = None ) -> NDArray[np.float64]: """Construct a positive-definite random-effects covariance matrix.""" sd = np.asarray(standard_deviations, dtype=float) if sd.ndim != 1 or sd.size == 0 or np.any(~np.isfinite(sd)) or np.any(sd <= 0): raise ValueError("standard_deviations must be a positive finite vector") if correlation is None: matrix = np.diag(sd * sd) else: corr = np.asarray(correlation, dtype=float) if corr.shape != (sd.size, sd.size): raise ValueError("correlation has incompatible shape") if not np.allclose(np.diag(corr), 1.0, rtol=1e-10, atol=1e-12): raise ValueError("correlation must have a unit diagonal") matrix = sd[:, None] * corr * sd[None, :] _positive_definite(matrix, name="omega") return _readonly(matrix, ndim=2)
[docs] def eta_shrinkage(eta_estimates: ArrayLike, omega: ArrayLike) -> NDArray[np.float64]: """Variance-based ETA shrinkage ``1 - Var(eta_hat)/diag(Omega)``. The result is diagnostic and is not clipped; negative values reveal empirical ETA variance greater than the modeled population variance. """ eta = np.asarray(eta_estimates, dtype=float) covariance, _, _ = _positive_definite(omega, name="omega") if eta.ndim != 2 or eta.shape[1] != covariance.shape[0]: raise ValueError("eta_estimates must have shape (subjects, eta_dimension)") if eta.shape[0] < 2: raise ValueError("at least two subjects are required for shrinkage") shrinkage = 1.0 - np.var(eta, axis=0, ddof=1) / np.diag(covariance) return _readonly(shrinkage, ndim=1)
[docs] @dataclass(frozen=True, slots=True) class ObjectiveComponents: """Conditional objective decomposition at one ETA value.""" total: float observation: float random_effect: float predictions: NDArray[np.float64] variances: NDArray[np.float64] def __post_init__(self) -> None: object.__setattr__(self, "predictions", _readonly(self.predictions, ndim=1)) object.__setattr__(self, "variances", _readonly(self.variances, ndim=1))
[docs] @dataclass(frozen=True, slots=True) class ConditionalObjective: """FOCEI-ready subject conditional negative log joint density. ``predict`` receives an ETA vector. Residual variance is recomputed from each resulting prediction, retaining ETA--residual-variance interaction. Missing observations (NaN) are excluded explicitly. A ``censored`` mask uses the Gaussian log-CDF at ``lower_limits`` (an M3-like contribution). """ observations: NDArray[np.float64] predict: Callable[[NDArray[np.float64]], ArrayLike] omega: NDArray[np.float64] error: ObservationError error_parameters: Mapping[str, float] = field(default_factory=lambda: MappingProxyType({})) censored: NDArray[np.bool_] | None = None lower_limits: NDArray[np.float64] | None = None include_constants: bool = True _omega_cholesky: NDArray[np.float64] = field(init=False, repr=False) _omega_logdet: float = field(init=False, repr=False) def __post_init__(self) -> None: observations = np.array(self.observations, dtype=float, copy=True) if observations.ndim != 1: raise ValueError("observations must be one-dimensional") omega, cholesky, logdet = _positive_definite(self.omega, name="omega") observations.setflags(write=False) omega = _readonly(omega, ndim=2) cholesky = _readonly(cholesky, ndim=2) object.__setattr__(self, "observations", observations) object.__setattr__(self, "omega", omega) object.__setattr__(self, "_omega_cholesky", cholesky) object.__setattr__(self, "_omega_logdet", logdet) object.__setattr__(self, "error_parameters", MappingProxyType(dict(self.error_parameters))) if self.censored is not None: censored = np.array(self.censored, dtype=bool, copy=True) if censored.shape != observations.shape: raise ValueError("censored mask must match observations") censored.setflags(write=False) object.__setattr__(self, "censored", censored) if self.lower_limits is None: raise ValueError("censored observations require lower_limits") if self.lower_limits is not None: limits = np.array(self.lower_limits, dtype=float, copy=True) if limits.shape != observations.shape: raise ValueError("lower_limits must match observations") limits.setflags(write=False) object.__setattr__(self, "lower_limits", limits) @property def eta_dimension(self) -> int: return self.omega.shape[0]
[docs] def components(self, eta: ArrayLike) -> ObjectiveComponents: eta_array = np.asarray(eta, dtype=float) if eta_array.shape != (self.eta_dimension,): raise ConditionalModeError( f"eta must have shape ({self.eta_dimension},), got {eta_array.shape}" ) if np.any(~np.isfinite(eta_array)): raise ConditionalModeError("eta must contain finite values") predictions = np.asarray(self.predict(eta_array), dtype=float) if predictions.shape != self.observations.shape: raise ConditionalModeError( f"predict returned shape {predictions.shape}; expected {self.observations.shape}" ) if np.any(~np.isfinite(predictions)): raise ConditionalModeError("predict returned non-finite values") variances = np.asarray(self.error.variance(predictions, self.error_parameters), dtype=float) if variances.ndim == 0: variances = np.full(predictions.shape, float(variances)) if variances.shape != predictions.shape: try: variances = np.broadcast_to(variances, predictions.shape).copy() except ValueError as exc: raise ConditionalModeError( "residual-error variance does not match predictions" ) from exc if np.any(~np.isfinite(variances)) or np.any(variances <= 0): raise ConditionalModeError("residual-error variances must be positive") observed_mask = np.isfinite(self.observations) censored = ( np.zeros(self.observations.shape, dtype=bool) if self.censored is None else self.censored ) uncensored_mask = observed_mask & ~censored residual = self.observations[uncensored_mask] - predictions[uncensored_mask] uncensored_variance = variances[uncensored_mask] observation_term = 0.5 * float(np.sum(np.square(residual) / uncensored_variance)) observation_term += 0.5 * float(np.log(uncensored_variance).sum()) if self.include_constants: observation_term += 0.5 * int(uncensored_mask.sum()) * log(2.0 * pi) if np.any(censored): assert self.lower_limits is not None standardized = (self.lower_limits[censored] - predictions[censored]) / np.sqrt( variances[censored] ) observation_term -= float(log_ndtr(standardized).sum()) whitened = np.linalg.solve(self._omega_cholesky, eta_array) random_effect_term = 0.5 * float(whitened @ whitened) random_effect_term += 0.5 * self._omega_logdet if self.include_constants: random_effect_term += 0.5 * self.eta_dimension * log(2.0 * pi) total = observation_term + random_effect_term if not isfinite(total): raise ConditionalModeError("conditional objective is non-finite") return ObjectiveComponents( total, observation_term, random_effect_term, predictions, variances, )
def __call__(self, eta: ArrayLike) -> float: return self.components(eta).total
[docs] def conditional_mode_objective( eta: ArrayLike, *, observations: ArrayLike, predict: Callable[[NDArray[np.float64]], ArrayLike], omega: ArrayLike, error: ObservationError, error_parameters: Mapping[str, float] | None = None, censored: ArrayLike | None = None, lower_limits: ArrayLike | None = None, include_constants: bool = True, ) -> float: """Functional interface to :class:`ConditionalObjective`.""" objective = ConditionalObjective( np.asarray(observations, dtype=float), predict, np.asarray(omega, dtype=float), error, {} if error_parameters is None else error_parameters, None if censored is None else np.asarray(censored, dtype=bool), None if lower_limits is None else np.asarray(lower_limits, dtype=float), include_constants, ) return objective(eta)
[docs] def finite_difference_gradient( function: Callable[[NDArray[np.float64]], float], point: ArrayLike, *, relative_step: float = np.cbrt(np.finfo(float).eps), ) -> NDArray[np.float64]: """Central finite-difference gradient for derivative verification.""" x = np.asarray(point, dtype=float) if x.ndim != 1: raise ValueError("point must be one-dimensional") gradient = np.empty_like(x) for index in range(x.size): increment = relative_step * max(1.0, abs(x[index])) plus = x.copy() minus = x.copy() plus[index] += increment minus[index] -= increment gradient[index] = (function(plus) - function(minus)) / (2.0 * increment) return _readonly(gradient, ndim=1)
[docs] def finite_difference_hessian( function: Callable[[NDArray[np.float64]], float], point: ArrayLike, *, relative_step: float = np.finfo(float).eps ** 0.25, ) -> NDArray[np.float64]: """Symmetric finite-difference Hessian for small conditional-mode problems.""" x = np.asarray(point, dtype=float) if x.ndim != 1: raise ValueError("point must be one-dimensional") dimension = x.size hessian = np.empty((dimension, dimension), dtype=float) center = float(function(x)) increments = relative_step * np.maximum(1.0, np.abs(x)) for i in range(dimension): plus = x.copy() minus = x.copy() plus[i] += increments[i] minus[i] -= increments[i] hessian[i, i] = (function(plus) - 2.0 * center + function(minus)) / increments[i] ** 2 for j in range(i): pp = x.copy() pm = x.copy() mp = x.copy() mm = x.copy() pp[i] += increments[i] pp[j] += increments[j] pm[i] += increments[i] pm[j] -= increments[j] mp[i] -= increments[i] mp[j] += increments[j] mm[i] -= increments[i] mm[j] -= increments[j] value = (function(pp) - function(pm) - function(mp) + function(mm)) / ( 4.0 * increments[i] * increments[j] ) hessian[i, j] = value hessian[j, i] = value return _readonly(0.5 * (hessian + hessian.T), ndim=2)
[docs] @dataclass(frozen=True, slots=True) class ConditionalModeResult: """Transparent subject-level optimizer result.""" eta: NDArray[np.float64] objective: float observation_objective: float random_effect_objective: float gradient: NDArray[np.float64] hessian: NDArray[np.float64] covariance: NDArray[np.float64] | None success: bool message: str iterations: int function_evaluations: int gradient_norm: float hessian_positive_definite: bool warning_codes: tuple[str, ...] = () def __post_init__(self) -> None: object.__setattr__(self, "eta", _readonly(self.eta, ndim=1)) object.__setattr__(self, "gradient", _readonly(self.gradient, ndim=1)) object.__setattr__(self, "hessian", _readonly(self.hessian, ndim=2)) if self.covariance is not None: object.__setattr__(self, "covariance", _readonly(self.covariance, ndim=2))
[docs] def find_conditional_mode( objective: ConditionalObjective, initial_eta: ArrayLike | None = None, *, method: str = "BFGS", tolerance: float = 1e-8, max_iterations: int = 500, require_success: bool = False, ) -> ConditionalModeResult: """Optimize a subject ETA mode and independently inspect its Hessian.""" eta0 = ( np.zeros(objective.eta_dimension, dtype=float) if initial_eta is None else np.asarray(initial_eta, dtype=float) ) if eta0.shape != (objective.eta_dimension,): raise ValueError(f"initial_eta must have shape ({objective.eta_dimension},)") result = minimize( objective, eta0, method=method, tol=tolerance, options={"maxiter": int(max_iterations)}, ) eta = np.asarray(result.x, dtype=float) components = objective.components(eta) gradient = finite_difference_gradient(objective, eta) hessian = finite_difference_hessian(objective, eta) eigenvalues = np.linalg.eigvalsh(hessian) positive_definite = bool(np.all(eigenvalues > 0)) covariance: NDArray[np.float64] | None warnings: list[str] = [] if positive_definite: covariance = np.linalg.inv(hessian) else: covariance = None warnings.append("NLME-HESSIAN-NONPOSITIVE-001") gradient_norm = float(np.linalg.norm(gradient, ord=np.inf)) gradient_tolerance = _CONDITIONAL_GRADIENT_TOLERANCE if not result.success: warnings.append("NLME-CONDITIONAL-OPTIMIZER-001") if not np.isfinite(gradient_norm) or gradient_norm > gradient_tolerance: warnings.append("NLME-CONDITIONAL-GRADIENT-001") independently_verified = bool( np.isfinite(components.total) and np.isfinite(gradient_norm) and gradient_norm <= gradient_tolerance and positive_definite ) mode_result = ConditionalModeResult( eta, components.total, components.observation, components.random_effect, gradient, hessian, covariance, bool(result.success or independently_verified), str(result.message), int(getattr(result, "nit", 0)), int(getattr(result, "nfev", 0)), gradient_norm, positive_definite, tuple(warnings), ) if require_success and ( not mode_result.success or not positive_definite or not np.isfinite(gradient_norm) or gradient_norm > gradient_tolerance ): raise ConditionalModeError( f"conditional mode failed: {mode_result.message}; warnings={mode_result.warning_codes}" ) return mode_result
[docs] @dataclass(frozen=True, slots=True) class LaplacePopulationResult: """Sum of subject Laplace contributions at conditional modes.""" objective: float subject_contributions: NDArray[np.float64] modes: tuple[ConditionalModeResult, ...] warning_codes: tuple[str, ...] def __post_init__(self) -> None: object.__setattr__( self, "subject_contributions", _readonly(self.subject_contributions, ndim=1) ) object.__setattr__(self, "modes", tuple(self.modes))
[docs] def laplace_population_objective( subject_objectives: Sequence[ConditionalObjective], *, initial_etas: Sequence[ArrayLike] | None = None, require_modes: bool = True, **mode_options: Any, ) -> LaplacePopulationResult: """Evaluate Laplace-integrated subject objectives. This is a FOCEI-ready outer-objective component. It does not optimize or transform population parameters and is therefore not exposed as a complete FOCEI fit. """ if initial_etas is not None and len(initial_etas) != len(subject_objectives): raise ValueError("initial_etas must match subject_objectives length") modes: list[ConditionalModeResult] = [] contributions: list[float] = [] warnings: list[str] = [] for index, objective in enumerate(subject_objectives): initial = None if initial_etas is None else initial_etas[index] mode = find_conditional_mode( objective, initial, require_success=require_modes, **mode_options ) modes.append(mode) sign, logdet = np.linalg.slogdet(mode.hessian) if sign <= 0: if require_modes: raise ConditionalModeError( f"subject {index} conditional Hessian is not positive definite" ) contribution = np.inf else: q = mode.eta.size contribution = mode.objective + 0.5 * logdet - 0.5 * q * log(2.0 * pi) contributions.append(float(contribution)) warnings.extend(mode.warning_codes) contribution_array = np.asarray(contributions, dtype=float) return LaplacePopulationResult( float(contribution_array.sum()), contribution_array, tuple(modes), tuple(dict.fromkeys(warnings)), )
[docs] def fit_focei(*args: Any, **kwargs: Any) -> None: """Refuse a production FOCEI claim until the complete engine is validated.""" del args, kwargs raise UnsupportedEstimatorError( "focei", compatible=( "ConditionalObjective", "find_conditional_mode", "laplace_population_objective", ), )
[docs] @dataclass(frozen=True, slots=True) class SAEMProblem: """Model-specific callbacks required by the experimental SAEM kernel. ``log_joint(parameters, latent)`` must return the log of the target density up to a constant. ``sufficient_statistics(parameters, latent)`` returns a fixed-shape numeric vector/array. ``m_step(averaged_statistics, current_parameters)`` performs the exact model-specific maximization. """ initial_parameters: NDArray[np.float64] initial_latent: NDArray[np.float64] log_joint: Callable[[NDArray[np.float64], NDArray[np.float64]], float] sufficient_statistics: Callable[[NDArray[np.float64], NDArray[np.float64]], ArrayLike] m_step: Callable[[NDArray[np.float64], NDArray[np.float64]], ArrayLike] parameter_names: tuple[str, ...] = () def __post_init__(self) -> None: parameters = np.array(self.initial_parameters, dtype=float, copy=True) latent = np.array(self.initial_latent, dtype=float, copy=True) if parameters.ndim != 1 or parameters.size == 0: raise ValueError("initial_parameters must be a non-empty vector") if latent.size == 0: raise ValueError("initial_latent must be non-empty") if np.any(~np.isfinite(parameters)) or np.any(~np.isfinite(latent)): raise ValueError("SAEM initial values must be finite") if self.parameter_names and len(self.parameter_names) != parameters.size: raise ValueError("parameter_names must match initial_parameters") parameters.setflags(write=False) latent.setflags(write=False) object.__setattr__(self, "initial_parameters", parameters) object.__setattr__(self, "initial_latent", latent) object.__setattr__(self, "parameter_names", tuple(self.parameter_names))
[docs] @dataclass(frozen=True, slots=True) class SAEMControl: """Versioned controls for the experimental SAEM kernel.""" iterations: int = 1000 burn_in: int = 300 step_exponent: float = 0.7 mcmc_steps: int = 2 proposal_scale: float = 0.2 seed: int = 20260722 keep_latent_trace: bool = False def __post_init__(self) -> None: if self.iterations <= 0: raise ValueError("iterations must be positive") if self.burn_in < 0 or self.burn_in >= self.iterations: raise ValueError("burn_in must be in [0, iterations)") if not 0.5 < self.step_exponent <= 1.0: raise ValueError("step_exponent must be in (0.5, 1]") if self.mcmc_steps <= 0: raise ValueError("mcmc_steps must be positive") if not isfinite(self.proposal_scale) or self.proposal_scale <= 0: raise ValueError("proposal_scale must be finite and positive")
[docs] @dataclass(frozen=True, slots=True) class SAEMResult: """Trace and diagnostics from the explicitly experimental SAEM kernel.""" parameters: NDArray[np.float64] latent: NDArray[np.float64] sufficient_statistics: NDArray[np.float64] parameter_trace: NDArray[np.float64] latent_trace: NDArray[np.float64] | None step_sizes: NDArray[np.float64] acceptance_rate: float accepted: int proposals: int seed: int burn_in: int step_exponent: float experimental: bool = True reproducibility_class: str = "stochastic-with-monte-carlo-error" warning_codes: tuple[str, ...] = ("SAEM-EXPERIMENTAL-001",) def __post_init__(self) -> None: object.__setattr__(self, "parameters", _readonly(self.parameters, ndim=1)) latent = _readonly(self.latent) object.__setattr__(self, "latent", latent) object.__setattr__(self, "sufficient_statistics", _readonly(self.sufficient_statistics)) object.__setattr__(self, "parameter_trace", _readonly(self.parameter_trace, ndim=2)) if self.latent_trace is not None: object.__setattr__(self, "latent_trace", _readonly(self.latent_trace)) object.__setattr__(self, "step_sizes", _readonly(self.step_sizes, ndim=1))
[docs] def to_dict(self) -> dict[str, Any]: return { "parameters": self.parameters.tolist(), "latent": self.latent.tolist(), "sufficient_statistics": self.sufficient_statistics.tolist(), "parameter_trace": self.parameter_trace.tolist(), "step_sizes": self.step_sizes.tolist(), "acceptance_rate": self.acceptance_rate, "accepted": self.accepted, "proposals": self.proposals, "seed": self.seed, "burn_in": self.burn_in, "step_exponent": self.step_exponent, "experimental": self.experimental, "reproducibility_class": self.reproducibility_class, "warning_codes": list(self.warning_codes), }
[docs] def experimental_saem(problem: SAEMProblem, control: SAEMControl | None = None) -> SAEMResult: """Run the callback-based experimental SAEM algorithm. At iteration ``k`` the stochastic-approximation step is 1 during burn-in and ``(k - burn_in) ** (-step_exponent)`` afterward. The latent simulation is a symmetric random-walk Metropolis kernel, making its acceptance ratio explicit and auditable. """ controls = SAEMControl() if control is None else control rng = np.random.default_rng(controls.seed) parameters = np.array(problem.initial_parameters, copy=True) latent = np.array(problem.initial_latent, copy=True) parameter_trace = np.empty((controls.iterations, parameters.size), dtype=float) latent_trace = ( np.empty((controls.iterations, *latent.shape), dtype=float) if controls.keep_latent_trace else None ) step_sizes = np.empty(controls.iterations, dtype=float) accepted = 0 proposals = 0 try: current_log_joint = float(problem.log_joint(parameters, latent)) except Exception as exc: raise SAEMError(f"log_joint failed at initialization: {exc}") from exc if not isfinite(current_log_joint): raise SAEMError("initial log_joint must be finite") averaged_statistics: NDArray[np.float64] | None = None statistic_shape: tuple[int, ...] | None = None for iteration in range(1, controls.iterations + 1): for _ in range(controls.mcmc_steps): proposal = latent + rng.normal(0.0, controls.proposal_scale, size=latent.shape) try: proposed_log_joint = float(problem.log_joint(parameters, proposal)) except Exception as exc: raise SAEMError(f"log_joint failed at iteration {iteration}: {exc}") from exc proposals += 1 if isfinite(proposed_log_joint) and ( proposed_log_joint >= current_log_joint or log(rng.uniform()) < proposed_log_joint - current_log_joint ): latent = proposal current_log_joint = proposed_log_joint accepted += 1 try: statistics = np.asarray(problem.sufficient_statistics(parameters, latent), dtype=float) except Exception as exc: raise SAEMError( f"sufficient_statistics failed at iteration {iteration}: {exc}" ) from exc if statistics.size == 0 or np.any(~np.isfinite(statistics)): raise SAEMError("sufficient statistics must be non-empty and finite") if statistic_shape is None: statistic_shape = statistics.shape elif statistics.shape != statistic_shape: raise SAEMError("sufficient-statistic shape changed between iterations") gamma = ( 1.0 if iteration <= controls.burn_in else (iteration - controls.burn_in) ** (-controls.step_exponent) ) step_sizes[iteration - 1] = gamma if averaged_statistics is None: averaged_statistics = np.array(statistics, copy=True) else: averaged_statistics += gamma * (statistics - averaged_statistics) try: updated = np.asarray(problem.m_step(averaged_statistics, parameters), dtype=float) except Exception as exc: raise SAEMError(f"m_step failed at iteration {iteration}: {exc}") from exc if updated.shape != parameters.shape or np.any(~np.isfinite(updated)): raise SAEMError("m_step must return a finite vector matching initial_parameters") parameters = updated # Parameters changed, so refresh the current latent target before the # next Metropolis ratio. current_log_joint = float(problem.log_joint(parameters, latent)) if not isfinite(current_log_joint): raise SAEMError(f"log_joint became non-finite after M-step at iteration {iteration}") parameter_trace[iteration - 1] = parameters if latent_trace is not None: latent_trace[iteration - 1] = latent assert averaged_statistics is not None acceptance_rate = accepted / proposals warnings = ["SAEM-EXPERIMENTAL-001"] if acceptance_rate < 0.1 or acceptance_rate > 0.8: warnings.append("SAEM-MCMC-ACCEPTANCE-001") return SAEMResult( parameters, latent, averaged_statistics, parameter_trace, latent_trace, step_sizes, acceptance_rate, accepted, proposals, controls.seed, controls.burn_in, controls.step_exponent, warning_codes=tuple(warnings), )
saem = experimental_saem __all__ = [ "ConditionalModeError", "ConditionalModeResult", "ConditionalObjective", "EstimationError", "LaplacePopulationResult", "ObjectiveComponents", "SAEMControl", "SAEMError", "SAEMProblem", "SAEMResult", "UnsupportedEstimatorError", "apply_random_effects", "conditional_mode_objective", "eta_shrinkage", "experimental_saem", "find_conditional_mode", "finite_difference_gradient", "finite_difference_hessian", "fit_focei", "laplace_population_objective", "omega_from_standard_deviations", "saem", ]