Benchmarks and verification
The same logistic models in R, statsmodels and PHP
We fit the same saved observations and design columns in all three runtimes. See the math walkthrough for how likelihood, Newton updates and normal Wald inference produce these quantities.
from pathlib import Path
import sys
import pandas as pd
ROOT = next(p for p in [Path.cwd(), *Path.cwd().parents] if (p / "Cargo.toml").exists())
sys.path.insert(0, str(ROOT / "docs/verifications"))
from compare_logistic import cases, compare_case
results = [compare_case(case) for case in cases()]
first = results[0]
print(first["versions"])
rows = []
for implementation in ["R", "statsmodels", "php-stats"]:
fit = first[implementation]
for j, label in enumerate(["intercept", "study hours", "practice score"]):
rows.append({"implementation": implementation, "coefficient": label,
"estimate": fit["coefficients"][j], "SE": fit["std_errors"][j],
"z": fit["z_statistics"][j], "p": fit["p_values"][j],
"95% lower": fit["conf_int"][j][0], "95% upper": fit["conf_int"][j][1]})
display(pd.DataFrame(rows))
display(pd.DataFrame([{ "case": r["name"], "retained n": r["php-stats"]["n_observations"],
"iterations (PHP)": r["php-stats"]["iterations"],
"max absolute difference vs R": max(e for values in r["max_absolute_errors_vs_R"].values() for e in values.values()),
"warnings": len(r["warnings"])} for r in results])){'R': 'R version 4.6.1 (2026-06-24)', 'statsmodels': '0.15.0', 'numpy': '2.5.4', 'Python': '3.14.7', 'PHP': '8.5.10'}
| implementation | coefficient | estimate | SE | z | p | 95% lower | 95% upper | |
|---|---|---|---|---|---|---|---|---|
| 0 | R | intercept | -3.170981 | 1.478959 | -2.144062 | 0.032028 | -6.069687 | -0.272274 |
| 1 | R | study hours | 0.063675 | 0.113182 | 0.562592 | 0.573713 | -0.158157 | 0.285508 |
| 2 | R | practice score | 0.034248 | 0.028941 | 1.183392 | 0.236654 | -0.022474 | 0.090970 |
| 3 | statsmodels | intercept | -3.170981 | 1.478959 | -2.144062 | 0.032028 | -6.069687 | -0.272274 |
| 4 | statsmodels | study hours | 0.063675 | 0.113182 | 0.562592 | 0.573713 | -0.158157 | 0.285508 |
| 5 | statsmodels | practice score | 0.034248 | 0.028941 | 1.183392 | 0.236654 | -0.022474 | 0.090970 |
| 6 | php-stats | intercept | -3.170981 | 1.478959 | -2.144062 | 0.032028 | -6.069687 | -0.272274 |
| 7 | php-stats | study hours | 0.063675 | 0.113182 | 0.562592 | 0.573713 | -0.158157 | 0.285508 |
| 8 | php-stats | practice score | 0.034248 | 0.028941 | 1.183392 | 0.236654 | -0.022474 | 0.090970 |
| case | retained n | iterations (PHP) | max absolute difference vs R | warnings | |
|---|---|---|---|---|---|
| 0 | simulated_learning | 80 | 5 | 1.134662e-11 | 0 |
| 1 | simple | 80 | 5 | 1.767218e-12 | 0 |
| 2 | no_intercept | 80 | 5 | 3.753844e-12 | 0 |
| 3 | intercept_only | 80 | 4 | 6.502732e-13 | 0 |
| 4 | correlated | 80 | 5 | 1.284750e-12 | 0 |
| 5 | imbalanced | 80 | 8 | 3.527378e-08 | 0 |
| 6 | listwise_missing | 78 | 5 | 1.369999e-11 | 1 |
All complete statistical outputs, including every fitted probability and residual, are checked at tolerance 1e-9 + 1e-7 × abs(reference). Differences in iteration counts are expected because initial values and convergence rules differ.
Equivalent calls
R adds an intercept in this formula; the other two designs supply it explicitly.
students <- read.csv("docs/data/learning.csv")
fit <- glm(passed ~ study_hours + practice_score, data=students,
family=binomial(), control=glm.control(epsilon=1e-14, maxit=100))
coef(fit)
vcov(fit)
confint.default(fit) # normal Wald, not profile likelihoodstudents = pd.read_csv("docs/data/learning.csv")
x = sm.add_constant(students[["study_hours", "practice_score"]])
fit = sm.Logit(students["passed"], x).fit(method="newton", tol=1e-10, maxiter=100)
fit.params, fit.bse, fit.conf_int()$x = []; $y = [];
foreach ($students as $student) {
$x[] = [1.0, $student['study_hours'], $student['practice_score']];
$y[] = $student['passed'];
}
$result = stats_logistic(x: $x, y: $y);Here $students contains already numeric records; see examples/logistic.php for the complete CSV conversion and call. The native interface uses no formula parser, implicit column construction or model classes.
Missing rows and model statistics
missing = results[-1]
print("Retained rows:", missing["php-stats"]["retained_rows"])
print("Dropped rows:", missing["php-stats"]["dropped_rows"])
print("PHP warnings:", missing["warnings"])
display(pd.DataFrame([{ "implementation": name,
**{key: first[name][key] for key in ["log_likelihood", "deviance", "null_deviance", "aic", "df_residual"]}}
for name in ["R", "statsmodels", "php-stats"]]))Retained rows: [0, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79]
Dropped rows: [1, 4]
PHP warnings: [{'severity': 2, 'message': 'stats_logistic(): stats_logistic dropped 2 of 80 observations with missing values (listwise deletion)'}]
| implementation | log_likelihood | deviance | null_deviance | aic | df_residual | |
|---|---|---|---|---|---|---|
| 0 | R | -49.484325 | 98.96865 | 104.774889 | 104.96865 | 77 |
| 1 | statsmodels | -49.484325 | 98.96865 | 104.774889 | 104.96865 | 77 |
| 2 | php-stats | -49.484325 | 98.96865 | 104.774889 | 104.96865 | 77 |
The runner applies the identical retained sample before fitting reference models. For no-intercept cases it explicitly calculates the fixed 0.5 null baseline, rather than using statsmodels’ intercept-only null fit. Deviance uses individual Bernoulli outcomes, and no Gaussian residual variance is estimated. See the verification record for rejection cases and limits.
From phpstats/phit@76b22f0.