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
28 changes: 20 additions & 8 deletions sdk/python/feast/infra/offline_stores/dask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
32 changes: 24 additions & 8 deletions sdk/python/feast/monitoring/metrics_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
53 changes: 53 additions & 0 deletions sdk/python/tests/unit/monitoring/test_metrics_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading