diff --git a/src/evidently/core/metric_types.py b/src/evidently/core/metric_types.py index 62b2b3a90e..9185e0c46b 100644 --- a/src/evidently/core/metric_types.py +++ b/src/evidently/core/metric_types.py @@ -564,8 +564,24 @@ def convert_types(val): return int(val) if isinstance(val, str): return val - if val is None or np.isnan(val): + # `pd.NA` / `pd.NaT` reach here when label values come from nullable pandas + # dtypes (e.g. "string", "Int64"). `np.isnan(pd.NA)` raises TypeError + # ("boolean value of NA is ambiguous"); `pd.isna` safely covers all + # NA flavors (None, NaN, NaT, NA). We return None — these can't survive + # downstream as dict keys for ByLabelCountValue anyway, and the existing + # numpy-NaN call site already relied on returning a NA-shaped value here. + if val is None: return val + try: + if pd.isna(val): + # Preserve numpy.nan as-is (back-compat for the existing + # ByLabelCountValue serializer that turns it into the string + # "nan"); collapse pandas-flavored NA to None. + if isinstance(val, float): + return val + return None + except (TypeError, ValueError): + pass raise ValueError(f"type {type(val)} not supported as Label") diff --git a/tests/future/test_metric_types.py b/tests/future/test_metric_types.py index d6af0c6945..704ac65171 100644 --- a/tests/future/test_metric_types.py +++ b/tests/future/test_metric_types.py @@ -1,8 +1,10 @@ import numpy as np +import pandas as pd import pytest from evidently.core.metric_types import ByLabelCountValue from evidently.core.metric_types import SingleValue +from evidently.core.metric_types import convert_types @pytest.mark.parametrize( @@ -20,3 +22,46 @@ def test_by_label_count_value(input: dict, output: tuple): tests=[], ) assert {"counts": output[0], "shares": output[1]} == value.to_simple_dict() + + +@pytest.mark.parametrize( + "value", + [ + pd.NA, + pd.NaT, + ], +) +def test_convert_types_handles_pandas_na(value): + # Regression for #1844: convert_types previously called np.isnan(val) which + # raises TypeError on pd.NA ("boolean value of NA is ambiguous") and on + # pd.NaT. Pandas-flavored NA should now collapse to None instead of + # propagating a TypeError up through ByLabelCountValue construction. + assert convert_types(value) is None + + +def test_convert_types_preserves_existing_contracts(): + # Non-NA labels must still flow through untouched, and numpy.nan must + # round-trip as a float so the ByLabelCountValue serializer keeps emitting + # the string "nan" (see test_by_label_count_value above). + assert convert_types(True) is True + assert convert_types(0) == 0 + assert convert_types(42) == 42 + assert convert_types("class_a") == "class_a" + assert convert_types(None) is None + nan_out = convert_types(np.nan) + assert isinstance(nan_out, float) and np.isnan(nan_out) + + +def test_by_label_count_value_handles_pd_na_key(): + # End-to-end: ByLabelCountValue construction must not crash when a count + # dict keyed by pd.NA reaches the convert_types pipeline. + value = ByLabelCountValue( + counts={pd.NA: SingleValue(value=1.0, display_name="t", metric_value_location=None)}, + shares={pd.NA: SingleValue(value=1.0, display_name="t", metric_value_location=None)}, + display_name="t", + metric_value_location=None, + tests=[], + ) + # No assertion on key shape — just that construction + dict export succeed. + out = value.to_simple_dict() + assert "counts" in out and "shares" in out