Comparing Floating-Point Numbers
Floating-point arithmetic only has a finite set of numbers to work with. This can lead to subtle precision issues. Even the same calculation, when done in different ways, can yield slightly different outcomes. This leads to unexpected results when performing direct comparisons. To handle these challenges, it's important to use methods that account for tiny differences and determine whether values are sufficiently close to be considered equal.
The Compare class addresses this challenge by providing flexible methods for comparing floating-point numbers. It offers methods for checking equality within a tolerance and evaluating relative differences, ensuring that comparisons are robust and reliable. This class helps you avoid common pitfalls in floating-point operations, promoting accuracy and consistency in your numerical computations.
All the methods are generic, so you can use them with any numeric type.
How Close is Close Enough?
How close should two numbers be to be considered equal? There are multiple ways of answering this question. Which one is appropriate depends on the context. In general, the tolerance is a small number that is used to determine whether two numbers are close enough. The tolerance can take several forms:
- Absolute tolerance
The maximum allowable difference between two floating-point numbers for them to be considered equal. This is a fixed threshold, often used when the values being compared are close to zero.
- Relative tolerance
The maximum allowable difference between two floating-point numbers, expressed as a fraction of the larger of the two values. This accounts for differences in scale, making it more appropriate when comparing numbers that are far from zero.
- Units in the last place (ulp)
The maximum allowable difference between two floating-point numbers, expressed in terms of the number of representable floating-point values between them. This is a kind of relative tolerance that is independent of the scale of the numbers being compared.
- Combined tolerance
A tolerance that combines both absolute and relative tolerances. This allows for a flexible comparison that works for both small and large numbers, considering both fixed differences and scale-dependent differences.
A minor but important detail is whether the comparison should be symmetric, or whether one of the numbers should be considered the reference value. For example, in a root finding algorithm, the function value at successive approximations of the root is compared to zero. In this case, zero is the reference value.
In other cases, both numbers are subject to change, and the comparison should be symmetric. Using the same example, when comparing two approximations of the root, neither value is more important than the other.
Two more options are available to fine-tune comparisons involving numbers that are not finite. Not-a-Number (NaN) values are famously unordered. This means that traditionally, any comparison between a NaN and another number, including other NaN's, returns false. This may not be desirable in all cases.
Likewise, comparing to infinities is ambiguous. The difference between two infinities as well as their quotient are not defined. From this perspective, any comparison involving infinities should return false. Most often, this is not the desired behavior.
Comparing Two Numbers
The Compare class provides methods for comparing a value to a reference value. These are extension methods, so they can be called directly on the value or set of values being compared. There are different methods for each type of tolerance:
Method | Description |
---|---|
Determines if the value is close to the reference value within the given absolute and relative tolerances. | |
Determines if the value is close to the reference value within the given absolute tolerance. | |
Determines if the value is close to the reference value within the given relative tolerance. | |
Determines if the value is close to the reference value within the given units in the last place. |
As an example, consider the following code snippet that compares two numbers:
double x = 1.0;
double y = 1.0 + 1e-15;
Console.WriteLine("x == y: {0}", x == y);
Console.WriteLine("x ~= y: {0}", x.AlmostEquals(y));
The two numbers are different, but they are very close, and so the comparison returns true. When we check a number that is not as close, the comparison returns false. We can specify the tolerance to use. Here we'll use the absolute tolerance:
double z = 1.0 + 1e-10;
Console.WriteLine("x == z: {0}", x == z);
Console.WriteLine("x ~= z (default): {0}", x.AlmostEqualsAbsolute(z));
Console.WriteLine("x ~= z (within 1e-8): {0}", x.AlmostEqualsAbsolute(z, 1e-8));
You can compare numbers of any type. It is well known that 355/113 is an excellent approximation for pi. The following code snippet shows that the approximation is accurate to 6 digits by comparing the two numbers as BigRational numbers:
var pi = (BigRational)Math.PI;
var piApprox = new BigRational(355, 113);
var tolerance = BigRational.Pow(10, -6);
var accurate = piApprox.AlmostEqualsAbsolute(pi, tolerance);
Console.WriteLine($"355/113 == pi up to 6 digits? {accurate}");
Comparing Sequences of Numbers
A number of methods allow you to compare collections of numbers. There are two variations: all the numbers may be compared to the same reference value, or they may be compared to the corresponding items in another collection. There are overloads that take spans, arrays, or enumerables. As before, there are different methods for each type of tolerance:
Method | Description |
---|---|
Determines if the values in a collection are close to the reference value(s) within the given absolute and relative tolerances. | |
Determines if the values in a collection are close to the reference value(s) within the given absolute tolerance. | |
Determines if the values in a collection are close to the reference value(s) within the given relative tolerance. | |
Determines if the values in a collection are close to the reference value(s) within the given units in the last place. |
Here's a simple example that uses relative tolerances. We use larger numbers to show the difference. We create two arrays and check individually if corresponding elements in the arrays are close enough:
double[] a = { 1000.0, 2000.0, 3000.0 };
double[] b = { 1000.0 + 1e-6, 2000.0, 3000.0 - 1e-6 };
Console.WriteLine("a ~= b: {0} (default)", a.SequenceAlmostEqualRelative(b));
Console.WriteLine("a ~= b: {0} (within 1e-10)", a.SequenceAlmostEqualRelative(b, 1e-10));
You can also compare all values in one array to one single value:
double[] c = { 2000.0 + 1e-6, 2000.0, 2000.0 - 1e-6 };
double c0 = 2000;
Console.WriteLine("c ~= c0: {0} (default)", c.SequenceAlmostEqualRelative(c0));
Console.WriteLine("c ~= c0: {0} (within 1e-10)", c.SequenceAlmostEqualRelative(c0, 1e-10));
Setting Defaults
You can set default values for the tolerances used in comparisons. These defaults are used when the corresponding parameters are omitted.
Defaults are set using the SetDefaultAbsoluteTolerance<T> and SetDefaultRelativeTolerance<T> methods for the absolute and relative tolerance, respectively. The tolerances are specific for each type. Some caution is in order to ensure that implicit conversions do not lead to unexpected results.
You can call the GetDefaultRelativeTolerance<T> and GetDefaultAbsoluteTolerance<T> methods to retrieve the current default tolerances. You must supply the type of the values being compared as a type argument.