-
Notifications
You must be signed in to change notification settings - Fork 22
R Python differences
This page documents the cross-language numerical equivalence between the R
smooth package and its Python port. It is the result of a detailed
investigation that traced every cross-language gap down to its arithmetic
origin. Where the two implementations diverge, this page explains why.
The short version:
| Scenario | Equivalence |
|---|---|
OM (any model / occurrence) |
machine precision (~1e-9 or 0) |
OMG (any combination) |
machine precision (~1e-9 or 0) |
ADAM with initial="backcasting" or "complete"
|
machine precision (0 or ~1e-11) |
ADAM with initial="optimal", multi-parameter scenarios |
~1e-5 in FI, ~1e-3 in vcov — see below |
The single remaining cross-language gap is in multi-parameter ADAM with
initial="optimal" and is fully diagnosed below.
The following are bit-identical or agree to within a handful of ULPs between R and Python on the same input series:
- Final coefficient vector
coef(m)/m.coef - Log-likelihood
logLik(m)/m.loglik - Loss value
m$lossValue/m.loss_value - Fitted values
m$fitted/m.fitted - Residuals
m$residuals/m.residuals - Forecasts
forecast(m, h=...)/m.predict(h=...) - Information criteria (AIC, AICc, BIC, BICc)
-
vcov(m),confint(m), and the contents ofsummary(m)in all scenarios except the one called out below.
Reaching machine-precision parity took several specific alignments, specifically:
-
Shared C++ numerical Hessian. Both languages now call into the same
src/headers/hessianCore.h(via Rcpp on R, pybind11 on Python). R no longer depends onpracma::hessian. Same loop, same step size, same operation order — the Hessian wrapper itself contributes zero divergence. -
bounds="none"during FI computation. Both implementations now disable the cost-function bounds penalty during the finite-difference Hessian. Without this, perturbations at boundary parameters (e.g.alpha=0) trip the1e+300infeasibility return and the inverse FI collapses to zero. -
abs(diag(...))of vcov. MirrorsR/adam.R:5226("just in case, take absolute values for the diagonal") — both sides agree on the sign convention for non-positive-semi-definite FIs.
For adam(y, model="AAN", initial="optimal") on a 120-observation series,
R and Python produce:
Quantity R Python Diff
coef (alpha) 0.96308367588546961 0.96308367588546961 0
coef (beta) 0.12202608739523153 0.12202608739523153 0
coef (level) 100.5313366407765 100.53133664077598 -5.26e-13
coef (trend) -0.6389674596160431 -0.63896745961606904 -2.60e-14
logLik -168.84126131398833 -168.84126131398853 2.00e-13
vcov[0,0] (alpha,alpha) 0.0071360323… 0.0071358636… ~1.7e-7
… … … …
max |vcov_py − vcov_r| ~5.06e-03
Same series, same model spec, same initial="optimal", but the level coef ends 1 ULP apart and the vcov on the worst entry differs by 5e-3.
I traced this exhaustively. Every step is reproducible from the investigation log in the Python repo's git history; the summary follows.
Python: bundled libnlopt in the wheel — version 2.10.0
R: system /usr/lib/x86_64-linux-gnu/libnlopt.so.0 — version 2.7.1
The two NLopt versions are 3+ years of development apart. But: when given
the same starting point x0, both versions converge to bit-identical final
B. Version difference is not the cause.
xtol_rel = 1e-6
xtol_abs = 1e-8
ftol_rel = 1e-8
ftol_abs = 0
maxeval = length(B) * 40 = 160
algorithm = NLOPT_LN_NELDERMEAD
R's utils-adam.R:237-240 and Python's
adam_general/core/estimator/optimization.py:180-182 agree to the literal
value. Tolerances are not the cause.
Captured the exact x0 each implementation passes into nlopt::optimize:
alpha: py=0.10000000000000001 r=0.10000000000000001 diff= 0
beta: py=0.050000000000000003 r=0.050000000000000003 diff= 0
level: py=96.513677541526278 r=96.513677541526292 diff=-1.42e-14 ← seed
trend: py=0.11740463161952641 r=0.11740463161952630 diff=+1.11e-16
Before the optimiser runs, R and Python disagree on level by ~1 ULP
at scale 100. That ULP is the seed of all downstream divergence.
Monkey-patched nlopt.opt.optimize to replace Python's x0 with R's
verbatim, then re-ran. The result:
Final coef diff vs R
Python default x0: 5.26e-13
Python with R's x0: 0.00e+00 ← BIT-IDENTICAL
This is the decisive evidence: NLopt is deterministic given the same x0. The cross-language divergence comes from the x0 generator, not the optimiser.
R's R/utils-adam.R:666-678 and Python's adam_general/core/utils/utils.py
both call msdecompose(y_in_sample, lags=1, type="additive", smoother="global")
to seed level and trend. Compared the raw output:
Python msdecompose level: 96.513677541526278
R msdecompose level: 96.513677541526292 diff = 1.42e-14
The exact same 1 ULP of divergence shows up at the msdecompose output, so
the cause is upstream of msdecompose's post-processing. It is inside
the smoother.
The default smoother (smoother="global") fits a linear regression on the
input series. The implementations:
R (R/msdecompose.R:96-113):
X <- cbind(1L, seq_len(n))
return(y - .lm.fit(X, y)$residuals).lm.fit is R's fast lean LM, backed by LINPACK's QR via dqrls.f.
Python (adam_general/core/utils/utils.py:283-300):
X = np.column_stack([np.ones(n), np.arange(1, n + 1)])
coef = np.linalg.lstsq(X, y, rcond=None)[0]
return X @ coefnp.linalg.lstsq is backed by LAPACK's dgelsd (SVD-based).
Both solve the same least-squares problem. Different numerical algorithms — LINPACK Householder QR vs LAPACK SVD — round at different bits during the elimination/back-substitution. Empirically:
Method Max diff of fitted values vs R
np.linalg.lstsq (LAPACK SVD, current) 7.11e-14
np.linalg.lstsq → y − residuals (same path) 7.11e-14
np.linalg.solve(X.T @ X, X.T @ y) (normal eqs) 9.95e-14
scipy.linalg.lstsq(driver='gelsy') (QR+pivot) 4.26e-14 ← closest
None of NumPy / SciPy's LSQ paths match R's LINPACK QR bit-exactly. Even two different QR implementations (LINPACK Householder vs LAPACK QR with column pivoting) round at different bits. This is a property of the BLAS/LAPACK implementation, not an algorithmic bug.
That ~7e-14 ULP per fitted value in the smoother propagates as follows:
LSQ solver (LINPACK QR vs LAPACK SVD)
│ ~7e-14 ULP per fitted value
▼
smoothing_function_global trend array
│ same ULP scale
▼
msdecompose initial$nonseasonal (level / trend)
│ partial cancellation in level = trend[0] − mean(diff(trend)) * lagsMax
▼ ~1.42e-14 in level, ~1.11e-16 in trend
nlopt::optimize starts Nelder-Mead from this x0
│ ~160 iterations, ULP-different simplex trajectories
▼ ~5.26e-13 in final B (level)
Finite-difference Hessian at non-identical B
│ multiplied by 1/h² ≈ 6.7e7
▼ ~3e-5 in FI matrix entries
vcov = FI⁻¹ (with the 4×4 FI ill-conditioned for AAN)
▼ ~5e-3 max in the worst vcov entry
Each arrow is a real, identifiable amplification factor.
-
initial="backcasting"/"complete":levelandtrendare not inB. They are derived inside the C++ kernel by backcasting from the data each call.msdecomposeis not the source of any value inB. Therefore the LSQ-solver-ULP never enters the optimiser's x0. -
OMandOMG: same —Bholds only smoothing parameters (α, β, γ, …), not initial states.msdecomposeis not in the path. -
initial="optimal":levelandtrendare estimated as elements of B. Their starting values come straight frommsdecompose$initial$nonseasonal. The 1 ULP from the LSQ solver entersB[level]and propagates through the optimiser into the FI.
This explains why OM, OMG, and backcasting-ADAM are at machine precision
while only initial="optimal" ADAM shows the residual.
While verifying the explanation above, an additional finding emerged.
When Python's optimiser is started from R's converged model$B (not
R's x0), Python's Nelder-Mead does not stay there. It walks away and
finds a better local minimum:
loss
R model$B (claimed optimum): 168.84126131398833
Python re-optimises from there: 164.75966898758691 ← better by ~4 units
The coefficient vectors at these two points differ by 0.72 in the worst entry — a real, multi-component move, not noise.
R itself emits the warning at vcov(m) time:
Observed Fisher Information is not positive semi-definite, which might
mean that the likelihood was not maximised properly.
That warning is consistent with what happened: R's nloptr settled at a saddle of the loss surface — a point where some Hessian eigenvalues are positive and others negative — rather than a strict minimum. Python's NLopt, given a "kicked" starting point at that saddle, escapes downhill along the negative-eigenvalue direction.
Both implementations encounter this saddle when running their own default
workflow (starting from msdecompose x0). Neither finds the better
minimum in normal use; both stop on the same suboptimal ridge. The
"better minimum" is only reachable by deliberately displacing the
starting point.
This is not directly related to the cross-language gap, but it explains why the FI computed at the converged point is non-PSD on this scenario. Once you compute the FI at a saddle, the diagonal becomes a mix of positive and negative curvature, the inverse is ill-conditioned, and small ULPs in the FI inputs amplify into large ULPs in vcov.
The root cause is the LSQ solver inside msdecompose's default global
smoother. Options:
For the default X = [1, t] case (intercept + time), the linear
regression has a closed-form solution that involves only sums and
products — no QR, no SVD, no BLAS dependency. The formula is the same on
both sides and computable bit-identically:
t = np.arange(1, n + 1, dtype=float)
t_bar = (n + 1) / 2
y_bar = y.mean()
# Equivalent in R: same arithmetic, same order.
slope = ((t - t_bar) * (y - y_bar)).sum() / ((t - t_bar) ** 2).sum()
intercept = y_bar - slope * t_bar
fitted = intercept + slope * tImplementing this in both R's msdecompose.R and Python's
adam_general/core/utils/utils.py would make the smoother's output
bit-identical across languages, and by Test B above, the entire ADAM
optimiser convergence would follow. initial="optimal" would then reach
machine precision parity.
The block-dummies fallback (n_groups > 1) covers a less-common code path
and could either stay on the implementation-specific LSQ for now or be
migrated to a shared C++ QR via the same pattern used for the Hessian.
Same architecture as the shared hessianCore.h: write a small shared
QR-based LSQ in src/headers/, expose it to both languages, and route
smoothing_function_global through it on both sides. Guarantees
bit-identical output across all column counts. More work than Option A
for the same observable benefit on the default path; only worth doing if
the block-dummies fallback is also a parity concern.
Document the gap (this page) and the parity tests' tolerances around the
LSQ-noise floor (atol=1e-3 on vcov for ADAM-with-initial="optimal").
Both implementations are independently correct under their respective
numerical-algebra libraries; the gap is fundamental to LINPACK vs
LAPACK/OpenBLAS rounding.
The Python repo's tests/test_om_summary_r_comparison.py,
test_om_r_comparison.py, test_omg_r_comparison.py,
test_adam_summary_r_comparison.py, and test_fi_r_comparison.py carry
the r_parity marker and are deselected in CI by default. The tolerances
are calibrated to catch regressions, not to enforce bit-equivalence
where the LSQ-solver floor is unavoidable:
| Quantity | rtol | atol |
|---|---|---|
| Coefficients | 1e-2 | 1e-3 |
| Log-likelihood, loss, fitted | 1e-12 (typically; sometimes tighter) | 1e-13 |
| OM / OMG / backcasting-ADAM vcov | 1e-4 | 1e-6 |
ADAM-optimal vcov |
0.30 | 1e-2 |
ADAM-optimal SE |
0.20 | 2e-2 |
ADAM-optimal CI bounds |
0.10 | 5e-2 |
If Option A or B is implemented in the future, the ADAM-optimal
tolerances can be tightened to match the machine-precision band of the
other rows. The tests will then guard the tighter floor.
The investigation that produced this page used:
- Local R checkout (
devtools::load_all(".")) — necessary so that the smooth R code matches the current source rather than CRAN. - Local Python checkout (editable install via
pip install -e .). - An ad-hoc
nlopt.opt.optimizemonkey-patch to inject specific starting points into Python's optimiser without changing its public API. - The
_r_bridge.pytest helper in the Python repo for round-trip JSON marshalling between Python and R.
If revisiting this analysis, the three decisive measurements are:
- Capture
x0at the boundary betweenmsdecomposeoutput andnlopt::optimizeinput on both sides. Confirm any sub-ULP difference inlevel. - Inject R's
x0into Python's optimiser. Confirm bit-identical final B. - Compare
msdecompose$initial$nonseasonal(R) tomsdecompose(...)['initial']['nonseasonal'](Python) on the same series. The 1 ULP inlevelreproduces every time.
If any of those three break in the future, the cause has moved and the chain in this document needs re-tracing.