You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Improve accuracy of stdev functions in statistics #90034
The standard deviation computation in the statistics module is still subject to error even though the mean and sum of square differences are computed exactly using fractions.
The problem is that the exact fraction gets rounded to a float before going into math.sqrt() which also rounds.
It would be better to compute a high precision square root directly from the exact fraction rather than from a fraction rounded to a float.
Here are two ways to do it. With Cpython, the second way is twice as fast. With PyPy3, the speed is about the same.
deffrac_sqrt(x: Fraction) ->float:
'Return sqrt to greater precision than math.sqrt()'# Needed because a correctly rounded square root of# a correctly rounded input can still be off by 1 ulp.# Here we avoid the initial rounding and work directly# will the exact fractional input. The square root# is first approximated with math.sqrt() and then# refined with a divide-and-average step. Since the# Newton-Raphson algorithm has quadratic convergence,# one refinement step gives excellent accuracy.a=Fraction(math.sqrt(x))
returnfloat((x/a+a) /2)
defdeci_sqrt(x: Fraction) ->float:
ratio=Decimal(x.numerator) /Decimal(x.denominator)
returnfloat(ratio.sqrt())
I'm not sure this is worth worrying about. We already have a very tight error bound on the result: if x is a (positive) fraction and y is the closest float to x, (and assuming IEEE 754 binary64, round-ties-to-even, no overflow or underflow, etc.) then math.sqrt(y) will be in error by strictly less than 1 ulp from the true value √x, so we're already faithfully rounded. (And in particular, if the std. dev. is exactly representable as a float, this guarantees that we'll get that standard deviation exactly.)
One thought: would the deci_sqrt approach help with value ranges where the values are well within float limits, but the squares of the values are not? E.g., on my machine, I currently get errors for both of the following:
>> xs = [random.normalvariate(0.0, 1e200) for _ in range(10**6)]
>> statistics.stdev(xs)
>> xs = [random.normalvariate(0.0, 1e-200) for _ in range(10**6)]
>> statistics.stdev(xs)
It's hard to imagine that there are too many use-cases for values of this size, but it still feels a bit odd to be constrained to only half of the dynamic range of float.
Instead of writing simple, mostly accurate code with math.fsum(), these functions have already applied labor intensive measures to get an exact mean and exact sum of square differences expressed in fractions. Then at the final step before the square root, it prematurely rounds to a float. This is easy to fix and has a single step cost comparable to that already paid for each input datum.
In a sports analogy, we've run the ball almost the full length of the field and then failed to put the ball over the goal line.
Part of the module's value proposition is that it strives to be more accurate than obvious implementations. The mean, variance, and pvariance function are correctly rounded. In this regard, the stdev and pstdev functions are deficient, but they could easily be made to be almost always correctly rounded.
One thought: would the deci_sqrt approach help with value ranges
where the values are well within float limits, but the squares
of the values are not?
Yes, the Emin and Emax for the default context is already almost big enough:
we've run the ball almost the full length of the field and then failed to put the ball over the goal line
But if we only go from "faithfully rounded" to "almost always correctly rounded", it seems to me that we're still a couple of millimetres away from that goal line. It wouldn't be hard to go for _always_ correctly rounded and actually get it over.
Yes, the Emin and Emax for the default context is already almost big enough
I'm confused: big enough for what? I was thinking of the use-case where the inputs are all floats, in which case an Emax of 999 and an Emin of -999 would already be more than big enough.
Does the technique you had in mind involve testing 1 ulp up or down to see whether its square is closer to the input?
Kinda sorta. Below is some code: it's essentially just pure integer operations, with a last minute conversion to float (implicit in the division in the case of the second branch). And it would need to be better tested, documented, and double-checked to be viable.
defisqrt_rto(n):
""" Square root of n, rounded to the nearest integer using round-to-odd. """a=math.isqrt(n)
returna| (a*a!=n)
defisqrt_frac_rto(n, m):
""" Square root of n/m, rounded to the nearest integer using round-to-odd. """quotient, remainder=divmod(isqrt_rto(4*n*m), 2*m)
returnquotient|bool(remainder)
defsqrt_frac(n, m):
""" Square root of n/m as a float, correctly rounded. """quantum= (n.bit_length() -m.bit_length() -1) //2-54ifquantum>=0:
returnfloat(isqrt_frac_rto(n, m<<2*quantum) <<quantum)
else:
returnisqrt_frac_rto(n<<-2*quantum, m) / (1<<-quantum)
Here's the float-and-Fraction-based code that I'm using to compare the integer-based code against:
defsqrt_frac2(n, m):
""" Square root of n/m as a float, correctly rounded. """f=fractions.Fraction(n, m)
# First approximation.x=math.sqrt(n/m)
# Use the approximation to find a pair of floats bracketing the actual sqrtiffractions.Fraction(x)**2>=f:
x_lo, x_hi=math.nextafter(x, 0.0), xelse:
x_lo, x_hi=x, math.nextafter(x, math.inf)
# Check the bracketing. If math.sqrt is correctly rounded (as it will be on a
# typical machine), then the assert can't fail. But we can't rely on math.sqrt being
# correctly rounded in general, so would need some fallback.
fx_lo, fx_hi = fractions.Fraction(x_lo), fractions.Fraction(x_hi)
assert fx_lo**2 <= f <= fx_hi**2
# Compare true square root with the value halfway between the two floats.mid= (fx_lo+fx_hi) /2ifmid**2<f:
returnx_hielifmid**2>f:
returnx_loelse:
# Tricky case: mid**2 == f, so we need to choose the "even" endpoint.# Cheap trick: the addition in 0.5 * (x_lo + x_hi) will round to even.return0.5* (x_lo+x_hi)
Mark, ya, MS's Visual Studio's ldexp() has, far as I know, always worked this way. The code I showed was run under the 2019 edition, which we use to build the Windows CPython.
Both, not sure it helps, but this use of round-to-odd appears akin to the decimal module's ROUND_05UP, which rounds an operation result in such a way that, if it's rounded again - under any rounding mode - to a narrower precision, you get the same narrower result as if you had used that rounding mode on the original operation to that narrower precision to begin with.
Decimal only needs to adjust the value of the last retained digit to, effectively, "encode" all possibilities, but binary needs two trailing bits. "Round" and "sticky" are great names for them :-)
Note that, on Windows, ldexp() in the presence of
denorms can truncate. Division rounds, so
assert x / 2**i == ldexp(x, -i)
can fail.
Objects/longobject.c::long_true_divide() uses ldexp() internally. Will it suffer the same issues with subnormals on Windows? Is CPython int/int true division guaranteed to be correctly rounded?
Will it suffer the same issues with subnormals on Windows?
No, it should be fine. All the rounding has already happened at the point where ldexp is called, and the result of the ldexp call is exact.
Is CPython int/int true division guaranteed to be correctly rounded?
Funny you should ask. :-) There's certainly no documented guarantee, and there _is_ a case (documented in comments) where the current code may not return correctly rounded results on machines that use x87: there's a fast path where both numerator and denominator fit into an IEEE 754 double without rounding, and we then do a floating-point division.
But we can't hit that case with the proposed code, since the numerator will always have at least 55 bits, so the fast path is never taken.
Objects/longobject.c::long_true_divide() uses ldexp() internally.
Will it suffer the same issues with subnormals on Windows?
Doesn't look like it will. In context, looks like it's ensuring that ldexp can only lose trailing 0 bits, so that _whatever_ ldexp does in the way of rounding is irrelevant. But it's not doing this because of Windows - it's to prevent "double-rounding" errors regardless of platform.
Is CPython int/int true division guaranteed to be correctly rounded?
If there's some promise of that in the docs, I don't know where it is. But the code clearly intends to strive for correct rounding. Ironically, PEP-238 guarantees that if it is correctly rounded, that's purely by accident ;-) :
"""
True division for ints and longs will convert the arguments to float and then apply a float division. That is, even 2/1 will return a float
"""
But i/j is emphatically not implemented via float(i)/float(j).
from which (assuming IEEE 754 as usual) shift >= -1076. (DBL_MIN_EXP = -1021, DBL_MANT_DIG = 53)
Here we round away the last two or three bits of x, after which x is guaranteed to be a multiple of 4:
x->ob_digit[0] = low & ~(2U*mask-1U);
Then after converting the PyLong x to a double dx with exactly the same value here we make the ldexp call:
result=ldexp(dx, (int)shift);
At this point dx is a multiple of 4 and shift >= -1076, so the result of the ldexp scaling is a multiple of 2**-1074, and in the case of a subnormal result, it's already exactly representable.
For the int/int division possibly not being correctly rounded on x87, see here.
It won't affect _this_ application, but possibly we should fix this anyway. Though the progression of time is already effectively fixing it for us, as x87 becomes less and less relevant.
Concrete example of int/int not being correctly rounded on systems using x87 instructions: on those systems, I'd expect to see 1/2731 return a result of 0.00036616623947272064 (0x1.7ff4005ffd002p-12), as a result of first rounding to 64-bit precision and then to 53-bit. The correctly-rounded result is 0.0003661662394727206 (0x1.7ff4005ffd001p-12).
It won't affect _this_ application, but possibly we should
fix this anyway.
I would like to see this fixed. It affects our ability to
reason about int/int code. That comes up every time a
fraction is fed into a math library function than converts
its input to a float.
But I would like to leave it alone. Extended precision simply is not an issue on any current platform I'm aware of ("not even Windows"), and I would, e.g., hate trying to explain to users why
1 / 2731 != 1.0 / 2731.0
(assuming we're not also proposing to take float division away from the HW). It's A Feature that
I / J == float(I) / float(J)
whenever I and J are both representable as floats.
If extended precision is an issue on some platform, fine, let them speak up. On x87 we could document that CPython assumes the FPU's "precision control" is set to 53 bits.
I have been reading this whole thread. Thank you all. I am in awe and a little bit intimidated by how much I still have to learn about floating point arithmetic.
Activity
rhettinger commentedon Nov 23, 2021
The standard deviation computation in the statistics module is still subject to error even though the mean and sum of square differences are computed exactly using fractions.
The problem is that the exact fraction gets rounded to a float before going into math.sqrt() which also rounds.
It would be better to compute a high precision square root directly from the exact fraction rather than from a fraction rounded to a float.
Here are two ways to do it. With Cpython, the second way is twice as fast. With PyPy3, the speed is about the same.
mdickinson commentedon Nov 23, 2021
I'm not sure this is worth worrying about. We already have a very tight error bound on the result: if
xis a (positive) fraction andyis the closest float to x, (and assuming IEEE 754 binary64, round-ties-to-even, no overflow or underflow, etc.) thenmath.sqrt(y)will be in error by strictly less than 1 ulp from the true value √x, so we're already faithfully rounded. (And in particular, if the std. dev. is exactly representable as a float, this guarantees that we'll get that standard deviation exactly.)mdickinson commentedon Nov 23, 2021
One thought: would the deci_sqrt approach help with value ranges where the values are well within float limits, but the squares of the values are not? E.g., on my machine, I currently get errors for both of the following:
It's hard to imagine that there are too many use-cases for values of this size, but it still feels a bit odd to be constrained to only half of the dynamic range of float.
rhettinger commentedon Nov 23, 2021
Instead of writing simple, mostly accurate code with math.fsum(), these functions have already applied labor intensive measures to get an exact mean and exact sum of square differences expressed in fractions. Then at the final step before the square root, it prematurely rounds to a float. This is easy to fix and has a single step cost comparable to that already paid for each input datum.
In a sports analogy, we've run the ball almost the full length of the field and then failed to put the ball over the goal line.
Part of the module's value proposition is that it strives to be more accurate than obvious implementations. The mean, variance, and pvariance function are correctly rounded. In this regard, the stdev and pstdev functions are deficient, but they could easily be made to be almost always correctly rounded.
Yes, the Emin and Emax for the default context is already almost big enough:
We could bump that up to fully envelop operations on the sum of squares:
mdickinson commentedon Nov 23, 2021
But if we only go from "faithfully rounded" to "almost always correctly rounded", it seems to me that we're still a couple of millimetres away from that goal line. It wouldn't be hard to go for _always_ correctly rounded and actually get it over.
I'm confused: big enough for what? I was thinking of the use-case where the inputs are all floats, in which case an Emax of 999 and an Emin of -999 would already be more than big enough.
rhettinger commentedon Nov 23, 2021
Yes, that would be the right thing to do.
Does the technique you had in mind involve testing 1 ulp up or down to see whether its square is closer to the input?
Right. In haste, I confused max_exp=1024 with max_10_exp=308.
Am still thinking that the precision needs to be bumped up a bit the 28 place default.
mdickinson commentedon Nov 23, 2021
Kinda sorta. Below is some code: it's essentially just pure integer operations, with a last minute conversion to float (implicit in the division in the case of the second branch). And it would need to be better tested, documented, and double-checked to be viable.
mdickinson commentedon Nov 23, 2021
Here's the float-and-Fraction-based code that I'm using to compare the integer-based code against:
rhettinger commentedon Nov 23, 2021
Should the last line of sqrt_frac() be wrapped with float()?
mdickinson commentedon Nov 23, 2021
It's already a float - it's the result of an int / int division.
mdickinson commentedon Nov 23, 2021
Hmm. isqrt_frac_rto is unnecessarily complicated. Here's a more streamlined version of the code.
12 remaining items
tim-one commentedon Nov 26, 2021
Mark, ya, MS's Visual Studio's ldexp() has, far as I know, always worked this way. The code I showed was run under the 2019 edition, which we use to build the Windows CPython.
Raymond,
is screamingly obvious at first glance.
Both, not sure it helps, but this use of round-to-odd appears akin to the decimal module's ROUND_05UP, which rounds an operation result in such a way that, if it's rounded again - under any rounding mode - to a narrower precision, you get the same narrower result as if you had used that rounding mode on the original operation to that narrower precision to begin with.
Decimal only needs to adjust the value of the last retained digit to, effectively, "encode" all possibilities, but binary needs two trailing bits. "Round" and "sticky" are great names for them :-)
rhettinger commentedon Nov 26, 2021
[Tim]
Objects/longobject.c::long_true_divide() uses ldexp() internally. Will it suffer the same issues with subnormals on Windows? Is CPython int/int true division guaranteed to be correctly rounded?
mdickinson commentedon Nov 26, 2021
No, it should be fine. All the rounding has already happened at the point where ldexp is called, and the result of the ldexp call is exact.
Funny you should ask. :-) There's certainly no documented guarantee, and there _is_ a case (documented in comments) where the current code may not return correctly rounded results on machines that use x87: there's a fast path where both numerator and denominator fit into an IEEE 754 double without rounding, and we then do a floating-point division.
But we can't hit that case with the proposed code, since the numerator will always have at least 55 bits, so the fast path is never taken.
tim-one commentedon Nov 26, 2021
Doesn't look like it will. In context, looks like it's ensuring that ldexp can only lose trailing 0 bits, so that _whatever_ ldexp does in the way of rounding is irrelevant. But it's not doing this because of Windows - it's to prevent "double-rounding" errors regardless of platform.
If there's some promise of that in the docs, I don't know where it is. But the code clearly intends to strive for correct rounding. Ironically, PEP-238 guarantees that if it is correctly rounded, that's purely by accident ;-) :
"""
True division for ints and longs will convert the arguments to float and then apply a float division. That is, even 2/1 will return a float
"""
But i/j is emphatically not implemented via float(i)/float(j).
mdickinson commentedon Nov 26, 2021
Sketch of proof:
Here we have:
from which (assuming IEEE 754 as usual) shift >= -1076. (DBL_MIN_EXP = -1021, DBL_MANT_DIG = 53)
Here we round away the last two or three bits of x, after which x is guaranteed to be a multiple of 4:
Then after converting the PyLong x to a double dx with exactly the same value here we make the ldexp call:
At this point dx is a multiple of 4 and shift >= -1076, so the result of the ldexp scaling is a multiple of 2**-1074, and in the case of a subnormal result, it's already exactly representable.
For the int/int division possibly not being correctly rounded on x87, see here.
It won't affect _this_ application, but possibly we should fix this anyway. Though the progression of time is already effectively fixing it for us, as x87 becomes less and less relevant.
mdickinson commentedon Nov 26, 2021
Concrete example of int/int not being correctly rounded on systems using x87 instructions: on those systems, I'd expect to see 1/2731 return a result of 0.00036616623947272064 (0x1.7ff4005ffd002p-12), as a result of first rounding to 64-bit precision and then to 53-bit. The correctly-rounded result is 0.0003661662394727206 (0x1.7ff4005ffd001p-12).
rhettinger commentedon Nov 27, 2021
I would like to see this fixed. It affects our ability to
reason about int/int code. That comes up every time a
fraction is fed into a math library function than converts
its input to a float.
tim-one commentedon Nov 27, 2021
But I would like to leave it alone. Extended precision simply is not an issue on any current platform I'm aware of ("not even Windows"), and I would, e.g., hate trying to explain to users why
(assuming we're not also proposing to take float division away from the HW). It's A Feature that
whenever I and J are both representable as floats.
If extended precision is an issue on some platform, fine, let them speak up. On x87 we could document that CPython assumes the FPU's "precision control" is set to 53 bits.
stevendaprano commentedon Nov 27, 2021
Raymond, Mark, Tim,
I have been reading this whole thread. Thank you all. I am in awe and a little bit intimidated by how much I still have to learn about floating point arithmetic.
rhettinger commentedon Nov 27, 2021
New changeset af9ee57 by Raymond Hettinger in branch 'main':
bpo-45876: Improve accuracy for stdev() and pstdev() in statistics (GH-29736)
af9ee57
rhettinger commentedon Nov 27, 2021
Thank you all for looking at this. It's unlikely that anyone will ever notice the improvement, but I'm happy with it and that's all the matters ;-)
rhettinger commentedon Dec 1, 2021
New changeset a39f46a by Raymond Hettinger in branch 'main':
bpo-45876: Correctly rounded stdev() and pstdev() for the Decimal case (GH-29828)
a39f46a
rhettinger commentedon Dec 1, 2021
New changeset 0aa0bd0 by Raymond Hettinger in branch 'main':
bpo-45876: Have stdev() also use decimal specific square root. (GH-29869)
0aa0bd0