phit

Benchmarks and verification

Probit regression scale validation and performance

The standard benchmark compares the release PHP extension, R glm(binomial(link="probit")) and statsmodels GLM on identical synthetic observations. The raw report preserves all timing samples, machine and runtime versions, input hashes, numerical outputs and differences from R.

Recorded results

from pathlib import Path
import json
import pandas as pd

# Keep display order independent of the benchmark worker execution order.
def ordered_table(rows):
    table = pd.DataFrame(rows)
    table["implementation"] = pd.Categorical(
        table["implementation"], categories=["R", "statsmodels", "PHP"], ordered=True)
    return table.sort_values(["observations", "coefficients", "implementation"]).reset_index(drop=True)

ROOT = next(p for p in [Path.cwd(), *Path.cwd().parents] if (p / "Cargo.toml").exists())
report = json.loads((ROOT / "docs/benchmarks/probit-results.json").read_text())
assert report["model"] == "probit"
print("Recorded:", report["recorded_at"])
print("Machine:", report["cpu"], report["platform"])
print("Rust:", report["rust"])
print("Repeats:", report["repeats"], "Warm-ups:", report["warmups"])
rows, memory = [], []
for case in report["cases"]:
    assert case["agreement_passed"]
    for name, fit in case["implementations"].items():
        rows.append({"observations": case["n"], "coefficients": case["p"],
                     "implementation": name, "version": fit["version"],
                     "median full fit (ms)": 1000*fit["median_fit_seconds"],
                     "minimum full fit (ms)": 1000*fit["min_fit_seconds"]})
        memory.append({"observations": case["n"], "coefficients": case["p"],
                       "implementation": name, "CSV load (s)": fit["load_seconds"],
                       "whole process (s)": fit["process_seconds"],
                       "peak process RSS (MiB)": fit["peak_rss_bytes"]/2**20 if fit["peak_rss_bytes"] else None})
display(ordered_table(rows))
Recorded: 2026-10-16T07:50:38.410477+00:00
Machine: Apple M5 Ultra macOS-27.0.1-arm64-arm-64bit-Mach-O
Rust: rustc 1.99.0 (b940084d7 2026-09-28) (Homebrew)
Repeats: 5 Warm-ups: 1
observations coefficients implementation version median full fit (ms) minimum full fit (ms)
0 1000 3 R R version 4.6.1 (2026-06-24) 2.000000 2.000000
1 1000 3 statsmodels 0.15.0 1.879208 1.822209
2 1000 3 PHP 8.5.10 1.432833 1.421792
3 100000 3 R R version 4.6.1 (2026-06-24) 156.000000 136.000000
4 100000 3 statsmodels 0.15.0 59.247375 58.537042
5 100000 3 PHP 8.5.10 137.871416 137.446791
6 100000 11 R R version 4.6.1 (2026-06-24) 210.000000 207.000000
7 100000 11 statsmodels 0.15.0 89.800209 89.226542
8 100000 11 PHP 8.5.10 196.224917 193.636250
9 1000000 3 R R version 4.6.1 (2026-06-24) 1192.000000 1145.000000
10 1000000 3 statsmodels 0.15.0 591.340292 588.195125
11 1000000 3 PHP 8.5.10 1231.733541 1231.109209

The cases include 1,000, 100,000 and one million observations with three coefficients, and 100,000 observations with eleven coefficients. Counts include the intercept. These measurements describe this workload and environment, not a universal ranking. The raw report preserves each repetition.

Visual comparison

benchmarks/figures/probit-fit-time.svg 
benchmarks/figures/probit-memory.svg 

Full-fit times for R, statsmodels and PHP in four workloads. Bars show median times; dots show individual repetitions.

Peak whole-process memory for R, statsmodels and PHP in four workloads.

R, statsmodels and PHP use the same order and colors throughout. Each panel has its own linear scale so small workloads remain readable; compare labeled values across panels. Memory covers the entire worker process. It is not the solver’s allocation count. All workloads passed the numerical checks described below.

Download the timing SVG or PNG, and memory SVG or PNG. Charts refresh from saved measurements when this page is executed; they do not rerun the benchmarks.

What is timed?

Workers load the same CSV once, perform one untimed warm-up, and measure five full fits. Each timed fit materializes coefficients, covariance, standard errors, normal z tests, p-values, Wald intervals, every fitted probability and response residual, likelihood, deviance, null deviance, AIC and residual degrees of freedom. PHP also converts inputs and all outputs and constructs row metadata within the timer. Startup, CSV loading, data generation and final JSON serialization are excluded.

R uses formula/model construction and QR-based IRLS, with epsilon 1e-16 to stabilize covariance agreement. Python uses GLM IRLS with parameter tolerance 1e-11; PHP uses weighted SVD Fisher-scoring updates and its documented convergence rules. Starting values, linear solves and stopping criteria differ. This is a comparison of complete public API calls, not equal iteration counts or isolated decomposition speeds. Common numerical-library thread variables are set to one; installed backends and requested settings are recorded. Workers run separately with order alternated between cases. Other machine activity can affect measurements. R’s elapsed timer has coarse millisecond resolution for small cases.

display(ordered_table(memory))
observations coefficients implementation CSV load (s) whole process (s) peak process RSS (MiB)
0 1000 3 R 0.001000 0.189865 89.046875
1 1000 3 statsmodels 0.000406 0.698723 175.406250
2 1000 3 PHP 0.000934 0.055349 28.625000
3 100000 3 R 0.068000 1.181489 235.921875
4 100000 3 statsmodels 0.025129 1.080360 463.093750
5 100000 3 PHP 0.083124 0.961933 94.453125
6 100000 11 R 0.697000 2.315585 339.234375
7 100000 11 statsmodels 0.110455 1.306533 746.062500
8 100000 11 PHP 0.310802 1.543994 178.828125
9 1000000 3 R 1.571000 9.112119 822.046875
10 1000000 3 statsmodels 0.236973 4.523255 2727.531250
11 1000000 3 PHP 0.819627 8.333026 964.171875

Whole-process time includes loading, imports, warm-up and every measured fit. Peak resident memory covers the entire worker, not just the numerical solver. PHP memory_limit is disabled for this experiment because large nested PHP arrays can exceed typical application limits. See the OLS benchmark for the shared harness and further interpretation of these boundaries.

Simulation and agreement

NumPy RNG seed 20261011 generates study hours uniformly between 1 and 12, practice scores as clipped 45 + 3 × hours + normal noise with standard deviation 8, and independent standard-normal extra scores. Binary outcomes are draws from

\[\pi_i=\Phi(-1.9+0.13\,\mathrm{hours}_i+0.011\,\mathrm{score}_i +0.08\sum_j\mathrm{extra}_{ij}),\qquad y_i\sim\operatorname{Bernoulli}(\pi_i).\]

This simulation is separate from the small R-generated teaching sample; its scores are unrounded and its seed is different. Every runtime reads identical 17-significant-digit CSV values. Data hashes, generating coefficients and sample correlations are recorded. All coefficient-level quantities and scalar statistics are compared with R at tolerance 1e-9 + 1e-7 × abs(reference). Full fitted/residual vectors are materialized; cross-runtime comparison samples ten evenly spaced rows, while small fixtures check complete vectors. PHP convergence must be true and its update count must be between one and 100. Wald intervals use R confint.default(), and the null baseline follows the documented intercept convention. Probit covariance uses expected Fisher information in all three runtimes.

display(ordered_table([{ "observations": case["n"], "coefficients": case["p"],
                        "implementation": name, "largest absolute difference vs R": max(errors.values())}
                       for case in report["cases"]
                       for name, errors in case["max_absolute_errors_vs_R"].items()]))
observations coefficients implementation largest absolute difference vs R
0 1000 3 statsmodels 4.547474e-13
1 1000 3 PHP 1.598437e-10
2 100000 3 statsmodels 2.805609e-08
3 100000 3 PHP 2.805609e-08
4 100000 11 statsmodels 1.261360e-07
5 100000 11 PHP 1.261360e-07
6 1000000 3 statsmodels 3.107358e-06
7 1000000 3 PHP 3.107125e-06

Reproduce

export PHP=/opt/homebrew/bin/php
export PHP_CONFIG=/opt/homebrew/bin/php-config
cargo build -p php-stats --release --locked
.context/notebooks-venv/bin/python tests/benchmarks/run.py --model probit
QUARTO_PYTHON="$PWD/.context/notebooks-venv/bin/python" quarto render docs/benchmarks/probit.qmd
quarto render docs

Select matching PHP tools and .so on Linux. Use --cases 1000:3 --repeats 2 --output .context/probit-smoke.json for a quick check that preserves the published report. Numerical failures abort without publishing results. Benchmarks are explicit development commands; ordinary renders and tests read saved evidence. Optimization or additional numerical dependencies should answer a demonstrated need.

From phpstats/phit@76b22f0.