diff --git a/src/rtichoke/_viz_spec_v2.py b/src/rtichoke/_viz_spec_v2.py index fc32cec5..db8b2de5 100644 --- a/src/rtichoke/_viz_spec_v2.py +++ b/src/rtichoke/_viz_spec_v2.py @@ -89,6 +89,139 @@ def _precision_recall_v2_spec_from_performance_data( return spec +def _precision_recall_times_v2_spec_from_performance_data( + performance_data: pl.DataFrame, + evaluation_metadata: Mapping[str, _EvaluationMetadata], +) -> dict[str, object]: + """Build canonical time-dependent precision-recall from calculated production data.""" + required = _REQUIRED_PRECISION_RECALL_COLUMNS | { + "fixed_time_horizon", + "censoring_heuristic", + "competing_heuristic", + } + missing = required.difference(performance_data.columns) + if missing: + raise ValueError( + "Time-dependent precision-recall performance data is missing columns: " + + ", ".join(sorted(missing)) + ) + + finite_rows = performance_data.filter( + pl.col("chosen_cutoff").is_finite() + & pl.col("sensitivity").is_finite() + & pl.col("ppv").is_finite() + ) + + rows = finite_rows.select( + "reference_group", + "fixed_time_horizon", + "censoring_heuristic", + "competing_heuristic", + "chosen_cutoff", + "sensitivity", + "ppv", + ).to_dicts() + row_groups = {str(row["reference_group"]) for row in rows} + missing_metadata = row_groups.difference(evaluation_metadata) + if missing_metadata: + raise ValueError( + "Time-dependent precision-recall rows are missing evaluation metadata: " + + ", ".join(sorted(missing_metadata)) + ) + + ordered_groups = [group for group in evaluation_metadata if group in row_groups] + evaluation_ids = { + group: f"evaluation-{index}" + for index, group in enumerate(ordered_groups, start=1) + } + evaluations = [] + for group in ordered_groups: + metadata = evaluation_metadata[group] + evaluation: dict[str, object] = { + "id": evaluation_ids[group], + "population": metadata.population, + } + if metadata.model is not None: + evaluation["model"] = metadata.model + evaluations.append(evaluation) + + series_keys = list( + dict.fromkeys( + ( + str(row["reference_group"]), + float(row["fixed_time_horizon"]), + str(row["censoring_heuristic"]), + str(row["competing_heuristic"]), + ) + for row in rows + ) + ) + series_ids = { + key: f"series-{index}" for index, key in enumerate(series_keys, start=1) + } + series = [] + for key in series_keys: + group, horizon, _, _ = key + metadata = evaluation_metadata[group] + display_value = metadata.model or metadata.population + series.append( + { + "id": series_ids[key], + "evaluationId": evaluation_ids[group], + "horizon": horizon, + "display": { + "label": display_value, + "group": display_value, + "role": "model" if metadata.model is not None else "population", + }, + } + ) + + data = [] + for row in rows: + key = ( + str(row["reference_group"]), + float(row["fixed_time_horizon"]), + str(row["censoring_heuristic"]), + str(row["competing_heuristic"]), + ) + data.append( + { + "seriesId": series_ids[key], + "cutoff": row["chosen_cutoff"], + "sensitivity": row["sensitivity"], + "ppv": row["ppv"], + } + ) + + risks = _gains_population_horizon_risk(performance_data, evaluation_metadata) + references = [] + for (population, horizon), risk in risks.items(): + references.append( + { + "type": "horizontal", + "scope": "population_horizon", + "population": population, + "horizon": horizon, + "value": risk, + "label": "Prevalence", + } + ) + + return { + "schemaVersion": "2.0", + "type": "precision_recall", + "evaluations": evaluations, + "series": series, + "data": data, + "x": "sensitivity", + "y": "ppv", + "xAxis": {"label": "Sensitivity", "domain": [0, 1]}, + "yAxis": {"label": "Positive Predictive Value", "domain": [0, 1]}, + "references": references, + } + + def _gains_v2_spec_from_performance_data( performance_data: pl.DataFrame, evaluation_metadata: Mapping[str, _EvaluationMetadata], diff --git a/src/rtichoke/discrimination/precision_recall.py b/src/rtichoke/discrimination/precision_recall.py index fa533828..1183e308 100644 --- a/src/rtichoke/discrimination/precision_recall.py +++ b/src/rtichoke/discrimination/precision_recall.py @@ -3,7 +3,6 @@ """ from typing import Any, Dict, List, Sequence, Union -from plotly.graph_objs._figure import Figure from rtichoke.processing.binary_color_values import _apply_color_values_binary from rtichoke.processing.plotly_helper_functions import ( _create_rtichoke_plotly_curve_binary, @@ -16,8 +15,14 @@ import polars as pl from rtichoke._renderers import RtichokeBrowserChart, _validate_renderer -from rtichoke._viz_spec_v2 import _precision_recall_v2_spec_from_performance_data +from rtichoke._viz_spec_v2 import ( + _precision_recall_times_v2_spec_from_performance_data, + _precision_recall_v2_spec_from_performance_data, +) from rtichoke.performance_data.performance_data import prepare_performance_data +from rtichoke.performance_data.performance_data_times import ( + prepare_performance_data_times, +) from rtichoke.processing.evaluation_semantics import ( _EvaluationMetadata, _build_evaluation_metadata, @@ -241,7 +246,8 @@ def create_precision_recall_curve_times( "#D1603D", "#585123", ], -) -> Figure: + renderer: str = "plotly", +) -> Any: """Creates a time-dependent Precision-Recall curve. Generates a Precision-Recall curve for time-to-event models, evaluating @@ -268,12 +274,37 @@ def create_precision_recall_curve_times( The width and height of the plot in pixels. Defaults to 600. color_values : List[str], optional A list of hex color strings for the plot lines. + renderer : {"plotly", "browser", "rtichoke_viz"}, optional + Rendering backend. ``"plotly"`` remains the default. ``"browser"`` and + its ``"rtichoke_viz"`` alias return a canonical offline browser chart. Returns ------- - Figure - A Plotly ``Figure`` object for the time-dependent Precision-Recall curve. + Figure or RtichokeBrowserChart + A Plotly ``Figure`` or canonical offline browser chart depending on ``renderer``. """ + selected_renderer = _validate_renderer(renderer) + if selected_renderer != "plotly": + if selected_renderer == "matplotlib": + raise ValueError( + "Precision-Recall supports 'plotly', 'browser', and 'rtichoke_viz' " + "renderers." + ) + performance_data = prepare_performance_data_times( + probs, + reals, + times, + fixed_time_horizons=fixed_time_horizons, + heuristics_sets=heuristics_sets, + by=by, + stratified_by=stratified_by, + ) + evaluation_metadata = _build_evaluation_metadata(probs, reals, times) + spec = _precision_recall_times_v2_spec_from_performance_data( + performance_data, + evaluation_metadata, + ) + return RtichokeBrowserChart(spec=spec, size=size) fig = _create_rtichoke_plotly_curve_times_reference_safe( probs, diff --git a/tests/test_precision_recall_v2.py b/tests/test_precision_recall_v2.py index 90fb0c66..9ec8cb75 100644 --- a/tests/test_precision_recall_v2.py +++ b/tests/test_precision_recall_v2.py @@ -269,9 +269,10 @@ def test_default_and_explicit_plotly_behavior_are_unchanged(): assert pio.to_json(default_plot) == pio.to_json(explicit_plot) -def test_time_dependent_precision_recall_api_does_not_gain_renderer_selection(): +def test_time_dependent_precision_recall_api_has_renderer_selection(): parameters = inspect.signature(create_precision_recall_curve_times).parameters - assert "renderer" not in parameters + assert "renderer" in parameters + assert parameters["renderer"].default == "plotly" def test_vendored_v050_contains_static_precision_recall_contract_and_export(): diff --git a/tests/test_time_precision_recall_v2.py b/tests/test_time_precision_recall_v2.py new file mode 100644 index 00000000..73e485f3 --- /dev/null +++ b/tests/test_time_precision_recall_v2.py @@ -0,0 +1,290 @@ +import shutil +import subprocess +from contextlib import contextmanager +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from threading import Thread +from typing import Any, Iterator, cast + +import numpy as np +import plotly.graph_objects as go +import pytest + +from rtichoke import create_precision_recall_curve_times +from rtichoke._renderers import RtichokeBrowserChart +from rtichoke._viz_spec_v2 import ( + _precision_recall_times_v2_spec_from_performance_data, +) +from rtichoke.performance_data.performance_data_times import ( + prepare_performance_data_times, +) +from rtichoke.processing.evaluation_semantics import ( + _SHARED_POPULATION, + _build_evaluation_metadata, +) + +HORIZONS = [5.0, 10.0] +HEURISTICS = [ + { + "censoring_heuristic": "adjusted", + "competing_heuristic": "adjusted_as_negative", + } +] + + +def _shared_inputs(): + return ( + { + "Model A": np.array([0.05, 0.15, 0.35, 0.55, 0.75, 0.95]), + "Model B": np.array([0.10, 0.25, 0.45, 0.65, 0.80, 0.90]), + }, + np.array([1, 0, 1, 0, 1, 0]), + np.array([3.0, 12.0, 8.0, 13.0, 14.0, 15.0]), + ) + + +def _spec(probs, reals, times): + performance = prepare_performance_data_times( + probs, + reals, + times, + fixed_time_horizons=HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + ) + return _precision_recall_times_v2_spec_from_performance_data( + performance, _build_evaluation_metadata(probs, reals, times) + ) + + +def test_time_precision_recall_uses_one_evaluation_and_series_per_model_horizon(): + probs, reals, times = _shared_inputs() + spec = _spec(probs, reals, times) + + assert spec["schemaVersion"] == "2.0" + assert spec["type"] == "precision_recall" + assert spec["x"] == "sensitivity" + assert spec["y"] == "ppv" + assert len(spec["evaluations"]) == 2 + assert {evaluation["population"] for evaluation in spec["evaluations"]} == { + _SHARED_POPULATION + } + assert len(spec["series"]) == 4 + assert { + (series["display"]["group"], series["horizon"]) for series in spec["series"] + } == { + ("Model A", 5.0), + ("Model A", 10.0), + ("Model B", 5.0), + ("Model B", 10.0), + } + + # Prevalence references (scope = population_horizon) + references = spec["references"] + assert len(references) == 2 + assert { + (reference["population"], reference["horizon"]) for reference in references + } == { + (_SHARED_POPULATION, 5.0), + (_SHARED_POPULATION, 10.0), + } + + +def test_equal_risk_population_horizons_remain_distinct_pr_reference_owners(): + probs = { + "Population A": np.array([0.05, 0.2, 0.7, 0.95]), + "Population B": np.array([0.1, 0.4, 0.6, 0.9]), + } + reals = { + "Population A": np.array([1, 0, 0, 0]), + "Population B": np.array([1, 0, 0, 0]), + } + times = { + "Population A": np.array([3.0, 12.0, 13.0, 14.0]), + "Population B": np.array([3.0, 12.0, 13.0, 14.0]), + } + references = _spec(probs, reals, times)["references"] + + assert len(references) == 4 + by_owner = { + (reference["population"], reference["horizon"]): reference["value"] + for reference in references + } + assert len(by_owner) == 4 + assert by_owner[("Population A", 5.0)] == by_owner[("Population B", 5.0)] + + +def test_time_precision_recall_censoring_and_competing_risk_reference_comes_from_performance_layer(): + probs = {"Model A": np.array([0.05, 0.2, 0.4, 0.6, 0.8, 0.95])} + reals = np.array([1, 0, 2, 1, 0, 2]) + times = np.array([2.0, 3.0, 4.0, 8.0, 12.0, 14.0]) + performance = prepare_performance_data_times( + probs, + reals, + times, + fixed_time_horizons=HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + ) + spec = _precision_recall_times_v2_spec_from_performance_data( + performance, _build_evaluation_metadata(probs, reals, times) + ) + references = cast(list[dict[str, Any]], spec["references"]) + calculated_risks = { + float(row["fixed_time_horizon"]): float(row["real_positives"] / row["n"]) + for row in performance.filter(performance["chosen_cutoff"] == 0) + .select("fixed_time_horizon", "real_positives", "n") + .unique() + .to_dicts() + } + + for reference in references: + horizon = reference["horizon"] + risk = calculated_risks[horizon] + assert reference["value"] == risk + assert reference["scope"] == "population_horizon" + assert reference["type"] == "horizontal" + + +def test_time_precision_recall_renderers_preserve_plotly_default_and_browser( + tmp_path: Path, +): + probs, reals, times = _shared_inputs() + default = create_precision_recall_curve_times( + probs, reals, times, HORIZONS, heuristics_sets=HEURISTICS, by=0.25 + ) + assert isinstance(default, go.Figure) + + browser = create_precision_recall_curve_times( + probs, + reals, + times, + HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + renderer="browser", + ) + assert isinstance(browser, RtichokeBrowserChart) + html = browser.write_html(tmp_path / "pr-times.html").read_text(encoding="utf-8") + assert "renderPrecisionRecallV2" in html + assert {series["horizon"] for series in browser.spec["series"]} == set(HORIZONS) + + rtichoke_viz_browser = create_precision_recall_curve_times( + probs, + reals, + times, + HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + renderer="rtichoke_viz", + ) + assert isinstance(rtichoke_viz_browser, RtichokeBrowserChart) + + with pytest.raises(ValueError, match="Precision-Recall supports"): + create_precision_recall_curve_times( + probs, + reals, + times, + HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + renderer="matplotlib", + ) + + +def _chrome_executable() -> str: + for candidate in ( + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + ): + executable = shutil.which(candidate) + if executable is not None: + return executable + pytest.skip("headless Chrome/Chromium is not available") + raise RuntimeError("unreachable") + + +@contextmanager +def _serve(directory: Path) -> Iterator[str]: + handler = partial(SimpleHTTPRequestHandler, directory=str(directory)) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + thread.join() + server.server_close() + + +def test_time_precision_recall_browser_chart_executes_and_switches_horizons_in_chrome( + tmp_path: Path, +): + probs, reals, times = _shared_inputs() + chart = create_precision_recall_curve_times( + probs, + reals, + times, + HORIZONS, + heuristics_sets=HEURISTICS, + by=0.25, + renderer="browser", + ) + chart.write_html(tmp_path / "precision-recall-times.html") + + script_path = tmp_path / "test_switch.js" + script_path.write_text( + """ + import { renderPrecisionRecallV2 } from "./rtichoke-viz.js"; + const spec = JSON.parse(document.querySelector("#rtichoke-spec").textContent); + const chart = renderPrecisionRecallV2(spec, { width: 600, height: 600 }); + document.querySelector("#rtichoke-chart").replaceChildren(chart); + + const select = chart.querySelector("select"); + if (!select) { + console.error("Horizon selector missing!"); + } else { + console.log("INITIAL_HORIZON:" + select.value); + select.value = "10"; + select.dispatchEvent(new Event("change", { bubbles: true })); + console.log("SWITCHED_HORIZON:" + select.value); + } + """, + encoding="utf-8", + ) + + html_file = tmp_path / "precision-recall-times.html" + content = html_file.read_text(encoding="utf-8") + content = content.replace( + "", + '', + ) + html_file.write_text(content, encoding="utf-8") + + with _serve(tmp_path) as base_url: + result = subprocess.run( + [ + _chrome_executable(), + "--headless=new", + "--no-sandbox", + "--disable-gpu", + "--enable-logging=stderr", + "--log-level=0", + "--dump-dom", + f"{base_url}/precision-recall-times.html", + ], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode == 0, result.stderr + assert "INITIAL_HORIZON:5" in result.stderr, result.stderr + assert "SWITCHED_HORIZON:10" in result.stderr, result.stderr + assert "