# lme-rs User Guide

This guide covers the practical Rust workflow for fitting linear and generalized linear mixed-effects models with `lme-rs`.

## Table of Contents

- [Getting Started](#getting-started)
- [Data Requirements](#data-requirements)
- [Modeling Workflows](#modeling-workflows)
- [Predictions](#predictions)
- [Inference and Diagnostics](#inference-and-diagnostics)
- [Model Comparison](#model-comparison)
- [Limitations and Compatibility Notes](#limitations-and-compatibility-notes)
- [Troubleshooting](#troubleshooting)
- [Performance Notes](#performance-notes)
- [API Surface Summary](#api-surface-summary)

## Getting Started

### Installation

```toml
[dependencies]
lme-rs = "0.1.9"
polars = { version = "0.46", features = ["csv"] }
anyhow = "1"
```

### Loading data

`lme-rs` uses `polars::DataFrame` as its data container.

```rust
use polars::prelude::*;

let mut file = std::fs::File::open("tests/data/sleepstudy.csv")?;
let df = CsvReadOptions::default()
    .with_has_header(true)
    .into_reader_with_file_handle(&mut file)
    .finish()?;
```

### First model

```rust
use lme_rs::lmer;

let fit = lmer("Reaction ~ Days + (Days | Subject)", &df, true)?;
println!("{}", fit);
```

The `reml` flag controls LMM estimation mode:

- `true`: REML. Prefer this for reporting variance components.
- `false`: ML. Use this when comparing nested models with likelihood ratio tests.

## Data Requirements

`lme-rs` expects a rectangular `polars::DataFrame` with column names that match the variables referenced in the formula.

### Column types

- Response and numeric predictor columns should be numeric (`Float64` or `Int64` are the safe defaults).
- Grouping columns for random effects should be string-like or otherwise castable to strings.
- Prediction data must contain the same fixed-effect and grouping columns implied by the fitted formula.

### Formula expectations

The parser supports the standard R-style Wilkinson syntax used by `lme4` for the covered cases:

- `y ~ x`
- `y ~ x + (1 | group)`
- `y ~ x + (x | group)`
- `y ~ x + (1 | a) + (1 | b)`
- `y ~ x + (1 | a/b)`

### Practical advice

- Keep grouping identifiers stable and explicit. Converting them to strings before modeling is often the least surprising choice.
- Centering or scaling continuous predictors can help optimizer behavior for more complex random-effects structures.
- When building prediction frames, mirror the training column names exactly.

## Modeling Workflows

### `lm()` for fixed-effects-only linear models

```rust
use lme_rs::lm;
use ndarray::array;

let y = array![1.0, 2.0, 3.0, 4.0];
let x = array![
    [1.0, 1.0],
    [1.0, 2.0],
    [1.0, 3.0],
    [1.0, 4.0],
];

let fit = lm(&y, &x)?;
println!("Coefficients: {:?}", fit.coefficients);
```

### `lmer()` for linear mixed models

```rust
use lme_rs::lmer;

let fit = lmer("Reaction ~ Days + (Days | Subject)", &df, true)?;
```

Common patterns:

```rust
// Random intercept only
let fit = lmer("Yield ~ 1 + (1 | Batch)", &df, true)?;

// Random intercept and slope
let fit = lmer("Reaction ~ Days + (Days | Subject)", &df, true)?;

// Nested random effects
let fit = lmer("strength ~ 1 + (1 | batch/cask)", &df, true)?;

// Crossed random effects
let fit = lmer("diameter ~ 1 + (1 | plate) + (1 | sample)", &df, true)?;
```

### `lmer_weighted()` for weighted LMMs

```rust
use lme_rs::lmer_weighted;
use ndarray::Array1;

let weights = Array1::from_vec(vec![1.0; df.height()]);
let fit = lmer_weighted("y ~ x + (1 | group)", &df, true, Some(weights))?;
```

### `nlmer()` for nonlinear mixed models

`nlmer` fits Gaussian nonlinear mixed models with a three-part formula (response, nonlinear mean, random part):

```text
circumference ~ SSlogis(age, Asym, xmid, scal) ~ Asym|Tree
circumference ~ SSlogis(age, Asym, xmid, scal) ~ Asym + xmid | Tree
y ~ SSasymp(x, Asym, R0, lrc) ~ Asym|id
y ~ SSfol(x, Asym, R0, lrc) ~ Asym|id
y ~ SSmicmen(x, Vmax, K) ~ Vmax|id
y ~ SSgompertz(x, Asym, b2, b3) ~ Asym|id
```

```rust
use lme_rs::nlmer;
use std::collections::HashMap;

let mut start = HashMap::new();
start.insert("Asym".to_string(), 200.0);
start.insert("xmid".to_string(), 725.0);
start.insert("scal".to_string(), 350.0);

// ML (matches R `nlmer` default); pass `true` for REML profiling
let fit = nlmer(
    "circumference ~ SSlogis(age, Asym, xmid, scal) ~ Asym|Tree",
    &df,
    start,
    false,
)?;
```

For scalar adaptive quadrature on a single random effect (`k = 1`), use [`nlmer_with_options`](src/nlmm/mod.rs) and set [`NlmerOptions::n_agq`](src/nlmm/fit.rs) to a value `≥ 2` (default `1` is Laplace only). Python: `nlmer(..., n_agq=7)`.

Custom mean functions implement [`NlmmMeanEval`](src/nlmm/mean_fn.rs) or wrap a closure with [`CustomNlmmMean`](src/nlmm/mean_fn.rs), then call [`nlmer_with_mean`](src/nlmm/mod.rs). In Python, pass a callable to [`nlmer_with_mean`](python/src/lib.rs) with the `response ~ covariate ~ re | group` formula layout (see [python/PYTHON_GUIDE.md](python/PYTHON_GUIDE.md)).

Current limitations:

- Built-in mean functions: `SSlogis`, `SSasymp`, `SSfol`, `SSmicmen`, `SSgompertz` (not the full R `stats::SS*` catalog). User-defined means via [`nlmer_with_mean`](src/nlmm/mod.rs) / [`CustomNlmmMean`](src/nlmm/mean_fn.rs) in Rust, or `lme_python.nlmer_with_mean` in Python.
- Starting values: pass an empty `NlmmStart` / `start=None` in Python to use R-style `selfStart` heuristics (`stats::getInitial`); the fitter also tries static defaults and keeps the lowest-deviance result.
- Random effects: one grouping factor; multiple parameters before `|` use a multivariate Cholesky covariance (`Asym + xmid | Tree`). θ matches `lme4::getME(., "theta")` (relative Λ; VarCorr SDs are reported through `σ²ΛΛᵀ`). Orange scalar and correlated multi-RE fits, plus `SSasymp` / `SSfol` / `SSmicmen` / `SSgompertz`, are covered by lme4 parity tests.
- `predict()` evaluates the mean at fixed parameters only (`re.form = NA`); `predict_conditional()` adds stored random effects (`re.form = NULL`).
- Scalar AGQ (`n_agq ≥ 2`, `k = 1` RE) is applied in the final profile evaluation at the optimized θ, not inside the θ search (same pattern as `glmer`). Default `n_agq = 1` is Laplace / penalized Gauss–Newton (`nAGQ = 0` style).

### `glmer()` for generalized linear mixed models

```rust
use lme_rs::{family::Family, glmer};

let poisson_fit = glmer(
    "TICKS ~ YEAR + HEIGHT + (1 | BROOD)",
    &df,
    Family::Poisson,
)?;

let binomial_fit = glmer(
    "y ~ x1 + x2 + (1 | group)",
    &df,
    Family::Binomial,
)?;

let gamma_fit = glmer(
    "cost ~ age + (1 | clinic)",
    &df,
    Family::Gamma,
)?;
```

Built-in family support through the public enum:

| Family | Default link | Typical use |
| :----- | :----------- | :---------- |
| `Family::Binomial` | logit | binary outcomes and proportions |
| `Family::Poisson` | log | count data |
| `Family::Gaussian` | identity | continuous Gaussian responses |
| `Family::Gamma` | inverse | positive continuous responses |

`glmer()` uses each family's canonical (default) link. Non-canonical links are available via
[`glmer_with_link`](src/lib.rs) and [`family::Link`](src/family.rs):

| Family | Default link | Also supported |
| :----- | :----------- | :------------- |
| `Family::Binomial` | logit | probit, cloglog |
| `Family::Poisson` | log | identity, sqrt |
| `Family::Gaussian` | identity | log, inverse |
| `Family::Gamma` | inverse | identity, log |

```rust
use lme_rs::{glmer_with_link, family::{Family, Link}};

let probit_fit = glmer_with_link(
    "y ~ period2 + period3 + period4 + (1 | herd)",
    &df,
    Family::Binomial,
    Link::Probit,
    1,
)?;
```

### `glmer_weighted()` for prior observation weights

```rust
use lme_rs::{family::Family, glmer_weighted};
use ndarray::Array1;

let w = Array1::from_vec(vec![1.0; df.height()]);
let fit = glmer_weighted(
    "y ~ period2 + period3 + period4 + (1 | herd)",
    &df,
    Family::Binomial,
    1,
    Some(w),
)?;
```

Weights must be strictly positive and match the number of rows. For `Family::Gaussian`, fitting delegates to `lmer_weighted` with ML.

### Understanding model output

The `Display` implementation intentionally mirrors the shape of R output.

```text
Linear mixed model fit by REML ['lmerMod']
Formula: Reaction ~ Days + (Days | Subject)

     AIC      BIC   logLik deviance
  1755.6   1774.8   -871.8   1743.6
REML criterion at convergence: 1743.6283
...
```

Useful fields on `LmeFit` include:

```rust
let beta = &fit.coefficients;
let sigma2 = fit.sigma2;
let theta = fit.theta.as_ref();
let beta_se = fit.beta_se.as_ref();
let deviance = fit.deviance;
let converged = fit.converged;
let iterations = fit.iterations;
```

## Predictions

### Population-level predictions

`predict()` uses fixed effects only.

```rust
let newdata = DataFrame::new(vec![
    Series::new("Days".into(), &[0.0, 1.0, 5.0, 10.0]).into(),
    Series::new("Subject".into(), &["308", "308", "308", "308"]).into(),
])?;

let preds = fit.predict(&newdata)?;
```

### Conditional predictions

`predict_conditional()` includes stored random effects.

```rust
let preds = fit.predict_conditional(&newdata, false)?;
```

`allow_new_levels` controls how unseen groups are handled:

- `false`: return an error when a new grouping level appears
- `true`: use zero random-effect contribution for unseen groups

### Response-scale GLMM predictions

For GLMMs, `predict()` returns the linear predictor. Use `predict_response()` when you need probabilities, rates, or means on the response scale.

```rust
let eta = poisson_fit.predict(&newdata)?;
let mu = poisson_fit.predict_response(&newdata)?;
let mu_cond = poisson_fit.predict_conditional_response(&newdata, true)?;
```

## Inference and Diagnostics

### Confidence intervals

`confint()` returns Wald intervals for the fixed effects.

```rust
let ci = fit.confint(0.95)?;
println!("{}", ci);
```

### Parametric simulation

`simulate()` draws new response vectors from the fitted model.

```rust
let sims = fit.simulate(100)?;
println!("generated {} simulations", sims.simulations.len());
```

### Satterthwaite degrees of freedom

```rust
let mut fit = lmer("Reaction ~ Days + (Days | Subject)", &df, true)?;
fit.with_satterthwaite(&df)?;
println!("{}", fit);
```

Use this when you want approximate denominator degrees of freedom and p-values for LMM fixed effects.

### Kenward-Roger path

```rust
fit.with_kenward_roger(&df)?;
println!("{}", fit);
```

The Kenward-Roger path produces denominator degrees of freedom that match R's `pbkrtest` on the covered LMM configurations. As with Satterthwaite, results are derived via numerical differentiation of the REML objective.

### Robust standard errors

```rust
fit.with_robust_se(&df, None)?;
fit.with_robust_se(&df, Some("Subject"))?;
```

Use the first form for observation-level HC0 robust errors and the second for clustered robust errors.

### Fixed-effects ANOVA (Type I / II / III)

```rust
use lme_rs::{AnovaType, DdfMethod};

fit.with_satterthwaite(&df)?;
let table = fit.anova(DdfMethod::Satterthwaite)?; // Type III (default)
let table_ii = fit.anova_typed(AnovaType::Type2, DdfMethod::Satterthwaite)?;
let table_i = fit.anova_typed(AnovaType::Type1, DdfMethod::Satterthwaite)?;
let cask_test = fit.linear_hypothesis("cask", DdfMethod::Satterthwaite)?;
println!("{}", table);
```

Current scope:

- Type **I**, **II**, and **III** tables (`anova_typed` / `AnovaType`)
- `linear_hypothesis(term)` for single-term Wald tests (`car::linearHypothesis`-style)
- 1-DoF marginal tests for continuous predictors; joint multi-DoF Wald F-tests for categorical predictors when dummy columns are grouped in the fit
- Multi-DoF Satterthwaite denominator df follows **`lmerTest::contestMD()`** (orthogonal contrast directions + `get_Fstat_ddf()` pooling); call `with_satterthwaite()` before `anova()` so the vcov Jacobian is available
- Kenward–Roger multi-DoF rows use `pbkrtest::KRmodcomp` / `.KR_adjust` via [`kr_modcomp`](src/kr_modcomp.rs) and `vcovAdj16` via [`kr_vcov_adj`](src/kr_vcov_adj.rs); when `vcovAdj` ≈ `vcov`, DenDF matches marginal KR pooling (pastes `cask` reference)
- Golden regression for categorical multi-DoF ANOVA: manifest case `pastes_cask_multi_dof_reml` in [`tests/data/golden_parity_manifest.json`](tests/data/golden_parity_manifest.json) (pastes / `cask`)
- **User-defined contrasts:** any **q × p** matrix `L` via [`LmeFit::test_contrast`](src/contrast.rs) / [`test_contrast_vs`](src/contrast.rs) (`lmerTest::contestMD` / `KRmodcomp`); helpers [`contrast_matrix`](src/contrast.rs) and [`contrast_matrix_from_names`](src/contrast.rs). Python: `fit.test_contrast(l_matrix, ddf_method=...)`.

## Model Comparison

Use `anova(fit_a, fit_b)` for likelihood ratio tests between nested models. Both models should be fit with `reml = false`.

```rust
use lme_rs::{anova, lmer};

let fit0 = lmer("Reaction ~ 1 + (1 | Subject)", &df, false)?;
let fit1 = lmer("Reaction ~ Days + (Days | Subject)", &df, false)?;

let lrt = anova(&fit0, &fit1)?;
println!("{}", lrt);
```

## Limitations and Compatibility Notes

### Numerical comparisons with R

`lme-rs` is designed to track `lme4` behavior closely on the covered workflows. The strongest evidence for parity is in the repository tests and comparison fixtures, not in a blanket claim that every `lme4` feature is already mirrored.

See [comparisons/COMPARISONS.md](comparisons/COMPARISONS.md) for concrete side-by-side output.

### GLMM information criteria

For GLMMs, `lme-rs` computes the optimization target from a Laplace-approximated conditional deviance. R includes additional data-dependent constants in `logLik()` and downstream `AIC()` and `BIC()` reporting. That means absolute information criteria can differ even when the fitted parameters agree.

### ANOVA scope

Fixed-effects ANOVA supports Type **I**, **II**, and **III** (`AnovaType`). Type II uses `lmerTest`-style contrasts (marginal for non-contained terms, Doolittle reordering for contained terms). Continuous terms use 1-DoF tests where applicable; categorical predictors use joint multi-DoF Wald rows. Arbitrary **q × p** contrast matrices are supported via [`test_contrast`](src/contrast.rs); named-term tests via [`linear_hypothesis`](src/anova.rs).

### Kenward-Roger status

`with_kenward_roger()` produces denominator degrees of freedom that match R's `pbkrtest` on the covered LMM configurations. Numerical precision is consistent with the finite-difference Hessian used by the implementation; results have been validated against the `sleepstudy` reference to within 0.01 df. As always, validating against an R reference is sensible for any publication-critical workflow.

### Python bindings

The Python package is intentionally smaller than the Rust crate. If you need the full inference surface, use the Rust API directly.

## Troubleshooting

### Convergence issues

If a mixed model does not converge or reports unstable estimates:

- center or scale continuous predictors
- simplify the random-effects structure
- verify that grouping columns are coded as expected
- look for quasi-complete or complete separation in binomial models
- compare against an `lme4` fit on the same data when debugging a hard case

### Prediction errors

Prediction failures usually come from one of two issues:

- the prediction frame is missing a fixed-effect or grouping column required by the stored formula
- `allow_new_levels` is `false` and the prediction frame contains unseen groups

### Data shape issues

If you see dimension mismatch or underdetermined-system errors, inspect the effective design matrix implied by the formula. These errors often mean that the data does not contain enough information for the requested structure.

## Performance Notes

`lme-rs` uses sparse matrix machinery for grouped random-effects structure, which helps substantially for larger mixed models. The θ optimizer reuses a single [`LmmData`](src/math.rs) (precomputed `Z^T Z`, `Z^T X`, `Z^T y`) across Nelder–Mead evaluations; intercept-only random-effects models use a diagonal-Λ fast path that scales `Z^T Z` directly instead of rebuilding sparse triple products each step.

Even with those optimizations, performance depends heavily on:

- the number of observations
- the number of grouping levels
- whether you fit random slopes as well as intercepts
- how well-scaled the predictors are for optimization

Fair fit-only timings vs MixedModels.jl are documented in [BENCHMARKS.md](BENCHMARKS.md#fair-rust-vs-julia-reference-results) (reference workstation medians as of 2026-07-06).

For concrete parity outputs, use the scripts and datasets in `comparisons/` and `tests/data/`.

## API Surface Summary

### Free functions

| Function | Description |
| :------- | :---------- |
| `lm(y, x)` | fixed-effects-only linear regression |
| `lmer(formula, data, reml)` | linear mixed model |
| `lmer_weighted(formula, data, reml, weights)` | weighted linear mixed model |
| `nlmer(formula, data, start, reml)` | nonlinear mixed model (`SSlogis`, `SSasymp`, `SSfol`, `SSmicmen`, `SSgompertz`; multivariate RE; empty `start` → `selfStart`) |
| `nlmer_with_options(formula, data, opts)` | `nlmer` with [`NlmerOptions`](src/nlmm/fit.rs) (`n_agq`, `max_inner`, …) |
| `nlmer_with_mean(parsed, mean, data, formula_label, opts)` | `nlmer` with a custom [`NlmmMeanEval`](src/nlmm/mean_fn.rs) |
| `glmer(formula, data, family)` | generalized linear mixed model (canonical link) |
| `glmer_with_link(formula, data, family, link)` | GLMM with explicit link |
| `glmer_weighted(formula, data, family, n_agq, weights)` | GLMM with prior observation weights |
| `anova(fit_a, fit_b)` | likelihood ratio test between nested models |

### `LmeFit` methods

| Method | Description |
| :----- | :---------- |
| `predict(newdata)` | fixed-effects-only prediction |
| `predict_conditional(newdata, allow_new_levels)` | prediction with stored random effects |
| `predict_response(newdata)` | GLMM response-scale prediction |
| `predict_conditional_response(newdata, allow_new_levels)` | conditional response-scale GLMM prediction |
| `confint(level)` | Wald confidence intervals |
| `simulate(nsim)` | parametric simulation |
| `with_satterthwaite(data)` | Satterthwaite degrees of freedom and p-values |
| `with_kenward_roger(data)` | Kenward-Roger denominator degrees of freedom and p-values |
| `with_robust_se(data, cluster_col)` | robust or cluster-robust standard errors |
| `anova(ddf_method)` | Type III fixed-effects ANOVA table |

### Selected fields on `LmeFit`

| Field | Meaning |
| :---- | :------ |
| `coefficients` | fixed-effect coefficients |
| `residuals` | model residuals |
| `fitted` | fitted values |
| `sigma2` | residual variance when defined |
| `theta` | relative covariance parameters |
| `b` | estimated random effects |
| `ranef` | random effects as a `DataFrame` |
| `var_corr` | variance-correlation summary as a `DataFrame` |
| `aic`, `bic`, `log_likelihood`, `deviance` | fit statistics |
| `converged`, `iterations` | optimizer diagnostics |
