Skip to content

Commit b5590cd

Browse files
authored
Merge pull request #383 from uriahf/feat/performance-table-spec
Add canonical PerformanceTableSpec producers
2 parents e99fffc + 808a33c commit b5590cd

9 files changed

Lines changed: 442 additions & 13 deletions
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
"""Internal canonical PerformanceTableSpec builders.
2+
3+
These helpers translate already-calculated production performance data plus
4+
semantic evaluation metadata. They do not calculate or render statistics.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import math
10+
from collections.abc import Mapping
11+
from typing import Any, TypedDict, cast
12+
13+
import polars as pl
14+
15+
from rtichoke.processing.evaluation_semantics import _EvaluationMetadata
16+
17+
_METRICS: tuple[tuple[str, str], ...] = (
18+
("true_positives", "True Positives"),
19+
("true_negatives", "True Negatives"),
20+
("false_positives", "False Positives"),
21+
("false_negatives", "False Negatives"),
22+
("sensitivity", "Sensitivity"),
23+
("specificity", "Specificity"),
24+
("false_positive_rate", "False Positive Rate"),
25+
("ppv", "PPV"),
26+
("npv", "NPV"),
27+
("lift", "Lift"),
28+
("predicted_positives", "Predicted Positives"),
29+
("ppcr", "PPCR"),
30+
("net_benefit", "Net Benefit"),
31+
("net_benefit_interventions_avoided", "Interventions Avoided"),
32+
)
33+
34+
35+
class _EvaluationSpec(TypedDict, total=False):
36+
id: str
37+
model: str
38+
population: str
39+
40+
41+
class _MetricDefinition(TypedDict):
42+
id: str
43+
label: str
44+
45+
46+
class _OperatingPoint(TypedDict):
47+
type: str
48+
value: float
49+
50+
51+
class _MetricValue(TypedDict):
52+
metricId: str
53+
estimate: float | int | None
54+
55+
56+
class _EvaluationContext(TypedDict):
57+
censoringHeuristic: str
58+
competingEventHeuristic: str
59+
60+
61+
class _PerformanceTableRow(TypedDict, total=False):
62+
evaluationId: str
63+
operatingPoint: _OperatingPoint
64+
values: list[_MetricValue]
65+
horizon: float
66+
context: _EvaluationContext
67+
68+
69+
class _PerformanceTableSpec(TypedDict):
70+
schemaVersion: str
71+
type: str
72+
evaluations: list[_EvaluationSpec]
73+
metrics: list[_MetricDefinition]
74+
rows: list[_PerformanceTableRow]
75+
76+
77+
def _performance_table_spec_from_performance_data(
78+
performance_data: pl.DataFrame,
79+
evaluation_metadata: Mapping[str, _EvaluationMetadata],
80+
) -> _PerformanceTableSpec:
81+
"""Build the canonical static PerformanceTableSpec."""
82+
return _build_performance_table_spec(
83+
performance_data, evaluation_metadata, time_dependent=False
84+
)
85+
86+
87+
def _performance_table_times_spec_from_performance_data(
88+
performance_data: pl.DataFrame,
89+
evaluation_metadata: Mapping[str, _EvaluationMetadata],
90+
) -> _PerformanceTableSpec:
91+
"""Build the canonical time-dependent PerformanceTableSpec."""
92+
return _build_performance_table_spec(
93+
performance_data, evaluation_metadata, time_dependent=True
94+
)
95+
96+
97+
def _build_performance_table_spec(
98+
performance_data: pl.DataFrame,
99+
evaluation_metadata: Mapping[str, _EvaluationMetadata],
100+
*,
101+
time_dependent: bool,
102+
) -> _PerformanceTableSpec:
103+
required = {"reference_group", "stratified_by", "chosen_cutoff"}
104+
if time_dependent:
105+
required |= {
106+
"fixed_time_horizon",
107+
"censoring_heuristic",
108+
"competing_heuristic",
109+
}
110+
missing = required.difference(performance_data.columns)
111+
if missing:
112+
raise ValueError(
113+
"Performance-table data is missing columns: " + ", ".join(sorted(missing))
114+
)
115+
116+
metric_definitions: list[_MetricDefinition] = [
117+
cast(_MetricDefinition, {"id": metric_id, "label": label})
118+
for metric_id, label in _METRICS
119+
if metric_id in performance_data.columns
120+
]
121+
metric_ids = [definition["id"] for definition in metric_definitions]
122+
123+
rows = performance_data.select(
124+
[
125+
column
126+
for column in (
127+
"reference_group",
128+
"fixed_time_horizon",
129+
"censoring_heuristic",
130+
"competing_heuristic",
131+
"stratified_by",
132+
"chosen_cutoff",
133+
*metric_ids,
134+
)
135+
if column in performance_data.columns
136+
]
137+
).to_dicts()
138+
row_groups = {str(row["reference_group"]) for row in rows}
139+
missing_metadata = row_groups.difference(evaluation_metadata)
140+
if missing_metadata:
141+
raise ValueError(
142+
"Performance-table rows are missing evaluation metadata: "
143+
+ ", ".join(sorted(missing_metadata))
144+
)
145+
146+
ordered_groups = [group for group in evaluation_metadata if group in row_groups]
147+
evaluation_ids = {
148+
group: f"evaluation-{index}"
149+
for index, group in enumerate(ordered_groups, start=1)
150+
}
151+
evaluations: list[_EvaluationSpec] = []
152+
for group in ordered_groups:
153+
metadata = evaluation_metadata[group]
154+
evaluation: _EvaluationSpec = {
155+
"id": evaluation_ids[group],
156+
"population": metadata.population,
157+
}
158+
if metadata.model is not None:
159+
evaluation["model"] = metadata.model
160+
evaluations.append(evaluation)
161+
162+
canonical_rows: list[_PerformanceTableRow] = []
163+
for row in rows:
164+
group = str(row["reference_group"])
165+
stratified_by = str(row["stratified_by"])
166+
operating_point: _OperatingPoint
167+
if stratified_by == "probability_threshold":
168+
operating_point = {
169+
"type": "probability_threshold",
170+
"value": _number(row["chosen_cutoff"]),
171+
}
172+
elif stratified_by == "ppcr":
173+
operating_point = {"type": "ppcr", "value": _number(row["ppcr"])}
174+
else:
175+
raise ValueError(
176+
"Canonical PerformanceTableSpec supports probability_threshold "
177+
f"or ppcr operating points, not {stratified_by!r}"
178+
)
179+
180+
canonical_row: _PerformanceTableRow = {
181+
"evaluationId": evaluation_ids[group],
182+
"operatingPoint": operating_point,
183+
"values": cast(
184+
list[_MetricValue],
185+
[
186+
{
187+
"metricId": metric_id,
188+
"estimate": _nullable_number(row[metric_id]),
189+
}
190+
for metric_id in metric_ids
191+
],
192+
),
193+
}
194+
if time_dependent:
195+
canonical_row["horizon"] = _number(row["fixed_time_horizon"])
196+
canonical_row["context"] = {
197+
"censoringHeuristic": str(row["censoring_heuristic"]),
198+
"competingEventHeuristic": str(row["competing_heuristic"]),
199+
}
200+
canonical_rows.append(canonical_row)
201+
202+
return {
203+
"schemaVersion": "2.0",
204+
"type": "performance_table",
205+
"evaluations": evaluations,
206+
"metrics": metric_definitions,
207+
"rows": canonical_rows,
208+
}
209+
210+
211+
def _nullable_number(value: Any) -> float | int | None:
212+
if value is None:
213+
return None
214+
if isinstance(value, float) and math.isnan(value):
215+
return None
216+
return value
217+
218+
219+
def _number(value: Any) -> float:
220+
if value is None:
221+
raise ValueError("Operating point and horizon values must not be null")
222+
return float(value)

‎src/rtichoke/processing/adjustments.py‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
from collections.abc import Sequence
2+
13
import pandas as pd
24
import polars as pl
3-
from polarstate import predict_aj_estimates
4-
from polarstate import prepare_event_table
5-
from collections.abc import Sequence
5+
from polarstate import predict_aj_estimates, prepare_event_table
6+
67
from rtichoke.processing.transforms import assign_and_explode_polars
78

89

@@ -529,7 +530,7 @@ def _aj_estimates_by_cutoff_per_horizon(
529530
df.filter(pl.col("fixed_time_horizon") == h)
530531
.group_by("strata")
531532
.map_groups(
532-
lambda group: extract_aj_estimate_by_cutoffs(
533+
lambda group, h=h: extract_aj_estimate_by_cutoffs(
533534
group, [h], breaks, stratified_by, full_event_table=False
534535
)
535536
)
@@ -547,7 +548,7 @@ def _aj_estimates_per_horizon(
547548
df.filter(pl.col("fixed_time_horizon") == h)
548549
.group_by("strata")
549550
.map_groups(
550-
lambda group: extract_aj_estimate_for_strata(
551+
lambda group, h=h: extract_aj_estimate_for_strata(
551552
group, [h], full_event_table
552553
)
553554
)
@@ -650,6 +651,8 @@ def _aj_adjusted_events(
650651
adjusted = extract_aj_estimate_by_cutoffs(
651652
non_competing, horizons, breaks, stratified_by, full_event_table
652653
)
654+
else:
655+
raise ValueError(f"Unsupported risk-set scope: {risk_set_scope!r}")
653656

654657
adjusted = adjusted.with_columns(
655658
[
@@ -704,6 +707,8 @@ def _aj_adjusted_events(
704707
adjusted = _aj_estimates_by_cutoff_per_horizon(
705708
base_df, horizons, breaks, stratified_by
706709
)
710+
else:
711+
raise ValueError(f"Unsupported risk-set scope: {risk_set_scope!r}")
707712

708713
adjusted = adjusted.with_columns(
709714
pl.lit(risk_set_scope)
@@ -730,6 +735,8 @@ def _aj_adjusted_events(
730735
adjusted = extract_aj_estimate_by_cutoffs(
731736
base_df, horizons, breaks, stratified_by, full_event_table
732737
)
738+
else:
739+
raise ValueError(f"Unsupported risk-set scope: {risk_set_scope!r}")
733740

734741
adjusted = adjusted.with_columns(
735742
[

‎tests/test_cutoff_grid_endpoint.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from typing import cast
2+
13
import numpy as np
24
import pytest
35

@@ -17,7 +19,7 @@ def test_probability_threshold_breaks_match_r_seq_semantics(by, expected):
1719
breaks = create_breaks_values(None, "probability_threshold", by)
1820

1921
assert breaks.tolist() == expected
20-
assert np.max(breaks) <= 1.0
22+
assert float(np.max(breaks)) <= 1.0
2123

2224

2325
@pytest.mark.parametrize(
@@ -33,7 +35,7 @@ def test_binary_performance_data_matches_r_cutoff_endpoint(by, expected_max):
3335

3436
assert cutoffs.min() == 0.0
3537
assert cutoffs.max() == expected_max
36-
assert cutoffs.max() <= 1.0
38+
assert cast(float, cutoffs.max()) <= 1.0
3739

3840

3941
@pytest.mark.parametrize(
@@ -56,4 +58,4 @@ def test_time_performance_data_matches_r_cutoff_endpoint(by, expected_max):
5658

5759
assert cutoffs.min() == 0.0
5860
assert cutoffs.max() == expected_max
59-
assert cutoffs.max() <= 1.0
61+
assert cast(float, cutoffs.max()) <= 1.0

‎tests/test_performance_table.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1+
from typing import cast
2+
13
import numpy as np
24
import pytest
35
from great_tables import GT
46
from reactable import Reactable
57

68
import rtichoke.performance_table as performance_table_module
7-
from rtichoke.performance_table_reactable import _bar_style, _net_benefit_style
8-
99
from rtichoke import (
1010
create_performance_table,
1111
create_performance_table_times,
1212
prepare_performance_data,
1313
prepare_performance_data_times,
1414
render_performance_table,
1515
)
16+
from rtichoke.performance_table import PerformanceTableRenderer
17+
from rtichoke.performance_table_reactable import _bar_style, _net_benefit_style
1618

1719

1820
def _example():
@@ -215,7 +217,9 @@ def test_invalid_renderer_is_rejected():
215217
probs, reals = _example()
216218
data = prepare_performance_data(probs, reals, by=0.1)
217219
with pytest.raises(ValueError, match="renderer"):
218-
render_performance_table(data, renderer="unknown")
220+
render_performance_table(
221+
data, renderer=cast(PerformanceTableRenderer, "unknown")
222+
)
219223

220224

221225
def test_reactable_metric_bar_matches_r_colors_and_geometry():

0 commit comments

Comments
 (0)