From 22b488627b1c4f21664231d443af6e54d11a3c5b Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 09:53:33 +0300 Subject: [PATCH 1/4] test: add dcurves parity regression for issue 127 --- tests/test_decision_curve_dcurves_parity.py | 141 ++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 tests/test_decision_curve_dcurves_parity.py diff --git a/tests/test_decision_curve_dcurves_parity.py b/tests/test_decision_curve_dcurves_parity.py new file mode 100644 index 00000000..ccca969d --- /dev/null +++ b/tests/test_decision_curve_dcurves_parity.py @@ -0,0 +1,141 @@ +"""Regression tests for dcurves compatibility (issue #127).""" + +from importlib import resources + +import numpy as np +import pandas as pd +import polars as pl +from dcurves import dca +from numpy.testing import assert_allclose + +from rtichoke.performance_data.performance_data_times import ( + prepare_performance_data_times, +) + + +def _load_dcurves_survival_data() -> pd.DataFrame: + data_resource = resources.files("dcurves").joinpath("data/df_surv.csv") + with resources.as_file(data_resource) as path: + return pd.read_csv(path) + + +def _rtichoke_survival_dca(data: pd.DataFrame, by: float) -> pl.DataFrame: + return ( + prepare_performance_data_times( + probs={"cancerpredmarker": data["cancerpredmarker"].to_numpy()}, + reals=data["cancer"].astype(int).to_numpy(), + times=data["ttcancer"].to_numpy(), + fixed_time_horizons=[1.5], + by=by, + ) + .filter( + (pl.col("reference_group") == "cancerpredmarker") + & (pl.col("stratified_by") == "probability_threshold") + ) + .sort("chosen_cutoff") + ) + + +def test_survival_decision_curve_matches_dcurves_issue_127() -> None: + """Match dcurves TP/FP rates and net benefit on its 1.5-year example.""" + data = _load_dcurves_survival_data() + thresholds = np.array([0.00, 0.01, 0.05, 0.10, 0.20, 0.50]) + + dcurves_result = dca( + data=data, + outcome="cancer", + modelnames=["cancerpredmarker"], + thresholds=thresholds, + time=1.5, + time_to_outcome_col="ttcancer", + ) + dcurves_model = ( + dcurves_result[dcurves_result["model"] == "cancerpredmarker"] + .sort_values("threshold") + .reset_index(drop=True) + ) + + rtichoke_result = _rtichoke_survival_dca(data, by=0.01).filter( + pl.col("chosen_cutoff").is_in(thresholds.tolist()) + ) + + assert data.shape[0] == 750 + assert rtichoke_result.height == len(thresholds) + + rtichoke_prevalence = ( + rtichoke_result.filter(pl.col("chosen_cutoff") == 0.0) + .select(pl.col("real_positives") / pl.col("n")) + .item() + ) + dcurves_prevalence = dcurves_model["pos_rate"].iloc[0] + + assert round(dcurves_prevalence, 2) == 0.22 + assert_allclose(rtichoke_prevalence, dcurves_prevalence, rtol=0, atol=1e-10) + + assert_allclose( + rtichoke_result["true_positives"].to_numpy() + / rtichoke_result["n"].to_numpy(), + dcurves_model["tp_rate"].to_numpy(), + rtol=0, + atol=1e-10, + ) + assert_allclose( + rtichoke_result["false_positives"].to_numpy() + / rtichoke_result["n"].to_numpy(), + dcurves_model["fp_rate"].to_numpy(), + rtol=0, + atol=1e-10, + ) + assert_allclose( + rtichoke_result["net_benefit"].to_numpy(), + dcurves_model["net_benefit"].to_numpy(), + rtol=0, + atol=1e-10, + ) + + +def test_survival_decision_curve_includes_prediction_equal_to_threshold() -> None: + """Keep dcurves' prediction >= threshold convention at an exact boundary.""" + data = pd.DataFrame( + { + "cancer": [True, False, True, False, False, False], + "ttcancer": [0.5, 2.0, 0.7, 2.0, 2.0, 2.0], + "cancerpredmarker": [0.20, 0.20, 0.30, 0.10, 0.40, 0.05], + } + ) + + dcurves_result = dca( + data=data, + outcome="cancer", + modelnames=["cancerpredmarker"], + thresholds=[0.20], + time=1.5, + time_to_outcome_col="ttcancer", + ) + dcurves_model = dcurves_result[ + dcurves_result["model"] == "cancerpredmarker" + ].iloc[0] + + rtichoke_result = _rtichoke_survival_dca(data, by=0.10).filter( + pl.col("chosen_cutoff") == 0.20 + ) + + assert rtichoke_result.height == 1 + assert_allclose( + rtichoke_result["true_positives"].item() / rtichoke_result["n"].item(), + dcurves_model["tp_rate"], + rtol=0, + atol=1e-10, + ) + assert_allclose( + rtichoke_result["false_positives"].item() / rtichoke_result["n"].item(), + dcurves_model["fp_rate"], + rtol=0, + atol=1e-10, + ) + assert_allclose( + rtichoke_result["net_benefit"].item(), + dcurves_model["net_benefit"], + rtol=0, + atol=1e-10, + ) From 681bba90eb9f5a823549e2e473a9509221cd16fc Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 09:54:56 +0300 Subject: [PATCH 2/4] test: fix dcurves prevalence column name --- tests/test_decision_curve_dcurves_parity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_decision_curve_dcurves_parity.py b/tests/test_decision_curve_dcurves_parity.py index ccca969d..2038bafb 100644 --- a/tests/test_decision_curve_dcurves_parity.py +++ b/tests/test_decision_curve_dcurves_parity.py @@ -67,7 +67,7 @@ def test_survival_decision_curve_matches_dcurves_issue_127() -> None: .select(pl.col("real_positives") / pl.col("n")) .item() ) - dcurves_prevalence = dcurves_model["pos_rate"].iloc[0] + dcurves_prevalence = dcurves_model["prevalence"].iloc[0] assert round(dcurves_prevalence, 2) == 0.22 assert_allclose(rtichoke_prevalence, dcurves_prevalence, rtol=0, atol=1e-10) From 95861f488efc8ed99091c67e69192e87876d1aa7 Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 09:59:32 +0300 Subject: [PATCH 3/4] test: keep dcurves parity test Polars-first --- tests/test_decision_curve_dcurves_parity.py | 43 ++++++++++----------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/tests/test_decision_curve_dcurves_parity.py b/tests/test_decision_curve_dcurves_parity.py index 2038bafb..59aad221 100644 --- a/tests/test_decision_curve_dcurves_parity.py +++ b/tests/test_decision_curve_dcurves_parity.py @@ -3,7 +3,6 @@ from importlib import resources import numpy as np -import pandas as pd import polars as pl from dcurves import dca from numpy.testing import assert_allclose @@ -13,17 +12,17 @@ ) -def _load_dcurves_survival_data() -> pd.DataFrame: +def _load_dcurves_survival_data() -> pl.DataFrame: data_resource = resources.files("dcurves").joinpath("data/df_surv.csv") with resources.as_file(data_resource) as path: - return pd.read_csv(path) + return pl.read_csv(path) -def _rtichoke_survival_dca(data: pd.DataFrame, by: float) -> pl.DataFrame: +def _rtichoke_survival_dca(data: pl.DataFrame, by: float) -> pl.DataFrame: return ( prepare_performance_data_times( probs={"cancerpredmarker": data["cancerpredmarker"].to_numpy()}, - reals=data["cancer"].astype(int).to_numpy(), + reals=data["cancer"].cast(pl.Int64).to_numpy(), times=data["ttcancer"].to_numpy(), fixed_time_horizons=[1.5], by=by, @@ -36,19 +35,24 @@ def _rtichoke_survival_dca(data: pd.DataFrame, by: float) -> pl.DataFrame: ) -def test_survival_decision_curve_matches_dcurves_issue_127() -> None: - """Match dcurves TP/FP rates and net benefit on its 1.5-year example.""" - data = _load_dcurves_survival_data() - thresholds = np.array([0.00, 0.01, 0.05, 0.10, 0.20, 0.50]) - - dcurves_result = dca( - data=data, +def _dcurves_survival_dca(data: pl.DataFrame, thresholds: list[float]): + """Call dcurves at the test boundary; pandas remains its transitive dependency.""" + return dca( + data=data.to_pandas(), outcome="cancer", modelnames=["cancerpredmarker"], thresholds=thresholds, time=1.5, time_to_outcome_col="ttcancer", ) + + +def test_survival_decision_curve_matches_dcurves_issue_127() -> None: + """Match dcurves TP/FP rates and net benefit on its 1.5-year example.""" + data = _load_dcurves_survival_data() + thresholds = [0.00, 0.01, 0.05, 0.10, 0.20, 0.50] + + dcurves_result = _dcurves_survival_dca(data, thresholds) dcurves_model = ( dcurves_result[dcurves_result["model"] == "cancerpredmarker"] .sort_values("threshold") @@ -56,10 +60,10 @@ def test_survival_decision_curve_matches_dcurves_issue_127() -> None: ) rtichoke_result = _rtichoke_survival_dca(data, by=0.01).filter( - pl.col("chosen_cutoff").is_in(thresholds.tolist()) + pl.col("chosen_cutoff").is_in(thresholds) ) - assert data.shape[0] == 750 + assert data.height == 750 assert rtichoke_result.height == len(thresholds) rtichoke_prevalence = ( @@ -96,7 +100,7 @@ def test_survival_decision_curve_matches_dcurves_issue_127() -> None: def test_survival_decision_curve_includes_prediction_equal_to_threshold() -> None: """Keep dcurves' prediction >= threshold convention at an exact boundary.""" - data = pd.DataFrame( + data = pl.DataFrame( { "cancer": [True, False, True, False, False, False], "ttcancer": [0.5, 2.0, 0.7, 2.0, 2.0, 2.0], @@ -104,14 +108,7 @@ def test_survival_decision_curve_includes_prediction_equal_to_threshold() -> Non } ) - dcurves_result = dca( - data=data, - outcome="cancer", - modelnames=["cancerpredmarker"], - thresholds=[0.20], - time=1.5, - time_to_outcome_col="ttcancer", - ) + dcurves_result = _dcurves_survival_dca(data, [0.20]) dcurves_model = dcurves_result[ dcurves_result["model"] == "cancerpredmarker" ].iloc[0] From 2f513f5fa9a63f5028e0809700ee583be388a4ad Mon Sep 17 00:00:00 2001 From: Uriah Finkel Date: Thu, 20 Aug 2026 10:05:16 +0300 Subject: [PATCH 4/4] test: avoid pyarrow in dcurves parity test --- tests/test_decision_curve_dcurves_parity.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_decision_curve_dcurves_parity.py b/tests/test_decision_curve_dcurves_parity.py index 59aad221..57f62263 100644 --- a/tests/test_decision_curve_dcurves_parity.py +++ b/tests/test_decision_curve_dcurves_parity.py @@ -36,9 +36,17 @@ def _rtichoke_survival_dca(data: pl.DataFrame, by: float) -> pl.DataFrame: def _dcurves_survival_dca(data: pl.DataFrame, thresholds: list[float]): - """Call dcurves at the test boundary; pandas remains its transitive dependency.""" + """Call dcurves at the test boundary without requiring Polars->pandas conversion.""" + # dcurves requires a pandas DataFrame and already depends on pandas itself. + # Building it from plain Python data avoids adding pyarrow just for + # `pl.DataFrame.to_pandas()`. + pandas_df_type = type( + dca.__globals__["pd"].DataFrame() + ) + pandas_data = pandas_df_type(data.to_dict(as_series=False)) + return dca( - data=data.to_pandas(), + data=pandas_data, outcome="cancer", modelnames=["cancerpredmarker"], thresholds=thresholds,