SE-0259: Approximate Equality for Floating Point

+1

One thing I wonder: Can't we do this as well?

guard !other.isZero else {
  return self.isAlmostZero(tolerance: tolerance)
}
5 Likes

I like this proposal, though it is a bit dissonant that a positive value can be almostZero while simultaneously not being almostEqual(to: .leastNonzeroMagnitude):

let a = Double.leastNonzeroMagnitude
let b = 1.0e-9

b.isAlmostZero()          // true
b.isAlmostEqual(to: a)    // false

I understand why this is the case, but it’s still strange.

• • •

Also, while looking over the implementation, I find that the following initializer (which already exists on FloatingPoint and has nothing to do with the current proposal except that it is used there and I noticed it) has a misleading name:

init(sign: FloatingPointSign, exponent: Exponent, significand: Self)

I get that it is a useful operation, but when reading it in source code:

let scaledOther = Self(sign: .plus,
                       exponent: -1,
                       significand: other)

At the point of use this really looks like it should produce a value whose exponent is negative one, and whose significand is copied from other. But what it actually does is add negative one to the existing exponent of other.

I know, too late to change and not relevant to the current proposal, but I wanted to mention it anyway.

2 Likes

Well, it can be used to do that, it can just also be used to do some other tricks, like this one =) If you pass a significand in [1,2), it does exactly what it says on the tin. Passing "out of range" significands like this was deliberately not disallowed because it could be made fully defined and allows nice party tricks.

The addition of isAlmostZero seems a little odd to me; just looking at some code, I think that I would expect isAlmostEqual(to: 0) to behave the same as isAlmostZero(). Is it possible that to: 0 could be a special case for isAlmostEqual?

In addition to personally supporting the isApproximately spelling, looking at the alternatives considered, I feel that introducing an approximate equality operator makes a great deal of sense, as it seems likely that isAlmostEqual would be most commonly used without a manually-specified threshold. However, I feel that the above suggestion to unify isAlmostEqual and isAlmostZero is necessary for an operator to make logical sense.

1 Like

Translate this into Latin and it can go on IEEE 754's family crest.

34 Likes

+1
This would be a great addition! Will ≈ be added to Swift's current list of operators?

Intelligo quod ita sit, sed usque non est mirum.

5 Likes
  • What is your evaluation of the proposal?

Big +1, I have written the naive version many times in the past.

  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes

  • Does this proposal fit well with the feel and direction of Swift?

Yes. I have a slight preference for a combinations of alternatives mentioned by others in this thread, but haven’t thought through the implications. I’m curious if there's something I'm missing in this alternative:

  1. Named isApproximately
  2. Finite isn’t approximate to infinite (very weak opinion, curious what tradeoffs are)
  3. One API that explicitly checks for zero
extension FloatingPoint {
  public func isApproximately(
    _ other: Self,
    relativeTolerance: Self = Self.ulpOfOne.squareRoot(),
    zeroAbsoluteTolerance zeroTolerance: Self = Self.ulpOfOne.squareRoot()
  ) -> Bool {
    if self.isNaN || other.isNaN { return false }
    if self.isInfinite { return self == other }
    if other == 0 {
      _precondition(zeroTolerance > 0,
        "zeroAbsoluteTolerance should be greater than zero")
      return abs(self) < zeroTolerance
    }
    _precondition(relativeTolerance >= .ulpOfOne && relativeTolerance < 1,
      "relativeTolerance should be in [.ulpOfOne, 1).")
    ...
  }
}

Reasons for 1 API preference:

  1. It’s going to be very rare that a user supplies their own tolerance, so having one additional parameter is unlikely to be an additional burden.
  2. Concern for zero seems like something that would affect most usage and as proposed it requires reading the documentation and coding vigilance.
  3. other might be dynamically zero and, IIUC, this means callers would frequently have let closeEnough = other == 0 ? x.isAlmostZero() : x.isAlmostEqual(to: other), which seems obnoxious (and error prone).

What are the main arguments for two separate APIs over one with two parameters?

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

I don't recall.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Brief reading

4 Likes

+1.0000000000000002 for all the reasons already listed :). This is a problem I've always known about but never bothered to solve with anything more than the naive abs(x - y) < threshold solution. This feels like a perfect candidate for inclusion in the standard library. Also minorly in favor of the isApproximately naming suggestion, but support the proposal either way.

13 Likes

Any easy proposal to support.

With regards to isApproximately versus isAlmostEqual, one advantage of the latter is that it would appear in autocomplete lists for the word equal, but I wonder if that would matter much in practice. As an API, isAlmostEqual(to:) matches the existing isEqual(to:).

11 Likes

+1

This is a wonderful thing to have as a language level feature. Swift is a wonderful language for numeric use and this provides a sensible solution to an issue even casual users of floating point math run into.

I have one question on the semantics of the tolerance argument. A common form for tolerance equations is:

abs(self - other) < scale * relativeTolerance + absoluteTolerance

In the proposal isAlmostEqual(to:tolerance:) holds the absoluteTolerance from that equation at 0 and takes an argument for relativeTolerance. I wonder if we should expose both as arguments?

If we only expose one as an argument I wonder if absoluteTolerance is actually more what a common user expects the argument to control? With absoluteTolerance: 10e-7 and relativeTolerance held at a sensible default like the one in the proposal the user gets values within the 10e-7 they requested plus an allowance for machine representation. That might be what they want a decent amount of the time?

The proposal's isAlmostZero(absoluteTolerance:) is nice and specific about what its tolerance argument is talking about.

Overall very pleased to see the language growing in this direction.

+1 for sure. But I also wonder why we need the extra .isAlmostZero() instead of catching the zero-equality of the argument and returning the alternate evaluation, especially given @Michael_Ilseman 's #3 reason... If I don't know that what I'm comparing to == 0 how can I know to call the .isAlmostZero() function?

2 Likes

+1 with sympathy for comments of compiler warnings for anti-patterns by @nrith and @allevato’s comments. I also have sympathy for the “is approximately” naming suggestion by @bwm003.

+1 based on a read-through of the proposal and no particular floating point expertise other than general use. A nice and useful addition.

There is some subtlety involved in understand why zero requires a special case. Having read the proposal, I would now know to use isAlmostZero(). However, my alternate-universe self that didn't read this thread would be unlikely to use isAlmostZero() even if he saw it in the API; he'd just remember the general case and stick a 0 in there.

Much of the motivation for this proposal centers on the fact that developers try to make these approximate floating point comparisons but usually do it wrong. I would extend that logic to the zero case in this proposal as well: isAlmostEqual(to: x) when x == 0 should just call the zero-specific function. Yes, that probably creates inconsistencies around zero, but it's better than doing the clearly-wrong thing.

I too prefer isApproximately() to isAlmostEqual(to:). Just as an English word, "almost" suggests "not quite as much as" rather than "in the neighborhood of", which is a bit off. "Nearly" might address that issue, but "approximately" is most unambiguous of all.

2 Likes

Note that if we change the signature to:

isAlmostEqual(to:relativeTolerance:absoluteTolerance)

and default absoluteTolerance to something more than zero then we don't need isAlmostZero anymore. The problem with comparing to zero is that relativeTolerance by itself isn't enough.

If you have both absolute and relative terms then reasonable defaults I would suggest people to try are relativeTolerance = 2 * Self.ulpOfOne and absoluteTolerance = Self.ulpOfOne.squareRoot().

+1 to the inclusion of this proposal. I’m also in favour of isApproximately or approximatelyEquals over isAlmostEqual. I’ll leave discussion of the semantics of the method to those with more expertise, other than to say what has been explained makes sense to me.

Latin side note

Off-topic, but I can’t resist: if I’m remembering my high school Latin right, something like “sit cognobilis quamquam etiam peregrinus remanet” might be a better translation - literally “although it may be understandable, nevertheless even now it remains strange.” Google Translate generally does a very poor job with Latin; what it got is not quite nonsense but has numerous grammatical issues and word choice problems - e.g. “mirum” means “strange and marvellous”, which I don’t think is the sense of the word meant here.

Edit: or correction by the way of @nrith’s now-deleted post in case it isn’t reposted: “cognobile sit, quamquam etiam inusitum est”

2 Likes

IIUC, you're proposing a hybrid approach. Is this the alternative discussed in the proposal?

Why not a single function using a hybrid relative/absolute tolerance? Because--as a library--we do not know the scaling of user's inputs a priori, this will be wrong more often than right. The scale invariance (up to underflow) of the main isAlmostEqual function is a highly-desirable property, because it mirrors the way the rest of the floating-point number system works.

What's your take on this, do you disagree and if so why?

Also, what are the reasons that 2 * Self.ulpOfOne is a better a relative tolerance in contrast to the proposal's?

Yes, it is indeed that alternative. Thank you for pointing that out and sorry for missing that in the proposal.

The assertion that scale invariance only works for the relative and not the absolute tolerance is of course true. The hybrid approach with appropriately set absolute and relative tolerances can be robust for a portion of the floating point range. For the stats/ML work I'm personally involved in that has been a good trade off, but I can see how a language's standard library might want a more pure approach.

Even if we have two functions I'd suggest that we cover more use cases by exposing both relativeTolerance and absoluteTolerance as arguments in isAlmostEqual() and defaulting absoluteTolerance to 0.

My suggested tolerances were to be taken as a pair. When absolute tolerance is zero I like the relative tolerance in the proposal. I'm afraid that I don't have a better reason for my particular suggestions than that they are somewhat commonly used (e.g. Brent's root finding algo) and have worked for me in the past.

1 Like

+1, I have always used my own flawed versions of this function

Yes. It provides a solid default answer to a real world problem.

Perhaps there should be API to get the suggested tolerance value. That way it could be used with existing tolerance within APIs that other frameworks (XCTAssertEqualWithAccuracy).

The spelling I think I like the best is:

x.approximateEqualityTolerance
x.isApproximately(3.0)
x.isApproximatelyZero()

Quick reading ... but years of doing it the wrong way. :]

2 Likes

What a great addition. +1 from me too. I hope this won't have any stdlib private API in its implementation as I would want to copy this extension into my code base because otherwise I won't be able to reach to it on older Swift versions (my project is locked to Swift 5 stdlib).