`expect_near_rel` behaves weirdly for values around 1e-8

Inspired by @Bob_Carpenter I started using expect_near_rel instead of various manual solutions to test “approximate equivalence” in my tests for Stan math. I have however become slightly suspect of its behavior recently and want to discuss whether it’s implementation shouldn’t change (especially if we want to encourage it’s use across the codebase).

The relevant part of the code is:

void expect_near_rel_finite(const std::string& msg, const T1& x1, const T2& x2,
                            double tol = 1e-8) {
  using stan::math::fabs;
  // if either arg near zero, must use absolute tolerance as rel tol -> 2
  if (fabs(x1) < tol || fabs(x2) < tol) {
    EXPECT_NEAR(x1, x2, tol) 
    return;
  }
  auto avg = 0.5 * (fabs(x1) + fabs(x2));
  auto relative_diff = (x1 - x2) / avg;
  EXPECT_NEAR(0, relative_diff, tol)

This however behaves strangely for values close to the tolerance (1e-8 by default), where a steep change in required precision happens. For example, I now have a failing test with:

actual:    1.2019658157669255e-008, 
expected:  1.2019540397111151e-008, 
relative tolerance = 1e-008; relative diff = 9.7973780478180374e-006

This is OK to fail, but if I had actual = 0.9e-008, the test would suddenly pass, even though the difference just increased.

Some discussion on the topic is at Relative delta in finite difference approximations of derivatives? where @Daniel_Simpson recommends using max(epsilon, epsilon*|x|) as a tolerance, which doesn’t have this problem, but might be unnecessarily lax when we care about small numbers. I’ve previously used something like max(1e-14, 1e-8*|x|).

So I am asking whether there is some good reason expect_near_rel is implemented as it is and whether it might be sensible to implement it differently. expect_near_rel is currently used in 21 tests, so it should not be a major change. It is also possible that expect_near_rel is tailored to the autodiff case and shouldn’t be used elsewhere…

Thanks for any input!

Aren’t the current tests kinda lax for small numbers too?

Possibly yes. My motivation is that I am trying to handle numerical inaccuracies in the current implementation of neg_binomial_2 and I am seriously scared of messing something up, so I want to make sure I do the “right thing” in my tests.

What I mean by that is:

EXPECT_NEAR(x1, x2, tol) 

Doesn’t that just make sure abs(x1 - x2) < tol? If so then a comparison of something like 1e-16 vs. 1.5e-16 has the same problems as abs(x1 - x2) < max(epsilon, epsilon * abs(x)) right?

2 Likes

Nope. Just my best shot. I don’t think I understood Dan’s suggestion or I would’ve implemented it.

It’s the basis of expect_ad, so it’s used in a whole lot more than that indirectly.

Yes.

Disclaimer: I just taught myself some numerics in the past few months, I might easily be very wrong

I’ve been thinking a bit about this, and now I most like to make the tolerance depend on the average as:

auto avg = 0.5 * (fabs(x1) + fabs(x2));
tol_final = max(tol_min, tol * avg);
EXPECT_NEAR(0, x1 - x2, tol_final);

here both tol and tol_min can be set by the user and the default for tol_min is tol_rel ^ 2.

The proposed approach gives the same relative tolerance as the current approach when avg >= tol, but then increases the tolerance more gradually.

Graphically, the relative tolerance implied by the current and the proposed approach when tol = 1e-8 looks like this:

The code I’ve written for my recent pull request passes this more stringent standard, but some other tests fail - I am not sure if this is cause for concern or simply means that the tolerances need to be better adjusted.

Does that make sense as a recommended practice? Or am I missing something obvious?

Code for the plot
library(ggplot2)

x <- 10^(seq(-15,5, length.out = 100))
tol_current <- ifelse(x < 1e-8, 1e-8, x* 1e-8)
tol_proposed <- pmax(x*1e-8,1e-16)
df = rbind(
  data.frame(x = x, type = "current", tol = tol_current),
  data.frame(x = x, type = "proposed", tol = tol_proposed))
df$relative_tol = df$tol / df$x

ggplot(df, aes(x = x, y = relative_tol, color = type)) + geom_line() + scale_x_log10("0.5 * (fabs(x1) + fabs(x2))")+ scale_y_log10()

4 Likes

Join the club!

I don’t know. I have very poor intuitions about this kind of thing. But I don’t have issues with changing things to stricter tolerances. We can just lower the tolerance of things that fail. The problem, I think, is that many of our functions don’t have great relative tolerance behavior around zero.

Maybe @Daniel_Simpson has an opinion.

1 Like

I filed an issue for this https://github.com/stan-dev/math/issues/1656 and I am going to test what happens if the behavior changes.

2 Likes

So the change seems relatively painless, I’ve started a PR https://github.com/stan-dev/math/pull/1657 happy to get any feedback.

1 Like