Stream: ideas

Topic: ApproxEq implementation


view this post on Zulip Brian Teague (Jan 05 2024 at 19:53):

Issue #6349 - Algorithm
Something like this?
isApproxEq : a, a -> Bool
isApproxEq = a, b -> (Num.abs (a - b)) < 0.000000001 #epsilon 1e-9

view this post on Zulip Brendan Hansknecht (Jan 05 2024 at 19:56):

We should probably use the numpy style comparison here where it takes in two epsilon values atol and rtol. We can take a record with default fields as the last parameter to enable this

view this post on Zulip Brendan Hansknecht (Jan 05 2024 at 19:56):

This is much more robust: https://numpy.org/doc/stable/reference/generated/numpy.isclose.html

view this post on Zulip Brendan Hansknecht (Jan 05 2024 at 19:56):

Though, for rocs case, we probably should just ignore nan

view this post on Zulip Brian Teague (Jan 05 2024 at 20:12):

How does ROC default parameters? It looks like Option type is not supported yet.
Does ROC support scientific notation or planning to?

isApproxEq = a,b, Option rtol, Option atol ->
_rtol = when rtol is
Some x -> x
None -> 1e-05
_atol = when atol is
Some y -> y
None -> 1e-08
(Num.abs (a - b)) <= (_atol + _rtol * (Num.abs b))

result = isApproxEq (0.1 + 0.2) 0.3 None None

view this post on Zulip Brian Teague (Jan 05 2024 at 20:38):

Another option
isApproxEq = a, b -> isApproxEqWithEpsilon a b None None

isApproxEqWithEpsilon = # above signature

view this post on Zulip Brendan Hansknecht (Jan 06 2024 at 01:31):

Roc doesn't have an option type, though one is trivial to define. Instead roc uses results or custom tags.

view this post on Zulip Richard Feldman (Jan 06 2024 at 01:32):

we have optional record fields rather than optional parameters

view this post on Zulip Brendan Hansknecht (Jan 06 2024 at 01:32):

Also, this is what I was talking about for optionals: https://www.roc-lang.org/tutorial#optional-record-fields

view this post on Zulip Brendan Hansknecht (Jan 06 2024 at 01:34):

So the default call would be isApproxEq a b {}

And more args could be passes isApproxEq a b { absoluteTolerance: 0.1, relativeTolerance 0.001 }

Not sure the state of scientific notation parsing.

view this post on Zulip Brian Teague (Jan 06 2024 at 01:56):

Cool, so it's similar to an options object in Javascript.
I still like the idea of wrapping isApproxEq. What % of use cases would actually modify the tolerance?

isApproxEq a b
isApproxEqWithEpsilon a b {}

view this post on Zulip Brendan Hansknecht (Jan 06 2024 at 02:40):

I don't think It will be modified that often, but I do like isApproxEq a b {} cause it helps people remember that their are parameters that they are ignoring....though maybe that isn't important

view this post on Zulip Brendan Hansknecht (Jan 06 2024 at 02:41):

I mean I personally modify It all the time (but I also work on ML compilers which is a very special case)

view this post on Zulip Anton (Jan 06 2024 at 10:25):

cause it helps people remember that their are parameters that they are ignoring

I agree with this

view this post on Zulip Brian Teague (Jan 06 2024 at 17:56):

:+1:

view this post on Zulip Brian Teague (Jan 07 2024 at 00:14):

Does anyone like this shorthand syntax?
a ~= b is the same as isApproxEq a b {}

view this post on Zulip Brendan Hansknecht (Jan 07 2024 at 06:49):

I think it is reasonable but probably premature. As in, I'm not sure how much a short hand for that is wanted currently. Totally a reasonable topic to discuss in general though.

view this post on Zulip Hannes (Jan 07 2024 at 09:06):

I'd really like this function to be in the stdlib, because I end up adding it to a Utils.roc file in about 50% of libraries I write :sweat_smile: mainly for comparing values in unit tests, e.g here:
https://github.com/Hasnep/roc-math/blob/9a5a114c4e3e4500e5a7a4a6c8d939ef9c7cb85a/src/Utils.roc
I think I copied that implementation from the Julia stdlib, just because I'm used to reading Julia

view this post on Zulip Brendan Hansknecht (Jan 07 2024 at 15:47):

Yeah, I think we should add this to the standard as Num.isApproxEq

view this post on Zulip Brian Teague (Jan 08 2024 at 15:23):

Do we want the implementation of isApproxEq to be symmetric or asymmetric? isApproxEq(a, b) != isApproxEq(b, a)
See math.isClose vs Numpy.isClose.
https://docs.python.org/3/library/math.html#math.isclose
https://numpy.org/doc/stable/reference/generated/numpy.isclose.html

basile-henry also brought up a good point about handling infinity and denormals correctly.
https://floating-point-gui.de/errors/comparison/

view this post on Zulip Brian Teague (Jan 08 2024 at 15:24):

In the mean time, I'll add several more test cases to the PR.

view this post on Zulip Brendan Hansknecht (Jan 08 2024 at 15:48):

Asymmetric is much more useful in practice

view this post on Zulip Brendan Hansknecht (Jan 08 2024 at 15:48):

Otherwise you don't have control over which value is the reference and get way more values excepted by rtol than you actually want

view this post on Zulip Brendan Hansknecht (Jan 08 2024 at 15:50):

If we are really concerned about it, we could through all values in the record to give clearer names isApproxEq { val: a, reference: b } val and reference may not be the best name, but that idea in general.

view this post on Zulip Brendan Hansknecht (Jan 08 2024 at 15:54):

We may need an extra check around division by zero, but I wouldn't worry about denormals for now.

view this post on Zulip Brendan Hansknecht (Jan 08 2024 at 15:57):

Float step based algorithms are really hard to reason about and probably would confuse users more.

view this post on Zulip Brendan Hansknecht (Feb 02 2024 at 23:49):

So, I have already changed my mind on the isApproxEq function and want to change it slightly.

I was talking to some kernel engineers that work with high performance cpu and gpu kernels of various kinds.
They suggest two changes from the numpy version:

  1. Make the function symmetric
  2. use the max of the absolute and relative error instead of the sum of the two.

A bit of detail about why.

For symmetric vs asymmetric. The fear of using symmetric is that it can allow for more total relative error. Imagine an relative tolerance of 0.10. In that case a/b might be 1.11 which is a failure, but b/a would then be 0.90 which is a success. This is a true statement, but anyone using a relative tolerance of 0.10 already has crazy giant issues, so this doesn't matter anyway. In practice, relative tolerance is multiple orders of magnitude smaller then this. So the affect of switching arg order is negligible.
The gains for having a symmetric version are quite obvious:

The second change is to use max instead of sum of tolerances. That is to say instead of <= atol + rtol * Num.abs refValue, it is <= Num.max atol (rtol * Num.abs refValue). This decouples absolute and relative tolerances such that they become solo concepts that you can think about in isolation. Instead of verifying some conflated and merged absolute and relative tolerance, you are verifying either a match for the absolute tolerance or a match for the relative tolerance. This matches how people expect this function to work and gives more precisely scoped results.

So I think I want to change the implementation to hold these two properties. This would make the implementation:

isApproxEq = \x, y, { rtol ? 0.00001, atol ? 0.00000001 } ->
    eq = x <= y && x >= y
    meetsTolerance = Num.absDiff x y <= Num.max atol (rtol * Num.max (Num.abs x) (Num.abs y))
    eq || meetsTolerance

view this post on Zulip Richard Feldman (Feb 02 2024 at 23:51):

makes sense to me! :thumbs_up:


Last updated: Jun 16 2026 at 16:19 UTC