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