diff --git a/sdk/python/feast/infra/offline_stores/dask.py b/sdk/python/feast/infra/offline_stores/dask.py index cd930f7ab5c..b9c0396e6de 100644 --- a/sdk/python/feast/infra/offline_stores/dask.py +++ b/sdk/python/feast/infra/offline_stores/dask.py @@ -833,22 +833,34 @@ def _dask_compute_numeric_metrics( return result float_array = pc.cast(valid, pyarrow.float64()) + + # Non-finite values (NaN/+-Inf) arise from ordinary feature engineering, e.g. a + # ratio whose denominator is zero. They make summary statistics meaningless and + # np.histogram raises on them, so exclude them from every statistic below. + # row_count/null_count above still describe the raw data. + np_array = float_array.to_numpy(zero_copy_only=False) + finite_mask = np.isfinite(np_array) + if not finite_mask.all(): + np_array = np_array[finite_mask] + if len(np_array) == 0: + return result + float_array = pyarrow.array(np_array, type=pyarrow.float64()) + result["mean"] = opt_float(pc.mean(float_array).as_py()) # type: ignore[attr-defined] result["stddev"] = opt_float(pc.stddev(float_array, ddof=1).as_py()) # type: ignore[attr-defined] min_max = pc.min_max(float_array) # type: ignore[attr-defined] - result["min_val"] = min_max["min"].as_py() - result["max_val"] = min_max["max"].as_py() + result["min_val"] = opt_float(min_max["min"].as_py()) + result["max_val"] = opt_float(min_max["max"].as_py()) quantiles = pc.quantile(float_array, q=[0.50, 0.75, 0.90, 0.95, 0.99]) # type: ignore[attr-defined] q_values = quantiles.to_pylist() - result["p50"] = q_values[0] - result["p75"] = q_values[1] - result["p90"] = q_values[2] - result["p95"] = q_values[3] - result["p99"] = q_values[4] + result["p50"] = opt_float(q_values[0]) + result["p75"] = opt_float(q_values[1]) + result["p90"] = opt_float(q_values[2]) + result["p95"] = opt_float(q_values[3]) + result["p99"] = opt_float(q_values[4]) - np_array = float_array.to_numpy() counts, bin_edges = np.histogram(np_array, bins=histogram_bins) result["histogram"] = { "bins": bin_edges.tolist(), diff --git a/sdk/python/feast/monitoring/metrics_calculator.py b/sdk/python/feast/monitoring/metrics_calculator.py index 1b8b3b3e7ca..5e81466f8fa 100644 --- a/sdk/python/feast/monitoring/metrics_calculator.py +++ b/sdk/python/feast/monitoring/metrics_calculator.py @@ -95,22 +95,38 @@ def compute_numeric(self, array: pa.Array) -> Dict: return result float_array = pc.cast(valid, pa.float64()) + + # Non-finite values (NaN/+-Inf) arise from ordinary feature engineering, + # e.g. a ratio whose denominator is zero. They make summary statistics + # meaningless and np.histogram raises on them, so exclude them from every + # statistic below. row_count/null_count above still describe the raw data. + np_array = float_array.to_numpy(zero_copy_only=False) + finite_mask = np.isfinite(np_array) + if not finite_mask.all(): + logger.warning( + "Excluding %d non-finite value(s) (NaN/Inf) from numeric metrics", + int((~finite_mask).sum()), + ) + np_array = np_array[finite_mask] + if len(np_array) == 0: + return result + float_array = pa.array(np_array, type=pa.float64()) + result["mean"] = _safe_float(pc.mean(float_array).as_py()) # type: ignore[attr-defined] result["stddev"] = _safe_float(pc.stddev(float_array, ddof=1).as_py()) # type: ignore[attr-defined] min_max = pc.min_max(float_array) # type: ignore[attr-defined] - result["min_val"] = min_max["min"].as_py() - result["max_val"] = min_max["max"].as_py() + result["min_val"] = _safe_float(min_max["min"].as_py()) + result["max_val"] = _safe_float(min_max["max"].as_py()) quantiles = pc.quantile(float_array, q=[0.50, 0.75, 0.90, 0.95, 0.99]) # type: ignore[attr-defined] q_values = quantiles.to_pylist() - result["p50"] = q_values[0] - result["p75"] = q_values[1] - result["p90"] = q_values[2] - result["p95"] = q_values[3] - result["p99"] = q_values[4] + result["p50"] = _safe_float(q_values[0]) + result["p75"] = _safe_float(q_values[1]) + result["p90"] = _safe_float(q_values[2]) + result["p95"] = _safe_float(q_values[3]) + result["p99"] = _safe_float(q_values[4]) - np_array = float_array.to_numpy() counts, bin_edges = np.histogram(np_array, bins=self.histogram_bins) result["histogram"] = { "bins": bin_edges.tolist(), diff --git a/sdk/python/tests/unit/monitoring/test_metrics_calculator.py b/sdk/python/tests/unit/monitoring/test_metrics_calculator.py index 8124531d765..322f080b0ce 100644 --- a/sdk/python/tests/unit/monitoring/test_metrics_calculator.py +++ b/sdk/python/tests/unit/monitoring/test_metrics_calculator.py @@ -104,6 +104,59 @@ def test_percentiles_order(self): assert result["p90"] <= result["p95"] assert result["p95"] <= result["p99"] + @pytest.mark.parametrize("non_finite", [float("inf"), float("-inf"), float("nan")]) + def test_non_finite_values_are_excluded(self, non_finite): + """Non-finite values must not break metrics computation. + + They occur in ordinary feature engineering (e.g. a ratio whose + denominator is zero) and previously raised ValueError from + np.histogram, discarding metrics for the whole feature view. + """ + calc = _make_calc() + arr = pa.array([1.0, 2.0, 3.0, non_finite], type=pa.float64()) + result = calc.compute_numeric(arr) + + # Statistics are computed over the finite values only. + assert result["mean"] == pytest.approx(2.0) + assert result["min_val"] == 1.0 + assert result["max_val"] == 3.0 + assert result["histogram"] is not None + # row_count still describes the raw data. + assert result["row_count"] == 4 + + def test_all_non_finite(self): + calc = _make_calc() + arr = pa.array([float("inf"), float("nan")], type=pa.float64()) + result = calc.compute_numeric(arr) + + assert result["row_count"] == 2 + assert result["mean"] is None + assert result["histogram"] is None + + def test_non_finite_mixed_with_nulls(self): + calc = _make_calc() + arr = pa.array([1.0, None, 3.0, float("inf")], type=pa.float64()) + result = calc.compute_numeric(arr) + + assert result["null_count"] == 1 + assert result["mean"] == pytest.approx(2.0) + assert result["histogram"] is not None + + def test_compute_all_survives_non_finite_column(self): + """One bad column must not discard metrics for the others.""" + calc = _make_calc() + table = pa.table( + { + "ratio": pa.array([0.5, 1.0, float("inf")], type=pa.float64()), + "clicks": pa.array([1.0, 2.0, 3.0], type=pa.float64()), + } + ) + results = calc.compute_all(table, [("ratio", "numeric"), ("clicks", "numeric")]) + + assert {r["feature_name"] for r in results} == {"ratio", "clicks"} + clicks = next(r for r in results if r["feature_name"] == "clicks") + assert clicks["mean"] == pytest.approx(2.0) + class TestComputeCategorical: def test_basic(self):