Skip to content

Commit bc5027c

Browse files
authored
Merge pull request #391 from uriahf/fix/static-interventions-avoided-scaling
Fix static Interventions Avoided per-100 scaling
2 parents 646672c + 5d96ff3 commit bc5027c

3 files changed

Lines changed: 109 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
<!--next-version-placeholder-->
44

5+
- Fixed Interventions Avoided to apply the per-100 scaling to the full model expression, including the false-negative penalty term.
6+
57
## v0.1.36 (21/08/2026)
68

79
- Fixed several binary and time-dependent curve consistency issues, including cutoff-grid endpoints, binary cutoff equality, time-dependent reference prevalence, gains perfect-reference behavior, custom colors, and plot sizing.

‎src/rtichoke/processing/transforms.py‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -684,10 +684,13 @@ def _turn_cumulative_aj_to_performance_data(
684684
.alias("net_benefit"),
685685
pl.when(pl.col("stratified_by") == "probability_threshold")
686686
.then(
687-
100 * (pl.col("true_negatives") / pl.col("n"))
688-
- (pl.col("false_negatives") / pl.col("n"))
689-
* (1 - pl.col("chosen_cutoff"))
690-
/ pl.col("chosen_cutoff")
687+
100
688+
* (
689+
(pl.col("true_negatives") / pl.col("n"))
690+
- (pl.col("false_negatives") / pl.col("n"))
691+
* (1 - pl.col("chosen_cutoff"))
692+
/ pl.col("chosen_cutoff")
693+
)
691694
)
692695
.otherwise(None)
693696
.alias("net_benefit_interventions_avoided"),
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import numpy as np
2+
import polars as pl
3+
import pytest
4+
5+
from rtichoke import (
6+
create_decision_curve,
7+
plot_decision_curve,
8+
prepare_performance_data,
9+
)
10+
from rtichoke.processing.plotly_helper_functions import _create_reference_lines_data
11+
12+
13+
PROBS = {"model": np.array([0.9, 0.8, 0.7, 0.6, 0.4, 0.3, 0.2, 0.1])}
14+
REALS = np.array([1, 0, 1, 0, 1, 0, 0, 1])
15+
THRESHOLDS = [0.25, 0.5, 0.75]
16+
EXPECTED_IA = [-25.0, 0.0, 25.0]
17+
OLD_BUGGY_IA = [12.125, 24.75, 37.375]
18+
EXPECTED_TN = [1.0, 2.0, 3.0]
19+
EXPECTED_FN = [1.0, 2.0, 3.0]
20+
21+
22+
def _performance_rows() -> pl.DataFrame:
23+
data = prepare_performance_data(probs=PROBS, reals=REALS, by=0.25)
24+
data = data.filter(pl.col("chosen_cutoff").is_in(THRESHOLDS))
25+
return data.sort("chosen_cutoff")
26+
27+
28+
def test_static_interventions_avoided_matches_r_definition_per_100():
29+
"""Expected values characterize the current R static IA definition."""
30+
rows = _performance_rows()
31+
32+
np.testing.assert_allclose(rows["true_negatives"].to_numpy(), EXPECTED_TN)
33+
np.testing.assert_allclose(rows["false_negatives"].to_numpy(), EXPECTED_FN)
34+
actual = rows["net_benefit_interventions_avoided"].to_numpy()
35+
np.testing.assert_allclose(actual, EXPECTED_IA)
36+
assert not np.allclose(actual, OLD_BUGGY_IA)
37+
38+
39+
def test_static_interventions_avoided_count_and_nb_forms_are_equivalent():
40+
rows = _performance_rows()
41+
prevalence = float(REALS.mean())
42+
43+
for row in rows.iter_rows(named=True):
44+
threshold = float(row["chosen_cutoff"])
45+
tn = float(row["true_negatives"])
46+
fn = float(row["false_negatives"])
47+
n = float(row["n"])
48+
net_benefit = float(row["net_benefit"])
49+
net_benefit_all = prevalence - (1 - prevalence) * threshold / (1 - threshold)
50+
from_counts = 100 * (tn / n - fn / n * (1 - threshold) / threshold)
51+
from_nb = 100 * (net_benefit - net_benefit_all) * (1 - threshold) / threshold
52+
actual = float(row["net_benefit_interventions_avoided"])
53+
54+
assert actual == pytest.approx(from_counts)
55+
assert from_counts == pytest.approx(from_nb)
56+
57+
58+
def test_interventions_avoided_model_and_references_use_per_100_units():
59+
rows = _performance_rows()
60+
aj = pl.DataFrame({"reference_group": ["population"], "aj_estimate": [0.5]})
61+
refs = _create_reference_lines_data(
62+
curve="interventions avoided",
63+
aj_estimates_from_performance_data=aj,
64+
multiple_populations=False,
65+
min_p_threshold=0.25,
66+
max_p_threshold=0.75,
67+
)
68+
69+
treat_all = refs.filter(pl.col("reference_group") == "treat_all")
70+
assert np.allclose(treat_all["y"].to_numpy(), 0.0)
71+
72+
is_treat_none = pl.col("reference_group") == "treat_none"
73+
is_test_threshold = pl.col("x").is_in(THRESHOLDS)
74+
treat_none = refs.filter(is_treat_none & is_test_threshold).sort("x")
75+
expected_treat_none = [-100.0, 0.0, 100.0 / 3.0]
76+
np.testing.assert_allclose(treat_none["y"].to_numpy(), expected_treat_none)
77+
np.testing.assert_allclose(
78+
rows["net_benefit_interventions_avoided"].to_numpy(), EXPECTED_IA
79+
)
80+
81+
82+
def test_static_interventions_avoided_public_apis_are_unchanged():
83+
data = prepare_performance_data(probs=PROBS, reals=REALS, by=0.25)
84+
created = create_decision_curve(
85+
probs=PROBS,
86+
reals=REALS,
87+
decision_type="interventions avoided",
88+
by=0.25,
89+
min_p_threshold=0.25,
90+
max_p_threshold=0.75,
91+
)
92+
plotted = plot_decision_curve(
93+
data,
94+
decision_type="interventions avoided",
95+
min_p_threshold=0.25,
96+
max_p_threshold=0.75,
97+
)
98+
99+
assert created.__class__.__name__ == "Figure"
100+
assert plotted.__class__.__name__ == "Figure"

0 commit comments

Comments
 (0)