Benchmarks and verification
Expanded R compatibility and all four lme4 optimizers
This notebook executes the installed PHP extension on saved R reference inputs. R generated the checked-in expected values using lm, glm, and lmer with each of its four optimizer wrappers. The two native algorithms are BOBYQA and Nelder–Mead. See the compatibility ledger for supported features and the work still needed for full parity.
Run the audit
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_r_compatibility import run
report=run()
print(report['versions'])Passed 12 fixed-model cases and 15 mixed-model cases × 2 native × 4 lme4 optimizers; sleepstudy ML/REML also passed statsmodels.
{'R': 'R version 4.6.1 (2026-06-24)', 'lme4': '2.0.6', 'PHP': '8.5.10', 'python': '3.14.7', 'numpy': '2.5.4', 'scipy': '1.18.1', 'statsmodels': '0.15.0'}
The audit checks every coefficient, inference quantity, fitted value and residual; for mixed models it also checks every random effect and covariance entry, variance scale, likelihood, AIC/BIC and singular flag. It refuses nonzero R optimizer codes or R warnings in the saved reference runs. Reference controls and checksums are recorded alongside the actual PHP outputs in the downloadable audit.
Fixed estimators: weights, offsets and binomial trials
display(pd.DataFrame([dict(case=r['name'],estimator=r['model'],
largest_absolute_error=r['max_absolute_error'],passed=True)
for r in report['fixed']]))| case | estimator | largest_absolute_error | passed | |
|---|---|---|---|---|
| 0 | ols_intercept_individual | ols | 4.547474e-13 | True |
| 1 | ols_no_intercept_individual | ols | 1.091394e-11 | True |
| 2 | logistic_intercept_individual | logistic | 1.375864e-06 | True |
| 3 | logistic_intercept_grouped | logistic | 2.086686e-11 | True |
| 4 | logistic_no_intercept_individual | logistic | 8.937718e-07 | True |
| 5 | logistic_no_intercept_grouped | logistic | 1.047970e-06 | True |
| 6 | probit_intercept_individual | probit | 1.638299e-06 | True |
| 7 | probit_intercept_grouped | probit | 1.123628e-07 | True |
| 8 | probit_no_intercept_individual | probit | 5.972970e-07 | True |
| 9 | probit_no_intercept_grouped | probit | 6.556223e-07 | True |
| 10 | poisson_intercept_individual | poisson | 3.864403e-06 | True |
| 11 | poisson_no_intercept_individual | poisson | 1.888160e-08 | True |
Each estimator is fitted with an intercept and without one. Binary estimators include both individual binary responses and grouped success proportions. Weights and offsets affect the fit, covariance, null model and likelihood. We check those quantities rather than testing only unweighted coefficients.
For grouped binomial rows with s successes out of m trials, supply y=s/m and weight=m. R’s equivalent calls are:
#| eval: false
fit <- glm(proportion ~ study + practice,
family=binomial("logit"), weights=trials, offset=known_offset,
data=observations, control=glm.control(epsilon=1e-12, maxit=200))
# binomial("probit") selects the normal-CDF link instead.
coef(fit)
vcov(fit)
logLik(fit)
fit$deviance
fit$null.deviance
A Poisson exposure e contributes log(e) to the linear predictor; a prior weight instead multiplies that observation’s likelihood. Those are different operations. For weighted OLS, residual variance is sigma²/w_i and likelihood includes the weight determinant. Full generator code is in tests/generate-r-compatibility-fixtures.R.
Mixed models and optimizer agreement
R_ORDER=['bobyqa','Nelder_Mead','nlminbwrap','nloptwrap']
rows=[]
for case in report['mixed']:
for optimizer in R_ORDER:
ref=case['R'][optimizer]
for comparison in case['comparisons']:
if comparison['R_optimizer']==optimizer:
rows.append(dict(model=case['name'],method=case['method'],
R_optimizer=optimizer,native_optimizer=comparison['native_optimizer'],
log_likelihood_difference=comparison['log_likelihood_difference'],
max_fitted_difference=comparison['max_fitted_difference'],
optimizer_code=ref['optimizer_code'][0]))
display(pd.DataFrame(rows))| model | method | R_optimizer | native_optimizer | log_likelihood_difference | max_fitted_difference | optimizer_code | |
|---|---|---|---|---|---|---|---|
| 0 | random_slopes_ml | ML | bobyqa | bobyqa | -5.684342e-14 | 3.769021e-08 | 0 |
| 1 | random_slopes_ml | ML | bobyqa | nelder_mead | -1.278977e-12 | 3.057257e-07 | 0 |
| 2 | random_slopes_ml | ML | Nelder_Mead | bobyqa | 1.550973e-10 | 5.379521e-06 | 0 |
| 3 | random_slopes_ml | ML | Nelder_Mead | nelder_mead | 1.538751e-10 | 5.036105e-06 | 0 |
| 4 | random_slopes_ml | ML | nlminbwrap | bobyqa | 3.979039e-12 | 5.997995e-07 | 0 |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 115 | nested_4_grouping_levels_reml | REML | Nelder_Mead | nelder_mead | 8.526513e-12 | 3.506349e-08 | 0 |
| 116 | nested_4_grouping_levels_reml | REML | nlminbwrap | bobyqa | 1.631975e-10 | 4.570311e-07 | 0 |
| 117 | nested_4_grouping_levels_reml | REML | nlminbwrap | nelder_mead | 1.623448e-10 | 4.190508e-07 | 0 |
| 118 | nested_4_grouping_levels_reml | REML | nloptwrap | bobyqa | 3.802256e-10 | 8.428250e-07 | 0 |
| 119 | nested_4_grouping_levels_reml | REML | nloptwrap | nelder_mead | 3.793730e-10 | 8.844325e-07 | 0 |
120 rows × 7 columns
The model suite contains correlated/independent random slopes, crossed and nested terms, a random slope without a random intercept, no fixed intercept, weights and offsets, and an exact zero-variance boundary. The published sleepstudy model is Reaction ~ Days + (Days | Subject), under both ML and REML.
The R generator explicitly selects all four wrappers, tightened stopping controls and boundary.tol=1e-3. The exact per-wrapper settings are recorded in each fixture. Multi-level BOBYQA references also restart from their first R fit with rhobeg=0.001 and rhoend=1e-9: this improved random-effect agreement on a flat likelihood while keeping the comparison tolerances unchanged. Native estimates never seed the R references. At a smaller default boundary tolerance, one wrapper stopped just above a singular boundary; overly strict nlminb settings also produced convergence warnings. The saved audit does not claim identical behavior under every package default.
Likelihoods use 5e-6 + 1e-9 * abs(reference) tolerance. Native/R BOBYQA estimates use 1e-5 + 1e-5 * abs(reference); other optimizer comparisons use 1e-3 + 3e-4 * abs(reference). These admit the observed spread in parameters on a flat likelihood. Shapes, grouping/sample metadata and parameter counts are exact. All rows and raw outputs remain available for inspection.
More than two grouping levels
The fitter accepts a list of random terms; there is no two-level cap. The new synthetic cases use 256 repeated assessments, with 8 districts, 16 schools, 32 classes and 64 students. They fit three grouping levels (district/school/class) and four grouping levels (district/school/class/student), each under ML and REML. The four-level simulation includes independent Gaussian intercepts at all four grouping levels; the three-level simulation omits the student effect. Study hours and practice scores are correlated observed predictors, not initial parameter guesses.
fixture=json.loads((ROOT/'tests/fixtures/lmm-expanded-r.json').read_text())
nested_inputs={c['name']:c for c in fixture['cases'] if '_grouping_levels_' in c['name']}
summary=[]
for c in report['mixed']:
if c['name'] not in nested_inputs:continue
input_case=nested_inputs[c['name']]
for optimizer in ['bobyqa','nelder_mead']:
fit=c['PHP'][optimizer]
summary.append(dict(model=c['name'],optimizer=optimizer,
groups_per_term=[len(t['group_labels']) for t in fit['random']],
estimated_variances=[t['covariance'][0][0] for t in fit['random']],
residual_variance=fit['residual_variance'],log_likelihood=fit['log_likelihood'],
all_four_R_comparisons_passed=True))
display(pd.DataFrame(summary))| model | optimizer | groups_per_term | estimated_variances | residual_variance | log_likelihood | all_four_R_comparisons_passed | |
|---|---|---|---|---|---|---|---|
| 0 | nested_3_grouping_levels_ml | bobyqa | [8, 16, 32] | [16.406634248298126, 16.180811188648576, 3.232... | 0.668762 | -393.820120 | True |
| 1 | nested_3_grouping_levels_ml | nelder_mead | [8, 16, 32] | [16.406643287549933, 16.180817986835635, 3.232... | 0.668762 | -393.820120 | True |
| 2 | nested_4_grouping_levels_ml | bobyqa | [8, 16, 32, 64] | [14.645325034032776, 20.01780199062988, 2.9880... | 0.586857 | -428.742955 | True |
| 3 | nested_4_grouping_levels_ml | nelder_mead | [8, 16, 32, 64] | [14.645311637577024, 20.01781266197396, 2.9880... | 0.586857 | -428.742955 | True |
| 4 | nested_3_grouping_levels_reml | bobyqa | [8, 16, 32] | [20.02458308947715, 16.18081330026649, 3.23263... | 0.674776 | -398.151464 | True |
| 5 | nested_3_grouping_levels_reml | nelder_mead | [8, 16, 32] | [20.024612174592686, 16.18080276961506, 3.2326... | 0.674776 | -398.151464 | True |
| 6 | nested_4_grouping_levels_reml | bobyqa | [8, 16, 32, 64] | [18.321805711935404, 20.017853427099, 2.988051... | 0.592969 | -433.049254 | True |
| 7 | nested_4_grouping_levels_reml | nelder_mead | [8, 16, 32, 64] | [18.321807255448686, 20.017860206184494, 2.988... | 0.592969 | -433.049254 | True |
Each random term is independent of the other terms. Dependence between observations arises because they share some group effects. For two observations in the same class but different students, the district, school and class effects are shared; the student effects and observation noise are not.
Provide globally unique nested IDs: the native interface does not infer that a class label belongs to a school. Reusing local class number 1 across schools without a parent-aware key instead specifies a crossed/shared group effect. Three grouping levels plus the observation level are often called a four-level model; we count grouping terms explicitly here to avoid that ambiguity.
$result = stats_lmm_fit(
x: $fixedDesign,
y: $scores,
random: [
['z' => $ones, 'groups' => $districtIds],
['z' => $ones, 'groups' => $districtSchoolKeys],
['z' => $ones, 'groups' => $districtSchoolClassKeys],
['z' => $ones, 'groups' => $districtSchoolClassStudentKeys],
],
reml: true,
);The equivalent R random-intercept specification is (1 | district/school/class/student). Nesting does not introduce a correlation parameter between the levels; each term has its own variance. Random slopes can also be supplied through each term’s z columns, subject to identifiability and size limits. The current dense implementation supports at most 512 total random coefficients and 50,000,000 initial augmented matrix entries.
Published sleepstudy: R, statsmodels and PHP
for case in [r for r in report['mixed'] if r['name'].startswith('sleepstudy_')]:
fits=[('R: '+o,case['R'][o]['result']) for o in R_ORDER]
fits+=[('statsmodels',case['statsmodels'])]
fits+=[('PHP: '+o,case['PHP'][o]) for o in ['bobyqa','nelder_mead']]
print(case['name'])
display(pd.DataFrame([dict(implementation=label,intercept=fit['coefficients'][0],
days=fit['coefficients'][1],residual_variance=fit['residual_variance'],
intercept_variance=fit['terms'][0]['covariance'][0][0],
slope_variance=fit['terms'][0]['covariance'][1][1],
intercept_slope_covariance=fit['terms'][0]['covariance'][0][1],
log_likelihood=fit['log_likelihood']) for label,fit in fits]))
print('statsmodels warnings:',case['statsmodels_warnings'])sleepstudy_ml
| implementation | intercept | days | residual_variance | intercept_variance | slope_variance | intercept_slope_covariance | log_likelihood | |
|---|---|---|---|---|---|---|---|---|
| 0 | R: bobyqa | 251.405105 | 10.467286 | 654.941024 | 565.515383 | 32.682196 | 11.055452 | -875.969672 |
| 1 | R: Nelder_Mead | 251.405105 | 10.467286 | 654.941484 | 565.512012 | 32.682162 | 11.054994 | -875.969672 |
| 2 | R: nlminbwrap | 251.405105 | 10.467286 | 654.940760 | 565.517147 | 32.682237 | 11.055520 | -875.969672 |
| 3 | R: nloptwrap | 251.405105 | 10.467286 | 654.945706 | 565.476967 | 32.681785 | 11.055122 | -875.969672 |
| 4 | statsmodels | 251.405105 | 10.467286 | 654.941027 | 565.515367 | 32.682197 | 11.055429 | -875.969672 |
| 5 | PHP: bobyqa | 251.405105 | 10.467286 | 654.941027 | 565.515356 | 32.682198 | 11.055429 | -875.969672 |
| 6 | PHP: nelder_mead | 251.405105 | 10.467286 | 654.940988 | 565.515337 | 32.682219 | 11.055424 | -875.969672 |
statsmodels warnings: []
sleepstudy_reml
| implementation | intercept | days | residual_variance | intercept_variance | slope_variance | intercept_slope_covariance | log_likelihood | |
|---|---|---|---|---|---|---|---|---|
| 0 | R: bobyqa | 251.405105 | 10.467286 | 654.941043 | 612.089783 | 35.071660 | 9.604324 | -871.814136 |
| 1 | R: Nelder_Mead | 251.405105 | 10.467286 | 654.940137 | 612.097552 | 35.071770 | 9.604487 | -871.814136 |
| 2 | R: nlminbwrap | 251.405105 | 10.467286 | 654.941233 | 612.089991 | 35.071437 | 9.605977 | -871.814136 |
| 3 | R: nloptwrap | 251.405105 | 10.467286 | 654.940008 | 612.100158 | 35.071714 | 9.604409 | -871.814136 |
| 4 | statsmodels | 251.405105 | 10.467286 | 654.941027 | 612.089937 | 35.071661 | 9.604334 | -871.814136 |
| 5 | PHP: bobyqa | 251.405105 | 10.467286 | 654.941016 | 612.090017 | 35.071662 | 9.604341 | -871.814136 |
| 6 | PHP: nelder_mead | 251.405105 | 10.467286 | 654.941036 | 612.090075 | 35.071645 | 9.604397 | -871.814136 |
statsmodels warnings: []
statsmodels independently estimates ML/REML fixed effects and variance components. For fixed-effect inference we calculate plug-in GLS covariance from its estimates, matching lme4 and our contract; its default joint-Hessian standard errors differ. We require convergence and retain Python warnings. BFGS uses gtol=1e-8; at 1e-10 the REML fit reached the same likelihood but failed its convergence flag, so that failed run was not accepted. Its public MixedLM interface does not provide prior weights, so this Python comparison covers sleepstudy, not the weighted case or every general grouping specification.
What the random slopes mean
data=pd.read_csv(ROOT/'docs/data/lmm/sleepstudy.csv')
case=next(r for r in report['mixed'] if r['name']=='sleepstudy_reml')
fit=case['PHP']['bobyqa'];labels=fit['random'][0]['group_labels']
fixture=next(c for c in json.loads((ROOT/'tests/fixtures/lmm-expanded-r.json').read_text())['cases'] if c['name']=='sleepstudy_reml')
subject_group=dict(zip(data.Subject,fixture['terms'][0]['groups']))
# Fixture IDs are factor-coded by the R generator; original subject ordering is
# retained in the CSV. Map the groups explicitly rather than guessing labels.
subjects=sorted(data.Subject.unique())
render_figure('sleepstudy')figures/sleepstudy-slopes.svg
The observed predictor is days of sleep deprivation; it is not a trial coefficient. Each person’s intercept shifts the line, and their random slope changes its rate of increase. The population line averages over the zero-mean random effects. This model captures individual differences; numerical agreement alone does not establish that the Gaussian assumptions explain the study.
Reproduce or refresh
# Normal audit: stored R values, fresh PHP and statsmodels fits.
PHP=/opt/homebrew/bin/php .context/notebooks-venv/bin/python docs/verifications/compare_r_compatibility.py
# Explicitly refresh reference fits using installed R/lme4.
PHP=/opt/homebrew/bin/php .context/notebooks-venv/bin/python docs/verifications/compare_r_compatibility.py --refresh
QUARTO_PYTHON="$PWD/.context/notebooks-venv/bin/python" quarto render docs/notebooks/r-compatibility.qmdFrom phpstats/phit@76b22f0.