When would a Python float lose precision when cast to Protobuf/C++ float?

Question:

I’m interested in minimising the size of a protobuf message serialised from Python.

Protobuf has floats (4 bytes) and doubles (8 bytes). Python has a float type that’s actually a C double, at least in CPython.

My question is: given an instance of a Python float, is there a “fast” way of checking if the value would lose precision if it was assigned to a protobuf float (or really a C++ float) ?

Asked By: MarkNS

||

Answers:

You can check convert the float to a hex representation; the sign, exponent and fraction each get a separate section. Provided the fraction uses only the first 6 hex digits (the remaining 7 digits must be zero), and the 6th digit is even (so the last bit is not set) will your 64-bit double float fit in a 32-bit single. The exponent is limited to a value between -126 and 127:

import math
import re

def is_single_precision(
        f,
        _isfinite=math.isfinite,
        _singlepat=re.compile(
            r'-?0x[01].[0-9a-f]{5}[02468ace]0{7}p'
            r'(?:+(?:1[01]d|12[0-7]|[1-9]d|d)|'
            r'-(?:1[01]d|12[0-6]|[1-9]d|d))$').match):
    return not _isfinite(f) or _singlepat(f.hex()) is not None or f == 0.0

The float.hex() method is quite fast, faster than roundtripping via struct or numpy; you can create 1 million hex representations in under half a second:

>>> timeit.Timer('(1.2345678901e+26).hex()').autorange()
(1000000, 0.47934128501219675)

The regex engine is also pretty fast, and with name lookups optimised in the function above we can test 1 million float values in about 1.1 seconds:

>>> import random, sys
>>> testvalues = [0.0, float('inf'), float('-inf'), float('nan')] + [random.uniform(sys.float_info.min, sys.float_info.max) for _ in range(2 * 10 ** 6)]
>>> timeit.Timer('is_single_precision(f())', 'from __main__ import is_single_precision, testvalues; f = iter(testvalues).__next__').autorange()
(1000000, 1.1044921400025487)

The above works because the binary32 format for floats allots 23 bits for the fraction. The exponent is allotted 8 bits (signed). The regex only allows for the first 23 bits to be set, and the exponent to be within the range for a signed 8-bit number.

Also see

This may not be what you want however! Take for example 1/3rd or 1/10th. Both are values which require approximation in floating point values, and both fail the test:

>>> (1/3).hex()
'0x1.5555555555555p-2'
>>> (1/10).hex()
'0x1.999999999999ap-4'

You may have to instead take a heuristic approach; if your hex value has all zeros in the first 6 digits of the fraction, or an exponent outside of the (-126, 127) range, converting to double would lead to too much loss.

Answered By: Martijn Pieters

If you want a simple solution that covers almost all corner cases, and will correctly detect out-of-range exponents as well as loss of information from the smaller precision, you can use NumPy to convert your potential float into an np.float32 object, then compare with the original:

import numpy

def is_single_precision_numpy(floatval, _float32=np.float32):
    return _float32(floatval) == floatval

This automatically takes care of potentially problematic cases like values that are in the float32 subnormal range. For example:

>>> is_single_precision_numpy(float.fromhex('0x13p-149'))
True
>>> is_single_precision_numpy(float.fromhex('0x13.8p-149'))
False

Those cases are harder to deal with easily with the hex-based solution.

While not as fast as @Martijn Pieters’ regex-based solution, the speed is still respectable (about half as fast as the regex-based solution). Here are timings (where is_single_precision_re_hex is exactly the version from Martijn’s answer).

>>> timeit.Timer('is_single_precision_numpy(f)', 'f = 1.2345678901e+26; from __main__ import is_single_precision_numpy').repeat(3, 10**6)
[2.035495020012604, 2.0115931580075994, 2.013475093001034]
>>> timeit.Timer('is_single_precision_re_hex(f)', 'f = 1.2345678901e+26; from __main__ import is_single_precision_re_hex').repeat(3, 10**6)
[1.1169273109990172, 1.1178153319924604, 1.1184561859990936]

Unfortunately, while almost all corner cases (subnormals, infinities, signed zeros, overflows, etc.) are handled correctly, there’s one corner case that this solution won’t work for: the case that floatval is a NaN. In that case, is_single_precision_numpy will return False. That may or may not matter for your needs. If it does matter, then adding an extra isnan check should do the trick:

import math
import numpy as np

def is_single_precision_numpy(floatval, _float32=np.float32, _isnan=math.isnan):
    return _float32(floatval) == floatval or _isnan(floatval)
Answered By: Mark Dickinson

For completeness, here is the “round tripping through struct” method mentioned in the comments, which has the benefit of not requiring numpy but still giving accurate results:

import struct, math
def is_single_precision_struct(x, _s=struct.Struct("f")):
    return math.isnan(x) or _s.unpack(_s.pack(x))[0] == x

Time comparison against is_single_precision_numpy():

  • is_single_precision_numpy(f): [2.5650789737701416, 2.5488431453704834, 2.551704168319702]
  • is_single_precision_struct(f): [0.3972139358520508, 0.39684605598449707, 0.39119601249694824]

So it also seems to be faster on my machine.

Answered By: jpa
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.