From e0e3c4c2ae0c59e62c3c441c272c7aec293cb13c Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 12:47:27 +0300 Subject: [PATCH 1/6] Keep cutoff grid within probability bounds --- src/rtichoke/processing/combinations.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/rtichoke/processing/combinations.py b/src/rtichoke/processing/combinations.py index b7909291..d37104fb 100644 --- a/src/rtichoke/processing/combinations.py +++ b/src/rtichoke/processing/combinations.py @@ -92,9 +92,9 @@ def create_breaks_values(probs_vec, stratified_by, by): if stratified_by != "probability_threshold": breaks = np.quantile(probs_vec, np.linspace(1, 0, int(1 / by) + 1)) else: - breaks = np.round( - np.arange(0, 1 + by, by), decimals=len(str(by).split(".")[-1]) - ) + decimals = len(str(by).split(".")[-1]) + breaks = np.round(np.arange(0, 1, by), decimals=decimals) + breaks = np.append(breaks[breaks < 1], 1.0) return breaks From e37b3a21d11074dfc22fe6d36e82887ea332867a Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 12:47:44 +0300 Subject: [PATCH 2/6] Test cutoff grid endpoints --- tests/test_cutoff_grid_endpoint.py | 53 ++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/test_cutoff_grid_endpoint.py diff --git a/tests/test_cutoff_grid_endpoint.py b/tests/test_cutoff_grid_endpoint.py new file mode 100644 index 00000000..3db29712 --- /dev/null +++ b/tests/test_cutoff_grid_endpoint.py @@ -0,0 +1,53 @@ +import numpy as np +import pytest + +from rtichoke import prepare_performance_data, prepare_performance_data_times +from rtichoke.processing.combinations import create_breaks_values + + +@pytest.mark.parametrize( + ("by", "expected"), + [ + (0.3, [0.0, 0.3, 0.6, 0.9, 1.0]), + (0.4, [0.0, 0.4, 0.8, 1.0]), + ], +) +def test_probability_threshold_breaks_end_at_one_without_overshoot(by, expected): + breaks = create_breaks_values(None, "probability_threshold", by) + + assert breaks.tolist() == expected + assert breaks[-1] == 1.0 + assert np.max(breaks) <= 1.0 + + +@pytest.mark.parametrize("by", [0.3, 0.4]) +def test_binary_performance_data_never_returns_cutoff_above_one(by): + probs = {"model": np.array([0.05, 0.25, 0.45, 0.65, 0.85, 0.95])} + reals = np.array([0, 0, 1, 0, 1, 1]) + + result = prepare_performance_data(probs, reals, by=by) + cutoffs = result.get_column("chosen_cutoff") + + assert cutoffs.max() == 1.0 + assert cutoffs.min() == 0.0 + assert cutoffs.max() <= 1.0 + + +@pytest.mark.parametrize("by", [0.3, 0.4]) +def test_time_performance_data_never_returns_cutoff_above_one(by): + probs = {"model": np.array([0.05, 0.25, 0.45, 0.65, 0.85, 0.95])} + reals = np.array([0, 0, 1, 0, 1, 1]) + times = np.array([2.0, 4.0, 3.0, 8.0, 5.0, 6.0]) + + result = prepare_performance_data_times( + probs, + reals, + times, + fixed_time_horizons=[6.0], + by=by, + ) + cutoffs = result.get_column("chosen_cutoff") + + assert cutoffs.max() == 1.0 + assert cutoffs.min() == 0.0 + assert cutoffs.max() <= 1.0 From dc2d9a9b6327fa3fff8c504bd6132ec0f397362b Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 12:53:23 +0300 Subject: [PATCH 3/6] Match R seq cutoff semantics --- src/rtichoke/processing/combinations.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/rtichoke/processing/combinations.py b/src/rtichoke/processing/combinations.py index d37104fb..99fe4e7d 100644 --- a/src/rtichoke/processing/combinations.py +++ b/src/rtichoke/processing/combinations.py @@ -93,8 +93,8 @@ def create_breaks_values(probs_vec, stratified_by, by): breaks = np.quantile(probs_vec, np.linspace(1, 0, int(1 / by) + 1)) else: decimals = len(str(by).split(".")[-1]) - breaks = np.round(np.arange(0, 1, by), decimals=decimals) - breaks = np.append(breaks[breaks < 1], 1.0) + n_steps = int(np.floor((1.0 / by) + 1e-12)) + breaks = np.round(np.arange(n_steps + 1) * by, decimals=decimals) return breaks @@ -124,7 +124,6 @@ def _create_aj_data_combinations_binary( ] ) - # Define values for Cartesian product reals_labels = ["real_negatives", "real_positives"] combinations_frames: list[pl.DataFrame] = [ @@ -152,8 +151,6 @@ def create_aj_data_combinations( dfs = [create_strata_combinations(sb, by, breaks) for sb in stratified_by] strata_combinations = pl.concat(dfs, how="vertical") - # strata_enum = pl.Enum(strata_combinations["strata"]) - strata_cats = ( strata_combinations.select(pl.col("strata").unique(maintain_order=True)) .to_series() @@ -178,7 +175,6 @@ def create_aj_data_combinations( } ) - # Define values for Cartesian product reals_labels = [ "real_negatives", "real_positives", From ee24ea3886c63069de7332f3392f22d890974486 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 12:53:35 +0300 Subject: [PATCH 4/6] Test R-style cutoff endpoint behavior --- tests/test_cutoff_grid_endpoint.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/tests/test_cutoff_grid_endpoint.py b/tests/test_cutoff_grid_endpoint.py index 3db29712..0436c4dd 100644 --- a/tests/test_cutoff_grid_endpoint.py +++ b/tests/test_cutoff_grid_endpoint.py @@ -8,33 +8,39 @@ @pytest.mark.parametrize( ("by", "expected"), [ - (0.3, [0.0, 0.3, 0.6, 0.9, 1.0]), - (0.4, [0.0, 0.4, 0.8, 1.0]), + (0.3, [0.0, 0.3, 0.6, 0.9]), + (0.4, [0.0, 0.4, 0.8]), + (0.25, [0.0, 0.25, 0.5, 0.75, 1.0]), ], ) -def test_probability_threshold_breaks_end_at_one_without_overshoot(by, expected): +def test_probability_threshold_breaks_match_r_seq_semantics(by, expected): breaks = create_breaks_values(None, "probability_threshold", by) assert breaks.tolist() == expected - assert breaks[-1] == 1.0 assert np.max(breaks) <= 1.0 -@pytest.mark.parametrize("by", [0.3, 0.4]) -def test_binary_performance_data_never_returns_cutoff_above_one(by): +@pytest.mark.parametrize( + ("by", "expected_max"), + [(0.3, 0.9), (0.4, 0.8), (0.25, 1.0)], +) +def test_binary_performance_data_matches_r_cutoff_endpoint(by, expected_max): probs = {"model": np.array([0.05, 0.25, 0.45, 0.65, 0.85, 0.95])} reals = np.array([0, 0, 1, 0, 1, 1]) result = prepare_performance_data(probs, reals, by=by) cutoffs = result.get_column("chosen_cutoff") - assert cutoffs.max() == 1.0 assert cutoffs.min() == 0.0 + assert cutoffs.max() == expected_max assert cutoffs.max() <= 1.0 -@pytest.mark.parametrize("by", [0.3, 0.4]) -def test_time_performance_data_never_returns_cutoff_above_one(by): +@pytest.mark.parametrize( + ("by", "expected_max"), + [(0.3, 0.9), (0.4, 0.8), (0.25, 1.0)], +) +def test_time_performance_data_matches_r_cutoff_endpoint(by, expected_max): probs = {"model": np.array([0.05, 0.25, 0.45, 0.65, 0.85, 0.95])} reals = np.array([0, 0, 1, 0, 1, 1]) times = np.array([2.0, 4.0, 3.0, 8.0, 5.0, 6.0]) @@ -48,6 +54,6 @@ def test_time_performance_data_never_returns_cutoff_above_one(by): ) cutoffs = result.get_column("chosen_cutoff") - assert cutoffs.max() == 1.0 assert cutoffs.min() == 0.0 + assert cutoffs.max() == expected_max assert cutoffs.max() <= 1.0 From b645520e7825b5a12d9baa4171b831619d6e6db6 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 12:53:49 +0300 Subject: [PATCH 5/6] Clarify R parity test names From 1da7a77966beee2c365210cf03da399c1a3c06b8 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 13:00:32 +0300 Subject: [PATCH 6/6] Separate cutoff values from bin edges --- src/rtichoke/processing/combinations.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/rtichoke/processing/combinations.py b/src/rtichoke/processing/combinations.py index 99fe4e7d..24969e64 100644 --- a/src/rtichoke/processing/combinations.py +++ b/src/rtichoke/processing/combinations.py @@ -17,13 +17,17 @@ def create_strata_combinations(stratified_by: str, by: float, breaks) -> pl.Data fmt = f"{{:.{decimals}f}}" if stratified_by == "probability_threshold": - upper_bound = breaks[1:] # breaks - lower_bound = breaks[:-1] # np.roll(upper_bound, 1) - # lower_bound[0] = 0.0 + cutoff_values = np.asarray(breaks) + bin_edges = ( + cutoff_values + if cutoff_values[-1] == 1.0 + else np.append(cutoff_values, 1.0) + ) + upper_bound = bin_edges[1:] + lower_bound = bin_edges[:-1] mid_point = upper_bound - by / 2 include_lower_bound = lower_bound > -0.1 - include_upper_bound = upper_bound == 1.0 # upper_bound != 0.0 - # chosen_cutoff = upper_bound + include_upper_bound = upper_bound == 1.0 strata = format_strata_column( lower_bound=lower_bound, upper_bound=upper_bound, @@ -33,13 +37,13 @@ def create_strata_combinations(stratified_by: str, by: float, breaks) -> pl.Data ) elif stratified_by == "ppcr": + cutoff_values = np.asarray(breaks) strata_mid = breaks[1:] lower_bound = strata_mid - by / 2 upper_bound = strata_mid + by / 2 mid_point = breaks[1:] include_lower_bound = np.ones_like(strata_mid, dtype=bool) include_upper_bound = np.zeros_like(strata_mid, dtype=bool) - # chosen_cutoff = strata_mid strata = np.array([fmt.format(x) for x in strata_mid], dtype=object) else: raise ValueError(f"Unsupported stratified_by: {stratified_by}") @@ -52,12 +56,11 @@ def create_strata_combinations(stratified_by: str, by: float, breaks) -> pl.Data "mid_point": mid_point, "include_lower_bound": include_lower_bound, "include_upper_bound": include_upper_bound, - # "chosen_cutoff": chosen_cutoff, "stratified_by": [stratified_by] * len(strata), } ) - cutoffs_df = pl.DataFrame({"chosen_cutoff": breaks}) + cutoffs_df = pl.DataFrame({"chosen_cutoff": cutoff_values}) return bins_df.join(cutoffs_df, how="cross") @@ -95,6 +98,8 @@ def create_breaks_values(probs_vec, stratified_by, by): decimals = len(str(by).split(".")[-1]) n_steps = int(np.floor((1.0 / by) + 1e-12)) breaks = np.round(np.arange(n_steps + 1) * by, decimals=decimals) + if probs_vec is not None and breaks[-1] != 1.0: + breaks = np.append(breaks, 1.0) return breaks