phit

Benchmarks and verification

Gaussian mixed models on four published datasets

We verify the optimized random-intercept ML implementation on four published datasets, in addition to the synthetic fixtures, closed-form variance test and million-row benchmarks. Every model uses the same explicit fixed columns and observations in R lme4, R nlme, Python statsmodels and PHP. Data provenance, original research references, CSV downloads and checksums are in the data-source record.

These are numerical comparisons of the specified models. They do not establish that a random intercept adequately models each scientific study. In particular, we do not reproduce the random-slope sleepstudy example or a worker-by-machine interaction. All fits are marginal maximum likelihood, not REML.

Execute all four comparisons

from pathlib import Path
import json,sys
import numpy as np
import pandas as pd

from IPython.display import display
ROOT=next(p for p in [Path.cwd(),*Path.cwd().parents] if (p/'Cargo.toml').exists())
import sys
sys.path.insert(0,str(ROOT/'docs/figures'))
from bridge import render_figure
sys.path.insert(0,str(ROOT/'docs/verifications'))
from compare_lmm import public_cases,compare_case
cases=public_cases()
results=[compare_case(case,include_nlme=True) for case in cases]
ORDER=[('R','R: lme4'),('nlme','R: nlme'),('Python','statsmodels'),('php-stats','PHP')]
print('Versions:',results[0]['versions'])
display(pd.DataFrame([dict(dataset=c['name'],observations=len(c['y']),groups=len(set(c['groups'])),
                           fixed_coefficients=len(c['x'][0]),formula=c['source']['formula'],
                           all_comparisons_passed=True) for c in cases]))
Versions: {'R': 'R version 4.6.1 (2026-06-24)', 'lme4': '2.0.6', 'statsmodels': '0.15.0', 'PHP': '8.5.10', 'Python': '3.14.7', 'nlme': '3.1.169'}
dataset observations groups fixed_coefficients formula all_comparisons_passed
0 sleepstudy 180 18 2 Reaction ~ Days + (1 | Subject) True
1 Orthodont 108 27 4 distance ~ age * female + (1 | Subject) True
2 Machines 54 6 3 score ~ machine_B + machine_C + (1 | Worker) True
3 Hsb82 7185 160 4 mAch ~ ses + meanses + catholic + (1 | school) True

The runner checks every row, not a sample: population and conditional fitted values, both residual definitions, all group effects, coefficients, covariance, standard errors, normal Wald tests/intervals, variance components, ICC, likelihood and AIC. Group labels, ordering, retained rows and observation counts are exact. Both R packages supply independent model estimates. statsmodels supplies its own ML estimates; its fixed-effect covariance is recomputed as plug-in GLS to match lme4 and our contract. All Python mixed fits must converge; warnings are retained. Both R references use vcov() and explicit normal Wald calculations to match our declared inference. Default nlme t-tests and profile-likelihood intervals use different conventions.

Estimates by dataset

Reaction ~ Days + (1 | Subject), all 180 observations.

implementation term estimate SE z p lower95 upper95
0 R: lme4 intercept 251.405105 9.506185 26.446477 4.005641e-154 232.773324 270.036886
1 R: lme4 Days 10.467286 0.801735 13.055786 5.889911e-39 8.895913 12.038659
2 R: nlme intercept 251.405105 9.506185 26.446477 4.005631e-154 232.773324 270.036885
3 R: nlme Days 10.467286 0.801735 13.055786 5.889912e-39 8.895913 12.038659
4 statsmodels intercept 251.405105 9.506185 26.446477 4.005628e-154 232.773324 270.036885
5 statsmodels Days 10.467286 0.801735 13.055786 5.889912e-39 8.895913 12.038659
6 PHP intercept 251.405105 9.506185 26.446477 4.005628e-154 232.773324 270.036885
7 PHP Days 10.467286 0.801735 13.055786 5.889912e-39 8.895913 12.038659

distance ~ age * female + (1 | Subject), all 108 observations. The age × female column is constructed before calling the library.

implementation term estimate SE z p lower95 upper95
0 R: lme4 intercept 16.340625 0.963085 16.966962 1.442018e-64 14.453013 18.228237
1 R: lme4 age 0.784375 0.076538 10.248134 1.206497e-24 0.634363 0.934387
2 R: lme4 female 1.032102 1.508864 0.684026 4.939587e-01 -1.925217 3.989422
3 R: lme4 age × female -0.304830 0.119913 -2.542100 1.101887e-02 -0.539854 -0.069805
4 R: nlme intercept 16.340625 0.963085 16.966962 1.442018e-64 14.453013 18.228237
5 R: nlme age 0.784375 0.076538 10.248135 1.206495e-24 0.634363 0.934387
6 R: nlme female 1.032102 1.508864 0.684026 4.939587e-01 -1.925217 3.989422
7 R: nlme age × female -0.304830 0.119913 -2.542100 1.101887e-02 -0.539854 -0.069805
8 statsmodels intercept 16.340625 0.963085 16.966962 1.442018e-64 14.453013 18.228237
9 statsmodels age 0.784375 0.076538 10.248134 1.206497e-24 0.634363 0.934387
10 statsmodels female 1.032102 1.508864 0.684026 4.939587e-01 -1.925217 3.989422
11 statsmodels age × female -0.304830 0.119913 -2.542100 1.101887e-02 -0.539854 -0.069805
12 PHP intercept 16.340625 0.963085 16.966962 1.442018e-64 14.453013 18.228237
13 PHP age 0.784375 0.076538 10.248134 1.206497e-24 0.634363 0.934387
14 PHP female 1.032102 1.508864 0.684026 4.939587e-01 -1.925217 3.989422
15 PHP age × female -0.304830 0.119913 -2.542100 1.101887e-02 -0.539854 -0.069805

score ~ machine_B + machine_C + (1 | Worker), all 54 observations. Machine A is the reference brand.

implementation term estimate SE z p lower95 upper95
0 R: lme4 intercept 52.355556 2.046413 25.584062 2.295299e-144 48.344660 56.366451
1 R: lme4 machine B 7.966667 1.031693 7.721935 1.145765e-14 5.944586 9.988748
2 R: lme4 machine C 13.916667 1.031693 13.489155 1.811651e-41 11.894586 15.938748
3 R: nlme intercept 52.355556 2.046413 25.584061 2.295327e-144 48.344660 56.366451
4 R: nlme machine B 7.966667 1.031693 7.721935 1.145764e-14 5.944586 9.988748
5 R: nlme machine C 13.916667 1.031693 13.489155 1.811650e-41 11.894586 15.938748
6 statsmodels intercept 52.355556 2.046413 25.584062 2.295309e-144 48.344660 56.366451
7 statsmodels machine B 7.966667 1.031693 7.721935 1.145764e-14 5.944586 9.988748
8 statsmodels machine C 13.916667 1.031693 13.489155 1.811650e-41 11.894586 15.938748
9 PHP intercept 52.355556 2.046413 25.584062 2.295309e-144 48.344660 56.366451
10 PHP machine B 7.966667 1.031693 7.721935 1.145764e-14 5.944586 9.988748
11 PHP machine C 13.916667 1.031693 13.489155 1.811650e-41 11.894586 15.938748

mAch ~ ses + meanses + catholic + (1 | school), all 7,185 observations. Public schools are the reference sector.

implementation term estimate SE z p lower95 upper95
0 R: lme4 intercept 12.128223 0.197301 61.470575 0.000000e+00 11.741520 12.514927
1 R: lme4 SES 2.191172 0.108666 20.164274 2.016856e-90 1.978190 2.404154
2 R: lme4 school mean SES 3.143956 0.381147 8.248675 1.601605e-16 2.396922 3.890990
3 R: lme4 Catholic school 1.224874 0.303067 4.041598 5.308810e-05 0.630874 1.818874
4 R: nlme intercept 12.128223 0.197301 61.470580 0.000000e+00 11.741520 12.514927
5 R: nlme SES 2.191172 0.108666 20.164274 2.016857e-90 1.978190 2.404154
6 R: nlme school mean SES 3.143956 0.381147 8.248675 1.601597e-16 2.396922 3.890990
7 R: nlme Catholic school 1.224874 0.303067 4.041599 5.308802e-05 0.630874 1.818874
8 statsmodels intercept 12.128223 0.197301 61.470578 0.000000e+00 11.741520 12.514927
9 statsmodels SES 2.191172 0.108666 20.164274 2.016857e-90 1.978190 2.404154
10 statsmodels school mean SES 3.143956 0.381147 8.248675 1.601601e-16 2.396922 3.890990
11 statsmodels Catholic school 1.224874 0.303067 4.041599 5.308806e-05 0.630874 1.818874
12 PHP intercept 12.128223 0.197301 61.470578 0.000000e+00 11.741520 12.514927
13 PHP SES 2.191172 0.108666 20.164274 2.016857e-90 1.978190 2.404154
14 PHP school mean SES 3.143956 0.381147 8.248675 1.601601e-16 2.396922 3.890990
15 PHP Catholic school 1.224874 0.303067 4.041599 5.308806e-05 0.630874 1.818874

Variances and likelihood

rows=[]
for result in results:
    for key,label in ORDER:
        fit=result[key]
        rows.append(dict(dataset=result['name'],implementation=label,
                         residual_variance=fit['residual_variance'],group_variance=fit['random_intercept_variance'],
                         ICC=fit['icc'],log_likelihood=fit['log_likelihood'],AIC=fit['aic']))
display(pd.DataFrame(rows))
dataset implementation residual_variance group_variance ICC log_likelihood AIC
0 sleepstudy R: lme4 954.527833 1296.870056 0.576029 -897.039322 1802.078643
1 sleepstudy R: nlme 954.527834 1296.870044 0.576029 -897.039322 1802.078643
2 sleepstudy statsmodels 954.527835 1296.870040 0.576029 -897.039322 1802.078643
3 sleepstudy PHP 954.527835 1296.870040 0.576029 -897.039322 1802.078643
4 Orthodont R: lme4 1.874597 3.030562 0.617832 -214.319529 440.639058
5 Orthodont R: nlme 1.874597 3.030562 0.617832 -214.319529 440.639058
6 Orthodont statsmodels 1.874597 3.030562 0.617832 -214.319529 440.639058
7 Orthodont PHP 1.874597 3.030562 0.617832 -214.319529 440.639058
8 Machines R: lme4 9.579514 21.933665 0.696016 -146.851628 303.703256
9 Machines R: nlme 9.579514 21.933666 0.696016 -146.851628 303.703256
10 Machines statsmodels 9.579514 21.933665 0.696016 -146.851628 303.703256
11 Machines PHP 9.579514 21.933665 0.696016 -146.851628 303.703256
12 Hsb82 R: lme4 37.018217 2.306017 0.058641 -23274.159698 46560.319397
13 Hsb82 R: nlme 37.018217 2.306017 0.058641 -23274.159698 46560.319397
14 Hsb82 statsmodels 37.018217 2.306017 0.058641 -23274.159698 46560.319397
15 Hsb82 PHP 37.018217 2.306017 0.058641 -23274.159698 46560.319397

Look closely at numerical differences

The graph shows the difference between native and lme4 conditional fitted values, scaled by a million to make rounding differences visible. Zero means exact agreement. Every point is a retained observation; panel units differ by dataset. It is a numerical agreement plot, not a model residual diagnostic.

figures/lmm-public-agreement.svg 

dataset largest_native_residual_difference_vs_lme4 largest_native_log_likelihood_difference_vs_lme4 largest_native_residual_difference_vs_statsmodels
0 sleepstudy 6.950341e-08 1.136868e-13 3.489049e-10
1 Orthodont 1.275022e-08 8.526513e-14 3.197442e-14
2 Machines 7.426245e-09 2.842171e-14 3.324629e-11
3 Hsb82 2.423659e-07 7.275958e-12 4.600764e-13

Default public-data tolerances are 1e-7 absolute plus 1e-6 relative. The four observation-length output vectors use 1e-6 absolute in their outcome’s units, plus 1e-6 relative, so residuals close to zero are not tested by relative error alone. Likelihood and AIC use 1e-7 absolute plus 1e-10 relative. Original synthetic fixtures retain their uniform 1e-7 + 1e-6 × abs(reference) tolerance. These policies are recorded with each source fixture and live report. An additional native-versus-statsmodels check keeps the stricter original uniform tolerance for all quantities; reference-optimizer rounding allowances cannot hide divergence between those two estimates.

The High School and Beyond sample illustrates an optimizer precision limit: lme4 and nlme have effectively identical likelihoods but slightly different variance estimates. Conditional residual differences can reach a few 1e-7 when the residual itself is nearly zero. The profile scores below independently check stationarity at each estimate; they are derivatives of negative profile log-likelihood with respect to log(1 + tau²/sigma²). Zero is the interior optimum condition. Raw scores depend on dataset size and curvature and should not be compared across different studies as a universal error metric.

display(pd.DataFrame([dict(dataset=r['name'],implementation=label,
                           profile_score=r['profile_scores'][key],
                           log_likelihood_difference_vs_lme4=r[key]['log_likelihood']-r['R']['log_likelihood'])
                      for r in results for key,label in ORDER]))
for result in results:
    print(result['name'],'Python warnings:',result['python_diagnostics']['warnings'])
dataset implementation profile_score log_likelihood_difference_vs_lme4
0 sleepstudy R: lme4 1.594587e-07 0.000000e+00
1 sleepstudy R: nlme 3.946868e-08 -2.273737e-13
2 sleepstudy statsmodels -7.970267e-10 -1.136868e-13
3 sleepstudy PHP 3.542481e-12 -1.136868e-13
4 Orthodont R: lme4 -2.981962e-07 0.000000e+00
5 Orthodont R: nlme 1.549555e-06 -1.705303e-13
6 Orthodont statsmodels -1.942906e-12 -1.136868e-13
7 Orthodont PHP -1.457179e-12 -8.526513e-14
8 Machines R: lme4 -6.462817e-08 0.000000e+00
9 Machines R: nlme 1.087264e-07 0.000000e+00
10 Machines statsmodels -2.902163e-10 -2.842171e-14
11 Machines PHP -9.890258e-13 -2.842171e-14
12 Hsb82 R: lme4 8.646686e-05 0.000000e+00
13 Hsb82 R: nlme -7.109857e-05 -3.637979e-12
14 Hsb82 statsmodels -2.725753e-10 -7.275958e-12
15 Hsb82 PHP -4.321110e-10 -7.275958e-12
sleepstudy Python warnings: []
Orthodont Python warnings: []
Machines Python warnings: []
Hsb82 Python warnings: []

Reproduce without fetching data

The CSVs and lme4/nlme expected outputs are checked into the repository. cargo test --workspace and tests/lmm.php check the published fixtures without R, Python or network access. The live comparison requires lme4, nlme and the pinned Python environment, but not mlmRev: it reads the saved Hsb82 CSV.

cargo test --workspace --locked
PHP=/opt/homebrew/bin/php STATS_EXTENSION="$PWD/target/release/libphp_stats.dylib" \
  .context/notebooks-venv/bin/python docs/verifications/compare_lmm.py --public
QUARTO_PYTHON="$PWD/.context/notebooks-venv/bin/python" PHP=/opt/homebrew/bin/php \
  quarto render docs/notebooks/lmm-public-verification.qmd

Use matching PHP headers to build the extension first; use .so on Linux. The saved live report records full outputs, source checksums, runtime versions, the extension hash, warnings, tolerances and score/error checks. Re-execute this page after statistical-code or source-data changes; merely rendering saved outputs is not new verification.

This strengthens evidence for the current Gaussian random-intercept ML model. It does not establish correctness for random slopes, REML, other response families, assumption checks, or finite-sample group-level inference.

From phpstats/phit@76b22f0.