From 2d82660f199e5eddc98fa9aedccbfdba4e4c9a94 Mon Sep 17 00:00:00 2001 From: DMZ22 Date: Fri, 24 Jul 2026 12:21:32 +0530 Subject: [PATCH] Handle empty anomaly lists in the point metrics _point_partition derived the partition range with min()/max() over the union of the expected and observed anomalies, before the start/end overrides were applied. With no anomalies on either side that union is empty, so every point metric raised "ValueError: min() arg is an empty sequence" - including when a range was supplied through data/start/end. Take start/end into account first and only fall back to min()/max() when they are not given, so a known range is enough to build the partition. When there are no anomalies and no range to fall back on, return an empty confusion matrix instead: there are no true or false positives and no false negatives, but the number of true negatives is unknown, which is the same (None, fp, fn, tp) shape the contextual metrics already use. Precision, recall and f1 then return nan through the existing zero-division handling, and accuracy raises the existing error for a missing true-negative count unless a range makes it computable. Closes #527. --- orion/evaluation/point.py | 19 +++++++++++------ tests/unit/evaluation/test_point.py | 33 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/orion/evaluation/point.py b/orion/evaluation/point.py index 4a0bde8f..f9989def 100644 --- a/orion/evaluation/point.py +++ b/orion/evaluation/point.py @@ -5,13 +5,13 @@ def _point_partition(expected, observed, start=None, end=None): expected = set(expected) observed = set(observed) - edge_start = min(expected.union(observed)) - if start is not None: - edge_start = start + edge_start = start + if edge_start is None: + edge_start = min(expected.union(observed)) - edge_end = max(expected.union(observed)) - if end is not None: - edge_end = end + edge_end = end + if edge_end is None: + edge_end = max(expected.union(observed)) length = int(edge_end) - int(edge_start) + 1 @@ -62,6 +62,13 @@ def _ws(x, y, z, w): if not isinstance(observed, list): observed = list(observed['timestamp']) + if not expected and not observed and (start is None or end is None): + # Without any anomalies there is no range to partition, and none was + # supplied through ``data``/``start``/``end``. There are no true or + # false positives and no false negatives, but the number of true + # negatives is unknown. + return None, 0, 0, 0 + return _ws(expected, observed, start, end) diff --git a/tests/unit/evaluation/test_point.py b/tests/unit/evaluation/test_point.py index 31d9faec..ed1ea1df 100644 --- a/tests/unit/evaluation/test_point.py +++ b/tests/unit/evaluation/test_point.py @@ -67,3 +67,36 @@ def test_point_f1_score_nan(): observed = pd.DataFrame({"timestamp": [4, 5]}) returned = point_f1_score(expected, observed) assert np.isnan(returned) + + +def test_point_confusion_matrix_empty(): + empty = pd.DataFrame({"timestamp": []}) + + returned = point_confusion_matrix(empty, empty) + + # There is no range to partition, so the number of true negatives is + # unknown, but there are no positives and no false negatives. + assert returned == (None, 0, 0, 0) + + +def test_point_confusion_matrix_empty_with_range(): + empty = pd.DataFrame({"timestamp": []}) + + returned = point_confusion_matrix(empty, empty, start=0, end=9) + + # Every point in the range is correctly considered normal. + np.testing.assert_array_equal(np.array(returned), np.array((10, 0, 0, 0))) + + +def test_point_scores_empty(): + empty = pd.DataFrame({"timestamp": []}) + + assert np.isnan(point_precision(empty, empty)) + assert np.isnan(point_recall(empty, empty)) + assert np.isnan(point_f1_score(empty, empty)) + + # Accuracy needs the true negatives, which are unknown without a range. + with pytest.raises(ValueError): + point_accuracy(empty, empty) + + assert point_accuracy(empty, empty, start=0, end=9) == 1.0