Numerical accuracy and speed in orm’s Fortran likelihood code

This document catalogs the changes made to src/orm_links.f90, src/ormll.f90 and src/ormeta.f90 for computational accuracy and speed, explains the underlying problem — computing differences of cumulative probabilities accurately — in detail, and describes how the design connects to the future addition of y-dependent effects (YDE / partial proportional odds).

None of this changes the statistical model. orm.fit.r, orm.rfit.r, init.c’s function bodies, and every .Fortran() call signature except ormeta’s (see below) are unchanged.

1. The problem: differencing cumulative probabilities

orm’s log-likelihood is built from terms of the form

d = F(x1) - F(x2)            (an interior or interval-censored category)
d = F(x1)  or  1 - F(x1)     (a boundary or singly-censored category)

where x1 = alpha(ia) + lp and x2 = alpha(ia2) + lp, F is the CDF for one of five link functions (logistic, probit, log-log, complementary log-log, Cauchy), and alpha holds the fitted intercepts.

With continuous or near-continuous Y, adjacent intercepts are close together — for N = 250,000, typical adjacent gaps are on the order of 1e-5 — so d is the difference of two numbers that agree to 4–5 significant digits. Two independent things can destroy that difference’s accuracy:

  1. Cancellation in the subtraction itself. F(x1) and F(x2) each carry an absolute rounding error of about 1e-16 (or, in the upper tail, an error of order 1e-16·F, which stops shrinking once F saturates at 1 − 1e-16 in double precision). That fixed-size error does not shrink just because d is small, so the relative error of d grows in proportion to how narrow the cell is.
  2. Loss of the cell width before the subtraction ever happens. x1 and x2 are each rounded to a double (alpha(ia) + lp, alpha(ia2) + lp) before F is ever evaluated. If lp is large (e.g. a strong linear predictor, or, in the future, a y-dependent shift), the rounding of x1 and x2 individually can already be larger than the true gap between them — at which point no amount of care in evaluating F can recover the correct d, because the input arguments have already lost the information.

Both problems compound for the tail-saturating links (log-log, complementary log-log): with the naive 1 - exp(-exp(x)) and exp(-exp(-x)) forms, F and 1 - F reach their float limits (0 or 1) at much smaller |x| than the logistic, so d collapses to exactly 0 — not just inaccurately small — well within the range of x actually encountered in fits, triggering spurious salloc = 999 failures.

Getting d right the first time is what matters. Once d has full relative accuracy, log(d) inherits it to within one rounding (about 1e-16), and a simple summation of wt * log(d) — subject to the ordinary accumulation error described in §2.5 — gives an accurate log-likelihood. No other transformation downstream of d can repair an inaccurate d.

1.1 Measured impact of the un-fixed code

Before this work, ormll.f90 computed d as p1 - p2 from two ordinary expit/pnorm/etc. evaluations. Measured against an arbitrary-precision (mpmath) reference at cell width 1e-5:

link relative error of d, moderate x at x ≈ 20–30
logistic ~3e-11 1e-2 at x=20; wrong past x≈25
probit ~3e-11 wrong (saturates) past x≈8-10
log-log ~1e-9 wrong past x≈3-5
cloglog ~3e-11 wrong past x≈3-5 (opposite tail)
Cauchy ~1e-11 (no tail collapse; slow eps/delta growth) 4e-5 at extreme x

“Wrong” means d was off by order 1 relative error, or exactly 0 (triggering a spurious convergence failure), well inside the range of linear-predictor values a real fit can reach.

2. Changes made, in order

2.1 Accurate one-sided probabilities: never compute a small 1 - F (or F) by subtraction

linkpieces(x, link, need, f, g, p, t) returns f = F(x), g = 1 - F(x), and the pdf p, each computed so that the small one is never obtained by subtracting from 1:

t = exp(∓x) is also returned by linkpieces, since linkdpdf and celldiff need it and it should not be recomputed (§2.6).

2.2 Accurate two-point differences: celldiff uses the exact cell width, not two rounded arguments

celldiff(link, dl, x1, x2, f1, g1, f2, g2, p1, p2, t1, t2) takes the exact cell width dl = (alpha(ia) - alpha(ia2)) + (e1 - e2) (formed from small terms — see §2.4 and §3) as a first-class input, separate from x1 and x2 themselves, and uses a closed-form difference wherever one exists:

link exact difference formula
logistic d = 2·sinh(dl/2)·sqrt(pdf1·pdf2)
log-log d = F(x1)·(-expm1(-exp(-x1)·expm1(dl)))
cloglog d = G(x2)·(-expm1(-exp(x2)·expm1(dl)))
Cauchy d = atan2(dl, 1 + x1·x2) / π
probit none exists in closed form; see §2.3

Every term in these formulas is non-negative, so there is no cancellation — this is the key structural idea, and it is what makes d accurate to full double precision (about 1e-15–1e-16 relative) however narrow the cell, for four of the five links.

For dl > 30 (only reachable via a wild Newton iterate, since a converged cell this wide has a probability below 1e-13), sinh/expm1 risk overflow; celldiff instead falls back to differencing the two accurate small-side tails (§2.1): G(x2) - G(x1) when x2 > 0, else F(x1) - F(x2). Both terms are then already tiny and well separated, so ordinary subtraction is safe there.

2.3 Probit: no closed form, so tail-switching is the best available

Probit has no algebraic identity for Φ(x1) - Φ(x2). celldiff differences whichever pair of one-sided values (§2.1) is smaller in magnitude (G(x2) - G(x1) when x2 > 0, else F(x1) - F(x2)), which removes the tail-saturation failure (probit no longer returns 999 or a wildly wrong d for moderately large |x|) but leaves a residual cancellation error of order eps / width — measured at about 4e-10 relative for a 1e-5-wide cell, versus complete failure in the original code at the same width once |x| exceeded about 8. This is the one link where narrow-cell accuracy is still limited by cancellation; there is no way to remove that within double precision without a different algorithm (e.g. an extended-precision or series-based Φ(x1) - Φ(x2) evaluation), which was judged not worth the complexity for this pass.

All link-specific arithmetic in the package now lives in orm_links.f90. ormatoms(link, n, k, alpha, ia, ia2, sgn, lp, wt, what, d, s1, s2, hee, h11, h22, h12, h1e, h2e, salloc, e1, e2) is the single per-observation loop that both ormll (the main Newton fit) and ormeta (per-cluster/per-node evaluation for random-intercept and future YDE fits) call; neither contains any link arithmetic of its own — they only accumulate what ormatoms returns into u/ha/hb/hab (ormll) or logd/g/h/s1/s2 (ormeta).

what controls how much is computed per observation:

For the logistic link, the second derivatives have closed forms with no division-heavy cancellation, verified against 450-digit arithmetic:

one intercept:   hee = h11 = h1e = -wt * pdf1
two intercepts:  hee = -wt*(pdf1+pdf2)
                 h1e = -wt*pdf1,  h2e = -wt*pdf2
                 h12 =  wt*pdf1*pdf2 / d^2
                 h11 = h1e - h12,  h22 = h2e - h12

using the identity d²(D/Dη) = D/Da1 + D/Da2, so h1e = h11+h12, h2e = h12+h22, hee = h11+2·h12+h22 hold exactly. Links 2–5 use the direct pdf/pdf-derivative formulas (still exact to the pdf’s own accuracy, just without the special-case cancellation-free logistic identities).

2.5 Summation: block the sum(wt * log(d)) in ormll

Fortran’s sum() (and a naive running total) accumulates N ≈ 250,000 terms of size ~10 sequentially, which alone contributes a rounding error of about 1.8e-8 to -2·logL at N = 250,000 (1.9e-7 at N = 1,000,000) — larger than the residual error left in d itself after §2.1–2.3. ormll’s new wlogsum accumulates in blocks of 512 (sum each block, then sum the block totals), which reduces this to about 1.2e-9 (250k) / 1.9e-9 (1M) — the resolution of a double at that magnitude — at negligible extra cost.

2.6 Removing redundant transcendental evaluations

2.7 Net effect on speed (N = 250,000, single core, illustrative)

link ormll what=3 ormeta (all outputs) ormeta, what=1 only
logistic faster (~20%) faster (~25-45%) faster still (~35%)
probit ~similar ~similar faster (~15-30%)
log-log ~similar ~similar much faster (~55-70%)
cloglog ~similar ~similar much faster (~55-70%)
Cauchy faster (~10%) faster (~10-20%) much faster (~65-75%)

Timings vary run to run by roughly ±10-15% in the environment they were measured in and should be re-measured on the target machine; the pattern (large what=1 gains for the tail-heavy links; modest but real gains elsewhere) is the load-bearing conclusion, not the exact percentages.

Two structural options were evaluated and deliberately not adopted, for portability (plain, portable Fortran 2008 — no compiler-specific pragmas or non-default build flags):

2.8 Real-world confirmation: full orm fit, N = 250,000, 250,000 distinct Y levels, 10 covariates

A full orm.fit Newton fit on this design — N = 250,000, one intercept per distinct Y value (continuous Y), 10 covariates — was timed before and after this change on identical data:

old code new code
wall time 2.5 s 1.3 s (~1.9x)

Agreement between old and new fits on the same data:

This is consistent with §2.7’s per-call timings (roughly 1.9x here, versus the isolated ormll/ormeta benchmarks) and with the expectation that a converged continuous-Y logistic fit should shift by an amount comparable to the accuracy improvement itself (§1.1’s ~1e-10-to-1e-2 errors in the old d, now ~1e-15), not by more. Coefficient- and covariance-level agreement at 1e-11–1e-12 confirms the new code is reproducing, not just running faster than, the old fit’s converged solution — the improved accuracy in d manifests as the fit converging to a very slightly different (more correct) optimum, at the precision level predicted, rather than as a different answer outright.

3. Differencing issues and their relevance to future YDE work

Y-dependent effects (YDE / partial proportional odds) let a subset of covariate effects vary with Y. Structurally, this means the linear predictor at each of the two intercepts in a cell becomes different:

x1 = alpha(ia)  + lp + e1,   e1 = z * gamma(ia)
x2 = alpha(ia2) + lp + e2,   e2 = z * gamma(ia2)

lp (offset + Xβ + any random effect) is common to both terms; e1, e2 are the y-dependent contributions specific to each intercept. ormatoms already has the interface this needs — this was implemented in this pass in anticipation of YDE, ahead of the modeling code itself:

3.1 Why this matters: the caller cannot recover the width after the fact

This is the same cancellation problem as §1, one level up, and it is not fixable inside ormatoms if the caller has already destroyed the information before calling it:

3.2 Consequence for the eventual YDE model code

Whatever R/Fortran code eventually builds lp1/lp2 (or calls ormatoms directly with e1/e2) for a YDE fit must keep the y-dependent contribution separate from the common linear predictor until it reaches ormatoms, i.e.:

3.3 What YDE additionally needs from the derivative structure

With e1 ≠ e2, x1 and x2 no longer move together under a YDE parameter’s gradient direction:

3.4 What remains unresolved for YDE, and is out of scope here