Skip to content

Numerical precision of normal_lcdf #1284

Description

@alecksphillips

Description

The normal_lcdf(x | mu, sigma) function suffers from precision problems at x >> mu/sigma when compared to the equivalent pnorm(x, mu, sigma, log.p=T) in base R.

Example

R code:

library(rstan)

stan_model_code <- "
functions {
  real test_normal_lcdf(real x) {
    return normal_lcdf(x | 0, 1);
  }
}
parameters{}
model{}"

expose_stan_functions(stanc(model_code = stan_model_code))

x <- seq(7.5,7.51,0.00001)
y_stan <- sapply(x, test_normal_lcdf)
y_R <- pnorm(x,log.p=T)

df <- data.frame(
  x = c(x,x),
  y = c(y_R, y_stan),
  method = factor(c(rep_len("R", length(x)), rep_len("Stan", length(x))))
)

plot(df$x, df$y, col = df$method, xlab = "x", ylab = "normal_lcdf(x)")
legend(x = 7.502, y = -3e-14, legend = levels(df$method), col = c(1:2), pch = 16)

Expected Output:

See plot comparing Stan and R's output here: https://discourse.mc-stan.org/t/numerical-precision-of-normal-lcdf/9685

Current Version:

v2.19.1

Activity

  1. bob-carpenter commented on Jul 14, 2019

    @bob-carpenter
    Member

    Thanks for reporting.

    This is a known problem with pretty much every one of our lcdf and lccdf functions, which are implemented as log(foo_cdf(...)) and log1m(foo_cdf(...)). What's needed are iterative algorithms for the cdfs that work on the log scale with stable derivatives. Often what happens when you get out into the tails, log(foo_cdf(...)) remains finite, but one or more of its derivatives, which have the term 1 / foo_cdf(...), explode.

  2. avehtari commented on Aug 7, 2019

    @avehtari
    Member

    I don't think derivatives are the problem here, but computing log(1+erf(z)), using erf from boost.

    R stats package pnorm.c https://github.com/SurajGupta/r-source/blob/a28e609e72ed7c47f6ddfbb86c85279a0750f0b7/src/nmath/pnorm.c has numerically more accurate case for computing lcdf in tails with trivial derivatives. both pnorm.c and Boost erf are using the same rational Chebyshev approximation, but pnorm.c handles the cdf and lcdf far in tails with more care (see lines 199-204 and 219-260). Boost has cdf for normal but not lcdf, so it seems that the solution would be the follow the approach used in pnorm.c

  3. avehtari commented on Aug 7, 2019

    @avehtari
    Member

    I tested that using log(pnorm(x,log.p=F)) in R results also to loss of precision (with same discrete levels in lcdf values, but slightly different location of discontinuities. So it really is the case that lcdf specific computation is needed (and the pnorm.c has the reference implementation)

  4. wds15 commented on Aug 7, 2019

    @wds15
    Contributor

    How does the phi_approx compare to this? It may not be as precise, but it should be better as it stays continuous...of course i don’t expect great precision in the far tails, but maybe that’s better than the discrete steps you see. To be clear, I mean the common logisitic approximation to the cdf which is described in the stan manuals.

  5. avehtari commented on Aug 7, 2019

    @avehtari
    Member

    log(Phi_approx(x)) underflows to 0 in this range, ie, it's much worse.

  6. wds15 commented on Aug 7, 2019

    @wds15
    Contributor

    No...you should use the log inv logit function and define it accordingly.

  7. avehtari commented on Aug 7, 2019

    @avehtari
    Member

    You wrote phi_approx and I tested phi_approx. Can you be more specific and how your suggestion compares to the algorithm used in pnorm.c?

  8. wds15 commented on Aug 7, 2019

    @wds15
    Contributor

    You should always avoid calculating on the natural scale and log at the very end if you target log.

    So this is phi approx:

    inv_logit(0.07056 * pow(x, 3.0) + 1.5976 * x)

    And in stan we have a log inv logit...so you should try

    Log_inv_logit(0.07056 * pow(x, 3.0) + 1.5976 * x)

    I hope this helps.

  9. avehtari commented on Aug 7, 2019

    @avehtari
    Member

    I know about "avoid calculating on the natural scale and log at the very end if you target log." and that's why I recommend the approach from pnorm.c which does things in log scale and has been demonstrated to produce good results.

    So this is phi approx:

    The confusion came as there is also a function called Phi_approx().
    I know this approximation, but I doubt this is not good approximation for normal_lcdf compared to what pnorm.c does. I think that in this issue, we should focus on accurate computation for normal_lcdf and continue in the original discourse thread discussion about whether @alecksphillips could use this approximation instead of normal_lcdf.

    Phi_approx often works because often there is no difference even between logit or probit (Phi_approx has x^3 term so it's not just logit).

  10. wds15 commented on Aug 7, 2019

    @wds15
    Contributor

    Sure...the issue should be on the pnorm.c implementation and my note is better made in discourse. For practical purposes the log inv logit solution may give good enough results. If the approximation gives already good fits then this issue can be prioritized adequatley.

  11. avehtari commented on Aug 7, 2019

    @avehtari
    Member

    Log_inv_logit(0.07056 * pow(x, 3.0) + 1.5976 * x)

    Relative error compared to solution in pnorm.c in the example range is about 4550000%

  12. wds15 commented on Aug 7, 2019

    @wds15
    Contributor

    Sure...but this is far in the tails! Does it really matter?

  13. avehtari commented on Aug 8, 2019

    @avehtari
    Member

    I think it matters for this issue, and it's separate thing whether it matters in a certain model, which should be discussed somewhere else.

  14. bob-carpenter commented on Aug 8, 2019

    @bob-carpenter
    Member

    lcdf specific computation is needed (and the pnorm.c has the reference implementation)

    This is the way forward. We need an algorithm for the derivatives, too, if differentiating the algorithm doesn't work. We can't directly import GPL-ed code---is there a reference to the algorithm for this and others like it?

    The other thing to worry about is the complementary CDF. That probably needs a different algorithm to retain precision.

  15. avehtari commented on Aug 8, 2019

    @avehtari
    Member

    The references for the algorithm are mentioned in pnorm.c

    DESCRIPTION

    The main computation evaluates near-minimax approximations derived
    from those in "Rational Chebyshev approximations for the error
    function" by W. J. Cody, Math. Comp., 1969, 631-637. This
    transportable program uses rational functions that theoretically
    approximate the normal distribution function to at least 18
    significant decimal digits. The accuracy achieved depends on the
    arithmetic system, the compiler, the intrinsic functions, and
    proper selection of the machine-dependent constants.

    REFERENCE

    Cody, W. D. (1993).
    ALGORITHM 715: SPECFUN - A Portable FORTRAN Package of
    Special Function Routines and Test Drivers".
    ACM Transactions on Mathematical Software. 19, 22-32.

    EXTENSIONS

    The "_both" , lower, upper, and log_p variants were added by
    Martin Maechler, Jan.2000;
    as well as log1p() and similar improvements later on.

    That 1969 paper has the rational Chebyshev approximation. SPECFUN code https://www.netlib.org/specfun/ doesn't mention any license

    I couldn't find that extras by Martin Maechler would be reported in any article or report.

    upper is same as the complementary CDF

    upper and log variants don't seem to have any article reference

    boost seems to have the same Chebyshev approximations, but doesn't have upper and log variants.

  16. avehtari commented on Aug 9, 2019

    @avehtari
    Member

    I checked that for the range z>-20 (z=(x-mu)/sigma) we can use the simple approach used in log_ndtr in scipy https://github.com/scipy/scipy/blob/9109a70aad860f19bb2eeb7147d61efebedfb7f6/scipy/special/cephes/ndtr.c
    This would be easy to add and scipy license is BSD3. It would be possible to further extend to z<=-20, but that requires a bit more work.

    For the range z<6 we would use what we have now, and for z>6 we would have code equivalent to -(exp(normal_lcdf(-x | 0, 1)) which is based on the approximation log(1+x) \approx x. Naturally the exact code is not that, but I hope that gives the idea of the computation. I tested that even direct -(exp(normal_lcdf(-x | 0, 1)) produces the similar accuracy as pnorm.

  17. PhilClemson commented on Aug 9, 2019

    @PhilClemson
    Contributor

    I've been working with @alecksphillips on a new implementation of normal_lcdf(x | mu, sigma) to solve this issue. I've been using the Abramowitz and Stegun numerical approximations that are referenced in several sources but the new algorithm still suffers from the same issue. However, I did manage to derive some simple equations for the derivative. The only issue is it goes to infinity as x->0+ so I've had to bridge the gap with Taylor polynomials.

    It would be good to understand the R solution as it seems stable well beyond z<-20. I'll have a look at the approach used in scipy too.

  18. avehtari commented on Aug 9, 2019

    @avehtari
    Member

    Based on the error rates given for Abramowitz and Stegun approximations in https://en.wikipedia.org/wiki/Error_function#Numerical_approximations, they are not as good as the Rational Chebyshev approximations by Cody (see the references above). Boost has error rates for erf implementation here https://www.boost.org/doc/libs/1_64_0/libs/math/doc/html/math_toolkit/sf_erf/error_function.html. I think boost erf is good choice and there would not be need to change erf approximation. We would need to only adjust for log(erf) in z>6 as in scipy and if you want to add something for z<-20, something simpler than what scipy uses would be good (scipy uses iterative algorithm which has error rate based stopping rule so that would be most accurate, but I've seen several non-iterative proposals which might be sufficient).

  19. PhilClemson commented on Sep 16, 2019

    @PhilClemson
    Contributor

    Hi All,

    We eventually came up with a solution and were able to implement it in user-defined C++ files (attached), which has served its purpose in our application.

    Most of the inaccuracy was resolved by switching from using erf(x) to erfc(x), since erfc(x)->0 as x -> +infinity (allowing the 0's to go into the exponent, thereby preserving numerical precision). For the derivative function I couldn't find a numerical approximation that gave decent accuracy for the region 0.1<x<2.2. I've therefore used 3 Taylor expansions of the analytic expression to bridge this gap.

    I was now wondering if anyone could advise on the best procedure to implement this function in the stan math library?

    normal_lcdf2.zip

  20. bob-carpenter commented on Sep 16, 2019

    @bob-carpenter
    Member

    Thanks! The place to start is here:

    https://github.com/stan-dev/stan/wiki/Contributing-New-Functions-to-Stan

    It's a lot to digest, so please don't hesitate to ask question on our forums---there's a Developer category:

    https://discourse.mc-stan.org

  21. added a commit that references this issue on Oct 19, 2019
    54945c9
  22. added a commit that references this issue on Nov 20, 2019
  23. added this to the 3.0.0++ milestone on Dec 30, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions