From 1a87b8a1151c4f66d3315e4c861591b26d267996 Mon Sep 17 00:00:00 2001 From: NaiveCoding <71413722+ReviveCoding@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:59:58 -0400 Subject: [PATCH 1/3] Deduplicate precision and recall computation in binary metrics --- src/evaluation/metrics.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/evaluation/metrics.py b/src/evaluation/metrics.py index 2293c82..0180ca1 100644 --- a/src/evaluation/metrics.py +++ b/src/evaluation/metrics.py @@ -33,13 +33,17 @@ def expected_calibration_error(y_true, y_prob, n_bins: int = 10) -> float: def binary_metrics(y_true, y_prob, threshold: float = 0.5) -> dict: y_pred = (np.asarray(y_prob) >= threshold).astype(int) + precision = float(precision_score(y_true, y_pred, zero_division=0)) + recall = float(recall_score(y_true, y_pred, zero_division=0)) + f2 = (5.0 * precision * recall) / (4.0 * precision + recall + 1e-12) + return { "roc_auc": safe_auc(y_true, y_prob, "roc"), "pr_auc": safe_auc(y_true, y_prob, "pr"), "f1": float(f1_score(y_true, y_pred, zero_division=0)), - "f2": float((5 * precision_score(y_true, y_pred, zero_division=0) * recall_score(y_true, y_pred, zero_division=0)) / (4 * precision_score(y_true, y_pred, zero_division=0) + recall_score(y_true, y_pred, zero_division=0) + 1e-12)), - "precision": float(precision_score(y_true, y_pred, zero_division=0)), - "recall": float(recall_score(y_true, y_pred, zero_division=0)), + "f2": float(f2), + "precision": precision, + "recall": recall, "brier": float(brier_score_loss(y_true, y_prob)), "ece": expected_calibration_error(y_true, y_prob), } From 1dfb4036ea40c28a16327735ba6151ad5299a797 Mon Sep 17 00:00:00 2001 From: NaiveCoding <71413722+ReviveCoding@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:00:01 -0400 Subject: [PATCH 2/3] Cover F2 metric consistency and zero-precision edge case --- tests/test_binary_metrics_polish.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/test_binary_metrics_polish.py diff --git a/tests/test_binary_metrics_polish.py b/tests/test_binary_metrics_polish.py new file mode 100644 index 0000000..10deed2 --- /dev/null +++ b/tests/test_binary_metrics_polish.py @@ -0,0 +1,25 @@ +"""Regression tests for shared precision/recall calculations.""" + +import pytest + +from src.evaluation.metrics import binary_metrics + + +@pytest.mark.parametrize( + ("labels", "scores", "threshold", "expected_precision", "expected_recall"), + [ + ([0, 1, 1, 0], [0.1, 0.9, 0.4, 0.3], 0.5, 1.0, 0.5), + ([0, 0, 1, 1], [0.8, 0.7, 0.2, 0.1], 0.5, 0.0, 0.0), + ], +) +def test_f2_uses_reported_precision_and_recall( + labels, scores, threshold, expected_precision, expected_recall +) -> None: + result = binary_metrics(labels, scores, threshold) + assert result["precision"] == pytest.approx(expected_precision) + assert result["recall"] == pytest.approx(expected_recall) + expected_f2 = ( + 5 * expected_precision * expected_recall + / (4 * expected_precision + expected_recall + 1e-12) + ) + assert result["f2"] == pytest.approx(expected_f2) From c77f503016f3c31f0913be62401f629944754271 Mon Sep 17 00:00:00 2001 From: NaiveCoding <71413722+ReviveCoding@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:02:26 -0400 Subject: [PATCH 3/3] Make metric regression test compatible with direct-function CI runner --- tests/test_binary_metrics_polish.py | 36 ++++++++++++----------------- 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/tests/test_binary_metrics_polish.py b/tests/test_binary_metrics_polish.py index 10deed2..b5cf7d7 100644 --- a/tests/test_binary_metrics_polish.py +++ b/tests/test_binary_metrics_polish.py @@ -1,25 +1,19 @@ -"""Regression tests for shared precision/recall calculations.""" - -import pytest +"""Regression checks compatible with the project's lightweight test runner.""" from src.evaluation.metrics import binary_metrics -@pytest.mark.parametrize( - ("labels", "scores", "threshold", "expected_precision", "expected_recall"), - [ - ([0, 1, 1, 0], [0.1, 0.9, 0.4, 0.3], 0.5, 1.0, 0.5), - ([0, 0, 1, 1], [0.8, 0.7, 0.2, 0.1], 0.5, 0.0, 0.0), - ], -) -def test_f2_uses_reported_precision_and_recall( - labels, scores, threshold, expected_precision, expected_recall -) -> None: - result = binary_metrics(labels, scores, threshold) - assert result["precision"] == pytest.approx(expected_precision) - assert result["recall"] == pytest.approx(expected_recall) - expected_f2 = ( - 5 * expected_precision * expected_recall - / (4 * expected_precision + expected_recall + 1e-12) - ) - assert result["f2"] == pytest.approx(expected_f2) +def test_f2_uses_reported_precision_and_recall() -> None: + cases = [ + ([0, 1, 1, 0], [0.1, 0.9, 0.4, 0.3], 1.0, 0.5), + ([0, 0, 1, 1], [0.8, 0.7, 0.2, 0.1], 0.0, 0.0), + ] + for labels, scores, expected_precision, expected_recall in cases: + result = binary_metrics(labels, scores, threshold=0.5) + assert abs(result["precision"] - expected_precision) < 1e-12 + assert abs(result["recall"] - expected_recall) < 1e-12 + expected_f2 = ( + 5.0 * expected_precision * expected_recall + / (4.0 * expected_precision + expected_recall + 1e-12) + ) + assert abs(result["f2"] - expected_f2) < 1e-12