Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions dice_ml/data_interfaces/public_data_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,45 @@ def from_dummies(self, data, prefix_sep='_'):
out.drop(cols, axis=1, inplace=True)
return out

@staticmethod
def _decimal_precision_of(value):
"""Number of decimal digits in the default repr of *value*.

The historical implementation was ``len(str(value).split('.')[1])``,
which mirrors how many digits Python prints after the decimal point
in the default repr. That works for ordinary floats (``'0.25'`` → 2)
but IndexErrors when ``str(value)`` renders in scientific notation —
``str(np.float32(1e-6))`` is ``'1e-06'`` with no ``'.'`` (issue #442).

This helper preserves the original semantics for the common case and
re-derives an equivalent count from the scientific-notation form,
rather than from a re-formatted ``%.20f`` string (which leaks
float64 ↔ float32 round-off digits and inflates precision).

Examples:
``0.25`` → 2
``1e-06`` → 6 (no mantissa fraction, exponent -6)
``2.5e-3`` → 4 (mantissa '2.5' has 1 decimal, exponent -3)
``1e+16`` → 0 (exponent positive ⇒ no decimal digits)
"""
s = str(value).lower()
if 'e' not in s:
# Plain decimal repr, e.g. '0.25' or '17'.
if '.' not in s:
return 0
return len(s.split('.')[1])
# Scientific notation, e.g. '1e-06', '2.5e-3', '1e+16'.
mantissa, _, exponent = s.partition('e')
mantissa_decimals = len(mantissa.split('.')[1]) if '.' in mantissa else 0
try:
exp_value = int(exponent)
except ValueError:
return mantissa_decimals
# Decimal places needed = mantissa decimals minus the exponent shift.
# A negative exponent moves digits right; a positive one moves them
# left (and can cancel mantissa decimals out entirely).
return max(mantissa_decimals - exp_value, 0)

def get_decimal_precisions(self, output_type="list"):
""""Gets the precision of continuous features in the data."""
# if the precision of a continuous feature is not given, we use the maximum precision of the modes to capture the
Expand All @@ -393,9 +432,12 @@ def get_decimal_precisions(self, output_type="list"):
precisions_dict[col] = self.continuous_features_precision[col]
elif self.data_df[col].dtype == np.float32 or self.data_df[col].dtype == np.float64:
modes = self.data_df[col].mode()
maxp = len(str(modes[0]).split('.')[1]) # maxp stores the maximum precision of the modes
# Use _decimal_precision_of instead of raw str().split('.')[1]
# because the latter IndexErrors when the mode renders in
# scientific notation (e.g. 1e-06, 1e+16) — see #442.
maxp = self._decimal_precision_of(modes[0])
for mx in range(len(modes)):
prec = len(str(modes[mx]).split('.')[1])
prec = self._decimal_precision_of(modes[mx])
if prec > maxp:
maxp = prec
precisions[ix] = maxp
Expand Down
35 changes: 35 additions & 0 deletions tests/test_data_interface/test_public_data_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,41 @@ def test_feature_precision(self):
assert self.d.get_decimal_precisions()[1] == 2


class TestDecimalPrecisionsScientificNotation:
"""Regression for issue #442.

`str(np.float64(1e-6))` is `'1e-06'`; the historical
`str(value).split('.')[1]` then IndexErrors on mode values whose default
repr is in scientific notation (very small, e.g. <=1e-5, or very large,
e.g. >=1e16). This makes DiCE's Data() constructor blow up on legitimate
columns of currency / probability / nano-second magnitudes.
"""

@pytest.mark.parametrize(
"values, expected_precision",
[
# mode renders as '1e-06' — used to IndexError, now returns 6.
([1e-6, 1e-6, 1e-6], 6),
# mode renders as '2.5e-3' — mantissa has 1 decimal, exponent -3 ⇒ 4.
([2.5e-3, 2.5e-3, 2.5e-3], 4),
# mode renders as '1e+16' — positive exponent collapses to 0.
([1e16, 1e16, 1e16], 0),
# ordinary floats still work — mode 0.5 has 1 decimal.
([0.5, 0.5, 0.25], 1),
],
)
def test_get_decimal_precisions_handles_scientific_notation(
self, values, expected_precision
):
df = pd.DataFrame({"feat": values, "outcome": [0, 1, 0]})
d = dice_ml.Data(
dataframe=df, continuous_features=["feat"], outcome_name="outcome"
)
# Must not raise; must return a sensible precision.
precisions = d.get_decimal_precisions()
assert precisions[0] == expected_precision


class DataTypeCombinations(Enum):
Incorrect = 0
AsNone = 1
Expand Down