"""Dense Gaussian linear mixed-model reference engine.
This engine evaluates the exact marginal Gaussian likelihood and restricted
likelihood. It supports multiple independent random-effect blocks, correlated
or diagonal within-group covariance, known observation weights, and an explicit
structured residual covariance template. It deliberately forms dense
covariance matrices and is therefore a correctness/reference engine rather than
a sparse high-scale production engine.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import numpy as np
from numpy.typing import ArrayLike, NDArray
from scipy import linalg, optimize, special
from .base import (
BackendInputError,
BackendNumericalError,
CompiledData,
cho_solve,
convergence_mapping,
covariance_slices,
factorize,
field,
finite_gradient,
logdet_from_cholesky,
make_payload,
optimizer_covariance,
prepare_data,
random_covariance,
safe_cholesky,
select_optimizer_result,
)
def _residual_template(compiled: CompiledData) -> NDArray[np.float64]:
"""Resolve and validate a residual covariance/correlation template."""
raw = compiled.residual_covariance
n = compiled.n_obs
if raw is None:
template = np.eye(n)
elif isinstance(raw, (np.ndarray, list, tuple)):
template = np.asarray(raw, dtype=float)
else:
matrix_member = field(raw, "matrix", "covariance_matrix", default=None)
if matrix_member is None:
raise BackendInputError(
"residual covariance must be an array or expose matrix()/matrix"
)
if callable(matrix_member):
times = field(
compiled.source,
"times",
"time",
"visit_times",
default=np.arange(n, dtype=float),
)
attempts = (
lambda: matrix_member(size=n, times=times),
lambda: matrix_member(n, times),
lambda: matrix_member(times),
lambda: matrix_member(n),
lambda: matrix_member(),
)
last_error: Exception | None = None
for attempt in attempts:
try:
template = np.asarray(attempt(), dtype=float)
break
except (TypeError, ValueError) as exc:
last_error = exc
else:
raise BackendInputError(
"could not evaluate residual covariance matrix",
details={"last_error": repr(last_error)},
)
else:
template = np.asarray(matrix_member, dtype=float)
if template.shape != (n, n):
raise BackendInputError(
f"residual covariance has shape {template.shape}; expected {(n, n)}"
)
if np.any(~np.isfinite(template)):
raise BackendInputError("residual covariance contains non-finite values")
template = (template + template.T) / 2
try:
safe_cholesky(template, jitter_scale=1e-12, max_tries=2)
except linalg.LinAlgError as exc:
raise BackendInputError("residual covariance must be positive definite") from exc
# Analytic/frequency weights scale residual standard deviations.
scaling = 1.0 / np.sqrt(compiled.weights)
return scaling[:, None] * template * scaling[None, :]
@dataclass(slots=True)
class _StructuredResidual:
"""Adapter for covariance.CovarianceStructure-like objects."""
source: Any
compiled: CompiledData
row_blocks: tuple[NDArray[np.int64], ...]
index: NDArray[np.float64]
parameter_count: int
raw_names: tuple[str, ...]
@classmethod
def create(cls, source: Any, compiled: CompiledData) -> _StructuredResidual:
grouping = field(
compiled.source,
"residual_groups",
"residual_group",
"subjects",
"subject",
default=None,
)
if grouping is None and getattr(source, "group", None):
grouping = field(compiled.source, "groups", "group", default=None)
if grouping is None:
row_blocks = (np.arange(compiled.n_obs, dtype=np.int64),)
else:
codes, levels = factorize(grouping)
if codes.size != compiled.n_obs:
raise BackendInputError(
"structured residual grouping must have one value per observation"
)
row_blocks = tuple(np.flatnonzero(codes == code) for code in range(len(levels)))
raw_index = field(
compiled.source,
"residual_index",
"visit_times",
"times",
"time",
"visits",
"visit",
default=np.arange(compiled.n_obs),
)
try:
index = np.asarray(raw_index, dtype=float).reshape(-1)
except (TypeError, ValueError):
index = factorize(raw_index)[0].astype(float)
if index.size != compiled.n_obs:
raise BackendInputError("structured residual index must have one value per observation")
counts: list[int] = []
names: tuple[str, ...] | None = None
for rows in row_blocks:
try:
count = int(source.parameter_count(size=len(rows)))
except TypeError:
count = int(source.parameter_count(len(rows)))
counts.append(count)
try:
block_names = tuple(str(item) for item in source.parameter_names(size=len(rows)))
except TypeError:
block_names = tuple(str(item) for item in source.parameter_names(len(rows)))
if names is None:
names = block_names
elif block_names != names:
raise BackendInputError(
"structured residual groups imply different parameterizations; "
"use equal visit dimensions or the MMRM backend"
)
if len(set(counts)) != 1:
raise BackendInputError(
"structured residual groups have incompatible covariance dimensions"
)
return cls(
source=source,
compiled=compiled,
row_blocks=row_blocks,
index=index,
parameter_count=counts[0],
raw_names=names or (),
)
def initial(self, scale: float) -> NDArray[np.float64]:
result = np.zeros(self.parameter_count)
for position, name in enumerate(self.raw_names):
normalized = name.lower()
if (
normalized.startswith("log_sd")
or normalized.startswith("log_innovation_sd")
or normalized.startswith("log_chol")
):
result[position] = np.log(max(scale, 1e-4))
return result
def matrix(self, theta: ArrayLike) -> NDArray[np.float64]:
theta = np.asarray(theta, dtype=float)
result = np.zeros((self.compiled.n_obs, self.compiled.n_obs))
covariance_method = getattr(self.source, "covariance", None)
matrix_method = getattr(self.source, "matrix", None)
method = covariance_method if callable(covariance_method) else matrix_method
if not callable(method):
raise BackendInputError("structured residual object does not expose covariance()")
for rows in self.row_blocks:
block_index = self.index[rows]
attempts = (
lambda rows=rows, block_index=block_index: method(
theta, size=len(rows), index=block_index
),
lambda rows=rows, block_index=block_index: method(
theta, size=len(rows), times=block_index
),
lambda rows=rows: method(theta, size=len(rows)),
lambda: method(theta),
)
last_error: Exception | None = None
for attempt in attempts:
try:
block = np.asarray(attempt(), dtype=float)
break
except (TypeError, ValueError) as exc:
last_error = exc
else:
raise BackendInputError(
"could not evaluate structured residual covariance",
details={"last_error": repr(last_error)},
)
if block.shape != (len(rows), len(rows)):
raise BackendInputError(
"structured residual covariance returned the wrong block dimension"
)
result[np.ix_(rows, rows)] = block
scaling = 1.0 / np.sqrt(self.compiled.weights)
return scaling[:, None] * result * scaling[None, :]
def natural(self, theta: ArrayLike) -> dict[str, float]:
theta = np.asarray(theta, dtype=float)
structure = str(getattr(self.source, "name", "")).lower()
result: dict[str, float] = {}
for name, value in zip(self.raw_names, theta, strict=True):
normalized = name.lower()
if normalized == "logit_rho":
result["residual_spatial_correlation"] = float(special.expit(value))
elif "correlation_unconstrained" in normalized:
if structure == "compound-symmetry":
dimension = len(self.row_blocks[0])
lower = -1 / (dimension - 1) if dimension > 1 else 0.0
correlation = lower + (1 - lower) / (1 + np.exp(-value))
else:
correlation = np.tanh(value)
result["residual_correlation"] = float(correlation)
elif "partial_correlation" in normalized:
result[f"residual_{normalized}"] = float(np.tanh(value))
elif normalized.startswith("log_"):
natural_name = normalized.removeprefix("log_")
result[f"residual_{natural_name}"] = float(np.exp(value))
else:
result[f"residual_{normalized}"] = float(value)
return result
@dataclass(slots=True)
class _Evaluation:
log_likelihood: float
beta: NDArray[np.float64]
beta_covariance: NDArray[np.float64]
residual: NDArray[np.float64]
V: NDArray[np.float64]
V_cholesky: NDArray[np.float64]
G: NDArray[np.float64]
rank: int
logdet_x_information: float
jitter: float
[docs]
class GaussianLMMBackend:
"""Exact dense/reference Gaussian LMM engine."""
name = "lmm"
[docs]
def fit(
self,
data: Any,
*,
method: str | None = None,
reml: bool | None = None,
maxiter: int = 1_000,
tolerance: float = 1e-8,
compute_hessian: bool = True,
**options: Any,
) -> dict[str, Any]:
compiled = prepare_data(data)
if method is None:
method = str(field(compiled.source, "method", "estimator", default="REML"))
if reml is not None:
method = "REML" if reml else "ML"
method = method.upper()
if method not in {"ML", "REML"}:
raise BackendInputError("Gaussian LMM method must be 'ML' or 'REML'")
n, p = compiled.X.shape
if n == 0:
raise BackendInputError("cannot fit an empty response")
if method == "REML" and n <= np.linalg.matrix_rank(compiled.X):
raise BackendInputError("REML requires more observations than fixed-effect rank")
parameterizations = covariance_slices(compiled.random_blocks)
random_theta_count = sum(item.size for item, _ in parameterizations)
response_scale = max(float(np.std(compiled.y, ddof=1)) if n > 1 else 1.0, 1e-3)
theta_parts = [item.initial(response_scale * 0.35) for item, _ in parameterizations]
raw_residual = compiled.residual_covariance
is_structured = bool(
raw_residual is not None
and not isinstance(raw_residual, (np.ndarray, list, tuple))
and callable(getattr(raw_residual, "parameter_count", None))
and (
callable(getattr(raw_residual, "covariance", None))
or callable(getattr(raw_residual, "matrix", None))
)
)
structured_residual = (
_StructuredResidual.create(raw_residual, compiled) if is_structured else None
)
residual_template = None if is_structured else _residual_template(compiled)
if structured_residual is not None:
if compiled.residual_covariance_fixed and structured_residual.parameter_count:
raise BackendInputError(
"fixed structured residual covariance requires a zero-parameter "
"KnownCovariance object or an explicit matrix"
)
residual_theta_count = structured_residual.parameter_count
if residual_theta_count:
theta_parts.append(structured_residual.initial(response_scale * 0.8))
residual_mode = "structured"
elif not compiled.residual_covariance_fixed:
residual_theta_count = 1
theta_parts.append(np.array([np.log(response_scale * 0.8)]))
residual_mode = "template-scale"
else:
residual_theta_count = 0
residual_mode = "fixed"
residual_section = slice(random_theta_count, random_theta_count + residual_theta_count)
theta0 = np.concatenate(theta_parts) if theta_parts else np.zeros(0)
def residual_matrix(theta: NDArray[np.float64]) -> NDArray[np.float64]:
if residual_mode == "structured":
assert structured_residual is not None
return structured_residual.matrix(theta[residual_section])
assert residual_template is not None
if residual_mode == "template-scale":
return np.exp(2 * theta[residual_section.start]) * residual_template
return residual_template
def evaluate(theta: ArrayLike) -> _Evaluation:
theta = np.asarray(theta, dtype=float)
G = random_covariance(parameterizations, theta[:random_theta_count])
R = residual_matrix(theta)
Z = compiled.random_design
V = R if not Z.shape[1] else R + Z @ G @ Z.T
cholesky, jitter = safe_cholesky(V)
whitened_y = linalg.solve_triangular(
cholesky, compiled.y, lower=True, check_finite=False
)
whitened_X = linalg.solve_triangular(
cholesky, compiled.X, lower=True, check_finite=False
)
beta, _, rank, singular_values = np.linalg.lstsq(whitened_X, whitened_y, rcond=None)
residual = compiled.y - compiled.X @ beta
solved_residual = cho_solve(cholesky, residual)
quadratic = float(residual @ solved_residual)
logdet_v = logdet_from_cholesky(cholesky)
information = whitened_X.T @ whitened_X
beta_covariance = np.linalg.pinv(information, rcond=1e-10)
if rank:
positive = singular_values[:rank] ** 2
logdet_information = float(np.log(positive).sum())
else:
logdet_information = 0.0
if method == "ML":
log_likelihood = -0.5 * (n * np.log(2 * np.pi) + logdet_v + quadratic)
else:
log_likelihood = -0.5 * (
(n - rank) * np.log(2 * np.pi) + logdet_v + logdet_information + quadratic
)
return _Evaluation(
log_likelihood=float(log_likelihood),
beta=beta,
beta_covariance=beta_covariance,
residual=residual,
V=V,
V_cholesky=cholesky,
G=G,
rank=int(rank),
logdet_x_information=logdet_information,
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},
)
# Powell is a deterministic rescue for rough covariance surfaces,
# followed by L-BFGS-B so the reported gradient matches the optimum.
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, dtype=float)
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))
raw_gradient = getattr(result, "jac", None)
gradient = (
finite_gradient(objective, theta_hat)
if raw_gradient is None
else np.asarray(raw_gradient, dtype=float)
)
if np.any(~np.isfinite(gradient)):
gradient = finite_gradient(objective, theta_hat)
else:
theta_hat = theta0
success = True
optimizer_message = "No covariance parameters required optimization"
iterations = evaluations = 0
gradient = np.zeros(0)
try:
final = evaluate(theta_hat)
except (ValueError, linalg.LinAlgError) as exc:
raise BackendNumericalError(
"optimized LMM covariance could not be factorized",
details={"optimizer_message": optimizer_message},
) from exc
# Identify aliased fixed effects with pivoted QR.
_, _, pivots = linalg.qr(compiled.X, mode="economic", pivoting=True)
aliased_indices = sorted(int(index) for index in pivots[final.rank :])
aliased_names = [compiled.fixed_names[index] for index in aliased_indices]
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: dict[str, float] = {
name: float(value) for name, value in zip(compiled.fixed_names, final.beta, strict=True)
}
unconstrained_parameters: dict[str, float] = dict(natural_parameters)
theta_names: list[str] = []
natural_covariance_names: list[str] = []
natural_covariance_values: list[float] = []
covariance_eigenvalues: dict[str, list[float]] = {}
covariance_ratios: list[float] = []
for parameterization, section in parameterizations:
block_values = parameterization.names_and_values(theta_hat[section])
natural_parameters.update(block_values)
natural_covariance_names.extend(block_values)
natural_covariance_values.extend(block_values.values())
raw_names = parameterization.unconstrained_names()
theta_names.extend(raw_names)
unconstrained_parameters.update(
{
name: float(value)
for name, value in zip(raw_names, theta_hat[section], strict=True)
}
)
block_covariance = parameterization.matrix(theta_hat[section])
eigenvalues = np.linalg.eigvalsh(block_covariance)
covariance_eigenvalues[parameterization.block.name] = eigenvalues.tolist()
covariance_ratios.append(
float(eigenvalues[0] / eigenvalues[-1]) if eigenvalues[-1] > 0 else 0.0
)
if residual_mode == "template-scale":
residual_sd = float(np.exp(theta_hat[residual_section.start]))
natural_parameters["residual_sd"] = residual_sd
natural_covariance_names.append("residual_sd")
natural_covariance_values.append(residual_sd)
theta_names.append("log_residual_sd")
unconstrained_parameters["log_residual_sd"] = float(theta_hat[residual_section.start])
elif residual_mode == "structured" and structured_residual is not None:
residual_values = structured_residual.natural(theta_hat[residual_section])
natural_parameters.update(residual_values)
natural_covariance_names.extend(residual_values)
natural_covariance_values.extend(residual_values.values())
structured_names = [f"residual:{name}" for name in structured_residual.raw_names]
theta_names.extend(structured_names)
unconstrained_parameters.update(
{
name: float(value)
for name, value in zip(
structured_names, theta_hat[residual_section], strict=True
)
}
)
# Delta-method transform from covariance theta to sd/correlation scale.
if theta_hat.size:
def natural_theta_values(theta: NDArray[np.float64]) -> NDArray[np.float64]:
values: list[float] = []
for parameterization, section in parameterizations:
values.extend(parameterization.names_and_values(theta[section]).values())
if residual_mode == "template-scale":
values.append(float(np.exp(theta[residual_section.start])))
elif residual_mode == "structured" and structured_residual is not None:
values.extend(structured_residual.natural(theta[residual_section]).values())
return np.asarray(values)
jacobian = np.empty((len(natural_covariance_values), 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_theta_values(theta_hat + delta)
- natural_theta_values(theta_hat - delta)
) / (2 * step)
transformed_theta_covariance = jacobian @ theta_covariance @ jacobian.T
else:
transformed_theta_covariance = np.zeros((0, 0))
parameter_covariance = linalg.block_diag(
final.beta_covariance, transformed_theta_covariance
)
Z = compiled.random_design
if Z.shape[1]:
marginal_residual_solution = cho_solve(final.V_cholesky, final.residual)
random_effects = final.G @ Z.T @ marginal_residual_solution
conditional_covariance = final.G - final.G @ Z.T @ cho_solve(
final.V_cholesky, Z @ final.G
)
conditional_covariance = (conditional_covariance + conditional_covariance.T) / 2
conditional_sd = np.sqrt(np.maximum(np.diag(conditional_covariance), 0.0))
conditional_fitted = compiled.X @ final.beta + Z @ random_effects
random_labels = [
label for block in compiled.random_blocks for label in block.coefficient_labels()
]
else:
random_effects = np.zeros(0)
conditional_covariance = np.zeros((0, 0))
conditional_sd = np.zeros(0)
conditional_fitted = compiled.X @ final.beta
random_labels = []
fitted = conditional_fitted
residuals = compiled.y - fitted
standardized = residuals / np.sqrt(np.maximum(np.diag(final.V), np.finfo(float).tiny))
singular = bool(covariance_ratios and min(covariance_ratios) < 1e-6)
boundary_names = [
name
for name, value in natural_parameters.items()
if name.startswith("sd(") and value < response_scale * 1e-5
]
warnings: list[dict[str, Any]] = []
if aliased_names:
warnings.append(
{
"code": "LMM-RANK-001",
"severity": "warning",
"message": "fixed-effect design is rank deficient",
"details": {"aliased_coefficients": aliased_names},
}
)
if singular:
warnings.append(
{
"code": "COV-SINGULAR-001",
"severity": "warning",
"message": "a random-effect covariance block is singular or near-singular",
"details": {"eigenvalue_ratios": covariance_ratios},
}
)
if final.jitter:
warnings.append(
{
"code": "LMM-JITTER-001",
"severity": "info",
"message": "tiny diagonal jitter was required for covariance factorization",
"details": {"jitter": final.jitter},
}
)
convergence = convergence_mapping(
success=success,
message=optimizer_message,
iterations=iterations,
function_evaluations=evaluations,
gradient=gradient,
hessian_positive_definite=hessian_positive,
singular=singular,
boundary_parameters=boundary_names,
warnings=warnings,
extra={
"fixed_effect_rank": final.rank,
"fixed_effect_columns": p,
"aliased_coefficients": aliased_names,
"curvature_source": curvature_source,
},
)
parameter_names = list(compiled.fixed_names) + natural_covariance_names
return make_payload(
parameters=natural_parameters,
unconstrained_parameters=unconstrained_parameters,
parameter_covariance=parameter_covariance,
fitted_values=fitted,
residuals=residuals,
random_effects=random_effects,
objective=float(-final.log_likelihood),
log_likelihood=float(final.log_likelihood),
method=method,
engine=self.name,
convergence=convergence,
diagnostic_data={
"observations": {
"observed": compiled.y.tolist(),
"fitted_conditional": fitted.tolist(),
"residual": residuals.tolist(),
"standardized_residual": standardized.tolist(),
},
"random_effects": {
"name": random_labels,
"conditional_mode": random_effects.tolist(),
"conditional_sd": conditional_sd.tolist(),
},
},
extra={
"parameter_names": parameter_names,
"unconstrained_parameter_names": list(compiled.fixed_names) + theta_names,
"population_fitted_values": compiled.X @ final.beta,
"fixed_design": compiled.X,
"fixed_effect_covariance": final.beta_covariance,
"fixed_effect_names": list(compiled.fixed_names),
"marginal_observation_covariance": final.V,
"random_effect_observation_covariance": (final.V - residual_matrix(theta_hat)),
"objective_convention": (
"negative exact normalized Gaussian restricted log likelihood"
if method == "REML"
else "negative exact normalized Gaussian marginal log likelihood"
),
"reference_engine": True,
"dense_covariance": True,
"fixed_effect_rank": final.rank,
"aliased_coefficients": aliased_names,
"random_covariance_eigenvalues": covariance_eigenvalues,
"random_covariance_eigenvalue_ratios": covariance_ratios,
"conditional_random_effect_covariance": conditional_covariance,
"residual_covariance": residual_matrix(theta_hat),
"residual_covariance_mode": residual_mode,
"profiled_fixed_effect_covariance": final.beta_covariance,
"covariance_parameter_covariance": theta_covariance,
"likelihood_includes_data_constants": True,
},
)
LMMBackend = GaussianLMMBackend
DenseLMMBackend = GaussianLMMBackend
[docs]
def fit_lmm(data: Any, **options: Any) -> dict[str, Any]:
"""Fit a Gaussian LMM with the dense reference backend."""
return GaussianLMMBackend().fit(data, **options)
__all__ = ["DenseLMMBackend", "GaussianLMMBackend", "LMMBackend", "fit_lmm"]