Source code for pymixef.backends.mmrm

"""Dense repeated-measures Gaussian REML (MMRM) reference backend.

Expected fields in addition to the common backend contract are ``subject`` (or
``subjects``/``group``), ``visit`` (or ``visits``), and a covariance structure
in ``covariance``/``covariance_structure``/``residual``.  Supported structures
are homogeneous, diagonal, unstructured, compound symmetry, AR(1),
heterogeneous AR(1), Toeplitz, heterogeneous Toeplitz, first-order
ante-dependence, and spatial power.  Missing visits are handled by selecting the
observed submatrix of the shared visit covariance.

Satterthwaite denominator degrees of freedom use a finite-difference delta
method for contrast variance and the REML covariance-parameter Hessian.
``kenward-roger`` is intentionally not claimed.  An explicitly requested
``kenward-roger-approximate`` option uses a second-order delta adjustment and is
labelled KR-inspired, not exact KR.
"""

from __future__ import annotations

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

import numpy as np
from numpy.typing import ArrayLike, NDArray
from scipy import linalg, optimize, special, stats

from ..data import is_missing
from .base import (
    BackendInputError,
    BackendNumericalError,
    BackendUnsupportedError,
    cho_solve,
    convergence_mapping,
    field,
    finite_gradient,
    logdet_from_cholesky,
    make_payload,
    optimizer_covariance,
    prepare_data,
    safe_cholesky,
    select_optimizer_result,
)

_ORDER_DEPENDENT_STRUCTURES = frozenset(
    {
        "ar1",
        "heterogeneous-ar1",
        "toeplitz",
        "heterogeneous-toeplitz",
        "ante-dependence",
        "spatial-power",
        "heterogeneous-spatial-power",
    }
)


def _normalize_structure(value: Any) -> str:
    if value is None:
        return "unstructured"
    if isinstance(value, str):
        name = value
    elif isinstance(value, Mapping):
        name = str(value.get("name", value.get("type", value.get("structure", ""))))
    else:
        name = str(getattr(value, "name", value.__class__.__name__))
    name = name.lower().replace("_", "-").replace(" ", "-")
    aliases = {
        "iid": "homogeneous",
        "identity": "homogeneous",
        "homogeneous": "homogeneous",
        "homogeneous-variance": "homogeneous",
        "diagonal": "diagonal",
        "heterogeneous": "diagonal",
        "un": "unstructured",
        "us": "unstructured",
        "unstructured": "unstructured",
        "cs": "compound-symmetry",
        "compound-symmetry": "compound-symmetry",
        "heterogeneous-cs": "heterogeneous-compound-symmetry",
        "heterogeneous-compound-symmetry": "heterogeneous-compound-symmetry",
        "ar1": "ar1",
        "ar(1)": "ar1",
        "autoregressive": "ar1",
        "heterogeneous-ar1": "heterogeneous-ar1",
        "ar1h": "heterogeneous-ar1",
        "heterogeneous-autoregressive": "heterogeneous-ar1",
        "toeplitz": "toeplitz",
        "toep": "toeplitz",
        "heterogeneous-toeplitz": "heterogeneous-toeplitz",
        "toeph": "heterogeneous-toeplitz",
        "ante-dependence": "ante-dependence",
        "antedependence": "ante-dependence",
        "ad1": "ante-dependence",
        "spatial-power": "spatial-power",
        "spatialpower": "spatial-power",
        "spatial": "spatial-power",
        "heterogeneous-spatial-power": "heterogeneous-spatial-power",
    }
    try:
        return aliases[name]
    except KeyError as exc:
        raise BackendUnsupportedError(
            f"MMRM covariance structure {name!r} is unsupported",
            details={"supported_structures": sorted(set(aliases.values()))},
        ) from exc


def _level_value(raw: Any) -> Any:
    return raw.item() if isinstance(raw, np.generic) else raw


def _level_key(raw: Any) -> tuple[str, str]:
    value = _level_value(raw)
    kind = f"{type(value).__module__}.{type(value).__qualname__}"
    return kind, repr(value)


def _stable_factorize(values: Any) -> tuple[NDArray[np.int64], tuple[Any, ...]]:
    """Factorize grouping labels in a permutation-independent order."""

    array = np.asarray(values)
    if array.ndim != 1:
        array = np.ravel(array)
    observed: dict[tuple[str, str], Any] = {}
    row_keys: list[tuple[str, str]] = []
    for raw in array.tolist():
        value = _level_value(raw)
        key = _level_key(value)
        observed.setdefault(key, value)
        row_keys.append(key)
    ordered_keys = tuple(sorted(observed))
    lookup = {key: code for code, key in enumerate(ordered_keys)}
    codes = np.asarray([lookup[key] for key in row_keys], dtype=np.int64)
    return codes, tuple(observed[key] for key in ordered_keys)


def _declared_categories(values: Any) -> tuple[tuple[Any, ...], bool | None]:
    """Read pandas-like categorical metadata without importing pandas."""

    try:
        categorical = values.cat
    except (AttributeError, TypeError):
        categorical = values
    categories = getattr(categorical, "categories", None)
    if categories is None:
        return (), None
    try:
        levels = tuple(_level_value(item) for item in categories.tolist())
    except AttributeError:
        levels = tuple(_level_value(item) for item in categories)
    return levels, bool(getattr(categorical, "ordered", False))


def _visit_field_name(source: Any, covariance: Any) -> str:
    if isinstance(covariance, Mapping):
        index = covariance.get("index")
    else:
        index = getattr(covariance, "index", None)
    if isinstance(index, str) and index:
        return index
    if isinstance(source, Mapping):
        for candidate in ("visit", "visits", "visit_id", "visit_ids"):
            if candidate in source:
                return candidate
    return "visit"


def _audit_categories(source: Any, visit_name: str) -> tuple[tuple[Any, ...], bool | None]:
    audit = field(source, "audit", default=None)
    levels_by_factor = getattr(audit, "factor_levels", {})
    ordering_by_factor = getattr(audit, "factor_ordered", {})
    if not isinstance(levels_by_factor, Mapping) or visit_name not in levels_by_factor:
        return (), None
    levels = tuple(_level_value(item) for item in levels_by_factor[visit_name])
    ordered = (
        bool(ordering_by_factor[visit_name])
        if isinstance(ordering_by_factor, Mapping) and visit_name in ordering_by_factor
        else None
    )
    return levels, ordered


def _numeric_levels(levels: Sequence[Any]) -> NDArray[np.float64] | None:
    if any(isinstance(value, (bool, np.bool_)) for value in levels):
        return None
    try:
        numeric = np.asarray(levels, dtype=float)
    except (TypeError, ValueError):
        return None
    if numeric.ndim != 1 or np.any(~np.isfinite(numeric)):
        return None
    return numeric


def _validate_declared_levels(
    declared: Sequence[Any],
    observed: Mapping[tuple[str, str], Any],
    *,
    label: str,
) -> tuple[Any, ...]:
    declared_by_key: dict[tuple[str, str], Any] = {}
    for raw in declared:
        value = _level_value(raw)
        key = _level_key(value)
        if key in declared_by_key:
            raise BackendInputError(f"{label} contains duplicate visit level {value!r}")
        declared_by_key[key] = value
    missing = [value for key, value in observed.items() if key not in declared_by_key]
    if missing:
        raise BackendInputError(
            f"{label} does not include every observed visit",
            details={"missing_levels": [repr(value) for value in missing]},
        )
    # Globally unobserved declared categories have no estimable covariance
    # parameters. Preserve their relative order but restrict the fitted axis to
    # levels represented in the analysis data.
    return tuple(declared_by_key[key] for key in declared_by_key if key in observed)


@dataclass(slots=True)
class _VisitAxis:
    codes: NDArray[np.int64]
    levels: tuple[Any, ...]
    times: NDArray[np.float64]
    order_source: str
    declared_levels: tuple[Any, ...]


def _prepare_visit_axis(
    source: Any,
    visit_raw: Any,
    visit_times_raw: Any,
    *,
    covariance: Any,
    structure: str,
    n_observations: int,
) -> _VisitAxis:
    """Resolve a row-order-independent, scientifically explicit visit axis."""

    raw_array = np.asarray(visit_raw)
    if raw_array.ndim != 1:
        raw_array = np.ravel(raw_array)
    if raw_array.size != n_observations:
        raise BackendInputError("visit vector must match response length")

    observed: dict[tuple[str, str], Any] = {}
    row_keys: list[tuple[str, str]] = []
    for position, raw in enumerate(raw_array.tolist()):
        value = _level_value(raw)
        if is_missing(value):
            raise BackendInputError(
                "MMRM visit labels cannot be missing",
                details={"row": position},
            )
        key = _level_key(value)
        observed.setdefault(key, value)
        row_keys.append(key)
    if not observed:
        raise BackendInputError("MMRM requires at least one visit")

    explicit_raw = field(
        source,
        "visit_order",
        "ordered_visit_levels",
        "visit_levels",
        default=None,
    )
    explicit_levels = (
        ()
        if explicit_raw is None
        else tuple(_level_value(item) for item in np.asarray(explicit_raw).reshape(-1).tolist())
    )
    categorical_levels, categorical_ordered = _declared_categories(visit_raw)
    visit_name = _visit_field_name(source, covariance)
    if not categorical_levels:
        categorical_levels, categorical_ordered = _audit_categories(source, visit_name)

    declared_levels: tuple[Any, ...] = ()
    semantically_ordered = False
    if explicit_levels:
        base_levels = _validate_declared_levels(explicit_levels, observed, label="visit_order")
        declared_levels = explicit_levels
        base_source = "explicit-visit-order"
        semantically_ordered = True
    elif categorical_levels:
        base_levels = _validate_declared_levels(
            categorical_levels,
            observed,
            label=f"declared levels for {visit_name!r}",
        )
        declared_levels = categorical_levels
        semantically_ordered = categorical_ordered is True
        base_source = (
            "ordered-categorical-levels" if semantically_ordered else "unordered-categorical-levels"
        )
    else:
        observed_levels = tuple(observed.values())
        numeric_observed = _numeric_levels(observed_levels)
        if numeric_observed is not None:
            order = np.argsort(numeric_observed, kind="stable")
            sorted_numeric = numeric_observed[order]
            if sorted_numeric.size > 1 and np.any(np.diff(sorted_numeric) <= 0):
                raise BackendInputError(
                    "numeric visit labels must identify distinct ordered visits"
                )
            base_levels = tuple(observed_levels[index] for index in order.tolist())
            base_source = "ascending-numeric-visit-labels"
            semantically_ordered = True
        else:
            base_levels = tuple(observed[key] for key in sorted(observed))
            base_source = "deterministic-lexical-visit-labels"

    time_by_key: dict[tuple[str, str], float] | None = None
    if visit_times_raw is not None and isinstance(visit_times_raw, Mapping):
        time_by_key = {}
        for raw_level, raw_time in visit_times_raw.items():
            key = _level_key(raw_level)
            if key not in observed:
                continue
            try:
                value = float(raw_time)
            except (TypeError, ValueError) as exc:
                raise BackendInputError("visit_times mapping values must be numeric") from exc
            if not np.isfinite(value):
                raise BackendInputError("visit times must be finite")
            time_by_key[key] = value
        missing_time = [value for key, value in observed.items() if key not in time_by_key]
        if missing_time:
            raise BackendInputError(
                "visit_times mapping does not include every observed visit",
                details={"missing_levels": [repr(value) for value in missing_time]},
            )
    elif visit_times_raw is not None:
        try:
            time_values = np.asarray(visit_times_raw, dtype=float).reshape(-1)
        except (TypeError, ValueError):
            time_values = np.empty(0)
        if time_values.size:
            if np.any(~np.isfinite(time_values)):
                raise BackendInputError("visit times must be finite")
            if time_values.size == n_observations:
                time_by_key = {}
                for key, value in zip(row_keys, time_values.tolist(), strict=True):
                    previous = time_by_key.setdefault(key, float(value))
                    if previous != float(value):
                        raise BackendInputError(
                            "each visit must map to exactly one numeric visit time",
                            details={"visit": repr(observed[key])},
                        )
            elif time_values.size == len(base_levels):
                if not semantically_ordered:
                    raise BackendInputError(
                        "visit_times with one value per visit requires visit_order, "
                        "ordered categorical levels, or numeric visit labels"
                    )
                time_by_key = {
                    _level_key(level): float(value)
                    for level, value in zip(base_levels, time_values.tolist(), strict=True)
                }
            else:
                raise BackendInputError("visit_times must have one value per visit or observation")

    if time_by_key is not None:
        ordered_pairs = sorted(
            ((time_by_key[_level_key(level)], level) for level in base_levels),
            key=lambda item: item[0],
        )
        ordered_times = np.asarray([item[0] for item in ordered_pairs], dtype=float)
        if ordered_times.size > 1 and np.any(np.diff(ordered_times) <= 0):
            raise BackendInputError(
                "distinct visits must map to strictly increasing numeric visit times"
            )
        levels = tuple(item[1] for item in ordered_pairs)
        visit_times = ordered_times
        order_source = "ascending-explicit-visit-times"
    else:
        levels = base_levels
        numeric_levels = _numeric_levels(levels)
        if numeric_levels is not None:
            visit_times = numeric_levels
        else:
            visit_times = np.arange(len(levels), dtype=float)
        order_source = base_source
        if structure in _ORDER_DEPENDENT_STRUCTURES and not semantically_ordered:
            raise BackendInputError(
                f"MMRM covariance structure {structure!r} requires an explicit visit order "
                "for nonnumeric labels",
                details={
                    "provide": [
                        "visit_order",
                        "ordered categorical visit levels",
                        "numeric visit_times",
                    ],
                    "observed_levels": [repr(level) for level in levels],
                },
            )

    level_codes = {_level_key(level): code for code, level in enumerate(levels)}
    codes = np.asarray([level_codes[key] for key in row_keys], dtype=np.int64)
    return _VisitAxis(
        codes=codes,
        levels=levels,
        times=visit_times,
        order_source=order_source,
        declared_levels=declared_levels,
    )


def _pacf_to_autocorrelation(raw: ArrayLike) -> NDArray[np.float64]:
    """Map unconstrained partial autocorrelations to a positive Toeplitz row."""

    pacf = np.tanh(np.asarray(raw, dtype=float))
    order = pacf.size
    autocorrelation = np.ones(order + 1)
    previous = np.zeros(order + 1)
    for current_order in range(1, order + 1):
        current = np.zeros(order + 1)
        current[current_order] = pacf[current_order - 1]
        for lag in range(1, current_order):
            current[lag] = previous[lag] - pacf[current_order - 1] * previous[current_order - lag]
        autocorrelation[current_order] = sum(
            current[lag] * autocorrelation[current_order - lag]
            for lag in range(1, current_order + 1)
        )
        previous = current
    return autocorrelation


@dataclass(slots=True)
class _VisitCovariance:
    structure: str
    visits: tuple[Any, ...]
    visit_times: NDArray[np.float64]

    @property
    def q(self) -> int:
        return len(self.visits)

    @property
    def size(self) -> int:
        q = self.q
        if self.structure == "homogeneous":
            return 1
        if self.structure == "diagonal":
            return q
        if self.structure == "unstructured":
            return q * (q + 1) // 2
        if self.structure in {"compound-symmetry", "ar1", "spatial-power"}:
            return 1 if q == 1 else 2
        if self.structure in {
            "heterogeneous-compound-symmetry",
            "heterogeneous-ar1",
            "heterogeneous-spatial-power",
        }:
            return q if q == 1 else q + 1
        if self.structure == "toeplitz":
            return q
        if self.structure in {"heterogeneous-toeplitz", "ante-dependence"}:
            return q + max(q - 1, 0)
        raise AssertionError(self.structure)

    def initial(self, scale: float) -> NDArray[np.float64]:
        q = self.q
        log_scale = np.log(max(scale, 1e-4))
        if self.structure == "homogeneous":
            return np.array([log_scale])
        if self.structure == "diagonal":
            return np.full(q, log_scale)
        if self.structure == "unstructured":
            output: list[float] = []
            for row in range(q):
                for col in range(row + 1):
                    output.append(log_scale if row == col else 0.0)
            return np.asarray(output)
        if self.structure in {"compound-symmetry", "ar1"}:
            return np.array([log_scale]) if q == 1 else np.array([log_scale, 0.0])
        if self.structure == "spatial-power":
            return np.array([log_scale]) if q == 1 else np.array([log_scale, 0.0])
        if self.structure in {
            "heterogeneous-compound-symmetry",
            "heterogeneous-ar1",
            "heterogeneous-spatial-power",
        }:
            return np.concatenate([np.full(q, log_scale), np.zeros(0 if q == 1 else 1)])
        if self.structure == "toeplitz":
            return np.concatenate([[log_scale], np.zeros(q - 1)])
        if self.structure in {"heterogeneous-toeplitz", "ante-dependence"}:
            return np.concatenate([np.full(q, log_scale), np.zeros(q - 1)])
        raise AssertionError(self.structure)

    def _std_and_correlation(
        self, theta: ArrayLike
    ) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
        theta = np.asarray(theta, dtype=float)
        q = self.q
        if theta.size != self.size:
            raise ValueError("wrong MMRM covariance parameter length")
        if self.structure == "homogeneous":
            return np.full(q, np.exp(theta[0])), np.eye(q)
        if self.structure == "diagonal":
            return np.exp(theta), np.eye(q)
        if self.structure == "unstructured":
            chol = np.zeros((q, q))
            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
            covariance = chol @ chol.T
            std = np.sqrt(np.diag(covariance))
            return std, covariance / std[:, None] / std[None, :]
        if self.structure in {"compound-symmetry", "heterogeneous-compound-symmetry"}:
            homogeneous = self.structure == "compound-symmetry"
            std = np.full(q, np.exp(theta[0])) if homogeneous else np.exp(theta[:q])
            if q == 1:
                return std, np.eye(1)
            raw = theta[1] if homogeneous else theta[q]
            lower = -1.0 / (q - 1) + 1e-7
            rho = lower + (1.0 - lower) * special.expit(raw)
            correlation = np.full((q, q), rho)
            np.fill_diagonal(correlation, 1.0)
            return std, correlation
        if self.structure in {"ar1", "heterogeneous-ar1"}:
            homogeneous = self.structure == "ar1"
            std = np.full(q, np.exp(theta[0])) if homogeneous else np.exp(theta[:q])
            rho = 0.0 if q == 1 else np.tanh(theta[1] if homogeneous else theta[q])
            lag = np.abs(np.subtract.outer(np.arange(q), np.arange(q)))
            return std, rho**lag
        if self.structure in {"toeplitz", "heterogeneous-toeplitz"}:
            homogeneous = self.structure == "toeplitz"
            std = np.full(q, np.exp(theta[0])) if homogeneous else np.exp(theta[:q])
            raw = theta[1:] if homogeneous else theta[q:]
            first_row = _pacf_to_autocorrelation(raw)
            lag = np.abs(np.subtract.outer(np.arange(q), np.arange(q)))
            return std, first_row[lag]
        if self.structure == "ante-dependence":
            std = np.exp(theta[:q])
            adjacent = np.tanh(theta[q:])
            correlation = np.eye(q)
            for row in range(q):
                for col in range(row):
                    correlation[row, col] = correlation[col, row] = np.prod(adjacent[col:row])
            return std, correlation
        if self.structure in {"spatial-power", "heterogeneous-spatial-power"}:
            homogeneous = self.structure == "spatial-power"
            std = np.full(q, np.exp(theta[0])) if homogeneous else np.exp(theta[:q])
            if q == 1:
                return std, np.eye(1)
            rate = np.exp(theta[1] if homogeneous else theta[q])
            distance = np.abs(np.subtract.outer(self.visit_times, self.visit_times))
            return std, np.exp(-rate * distance)
        raise AssertionError(self.structure)

    def matrix(self, theta: ArrayLike) -> NDArray[np.float64]:
        std, correlation = self._std_and_correlation(theta)
        covariance = std[:, None] * correlation * std[None, :]
        safe_cholesky(covariance)
        return covariance

    def natural(self, theta: ArrayLike) -> dict[str, float]:
        std, correlation = self._std_and_correlation(theta)
        homogeneous = self.structure in {
            "homogeneous",
            "compound-symmetry",
            "ar1",
            "toeplitz",
            "spatial-power",
        }
        output = (
            {"residual_sd": float(std[0])}
            if homogeneous
            else {
                f"sd(visit={visit!s})": float(std[index]) for index, visit in enumerate(self.visits)
            }
        )
        if self.structure == "homogeneous":
            return output
        if (
            self.structure in {"compound-symmetry", "heterogeneous-compound-symmetry"}
            and self.q > 1
        ):
            output["correlation"] = float(correlation[0, 1])
        elif self.structure in {"ar1", "heterogeneous-ar1"} and self.q > 1:
            output["ar1_correlation"] = float(correlation[0, 1])
        elif self.structure in {"spatial-power", "heterogeneous-spatial-power"} and self.q > 1:
            # Report correlation at one time unit, not an opaque log-rate.
            distance_one = float(
                np.exp(-np.exp(theta[1] if self.structure == "spatial-power" else theta[self.q]))
            )
            output["spatial_correlation_at_unit_distance"] = distance_one
        elif self.structure in {"toeplitz", "heterogeneous-toeplitz"}:
            for lag in range(1, self.q):
                output[f"correlation_lag_{lag}"] = float(correlation[0, lag])
        elif self.structure in {"unstructured", "ante-dependence"}:
            for row in range(1, self.q):
                for col in range(row):
                    output[f"corr(visit={self.visits[row]!s},visit={self.visits[col]!s})"] = float(
                        correlation[row, col]
                    )
        return output

    def unconstrained_names(self) -> list[str]:
        q = self.q
        if self.structure == "homogeneous":
            return ["log_residual_sd"]
        if self.structure == "diagonal":
            return [f"log_sd(visit={visit!s})" for visit in self.visits]
        if self.structure == "unstructured":
            names: list[str] = []
            for row in range(q):
                for col in range(row + 1):
                    if row == col:
                        names.append(f"log_chol(visit={self.visits[row]!s})")
                    else:
                        names.append(f"chol(visit={self.visits[row]!s},visit={self.visits[col]!s})")
            return names
        heterogeneous = (
            self.structure.startswith("heterogeneous") or self.structure == "ante-dependence"
        )
        names = (
            [f"log_sd(visit={visit!s})" for visit in self.visits]
            if heterogeneous
            else ["log_residual_sd"]
        )
        remaining = self.size - len(names)
        if self.structure in {"toeplitz", "heterogeneous-toeplitz"}:
            names.extend(
                f"raw_partial_autocorrelation_lag_{lag}" for lag in range(1, remaining + 1)
            )
        elif self.structure == "ante-dependence":
            names.extend(f"raw_adjacent_correlation_{lag}_{lag + 1}" for lag in range(remaining))
        elif self.structure in {"spatial-power", "heterogeneous-spatial-power"} and remaining:
            names.append("log_spatial_decay")
        elif remaining:
            names.append("raw_correlation")
        return names


@dataclass(slots=True)
class _MMRMEvaluation:
    log_likelihood: float
    beta: NDArray[np.float64]
    beta_covariance: NDArray[np.float64]
    residual: NDArray[np.float64]
    covariance: NDArray[np.float64]
    cholesky: NDArray[np.float64]
    rank: int
    jitter: float


[docs] def linear_inference( beta: ArrayLike, covariance: ArrayLike, matrix: ArrayLike, *, names: Sequence[str] | None = None, degrees_of_freedom: ArrayLike | float = np.inf, confidence_level: float = 0.95, method: str = "Wald", ) -> dict[str, list[Any]]: """Build a tidy table for linear EMM/contrast estimates.""" beta = np.asarray(beta, dtype=float) covariance = np.asarray(covariance, dtype=float) matrix = np.asarray(matrix, dtype=float) if matrix.ndim == 1: matrix = matrix[None, :] if matrix.ndim != 2 or matrix.shape[1] != beta.size: raise BackendInputError("inference matrix columns must match fixed effects") estimates = matrix @ beta variances = np.einsum("ij,jk,ik->i", matrix, covariance, matrix) standard_errors = np.sqrt(np.maximum(variances, 0.0)) df = np.broadcast_to(np.asarray(degrees_of_freedom, dtype=float), estimates.shape) alpha = 1 - confidence_level critical = np.where( np.isfinite(df), stats.t.ppf(1 - alpha / 2, df), stats.norm.ppf(1 - alpha / 2) ) labels = ( list(names) if names is not None else [f"L{index + 1}" for index in range(len(estimates))] ) if len(labels) != len(estimates): raise BackendInputError("inference names length does not match matrix rows") return { "name": labels, "estimate": estimates.tolist(), "standard_error": standard_errors.tolist(), "degrees_of_freedom": df.tolist(), "lower_confidence_limit": (estimates - critical * standard_errors).tolist(), "upper_confidence_limit": (estimates + critical * standard_errors).tolist(), "method": [method] * len(estimates), }
[docs] class MMRMBackend: """Dense subject-block MMRM REML engine.""" name = "mmrm"
[docs] def fit( self, data: Any, *, covariance: Any = None, method: str = "REML", df_method: str = "satterthwaite", confidence_level: float = 0.95, maxiter: int = 1_000, tolerance: float = 1e-8, compute_hessian: bool = True, **options: Any, ) -> dict[str, Any]: compiled = prepare_data(data) if compiled.random_blocks: raise BackendUnsupportedError( "dedicated MMRM currently models within-subject covariance without random-effect blocks", suggested_engines=("lmm",), ) method = method.upper() if method not in {"REML", "ML"}: raise BackendInputError("MMRM method must be REML or ML") source = compiled.source subject_raw = field( source, "subject", "subjects", "subject_id", "subject_ids", "group", "groups" ) visit_raw = field(source, "visit", "visits", "visit_id", "visit_ids") subject_codes, subject_levels = _stable_factorize(subject_raw) if subject_codes.size != compiled.n_obs: raise BackendInputError("subject vector must match response length") if covariance is None: covariance = field( source, "covariance_structure", "covariance", default=compiled.residual_covariance, ) explicit_covariance_input = isinstance(covariance, (np.ndarray, list, tuple)) ordering_structure = ( "explicit-fixed" if explicit_covariance_input else _normalize_structure(covariance) ) visit_axis = _prepare_visit_axis( source, visit_raw, field(source, "visit_times", "times", "time", default=None), covariance=covariance, structure=ordering_structure, n_observations=compiled.n_obs, ) visit_codes = visit_axis.codes visit_levels = visit_axis.levels visit_times = visit_axis.times q = len(visit_levels) canonical_order = np.lexsort((visit_codes, subject_codes)) observed_pairs = list(zip(subject_codes.tolist(), visit_codes.tolist(), strict=True)) if len(set(observed_pairs)) != len(observed_pairs): raise BackendInputError( "MMRM reference engine requires at most one observation per subject and visit" ) explicit_covariance: NDArray[np.float64] | None = None if explicit_covariance_input: candidate = np.asarray(covariance, dtype=float) if candidate.shape == (compiled.n_obs, compiled.n_obs): explicit_covariance = (candidate + candidate.T) / 2 try: safe_cholesky(explicit_covariance) except linalg.LinAlgError as exc: raise BackendInputError( "explicit MMRM covariance must be positive definite" ) from exc structure_name = "explicit-fixed" visit_covariance = None theta0 = np.zeros(0) else: raise BackendInputError( "explicit MMRM covariance must be an n_observation square matrix" ) else: structure_name = _normalize_structure(covariance) visit_covariance = _VisitCovariance( structure_name, visit_levels, np.asarray(visit_times, dtype=float) ) response_scale = max( (float(np.std(compiled.y[canonical_order], ddof=1)) if compiled.n_obs > 1 else 1.0), 1e-3, ) theta0 = visit_covariance.initial(response_scale) subject_rows = [ np.flatnonzero(subject_codes == code) for code in range(len(subject_levels)) ] def full_covariance( theta: ArrayLike, ) -> tuple[NDArray[np.float64], NDArray[np.float64] | None]: if explicit_covariance is not None: covariance_matrix = explicit_covariance.copy() visit_matrix = None else: assert visit_covariance is not None visit_matrix = visit_covariance.matrix(theta) covariance_matrix = np.zeros((compiled.n_obs, compiled.n_obs)) for rows in subject_rows: codes = visit_codes[rows] covariance_matrix[np.ix_(rows, rows)] = visit_matrix[np.ix_(codes, codes)] scaling = 1 / np.sqrt(compiled.weights) covariance_matrix = scaling[:, None] * covariance_matrix * scaling[None, :] return covariance_matrix, visit_matrix def evaluate(theta: ArrayLike) -> _MMRMEvaluation: covariance_matrix, _ = full_covariance(theta) numerical_covariance = covariance_matrix[np.ix_(canonical_order, canonical_order)] numerical_y = compiled.y[canonical_order] numerical_X = compiled.X[canonical_order] cholesky, jitter = safe_cholesky(numerical_covariance) yw = linalg.solve_triangular(cholesky, numerical_y, lower=True, check_finite=False) Xw = linalg.solve_triangular(cholesky, numerical_X, lower=True, check_finite=False) beta, _, rank, singular_values = np.linalg.lstsq(Xw, yw, rcond=None) residual = compiled.y - compiled.X @ beta numerical_residual = residual[canonical_order] quadratic = float(numerical_residual @ cho_solve(cholesky, numerical_residual)) logdet_covariance = logdet_from_cholesky(cholesky) information = Xw.T @ Xw beta_covariance = np.linalg.pinv(information, rcond=1e-10) logdet_information = float(np.log(singular_values[:rank] ** 2).sum()) if rank else 0.0 if method == "REML": log_likelihood = -0.5 * ( (compiled.n_obs - rank) * np.log(2 * np.pi) + logdet_covariance + logdet_information + quadratic ) else: log_likelihood = -0.5 * ( compiled.n_obs * np.log(2 * np.pi) + logdet_covariance + quadratic ) return _MMRMEvaluation( log_likelihood=float(log_likelihood), beta=beta, beta_covariance=beta_covariance, residual=residual, covariance=covariance_matrix, cholesky=cholesky, rank=int(rank), jitter=jitter, ) def objective(theta: ArrayLike) -> float: try: value = -evaluate(theta).log_likelihood return value if np.isfinite(value) else 1e100 except (ValueError, FloatingPointError, linalg.LinAlgError): return 1e100 if theta0.size: bounds = [(-12.0, 12.0)] * theta0.size result = optimize.minimize( objective, theta0, method="L-BFGS-B", bounds=bounds, options={"maxiter": int(maxiter), "ftol": tolerance, "gtol": tolerance}, ) initial_gradient = np.asarray(getattr(result, "jac", np.array([np.inf]))) if ( not result.success or not np.isfinite(result.fun) or np.max(np.abs(initial_gradient)) > max(np.sqrt(tolerance), 1e-5) ): rescue = optimize.minimize( objective, np.clip(result.x if np.all(np.isfinite(result.x)) else theta0, -12, 12), method="Powell", bounds=bounds, options={"maxiter": int(maxiter), "xtol": tolerance, "ftol": tolerance}, ) refined = optimize.minimize( objective, rescue.x, method="L-BFGS-B", bounds=bounds, options={"maxiter": int(maxiter), "ftol": tolerance, "gtol": tolerance}, ) result = select_optimizer_result( refined, rescue, objective_tolerance=1e-8, ) theta_hat = np.asarray(result.x) success = bool(result.success and np.isfinite(result.fun)) optimizer_message = str(result.message) iterations = int(getattr(result, "nit", 0)) evaluations = int(getattr(result, "nfev", 0)) gradient = getattr(result, "jac", None) if gradient is None or np.any(~np.isfinite(gradient)): gradient = finite_gradient(objective, theta_hat) else: theta_hat = theta0 success = True optimizer_message = "Explicit covariance required no optimization" iterations = evaluations = 0 gradient = np.zeros(0) try: final = evaluate(theta_hat) except (ValueError, linalg.LinAlgError) as exc: raise BackendNumericalError("final MMRM covariance is not positive definite") from exc theta_covariance, hessian_positive, curvature_source = optimizer_covariance( objective, theta_hat, result if theta_hat.size else None, compute_hessian=compute_hessian, finite_difference_limit=25, ) natural_parameters = { name: float(value) for name, value in zip(compiled.fixed_names, final.beta, strict=True) } unconstrained_parameters = dict(natural_parameters) covariance_names: list[str] = [] raw_names: list[str] = [] if visit_covariance is not None: covariance_values = visit_covariance.natural(theta_hat) natural_parameters.update(covariance_values) covariance_names = list(covariance_values) raw_names = visit_covariance.unconstrained_names() unconstrained_parameters.update( {name: float(value) for name, value in zip(raw_names, theta_hat, strict=True)} ) def natural_covariance_vector(theta: NDArray[np.float64]) -> NDArray[np.float64]: return np.asarray(list(visit_covariance.natural(theta).values())) jacobian = np.empty((len(covariance_names), theta_hat.size)) for column in range(theta_hat.size): step = 1e-5 * max(1.0, abs(theta_hat[column])) delta = np.zeros(theta_hat.size) delta[column] = step jacobian[:, column] = ( natural_covariance_vector(theta_hat + delta) - natural_covariance_vector(theta_hat - delta) ) / (2 * step) natural_covariance_covariance = jacobian @ theta_covariance @ jacobian.T else: natural_covariance_covariance = np.zeros((0, 0)) parameter_covariance = linalg.block_diag( final.beta_covariance, natural_covariance_covariance ) residual_df = max(compiled.n_obs - final.rank, 1) def contrast_df( matrix: NDArray[np.float64], beta_covariance: NDArray[np.float64] ) -> NDArray[np.float64]: if df_method_normalized == "residual" or not theta_hat.size: return np.full(matrix.shape[0], float(residual_df)) output = np.empty(matrix.shape[0]) for row_index, contrast in enumerate(matrix): variance = float(contrast @ beta_covariance @ contrast) derivative = np.empty(theta_hat.size) for column in range(theta_hat.size): step = 2e-4 * max(1.0, abs(theta_hat[column])) delta = np.zeros(theta_hat.size) delta[column] = step plus = evaluate(theta_hat + delta).beta_covariance minus = evaluate(theta_hat - delta).beta_covariance derivative[column] = float(contrast @ (plus - minus) @ contrast / (2 * step)) denominator = float(derivative @ theta_covariance @ derivative) output[row_index] = ( 2 * variance**2 / denominator if denominator > np.finfo(float).eps and variance > 0 else residual_df ) return np.maximum(output, 1.0) df_method_normalized = df_method.lower().replace("_", "-") if df_method_normalized in {"satterthwaite", "satt"}: df_method_normalized = "satterthwaite" inference_covariance = final.beta_covariance inference_label = "Satterthwaite delta-method" elif df_method_normalized in {"residual", "residual-df"}: df_method_normalized = "residual" inference_covariance = final.beta_covariance inference_label = "residual degrees of freedom" elif df_method_normalized in {"kenward-roger", "kr", "kenward-roger-exact"}: raise BackendUnsupportedError( "exact Kenward-Roger adjustment is not implemented by the reference MMRM engine", details={ "available": [ "satterthwaite", "residual", "kenward-roger-approximate", ] }, ) elif df_method_normalized in { "kenward-roger-approximate", "kr-approximate", "kr-inspired", }: df_method_normalized = "kenward-roger-approximate" if theta_hat.size > 12: raise BackendUnsupportedError( "KR-inspired second-order delta adjustment is limited to 12 covariance parameters" ) # E[B(theta)] second-order delta approximation. This captures # covariance-parameter uncertainty but is not the KR information # adjustment and is labelled accordingly. correction = np.zeros_like(final.beta_covariance) step = 2e-3 * np.maximum(1.0, np.abs(theta_hat)) for first in range(theta_hat.size): e_first = np.zeros(theta_hat.size) e_first[first] = step[first] diagonal_second = ( evaluate(theta_hat + e_first).beta_covariance - 2 * final.beta_covariance + evaluate(theta_hat - e_first).beta_covariance ) / step[first] ** 2 correction += 0.5 * theta_covariance[first, first] * diagonal_second for second in range(first): e_second = np.zeros(theta_hat.size) e_second[second] = step[second] cross_second = ( evaluate(theta_hat + e_first + e_second).beta_covariance - evaluate(theta_hat + e_first - e_second).beta_covariance - evaluate(theta_hat - e_first + e_second).beta_covariance + evaluate(theta_hat - e_first - e_second).beta_covariance ) / (4 * step[first] * step[second]) correction += theta_covariance[first, second] * cross_second inference_covariance = final.beta_covariance + correction inference_covariance = (inference_covariance + inference_covariance.T) / 2 eigenvalues, eigenvectors = np.linalg.eigh(inference_covariance) inference_covariance = ( eigenvectors * np.maximum(eigenvalues, np.finfo(float).eps) ) @ eigenvectors.T inference_label = ( "second-order delta covariance adjustment (KR-inspired; not exact Kenward-Roger)" ) else: raise BackendInputError( "df_method must be satterthwaite, residual, kenward-roger, " "or kenward-roger-approximate" ) def inference_spec(*names: str) -> tuple[NDArray[np.float64] | None, list[str] | None]: raw = field(source, *names, default=None) if raw is None: return None, None if isinstance(raw, Mapping): matrix = np.asarray(raw.get("matrix", raw.get("L")), dtype=float) labels = raw.get("names", raw.get("labels")) return matrix, None if labels is None else [str(item) for item in labels] return np.asarray(raw, dtype=float), None emm_matrix, emm_names = inference_spec( "emm_matrix", "emmean_matrix", "estimated_marginal_mean_matrix" ) contrast_raw = field(source, "contrasts", "contrast_matrix", default=None) contrast_matrix: NDArray[np.float64] | None contrast_names: list[str] | None if contrast_raw is None: contrast_matrix = None contrast_names = None elif isinstance(contrast_raw, Mapping) and "matrix" not in contrast_raw: contrast_names = [str(key) for key in contrast_raw] contrast_matrix = np.vstack( [np.asarray(value, dtype=float) for value in contrast_raw.values()] ) elif isinstance(contrast_raw, Mapping): contrast_matrix = np.asarray(contrast_raw.get("matrix"), dtype=float) labels = contrast_raw.get("names", contrast_raw.get("labels")) contrast_names = None if labels is None else [str(item) for item in labels] else: contrast_matrix = np.asarray(contrast_raw, dtype=float) contrast_names = None def make_inference( matrix: NDArray[np.float64] | None, names: list[str] | None ) -> dict[str, list[Any]] | None: if matrix is None: return None if matrix.ndim == 1: matrix = matrix[None, :] degrees = contrast_df(matrix, inference_covariance) return linear_inference( final.beta, inference_covariance, matrix, names=names, degrees_of_freedom=degrees, confidence_level=confidence_level, method=inference_label, ) emm_table = make_inference(emm_matrix, emm_names) contrast_table = make_inference(contrast_matrix, contrast_names) _, visit_matrix_hat = full_covariance(theta_hat) covariance_eigenvalues = np.linalg.eigvalsh( visit_matrix_hat if visit_matrix_hat is not None else final.covariance ) covariance_ratio = float(covariance_eigenvalues[0] / covariance_eigenvalues[-1]) singular = covariance_ratio < 1e-7 warnings: list[dict[str, Any]] = [] if singular: warnings.append( { "code": "COV-SINGULAR-001", "severity": "warning", "message": "MMRM covariance is singular or near-singular", "details": {"eigenvalue_ratio": covariance_ratio}, } ) if df_method_normalized == "kenward-roger-approximate": warnings.append( { "code": "MMRM-KR-APPROX-001", "severity": "warning", "message": "requested adjustment is KR-inspired and is not exact Kenward-Roger", "details": {"method": inference_label}, } ) convergence = convergence_mapping( success=success, message=optimizer_message, iterations=iterations, function_evaluations=evaluations, gradient=gradient, hessian_positive_definite=hessian_positive, singular=singular, warnings=warnings, extra={ "fixed_effect_rank": final.rank, "subjects": len(subject_levels), "visits": q, "curvature_source": curvature_source, }, ) fitted = compiled.X @ final.beta residuals = compiled.y - fitted standardized = residuals / np.sqrt( np.maximum(np.diag(final.covariance), np.finfo(float).tiny) ) return make_payload( parameters=natural_parameters, unconstrained_parameters=unconstrained_parameters, parameter_covariance=parameter_covariance, fitted_values=fitted, residuals=residuals, random_effects=np.zeros(0), objective=float(-final.log_likelihood), log_likelihood=float(final.log_likelihood), method=method, engine=self.name, convergence=convergence, diagnostic_data={ "observations": { "subject": [subject_levels[code] for code in subject_codes], "visit": [visit_levels[code] for code in visit_codes], "observed": compiled.y.tolist(), "fitted": fitted.tolist(), "residual": residuals.tolist(), "standardized_residual": standardized.tolist(), }, "estimated_marginal_means": emm_table or {}, "contrasts": contrast_table or {}, }, extra={ "parameter_names": list(compiled.fixed_names) + covariance_names, "unconstrained_parameter_names": list(compiled.fixed_names) + raw_names, "population_fitted_values": fitted, "fixed_design": compiled.X, "fixed_effect_covariance": final.beta_covariance, "fixed_effect_names": list(compiled.fixed_names), "residual_covariance": final.covariance, "covariance_structure": structure_name, "visit_levels": list(visit_levels), "visit_times": visit_times.tolist(), "visit_order_source": visit_axis.order_source, "declared_visit_levels": list(visit_axis.declared_levels), "visit_covariance": visit_matrix_hat, "subject_covariance": final.covariance, "covariance_parameter_covariance": theta_covariance, "profiled_fixed_effect_covariance": final.beta_covariance, "inference_fixed_effect_covariance": inference_covariance, "degrees_of_freedom_method": inference_label, "estimated_marginal_means": emm_table, "contrasts": contrast_table, "objective_convention": ( "negative exact normalized Gaussian restricted log likelihood" if method == "REML" else "negative exact normalized Gaussian marginal log likelihood" ), "likelihood_includes_data_constants": True, "reference_engine": True, "dense_subject_blocks": True, "kenward_roger_exact_available": False, }, )
[docs] def fit_mmrm(data: Any, **options: Any) -> dict[str, Any]: """Fit a Gaussian MMRM with dense subject covariance blocks.""" return MMRMBackend().fit(data, **options)
estimated_marginal_means = linear_inference contrasts = linear_inference __all__ = [ "MMRMBackend", "contrasts", "estimated_marginal_means", "fit_mmrm", "linear_inference", ]