diff --git a/src/rtichoke/performance_table.py b/src/rtichoke/performance_table.py index 8f236d73..c645bccb 100644 --- a/src/rtichoke/performance_table.py +++ b/src/rtichoke/performance_table.py @@ -19,6 +19,7 @@ DEFAULT_COLORS, render_performance_table_reactable, ) +from rtichoke.processing.evaluation_semantics import _build_evaluation_metadata PerformanceTableRenderer = Literal["great_tables", "reactable"] @@ -30,6 +31,26 @@ ] +def _with_evaluation_group_role( + performance_data: pl.DataFrame, + probs: Dict[str, np.ndarray], + reals: Union[np.ndarray, Dict[str, np.ndarray]], + times: Union[np.ndarray, Dict[str, np.ndarray], None] = None, +) -> pl.DataFrame: + """Attach transient semantic role metadata for table rendering only.""" + metadata = _build_evaluation_metadata( + probs, + reals, + np.array([]) if times is None else times, + ) + group_role = ( + "model" + if all(evaluation.model is not None for evaluation in metadata.values()) + else "population" + ) + return performance_data.with_columns(pl.lit(group_role).alias("__group_role")) + + def render_performance_table( performance_data: pl.DataFrame, color_values: Sequence[str] = DEFAULT_COLORS, @@ -59,6 +80,11 @@ def create_performance_table( performance_data = prepare_performance_data( probs=probs, reals=reals, by=by, stratified_by=stratified_by ) + performance_data = _with_evaluation_group_role( + performance_data, + probs=probs, + reals=reals, + ) return render_performance_table( performance_data, color_values=color_values, renderer=renderer ) @@ -102,6 +128,12 @@ def create_performance_table_times( by=by, stratified_by=stratified_by, ) + performance_data = _with_evaluation_group_role( + performance_data, + probs=probs, + reals=reals, + times=normalized_times, + ) return render_performance_table( performance_data, color_values=color_values, renderer=renderer ) diff --git a/src/rtichoke/performance_table_great_tables.py b/src/rtichoke/performance_table_great_tables.py index ae97b4de..44dbde06 100644 --- a/src/rtichoke/performance_table_great_tables.py +++ b/src/rtichoke/performance_table_great_tables.py @@ -84,6 +84,12 @@ def render_performance_table_great_tables( raise ValueError("performance_data must contain exactly one stratification") stratified_by = stratifications[0] + group_label = "Model" + if "__group_role" in performance_data.columns: + group_roles = performance_data.get_column("__group_role").unique().to_list() + if group_roles == ["population"]: + group_label = "Population" + context_columns = [ "fixed_time_horizon", "censoring_heuristic", @@ -106,7 +112,7 @@ def render_performance_table_great_tables( [c for c in source_columns if c in performance_data.columns] ) if "reference_group" in data.columns: - data = data.rename({"reference_group": "Model"}) + data = data.rename({"reference_group": group_label}) if "fixed_time_horizon" in data.columns: data = data.rename({"fixed_time_horizon": "Time"}) if "censoring_heuristic" in data.columns: @@ -120,13 +126,13 @@ def render_performance_table_great_tables( if stratified_by == "probability_threshold": data = data.rename({"chosen_cutoff": "Threshold"}) sort_columns = context_sort + [ - c for c in ("Threshold", "Model") if c in data.columns + c for c in ("Threshold", group_label) if c in data.columns ] else: if "chosen_cutoff" in data.columns: data = data.drop("chosen_cutoff") sort_columns = context_sort + [ - c for c in ("ppcr", "Model") if c in data.columns + c for c in ("ppcr", group_label) if c in data.columns ] if sort_columns: data = data.sort(sort_columns) @@ -153,7 +159,7 @@ def render_performance_table_great_tables( display_columns = [ c for c in [ - "Model", + group_label, "Time", "Censoring", "Competing Event", @@ -171,7 +177,7 @@ def render_performance_table_great_tables( display = data.select(display_columns) labels = { - "Model": "Model", + group_label: group_label, "Time": "Time Horizon", "Censoring": "Censoring", "Competing Event": "Competing Event", @@ -210,20 +216,20 @@ def render_performance_table_great_tables( locations=loc.body(columns="net_benefit"), ) - if "Model" in data.columns: - models = data.get_column("Model").unique(maintain_order=True).to_list() - for index, model in enumerate(models): + if group_label in data.columns: + groups = data.get_column(group_label).unique(maintain_order=True).to_list() + for index, group in enumerate(groups): color = color_values[index % len(color_values)] rows = [ i - for i, value in enumerate(data.get_column("Model").to_list()) - if value == model + for i, value in enumerate(data.get_column(group_label).to_list()) + if value == group ] table = table.tab_style( style=style.css( f"color:{color};font-weight:600;text-shadow:0 0 0 currentColor;" ), - locations=loc.body(columns="Model", rows=rows), + locations=loc.body(columns=group_label, rows=rows), ) lift_max = float( diff --git a/src/rtichoke/performance_table_reactable.py b/src/rtichoke/performance_table_reactable.py index 6e53aa17..18f0c17a 100644 --- a/src/rtichoke/performance_table_reactable.py +++ b/src/rtichoke/performance_table_reactable.py @@ -54,7 +54,7 @@ def _net_benefit_style(value: float | None, maximum: float) -> dict[str, str]: if value is None or not np.isfinite(value) or maximum <= 0: return {} width = max(-1.0, min(float(value) / maximum, 1.0)) - position = (0.5 + width / 2) * 100 + position = (0.5 + width / 2.0) * 100 if width >= 0: background = ( "linear-gradient(90deg, transparent 50%, lightgreen 50%, " @@ -95,6 +95,12 @@ def render_performance_table_reactable( raise ValueError("performance_data must contain exactly one stratification") stratified_by = stratifications[0] + group_label = "Model" + if "__group_role" in performance_data.columns: + group_roles = performance_data.get_column("__group_role").unique().to_list() + if group_roles == ["population"]: + group_label = "Population" + display_columns = [ "reference_group", "fixed_time_horizon", @@ -118,7 +124,7 @@ def render_performance_table_reactable( [c for c in display_columns if c in performance_data.columns] ) rename_map = { - "reference_group": "Model", + "reference_group": group_label, "fixed_time_horizon": "Time", "censoring_heuristic": "Censoring", "competing_heuristic": "Competing Event", @@ -131,13 +137,13 @@ def render_performance_table_reactable( if stratified_by == "probability_threshold": data = data.rename({"chosen_cutoff": "Threshold"}) sort_columns = context_sort + [ - c for c in ("Threshold", "Model") if c in data.columns + c for c in ("Threshold", group_label) if c in data.columns ] else: if "chosen_cutoff" in data.columns: data = data.drop("chosen_cutoff") sort_columns = context_sort + [ - c for c in ("ppcr", "Model") if c in data.columns + c for c in ("ppcr", group_label) if c in data.columns ] if sort_columns: data = data.sort(sort_columns) @@ -150,16 +156,16 @@ def render_performance_table_reactable( data.get_column("net_benefit").drop_nulls().abs().max() or 1.0, ) - models = ( - data.get_column("Model").unique(maintain_order=True).to_list() - if "Model" in data.columns + groups = ( + data.get_column(group_label).unique(maintain_order=True).to_list() + if group_label in data.columns else [] ) colors = { - model: color_values[i % len(color_values)] for i, model in enumerate(models) + group: color_values[i % len(color_values)] for i, group in enumerate(groups) } - def model_cell(info: CellInfo): + def group_cell(info: CellInfo): value = info.value color = colors.get(value, "#aaa") return html.span( @@ -239,7 +245,7 @@ def matrix_style(colors: tuple[str, str, str]): ) return html.div(nested.to_widget(), style="padding:16px;") - columns = [Column(id="Model", cell=model_cell, min_width=120)] + columns = [Column(id=group_label, cell=group_cell, min_width=120)] if "Time" in data.columns: columns.append( Column( diff --git a/tests/test_performance_table.py b/tests/test_performance_table.py index 5bcfc4ff..8b329ed1 100644 --- a/tests/test_performance_table.py +++ b/tests/test_performance_table.py @@ -3,6 +3,7 @@ from great_tables import GT from reactable import Reactable +import rtichoke.performance_table as performance_table_module from rtichoke.performance_table_reactable import _bar_style, _net_benefit_style from rtichoke import ( @@ -27,6 +28,14 @@ def _time_example(): return probs, reals, times +def _capture_table_data(monkeypatch): + monkeypatch.setattr( + performance_table_module, + "render_performance_table", + lambda performance_data, **kwargs: performance_data, + ) + + def test_create_performance_table_defaults_to_great_tables(): probs, reals = _example() assert isinstance(create_performance_table(probs, reals, by=0.1), GT) @@ -60,6 +69,35 @@ def test_create_performance_table_supports_ppcr_stratification(): ) +def test_create_performance_table_propagates_model_role(monkeypatch): + _capture_table_data(monkeypatch) + probs = { + "Model A": np.array([0.1, 0.2, 0.8, 0.9]), + "Model B": np.array([0.2, 0.3, 0.7, 0.8]), + } + reals = np.array([0, 0, 1, 1]) + + data = performance_table_module.create_performance_table(probs, reals, by=0.25) + + assert data.get_column("__group_role").unique().to_list() == ["model"] + + +def test_create_performance_table_propagates_population_role(monkeypatch): + _capture_table_data(monkeypatch) + probs = { + "Population A": np.array([0.1, 0.2, 0.8, 0.9]), + "Population B": np.array([0.2, 0.3, 0.7, 0.8]), + } + reals = { + "Population A": np.array([0, 0, 1, 1]), + "Population B": np.array([0, 1, 0, 1]), + } + + data = performance_table_module.create_performance_table(probs, reals, by=0.25) + + assert data.get_column("__group_role").unique().to_list() == ["population"] + + def test_create_performance_table_times_defaults_to_great_tables(): probs, reals, times = _time_example() assert isinstance( @@ -80,6 +118,65 @@ def test_create_performance_table_times_supports_reactable(): ) +def test_create_performance_table_times_propagates_model_role(monkeypatch): + _capture_table_data(monkeypatch) + probs = { + "Model A": np.array([0.1, 0.2, 0.8, 0.9]), + "Model B": np.array([0.2, 0.3, 0.7, 0.8]), + } + reals = np.array([0, 0, 1, 1]) + times = np.array([2.0, 6.0, 3.0, 7.0]) + + data = performance_table_module.create_performance_table_times( + probs, + reals, + times, + fixed_time_horizons=[5], + by=0.25, + ) + + assert data.get_column("__group_role").unique().to_list() == ["model"] + + +def test_create_performance_table_times_propagates_population_role(monkeypatch): + _capture_table_data(monkeypatch) + probs = { + "Population A": np.array([0.1, 0.2, 0.8, 0.9]), + "Population B": np.array([0.2, 0.3, 0.7, 0.8]), + } + reals = { + "Population A": np.array([0, 0, 1, 1]), + "Population B": np.array([0, 1, 0, 1]), + } + times = { + "Population A": np.array([2.0, 6.0, 3.0, 7.0]), + "Population B": np.array([1.0, 4.0, 6.0, 8.0]), + } + + data = performance_table_module.create_performance_table_times( + probs, + reals, + times, + fixed_time_horizons=[5], + by=0.25, + ) + + assert data.get_column("__group_role").unique().to_list() == ["population"] + + +def test_prepare_performance_data_times_schema_has_no_transient_group_role(): + probs, reals, times = _time_example() + data = prepare_performance_data_times( + probs, + reals, + times.astype(float), + fixed_time_horizons=[5], + by=0.1, + ) + + assert "__group_role" not in data.columns + + def test_render_performance_table_preserves_multiple_time_horizons(): probs, reals, times = _time_example() data = prepare_performance_data_times(