Skip to content

Commit b0f37f5

Browse files
fix: expose effective probability cutoff for PPCR operating points
Expose producer-owned probability_threshold column in prepare_performance_data() derived via Type-7 linear quantile mapping for PPCR stratification, and populate operating_points.cutoff from probability_threshold in _prepare_probs_distribution_data(). Co-authored-by: uriahf <11351434+uriahf@users.noreply.github.com>
1 parent dee9515 commit b0f37f5

4 files changed

Lines changed: 282 additions & 6 deletions

File tree

‎src/rtichoke/performance_data/performance_data.py‎

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"reference_group",
2424
"stratified_by",
2525
"chosen_cutoff",
26+
"probability_threshold",
2627
"true_positives",
2728
"true_negatives",
2829
"false_positives",
@@ -244,9 +245,11 @@ def prepare_performance_data(
244245
Returns
245246
-------
246247
pl.DataFrame
247-
A Polars DataFrame where each row corresponds to a probability cutoff
248-
for a given model/dataset. Columns include the cutoff value and a rich
249-
set of performance metrics (e.g., `tpr`, `fpr`, `ppv`, `net_benefit`).
248+
A Polars DataFrame where each row corresponds to an operating point for a
249+
given model/dataset. Columns include `chosen_cutoff` (requested grid value),
250+
`probability_threshold` (effective score boundary corresponding to the grid
251+
point), `ppcr`, and a rich set of performance metrics (e.g., `sensitivity`,
252+
`specificity`, `ppv`, `net_benefit`).
250253
251254
Examples
252255
--------
@@ -274,6 +277,44 @@ def prepare_performance_data(
274277

275278
performance_data = _turn_cumulative_aj_to_performance_data(cumulative_aj_data)
276279

280+
prob_threshold_rows = []
281+
for group_name, probs_array in probs.items():
282+
probs_g = np.asarray(probs_array, dtype=float)
283+
unique_cutoffs = performance_data.filter(
284+
pl.col("reference_group") == group_name
285+
)["chosen_cutoff"].unique()
286+
for c in unique_cutoffs:
287+
eff_cutoff = float(np.quantile(probs_g, 1.0 - float(c), method="linear"))
288+
prob_threshold_rows.append(
289+
{
290+
"reference_group": group_name,
291+
"stratified_by": "ppcr",
292+
"chosen_cutoff": float(c),
293+
"probability_threshold": eff_cutoff,
294+
}
295+
)
296+
297+
ppcr_threshold_df = pl.DataFrame(
298+
prob_threshold_rows,
299+
schema={
300+
"reference_group": performance_data.schema["reference_group"],
301+
"stratified_by": performance_data.schema["stratified_by"],
302+
"chosen_cutoff": pl.Float64,
303+
"probability_threshold": pl.Float64,
304+
},
305+
)
306+
307+
performance_data = performance_data.join(
308+
ppcr_threshold_df,
309+
on=["reference_group", "stratified_by", "chosen_cutoff"],
310+
how="left",
311+
).with_columns(
312+
pl.when(pl.col("stratified_by") == "probability_threshold")
313+
.then(pl.col("chosen_cutoff"))
314+
.otherwise(pl.col("probability_threshold"))
315+
.alias("probability_threshold")
316+
)
317+
277318
return performance_data.select(_PERFORMANCE_DATA_COLUMNS).sort(
278319
["reference_group", "stratified_by", "chosen_cutoff"]
279320
)

‎src/rtichoke/performance_data/probs_distribution.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,11 @@ def _prepare_probs_distribution_data(
202202
Returns
203203
-------
204204
_PredictionDistributionData
205-
TypedDict containing ``bins`` and ``operating_points`` Polars DataFrames.
205+
TypedDict containing ``bins``, ``operating_points``, and ``rank_bins``
206+
Polars DataFrames. In ``operating_points``, ``value`` is the requested grid
207+
point (requested PPCR or probability threshold), ``cutoff`` is the effective
208+
predicted-probability boundary (from ``probability_threshold``), and
209+
``realized_ppcr`` is the empirical proportion classified positive.
206210
"""
207211
if not isinstance(stratified_by, (list, tuple)) or len(stratified_by) != 1:
208212
raise ValueError(
@@ -270,7 +274,7 @@ def _prepare_probs_distribution_data(
270274
requested_value = float(
271275
row["ppcr"] if stratification_type == "ppcr" else row["chosen_cutoff"]
272276
)
273-
effective_cutoff = float(row["chosen_cutoff"])
277+
effective_cutoff = float(row["probability_threshold"])
274278
n_observations = int(row["n"])
275279
predicted_positives = int(row["predicted_positives"])
276280
realized_ppcr = (
@@ -292,7 +296,11 @@ def _prepare_probs_distribution_data(
292296
)
293297

294298
# Build interval boundaries from effective cutoffs
295-
cutoffs = evaluation_performance_data["chosen_cutoff"].to_numpy().astype(float)
299+
cutoffs = (
300+
evaluation_performance_data["probability_threshold"]
301+
.to_numpy()
302+
.astype(float)
303+
)
296304
interval_boundaries = np.unique(np.concatenate(([0.0, 1.0], cutoffs)))
297305
interval_boundaries.sort()
298306

‎tests/test_performance_schema_order.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"reference_group",
1414
"stratified_by",
1515
"chosen_cutoff",
16+
"probability_threshold",
1617
"true_positives",
1718
"true_negatives",
1819
"false_positives",

‎tests/test_probs_distribution.py‎

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,3 +512,229 @@ def test_primary_golden_fixture_order_invariance():
512512
res_perm = _prepare_probs_distribution_data({"m1": probs_perm}, reals_perm, by=0.20)
513513

514514
assert res_orig["rank_bins"].equals(res_perm["rank_bins"])
515+
516+
517+
def test_frozen_r_oracle_ppcr_tied_fixture():
518+
probs = {"m1": np.array([0.00, 0.15, 0.30, 0.50, 0.50, 0.50, 0.65, 0.80, 1.00])}
519+
reals = np.array([0, 1, 0, 1, 0, 1, 1, 0, 1])
520+
521+
perf_df = prepare_performance_data(probs, reals, stratified_by=("ppcr",), by=0.20)
522+
dist = _prepare_probs_distribution_data(
523+
probs, reals, stratified_by=("ppcr",), by=0.20
524+
)
525+
ops = dist["operating_points"]
526+
527+
# Independently hardcoded frozen R oracle expected values
528+
expected = [
529+
# req_ppcr, eff_cutoff, realized_ppcr, tp, fp, tn, fn, sens, spec, ppv, npv, lift
530+
(0.00, 1.00, 0.00 / 9.0, 0, 0, 4, 5, 0.0, 1.0, np.nan, 4 / 9, np.nan),
531+
(0.20, 0.71, 2.00 / 9.0, 1, 1, 3, 4, 0.2, 0.75, 0.5, 3 / 7, 0.9),
532+
(0.40, 0.50, 3.00 / 9.0, 2, 1, 3, 3, 0.4, 0.75, 2 / 3, 0.5, 1.2),
533+
(0.60, 0.50, 3.00 / 9.0, 2, 1, 3, 3, 0.4, 0.75, 2 / 3, 0.5, 1.2),
534+
(0.80, 0.24, 9.00 / 9.0, 5, 4, 0, 0, 1.0, 0.0, 5 / 9, np.nan, 1.0),
535+
(1.00, 0.00, 9.00 / 9.0, 5, 4, 0, 0, 1.0, 0.0, 5 / 9, np.nan, 1.0),
536+
]
537+
538+
for (
539+
req_ppcr,
540+
eff_cutoff,
541+
real_ppcr,
542+
tp,
543+
fp,
544+
tn,
545+
fn,
546+
sens,
547+
spec,
548+
ppv,
549+
npv,
550+
lift,
551+
) in expected:
552+
p_row = perf_df.filter(pl.col("chosen_cutoff") == req_ppcr).row(0, named=True)
553+
op_row = ops.filter(pl.col("value") == req_ppcr).row(0, named=True)
554+
555+
assert p_row["ppcr"] == pytest.approx(req_ppcr)
556+
assert p_row["chosen_cutoff"] == pytest.approx(req_ppcr)
557+
assert p_row["probability_threshold"] == pytest.approx(eff_cutoff, abs=1e-4)
558+
559+
assert op_row["type"] == "ppcr"
560+
assert op_row["value"] == pytest.approx(req_ppcr)
561+
assert op_row["cutoff"] == pytest.approx(eff_cutoff, abs=1e-4)
562+
assert op_row["realized_ppcr"] == pytest.approx(real_ppcr)
563+
564+
assert p_row["true_positives"] == tp
565+
assert p_row["false_positives"] == fp
566+
assert p_row["true_negatives"] == tn
567+
assert p_row["false_negatives"] == fn
568+
569+
assert p_row["sensitivity"] == pytest.approx(sens)
570+
assert p_row["specificity"] == pytest.approx(spec)
571+
572+
if np.isnan(ppv):
573+
assert np.isnan(p_row["ppv"])
574+
else:
575+
assert p_row["ppv"] == pytest.approx(ppv)
576+
577+
if np.isnan(npv):
578+
assert np.isnan(p_row["npv"])
579+
else:
580+
assert p_row["npv"] == pytest.approx(npv)
581+
582+
if np.isnan(lift):
583+
assert np.isnan(p_row["lift"])
584+
else:
585+
assert p_row["lift"] == pytest.approx(lift)
586+
587+
# Concrete assertion that operating_point.cutoff != operating_point.value for tied PPCR point
588+
op_02 = ops.filter(pl.col("value") == 0.20).row(0, named=True)
589+
assert op_02["cutoff"] != op_02["value"]
590+
assert op_02["cutoff"] == pytest.approx(0.71)
591+
assert op_02["value"] == pytest.approx(0.20)
592+
593+
594+
def test_ppcr_repeated_effective_cutoffs_under_ties():
595+
probs = {"m1": np.array([0.00, 0.15, 0.30, 0.50, 0.50, 0.50, 0.65, 0.80, 1.00])}
596+
reals = np.array([0, 1, 0, 1, 0, 1, 1, 0, 1])
597+
598+
dist = _prepare_probs_distribution_data(
599+
probs, reals, stratified_by=("ppcr",), by=0.20
600+
)
601+
ops = dist["operating_points"]
602+
603+
op_04 = ops.filter(pl.col("value") == 0.40).row(0, named=True)
604+
op_06 = ops.filter(pl.col("value") == 0.60).row(0, named=True)
605+
606+
# Different requested value
607+
assert op_04["value"] == pytest.approx(0.40)
608+
assert op_06["value"] == pytest.approx(0.60)
609+
assert op_04["value"] != op_06["value"]
610+
611+
# Same effective cutoff, realized PPCR, and confusion matrix
612+
assert op_04["cutoff"] == pytest.approx(op_06["cutoff"]) == pytest.approx(0.50)
613+
assert (
614+
op_04["realized_ppcr"]
615+
== pytest.approx(op_06["realized_ppcr"])
616+
== pytest.approx(3 / 9)
617+
)
618+
619+
620+
def test_ppcr_n_less_than_q():
621+
probs = {"m1": np.array([0.10, 0.50, 0.90])}
622+
reals = np.array([0, 1, 1])
623+
624+
res = _prepare_probs_distribution_data(
625+
probs, reals, stratified_by=("ppcr",), by=0.20
626+
)
627+
rb = res["rank_bins"]
628+
ops = res["operating_points"]
629+
630+
# Explicit empty rank strata preserved (q = 5)
631+
assert len(rb) == 5
632+
assert rb["n_positive"].to_list() == [0, 0, 1, 0, 1]
633+
assert rb["n_negative"].to_list() == [1, 0, 0, 0, 0]
634+
635+
# Verify operating point cutoffs for N < q
636+
cutoffs = ops["cutoff"].to_list()
637+
assert len(cutoffs) == 6
638+
assert np.allclose(cutoffs, [0.9, 0.74, 0.58, 0.42, 0.26, 0.1])
639+
640+
641+
def test_ppcr_distinct_predictions_type7_boundaries():
642+
probs = {"m1": np.array([0.10, 0.30, 0.70, 0.90])}
643+
reals = np.array([0, 1, 0, 1])
644+
645+
res = _prepare_probs_distribution_data(
646+
probs, reals, stratified_by=("ppcr",), by=0.25
647+
)
648+
ops = res["operating_points"]
649+
650+
# Expected Type-7 linear quantiles at 1.0, 0.75, 0.50, 0.25, 0.0
651+
expected_cutoffs = [0.90, 0.75, 0.50, 0.25, 0.10]
652+
for row, exp_c in zip(ops.iter_rows(named=True), expected_cutoffs):
653+
assert row["cutoff"] == pytest.approx(exp_c)
654+
655+
656+
def test_ppcr_exact_scores_zero_and_one_endpoints():
657+
probs = {"m1": np.array([0.0, 0.0, 0.5, 1.0, 1.0])}
658+
reals = np.array([0, 1, 0, 1, 0])
659+
660+
res = _prepare_probs_distribution_data(
661+
probs, reals, stratified_by=("ppcr",), by=0.20
662+
)
663+
ops = res["operating_points"]
664+
665+
op_0 = ops.filter(pl.col("value") == 0.0).row(0, named=True)
666+
assert op_0["cutoff"] == pytest.approx(1.0)
667+
assert op_0["realized_ppcr"] == pytest.approx(0.0)
668+
669+
op_1 = ops.filter(pl.col("value") == 1.0).row(0, named=True)
670+
assert op_1["cutoff"] == pytest.approx(0.0)
671+
assert op_1["realized_ppcr"] == pytest.approx(1.0)
672+
673+
674+
def test_ppcr_multiple_models_shared_outcomes():
675+
probs = {
676+
"m1": np.array([0.1, 0.4, 0.6, 0.9]),
677+
"m2": np.array([0.2, 0.5, 0.8, 1.0]),
678+
}
679+
reals = np.array([0, 1, 0, 1])
680+
681+
res = _prepare_probs_distribution_data(
682+
probs, reals, stratified_by=("ppcr",), by=0.5
683+
)
684+
ops = res["operating_points"]
685+
686+
ops_m1 = ops.filter(pl.col("model") == "m1")
687+
ops_m2 = ops.filter(pl.col("model") == "m2")
688+
689+
assert len(ops_m1) == 3
690+
assert len(ops_m2) == 3
691+
692+
# Per-model effective cutoffs computed independently
693+
m1_c05 = ops_m1.filter(pl.col("value") == 0.5).row(0, named=True)["cutoff"]
694+
m2_c05 = ops_m2.filter(pl.col("value") == 0.5).row(0, named=True)["cutoff"]
695+
696+
assert m1_c05 == pytest.approx(0.5)
697+
assert m2_c05 == pytest.approx(0.65)
698+
699+
700+
def test_ppcr_multiple_populations_unequal_sample_sizes():
701+
probs = {
702+
"pop1": np.array([0.1, 0.5, 0.9]),
703+
"pop2": np.array([0.2, 0.4, 0.6, 0.8, 1.0]),
704+
}
705+
reals = {
706+
"pop1": np.array([0, 1, 1]),
707+
"pop2": np.array([0, 1, 0, 1, 0]),
708+
}
709+
710+
res = _prepare_probs_distribution_data(
711+
probs, reals, stratified_by=("ppcr",), by=0.5
712+
)
713+
ops = res["operating_points"]
714+
715+
p1_ops = ops.filter(pl.col("population") == "pop1")
716+
p2_ops = ops.filter(pl.col("population") == "pop2")
717+
718+
assert len(p1_ops) == 3
719+
assert len(p2_ops) == 3
720+
721+
722+
def test_ppcr_pre_post_non_regression():
723+
probs = {"m1": np.array([0.00, 0.15, 0.30, 0.50, 0.50, 0.50, 0.65, 0.80, 1.00])}
724+
reals = np.array([0, 1, 0, 1, 0, 1, 1, 0, 1])
725+
726+
perf_df = prepare_performance_data(probs, reals, stratified_by=("ppcr",), by=0.20)
727+
dist = _prepare_probs_distribution_data(
728+
probs, reals, stratified_by=("ppcr",), by=0.20
729+
)
730+
731+
# Rank bins schema and masses frozen
732+
rb = dist["rank_bins"]
733+
assert rb["n_positive"].to_list() == [1, 2, 0, 1, 1]
734+
assert rb["n_negative"].to_list() == [1, 2, 0, 0, 1]
735+
736+
# Metrics frozen
737+
assert perf_df["true_positives"].to_list() == [0, 1, 2, 2, 5, 5]
738+
assert perf_df["false_positives"].to_list() == [0, 1, 1, 1, 4, 4]
739+
assert perf_df["true_negatives"].to_list() == [4, 3, 3, 3, 0, 0]
740+
assert perf_df["false_negatives"].to_list() == [5, 4, 3, 3, 0, 0]

0 commit comments

Comments
 (0)