Benchmarks and verification
Binomial mixed-model verification and approximation accuracy
We compare the native binomial random-intercept fitter with R lme4, and independently evaluate its likelihood with SciPy. The model uses marginal maximum likelihood, a logit link and a Gaussian random intercept. Inputs remain plain design/outcome/group arrays. See the math walkthrough and contract.
Execute the 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_logistic_mixed import run
report=run()
print(report['versions'])Passed 28 R/PHP comparisons, independent likelihood/integration checks, and statsmodels zero-variance reduction.
Independent cbpp marginal ML fit: {'dataset': 'cbpp', 'coefficients': [-1.399230360516453, -0.9914039350112828, -1.1278198196624616, -1.579470750553407], 'random_intercept_sd': 0.6475184370023838, 'log_likelihood': -91.98336903731132, 'converged': True, 'evaluations': 120, 'message': 'CONVERGENCE: RELATIVE REDUCTION OF F <= FACTR*EPSMCH', 'method': 'SciPy L-BFGS-B + QUADPACK integration', 'start': 'statsmodels binomial GLM, SD=1'}
{'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'}
Seven input datasets are fitted with 1, 9, 25 and 61 nodes: 28 comparisons. They cover binary outcomes, grouped counts with offsets, no fixed intercept, small/unbalanced groups with rare events, large group variation, a zero random variance boundary, and the published cbpp model. R references use BOBYQA with rhoend=1e-9 and tolPwrss=1e-12. Codes must be zero and saved warnings empty. Rust/PHP tests compare every coefficient, covariance/inference value, observation prediction/residual, conditional group mode, scale, normalized likelihood and AIC/BIC.
The independent Python evaluator uses Brent root finding for conditional modes, NumPy Hermite nodes for the same approximation, and QUADPACK adaptive integration for a separate numerical accuracy check. Its matched-approximation likelihood must agree with native output within 2e-8 absolute. QUADPACK error estimates are recorded; they are numerical estimates, not mathematical error guarantees.
Published cbpp: matching estimation methods
rows=[]
for r in report['results']:
if r['dataset']!='cbpp':continue
for implementation,fit in [('R: lme4',r['R']),('PHP',r['PHP'])]:
rows.append(dict(nodes=r['quadrature_points'],implementation=implementation,
intercept=fit['coefficients'][0],group_SD=fit['random_intercept_sd'],
log_likelihood=fit['log_likelihood'],AIC=fit['aic']))
display(pd.DataFrame(rows))
print('Independent marginal ML fit using SciPy optimization and QUADPACK:',report['exact_fit'])| nodes | implementation | intercept | group_SD | log_likelihood | AIC | |
|---|---|---|---|---|---|---|
| 0 | 1 | R: lme4 | -1.398532 | 0.642261 | -92.026282 | 194.052564 |
| 1 | 1 | PHP | -1.398532 | 0.642261 | -92.026282 | 194.052564 |
| 2 | 9 | R: lme4 | -1.399230 | 0.647518 | -91.983372 | 193.966744 |
| 3 | 9 | PHP | -1.399230 | 0.647518 | -91.983372 | 193.966744 |
| 4 | 25 | R: lme4 | -1.399230 | 0.647518 | -91.983369 | 193.966738 |
| 5 | 25 | PHP | -1.399230 | 0.647518 | -91.983369 | 193.966738 |
| 6 | 61 | R: lme4 | -1.399230 | 0.647518 | -91.983369 | 193.966738 |
| 7 | 61 | PHP | -1.399230 | 0.647518 | -91.983369 | 193.966738 |
Independent marginal ML fit using SciPy optimization and QUADPACK: {'dataset': 'cbpp', 'coefficients': [-1.399230360516453, -0.9914039350112828, -1.1278198196624616, -1.579470750553407], 'random_intercept_sd': 0.6475184370023838, 'log_likelihood': -91.98336903731132, 'converged': True, 'evaluations': 120, 'message': 'CONVERGENCE: RELATIVE REDUCTION OF F <= FACTR*EPSMCH', 'method': 'SciPy L-BFGS-B + QUADPACK integration', 'start': 'statsmodels binomial GLM, SD=1'}
The independent SciPy optimizer starts from a statsmodels binomial GLM and SD=1, not from native mixed estimates. It optimizes the numerically integrated cbpp likelihood and checks its estimates against high-node native output.
statsmodels BinomialBayesMixedGLM uses Bayesian posterior estimation with priors on fixed effects and variance parameters. That is a different objective, so we do not claim equivalent mixed ML estimates from it. statsmodels GLM verifies the exact zero-random-variance reduction instead. The full mixed-model Python reference here is explicitly SciPy.
Likelihood normalization matters
display(pd.DataFrame([dict(nodes=r['quadrature_points'],
R_reported_log_likelihood=r['R_reference']['reported_log_likelihood'],
R_additive_correction=r['R_reference']['log_likelihood_correction'],
R_normalized_log_likelihood=r['R']['log_likelihood'],
PHP_normalized_log_likelihood=r['PHP']['log_likelihood'])
for r in report['results'] if r['dataset']=='cbpp']))| nodes | R_reported_log_likelihood | R_additive_correction | R_normalized_log_likelihood | PHP_normalized_log_likelihood | |
|---|---|---|---|---|---|
| 0 | 1 | -92.026282 | 0.000000 | -92.026282 | -92.026282 |
| 1 | 9 | -50.005018 | -41.978354 | -91.983372 | -91.983372 |
| 2 | 25 | -50.005015 | -41.978354 | -91.983369 | -91.983369 |
| 3 | 61 | -50.005015 | -41.978354 | -91.983369 | -91.983369 |
For grouped-binomial responses, lme4 nAGQ>1 computes its reported criterion using conditional deviance relative to the saturated model. Its additive constant differs from nAGQ=1. We retain the raw R value and restore the saturated log likelihood, sum(dbinom(successes, trials, successes/trials, log=TRUE)), before comparing normalized likelihoods and AIC/BIC. For binary observations this constant is zero. This convention is disclosed in the glmer documentation.
The native likelihood always includes binomial coefficients and Gaussian normalization. Changing node counts does not change its additive convention.
Approximation error, separately from implementation agreement
accuracy=pd.DataFrame([dict(dataset=r['dataset'],nodes=r['quadrature_points'],
approximation_error=r['approximation_log_likelihood_error'],
group_SD=r['PHP']['random_intercept_sd'],
max_coefficient_difference_vs_R=r['max_coefficient_difference'])
for r in report['results']])
display(accuracy)
render_figure('approximation')| dataset | nodes | approximation_error | group_SD | max_coefficient_difference_vs_R | |
|---|---|---|---|---|---|
| 0 | binary | 1 | -9.166328e-02 | 1.024953 | 3.149782e-07 |
| 1 | binary | 9 | -1.964821e-06 | 1.036172 | 8.375431e-08 |
| 2 | binary | 25 | -7.105427e-14 | 1.036174 | 1.261185e-07 |
| 3 | binary | 61 | 0.000000e+00 | 1.036174 | 4.001991e-08 |
| 4 | no_intercept | 1 | -1.884312e-01 | 1.822757 | 2.977063e-08 |
| 5 | no_intercept | 9 | -9.394904e-05 | 1.843266 | 7.561876e-09 |
| 6 | no_intercept | 25 | -1.719187e-09 | 1.843343 | 2.678783e-08 |
| 7 | no_intercept | 61 | 0.000000e+00 | 1.843343 | 2.235931e-08 |
| 8 | grouped_offset | 1 | -2.160278e-02 | 1.428117 | 1.769598e-06 |
| 9 | grouped_offset | 9 | -5.012076e-06 | 1.428642 | 1.350418e-06 |
| 10 | grouped_offset | 25 | -1.972467e-11 | 1.428643 | 7.443581e-07 |
| 11 | grouped_offset | 61 | 5.684342e-14 | 1.428643 | 8.173894e-08 |
| 12 | rare_events | 1 | 2.214320e+00 | 7.298560 | 1.152076e-06 |
| 13 | rare_events | 9 | 4.719648e-02 | 3.499107 | 4.137237e-08 |
| 14 | rare_events | 25 | 2.923269e-04 | 3.356057 | 1.316197e-07 |
| 15 | rare_events | 61 | 1.345489e-07 | 3.356287 | 2.813783e-08 |
| 16 | large_group_variation | 1 | -2.962600e-01 | 1.771035 | 1.523664e-06 |
| 17 | large_group_variation | 9 | 3.002809e-05 | 1.830570 | 5.192793e-08 |
| 18 | large_group_variation | 25 | 6.515599e-09 | 1.830524 | 4.205289e-08 |
| 19 | large_group_variation | 61 | 2.131628e-14 | 1.830524 | 4.595027e-08 |
| 20 | boundary | 1 | 3.552714e-14 | 0.000000 | 8.247378e-09 |
| 21 | boundary | 9 | 3.552714e-14 | 0.000000 | 4.832885e-09 |
| 22 | boundary | 25 | 3.552714e-14 | 0.000000 | 4.836544e-09 |
| 23 | boundary | 61 | 3.552714e-14 | 0.000000 | 4.842138e-09 |
| 24 | cbpp | 1 | -4.248342e-02 | 0.642261 | 7.570788e-08 |
| 25 | cbpp | 9 | -2.759014e-06 | 0.647518 | 5.221275e-08 |
| 26 | cbpp | 25 | 6.679102e-13 | 0.647518 | 7.137462e-08 |
| 27 | cbpp | 61 | 7.389644e-13 | 0.647518 | 8.575106e-08 |
figures/logistic-mixed-integration-error.svg
figures/logistic-mixed-node-sensitivity.svg
The likelihood errors are measured at each approximation’s own fitted parameter vector, using independent integration. They are not differences between log likelihoods at different parameters. The rare-event fixture makes the distinction visible: Laplace agrees with R but substantially changes estimated group variance. Higher node counts improve this example; 61 nodes is still an approximation. Check estimate sensitivity to node counts when groups are small or events rare.
Numerical tolerance and failure policy
- Estimates/predictions/group modes: absolute 2e-5 plus relative 2e-4.
- Covariance, standard errors, z tests, p-values and intervals: absolute 2e-5 plus relative 2e-3; R and native use independently computed finite-difference Hessians.
- Likelihood/AIC/BIC: absolute 1e-6 plus relative 1e-8.
- Independent matched-approximation likelihood: absolute 2e-8.
- Sample metadata, group ordering, integer counts and singular flags are exact where checked. Boundary reduction also checks equality with the native GLM.
The coefficient covariance is the fixed block of inverse joint marginal observed information, including scale coupling. A nonpositive Hessian, excessive final score, invalid/aliased design, fixed separation, invalid counts or exhausted budget throws an error. Controls have explicit parameter bounds; convergence is local and does not prove global optimality. Missingness follows the existing listwise policy with one warning and original retained/dropped positions.
Reproduce
cargo test --workspace --locked
cargo build -p php-stats --release --locked
php -n -d "extension=$PWD/target/release/libphp_stats.dylib" tests/logistic-mixed.php
php -n -d "extension=$PWD/target/release/libphp_stats.dylib" examples/logistic-mixed.php
PHP=/opt/homebrew/bin/php .context/notebooks-venv/bin/python docs/verifications/compare_logistic_mixed.py
# Explicit reference refresh, not needed by ordinary Rust/PHP tests:
Rscript tests/generate-logistic-mixed-fixtures.RUse .so on Linux and select matching PHP/php-config as in the development guide. The saved full audit includes versions, fixture/data/extension hashes, original R likelihoods and corrections, all actual fit outputs, integration error measurements and the independent SciPy fit.
From phpstats/phit@76b22f0.