diff --git a/dice_ml/data_interfaces/public_data_interface.py b/dice_ml/data_interfaces/public_data_interface.py index 5d7e394b..ce1bd248 100644 --- a/dice_ml/data_interfaces/public_data_interface.py +++ b/dice_ml/data_interfaces/public_data_interface.py @@ -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 @@ -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 diff --git a/tests/test_data_interface/test_public_data_interface.py b/tests/test_data_interface/test_public_data_interface.py index 2ef9eba8..87c546b9 100644 --- a/tests/test_data_interface/test_public_data_interface.py +++ b/tests/test_data_interface/test_public_data_interface.py @@ -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