Introduction to Marginal REML Estimation¶
The MarginalREML class implements restricted maximum
likelihood (REML) estimation for generalised least squares with a parametric
marginal covariance matrix, using the average information (AI) algorithm.
The AI algorithm combines the stability of the expected information matrix
with the curvature information of the observed information matrix, leading to
efficient and robust estimation of covariance parameters.
This vignette introduces the marginal REML model formulation, explains how to construct covariance models, demonstrates optimisation workflows, and covers post-estimation utilities such as BLUEs, marginal predictions, residuals, and convergence diagnostics.
The model¶
The MarginalREML class assumes the generalised
least squares model
where
\(\mathbf{y}\) is the response vector of length \(n\),
\(\mathbf{X}\) is the fixed-effects design matrix,
\(\boldsymbol{\beta}\) is the vector of fixed effects,
\(\mathbf{V}(\boldsymbol{\theta})\) is a parametric marginal covariance matrix parameterised by \(\boldsymbol{\theta}\).
The REML estimator maximises the restricted log-likelihood
where
is the residual projection matrix.
Important
Internally, MarginalREML repeatedly evaluates
the covariance matrix \(\mathbf{V}\),
its derivatives \(\partial \mathbf{V} / \partial \theta_k\),
the score vector,
and the average information matrix,
until convergence criteria are satisfied.
Constructing the REML object¶
The MarginalREML constructor takes a single
Matrix instance v that
defines the marginal covariance structure \(\mathbf{V}\).
from torch_openreml import MarginalREML
reml = MarginalREML(v)
There are two main ways to build v, depending on complexity:
SimpleMatrix— wrap a plain function for quick prototyping.The
covariancebuilder system — compose pre-built covariance components with operators.
The MarginalREML class works directly with the marginal covariance matrix \(\mathbf{V}\) and does not require separate \(\mathbf{G}\) (random) or \(\mathbf{R}\) (error) components as in mixed model.
Via SimpleMatrix (function-based)¶
For quick prototyping, SimpleMatrix
wraps a plain function. All parameters are free and unconstrained.
import torch
from torch_openreml import MarginalREML
from torch_openreml.covariance import SimpleMatrix
n = 50
def my_v(theta):
sigma2 = theta[0].exp()
rho = torch.sigmoid(theta[1])
I = torch.eye(n)
J = torch.ones(n, n)
return sigma2 * (I + rho * J)
v = SimpleMatrix(num_free_params=2, call=my_v)
reml = MarginalREML(v)
In this example:
theta[0]controls the variance scale,theta[1]controls a correlation parameter,exponential and sigmoid transforms enforce parameter constraints.
The covariance matrix is
where \(\mathbf{J}\) is the all-ones matrix.
When no manual_grad is supplied,
SimpleMatrix computes derivatives
automatically via auto_grad().
This approach is convenient for prototyping, but automatic differentiation can become expensive for large covariance matrices. At the same time, it offers essentially unrestricted flexibility: the marginal covariance can be constructed through arbitrary differentiable PyTorch operations, ranging from simple parameterizations to highly sophisticated matrix algebra, iterative procedures, decompositions, simulation-based constructions, dynamically assembled covariance components, or even neural networks that directly output or parameterize covariance structure.
Because the covariance construction is defined directly in Python/PyTorch code rather than through a fixed covariance specification, users are free to incorporate conditional branching, stochastic generation, adaptive logic, external modules, or even entirely different covariance structures across optimization iterations (although such behavior is usually not statistically meaningful in practice). In effect, any covariance model that can be expressed as a differentiable computational graph in PyTorch can be used within this framework.
Via SimpleMatrix with manual gradients¶
For better performance, analytical derivatives can be supplied via the
manual_grad argument.
import torch
from torch_openreml import MarginalREML
from torch_openreml.covariance import SimpleMatrix
n = 50
def my_v(theta):
sigma2 = theta[0].exp()
rho = torch.sigmoid(theta[1])
I = torch.eye(n)
J = torch.ones(n, n)
return sigma2 * (I + rho * J)
def my_dv(theta):
sigma2 = theta[0].exp()
rho = torch.sigmoid(theta[1])
dsigma2 = sigma2
drho = rho * (1 - rho)
I = torch.eye(n)
J = torch.ones(n, n)
dV_dtheta0 = dsigma2 * (I + rho * J)
dV_dtheta1 = sigma2 * drho * J
grad = torch.stack([dV_dtheta0, dV_dtheta1])
return grad, ["sigma2", "rho"]
v = SimpleMatrix(num_free_params=2, call=my_v, manual_grad=my_dv)
reml = MarginalREML(v)
The gradient function must return a tuple (grad, grad_names), where
grad is a tensor of shape (num_free_params, n, n) and
grad_names is a list of parameter name strings. Slice k of
grad corresponds to
Providing analytical derivatives is strongly recommended for large models because derivative evaluation is typically one of the most computationally expensive parts of REML optimisation.
Via a covariance Matrix builder¶
For realistic mixed models, the recommended workflow is to construct the
covariance structure using the
torch_openreml.covariance matrix system.
The covariance builder API provides reusable covariance components such as:
along with composition operators such as:
Example:
import torch
from torch_openreml import MarginalREML
from torch_openreml.covariance import DummyMatrix, ScalarMatrix, CovariancePropagation, Sum
n, p = 50, 2
y = torch.randn(n)
X = torch.randn(n, p)
Z = DummyMatrix(["a", "b"] * 25)
V = Sum(
CovariancePropagation(
Z,
ScalarMatrix(2),
),
ScalarMatrix(n),
)
reml = MarginalREML(V)
The builder automatically manages:
parameter bookkeeping,
parameter transforms,
covariance assembly,
covariance derivatives.
Internally, MarginalREML calls
__call__()to build \(\mathbf{V}\),grad()for the Jacobian.
The derivative calculation uses
grad(),
which attempts a closed-form
manual_grad()
implementation first and falls back to automatic differentiation when
necessary.
Running the optimiser¶
Once the REML object is constructed, optimisation is performed using
optimize().
import torch
from torch_openreml import MarginalREML
from torch_openreml.covariance import DummyMatrix, ScalarMatrix, CovariancePropagation, Sum
n, p = 50, 2
y = torch.randn(n)
X = torch.randn(n, p)
Z = DummyMatrix(["a", "b"] * 25)
V = Sum(
CovariancePropagation(
Z,
ScalarMatrix(2),
),
ScalarMatrix(n),
)
reml = MarginalREML(V)
theta_start = torch.zeros(V.num_free_params)
theta_hat, beta_hat, n_iter = reml.optimize(
y,
X,
theta_start,
verbose=1,
)
[ 1] ∥∇∥: 6.0693, ∥Δ∥: 12.5063, η: 1.00, ∥Δᶜ∥: 12.5063, log 𝓛: -27.2132
[ 2] ∥∇∥: 0.7871, ∥Δ∥: 0.0081, η: 1.00, ∥Δᶜ∥: 0.0081, log 𝓛: -24.8313 (+2.3819)
[ 3] ∥∇∥: 0.0063, ∥Δ∥: 0.0001, η: 1.00, ∥Δᶜ∥: 0.0001, log 𝓛: -24.8281 (+0.0032)
[ 4] ∥∇∥: 0.0000, ∥Δ∥: 0.0000, η: 1.00, ∥Δᶜ∥: 0.0000, log 𝓛: -24.8281 (+0.0000)
[∇: score, Δ: 𝐉⁻¹∇, η: learning rate, Δᶜ: clip(𝛉 + ηΔ, lb, ub) - 𝛉, 𝓛: restricted likelihood]
✓ Converged at iteration 4, total time: 0.00s
Inputs¶
yResponse vector of shape
(n,).XFixed-effects design matrix of shape
(n, p).theta_startInitial parameter vector.
Choosing sensible starting values can substantially improve convergence, especially for complex covariance structures.
Returned values¶
theta_hatFinal variance-component estimates.
beta_hatEstimated fixed effects evaluated at
theta_hat.n_iterNumber of completed optimisation iterations.
Optimisation controls¶
The optimisation routine supports several keyword arguments.
max_iter(default200)Maximum number of iterations.
eta(default1.0)Step-size multiplier applied to the AI update.
The parameter update is
\[\boldsymbol{\theta}^{(t+1)} = \boldsymbol{\theta}^{(t)} + \eta \, \mathbf{AI}^{-1}\mathbf{s}.\]Smaller values of
etacan improve stability when the optimisation oscillates or diverges.lbandubOptional lower and upper parameter bounds applied after each update.
verbose(default0)Controls optimisation output.
0: silent1: per-iteration diagnostics, a convergence notice, and the total optimisation time
Diagnostics include:
score norm,
update norm,
log-likelihood,
learning rate,
iteration count.
trace_approx(defaultFalse)Whether to start the optimisation with the stochastic probe estimate for the score. The estimator is Hutch++ — Meyer, Musco, Musco & Woodruff, Hutch++: Optimal stochastic trace estimation, SOSA 2021, pp. 142–155, doi:10.1137/1.9781611976496.16.
With
\[\mathbf{A}_k = \mathbf{V}^{-1} \frac{\partial \mathbf{V}}{\partial \theta_k}, \qquad \mathbf{B}_k = \mathbf{V}^{-1} \mathbf{X} \left(\mathbf{X}^\top \mathbf{V}^{-1} \mathbf{X}\right)^{-1} \mathbf{X}^\top \mathbf{V}^{-1} \frac{\partial \mathbf{V}}{\partial \theta_k}\]the trace term of the score splits as \(\mathrm{tr}(\mathbf{P} \, \partial \mathbf{V} / \partial \theta_k) = \mathrm{tr}(\mathbf{A}_k) - \mathrm{tr}(\mathbf{B}_k)\), and the two terms are estimated separately, so neither matrix is formed in full.
The estimate of a matrix \(\mathbf{C}\) draws two independent Rademacher probe matrices \(\mathbf{S}, \mathbf{G} \in \{\pm 1\}^{n \times m}\) with \(m = \lfloor n f \rfloor + 1\) probes each, where \(f\) is
subspace_fraction, and combines a QR sketch with Hutchinson’s estimator on the deflated residual:\[\mathbf{Q} = \mathrm{qr}(\mathbf{C} \mathbf{S}), \qquad \hat{h}(\mathbf{C}) = \mathrm{tr}(\mathbf{Q}^\top \mathbf{C} \mathbf{Q}) + \frac{1}{m} \mathrm{tr}\!\left( \mathbf{G}^\top (\mathbf{I} - \mathbf{Q}\mathbf{Q}^\top) \mathbf{C} (\mathbf{I} - \mathbf{Q}\mathbf{Q}^\top) \mathbf{G} \right).\]\(\mathbf{Q}\) spans the dominant range of \(\mathbf{C}\), so the first term is exact there; the second applies Hutchinson’s estimator to the deflated residual.
The estimate has an inherent noise floor: its error remains roughly constant even as the optimisation approaches the optimum, while the true score norm tends to zero. Far from the optimum, the score is much larger than this error, so the estimate is sufficiently accurate to make progress. Once the true score norm becomes comparable to the error, however, the norm seen by the optimiser stops decreasing and plateaus. As a result,
tol_scoreis never reached, and the optimisation continues untilmax_iterrather than stopping. The average information matrix and log-likelihood are exact at every iteration and are computed regardless, so these additional iterations provide no benefit. In fact, a run that uses the approximation throughout can cost more than one that uses the exact score from the beginning.To avoid this, the optimiser monitors the score norm and switches to the exact score once the norm falls below
exact_score_threshold. The exact score is then used for that iteration and all subsequent iterations. This is what makes the approximation worthwhile: it accelerates the early iterations, when the score is well above the noise floor, while leaving the final stage of optimisation to the exact score. By default,trace_approx=False, so the entire optimisation uses the exact score. Settrace_approx=Trueto opt in to the approximation.subspace_fraction(default0.01)Fraction of the
nobservations used as the dimension of the random probe subspace in the stochastic trace estimate that forms the score on iterations that are still far from the optimum.Each of the two probe matrices holds
int(n * subspace_fraction) + 1vectors, so the value must lie in[0, 1]and the total probe budget is about twice that. Larger values give a more accurate approximate score at a higher cost per iteration. Only used whentrace_approx=True. Only the score is affected: the average information matrix and the log-likelihood are always computed exactly.exact_score_threshold(default10.0)Score norm below which the stochastic probe estimate is switched off for the remainder of the optimisation.
Since the first score is always computed with the current setting,
infswitches the approximation off after the first iteration while0.0keeps it on throughout. Usetrace_approx=Falseinstead to skip it entirely.
Convergence criteria¶
At each iteration, convergence is evaluated using three criteria.
Score norm¶
The score vector must satisfy
This checks whether the gradient is close to zero.
Parameter update norm¶
The parameter update must satisfy
This checks whether the parameters have stabilised.
Log-likelihood change¶
The restricted log-likelihood change must satisfy
Default tolerances¶
The default tolerances are:
tol_score = 1e-4tol_delta = 1e-4tol_loglik = 1e-4
All enabled criteria must be satisfied simultaneously.
Criteria may be individually disabled using:
check_score=Falsecheck_delta=Falsecheck_loglik=False
At least two iterations are required before convergence can be declared.
Post-estimation methods¶
After optimisation, the fitted REML object provides several utilities for extracting estimates and predictions.
Retrieving parameter estimates¶
theta_last = reml.get_theta(select="last")
theta_best = reml.get_theta(select="best")
beta_last = reml.get_beta(select="last")
beta_best = reml.get_beta(select="best")
select="last"Returns the final optimisation iterate.
select="best"Returns the iterate with the highest restricted log-likelihood.
The "best" option is useful if optimisation temporarily overshoots
or oscillates near convergence.
BLUE — fixed effects¶
The best linear unbiased estimator (BLUE) of the fixed effects is
Compute it using:
beta_hat = reml.blue(y, X, theta_hat)
Predictions¶
Predictions use the fixed effects:
Example:
y_hat = reml.predict(
y,
X,
theta_hat,
)
Residuals¶
Residuals:
Example:
e = reml.residual(
y,
X,
theta_hat,
)
Evaluating the log-likelihood¶
The REML log-likelihood can be evaluated directly without optimisation.
loglik = reml.loglik(y, X, theta_hat)
This is useful for:
debugging,
likelihood profiling,
model comparison,
monitoring optimisation trajectories.
Complete example¶
The following example demonstrates a mixed model with:
genotype random effects,
replicate-block random effects,
residual variance.
import torch
from torch_openreml import MarginalREML
from torch_openreml.covariance import (
Augment,
BlockDiagonal,
DummyMatrix,
IdentityMatrix,
ScalarMatrix,
Sum,
CovariancePropagation,
KroneckerProduct,
)
from torch_openreml.example_data import john_alpha
# --- response ---
y = torch.tensor(john_alpha["yield"].values)
# --- fixed effects ---
X = Augment(
torch.ones(len(john_alpha), 1),
DummyMatrix(john_alpha["rep"], drop_first=True)
)()
# --- random effects ---
gen = john_alpha["gen"]
rep = john_alpha["rep"]
block = john_alpha["block"]
n = len(john_alpha)
# --- marginal covariance ---
V = Sum(
CovariancePropagation(
Augment(
DummyMatrix(gen),
DummyMatrix(rep, block),
),
BlockDiagonal(
ScalarMatrix(gen),
KroneckerProduct(
IdentityMatrix(rep),
ScalarMatrix(block),
),
),
),
ScalarMatrix(n),
)
# --- REML fit ---
reml = MarginalREML(V)
theta_start = torch.zeros(V.num_free_params)
theta_hat, beta_hat, n_iter = reml.optimize(
y,
X,
theta_start,
verbose=1,
)
# --- results ---
print("theta:", theta_hat)
print("variance components:", V.build_params(theta_hat))
print("fixed effects:", beta_hat)
print("loglik:", reml.loglik(y, X, theta_hat))
[ 1] ∥∇∥: 41.8197, ∥Δ∥: 8.9438, η: 1.00, ∥Δᶜ∥: 8.9438, log 𝓛: -34.3129
[ 2] ∥∇∥: 315227.4375, ∥Δ∥: 0.8838, η: 1.00, ∥Δᶜ∥: 0.8838, log 𝓛: -201185.3906 (-201151.0777)
[ 3] ∥∇∥: 119230.2656, ∥Δ∥: 0.8519, η: 1.00, ∥Δᶜ∥: 0.8519, log 𝓛: -71844.5469 (+129340.8438)
[ 4] ∥∇∥: 44273.9336, ∥Δ∥: 0.8143, η: 1.00, ∥Δᶜ∥: 0.8143, log 𝓛: -26065.6270 (+45778.9199)
[ 5] ∥∇∥: 16331.3926, ∥Δ∥: 0.7587, η: 1.00, ∥Δᶜ∥: 0.7587, log 𝓛: -9457.4141 (+16608.2129)
[ 6] ∥∇∥: 6001.7085, ∥Δ∥: 0.7142, η: 1.00, ∥Δᶜ∥: 0.7142, log 𝓛: -3393.0168 (+6064.3972)
[ 7] ∥∇∥: 2194.9878, ∥Δ∥: 0.7031, η: 1.00, ∥Δᶜ∥: 0.7031, log 𝓛: -1181.3339 (+2211.6830)
[ 8] ∥∇∥: 793.8170, ∥Δ∥: 0.6952, η: 1.00, ∥Δᶜ∥: 0.6952, log 𝓛: -382.5103 (+798.8235)
[ 9] ∥∇∥: 278.6989, ∥Δ∥: 0.6715, η: 1.00, ∥Δᶜ∥: 0.6715, log 𝓛: -102.3058 (+280.2045)
[ 10] ∥∇∥: 90.4160, ∥Δ∥: 0.5957, η: 1.00, ∥Δᶜ∥: 0.5957, log 𝓛: -11.4295 (+90.8763)
[ 11] ∥∇∥: 23.9232, ∥Δ∥: 0.4006, η: 1.00, ∥Δᶜ∥: 0.4006, log 𝓛: 12.6788 (+24.1083)
[ 12] ∥∇∥: 3.8678, ∥Δ∥: 0.1392, η: 1.00, ∥Δᶜ∥: 0.1392, log 𝓛: 16.5908 (+3.9120)
[ 13] ∥∇∥: 0.2448, ∥Δ∥: 0.0157, η: 1.00, ∥Δᶜ∥: 0.0157, log 𝓛: 16.8077 (+0.2169)
[ 14] ∥∇∥: 0.0104, ∥Δ∥: 0.0007, η: 1.00, ∥Δᶜ∥: 0.0007, log 𝓛: 16.8099 (+0.0021)
[ 15] ∥∇∥: 0.0005, ∥Δ∥: 0.0000, η: 1.00, ∥Δᶜ∥: 0.0000, log 𝓛: 16.8097 (-0.0002)
[ 16] ∥∇∥: 0.0000, ∥Δ∥: 0.0000, η: 1.00, ∥Δᶜ∥: 0.0000, log 𝓛: 16.8101 (+0.0004)
[ 17] ∥∇∥: 0.0001, ∥Δ∥: 0.0000, η: 1.00, ∥Δᶜ∥: 0.0000, log 𝓛: 16.8098 (-0.0002)
[ 18] ∥∇∥: 0.0001, ∥Δ∥: 0.0000, η: 1.00, ∥Δᶜ∥: 0.0000, log 𝓛: 16.8101 (+0.0002)
[ 19] ∥∇∥: 0.0001, ∥Δ∥: 0.0000, η: 1.00, ∥Δᶜ∥: 0.0000, log 𝓛: 16.8101 (+0.0000)
[∇: score, Δ: 𝐉⁻¹∇, η: learning rate, Δᶜ: clip(𝛉 + ηΔ, lb, ub) - 𝛉, 𝓛: restricted likelihood]
✓ Converged at iteration 19, total time: 0.02s
theta: tensor([-0.9728, -1.3281, -1.2529])
variance components: tensor([0.1429, 0.0702, 0.0816])
fixed effects: tensor([ 4.5183, 0.2978, -0.4140])
loglik: tensor(16.8098)
Optimisation history¶
After optimisation, the full iteration history is stored in
reml.history.
This dictionary contains per-iteration records including:
thetabetaloglikscoreaideltaupdate
This history is useful for:
diagnosing convergence problems,
inspecting optimisation trajectories,
plotting likelihood curves,
monitoring parameter stability.
Example:
scores = [
torch.norm(s).item()
for s in reml.history["score"]
]
logliks = [
ll.item()
for ll in reml.history["loglik"]
]
print(
"Score norms:",
[f"{s:.6f}" for s in scores],
)
print(
"Log-likelihoods:",
[f"{ll:.4f}" for ll in logliks],
)
Score norms: ['41.819736', '315227.437500', '119230.265625', '44273.933594', '16331.392578', '6001.708496', '2194.987793', '793.817017', '278.698944', '90.416023', '23.923166', '3.867793', '0.244776', '0.010351', '0.000450', '0.000027', '0.000051', '0.000069', '0.000054']
Log-likelihoods: ['-34.3129', '-201185.3906', '-71844.5469', '-26065.6270', '-9457.4141', '-3393.0168', '-1181.3339', '-382.5103', '-102.3058', '-11.4295', '12.6788', '16.5908', '16.8077', '16.8099', '16.8097', '16.8101', '16.8098', '16.8101', '16.8101']