phit

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.R

Use .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.